@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.
- package/CHANGELOG.md +50 -0
- package/package.json +7 -11
- package/src/autoresearch/git.ts +25 -30
- package/src/autoresearch/tools/log-experiment.ts +61 -74
- package/src/commit/agentic/agent.ts +0 -3
- package/src/commit/agentic/index.ts +19 -22
- package/src/commit/agentic/tools/git-file-diff.ts +3 -6
- package/src/commit/agentic/tools/git-hunk.ts +3 -3
- package/src/commit/agentic/tools/git-overview.ts +6 -9
- package/src/commit/agentic/tools/index.ts +6 -8
- package/src/commit/agentic/tools/propose-commit.ts +4 -7
- package/src/commit/agentic/tools/recent-commits.ts +3 -3
- package/src/commit/agentic/tools/split-commit.ts +4 -4
- package/src/commit/changelog/index.ts +5 -9
- package/src/commit/pipeline.ts +10 -12
- package/src/config/keybindings.ts +7 -6
- package/src/config/settings-schema.ts +44 -0
- package/src/extensibility/custom-commands/bundled/ci-green/index.ts +4 -16
- package/src/extensibility/custom-commands/bundled/review/index.ts +43 -41
- package/src/extensibility/custom-tools/types.ts +1 -1
- package/src/extensibility/extensions/types.ts +3 -1
- package/src/extensibility/hooks/types.ts +1 -1
- package/src/extensibility/plugins/marketplace/fetcher.ts +2 -57
- package/src/extensibility/plugins/marketplace/source-resolver.ts +4 -4
- package/src/index.ts +1 -0
- package/src/main.ts +24 -2
- package/src/modes/components/footer.ts +9 -29
- package/src/modes/components/hook-editor.ts +3 -3
- package/src/modes/components/hook-selector.ts +6 -1
- package/src/modes/components/session-observer-overlay.ts +472 -0
- package/src/modes/components/settings-defs.ts +19 -0
- package/src/modes/components/status-line.ts +15 -61
- package/src/modes/controllers/command-controller.ts +1 -0
- package/src/modes/controllers/event-controller.ts +59 -2
- package/src/modes/controllers/extension-ui-controller.ts +1 -0
- package/src/modes/controllers/input-controller.ts +3 -0
- package/src/modes/controllers/selector-controller.ts +26 -0
- package/src/modes/interactive-mode.ts +195 -43
- package/src/modes/session-observer-registry.ts +146 -0
- package/src/modes/shared.ts +0 -42
- package/src/modes/types.ts +2 -0
- package/src/modes/utils/keybinding-matchers.ts +9 -0
- package/src/prompts/system/custom-system-prompt.md +5 -0
- package/src/prompts/system/system-prompt.md +6 -0
- package/src/sdk.ts +28 -13
- package/src/secrets/index.ts +1 -1
- package/src/secrets/obfuscator.ts +24 -16
- package/src/session/agent-session.ts +75 -30
- package/src/session/session-manager.ts +15 -5
- package/src/system-prompt.ts +4 -0
- package/src/task/executor.ts +28 -0
- package/src/task/index.ts +88 -78
- package/src/task/types.ts +25 -0
- package/src/task/worktree.ts +127 -145
- package/src/tools/exit-plan-mode.ts +1 -0
- package/src/tools/gh.ts +120 -297
- package/src/tools/read.ts +13 -79
- package/src/utils/external-editor.ts +11 -5
- package/src/utils/git.ts +1400 -0
- package/src/web/search/render.ts +6 -4
- package/src/commit/git/errors.ts +0 -9
- package/src/commit/git/index.ts +0 -210
- package/src/commit/git/operations.ts +0 -54
- 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 =
|
|
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
|
|
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
|
-
#
|
|
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.
|
|
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
|
|
618
|
-
if (!
|
|
644
|
+
const resolved = this.session.resolveRoleModelWithThinking("plan");
|
|
645
|
+
if (!resolved.model) return;
|
|
646
|
+
|
|
619
647
|
const currentModel = this.session.model;
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
this.#
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
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
|
|
640
|
-
if (!
|
|
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.#
|
|
708
|
-
|
|
709
|
-
|
|
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(
|
|
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.#
|
|
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
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
this.
|
|
747
|
-
this.
|
|
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(
|
|
821
|
-
"
|
|
822
|
-
"Refine plan",
|
|
823
|
-
|
|
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.#
|
|
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.
|
|
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
|
|