From de397b3b04a073185d20fae42e9ddf31702a93da Mon Sep 17 00:00:00 2001 From: can1357 Date: Fri, 6 Feb 2026 02:55:41 +0100 Subject: [PATCH] feat(coding-agent): restored plan mode on resume from session - Added ModeChangeEntry type export to public API for tracking agent mode transitions. - Added mode and modeData fields to SessionContext to track active agent mode state across sessions. - Added appendModeChange() method to SessionManager for recording mode transition entries. - Added plan mode state restoration from session entries when resuming interactive mode sessions. - Updated HTML export filter to treat mode_change entries as settings entries alongside model changes and thinking level changes. - Added type validation for tool arguments in HTML export template with error handling for invalid values. --- packages/coding-agent/CHANGELOG.md | 6 ++- .../src/export/html/template.generated.ts | 2 +- .../coding-agent/src/export/html/template.js | 2 +- packages/coding-agent/src/index.ts | 1 + .../src/modes/interactive-mode.ts | 21 +++++++++- .../src/session/session-manager.ts | 41 +++++++++++++++++-- 6 files changed, 65 insertions(+), 8 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a0028ecc2..9a0d1274d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,9 +1,12 @@ # Changelog ## [Unreleased] - ### Added +- Exported `ModeChangeEntry` type for tracking agent mode transitions +- Support for restoring plan mode state when resuming sessions +- New `appendModeChange()` method in SessionManager to record mode transitions +- New `mode` and `modeData` fields in SessionContext to track active agent mode - Support for `PI_PACKAGE_DIR` environment variable to override package directory (useful for Nix/Guix store paths) - New keybindings for session management: `toggleSessionNamedFilter` (Ctrl+N), `newSession`, `tree`, `fork`, and `resume` actions - Support for shell command execution in configuration values (API keys, headers) using `!` prefix, with result caching @@ -30,6 +33,7 @@ ### Changed +- HTML export filter now treats `mode_change` entries as settings entries alongside model changes and thinking level changes - Replaced ellipsis string (`...`) with Unicode ellipsis character (`…`) throughout UI text and truncation logic for improved typography - Improved render performance by introducing caching for tool output blocks and search results to avoid redundant text width and padding computations - Enhanced read tool grouping to reset when non-tool content (text/thinking blocks) appears between read calls, preventing unintended coalescing diff --git a/packages/coding-agent/src/export/html/template.generated.ts b/packages/coding-agent/src/export/html/template.generated.ts index 92f8e7a48..ecad9b142 100644 --- a/packages/coding-agent/src/export/html/template.generated.ts +++ b/packages/coding-agent/src/export/html/template.generated.ts @@ -1,2 +1,2 @@ // Auto-generated by scripts/generate-template.ts - DO NOT EDIT -export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; +export const TEMPLATE = "\n\n\n \n \n Session Export\n \n \n\n\n \n
\n
\n \n
\n
\n
\n
\n
\n \"\"\n
\n
\n\n \n \n \n \n\n\n"; diff --git a/packages/coding-agent/src/export/html/template.js b/packages/coding-agent/src/export/html/template.js index 64f9e2815..de5876e73 100644 --- a/packages/coding-agent/src/export/html/template.js +++ b/packages/coding-agent/src/export/html/template.js @@ -338,7 +338,7 @@ } // Apply filter mode - const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change'].includes(entry.type); + const isSettingsEntry = ['label', 'custom', 'model_change', 'thinking_level_change', 'mode_change'].includes(entry.type); let passesFilter = true; switch (filterMode) { diff --git a/packages/coding-agent/src/index.ts b/packages/coding-agent/src/index.ts index 95b5c888c..5bf217286 100644 --- a/packages/coding-agent/src/index.ts +++ b/packages/coding-agent/src/index.ts @@ -231,6 +231,7 @@ export { type CustomMessageEntry, type FileEntry, getLatestCompactionEntry, + type ModeChangeEntry, type ModelChangeEntry, migrateSessionEntries, type NewSessionOptions, diff --git a/packages/coding-agent/src/modes/interactive-mode.ts b/packages/coding-agent/src/modes/interactive-mode.ts index 177eabc0f..6ab39d314 100644 --- a/packages/coding-agent/src/modes/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive-mode.ts @@ -345,6 +345,9 @@ export class InteractiveMode implements InteractiveModeContext { // Initialize hooks with TUI-based UI context await this.initHooksAndCustomTools(); + // Restore mode from session (e.g. plan mode on resume) + await this.restoreModeFromSession(); + // Subscribe to agent events this.subscribeToAgent(); @@ -533,6 +536,19 @@ export class InteractiveMode implements InteractiveModeContext { } } + /** Restore mode state from session entries on resume (e.g. plan mode). */ + private async restoreModeFromSession(): Promise { + const sessionContext = this.sessionManager.buildSessionContext(); + if (sessionContext.mode === "plan") { + const planFilePath = sessionContext.modeData?.planFilePath as string | undefined; + await this.enterPlanMode({ planFilePath }); + } else if (sessionContext.mode === "plan_paused") { + this.planModePaused = true; + this.planModeHasEntered = true; + this.updatePlanModeStatus(); + } + } + private async enterPlanMode(options?: { planFilePath?: string; workflow?: "parallel" | "iterative"; @@ -566,6 +582,7 @@ export class InteractiveMode implements InteractiveModeContext { this.planModeHasEntered = true; await this.applyPlanModeModel(); this.updatePlanModeStatus(); + this.sessionManager.appendModeChange("plan", { planFilePath }); this.showStatus(`Plan mode enabled. Plan file: ${planFilePath}`); } @@ -593,8 +610,10 @@ export class InteractiveMode implements InteractiveModeContext { this.planModePreviousTools = undefined; this.planModePreviousModel = undefined; this.updatePlanModeStatus(); + const paused = options?.paused ?? false; + this.sessionManager.appendModeChange(paused ? "plan_paused" : "none"); if (!options?.silent) { - this.showStatus(this.planModePaused ? "Plan mode paused." : "Plan mode disabled."); + this.showStatus(paused ? "Plan mode paused." : "Plan mode disabled."); } } diff --git a/packages/coding-agent/src/session/session-manager.ts b/packages/coding-agent/src/session/session-manager.ts index 10b9d984b..038f26273 100644 --- a/packages/coding-agent/src/session/session-manager.ts +++ b/packages/coding-agent/src/session/session-manager.ts @@ -125,6 +125,15 @@ export interface SessionInitEntry extends SessionEntryBase { outputSchema?: unknown; } +/** Mode change entry - tracks agent mode transitions (e.g. plan mode). */ +export interface ModeChangeEntry extends SessionEntryBase { + type: "mode_change"; + /** Current mode name, or "none" when exiting a mode */ + mode: string; + /** Optional mode-specific data (e.g. plan file path) */ + data?: Record; +} + /** * Custom message entry for extensions to inject messages into LLM context. * Use customType to identify your extension's entries. @@ -156,7 +165,8 @@ export type SessionEntry = | CustomMessageEntry | LabelEntry | TtsrInjectionEntry - | SessionInitEntry; + | SessionInitEntry + | ModeChangeEntry; /** Raw file entry (includes header) */ export type FileEntry = SessionHeader | SessionEntry; @@ -176,6 +186,10 @@ export interface SessionContext { models: Record; /** Names of TTSR rules that have been injected this session */ injectedTtsrRules: string[]; + /** Active mode (e.g. "plan") or "none" if no special mode is active */ + mode: string; + /** Mode-specific data from the last mode_change entry */ + modeData?: Record; } export interface SessionInfo { @@ -326,7 +340,7 @@ export function buildSessionContext( let leaf: SessionEntry | undefined; if (leafId === null) { // Explicitly null - return no messages (navigated to before first entry) - return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] }; + return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [], mode: "none" }; } if (leafId) { leaf = byId.get(leafId); @@ -337,7 +351,7 @@ export function buildSessionContext( } if (!leaf) { - return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [] }; + return { messages: [], thinkingLevel: "off", models: {}, injectedTtsrRules: [], mode: "none" }; } // Walk from leaf to root, collecting path @@ -353,6 +367,8 @@ export function buildSessionContext( const models: Record = {}; let compaction: CompactionEntry | null = null; const injectedTtsrRulesSet = new Set(); + let mode = "none"; + let modeData: Record | undefined; for (const entry of path) { if (entry.type === "thinking_level_change") { @@ -373,6 +389,9 @@ export function buildSessionContext( for (const ruleName of entry.injectedRules) { injectedTtsrRulesSet.add(ruleName); } + } else if (entry.type === "mode_change") { + mode = entry.mode; + modeData = entry.data; } } @@ -435,7 +454,7 @@ export function buildSessionContext( } } - return { messages, thinkingLevel, models, injectedTtsrRules }; + return { messages, thinkingLevel, models, injectedTtsrRules, mode, modeData }; } /** @@ -1388,6 +1407,20 @@ export class SessionManager { return entry.id; } + /** Append a mode change as child of current leaf, then advance leaf. Returns entry id. */ + appendModeChange(mode: string, data?: Record): string { + const entry: ModeChangeEntry = { + type: "mode_change", + id: generateId(this.byId), + parentId: this.leafId, + timestamp: new Date().toISOString(), + mode, + data, + }; + this._appendEntry(entry); + return entry.id; + } + /** * Append a model change as child of current leaf, then advance leaf. Returns entry id. * @param model Model in "provider/modelId" format