@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 +45 -24
- package/package.json +9 -1
- package/src/bring-to-main.ts +0 -238
- package/src/btw.ts +273 -142
- package/src/menu.ts +256 -0
- package/src/settings.ts +237 -0
- package/src/text.ts +10 -0
- package/src/transcript-pager.ts +88 -11
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`
|
|
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
|
-
-
|
|
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
|
-
|
|
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
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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.
|
|
65
|
-
`
|
|
66
|
-
|
|
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**.
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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.
|
|
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
|
}
|
package/src/bring-to-main.ts
CHANGED
|
@@ -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
|
}
|