@oh-my-pi/pi-tui 18.4.4 → 18.4.6

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/dist/types/apps/git/sidebar.d.ts +2 -2
  3. package/dist/types/chat/recap-notice.d.ts +13 -0
  4. package/dist/types/components/editor.d.ts +15 -3
  5. package/dist/types/components/input.d.ts +11 -1
  6. package/dist/types/native/blobs.d.ts +1 -1
  7. package/dist/types/native/node.d.ts +63 -1
  8. package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
  9. package/dist/types/overlays/ask-dialog.d.ts +9 -5
  10. package/dist/types/overlays/copy-selector.d.ts +0 -23
  11. package/dist/types/overlays/error-banner.d.ts +5 -2
  12. package/dist/types/overlays/hook-editor.d.ts +12 -0
  13. package/dist/types/overlays/jobs-panel.d.ts +25 -1
  14. package/dist/types/overlays/model-hub.d.ts +2 -0
  15. package/dist/types/overlays/rewind-selector.d.ts +17 -4
  16. package/dist/types/prompt/custom-editor.d.ts +16 -8
  17. package/dist/types/prompt/welcome.d.ts +3 -3
  18. package/dist/types/render/render-utils.d.ts +5 -0
  19. package/dist/types/status-line/component.d.ts +2 -0
  20. package/dist/types/status-line/host.d.ts +2 -0
  21. package/dist/types/status-line/metrics.d.ts +4 -0
  22. package/dist/types/status-line/types.d.ts +13 -2
  23. package/dist/types/tools/edit.d.ts +2 -0
  24. package/dist/types/tools/task.d.ts +7 -0
  25. package/dist/types/tui.d.ts +8 -1
  26. package/package.json +9 -9
  27. package/src/apps/git/sidebar.ts +15 -2
  28. package/src/chat/assistant-message.ts +2 -2
  29. package/src/chat/recap-notice.ts +30 -0
  30. package/src/chat/tool-execution.ts +19 -3
  31. package/src/chat/user-message.ts +2 -1
  32. package/src/components/editor.ts +115 -16
  33. package/src/components/input.ts +57 -1
  34. package/src/native/backend.ts +28 -10
  35. package/src/native/blobs.ts +1 -1
  36. package/src/native/encode.ts +1 -0
  37. package/src/native/node.ts +108 -1
  38. package/src/native/reconcile.ts +5 -4
  39. package/src/overlays/agent-hub-projection.ts +25 -0
  40. package/src/overlays/agent-hub.ts +4 -15
  41. package/src/overlays/ask-dialog.ts +20 -32
  42. package/src/overlays/copy-selector.ts +6 -6
  43. package/src/overlays/error-banner.ts +14 -3
  44. package/src/overlays/hook-editor.ts +59 -6
  45. package/src/overlays/jobs-panel.ts +132 -49
  46. package/src/overlays/model-browser.ts +56 -12
  47. package/src/overlays/model-hub.ts +82 -29
  48. package/src/overlays/rewind-selector.ts +235 -332
  49. package/src/overlays/session-selector.ts +19 -20
  50. package/src/prompt/custom-editor.ts +119 -30
  51. package/src/prompt/welcome.ts +4 -33
  52. package/src/render/render-utils.ts +51 -3
  53. package/src/status-line/component.ts +61 -30
  54. package/src/status-line/host.ts +2 -0
  55. package/src/status-line/metrics.ts +36 -5
  56. package/src/status-line/segments.ts +9 -1
  57. package/src/status-line/types.ts +13 -2
  58. package/src/terminal.ts +27 -6
  59. package/src/tools/edit.ts +23 -0
  60. package/src/tools/eval.ts +43 -11
  61. package/src/tools/grouped-file-output.ts +6 -0
  62. package/src/tools/task.ts +29 -4
  63. package/src/tui.ts +9 -1
