@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.
@@ -0,0 +1,74 @@
1
+ # Side-thread workflows
2
+
3
+ [Back to README](../README.md#-commands)
4
+
5
+ ## Choose context and resume threads
6
+
7
+ `/btw` lets you start from the current conversation or choose **Start from main thread tree…** to use the root-to-selected-entry path, including the selected entry.
8
+ The tree snapshots persisted entries and preserves the main editor draft without navigating, forking, appending to, or switching the main conversation.
9
+ Side-thread context stays immutable if the main conversation later changes.
10
+ An explicit `Shift+L` label edit is the selector's only main-session mutation and persists through Pi.
11
+ Native tree copying reports success or failure; Escape returns to the manager and Ctrl+C closes the flow.
12
+
13
+ **Resume side thread** searches non-empty in-memory threads by their first question and question count.
14
+ The first question remains the title; threads are ordered by their newest answer or visible error.
15
+ Opening and closing without a new result does not reorder them.
16
+ `/btw <question>` bypasses selection and always starts a new thread.
17
+
18
+ ## Read, select, and search
19
+
20
+ The fullscreen workspace keeps answers above the editor and identifies itself with a fixed `btw · side thread` header.
21
+ The main agent may keep running, but its rendering is suspended so new output cannot move a mouse selection; returning to Pi redraws that output.
22
+ Submit each question with Enter; successful prior questions and answers remain available to the side model.
23
+
24
+ Drag the primary mouse button across the transcript to select text.
25
+ Automatic selection copying requests Pi's host clipboard helper immediately and is enabled by default.
26
+ With **Copy selection automatically** off, the selection stays highlighted and Pi's effective `app.message.copy` binding copies it.
27
+ Manual copying requires Pi's fullscreen selection APIs; if unavailable, pi-btw restores the main TUI and asks you to update Pi or re-enable automatic copying.
28
+ The view reports `No selection to copy`, `Copied!`, or `Copy failed` for the request; actual clipboard access still depends on the operating system and terminal.
29
+ Ctrl+C always cancels the side flow, even if `app.message.copy` is also mapped to Ctrl+C.
30
+
31
+ Press `Ctrl+Shift+F` to search completed or in-progress transcript text, excluding the fixed header and footer.
32
+ Enter or `Ctrl+G` finds the next match; `Shift+Enter` or `Ctrl+Shift+G` finds the previous match.
33
+ Escape closes search and returns focus to the composer.
34
+
35
+ Scroll with the mouse wheel, trackpad, or `PgUp`/`PgDn`.
36
+ On Pi 0.85 or newer, scrolling away from the latest content reveals **Jump to latest message**.
37
+ Click it or use Pi's effective `tui.altScreen.bottom` binding (`End` by default) to resume following new output.
38
+
39
+ ## Thinking and queued questions
40
+
41
+ The header shows the current side-thread thinking level.
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).
44
+ Whether that change is remembered depends on [Settings](../README.md#-settings); it never changes the main session's thinking level.
45
+
46
+ During a response, submit another question to queue it as `Steering`.
47
+ Queued questions run in order after the current response, using the thinking level effective when each turn starts.
48
+ A failed response remains visible without discarding later queued questions.
49
+ Steering does not append to the main conversation or editor.
50
+
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.
52
+ Completed questions, answers, and visible errors remain resumable until the extension instance ends.
53
+
54
+ ## Bring context to the main editor
55
+
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.
57
+ The scope chooser reports the latest exchange and full-thread sizes.
58
+ Question-suffix, exact-range, and full-thread choices preview an editable context block before closing the side thread.
59
+ Escape returns; Ctrl+C closes without bringing context back.
60
+
61
+ The exact-range selector works on raw source text, not terminal-wrapped rows, and reports line, message, and approximate token counts.
62
+ Press Space to select the current raw line, extend with Up or Down, and press Space again to clear.
63
+ Alternatively, move with arrow keys and extend character selection with Shift plus an arrow key; starting a Shift selection replaces an active line selection.
64
+ Selected lines have a `●` marker and highlighting.
65
+ Pi's effective navigation, submission, and back bindings apply, with Up, Down, Enter, and Escape as defaults.
66
+
67
+ Bringing context closes the side thread and loads a deterministic, editable block into the main editor **without sending it**.
68
+ For an existing draft, append is recommended; replace is destructive and requires a second confirmation.
69
+ Cancel returns to the side thread without changing either draft, and concurrent editor updates are preserved.
70
+ The success message reports whether context was loaded, appended, or replaced and its approximate size.
71
+
72
+ Without an explicit bring action, closing `/btw` never changes the main conversation.
73
+ Resume state is memory-only: `/new`, Pi `/resume`, `/reload`, extension replacement, and process restart discard it.
74
+ Unsent drafts, steering queues, interrupted answers, and model credentials are not retained.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.57.0",
3
+ "version": "0.58.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@
15
15
  "files": [
16
16
  "src",
17
17
  "dist",
18
+ "docs",
18
19
  "README.md",
19
20
  "LICENSE"
20
21
  ],
package/src/btw.ts CHANGED
@@ -204,6 +204,13 @@ function formatError(error: unknown): string {
204
204
  return error instanceof Error ? error.message : String(error);
205
205
  }
206
206
 
207
+ function readBtwSessionId(ctx: ExtensionCommandContext): string | undefined {
208
+ const getSessionId = ctx.sessionManager.getSessionId;
209
+ if (typeof getSessionId !== "function") return undefined;
210
+ const sessionId = getSessionId.call(ctx.sessionManager);
211
+ return sessionId.length > 0 ? sessionId : undefined;
212
+ }
213
+
207
214
  function notifySafely(
208
215
  ctx: ExtensionCommandContext,
209
216
  message: string,
@@ -216,6 +223,9 @@ function notifySafely(
216
223
  }
217
224
  }
218
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.
219
229
  export interface BtwExtensionDependencies {
220
230
  showCommandMenu?: (
221
231
  pi: ExtensionAPI,
@@ -342,7 +352,10 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
342
352
  ctx: fullscreenCtx,
343
353
  });
344
354
  },
345
- { copyOnSelect: effectiveFullscreenCopyOnSelect(settings) },
355
+ {
356
+ copyOnSelect: effectiveFullscreenCopyOnSelect(settings),
357
+ ...(settings.keybindings ? { keybindings: settings.keybindings } : {}),
358
+ },
346
359
  );
347
360
  } finally {
348
361
  if (state?.title && state.thread.turns.length > 0) {
@@ -895,6 +908,7 @@ async function askThreadQuestion(
895
908
  auth: selected.auth,
896
909
  signal: view.signal,
897
910
  completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
911
+ sessionId: readBtwSessionId(ctx),
898
912
  }).then((result) => {
899
913
  if (settled) return;
900
914
  settled = true;
@@ -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
- if (isKeyRelease(data) || !matchesKey(data, Key.ctrl("c"))) return undefined;
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
+ }