@narumitw/pi-btw 0.49.6 → 0.50.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,10 +8,13 @@ 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` menu for starting a side thread or changing pi-btw settings.
12
- - Keeps `/btw <question>` as a direct fast path.
13
- - Answers side questions in a temporary, scrollable UI.
11
+ - Adds a `/btw` menu for starting or resuming an in-memory side thread or changing pi-btw settings.
12
+ - Keeps `/btw <question>` as a direct fast path that always starts a fresh side thread.
13
+ - Answers side questions in a dedicated, scrollable full-screen UI.
14
+ - Keeps mouse-drag copying stable while the main agent continues running in the background.
14
15
  - Supports follow-up questions in the same ephemeral side thread.
16
+ - Resumes any non-empty side thread retained by the current Pi session, listed by its first question.
17
+ - Queues Pi-style `Steering` questions while an answer is running and processes them one at a time.
15
18
  - Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
16
19
  - Uses the current session branch as context.
17
20
  - Uses Pi's current model or an independent model selected in `pi-btw.json`.
@@ -55,11 +58,20 @@ Examples:
55
58
  /btw is this API name idiomatic?
56
59
  ```
57
60
 
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
61
+ Running `/btw` alone opens a menu with **Start side thread** selected first. When the current Pi
62
+ session has non-empty side threads in memory, **Resume side thread** opens a bounded choice list;
63
+ **Settings** changes the starting thinking level and whether shortcut changes are remembered.
64
+ Each Resume row keeps the first question as its fixed title, shows its question count, and the list
65
+ is ordered by the newest recorded answer or visible error. Opening and closing a thread without a
66
+ new result does not reorder it. `/btw <question>` bypasses this menu and always starts a fresh side
67
+ thread. Its answer opens above the side-thread editor. The side thread uses a dedicated full-screen
68
+ terminal view.
69
+ The main agent continues running in the background, but its screen rendering stays suspended until
70
+ `/btw` closes, so new main-thread output cannot move a mouse selection inside the side thread.
71
+ Drag the primary mouse button across side-thread text to select and copy it through Pi's terminal
72
+ clipboard support. Returning from `/btw` redraws the main view with everything produced while it
73
+ was hidden. A compact `btw · side thread` header stays fixed above the content so the ephemeral
74
+ workspace remains recognizable while scrolling. Messages use Pi's normal
63
75
  user and assistant presentation without numbered turns or role labels. Type each question and press
64
76
  `Enter`; no follow-up shortcut is required.
65
77
  Previous side questions and answers remain available to the model and visible for that
@@ -68,10 +80,17 @@ invocation. The side-thread header shows its current thinking level. Press Pi's
68
80
  supported by the side-thread model; every later question uses the displayed level until it is
69
81
  changed again. By default, each shortcut change is also written to `pi-btw.json` for the next
70
82
  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.
83
+ current side thread. Neither path changes the main session's thinking level.
84
+ While a response is running, the transcript and composer remain visible above an `Answering…`
85
+ status.
86
+ Type another question and press `Enter` to queue it as `Steering`; queued questions are shown in
87
+ submission order and answered one at a time after the active response completes.
88
+ A queued question uses the side thread's thinking level when its turn begins.
89
+ A failed active response is shown in the transcript and does not discard later steering questions.
90
+ The footer shows `PgUp`/`PgDn` only when history can scroll; press `Ctrl+C` to cancel the active
91
+ response and discard the ephemeral side-thread draft and steering queue. Completed questions,
92
+ answers, and visible errors remain available through Resume until the current extension instance
93
+ ends. Steering remains entirely inside pi-btw and never appends to the main conversation or editor.
75
94
 
76
95
  After at least one successful answer, press `Ctrl+R` to bring selected context to the main
77
96
  editor. The scope menu shows the size of the latest question and answer and the entire side
@@ -95,9 +114,11 @@ into Pi's main editor. It never sends the draft automatically. If the main edito
95
114
  draft, append is the recommended default. Replace is labeled as destructive and requires a second
96
115
  confirmation; Cancel returns to the side thread without changing either draft. Concurrent editor
97
116
  updates made while these menus are open are preserved. A success message reports whether context
98
- was loaded, appended, or replaced and its approximate size. Without an explicit bring-to-main
99
- action, closing `/btw`, reloading Pi, or switching sessions still discards the side thread without
100
- adding it to the main conversation.
117
+ was loaded, appended, or replaced and its approximate size.
118
+ Without an explicit bring-to-main action, closing `/btw` never adds the side thread to the main
119
+ conversation. Non-empty threads remain only in memory for Resume within the current Pi session.
120
+ `/new`, Pi `/resume`, `/reload`, extension replacement, and process restart discard every retained
121
+ thread. Unsent drafts, steering queues, interrupted answers, and model credentials are never retained.
101
122
 
102
123
  ## ⚙️ Model and thinking level
103
124
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.49.6",
3
+ "version": "0.50.0",
4
4
  "description": "Pi extension that adds a /btw side-question command.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -32,9 +32,9 @@
32
32
  },
33
33
  "devDependencies": {
34
34
  "@biomejs/biome": "2.5.7",
35
- "@earendil-works/pi-ai": "0.84.0",
36
- "@earendil-works/pi-coding-agent": "0.84.0",
37
- "@earendil-works/pi-tui": "0.84.0",
35
+ "@earendil-works/pi-ai": "0.84.1",
36
+ "@earendil-works/pi-coding-agent": "0.84.1",
37
+ "@earendil-works/pi-tui": "0.84.1",
38
38
  "typescript": "7.0.2"
39
39
  },
