@oh-my-pi/pi-tui 17.4.2 → 18.0.1
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 +67 -0
- package/README.md +1 -1
- package/dist/types/autocomplete.d.ts +17 -6
- package/dist/types/components/editor.d.ts +42 -5
- package/dist/types/components/image.d.ts +2 -5
- package/dist/types/components/markdown.d.ts +18 -24
- package/dist/types/components/select-list.d.ts +9 -0
- package/dist/types/components/text.d.ts +0 -1
- package/dist/types/keybindings.d.ts +5 -0
- package/dist/types/terminal-capabilities.d.ts +10 -3
- package/dist/types/terminal.d.ts +42 -2
- package/dist/types/tui.d.ts +63 -218
- package/dist/types/utils.d.ts +3 -2
- package/package.json +4 -4
- package/src/autocomplete.ts +147 -61
- package/src/components/editor.ts +407 -157
- package/src/components/image.ts +4 -6
- package/src/components/loader.ts +1 -9
- package/src/components/markdown.ts +133 -417
- package/src/components/select-list.ts +67 -15
- package/src/components/text.ts +8 -18
- package/src/fuzzy.ts +63 -6
- package/src/keybindings.ts +5 -0
- package/src/stdin-buffer.ts +130 -20
- package/src/terminal-capabilities.ts +20 -3
- package/src/terminal.ts +147 -22
- package/src/tui.ts +628 -3510
- package/src/utils.ts +12 -3
|
@@ -105,7 +105,7 @@ export class TerminalInfo {
|
|
|
105
105
|
public readonly deccara: boolean = false,
|
|
106
106
|
readonly supportsScreenToScrollback: boolean = false,
|
|
107
107
|
/** Renders the Kitty OSC 66 text-sizing protocol (scaled spans). Kitty only. */
|
|
108
|
-
public readonly
|
|
108
|
+
public readonly supportsTextSizing: boolean = false,
|
|
109
109
|
/**
|
|
110
110
|
* Hangul Compatibility Jamo (U+3131..=U+318E) cell width. Ghostty follows
|
|
111
111
|
* UAX#11 (2 cells); Warp paints 1; "platform" keeps the OS default
|
|
@@ -129,7 +129,13 @@ export class TerminalInfo {
|
|
|
129
129
|
if (this.imageProtocol === ImageProtocol.Sixel) {
|
|
130
130
|
return hasSixelDcsStart(line);
|
|
131
131
|
}
|
|
132
|
-
|
|
132
|
+
// 512-unit window: placeholder cells can sit deep in a composed row —
|
|
133
|
+
// the composer attachment band prefixes each thumbnail row with border
|
|
134
|
+
// SGRs and stacks cards side by side, so the first placeholder of a
|
|
135
|
+
// later card starts hundreds of units in. Rows past the window would
|
|
136
|
+
// silently lose the verbatim image-line path (no truncation, no SGR
|
|
137
|
+
// coalescing) that placeholder grids and placement APCs rely on.
|
|
138
|
+
return hasNeedleBefore(line, this.imageProtocol, 512) || hasNeedleBefore(line, KITTY_PLACEHOLDER, 512);
|
|
133
139
|
}
|
|
134
140
|
|
|
135
141
|
formatNotification(message: string | TerminalNotification): string {
|
|
@@ -551,11 +557,14 @@ export interface RuntimeTerminal extends TerminalInfo {
|
|
|
551
557
|
hyperlinks: boolean;
|
|
552
558
|
deccara: boolean;
|
|
553
559
|
supportsScreenToScrollback: boolean;
|
|
560
|
+
/** Whether OSC 66 text sizing is currently enabled. */
|
|
554
561
|
textSizing: boolean;
|
|
555
562
|
}
|
|
556
563
|
|
|
557
564
|
export const TERMINAL: RuntimeTerminal = (() => {
|
|
558
565
|
const resolved = getTerminalInfo(TERMINAL_ID).clone();
|
|
566
|
+
// Detection records support; hosts opt into OSC 66 separately.
|
|
567
|
+
resolved.textSizing = false;
|
|
559
568
|
|
|
560
569
|
const forcedImageProtocol = getForcedImageProtocol();
|
|
561
570
|
if (forcedImageProtocol !== undefined) {
|
|
@@ -612,7 +621,7 @@ export function setTerminalScreenToScrollback(enabled: boolean): void {
|
|
|
612
621
|
|
|
613
622
|
/**
|
|
614
623
|
* Enable/disable OSC 66 text-sizing at runtime. The coding-agent calls this from
|
|
615
|
-
* the `tui.textSizing` setting (gated on the terminal's static `
|
|
624
|
+
* the `tui.textSizing` setting (gated on the terminal's static `supportsTextSizing`
|
|
616
625
|
* capability); tests flip it directly to exercise the scaled-heading path.
|
|
617
626
|
*/
|
|
618
627
|
export function setTerminalTextSizing(enabled: boolean): void {
|
|
@@ -825,6 +834,14 @@ export function encodeKittyPlacementLine(options: {
|
|
|
825
834
|
export function encodeKittyDeleteImage(imageId: number): string {
|
|
826
835
|
return wrapTmuxPassthroughIfNeeded(`\x1b_Ga=d,d=I,i=${imageId},q=2\x1b\\`);
|
|
827
836
|
}
|
|
837
|
+
/**
|
|
838
|
+
* Delete every Kitty image and placement in the terminal. Used only by an
|
|
839
|
+
* explicit destructive display reset: text erases leave untracked placements
|
|
840
|
+
* painted, so per-image bookkeeping cannot guarantee a clean viewport.
|
|
841
|
+
*/
|
|
842
|
+
export function encodeKittyDeleteAllImages(): string {
|
|
843
|
+
return wrapTmuxPassthroughIfNeeded("\x1b_Ga=d,d=A,q=2\x1b\\");
|
|
844
|
+
}
|
|
828
845
|
|
|
829
846
|
/**
|
|
830
847
|
* Delete a single placement of an image (`d=i`, lowercase): removes its cells
|
package/src/terminal.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { dlopen, FFIType, ptr } from "bun:ffi";
|
|
2
2
|
import * as fs from "node:fs";
|
|
3
|
+
import { TtyWriter } from "@oh-my-pi/pi-natives";
|
|
3
4
|
import {
|
|
4
5
|
$env,
|
|
5
6
|
isBunTestRuntime,
|
|
@@ -230,6 +231,20 @@ function registerPostmortemTerminalRestore(): void {
|
|
|
230
231
|
export function setAltScreenActive(active: boolean): void {
|
|
231
232
|
altScreenActive = active;
|
|
232
233
|
}
|
|
234
|
+
/**
|
|
235
|
+
* Route an out-of-band escape sequence (e.g. an OSC title update) through the
|
|
236
|
+
* active terminal's output path. While a TUI owns stdout, frame paints go
|
|
237
|
+
* through the off-thread write pump and can split across multiple write(2)
|
|
238
|
+
* calls; a direct main-thread `process.stdout.write` can land between two of
|
|
239
|
+
* them — mid escape sequence — and the host terminal then prints the payload
|
|
240
|
+
* as literal text at the cursor position. Returns false when no terminal has
|
|
241
|
+
* started, in which case the caller owns stdout and may write directly.
|
|
242
|
+
*/
|
|
243
|
+
export function writeThroughActiveTerminal(data: string): boolean {
|
|
244
|
+
if (!activeTerminal) return false;
|
|
245
|
+
activeTerminal.write(data);
|
|
246
|
+
return true;
|
|
247
|
+
}
|
|
233
248
|
|
|
234
249
|
const stdoutErrorHandlers = new Set<(err: Error) => void>();
|
|
235
250
|
let stdoutErrorListenerInstalled = false;
|
|
@@ -377,11 +392,37 @@ export function emergencyTerminalRestore(): void {
|
|
|
377
392
|
}
|
|
378
393
|
/** Terminal-reported appearance (dark/light mode). */
|
|
379
394
|
export type TerminalAppearance = "dark" | "light";
|
|
395
|
+
/** Options for {@link Terminal.start}. */
|
|
396
|
+
export interface TerminalStartOptions {
|
|
397
|
+
/**
|
|
398
|
+
* Paint-only start: skip raw mode, stdin ownership, and every probe that
|
|
399
|
+
* elicits a response on stdin. The host tty keeps cooked-mode line editing
|
|
400
|
+
* (kernel echo lands at the hardware cursor), and typed bytes stay queued
|
|
401
|
+
* in the kernel until {@link Terminal.enableInput} takes ownership and
|
|
402
|
+
* replays them through `onInput`. Used for the startup prepaint so typing
|
|
403
|
+
* echoes even while module loading blocks the event loop.
|
|
404
|
+
*/
|
|
405
|
+
deferInput?: boolean;
|
|
406
|
+
}
|
|
380
407
|
/** Identity of an accepted explicit terminal appearance refresh request. */
|
|
381
408
|
export type TerminalAppearanceRequestToken = number;
|
|
382
409
|
export interface Terminal {
|
|
383
410
|
// Start the terminal with input, resize, and host-disconnect handlers.
|
|
384
|
-
start(
|
|
411
|
+
start(
|
|
412
|
+
onInput: (data: string) => void,
|
|
413
|
+
onResize: () => void,
|
|
414
|
+
onDisconnect?: () => void,
|
|
415
|
+
options?: TerminalStartOptions,
|
|
416
|
+
): void;
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* Take ownership of stdin after a `deferInput` start: enable raw mode,
|
|
420
|
+
* attach input handlers, and run the capability probes start() skipped.
|
|
421
|
+
* Bytes the user typed in cooked mode meanwhile are replayed through
|
|
422
|
+
* `onInput`. No-op when input was never deferred. Optional so custom
|
|
423
|
+
* Terminals built against older pi-tui versions keep working.
|
|
424
|
+
*/
|
|
425
|
+
enableInput?(): void;
|
|
385
426
|
|
|
386
427
|
// Stop the terminal and restore state
|
|
387
428
|
stop(): void;
|
|
@@ -400,6 +441,14 @@ export interface Terminal {
|
|
|
400
441
|
// Get terminal dimensions
|
|
401
442
|
get columns(): number;
|
|
402
443
|
get rows(): number;
|
|
444
|
+
/**
|
|
445
|
+
* Output bytes accepted but not yet delivered to the terminal, when the
|
|
446
|
+
* implementation can report it. The renderer skips composing new frames
|
|
447
|
+
* while this backlog is deep, so a slow terminal receives only fresh
|
|
448
|
+
* frames instead of a queue of stale ones. Optional so custom Terminals
|
|
449
|
+
* built against older pi-tui versions keep working.
|
|
450
|
+
*/
|
|
451
|
+
readonly pendingOutputBytes?: number;
|
|
403
452
|
|
|
404
453
|
// Whether Kitty keyboard protocol is active
|
|
405
454
|
get kittyProtocolActive(): boolean;
|
|
@@ -539,6 +588,8 @@ export class ProcessTerminal implements Terminal {
|
|
|
539
588
|
#wasRaw = false;
|
|
540
589
|
#inputHandler?: (data: string) => void;
|
|
541
590
|
#resizeHandler?: () => void;
|
|
591
|
+
/** True between a `deferInput` start() and enableInput(). */
|
|
592
|
+
#inputDeferred = false;
|
|
542
593
|
#stdoutResizeListener?: () => void;
|
|
543
594
|
#kittyProtocolActive = false;
|
|
544
595
|
#kittyEnableSeq: string | null = null;
|
|
@@ -578,6 +629,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
578
629
|
// and the writable buffer grows without bound as cosmetic frames pile up.
|
|
579
630
|
// See OutputBacklogGuard and #6854.
|
|
580
631
|
#stdoutBacklog = new OutputBacklogGuard();
|
|
632
|
+
// Off-thread output pump (unix TTYs): Bun's `process.stdout.write` blocks
|
|
633
|
+
// the event loop until the terminal drains, so a slow/occluded emulator
|
|
634
|
+
// froze the whole TUI for the duration of a multi-MB repaint. The pump
|
|
635
|
+
// enqueues frames and performs the blocking write(2) on its own thread;
|
|
636
|
+
// `pendingOutputBytes` exposes the backlog for render-side frame skipping.
|
|
637
|
+
#outputPump?: TtyWriter;
|
|
581
638
|
#stdoutDrainArmed = false;
|
|
582
639
|
#stdoutDrainHandler = () => {
|
|
583
640
|
this.#stdoutDrainArmed = false;
|
|
@@ -697,7 +754,12 @@ export class ProcessTerminal implements Terminal {
|
|
|
697
754
|
this.#privateModeCallbacks.push(callback);
|
|
698
755
|
}
|
|
699
756
|
|
|
700
|
-
start(
|
|
757
|
+
start(
|
|
758
|
+
onInput: (data: string) => void,
|
|
759
|
+
onResize: () => void,
|
|
760
|
+
onDisconnect?: () => void,
|
|
761
|
+
options?: TerminalStartOptions,
|
|
762
|
+
): void {
|
|
701
763
|
this.#inputHandler = onInput;
|
|
702
764
|
this.#resizeHandler = onResize;
|
|
703
765
|
this.#disconnectHandler = onDisconnect;
|
|
@@ -715,12 +777,65 @@ export class ProcessTerminal implements Terminal {
|
|
|
715
777
|
// Register for emergency cleanup
|
|
716
778
|
activeTerminal = this;
|
|
717
779
|
terminalEverStarted = true;
|
|
780
|
+
// Own the blocking write(2) on a pump thread (unix TTYs only). A stale
|
|
781
|
+
// prebuilt natives module without the export falls back to direct writes.
|
|
782
|
+
// Test suites spy on `process.stdout.write` with a faked isTTY, so the
|
|
783
|
+
// pump stays off under `bun test` — same philosophy as isTerminalHeadless.
|
|
784
|
+
if (process.platform !== "win32" && process.stdout.isTTY && !isBunTestRuntime() && !this.#outputPump) {
|
|
785
|
+
try {
|
|
786
|
+
this.#outputPump = new TtyWriter(1);
|
|
787
|
+
} catch (err) {
|
|
788
|
+
logger.debug("tty output pump unavailable; using direct stdout writes", { err: String(err) });
|
|
789
|
+
}
|
|
790
|
+
}
|
|
718
791
|
|
|
719
792
|
// Keep unmanaged fd-2 writes (macOS libmalloc/framework diagnostics) off
|
|
720
793
|
// the viewport while we own the terminal; released in stop(). See
|
|
721
794
|
// stderr-guard in pi-utils (mirrors openai/codex#24459).
|
|
722
795
|
suppressTerminalStderr();
|
|
723
796
|
|
|
797
|
+
// Set up resize handler immediately. The OS refreshes process.stdout
|
|
798
|
+
// dimensions before firing `resize`, so it is authoritative for geometry:
|
|
799
|
+
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
|
800
|
+
this.#stdoutResizeListener = () => {
|
|
801
|
+
// Conservative: some hosts reset modes across a resize/reattach, so
|
|
802
|
+
// re-establish cursor visibility on the next explicit call.
|
|
803
|
+
this.#cursorVisible = undefined;
|
|
804
|
+
this.#reconcileInBandGeometryOnResize();
|
|
805
|
+
this.#resizeHandler?.();
|
|
806
|
+
};
|
|
807
|
+
process.stdout.on("resize", this.#stdoutResizeListener);
|
|
808
|
+
|
|
809
|
+
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
810
|
+
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
811
|
+
if (process.platform !== "win32") {
|
|
812
|
+
process.kill(process.pid, "SIGWINCH");
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
|
|
816
|
+
|
|
817
|
+
if (options?.deferInput) {
|
|
818
|
+
this.#inputDeferred = true;
|
|
819
|
+
return;
|
|
820
|
+
}
|
|
821
|
+
this.#attachInput();
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
enableInput(): void {
|
|
825
|
+
if (!this.#inputDeferred) return;
|
|
826
|
+
this.#inputDeferred = false;
|
|
827
|
+
if (this.#headless || this.#dead) return;
|
|
828
|
+
this.#attachInput();
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/**
|
|
832
|
+
* Own stdin: raw mode, input listeners, and the capability probes that
|
|
833
|
+
* elicit stdin responses. Split from start() so a `deferInput` prepaint can
|
|
834
|
+
* leave the tty in cooked mode (kernel echo + line editing) while startup
|
|
835
|
+
* module loading blocks the event loop, then adopt the kernel-buffered
|
|
836
|
+
* keystrokes here once the app can process them.
|
|
837
|
+
*/
|
|
838
|
+
#attachInput(): void {
|
|
724
839
|
// A multiplexer or SSH disconnect can leave isTTY true after its pty has
|
|
725
840
|
// been revoked. Raw mode is then impossible, so take the normal terminal
|
|
726
841
|
// disconnect path rather than letting Bun abort startup with EIO.
|
|
@@ -751,24 +866,6 @@ export class ProcessTerminal implements Terminal {
|
|
|
751
866
|
// See #6374.
|
|
752
867
|
this.#safeWrite("\x1b[?1l\x1b>");
|
|
753
868
|
|
|
754
|
-
// Set up resize handler immediately. The OS refreshes process.stdout
|
|
755
|
-
// dimensions before firing `resize`, so it is authoritative for geometry:
|
|
756
|
-
// reconcile any stale cached DEC 2048 report before notifying the renderer.
|
|
757
|
-
this.#stdoutResizeListener = () => {
|
|
758
|
-
// Conservative: some hosts reset modes across a resize/reattach, so
|
|
759
|
-
// re-establish cursor visibility on the next explicit call.
|
|
760
|
-
this.#cursorVisible = undefined;
|
|
761
|
-
this.#reconcileInBandGeometryOnResize();
|
|
762
|
-
this.#resizeHandler?.();
|
|
763
|
-
};
|
|
764
|
-
process.stdout.on("resize", this.#stdoutResizeListener);
|
|
765
|
-
|
|
766
|
-
// Refresh terminal dimensions - they may be stale after suspend/resume
|
|
767
|
-
// (SIGWINCH is lost while process is stopped). Unix only.
|
|
768
|
-
if (process.platform !== "win32") {
|
|
769
|
-
process.kill(process.pid, "SIGWINCH");
|
|
770
|
-
}
|
|
771
|
-
|
|
772
869
|
// On Windows, enable ENABLE_VIRTUAL_TERMINAL_INPUT so the console sends
|
|
773
870
|
// VT escape sequences (e.g. \x1b[Z for Shift+Tab) instead of raw console
|
|
774
871
|
// events that lose modifier information. Must run after setRawMode(true)
|
|
@@ -781,8 +878,6 @@ export class ProcessTerminal implements Terminal {
|
|
|
781
878
|
// Explicit probes are safe only after their response parser and stdin
|
|
782
879
|
// data handler are installed. Keep this false throughout temporary stops.
|
|
783
880
|
this.#active = true;
|
|
784
|
-
setHangulCompatibilityJamoWidth(TERMINAL.hangulJamoWidth);
|
|
785
|
-
|
|
786
881
|
// Query terminal background color via OSC 11 for dark/light detection.
|
|
787
882
|
// Uses DA1 (Primary Device Attributes) as a sentinel: terminals process
|
|
788
883
|
// sequences in order, so if DA1 arrives before OSC 11 response,
|
|
@@ -1530,6 +1625,7 @@ export class ProcessTerminal implements Terminal {
|
|
|
1530
1625
|
stop(): void {
|
|
1531
1626
|
// Suppress observer/timer callbacks before any teardown can yield or throw.
|
|
1532
1627
|
this.#active = false;
|
|
1628
|
+
this.#inputDeferred = false;
|
|
1533
1629
|
if (this.#headless) return;
|
|
1534
1630
|
// Unregister from emergency cleanup
|
|
1535
1631
|
if (activeTerminal === this) {
|
|
@@ -1649,6 +1745,13 @@ export class ProcessTerminal implements Terminal {
|
|
|
1649
1745
|
}
|
|
1650
1746
|
this.#stdoutBacklog.reset();
|
|
1651
1747
|
this.#resizeHandler = undefined;
|
|
1748
|
+
// Flush the restore sequences enqueued above (bounded — a stalled PTY
|
|
1749
|
+
// must not wedge exit), then retire the pump. Later writes (emergency
|
|
1750
|
+
// restore's showCursor) fall back to direct stdout writes.
|
|
1751
|
+
if (this.#outputPump) {
|
|
1752
|
+
this.#outputPump.stop(1000);
|
|
1753
|
+
this.#outputPump = undefined;
|
|
1754
|
+
}
|
|
1652
1755
|
|
|
1653
1756
|
// Pause stdin to prevent any buffered input (e.g., Ctrl+D) from being
|
|
1654
1757
|
// re-interpreted after raw mode is disabled. This fixes a race condition
|
|
@@ -1724,6 +1827,23 @@ export class ProcessTerminal implements Terminal {
|
|
|
1724
1827
|
if (!process.stdout.isTTY) return;
|
|
1725
1828
|
this.#ensureStdoutErrorHandler();
|
|
1726
1829
|
this.#trackCursorVisibility(data);
|
|
1830
|
+
const pump = this.#outputPump;
|
|
1831
|
+
if (pump) {
|
|
1832
|
+
if (pump.dead) {
|
|
1833
|
+
this.#markTerminalDisconnected("stdout failed; output pump died");
|
|
1834
|
+
return;
|
|
1835
|
+
}
|
|
1836
|
+
try {
|
|
1837
|
+
// Same stalled-consumer bound as the stream path (#6854): a PTY reader
|
|
1838
|
+
// that never drains must tear the terminal down, not grow the queue.
|
|
1839
|
+
if (pump.write(data) > MAX_STDOUT_BACKLOG_BYTES) {
|
|
1840
|
+
this.#markTerminalDisconnected("stdout backlog exceeded cap; PTY consumer stalled");
|
|
1841
|
+
}
|
|
1842
|
+
} catch (err) {
|
|
1843
|
+
this.#markTerminalDisconnected("stdout failed", err);
|
|
1844
|
+
}
|
|
1845
|
+
return;
|
|
1846
|
+
}
|
|
1727
1847
|
// A console-sharing child process may have flipped the console codepage
|
|
1728
1848
|
// away from UTF-8; repair it before any bytes hit WriteFile so no frame
|
|
1729
1849
|
// is ever translated through an OEM codepage. See ensureWindowsConsoleUtf8.
|
|
@@ -1771,6 +1891,11 @@ export class ProcessTerminal implements Terminal {
|
|
|
1771
1891
|
if (this.#inBandResizeActive && this.#reportedColumns) return this.#reportedColumns;
|
|
1772
1892
|
return process.stdout.columns || Number(Bun.env.COLUMNS) || 80;
|
|
1773
1893
|
}
|
|
1894
|
+
get pendingOutputBytes(): number {
|
|
1895
|
+
if (this.#outputPump) return this.#outputPump.pending();
|
|
1896
|
+
// Stream fallback: bytes queued past the high-water mark by refused writes.
|
|
1897
|
+
return process.stdout.writableLength ?? 0;
|
|
1898
|
+
}
|
|
1774
1899
|
|
|
1775
1900
|
get rows(): number {
|
|
1776
1901
|
if (this.#inBandResizeActive && this.#reportedRows) return this.#reportedRows;
|