@gajae-code/tui 0.2.0 → 0.2.2

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,18 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.2.2] - 2026-05-31
6
+
7
+ ### Changed
8
+
9
+ - Refreshed TUI package metadata for the GJC 0.2.2 release.
10
+
11
+ ## [0.2.1] - 2026-05-30
12
+
13
+ ### Fixed
14
+
15
+ - Made TUI terminal writes tolerate `EIO`/closed PTY failures by marking output unavailable, swallowing render/cursor cleanup write errors, and suppressing later render writes after detach.
16
+
5
17
  ## [0.2.0] - 2026-05-28
6
18
 
7
19
  ### Changed
@@ -833,4 +845,4 @@ Initial release under @gajae-code scope. See previous releases at [badlogic/pi-m
833
845
 
834
846
  ### Fixed
835
847
 
836
- - **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))
848
+ - **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))
@@ -10,6 +10,11 @@ export interface SlashCommand {
10
10
  name: string;
11
11
  description?: string;
12
12
  argumentHint?: string;
13
+ /**
14
+ * Higher values surface first in autocomplete, ahead of fuzzy-score ordering.
15
+ * Use this to pin first-class commands (e.g. bundled GJC skills) to the top.
16
+ */
17
+ priority?: number;
13
18
  getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
14
19
  /** Return inline hint text for the current argument state (shown as dim ghost text after cursor) */
15
20
  getInlineHint?(argumentText: string): string | null;
@@ -16,6 +16,7 @@ export interface Terminal {
16
16
  */
17
17
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
18
18
  write(data: string): void;
19
+ get available(): boolean;
19
20
  get columns(): number;
20
21
  get rows(): number;
21
22
  get kittyProtocolActive(): boolean;
@@ -48,6 +49,7 @@ export declare class ProcessTerminal implements Terminal {
48
49
  drainInput(maxMs?: number, idleMs?: number): Promise<void>;
49
50
  stop(): void;
50
51
  write(data: string): void;
52
+ get available(): boolean;
51
53
  get columns(): number;
52
54
  get rows(): number;
53
55
  moveBy(lines: number): void;
@@ -154,6 +154,7 @@ export declare class TUI extends Container {
154
154
  hasOverlay(): boolean;
155
155
  invalidate(): void;
156
156
  start(): void;
157
+ get terminalAvailable(): boolean;
157
158
  addInputListener(listener: InputListener): () => void;
158
159
  removeInputListener(listener: InputListener): void;
159
160
  stop(): void;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/tui",
4
- "version": "0.2.0",
4
+ "version": "0.2.2",
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",
@@ -37,8 +37,8 @@
37
37
  "fmt": "biome format --write ."
38
38
  },
39
39
  "dependencies": {
40
- "@gajae-code/natives": "0.2.0",
41
- "@gajae-code/utils": "0.2.0",
40
+ "@gajae-code/natives": "0.2.2",
41
+ "@gajae-code/utils": "0.2.2",
42
42
  "lru-cache": "11.3.6",
43
43
  "marked": "^18.0.3"
44
44
  },
@@ -162,6 +162,11 @@ export interface SlashCommand {
162
162
  name: string;
163
163
  description?: string;
164
164
  argumentHint?: string;
165
+ /**
166
+ * Higher values surface first in autocomplete, ahead of fuzzy-score ordering.
167
+ * Use this to pin first-class commands (e.g. bundled GJC skills) to the top.
168
+ */
169
+ priority?: number;
165
170
  // Function to get argument completions for this command
166
171
  // Returns null if no argument completion is available
167
172
  getArgumentCompletions?(argumentPrefix: string): Awaitable<AutocompleteItem[] | null>;
@@ -283,15 +288,17 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
283
288
  const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
284
289
  const desc = cmd.description ?? "";
285
290
  const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
291
+ const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
286
292
  return {
287
293
  value: name,
288
294
  label: "name" in cmd ? cmd.name : cmd.label,
289
295
  score: Math.max(nameScore, descScore),
296
+ priority,
290
297
  ...(fullDesc && { description: fullDesc }),
291
298
  };
292
299
  })
293
- .sort((a, b) => b.score - a.score)
294
- .map(({ score: _, ...rest }) => rest);
300
+ .sort((a, b) => b.priority - a.priority || b.score - a.score)
301
+ .map(({ score: _score, priority: _priority, ...rest }) => rest);
295
302
 
296
303
  if (matches.length === 0) return null;
297
304
 
@@ -820,15 +827,17 @@ export class CombinedAutocompleteProvider implements AutocompleteProvider {
820
827
  const hint = "argumentHint" in cmd && cmd.argumentHint ? cmd.argumentHint : undefined;
821
828
  const desc = cmd.description ?? "";
822
829
  const fullDesc = hint ? (desc ? `${hint} — ${desc}` : hint) : desc;
830
+ const priority = "priority" in cmd && typeof cmd.priority === "number" ? cmd.priority : 0;
823
831
  return {
824
832
  value: name,
825
833
  label: "name" in cmd ? cmd.name : cmd.label,
826
834
  score: Math.max(nameScore, descScore),
835
+ priority,
827
836
  ...(fullDesc && { description: fullDesc }),
828
- } as AutocompleteItem & { score: number };
837
+ } as AutocompleteItem & { score: number; priority: number };
829
838
  })
