@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/src/settings.ts
CHANGED
|
@@ -3,6 +3,11 @@ import { constants } from "node:fs";
|
|
|
3
3
|
import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import { basename, dirname, join } from "node:path";
|
|
5
5
|
import { getAgentDir } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import {
|
|
7
|
+
BTW_SHORTCUT_ACTIONS,
|
|
8
|
+
type BtwKeybindingOverrides,
|
|
9
|
+
normalizeBtwKey,
|
|
10
|
+
} from "./keybindings.js";
|
|
6
11
|
import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
|
|
7
12
|
|
|
8
13
|
export const BTW_SETTINGS_FILE = "pi-btw.json";
|
|
@@ -11,6 +16,7 @@ export const DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
|
|
|
11
16
|
const MAX_SETTINGS_BYTES = 64 * 1024;
|
|
12
17
|
|
|
13
18
|
export interface BtwSettings {
|
|
19
|
+
keybindings?: BtwKeybindingOverrides;
|
|
14
20
|
model?: string;
|
|
15
21
|
thinkingLevel?: BtwThinkingLevel;
|
|
16
22
|
rememberThinkingLevelChanges?: boolean;
|
|
@@ -23,12 +29,15 @@ export type BtwSettingsLoadResult =
|
|
|
23
29
|
| { kind: "loaded"; settings: BtwSettings };
|
|
24
30
|
|
|
25
31
|
export interface BtwSettingsPatch {
|
|
32
|
+
keybindings?: BtwKeybindingOverrides;
|
|
26
33
|
thinkingLevel?: BtwThinkingLevel;
|
|
27
34
|
rememberThinkingLevelChanges?: boolean;
|
|
28
35
|
fullscreenCopyOnSelect?: boolean;
|
|
29
36
|
}
|
|
30
37
|
|
|
31
38
|
export interface UpdateBtwSettingsOptions {
|
|
39
|
+
/** Validate against the latest document inside the mutation queue, before applying the patch. */
|
|
40
|
+
validateCurrent?: (settings: BtwSettings) => void;
|
|
32
41
|
settingsPath?: string;
|
|
33
42
|
signal?: AbortSignal;
|
|
34
43
|
beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
|
|
@@ -46,6 +55,17 @@ export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
|
|
|
46
55
|
if (!isSettingsDocument(value)) return undefined;
|
|
47
56
|
|
|
48
57
|
const settings: BtwSettings = {};
|
|
58
|
+
if (Object.hasOwn(value, "keybindings")) {
|
|
59
|
+
const keys = value.keybindings;
|
|
60
|
+
if (!isSettingsDocument(keys)) return undefined;
|
|
61
|
+
settings.keybindings = {};
|
|
62
|
+
for (const action of BTW_SHORTCUT_ACTIONS) {
|
|
63
|
+
if (!Object.hasOwn(keys, action)) continue;
|
|
64
|
+
const key = normalizeBtwKey(keys[action]);
|
|
65
|
+
if (!key) return undefined;
|
|
66
|
+
settings.keybindings[action] = key;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
49
69
|
if (Object.hasOwn(value, "model")) {
|
|
50
70
|
const model = Reflect.get(value, "model");
|
|
51
71
|
if (typeof model !== "string" || !parseBtwModelReference(model)) return undefined;
|
|
@@ -101,6 +121,8 @@ export function updateBtwSettings(
|
|
|
101
121
|
return enqueueMutation(settingsPath, async () => {
|
|
102
122
|
options.signal?.throwIfAborted();
|
|
103
123
|
const current = await readSettingsDocumentForUpdate(settingsPath);
|
|
124
|
+
options.signal?.throwIfAborted();
|
|
125
|
+
options.validateCurrent?.(normalizeBtwSettings(current) ?? {});
|
|
104
126
|
const updated = applyBtwSettingsPatch(current, patch);
|
|
105
127
|
const settings = normalizeBtwSettings(updated);
|
|
106
128
|
if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
|
|
@@ -238,6 +260,16 @@ function applyBtwSettingsPatch(
|
|
|
238
260
|
patch: BtwSettingsPatch,
|
|
239
261
|
): SettingsDocument {
|
|
240
262
|
const updated: SettingsDocument = { ...current };
|
|
263
|
+
if (patch.keybindings) {
|
|
264
|
+
const keys = isSettingsDocument(current.keybindings) ? { ...current.keybindings } : {};
|
|
265
|
+
for (const action of BTW_SHORTCUT_ACTIONS) {
|
|
266
|
+
if (!Object.hasOwn(patch.keybindings, action)) continue;
|
|
267
|
+
if (patch.keybindings[action] === undefined) delete keys[action];
|
|
268
|
+
else keys[action] = patch.keybindings[action];
|
|
269
|
+
}
|
|
270
|
+
if (Object.keys(keys).length) updated.keybindings = keys;
|
|
271
|
+
else delete updated.keybindings;
|
|
272
|
+
}
|
|
241
273
|
if (Object.hasOwn(patch, "thinkingLevel")) {
|
|
242
274
|
if (patch.thinkingLevel === undefined) delete updated.thinkingLevel;
|
|
243
275
|
else updated.thinkingLevel = patch.thinkingLevel;
|
package/src/transcript-pager.ts
CHANGED
|
@@ -23,8 +23,9 @@ import {
|
|
|
23
23
|
visibleWidth,
|
|
24
24
|
} from "@earendil-works/pi-tui";
|
|
25
25
|
import type { BtwFullscreenLayoutComponent } from "./fullscreen-ui.js";
|
|
26
|
+
import { BtwPasteGuard, type BtwShortcuts, getBtwShortcuts } from "./keybindings.js";
|
|
26
27
|
import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
|
|
27
|
-
import {
|
|
28
|
+
import { sanitizeSingleLine } from "./text.js";
|
|
28
29
|
|
|
29
30
|
const TRANSCRIPT_CHROME_LINES = 2;
|
|
30
31
|
const MAX_STEERING_DISPLAY_LINES = 3;
|
|
@@ -68,6 +69,8 @@ export interface BtwAnsweringViewOptions {
|
|
|
68
69
|
}
|
|
69
70
|
|
|
70
71
|
export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusable {
|
|
72
|
+
private readonly shortcuts: BtwShortcuts;
|
|
73
|
+
private readonly pasteGuard = new BtwPasteGuard();
|
|
71
74
|
private readonly transcriptComponents: Component[];
|
|
72
75
|
private readonly editor: Editor;
|
|
73
76
|
private readonly canBringToMain: boolean;
|
|
@@ -90,6 +93,7 @@ export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusab
|
|
|
90
93
|
thinking?: BtwThinkingControl;
|
|
91
94
|
} = {},
|
|
92
95
|
) {
|
|
96
|
+
this.shortcuts = getBtwShortcuts(tui, options.thinking?.keybindings);
|
|
93
97
|
this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
|
|
94
98
|
this.canBringToMain = turns.some((turn) => turn.kind === "answered");
|
|
95
99
|
this.thinkingLevel = options.thinking?.level;
|
|
@@ -144,6 +148,7 @@ export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusab
|
|
|
144
148
|
}
|
|
145
149
|
|
|
146
150
|
render(width: number): string[] {
|
|
151
|
+
if (width <= 0) return [];
|
|
147
152
|
const safeWidth = Math.max(1, width);
|
|
148
153
|
const editorLines = this.editor.render(safeWidth);
|
|
149
154
|
const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
|
|
@@ -163,17 +168,22 @@ export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusab
|
|
|
163
168
|
this.renderFooter(safeWidth),
|
|
164
169
|
editorLines,
|
|
165
170
|
availableRows,
|
|
166
|
-
);
|
|
171
|
+
).map((line) => truncateToWidth(line, safeWidth));
|
|
167
172
|
}
|
|
168
173
|
|
|
169
174
|
handleInput(data: string): void {
|
|
170
175
|
if (this.finished) return;
|
|
171
|
-
if (
|
|
176
|
+
if (this.pasteGuard.consume(data)) {
|
|
177
|
+
this.editor.handleInput(data);
|
|
178
|
+
this.tui.requestRender();
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
if (this.shortcuts.matches(data, "exit")) {
|
|
172
182
|
this.finished = true;
|
|
173
183
|
this.onAction({ kind: "close" });
|
|
174
184
|
return;
|
|
175
185
|
}
|
|
176
|
-
if (this.canBringToMain &&
|
|
186
|
+
if (this.canBringToMain && this.shortcuts.matches(data, "bringToMain")) {
|
|
177
187
|
this.finished = true;
|
|
178
188
|
this.onAction({ kind: "bringToMain", questionDraft: this.editor.getExpandedText() });
|
|
179
189
|
return;
|
|
@@ -182,7 +192,7 @@ export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusab
|
|
|
182
192
|
if (
|
|
183
193
|
thinking &&
|
|
184
194
|
thinking.levels.length > 1 &&
|
|
185
|
-
|
|
195
|
+
this.shortcuts.matches(data, "cycleThinkingLevel")
|
|
186
196
|
) {
|
|
187
197
|
const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
|
|
188
198
|
const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
|
|
@@ -219,22 +229,28 @@ export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusab
|
|
|
219
229
|
}
|
|
220
230
|
|
|
221
231
|
private renderFooter(width: number): string {
|
|
232
|
+
const exit = this.shortcuts.label("exit");
|
|
233
|
+
const bring = this.canBringToMain && this.shortcuts.keys.bringToMain.length > 0;
|
|
234
|
+
const bringKey = this.shortcuts.label("bringToMain");
|
|
222
235
|
if (this.warning) {
|
|
223
|
-
const warning = width < 32 ?
|
|
236
|
+
const warning = width < 32 ? `Empty • ${exit}` : `${this.warning} • ${exit} exit`;
|
|
224
237
|
return truncateToWidth(this.theme.fg("warning", warning), width);
|
|
225
238
|
}
|
|
226
239
|
const scrollable = this.getMaxScrollOffset() > 0;
|
|
227
240
|
const thinking = this.options.thinking;
|
|
228
241
|
const cycleHint =
|
|
229
|
-
thinking &&
|
|
230
|
-
|
|
242
|
+
thinking &&
|
|
243
|
+
thinking.levels.length > 1 &&
|
|
244
|
+
this.thinkingLevel &&
|
|
245
|
+
this.shortcuts.keys.cycleThinkingLevel.length
|
|
246
|
+
? ` • thinking ${this.thinkingLevel} • ${this.shortcuts.label("cycleThinkingLevel")} cycle`
|
|
231
247
|
: "";
|
|
232
|
-
const base =
|
|
233
|
-
?
|
|
234
|
-
:
|
|
248
|
+
const base = bring
|
|
249
|
+
? `btw • Enter send • ${bringKey} bring to main • ${exit} exit`
|
|
250
|
+
: `btw • Enter send • ${exit} exit`;
|
|
235
251
|
const fullBase = `${base}${cycleHint}`;
|
|
236
|
-
const fallbackBase =
|
|
237
|
-
const compactBase =
|
|
252
|
+
const fallbackBase = `btw • Enter • ${exit}`;
|
|
253
|
+
const compactBase = bring ? `btw • Enter • ${bringKey} • ${exit}` : fallbackBase;
|
|
238
254
|
const compactWithThinking = `${compactBase}${cycleHint}`;
|
|
239
255
|
let hints =
|
|
240
256
|
visibleWidth(fullBase) <= width
|
|
@@ -247,8 +263,8 @@ export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusab
|
|
|
247
263
|
if (scrollable) {
|
|
248
264
|
const history = ` • ${this.scrollView.scrollTop > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
|
|
249
265
|
const compactHistory = " • PgUp/PgDn";
|
|
250
|
-
const compactScrollable =
|
|
251
|
-
?
|
|
266
|
+
const compactScrollable = bring
|
|
267
|
+
? `Enter • ${bringKey} • ${exit} • PgUp/PgDn`
|
|
252
268
|
: `${fallbackBase}${compactHistory}`;
|
|
253
269
|
if (visibleWidth(`${hints}${history}`) <= width) {
|
|
254
270
|
hints += history;
|
|
@@ -298,6 +314,8 @@ export class BtwTranscriptPager implements BtwFullscreenLayoutComponent, Focusab
|
|
|
298
314
|
}
|
|
299
315
|
|
|
300
316
|
export class BtwAnsweringView implements BtwFullscreenLayoutComponent, Focusable {
|
|
317
|
+
private readonly shortcuts: BtwShortcuts;
|
|
318
|
+
private readonly pasteGuard = new BtwPasteGuard();
|
|
301
319
|
private readonly transcriptComponents: Component[];
|
|
302
320
|
private readonly loader: Loader;
|
|
303
321
|
private readonly editor: Editor | undefined;
|
|
@@ -319,6 +337,7 @@ export class BtwAnsweringView implements BtwFullscreenLayoutComponent, Focusable
|
|
|
319
337
|
thinkingLevel?: BtwThinkingLevel,
|
|
320
338
|
private readonly options: BtwAnsweringViewOptions = {},
|
|
321
339
|
) {
|
|
340
|
+
this.shortcuts = getBtwShortcuts(tui, options.steering?.thinking?.keybindings);
|
|
322
341
|
this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
|
|
323
342
|
this.thinkingLevel = options.steering?.thinking?.level ?? thinkingLevel;
|
|
324
343
|
this.loader = new Loader(
|
|
@@ -389,6 +408,7 @@ export class BtwAnsweringView implements BtwFullscreenLayoutComponent, Focusable
|
|
|
389
408
|
}
|
|
390
409
|
|
|
391
410
|
render(width: number): string[] {
|
|
411
|
+
if (width <= 0) return [];
|
|
392
412
|
const safeWidth = Math.max(1, width);
|
|
393
413
|
const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
|
|
394
414
|
const editorLines = this.editor?.render(safeWidth) ?? [];
|
|
@@ -419,12 +439,17 @@ export class BtwAnsweringView implements BtwFullscreenLayoutComponent, Focusable
|
|
|
419
439
|
editorLines,
|
|
420
440
|
availableRows,
|
|
421
441
|
steeringLines,
|
|
422
|
-
);
|
|
442
|
+
).map((line) => truncateToWidth(line, safeWidth));
|
|
423
443
|
}
|
|
424
444
|
|
|
425
445
|
handleInput(data: string): void {
|
|
426
446
|
if (this.finished) return;
|
|
427
|
-
if (
|
|
447
|
+
if (this.pasteGuard.consume(data)) {
|
|
448
|
+
this.editor?.handleInput(data);
|
|
449
|
+
this.tui.requestRender();
|
|
450
|
+
return;
|
|
451
|
+
}
|
|
452
|
+
if (this.shortcuts.matches(data, "exit")) {
|
|
428
453
|
this.finished = true;
|
|
429
454
|
this.loader.stop();
|
|
430
455
|
this.controller.abort();
|
|
@@ -435,7 +460,7 @@ export class BtwAnsweringView implements BtwFullscreenLayoutComponent, Focusable
|
|
|
435
460
|
if (
|
|
436
461
|
thinking &&
|
|
437
462
|
thinking.levels.length > 1 &&
|
|
438
|
-
|
|
463
|
+
this.shortcuts.matches(data, "cycleThinkingLevel")
|
|
439
464
|
) {
|
|
440
465
|
const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
|
|
441
466
|
const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
|
|
@@ -483,19 +508,23 @@ export class BtwAnsweringView implements BtwFullscreenLayoutComponent, Focusable
|
|
|
483
508
|
}
|
|
484
509
|
|
|
485
510
|
private renderFooter(width: number): string {
|
|
511
|
+
const exit = this.shortcuts.label("exit");
|
|
486
512
|
if (this.warning) {
|
|
487
|
-
const warning = width < 32 ?
|
|
513
|
+
const warning = width < 32 ? `Empty • ${exit}` : `${this.warning} • ${exit} cancel`;
|
|
488
514
|
return truncateToWidth(this.theme.fg("warning", warning), width);
|
|
489
515
|
}
|
|
490
|
-
const baseHint = this.editor ?
|
|
516
|
+
const baseHint = this.editor ? `Enter steer • ${exit} cancel` : `${exit} cancel`;
|
|
491
517
|
const thinking = this.options.steering?.thinking;
|
|
492
518
|
const cycleHint =
|
|
493
|
-
thinking &&
|
|
494
|
-
|
|
519
|
+
thinking &&
|
|
520
|
+
thinking.levels.length > 1 &&
|
|
521
|
+
this.thinkingLevel &&
|
|
522
|
+
this.shortcuts.keys.cycleThinkingLevel.length
|
|
523
|
+
? ` • thinking ${this.thinkingLevel} • ${this.shortcuts.label("cycleThinkingLevel")} cycle`
|
|
495
524
|
: "";
|
|
496
525
|
const scrollHint = this.getMaxScrollOffset() > 0 ? " • PgUp/PgDn history" : "";
|
|
497
526
|
const hints = `${baseHint}${cycleHint}${scrollHint}`;
|
|
498
|
-
const compactHints = this.editor ?
|
|
527
|
+
const compactHints = this.editor ? `Enter • ${exit}` : exit;
|
|
499
528
|
const selectedHints = visibleWidth(hints) <= width ? hints : compactHints;
|
|
500
529
|
const loaderWidth = Math.max(1, width - visibleWidth(selectedHints) - 3);
|
|
501
530
|
const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
|
|
@@ -612,13 +641,6 @@ function renderSideThreadHeader(
|
|
|
612
641
|
return theme.fg("muted", `${title}${"─".repeat(ruleWidth)}`);
|
|
613
642
|
}
|
|
614
643
|
|
|
615
|
-
function thinkingKeyLabel(keybindings: KeybindingsManager): string {
|
|
616
|
-
return (
|
|
617
|
-
formatKeyLabel(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
|
|
618
|
-
"Shift+Tab"
|
|
619
|
-
);
|
|
620
|
-
}
|
|
621
|
-
|
|
622
644
|
function fitComposerLayout(
|
|
623
645
|
header: string,
|
|
624
646
|
contentLines: string[],
|