@narumitw/pi-btw 0.56.2 → 0.57.1

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,73 @@
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 Pi's `app.thinking.cycle` shortcut (`Shift+Tab` by default) in the composer to cycle supported levels for later questions.
43
+ Whether that change is remembered depends on [Settings](../README.md#-settings); it never changes the main session's thinking level.
44
+
45
+ During a response, submit another question to queue it as `Steering`.
46
+ Queued questions run in order after the current response, using the thinking level effective when each turn starts.
47
+ A failed response remains visible without discarding later queued questions.
48
+ Steering does not append to the main conversation or editor.
49
+
50
+ Ctrl+C cancels the active response and discards the current draft and steering queue.
51
+ Completed questions, answers, and visible errors remain resumable until the extension instance ends.
52
+
53
+ ## Bring context to the main editor
54
+
55
+ After a successful answer, press `Ctrl+R` to choose the latest question and answer, everything from one question onward, an exact range, or the full thread.
56
+ The scope chooser reports the latest exchange and full-thread sizes.
57
+ Question-suffix, exact-range, and full-thread choices preview an editable context block before closing the side thread.
58
+ Escape returns; Ctrl+C closes without bringing context back.
59
+
60
+ The exact-range selector works on raw source text, not terminal-wrapped rows, and reports line, message, and approximate token counts.
61
+ Press Space to select the current raw line, extend with Up or Down, and press Space again to clear.
62
+ Alternatively, move with arrow keys and extend character selection with Shift plus an arrow key; starting a Shift selection replaces an active line selection.
63
+ Selected lines have a `●` marker and highlighting.
64
+ Pi's effective navigation, submission, and back bindings apply, with Up, Down, Enter, and Escape as defaults.
65
+
66
+ Bringing context closes the side thread and loads a deterministic, editable block into the main editor **without sending it**.
67
+ For an existing draft, append is recommended; replace is destructive and requires a second confirmation.
68
+ Cancel returns to the side thread without changing either draft, and concurrent editor updates are preserved.
69
+ The success message reports whether context was loaded, appended, or replaced and its approximate size.
70
+
71
+ Without an explicit bring action, closing `/btw` never changes the main conversation.
72
+ Resume state is memory-only: `/new`, Pi `/resume`, `/reload`, extension replacement, and process restart discard it.
73
+ 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.56.2",
3
+ "version": "0.57.1",
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
  ],
@@ -32,9 +33,9 @@
32
33
  },
