@mariozechner/pi-tui 0.37.8 → 0.39.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.
package/dist/terminal.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { setKittyProtocolActive } from "./keys.js";
2
+ import { StdinBuffer } from "./stdin-buffer.js";
2
3
  /**
3
4
  * Real terminal using process.stdin/stdout
4
5
  */
@@ -7,6 +8,8 @@ export class ProcessTerminal {
7
8
  inputHandler;
8
9
  resizeHandler;
9
10
  _kittyProtocolActive = false;
11
+ stdinBuffer;
12
+ stdinDataHandler;
10
13
  get kittyProtocolActive() {
11
14
  return this._kittyProtocolActive;
12
15
  }
@@ -29,6 +32,31 @@ export class ProcessTerminal {
29
32
  // See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/
30
33
  this.queryAndEnableKittyProtocol();
31
34
  }
35
+ /**
36
+ * Set up StdinBuffer to split batched input into individual sequences.
37
+ * This ensures components receive single events, making matchesKey/isKeyRelease work correctly.
38
+ * Note: Does NOT register the stdin handler - that's done after the Kitty protocol query.
39
+ */
40
+ setupStdinBuffer() {
41
+ this.stdinBuffer = new StdinBuffer({ timeout: 10 });
42
+ // Forward individual sequences to the input handler
43
+ this.stdinBuffer.on("data", (sequence) => {
44
+ if (this.inputHandler) {
45
+ this.inputHandler(sequence);
46
+ }
47
+ });
48
+ // Re-wrap paste content with bracketed paste markers for existing editor handling
49
+ this.stdinBuffer.on("paste", (content) => {
50
+ if (this.inputHandler) {
51
+ this.inputHandler(`\x1b[200~${content}\x1b[201~`);
52
+ }
53
+ });
54
+ // Handler that pipes stdin data through the buffer
55
+ // Registration happens after Kitty protocol query completes
56
+ this.stdinDataHandler = (data) => {
57
+ this.stdinBuffer.process(data);
58
+ };
59
+ }
32
60
  /**
33
61
  * Query terminal for Kitty keyboard protocol support and enable if available.
34
62
  *
@@ -45,9 +73,9 @@ export class ProcessTerminal {
45
73
  const kittyResponsePattern = /\x1b\[\?(\d+)u/;
46
74
  const queryHandler = (data) => {
47
75
  if (resolved) {
48
- // Query phase done, forward to user handler
49
- if (this.inputHandler) {
50
- this.inputHandler(data);
76
+ // Query phase done, forward to StdinBuffer
77
+ if (this.stdinBuffer) {
78
+ this.stdinBuffer.process(data);
51
79
  }
52
80
  return;
53
81
  }
@@ -62,19 +90,21 @@ export class ProcessTerminal {
62
90
  // Flag 1 = disambiguate escape codes
63
91
  // Flag 2 = report event types (press/repeat/release)
64
92
  process.stdout.write("\x1b[>3u");
65
- // Remove the response from buffer, forward any remaining input
93
+ // Remove the response from buffer, forward any remaining input through StdinBuffer
66
94
  const remaining = buffer.replace(kittyResponsePattern, "");
67
- if (remaining && this.inputHandler) {
68
- this.inputHandler(remaining);
95
+ if (remaining && this.stdinBuffer) {
96
+ this.stdinBuffer.process(remaining);
69
97
  }
70
- // Replace with user handler
98
+ // Replace query handler with StdinBuffer handler
71
99
  process.stdin.removeListener("data", queryHandler);
72
- if (this.inputHandler) {
73
- process.stdin.on("data", this.inputHandler);
100
+ if (this.stdinDataHandler) {
101
+ process.stdin.on("data", this.stdinDataHandler);
74
102
  }
75
103
  }
76
104
  };
77
- // Temporarily intercept input for the query
105
+ // Set up StdinBuffer before query (it will receive input after query completes)
106
+ this.setupStdinBuffer();
107
+ // Temporarily intercept input for the query (before StdinBuffer)
78
108
  process.stdin.on("data", queryHandler);
79
109
  // Send query
80
110
  process.stdout.write("\x1b[?u");
@@ -84,14 +114,14 @@ export class ProcessTerminal {
84
114
  resolved = true;
85
115
  this._kittyProtocolActive = false;
86
116
  setKittyProtocolActive(false);
87
- // Forward any buffered input that wasn't a Kitty response
88
- if (buffer && this.inputHandler) {
89
- this.inputHandler(buffer);
117
+ // Forward any buffered input that wasn't a Kitty response through StdinBuffer
118
+ if (buffer && this.stdinBuffer) {
119
+ this.stdinBuffer.process(buffer);
90
120
  }
91
- // Replace with user handler
121
+ // Replace query handler with StdinBuffer handler
92
122
  process.stdin.removeListener("data", queryHandler);
93
- if (this.inputHandler) {
94
- process.stdin.on("data", this.inputHandler);
123
+ if (this.stdinDataHandler) {
124
+ process.stdin.on("data", this.stdinDataHandler);
95
125
  }
96
126
  }
97
127
  }, QUERY_TIMEOUT_MS);
@@ -105,11 +135,17 @@ export class ProcessTerminal {
105
135
  this._kittyProtocolActive = false;
106
136
  setKittyProtocolActive(false);
107
137
  }
138
+ // Clean up StdinBuffer
139
+ if (this.stdinBuffer) {
140
+ this.stdinBuffer.destroy();
141
+ this.stdinBuffer = undefined;
142
+ }
108
143
  // Remove event handlers
109
- if (this.inputHandler) {
110
- process.stdin.removeListener("data", this.inputHandler);
111
- this.inputHandler = undefined;
144
+ if (this.stdinDataHandler) {
145
+ process.stdin.removeListener("data", this.stdinDataHandler);
146
+ this.stdinDataHandler = undefined;
112
147
  }
148
+ this.inputHandler = undefined;
113
149
  if (this.resizeHandler) {
114
150
  process.stdout.removeListener("resize", this.resizeHandler);
115
151
  this.resizeHandler = undefined;
@@ -1 +1 @@
1
- {"version":3,"file":"terminal.js","sourceRoot":"","sources":["../src/terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { setKittyProtocolActive } from \"./keys.js\";\n\n/**\n * Minimal terminal interface for TUI\n */\nexport interface Terminal {\n\t// Start the terminal with input and resize handlers\n\tstart(onInput: (data: string) => void, onResize: () => void): void;\n\n\t// Stop the terminal and restore state\n\tstop(): void;\n\n\t// Write output to terminal\n\twrite(data: string): void;\n\n\t// Get terminal dimensions\n\tget columns(): number;\n\tget rows(): number;\n\n\t// Whether Kitty keyboard protocol is active\n\tget kittyProtocolActive(): boolean;\n\n\t// Cursor positioning (relative to current position)\n\tmoveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines\n\n\t// Cursor visibility\n\thideCursor(): void; // Hide the cursor\n\tshowCursor(): void; // Show the cursor\n\n\t// Clear operations\n\tclearLine(): void; // Clear current line\n\tclearFromCursor(): void; // Clear from cursor to end of screen\n\tclearScreen(): void; // Clear entire screen and move cursor to (0,0)\n\n\t// Title operations\n\tsetTitle(title: string): void; // Set terminal window title\n}\n\n/**\n * Real terminal using process.stdin/stdout\n */\nexport class ProcessTerminal implements Terminal {\n\tprivate wasRaw = false;\n\tprivate inputHandler?: (data: string) => void;\n\tprivate resizeHandler?: () => void;\n\tprivate _kittyProtocolActive = false;\n\n\tget kittyProtocolActive(): boolean {\n\t\treturn this._kittyProtocolActive;\n\t}\n\n\tstart(onInput: (data: string) => void, onResize: () => void): void {\n\t\tthis.inputHandler = onInput;\n\t\tthis.resizeHandler = onResize;\n\n\t\t// Save previous state and enable raw mode\n\t\tthis.wasRaw = process.stdin.isRaw || false;\n\t\tif (process.stdin.setRawMode) {\n\t\t\tprocess.stdin.setRawMode(true);\n\t\t}\n\t\tprocess.stdin.setEncoding(\"utf8\");\n\t\tprocess.stdin.resume();\n\n\t\t// Enable bracketed paste mode - terminal will wrap pastes in \\x1b[200~ ... \\x1b[201~\n\t\tprocess.stdout.write(\"\\x1b[?2004h\");\n\n\t\t// Set up resize handler immediately\n\t\tprocess.stdout.on(\"resize\", this.resizeHandler);\n\n\t\t// Query and enable Kitty keyboard protocol\n\t\t// The query handler intercepts input temporarily, then installs the user's handler\n\t\t// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/\n\t\tthis.queryAndEnableKittyProtocol();\n\t}\n\n\t/**\n\t * Query terminal for Kitty keyboard protocol support and enable if available.\n\t *\n\t * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,\n\t * it supports the protocol and we enable it with CSI > 1 u.\n\t *\n\t * Non-supporting terminals won't respond, so we use a timeout.\n\t */\n\tprivate queryAndEnableKittyProtocol(): void {\n\t\tconst QUERY_TIMEOUT_MS = 100;\n\t\tlet resolved = false;\n\t\tlet buffer = \"\";\n\n\t\t// Kitty protocol response pattern: \\x1b[?<flags>u\n\t\tconst kittyResponsePattern = /\\x1b\\[\\?(\\d+)u/;\n\n\t\tconst queryHandler = (data: string) => {\n\t\t\tif (resolved) {\n\t\t\t\t// Query phase done, forward to user handler\n\t\t\t\tif (this.inputHandler) {\n\t\t\t\t\tthis.inputHandler(data);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tbuffer += data;\n\n\t\t\t// Check if we have a Kitty protocol response\n\t\t\tconst match = buffer.match(kittyResponsePattern);\n\t\t\tif (match) {\n\t\t\t\tresolved = true;\n\t\t\t\tthis._kittyProtocolActive = true;\n\t\t\t\tsetKittyProtocolActive(true);\n\n\t\t\t\t// Enable Kitty keyboard protocol (push flags)\n\t\t\t\t// Flag 1 = disambiguate escape codes\n\t\t\t\t// Flag 2 = report event types (press/repeat/release)\n\t\t\t\tprocess.stdout.write(\"\\x1b[>3u\");\n\n\t\t\t\t// Remove the response from buffer, forward any remaining input\n\t\t\t\tconst remaining = buffer.replace(kittyResponsePattern, \"\");\n\t\t\t\tif (remaining && this.inputHandler) {\n\t\t\t\t\tthis.inputHandler(remaining);\n\t\t\t\t}\n\n\t\t\t\t// Replace with user handler\n\t\t\t\tprocess.stdin.removeListener(\"data\", queryHandler);\n\t\t\t\tif (this.inputHandler) {\n\t\t\t\t\tprocess.stdin.on(\"data\", this.inputHandler);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// Temporarily intercept input for the query\n\t\tprocess.stdin.on(\"data\", queryHandler);\n\n\t\t// Send query\n\t\tprocess.stdout.write(\"\\x1b[?u\");\n\n\t\t// Timeout: if no response, terminal doesn't support Kitty protocol\n\t\tsetTimeout(() => {\n\t\t\tif (!resolved) {\n\t\t\t\tresolved = true;\n\t\t\t\tthis._kittyProtocolActive = false;\n\t\t\t\tsetKittyProtocolActive(false);\n\n\t\t\t\t// Forward any buffered input that wasn't a Kitty response\n\t\t\t\tif (buffer && this.inputHandler) {\n\t\t\t\t\tthis.inputHandler(buffer);\n\t\t\t\t}\n\n\t\t\t\t// Replace with user handler\n\t\t\t\tprocess.stdin.removeListener(\"data\", queryHandler);\n\t\t\t\tif (this.inputHandler) {\n\t\t\t\t\tprocess.stdin.on(\"data\", this.inputHandler);\n\t\t\t\t}\n\t\t\t}\n\t\t}, QUERY_TIMEOUT_MS);\n\t}\n\n\tstop(): void {\n\t\t// Disable bracketed paste mode\n\t\tprocess.stdout.write(\"\\x1b[?2004l\");\n\n\t\t// Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it\n\t\tif (this._kittyProtocolActive) {\n\t\t\tprocess.stdout.write(\"\\x1b[<u\");\n\t\t\tthis._kittyProtocolActive = false;\n\t\t\tsetKittyProtocolActive(false);\n\t\t}\n\n\t\t// Remove event handlers\n\t\tif (this.inputHandler) {\n\t\t\tprocess.stdin.removeListener(\"data\", this.inputHandler);\n\t\t\tthis.inputHandler = undefined;\n\t\t}\n\t\tif (this.resizeHandler) {\n\t\t\tprocess.stdout.removeListener(\"resize\", this.resizeHandler);\n\t\t\tthis.resizeHandler = undefined;\n\t\t}\n\n\t\t// Restore raw mode state\n\t\tif (process.stdin.setRawMode) {\n\t\t\tprocess.stdin.setRawMode(this.wasRaw);\n\t\t}\n\t}\n\n\twrite(data: string): void {\n\t\tprocess.stdout.write(data);\n\t}\n\n\tget columns(): number {\n\t\treturn process.stdout.columns || 80;\n\t}\n\n\tget rows(): number {\n\t\treturn process.stdout.rows || 24;\n\t}\n\n\tmoveBy(lines: number): void {\n\t\tif (lines > 0) {\n\t\t\t// Move down\n\t\t\tprocess.stdout.write(`\\x1b[${lines}B`);\n\t\t} else if (lines < 0) {\n\t\t\t// Move up\n\t\t\tprocess.stdout.write(`\\x1b[${-lines}A`);\n\t\t}\n\t\t// lines === 0: no movement\n\t}\n\n\thideCursor(): void {\n\t\tprocess.stdout.write(\"\\x1b[?25l\");\n\t}\n\n\tshowCursor(): void {\n\t\tprocess.stdout.write(\"\\x1b[?25h\");\n\t}\n\n\tclearLine(): void {\n\t\tprocess.stdout.write(\"\\x1b[K\");\n\t}\n\n\tclearFromCursor(): void {\n\t\tprocess.stdout.write(\"\\x1b[J\");\n\t}\n\n\tclearScreen(): void {\n\t\tprocess.stdout.write(\"\\x1b[2J\\x1b[H\"); // Clear screen and move to home (1,1)\n\t}\n\n\tsetTitle(title: string): void {\n\t\t// OSC 0;title BEL - set terminal window title\n\t\tprocess.stdout.write(`\\x1b]0;${title}\\x07`);\n\t}\n}\n"]}
1
+ {"version":3,"file":"terminal.js","sourceRoot":"","sources":["../src/terminal.ts"],"names":[],"mappings":"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","sourcesContent":["import { setKittyProtocolActive } from \"./keys.js\";\nimport { StdinBuffer } from \"./stdin-buffer.js\";\n\n/**\n * Minimal terminal interface for TUI\n */\nexport interface Terminal {\n\t// Start the terminal with input and resize handlers\n\tstart(onInput: (data: string) => void, onResize: () => void): void;\n\n\t// Stop the terminal and restore state\n\tstop(): void;\n\n\t// Write output to terminal\n\twrite(data: string): void;\n\n\t// Get terminal dimensions\n\tget columns(): number;\n\tget rows(): number;\n\n\t// Whether Kitty keyboard protocol is active\n\tget kittyProtocolActive(): boolean;\n\n\t// Cursor positioning (relative to current position)\n\tmoveBy(lines: number): void; // Move cursor up (negative) or down (positive) by N lines\n\n\t// Cursor visibility\n\thideCursor(): void; // Hide the cursor\n\tshowCursor(): void; // Show the cursor\n\n\t// Clear operations\n\tclearLine(): void; // Clear current line\n\tclearFromCursor(): void; // Clear from cursor to end of screen\n\tclearScreen(): void; // Clear entire screen and move cursor to (0,0)\n\n\t// Title operations\n\tsetTitle(title: string): void; // Set terminal window title\n}\n\n/**\n * Real terminal using process.stdin/stdout\n */\nexport class ProcessTerminal implements Terminal {\n\tprivate wasRaw = false;\n\tprivate inputHandler?: (data: string) => void;\n\tprivate resizeHandler?: () => void;\n\tprivate _kittyProtocolActive = false;\n\tprivate stdinBuffer?: StdinBuffer;\n\tprivate stdinDataHandler?: (data: string) => void;\n\n\tget kittyProtocolActive(): boolean {\n\t\treturn this._kittyProtocolActive;\n\t}\n\n\tstart(onInput: (data: string) => void, onResize: () => void): void {\n\t\tthis.inputHandler = onInput;\n\t\tthis.resizeHandler = onResize;\n\n\t\t// Save previous state and enable raw mode\n\t\tthis.wasRaw = process.stdin.isRaw || false;\n\t\tif (process.stdin.setRawMode) {\n\t\t\tprocess.stdin.setRawMode(true);\n\t\t}\n\t\tprocess.stdin.setEncoding(\"utf8\");\n\t\tprocess.stdin.resume();\n\n\t\t// Enable bracketed paste mode - terminal will wrap pastes in \\x1b[200~ ... \\x1b[201~\n\t\tprocess.stdout.write(\"\\x1b[?2004h\");\n\n\t\t// Set up resize handler immediately\n\t\tprocess.stdout.on(\"resize\", this.resizeHandler);\n\n\t\t// Query and enable Kitty keyboard protocol\n\t\t// The query handler intercepts input temporarily, then installs the user's handler\n\t\t// See: https://sw.kovidgoyal.net/kitty/keyboard-protocol/\n\t\tthis.queryAndEnableKittyProtocol();\n\t}\n\n\t/**\n\t * Set up StdinBuffer to split batched input into individual sequences.\n\t * This ensures components receive single events, making matchesKey/isKeyRelease work correctly.\n\t * Note: Does NOT register the stdin handler - that's done after the Kitty protocol query.\n\t */\n\tprivate setupStdinBuffer(): void {\n\t\tthis.stdinBuffer = new StdinBuffer({ timeout: 10 });\n\n\t\t// Forward individual sequences to the input handler\n\t\tthis.stdinBuffer.on(\"data\", (sequence) => {\n\t\t\tif (this.inputHandler) {\n\t\t\t\tthis.inputHandler(sequence);\n\t\t\t}\n\t\t});\n\n\t\t// Re-wrap paste content with bracketed paste markers for existing editor handling\n\t\tthis.stdinBuffer.on(\"paste\", (content) => {\n\t\t\tif (this.inputHandler) {\n\t\t\t\tthis.inputHandler(`\\x1b[200~${content}\\x1b[201~`);\n\t\t\t}\n\t\t});\n\n\t\t// Handler that pipes stdin data through the buffer\n\t\t// Registration happens after Kitty protocol query completes\n\t\tthis.stdinDataHandler = (data: string) => {\n\t\t\tthis.stdinBuffer!.process(data);\n\t\t};\n\t}\n\n\t/**\n\t * Query terminal for Kitty keyboard protocol support and enable if available.\n\t *\n\t * Sends CSI ? u to query current flags. If terminal responds with CSI ? <flags> u,\n\t * it supports the protocol and we enable it with CSI > 1 u.\n\t *\n\t * Non-supporting terminals won't respond, so we use a timeout.\n\t */\n\tprivate queryAndEnableKittyProtocol(): void {\n\t\tconst QUERY_TIMEOUT_MS = 100;\n\t\tlet resolved = false;\n\t\tlet buffer = \"\";\n\n\t\t// Kitty protocol response pattern: \\x1b[?<flags>u\n\t\tconst kittyResponsePattern = /\\x1b\\[\\?(\\d+)u/;\n\n\t\tconst queryHandler = (data: string) => {\n\t\t\tif (resolved) {\n\t\t\t\t// Query phase done, forward to StdinBuffer\n\t\t\t\tif (this.stdinBuffer) {\n\t\t\t\t\tthis.stdinBuffer.process(data);\n\t\t\t\t}\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tbuffer += data;\n\n\t\t\t// Check if we have a Kitty protocol response\n\t\t\tconst match = buffer.match(kittyResponsePattern);\n\t\t\tif (match) {\n\t\t\t\tresolved = true;\n\t\t\t\tthis._kittyProtocolActive = true;\n\t\t\t\tsetKittyProtocolActive(true);\n\n\t\t\t\t// Enable Kitty keyboard protocol (push flags)\n\t\t\t\t// Flag 1 = disambiguate escape codes\n\t\t\t\t// Flag 2 = report event types (press/repeat/release)\n\t\t\t\tprocess.stdout.write(\"\\x1b[>3u\");\n\n\t\t\t\t// Remove the response from buffer, forward any remaining input through StdinBuffer\n\t\t\t\tconst remaining = buffer.replace(kittyResponsePattern, \"\");\n\t\t\t\tif (remaining && this.stdinBuffer) {\n\t\t\t\t\tthis.stdinBuffer.process(remaining);\n\t\t\t\t}\n\n\t\t\t\t// Replace query handler with StdinBuffer handler\n\t\t\t\tprocess.stdin.removeListener(\"data\", queryHandler);\n\t\t\t\tif (this.stdinDataHandler) {\n\t\t\t\t\tprocess.stdin.on(\"data\", this.stdinDataHandler);\n\t\t\t\t}\n\t\t\t}\n\t\t};\n\n\t\t// Set up StdinBuffer before query (it will receive input after query completes)\n\t\tthis.setupStdinBuffer();\n\n\t\t// Temporarily intercept input for the query (before StdinBuffer)\n\t\tprocess.stdin.on(\"data\", queryHandler);\n\n\t\t// Send query\n\t\tprocess.stdout.write(\"\\x1b[?u\");\n\n\t\t// Timeout: if no response, terminal doesn't support Kitty protocol\n\t\tsetTimeout(() => {\n\t\t\tif (!resolved) {\n\t\t\t\tresolved = true;\n\t\t\t\tthis._kittyProtocolActive = false;\n\t\t\t\tsetKittyProtocolActive(false);\n\n\t\t\t\t// Forward any buffered input that wasn't a Kitty response through StdinBuffer\n\t\t\t\tif (buffer && this.stdinBuffer) {\n\t\t\t\t\tthis.stdinBuffer.process(buffer);\n\t\t\t\t}\n\n\t\t\t\t// Replace query handler with StdinBuffer handler\n\t\t\t\tprocess.stdin.removeListener(\"data\", queryHandler);\n\t\t\t\tif (this.stdinDataHandler) {\n\t\t\t\t\tprocess.stdin.on(\"data\", this.stdinDataHandler);\n\t\t\t\t}\n\t\t\t}\n\t\t}, QUERY_TIMEOUT_MS);\n\t}\n\n\tstop(): void {\n\t\t// Disable bracketed paste mode\n\t\tprocess.stdout.write(\"\\x1b[?2004l\");\n\n\t\t// Disable Kitty keyboard protocol (pop the flags we pushed) - only if we enabled it\n\t\tif (this._kittyProtocolActive) {\n\t\t\tprocess.stdout.write(\"\\x1b[<u\");\n\t\t\tthis._kittyProtocolActive = false;\n\t\t\tsetKittyProtocolActive(false);\n\t\t}\n\n\t\t// Clean up StdinBuffer\n\t\tif (this.stdinBuffer) {\n\t\t\tthis.stdinBuffer.destroy();\n\t\t\tthis.stdinBuffer = undefined;\n\t\t}\n\n\t\t// Remove event handlers\n\t\tif (this.stdinDataHandler) {\n\t\t\tprocess.stdin.removeListener(\"data\", this.stdinDataHandler);\n\t\t\tthis.stdinDataHandler = undefined;\n\t\t}\n\t\tthis.inputHandler = undefined;\n\t\tif (this.resizeHandler) {\n\t\t\tprocess.stdout.removeListener(\"resize\", this.resizeHandler);\n\t\t\tthis.resizeHandler = undefined;\n\t\t}\n\n\t\t// Restore raw mode state\n\t\tif (process.stdin.setRawMode) {\n\t\t\tprocess.stdin.setRawMode(this.wasRaw);\n\t\t}\n\t}\n\n\twrite(data: string): void {\n\t\tprocess.stdout.write(data);\n\t}\n\n\tget columns(): number {\n\t\treturn process.stdout.columns || 80;\n\t}\n\n\tget rows(): number {\n\t\treturn process.stdout.rows || 24;\n\t}\n\n\tmoveBy(lines: number): void {\n\t\tif (lines > 0) {\n\t\t\t// Move down\n\t\t\tprocess.stdout.write(`\\x1b[${lines}B`);\n\t\t} else if (lines < 0) {\n\t\t\t// Move up\n\t\t\tprocess.stdout.write(`\\x1b[${-lines}A`);\n\t\t}\n\t\t// lines === 0: no movement\n\t}\n\n\thideCursor(): void {\n\t\tprocess.stdout.write(\"\\x1b[?25l\");\n\t}\n\n\tshowCursor(): void {\n\t\tprocess.stdout.write(\"\\x1b[?25h\");\n\t}\n\n\tclearLine(): void {\n\t\tprocess.stdout.write(\"\\x1b[K\");\n\t}\n\n\tclearFromCursor(): void {\n\t\tprocess.stdout.write(\"\\x1b[J\");\n\t}\n\n\tclearScreen(): void {\n\t\tprocess.stdout.write(\"\\x1b[2J\\x1b[H\"); // Clear screen and move to home (1,1)\n\t}\n\n\tsetTitle(title: string): void {\n\t\t// OSC 0;title BEL - set terminal window title\n\t\tprocess.stdout.write(`\\x1b]0;${title}\\x07`);\n\t}\n}\n"]}
package/dist/tui.d.ts CHANGED
@@ -54,8 +54,19 @@ export declare class TUI extends Container {
54
54
  private cursorRow;
55
55
  private inputBuffer;
56
56
  private cellSizeQueryPending;
57
+ private overlayStack;
57
58
  constructor(terminal: Terminal);
58
59
  setFocus(component: Component | null): void;
60
+ /** Show an overlay component centered (or at specified position). */
61
+ showOverlay(component: Component, options?: {
62
+ row?: number;
63
+ col?: number;
64
+ width?: number;
65
+ }): void;
66
+ /** Hide the topmost overlay and restore previous focus. */
67
+ hideOverlay(): void;
68
+ hasOverlay(): boolean;
69
+ invalidate(): void;
59
70
  start(): void;
60
71
  private queryCellSize;
61
72
  stop(): void;
@@ -63,6 +74,11 @@ export declare class TUI extends Container {
63
74
  private handleInput;
64
75
  private parseCellSizeResponse;
65
76
  private containsImage;
77
+ /** Composite all overlays into content lines (in stack order, later = on top). */
78
+ private compositeOverlays;
79
+ private static readonly SEGMENT_RESET;
80
+ /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
81
+ private compositeLineAt;
66
82
  private doRender;
67
83
  }
68
84
  //# sourceMappingURL=tui.d.ts.map
package/dist/tui.d.ts.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1C;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAEhC;;OAEG;IACH,WAAW,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAEjC;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI,CAAC;CACnB;AAED,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB;;GAEG;AACH,qBAAa,SAAU,YAAW,SAAS;IAC1C,QAAQ,EAAE,SAAS,EAAE,CAAM;IAE3B,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAEnC;IAED,WAAW,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAKtC;IAED,KAAK,IAAI,IAAI,CAEZ;IAED,UAAU,IAAI,IAAI,CAIjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAM9B;CACD;AAED;;GAEG;AACH,qBAAa,GAAI,SAAQ,SAAS;IAC1B,QAAQ,EAAE,QAAQ,CAAC;IAC1B,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,gBAAgB,CAA0B;IAElD,2GAA2G;IACpG,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,oBAAoB,CAAS;IAErC,YAAY,QAAQ,EAAE,QAAQ,EAG7B;IAED,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,GAAG,IAAI,CAE1C;IAED,KAAK,IAAI,IAAI,CAQZ;IAED,OAAO,CAAC,aAAa;IAWrB,IAAI,IAAI,IAAI,CAGX;IAED,aAAa,CAAC,KAAK,UAAQ,GAAG,IAAI,CAYjC;IAED,OAAO,CAAC,WAAW;IA2BnB,OAAO,CAAC,qBAAqB;IA0C7B,OAAO,CAAC,aAAa;IAIrB,OAAO,CAAC,QAAQ;CA+IhB","sourcesContent":["/**\n * Minimal TUI implementation with differential rendering\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { isKeyRelease, matchesKey } from \"./keys.js\";\nimport type { Terminal } from \"./terminal.js\";\nimport { getCapabilities, setCellDimensions } from \"./terminal-image.js\";\nimport { visibleWidth } from \"./utils.js\";\n\n/**\n * Component interface - all components must implement this\n */\nexport interface Component {\n\t/**\n\t * Render the component to lines for the given viewport width\n\t * @param width - Current viewport width\n\t * @returns Array of strings, each representing a line\n\t */\n\trender(width: number): string[];\n\n\t/**\n\t * Optional handler for keyboard input when component has focus\n\t */\n\thandleInput?(data: string): void;\n\n\t/**\n\t * If true, component receives key release events (Kitty protocol).\n\t * Default is false - release events are filtered out.\n\t */\n\twantsKeyRelease?: boolean;\n\n\t/**\n\t * Invalidate any cached rendering state.\n\t * Called when theme changes or when component needs to re-render from scratch.\n\t */\n\tinvalidate(): void;\n}\n\nexport { visibleWidth };\n\n/**\n * Container - a component that contains other components\n */\nexport class Container implements Component {\n\tchildren: Component[] = [];\n\n\taddChild(component: Component): void {\n\t\tthis.children.push(component);\n\t}\n\n\tremoveChild(component: Component): void {\n\t\tconst index = this.children.indexOf(component);\n\t\tif (index !== -1) {\n\t\t\tthis.children.splice(index, 1);\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.children = [];\n\t}\n\n\tinvalidate(): void {\n\t\tfor (const child of this.children) {\n\t\t\tchild.invalidate?.();\n\t\t}\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tfor (const child of this.children) {\n\t\t\tlines.push(...child.render(width));\n\t\t}\n\t\treturn lines;\n\t}\n}\n\n/**\n * TUI - Main class for managing terminal UI with differential rendering\n */\nexport class TUI extends Container {\n\tpublic terminal: Terminal;\n\tprivate previousLines: string[] = [];\n\tprivate previousWidth = 0;\n\tprivate focusedComponent: Component | null = null;\n\n\t/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */\n\tpublic onDebug?: () => void;\n\tprivate renderRequested = false;\n\tprivate cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)\n\tprivate inputBuffer = \"\"; // Buffer for parsing terminal responses\n\tprivate cellSizeQueryPending = false;\n\n\tconstructor(terminal: Terminal) {\n\t\tsuper();\n\t\tthis.terminal = terminal;\n\t}\n\n\tsetFocus(component: Component | null): void {\n\t\tthis.focusedComponent = component;\n\t}\n\n\tstart(): void {\n\t\tthis.terminal.start(\n\t\t\t(data) => this.handleInput(data),\n\t\t\t() => this.requestRender(),\n\t\t);\n\t\tthis.terminal.hideCursor();\n\t\tthis.queryCellSize();\n\t\tthis.requestRender();\n\t}\n\n\tprivate queryCellSize(): void {\n\t\t// Only query if terminal supports images (cell size is only used for image rendering)\n\t\tif (!getCapabilities().images) {\n\t\t\treturn;\n\t\t}\n\t\t// Query terminal for cell size in pixels: CSI 16 t\n\t\t// Response format: CSI 6 ; height ; width t\n\t\tthis.cellSizeQueryPending = true;\n\t\tthis.terminal.write(\"\\x1b[16t\");\n\t}\n\n\tstop(): void {\n\t\tthis.terminal.showCursor();\n\t\tthis.terminal.stop();\n\t}\n\n\trequestRender(force = false): void {\n\t\tif (force) {\n\t\t\tthis.previousLines = [];\n\t\t\tthis.previousWidth = 0;\n\t\t\tthis.cursorRow = 0;\n\t\t}\n\t\tif (this.renderRequested) return;\n\t\tthis.renderRequested = true;\n\t\tprocess.nextTick(() => {\n\t\t\tthis.renderRequested = false;\n\t\t\tthis.doRender();\n\t\t});\n\t}\n\n\tprivate handleInput(data: string): void {\n\t\t// If we're waiting for cell size response, buffer input and parse\n\t\tif (this.cellSizeQueryPending) {\n\t\t\tthis.inputBuffer += data;\n\t\t\tconst filtered = this.parseCellSizeResponse();\n\t\t\tif (filtered.length === 0) return;\n\t\t\tdata = filtered;\n\t\t}\n\n\t\t// Global debug key handler (Shift+Ctrl+D)\n\t\tif (matchesKey(data, \"shift+ctrl+d\") && this.onDebug) {\n\t\t\tthis.onDebug();\n\t\t\treturn;\n\t\t}\n\n\t\t// Pass input to focused component (including Ctrl+C)\n\t\t// The focused component can decide how to handle Ctrl+C\n\t\tif (this.focusedComponent?.handleInput) {\n\t\t\t// Filter out key release events unless component opts in\n\t\t\tif (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.focusedComponent.handleInput(data);\n\t\t\tthis.requestRender();\n\t\t}\n\t}\n\n\tprivate parseCellSizeResponse(): string {\n\t\t// Response format: ESC [ 6 ; height ; width t\n\t\t// Match the response pattern\n\t\tconst responsePattern = /\\x1b\\[6;(\\d+);(\\d+)t/;\n\t\tconst match = this.inputBuffer.match(responsePattern);\n\n\t\tif (match) {\n\t\t\tconst heightPx = parseInt(match[1], 10);\n\t\t\tconst widthPx = parseInt(match[2], 10);\n\n\t\t\tif (heightPx > 0 && widthPx > 0) {\n\t\t\t\tsetCellDimensions({ widthPx, heightPx });\n\t\t\t\t// Invalidate all components so images re-render with correct dimensions\n\t\t\t\tthis.invalidate();\n\t\t\t\tthis.requestRender();\n\t\t\t}\n\n\t\t\t// Remove the response from buffer\n\t\t\tthis.inputBuffer = this.inputBuffer.replace(responsePattern, \"\");\n\t\t\tthis.cellSizeQueryPending = false;\n\t\t}\n\n\t\t// Check if we have a partial cell size response starting (wait for more data)\n\t\t// Patterns that could be incomplete cell size response: \\x1b, \\x1b[, \\x1b[6, \\x1b[6;...(no t yet)\n\t\tconst partialCellSizePattern = /\\x1b(\\[6?;?[\\d;]*)?$/;\n\t\tif (partialCellSizePattern.test(this.inputBuffer)) {\n\t\t\t// Check if it's actually a complete different escape sequence (ends with a letter)\n\t\t\t// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.\n\t\t\tconst lastChar = this.inputBuffer[this.inputBuffer.length - 1];\n\t\t\tif (!/[a-zA-Z~]/.test(lastChar)) {\n\t\t\t\t// Doesn't end with a terminator, might be incomplete - wait for more\n\t\t\t\treturn \"\";\n\t\t\t}\n\t\t}\n\n\t\t// No cell size response found, return buffered data as user input\n\t\tconst result = this.inputBuffer;\n\t\tthis.inputBuffer = \"\";\n\t\tthis.cellSizeQueryPending = false; // Give up waiting\n\t\treturn result;\n\t}\n\n\tprivate containsImage(line: string): boolean {\n\t\treturn line.includes(\"\\x1b_G\") || line.includes(\"\\x1b]1337;File=\");\n\t}\n\n\tprivate doRender(): void {\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\n\t\t// Render all components to get new lines\n\t\tconst newLines = this.render(width);\n\n\t\t// Width changed - need full re-render\n\t\tconst widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;\n\n\t\t// First render - just output everything without clearing\n\t\tif (this.previousLines.length === 0) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\t// After rendering N lines, cursor is at end of last line (line N-1)\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Width changed - full re-render\n\t\tif (widthChanged) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Find first and last changed lines\n\t\tlet firstChanged = -1;\n\t\tconst maxLines = Math.max(newLines.length, this.previousLines.length);\n\t\tfor (let i = 0; i < maxLines; i++) {\n\t\t\tconst oldLine = i < this.previousLines.length ? this.previousLines[i] : \"\";\n\t\t\tconst newLine = i < newLines.length ? newLines[i] : \"\";\n\n\t\t\tif (oldLine !== newLine) {\n\t\t\t\tif (firstChanged === -1) {\n\t\t\t\t\tfirstChanged = i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// No changes\n\t\tif (firstChanged === -1) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if firstChanged is outside the viewport\n\t\t// cursorRow is the line where cursor is (0-indexed)\n\t\t// Viewport shows lines from (cursorRow - height + 1) to cursorRow\n\t\t// If firstChanged < viewportTop, we need full re-render\n\t\tconst viewportTop = this.cursorRow - height + 1;\n\t\tif (firstChanged < viewportTop) {\n\t\t\t// First change is above viewport - need full re-render\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Render from first changed line to end\n\t\t// Build buffer with all updates wrapped in synchronized output\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\n\t\t// Move cursor to first changed line\n\t\tconst lineDiff = firstChanged - this.cursorRow;\n\t\tif (lineDiff > 0) {\n\t\t\tbuffer += `\\x1b[${lineDiff}B`; // Move down\n\t\t} else if (lineDiff < 0) {\n\t\t\tbuffer += `\\x1b[${-lineDiff}A`; // Move up\n\t\t}\n\n\t\tbuffer += \"\\r\"; // Move to column 0\n\n\t\t// Render from first changed line to end, clearing each line before writing\n\t\t// This avoids the \\x1b[J clear-to-end which can cause flicker in xterm.js\n\t\tfor (let i = firstChanged; i < newLines.length; i++) {\n\t\t\tif (i > firstChanged) buffer += \"\\r\\n\";\n\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\tconst line = newLines[i];\n\t\t\tconst isImageLine = this.containsImage(line);\n\t\t\tif (!isImageLine && visibleWidth(line) > width) {\n\t\t\t\t// Log all lines to crash file for debugging\n\t\t\t\tconst crashLogPath = path.join(os.homedir(), \".pi\", \"agent\", \"pi-crash.log\");\n\t\t\t\tconst crashData = [\n\t\t\t\t\t`Crash at ${new Date().toISOString()}`,\n\t\t\t\t\t`Terminal width: ${width}`,\n\t\t\t\t\t`Line ${i} visible width: ${visibleWidth(line)}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"=== All rendered lines ===\",\n\t\t\t\t\t...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tfs.mkdirSync(path.dirname(crashLogPath), { recursive: true });\n\t\t\t\tfs.writeFileSync(crashLogPath, crashData);\n\t\t\t\tthrow new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`);\n\t\t\t}\n\t\t\tbuffer += line;\n\t\t}\n\n\t\t// If we had more lines before, clear them and move cursor back\n\t\tif (this.previousLines.length > newLines.length) {\n\t\t\tconst extraLines = this.previousLines.length - newLines.length;\n\t\t\tfor (let i = newLines.length; i < this.previousLines.length; i++) {\n\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t}\n\t\t\t// Move cursor back to end of new content\n\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t}\n\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\n\t\t// Write entire buffer at once\n\t\tthis.terminal.write(buffer);\n\n\t\t// Cursor is now at end of last line\n\t\tthis.cursorRow = newLines.length - 1;\n\n\t\tthis.previousLines = newLines;\n\t\tthis.previousWidth = width;\n\t}\n}\n"]}
1
+ {"version":3,"file":"tui.d.ts","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"AAAA;;GAEG;AAMH,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,eAAe,CAAC;AAE9C,OAAO,EAAkD,YAAY,EAAE,MAAM,YAAY,CAAC;AAE1F;;GAEG;AACH,MAAM,WAAW,SAAS;IACzB;;;;OAIG;IACH,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAAC;IAEhC;;OAEG;IACH,WAAW,CAAC,CAAC,IAAI,EAAE,MAAM,GAAG,IAAI,CAAC;IAEjC;;;OAGG;IACH,eAAe,CAAC,EAAE,OAAO,CAAC;IAE1B;;;OAGG;IACH,UAAU,IAAI,IAAI,CAAC;CACnB;AAED,OAAO,EAAE,YAAY,EAAE,CAAC;AAExB;;GAEG;AACH,qBAAa,SAAU,YAAW,SAAS;IAC1C,QAAQ,EAAE,SAAS,EAAE,CAAM;IAE3B,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAEnC;IAED,WAAW,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,CAKtC;IAED,KAAK,IAAI,IAAI,CAEZ;IAED,UAAU,IAAI,IAAI,CAIjB;IAED,MAAM,CAAC,KAAK,EAAE,MAAM,GAAG,MAAM,EAAE,CAM9B;CACD;AAED;;GAEG;AACH,qBAAa,GAAI,SAAQ,SAAS;IAC1B,QAAQ,EAAE,QAAQ,CAAC;IAC1B,OAAO,CAAC,aAAa,CAAgB;IACrC,OAAO,CAAC,aAAa,CAAK;IAC1B,OAAO,CAAC,gBAAgB,CAA0B;IAElD,2GAA2G;IACpG,OAAO,CAAC,EAAE,MAAM,IAAI,CAAC;IAC5B,OAAO,CAAC,eAAe,CAAS;IAChC,OAAO,CAAC,SAAS,CAAK;IACtB,OAAO,CAAC,WAAW,CAAM;IACzB,OAAO,CAAC,oBAAoB,CAAS;IAGrC,OAAO,CAAC,YAAY,CAIX;IAET,YAAY,QAAQ,EAAE,QAAQ,EAG7B;IAED,QAAQ,CAAC,SAAS,EAAE,SAAS,GAAG,IAAI,GAAG,IAAI,CAE1C;IAED,qEAAqE;IACrE,WAAW,CAAC,SAAS,EAAE,SAAS,EAAE,OAAO,CAAC,EAAE;QAAE,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,GAAG,CAAC,EAAE,MAAM,CAAC;QAAC,KAAK,CAAC,EAAE,MAAM,CAAA;KAAE,GAAG,IAAI,CAKhG;IAED,2DAA2D;IAC3D,WAAW,IAAI,IAAI,CAMlB;IAED,UAAU,IAAI,OAAO,CAEpB;IAEQ,UAAU,IAAI,IAAI,CAG1B;IAED,KAAK,IAAI,IAAI,CAQZ;IAED,OAAO,CAAC,aAAa;IAWrB,IAAI,IAAI,IAAI,CAGX;IAED,aAAa,CAAC,KAAK,UAAQ,GAAG,IAAI,CAYjC;IAED,OAAO,CAAC,WAAW;IA2BnB,OAAO,CAAC,qBAAqB;IA0C7B,OAAO,CAAC,aAAa;IAIrB,kFAAkF;IAClF,OAAO,CAAC,iBAAiB;IA0BzB,OAAO,CAAC,MAAM,CAAC,QAAQ,CAAC,aAAa,CAAyB;IAE9D,2FAA2F;IAC3F,OAAO,CAAC,eAAe;IAyCvB,OAAO,CAAC,QAAQ;CAgKhB","sourcesContent":["/**\n * Minimal TUI implementation with differential rendering\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { isKeyRelease, matchesKey } from \"./keys.js\";\nimport type { Terminal } from \"./terminal.js\";\nimport { getCapabilities, setCellDimensions } from \"./terminal-image.js\";\nimport { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from \"./utils.js\";\n\n/**\n * Component interface - all components must implement this\n */\nexport interface Component {\n\t/**\n\t * Render the component to lines for the given viewport width\n\t * @param width - Current viewport width\n\t * @returns Array of strings, each representing a line\n\t */\n\trender(width: number): string[];\n\n\t/**\n\t * Optional handler for keyboard input when component has focus\n\t */\n\thandleInput?(data: string): void;\n\n\t/**\n\t * If true, component receives key release events (Kitty protocol).\n\t * Default is false - release events are filtered out.\n\t */\n\twantsKeyRelease?: boolean;\n\n\t/**\n\t * Invalidate any cached rendering state.\n\t * Called when theme changes or when component needs to re-render from scratch.\n\t */\n\tinvalidate(): void;\n}\n\nexport { visibleWidth };\n\n/**\n * Container - a component that contains other components\n */\nexport class Container implements Component {\n\tchildren: Component[] = [];\n\n\taddChild(component: Component): void {\n\t\tthis.children.push(component);\n\t}\n\n\tremoveChild(component: Component): void {\n\t\tconst index = this.children.indexOf(component);\n\t\tif (index !== -1) {\n\t\t\tthis.children.splice(index, 1);\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.children = [];\n\t}\n\n\tinvalidate(): void {\n\t\tfor (const child of this.children) {\n\t\t\tchild.invalidate?.();\n\t\t}\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tfor (const child of this.children) {\n\t\t\tlines.push(...child.render(width));\n\t\t}\n\t\treturn lines;\n\t}\n}\n\n/**\n * TUI - Main class for managing terminal UI with differential rendering\n */\nexport class TUI extends Container {\n\tpublic terminal: Terminal;\n\tprivate previousLines: string[] = [];\n\tprivate previousWidth = 0;\n\tprivate focusedComponent: Component | null = null;\n\n\t/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */\n\tpublic onDebug?: () => void;\n\tprivate renderRequested = false;\n\tprivate cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)\n\tprivate inputBuffer = \"\"; // Buffer for parsing terminal responses\n\tprivate cellSizeQueryPending = false;\n\n\t// Overlay stack for modal components rendered on top of base content\n\tprivate overlayStack: {\n\t\tcomponent: Component;\n\t\toptions?: { row?: number; col?: number; width?: number };\n\t\tpreFocus: Component | null;\n\t}[] = [];\n\n\tconstructor(terminal: Terminal) {\n\t\tsuper();\n\t\tthis.terminal = terminal;\n\t}\n\n\tsetFocus(component: Component | null): void {\n\t\tthis.focusedComponent = component;\n\t}\n\n\t/** Show an overlay component centered (or at specified position). */\n\tshowOverlay(component: Component, options?: { row?: number; col?: number; width?: number }): void {\n\t\tthis.overlayStack.push({ component, options, preFocus: this.focusedComponent });\n\t\tthis.setFocus(component);\n\t\tthis.terminal.hideCursor();\n\t\tthis.requestRender();\n\t}\n\n\t/** Hide the topmost overlay and restore previous focus. */\n\thideOverlay(): void {\n\t\tconst overlay = this.overlayStack.pop();\n\t\tif (!overlay) return;\n\t\tthis.setFocus(overlay.preFocus);\n\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\tthis.requestRender();\n\t}\n\n\thasOverlay(): boolean {\n\t\treturn this.overlayStack.length > 0;\n\t}\n\n\toverride invalidate(): void {\n\t\tsuper.invalidate();\n\t\tfor (const overlay of this.overlayStack) overlay.component.invalidate?.();\n\t}\n\n\tstart(): void {\n\t\tthis.terminal.start(\n\t\t\t(data) => this.handleInput(data),\n\t\t\t() => this.requestRender(),\n\t\t);\n\t\tthis.terminal.hideCursor();\n\t\tthis.queryCellSize();\n\t\tthis.requestRender();\n\t}\n\n\tprivate queryCellSize(): void {\n\t\t// Only query if terminal supports images (cell size is only used for image rendering)\n\t\tif (!getCapabilities().images) {\n\t\t\treturn;\n\t\t}\n\t\t// Query terminal for cell size in pixels: CSI 16 t\n\t\t// Response format: CSI 6 ; height ; width t\n\t\tthis.cellSizeQueryPending = true;\n\t\tthis.terminal.write(\"\\x1b[16t\");\n\t}\n\n\tstop(): void {\n\t\tthis.terminal.showCursor();\n\t\tthis.terminal.stop();\n\t}\n\n\trequestRender(force = false): void {\n\t\tif (force) {\n\t\t\tthis.previousLines = [];\n\t\t\tthis.previousWidth = 0;\n\t\t\tthis.cursorRow = 0;\n\t\t}\n\t\tif (this.renderRequested) return;\n\t\tthis.renderRequested = true;\n\t\tprocess.nextTick(() => {\n\t\t\tthis.renderRequested = false;\n\t\t\tthis.doRender();\n\t\t});\n\t}\n\n\tprivate handleInput(data: string): void {\n\t\t// If we're waiting for cell size response, buffer input and parse\n\t\tif (this.cellSizeQueryPending) {\n\t\t\tthis.inputBuffer += data;\n\t\t\tconst filtered = this.parseCellSizeResponse();\n\t\t\tif (filtered.length === 0) return;\n\t\t\tdata = filtered;\n\t\t}\n\n\t\t// Global debug key handler (Shift+Ctrl+D)\n\t\tif (matchesKey(data, \"shift+ctrl+d\") && this.onDebug) {\n\t\t\tthis.onDebug();\n\t\t\treturn;\n\t\t}\n\n\t\t// Pass input to focused component (including Ctrl+C)\n\t\t// The focused component can decide how to handle Ctrl+C\n\t\tif (this.focusedComponent?.handleInput) {\n\t\t\t// Filter out key release events unless component opts in\n\t\t\tif (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.focusedComponent.handleInput(data);\n\t\t\tthis.requestRender();\n\t\t}\n\t}\n\n\tprivate parseCellSizeResponse(): string {\n\t\t// Response format: ESC [ 6 ; height ; width t\n\t\t// Match the response pattern\n\t\tconst responsePattern = /\\x1b\\[6;(\\d+);(\\d+)t/;\n\t\tconst match = this.inputBuffer.match(responsePattern);\n\n\t\tif (match) {\n\t\t\tconst heightPx = parseInt(match[1], 10);\n\t\t\tconst widthPx = parseInt(match[2], 10);\n\n\t\t\tif (heightPx > 0 && widthPx > 0) {\n\t\t\t\tsetCellDimensions({ widthPx, heightPx });\n\t\t\t\t// Invalidate all components so images re-render with correct dimensions\n\t\t\t\tthis.invalidate();\n\t\t\t\tthis.requestRender();\n\t\t\t}\n\n\t\t\t// Remove the response from buffer\n\t\t\tthis.inputBuffer = this.inputBuffer.replace(responsePattern, \"\");\n\t\t\tthis.cellSizeQueryPending = false;\n\t\t}\n\n\t\t// Check if we have a partial cell size response starting (wait for more data)\n\t\t// Patterns that could be incomplete cell size response: \\x1b, \\x1b[, \\x1b[6, \\x1b[6;...(no t yet)\n\t\tconst partialCellSizePattern = /\\x1b(\\[6?;?[\\d;]*)?$/;\n\t\tif (partialCellSizePattern.test(this.inputBuffer)) {\n\t\t\t// Check if it's actually a complete different escape sequence (ends with a letter)\n\t\t\t// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.\n\t\t\tconst lastChar = this.inputBuffer[this.inputBuffer.length - 1];\n\t\t\tif (!/[a-zA-Z~]/.test(lastChar)) {\n\t\t\t\t// Doesn't end with a terminator, might be incomplete - wait for more\n\t\t\t\treturn \"\";\n\t\t\t}\n\t\t}\n\n\t\t// No cell size response found, return buffered data as user input\n\t\tconst result = this.inputBuffer;\n\t\tthis.inputBuffer = \"\";\n\t\tthis.cellSizeQueryPending = false; // Give up waiting\n\t\treturn result;\n\t}\n\n\tprivate containsImage(line: string): boolean {\n\t\treturn line.includes(\"\\x1b_G\") || line.includes(\"\\x1b]1337;File=\");\n\t}\n\n\t/** Composite all overlays into content lines (in stack order, later = on top). */\n\tprivate compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {\n\t\tif (this.overlayStack.length === 0) return lines;\n\t\tconst result = [...lines];\n\t\tconst viewportStart = Math.max(0, result.length - termHeight);\n\n\t\tfor (const { component, options } of this.overlayStack) {\n\t\t\tconst w =\n\t\t\t\toptions?.width !== undefined\n\t\t\t\t\t? Math.max(1, Math.min(options.width, termWidth - 4))\n\t\t\t\t\t: Math.max(1, Math.min(80, termWidth - 4));\n\t\t\tconst overlayLines = component.render(w);\n\t\t\tconst h = overlayLines.length;\n\n\t\t\tconst row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h));\n\t\t\tconst col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w));\n\n\t\t\tfor (let i = 0; i < h; i++) {\n\t\t\t\tconst idx = viewportStart + row + i;\n\t\t\t\tif (idx >= 0 && idx < result.length) {\n\t\t\t\t\tresult[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate static readonly SEGMENT_RESET = \"\\x1b[0m\\x1b]8;;\\x07\";\n\n\t/** Splice overlay content into a base line at a specific column. Single-pass optimized. */\n\tprivate compositeLineAt(\n\t\tbaseLine: string,\n\t\toverlayLine: string,\n\t\tstartCol: number,\n\t\toverlayWidth: number,\n\t\ttotalWidth: number,\n\t): string {\n\t\tif (this.containsImage(baseLine)) return baseLine;\n\n\t\t// Single pass through baseLine extracts both before and after segments\n\t\tconst afterStart = startCol + overlayWidth;\n\t\tconst base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);\n\n\t\t// Extract overlay with width tracking\n\t\tconst overlay = sliceWithWidth(overlayLine, 0, overlayWidth);\n\n\t\t// Pad segments to target widths\n\t\tconst beforePad = Math.max(0, startCol - base.beforeWidth);\n\t\tconst overlayPad = Math.max(0, overlayWidth - overlay.width);\n\t\tconst actualBeforeWidth = Math.max(startCol, base.beforeWidth);\n\t\tconst actualOverlayWidth = Math.max(overlayWidth, overlay.width);\n\t\tconst afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);\n\t\tconst afterPad = Math.max(0, afterTarget - base.afterWidth);\n\n\t\t// Compose result - widths are tracked so no final visibleWidth check needed\n\t\tconst r = TUI.SEGMENT_RESET;\n\t\tconst result =\n\t\t\tbase.before +\n\t\t\t\" \".repeat(beforePad) +\n\t\t\tr +\n\t\t\toverlay.text +\n\t\t\t\" \".repeat(overlayPad) +\n\t\t\tr +\n\t\t\tbase.after +\n\t\t\t\" \".repeat(afterPad);\n\n\t\t// Only truncate if wide char at after boundary caused overflow (rare)\n\t\tconst resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth);\n\t\treturn resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);\n\t}\n\n\tprivate doRender(): void {\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\n\t\t// Render all components to get new lines\n\t\tlet newLines = this.render(width);\n\n\t\t// Composite overlays into the rendered lines (before differential compare)\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tnewLines = this.compositeOverlays(newLines, width, height);\n\t\t}\n\n\t\t// Width changed - need full re-render\n\t\tconst widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;\n\n\t\t// First render - just output everything without clearing\n\t\tif (this.previousLines.length === 0) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\t// After rendering N lines, cursor is at end of last line (line N-1)\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Width changed - full re-render\n\t\tif (widthChanged) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Find first and last changed lines\n\t\tlet firstChanged = -1;\n\t\tconst maxLines = Math.max(newLines.length, this.previousLines.length);\n\t\tfor (let i = 0; i < maxLines; i++) {\n\t\t\tconst oldLine = i < this.previousLines.length ? this.previousLines[i] : \"\";\n\t\t\tconst newLine = i < newLines.length ? newLines[i] : \"\";\n\n\t\t\tif (oldLine !== newLine) {\n\t\t\t\tif (firstChanged === -1) {\n\t\t\t\t\tfirstChanged = i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// No changes\n\t\tif (firstChanged === -1) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if firstChanged is outside the viewport\n\t\t// cursorRow is the line where cursor is (0-indexed)\n\t\t// Viewport shows lines from (cursorRow - height + 1) to cursorRow\n\t\t// If firstChanged < viewportTop, we need full re-render\n\t\tconst viewportTop = this.cursorRow - height + 1;\n\t\tif (firstChanged < viewportTop) {\n\t\t\t// First change is above viewport - need full re-render\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Render from first changed line to end\n\t\t// Build buffer with all updates wrapped in synchronized output\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\n\t\t// Move cursor to first changed line\n\t\tconst lineDiff = firstChanged - this.cursorRow;\n\t\tif (lineDiff > 0) {\n\t\t\tbuffer += `\\x1b[${lineDiff}B`; // Move down\n\t\t} else if (lineDiff < 0) {\n\t\t\tbuffer += `\\x1b[${-lineDiff}A`; // Move up\n\t\t}\n\n\t\tbuffer += \"\\r\"; // Move to column 0\n\n\t\t// Render from first changed line to end, clearing each line before writing\n\t\t// This avoids the \\x1b[J clear-to-end which can cause flicker in xterm.js\n\t\tfor (let i = firstChanged; i < newLines.length; i++) {\n\t\t\tif (i > firstChanged) buffer += \"\\r\\n\";\n\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\tconst line = newLines[i];\n\t\t\tconst isImageLine = this.containsImage(line);\n\t\t\tif (!isImageLine && visibleWidth(line) > width) {\n\t\t\t\t// Log all lines to crash file for debugging\n\t\t\t\tconst crashLogPath = path.join(os.homedir(), \".pi\", \"agent\", \"pi-crash.log\");\n\t\t\t\tconst crashData = [\n\t\t\t\t\t`Crash at ${new Date().toISOString()}`,\n\t\t\t\t\t`Terminal width: ${width}`,\n\t\t\t\t\t`Line ${i} visible width: ${visibleWidth(line)}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"=== All rendered lines ===\",\n\t\t\t\t\t...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tfs.mkdirSync(path.dirname(crashLogPath), { recursive: true });\n\t\t\t\tfs.writeFileSync(crashLogPath, crashData);\n\n\t\t\t\t// Clean up terminal state before throwing\n\t\t\t\tthis.stop();\n\n\t\t\t\tconst errorMsg = [\n\t\t\t\t\t`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"This is likely caused by a custom TUI component not truncating its output.\",\n\t\t\t\t\t\"Use visibleWidth() to measure and truncateToWidth() to truncate lines.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Debug log written to: ${crashLogPath}`,\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\t\t\tbuffer += line;\n\t\t}\n\n\t\t// If we had more lines before, clear them and move cursor back\n\t\tif (this.previousLines.length > newLines.length) {\n\t\t\tconst extraLines = this.previousLines.length - newLines.length;\n\t\t\tfor (let i = newLines.length; i < this.previousLines.length; i++) {\n\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t}\n\t\t\t// Move cursor back to end of new content\n\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t}\n\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\n\t\t// Write entire buffer at once\n\t\tthis.terminal.write(buffer);\n\n\t\t// Cursor is now at end of last line\n\t\tthis.cursorRow = newLines.length - 1;\n\n\t\tthis.previousLines = newLines;\n\t\tthis.previousWidth = width;\n\t}\n}\n"]}
package/dist/tui.js CHANGED
@@ -6,7 +6,7 @@ import * as os from "node:os";
6
6
  import * as path from "node:path";
7
7
  import { isKeyRelease, matchesKey } from "./keys.js";
8
8
  import { getCapabilities, setCellDimensions } from "./terminal-image.js";
9
- import { visibleWidth } from "./utils.js";
9
+ import { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from "./utils.js";
10
10
  export { visibleWidth };
11
11
  /**
12
12
  * Container - a component that contains other components
@@ -52,6 +52,8 @@ export class TUI extends Container {
52
52
  cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)
53
53
  inputBuffer = ""; // Buffer for parsing terminal responses
54
54
  cellSizeQueryPending = false;
55
+ // Overlay stack for modal components rendered on top of base content
56
+ overlayStack = [];
55
57
  constructor(terminal) {
56
58
  super();
57
59
  this.terminal = terminal;
@@ -59,6 +61,31 @@ export class TUI extends Container {
59
61
  setFocus(component) {
60
62
  this.focusedComponent = component;
61
63
  }
64
+ /** Show an overlay component centered (or at specified position). */
65
+ showOverlay(component, options) {
66
+ this.overlayStack.push({ component, options, preFocus: this.focusedComponent });
67
+ this.setFocus(component);
68
+ this.terminal.hideCursor();
69
+ this.requestRender();
70
+ }
71
+ /** Hide the topmost overlay and restore previous focus. */
72
+ hideOverlay() {
73
+ const overlay = this.overlayStack.pop();
74
+ if (!overlay)
75
+ return;
76
+ this.setFocus(overlay.preFocus);
77
+ if (this.overlayStack.length === 0)
78
+ this.terminal.hideCursor();
79
+ this.requestRender();
80
+ }
81
+ hasOverlay() {
82
+ return this.overlayStack.length > 0;
83
+ }
84
+ invalidate() {
85
+ super.invalidate();
86
+ for (const overlay of this.overlayStack)
87
+ overlay.component.invalidate?.();
88
+ }
62
89
  start() {
63
90
  this.terminal.start((data) => this.handleInput(data), () => this.requestRender());
64
91
  this.terminal.hideCursor();
@@ -157,11 +184,69 @@ export class TUI extends Container {
157
184
  containsImage(line) {
158
185
  return line.includes("\x1b_G") || line.includes("\x1b]1337;File=");
159
186
  }
187
+ /** Composite all overlays into content lines (in stack order, later = on top). */
188
+ compositeOverlays(lines, termWidth, termHeight) {
189
+ if (this.overlayStack.length === 0)
190
+ return lines;
191
+ const result = [...lines];
192
+ const viewportStart = Math.max(0, result.length - termHeight);
193
+ for (const { component, options } of this.overlayStack) {
194
+ const w = options?.width !== undefined
195
+ ? Math.max(1, Math.min(options.width, termWidth - 4))
196
+ : Math.max(1, Math.min(80, termWidth - 4));
197
+ const overlayLines = component.render(w);
198
+ const h = overlayLines.length;
199
+ const row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h));
200
+ const col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w));
201
+ for (let i = 0; i < h; i++) {
202
+ const idx = viewportStart + row + i;
203
+ if (idx >= 0 && idx < result.length) {
204
+ result[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth);
205
+ }
206
+ }
207
+ }
208
+ return result;
209
+ }
210
+ static SEGMENT_RESET = "\x1b[0m\x1b]8;;\x07";
211
+ /** Splice overlay content into a base line at a specific column. Single-pass optimized. */
212
+ compositeLineAt(baseLine, overlayLine, startCol, overlayWidth, totalWidth) {
213
+ if (this.containsImage(baseLine))
214
+ return baseLine;
215
+ // Single pass through baseLine extracts both before and after segments
216
+ const afterStart = startCol + overlayWidth;
217
+ const base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);
218
+ // Extract overlay with width tracking
219
+ const overlay = sliceWithWidth(overlayLine, 0, overlayWidth);
220
+ // Pad segments to target widths
221
+ const beforePad = Math.max(0, startCol - base.beforeWidth);
222
+ const overlayPad = Math.max(0, overlayWidth - overlay.width);
223
+ const actualBeforeWidth = Math.max(startCol, base.beforeWidth);
224
+ const actualOverlayWidth = Math.max(overlayWidth, overlay.width);
225
+ const afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);
226
+ const afterPad = Math.max(0, afterTarget - base.afterWidth);
227
+ // Compose result - widths are tracked so no final visibleWidth check needed
228
+ const r = TUI.SEGMENT_RESET;
229
+ const result = base.before +
230
+ " ".repeat(beforePad) +
231
+ r +
232
+ overlay.text +
233
+ " ".repeat(overlayPad) +
234
+ r +
235
+ base.after +
236
+ " ".repeat(afterPad);
237
+ // Only truncate if wide char at after boundary caused overflow (rare)
238
+ const resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth);
239
+ return resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);
240
+ }
160
241
  doRender() {
161
242
  const width = this.terminal.columns;
162
243
  const height = this.terminal.rows;
163
244
  // Render all components to get new lines
164
- const newLines = this.render(width);
245
+ let newLines = this.render(width);
246
+ // Composite overlays into the rendered lines (before differential compare)
247
+ if (this.overlayStack.length > 0) {
248
+ newLines = this.compositeOverlays(newLines, width, height);
249
+ }
165
250
  // Width changed - need full re-render
166
251
  const widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;
167
252
  // First render - just output everything without clearing
@@ -267,7 +352,17 @@ export class TUI extends Container {
267
352
  ].join("\n");
268
353
  fs.mkdirSync(path.dirname(crashLogPath), { recursive: true });
269
354
  fs.writeFileSync(crashLogPath, crashData);
270
- throw new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`);
355
+ // Clean up terminal state before throwing
356
+ this.stop();
357
+ const errorMsg = [
358
+ `Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,
359
+ "",
360
+ "This is likely caused by a custom TUI component not truncating its output.",
361
+ "Use visibleWidth() to measure and truncateToWidth() to truncate lines.",
362
+ "",
363
+ `Debug log written to: ${crashLogPath}`,
364
+ ].join("\n");
365
+ throw new Error(errorMsg);
271
366
  }
