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

Merged opened this

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

Merged

mason/forge:mason/7303-the-terminal-s-asked-screen-narrows-to-u → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/7303-the-terminal-s-asked-screen-narrows-to-u was merged into main as 9e9892e.

Review

Sign in to review this change.
4 files changed +206 −18
docs/TOUR.md +6 −1
@@ -119,7 +119,12 @@ about why.
119119 ## Asked, review, a client's forge and the family
120120
121121 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
123128 (`j`/`k` move, `enter` gives the issue's web address, `r` refreshes). It is read
124129 only, shows a guest nothing, and names nothing of a placement in a repository
125130 you cannot read.
src/tui/screens/asked.ts +54 −15
@@ -6,11 +6,13 @@
66 * repository the viewer cannot read says so and names nothing of it.
77 * The palette offers the door only when signed in, so the screen has no
88 * 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.
911 */
1012
1113 import type { Database } from "bun:sqlite";
1214 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";
1416 import { thoughtStream, type StreamThought } from "../../core/thought_stream.ts";
1517 import type { User } from "../../core/users.ts";
1618 import type { Ctx, Screen } from "../app/app.ts";
@@ -60,30 +62,47 @@ function thoughtLine(t: StreamThought): AskedLine {
6062 };
6163 }
6264
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. */
6666 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);
6867 return {
6968 title: plain(a.issue.title),
7069 ...toneOf(a.flagged, a.state),
7170 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)),
7372 path: `/${a.repo.ownerName}/${a.repo.name}/issues/${a.issue.number}`,
7473 };
7574 }
7675
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
7794 /** 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[] } {
79100 if (viewer === undefined) return { thoughts: [], asks: [] };
80101 const repos = askedRepos(db, viewer, null);
81102 return {
82103 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),
87106 };
88107 }
outdated

This sort puts the owner-needs group first even for all and newest, so the order differs from MCP. Match MCP, or apply the tier sort only to the default view, and test it.

89108
@@ -91,24 +110,34 @@ export function askedScreen(f: Session, ctx: Ctx): Screen {
91110 const state = new ListState();
92111 let lines: AskedLine[] = [];
93112 let thoughtCount = 0;
113 let view = DEFAULT_VIEW;
94114 const open = (index: number): void => {
95115 const line = lines[index];
96116 if (line?.path !== undefined) openOnWeb(f, ctx, line.path, "That ask");
97117 };
98118 const read = (): void => {
99 const got = askedLines(f.db, f.user);
119 const got = askedLines(f.db, f.user, view);
100120 lines = [...got.thoughts, ...got.asks];
101121 thoughtCount = got.thoughts.length;
102122 state.clamp(lines.length);
103123 };
104124 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 ],
107135 load: async () => read(),
108136 render(area, s) {
109137 const c = ctx.theme.c;
110138 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`;
112141 s.text(head!.x, head!.y, truncate(sentence, head!.w), style(c.mutedFg, c.bg), head!.x + head!.w);
113142 if (lines.length === 0) {
114143 emptyState(s, body!, c, {
@@ -126,6 +155,16 @@ export function askedScreen(f: Session, ctx: Ctx): Screen {
126155 open(state.index);
127156 return true;
128157 }
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 }
129168 if (e.key === "r") {
130169 read();
131170 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
184184 expect(by("Plant tomatoes in the north bed").outcomeText).toBe("parked: the check failed twice on the same test.");
185185 expect(by("Fix the gate").outcome).toMatchObject({ kind: "failed", by: "bot" });
186186 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");
189189 });
190190
191191 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

.sort() hides the order difference between the two doors. Compare the exact order, or state the intended difference in a test.

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 stale appears in a row, so the stale branch in askLine is covered.

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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs