@luan.sh/pi-libtui 0.3.8

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.
Files changed (157) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +208 -0
  3. package/native-revision +1 -0
  4. package/package.json +58 -0
  5. package/src/activity-presentation.ts +75 -0
  6. package/src/appearance.ts +632 -0
  7. package/src/background-surface.ts +125 -0
  8. package/src/color/palette.ts +186 -0
  9. package/src/color/preview.ts +157 -0
  10. package/src/color/resolver.ts +75 -0
  11. package/src/color/theme.ts +531 -0
  12. package/src/component-stack.ts +247 -0
  13. package/src/content/markdown-code.ts +60 -0
  14. package/src/content/terminal-text.ts +135 -0
  15. package/src/content/text.ts +105 -0
  16. package/src/controls/action-panel.ts +420 -0
  17. package/src/controls/dialog-button-bar.ts +287 -0
  18. package/src/controls/multi-select.ts +467 -0
  19. package/src/controls/picker-panel.ts +447 -0
  20. package/src/controls/screen-icon-actions.ts +134 -0
  21. package/src/controls/searchable-select.ts +394 -0
  22. package/src/controls/select-box.ts +224 -0
  23. package/src/controls/selectable-list.ts +433 -0
  24. package/src/controls/selection-action-bar.ts +382 -0
  25. package/src/controls/semantic-input.ts +23 -0
  26. package/src/controls/tab.ts +189 -0
  27. package/src/cursor.ts +288 -0
  28. package/src/decoration/editor-pills.ts +156 -0
  29. package/src/decoration/glyphs.ts +326 -0
  30. package/src/decoration/pointer-interaction.ts +123 -0
  31. package/src/decoration/powerline-pill.ts +104 -0
  32. package/src/decoration/status.ts +185 -0
  33. package/src/decoration/transient-pill.ts +176 -0
  34. package/src/diff/index.ts +21 -0
  35. package/src/diff/model.ts +76 -0
  36. package/src/diff/parse.ts +310 -0
  37. package/src/diff/pierre-syntax.ts +86 -0
  38. package/src/diff/render.ts +504 -0
  39. package/src/diff/structured.ts +264 -0
  40. package/src/diff/view.ts +75 -0
  41. package/src/editor/chrome.ts +341 -0
  42. package/src/editor/composition.ts +53 -0
  43. package/src/editor/layout.ts +81 -0
  44. package/src/editor/presentation.ts +35 -0
  45. package/src/editor/protocol.ts +188 -0
  46. package/src/editor.ts +41 -0
  47. package/src/extension.ts +30 -0
  48. package/src/folding.ts +192 -0
  49. package/src/host/cursor-bridge.ts +210 -0
  50. package/src/host/editor-bridge.ts +148 -0
  51. package/src/host/extension-host.ts +231 -0
  52. package/src/host/mouse-bridge.ts +212 -0
  53. package/src/host/mouse-dispatch.ts +400 -0
  54. package/src/host/mouse-input.ts +85 -0
  55. package/src/host/mouse-targets.ts +238 -0
  56. package/src/host/pi-layout-adapter.ts +298 -0
  57. package/src/host/pi-native-selection.ts +227 -0
  58. package/src/host/split-pane-bridge.ts +720 -0
  59. package/src/host/transcript-bridge.ts +236 -0
  60. package/src/index.ts +334 -0
  61. package/src/line-layout.ts +6 -0
  62. package/src/motion.ts +1409 -0
  63. package/src/mouse/events.ts +55 -0
  64. package/src/mouse/fullscreen-layout.ts +260 -0
  65. package/src/mouse/registry.ts +348 -0
  66. package/src/mouse.ts +37 -0
  67. package/src/native-binary.ts +189 -0
  68. package/src/overlay/anchored.ts +91 -0
  69. package/src/overlay/detail-card.ts +158 -0
  70. package/src/overlay/dialog.ts +266 -0
  71. package/src/overlay/floating.ts +106 -0
  72. package/src/overlay/framed-editor.ts +236 -0
  73. package/src/overlay/fullscreen.ts +170 -0
  74. package/src/overlay/hover-tooltip.ts +109 -0
  75. package/src/overlay/modal-mount.ts +118 -0
  76. package/src/panels.ts +207 -0
  77. package/src/render-cache.ts +80 -0
  78. package/src/render-epoch.ts +48 -0
  79. package/src/request-animation.ts +160 -0
  80. package/src/scrollbar.ts +27 -0
  81. package/src/selection.ts +148 -0
  82. package/src/split-pane.ts +232 -0
  83. package/src/status-presentation.ts +411 -0
  84. package/src/stream.ts +543 -0
  85. package/src/syntax.ts +316 -0
  86. package/src/terminal/bridge-client.ts +243 -0
  87. package/src/terminal/embedding.ts +6 -0
  88. package/src/terminal/output.ts +151 -0
  89. package/src/terminal/projection.ts +362 -0
  90. package/src/terminal/pty-host.ts +263 -0
  91. package/src/terminal/pty-pane.ts +308 -0
  92. package/src/terminal-colors.ts +334 -0
  93. package/src/tool/action.ts +170 -0
  94. package/src/tool/activity.ts +500 -0
  95. package/src/tool/call-preview.ts +44 -0
  96. package/src/tool/disclosure-action.ts +89 -0
  97. package/src/tool/expanded-region-viewport.ts +103 -0
  98. package/src/tool/index.ts +26 -0
  99. package/src/tool/output.ts +271 -0
  100. package/src/tool/transcript.ts +150 -0
  101. package/src/tool/view-region.ts +581 -0
  102. package/test/appearance.test.ts +183 -0
  103. package/test/bounded-stream.test.ts +150 -0
  104. package/test/bridge-client.test.ts +42 -0
  105. package/test/color-preview.test.ts +23 -0
  106. package/test/component-stack.test.ts +206 -0
  107. package/test/components.test.ts +1165 -0
  108. package/test/content-text.test.ts +21 -0
  109. package/test/cursor-bridge.test.ts +151 -0
  110. package/test/cursor.test.ts +147 -0
  111. package/test/editor-chrome.test.ts +119 -0
  112. package/test/editor-layout.test.ts +30 -0
  113. package/test/editor-registry.test.ts +203 -0
  114. package/test/editor-theme.test.ts +33 -0
  115. package/test/editor-token-pill.test.ts +72 -0
  116. package/test/floating-overlay.test.ts +57 -0
  117. package/test/folding.test.ts +147 -0
  118. package/test/harmonious-theme.test.ts +23 -0
  119. package/test/interaction.test.ts +104 -0
  120. package/test/key-icons.test.ts +62 -0
  121. package/test/markdown-code.test.ts +24 -0
  122. package/test/modal-overlay-mount.test.ts +94 -0
  123. package/test/motion.test.ts +649 -0
  124. package/test/mouse/bridge.test.ts +1302 -0
  125. package/test/mouse/extension.test.ts +205 -0
  126. package/test/mouse/registry.test.ts +140 -0
  127. package/test/mouse/screen-decoration.test.ts +167 -0
  128. package/test/native-binary.test.ts +52 -0
  129. package/test/palette.test.ts +32 -0
  130. package/test/panels.test.ts +74 -0
  131. package/test/pointer-components.test.ts +151 -0
  132. package/test/progress.test.ts +54 -0
  133. package/test/pty-host.test.ts +126 -0
  134. package/test/pty-pane.test.ts +62 -0
  135. package/test/public-surface.test.ts +75 -0
  136. package/test/render-cache.test.ts +25 -0
  137. package/test/render-epoch.test.ts +119 -0
  138. package/test/request-animation.test.ts +193 -0
  139. package/test/screen-icon-actions.test.ts +69 -0
  140. package/test/select-box.test.ts +86 -0
  141. package/test/select-pointer.test.ts +369 -0
  142. package/test/selectable-list.test.ts +258 -0
  143. package/test/selection/registry.test.ts +44 -0
  144. package/test/selection-action-bar.test.ts +204 -0
  145. package/test/split-pane-bridge.test.ts +944 -0
  146. package/test/split-pane.test.ts +134 -0
  147. package/test/status-presentation.test.ts +39 -0
  148. package/test/syntax.test.ts +59 -0
  149. package/test/terminal-colors.test.ts +115 -0
  150. package/test/terminal-embedding.test.ts +12 -0
  151. package/test/terminal-projection.test.ts +312 -0
  152. package/test/theme-colors.test.ts +175 -0
  153. package/test/tool-output.test.ts +160 -0
  154. package/test/tool-transcript.test.ts +1190 -0
  155. package/test/unified-diff.test.ts +691 -0
  156. package/themes/harmonious.json +61 -0
  157. package/tsconfig.json +16 -0
