@narumitw/pi-btw 0.42.0 β†’ 0.43.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/README.md CHANGED
@@ -8,13 +8,14 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
8
8
 
9
9
  ## ✨ Features
10
10
 
11
- - Adds a `/btw` side-thread command to Pi, with an optional initial question.
11
+ - Adds a `/btw` menu for starting a side thread or changing pi-btw settings.
12
+ - Keeps `/btw <question>` as a direct fast path.
12
13
  - Answers side questions in a temporary, scrollable UI.
13
14
  - Supports follow-up questions in the same ephemeral side thread.
14
15
  - Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
15
16
  - Uses the current session branch as context.
16
17
  - Uses Pi's current model or an independent model selected in `pi-btw.json`.
17
- - Inherits Pi's current thinking level or uses a fixed level from `pi-btw.json`.
18
+ - Uses a pi-btw thinking level that can be changed with Pi's configured thinking shortcut and remembered for next time.
18
19
  - Does not append the side question or answer to the main conversation.
19
20
  - Works as an independently installable npm Pi extension package.
20
21
 
@@ -38,7 +39,7 @@ pi -e ./extensions/pi-btw
38
39
 
39
40
  ## πŸš€ Usage
40
41
 
41
- Start an empty side thread or provide its first question immediately:
42
+ Open the pi-btw menu or provide the first question immediately:
42
43
 
43
44
  ```text
44
45
  /btw
@@ -54,16 +55,23 @@ Examples:
54
55
  /btw is this API name idiomatic?
55
56
  ```
56
57
 
57
- Running `/btw` alone opens an empty ephemeral side thread with its editor ready. When an
58
- initial question is provided, its answer opens above the same editor. A compact
59
- `btw Β· side thread` header stays fixed above the content so the ephemeral workspace remains
60
- recognizable while scrolling. Messages use Pi's normal user and assistant presentation without
61
- numbered turns or role labels. Type each question and press `Enter`; no follow-up shortcut is
62
- required.
58
+ Running `/btw` alone opens a two-row menu. **Start side thread** is selected first, so pressing
59
+ `Enter` opens an empty ephemeral side thread; **Settings** changes the starting thinking level
60
+ and whether shortcut changes are remembered. `/btw <question>` bypasses this menu, and its answer
61
+ opens above the side-thread editor. A compact `btw Β· side thread` header stays fixed above the
62
+ content so the ephemeral workspace remains recognizable while scrolling. Messages use Pi's normal
63
+ user and assistant presentation without numbered turns or role labels. Type each question and press
64
+ `Enter`; no follow-up shortcut is required.
63
65
  Previous side questions and answers remain available to the model and visible for that
64
- invocation. While a response is running, the transcript stays visible above a compact
65
- `Answering…` status. The footer shows `PgUp`/`PgDn` only when history can scroll; press
66
- `Ctrl+C` to cancel an in-progress answer or leave the side thread.
66
+ invocation. The side-thread header shows its current thinking level. Press Pi's configured
67
+ `app.thinking.cycle` shortcut (`Shift+Tab` by default) in the composer to cycle the levels
68
+ supported by the side-thread model; every later question uses the displayed level until it is
69
+ changed again. By default, each shortcut change is also written to `pi-btw.json` for the next
70
+ invocation. Turn **Remember thinking level changes** off in Settings to keep changes local to the
71
+ current side thread. Neither path changes the main session's thinking level. While a response is
72
+ running, the transcript stays visible above a compact `Answering…` status.
73
+ The footer shows `PgUp`/`PgDn` only when history can scroll; press `Ctrl+C` to cancel an
74
+ in-progress answer or leave the side thread.
67
75
 
68
76
  After at least one successful answer, press `Ctrl+R` to bring selected context to the main
69
77
  editor. The scope menu shows the size of the latest question and answer and the entire side
