@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 +49 -1
- package/dist/types/components/box.d.ts +1 -0
- package/dist/types/components/image.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/stdin-buffer.d.ts +2 -2
- package/dist/types/terminal-capabilities.d.ts +13 -18
- package/dist/types/terminal.d.ts +5 -0
- package/dist/types/tui.d.ts +18 -7
- package/package.json +3 -3
- package/src/components/box.ts +6 -0
- package/src/components/image.ts +10 -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/stdin-buffer.ts +7 -6
- package/src/terminal-capabilities.ts +75 -19
- package/src/terminal.ts +51 -6
- package/src/tui.ts +380 -226
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 {};
|
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";
|
|
@@ -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:
|
|
23
|
-
* After this time,
|
|
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
|
|
40
|
-
*
|
|
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).
|
|
43
|
-
* terminals
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
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
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
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
|
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
|
@@ -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
|
|
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
|
|
224
|
-
*
|
|
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(
|
|
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.
|
|
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.
|
|
41
|
-
"@oh-my-pi/pi-utils": "15.9.
|
|
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
|
},
|
package/src/components/box.ts
CHANGED
|
@@ -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
|
package/src/components/image.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|