@@ -0,0 +1,334 @@
1
+ import type { TUI } from "@earendil-works/pi-tui";
2
+ import { type RgbColor, rgb, yiqLuminance } from "./color/palette.ts";
3
+ import { bumpTuiRenderEpoch } from "./render-epoch.ts";
4
+
5
+ type TerminalColorScheme = "dark" | "light";
6
+ type IndexedPaletteKind = "generated" | "custom" | "unknown";
7
+
8
+ /** Color facts measured from the active terminal, without theme policy. */
9
+ export interface MeasuredTerminalColors {
10
+ readonly defaultForeground?: RgbColor;
11
+ readonly defaultBackground?: RgbColor;
12
+ readonly ansiBase16?: readonly RgbColor[];
13
+ readonly indexedPalette: IndexedPaletteKind;
14
+ readonly scheme: TerminalColorScheme;
15
+ }
16
+
17
+ export interface TerminalColorsRegistry {
18
+ current(): MeasuredTerminalColors | undefined;
19
+ publish(measurements: MeasuredTerminalColors | undefined): void;
20
+ subscribe(listener: () => void): () => void;
21
+ }
22
+
23
+ const REGISTRY_TAG = "pi-libtui/terminal-colors/v3";
24
+ const REGISTRY_KEY = Symbol.for(REGISTRY_TAG);
25
+ const COLOR_RESPONSE = /^\x1b\](10|4;(\d+));(?:rgb:)?([0-9a-f]+)\/([0-9a-f]+)\/([0-9a-f]+)(?:\x07|\x1b\\)$/iu;
26
+ const COLOR_RESPONSE_START = /^\x1b\](?:10|4;\d+);/u;
27
+ const DA1_RESPONSE = /^\x1b\[\?[0-9;]*c/u;
28
+
29
+ // type-boundary: Symbol.for slots may come from another installed libtui realm; these validators narrow it once.
30
+ type UntrustedRegistryValue = unknown;
31
+
32
+ interface SharedTerminalColorsRegistry extends TerminalColorsRegistry {
33
+ readonly tag: typeof REGISTRY_TAG;
34
+ readonly version: 3;
35
+ }
36
+
37
+ interface TerminalProbeBatch {
38
+ readonly responses: readonly TerminalColorReply[];
39
+ readonly residual: string;
40
+ readonly pending: string;
41
+ readonly sawDa1: boolean;
42
+ readonly handled: boolean;
43
+ }
44
+
45
+ type TerminalColorReply =
46
+ | { name: "foreground" | "color16" | "color231"; color: RgbColor }
47
+ | { name: "palette"; index: number; color: RgbColor };
48
+
49
+ /** Measure terminal defaults and palette anchors while preserving unrelated input. */
50
+ export async function measureTerminalColors(tui: TUI, timeoutMs = 100): Promise<MeasuredTerminalColors> {
51
+ const replies = new Map<string, RgbColor>();
52
+ let pending = "";
53
+ let sawDa1 = false;
54
+ let finish: (() => void) | undefined;
55
+ const completed = new Promise<void>((resolve) => {
56
+ finish = resolve;
57
+ });
58
+ const removeListener = tui.addInputListener((data) => {
59
+ const batch = parseTerminalProbeBatch(`${pending}${data}`);
60
+ pending = batch.pending;
61
+ for (const response of batch.responses) {
62
+ const key = response.name === "palette" ? `palette:${response.index}` : response.name;
63
+ replies.set(key, response.color);
64
+ }
65
+ sawDa1 ||= batch.sawDa1;
66
+ if (replies.size === 19 || sawDa1) finish?.();
67
+ if (!batch.handled) return undefined;
68
+ return batch.residual.length > 0 ? { data: batch.residual } : { consume: true };
69
+ });
70
+ const backgroundPromise = tui.queryTerminalBackgroundColor({ timeoutMs });
71
+ const schemePromise = tui.queryTerminalColorScheme({ timeoutMs });
72
+ const base16Queries = Array.from({ length: 16 }, (_, index) => `\x1b]4;${index};?\x1b\\`).join("");
73
+ tui.terminal.write(`\x1b]10;?\x1b\\${base16Queries}\x1b]4;16;?\x1b\\\x1b]4;231;?\x1b\\\x1b[c`);
74
+ let timer: ReturnType<typeof setTimeout> | undefined;
75
+ await Promise.race([
76
+ completed,
77
+ new Promise<void>((resolve) => {
78
+ timer = setTimeout(resolve, timeoutMs);
79
+ }),
80
+ ]);
81
+ if (timer) clearTimeout(timer);
82
+ if (sawDa1) {
83
+ removeListener();
84
+ } else {
85
+ const quarantine = setTimeout(removeListener, timeoutMs);
86
+ quarantine.unref?.();
87
+ }
88
+ const [backgroundValue, reportedScheme] = await Promise.all([backgroundPromise, schemePromise]);
89
+ const defaultBackground = backgroundValue ? rgb(backgroundValue.r, backgroundValue.g, backgroundValue.b) : undefined;
90
+ const defaultForeground = replies.get("foreground");
91
+ const indexed16 = replies.get("color16");
92
+ const indexed231 = replies.get("color231");
93
+ const base16 = Array.from({ length: 16 }, (_, index) => replies.get(`palette:${index}`));
94
+ const ansiBase16 = base16.every((color): color is RgbColor => color !== undefined)
95
+ ? Object.freeze(base16)
96
+ : undefined;
97
+ const scheme = reportedScheme ?? inferScheme(defaultBackground, defaultForeground);
98
+ const indexedPalette =
99
+ indexed16 === undefined || indexed231 === undefined
100
+ ? "unknown"
101
+ : defaultBackground === undefined || defaultForeground === undefined
102
+ ? "custom"
103
+ : matchesTerminalColor(indexed16, defaultBackground) && matchesTerminalColor(indexed231, defaultForeground)
104
+ ? "generated"
105
+ : "custom";
106
+ return Object.freeze({
107
+ defaultForeground,
108
+ defaultBackground,
109
+ ansiBase16,
110
+ indexedPalette,
111
+ scheme,
112
+ });
113
+ }
114
+
115
+ function parseTerminalColorReply(data: string): TerminalColorReply | undefined {
116
+ const match = COLOR_RESPONSE.exec(data);
117
+ if (!match) return undefined;
118
+ const color = rgb(channel(match[3]!), channel(match[4]!), channel(match[5]!));
119
+ if (match[1] === "10") return { name: "foreground", color };
120
+ const index = Number(match[2]);
121
+ if (index === 16) return { name: "color16", color };
122
+ if (index === 231) return { name: "color231", color };
123
+ return { name: "palette", index, color };
124
+ }
125
+
126
+ /** Return the process-wide measured-color store shared by duplicate package realms. */
127
+ export function terminalColorsRegistry(scope: typeof globalThis = globalThis): TerminalColorsRegistry {
128
+ const slots = scope as Record<PropertyKey, UntrustedRegistryValue>;
129
+ const existing = slots[REGISTRY_KEY];
130
+ if (isRegistry(existing)) return existing;
131
+
132
+ let current: MeasuredTerminalColors | undefined;
133
+ const listeners = new Set<() => void>();
134
+ const registry: SharedTerminalColorsRegistry = {
135
+ tag: REGISTRY_TAG,
136
+ version: 3,
137
+ current: () => current,
138
+ publish(measurements) {
139
+ if (measurements !== undefined && !isMeasurements(measurements)) {
140
+ throw new TypeError("Invalid pi-libtui terminal color measurements");
141
+ }
142
+ const next = measurements ? freezeMeasurements(measurements) : undefined;
143
+ if (sameMeasurements(current, next)) return;
144
+ current = next;
145
+ bumpTuiRenderEpoch();
146
+ for (const listener of [...listeners]) {
147
+ try {
148
+ listener();
149
+ } catch {
150
+ /* Subscribers only request a render. */
151
+ }
152
+ }
153
+ },
154
+ subscribe(listener) {
155
+ listeners.add(listener);
156
+ return () => listeners.delete(listener);
157
+ },
158
+ };
159
+ slots[REGISTRY_KEY] = registry;
160
+ return registry;
161
+ }
162
+
163
+ function sameMeasurements(
164
+ left: MeasuredTerminalColors | undefined,
165
+ right: MeasuredTerminalColors | undefined,
166
+ ): boolean {
167
+ if (left === right) return true;
168
+ if (!left || !right) return false;
169
+ if (
170
+ left.indexedPalette !== right.indexedPalette ||
171
+ left.scheme !== right.scheme ||
172
+ !sameRgb(left.defaultForeground, right.defaultForeground) ||
173
+ !sameRgb(left.defaultBackground, right.defaultBackground)
174
+ )
175
+ return false;
176
+ if (left.ansiBase16?.length !== right.ansiBase16?.length) return false;
177
+ return left.ansiBase16?.every((color, index) => sameRgb(color, right.ansiBase16?.[index])) ?? true;
178
+ }
179
+
180
+ function sameRgb(left: RgbColor | undefined, right: RgbColor | undefined): boolean {
181
+ return (
182
+ left === right ||
183
+ (left !== undefined && right !== undefined && left.r === right.r && left.g === right.g && left.b === right.b)
184
+ );
185
+ }
186
+
187
+ function parseTerminalProbeBatch(data: string): TerminalProbeBatch {
188
+ const responses: TerminalColorReply[] = [];
189
+ let residual = "";
190
+ let pending = "";
191
+ let sawDa1 = false;
192
+ let handled = false;
193
+ let offset = 0;
194
+
195
+ while (offset < data.length) {
196
+ const escapeIndex = data.indexOf("\x1b", offset);
197
+ if (escapeIndex < 0) {
198
+ residual += data.slice(offset);
199
+ break;
200
+ }
201
+ residual += data.slice(offset, escapeIndex);
202
+ const fragment = data.slice(escapeIndex);
203
+ const colorStart = COLOR_RESPONSE_START.exec(fragment);
204
+ if (colorStart) {
205
+ const terminator = colorResponseTerminator(fragment, colorStart[0].length);
206
+ if (terminator === undefined) {
207
+ pending = fragment;
208
+ handled = true;
209
+ break;
210
+ }
211
+ const frame = fragment.slice(0, terminator);
212
+ const response = parseTerminalColorReply(frame);
213
+ if (response) responses.push(response);
214
+ offset = escapeIndex + frame.length;
215
+ handled = true;
216
+ continue;
217
+ }
218
+ if (isPotentialColorResponse(fragment)) {
219
+ pending = fragment;
220
+ handled = true;
221
+ break;
222
+ }
223
+ const da1 = DA1_RESPONSE.exec(fragment);
224
+ if (da1) {
225
+ offset = escapeIndex + da1[0].length;
226
+ sawDa1 = true;
227
+ handled = true;
228
+ continue;
229
+ }
230
+ if (isPotentialDa1Response(fragment)) {
231
+ pending = fragment;
232
+ handled = true;
233
+ break;
234
+ }
235
+ residual += "\x1b";
236
+ offset = escapeIndex + 1;
237
+ }
238
+
239
+ return { responses, residual, pending, sawDa1, handled };
240
+ }
241
+
242
+ function colorResponseTerminator(data: string, from: number): number | undefined {
243
+ for (let index = from; index < data.length; index += 1) {
244
+ if (data[index] === "\x07") return index + 1;
245
+ if (data[index] === "\x1b" && data[index + 1] === "\\") return index + 2;
246
+ }
247
+ return undefined;
248
+ }
249
+
250
+ function isPotentialColorResponse(data: string): boolean {
251
+ if (!data.startsWith("\x1b]")) return false;
252
+ const body = data.slice(2);
253
+ if ("10;".startsWith(body) || "4;".startsWith(body)) return true;
254
+ if (!body.startsWith("4;")) return false;
255
+ return /^4;\d*;?$/u.test(body);
256
+ }
257
+
258
+ function isPotentialDa1Response(data: string): boolean {
259
+ return /^\x1b\[\?[0-9;]*$/u.test(data);
260
+ }
261
+
262
+ function channel(value: string): number {
263
+ if (value.length === 1) return Number.parseInt(value.repeat(2), 16);
264
+ if (value.length === 2) return Number.parseInt(value, 16);
265
+ const maximum = 16 ** value.length - 1;
266
+ return (Number.parseInt(value, 16) * 255) / maximum;
267
+ }
268
+
269
+ function inferScheme(background: RgbColor | undefined, foreground: RgbColor | undefined): TerminalColorScheme {
270
+ if (!background) return "dark";
271
+ const backgroundLuminance = yiqLuminance(background);
272
+ return foreground && backgroundLuminance > yiqLuminance(foreground)
273
+ ? "light"
274
+ : backgroundLuminance > 127
275
+ ? "light"
276
+ : "dark";
277
+ }
278
+
279
+ function isRegistry(value: UntrustedRegistryValue): value is SharedTerminalColorsRegistry {
280
+ if (!value || typeof value !== "object") return false;
281
+ const candidate = value as Partial<SharedTerminalColorsRegistry>;
282
+ return (
283
+ candidate.tag === REGISTRY_TAG &&
284
+ candidate.version === 3 &&
285
+ typeof candidate.current === "function" &&
286
+ typeof candidate.publish === "function" &&
287
+ typeof candidate.subscribe === "function"
288
+ );
289
+ }
290
+
291
+ function isRgbColor(value: UntrustedRegistryValue): value is RgbColor {
292
+ if (!value || typeof value !== "object") return false;
293
+ const color = value as Partial<RgbColor>;
294
+ return [color.r, color.g, color.b].every(
295
+ (channelValue) =>
296
+ typeof channelValue === "number" && Number.isInteger(channelValue) && channelValue >= 0 && channelValue <= 255,
297
+ );
298
+ }
299
+
300
+ function isMeasurements(value: UntrustedRegistryValue): value is MeasuredTerminalColors {
301
+ if (!value || typeof value !== "object") return false;
302
+ const measurements = value as Partial<MeasuredTerminalColors>;
303
+ return (
304
+ (measurements.scheme === "dark" || measurements.scheme === "light") &&
305
+ (measurements.indexedPalette === "generated" ||
306
+ measurements.indexedPalette === "custom" ||
307
+ measurements.indexedPalette === "unknown") &&
308
+ (measurements.defaultForeground === undefined || isRgbColor(measurements.defaultForeground)) &&
309
+ (measurements.defaultBackground === undefined || isRgbColor(measurements.defaultBackground)) &&
310
+ (measurements.ansiBase16 === undefined ||
311
+ (Array.isArray(measurements.ansiBase16) &&
312
+ measurements.ansiBase16.length === 16 &&
313
+ measurements.ansiBase16.every(isRgbColor)))
314
+ );
315
+ }
316
+
317
+ function freezeMeasurements(measurements: MeasuredTerminalColors): MeasuredTerminalColors {
318
+ return Object.freeze({
319
+ ...measurements,
320
+ defaultForeground: measurements.defaultForeground
321
+ ? rgb(measurements.defaultForeground.r, measurements.defaultForeground.g, measurements.defaultForeground.b)
322
+ : undefined,
323
+ defaultBackground: measurements.defaultBackground
324
+ ? rgb(measurements.defaultBackground.r, measurements.defaultBackground.g, measurements.defaultBackground.b)
325
+ : undefined,
326
+ ansiBase16: measurements.ansiBase16
327
+ ? Object.freeze(measurements.ansiBase16.map(({ r, g, b }) => rgb(r, g, b)))
328
+ : undefined,
329
+ });
330
+ }
331
+
332
+ function matchesTerminalColor(left: RgbColor, right: RgbColor): boolean {
333
+ return Math.abs(left.r - right.r) <= 2 && Math.abs(left.g - right.g) <= 2 && Math.abs(left.b - right.b) <= 2;
334
+ }
@@ -0,0 +1,170 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth } from "@earendil-works/pi-tui";
4
+ import { type TuiForegroundColor, tuiTheme } from "../color/theme.ts";
5
+ import { sanitizeTuiFieldPreview } from "../content/terminal-text.ts";
6
+ import { icon } from "../decoration/glyphs.ts";
7
+ import { activityFrame, type ActivityFrame, mountConfiguredAnimation, type MotionMount } from "../motion.ts";
8
+ import { RenderedLinesCache } from "../render-cache.ts";
9
+
10
+ /** Lifecycle state shared by semantic tool transcript rows. */
11
+ export type ToolTranscriptStatus = "queued" | "running" | "succeeded" | "failed" | "warning";
12
+
13
+ /** The single sentence which identifies a tool action. */
14
+ export interface ToolActionView {
15
+ verb: string;
16
+ status: ToolTranscriptStatus;
17
+ detail?: string;
18
+ meta?: readonly string[];
19
+ /** Override the status marker, or hide it for a markerless grammar. */
20
+ marker?: string | false;
21
+ /** Optional identity color for a custom marker, independent of lifecycle status. */
22
+ markerTone?: TuiForegroundColor;
23
+ }
24
+
25
+ export interface ToolActionOptions {
26
+ theme: Theme;
27
+ view: ToolActionView;
28
+ }
29
+
30
+ /** Cached, width-safe action row used by tool calls and results. */
31
+ export class ToolAction implements Component {
32
+ private readonly cache = new RenderedLinesCache();
33
+ private revision = 0;
34
+
35
+ constructor(private readonly options: ToolActionOptions) {}
36
+
37
+ update(view: ToolActionView): void {
38
+ if (this.options.view === view) return;
39
+ this.options.view = view;
40
+ this.revision += 1;
41
+ this.cache.clear();
42
+ }
43
+
44
+ render(width: number): string[] {
45
+ return this.cache.get(width, String(this.revision), () =>
46
+ width <= 0 ? [] : [renderToolAction(this.options.theme, this.options.view, width)],
47
+ );
48
+ }
49
+
50
+ invalidate(): void {
51
+ this.cache.clear();
52
+ }
53
+ }
54
+
55
+ /**
56
+ * Render one semantic action sentence without imposing a particular marker grammar.
57
+ *
58
+ * @param theme Active Pi theme translated through semantic libtui colors.
59
+ * @param view Tool-owned words, marker, state, and essential metadata.
60
+ * @param width Maximum terminal-cell width for the returned row.
61
+ * @returns One ANSI-styled, width-safe transcript row.
62
+ */
63
+ function renderToolAction(theme: Theme, view: ToolActionView, width: number, activity?: ActivityFrame): string {
64
+ const colors = tuiTheme(theme);
65
+ const status = actionStatus(view.status);
66
+ const markerValue = view.marker === false ? "" : actionField(view.marker ?? status.glyph, width);
67
+ const marker = markerValue ? `${colors.fg(view.markerTone ?? status.tone, markerValue)} ` : "";
68
+ const activityIndicator = activity?.marker ? `${activity.marker} ` : "";
69
+ const verb = activity?.text ?? colors.fg("text.secondary", actionField(view.verb, width));
70
+ let line = `${activityIndicator}${marker}${theme.bold(verb)}`;
71
+ if (view.detail)
72
+ line += `${colors.fg("text.muted", " · ")}${colors.fg("text.secondary", actionField(view.detail, width))}`;
73
+ if (view.meta?.length)
74
+ line += `${colors.fg("text.muted", " · ")}${colors.fg("text.muted", view.meta.map((value) => actionField(value, width)).join(" · "))}`;
75
+ return truncateToWidth(line, Math.max(0, width), "…");
76
+ }
77
+
78
+ export interface LiveToolActionOptions extends ToolActionOptions {
79
+ requestRender(): void;
80
+ running?: boolean;
81
+ reducedMotion?: boolean;
82
+ }
83
+
84
+ /** Tool action with a shared-scheduler activity glyph composed before its chosen grammar. */
85
+ export class LiveToolAction implements Component {
86
+ private readonly cache = new RenderedLinesCache();
87
+ private view: ToolActionView;
88
+ private running: boolean;
89
+ private startedAt = performance.now();
90
+ private now = this.startedAt;
91
+ private motion: MotionMount | undefined;
92
+
93
+ constructor(private readonly options: LiveToolActionOptions) {
94
+ this.view = options.view;
95
+ this.running = options.running ?? options.view.status === "running";
96
+ this.syncMotion();
97
+ }
98
+
99
+ update(view: ToolActionView, running = view.status === "running"): void {
100
+ const started = !this.running && running;
101
+ this.view = view;
102
+ this.running = running;
103
+ if (started) this.startedAt = performance.now();
104
+ this.cache.clear();
105
+ this.syncMotion();
106
+ }
107
+
108
+ render(width: number): string[] {
109
+ const verb = actionField(this.view.verb, width);
110
+ const activity = this.running
111
+ ? activityFrame(tuiTheme(this.options.theme), verb, this.now - this.startedAt, {
112
+ textTone: "text.secondary",
113
+ reducedMotion: this.options.reducedMotion,
114
+ })
115
+ : undefined;
116
+ const key = `${activity?.marker ?? ""}\0${activity?.text ?? ""}`;
117
+ return this.cache.get(width, key, () =>
118
+ width <= 0 ? [] : [renderToolAction(this.options.theme, this.view, width, activity)],
119
+ );
120
+ }
121
+
122
+ invalidate(): void {
123
+ this.cache.clear();
124
+ }
125
+
126
+ dispose(): void {
127
+ this.motion?.dispose();
128
+ this.motion = undefined;
129
+ }
130
+
131
+ private syncMotion(): void {
132
+ if (this.running && !this.motion) {
133
+ this.motion = mountConfiguredAnimation(
134
+ { requestRender: this.options.requestRender },
135
+ {
136
+ reducedMotion: this.options.reducedMotion,
137
+ onFrame: (now) => {
138
+ this.now = now;
139
+ },
140
+ },
141
+ );
142
+ } else if (!this.running && this.motion) {
143
+ this.motion.dispose();
144
+ this.motion = undefined;
145
+ }
146
+ }
147
+ }
148
+
149
+ function actionField(value: string, width: number): string {
150
+ const maximumCharacters = Math.max(1, Math.floor(width) * 2);
151
+ return sanitizeTuiFieldPreview(value, maximumCharacters);
152
+ }
153
+
154
+ function actionStatus(status: ToolTranscriptStatus): {
155
+ glyph: string;
156
+ tone: "accent" | "positive" | "negative" | "warning" | "text.muted";
157
+ } {
158
+ switch (status) {
159
+ case "running":
160
+ return { glyph: "●", tone: "accent" };
161
+ case "succeeded":
162
+ return { glyph: "•", tone: "positive" };
163
+ case "failed":
164
+ return { glyph: icon("error"), tone: "negative" };
165
+ case "warning":
166
+ return { glyph: icon("warning"), tone: "warning" };
167
+ case "queued":
168
+ return { glyph: "○", tone: "text.muted" };
169
+ }
170
+ }