@a-t-h-i/bot-lobby 0.6.6 → 0.6.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (58) hide show
  1. package/README.md +183 -11
  2. package/package.json +1 -1
  3. package/prompts/master.md +21 -0
  4. package/prompts/panel.md +4 -0
  5. package/prompts/planner.md +5 -0
  6. package/prompts/pr-review.md +62 -0
  7. package/prompts/splitter.md +58 -0
  8. package/src/ask/dialog.ts +23 -7
  9. package/src/ask/state.ts +18 -3
  10. package/src/ask/tool.ts +9 -2
  11. package/src/ask/view.ts +8 -4
  12. package/src/classifier/instance.ts +6 -0
  13. package/src/classifier/knowledge.ts +158 -0
  14. package/src/classifier/review.ts +146 -0
  15. package/src/execution/agent-runner.ts +23 -1
  16. package/src/execution/fallback.ts +75 -0
  17. package/src/execution/workspace.ts +185 -0
  18. package/src/knowledge/edit.ts +109 -0
  19. package/src/knowledge/notes.ts +143 -0
  20. package/src/knowledge/selector.ts +1 -1
  21. package/src/knowledge/store.ts +16 -2
  22. package/src/lobby/ask.ts +42 -21
  23. package/src/lobby/feed.ts +7 -0
  24. package/src/lobby/issues.ts +1 -1
  25. package/src/lobby/knowledge.ts +179 -0
  26. package/src/lobby/layout.ts +99 -1
  27. package/src/lobby/mini.ts +159 -0
  28. package/src/lobby/planner.ts +209 -10
  29. package/src/lobby/pr-review.ts +360 -0
  30. package/src/lobby/pulls.ts +250 -0
  31. package/src/lobby/quickfix.ts +17 -3
  32. package/src/lobby/runtime.ts +142 -17
  33. package/src/lobby/split.ts +230 -0
  34. package/src/lobby/tabs/git.ts +162 -0
  35. package/src/lobby/tabs/knowledge.ts +135 -0
  36. package/src/lobby/tabs/plan.ts +3 -1
  37. package/src/lobby/tabs/tasks.ts +11 -2
  38. package/src/lobby/view.ts +450 -35
  39. package/src/master/master.ts +40 -14
  40. package/src/master/research.ts +1 -0
  41. package/src/pi/commands.ts +12 -7
  42. package/src/pi/events.ts +2 -0
  43. package/src/pi/master-fallback.ts +61 -0
  44. package/src/pi/model-support.ts +27 -3
  45. package/src/pi/plan-checklist.ts +42 -0
  46. package/src/pi/route.ts +2 -1
  47. package/src/pi/settings-ui.ts +104 -11
  48. package/src/pi/start-flags.ts +14 -0
  49. package/src/pi/start-task.ts +56 -5
  50. package/src/pi/tools.ts +4 -2
  51. package/src/schemas/configuration.ts +74 -11
  52. package/src/schemas/findings.ts +2 -0
  53. package/src/schemas/task.ts +15 -0
  54. package/src/state/backlog.ts +27 -4
  55. package/src/state/metrics.ts +2 -2
  56. package/src/state/persistence.ts +5 -5
  57. package/src/text.ts +38 -0
  58. package/src/workflow/workflow.ts +23 -2
