@oh-my-pi/pi-coding-agent 13.18.0 → 14.0.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 (235) hide show
  1. package/CHANGELOG.md +316 -1
  2. package/package.json +86 -24
  3. package/scripts/format-prompts.ts +2 -2
  4. package/src/autoresearch/apply-contract-to-state.ts +24 -0
  5. package/src/autoresearch/contract.ts +0 -44
  6. package/src/autoresearch/dashboard.ts +1 -2
  7. package/src/autoresearch/git.ts +116 -30
  8. package/src/autoresearch/helpers.ts +49 -0
  9. package/src/autoresearch/index.ts +28 -187
  10. package/src/autoresearch/prompt.md +26 -9
  11. package/src/autoresearch/state.ts +0 -6
  12. package/src/autoresearch/tools/init-experiment.ts +202 -117
  13. package/src/autoresearch/tools/log-experiment.ts +123 -178
  14. package/src/autoresearch/tools/run-experiment.ts +48 -10
  15. package/src/autoresearch/types.ts +2 -2
  16. package/src/capability/index.ts +4 -2
  17. package/src/cli/file-processor.ts +3 -3
  18. package/src/cli/grep-cli.ts +8 -8
  19. package/src/cli/grievances-cli.ts +78 -0
  20. package/src/cli/read-cli.ts +67 -0
  21. package/src/cli/setup-cli.ts +4 -4
  22. package/src/cli/update-cli.ts +3 -3
  23. package/src/cli.ts +2 -0
  24. package/src/commands/grep.ts +6 -1
  25. package/src/commands/grievances.ts +20 -0
  26. package/src/commands/read.ts +33 -0
  27. package/src/commit/agentic/agent.ts +5 -8
  28. package/src/commit/agentic/index.ts +22 -26
  29. package/src/commit/agentic/tools/analyze-file.ts +3 -3
  30. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  31. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  32. package/src/commit/agentic/tools/git-overview.ts +6 -9
  33. package/src/commit/agentic/tools/index.ts +6 -8
  34. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  35. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  36. package/src/commit/agentic/tools/split-commit.ts +4 -4
  37. package/src/commit/agentic/validation.ts +1 -1
  38. package/src/commit/analysis/conventional.ts +4 -4
  39. package/src/commit/analysis/summary.ts +3 -3
  40. package/src/commit/changelog/generate.ts +4 -4
  41. package/src/commit/changelog/index.ts +5 -9
  42. package/src/commit/map-reduce/map-phase.ts +4 -4
  43. package/src/commit/map-reduce/reduce-phase.ts +4 -4
  44. package/src/commit/pipeline.ts +13 -16
  45. package/src/config/keybindings.ts +7 -6
  46. package/src/config/prompt-templates.ts +44 -226
  47. package/src/config/resolve-config-value.ts +4 -2
  48. package/src/config/settings-schema.ts +98 -2
  49. package/src/config/settings.ts +25 -26
  50. package/src/dap/client.ts +674 -0
  51. package/src/dap/config.ts +150 -0
  52. package/src/dap/defaults.json +211 -0
  53. package/src/dap/index.ts +4 -0
  54. package/src/dap/session.ts +1255 -0
  55. package/src/dap/types.ts +600 -0
  56. package/src/debug/log-viewer.ts +3 -2
  57. package/src/discovery/builtin.ts +1 -2
  58. package/src/discovery/codex.ts +2 -2
  59. package/src/discovery/github.ts +2 -1
  60. package/src/discovery/helpers.ts +2 -2
  61. package/src/discovery/opencode.ts +2 -2
  62. package/src/edit/diff.ts +818 -0
  63. package/src/edit/index.ts +309 -0
  64. package/src/edit/line-hash.ts +67 -0
  65. package/src/edit/modes/chunk.ts +454 -0
  66. package/src/{patch → edit/modes}/hashline.ts +741 -361
  67. package/src/{patch/applicator.ts → edit/modes/patch.ts} +420 -117
  68. package/src/{patch/fuzzy.ts → edit/modes/replace.ts} +519 -197
  69. package/src/{patch → edit}/normalize.ts +97 -76
  70. package/src/{patch/shared.ts → edit/renderer.ts} +181 -108
  71. package/src/exec/bash-executor.ts +4 -2
  72. package/src/exec/idle-timeout-watchdog.ts +126 -0
  73. package/src/exec/non-interactive-env.ts +5 -0
  74. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +6 -18
  75. package/src/extensibility/custom-commands/bundled/review/index.ts +45 -43
  76. package/src/extensibility/custom-commands/loader.ts +1 -2
  77. package/src/extensibility/custom-tools/loader.ts +34 -11
  78. package/src/extensibility/custom-tools/types.ts +1 -1
  79. package/src/extensibility/extensions/loader.ts +9 -4
  80. package/src/extensibility/extensions/runner.ts +24 -1
  81. package/src/extensibility/extensions/types.ts +4 -2
  82. package/src/extensibility/hooks/loader.ts +5 -6
  83. package/src/extensibility/hooks/types.ts +2 -2
  84. package/src/extensibility/plugins/doctor.ts +2 -1
  85. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  86. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  87. package/src/extensibility/slash-commands.ts +3 -7
  88. package/src/index.ts +3 -1
  89. package/src/internal-urls/docs-index.generated.ts +11 -11
  90. package/src/ipy/executor.ts +58 -17
  91. package/src/ipy/gateway-coordinator.ts +6 -4
  92. package/src/ipy/kernel.ts +45 -22
  93. package/src/ipy/runtime.ts +2 -2
  94. package/src/lsp/client.ts +7 -4
  95. package/src/lsp/clients/lsp-linter-client.ts +4 -4
  96. package/src/lsp/config.ts +2 -2
  97. package/src/lsp/defaults.json +688 -154
  98. package/src/lsp/index.ts +234 -45
  99. package/src/lsp/lspmux.ts +2 -2
  100. package/src/lsp/startup-events.ts +13 -0
  101. package/src/lsp/types.ts +12 -1
  102. package/src/lsp/utils.ts +8 -1
  103. package/src/main.ts +125 -47
  104. package/src/memories/index.ts +4 -5
  105. package/src/modes/acp/acp-agent.ts +563 -163
  106. package/src/modes/acp/acp-event-mapper.ts +9 -1
  107. package/src/modes/acp/acp-mode.ts +4 -2
  108. package/src/modes/components/agent-dashboard.ts +3 -4
  109. package/src/modes/components/diff.ts +6 -7
  110. package/src/modes/components/footer.ts +9 -29
  111. package/src/modes/components/hook-editor.ts +3 -3
  112. package/src/modes/components/hook-selector.ts +6 -1
  113. package/src/modes/components/read-tool-group.ts +6 -12
  114. package/src/modes/components/session-observer-overlay.ts +472 -0
  115. package/src/modes/components/settings-defs.ts +24 -0
  116. package/src/modes/components/status-line.ts +15 -61
  117. package/src/modes/components/tool-execution.ts +1 -1
  118. package/src/modes/components/welcome.ts +1 -1
  119. package/src/modes/controllers/btw-controller.ts +2 -2
  120. package/src/modes/controllers/command-controller.ts +4 -2
  121. package/src/modes/controllers/event-controller.ts +59 -2
  122. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  123. package/src/modes/controllers/input-controller.ts +15 -8
  124. package/src/modes/controllers/selector-controller.ts +26 -0
  125. package/src/modes/index.ts +20 -2
  126. package/src/modes/interactive-mode.ts +278 -69
  127. package/src/modes/rpc/host-tools.ts +186 -0
  128. package/src/modes/rpc/rpc-client.ts +178 -13
  129. package/src/modes/rpc/rpc-mode.ts +73 -3
  130. package/src/modes/rpc/rpc-types.ts +53 -1
  131. package/src/modes/session-observer-registry.ts +146 -0
  132. package/src/modes/shared.ts +0 -42
  133. package/src/modes/theme/theme.ts +80 -8
  134. package/src/modes/types.ts +4 -2
  135. package/src/modes/utils/keybinding-matchers.ts +9 -0
  136. package/src/prompts/system/custom-system-prompt.md +5 -0
  137. package/src/prompts/system/system-prompt.md +8 -1
  138. package/src/prompts/tools/chunk-edit.md +219 -0
  139. package/src/prompts/tools/debug.md +43 -0
  140. package/src/prompts/tools/grep.md +3 -0
  141. package/src/prompts/tools/lsp.md +5 -5
  142. package/src/prompts/tools/read-chunk.md +17 -0
  143. package/src/prompts/tools/read.md +19 -5
  144. package/src/sdk.ts +216 -165
  145. package/src/secrets/index.ts +1 -1
  146. package/src/secrets/obfuscator.ts +25 -17
  147. package/src/session/agent-session.ts +381 -286
  148. package/src/session/agent-storage.ts +12 -12
  149. package/src/session/compaction/branch-summarization.ts +3 -3
  150. package/src/session/compaction/compaction.ts +5 -6
  151. package/src/session/compaction/utils.ts +3 -3
  152. package/src/session/history-storage.ts +62 -19
  153. package/src/session/messages.ts +3 -3
  154. package/src/session/session-dump-format.ts +203 -0
  155. package/src/session/session-manager.ts +15 -5
  156. package/src/session/session-storage.ts +4 -2
  157. package/src/session/streaming-output.ts +1 -1
  158. package/src/session/tool-choice-queue.ts +213 -0
  159. package/src/slash-commands/builtin-registry.ts +56 -8
  160. package/src/ssh/connection-manager.ts +2 -2
  161. package/src/ssh/sshfs-mount.ts +5 -5
  162. package/src/stt/downloader.ts +4 -4
  163. package/src/stt/recorder.ts +4 -4
  164. package/src/stt/transcriber.ts +2 -2
  165. package/src/system-prompt.ts +25 -13
  166. package/src/task/agents.ts +5 -6
  167. package/src/task/commands.ts +2 -5
  168. package/src/task/executor.ts +32 -4
  169. package/src/task/index.ts +91 -82
  170. package/src/task/template.ts +2 -2
  171. package/src/task/types.ts +25 -0
  172. package/src/task/worktree.ts +131 -149
  173. package/src/tools/ask.ts +2 -3
  174. package/src/tools/ast-edit.ts +7 -7
  175. package/src/tools/ast-grep.ts +7 -7
  176. package/src/tools/auto-generated-guard.ts +36 -41
  177. package/src/tools/await-tool.ts +2 -2
  178. package/src/tools/bash.ts +5 -23
  179. package/src/tools/browser.ts +4 -5
  180. package/src/tools/calculator.ts +2 -3
  181. package/src/tools/cancel-job.ts +2 -2
  182. package/src/tools/checkpoint.ts +3 -3
  183. package/src/tools/debug.ts +1007 -0
  184. package/src/tools/exit-plan-mode.ts +3 -3
  185. package/src/tools/fetch.ts +67 -3
  186. package/src/tools/find.ts +4 -5
  187. package/src/tools/fs-cache-invalidation.ts +5 -0
  188. package/src/tools/gemini-image.ts +13 -5
  189. package/src/tools/gh.ts +130 -308
  190. package/src/tools/grep.ts +57 -9
  191. package/src/tools/index.ts +44 -22
  192. package/src/tools/inspect-image.ts +4 -4
  193. package/src/tools/output-meta.ts +1 -1
  194. package/src/tools/python.ts +19 -6
  195. package/src/tools/read.ts +211 -146
  196. package/src/tools/render-mermaid.ts +2 -3
  197. package/src/tools/render-utils.ts +20 -6
  198. package/src/tools/renderers.ts +3 -1
  199. package/src/tools/report-tool-issue.ts +80 -0
  200. package/src/tools/resolve.ts +70 -39
  201. package/src/tools/search-tool-bm25.ts +2 -2
  202. package/src/tools/ssh.ts +2 -2
  203. package/src/tools/todo-write.ts +2 -2
  204. package/src/tools/tool-timeouts.ts +1 -0
  205. package/src/tools/write.ts +5 -6
  206. package/src/tui/tree-list.ts +3 -1
  207. package/src/utils/clipboard.ts +80 -0
  208. package/src/utils/commit-message-generator.ts +2 -3
  209. package/src/utils/edit-mode.ts +49 -0
  210. package/src/utils/external-editor.ts +11 -5
  211. package/src/utils/file-display-mode.ts +6 -5
  212. package/src/utils/file-mentions.ts +8 -7
  213. package/src/utils/git.ts +1400 -0
  214. package/src/utils/image-loading.ts +98 -0
  215. package/src/utils/title-generator.ts +2 -3
  216. package/src/utils/tools-manager.ts +6 -6
  217. package/src/web/scrapers/choosealicense.ts +1 -1
  218. package/src/web/search/index.ts +3 -3
  219. package/src/web/search/render.ts +6 -4
  220. package/src/autoresearch/command-initialize.md +0 -34
  221. package/src/commit/git/errors.ts +0 -9
  222. package/src/commit/git/index.ts +0 -210
  223. package/src/commit/git/operations.ts +0 -54
  224. package/src/patch/diff.ts +0 -433
  225. package/src/patch/index.ts +0 -888
  226. package/src/patch/parser.ts +0 -532
  227. package/src/patch/types.ts +0 -292
  228. package/src/prompts/agents/oracle.md +0 -77
  229. package/src/tools/gh-cli.ts +0 -125
  230. package/src/tools/pending-action.ts +0 -49
  231. package/src/utils/child-process.ts +0 -88
  232. package/src/utils/frontmatter.ts +0 -117
  233. package/src/utils/image-input.ts +0 -274
  234. package/src/utils/mime.ts +0 -53
  235. package/src/utils/prompt-format.ts +0 -170
