@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/README.md +26 -6
- package/package.json +4 -2
- package/prompts/master.md +5 -0
- package/src/execution/pi-runner.ts +22 -10
- package/src/pi/activity.ts +3 -0
- package/src/pi/events.ts +25 -3
- package/src/pi/expressions.ts +46 -9
- package/src/pi/mascot-art.ts +150 -33
- package/src/pi/tools.ts +2 -2
- package/src/pi/ui.ts +135 -25
- package/src/pi/zen-large.ts +198 -27
- package/src/pi/zen.ts +227 -60
- package/src/schemas/task.ts +18 -0
- package/src/workflow/workflow.ts +25 -1
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
|
|
|
@@ -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
|
-
/**
|
|
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
|
-
/**
|
|
35
|
-
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
52
|
-
|
|
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.
|
|
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
|
|
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
|
|
150
|
-
const
|
|
151
|
-
|
|
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
|
-
|
|
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.
|
|
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"
|
package/src/pi/zen-large.ts
CHANGED
|
@@ -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
|
|
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:
|
|
75
|
-
/** Master's live
|
|
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
|
|
102
|
-
small: TOWER.smallRows.length
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
313
|
-
return
|
|
314
|
-
.
|
|
315
|
-
|
|
316
|
-
|
|
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
|
|
322
|
-
function
|
|
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
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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 {
|