@sayknow-cli/tui 0.3.5 → 0.3.7

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/CHANGELOG.md CHANGED
@@ -2,6 +2,14 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.8] - 2026-06-30
6
+
7
+ ### Fixed
8
+
9
+ - The render loop now isolates a component whose `render()` throws: the failure is logged once and replaced with a `[render error: <Name>]` fallback line instead of escaping the frame and tripping the process-level fail-fast `uncaughtException` exit. Previously any unguarded renderer fault (e.g. a tool renderer fed an undefined field) crashed the whole app on whatever triggered the next frame — a keystroke, resize, or command such as `/background` (#1291).
10
+
11
+ - Resolved terminal dimensions from the live TTY window size before stream defaults so wide Windows Terminal/PowerShell sessions render against the actual viewport width (#1239).
12
+
5
13
  ## [0.7.4] - 2026-06-27
6
14
 
7
15
  ### Fixed
@@ -39,6 +39,11 @@ export declare class Editor implements Component, Focusable {
39
39
  onChange?: (text: string) => void;
40
40
  onAutocompleteCancel?: () => void;
41
41
  onTabDeclined?: (text: string) => void;
42
+ /**
43
+ * Called before Tab opens/applies autocomplete. Return true to consume Tab
44
+ * for app-level behavior (for example, queueing a draft while a turn runs).
45
+ */
46
+ onTab?: (text: string) => boolean | undefined;
42
47
  disableSubmit: boolean;
43
48
  constructor(theme: EditorTheme);
44
49
  setAutocompleteProvider(provider: AutocompleteProvider): void;
@@ -22,7 +22,7 @@ export interface SettingsListTheme {
22
22
  }
23
23
  export declare class SettingsList implements Component {
24
24
  #private;
25
- constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void);
25
+ constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, onSelectionChange?: (item: SettingItem | undefined) => void);
26
26
  /** Update an item's currentValue */
27
27
  updateValue(id: string, newValue: string): void;
28
28
  /**
@@ -52,6 +52,13 @@ export interface Terminal {
52
52
  /** The last detected terminal appearance, or undefined if not yet known. */
53
53
  get appearance(): TerminalAppearance | undefined;
54
54
  }
55
+ interface TerminalSizeStream {
56
+ columns?: number;
57
+ rows?: number;
58
+ getWindowSize?: () => [number, number] | number[];
59
+ }
60
+ export declare function resolveTerminalColumns(stream?: TerminalSizeStream, envColumns?: string | undefined): number;
61
+ export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows?: string | undefined): number;
55
62
  /**
56
63
  * Real terminal using process.stdin/stdout
57
64
  */
@@ -78,3 +85,4 @@ export declare class ProcessTerminal implements Terminal {
78
85
  setTitle(title: string): void;
79
86
  setProgress(active: boolean): void;
80
87
  }