@@ -106,7 +114,8 @@ setting; pi-btw does not add any environment variables.
106
114
  ```json
107
115
  {
108
116
  "model": "anthropic/claude-sonnet-4-5",
109
- "thinkingLevel": "low"
117
+ "thinkingLevel": "low",
118
+ "rememberThinkingLevelChanges": true
110
119
  }
111
120
  ```
112
121
 
@@ -117,17 +126,26 @@ cannot be found or authenticated, pi-btw warns and falls back to the current ses
117
126
  If neither model is available, `/btw` reports an error and stops. This selection affects only
118
127
  `/btw`; it does not change the main session model.
119
128
 
120
- Pi calls its reasoning setting the **thinking level**. By default, `/btw` inherits the
121
- current runtime level, including changes made through `/settings` or `Shift+Tab`. It does
122
- not read or change `defaultThinkingLevel` directly. Supported fixed values are `off`,
123
- `minimal`, `low`, `medium`, `high`, and `xhigh`. The selected value applies to the model
124
- actually used by `/btw` and does not change the main session. Pi's provider layer may clamp
125
- a requested level when that model does not support it.
126
-
127
- The settings file is optional and is never created automatically. A missing file, `{}`, or
128
- omitted fields silently inherit the current Pi model and thinking level. The file is read for
129
- each `/btw` invocation, so edits apply to the next side question without `/reload`. Invalid
130
- or unreadable settings produce a warning and fall back to the current Pi defaults.
129
+ Pi calls its reasoning setting the **thinking level**. `thinkingLevel` sets pi-btw's starting
130
+ level; accepted values are `off`, `minimal`, `low`, `medium`, `high`, `xhigh`, and `max`. When the
131
+ field is absent for backward compatibility, the next invocation starts from the current session
132
+ level. The initial value and shortcut cycle are clamped to the selected side model's capabilities
133
+ using Pi's model rules. Pi-btw does not read, write, or change the main session's
134
+ `defaultThinkingLevel`.
135
+
136
+ `rememberThinkingLevelChanges` controls only persistence and defaults to `true` when omitted. A
137
+ side-thread shortcut always changes that side thread immediately. When remembering is on, the
138
+ concrete level is written for the next invocation; when off, `pi-btw.json` stays unchanged. If a
139
+ shortcut write fails, the local change remains active and pi-btw warns that it was not remembered.
140
+ A failed Settings-screen save instead restores the previous displayed value.
141
+
142
+ A missing settings file is a side-effect-free read: pi-btw creates it only after a Settings change
143
+ or a remembered shortcut change. Saves are ordered within the Pi process and published atomically
144
+ with a same-directory temporary file and rename. They preserve `model` and unknown fields; malformed
145
+ or invalid files block saves and remain unchanged. Settings must be valid UTF-8 and no larger than
146
+ 64 KiB, so unexpectedly large or invalidly encoded files are rejected without being rewritten.
147
+ Separate Pi processes and external editors are outside this in-process ordering boundary. The file
148
+ is read for each `/btw` invocation, so edits apply without `/reload`.
131
149
 
132
150
  ## 🧠 Why use pi-btw?
133
151
 
@@ -141,7 +159,10 @@ extensions/pi-btw/
141
159
  β”‚ β”œβ”€β”€ index.ts
142
160
  β”‚ β”œβ”€β”€ btw.ts
143
161
  β”‚ β”œβ”€β”€ bring-to-main.ts
162
+ β”‚ β”œβ”€β”€ menu.ts
163
+ β”‚ β”œβ”€β”€ settings.ts
144
164
  β”‚ β”œβ”€β”€ side-thread.ts
165
+ β”‚ β”œβ”€β”€ text.ts
145
166
  β”‚ └── transcript-pager.ts
146
167
  β”œβ”€β”€ README.md
147
168
  β”œβ”€β”€ LICENSE
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,5 +38,13 @@
38
38
  "type": "git",
39
39
  "url": "https://github.com/narumiruna/pi-extensions",