@@ -2,15 +2,22 @@
2
2
  * Interactive mode for the coding agent.
3
3
  * Handles TUI rendering and user interaction, delegating business logic to AgentSession.
4
4
  */
5
+ import * as fs from "node:fs/promises";
5
6
  import * as path from "node:path";
6
7
  import { type Agent, type AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
7
- import type { AssistantMessage, ImageContent, Message, Model, UsageReport } from "@oh-my-pi/pi-ai";
8
+ import {
9
+ type AssistantMessage,
10
+ type ImageContent,
11
+ type Message,
12
+ type Model,
13
+ modelsAreEqual,
14
+ type UsageReport,
15
+ } from "@oh-my-pi/pi-ai";
8
16
  import type { Component, SlashCommand } from "@oh-my-pi/pi-tui";
9
17
  import { Container, Loader, Markdown, ProcessTerminal, Spacer, Text, TUI, visibleWidth } from "@oh-my-pi/pi-tui";
10
- import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
18
+ import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem, prompt } from "@oh-my-pi/pi-utils";
11
19
  import chalk from "chalk";
12
20
  import { KeybindingsManager } from "../config/keybindings";
13
- import { renderPromptTemplate } from "../config/prompt-templates";
14
21
  import { type Settings, settings } from "../config/settings";
