@narumitw/pi-btw 0.57.0 → 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/dist/index.ts CHANGED
@@ -481,15 +481,23 @@ import {
481
481
  copyToClipboard as copyToHostClipboard
482
482
  } from "@earendil-works/pi-coding-agent";
483
483
  import {
484
- isKeyRelease,
485
- isKittyProtocolActive,
484
+ isKeyRelease as isKeyRelease2,
485
+ isKittyProtocolActive as isKittyProtocolActive2,
486
486
  Key as Key2,
487
- matchesKey as matchesKey2,
488
487
  parseKey,
489
488
  TuiAltScreen,
490
489
  truncateToWidth as truncateToWidth2
491
490
  } from "@earendil-works/pi-tui";
492
491
 
492
+ // src/keybindings.ts
493
+ import {
494
+ isKeyRelease,
495
+ isKittyProtocolActive,
496
+ KeybindingsManager,
497
+ matchesKey as matchesKey2,
498
+ TUI_KEYBINDINGS
499
+ } from "@earendil-works/pi-tui";
500
+
493
501
  // src/text.ts
494
502
  function sanitizeSingleLine(text) {
495
503
  return [...text.replace(/[\r\n\t]/gu, " ")].filter((character) => {
@@ -510,6 +518,234 @@ function formatKeyLabel2(key) {
510
518
  }).join("+");
511
519
  }
512
520
 
521
+ // src/keybindings.ts
522
+ var BTW_SHORTCUT_ACTIONS = ["exit", "cycleThinkingLevel", "bringToMain"];
523
+ var MODIFIERS = ["shift", "alt", "ctrl", "super"];
524
+ var SYMBOLS = "`-=[]\\;',./!@#$%^&*()_|~{}:<>?";
525
+ var SPECIAL = {
526
+ escape: 27,
527
+ tab: 9,
528
+ enter: 13,
529
+ space: 32,
530
+ backspace: 127,
531
+ insert: 57425,
532
+ delete: 57426,
533
+ home: 57423,
534
+ end: 57424,
535
+ pageup: 57421,
536
+ pagedown: 57422,
537
+ left: 57417,
538
+ right: 57418,
539
+ up: 57419,
540
+ down: 57420
541
+ };
542
+ var FUNCTION_INPUTS = [
543
+ "OP",
544
+ "OQ",
545
+ "OR",
546
+ "OS",
547
+ "[15~",
548
+ "[17~",
549
+ "[18~",
550
+ "[19~",
551
+ "[20~",
552
+ "[21~",
553
+ "[23~",
554
+ "[24~"
555
+ ];
556
+ var LEGACY_INPUTS = [
557
+ ...Array.from({ length: 128 }, (_, code) => String.fromCharCode(code)),
558
+ ...Array.from({ length: 128 }, (_, code) => `\x1B${String.fromCharCode(code)}`),
559
+ "\x1B[Z",
560
+ "\x1BOM",
561
+ "\x1B[E",
562
+ "\x1B[e",
563
+ "\x1BOe",
564
+ ...FUNCTION_INPUTS.map((suffix) => `\x1B${suffix}`)
565
+ ];
566
+ function normalizeBtwKey(value) {
567
+ if (typeof value !== "string" || value.length > 80 || /[\s\p{Cc}]/u.test(value)) return void 0;
568
+ const parts = value.toLowerCase().split("+");
569
+ let base = parts.pop();
570
+ if (!base) return void 0;
571
+ if (base === "esc") base = "escape";
572
+ if (base === "return") base = "enter";
573
+ if (new Set(parts).size !== parts.length || parts.some((part) => !MODIFIERS.includes(part)))
574
+ return void 0;
575
+ if (!Object.hasOwn(SPECIAL, base) && base !== "clear" && !/^f(?:[1-9]|1[0-2])$/u.test(base) && !(base.length === 1 && (/^[a-z0-9]$/u.test(base) || SYMBOLS.includes(base))))
576
+ return void 0;
577
+ if ((base === "escape" || base.startsWith("f") && base.length > 1) && parts.length)
578
+ return void 0;
579
+ if (base === "clear" && (parts.length > 1 || parts.length === 1 && !["ctrl", "shift"].includes(parts[0] ?? "")))
580
+ return void 0;
581
+ return [...MODIFIERS.filter((part) => parts.includes(part)), base].join("+");
582
+ }
583
+ function inputsFor(key) {
584
+ const parts = key.split("+");
585
+ const base = parts.pop() ?? "";
586
+ const modifier = MODIFIERS.reduce(
587
+ (mask, part, bit) => mask | (parts.includes(part) ? 1 << bit : 0),
588
+ 0
589
+ );
590
+ const code = SPECIAL[base] ?? (base.length === 1 ? base.charCodeAt(0) : void 0);
591
+ const inputs = code === void 0 ? LEGACY_INPUTS : [...LEGACY_INPUTS, `\x1B[${code};${modifier + 1}u`];
592
+ return inputs.filter((input) => matchesKey2(input, key));
593
+ }
594
+ function btwKeysOverlap(first, second) {
595
+ const normalized = normalizeBtwKey(first);
596
+ return normalized !== void 0 && inputsFor(normalized).some((input) => matchesKey2(input, second));
597
+ }
598
+ function reservedKeys(keybindings, copyOnSelect) {
599
+ return [
600
+ "ctrl+c",
601
+ "shift+backspace",
602
+ "shift+delete",
603
+ "shift+space",
604
+ // Exact-range review uses these fixed selection actions even if Editor is remapped.
605
+ "shift+left",
606
+ "shift+right",
607
+ "shift+up",
608
+ "shift+down",
609
+ "left",
610
+ "right",
611
+ "enter",
612
+ "alt+enter",
613
+ "ctrl+j",
614
+ "pageUp",
615
+ "pageDown",
616
+ ...Object.keys(TUI_KEYBINDINGS).flatMap(
617
+ (id) => keybindings.getKeys(id)
618
+ ),
619
+ ...!copyOnSelect ? keybindings.getKeys("app.message.copy") : []
620
+ ];
621
+ }
622
+ function isTextKey(key) {
623
+ const parts = key.split("+");
624
+ const base = parts.at(-1) ?? "";
625
+ return (base.length === 1 || base === "space") && !parts.some((part) => ["ctrl", "alt", "super"].includes(part));
626
+ }
627
+ function resolveBtwShortcuts(overrides = {}, keybindings, copyOnSelect = true) {
628
+ let mode = isKittyProtocolActive();
629
+ let snapshot = resolveShortcutSnapshot(overrides, keybindings, copyOnSelect);
630
+ const current = () => {
631
+ if (mode !== isKittyProtocolActive()) {
632
+ mode = isKittyProtocolActive();
633
+ snapshot = resolveShortcutSnapshot(overrides, keybindings, copyOnSelect);
634
+ }
635
+ return snapshot;
636
+ };
637
+ return {
638
+ get keys() {
639
+ return current().keys;
640
+ },
641
+ get warnings() {
642
+ return current().warnings;
643
+ },
644
+ matches: (data, action) => current().matches(data, action),
645
+ label: (action) => current().label(action)
646
+ };
647
+ }
648
+ function resolveShortcutSnapshot(overrides = {}, keybindings, copyOnSelect = true) {
649
+ const reserved = reservedKeys(keybindings, copyOnSelect);
650
+ const keys = { exit: ["ctrl+c"], cycleThinkingLevel: [], bringToMain: [] };
651
+ const warnings = [];
652
+ const defaults = {
653
+ exit: ["ctrl+c"],
654
+ cycleThinkingLevel: keybindings.getKeys("app.thinking.cycle"),
655
+ bringToMain: ["ctrl+r"]
656
+ };
657
+ const usable = (action, candidate, inherited = false) => {
658
+ const key = normalizeBtwKey(candidate);
659
+ if (!key || !inherited && isTextKey(key)) return void 0;
660
+ const inputs = inputsFor(key);
661
+ if (!inputs.length) return void 0;
662
+ if (action === "exit" && key === "ctrl+c") return key;
663
+ if (reserved.some(
664
+ (other) => typeof other === "string" && inputs.some((input) => matchesKey2(input, other))
665
+ ))
666
+ return void 0;
667
+ return key;
668
+ };
669
+ const candidates = {};
670
+ const availableDefaults = { ...defaults };
671
+ for (const action of BTW_SHORTCUT_ACTIONS) {
672
+ availableDefaults[action] = defaults[action].map((key) => usable(action, key, action === "cycleThinkingLevel")).filter((key) => key !== void 0);
673
+ const override = overrides[action];
674
+ if (override !== void 0) candidates[action] = usable(action, override);
675
+ }
676
+ for (; ; ) {
677
+ const rejected = BTW_SHORTCUT_ACTIONS.filter((action) => {
678
+ const candidate = candidates[action];
679
+ return candidate !== void 0 && BTW_SHORTCUT_ACTIONS.some(
680
+ (other) => other !== action && (candidates[other] ? [candidates[other]] : availableDefaults[other]).some(
681
+ (key) => btwKeysOverlap(candidate, key)
682
+ )
683
+ );
684
+ });
685
+ if (!rejected.length) break;
686
+ for (const action of rejected) delete candidates[action];
687
+ }
688
+ for (const action of BTW_SHORTCUT_ACTIONS) {
689
+ const override = overrides[action];
690
+ const selected = candidates[action];
691
+ if (override !== void 0 && !selected)
692
+ warnings.push(
693
+ `${action}: configured shortcut is invalid or conflicts with a reserved action; using an available default.`
694
+ );
695
+ const effective = selected ? [selected] : availableDefaults[action].filter(
696
+ (key) => !BTW_SHORTCUT_ACTIONS.some(
697
+ (other) => other !== action && keys[other].some((used) => btwKeysOverlap(key, used))
698
+ )
699
+ );
700
+ keys[action] = action === "exit" ? [.../* @__PURE__ */ new Set([...effective, "ctrl+c"])] : effective;
701
+ if (!keys[action].length && (override !== void 0 || defaults[action].length > 0))
702
+ warnings.push(`${action}: no usable shortcut; change Pi BTW Settings or Pi keybindings.`);
703
+ }
704
+ return {
705
+ keys,
706
+ warnings,
707
+ matches: (data, action) => !isKeyRelease(data) && keys[action].some((key) => matchesKey2(data, key)),
708
+ label: (action) => keys[action].length ? formatKeyLabel2(keys[action][0] ?? "") : "Unavailable"
709
+ };
710
+ }
711
+ function validateBtwShortcutEdit(action, value, overrides, keybindings, copyOnSelect) {
712
+ if (value === void 0) return void 0;
713
+ if (!normalizeBtwKey(value))
714
+ return "Invalid key combination. Use a Pi key name such as ctrl+q or f6.";
715
+ const next = { ...overrides, [action]: value };
716
+ const resolved = resolveBtwShortcuts(next, keybindings, copyOnSelect);
717
+ const previous = resolveBtwShortcuts(overrides, keybindings, copyOnSelect);
718
+ for (const item of BTW_SHORTCUT_ACTIONS) {
719
+ if (item === action && !resolved.keys[item].includes(normalizeBtwKey(value) ?? "") || item !== action && previous.keys[item].some((key) => !resolved.keys[item].includes(key))) {
720
+ return `${item} conflicts with another BTW shortcut, editing, selection, search, scrolling, or copying. Choose a different key.`;
721
+ }
722
+ }
723
+ return void 0;
724
+ }
725
+ var bindingsByTui = /* @__PURE__ */ new WeakMap();
726
+ function setBtwShortcuts(tui, shortcuts) {
727
+ bindingsByTui.set(tui, shortcuts);
728
+ }
729
+ function getBtwShortcuts(tui, keybindings) {
730
+ return bindingsByTui.get(tui) ?? resolveBtwShortcuts(
731
+ {},
732
+ keybindings ?? new KeybindingsManager({
733
+ ...TUI_KEYBINDINGS,
734
+ "app.thinking.cycle": { defaultKeys: "shift+tab" }
735
+ })
736
+ );
737
+ }
738
+ var BtwPasteGuard = class {
739
+ active = false;
740
+ consume(data) {
741
+ const wasActive = this.active;
742
+ const starts = data.includes("\x1B[200~");
743
+ if (starts) this.active = true;
744
+ if (this.active && data.includes("\x1B[201~")) this.active = false;
745
+ return wasActive || starts;
746
+ }
747
+ };
748
+
513
749
  // src/fullscreen-ui.ts
514
750
  var FullscreenUiDisposedError = class extends Error {
515
751
  constructor() {
@@ -690,7 +926,7 @@ function legacyRawInput(key) {
690
926
  const parts = key.split("+");
691
927
  const base = parts.at(-1) ?? "";
692
928
  if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
693
- if (isKittyProtocolActive()) return void 0;
929
+ if (isKittyProtocolActive2()) return void 0;
694
930
  if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\x1B${base}`;
695
931
  if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
696
932
  const input = rawCtrlInput(base);
@@ -761,7 +997,7 @@ function createBtwFullscreenTui(parent, theme, keybindings, copyOnSelect, manual
761
997
  if (isInBracketedPaste && data.includes(BRACKETED_PASTE_END)) {
762
998
  isInBracketedPaste = false;
763
999
  }
764
- if (wasInBracketedPaste || startsBracketedPaste || fullscreen.hasFocusedOverlay() || isKeyRelease(data) || !keybindings.matches(data, "app.message.copy")) {
1000
+ if (wasInBracketedPaste || startsBracketedPaste || fullscreen.hasFocusedOverlay() || isKeyRelease2(data) || !keybindings.matches(data, "app.message.copy")) {
765
1001
  return void 0;
766
1002
  }
767
1003
  if (!fullscreen.hasActiveSelection()) {
@@ -839,9 +1075,30 @@ var BtwFullscreenHost = class {
839
1075
  this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
840
1076
  this.fullscreenCreated = true;
841
1077
  this.fullscreen.start();
1078
+ const shortcuts = resolveBtwShortcuts(
1079
+ this.options.keybindings,
1080
+ this.keybindings,
1081
+ this.options.copyOnSelect ?? true
1082
+ );
1083
+ setBtwShortcuts(this.fullscreen, shortcuts);
1084
+ let previousWarnings = [];
1085
+ const reportWarnings = () => {
1086
+ const warnings = shortcuts.warnings;
1087
+ for (const warning of warnings) {
1088
+ if (previousWarnings.includes(warning)) continue;
1089
+ try {
1090
+ this.ctx.ui.notify(`Pi BTW: ${warning}`, "warning");
1091
+ } catch {
1092
+ }
1093
+ }
1094
+ previousWarnings = warnings;
1095
+ };
1096
+ const pasteGuard = new BtwPasteGuard();
842
1097
  const addHardCancelListener = this.fullscreen.addInputListenerBeforeAll?.bind(this.fullscreen) ?? this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ?? this.fullscreen.addInputListener.bind(this.fullscreen);
843
1098
  this.removeHardCancelListener = addHardCancelListener((data) => {
844
- if (isKeyRelease(data) || !matchesKey2(data, Key2.ctrl("c"))) return void 0;
1099
+ reportWarnings();
1100
+ if (pasteGuard.consume(data) || !shortcuts.matches(data, "exit")) return void 0;
1101
+ this.disposed = true;
845
1102
  try {
846
1103
  this.hardCancelActiveCustom?.();
847
1104
  } finally {
@@ -1125,14 +1382,15 @@ async function completeSideThreadTurn({
1125
1382
  thinkingLevel,
1126
1383
  auth,
1127
1384
  signal,
1128
- completeSimple
1385
+ completeSimple,
1386
+ sessionId
1129
1387
  }) {
1130
1388
  if (signal?.aborted) return { kind: "aborted" };
1131
1389
  try {
1132
1390
  const response = await completeSimple(
1133
1391
  model,
1134
1392
  { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
1135
- buildStreamOptions(auth, thinkingLevel, signal)
1393
+ buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId })
1136
1394
  );
1137
1395
  if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
1138
1396
  if (!isAssistantMessage(response)) {
@@ -1191,10 +1449,31 @@ function createUserMessage(text) {
1191
1449
  timestamp: Date.now()
1192
1450
  };
1193
1451
  }
1194
- function buildStreamOptions(auth, thinkingLevel, signal) {
1452
+ var OPENCODE_HOST = "opencode.ai";
1453
+ function matchesOpencodeHost(baseUrl) {
1454
+ if (!baseUrl) return false;
1455
+ try {
1456
+ return new URL(baseUrl).hostname === OPENCODE_HOST;
1457
+ } catch {
1458
+ return false;
1459
+ }
1460
+ }
1461
+ function getOpencodeSessionHeaders(model, sessionId) {
1462
+ if (!sessionId) return void 0;
1463
+ if (model.provider !== "opencode" && model.provider !== "opencode-go" && !matchesOpencodeHost(model.baseUrl)) {
1464
+ return void 0;
1465
+ }
1466
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
1467
+ }
1468
+ function mergeSessionHeaders(authHeaders, sessionHeaders) {
1469
+ if (!sessionHeaders && !authHeaders) return void 0;
1470
+ return { ...sessionHeaders, ...authHeaders };
1471
+ }
1472
+ function buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId }) {
1473
+ const sessionHeaders = model ? getOpencodeSessionHeaders(model, sessionId) : void 0;
1195
1474
  const options = {
1196
1475
  apiKey: auth.apiKey,
1197
- headers: auth.headers,
1476
+ headers: mergeSessionHeaders(auth.headers, sessionHeaders),
1198
1477
  env: auth.env,
1199
1478
  signal
1200
1479
  };
@@ -1220,6 +1499,17 @@ function btwSettingsPath() {
1220
1499
  function normalizeBtwSettings(value) {
1221
1500
  if (!isSettingsDocument(value)) return void 0;
1222
1501
  const settings = {};
1502
+ if (Object.hasOwn(value, "keybindings")) {
1503
+ const keys = value.keybindings;
1504
+ if (!isSettingsDocument(keys)) return void 0;
1505
+ settings.keybindings = {};
1506
+ for (const action of BTW_SHORTCUT_ACTIONS) {
1507
+ if (!Object.hasOwn(keys, action)) continue;
1508
+ const key = normalizeBtwKey(keys[action]);
1509
+ if (!key) return void 0;
1510
+ settings.keybindings[action] = key;
1511
+ }
1512
+ }
1223
1513
  if (Object.hasOwn(value, "model")) {
1224
1514
  const model = Reflect.get(value, "model");
1225
1515
  if (typeof model !== "string" || !parseBtwModelReference(model)) return void 0;
@@ -1263,6 +1553,8 @@ function updateBtwSettings(patch, options = {}) {
1263
1553
  return enqueueMutation(settingsPath, async () => {
1264
1554
  options.signal?.throwIfAborted();
1265
1555
  const current = await readSettingsDocumentForUpdate(settingsPath);
1556
+ options.signal?.throwIfAborted();
1557
+ options.validateCurrent?.(normalizeBtwSettings(current) ?? {});
1266
1558
  const updated = applyBtwSettingsPatch(current, patch);
1267
1559
  const settings = normalizeBtwSettings(updated);
1268
1560
  if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
@@ -1381,6 +1673,16 @@ async function publishSettings(settingsPath, document, signal, beforeRename) {
1381
1673
  }
1382
1674
  function applyBtwSettingsPatch(current, patch) {
1383
1675
  const updated = { ...current };
1676
+ if (patch.keybindings) {
1677
+ const keys = isSettingsDocument(current.keybindings) ? { ...current.keybindings } : {};
1678
+ for (const action of BTW_SHORTCUT_ACTIONS) {
1679
+ if (!Object.hasOwn(patch.keybindings, action)) continue;
1680
+ if (patch.keybindings[action] === void 0) delete keys[action];
1681
+ else keys[action] = patch.keybindings[action];
1682
+ }
1683
+ if (Object.keys(keys).length) updated.keybindings = keys;
1684
+ else delete updated.keybindings;
1685
+ }
1384
1686
  if (Object.hasOwn(patch, "thinkingLevel")) {
1385
1687
  if (patch.thinkingLevel === void 0) delete updated.thinkingLevel;
1386
1688
  else updated.thinkingLevel = patch.thinkingLevel;
@@ -1425,6 +1727,64 @@ async function showBtwCommandMenu(ctx, options) {
1425
1727
  let startSelected = false;
1426
1728
  let treeSelected = false;
1427
1729
  let resumedThreadId;
1730
+ let keybindings;
1731
+ let shortcut = "exit";
1732
+ const shortcutLabels = {
1733
+ exit: "Exit shortcut",
1734
+ cycleThinkingLevel: "Cycle thinking level shortcut",
1735
+ bringToMain: "Bring to main shortcut"
1736
+ };
1737
+ const shortcutValue = (settings, action) => {
1738
+ if (!keybindings) return "Default";
1739
+ const effective = resolveBtwShortcuts(
1740
+ settings.keybindings,
1741
+ keybindings,
1742
+ effectiveFullscreenCopyOnSelect(settings)
1743
+ );
1744
+ const configured = settings.keybindings?.[action];
1745
+ if (configured !== void 0 && !effective.keys[action].includes(configured)) {
1746
+ return `Fallback (${effective.label(action)}; saved ${formatKeyLabel2(configured)})`;
1747
+ }
1748
+ const source = configured === void 0 ? action === "cycleThinkingLevel" ? "Inherit Pi" : "Default" : "Custom";
1749
+ return `${source} (${effective.label(action)})`;
1750
+ };
1751
+ const saveShortcut = async (state, value, signal) => {
1752
+ if (!keybindings || state.kind !== "valid" || signal.aborted)
1753
+ return { kind: "rejected" };
1754
+ const action = shortcut;
1755
+ const manager = keybindings;
1756
+ const validate = (settings) => validateBtwShortcutEdit(
1757
+ action,
1758
+ value,
1759
+ settings.keybindings ?? {},
1760
+ manager,
1761
+ effectiveFullscreenCopyOnSelect(settings)
1762
+ );
1763
+ const error = validate(state.settings);
1764
+ if (error) {
1765
+ notifySafely(ctx, error, "error");
1766
+ return { kind: "rejected" };
1767
+ }
1768
+ try {
1769
+ await updateSettings(
1770
+ { keybindings: { [action]: value === void 0 ? void 0 : normalizeBtwKey(value) } },
1771
+ {
1772
+ settingsPath,
1773
+ signal,
1774
+ validateCurrent: (settings) => {
1775
+ const conflict = validate(settings);
1776
+ if (conflict) throw new Error(conflict);
1777
+ }
1778
+ }
1779
+ );
1780
+ if (signal.aborted) return { kind: "rejected" };
1781
+ notifySafely(ctx, "Pi BTW shortcut saved; applies when opening or resuming BTW.", "info");
1782
+ return { kind: "back" };
1783
+ } catch (error2) {
1784
+ if (!signal.aborted) notifySaveFailure(ctx, error2);
1785
+ return { kind: "rejected" };
1786
+ }
1787
+ };
1428
1788
  const loadState = async () => {
1429
1789
  const loaded = await readSettings(settingsPath);
1430
1790
  if (loaded.kind === "invalid") {
@@ -1476,7 +1836,7 @@ async function showBtwCommandMenu(ctx, options) {
1476
1836
  {
1477
1837
  id: "settings",
1478
1838
  label: "Settings",
1479
- description: "Choose thinking, shortcut memory, and selection copying",
1839
+ description: "Choose thinking, keybindings, and selection copying",
1480
1840
  to: state.kind === "invalid" ? "invalid" : "settings"
1481
1841
  }
1482
1842
  ],
@@ -1523,9 +1883,34 @@ async function showBtwCommandMenu(ctx, options) {
1523
1883
  currentValue: effectiveFullscreenCopyOnSelect(state.settings) ? "On" : "Off",
1524
1884
  values: ["On", "Off"],
1525
1885
  action: "set-fullscreen-copy"
1526
- }
1886
+ },
1887
+ ...BTW_SHORTCUT_ACTIONS.map((action) => ({
1888
+ id: action,
1889
+ label: shortcutLabels[action],
1890
+ description: "Edit a BTW-only key combination or restore its default. Ctrl+C always hard-cancels.",
1891
+ currentValue: shortcutValue(state.settings, action),
1892
+ action: "edit-shortcut"
1893
+ }))
1527
1894
  ]
1528
1895
  }),
1896
+ shortcut: ({ state }) => ({
1897
+ kind: "actions",
1898
+ title: shortcutLabels[shortcut],
1899
+ lines: [shortcutValue(state.settings, shortcut), "Ctrl+C always hard-cancels BTW."],
1900
+ items: [
1901
+ { id: "edit", label: "Edit key combination\u2026", to: "shortcut-input" },
1902
+ { id: "reset", label: "Restore default", action: "reset-shortcut" }
1903
+ ],
1904
+ hint: "back"
1905
+ }),
1906
+ "shortcut-input": () => ({
1907
+ kind: "input",
1908
+ title: shortcutLabels[shortcut],
1909
+ lines: ["Type a key name, not the shortcut itself. For example: ctrl+q or f6."],
1910
+ placeholder: "Key combination",
1911
+ action: "save-shortcut",
1912
+ hint: "back"
1913
+ }),
1529
1914
  invalid: ({ state }) => ({
1530
1915
  kind: "detail",
1531
1916
  title: "Pi BTW Settings \xB7 Read only",
@@ -1537,6 +1922,14 @@ async function showBtwCommandMenu(ctx, options) {
1537
1922
  })
1538
1923
  },
1539
1924
  actions: {
1925
+ "edit-shortcut": ({ itemId }) => {
1926
+ if (!BTW_SHORTCUT_ACTIONS.includes(itemId))
1927
+ return { kind: "rejected" };
1928
+ shortcut = itemId;
1929
+ return { kind: "to", screen: "shortcut" };
1930
+ },
1931
+ "save-shortcut": ({ state, value, signal }) => saveShortcut(state, value?.trim() ?? "", signal),
1932
+ "reset-shortcut": ({ state, signal }) => saveShortcut(state, void 0, signal),
1540
1933
  start: async () => {
1541
1934
  startSelected = true;
1542
1935
  return { kind: "close" };
@@ -1600,7 +1993,10 @@ async function showBtwCommandMenu(ctx, options) {
1600
1993
  });
1601
1994
  const result = await runBtwMenuPreservingEditor(
1602
1995
  ctx,
1603
- (menuContext) => runMenu(menuContext, menu, { getState: loadState })
1996
+ (menuContext) => runMenu(menuContext, menu, { getState: loadState }),
1997
+ (manager) => {
1998
+ keybindings = manager;
1999
+ }
1604
2000
  );
1605
2001
  if (result.kind !== "closed" || result.reason !== "close") return "closed";
1606
2002
  if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
@@ -1628,23 +2024,23 @@ async function showBtwCustomPreservingEditor(ctx, factory) {
1628
2024
  }
1629
2025
  return result;
1630
2026
  }
1631
- async function runBtwMenuPreservingEditor(ctx, run) {
2027
+ async function runBtwMenuPreservingEditor(ctx, run, onKeybindings) {
1632
2028
  let liveEditorText = ctx.ui.getEditorText();
1633
2029
  let completed = false;
1634
2030
  const ui = new Proxy(ctx.ui, {
1635
2031
  get(target, property) {
1636
2032
  if (property === "custom") {
1637
- return (factory, customOptions) => target.custom(
1638
- (tui, theme, keybindings, done) => factory(tui, theme, keybindings, (value2) => {
2033
+ return (factory, customOptions) => target.custom((tui, theme, keybindings, done) => {
2034
+ onKeybindings?.(keybindings);
2035
+ return factory(tui, theme, keybindings, (value2) => {
1639
2036
  try {
1640
2037
  liveEditorText = target.getEditorText();
1641
2038
  } catch {
1642
2039
  }
1643
2040
  completed = true;
1644
2041
  done(value2);
1645
- }),
1646
- customOptions
1647
- );
2042
+ });
2043
+ }, customOptions);
1648
2044
  }
1649
2045
  const value = Reflect.get(target, property, target);
1650
2046
  return typeof value === "function" ? value.bind(target) : value;
@@ -2030,6 +2426,7 @@ var BtwTranscriptPager = class {
2030
2426
  this.theme = theme;
2031
2427
  this.onAction = onAction;
2032
2428
  this.options = options;
2429
+ this.shortcuts = getBtwShortcuts(tui, options.thinking?.keybindings);
2033
2430
  this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
2034
2431
  this.canBringToMain = turns.some((turn) => turn.kind === "answered");
2035
2432
  this.thinkingLevel = options.thinking?.level;
@@ -2073,6 +2470,8 @@ var BtwTranscriptPager = class {
2073
2470
  theme;
2074
2471
  onAction;
2075
2472
  options;
2473
+ shortcuts;
2474
+ pasteGuard = new BtwPasteGuard();
2076
2475
  transcriptComponents;
2077
2476
  editor;
2078
2477
  canBringToMain;
@@ -2094,6 +2493,7 @@ var BtwTranscriptPager = class {
2094
2493
  return this.layoutRoot;
2095
2494
  }
2096
2495
  render(width) {
2496
+ if (width <= 0) return [];
2097
2497
  const safeWidth = Math.max(1, width);
2098
2498
  const editorLines = this.editor.render(safeWidth);
2099
2499
  const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
@@ -2114,22 +2514,27 @@ var BtwTranscriptPager = class {
2114
2514
  this.renderFooter(safeWidth),
2115
2515
  editorLines,
2116
2516
  availableRows
2117
- );
2517
+ ).map((line) => truncateToWidth3(line, safeWidth));
2118
2518
  }
2119
2519
  handleInput(data) {
2120
2520
  if (this.finished) return;
2121
- if (matchesKey4(data, Key4.ctrl("c"))) {
2521
+ if (this.pasteGuard.consume(data)) {
2522
+ this.editor.handleInput(data);
2523
+ this.tui.requestRender();
2524
+ return;
2525
+ }
2526
+ if (this.shortcuts.matches(data, "exit")) {
2122
2527
  this.finished = true;
2123
2528
  this.onAction({ kind: "close" });
2124
2529
  return;
2125
2530
  }
2126
- if (this.canBringToMain && matchesKey4(data, Key4.ctrl("r"))) {
2531
+ if (this.canBringToMain && this.shortcuts.matches(data, "bringToMain")) {
2127
2532
  this.finished = true;
2128
2533
  this.onAction({ kind: "bringToMain", questionDraft: this.editor.getExpandedText() });
2129
2534
  return;
2130
2535
  }
2131
2536
  const thinking = this.options.thinking;
2132
- if (thinking && thinking.levels.length > 1 && thinking.keybindings.matches(data, "app.thinking.cycle")) {
2537
+ if (thinking && thinking.levels.length > 1 && this.shortcuts.matches(data, "cycleThinkingLevel")) {
2133
2538
  const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
2134
2539
  const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
2135
2540
  if (nextLevel) {
@@ -2162,23 +2567,26 @@ var BtwTranscriptPager = class {
2162
2567
  this.onAction({ kind: "close" });
2163
2568
  }
2164
2569
  renderFooter(width) {
2570
+ const exit = this.shortcuts.label("exit");
2571
+ const bring = this.canBringToMain && this.shortcuts.keys.bringToMain.length > 0;
2572
+ const bringKey = this.shortcuts.label("bringToMain");
2165
2573
  if (this.warning) {
2166
- const warning = width < 32 ? "Empty \u2022 Ctrl+C" : `${this.warning} \u2022 Ctrl+C exit`;
2574
+ const warning = width < 32 ? `Empty \u2022 ${exit}` : `${this.warning} \u2022 ${exit} exit`;
2167
2575
  return truncateToWidth3(this.theme.fg("warning", warning), width);
2168
2576
  }
2169
2577
  const scrollable = this.getMaxScrollOffset() > 0;
2170
2578
  const thinking = this.options.thinking;
2171
- const cycleHint = thinking && thinking.levels.length > 1 && this.thinkingLevel ? ` \u2022 thinking ${this.thinkingLevel} \u2022 ${thinkingKeyLabel(thinking.keybindings)} cycle` : "";
2172
- const base = this.canBringToMain ? "btw \u2022 Enter send \u2022 Ctrl+R bring to main \u2022 Ctrl+C exit" : "btw \u2022 Enter send \u2022 Ctrl+C exit";
2579
+ const cycleHint = thinking && thinking.levels.length > 1 && this.thinkingLevel && this.shortcuts.keys.cycleThinkingLevel.length ? ` \u2022 thinking ${this.thinkingLevel} \u2022 ${this.shortcuts.label("cycleThinkingLevel")} cycle` : "";
2580
+ const base = bring ? `btw \u2022 Enter send \u2022 ${bringKey} bring to main \u2022 ${exit} exit` : `btw \u2022 Enter send \u2022 ${exit} exit`;
2173
2581
  const fullBase = `${base}${cycleHint}`;
2174
- const fallbackBase = "btw \u2022 Enter \u2022 Ctrl+C";
2175
- const compactBase = this.canBringToMain ? "btw \u2022 Enter \u2022 Ctrl+R \u2022 Ctrl+C" : fallbackBase;
2582
+ const fallbackBase = `btw \u2022 Enter \u2022 ${exit}`;
2583
+ const compactBase = bring ? `btw \u2022 Enter \u2022 ${bringKey} \u2022 ${exit}` : fallbackBase;
2176
2584
  const compactWithThinking = `${compactBase}${cycleHint}`;
2177
2585
  let hints = visibleWidth2(fullBase) <= width ? fullBase : visibleWidth2(compactWithThinking) <= width ? compactWithThinking : visibleWidth2(compactBase) <= width ? compactBase : fallbackBase;
2178
2586
  if (scrollable) {
2179
2587
  const history = ` \u2022 ${this.scrollView.scrollTop > 0 ? "\u2191 older" : "\u2193 newer"} \u2022 PgUp/PgDn history`;
2180
2588
  const compactHistory = " \u2022 PgUp/PgDn";
2181
- const compactScrollable = this.canBringToMain ? "Enter \u2022 Ctrl+R \u2022 Ctrl+C \u2022 PgUp/PgDn" : `${fallbackBase}${compactHistory}`;
2589
+ const compactScrollable = bring ? `Enter \u2022 ${bringKey} \u2022 ${exit} \u2022 PgUp/PgDn` : `${fallbackBase}${compactHistory}`;
2182
2590
  if (visibleWidth2(`${hints}${history}`) <= width) {
2183
2591
  hints += history;
2184
2592
  } else if (visibleWidth2(`${compactBase}${history}`) <= width) {
@@ -2229,6 +2637,7 @@ var BtwAnsweringView = class {
2229
2637
  this.theme = theme;
2230
2638
  this.onCancel = onCancel;
2231
2639
  this.options = options;
2640
+ this.shortcuts = getBtwShortcuts(tui, options.steering?.thinking?.keybindings);
2232
2641
  this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
2233
2642
  this.thinkingLevel = options.steering?.thinking?.level ?? thinkingLevel;
2234
2643
  this.loader = new Loader(
@@ -2282,6 +2691,8 @@ var BtwAnsweringView = class {
2282
2691
  theme;
2283
2692
  onCancel;
2284
2693
  options;
2694
+ shortcuts;
2695
+ pasteGuard = new BtwPasteGuard();
2285
2696
  transcriptComponents;
2286
2697
  loader;
2287
2698
  editor;
@@ -2307,6 +2718,7 @@ var BtwAnsweringView = class {
2307
2718
  return this.layoutRoot;
2308
2719
  }
2309
2720
  render(width) {
2721
+ if (width <= 0) return [];
2310
2722
  const safeWidth = Math.max(1, width);
2311
2723
  const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
2312
2724
  const editorLines = this.editor?.render(safeWidth) ?? [];
@@ -2338,11 +2750,16 @@ var BtwAnsweringView = class {
2338
2750
  editorLines,
2339
2751
  availableRows,
2340
2752
  steeringLines
2341
- );
2753
+ ).map((line) => truncateToWidth3(line, safeWidth));
2342
2754
  }
2343
2755
  handleInput(data) {
2344
2756
  if (this.finished) return;
2345
- if (matchesKey4(data, Key4.ctrl("c"))) {
2757
+ if (this.pasteGuard.consume(data)) {
2758
+ this.editor?.handleInput(data);
2759
+ this.tui.requestRender();
2760
+ return;
2761
+ }
2762
+ if (this.shortcuts.matches(data, "exit")) {
2346
2763
  this.finished = true;
2347
2764
  this.loader.stop();
2348
2765
  this.controller.abort();
@@ -2350,7 +2767,7 @@ var BtwAnsweringView = class {
2350
2767
  return;
2351
2768
  }
2352
2769
  const thinking = this.options.steering?.thinking;
2353
- if (thinking && thinking.levels.length > 1 && thinking.keybindings.matches(data, "app.thinking.cycle")) {
2770
+ if (thinking && thinking.levels.length > 1 && this.shortcuts.matches(data, "cycleThinkingLevel")) {
2354
2771
  const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
2355
2772
  const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
2356
2773
  if (nextLevel) {
@@ -2393,16 +2810,17 @@ var BtwAnsweringView = class {
2393
2810
  this.onCancel();
2394
2811
  }
2395
2812
  renderFooter(width) {
2813
+ const exit = this.shortcuts.label("exit");
2396
2814
  if (this.warning) {
2397
- const warning = width < 32 ? "Empty \u2022 Ctrl+C" : `${this.warning} \u2022 Ctrl+C cancel`;
2815
+ const warning = width < 32 ? `Empty \u2022 ${exit}` : `${this.warning} \u2022 ${exit} cancel`;
2398
2816
  return truncateToWidth3(this.theme.fg("warning", warning), width);
2399
2817
  }
2400
- const baseHint = this.editor ? "Enter steer \u2022 Ctrl+C cancel" : "Ctrl+C cancel";
2818
+ const baseHint = this.editor ? `Enter steer \u2022 ${exit} cancel` : `${exit} cancel`;
2401
2819
  const thinking = this.options.steering?.thinking;
2402
- const cycleHint = thinking && thinking.levels.length > 1 && this.thinkingLevel ? ` \u2022 thinking ${this.thinkingLevel} \u2022 ${thinkingKeyLabel(thinking.keybindings)} cycle` : "";
2820
+ const cycleHint = thinking && thinking.levels.length > 1 && this.thinkingLevel && this.shortcuts.keys.cycleThinkingLevel.length ? ` \u2022 thinking ${this.thinkingLevel} \u2022 ${this.shortcuts.label("cycleThinkingLevel")} cycle` : "";
2403
2821
  const scrollHint = this.getMaxScrollOffset() > 0 ? " \u2022 PgUp/PgDn history" : "";
2404
2822
  const hints = `${baseHint}${cycleHint}${scrollHint}`;
2405
- const compactHints = this.editor ? "Enter \u2022 Ctrl+C" : "Ctrl+C";
2823
+ const compactHints = this.editor ? `Enter \u2022 ${exit}` : exit;
2406
2824
  const selectedHints = visibleWidth2(hints) <= width ? hints : compactHints;
2407
2825
  const loaderWidth = Math.max(1, width - visibleWidth2(selectedHints) - 3);
2408
2826
  const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering\u2026";
@@ -2490,9 +2908,6 @@ function renderSideThreadHeader(width, theme, thinkingLevel) {
2490
2908
  const ruleWidth = Math.max(0, width - visibleWidth2(title));
2491
2909
  return theme.fg("muted", `${title}${"\u2500".repeat(ruleWidth)}`);
2492
2910
  }
2493
- function thinkingKeyLabel(keybindings) {
2494
- return formatKeyLabel2(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) || "Shift+Tab";
2495
- }
2496
2911
  function fitComposerLayout(header, contentLines, footer, editorLines, availableRows, statusLines = []) {
2497
2912
  const lines = [header, ...contentLines, ...statusLines, footer, ...editorLines];
2498
2913
  if (lines.length <= availableRows) return lines;
@@ -2615,6 +3030,12 @@ function providerHeadersHaveValue(headers) {
2615
3030
  function formatError5(error) {
2616
3031
  return error instanceof Error ? error.message : String(error);
2617
3032
  }
3033
+ function readBtwSessionId(ctx) {
3034
+ const getSessionId = ctx.sessionManager.getSessionId;
3035
+ if (typeof getSessionId !== "function") return void 0;
3036
+ const sessionId = getSessionId.call(ctx.sessionManager);
3037
+ return sessionId.length > 0 ? sessionId : void 0;
3038
+ }
2618
3039
  function notifySafely3(ctx, message, level) {
2619
3040
  try {
2620
3041
  ctx.ui.notify(sanitizeSingleLine(message), level);
@@ -2720,7 +3141,10 @@ function btw(pi, dependencies = {}) {
2720
3141
  ctx: fullscreenCtx
2721
3142
  });
2722
3143
  },
2723
- { copyOnSelect: effectiveFullscreenCopyOnSelect(settings) }
3144
+ {
3145
+ copyOnSelect: effectiveFullscreenCopyOnSelect(settings),
3146
+ ...settings.keybindings ? { keybindings: settings.keybindings } : {}
3147
+ }
2724
3148
  );
2725
3149
  } finally {
2726
3150
  if (state?.title && state.thread.turns.length > 0) {
@@ -3149,7 +3573,8 @@ async function askThreadQuestion(thread, question, selected, thinkingLevel, ctx,
3149
3573
  thinkingLevel,
3150
3574
  auth: selected.auth,
3151
3575
  signal: view.signal,
3152
- completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry)
3576
+ completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
3577
+ sessionId: readBtwSessionId(ctx)
3153
3578
  }).then((result) => {
3154
3579
  if (settled) return;
3155
3580
  settled = true;