830
- .sort((a, b) => b.score - a.score)
831
- .map(({ score: _, ...rest }) => rest);
839
+ .sort((a, b) => b.priority - a.priority || b.score - a.score)
840
+ .map(({ score: _score, priority: _priority, ...rest }) => rest);
832
841
 
833
842
  if (matches.length === 0) return null;
834
843
  return { items: matches, prefix: textBeforeCursor };
package/src/terminal.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { dlopen, FFIType, ptr } from "bun:ffi";
2
2
  import * as fs from "node:fs";
3
- import { $env, logger } from "@gajae-code/utils";
3
+ import { $env } from "@gajae-code/utils";
4
4
  import { setKittyProtocolActive } from "./keys";
5
5
  import { StdinBuffer } from "./stdin-buffer";
6
6
 
@@ -67,6 +67,9 @@ export interface Terminal {
67
67
  // Write output to terminal
68
68
  write(data: string): void;
69
69
 
70
+ // Whether terminal output is still writable
71
+ get available(): boolean;
72
+
70
73
  // Get terminal dimensions
71
74
  get columns(): number;
72
75
  get rows(): number;
@@ -121,7 +124,9 @@ export class ProcessTerminal implements Terminal {
121
124
  #stdinDataHandler?: (data: string) => void;
122
125
  #dead = false;
123
126
  #writeLogPath = $env.PI_TUI_WRITE_LOG || "";
127
+ #detachLogPath = $env.PI_TUI_TERMINAL_DETACH_LOG || "";
124
128
  #windowsVTInputRestore?: () => void;
129
+ #stdoutErrorHandler?: (err: Error) => void;
125
130
  #appearanceCallbacks: Array<(appearance: TerminalAppearance) => void> = [];
126
131
  #appearance: TerminalAppearance | undefined;
127
132
  #osc11Pending = false;
@@ -166,6 +171,10 @@ export class ProcessTerminal implements Terminal {
166
171
 
167
172
  // Set up resize handler immediately
168
173
  process.stdout.on("resize", this.#resizeHandler);
174
+ this.#stdoutErrorHandler = (err: Error) => {
175
+ this.#markUnavailable(err, "stdout-error");
176
+ };
177
+ process.stdout.on("error", this.#stdoutErrorHandler);
169
178
 
170
179
  // Refresh terminal dimensions - they may be stale after suspend/resume
171
180
  // (SIGWINCH is lost while process is stopped). Unix only.
@@ -611,6 +620,10 @@ export class ProcessTerminal implements Terminal {
611
620
  process.stdout.removeListener("resize", this.#resizeHandler);
612
621
  this.#resizeHandler = undefined;
613
622
  }
623
+ if (this.#stdoutErrorHandler) {
624
+ process.stdout.removeListener("error", this.#stdoutErrorHandler);
625
+ this.#stdoutErrorHandler = undefined;
626
+ }
614
627
 
615
628
  // Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
616
629
  // re-interpreted after raw mode is disabled. This fixes a race condition
@@ -639,13 +652,59 @@ export class ProcessTerminal implements Terminal {
639
652
  // Skip control sequences when stdout isn't a TTY (piped output, tests, log
640
653
  // files). They serve no purpose there and would surface as visible noise.
641
654
  if (!process.stdout.isTTY) return;
655
+ if (
656
+ !process.stdout.writable ||
657
+ process.stdout.destroyed ||
658
+ process.stdout.closed ||
659
+ process.stdout.writableEnded
660
+ ) {
661
+ this.#markUnavailable(undefined, "stdout-closed");
662
+ return;
663
+ }
642
664
  try {
643
665
  process.stdout.write(data);
644
666
  } catch (err) {
645
- // Any write failure means terminal is dead - no recovery possible
646
- this.#dead = true;
647
- logger.warn("terminal is dead - no recovery possible", { error: err, data });
667
+ this.#markUnavailable(err, "write");
668
+ }
669
+ }
670
+
671
+ #markUnavailable(err: unknown, operation: string): void {
672
+ if (this.#dead) return;
673
+ this.#dead = true;
674
+ this.#clearProgressTimer();
675
+ this.#stopOsc11Poll();
676
+ if (this.#mode2031DebounceTimer) {
677
+ clearTimeout(this.#mode2031DebounceTimer);
678
+ this.#mode2031DebounceTimer = undefined;
648
679
  }
680
+ if (this.#modifyOtherKeysTimeout) {
681
+ clearTimeout(this.#modifyOtherKeysTimeout);
682
+ this.#modifyOtherKeysTimeout = undefined;
683
+ }
684
+ this.#appendDetachDebugEvent(operation, err);
685
+ }
686
+
687
+ #appendDetachDebugEvent(operation: string, err: unknown): void {
688
+ if (!this.#detachLogPath) return;
689
+ const error = err instanceof Error ? err : undefined;
690
+ const code =
691
+ typeof (err as { code?: unknown } | undefined)?.code === "string" ? (err as { code: string }).code : undefined;
692
+ const line = JSON.stringify({
693
+ at: new Date().toISOString(),
694
+ operation,
695
+ code,
696
+ name: error?.name,
697
+ message: error?.message,
698
+ });
699
+ try {
700
+ fs.appendFileSync(this.#detachLogPath, `${line}\n`, { encoding: "utf8" });
701
+ } catch {
702
+ // Ignore debug logging errors; the terminal is already unavailable.
703
+ }
704
+ }
705
+
706
+ get available(): boolean {
707
+ return !this.#dead;
649
708
  }
650
709
 
651
710
  get columns(): number {
package/src/tui.ts CHANGED
@@ -256,6 +256,7 @@ export class TUI extends Container {
256
256
  #maxLinesRendered = 0; // Line count from last render, used for viewport calculation
257
257
  #fullRedrawCount = 0;
258
258
  #stopped = false;
259
+ #terminalUnavailable = false;
259
260
 
260
261
  // Overlay stack for modal components rendered on top of base content
261
262
  overlayStack: {
@@ -285,7 +286,7 @@ export class TUI extends Container {
285
286
  if (this.#showHardwareCursor === enabled) return;
286
287
  this.#showHardwareCursor = enabled;
287
288
  if (!enabled) {
288
- this.terminal.hideCursor();
289
+ this.#hideCursor();
289
290
  }
290
291
  this.requestRender();
291
292
  }
@@ -328,7 +329,7 @@ export class TUI extends Container {
328
329
  if (this.#isOverlayVisible(entry)) {
329
330
  this.setFocus(component);
330
331
  }
331
- this.terminal.hideCursor();
332
+ this.#hideCursor();
332
333
  this.requestRender();
333
334
 
334
335
  // Return handle for controlling this overlay
@@ -342,7 +343,7 @@ export class TUI extends Container {
342
343
  const topVisible = this.#getTopmostVisibleOverlay();
343
344
  this.setFocus(topVisible?.component ?? entry.preFocus);
344
345
  }
345
- if (this.overlayStack.length === 0) this.terminal.hideCursor();
346
+ if (this.overlayStack.length === 0) this.#hideCursor();
346
347
  this.requestRender();
347
348
  }
348
349
  },
@@ -375,7 +376,7 @@ export class TUI extends Container {
375
376
  // Find topmost visible overlay, or fall back to preFocus
376
377
  const topVisible = this.#getTopmostVisibleOverlay();
377
378
  this.setFocus(topVisible?.component ?? overlay.preFocus);
378
- if (this.overlayStack.length === 0) this.terminal.hideCursor();
379
+ if (this.overlayStack.length === 0) this.#hideCursor();
379
380
  this.requestRender();
380
381
  }
381
382
 
@@ -410,16 +411,62 @@ export class TUI extends Container {
410
411
 
411
412
  start(): void {
412
413
  this.#stopped = false;
414
+ this.#terminalUnavailable = false;
413
415
  this.terminal.start(
414
416
  data => this.#handleInput(data),
415
417
  () => this.requestRender(),
416
418
  );
417
- this.terminal.hideCursor();
419
+ this.#hideCursor();
418
420
  this.#querySixelSupport();
419
421
  this.#queryCellSize();
420
422
  this.requestRender(true);
421
423
  }
422
424
 
425
+ get terminalAvailable(): boolean {
426
+ return !this.#terminalUnavailable && this.terminal.available;
427
+ }
428
+
429
+ #markTerminalUnavailable(): void {
430
+ this.#terminalUnavailable = true;
431
+ this.#stopped = true;
432
+ this.#renderRequested = false;
433
+ if (this.#renderTimer) {
434
+ clearTimeout(this.#renderTimer);
435
+ this.#renderTimer = undefined;
436
+ }
437
+ this.#clearSixelProbeState();
438
+ }
439
+
440
+ #writeTerminal(data: string): boolean {
441
+ return this.#guardTerminalOperation(() => this.terminal.write(data));
442
+ }
443
+
444
+ #hideCursor(): boolean {
445
+ return this.#guardTerminalOperation(() => this.terminal.hideCursor());
446
+ }
447
+
448
+ #showCursor(): boolean {
449
+ return this.#guardTerminalOperation(() => this.terminal.showCursor());
450
+ }
451
+
452
+ #guardTerminalOperation(operation: () => void): boolean {
453
+ if (!this.terminalAvailable) {
454
+ this.#markTerminalUnavailable();
455
+ return false;
456
+ }
457
+ try {
458
+ operation();
459
+ } catch {
460
+ this.#markTerminalUnavailable();
461
+ return false;
462
+ }
463
+ if (!this.terminal.available) {
464
+ this.#markTerminalUnavailable();
465
+ return false;
466
+ }
467
+ return true;
468
+ }
469
+
423
470
  addInputListener(listener: InputListener): () => void {
424
471
  this.#inputListeners.add(listener);
425
472
  return () => {
@@ -441,8 +488,8 @@ export class TUI extends Container {
441
488
  this.#sixelProbePendingDa = true;
442
489
  this.#sixelProbePendingGraphics = true;
443
490
  this.#sixelProbeUnsubscribe = this.addInputListener(data => this.#handleSixelProbeInput(data));
444
- this.terminal.write("\x1b[c");
445
- this.terminal.write("\x1b[?2;1;0S");
491
+ if (!this.#writeTerminal("\x1b[c")) return;
492
+ if (!this.#writeTerminal("\x1b[?2;1;0S")) return;
446
493
  this.#sixelProbeTimeout = setTimeout(() => {
447
494
  this.#finishSixelProbe(false);
448
495
  }, 250);
@@ -563,7 +610,7 @@ export class TUI extends Container {
563
610
  }
564
611
  // Query terminal for cell size in pixels: CSI 16 t
565
612
  // Response format: CSI 6 ; height ; width t
566
- this.terminal.write("\x1b[16t");
613
+ this.#writeTerminal("\x1b[16t");
567
614
  }
568
615
 
569
616
  stop(): void {
@@ -578,18 +625,26 @@ export class TUI extends Container {
578
625
  const targetRow = this.#previousLines.length; // Line after the last content
579
626
  const lineDiff = targetRow - this.#hardwareCursorRow;
580
627
  if (lineDiff > 0) {
581
- this.terminal.write(`\x1b[${lineDiff}B`);
628
+ this.#writeTerminal(`\x1b[${lineDiff}B`);
582
629
  } else if (lineDiff < 0) {
583
- this.terminal.write(`\x1b[${-lineDiff}A`);
630
+ this.#writeTerminal(`\x1b[${-lineDiff}A`);
584
631
  }
585
- this.terminal.write("\r\n");
632
+ this.#writeTerminal("\r\n");
586
633
  }
587
634
 
588
- this.terminal.showCursor();
589
- this.terminal.stop();
635
+ this.#showCursor();
636
+ try {
637
+ this.terminal.stop();
638
+ } catch {
639
+ this.#markTerminalUnavailable();
640
+ }
590
641
  }
591
642
 
592
643
  requestRender(force = false): void {
644
+ if (!this.terminalAvailable) {
645
+ this.#markTerminalUnavailable();
646
+ return;
647
+ }
593
648
  if (force) {
594
649
  this.#previousLines = [];
595
650
  this.#previousWidth = -1; // -1 triggers widthChanged, forcing a full clear
@@ -1040,7 +1095,7 @@ export class TUI extends Container {
1040
1095
  }
1041
1096
 
1042
1097
  #doRender(): void {
1043
- if (this.#stopped) return;
1098
+ if (this.#stopped || !this.terminalAvailable) return;
1044
1099
  const width = this.terminal.columns;
1045
1100
  const height = this.terminal.rows;
1046
1101
  let viewportTop = Math.max(0, this.#maxLinesRendered - height);
@@ -1091,7 +1146,7 @@ export class TUI extends Container {
1091
1146
  this.#hardwareCursorRow = toRow;
1092
1147
  buffer += seq;
1093
1148
  buffer += "\x1b[?2026l"; // End synchronized output
1094
- this.terminal.write(buffer);
1149
+ if (!this.#writeTerminal(buffer)) return;
1095
1150
  // Reset max lines when clearing, otherwise track growth
1096
1151
  if (clear) {
1097
1152
  this.#maxLinesRendered = newLines.length;
@@ -1140,7 +1195,7 @@ export class TUI extends Container {
1140
1195
  this.#hardwareCursorRow = cursorToRow;
1141
1196
  buffer += cursorSeq;
1142
1197
  buffer += "\x1b[?2026l";
1143
- this.terminal.write(buffer);
1198
+ if (!this.#writeTerminal(buffer)) return;
1144
1199
 
1145
1200
  if ($flag("PI_DEBUG_REDRAW")) {
1146
1201
  const logPath = getDebugLogPath();
@@ -1274,7 +1329,7 @@ export class TUI extends Container {
1274
1329
  this.#hardwareCursorRow = toRow;
1275
1330
  buffer += seq;
1276
1331
  buffer += "\x1b[?2026l";
1277
- this.terminal.write(buffer);
1332
+ if (!this.#writeTerminal(buffer)) return;
1278
1333
  }
1279
1334
  this.#previousLines = newLines;
1280
1335
  this.#previousWidth = width;
@@ -1415,7 +1470,7 @@ export class TUI extends Container {
1415
1470
  }
1416
1471
 
1417
1472
  // Write entire buffer at once
1418
- this.terminal.write(buffer);
1473
+ if (!this.#writeTerminal(buffer)) return;
1419
1474
 
1420
1475
  // Track cursor position for next render.
1421
1476
  // cursorRow tracks end of content (for viewport calculation).
@@ -1471,11 +1526,11 @@ export class TUI extends Container {
1471
1526
  */
1472
1527
  #writeCursorPosition(cursorPos: { row: number; col: number } | null, totalLines: number): void {
1473
1528
  if (!cursorPos || totalLines <= 0) {
1474
- this.terminal.hideCursor();
1529
+ this.#hideCursor();
1475
1530
  return;
1476
1531
  }
1477
1532
  const { seq, toRow } = this.#cursorControlSequence(cursorPos, totalLines, this.#hardwareCursorRow);
1478
1533
  this.#hardwareCursorRow = toRow;
1479
- this.terminal.write(`\x1b[?2026h${seq}\x1b[?2026l`);
1534
+ this.#writeTerminal(`\x1b[?2026h${seq}\x1b[?2026l`);
1480
1535
  }
1481
1536
  }