@a-t-h-i/bot-lobby 0.1.0 → 0.3.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/src/pi/ui.ts CHANGED
@@ -4,10 +4,22 @@ import type { AgentRun } from "../schemas/findings.ts";
4
4
  import { TERMINAL_STATES, type Task } from "../schemas/task.ts";
5
5
  import { activeTask } from "../state/persistence.ts";
6
6
  import { detectProjectRoot } from "../state/project.ts";
7
- import { advanceExpression, anyPlaying, createExpression, FAST_TICK_MS, type ExpressionState } from "./expressions.ts";
7
+ import {
8
+ advanceExpression,
9
+ anyPlaying,
10
+ createExpression,
11
+ expressionPhase,
12
+ FAST_TICK_MS,
13
+ ORACLE_GAP,
14
+ SLOT_GAP,
15
+ talkFrame,
16
+ type ExpressionGap,
17
+ type ExpressionState,
18
+ } from "./expressions.ts";
19
+ import { ORACLE_THINKING } from "./activity.ts";
8
20
  import { SLOT_IDS } from "./mascot-art.ts";
9
21
  import { isQuiet, isSubagentProcess, toggleQuiet } from "./quiet.ts";
10
- import { panelLines, type ExpressionFrames } from "./zen.ts";
22
+ import { panelLines, type ExpressionFrames, type OracleMotion } from "./zen.ts";
11
23
 
12
24
  export const STATUS_KEY = "bot-lobby";
13
25
 
@@ -26,18 +38,42 @@ export function statusText(task: Task | undefined, minimized = false): string {
26
38
  }
27
39
 
28
40
  let zenOn = false;
29
- let zenState: { task: Task | undefined; runs: AgentRun[] } = { task: undefined, runs: [] };
30
41
 
31
- /** Latest master tool activity; the oracle spinner line shows it. */
42
+ /**
43
+ * Widget state. `live` holds the runs reported in this session; `runs` is what the
44
+ * panel draws: the task's persisted worker records overlaid by the live copies,
45
+ * so the checklist replays after a reload. `version` bumps on every change and
46
+ * keys the widget's render cache.
47
+ */
48
+ let zenState: { task: Task | undefined; live: AgentRun[]; runs: AgentRun[] } = { task: undefined, live: [], runs: [] };
49
+ let zenVersion = 0;
50
+
51
+ /** Latest master tool activity; the oracle's speech bubble shows it. */
32
52
  let oracleActivity: string | undefined;
33
53
 
