@narumitw/pi-btw 0.57.1 → 0.58.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/README.md +39 -0
- package/dist/index.ts +432 -36
- package/dist/index.ts.map +4 -4
- package/docs/workflows.md +4 -3
- package/package.json +1 -1
- package/src/btw.ts +7 -1
- package/src/fullscreen-ui.ts +32 -2
- package/src/keybindings.ts +330 -0
- package/src/menu.ts +136 -19
- package/src/settings.ts +32 -0
- package/src/transcript-pager.ts +52 -30
package/docs/workflows.md
CHANGED
|
@@ -39,7 +39,8 @@ Click it or use Pi's effective `tui.altScreen.bottom` binding (`End` by default)
|
|
|
39
39
|
## Thinking and queued questions
|
|
40
40
|
|
|
41
41
|
The header shows the current side-thread thinking level.
|
|
42
|
-
Use
|
|
42
|
+
Use the thinking-cycle shortcut shown in the composer to cycle supported levels for later questions.
|
|
43
|
+
It inherits Pi's `app.thinking.cycle` (`Shift+Tab` by default) unless overridden in [Keybindings](../README.md#keybindings).
|
|
43
44
|
Whether that change is remembered depends on [Settings](../README.md#-settings); it never changes the main session's thinking level.
|
|
44
45
|
|
|
45
46
|
During a response, submit another question to queue it as `Steering`.
|
|
@@ -47,12 +48,12 @@ Queued questions run in order after the current response, using the thinking lev
|
|
|
47
48
|
A failed response remains visible without discarding later queued questions.
|
|
48
49
|
Steering does not append to the main conversation or editor.
|
|
49
50
|
|
|
50
|
-
Ctrl+C cancels the active response and discards the current draft and steering queue.
|
|
51
|
+
The configured exit shortcut, or the permanent Ctrl+C hard-cancel key, cancels the active response and discards the current draft and steering queue.
|
|
51
52
|
Completed questions, answers, and visible errors remain resumable until the extension instance ends.
|
|
52
53
|
|
|
53
54
|
## Bring context to the main editor
|
|
54
55
|
|
|
55
|
-
After a successful answer,
|
|
56
|
+
After a successful answer, use the bring-to-main shortcut (`Ctrl+R` by default) to choose the latest question and answer, everything from one question onward, an exact range, or the full thread.
|
|
56
57
|
The scope chooser reports the latest exchange and full-thread sizes.
|
|
57
58
|
Question-suffix, exact-range, and full-thread choices preview an editable context block before closing the side thread.
|
|
58
59
|
Escape returns; Ctrl+C closes without bringing context back.
|
package/package.json
CHANGED
package/src/btw.ts
CHANGED
|
@@ -223,6 +223,9 @@ function notifySafely(
|
|
|
223
223
|
}
|
|
224
224
|
}
|
|
225
225
|
|
|
226
|
+
// Keep this slightly-over-1,000-line command coordinator intact: its injectable menu,
|
|
227
|
+
// request, resume, and delivery flows share the same thread-state and test seams;
|
|
228
|
+
// settings, keybinding policy, terminal ownership, and rendering live in separate modules.
|
|
226
229
|
export interface BtwExtensionDependencies {
|
|
227
230
|
showCommandMenu?: (
|
|
228
231
|
pi: ExtensionAPI,
|
|
@@ -349,7 +352,10 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
|
|
|
349
352
|
ctx: fullscreenCtx,
|
|
350
353
|
});
|
|
351
354
|
},
|
|
352
|
-
{
|
|
355
|
+
{
|
|
356
|
+
copyOnSelect: effectiveFullscreenCopyOnSelect(settings),
|
|
357
|
+
...(settings.keybindings ? { keybindings: settings.keybindings } : {}),
|
|
358
|
+
},
|
|
353
359
|
);
|
|
354
360
|
} finally {
|
|
355
361
|
if (state?.title && state.thread.turns.length > 0) {
|
package/src/fullscreen-ui.ts
CHANGED
|
@@ -10,7 +10,6 @@ import {
|
|
|
10
10
|
isKeyRelease,
|
|
11
11
|
isKittyProtocolActive,
|
|
12
12
|
Key,
|
|
13
|
-
matchesKey,
|
|
14
13
|
type OverlayHandle,
|
|
15
14
|
parseKey,
|
|
16
15
|
type TUI,
|
|
@@ -19,6 +18,12 @@ import {
|
|
|
19
18
|
type TuiInputListenerResult,
|
|
20
19
|
truncateToWidth,
|
|
21
20
|
} from "@earendil-works/pi-tui";
|
|
21
|
+
import {
|
|
22
|
+
type BtwKeybindingOverrides,
|
|
23
|
+
BtwPasteGuard,
|
|
24
|
+
resolveBtwShortcuts,
|
|
25
|
+
setBtwShortcuts,
|
|
26
|
+
} from "./keybindings.js";
|
|
22
27
|
import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
|
|
23
28
|
|
|
24
29
|
type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
|
|
@@ -41,6 +46,7 @@ export interface BtwFullscreenLayoutComponent extends Component {
|
|
|
41
46
|
}
|
|
42
47
|
|
|
43
48
|
export interface BtwFullscreenOptions {
|
|
49
|
+
keybindings?: BtwKeybindingOverrides;
|
|
44
50
|
copyOnSelect?: boolean;
|
|
45
51
|
}
|
|
46
52
|
|
|
@@ -473,13 +479,37 @@ class BtwFullscreenHost<T> implements Component {
|
|
|
473
479
|
this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
|
|
474
480
|
this.fullscreenCreated = true;
|
|
475
481
|
this.fullscreen.start();
|
|
482
|
+
const shortcuts = resolveBtwShortcuts(
|
|
483
|
+
this.options.keybindings,
|
|
484
|
+
this.keybindings,
|
|
485
|
+
this.options.copyOnSelect ?? true,
|
|
486
|
+
);
|
|
487
|
+
setBtwShortcuts(this.fullscreen, shortcuts);
|
|
488
|
+
// Negotiate before warning when possible: the first dispatched user input uses
|
|
489
|
+
// the current mode. Recheck each input, including later mode transitions.
|
|
490
|
+
let previousWarnings: readonly string[] = [];
|
|
491
|
+
const reportWarnings = () => {
|
|
492
|
+
const warnings = shortcuts.warnings;
|
|
493
|
+
for (const warning of warnings) {
|
|
494
|
+
if (previousWarnings.includes(warning)) continue;
|
|
495
|
+
try {
|
|
496
|
+
this.ctx.ui.notify(`Pi BTW: ${warning}`, "warning");
|
|
497
|
+
} catch {
|
|
498
|
+
/* A replaced context must not prevent terminal cleanup. */
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
previousWarnings = warnings;
|
|
502
|
+
};
|
|
503
|
+
const pasteGuard = new BtwPasteGuard();
|
|
476
504
|
// Waiting for the custom promise would leave follow-up keys bound to the side TUI.
|
|
477
505
|
const addHardCancelListener =
|
|
478
506
|
this.fullscreen.addInputListenerBeforeAll?.bind(this.fullscreen) ??
|
|
479
507
|
this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ??
|
|
480
508
|
this.fullscreen.addInputListener.bind(this.fullscreen);
|
|
481
509
|
this.removeHardCancelListener = addHardCancelListener((data) => {
|
|
482
|
-
|
|
510
|
+
reportWarnings();
|
|
511
|
+
if (pasteGuard.consume(data) || !shortcuts.matches(data, "exit")) return undefined;
|
|
512
|
+
this.disposed = true;
|
|
483
513
|
try {
|
|
484
514
|
this.hardCancelActiveCustom?.();
|
|
485
515
|
} finally {
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import {
|
|
2
|
+
isKeyRelease,
|
|
3
|
+
isKittyProtocolActive,
|
|
4
|
+
KeybindingsManager,
|
|
5
|
+
type KeyId,
|
|
6
|
+
matchesKey,
|
|
7
|
+
type TUI,
|
|
8
|
+
TUI_KEYBINDINGS,
|
|
9
|
+
} from "@earendil-works/pi-tui";
|
|
10
|
+
import { formatKeyLabel } from "./text.js";
|
|
11
|
+
|
|
12
|
+
export const BTW_SHORTCUT_ACTIONS = ["exit", "cycleThinkingLevel", "bringToMain"] as const;
|
|
13
|
+
export type BtwShortcutAction = (typeof BTW_SHORTCUT_ACTIONS)[number];
|
|
14
|
+
export type BtwKeybindingOverrides = Partial<Record<BtwShortcutAction, string>>;
|
|
15
|
+
|
|
16
|
+
const MODIFIERS = ["shift", "alt", "ctrl", "super"] as const;
|
|
17
|
+
const SYMBOLS = "`-=[]\\;',./!@#$%^&*()_|~{}:<>?";
|
|
18
|
+
const SPECIAL: Record<string, number> = {
|
|
19
|
+
escape: 27,
|
|
20
|
+
tab: 9,
|
|
21
|
+
enter: 13,
|
|
22
|
+
space: 32,
|
|
23
|
+
backspace: 127,
|
|
24
|
+
insert: 57425,
|
|
25
|
+
delete: 57426,
|
|
26
|
+
home: 57423,
|
|
27
|
+
end: 57424,
|
|
28
|
+
pageup: 57421,
|
|
29
|
+
pagedown: 57422,
|
|
30
|
+
left: 57417,
|
|
31
|
+
right: 57418,
|
|
32
|
+
up: 57419,
|
|
33
|
+
down: 57420,
|
|
34
|
+
};
|
|
35
|
+
const FUNCTION_INPUTS = [
|
|
36
|
+
"OP",
|
|
37
|
+
"OQ",
|
|
38
|
+
"OR",
|
|
39
|
+
"OS",
|
|
40
|
+
"[15~",
|
|
41
|
+
"[17~",
|
|
42
|
+
"[18~",
|
|
43
|
+
"[19~",
|
|
44
|
+
"[20~",
|
|
45
|
+
"[21~",
|
|
46
|
+
"[23~",
|
|
47
|
+
"[24~",
|
|
48
|
+
];
|
|
49
|
+
// All cross-identity legacy collisions in Pi's matchesKey(): raw Ctrl bytes,
|
|
50
|
+
// ESC-prefixed bytes (including Alt+arrows), BS/DEL, Enter and Shift+Tab.
|
|
51
|
+
// CSI-u, keypad, lock bits, shifted letters and modifyOtherKeys normalize to
|
|
52
|
+
// the same key+modifiers; probe that identity instead of duplicating the parser.
|
|
53
|
+
const LEGACY_INPUTS = [
|
|
54
|
+
...Array.from({ length: 128 }, (_, code) => String.fromCharCode(code)),
|
|
55
|
+
...Array.from({ length: 128 }, (_, code) => `\u001b${String.fromCharCode(code)}`),
|
|
56
|
+
"\u001b[Z",
|
|
57
|
+
"\u001bOM",
|
|
58
|
+
"\u001b[E",
|
|
59
|
+
"\u001b[e",
|
|
60
|
+
"\u001bOe",
|
|
61
|
+
...FUNCTION_INPUTS.map((suffix) => `\u001b${suffix}`),
|
|
62
|
+
];
|
|
63
|
+
|
|
64
|
+
/** Strict settings syntax; Pi itself ignores unknown/duplicate modifier tokens. */
|
|
65
|
+
export function normalizeBtwKey(value: unknown): string | undefined {
|
|
66
|
+
if (typeof value !== "string" || value.length > 80 || /[\s\p{Cc}]/u.test(value)) return undefined;
|
|
67
|
+
const parts = value.toLowerCase().split("+");
|
|
68
|
+
let base = parts.pop();
|
|
69
|
+
if (!base) return undefined;
|
|
70
|
+
if (base === "esc") base = "escape";
|
|
71
|
+
if (base === "return") base = "enter";
|
|
72
|
+
if (
|
|
73
|
+
new Set(parts).size !== parts.length ||
|
|
74
|
+
parts.some((part) => !MODIFIERS.includes(part as never))
|
|
75
|
+
)
|
|
76
|
+
return undefined;
|
|
77
|
+
if (
|
|
78
|
+
!Object.hasOwn(SPECIAL, base) &&
|
|
79
|
+
base !== "clear" &&
|
|
80
|
+
!/^f(?:[1-9]|1[0-2])$/u.test(base) &&
|
|
81
|
+
!(base.length === 1 && (/^[a-z0-9]$/u.test(base) || SYMBOLS.includes(base)))
|
|
82
|
+
)
|
|
83
|
+
return undefined;
|
|
84
|
+
if ((base === "escape" || (base.startsWith("f") && base.length > 1)) && parts.length)
|
|
85
|
+
return undefined;
|
|
86
|
+
if (
|
|
87
|
+
base === "clear" &&
|
|
88
|
+
(parts.length > 1 || (parts.length === 1 && !["ctrl", "shift"].includes(parts[0] ?? "")))
|
|
89
|
+
)
|
|
90
|
+
return undefined;
|
|
91
|
+
return [...MODIFIERS.filter((part) => parts.includes(part)), base].join("+");
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Representative inputs are checked by Pi, including its live terminal-mode branches. */
|
|
95
|
+
function inputsFor(key: string): string[] {
|
|
96
|
+
const parts = key.split("+");
|
|
97
|
+
const base = parts.pop() ?? "";
|
|
98
|
+
const modifier = MODIFIERS.reduce(
|
|
99
|
+
(mask, part, bit) => mask | (parts.includes(part) ? 1 << bit : 0),
|
|
100
|
+
0,
|
|
101
|
+
);
|
|
102
|
+
const code = SPECIAL[base] ?? (base.length === 1 ? base.charCodeAt(0) : undefined);
|
|
103
|
+
const inputs =
|
|
104
|
+
code === undefined ? LEGACY_INPUTS : [...LEGACY_INPUTS, `\u001b[${code};${modifier + 1}u`];
|
|
105
|
+
return inputs.filter((input) => matchesKey(input, key as KeyId));
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function btwKeysOverlap(first: string, second: string): boolean {
|
|
109
|
+
const normalized = normalizeBtwKey(first);
|
|
110
|
+
return (
|
|
111
|
+
normalized !== undefined &&
|
|
112
|
+
inputsFor(normalized).some((input) => matchesKey(input, second as KeyId))
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export interface BtwShortcuts {
|
|
117
|
+
keys: Record<BtwShortcutAction, readonly string[]>;
|
|
118
|
+
warnings: readonly string[];
|
|
119
|
+
matches(data: string, action: BtwShortcutAction): boolean;
|
|
120
|
+
label(action: BtwShortcutAction): string;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function reservedKeys(keybindings: KeybindingsManager, copyOnSelect: boolean): string[] {
|
|
124
|
+
// Editor has no autocomplete provider here. Select bindings are still reserved
|
|
125
|
+
// because exit also applies to BTW-owned nested review/menu components.
|
|
126
|
+
return [
|
|
127
|
+
"ctrl+c",
|
|
128
|
+
"shift+backspace",
|
|
129
|
+
"shift+delete",
|
|
130
|
+
"shift+space",
|
|
131
|
+
// Exact-range review uses these fixed selection actions even if Editor is remapped.
|
|
132
|
+
"shift+left",
|
|
133
|
+
"shift+right",
|
|
134
|
+
"shift+up",
|
|
135
|
+
"shift+down",
|
|
136
|
+
"left",
|
|
137
|
+
"right",
|
|
138
|
+
"enter",
|
|
139
|
+
"alt+enter",
|
|
140
|
+
"ctrl+j",
|
|
141
|
+
"pageUp",
|
|
142
|
+
"pageDown",
|
|
143
|
+
...Object.keys(TUI_KEYBINDINGS).flatMap((id) =>
|
|
144
|
+
keybindings.getKeys(id as keyof typeof TUI_KEYBINDINGS),
|
|
145
|
+
),
|
|
146
|
+
...(!copyOnSelect ? keybindings.getKeys("app.message.copy") : []),
|
|
147
|
+
];
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function isTextKey(key: string): boolean {
|
|
151
|
+
const parts = key.split("+");
|
|
152
|
+
const base = parts.at(-1) ?? "";
|
|
153
|
+
return (
|
|
154
|
+
(base.length === 1 || base === "space") &&
|
|
155
|
+
!parts.some((part) => ["ctrl", "alt", "super"].includes(part))
|
|
156
|
+
);
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function resolveBtwShortcuts(
|
|
160
|
+
overrides: BtwKeybindingOverrides = {},
|
|
161
|
+
keybindings: KeybindingsManager,
|
|
162
|
+
copyOnSelect = true,
|
|
163
|
+
): BtwShortcuts {
|
|
164
|
+
let mode = isKittyProtocolActive();
|
|
165
|
+
let snapshot = resolveShortcutSnapshot(overrides, keybindings, copyOnSelect);
|
|
166
|
+
const current = () => {
|
|
167
|
+
if (mode !== isKittyProtocolActive()) {
|
|
168
|
+
mode = isKittyProtocolActive();
|
|
169
|
+
snapshot = resolveShortcutSnapshot(overrides, keybindings, copyOnSelect);
|
|
170
|
+
}
|
|
171
|
+
return snapshot;
|
|
172
|
+
};
|
|
173
|
+
// ProcessTerminal negotiates Kitty asynchronously after the dedicated TUI starts.
|
|
174
|
+
// Hints and matching must use the same policy after that mode transition.
|
|
175
|
+
return {
|
|
176
|
+
get keys() {
|
|
177
|
+
return current().keys;
|
|
178
|
+
},
|
|
179
|
+
get warnings() {
|
|
180
|
+
return current().warnings;
|
|
181
|
+
},
|
|
182
|
+
matches: (data, action) => current().matches(data, action),
|
|
183
|
+
label: (action) => current().label(action),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function resolveShortcutSnapshot(
|
|
188
|
+
overrides: BtwKeybindingOverrides = {},
|
|
189
|
+
keybindings: KeybindingsManager,
|
|
190
|
+
copyOnSelect = true,
|
|
191
|
+
): BtwShortcuts {
|
|
192
|
+
const reserved = reservedKeys(keybindings, copyOnSelect);
|
|
193
|
+
const keys: BtwShortcuts["keys"] = { exit: ["ctrl+c"], cycleThinkingLevel: [], bringToMain: [] };
|
|
194
|
+
const warnings: string[] = [];
|
|
195
|
+
const defaults: Record<BtwShortcutAction, readonly string[]> = {
|
|
196
|
+
exit: ["ctrl+c"],
|
|
197
|
+
cycleThinkingLevel: keybindings.getKeys("app.thinking.cycle"),
|
|
198
|
+
bringToMain: ["ctrl+r"],
|
|
199
|
+
};
|
|
200
|
+
const usable = (
|
|
201
|
+
action: BtwShortcutAction,
|
|
202
|
+
candidate: string,
|
|
203
|
+
inherited = false,
|
|
204
|
+
): string | undefined => {
|
|
205
|
+
const key = normalizeBtwKey(candidate);
|
|
206
|
+
if (!key || (!inherited && isTextKey(key))) return undefined;
|
|
207
|
+
const inputs = inputsFor(key);
|
|
208
|
+
if (!inputs.length) return undefined;
|
|
209
|
+
if (action === "exit" && key === "ctrl+c") return key;
|
|
210
|
+
if (
|
|
211
|
+
reserved.some(
|
|
212
|
+
(other) =>
|
|
213
|
+
typeof other === "string" && inputs.some((input) => matchesKey(input, other as KeyId)),
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
return undefined;
|
|
217
|
+
return key;
|
|
218
|
+
};
|
|
219
|
+
const candidates: BtwKeybindingOverrides = {};
|
|
220
|
+
const availableDefaults = { ...defaults };
|
|
221
|
+
for (const action of BTW_SHORTCUT_ACTIONS) {
|
|
222
|
+
availableDefaults[action] = defaults[action]
|
|
223
|
+
.map((key) => usable(action, key, action === "cycleThinkingLevel"))
|
|
224
|
+
.filter((key): key is string => key !== undefined);
|
|
225
|
+
const override = overrides[action];
|
|
226
|
+
if (override !== undefined) candidates[action] = usable(action, override);
|
|
227
|
+
}
|
|
228
|
+
// Compare the complete proposal, not only actions visited earlier. Reject conflicts
|
|
229
|
+
// together, then repeat because a rejected override restores its default bindings.
|
|
230
|
+
for (;;) {
|
|
231
|
+
const rejected = BTW_SHORTCUT_ACTIONS.filter((action) => {
|
|
232
|
+
const candidate = candidates[action];
|
|
233
|
+
return (
|
|
234
|
+
candidate !== undefined &&
|
|
235
|
+
BTW_SHORTCUT_ACTIONS.some(
|
|
236
|
+
(other) =>
|
|
237
|
+
other !== action &&
|
|
238
|
+
(candidates[other] ? [candidates[other]] : availableDefaults[other]).some((key) =>
|
|
239
|
+
btwKeysOverlap(candidate, key),
|
|
240
|
+
),
|
|
241
|
+
)
|
|
242
|
+
);
|
|
243
|
+
});
|
|
244
|
+
if (!rejected.length) break;
|
|
245
|
+
for (const action of rejected) delete candidates[action];
|
|
246
|
+
}
|
|
247
|
+
for (const action of BTW_SHORTCUT_ACTIONS) {
|
|
248
|
+
const override = overrides[action];
|
|
249
|
+
const selected = candidates[action];
|
|
250
|
+
if (override !== undefined && !selected)
|
|
251
|
+
warnings.push(
|
|
252
|
+
`${action}: configured shortcut is invalid or conflicts with a reserved action; using an available default.`,
|
|
253
|
+
);
|
|
254
|
+
const effective = selected
|
|
255
|
+
? [selected]
|
|
256
|
+
: availableDefaults[action].filter(
|
|
257
|
+
(key) =>
|
|
258
|
+
!BTW_SHORTCUT_ACTIONS.some(
|
|
259
|
+
(other) => other !== action && keys[other].some((used) => btwKeysOverlap(key, used)),
|
|
260
|
+
),
|
|
261
|
+
);
|
|
262
|
+
keys[action] = action === "exit" ? [...new Set([...effective, "ctrl+c"])] : effective;
|
|
263
|
+
if (!keys[action].length && (override !== undefined || defaults[action].length > 0))
|
|
264
|
+
warnings.push(`${action}: no usable shortcut; change Pi BTW Settings or Pi keybindings.`);
|
|
265
|
+
}
|
|
266
|
+
return {
|
|
267
|
+
keys,
|
|
268
|
+
warnings,
|
|
269
|
+
matches: (data, action) =>
|
|
270
|
+
!isKeyRelease(data) && keys[action].some((key) => matchesKey(data, key as KeyId)),
|
|
271
|
+
label: (action) =>
|
|
272
|
+
keys[action].length ? formatKeyLabel(keys[action][0] ?? "") : "Unavailable",
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export function validateBtwShortcutEdit(
|
|
277
|
+
action: BtwShortcutAction,
|
|
278
|
+
value: string | undefined,
|
|
279
|
+
overrides: BtwKeybindingOverrides,
|
|
280
|
+
keybindings: KeybindingsManager,
|
|
281
|
+
copyOnSelect: boolean,
|
|
282
|
+
): string | undefined {
|
|
283
|
+
// Reset must remain available even when several existing overrides conflict.
|
|
284
|
+
if (value === undefined) return undefined;
|
|
285
|
+
if (!normalizeBtwKey(value))
|
|
286
|
+
return "Invalid key combination. Use a Pi key name such as ctrl+q or f6.";
|
|
287
|
+
const next = { ...overrides, [action]: value };
|
|
288
|
+
const resolved = resolveBtwShortcuts(next, keybindings, copyOnSelect);
|
|
289
|
+
const previous = resolveBtwShortcuts(overrides, keybindings, copyOnSelect);
|
|
290
|
+
// Reject edits that disable another binding as well as the edited binding.
|
|
291
|
+
for (const item of BTW_SHORTCUT_ACTIONS) {
|
|
292
|
+
if (
|
|
293
|
+
(item === action && !resolved.keys[item].includes(normalizeBtwKey(value) ?? "")) ||
|
|
294
|
+
(item !== action && previous.keys[item].some((key) => !resolved.keys[item].includes(key)))
|
|
295
|
+
) {
|
|
296
|
+
return `${item} conflicts with another BTW shortcut, editing, selection, search, scrolling, or copying. Choose a different key.`;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
return undefined;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const bindingsByTui = new WeakMap<TUI, BtwShortcuts>();
|
|
303
|
+
export function setBtwShortcuts(tui: TUI, shortcuts: BtwShortcuts): void {
|
|
304
|
+
bindingsByTui.set(tui, shortcuts);
|
|
305
|
+
}
|
|
306
|
+
export function getBtwShortcuts(tui: TUI, keybindings?: KeybindingsManager): BtwShortcuts {
|
|
307
|
+
return (
|
|
308
|
+
bindingsByTui.get(tui) ??
|
|
309
|
+
resolveBtwShortcuts(
|
|
310
|
+
{},
|
|
311
|
+
keybindings ??
|
|
312
|
+
new KeybindingsManager({
|
|
313
|
+
...TUI_KEYBINDINGS,
|
|
314
|
+
"app.thinking.cycle": { defaultKeys: "shift+tab" },
|
|
315
|
+
}),
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Keep split bracketed-paste payloads away from screen-level shortcuts. */
|
|
321
|
+
export class BtwPasteGuard {
|
|
322
|
+
private active = false;
|
|
323
|
+
consume(data: string): boolean {
|
|
324
|
+
const wasActive = this.active;
|
|
325
|
+
const starts = data.includes("\u001b[200~");
|
|
326
|
+
if (starts) this.active = true;
|
|
327
|
+
if (this.active && data.includes("\u001b[201~")) this.active = false;
|
|
328
|
+
return wasActive || starts;
|
|
329
|
+
}
|
|
330
|
+
}
|
package/src/menu.ts
CHANGED
|
@@ -5,6 +5,13 @@ import type {
|
|
|
5
5
|
} from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { Component, TUI } from "@earendil-works/pi-tui";
|
|
7
7
|
import type { MenuContext, RunMenuResult } from "@narumitw/pi-tui-kit";
|
|
8
|
+
import {
|
|
9
|
+
BTW_SHORTCUT_ACTIONS,
|
|
10
|
+
type BtwShortcutAction,
|
|
11
|
+
normalizeBtwKey,
|
|
12
|
+
resolveBtwShortcuts,
|
|
13
|
+
validateBtwShortcutEdit,
|
|
14
|
+
} from "./keybindings.js";
|
|
8
15
|
import {
|
|
9
16
|
type BtwSettings,
|
|
10
17
|
type BtwSettingsPatch,
|
|
@@ -16,7 +23,7 @@ import {
|
|
|
16
23
|
updateBtwSettings,
|
|
17
24
|
} from "./settings.js";
|
|
18
25
|
import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
|
|
19
|
-
import { sanitizeSingleLine } from "./text.js";
|
|
26
|
+
import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
|
|
20
27
|
|
|
21
28
|
interface BtwMenuState {
|
|
22
29
|
kind: "valid" | "invalid";
|
|
@@ -48,14 +55,17 @@ export type BtwCommandMenuResult =
|
|
|
48
55
|
| "closed"
|
|
49
56
|
| { kind: "resume"; threadId: string };
|
|
50
57
|
|
|
51
|
-
type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
|
|
58
|
+
type BtwMenuScreen = "main" | "resume" | "settings" | "invalid" | "shortcut" | "shortcut-input";
|
|
52
59
|
type BtwMenuAction =
|
|
53
60
|
| "start"
|
|
54
61
|
| "start-tree"
|
|
55
62
|
| "resume"
|
|
56
63
|
| "set-thinking"
|
|
57
64
|
| "set-remember"
|
|
58
|
-
| "set-fullscreen-copy"
|
|
65
|
+
| "set-fullscreen-copy"
|
|
66
|
+
| "edit-shortcut"
|
|
67
|
+
| "save-shortcut"
|
|
68
|
+
| "reset-shortcut";
|
|
59
69
|
const SAME_AS_MAIN_THREAD = "Same as main thread";
|
|
60
70
|
type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
|
|
61
71
|
|
|
@@ -85,6 +95,74 @@ export async function showBtwCommandMenu(
|
|
|
85
95
|
let startSelected = false;
|
|
86
96
|
let treeSelected = false;
|
|
87
97
|
let resumedThreadId: string | undefined;
|
|
98
|
+
let keybindings: KeybindingsManager | undefined;
|
|
99
|
+
let shortcut: BtwShortcutAction = "exit";
|
|
100
|
+
const shortcutLabels: Record<BtwShortcutAction, string> = {
|
|
101
|
+
exit: "Exit shortcut",
|
|
102
|
+
cycleThinkingLevel: "Cycle thinking level shortcut",
|
|
103
|
+
bringToMain: "Bring to main shortcut",
|
|
104
|
+
};
|
|
105
|
+
const shortcutValue = (settings: BtwSettings, action: BtwShortcutAction): string => {
|
|
106
|
+
if (!keybindings) return "Default";
|
|
107
|
+
const effective = resolveBtwShortcuts(
|
|
108
|
+
settings.keybindings,
|
|
109
|
+
keybindings,
|
|
110
|
+
effectiveFullscreenCopyOnSelect(settings),
|
|
111
|
+
);
|
|
112
|
+
const configured = settings.keybindings?.[action];
|
|
113
|
+
if (configured !== undefined && !effective.keys[action].includes(configured)) {
|
|
114
|
+
return `Fallback (${effective.label(action)}; saved ${formatKeyLabel(configured)})`;
|
|
115
|
+
}
|
|
116
|
+
const source =
|
|
117
|
+
configured === undefined
|
|
118
|
+
? action === "cycleThinkingLevel"
|
|
119
|
+
? "Inherit Pi"
|
|
120
|
+
: "Default"
|
|
121
|
+
: "Custom";
|
|
122
|
+
return `${source} (${effective.label(action)})`;
|
|
123
|
+
};
|
|
124
|
+
const saveShortcut = async (
|
|
125
|
+
state: BtwMenuState,
|
|
126
|
+
value: string | undefined,
|
|
127
|
+
signal: AbortSignal,
|
|
128
|
+
) => {
|
|
129
|
+
if (!keybindings || state.kind !== "valid" || signal.aborted)
|
|
130
|
+
return { kind: "rejected" } as const;
|
|
131
|
+
const action = shortcut;
|
|
132
|
+
const manager = keybindings;
|
|
133
|
+
const validate = (settings: BtwSettings) =>
|
|
134
|
+
validateBtwShortcutEdit(
|
|
135
|
+
action,
|
|
136
|
+
value,
|
|
137
|
+
settings.keybindings ?? {},
|
|
138
|
+
manager,
|
|
139
|
+
effectiveFullscreenCopyOnSelect(settings),
|
|
140
|
+
);
|
|
141
|
+
const error = validate(state.settings);
|
|
142
|
+
if (error) {
|
|
143
|
+
notifySafely(ctx, error, "error");
|
|
144
|
+
return { kind: "rejected" } as const;
|
|
145
|
+
}
|
|
146
|
+
try {
|
|
147
|
+
await updateSettings(
|
|
148
|
+
{ keybindings: { [action]: value === undefined ? undefined : normalizeBtwKey(value) } },
|
|
149
|
+
{
|
|
150
|
+
settingsPath,
|
|
151
|
+
signal,
|
|
152
|
+
validateCurrent: (settings) => {
|
|
153
|
+
const conflict = validate(settings);
|
|
154
|
+
if (conflict) throw new Error(conflict);
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
if (signal.aborted) return { kind: "rejected" } as const;
|
|
159
|
+
notifySafely(ctx, "Pi BTW shortcut saved; applies when opening or resuming BTW.", "info");
|
|
160
|
+
return { kind: "back" } as const;
|
|
161
|
+
} catch (error) {
|
|
162
|
+
if (!signal.aborted) notifySaveFailure(ctx, error);
|
|
163
|
+
return { kind: "rejected" } as const;
|
|
164
|
+
}
|
|
165
|
+
};
|
|
88
166
|
|
|
89
167
|
const loadState = async (): Promise<BtwMenuState> => {
|
|
90
168
|
const loaded = await readSettings(settingsPath);
|
|
@@ -146,7 +224,7 @@ export async function showBtwCommandMenu(
|
|
|
146
224
|
{
|
|
147
225
|
id: "settings",
|
|
148
226
|
label: "Settings",
|
|
149
|
-
description: "Choose thinking,
|
|
227
|
+
description: "Choose thinking, keybindings, and selection copying",
|
|
150
228
|
to: state.kind === "invalid" ? "invalid" : "settings",
|
|
151
229
|
},
|
|
152
230
|
],
|
|
@@ -195,7 +273,33 @@ export async function showBtwCommandMenu(
|
|
|
195
273
|
values: ["On", "Off"],
|
|
196
274
|
action: "set-fullscreen-copy",
|
|
197
275
|
},
|
|
276
|
+
...BTW_SHORTCUT_ACTIONS.map((action) => ({
|
|
277
|
+
id: action,
|
|
278
|
+
label: shortcutLabels[action],
|
|
279
|
+
description:
|
|
280
|
+
"Edit a BTW-only key combination or restore its default. Ctrl+C always hard-cancels.",
|
|
281
|
+
currentValue: shortcutValue(state.settings, action),
|
|
282
|
+
action: "edit-shortcut" as const,
|
|
283
|
+
})),
|
|
284
|
+
],
|
|
285
|
+
}),
|
|
286
|
+
shortcut: ({ state }) => ({
|
|
287
|
+
kind: "actions",
|
|
288
|
+
title: shortcutLabels[shortcut],
|
|
289
|
+
lines: [shortcutValue(state.settings, shortcut), "Ctrl+C always hard-cancels BTW."],
|
|
290
|
+
items: [
|
|
291
|
+
{ id: "edit", label: "Edit key combination…", to: "shortcut-input" },
|
|
292
|
+
{ id: "reset", label: "Restore default", action: "reset-shortcut" },
|
|
198
293
|
],
|
|
294
|
+
hint: "back",
|
|
295
|
+
}),
|
|
296
|
+
"shortcut-input": () => ({
|
|
297
|
+
kind: "input",
|
|
298
|
+
title: shortcutLabels[shortcut],
|
|
299
|
+
lines: ["Type a key name, not the shortcut itself. For example: ctrl+q or f6."],
|
|
300
|
+
placeholder: "Key combination",
|
|
301
|
+
action: "save-shortcut",
|
|
302
|
+
hint: "back",
|
|
199
303
|
}),
|
|
200
304
|
invalid: ({ state }) => ({
|
|
201
305
|
kind: "detail",
|
|
@@ -208,6 +312,15 @@ export async function showBtwCommandMenu(
|
|
|
208
312
|
}),
|
|
209
313
|
},
|
|
210
314
|
actions: {
|
|
315
|
+
"edit-shortcut": ({ itemId }) => {
|
|
316
|
+
if (!BTW_SHORTCUT_ACTIONS.includes(itemId as BtwShortcutAction))
|
|
317
|
+
return { kind: "rejected" };
|
|
318
|
+
shortcut = itemId as BtwShortcutAction;
|
|
319
|
+
return { kind: "to", screen: "shortcut" };
|
|
320
|
+
},
|
|
321
|
+
"save-shortcut": ({ state, value, signal }) =>
|
|
322
|
+
saveShortcut(state, value?.trim() ?? "", signal),
|
|
323
|
+
"reset-shortcut": ({ state, signal }) => saveShortcut(state, undefined, signal),
|
|
211
324
|
start: async () => {
|
|
212
325
|
startSelected = true;
|
|
213
326
|
return { kind: "close" };
|
|
@@ -275,8 +388,12 @@ export async function showBtwCommandMenu(
|
|
|
275
388
|
},
|
|
276
389
|
});
|
|
277
390
|
|
|
278
|
-
const result = await runBtwMenuPreservingEditor(
|
|
279
|
-
|
|
391
|
+
const result = await runBtwMenuPreservingEditor(
|
|
392
|
+
ctx,
|
|
393
|
+
(menuContext) => runMenu(menuContext, menu, { getState: loadState }),
|
|
394
|
+
(manager) => {
|
|
395
|
+
keybindings = manager;
|
|
396
|
+
},
|
|
280
397
|
);
|
|
281
398
|
if (result.kind !== "closed" || result.reason !== "close") return "closed";
|
|
282
399
|
if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
|
|
@@ -314,6 +431,7 @@ export async function showBtwCustomPreservingEditor<T>(
|
|
|
314
431
|
export async function runBtwMenuPreservingEditor(
|
|
315
432
|
ctx: ExtensionCommandContext,
|
|
316
433
|
run: (menuContext: MenuContext) => Promise<RunMenuResult>,
|
|
434
|
+
onKeybindings?: (keybindings: KeybindingsManager) => void,
|
|
317
435
|
): Promise<RunMenuResult> {
|
|
318
436
|
let liveEditorText = ctx.ui.getEditorText();
|
|
319
437
|
let completed = false;
|
|
@@ -321,19 +439,18 @@ export async function runBtwMenuPreservingEditor(
|
|
|
321
439
|
get(target, property) {
|
|
322
440
|
if (property === "custom") {
|
|
323
441
|
return <Value>(factory: BtwCustomFactory<Value>, customOptions?: BtwCustomOptions) =>
|
|
324
|
-
target.custom<Value>(
|
|
325
|
-
(
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
);
|
|
442
|
+
target.custom<Value>((tui, theme, keybindings, done) => {
|
|
443
|
+
onKeybindings?.(keybindings);
|
|
444
|
+
return factory(tui, theme, keybindings, (value) => {
|
|
445
|
+
try {
|
|
446
|
+
liveEditorText = target.getEditorText();
|
|
447
|
+
} catch {
|
|
448
|
+
// Keep completion finite if session replacement invalidates the editor context.
|
|
449
|
+
}
|
|
450
|
+
completed = true;
|
|
451
|
+
done(value);
|
|
452
|
+
});
|
|
453
|
+
}, customOptions);
|
|
337
454
|
}
|
|
338
455
|
const value = Reflect.get(target, property, target) as unknown;
|
|
339
456
|
return typeof value === "function" ? value.bind(target) : value;
|