@@ -209,10 +209,17 @@ export interface AgentProgress {
209
209
  lastIntent?: string;
210
210
  currentTool?: string;
211
211
  currentToolArgs?: string;
212
+ /** Argument key selected for the display preview, when known. */
213
+ currentToolArgsKey?: string;
214
+ /** Intent the model attached to the current call; undefined when that call carried none. */
215
+ currentToolIntent?: string;
212
216
  currentToolStartMs?: number;
213
217
  recentTools: Array<{
214
218
  tool: string;
215
219
  args: string;
220
+ argsKey?: string;
221
+ intent?: string;
222
+ isError?: boolean;
216
223
  endMs: number;
217
224
  }>;
218
225
  recentOutput: string[];
@@ -1,6 +1,6 @@
1
1
  import type { TspFrame, TspNode, TspText } from "@oh-my-pi/pi-wire";
2
2
  import { ImageBudget } from "./components/image.js";
3
- import type { DescribeContext, NativeNode, NativeUiEvent } from "./native/node.js";
3
+ import type { DescribeContext, NativeNode, NativeScreen, NativeUiEvent } from "./native/node.js";
4
4
  import { type Terminal } from "./terminal.js";
5
5
  import { visibleWidth } from "./utils.js";
6
6
  /** Full-attribute reset terminating each rendered content row. */
@@ -138,6 +138,13 @@ export interface Component {
138
138
  * `cx.supports("picker")`, so older terminals keep the wrapped fallback.
139
139
  */
140
140
  nativeSheet?(cx: DescribeContext): boolean;
141
+ /**
142
+ * A fullscreen overlay that is a page of its own rather than one block:
143
+ * the regions (and role) of the screen surface it fills, instead of the
144
+ * default lone `main` child. Put the component itself in `dock` to keep
145
+ * its focus target and events.
146
+ */
147
+ describeScreen?(cx: DescribeContext): NativeScreen;
141
148
  /** User actions on nodes this component described (toggle, select, activate, custom). */
142
149
  handleNativeEvent?(event: NativeUiEvent): void;
143
150
  /**
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.4.4",
4
+ "version": "18.4.6",
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.4.4",
45
- "@oh-my-pi/pi-agent-core": "18.4.4",
46
- "@oh-my-pi/pi-ai": "18.4.4",
47
- "@oh-my-pi/pi-catalog": "18.4.4",
48
- "@oh-my-pi/pi-natives": "18.4.4",
49
- "@oh-my-pi/pi-utils": "18.4.4",
50
- "@oh-my-pi/pi-wire": "18.4.4",
51
- "@oh-my-pi/snapcompact": "18.4.4"
44
+ "@oh-my-pi/omptype": "18.4.6",
45
+ "@oh-my-pi/pi-agent-core": "18.4.6",
46
+ "@oh-my-pi/pi-ai": "18.4.6",
47
+ "@oh-my-pi/pi-catalog": "18.4.6",
48
+ "@oh-my-pi/pi-natives": "18.4.6",
49
+ "@oh-my-pi/pi-utils": "18.4.6",
50
+ "@oh-my-pi/pi-wire": "18.4.6",
51
+ "@oh-my-pi/snapcompact": "18.4.6"
52
52
  },
53
53
  "devDependencies": {
54
54
  "kitty-vt-wasm": "^0.2.0"
@@ -1003,10 +1003,23 @@ export class Sidebar {
1003
1003
  * Pointer actions on the changes sheet: list clicks select (a folder also
1004
1004
  * folds, a second click on the selected file stages it, as the ANSI click
1005
1005
  * does), double clicks activate, the segmented control switches Path/Tree,
1006
- * and the quiet buttons run their row's action. False when not the
1007
- * sidebar's event.
1006
+ * and the quiet buttons run their row's action; selection edits go to the
1007
+ * text field being edited. False when not the sidebar's event.
1008
1008
  */
1009
1009
  handleNativeEvent(event: NativeUiEvent): boolean {
1010
+ if (event.type === "edit") {
1011
+ const editing = this.editing ? this.selected?.kind : undefined;
1012
+ const field =
1013
+ editing === "summary"
1014
+ ? this.summary.input
1015
+ : editing === "stage-ai-input"
1016
+ ? this.aiInput.input
1017
+ : editing === "description"
1018
+ ? this.description
1019
+ : undefined;
1020
+ field?.applyHostEdit(event);
1021
+ return field !== undefined;
1022
+ }
1010
1023
  if (event.type === "select" || event.type === "activate") {
1011
1024
  if (event.item === "path" || event.item === "tree") {
1012
1025
  this.viewStyle = event.item;
@@ -890,8 +890,8 @@ export class AssistantMessageComponent extends Container {
890
890
  * Turn-ending error, recovered-retry note or abort label. A failed request
891
891
  * is one error frame (head: "Request failed" + the HTTP status chip; body:
892
892
  * the message once; then Retry / Copy error / Switch model), and stays in
893
- * the transcript: the pinned banner is ANSI-only. A recovered attempt is an
894
- * inline row that discloses the original error.
893
+ * the transcript: unlike ANSI, the pinned banner does not hide it natively.
894
+ * A recovered attempt is an inline row that discloses the original error.
895
895
  */
896
896
  #describeError(message: AssistantMessage): NativeNode | undefined {
897
897
  const presentation = resolveAssistantErrorPresentation(message);
@@ -0,0 +1,30 @@
1
+ import { Spacer } from "../components/spacer";
2
+ import { Text } from "../components/text";
3
+ import { node, text } from "../native/describe";
4
+ import type { NativeNode } from "../native/node";
5
+ import { theme } from "../theme";
6
+ import { Container } from "../tui";
7
+
8
+ /**
9
+ * The idle recap (`recap.*`): where the session stands, written while the user
10
+ * was away. Stays in the transcript like a thought: a dim italic `※ recap:`
11
+ * line in ANSI, a history icon beside the italic thought-coloured text in a
12
+ * native terminal (`omp.recap`).
13
+ */
14
+ export class RecapNotice extends Container {
15
+ readonly #recap: string;
16
+
17
+ constructor(recap: string) {
18
+ super();
19
+ this.#recap = recap;
20
+ this.addChild(new Spacer(1));
21
+ this.addChild(new Text(`※ recap: ${recap}`, 1, 0).setStyleFn(line => theme.fg("dim", theme.italic(line))));
22
+ }
23
+
24
+ override describe(): NativeNode {
25
+ return node("row", { gap: "sm", align: "start", role: "omp.recap", title: "Recap while you were away" }, [
26
+ node("icon", { name: "history" }, undefined, "icon"),
27
+ text(this.#recap, { wrap: "word" }),
28
+ ]);
29
+ }
30
+ }
@@ -97,6 +97,11 @@ function isEditLikeToolName(toolName: string): boolean {
97
97
  return toolName === "edit" || toolName === "apply_patch";
98
98
  }
99
99
 
100
+ /** Tools whose arguments stream a growing file body or diff into the card (edit, apply_patch, write). */
101
+ function streamsBody(toolName: string): boolean {
102
+ return isEditLikeToolName(toolName) || toolName === "write";
103
+ }
104
+
100
105
  function resolveEditModeForTool(toolName: string, tool: AgentTool | undefined): EditMode | undefined {
101
106
  if (toolName === "apply_patch") return "apply_patch";
102
107
  if (toolName !== "edit") return undefined;
@@ -652,7 +657,7 @@ export class ToolExecutionComponent extends Container {
652
657
  // them. Todo snapshots and detached background tool progress are deliberate
653
658
  // static exceptions because their rows can be superseded or committed to
654
659
  // scrollback while later updates continue elsewhere.
655
- const isStreamingArgs = !this.#argsComplete && (isEditLikeToolName(this.#toolName) || this.#toolName === "write");
660
+ const isStreamingArgs = !this.#argsComplete && streamsBody(this.#toolName);
656
661
  const isBackgroundAsyncRunning =
657
662
  (this.#result?.details as { async?: { state?: string } } | undefined)?.async?.state === "running";
658
663
  const renderer = this.#renderer;
@@ -934,6 +939,17 @@ export class ToolExecutionComponent extends Container {
934
939
  return Math.max(0, Math.round((ended ?? performance.now()) - started));
935
940
  }
936
941
 
942
+ /**
943
+ * The native `collapsed` prop: the transcript's expand state, except that a
944
+ * call streaming its body (edit, write) stays open until it settles, then
945
+ * folds like a finished thought. The user's own toggle still wins meanwhile:
946
+ * the terminal keeps local collapse state until this prop changes.
947
+ */
948
+ #nativeCollapsed(status: TspCardStatus): boolean {
949
+ const live = status === "pending" || status === "running";
950
+ return !this.#expanded && !(live && streamsBody(this.#toolName));
951
+ }
952
+
937
953
  /** The late diagnostics section and head chip, when LSP reported after the result. */
938
954
  #lateDiagnosticsParts(): { section?: NativeNode; chip?: { text: string; tone: TspTone } } {
939
955
  const files = this.#lateDiagnostics;
@@ -992,7 +1008,7 @@ export class ToolExecutionComponent extends Container {
992
1008
  intent: typeof intent === "string" && intent ? plainText(intent) : undefined,
993
1009
  frame: inline ? "inline" : "card",
994
1010
  collapsible: hasBody,
995
- collapsed: hasBody ? !this.#expanded : undefined,
1011
+ collapsed: hasBody ? this.#nativeCollapsed(status) : undefined,
996
1012
  preview: hasBody ? (preview === "auto" ? { lines: DEFAULT_TERMINAL_PREVIEW_LINES } : preview) : undefined,
997
1013
  tools: view.tools,
998
1014
  tone: view.tone,
@@ -1060,7 +1076,7 @@ export class ToolExecutionComponent extends Container {
1060
1076
  tone: view.tone ?? NATIVE_STATUS_TONE[status],
1061
1077
  status,
1062
1078
  collapsible: hasBody,
1063
- collapsed: hasBody ? !this.#expanded : undefined,
1079
+ collapsed: hasBody ? this.#nativeCollapsed(status) : undefined,
1064
1080
  preview: hasBody ? cardPreview(view.preview) : undefined,
1065
1081
  },
1066
1082
  children,
@@ -222,7 +222,8 @@ export class UserMessageComponent extends Container implements ReactionTarget {
222
222
  if (!image.mimeType.startsWith("image/")) return;
223
223
  const label = `#${i + 1}`;
224
224
  const link = this.#imageLinks?.[i];
225
- const open = link ? { href: link, actions: { click: "open" } } : {};
225
+ // A click zooms the image in the terminal; the file opens from its context menu.
226
+ const open = link ? { href: link, actions: { menu: ["open"] } } : {};
226
227
  thumbs.push(base64ImageNode(image.data, image.mimeType, { alt: label, title: label, ...open }, label));
227
228
  });
228
229
  }
@@ -18,7 +18,14 @@ import type { TspEditorDecoration, TspEditorProps, TspTone } from "@oh-my-pi/pi-
18
18
  import { col, node } from "../native/describe";
19
19
  import { sameItems, sameProps } from "../native/memo";
20
20
  import { plainText } from "../native/spans";
21
- import type { DescribeContext, NativeChild, NativeNode } from "../native/node";
21
+ import {
22
+ type DescribeContext,
23
+ type NativeChild,
24
+ type NativeNode,
25
+ type NativeTextEdit,
26
+ type NativeUiEvent,
27
+ resolveTextEdit,
28
+ } from "../native/node";
22
29
  import type { SymbolTheme } from "../symbols";
23
30
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
24
31
  import {
@@ -614,11 +621,12 @@ export class Editor implements Component, Focusable {
614
621
  * TSP chrome around the `editor` node (the prompt composer's chips, mode
615
622
  * chip, send/stop): the root role, the children with `input` placed where
616
623
  * the layout wants it, and the input's keypath for the caret-anchored
617
- * autocomplete. Unset: a plain `omp.editor` column over the input.
624
+ * autocomplete. Unset: a plain `omp.field` column over the input (a
625
+ * dialog's text field; only the prompt composer claims `omp.editor`).
618
626
  */
619
627
  describeLayout: ((input: NativeNode, cx: DescribeContext) => NativeEditorLayout) | undefined;
620
628
  /** TSP placeholder for the empty buffer; natively it replaces the rotating ANSI {@link placeholder} hints. */
621
- nativePlaceholder: string | undefined;
629
+ describePlaceholder: (() => string) | undefined;
622
630
  #promptGutter: string | undefined;
623
631
  /** Bumped by {@link invalidate}: host-side decoration inputs (spelling results, settings) changed. */
624
632
  #nativeGeneration = 0;
@@ -629,6 +637,14 @@ export class Editor implements Component, Focusable {
629
637
 
630
638
  // Store last layout width for cursor navigation
631
639
  #lastLayoutWidth: number = 80;
640
+ /**
641
+ * Width the caret's rows wrap at for Up/Down and paging: the painted layout width in
642
+ * text mode. Natively it is unbounded (rows are logical lines): the terminal wraps with
643
+ * its own font and moves the caret between the rows it drew itself, handing over only
644
+ * Up on the first row and Down on the last (TSP §8.5, native editing), which are then
645
+ * on the first and last logical line.
646
+ */
647
+ #caretRowWidth: number = 80;
632
648
  // Line measurement + word-wrap cache shared by #layoutText,
633
649
  // #buildVisualLineMap, and key handlers within a frame. Line text is a
634
650
  // sound key (strings are immutable); cleared on layout-width or
@@ -1025,13 +1041,13 @@ export class Editor implements Component, Focusable {
1025
1041
  }
1026
1042
 
1027
1043
  #isOnFirstVisualLine(): boolean {
1028
- const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
1044
+ const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
1029
1045
  const currentVisualLine = this.#findCurrentVisualLine(visualLines);
1030
1046
  return currentVisualLine === 0;
1031
1047
  }
1032
1048
 
1033
1049
  #isOnLastVisualLine(): boolean {
1034
- const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
1050
+ const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
1035
1051
  const currentVisualLine = this.#findCurrentVisualLine(visualLines);
1036
1052
  return currentVisualLine === visualLines.length - 1;
1037
1053
  }
@@ -1321,6 +1337,7 @@ export class Editor implements Component, Focusable {
1321
1337
  const contentAreaWidth = this.#getContentWidth(width, paddingX);
1322
1338
  const layoutWidth = this.#getLayoutWidth(width, paddingX);
1323
1339
  this.#lastLayoutWidth = layoutWidth;
1340
+ this.#caretRowWidth = layoutWidth;
1324
1341
 
1325
1342
  const box = this.#theme.symbols.boxRound;
1326
1343
  const borderWidth = this.#getHorizontalChromeWidth(paddingX);
@@ -1619,9 +1636,10 @@ export class Editor implements Component, Focusable {
1619
1636
  * caret moves change only `text` and `cursor` on the same node.
1620
1637
  */
1621
1638
  describe(cx: DescribeContext): NativeNode {
1622
- // Vertical caret motion walks visual rows; the terminal owns the real
1623
- // wrap, so the surface width is the closest available layout width.
1639
+ // The terminal owns the wrap: the surface width only estimates whether a
1640
+ // recalled history entry spans rows, and caret rows are logical lines.
1624
1641
  this.#lastLayoutWidth = Math.max(1, cx.cols);
1642
+ this.#caretRowWidth = Number.POSITIVE_INFINITY;
1625
1643
  const { lines, cursorLine, cursorCol } = this.#state;
1626
1644
  const text = lines.join("\n");
1627
1645
  let offset = 0;
@@ -1646,8 +1664,8 @@ export class Editor implements Component, Focusable {
1646
1664
 
1647
1665
  const placeholder = this.#getPlaceholder();
1648
1666
  const nativePlaceholder =
1649
- this.nativePlaceholder !== undefined && !this.#autocompleteState && this.#isEditorEmpty()
1650
- ? this.nativePlaceholder
1667
+ this.describePlaceholder !== undefined && !this.#autocompleteState && this.#isEditorEmpty()
1668
+ ? this.describePlaceholder()
1651
1669
  : placeholder && plainText(placeholder).trim();
1652
1670
  const atLineEnd = cursorCol >= (lines[cursorLine]?.length ?? 0);
1653
1671
  const ghost = nativePlaceholder || !atLineEnd ? null : this.#getInlineHint();
@@ -1665,7 +1683,7 @@ export class Editor implements Component, Focusable {
1665
1683
  anchor = anchorOffset + vim.anchor.col;
1666
1684
  }
1667
1685
  }
1668
- const prompt = this.#promptGutter === undefined ? "" : plainText(this.#promptGutter);
1686
+ // The prompt gutter (`> `) is terminal chrome: native hosts draw their own field, so it stays out.
1669
1687
  const props: TspEditorProps = {
1670
1688
  text,
1671
1689
  cursor,
@@ -1673,7 +1691,6 @@ export class Editor implements Component, Focusable {
1673
1691
  decor: decor.decor,
1674
1692
  ghost: ghost ? plainText(ghost) : undefined,
1675
1693
  placeholder: nativePlaceholder || undefined,
1676
- prompt: prompt || undefined,
1677
1694
  mode: vim ? VIM_MODE_LABELS[vim.mode] : undefined,
1678
1695
  lang: this.describeLanguage?.(),
1679
1696
  maxLines: this.#maxHeight,
@@ -1685,7 +1702,7 @@ export class Editor implements Component, Focusable {
1685
1702
  }
1686
1703
 
1687
1704
  const layout = this.describeLayout?.(editor.node, cx) ?? {
1688
- role: "omp.editor",
1705
+ role: "omp.field",
1689
1706
  children: [editor.node],
1690
1707
  caret: "input",
1691
1708
  };
@@ -1949,8 +1966,14 @@ export class Editor implements Component, Focusable {
1949
1966
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1950
1967
  // A narrowed `@` list can be empty while its refresh is pending; Enter
1951
1968
  // then submits instead of waiting on the search.
1952
- if (!selected || !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1953
- // Autocomplete is stale - cancel and fall through to normal submission
1969
+ if (
1970
+ !selected ||
1971
+ !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
1972
+ this.#selectedSlashArgumentIsAlreadyTyped(selected)
1973
+ ) {
1974
+ // Stale, or accepting would only append whitespace to an already fully
1975
+ // typed slash-command argument (`/mcp list` + Enter): cancel and fall
1976
+ // through to normal submission instead of swallowing the keypress.
1954
1977
  this.#cancelAutocomplete();
1955
1978
  } else {
1956
1979
  if (selected && this.#autocompleteProvider) {
@@ -2839,6 +2862,63 @@ export class Editor implements Component, Focusable {
2839
2862
  this.#insertTextAtCursor(text);
2840
2863
  }
2841
2864
 
2865
+ /** Terminal-side selection edits on the `editor` node (see {@link applyHostEdit}). */
2866
+ handleNativeEvent(event: NativeUiEvent): void {
2867
+ if (event.type === "edit") this.applyHostEdit(event);
2868
+ }
2869
+
2870
+ /**
2871
+ * Apply an edit the terminal made over its own selection (TSP `edit`):
2872
+ * replace `[from, to)` of the described text with `text` and put the caret
2873
+ * at `cursor`, as one undo unit that leaves history browsing and updates
2874
+ * autocomplete like typing. A range cutting through an atomic placeholder
2875
+ * token takes the whole token. Stale edits (`len` no longer the text's
2876
+ * length) are dropped; a pure caret move only moves the caret.
2877
+ */
2878
+ applyHostEdit(edit: NativeTextEdit): void {
2879
+ const current = this.getText();
2880
+ const resolved = resolveTextEdit(
2881
+ current,
2882
+ edit,
2883
+ text => this.#sanitizePastedText(text),
2884
+ (from, to) => this.#widenOverAtomicTokens(current, from, to),
2885
+ );
2886
+ if (!resolved) return;
2887
+ this.#resetKillSequence();
2888
+ if (resolved.changed) {
2889
+ this.#historyIndex = -1;
2890
+ this.#recordUndoState();
2891
+ this.#state.lines = resolved.text.split("\n");
2892
+ }
2893
+ const lines = this.#state.lines;
2894
+ let line = 0;
2895
+ let col = resolved.cursor;
2896
+ while (line < lines.length - 1 && col > lines[line]!.length) col -= lines[line++]!.length + 1;
2897
+ this.#state.cursorLine = line;
2898
+ this.#setCursorCol(col);
2899
+ if (!resolved.changed) return;
2900
+ this.#notifyChange(resolved.text);
2901
+ this.#retriggerAutocompleteAtCursor();
2902
+ }
2903
+
2904
+ /** Widen `[from, to)` of `text` so neither end cuts through an atomic placeholder token. */
2905
+ #widenOverAtomicTokens(text: string, from: number, to: number): { from: number; to: number } {
2906
+ const fromLine = from === 0 ? 0 : text.lastIndexOf("\n", from - 1) + 1;
2907
+ const fromEnd = text.indexOf("\n", from);
2908
+ const fromToken = this.#atomicTokenAt(
2909
+ text.slice(fromLine, fromEnd === -1 ? text.length : fromEnd),
2910
+ from - fromLine,
2911
+ );
2912
+ if (fromToken !== undefined) from = fromLine + fromToken.start;
2913
+ const toLine = to === 0 ? 0 : text.lastIndexOf("\n", to - 1) + 1;
2914
+ if (to > toLine) {
2915
+ const toEnd = text.indexOf("\n", to);
2916
+ const toToken = this.#atomicTokenAt(text.slice(toLine, toEnd === -1 ? text.length : toEnd), to - toLine - 1);
2917
+ if (toToken !== undefined) to = toLine + toToken.end;
2918
+ }
2919
+ return { from, to };
2920
+ }
2921
+
2842
2922
  /** Delete up to `count` characters immediately before the cursor on the current line.
2843
2923
  * Used to "track back" the auto-repeat spaces that the space-hold push-to-talk gesture
2844
2924
  * optimistically inserts before it recognizes the hold. Capped at the cursor column so it
@@ -3966,7 +4046,7 @@ export class Editor implements Component, Focusable {
3966
4046
 
3967
4047
  #moveCursor(deltaLine: number, deltaCol: number): void {
3968
4048
  this.#resetKillSequence();
3969
- const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
4049
+ const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
3970
4050
  const currentVisualLine = this.#findCurrentVisualLine(visualLines);
3971
4051
 
3972
4052
  if (deltaLine !== 0) {
@@ -4018,7 +4098,7 @@ export class Editor implements Component, Focusable {
4018
4098
 
4019
4099
  #pageScroll(direction: -1 | 1): void {
4020
4100
  this.#resetKillSequence();
4021
- const visualLines = this.#buildVisualLineMap(this.#lastLayoutWidth);
4101
+ const visualLines = this.#buildVisualLineMap(this.#caretRowWidth);
4022
4102
  const currentVisualLine = this.#findCurrentVisualLine(visualLines);
4023
4103
  const step = this.#getPageScrollStep(visualLines.length);
4024
4104
  const targetVisualLine = Math.max(0, Math.min(visualLines.length - 1, currentVisualLine + direction * step));
@@ -4212,6 +4292,25 @@ export class Editor implements Component, Focusable {
4212
4292
  return this.#autocompleteList?.getSelectedItem()?.value === SKILL_NAMESPACE;
4213
4293
  }
4214
4294
 
4295
+ /**
4296
+ * Whether the selected completion for a submitted slash command's argument
4297
+ * only restates what the user already typed (e.g. `list ` for `/mcp list`).
4298
+ * Accepting it would change nothing visible, so Enter should submit.
4299
+ *
4300
+ * A selection whose usage hint still names a required `<arg>` outside any
4301
+ * optional `[...]` group (e.g. `test` with `<name>`) keeps Enter's accept
4302
+ * role, so the user continues into the argument instead of submitting a
4303
+ * command the handler can only reject.
4304
+ */
4305
+ #selectedSlashArgumentIsAlreadyTyped(selected: AutocompleteItem): boolean {
4306
+ if (!this.#isInSubmittedSlashCommandContext()) return false;
4307
+ const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
4308
+ if (this.#state.cursorCol !== currentLine.length) return false;
4309
+ if (selected.hint?.replace(/\[[^\]]*\]/g, "").includes("<")) return false;
4310
+ const typed = this.#autocompletePrefix.trimEnd();
4311
+ return typed.length > 0 && selected.value.trimEnd() === typed;
4312
+ }
4313
+
4215
4314
  #isSlashCommandNameAutocompleteSelection(): boolean {
4216
4315
  if (this.#autocompleteState !== "regular") {
4217
4316
  return false;
@@ -6,7 +6,14 @@ import type { TspInputProps } from "@oh-my-pi/pi-wire";
6
6
  import { node } from "../native/describe";
7
7
  import { sameProps } from "../native/memo";
8
8
  import { plainText } from "../native/spans";
9
- import type { DescribeContext, NativeNode } from "../native/node";
9
+ import {
10
+ clampTextOffset,
11
+ type DescribeContext,
12
+ type NativeNode,
13
+ type NativeTextEdit,
14
+ type NativeUiEvent,
15
+ resolveTextEdit,
16
+ } from "../native/node";
10
17
  import { SpaceHoldGesture } from "../space-hold";
11
18
  import { type Component, CURSOR_MARKER, type Focusable } from "../tui";
12
19
  import { cursorColumnWindow } from "./scroll-viewport";
@@ -277,6 +284,55 @@ export class Input implements Component, Focusable {
277
284
  this.#handlePaste(text);
278
285
  }
279
286
 
287
+ /** Terminal-side selection edits on the `input` node (see {@link applyHostEdit}). */
288
+ handleNativeEvent(event: NativeUiEvent): void {
289
+ if (event.type === "edit") this.applyHostEdit(event);
290
+ }
291
+
292
+ /**
293
+ * Apply an edit the terminal made over its own selection (TSP `edit`):
294
+ * replace `[from, to)` with `text` (newlines stripped, as a paste) and put
295
+ * the caret at `cursor`, as one undo unit. Offsets are in the described
296
+ * text, so a masked field's count bullets, one per grapheme. Stale edits
297
+ * (`len` no longer the described length) are dropped.
298
+ */
299
+ applyHostEdit(edit: NativeTextEdit): void {
300
+ let valueEdit = edit;
301
+ if (this.mask) {
302
+ // Bullet offsets → value offsets through the grapheme starts.
303
+ const starts = Array.from(segmenter.segment(this.#value), grapheme => grapheme.index);
304
+ starts.push(this.#value.length);
305
+ const bullets = starts.length - 1;
306
+ if (edit.len !== bullets) return;
307
+ let from = clampTextOffset(edit.from, bullets);
308
+ let to = clampTextOffset(edit.to, bullets);
309
+ if (to < from) [from, to] = [to, from];
310
+ const text = typeof edit.text === "string" ? edit.text : "";
311
+ const cursor = clampTextOffset(edit.cursor, bullets - (to - from) + text.length);
312
+ const after = cursor - from - text.length;
313
+ valueEdit = {
314
+ from: starts[from]!,
315
+ to: starts[to]!,
316
+ text,
317
+ cursor:
318
+ cursor <= from
319
+ ? starts[cursor]!
320
+ : after >= 0
321
+ ? starts[from]! + text.length + starts[to + after]! - starts[to]!
322
+ : starts[from]! + cursor - from,
323
+ len: this.#value.length,
324
+ };
325
+ }
326
+ const resolved = resolveTextEdit(this.#value, valueEdit, toSingleLine);
327
+ if (!resolved) return;
328
+ this.#lastAction = null;
329
+ if (resolved.changed) {
330
+ this.#pushUndo();
331
+ this.#value = resolved.text;
332
+ }
333
+ this.#cursor = resolved.cursor;
334
+ }
335
+
280
336
  /** Programmatically trigger submission (e.g. for voice submit). */
281
337
  submit(): void {
282
338
  this.onSubmit?.(this.#value);
@@ -87,6 +87,8 @@ export interface NativeBackendOptions {
87
87
  const RECENT_FRAMES = 64;
88
88
  /** An unanswered frame older than this no longer holds rendering back. */
89
89
  const STALLED_ACK_MS = 5000;
90
+ /** Role of the session's surfaces; a screen page may name its own. */
91
+ const SESSION_ROLE = "omp.session";
90
92
 
91
93
  class NativeContext implements DescribeContext {
92
94
  cols: number;
@@ -148,6 +150,8 @@ export function assumedTspHello(terminal: Terminal): TspHello {
148
150
  class Surface {
149
151
  readonly id: string;
150
152
  readonly mode: "inline" | "screen";
153
+ /** The `o` role: `omp.session`, or a screen page's own (`NativeScreen.role`). */
154
+ readonly role: string;
151
155
  readonly reconciler: Reconciler;
152
156
  readonly doc: TspDocument | null;
153
157
  seq = 0;
@@ -158,9 +162,10 @@ class Surface {
158
162
  focus: string | null = null;
159
163
  dirty = false;
160
164
 
161
- constructor(id: string, mode: "inline" | "screen", mirror: boolean) {
165
+ constructor(id: string, mode: "inline" | "screen", role: string, mirror: boolean) {
162
166
  this.id = id;
163
167
  this.mode = mode;
168
+ this.role = role;
164
169
  this.reconciler = new Reconciler(id);
165
170
  this.doc = mirror ? new TspDocument(id) : null;
166
171
  }
@@ -353,7 +358,7 @@ export class NativeBackend {
353
358
  surface.acked = surface.seq;
354
359
  surface.focus = null;
355
360
  surface.dirty = false;
356
- this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: "omp.session", adopt: true });
361
+ this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: SESSION_ROLE, adopt: true });
357
362
  this.#sendPalette(surface);
358
363
  // After the `o`, as in `start()`.
359
364
  setNativeRendering(true);
@@ -408,13 +413,18 @@ export class NativeBackend {
408
413
  let dock: readonly NativeChild[];
409
414
  let layer: NativeChild[];
410
415
  if (fullscreen >= 0) {
411
- if (!this.#screen) {
412
- this.#screen = this.#newSurface("screen");
416
+ const component = overlays[fullscreen]!.component;
417
+ const page = component.describeScreen?.(this.#cx);
418
+ const role = page?.role ?? SESSION_ROLE;
419
+ if (this.#screen?.role !== role) {
420
+ // A page with another role is another surface: its styling keys off the `o`.
421
+ if (this.#screen) this.#close(this.#screen, false);
422
+ this.#screen = this.#newSurface("screen", role);
413
423
  this.#open(this.#screen);
414
424
  }
415
425
  surface = this.#screen;
416
- main = [overlays[fullscreen]!.component];
417
- dock = [];
426
+ main = page?.main ?? [component];
427
+ dock = page?.dock ?? [];
418
428
  layer = overlays.slice(fullscreen + 1).map(overlay => this.#overlayNode(overlay));
419
429
  } else {
420
430
  if (this.#screen) {
@@ -478,12 +488,12 @@ export class NativeBackend {
478
488
  this.#sawResize = false;
479
489
  }
480
490
 
481
- #newSurface(mode: "inline" | "screen"): Surface {
482
- return new Surface(`s:${this.#nextSurface++}`, mode, this.#mirror);
491
+ #newSurface(mode: "inline" | "screen", role = SESSION_ROLE): Surface {
492
+ return new Surface(`s:${this.#nextSurface++}`, mode, role, this.#mirror);
483
493
  }
484
494
 
485
495
  #open(surface: Surface): void {
486
- this.#write("o", { id: surface.id, mode: surface.mode, title: "omp", role: "omp.session" });
496
+ this.#write("o", { id: surface.id, mode: surface.mode, title: "omp", role: surface.role });
487
497
  this.#sendPalette(surface);
488
498
  }
489
499
 
@@ -611,12 +621,15 @@ export class NativeBackend {
611
621
  case "activate":
612
622
  case "action":
613
623
  case "change":
624
+ case "edit":
614
625
  this.#routeUiEvent(event);
615
626
  return;
616
627
  }
617
628
  }
618
629
 
619
- #routeUiEvent(event: Extract<TspEvent, { ev: "toggle" | "select" | "activate" | "action" | "change" }>): void {
630
+ #routeUiEvent(
631
+ event: Extract<TspEvent, { ev: "toggle" | "select" | "activate" | "action" | "change" | "edit" }>,
632
+ ): void {
620
633
  const reconciler = this.#surfaceFor(event.sf)?.reconciler;
621
634
  const target = reconciler?.target(event.id);
622
635
  if (!reconciler || !target?.component.handleNativeEvent) return;
@@ -644,6 +657,11 @@ export class NativeBackend {
644
657
  case "change":
645
658
  ui = { type: "change", key: target.keypath, item: event.item, value: event.value };
646
659
  break;
660
+ case "edit": {
661
+ const { from, to, text, cursor, len } = event;
662
+ ui = { type: "edit", key: target.keypath, from, to, text, cursor, len };
663
+ break;
664
+ }
647
665
  }
648
666
  target.component.handleNativeEvent(ui);
649
667
  this.#host.requestRender();
@@ -48,7 +48,7 @@ export function getNativeBlob(id: string): NativeBlob | undefined {
48
48
  export function base64ImageNode(
49
49
  data: string,
50
50
  mimeType: string,
51
- p?: Omit<TspProps<"image">, "blob" | "w" | "h">,
51
+ p?: Omit<TspProps<"image">, "blob" | "builtin" | "w" | "h">,
52
52
  key?: string,
53
53
  ): NativeNode {
54
54
  const blob = registerNativeBlob(Buffer.from(data, "base64"), mimeType);
@@ -165,6 +165,7 @@ const EVENT_REQUIRED: Readonly<Record<string, Readonly<Record<string, "string" |
165
165
  select: { id: "string", item: "string" },
166
166
  activate: { id: "string", item: "string" },
167
167
  action: { id: "string", act: "string" },
168
+ edit: { id: "string", from: "number", to: "number", text: "string", cursor: "number", len: "number" },
168
169
  error: { msg: "string" },
169
170
  gone: { ids: "array" },
170
171
  };