@oh-my-pi/pi-tui 18.5.1 → 18.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/types/components/editor.d.ts +3 -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/components/editor.ts +6 -0
- package/src/components/input.ts +1 -0
- 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,12 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.6.0] - 2026-10-03
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- Fixed Alt+Up (restore queued steering), arrows and other escape-sequence keys on Windows acting as Esc (interrupting the turn) and typing `[1;3A` into the editor when the console host relays them one byte at a time ([#14216](https://github.com/can1357/oh-my-pi/pull/14216) by [@H4vC](https://github.com/H4vC)).
|
|
10
|
+
|
|
5
11
|
## [18.5.1] - 2026-10-03
|
|
6
12
|
|
|
7
13
|
### Added
|
|
@@ -138,6 +138,9 @@ export declare class Editor implements Component, Focusable {
|
|
|
138
138
|
onLargePaste?: (text: string, lineCount: number, options: PasteOptions) => boolean;
|
|
139
139
|
onAutocompleteCancel?: () => void;
|
|
140
140
|
disableSubmit: boolean;
|
|
141
|
+
/** Base editors accept native edits, not atomic sends. Implementations that
|
|
142
|
+
* handle `send` override this readiness check for their submission path. */
|
|
143
|
+
protected get nativeSendable(): boolean;
|
|
141
144
|
/** Placeholder painted right-aligned on the cursor row while the editor is empty and no
|
|
142
145
|
* autocomplete is open; hidden when it can't keep {@link PLACEHOLDER_MIN_GAP} cells from the
|
|
143
146
|
* cursor. The host styles it (ANSI allowed). Re-evaluated on every render, so hosts can derive
|
|
@@ -12,6 +12,14 @@ export interface ModelHubSource extends ModelBrowserSource {
|
|
|
12
12
|
getProjectModelRole(role: string): string | undefined;
|
|
13
13
|
getGlobalModelRole(role: string): string | undefined;
|
|
14
14
|
getModelRoleSource(role: string): "global" | "project" | "default";
|
|
15
|
+
/**
|
|
16
|
+
* Saved model presets (in switch order) and the one the current role setup
|
|
17
|
+
* matches, if any. Absent hosts have no presets to switch between.
|
|
18
|
+
*/
|
|
19
|
+
getModelPresets?(): {
|
|
20
|
+
names: readonly string[];
|
|
21
|
+
active: string | undefined;
|
|
22
|
+
};
|
|
15
23
|
}
|
|
16
24
|
/** Catalog capabilities required by the model hub. */
|
|
17
25
|
export interface ModelHubRegistry extends ModelBrowserRegistry {
|
|
@@ -48,6 +56,12 @@ export interface ModelHubCallbacks {
|
|
|
48
56
|
onLoginRequest?: (providerId: string) => void;
|
|
49
57
|
/** Save the current role assignments and default thinking level as a named model preset. */
|
|
50
58
|
onSavePreset?: (name: string) => void;
|
|
59
|
+
/**
|
|
60
|
+
* Apply a saved model preset (ctrl+←/→ or p/⇧P in the Roles view) and report
|
|
61
|
+
* the outcome itself. A switch that writes nothing (a refused preset) still
|
|
62
|
+
* advances the hub's preset cursor, so the next press moves past it.
|
|
63
|
+
*/
|
|
64
|
+
onSwitchPreset?: (name: string) => void | Promise<void>;
|
|
51
65
|
/** Persist a new quick-switch cycle order (the ctrl+p role cycle). */
|
|
52
66
|
onCycleOrderChange?: (order: string[]) => void;
|
|
53
67
|
onCancel: () => void;
|
|
@@ -213,6 +213,8 @@ export declare class CustomEditor extends Editor {
|
|
|
213
213
|
* once after construction (and again after `setEditorComponent` swaps the
|
|
214
214
|
* editor). Passing `undefined` clears any pending frame. */
|
|
215
215
|
setShimmerRepaintHandler(handler: (() => void) | undefined): void;
|
|
216
|
+
/** Editing is available during bootstrap; atomic sends wait until submission is wired and enabled. */
|
|
217
|
+
protected get nativeSendable(): boolean;
|
|
216
218
|
/** Viewing a subagent, the draft goes to it: the placeholder names it. */
|
|
217
219
|
describePlaceholder: () => string;
|
|
218
220
|
/** A shell-mode draft highlights as its language. */
|
|
@@ -16,10 +16,26 @@
|
|
|
16
16
|
* terminal sends; chords legacy encoding loses (modified Enter/Tab/Escape,
|
|
17
17
|
* Ctrl+Shift+letter, …) translate to kitty CSI-u, which key matching already
|
|
18
18
|
* parses.
|
|
19
|
+
*
|
|
20
|
+
* A host terminal that writes VT instead of key records (any ConPTY host
|
|
21
|
+
* besides Windows Terminal's native path) reaches us as one `Vk = 0` text
|
|
22
|
+
* record per byte: Alt+Up arrives as `ESC`, `[`, `1`, `;`, `3`, `A`. Those
|
|
23
|
+
* bytes are reassembled into the escape sequence they spell; delivering them
|
|
24
|
+
* one at a time turns every such key into a bare Escape plus typed garbage.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Stateful decoder: joins UTF-16 surrogate halves that arrive as separate
|
|
28
|
+
* records, and reassembles VT escape sequences relayed as text records.
|
|
19
29
|
*/
|
|
20
|
-
/** Stateful decoder: joins UTF-16 surrogate halves that arrive as separate records. */
|
|
21
30
|
export declare class Win32InputModeDecoder {
|
|
22
31
|
#private;
|
|
32
|
+
/** Whether relayed VT text is held waiting for the rest of its sequence. */
|
|
33
|
+
get hasPendingSequence(): boolean;
|
|
34
|
+
/**
|
|
35
|
+
* Release held VT text as-is. The caller invokes this once no further bytes
|
|
36
|
+
* arrived in time, so a lone relayed ESC still acts as the Escape key.
|
|
37
|
+
*/
|
|
38
|
+
flushPendingSequence(): string[];
|
|
23
39
|
/**
|
|
24
40
|
* Translate one stdin sequence. Returns `undefined` when `data` is not a
|
|
25
41
|
* win32-input-mode record (pass it through unchanged), otherwise the key
|
|
@@ -42,7 +58,11 @@ export declare class Win32InputModeDecoder {
|
|
|
42
58
|
*/
|
|
43
59
|
export declare class Win32PasteMarkerNormalizer {
|
|
44
60
|
#private;
|
|
45
|
-
|
|
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.0",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.
|
|
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.0",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.6.0",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.6.0",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.6.0",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.6.0",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.6.0",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.6.0",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.6.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
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
|
@@ -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. */
|