34
- /** Record the master's current activity word (see events.ts); undefined clears it. */
35
- export function setOracleActivity(activity: string | undefined): void {
54
+ /** The mounted widget, so run and activity updates can repaint without waiting a tick. */
55
+ let mountedWidget: { refresh(): void } | undefined;
56
+
57
+ function touch(): void {
58
+ zenVersion += 1;
59
+ mountedWidget?.refresh();
60
+ }
61
+
62
+ /** Record the master's current activity word (see events.ts); undefined means it waits on the user. */
63
+ export function setOracleActivity(activity: string | undefined, now = Date.now()): void {
64
+ if (activity === oracleActivity) return;
36
65
  oracleActivity = activity;
66
+ // The oracle speaks when it names something new, including handing you the turn;
67
+ // quietly going back to "thinking" between tool calls is not worth a word.
68
+ if (activity !== ORACLE_THINKING) oracleSpokeAt = now;
69
+ touch();
37
70
  }
38
71
 
72
+ /** When the oracle last said something new; its mouth lip-syncs for `TALK_MS` after. */
73
+ let oracleSpokeAt: number | undefined;
74
+
39
75
  /** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
40
- export const MAX_RETAINED_RUNS = 64;
76
+ export const MAX_RETAINED_RUNS = 128;
41
77
 
42
78
  /**
43
79
  * Merge `incoming` runs into `previous`, keyed by `runId`: a newer copy of a run
@@ -46,13 +82,39 @@ export const MAX_RETAINED_RUNS = 64;
46
82
  */
47
83
  export function mergeRuns(previous: readonly AgentRun[], incoming: readonly AgentRun[]): AgentRun[] {
48
84
  if (incoming.length === 0) return [...previous];
49
- const merged = [...previous];
85
+ const merged = new Map<string, AgentRun>();
86
+ for (const run of previous) merged.set(run.runId, run);
50
87
  for (const run of incoming) {
51
- const at = merged.findIndex((existing) => existing.runId === run.runId);
52
- if (at >= 0) merged.splice(at, 1);
53
- merged.push(run);
88
+ merged.delete(run.runId);
89
+ merged.set(run.runId, run);
54
90
  }
55
- return merged.slice(-MAX_RETAINED_RUNS);
91
+ return [...merged.values()].slice(-MAX_RETAINED_RUNS);
92
+ }
93
+
94
+ /** The task's persisted worker records as runs, oldest first; the panel only reads their plan fields. */
95
+ export function persistedRuns(task: Task | undefined): AgentRun[] {
96
+ return (task?.workerRuns ?? []).map((record) => ({
97
+ runId: record.runId,
98
+ taskId: task!.id,
99
+ domain: record.domain,
100
+ role: "worker",
101
+ status: record.status,
102
+ instruction: record.instruction,
103
+ output: "",
104
+ attempts: 1,
105
+ startedAt: record.startedAt,
106
+ ...(record.finishedAt ? { finishedAt: record.finishedAt } : {}),
107
+ }));
108
+ }
109
+
110
+ /** Runs are retained without their report text: the panel never reads it and reports can be large. */
111
+ function slim(runs: readonly AgentRun[]): AgentRun[] {
112
+ return runs.map((run) => (run.output ? { ...run, output: "" } : run));
113
+ }
114
+
115
+ function setZenState(task: Task | undefined, live: AgentRun[]): void {
116
+ zenState = { task, live, runs: mergeRuns(persistedRuns(task), live) };
117
+ touch();
56
118
  }
57
119
 
58
120
  /** Per-session standard-pi mode: the widget and Master prompt are hidden but ownership stays. */
@@ -69,7 +131,7 @@ export function setMinimized(value: boolean): void {
69
131
  /** Flip minimize/restore and refresh the footer; the session is unchanged. */
70
132
  export function toggleMinimized(ctx: ExtensionContext, configDir: string): void {
71
133
  setMinimized(!minimized);
72
- applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.runs);
134
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
73
135
  ctx.ui.notify(minimized ? "bot-lobby minimized — ctrl+shift+m or /bot-lobby restore to return" : "bot-lobby restored", "info");
74
136
  }
75
137
 
@@ -87,9 +149,9 @@ function liveTickDelay(): number {
87
149
  return isLive() ? LIVE_TICK_MS : IDLE_TICK_MS;
88
150
  }
89
151
 
90
- /** Tick delay for the zen clock: fastest while an expression plays, so no blink or emote step is skipped. */
91
- export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean): number {
92
- if (anyPlaying(states, now)) return FAST_TICK_MS;
152
+ /** Tick delay for the zen clock: fastest while an expression plays or the oracle talks, so no step is skipped. */
153
+ export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean, talking = false): number {
154
+ if (talking || anyPlaying(states, now)) return FAST_TICK_MS;
93
155
  return live ? LIVE_TICK_MS : IDLE_TICK_MS;
94
156
  }
95
157
 
@@ -97,9 +159,15 @@ export function expressionTickDelay(states: readonly ExpressionState[], now: num
97
159
  type ExpressionKey = keyof ExpressionFrames;
98
160
  const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
99
161
 
162
+ /** The oracle keeps a livelier schedule than the agent slots. */
163
+ function gapFor(key: ExpressionKey): ExpressionGap {
164
+ return key === "oracle" ? ORACLE_GAP : SLOT_GAP;
165
+ }
166
+
100
167
  /** Animated zen scene + plan checklist shown above the editor while a task is active. */
101
168
  class ZenWidget implements Component {
102
169
  private tick = 0;
170
+ private cache: { key: string; theme: Theme; lines: string[] } | undefined;
103
171
  private delay = liveTickDelay();
104
172
  private timer: ReturnType<typeof setInterval>;
105
173
  private disposed = false;
@@ -113,9 +181,15 @@ class ZenWidget implements Component {
113
181
  this.theme = theme;
114
182
  this.rng = rng;
115
183
  const now = Date.now();
116
- const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng)] as const);
184
+ const entries = EXPRESSION_KEYS.map((key) => [key, createExpression(now, rng, gapFor(key))] as const);
117
185
  this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
