@narumitw/pi-btw 0.42.0 → 0.43.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/src/menu.ts ADDED
@@ -0,0 +1,256 @@
1
+ import type {
2
+ ExtensionCommandContext,
3
+ KeybindingsManager,
4
+ Theme,
5
+ } from "@earendil-works/pi-coding-agent";
6
+ import type { Component, TUI } from "@earendil-works/pi-tui";
7
+ import { defineMenu, type MenuContext, type RunMenuResult, runMenu } from "@narumitw/pi-tui-kit";
8
+ import {
9
+ type BtwSettings,
10
+ btwSettingsPath,
11
+ effectiveRememberThinkingLevelChanges,
12
+ readBtwSettings,
13
+ type UpdateBtwSettingsOptions,
14
+ updateBtwSettings,
15
+ } from "./settings.js";
16
+ import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
17
+ import { sanitizeSingleLine } from "./text.js";
18
+
19
+ interface BtwMenuState {
20
+ kind: "valid" | "invalid";
21
+ settings: BtwSettings;
22
+ reason?: string;
23
+ }
24
+
25
+ export interface ShowBtwCommandMenuOptions {
26
+ currentThinkingLevel: BtwThinkingLevel;
27
+ availableThinkingLevels: readonly BtwThinkingLevel[];
28
+ settingsPath?: string;
29
+ readSettings?: typeof readBtwSettings;
30
+ updateSettings?: (
31
+ patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
32
+ options: UpdateBtwSettingsOptions,
33
+ ) => Promise<BtwSettings>;
34
+ }
35
+
36
+ export type BtwCommandMenuResult = "start" | "closed";
37
+
38
+ type BtwMenuScreen = "main" | "settings" | "invalid";
39
+ type BtwMenuAction = "start" | "set-thinking" | "set-remember";
40
+ type BtwCustomOptions = Parameters<ExtensionCommandContext["ui"]["custom"]>[1];
41
+
42
+ type BtwCustomFactory<T> = (
43
+ tui: TUI,
44
+ theme: Theme,
45
+ keybindings: KeybindingsManager,
46
+ done: (result: T) => void,
47
+ ) => Component;
48
+
49
+ export async function showBtwCommandMenu(
50
+ ctx: ExtensionCommandContext,
51
+ options: ShowBtwCommandMenuOptions,
52
+ ): Promise<BtwCommandMenuResult> {
53
+ if (ctx.mode !== "tui") return "closed";
54
+ const settingsPath = options.settingsPath ?? btwSettingsPath();
55
+ const readSettings = options.readSettings ?? readBtwSettings;
56
+ const updateSettings = options.updateSettings ?? updateBtwSettings;
57
+ const levels =
58
+ options.availableThinkingLevels.length > 0
59
+ ? [...options.availableThinkingLevels]
60
+ : (["off"] satisfies BtwThinkingLevel[]);
61
+ const displaySettingsPath = sanitizeSingleLine(settingsPath);
62
+ let startSelected = false;
63
+
64
+ const loadState = async (): Promise<BtwMenuState> => {
65
+ const loaded = await readSettings(settingsPath);
66
+ if (loaded.kind === "invalid") {
67
+ return { kind: "invalid", settings: {}, reason: loaded.reason };
68
+ }
69
+ return { kind: "valid", settings: loaded.kind === "loaded" ? loaded.settings : {} };
70
+ };
71
+ const displayThinkingLevel = (settings: BtwSettings): BtwThinkingLevel =>
72
+ clampToAvailableThinkingLevel(settings.thinkingLevel ?? options.currentThinkingLevel, levels);
73
+
74
+ const menu = defineMenu<BtwMenuState, BtwMenuScreen, BtwMenuAction, MenuContext>({
75
+ start: "main",
76
+ screens: {
77
+ main: ({ state }) => ({
78
+ kind: "actions",
79
+ title: "Pi BTW",
80
+ lines: [
81
+ `Thinking: ${displayThinkingLevel(state.settings)} · Remember changes: ${effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off"}`,
82
+ ],
83
+ items: [
84
+ {
85
+ id: "start",
86
+ label: "Start side thread",
87
+ description: "Open an empty side thread",
88
+ action: "start",
89
+ },
90
+ {
91
+ id: "settings",
92
+ label: "Settings",
93
+ description: "Choose pi-btw thinking and whether shortcut changes are remembered",
94
+ to: state.kind === "invalid" ? "invalid" : "settings",
95
+ },
96
+ ],
97
+ hint: "close",
98
+ }),
99
+ settings: ({ state }) => ({
100
+ kind: "settings",
101
+ title: "Pi BTW Settings",
102
+ lines: [`User settings · ${displaySettingsPath}`],
103
+ items: [
104
+ {
105
+ id: "thinkingLevel",
106
+ label: "Thinking level",
107
+ description: "Set the starting level for future pi-btw side threads.",
108
+ currentValue: displayThinkingLevel(state.settings),
109
+ values: levels,
110
+ action: "set-thinking",
111
+ },
112
+ {
113
+ id: "rememberThinkingLevelChanges",
114
+ label: "Remember thinking level changes",
115
+ description: "Save side-thread shortcut changes to pi-btw.json for next time.",
116
+ currentValue: effectiveRememberThinkingLevelChanges(state.settings) ? "On" : "Off",
117
+ values: ["On", "Off"],
118
+ action: "set-remember",
119
+ },
120
+ ],
121
+ }),
122
+ invalid: ({ state }) => ({
123
+ kind: "detail",
124
+ title: "Pi BTW Settings · Read only",
125
+ lines: [
126
+ `Invalid settings file. Fix ${displaySettingsPath} before saving.`,
127
+ sanitizeSingleLine(state.reason ?? "The settings file is invalid."),
128
+ ],
129
+ hint: "back",
130
+ }),
131
+ },
132
+ actions: {
133
+ start: async () => {
134
+ startSelected = true;
135
+ return { kind: "close" };
136
+ },
137
+ "set-thinking": async ({ value, signal }) => {
138
+ if (!value || !levels.includes(value as BtwThinkingLevel)) return { kind: "rejected" };
139
+ try {
140
+ await updateSettings(
141
+ { thinkingLevel: value as BtwThinkingLevel },
142
+ { settingsPath, signal },
143
+ );
144
+ if (signal.aborted) return { kind: "rejected" };
145
+ notifySafely(ctx, `Pi BTW thinking level: ${value}.`, "info");
146
+ return { kind: "stay" };
147
+ } catch (error) {
148
+ if (!signal.aborted) notifySaveFailure(ctx, error);
149
+ return { kind: "rejected" };
150
+ }
151
+ },
152
+ "set-remember": async ({ value, signal }) => {
153
+ if (value !== "On" && value !== "Off") return { kind: "rejected" };
154
+ try {
155
+ await updateSettings(
156
+ { rememberThinkingLevelChanges: value === "On" },
157
+ { settingsPath, signal },
158
+ );
159
+ if (signal.aborted) return { kind: "rejected" };
160
+ notifySafely(ctx, `Remember thinking level changes: ${value}.`, "info");
161
+ return { kind: "stay" };
162
+ } catch (error) {
163
+ if (!signal.aborted) notifySaveFailure(ctx, error);
164
+ return { kind: "rejected" };
165
+ }
166
+ },
167
+ },
168
+ });
169
+
170
+ const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
171
+ runMenu(menuContext, menu, { getState: loadState }),
172
+ );
173
+ return startSelected && result.kind === "closed" && result.reason === "close"
174
+ ? "start"
175
+ : "closed";
176
+ }
177
+
178
+ export async function runBtwMenuPreservingEditor(
179
+ ctx: ExtensionCommandContext,
180
+ run: (menuContext: MenuContext) => Promise<RunMenuResult>,
181
+ ): Promise<RunMenuResult> {
182
+ let liveEditorText = ctx.ui.getEditorText();
183
+ let completed = false;
184
+ const ui = new Proxy(ctx.ui, {
185
+ get(target, property) {
186
+ if (property === "custom") {
187
+ return <Value>(factory: BtwCustomFactory<Value>, customOptions?: BtwCustomOptions) =>
188
+ target.custom<Value>(
189
+ (tui, theme, keybindings, done) =>
190
+ factory(tui, theme, keybindings, (value) => {
191
+ try {
192
+ liveEditorText = target.getEditorText();
193
+ } catch {
194
+ // Keep completion finite if session replacement invalidates the editor context.
195
+ }
196
+ completed = true;
197
+ done(value);
198
+ }),
199
+ customOptions,
200
+ );
201
+ }
202
+ const value = Reflect.get(target, property, target) as unknown;
203
+ return typeof value === "function" ? value.bind(target) : value;
204
+ },
205
+ });
206
+ const result = await run({ mode: ctx.mode, hasUI: ctx.hasUI, ui });
207
+ if (result.kind !== "stale" && completed) {
208
+ try {
209
+ if (ctx.ui.getEditorText() !== liveEditorText) ctx.ui.setEditorText(liveEditorText);
210
+ } catch {
211
+ // A replaced context owns a different editor and must not receive stale restoration.
212
+ }
213
+ }
214
+ return result;
215
+ }
216
+
217
+ function clampToAvailableThinkingLevel(
218
+ requested: BtwThinkingLevel,
219
+ available: readonly BtwThinkingLevel[],
220
+ ): BtwThinkingLevel {
221
+ if (available.includes(requested)) return requested;
222
+ const requestedIndex = BTW_THINKING_LEVELS.indexOf(requested);
223
+ for (let index = requestedIndex; index < BTW_THINKING_LEVELS.length; index += 1) {
224
+ const candidate = BTW_THINKING_LEVELS[index];
225
+ if (candidate && available.includes(candidate)) return candidate;
226
+ }
227
+ for (let index = requestedIndex - 1; index >= 0; index -= 1) {
228
+ const candidate = BTW_THINKING_LEVELS[index];
229
+ if (candidate && available.includes(candidate)) return candidate;
230
+ }
231
+ return available[0] ?? "off";
232
+ }
233
+
234
+ function notifySaveFailure(ctx: ExtensionCommandContext, error: unknown): void {
235
+ notifySafely(
236
+ ctx,
237
+ `Pi BTW settings were not saved; the previous value remains active: ${formatError(error)}`,
238
+ "error",
239
+ );
240
+ }
241
+
242
+ function notifySafely(
243
+ ctx: ExtensionCommandContext,
244
+ message: string,
245
+ level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
246
+ ): void {
247
+ try {
248
+ ctx.ui.notify(sanitizeSingleLine(message), level);
249
+ } catch {
250
+ // A completed save remains valid if its command context was replaced before notification.
251
+ }
252
+ }
253
+
254
+ function formatError(error: unknown): string {
255
+ return error instanceof Error ? error.message : String(error);
256
+ }
@@ -0,0 +1,237 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { mkdir, open, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join } from "node:path";
5
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
6
+ import { BTW_THINKING_LEVELS, type BtwThinkingLevel } from "./side-thread.js";
7
+
8
+ export const BTW_SETTINGS_FILE = "pi-btw.json";
9
+ export const DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES = true;
10
+ const MAX_SETTINGS_BYTES = 64 * 1024;
11
+
12
+ export interface BtwSettings {
13
+ model?: string;
14
+ thinkingLevel?: BtwThinkingLevel;
15
+ rememberThinkingLevelChanges?: boolean;
16
+ }
17
+
18
+ export type BtwSettingsLoadResult =
19
+ | { kind: "missing" }
20
+ | { kind: "invalid"; reason: string }
21
+ | { kind: "loaded"; settings: BtwSettings };
22
+
23
+ export interface UpdateBtwSettingsOptions {
24
+ settingsPath?: string;
25
+ signal?: AbortSignal;
26
+ beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>;
27
+ }
28
+
29
+ type SettingsDocument = Record<string, unknown>;
30
+
31
+ const mutationQueues = new Map<string, Promise<void>>();
32
+
33
+ export function btwSettingsPath(): string {
34
+ return join(getAgentDir(), BTW_SETTINGS_FILE);
35
+ }
36
+
37
+ export function normalizeBtwSettings(value: unknown): BtwSettings | undefined {
38
+ if (!isSettingsDocument(value)) return undefined;
39
+
40
+ const settings: BtwSettings = {};
41
+ if (Object.hasOwn(value, "model")) {
42
+ const model = Reflect.get(value, "model");
43
+ if (typeof model !== "string" || !parseBtwModelReference(model)) return undefined;
44
+ settings.model = model;
45
+ }
46
+ if (Object.hasOwn(value, "thinkingLevel")) {
47
+ const thinkingLevel = Reflect.get(value, "thinkingLevel");
48
+ if (!isBtwThinkingLevel(thinkingLevel)) return undefined;
49
+ settings.thinkingLevel = thinkingLevel;
50
+ }
51
+ if (Object.hasOwn(value, "rememberThinkingLevelChanges")) {
52
+ const remember = Reflect.get(value, "rememberThinkingLevelChanges");
53
+ if (typeof remember !== "boolean") return undefined;
54
+ settings.rememberThinkingLevelChanges = remember;
55
+ }
56
+ return settings;
57
+ }
58
+
59
+ export function parseBtwModelReference(
60
+ reference: string,
61
+ ): { provider: string; modelId: string } | undefined {
62
+ if (/[\s\p{Cc}]/u.test(reference)) return undefined;
63
+ const separator = reference.indexOf("/");
64
+ if (separator <= 0 || separator === reference.length - 1) return undefined;
65
+ return { provider: reference.slice(0, separator), modelId: reference.slice(separator + 1) };
66
+ }
67
+
68
+ export function effectiveRememberThinkingLevelChanges(settings: BtwSettings): boolean {
69
+ return settings.rememberThinkingLevelChanges ?? DEFAULT_REMEMBER_THINKING_LEVEL_CHANGES;
70
+ }
71
+
72
+ export async function readBtwSettings(
73
+ settingsPath = btwSettingsPath(),
74
+ ): Promise<BtwSettingsLoadResult> {
75
+ await awaitBtwSettingsWrites(settingsPath);
76
+ return readBtwSettingsUncoordinated(settingsPath);
77
+ }
78
+
79
+ export function updateBtwSettings(
80
+ patch: Partial<Pick<BtwSettings, "thinkingLevel" | "rememberThinkingLevelChanges">>,
81
+ options: UpdateBtwSettingsOptions = {},
82
+ ): Promise<BtwSettings> {
83
+ const settingsPath = options.settingsPath ?? btwSettingsPath();
84
+ return enqueueMutation(settingsPath, async () => {
85
+ options.signal?.throwIfAborted();
86
+ const current = await readSettingsDocumentForUpdate(settingsPath);
87
+ const updated: SettingsDocument = { ...current, ...patch };
88
+ const settings = normalizeBtwSettings(updated);
89
+ if (!settings) throw invalidSettingsError(settingsPath, "invalid settings shape");
90
+ await publishSettings(settingsPath, updated, options.signal, options.beforeRename);
91
+ return settings;
92
+ });
93
+ }
94
+
95
+ export async function awaitBtwSettingsWrites(settingsPath = btwSettingsPath()): Promise<void> {
96
+ await mutationQueues.get(settingsPath);
97
+ }
98
+
99
+ function enqueueMutation<T>(settingsPath: string, mutation: () => Promise<T>): Promise<T> {
100
+ const previous = mutationQueues.get(settingsPath) ?? Promise.resolve();
101
+ const result = previous.then(mutation, mutation);
102
+ const settled = result.then(
103
+ () => undefined,
104
+ () => undefined,
105
+ );
106
+ mutationQueues.set(settingsPath, settled);
107
+ void settled.finally(() => {
108
+ if (mutationQueues.get(settingsPath) === settled) mutationQueues.delete(settingsPath);
109
+ });
110
+ return result;
111
+ }
112
+
113
+ async function readBtwSettingsUncoordinated(settingsPath: string): Promise<BtwSettingsLoadResult> {
114
+ let contents: string;
115
+ try {
116
+ contents = await readSettingsContents(settingsPath);
117
+ } catch (error: unknown) {
118
+ if (isNodeError(error) && error.code === "ENOENT") return { kind: "missing" };
119
+ return { kind: "invalid", reason: `${settingsPath}: ${formatError(error)}` };
120
+ }
121
+
122
+ try {
123
+ const settings = normalizeBtwSettings(JSON.parse(contents) as unknown);
124
+ return settings
125
+ ? { kind: "loaded", settings }
126
+ : { kind: "invalid", reason: `${settingsPath}: invalid settings shape` };
127
+ } catch {
128
+ return { kind: "invalid", reason: `${settingsPath}: invalid JSON` };
129
+ }
130
+ }
131
+
132
+ async function readSettingsDocumentForUpdate(settingsPath: string): Promise<SettingsDocument> {
133
+ let contents: string;
134
+ try {
135
+ contents = await readSettingsContents(settingsPath);
136
+ } catch (error: unknown) {
137
+ if (isNodeError(error) && error.code === "ENOENT") return {};
138
+ throw invalidSettingsError(settingsPath, formatError(error));
139
+ }
140
+
141
+ let parsed: unknown;
142
+ try {
143
+ parsed = JSON.parse(contents) as unknown;
144
+ } catch {
145
+ throw invalidSettingsError(settingsPath, "invalid JSON");
146
+ }
147
+ if (!isSettingsDocument(parsed) || !normalizeBtwSettings(parsed)) {
148
+ throw invalidSettingsError(settingsPath, "invalid settings shape");
149
+ }
150
+ return parsed;
151
+ }
152
+
153
+ async function readSettingsContents(settingsPath: string): Promise<string> {
154
+ const flags = constants.O_RDONLY | (constants.O_NONBLOCK ?? 0);
155
+ const handle = await open(settingsPath, flags);
156
+ try {
157
+ const descriptorStats = await handle.stat();
158
+ if (!descriptorStats.isFile()) throw new Error("settings path is not a regular file");
159
+ if (descriptorStats.size > MAX_SETTINGS_BYTES) {
160
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
161
+ }
162
+
163
+ const buffer = Buffer.alloc(MAX_SETTINGS_BYTES + 1);
164
+ let offset = 0;
165
+ while (offset < buffer.byteLength) {
166
+ const { bytesRead } = await handle.read(buffer, offset, buffer.byteLength - offset, offset);
167
+ if (bytesRead === 0) break;
168
+ offset += bytesRead;
169
+ }
170
+ if (offset > MAX_SETTINGS_BYTES) {
171
+ throw new Error(`settings file exceeds ${MAX_SETTINGS_BYTES} bytes`);
172
+ }
173
+ try {
174
+ return new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(
175
+ buffer.subarray(0, offset),
176
+ );
177
+ } catch {
178
+ throw new Error("settings file is not valid UTF-8");
179
+ }
180
+ } finally {
181
+ await handle.close();
182
+ }
183
+ }
184
+
185
+ async function publishSettings(
186
+ settingsPath: string,
187
+ document: SettingsDocument,
188
+ signal?: AbortSignal,
189
+ beforeRename?: (temporaryPath: string, settingsPath: string) => Promise<void>,
190
+ ): Promise<void> {
191
+ signal?.throwIfAborted();
192
+ const contents = `${JSON.stringify(document, null, 2)}\n`;
193
+ if (Buffer.byteLength(contents, "utf8") > MAX_SETTINGS_BYTES) {
194
+ throw new Error(`settings document exceeds ${MAX_SETTINGS_BYTES} bytes`);
195
+ }
196
+ const directory = dirname(settingsPath);
197
+ await mkdir(directory, { recursive: true });
198
+ signal?.throwIfAborted();
199
+ const temporaryPath = join(
200
+ directory,
201
+ `.${basename(settingsPath)}.${process.pid}.${randomUUID()}.tmp`,
202
+ );
203
+ try {
204
+ await writeFile(temporaryPath, contents, {
205
+ encoding: "utf8",
206
+ flag: "wx",
207
+ mode: 0o600,
208
+ signal,
209
+ });
210
+ await beforeRename?.(temporaryPath, settingsPath);
211
+ signal?.throwIfAborted();
212
+ await rename(temporaryPath, settingsPath);
213
+ } catch (error) {
214
+ await rm(temporaryPath, { force: true }).catch(() => undefined);
215
+ throw error;
216
+ }
217
+ }
218
+
219
+ function isSettingsDocument(value: unknown): value is SettingsDocument {
220
+ return typeof value === "object" && value !== null && !Array.isArray(value);
221
+ }
222
+
223
+ function isBtwThinkingLevel(value: unknown): value is BtwThinkingLevel {
224
+ return BTW_THINKING_LEVELS.includes(value as BtwThinkingLevel);
225
+ }
226
+
227
+ function invalidSettingsError(settingsPath: string, reason: string): Error {
228
+ return new Error(`pi-btw settings at ${settingsPath} are invalid: ${reason}`);
229
+ }
230
+
231
+ function isNodeError(error: unknown): error is NodeJS.ErrnoException {
232
+ return error instanceof Error && "code" in error;
233
+ }
234
+
235
+ function formatError(error: unknown): string {
236
+ return error instanceof Error ? error.message : String(error);
237
+ }
package/src/text.ts ADDED
@@ -0,0 +1,10 @@
1
+ export function sanitizeSingleLine(text: string): string {
2
+ return [...text.replace(/[\r\n\t]/gu, " ")]
3
+ .filter((character) => {
4
+ const code = character.charCodeAt(0);
5
+ return code > 31 && (code < 127 || code > 159);
6
+ })
7
+ .join("")
8
+ .replace(/ +/gu, " ")
9
+ .trim();
10
+ }
@@ -2,6 +2,7 @@ import type { AssistantMessage } from "@earendil-works/pi-ai";
2
2
  import {
3
3
  AssistantMessageComponent,
4
4
  getMarkdownTheme,
5
+ type KeybindingsManager,
5
6
  type Theme,
6
7
  UserMessageComponent,
7
8
  } from "@earendil-works/pi-coding-agent";
