@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.
package/README.md CHANGED
@@ -9,7 +9,7 @@ Only context you explicitly bring back is loaded into the main editor.
9
9
 
10
10
  - Starts a side thread immediately with `/btw <question>` or opens the manager with `/btw`.
11
11
  - Uses any persisted main-session branch as context without switching branches.
12
- - Supports scrollable answers, transcript search, follow-up questions, queued steering, and in-memory resume.
12
+ - Supports scrollable answers, transcript search, a clickable jump-to-latest control, follow-up questions, queued steering, and in-memory resume.
13
13
  - Keeps side questions and answers out of the main conversation by default.
14
14
  - Brings back the latest answer, a question suffix, an exact range, or the complete thread only when requested.
15
15
  - Uses Pi's current model and thinking level or saved pi-btw choices.
@@ -43,114 +43,16 @@ The side thread stays separate until you explicitly bring context to the main ed
43
43
 
44
44
  ## 💬 Commands
45
45
 
46
- `/btw` is TUI-only.
47
- Open the manager or provide the first question immediately:
46
+ | Command | Purpose |
47
+ | --- | --- |
48
+ | `/btw` | Choose context, start or resume a side thread, or change settings. |
49
+ | `/btw <question>` | Start a new side thread immediately with the supplied question. |
48
50
 
