@narumitw/pi-btw 0.20.0 → 0.25.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,8 +8,9 @@ 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 <question>` command to Pi.
11
+ - Adds a `/btw` side-thread command to Pi, with an optional initial question.
12
12
  - Answers side questions in a temporary, scrollable UI.
13
+ - Supports follow-up questions in the same ephemeral side thread.
13
14
  - Uses the current session branch as context.
14
15
  - Uses Pi's current model or an independent model selected in `pi-btw.json`.
15
16
  - Inherits Pi's current thinking level or uses a fixed level from `pi-btw.json`.
@@ -36,22 +37,34 @@ pi -e ./extensions/pi-btw
36
37
 
37
38
  ## 🚀 Usage
38
39
 
40
+ Start an empty side thread or provide its first question immediately:
41
+
39
42
  ```text
43
+ /btw
40
44
  /btw <your side question>
41
45
  ```
42
46
 
43
47
  Examples:
44
48
 
45
49
  ```text
50
+ /btw
46
51
  /btw what does this TypeScript error mean?
47
52
  /btw summarize the current implementation before we continue
48
53
  /btw is this API name idiomatic?
49
54
  ```
50
55
 
51
- Long answers open in a pager-style view. Use `↑`/`↓` or `k`/`j` to scroll by line,
52
- `PgUp`/`PgDn`, `Shift+Space`/`Space`, or `Ctrl+B`/`Ctrl+F` to scroll by page,
53
- `Ctrl+U`/`Ctrl+D` to scroll by half page, and `Home`/`End` to jump. Close with
54
- `q`, `Esc`, `Enter`, or `Ctrl+C`.
56
+ Running `/btw` alone opens an empty ephemeral side thread with its editor ready. When an
57
+ initial question is provided, its answer opens above the same editor. A compact
58
+ `btw · side thread` header stays fixed above the content so the ephemeral workspace remains
59
+ recognizable while scrolling. Messages use Pi's normal user and assistant presentation without
60
+ numbered turns or role labels. Type each question and press `Enter`; no follow-up shortcut is
61
+ required.
62
+ Previous side questions and answers remain available to the model and visible for that
63
+ invocation. While a response is running, the transcript stays visible above a compact
64
+ `Answering…` status. The footer shows `PgUp`/`PgDn` only when history can scroll; press
65
+ `Ctrl+C` to cancel an in-progress answer or leave the side thread. Closing it, reloading Pi,
66
+ or switching sessions discards it without adding any of its questions or answers to the main
67
+ conversation.
55
68
 
56
69
  ## ⚙️ Model and thinking level
57
70
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.20.0",
3
+ "version": "0.25.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
package/src/btw.ts CHANGED
@@ -1,84 +1,36 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import type {
4
- Api,
5
- AssistantMessage,
6
- Context,
7
- Model,
8
- SimpleStreamOptions,
9
- UserMessage,
10
- } from "@earendil-works/pi-ai";
11
- // pi-ai 0.79 exports completeSimple from the root; 0.80 moved it to the compat subpath.
12
- type CompleteSimpleFunction = <TApi extends Api>(
13
- model: Model<TApi>,
14
- context: Context,
15
- options?: SimpleStreamOptions,
16
- ) => Promise<AssistantMessage>;
17
-
18
- function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
19
- return (
20
- typeof value === "object" &&
21
- value !== null &&
22
- typeof Reflect.get(value, "completeSimple") === "function"
23
- );
24
- }
25
-
26
- type ModuleImporter = (moduleId: string) => Promise<unknown>;
27
-
28
- export async function loadCompleteSimple(
29
- importModule: ModuleImporter = (moduleId) => import(moduleId),
30
- ): Promise<CompleteSimpleFunction> {
31
- let importError: unknown;
32
- for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
33
- try {
34
- const module = await importModule(moduleId);
35
- if (hasCompleteSimple(module)) return module.completeSimple;
36
- } catch (error: unknown) {
37
- importError = error;
38
- }
39
- }
40
-
41
- throw new Error("@earendil-works/pi-ai does not export completeSimple", {
42
- cause: importError,
43
- });
44
- }
45
-
46
- const completeSimple = await loadCompleteSimple();
3
+ import type { Api, Model } from "@earendil-works/pi-ai";
47
4
  import {
48
5
  BorderedLoader,
49
- DynamicBorder,
50
- getAgentDir,
51
- getMarkdownTheme,
52
6
  type ExtensionAPI,
53
7
  type ExtensionCommandContext,
54
- type Theme,
8
+ getAgentDir,
55
9
  } from "@earendil-works/pi-coding-agent";
56
10
  import {
57
- Key,
58
- Markdown,
59
- matchesKey,
60
- truncateToWidth,
61
- visibleWidth,
62
- type Component,
63
- type TUI,
64
- } from "@earendil-works/pi-tui";
11
+ BTW_THINKING_LEVELS,
12
+ type BtwThinkingLevel,
13
+ completeSideThreadTurn,
14
+ createSideThread,
15
+ type SideQuestionAuth,
16
+ type SideThread,
17
+ } from "./side-thread.js";
18
+ import {
19
+ BtwAnsweringView,
20
+ BtwTranscriptPager,
21
+ type TranscriptPagerAction,
22
+ } from "./transcript-pager.js";
23
+
24
+ export {
25
+ BTW_THINKING_LEVELS,
26
+ type BtwThinkingLevel,
27
+ buildUserPrompt,
28
+ completeSideQuestion,
29
+ loadCompleteSimple,
30
+ } from "./side-thread.js";
65
31
 
66
32
  const MAX_CONTEXT_CHARS = 40_000;
67
- const ANSWER_CHROME_LINES = 4;
68
- // Pi renders a spacer above the custom editor and a two-line built-in footer below it.
69
- const ANSWER_RESERVED_APP_LINES = 3;
70
33
  export const BTW_SETTINGS_FILE = "pi-btw.json";
71
- export const BTW_THINKING_LEVELS = [
72
- "off",
73
- "minimal",
74
- "low",
75
- "medium",
76
- "high",
77
- "xhigh",
78
- "max",
79
- ] as const;
80
-
81
- export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
82
34
 
83
35
  export interface BtwSettings {
84
36
  model?: string;
@@ -95,15 +47,11 @@ interface LoadBtwThinkingLevelOptions {
95
47
  warn?: (message: string) => void;
96
48
  }
97
49
 
98
- interface SideQuestionAuth {
99
- apiKey?: string;
100
- headers?: Record<string, string>;
101
- env?: Record<string, string>;
102
- }
103
-
104
50
  interface BtwModelRegistry {
105
51
  find(provider: string, modelId: string): Model<Api> | undefined;
106
- getApiKeyAndHeaders(model: Model<Api>): Promise<
52
+ getApiKeyAndHeaders(
53
+ model: Model<Api>,
54
+ ): Promise<
107
55
  | { ok: true; apiKey?: string; headers?: Record<string, string>; env?: Record<string, string> }
108
56
  | { ok: false; error: string }
109
57
  >;
@@ -116,21 +64,11 @@ interface ResolveBtwModelOptions {
116
64
  warn?: (message: string) => void;
117
65
  }
118
66
 
119
- interface ResolvedBtwModel {
67
+ export interface ResolvedBtwModel {
120
68
  model: Model<Api>;
121
69
  auth: SideQuestionAuth;
122
70
  }
123
71
 
124
- interface CompleteSideQuestionOptions {
125
- model: Model<Api>;
126
- question: string;
127
- conversationContext: string;
128
- thinkingLevel: BtwThinkingLevel;
129
- auth: SideQuestionAuth;
130
- signal?: AbortSignal;
131
- completeSimple?: CompleteSimpleFunction;
132
- }
133
-
134
72
  export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
135
73
  if (typeof value !== "object" || value === null || Array.isArray(value)) return undefined;
136
74
 
@@ -167,14 +105,19 @@ export async function resolveBtwModel({
167
105
  const fallback = currentModel
168
106
  ? `${currentModel.provider}/${currentModel.id}`
169
107
  : "the current model";
170
- const reference = parseBtwModelReference(settings.model)!;
108
+ const reference = parseBtwModelReference(settings.model);
109
+ if (!reference) {
110
+ warn?.(`pi-btw model ${settings.model} is invalid; falling back to ${fallback}.`);
111
+ return resolveBtwModel({ settings: {}, currentModel, modelRegistry, warn });
112
+ }
171
113
  const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
172
114
  if (!configuredModel) {
173
115
  warn?.(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
174
116
  } else {
175
117
  const sameAsCurrent =
176
118
  configuredModel === currentModel ||
177
- (configuredModel.provider === currentModel?.provider && configuredModel.id === currentModel.id);
119
+ (configuredModel.provider === currentModel?.provider &&
120
+ configuredModel.id === currentModel.id);
178
121
  const fallbackAction = sameAsCurrent
179
122
  ? "no distinct current model is available"
180
123
  : `falling back to ${fallback}`;
@@ -182,9 +125,7 @@ export async function resolveBtwModel({
182
125
  const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
183
126
  if (auth.ok && hasRequestAuth(auth)) return { model: configuredModel, auth };
184
127
  const reason = auth.ok ? "has no request credentials" : auth.error;
185
- warn?.(
186
- `pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`,
187
- );
128
+ warn?.(`pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`);
188
129
  } catch (error: unknown) {
189
130
  warn?.(
190
131
  `pi-btw model ${settings.model} credentials failed (${formatError(error)}); ${fallbackAction}.`,
@@ -248,38 +189,6 @@ export async function loadBtwThinkingLevel(
248
189
  return currentThinkingLevel;
249
190
  }
250
191
 
251
- export async function completeSideQuestion({
252
- model,
253
- question,
254
- conversationContext,
255
- thinkingLevel,
256
- auth,
257
- signal,
258
- completeSimple: runCompleteSimple = completeSimple,
259
- }: CompleteSideQuestionOptions): Promise<AssistantMessage> {
260
- const userMessage: UserMessage = {
261
- role: "user",
262
- content: [
263
- {
264
- type: "text",
265
- text: buildUserPrompt(question, conversationContext),
266
- },
267
- ],
268
- timestamp: Date.now(),
269
- };
270
- const streamOptions: SimpleStreamOptions = {
271
- apiKey: auth.apiKey,
272
- headers: auth.headers,
273
- env: auth.env,
274
- signal,
275
- };
276
- if (thinkingLevel !== "off") {
277
- (streamOptions as unknown as { reasoning?: BtwThinkingLevel }).reasoning = thinkingLevel;
278
- }
279
-
280
- return runCompleteSimple(model, { systemPrompt: SYSTEM_PROMPT, messages: [userMessage] }, streamOptions);
281
- }
282
-
283
192
  function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
284
193
  return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
285
194
  }
@@ -292,52 +201,17 @@ function formatError(error: unknown): string {
292
201
  return error instanceof Error ? error.message : String(error);
293
202
  }
294
203
 
295
- const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
296
-
297
- Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
298
-
299
- type MessageContentBlock = {
300
- type?: string;
301
- text?: string;
302
- name?: string;
303
- arguments?: unknown;
304
- result?: unknown;
305
- };
306
-
307
- type SessionMessage = {
308
- role?: string;
309
- content?: unknown;
310
- stopReason?: string;
311
- };
312
-
313
- type SessionEntry = {
314
- type: string;
315
- message?: SessionMessage;
316
- };
317
-
318
204
  export default function btw(pi: ExtensionAPI) {
319
205
  pi.registerCommand("btw", {
320
206
  description: "Ask a quick side question without adding it to the main conversation",
321
207
  handler: async (args, ctx) => {
322
208
  const question = args.trim();
323
- if (!question) {
324
- ctx.ui.notify("Usage: /btw <your side question>", "warning");
209
+ if (ctx.mode !== "tui") {
210
+ ctx.ui.notify("/btw requires interactive TUI mode", "error");
325
211
  return;
326
212
  }
327
213
 
328
- if (!ctx.hasUI) {
329
- ctx.ui.notify("/btw requires interactive mode", "error");
330
- return;
331
- }
332
-
333
- const settingsResult = await readBtwSettings();
334
- let settings: BtwSettings = {};
335
- if (settingsResult.kind === "loaded") {
336
- settings = settingsResult.settings;
337
- } else if (settingsResult.kind === "invalid") {
338
- ctx.ui.notify(`pi-btw settings ignored: ${settingsResult.reason}`, "warning");
339
- }
340
-
214
+ const settings = await loadSettingsForCommand(ctx);
341
215
  const resolution = await resolveBtwModelWithLoader(settings, ctx);
342
216
  if (resolution.kind === "cancelled") {
343
217
  ctx.ui.notify("Cancelled", "info");
@@ -348,18 +222,25 @@ export default function btw(pi: ExtensionAPI) {
348
222
  return;
349
223
  }
350
224
 
351
- const thinkingLevel = settings.thinkingLevel ?? pi.getThinkingLevel();
352
- const answer = await askSideQuestion(question, resolution.selected, thinkingLevel, ctx);
353
- if (answer === undefined) {
354
- ctx.ui.notify("Cancelled", "info");
355
- return;
356
- }
357
-
358
- await showAnswer(question, answer, ctx);
225
+ await runBtwThread({
226
+ initialQuestion: question || undefined,
227
+ selected: resolution.selected,
228
+ thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
229
+ ctx,
230
+ });
359
231
  },
360
232
  });
361
233
  }
362
234
 
235
+ async function loadSettingsForCommand(ctx: ExtensionCommandContext): Promise<BtwSettings> {
236
+ const settingsResult = await readBtwSettings();
237
+ if (settingsResult.kind === "loaded") return settingsResult.settings;
238
+ if (settingsResult.kind === "invalid") {
239
+ ctx.ui.notify(`pi-btw settings ignored: ${settingsResult.reason}`, "warning");
240
+ }
241
+ return {};
242
+ }
243
+
363
244
  type ModelResolutionOutcome =
364
245
  | { kind: "cancelled" }
365
246
  | { kind: "unavailable" }
@@ -371,9 +252,10 @@ async function resolveBtwModelWithLoader(
371
252
  ): Promise<ModelResolutionOutcome> {
372
253
  return ctx.ui.custom<ModelResolutionOutcome>((tui, theme, _keybindings, done) => {
373
254
  const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
374
- let cancelled = false;
255
+ let settled = false;
375
256
  loader.onAbort = () => {
376
- cancelled = true;
257
+ if (settled) return;
258
+ settled = true;
377
259
  done({ kind: "cancelled" });
378
260
  };
379
261
 
@@ -382,235 +264,146 @@ async function resolveBtwModelWithLoader(
382
264
  currentModel: ctx.model,
383
265
  modelRegistry: ctx.modelRegistry,
384
266
  warn: (message) => {
385
- if (!cancelled) ctx.ui.notify(message, "warning");
267
+ if (!settled) ctx.ui.notify(message, "warning");
386
268
  },
387
- }).then((selected) => {
388
- if (cancelled) return;
389
- done(selected ? { kind: "selected", selected } : { kind: "unavailable" });
390
- });
391
-
392
- return loader;
393
- });
394
- }
395
-
396
- async function askSideQuestion(
397
- question: string,
398
- selected: ResolvedBtwModel,
399
- thinkingLevel: BtwThinkingLevel,
400
- ctx: ExtensionCommandContext,
401
- ): Promise<string | undefined> {
402
- return ctx.ui.custom<string | undefined>((tui, theme, _keybindings, done) => {
403
- const loader = new BorderedLoader(
404
- tui,
405
- theme,
406
- `Answering /btw with ${selected.model.provider}/${selected.model.id}...`,
407
- );
408
- loader.onAbort = () => done(undefined);
409
-
410
- const ask = async () => {
411
- const conversationContext = buildConversationContext(ctx.sessionManager.getBranch());
412
- const response = await completeSideQuestion({
413
- model: selected.model,
414
- question,
415
- conversationContext,
416
- thinkingLevel,
417
- auth: selected.auth,
418
- signal: loader.signal,
419
- });
420
-
421
- if (response.stopReason === "aborted") {
422
- return undefined;
423
- }
424
-
425
- const text = response.content
426
- .filter((content): content is { type: "text"; text: string } => content.type === "text")
427
- .map((content) => content.text)
428
- .join("\n")
429
- .trim();
430
-
431
- return text || "No response received.";
432
- };
433
-
434
- ask()
435
- .then(done)
436
- .catch((error: unknown) => {
437
- const message = error instanceof Error ? error.message : String(error);
438
- done(`Error: ${message}`);
269
+ })
270
+ .then((selected) => {
271
+ if (settled) return;
272
+ settled = true;
273
+ done(selected ? { kind: "selected", selected } : { kind: "unavailable" });
274
+ })
275
+ .catch(() => {
276
+ if (settled) return;
277
+ settled = true;
278
+ done({ kind: "unavailable" });
439
279
  });
440
280
 
441
281
  return loader;
442
282
  });
443
283
  }
444
284
 
445
- async function showAnswer(question: string, answer: string, ctx: ExtensionCommandContext) {
446
- await ctx.ui.custom((tui, theme, _keybindings, done) => {
447
- return new BtwAnswerPager(tui, theme, question, answer, () => done(undefined));
448
- });
285
+ interface RunBtwThreadDependencies {
286
+ ask?: typeof askThreadQuestion;
287
+ interact?: typeof showThreadComposer;
449
288
  }
450
289
 
451
- class BtwAnswerPager implements Component {
452
- private readonly tui: TUI;
453
- private readonly theme: Theme;
454
- private readonly title: string;
455
- private readonly onClose: () => void;
456
- private readonly topBorder: DynamicBorder;
457
- private readonly bottomBorder: DynamicBorder;
458
- private readonly markdown: Markdown;
459
- private scrollOffset = 0;
460
- private lastContentLineCount = 0;
461
- private lastViewportHeight = 1;
462
-
463
- constructor(tui: TUI, theme: Theme, question: string, answer: string, onClose: () => void) {
464
- this.tui = tui;
465
- this.theme = theme;
466
- this.title = sanitizeSingleLine(`/btw ${question}`);
467
- this.onClose = onClose;
468
- const borderColor = (text: string) => this.theme.fg("warning", text);
469
- this.topBorder = new DynamicBorder(borderColor);
470
- this.bottomBorder = new DynamicBorder(borderColor);
471
- this.markdown = new Markdown(answer, 1, 1, getMarkdownTheme());
472
- }
290
+ interface RunBtwThreadOptions {
291
+ initialQuestion?: string;
292
+ selected: ResolvedBtwModel;
293
+ thinkingLevel: BtwThinkingLevel;
294
+ ctx: ExtensionCommandContext;
295
+ dependencies?: RunBtwThreadDependencies;
296
+ }
473
297
 
474
- render(width: number): string[] {
475
- const viewportHeight = this.getViewportHeight();
476
- const contentLines = this.markdown.render(width);
477
- this.lastContentLineCount = contentLines.length;
478
- this.lastViewportHeight = viewportHeight;
479
- this.clampScrollOffset();
480
-
481
- const visibleContent = contentLines.slice(
482
- this.scrollOffset,
483
- this.scrollOffset + viewportHeight,
484
- );
485
-
486
- return [
487
- ...this.topBorder.render(width),
488
- this.renderTitle(width),
489
- ...visibleContent,
490
- this.renderFooter(width),
491
- ...this.bottomBorder.render(width),
492
- ];
493
- }
298
+ export async function runBtwThread({
299
+ initialQuestion,
300
+ selected,
301
+ thinkingLevel,
302
+ ctx,
303
+ dependencies = {},
304
+ }: RunBtwThreadOptions): Promise<void> {
305
+ const ask = dependencies.ask ?? askThreadQuestion;
306
+ const interact = dependencies.interact ?? showThreadComposer;
307
+ const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
308
+ let pendingQuestion = initialQuestion;
309
+
310
+ while (true) {
311
+ if (!pendingQuestion) {
312
+ const action = await interact(thread, thread.turns.length > 0, ctx);
313
+ if (action.kind === "close") return;
314
+ pendingQuestion = action.question;
315
+ }
494
316
 
495
- handleInput(data: string): void {
496
- if (this.matchesCloseKey(data)) {
497
- this.onClose();
317
+ const result = await ask(thread, pendingQuestion, selected, thinkingLevel, ctx);
318
+ if (result.kind === "aborted") {
319
+ ctx.ui.notify("Cancelled", "info");
498
320
  return;
499
321
  }
500
-
501
- if (matchesKey(data, Key.up) || matchesKey(data, "k")) {
502
- this.scrollBy(-1);
503
- } else if (matchesKey(data, Key.down) || matchesKey(data, "j")) {
504
- this.scrollBy(1);
505
- } else if (
506
- matchesKey(data, Key.pageUp) ||
507
- matchesKey(data, Key.shift(Key.space)) ||
508
- matchesKey(data, Key.ctrl("b"))
509
- ) {
510
- this.scrollBy(-this.lastViewportHeight);
511
- } else if (
512
- matchesKey(data, Key.pageDown) ||
513
- matchesKey(data, Key.space) ||
514
- matchesKey(data, Key.ctrl("f"))
515
- ) {
516
- this.scrollBy(this.lastViewportHeight);
517
- } else if (matchesKey(data, Key.ctrl("u"))) {
518
- this.scrollBy(-this.getHalfPageHeight());
519
- } else if (matchesKey(data, Key.ctrl("d"))) {
520
- this.scrollBy(this.getHalfPageHeight());
521
- } else if (matchesKey(data, Key.home)) {
522
- this.scrollOffset = 0;
523
- } else if (matchesKey(data, Key.end)) {
524
- this.scrollOffset = this.getMaxScrollOffset();
322
+ if (result.kind === "error") {
323
+ thread.turns.push({
324
+ kind: "error",
325
+ question: pendingQuestion,
326
+ answer: result.message,
327
+ });
525
328
  }
526
- }
527
-
528
- invalidate(): void {
529
- this.topBorder.invalidate();
530
- this.bottomBorder.invalidate();
531
- this.markdown.invalidate();
532
- }
533
329
 
534
- private matchesCloseKey(data: string): boolean {
535
- return (
536
- matchesKey(data, "q") ||
537
- matchesKey(data, Key.escape) ||
538
- matchesKey(data, Key.enter) ||
539
- matchesKey(data, Key.return) ||
540
- matchesKey(data, Key.ctrl("c"))
541
- );
542
- }
543
-
544
- private renderTitle(width: number): string {
545
- return truncateToWidth(this.theme.fg("warning", this.theme.bold(this.title)), width);
546
- }
547
-
548
- private renderFooter(width: number): string {
549
- const progress = this.formatProgress();
550
- const hints = "↑↓/j/k scroll • PgUp/PgDn page • Home/End jump • q/Esc close";
551
- const progressWidth = visibleWidth(progress);
552
- const footer =
553
- progressWidth + 3 >= width
554
- ? truncateToWidth(progress, width)
555
- : `${truncateToWidth(hints, width - progressWidth - 3)} • ${progress}`;
556
- return this.theme.fg("dim", footer);
557
- }
558
-
559
- private formatProgress(): string {
560
- const total = this.lastContentLineCount;
561
- if (total === 0) return "100% 0-0/0";
562
-
563
- const maxScroll = this.getMaxScrollOffset();
564
- const percent = maxScroll === 0 ? 100 : Math.round((this.scrollOffset / maxScroll) * 100);
565
- const firstLine = this.scrollOffset + 1;
566
- const lastLine = Math.min(total, this.scrollOffset + this.lastViewportHeight);
567
-
568
- return `${percent}% ${firstLine}-${lastLine}/${total}`;
569
- }
570
-
571
- private scrollBy(delta: number): void {
572
- this.scrollOffset += delta;
573
- this.clampScrollOffset();
574
- }
575
-
576
- private clampScrollOffset(): void {
577
- this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
578
- }
579
-
580
- private getMaxScrollOffset(): number {
581
- return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
330
+ pendingQuestion = undefined;
582
331
  }
332
+ }
583
333
 
584
- private getViewportHeight(): number {
585
- return Math.max(1, this.tui.terminal.rows - ANSWER_CHROME_LINES - ANSWER_RESERVED_APP_LINES);
586
- }
334
+ async function askThreadQuestion(
335
+ thread: SideThread,
336
+ question: string,
337
+ selected: ResolvedBtwModel,
338
+ thinkingLevel: BtwThinkingLevel,
339
+ ctx: ExtensionCommandContext,
340
+ ) {
341
+ return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
342
+ (tui, theme, _keybindings, done) => {
343
+ let settled = false;
344
+ const view = new BtwAnsweringView(tui, theme, thread.turns, question, () => {
345
+ if (settled) return;
346
+ settled = true;
347
+ done({ kind: "aborted" });
348
+ });
349
+ completeSideThreadTurn({
350
+ thread,
351
+ question,
352
+ model: selected.model,
353
+ thinkingLevel,
354
+ auth: selected.auth,
355
+ signal: view.signal,
356
+ }).then((result) => {
357
+ if (settled) return;
358
+ settled = true;
359
+ view.finish();
360
+ done(result);
361
+ });
362
+ return view;
363
+ },
364
+ );
365
+ }
587
366
 
588
- private getHalfPageHeight(): number {
589
- return Math.max(1, Math.ceil(this.lastViewportHeight / 2));
590
- }
367
+ async function showThreadComposer(
368
+ thread: SideThread,
369
+ startAtBottom: boolean,
370
+ ctx: ExtensionCommandContext,
371
+ ): Promise<TranscriptPagerAction> {
372
+ return ctx.ui.custom<TranscriptPagerAction>(
373
+ (tui, theme, _keybindings, done) =>
374
+ new BtwTranscriptPager(tui, theme, thread.turns, done, { startAtBottom }),
375
+ );
591
376
  }
592
377
 
593
378
  export function sanitizeSingleLine(text: string) {
594
- return text
595
- .replace(/[\r\n\t]/g, " ")
596
- .replace(/[\u0000-\u001f\u007f-\u009f]/g, "")
379
+ return [...text.replace(/[\r\n\t]/g, " ")]
380
+ .filter((character) => {
381
+ const code = character.charCodeAt(0);
382
+ return code > 31 && (code < 127 || code > 159);
383
+ })
384
+ .join("")
597
385
  .replace(/ +/g, " ")
598
386
  .trim();
599
387
  }
600
388
 
601
- export function buildUserPrompt(question: string, conversationContext: string) {
602
- return [
603
- "Answer this side question without modifying the main conversation.",
604
- "",
605
- "<side_question>",
606
- question,
607
- "</side_question>",
608
- "",
609
- "<conversation_context>",
610
- conversationContext || "No prior conversation context was available.",
611
- "</conversation_context>",
612
- ].join("\n");
613
- }
389
+ type MessageContentBlock = {
390
+ type?: string;
391
+ text?: string;
392
+ name?: string;
393
+ arguments?: unknown;
394
+ result?: unknown;
395
+ };
396
+
397
+ type SessionMessage = {
398
+ role?: string;
399
+ content?: unknown;
400
+ stopReason?: string;
401
+ };
402
+
403
+ type SessionEntry = {
404
+ type: string;
405
+ message?: SessionMessage;
406
+ };
614
407
 
615
408
  export function buildConversationContext(entries: readonly SessionEntry[]) {
616
409
  const sections: string[] = [];
@@ -636,18 +429,12 @@ export function buildConversationContext(entries: readonly SessionEntry[]) {
636
429
  }
637
430
 
638
431
  function extractContentLines(content: unknown): string[] {
639
- if (typeof content === "string") {
640
- return [content.trim()].filter(Boolean);
641
- }
642
-
643
- if (!Array.isArray(content)) {
644
- return [];
645
- }
432
+ if (typeof content === "string") return [content.trim()].filter(Boolean);
433
+ if (!Array.isArray(content)) return [];
646
434
 
647
435
  const lines: string[] = [];
648
436
  for (const part of content) {
649
437
  if (!part || typeof part !== "object") continue;
650
-
651
438
  const block = part as MessageContentBlock;
652
439
  if (block.type === "text" && typeof block.text === "string") {
653
440
  lines.push(block.text.trim());
@@ -657,7 +444,6 @@ function extractContentLines(content: unknown): string[] {
657
444
  lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
658
445
  }
659
446
  }
660
-
661
447
  return lines.filter(Boolean);
662
448
  }
663
449
 
@@ -0,0 +1,247 @@
1
+ import type {
2
+ Api,
3
+ AssistantMessage,
4
+ Context,
5
+ Message,
6
+ Model,
7
+ SimpleStreamOptions,
8
+ UserMessage,
9
+ } from "@earendil-works/pi-ai";
10
+
11
+ export const BTW_THINKING_LEVELS = [
12
+ "off",
13
+ "minimal",
14
+ "low",
15
+ "medium",
16
+ "high",
17
+ "xhigh",
18
+ "max",
19
+ ] as const;
20
+
21
+ export type BtwThinkingLevel = (typeof BTW_THINKING_LEVELS)[number];
22
+
23
+ export interface SideQuestionAuth {
24
+ apiKey?: string;
25
+ headers?: Record<string, string>;
26
+ env?: Record<string, string>;
27
+ }
28
+
29
+ export type CompleteSimpleFunction = <TApi extends Api>(
30
+ model: Model<TApi>,
31
+ context: Context,
32
+ options?: SimpleStreamOptions,
33
+ ) => Promise<AssistantMessage>;
34
+
35
+ type ModuleImporter = (moduleId: string) => Promise<unknown>;
36
+
37
+ function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
38
+ return (
39
+ typeof value === "object" &&
40
+ value !== null &&
41
+ typeof Reflect.get(value, "completeSimple") === "function"
42
+ );
43
+ }
44
+
45
+ export async function loadCompleteSimple(
46
+ importModule: ModuleImporter = (moduleId) => import(moduleId),
47
+ ): Promise<CompleteSimpleFunction> {
48
+ let importError: unknown;
49
+ for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
50
+ try {
51
+ const module = await importModule(moduleId);
52
+ if (hasCompleteSimple(module)) return module.completeSimple;
53
+ } catch (error: unknown) {
54
+ importError = error;
55
+ }
56
+ }
57
+
58
+ throw new Error("@earendil-works/pi-ai does not export completeSimple", {
59
+ cause: importError,
60
+ });
61
+ }
62
+
63
+ const defaultCompleteSimple = await loadCompleteSimple();
64
+
65
+ export type SideThreadTurn =
66
+ | {
67
+ kind: "answered";
68
+ question: string;
69
+ answer: string;
70
+ response: AssistantMessage;
71
+ }
72
+ | {
73
+ kind: "error";
74
+ question: string;
75
+ answer: string;
76
+ };
77
+
78
+ export interface SideThread {
79
+ conversationContext: string;
80
+ turns: SideThreadTurn[];
81
+ }
82
+
83
+ export function createSideThread(conversationContext: string): SideThread {
84
+ return { conversationContext, turns: [] };
85
+ }
86
+
87
+ export function buildSideThreadMessages(thread: SideThread, question: string): Message[] {
88
+ const answeredTurns = thread.turns.filter(
89
+ (turn): turn is Extract<SideThreadTurn, { kind: "answered" }> => turn.kind === "answered",
90
+ );
91
+ const messages: Message[] = [];
92
+
93
+ if (answeredTurns.length === 0) {
94
+ messages.push(createUserMessage(buildUserPrompt(question, thread.conversationContext)));
95
+ return messages;
96
+ }
97
+
98
+ const [first, ...rest] = answeredTurns;
99
+ messages.push(
100
+ createUserMessage(buildUserPrompt(first.question, thread.conversationContext)),
101
+ first.response,
102
+ );
103
+ for (const turn of rest) {
104
+ messages.push(createUserMessage(buildFollowUpPrompt(turn.question)), turn.response);
105
+ }
106
+ messages.push(createUserMessage(buildFollowUpPrompt(question)));
107
+ return messages;
108
+ }
109
+
110
+ export interface CompleteSideThreadTurnOptions {
111
+ thread: SideThread;
112
+ model: Model<Api>;
113
+ question: string;
114
+ thinkingLevel: BtwThinkingLevel;
115
+ auth: SideQuestionAuth;
116
+ signal?: AbortSignal;
117
+ completeSimple?: CompleteSimpleFunction;
118
+ }
119
+
120
+ export type CompleteSideThreadTurnResult =
121
+ | { kind: "answered"; response: AssistantMessage; answer: string }
122
+ | { kind: "aborted" }
123
+ | { kind: "error"; message: string };
124
+
125
+ export async function completeSideThreadTurn({
126
+ thread,
127
+ model,
128
+ question,
129
+ thinkingLevel,
130
+ auth,
131
+ signal,
132
+ completeSimple = defaultCompleteSimple,
133
+ }: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
134
+ if (signal?.aborted) return { kind: "aborted" };
135
+ let response: AssistantMessage;
136
+ try {
137
+ response = await completeSimple(
138
+ model,
139
+ { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
140
+ buildStreamOptions(auth, thinkingLevel, signal),
141
+ );
142
+ } catch (error: unknown) {
143
+ if (signal?.aborted) return { kind: "aborted" };
144
+ return { kind: "error", message: formatError(error) };
145
+ }
146
+
147
+ if (signal?.aborted || response.stopReason === "aborted") return { kind: "aborted" };
148
+ if (response.stopReason === "error") {
149
+ return { kind: "error", message: response.errorMessage ?? "The side model returned an error." };
150
+ }
151
+
152
+ const answer = extractAssistantText(response) || "No response received.";
153
+ thread.turns.push({ kind: "answered", question, answer, response });
154
+ return { kind: "answered", response, answer };
155
+ }
156
+
157
+ export interface CompleteSideQuestionOptions {
158
+ model: Model<Api>;
159
+ question: string;
160
+ conversationContext: string;
161
+ thinkingLevel: BtwThinkingLevel;
162
+ auth: SideQuestionAuth;
163
+ signal?: AbortSignal;
164
+ completeSimple?: CompleteSimpleFunction;
165
+ }
166
+
167
+ export async function completeSideQuestion({
168
+ model,
169
+ question,
170
+ conversationContext,
171
+ thinkingLevel,
172
+ auth,
173
+ signal,
174
+ completeSimple = defaultCompleteSimple,
175
+ }: CompleteSideQuestionOptions): Promise<AssistantMessage> {
176
+ return completeSimple(
177
+ model,
178
+ {
179
+ systemPrompt: SYSTEM_PROMPT,
180
+ messages: [createUserMessage(buildUserPrompt(question, conversationContext))],
181
+ },
182
+ buildStreamOptions(auth, thinkingLevel, signal),
183
+ );
184
+ }
185
+
186
+ export function extractAssistantText(response: AssistantMessage): string {
187
+ return response.content
188
+ .filter((content): content is { type: "text"; text: string } => content.type === "text")
189
+ .map((content) => content.text)
190
+ .join("\n")
191
+ .trim();
192
+ }
193
+
194
+ export function buildUserPrompt(question: string, conversationContext: string): string {
195
+ return [
196
+ "Answer this side question without modifying the main conversation.",
197
+ "",
198
+ "<side_question>",
199
+ question,
200
+ "</side_question>",
201
+ "",
202
+ "<conversation_context>",
203
+ conversationContext || "No prior conversation context was available.",
204
+ "</conversation_context>",
205
+ ].join("\n");
206
+ }
207
+
208
+ export function buildFollowUpPrompt(question: string): string {
209
+ return [
210
+ "Continue the same side conversation.",
211
+ "",
212
+ "<side_question>",
213
+ question,
214
+ "</side_question>",
215
+ ].join("\n");
216
+ }
217
+
218
+ function createUserMessage(text: string): UserMessage {
219
+ return {
220
+ role: "user",
221
+ content: [{ type: "text", text }],
222
+ timestamp: Date.now(),
223
+ };
224
+ }
225
+
226
+ function buildStreamOptions(
227
+ auth: SideQuestionAuth,
228
+ thinkingLevel: BtwThinkingLevel,
229
+ signal?: AbortSignal,
230
+ ): SimpleStreamOptions {
231
+ const options: SimpleStreamOptions = {
232
+ apiKey: auth.apiKey,
233
+ headers: auth.headers,
234
+ env: auth.env,
235
+ signal,
236
+ };
237
+ if (thinkingLevel !== "off") options.reasoning = thinkingLevel;
238
+ return options;
239
+ }
240
+
241
+ function formatError(error: unknown): string {
242
+ return error instanceof Error ? error.message : String(error);
243
+ }
244
+
245
+ const SYSTEM_PROMPT = `You answer quick side questions for a coding-agent user.
246
+
247
+ Use the provided conversation context only as background. Answer the user's side question directly and concisely. Do not claim to have changed files, run tools, or affected the main task. If the context is insufficient, say what is unknown and give the best next step.`;
@@ -0,0 +1,378 @@
1
+ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
+ import {
3
+ AssistantMessageComponent,
4
+ getMarkdownTheme,
5
+ type Theme,
6
+ UserMessageComponent,
7
+ } from "@earendil-works/pi-coding-agent";
8
+ import {
9
+ type Component,
10
+ CURSOR_MARKER,
11
+ Editor,
12
+ type EditorTheme,
13
+ Key,
14
+ Loader,
15
+ Markdown,
16
+ matchesKey,
17
+ type TUI,
18
+ truncateToWidth,
19
+ visibleWidth,
20
+ } from "@earendil-works/pi-tui";
21
+ import type { SideThreadTurn } from "./side-thread.js";
22
+
23
+ const TRANSCRIPT_CHROME_LINES = 2;
24
+ const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
25
+ // Pi renders a spacer above the custom component and a two-line built-in footer below it.
26
+ const RESERVED_APP_LINES = 3;
27
+
28
+ export type TranscriptPagerAction = { kind: "submit"; question: string } | { kind: "close" };
29
+
30
+ export class BtwTranscriptPager implements Component {
31
+ private readonly transcriptComponents: Component[];
32
+ private readonly editor: Editor;
33
+ private scrollOffset = 0;
34
+ private lastContentLineCount = 0;
35
+ private lastViewportHeight = 1;
36
+ private followBottom: boolean;
37
+ private warning: string | undefined;
38
+ private finished = false;
39
+ private isFocused = false;
40
+
41
+ constructor(
42
+ private readonly tui: TUI,
43
+ private readonly theme: Theme,
44
+ turns: readonly SideThreadTurn[],
45
+ private readonly onAction: (action: TranscriptPagerAction) => void,
46
+ options: { startAtBottom?: boolean } = {},
47
+ ) {
48
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
49
+ this.followBottom = options.startAtBottom ?? false;
50
+ const editorTheme: EditorTheme = {
51
+ borderColor: (text) => this.theme.fg("accent", text),
52
+ selectList: {
53
+ selectedPrefix: (text) => this.theme.fg("accent", text),
54
+ selectedText: (text) => this.theme.fg("accent", text),
55
+ description: (text) => this.theme.fg("muted", text),
56
+ scrollInfo: (text) => this.theme.fg("dim", text),
57
+ noMatch: (text) => this.theme.fg("warning", text),
58
+ },
59
+ };
60
+ this.editor = new Editor(this.tui, editorTheme);
61
+ this.editor.onChange = () => {
62
+ this.warning = undefined;
63
+ };
64
+ this.editor.onSubmit = (text) => {
65
+ const question = text.trim();
66
+ if (!question) {
67
+ this.warning = "Question cannot be empty";
68
+ return;
69
+ }
70
+ this.finished = true;
71
+ this.onAction({ kind: "submit", question });
72
+ };
73
+ }
74
+
75
+ get focused(): boolean {
76
+ return this.isFocused;
77
+ }
78
+
79
+ set focused(value: boolean) {
80
+ this.isFocused = value;
81
+ this.editor.focused = value;
82
+ }
83
+
84
+ render(width: number): string[] {
85
+ const safeWidth = Math.max(1, width);
86
+ const editorLines = this.editor.render(safeWidth);
87
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
88
+ const viewportHeight = Math.max(
89
+ 0,
90
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES,
91
+ );
92
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
93
+ this.lastContentLineCount = contentLines.length;
94
+ this.lastViewportHeight = viewportHeight;
95
+ if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
96
+ this.clampScrollOffset();
97
+
98
+ return fitComposerLayout(
99
+ renderSideThreadHeader(safeWidth, this.theme),
100
+ contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
101
+ this.renderFooter(safeWidth),
102
+ editorLines,
103
+ availableRows,
104
+ );
105
+ }
106
+
107
+ handleInput(data: string): void {
108
+ if (this.finished) return;
109
+ if (matchesKey(data, Key.ctrl("c"))) {
110
+ this.finished = true;
111
+ this.onAction({ kind: "close" });
112
+ return;
113
+ }
114
+ if (matchesKey(data, Key.pageUp)) {
115
+ const previousOffset = this.scrollOffset;
116
+ this.scrollBy(-this.lastViewportHeight);
117
+ if (this.scrollOffset < previousOffset) this.followBottom = false;
118
+ this.tui.requestRender();
119
+ return;
120
+ }
121
+ if (matchesKey(data, Key.pageDown)) {
122
+ this.scrollBy(this.lastViewportHeight);
123
+ this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
124
+ this.tui.requestRender();
125
+ return;
126
+ }
127
+ this.editor.handleInput(data);
128
+ if (!this.finished) this.tui.requestRender();
129
+ }
130
+
131
+ invalidate(): void {
132
+ for (const component of this.transcriptComponents) component.invalidate();
133
+ this.editor.invalidate();
134
+ }
135
+
136
+ private renderFooter(width: number): string {
137
+ if (this.warning) {
138
+ const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C exit`;
139
+ return truncateToWidth(this.theme.fg("warning", warning), width);
140
+ }
141
+ const scrollable = this.getMaxScrollOffset() > 0;
142
+ const fullBase = "btw • Enter send • Ctrl+C exit";
143
+ const compactBase = "btw • Enter • Ctrl+C";
144
+ let hints = visibleWidth(fullBase) <= width ? fullBase : compactBase;
145
+ if (scrollable) {
146
+ const history = ` • ${this.scrollOffset > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
147
+ const compactHistory = " • PgUp/PgDn";
148
+ if (visibleWidth(`${hints}${history}`) <= width) {
149
+ hints += history;
150
+ } else if (visibleWidth(`${hints}${compactHistory}`) <= width) {
151
+ hints += compactHistory;
152
+ } else if (visibleWidth(`${compactBase}${compactHistory}`) <= width) {
153
+ hints = `${compactBase}${compactHistory}`;
154
+ }
155
+ }
156
+ return truncateToWidth(this.theme.fg("muted", hints), width);
157
+ }
158
+
159
+ private scrollBy(delta: number): void {
160
+ this.scrollOffset += delta;
161
+ this.clampScrollOffset();
162
+ }
163
+
164
+ private clampScrollOffset(): void {
165
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
166
+ }
167
+
168
+ private getMaxScrollOffset(): number {
169
+ return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
170
+ }
171
+ }
172
+
173
+ export class BtwAnsweringView implements Component {
174
+ private readonly transcriptComponents: Component[];
175
+ private readonly loader: Loader;
176
+ private readonly controller = new AbortController();
177
+ private scrollOffset = 0;
178
+ private lastContentLineCount = 0;
179
+ private lastViewportHeight = 1;
180
+ private followBottom = true;
181
+ private finished = false;
182
+
183
+ constructor(
184
+ private readonly tui: TUI,
185
+ private readonly theme: Theme,
186
+ turns: readonly SideThreadTurn[],
187
+ pendingQuestion: string,
188
+ private readonly onCancel: () => void,
189
+ ) {
190
+ this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
191
+ this.loader = new Loader(
192
+ this.tui,
193
+ (text) => this.theme.fg("accent", text),
194
+ (text) => this.theme.fg("muted", text),
195
+ "Answering…",
196
+ );
197
+ }
198
+
199
+ get signal(): AbortSignal {
200
+ return this.controller.signal;
201
+ }
202
+
203
+ render(width: number): string[] {
204
+ const safeWidth = Math.max(1, width);
205
+ const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
206
+ const viewportHeight = Math.max(0, availableRows - TRANSCRIPT_CHROME_LINES);
207
+ const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
208
+ this.lastContentLineCount = contentLines.length;
209
+ this.lastViewportHeight = viewportHeight;
210
+ if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
211
+ this.clampScrollOffset();
212
+ const cancelHint = safeWidth < 28 ? "Ctrl+C" : "Ctrl+C cancel";
213
+ const loaderWidth = Math.max(1, safeWidth - visibleWidth(cancelHint) - 3);
214
+ const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
215
+ const lines = [
216
+ renderSideThreadHeader(safeWidth, this.theme),
217
+ ...contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
218
+ truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", cancelHint)}`, safeWidth),
219
+ ];
220
+ return fitWithFixedHeader(lines, availableRows);
221
+ }
222
+
223
+ handleInput(data: string): void {
224
+ if (this.finished) return;
225
+ if (matchesKey(data, Key.ctrl("c"))) {
226
+ this.finished = true;
227
+ this.loader.stop();
228
+ this.controller.abort();
229
+ this.onCancel();
230
+ return;
231
+ }
232
+ if (matchesKey(data, Key.pageUp)) {
233
+ const previousOffset = this.scrollOffset;
234
+ this.scrollBy(-this.lastViewportHeight);
235
+ if (this.scrollOffset < previousOffset) this.followBottom = false;
236
+ this.tui.requestRender();
237
+ } else if (matchesKey(data, Key.pageDown)) {
238
+ this.scrollBy(this.lastViewportHeight);
239
+ this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
240
+ this.tui.requestRender();
241
+ }
242
+ }
243
+
244
+ invalidate(): void {
245
+ for (const component of this.transcriptComponents) component.invalidate();
246
+ this.loader.invalidate();
247
+ }
248
+
249
+ finish(): void {
250
+ this.finished = true;
251
+ this.loader.stop();
252
+ }
253
+
254
+ dispose(): void {
255
+ this.finish();
256
+ this.controller.abort();
257
+ }
258
+
259
+ private scrollBy(delta: number): void {
260
+ this.scrollOffset += delta;
261
+ this.clampScrollOffset();
262
+ }
263
+
264
+ private clampScrollOffset(): void {
265
+ this.scrollOffset = Math.max(0, Math.min(this.scrollOffset, this.getMaxScrollOffset()));
266
+ }
267
+
268
+ private getMaxScrollOffset(): number {
269
+ return Math.max(0, this.lastContentLineCount - this.lastViewportHeight);
270
+ }
271
+ }
272
+
273
+ export function formatSideTranscript(turns: readonly SideThreadTurn[]): string {
274
+ return turns
275
+ .map((turn) => {
276
+ const question = escapeTerminalControls(turn.question);
277
+ const rawAnswer = escapeTerminalControls(turn.answer);
278
+ const answer = turn.kind === "error" ? `Error: ${rawAnswer}` : rawAnswer;
279
+ return `${question}\n\n${answer}`;
280
+ })
281
+ .join("\n\n");
282
+ }
283
+
284
+ function buildTranscriptComponents(
285
+ turns: readonly SideThreadTurn[],
286
+ theme: Theme,
287
+ pendingQuestion?: string,
288
+ ): Component[] {
289
+ const components = turns.flatMap((turn): Component[] => {
290
+ const question = new UserMessageComponent(
291
+ escapeTerminalControls(turn.question),
292
+ getMarkdownTheme(),
293
+ 1,
294
+ );
295
+ if (turn.kind === "error") {
296
+ const error = new Markdown(
297
+ `Error: ${escapeTerminalControls(turn.answer)}`,
298
+ 1,
299
+ 1,
300
+ getMarkdownTheme(),
301
+ { color: (text) => theme.fg("error", text) },
302
+ );
303
+ return [question, error];
304
+ }
305
+ const response: AssistantMessage = {
306
+ ...turn.response,
307
+ content: [{ type: "text", text: escapeTerminalControls(turn.answer) }],
308
+ stopReason: "stop",
309
+ errorMessage: undefined,
310
+ };
311
+ return [question, new AssistantMessageComponent(response, true, getMarkdownTheme(), "", 1)];
312
+ });
313
+ if (pendingQuestion) {
314
+ components.push(
315
+ new UserMessageComponent(escapeTerminalControls(pendingQuestion), getMarkdownTheme(), 1),
316
+ );
317
+ }
318
+ return components;
319
+ }
320
+
321
+ function renderTranscriptLines(components: readonly Component[], width: number): string[] {
322
+ return components
323
+ .flatMap((component) => component.render(width))
324
+ .map(stripShellIntegrationMarkers);
325
+ }
326
+
327
+ function renderSideThreadHeader(width: number, theme: Theme): string {
328
+ const title = truncateToWidth("─ btw · side thread ", width);
329
+ const ruleWidth = Math.max(0, width - visibleWidth(title));
330
+ return theme.fg("muted", `${title}${"─".repeat(ruleWidth)}`);
331
+ }
332
+
333
+ function fitComposerLayout(
334
+ header: string,
335
+ contentLines: string[],
336
+ footer: string,
337
+ editorLines: string[],
338
+ availableRows: number,
339
+ ): string[] {
340
+ const lines = [header, ...contentLines, footer, ...editorLines];
341
+ if (lines.length <= availableRows) return lines;
342
+ if (availableRows <= 1) return [header];
343
+ const editorBudget = Math.max(0, availableRows - 2);
344
+ return [header, footer, ...fitEditorLines(editorLines, editorBudget)];
345
+ }
346
+
347
+ function fitEditorLines(editorLines: string[], budget: number): string[] {
348
+ if (budget <= 0) return [];
349
+ if (editorLines.length <= budget) return editorLines;
350
+ const cursorIndex = editorLines.findIndex((line) => line.includes(CURSOR_MARKER));
351
+ if (cursorIndex < 0) return editorLines.slice(-budget);
352
+ const start = Math.min(cursorIndex, editorLines.length - budget);
353
+ return editorLines.slice(start, start + budget);
354
+ }
355
+
356
+ function fitWithFixedHeader(lines: string[], availableRows: number): string[] {
357
+ if (lines.length <= availableRows) return lines;
358
+ if (availableRows <= 1) return lines.slice(0, 1);
359
+ return [lines[0] ?? "", ...lines.slice(lines.length - availableRows + 1)];
360
+ }
361
+
362
+ function stripShellIntegrationMarkers(line: string): string {
363
+ return OSC133_MARKERS.reduce((result, marker) => result.replaceAll(marker, ""), line);
364
+ }
365
+
366
+ function escapeTerminalControls(text: string): string {
367
+ return [...text]
368
+ .map((character) => {
369
+ if (character === "\n") return character;
370
+ if (character === "\t") return " ";
371
+ const code = character.charCodeAt(0);
372
+ if (code <= 31 || (code >= 127 && code <= 159)) {
373
+ return `\\x${code.toString(16).padStart(2, "0")}`;
374
+ }
375
+ return character;
376
+ })
377
+ .join("");
378
+ }