@pi-unipi/btw 3.0.0-alpha.33 → 3.0.0-alpha.35

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.
Files changed (2) hide show
  1. package/extensions/btw.ts +83 -2
  2. package/package.json +2 -2
package/extensions/btw.ts CHANGED
@@ -154,10 +154,18 @@ export interface BtwPage {
154
154
  error?: string;
155
155
  aborted?: boolean;
156
156
  done: boolean;
157
+ /** Token usage for the one assistant response (bridge forwards it in `btw_end`). */
158
+ usage?: { input: number; output: number; totalTokens: number };
157
159
  }
158
160
 
159
161
  const pages: BtwPage[] = [];
160
162
 
163
+ /** Read-only snapshot of recent pages (question, answer, error) for a
164
+ * `btw_list{}` reply — never mutate the result. */
165
+ export function listPages(): Array<{ question: string; answer: string; error?: string }> {
166
+ return pages.map((p) => ({ question: p.question, answer: p.answer, error: p.error }));
167
+ }
168
+
161
169
  /** ↑/↓ paging reducer — clamp the target index into [0, count-1]. */
162
170
  export function pageIndexAfter(current: number, delta: number, count: number): number {
163
171
  if (count <= 0) return 0;
@@ -210,12 +218,18 @@ export function formatToolLine(
210
218
  return `${mark} ${label}${detail}`;
211
219
  }
212
220
 
221
+ /** Delta kinds forwarded to `onDelta` (the bridge's UI-free API streams these
222
+ * to the phone as `btw_delta`; the TUI panel ignores it — it re-renders
223
+ * `page.answer`/`toolLines` from `onUpdate` instead). */
224
+ export type BtwDeltaKind = "text" | "thinking" | "tool";
225
+
213
226
  function runQuestion(
214
227
  ctx: ExtensionCommandContext,
215
228
  question: string,
216
229
  page: BtwPage,
217
230
  thinkingLevel: AiThinkingLevel,
218
231
  onUpdate: () => void,
232
+ onDelta?: (kind: BtwDeltaKind, text: string) => void,
219
233
  ): BtwRun {
220
234
  let session: AgentSession | null = null;
221
235
  let unsubscribe: (() => void) | null = null;
@@ -253,8 +267,10 @@ function runQuestion(
253
267
  const e = event as { toolCallId: string; toolName: string; args?: Record<string, unknown> };
254
268
  pendingStarts.set(e.toolCallId, { args: e.args });
255
269
  lineIndex.set(e.toolCallId, page.toolLines.length);
256
- page.toolLines.push(formatToolLine(e.toolName, e.args, ctx.cwd, "running"));
270
+ const line = formatToolLine(e.toolName, e.args, ctx.cwd, "running");
271
+ page.toolLines.push(line);
257
272
  onUpdate();
273
+ onDelta?.("tool", line);
258
274
  } else if (event.type === "tool_execution_end") {
259
275
  const e = event as { toolCallId: string; toolName: string; isError: boolean };
260
276
  const start = pendingStarts.get(e.toolCallId);
@@ -264,6 +280,11 @@ function runQuestion(
264
280
  else page.toolLines.push(line);
265
281
  pendingStarts.delete(e.toolCallId);
266
282
  onUpdate();
283
+ onDelta?.("tool", line);
284
+ } else if (event.type === "message_update") {
285
+ const e = event as { assistantMessageEvent?: { type?: string; delta?: string } };
286
+ if (e.assistantMessageEvent?.type === "text_delta" && e.assistantMessageEvent.delta) onDelta?.("text", e.assistantMessageEvent.delta);
287
+ else if (e.assistantMessageEvent?.type === "thinking_delta" && e.assistantMessageEvent.delta) onDelta?.("thinking", e.assistantMessageEvent.delta);
267
288
  }
268
289
  });
269
290
 
@@ -279,6 +300,7 @@ function runQuestion(
279
300
  page.error = response.errorMessage || "request failed";
280
301
  } else {
281
302
  page.answer = extractText(response.content, "text") || "(no text)";
303
+ if (response.usage) page.usage = { input: response.usage.input, output: response.usage.output, totalTokens: response.usage.totalTokens };
282
304
  }
283
305
  } catch (err) {
284
306
  page.error = err instanceof Error ? err.message : String(err);
@@ -455,7 +477,64 @@ class BtwPanel implements Component {
455
477
  dispose(): void {}
456
478
  }
457
479
 
458
- // ─── Extension ──────────────────────────────────────────────────────────────
480
+ // ─── UI-free API (the app bridge, no TUI needed) ───────────────────
481
+
482
+ /** One streamed update from `BtwApi.ask` (mirrors the bridge's `btw_delta`/`btw_end`). */
483
+ export type BtwEvent =
484
+ | { type: "delta"; kind: BtwDeltaKind; text: string }
485
+ | { type: "end"; answer: string; error?: string; usage?: { input: number; output: number; totalTokens: number } };
486
+
487
+ export interface BtwApi {
488
+ /** Starts a question, streaming `onEvent` until it ends (also resolved by `finished`).
489
+ * Only one question runs at a time (shared with the TUI panel): a second call while
490
+ * one is in flight ends immediately with an error. */
491
+ ask(cctx: ExtensionCommandContext, question: string, onEvent: (event: BtwEvent) => void): { id: string; finished: Promise<void> };
492
+ /** Recent pages (question, answer, error) for this pi session — shared with the TUI panel. */
493
+ list(): Array<{ question: string; answer: string; error?: string }>;
494
+ }
495
+
496
+ const BTW_API_KEY = Symbol.for("unipi.btw.api");
497
+
498
+ /** Publishes the UI-free API on globalThis so the app bridge (a sibling
499
+ * extension, no shared module instance guaranteed) can ask a side question
500
+ * without a TUI: `(globalThis as any)[Symbol.for("unipi.btw.api")]`. */
501
+ function publishUiFreeApi(pi: ExtensionAPI, getActiveRun: () => BtwRun | null, setActiveRun: (run: BtwRun | null) => void): void {
502
+ let idSeq = 0;
503
+ const api: BtwApi = {
504
+ ask(cctx, question, onEvent) {
505
+ const id = `api-${process.pid}-${++idSeq}`;
506
+ if (getActiveRun()) {
507
+ const finished = Promise.resolve().then(() => onEvent({ type: "end", answer: "", error: "btw is already answering another question" }));
508
+ return { id, finished };
509
+ }
510
+ const page: BtwPage = { question, toolLines: [], answer: "", done: false };
511
+ pages.push(page);
512
+ const run = runQuestion(
513
+ cctx,
514
+ question,
515
+ page,
516
+ pi.getThinkingLevel() as AiThinkingLevel,
517
+ () => {},
518
+ (kind, text) => onEvent({ type: "delta", kind, text }),
519
+ );
520
+ setActiveRun(run);
521
+ const finished = run.finished.then(() => {
522
+ setActiveRun(null);
523
+ onEvent({ type: "end", answer: page.answer, error: page.error, usage: page.usage });
524
+ });
525
+ return { id, finished };
526
+ },
527
+ list: () => listPages(),
528
+ };
529
+ (globalThis as unknown as Record<symbol, unknown>)[BTW_API_KEY] = api;
530
+ }
531
+
532
+ /** Reads the published API, or undefined when @pi-unipi/btw is not loaded. */
533
+ export function getBtwApi(): BtwApi | undefined {
534
+ return (globalThis as unknown as Record<symbol, unknown>)[BTW_API_KEY] as BtwApi | undefined;
535
+ }
536
+
537
+ // ─── Extension ─────────────────────────────────────────────────────────────────────
459
538
 
460
539
  export default function btwExtension(pi: ExtensionAPI): void {
461
540
  let panelRef: BtwPanel | null = null;
@@ -520,6 +599,8 @@ export default function btwExtension(pi: ExtensionAPI): void {
520
599
  notify(ctx, page.error ? `btw failed: ${page.error}` : `btw: ${page.answer}`, page.error ? "error" : "info");
521
600
  }
522
601
 
602
+ publishUiFreeApi(pi, () => activeRun, (run) => (activeRun = run));
603
+
523
604
  pi.on("session_start", () => {
524
605
  abortActiveRun();
525
606
  clearPages();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/btw",
3
- "version": "3.0.0-alpha.33",
3
+ "version": "3.0.0-alpha.35",
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.33"
40
+ "@pi-unipi/core": "3.0.0-alpha.35"
41
41
  },
42
42
  "pi": {
43
43
  "extensions": [