@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.
- package/CHANGELOG.md +46 -0
- package/dist/types/apps/git/sidebar.d.ts +2 -2
- package/dist/types/chat/recap-notice.d.ts +13 -0
- package/dist/types/components/editor.d.ts +15 -3
- package/dist/types/components/input.d.ts +11 -1
- package/dist/types/native/blobs.d.ts +1 -1
- package/dist/types/native/node.d.ts +63 -1
- package/dist/types/overlays/agent-hub-projection.d.ts +9 -0
- package/dist/types/overlays/ask-dialog.d.ts +9 -5
- package/dist/types/overlays/copy-selector.d.ts +0 -23
- package/dist/types/overlays/error-banner.d.ts +5 -2
- package/dist/types/overlays/hook-editor.d.ts +12 -0
- package/dist/types/overlays/jobs-panel.d.ts +25 -1
- package/dist/types/overlays/model-hub.d.ts +2 -0
- package/dist/types/overlays/rewind-selector.d.ts +17 -4
- package/dist/types/prompt/custom-editor.d.ts +16 -8
- package/dist/types/prompt/welcome.d.ts +3 -3
- package/dist/types/render/render-utils.d.ts +5 -0
- package/dist/types/status-line/component.d.ts +2 -0
- package/dist/types/status-line/host.d.ts +2 -0
- package/dist/types/status-line/metrics.d.ts +4 -0
- package/dist/types/status-line/types.d.ts +13 -2
- package/dist/types/tools/edit.d.ts +2 -0
- package/dist/types/tools/task.d.ts +7 -0
- package/dist/types/tui.d.ts +8 -1
- package/package.json +9 -9
- package/src/apps/git/sidebar.ts +15 -2
- package/src/chat/assistant-message.ts +2 -2
- package/src/chat/recap-notice.ts +30 -0
- package/src/chat/tool-execution.ts +19 -3
- package/src/chat/user-message.ts +2 -1
- package/src/components/editor.ts +115 -16
- package/src/components/input.ts +57 -1
- package/src/native/backend.ts +28 -10
- package/src/native/blobs.ts +1 -1
- package/src/native/encode.ts +1 -0
- package/src/native/node.ts +108 -1
- package/src/native/reconcile.ts +5 -4
- package/src/overlays/agent-hub-projection.ts +25 -0
- package/src/overlays/agent-hub.ts +4 -15
- package/src/overlays/ask-dialog.ts +20 -32
- package/src/overlays/copy-selector.ts +6 -6
- package/src/overlays/error-banner.ts +14 -3
- package/src/overlays/hook-editor.ts +59 -6
- package/src/overlays/jobs-panel.ts +132 -49
- package/src/overlays/model-browser.ts +56 -12
- package/src/overlays/model-hub.ts +82 -29
- package/src/overlays/rewind-selector.ts +235 -332
- package/src/overlays/session-selector.ts +19 -20
- package/src/prompt/custom-editor.ts +119 -30
- package/src/prompt/welcome.ts +4 -33
- package/src/render/render-utils.ts +51 -3
- package/src/status-line/component.ts +61 -30
- package/src/status-line/host.ts +2 -0
- package/src/status-line/metrics.ts +36 -5
- package/src/status-line/segments.ts +9 -1
- package/src/status-line/types.ts +13 -2
- package/src/terminal.ts +27 -6
- package/src/tools/edit.ts +23 -0
- package/src/tools/eval.ts +43 -11
- package/src/tools/grouped-file-output.ts +6 -0
- package/src/tools/task.ts +29 -4
- 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[];
|
package/dist/types/tui.d.ts
CHANGED
|
@@ -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
|
+
"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.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.4.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.4.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.4.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.4.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.4.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.4.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.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"
|
package/src/apps/git/sidebar.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 && (
|
|
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 ?
|
|
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 ?
|
|
1079
|
+
collapsed: hasBody ? this.#nativeCollapsed(status) : undefined,
|
|
1064
1080
|
preview: hasBody ? cardPreview(view.preview) : undefined,
|
|
1065
1081
|
},
|
|
1066
1082
|
children,
|
package/src/chat/user-message.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/components/editor.ts
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
-
|
|
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.#
|
|
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.#
|
|
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
|
-
//
|
|
1623
|
-
//
|
|
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.
|
|
1650
|
-
? this.
|
|
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
|
-
|
|
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.
|
|
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 (
|
|
1953
|
-
|
|
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.#
|
|
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.#
|
|
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;
|
package/src/components/input.ts
CHANGED
|
@@ -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
|
|
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);
|
package/src/native/backend.ts
CHANGED
|
@@ -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:
|
|
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
|
-
|
|
412
|
-
|
|
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 = [
|
|
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:
|
|
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(
|
|
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();
|
package/src/native/blobs.ts
CHANGED
|
@@ -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);
|
package/src/native/encode.ts
CHANGED
|
@@ -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
|
};
|