@narumitw/pi-btw 0.49.5 → 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`.
@@ -34,7 +36,7 @@ pi -e npm:@narumitw/pi-btw
34
36
  Try this package locally from the repository root:
35
37
 
36
38
  ```bash
37
- pi -e ./extensions/pi-btw
39
+ pi -e ./packages/pi-btw
38
40
  ```
39
41
 
40
42
  ## 🚀 Usage
@@ -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
@@ -154,7 +167,7 @@ Normal assistant messages become part of the main Pi conversation and can distra
154
167
  ## 🗂️ Package layout
155
168
 
156
169
  ```txt
157
- extensions/pi-btw/
170
+ packages/pi-btw/
158
171
  ├── src/
159
172
  │ ├── index.ts
160
173
  │ ├── btw.ts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@narumitw/pi-btw",
3
- "version": "0.49.5",
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",
@@ -22,6 +22,9 @@
22
22
  "./src/index.ts"
23
23
  ]
24
24
  },
25
+ "piExtension": {
26
+ "lifecycle": "stable"
27
+ },
25
28
  "scripts": {
26
29
  "check": "biome check . && npm run typecheck",
27
30
  "format": "biome check --write .",
@@ -29,15 +32,15 @@
29
32
  },
30
33
  "devDependencies": {
31
34
  "@biomejs/biome": "2.5.7",
32
- "@earendil-works/pi-ai": "0.83.0",
33
- "@earendil-works/pi-coding-agent": "0.83.0",
34
- "@earendil-works/pi-tui": "0.83.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",
35
38
  "typescript": "7.0.2"
36
39
  },
37
40
  "repository": {
38
41
  "type": "git",
39
42
  "url": "https://github.com/narumiruna/pi-extensions",
40
- "directory": "extensions/pi-btw"
43
+ "directory": "packages/pi-btw"
41
44
  },
