@narumitw/pi-btw 0.58.1 → 0.60.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/btw.ts CHANGED
@@ -1,466 +1,425 @@
1
1
  import {
2
- type Api,
3
- clampThinkingLevel,
4
- getSupportedThinkingLevels,
5
- type Model,
6
- type ProviderHeaders,
2
+ type Api,
3
+ clampThinkingLevel,
4
+ getSupportedThinkingLevels,
5
+ type Model,
6
+ type ProviderHeaders,
7
7
  } from "@earendil-works/pi-ai";
8
- import {
9
- BorderedLoader,
10
- type ExtensionAPI,
11
- type ExtensionCommandContext,
12
- } from "@earendil-works/pi-coding-agent";
8
+ import { BorderedLoader, type ExtensionAPI, type ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
13
9
  import type { MenuContext, RunMenuResult } from "@narumitw/pi-tui-kit";
14
10
  import {
15
- type BtwBringToMainSegment,
16
- type BtwBringToMainSummary,
17
- BtwTextRangeSelector,
18
- type BtwTextRangeSelectorState,
19
- buildQuickBringToMainSegments,
20
- estimateBringToMainTokens,
21
- formatBtwBringToMain,
22
- getAnsweredTurns,
23
- summarizeBringToMain,
11
+ type BtwBringToMainSegment,
12
+ type BtwBringToMainSummary,
13
+ BtwTextRangeSelector,
14
+ type BtwTextRangeSelectorState,
15
+ buildQuickBringToMainSegments,
16
+ estimateBringToMainTokens,
17
+ formatBtwBringToMain,
18
+ getAnsweredTurns,
19
+ summarizeBringToMain,
24
20
  } from "./bring-to-main.js";
25
21
  import { type RunBtwFullscreen, runBtwFullscreen } from "./fullscreen-ui.js";
26
22
  import { pickMainEntry } from "./main-tree-picker.js";
27
23
  import {
28
- type BtwCommandMenuResult,
29
- type BtwResumeThreadSummary,
30
- runBtwMenuPreservingEditor,
31
- showBtwCommandMenu,
32
- showBtwCustomPreservingEditor,
24
+ type BtwCommandMenuResult,
25
+ type BtwResumeThreadSummary,
26
+ runBtwMenuPreservingEditor,
27
+ showBtwCommandMenu,
28
+ showBtwCustomPreservingEditor,
33
29
  } from "./menu.js";
34
30
  import {
35
- type BtwSettings,
36
- effectiveFullscreenCopyOnSelect,
37
- effectiveRememberThinkingLevelChanges,
38
- parseBtwModelReference,
39
- readBtwSettings,
40
- updateBtwSettings,
31
+ type BtwSettings,
32
+ effectiveFullscreenCopyOnSelect,
33
+ effectiveRememberThinkingLevelChanges,
34
+ parseBtwModelReference,
35
+ readBtwSettings,
36
+ updateBtwSettings,
41
37
  } from "./settings.js";
42
38
  import {
43
- BTW_THINKING_LEVELS,
44
- type BtwThinkingLevel,
45
- type CompleteSimpleFunction,
46
- completeSideThreadTurn,
47
- createSideThread,
48
- type SideQuestionAuth,
49
- type SideThread,
39
+ BTW_THINKING_LEVELS,
40
+ type BtwThinkingLevel,
41
+ type CompleteSimpleFunction,
42
+ completeSideThreadTurn,
43
+ createSideThread,
44
+ type SideQuestionAuth,
45
+ type SideThread,
50
46
  } from "./side-thread.js";
51
47
  import { sanitizeSingleLine } from "./text.js";
48
+ import { type BtwMarkdownTransformers, prepareBtwTranscriptMarkdown } from "./transcript-markdown.js";
52
49
  import {
53
- BtwAnsweringView,
54
- type BtwThinkingControl,
55
- BtwTranscriptPager,
56
- type TranscriptPagerAction,
50
+ BtwAnsweringView,
51
+ type BtwThinkingControl,
52
+ BtwTranscriptPager,
53
+ type TranscriptPagerAction,
57
54
  } from "./transcript-pager.js";
58
55
 
59
56
  export {
60
- BTW_SETTINGS_FILE,
61
- type BtwSettings,
62
- type BtwSettingsLoadResult,
63
- normalizeBtwSettings,
64
- parseBtwModelReference,
65
- readBtwSettings,
57
+ BTW_SETTINGS_FILE,
58
+ type BtwSettings,
59
+ type BtwSettingsLoadResult,
60
+ normalizeBtwSettings,
61
+ parseBtwModelReference,
62
+ readBtwSettings,
66
63
  } from "./settings.js";
67
64
  export {
68
- BTW_THINKING_LEVELS,
69
- type BtwThinkingLevel,
70
- buildUserPrompt,
71
- completeSideQuestion,
65
+ BTW_THINKING_LEVELS,
66
+ type BtwThinkingLevel,
67
+ buildUserPrompt,
68
+ completeSideQuestion,
72
69
  } from "./side-thread.js";
73
70
  export { sanitizeSingleLine } from "./text.js";
74
71
 
75
72
  const MAX_CONTEXT_CHARS = 40_000;
76
73
 
77
74
  interface LoadBtwThinkingLevelOptions {
78
- settingsPath?: string;
79
- warn?: (message: string) => void;
75
+ settingsPath?: string;
76
+ warn?: (message: string) => void;
80
77
  }
81
78
 
82
- type BtwModelRegistry = Pick<
83
- ExtensionCommandContext["modelRegistry"],
84
- "find" | "getApiKeyAndHeaders"
85
- >;
79
+ type BtwModelRegistry = Pick<ExtensionCommandContext["modelRegistry"], "find" | "getApiKeyAndHeaders">;
86
80
 
87
81
  type BtwProviderRegistry = Pick<ExtensionCommandContext["modelRegistry"], "getProvider">;
88
82
 
89
- export function createModelRegistryCompleteSimple(
90
- modelRegistry: BtwProviderRegistry,
91
- ): CompleteSimpleFunction {
92
- return async (model, context, options) => {
93
- const provider = modelRegistry.getProvider(model.provider);
94
- if (!provider) throw new Error(`No provider registered for model provider: ${model.provider}`);
95
- return provider.streamSimple(model, context, options).result();
96
- };
83
+ export function createModelRegistryCompleteSimple(modelRegistry: BtwProviderRegistry): CompleteSimpleFunction {
84
+ return async (model, context, options) => {
85
+ const provider = modelRegistry.getProvider(model.provider);
86
+ if (!provider) throw new Error(`No provider registered for model provider: ${model.provider}`);
87
+ return provider.streamSimple(model, context, options).result();
88
+ };
97
89
  }
98
90
 
99
91
  interface ResolveBtwModelOptions {
100
- settings: BtwSettings;
101
- currentModel: Model<Api> | undefined;
102
- modelRegistry: BtwModelRegistry;
103
- warn?: (message: string) => void;
92
+ settings: BtwSettings;
93
+ currentModel: Model<Api> | undefined;
94
+ modelRegistry: BtwModelRegistry;
95
+ warn?: (message: string) => void;
104
96
  }
105
97
 
106
98
  export interface ResolvedBtwModel {
107
- model: Model<Api>;
108
- auth: SideQuestionAuth;
99
+ model: Model<Api>;
100
+ auth: SideQuestionAuth;
109
101
  }
110
102
 
111
103
  export interface BtwThreadState {
112
- id: string;
113
- title?: string;
114
- thread: SideThread;
115
- thinkingLevel: BtwThinkingLevel;
116
- createdAt: number;
117
- updatedAt: number;
104
+ id: string;
105
+ title?: string;
106
+ thread: SideThread;
107
+ thinkingLevel: BtwThinkingLevel;
108
+ createdAt: number;
109
+ updatedAt: number;
118
110
  }
119
111
 
120
112
  export async function resolveBtwModel({
121
- settings,
122
- currentModel,
123
- modelRegistry,
124
- warn,
113
+ settings,
114
+ currentModel,
115
+ modelRegistry,
116
+ warn,
125
117
  }: ResolveBtwModelOptions): Promise<ResolvedBtwModel | undefined> {
126
- const reportWarning = (message: string) => warn?.(sanitizeSingleLine(message));
127
- if (settings.model) {
128
- const fallback = currentModel
129
- ? `${currentModel.provider}/${currentModel.id}`
130
- : "the current model";
131
- const reference = parseBtwModelReference(settings.model);
132
- if (!reference) {
133
- reportWarning(`pi-btw model ${settings.model} is invalid; falling back to ${fallback}.`);
134
- return resolveBtwModel({ settings: {}, currentModel, modelRegistry, warn: reportWarning });
135
- }
136
- const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
137
- if (!configuredModel) {
138
- reportWarning(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
139
- } else {
140
- const sameAsCurrent =
141
- configuredModel === currentModel ||
142
- (configuredModel.provider === currentModel?.provider &&
143
- configuredModel.id === currentModel.id);
144
- const fallbackAction = sameAsCurrent
145
- ? "no distinct current model is available"
146
- : `falling back to ${fallback}`;
147
- try {
148
- const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
149
- if (auth.ok && hasRequestAuth(auth)) {
150
- return {
151
- model: auth.baseUrl ? { ...configuredModel, baseUrl: auth.baseUrl } : configuredModel,
152
- auth,
153
- };
154
- }
155
- const reason = auth.ok ? "has no request credentials" : auth.error;
156
- reportWarning(
157
- `pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`,
158
- );
159
- } catch (error: unknown) {
160
- reportWarning(
161
- `pi-btw model ${settings.model} credentials failed (${formatError(error)}); ${fallbackAction}.`,
162
- );
163
- }
164
- if (sameAsCurrent) return undefined;
165
- }
166
- }
167
-
168
- if (!currentModel) return undefined;
169
- try {
170
- const auth = await modelRegistry.getApiKeyAndHeaders(currentModel);
171
- if (auth.ok && hasRequestAuth(auth)) {
172
- // Direct provider streams bypass Pi's request preparation, including OAuth routing.
173
- return {
174
- model: auth.baseUrl ? { ...currentModel, baseUrl: auth.baseUrl } : currentModel,
175
- auth,
176
- };
177
- }
178
- } catch {
179
- // The caller reports the final lack of an available model.
180
- }
181
- return undefined;
118
+ const reportWarning = (message: string) => warn?.(sanitizeSingleLine(message));
119
+ if (settings.model) {
120
+ const fallback = currentModel ? `${currentModel.provider}/${currentModel.id}` : "the current model";
121
+ const reference = parseBtwModelReference(settings.model);
122
+ if (!reference) {
123
+ reportWarning(`pi-btw model ${settings.model} is invalid; falling back to ${fallback}.`);
124
+ return resolveBtwModel({ settings: {}, currentModel, modelRegistry, warn: reportWarning });
125
+ }
126
+ const configuredModel = modelRegistry.find(reference.provider, reference.modelId);
127
+ if (!configuredModel) {
128
+ reportWarning(`pi-btw model ${settings.model} was not found; falling back to ${fallback}.`);
129
+ } else {
130
+ const sameAsCurrent =
131
+ configuredModel === currentModel ||
132
+ (configuredModel.provider === currentModel?.provider && configuredModel.id === currentModel.id);
133
+ const fallbackAction = sameAsCurrent ? "no distinct current model is available" : `falling back to ${fallback}`;
134
+ try {
135
+ const auth = await modelRegistry.getApiKeyAndHeaders(configuredModel);
136
+ if (auth.ok && hasRequestAuth(auth)) {
137
+ return {
138
+ model: auth.baseUrl ? { ...configuredModel, baseUrl: auth.baseUrl } : configuredModel,
139
+ auth,
140
+ };
141
+ }
142
+ const reason = auth.ok ? "has no request credentials" : auth.error;
143
+ reportWarning(`pi-btw model ${settings.model} is unavailable (${reason}); ${fallbackAction}.`);
144
+ } catch (error: unknown) {
145
+ reportWarning(`pi-btw model ${settings.model} credentials failed (${formatError(error)}); ${fallbackAction}.`);
146
+ }
147
+ if (sameAsCurrent) return undefined;
148
+ }
149
+ }
150
+
151
+ if (!currentModel) return undefined;
152
+ try {
153
+ const auth = await modelRegistry.getApiKeyAndHeaders(currentModel);
154
+ if (auth.ok && hasRequestAuth(auth)) {
155
+ // Direct provider streams bypass Pi's request preparation, including OAuth routing.
156
+ return {
157
+ model: auth.baseUrl ? { ...currentModel, baseUrl: auth.baseUrl } : currentModel,
158
+ auth,
159
+ };
160
+ }
161
+ } catch {
162
+ // The caller reports the final lack of an available model.
163
+ }
164
+ return undefined;
182
165
  }
183
166
 
184
167
  function hasRequestAuth(auth: SideQuestionAuth): boolean {
185
- return Boolean(
186
- auth.apiKey ||
187
- providerHeadersHaveValue(auth.headers) ||
188
- (auth.env && Object.keys(auth.env).length > 0),
189
- );
168
+ return Boolean(
169
+ auth.apiKey || providerHeadersHaveValue(auth.headers) || (auth.env && Object.keys(auth.env).length > 0),
170
+ );
190
171
  }
191
172
 
192
173
  function providerHeadersHaveValue(headers: ProviderHeaders | undefined): boolean {
193
- return headers !== undefined && Object.values(headers).some((value) => value !== null);
174
+ return headers !== undefined && Object.values(headers).some((value) => value !== null);
194
175
  }
195
176
 
196
177
  export async function loadBtwThinkingLevel(
197
- currentThinkingLevel: BtwThinkingLevel,
198
- options: LoadBtwThinkingLevelOptions = {},
178
+ currentThinkingLevel: BtwThinkingLevel,
179
+ options: LoadBtwThinkingLevelOptions = {},
199
180
  ): Promise<BtwThinkingLevel> {
200
- const settings = await readBtwSettings(options.settingsPath);
201
- if (settings.kind === "missing") return currentThinkingLevel;
202
- if (settings.kind === "loaded") {
203
- return settings.settings.thinkingLevel ?? currentThinkingLevel;
204
- }
205
-
206
- options.warn?.(
207
- sanitizeSingleLine(
208
- `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", omitted thinkingLevel for Same as main thread or thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", boolean rememberThinkingLevelChanges, and boolean fullscreenCopyOnSelect. Using current Pi thinking level.`,
209
- ),
210
- );
211
- return currentThinkingLevel;
181
+ const settings = await readBtwSettings(options.settingsPath);
182
+ if (settings.kind === "missing") return currentThinkingLevel;
183
+ if (settings.kind === "loaded") {
184
+ return settings.settings.thinkingLevel ?? currentThinkingLevel;
185
+ }
186
+
187
+ options.warn?.(
188
+ sanitizeSingleLine(
189
+ `pi-btw settings ignored: ${settings.reason}; expected optional model "provider/model-id", omitted thinkingLevel for Same as main thread or thinkingLevel "${BTW_THINKING_LEVELS.join('" | "')}", boolean rememberThinkingLevelChanges, and boolean fullscreenCopyOnSelect. Using current Pi thinking level.`,
190
+ ),
191
+ );
192
+ return currentThinkingLevel;
212
193
  }
213
194
 
214
195
  function formatError(error: unknown): string {
215
- return error instanceof Error ? error.message : String(error);
196
+ return error instanceof Error ? error.message : String(error);
216
197
  }
217
198
 
218
199
  function readBtwSessionId(ctx: ExtensionCommandContext): string | undefined {
219
- const getSessionId = ctx.sessionManager.getSessionId;
220
- if (typeof getSessionId !== "function") return undefined;
221
- const sessionId = getSessionId.call(ctx.sessionManager);
222
- return sessionId.length > 0 ? sessionId : undefined;
200
+ const getSessionId = ctx.sessionManager.getSessionId;
201
+ if (typeof getSessionId !== "function") return undefined;
202
+ const sessionId = getSessionId.call(ctx.sessionManager);
203
+ return sessionId.length > 0 ? sessionId : undefined;
223
204
  }
224
205
 
225
206
  function notifySafely(
226
- ctx: ExtensionCommandContext,
227
- message: string,
228
- level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
207
+ ctx: ExtensionCommandContext,
208
+ message: string,
209
+ level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
229
210
  ): void {
230
- try {
231
- ctx.ui.notify(sanitizeSingleLine(message), level);
232
- } catch {
233
- // Async command continuations may finish after their ExtensionContext is replaced.
234
- }
211
+ try {
212
+ ctx.ui.notify(sanitizeSingleLine(message), level);
213
+ } catch {
214
+ // Async command continuations may finish after their ExtensionContext is replaced.
215
+ }
235
216
  }
236
217
 
237
218
  // Keep this slightly-over-1,000-line command coordinator intact: its injectable menu,
238
219
  // request, resume, and delivery flows share the same thread-state and test seams;
239
220
  // settings, keybinding policy, terminal ownership, and rendering live in separate modules.
240
221
  export interface BtwExtensionDependencies {
241
- showCommandMenu?: (
242
- pi: ExtensionAPI,
243
- ctx: ExtensionCommandContext,
244
- resumeThreads: readonly BtwResumeThreadSummary[],
245
- ) => Promise<BtwCommandMenuResult>;
246
- pickMainEntry?: typeof pickMainEntry;
247
- loadSettings?: typeof loadSettingsForCommand;
248
- resolveModel?: typeof resolveBtwModelWithLoader;
249
- runThread?: typeof runBtwThread;
250
- runFullscreen?: RunBtwFullscreen;
222
+ showCommandMenu?: (
223
+ pi: ExtensionAPI,
224
+ ctx: ExtensionCommandContext,
225
+ resumeThreads: readonly BtwResumeThreadSummary[],
226
+ ) => Promise<BtwCommandMenuResult>;
227
+ pickMainEntry?: typeof pickMainEntry;
228
+ loadSettings?: typeof loadSettingsForCommand;
229
+ resolveModel?: typeof resolveBtwModelWithLoader;
230
+ runThread?: typeof runBtwThread;
231
+ runFullscreen?: RunBtwFullscreen;
251
232
  }
252
233
 
253
234
  export default function btw(pi: ExtensionAPI, dependencies: BtwExtensionDependencies = {}) {
254
- const showCommandMenu = dependencies.showCommandMenu ?? showCommandMenuForBtw;
255
- const pickEntry = dependencies.pickMainEntry ?? pickMainEntry;
256
- const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
257
- const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
258
- const runThread = dependencies.runThread ?? runBtwThread;
259
- const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
260
- // Pi creates a fresh extension instance after session replacement or reload.
261
- const resumableThreads = new Map<string, BtwThreadState>();
262
- let nextThreadNumber = 1;
263
- const listResumeThreads = (): BtwResumeThreadSummary[] =>
264
- [...resumableThreads.values()]
265
- .reverse()
266
- .filter((state) => state.thread.turns.length > 0 && state.title)
267
- .sort(
268
- (first, second) => second.updatedAt - first.updatedAt || second.createdAt - first.createdAt,
269
- )
270
- .map((state) => ({
271
- id: state.id,
272
- title: state.title ?? "Untitled side thread",
273
- questionCount: state.thread.turns.length,
274
- }));
275
- pi.registerCommand("btw", {
276
- description: "Ask a quick side question without adding it to the main conversation",
277
- handler: async (args, ctx) => {
278
- const question = args.trim();
279
- if (ctx.mode !== "tui") {
280
- ctx.ui.notify("/btw requires interactive TUI mode", "error");
281
- return;
282
- }
283
-
284
- let menuResult: BtwCommandMenuResult = "start";
285
- let selectedConversationContext: string | undefined;
286
- if (!question) {
287
- while (true) {
288
- menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
289
- if (menuResult === "closed") return;
290
- if (menuResult !== "tree") break;
291
-
292
- const treeResult = await pickEntry(pi, ctx);
293
- if (treeResult.kind === "closed") return;
294
- if (treeResult.kind === "back") continue;
295
- try {
296
- if (!ctx.sessionManager.getEntry(treeResult.entryId)) {
297
- notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
298
- continue;
299
- }
300
- const branch = ctx.sessionManager.getBranch(treeResult.entryId);
301
- if (branch.at(-1)?.id !== treeResult.entryId) {
302
- notifySafely(
303
- ctx,
304
- "The selected main-thread branch is no longer available",
305
- "warning",
306
- );
307
- continue;
308
- }
309
- selectedConversationContext = buildConversationContext(branch);
310
- menuResult = "start";
311
- break;
312
- } catch {
313
- return;
314
- }
315
- }
316
- }
317
-
318
- const settings = await loadSettings(ctx);
319
- const sameAsMainThinkingLevel = settings.thinkingLevel === undefined;
320
- const resolution = await resolveModel(settings, ctx);
321
- if (resolution.kind === "cancelled") {
322
- notifySafely(ctx, "Cancelled", "info");
323
- return;
324
- }
325
- if (resolution.kind === "unavailable") {
326
- notifySafely(ctx, "No available model for /btw", "error");
327
- return;
328
- }
329
-
330
- let state =
331
- typeof menuResult === "object" ? resumableThreads.get(menuResult.threadId) : undefined;
332
- if (typeof menuResult === "object" && !state) {
333
- notifySafely(ctx, "The selected /btw side thread is no longer available", "warning");
334
- return;
335
- }
336
- const startingTurnCount = state?.thread.turns.length ?? 0;
337
-
338
- try {
339
- await runFullscreen(
340
- ctx,
341
- (fullscreenCtx) => {
342
- if (!state) {
343
- const createdAt = Date.now();
344
- state = {
345
- id: `btw-${nextThreadNumber}`,
346
- thread: createSideThread(
347
- selectedConversationContext ??
348
- buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
349
- ),
350
- thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
351
- createdAt,
352
- updatedAt: createdAt,
353
- };
354
- nextThreadNumber += 1;
355
- }
356
- return runThread({
357
- initialQuestion: question || undefined,
358
- selected: resolution.selected,
359
- thinkingLevel: state.thinkingLevel,
360
- rememberThinkingLevelChanges:
361
- !sameAsMainThinkingLevel && effectiveRememberThinkingLevelChanges(settings),
362
- state,
363
- ctx: fullscreenCtx,
364
- });
365
- },
366
- {
367
- copyOnSelect: effectiveFullscreenCopyOnSelect(settings),
368
- ...(settings.keybindings ? { keybindings: settings.keybindings } : {}),
369
- },
370
- );
371
- } finally {
372
- if (state?.title && state.thread.turns.length > 0) {
373
- if (state.thread.turns.length > startingTurnCount) {
374
- resumableThreads.delete(state.id);
375
- }
376
- resumableThreads.set(state.id, state);
377
- }
378
- }
379
- },
380
- });
235
+ const showCommandMenu = dependencies.showCommandMenu ?? showCommandMenuForBtw;
236
+ const pickEntry = dependencies.pickMainEntry ?? pickMainEntry;
237
+ const loadSettings = dependencies.loadSettings ?? loadSettingsForCommand;
238
+ const resolveModel = dependencies.resolveModel ?? resolveBtwModelWithLoader;
239
+ const runThread = dependencies.runThread ?? runBtwThread;
240
+ const runFullscreen = dependencies.runFullscreen ?? runBtwFullscreen;
241
+ // Pi creates a fresh extension instance after session replacement or reload.
242
+ const resumableThreads = new Map<string, BtwThreadState>();
243
+ let nextThreadNumber = 1;
244
+ const listResumeThreads = (): BtwResumeThreadSummary[] =>
245
+ [...resumableThreads.values()]
246
+ .reverse()
247
+ .filter((state) => state.thread.turns.length > 0 && state.title)
248
+ .sort((first, second) => second.updatedAt - first.updatedAt || second.createdAt - first.createdAt)
249
+ .map((state) => ({
250
+ id: state.id,
251
+ title: state.title ?? "Untitled side thread",
252
+ questionCount: state.thread.turns.length,
253
+ }));
254
+ pi.registerCommand("btw", {
255
+ description: "Ask a quick side question without adding it to the main conversation",
256
+ handler: async (args, ctx) => {
257
+ const question = args.trim();
258
+ if (ctx.mode !== "tui") {
259
+ ctx.ui.notify("/btw requires interactive TUI mode", "error");
260
+ return;
261
+ }
262
+
263
+ let menuResult: BtwCommandMenuResult = "start";
264
+ let selectedConversationContext: string | undefined;
265
+ if (!question) {
266
+ while (true) {
267
+ menuResult = await showCommandMenu(pi, ctx, listResumeThreads());
268
+ if (menuResult === "closed") return;
269
+ if (menuResult !== "tree") break;
270
+
271
+ const treeResult = await pickEntry(pi, ctx);
272
+ if (treeResult.kind === "closed") return;
273
+ if (treeResult.kind === "back") continue;
274
+ try {
275
+ if (!ctx.sessionManager.getEntry(treeResult.entryId)) {
276
+ notifySafely(ctx, "The selected main-thread entry is no longer available", "warning");
277
+ continue;
278
+ }
279
+ const branch = ctx.sessionManager.getBranch(treeResult.entryId);
280
+ if (branch.at(-1)?.id !== treeResult.entryId) {
281
+ notifySafely(ctx, "The selected main-thread branch is no longer available", "warning");
282
+ continue;
283
+ }
284
+ selectedConversationContext = buildConversationContext(branch);
285
+ menuResult = "start";
286
+ break;
287
+ } catch {
288
+ return;
289
+ }
290
+ }
291
+ }
292
+
293
+ const settings = await loadSettings(ctx);
294
+ const sameAsMainThinkingLevel = settings.thinkingLevel === undefined;
295
+ const resolution = await resolveModel(settings, ctx);
296
+ if (resolution.kind === "cancelled") {
297
+ notifySafely(ctx, "Cancelled", "info");
298
+ return;
299
+ }
300
+ if (resolution.kind === "unavailable") {
301
+ notifySafely(ctx, "No available model for /btw", "error");
302
+ return;
303
+ }
304
+
305
+ let state = typeof menuResult === "object" ? resumableThreads.get(menuResult.threadId) : undefined;
306
+ if (typeof menuResult === "object" && !state) {
307
+ notifySafely(ctx, "The selected /btw side thread is no longer available", "warning");
308
+ return;
309
+ }
310
+ const startingTurnCount = state?.thread.turns.length ?? 0;
311
+
312
+ try {
313
+ await runFullscreen(
314
+ ctx,
315
+ (fullscreenCtx) => {
316
+ if (!state) {
317
+ const createdAt = Date.now();
318
+ state = {
319
+ id: `btw-${nextThreadNumber}`,
320
+ thread: createSideThread(
321
+ selectedConversationContext ?? buildConversationContext(fullscreenCtx.sessionManager.getBranch()),
322
+ ),
323
+ thinkingLevel: settings.thinkingLevel ?? pi.getThinkingLevel(),
324
+ createdAt,
325
+ updatedAt: createdAt,
326
+ };
327
+ nextThreadNumber += 1;
328
+ }
329
+ return runThread({
330
+ initialQuestion: question || undefined,
331
+ selected: resolution.selected,
332
+ thinkingLevel: state.thinkingLevel,
333
+ rememberThinkingLevelChanges: !sameAsMainThinkingLevel && effectiveRememberThinkingLevelChanges(settings),
334
+ state,
335
+ ctx: fullscreenCtx,
336
+ });
337
+ },
338
+ {
339
+ copyOnSelect: effectiveFullscreenCopyOnSelect(settings),
340
+ ...(settings.keybindings ? { keybindings: settings.keybindings } : {}),
341
+ },
342
+ );
343
+ } finally {
344
+ if (state?.title && state.thread.turns.length > 0) {
345
+ if (state.thread.turns.length > startingTurnCount) {
346
+ resumableThreads.delete(state.id);
347
+ }
348
+ resumableThreads.set(state.id, state);
349
+ }
350
+ }
351
+ },
352
+ });
381
353
  }
382
354
 
383
355
  async function showCommandMenuForBtw(
384
- pi: ExtensionAPI,
385
- ctx: ExtensionCommandContext,
386
- resumeThreads: readonly BtwResumeThreadSummary[],
356
+ pi: ExtensionAPI,
357
+ ctx: ExtensionCommandContext,
358
+ resumeThreads: readonly BtwResumeThreadSummary[],
387
359
  ): Promise<BtwCommandMenuResult> {
388
- const currentModel = ctx.model;
389
- const availableModels = ctx.modelRegistry.getAll();
390
- const currentThinkingLevel = pi.getThinkingLevel();
391
- const loaded = await readBtwSettings();
392
- const settings = loaded.kind === "loaded" ? loaded.settings : {};
393
- const configured = settings.model ? parseBtwModelReference(settings.model) : undefined;
394
- const configuredModel = configured
395
- ? availableModels.find(
396
- (model) => model.provider === configured.provider && model.id === configured.modelId,
397
- )
398
- : undefined;
399
- const model = configuredModel ?? currentModel;
400
- return showBtwCommandMenu(ctx, {
401
- currentThinkingLevel,
402
- availableThinkingLevels: model ? getSupportedThinkingLevels(model) : BTW_THINKING_LEVELS,
403
- resumeThreads,
404
- });
360
+ return showBtwCommandMenu(ctx, {
361
+ currentThinkingLevel: pi.getThinkingLevel(),
362
+ resumeThreads,
363
+ });
405
364
  }
406
365
 
407
366
  async function loadSettingsForCommand(ctx: ExtensionCommandContext): Promise<BtwSettings> {
408
- const settingsResult = await readBtwSettings();
409
- if (settingsResult.kind === "loaded") return settingsResult.settings;
410
- if (settingsResult.kind === "invalid") {
411
- notifySafely(ctx, `pi-btw settings ignored: ${settingsResult.reason}`, "warning");
412
- }
413
- return {};
367
+ const settingsResult = await readBtwSettings();
368
+ if (settingsResult.kind === "loaded") return settingsResult.settings;
369
+ if (settingsResult.kind === "invalid") {
370
+ notifySafely(ctx, `pi-btw settings ignored: ${settingsResult.reason}`, "warning");
371
+ }
372
+ return {};
414
373
  }
415
374
 
416
375
  type ModelResolutionOutcome =
417
- | { kind: "cancelled" }
418
- | { kind: "unavailable" }
419
- | { kind: "selected"; selected: ResolvedBtwModel };
376
+ | { kind: "cancelled" }
377
+ | { kind: "unavailable" }
378
+ | { kind: "selected"; selected: ResolvedBtwModel };
420
379
 
421
380
  async function resolveBtwModelWithLoader(
422
- settings: BtwSettings,
423
- ctx: ExtensionCommandContext,
381
+ settings: BtwSettings,
382
+ ctx: ExtensionCommandContext,
424
383
  ): Promise<ModelResolutionOutcome> {
425
- return ctx.ui.custom<ModelResolutionOutcome>((tui, theme, _keybindings, done) => {
426
- const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
427
- let settled = false;
428
- loader.onAbort = () => {
429
- if (settled) return;
430
- settled = true;
431
- done({ kind: "cancelled" });
432
- };
433
-
434
- resolveBtwModel({
435
- settings,
436
- currentModel: ctx.model,
437
- modelRegistry: ctx.modelRegistry,
438
- warn: (message) => {
439
- if (!settled) notifySafely(ctx, message, "warning");
440
- },
441
- })
442
- .then((selected) => {
443
- if (settled) return;
444
- settled = true;
445
- done(selected ? { kind: "selected", selected } : { kind: "unavailable" });
446
- })
447
- .catch(() => {
448
- if (settled) return;
449
- settled = true;
450
- done({ kind: "unavailable" });
451
- });
452
-
453
- return loader;
454
- });
384
+ return ctx.ui.custom<ModelResolutionOutcome>((tui, theme, _keybindings, done) => {
385
+ const loader = new BorderedLoader(tui, theme, "Resolving /btw model credentials...");
386
+ let settled = false;
387
+ loader.onAbort = () => {
388
+ if (settled) return;
389
+ settled = true;
390
+ done({ kind: "cancelled" });
391
+ };
392
+
393
+ resolveBtwModel({
394
+ settings,
395
+ currentModel: ctx.model,
396
+ modelRegistry: ctx.modelRegistry,
397
+ warn: (message) => {
398
+ if (!settled) notifySafely(ctx, message, "warning");
399
+ },
400
+ })
401
+ .then((selected) => {
402
+ if (settled) return;
403
+ settled = true;
404
+ done(selected ? { kind: "selected", selected } : { kind: "unavailable" });
405
+ })
406
+ .catch(() => {
407
+ if (settled) return;
408
+ settled = true;
409
+ done({ kind: "unavailable" });
410
+ });
411
+
412
+ return loader;
413
+ });
455
414
  }
456
415
 
457
416
  interface RunBtwThreadDependencies {
458
- ask?: typeof askThreadQuestion;
459
- interact?: typeof showThreadComposer;
460
- chooseBringToMain?: typeof chooseBringToMain;
461
- deliverBringToMain?: typeof loadBringToMainDraft;
462
- persistThinkingLevel?: (level: BtwThinkingLevel) => Promise<unknown>;
463
- now?: () => number;
417
+ ask?: typeof askThreadQuestion;
418
+ interact?: typeof showThreadComposer;
419
+ chooseBringToMain?: typeof chooseBringToMain;
420
+ deliverBringToMain?: typeof loadBringToMainDraft;
421
+ persistThinkingLevel?: (level: BtwThinkingLevel) => Promise<unknown>;
422
+ now?: () => number;
464
423
  }
465
424
 
466
425
  export type BtwThreadResult = { kind: "closed" };
@@ -468,557 +427,545 @@ export type BtwThreadResult = { kind: "closed" };
468
427
  type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
469
428
 
470
429
  interface BtwThreadSteeringControl {
471
- questions: readonly string[];
472
- submit: (question: string) => void;
473
- thinking: BtwThreadThinkingControl;
430
+ questions: readonly string[];
431
+ submit: (question: string) => void;
432
+ thinking: BtwThreadThinkingControl;
474
433
  }
475
434
 
476
435
  type BtwBringToMainChoice =
477
- | BtwThreadResult
478
- | {
479
- kind: "bringToMain";
480
- draft: string;
481
- summary: BtwBringToMainSummary;
482
- selectionState?: BtwTextRangeSelectorState;
483
- }
484
- | { kind: "back" };
436
+ | BtwThreadResult
437
+ | {
438
+ kind: "bringToMain";
439
+ draft: string;
440
+ summary: BtwBringToMainSummary;
441
+ selectionState?: BtwTextRangeSelectorState;
442
+ }
443
+ | { kind: "back" };
485
444
 
486
445
  type BtwBringToMainDelivery = "loaded" | "back" | "closed";
487
446
 
488
447
  interface RunBtwThreadOptions {
489
- initialQuestion?: string;
490
- selected: ResolvedBtwModel;
491
- thinkingLevel: BtwThinkingLevel;
492
- rememberThinkingLevelChanges?: boolean;
493
- settingsPath?: string;
494
- state?: BtwThreadState;
495
- ctx: ExtensionCommandContext;
496
- dependencies?: RunBtwThreadDependencies;
448
+ initialQuestion?: string;
449
+ selected: ResolvedBtwModel;
450
+ thinkingLevel: BtwThinkingLevel;
451
+ rememberThinkingLevelChanges?: boolean;
452
+ settingsPath?: string;
453
+ state?: BtwThreadState;
454
+ ctx: ExtensionCommandContext;
455
+ dependencies?: RunBtwThreadDependencies;
497
456
  }
498
457
 
499
458
  export async function runBtwThread({
500
- initialQuestion,
501
- selected,
502
- thinkingLevel,
503
- rememberThinkingLevelChanges = false,
504
- settingsPath,
505
- state,
506
- ctx,
507
- dependencies = {},
459
+ initialQuestion,
460
+ selected,
461
+ thinkingLevel,
462
+ rememberThinkingLevelChanges = false,
463
+ settingsPath,
464
+ state,
465
+ ctx,
466
+ dependencies = {},
508
467
  }: RunBtwThreadOptions): Promise<BtwThreadResult> {
509
- const ask = dependencies.ask ?? askThreadQuestion;
510
- const interact = dependencies.interact ?? showThreadComposer;
511
- const chooseBringToMainAction = dependencies.chooseBringToMain ?? chooseBringToMain;
512
- const deliverBringToMainDraft = dependencies.deliverBringToMain ?? loadBringToMainDraft;
513
- const persistThinkingLevel =
514
- dependencies.persistThinkingLevel ??
515
- ((level: BtwThinkingLevel) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
516
- const now = dependencies.now ?? Date.now;
517
- const thread =
518
- state?.thread ?? createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
519
- const thinkingLevels = getSupportedThinkingLevels(selected.model);
520
- const pendingWrites = new Set<Promise<void>>();
521
- const steeringQuestions: string[] = [];
522
- let activeThinkingLevel = clampThinkingLevel(
523
- selected.model,
524
- state?.thinkingLevel ?? thinkingLevel,
525
- );
526
- if (state) state.thinkingLevel = activeThinkingLevel;
527
- let pendingQuestion = initialQuestion;
528
- let composerDraft: string | undefined;
529
- const createThinkingControl = (): BtwThreadThinkingControl => ({
530
- level: activeThinkingLevel,
531
- levels: thinkingLevels,
532
- onChange: (level) => {
533
- if (!thinkingLevels.includes(level)) return;
534
- activeThinkingLevel = level;
535
- if (state) state.thinkingLevel = level;
536
- if (!rememberThinkingLevelChanges) return;
537
- let write!: Promise<void>;
538
- write = Promise.resolve()
539
- .then(() => persistThinkingLevel(level))
540
- .then(() => undefined)
541
- .catch((error: unknown) => {
542
- notifySafely(
543
- ctx,
544
- `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
545
- "warning",
546
- );
547
- })
548
- .finally(() => pendingWrites.delete(write));
549
- pendingWrites.add(write);
550
- },
551
- });
552
-
553
- try {
554
- while (true) {
555
- if (!pendingQuestion) {
556
- const action = await interact(
557
- thread,
558
- thread.turns.length > 0,
559
- ctx,
560
- composerDraft,
561
- createThinkingControl(),
562
- );
563
- if (action.kind === "close") return { kind: "closed" };
564
- if (action.kind === "bringToMain") {
565
- const choice = await chooseBringToMainAction(thread, ctx);
566
- if (choice.kind === "closed") return choice;
567
- if (choice.kind === "back") {
568
- composerDraft = action.questionDraft;
569
- continue;
570
- }
571
- const delivery = await deliverBringToMainDraft(choice.draft, ctx, choice.summary);
572
- if (delivery === "loaded" || delivery === "closed") return { kind: "closed" };
573
- composerDraft = action.questionDraft;
574
- continue;
575
- }
576
- composerDraft = undefined;
577
- pendingQuestion = action.question;
578
- }
579
-
580
- const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx, {
581
- questions: steeringQuestions,
582
- submit: (question) => steeringQuestions.push(question),
583
- thinking: createThinkingControl(),
584
- });
585
- if (result.kind === "aborted") {
586
- notifySafely(ctx, "Cancelled", "info");
587
- return { kind: "closed" };
588
- }
589
- if (result.kind === "error") {
590
- thread.turns.push({
591
- kind: "error",
592
- question: pendingQuestion,
593
- answer: result.message,
594
- });
595
- }
596
- if (state) {
597
- state.title ||= sanitizeSingleLine(pendingQuestion) || "Untitled side thread";
598
- state.updatedAt = now();
599
- }
600
-
601
- pendingQuestion = steeringQuestions.shift();
602
- }
603
- } finally {
604
- await Promise.allSettled([...pendingWrites]);
605
- }
468
+ const ask = dependencies.ask ?? askThreadQuestion;
469
+ const interact = dependencies.interact ?? showThreadComposer;
470
+ const chooseBringToMainAction = dependencies.chooseBringToMain ?? chooseBringToMain;
471
+ const deliverBringToMainDraft = dependencies.deliverBringToMain ?? loadBringToMainDraft;
472
+ const persistThinkingLevel =
473
+ dependencies.persistThinkingLevel ??
474
+ ((level: BtwThinkingLevel) => updateBtwSettings({ thinkingLevel: level }, { settingsPath }));
475
+ const now = dependencies.now ?? Date.now;
476
+ const thread = state?.thread ?? createSideThread(buildConversationContext(ctx.sessionManager.getBranch()));
477
+ const thinkingLevels = getSupportedThinkingLevels(selected.model);
478
+ const pendingWrites = new Set<Promise<void>>();
479
+ const steeringQuestions: string[] = [];
480
+ let activeThinkingLevel = clampThinkingLevel(selected.model, state?.thinkingLevel ?? thinkingLevel);
481
+ if (state) state.thinkingLevel = activeThinkingLevel;
482
+ let pendingQuestion = initialQuestion;
483
+ let composerDraft: string | undefined;
484
+ const createThinkingControl = (): BtwThreadThinkingControl => ({
485
+ level: activeThinkingLevel,
486
+ levels: thinkingLevels,
487
+ onChange: (level) => {
488
+ if (!thinkingLevels.includes(level)) return;
489
+ activeThinkingLevel = level;
490
+ if (state) state.thinkingLevel = level;
491
+ if (!rememberThinkingLevelChanges) return;
492
+ let write!: Promise<void>;
493
+ write = Promise.resolve()
494
+ .then(() => persistThinkingLevel(level))
495
+ .then(() => undefined)
496
+ .catch((error: unknown) => {
497
+ notifySafely(
498
+ ctx,
499
+ `Thinking level changed to ${level}, but could not be remembered in pi-btw.json: ${formatError(error)}`,
500
+ "warning",
501
+ );
502
+ })
503
+ .finally(() => pendingWrites.delete(write));
504
+ pendingWrites.add(write);
505
+ },
506
+ });
507
+
508
+ try {
509
+ while (true) {
510
+ if (!pendingQuestion) {
511
+ const action = await interact(thread, thread.turns.length > 0, ctx, composerDraft, createThinkingControl());
512
+ if (action.kind === "close") return { kind: "closed" };
513
+ if (action.kind === "bringToMain") {
514
+ const choice = await chooseBringToMainAction(thread, ctx);
515
+ if (choice.kind === "closed") return choice;
516
+ if (choice.kind === "back") {
517
+ composerDraft = action.questionDraft;
518
+ continue;
519
+ }
520
+ const delivery = await deliverBringToMainDraft(choice.draft, ctx, choice.summary);
521
+ if (delivery === "loaded" || delivery === "closed") return { kind: "closed" };
522
+ composerDraft = action.questionDraft;
523
+ continue;
524
+ }
525
+ composerDraft = undefined;
526
+ pendingQuestion = action.question;
527
+ }
528
+
529
+ const result = await ask(thread, pendingQuestion, selected, activeThinkingLevel, ctx, {
530
+ questions: steeringQuestions,
531
+ submit: (question) => steeringQuestions.push(question),
532
+ thinking: createThinkingControl(),
533
+ });
534
+ if (result.kind === "aborted") {
535
+ notifySafely(ctx, "Cancelled", "info");
536
+ return { kind: "closed" };
537
+ }
538
+ if (result.kind === "error") {
539
+ thread.turns.push({
540
+ kind: "error",
541
+ question: pendingQuestion,
542
+ answer: result.message,
543
+ });
544
+ }
545
+ if (state) {
546
+ state.title ||= sanitizeSingleLine(pendingQuestion) || "Untitled side thread";
547
+ state.updatedAt = now();
548
+ }
549
+
550
+ pendingQuestion = steeringQuestions.shift();
551
+ }
552
+ } finally {
553
+ await Promise.allSettled([...pendingWrites]);
554
+ }
606
555
  }
607
556
 
608
557
  interface ChooseBringToMainDependencies {
609
- showMenu?: typeof showBtwMenu;
610
- showPreview?: typeof showBringToMainPreview;
558
+ showMenu?: typeof showBtwMenu;
559
+ showPreview?: typeof showBringToMainPreview;
611
560
  }
612
561
 
613
562
  export async function chooseBringToMain(
614
- thread: SideThread,
615
- ctx: ExtensionCommandContext,
616
- dependencies: ChooseBringToMainDependencies = {},
563
+ thread: SideThread,
564
+ ctx: ExtensionCommandContext,
565
+ dependencies: ChooseBringToMainDependencies = {},
617
566
  ): Promise<BtwBringToMainChoice> {
618
- const answered = getAnsweredTurns(thread.turns);
619
- if (answered.length === 0) return { kind: "back" };
620
- const showMenu = dependencies.showMenu ?? showBtwMenu;
621
- const showPreview = dependencies.showPreview ?? showBringToMainPreview;
622
- const makeChoice = (segments: readonly BtwBringToMainSegment[]) => ({
623
- kind: "bringToMain" as const,
624
- draft: formatBtwBringToMain(segments),
625
- summary: summarizeBringToMain(segments),
626
- });
627
-
628
- const latestSegments = buildQuickBringToMainSegments(thread.turns, { kind: "latest" });
629
- const entireSegments = buildQuickBringToMainSegments(thread.turns, { kind: "entire" });
630
- const latestOption = `Latest question and answer 1 Q&A · ~${estimateBringToMainTokens(latestSegments)} tokens`;
631
- const fromOption = "From a question onward… Choose a starting question";
632
- const exactOption = "Select exact text… Lines or characters";
633
- const entireOption = `Entire side thread ${answered.length} Q&A · ~${estimateBringToMainTokens(entireSegments)} tokens`;
634
- const cancelOption = "Cancel Return to the side thread";
635
- let selectedScope: string | undefined;
636
-
637
- while (true) {
638
- const scopeResult = await showMenu(
639
- ctx,
640
- "Bring what back to the main thread?",
641
- [latestOption, fromOption, exactOption, entireOption, cancelOption],
642
- selectedScope,
643
- );
644
- if (scopeResult.kind === "close") return { kind: "closed" };
645
- if (scopeResult.kind === "back" || scopeResult.value === cancelOption) return { kind: "back" };
646
- const scope = scopeResult.value;
647
- selectedScope = scope;
648
- if (scope === latestOption) return makeChoice(latestSegments);
649
- if (scope === entireOption) {
650
- const choice = makeChoice(entireSegments);
651
- const preview = await showPreview(ctx, choice.draft, choice.summary);
652
- if (preview.kind === "close") return { kind: "closed" };
653
- if (preview.kind === "back") continue;
654
- return choice;
655
- }
656
- if (scope === fromOption) {
657
- const questions = answered.map(
658
- (turn, index) => `${index + 1}. ${truncatePreview(sanitizeSingleLine(turn.question))}`,
659
- );
660
- let selectedQuestion: string | undefined;
661
- while (true) {
662
- const questionResult = await showMenu(
663
- ctx,
664
- "Start from which question?",
665
- questions,
666
- selectedQuestion,
667
- );
668
- if (questionResult.kind === "close") return { kind: "closed" };
669
- if (questionResult.kind === "back") break;
670
- const answeredTurnIndex = questions.indexOf(questionResult.value);
671
- if (answeredTurnIndex < 0) continue;
672
- selectedQuestion = questionResult.value;
673
- const choice = makeChoice(
674
- buildQuickBringToMainSegments(thread.turns, { kind: "from", answeredTurnIndex }),
675
- );
676
- const preview = await showPreview(ctx, choice.draft, choice.summary);
677
- if (preview.kind === "close") return { kind: "closed" };
678
- if (preview.kind === "back") continue;
679
- return choice;
680
- }
681
- continue;
682
- }
683
-
684
- if (scope !== exactOption) continue;
685
- let selectionState: BtwTextRangeSelectorState | undefined;
686
- while (true) {
687
- const selectedRange = await showBtwCustomPreservingEditor<BtwBringToMainChoice>(
688
- ctx,
689
- (tui, theme, keybindings, done) => {
690
- let selector: BtwTextRangeSelector;
691
- selector = new BtwTextRangeSelector(
692
- tui,
693
- theme,
694
- keybindings,
695
- thread.turns,
696
- (action) => {
697
- if (action.kind === "back") done({ kind: "back" });
698
- else if (action.kind === "close") done({ kind: "closed" });
699
- else done({ ...makeChoice(action.segments), selectionState: selector.getState() });
700
- },
701
- selectionState,
702
- );
703
- return selector;
704
- },
705
- );
706
- if (!selectedRange) return { kind: "closed" };
707
- if (selectedRange.kind === "closed") return selectedRange;
708
- if (selectedRange.kind === "back") break;
709
- const preview = await showPreview(ctx, selectedRange.draft, selectedRange.summary);
710
- if (preview.kind === "close") return { kind: "closed" };
711
- if (preview.kind === "back") {
712
- selectionState = selectedRange.selectionState;
713
- continue;
714
- }
715
- return {
716
- kind: "bringToMain",
717
- draft: selectedRange.draft,
718
- summary: selectedRange.summary,
719
- };
720
- }
721
- }
722
- }
723
-
724
- type BtwMenuSelectorAction =
725
- | { kind: "select"; value: string }
726
- | { kind: "back" }
727
- | { kind: "close" };
567
+ const answered = getAnsweredTurns(thread.turns);
568
+ if (answered.length === 0) return { kind: "back" };
569
+ const showMenu = dependencies.showMenu ?? showBtwMenu;
570
+ const showPreview = dependencies.showPreview ?? showBringToMainPreview;
571
+ const makeChoice = (segments: readonly BtwBringToMainSegment[]) => ({
572
+ kind: "bringToMain" as const,
573
+ draft: formatBtwBringToMain(segments),
574
+ summary: summarizeBringToMain(segments),
575
+ });
576
+
577
+ const latestSegments = buildQuickBringToMainSegments(thread.turns, { kind: "latest" });
578
+ const entireSegments = buildQuickBringToMainSegments(thread.turns, { kind: "entire" });
579
+ const latestOption = `Latest question and answer 1 Q&A · ~${estimateBringToMainTokens(latestSegments)} tokens`;
580
+ const fromOption = "From a question onward… Choose a starting question";
581
+ const exactOption = "Select exact text… Lines or characters";
582
+ const entireOption = `Entire side thread ${answered.length} Q&A · ~${estimateBringToMainTokens(entireSegments)} tokens`;
583
+ const cancelOption = "Cancel Return to the side thread";
584
+ let selectedScope: string | undefined;
585
+
586
+ while (true) {
587
+ const scopeResult = await showMenu(
588
+ ctx,
589
+ "Bring what back to the main thread?",
590
+ [latestOption, fromOption, exactOption, entireOption, cancelOption],
591
+ selectedScope,
592
+ );
593
+ if (scopeResult.kind === "close") return { kind: "closed" };
594
+ if (scopeResult.kind === "back" || scopeResult.value === cancelOption) return { kind: "back" };
595
+ const scope = scopeResult.value;
596
+ selectedScope = scope;
597
+ if (scope === latestOption) return makeChoice(latestSegments);
598
+ if (scope === entireOption) {
599
+ const choice = makeChoice(entireSegments);
600
+ const preview = await showPreview(ctx, choice.draft, choice.summary);
601
+ if (preview.kind === "close") return { kind: "closed" };
602
+ if (preview.kind === "back") continue;
603
+ return choice;
604
+ }
605
+ if (scope === fromOption) {
606
+ const questions = answered.map(
607
+ (turn, index) => `${index + 1}. ${truncatePreview(sanitizeSingleLine(turn.question))}`,
608
+ );
609
+ let selectedQuestion: string | undefined;
610
+ while (true) {
611
+ const questionResult = await showMenu(ctx, "Start from which question?", questions, selectedQuestion);
612
+ if (questionResult.kind === "close") return { kind: "closed" };
613
+ if (questionResult.kind === "back") break;
614
+ const answeredTurnIndex = questions.indexOf(questionResult.value);
615
+ if (answeredTurnIndex < 0) continue;
616
+ selectedQuestion = questionResult.value;
617
+ const choice = makeChoice(buildQuickBringToMainSegments(thread.turns, { kind: "from", answeredTurnIndex }));
618
+ const preview = await showPreview(ctx, choice.draft, choice.summary);
619
+ if (preview.kind === "close") return { kind: "closed" };
620
+ if (preview.kind === "back") continue;
621
+ return choice;
622
+ }
623
+ continue;
624
+ }
625
+
626
+ if (scope !== exactOption) continue;
627
+ let selectionState: BtwTextRangeSelectorState | undefined;
628
+ while (true) {
629
+ const selectedRange = await showBtwCustomPreservingEditor<BtwBringToMainChoice>(
630
+ ctx,
631
+ (tui, theme, keybindings, done) => {
632
+ let selector: BtwTextRangeSelector;
633
+ selector = new BtwTextRangeSelector(
634
+ tui,
635
+ theme,
636
+ keybindings,
637
+ thread.turns,
638
+ (action) => {
639
+ if (action.kind === "back") done({ kind: "back" });
640
+ else if (action.kind === "close") done({ kind: "closed" });
641
+ else done({ ...makeChoice(action.segments), selectionState: selector.getState() });
642
+ },
643
+ selectionState,
644
+ );
645
+ return selector;
646
+ },
647
+ );
648
+ if (!selectedRange) return { kind: "closed" };
649
+ if (selectedRange.kind === "closed") return selectedRange;
650
+ if (selectedRange.kind === "back") break;
651
+ const preview = await showPreview(ctx, selectedRange.draft, selectedRange.summary);
652
+ if (preview.kind === "close") return { kind: "closed" };
653
+ if (preview.kind === "back") {
654
+ selectionState = selectedRange.selectionState;
655
+ continue;
656
+ }
657
+ return {
658
+ kind: "bringToMain",
659
+ draft: selectedRange.draft,
660
+ summary: selectedRange.summary,
661
+ };
662
+ }
663
+ }
664
+ }
665
+
666
+ type BtwMenuSelectorAction = { kind: "select"; value: string } | { kind: "back" } | { kind: "close" };
728
667
 
729
668
  type BtwBringToMainPreviewAction = { kind: "bring" } | { kind: "back" } | { kind: "close" };
730
669
 
731
670
  async function showBringToMainPreview(
732
- ctx: ExtensionCommandContext,
733
- draft: string,
734
- summary: BtwBringToMainSummary,
671
+ ctx: ExtensionCommandContext,
672
+ draft: string,
673
+ summary: BtwBringToMainSummary,
735
674
  ): Promise<BtwBringToMainPreviewAction> {
736
- const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
737
- if (ctx.signal?.aborted) return { kind: "close" };
738
- let confirmed = false;
739
- const count = summary.messages === 1 ? "1 message" : `${summary.messages} messages`;
740
- const lineCount = summary.lines === 1 ? "1 line" : `${summary.lines} lines`;
741
- const menu = defineMenu<void, "preview", "bring", MenuContext>({
742
- start: "preview",
743
- screens: {
744
- preview: () => ({
745
- kind: "review",
746
- title: `Preview · ${count} · ${lineCount} · ~${summary.tokens} tokens`,
747
- content: draft,
748
- viewportSize: "adaptive",
749
- hint: "back",
750
- confirm: { id: "bring", label: "Bring", action: "bring" },
751
- }),
752
- },
753
- actions: {
754
- bring: async () => {
755
- confirmed = true;
756
- return { kind: "close" } as const;
757
- },
758
- },
759
- });
760
- const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
761
- runMenu(menuContext, menu, { getState: () => undefined }),
762
- );
763
- if (confirmed && result.kind === "closed" && result.reason === "close") {
764
- return { kind: "bring" };
765
- }
766
- return terminalBtwMenuAction(result);
675
+ const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
676
+ if (ctx.signal?.aborted) return { kind: "close" };
677
+ let confirmed = false;
678
+ const count = summary.messages === 1 ? "1 message" : `${summary.messages} messages`;
679
+ const lineCount = summary.lines === 1 ? "1 line" : `${summary.lines} lines`;
680
+ const menu = defineMenu<void, "preview", "bring", MenuContext>({
681
+ start: "preview",
682
+ screens: {
683
+ preview: () => ({
684
+ kind: "review",
685
+ title: `Preview · ${count} · ${lineCount} · ~${summary.tokens} tokens`,
686
+ content: draft,
687
+ viewportSize: "adaptive",
688
+ hint: "back",
689
+ confirm: { id: "bring", label: "Bring", action: "bring" },
690
+ }),
691
+ },
692
+ actions: {
693
+ bring: async () => {
694
+ confirmed = true;
695
+ return { kind: "close" } as const;
696
+ },
697
+ },
698
+ });
699
+ const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
700
+ runMenu(menuContext, menu, { getState: () => undefined }),
701
+ );
702
+ if (confirmed && result.kind === "closed" && result.reason === "close") {
703
+ return { kind: "bring" };
704
+ }
705
+ return terminalBtwMenuAction(result);
767
706
  }
768
707
 
769
708
  async function showBtwMenu(
770
- ctx: ExtensionCommandContext,
771
- title: string,
772
- options: readonly string[],
773
- initialValue?: string,
709
+ ctx: ExtensionCommandContext,
710
+ title: string,
711
+ options: readonly string[],
712
+ initialValue?: string,
774
713
  ): Promise<BtwMenuSelectorAction> {
775
- const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
776
- if (ctx.signal?.aborted) return { kind: "close" };
777
- const items = options.map((label, index) => ({ id: `option-${index}`, label }));
778
- const initialIndex = initialValue === undefined ? -1 : options.indexOf(initialValue);
779
- let selectedValue: string | undefined;
780
- const menu = defineMenu<void, "choices", "select", MenuContext>({
781
- start: "choices",
782
- screens: {
783
- choices: () => ({
784
- kind: "choice",
785
- title,
786
- items,
787
- action: "select",
788
- initialItemId: initialIndex >= 0 ? `option-${initialIndex}` : undefined,
789
- hint: "back",
790
- }),
791
- },
792
- actions: {
793
- select: async ({ itemId }: { itemId: string }) => {
794
- const index = Number.parseInt(itemId.slice("option-".length), 10);
795
- selectedValue = options[index];
796
- return selectedValue === undefined
797
- ? ({ kind: "stay" } as const)
798
- : ({ kind: "close" } as const);
799
- },
800
- },
801
- });
802
- const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
803
- runMenu(menuContext, menu, { getState: () => undefined }),
804
- );
805
- return selectedValue !== undefined && result.kind === "closed" && result.reason === "close"
806
- ? { kind: "select", value: selectedValue }
807
- : terminalBtwMenuAction(result);
714
+ const { defineMenu, runMenu } = await import("@narumitw/pi-tui-kit");
715
+ if (ctx.signal?.aborted) return { kind: "close" };
716
+ const items = options.map((label, index) => ({ id: `option-${index}`, label }));
717
+ const initialIndex = initialValue === undefined ? -1 : options.indexOf(initialValue);
718
+ let selectedValue: string | undefined;
719
+ const menu = defineMenu<void, "choices", "select", MenuContext>({
720
+ start: "choices",
721
+ screens: {
722
+ choices: () => ({
723
+ kind: "choice",
724
+ title,
725
+ items,
726
+ action: "select",
727
+ initialItemId: initialIndex >= 0 ? `option-${initialIndex}` : undefined,
728
+ hint: "back",
729
+ }),
730
+ },
731
+ actions: {
732
+ select: async ({ itemId }: { itemId: string }) => {
733
+ const index = Number.parseInt(itemId.slice("option-".length), 10);
734
+ selectedValue = options[index];
735
+ return selectedValue === undefined ? ({ kind: "stay" } as const) : ({ kind: "close" } as const);
736
+ },
737
+ },
738
+ });
739
+ const result = await runBtwMenuPreservingEditor(ctx, (menuContext) =>
740
+ runMenu(menuContext, menu, { getState: () => undefined }),
741
+ );
742
+ return selectedValue !== undefined && result.kind === "closed" && result.reason === "close"
743
+ ? { kind: "select", value: selectedValue }
744
+ : terminalBtwMenuAction(result);
808
745
  }
809
746
 
810
747
  function terminalBtwMenuAction(result: RunMenuResult): { kind: "back" } | { kind: "close" } {
811
- if (result.kind === "closed") return { kind: result.reason };
812
- if (result.kind === "error") throw result.error;
813
- return { kind: "close" };
748
+ if (result.kind === "closed") return { kind: result.reason };
749
+ if (result.kind === "error") throw result.error;
750
+ return { kind: "close" };
814
751
  }
815
752
 
816
753
  export async function loadBringToMainDraft(
817
- draft: string,
818
- ctx: ExtensionCommandContext,
819
- summary: BtwBringToMainSummary,
754
+ draft: string,
755
+ ctx: ExtensionCommandContext,
756
+ summary: BtwBringToMainSummary,
820
757
  ): Promise<BtwBringToMainDelivery> {
821
- const describeContent = () =>
822
- `${summary.messages} ${summary.messages === 1 ? "message" : "messages"} (~${summary.tokens} ${summary.tokens === 1 ? "token" : "tokens"})`;
823
- const existing = ctx.ui.getEditorText();
824
- if (!existing.trim()) {
825
- ctx.ui.setEditorText(draft);
826
- ctx.ui.notify(
827
- `Brought ${describeContent()} to the main editor. Review and submit when ready.`,
828
- "info",
829
- );
830
- return "loaded";
831
- }
832
-
833
- const appendOption = "Append after current draft Recommended";
834
- const replaceOption = " Replace current draft Discards current editor text";
835
- const cancelOption = "Cancel Return to the side thread";
836
- while (true) {
837
- const action = await showBtwMenu(ctx, "The main editor already has a draft", [
838
- appendOption,
839
- replaceOption,
840
- cancelOption,
841
- ]);
842
- if (action.kind === "close") return "closed";
843
- if (action.kind === "back" || action.value === cancelOption) return "back";
844
- if (action.value === appendOption) {
845
- ctx.ui.setEditorText(`${ctx.ui.getEditorText()}\n\n${draft}`);
846
- ctx.ui.notify(
847
- `Appended ${describeContent()} to the existing main-editor draft. Review and submit when ready.`,
848
- "info",
849
- );
850
- return "loaded";
851
- }
852
- if (action.value !== replaceOption) continue;
853
-
854
- const current = ctx.ui.getEditorText();
855
- const characters = [...current].length;
856
- const confirmed = await showBtwMenu(
857
- ctx,
858
- `Replace the current ${characters}-character editor draft?`,
859
- ["Back Keep current editor text", "⚠ Replace current draft Cannot be undone"],
860
- );
861
- if (confirmed.kind === "close") return "closed";
862
- if (confirmed.kind === "back" || confirmed.value === "Back Keep current editor text") continue;
863
- if (confirmed.value !== "⚠ Replace current draft Cannot be undone") continue;
864
- if (ctx.ui.getEditorText() !== current) {
865
- ctx.ui.notify(
866
- "The main editor changed during confirmation. Review the updated draft and choose again.",
867
- "warning",
868
- );
869
- continue;
870
- }
871
- ctx.ui.setEditorText(draft);
872
- ctx.ui.notify(
873
- `Replaced the main-editor draft with ${describeContent()}. Review and submit when ready.`,
874
- "info",
875
- );
876
- return "loaded";
877
- }
758
+ const describeContent = () =>
759
+ `${summary.messages} ${summary.messages === 1 ? "message" : "messages"} (~${summary.tokens} ${summary.tokens === 1 ? "token" : "tokens"})`;
760
+ const existing = ctx.ui.getEditorText();
761
+ if (!existing.trim()) {
762
+ ctx.ui.setEditorText(draft);
763
+ ctx.ui.notify(`Brought ${describeContent()} to the main editor. Review and submit when ready.`, "info");
764
+ return "loaded";
765
+ }
766
+
767
+ const appendOption = "Append after current draft Recommended";
768
+ const replaceOption = "⚠ Replace current draft Discards current editor text";
769
+ const cancelOption = "Cancel Return to the side thread";
770
+ while (true) {
771
+ const action = await showBtwMenu(ctx, "The main editor already has a draft", [
772
+ appendOption,
773
+ replaceOption,
774
+ cancelOption,
775
+ ]);
776
+ if (action.kind === "close") return "closed";
777
+ if (action.kind === "back" || action.value === cancelOption) return "back";
778
+ if (action.value === appendOption) {
779
+ ctx.ui.setEditorText(`${ctx.ui.getEditorText()}\n\n${draft}`);
780
+ ctx.ui.notify(
781
+ `Appended ${describeContent()} to the existing main-editor draft. Review and submit when ready.`,
782
+ "info",
783
+ );
784
+ return "loaded";
785
+ }
786
+ if (action.value !== replaceOption) continue;
787
+
788
+ const current = ctx.ui.getEditorText();
789
+ const characters = [...current].length;
790
+ const confirmed = await showBtwMenu(ctx, `Replace the current ${characters}-character editor draft?`, [
791
+ "Back Keep current editor text",
792
+ "⚠ Replace current draft Cannot be undone",
793
+ ]);
794
+ if (confirmed.kind === "close") return "closed";
795
+ if (confirmed.kind === "back" || confirmed.value === "Back Keep current editor text") continue;
796
+ if (confirmed.value !== "⚠ Replace current draft Cannot be undone") continue;
797
+ if (ctx.ui.getEditorText() !== current) {
798
+ ctx.ui.notify(
799
+ "The main editor changed during confirmation. Review the updated draft and choose again.",
800
+ "warning",
801
+ );
802
+ continue;
803
+ }
804
+ ctx.ui.setEditorText(draft);
805
+ ctx.ui.notify(`Replaced the main-editor draft with ${describeContent()}. Review and submit when ready.`, "info");
806
+ return "loaded";
807
+ }
878
808
  }
879
809
 
880
810
  function truncatePreview(text: string): string {
881
- return text.length <= 72 ? text : `${text.slice(0, 69)}…`;
811
+ return text.length <= 72 ? text : `${text.slice(0, 69)}…`;
812
+ }
813
+
814
+ async function prepareCurrentTranscriptMarkdown(
815
+ thread: SideThread,
816
+ pendingQuestion: string | undefined,
817
+ ctx: ExtensionCommandContext,
818
+ ): Promise<BtwMarkdownTransformers | undefined> {
819
+ while (true) {
820
+ const turnCount = thread.turns.length;
821
+ const createMarkdownTransformers = ctx.signal
822
+ ? await prepareBtwTranscriptMarkdown(thread.turns, pendingQuestion, ctx.signal)
823
+ : await prepareBtwTranscriptMarkdown(thread.turns, pendingQuestion);
824
+ if (!createMarkdownTransformers || ctx.signal?.aborted) return undefined;
825
+ if (thread.turns.length === turnCount) return createMarkdownTransformers;
826
+ }
882
827
  }
883
828
 
884
829
  async function askThreadQuestion(
885
- thread: SideThread,
886
- question: string,
887
- selected: ResolvedBtwModel,
888
- thinkingLevel: BtwThinkingLevel,
889
- ctx: ExtensionCommandContext,
890
- steering: BtwThreadSteeringControl,
830
+ thread: SideThread,
831
+ question: string,
832
+ selected: ResolvedBtwModel,
833
+ thinkingLevel: BtwThinkingLevel,
834
+ ctx: ExtensionCommandContext,
835
+ steering: BtwThreadSteeringControl,
891
836
  ) {
892
- return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>(
893
- (tui, theme, keybindings, done) => {
894
- let settled = false;
895
- const view = new BtwAnsweringView(
896
- tui,
897
- theme,
898
- thread.turns,
899
- question,
900
- () => {
901
- if (settled) return;
902
- settled = true;
903
- done({ kind: "aborted" });
904
- },
905
- thinkingLevel,
906
- {
907
- steering: {
908
- questions: steering.questions,
909
- onSubmit: steering.submit,
910
- thinking: { ...steering.thinking, keybindings },
911
- },
912
- },
913
- );
914
- completeSideThreadTurn({
915
- thread,
916
- question,
917
- model: selected.model,
918
- thinkingLevel,
919
- auth: selected.auth,
920
- signal: view.signal,
921
- completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
922
- sessionId: readBtwSessionId(ctx),
923
- }).then((result) => {
924
- if (settled) return;
925
- settled = true;
926
- view.finish();
927
- done(result);
928
- });
929
- return view;
930
- },
931
- );
837
+ const createMarkdownTransformers = await prepareCurrentTranscriptMarkdown(thread, question, ctx);
838
+ if (!createMarkdownTransformers) return { kind: "aborted" as const };
839
+ return ctx.ui.custom<Awaited<ReturnType<typeof completeSideThreadTurn>>>((tui, theme, keybindings, done) => {
840
+ let settled = false;
841
+ const view = new BtwAnsweringView(
842
+ tui,
843
+ theme,
844
+ thread.turns,
845
+ question,
846
+ () => {
847
+ if (settled) return;
848
+ settled = true;
849
+ done({ kind: "aborted" });
850
+ },
851
+ thinkingLevel,
852
+ {
853
+ markdownTransformers: createMarkdownTransformers(theme),
854
+ steering: {
855
+ questions: steering.questions,
856
+ onSubmit: steering.submit,
857
+ thinking: { ...steering.thinking, keybindings },
858
+ },
859
+ },
860
+ );
861
+ completeSideThreadTurn({
862
+ thread,
863
+ question,
864
+ model: selected.model,
865
+ thinkingLevel,
866
+ auth: selected.auth,
867
+ signal: view.signal,
868
+ completeSimple: createModelRegistryCompleteSimple(ctx.modelRegistry),
869
+ sessionId: readBtwSessionId(ctx),
870
+ }).then((result) => {
871
+ if (settled) return;
872
+ settled = true;
873
+ view.finish();
874
+ done(result);
875
+ });
876
+ return view;
877
+ });
932
878
  }
933
879
 
934
880
  async function showThreadComposer(
935
- thread: SideThread,
936
- startAtBottom: boolean,
937
- ctx: ExtensionCommandContext,
938
- initialQuestion: string | undefined,
939
- thinking: BtwThreadThinkingControl,
881
+ thread: SideThread,
882
+ startAtBottom: boolean,
883
+ ctx: ExtensionCommandContext,
884
+ initialQuestion: string | undefined,
885
+ thinking: BtwThreadThinkingControl,
940
886
  ): Promise<TranscriptPagerAction> {
941
- return ctx.ui.custom<TranscriptPagerAction>(
942
- (tui, theme, keybindings, done) =>
943
- new BtwTranscriptPager(tui, theme, thread.turns, done, {
944
- startAtBottom,
945
- initialQuestion,
946
- thinking: { ...thinking, keybindings },
947
- }),
948
- );
887
+ const createMarkdownTransformers = await prepareCurrentTranscriptMarkdown(thread, initialQuestion, ctx);
888
+ if (!createMarkdownTransformers) return { kind: "close" };
889
+ return ctx.ui.custom<TranscriptPagerAction>(
890
+ (tui, theme, keybindings, done) =>
891
+ new BtwTranscriptPager(tui, theme, thread.turns, done, {
892
+ startAtBottom,
893
+ initialQuestion,
894
+ markdownTransformers: createMarkdownTransformers(theme),
895
+ thinking: { ...thinking, keybindings },
896
+ }),
897
+ );
949
898
  }
950
899
 
951
900
  type MessageContentBlock = {
952
- type?: string;
953
- text?: string;
954
- name?: string;
955
- arguments?: unknown;
956
- result?: unknown;
901
+ type?: string;
902
+ text?: string;
903
+ name?: string;
904
+ arguments?: unknown;
905
+ result?: unknown;
957
906
  };
958
907
 
959
908
  type SessionMessage = {
960
- role?: string;
961
- content?: unknown;
962
- stopReason?: string;
909
+ role?: string;
910
+ content?: unknown;
911
+ stopReason?: string;
963
912
  };
964
913
 
965
914
  type SessionEntry = {
966
- type: string;
967
- message?: SessionMessage;
915
+ type: string;
916
+ message?: SessionMessage;
968
917
  };
969
918
 
970
919
  export function buildConversationContext(entries: readonly SessionEntry[]) {
971
- const sections: string[] = [];
920
+ const sections: string[] = [];
972
921
 
973
- for (const entry of entries) {
974
- if (entry.type !== "message" || !entry.message?.role) continue;
922
+ for (const entry of entries) {
923
+ if (entry.type !== "message" || !entry.message?.role) continue;
975
924
 
976
- const role = entry.message.role;
977
- if (role !== "user" && role !== "assistant") continue;
925
+ const role = entry.message.role;
926
+ if (role !== "user" && role !== "assistant") continue;
978
927
 
979
- const contentLines = extractContentLines(entry.message.content);
980
- if (contentLines.length === 0) continue;
928
+ const contentLines = extractContentLines(entry.message.content);
929
+ if (contentLines.length === 0) continue;
981
930
 
982
- const label = role === "user" ? "User" : "Assistant";
983
- const status =
984
- entry.message.stopReason && entry.message.stopReason !== "stop"
985
- ? ` (${entry.message.stopReason})`
986
- : "";
987
- sections.push(`${label}${status}: ${contentLines.join("\n")}`);
988
- }
931
+ const label = role === "user" ? "User" : "Assistant";
932
+ const status =
933
+ entry.message.stopReason && entry.message.stopReason !== "stop" ? ` (${entry.message.stopReason})` : "";
934
+ sections.push(`${label}${status}: ${contentLines.join("\n")}`);
935
+ }
989
936
 
990
- return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
937
+ return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
991
938
  }
992
939
 
993
940
  function extractContentLines(content: unknown): string[] {
994
- if (typeof content === "string") return [content.trim()].filter(Boolean);
995
- if (!Array.isArray(content)) return [];
996
-
997
- const lines: string[] = [];
998
- for (const part of content) {
999
- if (!part || typeof part !== "object") continue;
1000
- const block = part as MessageContentBlock;
1001
- if (block.type === "text" && typeof block.text === "string") {
1002
- lines.push(block.text.trim());
1003
- } else if (block.type === "toolCall" && typeof block.name === "string") {
1004
- lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
1005
- } else if (block.type === "toolResult" && typeof block.name === "string") {
1006
- lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
1007
- }
1008
- }
1009
- return lines.filter(Boolean);
941
+ if (typeof content === "string") return [content.trim()].filter(Boolean);
942
+ if (!Array.isArray(content)) return [];
943
+
944
+ const lines: string[] = [];
945
+ for (const part of content) {
946
+ if (!part || typeof part !== "object") continue;
947
+ const block = part as MessageContentBlock;
948
+ if (block.type === "text" && typeof block.text === "string") {
949
+ lines.push(block.text.trim());
950
+ } else if (block.type === "toolCall" && typeof block.name === "string") {
951
+ lines.push(`Tool call: ${block.name}(${formatJson(block.arguments)})`);
952
+ } else if (block.type === "toolResult" && typeof block.name === "string") {
953
+ lines.push(`Tool result from ${block.name}: ${formatJson(block.result)}`);
954
+ }
955
+ }
956
+ return lines.filter(Boolean);
1010
957
  }
1011
958
 
1012
959
  function formatJson(value: unknown) {
1013
- if (value === undefined) return "";
1014
- try {
1015
- return JSON.stringify(value);
1016
- } catch {
1017
- return String(value);
1018
- }
960
+ if (value === undefined) return "";
961
+ try {
962
+ return JSON.stringify(value);
963
+ } catch {
964
+ return String(value);
965
+ }
1019
966
  }
1020
967
 
1021
968
  function truncateFromStart(text: string, maxChars: number) {
1022
- if (text.length <= maxChars) return text;
1023
- return `[Earlier context omitted; showing the last ${maxChars} characters.]\n${text.slice(-maxChars)}`;
969
+ if (text.length <= maxChars) return text;
970
+ return `[Earlier context omitted; showing the last ${maxChars} characters.]\n${text.slice(-maxChars)}`;
1024
971
  }