@gajae-code/tui 0.11.10 → 0.12.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 +15 -1
- package/README.md +8 -0
- package/dist/types/components/select-list.d.ts +1 -0
- package/dist/types/metrics.d.ts +3 -0
- package/dist/types/tui.d.ts +35 -2
- package/package.json +3 -3
- package/src/components/select-list.ts +16 -0
- package/src/metrics.ts +15 -0
- package/src/terminal.ts +10 -5
- package/src/tui.ts +843 -130
package/CHANGELOG.md
CHANGED
|
@@ -2,7 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
-
## [0.
|
|
5
|
+
## [0.12.0] - 2026-07-28
|
|
6
|
+
|
|
7
|
+
### Changed
|
|
8
|
+
|
|
9
|
+
- Mouse wheel scrolling now moves the session viewport by exactly three lines (`DEFAULT_WHEEL_LINES = 3`) instead of a full page. PageUp/PageDown keep page-sized steps with edge pinning.
|
|
10
|
+
- Manual transcript scrolling now keeps a valid registered status/composer boundary fixed at the bottom independently of output-source registration. Output sources control only the exact semantic new-output notice; transcript selection excludes pinned chrome, short transcript lanes emit blanks instead of duplicating suffix rows, and constrained heights retain the focused suffix component before decorative rows.
|
|
11
|
+
- Manual viewport revisions now advance for semantic changes in a visible capped sidebar even without an inline component; duplicate, elided, hidden, geometry-only, and theme-only changes do not raise a false new-output notice. Equal output-source updates do not render, and constrained pinned suffixes avoid copying transcript-length prefixes.
|
|
12
|
+
- A downward manual scroll (wheel or `PageDown`) that reaches the true transcript bottom now transitions through the existing live-follow transaction instead of repainting another manual frame, so wheel and PageDown automatically return to live output at the bottom. A partial downward movement retains manual ownership and the notice; upward movement never follows. The transition preserves editor focus, pinned chrome, notice clearing, and fatal terminal transaction semantics, clears manual anchor state through the existing transaction, and does not replay manual-era output into native/host scrollback.
|
|
13
|
+
|
|
14
|
+
### Fixed
|
|
15
|
+
|
|
16
|
+
- `waitForRenderCommit` / generation-scoped render tokens resolve only after a successful buffer write (or fail open on stopped/unavailable terminals), enabling awaitable progress frames for interactive resume without hanging (#2914).
|
|
17
|
+
- Streaming layout contraction followed by regrowth no longer re-admits an already committed logical row into native terminal scrollback, preventing occasional duplicated assistant lines after Markdown reflow.
|
|
18
|
+
- Repeated clearing of an already-clear viewport output source is now a render-request no-op, matching identical non-null source updates.
|
|
19
|
+
- A terminal width change now ends in one forced full redraw 1000ms after the last observed resize event, repairing stale bands left by lines wrapped at the old column count — across the full transcript, including scrollback history, on every host. Interim resize frames keep their cheap per-host path; the debounce is what makes the one full replay safe, so drag-resizing still does not replay the transcript per `SIGWINCH`. While the user is reading scrollback (manual viewport), the repair is deferred and runs when they return to live output. Height-only changes are unaffected (#3360, #3361).
|
|
6
20
|
|
|
7
21
|
## [0.11.7] - 2026-07-22
|
|
8
22
|
### Fixed
|
package/README.md
CHANGED
|
@@ -56,6 +56,14 @@ tui.requestRender(); // Request a re-render
|
|
|
56
56
|
tui.onDebug = () => console.log("Debug triggered");
|
|
57
57
|
```
|
|
58
58
|
|
|
59
|
+
### Manual viewport and pinned suffix
|
|
60
|
+
|
|
61
|
+
`setBottomPinnedComponent(component)` marks a direct-child boundary. During manual viewport ownership, that child and all later direct children remain fixed at the bottom while rows before it form the scrollable lane; this does not require an output source. `scrollViewportPages()` moves by the lane height minus one; `scrollViewportBy()` supports smaller row steps and rejects non-finite deltas.
|
|
62
|
+
|
|
63
|
+
`setViewportOutputSource({ identity, revision })` reports semantic output changes without coupling the TUI to message types. A same-identity revision advance while manually scrolled displays the exact notice `New output — type to follow`; following live or changing/removing the identity clears it, while a stale same-identity revision rollback does not.
|
|
64
|
+
|
|
65
|
+
Pinned rows and the notice are excluded from transcript mouse-selection coordinates. When the terminal is too short, the focused direct-child suffix component is retained before decorative or lower-priority suffix rows.
|
|
66
|
+
|
|
59
67
|
### Component Interface
|
|
60
68
|
|
|
61
69
|
All components implement:
|
|
@@ -45,6 +45,7 @@ export declare class SelectList implements Component {
|
|
|
45
45
|
constructor(items: ReadonlyArray<SelectItem>, maxVisible: number, theme: SelectListTheme, layout?: SelectListLayoutOptions);
|
|
46
46
|
setFilter(filter: string): void;
|
|
47
47
|
setSelectedIndex(index: number): void;
|
|
48
|
+
handleNavigation(action: "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown"): void;
|
|
48
49
|
invalidate(): void;
|
|
49
50
|
render(width: number): string[];
|
|
50
51
|
handleInput(keyData: string): void;
|
package/dist/types/metrics.d.ts
CHANGED
|
@@ -48,6 +48,7 @@ export interface RenderMetricsSnapshot {
|
|
|
48
48
|
timerGauges: Record<string, number>;
|
|
49
49
|
helperStats: Record<string, HelperStat>;
|
|
50
50
|
lineCounts: Record<string, LineCountGauge>;
|
|
51
|
+
structuralCounters: Record<string, number>;
|
|
51
52
|
}
|
|
52
53
|
export declare class RenderMetrics {
|
|
53
54
|
#private;
|
|
@@ -73,6 +74,8 @@ export declare class RenderMetrics {
|
|
|
73
74
|
recordHelper(name: string, durationMs: number): void;
|
|
74
75
|
/** Record a per-render line-count gauge (e.g. "rendered", "normalized", "diffed"). */
|
|
75
76
|
recordLineCount(name: string, value: number): void;
|
|
77
|
+
/** Accumulate deterministic structural render work without retaining frame data. */
|
|
78
|
+
recordStructuralCounter(name: string, value?: number): void;
|
|
76
79
|
/**
|
|
77
80
|
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
78
81
|
* "return" value used by the memory-leak gate. Callers should drop large
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { Terminal } from "./terminal";
|
|
2
2
|
import { visibleWidth } from "./utils";
|
|
3
|
+
/** Discrete mouse-wheel notch size in terminal rows (xterm/less-style). */
|
|
4
|
+
export declare const DEFAULT_WHEEL_LINES = 3;
|
|
3
5
|
type InputListenerResult = {
|
|
4
6
|
consume?: boolean;
|
|
5
7
|
data?: string;
|
|
@@ -9,7 +11,7 @@ type InputListener = (data: string) => InputListenerResult;
|
|
|
9
11
|
* Component interface - all components must implement this
|
|
10
12
|
*/
|
|
11
13
|
export type MouseEvent = {
|
|
12
|
-
kind: "wheel" | "click";
|
|
14
|
+
kind: "wheel" | "click" | "drag" | "release";
|
|
13
15
|
direction?: -1 | 1;
|
|
14
16
|
button?: 0;
|
|
15
17
|
/** Terminal cell coordinates, one-based. */
|
|
@@ -27,7 +29,7 @@ type OverlayMouseBounds = {
|
|
|
27
29
|
termWidth: number;
|
|
28
30
|
termHeight: number;
|
|
29
31
|
};
|
|
30
|
-
/** Parse xterm SGR mouse reports
|
|
32
|
+
/** Parse xterm SGR mouse reports for wheel, left-click, drag, and release events. */
|
|
31
33
|
export declare function parseSgrMouseEvent(data: string): MouseEvent | undefined;
|
|
32
34
|
export interface Component {
|
|
33
35
|
/**
|
|
@@ -99,6 +101,11 @@ export interface ViewportAnchorProvider extends Component {
|
|
|
99
101
|
export interface ViewportAnchorSource {
|
|
100
102
|
id: ViewportAnchorId;
|
|
101
103
|
}
|
|
104
|
+
/** Identity and monotonic revision of the logical output producer. */
|
|
105
|
+
export type ViewportOutputSource = {
|
|
106
|
+
identity: string;
|
|
107
|
+
revision: bigint;
|
|
108
|
+
};
|
|
102
109
|
export interface ViewportAnchorSourceRenderer extends Component {
|
|
103
110
|
renderWithViewportAnchorSource(width: number, source: ViewportAnchorSource): ViewportAnchorRender;
|
|
104
111
|
}
|
|
@@ -230,6 +237,14 @@ export declare class TUI extends Container {
|
|
|
230
237
|
}[];
|
|
231
238
|
constructor(terminal: Terminal, showHardwareCursor?: boolean, options?: {
|
|
232
239
|
enableMouse?: boolean;
|
|
240
|
+
copySelection?: (text: string) => void | Promise<void>;
|
|
241
|
+
/**
|
|
242
|
+
* Trailing debounce for the settled width repair, in ms. `0` disables the
|
|
243
|
+
* settled repair (deterministic harnesses need this — a wall-clock-timed
|
|
244
|
+
* full replay lands at nondeterministic logical positions). Defaults to
|
|
245
|
+
* `GJC_TUI_WIDTH_SETTLE_MS` / `PI_TUI_WIDTH_SETTLE_MS`, then 1000.
|
|
246
|
+
*/
|
|
247
|
+
widthSettleMs?: number;
|
|
233
248
|
});
|
|
234
249
|
dispose(): void;
|
|
235
250
|
get fullRedraws(): number;
|
|
@@ -243,7 +258,11 @@ export declare class TUI extends Container {
|
|
|
243
258
|
*/
|
|
244
259
|
setClearOnShrink(enabled: boolean): void;
|
|
245
260
|
setFocus(component: Component | null): void;
|
|
261
|
+
removeChild(component: Component): void;
|
|
262
|
+
clear(): void;
|
|
246
263
|
setBottomPinnedComponent(component: Component | null): void;
|
|
264
|
+
/** Report the logical output producer revision without coupling TUI to message types. */
|
|
265
|
+
setViewportOutputSource(source: ViewportOutputSource | null): void;
|
|
247
266
|
/** Register the direct child whose rows are eligible for semantic viewport anchoring. */
|
|
248
267
|
setViewportAnchorComponent(component: Component | null): void;
|
|
249
268
|
/** Clear manual viewport ownership before replacing the transcript identity namespace. */
|
|
@@ -252,6 +271,10 @@ export declare class TUI extends Container {
|
|
|
252
271
|
prepareViewportAnchorForTranscriptRebuild(): void;
|
|
253
272
|
/** Reveal a semantic viewport anchor without changing the rendered content width. */
|
|
254
273
|
revealViewportAnchor(id: ViewportAnchorId, alignment: "top" | "center" | "bottom"): boolean;
|
|
274
|
+
scrollViewportBy(deltaRows: number, options?: {
|
|
275
|
+
/** edge: PageUp/PageDown pin; stable: preserve/center pin for fine wheel motion */
|
|
276
|
+
pin?: "edge" | "stable";
|
|
277
|
+
}): boolean;
|
|
255
278
|
scrollViewportPages(direction: -1 | 1): boolean;
|
|
256
279
|
followLiveViewport(): boolean;
|
|
257
280
|
/**
|
|
@@ -265,6 +288,15 @@ export declare class TUI extends Container {
|
|
|
265
288
|
hasOverlay(): boolean;
|
|
266
289
|
invalidate(): void;
|
|
267
290
|
start(): void;
|
|
291
|
+
/**
|
|
292
|
+
* Wait for a specific render request generation to be written successfully.
|
|
293
|
+
*
|
|
294
|
+
* Render requests are coalesced, so committing a newer generation also commits
|
|
295
|
+
* every older generation represented by that frame. A stopped or unavailable
|
|
296
|
+
* terminal resolves waiters false so UI callers can fail open instead of
|
|
297
|
+
* holding a session operation behind a dead renderer.
|
|
298
|
+
*/
|
|
299
|
+
waitForRenderCommit(generation: number, timeoutMs?: number): Promise<boolean>;
|
|
268
300
|
get terminalAvailable(): boolean;
|
|
269
301
|
addInputListener(listener: InputListener): () => void;
|
|
270
302
|
removeInputListener(listener: InputListener): void;
|
|
@@ -293,6 +325,7 @@ export declare class TUI extends Container {
|
|
|
293
325
|
*/
|
|
294
326
|
requestResizeRender(): void;
|
|
295
327
|
requestRender(force?: boolean, source?: string): void;
|
|
328
|
+
requestRenderWithGeneration(force?: boolean, source?: string): number;
|
|
296
329
|
getLineRenderCacheStats(): {
|
|
297
330
|
normalizationSize: number;
|
|
298
331
|
truncationSize: number;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.
|
|
4
|
+
"version": "0.12.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gajae-code.com",
|
|
7
7
|
"author": "Yeachan-Heo and Gajae Code Contributors",
|
|
@@ -36,8 +36,8 @@
|
|
|
36
36
|
"fmt": "biome format --write ."
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@gajae-code/natives": "0.
|
|
40
|
-
"@gajae-code/utils": "0.
|
|
39
|
+
"@gajae-code/natives": "0.12.0",
|
|
40
|
+
"@gajae-code/utils": "0.12.0",
|
|
41
41
|
"lru-cache": "11.3.6",
|
|
42
42
|
"marked": "18.0.6"
|
|
43
43
|
},
|
|
@@ -92,6 +92,22 @@ export class SelectList implements Component {
|
|
|
92
92
|
this.#findEnabledIndex(clamped, 1, false) ?? this.#findEnabledIndex(clamped, -1, false) ?? -1;
|
|
93
93
|
this.#syncViewportToIndex(this.#selectedIndex >= 0 ? this.#selectedIndex : clamped);
|
|
94
94
|
}
|
|
95
|
+
handleNavigation(action: "tui.select.up" | "tui.select.down" | "tui.select.pageUp" | "tui.select.pageDown"): void {
|
|
96
|
+
switch (action) {
|
|
97
|
+
case "tui.select.up":
|
|
98
|
+
this.#moveSelection(-1);
|
|
99
|
+
break;
|
|
100
|
+
case "tui.select.down":
|
|
101
|
+
this.#moveSelection(1);
|
|
102
|
+
break;
|
|
103
|
+
case "tui.select.pageUp":
|
|
104
|
+
this.#movePage(-1);
|
|
105
|
+
break;
|
|
106
|
+
case "tui.select.pageDown":
|
|
107
|
+
this.#movePage(1);
|
|
108
|
+
break;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
95
111
|
|
|
96
112
|
invalidate(): void {
|
|
97
113
|
// No cached state to invalidate currently
|
package/src/metrics.ts
CHANGED
|
@@ -124,6 +124,7 @@ export interface RenderMetricsSnapshot {
|
|
|
124
124
|
timerGauges: Record<string, number>;
|
|
125
125
|
helperStats: Record<string, HelperStat>;
|
|
126
126
|
lineCounts: Record<string, LineCountGauge>;
|
|
127
|
+
structuralCounters: Record<string, number>;
|
|
127
128
|
}
|
|
128
129
|
|
|
129
130
|
function emptyDurationStats(): DurationStats {
|
|
@@ -160,6 +161,7 @@ export class RenderMetrics {
|
|
|
160
161
|
#timerGauges = new Map<string, number>();
|
|
161
162
|
#helpers = new Map<string, { count: number; totalMs: number }>();
|
|
162
163
|
#lineGauges = new Map<string, LineCountGauge>();
|
|
164
|
+
#structuralCounters = new Map<string, number>();
|
|
163
165
|
#rssReturn: number | null = null;
|
|
164
166
|
#heapBaseline: number | null = null;
|
|
165
167
|
#heapReturn: number | null = null;
|
|
@@ -200,6 +202,7 @@ export class RenderMetrics {
|
|
|
200
202
|
this.#timerGauges.clear();
|
|
201
203
|
this.#helpers.clear();
|
|
202
204
|
this.#lineGauges.clear();
|
|
205
|
+
this.#structuralCounters.clear();
|
|
203
206
|
this.#rssReturn = null;
|
|
204
207
|
this.#heapBaseline = null;
|
|
205
208
|
this.#heapReturn = null;
|
|
@@ -296,6 +299,13 @@ export class RenderMetrics {
|
|
|
296
299
|
this.#lineGauges.set(retained, cur);
|
|
297
300
|
}
|
|
298
301
|
|
|
302
|
+
/** Accumulate deterministic structural render work without retaining frame data. */
|
|
303
|
+
recordStructuralCounter(name: string, value = 1): void {
|
|
304
|
+
if (!this.#enabled) return;
|
|
305
|
+
const retained = retainedLabel(this.#structuralCounters, name);
|
|
306
|
+
this.#structuralCounters.set(retained, (this.#structuralCounters.get(retained) ?? 0) + value);
|
|
307
|
+
}
|
|
308
|
+
|
|
299
309
|
/**
|
|
300
310
|
* Force a GC when the runtime exposes one and sample RSS as the post-run
|
|
301
311
|
* "return" value used by the memory-leak gate. Callers should drop large
|
|
@@ -345,6 +355,10 @@ export class RenderMetrics {
|
|
|
345
355
|
return out;
|
|
346
356
|
}
|
|
347
357
|
|
|
358
|
+
#structuralCounterStats(): Record<string, number> {
|
|
359
|
+
return Object.fromEntries(this.#structuralCounters);
|
|
360
|
+
}
|
|
361
|
+
|
|
348
362
|
snapshot(): RenderMetricsSnapshot {
|
|
349
363
|
return {
|
|
350
364
|
enabled: this.#enabled,
|
|
@@ -374,6 +388,7 @@ export class RenderMetrics {
|
|
|
374
388
|
timerGauges: Object.fromEntries(this.#timerGauges),
|
|
375
389
|
helperStats: this.#helperStats(),
|
|
376
390
|
lineCounts: this.#lineCountStats(),
|
|
391
|
+
structuralCounters: this.#structuralCounterStats(),
|
|
377
392
|
};
|
|
378
393
|
}
|
|
379
394
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -3,7 +3,6 @@ import * as fs from "node:fs";
|
|
|
3
3
|
import { $env, $flag, $pickenv } from "@gajae-code/utils";
|
|
4
4
|
import { setKittyProtocolActive } from "./keys";
|
|
5
5
|
import { StdinBuffer } from "./stdin-buffer";
|
|
6
|
-
import { isUnderTerminalMultiplexer } from "./terminal-capabilities";
|
|
7
6
|
|
|
8
7
|
const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
|
|
9
8
|
const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
|
|
@@ -52,6 +51,7 @@ export function emergencyTerminalRestore(): void {
|
|
|
52
51
|
process.stdout.write(
|
|
53
52
|
"\x1b[?2004l" + // Disable bracketed paste
|
|
54
53
|
"\x1b[?1000l" + // Disable normal mouse reporting
|
|
54
|
+
"\x1b[?1002l" + // Disable button-event mouse reporting
|
|
55
55
|
"\x1b[?1006l" + // Disable SGR extended mouse reporting
|
|
56
56
|
"\x1b[?2031l" + // Disable Mode 2031 appearance notifications
|
|
57
57
|
"\x1b[<u" + // Pop kitty keyboard protocol
|
|
@@ -244,8 +244,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
244
244
|
}
|
|
245
245
|
|
|
246
246
|
setMouseEnabled(enabled: boolean): void {
|
|
247
|
-
this.#mouseEnabled = enabled
|
|
248
|
-
if (this.#started)
|
|
247
|
+
this.#mouseEnabled = enabled;
|
|
248
|
+
if (this.#started)
|
|
249
|
+
this.#safeWrite(
|
|
250
|
+
this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l",
|
|
251
|
+
);
|
|
249
252
|
}
|
|
250
253
|
|
|
251
254
|
start(onInput: (data: string) => void, onResize: () => void): void {
|
|
@@ -271,8 +274,9 @@ export class ProcessTerminal implements Terminal {
|
|
|
271
274
|
|
|
272
275
|
// Enable bracketed paste mode - terminal will wrap pastes in \x1b[200~ ... \x1b[201~
|
|
273
276
|
this.#safeWrite("\x1b[?2004h");
|
|
274
|
-
//
|
|
275
|
-
|
|
277
|
+
// Button-event reporting preserves wheel input while also letting the TUI implement drag selection.
|
|
278
|
+
// Clear both tracking variants first so stale modes from another application cannot leak across startup.
|
|
279
|
+
this.#safeWrite(this.#mouseEnabled ? "\x1b[?1000l\x1b[?1002h\x1b[?1006h" : "\x1b[?1000l\x1b[?1002l\x1b[?1006l");
|
|
276
280
|
|
|
277
281
|
// Set up resize handler immediately
|
|
278
282
|
process.stdout.on("resize", this.#resizeHandler);
|
|
@@ -708,6 +712,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
708
712
|
this.#mouseEnabled = false;
|
|
709
713
|
this.#safeWrite("\x1b[?2004l");
|
|
710
714
|
this.#safeWrite("\x1b[?1000l");
|
|
715
|
+
this.#safeWrite("\x1b[?1002l");
|
|
711
716
|
this.#safeWrite("\x1b[?1006l");
|
|
712
717
|
|
|
713
718
|
// Disable Mode 2031 appearance change notifications
|