pi-tui-footer 1.2.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.
@@ -0,0 +1,457 @@
1
+ /**
2
+ * pi-tui-footer — full TUI polish for pi: Pi logo header,
3
+ * Starship-style footer, rounded editor with embedded working status,
4
+ * thinking peek preview, rounded tool frames, and turn telemetry.
5
+ *
6
+ * Integrated from:
7
+ * - pi-open-tui (v0.3.7, MIT, by OldSun, https://github.com/OldSuns/pi-open-tui)
8
+ * - pi-rounded-tools (v0.1.3, MIT, by OrionPax, https://github.com/orionpax1997/pi-rounded-tools)
9
+ *
10
+ * Security audit notes:
11
+ * - zero third-party runtime deps (pi core bundles + node built-ins only)
12
+ * - execFile runs only hardcoded git / runtime version commands (no shell
13
+ * injection surface); fs access limited to ~/.pi/agent/pi-tui.json
14
+ * - no network / eval / obfuscation / credential reads
15
+ */
16
+
17
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
18
+ import { type OpenTuiConfig, DEFAULT_CONFIG, ensureConfigExists, loadConfig, saveConfig } from "./config.ts";
19
+ import { installEditor } from "./editor.ts";
20
+ import { installFooter } from "./footer.ts";
21
+ import { installHeader } from "./header.ts";
22
+ import { emptyGitStatus, readGitStatus } from "./git.ts";
23
+ import { readRuntimeInfo } from "./runtime.ts";
24
+ import { RoundedToolsManager } from "./rounded-tools.ts";
25
+ import { SessionLifecycle } from "./session-lifecycle.ts";
26
+ import { registerSettingsCommand } from "./settings-command.ts";
27
+ import { formatTurnTelemetry, TurnTelemetryTracker } from "./telemetry.ts";
28
+ import {
29
+ createInitialState,
30
+ getModelMeta,
31
+ invalidateUsageCache,
32
+ type FooterState,
33
+ } from "./state.ts";
34
+ import { resolveGlyphs } from "./icons.ts";
35
+ import {
36
+ buildPeekLabel,
37
+ collectPeekParts,
38
+ createPeekState,
39
+ reducePeek,
40
+ type PeekState,
41
+ } from "./peek.ts";
42
+
43
+ type PendingUiChange = "install" | "uninstall";
44
+ const HIDDEN_THINKING_HORIZONTAL_PADDING = 2;
45
+ // Pi's fullscreen transcript can reserve one more column for its scrollbar.
46
+ const HIDDEN_THINKING_SCROLLBAR_RESERVE = 1;
47
+
48
+ export function getPendingUiChange(enabled: boolean, active: boolean): PendingUiChange | undefined {
49
+ if (enabled === active) return undefined;
50
+ return enabled ? "install" : "uninstall";
51
+ }
52
+
53
+ function isTuiContext(ctx: ExtensionContext): boolean {
54
+ try {
55
+ const mode = (ctx as ExtensionContext & { mode?: string }).mode;
56
+ return ctx.hasUI && (mode === undefined || mode === "tui");
57
+ } catch {
58
+ return false;
59
+ }
60
+ }
61
+
62
+ export default function (pi: ExtensionAPI) {
63
+ const sessionLifecycle = new SessionLifecycle();
64
+ const state: FooterState = createInitialState();
65
+ const turnTelemetry = new TurnTelemetryTracker();
66
+ const roundedTools = new RoundedToolsManager(pi);
67
+
68
+ let config: OpenTuiConfig = structuredClone(DEFAULT_CONFIG);
69
+ let active = false;
70
+ let lastCtx: ExtensionContext | undefined;
71
+ let requestFooterRender: (() => void) | undefined;
72
+ let workingTimer: ReturnType<typeof setInterval> | undefined;
73
+ let cleanupHeader: (() => void) | undefined;
74
+ let cleanupFooter: (() => void) | undefined;
75
+ let editor: ReturnType<typeof installEditor> | undefined;
76
+ let pendingUiChange: PendingUiChange | undefined;
77
+
78
+ // thinking-peek state (rendered by Pi inside its hidden-thinking block)
79
+ const peek: PeekState = createPeekState();
80
+ let peekFrame = 0;
81
+ let peekSettleTimer: ReturnType<typeof setTimeout> | undefined;
82
+ let peekTaskEpoch = 0; // bumped at every agent task boundary
83
+ let peekLabelActive = false;
84
+
85
+ const getThinkingLevel = () => (sessionLifecycle.isCurrent() ? pi.getThinkingLevel() : "off");
86
+
87
+ /** True while the agent is running (false during session-restore replay). */
88
+ const isAgentIdle = (ctx: ExtensionContext): boolean => {
89
+ try {
90
+ return (ctx as ExtensionContext & { isIdle?: () => boolean }).isIdle?.() === true;
91
+ } catch {
92
+ return false;
93
+ }
94
+ };
95
+
96
+ /** Pi decides whether this label is visible; our toggle only controls updates. */
97
+ const isPeekEnabled = () => sessionLifecycle.isCurrent() && config.enabled && config.thinkingPeek.lines > 0 && active;
98
+
99
+ /** Keep each native hidden-thinking label row within Pi's narrowest transcript width. */
100
+ const hiddenThinkingLabelWidth = (): number => {
101
+ if (!editor) throw new Error("Open TUI editor is not installed");
102
+ return Math.max(
103
+ 1,
104
+ editor.getViewportWidth() - HIDDEN_THINKING_HORIZONTAL_PADDING - HIDDEN_THINKING_SCROLLBAR_RESERVE,
105
+ );
106
+ };
107
+
108
+ const setPeekLabel = (ctx?: ExtensionContext): void => {
109
+ if (!isPeekEnabled() || peek.phase === "idle") return;
110
+ const target = ctx ?? lastCtx;
111
+ if (!target || !isTuiContext(target)) return;
112
+ if (!editor) throw new Error("Open TUI editor is not installed");
113
+ editor.setLatestHiddenThinkingLabel(
114
+ buildPeekLabel(
115
+ peek,
116
+ peekFrame,
117
+ resolveGlyphs(config.icons.mode),
118
+ hiddenThinkingLabelWidth(),
119
+ config.thinkingPeek.lines,
120
+ ),
121
+ );
122
+ peekLabelActive = true;
123
+ };
124
+
125
+ const clearPeekLabel = (ctx?: ExtensionContext): void => {
126
+ if (!peekLabelActive) return;
127
+ const target = ctx ?? lastCtx;
128
+ if (!target || !isTuiContext(target)) return;
129
+ // The global reset is safe here: every completed message returns to Pi's native label.
130
+ target.ui.setHiddenThinkingLabel();
131
+ peekLabelActive = false;
132
+ };
133
+
134
+ /** Reset peek state to idle and cancel any scheduled cleanup. */
135
+ const resetPeek = () => {
136
+ peek.phase = "idle";
137
+ peek.tail = "";
138
+ editor?.resetHiddenThinkingLabelTarget();
139
+ if (peekSettleTimer) {
140
+ clearTimeout(peekSettleTimer);
141
+ peekSettleTimer = undefined;
142
+ }
143
+ };
144
+
145
+ const applyUi = (ctx: ExtensionContext) => {
146
+ if (!isTuiContext(ctx)) return;
147
+ if (!config.enabled) {
148
+ uninstallUi(ctx);
149
+ return;
150
+ }
151
+ if (!active) {
152
+ cleanupHeader = installHeader(pi, ctx);
153
+ cleanupFooter = installFooter(
154
+ ctx,
155
+ () => state,
156
+ () => config,
157
+ () => getModelMeta(ctx, getThinkingLevel, config.footerSegments.capitalizeProviderName),
158
+ {
159
+ setRequestRender: (fn) => {
160
+ requestFooterRender = fn ?? undefined;
161
+ },
162
+ scheduleGitRefresh: () => {
163
+ void scheduleGitRefresh(ctx);
164
+ },
165
+ },
166
+ );
167
+ editor = installEditor(pi, ctx, config.cursorStyle, config.fullscreen.wheelScrollLines);
168
+ active = true;
169
+ }
170
+ };
171
+
172
+ const uninstallUi = (ctx: ExtensionContext) => {
173
+ if (!isTuiContext(ctx)) return;
174
+ if (active) {
175
+ cleanupHeader?.();
176
+ cleanupFooter?.();
177
+ editor?.cleanup();
178
+ cleanupHeader = undefined;
179
+ cleanupFooter = undefined;
180
+ editor = undefined;
181
+ requestFooterRender = undefined;
182
+ resetPeek();
183
+ clearPeekLabel(ctx);
184
+ active = false;
185
+ }
186
+ };
187
+
188
+ const scheduleGitRefresh = async (ctx: ExtensionContext) => {
189
+ if (!sessionLifecycle.isCurrent()) return;
190
+ const segs = config.footerSegments;
191
+ if (!segs.gitBranch && !segs.gitStatus && !segs.gitCommit) {
192
+ state.git = emptyGitStatus();
193
+ requestFooterRender?.();
194
+ return;
195
+ }
196
+ const generation = sessionLifecycle.currentGeneration();
197
+ const cwd = ctx.cwd;
198
+ const git = await readGitStatus(cwd, {
199
+ readCommit: true,
200
+ readTag: segs.gitCommit,
201
+ readCounts: segs.gitStatus,
202
+ });
203
+ if (!sessionLifecycle.isCurrent(generation)) return;
204
+ state.git = git;
205
+ requestFooterRender?.();
206
+ };
207
+
208
+ const refreshRuntime = async (ctx: ExtensionContext) => {
209
+ if (!sessionLifecycle.isCurrent()) return;
210
+ const generation = sessionLifecycle.currentGeneration();
211
+ const cwd = ctx.cwd;
212
+ const runtime = await readRuntimeInfo(cwd);
213
+ if (!sessionLifecycle.isCurrent(generation)) return;
214
+ state.runtime = runtime;
215
+ requestFooterRender?.();
216
+ };
217
+
218
+ const applyRoundedTools = (ctx: ExtensionContext) => {
219
+ roundedTools.apply(config.enabled && config.roundedTools, ctx.cwd);
220
+ };
221
+
222
+ // Register rounded tool renderers at load time (before session_start) so a
223
+ // /reload transcript rebuild — which pi runs BEFORE emitting session_start —
224
+ // constructs historical tool-call components with the wrapped renderers.
225
+ // session_start re-registers with the real session cwd afterwards, which
226
+ // corrects the execute closure for relative-path tools. Toggling the
227
+ // setting re-registers for new components; already-rendered historical
228
+ // components keep their current frames until the next transcript rebuild
229
+ // (compaction / resume / tree navigation / thinking-block toggle).
230
+ const initialConfig = loadConfig();
231
+ config = initialConfig;
232
+ roundedTools.init(initialConfig.enabled && initialConfig.roundedTools);
233
+
234
+ const refreshInteractiveState = (ctx: ExtensionContext, project = false) => {
235
+ if (!sessionLifecycle.isCurrent() || !ctx.hasUI) return;
236
+ if (project) {
237
+ void scheduleGitRefresh(ctx);
238
+ void refreshRuntime(ctx);
239
+ }
240
+ requestFooterRender?.();
241
+ };
242
+
243
+ const startWorkingTimer = () => {
244
+ stopWorkingTimer();
245
+ const tick = () => {
246
+ if (!sessionLifecycle.isCurrent() || !active) return;
247
+ requestFooterRender?.();
248
+ };
249
+ tick();
250
+ workingTimer = setInterval(tick, 250);
251
+ workingTimer.unref?.();
252
+ };
253
+
254
+ const stopWorkingTimer = () => {
255
+ if (workingTimer) {
256
+ clearInterval(workingTimer);
257
+ workingTimer = undefined;
258
+ }
259
+ };
260
+
261
+ pi.on("session_start", async (_event, ctx) => {
262
+ sessionLifecycle.start();
263
+ lastCtx = ctx;
264
+ state.sessionStartEpoch = Date.now();
265
+ state.workingSince = undefined;
266
+ state.lastDoneIn = undefined;
267
+ invalidateUsageCache();
268
+
269
+ ensureConfigExists();
270
+ config = loadConfig((msg, level) => ctx.ui.notify(msg, level));
271
+ clearPeekLabel(ctx);
272
+
273
+ applyUi(ctx);
274
+ applyRoundedTools(ctx);
275
+
276
+ refreshInteractiveState(ctx, true);
277
+ });
278
+
279
+ pi.on("session_shutdown", async (_event, ctx) => {
280
+ sessionLifecycle.shutdown();
281
+ stopWorkingTimer();
282
+ if (active) {
283
+ uninstallUi(ctx);
284
+ }
285
+ lastCtx = undefined;
286
+ });
287
+
288
+ pi.on("agent_start", (event, _ctx) => {
289
+ turnTelemetry.handle(event);
290
+ if (!sessionLifecycle.isCurrent()) return;
291
+ // agent_start also covers custom-message and continuation-triggered tasks
292
+ // that do not emit a user message_start event.
293
+ peekTaskEpoch++;
294
+ state.workingSince = Date.now();
295
+ state.lastDoneIn = undefined;
296
+ startWorkingTimer();
297
+ });
298
+
299
+ pi.on("agent_end", (_event, _ctx) => {
300
+ if (!sessionLifecycle.isCurrent()) return;
301
+ stopWorkingTimer();
302
+ if (state.workingSince !== undefined) {
303
+ state.lastDoneIn = Date.now() - state.workingSince;
304
+ state.workingSince = undefined;
305
+ }
306
+ requestFooterRender?.();
307
+ });
308
+
309
+ pi.on("turn_start", (event) => {
310
+ turnTelemetry.handle(event);
311
+ });
312
+
313
+ pi.on("message_start", (event, ctx) => {
314
+ turnTelemetry.handle(event);
315
+ if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
316
+ const role = event.message?.role;
317
+ if (role === "user") {
318
+ // Restore Pi's native label until real thinking arrives. The task epoch is
319
+ // advanced by agent_start so custom/no-user tasks are covered too.
320
+ resetPeek();
321
+ clearPeekLabel(ctx);
322
+ } else if (role === "assistant") {
323
+ resetPeek();
324
+ clearPeekLabel(ctx);
325
+ }
326
+ });
327
+
328
+ pi.on("message_update", (event, ctx) => {
329
+ turnTelemetry.handle(event);
330
+ if (!sessionLifecycle.isCurrent() || isAgentIdle(ctx)) return;
331
+ if (!isPeekEnabled()) return;
332
+ const message = event.message;
333
+ if (!message || message.role !== "assistant") return;
334
+ if (peek.phase === "done") return;
335
+ const parts = collectPeekParts(message.content);
336
+ const next = reducePeek(peek, parts);
337
+ const changed = next.phase !== peek.phase || next.tail !== peek.tail;
338
+ peek.phase = next.phase;
339
+ peek.tail = next.tail;
340
+ if (!changed) return;
341
+ if (peek.phase === "thinking") {
342
+ peekFrame++;
343
+ setPeekLabel(ctx);
344
+ } else if (peek.phase === "done") {
345
+ setPeekLabel(ctx);
346
+ }
347
+ });
348
+
349
+ pi.on("tool_execution_start", (event) => {
350
+ turnTelemetry.handle(event);
351
+ });
352
+
353
+ pi.on("turn_end", (event) => {
354
+ turnTelemetry.handle(event);
355
+ });
356
+
357
+ pi.on("agent_settled", (event, ctx) => {
358
+ const telemetry = turnTelemetry.handle(event);
359
+ if (telemetry && config.enabled && config.telemetry.enabled && isTuiContext(ctx)) {
360
+ const message = formatTurnTelemetry(telemetry, ctx.ui.theme, config.telemetry, config.icons.mode);
361
+ if (message) ctx.ui.notify(message, "info");
362
+ }
363
+ // Only clear the peek label when this task is still the current one.
364
+ const settleEpoch = peekTaskEpoch;
365
+ if (peekSettleTimer) clearTimeout(peekSettleTimer);
366
+ peekSettleTimer = setTimeout(() => {
367
+ peekSettleTimer = undefined;
368
+ if (!sessionLifecycle.isCurrent() || peekTaskEpoch !== settleEpoch) return;
369
+ resetPeek();
370
+ clearPeekLabel(ctx);
371
+ }, 300);
372
+ });
373
+
374
+ pi.on("model_select", (_event, ctx) => {
375
+ refreshInteractiveState(ctx);
376
+ });
377
+
378
+ pi.on("thinking_level_select", (_event, ctx) => {
379
+ refreshInteractiveState(ctx);
380
+ });
381
+
382
+ pi.on("message_end", (event, ctx) => {
383
+ turnTelemetry.handle(event);
384
+ if (!sessionLifecycle.isCurrent()) return;
385
+ if (event.message?.role === "assistant" && peek.phase === "thinking") {
386
+ // Sub-message finished (answer text or a tool call): freeze the peek line.
387
+ peek.phase = "done";
388
+ setPeekLabel(ctx);
389
+ }
390
+ invalidateUsageCache();
391
+ refreshInteractiveState(ctx);
392
+ });
393
+
394
+ pi.on("tool_execution_end", (_event, ctx) => {
395
+ refreshInteractiveState(ctx);
396
+ });
397
+
398
+ pi.on("session_compact", (_event, ctx) => {
399
+ if (!sessionLifecycle.isCurrent()) return;
400
+ invalidateUsageCache();
401
+ refreshInteractiveState(ctx);
402
+ });
403
+
404
+ pi.on("session_tree", (_event, ctx) => {
405
+ if (!sessionLifecycle.isCurrent()) return;
406
+ invalidateUsageCache();
407
+ refreshInteractiveState(ctx);
408
+ });
409
+
410
+ registerSettingsCommand(pi, {
411
+ getConfig: () => config,
412
+ onConfigChanged: (newConfig) => {
413
+ const wasEnabled = config.enabled;
414
+ const wasRoundedTools = config.roundedTools;
415
+ const cursorStyleChanged = config.cursorStyle !== newConfig.cursorStyle;
416
+ const wheelScrollLinesChanged = config.fullscreen.wheelScrollLines !== newConfig.fullscreen.wheelScrollLines;
417
+ const thinkingPeekLinesChanged = config.thinkingPeek.lines !== newConfig.thinkingPeek.lines;
418
+ saveConfig(newConfig);
419
+ config = newConfig;
420
+ if (newConfig.thinkingPeek.lines === 0 || !newConfig.enabled) {
421
+ resetPeek();
422
+ clearPeekLabel(lastCtx);
423
+ } else if (thinkingPeekLinesChanged && peekLabelActive) {
424
+ setPeekLabel(lastCtx);
425
+ }
426
+ if (cursorStyleChanged && active && editor) {
427
+ editor.setCursorStyle(newConfig.cursorStyle);
428
+ }
429
+ if (wheelScrollLinesChanged && active && editor) {
430
+ editor.setWheelScrollLines(newConfig.fullscreen.wheelScrollLines);
431
+ }
432
+ if (lastCtx) {
433
+ pendingUiChange = getPendingUiChange(newConfig.enabled, active);
434
+ }
435
+ if (lastCtx && (wasRoundedTools !== newConfig.roundedTools || wasEnabled !== newConfig.enabled)) {
436
+ applyRoundedTools(lastCtx);
437
+ }
438
+ const gitNeeded = newConfig.footerSegments.gitBranch || newConfig.footerSegments.gitStatus || newConfig.footerSegments.gitCommit;
439
+ if (lastCtx && gitNeeded) {
440
+ void scheduleGitRefresh(lastCtx);
441
+ } else {
442
+ state.git = emptyGitStatus();
443
+ }
444
+ requestFooterRender?.();
445
+ },
446
+ onOverlayClosed: () => {
447
+ if (!lastCtx || pendingUiChange === undefined) return;
448
+ const change = pendingUiChange;
449
+ pendingUiChange = undefined;
450
+ if (!config.enabled || change === "uninstall") {
451
+ uninstallUi(lastCtx);
452
+ } else {
453
+ applyUi(lastCtx);
454
+ }
455
+ },
456
+ });
457
+ }
@@ -0,0 +1,168 @@
1
+ import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
2
+ import type { IconGlyphs } from "./icons.ts";
3
+ import { sanitizeStatus } from "./utils.ts";
4
+
5
+ const SPINNER_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
6
+ const MAX_PEEK_LINES = 2;
7
+ const graphemeSegmenter = new Intl.Segmenter(undefined, { granularity: "grapheme" });
8
+
9
+ export type PeekPhase = "idle" | "thinking" | "done";
10
+
11
+ export interface PeekState {
12
+ phase: PeekPhase;
13
+ tail: string;
14
+ }
15
+
16
+ export interface PeekMessageParts {
17
+ thinking: string;
18
+ text: string;
19
+ }
20
+
21
+ function stringValue(value: unknown): string | undefined {
22
+ return typeof value === "string" ? value : undefined;
23
+ }
24
+
25
+ const normalizePeekLineCount = (lineCount: number): number => Math.max(1, Math.min(MAX_PEEK_LINES, Math.floor(lineCount)));
26
+
27
+ /**
28
+ * Pure phase machine for one assistant-message update.
29
+ *
30
+ * Thinking text is cumulative: once the answer starts, later updates still
31
+ * carry the full thinking block alongside new text. To keep the spinner honest:
32
+ * - idle + thinking -> thinking (spinner starts)
33
+ * - idle + thinking + text -> done (already answered, never spin)
34
+ * - thinking + text -> done (answer started: stop spinner)
35
+ * - done + thinking -> done (never restart the spinner)
36
+ * - any + text only -> idle stays idle (plain answer, nothing to peek)
37
+ */
38
+ export function reducePeek(prev: PeekState, parts: PeekMessageParts): PeekState {
39
+ const tail = parts.thinking ? peekTail(parts.thinking, MAX_PEEK_LINES) : prev.tail;
40
+ let phase = prev.phase;
41
+ if (parts.thinking && phase === "idle") phase = "thinking";
42
+ if (parts.text && phase === "thinking") phase = "done";
43
+ return { phase, tail };
44
+ }
45
+
46
+ /** Fresh peek state (no task running yet). */
47
+ export function createPeekState(): PeekState {
48
+ return { phase: "idle", tail: "" };
49
+ }
50
+
51
+ /**
52
+ * Collect the running thinking text and answer text from an assistant
53
+ * message's content parts. Thinking arrives token-by-token, so re-calling
54
+ * this on every `message_update` yields the cumulative text.
55
+ */
56
+ export function collectPeekParts(content: unknown): PeekMessageParts {
57
+ let thinking = "";
58
+ let text = "";
59
+ if (!Array.isArray(content)) return { thinking, text };
60
+ for (const rawPart of content) {
61
+ if (!rawPart || typeof rawPart !== "object") continue;
62
+ const part = rawPart as { type?: unknown; thinking?: unknown; text?: unknown };
63
+ if (part.type === "thinking") {
64
+ thinking += stringValue(part.thinking) ?? stringValue(part.text) ?? "";
65
+ } else if (part.type === "text") {
66
+ text += stringValue(part.text) ?? "";
67
+ }
68
+ }
69
+ return { thinking, text };
70
+ }
71
+
72
+ /**
73
+ * Extract the last non-empty logical lines of the thinking text, whitespace-
74
+ * normalized. NOT width-bounded: clipping to the terminal happens at render
75
+ * time in buildPeekLabel/clipTail, so wide terminals get to see more of it.
76
+ */
77
+ export function peekTail(fullThinking: string, lineCount = 1): string {
78
+ if (!fullThinking) return "";
79
+ const count = normalizePeekLineCount(lineCount);
80
+ const lines: string[] = [];
81
+ let end = fullThinking.length;
82
+ while (end > 0 && lines.length < count) {
83
+ const start = fullThinking.lastIndexOf("\n", end - 1) + 1;
84
+ const line = fullThinking.slice(start, end).replace(/\s+/g, " ").trim();
85
+ if (line) lines.push(line);
86
+ end = start - 1;
87
+ }
88
+ return lines.reverse().join("\n");
89
+ }
90
+
91
+ /**
92
+ * Clip a tail string to `budget` visible columns, keeping the END (it is a
93
+ * tail view) and prefixing "…" when clipped. Walk backwards over complete
94
+ * graphemes so measurement is limited to the visible suffix.
95
+ */
96
+ export function clipTail(text: string, budget: number): string {
97
+ if (!text || budget <= 0) return "";
98
+ const limit = budget - 1; // reserve one column for the ellipsis
99
+ let w = 0;
100
+ let end = text.length;
101
+ let clippedStart = end;
102
+ const segments = graphemeSegmenter.segment(text);
103
+ while (end > 0) {
104
+ const part = segments.containing(end - 1)!;
105
+ w += visibleWidth(part.segment);
106
+ if (w > budget) return "…" + text.slice(clippedStart);
107
+ end = part.index;
108
+ if (w <= limit) clippedStart = end;
109
+ }
110
+ return text;
111
+ }
112
+
113
+ /**
114
+ * Build the label Pi renders in its hidden-thinking block. `width` is a
115
+ * conservative budget that keeps each native Text row within the terminal.
116
+ * In two-line mode, an overflowing latest logical line uses both rows for
117
+ * its continuation instead of retaining the previous logical line.
118
+ */
119
+ export function buildPeekLabel(
120
+ state: PeekState,
121
+ frame: number,
122
+ glyphs: IconGlyphs,
123
+ width: number,
124
+ lineCount = 1,
125
+ ): string {
126
+ const w = Math.max(1, Math.floor(width));
127
+ const prefix = `${glyphs.thinking} think`;
128
+ const count = normalizePeekLineCount(lineCount);
129
+ const fit = (line: string): string => truncateToWidth(line, w, "…");
130
+ switch (state.phase) {
131
+ case "thinking": {
132
+ const spin = SPINNER_FRAMES[frame % SPINNER_FRAMES.length] ?? "";
133
+ // Thinking text is model output (untrusted): strip terminal control
134
+ // sequences before placing it in Pi's native label.
135
+ const marker = `${prefix} ${spin}`;
136
+ const markerWidth = visibleWidth(`${marker} `);
137
+ const contentWidth = Math.max(0, w - markerWidth);
138
+ const thoughtIndent = " ".repeat(markerWidth);
139
+ const safeLines = state.tail
140
+ .split("\n")
141
+ .map((tail) => sanitizeStatus(tail))
142
+ .filter(Boolean)
143
+ .slice(-count);
144
+ const latest = safeLines.at(-1) ?? "";
145
+ const lineWithMarker = (tail: string): string => fit(tail ? `${marker} ${tail}` : marker);
146
+ if (count >= 2) {
147
+ const window = clipTail(latest, contentWidth * count);
148
+ const latestWrapped = wrapTextWithAnsi(window, Math.max(1, contentWidth));
149
+ if (latestWrapped.length > 1) {
150
+ // Word wrapping may add short rows; always retain the newest content.
151
+ const first = latestWrapped.at(-2) ?? "";
152
+ const second = latestWrapped.at(-1) ?? "";
153
+ return `${lineWithMarker(first)}\n${fit(`${thoughtIndent}${second}`)}`;
154
+ }
155
+ }
156
+ const firstTail = clipTail(count >= 2 ? safeLines[0] ?? "" : latest, contentWidth);
157
+ if (count < 2 || safeLines.length < 2) return lineWithMarker(firstTail);
158
+ // Align the latest line with the first line's thinking text, not with
159
+ // the status marker.
160
+ const secondTail = clipTail(latest, contentWidth);
161
+ return `${lineWithMarker(firstTail)}\n${fit(secondTail ? `${thoughtIndent}${secondTail}` : thoughtIndent)}`;
162
+ }
163
+ case "done":
164
+ return fit(`${prefix} ${glyphs.done}`);
165
+ default:
166
+ return fit(`${prefix} ·`);
167
+ }
168
+ }