@oh-my-pi/pi-tui 15.9.5 → 15.10.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/CHANGELOG.md +41 -0
- package/dist/types/components/box.d.ts +2 -0
- package/dist/types/components/scroll-view.d.ts +40 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/terminal-capabilities.d.ts +23 -2
- package/dist/types/terminal.d.ts +5 -0
- package/dist/types/tui.d.ts +20 -4
- package/package.json +3 -3
- package/src/components/box.ts +12 -0
- package/src/components/scroll-view.ts +166 -0
- package/src/components/select-list.ts +17 -11
- package/src/components/settings-list.ts +36 -38
- package/src/index.ts +1 -0
- package/src/terminal-capabilities.ts +60 -25
- package/src/terminal.ts +6 -0
- package/src/tui.ts +273 -72
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,46 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [15.10.0] - 2026-06-06
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Reworked the DEC 2026 synchronized-output default policy: a positive DECRQM mode-2026 report now **enables** sync (previously a report could only disable it), so conservatively defaulted-off hosts that actually support it — current Zellij, tmux master, foot, contour, mintty — are upgraded at runtime. The static allowlist also covers Alacritty and the VS Code terminal, honors a `TERM_FEATURES` `Sy` advertisement and `WT_SESSION` (Windows Terminal / WSL), and no longer blanket-disables SSH (DEC 2026 passes through to the outer terminal). Risky multiplexers still start off and rely on the probe. Added `synchronizedOutputUserOverride()` as the shared opt-out/force resolver.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Fixed WSL/Windows Terminal row flicker while typing by repainting changed text rows before clearing only their stale suffix ([#2011](https://github.com/can1357/oh-my-pi/issues/2011)).
|
|
14
|
+
- Fixed terminals that support DEC 2026 still tearing/flickering because the renderer ignored a positive DECRQM capability report and kept synchronized output off — most visibly WSL + Windows Terminal, Alacritty (≥0.13), and the VS Code terminal (≥1.108), which were detected yet refused sync.
|
|
15
|
+
|
|
16
|
+
## [15.9.69] - 2026-06-06
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
- Added `TUI.resetDisplay()` to force an immediate full-frame replay, including native scrollback when the host can safely clear it.
|
|
21
|
+
- Added `setPaddingY` to `Box` so vertical padding can be updated programmatically after creation.
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- Fixed DECCARA background-fill optimization running when synchronized output is disabled, which could expose default-background gaps during rapidly updating tool-use panels ([#2000](https://github.com/can1357/oh-my-pi/issues/2000)).
|
|
26
|
+
|
|
27
|
+
## [15.9.67] - 2026-06-06
|
|
28
|
+
### Added
|
|
29
|
+
|
|
30
|
+
- Added `setPaddingX` to `Box` so horizontal padding can be updated programmatically after creation
|
|
31
|
+
- Added `ScrollView`, a fixed-height viewport component for pre-rendered lines with optional right-edge scrollbars and imperative scroll/page controls.
|
|
32
|
+
- Added optional `Terminal.hasEagerEraseScrollbackRisk()` so custom/test terminal implementations can override the global ED3-risk profile without mutating the shared `TERMINAL` object.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- 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).
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- 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.
|
|
41
|
+
- 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.
|
|
42
|
+
- 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)).
|
|
43
|
+
- 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.
|
|
44
|
+
|
|
5
45
|
## [15.9.5] - 2026-06-05
|
|
6
46
|
|
|
7
47
|
### Changed
|
|
@@ -13,6 +53,7 @@
|
|
|
13
53
|
- 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.
|
|
14
54
|
|
|
15
55
|
## [15.9.4] - 2026-06-05
|
|
56
|
+
|
|
16
57
|
### Added
|
|
17
58
|
|
|
18
59
|
- 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
|
|
@@ -9,6 +9,8 @@ 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;
|
|
13
|
+
setPaddingY(paddingY: number): void;
|
|
12
14
|
setBgFn(bgFn?: (text: string) => string): void;
|
|
13
15
|
invalidate(): void;
|
|
14
16
|
render(width: number): string[];
|
|
@@ -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 {};
|
package/dist/types/index.d.ts
CHANGED
|
@@ -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";
|
|
@@ -51,8 +51,29 @@ export declare function isWindowsTerminalPreviewSixelSupported(env?: NodeJS.Proc
|
|
|
51
51
|
* outer host fronting WSL, where the same ED3 yank applies. See #1610/#1682/#1799.
|
|
52
52
|
*/
|
|
53
53
|
export declare function detectTerminalEagerEraseScrollbackRisk(env?: NodeJS.ProcessEnv, platform?: NodeJS.Platform): boolean;
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/**
|
|
55
|
+
* Resolve an explicit user override for DEC 2026 synchronized output. Returns
|
|
56
|
+
* `false` for an opt-out, `true` for a force-on, or `null` when the user has
|
|
57
|
+
* expressed no preference. Shared by the static default and the runtime DECRQM
|
|
58
|
+
* probe so both honor the same precedence — an opt-out beats a force-on.
|
|
59
|
+
*/
|
|
60
|
+
export declare function synchronizedOutputUserOverride(env?: NodeJS.ProcessEnv): boolean | null;
|
|
61
|
+
/**
|
|
62
|
+
* Whether DEC 2026 synchronized-output wrappers should be enabled by default.
|
|
63
|
+
*
|
|
64
|
+
* Policy (highest precedence first):
|
|
65
|
+
* 1. Explicit user override (`PI_NO_SYNC_OUTPUT`/`PI_TUI_SYNC_OUTPUT=0` off,
|
|
66
|
+
* `PI_FORCE_SYNC_OUTPUT=1`/`PI_TUI_SYNC_OUTPUT=1` on).
|
|
67
|
+
* 2. Positive `TERM_FEATURES` advertisement (`Sy`) — survives SSH/mux wrapping.
|
|
68
|
+
* 3. Windows Terminal (1.24+) via `WT_SESSION`, on native win32 and the
|
|
69
|
+
* WSL/SSH-fronted host alike.
|
|
70
|
+
* 4. Known direct terminals with confirmed support. SSH does *not* disable —
|
|
71
|
+
* DEC 2026 passes through SSH when the outer terminal honors it.
|
|
72
|
+
* 5. Everything else starts off, including risky multiplexers; the runtime
|
|
73
|
+
* DECRQM probe upgrades any of them when the terminal actually reports
|
|
74
|
+
* `?2026` supported (current zellij, tmux master, foot, contour, mintty…).
|
|
75
|
+
*/
|
|
76
|
+
export declare function shouldEnableSynchronizedOutputByDefault(env?: NodeJS.ProcessEnv, terminalId?: TerminalId): boolean;
|
|
56
77
|
/**
|
|
57
78
|
* Whether the terminal applies Kitty-style DECCARA rectangular SGR changes
|
|
58
79
|
* (`CSI Pt ; Pl ; Pb ; Pr ; <sgr> $ r`) extended to background color, so large
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -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.
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -215,8 +215,9 @@ export declare class TUI extends Container {
|
|
|
215
215
|
setClearOnShrink(enabled: boolean): void;
|
|
216
216
|
/**
|
|
217
217
|
* Whether DEC 2026 synchronized-output wrappers are currently emitted around
|
|
218
|
-
* paints. Starts from conservative terminal/env detection and is
|
|
219
|
-
*
|
|
218
|
+
* paints. Starts from conservative terminal/env detection and is reconciled at
|
|
219
|
+
* runtime against the terminal's DECRQM mode-2026 report — enabled on a
|
|
220
|
+
* positive report, disabled on a negative one.
|
|
220
221
|
*/
|
|
221
222
|
get synchronizedOutput(): boolean;
|
|
222
223
|
/**
|
|
@@ -231,8 +232,8 @@ export declare class TUI extends Container {
|
|
|
231
232
|
* (the viewport is never observable there and ConPTY hosts erase host
|
|
232
233
|
* scrollback on ED3 — #1635/#1746); only the unknown POSIX case is forced to
|
|
233
234
|
* rebuild. POSIX hosts known to disturb scrolled readers on xterm ED3
|
|
234
|
-
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
|
|
235
|
-
*
|
|
235
|
+
* (`CSI 3 J`, erase saved lines) also defer the eager opt-in; checkpoint
|
|
236
|
+
* rebuilds are unaffected.
|
|
236
237
|
*
|
|
237
238
|
* Disabling stays active through one already-requested frame: the event batch
|
|
238
239
|
* that ends a foreground stream both removes its UI rows (loader/status
|
|
@@ -264,5 +265,20 @@ export declare class TUI extends Container {
|
|
|
264
265
|
* at the terminal bottom, such as after submitting a new prompt.
|
|
265
266
|
*/
|
|
266
267
|
refreshNativeScrollbackIfDirty(_options?: NativeScrollbackRefreshOptions): boolean;
|
|
268
|
+
/**
|
|
269
|
+
* Force an immediate full replay of the current frame, including native
|
|
270
|
+
* scrollback. This is the keyboard-accessible equivalent of the resize reset:
|
|
271
|
+
* no queued diff frame or terminal scrollback probe can downgrade it to a
|
|
272
|
+
* viewport-only repaint.
|
|
273
|
+
*
|
|
274
|
+
* Invalidates every component first so the replay reflects current state. A
|
|
275
|
+
* geometry-driven reset thaws frozen scrollback snapshots implicitly (the new
|
|
276
|
+
* width misses every cached snapshot), but a same-width reset would otherwise
|
|
277
|
+
* replay stale snapshots — leaving host-frozen blocks (e.g. a transcript whose
|
|
278
|
+
* committed rows are immutable on ED3-risk terminals) showing pre-mutation
|
|
279
|
+
* content. Invalidation is the generic signal those containers use to retire
|
|
280
|
+
* their snapshots, which is exactly what a user-driven display reset wants.
|
|
281
|
+
*/
|
|
282
|
+
resetDisplay(): void;
|
|
267
283
|
requestRender(force?: boolean, options?: RenderRequestOptions): void;
|
|
268
284
|
}
|
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.
|
|
4
|
+
"version": "15.10.0",
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.
|
|
40
|
+
"@oh-my-pi/pi-natives": "15.10.0",
|
|
41
|
+
"@oh-my-pi/pi-utils": "15.10.0",
|
|
42
42
|
"lru-cache": "11.5.1",
|
|
43
43
|
"marked": "^18.0.4"
|
|
44
44
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -42,6 +42,18 @@ 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
|
+
|
|
51
|
+
setPaddingY(paddingY: number): void {
|
|
52
|
+
if (this.#paddingY === paddingY) return;
|
|
53
|
+
this.#paddingY = paddingY;
|
|
54
|
+
this.#invalidateCache();
|
|
55
|
+
}
|
|
56
|
+
|
|
45
57
|
setBgFn(bgFn?: (text: string) => string): void {
|
|
46
58
|
this.#bgFn = bgFn;
|
|
47
59
|
// Don't invalidate here - we'll detect bgFn changes by sampling output
|
|
@@ -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
|
-
|
|
117
|
+
rows.push(this.#renderItem(item, isSelected, rowWidth, descriptionText, primaryColumnWidth));
|
|
114
118
|
}
|
|
115
119
|
|
|
116
|
-
|
|
117
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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";
|