@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 +14 -0
- package/dist/types/apps/git/git-tui.d.ts +3 -2
- package/dist/types/components/editor.d.ts +3 -0
- package/dist/types/native/apply.d.ts +4 -3
- package/dist/types/native/encode.d.ts +4 -3
- package/dist/types/native/node.d.ts +2 -1
- package/dist/types/native/settle.d.ts +4 -3
- package/dist/types/overlays/btw-history-panel.d.ts +8 -0
- package/dist/types/overlays/model-hub.d.ts +14 -0
- package/dist/types/prompt/custom-editor.d.ts +2 -0
- package/dist/types/windows-input-mode.d.ts +22 -2
- package/package.json +9 -9
- package/src/apps/git/diff-pane.ts +28 -2
- package/src/apps/git/git-tui.ts +12 -5
- package/src/components/editor.ts +6 -0
- package/src/components/input.ts +1 -0
- package/src/native/apply.ts +4 -3
- package/src/native/encode.ts +4 -3
- package/src/native/node.ts +2 -1
- package/src/native/settle.ts +4 -3
- package/src/overlays/btw-history-panel.ts +115 -36
- package/src/overlays/model-hub.ts +120 -8
- package/src/prompt/custom-editor.ts +5 -1
- package/src/terminal.ts +31 -3
- package/src/windows-input-mode.ts +96 -8
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, `
|
|
17
|
-
*
|
|
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 (
|
|
4
|
-
* one; a rejected op is reported and
|
|
5
|
-
* the frame. `settle` is recorded as a
|
|
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 (
|
|
30
|
-
*
|
|
31
|
-
* says omp applies `undo` events,
|
|
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
|
-
* `
|
|
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 (
|
|
4
|
-
*
|
|
5
|
-
* targeted ops by id, followed by a fresh `settle`
|
|
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
|
-
|
|
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.
|
|
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.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.
|
|
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
|
-
?
|
|
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
|
|
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;
|
package/src/apps/git/git-tui.ts
CHANGED
|
@@ -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, `
|
|
17
|
-
*
|
|
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")
|
|
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
|
|
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;
|
package/src/components/editor.ts
CHANGED
|
@@ -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,
|
package/src/components/input.ts
CHANGED
package/src/native/apply.ts
CHANGED
|
@@ -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 (
|
|
4
|
-
* one; a rejected op is reported and
|
|
5
|
-
* the frame. `settle` is recorded as a
|
|
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
|
*/
|
package/src/native/encode.ts
CHANGED
|
@@ -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 (
|
|
112
|
-
*
|
|
113
|
-
* says omp applies `undo` events,
|
|
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 {
|
package/src/native/node.ts
CHANGED
|
@@ -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
|
-
* `
|
|
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";
|
package/src/native/settle.ts
CHANGED
|
@@ -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 (
|
|
4
|
-
*
|
|
5
|
-
* targeted ops by id, followed by a fresh `settle`
|
|
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.#
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
"
|
|
392
|
-
{
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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(
|
|
497
|
-
|
|
498
|
-
|
|
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",
|
|
515
|
-
{ keys: ["
|
|
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",
|
|
699
|
-
rawKeyHint(
|
|
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 =
|
|
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
|
-
/**
|
|
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 = "
|
|
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 = "
|
|
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 = "
|
|
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
|
-
|
|
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
|
-
|
|
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() ||
|
|
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
|
|
1574
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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)
|
|
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. */
|