@a-t-h-i/bot-lobby 0.3.0 → 0.5.0

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 (61) hide show
  1. package/README.md +266 -35
  2. package/package.json +1 -1
  3. package/prompts/backend.md +46 -1
  4. package/prompts/designer.md +94 -15
  5. package/prompts/master.md +70 -1
  6. package/prompts/panel.md +39 -0
  7. package/prompts/planner.md +64 -0
  8. package/prompts/qa.md +35 -2
  9. package/prompts/quickfix.md +41 -0
  10. package/prompts/researcher.md +6 -0
  11. package/prompts/reviewer.md +16 -0
  12. package/prompts/scout.md +11 -2
  13. package/prompts/worker.md +35 -2
  14. package/src/desk/client-extension.ts +101 -0
  15. package/src/desk/desk.ts +249 -0
  16. package/src/desk/ipc.ts +178 -0
  17. package/src/desk/session.ts +214 -0
  18. package/src/execution/agent-runner.ts +165 -14
  19. package/src/execution/pi-runner.ts +376 -65
  20. package/src/index.ts +7 -0
  21. package/src/lobby/feed.ts +253 -0
  22. package/src/lobby/issues.ts +227 -0
  23. package/src/lobby/layout.ts +174 -0
  24. package/src/lobby/planner.ts +474 -0
  25. package/src/lobby/quickfix.ts +227 -0
  26. package/src/lobby/runtime.ts +440 -0
  27. package/src/lobby/tabs/home.ts +164 -0
  28. package/src/lobby/tabs/issues.ts +72 -0
  29. package/src/lobby/tabs/metrics.ts +162 -0
  30. package/src/lobby/tabs/plan.ts +160 -0
  31. package/src/lobby/tabs/quickfix.ts +101 -0
  32. package/src/lobby/tabs/tasks.ts +209 -0
  33. package/src/lobby/view.ts +855 -0
  34. package/src/master/master.ts +21 -17
  35. package/src/master/research.ts +8 -7
  36. package/src/pi/activity.ts +165 -0
  37. package/src/pi/commands.ts +46 -59
  38. package/src/pi/events.ts +5 -2
  39. package/src/pi/expressions.ts +43 -12
  40. package/src/pi/kaomoji.ts +227 -0
  41. package/src/pi/mascot-art.ts +5 -15
  42. package/src/pi/model-support.ts +135 -0
  43. package/src/pi/run-summary.ts +172 -0
  44. package/src/pi/settings-ui.ts +162 -60
  45. package/src/pi/start-task.ts +63 -0
  46. package/src/pi/tools.ts +51 -8
  47. package/src/pi/ui.ts +151 -49
  48. package/src/pi/zen-large.ts +41 -4
  49. package/src/pi/zen-metrics.ts +47 -7
  50. package/src/pi/zen.ts +29 -8
  51. package/src/roles/reviewer.ts +24 -4
  52. package/src/roles/worker.ts +7 -1
  53. package/src/schemas/configuration.ts +177 -18
  54. package/src/schemas/findings.ts +26 -0
  55. package/src/schemas/task.ts +27 -1
  56. package/src/state/backlog.ts +106 -0
  57. package/src/state/comments.ts +136 -0
  58. package/src/state/metrics.ts +305 -0
  59. package/src/state/project.ts +9 -0
  60. package/src/text.ts +9 -0
  61. package/src/workflow/workflow.ts +161 -12
