@a-t-h-i/bot-lobby 0.5.1 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,288 @@
1
+ /**
2
+ * Background sessions: a task started "in a new session" from the lobby runs
3
+ * in its own headless pi process (`pi --mode rpc --name <task>`). It is an
4
+ * ordinary saved pi session, named after its task so `/resume` finds it, that
5
+ * this window drives over pi's RPC protocol: the lobby shows its conversation,
6
+ * activity and thoughts live (a feed per session, fed from its events), sends
7
+ * it your messages, and puts the questions it asks to you in this window.
8
+ */
9
+ import { spawn, type ChildProcess } from "node:child_process";
10
+ import { resolvePiInvocation } from "../execution/pi-runner.ts";
11
+ import { describeToolCall } from "../pi/activity.ts";
12
+ import { LobbyFeed, narrateEvent, type AgentEventLike } from "./feed.ts";
13
+
14
+ /** A question a background session is waiting on, as its RPC request carries it. */
15
+ export interface SessionDialog {
16
+ id: string;
17
+ method: "select" | "confirm" | "input" | "editor";
18
+ title: string;
19
+ message?: string;
20
+ options?: string[];
21
+ placeholder?: string;
22
+ prefill?: string;
23
+ }
24
+
25
+ /** An answer to a dialog: the value, a yes/no, or put away. */
26
+ export type DialogAnswer = { value: string } | { confirmed: boolean } | { cancelled: true };
27
+
28
+ /** The child process as the session needs it (a real ChildProcess in pi, a fake in tests). */
29
+ export interface SessionProcess {
30
+ stdin: { write(text: string): unknown; end(): unknown; on?(event: "error", listener: (error: Error) => void): unknown } | null;
31
+ stdout: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
32
+ stderr: { on(event: "data", listener: (chunk: Buffer | string) => void): unknown } | null;
33
+ on(event: "exit", listener: (code: number | null) => void): unknown;
34
+ on(event: "error", listener: (error: Error) => void): unknown;
35
+ kill(signal?: NodeJS.Signals): unknown;
36
+ pid?: number;
37
+ }
38
+
39
+ export type SessionStatus = "starting" | "idle" | "working" | "exited";
40
+
41
+ export interface SessionStart {
42
+ /** The display name: the task's title. */
43
+ name: string;
44
+ /** What to send once it is up: the request, or a planned task to start. */
45
+ request?: string;
46
+ planId?: string;
47
+ auto?: boolean;
48
+ }
49
+
50
+ let counter = 0;
51
+
52
+ export class BackgroundSession {
53
+ readonly key: string;
54
+ readonly name: string;
55
+ /** The planned task it was started from. */
56
+ readonly planId?: string;
57
+ readonly feed = new LobbyFeed();
58
+ readonly startedAt = Date.now();
59
+ status: SessionStatus = "starting";
60
+ sessionId?: string;
61
+ sessionFile?: string;
62
+ /** The footer status the session sets (bot-lobby's task line), when it sets one. */
63
+ statusText?: string;
64
+ exitCode?: number | null;
65
+ /** Dialogs the session waits on, oldest first. */
66
+ readonly dialogs: SessionDialog[] = [];
67
+ private readonly proc: SessionProcess;
68
+ private buffer = "";
69
+ private stderr = "";
70
+ private nextId = 0;
71
+ private readonly onChange: () => void;
72
+
73
+ constructor(proc: SessionProcess, start: SessionStart, onChange: () => void = () => {}) {
74
+ counter += 1;
75
+ this.key = `S${counter}`;
76
+ this.name = start.name;
77
+ if (start.planId) this.planId = start.planId;
78
+ this.proc = proc;
79
+ this.onChange = onChange;
80
+ proc.stdout?.on("data", (chunk) => this.receive(String(chunk)));
81
+ proc.stderr?.on("data", (chunk) => {
82
+ this.stderr = `${this.stderr}${String(chunk)}`.slice(-4000);
83
+ });
84
+ // A write to a session that just ended must not take this pi down with it.
85
+ proc.stdin?.on?.("error", () => {});
86
+ proc.on("exit", (code) => this.ended(code, code === 0 || code === null ? "the session ended" : `the session exited (${code})${this.lastError() ? `: ${this.lastError()}` : ""}`));
87
+ proc.on("error", (error) => {
88
+ this.stderr = `${this.stderr}\n${error.message}`;
89
+ this.ended(1, `the session could not run: ${error.message}`);
90
+ });
91
+ this.write({ type: "get_state" });
92
+ if (start.planId) this.prompt(`/bot-lobby start-plan ${start.planId}${start.auto ? " auto" : ""}`);
93
+ else if (start.request) this.prompt(`/bot-lobby --task ${start.auto ? "--auto " : ""}${start.request}`);
94
+ }
95
+
96
+ private ended(code: number | null, reason: string): void {
97
+ if (this.status === "exited") return;
98
+ this.status = "exited";
99
+ this.exitCode = code;
100
+ this.dialogs.length = 0;
101
+ this.feed.say("note", reason);
102
+ this.onChange();
103
+ }
104
+
105
+ get alive(): boolean {
106
+ return this.status !== "exited";
107
+ }
108
+
109
+ get busy(): boolean {
110
+ return this.status === "working";
111
+ }
112
+
113
+ /** The last line the process wrote to stderr, for a failure message. */
114
+ lastError(): string {
115
+ // Attention pings (OSC 9) a session writes to stderr are not errors.
116
+ return this.stderr.replace(/\x1b\][^\x07]*\x07|\x07/g, "").trim().split("\n").at(-1)?.trim() ?? "";
117
+ }
118
+
119
+ private write(command: Record<string, unknown>): void {
120
+ if (!this.alive || !this.proc.stdin) return;
121
+ this.nextId += 1;
122
+ this.proc.stdin.write(`${JSON.stringify({ id: `${this.key}-${this.nextId}`, ...command })}\n`);
123
+ }
124
+
125
+ private prompt(message: string): void {
126
+ this.write({ type: "prompt", message, ...(this.busy ? { streamingBehavior: "steer" } : {}) });
127
+ }
128
+
129
+ /** Send the session's oracle a message, steering its running turn when it works. */
130
+ send(text: string): void {
131
+ const body = text.trim();
132
+ if (!body) return;
133
+ this.prompt(body);
134
+ }
135
+
136
+ /** Stop the oracle's running turn (the session stays up). */
137
+ abort(): void {
138
+ if (this.busy) this.write({ type: "abort" });
139
+ }
140
+
141
+ /** Answer the oldest dialog it waits on. */
142
+ answer(id: string, answer: DialogAnswer): void {
143
+ const index = this.dialogs.findIndex((dialog) => dialog.id === id);
144
+ if (index < 0) return;
145
+ this.dialogs.splice(index, 1);
146
+ if (this.alive && this.proc.stdin) this.proc.stdin.write(`${JSON.stringify({ type: "extension_ui_response", id, ...answer })}\n`);
147
+ this.onChange();
148
+ }
149
+
150
+ /** Stop the session: its process ends (its task keeps its state and can be resumed or claimed later). */
151
+ stop(): void {
152
+ if (!this.alive) return;
153
+ try {
154
+ this.proc.stdin?.end();
155
+ } catch {
156
+ // Already closed.
157
+ }
158
+ try {
159
+ this.proc.kill("SIGTERM");
160
+ } catch {
161
+ // Already gone.
162
+ }
163
+ }
164
+
165
+ /** Split stdout into JSON lines (LF only, as pi's RPC framing requires) and handle each. */
166
+ private receive(chunk: string): void {
167
+ this.buffer += chunk;
168
+ let newline = this.buffer.indexOf("\n");
169
+ while (newline >= 0) {
170
+ const line = this.buffer.slice(0, newline).replace(/\r$/, "");
171
+ this.buffer = this.buffer.slice(newline + 1);
172
+ if (line.trim()) this.handle(line);
173
+ newline = this.buffer.indexOf("\n");
174
+ }
175
+ }
176
+
177
+ private handle(line: string): void {
178
+ let event: Record<string, unknown>;
179
+ try {
180
+ event = JSON.parse(line) as Record<string, unknown>;
181
+ } catch {
182
+ return;
183
+ }
184
+ const type = event.type;
185
+ if (type === "response") {
186
+ const data = event.data as { sessionId?: string; sessionFile?: string } | undefined;
187
+ if (event.command === "get_state" && data) {
188
+ this.sessionId = data.sessionId;
189
+ this.sessionFile = data.sessionFile;
190
+ if (this.status === "starting") this.status = "idle";
191
+ } else if (event.success === false && typeof event.error === "string") {
192
+ this.feed.log("LOBBY", `the session refused a request: ${event.error}`, "warning");
193
+ }
194
+ this.onChange();
195
+ return;
196
+ }
197
+ if (type === "extension_ui_request") return this.uiRequest(event);
198
+ if (type === "agent_start") this.status = "working";
199
+ else if (type === "agent_end") this.status = "idle";
200
+ narrateEvent(this.feed, event as unknown as AgentEventLike, describeToolCall);
201
+ this.onChange();
202
+ }
203
+
204
+ private uiRequest(event: Record<string, unknown>): void {
205
+ const id = String(event.id ?? "");
206
+ const method = String(event.method ?? "");
207
+ if (method === "select" || method === "confirm" || method === "input" || method === "editor") {
208
+ this.dialogs.push({
209
+ id,
210
+ method,
211
+ title: String(event.title ?? ""),
212
+ ...(typeof event.message === "string" ? { message: event.message } : {}),
213
+ ...(Array.isArray(event.options) ? { options: event.options.map(String) } : {}),
214
+ ...(typeof event.placeholder === "string" ? { placeholder: event.placeholder } : {}),
215
+ ...(typeof event.prefill === "string" ? { prefill: event.prefill } : {}),
216
+ });
217
+ this.feed.log("LOBBY", `waiting for you: ${String(event.title ?? "a question").split("\n")[0]}`, "warning");
218
+ } else if (method === "notify") {
219
+ const level = event.notifyType === "error" ? "error" : event.notifyType === "warning" ? "warning" : "info";
220
+ this.feed.log("LOBBY", String(event.message ?? "").split("\n")[0]!.replace(/^bot-lobby:?\s+/, ""), level);
221
+ } else if (method === "setStatus" && event.statusKey === "bot-lobby") {
222
+ this.statusText = typeof event.statusText === "string" ? event.statusText : undefined;
223
+ }
224
+ this.onChange();
225
+ }
226
+ }
227
+
228
+ /** Launch a headless pi for a new session: `pi --mode rpc --name <name>`, loading bot-lobby as this pi did. */
229
+ export type SessionLauncher = (args: string[], cwd: string) => SessionProcess;
230
+
231
+ /** `-e`/`--extension` arguments this pi was started with, so a session started from it loads the same extensions. */
232
+ export function extensionArgs(argv: readonly string[] = process.argv): string[] {
233
+ const forwarded: string[] = [];
234
+ for (let index = 0; index < argv.length; index += 1) {
235
+ const arg = argv[index]!;
236
+ if ((arg === "-e" || arg === "--extension") && argv[index + 1]) forwarded.push(arg, argv[++index]!);
237
+ else if (arg.startsWith("--extension=")) forwarded.push(arg);
238
+ }
239
+ return forwarded;
240
+ }
241
+
242
+ export function sessionArgs(name: string, model?: string, argv: readonly string[] = process.argv): string[] {
243
+ return ["--mode", "rpc", "--name", name, ...(model ? ["--model", model] : []), ...extensionArgs(argv)];
244
+ }
245
+
246
+ const POSIX = process.platform !== "win32";
247
+
248
+ /** The real launcher: the same pi build as this session, in its own process group, without subagent markers. */
249
+ export const launchPi: SessionLauncher = (args, cwd) => {
250
+ const invocation = resolvePiInvocation(args);
251
+ const env: NodeJS.ProcessEnv = { ...process.env, PI_SKIP_VERSION_CHECK: "1" };
252
+ delete env.BOT_LOBBY_SUBAGENT;
253
+ const proc: ChildProcess = spawn(invocation.command, invocation.args, { cwd, env, shell: false, detached: POSIX, windowsHide: true, stdio: ["pipe", "pipe", "pipe"] });
254
+ return proc as unknown as SessionProcess;
255
+ };
256
+
257
+ /** Every background session this window started, oldest first. */
258
+ export class SessionRegistry {
259
+ readonly sessions: BackgroundSession[] = [];
260
+ private readonly launcher: SessionLauncher;
261
+ private readonly onChange: () => void;
262
+
263
+ constructor(launcher: SessionLauncher = launchPi, onChange: () => void = () => {}) {
264
+ this.launcher = launcher;
265
+ this.onChange = onChange;
266
+ }
267
+
268
+ start(cwd: string, start: SessionStart, model?: string): BackgroundSession {
269
+ const proc = this.launcher(sessionArgs(start.name, model), cwd);
270
+ const session = new BackgroundSession(proc, start, this.onChange);
271
+ this.sessions.push(session);
272
+ this.onChange();
273
+ return session;
274
+ }
275
+
276
+ get(key: string): BackgroundSession | undefined {
277
+ return this.sessions.find((session) => session.key === key);
278
+ }
279
+
280
+ /** The background session running a pi session id, if this window started it. */
281
+ bySessionId(sessionId: string | undefined): BackgroundSession | undefined {
282
+ return sessionId ? this.sessions.find((session) => session.sessionId === sessionId) : undefined;
283
+ }
284
+
285
+ stopAll(): void {
286
+ for (const session of this.sessions) session.stop();
287
+ }
288
+ }
@@ -1,13 +1,15 @@
1
1
  /**
2
- * The Lobby tab: the zen scene of this session's task on top, then the
2
+ * The Lobby tab: the zen scene of this session's task on top (the oracle
3
+ * and agent animations, or just their status when animations are off), then the
3
4
  * conversation with the oracle (text only, no tool rows, no thinking), the
4
5
  * activity log of plain-words steps from every agent, and the one place where
5
6
  * thoughts show up. Pure: the scene arrives as a callback, the clock as `now`.
6
7
  */
