@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,263 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { spawn } from "node:child_process";
3
+ import {
4
+ createTerminalBridgeClient,
5
+ parseTerminalBridgeReadResponse,
6
+ type TerminalBridgeClient,
7
+ type TerminalBridgeReadResponse,
8
+ } from "./bridge-client.ts";
9
+ import { terminalBridgeBinaryPath } from "../native-binary.ts";
10
+ import { normalizeTerminalDimensions, TerminalProjection } from "./projection.ts";
11
+
12
+ const PTY_HOST_KEY = Symbol.for("pi-libtui/pty-host/v2");
13
+ const PTY_HOST_PROTOCOL = "pi-libtui/pty-host/v2" as const;
14
+ const PTY_READ_IDLE_TIMEOUT_MS = 30_000;
15
+
16
+ export interface PtyHost {
17
+ readonly protocol: typeof PTY_HOST_PROTOCOL;
18
+ readonly version: 2;
19
+ spawn(request: {
20
+ readonly command: string;
21
+ readonly cwd: string;
22
+ readonly columns?: number;
23
+ readonly rows?: number;
24
+ }): Promise<string>;
25
+ render(processId: string, rows: number, cursor: boolean): readonly string[];
26
+ resize(processId: string, columns: number, rows: number): Promise<boolean>;
27
+ sendInput(processId: string, data: string): Promise<boolean>;
28
+ terminate(processId: string): Promise<boolean>;
29
+ isRunning(processId: string): boolean;
30
+ acceptsFocusEvents(processId: string): boolean;
31
+ subscribe(processId: string, listener: () => void): () => void;
32
+ shutdown(): Promise<void>;
33
+ }
34
+
35
+ export interface PtyHostDependencies {
36
+ readonly bridge: TerminalBridgeClient;
37
+ readonly createProcessId: () => string;
38
+ readonly environment: Readonly<Record<string, string>>;
39
+ }
40
+
41
+ interface HostedProcess {
42
+ readonly terminal: TerminalProjection;
43
+ readonly listeners: Set<() => void>;
44
+ nextSeq: number;
45
+ running: boolean;
46
+ polling?: Promise<void>;
47
+ termination?: Promise<boolean>;
48
+ }
49
+
50
+ /** A process may exit before its PTY reader closes and drains the final bytes. */
51
+ function closesProcess(response: TerminalBridgeReadResponse): boolean {
52
+ const failed = response.failure != null;
53
+ return (response.closed || failed) && response.more !== true;
54
+ }
55
+
56
+ // type-boundary: Symbol.for capabilities can be populated by another installed pi-libtui copy; this validator narrows it once.
57
+ type UntrustedPtyHost = unknown;
58
+
59
+ function isPtyHost(value: UntrustedPtyHost): value is PtyHost {
60
+ if (!value || typeof value !== "object") return false;
61
+ const candidate = value as Partial<PtyHost>;
62
+ return (
63
+ candidate.protocol === PTY_HOST_PROTOCOL &&
64
+ candidate.version === 2 &&
65
+ typeof candidate.spawn === "function" &&
66
+ typeof candidate.render === "function" &&
67
+ typeof candidate.resize === "function" &&
68
+ typeof candidate.sendInput === "function" &&
69
+ typeof candidate.terminate === "function" &&
70
+ typeof candidate.isRunning === "function" &&
71
+ typeof candidate.acceptsFocusEvents === "function" &&
72
+ typeof candidate.subscribe === "function" &&
73
+ typeof candidate.shutdown === "function"
74
+ );
75
+ }
76
+
77
+ /** Lazily claim the process host shared by every installed pi-libtui copy. */
78
+ export function ensurePtyHost(scope: typeof globalThis = globalThis): PtyHost {
79
+ const slots = scope as Record<PropertyKey, UntrustedPtyHost>;
80
+ const existing = slots[PTY_HOST_KEY];
81
+ if (isPtyHost(existing)) return existing;
82
+ const host = createPtyHost({
83
+ bridge: createTerminalBridgeClient({
84
+ binaryPath: terminalBridgeBinaryPath,
85
+ spawnBridge: (binaryPath) => spawn(binaryPath, [], { stdio: "pipe", env: process.env }),
86
+ }),
87
+ createProcessId: () => `pi-libtui-${process.pid}-${randomUUID()}`,
88
+ environment: stringEnvironment(process.env),
89
+ });
90
+ slots[PTY_HOST_KEY] = host;
91
+ return host;
92
+ }
93
+
94
+ export async function shutdownPtyHost(scope: typeof globalThis = globalThis): Promise<void> {
95
+ const slots = scope as Record<PropertyKey, UntrustedPtyHost>;
96
+ const host = slots[PTY_HOST_KEY];
97
+ if (!isPtyHost(host)) return;
98
+ Reflect.deleteProperty(slots, PTY_HOST_KEY);
99
+ await host.shutdown();
100
+ }
101
+
102
+ export function createPtyHost({ bridge, createProcessId, environment }: PtyHostDependencies): PtyHost {
103
+ const processes = new Map<string, HostedProcess>();
104
+ let stopped = false;
105
+ let shutdownPromise: Promise<void> | undefined;
106
+
107
+ const emit = (process: HostedProcess): void => {
108
+ for (const listener of process.listeners) listener();
109
+ };
110
+
111
+ const poll = async (processId: string, process: HostedProcess): Promise<void> => {
112
+ while (!stopped && process.running && processes.get(processId) === process) {
113
+ let response: TerminalBridgeReadResponse;
114
+ try {
115
+ const result = await bridge.request<unknown>({
116
+ op: "read",
117
+ process_id: processId,
118
+ after_seq: Math.max(0, process.nextSeq - 1),
119
+ max_bytes: 1_048_576,
120
+ // Native output and lifecycle changes wake this read. The timeout only
121
+ // recovers a lost wake without generating steady idle bridge traffic.
122
+ wait_ms: PTY_READ_IDLE_TIMEOUT_MS,
123
+ });
124
+ const parsed = parseTerminalBridgeReadResponse(result, process.nextSeq);
125
+ if (!parsed) throw new Error("terminal bridge emitted an invalid read result");
126
+ response = parsed;
127
+ } catch {
128
+ // A later bridge failure must not discard bytes accepted by an earlier read.
129
+ await process.terminal.drain();
130
+ process.running = false;
131
+ if (!stopped) emit(process);
132
+ return;
133
+ }
134
+ process.nextSeq = response.nextSeq;
135
+ for (const chunk of response.chunks) {
136
+ process.terminal.write(chunk.bytes);
137
+ }
138
+ if (process.running && closesProcess(response)) {
139
+ // Do not expose terminal completion until every accepted byte is visible
140
+ // to consumers; otherwise onExit can dispose a pane with a queued tail.
141
+ await process.terminal.drain();
142
+ process.running = false;
143
+ if (!stopped && processes.get(processId) === process) emit(process);
144
+ }
145
+ }
146
+ };
147
+
148
+ return {
149
+ protocol: PTY_HOST_PROTOCOL,
150
+ version: 2,
151
+ async spawn(request) {
152
+ if (stopped) throw new Error("PTY host is shut down");
153
+ const processId = createProcessId();
154
+ const [columns, rows] = normalizeTerminalDimensions(request.columns ?? 80, request.rows ?? 24);
155
+ const listeners = new Set<() => void>();
156
+ const hosted: HostedProcess = {
157
+ terminal: new TerminalProjection({
158
+ requestRender: () => emit(hosted),
159
+ cols: columns,
160
+ rows,
161
+ repaintIntervalMs: 0,
162
+ scrollback: 0,
163
+ }),
164
+ listeners,
165
+ nextSeq: 1,
166
+ running: true,
167
+ };
168
+ processes.set(processId, hosted);
169
+ try {
170
+ await bridge.request({
171
+ op: "exec",
172
+ process_id: processId,
173
+ argv: ["/bin/sh", "-c", request.command],
174
+ cwd: request.cwd,
175
+ env: { ...environment, TERM: "xterm-256color", COLORTERM: "truecolor" },
176
+ tty: true,
177
+ pipe_stdin: true,
178
+ arg0: null,
179
+ rows,
180
+ cols: columns,
181
+ });
182
+ } catch (error) {
183
+ processes.delete(processId);
184
+ hosted.terminal.dispose();
185
+ throw error;
186
+ }
187
+ hosted.polling = poll(processId, hosted);
188
+ return processId;
189
+ },
190
+ render(processId, rows, cursor) {
191
+ return processes.get(processId)?.terminal.renderLines({ maxRows: rows, cursor }) ?? [];
192
+ },
193
+ async resize(processId, columns, rows) {
194
+ const process = processes.get(processId);
195
+ if (!process?.running) return false;
196
+ [columns, rows] = normalizeTerminalDimensions(columns, rows);
197
+ if (process.terminal.cols === columns && process.terminal.rows === rows) return true;
198
+ await bridge.request({ op: "resize", process_id: processId, rows, cols: columns });
199
+ process.terminal.resize(columns, rows);
200
+ return true;
201
+ },
202
+ async sendInput(processId, data) {
203
+ const process = processes.get(processId);
204
+ if (!process?.running) return false;
205
+ const response = await bridge.request<{ status: string }>({
206
+ op: "write",
207
+ process_id: processId,
208
+ chunk: data,
209
+ });
210
+ return response.status === "accepted";
211
+ },
212
+ terminate(processId) {
213
+ const process = processes.get(processId);
214
+ if (!process) return Promise.resolve(false);
215
+ if (process.termination) return process.termination;
216
+ process.termination = (async () => {
217
+ const terminated = await bridge
218
+ .request({ op: "terminate", process_id: processId })
219
+ .then(() => true)
220
+ .catch(() => false);
221
+ // Keep the poll alive through termination so it can drain the PTY tail and
222
+ // observe closure before the native process entry is reaped.
223
+ if (terminated) await process.polling;
224
+ process.running = false;
225
+ await bridge.request({ op: "reap", process_id: processId }).catch(() => undefined);
226
+ if (processes.get(processId) === process) {
227
+ processes.delete(processId);
228
+ process.terminal.dispose();
229
+ emit(process);
230
+ process.listeners.clear();
231
+ }
232
+ return terminated;
233
+ })();
234
+ return process.termination;
235
+ },
236
+ isRunning: (processId) => processes.get(processId)?.running ?? false,
237
+ acceptsFocusEvents: (processId) => processes.get(processId)?.terminal.acceptsFocusEvents ?? false,
238
+ subscribe(processId, listener) {
239
+ const listeners = processes.get(processId)?.listeners;
240
+ if (!listeners) return () => {};
241
+ listeners.add(listener);
242
+ return () => listeners.delete(listener);
243
+ },
244
+ shutdown() {
245
+ if (shutdownPromise) return shutdownPromise;
246
+ stopped = true;
247
+ for (const process of processes.values()) {
248
+ process.running = false;
249
+ process.terminal.dispose();
250
+ process.listeners.clear();
251
+ }
252
+ processes.clear();
253
+ shutdownPromise = bridge.shutdown();
254
+ return shutdownPromise;
255
+ },
256
+ };
257
+ }
258
+
259
+ function stringEnvironment(environment: NodeJS.ProcessEnv): Record<string, string> {
260
+ return Object.fromEntries(
261
+ Object.entries(environment).filter((entry): entry is [string, string] => entry[1] !== undefined),
262
+ );
263
+ }
@@ -0,0 +1,308 @@
1
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
2
+ import { type Component, type Focusable, ScrollView, type TUI } from "@earendil-works/pi-tui";
3
+ import type { TuiMouseEvent } from "../mouse.ts";
4
+ import { ensurePtyHost, type PtyHost } from "./pty-host.ts";
5
+
6
+ export interface PtyProcessOptions {
7
+ readonly label: string;
8
+ readonly command: string;
9
+ readonly context: Pick<ExtensionContext, "cwd" | "ui">;
10
+ readonly onExit: () => void;
11
+ readonly scope: typeof globalThis;
12
+ }
13
+
14
+ interface PtySize {
15
+ readonly columns: number;
16
+ readonly rows: number;
17
+ }
18
+
19
+ function samePtySize(left: PtySize | undefined, right: PtySize): boolean {
20
+ return left?.columns === right.columns && left.rows === right.rows;
21
+ }
22
+
23
+ /** One reusable PTY lifecycle independent from its side-panel or overlay presentation. */
24
+ export class PtyProcess {
25
+ private host: PtyHost | undefined;
26
+ private processId: string | undefined;
27
+ private starting: Promise<void> | undefined;
28
+ private unsubscribe: (() => void) | undefined;
29
+ private readonly renderListeners = new Set<(interactive: boolean) => void>();
30
+ private pendingInput = "";
31
+ private pendingSize: PtySize | undefined;
32
+ private appliedSize: PtySize | undefined;
33
+ private pumping = false;
34
+ private interactiveRenderPending = false;
35
+ private visible = false;
36
+ private reportedVisible: boolean | undefined;
37
+ private exitReported = false;
38
+ private disposed = false;
39
+ private onExit: (() => void) | undefined;
40
+ constructor(private readonly options: PtyProcessOptions) {
41
+ this.onExit = options.onExit;
42
+ }
43
+
44
+ /** Transfer exit ownership when a presentation host is reloaded. */
45
+ setOnExit(listener: (() => void) | undefined): void {
46
+ this.onExit = listener;
47
+ if (listener && this.exitReported) queueMicrotask(listener);
48
+ }
49
+
50
+ subscribeRender(listener: (interactive: boolean) => void): () => void {
51
+ this.renderListeners.add(listener);
52
+ return () => this.renderListeners.delete(listener);
53
+ }
54
+
55
+ private start(): void {
56
+ if (this.disposed || this.exitReported) return;
57
+ this.host ??= ensurePtyHost(this.options.scope);
58
+ const host = this.host;
59
+ if ((this.processId !== undefined && host.isRunning(this.processId)) || this.starting) return;
60
+ const initialSize = this.pendingSize;
61
+ this.starting = host
62
+ .spawn({
63
+ command: this.options.command,
64
+ cwd: this.options.context.cwd,
65
+ ...(initialSize ? { columns: initialSize.columns, rows: initialSize.rows } : {}),
66
+ })
67
+ .then((processId) => {
68
+ if (this.disposed) return void host.terminate(processId).catch(() => undefined);
69
+ this.processId = processId;
70
+ if (initialSize) {
71
+ this.appliedSize = initialSize;
72
+ if (samePtySize(this.pendingSize, initialSize)) this.pendingSize = undefined;
73
+ }
74
+ this.unsubscribe = host.subscribe(processId, () => this.hostUpdated());
75
+ this.hostUpdated();
76
+ this.schedulePump();
77
+ })
78
+ .catch((error) => this.fail(error))
79
+ .finally(() => {
80
+ this.starting = undefined;
81
+ });
82
+ }
83
+
84
+ render(columns: number, rows: number, cursor: boolean): readonly string[] {
85
+ this.queueResize({ columns, rows });
86
+ if (this.processId === undefined) {
87
+ void this.start();
88
+ return [`Starting ${this.options.label}…`];
89
+ }
90
+ return this.host?.render(this.processId, rows, cursor) ?? [];
91
+ }
92
+
93
+ sendInput(data: string): void {
94
+ if (!data || this.disposed) return;
95
+ this.pendingInput += data;
96
+ this.schedulePump();
97
+ }
98
+
99
+ setVisible(visible: boolean): void {
100
+ if (this.visible === visible) return;
101
+ this.visible = visible;
102
+ this.syncVisibility();
103
+ }
104
+
105
+ dispose(): void {
106
+ if (this.disposed) return;
107
+ this.disposed = true;
108
+ this.pendingInput = "";
109
+ this.pendingSize = undefined;
110
+ this.unsubscribe?.();
111
+ this.unsubscribe = undefined;
112
+ const processId = this.processId;
113
+ this.processId = undefined;
114
+ if (processId !== undefined) void this.host?.terminate(processId).catch(() => undefined);
115
+ }
116
+
117
+ private hostUpdated(): void {
118
+ this.syncVisibility();
119
+ const interactive = this.interactiveRenderPending;
120
+ this.interactiveRenderPending = false;
121
+ this.notifyRender(interactive);
122
+ const host = this.host;
123
+ if (!host) return;
124
+ if (!this.disposed && this.processId !== undefined && !host.isRunning(this.processId)) this.reportExit();
125
+ }
126
+
127
+ private notifyRender(interactive = false): void {
128
+ for (const listener of this.renderListeners) listener(interactive);
129
+ }
130
+
131
+ private syncVisibility(): void {
132
+ if (this.reportedVisible === this.visible || this.processId === undefined || this.disposed) return;
133
+ const host = this.host;
134
+ if (!host) return;
135
+ if (!host.acceptsFocusEvents(this.processId)) return;
136
+ this.reportedVisible = this.visible;
137
+ this.sendInput(this.visible ? "\x1b[I" : "\x1b[O");
138
+ }
139
+
140
+ private queueResize(size: PtySize): void {
141
+ if (samePtySize(this.pendingSize ?? this.appliedSize, size)) return;
142
+ this.pendingSize = size;
143
+ this.schedulePump();
144
+ }
145
+
146
+ private schedulePump(): void {
147
+ if (this.pumping || (!this.pendingSize && !this.pendingInput) || this.processId === undefined || this.disposed)
148
+ return;
149
+ this.pumping = true;
150
+ queueMicrotask(() => void this.pump());
151
+ }
152
+
153
+ private async pump(): Promise<void> {
154
+ try {
155
+ while ((this.pendingSize || this.pendingInput) && !this.disposed) {
156
+ const host = this.host;
157
+ if (this.processId === undefined) return;
158
+ if (!host) return;
159
+ const size = this.pendingSize;
160
+ this.pendingSize = undefined;
161
+ if (size && (await host.resize(this.processId, size.columns, size.rows))) this.appliedSize = size;
162
+ const data = this.pendingInput;
163
+ this.pendingInput = "";
164
+ if (data) {
165
+ // Mark latency-sensitive output only once the bytes are actually crossing
166
+ // the bridge; unrelated animation output must not consume this priority.
167
+ this.interactiveRenderPending = true;
168
+ await host.sendInput(this.processId, data);
169
+ }
170
+ }
171
+ } catch (error) {
172
+ this.notify(error);
173
+ } finally {
174
+ this.pumping = false;
175
+ this.schedulePump();
176
+ }
177
+ }
178
+
179
+ private fail(error: unknown): void {
180
+ if (this.disposed) return;
181
+ this.notify(error);
182
+ this.reportExit();
183
+ }
184
+
185
+ private reportExit(): void {
186
+ if (this.exitReported) return;
187
+ this.exitReported = true;
188
+ this.onExit?.();
189
+ }
190
+
191
+ private notify(error: unknown): void {
192
+ this.options.context.ui.notify(error instanceof Error ? error.message : String(error), "error");
193
+ }
194
+ }
195
+
196
+ export interface PtyPaneOptions {
197
+ readonly tui: TUI;
198
+ readonly rows: () => number;
199
+ readonly requestRender: () => void;
200
+ }
201
+
202
+ class ProjectedPtyScreen implements Component {
203
+ constructor(
204
+ private readonly process: PtyProcess,
205
+ private readonly cursor: () => boolean,
206
+ private readonly rows: () => number,
207
+ ) {}
208
+
209
+ render(width: number): string[] {
210
+ const rows = Math.max(1, this.rows());
211
+ const projected = this.process.render(width, rows, this.cursor());
212
+ if (projected.length >= rows) return projected.slice(-rows);
213
+ return [...Array.from({ length: rows - projected.length }, () => ""), ...projected];
214
+ }
215
+
216
+ invalidate(): void {}
217
+ }
218
+
219
+ /** Complete cursor, keyboard, pointer, and terminal projection for one PTY process. */
220
+ export class PtyPane extends ScrollView implements Focusable {
221
+ private _focused = false;
222
+ private previousHardwareCursor: boolean | undefined;
223
+ private readonly unsubscribe: () => void;
224
+ private readonly focusState: { value: boolean };
225
+
226
+ constructor(
227
+ private readonly process: PtyProcess,
228
+ private readonly options: PtyPaneOptions,
229
+ ) {
230
+ const focusState = { value: false };
231
+ const screen = new ProjectedPtyScreen(process, () => focusState.value, options.rows);
232
+ super(screen, {
233
+ follow: "end",
234
+ overscroll: "contain",
235
+ scrollbar: "hidden",
236
+ });
237
+ this.focusState = focusState;
238
+ this.unsubscribe = process.subscribeRender((interactive) => {
239
+ if (interactive && requestImmediateRender(options.tui)) return;
240
+ options.requestRender();
241
+ });
242
+ process.setVisible(true);
243
+ }
244
+
245
+ get focused(): boolean {
246
+ return this._focused;
247
+ }
248
+
249
+ set focused(value: boolean) {
250
+ this._focused = value;
251
+ this.focusState.value = value;
252
+ if (value) {
253
+ this.previousHardwareCursor ??= this.options.tui.getShowHardwareCursor();
254
+ this.options.tui.setShowHardwareCursor(true);
255
+ return;
256
+ }
257
+ if (this.previousHardwareCursor === undefined) return;
258
+ this.options.tui.setShowHardwareCursor(this.previousHardwareCursor);
259
+ this.previousHardwareCursor = undefined;
260
+ }
261
+
262
+ handleInput(data: string): void {
263
+ this.process.sendInput(data);
264
+ }
265
+
266
+ defersInputRender(): boolean {
267
+ return true;
268
+ }
269
+
270
+ onMouse(event: TuiMouseEvent): boolean {
271
+ const input = encodePointer(event);
272
+ if (!input) return false;
273
+ this.process.sendInput(input);
274
+ return true;
275
+ }
276
+
277
+ dispose(): void {
278
+ this.focused = false;
279
+ this.unsubscribe();
280
+ this.process.setVisible(false);
281
+ }
282
+ }
283
+
284
+ // type-boundary: Pi's TUI currently keeps its latency-sensitive render path private;
285
+ // the PTY surface uses it only for the first child frame caused by keyboard input.
286
+ type InteractiveTui = TUI & { requestImmediateRender?: () => void };
287
+
288
+ function requestImmediateRender(tui: TUI): boolean {
289
+ const render = (tui as InteractiveTui).requestImmediateRender;
290
+ if (typeof render !== "function") return false;
291
+ render.call(tui);
292
+ return true;
293
+ }
294
+
295
+ function encodePointer(event: TuiMouseEvent): string | undefined {
296
+ const modifiers = Number(event.shift) * 4 + Number(event.alt) * 8 + Number(event.ctrl) * 16;
297
+ if (event.type === "wheel" && event.wheel !== undefined) {
298
+ const button = event.wheel > 0 ? 65 : 64;
299
+ return `\x1b[<${button + modifiers};${event.col + 1};${event.row + 1}M`.repeat(Math.abs(event.wheel));
300
+ }
301
+ if (event.type === "press" && event.button !== undefined)
302
+ return `\x1b[<${event.button + modifiers};${event.col + 1};${event.row + 1}M`;
303
+ if (event.type === "drag" && event.button !== undefined)
304
+ return `\x1b[<${32 + event.button + modifiers};${event.col + 1};${event.row + 1}M`;
305
+ if (event.type === "release") return `\x1b[<${(event.button ?? 0) + modifiers};${event.col + 1};${event.row + 1}m`;
306
+ if (event.type === "move") return `\x1b[<${35 + modifiers};${event.col + 1};${event.row + 1}M`;
307
+ return undefined;
308
+ }