@oh-my-pi/pi-tui 18.5.1 → 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,12 @@
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
+
5
11
  ## [18.5.1] - 2026-10-03
6
12
 
7
13
  ### Added
@@ -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
@@ -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. */
@@ -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
@@ -42,7 +58,11 @@ export declare class Win32InputModeDecoder {
42
58
  */
43
59
  export declare class Win32PasteMarkerNormalizer {
44
60
  #private;
45
- constructor(onInput: (data: string) => void);
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);
46
66
  process(data: string): void;
47
67
  /** Release an incomplete marker as its original key records. */
48
68
  flush(): void;
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.1",
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.1",
45
- "@oh-my-pi/pi-agent-core": "18.5.1",
46
- "@oh-my-pi/pi-ai": "18.5.1",
47
- "@oh-my-pi/pi-catalog": "18.5.1",
48
- "@oh-my-pi/pi-natives": "18.5.1",
49
- "@oh-my-pi/pi-utils": "18.5.1",
50
- "@oh-my-pi/pi-wire": "18.5.1",
51
- "@oh-my-pi/snapcompact": "18.5.1"
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"
@@ -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
  };
@@ -109,6 +109,11 @@ export interface ModelHubSource extends ModelBrowserSource {
109
109
  getProjectModelRole(role: string): string | undefined;
110
110
  getGlobalModelRole(role: string): string | undefined;
111
111
  getModelRoleSource(role: string): "global" | "project" | "default";
112
+ /**
113
+ * Saved model presets (in switch order) and the one the current role setup
114
+ * matches, if any. Absent hosts have no presets to switch between.
115
+ */
116
+ getModelPresets?(): { names: readonly string[]; active: string | undefined };
112
117
  }
113
118
 
114
119
  /** Catalog capabilities required by the model hub. */
