@a-t-h-i/bot-lobby 0.2.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/README.md CHANGED
@@ -118,8 +118,16 @@ and the built-in spinner are hidden, and a widget above the editor animates the
118
118
  task. At 72 columns and wider it draws a large scene: a header box with the task
119
119
  title and state in its top border, a progress bar, and a metadata row with
120
120
  elapsed time, quiet-mode hint and task id; an oracle tower with a twinkling
121
- aura (drifting z's while dormant), a radiant orb crown, window eyes, a
122
- seven-column mouth and its ORC door; beside the crown, the oracle's speech
121
+ aura (drifting z's while dormant), a radiant orb crown, two window eyes, a
122
+ seven-column mouth and its ORC door. Its pupils look around: they move
123
+ left, centre or right in each window and turn up `◓`, ahead `◉` or down `◒`.
124
+ While agents work it looks down at them, taking turns between them; while it
125
+ talks it looks at you; otherwise its eyes wander the room and keep returning
126
+ to you. It keeps a straight, serious face (`───`), tightening into a frown
127
+ only when the task is blocked, and its mouth lip-syncs as a voice waveform
128
+ for ~2 s whenever it says something new. Every 6–12 s it blinks (lids
129
+ stepping down and up) or scans the whole room; asleep, it peeks one eye open
130
+ or snores. Beside the crown, the oracle's speech
123
131
  bubble, its tail on the orb, says what the master is doing (`⠋ delegating`,
124
132
  `⠋ thinking`, `· your turn` once its turn ends, `· dormant` when paused) above
125
133
  who is at work (`→ DEV · QA`), the current step (`step 3 of 7`) or the task
@@ -134,9 +142,9 @@ terminals keep the
134
142
  boxed banner, header and compact animated strip, whose working line names the
135
143
  newest running agent's activity and elapsed time. Each sprite rests on one calm
136
144
  face and, independently every 20–30 s, briefly blinks (~500 ms) or emotes
137
- (~2 s, stepping through its kaomoji frames); the oracle's mouth moves with its
145
+ (~2 s, stepping through its kaomoji frames), all on one
138
146
  adaptive clock — 250 ms while work is live, 1 s when idle and ~120 ms while an
139
- expression plays — and their faces, colours and words follow each agent's status
147
+ expression plays or the oracle talks — and their faces, colours and words follow each agent's status
140
148
  (working, idle, done, failed). The large scene's rest and blink frames stay the
141
149
  five-column ASCII eyes, while its emote frames are status-aware kaomoji: nervous
142
150
  while working, happy when done (QA flexes and dances), scared on failure. The
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@a-t-h-i/bot-lobby",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Structured multi-agent software engineering orchestrator for Pi",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -44,9 +44,20 @@ function unit(rng: () => number): number {
44
44
  return Number.isFinite(value) ? Math.min(1, Math.max(0, value)) : 0;
45
45
  }
46
46
 
47
- /** Random resting gap before the next expression, in `[BLINK_MIN_MS, BLINK_MAX_MS]`. */
48
- export function nextGap(rng: () => number): number {
49
- return BLINK_MIN_MS + Math.round(unit(rng) * (BLINK_MAX_MS - BLINK_MIN_MS));
47
+ /** Resting window between expressions, milliseconds. */
48
+ export interface ExpressionGap {
49
+ min: number;
50
+ max: number;
51
+ }
52
+
53
+ /** Agent slots rest 20-30 s between expressions. */
54
+ export const SLOT_GAP: ExpressionGap = { min: BLINK_MIN_MS, max: BLINK_MAX_MS };
55
+ /** The oracle is the scene's centrepiece: it blinks and emotes every 6-12 s. */
56
+ export const ORACLE_GAP: ExpressionGap = { min: 6_000, max: 12_000 };
57
+
58
+ /** Random resting gap before the next expression, inside `gap` (the slot window by default). */
59
+ export function nextGap(rng: () => number, gap: ExpressionGap = SLOT_GAP): number {
60
+ return gap.min + Math.round(unit(rng) * (gap.max - gap.min));
50
61
  }
51
62
 
52
63
  /** Whether the next expression blinks (~65%) or emotes (~35%). */
@@ -55,9 +66,9 @@ export function pickEvent(rng: () => number): "blink" | "emote" {
55
66
  }
56
67
 
57
68
  /** A freshly rested expression that fires for the first time after one random gap. */
58
- export function createExpression(now: number, rng: () => number): ExpressionState {
69
+ export function createExpression(now: number, rng: () => number, gap: ExpressionGap = SLOT_GAP): ExpressionState {
59
70
  const start = Number.isFinite(now) ? now : 0;
60
- return { nextAt: start + nextGap(rng), until: start, startedAt: start, frame: REST_FRAME };
71
+ return { nextAt: start + nextGap(rng, gap), until: start, startedAt: start, frame: REST_FRAME };
61
72
  }
62
73
 
63
74
  /** True while `now` is inside a playing blink or emote. */
@@ -74,17 +85,43 @@ export function anyPlaying(states: readonly ExpressionState[], now: number): boo
74
85
  * Advance one sprite to `now`: start a blink or emote once the gap has elapsed,
75
86
  * drop back to the rest frame when it ends, and change nothing in between.
76
87
  */
77
- export function advanceExpression(state: ExpressionState, now: number, rng: () => number): ExpressionState {
88
+ export function advanceExpression(
89
+ state: ExpressionState,
90
+ now: number,
91
+ rng: () => number,
92
+ gap: ExpressionGap = SLOT_GAP,
93
+ ): ExpressionState {
78
94
  if (!Number.isFinite(now)) return state;
79
95
  if (now < state.until) return state.frame >= EMOTE_FRAME ? steppedEmote(state, now) : state;
80
96
  if (now < state.nextAt) return state.frame === REST_FRAME ? state : { ...state, frame: REST_FRAME };
81
- return play(now, rng);
97
+ return play(now, rng, gap);
82
98
  }
83
99
 
84
- function play(now: number, rng: () => number): ExpressionState {
100
+ function play(now: number, rng: () => number, gap: ExpressionGap): ExpressionState {
85
101
  const blink = pickEvent(rng) === "blink";
86
102
  const until = now + (blink ? BLINK_MS : EMOTE_MS);
87
- return { nextAt: until + nextGap(rng), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
103
+ return { nextAt: until + nextGap(rng, gap), until, startedAt: now, frame: blink ? BLINK_FRAME : EMOTE_FRAME };
104
+ }
105
+
106
+ /** One sub-step of a playing expression; the oracle's blink and glance step through these. */
107
+ export const PHASE_MS = FAST_TICK_MS;
108
+
109
+ /** Sub-steps elapsed in the playing expression, 0 while resting or on a bad clock. */
110
+ export function expressionPhase(state: ExpressionState, now: number): number {
111
+ if (!isPlaying(state, now)) return 0;
112
+ const elapsed = now - state.startedAt;
113
+ return Number.isFinite(elapsed) && elapsed > 0 ? Math.floor(elapsed / PHASE_MS) : 0;
114
+ }
115
+
116
+ /** How long the oracle lip-syncs after it says something new, and one mouth shape's hold. */
117
+ export const TALK_MS = 1800;
118
+ export const TALK_STEP_MS = FAST_TICK_MS;
119
+
120
+ /** Mouth-shape index while the oracle is still talking about what it said at `since`, else undefined. */
121
+ export function talkFrame(since: number | undefined, now: number): number | undefined {
122
+ if (since === undefined || !Number.isFinite(since) || !Number.isFinite(now)) return undefined;
123
+ const elapsed = now - since;
124
+ return elapsed >= 0 && elapsed < TALK_MS ? Math.floor(elapsed / TALK_STEP_MS) : undefined;
88
125
  }
89
126
 
90
127
  /** Emote frame for `now`: one step per EMOTE_STEP_MS, clamped to the last frame. */
@@ -6,7 +6,7 @@ import type { TaskState } from "../schemas/task.ts";
6
6
  * that `zen.ts` and `zen-large.ts` compose. Every glyph is exactly one visible
7
7
  * column so the art stays narrow/ambiguous-width safe: the non-ASCII glyphs are
8
8
  * the lobby `⌂` in `BANNER_NARROW`, the panel's ◐/✓/✗ status family, and the
9
- * box-drawing, bar, eye and aura glyphs of the large scene (─ │ ┌ ┐ └ ┘ ╭ ╮ ╰ ╯ ╲ ╱ ╶ ├ ┤ ┬ ┴ █ ▓ ▒ ░ ▁ ▂ ▃ ◉ ◍ ◎ ◌ ○ ✦ ✧).
9
+ * box-drawing, bar, eye and aura glyphs of the large scene (─ │ ┌ ┐ └ ┘ ╭ ╮ ╰ ╯ ╲ ╱ ╶ ├ ┤ ┬ ┴ █ ▓ ▒ ░ ▁-▇ ◉ ◍ ◎ ◌ ○ ◓ ◒ ✦ ✧).
10
10
  */
11
11
 
12
12
  /**
@@ -105,36 +105,135 @@ export const ORACLE_COLORS: Record<OraclePose, { color: PanelColor; bold: boolea
105
105
  dormant: { color: "dim", bold: false },
106
106
  };
107
107
 
108
- /** One oracle frame: the crown orb, both window eyes and the seven-column mouth. */
108
+ /** One oracle frame: the crown orb, both pupils and the seven-column mouth. */
109
109
  export interface OracleFrame {
110
110
  orb: string;
111
+ /**
112
+ * One-column pupil drawn inside its three-column eye window: `OPEN_EYE` takes
113
+ * the gaze's pupil, `CLOSED_EYE` fills the window as a lid, anything else is drawn as is.
114
+ */
111
115
  winL: string;
112
116
  winR: string;
113
117
  /** Exactly seven one-column glyphs; never moves the tower geometry. */
114
118
  mouth: string;
115
119
  }
116
120
 
121
+ /** Oracle expression frame indexes, matching the shared schedule (see expressions.ts). */
122
+ export const ORACLE_REST = 0;
123
+ export const ORACLE_BLINK = 1;
124
+ /** Both emote frames are the look-around; the second only dims the orb. */
125
+ export const ORACLE_LOOK = 2;
126
+
127
+ /** An open eye: its pupil comes from where the oracle is looking. */
128
+ export const OPEN_EYE = "◉";
129
+ /** A closed eye: the lid fills the whole window. */
130
+ export const CLOSED_EYE = "─";
131
+ /** A half-closed lid, mid-blink or half-asleep. */
132
+ export const HALF_LID = "◒";
133
+ /** Inner width of each eye window; the pupil moves across it. */
134
+ export const EYE_WIDTH = 3;
135
+
136
+ /** The oracle's straight, serious mouth: it never idles on a smile. */
137
+ export const ORACLE_MOUTH = " ─── ";
138
+
117
139
  /**
118
- * Indexed oracle expressions per pose: 0 rests, 1 blinks and 2-3 emote. While
119
- * orchestrating the oracle glances around (2) and speaks (3); dormant, it sleeps
120
- * with closed eyes, peeks (1) and snores (2). Every glyph is one column and every
121
- * mouth seven, so a frame swap never moves the tower geometry.
140
+ * Indexed oracle expressions per pose: rest, blink, then the two emote steps.
141
+ * Orchestrating, the oracle keeps a straight face and its emotes are a look
142
+ * around the room; dormant, it sleeps, peeks one eye open and snores (o, then O).
143
+ * While it talks the mouth lip-syncs instead.
122
144
  */
123
145
  export const ORACLE_FRAMES: Record<OraclePose, readonly OracleFrame[]> = {
124
146
  orchestrating: [
125
- { orb: "◉", winL: "◉", winR: "◉", mouth: " ╰───╯ " },
126
- { orb: "◉", winL: "─", winR: "─", mouth: " ╰───╯ " },
127
- { orb: "◎", winL: "◍", winR: "◉", mouth: " ─── " },
128
- { orb: "◍", winL: "◉", winR: "◍", mouth: " ▁▂▃▂▁ " },
147
+ { orb: "◉", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
148
+ { orb: "◉", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
149
+ { orb: "◎", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
150
+ { orb: "◍", winL: OPEN_EYE, winR: OPEN_EYE, mouth: ORACLE_MOUTH },
129
151
  ],
130
152
  dormant: [
131
- { orb: "◌", winL: "─", winR: "─", mouth: " ─── " },
132
- { orb: "◌", winL: "◌", winR: "─", mouth: " ─── " },
133
- { orb: "○", winL: "─", winR: "─", mouth: " ─o─ " },
134
- { orb: "○", winL: "─", winR: "─", mouth: " ─── " },
153
+ { orb: "◌", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
154
+ { orb: "◌", winL: HALF_LID, winR: CLOSED_EYE, mouth: ORACLE_MOUTH },
155
+ { orb: "○", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: " ─o─ " },
156
+ { orb: "○", winL: CLOSED_EYE, winR: CLOSED_EYE, mouth: " ─O─ " },
135
157
  ],
136
158
  };
137
159
 
160
+ /**
161
+ * Lid positions stepped through by a blink (one per `PHASE_MS`, the last held):
162
+ * half-lidded, shut, shut, half-lidded. Dormant, the "blink" is a sleepy peek:
163
+ * the left eye cracks open, looks, and drifts shut again.
164
+ */
165
+ export const ORACLE_BLINKS: Record<OraclePose, readonly (readonly [string, string])[]> = {
166
+ orchestrating: [[HALF_LID, HALF_LID], [CLOSED_EYE, CLOSED_EYE], [CLOSED_EYE, CLOSED_EYE], [HALF_LID, HALF_LID]],
167
+ dormant: [[HALF_LID, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [OPEN_EYE, CLOSED_EYE], [HALF_LID, CLOSED_EYE], [CLOSED_EYE, CLOSED_EYE]],
168
+ };
169
+
170
+ /**
171
+ * Where the pupils look. `x` moves the pupil across its three-column window
172
+ * (left, centre, right); `y` swaps its glyph, since the window is one row:
173
+ * `◓` looks up, `◉` straight ahead, `◒` down.
174
+ */
175
+ export interface Gaze {
176
+ x: -1 | 0 | 1;
177
+ y: -1 | 0 | 1;
178
+ }
179
+
180
+ export const GAZE_AHEAD: Gaze = { x: 0, y: 0 };
181
+
182
+ /** The pupil glyph for each vertical gaze. */
183
+ export const ORACLE_PUPILS: Record<Gaze["y"], string> = { [-1]: "◓", 0: "◉", 1: "◒" };
184
+
185
+ const look = (x: Gaze["x"], y: Gaze["y"]): Gaze => ({ x, y });
186
+
187
+ /**
188
+ * The look-around emote, one step per `PHASE_MS` across both emote frames
189
+ * (~2 s): a slow scan left, up across the ceiling, right, down at the agents
190
+ * and back to you.
191
+ */
192
+ export const ORACLE_LOOK_PATH: readonly Gaze[] = [
193
+ look(0, 0), look(-1, 0), look(-1, 0), look(-1, 0), look(-1, -1), look(-1, -1),
194
+ look(0, -1), look(1, -1), look(1, -1), look(1, 0), look(1, 0), look(1, 0),
195
+ look(1, 1), look(0, 1), look(0, 1), look(-1, 1), look(0, 0), look(0, 0),
196
+ ];
197
+
198
+ /**
199
+ * Idle wandering when nothing holds the oracle's attention: it keeps returning
200
+ * to you between glances around the room. Starts looking ahead.
201
+ */
202
+ export const ORACLE_WANDER: readonly Gaze[] = [
203
+ look(0, 0), look(-1, 0), look(0, 0), look(1, -1), look(1, 0), look(0, 0),
204
+ look(-1, 1), look(0, -1), look(0, 0), look(1, 1), look(-1, -1), look(0, 0),
205
+ ];
206
+
207
+ /** Ticks the eyes hold one wandering spot (~2.5 s while work is live). */
208
+ export const ORACLE_WANDER_TICKS = 10;
209
+
210
+ /**
211
+ * Lip-sync while the oracle talks: a voice waveform that swells and falls, with
212
+ * a closed-mouth breath so it reads as phrases rather than a buzz.
213
+ */
214
+ export const ORACLE_TALK: readonly string[] = [
215
+ " ▁▃▅▃▁ ",
216
+ " ▂▆▂ ",
217
+ " ▂▄▇▄▂ ",
218
+ " ▁▂▁ ",
219
+ " ▃▅▂▅▃ ",
220
+ " ▁▂▄▂▁ ",
221
+ " ▁ ",
222
+ ORACLE_MOUTH,
223
+ ];
224
+
225
+ /** The oracle's resting mood: steady by default, worried when the task is blocked. */
226
+ export type OracleMood = "steady" | "worried";
227
+
228
+ /** The resting mouth per mood: straight, or a tight frown when blocked. */
229
+ export const ORACLE_MOOD_MOUTHS: Record<OracleMood, string> = {
230
+ steady: ORACLE_MOUTH,
231
+ worried: " ╭─╮ ",
232
+ };
233
+
234
+ /** Ticks the eyes hold on one working agent before glancing to the next. */
235
+ export const ORACLE_GAZE_TICKS = 8;
236
+
138
237
  /**
139
238
  * The seven-column aura above the crown, stepped by the spinner tick: sparkles
140
239
  * twinkle while the oracle orchestrates, and z's drift up while it sleeps.
@@ -223,8 +322,9 @@ export const COMPACT_FRAMES: Record<SlotId, Record<SlotState, readonly string[]>
223
322
  /**
224
323
  * The oracle tower: 13 columns wide, `rows` while height allows and `smallRows`
225
324
  * when it must shrink. `{aura}` twinkles over the radiant `{orb}` crown,
226
- * `{winL}`/`{winR}` are the two window eyes, `{mouth}` speaks and `{door}` is
227
- * the name over the door. The speech bubble hangs beside the crown.
325
+ * `{winL}`/`{winR}` are the three-column eye windows the pupils move in,
326
+ * `{mouth}` speaks and `{door}` is the name over the door. The speech bubble
327
+ * hangs beside the crown.
228
328
  */
229
329
  export interface TowerSpec {
230
330
  rows: readonly string[];
@@ -244,9 +344,9 @@ export const TOWER: TowerSpec = {
244
344
  " ╱ │ ╲ ",
245
345
  " │ ",
246
346
  "╭─────┴─────╮",
247
- "│ ╭─╮ ╭─╮ │",
248
- "│ │{winL}│ │{winR}│ │",
249
- "│ ╰─╯ ╰─╯ │",
347
+ "│╭───╮ ╭───╮│",
348
+ "││{winL}│ │{winR}││",
349
+ "│╰───╯ ╰───╯│",
250
350
  "│ {mouth} │",
251
351
  "├───┬───┬───┤",
252
352
  "│░▒▓│{door}│▓▒░│",
@@ -255,7 +355,7 @@ export const TOWER: TowerSpec = {
255
355
  smallRows: [
256
356
  " ──({orb})── ",
257
357
  "╭─────┴─────╮",
258
- "│ │{winL}│ │{winR}│ │",
358
+ "││{winL}│ │{winR}││",
259
359
  "│░▒▓│{door}│▓▒░│",
260
360
  "└───┴───┴───┘",
261
361
  ],
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
 
@@ -48,12 +60,18 @@ function touch(): void {
48
60
  }
49
61
 
50
62
  /** Record the master's current activity word (see events.ts); undefined means it waits on the user. */
51
- export function setOracleActivity(activity: string | undefined): void {
63
+ export function setOracleActivity(activity: string | undefined, now = Date.now()): void {
52
64
  if (activity === oracleActivity) return;
53
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;
54
69
  touch();
55
70
  }
56
71
 
72
+ /** When the oracle last said something new; its mouth lip-syncs for `TALK_MS` after. */
73
+ let oracleSpokeAt: number | undefined;
74
+
57
75
  /** Upper bound on retained runs so a long task cannot grow the widget state without limit. */
58
76
  export const MAX_RETAINED_RUNS = 128;
59
77
 
@@ -131,9 +149,9 @@ function liveTickDelay(): number {
131
149
  return isLive() ? LIVE_TICK_MS : IDLE_TICK_MS;
132
150
  }
133
151
 
134
- /** Tick delay for the zen clock: fastest while an expression plays, so no blink or emote step is skipped. */
135
- export function expressionTickDelay(states: readonly ExpressionState[], now: number, live: boolean): number {
136
- 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;
137
155
  return live ? LIVE_TICK_MS : IDLE_TICK_MS;
138
156
  }
139
157
 
@@ -141,6 +159,11 @@ export function expressionTickDelay(states: readonly ExpressionState[], now: num
141
159
  type ExpressionKey = keyof ExpressionFrames;
142
160
  const EXPRESSION_KEYS: readonly ExpressionKey[] = [...SLOT_IDS, "oracle"];
143
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
+
144
167
  /** Animated zen scene + plan checklist shown above the editor while a task is active. */
145
168
  class ZenWidget implements Component {
146
169
  private tick = 0;
@@ -158,7 +181,7 @@ class ZenWidget implements Component {
158
181
  this.theme = theme;
159
182
  this.rng = rng;
160
183
  const now = Date.now();
161
- 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);
162
185
  this.expressions = Object.fromEntries(entries) as Record<ExpressionKey, ExpressionState>;
163
186
  this.timer = setInterval(() => this.advance(), this.delay);
164
187
  mountedWidget = this;
@@ -179,18 +202,22 @@ class ZenWidget implements Component {
179
202
  }
180
203
 
181
204
  private play(now: number): void {
182
- 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));
183
206
  }
184
207
 
185
208
  /** One interval, retimed when work starts or stops or an expression plays. */
186
209
  private retime(now: number): void {
187
- const delay = expressionTickDelay(Object.values(this.expressions), now, isLive());
210
+ const delay = expressionTickDelay(Object.values(this.expressions), now, isLive(), talkFrame(oracleSpokeAt, now) !== undefined);
188
211
  if (delay === this.delay) return;
189
212
  this.delay = delay;
190
213
  clearInterval(this.timer);
191
214
  this.timer = setInterval(() => this.advance(), delay);
192
215
  }
193
216
 
217
+ private motion(now: number): OracleMotion {
218
+ return { phase: expressionPhase(this.expressions.oracle, now), talk: talkFrame(oracleSpokeAt, now) };
219
+ }
220
+
194
221
  private frames(): Partial<Record<ExpressionKey, number>> {
195
222
  return Object.fromEntries(EXPRESSION_KEYS.map((key) => [key, this.expressions[key].frame]));
196
223
  }
@@ -207,9 +234,10 @@ class ZenWidget implements Component {
207
234
  const expressions = this.frames();
208
235
  const quiet = isQuiet();
209
236
  const frameKey = EXPRESSION_KEYS.map((key) => expressions[key] ?? 0).join(",");
210
- const key = `${width}|${rows}|${this.tick}|${frameKey}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
237
+ const motion = this.motion(now);
238
+ const key = `${width}|${rows}|${this.tick}|${frameKey}|${motion.phase}|${motion.talk}|${zenVersion}|${Math.floor(now / 1000)}|${quiet}`;
211
239
  if (this.cache && this.cache.key === key && this.cache.theme === theme) return this.cache.lines;
212
- const opts = { width, rows, tick: this.tick, theme, expressions, oracleActivity };
240
+ const opts = { width, rows, tick: this.tick, theme, expressions, oracleActivity, oracleMotion: motion };
213
241
  const lines = panelLines(zenState.task, zenState.runs, now, quiet, opts).map((line) => truncateToWidth(line, width));
214
242
  this.cache = { key, theme, lines };
215
243
  return lines;
@@ -2,11 +2,26 @@ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
2
  import {
3
3
  BAR,
4
4
  BUBBLE,
5
+ CLOSED_EYE,
6
+ EYE_WIDTH,
7
+ GAZE_AHEAD,
8
+ OPEN_EYE,
5
9
  ORACLE_AURA,
6
10
  ORACLE_AURA_TICKS,
11
+ ORACLE_BLINK,
12
+ ORACLE_BLINKS,
7
13
  ORACLE_COLORS,
8
- ORACLE_IDLE_WORD,
9
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,
10
25
  ORACLE_WORDS,
11
26
  SLOT_FRAMES,
12
27
  SLOT_LABELS,
@@ -17,7 +32,9 @@ import {
17
32
  TOWER,
18
33
  TOWER_DOOR,
19
34
  TOWER_WIDTH,
35
+ type Gaze,
20
36
  type OracleFrame,
37
+ type OracleMood,
21
38
  type OraclePose,
22
39
  type PanelColor,
23
40
  type SlotId,
@@ -63,6 +80,17 @@ export interface LargeTaskRow {
63
80
  status: "done" | "current" | "pending";
64
81
  }
65
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
+
66
94
  /** Everything the large scene draws; assembled by the caller from task and runs. */
67
95
  export interface LargeSceneInput {
68
96
  taskId: string;
@@ -76,7 +104,7 @@ export interface LargeSceneInput {
76
104
  total: number;
77
105
  slots: readonly LargeSlot[];
78
106
  tasks: readonly LargeTaskRow[];
79
- oracle: { pose: OraclePose; frame: number };
107
+ oracle: OracleInput;
80
108
  /** Master's live activity for the speech bubble; absent means it waits on the user. */
81
109
  oracleActivity?: string;
82
110
  /** Plain-words task phase ("writing the plan"); the bubble's fallback second line. */
@@ -310,26 +338,98 @@ function oracleFrame(input: LargeSceneInput): OracleFrame {
310
338
  return frames[mod(input.oracle.frame, frames.length)]!;
311
339
  }
312
340
 
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
+ }
389
+ const frame = oracleFrame(input);
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
+
313
407
  function oracleAura(input: LargeSceneInput): string {
314
408
  const frames = ORACLE_AURA[input.oracle.pose];
315
409
  return frames[mod(Math.floor(input.tick / ORACLE_AURA_TICKS), frames.length)]!;
316
410
  }
317
411
 
318
- function towerRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
319
- const frame = oracleFrame(input);
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);
320
417
  const accent = ORACLE_COLORS[input.oracle.pose];
321
418
  const glow = (text: string) => paint(text, accent.color, theme, accent.bold);
322
- const values = new Map<string, () => string>([
323
- [TOWER.tokens.aura, () => paint(oracleAura(input), accent.color, theme)],
324
- [TOWER.tokens.orb, () => glow(frame.orb)],
325
- [TOWER.tokens.winL, () => glow(frame.winL)],
326
- [TOWER.tokens.winR, () => glow(frame.winR)],
327
- [TOWER.tokens.mouth, () => glow(frame.mouth)],
328
- [TOWER.tokens.door, () => glow(input.doorLabel ?? TOWER_DOOR)],
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)],
329
426
  ]);
427
+ }
428
+
429
+ function towerRow(row: string, values: TowerValues, theme?: PanelTheme): string {
330
430
  return row
331
431
  .split(TOWER_PATTERN)
332
- .map((part) => values.get(part)?.() ?? paint(part, "muted", theme))
432
+ .map((part) => values.get(part) ?? paint(part, "muted", theme))
333
433
  .join("");
334
434
  }
335
435
 
@@ -384,23 +484,24 @@ function bubbleStart(rows: readonly string[]): number {
384
484
  * The tail from the oracle to the bubble: the tower row's trailing blanks plus
385
485
  * the one-column gap become `╶──` (`──(◉)── ╶──┤`); a full-width row gets a stub.
386
486
  */
387
- function tailRow(row: string, input: LargeSceneInput, theme?: PanelTheme): string {
487
+ function tailRow(row: string, values: TowerValues, theme?: PanelTheme): string {
388
488
  const body = row.replace(/ +$/, "");
389
489
  const pad = row.length - body.length;
390
490
  const tail = pad >= 1 ? ` ╶${"─".repeat(pad - 1)}` : "╶";
391
- return towerRow(body, input, theme) + paint(tail, "muted", theme);
491
+ return towerRow(body, values, theme) + paint(tail, "muted", theme);
392
492
  }
393
493
 
394
494
  function towerLines(input: LargeSceneInput, width: number, theme: PanelTheme | undefined, size: TowerSize): string[] {
395
495
  if (size === "none") return [];
396
496
  const rows = size === "small" ? TOWER.smallRows : TOWER.rows;
397
497
  const left = " ".repeat(sceneLeft(width) + Math.floor((SCENE_WIDTH - TOWER_WIDTH) / 2));
498
+ const values = towerValues(input, theme);
398
499
  const bubble = bubbleRows(input, theme);
399
500
  const start = bubbleStart(rows);
400
501
  return rows.map((row, index) => {
401
502
  const speech = bubble[index - start];
402
- if (speech === undefined) return left + towerRow(row, input, theme);
403
- const tower = index === start + 1 ? tailRow(row, input, theme) : `${towerRow(row, input, theme)} `;
503
+ if (speech === undefined) return left + towerRow(row, values, theme);
504
+ const tower = index === start + 1 ? tailRow(row, values, theme) : `${towerRow(row, values, theme)} `;
404
505
  return left + tower + speech;
405
506
  });
406
507
  }
package/src/pi/zen.ts CHANGED
@@ -537,9 +537,9 @@ function sceneSlots(metrics: SceneMetrics, expressions: ExpressionFrames): Large
537
537
  }));
538
538
  }
539
539
 
540
- function oracleSlot(task: Task, expressions: ExpressionFrames): LargeSceneInput["oracle"] {
540
+ function oracleSlot(task: Task, expressions: ExpressionFrames, motion: OracleMotion): LargeSceneInput["oracle"] {
541
541
  const pose = oraclePose(task);
542
- return { pose, frame: expressions.oracle ?? REST_FRAME };
542
+ return { pose, frame: expressions.oracle ?? REST_FRAME, ...motion };
543
543
  }
544
544
 
545
545
  function sceneTasks(steps: readonly PlanStep[]): LargeTaskRow[] {
@@ -556,8 +556,7 @@ function sceneInput(
556
556
  quiet: boolean,
557
557
  tick: number,
558
558
  steps: PlanStep[],
559
- expressions: ExpressionFrames,
560
- oracleActivity: string | undefined,
559
+ opts: PanelOptions,
561
560
  ): LargeSceneInput {
562
561
  const metrics = sceneMetrics(task, runs, now);
563
562
  const alert = taskAlert(task);
@@ -570,10 +569,10 @@ function sceneInput(
570
569
  tick,
571
570
  done: metrics.done,
572
571
  total: metrics.total,
573
- slots: sceneSlots(metrics, expressions),
572
+ slots: sceneSlots(metrics, opts.expressions ?? {}),
574
573
  tasks: sceneTasks(steps),
575
- oracle: oracleSlot(task, expressions),
576
- oracleActivity,
574
+ oracle: oracleSlot(task, opts.expressions ?? {}, opts.oracleMotion ?? {}),
575
+ oracleActivity: opts.oracleActivity,
577
576
  caption: task.paused ? "task paused" : SCENE_PROPS[task.state],
578
577
  alert: alert?.text,
579
578
  alertKind: alert?.kind,
@@ -591,6 +590,14 @@ export interface PanelOptions {
591
590
  expressions?: ExpressionFrames;
592
591
  /** Live master activity word for the oracle's speech bubble; absent means it waits on the user. */
593
592
  oracleActivity?: string;
593
+ /** Caller-clocked oracle animation: the expression's sub-step and the lip-sync shape. */
594
+ oracleMotion?: OracleMotion;
595
+ }
596
+
597
+ /** The oracle's clocked animation state beyond its expression frame (see expressions.ts). */
598
+ export interface OracleMotion {
599
+ phase?: number;
600
+ talk?: number;
594
601
  }
595
602
 
596
603
  /**
@@ -613,7 +620,7 @@ export function panelLines(
613
620
  const steps = planChecklist(task.plan ?? "", runs);
614
621
  const budget = largeLineBudget(opts.rows ?? DEFAULT_ROWS);
615
622
  if (width >= LARGE_MIN_WIDTH && budget >= MIN_LARGE_LINES) {
616
- const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts.expressions ?? {}, opts.oracleActivity), width, budget, opts.theme);
623
+ const scene = largeLines(sceneInput(task, runs, now, quiet, tick, steps, opts), width, budget, opts.theme);
617
624
  return scene.map((line) => truncateToWidth(line, width));
618
625
  }
619
626
  return compactPanel(task, runs, now, quiet, tick, steps, opts, width);