@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 +12 -4
- package/package.json +1 -1
- package/src/pi/expressions.ts +46 -9
- package/src/pi/mascot-art.ts +120 -20
- package/src/pi/ui.ts +39 -11
- package/src/pi/zen-large.ts +117 -16
- package/src/pi/zen.ts +15 -8
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
|
|
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)
|
|
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
package/src/pi/expressions.ts
CHANGED
|
@@ -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
|
-
/**
|
|
48
|
-
export
|
|
49
|
-
|
|
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(
|
|
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. */
|
package/src/pi/mascot-art.ts
CHANGED
|
@@ -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
|
|
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:
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
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:
|
|
126
|
-
{ orb: "◉", winL:
|
|
127
|
-
{ orb: "◎", winL:
|
|
128
|
-
{ orb: "◍", winL:
|
|
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:
|
|
132
|
-
{ orb: "◌", winL:
|
|
133
|
-
{ orb: "○", winL:
|
|
134
|
-
{ orb: "○", winL:
|
|
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
|
|
227
|
-
* the name over the door. The speech bubble
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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 {
|
|
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
|
|
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
|
|
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;
|
package/src/pi/zen-large.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
319
|
-
|
|
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
|
-
|
|
323
|
-
[TOWER.tokens.aura,
|
|
324
|
-
[TOWER.tokens.orb,
|
|
325
|
-
[TOWER.tokens.winL,
|
|
326
|
-
[TOWER.tokens.winR,
|
|
327
|
-
[TOWER.tokens.mouth,
|
|
328
|
-
[TOWER.tokens.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)
|
|
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,
|
|
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,
|
|
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,
|
|
403
|
-
const tower = index === start + 1 ? tailRow(row,
|
|
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
|
-
|
|
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
|
|
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);
|