@@ -18,7 +19,8 @@ import {
18
19
  truncateToWidth,
19
20
  visibleWidth,
20
21
  } from "@earendil-works/pi-tui";
21
- import type { SideThreadTurn } from "./side-thread.js";
22
+ import type { BtwThinkingLevel, SideThreadTurn } from "./side-thread.js";
23
+ import { sanitizeSingleLine } from "./text.js";
22
24
 
23
25
  const TRANSCRIPT_CHROME_LINES = 2;
24
26
  const OSC133_MARKERS = ["\u001b]133;A\u0007", "\u001b]133;B\u0007", "\u001b]133;C\u0007"];
@@ -30,6 +32,13 @@ export type TranscriptPagerAction =
30
32
  | { kind: "bringToMain"; questionDraft: string }
31
33
  | { kind: "close" };
32
34
 
35
+ export interface BtwThinkingControl {
36
+ level: BtwThinkingLevel;
37
+ levels: readonly BtwThinkingLevel[];
38
+ keybindings: KeybindingsManager;
39
+ onChange: (level: BtwThinkingLevel) => void;
40
+ }
41
+
33
42
  export class BtwTranscriptPager implements Component {
34
43
  private readonly transcriptComponents: Component[];
35
44
  private readonly editor: Editor;
@@ -41,17 +50,23 @@ export class BtwTranscriptPager implements Component {
41
50
  private warning: string | undefined;
42
51
  private finished = false;
43
52
  private isFocused = false;
53
+ private thinkingLevel: BtwThinkingLevel | undefined;
44
54
 
45
55
  constructor(
46
56
  private readonly tui: TUI,
47
57
  private readonly theme: Theme,
48
58
  turns: readonly SideThreadTurn[],
49
59
  private readonly onAction: (action: TranscriptPagerAction) => void,
50
- options: { startAtBottom?: boolean; initialQuestion?: string } = {},
60
+ private readonly options: {
61
+ startAtBottom?: boolean;
62
+ initialQuestion?: string;
63
+ thinking?: BtwThinkingControl;
64
+ } = {},
51
65
  ) {
52
66
  this.transcriptComponents = buildTranscriptComponents(turns, this.theme);
53
67
  this.canBringToMain = turns.some((turn) => turn.kind === "answered");
54
68
  this.followBottom = options.startAtBottom ?? false;
69
+ this.thinkingLevel = options.thinking?.level;
55
70
  const editorTheme: EditorTheme = {
56
71
  borderColor: (text) => this.theme.fg("accent", text),
57
72
  selectList: {
@@ -102,7 +117,7 @@ export class BtwTranscriptPager implements Component {
102
117
  this.clampScrollOffset();
103
118
 
104
119
  return fitComposerLayout(
105
- renderSideThreadHeader(safeWidth, this.theme),
120
+ renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
106
121
  contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
107
122
  this.renderFooter(safeWidth),
108
123
  editorLines,
@@ -122,6 +137,22 @@ export class BtwTranscriptPager implements Component {
122
137
  this.onAction({ kind: "bringToMain", questionDraft: this.editor.getExpandedText() });
123
138
  return;
124
139
  }
140
+ const thinking = this.options.thinking;
141
+ if (
142
+ thinking &&
143
+ thinking.levels.length > 1 &&
144
+ thinking.keybindings.matches(data, "app.thinking.cycle")
145
+ ) {
146
+ const currentIndex = thinking.levels.indexOf(this.thinkingLevel ?? thinking.level);
147
+ const nextLevel = thinking.levels[(currentIndex + 1) % thinking.levels.length];
148
+ if (nextLevel) {
149
+ this.thinkingLevel = nextLevel;
150
+ thinking.onChange(nextLevel);
151
+ this.warning = undefined;
152
+ this.tui.requestRender();
153
+ }
154
+ return;
155
+ }
125
156
  if (matchesKey(data, Key.pageUp)) {
126
157
  const previousOffset = this.scrollOffset;
127
158
  this.scrollBy(-this.lastViewportHeight);
@@ -144,23 +175,38 @@ export class BtwTranscriptPager implements Component {
144
175
  this.editor.invalidate();
145
176
  }
146
177
 
178
+ dispose(): void {
179
+ if (this.finished) return;
180
+ this.finished = true;
181
+ this.onAction({ kind: "close" });
182
+ }
183
+
147
184
  private renderFooter(width: number): string {
148
185
  if (this.warning) {
149
186
  const warning = width < 32 ? "Empty • Ctrl+C" : `${this.warning} • Ctrl+C exit`;
150
187
  return truncateToWidth(this.theme.fg("warning", warning), width);
151
188
  }
152
189
  const scrollable = this.getMaxScrollOffset() > 0;
153
- const fullBase = this.canBringToMain
190
+ const thinking = this.options.thinking;
191
+ const cycleHint =
192
+ thinking && thinking.levels.length > 1 && this.thinkingLevel
193
+ ? ` • thinking ${this.thinkingLevel} • ${thinkingKeyLabel(thinking.keybindings)} cycle`
194
+ : "";
195
+ const base = this.canBringToMain
154
196
  ? "btw • Enter send • Ctrl+R bring to main • Ctrl+C exit"
155
197
  : "btw • Enter send • Ctrl+C exit";
198
+ const fullBase = `${base}${cycleHint}`;
156
199
  const fallbackBase = "btw • Enter • Ctrl+C";
157
200
  const compactBase = this.canBringToMain ? "btw • Enter • Ctrl+R • Ctrl+C" : fallbackBase;
201
+ const compactWithThinking = `${compactBase}${cycleHint}`;
158
202
  let hints =
159
203
  visibleWidth(fullBase) <= width
160
204
  ? fullBase
161
- : visibleWidth(compactBase) <= width
162
- ? compactBase
163
- : fallbackBase;
205
+ : visibleWidth(compactWithThinking) <= width
206
+ ? compactWithThinking
207
+ : visibleWidth(compactBase) <= width
208
+ ? compactBase
209
+ : fallbackBase;
164
210
  if (scrollable) {
165
211
  const history = ` • ${this.scrollOffset > 0 ? "↑ older" : "↓ newer"} • PgUp/PgDn history`;
166
212
  const compactHistory = " • PgUp/PgDn";
@@ -212,6 +258,7 @@ export class BtwAnsweringView implements Component {
212
258
  turns: readonly SideThreadTurn[],
213
259
  pendingQuestion: string,
214
260
  private readonly onCancel: () => void,
261
+ private readonly thinkingLevel?: BtwThinkingLevel,
215
262
  ) {
216
263
  this.transcriptComponents = buildTranscriptComponents(turns, this.theme, pendingQuestion);
217
264
  this.loader = new Loader(
@@ -239,7 +286,7 @@ export class BtwAnsweringView implements Component {
239
286
  const loaderWidth = Math.max(1, safeWidth - visibleWidth(cancelHint) - 3);
240
287
  const loaderLine = this.loader.render(loaderWidth).at(-1) ?? "Answering…";
241
288
  const lines = [
242
- renderSideThreadHeader(safeWidth, this.theme),
289
+ renderSideThreadHeader(safeWidth, this.theme, this.thinkingLevel),
243
290
  ...contentLines.slice(this.scrollOffset, this.scrollOffset + viewportHeight),
244
291
  truncateToWidth(`${loaderLine} • ${this.theme.fg("muted", cancelHint)}`, safeWidth),
245
292
  ];
@@ -278,8 +325,15 @@ export class BtwAnsweringView implements Component {
278
325
  }
279
326
 
280
327
  dispose(): void {
281
- this.finish();
328
+ if (this.finished) {
329
+ this.loader.stop();
330
+ this.controller.abort();
331
+ return;
332
+ }
333
+ this.finished = true;
334
+ this.loader.stop();
282
335
  this.controller.abort();
336
+ this.onCancel();
283
337
  }
284
338
 
285
339
  private scrollBy(delta: number): void {
@@ -350,12 +404,35 @@ function renderTranscriptLines(components: readonly Component[], width: number):
350
404
  .map(stripShellIntegrationMarkers);
351
405
  }
352
406
 
353
- function renderSideThreadHeader(width: number, theme: Theme): string {
354
- const title = truncateToWidth("─ btw · side thread ", width);
407
+ function renderSideThreadHeader(
408
+ width: number,
409
+ theme: Theme,
410
+ thinkingLevel?: BtwThinkingLevel,
411
+ ): string {
412
+ const thinking = thinkingLevel ? ` · thinking ${thinkingLevel}` : "";
413
+ const title = truncateToWidth(`─ btw · side thread${thinking} `, width);
355
414
  const ruleWidth = Math.max(0, width - visibleWidth(title));
356
415
  return theme.fg("muted", `${title}${"─".repeat(ruleWidth)}`);
357
416
  }
358
417
 
418
+ function thinkingKeyLabel(keybindings: KeybindingsManager): string {
419
+ const key =
420
+ sanitizeSingleLine(String(keybindings.getKeys("app.thinking.cycle")[0] ?? "shift+tab")) ||
421
+ "Shift+Tab";
422
+ return key
423
+ .split("+")
424
+ .map((part) => {
425
+ const lower = part.toLowerCase();
426
+ if (lower === "shift") return "Shift";
427
+ if (lower === "ctrl") return "Ctrl";
428
+ if (lower === "alt") return "Alt";
429
+ return part.length === 1
430
+ ? part.toUpperCase()
431
+ : `${part[0]?.toUpperCase() ?? ""}${part.slice(1)}`;
432
+ })
433
+ .join("+");
434
+ }
435
+
359
436
  function fitComposerLayout(
360
437
  header: string,
361
438
  contentLines: string[],