8
+ import { visibleWidth } from "@earendil-works/pi-tui";
7
9
  import type { Task } from "../../schemas/task.ts";
8
10
  import type { ActivityEntry, ChatEntry, ThoughtEntry } from "../feed.ts";
9
11
  import type { LobbyPanel } from "../../schemas/configuration.ts";
10
- import { beside, bold, box, clock, fill, italic, markdownHanging, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
12
+ import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, since, spinner, wrap, wrapHanging, type LobbyColor, type LobbyTheme, type PaneLayout } from "../layout.ts";
11
13
 
12
14
  /** The Lobby tab's scrollable panes. */
13
15
  export const HOME_PANES = ["conversation", "activity", "thinking"] as const;
@@ -15,8 +17,8 @@ export type HomePane = (typeof HOME_PANES)[number];
15
17
 
16
18
  export interface HomeInput {
17
19
  task?: Task;
18
- /** Draws the zen scene into at most `height` lines; absent when no task is active. */
19
- scene?: (width: number, height: number) => string[];
20
+ /** Draws the zen scene into at most `height` lines, animated or still; absent when no task is active. */
21
+ scene?: (width: number, height: number, animated: boolean) => string[];
20
22
  chat: readonly ChatEntry[];
21
23
  /** The oracle's reply while it streams. */
22
24
  liveReply?: string;
@@ -41,13 +43,22 @@ export interface HomeInput {
41
43
  query?: string;
42
44
  /** Key labels for the pane toggles, shown on the panes. */
43
45
  keys?: Record<LobbyPanel, string>;
46
+ /** Another session is shown: its name titles the conversation. */
47
+ title?: string;
48
+ /** The scene is another session's still status (no animations to toggle). */
49
+ stillScene?: boolean;
50
+ /** What the empty conversation says instead of the default (a session starting, say). */
51
+ emptyNote?: string;
52
+ /** What the empty activity log says instead of "No activity yet.". */
53
+ activityNote?: string;
44
54
  }
45
55
 
46
56
  /** Wide terminals put the conversation and the activity log side by side. */
47
57
  export const HOME_COLUMNS_MIN = 100;
48
- /** Most lines the scene may take, and its share of the body. */
58
+ /** Most lines the scene may take, and its share of the body; the still status needs far fewer. */
49
59
  const SCENE_SHARE = 0.45;
50
60
  const MAX_SCENE = 36;
61
+ const MAX_STILL = 10;
51
62
 
52
63
  const SOURCE_COLORS: Record<string, LobbyColor> = {
53
64
  MASTER: "accent",
@@ -83,34 +94,93 @@ export function activityLine(entry: ActivityEntry, width: number, tick: number,
83
94
  return `${paint(theme, "dim", clock(entry.at))} ${who} ${mark} ${body}`;
84
95
  }
85
96
 
86
- function chatLead(role: ChatEntry["role"], theme?: LobbyTheme): string {
87
- if (role === "you") return `${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `;
88
- if (role === "oracle") return `${bold(theme, paint(theme, "toolTitle", "oracle"))} ${paint(theme, "dim", "▸")} `;
89
- return `${paint(theme, "dim", "·")} `;
97
+ /** How each speaker is shown: a mark and a name in its colour. */
98
+ export const SPEAKERS = {
99
+ you: { mark: "●", name: "You", color: "accent" },
100
+ oracle: { mark: "◆", name: "Oracle", color: "toolTitle" },
101
+ panel: { mark: "◆", name: "Panel", color: "toolTitle" },
102
+ } as const satisfies Record<string, { mark: string; name: string; color: LobbyColor }>;
103
+ export type Speaker = keyof typeof SPEAKERS;
104
+
105
+ /** Messages from one speaker within this long of each other share a header. */
106
+ const GROUP_MS = 5 * 60_000;
107
+ /** Message bodies sit under the speaker's name, past its mark. */
108
+ const BODY_INDENT = 2;
109
+
110
+ /** `◆ Oracle ············ 12:04`: who speaks on the left; when, or what they are doing, on the right. */
111
+ export function speakerLine(speaker: Speaker, width: number, theme?: LobbyTheme, right = ""): string {
112
+ const { mark, name, color } = SPEAKERS[speaker];
113
+ const left = `${paint(theme, color, mark)} ${bold(theme, paint(theme, color, name))}`;
114
+ const note = right ? paint(theme, "dim", right) : "";
115
+ const gap = width - visibleWidth(left) - visibleWidth(note);
116
+ return gap >= 1 && note ? `${left}${" ".repeat(gap)}${note}` : left;
117
+ }
118
+
119
+ /**
120
+ * What you wrote, as pi shows it: a band in the user-message background with
121
+ * a column of padding (a bar in its colour where the theme has no background).
122
+ */
123
+ export function youLines(text: string, width: number, theme?: LobbyTheme): string[] {
124
+ const inner = Math.max(1, width - BODY_INDENT - 2);
125
+ const indent = " ".repeat(BODY_INDENT);
126
+ const band = theme?.bg
127
+ ? (line: string) => theme.bg!("userMessageBg", ` ${fit(paint(theme, "userMessageText", line), inner)} `)
128
+ : (line: string) => `${paint(theme, "accent", "▌")} ${line}`;
129
+ return wrap(text, inner).map((line) => `${indent}${band(line)}`);
130
+ }
131
+
132
+ /** The oracle's reply as Markdown, under its name. */
133
+ function oracleLines(text: string, width: number, theme?: LobbyTheme): string[] {
134
+ const indent = " ".repeat(BODY_INDENT);
135
+ return markdownLines(text, Math.max(1, width - BODY_INDENT), theme).map((line) => (line ? `${indent}${line}` : ""));
136
+ }
137
+
138
+ /** An event in the conversation (a task starting, a comment sent) as a centred rule; failures stand out instead. */
139
+ export function eventLines(text: string, at: number, width: number, theme?: LobbyTheme): string[] {
140
+ if (text.startsWith("✗")) return wrap(paint(theme, "error", text), width);
141
+ const label = ` ${text}${at > 0 ? ` · ${clock(at)}` : ""} `;
142
+ const room = width - visibleWidth(label);
143
+ if (room < 6) return wrap(paint(theme, "dim", text), width);
144
+ const left = Math.floor(room / 2);
145
+ return [paint(theme, "dim", `${"─".repeat(left)}${label}${"─".repeat(room - left)}`)];
90
146
  }
91
147
 
92
- /** The conversation as wrapped lines, oldest first, a blank line between turns. */
148
+ /**
149
+ * The conversation, oldest first: each turn under a speaker line with its
150
+ * time, your messages in a band, the oracle's in Markdown, events as rules,
151
+ * and while the oracle works its header says so (streaming the reply under it).
152
+ */
93
153
  export function chatLines(chat: readonly ChatEntry[], width: number, theme?: LobbyTheme, live?: string, busy = false, tick = 0): string[] {
94
154
  const lines: string[] = [];
95
- const push = (role: ChatEntry["role"], text: string) => {
96
- if (lines.length > 0) lines.push("");
97
- // The oracle writes Markdown; what you typed and notes are shown as written.
98
- if (role === "oracle") lines.push(...markdownHanging(chatLead(role, theme), text, width, theme));
99
- else lines.push(...wrapHanging(chatLead(role, theme), role === "note" ? paint(theme, "dim", text) : text, width));
155
+ const gap = () => {
156
+ if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
100
157
  };
101
- for (const entry of chat) push(entry.role, entry.text);
158
+ let last: ChatEntry | undefined;
159
+ for (const entry of chat) {
160
+ gap();
161
+ if (entry.role === "note") {
162
+ lines.push(...eventLines(entry.text, entry.at, width, theme));
163
+ last = undefined;
164
+ continue;
165
+ }
166
+ const grouped = last?.role === entry.role && entry.at >= last.at && entry.at - last.at < GROUP_MS;
167
+ if (!grouped) lines.push(speakerLine(entry.role, width, theme, entry.at > 0 ? clock(entry.at) : ""));
168
+ lines.push(...(entry.role === "you" ? youLines(entry.text, width, theme) : oracleLines(entry.text, width, theme)));
169
+ last = entry;
170
+ }
102
171
  if (live?.trim()) {
103
- push("oracle", live.trim());
104
- lines[lines.length - 1] = `${lines.at(-1)!.trimEnd()} ${paint(theme, "accent", spinner(tick))}`;
172
+ gap();
173
+ lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...oracleLines(live.trim(), width, theme));
105
174
  } else if (busy) {
106
- if (lines.length > 0) lines.push("");
107
- lines.push(...wrapHanging(chatLead("oracle", theme), paint(theme, "dim", `${spinner(tick)} working…`), width));
175
+ gap();
176
+ lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} working…`));
108
177
  }
109
178
  return lines;
110
179
  }
111
180
 
112
181
  function emptyChat(input: HomeInput, width: number, theme?: LobbyTheme): string[] {
113
182
  if (input.query) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${input.query}".`), width);
183
+ if (input.emptyNote) return wrap(paint(theme, "dim", input.emptyNote), width);
114
184
  const lines = input.task
115
185
  ? [paint(theme, "dim", "Nothing said yet. Type below to talk to the oracle about this task.")]
116
186
  : [
@@ -147,7 +217,7 @@ function conversation(input: HomeInput, chat: readonly ChatEntry[], width: numbe
147
217
  }
148
218
 
149
219
  function activity(input: HomeInput, entries: readonly ActivityEntry[], width: number, theme?: LobbyTheme): string[] {
150
- if (entries.length === 0) return [paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : "No activity yet.")];
220
+ if (entries.length === 0) return wrap(paint(theme, "dim", input.query ? `No activity matches "${input.query}".` : input.activityNote ?? "No activity yet."), width);
151
221
  return entries.map((entry) => activityLine(entry, width, input.tick, theme));
152
222
  }
153
223
 
@@ -177,17 +247,35 @@ function thinkingContent(input: HomeInput, thoughts: readonly ThoughtEntry[], wi
177
247
  return [paint(theme, "dim", input.query ? `No thought matches "${input.query}".` : "Thoughts from the oracle and every agent appear here, and only here.")];
178
248
  }
179
249
 
180
- function sceneLines(input: HomeInput, width: number, height: number): string[] {
181
- if (!input.task || !input.scene || !input.panels.scene) return [];
182
- const budget = Math.min(MAX_SCENE, Math.floor(height * SCENE_SHARE));
183
- if (budget < 6) return [];
184
- return input.scene(width, budget).slice(0, budget);
250
+ /**
251
+ * The scene: with animations off (the `animations` panel, off by default)
252
+ * only the task's status box, what the agents are doing and the checklist;
253
+ * with them on, the animated oracle and agents too. Its first line names the
254
+ * key that toggles the animations.
255
+ */
256
+ function sceneLines(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
257
+ if (!input.task || !input.scene) return [];
258
+ const animated = input.panels.animations && !input.stillScene;
259
+ const budget = Math.min(animated ? MAX_SCENE : MAX_STILL, Math.floor(height * SCENE_SHARE));
260
+ if (budget < (animated ? 6 : 4)) return [];
261
+ const lines = input.scene(width, budget, animated).slice(0, budget);
262
+ const note = input.keys && !input.stillScene ? `${input.keys.animations} ${animated ? "hides" : "shows"} animations` : undefined;
263
+ return keyNote(lines, width, note, theme);
264
+ }
265
+
266
+ /** `note` at the right end of the first line, dimmed, when it fits beside what is there. */
267
+ function keyNote(lines: readonly string[], width: number, note: string | undefined, theme?: LobbyTheme): string[] {
268
+ const first = lines[0];
269
+ if (!note || first === undefined) return [...lines];
270
+ const gap = width - visibleWidth(first) - visibleWidth(note) - 1;
271
+ if (gap < 2) return [...lines];
272
+ return [`${first}${" ".repeat(gap)}${paint(theme, "dim", note)}`, ...lines.slice(1)];
185
273
  }
186
274
 
187
275
  function hiddenHint(input: HomeInput, width: number, height: number, theme?: LobbyTheme): string[] {
188
276
  const keys = input.keys;
189
277
  const text = keys
190
- ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking${input.task ? ` · ${keys.scene} scene` : ""}`
278
+ ? `Every pane is hidden — ${keys.conversation} conversation · ${keys.activity} activity · ${keys.thinking} thinking`
191
279
  : "Every pane is hidden.";
192
280
  const lines = wrap(paint(theme, "dim", text), width);
193
281
  const top = Math.max(0, Math.floor((height - lines.length) / 2));
@@ -205,7 +293,7 @@ export function renderHome(input: HomeInput, width: number, height: number, them
205
293
  if (height <= 0) return [];
206
294
  const { panels } = input;
207
295
  const feed = filterFeed(input);
208
- const scene = sceneLines(input, width, height);
296
+ const scene = sceneLines(input, width, height, theme);
209
297
  const rest = height - scene.length;
210
298
  const showMain = panels.conversation || panels.activity;
211
299
  const thinkHeight = !panels.thinking ? 0 : !showMain ? rest : rest >= 18 ? Math.max(5, Math.floor(rest * 0.25)) : rest >= 10 ? 4 : 0;
@@ -220,7 +308,7 @@ export function renderHome(input: HomeInput, width: number, height: number, them
220
308
  const right = rightNote(view.offset, note);
221
309
  return box(w, h, view.shown, { title, ...(right ? { right } : {}), focused: input.focus === name, scroll: { total: all.length, start: view.start }, theme });
222
310
  };
223
- const chatBox = pane("conversation", input.task ? `Conversation · ${input.task.id}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
311
+ const chatBox = pane("conversation", input.title ? `Conversation · ${input.title}` : "Conversation", input.query ? matches(feed.chat.length) : input.keys?.conversation, (inner) => conversation(input, feed.chat, inner, theme));
224
312
  const activityBox = pane("activity", "Activity", input.query ? matches(feed.activity.length) : input.keys?.activity, (inner) => activity(input, feed.activity, inner, theme));
225
313
  const thought = currentThought(feed.thoughts);
226
314
  const thinkingNote = thought ? `${thought.source} · ${thought.live ? "thinking" : since(input.now - thought.at)}` : input.keys?.thinking;
@@ -9,8 +9,8 @@
9
9
  import { stripTerminalSequences } from "@earendil-works/pi-tui";
10
10
  import { ORACLE_LABEL, type LineComment, type PanelNote, type PanelQuestion, type PlannerMessage, type PlannerReply, type PlannerSeed } from "../planner.ts";
11
11
  import type { PlannedTask } from "../../state/backlog.ts";
12
- import { beside, bold, box, fill, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
- import { sourceColor, tailWindow } from "./home.ts";
12
+ import { beside, bold, box, clock, fill, fit, italic, markdownLines, notePane, paint, selectRow, spinner, wrap, wrapHanging, type LobbyTheme, type PaneLayout } from "../layout.ts";
13
+ import { sourceColor, speakerLine, tailWindow, youLines } from "./home.ts";
14
14
 
15
15
  export interface SeatView {
16
16
  label: string;
@@ -77,43 +77,34 @@ export interface PlanTabInput {
77
77
  layout?: PlanLayout;
78
78
  /** Filled with where the conversation (`talk`) and the draft landed, for scrolling. */
79
79
  panes?: PaneLayout;
80
+ /** The key that saves the plan, as the status line names it. */
81
+ saveKey?: string;
80
82
  }
81
83
 
82
84
  export const PLAN_COLUMNS_MIN = 100;
83
85
  const LABEL_WIDTH = 8;
84
86
 
87
+ /** Before a session: one sentence, then who sits on the panel and the model each runs on. */
85
88
  function intro(input: PlanTabInput, width: number, theme?: LobbyTheme): string[] {
86
- // The roster leads with the chair; the intro names it separately.
87
- const members = input.seats.filter((seat) => seat.label !== ORACLE_LABEL);
88
- const seats = members.filter((seat) => seat.seated).map((seat) => seat.label);
89
- const text = [
90
- bold(theme, "Plan a task with the whole team before anyone writes code."),
91
- "",
92
- `Describe what you want below. The planning panel — the oracle chairing${seats.length > 0 ? `, with ${seats.join(", ")}` : ""} — reads the codebase and questions you, each seat from its own domain: contracts and data, flows and states, acceptance criteria and tests, libraries and prior art.`,
93
- "",
94
- "After each round the oracle puts the panel's questions to you one at a time, each with options and room for your own answer, and folds every answer into the draft plan, so all the agents start from the same decisions.",
95
- "",
96
- "Comment on any line of the draft: enter moves to the draft, ↑↓ pick a line (or click it), c comments. When the panel agrees, s saves the plan to the pending tasks list. While browsing (esc), 1-4 seat or unseat DEV, DESIGN, QA and RESEARCH.",
97
- "",
98
- paint(theme, "dim", `oracle: ${input.profile} (m changes it; each seat uses its domain's settings, alt+s)`),
99
- ...members.map((seat) => paint(theme, "dim", `${seat.label.toLowerCase()}: ${seat.seated ? seat.profile ?? "" : "not seated"}`)),
100
- ];
101
- return text.flatMap((line) => wrap(line, width));
89
+ const rows = input.seats.map((seat) => {
90
+ const name = paint(theme, seat.seated ? sourceColor(seat.label) : "dim", seat.label.padEnd(LABEL_WIDTH + 2));
91
+ const model = seat.label === ORACLE_LABEL ? input.profile : seat.seated ? seat.profile ?? "" : "not seated";
92
+ return fit(`${name}${paint(theme, seat.seated ? "muted" : "dim", model)}`, width);
93
+ });
94
+ return [...wrap(bold(theme, "Describe a task below and the panel questions you until the plan is clear."), width), "", ...rows];
102
95
  }
103
96
 
104
97
  function statusLine(view: PlanView, input: PlanTabInput, theme?: LobbyTheme): string {
105
98
  const parts: string[] = [];
99
+ const count = (n: number, word: string) => `${n} ${word}${n === 1 ? "" : "s"}`;
106
100
  if (view.busy) parts.push(`${paint(theme, "accent", spinner(input.tick))} round ${view.turns}`);
107
- else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — esc, then r retries`));
108
- else if (view.reply?.status === "ready") parts.push(paint(theme, "success", "✓ READY — the panel agrees; s saves it as a pending task"));
109
- else if (view.awaitingAnswers) {
110
- const left = view.answeredChunks > 0 ? " (some answered — enter resumes)" : " — enter answers them one at a time";
111
- parts.push(paint(theme, "warning", `● ${view.questions.length} question${view.questions.length === 1 ? "" : "s"} for you${left}`));
112
- }
113
- if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${view.lineComments.length} line comment${view.lineComments.length === 1 ? "" : "s"} to send`));
101
+ else if (view.error) parts.push(paint(theme, "error", `✗ ${view.error.split("\n")[0]} — r retries`));
102
+ else if (view.reply?.status === "ready") parts.push(paint(theme, "success", `✓ ready — ${input.saveKey ?? "Ctrl+S"} saves it`));
103
+ else if (view.awaitingAnswers) parts.push(paint(theme, "warning", `● ${count(view.questions.length, "question")} — enter ${view.answeredChunks > 0 ? "resumes" : "answers them"}`));
104
+ if (view.lineComments.length > 0) parts.push(paint(theme, "accent", `◆ ${count(view.lineComments.length, "comment")} to send`));
114
105
  if (view.saved) parts.push(paint(theme, "success", `saved as ${view.saved.id}`));
115
106
  if (!view.busy && view.turns > 0) parts.push(paint(theme, "dim", `round ${view.turns}`));
116
- return parts.join(paint(theme, "dim", " · ")) || paint(theme, "dim", "Answer below; enter sends.");
107
+ return parts.join(paint(theme, "dim", " · "));
117
108
  }
118
109
 
119
110
  /** One seat on the roster: a spinner and its step while it works, then ready, its question count or a failure. */
@@ -124,6 +115,8 @@ function seatCell(seat: SeatView, tick: number, theme?: LobbyTheme): string {
124
115
  if (seat.status === "failed") return `${name} ${paint(theme, "error", "✗ failed — r retries")}`;
125
116
  if (seat.status === "idle") return `${name} ${paint(theme, "dim", "·")}`;
126
117
  if (seat.ready) return `${name} ${paint(theme, "success", "✓ ready")}`;
118
+ // A seat whose questions the oracle settled itself has nothing waiting on you.
119
+ if (seat.questions === 0) return `${name} ${paint(theme, "dim", "done")}`;
127
120
  return `${name} ${paint(theme, "warning", `${seat.questions} question${seat.questions === 1 ? "" : "s"}`)}`;
128
121
  }
129
122
 
@@ -158,12 +151,14 @@ export function conversationLines(view: PlanView, width: number, theme?: LobbyTh
158
151
  .filter(({ question }) => matches(`${question.from} ${question.text} ${question.options.map((option) => option.label).join(" ")}`, query));
159
152
  if (message.role === "you" ? !matches(message.text, query) : message.questions?.length ? questions.length === 0 : !matches(message.text, query)) continue;
160
153
  if (lines.length > 0 && lines.at(-1) !== "") lines.push("");
154
+ // The same turns as the Lobby's conversation: who speaks and when, your words in a band.
155
+ const time = message.at > 0 ? clock(message.at) : "";
161
156
  if (message.role === "you") {
162
- lines.push(...wrapHanging(`${bold(theme, paint(theme, "accent", "you"))} ${paint(theme, "dim", "▸")} `, message.text, width));
157
+ lines.push(speakerLine("you", width, theme, time), ...youLines(message.text, width, theme));
163
158
  continue;
164
159
  }
165
- lines.push(`${bold(theme, paint(theme, "toolTitle", "panel"))} ${paint(theme, "dim", "▸")}`);
166
- lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width)));
160
+ lines.push(speakerLine("panel", width, theme, time));
161
+ lines.push(...(questions.length > 0 ? questionLines(questions, width, theme) : wrap(message.text, width).map((line) => (line ? ` ${line}` : ""))));
167
162
  }
168
163
  if (query && lines.length === 0) return wrap(paint(theme, "dim", `Nothing in the conversation matches "${query}".`), width);
169
164
  return lines;
@@ -201,7 +196,7 @@ export function renderPlan(input: PlanTabInput, width: number, height: number, t
201
196
  if (height <= 0) return [];
202
197
  const notice = input.notice ? [paint(theme, "accent", input.notice)] : [];
203
198
  const view = input.session;
204
- if (!view) return fill([...notice, ...box(width, height - notice.length, intro(input, width - 4, theme), { title: "Plan", right: input.profile, theme })], height, width);
199
+ if (!view) return fill([...notice, ...box(width, height - notice.length, intro(input, width - 4, theme), { title: "Plan", theme })], height, width);
205
200
  const header = [
206
201
  ...notice,
207
202
  ...wrap(`${bold(theme, view.title ? `Planning · ${view.title}` : "Planning")} ${statusLine(view, input, theme)}`, width),