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

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