@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
package/src/syntax.ts ADDED
@@ -0,0 +1,316 @@
1
+ import type { Theme } from "@earendil-works/pi-coding-agent";
2
+ import type { Component } from "@earendil-works/pi-tui";
3
+ import { truncateToWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
4
+ import {
5
+ type DiffsHighlighter,
6
+ getFiletypeFromFileName,
7
+ getHighlighterIfLoaded,
8
+ getSharedHighlighter,
9
+ type SupportedLanguages,
10
+ type ThemedDiffResult,
11
+ } from "@pierre/diffs";
12
+ import type { TuiForegroundColor } from "./color/theme.ts";
13
+ import { tuiTheme } from "./color/theme.ts";
14
+ import { sanitizeTuiText } from "./content/terminal-text.ts";
15
+ import { RenderedLinesCache } from "./render-cache.ts";
16
+
17
+ const SHIKI_THEME = "github-dark";
18
+ const SYNTAX_LANGUAGES = [
19
+ "c",
20
+ "cpp",
21
+ "csharp",
22
+ "css",
23
+ "dockerfile",
24
+ "elixir",
25
+ "fish",
26
+ "go",
27
+ "graphql",
28
+ "haskell",
29
+ "hcl",
30
+ "html",
31
+ "java",
32
+ "javascript",
33
+ "json",
34
+ "jsx",
35
+ "kotlin",
36
+ "lua",
37
+ "makefile",
38
+ "markdown",
39
+ "ocaml",
40
+ "perl",
41
+ "php",
42
+ "protobuf",
43
+ "python",
44
+ "r",
45
+ "ruby",
46
+ "rust",
47
+ "scss",
48
+ "sql",
49
+ "swift",
50
+ "toml",
51
+ "tsx",
52
+ "typescript",
53
+ "xml",
54
+ "yaml",
55
+ "zsh",
56
+ ] as const satisfies readonly SupportedLanguages[];
57
+ const supportedLanguages = new Set<SupportedLanguages>(SYNTAX_LANGUAGES);
58
+ let highlighterReady: Promise<DiffsHighlighter | undefined> | undefined;
59
+
60
+ export interface SyntaxHighlightSpan {
61
+ readonly text: string;
62
+ readonly foreground?: TuiForegroundColor;
63
+ readonly emphasized?: boolean;
64
+ }
65
+
66
+ export interface SyntaxTextOptions {
67
+ theme: Theme;
68
+ text: string;
69
+ path?: string;
70
+ maxRows?: number;
71
+ maxCharacters?: number;
72
+ requestRender?: () => void;
73
+ }
74
+
75
+ /** Bounded, cached syntax-highlighted source or structured text without a container. */
76
+ export class SyntaxText implements Component {
77
+ private readonly cache = new RenderedLinesCache();
78
+ private text: string;
79
+ private syntaxReadyRequested = false;
80
+
81
+ constructor(private readonly options: SyntaxTextOptions) {
82
+ // Restored tool details may predate a newly-added presentation field.
83
+ this.text = typeof options.text === "string" ? sanitizeTuiText(options.text) : "";
84
+ }
85
+
86
+ setText(text: string): void {
87
+ const normalized = typeof text === "string" ? sanitizeTuiText(text) : "";
88
+ if (normalized === this.text) return;
89
+ this.text = normalized;
90
+ this.cache.clear();
91
+ }
92
+
93
+ render(width: number): string[] {
94
+ const boundedWidth = Math.max(0, Math.floor(width));
95
+ if (boundedWidth === 0) return [];
96
+ this.requestSyntaxReady();
97
+ const maximum = finiteBound(this.options.maxCharacters, 200_000, 0);
98
+ const clipped = this.text.length > maximum ? `${this.text.slice(0, Math.max(0, maximum - 1))}…` : this.text;
99
+ const highlighterState = getHighlighterIfLoaded() ? "highlighted" : "plain";
100
+ return this.cache.get(boundedWidth, highlighterState, () => {
101
+ const colors = tuiTheme(this.options.theme);
102
+ const limit = finiteBound(this.options.maxRows, 500, 1);
103
+ const rows: string[] = [];
104
+ let omitted = false;
105
+ const sourceLines = clipped.split("\n");
106
+ const highlightedLines = highlightSyntaxBlock(clipped, this.options.path);
107
+ for (const [lineIndex, line] of sourceLines.entries()) {
108
+ const remainingRows = Math.max(1, limit - rows.length);
109
+ const lineBudget = Math.max(1_024, Math.min(20_000, boundedWidth * remainingRows * 2));
110
+ const lineClipped = line.length > lineBudget;
111
+ const sourceSpans = highlightedLines[lineIndex] ?? [];
112
+ const spans = sourceSpans.map((span) => span.text).join("") === line ? sourceSpans : [{ text: line }];
113
+ const visibleSpans = lineClipped ? [...truncateSyntaxSpans(spans, lineBudget - 1), { text: "…" }] : spans;
114
+ const styled = visibleSpans.map((span) => colors.fg(span.foreground ?? "text.primary", span.text)).join("");
115
+ for (const row of wrapTextWithAnsi(styled, boundedWidth)) {
116
+ rows.push(row);
117
+ if (rows.length > limit) {
118
+ omitted = true;
119
+ break;
120
+ }
121
+ }
122
+ if (lineClipped) omitted = true;
123
+ if (omitted) break;
124
+ }
125
+ if (!omitted) return rows;
126
+ return [
127
+ ...rows.slice(0, limit - 1),
128
+ colors.fg("text.muted", truncateToWidth("… output omitted …", boundedWidth, "…")),
129
+ ];
130
+ });
131
+ }
132
+
133
+ invalidate(): void {
134
+ this.cache.clear();
135
+ }
136
+
137
+ private requestSyntaxReady(): void {
138
+ if (this.syntaxReadyRequested || getHighlighterIfLoaded()) return;
139
+ this.syntaxReadyRequested = true;
140
+ void ensureSyntaxHighlighter().then((highlighter) => {
141
+ if (!highlighter) return;
142
+ this.cache.clear();
143
+ this.options.requestRender?.();
144
+ });
145
+ }
146
+ }
147
+
148
+ export function highlightSyntaxBlock(
149
+ text: string,
150
+ path: string | undefined,
151
+ ): readonly (readonly SyntaxHighlightSpan[])[] {
152
+ const plainLines = plainSyntaxLines(text);
153
+ const highlighter = getHighlighterIfLoaded();
154
+ if (!highlighter) return plainLines;
155
+ const language = languageForPath(path);
156
+ try {
157
+ const root = highlighter.codeToHast(text, {
158
+ lang: language,
159
+ theme: SHIKI_THEME,
160
+ defaultColor: false,
161
+ tokenizeTimeLimit: 0,
162
+ });
163
+ const lines = syntaxSpansFromLines(root);
164
+ while (lines.length < plainLines.length) lines.push([]);
165
+ return lines.slice(0, plainLines.length);
166
+ } catch {
167
+ return plainLines;
168
+ }
169
+ }
170
+
171
+ export function loadedDiffHighlighter(): DiffsHighlighter | undefined {
172
+ return getHighlighterIfLoaded();
173
+ }
174
+
175
+ export function whenSyntaxReady(callback: () => void): void {
176
+ void ensureSyntaxHighlighter().then(callback);
177
+ }
178
+
179
+ function ensureSyntaxHighlighter(): Promise<DiffsHighlighter | undefined> {
180
+ if (highlighterReady) return highlighterReady;
181
+ highlighterReady = getSharedHighlighter({ themes: [SHIKI_THEME], langs: [...SYNTAX_LANGUAGES] }).catch(
182
+ () => undefined,
183
+ );
184
+ return highlighterReady;
185
+ }
186
+
187
+ export function syntaxSpansFromPierreLine(
188
+ node: ThemedDiffResult["code"]["additionLines"][number],
189
+ ): readonly SyntaxHighlightSpan[] {
190
+ return syntaxSpansFromNode(node);
191
+ }
192
+
193
+ export function syntaxLanguage(path: string | undefined): SupportedLanguages {
194
+ return languageForPath(path);
195
+ }
196
+
197
+ function languageForPath(path: string | undefined): SupportedLanguages {
198
+ if (!path) return "text";
199
+ const language = getFiletypeFromFileName(path);
200
+ return supportedLanguages.has(language) ? language : "text";
201
+ }
202
+
203
+ type PierreSyntaxNode =
204
+ | ReturnType<DiffsHighlighter["codeToHast"]>
205
+ | ReturnType<DiffsHighlighter["codeToHast"]>["children"][number];
206
+
207
+ function plainSyntaxLines(text: string): readonly (readonly SyntaxHighlightSpan[])[] {
208
+ return text.split("\n").map((line) => (line ? [{ text: line }] : []));
209
+ }
210
+
211
+ function syntaxSpansFromNode(node: PierreSyntaxNode): readonly SyntaxHighlightSpan[] {
212
+ const spans: SyntaxHighlightSpan[] = [];
213
+ collectSyntaxSpans(node, undefined, false, spans);
214
+ return spans;
215
+ }
216
+
217
+ function syntaxSpansFromLines(node: PierreSyntaxNode): SyntaxHighlightSpan[][] {
218
+ const lines: SyntaxHighlightSpan[][] = [[]];
219
+ for (const span of syntaxSpansFromNode(node)) {
220
+ const values = span.text.split("\n");
221
+ for (const [index, value] of values.entries()) {
222
+ pushSpan(lines.at(-1)!, { ...span, text: value });
223
+ if (index < values.length - 1) lines.push([]);
224
+ }
225
+ }
226
+ return lines;
227
+ }
228
+
229
+ function truncateSyntaxSpans(
230
+ spans: readonly SyntaxHighlightSpan[],
231
+ maximumCharacters: number,
232
+ ): readonly SyntaxHighlightSpan[] {
233
+ let remaining = Math.max(0, maximumCharacters);
234
+ const truncated: SyntaxHighlightSpan[] = [];
235
+ for (const span of spans) {
236
+ if (remaining === 0) break;
237
+ const text = span.text.slice(0, remaining);
238
+ if (!text) continue;
239
+ truncated.push({ ...span, text });
240
+ remaining -= text.length;
241
+ }
242
+ return truncated;
243
+ }
244
+
245
+ function collectSyntaxSpans(
246
+ node: PierreSyntaxNode,
247
+ foreground: TuiForegroundColor | undefined,
248
+ emphasized: boolean,
249
+ spans: SyntaxHighlightSpan[],
250
+ ): void {
251
+ if (node.type === "text") {
252
+ pushSpan(spans, { text: node.value, foreground, emphasized });
253
+ return;
254
+ }
255
+ if (node.type !== "root" && node.type !== "element") return;
256
+ const properties = node.type === "element" ? node.properties : undefined;
257
+ const nextForeground =
258
+ foregroundFromStyle(typeof properties?.style === "string" ? properties.style : undefined) ?? foreground;
259
+ const nextEmphasized = emphasized || (properties !== undefined && "data-diff-span" in properties);
260
+ for (const child of node.children) collectSyntaxSpans(child, nextForeground, nextEmphasized, spans);
261
+ }
262
+
263
+ function pushSpan(spans: SyntaxHighlightSpan[], span: SyntaxHighlightSpan): void {
264
+ if (!span.text) return;
265
+ const previous = spans.at(-1);
266
+ if (previous && sameForeground(previous.foreground, span.foreground) && previous.emphasized === span.emphasized) {
267
+ spans[spans.length - 1] = { ...previous, text: previous.text + span.text };
268
+ return;
269
+ }
270
+ spans.push(span);
271
+ }
272
+
273
+ function sameForeground(left: TuiForegroundColor | undefined, right: TuiForegroundColor | undefined): boolean {
274
+ if (left === right) return true;
275
+ if (typeof left === "string" || typeof right === "string" || !left || !right) return false;
276
+ return left.hue === right.hue && left.shade === right.shade;
277
+ }
278
+
279
+ function foregroundFromStyle(style: string | undefined): TuiForegroundColor | undefined {
280
+ const match = /(?:^|;)color:\s*#([0-9a-f]{6})(?:;|$)/iu.exec(style ?? "");
281
+ if (!match) return undefined;
282
+ const value = match[1]!;
283
+ const red = Number.parseInt(value.slice(0, 2), 16) / 255;
284
+ const green = Number.parseInt(value.slice(2, 4), 16) / 255;
285
+ const blue = Number.parseInt(value.slice(4, 6), 16) / 255;
286
+ const maximum = Math.max(red, green, blue);
287
+ const minimum = Math.min(red, green, blue);
288
+ const lightness = (maximum + minimum) / 2;
289
+ const delta = maximum - minimum;
290
+ if (delta < 0.08) return { hue: "gray", shade: shade(lightness) };
291
+ let hue = 0;
292
+ if (maximum === red) hue = ((green - blue) / delta + (green < blue ? 6 : 0)) / 6;
293
+ else if (maximum === green) hue = ((blue - red) / delta + 2) / 6;
294
+ else hue = ((red - green) / delta + 4) / 6;
295
+ const semanticHue =
296
+ hue < 1 / 24 || hue >= 23 / 24
297
+ ? "red"
298
+ : hue < 5 / 24
299
+ ? "yellow"
300
+ : hue < 10 / 24
301
+ ? "green"
302
+ : hue < 14 / 24
303
+ ? "cyan"
304
+ : hue < 19 / 24
305
+ ? "blue"
306
+ : "magenta";
307
+ return { hue: semanticHue, shade: shade(lightness) };
308
+ }
309
+
310
+ function shade(lightness: number): 0 | 1 | 2 | 3 | 4 | 5 {
311
+ return Math.max(0, Math.min(5, Math.round(lightness * 5))) as 0 | 1 | 2 | 3 | 4 | 5;
312
+ }
313
+
314
+ function finiteBound(value: number | undefined, fallback: number, minimum: number): number {
315
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(minimum, Math.floor(value)) : fallback;
316
+ }
@@ -0,0 +1,243 @@
1
+ import type { ChildProcessWithoutNullStreams } from "node:child_process";
2
+ import { StringDecoder } from "node:string_decoder";
3
+
4
+ interface BridgeResponse<T> {
5
+ request_id: number;
6
+ ok: boolean;
7
+ result?: T;
8
+ error?: string;
9
+ }
10
+
11
+ // type-boundary: native bridge stdout is untrusted JSON; this parser validates the response envelope before dispatch.
12
+ type BridgeResponseBoundary = unknown;
13
+
14
+ function parseBridgeResponse(line: string): BridgeResponse<unknown> | undefined {
15
+ let value: BridgeResponseBoundary;
16
+ try {
17
+ value = JSON.parse(line);
18
+ } catch {
19
+ return undefined;
20
+ }
21
+ if (!value || typeof value !== "object") return undefined;
22
+ const candidate = value as Partial<BridgeResponse<unknown>>;
23
+ if (!Number.isSafeInteger(candidate.request_id) || typeof candidate.ok !== "boolean") return undefined;
24
+ if (candidate.ok === false && candidate.error !== undefined && typeof candidate.error !== "string") return undefined;
25
+ return candidate as BridgeResponse<unknown>;
26
+ }
27
+
28
+ export interface TerminalBridgeReadResponse {
29
+ chunks: Array<{ startSeq?: number; seq: number; stream: "stdout" | "stderr" | "pty"; bytes: Uint8Array }>;
30
+ nextSeq: number;
31
+ more?: boolean;
32
+ exited: boolean;
33
+ exitCode?: number | null;
34
+ closed: boolean;
35
+ failure?: string | null;
36
+ }
37
+
38
+ // type-boundary: native bridge result payloads are untrusted JSON; validate read results before consumers reduce them.
39
+ type TerminalBridgeReadBoundary = unknown;
40
+
41
+ const BASE64 = /^(?:[A-Za-z\d+/]{4})*(?:[A-Za-z\d+/]{2}==|[A-Za-z\d+/]{3}=)?$/;
42
+
43
+ export function parseTerminalBridgeReadResponse(
44
+ value: TerminalBridgeReadBoundary,
45
+ expectedNextSeq?: number,
46
+ ): TerminalBridgeReadResponse | undefined {
47
+ if (!value || typeof value !== "object") return undefined;
48
+ const candidate = value as Partial<Record<keyof TerminalBridgeReadResponse, unknown>>;
49
+ if (!Array.isArray(candidate.chunks) || !Number.isSafeInteger(candidate.nextSeq) || Number(candidate.nextSeq) < 1)
50
+ return undefined;
51
+ if (typeof candidate.exited !== "boolean" || typeof candidate.closed !== "boolean") return undefined;
52
+ if (candidate.more !== undefined && typeof candidate.more !== "boolean") return undefined;
53
+ if (candidate.exitCode !== undefined && candidate.exitCode !== null && !Number.isSafeInteger(candidate.exitCode))
54
+ return undefined;
55
+ if (candidate.failure !== undefined && candidate.failure !== null && typeof candidate.failure !== "string")
56
+ return undefined;
57
+ const chunks: TerminalBridgeReadResponse["chunks"] = [];
58
+ let previousSeq = 0;
59
+ for (const value of candidate.chunks) {
60
+ if (!value || typeof value !== "object") return undefined;
61
+ const chunk = value as Partial<{
62
+ startSeq: number;
63
+ seq: number;
64
+ stream: "stdout" | "stderr" | "pty";
65
+ chunk: string;
66
+ }>;
67
+ const seq = Number(chunk.seq);
68
+ const startSeq = chunk.startSeq === undefined ? seq : Number(chunk.startSeq);
69
+ if (
70
+ !Number.isSafeInteger(chunk.seq) ||
71
+ seq < 1 ||
72
+ !Number.isSafeInteger(startSeq) ||
73
+ startSeq < 1 ||
74
+ startSeq > seq
75
+ )
76
+ return undefined;
77
+ if (previousSeq > 0 && startSeq !== previousSeq + 1) return undefined;
78
+ if (chunk.stream !== "stdout" && chunk.stream !== "stderr" && chunk.stream !== "pty") return undefined;
79
+ const bytes = decodeBase64(chunk.chunk);
80
+ if (!bytes) return undefined;
81
+ previousSeq = seq;
82
+ chunks.push({ startSeq, seq: previousSeq, stream: chunk.stream, bytes });
83
+ }
84
+ if (chunks.length > 0 && candidate.nextSeq !== previousSeq + 1) return undefined;
85
+ const first = chunks[0];
86
+ if (expectedNextSeq !== undefined && (first ? (first.startSeq ?? first.seq) : candidate.nextSeq) !== expectedNextSeq)
87
+ return undefined;
88
+ return {
89
+ chunks,
90
+ nextSeq: Number(candidate.nextSeq),
91
+ ...(typeof candidate.more === "boolean" ? { more: candidate.more } : {}),
92
+ exited: candidate.exited,
93
+ ...(typeof candidate.exitCode === "number" || candidate.exitCode === null ? { exitCode: candidate.exitCode } : {}),
94
+ closed: candidate.closed,
95
+ ...(typeof candidate.failure === "string" || candidate.failure === null ? { failure: candidate.failure } : {}),
96
+ };
97
+ }
98
+
99
+ function decodeBase64(value: unknown): Uint8Array | undefined {
100
+ return typeof value === "string" && BASE64.test(value) ? Buffer.from(value, "base64") : undefined;
101
+ }
102
+
103
+ export interface TerminalBridgeClient {
104
+ request<T>(request: Record<string, unknown>): Promise<T>;
105
+ shutdown(): Promise<void>;
106
+ }
107
+
108
+ export interface TerminalBridgeClientDependencies {
109
+ readonly binaryPath: () => Promise<string>;
110
+ readonly spawnBridge: (binaryPath: string) => ChildProcessWithoutNullStreams;
111
+ }
112
+
113
+ const BRIDGE_SHUTDOWN_GRACE_MS = 1_000;
114
+
115
+ /** Multiplex JSON-line requests over one shared native bridge process. */
116
+ export function createTerminalBridgeClient({
117
+ binaryPath,
118
+ spawnBridge,
119
+ }: TerminalBridgeClientDependencies): TerminalBridgeClient {
120
+ let child: ChildProcessWithoutNullStreams | undefined;
121
+ let nextRequestId = 1;
122
+ let closed = false;
123
+ let shutdownPromise: Promise<void> | undefined;
124
+ const pending = new Map<
125
+ number,
126
+ { readonly owner: ChildProcessWithoutNullStreams; resolve(value: unknown): void; reject(error: Error): void }
127
+ >();
128
+
129
+ function rejectPending(error: Error, owner?: ChildProcessWithoutNullStreams): void {
130
+ for (const [requestId, request] of pending) {
131
+ if (owner && request.owner !== owner) continue;
132
+ pending.delete(requestId);
133
+ request.reject(error);
134
+ }
135
+ }
136
+
137
+ function fail(owner: ChildProcessWithoutNullStreams, error: Error): void {
138
+ if (child === owner) child = undefined;
139
+ rejectPending(error, owner);
140
+ if (owner.exitCode === null && owner.signalCode === null) owner.kill("SIGKILL");
141
+ }
142
+
143
+ async function getChild(): Promise<ChildProcessWithoutNullStreams> {
144
+ if (closed) throw new Error("terminal bridge is shut down");
145
+ if (child && child.exitCode === null && child.signalCode === null) return child;
146
+ const binary = await binaryPath();
147
+ if (closed) throw new Error("terminal bridge is shut down");
148
+ if (child && child.exitCode === null && child.signalCode === null) return child;
149
+ let lineBuffer = "";
150
+ let stderr = "";
151
+ const decoder = new StringDecoder("utf8");
152
+ const spawned = spawnBridge(binary);
153
+ child = spawned;
154
+ spawned.stdout.on("data", (data: Buffer) => {
155
+ lineBuffer += decoder.write(data);
156
+ for (;;) {
157
+ const newline = lineBuffer.indexOf("\n");
158
+ if (newline === -1) break;
159
+ const line = lineBuffer.slice(0, newline).trim();
160
+ lineBuffer = lineBuffer.slice(newline + 1);
161
+ if (!line) continue;
162
+ const response = parseBridgeResponse(line);
163
+ if (!response) {
164
+ fail(spawned, new Error("terminal bridge emitted an invalid response"));
165
+ return;
166
+ }
167
+ const request = pending.get(response.request_id);
168
+ if (!request || request.owner !== spawned) {
169
+ fail(spawned, new Error(`terminal bridge responded to unknown request ${response.request_id}`));
170
+ return;
171
+ }
172
+ pending.delete(response.request_id);
173
+ if (response.ok) request.resolve(response.result);
174
+ else request.reject(new Error(response.error ?? "terminal bridge request failed"));
175
+ }
176
+ });
177
+ spawned.stderr.on("data", (data: Buffer) => {
178
+ stderr = `${stderr}${data.toString("utf8")}`.slice(-16_000);
179
+ });
180
+ spawned.on("error", (error) => fail(spawned, error));
181
+ spawned.on("close", (code, signal) => {
182
+ const status = code === null ? `signal ${signal}` : `code ${code}`;
183
+ rejectPending(
184
+ new Error(`terminal bridge exited (${status})${stderr.trim() ? `: ${stderr.trim()}` : ""}`),
185
+ spawned,
186
+ );
187
+ if (child === spawned) child = undefined;
188
+ });
189
+ return spawned;
190
+ }
191
+
192
+ async function request<T>(value: Record<string, unknown>): Promise<T> {
193
+ const requestId = nextRequestId++;
194
+ const bridge = await getChild();
195
+ return new Promise<T>((resolve, reject) => {
196
+ pending.set(requestId, { owner: bridge, resolve: resolve as (value: unknown) => void, reject });
197
+ const rejectWrite = (error: Error): void => {
198
+ pending.delete(requestId);
199
+ reject(error);
200
+ };
201
+ try {
202
+ bridge.stdin.write(`${JSON.stringify({ ...value, request_id: requestId })}\n`, (error) => {
203
+ if (error) rejectWrite(error);
204
+ });
205
+ } catch (error) {
206
+ rejectWrite(error instanceof Error ? error : new Error("terminal bridge write failed"));
207
+ }
208
+ });
209
+ }
210
+
211
+ return {
212
+ request,
213
+ shutdown() {
214
+ if (shutdownPromise) return shutdownPromise;
215
+ shutdownPromise = (async () => {
216
+ const bridge = child;
217
+ const wasRunning = bridge && bridge.exitCode === null && bridge.signalCode === null;
218
+ const exited = wasRunning
219
+ ? new Promise<void>((resolve) => bridge.once("close", () => resolve()))
220
+ : Promise.resolve();
221
+ const graceful = wasRunning
222
+ ? request<{ shutdown: boolean }>({ op: "shutdown" }).catch(() => undefined)
223
+ : Promise.resolve();
224
+ const deadline = wasRunning
225
+ ? setTimeout(() => {
226
+ if (bridge.exitCode === null && bridge.signalCode === null) bridge.kill("SIGKILL");
227
+ }, BRIDGE_SHUTDOWN_GRACE_MS)
228
+ : undefined;
229
+ deadline?.unref();
230
+ closed = true;
231
+ try {
232
+ await graceful;
233
+ } finally {
234
+ if (deadline) clearTimeout(deadline);
235
+ if (bridge && bridge.exitCode === null && bridge.signalCode === null) bridge.kill("SIGKILL");
236
+ rejectPending(new Error("terminal bridge is shut down"));
237
+ await exited;
238
+ }
239
+ })();
240
+ return shutdownPromise;
241
+ },
242
+ };
243
+ }
@@ -0,0 +1,6 @@
1
+ const TOP_LEVEL_ZONE_MARKER = /\x1b\]133;[ABC](?:\x07|\x1b\\)/gu;
2
+
3
+ /** Remove terminal-wide transcript zones before embedding rendered lines inside another surface. */
4
+ export function stripTopLevelZoneMarkers(lines: readonly string[]): string[] {
5
+ return lines.map((line) => line.replace(TOP_LEVEL_ZONE_MARKER, ""));
6
+ }