@gajae-code/tui 0.7.7 → 0.7.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,21 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.7.9] - 2026-07-01
6
+
7
+ ### Fixed
8
+
9
+ - Reverted the composer Enter/Shift+Enter handling to the 0.7.8 behavior. A recent restructure (#1298 and follow-ups) changed how plain Enter, Ctrl+Enter, and bare-LF Enter were routed and regressed prompt submission for some terminals; the submit/newline branches are now restored to their 0.7.8 form so Enter submits and Shift+Enter inserts a newline.
10
+
11
+ ## [0.7.8] - 2026-06-30
12
+
13
+ ### Fixed
14
+
15
+ - 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).
16
+ - `truncateToWidth` now coerces its required napi `text` argument to a safe string, mirroring the existing nullish guards for `maxWidth`/`ellipsis`/`pad`. Renderers that passed an optional/possibly-undefined field no longer crash with a napi String conversion / `undefined is not an object` error (#1290).
17
+
18
+ - 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).
19
+
5
20
  ## [0.7.4] - 2026-06-27
6
21
 
7
22
  ### 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, descriptionRows?: number);
26
26
  /** Update an item's currentValue */
27
27
  updateValue(id: string, newValue: string): void;
28
28
  /**
@@ -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.7",
4
+ "version": "0.7.9",
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.7",
42
- "@gajae-code/utils": "0.7.7",
41
+ "@gajae-code/natives": "0.7.9",
42
+ "@gajae-code/utils": "0.7.9",
43
43
  "lru-cache": "11.3.6",
44
44
  "marked": "^18.0.3"
45
45
  },
@@ -32,6 +32,8 @@ 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;
36
+ #descriptionRows: number;
35
37
 
36
38
  // Submenu state
37
39
  #submenuComponent: Component | null = null;
@@ -43,12 +45,17 @@ export class SettingsList implements Component {
43
45
  theme: SettingsListTheme,
44
46
  onChange: (id: string, newValue: string) => void,
45
47
  onCancel: () => void,
48
+ onSelectionChange?: (item: SettingItem | undefined) => void,
49
+ descriptionRows = 0,
46
50
  ) {
47
51
  this.#items = items;
48
52
  this.#maxVisible = maxVisible;
49
53
  this.#theme = theme;
50
54
  this.#onChange = onChange;
51
55
  this.#onCancel = onCancel;
56
+ this.#onSelectionChange = onSelectionChange;
57
+ this.#descriptionRows = Math.max(0, descriptionRows);
58
+ this.#notifySelectionChange();
52
59
  }
53
60
 
54
61
  /** Update an item's currentValue */
@@ -73,12 +80,17 @@ export class SettingsList implements Component {
73
80
  } else if (this.#selectedIndex >= this.#items.length) {
74
81
  this.#selectedIndex = this.#items.length - 1;
75
82
  }
83
+ this.#notifySelectionChange();
76
84
  }
77
85
 
78
86
  invalidate(): void {
79
87
  this.#submenuComponent?.invalidate?.();
80
88
  }
81
89
 
