@narumitw/pi-btw 0.55.4 → 0.56.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.55.4",
3
+ "version": "0.56.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -23,9 +23,6 @@
23
23
  "./dist/index.ts"
24
24
  ]
25
25
  },
26
- "piExtension": {
27
- "lifecycle": "stable"
28
- },
29
26
  "scripts": {
30
27
  "build": "node scripts/build-runtime.mjs",
31
28
  "check": "npm run build && biome check . && npm run typecheck",
@@ -34,10 +31,10 @@
34
31
  "prepack": "npm run build"
35
32
  },
36
33
  "devDependencies": {
37
- "@biomejs/biome": "2.5.10",
38
- "@earendil-works/pi-ai": "0.84.3",
39
- "@earendil-works/pi-coding-agent": "0.84.3",
40
- "@earendil-works/pi-tui": "0.84.3",
34
+ "@biomejs/biome": "2.5.11",
35
+ "@earendil-works/pi-ai": "0.84.4",
36
+ "@earendil-works/pi-coding-agent": "0.84.4",
37
+ "@earendil-works/pi-tui": "0.84.4",
41
38
  "esbuild": "0.28.2",
42
39
  "typescript": "7.0.2"
43
40
  },
package/src/btw.ts CHANGED
@@ -33,6 +33,7 @@ import {
33
33
  } from "./menu.js";
