@pi-unipi/btw 3.0.0-alpha.4 → 3.0.0-alpha.41

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
@@ -1,23 +1,69 @@
1
- # @pi-unipi/btw
1
+ # BTW
2
2
 
3
- `/unipi:btw [question]` opens an inline panel over the input area for a quick side question — the main agent keeps running and never sees any of it.
3
+ Ask a side question while the main agent works, without adding it to the main conversation.
4
4
 
5
- Each question runs in a fresh read-only pi session seeded from the main session's current branch (including in-progress tool calls). There is **no memory between btw questions** — earlier Q&As are not fed back. Page history lives only for the pi session and is cleared on new/resume/tree navigation. Nothing is written to the main session.
5
+ `@pi-unipi/btw` · part of [UniPi](../../README.md)
6
6
 
7
- ## Panel
7
+ ## What it does
8
8
 
9
+ - Opens a panel in place of the input box. The main agent continues to run.
10
+ - Answers each question in a new, read-only session. This session starts with a copy of the main conversation, with tool calls that are in progress.
11
+ - Gives the side session four tools only: `read`, `grep`, `find` and `ls`. It cannot run commands or change files.
12
+ - Uses the model and the thinking level of the main session.
13
+ - Writes nothing to the main session. The main agent never sees the question or the answer.
14
+ - Keeps no memory between questions. Each question starts again from the main conversation.
15
+
16
+ ## Quick start
17
+
18
+ UniPi installs this package:
19
+
20
+ ```bash
21
+ pi install npm:@pi-unipi/unipi
9
22
  ```
10
- ❭ <question> your question
11
- … / ✓ tool lines read-only tools only (read, grep, find, ls)
12
- Thinking.. while waiting
13
- answer rendered as markdown
14
- ❭ Ask a /btw… input (locked while streaming)
23
+
24
+ To install this package alone:
25
+
26
+ ```bash
27
+ pi install npm:@pi-unipi/btw
15
28
  ```
16
29
 
17
- - `Enter` ask · `↑`/`↓` (empty input) page earlier Q&As · `PgUp`/`PgDn` scroll long answers · `Ctrl+C` cancel the answer · `Esc` back to the chat (a streaming answer keeps running into history; reopen with `/unipi:btw`).
30
+ Then ask a question:
31
+
32
+ ```text
33
+ /unipi:btw what does the retry loop in client.ts do?
34
+ ```
35
+
36
+ ## Commands
37
+
38
+ | Command | What it does |
39
+ |---|---|
40
+ | `/unipi:btw [question]` | Opens the panel. If you give a question, the panel starts to answer it. |
41
+
42
+ In print mode (no UI), `/unipi:btw <question>` shows the answer as a notification. A question is necessary in this mode.
43
+
44
+ ## Panel keys
45
+
46
+ | Key | What it does |
47
+ |---|---|
48
+ | `Enter` | Asks the question in the input. It does nothing while an answer streams. |
49
+ | `↑` / `↓` | Shows an earlier or later question and answer. Works when the input is empty. |
50
+ | `PgUp` / `PgDn` | Scrolls a long answer. |
51
+ | `Ctrl+C` | Stops the answer that streams. If no answer streams, it closes the panel. |
52
+ | `Esc` | Closes the panel. An answer that streams continues and goes into the history. |
53
+
54
+ Run `/unipi:btw` again to open the history. Only one answer can stream at a time.
55
+
56
+ ## How it works
57
+
58
+ The panel shows each question, a line for each tool call, and the answer as markdown. The history stays in memory for the Pi session only. BTW clears it when a session starts or resumes, and when you move in the session tree.
59
+
60
+ If you want a change to your files, ask in the main conversation.
61
+
62
+ Older sessions can contain `btw-note` messages from an earlier BTW version. BTW still shows these messages and hides them from the model.
18
63
 
19
- Read-only: `read`, `grep`, `find`, `ls` only — no commands, no file changes. If you want something changed, ask in the main conversation.
64
+ BTW comes from [pi-btw](https://github.com/Neuron-Mr-White/pi-btw) by Dan Bachelder.
20
65
 
21
- Without a UI (print mode), `/unipi:btw <question>` prints the answer via notify.
66
+ ## See also
22
67
 
23
- Based on [pi-btw](https://github.com/Neuron-Mr-White/pi-btw) by Dan Bachelder.
68
+ - [Ask User](../ask-user/README.md)
69
+ - [Commands reference](../../docs/reference/commands.md)
package/extensions/btw.ts CHANGED
@@ -147,16 +147,64 @@ export function buildSeedEntries(ctx: {
147
147
  // ─── Page history (session-memory only, never persisted) ───────────────────
148
148
 
149
149
  export interface BtwPage {
150
+ /** Stable id (`btw-<pid>-<n>`), shared by the TUI panel and the app bridge
151
+ * (the bridge's `btw_delta`/`btw_end` `id` and `btw_list` page ids). */
152
+ id: string;
150
153
  question: string;
151
154
  /** One line per tool call: "… Reading <p>" while running → "✓ <desc>" */
152
155
  toolLines: string[];
153
156
  answer: string;
157
+ /** Text streamed so far (the final `answer` lands at the end). */
158
+ partial: string;
159
+ /** Model reasoning streamed so far (kept for `btw_list` replays). */
160
+ thinking: string;
154
161
  error?: string;
155
162
  aborted?: boolean;
156
163
  done: boolean;
164
+ /** Token usage for the one assistant response (bridge forwards it in `btw_end`). */
165
+ usage?: { input: number; output: number; totalTokens: number };
157
166
  }
158
167
 
159
168
  const pages: BtwPage[] = [];
169
+ let pageSeq = 0;
170
+
171
+ /** A fresh page with a process-unique id. */
172
+ export function newPage(question: string): BtwPage {
173
+ return { id: `btw-${process.pid}-${++pageSeq}`, question, toolLines: [], answer: "", partial: "", thinking: "", done: false };
174
+ }
175
+
176
+ /** One page as the app bridge's `btw_list` sends it. `question`/`answer`/
177
+ * `error` keep their original meaning (older apps read only those); `id`,
178
+ * `done`/`running` and `toolLines` let the app reconcile its own pages.
179
+ * A running page's `answer` is the text streamed so far. */
180
+ export interface BtwListPage {
181
+ id: string;
182
+ question: string;
183
+ answer: string;
184
+ error?: string;
185
+ done: boolean;
186
+ running: boolean;
187
+ toolLines?: string[];
188
+ /** Running pages only: the tail of the model's reasoning so far. */
189
+ thinking?: string;
190
+ }
191
+
192
+ /** Tail kept for a running page's `thinking` in `btw_list`. */
193
+ const LIST_THINKING_TAIL = 2000;
194
+
195
+ /** Read-only snapshot of recent pages for a `btw_list{}` reply — never mutate the result. */
196
+ export function listPages(): BtwListPage[] {
197
+ return pages.map((p) => ({
198
+ id: p.id,
199
+ question: p.question,
200
+ answer: p.done ? p.answer : p.partial,
201
+ ...(p.error ? { error: p.error } : {}),
202
+ done: p.done,
203
+ running: !p.done,
204
+ ...(p.toolLines.length ? { toolLines: [...p.toolLines] } : {}),
205
+ ...(!p.done && p.thinking ? { thinking: p.thinking.slice(-LIST_THINKING_TAIL) } : {}),
206
+ }));
207
+ }
160
208
 
161
209
  /** ↑/↓ paging reducer — clamp the target index into [0, count-1]. */
162
210
  export function pageIndexAfter(current: number, delta: number, count: number): number {
@@ -210,12 +258,20 @@ export function formatToolLine(
210
258
  return `${mark} ${label}${detail}`;
211
259
  }
212
260
 
261
+ /** Delta kinds forwarded to `onDelta` (the bridge's UI-free API streams these
262
+ * to the phone as `btw_delta`; the TUI panel ignores it — it re-renders
263
+ * `page.answer`/`toolLines` from `onUpdate` instead). */
264
+ export type BtwDeltaKind = "text" | "thinking" | "tool";
265
+
213
266
  function runQuestion(
214
267
  ctx: ExtensionCommandContext,
215
268
  question: string,
216
269
  page: BtwPage,
217
270
  thinkingLevel: AiThinkingLevel,
218
271
  onUpdate: () => void,
272
+ /** `index` (tool deltas only): the `page.toolLines` slot this line fills —
273
+ * a tool's "done" line replaces its own "running" line. */
274
+ onDelta?: (kind: BtwDeltaKind, text: string, index?: number) => void,
219
275
  ): BtwRun {
220
276
  let session: AgentSession | null = null;
221
277
  let unsubscribe: (() => void) | null = null;
@@ -253,8 +309,10 @@ function runQuestion(
253
309
  const e = event as { toolCallId: string; toolName: string; args?: Record<string, unknown> };
254
310
  pendingStarts.set(e.toolCallId, { args: e.args });
255
311
  lineIndex.set(e.toolCallId, page.toolLines.length);
256
- page.toolLines.push(formatToolLine(e.toolName, e.args, ctx.cwd, "running"));
312
+ const line = formatToolLine(e.toolName, e.args, ctx.cwd, "running");
313
+ page.toolLines.push(line);
257
314
  onUpdate();
315
+ onDelta?.("tool", line, page.toolLines.length - 1);
258
316
  } else if (event.type === "tool_execution_end") {
259
317
  const e = event as { toolCallId: string; toolName: string; isError: boolean };
260
318
  const start = pendingStarts.get(e.toolCallId);
@@ -264,6 +322,17 @@ function runQuestion(
264
322
  else page.toolLines.push(line);
265
323
  pendingStarts.delete(e.toolCallId);
266
324
  onUpdate();
325
+ onDelta?.("tool", line, idx ?? page.toolLines.length - 1);
326
+ } else if (event.type === "message_update") {
327
+ const e = event as { assistantMessageEvent?: { type?: string; delta?: string } };
328
+ if (e.assistantMessageEvent?.type === "text_delta" && e.assistantMessageEvent.delta) {
329
+ page.partial += e.assistantMessageEvent.delta;
330
+ onDelta?.("text", e.assistantMessageEvent.delta);
331
+ }
332
+ else if (e.assistantMessageEvent?.type === "thinking_delta" && e.assistantMessageEvent.delta) {
333
+ page.thinking += e.assistantMessageEvent.delta;
334
+ onDelta?.("thinking", e.assistantMessageEvent.delta);
335
+ }
267
336
  }
268
337
  });
269
338
 
@@ -279,6 +348,7 @@ function runQuestion(
279
348
  page.error = response.errorMessage || "request failed";
280
349
  } else {
281
350
  page.answer = extractText(response.content, "text") || "(no text)";
351
+ if (response.usage) page.usage = { input: response.usage.input, output: response.usage.output, totalTokens: response.usage.totalTokens };
282
352
  }
283
353
  } catch (err) {
284
354
  page.error = err instanceof Error ? err.message : String(err);
@@ -455,7 +525,65 @@ class BtwPanel implements Component {
455
525
  dispose(): void {}
456
526
  }
457
527
 
458
- // ─── Extension ──────────────────────────────────────────────────────────────
528
+ // ─── UI-free API (the app bridge, no TUI needed) ───────────────────
529
+
530
+ /** One streamed update from `BtwApi.ask` (mirrors the bridge's `btw_delta`/`btw_end`). */
531
+ export type BtwEvent =
532
+ | { type: "delta"; kind: BtwDeltaKind; text: string; index?: number }
533
+ | { type: "end"; answer: string; error?: string; usage?: { input: number; output: number; totalTokens: number } };
534
+
535
+ export interface BtwApi {
536
+ /** Starts a question, streaming `onEvent` until it ends (also resolved by `finished`).
537
+ * Only one question runs at a time (shared with the TUI panel): a second call while
538
+ * one is in flight ends immediately with an error. */
539
+ ask(cctx: ExtensionCommandContext, question: string, onEvent: (event: BtwEvent) => void): { id: string; finished: Promise<void> };
540
+ /** Recent pages for this pi session — shared with the TUI panel. */
541
+ list(): BtwListPage[];
542
+ }
543
+
544
+ const BTW_API_KEY = Symbol.for("unipi.btw.api");
545
+
546
+ /** Publishes the UI-free API on globalThis so the app bridge (a sibling
547
+ * extension, no shared module instance guaranteed) can ask a side question
548
+ * without a TUI: `(globalThis as any)[Symbol.for("unipi.btw.api")]`. */
549
+ function publishUiFreeApi(pi: ExtensionAPI, getActiveRun: () => BtwRun | null, setActiveRun: (run: BtwRun | null) => void): void {
550
+ let idSeq = 0;
551
+ const api: BtwApi = {
552
+ ask(cctx, question, onEvent) {
553
+ if (getActiveRun()) {
554
+ const id = `btw-${process.pid}-busy-${++idSeq}`;
555
+ const finished = Promise.resolve().then(() => onEvent({ type: "end", answer: "", error: "btw is already answering another question" }));
556
+ return { id, finished };
557
+ }
558
+ const page = newPage(question);
559
+ const id = page.id;
560
+ pages.push(page);
561
+ const run = runQuestion(
562
+ cctx,
563
+ question,
564
+ page,
565
+ pi.getThinkingLevel() as AiThinkingLevel,
566
+ () => {},
567
+ (kind, text, index) => onEvent(index === undefined ? { type: "delta", kind, text } : { type: "delta", kind, text, index }),
568
+ );
569
+ setActiveRun(run);
570
+ const finished = run.finished.then(() => {
571
+ setActiveRun(null);
572
+ onEvent({ type: "end", answer: page.answer, error: page.error, usage: page.usage });
573
+ });
574
+ return { id, finished };
575
+ },
576
+ list: () => listPages(),
577
+ };
578
+ (globalThis as unknown as Record<symbol, unknown>)[BTW_API_KEY] = api;
579
+ }
580
+
581
+ /** Reads the published API, or undefined when @pi-unipi/btw is not loaded. */
582
+ export function getBtwApi(): BtwApi | undefined {
583
+ return (globalThis as unknown as Record<symbol, unknown>)[BTW_API_KEY] as BtwApi | undefined;
584
+ }
585
+
586
+ // ─── Extension ─────────────────────────────────────────────────────────────────────
459
587
 
460
588
  export default function btwExtension(pi: ExtensionAPI): void {
461
589
  let panelRef: BtwPanel | null = null;
@@ -466,7 +594,7 @@ export default function btwExtension(pi: ExtensionAPI): void {
466
594
 
467
595
  function startRun(ctx: ExtensionCommandContext, question: string): void {
468
596
  if (activeRun) return; // never two answers at once
469
- const page: BtwPage = { question, toolLines: [], answer: "", done: false };
597
+ const page = newPage(question);
470
598
  const index = pages.length;
471
599
  pages.push(page);
472
600
  const run = runQuestion(ctx, question, page, pi.getThinkingLevel() as AiThinkingLevel, () => panelRef?.tick());
@@ -510,7 +638,7 @@ export default function btwExtension(pi: ExtensionAPI): void {
510
638
  }
511
639
 
512
640
  async function headlessAsk(ctx: ExtensionCommandContext, question: string): Promise<void> {
513
- const page: BtwPage = { question, toolLines: [], answer: "", done: false };
641
+ const page = newPage(question);
514
642
  pages.push(page);
515
643
  notify(ctx, `btw: ${question}`, "info");
516
644
  const run = runQuestion(ctx, question, page, pi.getThinkingLevel() as AiThinkingLevel, () => {});
@@ -520,6 +648,8 @@ export default function btwExtension(pi: ExtensionAPI): void {
520
648
  notify(ctx, page.error ? `btw failed: ${page.error}` : `btw: ${page.answer}`, page.error ? "error" : "info");
521
649
  }
522
650
 
651
+ publishUiFreeApi(pi, () => activeRun, (run) => (activeRun = run));
652
+
523
653
  pi.on("session_start", () => {
524
654
  abortActiveRun();
525
655
  clearPages();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "3.0.0-alpha.4",
3
+ "version": "3.0.0-alpha.41",
4
4
  "description": "A pi extension for parallel side conversations with /unipi:btw — part of the Unipi suite",
5
5
  "type": "module",
6
6
  "main": "extensions/btw.ts",
@@ -37,7 +37,7 @@
37
37
  "@earendil-works/pi-tui": "^0.87.1"
38
38
  },
39
39
  "dependencies": {
40
- "@pi-unipi/core": "3.0.0-alpha.4"
40
+ "@pi-unipi/core": "3.0.0-alpha.41"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [