@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/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, shortcut memory, and selection copying",
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(ctx, (menuContext) =>
279
- runMenu(menuContext, menu, { getState: loadState }),
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
- (tui, theme, keybindings, done) =>
326
- factory(tui, theme, keybindings, (value) => {
327
- try {
328
- liveEditorText = target.getEditorText();
329
- } catch {
330
- // Keep completion finite if session replacement invalidates the editor context.
331
- }
332
- completed = true;
333
- done(value);
334
- }),
335
- customOptions,
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;
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;
@@ -86,6 +86,7 @@ export interface CompleteSideThreadTurnOptions {
86
86
  auth: SideQuestionAuth;
87
87
  signal?: AbortSignal;
88
88
  completeSimple: CompleteSimpleFunction;
89
+ sessionId?: string;
89
90
  }
90
91
 
91
92
  export type CompleteSideThreadTurnResult =
@@ -101,13 +102,14 @@ export async function completeSideThreadTurn({
101
102
  auth,
102
103
  signal,
103
104
  completeSimple,
105
+ sessionId,
104
106
  }: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
105
107
  if (signal?.aborted) return { kind: "aborted" };
106
108
  try {
107
109
  const response = await completeSimple(
108
110
  model,
109
111
  { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
110
- buildStreamOptions(auth, thinkingLevel, signal),
112
+ buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId }),
111
113
  );
112
114
  if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
113
115
  if (!isAssistantMessage(response)) {
@@ -137,6 +139,7 @@ export interface CompleteSideQuestionOptions {
137
139
  auth: SideQuestionAuth;
138
140
  signal?: AbortSignal;
139
141
  completeSimple: CompleteSimpleFunction;
142
+ sessionId?: string;
140
143
  }
141
144
 
142
145
  export async function completeSideQuestion({
@@ -147,6 +150,7 @@ export async function completeSideQuestion({
147
150
  auth,
148
151
  signal,
149
152
  completeSimple,
153
+ sessionId,
150
154
  }: CompleteSideQuestionOptions): Promise<AssistantMessage> {
151
155
  return completeSimple(
152
156
  model,
@@ -154,7 +158,7 @@ export async function completeSideQuestion({
154
158
  systemPrompt: SYSTEM_PROMPT,
155
159
  messages: [createUserMessage(buildUserPrompt(question, conversationContext))],
156
160
  },
157
- buildStreamOptions(auth, thinkingLevel, signal),
161
+ buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId }),
158
162
  );
159
163
  }
160
164
 
@@ -214,14 +218,62 @@ function createUserMessage(text: string): UserMessage {
214
218
  };
215
219
  }
216
220
 
221
+ // Minimal session-headers fork of Pi core provider-attribution
222
+ // (pinned to @earendil-works/pi-coding-agent@0.85.0 src/core/provider-attribution.ts:getSessionHeaders).
223
+ // Core does not export this helper and extensions have no SettingsManager, so only session
224
+ // headers are mirrored here. Default attribution headers are intentionally out of scope.
225
+ // Keep semantics bug-compatible with core: case-sensitive Object.assign, explicit auth
226
+ // headers win on exact-case match.
227
+ const OPENCODE_HOST = "opencode.ai";
228
+
229
+ function matchesOpencodeHost(baseUrl: string | undefined): boolean {
230
+ if (!baseUrl) return false;
231
+ try {
232
+ return new URL(baseUrl).hostname === OPENCODE_HOST;
233
+ } catch {
234
+ return false;
235
+ }
236
+ }
237
+
238
+ function getOpencodeSessionHeaders(
239
+ model: Pick<Model<Api>, "provider" | "baseUrl">,
240
+ sessionId?: string,
241
+ ): ProviderHeaders | undefined {
242
+ if (!sessionId) return undefined;
243
+ if (
244
+ model.provider !== "opencode" &&
245
+ model.provider !== "opencode-go" &&
246
+ !matchesOpencodeHost(model.baseUrl)
247
+ ) {
248
+ return undefined;
249
+ }
250
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
251
+ }
252
+
253
+ function mergeSessionHeaders(
254
+ authHeaders: ProviderHeaders | undefined,
255
+ sessionHeaders: ProviderHeaders | undefined,
256
+ ): ProviderHeaders | undefined {
257
+ if (!sessionHeaders && !authHeaders) return undefined;
258
+ // Bug-compatible with core mergeProviderAttributionHeaders: case-sensitive assign.
259
+ return { ...sessionHeaders, ...authHeaders };
260
+ }
261
+
262
+ interface BuildSideThreadStreamOptions {
263
+ thinkingLevel: BtwThinkingLevel;
264
+ signal?: AbortSignal;
265
+ model?: Pick<Model<Api>, "provider" | "baseUrl">;
266
+ sessionId?: string;
267
+ }
268
+
217
269
  function buildStreamOptions(
218
270
  auth: SideQuestionAuth,
219
- thinkingLevel: BtwThinkingLevel,
220
- signal?: AbortSignal,
271
+ { thinkingLevel, signal, model, sessionId }: BuildSideThreadStreamOptions,
221
272
  ): SimpleStreamOptions {
273
+ const sessionHeaders = model ? getOpencodeSessionHeaders(model, sessionId) : undefined;
222
274
  const options: SimpleStreamOptions = {
223
275
  apiKey: auth.apiKey,
224
- headers: auth.headers,
276
+ headers: mergeSessionHeaders(auth.headers, sessionHeaders),
225
277
  env: auth.env,
226
278
  signal,
227
279
  };
@@ -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[],