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