272
367
  buffer += line;
273
368
  }
package/dist/tui.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"tui.js","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Minimal TUI implementation with differential rendering\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { isKeyRelease, matchesKey } from \"./keys.js\";\nimport type { Terminal } from \"./terminal.js\";\nimport { getCapabilities, setCellDimensions } from \"./terminal-image.js\";\nimport { visibleWidth } from \"./utils.js\";\n\n/**\n * Component interface - all components must implement this\n */\nexport interface Component {\n\t/**\n\t * Render the component to lines for the given viewport width\n\t * @param width - Current viewport width\n\t * @returns Array of strings, each representing a line\n\t */\n\trender(width: number): string[];\n\n\t/**\n\t * Optional handler for keyboard input when component has focus\n\t */\n\thandleInput?(data: string): void;\n\n\t/**\n\t * If true, component receives key release events (Kitty protocol).\n\t * Default is false - release events are filtered out.\n\t */\n\twantsKeyRelease?: boolean;\n\n\t/**\n\t * Invalidate any cached rendering state.\n\t * Called when theme changes or when component needs to re-render from scratch.\n\t */\n\tinvalidate(): void;\n}\n\nexport { visibleWidth };\n\n/**\n * Container - a component that contains other components\n */\nexport class Container implements Component {\n\tchildren: Component[] = [];\n\n\taddChild(component: Component): void {\n\t\tthis.children.push(component);\n\t}\n\n\tremoveChild(component: Component): void {\n\t\tconst index = this.children.indexOf(component);\n\t\tif (index !== -1) {\n\t\t\tthis.children.splice(index, 1);\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.children = [];\n\t}\n\n\tinvalidate(): void {\n\t\tfor (const child of this.children) {\n\t\t\tchild.invalidate?.();\n\t\t}\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tfor (const child of this.children) {\n\t\t\tlines.push(...child.render(width));\n\t\t}\n\t\treturn lines;\n\t}\n}\n\n/**\n * TUI - Main class for managing terminal UI with differential rendering\n */\nexport class TUI extends Container {\n\tpublic terminal: Terminal;\n\tprivate previousLines: string[] = [];\n\tprivate previousWidth = 0;\n\tprivate focusedComponent: Component | null = null;\n\n\t/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */\n\tpublic onDebug?: () => void;\n\tprivate renderRequested = false;\n\tprivate cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)\n\tprivate inputBuffer = \"\"; // Buffer for parsing terminal responses\n\tprivate cellSizeQueryPending = false;\n\n\tconstructor(terminal: Terminal) {\n\t\tsuper();\n\t\tthis.terminal = terminal;\n\t}\n\n\tsetFocus(component: Component | null): void {\n\t\tthis.focusedComponent = component;\n\t}\n\n\tstart(): void {\n\t\tthis.terminal.start(\n\t\t\t(data) => this.handleInput(data),\n\t\t\t() => this.requestRender(),\n\t\t);\n\t\tthis.terminal.hideCursor();\n\t\tthis.queryCellSize();\n\t\tthis.requestRender();\n\t}\n\n\tprivate queryCellSize(): void {\n\t\t// Only query if terminal supports images (cell size is only used for image rendering)\n\t\tif (!getCapabilities().images) {\n\t\t\treturn;\n\t\t}\n\t\t// Query terminal for cell size in pixels: CSI 16 t\n\t\t// Response format: CSI 6 ; height ; width t\n\t\tthis.cellSizeQueryPending = true;\n\t\tthis.terminal.write(\"\\x1b[16t\");\n\t}\n\n\tstop(): void {\n\t\tthis.terminal.showCursor();\n\t\tthis.terminal.stop();\n\t}\n\n\trequestRender(force = false): void {\n\t\tif (force) {\n\t\t\tthis.previousLines = [];\n\t\t\tthis.previousWidth = 0;\n\t\t\tthis.cursorRow = 0;\n\t\t}\n\t\tif (this.renderRequested) return;\n\t\tthis.renderRequested = true;\n\t\tprocess.nextTick(() => {\n\t\t\tthis.renderRequested = false;\n\t\t\tthis.doRender();\n\t\t});\n\t}\n\n\tprivate handleInput(data: string): void {\n\t\t// If we're waiting for cell size response, buffer input and parse\n\t\tif (this.cellSizeQueryPending) {\n\t\t\tthis.inputBuffer += data;\n\t\t\tconst filtered = this.parseCellSizeResponse();\n\t\t\tif (filtered.length === 0) return;\n\t\t\tdata = filtered;\n\t\t}\n\n\t\t// Global debug key handler (Shift+Ctrl+D)\n\t\tif (matchesKey(data, \"shift+ctrl+d\") && this.onDebug) {\n\t\t\tthis.onDebug();\n\t\t\treturn;\n\t\t}\n\n\t\t// Pass input to focused component (including Ctrl+C)\n\t\t// The focused component can decide how to handle Ctrl+C\n\t\tif (this.focusedComponent?.handleInput) {\n\t\t\t// Filter out key release events unless component opts in\n\t\t\tif (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.focusedComponent.handleInput(data);\n\t\t\tthis.requestRender();\n\t\t}\n\t}\n\n\tprivate parseCellSizeResponse(): string {\n\t\t// Response format: ESC [ 6 ; height ; width t\n\t\t// Match the response pattern\n\t\tconst responsePattern = /\\x1b\\[6;(\\d+);(\\d+)t/;\n\t\tconst match = this.inputBuffer.match(responsePattern);\n\n\t\tif (match) {\n\t\t\tconst heightPx = parseInt(match[1], 10);\n\t\t\tconst widthPx = parseInt(match[2], 10);\n\n\t\t\tif (heightPx > 0 && widthPx > 0) {\n\t\t\t\tsetCellDimensions({ widthPx, heightPx });\n\t\t\t\t// Invalidate all components so images re-render with correct dimensions\n\t\t\t\tthis.invalidate();\n\t\t\t\tthis.requestRender();\n\t\t\t}\n\n\t\t\t// Remove the response from buffer\n\t\t\tthis.inputBuffer = this.inputBuffer.replace(responsePattern, \"\");\n\t\t\tthis.cellSizeQueryPending = false;\n\t\t}\n\n\t\t// Check if we have a partial cell size response starting (wait for more data)\n\t\t// Patterns that could be incomplete cell size response: \\x1b, \\x1b[, \\x1b[6, \\x1b[6;...(no t yet)\n\t\tconst partialCellSizePattern = /\\x1b(\\[6?;?[\\d;]*)?$/;\n\t\tif (partialCellSizePattern.test(this.inputBuffer)) {\n\t\t\t// Check if it's actually a complete different escape sequence (ends with a letter)\n\t\t\t// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.\n\t\t\tconst lastChar = this.inputBuffer[this.inputBuffer.length - 1];\n\t\t\tif (!/[a-zA-Z~]/.test(lastChar)) {\n\t\t\t\t// Doesn't end with a terminator, might be incomplete - wait for more\n\t\t\t\treturn \"\";\n\t\t\t}\n\t\t}\n\n\t\t// No cell size response found, return buffered data as user input\n\t\tconst result = this.inputBuffer;\n\t\tthis.inputBuffer = \"\";\n\t\tthis.cellSizeQueryPending = false; // Give up waiting\n\t\treturn result;\n\t}\n\n\tprivate containsImage(line: string): boolean {\n\t\treturn line.includes(\"\\x1b_G\") || line.includes(\"\\x1b]1337;File=\");\n\t}\n\n\tprivate doRender(): void {\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\n\t\t// Render all components to get new lines\n\t\tconst newLines = this.render(width);\n\n\t\t// Width changed - need full re-render\n\t\tconst widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;\n\n\t\t// First render - just output everything without clearing\n\t\tif (this.previousLines.length === 0) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\t// After rendering N lines, cursor is at end of last line (line N-1)\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Width changed - full re-render\n\t\tif (widthChanged) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Find first and last changed lines\n\t\tlet firstChanged = -1;\n\t\tconst maxLines = Math.max(newLines.length, this.previousLines.length);\n\t\tfor (let i = 0; i < maxLines; i++) {\n\t\t\tconst oldLine = i < this.previousLines.length ? this.previousLines[i] : \"\";\n\t\t\tconst newLine = i < newLines.length ? newLines[i] : \"\";\n\n\t\t\tif (oldLine !== newLine) {\n\t\t\t\tif (firstChanged === -1) {\n\t\t\t\t\tfirstChanged = i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// No changes\n\t\tif (firstChanged === -1) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if firstChanged is outside the viewport\n\t\t// cursorRow is the line where cursor is (0-indexed)\n\t\t// Viewport shows lines from (cursorRow - height + 1) to cursorRow\n\t\t// If firstChanged < viewportTop, we need full re-render\n\t\tconst viewportTop = this.cursorRow - height + 1;\n\t\tif (firstChanged < viewportTop) {\n\t\t\t// First change is above viewport - need full re-render\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Render from first changed line to end\n\t\t// Build buffer with all updates wrapped in synchronized output\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\n\t\t// Move cursor to first changed line\n\t\tconst lineDiff = firstChanged - this.cursorRow;\n\t\tif (lineDiff > 0) {\n\t\t\tbuffer += `\\x1b[${lineDiff}B`; // Move down\n\t\t} else if (lineDiff < 0) {\n\t\t\tbuffer += `\\x1b[${-lineDiff}A`; // Move up\n\t\t}\n\n\t\tbuffer += \"\\r\"; // Move to column 0\n\n\t\t// Render from first changed line to end, clearing each line before writing\n\t\t// This avoids the \\x1b[J clear-to-end which can cause flicker in xterm.js\n\t\tfor (let i = firstChanged; i < newLines.length; i++) {\n\t\t\tif (i > firstChanged) buffer += \"\\r\\n\";\n\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\tconst line = newLines[i];\n\t\t\tconst isImageLine = this.containsImage(line);\n\t\t\tif (!isImageLine && visibleWidth(line) > width) {\n\t\t\t\t// Log all lines to crash file for debugging\n\t\t\t\tconst crashLogPath = path.join(os.homedir(), \".pi\", \"agent\", \"pi-crash.log\");\n\t\t\t\tconst crashData = [\n\t\t\t\t\t`Crash at ${new Date().toISOString()}`,\n\t\t\t\t\t`Terminal width: ${width}`,\n\t\t\t\t\t`Line ${i} visible width: ${visibleWidth(line)}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"=== All rendered lines ===\",\n\t\t\t\t\t...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tfs.mkdirSync(path.dirname(crashLogPath), { recursive: true });\n\t\t\t\tfs.writeFileSync(crashLogPath, crashData);\n\t\t\t\tthrow new Error(`Rendered line ${i} exceeds terminal width. Debug log written to ${crashLogPath}`);\n\t\t\t}\n\t\t\tbuffer += line;\n\t\t}\n\n\t\t// If we had more lines before, clear them and move cursor back\n\t\tif (this.previousLines.length > newLines.length) {\n\t\t\tconst extraLines = this.previousLines.length - newLines.length;\n\t\t\tfor (let i = newLines.length; i < this.previousLines.length; i++) {\n\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t}\n\t\t\t// Move cursor back to end of new content\n\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t}\n\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\n\t\t// Write entire buffer at once\n\t\tthis.terminal.write(buffer);\n\n\t\t// Cursor is now at end of last line\n\t\tthis.cursorRow = newLines.length - 1;\n\n\t\tthis.previousLines = newLines;\n\t\tthis.previousWidth = width;\n\t}\n}\n"]}
1
+ {"version":3,"file":"tui.js","sourceRoot":"","sources":["../src/tui.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Minimal TUI implementation with differential rendering\n */\n\nimport * as fs from \"node:fs\";\nimport * as os from \"node:os\";\nimport * as path from \"node:path\";\nimport { isKeyRelease, matchesKey } from \"./keys.js\";\nimport type { Terminal } from \"./terminal.js\";\nimport { getCapabilities, setCellDimensions } from \"./terminal-image.js\";\nimport { extractSegments, sliceByColumn, sliceWithWidth, visibleWidth } from \"./utils.js\";\n\n/**\n * Component interface - all components must implement this\n */\nexport interface Component {\n\t/**\n\t * Render the component to lines for the given viewport width\n\t * @param width - Current viewport width\n\t * @returns Array of strings, each representing a line\n\t */\n\trender(width: number): string[];\n\n\t/**\n\t * Optional handler for keyboard input when component has focus\n\t */\n\thandleInput?(data: string): void;\n\n\t/**\n\t * If true, component receives key release events (Kitty protocol).\n\t * Default is false - release events are filtered out.\n\t */\n\twantsKeyRelease?: boolean;\n\n\t/**\n\t * Invalidate any cached rendering state.\n\t * Called when theme changes or when component needs to re-render from scratch.\n\t */\n\tinvalidate(): void;\n}\n\nexport { visibleWidth };\n\n/**\n * Container - a component that contains other components\n */\nexport class Container implements Component {\n\tchildren: Component[] = [];\n\n\taddChild(component: Component): void {\n\t\tthis.children.push(component);\n\t}\n\n\tremoveChild(component: Component): void {\n\t\tconst index = this.children.indexOf(component);\n\t\tif (index !== -1) {\n\t\t\tthis.children.splice(index, 1);\n\t\t}\n\t}\n\n\tclear(): void {\n\t\tthis.children = [];\n\t}\n\n\tinvalidate(): void {\n\t\tfor (const child of this.children) {\n\t\t\tchild.invalidate?.();\n\t\t}\n\t}\n\n\trender(width: number): string[] {\n\t\tconst lines: string[] = [];\n\t\tfor (const child of this.children) {\n\t\t\tlines.push(...child.render(width));\n\t\t}\n\t\treturn lines;\n\t}\n}\n\n/**\n * TUI - Main class for managing terminal UI with differential rendering\n */\nexport class TUI extends Container {\n\tpublic terminal: Terminal;\n\tprivate previousLines: string[] = [];\n\tprivate previousWidth = 0;\n\tprivate focusedComponent: Component | null = null;\n\n\t/** Global callback for debug key (Shift+Ctrl+D). Called before input is forwarded to focused component. */\n\tpublic onDebug?: () => void;\n\tprivate renderRequested = false;\n\tprivate cursorRow = 0; // Track where cursor is (0-indexed, relative to our first line)\n\tprivate inputBuffer = \"\"; // Buffer for parsing terminal responses\n\tprivate cellSizeQueryPending = false;\n\n\t// Overlay stack for modal components rendered on top of base content\n\tprivate overlayStack: {\n\t\tcomponent: Component;\n\t\toptions?: { row?: number; col?: number; width?: number };\n\t\tpreFocus: Component | null;\n\t}[] = [];\n\n\tconstructor(terminal: Terminal) {\n\t\tsuper();\n\t\tthis.terminal = terminal;\n\t}\n\n\tsetFocus(component: Component | null): void {\n\t\tthis.focusedComponent = component;\n\t}\n\n\t/** Show an overlay component centered (or at specified position). */\n\tshowOverlay(component: Component, options?: { row?: number; col?: number; width?: number }): void {\n\t\tthis.overlayStack.push({ component, options, preFocus: this.focusedComponent });\n\t\tthis.setFocus(component);\n\t\tthis.terminal.hideCursor();\n\t\tthis.requestRender();\n\t}\n\n\t/** Hide the topmost overlay and restore previous focus. */\n\thideOverlay(): void {\n\t\tconst overlay = this.overlayStack.pop();\n\t\tif (!overlay) return;\n\t\tthis.setFocus(overlay.preFocus);\n\t\tif (this.overlayStack.length === 0) this.terminal.hideCursor();\n\t\tthis.requestRender();\n\t}\n\n\thasOverlay(): boolean {\n\t\treturn this.overlayStack.length > 0;\n\t}\n\n\toverride invalidate(): void {\n\t\tsuper.invalidate();\n\t\tfor (const overlay of this.overlayStack) overlay.component.invalidate?.();\n\t}\n\n\tstart(): void {\n\t\tthis.terminal.start(\n\t\t\t(data) => this.handleInput(data),\n\t\t\t() => this.requestRender(),\n\t\t);\n\t\tthis.terminal.hideCursor();\n\t\tthis.queryCellSize();\n\t\tthis.requestRender();\n\t}\n\n\tprivate queryCellSize(): void {\n\t\t// Only query if terminal supports images (cell size is only used for image rendering)\n\t\tif (!getCapabilities().images) {\n\t\t\treturn;\n\t\t}\n\t\t// Query terminal for cell size in pixels: CSI 16 t\n\t\t// Response format: CSI 6 ; height ; width t\n\t\tthis.cellSizeQueryPending = true;\n\t\tthis.terminal.write(\"\\x1b[16t\");\n\t}\n\n\tstop(): void {\n\t\tthis.terminal.showCursor();\n\t\tthis.terminal.stop();\n\t}\n\n\trequestRender(force = false): void {\n\t\tif (force) {\n\t\t\tthis.previousLines = [];\n\t\t\tthis.previousWidth = 0;\n\t\t\tthis.cursorRow = 0;\n\t\t}\n\t\tif (this.renderRequested) return;\n\t\tthis.renderRequested = true;\n\t\tprocess.nextTick(() => {\n\t\t\tthis.renderRequested = false;\n\t\t\tthis.doRender();\n\t\t});\n\t}\n\n\tprivate handleInput(data: string): void {\n\t\t// If we're waiting for cell size response, buffer input and parse\n\t\tif (this.cellSizeQueryPending) {\n\t\t\tthis.inputBuffer += data;\n\t\t\tconst filtered = this.parseCellSizeResponse();\n\t\t\tif (filtered.length === 0) return;\n\t\t\tdata = filtered;\n\t\t}\n\n\t\t// Global debug key handler (Shift+Ctrl+D)\n\t\tif (matchesKey(data, \"shift+ctrl+d\") && this.onDebug) {\n\t\t\tthis.onDebug();\n\t\t\treturn;\n\t\t}\n\n\t\t// Pass input to focused component (including Ctrl+C)\n\t\t// The focused component can decide how to handle Ctrl+C\n\t\tif (this.focusedComponent?.handleInput) {\n\t\t\t// Filter out key release events unless component opts in\n\t\t\tif (isKeyRelease(data) && !this.focusedComponent.wantsKeyRelease) {\n\t\t\t\treturn;\n\t\t\t}\n\t\t\tthis.focusedComponent.handleInput(data);\n\t\t\tthis.requestRender();\n\t\t}\n\t}\n\n\tprivate parseCellSizeResponse(): string {\n\t\t// Response format: ESC [ 6 ; height ; width t\n\t\t// Match the response pattern\n\t\tconst responsePattern = /\\x1b\\[6;(\\d+);(\\d+)t/;\n\t\tconst match = this.inputBuffer.match(responsePattern);\n\n\t\tif (match) {\n\t\t\tconst heightPx = parseInt(match[1], 10);\n\t\t\tconst widthPx = parseInt(match[2], 10);\n\n\t\t\tif (heightPx > 0 && widthPx > 0) {\n\t\t\t\tsetCellDimensions({ widthPx, heightPx });\n\t\t\t\t// Invalidate all components so images re-render with correct dimensions\n\t\t\t\tthis.invalidate();\n\t\t\t\tthis.requestRender();\n\t\t\t}\n\n\t\t\t// Remove the response from buffer\n\t\t\tthis.inputBuffer = this.inputBuffer.replace(responsePattern, \"\");\n\t\t\tthis.cellSizeQueryPending = false;\n\t\t}\n\n\t\t// Check if we have a partial cell size response starting (wait for more data)\n\t\t// Patterns that could be incomplete cell size response: \\x1b, \\x1b[, \\x1b[6, \\x1b[6;...(no t yet)\n\t\tconst partialCellSizePattern = /\\x1b(\\[6?;?[\\d;]*)?$/;\n\t\tif (partialCellSizePattern.test(this.inputBuffer)) {\n\t\t\t// Check if it's actually a complete different escape sequence (ends with a letter)\n\t\t\t// Cell size response ends with 't', Kitty keyboard ends with 'u', arrows end with A-D, etc.\n\t\t\tconst lastChar = this.inputBuffer[this.inputBuffer.length - 1];\n\t\t\tif (!/[a-zA-Z~]/.test(lastChar)) {\n\t\t\t\t// Doesn't end with a terminator, might be incomplete - wait for more\n\t\t\t\treturn \"\";\n\t\t\t}\n\t\t}\n\n\t\t// No cell size response found, return buffered data as user input\n\t\tconst result = this.inputBuffer;\n\t\tthis.inputBuffer = \"\";\n\t\tthis.cellSizeQueryPending = false; // Give up waiting\n\t\treturn result;\n\t}\n\n\tprivate containsImage(line: string): boolean {\n\t\treturn line.includes(\"\\x1b_G\") || line.includes(\"\\x1b]1337;File=\");\n\t}\n\n\t/** Composite all overlays into content lines (in stack order, later = on top). */\n\tprivate compositeOverlays(lines: string[], termWidth: number, termHeight: number): string[] {\n\t\tif (this.overlayStack.length === 0) return lines;\n\t\tconst result = [...lines];\n\t\tconst viewportStart = Math.max(0, result.length - termHeight);\n\n\t\tfor (const { component, options } of this.overlayStack) {\n\t\t\tconst w =\n\t\t\t\toptions?.width !== undefined\n\t\t\t\t\t? Math.max(1, Math.min(options.width, termWidth - 4))\n\t\t\t\t\t: Math.max(1, Math.min(80, termWidth - 4));\n\t\t\tconst overlayLines = component.render(w);\n\t\t\tconst h = overlayLines.length;\n\n\t\t\tconst row = Math.max(0, Math.min(options?.row ?? Math.floor((termHeight - h) / 2), termHeight - h));\n\t\t\tconst col = Math.max(0, Math.min(options?.col ?? Math.floor((termWidth - w) / 2), termWidth - w));\n\n\t\t\tfor (let i = 0; i < h; i++) {\n\t\t\t\tconst idx = viewportStart + row + i;\n\t\t\t\tif (idx >= 0 && idx < result.length) {\n\t\t\t\t\tresult[idx] = this.compositeLineAt(result[idx], overlayLines[i], col, w, termWidth);\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\t\treturn result;\n\t}\n\n\tprivate static readonly SEGMENT_RESET = \"\\x1b[0m\\x1b]8;;\\x07\";\n\n\t/** Splice overlay content into a base line at a specific column. Single-pass optimized. */\n\tprivate compositeLineAt(\n\t\tbaseLine: string,\n\t\toverlayLine: string,\n\t\tstartCol: number,\n\t\toverlayWidth: number,\n\t\ttotalWidth: number,\n\t): string {\n\t\tif (this.containsImage(baseLine)) return baseLine;\n\n\t\t// Single pass through baseLine extracts both before and after segments\n\t\tconst afterStart = startCol + overlayWidth;\n\t\tconst base = extractSegments(baseLine, startCol, afterStart, totalWidth - afterStart, true);\n\n\t\t// Extract overlay with width tracking\n\t\tconst overlay = sliceWithWidth(overlayLine, 0, overlayWidth);\n\n\t\t// Pad segments to target widths\n\t\tconst beforePad = Math.max(0, startCol - base.beforeWidth);\n\t\tconst overlayPad = Math.max(0, overlayWidth - overlay.width);\n\t\tconst actualBeforeWidth = Math.max(startCol, base.beforeWidth);\n\t\tconst actualOverlayWidth = Math.max(overlayWidth, overlay.width);\n\t\tconst afterTarget = Math.max(0, totalWidth - actualBeforeWidth - actualOverlayWidth);\n\t\tconst afterPad = Math.max(0, afterTarget - base.afterWidth);\n\n\t\t// Compose result - widths are tracked so no final visibleWidth check needed\n\t\tconst r = TUI.SEGMENT_RESET;\n\t\tconst result =\n\t\t\tbase.before +\n\t\t\t\" \".repeat(beforePad) +\n\t\t\tr +\n\t\t\toverlay.text +\n\t\t\t\" \".repeat(overlayPad) +\n\t\t\tr +\n\t\t\tbase.after +\n\t\t\t\" \".repeat(afterPad);\n\n\t\t// Only truncate if wide char at after boundary caused overflow (rare)\n\t\tconst resultWidth = actualBeforeWidth + actualOverlayWidth + Math.max(afterTarget, base.afterWidth);\n\t\treturn resultWidth <= totalWidth ? result : sliceByColumn(result, 0, totalWidth, true);\n\t}\n\n\tprivate doRender(): void {\n\t\tconst width = this.terminal.columns;\n\t\tconst height = this.terminal.rows;\n\n\t\t// Render all components to get new lines\n\t\tlet newLines = this.render(width);\n\n\t\t// Composite overlays into the rendered lines (before differential compare)\n\t\tif (this.overlayStack.length > 0) {\n\t\t\tnewLines = this.compositeOverlays(newLines, width, height);\n\t\t}\n\n\t\t// Width changed - need full re-render\n\t\tconst widthChanged = this.previousWidth !== 0 && this.previousWidth !== width;\n\n\t\t// First render - just output everything without clearing\n\t\tif (this.previousLines.length === 0) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\t// After rendering N lines, cursor is at end of last line (line N-1)\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Width changed - full re-render\n\t\tif (widthChanged) {\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Find first and last changed lines\n\t\tlet firstChanged = -1;\n\t\tconst maxLines = Math.max(newLines.length, this.previousLines.length);\n\t\tfor (let i = 0; i < maxLines; i++) {\n\t\t\tconst oldLine = i < this.previousLines.length ? this.previousLines[i] : \"\";\n\t\t\tconst newLine = i < newLines.length ? newLines[i] : \"\";\n\n\t\t\tif (oldLine !== newLine) {\n\t\t\t\tif (firstChanged === -1) {\n\t\t\t\t\tfirstChanged = i;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// No changes\n\t\tif (firstChanged === -1) {\n\t\t\treturn;\n\t\t}\n\n\t\t// Check if firstChanged is outside the viewport\n\t\t// cursorRow is the line where cursor is (0-indexed)\n\t\t// Viewport shows lines from (cursorRow - height + 1) to cursorRow\n\t\t// If firstChanged < viewportTop, we need full re-render\n\t\tconst viewportTop = this.cursorRow - height + 1;\n\t\tif (firstChanged < viewportTop) {\n\t\t\t// First change is above viewport - need full re-render\n\t\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\t\t\tbuffer += \"\\x1b[3J\\x1b[2J\\x1b[H\"; // Clear scrollback, screen, and home\n\t\t\tfor (let i = 0; i < newLines.length; i++) {\n\t\t\t\tif (i > 0) buffer += \"\\r\\n\";\n\t\t\t\tbuffer += newLines[i];\n\t\t\t}\n\t\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\t\t\tthis.terminal.write(buffer);\n\t\t\tthis.cursorRow = newLines.length - 1;\n\t\t\tthis.previousLines = newLines;\n\t\t\tthis.previousWidth = width;\n\t\t\treturn;\n\t\t}\n\n\t\t// Render from first changed line to end\n\t\t// Build buffer with all updates wrapped in synchronized output\n\t\tlet buffer = \"\\x1b[?2026h\"; // Begin synchronized output\n\n\t\t// Move cursor to first changed line\n\t\tconst lineDiff = firstChanged - this.cursorRow;\n\t\tif (lineDiff > 0) {\n\t\t\tbuffer += `\\x1b[${lineDiff}B`; // Move down\n\t\t} else if (lineDiff < 0) {\n\t\t\tbuffer += `\\x1b[${-lineDiff}A`; // Move up\n\t\t}\n\n\t\tbuffer += \"\\r\"; // Move to column 0\n\n\t\t// Render from first changed line to end, clearing each line before writing\n\t\t// This avoids the \\x1b[J clear-to-end which can cause flicker in xterm.js\n\t\tfor (let i = firstChanged; i < newLines.length; i++) {\n\t\t\tif (i > firstChanged) buffer += \"\\r\\n\";\n\t\t\tbuffer += \"\\x1b[2K\"; // Clear current line\n\t\t\tconst line = newLines[i];\n\t\t\tconst isImageLine = this.containsImage(line);\n\t\t\tif (!isImageLine && visibleWidth(line) > width) {\n\t\t\t\t// Log all lines to crash file for debugging\n\t\t\t\tconst crashLogPath = path.join(os.homedir(), \".pi\", \"agent\", \"pi-crash.log\");\n\t\t\t\tconst crashData = [\n\t\t\t\t\t`Crash at ${new Date().toISOString()}`,\n\t\t\t\t\t`Terminal width: ${width}`,\n\t\t\t\t\t`Line ${i} visible width: ${visibleWidth(line)}`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"=== All rendered lines ===\",\n\t\t\t\t\t...newLines.map((l, idx) => `[${idx}] (w=${visibleWidth(l)}) ${l}`),\n\t\t\t\t\t\"\",\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tfs.mkdirSync(path.dirname(crashLogPath), { recursive: true });\n\t\t\t\tfs.writeFileSync(crashLogPath, crashData);\n\n\t\t\t\t// Clean up terminal state before throwing\n\t\t\t\tthis.stop();\n\n\t\t\t\tconst errorMsg = [\n\t\t\t\t\t`Rendered line ${i} exceeds terminal width (${visibleWidth(line)} > ${width}).`,\n\t\t\t\t\t\"\",\n\t\t\t\t\t\"This is likely caused by a custom TUI component not truncating its output.\",\n\t\t\t\t\t\"Use visibleWidth() to measure and truncateToWidth() to truncate lines.\",\n\t\t\t\t\t\"\",\n\t\t\t\t\t`Debug log written to: ${crashLogPath}`,\n\t\t\t\t].join(\"\\n\");\n\t\t\t\tthrow new Error(errorMsg);\n\t\t\t}\n\t\t\tbuffer += line;\n\t\t}\n\n\t\t// If we had more lines before, clear them and move cursor back\n\t\tif (this.previousLines.length > newLines.length) {\n\t\t\tconst extraLines = this.previousLines.length - newLines.length;\n\t\t\tfor (let i = newLines.length; i < this.previousLines.length; i++) {\n\t\t\t\tbuffer += \"\\r\\n\\x1b[2K\";\n\t\t\t}\n\t\t\t// Move cursor back to end of new content\n\t\t\tbuffer += `\\x1b[${extraLines}A`;\n\t\t}\n\n\t\tbuffer += \"\\x1b[?2026l\"; // End synchronized output\n\n\t\t// Write entire buffer at once\n\t\tthis.terminal.write(buffer);\n\n\t\t// Cursor is now at end of last line\n\t\tthis.cursorRow = newLines.length - 1;\n\n\t\tthis.previousLines = newLines;\n\t\tthis.previousWidth = width;\n\t}\n}\n"]}
package/dist/utils.d.ts CHANGED
@@ -6,6 +6,13 @@ export declare function getSegmenter(): Intl.Segmenter;
6
6
  * Calculate the visible width of a string in terminal columns.