34
34
  import {
35
35
  type BtwSettings,
36
+ effectiveFullscreenCopyOnSelect,
36
37
  effectiveRememberThinkingLevelChanges,
37
38
  parseBtwModelReference,
38
39
  readBtwSettings,
@@ -193,7 +194,7 @@ export async function loadBtwThinkingLevel(
193
194
 
194
195
  options.warn?.(
195
196
  sanitizeSingleLine(
196
- `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", omitted thinkingLevel for Same as main thread or thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", and boolean rememberThinkingLevelChanges. Using current Pi thinking level.`,
197
+ `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", omitted thinkingLevel for Same as main thread or thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", boolean rememberThinkingLevelChanges, and boolean fullscreenCopyOnSelect. Using current Pi thinking level.`,
197
198
  ),
198
199
  );
199
200
  return currentThinkingLevel;
@@ -314,31 +315,35 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
314
315
  const startingTurnCount = state?.thread.turns.length ?? 0;
315
316
 
316
317
  try {
317
- await runFullscreen(ctx, (fullscreenCtx) => {
318
- if (!state) {
319
- const createdAt = Date.now();
320
- state = {
321
- id: `btw-${nextThreadNumber}`,
322
- thread: createSideThread(
323
- selectedConversationContext ??
324
- buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
325
- ),
326
- thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
327
- createdAt,
328
- updatedAt: createdAt,
329
- };
330
- nextThreadNumber += 1;
331
- }
332
- return runThread({
333
- initialQuestion: question || undefined,
334
- selected: resolution.selected,
335
- thinkingLevel: state.thinkingLevel,
336
- rememberThinkingLevelChanges:
337
- !sameAsMainThinkingLevel && effectiveRememberThinkingLevelChanges(settings),
338
- state,
339
- ctx: fullscreenCtx,
340
- });
341
- });
318
+ await runFullscreen(
319
+ ctx,
320
+ (fullscreenCtx) => {
321
+ if (!state) {
322
+ const createdAt = Date.now();
323
+ state = {
324
+ id: `btw-${nextThreadNumber}`,
325
+ thread: createSideThread(
326
+ selectedConversationContext ??
327
+ buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
328
+ ),
329
+ thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
330
+ createdAt,
331
+ updatedAt: createdAt,
332
+ };
333
+ nextThreadNumber += 1;
334
+ }
335
+ return runThread({
336
+ initialQuestion: question || undefined,
337
+ selected: resolution.selected,
338
+ thinkingLevel: state.thinkingLevel,
339
+ rememberThinkingLevelChanges:
340
+ !sameAsMainThinkingLevel && effectiveRememberThinkingLevelChanges(settings),
341
+ state,
342
+ ctx: fullscreenCtx,
343
+ });
344
+ },
345
+ { copyOnSelect: effectiveFullscreenCopyOnSelect(settings) },
346
+ );
342
347
  } finally {
343
348
  if (state?.title && state.thread.turns.length > 0) {
344
349
  if (state.thread.turns.length > startingTurnCount) {
@@ -30,6 +30,7 @@ type BtwCustomFactory<T> = (
30
30
  type BtwFullscreenTui = TUI & {
31
31
  flash?: (message: string, durationMs?: number) => void;
32
32
  setLayoutRoot(component: Component | undefined): void;
33
+ addInputListenerBeforeAll?(listener: TuiInputListener): () => void;
33
34
  addInputListenerBeforeViewport?(listener: TuiInputListener): () => void;
34
35
  };
35
36
 
@@ -37,17 +38,28 @@ export interface BtwFullscreenLayoutComponent extends Component {
37
38
  getFullscreenLayout(): Component;
38
39
  }
39
40
 
40
- export type BtwFullscreenTuiFactory = (parent: TUI, theme: Theme) => BtwFullscreenTui;
41
+ export interface BtwFullscreenOptions {
42
+ copyOnSelect?: boolean;
43
+ }
44
+
45
+ export type BtwFullscreenTuiFactory = (
46
+ parent: TUI,
47
+ theme: Theme,
48
+ keybindings: KeybindingsManager,
49
+ options: BtwFullscreenOptions,
50
+ ) => BtwFullscreenTui;
41
51
 
42
52
  export interface BtwFullscreenDependencies {
43
53
  createTui?: BtwFullscreenTuiFactory;
44
54
  openUrl?: (url: string) => void;
45
55
  copyToClipboard?: (text: string) => Promise<void>;
56
+ manualSelectionCopySupported?: boolean;
46
57
  }
47
58
 
48
59
  export type RunBtwFullscreen = <T>(
49
60
  ctx: ExtensionCommandContext,
50
61
  run: (ctx: ExtensionCommandContext) => Promise<T>,
62
+ options?: BtwFullscreenOptions,
51
63
  ) => Promise<T>;
52
64
 
53
65
  type FullscreenOutcome<T> = { kind: "completed"; value: T } | { kind: "failed"; error: unknown };
@@ -62,14 +74,23 @@ class FullscreenUiDisposedError extends Error {
62
74
  export async function runBtwFullscreen<T>(
63
75
  ctx: ExtensionCommandContext,
64
76
  run: (ctx: ExtensionCommandContext) => Promise<T>,
77
+ options: BtwFullscreenOptions = {},
65
78
  dependencies: BtwFullscreenDependencies = {},
66
79
  ): Promise<T> {
67
80
  const createTui =
68
81
  dependencies.createTui ??
69
- ((parent: TUI, theme: Theme) =>
82
+ ((
83
+ parent: TUI,
84
+ theme: Theme,
85
+ keybindings: KeybindingsManager,
86
+ fullscreenOptions: BtwFullscreenOptions,
87
+ ) =>
70
88
  createBtwFullscreenTui(
71
89
  parent,
72
90
  theme,
91
+ keybindings,
92
+ fullscreenOptions.copyOnSelect ?? true,
93
+ dependencies.manualSelectionCopySupported ?? hasManualSelectionCopyApi(),
73
94
  dependencies.openUrl ?? openUrlInBrowser,
74
95
  dependencies.copyToClipboard ?? copyToHostClipboard,
75
96
  ));
@@ -94,6 +115,7 @@ export async function runBtwFullscreen<T>(
94
115
  done(value);
95
116
  },
96
117
  createTui,
118
+ options,
97
119
  );
98
120
  return host;
99
121
  },
@@ -114,6 +136,7 @@ export async function runBtwFullscreen<T>(
114
136
  }
115
137
 
116
138
  type BtwInputListeners = {
139
+ beforeAll: Set<TuiInputListener>;
117
140
  beforeViewport: Set<TuiInputListener>;
118
141
  regular: Set<TuiInputListener>;
119
142
  };
@@ -122,7 +145,7 @@ const btwInputListeners = new WeakMap<BtwTuiAltScreen, BtwInputListeners>();
122
145
 
123
146
  function dispatchBtwInput(listeners: BtwInputListeners, data: string): TuiInputListenerResult {
124
147
  let current = data;
125
- for (const group of [listeners.beforeViewport, listeners.regular]) {
148
+ for (const group of [listeners.beforeAll, listeners.beforeViewport, listeners.regular]) {
126
149
  for (const listener of group) {
127
150
  const result = listener(current);
128
151
  if (result?.consume) return result;
@@ -133,10 +156,15 @@ function dispatchBtwInput(listeners: BtwInputListeners, data: string): TuiInputL
133
156
  }
134
157
 
135
158
  class BtwTuiAltScreen extends TuiAltScreen {
159
+ hasFocusedOverlay(): boolean {
160
+ return this.isOverlayFocused();
161
+ }
162
+
136
163
  override addInputListener(listener: TuiInputListener): () => void {
137
164
  let listeners = btwInputListeners.get(this);
138
165
  if (!listeners) {
139
166
  const registeredListeners: BtwInputListeners = {
167
+ beforeAll: new Set(),
140
168
  beforeViewport: new Set(),
141
169
  regular: new Set(),
142
170
  };
@@ -148,6 +176,13 @@ class BtwTuiAltScreen extends TuiAltScreen {
148
176
  return () => listeners.regular.delete(listener);
149
177
  }
150
178
 
179
+ addInputListenerBeforeAll(listener: TuiInputListener): () => void {
180
+ const listeners = btwInputListeners.get(this);
181
+ if (!listeners) return super.addInputListener(listener);
182
+ listeners.beforeAll.add(listener);
183
+ return () => listeners.beforeAll.delete(listener);
184
+ }
185
+
151
186
  addInputListenerBeforeViewport(listener: TuiInputListener): () => void {
152
187
  const listeners = btwInputListeners.get(this);
153
188
  if (!listeners) return super.addInputListener(listener);
@@ -161,33 +196,85 @@ class BtwTuiAltScreen extends TuiAltScreen {
161
196
  super.removeInputListener(listener);
162
197
  return;
163
198
  }
199
+ listeners.beforeAll.delete(listener);
164
200
  listeners.beforeViewport.delete(listener);
165
201
  listeners.regular.delete(listener);
166
202
  }
167
203
  }
168
204
 
205
+ const BRACKETED_PASTE_START = "\u001b[200~";
206
+ const BRACKETED_PASTE_END = "\u001b[201~";
207
+
208
+ function hasManualSelectionCopyApi(): boolean {
209
+ return (
210
+ typeof TuiAltScreen.prototype.hasActiveSelection === "function" &&
211
+ typeof TuiAltScreen.prototype.copyActiveSelectionToClipboard === "function"
212
+ );
213
+ }
214
+
169
215
  function createBtwFullscreenTui(
170
216
  parent: TUI,
171
217
  theme: Theme,
218
+ keybindings: KeybindingsManager,
219
+ copyOnSelect: boolean,
220
+ manualSelectionCopySupported: boolean,
172
221
  openUrl: (url: string) => void,
173
222
  copyToClipboard: (text: string) => Promise<void>,
174
223
  ): BtwFullscreenTui {
224
+ if (!copyOnSelect && !manualSelectionCopySupported) {
225
+ throw new Error(
226
+ "Manual fullscreen selection copying is unavailable in this Pi version; update Pi or enable automatic selection copying.",
227
+ );
228
+ }
175
229
  const styleSearchMatch = (text: string) =>
176
230
  theme.bg("searchMatchBg", theme.fg("searchMatchText", text));
177
- return new BtwTuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), undefined, {
178
- mouse: true,
179
- searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
180
- searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
181
- openUrl,
182
- copySelection: async (text) => {
183
- try {
184
- await copyToClipboard(text);
185
- return true;
186
- } catch {
187
- return false;
188
- }
231
+ const fullscreen = new BtwTuiAltScreen(
232
+ parent.terminal,
233
+ parent.getShowHardwareCursor(),
234
+ undefined,
235
+ {
236
+ mouse: true,
237
+ copyOnSelect,
238
+ searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
239
+ searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
240
+ openUrl,
241
+ copySelection: async (text) => {
242
+ try {
243
+ await copyToClipboard(text);
244
+ return true;
245
+ } catch {
246
+ return false;
247
+ }
248
+ },
189
249
  },
190
- });
250
+ );
251
+ if (!copyOnSelect) {
252
+ let isInBracketedPaste = false;
253
+ fullscreen.addInputListenerBeforeViewport((data) => {
254
+ const wasInBracketedPaste = isInBracketedPaste;
255
+ const startsBracketedPaste = data.includes(BRACKETED_PASTE_START);
256
+ if (startsBracketedPaste) isInBracketedPaste = true;
257
+ if (isInBracketedPaste && data.includes(BRACKETED_PASTE_END)) {
258
+ isInBracketedPaste = false;
259
+ }
260
+ if (
261
+ wasInBracketedPaste ||
262
+ startsBracketedPaste ||
263
+ fullscreen.hasFocusedOverlay() ||
264
+ isKeyRelease(data) ||
265
+ !keybindings.matches(data, "app.message.copy")
266
+ ) {
267
+ return undefined;
268
+ }
269
+ if (!fullscreen.hasActiveSelection()) {
270
+ fullscreen.flash("No selection to copy");
271
+ return { consume: true };
272
+ }
273
+ void fullscreen.copyActiveSelectionToClipboard().catch(() => fullscreen.flash("Copy failed"));
274
+ return { consume: true };
275
+ });
276
+ }
277
+ return fullscreen;
191
278
  }
192
279
 
193
280
  // Pi does not export its browser opener, so mirror its shell-free launcher for this isolated TUI.
@@ -226,6 +313,7 @@ class BtwFullscreenHost<T> implements Component {
226
313
  private readonly run: (ctx: ExtensionCommandContext) => Promise<T>,
227
314
  private readonly done: (outcome: FullscreenOutcome<T>) => void,
228
315
  private readonly createTui: BtwFullscreenTuiFactory,
316
+ private readonly options: BtwFullscreenOptions,
229
317
  ) {
230
318
  queueMicrotask(() => void this.start());
231
319
  }
@@ -255,11 +343,12 @@ class BtwFullscreenHost<T> implements Component {
255
343
  this.parent.stop({ preserveScreen: true });
256
344
  this.parentStopped = true;
257
345
  if (this.disposed) throw new FullscreenUiDisposedError();
258
- this.fullscreen = this.createTui(this.parent, this.theme);
346
+ this.fullscreen = this.createTui(this.parent, this.theme, this.keybindings, this.options);
259
347
  this.fullscreenCreated = true;
260
348
  this.fullscreen.start();
261
349
  // Waiting for the custom promise would leave follow-up keys bound to the side TUI.
262
350
  const addHardCancelListener =
351
+ this.fullscreen.addInputListenerBeforeAll?.bind(this.fullscreen) ??
263
352
  this.fullscreen.addInputListenerBeforeViewport?.bind(this.fullscreen) ??
264
353
  this.fullscreen.addInputListener.bind(this.fullscreen);
265
354
  this.removeHardCancelListener = addHardCancelListener((data) => {
package/src/menu.ts CHANGED
@@ -9,6 +9,7 @@ import {
9
9
  type BtwSettings,
10
10
  type BtwSettingsPatch,
11
11
  btwSettingsPath,
12
+ effectiveFullscreenCopyOnSelect,
12
13
  effectiveRememberThinkingLevelChanges,
13
14
  readBtwSettings,
14
15
  type UpdateBtwSettingsOptions,
@@ -48,7 +49,13 @@ export type BtwCommandMenuResult =
48
49
  | { kind: "resume"; threadId: string };
49
50
 
50
51
  type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
51
- type BtwMenuAction = "start" | "start-tree" | "resume" | "set-thinking" | "set-remember";
52
+ type BtwMenuAction =
53
+ | "start"
54
+ | "start-tree"
55
+ | "resume"
56
+ | "set-thinking"
57
+ | "set-remember"
58
+ | "set-fullscreen-copy";
52
59
  const SAME_AS_MAIN_THREAD = "Same as main thread";
53
60
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
54
61
 
@@ -111,6 +118,7 @@ export async function showBtwCommandMenu(
111
118
  title: "Pi BTW",
112
119
  lines: [
113
120
  `Thinking: ${displayThinkingSummary(state.settings)} · Remember changes: ${displayRememberSummary(state.settings)}`,
121
+ `Copy on select: ${effectiveFullscreenCopyOnSelect(state.settings) ? "On" : "Off"}`,
114
122
  ],
115
123
  items: [
116
124
  {
@@ -138,7 +146,7 @@ export async function showBtwCommandMenu(
138
146
  {
139
147
  id: "settings",
140
148
  label: "Settings",
141
- description: "Choose pi-btw thinking level and fixed-level shortcut memory",
149
+ description: "Choose thinking, shortcut memory, and selection copying",
142
150
  to: state.kind === "invalid" ? "invalid" : "settings",
143
151
  },
144
152
  ],
@@ -178,6 +186,15 @@ export async function showBtwCommandMenu(
178
186
  values: ["On", "Off"],
179
187
  action: "set-remember",
180
188
  },
189
+ {
190
+ id: "fullscreenCopyOnSelect",
191
+ label: "Copy selection automatically",
192
+ description:
193
+ "Copy mouse selections immediately instead of with the configured copy key.",
194
+ currentValue: effectiveFullscreenCopyOnSelect(state.settings) ? "On" : "Off",
195
+ values: ["On", "Off"],
196
+ action: "set-fullscreen-copy",
197
+ },
181
198
  ],
182
199
  }),
183
200
  invalid: ({ state }) => ({
@@ -240,6 +257,21 @@ export async function showBtwCommandMenu(
240
257
  return { kind: "rejected" };
241
258
  }
242
259
  },
260
+ "set-fullscreen-copy": async ({ value, signal }) => {
261
+ if (value !== "On" && value !== "Off") return { kind: "rejected" };
262
+ try {
263
+ await updateSettings(
264
+ { fullscreenCopyOnSelect: value === "On" },
265
+ { settingsPath, signal },
266
+ );
267
+ if (signal.aborted) return { kind: "rejected" };
268
+ notifySafely(ctx, `Copy selection automatically: ${value}.`, "info");
269
+ return { kind: "stay" };
270
+ } catch (error) {
271
+ if (!signal.aborted) notifySaveFailure(ctx, error);
272
+ return { kind: "rejected" };
273
+ }
274
+ },
243
275
  },
244
276
  });
245
277
 
package/src/settings.ts CHANGED
@@ -6,6 +6,7 @@ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
6
  import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
7
7
 
8
8
  export const BTW_SETTINGS_FILE = "pi-btw.json";
9
+ export const DEFAULT_FULLSCREEN_COPY_ON_SELECT = true;
9
10
  export const DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
10
11
  const MAX_SETTINGS_BYTES = 64 * 1024;
11
12
 
@@ -13,6 +14,7 @@ export interface BtwSettings {
13
14
  model?: string;
14
15
  thinkingLevel?: BtwThinkingLevel;
15
16
  rememberThinkingLevelChanges?: boolean;
17
+ fullscreenCopyOnSelect?: boolean;
16
18
  }
17
19
 
18
20
  export type BtwSettingsLoadResult =
@@ -23,6 +25,7 @@ export type BtwSettingsLoadResult =
23
25
  export interface BtwSettingsPatch {
24
26
  thinkingLevel?: BtwThinkingLevel;
25
27
  rememberThinkingLevelChanges?: boolean;
28
+ fullscreenCopyOnSelect?: boolean;
26
29
  }
27
30
 
28
31
  export interface UpdateBtwSettingsOptions {
@@ -58,6 +61,11 @@ export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
58
61
  if (typeof remember !== "boolean") return undefined;
59
62
  settings.rememberThinkingLevelChanges = remember;
60
63
  }
64
+ if (Object.hasOwn(value, "fullscreenCopyOnSelect")) {
65
+ const copyOnSelect = Reflect.get(value, "fullscreenCopyOnSelect");
66
+ if (typeof copyOnSelect !== "boolean") return undefined;
67
+ settings.fullscreenCopyOnSelect = copyOnSelect;
68
+ }
61
69
  return settings;
62
70
  }
63
71
 
@@ -70,6 +78,10 @@ export function parseBtwModelReference(
70
78
  return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
71
79
  }
72
80
 
81
+ export function effectiveFullscreenCopyOnSelect(settings: BtwSettings): boolean {
82
+ return settings.fullscreenCopyOnSelect ?? DEFAULT_FULLSCREEN_COPY_ON_SELECT;
83
+ }
84
+
73
85
  export function effectiveRememberThinkingLevelChanges(settings: BtwSettings): boolean {
74
86
  return settings.rememberThinkingLevelChanges ?? DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES;
75
87
  }
@@ -233,6 +245,10 @@ function applyBtwSettingsPatch(
233
245
  if (Object.hasOwn(patch, "rememberThinkingLevelChanges")) {
234
246
  updated.rememberThinkingLevelChanges = patch.rememberThinkingLevelChanges;
235
247
  }
248
+ if (Object.hasOwn(patch, "fullscreenCopyOnSelect")) {
249
+ if (patch.fullscreenCopyOnSelect === undefined) delete updated.fullscreenCopyOnSelect;
250
+ else updated.fullscreenCopyOnSelect = patch.fullscreenCopyOnSelect;
251
+ }
236
252
  return updated;
237
253
  }
238
254