refactor(coding-agent): add runtime host for session switching closes #2024

This commit is contained in:
Mario Zechner
2026-03-31 13:49:57 +02:00
parent a3bf1eb399
commit d86122cbd3
32 changed files with 1328 additions and 692 deletions

View File

@@ -38,6 +38,10 @@ export class FooterComponent implements Component {
private footerData: ReadonlyFooterDataProvider,
) {}
setSession(session: AgentSession): void {
this.session = session;
}
setAutoCompactEnabled(enabled: boolean): void {
this.autoCompactEnabled = enabled;
}
@@ -86,7 +90,7 @@ export class FooterComponent implements Component {
const contextPercent = contextUsage?.percent !== null ? contextPercentValue.toFixed(1) : "?";
// Replace home directory with ~
let pwd = process.cwd();
let pwd = this.session.sessionManager.getCwd();
const home = process.env.HOME || process.env.USERPROFILE;
if (home && pwd.startsWith(home)) {
pwd = `~${pwd.slice(home.length)}`;

View File

@@ -47,6 +47,7 @@ import {
VERSION,
} from "../../config.js";
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
import type { AgentSessionRuntimeHost } from "../../core/agent-session-runtime.js";
import type {
ExtensionContext,
ExtensionRunner,
@@ -144,7 +145,7 @@ export interface InteractiveModeOptions {
}
export class InteractiveMode {
private session: AgentSession;
private runtimeHost: AgentSessionRuntimeHost;
private ui: TUI;
private chatContainer: Container;
private pendingMessagesContainer: Container;
@@ -242,6 +243,9 @@ export class InteractiveMode {
private customHeader: (Component & { dispose?(): void }) | undefined = undefined;
// Convenience accessors
private get session(): AgentSession {
return this.runtimeHost.session;
}
private get agent() {
return this.session.agent;
}
@@ -253,10 +257,10 @@ export class InteractiveMode {
}
constructor(
session: AgentSession,
runtimeHost: AgentSessionRuntimeHost,
private options: InteractiveModeOptions = {},
) {
this.session = session;
this.runtimeHost = runtimeHost;
this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
@@ -277,9 +281,9 @@ export class InteractiveMode {
this.editor = this.defaultEditor;
this.editorContainer = new Container();
this.editorContainer.addChild(this.editor as Component);
this.footerDataProvider = new FooterDataProvider();
this.footer = new FooterComponent(session, this.footerDataProvider);
this.footer.setAutoCompactEnabled(session.autoCompactionEnabled);
this.footerDataProvider = new FooterDataProvider(this.sessionManager.getCwd());
this.footer = new FooterComponent(this.session, this.footerDataProvider);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
// Load hide thinking block setting
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
@@ -412,7 +416,7 @@ export class InteractiveMode {
// Setup autocomplete
this.autocompleteProvider = new CombinedAutocompleteProvider(
[...slashCommands, ...templateCommands, ...extensionCommands, ...skillCommandList],
process.cwd(),
this.sessionManager.getCwd(),
fdPath,
);
this.defaultEditor.setAutocompleteProvider(this.autocompleteProvider);
@@ -524,7 +528,7 @@ export class InteractiveMode {
this.isInitialized = true;
// Initialize extensions first so resources are shown before messages
await this.initExtensions();
await this.bindCurrentSessionExtensions();
// Render initial messages AFTER showing loaded resources
this.renderInitialMessages();
@@ -555,7 +559,7 @@ export class InteractiveMode {
* Update terminal title with session name and cwd.
*/
private updateTerminalTitle(): void {
const cwdBasename = path.basename(process.cwd());
const cwdBasename = path.basename(this.sessionManager.getCwd());
const sessionName = this.sessionManager.getSessionName();
if (sessionName) {
this.ui.terminal.setTitle(`π - ${sessionName} - ${cwdBasename}`);
@@ -673,7 +677,7 @@ export class InteractiveMode {
try {
const packageManager = new DefaultPackageManager({
cwd: process.cwd(),
cwd: this.sessionManager.getCwd(),
agentDir: getAgentDir(),
settingsManager: this.settingsManager,
});
@@ -1161,7 +1165,7 @@ export class InteractiveMode {
/**
* Initialize the extension system with TUI-based UI context.
*/
private async initExtensions(): Promise<void> {
private async bindCurrentSessionExtensions(): Promise<void> {
const uiContext = this.createExtensionUIContext();
await this.session.bindExtensions({
uiContext,
@@ -1173,39 +1177,23 @@ export class InteractiveMode {
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Delegate to AgentSession (handles setup + agent state sync)
const success = await this.session.newSession(options);
if (!success) {
return { cancelled: true };
const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.ui.requestRender();
}
// Clear UI state
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Render any messages added via setup, or show empty session
this.renderInitialMessages();
this.ui.requestRender();
return { cancelled: false };
return result;
},
fork: async (entryId) => {
const result = await this.session.fork(entryId);
if (result.cancelled) {
return { cancelled: true };
const result = await this.runtimeHost.fork(entryId);
if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session");
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
this.showStatus("Forked to new session");
return { cancelled: false };
return { cancelled: result.cancelled };
},
navigateTree: async (targetId, options) => {
const result = await this.session.navigateTree(targetId, {
@@ -1224,7 +1212,6 @@ export class InteractiveMode {
this.editor.setText(result.editorText);
}
this.showStatus("Navigated to selected point");
return { cancelled: false };
},
switchSession: async (sessionPath) => {
@@ -1259,6 +1246,45 @@ export class InteractiveMode {
this.showLoadedResources({ force: false });
}
private applyRuntimeSettings(): void {
this.footer.setSession(this.session);
this.footer.setAutoCompactEnabled(this.session.autoCompactionEnabled);
this.footerDataProvider.setCwd(this.sessionManager.getCwd());
this.hideThinkingBlock = this.settingsManager.getHideThinkingBlock();
this.ui.setShowHardwareCursor(this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
const editorPaddingX = this.settingsManager.getEditorPaddingX();
const autocompleteMaxVisible = this.settingsManager.getAutocompleteMaxVisible();
this.defaultEditor.setPaddingX(editorPaddingX);
this.defaultEditor.setAutocompleteMaxVisible(autocompleteMaxVisible);
if (this.editor !== this.defaultEditor) {
this.editor.setPaddingX?.(editorPaddingX);
this.editor.setAutocompleteMaxVisible?.(autocompleteMaxVisible);
}
}
private async handleRuntimeSessionChange(): Promise<void> {
this.resetExtensionUI();
this.unsubscribe?.();
this.unsubscribe = undefined;
this.applyRuntimeSettings();
await this.bindCurrentSessionExtensions();
this.subscribeToAgent();
await this.updateAvailableProviderCount();
this.updateEditorBorderColor();
this.updateTerminalTitle();
}
private renderCurrentSessionState(): void {
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
this.renderInitialMessages();
}
/**
* Get a registered tool definition by name (for custom rendering).
*/
@@ -1277,7 +1303,7 @@ export class InteractiveMode {
const createContext = (): ExtensionContext => ({
ui: this.createExtensionUIContext(),
hasUI: true,
cwd: process.cwd(),
cwd: this.sessionManager.getCwd(),
sessionManager: this.sessionManager,
modelRegistry: this.session.modelRegistry,
model: this.session.model,
@@ -2305,6 +2331,7 @@ export class InteractiveMode {
},
this.getRegisteredToolDefinition(content.name),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@@ -2372,6 +2399,7 @@ export class InteractiveMode {
},
this.getRegisteredToolDefinition(event.toolName),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@@ -2681,6 +2709,7 @@ export class InteractiveMode {
{ showImages: this.settingsManager.getShowImages() },
this.getRegisteredToolDefinition(content.name),
this.ui,
this.sessionManager.getCwd(),
);
component.setExpanded(this.toolOutputExpanded);
this.chatContainer.addChild(component);
@@ -2779,14 +2808,7 @@ export class InteractiveMode {
private async shutdown(): Promise<void> {
if (this.isShuttingDown) return;
this.isShuttingDown = true;
// Emit shutdown event to extensions
const extensionRunner = this.session.extensionRunner;
if (extensionRunner?.hasHandlers("session_shutdown")) {
await extensionRunner.emit({
type: "session_shutdown",
});
}
await this.runtimeHost.dispose();
// Wait for any pending renders to complete
// requestRender() uses process.nextTick(), so we wait one tick
@@ -3605,17 +3627,15 @@ export class InteractiveMode {
const selector = new UserMessageSelectorComponent(
userMessages.map((m) => ({ id: m.entryId, text: m.text })),
async (entryId) => {
const result = await this.session.fork(entryId);
const result = await this.runtimeHost.fork(entryId);
if (result.cancelled) {
// Extension cancelled the fork
done();
this.ui.requestRender();
return;
}
this.chatContainer.clear();
this.renderInitialMessages();
this.editor.setText(result.selectedText);
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? "");
done();
this.showStatus("Branched to new session");
},
@@ -3792,26 +3812,17 @@ export class InteractiveMode {
}
private async handleResumeSession(sessionPath: string): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Clear UI state
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
// Switch session via AgentSession (emits extension session events)
await this.session.switchSession(sessionPath);
// Clear and re-render the chat
this.chatContainer.clear();
this.renderInitialMessages();
const result = await this.runtimeHost.switchSession(sessionPath);
if (result.cancelled) {
return;
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.showStatus("Resumed session");
}
@@ -4063,29 +4074,18 @@ export class InteractiveMode {
}
try {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// Clear UI state
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
const success = await this.session.importFromJsonl(inputPath);
if (!success) {
this.showWarning("Import cancelled");
const result = await this.runtimeHost.importFromJsonl(inputPath);
if (result.cancelled) {
this.showStatus("Import cancelled");
return;
}
// Clear and re-render the chat
this.chatContainer.clear();
this.renderInitialMessages();
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`);
} catch (error: unknown) {
this.showError(`Failed to import session: ${error instanceof Error ? error.message : "Unknown error"}`);
@@ -4427,25 +4427,17 @@ export class InteractiveMode {
}
private async handleClearCommand(): Promise<void> {
// Stop loading animation
if (this.loadingAnimation) {
this.loadingAnimation.stop();
this.loadingAnimation = undefined;
}
this.statusContainer.clear();
// New session via session (emits extension session events)
await this.session.newSession();
// Clear UI state
this.headerContainer.clear();
this.chatContainer.clear();
this.pendingMessagesContainer.clear();
this.compactionQueuedMessages = [];
this.streamingComponent = undefined;
this.streamingMessage = undefined;
this.pendingTools.clear();
const result = await this.runtimeHost.newSession();
if (result.cancelled) {
return;
}
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
this.ui.requestRender();
@@ -4511,7 +4503,7 @@ export class InteractiveMode {
type: "user_bash",
command,
excludeFromContext,
cwd: process.cwd(),
cwd: this.sessionManager.getCwd(),
})
: undefined;