@narumitw/pi-btw 0.49.6 → 0.49.7

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
@@ -10,8 +10,10 @@ Use it when you want to ask a temporary question, inspect context, or get a shor
10
10
 
11
11
  - Adds a `/btw` menu for starting a side thread or changing pi-btw settings.
12
12
  - Keeps `/btw <question>` as a direct fast path.
13
- - Answers side questions in a temporary, scrollable UI.
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
+ - Queues Pi-style `Steering` questions while an answer is running and processes them one at a time.
15
17
  - Optionally brings the latest answer, a question-to-end suffix, an exact line range, or the entire side thread into the main editor.
16
18
  - Uses the current session branch as context.
17
19
  - Uses Pi's current model or an independent model selected in `pi-btw.json`.
@@ -58,8 +60,13 @@ Examples:
58
60
  Running `/btw` alone opens a two-row menu. **Start side thread** is selected first, so pressing
59
61
  `Enter` opens an empty ephemeral side thread; **Settings** changes the starting thinking level
60
62
  and whether shortcut changes are remembered. `/btw <question>` bypasses this menu, and its answer
61
- opens above the side-thread editor. A compact `btw · side thread` header stays fixed above the
62
- content so the ephemeral workspace remains recognizable while scrolling. Messages use Pi's normal
63
+ opens above the side-thread editor. The side thread uses a dedicated full-screen terminal view.
64
+ The main agent continues running in the background, but its screen rendering stays suspended until
65
+ `/btw` closes, so new main-thread output cannot move a mouse selection inside the side thread.
66
+ Drag the primary mouse button across side-thread text to select and copy it through Pi's terminal
67
+ clipboard support. Returning from `/btw` redraws the main view with everything produced while it
68
+ was hidden. A compact `btw · side thread` header stays fixed above the content so the ephemeral
69
+ workspace remains recognizable while scrolling. Messages use Pi's normal
63
70
  user and assistant presentation without numbered turns or role labels. Type each question and press
64
71
  `Enter`; no follow-up shortcut is required.
65
72
  Previous side questions and answers remain available to the model and visible for that
@@ -68,10 +75,16 @@ invocation. The side-thread header shows its current thinking level. Press Pi's
68
75
  supported by the side-thread model; every later question uses the displayed level until it is
69
76
  changed again. By default, each shortcut change is also written to `pi-btw.json` for the next
70
77
  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.
78
+ current side thread. Neither path changes the main session's thinking level.
79
+ While a response is running, the transcript and composer remain visible above an `Answering…`
80
+ status.
81
+ Type another question and press `Enter` to queue it as `Steering`; queued questions are shown in
82
+ submission order and answered one at a time after the active response completes.
83
+ A queued question uses the side thread's thinking level when its turn begins.
84
+ A failed active response is shown in the transcript and does not discard later steering questions.
85
+ The footer shows `PgUp`/`PgDn` only when history can scroll; press `Ctrl+C` to cancel the active
86
+ response and discard the ephemeral side-thread draft and steering queue.
87
+ Steering remains entirely inside pi-btw and never appends to the main conversation or editor.
75
88
 
76
89
  After at least one successful answer, press `Ctrl+R` to bring selected context to the main
77
90
  editor. The scope menu shows the size of the latest question and answer and the entire side
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.49.6",
3
+ "version": "0.49.7",
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,6 +25,7 @@ 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,
30
31
  runBtwMenuPreservingEditor,
@@ -213,6 +214,7 @@ export interface BtwExtensionDependencies {
213
214
  loadSettings?: typeof loadSettingsForCommand;
214
215
  resolveModel?: typeof resolveBtwModelWithLoader;
215
216
  runThread?: typeof runBtwThread;
217
+ runFullscreen?: RunBtwFullscreen;
216
218
  }
217
219
 
218
220
  export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
@@ -220,6 +222,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
220
222
  const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
221
223
  const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
222
224
  const runThread = dependencies.runThread ?? runBtwThread;
225
+ const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
223
226
  pi.registerCommand("btw", {
224
227
  description: "Ask a quick side question without adding it to the main conversation",
225
228
  handler: async (args, ctx) => {
@@ -241,13 +244,15 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
241
244
  return;
242
245
  }
243
246
 
244
- await runThread({
245
- initialQuestion: question || undefined,
246
- selected: resolution.selected,
247
- thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
248
- rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
249
- ctx,
250
- });
247
+ await runFullscreen(ctx, (fullscreenCtx) =>
248
+ runThread({
249
+ initialQuestion: question || undefined,
250
+ selected: resolution.selected,
251
+ thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
252
+ rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
253
+ ctx: fullscreenCtx,
254
+ }),
255
+ );
251
256
  },
252
257
  });