7
7
  */
8
8
  export declare function visibleWidth(str: string): number;
9
+ /**
10
+ * Extract ANSI escape sequences from a string at the given position.
11
+ */
12
+ export declare function extractAnsiCode(str: string, pos: number): {
13
+ code: string;
14
+ length: number;
15
+ } | null;
9
16
  /**
10
17
  * Wrap text with ANSI codes preserved.
11
18
  *
@@ -45,4 +52,25 @@ export declare function applyBackgroundToLine(line: string, width: number, bgFn:
45
52
  * @returns Truncated text with ellipsis if it exceeded maxWidth
46
53
  */
47
54
  export declare function truncateToWidth(text: string, maxWidth: number, ellipsis?: string): string;
55
+ /**
56
+ * Extract a range of visible columns from a line. Handles ANSI codes and wide chars.
57
+ * @param strict - If true, exclude wide chars at boundary that would extend past the range
58
+ */
59
+ export declare function sliceByColumn(line: string, startCol: number, length: number, strict?: boolean): string;
60
+ /** Like sliceByColumn but also returns the actual visible width of the result. */
61
+ export declare function sliceWithWidth(line: string, startCol: number, length: number, strict?: boolean): {
62
+ text: string;
63
+ width: number;
64
+ };
65
+ /**
66
+ * Extract "before" and "after" segments from a line in a single pass.
67
+ * Used for overlay compositing where we need content before and after the overlay region.
68
+ * Preserves styling from before the overlay that should affect content after it.
69
+ */
70
+ export declare function extractSegments(line: string, beforeEnd: number, afterStart: number, afterLen: number, strictAfter?: boolean): {
71
+ before: string;
72
+ beforeWidth: number;
73
+ after: string;
74
+ afterWidth: number;
75
+ };
48
76
  //# sourceMappingURL=utils.d.ts.map