40
40
  "repository": {
package/src/btw.ts CHANGED
@@ -25,8 +25,10 @@ import {
25
25
  getAnsweredTurns,
26
26
  summarizeBringToMain,
27
27
  } from "./bring-to-main.js";
28
+ import { type RunBtwFullscreen, runBtwFullscreen } from "./fullscreen-ui.js";
28
29
  import {
29
30
  type BtwCommandMenuResult,
31
+ type BtwResumeThreadSummary,
30
32
  runBtwMenuPreservingEditor,
31
33
  showBtwCommandMenu,
32
34
  } from "./menu.js";
@@ -106,6 +108,15 @@ export interface ResolvedBtwModel {
106
108
  auth: SideQuestionAuth;
107
109
  }
108
110
 
111
+ export interface BtwThreadState {
112
+ id: string;
113
+ title?: string;
114
+ thread: SideThread;
115
+ thinkingLevel: BtwThinkingLevel;
116
+ createdAt: number;
117
+ updatedAt: number;
118
+ }
119
+
109
120
  export async function resolveBtwModel({
110
121
  settings,
111
122
  currentModel,
@@ -209,10 +220,12 @@ export interface BtwExtensionDependencies {
209
220
  showCommandMenu?: (
210
221
  pi: ExtensionAPI,
211
222
  ctx: ExtensionCommandContext,
223
+ resumeThreads: readonly BtwResumeThreadSummary[],
212
224
  ) => Promise<BtwCommandMenuResult>;
213
225
  loadSettings?: typeof loadSettingsForCommand;
214
226
  resolveModel?: typeof resolveBtwModelWithLoader;
215
227
  runThread?: typeof runBtwThread;
228
+ runFullscreen?: RunBtwFullscreen;
216
229
  }
217
230
 
218
231
  export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
@@ -220,6 +233,22 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
220
233
  const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
221
234
  const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
222
235
  const runThread = dependencies.runThread ?? runBtwThread;
236
+ const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
237
+ // Pi creates a fresh extension instance after session replacement or reload.
238
+ const resumableThreads = new Map<string, BtwThreadState>();
239
+ let nextThreadNumber = 1;
240
+ const listResumeThreads = (): BtwResumeThreadSummary[] =>
241
+ [...resumableThreads.values()]
242
+ .reverse()
243
+ .filter((state) => state.thread.turns.length > 0 && state.title)
244
+ .sort(
245
+ (first, second) => second.updatedAt - first.updatedAt || second.createdAt - first.createdAt,
246
+ )
247
+ .map((state) => ({
248
+ id: state.id,
249
+ title: state.title ?? "Untitled side thread",
250
+ questionCount: state.thread.turns.length,
251
+ }));
223
252
  pi.registerCommand("btw", {
224
253
  description: "Ask a quick side question without adding it to the main conversation",
225
254
  handler: async (args, ctx) => {
@@ -228,7 +257,12 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
228
257
  ctx.ui.notify("/btw requires interactive TUI mode", "error");
229
258
  return;
230
259
  }
231
- if (!question && (await showCommandMenu(pi, ctx)) !== "start") return;
260
+
261
+ let menuResult: BtwCommandMenuResult = "start";
262
+ if (!question) {
263
+ menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
264
+ if (menuResult === "closed") return;
265
+ }
232
266
 
233
267
  const settings = await loadSettings(ctx);
234
268
  const resolution = await resolveModel(settings, ctx);
@@ -241,13 +275,46 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
241
275
  return;
242
276
  }
243
277
 
244
- await runThread({
245
- initialQuestion: question || undefined,
246
- selected: resolution.selected,
247
- thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
248
- rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
249
- ctx,
250
- });
278
+ let state =
279
+ typeof menuResult === "object" ? resumableThreads.get(menuResult.threadId) : undefined;
280
+ if (typeof menuResult === "object" && !state) {
281
+ notifySafely(ctx, "The selected /btw side thread is no longer available", "warning");
282
+ return;
283
+ }
284
+ const startingTurnCount = state?.thread.turns.length ?? 0;
285
+
286
+ try {
287
+ await runFullscreen(ctx, (fullscreenCtx) => {
288
+ if (!state) {
289
+ const createdAt = Date.now();
290
+ state = {
291
+ id: `btw-${nextThreadNumber}`,
292
+ thread: createSideThread(
293
+ buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
294
+ ),
295
+ thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
296
+ createdAt,
297
+ updatedAt: createdAt,
298
+ };
299
+ nextThreadNumber += 1;
300
+ }
301
+ return runThread({
302
+ initialQuestion: question || undefined,
303
+ selected: resolution.selected,
304
+ thinkingLevel: state.thinkingLevel,
305
+ rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
306
+ state,
307
+ ctx: fullscreenCtx,
308
+ });
309
+ });
310
+ } finally {
311
+ if (state?.title && state.thread.turns.length > 0) {
312
+ if (state.thread.turns.length > startingTurnCount) {
313
+ resumableThreads.delete(state.id);
314
+ }
315
+ resumableThreads.set(state.id, state);
316
+ }
317
+ }
251
318
  },
252
319
  });
253
320
  }
