@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.
- package/README.md +145 -44
- package/package.json +1 -1
- package/prompts/panel.md +6 -2
- package/prompts/planner.md +29 -16
- package/src/execution/pi-runner.ts +1 -1
- package/src/index.ts +4 -2
- package/src/lobby/feed.ts +65 -9
- package/src/lobby/keys.ts +5 -1
- package/src/lobby/layout.ts +17 -2
- package/src/lobby/planner.ts +16 -5
- package/src/lobby/runtime.ts +195 -59
- package/src/lobby/session-files.ts +126 -0
- package/src/lobby/sessions.ts +288 -0
- package/src/lobby/tabs/home.ts +117 -29
- package/src/lobby/tabs/plan.ts +25 -30
- package/src/lobby/tabs/quickfix.ts +5 -8
- package/src/lobby/tabs/tasks.ts +177 -44
- package/src/lobby/view.ts +491 -86
- package/src/pi/commands.ts +34 -6
- package/src/pi/events.ts +5 -2
- package/src/pi/notify.ts +18 -0
- package/src/pi/owner.ts +216 -0
- package/src/pi/settings-ui.ts +6 -2
- package/src/pi/start-task.ts +33 -2
- package/src/pi/ui.ts +3 -3
- package/src/pi/zen-large.ts +9 -1
- package/src/pi/zen.ts +7 -3
- package/src/schemas/configuration.ts +8 -3
- package/src/schemas/task.ts +2 -0
- package/src/state/auto.ts +40 -0
- package/src/state/inbox.ts +90 -0
- package/src/workflow/workflow.ts +27 -3
|
@@ -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
|
+
}
|
package/src/lobby/tabs/home.ts
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The Lobby tab: the zen scene of this session's task on top
|
|
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,
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
104
|
-
lines
|
|
172
|
+
gap();
|
|
173
|
+
lines.push(speakerLine("oracle", width, theme, `${spinner(tick)} writing`), ...oracleLines(live.trim(), width, theme));
|
|
105
174
|
} else if (busy) {
|
|
106
|
-
|
|
107
|
-
lines.push(
|
|
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
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
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
|
|
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.
|
|
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;
|
package/src/lobby/tabs/plan.ts
CHANGED
|
@@ -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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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]} —
|
|
108
|
-
else if (view.reply?.status === "ready") parts.push(paint(theme, "success",
|
|
109
|
-
else if (view.awaitingAnswers) {
|
|
110
|
-
|
|
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", " · "))
|
|
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(
|
|
157
|
+
lines.push(speakerLine("you", width, theme, time), ...youLines(message.text, width, theme));
|
|
163
158
|
continue;
|
|
164
159
|
}
|
|
165
|
-
lines.push(
|
|
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",
|
|
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),
|