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

The terminal keeps up: the board redraws when someone else changes it #47

Merged opened this

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

Merged

scout/tempo:scout/17-the-terminal-keeps-up-the-board-redraws → main 4 commits fork 2 threads · 2 unresolved

  • Merged. scout/17-the-terminal-keeps-up-the-board-redraws was merged into main as b6cf7bd.

Review

Sign in to review this change.
6 files changed +358 −5
README.md +16 −3
@@ -128,9 +128,22 @@ background, reverting if it fails.
128128 board's `/b/<id>` address the way the picker does, so a board made with
129129 `n` — unowned, and so invisible to the picker even for the person who
130130 just made it — stays reachable afterwards.
131 - The event stream is this one process's: the terminal is a separate
132 process on the same database, so a change made there shows up on the
133 next page load, not live.
131 - The event bus (`src/bus.ts`) is this one process's in memory, and the
132 terminal is a separate process on a pty, so it can't hear it. Instead the
133 terminal follows the same board live by polling: `src/db.ts` keeps a
134 revision per board (and one for the boards list) bumped in the same
135 transaction as every write, and `src/tty.ts` checks it about twice a
136 second while idle, redrawing only when it moved — so someone else's
137 card move, edit, or new board shows up within half a second whether it
138 came from the page or another terminal. A redraw never disturbs what
139 you're doing: an open prompt keeps its text, the filter stays, and the
140 cursor rides the same card or column (found by id) rather than jumping
141 when the list changes under it. It's one-way: only `src/routes.ts` and
142 `src/mcp.ts` call `bus.publish`, and the terminal writes straight to
143 `src/db.ts`, so a change made *in* the terminal still doesn't reach an
144 open web page until its next load — the revision table makes that cheap
145 to close (the server could poll it and publish too), but that's outside
146 this issue.
134147
135148 ## Limits
136149
src/db.test.ts +77
@@ -203,6 +203,83 @@ test("every board returned by createBoard has exactly its three default columns"
203203 }
204204 });
205205
206 test("boardRevision moves after every kind of change to a board, and after undo, but never on a plain read", () => {
207 const { boardId, columnId } = setup();
208 const before = db.boardRevision(boardId);
209 expect(db.boardRevision(boardId)).toBe(before); // reading twice does not move it
210
211 const card = assertCard(db.createCard(boardId, columnId, "one"));
212 const afterCreateCard = db.boardRevision(boardId);
213 expect(afterCreateCard).toBeGreaterThan(before);
214
215 db.editCard(boardId, card.id, "one!");
216 const afterEdit = db.boardRevision(boardId);
217 expect(afterEdit).toBeGreaterThan(afterCreateCard);
218
219 const column2 = db.createColumn(boardId, "Next");
220 const afterCreateColumn = db.boardRevision(boardId);
221 expect(afterCreateColumn).toBeGreaterThan(afterEdit);
222 if (typeof column2 === "string") throw new Error("expected a Column");
223
224 db.moveCard(boardId, card.id, column2.id, 0);
225 const afterMoveCard = db.boardRevision(boardId);
226 expect(afterMoveCard).toBeGreaterThan(afterCreateColumn);
227
228 db.moveColumn(boardId, column2.id, "left");
229 const afterMoveColumn = db.boardRevision(boardId);
230 expect(afterMoveColumn).toBeGreaterThan(afterMoveCard);
231
232 db.renameColumn(boardId, column2.id, "Renamed");
233 const afterRenameColumn = db.boardRevision(boardId);
234 expect(afterRenameColumn).toBeGreaterThan(afterMoveColumn);
235
236 db.deleteColumn(boardId, columnId);
237 const afterDeleteColumn = db.boardRevision(boardId);
238 expect(afterDeleteColumn).toBeGreaterThan(afterRenameColumn);
239
240 db.deleteCard(boardId, card.id);
241 const afterDeleteCard = db.boardRevision(boardId);
242 expect(afterDeleteCard).toBeGreaterThan(afterDeleteColumn);
243
244 db.undo(boardId);
245 const afterUndo = db.boardRevision(boardId);
246 expect(afterUndo).toBeGreaterThan(afterDeleteCard);
247 });
248
249 test("boardRevision of a board that does not exist is 0 and stays put", () => {
250 expect(db.boardRevision("no-such-board")).toBe(0);
251 expect(db.boardRevision("no-such-board")).toBe(0);
252 });
253
254 test("boardsRevision moves when a board is created, renamed, or claimed, but a board's own revision does not move for the other two", () => {
255 const start = db.boardsRevision();
256
257 const board = db.createBoard("Original");
258 const afterCreate = db.boardsRevision();
259 expect(afterCreate).toBeGreaterThan(start);
260
261 const ownRevision = db.boardRevision(board.id);
262
263 db.renameBoard(board.id, "Renamed");
264 const afterRename = db.boardsRevision();
265 expect(afterRename).toBeGreaterThan(afterCreate);
266 expect(db.boardRevision(board.id)).toBeGreaterThan(ownRevision); // the board's own revision moves too
267
268 db.claimBoard(board.id, { id: "owner-1", name: "Owner" });
269 const afterClaim = db.boardsRevision();
270 expect(afterClaim).toBeGreaterThan(afterRename);
271 });
272
273 test("undo of a renamed board also moves boardsRevision, since the boards list shows the restored name", () => {
274 const board = db.createBoard("Original");
275 db.renameBoard(board.id, "Renamed");
276 const afterRename = db.boardsRevision();
277
278 db.undo(board.id);
279 expect(db.getBoard(board.id)!.name).toBe("Original");
280 expect(db.boardsRevision()).toBeGreaterThan(afterRename);
281 });
282
206283 test("undoing a rename restores the old name and leaves a card moved before it untouched; a second undo then reverses the move", () => {
207284 const { boardId, columnId } = setup();
208285 const columns = db.listColumns(boardId);
src/db.ts +49
@@ -51,6 +51,10 @@ db.exec(`
5151 CREATE INDEX IF NOT EXISTS card_column_idx ON card(column_id);
5252 CREATE INDEX IF NOT EXISTS column_board_idx ON board_column(board_id);
5353 CREATE INDEX IF NOT EXISTS action_board_idx ON action(board_id);
54 CREATE TABLE IF NOT EXISTS revision (
55 scope TEXT PRIMARY KEY,
56 value INTEGER NOT NULL DEFAULT 0
57 );
5458 `);
5559
5660 // A board created before ownership existed has no owner_id/owner_name
@@ -109,6 +113,36 @@ function logAction(boardId: string, payload: Payload): void {
109113 ).run(boardId, MAX_UNDO_DEPTH);
110114 }
111115
116 // The terminal (src/tty.ts) is a separate process on a pty; it cannot hear
117 // the web server's in-process event bus (src/live.ts), so it polls this
118 // instead. A scope's revision is a counter bumped in the same transaction
119 // as every write that changes what that scope shows, never derived from
120 // the action log — the log is trimmed and undone, so it is not a clock.
121 // "__boards__" is not a valid board id (those are 8 lowercase/digit
122 // characters from randomId), so it cannot collide with one.
123 const BOARDS_SCOPE = "__boards__";
124
125 function bumpRevision(scope: string): void {
126 db.query(
127 `INSERT INTO revision (scope, value) VALUES (?1, 1)
128 ON CONFLICT(scope) DO UPDATE SET value = value + 1`,
129 ).run(scope);
130 }
131
132 function readRevision(scope: string): number {
133 return db.query<{ value: number }, [string]>("SELECT value FROM revision WHERE scope = ?1").get(scope)?.value ?? 0;
134 }
135
136 /** Bumped by every change to this board's columns and cards, and by undo. */
137 export function boardRevision(boardId: string): number {
138 return readRevision(boardId);
139 }
140
141 /** Bumped when a board is created, renamed, or claimed (leaves the unowned list). */
142 export function boardsRevision(): number {
143 return readRevision(BOARDS_SCOPE);
144 }
145
112146 const BOARD_FIELDS = "id, name, owner_id, owner_name, created_at";
113147
114148 export function createBoard(name: string, owner?: { id: string; name: string }): Board {
@@ -122,6 +156,7 @@ export function createBoard(name: string, owner?: { id: string; name: string }):
122156 );
123157 const defaults = ["To do", "Doing", "Done"];
124158 defaults.forEach((columnName, i) => createColumn(id, columnName, i));
159 bumpRevision(BOARDS_SCOPE);
125160 return getBoard(id)!;
126161 }).immediate();
127162 }
@@ -149,6 +184,7 @@ export function claimBoard(boardId: string, owner: { id: string; name: string })
149184 if (board.owner_id === owner.id) return "already_yours";
150185 if (board.owner_id !== null) return "owned_by_other";
151186 db.query("UPDATE board SET owner_id = ?2, owner_name = ?3 WHERE id = ?1").run(boardId, owner.id, owner.name);
187 bumpRevision(BOARDS_SCOPE);
152188 return "claimed";
153189 }).immediate();
154190 }
@@ -159,6 +195,8 @@ export function renameBoard(boardId: string, name: string): boolean {
159195 if (!before) return false;
160196 db.query("UPDATE board SET name = ?2 WHERE id = ?1").run(boardId, name);
161197 logAction(boardId, { restoreBoardName: { id: boardId, name: before.name } });
198 bumpRevision(boardId);
199 bumpRevision(BOARDS_SCOPE);
162200 return true;
163201 }).immediate();
164202 }
@@ -205,6 +243,7 @@ export function createColumn(boardId: string, name: string, explicitPosition?: n
205243 position,
206244 );
207245 logAction(boardId, { deleteColumnIds: [id] });
246 bumpRevision(boardId);
208247 return { id, board_id: boardId, name, position };
209248 }).immediate();
210249 }
@@ -221,6 +260,7 @@ export function renameColumn(boardId: string, columnId: string, name: string): b
221260 if (!before) return false;
222261 db.query("UPDATE board_column SET name = ?2 WHERE id = ?1").run(columnId, name);
223262 logAction(boardId, { restoreColumns: [before] });
263 bumpRevision(boardId);
224264 return true;
225265 }).immediate();
226266 }
@@ -236,6 +276,7 @@ export function moveColumn(boardId: string, columnId: string, direction: "left"
236276 db.query("UPDATE board_column SET position = ?2 WHERE id = ?1").run(columns[i].id, columns[j].position);
237277 db.query("UPDATE board_column SET position = ?2 WHERE id = ?1").run(columns[j].id, columns[i].position);
238278 logAction(boardId, { restoreColumns: before });
279 bumpRevision(boardId);
239280 return true;
240281 }).immediate();
241282 }
@@ -247,6 +288,7 @@ export function deleteColumn(boardId: string, columnId: string): boolean {
247288 const cards = listCards(boardId).filter((c) => c.column_id === columnId);
248289 db.query("DELETE FROM board_column WHERE id = ?1").run(columnId);
249290 logAction(boardId, { restoreColumns: [before], restoreCards: cards });
291 bumpRevision(boardId);
250292 return true;
251293 }).immediate();
252294 }
@@ -266,6 +308,7 @@ export function createCard(boardId: string, columnId: string, title: string): Ca
266308 position,
267309 );
268310 logAction(boardId, { deleteCardIds: [id] });
311 bumpRevision(boardId);
269312 return getCard(boardId, id)!;
270313 }).immediate();
271314 }
@@ -285,6 +328,7 @@ export function editCard(boardId: string, cardId: string, title: string): boolea
285328 if (!before) return false;
286329 db.query("UPDATE card SET title = ?2, updated_at = datetime('now') WHERE id = ?1").run(cardId, title);
287330 logAction(boardId, { restoreCards: [before] });
331 bumpRevision(boardId);
288332 return true;
289333 }).immediate();
290334 }
@@ -314,6 +358,7 @@ export function moveCard(boardId: string, cardId: string, columnId: string, inde
314358 position,
315359 );
316360 logAction(boardId, { restoreCards: [before] });
361 bumpRevision(boardId);
317362 return true;
318363 }
319364
@@ -331,6 +376,7 @@ export function moveCard(boardId: string, cardId: string, columnId: string, inde
331376 at + 1,
332377 );
333378 logAction(boardId, { restoreCards });
379 bumpRevision(boardId);
334380 return true;
335381 }).immediate();
336382 }
@@ -341,6 +387,7 @@ export function deleteCard(boardId: string, cardId: string): boolean {
341387 if (!before) return false;
342388 db.query("DELETE FROM card WHERE id = ?1").run(cardId);
343389 logAction(boardId, { restoreCards: [before] });
390 bumpRevision(boardId);
344391 return true;
345392 }).immediate();
346393 }
@@ -364,8 +411,10 @@ export function undo(boardId: string): boolean {
364411 for (const id of payload.deleteCardIds ?? []) db.query("DELETE FROM card WHERE id = ?1").run(id);
365412 if (payload.restoreBoardName) {
366413 db.query("UPDATE board SET name = ?2 WHERE id = ?1").run(payload.restoreBoardName.id, payload.restoreBoardName.name);
414 bumpRevision(BOARDS_SCOPE); // the name undo restored is what the boards list shows too
367415 }
368416 db.query("DELETE FROM action WHERE id = ?1").run(row.id);
417 bumpRevision(boardId);
369418 return true;
370419 }).immediate();
371420 }
src/keys.ts +54
@@ -31,6 +31,8 @@ export type BoardStore = {
3131 deleteCard(boardId: string, cardId: string): boolean;
3232 moveCard(boardId: string, cardId: string, columnId: string, index: number): boolean;
3333 undo(boardId: string): boolean;
34 boardRevision(boardId: string): number;
35 boardsRevision(): number;
3436 };
3537
3638 export type Mode = "boards" | "board";
@@ -55,6 +57,8 @@ export type State = {
5557 filter: string;
5658 message: string;
5759 prompt?: Prompt;
60 /** The revision (`src/db.ts`'s `boardRevision`/`boardsRevision`) last drawn, so the tty can poll for a newer one. */
61 revision?: number;
5862 };
5963
6064 export type Outcome = { state: State; quit: boolean };
@@ -85,6 +89,56 @@ export function visibleCards(board: BoardStore, state: State, columnId: string):
8589 .filter((c) => q === "" || c.title.toLowerCase().includes(q));
8690 }
8791
92 /** Whether a newly-read revision is worth a redraw: the first read, or one that moved. */
93 export function shouldRedraw(previous: number | undefined, next: number): boolean {
94 return previous === undefined || next !== previous;
95 }
96
97 /**
98 * Where the cursor lands in a freshly-read list after something else may
99 * have changed it: on the same item if it is still there (found by id),
100 * otherwise the nearest remaining item at (or before) its old index — never
101 * a jump to 0 just because the list moved under it.
102 */
103 export function keepCursor<T extends { id: string }>(previous: T[], index: number, next: T[]): number {
104 const id = previous[index]?.id;
105 if (id !== undefined) {
106 const found = next.findIndex((item) => item.id === id);
107 if (found >= 0) return found;
108 }
109 if (next.length === 0) return 0;
110 return Math.max(0, Math.min(index, next.length - 1));
111 }
112
113 /** What was last drawn, kept only so a later `follow` can carry the cursor onto the same item by id. */
114 export type Drawn = { boards: Board[]; columns: Column[]; cards: Card[] };
115
116 export const emptyDrawn: Drawn = { boards: [], columns: [], cards: [] };
117
118 /**
119 * What `src/tty.ts`'s idle poll does: read the current scope's revision
120 * and, if it moved since the state was last drawn, re-read the board (or
121 * boards list) and carry the cursor onto the same item by id — the same
122 * decision a keypress makes, just triggered by a timer instead of a key.
123 * `redraw` tells the caller whether to actually draw; `state` is always the
124 * next one to draw from, whether or not it changed.
125 */
126 export function follow(state: State, board: BoardStore, drawn: Drawn): { state: State; redraw: boolean } {

Approved. All three findings from the last round are fixed, and I re-ran everything against the new head on top of main at d322ec8.

Verified again, by hand. bun test: 126 pass, 0 fail. A tty opened on a board with o: idle wrote 0 bytes across five poll ticks (no flicker, no churn); a card created by a second process appeared without a keypress inside the poll interval; with a New card: prompt open and text typed into it, an external change redrew the board and the prompt still read New card: typed afterwards; q exited 0 in ~13 ms. The preview is healthy: root 200 in ~210 ms with the CSP / nosniff / referrer-policy / vary: x-forge-identity floor intact, POST /mcp initialize answers, an anonymous list_boards returns [], an unknown board 404s.

1. The behaviour is now genuinely covered. follow is in src/keys.ts where it can be driven without a pty, and src/tty.ts is back to raw mode, drawing and a timer. I mutation-tested the new tests rather than trusting them: making follow ignore the revision fails 1 test, resetting cardCursor to 0 instead of keepCursor fails 2, and dropping a single bumpRevision from the same-column branch of moveCard fails 1 in src/db.test.ts. That is the real thing being tested, not a restatement of the implementation.

2. The read order in draw is fixed, with the reason in the comment — the revision is read before the lists, so a write that lands mid-draw costs one redundant redraw instead of leaving the terminal stale until the next write.

3. The README says the limit out loud: the follow is one-way, only src/routes.ts and src/mcp.ts touch the bus, and a change made in the terminal still doesn't reach an open page until its next load. Naming the cheap fix and explicitly scoping it out is the right call for this issue.

You also closed something I hadn't flagged: undo of a board rename now bumps boardsRevision too, with a test — that path only appeared when #59 landed under you, and it would have left a stale picker.

Nothing else to raise. The revision is bumped in-transaction on every write path in src/db.ts with none missed, the SQL is parameterised, __boards__ can't collide with an 8-character id, follow gates the boards list through the same visibleBoards(board, state.ownerId) the draw does, and there's no new dependency, dead code or TODO.

127 const next = state.mode === "boards" ? board.boardsRevision() : board.boardRevision(state.boardId!);
128 if (!shouldRedraw(state.revision, next)) return { state, redraw: false };
129
130 if (state.mode === "boards") {
131 const boards = visibleBoards(board, state.ownerId);
132 return { state: { ...state, boardsCursor: keepCursor(drawn.boards, state.boardsCursor, boards) }, redraw: true };
133 }
134 const columns = board.listColumns(state.boardId!);
135 const columnCursor = keepCursor(drawn.columns, state.columnCursor, columns);
136 const column = columns[columnCursor];
137 const cards = column ? visibleCards(board, { ...state, columnCursor }, column.id) : [];
138 const cardCursor = keepCursor(drawn.cards, state.cardCursor, cards);
139 return { state: { ...state, columnCursor, cardCursor }, redraw: true };
140 }
141
88142 /**
89143 * Where `gap` — a position among a column's *visible* cards, 0 meaning
90144 * before all of them — lands among every card in that column, the index
src/tty.test.ts +129 −1
@@ -14,7 +14,9 @@ import { MAX_CARDS_PER_BOARD, MAX_COLUMNS_PER_BOARD } from "./limits.ts";
1414
1515 process.env.FORGE_APP_DATA = mkdtempSync(join(tmpdir(), "app-"));
1616 const db = await import("./db.ts");
17 const { handleKey, initialState, visibleBoards, visibleCards } = await import("./keys.ts");
17 const { emptyDrawn, follow, handleKey, initialState, keepCursor, shouldRedraw, visibleBoards, visibleCards } = await import(
18 "./keys.ts"
19 );
1820
1921 /** One keypress after another, the way the tty feeds them in. */
2022 function press(state: State, ...keys: string[]): State {
@@ -473,3 +475,129 @@ test("the cursor cannot point outside a board with no columns at all", () => {
473475 expect([after.columnCursor, after.cardCursor]).toEqual([0, 0]);
474476 expect(after.prompt).toBeUndefined();
475477 });
478
479 // shouldRedraw and keepCursor are what src/tty.ts's poll loop uses to decide
480 // whether, and where, to redraw when the board changed under someone who
481 // isn't typing — driven here as plain functions over ids, no pty involved.
482
483 test("shouldRedraw fires on the first read and whenever the revision moved, never when it is unchanged", () => {
484 expect(shouldRedraw(undefined, 0)).toBe(true);
485 expect(shouldRedraw(undefined, 5)).toBe(true);
486 expect(shouldRedraw(5, 5)).toBe(false);
487 expect(shouldRedraw(5, 6)).toBe(true);
488 expect(shouldRedraw(6, 5)).toBe(true); // moved is moved, even if the caller mixed up direction
489 });
490
491 const item = (id: string) => ({ id });
492
493 test("keepCursor stays on the same item by id when it is still in the list, wherever it moved to", () => {
494 const before = [item("a"), item("b"), item("c")];
495 const after = [item("c"), item("a"), item("b")]; // reordered elsewhere
496 expect(keepCursor(before, 1, after)).toBe(2); // was on "b", "b" is now at index 2
497 });
498
499 test("keepCursor clamps to the nearest remaining item when the focused one is gone", () => {
500 const before = [item("a"), item("b"), item("c")];
501 expect(keepCursor(before, 1, [item("a"), item("c")])).toBe(1); // "b" gone, index 1 still valid ("c")
502 expect(keepCursor(before, 2, [item("a"), item("b")])).toBe(1); // "c" gone, clamp to the end
503 expect(keepCursor(before, 0, [])).toBe(0); // nothing left at all
504 });
505
506 test("keepCursor never jumps to 0 just because the list changed under it", () => {
507 const before = [item("a"), item("b"), item("c"), item("d")];
508 const after = [item("x"), item("b"), item("c"), item("d")]; // "a" replaced by something new at 0
509 expect(keepCursor(before, 2, after)).toBe(2); // "c" is still findable, stays put
510 // an out-of-band index (nothing was focused) clamps rather than resetting
511 expect(keepCursor([], 0, after)).toBe(0);
512 });
513
514 // follow is the whole decision src/tty.ts's idle poll makes each tick: read
515 // the current revision, and if it moved since what's on screen, re-read the
516 // board (or boards list) and carry the cursor by id. It is driven here
517 // against the real db, the same way `press` drives handleKey, so a change
518 // to what triggers a redraw or where the cursor lands fails a test here —
519 // no pty, and nothing in src/tty.ts itself needs covering separately.
520
521 /** What src/tty.ts's `draw` would have captured last time, for this state. */
522 function drawnFor(state: State) {
523 if (state.mode === "boards") return { ...emptyDrawn, boards: visibleBoards(db, state.ownerId) };
524 const columns = db.listColumns(state.boardId!);
525 const column = columns[state.columnCursor];
526 const cards = column ? visibleCards(db, state, column.id) : [];
527 return { ...emptyDrawn, columns, cards };
528 }
529
530 test("follow does not redraw when the revision has not moved", () => {
531 const { state, boardId } = openBoard();
532 const [todo] = db.listColumns(boardId);
533 db.createCard(boardId, todo.id, "only");
534 const seeded = { ...state, revision: db.boardRevision(boardId) };
535
536 const result = follow(seeded, db, drawnFor(seeded));
537 expect(result.redraw).toBe(false);
538 expect(result.state).toBe(seeded); // the same object back, nothing recomputed
539 });
540
541 test("follow redraws and carries the cursor onto the same card by id after cards are reordered elsewhere", () => {
542 const { state, boardId } = openBoard();
543 const [todo] = db.listColumns(boardId);
544 for (const title of ["first", "second", "third"]) db.createCard(boardId, todo.id, title);
545 const onSecond = { ...press(state, "j"), revision: db.boardRevision(boardId) };
546 const drawn = drawnFor(onSecond);
547 expect(titlesIn(boardId, 0)[onSecond.cardCursor]).toBe("second");
548
549 const [, , third] = db.listCards(boardId).filter((c) => c.column_id === todo.id);
550 db.moveCard(boardId, third.id, todo.id, 0); // "third" jumps to the front elsewhere
551
552 const result = follow(onSecond, db, drawn);
553 expect(result.redraw).toBe(true);
554 expect(titlesIn(boardId, 0)).toEqual(["third", "first", "second"]);
555 expect(titlesIn(boardId, 0)[result.state.cardCursor]).toBe("second"); // rode "second", not its old index
556 });
557
558 test("follow moves the cursor to the nearest remaining card when the focused one is deleted elsewhere", () => {
559 const { state, boardId } = openBoard();
560 const [todo] = db.listColumns(boardId);
561 for (const title of ["a", "b", "c"]) db.createCard(boardId, todo.id, title);
562 const onB = { ...press(state, "j"), revision: db.boardRevision(boardId) };
563 const drawn = drawnFor(onB);
564
565 const [, b] = db.listCards(boardId).filter((c) => c.column_id === todo.id);
566 db.deleteCard(boardId, b.id);
567
568 const result = follow(onB, db, drawn);
569 expect(result.redraw).toBe(true);
570 expect(titlesIn(boardId, 0)).toEqual(["a", "c"]);
571 expect(result.state.cardCursor).toBe(1); // "b" is gone; clamped to the nearest remaining, not reset to 0
572 });
573
574 test("follow keeps a prompt and the filter untouched — it only moves the cursor", () => {
575 const { state, boardId } = openBoard();
576 const [todo] = db.listColumns(boardId);
577 db.createCard(boardId, todo.id, "editing this");
578 const prompted = { ...press(state, "e"), revision: db.boardRevision(boardId) };
579 expect(prompted.prompt).toMatchObject({ kind: "editCard", value: "editing this" });
580 const drawn = drawnFor(prompted);
581
582 db.createCard(boardId, todo.id, "added while typing");
583
584 const result = follow(prompted, db, drawn);
585 expect(result.redraw).toBe(true);
586 expect(result.state.prompt).toEqual(prompted.prompt);
587 expect(result.state.filter).toBe(prompted.filter);
588 });
589
590 test("follow redraws the boards list and carries its cursor onto the same board when one is created elsewhere", () => {
591 const owner = { id: "follow-owner", name: "Follow" };
592 db.createBoard("Kept", owner);
593 const onKept = { ...initialState(owner.id), boardsCursor: 0, revision: db.boardsRevision() };
594 const drawn = drawnFor(onKept);
595 const keptId = visibleBoards(db, owner.id)[0].id;
596
597 db.createBoard("New, listed first", owner); // ordered by created_at DESC, so it sorts ahead of "Kept"
598
599 const result = follow(onKept, db, drawn);
600 expect(result.redraw).toBe(true);
601 const boards = visibleBoards(db, owner.id);
602 expect(boards[result.state.boardsCursor].id).toBe(keptId);
603 });
src/tty.ts +33 −1
@@ -10,7 +10,7 @@
1010 */
1111
1212 import * as db from "./db.ts";
13 import { handleKey, initialState, visibleBoards, visibleCards, type State } from "./keys.ts";
13 import { emptyDrawn, follow, handleKey, initialState, visibleBoards, visibleCards, type Drawn, type State } from "./keys.ts";
1414 import { ownerIdFromEnv } from "./session.ts";
1515
1616 const esc = "\x1b[";
@@ -20,11 +20,24 @@ const out = (s: string): void => {
2020
2121 let state: State = initialState(ownerIdFromEnv(process.env));
2222
23 // What was last drawn, kept only so `follow` (src/keys.ts) can carry the
24 // cursor onto the same item by id when a redraw is triggered by someone
25 // else's change rather than a keypress.
26 let drawn: Drawn = emptyDrawn;
27
2328 function draw(): void {
29 // Read before the lists below, not after: a write landing while this
30 // function runs would otherwise be recorded as already-seen (the
31 // revision read at the end would include it) without its effect ever
32 // making it into `lines`, leaving the terminal stale until the *next*
33 // write. Reading first costs at most one redundant redraw instead.
34 const revision = state.mode === "boards" ? db.boardsRevision() : db.boardRevision(state.boardId!);
35
2436 const lines: string[] = [];
2537 const prompt = state.prompt;
2638 if (state.mode === "boards") {
2739 const boards = visibleBoards(db, state.ownerId);
40 drawn = { ...drawn, boards };
2841 lines.push(`${esc}1mtempo${esc}0m ${esc}2mpick a board${esc}0m`);
2942 lines.push("");
3043 if (boards.length === 0) {
@@ -48,12 +61,14 @@ function draw(): void {
4861 } else {
4962 const board = db.getBoard(state.boardId!)!;
5063 const columns = db.listColumns(state.boardId!);
64 drawn = { ...drawn, columns };
5165 lines.push(
5266 `${esc}1mtempo${esc}0m ${esc}2m${board.name} /b/${board.id}${esc}0m${state.filter ? ` ${esc}2mfilter: ${state.filter}${esc}0m` : ""}`,
5367 );
5468 lines.push("");
5569 const columnLines = columns.map((col, ci) => {
5670 const cards = visibleCards(db, state, col.id);
71 if (ci === state.columnCursor) drawn = { ...drawn, cards };
5772 const header = ci === state.columnCursor ? `${esc}1m${col.name}${esc}0m` : col.name;
5873 const body = cards.map((c, i) => {
5974 const focused = ci === state.columnCursor && i === state.cardCursor;
@@ -63,6 +78,7 @@ function draw(): void {
6378 if (body.length === 0) body.push(`${esc}2m(empty)${esc}0m`);
6479 return [`${header}`, ...body];
6580 });
81 if (!columns[state.columnCursor]) drawn = { ...drawn, cards: [] };
6682 const height = Math.max(...columnLines.map((c) => c.length));
6783 const width = 24;
6884 for (let row = 0; row < height; row++) {
@@ -79,6 +95,7 @@ function draw(): void {
7995 : state.message || `${esc}2mhjkl move · a add · e edit · d delete · H/L/K/J move card · c column · r rename · D delete col · [ ] reorder col · f filter · u undo · b back · q quit${esc}0m`,
8096 );
8197 }
98 state.revision = revision;
8299 out(`${esc}2J${esc}H` + lines.join("\r\n"));
83100 }
84101
@@ -87,13 +104,28 @@ function visibleLength(s: string): number {
87104 return s.replace(/\x1b\[[0-9]*m/g, "").length;
88105 }
89106
107 // Runs about twice a second, whether the board list or a board is open, and
108 // costs one indexed read when nothing changed (`follow`, src/keys.ts, decides
109 // that — never mid-keypress, so a redraw never eats a keystroke).
110 const POLL_MS = 500;
111
112 function poll(): void {
113 const followed = follow(state, db, drawn);
114 if (!followed.redraw) return;
115 state = followed.state;
116 draw();
117 }
118
90119 process.stdin.setRawMode?.(true);
91120 process.stdin.resume();
92121 process.stdin.setEncoding("utf8");
93122 draw();
123 const timer = setInterval(poll, POLL_MS);

I applied the head on top of the tree and ran it. The feature works: with a tty on board 3qwyvi4s, a card created from a second process appeared without a keypress inside the poll interval; idle is genuinely free (stdout went 632 → 632 bytes across five ticks, so no flicker and no redraw churn); an open a prompt kept the text I had typed through an external redraw; q exited 0 in ~11 ms. bun test is 108 pass / 0 fail. The revision is bumped inside the same transaction on every write path in src/db.ts (nothing missed), the SQL is parameterised, __boards__ cannot collide with an 8-character id, and no identity or request surface changes — nothing to flag on security.

Three things, in order.

1. The behaviour this pull is about has no test (fix this first). I replaced setInterval(poll, POLL_MS) (src/tty.ts) with a no-op timer — the entire follow-the-board feature gone — and bun test was still 108 pass / 0 fail. shouldRedraw and keepCursor are tested, but the part that decides what to re-read and which snapshot to reconcile against is poll plus the three mutable module-level snapshots lastBoards/lastColumns/lastCards (the top of src/tty.ts, written as a side effect of draw), and none of it is covered. That logic also doesn't belong in the pty half: src/keys.ts exists precisely so a state transition can be driven from a test without a tty, and the same shape fits here — something like follow(state, store, drawn) → State in src/keys.ts, with src/tty.ts keeping only the timer and the draw call. Then a test can seed the db, take a state, write a card from db directly, and assert the returned state moved (and that the cursor rode its card by id) — a test that fails if poll is removed.

2. draw() records the revision after it reads the board (src/tty.ts). A write that lands between db.listColumns(...) and currentRevision() is stored as already-seen but was never drawn, and the terminal then stays stale until the next write — not half a second, indefinitely. The window is small but the fix is one line: read currentRevision() at the top of draw (before the lists) and assign it at the end, so a race costs one redundant redraw instead of a missed one.

3. The README dropped a limit that is still true (README.md). The old bullet said a change made in the terminal shows up on the web on the next page load, not live. That is still the case — only src/routes.ts and src/mcp.ts call live.touch, and src/tty.ts writes straight to src/db.ts — but the new paragraph only describes the terminal's side, so "What's simulated" no longer says the other direction isn't live. Restore that sentence. (The real fix is now cheap with this revision table in place — the server could poll boardRevision and touch the bus, closing "a write in the terminal reaches web viewers live" — but that's a separate issue, not this one's scope.)

Approved. Both things I asked for last round are done, and I checked the result rather than the description.

Verified against the head (a5af938), run out of tree so this clone stays untouched:

  • bun test: 144 pass, 0 fail.
  • The tests are load-bearing, not decorative. I mutated shouldRedraw to return previous === undefined — 5 failures in src/tty.test.ts. I deleted the single bumpRevision(boardId) in createCard — 2 failures across src/db.test.ts and src/tty.test.ts. That was my first item last round and it is closed: follow in src/keys.ts is the whole poll decision (what to re-read, which snapshot to reconcile against), driven from tests against the real db, and only the setInterval line is left in the pty half.
  • End to end, no pty: a src/tty.ts on a seeded board, stdin a pipe, a card written from a second process — the title appeared in the terminal's stdout inside the poll interval, with no keypress. Idle is genuinely free: 3 seconds of no writes produced 2 frames total (the initial draw and the one after enter), 679 bytes, so no flicker and no churn. q exits 0 immediately; the timer is cleared and unref'd.
  • The draw() race is fixed the way I suggested: the revision is read before the lists and assigned after, so a write landing mid-draw costs one redundant redraw instead of a missed one that would leave the terminal stale indefinitely. The comment above it says exactly why.
  • The README bullet I asked for is back, and it is honest: the poll is one-way, a write made in the terminal still doesn't reach an open page live.
  • Every write path in src/db.ts bumps inside the same transaction — card create/edit/move/delete, column create/rename/move/delete, board rename, undo, and the boards scope on create/rename/claim/undo-of-rename. I read them all; nothing is missed, and the counter is never derived from the trimmed action log.

Security: no new request surface, no new input, no new output. The revision SQL is parameterised, __boards__ cannot collide with an 8-character randomId, CREATE TABLE IF NOT EXISTS migrates an existing database in place, identity in the terminal is unchanged (ownerIdFromEnv), and visibleBoards gating is reused by follow rather than bypassed — a guest still sees nothing in the picker while polling. Nothing to flag.

Preview (https://tempo--47--forge-trevato-apps.open-platform.sh): / 200 with the full security floor (CSP, nosniff, referrer-policy, vary: x-forge-identity), board created, board page in 125 ms, POST /mcp tools/list 200, and an open /b/<id>/events stream delivered card.added for a card posted from another connection — the web side is unaffected by the revision table.

One nit, not worth another round: the comment at src/db.ts:117 points at src/live.ts, which the merge with main renamed to src/bus.ts. One word, fix it on the next touch of that file.

The remaining direction gap — a terminal write reaching open web pages live — is real but out of this issue's scope and already tracked in #31 and #56, and this revision table is what makes it cheap. Nothing new to open.

124 timer.unref?.();
94125 process.stdin.on("data", (key: string) => {
95126 const outcome = handleKey(state, key, db);
96127 if (outcome.quit) {
128 clearInterval(timer);
97129 out(`${esc}2J${esc}H`);
98130 process.exit(0);
99131 }

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs