@a-t-h-i/bot-lobby 0.6.13 → 0.6.15

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 (71) hide show
  1. package/README.md +70 -79
  2. package/package.json +4 -2
  3. package/prompts/master.md +3 -2
  4. package/prompts/qa.md +33 -11
  5. package/prompts/reviewer.md +5 -2
  6. package/prompts/worker.md +5 -2
  7. package/src/ask/relay.ts +1 -2
  8. package/src/ask/tool.ts +4 -6
  9. package/src/ask/types.ts +16 -0
  10. package/src/ask/web.ts +63 -0
  11. package/src/index.ts +7 -1
  12. package/src/lobby/ask.ts +4 -5
  13. package/src/lobby/blocks.ts +57 -0
  14. package/src/lobby/feed.ts +1 -1
  15. package/src/lobby/host.ts +178 -0
  16. package/src/lobby/keys.ts +10 -41
  17. package/src/lobby/prompt-hub.ts +30 -77
  18. package/src/lobby/prompts.ts +8 -12
  19. package/src/lobby/runtime.ts +49 -310
  20. package/src/lobby/service.ts +54 -134
  21. package/src/lobby/task-rows.ts +28 -0
  22. package/src/pi/commands.ts +14 -20
  23. package/src/pi/model-settings.ts +112 -0
  24. package/src/pi/start-task.ts +1 -1
  25. package/src/pi/tools.ts +6 -7
  26. package/src/pi/ui.ts +0 -9
  27. package/src/schemas/configuration.ts +9 -33
  28. package/src/state/attachments.ts +82 -0
  29. package/src/webui/api/index.ts +17 -15
  30. package/src/webui/api/lobby.ts +4 -3
  31. package/src/webui/api/planner.ts +6 -4
  32. package/src/webui/api/quickfix.ts +4 -3
  33. package/src/webui/api/sessions.ts +9 -7
  34. package/src/webui/api/settings.ts +20 -8
  35. package/src/webui/api/status.ts +4 -15
  36. package/src/webui/api/tasks.ts +11 -15
  37. package/src/webui/command.ts +35 -19
  38. package/src/webui/dev/fake-service.ts +1 -2
  39. package/src/webui/dev/fixtures.ts +2 -5
  40. package/src/webui/events.ts +1 -1
  41. package/src/webui/protocol.ts +22 -11
  42. package/src/webui/server.ts +42 -1
  43. package/src/webui/uploads.ts +68 -0
  44. package/webui/dist/assets/index-B1PnCyl2.css +2 -0
  45. package/webui/dist/assets/index-DI9Yq9IC.js +90 -0
  46. package/webui/dist/assets/inter-latin-wght-normal-Dx4kXJAl.woff2 +0 -0
  47. package/webui/dist/build.json +142 -110
  48. package/webui/dist/index.html +4 -3
  49. package/src/ask/dialog.ts +0 -193
  50. package/src/ask/image.ts +0 -202
  51. package/src/ask/png.ts +0 -179
  52. package/src/ask/state.ts +0 -175
  53. package/src/ask/view.ts +0 -163
  54. package/src/lobby/layout.ts +0 -496
  55. package/src/lobby/markdown.ts +0 -121
  56. package/src/lobby/mini.ts +0 -167
  57. package/src/lobby/tabs/excalidraw.ts +0 -95
  58. package/src/lobby/tabs/git.ts +0 -162
  59. package/src/lobby/tabs/home.ts +0 -544
  60. package/src/lobby/tabs/issues.ts +0 -73
  61. package/src/lobby/tabs/knowledge.ts +0 -135
  62. package/src/lobby/tabs/metrics.ts +0 -300
  63. package/src/lobby/tabs/plan.ts +0 -262
  64. package/src/lobby/tabs/quickfix.ts +0 -136
  65. package/src/lobby/tabs/tasks.ts +0 -412
  66. package/src/lobby/theme.ts +0 -30
  67. package/src/lobby/view.ts +0 -3018
  68. package/src/pi/settings-ui.ts +0 -708
  69. package/src/width.ts +0 -102
  70. package/webui/dist/assets/index-Bk-lpAGm.css +0 -2
  71. package/webui/dist/assets/index-E6MPcbdC.js +0 -80
