@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/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;
@@ -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 { formatKeyLabel, sanitizeSingleLine } from "./text.js";
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 (matchesKey(data, Key.ctrl("c"))) {
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 && matchesKey(data, Key.ctrl("r"))) {
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
- thinking.keybindings.matches(data, "app.thinking.cycle")
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 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C exit`;
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 && thinking.levels.length > 1 && this.thinkingLevel
230
- ? ` • thinking ${this.thinkingLevel} ${thinkingKeyLabel(thinking.keybindings)} cycle`
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 = this.canBringToMain
233
- ? "btw • Enter send • Ctrl+R bring to main • Ctrl+C exit"
234
- : "btw • Enter send • Ctrl+C exit";
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 = "btw • Enter • Ctrl+C";
237
- const compactBase = this.canBringToMain ? "btw • Enter • Ctrl+RCtrl+C" : fallbackBase;
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 = this.canBringToMain
251
- ? "Enter • Ctrl+RCtrl+C • PgUp/PgDn"
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 (matchesKey(data, Key.ctrl("c"))) {
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
- thinking.keybindings.matches(data, "app.thinking.cycle")
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 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C cancel`;
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 ? "Enter steer • Ctrl+C cancel" : "Ctrl+C cancel";
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 && thinking.levels.length > 1 && this.thinkingLevel
494
- ? ` • thinking ${this.thinkingLevel} ${thinkingKeyLabel(thinking.keybindings)} cycle`
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 ? "Enter • Ctrl+C" : "Ctrl+C";
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[],