@ia-ccun/code-agent-cli 0.0.51 → 0.0.52-beta.2

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.
Files changed (38) hide show
  1. package/bin/cli.js +11 -10
  2. package/config/agent/extensions/aicode-advisor.ts +707 -0
  3. package/config/agent/extensions/aicode-btw.ts +2273 -0
  4. package/config/agent/extensions/aicode-plan.ts +903 -0
  5. package/config/agent/extensions/aicode-tips.ts +44 -8
  6. package/config/agent/extensions/bash-check.ts +1 -1
  7. package/config/agent/extensions/context-monitor.ts +1 -1
  8. package/config/agent/extensions/context.ts +3 -3
  9. package/config/agent/extensions/custom-footer.ts +22 -4
  10. package/config/agent/extensions/custom.ts +118 -84
  11. package/config/agent/extensions/error-monitor.ts +1 -1
  12. package/config/agent/extensions/goal.ts +450 -0
  13. package/config/agent/extensions/msg-debug.ts +49 -10
  14. package/config/agent/extensions/review.ts +3 -3
  15. package/config/agent/extensions/working-msg.ts +44 -23
  16. package/config/agent/help.md +25 -7
  17. package/config/agent/models.json +7 -7
  18. package/config/agent/settings.json +0 -2
  19. package/dist/banner.d.ts +5 -1
  20. package/dist/banner.d.ts.map +1 -1
  21. package/dist/banner.js +106 -7
  22. package/dist/banner.js.map +1 -1
  23. package/dist/config-loader.d.ts +2 -0
  24. package/dist/config-loader.d.ts.map +1 -1
  25. package/dist/config-loader.js +81 -7
  26. package/dist/config-loader.js.map +1 -1
  27. package/dist/config.js +1 -1
  28. package/dist/config.js.map +1 -1
  29. package/dist/index.d.ts +1 -2
  30. package/dist/index.d.ts.map +1 -1
  31. package/dist/index.js +1 -3
  32. package/dist/index.js.map +1 -1
  33. package/dist/security-logger.d.ts +69 -0
  34. package/dist/security-logger.d.ts.map +1 -0
  35. package/dist/security-logger.js +178 -0
  36. package/dist/security-logger.js.map +1 -0
  37. package/package.json +18 -7
  38. package/scripts/postinstall.js +188 -50
