Public: anyone can read this; the crew can change it.

The palette with nothing typed shows what you used last #10674

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

torque/forge:torque/10658-the-palette-with-nothing-typed-shows-wha → main 3 commits fork 3 threads · 3 unresolved

  • Merged. torque/10658-the-palette-with-nothing-typed-shows-wha was merged into main as 0d96924.

Review

Sign in to review this change.
4 files changed +159 −15
docs/features/palette-recent.md added +11
@@ -0,0 +1,11 @@
1 # The empty palette lists what you opened last
2
3 Open the command palette (Cmd/Ctrl K, or the phone search sheet) and type nothing: the first group, "Recent", lists the rows you opened before, ordered by frecency, then the usual rows follow.
4
5 - **Frecency:** each opening counts one, halved for every week since the last. A row opened often and a row opened just now both lead a row opened once long ago (`frecencyOrder`, `src/web/palette.ts`).
6 - **Local:** the list lives in this browser's `localStorage` (`forge.palette.recent`). Nothing is sent to the server, and clearing site data clears it.
7 - **Limits:** 50 rows are kept (the least useful falls off when a 51st is opened); 8 are shown while the box is empty. Typing searches everything as before.
8 - **Older lists:** entries saved before counts existed read as one opening.
9 - **Terminal:** the terminal's palette (`src/tui/`) has its own ranking and no browser storage; it is out of scope here.
10
11 Recording and reading are the pure `recordUse` and `readUses`; they ride in the shell script with the other palette helpers. Tests: `test/palette.test.ts`.
src/web/client.ts +6 −14
@@ -1661,7 +1661,8 @@ const shellCore = /* js */ `
16611661 // first while the box is empty
16621662 ${paletteHelpersJs}
16631663 var RECENT_KEY = 'forge.palette.recent';
1664 var RECENT_MAX = 8;
1664 var RECENT_MAX = 50; // entries kept
1665 var RECENT_SHOWN = 8; // entries listed while the box is empty
16651666 var index = null;
16661667 function loadIndex() {
16671668 if (index) return index;
@@ -1672,21 +1673,12 @@ const shellCore = /* js */ `
16721673 // Storage may be absent, full or refused (a private window): a palette
16731674 // with no memory is still a palette.
16741675 function readRecent() {
1675 try {
1676 var raw = JSON.parse(window.localStorage.getItem(RECENT_KEY) || '[]');
1677 if (!Array.isArray(raw)) return [];
1678 return raw.filter(function (r) {
1679 return r && typeof r.t === 'string' && typeof r.h === 'string' && r.h.charAt(0) === '/';
1680 }).slice(0, RECENT_MAX).map(function (r) {
1681 return { t: r.t, h: r.h, g: typeof r.g === 'string' ? r.g : '' };
1682 });
1683 } catch (_) { return []; }
1676 try { return readUses(JSON.parse(window.localStorage.getItem(RECENT_KEY) || '[]'), RECENT_MAX); } catch (_) { return []; }
16841677 }
16851678 function remember(r) {
16861679 try {
1687 var rest = readRecent().filter(function (x) { return x.h !== r.h; });
1688 var next = [{ t: r.t, h: r.h, g: r.g === 'Recent' ? (r.d || '') : r.g }].concat(rest);
1689 window.localStorage.setItem(RECENT_KEY, JSON.stringify(next.slice(0, RECENT_MAX)));
1680 var next = recordUse(readRecent(), { t: r.t, h: r.h, g: r.g === 'Recent' ? (r.d || '') : r.g }, Date.now(), RECENT_MAX);
1681 window.localStorage.setItem(RECENT_KEY, JSON.stringify(next));
outdated

Unused i parameter. Drop it.

16901682 } catch (_) {}
16911683 }
16921684 // What the server found, as rows. The links are built here from the
@@ -1914,7 +1906,7 @@ const shellCore = /* js */ `
19141906 return r;
19151907 });
19161908 var tellApp = (loadIndex().tell || {}).app;
1917 var recent = readRecent().map(function (r) { return { t: r.t, h: r.h, g: 'Recent', d: r.g }; });
1909 var recent = frecencyOrder(readRecent(), Date.now()).slice(0, RECENT_SHOWN).map(function (r) { return { t: r.t, h: r.h, g: 'Recent', d: r.g }; });
19181910 var recentHrefs = recent.map(function (r) { return r.h; });
19191911 var served = []; // rows the server found, for the query servedFor
19201912 var servedFor = '';
src/web/palette.ts +66 −1
@@ -143,9 +143,74 @@ export function opensAsSheet(marked: boolean, width: number): boolean {
143143 return marked && width > 0 && width < 720;
144144 }
145145
146 /** A row opened before: where, how it was labelled, how often, and when last. */
147 export interface PaletteUse {
148 readonly t: string;
149 readonly h: string;
150 readonly g: string;
151 /** Times opened. */
152 readonly c: number;
153 /** Milliseconds since the epoch of the last opening. */
154 readonly l: number;
155 }
156
157 /**
158 * Remembered rows in frecency order, best first: each opening is worth one,
159 * halved for every week since the last, so a row opened often and a row
160 * opened just now both lead a row opened once long ago. (Mozilla-style
161 * frecency, reduced to count × 2^(-weeks); the decay is applied to the whole
162 * count, an approximation that needs no per-visit history.) Ties go to the
163 * later opening. Returns a new array; `now` is passed in so the function is
164 * pure. Self-contained, shipped in the script.
165 */
166 export function frecencyOrder<T extends { c: number; l: number }>(uses: readonly T[], now: number): T[] {
167 const week = 7 * 24 * 3600 * 1000;
168 const score = (u: T) => u.c * Math.pow(0.5, Math.max(0, now - u.l) / week);
169 return uses.slice().sort((a, b) => score(b) - score(a) || b.l - a.l);
170 }
171
172 /**
173 * What the browser's stored list means: anything that is not an array of
174 * `{t, h}` with a same-origin `h` is dropped; a missing or bad count is one
175 * opening, a missing time is the epoch (oldest). Cut to `max`. Self-contained.
176 */
177 export function readUses(raw: unknown, max: number): PaletteUse[] {
178 if (!Array.isArray(raw)) return [];
179 const out: PaletteUse[] = [];
180 for (const r of raw) {
181 if (out.length >= max) break;
182 if (!r || typeof r.t !== "string" || typeof r.h !== "string" || r.h.charAt(0) !== "/") continue;
183 out.push({
184 t: r.t,
185 h: r.h,
186 g: typeof r.g === "string" ? r.g : "",
187 c: typeof r.c === "number" && r.c >= 1 ? Math.min(r.c, 1e6) : 1,
188 l: typeof r.l === "number" && r.l > 0 ? r.l : 1,
189 });
190 }
191 return out;
192 }
193
194 /**
195 * The list after opening `row` at `now`: its count is one more than before
196 * (one if new), its time is now, and the whole list is re-ordered by
197 * frecency and cut to `max` — so the least useful entry is the one that
198 * falls off. Pure; self-contained apart from `frecencyOrder`.
199 */
200 export function recordUse(
201 uses: readonly PaletteUse[],
202 row: { t: string; h: string; g: string },
203 now: number,
204 max: number,
205 ): PaletteUse[] {
206 const prev = uses.find((x) => x.h === row.h);
207 const entry: PaletteUse = { t: row.t, h: row.h, g: row.g, c: (prev ? prev.c : 0) + 1, l: now };
208 return frecencyOrder([entry].concat(uses.filter((x) => x.h !== row.h)), now).slice(0, max);
209 }
210
146211 /**
147212 * The functions above as the JavaScript the page runs: Bun transpiles
148213 * this module before it ever parses it, so `toString` is already the
149214 * browser's dialect. A test proves the string and the module agree.
150215 */
151 export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}\n${opensAsSheet.toString()}`;
216 export const paletteHelpersJs = `${fuzzyScore.toString()}\n${rankPalette.toString()}\n${opensAsSheet.toString()}\n${frecencyOrder.toString()}\n${readUses.toString()}\n${recordUse.toString()}`;
test/palette.test.ts +76
@@ -1,9 +1,13 @@
11 import { describe, expect, test } from "bun:test";
22 import { shellJs } from "../src/web/client.ts";
33 import {
4 frecencyOrder,
45 fuzzyScore,
56 paletteHelpersJs,
67 rankPalette,
8 readUses,
9 recordUse,
10 type PaletteUse,
711 type PaletteGroup,
812 type PaletteRow,
913 } from "../src/web/palette.ts";
@@ -175,3 +179,75 @@ describe("the page runs the same functions", () => {
175179 expect(shellJs).not.toContain("${");
176180 });
177181 });
182
183 describe("frecency", () => {
184 const now = Date.UTC(2026, 10, 10);
185 const day = 24 * 3600 * 1000;
186 const u = (h: string, c: number, ago: number) => ({ t: h, h, g: "", c, l: now - ago });
187
188 test("recent and frequent both beat old and rare", () => {
189 const order = frecencyOrder([u("/old", 1, 60 * day), u("/often", 6, 7 * day), u("/now", 1, 0)], now).map((x) => x.h);
190 // /often: 6 × 0.5 = 3; /now: 1; /old: ~0.003
191 expect(order).toEqual(["/often", "/now", "/old"]);
192 });
193
194 test("a hand calculation: 4 openings two weeks ago equal one opening now", () => {
195 const order = frecencyOrder([u("/a", 4, 14 * day), u("/b", 1, 1 * day)], now).map((x) => x.h);
196 // /a: 4 × 0.25 = 1; /b: 2^(-1/7) ≈ 0.906
197 expect(order).toEqual(["/a", "/b"]);
198 });
199
200 test("ties go to the later opening; the input is not mutated", () => {
201 const input = [u("/x", 1, 0), u("/y", 1, 0)].map((e, i) => ({ ...e, l: now - i }));
202 const out = frecencyOrder(input, now);
203 expect(out.map((x) => x.h)).toEqual(["/x", "/y"]);
204 expect(out).not.toBe(input);
205 });
outdated

This string-contains check passes without any working behaviour. Replace it with a test that runs remember() and the empty-query list.

206
207 test("the helpers ride in the shell script", () => {
208 for (const f of ["frecencyOrder", "readUses", "recordUse"]) expect(paletteHelpersJs).toContain(`function ${f}`);
209 });
210 });
211
212 describe("recording uses", () => {
213 const row = (h: string) => ({ t: h, h, g: "Go to" });
214
215 test("opening a row again raises its count and moves its time", () => {
216 const a = recordUse([], row("/a"), 1000, 50);
217 const b = recordUse(a, row("/a"), 2000, 50);
218 expect(b).toEqual([{ t: "/a", h: "/a", g: "Go to", c: 2, l: 2000 }]);
219 });
220
221 test("at most 50 are kept, and the least useful one falls off", () => {
222 let uses: PaletteUse[] = [];
223 for (let i = 0; i < 60; i++) uses = recordUse(uses, row("/r" + i), 1000 + i, 50);
224 expect(uses.length).toBe(50);
225 expect(uses[0]!.h).toBe("/r59");
226 expect(uses.some((u) => u.h === "/r0")).toBe(false);
227 });
228
229 test("a often-opened old row survives the cap over once-opened newer ones", () => {
230 let uses = recordUse([], row("/keep"), 1000, 3);
231 uses = recordUse(uses, row("/keep"), 1001, 3);
232 uses = recordUse(uses, row("/keep"), 1002, 3);
233 for (let i = 0; i < 5; i++) uses = recordUse(uses, row("/n" + i), 2000 + i, 3);
234 expect(uses.map((u) => u.h)).toContain("/keep");
235 });
236
237 test("legacy entries (no count or time) read as one opening; junk and off-origin links drop", () => {
238 const raw = [{ t: "A", h: "/a", g: "x" }, { t: "B", h: "https://evil.example/" }, { h: "/c" }, null, "s", { t: "D", h: "/d", c: -4, l: "no" }];
239 expect(readUses(raw, 50)).toEqual([
240 { t: "A", h: "/a", g: "x", c: 1, l: 1 },
241 { t: "D", h: "/d", g: "", c: 1, l: 1 },
242 ]);
243 expect(readUses("nope", 50)).toEqual([]);
244 expect(readUses(raw, 1).length).toBe(1);
245 });
246
247 test("the shell script remembers through the helpers, and lists 8", () => {
248 expect(shellJs).toContain("recordUse(readRecent()");
249 expect(shellJs).toContain("frecencyOrder(readRecent(), Date.now()).slice(0, RECENT_SHOWN)");
250 expect(shellJs).toContain("RECENT_MAX = 50");
251 expect(shellJs).toContain("RECENT_SHOWN = 8");
252 });
253 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs