@gajae-code/tui 0.7.7 → 0.7.8
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 +8 -0
- package/dist/types/components/settings-list.d.ts +1 -1
- package/dist/types/terminal.d.ts +8 -0
- package/package.json +3 -3
- package/src/components/settings-list.ts +12 -0
- package/src/terminal.ts +42 -2
- package/src/tui.ts +38 -4
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.7.8] - 2026-06-30
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- The render loop now isolates a component whose `render()` throws: the failure is logged once and replaced with a `[render error: <Name>]` fallback line instead of escaping the frame and tripping the process-level fail-fast `uncaughtException` exit. Previously any unguarded renderer fault (e.g. a tool renderer fed an undefined field) crashed the whole app on whatever triggered the next frame — a keystroke, resize, or command such as `/background` (#1291).
|
|
10
|
+
|
|
11
|
+
- Resolved terminal dimensions from the live TTY window size before stream defaults so wide Windows Terminal/PowerShell sessions render against the actual viewport width (#1239).
|
|
12
|
+
|
|
5
13
|
## [0.7.4] - 2026-06-27
|
|
6
14
|
|
|
7
15
|
### Fixed
|
|
@@ -22,7 +22,7 @@ export interface SettingsListTheme {
|
|
|
22
22
|
}
|
|
23
23
|
export declare class SettingsList implements Component {
|
|
24
24
|
#private;
|
|
25
|
-
constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void);
|
|
25
|
+
constructor(items: SettingItem[], maxVisible: number, theme: SettingsListTheme, onChange: (id: string, newValue: string) => void, onCancel: () => void, onSelectionChange?: (item: SettingItem | undefined) => void);
|
|
26
26
|
/** Update an item's currentValue */
|
|
27
27
|
updateValue(id: string, newValue: string): void;
|
|
28
28
|
/**
|
package/dist/types/terminal.d.ts
CHANGED
|
@@ -50,6 +50,13 @@ export interface Terminal {
|
|
|
50
50
|
/** The last detected terminal appearance, or undefined if not yet known. */
|
|
51
51
|
get appearance(): TerminalAppearance | undefined;
|
|
52
52
|
}
|
|
53
|
+
interface TerminalSizeStream {
|
|
54
|
+
columns?: number;
|
|
55
|
+
rows?: number;
|
|
56
|
+
getWindowSize?: () => [number, number] | number[];
|
|
57
|
+
}
|
|
58
|
+
export declare function resolveTerminalColumns(stream?: TerminalSizeStream, envColumns?: string | undefined): number;
|
|
59
|
+
export declare function resolveTerminalRows(stream?: TerminalSizeStream, envRows?: string | undefined): number;
|
|
53
60
|
/**
|
|
54
61
|
* Real terminal using process.stdin/stdout
|
|
55
62
|
*/
|
|
@@ -74,3 +81,4 @@ export declare class ProcessTerminal implements Terminal {
|
|
|
74
81
|
setTitle(title: string): void;
|
|
75
82
|
setProgress(active: boolean): void;
|
|
76
83
|
}
|
|
84
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@gajae-code/tui",
|
|
4
|
-
"version": "0.7.
|
|
4
|
+
"version": "0.7.8",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://gaebal-gajae.dev",
|
|
7
7
|
"author": "Yeachan-Heo",
|
|
@@ -38,8 +38,8 @@
|
|
|
38
38
|
"fmt": "biome format --write ."
|
|
39
39
|
},
|
|
40
40
|
"dependencies": {
|
|
41
|
-
"@gajae-code/natives": "0.7.
|
|
42
|
-
"@gajae-code/utils": "0.7.
|
|
41
|
+
"@gajae-code/natives": "0.7.8",
|
|
42
|
+
"@gajae-code/utils": "0.7.8",
|
|
43
43
|
"lru-cache": "11.3.6",
|
|
44
44
|
"marked": "^18.0.3"
|
|
45
45
|
},
|
|
@@ -32,6 +32,7 @@ export class SettingsList implements Component {
|
|
|
32
32
|
#maxVisible: number;
|
|
33
33
|
#onChange: (id: string, newValue: string) => void;
|
|
34
34
|
#onCancel: () => void;
|
|
35
|
+
#onSelectionChange?: (item: SettingItem | undefined) => void;
|
|
35
36
|
|
|
36
37
|
// Submenu state
|
|
37
38
|
#submenuComponent: Component | null = null;
|
|
@@ -43,12 +44,15 @@ export class SettingsList implements Component {
|
|
|
43
44
|
theme: SettingsListTheme,
|
|
44
45
|
onChange: (id: string, newValue: string) => void,
|
|
45
46
|
onCancel: () => void,
|
|
47
|
+
onSelectionChange?: (item: SettingItem | undefined) => void,
|
|
46
48
|
) {
|
|
47
49
|
this.#items = items;
|
|
48
50
|
this.#maxVisible = maxVisible;
|
|
49
51
|
this.#theme = theme;
|
|
50
52
|
this.#onChange = onChange;
|
|
51
53
|
this.#onCancel = onCancel;
|
|
54
|
+
this.#onSelectionChange = onSelectionChange;
|
|
55
|
+
this.#notifySelectionChange();
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
/** Update an item's currentValue */
|
|
@@ -73,12 +77,17 @@ export class SettingsList implements Component {
|
|
|
73
77
|
} else if (this.#selectedIndex >= this.#items.length) {
|
|
74
78
|
this.#selectedIndex = this.#items.length - 1;
|
|
75
79
|
}
|
|
80
|
+
this.#notifySelectionChange();
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
invalidate(): void {
|
|
79
84
|
this.#submenuComponent?.invalidate?.();
|
|
80
85
|
}
|
|
81
86
|
|
|
87
|
+
#notifySelectionChange(): void {
|
|
88
|
+
this.#onSelectionChange?.(this.#items[this.#selectedIndex]);
|
|
89
|
+
}
|
|
90
|
+
|
|
82
91
|
render(width: number): string[] {
|
|
83
92
|
// If submenu is active, render it instead
|
|
84
93
|
if (this.#submenuComponent) {
|
|
@@ -167,8 +176,10 @@ export class SettingsList implements Component {
|
|
|
167
176
|
const kb = getKeybindings();
|
|
168
177
|
if (kb.matches(data, "tui.select.up")) {
|
|
169
178
|
this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
|
|
179
|
+
this.#notifySelectionChange();
|
|
170
180
|
} else if (kb.matches(data, "tui.select.down")) {
|
|
171
181
|
this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
|
|
182
|
+
this.#notifySelectionChange();
|
|
172
183
|
} else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
|
|
173
184
|
this.#activateItem();
|
|
174
185
|
} else if (kb.matches(data, "tui.select.cancel")) {
|
|
@@ -206,6 +217,7 @@ export class SettingsList implements Component {
|
|
|
206
217
|
if (this.#submenuItemIndex !== null) {
|
|
207
218
|
this.#selectedIndex = this.#submenuItemIndex;
|
|
208
219
|
this.#submenuItemIndex = null;
|
|
220
|
+
this.#notifySelectionChange();
|
|
209
221
|
}
|
|
210
222
|
}
|
|
211
223
|
}
|
package/src/terminal.ts
CHANGED
|
@@ -124,6 +124,46 @@ export interface Terminal {
|
|
|
124
124
|
get appearance(): TerminalAppearance | undefined;
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
interface TerminalSizeStream {
|
|
128
|
+
columns?: number;
|
|
129
|
+
rows?: number;
|
|
130
|
+
getWindowSize?: () => [number, number] | number[];
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function positiveDimension(value: unknown): number | undefined {
|
|
134
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return undefined;
|
|
135
|
+
const dimension = Math.trunc(value);
|
|
136
|
+
return dimension > 0 ? dimension : undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function resolveTerminalColumns(
|
|
140
|
+
stream: TerminalSizeStream = process.stdout,
|
|
141
|
+
envColumns: string | undefined = Bun.env.COLUMNS,
|
|
142
|
+
): number {
|
|
143
|
+
try {
|
|
144
|
+
const windowSize = stream.getWindowSize?.();
|
|
145
|
+
const liveColumns = positiveDimension(windowSize?.[0]);
|
|
146
|
+
if (liveColumns !== undefined) return liveColumns;
|
|
147
|
+
} catch {
|
|
148
|
+
// Fall back below when the stream cannot report a live TTY size.
|
|
149
|
+
}
|
|
150
|
+
return positiveDimension(stream.columns) ?? positiveDimension(Number(envColumns)) ?? 80;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export function resolveTerminalRows(
|
|
154
|
+
stream: TerminalSizeStream = process.stdout,
|
|
155
|
+
envRows: string | undefined = Bun.env.LINES,
|
|
156
|
+
): number {
|
|
157
|
+
try {
|
|
158
|
+
const windowSize = stream.getWindowSize?.();
|
|
159
|
+
const liveRows = positiveDimension(windowSize?.[1]);
|
|
160
|
+
if (liveRows !== undefined) return liveRows;
|
|
161
|
+
} catch {
|
|
162
|
+
// Fall back below when the stream cannot report a live TTY size.
|
|
163
|
+
}
|
|
164
|
+
return positiveDimension(stream.rows) ?? positiveDimension(Number(envRows)) ?? 24;
|
|
165
|
+
}
|
|
166
|
+
|
|
127
167
|
function isWindowsSubsystemForLinux(): boolean {
|
|
128
168
|
return process.platform === "linux" && (!!$env.WSL_DISTRO_NAME || !!$env.WSL_INTEROP);
|
|
129
169
|
}
|
|
@@ -740,11 +780,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
740
780
|
}
|
|
741
781
|
|
|
742
782
|
get columns(): number {
|
|
743
|
-
return
|
|
783
|
+
return resolveTerminalColumns();
|
|
744
784
|
}
|
|
745
785
|
|
|
746
786
|
get rows(): number {
|
|
747
|
-
return
|
|
787
|
+
return resolveTerminalRows();
|
|
748
788
|
}
|
|
749
789
|
|
|
750
790
|
moveBy(lines: number): void {
|
package/src/tui.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import * as fs from "node:fs";
|
|
5
5
|
import * as path from "node:path";
|
|
6
6
|
import { performance } from "node:perf_hooks";
|
|
7
|
-
import { $flag, getDebugLogPath } from "@gajae-code/utils";
|
|
7
|
+
import { $flag, getDebugLogPath, logger } from "@gajae-code/utils";
|
|
8
8
|
import { getKeybindings } from "./keybindings";
|
|
9
9
|
import { isKeyRelease } from "./keys";
|
|
10
10
|
import { renderMetrics } from "./metrics";
|
|
@@ -257,7 +257,7 @@ export class Container implements Component {
|
|
|
257
257
|
width = Math.max(1, width);
|
|
258
258
|
const lines: string[] = [];
|
|
259
259
|
for (const child of this.children) {
|
|
260
|
-
const childLines = child
|
|
260
|
+
const childLines = safeRenderComponent(child, width, "container-child");
|
|
261
261
|
for (let i = 0; i < childLines.length; i++) {
|
|
262
262
|
lines.push(childLines[i]);
|
|
263
263
|
}
|
|
@@ -266,6 +266,40 @@ export class Container implements Component {
|
|
|
266
266
|
}
|
|
267
267
|
}
|
|
268
268
|
|
|
269
|
+
const MAX_REPORTED_RENDER_ERRORS = 200;
|
|
270
|
+
const reportedRenderErrors = new Set<string>();
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Render a component's lines without letting a thrown error escape the frame.
|
|
274
|
+
*
|
|
275
|
+
* The TUI render loop ({@link TUI.#doRender}) runs inside a `nextTick`/`setTimeout`
|
|
276
|
+
* with no try/catch, and the process installs a fail-fast `uncaughtException`
|
|
277
|
+
* handler that exits. So a single component whose `render()` throws (e.g. a tool
|
|
278
|
+
* renderer fed an optional/undefined field) used to take down the whole app —
|
|
279
|
+
* fatal on whatever happened to trigger the frame (a keystroke, resize, or a
|
|
280
|
+
* command such as `/background`). Isolate the failure: log it once, emit a
|
|
281
|
+
* visible fallback line, and keep rendering the rest of the tree.
|
|
282
|
+
*/
|
|
283
|
+
function safeRenderComponent(component: Component, width: number, where: string): string[] {
|
|
284
|
+
try {
|
|
285
|
+
return component.render(width);
|
|
286
|
+
} catch (err) {
|
|
287
|
+
const name = component?.constructor?.name ?? "Component";
|
|
288
|
+
const key = `${where}:${name}:${err instanceof Error ? err.message : String(err)}`;
|
|
289
|
+
if (!reportedRenderErrors.has(key)) {
|
|
290
|
+
if (reportedRenderErrors.size >= MAX_REPORTED_RENDER_ERRORS) reportedRenderErrors.clear();
|
|
291
|
+
reportedRenderErrors.add(key);
|
|
292
|
+
logger.error("Component render failed; emitting fallback line", {
|
|
293
|
+
where,
|
|
294
|
+
component: name,
|
|
295
|
+
error: err instanceof Error ? err.message : String(err),
|
|
296
|
+
stack: err instanceof Error ? err.stack : undefined,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
return [`[render error: ${name}]`];
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
269
303
|
type LineNormalizationCacheEntry = {
|
|
270
304
|
normalized: string;
|
|
271
305
|
terminated: string;
|
|
@@ -1071,7 +1105,7 @@ export class TUI extends Container {
|
|
|
1071
1105
|
const { width, maxHeight } = this.#resolveOverlayLayout(options, 0, termWidth, termHeight);
|
|
1072
1106
|
|
|
1073
1107
|
// Render component at calculated width
|
|
1074
|
-
let overlayLines = component
|
|
1108
|
+
let overlayLines = safeRenderComponent(component, width, "overlay");
|
|
1075
1109
|
|
|
1076
1110
|
// Apply maxHeight if specified
|
|
1077
1111
|
if (maxHeight !== undefined && overlayLines.length > maxHeight) {
|
|
@@ -1302,7 +1336,7 @@ export class TUI extends Container {
|
|
|
1302
1336
|
|
|
1303
1337
|
let pinnedLineCount = 0;
|
|
1304
1338
|
for (let i = pinnedStart; i < this.children.length; i++) {
|
|
1305
|
-
pinnedLineCount += this.children[i]
|
|
1339
|
+
pinnedLineCount += safeRenderComponent(this.children[i], this.terminal.columns, "pinned").length;
|
|
1306
1340
|
}
|
|
1307
1341
|
|
|
1308
1342
|
const blankRows = height - lines.length;
|