@narumitw/pi-btw 0.58.1 → 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/README.md +2 -0
- package/dist/index.ts +284 -365
- package/dist/index.ts.map +3 -3
- package/package.json +52 -53
- package/src/bring-to-main.ts +494 -547
- package/src/btw.ts +814 -856
- package/src/fullscreen-ui.ts +673 -674
- package/src/keybindings.ts +228 -270
- package/src/main-tree-picker.ts +309 -318
- package/src/menu.ts +416 -454
- package/src/settings.ts +203 -219
- package/src/side-thread.ts +173 -201
- package/src/text.ts +21 -23
- package/src/transcript-markdown.ts +65 -0
- package/src/transcript-pager.ts +611 -662
package/src/btw.ts
CHANGED
|
@@ -1,466 +1,436 @@
|
|
|
1
1
|
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
24
|
+
type BtwCommandMenuResult,
|
|
25
|
+
type BtwResumeThreadSummary,
|
|
26
|
+
runBtwMenuPreservingEditor,
|
|
27
|
+
showBtwCommandMenu,
|
|
28
|
+
showBtwCustomPreservingEditor,
|
|
33
29
|
} from "./menu.js";
|
|
34
30
|
import {
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
31
|
+
type BtwSettings,
|
|
32
|
+
effectiveFullscreenCopyOnSelect,
|
|
33
|
+
effectiveRememberThinkingLevelChanges,
|
|
34
|
+
parseBtwModelReference,
|
|
35
|
+
readBtwSettings,
|
|
36
|
+
updateBtwSettings,
|
|
41
37
|
} from "./settings.js";
|
|
42
38
|
import {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
50
|
+
BtwAnsweringView,
|
|
51
|
+
type BtwThinkingControl,
|
|
52
|
+
BtwTranscriptPager,
|
|
53
|
+
type TranscriptPagerAction,
|
|
57
54
|
} from "./transcript-pager.js";
|
|
58
55
|
|
|
59
56
|
export {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
79
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
108
|
-
|
|
99
|
+
model: Model<Api>;
|
|
100
|
+
auth: SideQuestionAuth;
|
|
109
101
|
}
|
|
110
102
|
|
|
111
103
|
export interface BtwThreadState {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
113
|
+
settings,
|
|
114
|
+
currentModel,
|
|
115
|
+
modelRegistry,
|
|
116
|
+
warn,
|
|
125
117
|
}: ResolveBtwModelOptions): Promise<ResolvedBtwModel | undefined> {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
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
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
174
|
+
return headers !== undefined && Object.values(headers).some((value) => value !== null);
|
|
194
175
|
}
|
|
195
176
|
|
|
196
177
|
export async function loadBtwThinkingLevel(
|
|
197
|
-
|
|
198
|
-
|
|
178
|
+
currentThinkingLevel: BtwThinkingLevel,
|
|
179
|
+
options: LoadBtwThinkingLevelOptions = {},
|
|
199
180
|
): Promise<BtwThinkingLevel> {
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
196
|
+
return error instanceof Error ? error.message : String(error);
|
|
216
197
|
}
|
|
217
198
|
|
|
218
199
|
function readBtwSessionId(ctx: ExtensionCommandContext): string | undefined {
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
207
|
+
ctx: ExtensionCommandContext,
|
|
208
|
+
message: string,
|
|
209
|
+
level: Parameters<ExtensionCommandContext["ui"]["notify"]>[1],
|
|
229
210
|
): void {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
356
|
+
pi: ExtensionAPI,
|
|
357
|
+
ctx: ExtensionCommandContext,
|
|
358
|
+
resumeThreads: readonly BtwResumeThreadSummary[],
|
|
387
359
|
): Promise<BtwCommandMenuResult> {
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
resumeThreads,
|
|
404
|
-
});
|
|
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
|
+
});
|
|
405
375
|
}
|
|
406
376
|
|
|
407
377
|
async function loadSettingsForCommand(ctx: ExtensionCommandContext): Promise<BtwSettings> {
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
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 {};
|
|
414
384
|
}
|
|
415
385
|
|
|
416
386
|
type ModelResolutionOutcome =
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
387
|
+
| { kind: "cancelled" }
|
|
388
|
+
| { kind: "unavailable" }
|
|
389
|
+
| { kind: "selected"; selected: ResolvedBtwModel };
|
|
420
390
|
|
|
421
391
|
async function resolveBtwModelWithLoader(
|
|
422
|
-
|
|
423
|
-
|
|
392
|
+
settings: BtwSettings,
|
|
393
|
+
ctx: ExtensionCommandContext,
|
|
424
394
|
): Promise<ModelResolutionOutcome> {
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
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
|
+
});
|
|
455
425
|
}
|
|
456
426
|
|
|
457
427
|
interface RunBtwThreadDependencies {
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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;
|
|
464
434
|
}
|
|
465
435
|
|
|
466
436
|
export type BtwThreadResult = { kind: "closed" };
|
|
@@ -468,557 +438,545 @@ export type BtwThreadResult = { kind: "closed" };
|
|
|
468
438
|
type BtwThreadThinkingControl = Omit<BtwThinkingControl, "keybindings">;
|
|
469
439
|
|
|
470
440
|
interface BtwThreadSteeringControl {
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
441
|
+
questions: readonly string[];
|
|
442
|
+
submit: (question: string) => void;
|
|
443
|
+
thinking: BtwThreadThinkingControl;
|
|
474
444
|
}
|
|
475
445
|
|
|
476
446
|
type BtwBringToMainChoice =
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
447
|
+
| BtwThreadResult
|
|
448
|
+
| {
|
|
449
|
+
kind: "bringToMain";
|
|
450
|
+
draft: string;
|
|
451
|
+
summary: BtwBringToMainSummary;
|
|
452
|
+
selectionState?: BtwTextRangeSelectorState;
|
|
453
|
+
}
|
|
454
|
+
| { kind: "back" };
|
|
485
455
|
|
|
486
456
|
type BtwBringToMainDelivery = "loaded" | "back" | "closed";
|
|
487
457
|
|
|
488
458
|
interface RunBtwThreadOptions {
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
459
|
+
initialQuestion?: string;
|
|
460
|
+
selected: ResolvedBtwModel;
|
|
461
|
+
thinkingLevel: BtwThinkingLevel;
|
|
462
|
+
rememberThinkingLevelChanges?: boolean;
|
|
463
|
+
settingsPath?: string;
|
|
464
|
+
state?: BtwThreadState;
|
|
465
|
+
ctx: ExtensionCommandContext;
|
|
466
|
+
dependencies?: RunBtwThreadDependencies;
|
|
497
467
|
}
|
|
498
468
|
|
|
499
469
|
export async function runBtwThread({
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
470
|
+
initialQuestion,
|
|
471
|
+
selected,
|
|
472
|
+
thinkingLevel,
|
|
473
|
+
rememberThinkingLevelChanges = false,
|
|
474
|
+
settingsPath,
|
|
475
|
+
state,
|
|
476
|
+
ctx,
|
|
477
|
+
dependencies = {},
|
|
508
478
|
}: RunBtwThreadOptions): Promise<BtwThreadResult> {
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
606
566
|
}
|
|
607
567
|
|
|
608
568
|
interface ChooseBringToMainDependencies {
|
|
609
|
-
|
|
610
|
-
|
|
569
|
+
showMenu?: typeof showBtwMenu;
|
|
570
|
+
showPreview?: typeof showBringToMainPreview;
|
|
611
571
|
}
|
|
612
572
|
|
|
613
573
|
export async function chooseBringToMain(
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
574
|
+
thread: SideThread,
|
|
575
|
+
ctx: ExtensionCommandContext,
|
|
576
|
+
dependencies: ChooseBringToMainDependencies = {},
|
|
617
577
|
): Promise<BtwBringToMainChoice> {
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
summary: selectedRange.summary,
|
|
719
|
-
};
|
|
720
|
-
}
|
|
721
|
-
}
|
|
722
|
-
}
|
|
723
|
-
|
|
724
|
-
type BtwMenuSelectorAction =
|
|
725
|
-
| { kind: "select"; value: string }
|
|
726
|
-
| { kind: "back" }
|
|
727
|
-
| { 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" };
|
|
728
678
|
|
|
729
679
|
type BtwBringToMainPreviewAction = { kind: "bring" } | { kind: "back" } | { kind: "close" };
|
|
730
680
|
|
|
731
681
|
async function showBringToMainPreview(
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
682
|
+
ctx: ExtensionCommandContext,
|
|
683
|
+
draft: string,
|
|
684
|
+
summary: BtwBringToMainSummary,
|
|
735
685
|
): Promise<BtwBringToMainPreviewAction> {
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
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);
|
|
767
717
|
}
|
|
768
718
|
|
|
769
719
|
async function showBtwMenu(
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
720
|
+
ctx: ExtensionCommandContext,
|
|
721
|
+
title: string,
|
|
722
|
+
options: readonly string[],
|
|
723
|
+
initialValue?: string,
|
|
774
724
|
): Promise<BtwMenuSelectorAction> {
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
? { kind: "select", value: selectedValue }
|
|
807
|
-
: 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);
|
|
808
756
|
}
|
|
809
757
|
|
|
810
758
|
function terminalBtwMenuAction(result: RunMenuResult): { kind: "back" } | { kind: "close" } {
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
759
|
+
if (result.kind === "closed") return { kind: result.reason };
|
|
760
|
+
if (result.kind === "error") throw result.error;
|
|
761
|
+
return { kind: "close" };
|
|
814
762
|
}
|
|
815
763
|
|
|
816
764
|
export async function loadBringToMainDraft(
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
765
|
+
draft: string,
|
|
766
|
+
ctx: ExtensionCommandContext,
|
|
767
|
+
summary: BtwBringToMainSummary,
|
|
820
768
|
): Promise<BtwBringToMainDelivery> {
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
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
|
-
}
|
|
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
|
+
}
|
|
878
819
|
}
|
|
879
820
|
|
|
880
821
|
function truncatePreview(text: string): string {
|
|
881
|
-
|
|
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
|
+
}
|
|
882
838
|
}
|
|
883
839
|
|
|
884
840
|
async function askThreadQuestion(
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
841
|
+
thread: SideThread,
|
|
842
|
+
question: string,
|
|
843
|
+
selected: ResolvedBtwModel,
|
|
844
|
+
thinkingLevel: BtwThinkingLevel,
|
|
845
|
+
ctx: ExtensionCommandContext,
|
|
846
|
+
steering: BtwThreadSteeringControl,
|
|
891
847
|
) {
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
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
|
+
});
|
|
932
889
|
}
|
|
933
890
|
|
|
934
891
|
async function showThreadComposer(
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
892
|
+
thread: SideThread,
|
|
893
|
+
startAtBottom: boolean,
|
|
894
|
+
ctx: ExtensionCommandContext,
|
|
895
|
+
initialQuestion: string | undefined,
|
|
896
|
+
thinking: BtwThreadThinkingControl,
|
|
940
897
|
): Promise<TranscriptPagerAction> {
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
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
|
+
);
|
|
949
909
|
}
|
|
950
910
|
|
|
951
911
|
type MessageContentBlock = {
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
912
|
+
type?: string;
|
|
913
|
+
text?: string;
|
|
914
|
+
name?: string;
|
|
915
|
+
arguments?: unknown;
|
|
916
|
+
result?: unknown;
|
|
957
917
|
};
|
|
958
918
|
|
|
959
919
|
type SessionMessage = {
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
920
|
+
role?: string;
|
|
921
|
+
content?: unknown;
|
|
922
|
+
stopReason?: string;
|
|
963
923
|
};
|
|
964
924
|
|
|
965
925
|
type SessionEntry = {
|
|
966
|
-
|
|
967
|
-
|
|
926
|
+
type: string;
|
|
927
|
+
message?: SessionMessage;
|
|
968
928
|
};
|
|
969
929
|
|
|
970
930
|
export function buildConversationContext(entries: readonly SessionEntry[]) {
|
|
971
|
-
|
|
931
|
+
const sections: string[] = [];
|
|
972
932
|
|
|
973
|
-
|
|
974
|
-
|
|
933
|
+
for (const entry of entries) {
|
|
934
|
+
if (entry.type !== "message" || !entry.message?.role) continue;
|
|
975
935
|
|
|
976
|
-
|
|
977
|
-
|
|
936
|
+
const role = entry.message.role;
|
|
937
|
+
if (role !== "user" && role !== "assistant") continue;
|
|
978
938
|
|
|
979
|
-
|
|
980
|
-
|
|
939
|
+
const contentLines = extractContentLines(entry.message.content);
|
|
940
|
+
if (contentLines.length === 0) continue;
|
|
981
941
|
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
sections.push(`${label}${status}: ${contentLines.join("\n")}`);
|
|
988
|
-
}
|
|
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
|
+
}
|
|
989
947
|
|
|
990
|
-
|
|
948
|
+
return truncateFromStart(sections.join("\n\n"), MAX_CONTEXT_CHARS);
|
|
991
949
|
}
|
|
992
950
|
|
|
993
951
|
function extractContentLines(content: unknown): string[] {
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
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);
|
|
1010
968
|
}
|
|
1011
969
|
|
|
1012
970
|
function formatJson(value: unknown) {
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
971
|
+
if (value === undefined) return "";
|
|
972
|
+
try {
|
|
973
|
+
return JSON.stringify(value);
|
|
974
|
+
} catch {
|
|
975
|
+
return String(value);
|
|
976
|
+
}
|
|
1019
977
|
}
|
|
1020
978
|
|
|
1021
979
|
function truncateFromStart(text: string, maxChars: number) {
|
|
1022
|
-
|
|
1023
|
-
|
|
980
|
+
if (text.length <= maxChars) return text;
|
|
981
|
+
return `[Earlier context omitted; showing the last ${maxChars} characters.]\n${text.slice(-maxChars)}`;
|
|
1024
982
|
}
|