118
186
  this.timer = setInterval(() => this.advance(), this.delay);
187
+ mountedWidget = this;
188
+ }
189
+
190
+ /** Repaint now: state changed between ticks. */
191
+ refresh(): void {
192
+ if (!this.disposed) this.tui.requestRender();
119
193
  }
120
194
 
121
195
  private advance(): void {
@@ -128,34 +202,55 @@ class ZenWidget implements Component {
128
202
  }
129
203
 
130
204
  private play(now: number): void {
131
- for (const key of EXPRESSION_KEYS) this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng);
205
+ for (const key of EXPRESSION_KEYS) this.expressions[key] = advanceExpression(this.expressions[key], now, this.rng, gapFor(key));
132
206
  }
133
207
 
134
208
  /** One interval, retimed when work starts or stops or an expression plays. */
135
209
  private retime(now: number): void {
136
- const delay = expressionTickDelay(Object.values(this.expressions), now, isLive());
210
+ const delay = expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
137
211
  if (delay === this.delay) return;
138
212
  this.delay = delay;
139
213
  clearInterval(this.timer);
140
214
  this.timer = setInterval(() => this.advance(), delay);
141
215
  }
142
216
 
217
+ private motion(now: number): OracleMotion {
218
+ return { phase: expressionPhase(this.expressions.oracle, now), talk: talkFrame(oracleSpokeAt, now) };
219
+ }
220
+
143
221
  private frames(): Partial<Record<ExpressionKey, number>> {
144
222
  return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
145
223
  }
146
224
 
225
+ /**
226
+ * The panel only changes with the tick, an expression frame, the widget state or
227
+ * the elapsed second, so every other repaint (typing in the editor, streaming
228
+ * output) reuses the last lines instead of recomposing the scene.
229
+ */
147
230
  render(width: number): string[] {
148
231
  const now = Date.now();
149
- const opts = { width, rows: this.tui.terminal.rows, tick: this.tick, theme: this.theme(), expressions: this.frames(), oracleActivity };
150
- const lines = panelLines(zenState.task, zenState.runs, now, isQuiet(), opts);
151
- return lines.map((line) => truncateToWidth(line, width));
232
+ const rows = this.tui.terminal.rows;
233
+ const theme = this.theme();
234
+ const expressions = this.frames();
235
+ const quiet = isQuiet();
236
+ const frameKey = EXPRESSION_KEYS.map((key) => expressions[key] ?? 0).join(",");
237
+ const motion = this.motion(now);
238
+ const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
239
+ if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
240
+ const opts = { width, rows, tick: this.tick, theme, expressions, oracleActivity, oracleMotion: motion };
241
+ const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
242
+ this.cache = { key, theme, lines };
243
+ return lines;
152
244
  }
153
245
 
154
- invalidate(): void {}
246
+ invalidate(): void {
247
+ this.cache = undefined;
248
+ }
155
249
 
156
250
  dispose(): void {
157
251
  this.disposed = true;
158
252
  clearInterval(this.timer);
253
+ if (mountedWidget === this) mountedWidget = undefined;
159
254
  }
160
255
  }
161
256
 
@@ -176,7 +271,7 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
176
271
  const sessionId = ctx.sessionManager.getSessionId();
177
272
  const task = isSubagentProcess() || minimized ? undefined : activeTask(root, configDir, sessionId);
178
273
  const sameTask = zenState.task?.id === task?.id;
179
- zenState = { task, runs: mergeRuns(sameTask ? zenState.runs : [], runs) };
274
+ setZenState(task, mergeRuns(sameTask ? zenState.live : [], slim(runs)));
180
275
  ctx.ui.setStatus(STATUS_KEY, statusText(task, minimized));
181
276
  const active = Boolean(task && !TERMINAL_STATES.includes(task.state));
182
277
  if (!active) {
@@ -192,9 +287,24 @@ export function applyStatus(ctx: ExtensionContext, root: string, configDir: stri
192
287
  }
193
288
  }
194
289
 