@@ -0,0 +1,2273 @@
1
+ import {
2
+ buildSessionContext,
3
+ createAgentSession,
4
+ createExtensionRuntime,
5
+ SessionManager,
6
+ type AgentSession,
7
+ type AgentSessionEvent,
8
+ type ExtensionAPI,
9
+ type ExtensionCommandContext,
10
+ type ExtensionContext,
11
+ type ResourceLoader,
12
+ } from "@earendil-works/pi-coding-agent";
13
+ import { type AssistantMessage, type Message, type ThinkingLevel as AiThinkingLevel, type UserMessage } from "@earendil-works/pi-ai";
14
+ import {
15
+ Box,
16
+ Container,
17
+ Input,
18
+ Key,
19
+ Text,
20
+ matchesKey,
21
+ truncateToWidth,
22
+ visibleWidth,
23
+ wrapTextWithAnsi,
24
+ type Focusable,
25
+ type KeybindingsManager,
26
+ type OverlayHandle,
27
+ type TUI,
28
+ } from "@earendil-works/pi-tui";
29
+
30
+ // ============== 常量 ==============
31
+ const STATUS_KEY = "aicode-btw";
32
+ const BTW_MESSAGE_TYPE = "aicode-btw-message";
33
+ const BTW_ENTRY_TYPE = "aicode-btw-entry";
34
+ const BTW_RESET_TYPE = "aicode-btw-reset";
35
+ const BTW_MODEL_OVERRIDE_TYPE = "aicode-btw-model-override";
36
+ const BTW_THINKING_OVERRIDE_TYPE = "aicode-btw-thinking-override";
37
+ const BTW_FOCUS_SHORTCUTS = [Key.alt("/"), Key.ctrlAlt("w")] as const;
38
+
39
+ function matchesBtwFocusShortcut(data: string): boolean {
40
+ return BTW_FOCUS_SHORTCUTS.some((shortcut) => matchesKey(data, shortcut));
41
+ }
42
+
43
+ const BTW_SYSTEM_PROMPT = [
44
+ "You are having an aside conversation with the user, separate from their main working session.",
45
+ "If main session messages are provided, they are for context only — that work is being handled by another agent.",
46
+ "If no main session messages are provided, treat this as a fully contextless tangent thread and rely only on the user's words plus your general instructions.",
47
+ "Focus on answering the user's side questions, helping them think through ideas, or planning next steps.",
48
+ "Do not act as if you need to continue unfinished work from the main session unless the user explicitly asks you to prepare something for injection back to it.",
49
+ ].join(" ");
50
+
51
+ const BTW_SUMMARIZE_SYSTEM_PROMPT =
52
+ "Summarize the side conversation concisely. Preserve key decisions, plans, insights, risks, and action items. Output only the summary.";
53
+
54
+ const BTW_CONTINUE_THREAD_USER_TEXT = "[The following is a separate side conversation. Continue this thread.]";
55
+ const BTW_CONTINUE_THREAD_ASSISTANT_TEXT = "Understood, continuing our side conversation.";
56
+
57
+ type SessionThinkingLevel = "off" | AiThinkingLevel;
58
+ type BtwThreadMode = "contextual" | "tangent";
59
+ type SessionModel = NonNullable<ExtensionCommandContext["model"]>;
60
+ /**
61
+ * Loose model reference parsed from `/btw:model <provider> <id> <api>` and persisted to
62
+ * session entries. Resolved to a full SessionModel via ctx.modelRegistry.find(...).
63
+ */
64
+ type BtwModelRef = Pick<SessionModel, "provider" | "id" | "api">;
65
+
66
+ type BtwDetails = {
67
+ question: string;
68
+ thinking: string;
69
+ answer: string;
70
+ provider: string;
71
+ model: string;
72
+ api: string;
73
+ thinkingLevel: SessionThinkingLevel;
74
+ timestamp: number;
75
+ usage?: AssistantMessage["usage"];
76
+ };
77
+
78
+ type ParsedBtwArgs = {
79
+ question: string;
80
+ save: boolean;
81
+ };
82
+
83
+ type SaveState = "not-saved" | "saved" | "queued";
84
+
85
+ type BtwResetDetails = {
86
+ timestamp: number;
87
+ mode?: BtwThreadMode;
88
+ };
89
+
90
+ type BtwModelOverrideDetails =
91
+ | ({ timestamp: number; action: "set" } & Pick<SessionModel, "provider" | "id" | "api">)
92
+ | { timestamp: number; action: "clear" };
93
+
94
+ type BtwThinkingOverrideDetails =
95
+ | { timestamp: number; action: "set"; thinkingLevel: SessionThinkingLevel }
96
+ | { timestamp: number; action: "clear" };
97
+
98
+ type ResolvedBtwModel = {
99
+ model: SessionModel | null;
100
+ source: "override" | "main" | "none";
101
+ configuredOverride: SessionModel | null;
102
+ fallbackReason?: string;
103
+ };
104
+
105
+ type ResolvedBtwSettings = {
106
+ model: SessionModel | null;
107
+ modelSource: "override" | "main" | "none";
108
+ configuredModelOverride: SessionModel | null;
109
+ thinkingLevel: SessionThinkingLevel;
110
+ thinkingSource: "override" | "main";
111
+ fallbackReason?: string;
112
+ };
113
+
114
+ type BtwTranscriptEntry =
115
+ | { id: number; turnId: number; type: "turn-boundary"; phase: "start" | "end" }
116
+ | { id: number; turnId: number; type: "user-message"; text: string }
117
+ | { id: number; turnId: number; type: "thinking"; text: string; streaming: boolean }
118
+ | { id: number; turnId: number; type: "assistant-text"; text: string; streaming: boolean }
119
+ | { id: number; turnId: number; type: "tool-call"; toolCallId: string; toolName: string; args: string }
120
+ | {
121
+ id: number;
122
+ turnId: number;
123
+ type: "tool-result";
124
+ toolCallId: string;
125
+ toolName: string;
126
+ content: string;
127
+ truncated: boolean;
128
+ isError: boolean;
129
+ streaming: boolean;
130
+ };
131
+
132
+ type BtwTranscript = BtwTranscriptEntry[];
133
+
134
+ type BtwTranscriptState = {
135
+ entries: BtwTranscript;
136
+ nextEntryId: number;
137
+ nextTurnId: number;
138
+ currentTurnId: number | null;
139
+ lastTurnId: number | null;
140
+ toolCalls: Map<string, { turnId: number; callEntryId: number; resultEntryId?: number }>;
141
+ };
142
+
143
+ type BtwSessionRuntime = {
144
+ session: AgentSession;
145
+ mode: BtwThreadMode;
146
+ subscriptions: Set<() => void>;
147
+ sideThreadStartIndex: number;
148
+ };
149
+
150
+ type OverlayRuntime = {
151
+ handle?: OverlayHandle;
152
+ refresh?: () => void;
153
+ close?: () => void;
154
+ finish?: () => void;
155
+ setDraft?: (value: string) => void;
156
+ closed?: boolean;
157
+ };
158
+
159
+ function isVisibleBtwMessage(message: { role: string; customType?: string }): boolean {
160
+ return message.role === "custom" && message.customType === BTW_MESSAGE_TYPE;
161
+ }
162
+
163
+ function isCustomEntry(entry: unknown, customType: string): entry is { type: "custom"; customType: string; data?: unknown } {
164
+ return !!entry && typeof entry === "object" && (entry as { type?: string }).type === "custom" && (entry as { customType?: string }).customType === customType;
165
+ }
166
+
167
+ function stripDynamicSystemPromptFooter(systemPrompt: string): string {
168
+ return systemPrompt
169
+ .replace(/\nCurrent date and time:[^\n]*(?:\nCurrent working directory:[^\n]*)?$/u, "")
170
+ .replace(/\nCurrent working directory:[^\n]*$/u, "")
171
+ .trim();
172
+ }
173
+
174
+ function createBtwResourceLoader(
175
+ ctx: ExtensionCommandContext,
176
+ appendSystemPrompt: string[] = [BTW_SYSTEM_PROMPT],
177
+ ): ResourceLoader {
178
+ const extensionsResult = { extensions: [], errors: [], runtime: createExtensionRuntime() };
179
+ const systemPrompt = stripDynamicSystemPromptFooter(ctx.getSystemPrompt());
180
+
181
+ return {
182
+ getExtensions: () => extensionsResult,
183
+ getSkills: () => ({ skills: [], diagnostics: [] }),
184
+ getPrompts: () => ({ prompts: [], diagnostics: [] }),
185
+ getThemes: () => ({ themes: [], diagnostics: [] }),
186
+ getAgentsFiles: () => ({ agentsFiles: [] }),
187
+ getSystemPrompt: () => systemPrompt,
188
+ getAppendSystemPrompt: () => appendSystemPrompt,
189
+ extendResources: () => {},
190
+ reload: async () => {},
191
+ };
192
+ }
193
+
194
+ function extractText(parts: AssistantMessage["content"], type: "text" | "thinking"): string {
195
+ const chunks: string[] = [];
196
+
197
+ for (const part of parts) {
198
+ if (type === "text" && part.type === "text") {
199
+ chunks.push(part.text);
200
+ } else if (type === "thinking" && part.type === "thinking") {
201
+ chunks.push(part.thinking);
202
+ }
203
+ }
204
+
205
+ return chunks.join("\n").trim();
206
+ }
207
+
208
+ function extractAnswer(message: AssistantMessage): string {
209
+ return extractText(message.content, "text") || "(No text response)";
210
+ }
211
+
212
+ function extractThinking(message: AssistantMessage): string {
213
+ return extractText(message.content, "thinking");
214
+ }
215
+
216
+ function parseBtwArgs(args: string): ParsedBtwArgs {
217
+ const save = /(?:^|\s)(?:--save|-s)(?=\s|$)/.test(args);
218
+ const question = args.replace(/(?:^|\s)(?:--save|-s)(?=\s|$)/g, " ").trim();
219
+ return { question, save };
220
+ }
221
+
222
+ function parseBtwModelArgs(args: string):
223
+ | { action: "show" }
224
+ | { action: "clear" }
225
+ | { action: "set"; model: BtwModelRef }
226
+ | { action: "invalid"; message: string } {
227
+ const trimmed = args.trim();
228
+ if (!trimmed) {
229
+ return { action: "show" };
230
+ }
231
+
232
+ if (trimmed === "clear") {
233
+ return { action: "clear" };
234
+ }
235
+
236
+ const parts = trimmed.split(/\s+/);
237
+ if (parts.length !== 3) {
238
+ return { action: "invalid", message: "Usage: /btw:model <provider> <model> <api> | clear" };
239
+ }
240
+
241
+ const [provider, id, api] = parts;
242
+ return { action: "set", model: { provider, id, api } as BtwModelRef };
243
+ }
244
+
245
+ function parseBtwThinkingArgs(args: string):
246
+ | { action: "show" }
247
+ | { action: "clear" }
248
+ | { action: "set"; thinkingLevel: SessionThinkingLevel } {
249
+ const trimmed = args.trim();
250
+ if (!trimmed) {
251
+ return { action: "show" };
252
+ }
253
+
254
+ if (trimmed === "clear") {
255
+ return { action: "clear" };
256
+ }
257
+
258
+ return { action: "set", thinkingLevel: trimmed as SessionThinkingLevel };
259
+ }
260
+
261
+ function formatModelRef(model: Pick<SessionModel, "provider" | "id" | "api">): string {
262
+ return `${model.provider}/${model.id} (${model.api})`;
263
+ }
264
+
265
+ function buildBtwSeedState(
266
+ ctx: ExtensionCommandContext,
267
+ thread: BtwDetails[],
268
+ mode: BtwThreadMode,
269
+ sessionModel: SessionModel | null,
270
+ ): { messages: Message[]; sideThreadStartIndex: number } {
271
+ const messages: Message[] = [];
272
+
273
+ if (mode === "contextual") {
274
+ try {
275
+ messages.push(
276
+ ...(buildSessionContext(ctx.sessionManager.getEntries(), ctx.sessionManager.getLeafId()).messages as Message[]).filter(
277
+ (message) => !isVisibleBtwMessage(message),
278
+ ),
279
+ );
280
+ } catch {
281
+ messages.push(
282
+ ...ctx.sessionManager.getEntries().flatMap((entry) => {
283
+ if (!entry || typeof entry !== "object") {
284
+ return [];
285
+ }
286
+
287
+ const message = entry as unknown as Partial<Message> & { role?: string; customType?: string; content?: unknown };
288
+ if (typeof message.role !== "string" || !Array.isArray(message.content)) {
289
+ return [];
290
+ }
291
+
292
+ return isVisibleBtwMessage({ role: message.role, customType: message.customType }) ? [] : [message as Message];
293
+ }),
294
+ );
295
+ }
296
+ }
297
+
298
+ const sideThreadStartIndex = messages.length;
299
+
300
+ if (thread.length > 0) {
301
+ messages.push(
302
+ {
303
+ role: "user",
304
+ content: [{ type: "text", text: BTW_CONTINUE_THREAD_USER_TEXT }],
305
+ timestamp: Date.now(),
306
+ },
307
+ {
308
+ role: "assistant",
309
+ content: [{ type: "text", text: BTW_CONTINUE_THREAD_ASSISTANT_TEXT }],
310
+ provider: sessionModel?.provider ?? "unknown",
311
+ model: sessionModel?.id ?? "unknown",
312
+ api: sessionModel?.api ?? "openai-responses",
313
+ usage: {
314
+ input: 0,
315
+ output: 0,
316
+ cacheRead: 0,
317
+ cacheWrite: 0,
318
+ totalTokens: 0,
319
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
320
+ },
321
+ stopReason: "stop",
322
+ timestamp: Date.now(),
323
+ },
324
+ );
325
+
326
+ for (const entry of thread) {
327
+ messages.push(
328
+ {
329
+ role: "user",
330
+ content: [{ type: "text", text: entry.question }],
331
+ timestamp: entry.timestamp,
332
+ },
333
+ {
334
+ role: "assistant",
335
+ content: [{ type: "text", text: entry.answer }],
336
+ provider: entry.provider,
337
+ model: entry.model,
338
+ api: entry.api || sessionModel?.api || ctx.model?.api || "openai-responses",
339
+ usage:
340
+ entry.usage ?? {
341
+ input: 0,
342
+ output: 0,
343
+ cacheRead: 0,
344
+ cacheWrite: 0,
345
+ totalTokens: 0,
346
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
347
+ },
348
+ stopReason: "stop",
349
+ timestamp: entry.timestamp,
350
+ },
351
+ );
352
+ }
353
+ }
354
+
355
+ return {
356
+ messages,
357
+ sideThreadStartIndex,
358
+ };
359
+ }
360
+
361
+ function formatToolPreview(value: unknown): string {
362
+ if (value === undefined) {
363
+ return "";
364
+ }
365
+
366
+ if (typeof value === "string") {
367
+ return value;
368
+ }
369
+
370
+ if (value && typeof value === "object") {
371
+ const path = (value as { path?: unknown }).path;
372
+ if (typeof path === "string") {
373
+ return path;
374
+ }
375
+ }
376
+
377
+ try {
378
+ const preview = JSON.stringify(value);
379
+ if (!preview || preview === "{}") {
380
+ return "";
381
+ }
382
+ return preview.length > 120 ? `${preview.slice(0, 117)}...` : preview;
383
+ } catch {
384
+ return "";
385
+ }
386
+ }
387
+
388
+ function createEmptyTranscriptState(): BtwTranscriptState {
389
+ return {
390
+ entries: [],
391
+ nextEntryId: 1,
392
+ nextTurnId: 1,
393
+ currentTurnId: null,
394
+ lastTurnId: null,
395
+ toolCalls: new Map(),
396
+ };
397
+ }
398
+
399
+ function appendTranscriptEntry<T extends BtwTranscriptEntry>(
400
+ state: BtwTranscriptState,
401
+ entry: Omit<T, "id">,
402
+ ): T {
403
+ const nextEntry = { ...entry, id: state.nextEntryId++ } as T;
404
+ state.entries.push(nextEntry);
405
+ return nextEntry;
406
+ }
407
+
408
+ function ensureTranscriptTurn(state: BtwTranscriptState): number {
409
+ if (state.currentTurnId !== null) {
410
+ return state.currentTurnId;
411
+ }
412
+
413
+ const turnId = state.nextTurnId++;
414
+ state.currentTurnId = turnId;
415
+ state.lastTurnId = turnId;
416
+ appendTranscriptEntry(state, { type: "turn-boundary", turnId, phase: "start" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
417
+ return turnId;
418
+ }
419
+
420
+ function finishTranscriptTurn(state: BtwTranscriptState, turnId?: number | null): void {
421
+ const resolvedTurnId = turnId ?? state.currentTurnId;
422
+ if (resolvedTurnId === null || resolvedTurnId === undefined) {
423
+ return;
424
+ }
425
+
426
+ const hasEndBoundary = state.entries.some(
427
+ (entry) => entry.turnId === resolvedTurnId && entry.type === "turn-boundary" && entry.phase === "end",
428
+ );
429
+ if (!hasEndBoundary) {
430
+ appendTranscriptEntry(state, { type: "turn-boundary", turnId: resolvedTurnId, phase: "end" } as Omit<Extract<BtwTranscriptEntry, { type: "turn-boundary" }>, "id">);
431
+ }
432
+
433
+ for (const entry of state.entries) {
434
+ if (entry.turnId !== resolvedTurnId) {
435
+ continue;
436
+ }
437
+
438
+ if (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") {
439
+ entry.streaming = false;
440
+ }
441
+ }
442
+
443
+ state.lastTurnId = resolvedTurnId;
444
+ if (state.currentTurnId === resolvedTurnId) {
445
+ state.currentTurnId = null;
446
+ }
447
+ }
448
+
449
+ function removeTranscriptTurn(state: BtwTranscriptState, turnId: number | null): void {
450
+ if (turnId === null) {
451
+ return;
452
+ }
453
+
454
+ state.entries = state.entries.filter((entry) => entry.turnId !== turnId);
455
+ for (const [toolCallId, toolCall] of state.toolCalls.entries()) {
456
+ if (toolCall.turnId === turnId) {
457
+ state.toolCalls.delete(toolCallId);
458
+ }
459
+ }
460
+
461
+ if (state.currentTurnId === turnId) {
462
+ state.currentTurnId = null;
463
+ }
464
+ if (state.lastTurnId === turnId) {
465
+ state.lastTurnId = null;
466
+ }
467
+ }
468
+
469
+ function findLatestTranscriptEntry<TType extends BtwTranscriptEntry["type"]>(
470
+ state: BtwTranscriptState,
471
+ turnId: number,
472
+ type: TType,
473
+ ): Extract<BtwTranscriptEntry, { type: TType }> | undefined {
474
+ for (let i = state.entries.length - 1; i >= 0; i--) {
475
+ const entry = state.entries[i];
476
+ if (entry.turnId === turnId && entry.type === type) {
477
+ return entry as Extract<BtwTranscriptEntry, { type: TType }>;
478
+ }
479
+ }
480
+
481
+ return undefined;
482
+ }
483
+
484
+ function ensureTranscriptTurnForUserMessage(state: BtwTranscriptState): number {
485
+ if (state.currentTurnId !== null) {
486
+ const currentAssistant = findLatestTranscriptEntry(state, state.currentTurnId, "assistant-text");
487
+ if (currentAssistant && !currentAssistant.streaming) {
488
+ finishTranscriptTurn(state, state.currentTurnId);
489
+ }
490
+ }
491
+
492
+ return ensureTranscriptTurn(state);
493
+ }
494
+
495
+ function extractMessageText(message: { content?: string | AssistantMessage["content"] | UserMessage["content"] }): string {
496
+ if (typeof message.content === "string") {
497
+ return message.content;
498
+ }
499
+ if (!Array.isArray(message.content)) {
500
+ return "";
501
+ }
502
+ return message.content
503
+ .filter((part): part is { type: "text"; text: string } => part.type === "text" && typeof part.text === "string")
504
+ .map((part) => part.text)
505
+ .join("\n")
506
+ .trim();
507
+ }
508
+
509
+ function upsertUserMessageEntry(state: BtwTranscriptState, turnId: number, text: string): void {
510
+ if (!text) {
511
+ return;
512
+ }
513
+
514
+ const existing = findLatestTranscriptEntry(state, turnId, "user-message");
515
+ if (existing) {
516
+ existing.text = text;
517
+ return;
518
+ }
519
+
520
+ appendTranscriptEntry(state, { type: "user-message", turnId, text } as Omit<Extract<BtwTranscriptEntry, { type: "user-message" }>, "id">);
521
+ }
522
+
523
+ function upsertTranscriptTextEntry(
524
+ state: BtwTranscriptState,
525
+ turnId: number,
526
+ type: "thinking" | "assistant-text",
527
+ text: string,
528
+ streaming: boolean,
529
+ ): void {
530
+ if (!text) {
531
+ return;
532
+ }
533
+
534
+ const existing = findLatestTranscriptEntry(state, turnId, type);
535
+ if (existing) {
536
+ existing.text = text;
537
+ existing.streaming = streaming;
538
+ return;
539
+ }
540
+
541
+ appendTranscriptEntry(state, { type, turnId, text, streaming } as Omit<Extract<BtwTranscriptEntry, { type: "thinking" | "assistant-text" }>, "id">);
542
+ }
543
+
544
+ function summarizeToolResult(value: unknown, maxLength = 400): { content: string; truncated: boolean } {
545
+ let content = "";
546
+
547
+ if (value && typeof value === "object") {
548
+ const toolValue = value as {
549
+ content?: Array<{ type?: string; text?: string }>;
550
+ error?: unknown;
551
+ message?: unknown;
552
+ };
553
+
554
+ if (Array.isArray(toolValue.content)) {
555
+ content = toolValue.content
556
+ .filter((part) => part.type === "text" && typeof part.text === "string")
557
+ .map((part) => part.text ?? "")
558
+ .join("\n")
559
+ .trim();
560
+ }
561
+
562
+ if (!content && typeof toolValue.error === "string") {
563
+ content = toolValue.error;
564
+ }
565
+
566
+ if (!content && typeof toolValue.message === "string") {
567
+ content = toolValue.message;
568
+ }
569
+ }
570
+
571
+ if (!content) {
572
+ if (typeof value === "string") {
573
+ content = value;
574
+ } else if (value !== undefined) {
575
+ try {
576
+ content = JSON.stringify(value, null, 2);
577
+ } catch {
578
+ content = String(value);
579
+ }
580
+ }
581
+ }
582
+
583
+ if (!content) {
584
+ content = "(no tool output)";
585
+ }
586
+
587
+ const truncated = content.length > maxLength;
588
+ return {
589
+ content: truncated ? `${content.slice(0, maxLength - 3)}...` : content,
590
+ truncated,
591
+ };
592
+ }
593
+
594
+ function ensureToolCallEntry(
595
+ state: BtwTranscriptState,
596
+ turnId: number,
597
+ toolCallId: string,
598
+ toolName: string,
599
+ args: string,
600
+ ): { turnId: number; callEntryId: number; resultEntryId?: number } {
601
+ const existing = state.toolCalls.get(toolCallId);
602
+ if (existing) {
603
+ return existing;
604
+ }
605
+
606
+ const callEntry = appendTranscriptEntry(state, {
607
+ type: "tool-call",
608
+ turnId,
609
+ toolCallId,
610
+ toolName,
611
+ args,
612
+ } as Omit<Extract<BtwTranscriptEntry, { type: "tool-call" }>, "id">);
613
+ const record = { turnId, callEntryId: callEntry.id };
614
+ state.toolCalls.set(toolCallId, record);
615
+ return record;
616
+ }
617
+
618
+ function upsertToolResultEntry(
619
+ state: BtwTranscriptState,
620
+ turnId: number,
621
+ toolCallId: string,
622
+ toolName: string,
623
+ content: string,
624
+ truncated: boolean,
625
+ isError: boolean,
626
+ streaming: boolean,
627
+ ): void {
628
+ const toolCall = ensureToolCallEntry(state, turnId, toolCallId, toolName, "");
629
+ const existing =
630
+ toolCall.resultEntryId !== undefined
631
+ ? state.entries.find((entry) => entry.id === toolCall.resultEntryId && entry.type === "tool-result")
632
+ : undefined;
633
+
634
+ if (existing && existing.type === "tool-result") {
635
+ existing.content = content;
636
+ existing.truncated = truncated;
637
+ existing.isError = isError;
638
+ existing.streaming = streaming;
639
+ return;
640
+ }
641
+
642
+ const resultEntry = appendTranscriptEntry(state, {
643
+ type: "tool-result",
644
+ turnId,
645
+ toolCallId,
646
+ toolName,
647
+ content,
648
+ truncated,
649
+ isError,
650
+ streaming,
651
+ } as Omit<Extract<BtwTranscriptEntry, { type: "tool-result" }>, "id">);
652
+ toolCall.resultEntryId = resultEntry.id;
653
+ }
654
+
655
+ function applyAssistantMessageToTranscript(
656
+ state: BtwTranscriptState,
657
+ turnId: number,
658
+ message: AssistantMessage,
659
+ streaming: boolean,
660
+ ): void {
661
+ const assistantMessage = message;
662
+ const thinking = extractThinking(assistantMessage);
663
+ const answer = extractMessageText(assistantMessage);
664
+
665
+ if (thinking) {
666
+ upsertTranscriptTextEntry(state, turnId, "thinking", thinking, streaming);
667
+ }
668
+
669
+ if (answer) {
670
+ upsertTranscriptTextEntry(state, turnId, "assistant-text", answer, streaming);
671
+ }
672
+ }
673
+
674
+ function applyTranscriptEvent(state: BtwTranscriptState, event: AgentSessionEvent): void {
675
+ switch (event.type) {
676
+ case "turn_start": {
677
+ ensureTranscriptTurn(state);
678
+ return;
679
+ }
680
+ case "message_start": {
681
+ if (event.message.role === "user") {
682
+ const turnId = ensureTranscriptTurnForUserMessage(state);
683
+ upsertUserMessageEntry(state, turnId, extractMessageText(event.message));
684
+ return;
685
+ }
686
+
687
+ if (event.message.role === "assistant") {
688
+ const turnId = ensureTranscriptTurn(state);
689
+ applyAssistantMessageToTranscript(state, turnId, event.message, true);
690
+ }
691
+ return;
692
+ }
693
+ case "message_update": {
694
+ if (event.message.role !== "assistant") {
695
+ return;
696
+ }
697
+
698
+ const turnId = ensureTranscriptTurn(state);
699
+ applyAssistantMessageToTranscript(state, turnId, event.message, true);
700
+ return;
701
+ }
702
+ case "message_end": {
703
+ if (event.message.role === "user") {
704
+ const turnId = ensureTranscriptTurnForUserMessage(state);
705
+ upsertUserMessageEntry(state, turnId, extractMessageText(event.message));
706
+ return;
707
+ }
708
+
709
+ if (event.message.role === "assistant") {
710
+ const turnId = ensureTranscriptTurn(state);
711
+ applyAssistantMessageToTranscript(state, turnId, event.message, false);
712
+ }
713
+ return;
714
+ }
715
+ case "tool_execution_start": {
716
+ const turnId = ensureTranscriptTurn(state);
717
+ ensureToolCallEntry(state, turnId, event.toolCallId, event.toolName, formatToolPreview(event.args));
718
+ return;
719
+ }
720
+ case "tool_execution_update": {
721
+ const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state);
722
+ const result = summarizeToolResult(event.partialResult);
723
+ upsertToolResultEntry(
724
+ state,
725
+ turnId,
726
+ event.toolCallId,
727
+ event.toolName,
728
+ result.content,
729
+ result.truncated,
730
+ false,
731
+ true,
732
+ );
733
+ return;
734
+ }
735
+ case "tool_execution_end": {
736
+ const turnId = state.toolCalls.get(event.toolCallId)?.turnId ?? ensureTranscriptTurn(state);
737
+ const result = summarizeToolResult(event.result);
738
+ upsertToolResultEntry(
739
+ state,
740
+ turnId,
741
+ event.toolCallId,
742
+ event.toolName,
743
+ result.content,
744
+ result.truncated,
745
+ event.isError,
746
+ false,
747
+ );
748
+ return;
749
+ }
750
+ case "turn_end": {
751
+ finishTranscriptTurn(state);
752
+ return;
753
+ }
754
+ default:
755
+ return;
756
+ }
757
+ }
758
+
759
+ function appendPersistedTranscriptTurn(state: BtwTranscriptState, details: BtwDetails): void {
760
+ const turnId = ensureTranscriptTurn(state);
761
+ upsertUserMessageEntry(state, turnId, details.question);
762
+ if (details.thinking) {
763
+ upsertTranscriptTextEntry(state, turnId, "thinking", details.thinking, false);
764
+ }
765
+ upsertTranscriptTextEntry(state, turnId, "assistant-text", details.answer, false);
766
+ finishTranscriptTurn(state, turnId);
767
+ }
768
+
769
+ function setTranscriptFailure(state: BtwTranscriptState, message: string): void {
770
+ const turnId = state.currentTurnId ?? state.lastTurnId ?? ensureTranscriptTurn(state);
771
+ upsertTranscriptTextEntry(state, turnId, "assistant-text", `❌ ${message}`, false);
772
+ finishTranscriptTurn(state, turnId);
773
+ }
774
+
775
+ function hasStreamingTranscriptEntry(entries: BtwTranscript): boolean {
776
+ return entries.some(
777
+ (entry) =>
778
+ (entry.type === "thinking" || entry.type === "assistant-text" || entry.type === "tool-result") &&
779
+ entry.streaming,
780
+ );
781
+ }
782
+
783
+ function getCompletedExchangeCount(entries: BtwTranscript): number {
784
+ return entries.filter((entry) => entry.type === "assistant-text" && !entry.streaming).length;
785
+ }
786
+
787
+ function buildOverlayTranscript(entries: BtwTranscript, theme: ExtensionContext["ui"]["theme"]): string[] {
788
+ if (entries.length === 0) {
789
+ return [theme.fg("dim", "暂无 BTW 对话。输入侧边问题开始。")];
790
+ }
791
+
792
+ const lines: string[] = [];
793
+ const userBadge = buildTranscriptBadge(theme, "You", "userMessageBg", "accent");
794
+ const thinkingBadge = buildTranscriptBadge(theme, "Thinking", "toolPendingBg", "warning");
795
+ const toolBadge = buildTranscriptBadge(theme, "Tool", "toolPendingBg", "warning");
796
+ const assistantBadge = buildTranscriptBadge(theme, "Assistant", "customMessageBg", "success");
797
+ const separator = theme.fg("borderMuted", "────────────────────────────────────────");
798
+ const blockIndent = " ";
799
+ const resultIndent = blockIndent;
800
+
801
+ const pushBlankLine = () => {
802
+ if (lines.length > 0 && lines[lines.length - 1] !== "") {
803
+ lines.push("");
804
+ }
805
+ };
806
+
807
+ const pushInlineBlock = (
808
+ header: string,
809
+ text: string,
810
+ options: { blankBefore?: boolean; style?: (value: string) => string } = {},
811
+ ) => {
812
+ const bodyLines = text.split("\n");
813
+ const style = options.style ?? ((value: string) => value);
814
+ if (options.blankBefore !== false) {
815
+ pushBlankLine();
816
+ }
817
+
818
+ const firstLine = bodyLines.shift() ?? "";
819
+ lines.push(`${header}${firstLine ? ` ${style(firstLine)}` : ""}`);
820
+ for (const line of bodyLines) {
821
+ lines.push(`${blockIndent}${style(line)}`);
822
+ }
823
+ };
824
+
825
+ const pushStackedBlock = (
826
+ header: string,
827
+ text: string,
828
+ options: { blankBefore?: boolean; indent?: string; style?: (value: string) => string } = {},
829
+ ) => {
830
+ const bodyLines = text.split("\n");
831
+ const indent = options.indent ?? blockIndent;
832
+ const style = options.style ?? ((value: string) => value);
833
+ if (options.blankBefore !== false) {
834
+ pushBlankLine();
835
+ }
836
+
837
+ lines.push(header);
838
+ for (const line of bodyLines) {
839
+ lines.push(`${indent}${style(line)}`);
840
+ }
841
+ };
842
+
843
+ for (const entry of entries) {
844
+ if (entry.type === "turn-boundary") {
845
+ if (entry.phase === "start" && lines.length > 0) {
846
+ pushBlankLine();
847
+ lines.push(separator);
848
+ }
849
+ continue;
850
+ }
851
+
852
+ if (entry.type === "user-message") {
853
+ pushInlineBlock(userBadge, entry.text, { blankBefore: false });
854
+ continue;
855
+ }
856
+
857
+ if (entry.type === "thinking") {
858
+ const thinkingHeader = entry.streaming ? `${thinkingBadge} ${theme.fg("warning", "▍")}` : thinkingBadge;
859
+ pushStackedBlock(thinkingHeader, entry.text, {
860
+ style: (line) => theme.fg("warning", theme.italic(line)),
861
+ });
862
+ continue;
863
+ }
864
+
865
+ if (entry.type === "tool-call") {
866
+ const toolLabel = theme.fg("warning", theme.bold(entry.toolName));
867
+ const argsLabel = entry.args ? theme.fg("dim", ` · ${entry.args}`) : "";
868
+ pushInlineBlock(toolBadge, `${toolLabel}${argsLabel}`);
869
+ continue;
870
+ }
871
+
872
+ if (entry.type === "tool-result") {
873
+ const resultHeaderLabel = entry.isError
874
+ ? theme.fg("error", "↳ error")
875
+ : entry.streaming
876
+ ? theme.fg("warning", "↳ 流式输出中")
877
+ : theme.fg("dim", "↳ result");
878
+ const truncationLabel = entry.truncated ? theme.fg("dim", " (truncated)") : "";
879
+ pushStackedBlock(`${resultHeaderLabel}${truncationLabel}`, entry.content, {
880
+ blankBefore: false,
881
+ indent: resultIndent,
882
+ style: (line) => (entry.isError ? theme.fg("error", line) : theme.fg("dim", line)),
883
+ });
884
+ continue;
885
+ }
886
+
887
+ if (entry.type === "assistant-text") {
888
+ const assistantHeader = entry.streaming ? `${assistantBadge} ${theme.fg("warning", "▍")}` : assistantBadge;
889
+ pushStackedBlock(assistantHeader, entry.text);
890
+ }
891
+ }
892
+
893
+ return lines;
894
+ }
895
+
896
+ function getLastAssistantMessage(session: AgentSession): AssistantMessage | null {
897
+ for (let i = session.state.messages.length - 1; i >= 0; i--) {
898
+ const message = session.state.messages[i];
899
+ if (message.role === "assistant") {
900
+ return message as AssistantMessage;
901
+ }
902
+ }
903
+
904
+ return null;
905
+ }
906
+
907
+ type BtwHandoffExchange = {
908
+ user: string;
909
+ assistant: string;
910
+ };
911
+
912
+ function buildBtwMessageContent(question: string, answer: string): string {
913
+ return `Q: ${question}\n\nA: ${answer}`;
914
+ }
915
+
916
+ function formatThread(thread: BtwHandoffExchange[]): string {
917
+ return thread.map((entry) => `User: ${entry.user.trim()}\nAssistant: ${entry.assistant.trim()}`).join("\n\n---\n\n");
918
+ }
919
+
920
+ function isThreadContinuationMarker(messages: Message[], index: number): boolean {
921
+ const userMessage = messages[index];
922
+ const assistantMessage = messages[index + 1];
923
+ return (
924
+ userMessage?.role === "user" &&
925
+ extractMessageText(userMessage) === BTW_CONTINUE_THREAD_USER_TEXT &&
926
+ assistantMessage?.role === "assistant" &&
927
+ extractMessageText(assistantMessage) === BTW_CONTINUE_THREAD_ASSISTANT_TEXT
928
+ );
929
+ }
930
+
931
+ function extractBtwHandoffThread(sessionRuntime: BtwSessionRuntime): BtwHandoffExchange[] {
932
+ const handoffMessages = sessionRuntime.session.state.messages.slice(sessionRuntime.sideThreadStartIndex);
933
+ const threadMessages = isThreadContinuationMarker(handoffMessages as Message[], 0) ? handoffMessages.slice(2) : handoffMessages;
934
+ const exchanges: BtwHandoffExchange[] = [];
935
+ let currentUser = "";
936
+ let currentAssistant = "";
937
+
938
+ const pushCurrent = () => {
939
+ if (!currentUser && !currentAssistant) {
940
+ return;
941
+ }
942
+
943
+ exchanges.push({
944
+ user: currentUser.trim() || "(No user prompt)",
945
+ assistant: currentAssistant.trim() || "(No assistant response)",
946
+ });
947
+ currentUser = "";
948
+ currentAssistant = "";
949
+ };
950
+
951
+ for (const message of threadMessages) {
952
+ if (message.role !== "user" && message.role !== "assistant") {
953
+ continue;
954
+ }
955
+
956
+ const text = extractMessageText(message).trim();
957
+ if (!text) {
958
+ continue;
959
+ }
960
+
961
+ if (message.role === "user") {
962
+ pushCurrent();
963
+ currentUser = text;
964
+ continue;
965
+ }
966
+
967
+ currentAssistant = currentAssistant ? `${currentAssistant}\n\n${text}` : text;
968
+ }
969
+
970
+ pushCurrent();
971
+ return exchanges;
972
+ }
973
+
974
+ function saveVisibleBtwNote(
975
+ pi: ExtensionAPI,
976
+ details: BtwDetails,
977
+ saveRequested: boolean,
978
+ wasBusy: boolean,
979
+ ): SaveState {
980
+ if (!saveRequested) {
981
+ return "not-saved";
982
+ }
983
+
984
+ const message = {
985
+ customType: BTW_MESSAGE_TYPE,
986
+ content: buildBtwMessageContent(details.question, details.answer),
987
+ display: true,
988
+ details,
989
+ };
990
+
991
+ if (wasBusy) {
992
+ pi.sendMessage(message, { deliverAs: "followUp" });
993
+ return "queued";
994
+ }
995
+
996
+ pi.sendMessage(message);
997
+ return "saved";
998
+ }
999
+
1000
+ function notify(ctx: ExtensionContext | ExtensionCommandContext, message: string, level: "info" | "warning" | "error"): void {
1001
+ if (ctx.hasUI) {
1002
+ ctx.ui.notify(message, level);
1003
+ }
1004
+ }
1005
+
1006
+ function getOverlayTitle(mode: BtwThreadMode): string {
1007
+ return mode === "tangent" ? "AICODE-CLI BTW · tangent" : "AICODE-CLI BTW";
1008
+ }
1009
+
1010
+ function buildTranscriptBadge(
1011
+ theme: ExtensionContext["ui"]["theme"],
1012
+ label: string,
1013
+ background: "userMessageBg" | "toolPendingBg" | "customMessageBg",
1014
+ foreground: "accent" | "warning" | "success",
1015
+ ): string {
1016
+ return theme.bg(background, theme.fg(foreground, theme.bold(` ${label} `)));
1017
+ }
1018
+
1019
+ class BtwOverlayComponent extends Container implements Focusable {
1020
+ private readonly input: Input;
1021
+ private readonly transcript: Container;
1022
+ private readonly statusText: Text;
1023
+ private readonly modeText: Text;
1024
+ private readonly summaryText: Text;
1025
+ private readonly hintsText: Text;
1026
+ private readonly readTranscriptEntries: () => BtwTranscript;
1027
+ private readonly getStatus: () => string | null;
1028
+ private readonly getMode: () => BtwThreadMode;
1029
+ private readonly onSubmitCallback: (value: string) => void;
1030
+ private readonly onDismissCallback: () => void;
1031
+ private readonly onUnfocusCallback: () => void;
1032
+ private readonly tui: TUI;
1033
+ private readonly theme: ExtensionContext["ui"]["theme"];
1034
+ private transcriptLines: string[] = [];
1035
+ private transcriptScrollOffset = 0;
1036
+ private transcriptViewportHeight = 8;
1037
+ private followTranscript = true;
1038
+ private _focused = false;
1039
+ private modeTextValue = "";
1040
+ private summaryTextValue = "";
1041
+ private statusTextValue = "";
1042
+ private hintsTextValue = "";
1043
+
1044
+ get focused(): boolean {
1045
+ return this._focused;
1046
+ }
1047
+
1048
+ set focused(value: boolean) {
1049
+ this._focused = value;
1050
+ this.input.focused = value;
1051
+ }
1052
+
1053
+ constructor(
1054
+ tui: TUI,
1055
+ theme: ExtensionContext["ui"]["theme"],
1056
+ keybindings: KeybindingsManager,
1057
+ readTranscriptEntries: () => BtwTranscript,
1058
+ getStatus: () => string | null,
1059
+ getMode: () => BtwThreadMode,
1060
+ onSubmit: (value: string) => void,
1061
+ onDismiss: () => void,
1062
+ onUnfocus: () => void,
1063
+ ) {
1064
+ super();
1065
+ this.tui = tui;
1066
+ this.theme = theme;
1067
+ this.readTranscriptEntries = readTranscriptEntries;
1068
+ this.getStatus = getStatus;
1069
+ this.getMode = getMode;
1070
+ this.onSubmitCallback = onSubmit;
1071
+ this.onDismissCallback = onDismiss;
1072
+ this.onUnfocusCallback = onUnfocus;
1073
+
1074
+ this.modeText = new Text("", 1, 0);
1075
+ this.summaryText = new Text("", 1, 0);
1076
+ this.transcript = new Container();
1077
+ this.statusText = new Text("", 1, 0);
1078
+
1079
+ this.input = new Input();
1080
+ this.input.onSubmit = (value) => {
1081
+ this.followTranscript = true;
1082
+ this.onSubmitCallback(value);
1083
+ };
1084
+ this.input.onEscape = () => {
1085
+ this.onDismissCallback();
1086
+ };
1087
+
1088
+ this.hintsText = new Text("", 1, 0);
1089
+
1090
+ const originalHandleInput = this.input.handleInput.bind(this.input);
1091
+ this.input.handleInput = (data: string) => {
1092
+ if (keybindings.matches(data, "app.clear")) {
1093
+ if (this.input.getValue().length > 0) {
1094
+ this.input.setValue("");
1095
+ this.tui.requestRender();
1096
+ return;
1097
+ }
1098
+
1099
+ this.onDismissCallback();
1100
+ return;
1101
+ }
1102
+
1103
+ if (keybindings.matches(data, "tui.select.cancel")) {
1104
+ this.onDismissCallback();
1105
+ return;
1106
+ }
1107
+ originalHandleInput(data);
1108
+ };
1109
+
1110
+ this.refresh();
1111
+ }
1112
+
1113
+ private frameLine(content: string, innerWidth: number): string {
1114
+ const truncated = truncateToWidth(content, innerWidth, "");
1115
+ const padding = Math.max(0, innerWidth - visibleWidth(truncated));
1116
+ return `${this.theme.fg("borderMuted", "│")}${truncated}${" ".repeat(padding)}${this.theme.fg("borderMuted", "│")}`;
1117
+ }
1118
+
1119
+ private ruleLine(innerWidth: number): string {
1120
+ return this.theme.fg("borderMuted", `├${"─".repeat(innerWidth)}┤`);
1121
+ }
1122
+
1123
+ private borderLine(innerWidth: number, edge: "top" | "bottom"): string {
1124
+ const left = edge === "top" ? "┌" : "└";
1125
+ const right = edge === "top" ? "┐" : "┘";
1126
+ return this.theme.fg("borderMuted", `${left}${"─".repeat(innerWidth)}${right}`);
1127
+ }
1128
+
1129
+ private wrapTranscript(innerWidth: number): string[] {
1130
+ const wrapped: string[] = [];
1131
+ for (const line of this.transcriptLines) {
1132
+ if (!line) {
1133
+ wrapped.push("");
1134
+ continue;
1135
+ }
1136
+ wrapped.push(...wrapTextWithAnsi(line, Math.max(1, innerWidth)));
1137
+ }
1138
+ return wrapped;
1139
+ }
1140
+
1141
+ private getDialogHeight(): number {
1142
+ const terminalRows = process.stdout.rows ?? 30;
1143
+ return Math.max(18, Math.min(32, Math.floor(terminalRows * 0.78)));
1144
+ }
1145
+
1146
+ handleInput(data: string): void {
1147
+ if (matchesBtwFocusShortcut(data)) {
1148
+ this.onUnfocusCallback();
1149
+ return;
1150
+ }
1151
+
1152
+ if (matchesKey(data, Key.pageUp)) {
1153
+ this.followTranscript = false;
1154
+ this.transcriptScrollOffset = Math.max(0, this.transcriptScrollOffset - Math.max(1, this.transcriptViewportHeight - 1));
1155
+ this.tui.requestRender();
1156
+ return;
1157
+ }
1158
+
1159
+ if (matchesKey(data, Key.pageDown)) {
1160
+ this.transcriptScrollOffset += Math.max(1, this.transcriptViewportHeight - 1);
1161
+ this.tui.requestRender();
1162
+ return;
1163
+ }
1164
+
1165
+ this.input.handleInput(data);
1166
+ }
1167
+
1168
+ private inputFrameLine(dialogWidth: number): string {
1169
+ const targetWidth = Math.max(1, dialogWidth - 2);
1170
+ const previousFocused = this.input.focused;
1171
+ // Input.render() emits CURSOR_MARKER when focused. In overlay mode that APC marker
1172
+ // can skew width/composition on this one row before the TUI strips it, producing a
1173
+ // right-edge notch and shifted border. Render the embedded input unfocused here so
1174
+ // the row stays geometrically stable while the overlay still owns keyboard input.
1175
+ this.input.focused = false;
1176
+ try {
1177
+ const inputLine = this.input.render(targetWidth)[0] ?? "";
1178
+ return `${this.theme.fg("borderMuted", "│")}${inputLine}${this.theme.fg("borderMuted", "│")}`;
1179
+ } finally {
1180
+ this.input.focused = previousFocused;
1181
+ }
1182
+ }
1183
+
1184
+ override render(width: number): string[] {
1185
+ const dialogWidth = Math.max(24, width);
1186
+ const innerWidth = Math.max(22, dialogWidth - 2);
1187
+ const transcriptLines = this.wrapTranscript(innerWidth);
1188
+ const dialogHeight = this.getDialogHeight();
1189
+ const chromeHeight = 8;
1190
+ const transcriptHeight = Math.max(6, dialogHeight - chromeHeight);
1191
+ this.transcriptViewportHeight = transcriptHeight;
1192
+
1193
+ const maxScroll = Math.max(0, transcriptLines.length - transcriptHeight);
1194
+ if (this.followTranscript) {
1195
+ this.transcriptScrollOffset = maxScroll;
1196
+ } else {
1197
+ this.transcriptScrollOffset = Math.max(0, Math.min(this.transcriptScrollOffset, maxScroll));
1198
+ if (this.transcriptScrollOffset >= maxScroll) {
1199
+ this.followTranscript = true;
1200
+ }
1201
+ }
1202
+
1203
+ const visibleTranscript = transcriptLines.slice(
1204
+ this.transcriptScrollOffset,
1205
+ this.transcriptScrollOffset + transcriptHeight,
1206
+ );
1207
+ const transcriptPadCount = Math.max(0, transcriptHeight - visibleTranscript.length);
1208
+ const hiddenAbove = this.transcriptScrollOffset;
1209
+ const hiddenBelow = Math.max(0, maxScroll - this.transcriptScrollOffset);
1210
+ const summary =
1211
+ hiddenAbove || hiddenBelow
1212
+ ? `${this.summaryTextValue.trim()} · ↑${hiddenAbove} ↓${hiddenBelow}`
1213
+ : this.summaryTextValue.trim();
1214
+
1215
+ const lines = [this.borderLine(innerWidth, "top")];
1216
+
1217
+ lines.push(this.frameLine(this.theme.fg("accent", this.theme.bold(this.modeTextValue.trim())), innerWidth));
1218
+ lines.push(this.frameLine(this.theme.fg("dim", summary), innerWidth));
1219
+ lines.push(this.ruleLine(innerWidth));
1220
+
1221
+ for (const line of visibleTranscript) {
1222
+ lines.push(this.frameLine(line, innerWidth));
1223
+ }
1224
+ for (let i = 0; i < transcriptPadCount; i++) {
1225
+ lines.push(this.frameLine("", innerWidth));
1226
+ }
1227
+
1228
+ lines.push(this.ruleLine(innerWidth));
1229
+ lines.push(this.frameLine(this.theme.fg("warning", this.statusTextValue.trim()), innerWidth));
1230
+ lines.push(this.inputFrameLine(dialogWidth));
1231
+ lines.push(this.frameLine(this.theme.fg("dim", this.hintsTextValue.trim()), innerWidth));
1232
+ lines.push(this.borderLine(innerWidth, "bottom"));
1233
+
1234
+ return lines;
1235
+ }
1236
+
1237
+ setDraft(value: string): void {
1238
+ this.input.setValue(value);
1239
+ this.tui.requestRender();
1240
+ }
1241
+
1242
+ getDraft(): string {
1243
+ return this.input.getValue();
1244
+ }
1245
+
1246
+ getTranscriptEntries(): BtwTranscript {
1247
+ return this.readTranscriptEntries().map((entry) => ({ ...entry }));
1248
+ }
1249
+
1250
+ refresh(): void {
1251
+ this.modeTextValue = `${getOverlayTitle(this.getMode())} · ${this.getMode() === "tangent" ? "无上下文" : "带主上下文"} · 线程已保留`;
1252
+ this.modeText.setText(this.modeTextValue);
1253
+ const entries = this.readTranscriptEntries();
1254
+ const exchanges = getCompletedExchangeCount(entries);
1255
+ const active = hasStreamingTranscriptEntry(entries) ? " · 流式输出中" : " · 空闲";
1256
+ this.summaryTextValue = `${exchanges} 轮对话${active}`;
1257
+ this.summaryText.setText(this.summaryTextValue);
1258
+
1259
+ this.transcriptLines = buildOverlayTranscript(entries, this.theme);
1260
+ this.transcript.clear();
1261
+ for (const line of this.transcriptLines) {
1262
+ this.transcript.addChild(new Text(line, 1, 0));
1263
+ }
1264
+
1265
+ const status = this.getStatus() ?? "就绪。Enter 提交,Escape 取消。";
1266
+ this.statusTextValue = status;
1267
+ this.statusText.setText(this.statusTextValue);
1268
+ this.hintsTextValue = "Enter 提交 · Alt+/ 切换焦点 · Escape 取消 · PgUp/PgDn 滚动";
1269
+ this.hintsText.setText(this.hintsTextValue);
1270
+ this.tui.requestRender();
1271
+ }
1272
+ }
1273
+
1274
+ export default function (pi: ExtensionAPI) {
1275
+ let pendingThread: BtwDetails[] = [];
1276
+ let pendingMode: BtwThreadMode = "contextual";
1277
+ let pendingSaveRequested = false;
1278
+ let btwModelOverride: SessionModel | null = null;
1279
+ let btwThinkingOverride: SessionThinkingLevel | null = null;
1280
+ let transcriptState = createEmptyTranscriptState();
1281
+ let overlayStatus: string | null = null;
1282
+ let overlayDraft = "";
1283
+ let overlayRuntime: OverlayRuntime | null = null;
1284
+ let lastUiContext: ExtensionContext | ExtensionCommandContext | null = null;
1285
+ let activeBtwSession: BtwSessionRuntime | null = null;
1286
+
1287
+ function syncUi(ctx?: ExtensionContext | ExtensionCommandContext): void {
1288
+ const activeCtx = ctx ?? lastUiContext;
1289
+ if (activeCtx?.hasUI) {
1290
+ activeCtx.ui.setWidget("btw", undefined);
1291
+ overlayRuntime?.refresh?.();
1292
+ }
1293
+ }
1294
+
1295
+ function setOverlayStatus(status: string | null, ctx?: ExtensionContext | ExtensionCommandContext): void {
1296
+ overlayStatus = status;
1297
+ syncUi(ctx);
1298
+ }
1299
+
1300
+ function setOverlayDraft(value: string): void {
1301
+ overlayDraft = value;
1302
+ overlayRuntime?.setDraft?.(value);
1303
+ }
1304
+
1305
+ function dismissOverlay(): void {
1306
+ overlayRuntime?.close?.();
1307
+ overlayRuntime = null;
1308
+ }
1309
+
1310
+ function toggleOverlayFocus(): void {
1311
+ const handle = overlayRuntime?.handle;
1312
+ if (!handle) {
1313
+ return;
1314
+ }
1315
+
1316
+ handle.setHidden(false);
1317
+ if (handle.isFocused()) {
1318
+ handle.unfocus();
1319
+ } else {
1320
+ handle.focus();
1321
+ }
1322
+ overlayRuntime?.refresh?.();
1323
+ }
1324
+
1325
+ function focusOverlay(): void {
1326
+ const handle = overlayRuntime?.handle;
1327
+ if (!handle) {
1328
+ return;
1329
+ }
1330
+
1331
+ handle.setHidden(false);
1332
+ handle.focus();
1333
+ overlayRuntime?.refresh?.();
1334
+ }
1335
+
1336
+ function removeBtwSessionSubscription(sessionRuntime: BtwSessionRuntime, unsubscribe: () => void): void {
1337
+ if (!sessionRuntime.subscriptions.delete(unsubscribe)) {
1338
+ return;
1339
+ }
1340
+
1341
+ try {
1342
+ unsubscribe();
1343
+ } catch {
1344
+ // Ignore unsubscribe errors during BTW session replacement/shutdown.
1345
+ }
1346
+ }
1347
+
1348
+ function clearBtwSessionSubscriptions(sessionRuntime: BtwSessionRuntime): void {
1349
+ for (const unsubscribe of [...sessionRuntime.subscriptions]) {
1350
+ removeBtwSessionSubscription(sessionRuntime, unsubscribe);
1351
+ }
1352
+ }
1353
+
1354
+ function handleBtwSessionEvent(
1355
+ sessionRuntime: BtwSessionRuntime,
1356
+ event: AgentSessionEvent,
1357
+ ctx?: ExtensionContext | ExtensionCommandContext,
1358
+ ): void {
1359
+ if (activeBtwSession?.session !== sessionRuntime.session || !overlayRuntime) {
1360
+ return;
1361
+ }
1362
+
1363
+ applyTranscriptEvent(transcriptState, event);
1364
+
1365
+ if (event.type === "tool_execution_start") {
1366
+ setOverlayStatus(`⏳ 运行工具: ${event.toolName}`, ctx);
1367
+ return;
1368
+ }
1369
+
1370
+ if (event.type === "tool_execution_end") {
1371
+ setOverlayStatus(sessionRuntime.session.isStreaming ? `⏳ 运行工具: ${event.toolName}` : "⏳ 流式输出中...", ctx);
1372
+ return;
1373
+ }
1374
+
1375
+ if (event.type === "turn_end") {
1376
+ setOverlayStatus("⏳ 流式输出中...", ctx);
1377
+ return;
1378
+ }
1379
+
1380
+ if (
1381
+ event.type === "message_start" ||
1382
+ event.type === "message_update" ||
1383
+ event.type === "message_end" ||
1384
+ event.type === "turn_start"
1385
+ ) {
1386
+ syncUi(ctx);
1387
+ }
1388
+ }
1389
+
1390
+ function subscribeOverlayToActiveBtwSession(ctx?: ExtensionContext | ExtensionCommandContext): void {
1391
+ const sessionRuntime = activeBtwSession;
1392
+ if (!sessionRuntime || sessionRuntime.subscriptions.size > 0) {
1393
+ return;
1394
+ }
1395
+
1396
+ const unsubscribe = sessionRuntime.session.subscribe((event: AgentSessionEvent) => {
1397
+ handleBtwSessionEvent(sessionRuntime, event, ctx);
1398
+ });
1399
+ sessionRuntime.subscriptions.add(unsubscribe);
1400
+ }
1401
+
1402
+ async function disposeBtwSession(): Promise<void> {
1403
+ const current = activeBtwSession;
1404
+ activeBtwSession = null;
1405
+ if (!current) {
1406
+ return;
1407
+ }
1408
+
1409
+ clearBtwSessionSubscriptions(current);
1410
+
1411
+ try {
1412
+ await current.session.abort();
1413
+ } catch {
1414
+ // Ignore abort errors during BTW session replacement/shutdown.
1415
+ }
1416
+
1417
+ current.session.dispose();
1418
+ }
1419
+
1420
+ async function dismissOverlaySession(): Promise<void> {
1421
+ dismissOverlay();
1422
+ await disposeBtwSession();
1423
+ }
1424
+
1425
+ async function resolveBtwModel(
1426
+ ctx: ExtensionCommandContext,
1427
+ notifyOnFallback = false,
1428
+ ): Promise<ResolvedBtwModel> {
1429
+ if (btwModelOverride) {
1430
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(btwModelOverride);
1431
+ if (auth.ok && auth.apiKey) {
1432
+ return {
1433
+ model: btwModelOverride,
1434
+ source: "override",
1435
+ configuredOverride: btwModelOverride,
1436
+ };
1437
+ }
1438
+
1439
+ const fallbackReason = ctx.model
1440
+ ? `Configured BTW model ${formatModelRef(btwModelOverride)} has no credentials. Falling back to main model ${formatModelRef(
1441
+ ctx.model,
1442
+ )}.`
1443
+ : `Configured BTW model ${formatModelRef(btwModelOverride)} has no credentials, and no main model is active.`;
1444
+ if (notifyOnFallback) {
1445
+ notify(ctx, fallbackReason, "warning");
1446
+ }
1447
+
1448
+ if (ctx.model) {
1449
+ return {
1450
+ model: ctx.model,
1451
+ source: "main",
1452
+ configuredOverride: btwModelOverride,
1453
+ fallbackReason,
1454
+ };
1455
+ }
1456
+
1457
+ return {
1458
+ model: null,
1459
+ source: "none",
1460
+ configuredOverride: btwModelOverride,
1461
+ fallbackReason,
1462
+ };
1463
+ }
1464
+
1465
+ if (ctx.model) {
1466
+ return {
1467
+ model: ctx.model,
1468
+ source: "main",
1469
+ configuredOverride: null,
1470
+ };
1471
+ }
1472
+
1473
+ return {
1474
+ model: null,
1475
+ source: "none",
1476
+ configuredOverride: null,
1477
+ };
1478
+ }
1479
+
1480
+ async function resolveBtwSettings(
1481
+ ctx: ExtensionCommandContext,
1482
+ notifyOnFallback = false,
1483
+ ): Promise<ResolvedBtwSettings> {
1484
+ const resolvedModel = await resolveBtwModel(ctx, notifyOnFallback);
1485
+ const thinkingLevel = btwThinkingOverride ?? (pi.getThinkingLevel() as SessionThinkingLevel);
1486
+
1487
+ return {
1488
+ model: resolvedModel.model,
1489
+ modelSource: resolvedModel.source,
1490
+ configuredModelOverride: resolvedModel.configuredOverride,
1491
+ thinkingLevel,
1492
+ thinkingSource: btwThinkingOverride ? "override" : "main",
1493
+ fallbackReason: resolvedModel.fallbackReason,
1494
+ };
1495
+ }
1496
+
1497
+ function describeResolvedModel(settings: ResolvedBtwSettings): string {
1498
+ if (!settings.model) {
1499
+ if (settings.configuredModelOverride && settings.fallbackReason) {
1500
+ return `BTW model unavailable. ${settings.fallbackReason}`;
1501
+ }
1502
+ return "BTW model unavailable. No active model selected.";
1503
+ }
1504
+
1505
+ const source =
1506
+ settings.modelSource === "override"
1507
+ ? "override"
1508
+ : settings.configuredModelOverride
1509
+ ? "inherited fallback"
1510
+ : "inherits main thread";
1511
+ return `BTW model: ${formatModelRef(settings.model)} (${source}).${
1512
+ settings.fallbackReason ? ` ${settings.fallbackReason}` : ""
1513
+ }`;
1514
+ }
1515
+
1516
+ function describeResolvedThinking(settings: ResolvedBtwSettings): string {
1517
+ const source = settings.thinkingSource === "override" ? "override" : "inherits main thread";
1518
+ return `BTW thinking: ${settings.thinkingLevel} (${source}).`;
1519
+ }
1520
+
1521
+ async function setBtwModelOverride(ctx: ExtensionCommandContext, nextModel: SessionModel | null): Promise<void> {
1522
+ btwModelOverride = nextModel;
1523
+ const details: BtwModelOverrideDetails = nextModel
1524
+ ? { action: "set", timestamp: Date.now(), provider: nextModel.provider, id: nextModel.id, api: nextModel.api }
1525
+ : { action: "clear", timestamp: Date.now() };
1526
+ pi.appendEntry(BTW_MODEL_OVERRIDE_TYPE, details);
1527
+ await disposeBtwSession();
1528
+ const settings = await resolveBtwSettings(ctx);
1529
+ const message = nextModel
1530
+ ? `BTW model override set to ${formatModelRef(nextModel)}.`
1531
+ : "BTW model override cleared. BTW now inherits the main thread model.";
1532
+ setOverlayStatus(message, ctx);
1533
+ notify(ctx, `${message} ${describeResolvedModel(settings)}`, "info");
1534
+ }
1535
+
1536
+ async function setBtwThinkingOverride(
1537
+ ctx: ExtensionCommandContext,
1538
+ nextThinkingLevel: SessionThinkingLevel | null,
1539
+ ): Promise<void> {
1540
+ btwThinkingOverride = nextThinkingLevel;
1541
+ const details: BtwThinkingOverrideDetails = nextThinkingLevel
1542
+ ? { action: "set", timestamp: Date.now(), thinkingLevel: nextThinkingLevel }
1543
+ : { action: "clear", timestamp: Date.now() };
1544
+ pi.appendEntry(BTW_THINKING_OVERRIDE_TYPE, details);
1545
+ await disposeBtwSession();
1546
+ const settings = await resolveBtwSettings(ctx);
1547
+ const message = nextThinkingLevel
1548
+ ? `BTW thinking override set to ${nextThinkingLevel}.`
1549
+ : "BTW thinking override cleared. BTW now inherits the main thread thinking level.";
1550
+ setOverlayStatus(message, ctx);
1551
+ notify(ctx, `${message} ${describeResolvedThinking(settings)}`, "info");
1552
+ }
1553
+
1554
+ async function createBtwSubSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime> {
1555
+ const settings = await resolveBtwSettings(ctx, true);
1556
+ if (!settings.model) {
1557
+ throw new Error(settings.fallbackReason || "No active model selected.");
1558
+ }
1559
+
1560
+ const { session } = await createAgentSession({
1561
+ sessionManager: SessionManager.inMemory(),
1562
+ model: settings.model,
1563
+ modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
1564
+ thinkingLevel: settings.thinkingLevel,
1565
+ // Match pi's default coding-agent toolset (read/bash/edit/write).
1566
+ tools: ["read", "bash", "edit", "write"],
1567
+ resourceLoader: createBtwResourceLoader(ctx),
1568
+ });
1569
+
1570
+ const { messages: seedMessages, sideThreadStartIndex } = buildBtwSeedState(ctx, pendingThread, mode, settings.model);
1571
+ if (seedMessages.length > 0) {
1572
+ session.agent.state.messages = seedMessages as typeof session.state.messages;
1573
+ }
1574
+
1575
+ return { session, mode, subscriptions: new Set(), sideThreadStartIndex };
1576
+ }
1577
+
1578
+ async function ensureBtwSession(ctx: ExtensionCommandContext, mode: BtwThreadMode): Promise<BtwSessionRuntime | null> {
1579
+ const settings = await resolveBtwSettings(ctx);
1580
+ if (!settings.model) {
1581
+ return null;
1582
+ }
1583
+
1584
+ if (activeBtwSession?.mode === mode) {
1585
+ return activeBtwSession;
1586
+ }
1587
+
1588
+ await disposeBtwSession();
1589
+ activeBtwSession = await createBtwSubSession(ctx, mode);
1590
+ return activeBtwSession;
1591
+ }
1592
+
1593
+ async function ensureOverlay(ctx: ExtensionCommandContext | ExtensionContext): Promise<void> {
1594
+ if (!ctx.hasUI) {
1595
+ return;
1596
+ }
1597
+ lastUiContext = ctx;
1598
+
1599
+ if (overlayRuntime?.handle) {
1600
+ subscribeOverlayToActiveBtwSession(ctx);
1601
+ focusOverlay();
1602
+ return;
1603
+ }
1604
+
1605
+ const runtime: OverlayRuntime = {};
1606
+ const closeRuntime = () => {
1607
+ if (runtime.closed) {
1608
+ return;
1609
+ }
1610
+ runtime.closed = true;
1611
+ if (activeBtwSession) {
1612
+ clearBtwSessionSubscriptions(activeBtwSession);
1613
+ }
1614
+ runtime.handle?.hide();
1615
+ if (overlayRuntime === runtime) {
1616
+ overlayRuntime = null;
1617
+ }
1618
+ runtime.finish?.();
1619
+ };
1620
+
1621
+ runtime.close = closeRuntime;
1622
+ overlayRuntime = runtime;
1623
+
1624
+ void ctx.ui
1625
+ .custom<void>(
1626
+ async (tui, theme, keybindings, done) => {
1627
+ runtime.finish = () => {
1628
+ done();
1629
+ };
1630
+
1631
+ const overlay = new BtwOverlayComponent(
1632
+ tui,
1633
+ theme,
1634
+ keybindings,
1635
+ () => transcriptState.entries,
1636
+ () => overlayStatus,
1637
+ () => pendingMode,
1638
+ (value) => {
1639
+ void submitFromOverlay(ctx, value);
1640
+ },
1641
+ () => {
1642
+ void dismissOverlaySession();
1643
+ },
1644
+ () => {
1645
+ overlayRuntime?.handle?.unfocus();
1646
+ overlayRuntime?.refresh?.();
1647
+ },
1648
+ );
1649
+
1650
+ overlay.focused = runtime.handle?.isFocused() ?? true;
1651
+ overlay.setDraft(overlayDraft);
1652
+ runtime.setDraft = (value) => {
1653
+ overlay.setDraft(value);
1654
+ };
1655
+ runtime.refresh = () => {
1656
+ overlay.focused = runtime.handle?.isFocused() ?? false;
1657
+ overlay.refresh();
1658
+ };
1659
+ runtime.close = () => {
1660
+ overlayDraft = overlay.getDraft();
1661
+ closeRuntime();
1662
+ };
1663
+
1664
+ subscribeOverlayToActiveBtwSession(ctx);
1665
+
1666
+ if (runtime.closed) {
1667
+ done();
1668
+ }
1669
+
1670
+ return overlay;
1671
+ },
1672
+ {
1673
+ overlay: true,
1674
+ overlayOptions: {
1675
+ width: "78%",
1676
+ minWidth: 72,
1677
+ maxHeight: "78%",
1678
+ anchor: "top-center",
1679
+ margin: { top: 1, left: 2, right: 2 },
1680
+ nonCapturing: true,
1681
+ },
1682
+ onHandle: (handle) => {
1683
+ runtime.handle = handle;
1684
+ handle.focus();
1685
+ if (runtime.closed) {
1686
+ closeRuntime();
1687
+ }
1688
+ },
1689
+ },
1690
+ )
1691
+ .catch((error) => {
1692
+ if (overlayRuntime === runtime) {
1693
+ overlayRuntime = null;
1694
+ }
1695
+ notify(ctx, error instanceof Error ? error.message : String(error), "error");
1696
+ });
1697
+ }
1698
+
1699
+ async function dispatchBtwCommand(name: string, args: string, ctx: ExtensionCommandContext): Promise<boolean> {
1700
+ const trimmedArgs = args.trim();
1701
+
1702
+ if (name === "btw") {
1703
+ const { question, save } = parseBtwArgs(trimmedArgs);
1704
+ if (!question) {
1705
+ await ensureBtwSession(ctx, pendingMode);
1706
+ await ensureOverlay(ctx);
1707
+ return true;
1708
+ }
1709
+
1710
+ if (pendingMode !== "contextual") {
1711
+ await resetThread(ctx, true, "contextual");
1712
+ }
1713
+
1714
+ await runBtw(ctx, question, save, "contextual");
1715
+ return true;
1716
+ }
1717
+
1718
+ if (name === "btw:tangent") {
1719
+ const { question, save } = parseBtwArgs(trimmedArgs);
1720
+ if (pendingMode !== "tangent") {
1721
+ await resetThread(ctx, true, "tangent");
1722
+ }
1723
+
1724
+ if (!question) {
1725
+ await ensureBtwSession(ctx, "tangent");
1726
+ await ensureOverlay(ctx);
1727
+ return true;
1728
+ }
1729
+
1730
+ await runBtw(ctx, question, save, "tangent");
1731
+ return true;
1732
+ }
1733
+
1734
+ if (name === "btw:new") {
1735
+ await resetThread(ctx, true, "contextual");
1736
+ const { question, save } = parseBtwArgs(trimmedArgs);
1737
+ if (question) {
1738
+ await runBtw(ctx, question, save, "contextual");
1739
+ } else {
1740
+ await ensureBtwSession(ctx, "contextual");
1741
+ setOverlayStatus("Started a fresh BTW thread.", ctx);
1742
+ await ensureOverlay(ctx);
1743
+ notify(ctx, "Started a fresh BTW thread.", "info");
1744
+ }
1745
+ return true;
1746
+ }
1747
+
1748
+ if (name === "btw:clear") {
1749
+ await resetThread(ctx);
1750
+ dismissOverlay();
1751
+ notify(ctx, "Cleared BTW thread.", "info");
1752
+ return true;
1753
+ }
1754
+
1755
+ if (name === "btw:model") {
1756
+ const parsed = parseBtwModelArgs(trimmedArgs);
1757
+ if (parsed.action === "invalid") {
1758
+ setOverlayStatus(parsed.message, ctx);
1759
+ notify(ctx, parsed.message, "error");
1760
+ return true;
1761
+ }
1762
+
1763
+ if (parsed.action === "show") {
1764
+ const settings = await resolveBtwSettings(ctx);
1765
+ const message = describeResolvedModel(settings);
1766
+ setOverlayStatus(message, ctx);
1767
+ notify(ctx, message, settings.model ? "info" : "warning");
1768
+ return true;
1769
+ }
1770
+
1771
+ if (parsed.action === "clear") {
1772
+ await setBtwModelOverride(ctx, null);
1773
+ return true;
1774
+ }
1775
+ const ref = parsed.model;
1776
+ const resolved = ctx.modelRegistry.find(ref.provider, ref.id);
1777
+ if (!resolved) {
1778
+ const message = `Unknown model ${ref.provider}/${ref.id}. Use /login or /models to add it before setting it as the BTW override.`;
1779
+ setOverlayStatus(message, ctx);
1780
+ notify(ctx, message, "error");
1781
+ return true;
1782
+ }
1783
+ await setBtwModelOverride(ctx, resolved);
1784
+ return true;
1785
+ }
1786
+
1787
+ if (name === "btw:thinking") {
1788
+ const parsed = parseBtwThinkingArgs(trimmedArgs);
1789
+ if (parsed.action === "show") {
1790
+ const settings = await resolveBtwSettings(ctx);
1791
+ const message = describeResolvedThinking(settings);
1792
+ setOverlayStatus(message, ctx);
1793
+ notify(ctx, message, "info");
1794
+ return true;
1795
+ }
1796
+
1797
+ await setBtwThinkingOverride(ctx, parsed.action === "clear" ? null : parsed.thinkingLevel);
1798
+ return true;
1799
+ }
1800
+
1801
+ if (name === "btw:inject") {
1802
+ if (pendingThread.length === 0) {
1803
+ notify(ctx, "暂无可注入的 BTW 对话。", "warning");
1804
+ return true;
1805
+ }
1806
+
1807
+ setOverlayStatus("⏳ 正在注入主对话...", ctx);
1808
+ await ensureOverlay(ctx);
1809
+
1810
+ try {
1811
+ const { thread } = await getBtwHandoffThread(ctx);
1812
+ const instructions = trimmedArgs;
1813
+ const content = instructions
1814
+ ? `Here is a side conversation I had. ${instructions}\n\n${formatThread(thread)}`
1815
+ : `Here is a side conversation I had for additional context:\n\n${formatThread(thread)}`;
1816
+
1817
+ sendThreadToMain(ctx, content);
1818
+ const count = thread.length;
1819
+ await resetThread(ctx);
1820
+ dismissOverlay();
1821
+ notify(ctx, `已注入 BTW 线程(${count} 轮对话)。`, "info");
1822
+ } catch (error) {
1823
+ setOverlayStatus("注入失败。线程已保留,可重试或总结。", ctx);
1824
+ notify(ctx, error instanceof Error ? error.message : String(error), "error");
1825
+ }
1826
+ return true;
1827
+ }
1828
+
1829
+ if (name === "btw:summarize") {
1830
+ if (pendingThread.length === 0) {
1831
+ notify(ctx, "暂无可总结的 BTW 对话。", "warning");
1832
+ return true;
1833
+ }
1834
+
1835
+ setOverlayStatus("⏳ 正在总结...", ctx);
1836
+ await ensureOverlay(ctx);
1837
+
1838
+ try {
1839
+ const { thread } = await getBtwHandoffThread(ctx);
1840
+ const summary = await summarizeThread(ctx, thread);
1841
+ const instructions = trimmedArgs;
1842
+ const content = instructions
1843
+ ? `Here is a summary of a side conversation I had. ${instructions}\n\n${summary}`
1844
+ : `Here is a summary of a side conversation I had:\n\n${summary}`;
1845
+
1846
+ sendThreadToMain(ctx, content);
1847
+ const count = thread.length;
1848
+ await resetThread(ctx);
1849
+ dismissOverlay();
1850
+ notify(ctx, `已注入 BTW 总结(${count} 轮对话)。`, "info");
1851
+ } catch (error) {
1852
+ setOverlayStatus("总结失败。线程已保留,可重试或注入。", ctx);
1853
+ notify(ctx, error instanceof Error ? error.message : String(error), "error");
1854
+ }
1855
+ return true;
1856
+ }
1857
+
1858
+ return false;
1859
+ }
1860
+
1861
+ function parseOverlayBtwCommand(value: string): { name: string; args: string } | null {
1862
+ const trimmed = value.trim();
1863
+ const match = trimmed.match(/^\/(btw:(?:new|tangent|clear|inject|summarize|model|thinking))(?:\s+(.*))?$/);
1864
+ if (!match) {
1865
+ return null;
1866
+ }
1867
+
1868
+ return {
1869
+ name: match[1],
1870
+ args: match[2]?.trim() ?? "",
1871
+ };
1872
+ }
1873
+
1874
+ async function submitFromOverlay(ctx: ExtensionCommandContext | ExtensionContext, value: string): Promise<void> {
1875
+ const question = value.trim();
1876
+ if (!question) {
1877
+ setOverlayStatus("Enter a BTW prompt before submitting.", ctx);
1878
+ return;
1879
+ }
1880
+
1881
+ if (!("getSystemPrompt" in ctx)) {
1882
+ setOverlayStatus("BTW overlay submit requires a command context. Reopen BTW from a command.", ctx);
1883
+ return;
1884
+ }
1885
+
1886
+ const cmdCtx = ctx as ExtensionCommandContext;
1887
+ const btwCommand = parseOverlayBtwCommand(question);
1888
+ if (btwCommand) {
1889
+ setOverlayDraft("");
1890
+ await dispatchBtwCommand(btwCommand.name, btwCommand.args, cmdCtx);
1891
+ return;
1892
+ }
1893
+
1894
+ setOverlayDraft("");
1895
+ setOverlayStatus("⏳ 流式输出中...", ctx);
1896
+ syncUi(ctx);
1897
+ await runBtw(cmdCtx, question, pendingSaveRequested, pendingMode);
1898
+ }
1899
+
1900
+ async function resetThread(
1901
+ ctx: ExtensionContext | ExtensionCommandContext,
1902
+ persist = true,
1903
+ mode: BtwThreadMode = "contextual",
1904
+ ): Promise<void> {
1905
+ await disposeBtwSession();
1906
+ pendingThread = [];
1907
+ pendingMode = mode;
1908
+ pendingSaveRequested = false;
1909
+ transcriptState = createEmptyTranscriptState();
1910
+ setOverlayDraft("");
1911
+ setOverlayStatus(null, ctx);
1912
+ if (persist) {
1913
+ const details: BtwResetDetails = { timestamp: Date.now(), mode };
1914
+ pi.appendEntry(BTW_RESET_TYPE, details);
1915
+ }
1916
+ syncUi(ctx);
1917
+ }
1918
+
1919
+ async function restoreThread(ctx: ExtensionContext): Promise<void> {
1920
+ await disposeBtwSession();
1921
+ pendingThread = [];
1922
+ pendingMode = "contextual";
1923
+ pendingSaveRequested = false;
1924
+ btwModelOverride = null;
1925
+ btwThinkingOverride = null;
1926
+ transcriptState = createEmptyTranscriptState();
1927
+ overlayDraft = "";
1928
+ lastUiContext = ctx;
1929
+ overlayStatus = null;
1930
+
1931
+ const branch = ctx.sessionManager.getBranch();
1932
+ let lastResetIndex = -1;
1933
+
1934
+ for (let i = 0; i < branch.length; i++) {
1935
+ if (isCustomEntry(branch[i], BTW_MODEL_OVERRIDE_TYPE)) {
1936
+ const details = (branch[i] as unknown as { data?: BtwModelOverrideDetails }).data;
1937
+ if (details?.action === "set") {
1938
+ const resolved = ctx.modelRegistry.find(details.provider, details.id);
1939
+ if (resolved) {
1940
+ btwModelOverride = resolved;
1941
+ } else {
1942
+ // Configured override is no longer in the registry; drop it on restore.
1943
+ btwModelOverride = null;
1944
+ }
1945
+ } else if (details?.action === "clear") {
1946
+ btwModelOverride = null;
1947
+ }
1948
+ }
1949
+
1950
+ if (isCustomEntry(branch[i], BTW_THINKING_OVERRIDE_TYPE)) {
1951
+ const details = (branch[i] as unknown as { data?: BtwThinkingOverrideDetails }).data;
1952
+ btwThinkingOverride =
1953
+ details?.action === "set"
1954
+ ? details.thinkingLevel
1955
+ : details?.action === "clear"
1956
+ ? null
1957
+ : btwThinkingOverride;
1958
+ }
1959
+
1960
+ if (isCustomEntry(branch[i], BTW_RESET_TYPE)) {
1961
+ lastResetIndex = i;
1962
+ const details = (branch[i] as unknown as { data?: BtwResetDetails }).data;
1963
+ pendingMode = details?.mode ?? "contextual";
1964
+ }
1965
+ }
1966
+
1967
+ for (const entry of branch.slice(lastResetIndex + 1)) {
1968
+ if (!isCustomEntry(entry, BTW_ENTRY_TYPE)) {
1969
+ continue;
1970
+ }
1971
+
1972
+ const details = (entry as unknown as { data?: BtwDetails }).data;
1973
+ if (!details?.question || !details.answer) {
1974
+ continue;
1975
+ }
1976
+
1977
+ const normalizedDetails: BtwDetails = {
1978
+ ...details,
1979
+ api: details.api || ctx.model?.api || "openai-responses",
1980
+ };
1981
+
1982
+ pendingThread.push(normalizedDetails);
1983
+ appendPersistedTranscriptTurn(transcriptState, normalizedDetails);
1984
+ }
1985
+
1986
+ syncUi(ctx);
1987
+ }
1988
+
1989
+ async function runBtw(
1990
+ ctx: ExtensionCommandContext,
1991
+ question: string,
1992
+ saveRequested: boolean,
1993
+ mode: BtwThreadMode,
1994
+ ): Promise<void> {
1995
+ lastUiContext = ctx;
1996
+ const settings = await resolveBtwSettings(ctx);
1997
+ const model = settings.model;
1998
+ if (!model) {
1999
+ const message = settings.fallbackReason || "No active model selected.";
2000
+ setOverlayStatus(message, ctx);
2001
+ notify(ctx, message, "error");
2002
+ return;
2003
+ }
2004
+
2005
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2006
+ if (!auth.ok || !auth.apiKey) {
2007
+ const message = auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error;
2008
+ setOverlayStatus(message, ctx);
2009
+ notify(ctx, message, "error");
2010
+ await ensureOverlay(ctx);
2011
+ return;
2012
+ }
2013
+
2014
+ const sessionRuntime = await ensureBtwSession(ctx, mode);
2015
+ if (!sessionRuntime) {
2016
+ setOverlayStatus("No active model selected.", ctx);
2017
+ notify(ctx, "No active model selected.", "error");
2018
+ return;
2019
+ }
2020
+
2021
+ const session = sessionRuntime.session;
2022
+ const wasBusy = !ctx.isIdle();
2023
+ pendingMode = mode;
2024
+ if (saveRequested) {
2025
+ pendingSaveRequested = true;
2026
+ }
2027
+ const thinkingLevel = settings.thinkingLevel;
2028
+
2029
+ setOverlayStatus("⏳ 流式输出中...", ctx);
2030
+ await ensureOverlay(ctx);
2031
+
2032
+ try {
2033
+ await session.prompt(question, { source: "extension" });
2034
+
2035
+ const response = getLastAssistantMessage(session);
2036
+ if (!response) {
2037
+ throw new Error("BTW request finished without a response.");
2038
+ }
2039
+ if (response.stopReason === "aborted") {
2040
+ removeTranscriptTurn(transcriptState, transcriptState.lastTurnId ?? transcriptState.currentTurnId);
2041
+ setOverlayStatus("Request aborted.", ctx);
2042
+ return;
2043
+ }
2044
+ if (response.stopReason === "error") {
2045
+ throw new Error(response.errorMessage || "BTW request failed.");
2046
+ }
2047
+
2048
+ const completedTurnId = transcriptState.lastTurnId ?? transcriptState.currentTurnId;
2049
+ const streamedThinking =
2050
+ completedTurnId !== null ? findLatestTranscriptEntry(transcriptState, completedTurnId, "thinking")?.text : "";
2051
+ const answer = extractAnswer(response);
2052
+ const thinking = extractThinking(response) || streamedThinking || "";
2053
+
2054
+ const details: BtwDetails = {
2055
+ question,
2056
+ thinking,
2057
+ answer,
2058
+ provider: model.provider,
2059
+ model: model.id,
2060
+ api: model.api,
2061
+ thinkingLevel,
2062
+ timestamp: Date.now(),
2063
+ usage: response.usage,
2064
+ };
2065
+
2066
+ pendingThread.push(details);
2067
+ pi.appendEntry(BTW_ENTRY_TYPE, details);
2068
+
2069
+ const saveState = saveVisibleBtwNote(pi, details, saveRequested, wasBusy);
2070
+ if (saveState === "saved") {
2071
+ notify(ctx, "已保存到主对话。", "info");
2072
+ setOverlayStatus("已保存到主对话。", ctx);
2073
+ } else if (saveState === "queued") {
2074
+ notify(ctx, "已排队,将在当前轮次结束后保存到主对话。", "info");
2075
+ setOverlayStatus("已排队,将在当前轮次结束后保存到主对话。", ctx);
2076
+ } else {
2077
+ setOverlayStatus("Ready for a follow-up. Hidden BTW thread updated.", ctx);
2078
+ }
2079
+ } catch (error) {
2080
+ const errorMessage = error instanceof Error ? error.message : String(error);
2081
+ setTranscriptFailure(transcriptState, errorMessage);
2082
+ setOverlayStatus("Request failed. Thread preserved for retry or follow-up.", ctx);
2083
+ notify(ctx, errorMessage, "error");
2084
+ await disposeBtwSession();
2085
+ } finally {
2086
+ syncUi(ctx);
2087
+ }
2088
+ }
2089
+
2090
+ function getPendingThreadForHandoff(): BtwHandoffExchange[] {
2091
+ return pendingThread.map((entry) => ({ user: entry.question, assistant: entry.answer }));
2092
+ }
2093
+
2094
+ async function getBtwHandoffThread(
2095
+ ctx: ExtensionCommandContext,
2096
+ ): Promise<{ sessionRuntime: BtwSessionRuntime | null; thread: BtwHandoffExchange[] }> {
2097
+ const sessionRuntime = activeBtwSession ?? (await ensureBtwSession(ctx, pendingMode));
2098
+ const thread = sessionRuntime ? extractBtwHandoffThread(sessionRuntime) : [];
2099
+ const resolvedThread = thread.length > 0 ? thread : getPendingThreadForHandoff();
2100
+
2101
+ if (resolvedThread.length === 0) {
2102
+ throw new Error("无可用 BTW 对话用于交接。");
2103
+ }
2104
+
2105
+ return { sessionRuntime, thread: resolvedThread };
2106
+ }
2107
+
2108
+ async function summarizeThread(ctx: ExtensionCommandContext, thread: BtwHandoffExchange[]): Promise<string> {
2109
+ const settings = await resolveBtwSettings(ctx, true);
2110
+ const model = settings.model;
2111
+ if (!model) {
2112
+ throw new Error(settings.fallbackReason || "No active model selected.");
2113
+ }
2114
+
2115
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
2116
+ if (!auth.ok || !auth.apiKey) {
2117
+ throw new Error(auth.ok ? `No credentials available for ${model.provider}/${model.id}.` : auth.error);
2118
+ }
2119
+
2120
+ const { session } = await createAgentSession({
2121
+ sessionManager: SessionManager.inMemory(),
2122
+ model,
2123
+ modelRegistry: ctx.modelRegistry as AgentSession["modelRegistry"],
2124
+ thinkingLevel: "off",
2125
+ tools: [],
2126
+ resourceLoader: createBtwResourceLoader(ctx, [BTW_SUMMARIZE_SYSTEM_PROMPT]),
2127
+ });
2128
+
2129
+ try {
2130
+ await session.prompt(formatThread(thread), { source: "extension" });
2131
+
2132
+ const response = getLastAssistantMessage(session);
2133
+ if (!response) {
2134
+ throw new Error("BTW summarize finished without a response.");
2135
+ }
2136
+ if (response.stopReason === "error") {
2137
+ throw new Error(response.errorMessage || "Failed to summarize BTW thread.");
2138
+ }
2139
+ if (response.stopReason === "aborted") {
2140
+ throw new Error("BTW summarize aborted.");
2141
+ }
2142
+
2143
+ return extractAnswer(response);
2144
+ } finally {
2145
+ try {
2146
+ await session.abort();
2147
+ } catch {
2148
+ // Ignore abort errors during summarize session shutdown.
2149
+ }
2150
+ session.dispose();
2151
+ }
2152
+ }
2153
+
2154
+ function sendThreadToMain(ctx: ExtensionCommandContext, content: string): void {
2155
+ if (ctx.isIdle()) {
2156
+ pi.sendUserMessage(content);
2157
+ } else {
2158
+ pi.sendUserMessage(content, { deliverAs: "followUp" });
2159
+ }
2160
+ }
2161
+
2162
+ pi.registerMessageRenderer(BTW_MESSAGE_TYPE, (message, { expanded }, theme) => {
2163
+ const details = message.details as BtwDetails | undefined;
2164
+ const content = typeof message.content === "string" ? message.content : "[non-text btw message]";
2165
+ const lines = [theme.fg("accent", theme.bold("[BTW]")), content];
2166
+
2167
+ if (expanded && details) {
2168
+ lines.push(
2169
+ theme.fg(
2170
+ "dim",
2171
+ `model: ${details.provider}/${details.model} (${details.api ?? "openai-responses"}) · thinking: ${details.thinkingLevel}`,
2172
+ ),
2173
+ );
2174
+
2175
+ if (details.usage) {
2176
+ lines.push(
2177
+ theme.fg(
2178
+ "dim",
2179
+ `tokens: in ${details.usage.input} · out ${details.usage.output} · total ${details.usage.totalTokens}`,
2180
+ ),
2181
+ );
2182
+ }
2183
+ }
2184
+
2185
+ const box = new Box(1, 1, (text) => theme.bg("customMessageBg", text));
2186
+ box.addChild(new Text(lines.join("\n"), 0, 0));
2187
+ return box;
2188
+ });
2189
+
2190
+ pi.on("context", async (event) => {
2191
+ return {
2192
+ messages: event.messages.filter((message) => !isVisibleBtwMessage(message)),
2193
+ };
2194
+ });
2195
+
2196
+ pi.on("session_start", async (_event, ctx) => {
2197
+ await restoreThread(ctx);
2198
+ });
2199
+
2200
+ pi.on("session_tree", async (_event, ctx) => {
2201
+ await restoreThread(ctx);
2202
+ });
2203
+
2204
+ pi.on("session_shutdown", async () => {
2205
+ await disposeBtwSession();
2206
+ dismissOverlay();
2207
+ });
2208
+
2209
+ for (const shortcut of BTW_FOCUS_SHORTCUTS) {
2210
+ pi.registerShortcut(shortcut, {
2211
+ description: "切换 BTW 弹窗焦点。",
2212
+ handler: async (_ctx) => {
2213
+ toggleOverlayFocus();
2214
+ },
2215
+ });
2216
+ }
2217
+
2218
+ pi.registerCommand("btw", {
2219
+ description: "侧边对话。默认带主会话上下文;用 /btw:tangent 开启无上下文模式。加 --save 保存到主对话。",
2220
+ handler: async (args, ctx) => {
2221
+ await dispatchBtwCommand("btw", args, ctx);
2222
+ },
2223
+ });
2224
+
2225
+ pi.registerCommand("btw:tangent", {
2226
+ description: "无上下文侧边对话。与当前工作无关的话题探索。",
2227
+ handler: async (args, ctx) => {
2228
+ await dispatchBtwCommand("btw:tangent", args, ctx);
2229
+ },
2230
+ });
2231
+
2232
+ pi.registerCommand("btw:new", {
2233
+ description: "开启全新 BTW 线程(带主会话上下文)。",
2234
+ handler: async (args, ctx) => {
2235
+ await dispatchBtwCommand("btw:new", args, ctx);
2236
+ },
2237
+ });
2238
+
2239
+ pi.registerCommand("btw:clear", {
2240
+ description: "关闭 BTW 弹窗并清除当前线程。",
2241
+ handler: async (args, ctx) => {
2242
+ await dispatchBtwCommand("btw:clear", args, ctx);
2243
+ },
2244
+ });
2245
+
2246
+ pi.registerCommand("btw:inject", {
2247
+ description: "将完整 BTW 线程注入主对话。",
2248
+ handler: async (args, ctx) => {
2249
+ await dispatchBtwCommand("btw:inject", args, ctx);
2250
+ },
2251
+ });
2252
+
2253
+ pi.registerCommand("btw:summarize", {
2254
+ description: "总结 BTW 线程后注入主对话。",
2255
+ handler: async (args, ctx) => {
2256
+ await dispatchBtwCommand("btw:summarize", args, ctx);
2257
+ },
2258
+ });
2259
+
2260
+ pi.registerCommand("btw:model", {
2261
+ description: "查看、设置或清除 BTW 专属模型覆盖。",
2262
+ handler: async (args, ctx) => {
2263
+ await dispatchBtwCommand("btw:model", args, ctx);
2264
+ },
2265
+ });
2266
+
2267
+ pi.registerCommand("btw:thinking", {
2268
+ description: "查看、设置或清除 BTW 专属思维链级别。",
2269
+ handler: async (args, ctx) => {
2270
+ await dispatchBtwCommand("btw:thinking", args, ctx);
2271
+ },
2272
+ });
2273
+ }