@@ -0,0 +1,164 @@
1
+ /**
2
+ * The Lobby tab: the zen scene of this session's task on top, then the
3
+ * conversation with the oracle (text only, no tool rows, no thinking), the
4
+ * activity log of plain-words steps from every agent, and the one place where
5
+ * thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
6
+ */
7
+ import type { Task } from "../../schemas/task.ts";
8
+ import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
9
+ import { bold, since, clock, columns, fill, italic, paint, rule, spinner, split, tail, wrap, wrapHanging, type LobbyColor, type LobbyTheme } from "../layout.ts";
10
+
11
+ export interface HomeInput {
12
+ task?: Task;
13
+ /** Draws the zen scene into at most `height` lines; absent when no task is active. */
14
+ scene?: (width: number, height: number) => string[];
15
+ chat: readonly ChatEntry[];
16
+ /** The oracle's reply while it streams. */
17
+ liveReply?: string;
18
+ activity: readonly ActivityEntry[];
19
+ thoughts: readonly ThoughtEntry[];
20
+ /** The Master's turn is running. */
21
+ busy: boolean;
22
+ /** Active tasks owned by other sessions, and saved plans waiting to start. */
23
+ others: number;
24
+ pending: number;
25
+ /** Lines of conversation scrollback. */
26
+ chatOffset: number;
27
+ tick: number;
28
+ now: number;
29
+ }
30
+
31
+ /** Wide terminals put the conversation and the activity log side by side. */
32
+ export const HOME_COLUMNS_MIN = 100;
33
+ /** Most lines the scene may take, and its share of the body. */
34
+ const SCENE_SHARE = 0.45;
35
+ const MAX_SCENE = 36;
36
+
37
+ const SOURCE_COLORS: Record<string, LobbyColor> = {
38
+ MASTER: "accent",
39
+ DEV: "success",
40
+ DESIGN: "mdHeading",
41
+ QA: "warning",
42
+ RESEARCH: "toolTitle",
43
+ "QUICK FIX": "mdCode",
44
+ ORACLE: "accent",
45
+ LOBBY: "muted",
46
+ };
47
+
48
+ export function sourceColor(source: string): LobbyColor {
49
+ return SOURCE_COLORS[source] ?? "muted";
50
+ }
51
+
52
+ const SOURCE_WIDTH = 9;
53
+
54
+ /** One activity line: time, who, what; a running step spins, a finished one is marked. */
55
+ export function activityLine(entry: ActivityEntry, width: number, tick: number, theme?: LobbyTheme): string {
56
+ const who = paint(theme, sourceColor(entry.source), entry.source.padEnd(SOURCE_WIDTH).slice(0, SOURCE_WIDTH));
57
+ const mark = entry.pending
58
+ ? paint(theme, "accent", spinner(tick))
59
+ : entry.kind === "error"
60
+ ? paint(theme, "error", "✗")
61
+ : entry.kind === "warning"
62
+ ? paint(theme, "warning", "!")
63
+ : entry.kind === "success"
64
+ ? paint(theme, "success", "✓")
65
+ : paint(theme, "dim", "·");
66
+ const text = entry.pending ? `${entry.text}…` : entry.text;
67
+ const body = entry.kind === "error" ? paint(theme, "error", text) : entry.pending ? text : paint(theme, "muted", text);
68
+ return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
69
+ }
70
+
71
+ function chatLead(role: ChatEntry["role"], theme?: LobbyTheme): string {
72
+ if (role === "you") return `${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `;
73
+ if (role === "oracle") return `${bold(theme, paint(theme, "toolTitle", "oracle"))} ${paint(theme, "dim", "▸")} `;
74
+ return `${paint(theme, "dim", "·")} `;
75
+ }
76
+
77
+ /** The conversation as wrapped lines, oldest first, a blank line between turns. */
78
+ export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
79
+ const lines: string[] = [];
80
+ const push = (role: ChatEntry["role"], text: string) => {
81
+ if (lines.length > 0) lines.push("");
82
+ const body = role === "note" ? paint(theme, "dim", text) : text;
83
+ lines.push(...wrapHanging(chatLead(role, theme), body, width));
84
+ };
85
+ for (const entry of chat) push(entry.role, entry.text);
86
+ if (live?.trim()) push("oracle", `${live.trim()} ${paint(theme, "accent", spinner(tick))}`);
87
+ else if (busy) push("oracle", paint(theme, "dim", `${spinner(tick)} working…`));
88
+ return lines;
89
+ }
90
+
91
+ function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
92
+ const lines = input.task
93
+ ? [paint(theme, "dim", "Nothing said yet. Type below to talk to the oracle about this task.")]
94
+ : [
95
+ bold(theme, "No task is running in this session."),
96
+ "",
97
+ "Type a request below and press enter to start one: the oracle scouts, proposes, plans and delegates.",
98
+ `Or plan it first with the whole planning panel in ${bold(theme, "3 Plan")}, or make a direct change in ${bold(theme, "4 Quick fix")}.`,
99
+ ];
100
+ const extra: string[] = [];
101
+ if (input.others > 0) extra.push(`${input.others} task${input.others === 1 ? " is" : "s are"} running in other sessions — see ${bold(theme, "2 Tasks")}.`);
102
+ if (input.pending > 0) extra.push(`${input.pending} planned task${input.pending === 1 ? "" : "s"} waiting to start — see ${bold(theme, "2 Tasks")}.`);
103
+ return [...lines.flatMap((line) => wrap(line, width)), ...(extra.length > 0 ? ["", ...extra.flatMap((line) => wrap(paint(theme, "muted", line), width))] : [])];
104
+ }
105
+
106
+ function conversation(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
107
+ const all = input.chat.length > 0 || input.liveReply || input.busy ? chatLines(input.chat, width, theme, input.liveReply, input.busy, input.tick) : emptyChat(input, width, theme);
108
+ const shown = tail(all, height, input.chatOffset);
109
+ return fill(shown, height);
110
+ }
111
+
112
+ function activity(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
113
+ if (input.activity.length === 0) return fill([paint(theme, "dim", "No activity yet.")], height);
114
+ const lines = input.activity.slice(-Math.max(height, 1)).map((entry) => activityLine(entry, width, input.tick, theme));
115
+ return fill(tail(lines, height), height);
116
+ }
117
+
118
+ /** The thought to show: a live one first, otherwise the newest. */
119
+ export function currentThought(thoughts: readonly ThoughtEntry[]): ThoughtEntry | undefined {
120
+ return [...thoughts].reverse().find((entry) => entry.live) ?? thoughts.at(-1);
121
+ }
122
+
123
+ function thinking(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
124
+ const thought = currentThought(input.thoughts);
125
+ const right = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : "";
126
+ const header = rule(width, "Thinking", theme, right);
127
+ if (height <= 1) return [header].slice(0, height);
128
+ if (!thought) return fill([header, paint(theme, "dim", "Thoughts from the oracle and every agent appear here, and only here.")], height);
129
+ const body = wrap(thought.text, width).map((line) => italic(theme, paint(theme, "dim", line)));
130
+ return fill([header, ...tail(body, height - 1)], height);
131
+ }
132
+
133
+ function sceneLines(input: HomeInput, width: number, height: number): string[] {
134
+ if (!input.task || !input.scene) return [];
135
+ const budget = Math.min(MAX_SCENE, Math.floor(height * SCENE_SHARE));
136
+ if (budget < 6) return [];
137
+ return input.scene(width, budget).slice(0, budget);
138
+ }
139
+
140
+ /** The whole tab, exactly `height` lines of `width` columns. */
141
+ export function renderHome(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
142
+ if (height <= 0) return [];
143
+ const scene = sceneLines(input, width, height);
144
+ const rest = height - scene.length;
145
+ const thinkHeight = rest >= 16 ? Math.max(4, Math.floor(rest * 0.25)) : rest >= 9 ? 3 : 0;
146
+ const main = rest - thinkHeight;
147
+ const chatTitle = input.task ? `Conversation · ${input.task.id}` : "Conversation";
148
+ let body: string[];
149
+ if (width >= HOME_COLUMNS_MIN) {
150
+ const [left, right] = split(width, 0.56);
151
+ const headers = columns([rule(left, chatTitle, theme)], [rule(right, "Activity", theme)], left, right, " ┬ ", theme);
152
+ const rows = columns(conversation(input, left, main - 1, theme), activity(input, right, main - 1, theme), left, right, " │ ", theme);
153
+ body = [...headers, ...rows];
154
+ } else {
155
+ const chatHeight = Math.max(1, Math.ceil(main * 0.6));
156
+ const activityHeight = main - chatHeight;
157
+ body = [
158
+ rule(width, chatTitle, theme),
159
+ ...conversation(input, width, chatHeight - 1, theme),
160
+ ...(activityHeight > 0 ? [rule(width, "Activity", theme), ...activity(input, width, activityHeight - 1, theme)] : []),
161
+ ];
162
+ }
163
+ return fill([...scene, ...fill(body, main), ...thinking(input, width, thinkHeight, theme)], height, width);
164
+ }
@@ -0,0 +1,72 @@
1
+ /**
2
+ * The Issues tab: the repository's open GitHub issues through `gh`, the
3
+ * selected issue with its comments, and the way into planning it (p) before it
4
+ * becomes a task.
5
+ */
6
+ import type { IssueDetail, IssueSummary } from "../issues.ts";
7
+ import { bold, since, columns, fill, paint, rule, selectRow, spinner, split, windowStart, wrap, type LobbyTheme } from "../layout.ts";
8
+
9
+ export interface IssuesTabInput {
10
+ issues: readonly IssueSummary[];
11
+ selected: number;
12
+ detail?: IssueDetail;
13
+ focus: "list" | "detail";
14
+ detailOffset: number;
15
+ loading: boolean;
16
+ loaded: boolean;
17
+ error?: string;
18
+ notice?: string;
19
+ tick: number;
20
+ now: number;
21
+ }
22
+
23
+ export const ISSUES_COLUMNS_MIN = 90;
24
+
25
+ export function issueDetailLines(issue: IssueDetail, width: number, now: number, theme?: LobbyTheme): string[] {
26
+ const facts = [
27
+ issue.state ? paint(theme, issue.state === "OPEN" ? "success" : "dim", issue.state.toLowerCase()) : "",
28
+ issue.author ? `by ${issue.author}` : "",
29
+ issue.updatedAt ? `updated ${since(now - Date.parse(issue.updatedAt))}` : "",
30
+ issue.labels.length > 0 ? paint(theme, "warning", issue.labels.join(", ")) : "",
31
+ ].filter(Boolean);
32
+ const lines = [...wrap(bold(theme, `#${issue.number} ${issue.title}`), width), facts.join(paint(theme, "dim", " · "))];
33
+ if (issue.url) lines.push(paint(theme, "dim", issue.url));
34
+ lines.push("", paint(theme, "dim", "p plans it with the planning panel, then save it as a task"), "", ...wrap(issue.body.trim() || paint(theme, "dim", "(no description)"), width));
35
+ for (const comment of issue.comments) {
36
+ lines.push("", rule(width, comment.author ?? "comment", theme, comment.createdAt ? since(now - Date.parse(comment.createdAt)) : ""), ...wrap(comment.body.trim(), width));
37
+ }
38
+ return lines;
39
+ }
40
+
41
+ function empty(input: IssuesTabInput, width: number, theme?: LobbyTheme): string[] {
42
+ if (input.loading) return [`${paint(theme, "accent", spinner(input.tick))} loading issues from GitHub…`];
43
+ if (input.error) return [];
44
+ if (!input.loaded) return wrap(paint(theme, "dim", "Press r to load open issues with the GitHub CLI (gh)."), width);
45
+ return wrap(paint(theme, "dim", "No open issues. Press n to file one."), width);
46
+ }
47
+
48
+ export function renderIssues(input: IssuesTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
49
+ if (height <= 0) return [];
50
+ const banner = [
51
+ ...(input.error ? wrap(paint(theme, "error", `✗ ${input.error}`), width) : []),
52
+ ...(input.notice ? wrap(paint(theme, "success", input.notice), width) : []),
53
+ ];
54
+ const bodyHeight = Math.max(0, height - banner.length);
55
+ if (input.issues.length === 0) return fill([...banner, rule(width, "Issues", theme), ...empty(input, width, theme)], height, width);
56
+ const selected = Math.min(Math.max(0, input.selected), input.issues.length - 1);
57
+ const wide = width >= ISSUES_COLUMNS_MIN;
58
+ const [listWidth, detailWidth] = wide ? split(width, 0.4, 3, 40) : [width, width];
59
+ const rows = input.issues.map((issue, index) => {
60
+ const labels = issue.labels.length > 0 ? ` ${paint(theme, "warning", `[${issue.labels.slice(0, 2).join(", ")}]`)}` : "";
61
+ const title = index === selected ? bold(theme, issue.title) : issue.title;
62
+ return selectRow(theme, `${paint(theme, "dim", `#${issue.number}`)} ${title}${labels}`, listWidth, index === selected, input.focus === "list");
63
+ });
64
+ const right = input.loading ? `${spinner(input.tick)} refreshing` : `${input.issues.length} open`;
65
+ const listPane = fill([rule(listWidth, "Issues", theme, right), ...rows.slice(windowStart(selected, rows.length, bodyHeight - 1))], bodyHeight);
66
+ const detail = input.detail && input.detail.number === input.issues[selected]!.number
67
+ ? issueDetailLines(input.detail, detailWidth, input.now, theme)
68
+ : [`${paint(theme, "accent", spinner(input.tick))} loading #${input.issues[selected]!.number}…`];
69
+ const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Issue ◂" : "Issue", theme), ...detail.slice(Math.max(0, Math.min(input.detailOffset, detail.length - 1)))], bodyHeight);
70
+ if (!wide) return fill([...banner, ...(input.focus === "detail" ? detailPane : listPane)], height, width);
71
+ return fill([...banner, ...columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme)], height, width);
72
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * The Metrics tab: how each model performs at each thinking level — how long
3
+ * its runs take (mean, median, p90), how often they succeed, how many turns,
4
+ * tools and tokens they use, their throughput and cost — across the Master,
5
+ * every subagent role, quick fixes and planner turns. Columns drop from the
6
+ * right as the terminal narrows.
7
+ */
8
+ import { shortDuration } from "../../text.ts";
9
+ import { tokens } from "../../pi/run-summary.ts";
10
+ import type { GroupBy, MetricGroup, MetricRecord, SortKey, TaskStats, TaskTimeGroup } from "../../state/metrics.ts";
11
+ import { bold, fill, fit, paint, rule, selectRow, windowStart, type LobbyTheme } from "../layout.ts";
12
+
13
+ export interface MetricsTabInput {
14
+ groups: readonly MetricGroup[];
15
+ /** Completed tasks' request-to-done time by the oracle's model and thinking. */
16
+ taskTimes?: readonly TaskTimeGroup[];
17
+ records: readonly MetricRecord[];
18
+ stats: TaskStats;
19
+ by: GroupBy;
20
+ sort: SortKey;
21
+ selected: number;
22
+ }
23
+
24
+ interface Column {
25
+ title: string;
26
+ width: number;
27
+ align: "left" | "right";
28
+ cell: (group: MetricGroup) => string;
29
+ }
30
+
31
+ function percent(part: number, whole: number): string {
32
+ return whole > 0 ? `${Math.round((part / whole) * 100)}%` : "—";
33
+ }
34
+
35
+ function duration(ms: number): string {
36
+ return ms > 0 ? shortDuration(ms) : "—";
37
+ }
38
+
39
+ function money(value: number): string {
40
+ if (value <= 0) return "—";
41
+ return value < 0.01 ? "<$0.01" : `$${value.toFixed(2)}`;
42
+ }
43
+
44
+ const KIND_LABELS: Record<string, string> = {
45
+ master: "master",
46
+ scout: "scout",
47
+ worker: "worker",
48
+ reviewer: "QA gate",
49
+ researcher: "research",
50
+ quickfix: "quick fix",
51
+ planner: "oracle (plan)",
52
+ panel: "panel",
53
+ };
54
+
55
+ /** Columns in priority order; the table keeps as many as the width allows. */
56
+ const COLUMNS: readonly Column[] = [
57
+ { title: "Runs", width: 5, align: "right", cell: (g) => String(g.runs) },
58
+ { title: "OK", width: 5, align: "right", cell: (g) => percent(g.successes, g.runs) },
59
+ { title: "Avg", width: 8, align: "right", cell: (g) => duration(g.avgMs) },
60
+ { title: "p50", width: 8, align: "right", cell: (g) => duration(g.p50Ms) },
61
+ { title: "p90", width: 8, align: "right", cell: (g) => duration(g.p90Ms) },
62
+ { title: "Turns", width: 6, align: "right", cell: (g) => (g.avgTurns > 0 ? g.avgTurns.toFixed(1) : "—") },
63
+ { title: "Tools", width: 6, align: "right", cell: (g) => (g.avgTools > 0 ? g.avgTools.toFixed(1) : "—") },
64
+ { title: "Tokens", width: 7, align: "right", cell: (g) => (g.avgTokens > 0 ? tokens(g.avgTokens) : "—") },
65
+ { title: "tok/s", width: 6, align: "right", cell: (g) => (g.tokensPerSecond > 0 ? g.tokensPerSecond.toFixed(0) : "—") },
66
+ { title: "$/run", width: 7, align: "right", cell: (g) => money(g.avgCost) },
67
+ { title: "$ total", width: 8, align: "right", cell: (g) => money(g.totalCost) },
68
+ { title: "Stalls", width: 6, align: "right", cell: (g) => (g.timeouts > 0 ? String(g.timeouts) : "—") },
69
+ ];
70
+
71
+ const MODEL_MIN = 30;
72
+ const THINK_WIDTH = 7;
73
+ const AGENTS_WIDTH = 14;
74
+
75
+ function align(text: string, width: number, side: "left" | "right"): string {
76
+ const cut = fit(text, width).trimEnd();
77
+ return side === "right" ? cut.padStart(width) : fit(cut, width);
78
+ }
79
+
80
+ /** The columns that fit: model, thinking and agents first, then metrics in priority order. */
81
+ export function fittedColumns(width: number): Column[] {
82
+ let used = MODEL_MIN + 1 + THINK_WIDTH + 1 + AGENTS_WIDTH;
83
+ const kept: Column[] = [];
84
+ for (const column of COLUMNS) {
85
+ if (used + 1 + column.width > width) break;
86
+ kept.push(column);
87
+ used += 1 + column.width;
88
+ }
89
+ return kept;
90
+ }
91
+
92
+ function agentsCell(group: MetricGroup, by: GroupBy): string {
93
+ const kinds = group.kinds.map((kind) => KIND_LABELS[kind] ?? kind);
94
+ return by === "model-kind" ? kinds[0] ?? "" : kinds.join(", ");
95
+ }
96
+
97
+ export function tableLines(input: MetricsTabInput, width: number, theme?: LobbyTheme): { header: string; rows: string[] } {
98
+ const kept = fittedColumns(width);
99
+ const fixed = kept.reduce((sum, column) => sum + 1 + column.width, 0);
100
+ const modelWidth = Math.max(MODEL_MIN, width - fixed - 1 - THINK_WIDTH - 1 - AGENTS_WIDTH);
101
+ const head = [fit("Model", modelWidth), fit("Think", THINK_WIDTH), fit(input.by === "model-kind" ? "Agent" : "Agents", AGENTS_WIDTH), ...kept.map((column) => align(column.title, column.width, column.align))].join(" ");
102
+ const rows = input.groups.map((group, index) => {
103
+ const cells = [
104
+ fit(group.model, modelWidth),
105
+ fit(group.thinking, THINK_WIDTH),
106
+ fit(agentsCell(group, input.by), AGENTS_WIDTH),
107
+ ...kept.map((column) => align(column.cell(group), column.width, column.align)),
108
+ ].join(" ");
109
+ return selectRow(theme, index === input.selected ? bold(theme, cells) : cells, width, index === input.selected);
110
+ });
111
+ return { header: bold(theme, paint(theme, "muted", fit(head, width))), rows };
112
+ }
113
+
114
+ function summary(input: MetricsTabInput, theme?: LobbyTheme): string[] {
115
+ const { stats, records } = input;
116
+ const cost = records.reduce((sum, record) => sum + (record.cost ?? 0), 0);
117
+ const tasks = [
118
+ `${stats.completed} completed${stats.completed > 0 && stats.avgCompleteMs > 0 ? ` (avg ${shortDuration(stats.avgCompleteMs)} from request to done)` : ""}`,
119
+ `${stats.active} active`,
120
+ `${stats.abandoned} abandoned`,
121
+ ].join(" · ");
122
+ return [
123
+ `${bold(theme, "Tasks")} ${tasks}`,
124
+ `${bold(theme, "Runs")} ${records.length} recorded · ${money(cost)} total · grouped by ${input.by === "model" ? "model + thinking" : "model + thinking + agent"} · sorted by ${input.sort}`,
125
+ ];
126
+ }
127
+
128
+ /** Per-agent averages across every model, a quick read of where the time goes. */
129
+ function agentLine(records: readonly MetricRecord[], theme?: LobbyTheme): string {
130
+ const byAgent = new Map<string, { n: number; ms: number }>();
131
+ for (const record of records) {
132
+ if (record.status === "cancelled" || record.durationMs <= 0) continue;
133
+ const entry = byAgent.get(record.agent) ?? { n: 0, ms: 0 };
134
+ entry.n += 1;
135
+ entry.ms += record.durationMs;
136
+ byAgent.set(record.agent, entry);
137
+ }
138
+ const parts = [...byAgent.entries()].sort((a, b) => b[1].n - a[1].n).map(([agent, entry]) => `${agent} ${shortDuration(entry.ms / entry.n)} ${paint(theme, "dim", `×${entry.n}`)}`);
139
+ return parts.length > 0 ? `${bold(theme, "Avg by agent")} ${parts.join(paint(theme, "dim", " · "))}` : "";
140
+ }
141
+
142
+ /** One line: how long a task takes on each oracle model, request to done. */
143
+ function taskTimeLine(groups: readonly TaskTimeGroup[] | undefined, theme?: LobbyTheme): string {
144
+ if (!groups || groups.length === 0) return "";
145
+ const parts = groups.map((group) => `${group.model} ${paint(theme, "dim", group.thinking)} ${shortDuration(group.avgMs)} ${paint(theme, "dim", `×${group.tasks}`)}`);
146
+ return `${bold(theme, "Task time by oracle")} ${parts.join(paint(theme, "dim", " · "))}`;
147
+ }
148
+
149
+ export function renderMetrics(input: MetricsTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
150
+ if (height <= 0) return [];
151
+ const head = [...summary(input, theme), taskTimeLine(input.taskTimes, theme), agentLine(input.records, theme)].filter(Boolean);
152
+ if (input.groups.length === 0) {
153
+ return fill([
154
+ ...head, "", rule(width, "Model performance", theme),
155
+ paint(theme, "dim", "No runs recorded yet. Every Master turn, subagent run, quick fix and planning round lands here with its model, thinking level, time, tokens and cost."),
156
+ ], height, width);
157
+ }
158
+ const table = tableLines(input, width, theme);
159
+ const bodyHeight = Math.max(0, height - head.length - 3);
160
+ const start = windowStart(input.selected, table.rows.length, bodyHeight);
161
+ return fill([...head, "", rule(width, "Model performance", theme, "g group · s sort"), table.header, ...table.rows.slice(start, start + bodyHeight)], height, width);
162
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The Plan tab: task planning mode with a planning panel. The roster shows
3
+ * each seat — the oracle chairing, DEV, DESIGN, QA and RESEARCH — and what it
4
+ * is doing; the conversation shows every seat's questions, attributed, with
5
+ * your answers; the other pane shows the draft plan converging and what each
6
+ * seat said the plan must respect.
7
+ */
8
+ import { ORACLE_LABEL, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed } from "../planner.ts";
9
+ import type { PlannedTask } from "../../state/backlog.ts";
10
+ import { bold, columns, fill, markdownLines, paint, rule, spinner, split, tail, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
11
+ import { sourceColor } from "./home.ts";
12
+
13
+ export interface SeatView {
14
+ label: string;
15
+ /** Sits on the panel next round. */
16
+ seated: boolean;
17
+ status: "idle" | "thinking" | "done" | "failed";
18
+ step?: string;
19
+ /** Open questions it asked in the latest round. */
20
+ questions: number;
21
+ ready: boolean;
22
+ /** `provider/model · thinking` the seat runs on. */
23
+ profile?: string;
24
+ }
25
+
26
+ export interface PlanView {
27
+ messages: readonly PlannerMessage[];
28
+ reply?: PlannerReply;
29
+ questions: readonly PanelQuestion[];
30
+ notes: readonly PanelNote[];
31
+ seats: readonly SeatView[];
32
+ busy: boolean;
33
+ step?: string;
34
+ error?: string;
35
+ turns: number;
36
+ seed?: PlannerSeed;
37
+ saved?: PlannedTask;
38
+ title?: string;
39
+ }
40
+
41
+ export interface PlanTabInput {
42
+ session?: PlanView;
43
+ /** `provider/model · thinking` the oracle plans on. */
44
+ profile: string;
45
+ /** The seats as they would sit before a session exists. */
46
+ seats: readonly SeatView[];
47
+ offset: number;
48
+ focus: "talk" | "draft";
49
+ draftOffset: number;
50
+ tick: number;
51
+ notice?: string;
52
+ }
53
+
54
+ export const PLAN_COLUMNS_MIN = 100;
55
+ const LABEL_WIDTH = 8;
56
+
57
+ function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[] {
58
+ // The roster leads with the chair; the intro names it separately.
59
+ const members = input.seats.filter((seat) => seat.label !== ORACLE_LABEL);
60
+ const seats = members.filter((seat) => seat.seated).map((seat) => seat.label);
61
+ const text = [
62
+ bold(theme, "Plan a task with the whole team before anyone writes code."),
63
+ "",
64
+ `Describe what you want below. The planning panel — the oracle chairing${seats.length > 0 ? `, with ${seats.join(", ")}` : ""} — reads the codebase and grills you, each seat from its own domain: contracts and data, flows and states, acceptance criteria and tests, libraries and prior art. You answer everyone in one conversation, and the oracle folds every answer into the draft plan, so all the agents start from the same decisions.`,
65
+ "",
66
+ "When the panel agrees the plan is clear, press s to save it to the pending tasks list; start it from the Tasks tab whenever you like. To plan a GitHub issue, pick it in the Issues tab and press p. While browsing (esc), 1-4 seat or unseat DEV, DESIGN, QA and RESEARCH.",
67
+ "",
68
+ paint(theme, "dim", `oracle: ${input.profile}`),
69
+ ...members.map((seat) => paint(theme, "dim", `${seat.label.toLowerCase()}: ${seat.seated ? seat.profile ?? "" : "not seated"}`)),
70
+ ];
71
+ return text.flatMap((line) => wrap(line, width));
72
+ }
73
+
74
+ function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): string {
75
+ const parts: string[] = [];
76
+ if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} round ${view.turns}`);
77
+ else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — esc, then r retries`));
78
+ else if (view.reply?.status === "ready") parts.push(paint(theme, "success", "✓ READY — the panel agrees; s saves it as a pending task"));
79
+ else if (view.messages.some((message) => message.role === "planner")) parts.push(paint(theme, "warning", `● GRILLING · ${view.questions.length} open question${view.questions.length === 1 ? "" : "s"}`));
80
+ if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
81
+ if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", `round ${view.turns}`));
82
+ return parts.join(paint(theme, "dim", " · ")) || paint(theme, "dim", "Answer below; enter sends.");
83
+ }
84
+
85
+ /** One seat on the roster: a spinner and its step while it works, then ready, its question count or a failure. */
86
+ function seatCell(seat: SeatView, tick: number, theme?: LobbyTheme): string {
87
+ const name = paint(theme, seat.seated ? sourceColor(seat.label) : "dim", seat.label);
88
+ if (!seat.seated) return `${name} ${paint(theme, "dim", "off")}`;
89
+ if (seat.status === "thinking") return `${name} ${paint(theme, "accent", spinner(tick))} ${paint(theme, "muted", seat.step ?? "thinking")}`;
90
+ if (seat.status === "failed") return `${name} ${paint(theme, "error", "✗ failed — r retries")}`;
91
+ if (seat.status === "idle") return `${name} ${paint(theme, "dim", "·")}`;
92
+ if (seat.ready) return `${name} ${paint(theme, "success", "✓ ready")}`;
93
+ return `${name} ${paint(theme, "warning", `${seat.questions} question${seat.questions === 1 ? "" : "s"}`)}`;
94
+ }
95
+
96
+ /** The panel roster; while working, each seat's current step, so one busy seat does not look like a hang. */
97
+ export function rosterLines(view: PlanView, width: number, tick: number, theme?: LobbyTheme): string[] {
98
+ const cells = view.seats.map((seat) => seatCell(seat, tick, theme));
99
+ return wrap(`${paint(theme, "dim", "panel")} ${cells.join(paint(theme, "dim", " · "))}`, width);
100
+ }
101
+
102
+ function questionLines(questions: readonly PanelQuestion[], width: number, theme?: LobbyTheme): string[] {
103
+ return questions.flatMap((question, index) => {
104
+ const lead = `${paint(theme, "dim", `${String(index + 1).padStart(2)}.`)} ${bold(theme, paint(theme, sourceColor(question.from), question.from.padEnd(LABEL_WIDTH)))} `;
105
+ return wrapHanging(lead, question.text, width);
106
+ });
107
+ }
108
+
109
+ export function conversationLines(view: PlanView, width: number, theme?: LobbyTheme): string[] {
110
+ const lines: string[] = [];
111
+ if (view.seed) {
112
+ lines.push(...wrap(paint(theme, "dim", `from issue #${view.seed.issue.number} — ${view.seed.issue.title}`), width), "");
113
+ }
114
+ for (const message of view.messages) {
115
+ if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
116
+ if (message.role === "you") {
117
+ lines.push(...wrapHanging(`${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `, message.text, width));
118
+ continue;
119
+ }
120
+ lines.push(`${bold(theme, paint(theme, "toolTitle", "panel"))} ${paint(theme, "dim", "▸")}`);
121
+ lines.push(...(message.questions && message.questions.length > 0 ? questionLines(message.questions, width, theme) : wrap(message.text, width)));
122
+ }
123
+ return lines;
124
+ }
125
+
126
+ export function draftLines(view: PlanView, width: number, theme?: LobbyTheme): string[] {
127
+ const plan = view.reply?.plan;
128
+ const lines = plan ? markdownLines(plan, width, theme) : wrap(paint(theme, "dim", view.busy ? "The draft appears after the panel's first round." : "No draft yet."), width);
129
+ if (view.notes.length === 0) return lines;
130
+ lines.push("", rule(width, "What each seat needs", theme));
131
+ for (const note of view.notes) lines.push(...wrapHanging(`${paint(theme, sourceColor(note.from), note.from.padEnd(LABEL_WIDTH))} `, note.text, width));
132
+ return lines;
133
+ }
134
+
135
+ export function renderPlan(input: PlanTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
136
+ if (height <= 0) return [];
137
+ const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
138
+ const view = input.session;
139
+ if (!view) return fill([...notice, rule(width, "Plan", theme, input.profile), ...intro(input, width, theme)], height, width);
140
+ const header = [
141
+ ...notice,
142
+ rule(width, view.title ? `Planning · ${view.title}` : "Planning", theme, input.profile),
143
+ statusLine(view, input, theme),
144
+ ...rosterLines(view, width, input.tick, theme),
145
+ ];
146
+ const bodyHeight = Math.max(0, height - header.length);
147
+ const draftTitle = `${view.reply?.status === "ready" ? "Plan ✓" : "Draft plan"}${input.focus === "draft" ? " ◂" : ""}`;
148
+ const talkTitle = `Conversation${input.focus === "talk" ? " ◂" : ""}`;
149
+ if (width >= PLAN_COLUMNS_MIN) {
150
+ const [left, right] = split(width, 0.5);
151
+ const talk = fill([rule(left, talkTitle, theme), ...tail(conversationLines(view, left, theme), bodyHeight - 1, input.offset)], bodyHeight);
152
+ const draft = draftLines(view, right, theme);
153
+ const draftPane = fill([rule(right, draftTitle, theme), ...draft.slice(Math.max(0, Math.min(input.draftOffset, draft.length - 1)))], bodyHeight);
154
+ return fill([...header, ...columns(talk, draftPane, left, right, " │ ", theme)], height, width);
155
+ }
156
+ const talkHeight = Math.max(2, Math.ceil(bodyHeight * 0.55));
157
+ const talk = [rule(width, talkTitle, theme), ...fill(tail(conversationLines(view, width, theme), talkHeight - 1, input.offset), talkHeight - 1)];
158
+ const draft = draftLines(view, width, theme);
159
+ return fill([...header, ...talk, rule(width, draftTitle, theme), ...draft.slice(Math.max(0, Math.min(input.draftOffset, draft.length - 1)))], height, width);
160
+ }
@@ -0,0 +1,101 @@
1
+ /**
2
+ * The Quick fix tab: direct prompts that skip the whole workflow. The job list
3
+ * on one side, the selected job's plain-words steps and final report on the
4
+ * other.
5
+ */
6
+ import { shortDuration } from "../../text.ts";
7
+ import { jobTitle, type QuickFixJob } from "../quickfix.ts";
8
+ import { bold, clock, columns, fill, markdownLines, paint, rule, selectRow, spinner, split, windowStart, wrap, wrapHanging, type LobbyTheme } from "../layout.ts";
9
+
10
+ export interface QuickFixTabInput {
11
+ jobs: readonly QuickFixJob[];
12
+ /** Index into `jobs` shown newest first. */
13
+ selected: number;
14
+ focus: "list" | "detail";
15
+ detailOffset: number;
16
+ profile: string;
17
+ tick: number;
18
+ now: number;
19
+ }
20
+
21
+ export const QUICKFIX_COLUMNS_MIN = 90;
22
+
23
+ const STATUS_COLORS: Record<QuickFixJob["status"], "accent" | "success" | "error" | "warning" | "dim" | "muted"> = {
24
+ queued: "muted",
25
+ running: "accent",
26
+ success: "success",
27
+ failed: "error",
28
+ timeout: "error",
29
+ cancelled: "warning",
30
+ };
31
+
32
+ function mark(job: QuickFixJob, tick: number, theme?: LobbyTheme): string {
33
+ if (job.status === "running") return paint(theme, "accent", spinner(tick));
34
+ if (job.status === "queued") return paint(theme, "muted", "…");
35
+ if (job.status === "success") return paint(theme, "success", "✓");
36
+ if (job.status === "cancelled") return paint(theme, "warning", "·");
37
+ return paint(theme, "error", "✗");
38
+ }
39
+
40
+ function elapsed(job: QuickFixJob, now: number): string {
41
+ if (!job.startedAt) return "";
42
+ return shortDuration((job.finishedAt ?? now) - job.startedAt);
43
+ }
44
+
45
+ /** Jobs newest first, the order the list shows. */
46
+ export function newestFirst(jobs: readonly QuickFixJob[]): QuickFixJob[] {
47
+ return [...jobs].reverse();
48
+ }
49
+
50
+ export function jobDetailLines(job: QuickFixJob, width: number, tick: number, now: number, theme?: LobbyTheme): string[] {
51
+ const facts = [
52
+ paint(theme, STATUS_COLORS[job.status], job.status),
53
+ elapsed(job, now),
54
+ job.model ? `${job.model}${job.thinking ? ` · ${job.thinking}` : ""}` : job.thinking ?? "",
55
+ job.tools ? `${job.tools} tool${job.tools === 1 ? "" : "s"}` : "",
56
+ job.usage?.cost ? `$${job.usage.cost.toFixed(2)}` : "",
57
+ ].filter(Boolean);
58
+ const lines = [paint(theme, "dim", job.id), facts.join(paint(theme, "dim", " · ")), "", ...wrap(bold(theme, job.prompt), width)];
59
+ lines.push("", rule(width, "Steps", theme));
60
+ if (job.steps.length === 0) lines.push(paint(theme, "dim", job.status === "queued" ? "Waiting for the quick fix ahead of it." : job.status === "running" ? `${spinner(tick)} starting…` : "No tool calls."));
61
+ for (const step of job.steps) {
62
+ const lead = `${paint(theme, "dim", clock(step.at))} ${step.pending && job.status === "running" ? paint(theme, "accent", spinner(tick)) : paint(theme, "dim", "·")} `;
63
+ lines.push(...wrapHanging(lead, step.pending && job.status === "running" ? `${step.text}…` : paint(theme, "muted", step.text), width));
64
+ }
65
+ if (job.error) lines.push("", ...wrap(paint(theme, "error", `✗ ${job.error}`), width));
66
+ if (job.report) {
67
+ lines.push("", rule(width, "Report", theme), ...markdownLines(job.report, width, theme));
68
+ }
69
+ return lines;
70
+ }
71
+
72
+ function intro(input: QuickFixTabInput, width: number, theme?: LobbyTheme): string[] {
73
+ return [
74
+ bold(theme, "Make a direct change, the way you would ask pi."),
75
+ "",
76
+ "Type what you want below and press enter. One coding agent makes the change right away — no scouting, proposal, plan or review — while any bot-lobby task keeps running. Quick fixes run one at a time in the order you send them.",
77
+ "",
78
+ "Keep them small: a rename, a typo, a one-function fix. If it turns out bigger, the agent stops and says so, and you can plan it as a task.",
79
+ "",
80
+ paint(theme, "dim", `quick fix: ${input.profile}`),
81
+ ].flatMap((line) => wrap(line, width));
82
+ }
83
+
84
+ export function renderQuickFix(input: QuickFixTabInput, width: number, height: number, theme?: LobbyTheme): string[] {
85
+ if (height <= 0) return [];
86
+ if (input.jobs.length === 0) return fill([rule(width, "Quick fix", theme, input.profile), ...intro(input, width, theme)], height, width);
87
+ const jobs = newestFirst(input.jobs);
88
+ const selected = Math.min(Math.max(0, input.selected), jobs.length - 1);
89
+ const wide = width >= QUICKFIX_COLUMNS_MIN;
90
+ const [listWidth, detailWidth] = wide ? split(width, 0.38, 3, 40) : [width, width];
91
+ const rows = jobs.map((job, index) => {
92
+ const time = elapsed(job, input.now);
93
+ const text = `${mark(job, input.tick, theme)} ${index === selected ? bold(theme, jobTitle(job)) : jobTitle(job)}${time ? ` ${paint(theme, "dim", time)}` : ""}`;
94
+ return selectRow(theme, text, listWidth, index === selected, input.focus === "list");
95
+ });
96
+ const listPane = fill([rule(listWidth, "Quick fixes", theme, `${jobs.length}`), ...rows.slice(windowStart(selected, rows.length, height - 1))], height);
97
+ const detail = jobDetailLines(jobs[selected]!, detailWidth, input.tick, input.now, theme);
98
+ const detailPane = fill([rule(detailWidth, input.focus === "detail" ? "Detail ◂" : "Detail", theme, input.profile), ...detail.slice(Math.max(0, Math.min(input.detailOffset, detail.length - 1)))], height);
99
+ if (!wide) return fill(input.focus === "detail" ? detailPane : listPane, height, width);
100
+ return fill(columns(listPane, detailPane, listWidth, detailWidth, " │ ", theme), height, width);
101
+ }