290
+ /**
291
+ * Streamed run updates (start, every activity change, finish) from an in-flight
292
+ * `orchestrate` call. The task on disk does not change mid-call, so this only
293
+ * merges the runs and repaints; `applyStatus` rereads the task once the call ends.
294
+ * Before any task is loaded it falls back to `applyStatus` so the widget appears.
295
+ */
296
+ export function reportRuns(ctx: ExtensionContext, root: string, configDir: string, runs: AgentRun[]): void {
297
+ if (!zenState.task && !minimized) {
298
+ applyStatus(ctx, root, configDir, runs);
299
+ return;
300
+ }
301
+ setZenState(zenState.task, mergeRuns(zenState.live, slim(runs)));
302
+ }
303
+
195
304
  export function clearStatus(ctx: ExtensionContext): void {
196
305
  leaveZen(ctx);
197
306
  oracleActivity = undefined;
307
+ zenState = { task: undefined, live: [], runs: [] };
198
308
  ctx.ui.setStatus(STATUS_KEY, undefined);
199
309
  ctx.ui.setWidget(STATUS_KEY, undefined);
200
310
  }
@@ -217,7 +327,7 @@ function revealTools(ctx: ExtensionContext, configDir: string): void {
217
327
  const expanded = ctx.ui.getToolsExpanded();
218
328
  ctx.ui.setToolsExpanded(!expanded);
219
329
  ctx.ui.setToolsExpanded(expanded);
220
- applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.runs);
330
+ applyStatus(ctx, detectProjectRoot(ctx.cwd, configDir), configDir, zenState.live);
221
331
  ctx.ui.notify(
222
332
  quiet
223
333
  ? "bot-lobby: tool rows hidden from now on — alt+t reveals them"
@@ -1,8 +1,27 @@
1
1
  import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import {
3
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,
4
13
  ORACLE_COLORS,
5
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,
6
25
  ORACLE_WORDS,
7
26
  SLOT_FRAMES,
8
27
  SLOT_LABELS,
@@ -12,7 +31,10 @@ import {
12
31
  SPIN_FRAMES,
13
32
  TOWER,
14
33
  TOWER_DOOR,
34
+ TOWER_WIDTH,
35
+ type Gaze,
15
36
  type OracleFrame,
37
+ type OracleMood,
16
38
  type OraclePose,
17
39
  type PanelColor,
18
40
  type SlotId,
@@ -21,8 +43,8 @@ import {
21
43
  import type { PanelTheme } from "./zen.ts";
22
44
 
23
45
  /**
24
- * The large animated zen scene: a header box, the pulsing oracle tower with its
25
- * tree connector, the four animated agent columns (face, label, status word and
46
+ * The large animated zen scene: a header box, the oracle tower with its speech
47
+ * bubble and tree connector, the four animated agent columns (face, label, status word and
26
48
  * elapsed rows), and the TASKS checklist spanning the full scene width (there is
27
49
  * no LOG window).
28
50
  *
@@ -58,6 +80,17 @@ export interface LargeTaskRow {
58
80
  status: "done" | "current" | "pending";
59
81
  }
60
82
 
83
+ /** The oracle's pose plus the caller-clocked animation state (see expressions.ts). */
84
+ export interface OracleInput {
85
+ pose: OraclePose;
86
+ /** Expression frame: rest, blink, glance or delight (snore while dormant). */
87
+ frame: number;
88
+ /** Sub-step inside the playing expression; steps the blink lids and the glance. */
89
+ phase?: number;
90
+ /** Lip-sync shape index while the oracle talks about something it just said. */
91
+ talk?: number;
92
+ }
93
+
61
94
  /** Everything the large scene draws; assembled by the caller from task and runs. */
62
95
  export interface LargeSceneInput {
63
96
  taskId: string;
@@ -71,9 +104,11 @@ export interface LargeSceneInput {
71
104
  total: number;
72
105
  slots: readonly LargeSlot[];
73
106
  tasks: readonly LargeTaskRow[];
74
- oracle: { pose: OraclePose; frame: number };
75
- /** Master's live tool activity for the oracle spinner; absent reads "working". */
107
+ oracle: OracleInput;
108
+ /** Master's live activity for the speech bubble; absent means it waits on the user. */
76
109
  oracleActivity?: string;
110
+ /** Plain-words task phase ("writing the plan"); the bubble's fallback second line. */
111
+ caption?: string;
77
112
  /** Approval/blocked line; never dropped when present. */
78
113
  alert?: string;
79
114
  /** Severity of the alert line: approvals are `warning`, blocked is `error`. */
@@ -98,11 +133,15 @@ const TASK_COLORS: Record<LargeTaskRow["status"], PanelColor> = {
98
133
  };
99
134
 
100
135
  const TOWER_ROWS: Record<TowerSize, number> = {
101
- full: TOWER.rows.length + 1,
102
- small: TOWER.smallRows.length + 1,
136
+ full: TOWER.rows.length,
137
+ small: TOWER.smallRows.length,
103
138
  none: 0,
104
139
  };
105
140
 
141
+ /** Bubble rows: top border, the text lines and the bottom border. */
142
+ const BUBBLE_ROWS = BUBBLE.lines + 2;
143
+ const BUBBLE_TEXT = BUBBLE.width - 4;
144
+
106
145
  const TOWER_PATTERN = new RegExp(
107
146
  `(${Object.values(TOWER.tokens).map((token) => token.replace(/([{}])/g, "\\$1")).join("|")})`,
108
147
  );
@@ -299,40 +338,172 @@ function oracleFrame(input: LargeSceneInput): OracleFrame {
299
338
  return frames[mod(input.oracle.frame, frames.length)]!;
300
339
  }
301
340
 
302
- function towerRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
341
+ function frameIndex(input: LargeSceneInput): number {
342
+ return mod(input.oracle.frame, ORACLE_FRAMES[input.oracle.pose].length);
343
+ }
344
+
345
+ function phaseOf(input: LargeSceneInput): number {
346
+ const phase = input.oracle.phase ?? 0;
347
+ return Number.isFinite(phase) && phase > 0 ? Math.floor(phase) : 0;
348
+ }
349
+
350
+ /** The oracle's resting mood: worried by a blocker, otherwise steady. */
351
+ export function oracleMood(input: LargeSceneInput): OracleMood {
352
+ return input.alert && input.alertKind === "error" ? "worried" : "steady";
353
+ }
354
+
355
+ function isLooking(input: LargeSceneInput): boolean {
356
+ return frameIndex(input) >= ORACLE_LOOK;
357
+ }
358
+
359
+ /**
360
+ * Where the pupils look. The look-around emote scans the room; while talking the
361
+ * oracle looks at you; while agents work it looks down at them, moving on to
362
+ * the next one every `ORACLE_GAZE_TICKS`; otherwise its eyes wander the room.
363
+ */
364
+ export function oracleGaze(input: LargeSceneInput): Gaze {
365
+ if (input.oracle.pose !== "orchestrating") return GAZE_AHEAD;
366
+ if (isLooking(input)) return ORACLE_LOOK_PATH[Math.min(phaseOf(input), ORACLE_LOOK_PATH.length - 1)]!;
367
+ if (input.oracle.talk !== undefined) return GAZE_AHEAD;
368
+ const working = input.slots.flatMap((slot, index) => (slot.status === "working" ? [index] : []));
369
+ if (working.length === 0) return ORACLE_WANDER[mod(Math.floor(input.tick / ORACLE_WANDER_TICKS), ORACLE_WANDER.length)]!;
370
+ const target = working[mod(Math.floor(input.tick / ORACLE_GAZE_TICKS), working.length)]!;
371
+ const centre = (input.slots.length - 1) / 2;
372
+ return { x: target < centre ? -1 : target > centre ? 1 : 0, y: 1 };
373
+ }
374
+
375
+ /** One eye window: a lid fills it, otherwise the pupil sits at the gaze column. */
376
+ function eyeCell(pupil: string, gaze: Gaze): string {
377
+ if (pupil === CLOSED_EYE) return CLOSED_EYE.repeat(EYE_WIDTH);
378
+ const glyph = pupil === OPEN_EYE ? ORACLE_PUPILS[gaze.y] : pupil;
379
+ const at = Math.floor(EYE_WIDTH / 2) + gaze.x;
380
+ return Array.from({ length: EYE_WIDTH }, (_value, column) => (column === at ? glyph : " ")).join("");
381
+ }
382
+
383
+ /** Both pupils: blink lids step with the phase, the frame supplies the rest. */
384
+ function oraclePupils(input: LargeSceneInput): readonly [string, string] {
385
+ if (frameIndex(input) === ORACLE_BLINK) {
386
+ const lids = ORACLE_BLINKS[input.oracle.pose];
387
+ return lids[Math.min(phaseOf(input), lids.length - 1)]!;
388
+ }
303
389
  const frame = oracleFrame(input);
304
- const values = new Map<string, string>([
305
- [TOWER.tokens.orb, frame.orb],
306
- [TOWER.tokens.winL, frame.winL],
307
- [TOWER.tokens.winR, frame.winR],
308
- [TOWER.tokens.mouth, frame.mouth],
309
- [TOWER.tokens.door, input.doorLabel ?? TOWER_DOOR],
310
- ]);
390
+ return [frame.winL, frame.winR];
391
+ }
392
+
393
+ /** The mouth: lip-sync while talking, the mood's straight line or frown otherwise; dormant it snores. */
394
+ function oracleMouth(input: LargeSceneInput): string {
395
+ if (input.oracle.pose !== "orchestrating") return oracleFrame(input).mouth;
396
+ if (input.oracle.talk !== undefined) return ORACLE_TALK[mod(input.oracle.talk, ORACLE_TALK.length)]!;
397
+ return ORACLE_MOOD_MOUTHS[oracleMood(input)];
398
+ }
399
+
400
+ /** The whole face for this frame: orb, both eye windows (three columns each) and the mouth. */
401
+ export function oracleFace(input: LargeSceneInput): { orb: string; left: string; right: string; mouth: string } {
402
+ const [left, right] = oraclePupils(input);
403
+ const gaze = oracleGaze(input);
404
+ return { orb: oracleFrame(input).orb, left: eyeCell(left, gaze), right: eyeCell(right, gaze), mouth: oracleMouth(input) };
405
+ }
406
+
407
+ function oracleAura(input: LargeSceneInput): string {
408
+ const frames = ORACLE_AURA[input.oracle.pose];
409
+ return frames[mod(Math.floor(input.tick / ORACLE_AURA_TICKS), frames.length)]!;
410
+ }
411
+
412
+ /** Painted token values for one frame; built once and shared by every tower row. */
413
+ type TowerValues = ReadonlyMap<string, string>;
414
+
415
+ function towerValues(input: LargeSceneInput, theme?: PanelTheme): TowerValues {
416
+ const face = oracleFace(input);
311
417
  const accent = ORACLE_COLORS[input.oracle.pose];
312
- const parts = row.split(TOWER_PATTERN);
313
- return parts
314
- .map((part) => {
315
- const value = values.get(part);
316
- return value === undefined ? paint(part, "muted", theme) : paint(value, accent.color, theme, accent.bold);
317
- })
418
+ const glow = (text: string) => paint(text, accent.color, theme, accent.bold);
419
+ return new Map([
420
+ [TOWER.tokens.aura, paint(oracleAura(input), accent.color, theme)],
421
+ [TOWER.tokens.orb, glow(face.orb)],
422
+ [TOWER.tokens.winL, glow(face.left)],
423
+ [TOWER.tokens.winR, glow(face.right)],
424
+ [TOWER.tokens.mouth, glow(face.mouth)],
425
+ [TOWER.tokens.door, glow(input.doorLabel ?? TOWER_DOOR)],
426
+ ]);
427
+ }
428
+
429
+ function towerRow(row: string, values: TowerValues, theme?: PanelTheme): string {
430
+ return row
431
+ .split(TOWER_PATTERN)
432
+ .map((part) => values.get(part) ?? paint(part, "muted", theme))
318
433
  .join("");
319
434
  }
320
435
 
321
- /** The oracle spinner line: the master's live tool activity, or the dormant word. */
322
- function oracleLine(input: LargeSceneInput, width: number, theme?: PanelTheme): string {
436
+ /** The bubble's first line: what the oracle is doing right now. */
437
+ export function oracleSpeech(input: LargeSceneInput): string {
438
+ if (input.oracle.pose !== "orchestrating") return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_WORDS.dormant}`;
439
+ const spin = SPIN_FRAMES[mod(input.tick, SPIN_FRAMES.length)]!;
440
+ if (input.oracleActivity) return `${spin} ${input.oracleActivity}`;
441
+ if (workingLabels(input).length > 0) return `${spin} ${ORACLE_WORDS.orchestrating}`;
442
+ return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_IDLE_WORD}`;
443
+ }
444
+
445
+ function workingLabels(input: LargeSceneInput): string[] {
446
+ return input.slots.filter((slot) => slot.status === "working").map((slot) => slot.label || SLOT_LABELS[slot.id]);
447
+ }
448
+
449
+ /** The bubble's second line: who is at work, else plan progress while building, else the task phase. */
450
+ export function oracleAside(input: LargeSceneInput): string {
451
+ const working = workingLabels(input);
452
+ if (input.oracle.pose === "orchestrating" && working.length > 0) return `→ ${working.join(" · ")}`;
453
+ const building = /^(implementing|reviewing)\b/.test(input.state);
454
+ if (building && input.total > 0) {
455
+ return input.done >= input.total ? "all steps done" : `step ${Math.min(input.done + 1, input.total)} of ${input.total}`;
456
+ }
457
+ return input.caption ?? input.state;
458
+ }
459
+
460
+ /**
461
+ * The speech bubble beside the crown: a rounded box with `BUBBLE.lines` text
462
+ * rows. The first text row opens with `┤`, where the tail from the oracle lands.
463
+ */
464
+ function bubbleRows(input: LargeSceneInput, theme?: PanelTheme): string[] {
323
465
  const accent = ORACLE_COLORS[input.oracle.pose];
324
- return place(paint(oracleStatusText(input), accent.color, theme, accent.bold), width);
466
+ const border = (text: string) => paint(text, "muted", theme);
467
+ const text = (value: string) => padTo(truncateToWidth(value, BUBBLE_TEXT, "…"), BUBBLE_TEXT);
468
+ const rule = "─".repeat(BUBBLE.width - 2);
469
+ return [
470
+ border(`╭${rule}╮`),
471
+ border("┤ ") + paint(text(oracleSpeech(input)), accent.color, theme, accent.bold) + border(" │"),
472
+ border("│ ") + paint(text(oracleAside(input)), "muted", theme) + border(" │"),
473
+ border(`╰${rule}╯`),
474
+ ];
325
475
  }
326
476
 
327
- function oracleStatusText(input: LargeSceneInput): string {
328
- if (input.oracle.pose !== "orchestrating") return `${SLOT_STATE_GLYPHS.idle} ${ORACLE_WORDS.dormant}`;
329
- return `${SPIN_FRAMES[mod(input.tick, SPIN_FRAMES.length)]!} ${input.oracleActivity ?? "working"}`;
477
+ /** Row where the bubble starts: its tail row lines up with the crown orb where there is room. */
478
+ function bubbleStart(rows: readonly string[]): number {
479
+ const orb = rows.findIndex((row) => row.includes(TOWER.tokens.orb));
480
+ return clamp(orb - 1, 0, Math.max(0, rows.length - BUBBLE_ROWS));
481
+ }
482
+
483
+ /**
484
+ * The tail from the oracle to the bubble: the tower row's trailing blanks plus
485
+ * the one-column gap become `╶──` (`──(◉)── ╶──┤`); a full-width row gets a stub.
486
+ */
487
+ function tailRow(row: string, values: TowerValues, theme?: PanelTheme): string {
488
+ const body = row.replace(/ +$/, "");
489
+ const pad = row.length - body.length;
490
+ const tail = pad >= 1 ? ` ╶${"─".repeat(pad - 1)}` : "╶";
491
+ return towerRow(body, values, theme) + paint(tail, "muted", theme);
330
492
  }
331
493
 
332
494
  function towerLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, size: TowerSize): string[] {
333
495
  if (size === "none") return [];
334
496
  const rows = size === "small" ? TOWER.smallRows : TOWER.rows;
335
- return [oracleLine(input, width, theme), ...rows.map((row) => place(towerRow(row, input, theme), width))];
497
+ const left = " ".repeat(sceneLeft(width) + Math.floor((SCENE_WIDTH - TOWER_WIDTH) / 2));
498
+ const values = towerValues(input, theme);
499
+ const bubble = bubbleRows(input, theme);
500
+ const start = bubbleStart(rows);
501
+ return rows.map((row, index) => {
502
+ const speech = bubble[index - start];
503
+ if (speech === undefined) return left + towerRow(row, values, theme);
504
+ const tower = index === start + 1 ? tailRow(row, values, theme) : `${towerRow(row, values, theme)} `;
505
+ return left + tower + speech;
506
+ });
336
507
  }
337
508
 
338
509
  function stripWidth(count: number): number {