253
258
  }
@@ -336,6 +341,12 @@ export type BtwThreadResult = { kind: "closed" };
336
341
 
337
342
  type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
338
343
 
344
+ interface BtwThreadSteeringControl {
345
+ questions: readonly string[];
346
+ submit: (question: string) => void;
347
+ thinking: BtwThreadThinkingControl;
348
+ }
349
+
339
350
  type BtwBringToMainChoice =
340
351
  | BtwThreadResult
341
352
  | {
@@ -377,41 +388,42 @@ export async function runBtwThread({
377
388
  const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
378
389
  const thinkingLevels = getSupportedThinkingLevels(selected.model);
379
390
  const pendingWrites = new Set<Promise<void>>();
391
+ const steeringQuestions: string[] = [];
380
392
  let activeThinkingLevel = clampThinkingLevel(selected.model, thinkingLevel);
381
393
  let pendingQuestion = initialQuestion;
382
394
  let composerDraft: string | undefined;
395
+ const createThinkingControl = (): BtwThreadThinkingControl => ({
396
+ level: activeThinkingLevel,
397
+ levels: thinkingLevels,
398
+ onChange: (level) => {
399
+ if (!thinkingLevels.includes(level)) return;
400
+ activeThinkingLevel = level;
401
+ if (!rememberThinkingLevelChanges) return;
402
+ let write!: Promise<void>;
403
+ write = Promise.resolve()
404
+ .then(() => persistThinkingLevel(level))
405
+ .then(() => undefined)
406
+ .catch((error: unknown) => {
407
+ notifySafely(
408
+ ctx,
409
+ `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
410
+ "warning",
411
+ );
412
+ })
413
+ .finally(() => pendingWrites.delete(write));
414
+ pendingWrites.add(write);
415
+ },
416
+ });
383
417
 
384
418
  try {
385
419
  while (true) {
386
420
  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
421
  const action = await interact(
410
422
  thread,
411
423
  thread.turns.length > 0,
412
424
  ctx,
413
425
  composerDraft,
414
- thinking,
426
+ createThinkingControl(),
415
427
  );
416
428
  if (action.kind === "close") return { kind: "closed" };
417
429
  if (action.kind === "bringToMain") {
@@ -430,7 +442,11 @@ export async function runBtwThread({
430
442
  pendingQuestion = action.question;
431
443
  }
432
444
 
433
- const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx);
445
+ const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx, {
446
+ questions: steeringQuestions,
447
+ submit: (question) => steeringQuestions.push(question),
448
+ thinking: createThinkingControl(),
449
+ });
434
450
  if (result.kind === "aborted") {
435
451
  notifySafely(ctx, "Cancelled", "info");
436
452
  return { kind: "closed" };
@@ -443,7 +459,7 @@ export async function runBtwThread({
443
459
  });
444
460
  }
445
461
 
446
- pendingQuestion = undefined;
462
+ pendingQuestion = steeringQuestions.shift();
447
463
  }
448
464
  } finally {
449
465
  await Promise.allSettled([...pendingWrites]);
@@ -766,9 +782,10 @@ async function askThreadQuestion(
766
782
  selected: ResolvedBtwModel,
767
783
  thinkingLevel: BtwThinkingLevel,
768
784
  ctx: ExtensionCommandContext,
785
+ steering: BtwThreadSteeringControl,
769
786
  ) {
770
787
  return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
771
- (tui, theme, _keybindings, done) => {
788
+ (tui, theme, keybindings, done) => {
772
789
  let settled = false;
773
790
  const view = new BtwAnsweringView(
774
791
  tui,
@@ -781,6 +798,13 @@ async function askThreadQuestion(
781
798
  done({ kind: "aborted" });
782
799
  },
783
800
  thinkingLevel,
801
+ {
802
+ steering: {
803
+ questions: steering.questions,
804
+ onSubmit: steering.submit,
805
+ thinking: { ...steering.thinking, keybindings },
806
+ },
807
+ },
784
808
  );
785
809
  completeSideThreadTurn({
786
810
  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
+ }
@@ -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 {