@oh-my-pi/pi-tui 15.9.3 → 15.9.67

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,54 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [15.9.67] - 2026-06-06
6
+ ### Added
7
+
8
+ - Added `setPaddingX` to `Box` so horizontal padding can be updated programmatically after creation
9
+ - Added `ScrollView`, a fixed-height viewport component for pre-rendered lines with optional right-edge scrollbars and imperative scroll/page controls.
10
+ - Added optional `Terminal.hasEagerEraseScrollbackRisk()` so custom/test terminal implementations can override the global ED3-risk profile without mutating the shared `TERMINAL` object.
11
+
12
+ ### Changed
13
+
14
+ - Changed `SelectList` to render its visible window through `ScrollView`, replacing the `(N/M)` text scroll indicator with a uniform right-edge scrollbar (the type-to-search hint line is preserved).
15
+
16
+ ### Fixed
17
+
18
+ - Fixed unknown-viewport deferred renders freezing bottom-anchored live chrome; deferred history mutations can now repaint only the active-grid bottom row with relative cursor movement, so spinner/status tails keep advancing without rewriting rows a scrolled reader can still see.
19
+ - Fixed autocomplete popups freezing live repaint on ED3-risk macOS/POSIX terminals with unknown native viewport position; direct autocomplete shrink frames now repaint the live viewport without zero-byte deferral and preserve the old bottom anchor when padding can clear stale popup rows without duplicating committed scrollback.
20
+ - Fixed focused Up/Down navigation on ED3-risk macOS/POSIX terminals replaying the whole transcript after dirty foreground-stream renders; selector/editor frames now repaint non-destructively instead of emitting `CSI 3 J` on every arrow-key move ([#1962](https://github.com/can1357/oh-my-pi/issues/1962)).
21
+ - Fixed tmux (and screen/zellij) pane scrollback losing the head of a long streamed assistant reply once it grew past the visible pane, and stranding the chrome/footer in pane history after a later collapse — producing the "repeating chunks and missing sections" reporters saw when scrolling back through tmux pane history ([#1974](https://github.com/can1357/oh-my-pi/issues/1974)). The renderer's foreground-streaming cap-to-viewport branch (introduced in 15.9.2 for ED3-risk hosts that can checkpoint-rebuild later) also activated inside multiplexers, where checkpoint reconcile is a no-op (`refreshNativeScrollbackIfDirty` short-circuits because `\x1b[3J` cannot erase pane history). Every streaming frame clipped `lines` to the visible tail and reset `#scrollbackHighWater` to 0, so any row that scrolled above the viewport top was committed nowhere — pane history stayed empty until streaming ended. Meanwhile `#planLiveRegionPinnedRender` was explicitly disabled for multiplexers, but its `#emitLiveRegionPinnedRepaint` is built from the exact primitives tmux accepts (relative cursor moves, per-line `\x1b[2K`, `\r\n` to scroll the sealed prefix past the viewport bottom) and never emits `\x1b[2J`/`\x1b[3J`. The pinned planner now runs in multiplexers too, the cap branch skips them, and the diff/append path commits incrementally into pane history; the actively-mutating live tail stays in the visible viewport only.
22
+
23
+ ## [15.9.5] - 2026-06-05
24
+
25
+ ### Changed
26
+
27
+ - Changed terminal resize handling so any width or height change always performs a clean reset + redraw: the renderer now unconditionally clears the viewport and native scrollback (`CSI 2 J` / `CSI 3 J`) and replays the full transcript at the new geometry, replacing the previous matrix of conditional viewport-repaint / history-rebuild / deferred-mutation branches. Multiplexer panes still repaint the visible window in place (pane scrollback cannot be erased), but a resize during active ED3-risk foreground streaming now performs the same clean rebuild rather than downgrading to a non-destructive viewport repaint: the terminal already re-wrapped its saved lines at the old width, so the rebuild must erase them (ED 3) instead of leaving the mis-wrapped history on screen. As a deliberate tradeoff this drops the prior no-overflow and confirmed-scrolled guards on resize: a reader scrolled into history snaps back to the bottom and preexisting shell scrollback above the UI is cleared.
28
+
29
+ ### Fixed
30
+
31
+ - Fixed ED3-risk foreground streaming dropping the scrolled-off head of an append-only live block that alone overflows the viewport (a long streamed assistant reply). The live-region pin again committed native scrollback only up to the live-region start, so once the live block grew past the viewport its earlier rows scrolled above the viewport top but were committed nowhere and repainted nowhere — they vanished, leaving the reply looking like a ~viewport-tall circular buffer. The `NativeScrollbackLiveRegion` seam now also reports an optional append-only `getNativeScrollbackCommitSafeEnd`, and the pinned commit boundary is the deeper of the sealed start and that append-only end: rows in `[liveRegionStart, commitSafeEnd)` above the viewport top commit to scrollback, while volatile live blocks (tool previews that collapse) omit the boundary and keep their mutable rows deferred — preserving the pending-box-above-running-box fix.
32
+
33
+ ## [15.9.4] - 2026-06-05
34
+
35
+ ### Added
36
+
37
+ - Added `PI_TUI_SYNC_OUTPUT=0` and `PI_TUI_SYNC_OUTPUT=1` to explicitly disable or force-enable DEC 2026 synchronized-output mode, alongside `PI_FORCE_SYNC_OUTPUT=1` as a force-on alias
38
+ - Added `PI_TUI_ED3_SAFE=1` environment override to treat a terminal as non-ED3-risk for eager native scrollback rebuilds on unknown POSIX hosts
39
+
40
+ ### Changed
41
+
42
+ - Changed native-scrollback safety defaults to treat unknown POSIX, SSH, and multiplexer-shaped terminals as ED3-risk for passive rendering; checkpoint replay now requires a positive at-tail viewport proof instead of assuming prompt submit makes host scrollback safe.
43
+ - Changed synchronized-output defaults to a conservative opt-in profile: DEC 2026 paint wrappers stay disabled for remote/multiplexer/VTE/unknown terminals unless explicitly forced, while the autowrap guards remain active.
44
+
45
+ ### Fixed
46
+
47
+ - Fixed ED3-risk unknown-viewport renders repainting offscreen structural edits over stale native scrollback, which could duplicate or shift rows when async blocks collapsed or middle rows were deleted.
48
+ - Fixed ED3-risk foreground streams committing mutable live-region rows into native scrollback, which could leave a stale `pending` tool box above the `running` box after the preview re-rendered.
49
+ - Fixed TUI shutdown leaving paint-time terminal state and Kitty image data behind by restoring synchronized-output/autowrap modes and purging all transmitted Kitty image ids on stop.
50
+ - Fixed stdin buffering splitting surrogate-pair text into UTF-16 halves and reduced timing sensitivity for incomplete escape sequences.
51
+ - Fixed terminal content not reflowing after a resize on terminals using DEC 2048 in-band resize (kitty/Ghostty/iTerm2/WezTerm). `ProcessTerminal.columns`/`rows` returned the last cached in-band report even after the OS already knew the new size, so a SIGWINCH whose in-band report was dropped or malformed (split past the stdin flush window, `:`-subparameter fields) re-rendered the whole transcript at the stale width. OS resize events now reconcile cached in-band geometry against the live `process.stdout` dimensions, dropping a stale cached value so the next render uses the true size; a valid in-band report still re-seeds pixel sizing.
52
+
5
53
  ## [15.9.3] - 2026-06-05
6
54
 
7
55
  ### Fixed
@@ -1043,4 +1091,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
1043
1091
 
1044
1092
  ### Fixed
1045
1093
 
1046
- - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
1094
+ - **Readline-style Ctrl+W**: Now skips trailing whitespace before deleting the preceding word, matching standard readline behavior. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
@@ -9,6 +9,7 @@ export declare class Box implements Component {
9
9
  addChild(component: Component): void;
10
10
  removeChild(component: Component): void;
11
11
  clear(): void;
12
+ setPaddingX(paddingX: number): void;
12
13
  setBgFn(bgFn?: (text: string) => string): void;
13
14
  invalidate(): void;
14
15
  render(width: number): string[];
@@ -64,6 +64,8 @@ export declare class ImageBudget {
64
64
  endPass(): boolean;
65
65
  /** Image ids to delete from the terminal this frame; clears the pending set. */
66
66
  takePurgeIds(): readonly number[];
67
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
68
+ takeAllTransmittedIds(): readonly number[];
67
69
  /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
68
70
  shouldTransmit(imageId: number): boolean;
69
71
  /**
@@ -0,0 +1,40 @@
1
+ import type { Component } from "../tui";
2
+ type ScrollbarMode = "auto" | "always" | "never";
3
+ export interface ScrollViewTheme {
4
+ track?: (text: string) => string;
5
+ thumb?: (text: string) => string;
6
+ }
7
+ export interface ScrollViewOptions {
8
+ height: number;
9
+ /** Defaults to "auto". "auto" reserves a scrollbar column only when content overflows. */
10
+ scrollbar?: ScrollbarMode | boolean;
11
+ /** Logical row count for pre-windowed line slices. Defaults to lines.length. */
12
+ totalRows?: number;
13
+ theme?: ScrollViewTheme;
14
+ trackChar?: string;
15
+ thumbChar?: string;
16
+ }
17
+ /**
18
+ * Fixed-height viewport over pre-rendered lines, with optional right-edge scrollbar.
19
+ *
20
+ * ScrollView owns only the row offset. Callers remain responsible for producing
21
+ * already-wrapped logical lines appropriate for the current render width.
22
+ */
23
+ export declare class ScrollView implements Component {
24
+ #private;
25
+ constructor(lines: readonly string[], options: ScrollViewOptions);
26
+ setLines(lines: readonly string[]): void;
27
+ setTotalRows(totalRows: number | undefined): void;
28
+ setHeight(height: number): void;
29
+ setScrollbar(scrollbar: ScrollViewOptions["scrollbar"]): void;
30
+ getScrollOffset(): number;
31
+ getMaxScrollOffset(): number;
32
+ setScrollOffset(offset: number): void;
33
+ scroll(delta: number): void;
34
+ page(delta: number): void;
35
+ scrollToTop(): void;
36
+ scrollToBottom(): void;
37
+ invalidate(): void;
38
+ render(width: number): string[];
39
+ }
40
+ export {};
@@ -6,6 +6,7 @@ export * from "./components/image";
6
6
  export * from "./components/input";
7
7
  export * from "./components/loader";
8
8
  export * from "./components/markdown";
9
+ export * from "./components/scroll-view";
9
10
  export * from "./components/select-list";
10
11
  export * from "./components/settings-list";
11
12
  export * from "./components/spacer";
@@ -19,8 +19,8 @@
19
19
  import { EventEmitter } from "events";
20
20
  export type StdinBufferOptions = {
21
21
  /**
22
- * Maximum time to wait for sequence completion (default: 10ms)
23
- * After this time, the buffer is flushed even if incomplete
22
+ * Maximum time to wait for sequence completion (default: 75ms).
23
+ * After this time, a genuinely incomplete escape is flushed.
24
24
  */
25
25
  timeout?: number;
26
26
  };
@@ -36,28 +36,23 @@ export declare function isNotificationSuppressed(): boolean;
36
36
  */
37
37
  export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
38
38
  /**
39
- * Whether eager live-frame native scrollback rebuilds are unsafe when the
40
- * terminal viewport position is unobservable.
39
+ * Whether live-frame native scrollback rebuilds are unsafe when the terminal
40
+ * viewport position is unobservable.
41
41
  *
42
- * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). On the
43
- * terminals below, ED3 can disturb a reader parked in native scrollback during
44
- * streaming: kitty/ghostty/alacritty/VTE clamp the scroll offset back to the
45
- * active tail when saved lines are erased. WezTerm, macOS Terminal.app, and
46
- * iTerm2 expose scrollback-only clears via ED3/terminfo E3; that still
47
- * invalidates a reader's scrollback position during live streaming.
42
+ * A TUI history rebuild emits xterm ED3 (`CSI 3 J`, erase saved lines). Many
43
+ * terminals either clamp a scrolled reader back to the active tail or erase host
44
+ * scrollback when ED3 lands. The important property is not the brand name — it
45
+ * is that an unknown viewport position cannot be proven safe. Environment
46
+ * markers are therefore only used to prove *risk* or a strongly-known profile;
47
+ * unknown POSIX/remote/multiplexer shapes default to risky for passive renders.
48
48
  *
49
- * Windows Terminal erases its host scrollback on ED3 and repositions the
50
- * viewport against the shortened buffer, so a scrolled-up reader is yanked.
51
- * Native win32 is excluded here because the renderer guards it with dedicated
52
- * platform checks (the viewport position is never observable on Windows — see
53
- * `Terminal.isNativeViewportAtBottom`); a `WT_SESSION` sighting on any other
54
- * platform means the outer host is Windows Terminal fronting a WSL distro (WT
55
- * propagates the variable into the Linux environment), where the same ED3
56
- * yank applies. See #1610.
57
- *
58
- * Pure helper for tests and `TERMINAL` trait construction. See #1682 and #1719.
49
+ * Native win32 is excluded here because the renderer has dedicated ConPTY
50
+ * deferral paths; a `WT_SESSION` sighting on POSIX means Windows Terminal is the
51
+ * outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
59
52
  */
60
53
  export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
54
+ /** Whether DEC 2026 synchronized-output wrappers should be enabled by default. */
55
+ export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform, terminalId?: TerminalId): boolean;
61
56
  /**
62
57
  * Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
63
58
  * (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
@@ -58,6 +58,11 @@ export interface Terminal {
58
58
  * (xterm.js-backed) implement this with a real answer.
59
59
  */
60
60
  isNativeViewportAtBottom?(): boolean | undefined;
61
+ /**
62
+ * Override the global terminal-profile ED3 risk decision for custom/test
63
+ * terminals. `undefined` falls back to the resolved `TERMINAL` profile.
64
+ */
65
+ hasEagerEraseScrollbackRisk?(): boolean | undefined;
61
66
  /**
62
67
  * Register a callback for terminal appearance (dark/light) changes.
63
68
  * Detection uses OSC 11 background color query with Mode 2031 as a change trigger.
@@ -48,9 +48,21 @@ export interface Component {
48
48
  * line index where that suffix begins after each render. TUI treats that suffix
49
49
  * — and every root child rendered below it — as not yet safe to commit to native
50
50
  * scrollback on ED3-risk terminals whose viewport position is unobservable.
51
+ *
52
+ * `getNativeScrollbackCommitSafeEnd` optionally reports a *deeper* boundary
53
+ * inside that live suffix: the line index up to which the live region is
54
+ * append-only (its earlier rows never re-layout, only new rows append at the
55
+ * bottom — a streaming assistant message). Rows in `[liveRegionStart,
56
+ * commitSafeEnd)` that scroll above the viewport are safe to commit to native
57
+ * scrollback even though they are technically live, because they will never
58
+ * change. Without this, a single live block that alone overflows the viewport
59
+ * loses its scrolled-off head (committed nowhere, repainted nowhere). Volatile
60
+ * live blocks (tool previews that collapse) omit it, so their mutable rows stay
61
+ * deferred. Defaults to `liveRegionStart` when absent.
51
62
  */
52
63
  export interface NativeScrollbackLiveRegion {
53
64
  getNativeScrollbackLiveRegionStart(): number | undefined;
65
+ getNativeScrollbackCommitSafeEnd?(): number | undefined;
54
66
  }
55
67
  /**
56
68
  * Interface for components that can receive focus and display a cursor.
@@ -86,9 +98,8 @@ export interface RenderRequestOptions {
86
98
  */
87
99
  allowUnknownViewportMutation?: boolean;
88
100
  }
89
- /** Options for deferred native scrollback rebuild checkpoints. */
101
+ /** Options for deferred native scrollback rebuild checkpoints. Reserved for API stability. */
90
102
  export interface NativeScrollbackRefreshOptions {
91
- /** Allow replay when the terminal cannot report viewport state. Use only for explicit user submit checkpoints. */
92
103
  allowUnknownViewport?: boolean;
93
104
  }
94
105
  /** Type guard to check if a component implements Focusable */
@@ -204,8 +215,8 @@ export declare class TUI extends Container {
204
215
  setClearOnShrink(enabled: boolean): void;
205
216
  /**
206
217
  * Whether DEC 2026 synchronized-output wrappers are currently emitted around
207
- * paints. Starts from `PI_NO_SYNC_OUTPUT` and is force-disabled at runtime if
208
- * the terminal reports mode 2026 unsupported via DECRQM.
218
+ * paints. Starts from conservative terminal/env detection and is force-disabled
219
+ * at runtime if the terminal reports mode 2026 unsupported via DECRQM.
209
220
  */
210
221
  get synchronizedOutput(): boolean;
211
222
  /**
@@ -220,8 +231,8 @@ export declare class TUI extends Container {
220
231
  * (the viewport is never observable there and ConPTY hosts erase host
221
232
  * scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
222
233
  * rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
223
- * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint and
224
- * direct user-input rebuilds are unaffected.
234
+ * (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
235
+ * rebuilds are unaffected.
225
236
  *
226
237
  * Disabling stays active through one already-requested frame: the event batch
227
238
  * that ends a foreground stream both removes its UI rows (loader/status
@@ -252,6 +263,6 @@ export declare class TUI extends Container {
252
263
  * Callers should only invoke this at checkpoints where the user is expected to be
253
264
  * at the terminal bottom, such as after submitting a new prompt.
254
265
  */
255
- refreshNativeScrollbackIfDirty(options?: NativeScrollbackRefreshOptions): boolean;
266
+ refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
256
267
  requestRender(force?: boolean, options?: RenderRequestOptions): void;
257
268
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "15.9.3",
4
+ "version": "15.9.67",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@oh-my-pi/pi-natives": "15.9.3",
41
- "@oh-my-pi/pi-utils": "15.9.3",
40
+ "@oh-my-pi/pi-natives": "15.9.67",
41
+ "@oh-my-pi/pi-utils": "15.9.67",
42
42
  "lru-cache": "11.5.1",
43
43
  "marked": "^18.0.4"
44
44
  },
@@ -42,6 +42,12 @@ export class Box implements Component {
42
42
  this.#invalidateCache();
43
43
  }
44
44
 
45
+ setPaddingX(paddingX: number): void {
46
+ if (this.#paddingX === paddingX) return;
47
+ this.#paddingX = paddingX;
48
+ this.#invalidateCache();
49
+ }
50
+
45
51
  setBgFn(bgFn?: (text: string) => string): void {
46
52
  this.#bgFn = bgFn;
47
53
  // Don't invalidate here - we'll detect bgFn changes by sampling output
@@ -163,6 +163,16 @@ export class ImageBudget {
163
163
  return ids;
164
164
  }
165
165
 
166
+ /** All image ids believed to be loaded in the terminal store; clears tracking. */
167
+ takeAllTransmittedIds(): readonly number[] {
168
+ if (this.#transmitted.size === 0) return EMPTY_IDS;
169
+ const ids = [...this.#transmitted];
170
+ this.#transmitted.clear();
171
+ this.#purgeIds = [];
172
+ this.#pendingTransmits = [];
173
+ return ids;
174
+ }
175
+
166
176
  /** Whether `imageId`'s data still needs to be transmitted to the terminal. */
167
177
  shouldTransmit(imageId: number): boolean {
168
178
  return !this.#transmitted.has(imageId);
@@ -0,0 +1,166 @@
1
+ import type { Component } from "../tui";
2
+ import { Ellipsis, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
3
+
4
+ const DEFAULT_TRACK = "│";
5
+ const DEFAULT_THUMB = "█";
6
+
7
+ type ScrollbarMode = "auto" | "always" | "never";
8
+
9
+ export interface ScrollViewTheme {
10
+ track?: (text: string) => string;
11
+ thumb?: (text: string) => string;
12
+ }
13
+
14
+ export interface ScrollViewOptions {
15
+ height: number;
16
+ /** Defaults to "auto". "auto" reserves a scrollbar column only when content overflows. */
17
+ scrollbar?: ScrollbarMode | boolean;
18
+ /** Logical row count for pre-windowed line slices. Defaults to lines.length. */
19
+ totalRows?: number;
20
+ theme?: ScrollViewTheme;
21
+ trackChar?: string;
22
+ thumbChar?: string;
23
+ }
24
+
25
+ function normalizeScrollbarMode(scrollbar: ScrollViewOptions["scrollbar"]): ScrollbarMode {
26
+ if (scrollbar === true) return "auto";
27
+ if (scrollbar === false) return "never";
28
+ return scrollbar ?? "auto";
29
+ }
30
+
31
+ function firstCellGlyph(value: string, fallback: string): string {
32
+ const glyph = Array.from(value)[0] ?? fallback;
33
+ return visibleWidth(glyph) === 1 ? glyph : fallback;
34
+ }
35
+
36
+ /**
37
+ * Fixed-height viewport over pre-rendered lines, with optional right-edge scrollbar.
38
+ *
39
+ * ScrollView owns only the row offset. Callers remain responsible for producing
40
+ * already-wrapped logical lines appropriate for the current render width.
41
+ */
42
+ export class ScrollView implements Component {
43
+ #lines: string[];
44
+ #height: number;
45
+ #scrollOffset = 0;
46
+ #totalRows: number | undefined;
47
+ #scrollbar: ScrollbarMode;
48
+ #theme: Required<ScrollViewTheme>;
49
+ #trackChar: string;
50
+ #thumbChar: string;
51
+
52
+ constructor(lines: readonly string[], options: ScrollViewOptions) {
53
+ this.#lines = [...lines];
54
+ this.#height = Number.isFinite(options.height) ? Math.max(0, Math.trunc(options.height)) : 0;
55
+ this.#totalRows = options.totalRows === undefined ? undefined : Math.max(0, Math.trunc(options.totalRows));
56
+ this.#scrollbar = normalizeScrollbarMode(options.scrollbar);
57
+ this.#theme = {
58
+ track: options.theme?.track ?? (text => text),
59
+ thumb: options.theme?.thumb ?? (text => text),
60
+ };
61
+ this.#trackChar = firstCellGlyph(options.trackChar ?? DEFAULT_TRACK, DEFAULT_TRACK);
62
+ this.#thumbChar = firstCellGlyph(options.thumbChar ?? DEFAULT_THUMB, DEFAULT_THUMB);
63
+ this.#clampScrollOffset();
64
+ }
65
+
66
+ setLines(lines: readonly string[]): void {
67
+ this.#lines = [...lines];
68
+ this.#clampScrollOffset();
69
+ }
70
+
71
+ setTotalRows(totalRows: number | undefined): void {
72
+ this.#totalRows = totalRows === undefined ? undefined : Math.max(0, Math.trunc(totalRows));
73
+ this.#clampScrollOffset();
74
+ }
75
+
76
+ setHeight(height: number): void {
77
+ this.#height = Number.isFinite(height) ? Math.max(0, Math.trunc(height)) : 0;
78
+ this.#clampScrollOffset();
79
+ }
80
+
81
+ setScrollbar(scrollbar: ScrollViewOptions["scrollbar"]): void {
82
+ this.#scrollbar = normalizeScrollbarMode(scrollbar);
83
+ }
84
+
85
+ getScrollOffset(): number {
86
+ return this.#scrollOffset;
87
+ }
88
+
89
+ getMaxScrollOffset(): number {
90
+ const rowCount = this.#totalRows ?? this.#lines.length;
91
+ return Math.max(0, rowCount - this.#height);
92
+ }
93
+
94
+ setScrollOffset(offset: number): void {
95
+ this.#scrollOffset = Number.isFinite(offset) ? Math.trunc(offset) : 0;
96
+ this.#clampScrollOffset();
97
+ }
98
+
99
+ scroll(delta: number): void {
100
+ this.setScrollOffset(this.#scrollOffset + (Number.isFinite(delta) ? Math.trunc(delta) : 0));
101
+ }
102
+
103
+ page(delta: number): void {
104
+ const step = Math.max(1, this.#height - 1);
105
+ this.scroll(step * (Number.isFinite(delta) ? Math.trunc(delta) : 0));
106
+ }
107
+
108
+ scrollToTop(): void {
109
+ this.#scrollOffset = 0;
110
+ }
111
+
112
+ scrollToBottom(): void {
113
+ this.#scrollOffset = this.getMaxScrollOffset();
114
+ }
115
+
116
+ invalidate(): void {
117
+ // No cached layout to invalidate.
118
+ }
119
+
120
+ render(width: number): string[] {
121
+ this.#clampScrollOffset();
122
+ const safeWidth = Number.isFinite(width) ? Math.max(0, Math.trunc(width)) : 0;
123
+ if (this.#height === 0) return [];
124
+ const showScrollbar = safeWidth > 0 && this.#shouldRenderScrollbar();
125
+ const contentWidth = Math.max(0, safeWidth - (showScrollbar ? 1 : 0));
126
+ const thumb = showScrollbar ? this.#thumbRange() : undefined;
127
+ const lines: string[] = [];
128
+ for (let row = 0; row < this.#height; row++) {
129
+ const sourceIndex = this.#totalRows === undefined ? this.#scrollOffset + row : row;
130
+ const source = this.#lines[sourceIndex] ?? "";
131
+ const truncated = truncateToWidth(replaceTabs(source), contentWidth, Ellipsis.Unicode);
132
+ if (!showScrollbar) {
133
+ lines.push(truncated);
134
+ continue;
135
+ }
136
+ const content = `${truncated}${" ".repeat(Math.max(0, contentWidth - visibleWidth(truncated)))}`;
137
+ const barGlyph = thumb && row >= thumb.start && row < thumb.end ? this.#thumbChar : this.#trackChar;
138
+ const styledBar =
139
+ thumb && row >= thumb.start && row < thumb.end ? this.#theme.thumb(barGlyph) : this.#theme.track(barGlyph);
140
+ lines.push(`${content}${styledBar}`);
141
+ }
142
+ return lines;
143
+ }
144
+
145
+ #clampScrollOffset(): void {
146
+ this.#scrollOffset = Math.max(0, Math.min(this.#scrollOffset, this.getMaxScrollOffset()));
147
+ }
148
+
149
+ #shouldRenderScrollbar(): boolean {
150
+ if (this.#height <= 0) return false;
151
+ if (this.#scrollbar === "never") return false;
152
+ if (this.#scrollbar === "always") return true;
153
+ return (this.#totalRows ?? this.#lines.length) > this.#height;
154
+ }
155
+
156
+ #thumbRange(): { start: number; end: number } {
157
+ if (this.#height <= 0) return { start: 0, end: 0 };
158
+ const rowCount = this.#totalRows ?? this.#lines.length;
159
+ if (rowCount <= this.#height) return { start: 0, end: this.#height };
160
+ const thumbSize = Math.max(1, Math.min(Math.floor((this.#height * this.#height) / rowCount), this.#height));
161
+ const travel = this.#height - thumbSize;
162
+ const maxOffset = this.getMaxScrollOffset();
163
+ const start = maxOffset === 0 ? 0 : Math.round((this.#scrollOffset / maxOffset) * travel);
164
+ return { start, end: start + thumbSize };
165
+ }
166
+ }
@@ -4,6 +4,7 @@ import { extractPrintableText } from "../keys";
4
4
  import type { SymbolTheme } from "../symbols";
5
5
  import type { Component } from "../tui";
6
6
  import { Ellipsis, padding, replaceTabs, truncateToWidth, visibleWidth } from "../utils";
7
+ import { ScrollView } from "./scroll-view";
7
8
 
8
9
  const DEFAULT_PRIMARY_COLUMN_WIDTH = 32;
9
10
  const PRIMARY_COLUMN_GAP = 2;
@@ -104,17 +105,29 @@ export class SelectList implements Component {
104
105
  const endIndex = Math.min(startIndex + this.maxVisible, this.#filteredItems.length);
105
106
 
106
107
  // Render visible items
108
+ const overflow = this.#filteredItems.length > this.maxVisible;
109
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
110
+ const rows: string[] = [];
107
111
  for (let i = startIndex; i < endIndex; i++) {
108
112
  const item = this.#filteredItems[i];
109
113
  if (!item) continue;
110
114
 
111
115
  const isSelected = i === this.#selectedIndex;
112
116
  const descriptionText = item.description ? sanitizeSingleLine(item.description) : undefined;
113
- lines.push(this.#renderItem(item, isSelected, width, descriptionText, primaryColumnWidth));
117
+ rows.push(this.#renderItem(item, isSelected, rowWidth, descriptionText, primaryColumnWidth));
114
118
  }
115
119
 
116
- // Add scroll/search status when needed
117
- if (startIndex > 0 || endIndex < this.#filteredItems.length || showSearchStatus) {
120
+ const sv = new ScrollView(rows, {
121
+ height: rows.length,
122
+ scrollbar: "auto",
123
+ totalRows: this.#filteredItems.length,
124
+ theme: { track: t => this.theme.scrollInfo(t), thumb: t => this.theme.selectedPrefix(t) },
125
+ });
126
+ sv.setScrollOffset(startIndex);
127
+ lines.push(...sv.render(width));
128
+
129
+ // Add search status when relevant (scrollbar now indicates overflow)
130
+ if (showSearchStatus) {
118
131
  lines.push(this.#renderStatusLine(width));
119
132
  }
120
133
 
@@ -247,15 +260,8 @@ export class SelectList implements Component {
247
260
  }
248
261
 
249
262
  #renderStatusLine(width: number): string {
250
- const selectedCount = this.#filteredItems.length === 0 ? 0 : this.#selectedIndex + 1;
251
- const filteredCount = this.#filteredItems.length;
252
- const count =
253
- this.#filterQuery.trim() && filteredCount !== this.items.length
254
- ? `${selectedCount}/${filteredCount} of ${this.items.length}`
255
- : `${selectedCount}/${filteredCount}`;
256
263
  const query = sanitizeSingleLine(this.#filterQuery);
257
- const searchSuffix = this.#shouldRenderSearchStatus() ? (query ? ` Search: ${query}` : " Type to search") : "";
258
- const statusText = ` (${count})${searchSuffix}`;
264
+ const statusText = query ? ` Search: ${query}` : " Type to search";
259
265
  return this.theme.scrollInfo(truncateToWidth(statusText, Math.max(1, width - 2), Ellipsis.Omit));
260
266
  }
261
267
 
@@ -1,6 +1,7 @@
1
1
  import { getKeybindings } from "../keybindings";
2
2
  import type { Component } from "../tui";
3
3
  import { Ellipsis, padding, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "../utils";
4
+ import { ScrollView } from "./scroll-view";
4
5
 
5
6
  export interface SettingItem {
6
7
  /** Unique identifier for this setting */
@@ -90,6 +91,22 @@ export class SettingsList implements Component {
90
91
  return this.#renderMainList(width);
91
92
  }
92
93
 
94
+ #renderItemRow(item: SettingItem, index: number, maxLabelWidth: number, rowWidth: number): string {
95
+ const isSelected = index === this.#selectedIndex;
96
+ const prefix = isSelected ? this.#theme.cursor : " ";
97
+ const prefixWidth = visibleWidth(prefix);
98
+ const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
99
+ const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
100
+ const separator = " ";
101
+ const valueMaxWidth = rowWidth - prefixWidth - maxLabelWidth - visibleWidth(separator) - 2;
102
+ const valueText = this.#theme.value(
103
+ truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
104
+ isSelected,
105
+ item.changed === true,
106
+ );
107
+ return truncateToWidth(prefix + labelText + separator + valueText, Math.max(0, rowWidth));
108
+ }
109
+
93
110
  #renderMainList(width: number): string[] {
94
111
  const lines: string[] = [];
95
112
 
@@ -98,48 +115,29 @@ export class SettingsList implements Component {
98
115
  return lines;
99
116
  }
100
117
 
101
- // Calculate visible range with scrolling
118
+ const viewportHeight = Math.min(this.#maxVisible, this.#items.length);
102
119
  const startIndex = Math.max(
103
120
  0,
104
- Math.min(this.#selectedIndex - Math.floor(this.#maxVisible / 2), this.#items.length - this.#maxVisible),
121
+ Math.min(this.#selectedIndex - Math.floor(viewportHeight / 2), this.#items.length - viewportHeight),
105
122
  );
106
- const endIndex = Math.min(startIndex + this.#maxVisible, this.#items.length);
107
-
108
- // Calculate max label width for alignment
109
123
  const maxLabelWidth = Math.min(30, Math.max(...this.#items.map(item => visibleWidth(item.label))));
110
-
111
- // Render visible items
112
- for (let i = startIndex; i < endIndex; i++) {
113
- const item = this.#items[i];
114
- if (!item) continue;
115
-
116
- const isSelected = i === this.#selectedIndex;
117
- const prefix = isSelected ? this.#theme.cursor : " ";
118
- const prefixWidth = visibleWidth(prefix);
119
-
120
- // Pad label to align values
121
- const labelPadded = item.label + padding(Math.max(0, maxLabelWidth - visibleWidth(item.label)));
122
- const labelText = this.#theme.label(labelPadded, isSelected, item.changed === true);
123
-
124
- // Calculate space for value
125
- const separator = " ";
126
- const usedWidth = prefixWidth + maxLabelWidth + visibleWidth(separator);
127
- const valueMaxWidth = width - usedWidth - 2;
128
-
129
- const valueText = this.#theme.value(
130
- truncateToWidth(item.currentValue, valueMaxWidth, Ellipsis.Omit),
131
- isSelected,
132
- item.changed === true,
133
- );
134
-
135
- lines.push(truncateToWidth(prefix + labelText + separator + valueText, width));
136
- }
137
-
138
- // Add scroll indicator if needed
139
- if (startIndex > 0 || endIndex < this.#items.length) {
140
- const scrollText = ` (${this.#selectedIndex + 1}/${this.#items.length})`;
141
- lines.push(this.#theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
142
- }
124
+ const itemRowsOverflow = this.#items.length > viewportHeight;
125
+ const itemRowWidth = Math.max(0, width - (itemRowsOverflow ? 1 : 0));
126
+ const visibleItems = this.#items.slice(startIndex, startIndex + viewportHeight);
127
+ const itemRows = visibleItems.map((item, index) =>
128
+ this.#renderItemRow(item, startIndex + index, maxLabelWidth, itemRowWidth),
129
+ );
130
+ const scrollView = new ScrollView(itemRows, {
131
+ height: viewportHeight,
132
+ scrollbar: "auto",
133
+ totalRows: this.#items.length,
134
+ theme: {
135
+ track: text => this.#theme.hint(text),
136
+ thumb: text => this.#theme.label(text, true, false),
137
+ },
138
+ });
139
+ scrollView.setScrollOffset(startIndex);
140
+ lines.push(...scrollView.render(width));
143
141
 
144
142
  // Add description for selected item
145
143
  const selectedItem = this.#items[this.#selectedIndex];
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ export * from "./components/image";
10
10
  export * from "./components/input";
11
11
  export * from "./components/loader";
12
12
  export * from "./components/markdown";
13
+ export * from "./components/scroll-view";
13
14
  export * from "./components/select-list";
14
15
  export * from "./components/settings-list";
15
16
  export * from "./components/spacer";