Public: anyone can read this; the crew can change it.
On the terminal's asked screen, Enter on a thought says where it went: the issue or note it became, and the thoughts it repeats or takes back, as MCP `thought` answers #7710
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7473-on-the-terminal-s-asked-screen-enter-on → main 8 commits fork
3 files
changed +323
−2
README.md +1
| @@ -471,6 +471,7 @@ This page is the front door. Everything else is a file of its own under | ||
| 471 | 471 | - Under each queued repository's merge-queue line, the terminal Flow dialog lists its last five gates, newest first: the pulls carried, `green`, `red` or `cut off`, how long ago, and for a red gate the first failing test with its file, `flaky` when two gates sharing no pull failed it (`src/checks/gate_result.ts`, as MCP `queue_gates` reads them); a repository with none kept says `no gates yet`, and an agent reader sees public repositories only. |
| 472 | 472 | - The flow panel on `/boards` has a Last gates card: the newest ten merge-queue gates, each with its pulls linked, green, red or cut off, and the first failing test with its first error line (or the reason a gate was cut off). A gate carrying a sealed pull shows `a sealed pull` and the verdict alone; a non-admin reader sees public repositories only (`src/web/pages/boards-gates.ts`, the same `recentGates` read as MCP `queue_gates`). |
| 473 | 473 | - The flow panel on `/boards` and the terminal Flow dialog say how the open crew pulls sort into piles (red, conflicted, changes asked, approved and waiting, awaiting review), each pull counted once in the first pile it fits (`src/flow/pile.ts`). |
| 474 | - On the terminal's Asked screen, Enter on a thought opens a card of what became of it, as MCP `thought` answers: the issue it placed (Enter opens it), "kept as a note" or "not placed yet", and the thoughts it repeats or takes back and the later ones that did the same to it. A repository you cannot read is named as nothing, and a thought that is not yours or is gone says "No such thought". The card is a snapshot; `r` re-reads it (`src/tui/screens/asked.ts`). | |
| 474 | 475 | - The off-box snapshot mirror is set from three doors with one set of rules (`src/actions/mirror.ts`): Settings → Backups, MCP `mirror_set` (`set` or `forget`), and the terminal's Resilience screen (a forge admin who is a person; an admin agent only reads). The screen shows `Mirror: <place>` or `Mirror: none.`, `m` seals it with five prompts (endpoint, bucket, region, access key, secret key; the keys are masked as typed) where a blank keeps what was sealed, and `f` forgets it after a confirm. `t` tests where snapshots go, as MCP `backups_test` and the Backups page's Test button do: the sealed destination, otherwise the environment's, said as `Reached …: N objects listed.` or `Could not list …: <reason>` in the page's words; with none set it says so and calls nobody. A key is never read back or drawn. |
| 475 | 476 | - [](docs/PLANNING.md) — how issues depend on issues: the edge, the map, and what "ready now" means to a person and to the crew. |
| 476 | 477 | - [](docs/FLEET.md) — one change across many repositories, runs or issues: a query, a dry-run plan of every row before → after, a receipt, and undo. |
src/tui/screens/asked.ts +112 −2
| @@ -5,15 +5,24 @@ | ||
| 5 | 5 | * neither, a viewer sees only their own thoughts, and a placement in a |
| 6 | 6 | * repository the viewer cannot read says so and names nothing of it. |
| 7 | 7 | * The palette offers the door only when signed in, so the screen has no |
| 8 | * signed-out face; `askedLines` still returns nothing for a guest. Read-only; `enter` on an ask hands over its web address. | |
| 8 | * signed-out face; `askedLines` still returns nothing for a guest. Read-only; | |
| 9 | * `enter` on an ask hands over its web address, on a thought opens its card | |
| 10 | * (#7473): where it went and what it repeats or takes back, as MCP `thought` | |
| 11 | * answers. The card is re-read on `r`. | |
| 9 | 12 | * `s` cycles unshipped → flagged → all and `o` flips oldest/newest, with MCP |
| 10 | 13 | * `asked`'s defaults, words and order (#7303) — not the page's grouping. |
| 11 | 14 | */ |
| 12 | 15 | |
| 13 | 16 | import type { Database } from "bun:sqlite"; |
| 14 | import { thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts"; | |
| 17 | import { NOT_PLACED_YET, thoughtNotes, whereItWent } from "../../actions/ask_state_words.ts"; | |
| 15 | 18 | import { askedIn, askedRepos, outcomeText, type Asked, type AskedOrder, type AskedShow } from "../../core/asked.ts"; |
| 19 | import { getIssue } from "../../core/issues.ts"; | |
| 20 | import { mayView } from "../../core/sealed.ts"; | |
| 21 | import { placementOf } from "../../core/placements.ts"; | |
| 22 | import { canRead, getRepo, type Repo } from "../../core/repos.ts"; | |
| 23 | import { linksFrom, linksTo } from "../../core/thought_links.ts"; | |
| 16 | 24 | import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts"; |
| 25 | import { thoughtOf } from "../../core/thoughts.ts"; | |
| 17 | 26 | import type { User } from "../../core/users.ts"; |
| 18 | 27 | import type { Ctx, Screen } from "../app/app.ts"; |
| 19 | 28 | import { binding } from "../app/keys.ts"; |
| @@ -23,6 +32,8 @@ import { emptyState, type Tone } from "../ui/widgets.ts"; | ||
| 23 | 32 | import { style } from "../term/style.ts"; |
| 24 | 33 | import { truncate } from "../term/width.ts"; |
| 25 | 34 | import { ListState, drawList, hitList, metaOf, openOnWeb, relTime, type ListRow } from "./common.ts"; |
| 35 | import { issueScreen } from "./issue.ts"; | |
| 36 | import { local } from "./where.ts"; | |
| 26 | 37 | |
| 27 | 38 | /** One line of plain text: a typed thought may carry newlines or escapes the terminal must not obey. */ |
| 28 | 39 | const plain = (text: string): string => |
| @@ -32,6 +43,10 @@ const plain = (text: string): string => | ||
| 32 | 43 | export interface AskedLine extends ListRow { |
| 33 | 44 | /** The web path `enter` opens; absent for a thought. */ |
| 34 | 45 | readonly path?: string; |
| 46 | /** The thought `enter` opens a card for. */ | |
| 47 | readonly thoughtId?: string; | |
| 48 | /** The issue `enter` opens, on a card line: only ever a repository the viewer can read. */ | |
| 49 | readonly issue?: { readonly repo: Repo; readonly number: number }; | |
| 35 | 50 | } |
| 36 | 51 | |
| 37 | 52 | const THOUGHT_WORD = { |
| @@ -54,6 +69,7 @@ function thoughtLine(t: StreamThought): AskedLine { | ||
| 54 | 69 | const where = whereItWent(t).map((x) => `${x.lead}${x.text}`); |
| 55 | 70 | const notes = thoughtNotes(t).map((n) => `${n.text} ${relTime(n.at)}`); |
| 56 | 71 | return { |
| 72 | thoughtId: t.id, | |
| 57 | 73 | title: plain(t.text), |
| 58 | 74 | ...toneOf(t.flagged, t.state), |
| 59 | 75 | hollow: t.state === "unplaced" || t.state === "superseded", |
| @@ -73,6 +89,93 @@ function askLine(a: Asked): AskedLine { | ||
| 73 | 89 | }; |
| 74 | 90 | } |
| 75 | 91 | |
| 92 | /** The repository `owner/name` when the viewer may read it: the one place a card decides that. */ | |
| 93 | function readableRepo(db: Database, viewer: User, slug: string): Repo | undefined { | |
| 94 | const [owner, name, ...rest] = slug.split("/"); | |
| 95 | const r = owner && name && rest.length === 0 ? getRepo(db, owner, name) : undefined; | |
| 96 | return r && canRead(db, r, viewer) ? r : undefined; | |
| 97 | } | |
| 98 | ||
| 99 | const quoted = (text: string): string => `\u201c${plain(text)}\u201d`; | |
| 100 | ||
| 101 | /** | |
| 102 | * What became of one thought, as MCP `thought` answers: one sentence a line. | |
| 103 | * Not the viewer's, or not there, is `undefined` alike. | |
| 104 | */ | |
| 105 | export function thoughtCard(db: Database, viewer: User | undefined, id: string): AskedLine[] | undefined { | |
| 106 | if (viewer === undefined) return undefined; | |
| 107 | const t = thoughtOf(db, viewer.id, id); | |
| 108 | if (t === undefined) return undefined; | |
| 109 | const out: AskedLine[] = [{ title: quoted(t.text), hollow: true }]; | |
| 110 | const p = placementOf(db, id); | |
| 111 | if (p === undefined) out.push({ title: `Not placed yet: ${NOT_PLACED_YET}.` }); | |
| 112 | else if (p.placed.length === 0) out.push({ title: "Kept as a note." }); | |
| 113 | for (const x of p?.placed ?? []) { | |
| 114 | const repo = readableRepo(db, viewer, x.repo); | |
| 115 | const issue = repo === undefined ? undefined : getIssue(db, repo.id, x.number); | |
| 116 | // A sealed issue in a readable repository is no more there than an unreadable repository. | |
| 117 | if (repo === undefined || issue === undefined || !mayView(db, repo, issue, viewer)) { | |
| 118 | out.push({ title: `It ${x.action} something in a repository you cannot read.` }); | |
| 119 | continue; | |
| 120 | } | |
| 121 | out.push({ | |
| 122 | title: `It ${x.action} ${x.repo} #${x.number}: ${plain(issue.title)}.`, | |
| 123 | issue: { repo, number: x.number }, | |
| 124 | }); | |
| 125 | } | |
| 126 | const refs = (ids: readonly string[], verb: string): void => { | |
| 127 | for (const other of ids) { | |
| 128 | const o = thoughtOf(db, viewer.id, other); | |
| 129 | out.push({ title: o ? `${verb} ${quoted(o.text)}.` : `${verb} a thought that is gone.` }); | |
| 130 | } | |
| 131 | }; | |
| 132 | const from = linksFrom(db, id); | |
| 133 | const to = linksTo(db, id); | |
| 134 | refs(from.repeats, "It repeats"); | |
| 135 | refs(from.supersedes, "It takes back"); | |
| 136 | refs(to.repeatedBy, "Repeated later by"); | |
| 137 | refs(to.supersededBy === null ? [] : [to.supersededBy], "Taken back later by"); | |
| 138 | return out; | |
| 139 | } | |
| 140 | ||
| 141 | function cardScreen(f: Session, ctx: Ctx, id: string, first: AskedLine[]): Screen { | |
| 142 | const state = new ListState(); | |
| 143 | let rows = first; | |
| 144 | const open = (index: number): void => { | |
| 145 | const x = rows[index]?.issue; | |
| 146 | if (x !== undefined) ctx.push(issueScreen(f, ctx, local(f, x.repo), x.number)); | |
| 147 | }; | |
| 148 | return { | |
| 149 | title: "Thought", | |
| 150 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open issue (on a line that has one)"), binding(["r"], "refresh")], | |
| 151 | render(area, s) { | |
| 152 | drawList(s, area, ctx.theme.c, state, rows); | |
| 153 | hitList(ctx, area, state, rows.length, { open }); | |
| 154 | }, | |
| 155 | key(e) { | |
| 156 | if (state.key(e, rows.length)) return true; | |
| 157 | if (e.key === "enter") { | |
| 158 | open(state.index); | |
| 159 | return true; | |
| 160 | } | |
| 161 | if (e.key === "r") { | |
| 162 | const again = thoughtCard(f.db, f.user, id); | |
| 163 | if (again === undefined) { | |
| 164 | // Gone since it was drawn: stale rows must not pass for current ones. | |
| 165 | ctx.toast("No such thought", "attn"); | |
| 166 | ctx.pop(); | |
| 167 | return true; | |
| 168 | } | |
| 169 | rows = again; | |
| 170 | state.clamp(rows.length); | |
| 171 | ctx.invalidate(); | |
| 172 | return true; | |
| 173 | } | |
| 174 | return false; | |
| 175 | }, | |
| 176 | }; | |
| 177 | } | |
| 178 | ||
| 76 | 179 | /** What MCP `asked` takes, and its defaults. */ |
| 77 | 180 | export interface AskedView { |
| 78 | 181 | readonly show: AskedShow; |
| @@ -113,6 +216,13 @@ export function askedScreen(f: Session, ctx: Ctx): Screen { | ||
| 113 | 216 | let view = DEFAULT_VIEW; |
| 114 | 217 | const open = (index: number): void => { |
| 115 | 218 | const line = lines[index]; |
| 219 | if (line?.thoughtId !== undefined) { | |
| 220 | // Not the viewer's, or gone: nothing exists, as MCP answers. | |
| 221 | const card = thoughtCard(f.db, f.user, line.thoughtId); | |
| 222 | if (card === undefined) ctx.toast("No such thought", "attn"); | |
| 223 | else ctx.push(cardScreen(f, ctx, line.thoughtId, card)); | |
| 224 | return; | |
| 225 | } | |
| 116 | 226 | if (line?.path !== undefined) openOnWeb(f, ctx, line.path, "That ask"); |
| 117 | 227 | }; |
| 118 | 228 | const read = (): void => { |
test/tui-thought-where.test.ts added +210
| @@ -0,0 +1,210 @@ | ||
| 1 | /** Enter on a thought in the terminal's asked screen says where it went (#7473). */ | |
| 2 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createRepo } from "../src/actions/repos.ts"; | |
| 7 | import { createApp, type App } from "../src/app.ts"; | |
| 8 | import { createIssue } from "../src/core/issues.ts"; | |
| 9 | import { savePlacement } from "../src/core/placements.ts"; | |
| 10 | import { linkThoughts } from "../src/core/thought_links.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 13 | import { askedLines, askedScreen, thoughtCard } from "../src/tui/screens/asked.ts"; | |
| 14 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 15 | import { promote } from "./helpers/members.ts"; | |
| 16 | import { quietLogs } from "./helpers/globals.ts"; | |
| 17 | import { settleApp } from "./helpers/tui.ts"; | |
| 18 | ||
| 19 | quietLogs(); | |
| 20 | ||
| 21 | let dir: string; | |
| 22 | let app: App; | |
| 23 | let alice: User; | |
| 24 | let bob: User; | |
| 25 | ||
| 26 | const thought = (who: User, id: string, text: string, at: string) => | |
| 27 | app.db | |
| 28 | .query("INSERT INTO thought (id, author, text, source, received_at) VALUES (?1, ?2, ?3, 'composer', ?4)") | |
| 29 | .run(id, who.id, text, at); | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | dir = await mkdtemp(join(tmpdir(), "forge-tui-thought-")); | |
| 33 | app = createApp({ dataDir: dir, port: 0 }); | |
| 34 | const mk = async (username: string, first = false) => { | |
| 35 | const r = await createUser(app.db, { | |
| 36 | origin: "seed", trusted: true, username, email: `${username}@forge.local`, | |
| 37 | password: "a-good-passphrase", adminIfFirst: first, | |
| 38 | }); | |
| 39 | if (!r.ok) throw new Error(r.error); | |
| 40 | promote(app.db, r.value); | |
| 41 | return r.value; | |
| 42 | }; | |
| 43 | await mk("carol", true); | |
| 44 | alice = await mk("alice"); | |
| 45 | bob = await mk("bob"); | |
| 46 | const lobby = await createRepo(app, alice, { name: "lobby", visibility: "public" }); | |
| 47 | if (!lobby.ok) throw new Error(String(lobby.error)); | |
| 48 | const i = createIssue(app.db, { repoId: lobby.value.id, authorId: alice.id, title: "Louder lobby" }); | |
| 49 | if (!i.ok) throw new Error(i.error); | |
| 50 | thought(alice, "t1", "make the lobby louder", "2026-01-01T00:00:00.000Z"); | |
| 51 | thought(alice, "t2", "louder lobby please", "2026-01-02T00:00:00.000Z"); | |
| 52 | thought(bob, "t3", "bob's idea", "2026-01-03T00:00:00.000Z"); | |
| 53 | const vault = await createRepo(app, bob, { name: "vault", visibility: "private" }); | |
| 54 | if (!vault.ok) throw new Error(String(vault.error)); | |
| 55 | const secret = createIssue(app.db, { repoId: vault.value.id, authorId: bob.id, title: "Hidden vault door" }); | |
| 56 | if (!secret.ok) throw new Error(secret.error); | |
| 57 | thought(alice, "t5", "hide the vault door", "2026-01-05T00:00:00.000Z"); | |
| 58 | savePlacement(app.db, { | |
| 59 | thoughtId: "t5", kind: "work", asked: false, decidedBy: "rule", reply: "ok", | |
| 60 | placed: [{ action: "opened", repo: "bob/vault", number: secret.value.number }], | |
| 61 | placedAt: "2026-01-05T00:01:00.000Z", | |
| 62 | }); | |
| 63 | thought(alice, "t6", "and again", "2026-01-06T00:00:00.000Z"); | |
| 64 | linkThoughts(app.db, "t6", "t3", "repeats"); | |
| 65 | linkThoughts(app.db, "t6", "t3", "supersedes"); | |
| 66 | savePlacement(app.db, { | |
| 67 | thoughtId: "t1", kind: "work", asked: false, decidedBy: "rule", reply: "ok", | |
| 68 | placed: [{ action: "opened", repo: "alice/lobby", number: i.value.number }], | |
| 69 | placedAt: "2026-01-01T00:01:00.000Z", | |
| 70 | }); | |
| 71 | linkThoughts(app.db, "t2", "t1", "repeats"); | |
| 72 | }); | |
| 73 | afterAll(async () => { | |
| 74 | app.db.close(); | |
| 75 | await rm(dir, { recursive: true, force: true }); | |
| 76 | }); | |
| 77 | ||
| 78 | const titles = (id: string, who: User) => thoughtCard(app.db, who, id)?.map((l) => l.title); | |
| 79 | ||
| 80 | describe("a thought's card", () => { | |
| 81 | test("the placed one names its issue and the thought that repeated it", () => { | |
| 82 | const lines = titles("t1", alice)!.join("\n"); | |
| 83 | expect(lines).toContain("It opened alice/lobby #1: Louder lobby."); | |
| 84 | expect(lines).toContain("Repeated later by \u201clouder lobby please\u201d."); | |
| 85 | expect(thoughtCard(app.db, alice, "t1")!.some((l) => l.issue?.repo.name === "lobby")).toBe(true); | |
| 86 | }); | |
| 87 | ||
| 88 | test("the repeating one says what it repeats and that it is not placed yet", () => { | |
| 89 | const lines = titles("t2", alice)!.join("\n"); | |
| 90 | expect(lines).toContain("Not placed yet"); | |
| 91 | expect(lines).toContain("It repeats \u201cmake the lobby louder\u201d."); | |
| 92 | }); | |
| 93 | ||
| 94 | test("a placement with nothing on the map is kept as a note", () => { | |
| 95 | thought(alice, "t4", "we value small things", "2026-01-04T00:00:00.000Z"); | |
| 96 | savePlacement(app.db, { | |
| 97 | thoughtId: "t4", kind: "vision", asked: false, decidedBy: "rule", reply: "noted", placed: [], | |
| 98 | placedAt: "2026-01-04T00:01:00.000Z", | |
| 99 | }); | |
| 100 | expect(titles("t4", alice)).toContain("Kept as a note."); | |
| 101 | }); | |
| 102 | ||
| 103 | test("a placement in a repository the viewer cannot read names nothing and opens nothing", () => { | |
| 104 | const card = thoughtCard(app.db, alice, "t5")!; | |
| 105 | expect(card.map((l) => l.title)).toContain("It opened something in a repository you cannot read."); | |
| 106 | const all = JSON.stringify(card); | |
| 107 | for (const leak of ["bob/vault", "Hidden", "#1"]) expect(all).not.toContain(leak); | |
| 108 | expect(card.some((l) => l.issue !== undefined)).toBe(false); | |
| 109 | }); | |
| 110 | ||
| 111 | test("a sealed issue in a readable repository names nothing and opens nothing", async () => { | |
| 112 | // A public repository of bob's: alice reads it but does not write it. | |
| 113 | const open = await createRepo(app, bob, { name: "open", visibility: "public" }); | |
| 114 | if (!open.ok) throw new Error(String(open.error)); | |
| 115 | const sealed = createIssue(app.db, { repoId: open.value.id, authorId: bob.id, title: "Sealed zero-day", sealed: true }); | |
| 116 | if (!sealed.ok) throw new Error(sealed.error); | |
| 117 | thought(alice, "t8", "what about the zero-day", "2025-12-31T00:00:00.000Z"); | |
| 118 | savePlacement(app.db, { | |
| 119 | thoughtId: "t8", kind: "work", asked: false, decidedBy: "rule", reply: "ok", | |
| 120 | placed: [{ action: "opened", repo: "bob/open", number: sealed.value.number }], | |
| 121 | placedAt: "2026-01-08T00:01:00.000Z", | |
| 122 | }); | |
| 123 | const card = thoughtCard(app.db, alice, "t8")!; | |
| 124 | expect(card.map((l) => l.title)).toContain("It opened something in a repository you cannot read."); | |
| 125 | const all = JSON.stringify(card); | |
| 126 | expect(all).not.toContain("Sealed zero-day"); | |
| 127 | expect(all).not.toContain(`#${sealed.value.number}`); | |
| 128 | expect(card.some((l) => l.issue !== undefined)).toBe(false); | |
| 129 | }); | |
| 130 | ||
| 131 | test("a link to another person's thought reads as gone and quotes nothing of it", () => { | |
| 132 | const card = thoughtCard(app.db, alice, "t6")!; | |
| 133 | const titles = card.map((l) => l.title); | |
| 134 | expect(titles).toContain("It repeats a thought that is gone."); | |
| 135 | expect(titles).toContain("It takes back a thought that is gone."); | |
| 136 | expect(JSON.stringify(card)).not.toContain("bob's idea"); | |
| 137 | }); | |
| 138 | ||
| 139 | test("someone else's thought and a missing one are alike nothing", () => { | |
| 140 | expect(thoughtCard(app.db, alice, "t3")).toBeUndefined(); | |
| 141 | expect(thoughtCard(app.db, alice, "nope")).toBeUndefined(); | |
| 142 | expect(thoughtCard(app.db, undefined, "t1")).toBeUndefined(); | |
| 143 | }); | |
| 144 | ||
| 145 | // A thought is never deleted, so a test that needs it gone lifts the guard for one statement. | |
| 146 | const vanish = (id: string) => { | |
| 147 | app.db.run("DROP TRIGGER thought_no_delete"); | |
| 148 | app.db.query("DELETE FROM thought WHERE id = ?1").run(id); | |
| 149 | app.db.run("CREATE TRIGGER thought_no_delete BEFORE DELETE ON thought BEGIN SELECT RAISE(ABORT, 'a thought is never deleted'); END"); | |
| 150 | }; | |
| 151 | ||
| 152 | const open = async (text: string) => { | |
| 153 | const terminal = headlessTerminal(120, 30); | |
| 154 | const { app: tui, session } = createForgeApp(app, { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }, { terminal, exitHooks: false }); | |
| 155 | tui.start(); | |
| 156 | tui.ctx.push(askedScreen(session, tui.ctx)); | |
| 157 | await settleApp(tui); | |
| 158 | // Select the row by its text, not by its place in the list. | |
| 159 | const at = askedLines(app.db, alice).thoughts.findIndex((t) => t.title === text); | |
| 160 | if (at < 0) throw new Error(`no thought ${text}`); | |
| 161 | terminal.feed("j".repeat(at)); | |
| 162 | await settleApp(tui); | |
| 163 | return { tui, terminal }; | |
| 164 | }; | |
| 165 | ||
| 166 | test("enter on a thought in the screen opens its card", async () => { | |
| 167 | const { tui, terminal } = await open("make the lobby louder"); | |
| 168 | terminal.feed("\r"); | |
| 169 | await settleApp(tui); | |
| 170 | const text = tui.screen.toText(); | |
| 171 | tui.stop?.(); | |
| 172 | expect(text).toContain("It opened alice/lobby #1"); | |
| 173 | }); | |
| 174 | ||
| 175 | test("enter on a thought that has gone toasts and opens no card", async () => { | |
| 176 | thought(alice, "t9", "soon gone", "2026-01-09T00:00:00.000Z"); | |
| 177 | const { tui, terminal } = await open("soon gone"); | |
| 178 | vanish("t9"); | |
| 179 | terminal.feed("\r"); | |
| 180 | await settleApp(tui); | |
| 181 | const text = tui.screen.toText(); | |
| 182 | tui.stop?.(); | |
| 183 | expect(text).toContain("No such thought"); | |
| 184 | expect(text).toContain("Asked"); | |
| 185 | expect(text).not.toContain("Not placed yet"); | |
| 186 | }); | |
| 187 | ||
| 188 | test("r on a card reads again, and a card whose thought has gone toasts and pops", async () => { | |
| 189 | thought(alice, "t10", "to be placed", "2026-01-10T00:00:00.000Z"); | |
| 190 | const { tui, terminal } = await open("to be placed"); | |
| 191 | terminal.feed("\r"); | |
| 192 | await settleApp(tui); | |
| 193 | expect(tui.screen.toText()).toContain("Not placed yet"); | |
| 194 | savePlacement(app.db, { | |
| 195 | thoughtId: "t10", kind: "vision", asked: false, decidedBy: "rule", reply: "noted", placed: [], | |
| 196 | placedAt: "2026-01-10T00:01:00.000Z", | |
| 197 | }); | |
| 198 | terminal.feed("r"); | |
| 199 | await settleApp(tui); | |
| 200 | expect(tui.screen.toText()).toContain("Kept as a note."); | |
| 201 | vanish("t10"); | |
| 202 | terminal.feed("r"); | |
| 203 | await settleApp(tui); | |
| 204 | const text = tui.screen.toText(); | |
| 205 | tui.stop?.(); | |
| 206 | expect(text).toContain("No such thought"); | |
| 207 | expect(text).not.toContain("Kept as a note."); | |
| 208 | expect(text).toContain("Asked"); | |
| 209 | }); | |
| 210 | }); |
new above
offline