@@ -0,0 +1,179 @@
1
+ /**
2
+ * The Knowledge tab's side of the disk: every agent's knowledge files, one
3
+ * file read as entries with the user's notes on them, and the edits the user
4
+ * can make — replace, add or delete an entry, edit the whole file — plus
5
+ * notes. Every write goes through the compactor's path, which archives the
6
+ * previous version first (`archive/<Agent>/…bak`), so an edit can be undone by
7
+ * hand. An entry is found again by its exact text; when the file changed on
8
+ * disk since the tab drew it, the edit is refused with a line saying so.
9
+ */
10
+ import { join } from "node:path";
11
+ import { compactKnowledgeFile } from "../knowledge/compactor.ts";
12
+ import { findEntry, insertAfter, parseEntries, removeEntry, replaceEntry, type KnowledgeEntry } from "../knowledge/edit.ts";
13
+ import { addNote, moveNotes, readNotes, removeNote, removeNotesOn, type KnowledgeNote } from "../knowledge/notes.ts";
14
+ import { AGENT_DIR_NAMES, KNOWLEDGE_FILES, knowledgeDir, type KnowledgeAgent } from "../knowledge/paths.ts";
15
+ import { readFirstExisting } from "../knowledge/store.ts";
16
+ import { dataRoot, readDataRoots } from "../state/project.ts";
17
+
18
+ /** What each knowledge file is, in words. */
19
+ export const FILE_LABELS: Record<string, string> = {
20
+ "knowledge.md": "Knowledge",
21
+ "decisions.md": "Decisions",
22
+ "completed-tasks.md": "Completed tasks",
23
+ "standards.md": "Standards",
24
+ "design-language.md": "Design language",
25
+ "engineering-standards.md": "Engineering standards",
26
+ "testing-standards.md": "Testing standards",
27
+ };
28
+
29
+ /** Who the agents are, as the lobby names them. */
30
+ export const AGENT_LABELS: Record<KnowledgeAgent, string> = { master: "Master (oracle)", designer: "Designer", backend: "Backend", qa: "QA" };
31
+
32
+ export interface KnowledgeFileInfo {
33
+ agent: KnowledgeAgent;
34
+ file: string;
35
+ label: string;
36
+ chars: number;
37
+ /** Past the compaction threshold: the Master should compact it. */
38
+ over: boolean;
39
+ notes: number;
40
+ }
41
+
42
+ export interface KnowledgeView extends KnowledgeFileInfo {
43
+ content: string;
44
+ entries: KnowledgeEntry[];
45
+ /** Notes on entries this file has. */
46
+ attached: KnowledgeNote[];
47
+ /** Notes whose entry has changed or gone: kept, shown apart. */
48
+ detached: KnowledgeNote[];
49
+ }
50
+
51
+ /** An entry picked in the tab: its text, and which of several identical ones it is. */
52
+ export interface EntryRef {
53
+ text: string;
54
+ occurrence: number;
55
+ }
56
+
57
+ export interface KnowledgeDeps {
58
+ root: string;
59
+ configDir: string;
60
+ /** Characters past which a file is over its compaction threshold. */
61
+ threshold: () => number;
62
+ /** Backups of a file kept in the archive. */
63
+ backups: () => number;
64
+ sessionId?: () => string | undefined;
65
+ now?: () => Date;
66
+ }
67
+
68
+ const CHANGED = "that entry changed on disk since it was drawn — r reloads the file";
69
+
70
+ export class KnowledgeBook {
71
+ private readonly deps: KnowledgeDeps;
72
+
73
+ constructor(deps: KnowledgeDeps) {
74
+ this.deps = deps;
75
+ }
76
+
77
+ private get data(): string {
78
+ return dataRoot(this.deps.root, this.deps.configDir);
79
+ }
80
+
81
+ private read(agent: KnowledgeAgent, file: string): string {
82
+ return readFirstExisting(readDataRoots(this.deps.root, this.deps.configDir).map((root) => join(knowledgeDir(root, agent), file)));
83
+ }
84
+
85
+ /** Every agent's files in the order the tab lists them, with their size and notes. */
86
+ files(): KnowledgeFileInfo[] {
87
+ const notes = readNotes(this.data);
88
+ return (Object.keys(AGENT_DIR_NAMES) as KnowledgeAgent[]).flatMap((agent) =>
89
+ KNOWLEDGE_FILES[agent].map((file) => {
90
+ const chars = this.read(agent, file).length;
91
+ return { agent, file, label: FILE_LABELS[file] ?? file, chars, over: chars > this.deps.threshold(), notes: notes.filter((note) => note.agent === agent && note.file === file).length };
92
+ }),
93
+ );
94
+ }
95
+
96
+ /** One file as entries with their notes. */
97
+ open(agent: KnowledgeAgent, file: string): KnowledgeView {
98
+ const content = this.read(agent, file);
99
+ const entries = parseEntries(content);
100
+ const notes = readNotes(this.data).filter((note) => note.agent === agent && note.file === file);
101
+ const known = new Set(entries.map((entry) => entry.text));
102
+ return {
103
+ agent,
104
+ file,
105
+ label: FILE_LABELS[file] ?? file,
106
+ chars: content.length,
107
+ over: content.length > this.deps.threshold(),
108
+ notes: notes.length,
109
+ content,
110
+ entries,
111
+ attached: notes.filter((note) => known.has(note.entry)),
112
+ detached: notes.filter((note) => !known.has(note.entry)),
113
+ };
114
+ }
115
+
116
+ /** Write a file's new content with the previous one archived; the notice says where it went. */
117
+ private write(agent: KnowledgeAgent, file: string, content: string): string {
118
+ compactKnowledgeFile({ dataRoot: this.data, agent, file, content, backupCount: this.deps.backups(), ...(this.deps.now ? { now: this.deps.now() } : {}) });
119
+ return `saved ${agent}/${file} — the version before is in archive/${AGENT_DIR_NAMES[agent]}`;
120
+ }
121
+
122
+ /** Replace one entry with `text`, exactly as written; its notes move with it. */
123
+ edit(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
124
+ const content = this.read(agent, file);
125
+ const entry = findEntry(content, ref.text, ref.occurrence);
126
+ if (!entry) return CHANGED;
127
+ if (!text.trim()) return "nothing to save — d d deletes an entry, esc cancels";
128
+ const next = replaceEntry(content, entry, text);
129
+ if (next.trim() === content.trim()) return "nothing changed";
130
+ const notice = this.write(agent, file, next);
131
+ const moved = moveNotes(this.data, agent, file, entry.text, text.replace(/\s+$/, ""), this.deps.now?.());
132
+ return `${notice}${moved > 0 ? `; ${moved} note${moved === 1 ? "" : "s"} stayed with it` : ""}`;
133
+ }
134
+
135
+ /** Add a bullet after an entry (at the end when there is none). */
136
+ add(agent: KnowledgeAgent, file: string, after: EntryRef | undefined, text: string): string {
137
+ const content = this.read(agent, file);
138
+ const entry = after ? findEntry(content, after.text, after.occurrence) : undefined;
139
+ if (after && !entry) return CHANGED;
140
+ const next = insertAfter(content, entry, text);
141
+ if (next === content) return "nothing to add";
142
+ return this.write(agent, file, next);
143
+ }
144
+
145
+ /** Delete an entry, and the notes on it. */
146
+ remove(agent: KnowledgeAgent, file: string, ref: EntryRef): string {
147
+ const content = this.read(agent, file);
148
+ const entry = findEntry(content, ref.text, ref.occurrence);
149
+ if (!entry) return CHANGED;
150
+ const notice = this.write(agent, file, removeEntry(content, entry));
151
+ const gone = removeNotesOn(this.data, agent, file, entry.text, this.deps.now?.());
152
+ return `${notice}${gone > 0 ? `; its ${gone} note${gone === 1 ? "" : "s"} went with it` : ""}`;
153
+ }
154
+
155
+ /** Replace a whole file (edited in pi's editor). */
156
+ replaceFile(agent: KnowledgeAgent, file: string, text: string): string {
157
+ const content = this.read(agent, file);
158
+ if (text.trim() === content.trim()) return "nothing changed";
159
+ return this.write(agent, file, text);
160
+ }
161
+
162
+ /** Leave a note on an entry: every agent that reads this knowledge reads it under the entry. */
163
+ comment(agent: KnowledgeAgent, file: string, ref: EntryRef, text: string): string {
164
+ const entry = findEntry(this.read(agent, file), ref.text, ref.occurrence);
165
+ if (!entry) return CHANGED;
166
+ try {
167
+ addNote(this.data, { agent, file, entry: entry.text, text, ...(this.deps.sessionId?.() ? { by: this.deps.sessionId!()! } : {}) }, this.deps.now?.());
168
+ } catch (error) {
169
+ return (error as Error).message;
170
+ }
171
+ return "note saved — every agent reads it under this entry";
172
+ }
173
+
174
+ /** Take a note back. */
175
+ unnote(id: string): string {
176
+ removeNote(this.data, id, this.deps.now?.());
177
+ return "note removed";
178
+ }
179
+ }
@@ -91,6 +91,9 @@ export function wrapHanging(lead: string, text: string, width: number): string[]
91
91
  return body.map((line, index) => `${index === 0 ? lead : " ".repeat(indent)}${line}`);
