@oh-my-pi/pi-coding-agent 13.18.0 → 13.19.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/package.json +7 -11
  3. package/src/autoresearch/git.ts +25 -30
  4. package/src/autoresearch/tools/log-experiment.ts +61 -74
  5. package/src/commit/agentic/agent.ts +0 -3
  6. package/src/commit/agentic/index.ts +19 -22
  7. package/src/commit/agentic/tools/git-file-diff.ts +3 -6
  8. package/src/commit/agentic/tools/git-hunk.ts +3 -3
  9. package/src/commit/agentic/tools/git-overview.ts +6 -9
  10. package/src/commit/agentic/tools/index.ts +6 -8
  11. package/src/commit/agentic/tools/propose-commit.ts +4 -7
  12. package/src/commit/agentic/tools/recent-commits.ts +3 -3
  13. package/src/commit/agentic/tools/split-commit.ts +4 -4
  14. package/src/commit/changelog/index.ts +5 -9
  15. package/src/commit/pipeline.ts +10 -12
  16. package/src/config/keybindings.ts +7 -6
  17. package/src/config/settings-schema.ts +44 -0
  18. package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
  19. package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
  20. package/src/extensibility/custom-tools/types.ts +1 -1
  21. package/src/extensibility/extensions/types.ts +3 -1
  22. package/src/extensibility/hooks/types.ts +1 -1
  23. package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
  24. package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
  25. package/src/index.ts +1 -0
  26. package/src/main.ts +24 -2
  27. package/src/modes/components/footer.ts +9 -29
  28. package/src/modes/components/hook-editor.ts +3 -3
  29. package/src/modes/components/hook-selector.ts +6 -1
  30. package/src/modes/components/session-observer-overlay.ts +472 -0
  31. package/src/modes/components/settings-defs.ts +19 -0
  32. package/src/modes/components/status-line.ts +15 -61
  33. package/src/modes/controllers/command-controller.ts +1 -0
  34. package/src/modes/controllers/event-controller.ts +59 -2
  35. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  36. package/src/modes/controllers/input-controller.ts +3 -0
  37. package/src/modes/controllers/selector-controller.ts +26 -0
  38. package/src/modes/interactive-mode.ts +195 -43
  39. package/src/modes/session-observer-registry.ts +146 -0
  40. package/src/modes/shared.ts +0 -42
  41. package/src/modes/types.ts +2 -0
  42. package/src/modes/utils/keybinding-matchers.ts +9 -0
  43. package/src/prompts/system/custom-system-prompt.md +5 -0
  44. package/src/prompts/system/system-prompt.md +6 -0
  45. package/src/sdk.ts +28 -13
  46. package/src/secrets/index.ts +1 -1
  47. package/src/secrets/obfuscator.ts +24 -16
  48. package/src/session/agent-session.ts +75 -30
  49. package/src/session/session-manager.ts +15 -5
  50. package/src/system-prompt.ts +4 -0
  51. package/src/task/executor.ts +28 -0
  52. package/src/task/index.ts +88 -78
  53. package/src/task/types.ts +25 -0
  54. package/src/task/worktree.ts +127 -145
  55. package/src/tools/exit-plan-mode.ts +1 -0
  56. package/src/tools/gh.ts +120 -297
  57. package/src/tools/read.ts +13 -79
  58. package/src/utils/external-editor.ts +11 -5
  59. package/src/utils/git.ts +1400 -0
  60. package/src/web/search/render.ts +6 -4
  61. package/src/commit/git/errors.ts +0 -9
  62. package/src/commit/git/index.ts +0 -210
  63. package/src/commit/git/operations.ts +0 -54
  64. package/src/tools/gh-cli.ts +0 -125
@@ -1,5 +1,5 @@
1
1
  import { INTENT_FIELD } from "@oh-my-pi/pi-agent-core";
2
- import type { ImageContent } from "@oh-my-pi/pi-ai";
2
+ import type { AssistantMessage, ImageContent } from "@oh-my-pi/pi-ai";
3
3
  import { Loader, TERMINAL, Text } from "@oh-my-pi/pi-tui";
