@gajae-code/tui 0.12.4 → 0.12.6

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,12 +2,38 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.12.6] - 2026-07-31
6
+ ### Fixed
7
+
8
+ - Select-list no-match rows now stay within the requested render width instead of wrapping into extra terminal lines after narrow resizes (#3600).
9
+
10
+ ## [0.12.5] - 2026-07-30
11
+
12
+ ## [0.12.5] - 2026-07-30
13
+
5
14
  ## [0.12.4] - 2026-07-30
6
15
 
7
16
  ## [0.12.3] - 2026-07-30
8
17
 
9
18
  ## [0.12.2] - 2026-07-30
10
19
 
20
+ ## [0.12.1] - 2026-07-29
21
+ ### Added
22
+
23
+ - `Tui` now exposes renderer-owned viewport observations so tests and evidence capture can assert against committed paint state instead of re-deriving geometry: `getViewportObservation()` (returns a defensive copy of the latest committed observation), `getViewportAnchorSnapshot()`, `getViewportAnchorComponent()`, `getFocusedComponent()`, and `setViewportSelection()`, plus the `TuiViewportObservation` and `MouseSelectionPoint` types.
24
+
25
+ ### Changed
26
+
27
+ - Alternate-scroll mode (`DECSET 1007`) is now explicitly disabled at startup, on every mouse-capture toggle, at stop, and during emergency restore, so no host translates wheel input into cursor keys. While mouse capture is off this leaves wheel scrollback to the host terminal or multiplexer; while capture is on GJC consumes SGR wheel reports itself, and the reset removes a conflicting second translation path. Hosts that never answer a `DECRQM ?1007$p` query (Apple Terminal among them) cannot report this mode, so it is reset unconditionally rather than conditionally on a reported state.
28
+
29
+ ### Fixed
30
+
31
+ - The renderer's reported cursor column is now clamped to the last real cell, so it always names a column that exists in the current terminal width. On the wire this is unchanged (a terminal already clamps `CHA` to the last column), but the reported value is now truthful for callers reading it through the viewport observation.
32
+
33
+ ## [0.12.3] - 2026-07-30
34
+
35
+ ## [0.12.2] - 2026-07-30
36
+
11
37
  ## [0.12.1] - 2026-07-29
12
38
 
13
39
  ### Fixed
@@ -29,6 +29,10 @@ type OverlayMouseBounds = {
29
29
  termWidth: number;
30
30
  termHeight: number;
31
31
  };
32
+ export type MouseSelectionPoint = {
33
+ line: number;
34
+ column: number;
35
+ };
32
36
  /** Parse xterm SGR mouse reports for wheel, left-click, drag, and release events. */
33
37
  export declare function parseSgrMouseEvent(data: string): MouseEvent | undefined;
34
38
  export interface Component {
@@ -84,6 +88,30 @@ export declare const CURSOR_MARKER = "\u001B_pi:c\u0007";
84
88
  export { visibleWidth };
85
89
  /** Durable source identifier for a semantically anchored viewport row. */
86
90
  export type ViewportAnchorId = string;
91
+ /** Immutable renderer-owned details of the most recently rendered viewport. */
92
+ export interface TuiViewportObservation {
93
+ transcriptCapacity: number;
94
+ pinBoundary: {
95
+ row: number;
96
+ pinned: boolean;
97
+ };
98
+ manualHistory: boolean;
99
+ newOutputNoticeVisible: boolean;
100
+ outputRevision: string | null;
101
+ focused: boolean;
102
+ cursor: {
103
+ row: number;
104
+ col: number;
105
+ visible: boolean;
106
+ } | null;
107
+ selection: {
108
+ start: MouseSelectionPoint;
109
+ end: MouseSelectionPoint;
110
+ } | null;
111
+ semanticAnchor: (ViewportAnchorRow & {
112
+ frameRow: number;
113
+ }) | null;
114
+ }
87
115
  export interface ViewportAnchorRow {
88
116
  id: ViewportAnchorId;
89
117
  graphemeStart: number;
@@ -258,6 +286,17 @@ export declare class TUI extends Container {
258
286
  */
259
287
  setClearOnShrink(enabled: boolean): void;
260
288
  setFocus(component: Component | null): void;
289
+ /** Returns the currently focused component without exposing mutable focus state. */
290
+ getFocusedComponent(): Component | null;
291
+ /** Returns a defensive snapshot of the latest renderer-owned viewport anchors. */
292
+ getViewportAnchorSnapshot(): {
293
+ startRow: number;
294
+ anchors: Array<ViewportAnchorRow | null>;
295
+ } | null;
296
+ /** Returns a defensive snapshot of renderer-owned viewport geometry. */
297
+ getViewportObservation(): TuiViewportObservation | null;
298
+ /** Selects a zero-based painted viewport cell range using the same renderer path as mouse dragging. */
299
+ setViewportSelection(start: MouseSelectionPoint, end: MouseSelectionPoint): void;
261
300
  removeChild(component: Component): void;
262
301
  clear(): void;
263
302
  setBottomPinnedComponent(component: Component | null): void;
@@ -265,6 +304,8 @@ export declare class TUI extends Container {
265
304
  setViewportOutputSource(source: ViewportOutputSource | null): void;
266
305
  /** Register the direct child whose rows are eligible for semantic viewport anchoring. */
267
306
  setViewportAnchorComponent(component: Component | null): void;
307
+ /** Returns the direct component registered as the semantic viewport anchor source. */
308
+ getViewportAnchorComponent(): Component | null;
268
309
  /** Clear manual viewport ownership before replacing the transcript identity namespace. */
269
310
  resetViewportAnchorIntent(): void;
270
311
  /** Allow one semantic-neighbor reconciliation after a definitive same-transcript rebuild. */
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.12.4",
4
+ "version": "0.12.6",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo and Gajae Code Contributors",
@@ -36,8 +36,8 @@
36
36
  "fmt": "biome format --write ."
37
37
  },
38
38
  "dependencies": {
39
- "@gajae-code/natives": "0.12.4",
40
- "@gajae-code/utils": "0.12.4",
39
+ "@gajae-code/natives": "0.12.6",
40
+ "@gajae-code/utils": "0.12.6",
41
41
  "lru-cache": "11.3.6",
42
42
  "marked": "18.0.6"
43
43
  },
@@ -118,7 +118,7 @@ export class SelectList implements Component {
118
118
 
119
119
  // If no items match filter, show message
120
120
  if (this.#filteredItems.length === 0) {
121
- lines.push(this.theme.noMatch(" No matching commands"));
121
+ lines.push(truncateToWidth(this.theme.noMatch(" No matching commands"), Math.max(0, width), Ellipsis.Omit));
122
122
  return lines;
123
123
  }
124
124
 
package/src/terminal.ts CHANGED
@@ -80,6 +80,7 @@ export function emergencyTerminalRestore(): void {
80
80
  "\x1b[?1000l" + // Disable normal mouse reporting
81
81
  "\x1b[?1002l" + // Disable button-event mouse reporting
82
82
  "\x1b[?1006l" + // Disable SGR extended mouse reporting
83
+ "\x1b[?1007l" + // Disable alternate-scroll wheel-to-cursor translation
83
84
  "\x1b[?2031l" + // Disable Mode 2031 appearance notifications
84
85
  "\x1b[<u" + // Pop kitty keyboard protocol
85
86
  "\x1b[>4;0m" + // Disable modifyOtherKeys fallback
@@ -278,7 +279,9 @@ export class ProcessTerminal implements Terminal {
278
279
  this.#mouseEnabled = enabled;
279
280
  if (this.#started)
280
281
  this.#safeWrite(
281
- this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l",
282
+ this.#mouseEnabled
283
+ ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h\x1b[?1007l"
284
+ : "\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1007l",
282
285
  );
283
286
  }
284
287
 
@@ -306,8 +309,14 @@ export class ProcessTerminal implements Terminal {
306
309
  // Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
307
310
  this.#safeWrite("\x1b[?2004h");
308
311
  // Button-event reporting preserves wheel input while also letting the TUI implement drag selection.
312
+ // Alternate-scroll must stay disabled: otherwise Windows Terminal/tmux can translate wheel notches
313
+ // into cursor Up/Down input, which the focused composer interprets as prompt history.
309
314
  // Clear both tracking variants first so stale modes from another application cannot leak across startup.
310
- this.#safeWrite(this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l");
315
+ this.#safeWrite(
316
+ this.#mouseEnabled
317
+ ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h\x1b[?1007l"
318
+ : "\x1b[?1000l\x1b[?1002l\x1b[?1006l\x1b[?1007l",
319
+ );
311
320
 
312
321
  // Set up resize handler immediately
313
322
  process.stdout.on("resize", this.#resizeHandler);
@@ -801,6 +810,7 @@ export class ProcessTerminal implements Terminal {
801
810
  this.#safeWrite("\x1b[?1000l");
802
811
  this.#safeWrite("\x1b[?1002l");
803
812
  this.#safeWrite("\x1b[?1006l");
813
+ this.#safeWrite("\x1b[?1007l");
804
814
 
805
815
  // Disable Mode 2031 appearance change notifications
806
816
  this.#safeWrite("\x1b[?2031l");
package/src/tui.ts CHANGED
@@ -83,7 +83,7 @@ type OverlayMouseBounds = {
83
83
  termHeight: number;
84
84
  };
85
85
 
86
- type MouseSelectionPoint = {
86
+ export type MouseSelectionPoint = {
87
87
  line: number;
88
88
  column: number;
89
89
  };
@@ -176,6 +176,18 @@ export { visibleWidth };
176
176
 
177
177
  /** Durable source identifier for a semantically anchored viewport row. */
178
178
  export type ViewportAnchorId = string;
179
+ /** Immutable renderer-owned details of the most recently rendered viewport. */
180
+ export interface TuiViewportObservation {
181
+ transcriptCapacity: number;
182
+ pinBoundary: { row: number; pinned: boolean };
183
+ manualHistory: boolean;
184
+ newOutputNoticeVisible: boolean;
185
+ outputRevision: string | null;
186
+ focused: boolean;
187
+ cursor: { row: number; col: number; visible: boolean } | null;
188
+ selection: { start: MouseSelectionPoint; end: MouseSelectionPoint } | null;
189
+ semanticAnchor: (ViewportAnchorRow & { frameRow: number }) | null;
190
+ }
179
191
 
180
192
  export interface ViewportAnchorRow {
181
193
  id: ViewportAnchorId;
@@ -731,6 +743,7 @@ export class TUI extends Container {
731
743
  #manualViewportFallbackAnchors: ManualViewportAnchor[] = [];
732
744
  #reconcileMissingViewportAnchor = false;
733
745
  #lastCursorPosition: { row: number; col: number } | null = null;
746
+ #latestViewportObservation: TuiViewportObservation | null = null;
734
747
  #sixelProbePendingDa = false;
735
748
  #sixelProbePendingGraphics = false;
736
749
  #sixelProbeBuffer = "";
@@ -890,6 +903,61 @@ export class TUI extends Container {
890
903
  component.focused = true;
891
904
  }
892
905
  }
906
+ /** Returns the currently focused component without exposing mutable focus state. */
907
+ getFocusedComponent(): Component | null {
908
+ return this.#focusedComponent;
909
+ }
910
+
911
+ /** Returns a defensive snapshot of the latest renderer-owned viewport anchors. */
912
+ getViewportAnchorSnapshot(): { startRow: number; anchors: Array<ViewportAnchorRow | null> } | null {
913
+ if (this.#viewportAnchorFrame === null) return null;
914
+ return {
915
+ startRow: this.#viewportAnchorFrame.startRow,
916
+ anchors: this.#viewportAnchorFrame.anchors.map(anchor => (anchor ? { ...anchor } : null)),
917
+ };
918
+ }
919
+
920
+ /** Returns a defensive snapshot of renderer-owned viewport geometry. */
921
+ getViewportObservation(): TuiViewportObservation | null {
922
+ const observation = this.#latestViewportObservation;
923
+ return observation
924
+ ? {
925
+ ...observation,
926
+ pinBoundary: { ...observation.pinBoundary },
927
+ cursor: observation.cursor ? { ...observation.cursor } : null,
928
+ selection: observation.selection
929
+ ? { start: { ...observation.selection.start }, end: { ...observation.selection.end } }
930
+ : null,
931
+ semanticAnchor: observation.semanticAnchor ? { ...observation.semanticAnchor } : null,
932
+ }
933
+ : null;
934
+ }
935
+
936
+ /** Selects a zero-based painted viewport cell range using the same renderer path as mouse dragging. */
937
+ setViewportSelection(start: MouseSelectionPoint, end: MouseSelectionPoint): void {
938
+ if (!this.options.copySelection) return;
939
+ // Non-finite coordinates would survive the clamp below as NaN, latch
940
+ // #mouseSelectionDragged, and force a repaint every frame while reporting a
941
+ // NaN selection through getViewportObservation(). Reject them the same way
942
+ // scrollViewportBy does rather than storing an unpaintable selection.
943
+ if (![start.line, start.column, end.line, end.column].every(value => Number.isFinite(value))) return;
944
+ const map = (point: MouseSelectionPoint): MouseSelectionPoint | null => {
945
+ const row = Math.max(0, Math.min(this.terminal.rows - 1, point.line));
946
+ const column = Math.max(0, Math.min(this.terminal.columns - 1, point.column));
947
+ return this.#mouseSelectionPoint({ x: column + 1, y: row + 1, kind: "drag" });
948
+ };
949
+ const mappedStart = map(start);
950
+ const mappedEnd = map(end);
951
+ if (mappedStart === null || mappedEnd === null) {
952
+ this.#clearMouseSelection();
953
+ this.requestRender(false, "selection");
954
+ return;
955
+ }
956
+ this.#mouseSelectionStart = mappedStart;
957
+ this.#mouseSelectionEnd = mappedEnd;
958
+ this.#mouseSelectionDragged = true;
959
+ this.requestRender(false, "selection");
960
+ }
893
961
 
894
962
  override removeChild(component: Component): void {
895
963
  this.#invalidateFocusForRemovedTree(component);
@@ -952,6 +1020,10 @@ export class TUI extends Container {
952
1020
  this.#viewportAnchorComponent = component;
953
1021
  this.#viewportAnchorFrame = null;
954
1022
  }
1023
+ /** Returns the direct component registered as the semantic viewport anchor source. */
1024
+ getViewportAnchorComponent(): Component | null {
1025
+ return this.#viewportAnchorComponent;
1026
+ }
955
1027
 
956
1028
  /** Clear manual viewport ownership before replacing the transcript identity namespace. */
957
1029
  resetViewportAnchorIntent(): void {
@@ -2439,7 +2511,7 @@ export class TUI extends Container {
2439
2511
  if (markerIndex !== -1) {
2440
2512
  // Calculate visual column (width of text before marker)
2441
2513
  const beforeMarker = line.slice(0, markerIndex);
2442
- const col = visibleWidth(beforeMarker);
2514
+ const col = Math.max(0, Math.min(Math.max(0, this.terminal.columns - 1), visibleWidth(beforeMarker)));
2443
2515
 
2444
2516
  // Strip marker from the line
2445
2517
  lines[row] = line.slice(0, markerIndex) + line.slice(markerIndex + CURSOR_MARKER.length);
@@ -2909,6 +2981,8 @@ export class TUI extends Container {
2909
2981
  emittedRegions,
2910
2982
  );
2911
2983
  onPainted?.();
2984
+ this.#paintedManualOutputNotice = paintManual && this.#manualOutputNotice;
2985
+ this.#recordPaintedViewportObservation(nextViewportTop, height, paintManual);
2912
2986
  });
2913
2987
  if (!contentWritten) return false;
2914
2988
 
@@ -2918,6 +2992,63 @@ export class TUI extends Container {
2918
2992
  }
2919
2993
  return writeSucceeded;
2920
2994
  }
2995
+ #recordPaintedViewportObservation(viewportTop: number, height: number, paintManual: boolean): void {
2996
+ const transcriptCapacity = this.#manualTranscriptCapacity(height);
2997
+ const anchorFrame = this.#viewportAnchorFrame;
2998
+ const semanticAnchor =
2999
+ anchorFrame === null
3000
+ ? null
3001
+ : (this.#committedTranscriptRows
3002
+ .map((transcriptRow, screenRow) => {
3003
+ if (transcriptRow === null) return null;
3004
+ const anchor = anchorFrame.anchors[transcriptRow - anchorFrame.startRow];
3005
+ return anchor ? { ...anchor, frameRow: screenRow } : null;
3006
+ })
3007
+ .find(anchor => anchor !== null) ?? null);
3008
+ const cursor = this.#lastCursorPosition;
3009
+ let cursorRow: number | null = null;
3010
+ if (cursor !== null) {
3011
+ if (paintManual && cursor.row >= this.#manualTranscriptLineCount) {
3012
+ const noticeRows = this.#manualOutputNotice && height > this.#manualSuffixLineCount ? 1 : 0;
3013
+ cursorRow = transcriptCapacity + noticeRows + (cursor.row - this.#manualTranscriptLineCount);
3014
+ } else if (paintManual) {
3015
+ cursorRow = this.#committedTranscriptRows.indexOf(cursor.row);
3016
+ } else {
3017
+ cursorRow = cursor.row - viewportTop;
3018
+ }
3019
+ }
3020
+ const cursorVisible = cursorRow !== null && cursorRow >= 0 && cursorRow < height;
3021
+ const selectedRange = this.#mouseSelectionDragged ? this.#orderedMouseSelection() : null;
3022
+ const paintedSelection =
3023
+ selectedRange === null
3024
+ ? null
3025
+ : {
3026
+ start: { line: selectedRange.start.line - viewportTop, column: selectedRange.start.column },
3027
+ end: { line: selectedRange.end.line - viewportTop, column: selectedRange.end.column },
3028
+ };
3029
+ this.#latestViewportObservation = {
3030
+ transcriptCapacity,
3031
+ pinBoundary: { row: transcriptCapacity, pinned: this.#bottomPinnedComponent !== null },
3032
+ manualHistory: paintManual,
3033
+ newOutputNoticeVisible: paintManual && this.#paintedManualOutputNotice,
3034
+ outputRevision: this.#viewportOutputSource?.revision.toString() ?? null,
3035
+ focused: this.#focusedComponent !== null,
3036
+ cursor: cursor
3037
+ ? { row: cursorVisible ? cursorRow! : cursor.row, col: cursor.col, visible: cursorVisible }
3038
+ : null,
3039
+ selection: paintedSelection,
3040
+ semanticAnchor,
3041
+ };
3042
+ }
3043
+
3044
+ #refreshPaintedLiveViewportObservation(height: number): void {
3045
+ this.#committedTranscriptRows = Array.from({ length: height }, (_, screenRow) => {
3046
+ const transcriptRow = this.#viewportTopRow + screenRow;
3047
+ return transcriptRow < this.#manualTranscriptLineCount ? transcriptRow : null;
3048
+ });
3049
+ this.#paintedManualOutputNotice = false;
3050
+ this.#recordPaintedViewportObservation(this.#viewportTopRow, height, false);
3051
+ }
2921
3052
 
2922
3053
  #doRender(): void {
2923
3054
  if (this.#stopped || !this.terminalAvailable) return;
@@ -3147,6 +3278,7 @@ export class TUI extends Container {
3147
3278
  }
3148
3279
  const nextViewportTop = resolvedAnchorTop ?? this.#manualViewportTop;
3149
3280
  if (
3281
+ !this.#mouseSelectionDragged &&
3150
3282
  this.#previousWidth === width &&
3151
3283
  this.#previousHeight === height &&
3152
3284
  nextViewportTop === this.#manualViewportTop &&
@@ -3248,6 +3380,7 @@ export class TUI extends Container {
3248
3380
  );
3249
3381
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
3250
3382
  this.#manualSuffixLineCount = nextSuffixLineCount;
3383
+ this.#refreshPaintedLiveViewportObservation(height);
3251
3384
  })
3252
3385
  )
3253
3386
  return;
@@ -3310,6 +3443,7 @@ export class TUI extends Container {
3310
3443
  );
3311
3444
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
3312
3445
  this.#manualSuffixLineCount = nextSuffixLineCount;
3446
+ this.#refreshPaintedLiveViewportObservation(height);
3313
3447
  });
3314
3448
  if (!contentWritten) return false;
3315
3449
 
@@ -3436,8 +3570,8 @@ export class TUI extends Container {
3436
3570
 
3437
3571
  // No changes - but still need to update hardware cursor position if it moved
3438
3572
  if (firstChanged === -1) {
3439
- this.#writeCursorPosition(cursorPos, newLines.length);
3440
3573
  this.#viewportTopRow = Math.max(0, this.#maxLinesRendered - height);
3574
+ if (this.#writeCursorPosition(cursorPos, newLines.length)) this.#refreshPaintedLiveViewportObservation(height);
3441
3575
  return;
3442
3576
  }
3443
3577
 
@@ -3548,6 +3682,7 @@ export class TUI extends Container {
3548
3682
  );
3549
3683
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
3550
3684
  this.#manualSuffixLineCount = nextSuffixLineCount;
3685
+ this.#refreshPaintedLiveViewportObservation(height);
3551
3686
  })
3552
3687
  )
3553
3688
  return;
@@ -3559,6 +3694,7 @@ export class TUI extends Container {
3559
3694
  this.#viewportTopRow = Math.max(0, newLines.length - height);
3560
3695
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
3561
3696
  this.#manualSuffixLineCount = nextSuffixLineCount;
3697
+ this.#refreshPaintedLiveViewportObservation(height);
3562
3698
  return;
3563
3699
  }
3564
3700
 
@@ -3724,6 +3860,7 @@ export class TUI extends Container {
3724
3860
  );
3725
3861
  this.#manualTranscriptLineCount = nextTranscriptLineCount;
3726
3862
  this.#manualSuffixLineCount = nextSuffixLineCount;
3863
+ this.#refreshPaintedLiveViewportObservation(height);
3727
3864
  })
3728
3865
  )
3729
3866
  return;