package/src/lobby/view.ts DELETED
@@ -1,3018 +0,0 @@
1
- /**
2
- * The lobby: a full-screen, tabbed view over everything bot-lobby does. A tab
3
- * bar on top, the active tab's body, a search bar while one is in force, a
4
- * prompt at the bottom whose target follows the tab (the oracle, a plan
5
- * comment, the planner, a comment on one draft line, a quick fix) and a
6
- * key-hint line. Two modes, like a modal editor: typing mode sends keys to the
7
- * prompt; browsing mode (esc) moves through lists and runs the tab's
8
- * single-key commands. The shortcuts in `keys.ts` (hide, help, search, tabs,
9
- * pane toggles, paging) work in both. Everything bot-lobby-specific arrives
10
- * through `LobbyHost`, so the view renders and reacts the same in tests.
11
- */
12
- import { decodeKittyPrintable, Editor, Input, Key, matchesKey, type Component, type EditorTheme, type Focusable, type TUI, type TuiMouseEvent, type TuiMouseEventResult } from "@earendil-works/pi-tui";
13
- import { textWidth } from "../width.ts";
14
- import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
15
- import type { AgentRun } from "../schemas/findings.ts";
16
- import type { PlannedTask } from "../state/backlog.ts";
17
- import type { PlanComment } from "../state/comments.ts";
18
- import { aggregateMetrics, collectMetrics, SORT_KEYS, sortGroups, summarizeClassifier, taskStats, taskTimesByModel, type GroupBy, type MetricRecord, type SortKey } from "../state/metrics.ts";
19
- import { PANEL_MEMBERS, type BotLobbyConfig, type LobbyAgentKind, type LobbyPanel, type PanelMember } from "../schemas/configuration.ts";
20
- import type { ChatEntry, LobbyFeed } from "./feed.ts";
21
- import type { BackgroundSession } from "./sessions.ts";
22
- import type { QuickFixQueue } from "./quickfix.ts";
23
- import { MEMBER_LABELS, ORACLE_LABEL, type PlannerSeed, type PlanningSession } from "./planner.ts";
24
- import { issueText, type IssuesState } from "./issues.ts";
25
- import { agentsIndicator, workingAgents } from "./mini.ts";
26
- import { actionFor, keyLabel, keyMap, LOBBY_ACTIONS, tabJumpKey, type KeyMap, type LobbyAction } from "./keys.ts";
27
- import { beside, BRANCH_GLYPH, bold, box, fit, highlight, pageStep, pagerButton, paint, rule, selectRow, spinner, spread, windowStart, wrapHanging, type LobbyTheme, type PaneBox, type PaneLayout, type PaneMark } from "./layout.ts";
28
- import { chatTail, HOME_PANES, renderHome, type HomePane } from "./tabs/home.ts";
29
- import { filterRows, pips, planDetailLines, renderTasks, taskDetailLines, taskProgress, taskRows, tasksWidths, type TaskRow } from "./tabs/tasks.ts";
30
- import { renderPlan, type PlanLayout, type PlanView, type SeatView } from "./tabs/plan.ts";
31
- import { filterJobs, newestFirst, renderQuickFix } from "./tabs/quickfix.ts";
32
- import { renderIssues } from "./tabs/issues.ts";
33
- import { renderGit } from "./tabs/git.ts";
34
- import { renderKnowledge, type KnowledgeLayout } from "./tabs/knowledge.ts";
35
- import { renderExcalidraw, type SessionCheck } from "./tabs/excalidraw.ts";
36
- import { EXCALIDRAW_AGENTS, MAX_SESSIONS, type ExcalidrawBook, type ExcalidrawSession } from "../excalidraw/sessions.ts";
37
- import type { EntryRef, KnowledgeBook, KnowledgeFileInfo, KnowledgeView } from "./knowledge.ts";
38
- import type { PullsState } from "./pulls.ts";
39
- import type { PullReviews } from "./pr-review.ts";
40
- import type { WorkspaceInfo } from "../execution/workspace.ts";
41
- import { filterRecords, renderMetrics } from "./tabs/metrics.ts";
42
-
43
- /** The mark before a branch name in the title (shared with the task details). */
44
- export { BRANCH_GLYPH };
45
-
46
- export { TAB_IDS, TAB_LABELS, visibleTabs } from "./prompts.ts";
47
- export type { TabId } from "./prompts.ts";
48
- // Local bindings for the uses below; the definitions live in prompts.ts.
49
- import { TAB_LABELS, visibleTabs, type TabId } from "./prompts.ts";
50
-
51
- /** Tabs where the prompt is the point: they open in typing mode and typing in browsing mode resumes it. */
52
- const PROMPT_FIRST: ReadonlySet<TabId> = new Set(["lobby", "plan", "quickfix"]);
53
-
54
- export type LobbyMode = "type" | "browse";
55
-
56
- /** What each pane toggle is called in notices and help. */
57
- const PANEL_NAMES: Record<LobbyPanel, string> = { conversation: "conversation", activity: "activity log", thinking: "thinking" };
58
- const PANEL_ACTIONS: Record<LobbyPanel, LobbyAction> = { conversation: "toggleConversation", activity: "toggleActivity", thinking: "toggleThinking" };
59
-
60
- /**
61
- * Which session the Lobby tab shows and talks to: this window, one it started
62
- * in the background, one running in another terminal, or a task whose
63
- * session is not running at all.
64
- */
65
- export type SessionView =
66
- | { kind: "here" }
67
- | { kind: "background"; key: string }
68
- | { kind: "other"; sessionId: string }
69
- | { kind: "idle"; taskId: string };
70
-
71
- export type SessionWhere = "this window" | "background" | "other terminal" | "not running";
72
-
73
- /** One row of the session browser. */
74
- export interface SessionEntry {
75
- view: SessionView;
76
- name: string;
77
- where: SessionWhere;
78
- task?: Task;
79
- /** What it is doing: working, idle, starting, ended, or its task's state. */
80
- status: string;
81
- auto: boolean;
82
- /** Questions it waits on you for. */
83
- waiting: number;
84
- /** The pi session it is (absent for a task no session ever owned). */
85
- sessionId?: string;
86
- /** The process running it, for sessions in other terminals. */
87
- pid?: number;
88
- }
89
-
90
- /** A session running in this project, as its heartbeat tells (see state/presence.ts). */
91
- export interface LiveSession {
92
- sessionId: string;
93
- pid: number;
94
- name?: string;
95
- taskId?: string;
96
- mode: string;
97
- }
98
-
99
- /** What `switchTo` needs: the session to run in this window, and the background process to stop first. */
100
- export interface SwitchTarget {
101
- name: string;
102
- sessionId?: string;
103
- background?: BackgroundSession;
104
- /** A task no session owns: this window claims it instead. */
105
- claimTaskId?: string;
106
- }
107
-
108
- /** Two views show the same session. */
109
- function sameView(a: SessionView, b: SessionView): boolean {
110
- if (a.kind !== b.kind) return false;
111
- if (a.kind === "background" && b.kind === "background") return a.key === b.key;
112
- if (a.kind === "other" && b.kind === "other") return a.sessionId === b.sessionId;
113
- if (a.kind === "idle" && b.kind === "idle") return a.taskId === b.taskId;
114
- return true;
115
- }
116
-
117
- /** Tops a conversation whose earlier messages are not in memory. */
118
- export const OLDER_NOTE = "↑ earlier messages load as you scroll up";
119
-
120
- const SECTION_OF: Record<SessionWhere, string> = { "this window": "THIS WINDOW", background: "BACKGROUND", "other terminal": "OTHER TERMINALS", "not running": "NOT RUNNING" };
121
- const WHERE_MARKS: Record<SessionWhere, string> = { "this window": "●", background: "◆", "other terminal": "◇", "not running": "○" };
122
-
123
- /** One model Pi offers, for the settings page's picker: `provider/id`, its label and thinking levels. */
124
- export interface ModelChoice {
125
- id: string;
126
- label: string;
127
- thinkingLevels: string[];
128
- }
129
-
130
- /** Everything the view needs from pi and bot-lobby. */
131
- export interface LobbyHost {
132
- rows(): number;
133
- theme(): LobbyTheme;
134
- sessionId(): string | undefined;
135
- /** This session's active task and its runs, as the lobby reads them. */
136
- zen(): { task?: Task; runs: readonly AgentRun[] };
137
- feed: LobbyFeed;
138
- masterBusy(): boolean;
139
- tasks(): Task[];
140
- plans(): PlannedTask[];
141
- comments(taskId: string): PlanComment[];
142
- metrics(): MetricRecord[];
143
- /** Classifier calls (kind `classifier`), kept out of the agent tables. */
144
- classifierMetrics?(): MetricRecord[];
145
- /** Send text to the oracle, or start a task when none is active; returns a notice. */
146
- toOracle(text: string): string | undefined;
147
- comment(taskId: string, text: string): string;
148
- startPlanned(plan: PlannedTask): string;
149
- discardPlan(id: string): void;
150
- abortMaster(): void;
151
- hide(): void;
152
- quickfix: QuickFixQueue;
153
- planner(): PlanningSession | undefined;
154
- /** Start a planning session (replacing any other) with these seats on the panel. */
155
- newPlanner(seed?: PlannerSeed, seats?: readonly PanelMember[]): PlanningSession;
156
- /** The oracle puts the panel's open questions to the user, one questionnaire at a time; returns a notice. */
157
- answerPanel(): Promise<string>;
158
- /** Save the plan as a pending task, offering to split a long one into several first; returns a notice. */
159
- savePlan(): Promise<string>;
160
- /** The seats a new session starts with, from settings. */
161
- defaultPanel(): readonly PanelMember[];
162
- /** Planning rounds before the oracle finalizes alone (`lobby.maxPlanningRounds`); 0 = unlimited. */
163
- planningRounds?(): number;
164
- /** `model · thinking` a panel seat runs on. */
165
- seatLabel(member: PanelMember): string;
166
- issues: IssuesState;
167
- /** The Git tab's pull requests, read through `gh`. */
168
- pulls: PullsState;
169
- /** Reviews of pull requests by an agent, and Jev's reads of them. */
170
- reviews: PullReviews;
171
- /** Every agent's knowledge files: read, edited, commented on. */
172
- knowledge: KnowledgeBook;
173
- /** The shared Excalidraw sessions (five at most) and which agents each is assigned to. */
174
- excalidraw: ExcalidrawBook;
175
- /** Join a session's room for a moment and report what is there. */
176
- checkExcalidraw(session: ExcalidrawSession): Promise<SessionCheck>;
177
- /** Edit a text in pi's multi-line editor (the lobby steps aside meanwhile); the new text, or undefined when cancelled. */
178
- editText(title: string, text: string): Promise<string | undefined>;
179
- /** The effective config, never a secret; the web settings page reads it. */
180
- config?(): BotLobbyConfig;
181
- /** Save a whole config, the way the settings menu does; the web settings page writes it. */
182
- saveConfig?(config: BotLobbyConfig): void;
183
- /** Reload cached config (keys, panes) after a settings change. */
184
- configChanged?(): void;
185
- /** The models Pi offers, for the settings page's picker. */
186
- models?(): ModelChoice[];
187
- /** The Issues tab is switched on (`lobby.issues`). */
188
- issuesEnabled(): boolean;
189
- /** Which panes show (`lobby.panels`), and remembering a change. */
190
- panels(): Record<LobbyPanel, boolean>;
191
- savePanels(panels: Record<LobbyPanel, boolean>): void;
192
- /** Key overrides from the config (`lobby.keys`). */
193
- keys(): Readonly<Record<string, string>>;
194
- /** Open bot-lobby's settings (or one agent's entry); the lobby steps aside and rereads the config after. */
195
- openSettings(entry?: "quickfix" | "planner"): Promise<void>;
196
- /** This window's pi session name, when it has one. */
197
- sessionName(): string | undefined;
198
- /** Background sessions this window started, oldest first. */
199
- sessions(): readonly BackgroundSession[];
200
- /** Start a task in a new background session, named after the task; the session, or why not. */
201
- startSession(start: { request?: string; plan?: PlannedTask; auto?: boolean }): BackgroundSession | string;
202
- /** Put the oldest question a background session waits on to the user in this window. */
203
- answerDialog(session: BackgroundSession): Promise<void>;
204
- /** Auto mode for any task, whichever session drives it. */
205
- isAuto(taskId: string): boolean;
206
- setAuto(taskId: string, on: boolean): void;
207
- /** Leave a message for a task's oracle, delivered by whichever session drives it (now or once one resumes it); returns a notice. */
208
- sendToTask(taskId: string, text: string): string;
209
- /** Leave a message for a session running in another terminal; it delivers it within seconds. Returns a notice. */
210
- sendToSession(sessionId: string, text: string): string;
211
- /** Sessions running in this project, other than this window's, from their heartbeats. */
212
- liveSessions(): readonly LiveSession[];
213
- /** Run another session in this window (stopping its background process first); resolves to a notice. */
214
- switchTo(target: SwitchTarget): Promise<string>;
215
- /** Archived tasks, most recently archived first. */
216
- archivedTasks(): Task[];
217
- /** Archive, restore or delete a task; each returns a notice. */
218
- archiveTask(taskId: string): string;
219
- restoreTask(taskId: string): string;
220
- deleteTask(taskId: string, where: "list" | "archive"): string;
221
- /** Another session's conversation, read from its saved session file (its newest messages). */
222
- sessionChat(sessionId: string): ChatEntry[];
223
- /** Whether another session has messages earlier than `sessionChat` returns. */
224
- hasOlderChat(sessionId: string): boolean;
225
- /** A session's whole conversation, oldest first: this window's (no id) or another's; loaded only while scrolled back to it. */
226
- chatHistory(sessionId?: string): readonly ChatEntry[];
227
- profileLabel(kind: LobbyAgentKind): string;
228
- /** The repository (or folder) and branch the title shows; the folder's name alone until git has answered. */
229
- workspace?(): WorkspaceInfo;
230
- /** Ask for the workspace to be read again (the branch may have changed); it arrives through `workspace()`. */
231
- refreshWorkspace?(): void;
232
- requestRender(): void;
233
- now?(): number;
234
- }
235
-
236
- /** Live work speeds the clock up so spinners move. */
237
- export const LIVE_MS = 250;
238
- export const IDLE_MS = 1000;
239
- /** How often task and metrics data is reread from disk while the lobby is open. */
240
- export const DATA_REFRESH_MS = 2000;
241
- const NOTICE_MS = 6000;
242
- /** Lines one wheel notch scrolls. */
243
- const WHEEL_LINES = 3;
244
- /** The longest repository and branch names the title shows whole. */
245
- const TITLE_NAME_MAX = 28;
246
- const TITLE_BRANCH_MAX = 36;
247
-
248
- /** The prompt editor, with its target written into the top border. */
249
- class LobbyEditor extends Editor {
250
- label = "";
251
- paintLabel: (text: string) => string = (text) => text;
252
-
253
- protected override renderTopBorder(width: number, hiddenLineCount: number): string {
254
- if (!this.label) return super.renderTopBorder(width, hiddenLineCount);
255
- const label = ` ${this.label} `;
256
- const more = hiddenLineCount > 0 ? ` ↑${hiddenLineCount} ` : "";
257
- const rest = width - 2 - textWidth(label) - textWidth(more);
258
- if (rest < 1) return super.renderTopBorder(width, hiddenLineCount);
259
- return `${this.borderColor("──")}${this.paintLabel(label)}${this.borderColor("─".repeat(rest))}${more ? this.borderColor(more) : ""}`;
260
- }
261
- }
262
-
263
- /** The editor's reverse-video block cursor. */
264
- const REVERSE_VIDEO = /\x1b\[7m([^\x1b]*)\x1b\[0m/g;
265
-
266
- interface Notice {
267
- text: string;
268
- at: number;
269
- kind: "info" | "warning";
270
- }
271
-
272
- /** A key and what it does, for the help screen. */
273
- interface KeyHelp {
274
- key: string;
275
- text: string;
276
- }
277
-
278
- function isPrintable(data: string): string | undefined {
279
- const kitty = decodeKittyPrintable(data);
280
- if (kitty !== undefined) return kitty;
281
- return data.length === 1 && data.charCodeAt(0) >= 32 && data.charCodeAt(0) !== 127 ? data : undefined;
282
- }
283
-
284
- /** A mouse report in SGR encoding (`ESC [ < b ; x ; y M` or `m`), zero-based cells. */
285
- export interface MouseReport {
286
- kind: "press" | "release" | "wheel";
287
- button: number;
288
- x: number;
289
- y: number;
290
- /** Wheel direction: -1 up, 1 down. */
291
- delta: number;
292
- }
293
-
294
- const SGR_MOUSE = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])$/;
295
-
296
- /** Parse an SGR mouse report, the form terminals send once the lobby turns mouse reporting on. */
297
- export function parseMouse(data: string): MouseReport | undefined {
298
- const match = SGR_MOUSE.exec(data);
299
- if (!match) return undefined;
300
- const code = Number(match[1]);
301
- const x = Number(match[2]) - 1;
302
- const y = Number(match[3]) - 1;
303
- if (code & 64) return { kind: "wheel", button: code, x, y, delta: (code & 1) === 1 ? 1 : -1 };
304
- return { kind: match[4] === "M" ? "press" : "release", button: code & 3, x, y, delta: 0 };
305
- }
306
-
307
- function clip(text: string, max: number): string {
308
- const flat = text.replace(/\s+/g, " ").trim();
309
- return flat.length > max ? `${flat.slice(0, max - 1)}…` : flat;
310
- }
311
-
312
- export class LobbyView implements Component, Focusable {
313
- tab: TabId = "lobby";
314
- mode: LobbyMode = "type";
315
- private readonly host: LobbyHost;
316
- private readonly editor: LobbyEditor;
317
- private readonly search: Input;
318
- private focusedState = false;
319
- private tick = 0;
320
- private timer: ReturnType<typeof setTimeout> | undefined;
321
- private running = false;
322
- private notice: Notice | undefined;
323
- private readonly drafts: Partial<Record<TabId, string>> = {};
324
- /** Double-press confirmations, such as discarding a plan. */
325
- private armed: string | undefined;
326
- /** The keys, the panes on show and whether Issues is on, read once from the config. */
327
- private keys: KeyMap;
328
- private panels: Record<LobbyPanel, boolean>;
329
- private issuesOn: boolean;
330
- /** The help screen covers the body. */
331
- help = false;
332
- /** The session switcher covers the body. */
333
- picking = false;
334
- private pickIndex = 0;
335
- /** The session the Lobby tab shows and talks to. */
336
- viewing: SessionView = { kind: "here" };
337
- /** The Lobby prompt starts a task in a new session instead of talking to an oracle. */
338
- newSession = false;
339
- /** The search bar has the keys. */
340
- searching = false;
341
- /** Each tab's search, kept while you move between tabs. */
342
- private readonly queries: Partial<Record<TabId, string>> = {};
343
- /** Where the body starts and where each tab's label sits in the tab bar, for clicks. */
344
- private bodyTop = 1;
345
- private promptTop = Number.POSITIVE_INFINITY;
346
- private tabSpans: Array<{ tab: TabId; from: number; to: number }> = [];
347
-
348
- /** Each Lobby pane's scroll, in lines back from its newest, and the pane the keys scroll. */
349
- private readonly homeOffsets: Record<HomePane, number> = { conversation: 0, activity: 0, thinking: 0 };
350
- homeFocus: HomePane = "conversation";
351
- /** How many lines each pane held last frame, so a pane scrolled back stays on what you are reading as lines arrive. */
352
- private readonly seenTotals = new Map<string, number>();
353
- /** Each Lobby pane's newest entry last frame: those panes measure what arrived below it, as their totals are estimates. */
354
- private readonly marks = new Map<string, PaneMark>();
355
- /** Where the current tab's scrollable panes landed in the last frame. */
356
- private readonly panes: PaneLayout = new Map();
357
- private tasksSelected = 0;
358
- private tasksFocus: "list" | "detail" = "list";
359
- private tasksDetailOffset = 0;
360
- private commentTarget: string | undefined;
361
- private planOffset = 0;
362
- private planFocus: "talk" | "draft" = "talk";
363
- private planDraftOffset = 0;
364
- /** The draft line the cursor is on, and the one a comment is being written for. */
365
- planCursor = 0;
366
- private lineTarget: string | undefined;
367
- private readonly planLayout: PlanLayout = { draftTop: 0, draftLeft: 0, draftWidth: 0, draftRows: 0, draftStart: 0, draftText: [] };
368
- /** Seats for the next planning session, until one exists. */
369
- private seats: Set<PanelMember> | undefined;
370
- private fixSelected = 0;
371
- private fixFocus: "list" | "detail" = "list";
372
- private fixDetailOffset = 0;
373
- private issueSelected = 0;
374
- private issueFocus: "list" | "detail" = "list";
375
- private issueDetailOffset = 0;
376
- private issueDraft = false;
377
- private gitSelected = 0;
378
- private gitFocus: "list" | "detail" = "list";
379
- private gitDetailOffset = 0;
380
- /** The prompt is taking what the review of the selected pull request should look at first. */
381
- private gitFocusDraft = false;
382
- private kSelected = 0;
383
- private kFocus: "list" | "detail" = "list";
384
- /** The picked entry of the open knowledge file (an index into its entries), and how far its pane is scrolled. */
385
- private kCursor = 0;
386
- private kOffset = 0;
387
- private readonly kLayout: KnowledgeLayout = { offset: 0, total: 0, rows: 0 };
388
- /** The prompt is writing an edit of the picked entry, a note on it, or a new entry after it. */
389
- private kDraft: { kind: "edit" | "comment" | "add"; entry?: EntryRef } | undefined;
390
- private xSelected = 0;
391
- private xFocus: "list" | "detail" = "list";
392
- /** The agent under the cursor in the picked session's checklist. */
393
- private xAgent = 0;
394
- /** The prompt is taking a link to add (with an optional name), the name of a new room, or a new name for the picked session. */
395
- private xDraft: "add" | "new" | "rename" | undefined;
396
- private readonly xChecks = new Map<string, SessionCheck>();
397
- private readonly xChecking = new Set<string>();
398
- private metricsSelected = 0;
399
- private metricsBy: GroupBy = "model";
400
- private metricsSort: SortKey = "runs";
401
-
402
- /** The Tasks tab lists archived tasks too (v). */
403
- showArchived = false;
404
- private data: { tasks: Task[]; plans: PlannedTask[]; archived: Task[]; comments: Map<string, PlanComment[]>; metrics: MetricRecord[]; classifierMetrics: MetricRecord[]; knowledge: { files: KnowledgeFileInfo[]; view?: KnowledgeView }; excalidraw: ExcalidrawSession[]; at: number } = {
405
- tasks: [],
406
- plans: [],
407
- archived: [],
408
- comments: new Map(),
409
- metrics: [],
410
- classifierMetrics: [],
411
- knowledge: { files: [] },
412
- excalidraw: [],
413
- at: 0,
414
- };
415
-
416
- constructor(tui: TUI, host: LobbyHost, editorTheme: EditorTheme) {
417
- this.host = host;
418
- this.editor = new LobbyEditor(tui, editorTheme, { paddingX: 1 });
419
- this.editor.onSubmit = (text) => this.submit(text);
420
- this.search = new Input({ prompt: "", placeholder: "type to search this tab" });
421
- this.keys = keyMap(host.keys());
422
- this.panels = { ...host.panels() };
423
- this.issuesOn = host.issuesEnabled();
424
- }
425
-
426
- get focused(): boolean {
427
- return this.focusedState;
428
- }
429
-
430
- set focused(value: boolean) {
431
- this.focusedState = value;
432
- this.syncFocus();
433
- }
434
-
435
- private syncFocus(): void {
436
- this.editor.focused = this.focusedState && this.mode === "type" && !this.searching;
437
- this.search.focused = this.focusedState && this.searching;
438
- }
439
-
440
- private now(): number {
441
- return this.host.now?.() ?? Date.now();
442
- }
443
-
444
- /** Start the lobby clock (while visible). */
445
- start(): void {
446
- if (this.running) return;
447
- this.running = true;
448
- this.refreshData(true);
449
- this.schedule(0);
450
- }
451
-
452
- stop(): void {
453
- this.running = false;
454
- if (this.timer) clearTimeout(this.timer);
455
- this.timer = undefined;
456
- }
457
-
458
- dispose(): void {
459
- this.stop();
460
- }
461
-
462
- private schedule(delay: number): void {
463
- if (this.timer) clearTimeout(this.timer);
464
- if (!this.running) return;
465
- this.timer = setTimeout(() => this.step(), delay);
466
- this.timer.unref?.();
467
- }
468
-
469
- private isLive(): boolean {
470
- const zen = this.host.zen();
471
- return this.host.masterBusy()
472
- || Boolean(this.host.quickfix.running)
473
- || Boolean(this.host.planner()?.busy)
474
- || this.host.sessions().some((session) => session.busy)
475
- || (this.issuesOn && this.host.issues.loading)
476
- || (this.tab === "git" && this.host.pulls.loading)
477
- || this.host.reviews.busy
478
- || zen.runs.some((run) => run.status === "running")
479
- || Boolean(zen.task && !zen.task.paused && !TERMINAL_STATES.includes(zen.task.state));
480
- }
481
-
482
- /** One clock step: reread data when due, repaint. */
483
- private step(): void {
484
- if (!this.running) return;
485
- this.tick += 1;
486
- this.refreshData(false);
487
- this.host.requestRender();
488
- this.schedule(this.isLive() ? LIVE_MS : IDLE_MS);
489
- }
490
-
491
- /** Reread tasks, plans, comments and metrics from disk (throttled unless forced). */
492
- refreshData(force: boolean): void {
493
- const now = this.now();
494
- if (!force && now - this.data.at < DATA_REFRESH_MS) return;
495
- this.dataVersion += 1;
496
- this.host.refreshWorkspace?.();
497
- this.data = {
498
- tasks: this.host.tasks(),
499
- plans: this.host.plans(),
500
- archived: this.showArchived ? this.host.archivedTasks() : [],
501
- comments: new Map(),
502
- metrics: this.tab === "metrics" ? this.host.metrics() : this.data.metrics,
503
- classifierMetrics: this.tab === "metrics" ? this.host.classifierMetrics?.() ?? [] : this.data.classifierMetrics,
504
- knowledge: this.tab === "knowledge" ? this.readKnowledge() : this.data.knowledge,
505
- excalidraw: this.host.excalidraw.list(),
506
- at: now,
507
- };
508
- if (this.tab === "tasks") {
509
- const row = this.taskRowList()[this.tasksSelected];
510
- if (row?.kind === "task") this.data.comments.set(row.id, this.host.comments(row.id));
511
- }
512
- }
513
-
514
- /** Every knowledge file, and the selected one read as entries. */
515
- private readKnowledge(): { files: KnowledgeFileInfo[]; view?: KnowledgeView } {
516
- const files = this.host.knowledge.files();
517
- this.kSelected = Math.max(0, Math.min(this.kSelected, files.length - 1));
518
- const info = files[this.kSelected];
519
- return { files, ...(info ? { view: this.host.knowledge.open(info.agent, info.file) } : {}) };
520
- }
521
-
522
- /** Read the open knowledge file again (after an edit, or another file was picked). */
523
- private reloadKnowledge(): void {
524
- this.data.knowledge = this.readKnowledge();
525
- }
526
-
527
- private say(text: string, kind: Notice["kind"] = "info"): void {
528
- this.notice = { text, at: this.now(), kind };
529
- }
530
-
531
- /** The search in force on this tab. */
532
- query(): string | undefined {
533
- return this.queries[this.tab]?.trim() || undefined;
534
- }
535
-
536
- /* ---------------------------------------------------------------- tabs */
537
-
538
- tabs(): TabId[] {
539
- return visibleTabs(this.issuesOn);
540
- }
541
-
542
- /** Open the Quick fix tab on a job the oracle routed there, with a line saying so. */
543
- showQuickFix(id: string): void {
544
- this.setTab("quickfix");
545
- delete this.queries.quickfix;
546
- this.fixSelected = Math.max(0, this.fixJobs().findIndex((job) => job.id === id));
547
- this.fixDetailOffset = 0;
548
- this.say(`${id} — the oracle sent your request to the quick-fix agent`);
549
- }
550
-
551
- setTab(tab: TabId): void {
552
- if (tab === this.tab) return;
553
- if (!this.tabs().includes(tab)) return this.say(`the ${TAB_LABELS[tab]} tab is off — set lobby.${tab} to true in the config to bring it back`, "warning");
554
- // An entry being edited is not a draft to come back to: its text was only lent to the prompt.
555
- if (this.kDraft || this.xDraft) {
556
- this.kDraft = undefined;
557
- this.xDraft = undefined;
558
- this.editor.setText("");
559
- }
560
- this.drafts[this.tab] = this.editor.getText();
561
- this.tab = tab;
562
- this.editor.setText(this.drafts[tab] ?? "");
563
- this.commentTarget = undefined;
564
- this.lineTarget = undefined;
565
- this.issueDraft = false;
566
- this.gitFocusDraft = false;
567
- this.armed = undefined;
568
- this.help = false;
569
- this.picking = false;
570
- this.newSession = false;
571
- this.closeSearch();
572
- this.setMode(PROMPT_FIRST.has(tab) ? "type" : "browse");
573
- this.refreshData(true);
574
- if (tab === "issues" && !this.host.issues.loaded && !this.host.issues.loading) void this.host.issues.refresh().then(() => this.loadIssueDetail());
575
- if (tab === "git" && !this.host.pulls.loaded && !this.host.pulls.loading) void this.host.pulls.refresh().then(() => this.loadPullDetail());
576
- else if (tab === "git") void this.loadPullDetail();
577
- this.host.requestRender();
578
- }
579
-
580
- private cycleTab(direction: 1 | -1): void {
581
- const tabs = this.tabs();
582
- const index = Math.max(0, tabs.indexOf(this.tab));
583
- this.setTab(tabs[(index + direction + tabs.length) % tabs.length]!);
584
- }
585
-
586
- private setMode(mode: LobbyMode): void {
587
- this.mode = this.tab === "metrics" ? "browse" : mode;
588
- this.syncFocus();
589
- }
590
-
591
- /** Open the prompt for a comment on `taskId`'s plan (from any tab). */
592
- commentOn(taskId: string): void {
593
- this.setTab("tasks");
594
- delete this.queries.tasks;
595
- const rows = this.taskRowList();
596
- const index = rows.findIndex((row) => row.id === taskId);
597
- if (index >= 0) this.tasksSelected = index;
598
- this.refreshData(true);
599
- this.commentTarget = taskId;
600
- this.setMode("type");
601
- }
602
-
603
- /** Plan an issue: seed a planning session with it and switch to the Plan tab. */
604
- async planIssue(number: number): Promise<void> {
605
- const detail = await this.host.issues.detail(number);
606
- if (!detail) return this.say(`could not load issue #${number}`, "warning");
607
- const session = this.host.newPlanner({ issue: { number: detail.number, title: detail.title, ...(detail.url ? { url: detail.url } : {}) }, body: issueText(detail) }, [...this.seatSet()]);
608
- this.resetPlanScroll();
609
- this.setTab("plan");
610
- void session.open();
611
- }
612
-
613
- /** Show or hide one pane and remember it in the config. */
614
- togglePanel(panel: LobbyPanel): void {
615
- const shown = !this.panels[panel];
616
- this.panels = { ...this.panels, [panel]: shown };
617
- this.host.savePanels(this.panels);
618
- const key = keyLabel(this.keys[PANEL_ACTIONS[panel]]);
619
- const where = this.tab === "lobby" ? "" : " on the Lobby tab";
620
- this.say(`${PANEL_NAMES[panel]} ${shown ? "shown" : "hidden"}${where} · ${key} ${shown ? "hides" : "shows"} it`);
621
- }
622
-
623
- panelShown(panel: LobbyPanel): boolean {
624
- return this.panels[panel];
625
- }
626
-
627
- /** Reread keys, panes and the Issues switch, after the settings changed them. */
628
- reloadConfig(): void {
629
- this.keys = keyMap(this.host.keys());
630
- this.panels = { ...this.host.panels() };
631
- this.issuesOn = this.host.issuesEnabled();
632
- if (!this.tabs().includes(this.tab)) this.setTab("lobby");
633
- }
634
-
635
- private openSettings(entry?: "quickfix" | "planner"): void {
636
- void this.host.openSettings(entry).then(() => this.host.requestRender());
637
- }
638
-
639
- /* -------------------------------------------------------------- search */
640
-
641
- private openSearch(): void {
642
- this.help = false;
643
- this.searching = true;
644
- this.search.setValue(this.queries[this.tab] ?? "");
645
- this.syncFocus();
646
- }
647
-
648
- /** Close the search bar; the query stays in force unless `clear`. */
649
- private closeSearch(clear = false): void {
650
- if (clear) delete this.queries[this.tab];
651
- this.searching = false;
652
- this.syncFocus();
653
- }
654
-
655
- private searchKey(data: string): void {
656
- if (matchesKey(data, Key.escape)) {
657
- const had = Boolean(this.query());
658
- this.closeSearch(true);
659
- if (had) this.say("search cleared");
660
- return;
661
- }
662
- if (matchesKey(data, Key.enter)) {
663
- if (!this.query()) delete this.queries[this.tab];
664
- return this.closeSearch();
665
- }
666
- if (matchesKey(data, Key.up) || matchesKey(data, Key.down)) {
667
- // Keep the query, let the arrows move through what it found.
668
- this.closeSearch();
669
- return this.scroll(matchesKey(data, Key.up) ? -1 : 1);
670
- }
671
- this.search.handleInput(data);
672
- const value = this.search.getValue();
673
- if (value) this.queries[this.tab] = value;
674
- else delete this.queries[this.tab];
675
- this.resetSelections();
676
- }
677
-
678
- /** A new search starts every list at its top. */
679
- private resetSelections(): void {
680
- for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
681
- this.planOffset = 0;
682
- switch (this.tab) {
683
- case "tasks":
684
- this.tasksSelected = 0;
685
- this.tasksDetailOffset = 0;
686
- return;
687
- case "quickfix":
688
- this.fixSelected = 0;
689
- this.fixDetailOffset = 0;
690
- return;
691
- case "metrics":
692
- this.metricsSelected = 0;
693
- return;
694
- default:
695
- return;
696
- }
697
- }
698
-
699
- /* --------------------------------------------------------------- input */
700
-
701
- handleInput(data: string): void {
702
- this.handleKey(data);
703
- this.host.requestRender();
704
- }
705
-
706
- private handleKey(data: string): void {
707
- const mouse = parseMouse(data);
708
- if (mouse) return this.mouseReport(mouse);
709
- const action = actionFor(data, this.keys);
710
- if (this.help) {
711
- this.help = false;
712
- // Esc, ?, q and the help key only close it; anything else also does its job.
713
- if (action === "help" || matchesKey(data, Key.escape) || data === "?" || data === "q" || matchesKey(data, Key.enter)) return;
714
- }
715
- // The session switcher takes the keys while it is open (shortcuts other than its own still work).
716
- if (this.picking && (!action || action === "sessions" || action === "newSession") && this.pickerKey(data, action)) return;
717
- // While searching, the shortcuts still work; everything else edits the query.
718
- if (this.searching) return action ? this.runAction(action) : this.searchKey(data);
719
- if (action) return this.runAction(action);
720
- const tabs = this.tabs();
721
- for (const [index, tab] of tabs.entries()) {
722
- if (matchesKey(data, tabJumpKey(index))) return this.setTab(tab);
723
- }
724
- if (matchesKey(data, Key.ctrl("c"))) {
725
- if (this.editor.getText()) return this.editor.setText("");
726
- return this.host.hide();
727
- }
728
- if (this.mode === "type") return this.typeKey(data);
729
- return this.browseKey(data);
730
- }
731
-
732
- private runAction(action: LobbyAction): void {
733
- switch (action) {
734
- case "hide":
735
- return this.host.hide();
736
- case "help":
737
- this.help = !this.help;
738
- return;
739
- case "settings":
740
- return this.openSettings();
741
- case "savePlan":
742
- return this.savePlan();
743
- case "sessions":
744
- return this.picking ? void (this.picking = false) : this.openPicker();
745
- case "newSession":
746
- return this.startNewSessionPrompt();
747
- case "toggleAuto":
748
- return this.toggleAuto();
749
- case "search":
750
- return this.searching ? this.closeSearch() : this.openSearch();
751
- case "nextTab":
752
- return this.cycleTab(1);
753
- case "prevTab":
754
- return this.cycleTab(-1);
755
- case "toggleConversation":
756
- return this.togglePanel("conversation");
757
- case "toggleActivity":
758
- return this.togglePanel("activity");
759
- case "toggleThinking":
760
- return this.togglePanel("thinking");
761
- case "scrollUp":
762
- return this.scroll(-this.pageSize());
763
- case "scrollDown":
764
- return this.scroll(this.pageSize());
765
- }
766
- }
767
-
768
- private typeKey(data: string): void {
769
- if (matchesKey(data, Key.escape)) {
770
- if (this.tab === "lobby" && this.host.masterBusy() && !this.editor.getText().trim()) {
771
- this.host.abortMaster();
772
- return this.say("stopping the oracle…");
773
- }
774
- this.commentTarget = undefined;
775
- this.issueDraft = false;
776
- this.gitFocusDraft = false;
777
- if (this.kDraft || this.xDraft) {
778
- this.kDraft = undefined;
779
- this.xDraft = undefined;
780
- this.editor.setText("");
781
- }
782
- this.newSession = false;
783
- if (this.lineTarget) {
784
- this.lineTarget = undefined;
785
- this.planFocus = "draft";
786
- }
787
- return this.setMode("browse");
788
- }
789
- this.editor.handleInput(data);
790
- }
791
-
792
- private browseKey(data: string): void {
793
- if (matchesKey(data, Key.escape) && this.query()) {
794
- delete this.queries[this.tab];
795
- return this.say("search cleared");
796
- }
797
- if (data === "/") return this.openSearch();
798
- if (data === "?") {
799
- this.help = true;
800
- return;
801
- }
802
- if (matchesKey(data, Key.up) || (!PROMPT_FIRST.has(this.tab) && data === "k")) return this.scroll(-1);
803
- if (matchesKey(data, Key.down) || (!PROMPT_FIRST.has(this.tab) && data === "j")) return this.scroll(1);
804
- if (matchesKey(data, Key.left) || matchesKey(data, Key.right)) return this.movePaneFocus(matchesKey(data, Key.left) ? -1 : 1);
805
- if (matchesKey(data, Key.home) || matchesKey(data, Key.end)) return this.scrollToEdge(matchesKey(data, Key.home));
806
- const tabs = this.tabs();
807
- const digit = /^[1-9]$/.test(data) ? Number(data) - 1 : -1;
808
- if (!PROMPT_FIRST.has(this.tab) && digit >= 0 && digit < tabs.length) {
809
- // Plain digits jump without Alt; the Alt key stays the shared definition.
810
- void tabJumpKey(digit);
811
- return this.setTab(tabs[digit]!);
812
- }
813
- if (data === "i" && this.tab !== "metrics" && this.tab !== "tasks" && this.tab !== "issues" && this.tab !== "git" && this.tab !== "knowledge" && this.tab !== "excalidraw") return this.setMode("type");
814
- if (this.tabCommand(data)) return;
815
- const printable = isPrintable(data);
816
- if (printable && PROMPT_FIRST.has(this.tab)) {
817
- this.setMode("type");
818
- this.editor.handleInput(data);
819
- }
820
- }
821
-
822
- /** The pane the keys scroll on this tab. */
823
- private focusedPane(): string {
824
- switch (this.tab) {
825
- case "lobby":
826
- return this.homePane();
827
- case "plan":
828
- return this.planFocus;
829
- case "tasks":
830
- return this.tasksFocus;
831
- case "quickfix":
832
- return this.fixFocus;
833
- case "issues":
834
- return this.issueFocus;
835
- case "git":
836
- return this.gitFocus;
837
- case "knowledge":
838
- return this.kFocus;
839
- case "excalidraw":
840
- return this.xFocus;
841
- case "metrics":
842
- return "table";
843
- }
844
- }
845
-
846
- /** The focused Lobby pane, or the first one showing when it is hidden. */
847
- private homePane(): HomePane {
848
- const shown = this.shownHomePanes();
849
- if (!shown.includes(this.homeFocus) && shown.length > 0) this.homeFocus = shown[0]!;
850
- return this.homeFocus;
851
- }
852
-
853
- private shownHomePanes(): HomePane[] {
854
- const drawn = HOME_PANES.filter((pane) => this.tab === "lobby" && this.panes.has(pane));
855
- return drawn.length > 0 ? drawn : HOME_PANES.filter((pane) => this.panels[pane]);
856
- }
857
-
858
- /** A page of the focused pane: its rows less one, so a line of context carries over. */
859
- private pageSize(): number {
860
- const pane = this.panes.get(this.focusedPane());
861
- return pane && pane.rows > 2 ? pane.rows - 1 : 10;
862
- }
863
-
864
- /** ← → move between the tab's panes. */
865
- private movePaneFocus(direction: -1 | 1): void {
866
- switch (this.tab) {
867
- case "lobby": {
868
- const shown = this.shownHomePanes();
869
- if (shown.length === 0) return;
870
- const index = Math.max(0, shown.indexOf(this.homePane()));
871
- this.homeFocus = shown[(index + direction + shown.length) % shown.length]!;
872
- return;
873
- }
874
- case "plan":
875
- this.planFocus = direction < 0 ? "talk" : "draft";
876
- if (this.planFocus === "draft") this.cursorToView();
877
- return;
878
- case "tasks":
879
- this.tasksFocus = direction < 0 ? "list" : "detail";
880
- return;
881
- case "quickfix":
882
- this.fixFocus = direction < 0 ? "list" : "detail";
883
- return;
884
- case "issues":
885
- this.issueFocus = direction < 0 ? "list" : "detail";
886
- return;
887
- case "git":
888
- this.gitFocus = direction < 0 ? "list" : "detail";
889
- return;
890
- case "knowledge":
891
- this.kFocus = direction < 0 ? "list" : "detail";
892
- return;
893
- case "excalidraw":
894
- this.xFocus = direction < 0 ? "list" : "detail";
895
- return;
896
- case "metrics":
897
- return;
898
- }
899
- }
900
-
901
- /** Arrow keys (±1) and page keys (± a page): scroll or select in the tab's focused pane. */
902
- private scroll(delta: number): void {
903
- switch (this.tab) {
904
- case "lobby":
905
- return this.scrollPane(this.homePane(), delta);
906
- case "plan":
907
- return this.scrollPane(this.planFocus, delta);
908
- case "tasks":
909
- // Page keys always page the detail; the arrows follow the focus.
910
- return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.tasksFocus, delta);
911
- case "quickfix":
912
- return this.scrollPane(Math.abs(delta) > 1 ? "detail" : this.fixFocus, delta);
913
- case "issues":
914
- if (this.issueFocus === "detail" || Math.abs(delta) > 1) this.issueDetailOffset = Math.max(0, this.issueDetailOffset + delta);
915
- else {
916
- this.issueSelected = Math.max(0, Math.min(this.host.issues.issues.length - 1, this.issueSelected + delta));
917
- this.issueDetailOffset = 0;
918
- void this.loadIssueDetail();
919
- }
920
- return;
921
- case "git":
922
- if (this.gitFocus === "detail" || Math.abs(delta) > 1) this.gitDetailOffset = Math.max(0, this.gitDetailOffset + delta);
923
- else {
924
- this.gitSelected = Math.max(0, Math.min(this.host.pulls.pulls.length - 1, this.gitSelected + delta));
925
- this.gitDetailOffset = 0;
926
- void this.loadPullDetail();
927
- }
928
- return;
929
- case "knowledge":
930
- return this.moveKnowledge(delta);
931
- case "excalidraw":
932
- return this.moveExcalidraw(delta);
933
- case "metrics":
934
- return this.scrollPane("table", delta);
935
- }
936
- }
937
-
938
- /** Arrows on the Excalidraw tab: pick a session, or (in the checklist) an agent. */
939
- private moveExcalidraw(delta: number): void {
940
- const step = Math.sign(delta);
941
- if (this.xFocus === "list") {
942
- const next = Math.max(0, Math.min(this.data.excalidraw.length - 1, this.xSelected + step));
943
- if (next !== this.xSelected) this.xAgent = 0;
944
- this.xSelected = next;
945
- return;
946
- }
947
- this.xAgent = Math.max(0, Math.min(EXCALIDRAW_AGENTS.length - 1, this.xAgent + step));
948
- }
949
-
950
- /** Arrows and pages on the Knowledge tab: pick a file, or pick an entry of the open one (a page moves several). */
951
- private moveKnowledge(delta: number): void {
952
- const knowledge = this.data.knowledge;
953
- if (this.kFocus === "list") {
954
- const next = Math.max(0, Math.min(knowledge.files.length - 1, this.kSelected + delta));
955
- if (next === this.kSelected) return;
956
- this.kSelected = next;
957
- this.kCursor = 0;
958
- this.kOffset = 0;
959
- this.reloadKnowledge();
960
- return;
961
- }
962
- const count = knowledge.view?.entries.length ?? 0;
963
- const step = Math.abs(delta) > 1 ? Math.sign(delta) * Math.max(2, Math.round(Math.abs(delta) / 3)) : delta;
964
- this.kCursor = Math.max(0, Math.min(count - 1, this.kCursor + step));
965
- }
966
-
967
- /**
968
- * Scroll one pane of the current tab by `delta` lines, positive toward its
969
- * end. Lists move their selection instead; the draft moves its cursor (the
970
- * wheel scrolls it and the cursor follows only when it would leave the screen).
971
- */
972
- private scrollPane(pane: string, delta: number, wheel = false, page = false): void {
973
- if (delta === 0) return;
974
- switch (pane) {
975
- case "conversation":
976
- case "activity":
977
- case "thinking":
978
- this.homeOffsets[pane] = Math.max(0, this.homeOffsets[pane] - delta);
979
- break;
980
- case "talk":
981
- this.planOffset = Math.max(0, this.planOffset - delta);
982
- break;
983
- case "draft":
984
- if (wheel || page) this.wheelDraft(delta);
985
- else this.moveCursor(delta);
986
- break;
987
- case "list":
988
- if (this.tab === "tasks") this.selectTask(this.tasksSelected + (page ? delta : Math.sign(delta)));
989
- else if (this.tab === "quickfix") {
990
- this.fixSelected = Math.max(0, Math.min(this.fixJobs().length - 1, this.fixSelected + (page ? delta : Math.sign(delta))));
991
- this.fixDetailOffset = 0;
992
- }
993
- break;
994
- case "detail":
995
- if (this.tab === "tasks") this.tasksDetailOffset = Math.max(0, this.tasksDetailOffset + delta);
996
- else if (this.tab === "quickfix") this.fixDetailOffset = Math.max(0, this.fixDetailOffset + delta);
997
- break;
998
- case "table":
999
- this.metricsSelected = Math.max(0, this.metricsSelected + (wheel && !page ? Math.sign(delta) : delta));
1000
- break;
1001
- }
1002
- this.clampScroll();
1003
- if (pane === "conversation") this.followHistory();
1004
- }
1005
-
1006
- /** Home and End: the oldest or first line of the focused pane, or back to its newest or last. */
1007
- private scrollToEdge(start: boolean): void {
1008
- const pane = this.focusedPane();
1009
- const far = start ? -1e9 : 1e9;
1010
- if (pane === "draft") {
1011
- const last = this.planLayout.draftText.length - 1;
1012
- this.planCursor = start ? 0 : Math.max(0, last);
1013
- this.moveCursor(0);
1014
- return;
1015
- }
1016
- if (pane === "list" && this.tab === "tasks") return this.selectTask(start ? 0 : this.taskRowList().length - 1);
1017
- if (pane === "list" && this.tab === "quickfix") {
1018
- this.fixSelected = start ? 0 : Math.max(0, this.fixJobs().length - 1);
1019
- this.fixDetailOffset = 0;
1020
- return;
1021
- }
1022
- if (pane === "table") {
1023
- this.metricsSelected = start ? 0 : Number.MAX_SAFE_INTEGER;
1024
- return;
1025
- }
1026
- this.scrollPane(pane, far);
1027
- // Home loads earlier messages at the top; go on to the very first of them.
1028
- if (start && pane === "conversation" && this.history) this.homeOffsets.conversation = Number.MAX_SAFE_INTEGER;
1029
- }
1030
-
1031
- /** The wheel over the draft scrolls it; the cursor follows only when it would leave the screen. */
1032
- private wheelDraft(delta: number): void {
1033
- const rows = Math.max(1, this.planLayout.draftRows);
1034
- const max = Math.max(0, this.planLayout.draftText.length - rows);
1035
- this.planDraftOffset = Math.max(0, Math.min(max, this.planLayout.draftStart + delta));
1036
- if (this.planCursor < this.planDraftOffset) this.planCursor = this.planDraftOffset;
1037
- if (this.planCursor >= this.planDraftOffset + rows) this.planCursor = this.planDraftOffset + rows - 1;
1038
- }
1039
-
1040
- /**
1041
- * Keep every offset within what its pane holds, so scrolling back the other
1042
- * way responds at once; a pane scrolled back from its newest line stays on
1043
- * the lines being read as new ones arrive. True when that moved a pane.
1044
- */
1045
- private clampScroll(): boolean {
1046
- let moved = false;
1047
- const limit = (name: string, offset: number, anchored: boolean): number => {
1048
- const pane = this.panes.get(name);
1049
- if (!pane) return offset;
1050
- const grew = this.grewBelow(name, pane);
1051
- let next = offset;
1052
- if (anchored && next > 0 && grew > 0) {
1053
- next += grew;
1054
- moved = true;
1055
- }
1056
- return Math.min(next, Math.max(0, pane.total - pane.rows));
1057
- };
1058
- switch (this.tab) {
1059
- case "lobby":
1060
- for (const pane of HOME_PANES) this.homeOffsets[pane] = limit(pane, this.homeOffsets[pane], true);
1061
- break;
1062
- case "plan":
1063
- this.planOffset = limit("talk", this.planOffset, true);
1064
- break;
1065
- case "tasks":
1066
- this.tasksDetailOffset = limit("detail", this.tasksDetailOffset, false);
1067
- break;
1068
- case "quickfix":
1069
- this.fixDetailOffset = limit("detail", this.fixDetailOffset, false);
1070
- break;
1071
- default:
1072
- break;
1073
- }
1074
- return moved;
1075
- }
1076
-
1077
- /**
1078
- * Lines that arrived below a pane since it was last looked at, counted once.
1079
- * A pane that measures them from its newest entry says so; for the others
1080
- * (their totals exact) the total's growth stands in. An estimated total also
1081
- * moves as scrolling draws more or fewer of the entries it estimates, and
1082
- * following that pinned a pane scrolled to its top there.
1083
- */
1084
- private grewBelow(name: string, pane: PaneBox): number {
1085
- const seen = this.seenTotals.get(name);
1086
- this.seenTotals.set(name, pane.total);
1087
- const follow = pane.follow;
1088
- if (!follow) return seen === undefined ? 0 : pane.total - seen;
1089
- if (follow.mark) this.marks.set(name, follow.mark);
1090
- else this.marks.delete(name);
1091
- const grew = follow.grew;
1092
- follow.grew = 0;
1093
- return grew;
1094
- }
1095
-
1096
- /** Move the draft cursor by `delta` lines, skipping blank ones, and keep it on screen. */
1097
- private moveCursor(delta: number): void {
1098
- const lines = this.planLayout.draftText;
1099
- if (lines.length === 0) return;
1100
- const step = Math.sign(delta) || 1;
1101
- let target = Math.max(0, Math.min(lines.length - 1, this.planCursor + delta));
1102
- while (target > 0 && target < lines.length - 1 && !lines[target]!.trim()) target += step;
1103
- if (!lines[target]?.trim()) {
1104
- // Ran into a blank edge: look back the other way for the nearest line with text.
1105
- let back = target;
1106
- while (back >= 0 && back < lines.length && !lines[back]!.trim()) back -= step;
1107
- if (back >= 0 && back < lines.length) target = back;
1108
- }
1109
- this.planCursor = target;
1110
- this.keepCursorVisible();
1111
- }
1112
-
1113
- private keepCursorVisible(): void {
1114
- const rows = Math.max(1, this.planLayout.draftRows);
1115
- const start = Math.min(this.planDraftOffset, Math.max(0, this.planLayout.draftText.length - rows));
1116
- if (this.planCursor < start) this.planDraftOffset = this.planCursor;
1117
- else if (this.planCursor >= start + rows) this.planDraftOffset = this.planCursor - rows + 1;
1118
- else this.planDraftOffset = start;
1119
- }
1120
-
1121
- /** Put the draft cursor on the first line with text on screen. */
1122
- private cursorToView(): void {
1123
- const lines = this.planLayout.draftText;
1124
- const start = this.planLayout.draftStart;
1125
- const end = Math.min(lines.length, start + Math.max(1, this.planLayout.draftRows));
1126
- if (this.planCursor >= start && this.planCursor < end && lines[this.planCursor]?.trim()) return;
1127
- for (let index = start; index < end; index++) {
1128
- if (lines[index]?.trim()) {
1129
- this.planCursor = index;
1130
- return;
1131
- }
1132
- }
1133
- this.planCursor = start;
1134
- }
1135
-
1136
- private selectTask(index: number): void {
1137
- const rows = this.taskRowList();
1138
- this.tasksSelected = Math.max(0, Math.min(rows.length - 1, index));
1139
- this.tasksDetailOffset = 0;
1140
- // Only the newly selected task's comments: moving through the list must not reread every task from disk.
1141
- const row = rows[this.tasksSelected];
1142
- if (row?.kind === "task" && !this.data.comments.has(row.id)) this.data.comments.set(row.id, this.host.comments(row.id));
1143
- }
1144
-
1145
- private async loadPullDetail(): Promise<void> {
1146
- const pull = this.host.pulls.pulls[this.gitSelected];
1147
- if (pull) await this.host.pulls.detail(pull.number);
1148
- }
1149
-
1150
- private async loadIssueDetail(): Promise<void> {
1151
- const issue = this.host.issues.issues[this.issueSelected];
1152
- if (issue) await this.host.issues.detail(issue.number);
1153
- }
1154
-
1155
- /** The tab's single-key commands in browsing mode; true when the key was one. */
1156
- private tabCommand(data: string): boolean {
1157
- const enter = matchesKey(data, Key.enter);
1158
- const escape = matchesKey(data, Key.escape);
1159
- switch (this.tab) {
1160
- case "lobby": {
1161
- const background = this.viewedSession();
1162
- if (escape && background?.busy) {
1163
- background.abort();
1164
- this.say(`stopping ${background.name}'s oracle…`);
1165
- return true;
1166
- }
1167
- if (escape && this.viewing.kind === "here" && this.host.masterBusy()) {
1168
- this.host.abortMaster();
1169
- this.say("stopping the oracle…");
1170
- return true;
1171
- }
1172
- if (data === "c") {
1173
- const task = this.viewedEntry().task;
1174
- if (task && !TERMINAL_STATES.includes(task.state)) this.commentOn(task.id);
1175
- else this.say("no task in this session to comment on", "warning");
1176
- return true;
1177
- }
1178
- if (data === "s" && this.viewing.kind !== "here") {
1179
- this.switchTo(this.viewedEntry());
1180
- return true;
1181
- }
1182
- return enter ? (this.setMode("type"), true) : false;
1183
- }
1184
- case "tasks":
1185
- return this.tasksCommand(data, enter, escape);
1186
- case "plan":
1187
- return this.planCommand(data, enter, escape);
1188
- case "quickfix":
1189
- if (enter) return (this.fixFocus = this.fixFocus === "list" ? "detail" : "list"), true;
1190
- if (escape && this.fixFocus === "detail") return (this.fixFocus = "list"), true;
1191
- if (data === "x") {
1192
- const job = this.fixJobs()[this.fixSelected];
1193
- if (job && this.host.quickfix.cancel(job.id)) this.say(`cancelling ${job.id}`);
1194
- return true;
1195
- }
1196
- if (data === "m") return this.openSettings("quickfix"), true;
1197
- if (data === "r" || data === "t") {
1198
- const job = this.fixJobs()[this.fixSelected];
1199
- // Only a held job takes r and t; otherwise the key resumes typing like any other.
1200
- if (!job || job.status !== "held") return false;
1201
- if (data === "r" && this.host.quickfix.runAnyway(job.id)) this.say(`running ${job.id} anyway`);
1202
- if (data === "t") {
1203
- this.startInNewSession({ request: job.prompt });
1204
- this.host.quickfix.movedToTask(job.id);
1205
- }
1206
- return true;
1207
- }
1208
- return false;
1209
- case "issues":
1210
- return this.issuesCommand(data, enter, escape);
1211
- case "git":
1212
- return this.gitCommand(data, enter, escape);
1213
- case "knowledge":
1214
- return this.knowledgeCommand(data, enter, escape);
1215
- case "excalidraw":
1216
- return this.excalidrawCommand(data, enter, escape);
1217
- case "metrics":
1218
- if (data === "g") return (this.metricsBy = this.metricsBy === "model" ? "model-kind" : "model", this.metricsSelected = 0), true;
1219
- if (data === "s") return (this.metricsSort = SORT_KEYS[(SORT_KEYS.indexOf(this.metricsSort) + 1) % SORT_KEYS.length]!), true;
1220
- if (data === "r") return this.refreshData(true), true;
1221
- return false;
1222
- }
1223
- }
1224
-
1225
- private tasksCommand(data: string, enter: boolean, escape: boolean): boolean {
1226
- const row = this.taskRowList()[this.tasksSelected];
1227
- if (enter) return (this.tasksFocus = this.tasksFocus === "list" ? "detail" : "list"), true;
1228
- if (escape) return (this.tasksFocus = "list"), true;
1229
- if (data === "r") return this.refreshData(true), true;
1230
- if (data === "n") return this.startNewSessionPrompt(), true;
1231
- if (data === "v") {
1232
- this.showArchived = !this.showArchived;
1233
- this.refreshData(true);
1234
- const count = this.data.archived.length;
1235
- this.say(this.showArchived ? (count > 0 ? `showing ${count} archived task${count === 1 ? "" : "s"} — a restores one, d d deletes it` : "the archive is empty") : "archived tasks hidden");
1236
- return true;
1237
- }
1238
- if (data === "A") return this.archiveFinished(), true;
1239
- if (!row) return false;
1240
- if (row.kind === "archived") return this.archivedCommand(row, data);
1241
- if ((data === "a" || data === "d") && row.kind === "task") return this.archiveOrDelete(row, data === "a" ? "archive" : "delete"), true;
1242
- if (data === "c") {
1243
- const task = this.data.tasks.find((entry) => entry.id === row.id);
1244
- if (row.kind !== "task" || !task) this.say("pick a task to comment on its plan", "warning");
1245
- else if (TERMINAL_STATES.includes(task.state)) this.say(`${task.id} is ${task.state}; there is no plan left to change`, "warning");
1246
- else {
1247
- this.commentTarget = task.id;
1248
- this.setMode("type");
1249
- }
1250
- return true;
1251
- }
1252
- const plan = row.kind === "plan" ? this.data.plans.find((entry) => entry.id === row.id) : undefined;
1253
- // A planned task starts in a new session of its own (s), or in this window (h).
1254
- if (data === "s" && plan) {
1255
- this.startInNewSession({ plan });
1256
- return true;
1257
- }
1258
- if (data === "h" && plan) {
1259
- this.say(this.host.startPlanned(plan));
1260
- this.refreshData(true);
1261
- return true;
1262
- }
1263
- const task = row.kind === "task" ? this.data.tasks.find((entry) => entry.id === row.id) : undefined;
1264
- if (data === "o" && task) {
1265
- const target = this.sessionEntries().find((entry) => entry.task?.id === task.id);
1266
- if (!target) this.say(`${task.id} is not running in any session now`, "warning");
1267
- else {
1268
- this.view(target.view);
1269
- this.say(target.view.kind === "here" ? "this window's task" : `showing ${target.name}`);
1270
- }
1271
- return true;
1272
- }
1273
- if (data === "x" && task) {
1274
- const session = this.host.sessions().find((entry) => entry.alive && entry.sessionId === task.ownerSessionId);
1275
- if (!session) return this.say("only a background session this window started can be stopped from here", "warning"), true;
1276
- if (this.armed !== `stop:${session.key}`) {
1277
- this.armed = `stop:${session.key}`;
1278
- this.say(`press x again to stop ${session.name} (its task keeps its state)`, "warning");
1279
- return true;
1280
- }
1281
- this.armed = undefined;
1282
- session.stop();
1283
- this.say(`stopping ${session.name}`);
1284
- return true;
1285
- }
1286
- if (data === "d" && plan) {
1287
- if (this.armed !== `discard:${plan.id}`) {
1288
- this.armed = `discard:${plan.id}`;
1289
- this.say(`press d again to discard ${plan.id}`, "warning");
1290
- return true;
1291
- }
1292
- this.armed = undefined;
1293
- this.host.discardPlan(plan.id);
1294
- this.say(`discarded ${plan.id}`);
1295
- this.refreshData(true);
1296
- return true;
1297
- }
1298
- return false;
1299
- }
1300
-
1301
- /**
1302
- * Archive (a) or delete (d d) a task on the list. Deleting always asks for
1303
- * the key twice; archiving a task still under way does too, since it is
1304
- * abandoned first.
1305
- */
1306
- private archiveOrDelete(row: TaskRow, action: "archive" | "delete"): void {
1307
- const task = this.data.tasks.find((entry) => entry.id === row.id);
1308
- if (!task) return;
1309
- const open = !TERMINAL_STATES.includes(task.state);
1310
- const armed = `${action}:${task.id}`;
1311
- if ((action === "delete" || open) && this.armed !== armed) {
1312
- this.armed = armed;
1313
- const key = action === "delete" ? "d" : "a";
1314
- this.say(action === "delete" ? `press ${key} again to delete ${task.id} for good` : `${task.id} is ${task.state.replace(/_/g, " ")} — press a again to abandon and archive it`, "warning");
1315
- return;
1316
- }
1317
- this.armed = undefined;
1318
- const notice = action === "archive" ? this.host.archiveTask(task.id) : this.host.deleteTask(task.id, "list");
1319
- this.say(notice, /^(archived|deleted)/.test(notice) ? "info" : "warning");
1320
- this.refreshData(true);
1321
- }
1322
-
1323
- /** Keys on an archived task: a restores it, d d deletes it. */
1324
- private archivedCommand(row: TaskRow, data: string): boolean {
1325
- if (data === "a") {
1326
- const notice = this.host.restoreTask(row.id);
1327
- this.say(notice, notice.startsWith("restored") ? "info" : "warning");
1328
- this.refreshData(true);
1329
- return true;
1330
- }
1331
- if (data === "d") {
1332
- if (this.armed !== `purge:${row.id}`) {
1333
- this.armed = `purge:${row.id}`;
1334
- this.say(`press d again to delete ${row.id} for good`, "warning");
1335
- return true;
1336
- }
1337
- this.armed = undefined;
1338
- const notice = this.host.deleteTask(row.id, "archive");
1339
- this.say(notice, notice.startsWith("deleted") ? "info" : "warning");
1340
- this.refreshData(true);
1341
- return true;
1342
- }
1343
- return false;
1344
- }
1345
-
1346
- /** Archive every finished task at once (A twice). */
1347
- private archiveFinished(): void {
1348
- const finished = this.data.tasks.filter((task) => TERMINAL_STATES.includes(task.state));
1349
- if (finished.length === 0) return this.say("no finished tasks to archive", "warning");
1350
- if (this.armed !== "archive-all") {
1351
- this.armed = "archive-all";
1352
- return this.say(`press A again to archive all ${finished.length} finished task${finished.length === 1 ? "" : "s"}`, "warning");
1353
- }
1354
- this.armed = undefined;
1355
- const failed = finished.map((task) => this.host.archiveTask(task.id)).filter((notice) => !notice.startsWith("archived"));
1356
- this.refreshData(true);
1357
- this.say(failed.length === 0 ? `archived ${finished.length} finished task${finished.length === 1 ? "" : "s"} — v shows them` : `archived ${finished.length - failed.length}; ${failed[0]}`, failed.length === 0 ? "info" : "warning");
1358
- }
1359
-
1360
- /** Seats for the next round: the session's, or the ones chosen before it started. */
1361
- private seatSet(): Set<PanelMember> {
1362
- const session = this.host.planner();
1363
- if (session) return session.seats;
1364
- this.seats ??= new Set(this.host.defaultPanel());
1365
- return this.seats;
1366
- }
1367
-
1368
- private resetPlanScroll(): void {
1369
- this.planOffset = 0;
1370
- this.planDraftOffset = 0;
1371
- this.planCursor = 0;
1372
- }
1373
-
1374
- /** The Lobby prompt: a new task in its own session, or a message to the session in view. */
1375
- private submitLobby(body: string): void {
1376
- this.homeOffsets.conversation = 0;
1377
- if (this.newSession) {
1378
- this.newSession = false;
1379
- return this.startInNewSession({ request: body });
1380
- }
1381
- const view = this.viewing;
1382
- if (view.kind === "background") {
1383
- const session = this.viewedSession();
1384
- if (!session?.alive) {
1385
- this.editor.setText(body);
1386
- return this.say(`${session?.name ?? "that session"} has ended — alt+n starts a new one`, "warning");
1387
- }
1388
- session.send(body);
1389
- return;
1390
- }
1391
- if (view.kind === "other") return this.say(this.host.sendToSession(view.sessionId, body));
1392
- if (view.kind === "idle") return this.say(this.host.sendToTask(view.taskId, body));
1393
- const notice = this.host.toOracle(body);
1394
- if (notice) this.say(notice);
1395
- }
1396
-
1397
- /** Put the question the background session in view waits on to the user. */
1398
- private answerSessionDialog(): void {
1399
- const session = this.viewedSession();
1400
- if (!session?.dialogs.length) return;
1401
- void this.host.answerDialog(session).then(() => this.host.requestRender());
1402
- }
1403
-
1404
- /* -------------------------------------------------------------- history */
1405
-
1406
- /**
1407
- * The whole conversation of the session in view, loaded when the
1408
- * conversation pane is scrolled back past the messages kept in memory, and
1409
- * let go once it is back at the newest.
1410
- */
1411
- private history?: { view: SessionView; entries: readonly ChatEntry[] };
1412
-
1413
- /** The conversation to draw for the session in view, and whether earlier messages wait to be loaded. */
1414
- private conversationFor(entry: SessionEntry): { chat: readonly ChatEntry[]; older: boolean } {
1415
- if (this.history && sameView(this.history.view, entry.view)) return { chat: this.history.entries, older: false };
1416
- if (entry.view.kind === "here") return { chat: this.host.feed.chat, older: this.host.feed.chatOlder };
1417
- if (entry.view.kind === "background") {
1418
- const session = this.viewedSessionFor(entry.view.key);
1419
- return { chat: session?.feed.chat ?? [], older: Boolean(session?.feed.chatOlder && session.sessionId) };
1420
- }
1421
- return entry.sessionId ? { chat: this.host.sessionChat(entry.sessionId), older: this.host.hasOlderChat(entry.sessionId) } : { chat: [], older: false };
1422
- }
1423
-
1424
- /**
1425
- * After the conversation pane scrolls: at its top, with earlier messages
1426
- * waiting, load the whole conversation; back at its newest, let it go.
1427
- * True when history was loaded now.
1428
- */
1429
- private followHistory(): boolean {
1430
- if (this.tab !== "lobby") return false;
1431
- if (this.homeOffsets.conversation === 0) {
1432
- this.history = undefined;
1433
- return false;
1434
- }
1435
- if (this.history && sameView(this.history.view, this.viewing)) return false;
1436
- const pane = this.panes.get("conversation");
1437
- if (!pane || this.homeOffsets.conversation < pane.total - pane.rows) return false;
1438
- const entry = this.viewedEntry();
1439
- if (!this.conversationFor(entry).older) return false;
1440
- const sessionId = entry.view.kind === "here" ? undefined : entry.view.kind === "background" ? this.viewedSessionFor(entry.view.key)?.sessionId : entry.sessionId;
1441
- if (entry.view.kind !== "here" && !sessionId) return false;
1442
- const entries = this.host.chatHistory(sessionId);
1443
- if (entries.length === 0) return false;
1444
- this.history = { view: entry.view, entries };
1445
- return true;
1446
- }
1447
-
1448
- /* ------------------------------------------------------------ sessions */
1449
-
1450
- /** Every session the lobby can show: this window, the background sessions it started, then tasks other terminals drive. */
1451
- sessionEntries(): SessionEntry[] {
1452
- const me = this.host.sessionId();
1453
- const zen = this.host.zen();
1454
- const background = this.host.sessions();
1455
- const started = new Set(background.map((session) => session.sessionId).filter((id): id is string => Boolean(id)));
1456
- const auto = (task: Task | undefined) => Boolean(task && !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id));
1457
- const entries: SessionEntry[] = [{
1458
- view: { kind: "here" },
1459
- name: this.host.sessionName() ?? zen.task?.title ?? "unnamed session",
1460
- where: "this window",
1461
- ...(zen.task ? { task: zen.task } : {}),
1462
- status: this.host.masterBusy() ? "working" : zen.task?.state ?? "no task",
1463
- auto: auto(zen.task),
1464
- waiting: 0,
1465
- }];
1466
- const active = (sessionId: string | undefined) => (sessionId ? this.data.tasks.find((task) => task.ownerSessionId === sessionId && !TERMINAL_STATES.includes(task.state)) : undefined);
1467
- for (const session of background) {
1468
- const task = active(session.sessionId);
1469
- const status = session.status === "exited" ? "ended" : session.status === "starting" ? "starting" : session.busy ? "working" : task?.state ?? "idle";
1470
- entries.push({ view: { kind: "background", key: session.key }, name: session.name, where: "background", ...(task ? { task } : {}), status, auto: auto(task), waiting: session.dialogs.length, ...(session.sessionId ? { sessionId: session.sessionId } : {}) });
1471
- }
1472
- const running = new Set<string>([...(me ? [me] : []), ...started]);
1473
- for (const live of this.host.liveSessions()) {
1474
- if (running.has(live.sessionId)) continue;
1475
- running.add(live.sessionId);
1476
- const task = active(live.sessionId);
1477
- entries.push({ view: { kind: "other", sessionId: live.sessionId }, name: live.name ?? task?.title ?? "unnamed session", where: "other terminal", ...(task ? { task } : {}), status: task?.state ?? "no task", auto: auto(task), waiting: 0, sessionId: live.sessionId, pid: live.pid });
1478
- }
1479
- // Tasks under way whose session is not running (it ended, or none ever owned them): they can be resumed here.
1480
- for (const task of this.data.tasks) {
1481
- if (TERMINAL_STATES.includes(task.state) || (task.ownerSessionId && running.has(task.ownerSessionId))) continue;
1482
- entries.push({ view: { kind: "idle", taskId: task.id }, name: task.title, where: "not running", task, status: task.state, auto: auto(task), waiting: 0, ...(task.ownerSessionId ? { sessionId: task.ownerSessionId } : {}) });
1483
- }
1484
- return entries;
1485
- }
1486
-
1487
- /**
1488
- * The entry the Lobby tab shows. A task that was not running follows its
1489
- * session once one picks it up; a session that is gone falls back to this
1490
- * window.
1491
- */
1492
- viewedEntry(): SessionEntry {
1493
- const entries = this.sessionEntries();
1494
- const view = this.viewing;
1495
- if (view.kind === "here") return entries[0]!;
1496
- let found = entries.find((entry) => sameView(entry.view, view));
1497
- if (!found && view.kind === "idle") found = entries.find((entry) => entry.task?.id === view.taskId);
1498
- if (!found) this.viewing = { kind: "here" };
1499
- else this.viewing = found.view;
1500
- return found ?? entries[0]!;
1501
- }
1502
-
1503
- private viewedSession(): BackgroundSession | undefined {
1504
- const view = this.viewing;
1505
- return view.kind === "background" ? this.host.sessions().find((session) => session.key === view.key) : undefined;
1506
- }
1507
-
1508
- /** Show a session in the Lobby tab, and talk to it from there. */
1509
- view(target: SessionView): void {
1510
- this.viewing = target;
1511
- this.picking = false;
1512
- this.newSession = false;
1513
- this.history = undefined;
1514
- for (const pane of HOME_PANES) this.homeOffsets[pane] = 0;
1515
- if (this.tab !== "lobby") this.setTab("lobby");
1516
- this.setMode("type");
1517
- }
1518
-
1519
- private openPicker(): void {
1520
- this.help = false;
1521
- this.picking = true;
1522
- const entries = this.sessionEntries();
1523
- const current = this.viewedEntry();
1524
- this.pickIndex = Math.max(0, entries.findIndex((entry) => sameView(entry.view, current.view)));
1525
- }
1526
-
1527
- /**
1528
- * Run a session in this window: a background one is stopped and resumed
1529
- * here, a task nobody runs is resumed (or claimed) here. A session running
1530
- * in another terminal stays there — two pi processes must not write one
1531
- * session.
1532
- */
1533
- switchTo(entry: SessionEntry): void {
1534
- if (entry.view.kind === "here") return this.say("this is this window's session");
1535
- if (entry.where === "other terminal") return this.say(`${entry.name} is running in another terminal${entry.pid ? ` (pid ${entry.pid})` : ""} — switch there, or close it first and resume it here`, "warning");
1536
- if (this.host.masterBusy()) return this.say("this window's oracle is working — esc stops it, then switch", "warning");
1537
- const background = entry.view.kind === "background" ? this.host.sessions().find((session) => entry.view.kind === "background" && session.key === entry.view.key) : undefined;
1538
- const target: SwitchTarget = {
1539
- name: entry.name,
1540
- ...(entry.sessionId ? { sessionId: entry.sessionId } : {}),
1541
- ...(background ? { background } : {}),
1542
- ...(!entry.sessionId && entry.task ? { claimTaskId: entry.task.id } : {}),
1543
- };
1544
- this.picking = false;
1545
- this.say(background ? `stopping ${entry.name}'s background process to resume it here…` : `switching this window to ${entry.name}…`);
1546
- void this.host.switchTo(target).then((notice) => {
1547
- this.say(notice);
1548
- this.host.requestRender();
1549
- });
1550
- }
1551
-
1552
- /** Keys while the session switcher is open; true when the key was handled. */
1553
- private pickerKey(data: string, action: LobbyAction | undefined): boolean {
1554
- const entries = this.sessionEntries();
1555
- this.pickIndex = Math.min(this.pickIndex, Math.max(0, entries.length - 1));
1556
- if (matchesKey(data, Key.escape) || action === "sessions") {
1557
- this.picking = false;
1558
- return true;
1559
- }
1560
- if (matchesKey(data, Key.up) || data === "k") return (this.pickIndex = Math.max(0, this.pickIndex - 1)), true;
1561
- if (matchesKey(data, Key.down) || data === "j") return (this.pickIndex = Math.min(entries.length - 1, this.pickIndex + 1)), true;
1562
- const entry = entries[this.pickIndex];
1563
- if (matchesKey(data, Key.enter) && entry) {
1564
- this.view(entry.view);
1565
- this.say(entry.view.kind === "here" ? "back to this window" : `showing ${entry.name} — you talk to its oracle from here`);
1566
- return true;
1567
- }
1568
- if (data === "s" && entry) {
1569
- this.switchTo(entry);
1570
- return true;
1571
- }
1572
- if (data === "n" || action === "newSession") {
1573
- this.picking = false;
1574
- this.startNewSessionPrompt();
1575
- return true;
1576
- }
1577
- if (data === "x" && entry?.view.kind === "background") {
1578
- const key = entry.view.key;
1579
- if (this.armed !== `stop:${key}`) {
1580
- this.armed = `stop:${key}`;
1581
- this.say(`press x again to stop ${entry.name} (its task keeps its state)`, "warning");
1582
- return true;
1583
- }
1584
- this.armed = undefined;
1585
- this.host.sessions().find((session) => session.key === key)?.stop();
1586
- this.say(`stopping ${entry.name}`);
1587
- return true;
1588
- }
1589
- return true;
1590
- }
1591
-
1592
- /** Start typing a task that begins in its own new session. */
1593
- startNewSessionPrompt(): void {
1594
- this.picking = false;
1595
- this.help = false;
1596
- if (this.tab !== "lobby") this.setTab("lobby");
1597
- this.newSession = true;
1598
- this.setMode("type");
1599
- }
1600
-
1601
- /** Start a task in a new background session and show it. */
1602
- startInNewSession(start: { request?: string; plan?: PlannedTask }): void {
1603
- const session = this.host.startSession(start);
1604
- if (typeof session === "string") return this.say(session, "warning");
1605
- this.view({ kind: "background", key: session.key });
1606
- this.say(`started ${session.name} in a new session — alt+o switches back`);
1607
- this.refreshData(true);
1608
- }
1609
-
1610
- /** The task auto mode applies to: the selected row on the Tasks tab, the viewed session's task elsewhere. */
1611
- private autoTarget(): Task | undefined {
1612
- if (this.tab === "tasks") {
1613
- const row = this.taskRowList()[this.tasksSelected];
1614
- return row?.kind === "task" ? this.data.tasks.find((task) => task.id === row.id) : undefined;
1615
- }
1616
- return this.viewedEntry().task;
1617
- }
1618
-
1619
- /** Switch auto mode for the task in view. */
1620
- toggleAuto(): void {
1621
- const task = this.autoTarget();
1622
- if (!task || TERMINAL_STATES.includes(task.state)) return this.say("no active task here — auto mode applies to a task", "warning");
1623
- const on = !this.host.isAuto(task.id);
1624
- this.host.setAuto(task.id, on);
1625
- this.say(on ? `auto mode on — the oracle drives ${task.id} to completion without asking` : `auto mode off — the oracle asks you again on ${task.id}`);
1626
- }
1627
-
1628
- /**
1629
- * Save the planning session's draft to the pending tasks, from any tab and
1630
- * in either mode. A plan with many steps is first offered a split into
1631
- * several tasks, which takes the oracle a moment and asks the user.
1632
- */
1633
- savePlan(): void {
1634
- const session = this.host.planner();
1635
- if (!session?.reply?.plan) return this.say(session?.busy ? "the first draft is still being written" : "no plan to save yet — describe a task in the Plan tab", "warning");
1636
- void this.host.savePlan().then(
1637
- (notice) => {
1638
- this.say(notice);
1639
- this.refreshData(true);
1640
- this.host.requestRender();
1641
- },
1642
- (error: Error) => {
1643
- this.say(error.message, "warning");
1644
- this.host.requestRender();
1645
- },
1646
- );
1647
- }
1648
-
1649
- /** The oracle puts the round's questions to the user through the questionnaire. */
1650
- answerQuestions(): void {
1651
- const session = this.host.planner();
1652
- if (!session?.awaitingAnswers) return this.say(session?.busy ? "the panel is still thinking" : "no open questions", "warning");
1653
- void this.host.answerPanel().then((notice) => {
1654
- this.say(notice);
1655
- this.host.requestRender();
1656
- });
1657
- }
1658
-
1659
- /** Start a comment on the draft line under the cursor. */
1660
- private commentOnCursor(): void {
1661
- const line = this.planLayout.draftText[this.planCursor];
1662
- if (!this.host.planner()?.reply?.plan) return this.say("there is no draft to comment on yet", "warning");
1663
- if (!line?.trim()) return this.say("that line is blank — ↑↓ picks another", "warning");
1664
- this.lineTarget = line.trim();
1665
- this.setMode("type");
1666
- }
1667
-
1668
- private planCommand(data: string, enter: boolean, escape: boolean): boolean {
1669
- const session = this.host.planner();
1670
- if (enter) {
1671
- this.planFocus = this.planFocus === "talk" ? "draft" : "talk";
1672
- if (this.planFocus === "draft") this.cursorToView();
1673
- return true;
1674
- }
1675
- if (data === "a") {
1676
- this.answerQuestions();
1677
- return true;
1678
- }
1679
- if (data === "m") {
1680
- this.openSettings("planner");
1681
- return true;
1682
- }
1683
- if (data === "c") {
1684
- if (this.planFocus !== "draft") {
1685
- this.planFocus = "draft";
1686
- this.cursorToView();
1687
- this.say("↑↓ picks a draft line, c comments on it (or click the line)");
1688
- return true;
1689
- }
1690
- this.commentOnCursor();
1691
- return true;
1692
- }
1693
- const seat = /^[1-4]$/.test(data) ? PANEL_MEMBERS[Number(data) - 1] : undefined;
1694
- if (seat) {
1695
- let seated: boolean;
1696
- if (session) seated = session.toggle(seat);
1697
- else {
1698
- const seats = this.seatSet();
1699
- seated = !seats.delete(seat);
1700
- if (seated) seats.add(seat);
1701
- }
1702
- this.say(`${MEMBER_LABELS[seat]} ${seated ? "joins the panel from the next round and sits every round" : "leaves the panel from the next round"}`);
1703
- return true;
1704
- }
1705
- if (data === "x" || (escape && session?.busy)) {
1706
- if (session?.busy) {
1707
- session.cancel();
1708
- this.say("stopping the panel…");
1709
- }
1710
- return true;
1711
- }
1712
- if (escape && this.planFocus === "draft") {
1713
- this.planFocus = "talk";
1714
- return true;
1715
- }
1716
- if (data === "r") {
1717
- if (session?.retryable) void session.retry();
1718
- else this.say("nothing to retry", "warning");
1719
- return true;
1720
- }
1721
- if (data === "n") {
1722
- const unsaved = session && session.messages.length > 0 && !session.saved;
1723
- if (unsaved && this.armed !== "new-plan") {
1724
- this.armed = "new-plan";
1725
- this.say("this plan is not saved — press n again to start over anyway", "warning");
1726
- return true;
1727
- }
1728
- this.armed = undefined;
1729
- session?.cancel();
1730
- this.host.newPlanner(undefined, [...this.seatSet()]);
1731
- this.resetPlanScroll();
1732
- this.setMode("type");
1733
- return true;
1734
- }
1735
- return false;
1736
- }
1737
-
1738
- private issuesCommand(data: string, enter: boolean, escape: boolean): boolean {
1739
- const issue = this.host.issues.issues[this.issueSelected];
1740
- if (enter) {
1741
- this.issueFocus = this.issueFocus === "list" ? "detail" : "list";
1742
- void this.loadIssueDetail();
1743
- return true;
1744
- }
1745
- if (escape) return (this.issueFocus = "list"), true;
1746
- if (data === "r") {
1747
- this.host.issues.details.clear();
1748
- void this.host.issues.refresh().then(() => this.loadIssueDetail());
1749
- return true;
1750
- }
1751
- if (data === "n") {
1752
- this.issueDraft = true;
1753
- this.setMode("type");
1754
- return true;
1755
- }
1756
- if (data === "p" && issue) {
1757
- void this.planIssue(issue.number);
1758
- return true;
1759
- }
1760
- return false;
1761
- }
1762
-
1763
- /** The selected pull request on the Git tab. */
1764
- private selectedPull() {
1765
- return this.host.pulls.pulls[this.gitSelected];
1766
- }
1767
-
1768
- /** Review the selected pull request with a read-only agent, optionally looking at `focus` first. */
1769
- private startReview(focus?: string): void {
1770
- const pull = this.selectedPull();
1771
- if (!pull) return this.say("no pull request selected — r loads the open ones", "warning");
1772
- if (this.host.reviews.running(pull.number)) return this.say(`already reviewing #${pull.number} — x stops it`, "warning");
1773
- this.gitFocus = "detail";
1774
- this.gitDetailOffset = 0;
1775
- this.say(`reviewing #${pull.number}${focus ? ` — looking at: ${clip(focus, 40)}` : ""} — it streams into the activity log; x stops it`);
1776
- void this.host.reviews.start(pull.number, { ...(focus ? { focus } : {}), ...(this.host.pulls.details.has(pull.number) ? { detail: this.host.pulls.details.get(pull.number)! } : {}) }).then(() => this.host.requestRender());
1777
- }
1778
-
1779
- private gitCommand(data: string, enter: boolean, escape: boolean): boolean {
1780
- const pull = this.selectedPull();
1781
- if (enter) {
1782
- this.gitFocus = this.gitFocus === "list" ? "detail" : "list";
1783
- void this.loadPullDetail();
1784
- return true;
1785
- }
1786
- if (escape) return (this.gitFocus = "list"), true;
1787
- if (data === "r") {
1788
- this.host.pulls.forget();
1789
- void this.host.pulls.refresh().then(() => this.loadPullDetail());
1790
- return true;
1791
- }
1792
- if (!pull) {
1793
- // Nothing listed: the review keys say so instead of doing nothing.
1794
- if (!"vftx".includes(data) || data.length !== 1) return false;
1795
- this.say("no pull request selected — r loads the open ones", "warning");
1796
- return true;
1797
- }
1798
- if (data === "v") return this.startReview(), true;
1799
- if (data === "f") {
1800
- this.gitFocusDraft = true;
1801
- this.setMode("type");
1802
- return true;
1803
- }
1804
- if (data === "t") {
1805
- this.gitFocus = "detail";
1806
- this.gitDetailOffset = 0;
1807
- this.say(`asking Jev to read #${pull.number}…`);
1808
- void this.host.reviews.readWithJev(pull.number, this.host.pulls.details.get(pull.number)).then(() => this.host.requestRender());
1809
- return true;
1810
- }
1811
- if (data === "x") {
1812
- this.say(this.host.reviews.cancel(pull.number) ? `stopping the review of #${pull.number}` : `no review of #${pull.number} is running`, "warning");
1813
- return true;
1814
- }
1815
- return false;
1816
- }
1817
-
1818
- /** The picked entry of the open knowledge file, as the edit functions find it again. */
1819
- private pickedEntry(): { entry: EntryRef; text: string } | undefined {
1820
- const entry = this.data.knowledge.view?.entries[this.kCursor];
1821
- return entry ? { entry: { text: entry.text, occurrence: entry.occurrence }, text: entry.text } : undefined;
1822
- }
1823
-
1824
- private knowledgeCommand(data: string, enter: boolean, escape: boolean): boolean {
1825
- const info = this.data.knowledge.files[this.kSelected];
1826
- if (enter) return (this.kFocus = this.kFocus === "list" ? "detail" : "list"), true;
1827
- if (escape) return (this.kFocus = "list"), true;
1828
- if (data === "r") {
1829
- this.reloadKnowledge();
1830
- this.say("knowledge reread from disk");
1831
- return true;
1832
- }
1833
- if (!info) return false;
1834
- if (data === "E") {
1835
- const view = this.data.knowledge.view;
1836
- if (!view) return true;
1837
- void this.host.editText(`${info.label} — ${info.agent}/${info.file}`, view.content).then((text) => {
1838
- if (text !== undefined) this.say(this.host.knowledge.replaceFile(info.agent, info.file, text));
1839
- this.reloadKnowledge();
1840
- this.host.requestRender();
1841
- });
1842
- return true;
1843
- }
1844
- if (!"ecndx".includes(data) || data.length !== 1) return false;
1845
- const picked = this.pickedEntry();
1846
- // Adding needs no entry to start from: it goes at the end of an empty or entry-less file.
1847
- if (!picked && data !== "n") {
1848
- this.say("nothing is picked — n adds an entry", "warning");
1849
- return true;
1850
- }
1851
- this.kFocus = "detail";
1852
- if (data === "e" && picked) {
1853
- this.kDraft = { kind: "edit", entry: picked.entry };
1854
- this.editor.setText(picked.text);
1855
- this.setMode("type");
1856
- return true;
1857
- }
1858
- if (data === "c" && picked) {
1859
- this.kDraft = { kind: "comment", entry: picked.entry };
1860
- this.setMode("type");
1861
- return true;
1862
- }
1863
- if (data === "n") {
1864
- this.kDraft = { kind: "add", ...(picked ? { entry: picked.entry } : {}) };
1865
- this.setMode("type");
1866
- return true;
1867
- }
1868
- if (data === "d" && picked) {
1869
- const key = `kdelete:${info.agent}/${info.file}:${picked.entry.occurrence}:${picked.text}`;
1870
- if (this.armed !== key) {
1871
- this.armed = key;
1872
- this.say("press d again to delete this entry (the file is archived first, and its notes go with it)", "warning");
1873
- return true;
1874
- }
1875
- this.armed = undefined;
1876
- this.say(this.host.knowledge.remove(info.agent, info.file, picked.entry));
1877
- this.reloadKnowledge();
1878
- return true;
1879
- }
1880
- if (data === "x" && picked) {
1881
- const notes = this.data.knowledge.view?.attached.filter((note) => note.entry === picked.text) ?? [];
1882
- const newest = notes.at(-1);
1883
- if (!newest) {
1884
- this.say("this entry has no notes", "warning");
1885
- return true;
1886
- }
1887
- const key = `kunnote:${newest.id}`;
1888
- if (this.armed !== key) {
1889
- this.armed = key;
1890
- this.say(`press x again to remove the note “${clip(newest.text, 40)}”`, "warning");
1891
- return true;
1892
- }
1893
- this.armed = undefined;
1894
- this.say(this.host.knowledge.unnote(newest.id));
1895
- this.reloadKnowledge();
1896
- return true;
1897
- }
1898
- return true;
1899
- }
1900
-
1901
- /** Assign the picked session to the agent under the cursor, or take it back. */
1902
- private toggleExcalidrawAgent(session: ExcalidrawSession): void {
1903
- this.say(this.host.excalidraw.toggleAgent(session.id, EXCALIDRAW_AGENTS[this.xAgent]!));
1904
- this.data.excalidraw = this.host.excalidraw.list();
1905
- }
1906
-
1907
- private excalidrawCommand(data: string, enter: boolean, escape: boolean): boolean {
1908
- const sessions = this.data.excalidraw;
1909
- const session = sessions[this.xSelected];
1910
- if (data === "a" || data === "n") {
1911
- if (sessions.length >= MAX_SESSIONS) {
1912
- this.say(`all ${MAX_SESSIONS} sessions are in use — remove one first (d d)`, "warning");
1913
- return true;
1914
- }
1915
- this.xDraft = data === "a" ? "add" : "new";
1916
- this.xFocus = "list";
1917
- this.setMode("type");
1918
- return true;
1919
- }
1920
- if (escape) return (this.xFocus = "list"), true;
1921
- if (!session) return false;
1922
- if (enter || (data === " " && this.xFocus === "detail")) {
1923
- if (this.xFocus === "list") this.xFocus = "detail";
1924
- else this.toggleExcalidrawAgent(session);
1925
- return true;
1926
- }
1927
- if (data === "*") {
1928
- this.say(this.host.excalidraw.toggleAll(session.id));
1929
- this.data.excalidraw = this.host.excalidraw.list();
1930
- return true;
1931
- }
1932
- if (data === "w") {
1933
- this.say(this.host.excalidraw.toggleContribute(session.id));
1934
- this.data.excalidraw = this.host.excalidraw.list();
1935
- return true;
1936
- }
1937
- if (data === "r") {
1938
- this.xDraft = "rename";
1939
- this.editor.setText(session.name);
1940
- this.setMode("type");
1941
- return true;
1942
- }
1943
- if (data === "t") {
1944
- if (this.xChecking.has(session.id)) return true;
1945
- this.xChecking.add(session.id);
1946
- this.xChecks.delete(session.id);
1947
- void this.host.checkExcalidraw(session).then((result) => {
1948
- this.xChecking.delete(session.id);
1949
- this.xChecks.set(session.id, result);
1950
- this.host.requestRender();
1951
- });
1952
- return true;
1953
- }
1954
- if (data === "d") {
1955
- const key = `xdelete:${session.id}`;
1956
- if (this.armed !== key) {
1957
- this.armed = key;
1958
- this.say(`press d again to remove “${session.name}” (agents lose it at once; the room itself is untouched)`, "warning");
1959
- return true;
1960
- }
1961
- this.armed = undefined;
1962
- this.say(this.host.excalidraw.remove(session.id));
1963
- this.xChecks.delete(session.id);
1964
- this.data.excalidraw = this.host.excalidraw.list();
1965
- this.xSelected = Math.max(0, Math.min(this.xSelected, this.data.excalidraw.length - 1));
1966
- return true;
1967
- }
1968
- return false;
1969
- }
1970
-
1971
- /** The prompt's text on the Excalidraw tab: a link to add (a name may follow it), the name of a new room, or a new name. */
1972
- private submitExcalidraw(body: string): void {
1973
- const draft = this.xDraft;
1974
- if (!draft) {
1975
- this.editor.setText(body);
1976
- return this.say("press a to add a session from a link, n for a new room", "warning");
1977
- }
1978
- const book = this.host.excalidraw;
1979
- if (draft === "rename") {
1980
- const session = this.data.excalidraw[this.xSelected];
1981
- this.xDraft = undefined;
1982
- this.setMode("browse");
1983
- if (session) this.say(book.rename(session.id, body));
1984
- this.data.excalidraw = book.list();
1985
- return;
1986
- }
1987
- // A link, then optionally a name for it; or the link's name first.
1988
- const parts = body.split(/\s+/);
1989
- const linkAt = parts.findIndex((part) => part.includes("#room=") || /^[\w-]+,[\w-]{22}$/.test(part));
1990
- const added = draft === "add" ? (linkAt < 0 ? { notice: "that is not an Excalidraw room link — it looks like https://excalidraw.com/#room=<id>,<key>" } : book.add(parts[linkAt]!, parts.filter((_, index) => index !== linkAt).join(" "))) : book.create(body);
1991
- if (!added.session) {
1992
- // Keep what was typed, so a slip in the link can be fixed.
1993
- this.editor.setText(body);
1994
- return this.say(added.notice, "warning");
1995
- }
1996
- this.xDraft = undefined;
1997
- this.setMode("browse");
1998
- this.data.excalidraw = book.list();
1999
- this.xSelected = Math.max(0, this.data.excalidraw.findIndex((session) => session.id === added.session!.id));
2000
- this.xFocus = "detail";
2001
- this.xAgent = 0;
2002
- this.say(added.notice);
2003
- }
2004
-
2005
- /** The prompt's text on the Knowledge tab: an edit of the picked entry, a note on it, or a new entry after it. */
2006
- private submitKnowledge(body: string): void {
2007
- const draft = this.kDraft;
2008
- const info = this.data.knowledge.files[this.kSelected];
2009
- if (!draft || !info) {
2010
- this.editor.setText(body);
2011
- return this.say("press e to edit the picked entry, c to comment on it, n to add one", "warning");
2012
- }
2013
- this.kDraft = undefined;
2014
- this.setMode("browse");
2015
- const book = this.host.knowledge;
2016
- const notice = draft.kind === "edit" ? book.edit(info.agent, info.file, draft.entry!, body) : draft.kind === "comment" ? book.comment(info.agent, info.file, draft.entry!, body) : book.add(info.agent, info.file, draft.entry, body);
2017
- this.say(notice, /^(saved|note saved)/.test(notice) ? "info" : "warning");
2018
- this.reloadKnowledge();
2019
- // A new entry goes right after the picked one, which the cursor then moves onto.
2020
- if (draft.kind === "add" && notice.startsWith("saved")) this.kCursor = Math.min(this.kCursor + (draft.entry ? 1 : 0), Math.max(0, (this.data.knowledge.view?.entries.length ?? 1) - 1));
2021
- }
2022
-
2023
- /* --------------------------------------------------------------- mouse */
2024
-
2025
- /** Mouse events when pi runs full screen (pi-tui dispatches them to the overlay). */
2026
- handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
2027
- if (event.type === "wheel") this.wheel(Math.sign(event.wheelDelta ?? 0) || 1, event.x, event.y);
2028
- else if (event.type === "click" && event.button === "left") this.click(event.x, event.y);
2029
- else return undefined;
2030
- this.host.requestRender();
2031
- return { handled: true };
2032
- }
2033
-
2034
- /** Mouse reports in pi's regular screen, where the lobby turns reporting on itself. */
2035
- private mouseReport(report: MouseReport): void {
2036
- if (report.kind === "wheel") this.wheel(report.delta, report.x, report.y);
2037
- else if (report.kind === "press" && report.button === 0) this.click(report.x, report.y);
2038
- }
2039
-
2040
- /** The pane of the current tab under a body cell, if any. */
2041
- private paneAt(x: number, y: number): string | undefined {
2042
- const row = y - this.bodyTop;
2043
- for (const [name, pane] of this.panes) {
2044
- if (row >= pane.top && row < pane.top + pane.height && x >= pane.left && x < pane.left + pane.width) return name;
2045
- }
2046
- return undefined;
2047
- }
2048
-
2049
- /** The wheel scrolls the pane under the pointer (the focused one when it is over none). */
2050
- private wheel(direction: number, x = -1, y = -1): void {
2051
- if (this.help) return;
2052
- if (this.tab === "issues" || this.tab === "git" || this.tab === "knowledge" || this.tab === "excalidraw") return this.scroll(direction * WHEEL_LINES);
2053
- this.scrollPane(this.paneAt(x, y) ?? this.focusedPane(), direction * WHEEL_LINES, true);
2054
- }
2055
-
2056
- /** A click: a tab in the tab bar, the prompt, or a line of the draft plan to comment on. */
2057
- click(x: number, y: number): void {
2058
- this.help = false;
2059
- if (y === 0) {
2060
- const span = this.tabSpans.find((entry) => x >= entry.from && x < entry.to);
2061
- if (span) this.setTab(span.tab);
2062
- return;
2063
- }
2064
- if (y >= this.promptTop) {
2065
- if (this.searching) this.closeSearch();
2066
- if (this.tab !== "metrics" && this.tab !== "tasks") this.setMode("type");
2067
- return;
2068
- }
2069
- const row = y - this.bodyTop;
2070
- // A click on a pane gives it the keys.
2071
- const pane = this.paneAt(x, y);
2072
- if (this.tab === "lobby" && pane) this.homeFocus = pane as HomePane;
2073
- if (this.tab === "tasks" && (pane === "list" || pane === "detail")) this.tasksFocus = pane;
2074
- if (this.tab === "quickfix" && (pane === "list" || pane === "detail")) this.fixFocus = pane;
2075
- // The page buttons in a scrolling pane's bottom border scroll it a page or two.
2076
- const paneBox = pane ? this.panes.get(pane) : undefined;
2077
- if (pane && paneBox && paneBox.total > paneBox.rows && row === paneBox.top + paneBox.height - 1) {
2078
- const button = pagerButton(paneBox.width, x - paneBox.left, paneBox.total, paneBox.rows);
2079
- if (button) {
2080
- this.scrollPane(pane, button * pageStep(paneBox.rows), false, true);
2081
- return;
2082
- }
2083
- }
2084
- if (this.tab !== "plan" || row < 0 || !this.host.planner()) return;
2085
- const layout = this.planLayout;
2086
- // The draft pane: its border sits one row above its first line and two columns left of its text.
2087
- const inDraftColumns = x >= layout.draftLeft - 2 && x < layout.draftLeft + layout.draftWidth + 2;
2088
- const line = row - layout.draftTop;
2089
- if (inDraftColumns && line >= -1 && line <= layout.draftRows) {
2090
- this.planFocus = "draft";
2091
- const index = layout.draftStart + line;
2092
- if (line < 0 || line >= layout.draftRows || index >= layout.draftText.length) return;
2093
- this.planCursor = index;
2094
- if (this.searching) this.closeSearch();
2095
- this.commentOnCursor();
2096
- return;
2097
- }
2098
- // The conversation sits left of the draft on wide terminals, above it on narrow ones.
2099
- const inTalk = layout.draftLeft > 2 ? x < layout.draftLeft - 2 : line < -1;
2100
- if (inTalk) this.planFocus = "talk";
2101
- }
2102
-
2103
- /* -------------------------------------------------------------- submit */
2104
-
2105
- private submit(text: string): void {
2106
- const body = text.trim();
2107
- if (!body) {
2108
- if (this.tab === "knowledge" && this.kDraft) return this.say(this.kDraft.kind === "edit" ? "nothing to save — d d deletes an entry, esc cancels" : "write something first, or esc to cancel", "warning");
2109
- // A new room's name is optional: an empty enter makes it with a numbered one.
2110
- if (this.tab === "excalidraw" && this.xDraft === "new") return this.submitExcalidraw("");
2111
- if (this.tab === "excalidraw" && this.xDraft) return this.say(this.xDraft === "add" ? "paste the session's link first, or esc to cancel" : "write a name first, or esc to cancel", "warning");
2112
- // An empty enter on the Plan tab opens the panel's questions; on the Lobby, a background session's question.
2113
- if (this.tab === "plan" && !this.lineTarget && this.host.planner()?.awaitingAnswers) this.answerQuestions();
2114
- if (this.tab === "lobby" && !this.newSession) this.answerSessionDialog();
2115
- return;
2116
- }
2117
- this.editor.addToHistory(body);
2118
- switch (this.tab) {
2119
- case "lobby":
2120
- return this.submitLobby(body);
2121
- case "tasks":
2122
- if (!this.commentTarget) {
2123
- this.editor.setText(body);
2124
- return this.say("press c on a task to comment on its plan", "warning");
2125
- }
2126
- this.say(this.host.comment(this.commentTarget, body));
2127
- this.commentTarget = undefined;
2128
- this.setMode("browse");
2129
- this.refreshData(true);
2130
- return;
2131
- case "plan":
2132
- return this.submitPlan(body);
2133
- case "quickfix": {
2134
- const job = this.host.quickfix.submit(body);
2135
- this.fixSelected = 0;
2136
- this.fixDetailOffset = 0;
2137
- delete this.queries.quickfix;
2138
- this.say(this.host.quickfix.running?.id === job.id ? `${job.id} started` : `${job.id} queued behind the running quick fix`);
2139
- return;
2140
- }
2141
- case "issues":
2142
- if (!this.issueDraft) {
2143
- this.editor.setText(body);
2144
- return this.say("press n to file a new issue", "warning");
2145
- }
2146
- this.issueDraft = false;
2147
- this.setMode("browse");
2148
- this.say("filing the issue…");
2149
- void this.host.issues.create(body);
2150
- return;
2151
- case "knowledge":
2152
- return this.submitKnowledge(body);
2153
- case "excalidraw":
2154
- return this.submitExcalidraw(body);
2155
- case "git":
2156
- if (!this.gitFocusDraft) {
2157
- this.editor.setText(body);
2158
- return this.say("press f to review the selected pull request with a focus", "warning");
2159
- }
2160
- this.gitFocusDraft = false;
2161
- this.setMode("browse");
2162
- return this.startReview(body);
2163
- case "metrics":
2164
- return;
2165
- }
2166
- }
2167
-
2168
- private submitPlan(body: string): void {
2169
- if (this.lineTarget) {
2170
- const line = this.lineTarget;
2171
- this.lineTarget = undefined;
2172
- const session = this.host.planner();
2173
- if (!session) return this.say("there is no draft to comment on", "warning");
2174
- const sent = session.commentOnLine(line, body);
2175
- this.planFocus = "draft";
2176
- this.setMode("browse");
2177
- if (sent) this.say("comment sent — the panel revises the draft");
2178
- else if (session.busy) this.say("comment kept — it goes to the panel after this round");
2179
- else this.say("comment kept — it goes with your answers (a answers the questions)");
2180
- return;
2181
- }
2182
- const session = this.host.planner() ?? this.host.newPlanner(undefined, [...this.seatSet()]);
2183
- if (session.busy) {
2184
- this.editor.setText(body);
2185
- return this.say("the panel is still thinking — x stops it", "warning");
2186
- }
2187
- this.planOffset = 0;
2188
- this.planDraftOffset = 0;
2189
- session.send(body).catch((error: Error) => this.say(error.message, "warning"));
2190
- }
2191
-
2192
- /* -------------------------------------------------------------- render */
2193
-
2194
- /** Bumped each time the data is reread. */
2195
- private dataVersion = 0;
2196
- /** Task rows as last built: several parts of a frame ask, and they only change with the data, the search or the clock. */
2197
- private rowsMemo?: { key: string; rows: TaskRow[] };
2198
-
2199
- /** The Metrics tab's figures as last computed: they change only with the data, the grouping, the order or the search. */
2200
- private metricsMemo?: { key: string; figures: ReturnType<LobbyView["computeMetrics"]> };
2201
-
2202
- private metricsFigures(query: string | undefined) {
2203
- const key = `${this.dataVersion}|${this.metricsBy}|${this.metricsSort}|${query ?? ""}`;
2204
- if (this.metricsMemo?.key !== key) this.metricsMemo = { key, figures: this.computeMetrics(query) };
2205
- return this.metricsMemo.figures;
2206
- }
2207
-
2208
- private computeMetrics(query: string | undefined) {
2209
- const records = filterRecords(collectMetrics(this.data.metrics, this.data.tasks), query);
2210
- return {
2211
- records,
2212
- groups: sortGroups(aggregateMetrics(records, this.metricsBy), this.metricsSort),
2213
- taskTimes: taskTimesByModel(this.data.tasks, records),
2214
- stats: taskStats(this.data.tasks),
2215
- jev: summarizeClassifier(this.data.classifierMetrics, records),
2216
- };
2217
- }
2218
-
2219
- /** Task rows, narrowed by the Tasks tab's search. */
2220
- private taskRowList(): TaskRow[] {
2221
- const key = `${this.dataVersion}|${this.tick}|${this.showArchived}|${this.queries.tasks ?? ""}`;
2222
- if (this.rowsMemo?.key === key) return this.rowsMemo.rows;
2223
- const rows = this.buildTaskRows();
2224
- this.rowsMemo = { key, rows };
2225
- return rows;
2226
- }
2227
-
2228
- private buildTaskRows(): TaskRow[] {
2229
- const names = new Map<string, string>();
2230
- for (const session of this.host.sessions()) if (session.sessionId && session.alive) names.set(session.sessionId, session.name);
2231
- const auto = new Set(this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && this.host.isAuto(task.id)).map((task) => task.id));
2232
- const live = new Set(this.host.liveSessions().map((session) => session.sessionId));
2233
- const rows = taskRows(this.data.tasks, this.data.plans, this.host.sessionId(), this.now(), { names, auto, live, archived: this.data.archived });
2234
- return filterRows(rows, this.data.tasks, this.data.plans, this.queries.tasks);
2235
- }
2236
-
2237
- /** Quick fix jobs as listed: newest first, narrowed by the search. */
2238
- private fixJobs() {
2239
- return newestFirst(filterJobs(this.host.quickfix.jobs, this.queries.quickfix));
2240
- }
2241
-
2242
- private tabBar(width: number, theme: LobbyTheme): string {
2243
- const zen = this.host.zen();
2244
- const badges: Partial<Record<TabId, string>> = {};
2245
- const active = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state)).length;
2246
- if (active > 0) badges.tasks = String(active);
2247
- const session = this.host.planner();
2248
- if (session?.busy) badges.plan = spinner(this.tick);
2249
- else if (session?.awaitingAnswers) badges.plan = `${session.questions.length}?`;
2250
- if (this.host.quickfix.running) badges.quickfix = spinner(this.tick);
2251
- if (this.issuesOn && this.host.issues.issues.length > 0) badges.issues = String(this.host.issues.issues.length);
2252
- if (this.host.reviews.busy) badges.git = spinner(this.tick);
2253
- else if (this.host.pulls.pulls.length > 0) badges.git = String(this.host.pulls.pulls.length);
2254
- if (this.data.excalidraw.length > 0) badges.excalidraw = String(this.data.excalidraw.length);
2255
- const cells = this.tabs().map((tab, index) => {
2256
- const number = paint(theme, tab === this.tab ? "accent" : "dim", String(index + 1));
2257
- const name = tab === this.tab ? bold(theme, paint(theme, "text", TAB_LABELS[tab])) : paint(theme, "muted", TAB_LABELS[tab]);
2258
- const badge = badges[tab] ? ` ${paint(theme, tab === "plan" && session?.awaitingAnswers ? "warning" : "accent", badges[tab]!)}` : "";
2259
- const cell = ` ${number} ${name}${badge} `;
2260
- return { tab, text: tab === this.tab ? (theme.bg ? theme.bg("selectedBg", cell) : `[${cell}]`) : cell };
2261
- });
2262
- const entry = this.viewedEntry();
2263
- const help = paint(theme, "dim", `${keyLabel(this.keys.help)} keys `);
2264
- const auto = entry.auto ? ` ${bold(theme, paint(theme, "success", "⟳ AUTO"))}` : "";
2265
- const waiting = this.host.sessions().reduce((count, session) => count + session.dialogs.length, 0);
2266
- const asking = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting · ${keyLabel(this.keys.sessions)}`)}` : "";
2267
- // Short of room, the key to open the sessions goes before the waiting count does.
2268
- const asked = waiting > 0 ? ` ${paint(theme, "warning", `● ${waiting} waiting`)}` : "";
2269
- let choices: string[];
2270
- if (entry.view.kind !== "here") {
2271
- // Another session in view: its name and what it is doing lead, so it is never mistaken for this window.
2272
- const working = entry.status === "working";
2273
- const dot = paint(theme, working ? "accent" : "dim", working ? spinner(this.tick) : "◆");
2274
- const where = paint(theme, "muted", `${entry.name} · ${entry.status}`);
2275
- choices = [`${dot} ${where}${auto}${asking} ${help}`, `${dot} ${where}${auto}${asking} `, `${dot} ${where}${auto}${asked} `, `${dot}${auto}${asked} `, `${dot} ${where}${auto} `, `${dot}${auto} `];
2276
- } else {
2277
- const dot = paint(theme, this.host.masterBusy() ? "accent" : "dim", this.host.masterBusy() ? spinner(this.tick) : "●");
2278
- const state = zen.task ? paint(theme, "muted", zen.task.paused ? `${zen.task.state} (paused)` : zen.task.state) : "";
2279
- // Long task ids give way to the state, then to the dot alone.
2280
- choices = zen.task
2281
- ? [`${dot} ${zen.task.id} ${state}${auto}${asking} ${help}`, `${dot} ${zen.task.id} ${state}${auto}${asking} `, `${dot} ${zen.task.id} ${state}${auto}${asked} `, `${dot} ${state}${auto}${asked} `, `${dot}${auto}${asked} `, `${dot} ${state}${auto} `, `${dot}${auto} `]
2282
- : [`${paint(theme, "dim", "no task in this session")}${asking} ${help}`, `${paint(theme, "dim", "no task in this session")}${asking} `, `${paint(theme, "dim", "no task in this session")}${asked} `, `${asked.trimStart()} `, help, ""];
2283
- }
2284
- // The longest title that leaves room for the tabs and, when a session waits on you, for saying so.
2285
- const tabsWidth = cells.reduce((sum, cell) => sum + textWidth(cell.text), 0);
2286
- const titles = this.titles(entry.task, theme);
2287
- let brand = titles.at(-1)!;
2288
- let status = "";
2289
- for (const [index, title] of titles.entries()) {
2290
- const room = width - textWidth(title) - 1 - tabsWidth - 1;
2291
- const shown = choices.find((choice) => textWidth(choice) <= room) ?? "";
2292
- if (index === titles.length - 1 || (room >= 0 && (waiting === 0 || shown.includes("waiting")))) {
2293
- brand = title;
2294
- status = shown;
2295
- break;
2296
- }
2297
- }
2298
- let left = `${brand}${paint(theme, "borderMuted", "│")}`;
2299
- const spans: Array<{ tab: TabId; from: number; to: number }> = [];
2300
- for (const cell of cells) {
2301
- const from = textWidth(left);
2302
- left += cell.text;
2303
- spans.push({ tab: cell.tab, from, to: textWidth(left) });
2304
- }
2305
- this.tabSpans = spans;
2306
- const gap = width - textWidth(left) - textWidth(status);
2307
- return gap >= 1 ? `${left}${" ".repeat(gap)}${status}` : fit(left, width);
2308
- }
2309
-
2310
- /**
2311
- * The lobby's title, longest first: the repository (or folder) it works
2312
- * from and the branch, `◆ my-repo (⎇ main)`, then the name alone, then just
2313
- * the mark. A task with a worktree of its own shows that branch, since that
2314
- * is where its work happens.
2315
- */
2316
- private titles(task: Task | undefined, theme: LobbyTheme): string[] {
2317
- const workspace = this.host.workspace?.();
2318
- const name = clip(workspace?.name || "bot-lobby", TITLE_NAME_MAX);
2319
- const own = task?.git?.mode === "worktree" ? task.git.branch : undefined;
2320
- const branch = own ?? workspace?.branch;
2321
- const glyph = paint(theme, "accent", " ◆ ");
2322
- const label = (text: string) => bold(theme, paint(theme, "accent", text));
2323
- return [
2324
- ...(branch ? [`${glyph}${label(name)} ${paint(theme, "muted", `(${BRANCH_GLYPH} ${clip(branch, TITLE_BRANCH_MAX)})`)} `] : []),
2325
- `${glyph}${label(name)} `,
2326
- glyph,
2327
- ];
2328
- }
2329
-
2330
- private promptLabel(): string {
2331
- const zen = this.host.zen();
2332
- const browsing = this.mode === "browse";
2333
- switch (this.tab) {
2334
- case "lobby": {
2335
- if (this.newSession) return "new task — it starts in its own session, named after it";
2336
- if (this.viewing.kind !== "here") {
2337
- const entry = this.viewedEntry();
2338
- const session = this.viewedSession();
2339
- if (session && !session.alive) return `${entry.name} has ended`;
2340
- if (session?.dialogs.length) return `press enter to answer ${entry.name}'s question, or type a message`;
2341
- if (entry.where === "not running") return `leave ${entry.name} a message for when it resumes · esc, s resumes it here`;
2342
- return entry.where === "other terminal" ? `message ${entry.name}'s oracle (its own session delivers it)` : `message ${entry.name}'s oracle`;
2343
- }
2344
- if (!zen.task) return browsing ? "describe a task to start — i to type" : "describe a task to start · enter starts it";
2345
- return browsing ? "message the oracle — i to type" : this.host.masterBusy() ? "message the oracle · enter steers the running turn" : "message the oracle";
2346
- }
2347
- case "tasks":
2348
- return this.commentTarget ? `comment on ${this.commentTarget}'s plan · enter sends it to the oracle` : "c comments on the selected task's plan";
2349
- case "plan": {
2350
- if (this.lineTarget) return `comment on “${clip(this.lineTarget, 48)}”`;
2351
- const session = this.host.planner();
2352
- if (session?.busy) return "the panel is thinking";
2353
- if (session?.awaitingAnswers) {
2354
- const count = session.questions.length;
2355
- return `press enter to answer ${count} question${count === 1 ? "" : "s"}, or type a reply`;
2356
- }
2357
- if (session?.reply?.status === "ready") return `the plan is ready — ${keyLabel(this.keys.savePlan)} saves it, or reply to refine it`;
2358
- return session && session.messages.length > 0 ? "reply to the panel" : "describe the task to plan";
2359
- }
2360
- case "quickfix":
2361
- return this.host.quickfix.running ? "describe a quick change; it runs after the current one" : "describe a quick change";
2362
- case "issues":
2363
- return this.issueDraft ? "new issue · first line is the title" : "n files a new issue";
2364
- case "knowledge": {
2365
- const picked = this.pickedEntry();
2366
- if (this.kDraft?.kind === "edit") return `edit “${clip(picked?.text ?? "", 40)}” · enter saves it · esc cancels`;
2367
- if (this.kDraft?.kind === "comment") return `a note on “${clip(picked?.text ?? "", 40)}” — every agent reads it under the entry · enter saves`;
2368
- if (this.kDraft?.kind === "add") return picked ? `a new entry after “${clip(picked.text, 40)}” · enter adds it` : "a new entry · enter adds it";
2369
- return "e edits the picked entry, c comments on it, n adds one";
2370
- }
2371
- case "excalidraw": {
2372
- if (this.xDraft === "add") return "paste the session's link (a name may follow it) · enter adds it · esc cancels";
2373
- if (this.xDraft === "new") return "a name for the new room (optional) · enter makes it · esc cancels";
2374
- if (this.xDraft === "rename") return "a new name for the session · enter saves it · esc cancels";
2375
- return "a adds a session from a link, n makes a new room";
2376
- }
2377
- case "git": {
2378
- const pull = this.selectedPull();
2379
- if (this.gitFocusDraft) return `what should the review of #${pull?.number ?? "?"} look at first? · enter reviews`;
2380
- return pull ? `f reviews #${pull.number} with a focus you type, v without one` : "r loads the open pull requests";
2381
- }
2382
- case "metrics":
2383
- return "";
2384
- }
2385
- }
2386
-
2387
- private promptLines(width: number, theme: LobbyTheme): string[] {
2388
- if (this.tab === "metrics") return [];
2389
- const typing = this.mode === "type" && !this.searching;
2390
- this.editor.label = this.promptLabel();
2391
- this.editor.paintLabel = (text) => paint(theme, typing ? (this.lineTarget ? "warning" : "accent") : "dim", text);
2392
- this.editor.borderColor = (text) => paint(theme, typing ? "border" : "borderMuted", text);
2393
- const lines = this.editor.render(width);
2394
- // The editor always draws its block cursor; while browsing it would suggest the prompt has focus.
2395
- return typing ? lines : lines.map((line) => line.replace(REVERSE_VIDEO, "$1"));
2396
- }
2397
-
2398
- /** The search bar: the query being typed, or the one in force. */
2399
- private searchLines(width: number, theme: LobbyTheme): string[] {
2400
- const query = this.query();
2401
- if (!this.searching && !query) return [];
2402
- const lead = `${paint(theme, "accent", " / ")}`;
2403
- const tail = this.searching
2404
- ? paint(theme, "dim", " enter keep · esc clear · ↑↓ browse results ")
2405
- : paint(theme, "dim", ` ${keyLabel(this.keys.search)} or / edits · esc clears `);
2406
- const room = Math.max(4, width - textWidth(lead) - textWidth(tail));
2407
- const field = this.searching ? this.search.render(room)[0] ?? "" : bold(theme, fit(query ?? "", room));
2408
- return [fit(`${lead}${fit(field, room)}${tail}`, width)];
2409
- }
2410
-
2411
- /** Every key the current tab understands while browsing. */
2412
- private tabKeys(): KeyHelp[] {
2413
- switch (this.tab) {
2414
- case "lobby":
2415
- return [
2416
- { key: "type", text: "talk to the oracle of the session in view (in this window, starts a task when none is running)" },
2417
- { key: "enter", text: "on an empty prompt: answer the question a background session waits on" },
2418
- { key: "s", text: "run the session in view in this window: a background one moves here, a task nobody runs resumes here" },
2419
- { key: "↑ ↓", text: "scroll the focused pane (PageUp/PageDown a page)" },
2420
- { key: "← →", text: "move between the conversation, activity log and thinking" },
2421
- { key: "Home End", text: "the oldest lines, or back to the newest" },
2422
- { key: "c", text: "comment on this task's plan" },
2423
- { key: "esc", text: "stop the oracle while it works" },
2424
- ];
2425
- case "tasks":
2426
- return [
2427
- { key: "↑ ↓", text: "select a task or plan, or scroll the detail (PageUp/PageDown)" },
2428
- { key: "enter / ← →", text: "move between the list and the detail" },
2429
- { key: "c", text: "comment on the selected task's plan" },
2430
- { key: "s", text: "start the selected planned task in a new session, named after it" },
2431
- { key: "h", text: "start the selected planned task here, in this window" },
2432
- { key: "n", text: "type a new task that starts in its own session" },
2433
- { key: "o", text: "show the session driving the selected task in the Lobby tab" },
2434
- { key: "x x", text: "stop the background session driving the selected task" },
2435
- { key: "d d", text: "discard the selected planned task, or delete the selected task for good" },
2436
- { key: "a", text: "archive the selected task (a task under way is abandoned first: a twice), or restore an archived one" },
2437
- { key: "A A", text: "archive every finished task" },
2438
- { key: "v", text: "show or hide archived tasks" },
2439
- { key: "r", text: "reread tasks from disk" },
2440
- ];
2441
- case "plan":
2442
- return [
2443
- { key: "a", text: "answer the panel's questions, one questionnaire at a time (enter on an empty prompt too)" },
2444
- { key: "enter / ← →", text: "move between the conversation and the draft" },
2445
- { key: "↑ ↓", text: "pick a draft line (draft) or scroll (conversation); PageUp/PageDown a page" },
2446
- { key: "c / click", text: "comment on the picked draft line" },
2447
- { key: "1-4", text: "seat or unseat DEV, DESIGN, QA, RESEARCH" },
2448
- { key: "n", text: "start a new plan" },
2449
- { key: "x", text: "stop the round" },
2450
- { key: "r", text: "retry a failed round or seat" },
2451
- { key: "m", text: "the oracle's model, thinking and time limit (Planner settings)" },
2452
- ];
2453
- case "quickfix":
2454
- return [
2455
- { key: "type", text: "describe a quick change" },
2456
- { key: "↑ ↓", text: "select a quick fix, or scroll the detail (PageUp/PageDown)" },
2457
- { key: "enter / ← →", text: "move between the list and the detail" },
2458
- { key: "x", text: "cancel the selected quick fix" },
2459
- { key: "m", text: "the quick fix agent's model, thinking, time limit and instructions" },
2460
- ];
2461
- case "issues":
2462
- return [
2463
- { key: "↑ ↓", text: "select an issue" },
2464
- { key: "enter", text: "read the selected issue" },
2465
- { key: "p", text: "plan the selected issue" },
2466
- { key: "n", text: "file a new issue" },
2467
- { key: "r", text: "reload issues" },
2468
- ];
2469
- case "knowledge":
2470
- return [
2471
- { key: "↑ ↓", text: "pick a file, or (in the entries) pick an entry; PageUp/PageDown several" },
2472
- { key: "enter / ← →", text: "move between the files and their entries" },
2473
- { key: "e", text: "edit the picked entry: it comes into the prompt (Shift+Enter for a new line, enter saves)" },
2474
- { key: "c", text: "comment on it: a note every agent reads under that entry" },
2475
- { key: "n", text: "add an entry after the picked one" },
2476
- { key: "d d", text: "delete the picked entry, and its notes" },
2477
- { key: "x x", text: "take back the newest note on the picked entry" },
2478
- { key: "E", text: "edit the whole file in pi's editor" },
2479
- { key: "r", text: "reread the files from disk" },
2480
- ];
2481
- case "excalidraw":
2482
- return [
2483
- { key: "↑ ↓", text: "pick a session, or (in the checklist) an agent" },
2484
- { key: "enter / ← →", text: "move between the sessions and the agents; in the checklist, enter or space assigns the picked agent" },
2485
- { key: "a", text: "add a session: paste the link from Excalidraw (Share → Live collaboration → Start session)" },
2486
- { key: "n", text: "make a new room, whose link you open in Excalidraw" },
2487
- { key: "*", text: "assign the session to every agent, or (when they all have it) to none" },
2488
- { key: "w", text: "let agents draw in the session, or only look at it" },
2489
- { key: "t", text: "check the session: join the room for a moment and report who is there and what is on the board" },
2490
- { key: "r", text: "rename the session" },
2491
- { key: "d d", text: "remove the session (agents lose it at once)" },
2492
- ];
2493
- case "git":
2494
- return [
2495
- { key: "↑ ↓", text: "select a pull request, or scroll its detail (PageUp/PageDown)" },
2496
- { key: "enter / ← →", text: "move between the list and the detail" },
2497
- { key: "v", text: "review it with a read-only agent on QA's model (nothing is posted to GitHub)" },
2498
- { key: "f", text: "review it with a focus you type first (Shift+Enter for a new line, enter reviews)" },
2499
- { key: "t", text: "Jev's quick read: size, risk, security, breaking changes, missing tests" },
2500
- { key: "x", text: "stop the review that is running" },
2501
- { key: "r", text: "reload the pull requests" },
2502
- ];
2503
- case "metrics":
2504
- return [
2505
- { key: "↑ ↓", text: "select a row of the table" },
2506
- { key: "g", text: "group by model, or by model and agent" },
2507
- { key: "s", text: "change the sort" },
2508
- { key: "r", text: "reread the metrics log" },
2509
- ];
2510
- }
2511
- }
2512
-
2513
- private chips(entries: ReadonlyArray<readonly [string, string]>, theme: LobbyTheme): string {
2514
- return entries.map(([key, text]) => `${paint(theme, "accent", key)} ${paint(theme, "dim", text)}`).join(paint(theme, "dim", " "));
2515
- }
2516
-
2517
- private hintLine(width: number, theme: LobbyTheme): string {
2518
- const notice = this.notice && this.now() - this.notice.at < NOTICE_MS ? this.notice : undefined;
2519
- if (notice) return fit(paint(theme, notice.kind === "warning" ? "warning" : "accent", ` ${notice.text}`), width);
2520
- const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
2521
- const badge = (text: string, color: "accent" | "muted" | "warning") => {
2522
- const label = bold(theme, paint(theme, color, ` ${text} `));
2523
- return theme.bg ? theme.bg("selectedBg", label) : label;
2524
- };
2525
- if (this.help) return fit(`${badge("KEYS", "accent")} ${this.chips([["any key", "closes"]], theme)}`, width);
2526
- if (this.searching) return fit(`${badge("SEARCH", "warning")} ${this.chips([["enter", "keep"], ["esc", "clear"], ["↑↓", "results"], [k("nextTab"), "next tab"]], theme)}`, width);
2527
- const mode = this.mode === "type" ? badge("TYPE", "accent") : badge("BROWSE", "muted");
2528
- const keys = `${mode} ${this.chips(this.hintChips(), theme)}`;
2529
- // The subagents at work sit at the right end of the line, when the keys leave room.
2530
- const working = this.viewing.kind === "here" ? agentsIndicator(workingAgents(this.host.zen().runs, this.host.quickfix.running), this.now(), spinner(this.tick), width - textWidth(keys) - 3, theme) : "";
2531
- const gap = width - textWidth(keys) - textWidth(working) - 1;
2532
- return working && gap >= 2 ? `${keys}${" ".repeat(gap)}${working} ` : fit(keys, width);
2533
- }
2534
-
2535
- /**
2536
- * The few keys that matter right now, most useful first: typing names what
2537
- * enter does here; browsing names the tab's main commands, only those that
2538
- * apply. Everything else is one `?` away.
2539
- */
2540
- private hintChips(): Array<[string, string]> {
2541
- const k = (action: LobbyAction) => keyLabel(this.keys[action]).toLowerCase();
2542
- const session = this.host.planner();
2543
- if (this.picking) return [["↑↓", "browse"], ["enter", "view"], ["s", "switch here"], ["x x", "stop"], ["n", "new"], ["esc", "close"]];
2544
- if (this.mode === "type") {
2545
- if (this.tab === "lobby" && this.newSession) return [["enter", "start in a new session"], ["esc", "cancel"]];
2546
- if (this.tab === "lobby" && this.viewing.kind !== "here") {
2547
- const background = this.viewedSession();
2548
- if (background?.dialogs.length && !this.editor.getText().trim()) return [["enter", "answer its question"], [k("sessions"), "sessions"], [k("hide"), "hide"]];
2549
- return [["enter", background?.busy ? "steer" : "send"], [k("sessions"), "sessions"], [k("toggleAuto"), "auto"]];
2550
- }
2551
- if (this.tab === "lobby" && this.host.masterBusy()) return [["enter", "steer"], ["esc", "stop the oracle"], [k("hide"), "hide"]];
2552
- if (this.tab === "plan" && this.lineTarget) return [["enter", "add the comment"], ["esc", "cancel"]];
2553
- if (this.tab === "knowledge" && this.kDraft) return [["enter", this.kDraft.kind === "edit" ? "save" : this.kDraft.kind === "comment" ? "save the note" : "add it"], ["shift+enter", "new line"], ["esc", "cancel"]];
2554
- if (this.tab === "excalidraw" && this.xDraft) return [["enter", this.xDraft === "add" ? "add it" : this.xDraft === "new" ? "make it" : "save"], ["esc", "cancel"]];
2555
- if (this.tab === "git" && this.gitFocusDraft) return [["enter", "review with this focus"], ["shift+enter", "new line"], ["esc", "cancel"]];
2556
- const enter = this.tab === "plan" && session?.awaitingAnswers && !this.editor.getText().trim() ? "answer questions" : this.tab === "quickfix" ? "run it" : "send";
2557
- return [["enter", enter], ["esc", "browse"], [k("hide"), "hide"]];
2558
- }
2559
- const keys: Array<[string, string]> = [];
2560
- switch (this.tab) {
2561
- case "lobby":
2562
- keys.push(["←→", "pane"], ["↑↓", "scroll"], [k("sessions"), "sessions"], [k("newSession"), "new session"]);
2563
- if (this.viewing.kind === "background" || this.viewing.kind === "idle") keys.push(["s", "switch here"]);
2564
- if (this.viewedEntry().task) keys.push([k("toggleAuto"), "auto"], ["c", "comment on plan"]);
2565
- break;
2566
- case "tasks": {
2567
- const row = this.taskRowList()[this.tasksSelected];
2568
- keys.push(["↑↓", "select"]);
2569
- const finished = row?.kind === "task" && TERMINAL_STATES.includes(row.status as Task["state"]);
2570
- if (row?.kind === "task" && !finished) keys.push(["c", "comment"], ["o", "open its session"], [k("toggleAuto"), "auto"]);
2571
- if (row?.kind === "task") keys.push(["a", "archive"], ["d d", "delete"]);
2572
- if (row?.kind === "archived") keys.push(["a", "restore"], ["d d", "delete"]);
2573
- if (row?.kind === "plan") keys.push(["s", "start in a new session"], ["h", "start here"], ["d", "discard"]);
2574
- keys.push(["v", this.showArchived ? "hide archived" : "archived"], ["n", "new task"]);
2575
- break;
2576
- }
2577
- case "plan":
2578
- if (session?.awaitingAnswers) keys.push(["a", "answer"]);
2579
- if (session?.reply?.plan) keys.push([k("savePlan"), "save"], ["c", "comment on a line"]);
2580
- if (session?.busy) keys.push(["x", "stop"]);
2581
- keys.push(["n", "new"], ["1-4", "seats"]);
2582
- break;
2583
- case "quickfix":
2584
- if (this.host.quickfix.jobs.length > 0) keys.push(["↑↓", "select"], ["x", "cancel"]);
2585
- if (this.fixJobs()[this.fixSelected]?.status === "held") keys.push(["r", "run anyway"], ["t", "as a task"]);
2586
- keys.push(["m", "model"]);
2587
- break;
2588
- case "issues":
2589
- keys.push(["↑↓", "select"], ["p", "plan it"], ["n", "new"]);
2590
- break;
2591
- case "knowledge":
2592
- keys.push(["↑↓", "pick"], ["enter", this.kFocus === "list" ? "entries" : "files"]);
2593
- if (this.kFocus === "detail" || this.pickedEntry()) keys.push(["e", "edit"], ["c", "comment"], ["n", "new"], ["d d", "delete"]);
2594
- keys.push(["E", "edit file"]);
2595
- break;
2596
- case "excalidraw": {
2597
- const session = this.data.excalidraw[this.xSelected];
2598
- keys.push(["a", "add a link"], ["n", "new room"]);
2599
- if (session) keys.push(["↑↓", this.xFocus === "detail" ? "agent" : "session"], ["enter", this.xFocus === "detail" ? "assign" : "agents"], ["t", "check"], ["w", session.contribute ? "look only" : "let agents draw"], ["d d", "remove"]);
2600
- break;
2601
- }
2602
- case "git": {
2603
- const pull = this.selectedPull();
2604
- keys.push(["↑↓", "select"]);
2605
- if (pull) keys.push(["v", "review"], ["f", "review with a focus"], ["t", "Jev's read"]);
2606
- if (pull && this.host.reviews.running(pull.number)) keys.push(["x", "stop"]);
2607
- keys.push(["r", "reload"]);
2608
- break;
2609
- }
2610
- case "metrics":
2611
- keys.push(["↑↓", "select"], ["g", "group"], ["s", "sort"]);
2612
- break;
2613
- }
2614
- return [...keys, ["?", "all keys"]];
2615
- }
2616
-
2617
- /** The help screen: every shortcut, the typing and browsing keys, and this tab's commands. */
2618
- private helpBody(width: number, height: number, theme: LobbyTheme): string[] {
2619
- const keyWidth = 13;
2620
- const row = (key: string, text: string, inner: number) => wrapHanging(`${paint(theme, "accent", fit(key, keyWidth))} `, text, inner);
2621
- const section = (title: string) => bold(theme, paint(theme, "mdHeading", title));
2622
- const tabs = this.tabs();
2623
- const everywhere = (inner: number) => [
2624
- section("Everywhere"),
2625
- ...(Object.keys(LOBBY_ACTIONS) as LobbyAction[]).flatMap((action) => row(keyLabel(this.keys[action]), LOBBY_ACTIONS[action].help, inner)),
2626
- ...row(`Alt+1…${tabs.length}`, "jump to a tab", inner),
2627
- ...row("Ctrl+C", "clear the prompt, or hide the lobby", inner),
2628
- ...row("click", "a tab to open it, a pane to give it the keys, a draft plan line to comment on it", inner),
2629
- ...row("wheel", "scroll the pane under the pointer", inner),
2630
- ...row("prev next", "click on a long pane's bottom edge (▲ prev · page 2/5 · next ▼) to move a page; dimmed at either end", inner),
2631
- ];
2632
- const modes = (inner: number) => [
2633
- section("Typing"),
2634
- ...row("Enter", "send", inner),
2635
- ...row("Shift+Enter", "new line", inner),
2636
- ...row("Esc", "stop typing and browse", inner),
2637
- "",
2638
- section("Browsing"),
2639
- ...row("i", "type", inner),
2640
- ...row("/", "search this tab (esc clears)", inner),
2641
- ...row("?", "these keys", inner),
2642
- "",
2643
- section(`${TAB_LABELS[this.tab]} tab`),
2644
- ...this.tabKeys().flatMap((entry) => row(entry.key, entry.text, inner)),
2645
- ];
2646
- const footer = paint(theme, "dim", "Rebind any shortcut under lobby.keys in the bot-lobby config, e.g. { \"toggleThinking\": \"alt+t\" }. Hidden panes stay hidden next time (lobby.panels); lobby.mouse turns clicks off.");
2647
- const title = { title: "Keys", right: "any key closes", focused: true, theme } as const;
2648
- if (width >= 100) {
2649
- const left = Math.floor((width - 1) / 2);
2650
- const inner = left - 4;
2651
- const columns = beside([
2652
- box(left, height, [...everywhere(inner), "", ...wrapHanging("", footer, inner)], title),
2653
- box(width - 1 - left, height, modes(width - 1 - left - 4), { focused: true, theme }),
2654
- ]);
2655
- return columns;
2656
- }
2657
- const inner = width - 4;
2658
- return box(width, height, [...everywhere(inner), "", ...modes(inner), "", ...wrapHanging("", footer, inner)], title);
2659
- }
2660
-
2661
- private body(width: number, height: number, theme: LobbyTheme): string[] {
2662
- const now = this.now();
2663
- const query = this.query();
2664
- this.panes.clear();
2665
- switch (this.tab) {
2666
- case "lobby":
2667
- return this.homeBody(width, height, theme, now);
2668
- case "tasks":
2669
- return this.tasksBody(width, height, theme, now);
2670
- case "plan": {
2671
- const session = this.host.planner();
2672
- const lines = renderPlan({
2673
- ...(session ? { session: this.planView(session) } : {}),
2674
- profile: this.host.profileLabel("planner"),
2675
- seats: this.seatViews(undefined),
2676
- limit: this.host.planningRounds?.() ?? 0,
2677
- offset: this.planOffset,
2678
- focus: this.planFocus,
2679
- draftOffset: this.planDraftOffset,
2680
- cursor: this.planCursor,
2681
- tick: this.tick,
2682
- ...(query ? { query } : {}),
2683
- layout: this.planLayout,
2684
- panes: this.panes,
2685
- saveKey: keyLabel(this.keys.savePlan),
2686
- }, width, height, theme);
2687
- if (!session) Object.assign(this.planLayout, { draftRows: 0, draftStart: 0, draftText: [] });
2688
- this.planCursor = Math.min(this.planCursor, Math.max(0, this.planLayout.draftText.length - 1));
2689
- return lines;
2690
- }
2691
- case "quickfix":
2692
- this.fixSelected = Math.min(this.fixSelected, Math.max(0, this.fixJobs().length - 1));
2693
- return renderQuickFix({ jobs: this.host.quickfix.jobs, selected: this.fixSelected, focus: this.fixFocus, detailOffset: this.fixDetailOffset, profile: this.host.profileLabel("quickfix"), tick: this.tick, now, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
2694
- case "issues": {
2695
- const issues = this.host.issues;
2696
- this.issueSelected = Math.min(this.issueSelected, Math.max(0, issues.issues.length - 1));
2697
- const selected = issues.issues[this.issueSelected];
2698
- const detail = selected ? issues.details.get(selected.number) : undefined;
2699
- return renderIssues({
2700
- issues: issues.issues, selected: this.issueSelected, ...(detail ? { detail } : {}), focus: this.issueFocus, detailOffset: this.issueDetailOffset,
2701
- loading: issues.loading, loaded: issues.loaded, ...(issues.error ? { error: issues.error } : {}), ...(issues.notice ? { notice: issues.notice } : {}), tick: this.tick, now,
2702
- }, width, height, theme);
2703
- }
2704
- case "knowledge": {
2705
- const knowledge = this.data.knowledge;
2706
- this.kSelected = Math.min(this.kSelected, Math.max(0, knowledge.files.length - 1));
2707
- this.kCursor = Math.min(this.kCursor, Math.max(0, (knowledge.view?.entries.length ?? 1) - 1));
2708
- const lines = renderKnowledge({ files: knowledge.files, selected: this.kSelected, ...(knowledge.view ? { view: knowledge.view } : {}), cursor: this.kCursor, focus: this.kFocus, offset: this.kOffset, layout: this.kLayout }, width, height, theme);
2709
- this.kOffset = this.kLayout.offset;
2710
- return lines;
2711
- }
2712
- case "excalidraw": {
2713
- this.xSelected = Math.min(this.xSelected, Math.max(0, this.data.excalidraw.length - 1));
2714
- return renderExcalidraw({ sessions: this.data.excalidraw, selected: this.xSelected, focus: this.xFocus, agent: this.xAgent, checks: this.xChecks, checking: this.xChecking, tick: this.tick }, width, height, theme);
2715
- }
2716
- case "git": {
2717
- const pulls = this.host.pulls;
2718
- this.gitSelected = Math.min(this.gitSelected, Math.max(0, pulls.pulls.length - 1));
2719
- const selected = pulls.pulls[this.gitSelected];
2720
- const detail = selected ? pulls.details.get(selected.number) : undefined;
2721
- const review = selected ? this.host.reviews.review(selected.number) : undefined;
2722
- const read = selected ? this.host.reviews.reads.get(selected.number) : undefined;
2723
- return renderGit({
2724
- pulls: pulls.pulls, selected: this.gitSelected, ...(detail ? { detail } : {}), ...(review ? { review } : {}), ...(read ? { read } : {}),
2725
- focus: this.gitFocus, detailOffset: this.gitDetailOffset, loading: pulls.loading, loaded: pulls.loaded, ...(pulls.error ? { error: pulls.error } : {}), tick: this.tick, now,
2726
- }, width, height, theme);
2727
- }
2728
- case "metrics": {
2729
- const { records, groups, taskTimes, stats, jev } = this.metricsFigures(query);
2730
- this.metricsSelected = Math.min(this.metricsSelected, Math.max(0, groups.length - 1));
2731
- return renderMetrics({ groups, taskTimes, records, stats, by: this.metricsBy, sort: this.metricsSort, selected: this.metricsSelected, ...(query ? { query } : {}), panes: this.panes, ...(jev ? { classifier: jev } : {}) }, width, height, theme);
2732
- }
2733
- }
2734
- }
2735
-
2736
- /** The roster: the oracle chairing, then every domain seat in order, seated or not. */
2737
- private seatViews(session: PlanningSession | undefined): SeatView[] {
2738
- const seats = session?.seats ?? this.seatSet();
2739
- const oracleQuestions = session?.questions.filter((question) => question.from === ORACLE_LABEL).length ?? 0;
2740
- // While the seats work the oracle waits for them; its own part fails only when the round reports an error.
2741
- const status: SeatView["status"] = !session || session.turns === 0 ? "idle" : session.busy ? "thinking" : session.error ? "failed" : "done";
2742
- const oracle: SeatView = {
2743
- label: ORACLE_LABEL,
2744
- seated: true,
2745
- status,
2746
- ...(session?.busy && session.step ? { step: session.step } : {}),
2747
- questions: oracleQuestions,
2748
- ready: session?.reply?.status === "ready",
2749
- profile: this.host.profileLabel("planner"),
2750
- };
2751
- const members = PANEL_MEMBERS.map((member): SeatView => {
2752
- const state = session?.members.find((entry) => entry.member === member);
2753
- return {
2754
- label: MEMBER_LABELS[member],
2755
- seated: seats.has(member),
2756
- status: state ? (state.status === "thinking" ? "thinking" : state.status) : "idle",
2757
- ...(state?.step ? { step: state.step } : {}),
2758
- // The questions from this seat that reached the user this round.
2759
- questions: session?.questions.filter((question) => question.from === MEMBER_LABELS[member]).length ?? 0,
2760
- ready: state?.reply?.status === "ready",
2761
- profile: this.host.seatLabel(member),
2762
- ...(!state && session?.satOut.has(member) ? { satOut: session.satOut.get(member)! } : {}),
2763
- ...(session?.pins.has(member) ? { pinned: true } : {}),
2764
- };
2765
- });
2766
- return [oracle, ...members];
2767
- }
2768
-
2769
- private planView(session: PlanningSession): PlanView {
2770
- return {
2771
- messages: session.messages,
2772
- ...(session.reply ? { reply: session.reply } : {}),
2773
- questions: session.questions,
2774
- notes: session.notes,
2775
- seats: this.seatViews(session),
2776
- busy: session.busy,
2777
- ...(session.step ? { step: session.step } : {}),
2778
- ...(session.error ? { error: session.error } : {}),
2779
- turns: session.turns,
2780
- limit: session.limit,
2781
- nextMode: session.nextMode,
2782
- ...(session.seed ? { seed: session.seed } : {}),
2783
- ...(session.saved ? { saved: session.saved } : {}),
2784
- ...(session.savedParts.length > 1 ? { savedParts: session.savedParts.length } : {}),
2785
- ...(session.title ? { title: session.title } : {}),
2786
- awaitingAnswers: session.awaitingAnswers,
2787
- answeredChunks: session.answered.length,
2788
- lineComments: session.lineComments,
2789
- };
2790
- }
2791
-
2792
- /**
2793
- * The session browser: every session this window can show, grouped by
2794
- * where it runs, with a preview of the one picked — its task, what it is
2795
- * doing and the last of its conversation.
2796
- */
2797
- private pickerBody(width: number, height: number, theme: LobbyTheme): string[] {
2798
- const entries = this.sessionEntries();
2799
- this.pickIndex = Math.min(this.pickIndex, Math.max(0, entries.length - 1));
2800
- const wide = width >= 90;
2801
- const listWidth = wide ? Math.max(38, Math.round((width - 1) * 0.42)) : width;
2802
- const inner = listWidth - 4;
2803
- const lines: string[] = [];
2804
- let selectedLine = 0;
2805
- let section: SessionWhere | undefined;
2806
- const counts = new Map<SessionWhere, number>();
2807
- for (const entry of entries) counts.set(entry.where, (counts.get(entry.where) ?? 0) + 1);
2808
- entries.forEach((entry, index) => {
2809
- if (entry.where !== section) {
2810
- section = entry.where;
2811
- if (lines.length > 0) lines.push("");
2812
- lines.push(rule(inner, SECTION_OF[section], theme, String(counts.get(section) ?? 0)));
2813
- }
2814
- const selected = index === this.pickIndex;
2815
- if (selected) selectedLine = lines.length;
2816
- const mark = paint(theme, entry.where === "this window" ? "accent" : entry.where === "not running" ? "dim" : "toolTitle", WHERE_MARKS[entry.where]);
2817
- const name = selected ? bold(theme, entry.name) : entry.where === "not running" ? paint(theme, "muted", entry.name) : entry.name;
2818
- const status = paint(theme, entry.status === "working" ? "accent" : entry.status === "ended" || entry.status === "no task" ? "dim" : "muted", entry.status.replace(/_/g, " "));
2819
- const badges = [entry.auto ? paint(theme, "success", "⟳") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting}`) : ""].filter(Boolean).join(" ");
2820
- const head = `${selected ? paint(theme, "accent", "▸") : " "} ${mark} ${name}`;
2821
- lines.push(selectRow(theme, spread(head, `${status}${badges ? ` ${badges}` : ""}`, inner), inner, selected, true));
2822
- });
2823
- const rows = Math.max(0, height - 2);
2824
- const start = windowStart(selectedLine, lines.length, rows);
2825
- const list = box(listWidth, height, lines.slice(start), { title: "Sessions", right: `${entries.length}`, focused: true, scroll: { total: lines.length, start }, theme });
2826
- if (!wide) return list;
2827
- const previewWidth = width - 1 - listWidth;
2828
- const picked = entries[this.pickIndex];
2829
- const preview = picked ? this.sessionPreview(picked, previewWidth - 4, rows, theme) : [];
2830
- return beside([list, box(previewWidth, height, preview, { title: "Preview", theme })]);
2831
- }
2832
-
2833
- /** What the browser shows of one session: who and where, its task, what `enter` and `s` do, then the end of its conversation. */
2834
- private sessionPreview(entry: SessionEntry, width: number, height: number, theme: LobbyTheme): string[] {
2835
- const dot = paint(theme, "dim", " · ");
2836
- const task = entry.task;
2837
- const lines = [
2838
- ...wrapHanging(`${paint(theme, entry.where === "this window" ? "accent" : "toolTitle", WHERE_MARKS[entry.where])} `, bold(theme, entry.name), width),
2839
- ...wrapHanging(" ", [paint(theme, "muted", entry.where), paint(theme, entry.status === "working" ? "accent" : "muted", entry.status.replace(/_/g, " ")), entry.pid ? paint(theme, "dim", `pid ${entry.pid}`) : ""].filter(Boolean).join(dot), width),
2840
- ];
2841
- if (task) {
2842
- const progress = taskProgress(task);
2843
- lines.push(...wrapHanging(" ", [paint(theme, "dim", task.id), progress ? `${pips(progress.done, progress.total, Math.min(progress.total, 8), theme)} ${paint(theme, "muted", `${progress.done}/${progress.total}`)}` : ""].filter(Boolean).join(dot), width));
2844
- }
2845
- const flags = [entry.auto ? paint(theme, "success", "⟳ auto mode") : "", entry.waiting > 0 ? paint(theme, "warning", `● ${entry.waiting} question${entry.waiting === 1 ? "" : "s"} waiting for you`) : ""].filter(Boolean);
2846
- if (flags.length > 0) lines.push(...wrapHanging(" ", flags.join(dot), width));
2847
- const key = (text: string) => paint(theme, "accent", text);
2848
- const does: Record<SessionWhere, string> = {
2849
- "this window": `${key("enter")} ${paint(theme, "muted", "back to this window")}`,
2850
- background: `${key("enter")} ${paint(theme, "muted", "view it")} ${key("s")} ${paint(theme, "muted", "move it into this window")} ${key("x x")} ${paint(theme, "muted", "stop it")}`,
2851
- "other terminal": `${key("enter")} ${paint(theme, "muted", "view it and message it")} ${paint(theme, "dim", "switch in its own terminal")}`,
2852
- "not running": `${key("enter")} ${paint(theme, "muted", "view it")} ${key("s")} ${paint(theme, "muted", entry.sessionId ? "resume it in this window" : "take it over in this window")}`,
2853
- };
2854
- lines.push("", ...wrapHanging(" ", does[entry.where], width));
2855
- const chat = this.previewChat(entry);
2856
- const room = height - lines.length - 2;
2857
- if (room > 2) {
2858
- lines.push("", rule(width, "Conversation", theme));
2859
- const talk = chat.length > 0 ? chatTail(chat, width, theme, room - 1).lines : [paint(theme, "dim", "Nothing said yet.")];
2860
- lines.push(...talk.slice(-(room - 1)));
2861
- }
2862
- return lines;
2863
- }
2864
-
2865
- /** The conversation behind an entry: this window's feed, a background session's, or a saved session file. */
2866
- private previewChat(entry: SessionEntry): readonly ChatEntry[] {
2867
- if (entry.view.kind === "here") return this.host.feed.chat;
2868
- if (entry.view.kind === "background") return this.viewedSessionFor(entry.view.key)?.feed.chat ?? [];
2869
- return entry.sessionId ? this.host.sessionChat(entry.sessionId) : [];
2870
- }
2871
-
2872
- private viewedSessionFor(key: string): BackgroundSession | undefined {
2873
- return this.host.sessions().find((session) => session.key === key);
2874
- }
2875
-
2876
- private homeBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
2877
- const zen = this.host.zen();
2878
- const feed = this.host.feed;
2879
- const sessionId = this.host.sessionId();
2880
- const others = this.data.tasks.filter((task) => !TERMINAL_STATES.includes(task.state) && task.ownerSessionId !== sessionId).length;
2881
- const query = this.query();
2882
- const entry = this.viewedEntry();
2883
- if (entry.view.kind !== "here") return this.otherSessionBody(entry, width, height, theme, now);
2884
- const talk = this.conversationFor(entry);
2885
- return renderHome({
2886
- ...(zen.task ? { task: zen.task } : {}),
2887
- chat: talk.chat,
2888
- ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2889
- ...(feed.reply ? { liveReply: feed.reply } : {}),
2890
- activity: feed.activity,
2891
- thoughts: feed.thoughts,
2892
- busy: this.host.masterBusy(),
2893
- others,
2894
- pending: this.data.plans.filter((plan) => plan.status === "pending").length,
2895
- offsets: this.homeOffsets,
2896
- anchors: this.marks,
2897
- ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
2898
- panes: this.panes,
2899
- tick: this.tick,
2900
- now,
2901
- panels: this.panels,
2902
- ...(query ? { query } : {}),
2903
- keys: {
2904
- conversation: keyLabel(this.keys.toggleConversation),
2905
- activity: keyLabel(this.keys.toggleActivity),
2906
- thinking: keyLabel(this.keys.toggleThinking),
2907
- },
2908
- }, width, height, theme);
2909
- }
2910
-
2911
- /**
2912
- * Another session on the Lobby tab: a background session's live feed, or an
2913
- * other terminal's conversation read from its session file.
2914
- */
2915
- private otherSessionBody(entry: SessionEntry, width: number, height: number, theme: LobbyTheme, now: number): string[] {
2916
- const session = this.viewedSession();
2917
- const talk = this.conversationFor(entry);
2918
- const task = entry.task;
2919
- const query = this.query();
2920
- const common = {
2921
- ...(task ? { task } : {}),
2922
- others: 0,
2923
- pending: 0,
2924
- offsets: this.homeOffsets,
2925
- anchors: this.marks,
2926
- ...(this.mode === "browse" ? { focus: this.homeFocus } : {}),
2927
- panes: this.panes,
2928
- tick: this.tick,
2929
- now,
2930
- panels: this.panels,
2931
- ...(query ? { query } : {}),
2932
- title: entry.name,
2933
- keys: {
2934
- conversation: keyLabel(this.keys.toggleConversation),
2935
- activity: keyLabel(this.keys.toggleActivity),
2936
- thinking: keyLabel(this.keys.toggleThinking),
2937
- },
2938
- };
2939
- if (session) {
2940
- const emptyNote = session.status === "starting" ? `starting ${session.name}…` : !session.alive ? `${session.name} has ended.` : `${session.name} has not said anything yet.`;
2941
- return renderHome({
2942
- ...common,
2943
- chat: talk.chat,
2944
- ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2945
- ...(session.feed.reply ? { liveReply: session.feed.reply } : {}),
2946
- activity: session.feed.activity,
2947
- thoughts: session.feed.thoughts,
2948
- busy: session.busy,
2949
- emptyNote,
2950
- }, width, height, theme);
2951
- }
2952
- const running = entry.where === "other terminal";
2953
- return renderHome({
2954
- ...common,
2955
- chat: talk.chat,
2956
- ...(talk.older ? { olderNote: OLDER_NOTE } : {}),
2957
- activity: [],
2958
- thoughts: [],
2959
- busy: false,
2960
- emptyNote: running ? "Nothing said in this session yet." : entry.sessionId ? "Nothing was said in its session." : "No session has driven this task yet.",
2961
- activityNote: running
2962
- ? "This session runs in another terminal: its conversation shows here and your messages reach its oracle within seconds, but live activity only streams from sessions started in this window."
2963
- : `No session is running this task. Press esc, then s to ${entry.sessionId ? "resume it" : "take it over"} in this window; a message you send now waits until a session picks it up.`,
2964
- }, width, height, theme);
2965
- }
2966
-
2967
- private tasksBody(width: number, height: number, theme: LobbyTheme, now: number): string[] {
2968
- const rows = this.taskRowList();
2969
- this.tasksSelected = Math.min(this.tasksSelected, Math.max(0, rows.length - 1));
2970
- const row = rows[this.tasksSelected];
2971
- const detailWidth = tasksWidths(width).detail - 4;
2972
- let detail: string[] = [];
2973
- if (row?.kind === "task") {
2974
- const task = this.data.tasks.find((entry) => entry.id === row.id);
2975
- if (task) detail = taskDetailLines(task, this.data.comments.get(task.id) ?? [], this.host.sessionId(), detailWidth, now, theme);
2976
- } else if (row?.kind === "plan") {
2977
- const plan = this.data.plans.find((entry) => entry.id === row.id);
2978
- if (plan) detail = planDetailLines(plan, detailWidth, now, theme);
2979
- } else if (row?.kind === "archived") {
2980
- const task = this.data.archived.find((entry) => entry.id === row.id);
2981
- const key = (text: string) => paint(theme, "accent", text);
2982
- if (task) {
2983
- detail = [
2984
- ...taskDetailLines(task, [], this.host.sessionId(), detailWidth, now, theme).slice(0, 3),
2985
- ...wrapHanging(" ", paint(theme, "warning", `archived ${row.age === "now" ? "just now" : `${row.age} ago`}`), detailWidth),
2986
- "",
2987
- ...wrapHanging(" ", `${key("a")} ${paint(theme, "muted", "restore it to the list")} ${key("d d")} ${paint(theme, "muted", "delete it for good")}`, detailWidth),
2988
- ...taskDetailLines(task, [], this.host.sessionId(), detailWidth, now, theme).slice(3),
2989
- ];
2990
- }
2991
- }
2992
- const query = this.query();
2993
- return renderTasks({ rows, selected: this.tasksSelected, detail, focus: this.tasksFocus, detailOffset: this.tasksDetailOffset, ...(query ? { query } : {}), panes: this.panes }, width, height, theme);
2994
- }
2995
-
2996
- render(width: number): string[] {
2997
- const theme = this.host.theme();
2998
- const rows = Math.max(1, this.host.rows());
2999
- const top = [this.tabBar(width, theme)];
3000
- const prompt = this.promptLines(width, theme);
3001
- const search = this.searchLines(width, theme);
3002
- const hint = this.hintLine(width, theme);
3003
- const bodyHeight = Math.max(0, rows - top.length - search.length - prompt.length - 1);
3004
- this.bodyTop = top.length;
3005
- this.promptTop = top.length + bodyHeight + search.length;
3006
- let body = this.help ? this.helpBody(width, bodyHeight, theme) : this.picking ? this.pickerBody(width, bodyHeight, theme) : this.body(width, bodyHeight, theme);
3007
- // A pane scrolled back that gained lines moves with them, so it keeps showing what was being read.
3008
- if (!this.help && !this.picking && this.clampScroll()) body = this.body(width, bodyHeight, theme);
3009
- const query = this.query();
3010
- if (query && !this.help) body = body.map((line) => highlight(line, query));
3011
- return [...top, ...body, ...search, ...prompt, hint].slice(0, rows).map((line) => fit(line, width));
3012
- }
3013
-
3014
- invalidate(): void {
3015
- this.editor.invalidate();
3016
- this.search.invalidate();
3017
- }
3018
- }