33
34
  "devDependencies": {
34
35
  "@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",
36
+ "@earendil-works/pi-ai": "0.85.0",
37
+ "@earendil-works/pi-coding-agent": "0.85.0",
38
+ "@earendil-works/pi-tui": "0.85.0",
38
39
  "esbuild": "0.28.2",
39
40
  "typescript": "7.0.2"
40
41
  },
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,
@@ -895,6 +902,7 @@ async function askThreadQuestion(
895
902
  auth: selected.auth,
896
903
  signal: view.signal,
897
904
  completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
905
+ sessionId: readBtwSessionId(ctx),
898
906
  }).then((result) => {
899
907
  if (settled) return;
900
908
  settled = true;
@@ -8,16 +8,18 @@ import {
8
8
  import {
9
9
  type Component,
10
10
  isKeyRelease,
11
+ isKittyProtocolActive,
11
12
  Key,
12
13
  matchesKey,
13
14
  type OverlayHandle,
15
+ parseKey,
14
16
  type TUI,
15
17
  TuiAltScreen,
16
18
  type TuiInputListener,
17
19
  type TuiInputListenerResult,
18
20
  truncateToWidth,
19
21
  } from "@earendil-works/pi-tui";
20
- import { sanitizeSingleLine } from "./text.js";
22
+ import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
21
23
 
22
24
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
23
25
  type BtwCustomFactory<T> = (
@@ -205,6 +207,103 @@ class BtwTuiAltScreen extends TuiAltScreen {
205
207
  const BRACKETED_PASTE_START = "\u001b[200~";
206
208
  const BRACKETED_PASTE_END = "\u001b[201~";
207
209
 
210
+ // TuiAltScreen evaluates these actions before bottom, so shared keys cannot jump to latest.
211
+ const ALT_SCREEN_ACTIONS_BEFORE_BOTTOM = [
212
+ "tui.altScreen.search",
213
+ "tui.altScreen.searchNext",
214
+ "tui.altScreen.searchPrevious",
215
+ "tui.altScreen.searchClose",
216
+ "tui.altScreen.pageUp",
217
+ "tui.altScreen.pageDown",
218
+ "tui.altScreen.halfPageUp",
219
+ "tui.altScreen.halfPageDown",
220
+ "tui.altScreen.lineUp",
221
+ "tui.altScreen.lineDown",
222
+ "tui.altScreen.previousPrompt",
223
+ "tui.altScreen.nextPrompt",
224
+ "tui.altScreen.top",
225
+ ] as const;
226
+ const KEY_MODIFIER_ORDER = ["shift", "ctrl", "alt", "super"] as const;
227
+ const MATCHABLE_SPECIAL_KEYS = new Set([
228
+ "space",
229
+ "tab",
230
+ "enter",
231
+ "backspace",
232
+ "delete",
233
+ "insert",
234
+ "home",
235
+ "end",
236
+ "pageup",
237
+ "pagedown",
238
+ "up",
239
+ "down",
240
+ "left",
241
+ "right",
242
+ ]);
243
+ const MATCHABLE_SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_+|~{}:<>?");
244
+
245
+ function normalizedKeyId(key: string): string {
246
+ const parts = key.toLowerCase().split("+");
247
+ const base = parts.at(-1);
248
+ if (!base) return "";
249
+ const normalizedBase = base === "esc" ? "escape" : base === "return" ? "enter" : base;
250
+ const modifiers = KEY_MODIFIER_ORDER.filter((modifier) => parts.includes(modifier));
251
+ return [...modifiers, normalizedBase].join("+");
252
+ }
253
+
254
+ function formatEffectiveKeyLabel(key: string): string {
255
+ const parts = key.split("+");
256
+ const base = parts.at(-1);
257
+ if (base === "pageup") parts[parts.length - 1] = "pageUp";
258
+ if (base === "pagedown") parts[parts.length - 1] = "pageDown";
259
+ return formatKeyLabel(parts.join("+"));
260
+ }
261
+
262
+ function canMatchKeyInput(key: string): boolean {
263
+ const parts = key.split("+");
264
+ const base = parts.at(-1) ?? "";
265
+ const modifiers = parts.slice(0, -1);
266
+ if (base === "escape") return modifiers.length === 0;
267
+ if (base === "clear") {
268
+ return (
269
+ modifiers.length === 0 ||
270
+ (modifiers.length === 1 && (modifiers[0] === "shift" || modifiers[0] === "ctrl"))
271
+ );
272
+ }
273
+ if (/^f(?:[1-9]|1[0-2])$/u.test(base)) return modifiers.length === 0;
274
+ return (
275
+ MATCHABLE_SPECIAL_KEYS.has(base) ||
276
+ (base.length === 1 && (/^[a-z0-9]$/u.test(base) || MATCHABLE_SYMBOL_KEYS.has(base)))
277
+ );
278
+ }
279
+
280
+ function rawCtrlInput(base: string): string | undefined {
281
+ if (base.length !== 1) return undefined;
282
+ const rawBase = base === "-" ? "_" : base;
283
+ if (!"abcdefghijklmnopqrstuvwxyz[\\]_".includes(rawBase)) return undefined;
284
+ return String.fromCharCode(rawBase.charCodeAt(0) & 0x1f);
285
+ }
286
+
287
+ function legacyRawInput(key: string): string | undefined {
288
+ const parts = key.split("+");
289
+ const base = parts.at(-1) ?? "";
290
+ if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
291
+ if (isKittyProtocolActive()) return undefined;
292
+ if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\u001b${base}`;
293
+ if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
294
+ const input = rawCtrlInput(base);
295
+ return input ? `\u001b${input}` : undefined;
296
+ }
297
+ return undefined;
298
+ }
299
+
300
+ // Mirror matchesKey(), using parseKey() to canonicalize IDs that share legacy raw input.
301
+ function keyInputIdentity(key: string): string {
302
+ const identity = normalizedKeyId(key);
303
+ const input = legacyRawInput(identity);
304
+ return input ? normalizedKeyId(parseKey(input) ?? identity) : identity;
305
+ }
306
+
208
307
  function hasManualSelectionCopyApi(): boolean {
209
308
  return (
210
309
  typeof TuiAltScreen.prototype.hasActiveSelection === "function" &&
@@ -236,6 +335,32 @@ function createBtwFullscreenTui(
236
335
  mouse: true,
237
336
  copyOnSelect,
238
337
  searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
338
+ scrollToEndIndicator: () => {
339
+ const unavailableKeyIdentities = new Set<string>([keyInputIdentity(Key.ctrl("c"))]);
340
+ for (const action of ALT_SCREEN_ACTIONS_BEFORE_BOTTOM) {
341
+ for (const actionKey of keybindings.getKeys(action)) {
342
+ unavailableKeyIdentities.add(keyInputIdentity(String(actionKey)));
343
+ }
344
+ }
345
+ if (!copyOnSelect) {
346
+ for (const copyKey of keybindings.getKeys("app.message.copy")) {
347
+ unavailableKeyIdentities.add(keyInputIdentity(String(copyKey)));
348
+ }
349
+ }
350
+ const key = keybindings
351
+ .getKeys("tui.altScreen.bottom")
352
+ .map((candidate) => keyInputIdentity(String(candidate)))
353
+ .find(
354
+ (identity) =>
355
+ identity &&
356
+ canMatchKeyInput(identity) &&
357
+ !unavailableKeyIdentities.has(identity) &&
358
+ formatEffectiveKeyLabel(identity),
359
+ );
360
+ const label = theme.fg("text", " ↓ Jump to latest message");
361
+ const shortcut = key ? theme.fg("muted", ` · ${formatEffectiveKeyLabel(key)}`) : "";
362
+ return theme.bg("selectedBg", `${label}${shortcut} `);
363
+ },
239
364
  searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
240
365
  openUrl,
241
366
  copySelection: async (text) => {
@@ -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
  };
package/src/text.ts CHANGED
@@ -8,3 +8,21 @@ export function sanitizeSingleLine(text: string): string {
8
8
  .replace(/ +/gu, " ")
9
9
  .trim();
10
10
  }
11
+
12
+ export function formatKeyLabel(key: string): string {
13
+ const sanitized = sanitizeSingleLine(key);
14
+ if (!sanitized) return "";
15
+ return sanitized
16
+ .split("+")
17
+ .map((part) => {
18
+ const lower = part.toLowerCase();
19
+ if (lower === "shift") return "Shift";
20
+ if (lower === "ctrl") return "Ctrl";
21
+ if (lower === "alt") return "Alt";
22
+ if (lower === "super") return "Super";
23
+ return part.length === 1
24
+ ? part.toUpperCase()
25
+ : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
26
+ })
27
+ .join("+");
28
+ }
@@ -24,7 +24,7 @@ import {
24
24
  } from "@earendil-works/pi-tui";
25
25
  import type { BtwFullscreenLayoutComponent } from "./fullscreen-ui.js";
26
26
  import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
27
- import { sanitizeSingleLine } from "./text.js";
27
+ import { formatKeyLabel, sanitizeSingleLine } from "./text.js";
28
28
 
29
29
  const TRANSCRIPT_CHROME_LINES = 2;
30
30
  const MAX_STEERING_DISPLAY_LINES = 3;
@@ -613,21 +613,10 @@ function renderSideThreadHeader(
613
613
  }
614
614
 
615
615
  function thinkingKeyLabel(keybindings: KeybindingsManager): string {
616
- const key =
617
- sanitizeSingleLine(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
618
- "Shift+Tab";
619
- return key
620
- .split("+")
621
- .map((part) => {
622
- const lower = part.toLowerCase();
623
- if (lower === "shift") return "Shift";
624
- if (lower === "ctrl") return "Ctrl";
625
- if (lower === "alt") return "Alt";
626
- return part.length === 1
627
- ? part.toUpperCase()
628
- : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
629
- })
630
- .join("+");
616
+ return (
617
+ formatKeyLabel(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
618
+ "Shift+Tab"
619
+ );
631
620
  }
632
621
 
633
622
  function fitComposerLayout(