42
45
  "dependencies": {
43
46
  "@narumitw/pi-tui-kit": "^0.49.1"
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,
@@ -40,6 +41,7 @@ import {
40
41
  import {
41
42
  BTW_THINKING_LEVELS,
42
43
  type BtwThinkingLevel,
44
+ type CompleteSimpleFunction,
43
45
  completeSideThreadTurn,
44
46
  createSideThread,
45
47
  type SideQuestionAuth,
@@ -66,7 +68,6 @@ export {
66
68
  type BtwThinkingLevel,
67
69
  buildUserPrompt,
68
70
  completeSideQuestion,
69
- loadCompleteSimple,
70
71
  } from "./side-thread.js";
71
72
  export { sanitizeSingleLine } from "./text.js";
72
73
 
@@ -82,6 +83,18 @@ type BtwModelRegistry = Pick<
82
83
  "find" | "getApiKeyAndHeaders"
83
84
  >;
84
85
 
86
+ type BtwProviderRegistry = Pick<ExtensionCommandContext["modelRegistry"], "getProvider">;
87
+
88
+ export function createModelRegistryCompleteSimple(
89
+ modelRegistry: BtwProviderRegistry,
90
+ ): CompleteSimpleFunction {
91
+ return async (model, context, options) => {
92
+ const provider = modelRegistry.getProvider(model.provider);
93
+ if (!provider) throw new Error(`No provider registered for model provider: ${model.provider}`);
94
+ return provider.streamSimple(model, context, options).result();
95
+ };
96
+ }
97
+
85
98
  interface ResolveBtwModelOptions {
86
99
  settings: BtwSettings;
87
100
  currentModel: Model<Api> | undefined;
@@ -201,6 +214,7 @@ export interface BtwExtensionDependencies {
201
214
  loadSettings?: typeof loadSettingsForCommand;
202
215
  resolveModel?: typeof resolveBtwModelWithLoader;
203
216
  runThread?: typeof runBtwThread;
217
+ runFullscreen?: RunBtwFullscreen;
204
218
  }
205
219
 
206
220
  export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
@@ -208,6 +222,7 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
208
222
  const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
209
223
  const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
210
224
  const runThread = dependencies.runThread ?? runBtwThread;
225
+ const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
211
226
  pi.registerCommand("btw", {
212
227
  description: "Ask a quick side question without adding it to the main conversation",
213
228
  handler: async (args, ctx) => {
@@ -229,13 +244,15 @@ export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependen
229
244
  return;
230
245
  }
231
246
 
232
- await runThread({
233
- initialQuestion: question || undefined,
234
- selected: resolution.selected,
235
- thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
236
- rememberThinkingLevelChanges: effectiveRememberThinkingLevelChanges(settings),
237
- ctx,
238
- });
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
+ );
239
256
  },
240
257
  });
241
258
  }
@@ -324,6 +341,12 @@ export type BtwThreadResult = { kind: "closed" };
324
341
 
325
342
  type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
326
343
 
344
+ interface BtwThreadSteeringControl {
345
+ questions: readonly string[];
346
+ submit: (question: string) => void;
347
+ thinking: BtwThreadThinkingControl;
348
+ }
349
+
327
350
  type BtwBringToMainChoice =
328
351
  | BtwThreadResult
329
352
  | {
@@ -365,41 +388,42 @@ export async function runBtwThread({
365
388
  const thread = createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
366
389
  const thinkingLevels = getSupportedThinkingLevels(selected.model);
367
390
  const pendingWrites = new Set<Promise<void>>();
391
+ const steeringQuestions: string[] = [];
368
392
  let activeThinkingLevel = clampThinkingLevel(selected.model, thinkingLevel);
369
393
  let pendingQuestion = initialQuestion;
370
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
+ });
371
417
 
372
418
  try {
373
419
  while (true) {
374
420
  if (!pendingQuestion) {
375
- const thinking: BtwThreadThinkingControl = {
376
- level: activeThinkingLevel,
377
- levels: thinkingLevels,
378
- onChange: (level) => {
379
- if (!thinkingLevels.includes(level)) return;
380
- activeThinkingLevel = level;
381
- if (!rememberThinkingLevelChanges) return;
382
- let write!: Promise<void>;
383
- write = Promise.resolve()
384
- .then(() => persistThinkingLevel(level))
385
- .then(() => undefined)
386
- .catch((error: unknown) => {
387
- notifySafely(
388
- ctx,
389
- `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
390
- "warning",
391
- );
392
- })
393
- .finally(() => pendingWrites.delete(write));
394
- pendingWrites.add(write);
395
- },
396
- };
397
421
  const action = await interact(
398
422
  thread,
399
423
  thread.turns.length > 0,
400
424
  ctx,
401
425
  composerDraft,
402
- thinking,
426
+ createThinkingControl(),
403
427
  );
404
428
  if (action.kind === "close") return { kind: "closed" };
405
429
  if (action.kind === "bringToMain") {
@@ -418,7 +442,11 @@ export async function runBtwThread({
418
442
  pendingQuestion = action.question;
419
443
  }
420
444
 
421
- 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
+ });
422
450
  if (result.kind === "aborted") {
423
451
  notifySafely(ctx, "Cancelled", "info");
424
452
  return { kind: "closed" };
@@ -431,7 +459,7 @@ export async function runBtwThread({
431
459
  });
432
460
  }
433
461
 
434
- pendingQuestion = undefined;
462
+ pendingQuestion = steeringQuestions.shift();
435
463
  }
436
464
  } finally {
437
465
  await Promise.allSettled([...pendingWrites]);
@@ -754,9 +782,10 @@ async function askThreadQuestion(
754
782
  selected: ResolvedBtwModel,
755
783
  thinkingLevel: BtwThinkingLevel,
756
784
  ctx: ExtensionCommandContext,
785
+ steering: BtwThreadSteeringControl,
757
786
  ) {
758
787
  return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
759
- (tui, theme, _keybindings, done) => {
788
+ (tui, theme, keybindings, done) => {
760
789
  let settled = false;
761
790
  const view = new BtwAnsweringView(
762
791
  tui,
@@ -769,6 +798,13 @@ async function askThreadQuestion(
769
798
  done({ kind: "aborted" });
770
799
  },
771
800
  thinkingLevel,
801
+ {
802
+ steering: {
803
+ questions: steering.questions,
804
+ onSubmit: steering.submit,
805
+ thinking: { ...steering.thinking, keybindings },
806
+ },
807
+ },
772
808
  );
773
809
  completeSideThreadTurn({
774
810
  thread,
@@ -777,6 +813,7 @@ async function askThreadQuestion(
777
813
  thinkingLevel,
778
814
  auth: selected.auth,
779
815
  signal: view.signal,
816
+ completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
780
817
  }).then((result) => {
781
818
  if (settled) return;
782
819
  settled = true;
@@ -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
+ }
@@ -33,36 +33,6 @@ export type CompleteSimpleFunction = <TApi extends Api>(
33
33
  options?: SimpleStreamOptions,
34
34
  ) => Promise<AssistantMessage>;
35
35
 
36
- type ModuleImporter = (moduleId: string) => Promise<unknown>;
37
-
38
- function hasCompleteSimple(value: unknown): value is { completeSimple: CompleteSimpleFunction } {
39
- return (
40
- typeof value === "object" &&
41
- value !== null &&
42
- typeof Reflect.get(value, "completeSimple") === "function"
43
- );
44
- }
45
-
46
- export async function loadCompleteSimple(
47
- importModule: ModuleImporter = (moduleId) => import(moduleId),
48
- ): Promise<CompleteSimpleFunction> {
49
- let importError: unknown;
50
- for (const moduleId of ["@earendil-works/pi-ai/compat", "@earendil-works/pi-ai"]) {
51
- try {
52
- const module = await importModule(moduleId);
53
- if (hasCompleteSimple(module)) return module.completeSimple;
54
- } catch (error: unknown) {
55
- importError = error;
56
- }
57
- }
58
-
59
- throw new Error("@earendil-works/pi-ai does not export completeSimple", {
60
- cause: importError,
61
- });
62
- }
63
-
64
- const defaultCompleteSimple = await loadCompleteSimple();
65
-
66
36
  export type SideThreadTurn =
67
37
  | {
68
38
  kind: "answered";
@@ -115,7 +85,7 @@ export interface CompleteSideThreadTurnOptions {
115
85
  thinkingLevel: BtwThinkingLevel;
116
86
  auth: SideQuestionAuth;
117
87
  signal?: AbortSignal;
118
- completeSimple?: CompleteSimpleFunction;
88
+ completeSimple: CompleteSimpleFunction;
119
89
  }
120
90
 
121
91
  export type CompleteSideThreadTurnResult =
@@ -130,29 +100,33 @@ export async function completeSideThreadTurn({
130
100
  thinkingLevel,
131
101
  auth,
132
102
  signal,
133
- completeSimple = defaultCompleteSimple,
103
+ completeSimple,
134
104
  }: CompleteSideThreadTurnOptions): Promise<CompleteSideThreadTurnResult> {
135
105
  if (signal?.aborted) return { kind: "aborted" };
136
- let response: AssistantMessage;
137
106
  try {
138
- response = await completeSimple(
107
+ const response = await completeSimple(
139
108
  model,
140
109
  { systemPrompt: SYSTEM_PROMPT, messages: buildSideThreadMessages(thread, question) },
141
110
  buildStreamOptions(auth, thinkingLevel, signal),
142
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 };
143
126
  } catch (error: unknown) {
144
127
  if (signal?.aborted) return { kind: "aborted" };
145
128
  return { kind: "error", message: formatError(error) };
146
129
  }
147
-
148
- if (signal?.aborted || response.stopReason === "aborted") return { kind: "aborted" };
149
- if (response.stopReason === "error") {
150
- return { kind: "error", message: response.errorMessage ?? "The side model returned an error." };
151
- }
152
-
153
- const answer = extractAssistantText(response) || "No response received.";
154
- thread.turns.push({ kind: "answered", question, answer, response });
155
- return { kind: "answered", response, answer };
156
130
  }
157
131
 
158
132
  export interface CompleteSideQuestionOptions {
@@ -162,7 +136,7 @@ export interface CompleteSideQuestionOptions {
162
136
  thinkingLevel: BtwThinkingLevel;
163
137
  auth: SideQuestionAuth;
164
138
  signal?: AbortSignal;
165
- completeSimple?: CompleteSimpleFunction;
139
+ completeSimple: CompleteSimpleFunction;
166
140
  }
167
141
 
168
142
  export async function completeSideQuestion({
@@ -172,7 +146,7 @@ export async function completeSideQuestion({
172
146
  thinkingLevel,
173
147
  auth,
174
148
  signal,
175
- completeSimple = defaultCompleteSimple,
149
+ completeSimple,
176
150
  }: CompleteSideQuestionOptions): Promise<AssistantMessage> {
177
151
  return completeSimple(
178
152
  model,
@@ -186,12 +160,28 @@ export async function completeSideQuestion({
186
160
 
187
161
  export function extractAssistantText(response: AssistantMessage): string {
188
162
  return response.content
189
- .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
+ )
190
170
  .map((content) => content.text)
191
171
  .join("\n")
192
172
  .trim();
193
173
  }
194
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
+
195
185
  export function buildUserPrompt(question: string, conversationContext: string): string {
196
186
  return [
197
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 {