@oh-my-pi/pi-tui 18.5.0 → 18.6.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 CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.6.0] - 2026-10-03
6
+
7
+ ### Fixed
8
+
9
+ - Fixed Alt+Up (restore queued steering), arrows and other escape-sequence keys on Windows acting as Esc (interrupting the turn) and typing `[1;3A` into the editor when the console host relays them one byte at a time ([#14216](https://github.com/can1357/oh-my-pi/pull/14216) by [@H4vC](https://github.com/H4vC)).
10
+
11
+ ## [18.5.1] - 2026-10-03
12
+
13
+ ### Added
14
+
15
+ - Added native terminal support for submitting explicit composer prompts atomically, preserving displaced drafts and attachments for local recall.
16
+ - Added progress percentages to subagent entries in the agent tree, task, and wait views.
17
+ - Added a `Rebuilding…` indicator for lengthy tmux resize redraws without flashing it for quick updates.
18
+ - Added `writeTerminalSequence()` for sending supported terminal escape sequences through the active terminal output stream, with stdout fallback when no terminal is active.
19
+
20
+ ### Changed
21
+
22
+ - Clipboard payloads are now represented by their length in `PI_TUI_WRITE_LOG` rather than by their contents.
23
+
24
+ ### Fixed
25
+
26
+ - Fixed pending clipboard-image attachments being bypassed when subsequent input or native prompts were submitted before loading completed.
27
+ - Improved narrow `/models` layouts by retaining the scope sidebar while simplifying and truncating model rows as needed.
28
+ - Fixed Markdown rendering with themes that do not define their own symbol set; the active theme's symbols are now used as a fallback.
29
+ - Fixed model role assignment showing models that are unavailable for the selected provider.
30
+ - Improved tmux resize, zoom, and rebuild behavior, including smoother coalesced redraws, reduced visual artifacts and extra output, correct transcript restoration after rapid size changes, and preservation of history when panes are resized.
31
+ - Fixed multiline paste on Windows inserting escape-code fragments instead of line breaks, and prevented bracketed-paste markers from appearing in the composer.
32
+ - Fixed terminal notifications occasionally corrupting the screen with stray escape-code fragments during streaming output.
33
+ - Added rendering and notification compatibility for Monstar terminals, including Kitty graphics, hyperlinks, synchronized output, styled underlines, progress keepalives, and Monstar-focused desktop notifications.
34
+ - Fixed Agent Hub transcripts crashing when assistant messages do not include usage or cost data.
35
+ - Fixed idle terminal activity notifications while preserving bracketed-paste recovery during input and rendering.
36
+ - Fixed diff blocks in ask questions losing their layout; additions and removals now retain separate diff highlighting.
37
+ - Fixed duplicate ASCII Plan, Prewalk, and Goal labels in the status line.
38
+ - Improved Markdown rendering for long and streamed messages, including display math, fenced code, reference links, lists, whitespace, emphasis, and line breaks, so completed content no longer shows raw markup, misplaced blank lines, or broken block layout.
39
+
5
40
  ## [18.5.0] - 2026-10-03
6
41
 
7
42
  ### Breaking Changes
@@ -2988,14 +3023,4 @@ Initial release under @oh-my-pi scope. See previous releases at [badlogic/pi-mon
2988
3023
  - Crash in `visibleWidth()` and grapheme iteration when encountering undefined code points ([#372](https://github.com/badlogic/pi-mono/pull/372) by [@HACKE-RC](https://github.com/HACKE-RC))
2989
3024
  - ZWJ emoji sequences (rainbow flag, family, etc.) now render with correct width instead of being split into multiple characters ([#369](https://github.com/badlogic/pi-mono/pull/369) by [@nathyong](https://github.com/nathyong))
2990
3025
 
2991
- ## [0.29.0] - 2025-12-25
2992
-
2993
- ### Added
2994
-
2995
- - **Auto-space before pasted file paths**: When pasting a file path (starting with `/`, `~`, or `.`) and the cursor is after a word character, a space is automatically prepended for better readability. Useful when dragging screenshots from macOS. ([#307](https://github.com/badlogic/pi-mono/pull/307) by [@mitsuhiko](https://github.com/mitsuhiko))
2996
- - **Word navigation for Input component**: Added Ctrl+Left/Right and Alt+Left/Right support for word-by-word cursor movement. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
2997
- - **Full Unicode input**: Input component now accepts Unicode characters beyond ASCII. ([#306](https://github.com/badlogic/pi-mono/pull/306) by [@kim0](https://github.com/kim0))
2998
-
2999
- ### Fixed
3000
-
3001
- - **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))
3026
+ Older entries are archived in [packages/tui/CHANGELOG.md@bac7e83b5b0e](https://github.com/can1357/oh-my-pi/blob/bac7e83b5b0eb86c909c17830a6666efc359578b/packages/tui/CHANGELOG.md).
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Ownership of stdout by a started {@link ProcessTerminal}, kept in a leaf
3
+ * module so writers that `terminal.ts` itself imports (terminal capabilities,
4
+ * notifications) can route through the active terminal without an import cycle.
5
+ */
6
+ import type { ProcessTerminal } from "./terminal.js";
7
+ /** The started terminal that owns stdout in this thread, if any. */
8
+ export declare function getActiveTerminal(): ProcessTerminal | null;
9
+ /** Record the terminal that owns stdout (`ProcessTerminal.start`), or release it with `null` (`stop`). */
10
+ export declare function setActiveTerminal(terminal: ProcessTerminal | null): void;
11
+ /** Observe stdout errors through the shared listener. Returns the unregister callback. */
12
+ export declare function registerStdoutErrorHandler(handler: (err: Error) => void): () => void;
13
+ /**
14
+ * Hand `data` to the active terminal's ordered output path. Returns false when
15
+ * no terminal in this thread has started. On the main thread that means the
16
+ * caller owns stdout; a worker thread never sees the main thread's terminal, so
17
+ * there `false` is not permission to write. Prefer {@link writeTerminalSequence}.
18
+ */
19
+ export declare function writeThroughActiveTerminal(data: string): boolean;
20
+ /**
21
+ * Write an out-of-band escape sequence (window title, OSC 52 clipboard,
22
+ * notification). While a TUI owns stdout, its frames go through an off-thread
23
+ * pump in chunks that ignore escape boundaries; a direct `process.stdout.write`
24
+ * can land between two chunks and split a frame's escape sequence, and the host
25
+ * terminal then prints its tail as text. So the sequence takes the active
26
+ * terminal's ordered output path, and goes straight to stdout, best-effort, only
27
+ * when no terminal owns it. A worker thread cannot reach the main thread's
28
+ * terminal and would write around its pump, so it writes nothing.
29
+ */
30
+ export declare function writeTerminalSequence(data: string): void;
@@ -138,6 +138,9 @@ export declare class Editor implements Component, Focusable {
138
138
  onLargePaste?: (text: string, lineCount: number, options: PasteOptions) => boolean;
139
139
  onAutocompleteCancel?: () => void;
140
140
  disableSubmit: boolean;
141
+ /** Base editors accept native edits, not atomic sends. Implementations that
142
+ * handle `send` override this readiness check for their submission path. */
143
+ protected get nativeSendable(): boolean;
141
144
  /** Placeholder painted right-aligned on the cursor row while the editor is empty and no
142
145
  * autocomplete is open; hidden when it can't keep {@link PLACEHOLDER_MIN_GAP} cells from the
143
146
  * cursor. The host styles it (ANSI allowed). Re-evaluated on every render, so hosts can derive
@@ -1,8 +1,10 @@
1
1
  import type { DescribeContext, NativeNode } from "../native/node.js";
2
2
  import type { SymbolTheme } from "../symbols.js";
3
3
  import type { Component } from "../tui.js";
4
- /** @internal exported for tests — the grown-line-start block-kind gate. */
4
+ /** @internal exported for tests — the grown-line block-kind gate. */
5
5
  export declare function fastLineStartHazard(grownLine: string): boolean;
6
+ /** @internal exported for tests — the windowed lexer's first-probe window size. */
7
+ export declare const LEX_WINDOW_BYTES: number;
6
8
  /** A hyperlink as the renderer sees it: inline `[text](href)`, `<autolink>`, bare GFM URL, or reference link. */
7
9
  export interface MarkdownLink {
8
10
  /** Flattened visible label with whitespace collapsed to one row; falls back to `href` when empty. */
@@ -82,7 +84,12 @@ export interface MarkdownTheme {
82
84
  * Return null to fall back to fenced code rendering.
83
85
  */
84
86
  resolveMermaidAscii?: (source: string, maxWidth?: number) => string | null;
85
- symbols: SymbolTheme;
87
+ /**
88
+ * Glyphs for quote borders, rules, tables and color swatches. Optional so themes
89
+ * built to the upstream pi-tui `MarkdownTheme` shape (which has no `symbols`)
90
+ * still render; omitted symbols fall back to the active theme's set.
91
+ */
92
+ symbols?: SymbolTheme;
86
93
  }
87
94
  export declare class Markdown implements Component {
88
95
  #private;
@@ -101,8 +108,10 @@ export declare class Markdown implements Component {
101
108
  describe(_cx: DescribeContext): NativeNode;
102
109
  /**
103
110
  * Width-independent source prefix of the last render ending at a frozen
104
- * Markdown block boundary. Only meaningful while streaming (transient
105
- * render cache on); grows monotonically under append-only `setText`.
111
+ * Markdown block boundary that no append can move: it stops in front of a
112
+ * display-math opener whose block an append could still close. Only
113
+ * meaningful while streaming (transient render cache on); grows
114
+ * monotonically under append-only `setText`.
106
115
  */
107
116
  getLastRenderStableText(): string;
108
117
  get transientRenderCache(): boolean;
@@ -29,6 +29,7 @@ export declare function encodeTspJson(verb: TspVerb, value: unknown, params?: Ts
29
29
  * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
30
30
  * native selection in omp's editors; without it, every key stays omp's. `"undo"`
31
31
  * says omp applies `undo` events, so the terminal may turn ⌃Z in a field into one.
32
+ * `"send"` accepts an explicit prompt for a live composer without simulating keys.
32
33
  */
33
34
  export declare function encodeTspHelloQuery(version?: string): string;
34
35
  /** One decoded APC message: verb, parameters and raw body. */
@@ -135,6 +135,12 @@ export type NativeUiEvent = {
135
135
  | {
136
136
  readonly type: "undo";
137
137
  readonly key: string;
138
+ }
139
+ /** Submit an explicit prompt through the composer's normal path, preserving the previous draft for recall. */
140
+ | {
141
+ readonly type: "send";
142
+ readonly key: string;
143
+ readonly text: string;
138
144
  };
139
145
  /**
140
146
  * A primitive edit the terminal made over its own text selection (cut,
@@ -61,7 +61,10 @@ export declare class HubFrame {
61
61
  constructor(title: string, sidebarBounds: {
62
62
  min: number;
63
63
  max: number;
64
- }, renderSidebar: (width: number, rows: number) => string[], renderBody: (width: number, height: number | undefined) => readonly string[]);
64
+ }, renderSidebar: (width: number, rows: number) => string[], renderBody: (width: number, height: number | undefined) => readonly string[], options?: {
65
+ bodyMinWidth?: number;
66
+ preserveSidebar?: boolean;
67
+ });
65
68
  /** Invalidate the composed layout. */
66
69
  invalidate(): void;
67
70
  /** Measure labels and annotations within this hub's sidebar width bounds. */
@@ -12,6 +12,14 @@ export interface ModelHubSource extends ModelBrowserSource {
12
12
  getProjectModelRole(role: string): string | undefined;
13
13
  getGlobalModelRole(role: string): string | undefined;
14
14
  getModelRoleSource(role: string): "global" | "project" | "default";
15
+ /**
16
+ * Saved model presets (in switch order) and the one the current role setup
17
+ * matches, if any. Absent hosts have no presets to switch between.
18
+ */
19
+ getModelPresets?(): {
20
+ names: readonly string[];
21
+ active: string | undefined;
22
+ };
15
23
  }
16
24
  /** Catalog capabilities required by the model hub. */
17
25
  export interface ModelHubRegistry extends ModelBrowserRegistry {
@@ -48,6 +56,12 @@ export interface ModelHubCallbacks {
48
56
  onLoginRequest?: (providerId: string) => void;
49
57
  /** Save the current role assignments and default thinking level as a named model preset. */
50
58
  onSavePreset?: (name: string) => void;
59
+ /**
60
+ * Apply a saved model preset (ctrl+←/→ or p/⇧P in the Roles view) and report
61
+ * the outcome itself. A switch that writes nothing (a refused preset) still
62
+ * advances the hub's preset cursor, so the next press moves past it.
63
+ */
64
+ onSwitchPreset?: (name: string) => void | Promise<void>;
51
65
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
52
66
  onCycleOrderChange?: (order: string[]) => void;
53
67
  onCancel: () => void;
@@ -213,6 +213,8 @@ export declare class CustomEditor extends Editor {
213
213
  * once after construction (and again after `setEditorComponent` swaps the
214
214
  * editor). Passing `undefined` clears any pending frame. */
215
215
  setShimmerRepaintHandler(handler: (() => void) | undefined): void;
216
+ /** Editing is available during bootstrap; atomic sends wait until submission is wired and enabled. */
217
+ protected get nativeSendable(): boolean;
216
218
  /** Viewing a subagent, the draft goes to it: the placeholder names it. */
217
219
  describePlaceholder: () => string;
218
220
  /** A shell-mode draft highlights as its language. */
@@ -237,7 +239,8 @@ export declare class CustomEditor extends Editor {
237
239
  * Clicks on the composer's controls take the same paths as their keys: ⇧⇥,
238
240
  * ⏎ and Esc; the viewing header's links (`focus:<id>`) go to the host,
239
241
  * the status facts' clicks (`status.*`) to their source. Selection edits
240
- * go to the buffer.
242
+ * go to the buffer; `send` submits its own prompt after saving the old draft
243
+ * for recall and waiting for in-flight clipboard work.
241
244
  */
242
245
  handleNativeEvent(event: NativeUiEvent): void;
243
246
  onEscape?: () => void;
@@ -11,7 +11,7 @@ export declare enum NotifyProtocol {
11
11
  Osc99 = "\u001B]99;;",
12
12
  Osc9 = "\u001B]9;"
13
13
  }
14
- export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "orca" | "otty" | "rio" | "tern" | "base" | "trueColor";
14
+ export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "orca" | "otty" | "rio" | "tern" | "monstar" | "base" | "trueColor";
15
15
  /** Terminal capability details used for rendering and protocol selection. */
16
16
  export declare class TerminalInfo {
17
17
  readonly id: TerminalId;
@@ -1,4 +1,5 @@
1
1
  import { type TspHello } from "./native/encode.js";
2
+ export { writeTerminalSequence, writeThroughActiveTerminal } from "./active-terminal.js";
2
3
  /**
3
4
  * Split `data` into chunks whose encoded UTF-8 byte length is no greater than
4
5
  * `maxChunkBytes`, preferring a line boundary (`\n`) as the cut point so
@@ -69,16 +70,6 @@ export declare class StdoutStallWatchdog {
69
70
  }
70
71
  /** Record alternate-screen state (called by the TUI on `?1049h`/`?1049l` writes). */
71
72
  export declare function setAltScreenActive(active: boolean): void;
72
- /**
73
- * Route an out-of-band escape sequence (e.g. an OSC title update) through the
74
- * active terminal's output path. While a TUI owns stdout, frame paints go
75
- * through the off-thread write pump and can split across multiple write(2)
76
- * calls; a direct main-thread `process.stdout.write` can land between two of
77
- * them — mid escape sequence — and the host terminal then prints the payload
78
- * as literal text at the cursor position. Returns false when no terminal has
79
- * started, in which case the caller owns stdout and may write directly.
80
- */
81
- export declare function writeThroughActiveTerminal(data: string): boolean;
82
73
  /**
83
74
  * Emergency terminal restore - call this from signal/crash handlers
84
75
  * Resets terminal state without requiring access to the ProcessTerminal instance
@@ -4,8 +4,13 @@ import { type OutputMeta } from "./output-meta.js";
4
4
  import type { NativeToolView, RenderResultContextOptions, RenderResultOptions } from "./renderer.js";
5
5
  /** Default collapsed shell output preview height. */
6
6
  export declare const BASH_DEFAULT_PREVIEW_LINES = 10;
7
- /** LLM-facing footer appended when a tool call becomes a background job. */
8
- export declare function formatBackgroundNotice(jobId: string): string;
7
+ /**
8
+ * LLM-facing footer appended when a tool call becomes a background job. It states the job's kill
9
+ * deadline (`timeoutSec`, `undefined` when disabled) so the model knows the job will die at it before
10
+ * it waits on the result. The deadline counts the job's whole run time, not time left from now: an
11
+ * auto-backgrounded call has already spent its foreground wait.
12
+ */
13
+ export declare function formatBackgroundNotice(jobId: string, timeoutSec: number | undefined): string;
9
14
  /** Shell execution metadata used by transcript rendering. */
10
15
  export interface BashToolDetails {
11
16
  meta?: OutputMeta;
@@ -16,10 +16,26 @@
16
16
  * terminal sends; chords legacy encoding loses (modified Enter/Tab/Escape,
17
17
  * Ctrl+Shift+letter, …) translate to kitty CSI-u, which key matching already
18
18
  * parses.
19
+ *
20
+ * A host terminal that writes VT instead of key records (any ConPTY host
21
+ * besides Windows Terminal's native path) reaches us as one `Vk = 0` text
22
+ * record per byte: Alt+Up arrives as `ESC`, `[`, `1`, `;`, `3`, `A`. Those
23
+ * bytes are reassembled into the escape sequence they spell; delivering them
24
+ * one at a time turns every such key into a bare Escape plus typed garbage.
25
+ */
26
+ /**
27
+ * Stateful decoder: joins UTF-16 surrogate halves that arrive as separate
28
+ * records, and reassembles VT escape sequences relayed as text records.
19
29
  */
20
- /** Stateful decoder: joins UTF-16 surrogate halves that arrive as separate records. */
21
30
  export declare class Win32InputModeDecoder {
22
31
  #private;
32
+ /** Whether relayed VT text is held waiting for the rest of its sequence. */
33
+ get hasPendingSequence(): boolean;
34
+ /**
35
+ * Release held VT text as-is. The caller invokes this once no further bytes
36
+ * arrived in time, so a lone relayed ESC still acts as the Escape key.
37
+ */
38
+ flushPendingSequence(): string[];
23
39
  /**
24
40
  * Translate one stdin sequence. Returns `undefined` when `data` is not a
25
41
  * win32-input-mode record (pass it through unchanged), otherwise the key
@@ -35,3 +51,19 @@ export declare class Win32InputModeDecoder {
35
51
  */
36
52
  decodePaste(content: string): string;
37
53
  }
54
+ /**
55
+ * Restores paste delimiters that a Windows console host emitted as individual
56
+ * win32-input-mode key records before StdinBuffer searches for the delimiters.
57
+ * Non-matching records remain untouched for normal key and paste decoding.
58
+ */
59
+ export declare class Win32PasteMarkerNormalizer {
60
+ #private;
61
+ /**
62
+ * `onExpired` runs after held records are released because their wait timed
63
+ * out, so a downstream holder can treat the same wait as already elapsed.
64
+ */
65
+ constructor(onInput: (data: string) => void, onExpired?: () => void);
66
+ process(data: string): void;
67
+ /** Release an incomplete marker as its original key records. */
68
+ flush(): void;
69
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-tui",
4
- "version": "18.5.0",
4
+ "version": "18.6.0",
5
5
  "description": "Terminal User Interface library with differential rendering for efficient text-based applications",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": {
@@ -41,14 +41,14 @@
41
41
  "gen:glyphs": "bun scripts/gen-glyphs.ts"
42
42
  },
43
43
  "dependencies": {
44
- "@oh-my-pi/omptype": "18.5.0",
45
- "@oh-my-pi/pi-agent-core": "18.5.0",
46
- "@oh-my-pi/pi-ai": "18.5.0",
47
- "@oh-my-pi/pi-catalog": "18.5.0",
48
- "@oh-my-pi/pi-natives": "18.5.0",
49
- "@oh-my-pi/pi-utils": "18.5.0",
50
- "@oh-my-pi/pi-wire": "18.5.0",
51
- "@oh-my-pi/snapcompact": "18.5.0"
44
+ "@oh-my-pi/omptype": "18.6.0",
45
+ "@oh-my-pi/pi-agent-core": "18.6.0",
46
+ "@oh-my-pi/pi-ai": "18.6.0",
47
+ "@oh-my-pi/pi-catalog": "18.6.0",
48
+ "@oh-my-pi/pi-natives": "18.6.0",
49
+ "@oh-my-pi/pi-utils": "18.6.0",
50
+ "@oh-my-pi/pi-wire": "18.6.0",
51
+ "@oh-my-pi/snapcompact": "18.6.0"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Ownership of stdout by a started {@link ProcessTerminal}, kept in a leaf
3
+ * module so writers that `terminal.ts` itself imports (terminal capabilities,
4
+ * notifications) can route through the active terminal without an import cycle.
5
+ */
6
+ import type { ProcessTerminal } from "./terminal";
7
+
8
+ // The started terminal that owns stdout in this thread; also consulted by the
9
+ // emergency restore on crash.
10
+ let activeTerminal: ProcessTerminal | null = null;
11
+
12
+ /** The started terminal that owns stdout in this thread, if any. */
13
+ export function getActiveTerminal(): ProcessTerminal | null {
14
+ return activeTerminal;
15
+ }
16
+
17
+ /** Record the terminal that owns stdout (`ProcessTerminal.start`), or release it with `null` (`stop`). */
18
+ export function setActiveTerminal(terminal: ProcessTerminal | null): void {
19
+ activeTerminal = terminal;
20
+ }
21
+
22
+ const stdoutErrorHandlers = new Set<(err: Error) => void>();
23
+ let stdoutErrorListenerInstalled = false;
24
+
25
+ function onStdoutError(err: Error): void {
26
+ for (const handler of stdoutErrorHandlers) handler(err);
27
+ }
28
+
29
+ /**
30
+ * Install the one shared `process.stdout` `error` listener. Its presence alone
31
+ * keeps a stdout error (EPIPE on a closed pipe, EIO on a revoked PTY) from
32
+ * crashing the process as an unhandled `error` event.
33
+ */
34
+ function installStdoutErrorListener(): void {
35
+ if (stdoutErrorListenerInstalled) return;
36
+ process.stdout.on("error", onStdoutError);
37
+ stdoutErrorListenerInstalled = true;
38
+ }
39
+
40
+ /** Observe stdout errors through the shared listener. Returns the unregister callback. */
41
+ export function registerStdoutErrorHandler(handler: (err: Error) => void): () => void {
42
+ stdoutErrorHandlers.add(handler);
43
+ installStdoutErrorListener();
44
+ return () => {
45
+ stdoutErrorHandlers.delete(handler);
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Hand `data` to the active terminal's ordered output path. Returns false when
51
+ * no terminal in this thread has started. On the main thread that means the
52
+ * caller owns stdout; a worker thread never sees the main thread's terminal, so
53
+ * there `false` is not permission to write. Prefer {@link writeTerminalSequence}.
54
+ */
55
+ export function writeThroughActiveTerminal(data: string): boolean {
56
+ if (!activeTerminal) return false;
57
+ activeTerminal.write(data);
58
+ return true;
59
+ }
60
+
61
+ /**
62
+ * Write an out-of-band escape sequence (window title, OSC 52 clipboard,
63
+ * notification). While a TUI owns stdout, its frames go through an off-thread
64
+ * pump in chunks that ignore escape boundaries; a direct `process.stdout.write`
65
+ * can land between two chunks and split a frame's escape sequence, and the host
66
+ * terminal then prints its tail as text. So the sequence takes the active
67
+ * terminal's ordered output path, and goes straight to stdout, best-effort, only
68
+ * when no terminal owns it. A worker thread cannot reach the main thread's
69
+ * terminal and would write around its pump, so it writes nothing.
70
+ */
71
+ export function writeTerminalSequence(data: string): void {
72
+ if (!Bun.isMainThread) return;
73
+ if (writeThroughActiveTerminal(data)) return;
74
+ installStdoutErrorListener();
75
+ try {
76
+ process.stdout.write(data);
77
+ } catch {
78
+ // Best-effort: a closed stdout must not fail the caller.
79
+ }
80
+ }
@@ -85,10 +85,17 @@ export class BracketedPasteHandler {
85
85
  * buffer); omitted when still buffering.
86
86
  */
87
87
  process(data: string): PasteResult {
88
- if (data.includes(PASTE_START)) {
88
+ const start = data.indexOf(PASTE_START);
89
+ const end = data.indexOf(PASTE_END);
90
+ if (start !== -1 && (end === -1 || start < end)) {
91
+ if (!this.#active) this.#buffer = "";
89
92
  this.#active = true;
90
- this.#buffer = "";
91
- data = data.replace(PASTE_START, "");
93
+ // Only strip nested starts before this paste's first end. Later starts
94
+ // belong to the next paste in `remaining`.
95
+ data =
96
+ end === -1
97
+ ? data.replaceAll(PASTE_START, "")
98
+ : data.slice(0, end).replaceAll(PASTE_START, "") + data.slice(end);
92
99
  }
93
100
 
94
101
  if (!this.#active) return { handled: false };
@@ -754,6 +754,11 @@ export class Editor implements Component, Focusable {
754
754
  onLargePaste?: (text: string, lineCount: number, options: PasteOptions) => boolean;
755
755
  onAutocompleteCancel?: () => void;
756
756
  disableSubmit: boolean = false;
757
+ /** Base editors accept native edits, not atomic sends. Implementations that
758
+ * handle `send` override this readiness check for their submission path. */
759
+ protected get nativeSendable(): boolean {
760
+ return false;
761
+ }
757
762
  /** Placeholder painted right-aligned on the cursor row while the editor is empty and no
758
763
  * autocomplete is open; hidden when it can't keep {@link PLACEHOLDER_MIN_GAP} cells from the
759
764
  * cursor. The host styles it (ANSI allowed). Re-evaluated on every render, so hosts can derive
@@ -1687,6 +1692,7 @@ export class Editor implements Component, Focusable {
1687
1692
  const props: TspEditorProps = {
1688
1693
  text,
1689
1694
  cursor,
1695
+ sendable: this.nativeSendable,
1690
1696
  anchor: anchor ?? undefined,
1691
1697
  decor: decor.decor,
1692
1698
  ghost: ghost ? plainText(ghost) : undefined,
@@ -583,6 +583,7 @@ export class Input implements Component, Focusable {
583
583
  const props: TspInputProps = {
584
584
  text: value,
585
585
  cursor,
586
+ sendable: false,
586
587
  prompt: prompt || undefined,
587
588
  placeholder: this.placeholder,
588
589
  };