Public: anyone can read this; the crew can change it.
The terminal's asked screen narrows to unshipped, flagged or all with `s`, and flips oldest/newest with `o`, as MCP `asked` does #7313
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7303-the-terminal-s-asked-screen-narrows-to-u → main 3 commits fork 3 threads · 3 unresolved
4 files
changed +206
−18
docs/TOUR.md +6 −1
| @@ -119,7 +119,12 @@ about why. | ||
| 119 | 119 | ## Asked, review, a client's forge and the family |
| 120 | 120 | |
| 121 | 121 | What you asked for: `Ctrl+K`, then **Asked**, is `/asked` in the terminal — your |
| 122 | thoughts, then the issues you can write to that are not shipped, oldest first | |
| 122 | thoughts, then the issues you can write to that are not shipped, oldest first, | |
| 123 | as MCP `asked` defaults to. `s` cycles not shipped → flagged → all and `o` flips | |
| 124 | oldest/newest first, the title saying which; each row says the ask's state | |
| 125 | (waiting, building, shipped, closed) and stale or flagged in MCP's words. The | |
| 126 | rows keep MCP's order in every view, so the terminal and an agent read the same | |
| 127 | list; the web page `/asked` groups what needs you first instead | |
| 123 | 128 | (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read |
| 124 | 129 | only, shows a guest nothing, and names nothing of a placement in a repository |
| 125 | 130 | you cannot read. |
src/tui/screens/asked.ts +54 −15
| @@ -6,11 +6,13 @@ | ||
| 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 | 8 | * signed-out face; `askedLines` still returns nothing for a guest. Read-only; `enter` on an ask hands over its web address. |
| 9 | * `s` cycles unshipped → flagged → all and `o` flips oldest/newest, with MCP | |
| 10 | * `asked`'s defaults, words and order (#7303) — not the page's grouping. | |
| 9 | 11 | */ |
| 10 | 12 | |
| 11 | 13 | import type { Database } from "bun:sqlite"; |
| 12 | 14 | import { NOT_PLACED_YET } from "../../actions/ask_state_words.ts"; |
| 13 | import { askedIn, askedRepos, askedTier, outcomeText, type Asked } from "../../core/asked.ts"; | |
| 15 | import { askedIn, askedRepos, outcomeText, type Asked, type AskedOrder, type AskedShow } from "../../core/asked.ts"; | |
| 14 | 16 | import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts"; |
| 15 | 17 | import type { User } from "../../core/users.ts"; |
| 16 | 18 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -60,30 +62,47 @@ function thoughtLine(t: StreamThought): AskedLine { | ||
| 60 | 62 | }; |
| 61 | 63 | } |
| 62 | 64 | |
| 63 | /** The words the page's badge uses, so the two doors agree. */ | |
| 64 | const OUTCOME_WORD: Record<string, string> = { unplaced: "waiting", landed: "landed", failed: "failed", parked: "parked", building: "building", closed: "closed" }; | |
| 65 | ||
| 65 | /** The state, then stale or flagged, in MCP `asked`'s words; the outcome is the secondary line. */ | |
| 66 | 66 | function askLine(a: Asked): AskedLine { |
| 67 | const word = a.doneBy.length > 0 ? "done on main" : a.flagged ? "nothing built" : (OUTCOME_WORD[a.outcome.kind] ?? a.outcome.kind); | |
| 68 | 67 | return { |
| 69 | 68 | title: plain(a.issue.title), |
| 70 | 69 | ...toneOf(a.flagged, a.state), |
| 71 | 70 | secondary: `${plain(outcomeText(a))} · ${a.repo.ownerName}/${a.repo.name} #${a.issue.number}`, |
| 72 | meta: metaOf(word, relTime(a.askedAt)), | |
| 71 | meta: metaOf(a.state, a.flagged ? "flagged" : a.stale ? "stale" : "", relTime(a.askedAt)), | |
| 73 | 72 | path: `/${a.repo.ownerName}/${a.repo.name}/issues/${a.issue.number}`, |
| 74 | 73 | }; |
| 75 | 74 | } |
| 76 | 75 | |
| 76 | /** What MCP `asked` takes, and its defaults. */ | |
| 77 | export interface AskedView { | |
| 78 | readonly show: AskedShow; | |
| 79 | readonly order: AskedOrder; | |
| 80 | } | |
| 81 | ||
| 82 | export const DEFAULT_VIEW: AskedView = { show: "unshipped", order: "oldest" }; | |
| 83 | ||
| 84 | const SHOW_CYCLE: readonly AskedShow[] = ["unshipped", "flagged", "all"]; | |
| 85 | ||
| 86 | const SHOW_WORDS: Record<AskedShow, string> = { unshipped: "not shipped", flagged: "flagged", all: "all" }; | |
| 87 | ||
| 88 | /** The choice in words, for the title: "not shipped, oldest first". */ | |
| 89 | export const viewWords = (v: AskedView): string => `${SHOW_WORDS[v.show]}, ${v.order} first`; | |
| 90 | ||
| 91 | export const nextShow = (show: AskedShow): AskedShow => | |
| 92 | SHOW_CYCLE[(SHOW_CYCLE.indexOf(show) + 1) % SHOW_CYCLE.length]!; | |
| 93 | ||
| 77 | 94 | /** The thoughts, then the asks, as rows. A guest has none of either. */ |
| 78 | export function askedLines(db: Database, viewer: User | undefined): { thoughts: AskedLine[]; asks: AskedLine[] } { | |
| 95 | export function askedLines( | |
| 96 | db: Database, | |
| 97 | viewer: User | undefined, | |
| 98 | view: AskedView = DEFAULT_VIEW, | |
| 99 | ): { thoughts: AskedLine[]; asks: AskedLine[] } { | |
| 79 | 100 | if (viewer === undefined) return { thoughts: [], asks: [] }; |
| 80 | 101 | const repos = askedRepos(db, viewer, null); |
| 81 | 102 | return { |
| 82 | 103 | thoughts: thoughtStream(db, viewer, { limit: 50 }).map(thoughtLine), |
| 83 | // Needs the owner first, then in progress; the sort is stable, so each keeps oldest first. | |
| 84 | asks: askedIn(db, repos, { show: "unshipped", order: "oldest" }) | |
| 85 | .sort((x, y) => askedTier(x) - askedTier(y)) | |
| 86 | .map(askLine), | |
| 104 | // MCP's order, not /asked's needs-the-owner grouping: the title names the order, so the rows keep it. | |
| 105 | asks: askedIn(db, repos, view).map(askLine), | |
| 87 | 106 | }; |
| 88 | 107 | } |
| 89 | 108 | |
| @@ -91,24 +110,34 @@ export function askedScreen(f: Session, ctx: Ctx): Screen { | ||
| 91 | 110 | const state = new ListState(); |
| 92 | 111 | let lines: AskedLine[] = []; |
| 93 | 112 | let thoughtCount = 0; |
| 113 | let view = DEFAULT_VIEW; | |
| 94 | 114 | const open = (index: number): void => { |
| 95 | 115 | const line = lines[index]; |
| 96 | 116 | if (line?.path !== undefined) openOnWeb(f, ctx, line.path, "That ask"); |
| 97 | 117 | }; |
| 98 | 118 | const read = (): void => { |
| 99 | const got = askedLines(f.db, f.user); | |
| 119 | const got = askedLines(f.db, f.user, view); | |
| 100 | 120 | lines = [...got.thoughts, ...got.asks]; |
| 101 | 121 | thoughtCount = got.thoughts.length; |
| 102 | 122 | state.clamp(lines.length); |
| 103 | 123 | }; |
| 104 | 124 | return { |
| 105 | title: "Asked", | |
| 106 | keys: [binding(["j", "k"], "move"), binding(["enter"], "open"), binding(["r"], "refresh")], | |
| 125 | get title() { | |
| 126 | return `Asked · ${viewWords(view)}`; | |
| 127 | }, | |
| 128 | keys: [ | |
| 129 | binding(["j", "k"], "move"), | |
| 130 | binding(["enter"], "open"), | |
| 131 | binding(["s"], "show"), | |
| 132 | binding(["o"], "order"), | |
| 133 | binding(["r"], "refresh"), | |
| 134 | ], | |
| 107 | 135 | load: async () => read(), |
| 108 | 136 | render(area, s) { |
| 109 | 137 | const c = ctx.theme.c; |
| 110 | 138 | const [head, body] = split(area, "v", [len(1), fill(1)]); |
| 111 | const sentence = `${thoughtCount} thoughts · ${lines.length - thoughtCount} asks not shipped`; | |
| 139 | // The title names the choice; the head line only counts. | |
| 140 | const sentence = `${thoughtCount} thoughts · ${lines.length - thoughtCount} asks`; | |
| 112 | 141 | s.text(head!.x, head!.y, truncate(sentence, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w); |
| 113 | 142 | if (lines.length === 0) { |
| 114 | 143 | emptyState(s, body!, c, { |
| @@ -126,6 +155,16 @@ export function askedScreen(f: Session, ctx: Ctx): Screen { | ||
| 126 | 155 | open(state.index); |
| 127 | 156 | return true; |
| 128 | 157 | } |
| 158 | if (e.key === "s" || e.key === "o") { | |
| 159 | view = e.key === "s" | |
| 160 | ? { ...view, show: nextShow(view.show) } | |
| 161 | : { ...view, order: view.order === "oldest" ? "newest" : "oldest" }; | |
| 162 | state.index = 0; | |
| 163 | state.offset = 0; | |
| 164 | read(); | |
| 165 | ctx.invalidate(); | |
| 166 | return true; | |
| 167 | } | |
| 129 | 168 | if (e.key === "r") { |
| 130 | 169 | read(); |
| 131 | 170 | ctx.invalidate(); |
test/asked-outcomes.test.ts +2 −2
| @@ -184,8 +184,8 @@ test("the API, the MCP and the terminal carry the same outcome and sentence", as | ||
| 184 | 184 | expect(by("Plant tomatoes in the north bed").outcomeText).toBe("parked: the check failed twice on the same test."); |
| 185 | 185 | expect(by("Fix the gate").outcome).toMatchObject({ kind: "failed", by: "bot" }); |
| 186 | 186 | const lines = askedLines(app.db, getUserByName(app.db, "alice")!).asks; |
| 187 | expect(lines.map((l) => l.title)[0]).toBe("Plant tomatoes in the north bed"); | |
| 188 | expect(lines[0]!.secondary).toStartWith("parked: the check failed twice"); | |
| 187 | // Found by title: the terminal keeps MCP's order, not the page's grouping (#7303). | |
| 188 | expect(lines.find((l) => l.title === "Plant tomatoes in the north bed")?.secondary).toStartWith("parked: the check failed twice"); | |
| 189 | 189 | }); |
| 190 | 190 | |
| 191 | 191 | test("All lists every ask it counts, closed ones last", async () => { |
test/tui-asked-view.test.ts added +144
| @@ -0,0 +1,144 @@ | ||
| 1 | /** | |
| 2 | * The asked screen narrows as MCP `asked` does (#7303): `s` cycles | |
| 3 | * unshipped → flagged → all, `o` flips oldest/newest, the title says the | |
| 4 | * choice, and each row says its state and stale or flagged in MCP's words. | |
| 5 | */ | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 8 | import { tmpdir } from "node:os"; | |
| 9 | import { join } from "node:path"; | |
| 10 | import { createRepo } from "../src/actions/repos.ts"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { createIssue } from "../src/core/issues.ts"; | |
| 13 | import { createUser, type User } from "../src/core/users.ts"; | |
| 14 | import { tool } from "../src/http/mcp/tools/asked.ts"; | |
| 15 | import { createForgeApp } from "../src/tui/forge.ts"; | |
| 16 | import { askedLines, askedScreen, type AskedView } from "../src/tui/screens/asked.ts"; | |
| 17 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; | |
| 18 | import { quietLogs } from "./helpers/globals.ts"; | |
| 19 | import { settleApp } from "./helpers/tui.ts"; | |
| 20 | import { promote } from "./helpers/members.ts"; | |
| 21 | ||
| 22 | quietLogs(); | |
| 23 | ||
| 24 | const DAY = 86_400_000; | |
| 25 | let dir: string; | |
| 26 | let app: App; | |
| 27 | let alice: User; | |
| 28 | ||
| 29 | const ask = (repoId: string, title: string, daysAgo: number, kind: "issue" | "pull" = "issue", body = "") => { | |
| 30 | const r = createIssue(app.db, { repoId, authorId: alice.id, title, body, kind }); | |
| 31 | if (!r.ok) throw new Error(r.error); | |
| 32 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(new Date(Date.now() - daysAgo * DAY).toISOString(), r.value.id); | |
| 33 | return r.value; | |
| 34 | }; | |
| 35 | ||
| 36 | beforeAll(async () => { | |
| 37 | dir = await mkdtemp(join(tmpdir(), "forge-tui-asked-view-")); | |
| 38 | app = createApp({ dataDir: dir, port: 0 }); | |
| 39 | const u = await createUser(app.db, { | |
| 40 | origin: "seed", trusted: true, username: "alice", email: "[email protected]", | |
| 41 | password: "a-good-passphrase", adminIfFirst: true, | |
| 42 | }); | |
| 43 | if (!u.ok) throw new Error(u.error); | |
| 44 | alice = u.value; | |
| 45 | promote(app.db, alice); | |
| 46 | const repo = await createRepo(app, alice, { name: "garden", visibility: "public" }); | |
| 47 | if (!repo.ok) throw new Error(String(repo.error)); | |
| 48 | const id = repo.value.id; | |
| 49 | ask(id, "Flagged ask", 5); | |
| 50 | const shipped = ask(id, "Shipped ask", 4); | |
| 51 | const pull = ask(id, "Answers it", 4, "pull", `Closes #${shipped.number}`); | |
| 52 | app.db | |
| 53 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, merged_at) | |
| 54 | VALUES (?1, 'main', 'answer', 'a', 'b', ?2)`) | |
| 55 | .run(pull.id, new Date().toISOString()); | |
| 56 | app.db.query("UPDATE issue SET state = 'closed' WHERE id IN (?1, ?2)").run(shipped.id, pull.id); | |
| 57 | ask(id, "Fresh ask", 1); | |
| 58 | // Stale and not flagged: older than the stale window, with an open pull on it. | |
| 59 | const building = ask(id, "Building ask", 6); | |
| 60 | const draft = ask(id, "Works on it", 1, "pull", `Closes #${building.number}`); | |
| 61 | app.db | |
| 62 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) | |
| 63 | VALUES (?1, 'main', 'work', 'a', 'b')`) | |
| 64 | .run(draft.id); | |
| 65 | }); | |
| 66 | afterAll(async () => { | |
| 67 | app.db.close(); | |
| 68 | await rm(dir, { recursive: true, force: true }); | |
| 69 | }); | |
| 70 | ||
| 71 | const titles = (view?: AskedView) => askedLines(app.db, alice, view).asks.map((a) => a.title); | |
| 72 | ||
| 73 | /** What MCP `asked` answers for the same choice. */ | |
| 74 | const mcp = async (args: Record<string, string>) => { | |
| 75 | const answer = await tool.run({ app } as never, alice, args); | |
| 76 | if (!answer.ok) throw new Error(answer.message); | |
| 77 | return (answer.value as { asked: { title: string }[] }).asked.map((a) => a.title); | |
| 78 | }; | |
| 79 | ||
| 80 | test("the defaults are MCP's: unshipped, oldest first, in the same order", async () => { | |
| 81 | expect(titles()).toEqual(["Building ask", "Flagged ask", "Fresh ask"]); | |
| 82 | expect(titles()).toEqual(await mcp({})); | |
| 83 | }); | |
| 84 | ||
|
||
| 85 | test("each choice of show lists what MCP lists", async () => { | |
| 86 | expect(titles({ show: "flagged", order: "oldest" })).toEqual(["Flagged ask"]); | |
| 87 | expect(titles({ show: "flagged", order: "oldest" })).toEqual(await mcp({ show: "flagged" })); | |
| 88 | // Exactly MCP's order: /asked's grouping would put the shipped ask after the fresh one. | |
| 89 | expect(titles({ show: "all", order: "oldest" })).toEqual(["Building ask", "Flagged ask", "Shipped ask", "Fresh ask"]); | |
| 90 | expect(titles({ show: "all", order: "oldest" })).toEqual(await mcp({ show: "all" })); | |
| 91 | expect(titles({ show: "all", order: "newest" })).toEqual(["Fresh ask", "Shipped ask", "Flagged ask", "Building ask"]); | |
| 92 | expect(titles({ show: "all", order: "newest" })).toEqual(await mcp({ show: "all", order: "newest" })); | |
| 93 | }); | |
| 94 | ||
| 95 | test("newest first reverses them, as MCP does", async () => { | |
outdated
Add a case that asserts |
||
| 96 | expect(titles({ show: "unshipped", order: "newest" })).toEqual(["Fresh ask", "Flagged ask", "Building ask"]); | |
| 97 | expect(titles({ show: "unshipped", order: "newest" })).toEqual(await mcp({ order: "newest" })); | |
| 98 | }); | |
| 99 | ||
| 100 | test("a row says its state and marks it flagged, in MCP's words", () => { | |
| 101 | const rows = askedLines(app.db, alice, { show: "all", order: "oldest" }).asks; | |
| 102 | const meta = (t: string) => rows.find((r) => r.title === t)!.meta ?? ""; | |
| 103 | expect(meta("Flagged ask")).toStartWith("waiting flagged"); | |
| 104 | expect(meta("Fresh ask")).toStartWith("waiting"); | |
| 105 | expect(meta("Fresh ask")).not.toContain("flagged"); | |
| 106 | expect(meta("Fresh ask")).not.toContain("stale"); | |
| 107 | expect(meta("Shipped ask")).toStartWith("shipped"); | |
| 108 | expect(meta("Building ask")).toStartWith("building stale"); | |
| 109 | expect(meta("Building ask")).not.toContain("flagged"); | |
| 110 | }); | |
| 111 | ||
| 112 | test("s cycles unshipped, flagged, all and o flips the order, the title saying which", async () => { | |
| 113 | const terminal = headlessTerminal(120, 30); | |
| 114 | const { app: tui, session } = createForgeApp( | |
| 115 | app, | |
| 116 | { sshUsername: "alice", remoteAddress: "127.0.0.1", fingerprint: "SHA256:test", user: alice }, | |
| 117 | { terminal, exitHooks: false }, | |
| 118 | ); | |
| 119 | tui.start(); | |
| 120 | tui.ctx.push(askedScreen(session, tui.ctx)); | |
| 121 | await settleApp(tui); | |
| 122 | const screen = () => tui.screen.toText(); | |
| 123 | const press = async (key: string) => { | |
| 124 | terminal.feed(key); | |
| 125 | await settleApp(tui); | |
| 126 | }; | |
| 127 | expect(screen()).toContain("Asked · not shipped, oldest first"); | |
| 128 | expect(screen()).toContain("· 3 asks"); | |
| 129 | expect(screen()).not.toContain("asks not shipped"); | |
| 130 | expect(screen()).not.toContain("Shipped ask"); | |
| 131 | expect(screen().indexOf("Flagged ask")).toBeLessThan(screen().indexOf("Fresh ask")); | |
| 132 | await press("s"); | |
| 133 | expect(screen()).toContain("flagged, oldest first"); | |
| 134 | expect(screen()).toContain("Flagged ask"); | |
| 135 | expect(screen()).not.toContain("Fresh ask"); | |
| 136 | await press("s"); | |
| 137 | expect(screen()).toContain("all, oldest first"); | |
| 138 | expect(screen()).toContain("Shipped ask"); | |
| 139 | await press("s"); | |
| 140 | await press("o"); | |
| 141 | expect(screen()).toContain("not shipped, newest first"); | |
| 142 | expect(screen().indexOf("Fresh ask")).toBeLessThan(screen().indexOf("Flagged ask")); | |
| 143 | tui.stop?.(); | |
| 144 | }); |
new above
offline
This sort puts the owner-needs group first even for
allandnewest, so the order differs from MCP. Match MCP, or apply the tier sort only to the default view, and test it.