@oh-my-pi/pi-tui 18.5.1 → 18.6.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 CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [18.6.1] - 2026-10-04
6
+
7
+ ### Fixed
8
+
9
+ - Fixed the Space key in the Git diff pane so it stages or unstages the focused hunk instead of scrolling or acting on the wrong change.
10
+ - Fixed the BTW history sheet in Tern: the history list no longer collapses beside a long answer, the panes lose their foldable `##` headings, and the arrow/page keys scroll the answer ([#14331](https://github.com/can1357/oh-my-pi/pull/14331) by [@H4vC](https://github.com/H4vC))
11
+ - Fixed a single saved side question in the BTW history panel opening on its one-row list: its answer now has focus, and Enter or `f` to follow up jumps to the bottom of the conversation ([#14331](https://github.com/can1357/oh-my-pi/pull/14331) by [@H4vC](https://github.com/H4vC))
12
+
13
+ ## [18.6.0] - 2026-10-03
14
+
15
+ ### Fixed
16
+
17
+ - 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)).
18
+
5
19
  ## [18.5.1] - 2026-10-03
6
20
 
7
21
  ### Added
@@ -13,8 +13,9 @@
13
13
  * arrows/PgUp/PgDn, vim motions (`j`/`k`/`h`/`l`/`g`/`G`), and mouse
14
14
  * clicks/wheel. All toolbar buttons are clickable and mirrored by keys:
15
15
  * `v` cycles the view (`1`–`4` pick one), `alt+↓`/`alt+↑` jump hunks and roll
16
- * into the adjacent file at the edges, `]`/`[` switch files, `s`/`u`
17
- * stage/unstage (hunk-aware), `x` discards a hunk, `delete` discards the
16
+ * into the adjacent file at the edges, `space` stages/unstages the focused
17
+ * diff row (the focused hunk in hunk view), `s`/`u` stage/unstage
18
+ * (hunk-aware), `x` discards a hunk, `delete` discards the
18
19
  * whole file (press twice to confirm), `w` wraps, `b` cycles
19
20
  * whitespace handling (exact → ignore whitespace → ignore
20
21
  * formatting/import-only changes), `c` jumps to the commit form, `r`
@@ -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,9 @@
1
1
  /**
2
2
  * Reference document applier: applies TSP frames to a node tree the way the
3
- * spec says the terminal does (§5.1). Ops are validated and applied one by
4
- * one; a rejected op is reported and skipped without affecting the rest of
5
- * the frame. `settle` is recorded as a hint and never restricts later ops.
3
+ * spec says the terminal does (Tern SDK, `protocol/documents.md`, frame ops).
4
+ * Ops are validated and applied one by one; a rejected op is reported and
5
+ * skipped without affecting the rest of the frame. `settle` is recorded as a
6
+ * hint and never restricts later ops.
6
7
  *
7
8
  * Used by tests, the debug server's `doc` op, and the TSP test harness.
8
9
  */
@@ -26,9 +26,10 @@ export declare function encodeTspMessage(verb: TspVerb, body: string, params?: T
26
26
  export declare function encodeTspJson(verb: TspVerb, value: unknown, params?: TspParams, limit?: number): string;
27
27
  /**
28
28
  * The `hello` query; callers follow it with a DA1 sentinel. `features: ["edit"]`
29
- * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
30
- * native selection in omp's editors; without it, every key stays omp's. `"undo"`
31
- * says omp applies `undo` events, so the terminal may turn ⌃Z in a field into one.
29
+ * tells the terminal that omp applies its `edit` events (Tern SDK,
30
+ * `protocol/input.md`), so it may keep a native selection in omp's editors;
31
+ * without it, every key stays omp's. `"undo"` says omp applies `undo` events,
32
+ * so the terminal may turn ⌃Z in a field into one.
32
33
  * `"send"` accepts an explicit prompt for a live composer without simulating keys.
33
34
  */
34
35
  export declare function encodeTspHelloQuery(version?: string): string;
@@ -11,7 +11,8 @@
11
11
  * `rows` fallback node rendered through `render(cx.cols)`.
12
12
  *
13
13
  * See `packages/wire/src/tsp.ts` for the wire vocabulary and
14
- * `crates/tern/SURFACE_PROTOCOL.md` (Stencil repository) for the spec.
14
+ * the Tern SDK's Surface Protocol reference (`docs/sdk/src/protocol` in the
15
+ * Stencil repository, https://docs.stencil.so/tern/protocol/) for the spec.
15
16
  */
16
17
  import type { TspEvent, TspKind, TspProps, TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
17
18
  import type { Component } from "../tui.js";
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Settling: the frame provider marks finalized transcript blocks so the
3
- * native reconciler stops keeping their description (spec §12 "Settling and
4
- * program memory"). A settled block stays editable: a later change is sent as
5
- * targeted ops by id, followed by a fresh `settle` hint.
3
+ * native reconciler stops keeping their description (Tern SDK,
4
+ * `protocol/operations.md`, "Settling"). A settled block stays editable: a
5
+ * later change is sent as targeted ops by id, followed by a fresh `settle`
6
+ * hint.
6
7
  */
7
8
  import type { Component } from "../tui.js";
8
9
  /** Mark `component` (and everything it describes) as finalized. Idempotent; a no-op outside a TSP terminal. */
@@ -13,6 +13,12 @@ interface BtwHistoryPanelOptions {
13
13
  spaceHold?: (input: Input) => SpaceHoldHandler;
14
14
  requestRender: () => void;
15
15
  getHeight: () => number;
16
+ /**
17
+ * Esc closes the panel even while the selected answer runs (it keeps
18
+ * streaming in the background) and `x` cancels it, instead of Esc cancelling
19
+ * first. For a host with no inline panel to fall back to (Tern).
20
+ */
21
+ escapeHides?: boolean;
16
22
  }
17
23
  /** Session-local side questions. Selecting or copying never promotes them into chat. */
18
24
  export declare class BtwHistoryPanel implements Component, Focusable {
@@ -25,6 +31,8 @@ export declare class BtwHistoryPanel implements Component, Focusable {
25
31
  markCopied(recordId: string, text: string): void;
26
32
  invalidate(): void;
27
33
  openFollowUp(recordId: string): boolean;
34
+ /** Select a record with its answer pane focused, read from the top (a just-asked /btw). */
35
+ showRecord(recordId: string): void;
28
36
  /** Enhanced clipboard pastes belong only to the active composer. */
29
37
  pasteText(text: string): void;
30
38
  handleInput(data: string): void;
@@ -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.1",
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.1",
45
+ "@oh-my-pi/pi-agent-core": "18.6.1",
46
+ "@oh-my-pi/pi-ai": "18.6.1",
47
+ "@oh-my-pi/pi-catalog": "18.6.1",
48
+ "@oh-my-pi/pi-natives": "18.6.1",
49
+ "@oh-my-pi/pi-utils": "18.6.1",
50
+ "@oh-my-pi/pi-wire": "18.6.1",
51
+ "@oh-my-pi/snapcompact": "18.6.1"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -184,6 +184,10 @@ export interface DiffBuildOptions {
184
184
  streamResult?: DiffStreamResult;
185
185
  }
186
186
 
187
+ function filePatchHeader(filePath: string): string {
188
+ return `diff --git a/${filePath} b/${filePath}\n--- a/${filePath}\n+++ b/${filePath}\n`;
189
+ }
190
+
187
191
  /** Build the aligned document for one file from its raw old/new texts. */
188
192
  export function buildDiffDocument(
189
193
  oldRaw: string,
@@ -376,10 +380,11 @@ export function buildDiffDocument(
376
380
  // cannot use the raw streamed result because its equality basis differs.
377
381
  const canPatch = !ignoreWs;
378
382
  const tightHunks = streamed?.hunks ?? structuredPatchHunks(oldBasis, newBasis, DIFF_CONTEXT_LINES);
383
+ const patchHeader = canPatch && tightHunks.length > 0 ? filePatchHeader(filePath) : "";
379
384
  const allHunks: HunkBlock[] = tightHunks.map(hunk => ({
380
385
  header: `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@`,
381
386
  patch: canPatch
382
- ? `--- a/${filePath}\n+++ b/${filePath}\n@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}\n`
387
+ ? `${patchHeader}@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}\n`
383
388
  : "",
384
389
  rows: walkHunk(hunk, false),
385
390
  }));
@@ -476,7 +481,7 @@ export function buildLineSelectionPatch(
476
481
  `@@ -${hunk.oldStart},${hunk.oldLines} +${hunk.newStart},${hunk.newLines} @@\n${hunk.lines.join("\n")}`,
477
482
  )
478
483
  .join("\n");
479
- return `--- a/${doc.filePath}\n+++ b/${doc.filePath}\n${body}\n`;
484
+ return `${filePatchHeader(doc.filePath)}${body}\n`;
480
485
  }
481
486
 
482
487
  // ── palette ──────────────────────────────────────────────────────────────────
@@ -721,6 +726,7 @@ export class DiffPane {
721
726
  this.scrollLeft = 0;
722
727
  this.cursor = 0;
723
728
  this.anchor = null;
729
+ if (mode === "hunk") this.selectedHunk = 0;
724
730
  }
725
731
 
726
732
  cycleMode(): void {
@@ -771,6 +777,7 @@ export class DiffPane {
771
777
  this.anchor = null;
772
778
  }
773
779
  this.cursor = Math.max(0, Math.min(total - 1, this.cursor + delta));
780
+ this.#syncHunkToCursor();
774
781
  this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
775
782
  this.#clampScroll();
776
783
  }
@@ -854,6 +861,7 @@ export class DiffPane {
854
861
  if (total === 0) return;
855
862
  this.anchor = null;
856
863
  this.cursor = edge === "start" ? 0 : total - 1;
864
+ this.#syncHunkToCursor();
857
865
  this.scrollTop = scrollOffsetForRow(this.scrollTop, this.cursor, total, this.#lastHeight);
858
866
  this.#clampScroll();
859
867
  }
@@ -870,6 +878,19 @@ export class DiffPane {
870
878
  }
871
879
  }
872
880
 
881
+ /** Keep the hunk action and highlighted header on the cursor's hunk, including blank separators. */
882
+ #syncHunkToCursor(): void {
883
+ if (this.mode !== "hunk") return;
884
+ const visuals = this.#layout(this.#lastWidth || 80);
885
+ for (let row = this.cursor; row >= 0; row--) {
886
+ const visual = visuals[row];
887
+ if (visual?.t === "header") {
888
+ this.selectedHunk = visual.hunk;
889
+ return;
890
+ }
891
+ }
892
+ }
893
+
873
894
  /** First visual row of every change block, in document order. */
874
895
  #changeStarts(visuals: Visual[]): number[] {
875
896
  const starts: number[] = [];
@@ -917,6 +938,10 @@ export class DiffPane {
917
938
  const hit = this.#hits[row];
918
939
  if (hit && this.#doc) {
919
940
  this.selectedHunk = hit.hunk;
941
+ if (this.mode === "hunk") {
942
+ this.cursor = this.scrollTop + row;
943
+ this.anchor = null;
944
+ }
920
945
  if (hit.primary && col >= hit.primary[0] && col < hit.primary[1] && this.patchTarget) {
921
946
  return { type: "hunk-action", hunk: this.#doc.hunks[hit.hunk], action: this.patchTarget };
922
947
  }
@@ -933,6 +958,7 @@ export class DiffPane {
933
958
  this.anchor = null;
934
959
  }
935
960
  this.cursor = visual;
961
+ this.#syncHunkToCursor();
936
962
  return { type: "handled" };
937
963
  }
938
964
  return null;
@@ -13,8 +13,9 @@
13
13
  * arrows/PgUp/PgDn, vim motions (`j`/`k`/`h`/`l`/`g`/`G`), and mouse
14
14
  * clicks/wheel. All toolbar buttons are clickable and mirrored by keys:
15
15
  * `v` cycles the view (`1`–`4` pick one), `alt+↓`/`alt+↑` jump hunks and roll
16
- * into the adjacent file at the edges, `]`/`[` switch files, `s`/`u`
17
- * stage/unstage (hunk-aware), `x` discards a hunk, `delete` discards the
16
+ * into the adjacent file at the edges, `space` stages/unstages the focused
17
+ * diff row (the focused hunk in hunk view), `s`/`u` stage/unstage
18
+ * (hunk-aware), `x` discards a hunk, `delete` discards the
18
19
  * whole file (press twice to confirm), `w` wraps, `b` cycles
19
20
  * whitespace handling (exact → ignore whitespace → ignore
20
21
  * formatting/import-only changes), `c` jumps to the commit form, `r`
@@ -669,14 +670,20 @@ class GitTuiComponent implements Component {
669
670
  else if (matchesKey(data, "up") || data === "k") this.#pane.moveCursor(-1, false);
670
671
  else if (matchesKey(data, "down") || data === "j") this.#pane.moveCursor(1, false);
671
672
  else if (matchesKey(data, "pageUp")) this.#pane.moveCursor(-Math.max(1, this.#contentHeight - 2), false);
672
- else if (matchesKey(data, "pageDown") || data === " ")
673
- this.#pane.moveCursor(Math.max(1, this.#contentHeight - 2), false);
673
+ else if (matchesKey(data, "pageDown")) this.#pane.moveCursor(Math.max(1, this.#contentHeight - 2), false);
674
674
  else if (matchesKey(data, "left") || data === "h") this.#pane.scrollLeftBy(-8);
675
675
  else if (matchesKey(data, "right") || data === "l") this.#pane.scrollLeftBy(8);
676
676
  else if (matchesKey(data, "home") || data === "g") this.#pane.cursorToEdge("start");
677
677
  else if (matchesKey(data, "end") || data === "G") this.#pane.cursorToEdge("end");
678
678
  else if (matchesKey(data, "enter")) return this.#jumpHunkOrFile(1);
679
- else if (data === "s" || data === "u") {
679
+ else if (matchesKey(data, "space")) {
680
+ // Hunk-view rows do not map back to document rows, so Space acts on the focused hunk there.
681
+ const target = this.#pane.patchTarget;
682
+ const hunk = this.#pane.mode === "hunk" ? this.#pane.currentHunk : null;
683
+ if (target && hunk) void this.#hunkAction(hunk, target);
684
+ else if (target && this.#pane.selection) void this.#lineAction(target);
685
+ return;
686
+ } else if (data === "s" || data === "u") {
680
687
  if (this.#pane.selection?.explicit && this.#pane.patchTarget) {
681
688
  void this.#lineAction(this.#pane.patchTarget);
682
689
  return;
@@ -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
  };
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Reference document applier: applies TSP frames to a node tree the way the
3
- * spec says the terminal does (§5.1). Ops are validated and applied one by
4
- * one; a rejected op is reported and skipped without affecting the rest of
5
- * the frame. `settle` is recorded as a hint and never restricts later ops.
3
+ * spec says the terminal does (Tern SDK, `protocol/documents.md`, frame ops).
4
+ * Ops are validated and applied one by one; a rejected op is reported and
5
+ * skipped without affecting the rest of the frame. `settle` is recorded as a
6
+ * hint and never restricts later ops.
6
7
  *
7
8
  * Used by tests, the debug server's `doc` op, and the TSP test harness.
8
9
  */
@@ -108,9 +108,10 @@ export function encodeTspJson(verb: TspVerb, value: unknown, params?: TspParams,
108
108
 
109
109
  /**
110
110
  * The `hello` query; callers follow it with a DA1 sentinel. `features: ["edit"]`
111
- * tells the terminal that omp applies its `edit` events (TSP §8.5), so it may keep a
112
- * native selection in omp's editors; without it, every key stays omp's. `"undo"`
113
- * says omp applies `undo` events, so the terminal may turn ⌃Z in a field into one.
111
+ * tells the terminal that omp applies its `edit` events (Tern SDK,
112
+ * `protocol/input.md`), so it may keep a native selection in omp's editors;
113
+ * without it, every key stays omp's. `"undo"` says omp applies `undo` events,
114
+ * so the terminal may turn ⌃Z in a field into one.
114
115
  * `"send"` accepts an explicit prompt for a live composer without simulating keys.
115
116
  */
116
117
  export function encodeTspHelloQuery(version?: string): string {
@@ -11,7 +11,8 @@
11
11
  * `rows` fallback node rendered through `render(cx.cols)`.
12
12
  *
13
13
  * See `packages/wire/src/tsp.ts` for the wire vocabulary and
14
- * `crates/tern/SURFACE_PROTOCOL.md` (Stencil repository) for the spec.
14
+ * the Tern SDK's Surface Protocol reference (`docs/sdk/src/protocol` in the
15
+ * Stencil repository, https://docs.stencil.so/tern/protocol/) for the spec.
15
16
  */
16
17
  import type { TspEvent, TspKind, TspProps, TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
17
18
  import type { Component } from "../tui";
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * Settling: the frame provider marks finalized transcript blocks so the
3
- * native reconciler stops keeping their description (spec §12 "Settling and
4
- * program memory"). A settled block stays editable: a later change is sent as
5
- * targeted ops by id, followed by a fresh `settle` hint.
3
+ * native reconciler stops keeping their description (Tern SDK,
4
+ * `protocol/operations.md`, "Settling"). A settled block stays editable: a
5
+ * later change is sent as targeted ops by id, followed by a fresh `settle`
6
+ * hint.
6
7
  */
7
8
  import type { Component } from "../tui";
8
9
 
@@ -34,8 +34,8 @@ import { bottomBorder, row, topBorder } from "../chrome/overlay-box";
34
34
  import { padToWidth } from "../render/utils";
35
35
  import { SplitPane } from "../components/layout/split-pane";
36
36
  import { clampSelection, contentRowWidth, padLinesToHeight, renderScrollableList } from "../chrome/selector-helpers";
37
- import type { TspSpan } from "@oh-my-pi/pi-wire";
38
- import type { NativeChild, NativeNode, NativeUiEvent } from "../native/node";
37
+ import type { TspScrollBy, TspSpan } from "@oh-my-pi/pi-wire";
38
+ import type { NativeChild, NativeNode, NativeScroll, NativeUiEvent } from "../native/node";
39
39
  import { col, md, node, span, text } from "../native/describe";
40
40
  import { actionHint, hintsRow, type NativeHint, statusHintsRow } from "../native/overlay";
41
41
 
@@ -50,6 +50,12 @@ interface BtwHistoryPanelOptions {
50
50
  spaceHold?: (input: Input) => SpaceHoldHandler;
51
51
  requestRender: () => void;
52
52
  getHeight: () => number;
53
+ /**
54
+ * Esc closes the panel even while the selected answer runs (it keeps
55
+ * streaming in the background) and `x` cancels it, instead of Esc cancelling
56
+ * first. For a host with no inline panel to fall back to (Tern).
57
+ */
58
+ escapeHides?: boolean;
53
59
  }
54
60
 
55
61
  interface FollowUpComposer {
@@ -78,6 +84,7 @@ interface BtwHistoryNativeMemo {
78
84
  followUpPending: boolean;
79
85
  canFollowUp: boolean;
80
86
  copied: boolean;
87
+ scroll: NativeScroll | undefined;
81
88
  node: NativeNode;
82
89
  }
83
90
 
@@ -108,6 +115,8 @@ export class BtwHistoryPanel implements Component, Focusable {
108
115
  #lastCopiedId: string | undefined;
109
116
  #lastCopiedText: string | undefined;
110
117
  #native: BtwHistoryNativeMemo | undefined;
118
+ /** The last answer-pane scroll key, forwarded natively (see {@link NativeNode.scroll}). */
119
+ #scroll: NativeScroll | undefined;
111
120
  readonly #detail = new ScrollView([], {
112
121
  height: 1,
113
122
  scrollbar: "auto",
@@ -159,6 +168,7 @@ export class BtwHistoryPanel implements Component, Focusable {
159
168
  this.#options = options;
160
169
  this.#records = options.records;
161
170
  this.#selectedId = options.records[0]?.id;
171
+ if (options.records.length === 1) this.#focus = "answer";
162
172
  }
163
173
 
164
174
  get focused(): boolean {
@@ -187,6 +197,8 @@ export class BtwHistoryPanel implements Component, Focusable {
187
197
  this.#followLatest = false;
188
198
  this.#detail.scrollToTop();
189
199
  }
200
+ // One side question has nothing to pick: its answer keeps the arrow keys.
201
+ if (records.length === 1) this.#focus = "answer";
190
202
  this.#options.requestRender();
191
203
  }
192
204
 
@@ -247,6 +259,26 @@ export class BtwHistoryPanel implements Component, Focusable {
247
259
  return true;
248
260
  }
249
261
 
262
+ /** Select a record with its answer pane focused, read from the top (a just-asked /btw). */
263
+ showRecord(recordId: string): void {
264
+ const index = this.#records.findIndex(record => record.id === recordId);
265
+ if (index === -1) return;
266
+ this.#select(index);
267
+ this.#focus = "answer";
268
+ this.#options.requestRender();
269
+ }
270
+
271
+ /**
272
+ * Jump the answer pane to its bottom once (opening or sending a follow-up):
273
+ * text mode keeps following the newest row at render until a scroll key;
274
+ * natively the sheet body scrolls to its end. Tern reports no wheel scroll,
275
+ * so streamed updates never re-pin it there and a reader is never yanked.
276
+ */
277
+ #jumpToEnd(): void {
278
+ this.#followLatest = true;
279
+ this.#scroll = { by: "end", n: (this.#scroll?.n ?? 0) + 1 };
280
+ }
281
+
250
282
  #openComposer(record: BtwHistoryRecord): void {
251
283
  const input = new Input();
252
284
  input.prompt = theme.fg("accent", "Follow up: ");
@@ -261,6 +293,8 @@ export class BtwHistoryPanel implements Component, Focusable {
261
293
  };
262
294
  this.#composer = composer;
263
295
  this.#focus = "answer";
296
+ // Following up reads from the newest turn, right above the composer.
297
+ this.#jumpToEnd();
264
298
  this.#options.requestRender();
265
299
  }
266
300
 
@@ -286,7 +320,7 @@ export class BtwHistoryPanel implements Component, Focusable {
286
320
  this.#composer = undefined;
287
321
  this.#selectedId = composer.recordId;
288
322
  this.#focus = "answer";
289
- this.#followLatest = true;
323
+ this.#jumpToEnd();
290
324
  } else {
291
325
  composer.notice = `Follow-up was not started. Your draft is kept; ${editorKey("tui.input.submit")} to retry.`;
292
326
  }
@@ -314,11 +348,17 @@ export class BtwHistoryPanel implements Component, Focusable {
314
348
  return;
315
349
  }
316
350
  const record = this.#selected();
351
+ const running = record !== undefined && getBtwLatestTurn(record).status === "running" ? record : undefined;
352
+ const hides = this.#options.escapeHides === true;
317
353
  if (matchesSelectCancel(data) || matchesKey(data, "escape")) {
318
- if (record && getBtwLatestTurn(record).status === "running") this.#options.onCancel(record);
354
+ if (running && !hides) this.#options.onCancel(running);
319
355
  else this.#options.onClose();
320
356
  return;
321
357
  }
358
+ if (hides && matchesKey(data, "x")) {
359
+ if (running) this.#options.onCancel(running);
360
+ return;
361
+ }
322
362
  if (matchesKey(data, "f") || matchesKey(data, "enter")) {
323
363
  if (record) this.openFollowUp(record.id);
324
364
  return;
@@ -335,11 +375,12 @@ export class BtwHistoryPanel implements Component, Focusable {
335
375
  matchesKey(data, "ctrl+_") ||
336
376
  data === String.fromCharCode(31)
337
377
  ) {
338
- this.#focus = this.#focus === "list" ? "answer" : "list";
378
+ // One side question has nothing to pick: its answer keeps the arrow keys.
379
+ if (this.#records.length > 1) this.#focus = this.#focus === "list" ? "answer" : "list";
339
380
  } else if (matchesKey(data, "right")) {
340
381
  this.#focus = "answer";
341
382
  } else if (matchesKey(data, "left")) {
342
- this.#focus = "list";
383
+ if (this.#records.length > 1) this.#focus = "list";
343
384
  } else if (this.#focus === "list") {
344
385
  const index = this.#selectedIndex();
345
386
  if (matchesSelectUp(data)) this.#select(index - 1);
@@ -350,11 +391,25 @@ export class BtwHistoryPanel implements Component, Focusable {
350
391
  else if (matchesKey(data, "end")) this.#select(this.#records.length - 1);
351
392
  else return;
352
393
  } else {
353
- if (matchesSelectUp(data)) this.#detail.scroll(-1);
354
- else if (matchesSelectDown(data)) this.#detail.scroll(1);
355
- else if (matchesSelectPageUp(data)) this.#detail.page(-1);
356
- else if (matchesSelectPageDown(data)) this.#detail.page(1);
394
+ const by: TspScrollBy | undefined = matchesSelectUp(data)
395
+ ? "line-up"
396
+ : matchesSelectDown(data)
397
+ ? "line-down"
398
+ : matchesSelectPageUp(data)
399
+ ? "page-up"
400
+ : matchesSelectPageDown(data)
401
+ ? "page-down"
402
+ : matchesKey(data, "home")
403
+ ? "start"
404
+ : matchesKey(data, "end")
405
+ ? "end"
406
+ : undefined;
407
+ if (by === "line-up") this.#detail.scroll(-1);
408
+ else if (by === "line-down") this.#detail.scroll(1);
409
+ else if (by === "page-up") this.#detail.page(-1);
410
+ else if (by === "page-down") this.#detail.page(1);
357
411
  else if (!this.#detail.handleScrollKey(data)) return;
412
+ if (by) this.#scroll = { by, n: (this.#scroll?.n ?? 0) + 1 };
358
413
  this.#followLatest = matchesKey(data, "end");
359
414
  }
360
415
  this.#options.requestRender();
@@ -378,31 +433,39 @@ export class BtwHistoryPanel implements Component, Focusable {
378
433
  memo.notice === composer?.notice &&
379
434
  memo.followUpPending === this.#followUpPending &&
380
435
  memo.canFollowUp === canFollowUp &&
381
- memo.copied === copied
436
+ memo.copied === copied &&
437
+ memo.scroll === this.#scroll
382
438
  ) {
383
439
  return memo.node;
384
440
  }
385
- const children: NativeChild[] = [
386
- node(
387
- "row",
388
- { gap: "md", align: "start" },
389
- [
441
+ // Plain columns, not `section`s: Tern draws a section as a foldable `##` heading,
442
+ // and a flex item's min-content width (a long code line in the answer) would
443
+ // crush the list pane. The answer pane may shrink to nothing; the list may not.
444
+ // With one side question the list and the pane labels are noise: the answer alone.
445
+ const showList = this.#records.length > 1;
446
+ const detail = this.#describeDetail(record, copied);
447
+ const answerPane: NativeNode = {
448
+ ...node(
449
+ "col",
450
+ { gap: "sm", grow: 1, basis: 0, min: { w: 0 } },
451
+ showList ? [text(this.#describeFocusLabel("answer"), { truncate: "end", lines: 1 }), detail] : [detail],
452
+ "details",
453
+ ),
454
+ // Natively the sheet body is the scroller; arrow/page keys on the answer pane move it.
455
+ scroll: this.#scroll,
456
+ };
457
+ const panes: NativeChild[] = showList
458
+ ? [
390
459
  node(
391
- "section",
392
- { head: this.#describeFocusLabel("list"), basis: 0.42, max: { w: "46ch" } },
393
- [this.#describeList()],
460
+ "col",
461
+ { gap: "sm", shrink: 0, basis: 0.3, max: { w: "40ch" } },
462
+ [text(this.#describeFocusLabel("list"), { truncate: "end", lines: 1 }), this.#describeList()],
394
463
  "history",
395
464
  ),
396
- node(
397
- "section",
398
- { head: this.#describeFocusLabel("answer"), grow: 1 },
399
- [this.#describeDetail(record, copied)],
400
- "details",
401
- ),
402
- ],
403
- "body",
404
- ),
405
- ];
465
+ answerPane,
466
+ ]
467
+ : [answerPane];
468
+ const children: NativeChild[] = [node("row", { gap: "lg", align: "start" }, panes, "body")];
406
469
  if (composer) {
407
470
  composer.input.focused = this.#focused;
408
471
  const lines: NativeChild[] = [
@@ -427,6 +490,7 @@ export class BtwHistoryPanel implements Component, Focusable {
427
490
  followUpPending: this.#followUpPending,
428
491
  canFollowUp,
429
492
  copied,
493
+ scroll: this.#scroll,
430
494
  node: described,
431
495
  };
432
496
  return described;
@@ -492,10 +556,21 @@ export class BtwHistoryPanel implements Component, Focusable {
492
556
  const answer = turn.answer.trim()
493
557
  ? md(sanitizeDisplayText(turn.answer), { stream: turn.status === "running" })
494
558
  : text([span(turn.status === "running" ? "Waiting for response…" : "No answer text.", "dim")]);
559
+ // Plain labels, not `section`s (Tern draws those as foldable `##` headings);
560
+ // the question stays markdown so its inline code and links render.
495
561
  const children: NativeChild[] = [
496
- text([span(`${status.label} · ${this.#dateFormat.format(turn.createdAt)}`, status.color)], { wrap: "word" }),
497
- node("section", { head: [span("Question", "accent strong")] }, [md(sanitizeDisplayText(turn.question))]),
498
- node("section", { head: [span("Answer", "accent strong")] }, [answer]),
562
+ text(
563
+ [
564
+ span("Question", "accent strong"),
565
+ span(" · ", "dim"),
566
+ span(status.label, status.color),
567
+ span(` · ${this.#dateFormat.format(turn.createdAt)}`, "dim"),
568
+ ],
569
+ { wrap: "word" },
570
+ ),
571
+ md(sanitizeDisplayText(turn.question)),
572
+ text([span("Answer", "accent strong")]),
573
+ answer,
499
574
  ];
500
575
  if (turn.error) children.push(text([span(sanitizeErrorLine(turn.error), "error")], { wrap: "word" }));
501
576
  if (turn.status === "interrupted") children.push(text([span("Not resumed in this view.", "muted")]));
@@ -510,9 +585,11 @@ export class BtwHistoryPanel implements Component, Focusable {
510
585
  ]);
511
586
  }
512
587
  const latest = record ? getBtwLatestTurn(record) : undefined;
588
+ const running = latest?.status === "running";
513
589
  const hints: (NativeHint | undefined)[] = [
514
- actionHint("tui.select.cancel", latest?.status === "running" ? "cancel" : "close"),
515
- { keys: ["tab", "ctrl+/"], label: "switch pane" },
590
+ actionHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
591
+ running && this.#options.escapeHides ? { keys: ["x"], label: "cancel" } : undefined,
592
+ this.#records.length > 1 ? { keys: ["tab", "ctrl+/"], label: "switch pane" } : undefined,
516
593
  actionHint(["tui.select.up", "tui.select.down"], this.#focus === "list" ? "select" : "scroll"),
517
594
  ];
518
595
  if (canFollowUp) hints.push({ keys: ["f", "enter"], label: "follow up" });
@@ -689,14 +766,16 @@ export class BtwHistoryPanel implements Component, Focusable {
689
766
  const record = this.#selected();
690
767
  const composer = this.#composer;
691
768
  const latest = record ? getBtwLatestTurn(record) : undefined;
769
+ const running = latest?.status === "running";
692
770
  const actions = composer
693
771
  ? [
694
772
  keyHint("tui.input.submit", this.#followUpPending ? "starting…" : "send"),
695
773
  keyHint("tui.select.cancel", "cancel"),
696
774
  ]
697
775
  : [
698
- keyHint("tui.select.cancel", latest?.status === "running" ? "cancel" : "close"),
699
- rawKeyHint(["tab", "ctrl+/"], "switch pane"),
776
+ keyHint("tui.select.cancel", running && !this.#options.escapeHides ? "cancel" : "close"),
777
+ ...(running && this.#options.escapeHides ? [rawKeyHint("x", "cancel")] : []),
778
+ ...(this.#records.length > 1 ? [rawKeyHint(["tab", "ctrl+/"], "switch pane")] : []),
700
779
  ];
701
780
  if (!composer) {
702
781
  if (record && this.#canFollowUp(record)) actions.push(rawKeyHint(["f", "enter"], "follow up"));
@@ -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. */