4
4
  import { settings } from "../../config/settings";
5
5
  import { AssistantMessageComponent } from "../../modes/components/assistant-message";
@@ -10,6 +10,7 @@ import { TtsrNotificationComponent } from "../../modes/components/ttsr-notificat
10
10
  import { getSymbolTheme, theme } from "../../modes/theme/theme";
11
11
  import type { InteractiveModeContext, TodoPhase } from "../../modes/types";
12
12
  import type { AgentSessionEvent } from "../../session/agent-session";
13
+ import { calculatePromptTokens } from "../../session/compaction/compaction";
13
14
  import type { ExitPlanModeDetails } from "../../tools";
14
15
 
15
16
  export class EventController {
@@ -21,8 +22,13 @@ export class EventController {
21
22
  #readToolCallArgs = new Map<string, Record<string, unknown>>();
22
23
  #readToolCallAssistantComponents = new Map<string, AssistantMessageComponent>();
23
24
  #lastAssistantComponent: AssistantMessageComponent | undefined = undefined;
25
+ #idleCompactionTimer?: NodeJS.Timeout;
24
26
  constructor(private ctx: InteractiveModeContext) {}
25
27
 
28
+ dispose(): void {
29
+ this.#cancelIdleCompaction();
30
+ }
31
+
26
32
  #resetReadGroup(): void {
27
33
  this.#lastReadGroup = undefined;
28
34
  }
@@ -107,6 +113,7 @@ export class EventController {
107
113
  this.ctx.retryLoader = undefined;
108
114
  this.ctx.statusContainer.clear();
109
115
  }
116
+ this.#cancelIdleCompaction();
110
117
  this.ctx.ensureLoadingAnimation();
111
118
  this.ctx.ui.requestRender();
112
119
  break;
@@ -434,16 +441,19 @@ export class EventController {
434
441
  this.#readToolCallAssistantComponents.clear();
435
442
  this.#lastAssistantComponent = undefined;
436
443
  this.ctx.ui.requestRender();
444
+ this.#scheduleIdleCompaction();
437
445
  this.sendCompletionNotification();
438
446
  break;
439
447
 
440
448
  case "auto_compaction_start": {
449
+ this.#cancelIdleCompaction();
441
450
  this.ctx.autoCompactionEscapeHandler = this.ctx.editor.onEscape;
442
451
  this.ctx.editor.onEscape = () => {
443
452
  this.ctx.session.abortCompaction();
444
453
  };
445
454
  this.ctx.statusContainer.clear();
446
- const reasonText = event.reason === "overflow" ? "Context overflow detected, " : "";
455
+ const reasonText =
456
+ event.reason === "overflow" ? "Context overflow detected, " : event.reason === "idle" ? "Idle " : "";
447
457
  const actionLabel = event.action === "handoff" ? "Auto-handoff" : "Auto context-full maintenance";
448
458
  this.ctx.autoCompactionLoader = new Loader(
449
459
  this.ctx.ui,
@@ -458,6 +468,7 @@ export class EventController {
458
468
  }
459
469
 
460
470
  case "auto_compaction_end": {
471
+ this.#cancelIdleCompaction();
461
472
  if (this.ctx.autoCompactionEscapeHandler) {
462
473
  this.ctx.editor.onEscape = this.ctx.autoCompactionEscapeHandler;
463
474
  this.ctx.autoCompactionEscapeHandler = undefined;
@@ -565,6 +576,52 @@ export class EventController {
565
576
  }
566
577
  }
567
578
 
579
+ #cancelIdleCompaction(): void {
580
+ if (this.#idleCompactionTimer) {
581
+ clearTimeout(this.#idleCompactionTimer);
582
+ this.#idleCompactionTimer = undefined;
583
+ }
584
+ }
585
+
586
+ #scheduleIdleCompaction(): void {
587
+ this.#cancelIdleCompaction();
588
+ // Don't schedule while compaction/handoff is already running — the agent_end from a
589
+ // handoff agent turn still has the old session's bloated token counts, and scheduling
590
+ // here would fire after the session resets, trying to handoff an empty session.
591
+ if (this.ctx.session.isCompacting) return;
592
+
593
+ const idleSettings = settings.getGroup("compaction");
594
+ if (!idleSettings.idleEnabled) return;
595
+
596
+ // Only if input is empty
597
+ if (this.ctx.editor.getText().trim()) return;
598
+
599
+ const threshold = idleSettings.idleThresholdTokens;
600
+ if (threshold <= 0) return;
601
+ if (this.#currentContextTokens() < threshold) return;
602
+
603
+ const timeoutMs = Math.max(60, Math.min(3600, idleSettings.idleTimeoutSeconds)) * 1000;
604
+ this.#idleCompactionTimer = setTimeout(() => {
605
+ this.#idleCompactionTimer = undefined;
606
+ // Re-check conditions before firing. Pruning may have run between arming
607
+ // the timer and now, dropping usage back below the idle threshold.
608
+ if (this.ctx.session.isStreaming) return;
609
+ if (this.ctx.session.isCompacting) return;
610
+ if (this.ctx.editor.getText().trim()) return;
611
+ if (this.#currentContextTokens() < threshold) return;
612
+ void this.ctx.session.runIdleCompaction();
613
+ }, timeoutMs);
614
+ this.#idleCompactionTimer.unref?.();
615
+ }
616
+
617
+ #currentContextTokens(): number {
618
+ const lastAssistant = this.ctx.session.agent.state.messages
619
+ .slice()
620
+ .reverse()
621
+ .find((m): m is AssistantMessage => m.role === "assistant" && m.stopReason !== "aborted");
622
+ return lastAssistant?.usage ? calculatePromptTokens(lastAssistant.usage) : 0;
623
+ }
624
+
568
625
  sendCompletionNotification(): void {
569
626
  if (this.ctx.isBackgrounded === false) return;
570
627
  const notify = settings.get("completion.notify");
@@ -676,6 +676,7 @@ export class ExtensionUiController {
676
676
  finish(undefined);
677
677
  }
678
678
  : undefined,
679
+ onExternalEditor: dialogOptions?.onExternalEditor,
679
680
  helpText: dialogOptions?.helpText,
680
681
  initialIndex: dialogOptions?.initialIndex,
681
682
  timeout: dialogOptions?.timeout,
@@ -158,6 +158,9 @@ export class InputController {
158
158
  for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
159
159
  this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
160
160
  }
161
+ for (const key of this.ctx.keybindings.getKeys("app.session.observe")) {
162
+ this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionObserver());
163
+ }
161
164
 
162
165
  this.ctx.editor.onChange = (text: string) => {
163
166
  const wasBashMode = this.ctx.isBashMode;
@@ -39,11 +39,13 @@ import { HistorySearchComponent } from "../components/history-search";
39
39
  import { ModelSelectorComponent } from "../components/model-selector";
40
40
  import { OAuthSelectorComponent } from "../components/oauth-selector";
41
41
  import { PluginSelectorComponent } from "../components/plugin-selector";
42
+ import { SessionObserverOverlayComponent } from "../components/session-observer-overlay";
42
43
  import { SessionSelectorComponent } from "../components/session-selector";
43
44
  import { SettingsSelectorComponent } from "../components/settings-selector";
44
45
  import { ToolExecutionComponent } from "../components/tool-execution";
45
46
  import { TreeSelectorComponent } from "../components/tree-selector";
46
47
  import { UserMessageSelectorComponent } from "../components/user-message-selector";
48
+ import type { SessionObserverRegistry } from "../session-observer-registry";
47
49
 
48
50
  const CALLBACK_SERVER_PROVIDERS = new Set<OAuthProvider>([
49
51
  "anthropic",
@@ -962,4 +964,28 @@ export class SelectorController {
962
964
  return { component: selector, focus: selector };
963
965
  });
964
966
  }
967
+
968
+ showSessionObserver(registry: SessionObserverRegistry): void {
969
+ const observeKeys = this.ctx.keybindings.getKeys("app.session.observe");
970
+
971
+ this.showSelector(done => {
972
+ let cleanup: (() => void) | undefined;
973
+
974
+ const selector = new SessionObserverOverlayComponent(
975
+ registry,
976
+ () => {
977
+ cleanup?.();
978
+ done();
979
+ },
980
+ observeKeys,
981
+ );
982
+
983
+ cleanup = registry.onChange(() => {
984
+ selector.refreshFromRegistry();
985
+ this.ctx.ui.requestRender();
986
+ });
987
+
988
+ return { component: selector, focus: selector };
989
+ });
990
+ }
965
991
  }
@@ -2,9 +2,17 @@
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
18
  import { APP_NAME, getProjectDir, hsvToRgb, isEnoent, logger, postmortem } from "@oh-my-pi/pi-utils";
@@ -29,6 +37,8 @@ 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
39
  import type { ExitPlanModeDetails } 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";
@@ -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,9 +162,10 @@ 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;
168
+ #planReviewContainer: Container | undefined;
157
169
  readonly lspServers:
158
170
  | Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>
159
171
  | undefined = undefined;
@@ -173,6 +185,8 @@ export class InteractiveMode implements InteractiveModeContext {
173
185
  #voicePreviousShowHardwareCursor: boolean | null = null;
174
186
  #voicePreviousUseTerminalCursor: boolean | null = null;
175
187
  #resizeHandler?: () => void;
188
+ #observerRegistry: SessionObserverRegistry;
189
+ #eventBus?: EventBus;
176
190
 
177
191
  constructor(
178
192
  session: AgentSession,
@@ -183,6 +197,7 @@ export class InteractiveMode implements InteractiveModeContext {
183
197
  | Array<{ name: string; status: "ready" | "error"; fileTypes: string[]; error?: string }>
184
198
  | undefined = undefined,
185
199
  mcpManager?: import("../mcp").MCPManager,
200
+ eventBus?: EventBus,
186
201
  ) {
187
202
  this.session = session;
188
203
  this.sessionManager = session.sessionManager;
@@ -194,6 +209,7 @@ export class InteractiveMode implements InteractiveModeContext {
194
209
  this.#toolUiContextSetter = setToolUIContext;
195
210
  this.lspServers = lspServers;
196
211
  this.mcpManager = mcpManager;
212
+ this.#eventBus = eventBus;
197
213
 
198
214
  this.ui = new TUI(new ProcessTerminal(), settings.get("showHardwareCursor"));
199
215
  this.ui.setClearOnShrink(settings.get("clearOnShrink"));
@@ -268,6 +284,7 @@ export class InteractiveMode implements InteractiveModeContext {
268
284
  this.#commandController = new CommandController(this);
269
285
  this.#selectorController = new SelectorController(this);
270
286
  this.#inputController = new InputController(this);
287
+ this.#observerRegistry = new SessionObserverRegistry();
271
288
  }
272
289
 
273
290
  async init(): Promise<void> {
@@ -352,6 +369,16 @@ export class InteractiveMode implements InteractiveModeContext {
352
369
  this.#inputController.setupKeyHandlers();
353
370
  this.#inputController.setupEditorSubmitHandler();
354
371
 
372
+ // Wire observer registry to EventBus
373
+ if (this.#eventBus) {
374
+ this.#observerRegistry.subscribeToEventBus(this.#eventBus);
375
+ }
376
+ this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
377
+ this.#observerRegistry.onChange(() => {
378
+ this.statusLine.setSubagentCount(this.#observerRegistry.getActiveSubagentCount());
379
+ this.ui.requestRender();
380
+ });
381
+
355
382
  // Load initial todos
356
383
  await this.#loadTodoList();
357
384
 
@@ -512,7 +539,7 @@ export class InteractiveMode implements InteractiveModeContext {
512
539
 
513
540
  rebuildChatFromMessages(): void {
514
541
  this.chatContainer.clear();
515
- const context = this.sessionManager.buildSessionContext();
542
+ const context = this.session.buildDisplaySessionContext();
516
543
  this.renderSessionContext(context);
517
544
  }
518
545
 
@@ -614,33 +641,41 @@ export class InteractiveMode implements InteractiveModeContext {
614
641
  }
615
642
 
616
643
  async #applyPlanModeModel(): Promise<void> {
617
- const planModel = this.session.resolveRoleModel("plan");
618
- if (!planModel) return;
644
+ const resolved = this.session.resolveRoleModelWithThinking("plan");
645
+ if (!resolved.model) return;
646
+
619
647
  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
- );
648
+ const sameModel = modelsAreEqual(currentModel, resolved.model);
649
+ const planThinkingLevel = resolved.explicitThinkingLevel ? resolved.thinkingLevel : undefined;
650
+
651
+ this.#planModePreviousModelState = currentModel
652
+ ? { model: currentModel, thinkingLevel: this.session.thinkingLevel }
653
+ : undefined;
654
+
655
+ if (!sameModel) {
656
+ if (this.session.isStreaming) {
657
+ this.#pendingModelSwitch = { model: resolved.model, thinkingLevel: planThinkingLevel };
658
+ return;
659
+ }
660
+ try {
661
+ await this.session.setModelTemporary(resolved.model, planThinkingLevel);
662
+ } catch (error) {
663
+ this.showWarning(
664
+ `Failed to switch to plan model for plan mode: ${error instanceof Error ? error.message : String(error)}`,
665
+ );
666
+ }
667
+ } else if (planThinkingLevel) {
668
+ this.session.setThinkingLevel(planThinkingLevel);
634
669
  }
635
670
  }
636
671
 
637
672
  /** Apply any deferred model switch after the current stream ends. */
638
673
  async flushPendingModelSwitch(): Promise<void> {
639
- const model = this.#pendingModelSwitch;
640
- if (!model) return;
674
+ const pending = this.#pendingModelSwitch;
675
+ if (!pending) return;
641
676
  this.#pendingModelSwitch = undefined;
642
677
  try {
643
- await this.session.setModelTemporary(model);
678
+ await this.session.setModelTemporary(pending.model, pending.thinkingLevel);
644
679
  } catch (error) {
645
680
  this.showWarning(
646
681
  `Failed to switch model after streaming: ${error instanceof Error ? error.message : String(error)}`,
@@ -704,20 +739,25 @@ export class InteractiveMode implements InteractiveModeContext {
704
739
  if (previousTools && previousTools.length > 0) {
705
740
  await this.session.setActiveToolsByName(previousTools);
706
741
  }
707
- if (this.#planModePreviousModel) {
708
- if (this.session.isStreaming) {
709
- this.#pendingModelSwitch = this.#planModePreviousModel;
742
+ if (this.#planModePreviousModelState) {
743
+ const prev = this.#planModePreviousModelState;
744
+ if (modelsAreEqual(this.session.model, prev.model)) {
745
+ // Same model — only thinking level may differ. Avoid setModelTemporary()
746
+ // which would reset provider-side sessions (openai-responses/Codex) and
747
+ // break conversation continuity.
748
+ this.session.setThinkingLevel(prev.thinkingLevel);
749
+ } else if (this.session.isStreaming) {
750
+ this.#pendingModelSwitch = { model: prev.model, thinkingLevel: prev.thinkingLevel };
710
751
  } else {
711
- await this.session.setModelTemporary(this.#planModePreviousModel);
752
+ await this.session.setModelTemporary(prev.model, prev.thinkingLevel);
712
753
  }
713
754
  }
714
-
715
755
  this.session.setPlanModeState(undefined);
716
756
  this.planModeEnabled = false;
717
757
  this.planModePaused = options?.paused ?? false;
718
758
  this.planModePlanFilePath = undefined;
719
759
  this.#planModePreviousTools = undefined;
720
- this.#planModePreviousModel = undefined;
760
+ this.#planModePreviousModelState = undefined;
721
761
  this.#updatePlanModeStatus();
722
762
  const paused = options?.paused ?? false;
723
763
  this.sessionManager.appendModeChange(paused ? "plan_paused" : "none");
@@ -739,15 +779,97 @@ export class InteractiveMode implements InteractiveModeContext {
739
779
  }
740
780
 
741
781
  #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());
782
+ if (!this.#planReviewContainer) {
783
+ this.#planReviewContainer = new Container();
784
+ this.chatContainer.addChild(this.#planReviewContainer);
785
+ }
786
+ this.#planReviewContainer.clear();
787
+ this.#planReviewContainer.addChild(new Spacer(1));
788
+ this.#planReviewContainer.addChild(new DynamicBorder());
789
+ this.#planReviewContainer.addChild(new Text(theme.bold(theme.fg("accent", "Plan Review")), 1, 1));
790
+ this.#planReviewContainer.addChild(new Spacer(1));
791
+ this.#planReviewContainer.addChild(new Markdown(planContent, 1, 1, getMarkdownTheme()));
792
+ this.#planReviewContainer.addChild(new DynamicBorder());
748
793
  this.ui.requestRender();
749
794
  }
750
795
 
796
+ #getEditorTerminalPath(): string | null {
797
+ if (process.platform === "win32") {
798
+ return null;
799
+ }
800
+ return "/dev/tty";
801
+ }
802
+
803
+ async #openEditorTerminalHandle(): Promise<fs.FileHandle | null> {
804
+ const terminalPath = this.#getEditorTerminalPath();
805
+ if (!terminalPath) {
806
+ return null;
807
+ }
808
+ try {
809
+ return await fs.open(terminalPath, "r+");
810
+ } catch {
811
+ return null;
812
+ }
813
+ }
814
+
815
+ #getPlanReviewHelpText(): string {
816
+ const externalEditorKey = this.keybindings.getDisplayString("app.editor.external");
817
+ if (!externalEditorKey) {
818
+ return "up/down navigate enter select esc cancel";
819
+ }
820
+ return `up/down navigate enter select ${externalEditorKey.toLowerCase()} open in editor esc cancel`;
821
+ }
822
+
823
+ async #openPlanInExternalEditor(planFilePath: string): Promise<void> {
824
+ const editorCmd = getEditorCommand();
825
+ if (!editorCmd) {
826
+ this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
827
+ return;
828
+ }
829
+
830
+ const resolvedPath = this.#resolvePlanFilePath(planFilePath);
831
+ let currentText: string;
832
+ try {
833
+ currentText = await Bun.file(resolvedPath).text();
834
+ } catch (error) {
835
+ if (isEnoent(error)) {
836
+ this.showError(`Plan file not found at ${planFilePath}`);
837
+ return;
838
+ }
839
+ this.showWarning(`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`);
840
+ return;
841
+ }
842
+
843
+ let ttyHandle: fs.FileHandle | null = null;
844
+ try {
845
+ ttyHandle = await this.#openEditorTerminalHandle();
846
+ this.ui.stop();
847
+
848
+ const stdio: [number | "inherit", number | "inherit", number | "inherit"] = ttyHandle
849
+ ? [ttyHandle.fd, ttyHandle.fd, ttyHandle.fd]
850
+ : ["inherit", "inherit", "inherit"];
851
+
852
+ const result = await openInEditor(editorCmd, currentText, {
853
+ extension: path.extname(resolvedPath) || ".md",
854
+ stdio,
855
+ trimTrailingNewline: false,
856
+ });
857
+ if (result !== null) {
858
+ await Bun.write(resolvedPath, result);
859
+ this.#renderPlanPreview(result);
860
+ this.showStatus("Plan updated in external editor.");
861
+ }
862
+ } catch (error) {
863
+ this.showWarning(`Failed to open external editor: ${error instanceof Error ? error.message : String(error)}`);
864
+ } finally {
865
+ if (ttyHandle) {
866
+ await ttyHandle.close();
867
+ }
868
+ this.ui.start();
869
+ this.ui.requestRender(true);
870
+ }
871
+ }
872
+
751
873
  async #approvePlan(
752
874
  planContent: string,
753
875
  options: { planFilePath: string; finalPlanFilePath: string },
@@ -817,16 +939,24 @@ export class InteractiveMode implements InteractiveModeContext {
817
939
  }
818
940
 
819
941
  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
- ]);
942
+ const choice = await this.showHookSelector(
943
+ "Plan mode - next step",
944
+ ["Approve and execute", "Refine plan", "Stay in plan mode"],
945
+ {
946
+ helpText: this.#getPlanReviewHelpText(),
947
+ onExternalEditor: () => void this.#openPlanInExternalEditor(planFilePath),
948
+ },
949
+ );
825
950
 
826
951
  if (choice === "Approve and execute") {
827
952
  const finalPlanFilePath = details.finalPlanFilePath || planFilePath;
828
953
  try {
829
- await this.#approvePlan(planContent, { planFilePath, finalPlanFilePath });
954
+ const latestPlanContent = await this.#readPlanFile(planFilePath);
955
+ if (!latestPlanContent) {
956
+ this.showError(`Plan file not found at ${planFilePath}`);
957
+ return;
958
+ }
959
+ await this.#approvePlan(latestPlanContent, { planFilePath, finalPlanFilePath });
830
960
  } catch (error) {
831
961
  this.showError(
832
962
  `Failed to finalize approved plan: ${error instanceof Error ? error.message : String(error)}`,
@@ -835,9 +965,13 @@ export class InteractiveMode implements InteractiveModeContext {
835
965
  return;
836
966
  }
837
967
  if (choice === "Refine plan") {
838
- const refinement = await this.showHookInput("What should be refined?");
968
+ const refinement = (await this.showHookInput("What should be refined?"))?.trim();
839
969
  if (refinement) {
840
- this.editor.setText(refinement);
970
+ if (this.onInputCallback) {
971
+ this.onInputCallback(this.startPendingSubmission({ text: refinement }));
972
+ } else {
973
+ this.editor.setText(refinement);
974
+ }
841
975
  }
842
976
  }
843
977
  }
@@ -854,6 +988,8 @@ export class InteractiveMode implements InteractiveModeContext {
854
988
  }
855
989
  this.#extensionUiController.clearExtensionTerminalInputListeners();
856
990
  this.#extensionUiController.clearHookWidgets();
991
+ this.#observerRegistry.dispose();
992
+ this.#eventController.dispose();
857
993
  this.statusLine.dispose();
858
994
  if (this.#resizeHandler) {
859
995
  process.stdout.removeListener("resize", this.#resizeHandler);
@@ -1096,6 +1232,7 @@ export class InteractiveMode implements InteractiveModeContext {
1096
1232
  handleClearCommand(): Promise<void> {
1097
1233
  this.#btwController.dispose();
1098
1234
  this.#extensionUiController.clearExtensionTerminalInputListeners();
1235
+ this.#planReviewContainer = undefined;
1099
1236
  return this.#commandController.handleClearCommand();
1100
1237
  }
1101
1238
 
@@ -1192,6 +1329,20 @@ export class InteractiveMode implements InteractiveModeContext {
1192
1329
  this.#selectorController.showDebugSelector();
1193
1330
  }
1194
1331
 
1332
+ showSessionObserver(): void {
1333
+ const sessions = this.#observerRegistry.getSessions();
1334
+ if (sessions.length <= 1) {
1335
+ this.showStatus("No active subagent sessions");
1336
+ return;
1337
+ }
1338
+ this.#selectorController.showSessionObserver(this.#observerRegistry);
1339
+ }
1340
+
1341
+ resetObserverRegistry(): void {
1342
+ this.#observerRegistry.resetSessions();
1343
+ this.#observerRegistry.setMainSession(this.sessionManager.getSessionFile() ?? undefined);
1344
+ }
1345
+
1195
1346
  handleBashCommand(command: string, excludeFromContext?: boolean): Promise<void> {
1196
1347
  return this.#commandController.handleBashCommand(command, excludeFromContext);
1197
1348
  }
@@ -1265,6 +1416,7 @@ export class InteractiveMode implements InteractiveModeContext {
1265
1416
 
1266
1417
  handleResumeSession(sessionPath: string): Promise<void> {
1267
1418
  this.#btwController.dispose();
1419
+ this.resetObserverRegistry();
1268
1420
  return this.#selectorController.handleResumeSession(sessionPath);
1269
1421
  }
1270
1422