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