@a-t-h-i/bot-lobby 0.6.4 → 0.6.6
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 +150 -12
- package/package.json +12 -3
- package/prompts/backend.md +3 -1
- package/prompts/designer.md +24 -1
- package/prompts/global.md +18 -0
- package/prompts/master.md +122 -20
- package/prompts/quickfix.md +16 -3
- package/prompts/worker.md +7 -2
- package/src/agents/backend.ts +2 -2
- package/src/agents/designer.ts +2 -2
- package/src/ask/dialog.ts +11 -1
- package/src/classifier/triage.ts +24 -7
- package/src/index.ts +4 -1
- package/src/lobby/feed.ts +7 -2
- package/src/lobby/keys.ts +0 -1
- package/src/lobby/quickfix.ts +29 -3
- package/src/lobby/runtime.ts +29 -26
- package/src/lobby/tabs/home.ts +6 -42
- package/src/lobby/tabs/quickfix.ts +1 -1
- package/src/lobby/tabs/tasks.ts +3 -2
- package/src/lobby/view.ts +18 -23
- package/src/master/decisions.ts +10 -2
- package/src/pi/activity.ts +1 -33
- package/src/pi/commands.ts +22 -11
- package/src/pi/events.ts +3 -17
- package/src/pi/plan-checklist.ts +305 -0
- package/src/pi/route.ts +180 -0
- package/src/pi/run-summary.ts +12 -3
- package/src/pi/settings-ui.ts +2 -2
- package/src/pi/start-task.ts +51 -5
- package/src/pi/tools.ts +13 -7
- package/src/pi/ui.ts +18 -284
- package/src/roles/worker.ts +11 -3
- package/src/schemas/configuration.ts +19 -6
- package/src/schemas/task.ts +31 -0
- package/src/workflow/brief.ts +59 -0
- package/src/workflow/track.ts +436 -0
- package/src/workflow/workflow.ts +151 -10
- package/src/pi/expressions.ts +0 -169
- package/src/pi/kaomoji.ts +0 -227
- package/src/pi/mascot-art.ts +0 -359
- package/src/pi/zen-large.ts +0 -699
- package/src/pi/zen-metrics.ts +0 -130
- package/src/pi/zen.ts +0 -659
package/src/pi/zen-large.ts
DELETED
|
@@ -1,699 +0,0 @@
|
|
|
1
|
-
import { clip, textWidth } from "../width.ts";
|
|
2
|
-
import {
|
|
3
|
-
BAR,
|
|
4
|
-
BUBBLE,
|
|
5
|
-
CLOSED_EYE,
|
|
6
|
-
EYE_WIDTH,
|
|
7
|
-
GAZE_AHEAD,
|
|
8
|
-
OPEN_EYE,
|
|
9
|
-
ORACLE_AURA,
|
|
10
|
-
ORACLE_AURA_TICKS,
|
|
11
|
-
ORACLE_BLINK,
|
|
12
|
-
ORACLE_BLINKS,
|
|
13
|
-
ORACLE_COLORS,
|
|
14
|
-
ORACLE_FRAMES,
|
|
15
|
-
ORACLE_GAZE_TICKS,
|
|
16
|
-
ORACLE_IDLE_WORD,
|
|
17
|
-
ORACLE_LOOK,
|
|
18
|
-
ORACLE_LOOK_PATH,
|
|
19
|
-
ORACLE_MOOD_MOUTHS,
|
|
20
|
-
ORACLE_PUPILS,
|
|
21
|
-
ORACLE_REST,
|
|
22
|
-
ORACLE_TALK,
|
|
23
|
-
ORACLE_WANDER,
|
|
24
|
-
ORACLE_WANDER_TICKS,
|
|
25
|
-
ORACLE_WORDS,
|
|
26
|
-
SLOT_FRAMES,
|
|
27
|
-
SLOT_LABELS,
|
|
28
|
-
SLOT_STATE_COLORS,
|
|
29
|
-
SLOT_STATE_GLYPHS,
|
|
30
|
-
SLOT_STATE_WORDS,
|
|
31
|
-
SPIN_FRAMES,
|
|
32
|
-
TOWER,
|
|
33
|
-
TOWER_DOOR,
|
|
34
|
-
TOWER_WIDTH,
|
|
35
|
-
type Gaze,
|
|
36
|
-
type OracleFrame,
|
|
37
|
-
type OracleMood,
|
|
38
|
-
type OraclePose,
|
|
39
|
-
type PanelColor,
|
|
40
|
-
type SlotId,
|
|
41
|
-
type SlotState,
|
|
42
|
-
} from "./mascot-art.ts";
|
|
43
|
-
import type { PanelTheme } from "./zen.ts";
|
|
44
|
-
import type { SlotFlag } from "./zen-metrics.ts";
|
|
45
|
-
import { EMOTE_FRAME } from "./expressions.ts";
|
|
46
|
-
import type { FeedLine } from "./run-summary.ts";
|
|
47
|
-
import { shortDuration } from "../text.ts";
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* The large animated zen scene: a header box, the oracle tower with its speech
|
|
51
|
-
* bubble and tree connector, the four animated agent columns (face, label, status word and
|
|
52
|
-
* elapsed rows), and the TASKS checklist spanning the full scene width (there is
|
|
53
|
-
* no LOG window).
|
|
54
|
-
*
|
|
55
|
-
* Pure layout: every colour comes from the optional `PanelTheme`, every frame
|
|
56
|
-
* index comes from the caller, and nothing here reads the clock or a random
|
|
57
|
-
* source. Below `LARGE_MIN_WIDTH` the caller renders the compact strip instead
|
|
58
|
-
* of calling this module.
|
|
59
|
-
*/
|
|
60
|
-
|
|
61
|
-
/** The large scene only renders at or above this width. */
|
|
62
|
-
export const LARGE_MIN_WIDTH = 72;
|
|
63
|
-
|
|
64
|
-
/** Fixed scene width; the box, tower and agent strip are all centered on it. */
|
|
65
|
-
export const SCENE_WIDTH = 63;
|
|
66
|
-
|
|
67
|
-
export const MAX_TASK_ROWS = 6;
|
|
68
|
-
export const MAX_LARGE_LINES = 35;
|
|
69
|
-
|
|
70
|
-
/** One agent column: state, caller-selected animation frame, live activity and elapsed. */
|
|
71
|
-
export interface LargeSlot {
|
|
72
|
-
id: SlotId;
|
|
73
|
-
label: string;
|
|
74
|
-
status: SlotState;
|
|
75
|
-
frame: number;
|
|
76
|
-
/** One-word tool activity while working; absent reads as "working". */
|
|
77
|
-
activity?: string;
|
|
78
|
-
/** Run duration label rendered under the status row; "—" when idle. */
|
|
79
|
-
elapsedLabel: string;
|
|
80
|
-
/** Waiting on a file, gone quiet or retrying; replaces the activity word while set. */
|
|
81
|
-
flag?: SlotFlag;
|
|
82
|
-
/** This expression's emote frames (open, blink, action, action); the status default when absent. */
|
|
83
|
-
emote?: readonly string[];
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export interface LargeTaskRow {
|
|
87
|
-
text: string;
|
|
88
|
-
status: "done" | "current" | "pending";
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
/** The oracle's pose plus the caller-clocked animation state (see expressions.ts). */
|
|
92
|
-
export interface OracleInput {
|
|
93
|
-
pose: OraclePose;
|
|
94
|
-
/** Expression frame: rest, blink, glance or delight (snore while dormant). */
|
|
95
|
-
frame: number;
|
|
96
|
-
/** Sub-step inside the playing expression; steps the blink lids and the glance. */
|
|
97
|
-
phase?: number;
|
|
98
|
-
/** Lip-sync shape index while the oracle talks about something it just said. */
|
|
99
|
-
talk?: number;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/** Everything the large scene draws; assembled by the caller from task and runs. */
|
|
103
|
-
export interface LargeSceneInput {
|
|
104
|
-
taskId: string;
|
|
105
|
-
taskTitle: string;
|
|
106
|
-
state: string;
|
|
107
|
-
elapsedLabel: string;
|
|
108
|
-
quietHint: string;
|
|
109
|
-
/** Caller-injected spinner frame; the scene reads no clock. */
|
|
110
|
-
tick: number;
|
|
111
|
-
done: number;
|
|
112
|
-
total: number;
|
|
113
|
-
slots: readonly LargeSlot[];
|
|
114
|
-
tasks: readonly LargeTaskRow[];
|
|
115
|
-
oracle: OracleInput;
|
|
116
|
-
/** Master's live activity for the speech bubble; absent means it waits on the user. */
|
|
117
|
-
oracleActivity?: string;
|
|
118
|
-
/** Plain-words task phase ("writing the plan"); the bubble's fallback second line. */
|
|
119
|
-
caption?: string;
|
|
120
|
-
/** Approval/blocked line; never dropped when present. */
|
|
121
|
-
alert?: string;
|
|
122
|
-
/** Severity of the alert line: approvals are `warning`, blocked is `error`. */
|
|
123
|
-
alertKind?: "warning" | "error";
|
|
124
|
-
/** Name over the tower door, `TOWER_DOOR` when omitted. */
|
|
125
|
-
doorLabel?: string;
|
|
126
|
-
/** Live feed row under the agents: what the busiest agent is doing right now. */
|
|
127
|
-
feed?: FeedLine;
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
export const SLOT_CELL = 15;
|
|
131
|
-
const SLOT_GAP = 1;
|
|
132
|
-
const SLOT_ROWS = 4;
|
|
133
|
-
const BRANCH_ROWS = 3;
|
|
134
|
-
const SECTION_MIN = 2;
|
|
135
|
-
const MAX_SLOTS = 4;
|
|
136
|
-
const CONTENT = SCENE_WIDTH - 4;
|
|
137
|
-
const TASKS_CELL = CONTENT;
|
|
138
|
-
|
|
139
|
-
const TASK_COLORS: Record<LargeTaskRow["status"], PanelColor> = {
|
|
140
|
-
done: "success",
|
|
141
|
-
current: "accent",
|
|
142
|
-
pending: "dim",
|
|
143
|
-
};
|
|
144
|
-
|
|
145
|
-
const TOWER_ROWS: Record<TowerSize, number> = {
|
|
146
|
-
full: TOWER.rows.length,
|
|
147
|
-
small: TOWER.smallRows.length,
|
|
148
|
-
none: 0,
|
|
149
|
-
};
|
|
150
|
-
|
|
151
|
-
/** Bubble rows: top border, the text lines and the bottom border. */
|
|
152
|
-
const BUBBLE_ROWS = BUBBLE.lines + 2;
|
|
153
|
-
const BUBBLE_TEXT = BUBBLE.width - 4;
|
|
154
|
-
|
|
155
|
-
const TOWER_PATTERN = new RegExp(
|
|
156
|
-
`(${Object.values(TOWER.tokens).map((token) => token.replace(/([{}])/g, "\\$1")).join("|")})`,
|
|
157
|
-
);
|
|
158
|
-
|
|
159
|
-
type TowerSize = "full" | "small" | "none";
|
|
160
|
-
|
|
161
|
-
interface Cell {
|
|
162
|
-
offset: number;
|
|
163
|
-
text: string;
|
|
164
|
-
paint?: (text: string) => string;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
interface LayoutPlan {
|
|
168
|
-
tower: TowerSize;
|
|
169
|
-
branch: boolean;
|
|
170
|
-
strip: boolean;
|
|
171
|
-
section: boolean;
|
|
172
|
-
/** Entry rows the plan guarantees when a section is present. */
|
|
173
|
-
minEntries: number;
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Rich to sparse. The section outranks the tower: the tower only grows once the
|
|
178
|
-
* section can keep all `MAX_TASK_ROWS` entry rows, so a taller terminal never
|
|
179
|
-
* removes or shrinks TASKS.
|
|
180
|
-
*/
|
|
181
|
-
const PLANS: readonly LayoutPlan[] = [
|
|
182
|
-
{ tower: "full", branch: true, strip: true, section: true, minEntries: MAX_TASK_ROWS },
|
|
183
|
-
{ tower: "small", branch: true, strip: true, section: true, minEntries: MAX_TASK_ROWS },
|
|
184
|
-
{ tower: "small", branch: true, strip: true, section: true, minEntries: SECTION_MIN },
|
|
185
|
-
{ tower: "small", branch: true, strip: true, section: false, minEntries: 0 },
|
|
186
|
-
{ tower: "small", branch: false, strip: true, section: false, minEntries: 0 },
|
|
187
|
-
{ tower: "none", branch: false, strip: true, section: false, minEntries: 0 },
|
|
188
|
-
];
|
|
189
|
-
|
|
190
|
-
const EMPTY_PLAN: LayoutPlan = { tower: "none", branch: false, strip: false, section: false, minEntries: 0 };
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Scene lines for the large tier, or nothing below `LARGE_MIN_WIDTH`.
|
|
194
|
-
*
|
|
195
|
-
* `budget` is a caller-computed LINE budget (zen.ts derives it from the
|
|
196
|
-
* terminal height), not a terminal row count. The header box and the alert are
|
|
197
|
-
* the fixed frame and are always drawn, so a budget below the fixed frame still
|
|
198
|
-
* returns a bounded, width-safe scene instead of dropping the alert.
|
|
199
|
-
*/
|
|
200
|
-
export function largeLines(
|
|
201
|
-
input: LargeSceneInput,
|
|
202
|
-
width: number,
|
|
203
|
-
budget: number,
|
|
204
|
-
theme?: PanelTheme,
|
|
205
|
-
still = false,
|
|
206
|
-
): string[] {
|
|
207
|
-
if (width < LARGE_MIN_WIDTH) return [];
|
|
208
|
-
const head = boxLines(input, width, theme);
|
|
209
|
-
const alert = input.alert ? [alertLine(input.alert, width, theme, input.alertKind ?? "warning")] : [];
|
|
210
|
-
const avail = Math.max(0, lineBudget(budget) - head.length - alert.length);
|
|
211
|
-
return [...head, ...alert, ...(still ? stillLines(input, width, theme, avail) : bodyLines(input, width, theme, avail))];
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
/** The scene without its animations: what the agents are doing, then the task checklist. */
|
|
215
|
-
function stillLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, avail: number): string[] {
|
|
216
|
-
const feed = input.feed && avail >= 1 ? [feedRow(input.feed, width, theme)] : [];
|
|
217
|
-
const entries = clamp(avail - feed.length - 1, 0, MAX_TASK_ROWS);
|
|
218
|
-
return [...feed, ...sectionLines(input, width, theme, entries)];
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
/** Clamp a caller line budget to the scene cap, ignoring non-finite input. */
|
|
222
|
-
function lineBudget(budget: number): number {
|
|
223
|
-
return clamp(Number.isFinite(budget) ? Math.floor(budget) : 0, 0, MAX_LARGE_LINES);
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
function paint(text: string, color: PanelColor, theme?: PanelTheme, bold = false): string {
|
|
227
|
-
if (!theme || text.length === 0) return text;
|
|
228
|
-
const painted = theme.fg(color, text);
|
|
229
|
-
return bold ? theme.bold(painted) : painted;
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
function statusPaint(status: SlotState, theme?: PanelTheme): ((text: string) => string) | undefined {
|
|
233
|
-
if (!theme) return undefined;
|
|
234
|
-
const style = SLOT_STATE_COLORS[status];
|
|
235
|
-
return (text) => paint(text, style.color, theme, style.bold);
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
function labelPaint(status: SlotState, theme?: PanelTheme): ((text: string) => string) | undefined {
|
|
239
|
-
if (!theme) return undefined;
|
|
240
|
-
return status === "working"
|
|
241
|
-
? (text) => theme.bold(theme.fg("accent", text))
|
|
242
|
-
: (text) => theme.fg("dim", text);
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
function padTo(text: string, width: number): string {
|
|
246
|
-
return clip(text, width, "", true);
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
function centre(text: string, width: number): string {
|
|
250
|
-
return padTo(" ".repeat(Math.max(0, Math.floor((width - textWidth(text)) / 2))) + text, width);
|
|
251
|
-
}
|
|
252
|
-
|
|
253
|
-
function mod(index: number, length: number): number {
|
|
254
|
-
return length <= 0 ? 0 : ((index % length) + length) % length;
|
|
255
|
-
}
|
|
256
|
-
|
|
257
|
-
function clamp(value: number, min: number, max: number): number {
|
|
258
|
-
return Math.min(Math.max(value, min), max);
|
|
259
|
-
}
|
|
260
|
-
|
|
261
|
-
function progress(done: number, total: number): number {
|
|
262
|
-
if (!(total > 0) || !(done > 0)) return 0;
|
|
263
|
-
return clamp(Math.round((done / total) * 100), 0, 100);
|
|
264
|
-
}
|
|
265
|
-
|
|
266
|
-
function sceneLeft(width: number): number {
|
|
267
|
-
return Math.max(0, Math.floor((width - SCENE_WIDTH) / 2));
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
function sceneCentre(width: number): number {
|
|
271
|
-
return Math.floor((width - 1) / 2);
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
/** Place a row of at most `SCENE_WIDTH` columns on the scene's centre column. */
|
|
275
|
-
function place(row: string, width: number): string {
|
|
276
|
-
return " ".repeat(Math.max(0, sceneLeft(width) + Math.floor((SCENE_WIDTH - textWidth(row)) / 2))) + row;
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
function alertLine(
|
|
280
|
-
alert: string,
|
|
281
|
-
width: number,
|
|
282
|
-
theme?: PanelTheme,
|
|
283
|
-
kind: "warning" | "error" = "warning",
|
|
284
|
-
): string {
|
|
285
|
-
const left = sceneLeft(width);
|
|
286
|
-
const line = paint(clip(`! ${alert}`, width - left, "…"), kind, theme, true);
|
|
287
|
-
return " ".repeat(left) + line;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
function boxLines(input: LargeSceneInput, width: number, theme?: PanelTheme): string[] {
|
|
291
|
-
const rows = [topBorder(input, theme), barRow(input, theme), metaRow(input, theme), bottomBorder(theme)];
|
|
292
|
-
return rows.map((row) => place(row, width));
|
|
293
|
-
}
|
|
294
|
-
|
|
295
|
-
function topBorder(input: LargeSceneInput, theme?: PanelTheme): string {
|
|
296
|
-
const lead = "┌─ BOT-LOBBY ── ";
|
|
297
|
-
const label = clip(`${input.taskTitle} · ${input.state} `, SCENE_WIDTH - 18, "…");
|
|
298
|
-
const tail = "─".repeat(Math.max(0, SCENE_WIDTH - 1 - textWidth(lead) - textWidth(label)));
|
|
299
|
-
return paint(lead, "muted", theme) + paint(label, "accent", theme, true) + paint(`${tail}┐`, "muted", theme);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
function bottomBorder(theme?: PanelTheme): string {
|
|
303
|
-
return paint(`└${"─".repeat(SCENE_WIDTH - 2)}┘`, "muted", theme);
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
/** Metadata row: elapsed, quiet hint and the task id (the title lives in the border). */
|
|
307
|
-
function metaRow(input: LargeSceneInput, theme?: PanelTheme): string {
|
|
308
|
-
const meta = `⏱ ${input.elapsedLabel} · ${input.quietHint} · ${input.taskId}`;
|
|
309
|
-
return boxRow(paint(padTo(meta, CONTENT), "dim", theme), theme);
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
function barRow(input: LargeSceneInput, theme?: PanelTheme): string {
|
|
313
|
-
const percent = progress(input.done, input.total);
|
|
314
|
-
const filled = Math.round((percent / 100) * BAR.cells);
|
|
315
|
-
const counts = ` ${String(percent).padStart(3)}% (${input.done}/${input.total} tasks)`;
|
|
316
|
-
const row =
|
|
317
|
-
paint(BAR.filled.repeat(filled), "accent", theme, true) +
|
|
318
|
-
paint(BAR.empty.repeat(BAR.cells - filled), "dim", theme) +
|
|
319
|
-
paint(padTo(counts, CONTENT - BAR.cells), "dim", theme);
|
|
320
|
-
return boxRow(row, theme);
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
function boxRow(content: string, theme?: PanelTheme): string {
|
|
324
|
-
return paint("│", "muted", theme) + " " + content + " " + paint("│", "muted", theme);
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
function bodyLines(
|
|
328
|
-
input: LargeSceneInput,
|
|
329
|
-
width: number,
|
|
330
|
-
theme: PanelTheme | undefined,
|
|
331
|
-
avail: number,
|
|
332
|
-
): string[] {
|
|
333
|
-
const sections = input.tasks.length > 0;
|
|
334
|
-
const plan = pickPlan(avail, sections);
|
|
335
|
-
const entries = plan.section ? clamp(avail - blockRows(plan) - 1, 0, MAX_TASK_ROWS) : 0;
|
|
336
|
-
const section = sectionLines(input, width, theme, entries);
|
|
337
|
-
// The feed row takes a spare row only: it never costs the tower, the agents or a checklist row.
|
|
338
|
-
const spare = avail - blockRows(plan) - section.length;
|
|
339
|
-
const feed = input.feed && plan.strip && spare >= 1 ? [feedRow(input.feed, width, theme)] : [];
|
|
340
|
-
return [
|
|
341
|
-
...towerLines(input, width, theme, plan.tower),
|
|
342
|
-
...(plan.branch ? branchLines(input, width, theme) : []),
|
|
343
|
-
...(plan.strip ? slotLines(input, width, theme) : []),
|
|
344
|
-
...feed,
|
|
345
|
-
...section,
|
|
346
|
-
];
|
|
347
|
-
}
|
|
348
|
-
|
|
349
|
-
/** One dim line under the agent strip; warnings take the warning colour. */
|
|
350
|
-
function feedRow(feed: FeedLine, width: number, theme?: PanelTheme): string {
|
|
351
|
-
const left = sceneLeft(width) + 1;
|
|
352
|
-
const text = clip(feed.text, Math.max(0, Math.min(SCENE_WIDTH - 2, width - left)), "…");
|
|
353
|
-
return " ".repeat(left) + (feed.kind === "warning" ? paint(text, "warning", theme) : paint(text, "dim", theme));
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function blockRows(plan: LayoutPlan): number {
|
|
357
|
-
const tower = TOWER_ROWS[plan.tower];
|
|
358
|
-
return tower + (plan.branch ? BRANCH_ROWS : 0) + (plan.strip ? SLOT_ROWS : 0);
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
function pickPlan(avail: number, sections: boolean): LayoutPlan {
|
|
362
|
-
const needed = (plan: LayoutPlan) => blockRows(plan) + (plan.section && sections ? 1 + plan.minEntries : 0);
|
|
363
|
-
return PLANS.find((plan) => needed(plan) <= avail) ?? EMPTY_PLAN;
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
function oracleFrame(input: LargeSceneInput): OracleFrame {
|
|
367
|
-
const frames = ORACLE_FRAMES[input.oracle.pose];
|
|
368
|
-
return frames[mod(input.oracle.frame, frames.length)]!;
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
function frameIndex(input: LargeSceneInput): number {
|
|
372
|
-
return mod(input.oracle.frame, ORACLE_FRAMES[input.oracle.pose].length);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
function phaseOf(input: LargeSceneInput): number {
|
|
376
|
-
const phase = input.oracle.phase ?? 0;
|
|
377
|
-
return Number.isFinite(phase) && phase > 0 ? Math.floor(phase) : 0;
|
|
378
|
-
}
|
|
379
|
-
|
|
380
|
-
/** The oracle's resting mood: worried by a blocker, otherwise steady. */
|
|
381
|
-
export function oracleMood(input: LargeSceneInput): OracleMood {
|
|
382
|
-
return input.alert && input.alertKind === "error" ? "worried" : "steady";
|
|
383
|
-
}
|
|
384
|
-
|
|
385
|
-
function isLooking(input: LargeSceneInput): boolean {
|
|
386
|
-
return frameIndex(input) >= ORACLE_LOOK;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
/**
|
|
390
|
-
* Where the pupils look. The look-around emote scans the room; while talking the
|
|
391
|
-
* oracle looks at you; while agents work it looks down at them, moving on to
|
|
392
|
-
* the next one every `ORACLE_GAZE_TICKS`; otherwise its eyes wander the room.
|
|
393
|
-
*/
|
|
394
|
-
export function oracleGaze(input: LargeSceneInput): Gaze {
|
|
395
|
-
if (input.oracle.pose !== "orchestrating") return GAZE_AHEAD;
|
|
396
|
-
if (isLooking(input)) return ORACLE_LOOK_PATH[Math.min(phaseOf(input), ORACLE_LOOK_PATH.length - 1)]!;
|
|
397
|
-
if (input.oracle.talk !== undefined) return GAZE_AHEAD;
|
|
398
|
-
const working = input.slots.flatMap((slot, index) => (slot.status === "working" ? [index] : []));
|
|
399
|
-
if (working.length === 0) return ORACLE_WANDER[mod(Math.floor(input.tick / ORACLE_WANDER_TICKS), ORACLE_WANDER.length)]!;
|
|
400
|
-
const target = working[mod(Math.floor(input.tick / ORACLE_GAZE_TICKS), working.length)]!;
|
|
401
|
-
const centre = (input.slots.length - 1) / 2;
|
|
402
|
-
return { x: target < centre ? -1 : target > centre ? 1 : 0, y: 1 };
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
/** One eye window: a lid fills it, otherwise the pupil sits at the gaze column. */
|
|
406
|
-
function eyeCell(pupil: string, gaze: Gaze): string {
|
|
407
|
-
if (pupil === CLOSED_EYE) return CLOSED_EYE.repeat(EYE_WIDTH);
|
|
408
|
-
const glyph = pupil === OPEN_EYE ? ORACLE_PUPILS[gaze.y] : pupil;
|
|
409
|
-
const at = Math.floor(EYE_WIDTH / 2) + gaze.x;
|
|
410
|
-
return Array.from({ length: EYE_WIDTH }, (_value, column) => (column === at ? glyph : " ")).join("");
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
/** Both pupils: blink lids step with the phase, the frame supplies the rest. */
|
|
414
|
-
function oraclePupils(input: LargeSceneInput): readonly [string, string] {
|
|
415
|
-
if (frameIndex(input) === ORACLE_BLINK) {
|
|
416
|
-
const lids = ORACLE_BLINKS[input.oracle.pose];
|
|
417
|
-
return lids[Math.min(phaseOf(input), lids.length - 1)]!;
|
|
418
|
-
}
|
|
419
|
-
const frame = oracleFrame(input);
|
|
420
|
-
return [frame.winL, frame.winR];
|
|
421
|
-
}
|
|
422
|
-
|
|
423
|
-
/** The mouth: lip-sync while talking, the mood's straight line or frown otherwise; dormant it snores. */
|
|
424
|
-
function oracleMouth(input: LargeSceneInput): string {
|
|
425
|
-
if (input.oracle.pose !== "orchestrating") return oracleFrame(input).mouth;
|
|
426
|
-
if (input.oracle.talk !== undefined) return ORACLE_TALK[mod(input.oracle.talk, ORACLE_TALK.length)]!;
|
|
427
|
-
return ORACLE_MOOD_MOUTHS[oracleMood(input)];
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/** The whole face for this frame: orb, both eye windows (three columns each) and the mouth. */
|
|
431
|
-
export function oracleFace(input: LargeSceneInput): { orb: string; left: string; right: string; mouth: string } {
|
|
432
|
-
const [left, right] = oraclePupils(input);
|
|
433
|
-
const gaze = oracleGaze(input);
|
|
434
|
-
return { orb: oracleFrame(input).orb, left: eyeCell(left, gaze), right: eyeCell(right, gaze), mouth: oracleMouth(input) };
|
|
435
|
-
}
|
|
436
|
-
|
|
437
|
-
function oracleAura(input: LargeSceneInput): string {
|
|
438
|
-
const frames = ORACLE_AURA[input.oracle.pose];
|
|
439
|
-
return frames[mod(Math.floor(input.tick / ORACLE_AURA_TICKS), frames.length)]!;
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
/** Painted token values for one frame; built once and shared by every tower row. */
|
|
443
|
-
type TowerValues = ReadonlyMap<string, string>;
|
|
444
|
-
|
|
445
|
-
function towerValues(input: LargeSceneInput, theme?: PanelTheme): TowerValues {
|
|
446
|
-
const face = oracleFace(input);
|
|
447
|
-
const accent = ORACLE_COLORS[input.oracle.pose];
|
|
448
|
-
const glow = (text: string) => paint(text, accent.color, theme, accent.bold);
|
|
449
|
-
return new Map([
|
|
450
|
-
[TOWER.tokens.aura, paint(oracleAura(input), accent.color, theme)],
|
|
451
|
-
[TOWER.tokens.orb, glow(face.orb)],
|
|
452
|
-
[TOWER.tokens.winL, glow(face.left)],
|
|
453
|
-
[TOWER.tokens.winR, glow(face.right)],
|
|
454
|
-
[TOWER.tokens.mouth, glow(face.mouth)],
|
|
455
|
-
[TOWER.tokens.door, glow(input.doorLabel ?? TOWER_DOOR)],
|
|
456
|
-
]);
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function towerRow(row: string, values: TowerValues, theme?: PanelTheme): string {
|
|
460
|
-
return row
|
|
461
|
-
.split(TOWER_PATTERN)
|
|
462
|
-
.map((part) => values.get(part) ?? paint(part, "muted", theme))
|
|
463
|
-
.join("");
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
/** The bubble's first line: what the oracle is doing right now. */
|
|
467
|
-
export function oracleSpeech(input: LargeSceneInput): string {
|
|
468
|
-
if (input.oracle.pose !== "orchestrating") return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_WORDS.dormant}`;
|
|
469
|
-
const spin = SPIN_FRAMES[mod(input.tick, SPIN_FRAMES.length)]!;
|
|
470
|
-
if (input.oracleActivity) return `${spin} ${input.oracleActivity}`;
|
|
471
|
-
if (workingLabels(input).length > 0) return `${spin} ${ORACLE_WORDS.orchestrating}`;
|
|
472
|
-
return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_IDLE_WORD}`;
|
|
473
|
-
}
|
|
474
|
-
|
|
475
|
-
function workingLabels(input: LargeSceneInput): string[] {
|
|
476
|
-
return input.slots.filter((slot) => slot.status === "working").map((slot) => slot.label || SLOT_LABELS[slot.id]);
|
|
477
|
-
}
|
|
478
|
-
|
|
479
|
-
/** The bubble's second line: who is at work, else plan progress while building, else the task phase. */
|
|
480
|
-
export function oracleAside(input: LargeSceneInput): string {
|
|
481
|
-
const working = workingLabels(input);
|
|
482
|
-
if (input.oracle.pose === "orchestrating" && working.length > 0) return `→ ${working.join(" · ")}`;
|
|
483
|
-
const building = /^(implementing|reviewing)\b/.test(input.state);
|
|
484
|
-
if (building && input.total > 0) {
|
|
485
|
-
return input.done >= input.total ? "all steps done" : `step ${Math.min(input.done + 1, input.total)} of ${input.total}`;
|
|
486
|
-
}
|
|
487
|
-
return input.caption ?? input.state;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
/**
|
|
491
|
-
* The speech bubble beside the crown: a rounded box with `BUBBLE.lines` text
|
|
492
|
-
* rows. The first text row opens with `┤`, where the tail from the oracle lands.
|
|
493
|
-
*/
|
|
494
|
-
function bubbleRows(input: LargeSceneInput, theme?: PanelTheme): string[] {
|
|
495
|
-
const accent = ORACLE_COLORS[input.oracle.pose];
|
|
496
|
-
const border = (text: string) => paint(text, "muted", theme);
|
|
497
|
-
const text = (value: string) => padTo(clip(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
|
|
498
|
-
const rule = "─".repeat(BUBBLE.width - 2);
|
|
499
|
-
return [
|
|
500
|
-
border(`╭${rule}╮`),
|
|
501
|
-
border("┤ ") + paint(text(oracleSpeech(input)), accent.color, theme, accent.bold) + border(" │"),
|
|
502
|
-
border("│ ") + paint(text(oracleAside(input)), "muted", theme) + border(" │"),
|
|
503
|
-
border(`╰${rule}╯`),
|
|
504
|
-
];
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
/** Row where the bubble starts: its tail row lines up with the crown orb where there is room. */
|
|
508
|
-
function bubbleStart(rows: readonly string[]): number {
|
|
509
|
-
const orb = rows.findIndex((row) => row.includes(TOWER.tokens.orb));
|
|
510
|
-
return clamp(orb - 1, 0, Math.max(0, rows.length - BUBBLE_ROWS));
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
/**
|
|
514
|
-
* The tail from the oracle to the bubble: the tower row's trailing blanks plus
|
|
515
|
-
* the one-column gap become `╶──` (`──(◉)── ╶──┤`); a full-width row gets a stub.
|
|
516
|
-
*/
|
|
517
|
-
function tailRow(row: string, values: TowerValues, theme?: PanelTheme): string {
|
|
518
|
-
const body = row.replace(/ +$/, "");
|
|
519
|
-
const pad = row.length - body.length;
|
|
520
|
-
const tail = pad >= 1 ? ` ╶${"─".repeat(pad - 1)}` : "╶";
|
|
521
|
-
return towerRow(body, values, theme) + paint(tail, "muted", theme);
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function towerLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, size: TowerSize): string[] {
|
|
525
|
-
if (size === "none") return [];
|
|
526
|
-
const rows = size === "small" ? TOWER.smallRows : TOWER.rows;
|
|
527
|
-
const left = " ".repeat(sceneLeft(width) + Math.floor((SCENE_WIDTH - TOWER_WIDTH) / 2));
|
|
528
|
-
const values = towerValues(input, theme);
|
|
529
|
-
const bubble = bubbleRows(input, theme);
|
|
530
|
-
const start = bubbleStart(rows);
|
|
531
|
-
return rows.map((row, index) => {
|
|
532
|
-
const speech = bubble[index - start];
|
|
533
|
-
if (speech === undefined) return left + towerRow(row, values, theme);
|
|
534
|
-
const tower = index === start + 1 ? tailRow(row, values, theme) : `${towerRow(row, values, theme)} `;
|
|
535
|
-
return left + tower + speech;
|
|
536
|
-
});
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
function stripWidth(count: number): number {
|
|
540
|
-
return count * SLOT_CELL + Math.max(0, count - 1) * SLOT_GAP;
|
|
541
|
-
}
|
|
542
|
-
|
|
543
|
-
function stripLeft(count: number, width: number): number {
|
|
544
|
-
return Math.max(0, Math.floor((width - stripWidth(count)) / 2));
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
function slotOffsets(count: number, width: number): number[] {
|
|
548
|
-
const left = stripLeft(count, width);
|
|
549
|
-
return Array.from({ length: count }, (_value, index) => left + index * (SLOT_CELL + SLOT_GAP));
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
function branchLines(input: LargeSceneInput, width: number, theme?: PanelTheme): string[] {
|
|
553
|
-
const count = Math.min(input.slots.length, MAX_SLOTS);
|
|
554
|
-
if (count === 0) return [];
|
|
555
|
-
const nodes = slotOffsets(count, width).map((offset) => offset + Math.floor(SLOT_CELL / 2));
|
|
556
|
-
const stem = sceneCentre(width);
|
|
557
|
-
const end = nodes[nodes.length - 1]! + 1;
|
|
558
|
-
const rows = [stemLine(stem, end), treeLine(nodes, stem, end), pipeLine(nodes, end)];
|
|
559
|
-
return rows.map((row) => paint(row, "muted", theme));
|
|
560
|
-
}
|
|
561
|
-
|
|
562
|
-
/** Absolute-coordinate row: an explicit mark wins, otherwise `fill` draws the cell. */
|
|
563
|
-
function absRow(length: number, fill: (index: number) => string, marks: ReadonlyMap<number, string>): string {
|
|
564
|
-
return Array.from({ length }, (_value, index) => marks.get(index) ?? fill(index)).join("");
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
/** The tower stem, dropping from `sceneCentre(width)` onto the tree row. */
|
|
568
|
-
function stemLine(stem: number, end: number): string {
|
|
569
|
-
return absRow(Math.max(end, stem + 1), () => " ", new Map([[stem, "│"]]));
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
function treeLine(nodes: readonly number[], stem: number, end: number): string {
|
|
573
|
-
const marks = new Map<number, string>();
|
|
574
|
-
nodes.forEach((node, index) => marks.set(node, index === 0 ? "┌" : index === nodes.length - 1 ? "┐" : "┬"));
|
|
575
|
-
if (!marks.has(stem) && stem > nodes[0]! && stem < nodes[nodes.length - 1]!) marks.set(stem, "┴");
|
|
576
|
-
return absRow(end, (index) => (index >= nodes[0]! ? "─" : " "), marks);
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
function pipeLine(nodes: readonly number[], end: number): string {
|
|
580
|
-
return absRow(end, () => " ", new Map(nodes.map((node) => [node, "│"] as const)));
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function slotLines(input: LargeSceneInput, width: number, theme?: PanelTheme): string[] {
|
|
584
|
-
const slots = input.slots.slice(0, MAX_SLOTS);
|
|
585
|
-
if (slots.length === 0) return [];
|
|
586
|
-
const offsets = slotOffsets(slots.length, width);
|
|
587
|
-
const end = stripLeft(slots.length, width) + stripWidth(slots.length);
|
|
588
|
-
return [
|
|
589
|
-
assemble(end, faceCells(slots, offsets, theme)),
|
|
590
|
-
assemble(end, labelCells(slots, offsets, theme)),
|
|
591
|
-
assemble(end, wordCells(slots, offsets, input.tick, theme)),
|
|
592
|
-
assemble(end, elapsedCells(slots, offsets, theme)),
|
|
593
|
-
];
|
|
594
|
-
}
|
|
595
|
-
|
|
596
|
-
function slotFrame(slot: LargeSlot): readonly string[] {
|
|
597
|
-
const frames = SLOT_FRAMES[slot.id][slot.status];
|
|
598
|
-
const index = mod(slot.frame, frames.length);
|
|
599
|
-
const live = index >= EMOTE_FRAME ? slot.emote?.[index - EMOTE_FRAME] : undefined;
|
|
600
|
-
return live !== undefined ? [live] : frames[index] ?? [];
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function faceCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
|
|
604
|
-
return slots.map((slot, index) => ({
|
|
605
|
-
offset: offsets[index]!,
|
|
606
|
-
text: centre(slotFrame(slot)[0] ?? "", SLOT_CELL),
|
|
607
|
-
paint: statusPaint(slot.status, theme),
|
|
608
|
-
}));
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
function labelCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
|
|
612
|
-
return slots.map((slot, index) => ({
|
|
613
|
-
offset: offsets[index]!,
|
|
614
|
-
text: centre(slot.label || SLOT_LABELS[slot.id], SLOT_CELL),
|
|
615
|
-
paint: labelPaint(slot.status, theme),
|
|
616
|
-
}));
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function wordCells(slots: readonly LargeSlot[], offsets: readonly number[], tick: number, theme?: PanelTheme): Cell[] {
|
|
620
|
-
return slots.map((slot, index) => ({
|
|
621
|
-
offset: offsets[index]!,
|
|
622
|
-
text: centre(slotStatusText(slot, tick), SLOT_CELL),
|
|
623
|
-
paint: wordPaint(slot, theme),
|
|
624
|
-
}));
|
|
625
|
-
}
|
|
626
|
-
|
|
627
|
-
function elapsedCells(slots: readonly LargeSlot[], offsets: readonly number[], theme?: PanelTheme): Cell[] {
|
|
628
|
-
return slots.map((slot, index) => ({
|
|
629
|
-
offset: offsets[index]!,
|
|
630
|
-
text: centre(slot.elapsedLabel, SLOT_CELL),
|
|
631
|
-
paint: dimPaint(theme),
|
|
632
|
-
}));
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
/**
|
|
636
|
-
* Working agents show the braille spinner with their live activity, or a flag
|
|
637
|
-
* when they wait on a file, go quiet or retry; every other state keeps its
|
|
638
|
-
* glyph and word.
|
|
639
|
-
*/
|
|
640
|
-
function slotStatusText(slot: LargeSlot, tick: number): string {
|
|
641
|
-
if (slot.status !== "working") return `${SLOT_STATE_GLYPHS[slot.status]} ${SLOT_STATE_WORDS[slot.status]}`;
|
|
642
|
-
if (slot.flag?.kind === "waiting") return "⧗ waiting";
|
|
643
|
-
if (slot.flag?.kind === "quiet") return `! quiet ${shortDuration(slot.flag.ms)}`;
|
|
644
|
-
if (slot.flag?.kind === "retry") return "↻ retrying";
|
|
645
|
-
return `${SPIN_FRAMES[mod(tick, SPIN_FRAMES.length)]!} ${slot.activity ?? SLOT_STATE_WORDS.working}`;
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
/** Flagged working agents paint their status row in the warning colour. */
|
|
649
|
-
function wordPaint(slot: LargeSlot, theme?: PanelTheme): ((text: string) => string) | undefined {
|
|
650
|
-
if (slot.status === "working" && slot.flag && theme) return (text) => paint(text, "warning", theme, true);
|
|
651
|
-
return statusPaint(slot.status, theme);
|
|
652
|
-
}
|
|
653
|
-
|
|
654
|
-
/** The elapsed row stays dim so the status row keeps the colour. */
|
|
655
|
-
function dimPaint(theme?: PanelTheme): ((text: string) => string) | undefined {
|
|
656
|
-
return theme ? (text) => theme.fg("dim", text) : undefined;
|
|
657
|
-
}
|
|
658
|
-
|
|
659
|
-
function assemble(end: number, cells: readonly Cell[]): string {
|
|
660
|
-
let row = "";
|
|
661
|
-
let cursor = 0;
|
|
662
|
-
for (const cell of cells) {
|
|
663
|
-
row += " ".repeat(Math.max(0, cell.offset - cursor)) + (cell.paint ? cell.paint(cell.text) : cell.text);
|
|
664
|
-
cursor = cell.offset + textWidth(cell.text);
|
|
665
|
-
}
|
|
666
|
-
return row + " ".repeat(Math.max(0, end - cursor));
|
|
667
|
-
}
|
|
668
|
-
|
|
669
|
-
/** The full-width TASKS checklist whenever it has rows and the entry budget allows it. */
|
|
670
|
-
function sectionLines(
|
|
671
|
-
input: LargeSceneInput,
|
|
672
|
-
width: number,
|
|
673
|
-
theme: PanelTheme | undefined,
|
|
674
|
-
entries: number,
|
|
675
|
-
): string[] {
|
|
676
|
-
if (entries < 1) return [];
|
|
677
|
-
const tasks = input.tasks.slice(0, MAX_TASK_ROWS);
|
|
678
|
-
if (tasks.length === 0) return [];
|
|
679
|
-
return soloSection(" TASKS", tasks.map((task) => taskCell(task, TASKS_CELL, theme)), entries, width, theme);
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
function soloSection(
|
|
683
|
-
header: string,
|
|
684
|
-
cells: readonly string[],
|
|
685
|
-
entries: number,
|
|
686
|
-
width: number,
|
|
687
|
-
theme?: PanelTheme,
|
|
688
|
-
): string[] {
|
|
689
|
-
const left = sceneLeft(width);
|
|
690
|
-
const rows = [paint(header, "muted", theme, true), ...cells.slice(0, entries)];
|
|
691
|
-
return rows.map((row) => " ".repeat(left) + row);
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
function taskCell(task: LargeTaskRow | undefined, cell: number, theme?: PanelTheme): string {
|
|
695
|
-
if (!task) return " ".repeat(cell);
|
|
696
|
-
const icon = task.status === "done" ? "[x]" : task.status === "current" ? "[>]" : "[ ]";
|
|
697
|
-
const text = padTo(`${icon} ${task.text}`, cell);
|
|
698
|
-
return paint(text, TASK_COLORS[task.status], theme, task.status === "current");
|
|
699
|
-
}
|