40
40
  "directory": "extensions/pi-btw"
41
+ },
42
+ "dependencies": {
43
+ "@narumitw/pi-tui-kit": "^0.42.0"
44
+ },
45
+ "peerDependencies": {
46
+ "@earendil-works/pi-ai": "*",
47
+ "@earendil-works/pi-coding-agent": "*",
48
+ "@earendil-works/pi-tui": "*"
41
49
  }
42
50
  }
@@ -47,11 +47,6 @@ export type BtwQuickBringToMainScope =
47
47
  | { kind: "from"; answeredTurnIndex: number }
48
48
  | { kind: "entire" };
49
49
 
50
- export type BtwMenuSelectorAction =
51
- | { kind: "select"; value: string }
52
- | { kind: "back" }
53
- | { kind: "close" };
54
-
55
50
  export type BtwTextRangeSelectorAction =
56
51
  | { kind: "confirm"; segments: BtwBringToMainSegment[] }
57
52
  | { kind: "back" }
@@ -175,210 +170,6 @@ export function formatBtwBringToMain(segments: readonly BtwBringToMainSegment[])
175
170
  ].join("\n");
176
171
  }
177
172
 
178
- export type BtwBringToMainPreviewAction = { kind: "bring" } | { kind: "back" } | { kind: "close" };
179
-
180
- export class BtwBringToMainPreview implements Component {
181
- private readonly lines: string[];
182
- private displayLines: string[];
183
- private scrollOffset = 0;
184
- private finished = false;
185
-
186
- constructor(
187
- private readonly tui: TUI,
188
- private readonly theme: Theme,
189
- private readonly keybindings: KeybindingsManager,
190
- draft: string,
191
- private readonly summary: BtwBringToMainSummary,
192
- private readonly onAction: (action: BtwBringToMainPreviewAction) => void,
193
- ) {
194
- this.lines = draft.split("\n").map(escapeTerminalControls);
195
- this.displayLines = this.lines;
196
- }
197
-
198
- render(width: number): string[] {
199
- const safeWidth = Math.max(1, width);
200
- this.displayLines = this.lines.flatMap((line) => wrapPreviewLine(line, safeWidth));
201
- const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
202
- const showFooter = availableRows >= 3;
203
- const viewportHeight = Math.max(1, availableRows - 1 - (showFooter ? 1 : 0));
204
- this.clampScroll(viewportHeight);
205
- const count = this.summary.messages === 1 ? "1 message" : `${this.summary.messages} messages`;
206
- const lineCount = this.summary.lines === 1 ? "1 line" : `${this.summary.lines} lines`;
207
- const firstVisible = Math.min(this.displayLines.length, this.scrollOffset + 1);
208
- const lastVisible = Math.min(this.displayLines.length, this.scrollOffset + viewportHeight);
209
- const scrollable = this.displayLines.length > viewportHeight;
210
- const position = `${firstVisible}–${lastVisible}/${this.displayLines.length}`;
211
- const header = `Preview${scrollable ? ` ${position}` : ""} Β· ${count} Β· ${lineCount} Β· ~${this.summary.tokens} tokens`;
212
- const actions = `${confirmKeyLabel(this.keybindings)} bring β€’ ${keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"])} back β€’ Ctrl+C close`;
213
- const scroll = `${position} β€’ ${keybindingLabel(this.keybindings, "tui.select.pageUp")}/${keybindingLabel(this.keybindings, "tui.select.pageDown")} scroll`;
214
- const detailedFooter = scrollable ? `${scroll} β€’ ${actions}` : actions;
215
- const footer = visibleWidth(detailedFooter) <= safeWidth ? detailedFooter : actions;
216
- return fitRows(
217
- [
218
- truncateToWidth(this.theme.fg("accent", this.theme.bold(header)), safeWidth, ""),
219
- ...this.displayLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
220
- ...(showFooter ? [truncateToWidth(this.theme.fg("muted", footer), safeWidth, "")] : []),
221
- ],
222
- availableRows,
223
- );
224
- }
225
-
226
- handleInput(data: string): void {
227
- if (this.finished) return;
228
- if (matchesKey(data, Key.ctrl("c"))) {
229
- this.finish({ kind: "close" });
230
- return;
231
- }
232
- if (this.keybindings.matches(data, "tui.select.cancel")) {
233
- this.finish({ kind: "back" });
234
- return;
235
- }
236
- if (matchesConfirm(data, this.keybindings)) {
237
- this.finish({ kind: "bring" });
238
- return;
239
- }
240
- if (this.keybindings.matches(data, "tui.select.pageUp")) {
241
- this.scrollOffset -= Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS - 2);
242
- this.clampScroll(Math.max(0, this.tui.terminal.rows - RESERVED_APP_ROWS - 2));
243
- this.tui.requestRender();
244
- return;
245
- }
246
- if (this.keybindings.matches(data, "tui.select.pageDown")) {
247
- this.scrollOffset += Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS - 2);
248
- this.clampScroll(Math.max(0, this.tui.terminal.rows - RESERVED_APP_ROWS - 2));
249
- this.tui.requestRender();
250
- }
251
- }
252
-
253
- invalidate(): void {}
254
-
255
- private clampScroll(viewportHeight: number): void {
256
- this.scrollOffset = Math.max(
257
- 0,
258
- Math.min(this.scrollOffset, Math.max(0, this.displayLines.length - viewportHeight)),
259
- );
260
- }
261
-
262
- private finish(action: BtwBringToMainPreviewAction): void {
263
- if (this.finished) return;
264
- this.finished = true;
265
- this.onAction(action);
266
- }
267
- }
268
-
269
- export class BtwMenuSelector implements Component {
270
- private cursor = 0;
271
- private scrollOffset = 0;
272
- private finished = false;
273
-
274
- constructor(
275
- private readonly tui: TUI,
276
- private readonly theme: Theme,
277
- private readonly keybindings: KeybindingsManager,
278
- private readonly title: string,
279
- private readonly options: readonly string[],
280
- private readonly onAction: (action: BtwMenuSelectorAction) => void,
281
- initialValue?: string,
282
- ) {
283
- const initialIndex = initialValue === undefined ? -1 : options.indexOf(initialValue);
284
- if (initialIndex >= 0) this.cursor = initialIndex;
285
- }
286
-
287
- render(width: number): string[] {
288
- const safeWidth = Math.max(1, width);
289
- const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_ROWS);
290
- const showFooter = availableRows >= 3;
291
- const viewportHeight = Math.max(1, availableRows - 1 - (showFooter ? 1 : 0));
292
- this.keepCursorVisible(viewportHeight);
293
- const rows = this.options
294
- .slice(this.scrollOffset, this.scrollOffset + viewportHeight)
295
- .map((option, visibleIndex) => {
296
- const index = this.scrollOffset + visibleIndex;
297
- const raw = `${index === this.cursor ? ">" : " "} ${escapeTerminalControls(option)}`;
298
- const toned = option.startsWith("⚠") ? this.theme.fg("warning", raw) : raw;
299
- const styled =
300
- index === this.cursor ? this.theme.bg("selectedBg", this.theme.fg("text", toned)) : toned;
301
- return truncateToWidth(styled, safeWidth, "");
302
- });
303
- return fitRows(
304
- [
305
- truncateToWidth(
306
- this.theme.fg("accent", this.theme.bold(escapeTerminalControls(this.title))),
307
- safeWidth,
308
- "",
309
- ),
310
- ...rows,
311
- ...(showFooter
312
- ? [
313
- truncateToWidth(
314
- this.theme.fg(
315
- "muted",
316
- `${confirmKeyLabel(this.keybindings)} confirm β€’ ${keybindingLabel(this.keybindings, "tui.select.cancel", ["ctrl+c"])} back β€’ Ctrl+C close`,
317
- ),
318
- safeWidth,
319
- "",
320
- ),
321
- ]
322
- : []),
323
- ],
324
- availableRows,
325
- );
326
- }
327
-
328
- handleInput(data: string): void {
329
- if (this.finished) return;
330
- if (matchesKey(data, Key.ctrl("c"))) {
331
- this.finish({ kind: "close" });
332
- return;
333
- }
334
- if (this.keybindings.matches(data, "tui.select.cancel")) {
335
- this.finish({ kind: "back" });
336
- return;
337
- }
338
- if (matchesConfirm(data, this.keybindings)) {
339
- const value = this.options[this.cursor];
340
- if (value !== undefined) this.finish({ kind: "select", value });
341
- return;
342
- }
343
- if (this.keybindings.matches(data, "tui.select.up")) {
344
- this.cursor = Math.max(0, this.cursor - 1);
345
- this.tui.requestRender();
346
- return;
347
- }
348
- if (this.keybindings.matches(data, "tui.select.down")) {
349
- this.cursor = Math.min(Math.max(0, this.options.length - 1), this.cursor + 1);
350
- this.tui.requestRender();
351
- return;
352
- }
353
- if (this.keybindings.matches(data, "tui.select.pageUp")) {
354
- this.cursor = Math.max(0, this.cursor - 10);
355
- this.tui.requestRender();
356
- return;
357
- }
358
- if (this.keybindings.matches(data, "tui.select.pageDown")) {
359
- this.cursor = Math.min(Math.max(0, this.options.length - 1), this.cursor + 10);
360
- this.tui.requestRender();
361
- return;
362
- }
363
- }
364
-
365
- invalidate(): void {}
366
-
367
- private keepCursorVisible(height: number): void {
368
- if (height <= 0) return;
369
- if (this.cursor < this.scrollOffset) this.scrollOffset = this.cursor;
370
- if (this.cursor >= this.scrollOffset + height) {
371
- this.scrollOffset = this.cursor - height + 1;
372
- }
373
- }
374
-
375
- private finish(action: BtwMenuSelectorAction): void {
376
- if (this.finished) return;
377
- this.finished = true;
378
- this.onAction(action);
379
- }
380
- }
381
-
382
173
  export class BtwTextRangeSelector implements Component {
383
174
  private readonly lines: BtwSelectionLine[];
384
175
  private cursor: BtwTextPosition = { line: 0, column: 0 };
@@ -788,35 +579,6 @@ function splitGraphemes(text: string): string[] {
788
579
  return [...GRAPHEME_SEGMENTER.segment(text)].map(({ segment }) => segment);
789
580
  }
790
581
 
791
- function wrapPreviewLine(text: string, width: number): string[] {
792
- if (!text) return [""];
793
- const rows: string[] = [];
794
- let row = "";
795
- let rowWidth = 0;
796
- const append = (value: string, valueWidth: number) => {
797
- if (row && rowWidth + valueWidth > width) {
798
- rows.push(row);
799
- row = "";
800
- rowWidth = 0;
801
- }
802
- row += value;
803
- rowWidth += valueWidth;
804
- };
805
- for (const character of splitGraphemes(text)) {
806
- const characterWidth = visibleWidth(character);
807
- if (characterWidth <= width) {
808
- append(character, characterWidth);
809
- continue;
810
- }
811
- const codePoints = [...character]
812
- .map((value) => `\\u{${value.codePointAt(0)?.toString(16) ?? "0"}}`)
813
- .join("");
814
- for (const value of codePoints) append(value, 1);
815
- }
816
- rows.push(row);
817
- return rows;
818
- }
819
-
820
582
  function compareTextPositions(first: BtwTextPosition, second: BtwTextPosition): number {
821
583
  return first.line === second.line ? first.column - second.column : first.line - second.line;
822
584
  }