From 3ae02a6849305b982dc886afbce7bcc8221f7d2c Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Fri, 2 Jan 2026 23:47:53 +0100 Subject: [PATCH] feat(coding-agent): complete steer()/followUp() migration - Update settings-manager with steeringMode/followUpMode (migrates old queueMode) - Update sdk.ts to use new mode options - Update settings-selector UI to show both modes - Add Alt+Enter keybind for follow-up messages - Update RPC API: steer/follow_up commands, set_steering_mode/set_follow_up_mode - Update rpc-client with new methods - Delete dead code: queue-mode-selector.ts - Update tests for new API - Update mom/context.ts stubs - Update web-ui example --- packages/coding-agent/src/core/sdk.ts | 6 +- .../coding-agent/src/core/settings-manager.ts | 36 +++++++++--- .../interactive/components/custom-editor.ts | 7 +++ .../components/queue-mode-selector.ts | 56 ------------------- .../components/settings-selector.ts | 28 +++++++--- .../src/modes/interactive/interactive-mode.ts | 36 ++++++++++-- .../coding-agent/src/modes/rpc/rpc-client.ts | 26 +++++++-- .../coding-agent/src/modes/rpc/rpc-mode.ts | 27 ++++++--- .../coding-agent/src/modes/rpc/rpc-types.ts | 19 ++++--- .../test/agent-session-concurrent.test.ts | 24 ++++++-- packages/mom/src/context.ts | 12 +++- packages/web-ui/example/src/main.ts | 2 +- 12 files changed, 173 insertions(+), 106 deletions(-) delete mode 100644 packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts diff --git a/packages/coding-agent/src/core/sdk.ts b/packages/coding-agent/src/core/sdk.ts index 9545fb39..5e6b663e 100644 --- a/packages/coding-agent/src/core/sdk.ts +++ b/packages/coding-agent/src/core/sdk.ts @@ -305,7 +305,8 @@ export function loadSettings(cwd?: string, agentDir?: string): Settings { defaultProvider: manager.getDefaultProvider(), defaultModel: manager.getDefaultModel(), defaultThinkingLevel: manager.getDefaultThinkingLevel(), - queueMode: manager.getQueueMode(), + steeringMode: manager.getSteeringMode(), + followUpMode: manager.getFollowUpMode(), theme: manager.getTheme(), compaction: manager.getCompactionSettings(), retry: manager.getRetrySettings(), @@ -626,7 +627,8 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {} return hookRunner.emitContext(messages); } : undefined, - queueMode: settingsManager.getQueueMode(), + steeringMode: settingsManager.getSteeringMode(), + followUpMode: settingsManager.getFollowUpMode(), getApiKey: async () => { const currentModel = agent.state.model; if (!currentModel) { diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 4231655f..4e723cb6 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -39,7 +39,8 @@ export interface Settings { defaultProvider?: string; defaultModel?: string; defaultThinkingLevel?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh"; - queueMode?: "all" | "one-at-a-time"; + steeringMode?: "all" | "one-at-a-time"; + followUpMode?: "all" | "one-at-a-time"; theme?: string; compaction?: CompactionSettings; branchSummary?: BranchSummarySettings; @@ -125,13 +126,24 @@ export class SettingsManager { } try { const content = readFileSync(path, "utf-8"); - return JSON.parse(content); + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); } catch (error) { console.error(`Warning: Could not read settings file ${path}: ${error}`); return {}; } } + /** Migrate old settings format to new format */ + private static migrateSettings(settings: Record): Settings { + // Migrate queueMode -> steeringMode + if ("queueMode" in settings && !("steeringMode" in settings)) { + settings.steeringMode = settings.queueMode; + delete settings.queueMode; + } + return settings as Settings; + } + private loadProjectSettings(): Settings { if (!this.projectSettingsPath || !existsSync(this.projectSettingsPath)) { return {}; @@ -139,7 +151,8 @@ export class SettingsManager { try { const content = readFileSync(this.projectSettingsPath, "utf-8"); - return JSON.parse(content); + const settings = JSON.parse(content); + return SettingsManager.migrateSettings(settings); } catch (error) { console.error(`Warning: Could not read project settings file: ${error}`); return {}; @@ -204,12 +217,21 @@ export class SettingsManager { this.save(); } - getQueueMode(): "all" | "one-at-a-time" { - return this.settings.queueMode || "one-at-a-time"; + getSteeringMode(): "all" | "one-at-a-time" { + return this.settings.steeringMode || "one-at-a-time"; } - setQueueMode(mode: "all" | "one-at-a-time"): void { - this.globalSettings.queueMode = mode; + setSteeringMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.steeringMode = mode; + this.save(); + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return this.settings.followUpMode || "one-at-a-time"; + } + + setFollowUpMode(mode: "all" | "one-at-a-time"): void { + this.globalSettings.followUpMode = mode; this.save(); } diff --git a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts index 8a75f0d9..c0b951bd 100644 --- a/packages/coding-agent/src/modes/interactive/components/custom-editor.ts +++ b/packages/coding-agent/src/modes/interactive/components/custom-editor.ts @@ -1,5 +1,6 @@ import { Editor, + isAltEnter, isCtrlC, isCtrlD, isCtrlG, @@ -28,8 +29,14 @@ export class CustomEditor extends Editor { public onCtrlT?: () => void; public onCtrlG?: () => void; public onCtrlZ?: () => void; + public onAltEnter?: () => void; handleInput(data: string): void { + // Intercept Alt+Enter for follow-up messages + if (isAltEnter(data) && this.onAltEnter) { + this.onAltEnter(); + return; + } // Intercept Ctrl+G for external editor if (isCtrlG(data) && this.onCtrlG) { this.onCtrlG(); diff --git a/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts b/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts deleted file mode 100644 index cebd1e5b..00000000 --- a/packages/coding-agent/src/modes/interactive/components/queue-mode-selector.ts +++ /dev/null @@ -1,56 +0,0 @@ -import { Container, type SelectItem, SelectList } from "@mariozechner/pi-tui"; -import { getSelectListTheme } from "../theme/theme.js"; -import { DynamicBorder } from "./dynamic-border.js"; - -/** - * Component that renders a queue mode selector with borders - */ -export class QueueModeSelectorComponent extends Container { - private selectList: SelectList; - - constructor( - currentMode: "all" | "one-at-a-time", - onSelect: (mode: "all" | "one-at-a-time") => void, - onCancel: () => void, - ) { - super(); - - const queueModes: SelectItem[] = [ - { - value: "one-at-a-time", - label: "one-at-a-time", - description: "Process queued messages one by one (recommended)", - }, - { value: "all", label: "all", description: "Process all queued messages at once" }, - ]; - - // Add top border - this.addChild(new DynamicBorder()); - - // Create selector - this.selectList = new SelectList(queueModes, 2, getSelectListTheme()); - - // Preselect current mode - const currentIndex = queueModes.findIndex((item) => item.value === currentMode); - if (currentIndex !== -1) { - this.selectList.setSelectedIndex(currentIndex); - } - - this.selectList.onSelect = (item) => { - onSelect(item.value as "all" | "one-at-a-time"); - }; - - this.selectList.onCancel = () => { - onCancel(); - }; - - this.addChild(this.selectList); - - // Add bottom border - this.addChild(new DynamicBorder()); - } - - getSelectList(): SelectList { - return this.selectList; - } -} diff --git a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts index 1202e3ee..9538aebf 100644 --- a/packages/coding-agent/src/modes/interactive/components/settings-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/settings-selector.ts @@ -24,7 +24,8 @@ const THINKING_DESCRIPTIONS: Record = { export interface SettingsConfig { autoCompact: boolean; showImages: boolean; - queueMode: "all" | "one-at-a-time"; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; thinkingLevel: ThinkingLevel; availableThinkingLevels: ThinkingLevel[]; currentTheme: string; @@ -36,7 +37,8 @@ export interface SettingsConfig { export interface SettingsCallbacks { onAutoCompactChange: (enabled: boolean) => void; onShowImagesChange: (enabled: boolean) => void; - onQueueModeChange: (mode: "all" | "one-at-a-time") => void; + onSteeringModeChange: (mode: "all" | "one-at-a-time") => void; + onFollowUpModeChange: (mode: "all" | "one-at-a-time") => void; onThinkingLevelChange: (level: ThinkingLevel) => void; onThemeChange: (theme: string) => void; onThemePreview?: (theme: string) => void; @@ -127,10 +129,17 @@ export class SettingsSelectorComponent extends Container { values: ["true", "false"], }, { - id: "queue-mode", - label: "Queue mode", - description: "How to process queued messages while agent is working", - currentValue: config.queueMode, + id: "steering-mode", + label: "Steering mode", + description: "How to deliver steering messages (Enter while streaming)", + currentValue: config.steeringMode, + values: ["one-at-a-time", "all"], + }, + { + id: "follow-up-mode", + label: "Follow-up mode", + description: "How to deliver follow-up messages (queued until agent finishes)", + currentValue: config.followUpMode, values: ["one-at-a-time", "all"], }, { @@ -227,8 +236,11 @@ export class SettingsSelectorComponent extends Container { case "show-images": callbacks.onShowImagesChange(newValue === "true"); break; - case "queue-mode": - callbacks.onQueueModeChange(newValue as "all" | "one-at-a-time"); + case "steering-mode": + callbacks.onSteeringModeChange(newValue as "all" | "one-at-a-time"); + break; + case "follow-up-mode": + callbacks.onFollowUpModeChange(newValue as "all" | "one-at-a-time"); break; case "hide-thinking": callbacks.onHideThinkingBlockChange(newValue === "true"); diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 9dcd8d6a..7acae1fd 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -262,6 +262,9 @@ export class InteractiveMode { theme.fg("dim", "!") + theme.fg("muted", " to run bash") + "\n" + + theme.fg("dim", "alt+enter") + + theme.fg("muted", " to queue follow-up") + + "\n" + theme.fg("dim", "drop files") + theme.fg("muted", " to attach"); const header = new Text(`${logo}\n${instructions}`, 1, 0); @@ -776,6 +779,7 @@ export class InteractiveMode { this.editor.onCtrlO = () => this.toggleToolOutputExpansion(); this.editor.onCtrlT = () => this.toggleThinkingBlockVisibility(); this.editor.onCtrlG = () => this.openExternalEditor(); + this.editor.onAltEnter = () => this.handleAltEnter(); this.editor.onChange = (text: string) => { const wasBashMode = this.isBashMode; @@ -920,9 +924,9 @@ export class InteractiveMode { } } - // Queue regular messages if agent is streaming + // Queue steering message if agent is streaming (interrupts current work) if (this.session.isStreaming) { - await this.session.queueMessage(text); + await this.session.steer(text); this.updatePendingMessagesDisplay(); this.editor.addToHistory(text); this.editor.setText(""); @@ -1447,6 +1451,24 @@ export class InteractiveMode { process.kill(0, "SIGTSTP"); } + private async handleAltEnter(): Promise { + const text = this.editor.getText().trim(); + if (!text) return; + + // Alt+Enter queues a follow-up message (waits until agent finishes) + if (this.session.isStreaming) { + await this.session.followUp(text); + this.updatePendingMessagesDisplay(); + this.editor.addToHistory(text); + this.editor.setText(""); + this.ui.requestRender(); + } + // If not streaming, Alt+Enter acts like regular Enter (trigger onSubmit) + else if (this.editor.onSubmit) { + this.editor.onSubmit(text); + } + } + private updateEditorBorderColor(): void { if (this.isBashMode) { this.editor.borderColor = theme.getBashModeBorderColor(); @@ -1651,7 +1673,8 @@ export class InteractiveMode { { autoCompact: this.session.autoCompactionEnabled, showImages: this.settingsManager.getShowImages(), - queueMode: this.session.queueMode, + steeringMode: this.session.steeringMode, + followUpMode: this.session.followUpMode, thinkingLevel: this.session.thinkingLevel, availableThinkingLevels: this.session.getAvailableThinkingLevels(), currentTheme: this.settingsManager.getTheme() || "dark", @@ -1672,8 +1695,11 @@ export class InteractiveMode { } } }, - onQueueModeChange: (mode) => { - this.session.setQueueMode(mode); + onSteeringModeChange: (mode) => { + this.session.setSteeringMode(mode); + }, + onFollowUpModeChange: (mode) => { + this.session.setFollowUpMode(mode); }, onThinkingLevelChange: (level) => { this.session.setThinkingLevel(level); diff --git a/packages/coding-agent/src/modes/rpc/rpc-client.ts b/packages/coding-agent/src/modes/rpc/rpc-client.ts index 93187dcb..39b89156 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-client.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-client.ts @@ -173,10 +173,17 @@ export class RpcClient { } /** - * Queue a message while agent is streaming. + * Queue a steering message to interrupt the agent mid-run. */ - async queueMessage(message: string): Promise { - await this.send({ type: "queue_message", message }); + async steer(message: string): Promise { + await this.send({ type: "steer", message }); + } + + /** + * Queue a follow-up message to be processed after the agent finishes. + */ + async followUp(message: string): Promise { + await this.send({ type: "follow_up", message }); } /** @@ -248,10 +255,17 @@ export class RpcClient { } /** - * Set queue mode. + * Set steering mode. */ - async setQueueMode(mode: "all" | "one-at-a-time"): Promise { - await this.send({ type: "set_queue_mode", mode }); + async setSteeringMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_steering_mode", mode }); + } + + /** + * Set follow-up mode. + */ + async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise { + await this.send({ type: "set_follow_up_mode", mode }); } /** diff --git a/packages/coding-agent/src/modes/rpc/rpc-mode.ts b/packages/coding-agent/src/modes/rpc/rpc-mode.ts index 090f4d5b..1d22781e 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-mode.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-mode.ts @@ -253,9 +253,14 @@ export async function runRpcMode(session: AgentSession): Promise { return success(id, "prompt"); } - case "queue_message": { - await session.queueMessage(command.message); - return success(id, "queue_message"); + case "steer": { + await session.steer(command.message); + return success(id, "steer"); + } + + case "follow_up": { + await session.followUp(command.message); + return success(id, "follow_up"); } case "abort": { @@ -279,7 +284,8 @@ export async function runRpcMode(session: AgentSession): Promise { thinkingLevel: session.thinkingLevel, isStreaming: session.isStreaming, isCompacting: session.isCompacting, - queueMode: session.queueMode, + steeringMode: session.steeringMode, + followUpMode: session.followUpMode, sessionFile: session.sessionFile, sessionId: session.sessionId, autoCompactionEnabled: session.autoCompactionEnabled, @@ -334,12 +340,17 @@ export async function runRpcMode(session: AgentSession): Promise { } // ================================================================= - // Queue Mode + // Queue Modes // ================================================================= - case "set_queue_mode": { - session.setQueueMode(command.mode); - return success(id, "set_queue_mode"); + case "set_steering_mode": { + session.setSteeringMode(command.mode); + return success(id, "set_steering_mode"); + } + + case "set_follow_up_mode": { + session.setFollowUpMode(command.mode); + return success(id, "set_follow_up_mode"); } // ================================================================= diff --git a/packages/coding-agent/src/modes/rpc/rpc-types.ts b/packages/coding-agent/src/modes/rpc/rpc-types.ts index b40fa9a7..5062f64a 100644 --- a/packages/coding-agent/src/modes/rpc/rpc-types.ts +++ b/packages/coding-agent/src/modes/rpc/rpc-types.ts @@ -18,7 +18,8 @@ import type { CompactionResult } from "../../core/compaction/index.js"; export type RpcCommand = // Prompting | { id?: string; type: "prompt"; message: string; images?: ImageContent[] } - | { id?: string; type: "queue_message"; message: string } + | { id?: string; type: "steer"; message: string } + | { id?: string; type: "follow_up"; message: string } | { id?: string; type: "abort" } | { id?: string; type: "new_session"; parentSession?: string } @@ -34,8 +35,9 @@ export type RpcCommand = | { id?: string; type: "set_thinking_level"; level: ThinkingLevel } | { id?: string; type: "cycle_thinking_level" } - // Queue mode - | { id?: string; type: "set_queue_mode"; mode: "all" | "one-at-a-time" } + // Queue modes + | { id?: string; type: "set_steering_mode"; mode: "all" | "one-at-a-time" } + | { id?: string; type: "set_follow_up_mode"; mode: "all" | "one-at-a-time" } // Compaction | { id?: string; type: "compact"; customInstructions?: string } @@ -69,7 +71,8 @@ export interface RpcSessionState { thinkingLevel: ThinkingLevel; isStreaming: boolean; isCompacting: boolean; - queueMode: "all" | "one-at-a-time"; + steeringMode: "all" | "one-at-a-time"; + followUpMode: "all" | "one-at-a-time"; sessionFile?: string; sessionId: string; autoCompactionEnabled: boolean; @@ -85,7 +88,8 @@ export interface RpcSessionState { export type RpcResponse = // Prompting (async - events follow) | { id?: string; type: "response"; command: "prompt"; success: true } - | { id?: string; type: "response"; command: "queue_message"; success: true } + | { id?: string; type: "response"; command: "steer"; success: true } + | { id?: string; type: "response"; command: "follow_up"; success: true } | { id?: string; type: "response"; command: "abort"; success: true } | { id?: string; type: "response"; command: "new_session"; success: true; data: { cancelled: boolean } } @@ -125,8 +129,9 @@ export type RpcResponse = data: { level: ThinkingLevel } | null; } - // Queue mode - | { id?: string; type: "response"; command: "set_queue_mode"; success: true } + // Queue modes + | { id?: string; type: "response"; command: "set_steering_mode"; success: true } + | { id?: string; type: "response"; command: "set_follow_up_mode"; success: true } // Compaction | { id?: string; type: "response"; command: "compact"; success: true; data: CompactionResult } diff --git a/packages/coding-agent/test/agent-session-concurrent.test.ts b/packages/coding-agent/test/agent-session-concurrent.test.ts index 30f3692d..2458d879 100644 --- a/packages/coding-agent/test/agent-session-concurrent.test.ts +++ b/packages/coding-agent/test/agent-session-concurrent.test.ts @@ -127,7 +127,7 @@ describe("AgentSession concurrent prompt guard", () => { // Second prompt should reject await expect(session.prompt("Second message")).rejects.toThrow( - "Agent is already processing. Use queueMessage() to queue messages during streaming.", + "Agent is already processing. Use steer() or followUp() to queue messages during streaming.", ); // Cleanup @@ -135,15 +135,31 @@ describe("AgentSession concurrent prompt guard", () => { await firstPrompt.catch(() => {}); // Ignore abort error }); - it("should allow queueMessage() while streaming", async () => { + it("should allow steer() while streaming", async () => { createSession(); // Start first prompt const firstPrompt = session.prompt("First message"); await new Promise((resolve) => setTimeout(resolve, 10)); - // queueMessage should work while streaming - expect(() => session.queueMessage("Queued message")).not.toThrow(); + // steer should work while streaming + expect(() => session.steer("Steering message")).not.toThrow(); + expect(session.pendingMessageCount).toBe(1); + + // Cleanup + await session.abort(); + await firstPrompt.catch(() => {}); + }); + + it("should allow followUp() while streaming", async () => { + createSession(); + + // Start first prompt + const firstPrompt = session.prompt("First message"); + await new Promise((resolve) => setTimeout(resolve, 10)); + + // followUp should work while streaming + expect(() => session.followUp("Follow-up message")).not.toThrow(); expect(session.pendingMessageCount).toBe(1); // Cleanup diff --git a/packages/mom/src/context.ts b/packages/mom/src/context.ts index 11f8a69c..f5a106da 100644 --- a/packages/mom/src/context.ts +++ b/packages/mom/src/context.ts @@ -495,11 +495,19 @@ export class MomSettingsManager { } // Compatibility methods for AgentSession - getQueueMode(): "all" | "one-at-a-time" { + getSteeringMode(): "all" | "one-at-a-time" { return "one-at-a-time"; // Mom processes one message at a time } - setQueueMode(_mode: "all" | "one-at-a-time"): void { + setSteeringMode(_mode: "all" | "one-at-a-time"): void { + // No-op for mom + } + + getFollowUpMode(): "all" | "one-at-a-time" { + return "one-at-a-time"; // Mom processes one message at a time + } + + setFollowUpMode(_mode: "all" | "one-at-a-time"): void { // No-op for mom } diff --git a/packages/web-ui/example/src/main.ts b/packages/web-ui/example/src/main.ts index 4a93f409..ae7e2ed0 100644 --- a/packages/web-ui/example/src/main.ts +++ b/packages/web-ui/example/src/main.ts @@ -346,7 +346,7 @@ const renderApp = () => { onClick: () => { // Demo: Inject custom message (will appear on next agent run) if (agent) { - agent.queueMessage( + agent.steer( createSystemNotification( "This is a custom message! It appears in the UI but is never sent to the LLM.", ),