49
- ```text
50
- /btw
51
- /btw <your side question>
52
- ```
53
-
54
- Examples:
55
-
56
- ```text
57
- /btw
58
- /btw what does this TypeScript error mean?
59
- /btw summarize the current implementation before we continue
60
- /btw is this API name idiomatic?
61
- ```
62
-
63
- ### Choose context or resume a thread
64
-
65
- Running `/btw` opens a manager with **Start side thread** selected first.
66
- **Start from main thread tree…** opens Pi's session tree and uses the root-to-selected-entry path, including the selected entry, as context.
67
- This choice preserves the main editor draft and does not navigate, fork, append to, or switch the main conversation.
68
- The tree is a snapshot of persisted entries, and the side thread keeps immutable context even if the main conversation later changes.
69
- Escape returns to the manager, and Ctrl+C closes the flow.
70
- Native tree copying reports success or failure.
71
- An explicit `Shift+L` label edit is the only main-session mutation available from this selector and persists through Pi.
72
-
73
- When non-empty threads exist in memory, **Resume side thread** opens a bounded searchable list.
74
- Search matches the first question and question count while retaining each raw thread ID.
75
- Each row uses the first question as its fixed title and shows the question count.
76
- Rows are ordered by the newest recorded answer or visible error; opening and closing without a new result does not reorder them.
77
- **Settings** controls the starting thinking level, fixed-level shortcut memory, and automatic selection copying.
78
- `/btw <question>` bypasses the manager and always starts a new thread.
79
-
80
- ### Use the side-thread workspace
81
-
82
- The side thread uses a dedicated full-screen terminal view with answers above the editor.
83
- The main agent can continue running, but main-screen rendering stays suspended until `/btw` closes so new output cannot move a mouse selection.
84
- Returning to Pi redraws everything produced while the main view was hidden.
85
-
86
- A fixed `btw · side thread` header identifies the workspace while scrolling.
87
- Messages use Pi's normal user and assistant presentation without turn numbers or role labels.
88
- Type a question and press Enter for each turn.
89
- Previous successful questions and answers remain visible and available to the side model.
90
-
91
- Drag the primary mouse button across transcript text to select it.
92
- Automatic selection copying is on by default and immediately requests a copy through Pi's host clipboard helper.
93
- When **Copy selection automatically** is off, the selection stays highlighted and Pi's effective `app.message.copy` binding copies it.
94
- Manual mode requires Pi's current fullscreen selection APIs.
95
- If those APIs are unavailable, pi-btw restores the main TUI and asks you to update Pi or re-enable automatic copying.
96
- The view reports `No selection to copy` when that binding is used without an active selection.
97
- It reports `Copied!` when Pi accepts a clipboard request and `Copy failed` when Pi rejects it.
98
- Actual clipboard access still depends on the operating system and terminal.
99
- Ctrl+C always cancels the side flow, even when `app.message.copy` is also mapped to Ctrl+C.
100
-
101
- Press `Ctrl+Shift+F` to search completed or in-progress transcript content.
102
- Press Enter or `Ctrl+G` for the next match, `Shift+Enter` or `Ctrl+Shift+G` for the previous match, and Escape to close search.
103
- Search uses Pi's active theme, excludes fixed header and footer text, and returns focus to the composer when closed.
104
-
105
- ### Change thinking level or queue steering
106
-
107
- The header shows the side thread's current thinking level.
108
- In the composer, use Pi's configured `app.thinking.cycle` shortcut (`Shift+Tab` by default) to cycle levels supported by the side model.
109
- Later questions use the displayed level until it changes again.
110
- For a fixed starting level, shortcut changes are saved to `pi-btw.json` by default.
111
- Turn **Remember thinking level changes** off to keep them local to the thread.
112
- With **Same as main thread**, shortcut changes are always local.
113
- Neither setting changes the main session's thinking level.
114
-
115
- During a response, the transcript and composer remain visible above `Answering…`.
116
- Submit another question to queue it as `Steering`.
117
- Queued questions appear in submission order and run one at a time after the active response.
118
- Each queued question uses the side thread's thinking level when its turn starts.
119
- A failed response remains visible and does not discard later queued questions.
120
- Steering never appends to the main conversation or editor.
121
-
122
- Use the mouse wheel, trackpad, or `PgUp`/`PgDn` to scroll transcript history.
123
- The footer shows history keys only when scrolling is available.
124
- Ctrl+C cancels the active response and discards the current draft and steering queue.
125
- Completed questions, answers, and visible errors remain available through Resume until the extension instance ends.
126
-
127
- ### Bring context to the main editor
128
-
129
- After a successful answer, press `Ctrl+R` to choose context for the main editor.
130
- The scope menu shows the size of the latest question and answer and the full thread.
131
- Choose the latest question and answer, everything from one question onward, an exact range, or the full thread.
132
- Question-suffix, exact-range, and full-thread choices preview the editable context block before the side thread closes.
133
- Escape returns, while Ctrl+C closes without bringing context back.
134
-
135
- The exact-range selector supports whole-line and editor-style character selection.
136
- It reports selected line, message, and approximate token counts.
137
- Press Space to select the current raw source line, use Up or Down to extend by lines, and press Space again to clear.
138
- Alternatively, move with arrow keys and extend a character selection with Shift plus an arrow key.
139
- Starting a Shift selection replaces an active line selection.
140
- Selected lines show a `●` marker as well as highlighting.
141
- Pi's configured keys control vertical navigation, bringing, and going back, with Up, Down, Enter, and Escape as defaults.
142
- Selection follows raw source text rather than terminal-wrapped rows.
143
-
144
- Bringing context closes the side thread and loads a deterministic, editable block into Pi's main editor without sending it.
145
- If a draft already exists, append is the recommended default.
146
- Replace is marked destructive and requires a second confirmation.
147
- Cancel returns to the side thread without changing either draft, and concurrent editor updates are preserved.
148
- A success message reports whether context was loaded, appended, or replaced and gives its approximate size.
149
-
150
- Without an explicit bring action, closing `/btw` never changes the main conversation.
151
- Non-empty threads remain in memory only for Resume during the current extension instance.
152
- `/new`, Pi `/resume`, `/reload`, extension replacement, and process restart discard retained threads.
153
- Unsent drafts, steering queues, interrupted answers, and model credentials are not retained.
51
+ Both routes require TUI mode and a model with usable credentials; see [Settings](#-settings).
52
+ Side questions and selected conversation context are sent to that model's provider.
53
+ Bringing context back fills the main editor without submitting; replacing an existing draft requires confirmation.
54
+ Ctrl+C cancels the response and discards the current draft and queued questions, but completed exchanges remain resumable in memory.
55
+ Read the [workflow guide](./docs/workflows.md) for context selection, copying, search, steering, and draft recovery; `/new`, `/resume`, `/reload`, and restart discard retained threads.
154
56
 
155
57
  ## ⚙️ Settings
156
58
 
@@ -216,43 +118,22 @@ The file is read for every `/btw` invocation, so edits apply without `/reload`.
216
118
  - Resume state is memory-only and lasts only for the current extension instance.
217
119
  - A side thread retains the latest 40,000 characters of main-conversation context and adds a truncation notice when earlier content is omitted.
218
120
  - Clipboard access depends on Pi's host helper, the operating system, and the terminal.
121
+ - Pi versions before 0.85 omit the clickable jump-to-latest control.
219
122
 
220
123
  ## 🗂️ Package layout
221
124
 
222
- ```txt
125
+ ```text
223
126
  packages/pi-btw/
224
- ├── dist/ # Generated TypeScript runtime loaded by Jiti
225
- ├── scripts/
226
- │ └── build-runtime.mjs # Deterministic runtime builder and boundary validator
227
- ├── src/
228
- ├── index.ts
229
- ├── btw.ts
230
- │ ├── bring-to-main.ts
231
- │ ├── fullscreen-ui.ts
232
- │ ├── main-tree-picker.ts
233
- │ ├── menu.ts
234
- │ ├── settings.ts
235
- │ ├── side-thread.ts
236
- │ ├── text.ts
237
- │ └── transcript-pager.ts
238
- ├── test/
239
- ├── README.md
240
- ├── LICENSE
241
- ├── tsconfig.json
242
- └── package.json
243
- ```
244
-
245
- The package exposes its Pi extension through `package.json`:
246
-
247
- ```json
248
- {
249
- "pi": {
250
- "extensions": ["./dist/index.ts"]
251
- }
252
- }
127
+ ├── src/ # Authoritative implementation and helpers
128
+ ├── index.ts # Thin Pi entrypoint
129
+ │ └── btw.ts # Side-thread lifecycle and command
130
+ ├── dist/ # Generated Jiti runtime
131
+ ├── docs/ # Side-thread workflows and controls
132
+ ├── scripts/build-runtime.mjs # Runtime builder
133
+ └── test/ # Behavior and lifecycle coverage
253
134
  ```
254
135
 
255
- The generated runtime is built from the authoritative `src/index.ts` graph and does not import back into `src`.
136
+ The generated runtime is built from `src/index.ts` and does not import back into `src`.
256
137
 
257
138
  ## 🔎 Keywords
258
139
 
package/dist/index.ts CHANGED
@@ -482,8 +482,10 @@ import {
482
482
  } from "@earendil-works/pi-coding-agent";
483
483
  import {
484
484
  isKeyRelease,
485
+ isKittyProtocolActive,
485
486
  Key as Key2,
486
487
  matchesKey as matchesKey2,
488
+ parseKey,
487
489
  TuiAltScreen,
488
490
  truncateToWidth as truncateToWidth2
489
491
  } from "@earendil-works/pi-tui";
@@ -495,6 +497,18 @@ function sanitizeSingleLine(text) {
495
497
  return code > 31 && (code < 127 || code > 159);
496
498
  }).join("").replace(/ +/gu, " ").trim();
497
499
  }
500
+ function formatKeyLabel2(key) {
501
+ const sanitized = sanitizeSingleLine(key);
502
+ if (!sanitized) return "";
503
+ return sanitized.split("+").map((part) => {
504
+ const lower = part.toLowerCase();
505
+ if (lower === "shift") return "Shift";
506
+ if (lower === "ctrl") return "Ctrl";
507
+ if (lower === "alt") return "Alt";
508
+ if (lower === "super") return "Super";
509
+ return part.length === 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
510
+ }).join("+");
511
+ }
498
512
 
499
513
  // src/fullscreen-ui.ts
500
514
  var FullscreenUiDisposedError = class extends Error {
@@ -607,6 +621,88 @@ var BtwTuiAltScreen = class extends TuiAltScreen {
607
621
  };
608
622
  var BRACKETED_PASTE_START = "\x1B[200~";
609
623
  var BRACKETED_PASTE_END = "\x1B[201~";
624
+ var ALT_SCREEN_ACTIONS_BEFORE_BOTTOM = [
625
+ "tui.altScreen.search",
626
+ "tui.altScreen.searchNext",
627
+ "tui.altScreen.searchPrevious",
628
+ "tui.altScreen.searchClose",
629
+ "tui.altScreen.pageUp",
630
+ "tui.altScreen.pageDown",
631
+ "tui.altScreen.halfPageUp",
632
+ "tui.altScreen.halfPageDown",
633
+ "tui.altScreen.lineUp",
634
+ "tui.altScreen.lineDown",
635
+ "tui.altScreen.previousPrompt",
636
+ "tui.altScreen.nextPrompt",
637
+ "tui.altScreen.top"
638
+ ];
639
+ var KEY_MODIFIER_ORDER = ["shift", "ctrl", "alt", "super"];
640
+ var MATCHABLE_SPECIAL_KEYS = /* @__PURE__ */ new Set([
641
+ "space",
642
+ "tab",
643
+ "enter",
644
+ "backspace",
645
+ "delete",
646
+ "insert",
647
+ "home",
648
+ "end",
649
+ "pageup",
650
+ "pagedown",
651
+ "up",
652
+ "down",
653
+ "left",
654
+ "right"
655
+ ]);
656
+ var MATCHABLE_SYMBOL_KEYS = new Set("`-=[]\\;',./!@#$%^&*()_+|~{}:<>?");
657
+ function normalizedKeyId(key) {
658
+ const parts = key.toLowerCase().split("+");
659
+ const base = parts.at(-1);
660
+ if (!base) return "";
661
+ const normalizedBase = base === "esc" ? "escape" : base === "return" ? "enter" : base;
662
+ const modifiers = KEY_MODIFIER_ORDER.filter((modifier) => parts.includes(modifier));
663
+ return [...modifiers, normalizedBase].join("+");
664
+ }
665
+ function formatEffectiveKeyLabel(key) {
666
+ const parts = key.split("+");
667
+ const base = parts.at(-1);
668
+ if (base === "pageup") parts[parts.length - 1] = "pageUp";
669
+ if (base === "pagedown") parts[parts.length - 1] = "pageDown";
670
+ return formatKeyLabel2(parts.join("+"));
671
+ }
672
+ function canMatchKeyInput(key) {
673
+ const parts = key.split("+");
674
+ const base = parts.at(-1) ?? "";
675
+ const modifiers = parts.slice(0, -1);
676
+ if (base === "escape") return modifiers.length === 0;
677
+ if (base === "clear") {
678
+ return modifiers.length === 0 || modifiers.length === 1 && (modifiers[0] === "shift" || modifiers[0] === "ctrl");
679
+ }
680
+ if (/^f(?:[1-9]|1[0-2])$/u.test(base)) return modifiers.length === 0;
681
+ return MATCHABLE_SPECIAL_KEYS.has(base) || base.length === 1 && (/^[a-z0-9]$/u.test(base) || MATCHABLE_SYMBOL_KEYS.has(base));
682
+ }
683
+ function rawCtrlInput(base) {
684
+ if (base.length !== 1) return void 0;
685
+ const rawBase = base === "-" ? "_" : base;
686
+ if (!"abcdefghijklmnopqrstuvwxyz[\\]_".includes(rawBase)) return void 0;
687
+ return String.fromCharCode(rawBase.charCodeAt(0) & 31);
688
+ }
689
+ function legacyRawInput(key) {
690
+ const parts = key.split("+");
691
+ const base = parts.at(-1) ?? "";
692
+ if (parts.length === 2 && parts[0] === "ctrl") return rawCtrlInput(base);
693
+ if (isKittyProtocolActive()) return void 0;
694
+ if (parts.length === 2 && parts[0] === "alt" && base.length === 1) return `\x1B${base}`;
695
+ if (parts.length === 3 && parts[0] === "ctrl" && parts[1] === "alt") {
696
+ const input = rawCtrlInput(base);
697
+ return input ? `\x1B${input}` : void 0;
698
+ }
699
+ return void 0;
700
+ }
701
+ function keyInputIdentity(key) {
702
+ const identity = normalizedKeyId(key);
703
+ const input = legacyRawInput(identity);
704
+ return input ? normalizedKeyId(parseKey(input) ?? identity) : identity;
705
+ }
610
706
  function hasManualSelectionCopyApi() {
611
707
  return typeof TuiAltScreen.prototype.hasActiveSelection === "function" && typeof TuiAltScreen.prototype.copyActiveSelectionToClipboard === "function";
612
708
  }
@@ -625,6 +721,25 @@ function createBtwFullscreenTui(parent, theme, keybindings, copyOnSelect, manual
625
721
  mouse: true,
626
722
  copyOnSelect,
627
723
  searchMatchStyle: (text) => theme.underline(styleSearchMatch(text)),
724
+ scrollToEndIndicator: () => {
725
+ const unavailableKeyIdentities = /* @__PURE__ */ new Set([keyInputIdentity(Key2.ctrl("c"))]);
726
+ for (const action of ALT_SCREEN_ACTIONS_BEFORE_BOTTOM) {
727
+ for (const actionKey of keybindings.getKeys(action)) {
728
+ unavailableKeyIdentities.add(keyInputIdentity(String(actionKey)));
729
+ }
730
+ }
731
+ if (!copyOnSelect) {
732
+ for (const copyKey of keybindings.getKeys("app.message.copy")) {
733
+ unavailableKeyIdentities.add(keyInputIdentity(String(copyKey)));
734
+ }
735
+ }
736
+ const key = keybindings.getKeys("tui.altScreen.bottom").map((candidate) => keyInputIdentity(String(candidate))).find(
737
+ (identity) => identity && canMatchKeyInput(identity) && !unavailableKeyIdentities.has(identity) && formatEffectiveKeyLabel(identity)
738
+ );
739
+ const label = theme.fg("text", " \u2193 Jump to latest message");
740
+ const shortcut = key ? theme.fg("muted", ` \xB7 ${formatEffectiveKeyLabel(key)}`) : "";
741
+ return theme.bg("selectedBg", `${label}${shortcut} `);
742
+ },
628
743
  searchCurrentMatchStyle: (text) => theme.bold(theme.inverse(styleSearchMatch(text))),
629
744
  openUrl,
630
745
  copySelection: async (text) => {
@@ -1010,14 +1125,15 @@ async function completeSideThreadTurn({
1010
1125
  thinkingLevel,
1011
1126
  auth,
1012
1127
  signal,
1013
- completeSimple
1128
+ completeSimple,
1129
+ sessionId
1014
1130
  }) {
1015
1131
  if (signal?.aborted) return { kind: "aborted" };
1016
1132
  try {
1017
1133
  const response = await completeSimple(
1018
1134
  model,
1019
1135
  { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
1020
- buildStreamOptions(auth, thinkingLevel, signal)
1136
+ buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId })
1021
1137
  );
1022
1138
  if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
1023
1139
  if (!isAssistantMessage(response)) {
@@ -1076,10 +1192,31 @@ function createUserMessage(text) {
1076
1192
  timestamp: Date.now()
1077
1193
  };
1078
1194
  }
1079
- function buildStreamOptions(auth, thinkingLevel, signal) {
1195
+ var OPENCODE_HOST = "opencode.ai";
1196
+ function matchesOpencodeHost(baseUrl) {
1197
+ if (!baseUrl) return false;
1198
+ try {
1199
+ return new URL(baseUrl).hostname === OPENCODE_HOST;
1200
+ } catch {
1201
+ return false;
1202
+ }
1203
+ }
1204
+ function getOpencodeSessionHeaders(model, sessionId) {
1205
+ if (!sessionId) return void 0;
1206
+ if (model.provider !== "opencode" && model.provider !== "opencode-go" && !matchesOpencodeHost(model.baseUrl)) {
1207
+ return void 0;
1208
+ }
1209
+ return { "x-opencode-session": sessionId, "x-opencode-client": "pi" };
1210
+ }
1211
+ function mergeSessionHeaders(authHeaders, sessionHeaders) {
1212
+ if (!sessionHeaders && !authHeaders) return void 0;
1213
+ return { ...sessionHeaders, ...authHeaders };
1214
+ }
1215
+ function buildStreamOptions(auth, { thinkingLevel, signal, model, sessionId }) {
1216
+ const sessionHeaders = model ? getOpencodeSessionHeaders(model, sessionId) : void 0;
1080
1217
  const options = {
1081
1218
  apiKey: auth.apiKey,
1082
- headers: auth.headers,
1219
+ headers: mergeSessionHeaders(auth.headers, sessionHeaders),
1083
1220
  env: auth.env,
1084
1221
  signal
1085
1222
  };
@@ -2376,14 +2513,7 @@ function renderSideThreadHeader(width, theme, thinkingLevel) {
2376
2513
  return theme.fg("muted", `${title}${"\u2500".repeat(ruleWidth)}`);
2377
2514
  }
2378
2515
  function thinkingKeyLabel(keybindings) {
2379
- const key = sanitizeSingleLine(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) || "Shift+Tab";
2380
- return key.split("+").map((part) => {
2381
- const lower = part.toLowerCase();
2382
- if (lower === "shift") return "Shift";
2383
- if (lower === "ctrl") return "Ctrl";
2384
- if (lower === "alt") return "Alt";
2385
- return part.length === 1 ? part.toUpperCase() : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
2386
- }).join("+");
2516
+ return formatKeyLabel2(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) || "Shift+Tab";
2387
2517
  }
2388
2518
  function fitComposerLayout(header, contentLines, footer, editorLines, availableRows, statusLines = []) {
2389
2519
  const lines = [header, ...contentLines, ...statusLines, footer, ...editorLines];
@@ -2507,6 +2637,12 @@ function providerHeadersHaveValue(headers) {
2507
2637
  function formatError5(error) {
2508
2638
  return error instanceof Error ? error.message : String(error);
2509
2639
  }
2640
+ function readBtwSessionId(ctx) {
2641
+ const getSessionId = ctx.sessionManager.getSessionId;
2642
+ if (typeof getSessionId !== "function") return void 0;
2643
+ const sessionId = getSessionId.call(ctx.sessionManager);
2644
+ return sessionId.length > 0 ? sessionId : void 0;
2645
+ }
2510
2646
  function notifySafely3(ctx, message, level) {
2511
2647
  try {
2512
2648
  ctx.ui.notify(sanitizeSingleLine(message), level);
@@ -3041,7 +3177,8 @@ async function askThreadQuestion(thread, question, selected, thinkingLevel, ctx,
3041
3177
  thinkingLevel,
3042
3178
  auth: selected.auth,
3043
3179
  signal: view.signal,
3044
- completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry)
3180
+ completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
3181
+ sessionId: readBtwSessionId(ctx)
3045
3182
  }).then((result) => {
3046
3183
  if (settled) return;
3047
3184
  settled = true;