@@ -255,6 +322,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
255
322
  async function showCommandMenuForBtw(
256
323
  pi: ExtensionAPI,
257
324
  ctx: ExtensionCommandContext,
325
+ resumeThreads: readonly BtwResumeThreadSummary[],
258
326
  ): Promise<BtwCommandMenuResult> {
259
327
  const currentModel = ctx.model;
260
328
  const availableModels = ctx.modelRegistry.getAll();
@@ -271,6 +339,7 @@ async function showCommandMenuForBtw(
271
339
  return showBtwCommandMenu(ctx, {
272
340
  currentThinkingLevel,
273
341
  availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
342
+ resumeThreads,
274
343
  });
275
344
  }
276
345
 
@@ -330,12 +399,19 @@ interface RunBtwThreadDependencies {
330
399
  chooseBringToMain?: typeof chooseBringToMain;
331
400
  deliverBringToMain?: typeof loadBringToMainDraft;
332
401
  persistThinkingLevel?: (level: BtwThinkingLevel) => Promise<unknown>;
402
+ now?: () => number;
333
403
  }
334
404
 
335
405
  export type BtwThreadResult = { kind: "closed" };
336
406
 
337
407
  type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
338
408
 
409
+ interface BtwThreadSteeringControl {
410
+ questions: readonly string[];
411
+ submit: (question: string) => void;
412
+ thinking: BtwThreadThinkingControl;
413
+ }
414
+
339
415
  type BtwBringToMainChoice =
340
416
  | BtwThreadResult
341
417
  | {
@@ -354,6 +430,7 @@ interface RunBtwThreadOptions {
354
430
  thinkingLevel: BtwThinkingLevel;
355
431
  rememberThinkingLevelChanges?: boolean;
356
432
  settingsPath?: string;
433
+ state?: BtwThreadState;
357
434
  ctx: ExtensionCommandContext;
358
435
  dependencies?: RunBtwThreadDependencies;
359
436
  }
@@ -364,6 +441,7 @@ export async function runBtwThread({
364
441
  thinkingLevel,
365
442
  rememberThinkingLevelChanges = false,
366
443
  settingsPath,
444
+ state,
367
445
  ctx,
368
446
  dependencies = {},
369
447
  }: RunBtwThreadOptions): Promise<BtwThreadResult> {
@@ -374,44 +452,52 @@ export async function runBtwThread({
374
452
  const persistThinkingLevel =
375
453
  dependencies.persistThinkingLevel ??
376
454
  ((level: BtwThinkingLevel) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
377
- const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
455
+ const now = dependencies.now ?? Date.now;
456
+ const thread =
457
+ state?.thread ?? createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
378
458
  const thinkingLevels = getSupportedThinkingLevels(selected.model);
379
459
  const pendingWrites = new Set<Promise<void>>();
380
- let activeThinkingLevel = clampThinkingLevel(selected.model, thinkingLevel);
460
+ const steeringQuestions: string[] = [];
461
+ let activeThinkingLevel = clampThinkingLevel(
462
+ selected.model,
463
+ state?.thinkingLevel ?? thinkingLevel,
464
+ );
465
+ if (state) state.thinkingLevel = activeThinkingLevel;
381
466
  let pendingQuestion = initialQuestion;
382
467
  let composerDraft: string | undefined;
468
+ const createThinkingControl = (): BtwThreadThinkingControl => ({
469
+ level: activeThinkingLevel,
470
+ levels: thinkingLevels,
471
+ onChange: (level) => {
472
+ if (!thinkingLevels.includes(level)) return;
473
+ activeThinkingLevel = level;
474
+ if (state) state.thinkingLevel = level;
475
+ if (!rememberThinkingLevelChanges) return;
476
+ let write!: Promise<void>;
477
+ write = Promise.resolve()
478
+ .then(() => persistThinkingLevel(level))
479
+ .then(() => undefined)
480
+ .catch((error: unknown) => {
481
+ notifySafely(
482
+ ctx,
483
+ `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
484
+ "warning",
485
+ );
486
+ })
487
+ .finally(() => pendingWrites.delete(write));
488
+ pendingWrites.add(write);
489
+ },
490
+ });
383
491
 
384
492
  try {
385
493
  while (true) {
386
494
  if (!pendingQuestion) {
387
- const thinking: BtwThreadThinkingControl = {
388
- level: activeThinkingLevel,
389
- levels: thinkingLevels,
390
- onChange: (level) => {
391
- if (!thinkingLevels.includes(level)) return;
392
- activeThinkingLevel = level;
393
- if (!rememberThinkingLevelChanges) return;
394
- let write!: Promise<void>;
395
- write = Promise.resolve()
396
- .then(() => persistThinkingLevel(level))
397
- .then(() => undefined)
398
- .catch((error: unknown) => {
399
- notifySafely(
400
- ctx,
401
- `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
402
- "warning",
403
- );
404
- })
405
- .finally(() => pendingWrites.delete(write));
406
- pendingWrites.add(write);
407
- },
408
- };
409
495
  const action = await interact(
410
496
  thread,
411
497
  thread.turns.length > 0,
412
498
  ctx,
413
499
  composerDraft,
414
- thinking,
500
+ createThinkingControl(),
415
501
  );
416
502
  if (action.kind === "close") return { kind: "closed" };
417
503
  if (action.kind === "bringToMain") {
@@ -430,7 +516,11 @@ export async function runBtwThread({
430
516
  pendingQuestion = action.question;
431
517
  }
432
518
 
433
- const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx);
519
+ const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx, {
520
+ questions: steeringQuestions,
521
+ submit: (question) => steeringQuestions.push(question),
522
+ thinking: createThinkingControl(),
523
+ });
434
524
  if (result.kind === "aborted") {
435
525
  notifySafely(ctx, "Cancelled", "info");
436
526
  return { kind: "closed" };
@@ -442,8 +532,12 @@ export async function runBtwThread({
442
532
  answer: result.message,
443
533
  });
444
534
  }
535
+ if (state) {
536
+ state.title ||= sanitizeSingleLine(pendingQuestion) || "Untitled side thread";
537
+ state.updatedAt = now();
538
+ }
445
539
 
446
- pendingQuestion = undefined;
540
+ pendingQuestion = steeringQuestions.shift();
447
541
  }
448
542
  } finally {
449
543
  await Promise.allSettled([...pendingWrites]);
@@ -766,9 +860,10 @@ async function askThreadQuestion(
766
860
  selected: ResolvedBtwModel,
767
861
  thinkingLevel: BtwThinkingLevel,
768
862
  ctx: ExtensionCommandContext,
863
+ steering: BtwThreadSteeringControl,
769
864
  ) {
770
865
  return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
771
- (tui, theme, _keybindings, done) => {
866
+ (tui, theme, keybindings, done) => {
772
867
  let settled = false;
773
868
  const view = new BtwAnsweringView(
774
869
  tui,
@@ -781,6 +876,13 @@ async function askThreadQuestion(
781
876
  done({ kind: "aborted" });
782
877
  },
783
878
  thinkingLevel,
879
+ {
880
+ steering: {
881
+ questions: steering.questions,
882
+ onSubmit: steering.submit,
883
+ thinking: { ...steering.thinking, keybindings },
884
+ },
885
+ },
784
886
  );
785
887
  completeSideThreadTurn({
786
888
  thread,
@@ -0,0 +1,339 @@
1
+ import { spawn } from "node:child_process";
2
+ import type {
3
+ ExtensionCommandContext,
4
+ KeybindingsManager,
5
+ Theme,
6
+ } from "@earendil-works/pi-coding-agent";
7
+ import {
8
+ type Component,
9
+ type OverlayHandle,
10
+ type TUI,
11
+ TuiAltScreen,
12
+ truncateToWidth,
13
+ } from "@earendil-works/pi-tui";
14
+ import { sanitizeSingleLine } from "./text.js";
15
+
16
+ type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
17
+ type BtwCustomFactory<T> = (
18
+ tui: TUI,
19
+ theme: Theme,
20
+ keybindings: KeybindingsManager,
21
+ done: (result: T) => void,
22
+ ) => (Component & { dispose?(): void }) | Promise<Component & { dispose?(): void }>;
23
+
24
+ type BtwFullscreenTui = TUI & { flash?: (message: string, durationMs?: number) => void };
25
+
26
+ export type BtwFullscreenTuiFactory = (parent: TUI) => BtwFullscreenTui;
27
+
28
+ export interface BtwFullscreenDependencies {
29
+ createTui?: BtwFullscreenTuiFactory;
30
+ openUrl?: (url: string) => void;
31
+ }
32
+
33
+ export type RunBtwFullscreen = <T>(
34
+ ctx: ExtensionCommandContext,
35
+ run: (ctx: ExtensionCommandContext) => Promise<T>,
36
+ ) => Promise<T>;
37
+
38
+ type FullscreenOutcome<T> = { kind: "completed"; value: T } | { kind: "failed"; error: unknown };
39
+
40
+ class FullscreenUiDisposedError extends Error {
41
+ constructor() {
42
+ super("The dedicated pi-btw UI was disposed.");
43
+ this.name = "FullscreenUiDisposedError";
44
+ }
45
+ }
46
+
47
+ export async function runBtwFullscreen<T>(
48
+ ctx: ExtensionCommandContext,
49
+ run: (ctx: ExtensionCommandContext) => Promise<T>,
50
+ dependencies: BtwFullscreenDependencies = {},
51
+ ): Promise<T> {
52
+ const createTui =
53
+ dependencies.createTui ??
54
+ ((parent: TUI) => createBtwFullscreenTui(parent, dependencies.openUrl ?? openUrlInBrowser));
55
+ let liveEditorText = ctx.ui.getEditorText();
56
+ let restoreEditor = false;
57
+ const outcome = await ctx.ui.custom<FullscreenOutcome<T>>(
58
+ (parent, theme, keybindings, done) =>
59
+ new BtwFullscreenHost(
60
+ parent,
61
+ theme,
62
+ keybindings,
63
+ ctx,
64
+ run,
65
+ (value) => {
66
+ try {
67
+ liveEditorText = ctx.ui.getEditorText();
68
+ restoreEditor = true;
69
+ } catch {
70
+ // A replaced session owns a different editor and must not receive stale text.
71
+ }
72
+ done(value);
73
+ },
74
+ createTui,
75
+ ),
76
+ );
77
+ if (restoreEditor) {
78
+ try {
79
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
80
+ } catch {
81
+ // A replaced session owns a different editor and must not receive stale restoration.
82
+ }
83
+ }
84
+ if (outcome.kind === "failed") throw outcome.error;
85
+ return outcome.value;
86
+ }
87
+
88
+ function createBtwFullscreenTui(parent: TUI, openUrl: (url: string) => void): BtwFullscreenTui {
89
+ return new TuiAltScreen(parent.terminal, parent.getShowHardwareCursor(), undefined, {
90
+ mouse: true,
91
+ openUrl,
92
+ });
93
+ }
94
+
95
+ // Pi does not export its browser opener, so mirror its shell-free launcher for this isolated TUI.
96
+ function openUrlInBrowser(target: string): void {
97
+ const [command, args] =
98
+ process.platform === "darwin"
99
+ ? ["open", [target]]
100
+ : process.platform === "win32"
101
+ ? ["rundll32", ["url.dll,FileProtocolHandler", target]]
102
+ : ["xdg-open", [target]];
103
+ spawn(command, args, { stdio: "ignore", detached: true })
104
+ .on("error", () => {})
105
+ .unref();
106
+ }
107
+
108
+ class BtwFullscreenHost<T> implements Component {
109
+ private fullscreen: BtwFullscreenTui | undefined;
110
+ private cancelActiveCustom: (() => void) | undefined;
111
+ private started = false;
112
+ private disposed = false;
113
+ private finished = false;
114
+
115
+ constructor(
116
+ private readonly parent: TUI,
117
+ private readonly theme: Theme,
118
+ private readonly keybindings: KeybindingsManager,
119
+ private readonly ctx: ExtensionCommandContext,
120
+ private readonly run: (ctx: ExtensionCommandContext) => Promise<T>,
121
+ private readonly done: (outcome: FullscreenOutcome<T>) => void,
122
+ private readonly createTui: BtwFullscreenTuiFactory,
123
+ ) {
124
+ queueMicrotask(() => void this.start());
125
+ }
126
+
127
+ render(width: number): string[] {
128
+ return [truncateToWidth(this.theme.fg("muted", "Opening btw side thread…"), width)];
129
+ }
130
+
131
+ invalidate(): void {}
132
+
133
+ dispose(): void {
134
+ if (this.disposed || this.finished) return;
135
+ this.disposed = true;
136
+ this.cancelActiveCustom?.();
137
+ }
138
+
139
+ private async start(): Promise<void> {
140
+ if (this.started || this.finished) return;
141
+ this.started = true;
142
+ let outcome: FullscreenOutcome<T>;
143
+ let parentStopped = false;
144
+ let fullscreenCreated = false;
145
+ try {
146
+ if (this.disposed) throw new FullscreenUiDisposedError();
147
+ this.parent.stop({ preserveScreen: true });
148
+ parentStopped = true;
149
+ if (this.disposed) throw new FullscreenUiDisposedError();
150
+ this.fullscreen = this.createTui(this.parent);
151
+ fullscreenCreated = true;
152
+ this.fullscreen.start();
153
+ outcome = { kind: "completed", value: await this.run(this.createContext()) };
154
+ } catch (error) {
155
+ outcome = { kind: "failed", error };
156
+ }
157
+
158
+ let cleanupError: unknown;
159
+ try {
160
+ this.cancelActiveCustom?.();
161
+ } catch (error) {
162
+ cleanupError = error;
163
+ }
164
+ if (fullscreenCreated) {
165
+ try {
166
+ this.fullscreen?.stop({ preserveScreen: true });
167
+ } catch (error) {
168
+ cleanupError ??= error;
169
+ }
170
+ }
171
+ if (parentStopped) {
172
+ try {
173
+ this.parent.start();
174
+ this.parent.renderNow(false);
175
+ } catch (error) {
176
+ cleanupError ??= error;
177
+ }
178
+ }
179
+ if (cleanupError !== undefined) outcome = { kind: "failed", error: cleanupError };
180
+ this.finished = true;
181
+ this.done(outcome);
182
+ }
183
+
184
+ private createContext(): ExtensionCommandContext {
185
+ const ui = new Proxy(this.ctx.ui, {
186
+ get: (target, property) => {
187
+ if (property === "custom") {
188
+ return <Value>(factory: BtwCustomFactory<Value>, options?: BtwCustomOptions) =>
189
+ this.showCustom(factory, options);
190
+ }
191
+ if (property === "notify") {
192
+ return (
193
+ message: string,
194
+ level?: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
195
+ ) => {
196
+ target.notify(message, level);
197
+ const display = sanitizeSingleLine(message);
198
+ if (display) this.fullscreen?.flash?.(display);
199
+ };
200
+ }
201
+ const value = Reflect.get(target, property, target) as unknown;
202
+ return typeof value === "function" ? value.bind(target) : value;
203
+ },
204
+ });
205
+ return new Proxy(this.ctx, {
206
+ get: (target, property) => (property === "ui" ? ui : Reflect.get(target, property, target)),
207
+ });
208
+ }
209
+
210
+ private showCustom<Value>(
211
+ factory: BtwCustomFactory<Value>,
212
+ options?: BtwCustomOptions,
213
+ ): Promise<Value> {
214
+ const fullscreen = this.fullscreen;
215
+ if (!fullscreen || this.disposed || this.finished) {
216
+ return Promise.reject(new FullscreenUiDisposedError());
217
+ }
218
+ if (this.cancelActiveCustom) {
219
+ return Promise.reject(new Error("pi-btw attempted to open overlapping custom UI."));
220
+ }
221
+
222
+ return new Promise<Value>((resolve, reject) => {
223
+ let component: (Component & { dispose?(): void }) | undefined;
224
+ let overlay: OverlayHandle | undefined;
225
+ let mounted = false;
226
+ let factorySettled = false;
227
+ let closed = false;
228
+ let promiseSettled = false;
229
+ let componentDisposed = false;
230
+ let pendingValue: Value | undefined;
231
+ let hasPendingValue = false;
232
+
233
+ const disposeComponent = () => {
234
+ if (!component || componentDisposed) return;
235
+ componentDisposed = true;
236
+ try {
237
+ component.dispose?.();
238
+ } catch {
239
+ // Cleanup must continue so terminal ownership is restored.
240
+ }
241
+ };
242
+ const unmount = () => {
243
+ let cleanupError: unknown;
244
+ try {
245
+ if (overlay) overlay.hide();
246
+ else if (mounted && component) fullscreen.removeChild(component);
247
+ } catch (error) {
248
+ cleanupError = error;
249
+ }
250
+ if (overlay || mounted) {
251
+ try {
252
+ fullscreen.setFocus(null);
253
+ fullscreen.requestRender();
254
+ } catch (error) {
255
+ cleanupError ??= error;
256
+ }
257
+ }
258
+ disposeComponent();
259
+ if (cleanupError !== undefined) throw cleanupError;
260
+ };
261
+ const complete = () => {
262
+ if (promiseSettled || !hasPendingValue) return;
263
+ promiseSettled = true;
264
+ this.cancelActiveCustom = undefined;
265
+ if (!factorySettled) {
266
+ resolve(pendingValue as Value);
267
+ return;
268
+ }
269
+ try {
270
+ unmount();
271
+ resolve(pendingValue as Value);
272
+ } catch (error) {
273
+ reject(error);
274
+ }
275
+ };
276
+ const close = (value: Value) => {
277
+ if (closed || promiseSettled) return;
278
+ closed = true;
279
+ pendingValue = value;
280
+ hasPendingValue = true;
281
+ complete();
282
+ };
283
+ const fail = (error: unknown) => {
284
+ if (promiseSettled) return;
285
+ closed = true;
286
+ promiseSettled = true;
287
+ this.cancelActiveCustom = undefined;
288
+ try {
289
+ unmount();
290
+ reject(error);
291
+ } catch (cleanupError) {
292
+ reject(cleanupError);
293
+ }
294
+ };
295
+ this.cancelActiveCustom = () => {
296
+ if (promiseSettled) return;
297
+ disposeComponent();
298
+ if (!promiseSettled) fail(new FullscreenUiDisposedError());
299
+ };
300
+
301
+ let created: ReturnType<BtwCustomFactory<Value>>;
302
+ try {
303
+ created = factory(fullscreen, this.theme, this.keybindings, close);
304
+ } catch (error) {
305
+ factorySettled = true;
306
+ fail(error);
307
+ return;
308
+ }
309
+ Promise.resolve(created)
310
+ .then((value) => {
311
+ component = value;
312
+ factorySettled = true;
313
+ if (promiseSettled) {
314
+ disposeComponent();
315
+ return;
316
+ }
317
+ if (closed) {
318
+ complete();
319
+ return;
320
+ }
321
+ if (options?.overlay) {
322
+ const overlayOptions =
323
+ typeof options.overlayOptions === "function"
324
+ ? options.overlayOptions()
325
+ : options.overlayOptions;
326
+ overlay = fullscreen.showOverlay(component, overlayOptions);
327
+ options.onHandle?.(overlay);
328
+ } else {
329
+ fullscreen.clear();
330
+ fullscreen.addChild(component);
331
+ mounted = true;
332
+ fullscreen.setFocus(component);
333
+ fullscreen.requestRender();
334
+ }
335
+ })
336
+ .catch(fail);
337
+ });
338
+ }
339
+ }
package/src/menu.ts CHANGED
@@ -22,9 +22,16 @@ interface BtwMenuState {
22
22
  reason?: string;
23
23
  }
24
24
 
25
+ export interface BtwResumeThreadSummary {
26
+ id: string;
27
+ title: string;
28
+ questionCount: number;
29
+ }
30
+
25
31
  export interface ShowBtwCommandMenuOptions {
26
32
  currentThinkingLevel: BtwThinkingLevel;
27
33
  availableThinkingLevels: readonly BtwThinkingLevel[];
34
+ resumeThreads?: readonly BtwResumeThreadSummary[];
28
35
  settingsPath?: string;
29
36
  readSettings?: typeof readBtwSettings;
30
37
  updateSettings?: (
@@ -33,10 +40,10 @@ export interface ShowBtwCommandMenuOptions {
33
40
  ) => Promise<BtwSettings>;
34
41
  }
35
42
 
36
- export type BtwCommandMenuResult = "start" | "closed";
43
+ export type BtwCommandMenuResult = "start" | "closed" | { kind: "resume"; threadId: string };
37
44
 
38
- type BtwMenuScreen = "main" | "settings" | "invalid";
39
- type BtwMenuAction = "start" | "set-thinking" | "set-remember";
45
+ type BtwMenuScreen = "main" | "resume" | "settings" | "invalid";
46
+ type BtwMenuAction = "start" | "resume" | "set-thinking" | "set-remember";
40
47
  type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
41
48
 
42
49
  type BtwCustomFactory<T> = (
@@ -61,7 +68,9 @@ export async function showBtwCommandMenu(
61
68
  ? [...options.availableThinkingLevels]
62
69
  : (["off"] satisfies BtwThinkingLevel[]);
63
70
  const displaySettingsPath = sanitizeSingleLine(settingsPath);
71
+ const resumeThreads = options.resumeThreads ?? [];
64
72
  let startSelected = false;
73
+ let resumedThreadId: string | undefined;
65
74
 
66
75
  const loadState = async (): Promise<BtwMenuState> => {
67
76
  const loaded = await readSettings(settingsPath);
@@ -89,6 +98,16 @@ export async function showBtwCommandMenu(
89
98
  description: "Open an empty side thread",
90
99
  action: "start",
91
100
  },
101
+ ...(resumeThreads.length > 0
102
+ ? [
103
+ {
104
+ id: "resume" as const,
105
+ label: "Resume side thread",
106
+ description: "Continue an in-memory side thread",
107
+ to: "resume" as const,
108
+ },
109
+ ]
110
+ : []),
92
111
  {
93
112
  id: "settings",
94
113
  label: "Settings",
@@ -98,6 +117,18 @@ export async function showBtwCommandMenu(
98
117
  ],
99
118
  hint: "close",
100
119
  }),
120
+ resume: () => ({
121
+ kind: "choice",
122
+ title: "Resume BTW side thread",
123
+ items: resumeThreads.map((thread) => ({
124
+ id: thread.id,
125
+ label: thread.title,
126
+ description: `${thread.questionCount} ${thread.questionCount === 1 ? "question" : "questions"}`,
127
+ })),
128
+ action: "resume",
129
+ viewportSize: 10,
130
+ hint: "back",
131
+ }),
101
132
  settings: ({ state }) => ({
102
133
  kind: "settings",
103
134
  title: "Pi BTW Settings",
@@ -136,6 +167,13 @@ export async function showBtwCommandMenu(
136
167
  startSelected = true;
137
168
  return { kind: "close" };
138
169
  },
170
+ resume: async ({ itemId }: { itemId: string }) => {
171
+ if (!resumeThreads.some((thread) => thread.id === itemId)) {
172
+ return { kind: "rejected" } as const;
173
+ }
174
+ resumedThreadId = itemId;
175
+ return { kind: "close" } as const;
176
+ },
139
177
  "set-thinking": async ({ value, signal }) => {
140
178
  if (!value || !levels.includes(value as BtwThinkingLevel)) return { kind: "rejected" };
141
179
  try {
@@ -172,9 +210,9 @@ export async function showBtwCommandMenu(
172
210
  const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
173
211
  runMenu(menuContext, menu, { getState: loadState }),
174
212
  );
175
- return startSelected && result.kind === "closed" && result.reason === "close"
176
- ? "start"
177
- : "closed";
213
+ if (result.kind !== "closed" || result.reason !== "close") return "closed";
214
+ if (resumedThreadId) return { kind: "resume", threadId: resumedThreadId };
215
+ return startSelected ? "start" : "closed";
178
216
  }
179
217
 
180
218
  export async function runBtwMenuPreservingEditor(
@@ -103,26 +103,30 @@ export async function completeSideThreadTurn({
103
103
  completeSimple,
104
104
  }: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
105
105
  if (signal?.aborted) return { kind: "aborted" };
106
- let response: AssistantMessage;
107
106
  try {
108
- response = await completeSimple(
107
+ const response = await completeSimple(
109
108
  model,
110
109
  { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
111
110
  buildStreamOptions(auth, thinkingLevel, signal),
112
111
  );
112
+ if (signal?.aborted || response?.stopReason === "aborted") return { kind: "aborted" };
113
+ if (!isAssistantMessage(response)) {
114
+ return { kind: "error", message: "The side model returned a malformed response." };
115
+ }
116
+ if (response.stopReason === "error") {
117
+ return {
118
+ kind: "error",
119
+ message: response.errorMessage ?? "The side model returned an error.",
120
+ };
121
+ }
122
+
123
+ const answer = extractAssistantText(response) || "No response received.";
124
+ thread.turns.push({ kind: "answered", question, answer, response });
125
+ return { kind: "answered", response, answer };
113
126
  } catch (error: unknown) {
114
127
  if (signal?.aborted) return { kind: "aborted" };
115
128
  return { kind: "error", message: formatError(error) };
116
129
  }
117
-
118
- if (signal?.aborted || response.stopReason === "aborted") return { kind: "aborted" };
119
- if (response.stopReason === "error") {
120
- return { kind: "error", message: response.errorMessage ?? "The side model returned an error." };
121
- }
122
-
123
- const answer = extractAssistantText(response) || "No response received.";
124
- thread.turns.push({ kind: "answered", question, answer, response });
125
- return { kind: "answered", response, answer };
126
130
  }
127
131
 
128
132
  export interface CompleteSideQuestionOptions {
@@ -156,12 +160,28 @@ export async function completeSideQuestion({
156
160
 
157
161
  export function extractAssistantText(response: AssistantMessage): string {
158
162
  return response.content
159
- .filter((content): content is { type: "text"; text: string } => content.type === "text")
163
+ .filter(
164
+ (content): content is { type: "text"; text: string } =>
165
+ content !== null &&
166
+ typeof content === "object" &&
167
+ content.type === "text" &&
168
+ typeof content.text === "string",
169
+ )
160
170
  .map((content) => content.text)
161
171
  .join("\n")
162
172
  .trim();
163
173
  }
164
174
 
175
+ function isAssistantMessage(value: unknown): value is AssistantMessage {
176
+ if (value === null || typeof value !== "object") return false;
177
+ const candidate = value as Partial<AssistantMessage>;
178
+ return (
179
+ candidate.role === "assistant" &&
180
+ Array.isArray(candidate.content) &&
181
+ typeof candidate.stopReason === "string"
182
+ );
183
+ }
184
+
165
185
  export function buildUserPrompt(question: string, conversationContext: string): string {
166
186
  return [
167
187
  "Answer this side question without modifying the main conversation.",
@@ -11,6 +11,7 @@ import {
11
11
  CURSOR_MARKER,
12
12
  Editor,
13
13
  type EditorTheme,
14
+ type Focusable,
14
15
  Key,
15
16
  Loader,
16
17
  Markdown,
@@ -23,6 +24,7 @@ import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
23
24
  import { sanitizeSingleLine } from "./text.js";
24
25
 
25
26
  const TRANSCRIPT_CHROME_LINES = 2;
27
+ const MAX_STEERING_DISPLAY_LINES = 3;
26
28
  const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
27
29
  // Pi renders a spacer above the custom component and a two-line built-in footer below it.
28
30
  const RESERVED_APP_LINES = 3;
@@ -39,7 +41,15 @@ export interface BtwThinkingControl {
39
41
  onChange: (level: BtwThinkingLevel) => void;
40
42
  }
41
43
 
42
- export class BtwTranscriptPager implements Component {
44
+ export interface BtwAnsweringViewOptions {
45
+ steering?: {
46
+ questions: readonly string[];
47
+ onSubmit: (question: string) => void;
48
+ thinking?: BtwThinkingControl;
49
+ };
50
+ }
51
+
52
+ export class BtwTranscriptPager implements Component, Focusable {
43
53
  private readonly transcriptComponents: Component[];
44
54
  private readonly editor: Editor;
45
55
  private readonly canBringToMain: boolean;
@@ -242,15 +252,19 @@ export class BtwTranscriptPager implements Component {
242
252
  }
243
253
  }
244
254
 
245
- export class BtwAnsweringView implements Component {
255
+ export class BtwAnsweringView implements Component, Focusable {
246
256
  private readonly transcriptComponents: Component[];
247
257
  private readonly loader: Loader;
258
+ private readonly editor: Editor | undefined;
248
259
  private readonly controller = new AbortController();
249
260
  private scrollOffset = 0;
250
261
  private lastContentLineCount = 0;
251
262
  private lastViewportHeight = 1;
252
263
  private followBottom = true;
264
+ private warning: string | undefined;
253
265
  private finished = false;
266
+ private isFocused = false;
267
+ private thinkingLevel: BtwThinkingLevel | undefined;
254
268
 
255
269
  constructor(
256
270
  private readonly tui: TUI,
@@ -258,15 +272,51 @@ export class BtwAnsweringView implements Component {
258
272
  turns: readonly SideThreadTurn[],
259
273
  pendingQuestion: string,
260
274
  private readonly onCancel: () => void,
261
- private readonly thinkingLevel?: BtwThinkingLevel,
275
+ thinkingLevel?: BtwThinkingLevel,
276
+ private readonly options: BtwAnsweringViewOptions = {},
262
277
  ) {
263
278
  this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
279
+ this.thinkingLevel = options.steering?.thinking?.level ?? thinkingLevel;
264
280
  this.loader = new Loader(
265
281
  this.tui,
266
282
  (text) => this.theme.fg("accent", text),
267
283
  (text) => this.theme.fg("muted", text),
268
284
  "Answering…",
269
285
  );
286
+ if (options.steering) {
287
+ const editorTheme: EditorTheme = {
288
+ borderColor: (text) => this.theme.fg("accent", text),
289
+ selectList: {
290
+ selectedPrefix: (text) => this.theme.fg("accent", text),
291
+ selectedText: (text) => this.theme.fg("accent", text),
292
+ description: (text) => this.theme.fg("muted", text),
293
+ scrollInfo: (text) => this.theme.fg("dim", text),
294
+ noMatch: (text) => this.theme.fg("warning", text),
295
+ },
296
+ };
297
+ this.editor = new Editor(this.tui, editorTheme);
298
+ this.editor.onChange = () => {
299
+ this.warning = undefined;
300
+ };
301
+ this.editor.onSubmit = (text) => {
302
+ const question = text.trim();
303
+ if (!question) {
304
+ this.warning = "Question cannot be empty";
305
+ return;
306
+ }
307
+ options.steering?.onSubmit(question);
308
+ this.warning = undefined;
309
+ };
310
+ }
311
+ }
312
+
313
+ get focused(): boolean {
314
+ return this.isFocused;
315
+ }
316
+
317
+ set focused(value: boolean) {
318
+ this.isFocused = value;
319
+ if (this.editor) this.editor.focused = value;
270
320
  }
271
321
 
272
322
  get signal(): AbortSignal {
@@ -276,21 +326,35 @@ export class BtwAnsweringView implements Component {
276
326
  render(width: number): string[] {
277
327
  const safeWidth = Math.max(1, width);
278
328
  const availableRows = Math.max(1, this.tui.terminal.rows - RESERVED_APP_LINES);
279
- const viewportHeight = Math.max(0, availableRows - TRANSCRIPT_CHROME_LINES);
329
+ const editorLines = this.editor?.render(safeWidth) ?? [];
330
+ const steeringCapacity = Math.max(
331
+ 0,
332
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES,
333
+ );
334
+ const steeringLines = renderSteeringLines(
335
+ this.options.steering?.questions ?? [],
336
+ safeWidth,
337
+ this.theme,
338
+ Math.min(MAX_STEERING_DISPLAY_LINES, steeringCapacity),
339
+ );
340
+ const viewportHeight = Math.max(
341
+ 0,
342
+ availableRows - editorLines.length - TRANSCRIPT_CHROME_LINES - steeringLines.length,
343
+ );
280
344
  const contentLines = renderTranscriptLines(this.transcriptComponents, safeWidth);
281
345
  this.lastContentLineCount = contentLines.length;
282
346
  this.lastViewportHeight = viewportHeight;
283
347
  if (this.followBottom) this.scrollOffset = this.getMaxScrollOffset();
284
348
  this.clampScrollOffset();
285
- const cancelHint = safeWidth < 28 ? "Ctrl+C" : "Ctrl+C cancel";
286
- const loaderWidth = Math.max(1, safeWidth - visibleWidth(cancelHint) - 3);
287
- const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
288
- const lines = [
349
+
350
+ return fitComposerLayout(
289
351
  renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
290
- ...contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
291
- truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", cancelHint)}`, safeWidth),
292
- ];
293
- return fitWithFixedHeader(lines, availableRows);
352
+ contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
353
+ this.renderFooter(safeWidth),
354
+ editorLines,
355
+ availableRows,
356
+ steeringLines,
357
+ );
294
358
  }
295
359
 
296
360
  handleInput(data: string): void {
@@ -302,21 +366,43 @@ export class BtwAnsweringView implements Component {
302
366
  this.onCancel();
303
367
  return;
304
368
  }
369
+ const thinking = this.options.steering?.thinking;
370
+ if (
371
+ thinking &&
372
+ thinking.levels.length > 1 &&
373
+ thinking.keybindings.matches(data, "app.thinking.cycle")
374
+ ) {
375
+ const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
376
+ const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
377
+ if (nextLevel) {
378
+ this.thinkingLevel = nextLevel;
379
+ thinking.onChange(nextLevel);
380
+ this.warning = undefined;
381
+ this.tui.requestRender();
382
+ }
383
+ return;
384
+ }
305
385
  if (matchesKey(data, Key.pageUp)) {
306
386
  const previousOffset = this.scrollOffset;
307
387
  this.scrollBy(-this.lastViewportHeight);
308
388
  if (this.scrollOffset < previousOffset) this.followBottom = false;
309
389
  this.tui.requestRender();
310
- } else if (matchesKey(data, Key.pageDown)) {
390
+ return;
391
+ }
392
+ if (matchesKey(data, Key.pageDown)) {
311
393
  this.scrollBy(this.lastViewportHeight);
312
394
  this.followBottom = this.scrollOffset >= this.getMaxScrollOffset();
313
395
  this.tui.requestRender();
396
+ return;
314
397
  }
398
+ this.editor?.handleInput(data);
399
+ this.tui.requestRender();
315
400
  }
316
401
 
317
402
  invalidate(): void {
318
403
  for (const component of this.transcriptComponents) component.invalidate();
319
404
  this.loader.invalidate();
405
+ this.editor?.invalidate();
320
406
  }
321
407
 
322
408
  finish(): void {
@@ -336,6 +422,26 @@ export class BtwAnsweringView implements Component {
336
422
  this.onCancel();
337
423
  }
338
424
 
425
+ private renderFooter(width: number): string {
426
+ if (this.warning) {
427
+ const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C cancel`;
428
+ return truncateToWidth(this.theme.fg("warning", warning), width);
429
+ }
430
+ const baseHint = this.editor ? "Enter steer • Ctrl+C cancel" : "Ctrl+C cancel";
431
+ const thinking = this.options.steering?.thinking;
432
+ const cycleHint =
433
+ thinking && thinking.levels.length > 1 && this.thinkingLevel
434
+ ? ` • thinking ${this.thinkingLevel} • ${thinkingKeyLabel(thinking.keybindings)} cycle`
435
+ : "";
436
+ const scrollHint = this.getMaxScrollOffset() > 0 ? " • PgUp/PgDn history" : "";
437
+ const hints = `${baseHint}${cycleHint}${scrollHint}`;
438
+ const compactHints = this.editor ? "Enter • Ctrl+C" : "Ctrl+C";
439
+ const selectedHints = visibleWidth(hints) <= width ? hints : compactHints;
440
+ const loaderWidth = Math.max(1, width - visibleWidth(selectedHints) - 3);
441
+ const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
442
+ return truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", selectedHints)}`, width);
443
+ }
444
+
339
445
  private scrollBy(delta: number): void {
340
446
  this.scrollOffset += delta;
341
447
  this.clampScrollOffset();
@@ -439,8 +545,9 @@ function fitComposerLayout(
439
545
  footer: string,
440
546
  editorLines: string[],
441
547
  availableRows: number,
548
+ statusLines: string[] = [],
442
549
  ): string[] {
443
- const lines = [header, ...contentLines, footer, ...editorLines];
550
+ const lines = [header, ...contentLines, ...statusLines, footer, ...editorLines];
444
551
  if (lines.length <= availableRows) return lines;
445
552
  if (availableRows <= 1) return [header];
446
553
  const editorBudget = Math.max(0, availableRows - 2);
@@ -456,10 +563,42 @@ function fitEditorLines(editorLines: string[], budget: number): string[] {
456
563
  return editorLines.slice(start, start + budget);
457
564
  }
458
565
 
459
- function fitWithFixedHeader(lines: string[], availableRows: number): string[] {
460
- if (lines.length <= availableRows) return lines;
461
- if (availableRows <= 1) return lines.slice(0, 1);
462
- return [lines[0] ?? "", ...lines.slice(lines.length - availableRows + 1)];
566
+ function renderSteeringLines(
567
+ questions: readonly string[],
568
+ width: number,
569
+ theme: Theme,
570
+ maxLines: number,
571
+ ): string[] {
572
+ if (questions.length === 0 || maxLines <= 0) return [];
573
+ const formatQuestion = (question: string) =>
574
+ sanitizeSingleLine(question) || "(non-printing message)";
575
+ if (maxLines === 1 && questions.length > 1) {
576
+ return [
577
+ truncateToWidth(
578
+ theme.fg(
579
+ "dim",
580
+ `Steering (+${questions.length - 1} more): ${formatQuestion(questions[0] ?? "")}`,
581
+ ),
582
+ width,
583
+ ),
584
+ ];
585
+ }
586
+ const hasOverflow = questions.length > maxLines;
587
+ const questionLimit = hasOverflow ? Math.max(1, maxLines - 1) : maxLines;
588
+ const lines = questions
589
+ .slice(0, questionLimit)
590
+ .map((question) =>
591
+ truncateToWidth(theme.fg("dim", `Steering: ${formatQuestion(question)}`), width),
592
+ );
593
+ if (hasOverflow) {
594
+ lines.push(
595
+ truncateToWidth(
596
+ theme.fg("dim", `Steering: … +${questions.length - questionLimit} more`),
597
+ width,
598
+ ),
599
+ );
600
+ }
601
+ return lines;
463
602
  }
464
603
 
465
604
  function stripShellIntegrationMarkers(line: string): string {