Public: anyone can read this; the crew can change it.
Sentinel: shared:01m23vygyffx5hg2f5vm8r74sr:11t6cevjos is past its budget: trevato/forge: "egress grants in the terminal > a non-admin is not offered it, and the key does nothing" failed on 16 p… #8120
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8094-sentinel-shared-01m23vygyffx5hg2f5vm8r74 → main 3 commits fork
4 files
changed +71
−2
src/tui/app/app.ts +13
| @@ -338,6 +338,15 @@ export class App { | ||
| 338 | 338 | return !this.dirty && !this.busy; |
| 339 | 339 | } |
| 340 | 340 | |
| 341 | /** | |
| 342 | * No palette search waiting on its debounce or in flight. `settled` does | |
| 343 | * not count it, so typing in the palette stays snappy under a harness; a | |
| 344 | * test that acts on the results waits on this, not on a sleep. | |
| 345 | */ | |
| 346 | get paletteSettled(): boolean { | |
| 347 | return this.paletteTimers.length === 0; | |
| 348 | } | |
| 349 | ||
| 341 | 350 | /** Frames actually painted. A harness watches it stop changing. */ |
| 342 | 351 | get frameCount(): number { |
| 343 | 352 | return this.frames; |
| @@ -845,6 +854,10 @@ export class App { | ||
| 845 | 854 | }) |
| 846 | 855 | .catch(() => { |
| 847 | 856 | if (!abort.signal.aborted) this.palette.searching = false; |
| 857 | }) | |
| 858 | .finally(() => { | |
| 859 | // The live run is the last; once it lands this query has nothing left to do. | |
| 860 | if (live && this.paletteAbort === abort) this.paletteTimers = []; | |
| 848 | 861 | }); |
| 849 | 862 | }; |
| 850 | 863 | this.paletteTimers = [ |
test/egress-admin.test.ts +4 −2
| @@ -17,7 +17,7 @@ import { startEgress } from "../src/apps/egress.ts"; | ||
| 17 | 17 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 18 | 18 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 19 | 19 | import { quietLogs } from "./helpers/globals.ts"; |
| 20 | import { settleApp, settleUntil } from "./helpers/tui.ts"; | |
| 20 | import { settleApp, settlePalette, settleUntil } from "./helpers/tui.ts"; | |
| 21 | 21 | import { seedAdmin } from "./helpers/admin.ts"; |
| 22 | 22 | |
| 23 | 23 | quietLogs(); |
| @@ -76,7 +76,9 @@ describe("egress grants in the terminal", () => { | ||
| 76 | 76 | await settleApp(tui); |
| 77 | 77 | await press("/"); |
| 78 | 78 | for (const ch of "arm") await press(ch); |
| 79 | // The palette searches on a debounce no settle can see; enter on an empty list opens nothing. | |
| 79 | // The palette searches twice, at 120ms and live at 400ms, and redraws the list each time; | |
| 80 | // enter before the second can open a row the redraw moves. | |
| 81 | await settlePalette(tui); | |
| 80 | 82 | await settleUntil(tui, "ada/arm"); |
| 81 | 83 | await press("enter", "enter"); |
| 82 | 84 | // "ada/arm" is on screen from the palette and the dashboard before the repository opens; its one-line file is the sign it has. |
test/helpers/tui.ts +22
| @@ -102,3 +102,25 @@ export async function settleUntil( | ||
| 102 | 102 | await turn(); |
| 103 | 103 | } |
| 104 | 104 | } |
| 105 | ||
| 106 | /** | |
| 107 | * Settle once the palette's searches have all landed — the 120ms local one | |
| 108 | * and the 400ms live re-run that redraws the same list. Enter pressed before | |
| 109 | * the second can open a row the redraw then moves. | |
| 110 | */ | |
| 111 | export async function settlePalette( | |
| 112 | app: App, | |
| 113 | options: SettleOptions = {}, | |
| 114 | ): Promise<void> { | |
| 115 | const timeoutMs = options.timeoutMs ?? 20_000; | |
| 116 | const deadline = Date.now() + timeoutMs; | |
| 117 | while (!app.paletteSettled) { | |
| 118 | if (Date.now() > deadline) { | |
| 119 | throw new Error( | |
| 120 | `the palette's search never landed in ${timeoutMs}ms:\n${app.screen.toText()}`, | |
| 121 | ); | |
| 122 | } | |
| 123 | await turn(); | |
| 124 | } | |
| 125 | await settleApp(app, { timeoutMs: Math.max(1_000, deadline - Date.now()) }); | |
| 126 | } |
test/tui-app.test.ts +32
| @@ -140,3 +140,35 @@ describe("the app loop", () => { | ||
| 140 | 140 | app.stop(); |
| 141 | 141 | }); |
| 142 | 142 | }); |
| 143 | ||
| 144 | describe("the palette's search", () => { | |
| 145 | test("is not settled until its live re-run has landed", async () => { | |
| 146 | const asked: boolean[] = []; | |
| 147 | const app = new App({ | |
| 148 | terminal: headlessTerminal(80, 24), | |
| 149 | theme: "dark", | |
| 150 | root: () => ({ title: "Root", keys: [], render() {}, key: () => false }), | |
| 151 | search: () => async (_query, live) => { | |
| 152 | asked.push(live); | |
| 153 | return []; | |
| 154 | }, | |
| 155 | }); | |
| 156 | app.start(); | |
| 157 | try { | |
| 158 | expect(app.paletteSettled).toBe(true); | |
| 159 | const key = (k: string) => app.dispatch({ type: "key", key: k, ctrl: false, alt: false, shift: false }); | |
| 160 | key(":"); | |
| 161 | key("a"); | |
| 162 | expect(app.paletteSettled).toBe(false); | |
| 163 | await Bun.sleep(200); | |
| 164 | expect(asked).toEqual([false]); | |
| 165 | expect(app.paletteSettled).toBe(false); | |
| 166 | const deadline = Date.now() + 5_000; | |
| 167 | while (!app.paletteSettled && Date.now() < deadline) await Bun.sleep(10); | |
| 168 | expect(asked).toEqual([false, true]); | |
| 169 | expect(app.paletteSettled).toBe(true); | |
| 170 | } finally { | |
| 171 | app.stop(); | |
| 172 | } | |
| 173 | }); | |
| 174 | }); |
new above
offline