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
This commit is contained in:
Mario Zechner 2026-01-02 23:47:53 +01:00
parent 58c423ba36
commit 3ae02a6849
12 changed files with 173 additions and 106 deletions

View file

@ -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) {

View file

@ -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<string, unknown>): 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();
}

View file

@ -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();

View file

@ -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;
}
}

View file

@ -24,7 +24,8 @@ const THINKING_DESCRIPTIONS: Record<ThinkingLevel, string> = {
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");

View file

@ -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<void> {
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);

View file

@ -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<void> {
await this.send({ type: "queue_message", message });
async steer(message: string): Promise<void> {
await this.send({ type: "steer", message });
}
/**
* Queue a follow-up message to be processed after the agent finishes.
*/
async followUp(message: string): Promise<void> {
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<void> {
await this.send({ type: "set_queue_mode", mode });
async setSteeringMode(mode: "all" | "one-at-a-time"): Promise<void> {
await this.send({ type: "set_steering_mode", mode });
}
/**
* Set follow-up mode.
*/
async setFollowUpMode(mode: "all" | "one-at-a-time"): Promise<void> {
await this.send({ type: "set_follow_up_mode", mode });
}
/**

View file

@ -253,9 +253,14 @@ export async function runRpcMode(session: AgentSession): Promise<never> {
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<never> {
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<never> {
}
// =================================================================
// 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");
}
// =================================================================

View file

@ -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 }

View file

@ -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

View file

@ -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
}

View file

@ -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.",
),