90
+ #notifySelectionChange(): void {
91
+ this.#onSelectionChange?.(this.#items[this.#selectedIndex]);
92
+ }
93
+
82
94
  render(width: number): string[] {
83
95
  // If submenu is active, render it instead
84
96
  if (this.#submenuComponent) {
@@ -138,9 +150,18 @@ export class SettingsList implements Component {
138
150
  lines.push(this.#theme.hint(truncateToWidth(scrollText, width - 2, Ellipsis.Omit)));
139
151
  }
140
152
 
141
- // Add description for selected item
153
+ // Add description for selected item. Some hosts reserve a fixed
154
+ // description area so keyboard navigation does not resize the TUI when
155
+ // moving between described and undescribed rows.
142
156
  const selectedItem = this.#items[this.#selectedIndex];
143
- if (selectedItem?.description) {
157
+ if (this.#descriptionRows > 0) {
158
+ lines.push("");
159
+ const wrappedDesc = selectedItem?.description ? wrapTextWithAnsi(selectedItem.description, width - 4) : [];
160
+ for (let i = 0; i < this.#descriptionRows; i++) {
161
+ const line = wrappedDesc[i] ?? "";
162
+ lines.push(line ? this.#theme.description(` ${line}`) : "");
163
+ }
164
+ } else if (selectedItem?.description) {
144
165
  lines.push("");
145
166
  const wrappedDesc = wrapTextWithAnsi(selectedItem.description, width - 4);
146
167
  for (const line of wrappedDesc) {
@@ -167,8 +188,10 @@ export class SettingsList implements Component {
167
188
  const kb = getKeybindings();
168
189
  if (kb.matches(data, "tui.select.up")) {
169
190
  this.#selectedIndex = this.#selectedIndex === 0 ? this.#items.length - 1 : this.#selectedIndex - 1;
191
+ this.#notifySelectionChange();
170
192
  } else if (kb.matches(data, "tui.select.down")) {
171
193
  this.#selectedIndex = this.#selectedIndex === this.#items.length - 1 ? 0 : this.#selectedIndex + 1;
194
+ this.#notifySelectionChange();
172
195
  } else if (kb.matches(data, "tui.select.confirm") || data === " " || data === "\n") {
173
196
  this.#activateItem();
174
197
  } else if (kb.matches(data, "tui.select.cancel")) {
@@ -206,6 +229,7 @@ export class SettingsList implements Component {
206
229
  if (this.#submenuItemIndex !== null) {
207
230
  this.#selectedIndex = this.#submenuItemIndex;
208
231
  this.#submenuItemIndex = null;
232
+ this.#notifySelectionChange();
209
233
  }
210
234
  }
211
235
  }
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 process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
783
+ return resolveTerminalColumns();
744
784
  }
745
785
 
746
786
  get rows(): number {
747
- return process.stdout.rows || Number(Bun.env.LINES) || 24;
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.render(width);
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.render(width);
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].render(this.terminal.columns).length;
1339
+ pinnedLineCount += safeRenderComponent(this.children[i], this.terminal.columns, "pinned").length;
1306
1340
  }
1307
1341
 
1308
1342
  const blankRows = height - lines.length;
package/src/utils.ts CHANGED
@@ -45,14 +45,18 @@ export function truncateToWidth(
45
45
  // Guard nullish napi inputs: napi-rs 3 on the Windows prebuilt rejects
46
46
  // `null` for `Option<u8>` (Ellipsis) / `Option<bool>` (pad) (issue #848),
47
47
  // and `maxWidth` is a required `u32` that throws on `null`/`undefined`
48
- // everywhere. Pass concrete defaults that mirror the Rust `unwrap_or`s.
48
+ // everywhere. The `text` arg is a required `String` that likewise throws on
49
+ // `null`/`undefined` on every platform, which crashed renderers that passed
50
+ // an optional/possibly-undefined field. Pass concrete defaults that mirror
51
+ // the Rust `unwrap_or`s.
52
+ const safeText = typeof text === "string" ? text : String(text ?? "");
49
53
  const safeWidth = Number.isFinite(maxWidth) ? Math.max(0, Math.trunc(maxWidth)) : 0;
50
54
  let resolvedEllipsis: Ellipsis | null | undefined | string = ellipsisKind;
51
55
  if (typeof resolvedEllipsis === "string") {
52
56
  resolvedEllipsis = resolvedEllipsis === "" ? Ellipsis.Omit : Ellipsis.Unicode;
53
57
  }
54
58
  return nativeTruncateToWidth(
55
- text,
59
+ safeText,
56
60
  safeWidth,
57
61
  resolvedEllipsis ?? Ellipsis.Unicode,
58
62
  pad ?? false,