15
22
  import type {
16
23
  ExtensionUIContext,
@@ -21,6 +28,7 @@ import type {
21
28
  import type { CompactOptions } from "../extensibility/extensions/types";
22
29
  import { BUILTIN_SLASH_COMMANDS, loadSlashCommands } from "../extensibility/slash-commands";
23
30
  import { resolveLocalUrlToPath } from "../internal-urls";
31
+ import { LSP_STARTUP_EVENT_CHANNEL, type LspStartupEvent } from "../lsp/startup-events";
24
32
  import { renameApprovedPlanFile } from "../plan-mode/approved-plan";
25
33
  import planModeApprovedPrompt from "../prompts/system/plan-mode-approved.md" with { type: "text" };
26
34
  import type { AgentSession, AgentSessionEvent } from "../session/agent-session";
@@ -28,7 +36,9 @@ import { HistoryStorage } from "../session/history-storage";
28
36
  import type { SessionContext, SessionManager } from "../session/session-manager";
29
37
  import { getRecentSessions } from "../session/session-manager";
30
38
  import { STTController, type SttState } from "../stt";
31
- import type { ExitPlanModeDetails } from "../tools";
39
+ import type { ExitPlanModeDetails, LspStartupServerInfo } from "../tools";
40
+ import type { EventBus } from "../utils/event-bus";
41
+ import { getEditorCommand, openInEditor } from "../utils/external-editor";
32
42
  import { popTerminalTitle, pushTerminalTitle, setSessionTerminalTitle } from "../utils/title-generator";
33
43
  import type { AssistantMessageComponent } from "./components/assistant-message";
34
44
  import type { BashExecutionComponent } from "./components/bash-execution";
@@ -40,7 +50,7 @@ import type { HookSelectorComponent } from "./components/hook-selector";
40
50
  import type { PythonExecutionComponent } from "./components/python-execution";
41
51
  import { StatusLineComponent } from "./components/status-line";
42
52
  import type { ToolExecutionHandle } from "./components/tool-execution";
43
- import { WelcomeComponent } from "./components/welcome";
53
+ import { WelcomeComponent, type LspServerInfo as WelcomeLspServerInfo } from "./components/welcome";
44
54
  import { BtwController } from "./controllers/btw-controller";
45
55
  import { CommandController } from "./controllers/command-controller";
46
56
  import { EventController } from "./controllers/event-controller";
@@ -50,6 +60,7 @@ import { MCPCommandController } from "./controllers/mcp-command-controller";
50
60
  import { SelectorController } from "./controllers/selector-controller";
51
61
  import { SSHCommandController } from "./controllers/ssh-command-controller";
52
62
  import { OAuthManualInputManager } from "./oauth-manual-input";
63
+ import { SessionObserverRegistry } from "./session-observer-registry";
53
64
  import { setMermaidRenderCallback } from "./theme/mermaid-cache";
54
65
  import type { Theme } from "./theme/theme";
55
66
  import {
@@ -151,12 +162,11 @@ export class InteractiveMode implements InteractiveModeContext {
151
162
  readonly #version: string;
152
163
  readonly #changelogMarkdown: string | undefined;
153
164
  #planModePreviousTools: string[] | undefined;
154
- #planModePreviousModel: Model | undefined;
155
- #pendingModelSwitch: Model | undefined;
165
+ #planModePreviousModelState: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
166
+ #pendingModelSwitch: { model: Model; thinkingLevel?: ThinkingLevel } | undefined;
156
167
  #planModeHasEntered = false;
157
- readonly lspServers:
158
- | Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>
159
- | undefined = undefined;
168
+ #planReviewContainer: Container | undefined;
169
+ readonly lspServers: LspStartupServerInfo[] | undefined = undefined;
160
170
  mcpManager?: import("../mcp").MCPManager;
161
171
  readonly #toolUiContextSetter: (uiContext: ExtensionUIContext, hasUI: boolean) => void;
162
172
 
@@ -173,16 +183,19 @@ export class InteractiveMode implements InteractiveModeContext {
173
183
  #voicePreviousShowHardwareCursor: boolean | null = null;
174
184
  #voicePreviousUseTerminalCursor: boolean | null = null;
175
185
  #resizeHandler?: () => void;
186
+ #observerRegistry: SessionObserverRegistry;
187
+ #eventBus?: EventBus;
188
+ #eventBusUnsubscribers: Array<() => void> = [];
189
+ #welcomeComponent?: WelcomeComponent;
176
190
 
177
191
  constructor(
178
192
  session: AgentSession,
179
193
  version: string,
180
194
  changelogMarkdown: string | undefined = undefined,
181
195
  setToolUIContext: (uiContext: ExtensionUIContext, hasUI: boolean) => void = () => {},
182
- lspServers:
183
- | Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>
184
- | undefined = undefined,
196
+ lspServers: LspStartupServerInfo[] | undefined = undefined,
185
197
  mcpManager?: import("../mcp").MCPManager,
198
+ eventBus?: EventBus,
186
199
  ) {
187
200
  this.session = session;
188
201
  this.sessionManager = session.sessionManager;
@@ -194,6 +207,14 @@ export class InteractiveMode implements InteractiveModeContext {
194
207
  this.#toolUiContextSetter = setToolUIContext;
195
208
  this.lspServers = lspServers;
196
209
  this.mcpManager = mcpManager;
210
+ this.#eventBus = eventBus;
211
+ if (eventBus) {
212
+ this.#eventBusUnsubscribers.push(
213
+ eventBus.on(LSP_STARTUP_EVENT_CHANNEL, data => {
214
+ this.#handleLspStartupEvent(data as LspStartupEvent);
215
+ }),
216
+ );
217
+ }
197
218
 
198
219
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
199
220
  this.ui.setClearOnShrink(settings.get("clearOnShrink"));
@@ -268,18 +289,22 @@ export class InteractiveMode implements InteractiveModeContext {
268
289
  this.#commandController = new CommandController(this);
269
290
  this.#selectorController = new SelectorController(this);
270
291
  this.#inputController = new InputController(this);
292
+ this.#observerRegistry = new SessionObserverRegistry();
271
293
  }
272
294
 
273
295
  async init(): Promise<void> {
274
296
  if (this.isInitialized) return;
275
297
 
276
- this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
298
+ logger.time("InteractiveMode.init:keybindings");
299
+ this.keybindings = KeybindingsManager.create();
277
300
 
278
301
  // Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
279
302
  this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
280
303
 
281
- await logger.timeAsync("InteractiveMode.init:slashCommands", () =>
282
- this.refreshSlashCommandState(getProjectDir()),
304
+ await logger.time(
305
+ "InteractiveMode.init:slashCommands",
306
+ this.refreshSlashCommandState.bind(this),
307
+ getProjectDir(),
283
308
  );
284
309
 
285
310
  // Get current model info for welcome screen
@@ -287,7 +312,7 @@ export class InteractiveMode implements InteractiveModeContext {
287
312
  const providerName = this.session.model?.provider ?? "Unknown";
288
313
 
289
314
  // Get recent sessions
290
- const recentSessions = await logger.timeAsync("InteractiveMode.init:recentSessions", () =>
315
+ const recentSessions = await logger.time("InteractiveMode.init:recentSessions", () =>
291
316
  getRecentSessions(this.sessionManager.getSessionDir()).then(sessions =>
292
317
  sessions.map(s => ({
293
318
  name: s.name,
@@ -296,15 +321,8 @@ export class InteractiveMode implements InteractiveModeContext {
296
321
  ),
297
322
  );
298
323
 
299
- // Convert LSP servers to welcome format
300
- const lspServerInfo =
301
- this.lspServers?.map(s => ({
302
- name: s.name,
303
- status: s.status as "ready" | "error" | "connecting",
304
- fileTypes: s.fileTypes,
305
- })) ?? [];
306
-
307
324
  const startupQuiet = settings.get("startup.quiet");
325
+ this.#welcomeComponent = undefined;
308
326
 
309
327
  for (const warning of this.session.configWarnings) {
310
328
  this.ui.addChild(new Text(theme.fg("warning", `Warning: ${warning}`), 1, 0));
@@ -313,11 +331,17 @@ export class InteractiveMode implements InteractiveModeContext {
313
331
 
314
332
  if (!startupQuiet) {
315
333
  // Add welcome header
316
- const welcome = new WelcomeComponent(this.#version, modelName, providerName, recentSessions, lspServerInfo);
334
+ this.#welcomeComponent = new WelcomeComponent(
335
+ this.#version,
336
+ modelName,
337
+ providerName,
338
+ recentSessions,
339
+ this.#getWelcomeLspServers(),
340
+ );
317
341
 
318
342
  // Setup UI layout
319
343
  this.ui.addChild(new Spacer(1));
320
- this.ui.addChild(welcome);
344
+ this.ui.addChild(this.#welcomeComponent);
321
345
  this.ui.addChild(new Spacer(1));
322
346
 
323
347
  // Add changelog if provided
@@ -352,6 +376,16 @@ export class InteractiveMode implements InteractiveModeContext {
352
376
  this.#inputController.setupKeyHandlers();
353
377
  this.#inputController.setupEditorSubmitHandler();
354
378
 
379
+ // Wire observer registry to EventBus
380
+ if (this.#eventBus) {
381
+ this.#observerRegistry.subscribeToEventBus(this.#eventBus);
382
+ }
383
+ this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
384
+ this.#observerRegistry.onChange(() => {
385
+ this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount());
386
+ this.ui.requestRender();
387
+ });
388
+
355
389
  // Load initial todos
356
390
  await this.#loadTodoList();
357
391
 
@@ -512,7 +546,7 @@ export class InteractiveMode implements InteractiveModeContext {
512
546
 
513
547
  rebuildChatFromMessages(): void {
514
548
  this.chatContainer.clear();
515
- const context = this.sessionManager.buildSessionContext();
549
+ const context = this.session.buildDisplaySessionContext();
516
550
  this.renderSessionContext(context);
517
551
  }
518
552
 
@@ -614,33 +648,41 @@ export class InteractiveMode implements InteractiveModeContext {
614
648
  }
615
649
 
616
650
  async #applyPlanModeModel(): Promise<void> {
617
- const planModel = this.session.resolveRoleModel("plan");
618
- if (!planModel) return;
651
+ const resolved = this.session.resolveRoleModelWithThinking("plan");
652
+ if (!resolved.model) return;
653
+
619
654
  const currentModel = this.session.model;
620
- if (currentModel && currentModel.provider === planModel.provider && currentModel.id === planModel.id) {
621
- return;
622
- }
623
- this.#planModePreviousModel = currentModel;
624
- if (this.session.isStreaming) {
625
- this.#pendingModelSwitch = planModel;
626
- return;
627
- }
628
- try {
629
- await this.session.setModelTemporary(planModel);
630
- } catch (error) {
631
- this.showWarning(
632
- `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
633
- );
655
+ const sameModel = modelsAreEqual(currentModel, resolved.model);
656
+ const planThinkingLevel = resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined;
657
+
658
+ this.#planModePreviousModelState = currentModel
659
+ ? { model: currentModel, thinkingLevel: this.session.thinkingLevel }
660
+ : undefined;
661
+
662
+ if (!sameModel) {
663
+ if (this.session.isStreaming) {
664
+ this.#pendingModelSwitch = { model: resolved.model, thinkingLevel: planThinkingLevel };
665
+ return;
666
+ }
667
+ try {
668
+ await this.session.setModelTemporary(resolved.model, planThinkingLevel);
669
+ } catch (error) {
670
+ this.showWarning(
671
+ `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
672
+ );
673
+ }
674
+ } else if (planThinkingLevel) {
675
+ this.session.setThinkingLevel(planThinkingLevel);
634
676
  }
635
677
  }
636
678
 
637
679
  /** Apply any deferred model switch after the current stream ends. */
638
680
  async flushPendingModelSwitch(): Promise<void> {
639
- const model = this.#pendingModelSwitch;
640
- if (!model) return;
681
+ const pending = this.#pendingModelSwitch;
682
+ if (!pending) return;
641
683
  this.#pendingModelSwitch = undefined;
642
684
  try {
643
- await this.session.setModelTemporary(model);
685
+ await this.session.setModelTemporary(pending.model, pending.thinkingLevel);
644
686
  } catch (error) {
645
687
  this.showWarning(
646
688
  `Failed to switch model after streaming: ${error instanceof Error ? error.message : String(error)}`,
@@ -704,20 +746,25 @@ export class InteractiveMode implements InteractiveModeContext {
704
746
  if (previousTools && previousTools.length > 0) {
705
747
  await this.session.setActiveToolsByName(previousTools);
706
748
  }
707
- if (this.#planModePreviousModel) {
708
- if (this.session.isStreaming) {
709
- this.#pendingModelSwitch = this.#planModePreviousModel;
749
+ if (this.#planModePreviousModelState) {
750
+ const prev = this.#planModePreviousModelState;
751
+ if (modelsAreEqual(this.session.model, prev.model)) {
752
+ // Same model — only thinking level may differ. Avoid setModelTemporary()
753
+ // which would reset provider-side sessions (openai-responses/Codex) and
754
+ // break conversation continuity.
755
+ this.session.setThinkingLevel(prev.thinkingLevel);
756
+ } else if (this.session.isStreaming) {
757
+ this.#pendingModelSwitch = { model: prev.model, thinkingLevel: prev.thinkingLevel };
710
758
  } else {
711
- await this.session.setModelTemporary(this.#planModePreviousModel);
759
+ await this.session.setModelTemporary(prev.model, prev.thinkingLevel);
712
760
  }
713
761
  }
714
-
715
762
  this.session.setPlanModeState(undefined);
716
763
  this.planModeEnabled = false;
717
764
  this.planModePaused = options?.paused ?? false;
718
765
  this.planModePlanFilePath = undefined;
719
766
  this.#planModePreviousTools = undefined;
720
- this.#planModePreviousModel = undefined;
767
+ this.#planModePreviousModelState = undefined;
721
768
  this.#updatePlanModeStatus();
722
769
  const paused = options?.paused ?? false;
723
770
  this.sessionManager.appendModeChange(paused ? "plan_paused" : "none");
@@ -739,15 +786,101 @@ export class InteractiveMode implements InteractiveModeContext {
739
786
  }
740
787
 
741
788
  #renderPlanPreview(planContent: string): void {
742
- this.chatContainer.addChild(new Spacer(1));
743
- this.chatContainer.addChild(new DynamicBorder());
744
- this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Plan Review")), 1, 1));
745
- this.chatContainer.addChild(new Spacer(1));
746
- this.chatContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme()));
747
- this.chatContainer.addChild(new DynamicBorder());
789
+ const planReviewContainer = this.#planReviewContainer ?? new Container();
790
+ if (this.#planReviewContainer) {
791
+ // Re-append the preview so repeated plan-review refreshes stay adjacent to the
792
+ // active selector instead of updating an older off-screen preview in place.
793
+ this.chatContainer.removeChild(this.#planReviewContainer);
794
+ }
795
+ planReviewContainer.clear();
796
+ planReviewContainer.addChild(new Spacer(1));
797
+ planReviewContainer.addChild(new DynamicBorder());
798
+ planReviewContainer.addChild(new Text(theme.bold(theme.fg("accent", "Plan Review")), 1, 1));
799
+ planReviewContainer.addChild(new Spacer(1));
800
+ planReviewContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme()));
801
+ planReviewContainer.addChild(new DynamicBorder());
802
+ this.chatContainer.addChild(planReviewContainer);
803
+ this.#planReviewContainer = planReviewContainer;
748
804
  this.ui.requestRender();
749
805
  }
750
806
 
807
+ #getEditorTerminalPath(): string | null {
808
+ if (process.platform === "win32") {
809
+ return null;
810
+ }
811
+ return "/dev/tty";
812
+ }
813
+
814
+ async #openEditorTerminalHandle(): Promise<fs.FileHandle | null> {
815
+ const terminalPath = this.#getEditorTerminalPath();
816
+ if (!terminalPath) {
817
+ return null;
818
+ }
819
+ try {
820
+ return await fs.open(terminalPath, "r+");
821
+ } catch {
822
+ return null;
823
+ }
824
+ }
825
+
826
+ #getPlanReviewHelpText(): string {
827
+ const externalEditorKey = this.keybindings.getDisplayString("app.editor.external");
828
+ if (!externalEditorKey) {
829
+ return "up/down navigate enter select esc cancel";
830
+ }
831
+ return `up/down navigate enter select ${externalEditorKey.toLowerCase()} open in editor esc cancel`;
832
+ }
833
+
834
+ async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
835
+ const editorCmd = getEditorCommand();
836
+ if (!editorCmd) {
837
+ this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
838
+ return;
839
+ }
840
+
841
+ const resolvedPath = this.#resolvePlanFilePath(planFilePath);
842
+ let currentText: string;
843
+ try {
844
+ currentText = await Bun.file(resolvedPath).text();
845
+ } catch (error) {
846
+ if (isEnoent(error)) {
847
+ this.showError(`Plan file not found at ${planFilePath}`);
848
+ return;
849
+ }
850
+ this.showWarning(`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`);
851
+ return;
852
+ }
853
+
854
+ let ttyHandle: fs.FileHandle | null = null;
855
+ try {
856
+ ttyHandle = await this.#openEditorTerminalHandle();
857
+ this.ui.stop();
858
+
859
+ const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle
860
+ ? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd]
861
+ : ["inherit", "inherit", "inherit"];
862
+
863
+ const result = await openInEditor(editorCmd, currentText, {
864
+ extension: path.extname(resolvedPath) || ".md",
865
+ stdio,
866
+ trimTrailingNewline: false,
867
+ });
868
+ if (result !== null) {
869
+ await Bun.write(resolvedPath, result);
870
+ this.#renderPlanPreview(result);
871
+ this.showStatus("Plan updated in external editor.");
872
+ }
873
+ } catch (error) {
874
+ this.showWarning(`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`);
875
+ } finally {
876
+ if (ttyHandle) {
877
+ await ttyHandle.close();
878
+ }
879
+ this.ui.start();
880
+ this.ui.requestRender(true);
881
+ }
882
+ }
883
+
751
884
  async #approvePlan(
752
885
  planContent: string,
753
886
  options: { planFilePath: string; finalPlanFilePath: string },
@@ -773,11 +906,11 @@ export class InteractiveMode implements InteractiveModeContext {
773
906
  }
774
907
  this.session.setPlanReferencePath(options.finalPlanFilePath);
775
908
  this.session.markPlanReferenceSent();
776
- const prompt = renderPromptTemplate(planModeApprovedPrompt, {
909
+ const planModePrompt = prompt.render(planModeApprovedPrompt, {
777
910
  planContent,
778
911
  finalPlanFilePath: options.finalPlanFilePath,
779
912
  });
780
- await this.session.prompt(prompt, { synthetic: true });
913
+ await this.session.prompt(planModePrompt, { synthetic: true });
781
914
  }
782
915
 
783
916
  async handlePlanModeCommand(initialPrompt?: string): Promise<void> {
@@ -817,16 +950,24 @@ export class InteractiveMode implements InteractiveModeContext {
817
950
  }
818
951
 
819
952
  this.#renderPlanPreview(planContent);
820
- const choice = await this.showHookSelector("Plan mode - next step", [
821
- "Approve and execute",
822
- "Refine plan",
823
- "Stay in plan mode",
824
- ]);
953
+ const choice = await this.showHookSelector(
954
+ "Plan mode - next step",
955
+ ["Approve and execute", "Refine plan", "Stay in plan mode"],
956
+ {
957
+ helpText: this.#getPlanReviewHelpText(),
958
+ onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
959
+ },
960
+ );
825
961
 
826
962
  if (choice === "Approve and execute") {
827
963
  const finalPlanFilePath = details.finalPlanFilePath || planFilePath;
828
964
  try {
829
- await this.#approvePlan(planContent, { planFilePath, finalPlanFilePath });
965
+ const latestPlanContent = await this.#readPlanFile(planFilePath);
966
+ if (!latestPlanContent) {
967
+ this.showError(`Plan file not found at ${planFilePath}`);
968
+ return;
969
+ }
970
+ await this.#approvePlan(latestPlanContent, { planFilePath, finalPlanFilePath });
830
971
  } catch (error) {
831
972
  this.showError(
832
973
  `Failed to finalize approved plan: ${error instanceof Error ? error.message : String(error)}`,
@@ -835,9 +976,13 @@ export class InteractiveMode implements InteractiveModeContext {
835
976
  return;
836
977
  }
837
978
  if (choice === "Refine plan") {
838
- const refinement = await this.showHookInput("What should be refined?");
979
+ const refinement = (await this.showHookInput("What should be refined?"))?.trim();
839
980
  if (refinement) {
840
- this.editor.setText(refinement);
981
+ if (this.onInputCallback) {
982
+ this.onInputCallback(this.startPendingSubmission({ text: refinement }));
983
+ } else {
984
+ this.editor.setText(refinement);
985
+ }
841
986
  }
842
987
  }
843
988
  }
@@ -854,6 +999,12 @@ export class InteractiveMode implements InteractiveModeContext {
854
999
  }
855
1000
  this.#extensionUiController.clearExtensionTerminalInputListeners();
856
1001
  this.#extensionUiController.clearHookWidgets();
1002
+ for (const unsubscribe of this.#eventBusUnsubscribers) {
1003
+ unsubscribe();
1004
+ }
1005
+ this.#eventBusUnsubscribers = [];
1006
+ this.#observerRegistry.dispose();
1007
+ this.#eventController.dispose();
857
1008
  this.statusLine.dispose();
858
1009
  if (this.#resizeHandler) {
859
1010
  process.stdout.removeListener("resize", this.#resizeHandler);
@@ -950,6 +1101,48 @@ export class InteractiveMode implements InteractiveModeContext {
950
1101
  this.#uiHelpers.showWarning(message);
951
1102
  }
952
1103
 
1104
+ #handleLspStartupEvent(event: LspStartupEvent): void {
1105
+ this.#updateWelcomeLspServers();
1106
+
1107
+ if (event.type === "failed") {
1108
+ this.showWarning(`LSP startup failed: ${event.error}. It will retry lazily on write.`);
1109
+ return;
1110
+ }
1111
+
1112
+ const failedServers = event.servers.filter(server => server.status === "error");
1113
+
1114
+ if (failedServers.length === 1) {
1115
+ const failedServer = failedServers[0];
1116
+ const detail = failedServer.error ? `: ${failedServer.error}` : "";
1117
+ this.showWarning(`LSP startup failed for ${failedServer.name}${detail}. It will retry lazily on write.`);
1118
+ return;
1119
+ }
1120
+
1121
+ if (failedServers.length > 1) {
1122
+ const failedNames = failedServers.map(server => server.name).join(", ");
1123
+ this.showWarning(`LSP startup failed for ${failedNames}. It will retry lazily on write.`);
1124
+ }
1125
+ }
1126
+
1127
+ #getWelcomeLspServers(): WelcomeLspServerInfo[] {
1128
+ return (
1129
+ this.lspServers?.map(server => ({
1130
+ name: server.name,
1131
+ status: server.status,
1132
+ fileTypes: server.fileTypes,
1133
+ })) ?? []
1134
+ );
1135
+ }
1136
+
1137
+ #updateWelcomeLspServers(): void {
1138
+ if (!this.#welcomeComponent) {
1139
+ return;
1140
+ }
1141
+
1142
+ this.#welcomeComponent.setLspServers(this.#getWelcomeLspServers());
1143
+ this.ui.requestRender();
1144
+ }
1145
+
953
1146
  ensureLoadingAnimation(): void {
954
1147
  if (!this.loadingAnimation) {
955
1148
  this.statusContainer.clear();
@@ -1096,6 +1289,7 @@ export class InteractiveMode implements InteractiveModeContext {
1096
1289
  handleClearCommand(): Promise<void> {
1097
1290
  this.#btwController.dispose();
1098
1291
  this.#extensionUiController.clearExtensionTerminalInputListeners();
1292
+ this.#planReviewContainer = undefined;
1099
1293
  return this.#commandController.handleClearCommand();
1100
1294
  }
1101
1295
 
@@ -1192,6 +1386,20 @@ export class InteractiveMode implements InteractiveModeContext {
1192
1386
  this.#selectorController.showDebugSelector();
1193
1387
  }
1194
1388
 
1389
+ showSessionObserver(): void {
1390
+ const sessions = this.#observerRegistry.getSessions();
1391
+ if (sessions.length <= 1) {
1392
+ this.showStatus("No active subagent sessions");
1393
+ return;
1394
+ }
1395
+ this.#selectorController.showSessionObserver(this.#observerRegistry);
1396
+ }
1397
+
1398
+ resetObserverRegistry(): void {
1399
+ this.#observerRegistry.resetSessions();
1400
+ this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
1401
+ }
1402
+
1195
1403
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void> {
1196
1404
  return this.#commandController.handleBashCommand(command, excludeFromContext);
1197
1405
  }
@@ -1265,6 +1473,7 @@ export class InteractiveMode implements InteractiveModeContext {
1265
1473
 
1266
1474
  handleResumeSession(sessionPath: string): Promise<void> {
1267
1475
  this.#btwController.dispose();
1476
+ this.resetObserverRegistry();
1268
1477
  return this.#selectorController.handleResumeSession(sessionPath);
1269
1478
  }
1270
1479