pi-one-ui 0.1.1
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/LICENSE +21 -0
- package/README.md +106 -0
- package/extensions/app/config/renderer.ts +452 -0
- package/extensions/app/config/shell.ts +2484 -0
- package/extensions/app/ownership.ts +18 -0
- package/extensions/app/panel.ts +533 -0
- package/extensions/app/presets.ts +36 -0
- package/extensions/features/agent-summary/core.ts +194 -0
- package/extensions/features/agent-summary/index.ts +37 -0
- package/extensions/features/compact-thinking.ts +1262 -0
- package/extensions/features/context.ts +857 -0
- package/extensions/features/reference/index.ts +643 -0
- package/extensions/features/reference/session.ts +278 -0
- package/extensions/features/reference/subagent.ts +218 -0
- package/extensions/features/shell/aliases.ts +28 -0
- package/extensions/features/shell/flush-docked-bash.ts +39 -0
- package/extensions/features/shell/startup-header.ts +357 -0
- package/extensions/features/shell/working-message.ts +226 -0
- package/extensions/index.ts +52 -0
- package/extensions/shell/accent-rail-editor.ts +112 -0
- package/extensions/shell/accent-rail-layout-patch.ts +530 -0
- package/extensions/shell/completion-menu.ts +186 -0
- package/extensions/shell/editor-metadata-format.ts +287 -0
- package/extensions/shell/editor-transfer.ts +73 -0
- package/extensions/shell/extension-status.ts +102 -0
- package/extensions/shell/footer-format.ts +388 -0
- package/extensions/shell/footer-layout.ts +144 -0
- package/extensions/shell/footer.ts +1035 -0
- package/extensions/shell/format.ts +577 -0
- package/extensions/shell/git.ts +434 -0
- package/extensions/shell/icons.ts +261 -0
- package/extensions/shell/index.ts +1659 -0
- package/extensions/shell/interaction-summary.ts +1194 -0
- package/extensions/shell/live-context.ts +82 -0
- package/extensions/shell/minimalist-editor.ts +548 -0
- package/extensions/shell/package-version.ts +675 -0
- package/extensions/shell/project-refresh.ts +130 -0
- package/extensions/shell/project-state.ts +60 -0
- package/extensions/shell/prototype-patch-registry.ts +183 -0
- package/extensions/shell/runtime.ts +915 -0
- package/extensions/shell/selector-border.ts +109 -0
- package/extensions/shell/session-lifecycle.ts +60 -0
- package/extensions/shell/settings-command.ts +2144 -0
- package/extensions/shell/settings-previews.ts +137 -0
- package/extensions/shell/state.ts +83 -0
- package/extensions/shell/style.ts +480 -0
- package/extensions/shell/telemetry.ts +90 -0
- package/extensions/shell/ui.ts +1326 -0
- package/extensions/shell/user-message-osc.ts +279 -0
- package/extensions/shell/user-message-styles.ts +266 -0
- package/extensions/shell/user-message.ts +226 -0
- package/extensions/shell/working-line-messages.ts +24 -0
- package/extensions/shell/working-line-spinners.ts +54 -0
- package/extensions/shell/working-line.ts +1603 -0
- package/extensions/tools/ansi-color.ts +44 -0
- package/extensions/tools/ansi-text.ts +52 -0
- package/extensions/tools/component-tree.ts +37 -0
- package/extensions/tools/format.ts +28 -0
- package/extensions/tools/fullscreen-detect.ts +11 -0
- package/extensions/tools/patch-keys.ts +158 -0
- package/extensions/tools/sgr-mouse.ts +59 -0
- package/extensions/tools/tool-loading-icon.ts +20 -0
- package/extensions/tools/tool-result-sanitize.ts +27 -0
- package/extensions/transcript/index.ts +65 -0
- package/extensions/transcript/renderer/compact-mode.ts +1443 -0
- package/extensions/transcript/renderer/default-mode.ts +735 -0
- package/extensions/transcript/renderer/index.ts +286 -0
- package/extensions/transcript/renderer/markdown-enhance.ts +325 -0
- package/extensions/transcript/renderer/mouse/hover.ts +175 -0
- package/extensions/transcript/renderer/mouse/interaction.ts +1016 -0
- package/extensions/transcript/renderer/mouse/layout.ts +207 -0
- package/extensions/transcript/renderer/mouse/packets.ts +96 -0
- package/extensions/transcript/renderer/mouse/scroll.ts +211 -0
- package/extensions/transcript/renderer/tool/diff/ATTRIBUTION.md +28 -0
- package/extensions/transcript/renderer/tool/diff/ansi-utils.ts +128 -0
- package/extensions/transcript/renderer/tool/diff/diff-component.ts +112 -0
- package/extensions/transcript/renderer/tool/diff/diff-edit-render.ts +255 -0
- package/extensions/transcript/renderer/tool/diff/diff-header.ts +184 -0
- package/extensions/transcript/renderer/tool/diff/diff-highlight.ts +131 -0
- package/extensions/transcript/renderer/tool/diff/diff-inline.ts +350 -0
- package/extensions/transcript/renderer/tool/diff/diff-layout.ts +870 -0
- package/extensions/transcript/renderer/tool/diff/diff-limits.ts +189 -0
- package/extensions/transcript/renderer/tool/diff/diff-palette.ts +436 -0
- package/extensions/transcript/renderer/tool/diff/diff-parse.ts +536 -0
- package/extensions/transcript/renderer/tool/diff/diff-presentation.ts +79 -0
- package/extensions/transcript/renderer/tool/diff/diff-renderer.ts +29 -0
- package/extensions/transcript/renderer/tool/diff/diff-text.ts +57 -0
- package/extensions/transcript/renderer/tool/diff/diff-write-render.ts +629 -0
- package/extensions/transcript/renderer/tool/diff/index.ts +147 -0
- package/extensions/transcript/renderer/tool/diff/line-width-safety.ts +100 -0
- package/extensions/transcript/renderer/tool/diff/shiki-highlight.ts +104 -0
- package/extensions/transcript/renderer/tool/diff/write-execution.ts +124 -0
- package/extensions/transcript/renderer/tool/grouping.ts +619 -0
- package/extensions/transcript/renderer/tool/message-display.ts +183 -0
- package/extensions/transcript/renderer/tool/names.ts +195 -0
- package/extensions/transcript/renderer/tool/result.ts +842 -0
- package/extensions/transcript/renderer/tool/show-more-hint.ts +42 -0
- package/extensions/transcript/renderer/transcript-refresh.ts +79 -0
- package/package.json +58 -0
- package/themes/cc-dark.json +91 -0
- package/themes/cc-light.json +91 -0
|
@@ -0,0 +1,1603 @@
|
|
|
1
|
+
import { stripVTControlCharacters } from "node:util";
|
|
2
|
+
import { visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
import type {
|
|
4
|
+
ColorSpec,
|
|
5
|
+
PolishedTuiColors,
|
|
6
|
+
WorkingLineComponentConfig,
|
|
7
|
+
WorkingLineTextAnimation,
|
|
8
|
+
ZentuiConfig,
|
|
9
|
+
} from "../app/config/shell.ts";
|
|
10
|
+
import { formatCount } from "./format.ts";
|
|
11
|
+
import {
|
|
12
|
+
isSafeSgrStylePrefix,
|
|
13
|
+
isSupportedColorSpec,
|
|
14
|
+
renderStyleForSourceOrFallback,
|
|
15
|
+
type SourceStyleFallback,
|
|
16
|
+
type ThemeLike,
|
|
17
|
+
} from "./style.ts";
|
|
18
|
+
import { PI_WORKING_LINE_MESSAGES } from "./working-line-messages.ts";
|
|
19
|
+
import { WORKING_LINE_SPINNERS } from "./working-line-spinners.ts";
|
|
20
|
+
|
|
21
|
+
export { WORKING_LINE_SPINNERS } from "./working-line-spinners.ts";
|
|
22
|
+
|
|
23
|
+
export const BUILT_IN_WORKING_LINE_MESSAGES = PI_WORKING_LINE_MESSAGES;
|
|
24
|
+
export const WORKING_LINE_FALLBACK_MESSAGE = "Working…";
|
|
25
|
+
|
|
26
|
+
export const MAX_WORKING_LINE_MESSAGES = 48;
|
|
27
|
+
/** Complete Loader row, including its two margins and Pi's indicator separator. */
|
|
28
|
+
export const MAX_WORKING_LINE_ROW_CELLS = 80;
|
|
29
|
+
/** Indicator payload budget inside the complete Loader row. */
|
|
30
|
+
export const MAX_WORKING_LINE_FRAME_CELLS = MAX_WORKING_LINE_ROW_CELLS - 3;
|
|
31
|
+
export const MAX_WORKING_LINE_MESSAGE_CELLS = 43;
|
|
32
|
+
export const MAX_WORKING_LINE_TOOL_CELLS = 18;
|
|
33
|
+
export const MAX_WORKING_LINE_FRAMES = 1024;
|
|
34
|
+
export const MAX_WORKING_LINE_FRAME_CODE_UNITS = 512 * 1024;
|
|
35
|
+
export const MAX_WORKING_LINE_RAW_CODE_UNITS = 4096;
|
|
36
|
+
/** Keeps combining-rich visible text bounded before it is copied into every animation frame. */
|
|
37
|
+
export const MAX_WORKING_LINE_NORMALIZED_CODE_UNITS = 256;
|
|
38
|
+
/** Three modifiers plus one color are sufficient for each Working-line tier. */
|
|
39
|
+
export const MAX_WORKING_LINE_STYLE_TOKENS = 4;
|
|
40
|
+
export const MAX_WORKING_LINE_STYLE_CODE_UNITS = 48;
|
|
41
|
+
export const MAX_WORKING_LINE_ENTRIES_EXAMINED = 256;
|
|
42
|
+
|
|
43
|
+
const WORKING_LINE_FALLBACKS: Record<
|
|
44
|
+
"low" | "mid" | "high",
|
|
45
|
+
SourceStyleFallback
|
|
46
|
+
> = {
|
|
47
|
+
low: { theme: "dim", terminal: "bright-black" },
|
|
48
|
+
mid: { theme: "muted", terminal: "cyan" },
|
|
49
|
+
high: { theme: "bold accent", terminal: "bold cyan" },
|
|
50
|
+
};
|
|
51
|
+
const CLASSIC_PADDING_CELLS = 4;
|
|
52
|
+
const CLASSIC_HIGHLIGHT_HALF_WIDTH = 4;
|
|
53
|
+
const KITT_TRAIL_CELLS = 4;
|
|
54
|
+
const SGR_RESET = "\x1b[0m";
|
|
55
|
+
|
|
56
|
+
type Tier = "low" | "mid" | "high";
|
|
57
|
+
type GraphemeCell = { text: string; start: number; width: number };
|
|
58
|
+
|
|
59
|
+
type WorkingLineUi = {
|
|
60
|
+
setWorkingMessage(message?: string): void;
|
|
61
|
+
setWorkingIndicator(options?: {
|
|
62
|
+
frames?: string[];
|
|
63
|
+
intervalMs?: number;
|
|
64
|
+
}): void;
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
type WorkingLineContext = {
|
|
68
|
+
hasUI?: boolean;
|
|
69
|
+
mode?: string;
|
|
70
|
+
ui: object;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
type AgentDurationListener = (durationMs: number) => void;
|
|
74
|
+
|
|
75
|
+
/** One agent-duration clock shared by minimalist Editor and Working-line consumers. */
|
|
76
|
+
export class AgentDurationClock {
|
|
77
|
+
private startedAt: number | undefined;
|
|
78
|
+
private completedMs: number | undefined;
|
|
79
|
+
private timer: ReturnType<typeof setInterval> | undefined;
|
|
80
|
+
private readonly listeners = new Set<AgentDurationListener>();
|
|
81
|
+
|
|
82
|
+
start(now = Date.now()): void {
|
|
83
|
+
this.stopTimer();
|
|
84
|
+
this.startedAt = now;
|
|
85
|
+
this.completedMs = 0;
|
|
86
|
+
this.notify();
|
|
87
|
+
this.reconcileTimer();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
finish(now = Date.now()): void {
|
|
91
|
+
if (this.startedAt !== undefined)
|
|
92
|
+
this.completedMs = Math.max(0, now - this.startedAt);
|
|
93
|
+
this.startedAt = undefined;
|
|
94
|
+
this.notify();
|
|
95
|
+
this.stopTimer();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
reset(): void {
|
|
99
|
+
this.stopTimer();
|
|
100
|
+
this.startedAt = undefined;
|
|
101
|
+
this.completedMs = undefined;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
isActive(): boolean {
|
|
105
|
+
return this.startedAt !== undefined;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
elapsedMs(now = Date.now()): number | undefined {
|
|
109
|
+
return this.startedAt === undefined
|
|
110
|
+
? this.completedMs
|
|
111
|
+
: Math.max(0, now - this.startedAt);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
subscribe(listener: AgentDurationListener): () => void {
|
|
115
|
+
this.listeners.add(listener);
|
|
116
|
+
this.reconcileTimer();
|
|
117
|
+
let active = true;
|
|
118
|
+
return () => {
|
|
119
|
+
if (!active) return;
|
|
120
|
+
active = false;
|
|
121
|
+
this.listeners.delete(listener);
|
|
122
|
+
this.reconcileTimer();
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private notify(): void {
|
|
127
|
+
const elapsed = this.elapsedMs();
|
|
128
|
+
if (elapsed === undefined) return;
|
|
129
|
+
for (const listener of this.listeners) listener(elapsed);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private reconcileTimer(): void {
|
|
133
|
+
if (!this.isActive() || this.listeners.size === 0) {
|
|
134
|
+
this.stopTimer();
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (this.timer !== undefined) return;
|
|
138
|
+
this.timer = setInterval(() => this.notify(), 1000);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
private stopTimer(): void {
|
|
142
|
+
if (this.timer === undefined) return;
|
|
143
|
+
clearInterval(this.timer);
|
|
144
|
+
this.timer = undefined;
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function segmentGraphemes(value: string): Iterable<string> {
|
|
149
|
+
try {
|
|
150
|
+
const Segmenter = Intl.Segmenter;
|
|
151
|
+
if (typeof Segmenter === "function") {
|
|
152
|
+
const segments = new Segmenter(undefined, {
|
|
153
|
+
granularity: "grapheme",
|
|
154
|
+
}).segment(value);
|
|
155
|
+
return {
|
|
156
|
+
*[Symbol.iterator]() {
|
|
157
|
+
for (const part of segments) yield part.segment;
|
|
158
|
+
},
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// Without Intl.Segmenter, treat the complete value as one conservative grapheme.
|
|
163
|
+
}
|
|
164
|
+
return [value];
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function truncateGraphemes(
|
|
168
|
+
value: string,
|
|
169
|
+
maximumCells: number,
|
|
170
|
+
maximumCodeUnits = Number.POSITIVE_INFINITY,
|
|
171
|
+
): string {
|
|
172
|
+
let width = 0;
|
|
173
|
+
let codeUnits = 0;
|
|
174
|
+
const output: string[] = [];
|
|
175
|
+
for (const grapheme of segmentGraphemes(value)) {
|
|
176
|
+
if (width >= maximumCells) break;
|
|
177
|
+
const nextWidth = visibleWidth(grapheme);
|
|
178
|
+
if (width + nextWidth > maximumCells) break;
|
|
179
|
+
if (codeUnits + grapheme.length > maximumCodeUnits) break;
|
|
180
|
+
output.push(grapheme);
|
|
181
|
+
width += nextWidth;
|
|
182
|
+
codeUnits += grapheme.length;
|
|
183
|
+
}
|
|
184
|
+
while (output.length > 0 && /^\s+$/u.test(output.at(-1) ?? "")) output.pop();
|
|
185
|
+
return output.join("");
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function boundRawWorkingLineInput(value: string): string {
|
|
189
|
+
if (value.length <= MAX_WORKING_LINE_RAW_CODE_UNITS) return value;
|
|
190
|
+
const prefix = value.slice(0, MAX_WORKING_LINE_RAW_CODE_UNITS);
|
|
191
|
+
const graphemes = [...segmentGraphemes(prefix)];
|
|
192
|
+
// The last segmented item may be only the prefix of a grapheme that crosses the raw bound.
|
|
193
|
+
graphemes.pop();
|
|
194
|
+
return graphemes.join("");
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function trimGraphemeWhitespace(value: string): string {
|
|
198
|
+
const graphemes = [...segmentGraphemes(value)];
|
|
199
|
+
while (graphemes.length > 0 && /^\s+$/u.test(graphemes[0] ?? ""))
|
|
200
|
+
graphemes.shift();
|
|
201
|
+
while (graphemes.length > 0 && /^\s+$/u.test(graphemes.at(-1) ?? ""))
|
|
202
|
+
graphemes.pop();
|
|
203
|
+
return graphemes.join("");
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function stripC1TerminalSequences(value: string): string {
|
|
207
|
+
return value
|
|
208
|
+
.replaceAll(
|
|
209
|
+
/[\u0090\u0098\u009d\u009e\u009f][\s\S]*?(?:\u0007|\u009c|\x1b\\|$)/g,
|
|
210
|
+
"",
|
|
211
|
+
)
|
|
212
|
+
.replaceAll(/\u009b[0-?]*[ -/]*[@-~]/g, "");
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/** Normalize untrusted user-authored text before it can reach Pi's working row. */
|
|
216
|
+
export function normalizeWorkingLineMessage(value: unknown): string {
|
|
217
|
+
if (typeof value !== "string") return "";
|
|
218
|
+
const bounded = boundRawWorkingLineInput(value);
|
|
219
|
+
const withoutTerminalSequences = stripVTControlCharacters(
|
|
220
|
+
stripC1TerminalSequences(bounded),
|
|
221
|
+
);
|
|
222
|
+
const withoutControls = withoutTerminalSequences
|
|
223
|
+
.replaceAll(/[\u0000-\u001f\u007f-\u009f]/g, " ")
|
|
224
|
+
.replaceAll(
|
|
225
|
+
/[\u034f\u061c\u200b\u200e\u200f\u202a-\u202e\u2060\u2066-\u206f]/g,
|
|
226
|
+
"",
|
|
227
|
+
);
|
|
228
|
+
const normalized = trimGraphemeWhitespace(
|
|
229
|
+
withoutControls.normalize("NFC").replaceAll(/\s+/gu, " "),
|
|
230
|
+
);
|
|
231
|
+
const truncated = truncateGraphemes(
|
|
232
|
+
normalized,
|
|
233
|
+
MAX_WORKING_LINE_MESSAGE_CELLS,
|
|
234
|
+
MAX_WORKING_LINE_NORMALIZED_CODE_UNITS,
|
|
235
|
+
);
|
|
236
|
+
return visibleWidth(truncated) > 0 ? truncated : "";
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function normalizeWorkingLineMessages(values: unknown): string[] {
|
|
240
|
+
if (!Array.isArray(values)) return [];
|
|
241
|
+
const output: string[] = [];
|
|
242
|
+
const seen = new Set<string>();
|
|
243
|
+
const examined = Math.min(values.length, MAX_WORKING_LINE_ENTRIES_EXAMINED);
|
|
244
|
+
for (let index = 0; index < examined; index += 1) {
|
|
245
|
+
const normalized = normalizeWorkingLineMessage(values[index]);
|
|
246
|
+
if (!normalized || seen.has(normalized)) continue;
|
|
247
|
+
seen.add(normalized);
|
|
248
|
+
output.push(normalized);
|
|
249
|
+
if (output.length === MAX_WORKING_LINE_MESSAGES) break;
|
|
250
|
+
}
|
|
251
|
+
return output;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export function effectiveWorkingLineMessages(
|
|
255
|
+
config: WorkingLineComponentConfig,
|
|
256
|
+
): string[] {
|
|
257
|
+
if (!config.messages.custom) return [WORKING_LINE_FALLBACK_MESSAGE];
|
|
258
|
+
const custom = normalizeWorkingLineMessages(config.messages.values);
|
|
259
|
+
return custom.length > 0 ? custom : [WORKING_LINE_FALLBACK_MESSAGE];
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export function selectWorkingLineMessage(
|
|
263
|
+
config: WorkingLineComponentConfig,
|
|
264
|
+
random: () => number = Math.random,
|
|
265
|
+
): string {
|
|
266
|
+
const pool = effectiveWorkingLineMessages(config);
|
|
267
|
+
if (!config.messages.custom || pool.length <= 1)
|
|
268
|
+
return pool[0] ?? WORKING_LINE_FALLBACK_MESSAGE;
|
|
269
|
+
const sample = random();
|
|
270
|
+
const finite = Number.isFinite(sample) ? sample : 0;
|
|
271
|
+
const index = Math.min(
|
|
272
|
+
pool.length - 1,
|
|
273
|
+
Math.max(0, Math.floor(finite * pool.length)),
|
|
274
|
+
);
|
|
275
|
+
return pool[index] ?? WORKING_LINE_FALLBACK_MESSAGE;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function gcd(left: number, right: number): number {
|
|
279
|
+
let a = left;
|
|
280
|
+
let b = right;
|
|
281
|
+
while (b !== 0) {
|
|
282
|
+
const remainder = a % b;
|
|
283
|
+
a = b;
|
|
284
|
+
b = remainder;
|
|
285
|
+
}
|
|
286
|
+
return a;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function lcm(left: number, right: number): number {
|
|
290
|
+
return (left / gcd(left, right)) * right;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function schedulePeriod(
|
|
294
|
+
intervalMs: number,
|
|
295
|
+
cycleAdvances: number,
|
|
296
|
+
quantumMs: number,
|
|
297
|
+
): number {
|
|
298
|
+
const product = intervalMs * cycleAdvances;
|
|
299
|
+
return product / gcd(quantumMs, product);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function graphemeCells(message: string): {
|
|
303
|
+
cells: GraphemeCell[];
|
|
304
|
+
width: number;
|
|
305
|
+
} {
|
|
306
|
+
const cells: GraphemeCell[] = [];
|
|
307
|
+
let start = 0;
|
|
308
|
+
for (const text of segmentGraphemes(message)) {
|
|
309
|
+
const width = visibleWidth(text);
|
|
310
|
+
cells.push({ text, start, width });
|
|
311
|
+
start += width;
|
|
312
|
+
}
|
|
313
|
+
return { cells, width: start };
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function classicTier(cell: GraphemeCell, tick: number): Tier {
|
|
317
|
+
const center = tick - CLASSIC_PADDING_CELLS;
|
|
318
|
+
const cellCenter = cell.start + Math.max(0, cell.width - 1) / 2;
|
|
319
|
+
const distance = Math.abs(cellCenter - center);
|
|
320
|
+
if (distance <= 1) return "high";
|
|
321
|
+
if (distance <= CLASSIC_HIGHLIGHT_HALF_WIDTH) return "mid";
|
|
322
|
+
return "low";
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function kittHead(
|
|
326
|
+
tick: number,
|
|
327
|
+
width: number,
|
|
328
|
+
): { position: number; direction: 1 | -1 } {
|
|
329
|
+
const span = width + KITT_TRAIL_CELLS - 1;
|
|
330
|
+
if (tick < span)
|
|
331
|
+
return { position: tick - (KITT_TRAIL_CELLS - 1), direction: 1 };
|
|
332
|
+
return {
|
|
333
|
+
position: span * 2 - 2 - tick - (KITT_TRAIL_CELLS - 1),
|
|
334
|
+
direction: -1,
|
|
335
|
+
};
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function kittTier(cell: GraphemeCell, tick: number, width: number): Tier {
|
|
339
|
+
const { position, direction } = kittHead(tick, width);
|
|
340
|
+
const cellEnd = cell.start + cell.width;
|
|
341
|
+
if (position >= cell.start && position < cellEnd) return "high";
|
|
342
|
+
const cellCenter = cell.start + Math.max(0, cell.width - 1) / 2;
|
|
343
|
+
const distance =
|
|
344
|
+
direction === 1 ? position - cellCenter : cellCenter - position;
|
|
345
|
+
if (distance > 0 && distance <= KITT_TRAIL_CELLS) return "mid";
|
|
346
|
+
return "low";
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function textPeriod(
|
|
350
|
+
animation: WorkingLineTextAnimation,
|
|
351
|
+
width: number,
|
|
352
|
+
): number {
|
|
353
|
+
switch (animation) {
|
|
354
|
+
case "classic":
|
|
355
|
+
return width + CLASSIC_PADDING_CELLS * 2;
|
|
356
|
+
case "kitt":
|
|
357
|
+
return (width + KITT_TRAIL_CELLS - 2) * 2;
|
|
358
|
+
case "disabled":
|
|
359
|
+
return 1;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
type TextSpatialPhase = { position: number; direction: 1 | -1 };
|
|
364
|
+
|
|
365
|
+
function normalizedPhaseTick(tick: number, period: number): number {
|
|
366
|
+
const integral = Number.isFinite(tick) ? Math.floor(tick) : 0;
|
|
367
|
+
return ((integral % period) + period) % period;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function textSpatialPhase(
|
|
371
|
+
animation: WorkingLineTextAnimation,
|
|
372
|
+
width: number,
|
|
373
|
+
tick: number,
|
|
374
|
+
): TextSpatialPhase | undefined {
|
|
375
|
+
const phase = normalizedPhaseTick(tick, textPeriod(animation, width));
|
|
376
|
+
switch (animation) {
|
|
377
|
+
case "classic":
|
|
378
|
+
return { position: phase - CLASSIC_PADDING_CELLS, direction: 1 };
|
|
379
|
+
case "kitt":
|
|
380
|
+
return kittHead(phase, width);
|
|
381
|
+
case "disabled":
|
|
382
|
+
return undefined;
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function textTickForSpatialPhase(
|
|
387
|
+
animation: WorkingLineTextAnimation,
|
|
388
|
+
width: number,
|
|
389
|
+
spatial: TextSpatialPhase | undefined,
|
|
390
|
+
): number {
|
|
391
|
+
if (!spatial || animation === "disabled") return 0;
|
|
392
|
+
if (animation === "classic") {
|
|
393
|
+
const position = Math.min(
|
|
394
|
+
width + CLASSIC_PADDING_CELLS - 1,
|
|
395
|
+
Math.max(-CLASSIC_PADDING_CELLS, spatial.position),
|
|
396
|
+
);
|
|
397
|
+
return position + CLASSIC_PADDING_CELLS;
|
|
398
|
+
}
|
|
399
|
+
if (spatial.direction === 1) {
|
|
400
|
+
const position = Math.min(
|
|
401
|
+
width - 1,
|
|
402
|
+
Math.max(-(KITT_TRAIL_CELLS - 1), spatial.position),
|
|
403
|
+
);
|
|
404
|
+
return position + KITT_TRAIL_CELLS - 1;
|
|
405
|
+
}
|
|
406
|
+
const position = Math.min(
|
|
407
|
+
width - 2,
|
|
408
|
+
Math.max(-(KITT_TRAIL_CELLS - 2), spatial.position),
|
|
409
|
+
);
|
|
410
|
+
return width * 2 + 1 - position;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
export function remapWorkingLineTextTick(
|
|
414
|
+
fromAnimation: WorkingLineTextAnimation,
|
|
415
|
+
fromWidth: number,
|
|
416
|
+
fromTick: number,
|
|
417
|
+
toAnimation: WorkingLineTextAnimation,
|
|
418
|
+
toWidth: number,
|
|
419
|
+
fromOrigin = 0,
|
|
420
|
+
toOrigin = 0,
|
|
421
|
+
): number {
|
|
422
|
+
const spatial = textSpatialPhase(fromAnimation, fromWidth, fromTick);
|
|
423
|
+
return textTickForSpatialPhase(
|
|
424
|
+
toAnimation,
|
|
425
|
+
toWidth,
|
|
426
|
+
spatial
|
|
427
|
+
? { ...spatial, position: spatial.position + fromOrigin - toOrigin }
|
|
428
|
+
: undefined,
|
|
429
|
+
);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function styleForTier(
|
|
433
|
+
colors: PolishedTuiColors,
|
|
434
|
+
tier: Tier,
|
|
435
|
+
): ColorSpec | undefined {
|
|
436
|
+
switch (tier) {
|
|
437
|
+
case "low":
|
|
438
|
+
return colors.workingLineLow;
|
|
439
|
+
case "mid":
|
|
440
|
+
return colors.workingLineMid;
|
|
441
|
+
case "high":
|
|
442
|
+
return colors.workingLineHigh;
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
/** Normalize only Working-line palette specs before they are repeated across generated frames. */
|
|
447
|
+
export function normalizeWorkingLineStyleSpec(
|
|
448
|
+
value: ColorSpec | undefined,
|
|
449
|
+
): ColorSpec | undefined {
|
|
450
|
+
if (value === undefined) return undefined;
|
|
451
|
+
const tokens: string[] = [];
|
|
452
|
+
const seen = new Set<string>();
|
|
453
|
+
let codeUnits = 0;
|
|
454
|
+
for (const match of value.matchAll(/\S+/g)) {
|
|
455
|
+
const token = match[0];
|
|
456
|
+
if (!isSupportedColorSpec(token)) return undefined;
|
|
457
|
+
const lower = token.toLowerCase();
|
|
458
|
+
const equivalent = lower === "dimmed" ? "dim" : lower;
|
|
459
|
+
if (seen.has(equivalent)) continue;
|
|
460
|
+
const separator = tokens.length > 0 ? 1 : 0;
|
|
461
|
+
if (
|
|
462
|
+
tokens.length === MAX_WORKING_LINE_STYLE_TOKENS ||
|
|
463
|
+
codeUnits + separator + token.length > MAX_WORKING_LINE_STYLE_CODE_UNITS
|
|
464
|
+
) {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
seen.add(equivalent);
|
|
468
|
+
tokens.push(equivalent === "dim" ? "dim" : token);
|
|
469
|
+
codeUnits += separator + token.length;
|
|
470
|
+
}
|
|
471
|
+
return tokens.join(" ");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderTier(
|
|
475
|
+
theme: ThemeLike,
|
|
476
|
+
config: WorkingLineComponentConfig,
|
|
477
|
+
colors: PolishedTuiColors,
|
|
478
|
+
tier: Tier,
|
|
479
|
+
text: string,
|
|
480
|
+
): string {
|
|
481
|
+
return renderStyleForSourceOrFallback(
|
|
482
|
+
theme,
|
|
483
|
+
config.colorSource,
|
|
484
|
+
normalizeWorkingLineStyleSpec(styleForTier(colors, tier)),
|
|
485
|
+
WORKING_LINE_FALLBACKS[tier],
|
|
486
|
+
text,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/** Resolve the fixed, nonanimated high-tier style used by persisted Turn summaries. */
|
|
491
|
+
export function renderWorkingLineHigh(
|
|
492
|
+
theme: ThemeLike,
|
|
493
|
+
colorSource: WorkingLineComponentConfig["colorSource"],
|
|
494
|
+
style: ColorSpec | undefined,
|
|
495
|
+
text: string,
|
|
496
|
+
): string {
|
|
497
|
+
return renderStyleForSourceOrFallback(
|
|
498
|
+
theme,
|
|
499
|
+
colorSource,
|
|
500
|
+
normalizeWorkingLineStyleSpec(style),
|
|
501
|
+
WORKING_LINE_FALLBACKS.high,
|
|
502
|
+
text,
|
|
503
|
+
);
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
export function snapshotWorkingLineHighStyle(
|
|
507
|
+
theme: ThemeLike,
|
|
508
|
+
config: WorkingLineComponentConfig,
|
|
509
|
+
colors: PolishedTuiColors,
|
|
510
|
+
): string {
|
|
511
|
+
const sentinel = "\u{f0000}";
|
|
512
|
+
const rendered = renderWorkingLineHigh(
|
|
513
|
+
theme,
|
|
514
|
+
config.colorSource,
|
|
515
|
+
colors.workingLineHigh,
|
|
516
|
+
sentinel,
|
|
517
|
+
);
|
|
518
|
+
const position = rendered.indexOf(sentinel);
|
|
519
|
+
const prefix = position >= 0 ? rendered.slice(0, position) : "";
|
|
520
|
+
return isSafeSgrStylePrefix(prefix) ? prefix : "\x1b[1;36m";
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function renderAnimatedText(
|
|
524
|
+
theme: ThemeLike,
|
|
525
|
+
config: WorkingLineComponentConfig,
|
|
526
|
+
colors: PolishedTuiColors,
|
|
527
|
+
cells: GraphemeCell[],
|
|
528
|
+
width: number,
|
|
529
|
+
tick: number,
|
|
530
|
+
animation: WorkingLineTextAnimation,
|
|
531
|
+
): string {
|
|
532
|
+
const runs: Array<{ tier: Tier; text: string }> = [];
|
|
533
|
+
for (const cell of cells) {
|
|
534
|
+
const tier =
|
|
535
|
+
animation === "disabled"
|
|
536
|
+
? "mid"
|
|
537
|
+
: animation === "classic"
|
|
538
|
+
? classicTier(cell, tick)
|
|
539
|
+
: kittTier(cell, tick, width);
|
|
540
|
+
const previous = runs.at(-1);
|
|
541
|
+
if (previous?.tier === tier) previous.text += cell.text;
|
|
542
|
+
else runs.push({ tier, text: cell.text });
|
|
543
|
+
}
|
|
544
|
+
return runs
|
|
545
|
+
.map((run) => renderTier(theme, config, colors, run.tier, run.text))
|
|
546
|
+
.join("");
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
export type WorkingLineRuntimeSegments = {
|
|
550
|
+
tool?: string;
|
|
551
|
+
elapsedMs?: number;
|
|
552
|
+
thought?: { durationMs: number; active: boolean };
|
|
553
|
+
tokens?: { input: number; output: number; outputApproximate?: boolean };
|
|
554
|
+
};
|
|
555
|
+
|
|
556
|
+
export type WorkingLineFrameState = {
|
|
557
|
+
spinnerTick: number;
|
|
558
|
+
textTick: number;
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
export type WorkingLineSchedulerMetadata = {
|
|
562
|
+
quantumMs: number;
|
|
563
|
+
effectiveSpinnerIntervalMs: number;
|
|
564
|
+
effectiveTextIntervalMs?: number;
|
|
565
|
+
exact: boolean;
|
|
566
|
+
spinnerSeamless: boolean;
|
|
567
|
+
textSeamless: boolean;
|
|
568
|
+
spinnerAdvances: number;
|
|
569
|
+
textAdvances: number;
|
|
570
|
+
};
|
|
571
|
+
|
|
572
|
+
export type WorkingLineFrames = {
|
|
573
|
+
frames: string[];
|
|
574
|
+
frameStates: WorkingLineFrameState[];
|
|
575
|
+
intervalMs: number;
|
|
576
|
+
message: string;
|
|
577
|
+
row: string;
|
|
578
|
+
textAnimation: WorkingLineTextAnimation;
|
|
579
|
+
textWidth: number;
|
|
580
|
+
textOrigin: number;
|
|
581
|
+
scheduler: WorkingLineSchedulerMetadata;
|
|
582
|
+
};
|
|
583
|
+
|
|
584
|
+
export function formatWorkingLineElapsed(durationMs: number): string {
|
|
585
|
+
const totalSeconds = Math.max(0, Math.floor(durationMs / 1000));
|
|
586
|
+
const hours = Math.floor(totalSeconds / 3600);
|
|
587
|
+
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
|
588
|
+
const seconds = totalSeconds % 60;
|
|
589
|
+
if (hours > 0)
|
|
590
|
+
return `${hours}h${String(minutes).padStart(2, "0")}m${String(seconds).padStart(2, "0")}s`;
|
|
591
|
+
if (minutes > 0) return `${minutes}m${String(seconds).padStart(2, "0")}s`;
|
|
592
|
+
return `${seconds}s`;
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
export function formatWorkingLineThought(
|
|
596
|
+
thought: NonNullable<WorkingLineRuntimeSegments["thought"]>,
|
|
597
|
+
): string | undefined {
|
|
598
|
+
if (!thought.active && thought.durationMs <= 0) return undefined;
|
|
599
|
+
return `${thought.active ? "thinking" : "thought for"} ${formatWorkingLineElapsed(thought.durationMs)}`;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
export function normalizeWorkingLineToolLabel(value: unknown): string {
|
|
603
|
+
const normalized = normalizeWorkingLineMessage(value);
|
|
604
|
+
if (visibleWidth(normalized) <= MAX_WORKING_LINE_TOOL_CELLS)
|
|
605
|
+
return normalized;
|
|
606
|
+
const prefix = truncateGraphemes(normalized, MAX_WORKING_LINE_TOOL_CELLS - 1);
|
|
607
|
+
return prefix ? `${prefix}…` : "";
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
export function formatWorkingLineTokens(
|
|
611
|
+
tokens: WorkingLineRuntimeSegments["tokens"],
|
|
612
|
+
): string | undefined {
|
|
613
|
+
if (
|
|
614
|
+
!tokens ||
|
|
615
|
+
!Number.isSafeInteger(tokens.input) ||
|
|
616
|
+
!Number.isSafeInteger(tokens.output) ||
|
|
617
|
+
tokens.input < 0 ||
|
|
618
|
+
tokens.output < 0 ||
|
|
619
|
+
(tokens.outputApproximate !== undefined &&
|
|
620
|
+
typeof tokens.outputApproximate !== "boolean")
|
|
621
|
+
) {
|
|
622
|
+
return undefined;
|
|
623
|
+
}
|
|
624
|
+
return `↑${formatCount(tokens.input)} ↓${formatCount(tokens.output)}`;
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function truncateWithEllipsis(value: string, capacity: number): string {
|
|
628
|
+
if (visibleWidth(value) <= capacity) return value;
|
|
629
|
+
if (capacity <= 0) return "";
|
|
630
|
+
if (capacity === 1) return "…";
|
|
631
|
+
const prefix = truncateGraphemes(value, capacity - 1);
|
|
632
|
+
return prefix ? `${prefix}…` : "";
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
export type ComposedWorkingLine = { message: string; row: string };
|
|
636
|
+
|
|
637
|
+
/** Validate and measure the fixed visible width shared by every frame in a preset. */
|
|
638
|
+
export function workingLineSpinnerWidth(
|
|
639
|
+
spinnerId: WorkingLineComponentConfig["spinner"],
|
|
640
|
+
): number {
|
|
641
|
+
const frames: readonly string[] = WORKING_LINE_SPINNERS[spinnerId].frames;
|
|
642
|
+
const width = visibleWidth(frames[0] ?? "");
|
|
643
|
+
if (width <= 0 || frames.some((frame) => visibleWidth(frame) !== width)) {
|
|
644
|
+
throw new Error(
|
|
645
|
+
"Working-line spinner frames must share a positive fixed width",
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
return width;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
/** Compose visual Message · Tool · Elapsed · Thought · Tokens order with tokens reserved first. */
|
|
652
|
+
export function composeWorkingLineRow(
|
|
653
|
+
config: WorkingLineComponentConfig,
|
|
654
|
+
message: string,
|
|
655
|
+
runtime: WorkingLineRuntimeSegments = {},
|
|
656
|
+
): ComposedWorkingLine {
|
|
657
|
+
const normalized =
|
|
658
|
+
normalizeWorkingLineMessage(message) || WORKING_LINE_FALLBACK_MESSAGE;
|
|
659
|
+
const maximumRowCells =
|
|
660
|
+
MAX_WORKING_LINE_FRAME_CELLS -
|
|
661
|
+
workingLineSpinnerWidth(config.spinner) -
|
|
662
|
+
visibleWidth(" ");
|
|
663
|
+
const delimiter = " · ";
|
|
664
|
+
const tokens = config.segments.tokens
|
|
665
|
+
? formatWorkingLineTokens(runtime.tokens)
|
|
666
|
+
: undefined;
|
|
667
|
+
const mandatoryWidth = tokens
|
|
668
|
+
? visibleWidth(delimiter) + visibleWidth(tokens)
|
|
669
|
+
: 0;
|
|
670
|
+
const messageCapacity = maximumRowCells - mandatoryWidth;
|
|
671
|
+
const fittedMessage = truncateWithEllipsis(normalized, messageCapacity);
|
|
672
|
+
const segments: string[] = [fittedMessage];
|
|
673
|
+
let remaining =
|
|
674
|
+
maximumRowCells - visibleWidth(fittedMessage) - mandatoryWidth;
|
|
675
|
+
const elapsed =
|
|
676
|
+
config.segments.elapsed && runtime.elapsedMs !== undefined
|
|
677
|
+
? formatWorkingLineElapsed(runtime.elapsedMs)
|
|
678
|
+
: undefined;
|
|
679
|
+
const thought =
|
|
680
|
+
config.segments.thought && runtime.thought
|
|
681
|
+
? formatWorkingLineThought(runtime.thought)
|
|
682
|
+
: undefined;
|
|
683
|
+
const tool =
|
|
684
|
+
config.segments.tool && runtime.tool
|
|
685
|
+
? normalizeWorkingLineToolLabel(runtime.tool)
|
|
686
|
+
: undefined;
|
|
687
|
+
const accepted = new Set<"thought" | "elapsed" | "tool">();
|
|
688
|
+
for (const [key, label] of [
|
|
689
|
+
["thought", thought],
|
|
690
|
+
["elapsed", elapsed],
|
|
691
|
+
] as const) {
|
|
692
|
+
if (label && remaining >= visibleWidth(delimiter) + visibleWidth(label)) {
|
|
693
|
+
remaining -= visibleWidth(delimiter) + visibleWidth(label);
|
|
694
|
+
accepted.add(key);
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
let fittedTool = "";
|
|
698
|
+
if (tool && remaining > visibleWidth(delimiter)) {
|
|
699
|
+
fittedTool = truncateWithEllipsis(
|
|
700
|
+
tool,
|
|
701
|
+
remaining - visibleWidth(delimiter),
|
|
702
|
+
);
|
|
703
|
+
if (fittedTool) accepted.add("tool");
|
|
704
|
+
}
|
|
705
|
+
if (accepted.has("tool")) segments.push(fittedTool);
|
|
706
|
+
if (accepted.has("elapsed") && elapsed) segments.push(elapsed);
|
|
707
|
+
if (accepted.has("thought") && thought) segments.push(thought);
|
|
708
|
+
if (tokens) segments.push(tokens);
|
|
709
|
+
return { message: normalized, row: segments.filter(Boolean).join(delimiter) };
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
type ScheduleDefinition = {
|
|
713
|
+
frameCount: number;
|
|
714
|
+
stateAt: (index: number) => WorkingLineFrameState;
|
|
715
|
+
metadata: WorkingLineSchedulerMetadata;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
function exactSchedule(
|
|
719
|
+
spinnerIntervalMs: number,
|
|
720
|
+
textIntervalMs: number,
|
|
721
|
+
spinnerCycle: number,
|
|
722
|
+
textCycle: number,
|
|
723
|
+
): ScheduleDefinition {
|
|
724
|
+
const quantumMs = Math.max(30, gcd(spinnerIntervalMs, textIntervalMs));
|
|
725
|
+
const spinnerPeriod = schedulePeriod(
|
|
726
|
+
spinnerIntervalMs,
|
|
727
|
+
spinnerCycle,
|
|
728
|
+
quantumMs,
|
|
729
|
+
);
|
|
730
|
+
const textSchedulePeriod = schedulePeriod(
|
|
731
|
+
textIntervalMs,
|
|
732
|
+
textCycle,
|
|
733
|
+
quantumMs,
|
|
734
|
+
);
|
|
735
|
+
const frameCount = lcm(spinnerPeriod, textSchedulePeriod);
|
|
736
|
+
return {
|
|
737
|
+
frameCount,
|
|
738
|
+
stateAt: (index) => ({
|
|
739
|
+
spinnerTick: Math.floor((index * quantumMs) / spinnerIntervalMs),
|
|
740
|
+
textTick: Math.floor((index * quantumMs) / textIntervalMs),
|
|
741
|
+
}),
|
|
742
|
+
metadata: {
|
|
743
|
+
quantumMs,
|
|
744
|
+
effectiveSpinnerIntervalMs: spinnerIntervalMs,
|
|
745
|
+
effectiveTextIntervalMs: textIntervalMs,
|
|
746
|
+
exact: true,
|
|
747
|
+
spinnerSeamless: true,
|
|
748
|
+
textSeamless: true,
|
|
749
|
+
spinnerAdvances: (frameCount * quantumMs) / spinnerIntervalMs,
|
|
750
|
+
textAdvances: (frameCount * quantumMs) / textIntervalMs,
|
|
751
|
+
},
|
|
752
|
+
};
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function fallbackSchedule(
|
|
756
|
+
frameCount: number,
|
|
757
|
+
quantumMs: number,
|
|
758
|
+
spinnerIntervalMs: number,
|
|
759
|
+
textIntervalMs: number,
|
|
760
|
+
spinnerCycle: number,
|
|
761
|
+
textCycle: number,
|
|
762
|
+
): ScheduleDefinition {
|
|
763
|
+
const idealSpinnerAdvances = (frameCount * quantumMs) / spinnerIntervalMs;
|
|
764
|
+
const spinnerAdvances = Math.max(
|
|
765
|
+
spinnerCycle,
|
|
766
|
+
Math.round(idealSpinnerAdvances / spinnerCycle) * spinnerCycle,
|
|
767
|
+
);
|
|
768
|
+
const textAdvances = Math.max(
|
|
769
|
+
1,
|
|
770
|
+
Math.round((frameCount * quantumMs) / textIntervalMs),
|
|
771
|
+
);
|
|
772
|
+
return {
|
|
773
|
+
frameCount,
|
|
774
|
+
stateAt: (index) => ({
|
|
775
|
+
spinnerTick: Math.floor((index * spinnerAdvances) / frameCount),
|
|
776
|
+
textTick: Math.floor((index * textAdvances) / frameCount),
|
|
777
|
+
}),
|
|
778
|
+
metadata: {
|
|
779
|
+
quantumMs,
|
|
780
|
+
effectiveSpinnerIntervalMs: (frameCount * quantumMs) / spinnerAdvances,
|
|
781
|
+
effectiveTextIntervalMs: (frameCount * quantumMs) / textAdvances,
|
|
782
|
+
exact: false,
|
|
783
|
+
spinnerSeamless: spinnerAdvances % spinnerCycle === 0,
|
|
784
|
+
textSeamless: textAdvances % textCycle === 0,
|
|
785
|
+
spinnerAdvances,
|
|
786
|
+
textAdvances,
|
|
787
|
+
},
|
|
788
|
+
};
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function bestFallbackFrameCount(
|
|
792
|
+
maximum: number,
|
|
793
|
+
quantumMs: number,
|
|
794
|
+
spinnerIntervalMs: number,
|
|
795
|
+
spinnerCycle: number,
|
|
796
|
+
): number {
|
|
797
|
+
let best = 1;
|
|
798
|
+
let bestError = Number.POSITIVE_INFINITY;
|
|
799
|
+
for (let count = 1; count <= maximum; count += 1) {
|
|
800
|
+
const advances = Math.max(
|
|
801
|
+
spinnerCycle,
|
|
802
|
+
Math.round((count * quantumMs) / spinnerIntervalMs / spinnerCycle) *
|
|
803
|
+
spinnerCycle,
|
|
804
|
+
);
|
|
805
|
+
const effective = (count * quantumMs) / advances;
|
|
806
|
+
const error = Math.abs(effective - spinnerIntervalMs);
|
|
807
|
+
if (error < bestError || (error === bestError && count > best)) {
|
|
808
|
+
best = count;
|
|
809
|
+
bestError = error;
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
return best;
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function renderWorkingLineSchedule(
|
|
816
|
+
definition: ScheduleDefinition,
|
|
817
|
+
config: WorkingLineComponentConfig,
|
|
818
|
+
colors: PolishedTuiColors,
|
|
819
|
+
theme: ThemeLike,
|
|
820
|
+
row: string,
|
|
821
|
+
width: number,
|
|
822
|
+
textCycle: number,
|
|
823
|
+
spinnerStartTick: number,
|
|
824
|
+
textStartTick: number,
|
|
825
|
+
scheduleStartFrame: number,
|
|
826
|
+
): { frames: string[]; frameStates: WorkingLineFrameState[] } | undefined {
|
|
827
|
+
const spinner = WORKING_LINE_SPINNERS[config.spinner];
|
|
828
|
+
const frames: string[] = [];
|
|
829
|
+
const frameStates: WorkingLineFrameState[] = [];
|
|
830
|
+
let codeUnits = 0;
|
|
831
|
+
const scheduleOrigin = definition.stateAt(scheduleStartFrame);
|
|
832
|
+
for (let index = 0; index < definition.frameCount; index += 1) {
|
|
833
|
+
const scheduled = definition.stateAt(scheduleStartFrame + index);
|
|
834
|
+
const state = {
|
|
835
|
+
spinnerTick:
|
|
836
|
+
spinnerStartTick + scheduled.spinnerTick - scheduleOrigin.spinnerTick,
|
|
837
|
+
textTick: normalizedPhaseTick(
|
|
838
|
+
textStartTick + scheduled.textTick - scheduleOrigin.textTick,
|
|
839
|
+
textCycle,
|
|
840
|
+
),
|
|
841
|
+
};
|
|
842
|
+
const spinnerGlyph =
|
|
843
|
+
spinner.frames[state.spinnerTick % spinner.frames.length] ??
|
|
844
|
+
spinner.frames[0];
|
|
845
|
+
const frame = config.animateSpinnerColor
|
|
846
|
+
? `${renderAnimatedText(
|
|
847
|
+
theme,
|
|
848
|
+
config,
|
|
849
|
+
colors,
|
|
850
|
+
graphemeCells(`${spinnerGlyph} ${row}`).cells,
|
|
851
|
+
width,
|
|
852
|
+
state.textTick,
|
|
853
|
+
config.textAnimation,
|
|
854
|
+
)}${SGR_RESET}`
|
|
855
|
+
: `${renderTier(theme, config, colors, "high", spinnerGlyph)} ${renderAnimatedText(
|
|
856
|
+
theme,
|
|
857
|
+
config,
|
|
858
|
+
colors,
|
|
859
|
+
graphemeCells(row).cells,
|
|
860
|
+
width,
|
|
861
|
+
state.textTick,
|
|
862
|
+
config.textAnimation,
|
|
863
|
+
)}${SGR_RESET}`;
|
|
864
|
+
codeUnits += frame.length;
|
|
865
|
+
if (codeUnits > MAX_WORKING_LINE_FRAME_CODE_UNITS) return undefined;
|
|
866
|
+
frames.push(frame);
|
|
867
|
+
frameStates.push(state);
|
|
868
|
+
}
|
|
869
|
+
return { frames, frameStates };
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
export function buildWorkingLineFrames(
|
|
873
|
+
config: WorkingLineComponentConfig,
|
|
874
|
+
colors: PolishedTuiColors,
|
|
875
|
+
theme: ThemeLike,
|
|
876
|
+
message: string,
|
|
877
|
+
runtime: WorkingLineRuntimeSegments = {},
|
|
878
|
+
spinnerStartTick = 0,
|
|
879
|
+
textStartTick = spinnerStartTick,
|
|
880
|
+
scheduleStartFrame = 0,
|
|
881
|
+
): WorkingLineFrames {
|
|
882
|
+
const composed = composeWorkingLineRow(config, message, runtime);
|
|
883
|
+
const spinner = WORKING_LINE_SPINNERS[config.spinner];
|
|
884
|
+
const spinnerWidth = workingLineSpinnerWidth(config.spinner);
|
|
885
|
+
const { width: rowWidth } = graphemeCells(composed.row);
|
|
886
|
+
const frameWidth = spinnerWidth + visibleWidth(" ") + rowWidth;
|
|
887
|
+
if (frameWidth > MAX_WORKING_LINE_FRAME_CELLS) {
|
|
888
|
+
throw new Error("Working-line row exceeds its visible-width cap");
|
|
889
|
+
}
|
|
890
|
+
const spinnerPhase = Number.isFinite(spinnerStartTick)
|
|
891
|
+
? Math.max(0, Math.floor(spinnerStartTick))
|
|
892
|
+
: 0;
|
|
893
|
+
const animatedTextWidth = config.animateSpinnerColor ? frameWidth : rowWidth;
|
|
894
|
+
const textOrigin = config.animateSpinnerColor
|
|
895
|
+
? 0
|
|
896
|
+
: spinnerWidth + visibleWidth(" ");
|
|
897
|
+
const textCycle = textPeriod(config.textAnimation, animatedTextWidth);
|
|
898
|
+
const textPhase = normalizedPhaseTick(textStartTick, textCycle);
|
|
899
|
+
|
|
900
|
+
if (config.textAnimation === "disabled") {
|
|
901
|
+
const frameStates = Array.from(
|
|
902
|
+
{ length: spinner.frames.length },
|
|
903
|
+
(_, index) => ({
|
|
904
|
+
spinnerTick: spinnerPhase + index,
|
|
905
|
+
textTick: 0,
|
|
906
|
+
}),
|
|
907
|
+
);
|
|
908
|
+
let codeUnits = 0;
|
|
909
|
+
const frames = frameStates.map((state) => {
|
|
910
|
+
const glyph =
|
|
911
|
+
spinner.frames[state.spinnerTick % spinner.frames.length] ??
|
|
912
|
+
spinner.frames[0];
|
|
913
|
+
const frame = `${renderAnimatedText(
|
|
914
|
+
theme,
|
|
915
|
+
config,
|
|
916
|
+
colors,
|
|
917
|
+
graphemeCells(`${glyph} ${composed.row}`).cells,
|
|
918
|
+
frameWidth,
|
|
919
|
+
0,
|
|
920
|
+
"disabled",
|
|
921
|
+
)}${SGR_RESET}`;
|
|
922
|
+
codeUnits += frame.length;
|
|
923
|
+
if (codeUnits > MAX_WORKING_LINE_FRAME_CODE_UNITS)
|
|
924
|
+
throw new Error("Working-line animation exceeds its memory cap");
|
|
925
|
+
return frame;
|
|
926
|
+
});
|
|
927
|
+
return {
|
|
928
|
+
frames,
|
|
929
|
+
frameStates,
|
|
930
|
+
intervalMs: config.spinnerIntervalMs,
|
|
931
|
+
message: composed.message,
|
|
932
|
+
row: composed.row,
|
|
933
|
+
textAnimation: config.textAnimation,
|
|
934
|
+
textWidth: frameWidth,
|
|
935
|
+
textOrigin: 0,
|
|
936
|
+
scheduler: {
|
|
937
|
+
quantumMs: config.spinnerIntervalMs,
|
|
938
|
+
effectiveSpinnerIntervalMs: config.spinnerIntervalMs,
|
|
939
|
+
exact: true,
|
|
940
|
+
spinnerSeamless: true,
|
|
941
|
+
textSeamless: true,
|
|
942
|
+
spinnerAdvances: spinner.frames.length,
|
|
943
|
+
textAdvances: 0,
|
|
944
|
+
},
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
const exact = exactSchedule(
|
|
949
|
+
config.spinnerIntervalMs,
|
|
950
|
+
config.textIntervalMs,
|
|
951
|
+
spinner.frames.length,
|
|
952
|
+
textCycle,
|
|
953
|
+
);
|
|
954
|
+
if (exact.frameCount <= MAX_WORKING_LINE_FRAMES) {
|
|
955
|
+
const rendered = renderWorkingLineSchedule(
|
|
956
|
+
exact,
|
|
957
|
+
config,
|
|
958
|
+
colors,
|
|
959
|
+
theme,
|
|
960
|
+
composed.row,
|
|
961
|
+
animatedTextWidth,
|
|
962
|
+
textCycle,
|
|
963
|
+
spinnerPhase,
|
|
964
|
+
textPhase,
|
|
965
|
+
scheduleStartFrame,
|
|
966
|
+
);
|
|
967
|
+
if (rendered) {
|
|
968
|
+
return {
|
|
969
|
+
...rendered,
|
|
970
|
+
intervalMs: exact.metadata.quantumMs,
|
|
971
|
+
message: composed.message,
|
|
972
|
+
row: composed.row,
|
|
973
|
+
textAnimation: config.textAnimation,
|
|
974
|
+
textWidth: animatedTextWidth,
|
|
975
|
+
textOrigin,
|
|
976
|
+
scheduler: exact.metadata,
|
|
977
|
+
};
|
|
978
|
+
}
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
const quantumMs = exact.metadata.quantumMs;
|
|
982
|
+
let maximum = MAX_WORKING_LINE_FRAMES;
|
|
983
|
+
while (maximum > 0) {
|
|
984
|
+
const frameCount = bestFallbackFrameCount(
|
|
985
|
+
maximum,
|
|
986
|
+
quantumMs,
|
|
987
|
+
config.spinnerIntervalMs,
|
|
988
|
+
spinner.frames.length,
|
|
989
|
+
);
|
|
990
|
+
const fallback = fallbackSchedule(
|
|
991
|
+
frameCount,
|
|
992
|
+
quantumMs,
|
|
993
|
+
config.spinnerIntervalMs,
|
|
994
|
+
config.textIntervalMs,
|
|
995
|
+
spinner.frames.length,
|
|
996
|
+
textCycle,
|
|
997
|
+
);
|
|
998
|
+
const rendered = renderWorkingLineSchedule(
|
|
999
|
+
fallback,
|
|
1000
|
+
config,
|
|
1001
|
+
colors,
|
|
1002
|
+
theme,
|
|
1003
|
+
composed.row,
|
|
1004
|
+
animatedTextWidth,
|
|
1005
|
+
textCycle,
|
|
1006
|
+
spinnerPhase,
|
|
1007
|
+
textPhase,
|
|
1008
|
+
scheduleStartFrame,
|
|
1009
|
+
);
|
|
1010
|
+
if (rendered) {
|
|
1011
|
+
return {
|
|
1012
|
+
...rendered,
|
|
1013
|
+
intervalMs: quantumMs,
|
|
1014
|
+
message: composed.message,
|
|
1015
|
+
row: composed.row,
|
|
1016
|
+
textAnimation: config.textAnimation,
|
|
1017
|
+
textWidth: animatedTextWidth,
|
|
1018
|
+
textOrigin,
|
|
1019
|
+
scheduler: fallback.metadata,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
maximum = frameCount - 1;
|
|
1023
|
+
}
|
|
1024
|
+
throw new Error("Working-line animation exceeds its memory cap");
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
export function buildWorkingLineSpinnerFrames(
|
|
1028
|
+
config: WorkingLineComponentConfig,
|
|
1029
|
+
colors: PolishedTuiColors,
|
|
1030
|
+
theme: ThemeLike,
|
|
1031
|
+
startTick = 0,
|
|
1032
|
+
): {
|
|
1033
|
+
frames: string[];
|
|
1034
|
+
frameStates: WorkingLineFrameState[];
|
|
1035
|
+
intervalMs: number;
|
|
1036
|
+
} {
|
|
1037
|
+
const spinner = WORKING_LINE_SPINNERS[config.spinner];
|
|
1038
|
+
workingLineSpinnerWidth(config.spinner);
|
|
1039
|
+
const phase = Number.isFinite(startTick)
|
|
1040
|
+
? Math.max(0, Math.floor(startTick))
|
|
1041
|
+
: 0;
|
|
1042
|
+
const frameStates = Array.from(
|
|
1043
|
+
{ length: spinner.frames.length },
|
|
1044
|
+
(_, index) => ({
|
|
1045
|
+
spinnerTick: phase + index,
|
|
1046
|
+
textTick: 0,
|
|
1047
|
+
}),
|
|
1048
|
+
);
|
|
1049
|
+
const frames = frameStates.map((state) => {
|
|
1050
|
+
const glyph =
|
|
1051
|
+
spinner.frames[state.spinnerTick % spinner.frames.length] ??
|
|
1052
|
+
spinner.frames[0];
|
|
1053
|
+
return `${renderTier(theme, config, colors, "high", glyph)}${SGR_RESET}`;
|
|
1054
|
+
});
|
|
1055
|
+
return { frames, frameStates, intervalMs: config.spinnerIntervalMs };
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
export function buildWorkingLinePreviewFrames(
|
|
1059
|
+
config: WorkingLineComponentConfig,
|
|
1060
|
+
colors: PolishedTuiColors,
|
|
1061
|
+
theme: ThemeLike,
|
|
1062
|
+
spinnerStartTick = 0,
|
|
1063
|
+
textStartTick = spinnerStartTick,
|
|
1064
|
+
): WorkingLineFrames {
|
|
1065
|
+
const message =
|
|
1066
|
+
effectiveWorkingLineMessages(config)[0] ?? WORKING_LINE_FALLBACK_MESSAGE;
|
|
1067
|
+
return buildWorkingLineFrames(
|
|
1068
|
+
config,
|
|
1069
|
+
colors,
|
|
1070
|
+
theme,
|
|
1071
|
+
message,
|
|
1072
|
+
{
|
|
1073
|
+
tool: "read",
|
|
1074
|
+
elapsedMs: 62_000,
|
|
1075
|
+
thought: { durationMs: 10_000, active: true },
|
|
1076
|
+
tokens: { input: 1234, output: 56 },
|
|
1077
|
+
},
|
|
1078
|
+
spinnerStartTick,
|
|
1079
|
+
textStartTick,
|
|
1080
|
+
);
|
|
1081
|
+
}
|
|
1082
|
+
|
|
1083
|
+
function workingLineUi(ctx: WorkingLineContext): WorkingLineUi | undefined {
|
|
1084
|
+
if (ctx.hasUI === false || (ctx.mode !== undefined && ctx.mode !== "tui"))
|
|
1085
|
+
return undefined;
|
|
1086
|
+
const ui = ctx.ui as unknown as Partial<WorkingLineUi>;
|
|
1087
|
+
if (
|
|
1088
|
+
typeof ui.setWorkingMessage !== "function" ||
|
|
1089
|
+
typeof ui.setWorkingIndicator !== "function"
|
|
1090
|
+
) {
|
|
1091
|
+
return undefined;
|
|
1092
|
+
}
|
|
1093
|
+
return ui as WorkingLineUi;
|
|
1094
|
+
}
|
|
1095
|
+
|
|
1096
|
+
export type WorkingLineReconcileResult = { applied: boolean; reason?: string };
|
|
1097
|
+
|
|
1098
|
+
type InstalledAnimationPhase = {
|
|
1099
|
+
/** Logical beginning of the dwell for frameStates[0]. */
|
|
1100
|
+
frameEpochMs: number;
|
|
1101
|
+
/** Scheduler frame represented by frameStates[0], retained across array rebuilds. */
|
|
1102
|
+
scheduleFrame: number;
|
|
1103
|
+
intervalMs: number;
|
|
1104
|
+
frameStates: WorkingLineFrameState[];
|
|
1105
|
+
textAnimation: WorkingLineTextAnimation;
|
|
1106
|
+
textWidth: number;
|
|
1107
|
+
textOrigin: number;
|
|
1108
|
+
};
|
|
1109
|
+
|
|
1110
|
+
type WorkingIndicatorOptions = { frames?: string[]; intervalMs?: number };
|
|
1111
|
+
|
|
1112
|
+
type InstallationSnapshot = {
|
|
1113
|
+
installed: boolean;
|
|
1114
|
+
ownsIndicator: boolean;
|
|
1115
|
+
ownsMessage: boolean;
|
|
1116
|
+
selectedMessage: string | undefined;
|
|
1117
|
+
frameKey: string | undefined;
|
|
1118
|
+
installedPhase: InstalledAnimationPhase | undefined;
|
|
1119
|
+
indicatorOptions: WorkingIndicatorOptions | undefined;
|
|
1120
|
+
elapsedUpdatesActive: boolean;
|
|
1121
|
+
elapsedUpdatesContext: WorkingLineContext | undefined;
|
|
1122
|
+
elapsedUpdatesGeneration: number;
|
|
1123
|
+
stopElapsedUpdates: (() => void) | undefined;
|
|
1124
|
+
};
|
|
1125
|
+
|
|
1126
|
+
/** Owns Pi's public, unkeyed working-row message and indicator configuration. */
|
|
1127
|
+
export class WorkingLineController {
|
|
1128
|
+
private installed = false;
|
|
1129
|
+
private ownsIndicator = false;
|
|
1130
|
+
private ownsMessage = false;
|
|
1131
|
+
private agentActive = false;
|
|
1132
|
+
private selectedMessage: string | undefined;
|
|
1133
|
+
private frameKey: string | undefined;
|
|
1134
|
+
private installedPhase: InstalledAnimationPhase | undefined;
|
|
1135
|
+
private installedIndicatorOptions: WorkingIndicatorOptions | undefined;
|
|
1136
|
+
private tokens: WorkingLineRuntimeSegments["tokens"];
|
|
1137
|
+
private thought: WorkingLineRuntimeSegments["thought"];
|
|
1138
|
+
private readonly activeTools = new Map<string, string>();
|
|
1139
|
+
private elapsedUpdatesActive = false;
|
|
1140
|
+
private elapsedUpdatesContext: WorkingLineContext | undefined;
|
|
1141
|
+
private elapsedUpdatesGeneration = 0;
|
|
1142
|
+
private stopElapsedUpdates: (() => void) | undefined;
|
|
1143
|
+
|
|
1144
|
+
constructor(
|
|
1145
|
+
private readonly getConfig: () => ZentuiConfig,
|
|
1146
|
+
private readonly getTheme: () => ThemeLike,
|
|
1147
|
+
private readonly durationClock: AgentDurationClock = new AgentDurationClock(),
|
|
1148
|
+
private readonly random: () => number = Math.random,
|
|
1149
|
+
private readonly now: () => number = Date.now,
|
|
1150
|
+
private readonly getThought: () => WorkingLineRuntimeSegments["thought"] = () =>
|
|
1151
|
+
this.thought,
|
|
1152
|
+
) {}
|
|
1153
|
+
|
|
1154
|
+
startSession(ctx: WorkingLineContext): WorkingLineReconcileResult {
|
|
1155
|
+
this.clearRuntime();
|
|
1156
|
+
this.selectedMessage = undefined;
|
|
1157
|
+
this.installedPhase = undefined;
|
|
1158
|
+
if (!this.getConfig().components.workingLine.enabled)
|
|
1159
|
+
return { applied: true };
|
|
1160
|
+
return this.install(ctx);
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
startAgent(ctx: WorkingLineContext): void {
|
|
1164
|
+
this.agentActive = true;
|
|
1165
|
+
this.activeTools.clear();
|
|
1166
|
+
if (this.getConfig().components.workingLine.enabled) {
|
|
1167
|
+
// Pi emits extension handlers before constructing its Loader. Force and rebase the
|
|
1168
|
+
// stored indicator so the subsequently constructed Loader begins at intended frame zero.
|
|
1169
|
+
this.install(ctx, true, true);
|
|
1170
|
+
}
|
|
1171
|
+
this.reconcileElapsedUpdates(ctx);
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
startTurn(ctx: WorkingLineContext): WorkingLineReconcileResult {
|
|
1175
|
+
const config = this.getConfig().components.workingLine;
|
|
1176
|
+
this.activeTools.clear();
|
|
1177
|
+
const selectedMessage = selectWorkingLineMessage(config, this.random);
|
|
1178
|
+
if (!config.enabled) {
|
|
1179
|
+
this.selectedMessage = selectedMessage;
|
|
1180
|
+
return { applied: true };
|
|
1181
|
+
}
|
|
1182
|
+
return this.install(ctx, false, false, selectedMessage);
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
updateMetrics(
|
|
1186
|
+
tokens: WorkingLineRuntimeSegments["tokens"],
|
|
1187
|
+
thought: WorkingLineRuntimeSegments["thought"],
|
|
1188
|
+
ctx: WorkingLineContext,
|
|
1189
|
+
): void {
|
|
1190
|
+
this.tokens = tokens;
|
|
1191
|
+
this.thought = thought;
|
|
1192
|
+
this.updateIndicator(ctx);
|
|
1193
|
+
this.reconcileElapsedUpdates(ctx);
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
updateTokens(
|
|
1197
|
+
tokens: WorkingLineRuntimeSegments["tokens"],
|
|
1198
|
+
ctx: WorkingLineContext,
|
|
1199
|
+
): void {
|
|
1200
|
+
this.updateMetrics(tokens, this.getThought(), ctx);
|
|
1201
|
+
}
|
|
1202
|
+
|
|
1203
|
+
startTool(
|
|
1204
|
+
toolCallId: string,
|
|
1205
|
+
toolName: string,
|
|
1206
|
+
ctx: WorkingLineContext,
|
|
1207
|
+
): void {
|
|
1208
|
+
this.activeTools.delete(toolCallId);
|
|
1209
|
+
this.activeTools.set(toolCallId, normalizeWorkingLineToolLabel(toolName));
|
|
1210
|
+
this.updateIndicator(ctx);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
finishTool(toolCallId: string, ctx: WorkingLineContext): void {
|
|
1214
|
+
this.activeTools.delete(toolCallId);
|
|
1215
|
+
this.updateIndicator(ctx);
|
|
1216
|
+
}
|
|
1217
|
+
|
|
1218
|
+
finishAgent(ctx: WorkingLineContext): void {
|
|
1219
|
+
this.agentActive = false;
|
|
1220
|
+
this.activeTools.clear();
|
|
1221
|
+
this.deactivateElapsedUpdates();
|
|
1222
|
+
this.updateIndicator(ctx);
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
settle(
|
|
1226
|
+
tokens: WorkingLineRuntimeSegments["tokens"],
|
|
1227
|
+
thought: WorkingLineRuntimeSegments["thought"],
|
|
1228
|
+
ctx: WorkingLineContext,
|
|
1229
|
+
): void {
|
|
1230
|
+
this.tokens = tokens;
|
|
1231
|
+
this.thought = thought;
|
|
1232
|
+
this.updateIndicator(ctx);
|
|
1233
|
+
this.reconcileElapsedUpdates(ctx);
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
reconcile(ctx: WorkingLineContext): WorkingLineReconcileResult {
|
|
1237
|
+
const config = this.getConfig().components.workingLine;
|
|
1238
|
+
if (!config.enabled) {
|
|
1239
|
+
this.reset(ctx);
|
|
1240
|
+
return { applied: true };
|
|
1241
|
+
}
|
|
1242
|
+
const effective = effectiveWorkingLineMessages(config);
|
|
1243
|
+
const selectedMessage =
|
|
1244
|
+
this.selectedMessage && effective.includes(this.selectedMessage)
|
|
1245
|
+
? this.selectedMessage
|
|
1246
|
+
: (effective[0] ?? WORKING_LINE_FALLBACK_MESSAGE);
|
|
1247
|
+
return this.install(ctx, false, false, selectedMessage);
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
dispose(ctx: WorkingLineContext): void {
|
|
1251
|
+
this.reset(ctx);
|
|
1252
|
+
this.clearRuntime();
|
|
1253
|
+
this.selectedMessage = undefined;
|
|
1254
|
+
this.installedPhase = undefined;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
currentMessage(): string | undefined {
|
|
1258
|
+
return this.selectedMessage;
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
private install(
|
|
1262
|
+
ctx: WorkingLineContext,
|
|
1263
|
+
forceIndicator = false,
|
|
1264
|
+
rebasePhase = false,
|
|
1265
|
+
selectedMessage?: string,
|
|
1266
|
+
): WorkingLineReconcileResult {
|
|
1267
|
+
const ui = workingLineUi(ctx);
|
|
1268
|
+
if (!ui) {
|
|
1269
|
+
this.installed = false;
|
|
1270
|
+
this.deactivateElapsedUpdates();
|
|
1271
|
+
return { applied: false, reason: "Working line requires a newer Pi TUI" };
|
|
1272
|
+
}
|
|
1273
|
+
const rootConfig = this.getConfig();
|
|
1274
|
+
const config = rootConfig.components.workingLine;
|
|
1275
|
+
const nextMessage =
|
|
1276
|
+
selectedMessage ??
|
|
1277
|
+
this.selectedMessage ??
|
|
1278
|
+
effectiveWorkingLineMessages(config)[0];
|
|
1279
|
+
const snapshot = this.installationSnapshot();
|
|
1280
|
+
try {
|
|
1281
|
+
if (!this.ownsMessage) {
|
|
1282
|
+
this.ownsMessage = true;
|
|
1283
|
+
ui.setWorkingMessage("");
|
|
1284
|
+
}
|
|
1285
|
+
this.applyIndicator(
|
|
1286
|
+
ui,
|
|
1287
|
+
rootConfig,
|
|
1288
|
+
nextMessage,
|
|
1289
|
+
forceIndicator,
|
|
1290
|
+
rebasePhase,
|
|
1291
|
+
);
|
|
1292
|
+
this.selectedMessage = nextMessage;
|
|
1293
|
+
this.reconcileElapsedUpdates(ctx);
|
|
1294
|
+
return { applied: true };
|
|
1295
|
+
} catch {
|
|
1296
|
+
this.recoverOrReleaseAfterFailure(ui, snapshot);
|
|
1297
|
+
return { applied: false, reason: "Pi could not apply the Working line" };
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
|
|
1301
|
+
private makeFrameKey(
|
|
1302
|
+
rootConfig: ZentuiConfig,
|
|
1303
|
+
selectedMessage: string | undefined,
|
|
1304
|
+
): string {
|
|
1305
|
+
const config = rootConfig.components.workingLine;
|
|
1306
|
+
const { row } = composeWorkingLineRow(
|
|
1307
|
+
config,
|
|
1308
|
+
selectedMessage ?? WORKING_LINE_FALLBACK_MESSAGE,
|
|
1309
|
+
this.runtimeSegments(),
|
|
1310
|
+
);
|
|
1311
|
+
return JSON.stringify([
|
|
1312
|
+
"owned",
|
|
1313
|
+
config.spinner,
|
|
1314
|
+
config.spinnerIntervalMs,
|
|
1315
|
+
...(config.textAnimation === "disabled"
|
|
1316
|
+
? [
|
|
1317
|
+
config.textAnimation,
|
|
1318
|
+
config.colorSource,
|
|
1319
|
+
rootConfig.colors.workingLineMid,
|
|
1320
|
+
]
|
|
1321
|
+
: [
|
|
1322
|
+
config.textIntervalMs,
|
|
1323
|
+
config.textAnimation,
|
|
1324
|
+
config.animateSpinnerColor,
|
|
1325
|
+
config.colorSource,
|
|
1326
|
+
rootConfig.colors.workingLineLow,
|
|
1327
|
+
rootConfig.colors.workingLineMid,
|
|
1328
|
+
rootConfig.colors.workingLineHigh,
|
|
1329
|
+
]),
|
|
1330
|
+
row,
|
|
1331
|
+
]);
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
private runtimeSegments(): WorkingLineRuntimeSegments {
|
|
1335
|
+
const thought = this.getThought() ?? this.thought;
|
|
1336
|
+
return {
|
|
1337
|
+
tool: [...this.activeTools.values()].at(-1),
|
|
1338
|
+
elapsedMs: this.agentActive ? this.durationClock.elapsedMs() : undefined,
|
|
1339
|
+
thought,
|
|
1340
|
+
tokens: this.tokens,
|
|
1341
|
+
};
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
private applyIndicator(
|
|
1345
|
+
ui: WorkingLineUi,
|
|
1346
|
+
rootConfig: ZentuiConfig,
|
|
1347
|
+
selectedMessage = this.selectedMessage,
|
|
1348
|
+
force = false,
|
|
1349
|
+
rebase = false,
|
|
1350
|
+
): void {
|
|
1351
|
+
const key = this.makeFrameKey(rootConfig, selectedMessage);
|
|
1352
|
+
if (!force && this.installed && this.frameKey === key) return;
|
|
1353
|
+
const config = rootConfig.components.workingLine;
|
|
1354
|
+
const sampledAtMs = this.now();
|
|
1355
|
+
let spinnerTick = 0;
|
|
1356
|
+
let spatial: TextSpatialPhase | undefined;
|
|
1357
|
+
let scheduleStartFrame = 0;
|
|
1358
|
+
let intervalFraction = 0;
|
|
1359
|
+
if (
|
|
1360
|
+
!rebase &&
|
|
1361
|
+
this.installedPhase &&
|
|
1362
|
+
this.installedPhase.frameStates.length > 0
|
|
1363
|
+
) {
|
|
1364
|
+
const elapsedMs = Math.max(
|
|
1365
|
+
0,
|
|
1366
|
+
sampledAtMs - this.installedPhase.frameEpochMs,
|
|
1367
|
+
);
|
|
1368
|
+
const elapsedFrames = Math.floor(
|
|
1369
|
+
elapsedMs / this.installedPhase.intervalMs,
|
|
1370
|
+
);
|
|
1371
|
+
scheduleStartFrame = this.installedPhase.scheduleFrame + elapsedFrames;
|
|
1372
|
+
const remainderMs = elapsedMs % this.installedPhase.intervalMs;
|
|
1373
|
+
intervalFraction = remainderMs / this.installedPhase.intervalMs;
|
|
1374
|
+
const sampled =
|
|
1375
|
+
this.installedPhase.frameStates[
|
|
1376
|
+
elapsedFrames % this.installedPhase.frameStates.length
|
|
1377
|
+
] ?? this.installedPhase.frameStates[0];
|
|
1378
|
+
spinnerTick = sampled?.spinnerTick ?? 0;
|
|
1379
|
+
spatial = textSpatialPhase(
|
|
1380
|
+
this.installedPhase.textAnimation,
|
|
1381
|
+
this.installedPhase.textWidth,
|
|
1382
|
+
sampled?.textTick ?? 0,
|
|
1383
|
+
);
|
|
1384
|
+
}
|
|
1385
|
+
const message = selectedMessage ?? WORKING_LINE_FALLBACK_MESSAGE;
|
|
1386
|
+
const runtime = this.runtimeSegments();
|
|
1387
|
+
const composed = composeWorkingLineRow(config, message, runtime);
|
|
1388
|
+
const spinnerWidth = workingLineSpinnerWidth(config.spinner);
|
|
1389
|
+
const textWidth =
|
|
1390
|
+
config.textAnimation !== "disabled" && config.animateSpinnerColor
|
|
1391
|
+
? spinnerWidth + 1 + visibleWidth(composed.row)
|
|
1392
|
+
: config.textAnimation === "disabled"
|
|
1393
|
+
? spinnerWidth + 1 + visibleWidth(composed.row)
|
|
1394
|
+
: visibleWidth(composed.row);
|
|
1395
|
+
const textOrigin =
|
|
1396
|
+
config.textAnimation !== "disabled" && !config.animateSpinnerColor
|
|
1397
|
+
? spinnerWidth + 1
|
|
1398
|
+
: 0;
|
|
1399
|
+
const relativeSpatial = spatial
|
|
1400
|
+
? {
|
|
1401
|
+
...spatial,
|
|
1402
|
+
position:
|
|
1403
|
+
spatial.position +
|
|
1404
|
+
(this.installedPhase?.textOrigin ?? 0) -
|
|
1405
|
+
textOrigin,
|
|
1406
|
+
}
|
|
1407
|
+
: undefined;
|
|
1408
|
+
const textTick = textTickForSpatialPhase(
|
|
1409
|
+
config.textAnimation,
|
|
1410
|
+
textWidth,
|
|
1411
|
+
relativeSpatial,
|
|
1412
|
+
);
|
|
1413
|
+
const generated = buildWorkingLineFrames(
|
|
1414
|
+
config,
|
|
1415
|
+
rootConfig.colors,
|
|
1416
|
+
this.getTheme(),
|
|
1417
|
+
message,
|
|
1418
|
+
runtime,
|
|
1419
|
+
spinnerTick,
|
|
1420
|
+
textTick,
|
|
1421
|
+
scheduleStartFrame,
|
|
1422
|
+
);
|
|
1423
|
+
// Pi's public Loader always gives replacement frame zero a full first interval. Carrying
|
|
1424
|
+
// the logical fractional dwell in this epoch keeps later rebuilds wall-clock-correct; the
|
|
1425
|
+
// visible replacement can still jitter by less than one interval because the API cannot
|
|
1426
|
+
// schedule a fractional first interval.
|
|
1427
|
+
const frameEpochMs = sampledAtMs - intervalFraction * generated.intervalMs;
|
|
1428
|
+
this.ownsIndicator = true;
|
|
1429
|
+
const indicatorOptions = {
|
|
1430
|
+
frames: generated.frames,
|
|
1431
|
+
intervalMs: generated.intervalMs,
|
|
1432
|
+
};
|
|
1433
|
+
ui.setWorkingIndicator(indicatorOptions);
|
|
1434
|
+
this.installedPhase = {
|
|
1435
|
+
frameEpochMs: rebase ? this.now() : frameEpochMs,
|
|
1436
|
+
scheduleFrame: rebase ? 0 : scheduleStartFrame,
|
|
1437
|
+
intervalMs: generated.intervalMs,
|
|
1438
|
+
frameStates: generated.frameStates,
|
|
1439
|
+
textAnimation: generated.textAnimation,
|
|
1440
|
+
textWidth: generated.textWidth,
|
|
1441
|
+
textOrigin: generated.textOrigin,
|
|
1442
|
+
};
|
|
1443
|
+
this.installed = true;
|
|
1444
|
+
this.frameKey = key;
|
|
1445
|
+
this.installedIndicatorOptions = indicatorOptions;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
private updateIndicator(ctx: WorkingLineContext): void {
|
|
1449
|
+
const rootConfig = this.getConfig();
|
|
1450
|
+
if (!rootConfig.components.workingLine.enabled || !this.installed) return;
|
|
1451
|
+
const ui = workingLineUi(ctx);
|
|
1452
|
+
if (!ui) return;
|
|
1453
|
+
const snapshot = this.installationSnapshot();
|
|
1454
|
+
try {
|
|
1455
|
+
this.applyIndicator(ui, rootConfig);
|
|
1456
|
+
} catch {
|
|
1457
|
+
// A transient last-writer/public-API failure must not break the agent turn.
|
|
1458
|
+
this.recoverOrReleaseAfterFailure(ui, snapshot);
|
|
1459
|
+
}
|
|
1460
|
+
}
|
|
1461
|
+
|
|
1462
|
+
private reconcileElapsedUpdates(ctx: WorkingLineContext): void {
|
|
1463
|
+
this.elapsedUpdatesContext = ctx;
|
|
1464
|
+
const config = this.getConfig().components.workingLine;
|
|
1465
|
+
const thoughtActive = Boolean(this.getThought()?.active);
|
|
1466
|
+
const needed =
|
|
1467
|
+
this.installed &&
|
|
1468
|
+
this.agentActive &&
|
|
1469
|
+
config.enabled &&
|
|
1470
|
+
(config.segments.elapsed || (config.segments.thought && thoughtActive));
|
|
1471
|
+
if (!needed) {
|
|
1472
|
+
this.deactivateElapsedUpdates();
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
if (this.elapsedUpdatesActive) return;
|
|
1476
|
+
this.elapsedUpdatesActive = true;
|
|
1477
|
+
const generation = ++this.elapsedUpdatesGeneration;
|
|
1478
|
+
this.stopElapsedUpdates = this.durationClock.subscribe(() => {
|
|
1479
|
+
if (
|
|
1480
|
+
!this.elapsedUpdatesActive ||
|
|
1481
|
+
generation !== this.elapsedUpdatesGeneration
|
|
1482
|
+
)
|
|
1483
|
+
return;
|
|
1484
|
+
const latest = this.elapsedUpdatesContext;
|
|
1485
|
+
if (latest) this.updateIndicator(latest);
|
|
1486
|
+
});
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1489
|
+
private deactivateElapsedUpdates(): void {
|
|
1490
|
+
this.elapsedUpdatesActive = false;
|
|
1491
|
+
this.elapsedUpdatesContext = undefined;
|
|
1492
|
+
this.elapsedUpdatesGeneration++;
|
|
1493
|
+
this.stopElapsedUpdates?.();
|
|
1494
|
+
this.stopElapsedUpdates = undefined;
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1497
|
+
private installationSnapshot(): InstallationSnapshot {
|
|
1498
|
+
return {
|
|
1499
|
+
installed: this.installed,
|
|
1500
|
+
ownsIndicator: this.ownsIndicator,
|
|
1501
|
+
ownsMessage: this.ownsMessage,
|
|
1502
|
+
selectedMessage: this.selectedMessage,
|
|
1503
|
+
frameKey: this.frameKey,
|
|
1504
|
+
installedPhase: this.installedPhase,
|
|
1505
|
+
indicatorOptions: this.installedIndicatorOptions,
|
|
1506
|
+
elapsedUpdatesActive: this.elapsedUpdatesActive,
|
|
1507
|
+
elapsedUpdatesContext: this.elapsedUpdatesContext,
|
|
1508
|
+
elapsedUpdatesGeneration: this.elapsedUpdatesGeneration,
|
|
1509
|
+
stopElapsedUpdates: this.stopElapsedUpdates,
|
|
1510
|
+
};
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
private recoverOrReleaseAfterFailure(
|
|
1514
|
+
ui: WorkingLineUi,
|
|
1515
|
+
snapshot: InstallationSnapshot,
|
|
1516
|
+
): void {
|
|
1517
|
+
if (
|
|
1518
|
+
!snapshot.installed ||
|
|
1519
|
+
!snapshot.ownsIndicator ||
|
|
1520
|
+
!snapshot.indicatorOptions
|
|
1521
|
+
) {
|
|
1522
|
+
this.releaseAfterFailure(ui);
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
this.installed = snapshot.installed;
|
|
1526
|
+
this.ownsIndicator = snapshot.ownsIndicator;
|
|
1527
|
+
this.ownsMessage = snapshot.ownsMessage;
|
|
1528
|
+
this.selectedMessage = snapshot.selectedMessage;
|
|
1529
|
+
this.frameKey = snapshot.frameKey;
|
|
1530
|
+
this.installedPhase = snapshot.installedPhase;
|
|
1531
|
+
this.installedIndicatorOptions = snapshot.indicatorOptions;
|
|
1532
|
+
this.elapsedUpdatesActive = snapshot.elapsedUpdatesActive;
|
|
1533
|
+
this.elapsedUpdatesContext = snapshot.elapsedUpdatesContext;
|
|
1534
|
+
this.elapsedUpdatesGeneration = snapshot.elapsedUpdatesGeneration;
|
|
1535
|
+
this.stopElapsedUpdates = snapshot.stopElapsedUpdates;
|
|
1536
|
+
try {
|
|
1537
|
+
ui.setWorkingIndicator(snapshot.indicatorOptions);
|
|
1538
|
+
if (snapshot.ownsMessage) ui.setWorkingMessage("");
|
|
1539
|
+
} catch {
|
|
1540
|
+
// Recovery is deliberately direct rather than recursive. If either setter cannot
|
|
1541
|
+
// restore the last successful public state, release both unkeyed surfaces.
|
|
1542
|
+
this.releaseAfterFailure(ui);
|
|
1543
|
+
}
|
|
1544
|
+
}
|
|
1545
|
+
|
|
1546
|
+
private releaseAfterFailure(ui: WorkingLineUi): void {
|
|
1547
|
+
this.deactivateElapsedUpdates();
|
|
1548
|
+
if (this.ownsIndicator) {
|
|
1549
|
+
try {
|
|
1550
|
+
ui.setWorkingIndicator();
|
|
1551
|
+
} catch {
|
|
1552
|
+
// Best-effort release after a partial public-API installation failure.
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
if (this.ownsMessage) {
|
|
1556
|
+
try {
|
|
1557
|
+
ui.setWorkingMessage();
|
|
1558
|
+
} catch {
|
|
1559
|
+
// Best-effort release after a partial public-API installation failure.
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
this.installed = false;
|
|
1563
|
+
this.ownsIndicator = false;
|
|
1564
|
+
this.ownsMessage = false;
|
|
1565
|
+
this.frameKey = undefined;
|
|
1566
|
+
this.installedPhase = undefined;
|
|
1567
|
+
this.installedIndicatorOptions = undefined;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
private reset(ctx: WorkingLineContext): void {
|
|
1571
|
+
this.deactivateElapsedUpdates();
|
|
1572
|
+
if (!this.ownsIndicator && !this.ownsMessage) return;
|
|
1573
|
+
const ui = workingLineUi(ctx);
|
|
1574
|
+
if (ui && this.ownsIndicator) {
|
|
1575
|
+
try {
|
|
1576
|
+
ui.setWorkingIndicator();
|
|
1577
|
+
} catch {
|
|
1578
|
+
// Cleanup is best effort and remains idempotent.
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (ui && this.ownsMessage) {
|
|
1582
|
+
try {
|
|
1583
|
+
ui.setWorkingMessage();
|
|
1584
|
+
} catch {
|
|
1585
|
+
// Cleanup is best effort and remains idempotent.
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
this.installed = false;
|
|
1589
|
+
this.ownsIndicator = false;
|
|
1590
|
+
this.ownsMessage = false;
|
|
1591
|
+
this.frameKey = undefined;
|
|
1592
|
+
this.installedPhase = undefined;
|
|
1593
|
+
this.installedIndicatorOptions = undefined;
|
|
1594
|
+
}
|
|
1595
|
+
|
|
1596
|
+
private clearRuntime(): void {
|
|
1597
|
+
this.deactivateElapsedUpdates();
|
|
1598
|
+
this.agentActive = false;
|
|
1599
|
+
this.activeTools.clear();
|
|
1600
|
+
this.tokens = undefined;
|
|
1601
|
+
this.thought = undefined;
|
|
1602
|
+
}
|
|
1603
|
+
}
|