92
92
  }
93
93
 
94
+ /** The mark before a git branch name, in the lobby's title and a task's details. */
95
+ export const BRANCH_GLYPH = "⎇";
96
+
94
97
  /** `── Title ───────` spanning `width`; `right` sits at the far end when it fits. */
95
98
  export function rule(width: number, title = "", theme?: LobbyTheme, right = "", color: LobbyColor = "borderMuted"): string {
96
99
  if (width <= 0) return "";
@@ -268,6 +271,87 @@ export function scrollThumb(total: number, rows: number, start: number): { from:
268
271
  return { from, to: from + size };
269
272
  }
270
273
 
274
+ /** Lines a page moves a pane: its rows less one, so a line of context carries over. */
275
+ export function pageStep(rows: number): number {
276
+ return Math.max(1, rows - 1);
277
+ }
278
+
279
+ /** How many pages a pane showing `rows` lines at a time holds out of `total`. */
280
+ export function pageCount(total: number, rows: number): number {
281
+ return total <= rows ? 1 : 1 + Math.ceil((total - rows) / pageStep(rows));
282
+ }
283
+
284
+ /**
285
+ * The page a pane showing lines from `start` is on, counted so that the top is
286
+ * page 1 and the bottom the last, and a page's worth of scrolling from either
287
+ * end lands exactly on the next number: a log that opens at its newest line
288
+ * and a detail that opens at its first both step through the same numbers.
289
+ */
290
+ export function pageOf(total: number, rows: number, start: number): number {
291
+ const end = Math.max(0, total - rows);
292
+ const behind = Math.ceil(Math.max(0, end - Math.max(0, start)) / pageStep(rows));
293
+ return Math.max(1, pageCount(total, rows) - behind);
294
+ }
295
+
296
+ /** One piece of the pager: a button, where the pane is, or what sits between them. */
297
+ export interface PagerPart {
298
+ text: string;
299
+ role: "up" | "down" | "where" | "between";
300
+ }
301
+
302
+ /** Fewest columns of border kept either side of the pager, beside its two spaces. */
303
+ const PAGER_MARGIN = 3;
304
+
305
+ /**
306
+ * The pager in a scrolling pane's bottom border, as words a newcomer can read:
307
+ * `▲ prev · page 2/5 · next ▼`, shortening to `▲ prev · 2/5 · next ▼` and
308
+ * `▲ 2/5 ▼` as the pane narrows, and to nothing when even that will not fit
309
+ * in `room` columns. The page number is padded to the width of the page count,
310
+ * so the buttons stay where they are as it changes and a click can be traced
311
+ * from the pane's size alone.
312
+ */
313
+ export function pager(total: number, rows: number, start: number, room: number): PagerPart[] | undefined {
314
+ const pages = pageCount(total, rows);
315
+ const count = String(pages);
316
+ const of = `${String(pageOf(total, rows, start)).padStart(count.length)}/${pages}`;
317
+ const between = (text: string): PagerPart => ({ text, role: "between" });
318
+ const up = (text: string): PagerPart => ({ text, role: "up" });
319
+ const down = (text: string): PagerPart => ({ text, role: "down" });
320
+ const where = (text: string): PagerPart => ({ text, role: "where" });
321
+ const variants: PagerPart[][] = [
322
+ [up("▲ prev"), between(" · "), where(`page ${of}`), between(" · "), down("next ▼")],
323
+ [up("▲ prev"), between(" · "), where(of), between(" · "), down("next ▼")],
324
+ [up("▲"), between(" "), where(of), between(" "), down("▼")],
325
+ ];
326
+ return variants.find((parts) => parts.reduce((sum, part) => sum + textWidth(part.text), 0) <= room);
327
+ }
328
+
329
+ /** Columns a box of `width` leaves for the pager's text: two spaces around it and a margin of border on each side. */
330
+ function pagerRoom(width: number): number {
331
+ return width - 2 - 2 - PAGER_MARGIN;
332
+ }
333
+
334
+ /**
335
+ * Which button a column of a scrollable pane's bottom border is: -1 (a page
336
+ * up) or 1 (a page down), or undefined. The buttons take their words and the
337
+ * space beside them, so a click a little off still lands.
338
+ */
339
+ export function pagerButton(width: number, column: number, total: number, rows: number): -1 | 1 | undefined {
340
+ const parts = pager(total, rows, 0, pagerRoom(width));
341
+ if (!parts) return undefined;
342
+ const size = parts.reduce((sum, part) => sum + textWidth(part.text), 0);
343
+ // The text ends one space before the corner: `… next ▼ ╯`.
344
+ const origin = width - 2 - size;
345
+ let at = origin;
346
+ for (const part of parts) {
347
+ const from = part.role === "up" ? origin - 1 : at;
348
+ const to = part.role === "down" ? width - 1 : at + textWidth(part.text);
349
+ if (column >= from && column < to) return part.role === "up" ? -1 : part.role === "down" ? 1 : undefined;
350
+ at += textWidth(part.text);
351
+ }
352
+ return undefined;
353
+ }
354
+
271
355
  /**
272
356
  * A rounded panel exactly `width` × `height`: the title set into the top
273
357
  * border, `content` inside with one column of padding, extra lines cut and
@@ -291,7 +375,21 @@ export function box(width: number, height: number, content: readonly string[], o
291
375
  const thumb = options.scroll ? scrollThumb(options.scroll.total, height - 2, options.scroll.start) : undefined;
292
376
  const rightEdge = (row: number) => (thumb && row >= thumb.from && row < thumb.to ? paint(theme, focused ? "accent" : "muted", "┃") : edge("│"));
293
377
  const rows = fill(content, height - 2).map((line, row) => `${edge("│")} ${fit(line, inner)} ${rightEdge(row)}`);
294
- return [top, ...rows, `${edge("╰")}${edge("─".repeat(width - 2))}${edge("╯")}`];
378
+ // A pane with more lines than rows carries a pager in its bottom border; `pagerButton` finds its buttons again for clicks.
379
+ const parts = thumb && options.scroll ? pager(options.scroll.total, height - 2, options.scroll.start, pagerRoom(width)) : undefined;
380
+ const atTop = (options.scroll?.start ?? 0) <= 0;
381
+ const atEnd = options.scroll ? options.scroll.start >= options.scroll.total - (height - 2) : true;
382
+ const paintPart = (part: PagerPart): string => {
383
+ if (part.role === "between") return paint(theme, "dim", part.text);
384
+ if (part.role === "where") return paint(theme, focused ? "text" : "muted", part.text);
385
+ // A button that cannot go further is dimmed: the pane is at that end.
386
+ if (part.role === "up" ? atTop : atEnd) return paint(theme, "dim", part.text);
387
+ return bold(theme, paint(theme, focused ? "accent" : "text", part.text));
388
+ };
389
+ const pagerText = parts ? ` ${parts.map(paintPart).join("")} ` : "";
390
+ const pagerWidth = parts ? parts.reduce((sum, part) => sum + textWidth(part.text), 0) + 2 : 0;
391
+ const bottom = `${edge("╰")}${edge("─".repeat(width - 2 - pagerWidth))}${pagerText}${edge("╯")}`;
392
+ return [top, ...rows, bottom];
295
393
  }
296
394
 
297
395
  /** Lay boxes out side by side, each already exactly its width and the same height. */
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The one-line status shown under pi's editor while the lobby is hidden: a
3
+ * small graphic of where the work stands — the task's plan steps (or its stage
4
+ * before there is a plan), the planning rounds, the quick fix in hand — or a
5
+ * quiet "idle". Pure: everything it shows arrives as input.
6
+ */
7
+ import { truncateToWidth } from "@earendil-works/pi-tui";
8
+ import type { AgentRun } from "../schemas/findings.ts";
9
+ import type { Task, TaskState } from "../schemas/task.ts";
10
+ import { planChecklist } from "../pi/plan-checklist.ts";
11
+ import { agentName } from "../pi/run-summary.ts";
12
+ import { paint, type LobbyTheme } from "./layout.ts";
13
+
14
+ export interface MiniPlanning {
15
+ /** A round is running. */
16
+ busy: boolean;
17
+ /** The round in hand, and the limit (0 = unlimited). */
18
+ round: number;
19
+ limit: number;
20
+ /** Questions waiting for the user. */
21
+ questions: number;
22
+ /** The oracle's draft is ready to save. */
23
+ ready: boolean;
24
+ /** The plan was saved as a pending task. */
25
+ saved: boolean;
26
+ }
27
+
28
+ export interface MiniQuickFix {
29
+ title: string;
30
+ running: boolean;
31
+ queued: number;
32
+ }
33
+
34
+ export interface MiniInput {
35
+ task?: Task;
36
+ runs: readonly AgentRun[];
37
+ planning?: MiniPlanning;
38
+ quickfix?: MiniQuickFix;
39
+ /** The key that opens the lobby, as shown. */
40
+ key: string;
41
+ }
42
+
43
+ /** A full-workflow task's stages, in order, for the strip shown before its plan has steps. */
44
+ const STAGES: readonly TaskState[] = ["clarifying", "scouting", "synthesizing", "awaiting_approval", "planning", "implementing", "reviewing"];
45
+
46
+ /** Widest step bar; longer plans share its cells. */
47
+ const MAX_CELLS = 12;
48
+
49
+ /** `■■◐□□`: the plan's steps done, current and to do, squeezed into at most `MAX_CELLS` cells. */
50
+ export function stepBar(statuses: readonly ("done" | "current" | "pending")[]): string {
51
+ if (statuses.length === 0) return "";
52
+ const cells = Math.min(statuses.length, MAX_CELLS);
53
+ const out: string[] = [];
54
+ for (let cell = 0; cell < cells; cell += 1) {
55
+ const from = Math.floor((cell * statuses.length) / cells);
56
+ const to = Math.max(from + 1, Math.floor(((cell + 1) * statuses.length) / cells));
57
+ const slice = statuses.slice(from, to);
58
+ out.push(slice.every((status) => status === "done") ? "■" : slice.some((status) => status === "current") ? "◐" : slice.some((status) => status === "done") ? "◐" : "□");
59
+ }
60
+ return out.join("");
61
+ }
62
+
63
+ /** `●●◐○○○○`: how far along the workflow a task without a plan yet is. */
64
+ export function stageBar(state: TaskState): string {
65
+ const at = STAGES.indexOf(state);
66
+ if (at < 0) return "";
67
+ return STAGES.map((_stage, index) => (index < at ? "●" : index === at ? "◐" : "○")).join("");
68
+ }
69
+
70
+ /** `■■□□□` for `round` of `limit`, or empty without a limit. */
71
+ function roundBar(round: number, limit: number): string {
72
+ if (limit <= 0) return "";
73
+ const done = Math.min(limit, Math.max(0, round));
74
+ return "■".repeat(done) + "□".repeat(limit - done);
75
+ }
76
+
77
+ const STATE_WORDS: Partial<Record<TaskState, string>> = {
78
+ awaiting_approval: "awaiting your approval",
79
+ };
80
+
81
+ function taskSegment(input: MiniInput, theme?: LobbyTheme): string | undefined {
82
+ const task = input.task;
83
+ if (!task || task.state === "completed" || task.state === "abandoned") return undefined;
84
+ const steps = task.plan ? planChecklist(task.plan, input.runs) : [];
85
+ const done = steps.filter((step) => step.status === "done").length;
86
+ const bar = steps.length > 0 ? `${stepBar(steps.map((step) => step.status))} ${done}/${steps.length}` : stageBar(task.state);
87
+ const state = task.paused ? `${task.state} (paused)` : STATE_WORDS[task.state] ?? task.state;
88
+ const running = [...input.runs].reverse().find((run) => run.status === "running");
89
+ const doing = running ? ` · ${agentName(running)}${running.activity ? ` ${running.activity}` : ""}` : "";
90
+ const alert = task.state === "awaiting_approval" || task.paused;
91
+ return `${paint(theme, "accent", task.id)} ${paint(theme, "success", bar)} ${paint(theme, alert ? "warning" : "muted", state)}${paint(theme, "dim", doing)}`;
92
+ }
93
+
94
+ function planningSegment(planning: MiniPlanning | undefined, theme?: LobbyTheme): string | undefined {
95
+ if (!planning || planning.saved) return undefined;
96
+ const rounds = roundBar(planning.round, planning.limit);
97
+ const head = `${paint(theme, "accent", "planning")} ${paint(theme, "success", rounds || `round ${planning.round}`)}${rounds ? paint(theme, "muted", ` ${planning.round}/${planning.limit}`) : ""}`;
98
+ if (planning.questions > 0) return `${head} ${paint(theme, "warning", `● ${planning.questions} question${planning.questions === 1 ? "" : "s"} for you`)}`;
99
+ if (planning.busy) return `${head} ${paint(theme, "muted", "the panel is thinking")}`;
100
+ if (planning.ready) return `${head} ${paint(theme, "success", "plan ready to save")}`;
101
+ return head;
102
+ }
103
+
104
+ function quickFixSegment(quickfix: MiniQuickFix | undefined, theme?: LobbyTheme): string | undefined {
105
+ if (!quickfix) return undefined;
106
+ const queued = quickfix.queued > 0 ? ` +${quickfix.queued} queued` : "";
107
+ return `${paint(theme, "accent", "quick fix")} ${paint(theme, "success", quickfix.running ? "◐" : "○")} ${paint(theme, "muted", quickfix.title)}${paint(theme, "dim", queued)}`;
108
+ }
109
+
110
+ /** The status line, at most `width` columns. */
111
+ export function miniLine(input: MiniInput, width: number, theme?: LobbyTheme): string {
112
+ if (width < 12) return "";
113
+ const segments = [taskSegment(input, theme), planningSegment(input.planning, theme), quickFixSegment(input.quickfix, theme)].filter((segment): segment is string => Boolean(segment));
114
+ const body = segments.length > 0 ? segments.join(paint(theme, "dim", " │ ")) : paint(theme, "dim", "idle");
115
+ const hint = paint(theme, "dim", ` ${input.key} opens`);
116
+ const line = ` ${paint(theme, "accent", "◆ bot-lobby")} ${body}`;
117
+ return truncateToWidth(`${line}${hint}`, width, "…");
118
+ }
119
+
120
+ /** An agent at work right now, for the lobby's bottom-line indicator. */
121
+ export interface WorkingAgent {
122
+ name: string;
123
+ activity?: string;
124
+ /** Epoch ms it started, for its elapsed time. */
125
+ since?: number;
126
+ }
127
+
128
+ /** The subagents running now: task runs still going, and the quick fix in hand. */
129
+ export function workingAgents(runs: readonly AgentRun[], quickFix?: { startedAt?: number }): WorkingAgent[] {
130
+ const agents: WorkingAgent[] = [];
131
+ for (const run of runs) {
132
+ if (run.status !== "running") continue;
133
+ const since = Date.parse(run.startedAt);
134
+ agents.push({ name: agentName(run), ...(run.activity ? { activity: run.activity } : {}), ...(Number.isFinite(since) ? { since } : {}) });
135
+ }
136
+ if (quickFix) agents.push({ name: "QUICK FIX", ...(quickFix.startedAt ? { since: quickFix.startedAt } : {}) });
137
+ return agents;
138
+ }
139
+
140
+ function elapsed(ms: number): string {
141
+ const seconds = Math.max(0, Math.floor(ms / 1000));
142
+ return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m`;
143
+ }
144
+
145
+ /**
146
+ * `◐ DESIGN editing 2m · DEV running 40s`, at most `width` columns: it gives up
147
+ * the activity and time, then the names, then shows only a count, and nothing
148
+ * when even that does not fit or nothing is working. `spin` is the spinner glyph.
149
+ */
150
+ export function agentsIndicator(agents: readonly WorkingAgent[], now: number, spin: string, width: number, theme?: LobbyTheme): string {
151
+ if (agents.length === 0) return "";
152
+ const full = agents.map((agent) => `${agent.name}${agent.activity ? ` ${agent.activity}` : ""}${agent.since ? ` ${elapsed(now - agent.since)}` : ""}`).join(" · ");
153
+ const names = [...new Set(agents.map((agent) => agent.name))].join(" · ");
154
+ const count = `${agents.length} agent${agents.length === 1 ? "" : "s"} working`;
155
+ for (const text of [full, names, count]) {
156
+ if (text.length + 2 <= width) return `${paint(theme, "accent", spin)} ${paint(theme, "muted", text)}`;
157
+ }
158
+ return "";
159
+ }