88
+ export {};
@@ -157,6 +157,8 @@ export declare class TUI extends Container {
157
157
  setClearOnShrink(enabled: boolean): void;
158
158
  setFocus(component: Component | null): void;
159
159
  setBottomPinnedComponent(component: Component | null): void;
160
+ scrollViewportPages(direction: -1 | 1): boolean;
161
+ followLiveViewport(): boolean;
160
162
  /**
161
163
  * Show an overlay component with configurable positioning and sizing.
162
164
  * Returns a handle to control the overlay's visibility.
@@ -172,6 +174,19 @@ export declare class TUI extends Container {
172
174
  addInputListener(listener: InputListener): () => void;
173
175
  removeInputListener(listener: InputListener): void;
174
176
  stop(): void;
177
+ /**
178
+ * Multiplexer-aware resize render request.
179
+ *
180
+ * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
181
+ * to -1, which makes `#doRender` treat the frame as a width change and fall into the
182
+ * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
183
+ * `3J` escape (users navigate scrollback history), so replaying every transcript line
184
+ * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
185
+ * high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
186
+ * height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
187
+ * Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
188
+ */
189
+ requestResizeRender(): void;
175
190
  requestRender(force?: boolean, source?: string): void;
176
191
  getLineRenderCacheStats(): {
177
192
  normalizationSize: number;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@sayknow-cli/tui",
4
- "version": "0.3.5",
4
+ "version": "0.3.7",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://github.com/jaybeyond/Sayknow_CLI",
7
7
  "author": "jaybeyond",
@@ -38,8 +38,8 @@
38
38
  "fmt": "biome format --write ."
39
39
  },
40
40
  "dependencies": {
41
- "@sayknow-cli/natives": "0.3.5",
42
- "@sayknow-cli/utils": "0.3.5",
41
+ "@sayknow-cli/natives": "0.3.7",
42
+ "@sayknow-cli/utils": "0.3.7",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -450,6 +450,11 @@ export class Editor implements Component, Focusable {
450
450
  onChange?: (text: string) => void;
451
451
  onAutocompleteCancel?: () => void;
452
452
  onTabDeclined?: (text: string) => void;
453
+ /**
454
+ * Called before Tab opens/applies autocomplete. Return true to consume Tab
455
+ * for app-level behavior (for example, queueing a draft while a turn runs).
456
+ */
457
+ onTab?: (text: string) => boolean | undefined;
453
458
  disableSubmit: boolean = false;
454
459
 
455
460
  // Custom top border (for status line integration)
@@ -1080,6 +1085,10 @@ export class Editor implements Component, Focusable {
1080
1085
  return;
1081
1086
  }
1082
1087
 
1088
+ if (kb.matches(data, "tui.input.tab") && this.onTab?.(this.getText())) {
1089
+ return;
1090
+ }
1091
+
1083
1092
  // Handle autocomplete special keys first (but don't block other input)
1084
1093
  if (this.#autocompleteState && this.#autocompleteList) {
1085
1094
  // Escape - cancel autocomplete
@@ -1147,7 +1156,7 @@ export class Editor implements Component, Focusable {
1147
1156
  // Check for stale autocomplete state due to debounce
1148
1157
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1149
1158
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1150
- if (currentTextBeforeCursor !== this.#autocompletePrefix) {
1159
+ if (!currentTextBeforeCursor.endsWith(this.#autocompletePrefix)) {
1151
1160
  // Autocomplete is stale - cancel and fall through to normal submission
1152
1161
  this.#cancelAutocomplete();
1153
1162
  } else {
@@ -1285,8 +1294,8 @@ export class Editor implements Component, Focusable {
1285
1294
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1286
1295
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1287
1296
  if (
1288
- textBeforeCursor.startsWith("/") &&
1289
- this.#isInSubmittedSlashCommandContext() &&
1297
+ (this.#isInSubmittedSlashCommandContext() ||
1298
+ this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true) &&
1290
1299
  this.#autocompleteProvider?.trySyncSlashCompletion
1291
1300
  ) {
1292
1301
  const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
@@ -1509,10 +1518,34 @@ export class Editor implements Component, Focusable {
1509
1518
  }
1510
1519
 
1511
1520
  if (hasCursorInChunk) {
1521
+ let displayChunkText = chunk.text;
1522
+ let displayCursorPos = adjustedCursorPos;
1523
+ if (displayCursorPos > displayChunkText.length) {
1524
+ let hiddenWhitespaceWidth = displayCursorPos - displayChunkText.length;
1525
+ const displayChunkWidth = visibleWidth(displayChunkText);
1526
+ if (displayChunkWidth + hiddenWhitespaceWidth <= contentWidth) {
1527
+ displayChunkText += padding(hiddenWhitespaceWidth);
1528
+ } else {
1529
+ layoutLines.push({
1530
+ text: displayChunkText,
1531
+ hasCursor: false,
1532
+ });
1533
+ hiddenWhitespaceWidth -= Math.max(0, contentWidth - displayChunkWidth);
1534
+ while (hiddenWhitespaceWidth > contentWidth) {
1535
+ layoutLines.push({
1536
+ text: padding(contentWidth),
1537
+ hasCursor: false,
1538
+ });
1539
+ hiddenWhitespaceWidth -= contentWidth;
1540
+ }
1541
+ displayChunkText = padding(hiddenWhitespaceWidth);
1542
+ displayCursorPos = hiddenWhitespaceWidth;
1543
+ }
1544
+ }
1512
1545
  layoutLines.push({
1513
- text: chunk.text,
1546
+ text: displayChunkText,
1514
1547
  hasCursor: true,
1515
- cursorPos: adjustedCursorPos,
1548
+ cursorPos: displayCursorPos,
1516
1549
  });
1517
1550
  } else {
1518
1551
  layoutLines.push({
@@ -1699,7 +1732,8 @@ export class Editor implements Component, Focusable {
1699
1732
 
1700
1733
  // Check if we should trigger or update autocomplete
1701
1734
  if (!this.#autocompleteState) {
1702
- // Auto-trigger for "/" at the start of a line (slash commands)
1735
+ // Auto-trigger for "/" at the start of a submitted command.
1736
+ // Inline skill autocomplete starts after the token becomes "/skill...".
1703
1737
  if (char === "/" && this.#isAtStartOfSubmittedMessage()) {
1704
1738
  this.#tryTriggerAutocomplete();
1705
1739
  }
@@ -1721,8 +1755,8 @@ export class Editor implements Component, Focusable {
1721
1755
  else if (/[a-zA-Z0-9.\-_/]/.test(char)) {
1722
1756
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1723
1757
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1724
- // Check if we're in a slash command (with or without space for arguments)
1725
- if (this.#isInSubmittedSlashCommandContext()) {
1758
+ // Check if we're in a slash command or inline slash token
1759
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
1726
1760
  this.#tryTriggerAutocomplete();
1727
1761
  }
1728
1762
  // Check if we're in an @ file reference context
@@ -1917,8 +1951,8 @@ export class Editor implements Component, Focusable {
1917
1951
  // If autocomplete was cancelled (no matches), re-trigger if we're in a completable context
1918
1952
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
1919
1953
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1920
- // Slash command context
1921
- if (this.#isInSubmittedSlashCommandContext()) {
1954
+ // Slash command or inline slash token context
1955
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
1922
1956
  this.#tryTriggerAutocomplete();
1923
1957
  }
1924
1958
  // @ file reference context
@@ -2074,7 +2108,7 @@ export class Editor implements Component, Focusable {
2074
2108
  } else {
2075
2109
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2076
2110
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2077
- if (this.#isInSubmittedSlashCommandContext()) {
2111
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
2078
2112
  this.#tryTriggerAutocomplete();
2079
2113
  } else if (textBeforeCursor.match(/(?:^|[\s])@[^\s]*$/)) {
2080
2114
  this.#tryTriggerAutocomplete();
@@ -2388,8 +2422,8 @@ export class Editor implements Component, Focusable {
2388
2422
  } else {
2389
2423
  const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2390
2424
  const textBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
2391
- // Slash command context
2392
- if (this.#isInSubmittedSlashCommandContext()) {
2425
+ // Slash command or inline slash token context
2426
+ if (this.#isInSubmittedSlashCommandContext() || this.#isInSlashTokenContext()) {
2393
2427
  this.#tryTriggerAutocomplete();
2394
2428
  }
2395
2429
  // @ file reference context
@@ -2618,6 +2652,17 @@ export class Editor implements Component, Focusable {
2618
2652
  return this.#hasOnlyWhitespaceBeforeCursorLine() && beforeCursor.trimStart().startsWith("/");
2619
2653
  }
2620
2654
 
2655
+ #getSlashTokenBeforeCursor(): string | null {
2656
+ const currentLine = this.#state.lines[this.#state.cursorLine] || "";
2657
+ const beforeCursor = currentLine.slice(0, this.#state.cursorCol);
2658
+ const match = beforeCursor.match(/(?:^|\s)(\/[^\s]*)$/);
2659
+ return match?.[1] ?? null;
2660
+ }
2661
+
2662
+ #isInSlashTokenContext(): boolean {
2663
+ return this.#getSlashTokenBeforeCursor()?.startsWith("/skill") === true;
2664
+ }
2665
+
2621
2666
  #isSlashCommandNameAutocompleteSelection(): boolean {
2622
2667
  if (this.#autocompleteState !== "regular") {
2623
2668
  return false;
@@ -58,7 +58,8 @@ export class Input implements Component, Focusable {
58
58
  setValue(value: string): void {
59
59
  const normalized = value.normalize("NFC");
60
60
  this.#value = normalized;
61
- this.#cursor = Math.min(this.#cursor, normalized.length);
61
+ this.#cursor = normalized.length;
62
+ this.#lastAction = null;
62
63
  }
63
64
 
64
65
  handleInput(data: string): void {
@@ -117,8 +117,15 @@ export class SelectList implements Component {
117
117
  }
118
118
 
119
119
  handleInput(keyData: string): void {
120
- if (this.#filteredItems.length === 0) return;
121
120
  const kb = getKeybindings();
121
+ if (this.#filteredItems.length === 0) {
122
+ if (kb.matches(keyData, "tui.select.cancel")) {
123
+ if (this.onCancel) {
124
+ this.onCancel();
125
+ }
126
+ }
127
+ return;
128
+ }
122
129
  // Up arrow - wrap to bottom when at top
123
130
  if (kb.matches(keyData, "tui.select.up")) {
124
131
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#filteredItems.length - 1 : this.#selectedIndex - 1;
@@ -32,6 +32,7 @@ export class SettingsList implements Component {
32
32
  #maxVisible: number;
33
33
  #onChange: (id: string, newValue: string) => void;
34
34
  #onCancel: () => void;
35
+ #onSelectionChange?: (item: SettingItem | undefined) => void;
35
36
 
36
37
  // Submenu state
37
38
  #submenuComponent: Component | null = null;
@@ -43,12 +44,15 @@ export class SettingsList implements Component {
43
44
  theme: SettingsListTheme,
44
45
  onChange: (id: string, newValue: string) => void,
45
46
  onCancel: () => void,
47
+ onSelectionChange?: (item: SettingItem | undefined) => void,
46
48
  ) {
47
49
  this.#items = items;
48
50
  this.#maxVisible = maxVisible;
49
51
  this.#theme = theme;
50
52
  this.#onChange = onChange;
51
53
  this.#onCancel = onCancel;
54
+ this.#onSelectionChange = onSelectionChange;
55
+ this.#notifySelectionChange();
52
56
  }
53
57
 
54
58
  /** Update an item's currentValue */
@@ -73,12 +77,17 @@ export class SettingsList implements Component {
73
77
  } else if (this.#selectedIndex >= this.#items.length) {
74
78
  this.#selectedIndex = this.#items.length - 1;
75
79
  }
80
+ this.#notifySelectionChange();
76
81
  }
77
82
 
78
83
  invalidate(): void {
79
84
  this.#submenuComponent?.invalidate?.();
80
85
  }
81
86
 
87
+ #notifySelectionChange(): void {
88
+ this.#onSelectionChange?.(this.#items[this.#selectedIndex]);
89
+ }
90
+
82
91
  render(width: number): string[] {
83
92
  // If submenu is active, render it instead
84
93
  if (this.#submenuComponent) {
@@ -167,8 +176,10 @@ export class SettingsList implements Component {
167
176
  const kb = getKeybindings();
168
177
  if (kb.matches(data, "tui.select.up")) {
169
178
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
179
+ this.#notifySelectionChange();
170
180
  } else if (kb.matches(data, "tui.select.down")) {
171
181
  this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
182
+ this.#notifySelectionChange();
172
183
  } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
173
184
  this.#activateItem();
174
185
  } else if (kb.matches(data, "tui.select.cancel")) {
@@ -206,6 +217,7 @@ export class SettingsList implements Component {
206
217
  if (this.#submenuItemIndex !== null) {
207
218
  this.#selectedIndex = this.#submenuItemIndex;
208
219
  this.#submenuItemIndex = null;
220
+ this.#notifySelectionChange();
209
221
  }
210
222
  }
211
223
  }
package/src/terminal.ts CHANGED
@@ -164,9 +164,50 @@ export interface Terminal {
164
164
  get appearance(): TerminalAppearance | undefined;
165
165
  }
166
166
 
167
+ interface TerminalSizeStream {
168
+ columns?: number;
169
+ rows?: number;
170
+ getWindowSize?: () => [number, number] | number[];
171
+ }
172
+
173
+ function positiveDimension(value: unknown): number | undefined {
174
+ if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
175
+ const dimension = Math.trunc(value);
176
+ return dimension > 0 ? dimension : undefined;
177
+ }
178
+
179
+ export function resolveTerminalColumns(
180
+ stream: TerminalSizeStream = process.stdout,
181
+ envColumns: string | undefined = Bun.env.COLUMNS,
182
+ ): number {
183
+ try {
184
+ const windowSize = stream.getWindowSize?.();
185
+ const liveColumns = positiveDimension(windowSize?.[0]);
186
+ if (liveColumns !== undefined) return liveColumns;
187
+ } catch {
188
+ // Fall back below when the stream cannot report a live TTY size.
189
+ }
190
+ return positiveDimension(stream.columns) ?? positiveDimension(Number(envColumns)) ?? 80;
191
+ }
192
+
193
+ export function resolveTerminalRows(
194
+ stream: TerminalSizeStream = process.stdout,
195
+ envRows: string | undefined = Bun.env.LINES,
196
+ ): number {
197
+ try {
198
+ const windowSize = stream.getWindowSize?.();
199
+ const liveRows = positiveDimension(windowSize?.[1]);
200
+ if (liveRows !== undefined) return liveRows;
201
+ } catch {
202
+ // Fall back below when the stream cannot report a live TTY size.
203
+ }
204
+ return positiveDimension(stream.rows) ?? positiveDimension(Number(envRows)) ?? 24;
205
+ }
206
+
167
207
  function isWindowsSubsystemForLinux(): boolean {
168
208
  return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
169
209
  }
210
+ const STDOUT_ERROR_HANDLER_GRACE_MS = 250;
170
211
 
171
212
  /**
172
213
  * Real terminal using process.stdin/stdout
@@ -185,6 +226,7 @@ export class ProcessTerminal implements Terminal {
185
226
  #detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
186
227
  #windowsVTInputRestore?: () => void;
187
228
  #stdoutErrorHandler?: (err: Error) => void;
229
+ #stdoutErrorHandlerCleanupTimer?: Timer;
188
230
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
189
231
  #appearance: TerminalAppearance | undefined;
190
232
  #osc11Pending = false;
@@ -237,10 +279,16 @@ export class ProcessTerminal implements Terminal {
237
279
 
238
280
  // Set up resize handler immediately
239
281
  process.stdout.on("resize", this.#resizeHandler);
240
- this.#stdoutErrorHandler = (err: Error) => {
241
- this.#markUnavailable(err, "stdout-error");
242
- };
243
- process.stdout.on("error", this.#stdoutErrorHandler);
282
+ if (this.#stdoutErrorHandlerCleanupTimer) {
283
+ clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
284
+ this.#stdoutErrorHandlerCleanupTimer = undefined;
285
+ }
286
+ if (!this.#stdoutErrorHandler) {
287
+ this.#stdoutErrorHandler = (err: Error) => {
288
+ this.#markUnavailable(err, "stdout-error");
289
+ };
290
+ process.stdout.on("error", this.#stdoutErrorHandler);
291
+ }
244
292
 
245
293
  // Refresh terminal dimensions - they may be stale after suspend/resume
246
294
  // (SIGWINCH is lost while process is stopped). Unix only.
@@ -582,6 +630,20 @@ export class ProcessTerminal implements Terminal {
582
630
  return;
583
631
  }
584
632
  this.#safeWrite("\x1b[?u");
633
+ // Windows Terminal and conhost do not implement the Kitty keyboard
634
+ // protocol, so the query above never activates it there. They do honor the
635
+ // modifyOtherKeys fallback below — but that mode breaks Windows CJK/Hangul
636
+ // IME composition: Alt+Enter (and other chords) bypass the IME commit, so
637
+ // the syllable still being composed is never delivered to the app and the
638
+ // action fires on empty text (e.g. queue-message no-ops unless the user
639
+ // types a trailing space to force a commit first). Skip the fallback on
640
+ // win32; legacy encodings still deliver Alt+Enter (ESC CR) and the newline
641
+ // chords, and IME composition works again. Opt back in with
642
+ // SKC_TUI_KEYBOARD_PROTOCOL=0 disabling all enhancement, or force-enable
643
+ // elsewhere if a Kitty-capable Windows terminal appears.
644
+ if (process.platform === "win32") {
645
+ return;
646
+ }
585
647
  this.#modifyOtherKeysTimeout = setTimeout(() => {
586
648
  this.#modifyOtherKeysTimeout = undefined;
587
649
  if (this.#kittyProtocolActive || this.#modifyOtherKeysActive) {
@@ -696,10 +758,7 @@ export class ProcessTerminal implements Terminal {
696
758
  process.stdout.removeListener("resize", this.#resizeHandler);
697
759
  this.#resizeHandler = undefined;
698
760
  }
699
- if (this.#stdoutErrorHandler) {
700
- process.stdout.removeListener("error", this.#stdoutErrorHandler);
701
- this.#stdoutErrorHandler = undefined;
702
- }
761
+ this.#scheduleStdoutErrorHandlerCleanup();
703
762
 
704
763
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
705
764
  // re-interpreted after raw mode is disabled. This fixes a race condition
@@ -712,6 +771,23 @@ export class ProcessTerminal implements Terminal {
712
771
  }
713
772
  }
714
773
 
774
+ #scheduleStdoutErrorHandlerCleanup(): void {
775
+ if (!this.#stdoutErrorHandler) return;
776
+ if (this.#stdoutErrorHandlerCleanupTimer) clearTimeout(this.#stdoutErrorHandlerCleanupTimer);
777
+ // Terminal restore writes above can fail asynchronously after stop() returns
778
+ // when an SSH/Windows Terminal PTY disappears. Keep the stdout error listener
779
+ // armed briefly so late EIO/EPIPE events mark the terminal unavailable instead
780
+ // of surfacing as uncaught exceptions that kill the tmux pane.
781
+ this.#stdoutErrorHandlerCleanupTimer = setTimeout(() => {
782
+ if (this.#stdoutErrorHandler) {
783
+ process.stdout.removeListener("error", this.#stdoutErrorHandler);
784
+ this.#stdoutErrorHandler = undefined;
785
+ }
786
+ this.#stdoutErrorHandlerCleanupTimer = undefined;
787
+ }, STDOUT_ERROR_HANDLER_GRACE_MS);
788
+ this.#stdoutErrorHandlerCleanupTimer.unref?.();
789
+ }
790
+
715
791
  write(data: string): void {
716
792
  this.#safeWrite(data);
717
793
  if (this.#writeLogPath) {
@@ -789,11 +865,11 @@ export class ProcessTerminal implements Terminal {
789
865
  }
790
866
 
791
867
  get columns(): number {
792
- return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
868
+ return resolveTerminalColumns();
793
869
  }
794
870
 
795
871
  get rows(): number {
796
- return process.stdout.rows || Number(Bun.env.LINES) || 24;
872
+ return resolveTerminalRows();
797
873
  }
798
874
 
799
875
  moveBy(lines: number): void {
package/src/tui.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  import * as fs from "node:fs";
5
5
  import * as path from "node:path";
6
6
  import { performance } from "node:perf_hooks";
7
- import { $flag, getDebugLogPath } from "@sayknow-cli/utils";
7
+ import { $flag, getDebugLogPath, logger } from "@sayknow-cli/utils";
8
8
  import { getKeybindings } from "./keybindings";
9
9
  import { isKeyRelease } from "./keys";
10
10
  import { renderMetrics } from "./metrics";
@@ -257,7 +257,7 @@ export class Container implements Component {
257
257
  width = Math.max(1, width);
258
258
  const lines: string[] = [];
259
259
  for (const child of this.children) {
260
- const childLines = child.render(width);
260
+ const childLines = safeRenderComponent(child, width, "container-child");
261
261
  for (let i = 0; i < childLines.length; i++) {
262
262
  lines.push(childLines[i]);
263
263
  }
@@ -266,6 +266,40 @@ export class Container implements Component {
266
266
  }
267
267
  }
268
268
 
269
+ const MAX_REPORTED_RENDER_ERRORS = 200;
270
+ const reportedRenderErrors = new Set<string>();
271
+
272
+ /**
273
+ * Render a component's lines without letting a thrown error escape the frame.
274
+ *
275
+ * The TUI render loop ({@link TUI.#doRender}) runs inside a `nextTick`/`setTimeout`
276
+ * with no try/catch, and the process installs a fail-fast `uncaughtException`
277
+ * handler that exits. So a single component whose `render()` throws (e.g. a tool
278
+ * renderer fed an optional/undefined field) used to take down the whole app —
279
+ * fatal on whatever happened to trigger the frame (a keystroke, resize, or a
280
+ * command such as `/background`). Isolate the failure: log it once, emit a
281
+ * visible fallback line, and keep rendering the rest of the tree.
282
+ */
283
+ function safeRenderComponent(component: Component, width: number, where: string): string[] {
284
+ try {
285
+ return component.render(width);
286
+ } catch (err) {
287
+ const name = component?.constructor?.name ?? "Component";
288
+ const key = `${where}:${name}:${err instanceof Error ? err.message : String(err)}`;
289
+ if (!reportedRenderErrors.has(key)) {
290
+ if (reportedRenderErrors.size >= MAX_REPORTED_RENDER_ERRORS) reportedRenderErrors.clear();
291
+ reportedRenderErrors.add(key);
292
+ logger.error("Component render failed; emitting fallback line", {
293
+ where,
294
+ component: name,
295
+ error: err instanceof Error ? err.message : String(err),
296
+ stack: err instanceof Error ? err.stack : undefined,
297
+ });
298
+ }
299
+ return [`[render error: ${name}]`];
300
+ }
301
+ }
302
+
269
303
  type LineNormalizationCacheEntry = {
270
304
  normalized: string;
271
305
  terminated: string;
@@ -307,6 +341,8 @@ export class TUI extends Container {
307
341
  #cursorRow = 0; // Logical cursor row (end of rendered content)
308
342
  #hardwareCursorRow = 0; // Actual terminal cursor row (may differ due to IME positioning)
309
343
  #viewportTopRow = 0; // Content row currently mapped to screen row 0
344
+ #manualViewportTop: number | undefined;
345
+ #lastCursorPosition: { row: number; col: number } | null = null;
310
346
  #sixelProbePendingDa = false;
311
347
  #sixelProbePendingGraphics = false;
312
348
  #sixelProbeBuffer = "";
@@ -393,6 +429,47 @@ export class TUI extends Container {
393
429
  this.#bottomPinnedComponent = component;
394
430
  this.requestRender();
395
431
  }
432
+ scrollViewportPages(direction: -1 | 1): boolean {
433
+ const height = this.terminal.rows;
434
+ const width = this.terminal.columns;
435
+ if (height <= 0 || width <= 0 || this.#previousLines.length === 0) return false;
436
+ const maxViewportTop = Math.max(0, this.#previousLines.length - height);
437
+ const currentViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop ?? this.#viewportTopRow));
438
+ const pageStep = Math.max(1, height - 1);
439
+ const targetViewportTop = Math.max(0, Math.min(maxViewportTop, currentViewportTop + direction * pageStep));
440
+
441
+ if (targetViewportTop >= maxViewportTop) {
442
+ this.#manualViewportTop = undefined;
443
+ } else {
444
+ this.#manualViewportTop = targetViewportTop;
445
+ }
446
+
447
+ const cursorPos = this.#manualViewportTop === undefined ? this.#lastCursorPosition : null;
448
+ return this.#repaintViewportFromLines(
449
+ this.#previousLines,
450
+ width,
451
+ height,
452
+ targetViewportTop,
453
+ cursorPos,
454
+ "manual viewport scroll",
455
+ );
456
+ }
457
+
458
+ followLiveViewport(): boolean {
459
+ if (this.#manualViewportTop === undefined) return false;
460
+ const height = this.terminal.rows;
461
+ const width = this.terminal.columns;
462
+ const liveViewportTop = Math.max(0, this.#previousLines.length - height);
463
+ this.#manualViewportTop = undefined;
464
+ return this.#repaintViewportFromLines(
465
+ this.#previousLines,
466
+ width,
467
+ height,
468
+ liveViewportTop,
469
+ this.#lastCursorPosition,
470
+ "manual viewport follow live",
471
+ );
472
+ }
396
473
 
397
474
  /**
398
475
  * Show an overlay component with configurable positioning and sizing.
@@ -492,7 +569,7 @@ export class TUI extends Container {
492
569
  data => this.#handleInput(data),
493
570
  () => {
494
571
  this.invalidate();
495
- this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
572
+ this.requestResizeRender();
496
573
  },
497
574
  );
498
575
  this.#hideCursor();
@@ -736,6 +813,22 @@ export class TUI extends Container {
736
813
  this.#previousHeight = 0;
737
814
  }
738
815
 
816
+ /**
817
+ * Multiplexer-aware resize render request.
818
+ *
819
+ * A forced full redraw (`requestRender(true)`) resets `#previousWidth`/`#previousHeight`
820
+ * to -1, which makes `#doRender` treat the frame as a width change and fall into the
821
+ * `fullRender` path. In terminal multiplexers that path skips the scrollback-clearing
822
+ * `3J` escape (users navigate scrollback history), so replaying every transcript line
823
+ * piles it back on top of scrollback — the "top of screen scrolls down to the prompt at
824
+ * high speed" resize storm. Here we keep force off in multiplexers so `#doRender`'s
825
+ * height-change branch takes the viewport-only `multiplexerViewportRepaint` path instead.
826
+ * Set `PI_TUI_LEGACY_MULTIPLEXER_FULL_RENDER=1` to restore the legacy forced redraw.
827
+ */
828
+ requestResizeRender(): void {
829
+ this.requestRender(!(isMultiplexerSession() && !useLegacyMultiplexerFullRender()), "resize");
830
+ }
831
+
739
832
  requestRender(force = false, source = "unknown"): void {
740
833
  if (!this.terminalAvailable) {
741
834
  this.#markTerminalUnavailable();
@@ -1071,7 +1164,7 @@ export class TUI extends Container {
1071
1164
  const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
1072
1165
 
1073
1166
  // Render component at calculated width
1074
- let overlayLines = component.render(width);
1167
+ let overlayLines = safeRenderComponent(component, width, "overlay");
1075
1168
 
1076
1169
  // Apply maxHeight if specified
1077
1170
  if (maxHeight !== undefined && overlayLines.length > maxHeight) {
@@ -1302,7 +1395,7 @@ export class TUI extends Container {
1302
1395
 
1303
1396
  let pinnedLineCount = 0;
1304
1397
  for (let i = pinnedStart; i < this.children.length; i++) {
1305
- pinnedLineCount += this.children[i].render(this.terminal.columns).length;
1398
+ pinnedLineCount += safeRenderComponent(this.children[i], this.terminal.columns, "pinned").length;
1306
1399
  }
1307
1400
 
1308
1401
  const blankRows = height - lines.length;
@@ -1311,6 +1404,64 @@ export class TUI extends Container {
1311
1404
  padded.splice(insertAt, 0, ...Array.from({ length: blankRows }, () => ""));
1312
1405
  return padded;
1313
1406
  }
1407
+ #repaintViewportFromLines(
1408
+ lines: string[],
1409
+ width: number,
1410
+ height: number,
1411
+ viewportTop: number,
1412
+ cursorPos: { row: number; col: number } | null,
1413
+ reason: string,
1414
+ ): boolean {
1415
+ if (height <= 0 || width <= 0) return false;
1416
+ const maxViewportTop = Math.max(0, lines.length - height);
1417
+ const nextViewportTop = Math.max(0, Math.min(maxViewportTop, viewportTop));
1418
+ const currentScreenRow = Math.max(0, Math.min(height - 1, this.#hardwareCursorRow - this.#viewportTopRow));
1419
+ let buffer = "\x1b[?2026h";
1420
+ if (currentScreenRow > 0) {
1421
+ buffer += `\x1b[${currentScreenRow}A`;
1422
+ }
1423
+ buffer += "\r";
1424
+
1425
+ for (let screenRow = 0; screenRow < height; screenRow++) {
1426
+ if (screenRow > 0) buffer += "\r\n";
1427
+ buffer += "\x1b[2K";
1428
+ const lineIndex = nextViewportTop + screenRow;
1429
+ if (lineIndex >= lines.length) continue;
1430
+ const line = lines[lineIndex];
1431
+ const isImage = TERMINAL.isImageLine(line);
1432
+ if (!isImage && visibleWidth(line) > width) {
1433
+ let truncatedLine = truncateToWidth(line, width, Ellipsis.Omit);
1434
+ truncatedLine += truncatedLine.includes("\x1b]8;") ? LINE_TERMINATOR : SEGMENT_RESET;
1435
+ buffer += truncatedLine;
1436
+ } else {
1437
+ buffer += line;
1438
+ }
1439
+ }
1440
+
1441
+ const finalPhysicalRow = nextViewportTop + Math.max(0, height - 1);
1442
+ let cursorSeq = "\x1b[?25l";
1443
+ let cursorToRow = finalPhysicalRow;
1444
+ if (cursorPos && cursorPos.row >= nextViewportTop && cursorPos.row < nextViewportTop + height) {
1445
+ const cursor = this.#cursorControlSequence(cursorPos, lines.length, finalPhysicalRow);
1446
+ cursorSeq = cursor.seq;
1447
+ cursorToRow = cursor.toRow;
1448
+ }
1449
+ this.#hardwareCursorRow = cursorToRow;
1450
+ buffer += cursorSeq;
1451
+ buffer += "\x1b[?2026l";
1452
+ if (!this.#writeTerminal(buffer)) return false;
1453
+
1454
+ if ($flag("PI_DEBUG_REDRAW")) {
1455
+ const logPath = getDebugLogPath();
1456
+ const msg = `[${new Date().toISOString()}] viewportRepaint: ${reason} (lines=${lines.length}, height=${height}, viewportTop=${nextViewportTop})\n`;
1457
+ fs.appendFileSync(logPath, msg);
1458
+ }
1459
+
1460
+ this.#cursorRow = Math.max(0, lines.length - 1);
1461
+ this.#maxLinesRendered = lines.length;
1462
+ this.#viewportTopRow = nextViewportTop;
1463
+ return true;
1464
+ }
1314
1465
 
1315
1466
  #doRender(): void {
1316
1467
  if (this.#stopped || !this.terminalAvailable) return;
@@ -1341,6 +1492,7 @@ export class TUI extends Container {
1341
1492
 
1342
1493
  // Extract cursor position (marker must be found before diff comparison)
1343
1494
  const cursorPos = this.#extractCursorPosition(newLines, height);
1495
+ this.#lastCursorPosition = cursorPos;
1344
1496
 
1345
1497
  // Terminate every non-image line so #previousLines mirrors emitted bytes
1346
1498
  // (closes SGR + OSC 8 hyperlink state). Must run after cursor extraction
@@ -1401,6 +1553,28 @@ export class TUI extends Container {
1401
1553
  if (usedWindowNormalize) renderMetrics.recordLineCount("offscreenScan", diffStart);
1402
1554
  }
1403
1555
 
1556
+ if (this.#manualViewportTop !== undefined) {
1557
+ const maxViewportTop = Math.max(0, newLines.length - height);
1558
+ const nextViewportTop = Math.max(0, Math.min(maxViewportTop, this.#manualViewportTop));
1559
+ const followingLive = nextViewportTop >= maxViewportTop;
1560
+ this.#manualViewportTop = followingLive ? undefined : nextViewportTop;
1561
+ const repaintCursorPos = followingLive ? cursorPos : null;
1562
+ if (
1563
+ this.#repaintViewportFromLines(
1564
+ newLines,
1565
+ width,
1566
+ height,
1567
+ nextViewportTop,
1568
+ repaintCursorPos,
1569
+ "manual viewport render",
1570
+ )
1571
+ ) {
1572
+ this.#previousLines = newLines;
1573
+ this.#previousWidth = width;
1574
+ this.#previousHeight = height;
1575
+ }
1576
+ return;
1577
+ }
1404
1578
  // Helper to clear scrollback and viewport and render all new lines
1405
1579
  const fullRender = (clear: boolean, reason = "full render"): void => {
1406
1580
  this.#fullRedrawCount += 1;
@@ -1506,7 +1680,16 @@ export class TUI extends Container {
1506
1680
  // Width changes always need a full re-render because wrapping changes.
1507
1681
  if (widthChanged) {
1508
1682
  logRedraw(`terminal width changed (${this.#previousWidth} -> ${width})`);
1509
- fullRender(true, "terminal width changed");
1683
+ if (isMultiplexerSession() && !useLegacyMultiplexerFullRender()) {
1684
+ // In multiplexers a full replay piles the whole transcript back onto
1685
+ // scrollback (3J is intentionally skipped). Repaint the viewport only,
1686
+ // mirroring the height-change branch. This also neutralizes the fake
1687
+ // width change that requestRender(true) injects via #previousWidth = -1,
1688
+ // so every force-render call site is safe in multiplexers too.
1689
+ multiplexerViewportRepaint(`terminal width changed (${this.#previousWidth} -> ${width})`);
1690
+ } else {
1691
+ fullRender(true, "terminal width changed");
1692
+ }
1510
1693
  return;
1511
1694
  }
1512
1695