@@ -157,6 +162,12 @@ export interface ModelHubCallbacks {
157
162
  onLoginRequest?: (providerId: string) => void;
158
163
  /** Save the current role assignments and default thinking level as a named model preset. */
159
164
  onSavePreset?: (name: string) => void;
165
+ /**
166
+ * Apply a saved model preset (ctrl+←/→ or p/⇧P in the Roles view) and report
167
+ * the outcome itself. A switch that writes nothing (a refused preset) still
168
+ * advances the hub's preset cursor, so the next press moves past it.
169
+ */
170
+ onSwitchPreset?: (name: string) => void | Promise<void>;
160
171
  /** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
161
172
  onCycleOrderChange?: (order: string[]) => void;
162
173
  onCancel: () => void;
@@ -216,7 +227,18 @@ type StripState =
216
227
  };
217
228
 
218
229
  /** A Roles-view command; keys and the picker's action bar both run {@link ModelHubComponent}'s `#runRolesAction`. */
219
- type RolesAction = "pick" | "clear" | "fallback" | "cycle" | "earlier" | "later" | "new" | "thinking" | "save";
230
+ type RolesAction =
231
+ | "pick"
232
+ | "clear"
233
+ | "fallback"
234
+ | "cycle"
235
+ | "earlier"
236
+ | "later"
237
+ | "new"
238
+ | "thinking"
239
+ | "save"
240
+ | "nextPreset"
241
+ | "prevPreset";
220
242
 
221
243
  /** Printable keys of the Roles view and the command each runs. */
222
244
  const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
@@ -228,6 +250,9 @@ const ROLES_ACTION_KEYS: Record<string, RolesAction> = {
228
250
  n: "new",
229
251
  t: "thinking",
230
252
  s: "save",
253
+ // Letter twins of ctrl+←/→, which macOS reserves for switching Spaces.
254
+ p: "nextPreset",
255
+ P: "prevPreset",
231
256
  };
232
257
 
233
258
  /** Picker fact columns of the Roles view. */
@@ -345,6 +370,12 @@ export class ModelHubComponent implements Component {
345
370
  #assigning: AssignTarget | null = null;
346
371
  #strip: StripState | null = null;
347
372
  #assignmentPending = false;
373
+ /**
374
+ * Last preset ctrl+←/→ tried in this hub, with the settings revision after the
375
+ * attempt; steps from it while nothing matches the setup or nothing changed since.
376
+ */
377
+ #presetCursor: { name: string; revision: number } | undefined;
378
+ #presetsMemo: { revision: number; names: readonly string[]; active: string | undefined } | undefined;
348
379
  #disposed = false;
349
380
  /** Per-provider fuzzy match counts while a query is active; null when not searching. */
350
381
  #searchCounts: Map<string, number> | null = null;
@@ -1536,10 +1567,13 @@ export class ModelHubComponent implements Component {
1536
1567
  }
1537
1568
  }
1538
1569
 
1539
- /** Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected. */
1570
+ /**
1571
+ * Switch the body into assign mode for `role`: full catalog, cleared query, current model preselected.
1572
+ * Arrows land on the model rows (the next step of the flow); ← still reaches the provider scopes.
1573
+ */
1540
1574
  #startAssign(role: string): void {
1541
1575
  this.#assigning = { kind: "role", role };
1542
- this.#focus = "scope";
1576
+ this.#focus = "list";
1543
1577
  this.#browser.setShowProvider(true);
1544
1578
  this.#setCandidateItems(this.#availableItems);
1545
1579
  this.#browser.setQuery("");
@@ -1552,7 +1586,7 @@ export class ModelHubComponent implements Component {
1552
1586
  /** Browse the catalog to fill a fallback-chain slot: `index` replaces an entry, `null` appends. */
1553
1587
  #startAssignFallback(role: string, index: number | null): void {
1554
1588
  this.#assigning = { kind: "fallback", role, index };
1555
- this.#focus = "scope";
1589
+ this.#focus = "list";
1556
1590
  this.#browser.setShowProvider(true);
1557
1591
  this.#setCandidateItems(this.#availableItems);
1558
1592
  this.#browser.setQuery("");
@@ -1570,7 +1604,7 @@ export class ModelHubComponent implements Component {
1570
1604
  /** Browse the catalog for the primary model a brand-new fallback chain protects. */
1571
1605
  #startAssignFallbackKey(): void {
1572
1606
  this.#assigning = { kind: "fallbackKey" };
1573
- this.#focus = "scope";
1607
+ this.#focus = "list";
1574
1608
  this.#browser.setShowProvider(true);
1575
1609
  this.#setCandidateItems(this.#availableItems);
1576
1610
  this.#browser.setQuery("");
@@ -1752,6 +1786,52 @@ export class ModelHubComponent implements Component {
1752
1786
  this.#buildRolesRows();
1753
1787
  }
1754
1788
 
1789
+ /** Saved presets and the one the setup matches; empty unless the host can both list and switch. */
1790
+ #presets(): { names: readonly string[]; active: string | undefined } {
1791
+ if (!this.#callbacks.onSwitchPreset || !this.#settings.getModelPresets) return { names: [], active: undefined };
1792
+ const revision = this.#settings.revision;
1793
+ if (this.#presetsMemo?.revision !== revision) {
1794
+ const { names, active } = this.#settings.getModelPresets();
1795
+ this.#presetsMemo = { revision, names, active };
1796
+ }
1797
+ return this.#presetsMemo;
1798
+ }
1799
+
1800
+ /**
1801
+ * The preset ctrl+←/→ steps from: the last one tried here while settings are
1802
+ * unchanged since (a refused switch writes nothing, so the next press moves
1803
+ * past it) or nothing matches; otherwise the preset the setup matches.
1804
+ */
1805
+ #presetBase(active: string | undefined): string | undefined {
1806
+ const cursor = this.#presetCursor;
1807
+ if (cursor && (active === undefined || cursor.revision === this.#settings.revision)) return cursor.name;
1808
+ return active;
1809
+ }
1810
+
1811
+ /** Apply the previous/next saved preset (wrapping), as `/modelpreset switch` does. */
1812
+ #switchPreset(delta: -1 | 1): void {
1813
+ const onSwitchPreset = this.#callbacks.onSwitchPreset;
1814
+ const { names, active } = this.#presets();
1815
+ if (!onSwitchPreset || names.length === 0) return;
1816
+ const base = this.#presetBase(active);
1817
+ const current = base === undefined ? -1 : names.indexOf(base);
1818
+ const next = current < 0 ? (delta > 0 ? 0 : names.length - 1) : (current + delta + names.length) % names.length;
1819
+ const name = names[next];
1820
+ if (name === undefined) return;
1821
+ const settle = () => {
1822
+ this.#presetCursor = { name, revision: this.#settings.revision };
1823
+ this.#refreshAfterMutation();
1824
+ };
1825
+ // Stepping onto the preset already in effect has nothing to apply.
1826
+ if (name === active) {
1827
+ settle();
1828
+ return;
1829
+ }
1830
+ // The host reports refusals itself, so the cursor moves once the switch
1831
+ // resolves, refused or not (`#presetBase` sees a refusal left the revision alone).
1832
+ this.#finishAssignment(onSwitchPreset(name), settle);
1833
+ }
1834
+
1755
1835
  handleInput(data: string): void {
1756
1836
  this.#nativeVersion++;
1757
1837
  if (this.#assignmentPending) {
@@ -1787,6 +1867,11 @@ export class ModelHubComponent implements Component {
1787
1867
  }
1788
1868
  return;
1789
1869
  }
1870
+ // Ctrl+←/→ in the Roles view steps through saved model presets, wrapping.
1871
+ if (rolesView && (matchesKey(data, "ctrl+left") || matchesKey(data, "ctrl+right"))) {
1872
+ this.#switchPreset(matchesKey(data, "ctrl+left") ? -1 : 1);
1873
+ return;
1874
+ }
1790
1875
  // Alt+←/→ cycles whichever tab strip is on screen: role tabs in the
1791
1876
  // Roles view, kind tabs in every browser view. Ctrl+←/→ is unusable on
1792
1877
  // macOS (Spaces shortcut). macOS terminals (ghostty, Terminal.app,
@@ -2067,6 +2152,10 @@ export class ModelHubComponent implements Component {
2067
2152
  case "save":
2068
2153
  if (this.#callbacks.onSavePreset) this.#openNameStrip("preset");
2069
2154
  return;
2155
+ case "nextPreset":
2156
+ case "prevPreset":
2157
+ this.#switchPreset(action === "nextPreset" ? 1 : -1);
2158
+ return;
2070
2159
  case "thinking":
2071
2160
  if (role) {
2072
2161
  const target = this.#roleThinkingTarget(role);
@@ -2265,8 +2354,13 @@ export class ModelHubComponent implements Component {
2265
2354
  ROLE_TABS.map(tab => ({ label: tab === "kind" ? "kinds" : tab })),
2266
2355
  Math.max(0, active),
2267
2356
  );
2357
+ const { names, active: activePreset } = this.#presets();
2358
+ const preset =
2359
+ names.length > 0
2360
+ ? ` ${theme.fg("dim", "Preset:")} ${activePreset ? theme.fg("accent", activePreset) : theme.fg("muted", "custom")} ${theme.fg("dim", `${formatKeyHints(["ctrl+left", "ctrl+right"])} · ${formatKeyHints(["p", "shift+p"])}`)}`
2361
+ : "";
2268
2362
  return truncateToWidth(
2269
- ` ${theme.fg("dim", "Roles:")} ${track} ${theme.fg("dim", formatKeyHints(["alt+left", "alt+right"]))}`,
2363
+ ` ${theme.fg("dim", "Roles:")} ${track} ${theme.fg("dim", formatKeyHints(["alt+left", "alt+right"]))}${preset}`,
2270
2364
  width,
2271
2365
  );
2272
2366
  }
@@ -2563,7 +2657,9 @@ export class ModelHubComponent implements Component {
2563
2657
  const entry = this.#activeEntry();
2564
2658
  if (entry.kind === "roles") {
2565
2659
  if (this.#focus !== "list") {
2566
- return `${upDown} providers · ${enterRight} roles · ${altLeftRight} tabs · ${cancel} close`;
2660
+ const presets =
2661
+ this.#presets().names.length > 0 ? ` · ${formatKeyHints(["ctrl+left", "ctrl+right"])} preset` : "";
2662
+ return `${upDown} providers · ${enterRight} roles · ${altLeftRight} tabs${presets} · ${cancel} close`;
2567
2663
  }
2568
2664
  const row = this.#rolesRows[this.#roleIndex];
2569
2665
  if (row?.kind === "fallback") {
@@ -2585,7 +2681,8 @@ export class ModelHubComponent implements Component {
2585
2681
  const editable = row?.kind === "role" && this.#roleThinkingTarget(row.role) !== undefined;
2586
2682
  const thinking = editable ? ` · ${formatKeyHint("t")} thinking` : "";
2587
2683
  const savePreset = this.#callbacks.onSavePreset ? ` · ${formatKeyHint("s")} save preset` : "";
2588
- return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new${savePreset}`;
2684
+ const switchPreset = this.#presets().names.length > 0 ? ` · ${formatKeyHints(["p", "shift+p"])} preset` : "";
2685
+ return `${upDown} rows · ${enter} pick · ${formatKeyHint("f")} fallback · ${formatKeyHint("x")} clear${thinking} · ${formatKeyHint("c")} cycle · [/] reorder · ${formatKeyHint("n")} new${savePreset}${switchPreset}`;
2589
2686
  }
2590
2687
  if (entry.kind === "provider" && entry.locked) {
2591
2688
  return entry.oauth
@@ -3143,6 +3240,17 @@ export class ModelHubComponent implements Component {
3143
3240
  if (entry.kind === "provider" && entry.providerId && this.#refreshingProviders.has(entry.providerId)) {
3144
3241
  return `${entry.label} · refreshing model list…`;
3145
3242
  }
3243
+ const presets = rolesView ? this.#presets() : undefined;
3244
+ if (presets && presets.names.length > 0) {
3245
+ return [
3246
+ span("Preset ", "muted"),
3247
+ presets.active ? span(presets.active, "strong") : span("custom", "muted"),
3248
+ span(
3249
+ this.#scopedModels.length > 0 ? " · --models scope" : " · Cleared roles fall back to auto-selection",
3250
+ "muted",
3251
+ ),
3252
+ ];
3253
+ }
3146
3254
  if (this.#scopedModels.length > 0) return "--models scope";
3147
3255
  if (rolesView) return "Cleared roles fall back to auto-selection";
3148
3256
  return undefined;
@@ -3296,6 +3404,7 @@ export class ModelHubComponent implements Component {
3296
3404
  roleAction("cycle", this.#cycleOrder().includes(row.role) ? "Leave cycle" : "Add to cycle", "c"),
3297
3405
  roleAction("new", "New role", "n"),
3298
3406
  this.#callbacks.onSavePreset ? roleAction("save", "Save preset", "s") : undefined,
3407
+ this.#presets().names.length > 0 ? roleAction("nextPreset", "Next preset", "p") : undefined,
3299
3408
  );
3300
3409
  break;
3301
3410
  }
@@ -3782,6 +3891,7 @@ export class ModelHubComponent implements Component {
3782
3891
  : "assign";
3783
3892
  return [keys(pick, "enter"), upDown("models"), keys("providers", "left"), search, kind, cancel("cancel")];
3784
3893
  }
3894
+ const presetHint = this.#presets().names.length > 0 ? keys("preset", "ctrl+left", "ctrl+right") : undefined;
3785
3895
  const entry = this.#activeEntry();
3786
3896
  if (entry.kind === "roles") {
3787
3897
  if (this.#focus !== "list") {
@@ -3789,6 +3899,7 @@ export class ModelHubComponent implements Component {
3789
3899
  upDown("providers"),
3790
3900
  keys("roles", "enter", "right"),
3791
3901
  keys("tabs", "alt+left", "alt+right"),
3902
+ presetHint,
3792
3903
  cancel("close"),
3793
3904
  ];
3794
3905
  }
@@ -3828,6 +3939,7 @@ export class ModelHubComponent implements Component {
3828
3939
  reorder,
3829
3940
  keys("new", "n"),
3830
3941
  this.#callbacks.onSavePreset ? keys("save preset", "s") : undefined,
3942
+ this.#presets().names.length > 0 ? keys("preset", "ctrl+left", "ctrl+right", "p", "shift+p") : undefined,
3831
3943
  ];
3832
3944
  }
3833
3945
  if (entry.kind === "provider" && entry.locked) {
@@ -1001,6 +1001,10 @@ export class CustomEditor extends Editor {
1001
1001
  }, CustomEditor.SHIMMER_FRAME_MS);
1002
1002
  this.#shimmerTimer.unref?.();
1003
1003
  }
1004
+ /** Editing is available during bootstrap; atomic sends wait until submission is wired and enabled. */
1005
+ protected override get nativeSendable(): boolean {
1006
+ return this.onSubmit !== undefined && !this.disableSubmit;
1007
+ }
1004
1008
  /** Viewing a subagent, the draft goes to it: the placeholder names it. */
1005
1009
  override describePlaceholder = (): string => {
1006
1010
  const agent = this.composerState().viewing?.at(-1);
@@ -1259,7 +1263,7 @@ export class CustomEditor extends Editor {
1259
1263
  if (event.type === "send") {
1260
1264
  // A send is its own prompt: never submit a stale draft for blank input,
1261
1265
  // and retain a displaced draft (including its attachments) for recall.
1262
- if (!event.text.trim() || this.disableSubmit || !this.onSubmit) return;
1266
+ if (!event.text.trim() || !this.nativeSendable) return;
1263
1267
  if (this.#pasteInFlight > 0) {
1264
1268
  this.#pendingInput.push(event);
1265
1269
  return;
package/src/terminal.ts CHANGED
@@ -38,6 +38,8 @@ const TERMINAL_PROGRESS_KEEPALIVE_MS = 1000;
38
38
  const TERMINAL_PROGRESS_ACTIVE_SEQUENCE = "\x1b]9;4;3\x07";
39
39
  const TERMINAL_PROGRESS_CLEAR_SEQUENCE = "\x1b]9;4;0;\x07";
40
40
  const WINDOWS_TERMINAL_OSC11_POLL_MS = 30_000;
41
+ /** How long a relayed ESC record waits for the rest of its sequence before acting as Escape (matches StdinBuffer). */
42
+ const WIN32_RELAYED_ESCAPE_TIMEOUT_MS = 75;
41
43
 
42
44
  /**
43
45
  * Terminal → program TSP messages through a Windows ConPTY: its input parser
@@ -759,6 +761,8 @@ export class ProcessTerminal implements Terminal {
759
761
  // win32-input-mode sequences and are decoded before reaching the handler.
760
762
  #win32InputDecoder?: Win32InputModeDecoder;
761
763
  #win32PasteNormalizer?: Win32PasteMarkerNormalizer;
764
+ // Releases a relayed lone ESC the decoder held for a sequence tail that never came.
765
+ #win32PendingFlushTimer?: Timer;
762
766
  #stdinBuffer?: StdinBuffer;
763
767
  #stdinDataHandler?: (data: string) => void;
764
768
  #disconnectHandler?: () => void;
@@ -1570,9 +1574,18 @@ export class ProcessTerminal implements Terminal {
1570
1574
  return;
1571
1575
  }
1572
1576
  if (this.#inputHandler) {
1573
- const win32Keys = this.#win32InputDecoder?.decode(sequence);
1574
- if (win32Keys !== undefined) {
1577
+ const decoder = this.#win32InputDecoder;
1578
+ const win32Keys = decoder?.decode(sequence);
1579
+ if (decoder && win32Keys !== undefined) {
1580
+ clearTimeout(this.#win32PendingFlushTimer);
1581
+ this.#win32PendingFlushTimer = undefined;
1575
1582
  for (const key of win32Keys) this.#inputHandler(key);
1583
+ if (decoder.hasPendingSequence) {
1584
+ this.#win32PendingFlushTimer = setTimeout(
1585
+ () => this.#releaseWin32PendingSequence(),
1586
+ WIN32_RELAYED_ESCAPE_TIMEOUT_MS,
1587
+ );
1588
+ }
1576
1589
  return;
1577
1590
  }
1578
1591
  // Windows console hosts drop AltGr text under kitty (AltGr+F → `CSI 102;3u`);
@@ -1868,7 +1881,12 @@ export class ProcessTerminal implements Terminal {
1868
1881
  // and the mode splits arrow keys into Escape plus literal text (#14034).
1869
1882
  this.#safeWrite("\x1b[?9001h");
1870
1883
  this.#win32InputDecoder = new Win32InputModeDecoder();
1871
- this.#win32PasteNormalizer = new Win32PasteMarkerNormalizer(data => this.#stdinBuffer?.process(data));
1884
+ // The normalizer's expiry already spent the ESC wait; release the decoder's hold
1885
+ // at once instead of starting a second window.
1886
+ this.#win32PasteNormalizer = new Win32PasteMarkerNormalizer(
1887
+ data => this.#stdinBuffer?.process(data),
1888
+ () => this.#releaseWin32PendingSequence(),
1889
+ );
1872
1890
  return;
1873
1891
  }
1874
1892
  // A remote terminal with no identifying env may not understand the request.
@@ -1877,10 +1895,20 @@ export class ProcessTerminal implements Terminal {
1877
1895
  this.#modifyOtherKeysActive = true;
1878
1896
  }
1879
1897
 
1898
+ /** Deliver relayed VT text the decoder is holding (a lone ESC becomes Escape). */
1899
+ #releaseWin32PendingSequence(): void {
1900
+ clearTimeout(this.#win32PendingFlushTimer);
1901
+ this.#win32PendingFlushTimer = undefined;
1902
+ const pending = this.#win32InputDecoder?.flushPendingSequence() ?? [];
1903
+ for (const key of pending) this.#inputHandler?.(key);
1904
+ }
1905
+
1880
1906
  #disableWin32InputMode(): void {
1881
1907
  if (!this.#win32InputDecoder) return;
1882
1908
  this.#win32PasteNormalizer?.flush();
1883
1909
  this.#win32PasteNormalizer = undefined;
1910
+ // Held input still belongs to the user (e.g. Escape before a late kitty reply).
1911
+ this.#releaseWin32PendingSequence();
1884
1912
  this.#safeWrite("\x1b[?9001l");
1885
1913
  this.#win32InputDecoder = undefined;
1886
1914
  }
@@ -16,6 +16,12 @@
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.
19
25
  */
20
26
 
21
27
  const W32IM_PATTERN = /^\x1b\[([\d;]*)_$/;
@@ -111,6 +117,31 @@ function baseCodepointForVk(vk: number): number {
111
117
  return 0;
112
118
  }
113
119
 
120
+ /** `split`: the leading ESC is a standalone Escape and the rest starts a new sequence. */
121
+ type VtState = "partial" | "complete" | "invalid" | "split";
122
+
123
+ /** Classify relayed VT text that starts with ESC. */
124
+ function vtState(text: string): VtState {
125
+ if (text.length === 1) return "partial";
126
+ const introducer = text[1]!;
127
+ if (introducer === "\x1b") {
128
+ if (text.length === 2) return "partial";
129
+ // Only meta-CSI/SS3 (legacy Alt+Up is `ESC ESC [ A`) stays grouped; `ESC ESC d`
130
+ // is Escape followed by Alt+d, matching StdinBuffer.
131
+ return text[2] === "[" || text[2] === "O" ? vtState(text.slice(1)) : "split";
132
+ }
133
+ if (introducer === "[") {
134
+ if (/^\x1b\[[0-?]*[ -/]*[@-~]$/.test(text)) return "complete";
135
+ return /^\x1b\[[0-?]*[ -/]*$/.test(text) ? "partial" : "invalid";
136
+ }
137
+ if (introducer === "O") return text.length === 2 ? "partial" : text.length === 3 ? "complete" : "invalid";
138
+ if (introducer === "]" || introducer === "P" || introducer === "_") {
139
+ return text.endsWith("\x07") || (text.length > 3 && text.endsWith("\x1b\\")) ? "complete" : "partial";
140
+ }
141
+ // ESC + one character: Alt+character.
142
+ return text.length === 2 ? "complete" : "invalid";
143
+ }
144
+
114
145
  function encodeKey(record: KeyRecord): string | null {
115
146
  const { vk, uc, state } = record;
116
147
  const shift = (state & SHIFT_PRESSED) !== 0;
@@ -165,9 +196,30 @@ function encodeKey(record: KeyRecord): string | null {
165
196
  return uc === 0 ? null : text;
166
197
  }
167
198
 
168
- /** Stateful decoder: joins UTF-16 surrogate halves that arrive as separate records. */
199
+ /**
200
+ * Stateful decoder: joins UTF-16 surrogate halves that arrive as separate
201
+ * records, and reassembles VT escape sequences relayed as text records.
202
+ */
169
203
  export class Win32InputModeDecoder {
170
204
  #pendingHighSurrogate = 0;
205
+ /** Relayed VT text that starts with ESC and has not completed a sequence yet. */
206
+ #pendingVt = "";
207
+
208
+ /** Whether relayed VT text is held waiting for the rest of its sequence. */
209
+ get hasPendingSequence(): boolean {
210
+ return this.#pendingVt.length > 0;
211
+ }
212
+
213
+ /**
214
+ * Release held VT text as-is. The caller invokes this once no further bytes
215
+ * arrived in time, so a lone relayed ESC still acts as the Escape key.
216
+ */
217
+ flushPendingSequence(): string[] {
218
+ if (this.#pendingVt.length === 0) return [];
219
+ const pending = this.#pendingVt;
220
+ this.#pendingVt = "";
221
+ return [pending];
222
+ }
171
223
 
172
224
  /**
173
225
  * Translate one stdin sequence. Returns `undefined` when `data` is not a
@@ -180,17 +232,42 @@ export class Win32InputModeDecoder {
180
232
 
181
233
  if (!record.down) {
182
234
  // Alt+Numpad composition delivers its character on the Alt release.
183
- if (record.vk === VK_MENU && record.uc !== 0) return this.#emitText(record.uc);
235
+ if (record.vk === VK_MENU && record.uc !== 0) return this.#assembleVt(this.#emitText(record.uc));
184
236
  return [];
185
237
  }
186
238
  if (MODIFIER_VKS.has(record.vk)) return [];
187
- if (record.vk === 0 || record.vk === VK_PACKET) return this.#emitText(record.uc, record.repeat);
188
- if (record.uc >= 0xd800 && record.uc <= 0xdfff) return this.#emitText(record.uc);
239
+ if (record.vk === 0 || record.vk === VK_PACKET) return this.#assembleVt(this.#emitText(record.uc, record.repeat));
240
+ if (record.uc >= 0xd800 && record.uc <= 0xdfff) return this.#assembleVt(this.#emitText(record.uc));
189
241
 
190
242
  this.#pendingHighSurrogate = 0;
191
243
  const encoded = encodeKey(record);
192
- if (encoded === null) return [];
193
- return Array.from({ length: record.repeat }, () => encoded);
244
+ // A real key ends whatever relayed text was still incomplete.
245
+ const flushed = this.flushPendingSequence();
246
+ if (encoded === null) return flushed;
247
+ for (let i = 0; i < record.repeat; i++) flushed.push(encoded);
248
+ return flushed;
249
+ }
250
+
251
+ /** Group relayed text into whole escape sequences; other text passes through per character. */
252
+ #assembleVt(texts: string[]): string[] {
253
+ const out: string[] = [];
254
+ for (const text of texts) {
255
+ if (this.#pendingVt.length === 0 && text !== "\x1b") {
256
+ out.push(text);
257
+ continue;
258
+ }
259
+ this.#pendingVt += text;
260
+ let state = vtState(this.#pendingVt);
261
+ while (state === "split") {
262
+ out.push("\x1b");
263
+ this.#pendingVt = this.#pendingVt.slice(1);
264
+ state = vtState(this.#pendingVt);
265
+ }
266
+ if (state === "partial") continue;
267
+ out.push(this.#pendingVt);
268
+ this.#pendingVt = "";
269
+ }
270
+ return out;
194
271
  }
195
272
 
196
273
  /**
@@ -245,9 +322,15 @@ export class Win32PasteMarkerNormalizer {
245
322
  #marker = "";
246
323
  #timer?: NodeJS.Timeout;
247
324
  readonly #onInput: (data: string) => void;
325
+ readonly #onExpired?: () => void;
248
326
 
249
- constructor(onInput: (data: string) => void) {
327
+ /**
328
+ * `onExpired` runs after held records are released because their wait timed
329
+ * out, so a downstream holder can treat the same wait as already elapsed.
330
+ */
331
+ constructor(onInput: (data: string) => void, onExpired?: () => void) {
250
332
  this.#onInput = onInput;
333
+ this.#onExpired = onExpired;
251
334
  }
252
335
 
253
336
  process(data: string): void {
@@ -324,7 +407,12 @@ export class Win32PasteMarkerNormalizer {
324
407
  }
325
408
 
326
409
  if (output) this.#onInput(output);
327
- if (this.#candidate || this.#pending) this.#timer = setTimeout(() => this.flush(), 75);
410
+ if (this.#candidate || this.#pending) {
411
+ this.#timer = setTimeout(() => {
412
+ this.flush();
413
+ this.#onExpired?.();
414
+ }, 75);
415
+ }
328
416
  }
329
417
 
330
418
  /** Release an incomplete marker as its original key records. */