From 970a7bcc748b73488748e8ba472568a9b20dd2db Mon Sep 17 00:00:00 2001 From: Subsy Date: Tue, 12 May 2026 22:11:07 +0100 Subject: [PATCH 1/3] Add in-session agent and model picker with runtime switching - New `AgentModelPicker` TUI overlay (press `a`) to select agent and model - `ExecutionEngine.switchToUserAgent` validates and applies changes without restart - `listModels()` added to agent plugin interface; Claude, Gemini, and Kiro implement it - `model` field persisted to StoredConfig so selection survives restarts - Rebinds `A` (shift) for "add remote"; `a` now opens the picker --- README.md | 5 +- src/chat/engine.test.ts | 1 + src/commands/run.tsx | 40 +- src/config/index.ts | 3 +- src/config/schema.ts | 1 + src/config/types.ts | 3 + src/engine/index.ts | 84 +++- src/engine/switch-to-user-agent.test.ts | 165 +++++++ src/engine/types.ts | 3 +- src/logs/persistence.ts | 6 +- src/logs/types.ts | 2 +- src/plugins/agents/base.ts | 8 + src/plugins/agents/builtin/claude.test.ts | 15 + src/plugins/agents/builtin/claude.ts | 7 + src/plugins/agents/builtin/gemini.test.ts | 9 + src/plugins/agents/builtin/gemini.ts | 10 +- src/plugins/agents/builtin/kiro.test.ts | 20 + src/plugins/agents/builtin/kiro.ts | 4 + src/plugins/agents/types.ts | 7 + src/tui/components/AgentModelPicker.test.ts | 74 +++ src/tui/components/AgentModelPicker.tsx | 443 ++++++++++++++++++ src/tui/components/RunApp.tsx | 83 +++- src/tui/components/SettingsView.tsx | 14 +- src/tui/theme.ts | 3 + website/content/docs/cli/run.mdx | 8 + .../docs/configuration/config-file.mdx | 7 +- .../content/docs/configuration/options.mdx | 5 +- .../docs/getting-started/quick-start.mdx | 3 +- 28 files changed, 1004 insertions(+), 29 deletions(-) create mode 100644 src/engine/switch-to-user-agent.test.ts create mode 100644 src/plugins/agents/builtin/claude.test.ts create mode 100644 src/plugins/agents/builtin/kiro.test.ts create mode 100644 src/tui/components/AgentModelPicker.test.ts create mode 100644 src/tui/components/AgentModelPicker.tsx diff --git a/README.md b/README.md index 88960519..2fb3fa57 100644 --- a/README.md +++ b/README.md @@ -148,13 +148,14 @@ ralph-tui create-prd --output ./docs | `T` | Toggle subagent tree panel (Shift+T) | | `t` | Cycle subagent detail level | | `o` | Cycle right panel views | +| `a` | Open agent/model picker (local tab only) | | `,` | Open settings (local tab only) | | `C` | Open read-only config viewer (Shift+C, works on local and remote tabs) | | `q` | Quit | | `?` | Show help | | `1-9` | Switch to tab 1-9 (remote instances) | | `[` / `]` | Previous/Next tab | -| `a` | Add new remote instance | +| `A` | Add new remote instance | | `e` | Edit current remote (when viewing remote tab) | | `x` | Delete current remote (when viewing remote tab) | @@ -393,7 +394,7 @@ The first tab is always "Local" (your current machine). Remote tabs show the ali You can add, edit, and delete remote servers directly from the TUI without leaving the interface: -**Add Remote (`a` key):** +**Add Remote (`A` key):** Opens a form dialog to configure a new remote: - **Alias**: A short name for the remote (e.g., "prod", "dev-server") - **Host**: The server address (e.g., "192.168.1.100", "server.example.com") diff --git a/src/chat/engine.test.ts b/src/chat/engine.test.ts index 794ce0e1..705bb0f2 100644 --- a/src/chat/engine.test.ts +++ b/src/chat/engine.test.ts @@ -102,6 +102,7 @@ function createMockAgent(responseText = 'mock response'): { getSetupQuestions: () => [], validateSetup: async () => null, validateModel: () => null, + listModels: () => [], getSandboxRequirements: () => ({ authPaths: [], binaryPaths: [], diff --git a/src/commands/run.tsx b/src/commands/run.tsx index ed92a1e7..0c135623 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -8,7 +8,7 @@ import { useState, useEffect, useMemo } from 'react'; import { createCliRenderer } from '@opentui/core'; import { createRoot } from '@opentui/react'; -import { buildConfig, validateConfig, loadStoredConfig, saveProjectConfig } from '../config/index.js'; +import { buildConfig, validateConfig, loadStoredConfig, saveProjectConfig, getDefaultAgentConfig } from '../config/index.js'; import type { RuntimeOptions, StoredConfig, SandboxConfig } from '../config/types.js'; import { checkSession, @@ -257,13 +257,43 @@ export function applyConflictResolvedTaskTracking( /** * Propagate runtime-updateable settings from stored config to a running engine. - * Called after saving settings to ensure the engine picks up changes immediately. + * Called during settings save so invalid runtime changes fail before persistence. */ -export function propagateSettingsToEngine( +export async function propagateSettingsToEngine( engine: ExecutionEngine | null | undefined, newConfig: StoredConfig, -): void { + previousConfig?: StoredConfig, +): Promise { if (!engine) return; + + const getConfiguredAgentName = (config: StoredConfig | undefined): string | undefined => + config?.agent ?? + config?.defaultAgent ?? + config?.agents?.find((agent) => agent.default)?.name ?? + config?.agents?.[0]?.name; + + const normalizeModel = (model: string | undefined): string | undefined => { + const trimmed = model?.trim(); + return trimmed ? trimmed : undefined; + }; + + const previousAgentName = getConfiguredAgentName(previousConfig); + const nextAgentName = getConfiguredAgentName(newConfig); + const previousModel = normalizeModel(previousConfig?.model); + const nextModel = normalizeModel(newConfig.model); + const shouldSwitchAgent = + previousConfig === undefined + ? nextAgentName !== undefined || nextModel !== undefined + : previousAgentName !== nextAgentName || previousModel !== nextModel; + + if (shouldSwitchAgent) { + const agentConfig = getDefaultAgentConfig(newConfig, {}); + if (!agentConfig) { + throw new Error('No agent configured'); + } + await engine.switchToUserAgent(agentConfig, nextModel); + } + if (newConfig.autoCommit !== undefined) { engine.setAutoCommit(newConfig.autoCommit); } @@ -1719,9 +1749,9 @@ function RunAppWrapper({ // Handle settings save const handleSaveSettings = async (newConfig: StoredConfig): Promise => { + await propagateSettingsToEngine(engine, newConfig, storedConfig); await saveProjectConfig(newConfig, cwd); setStoredConfig(newConfig); - propagateSettingsToEngine(engine, newConfig); }; // Handle loading available epics (engine absent in parallel mode) diff --git a/src/config/index.ts b/src/config/index.ts index 91fc5b8c..31cfc26c 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -208,6 +208,7 @@ function mergeConfigs( merged.iterationDelay = project.iterationDelay; if (project.outputDir !== undefined) merged.outputDir = project.outputDir; if (project.agent !== undefined) merged.agent = project.agent; + if (project.model !== undefined) merged.model = project.model; if (project.agentCommand !== undefined) merged.agentCommand = project.agentCommand; if (project.command !== undefined) merged.command = project.command; @@ -696,7 +697,7 @@ export async function buildConfig( DEFAULT_CONFIG.progressFile, epicId: options.epicId, prdPath: options.prdPath, - model: options.model, + model: options.model ?? storedConfig.model, showTui: !options.headless, errorHandling, sandbox, diff --git a/src/config/schema.ts b/src/config/schema.ts index 00b0fb04..b5d0c4b6 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -161,6 +161,7 @@ export const StoredConfigSchema = z // Agent-specific options (shorthand for common settings) agent: z.string().optional(), + model: z.string().optional(), agentCommand: z.string().optional(), /** * Custom command/executable path for the agent. diff --git a/src/config/types.ts b/src/config/types.ts index 24a7de95..032a39b0 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -269,6 +269,9 @@ export interface StoredConfig { /** Shorthand: agent plugin name */ agent?: string; + /** Shorthand: model override for the selected agent */ + model?: string; + /** Legacy alias: agent command name */ agentCommand?: string; diff --git a/src/engine/index.ts b/src/engine/index.ts index 04c3fb35..6610fcb6 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -35,6 +35,7 @@ import { RateLimitDetector, type RateLimitDetectionResult } from './rate-limit-d import type { TrackerPlugin, TrackerTask } from '../plugins/trackers/types.js'; import type { AgentPlugin, + AgentPluginConfig, AgentExecutionHandle, AgentExecutionResult, } from '../plugins/agents/types.js'; @@ -1863,10 +1864,15 @@ export class ExecutionEngine { * Updates state, emits agent:switched event, and persists across iterations. * * @param newAgentPlugin - Plugin identifier of the agent to switch to - * @param reason - Why the switch is happening (primary recovery or fallback) + * @param reason - Why the switch is happening */ - private switchAgent(newAgentPlugin: string, reason: ActiveAgentReason): void { - const previousAgent = this.state.activeAgent?.plugin ?? this.config.agent.plugin; + private switchAgent( + newAgentPlugin: string, + reason: ActiveAgentReason, + previousAgentOverride?: string + ): void { + const previousAgent = + previousAgentOverride ?? this.state.activeAgent?.plugin ?? this.config.agent.plugin; const now = new Date().toISOString(); // Update active agent state @@ -1889,6 +1895,10 @@ export class ExecutionEngine { primaryAgent: this.state.rateLimitState.primaryAgent, // Clear limitedAt and fallbackAgent on recovery }; + } else if (reason === 'user-selected') { + this.state.rateLimitState = { + primaryAgent: newAgentPlugin, + }; } // Record the agent switch for iteration logging @@ -1898,14 +1908,16 @@ export class ExecutionEngine { to: newAgentPlugin, reason, }; - this.currentIterationAgentSwitches.push(switchEntry); + if (reason !== 'user-selected') { + this.currentIterationAgentSwitches.push(switchEntry); + } // Log the switch to console for visibility if (reason === 'fallback') { console.log( `[agent-switch] Switching to fallback: ${previousAgent} → ${newAgentPlugin} (rate limit)` ); - } else { + } else if (reason === 'primary') { // Calculate duration on fallback for recovery logging let durationOnFallback = ''; if (this.state.rateLimitState?.limitedAt) { @@ -1923,6 +1935,10 @@ export class ExecutionEngine { console.log( `[agent-switch] Recovering to primary: ${previousAgent} → ${newAgentPlugin}${durationOnFallback}` ); + } else { + console.log( + `[agent-switch] User selected agent: ${previousAgent} → ${newAgentPlugin}` + ); } // Emit agent switched event @@ -2083,6 +2099,60 @@ export class ExecutionEngine { this.switchAgent(fallbackAgentPlugin, 'fallback'); } + /** + * Switch to a user-selected agent for subsequent iterations. + * Validates availability and model compatibility before mutating engine state. + * + * @param agentConfig - Agent configuration to activate + * @param model - Optional model override; undefined clears the runtime model override + */ + async switchToUserAgent( + agentConfig: AgentPluginConfig, + model: string | undefined + ): Promise { + const normalizedModel = model?.trim() ? model.trim() : undefined; + + const agentRegistry = getAgentRegistry(); + const newInstance = await agentRegistry.getInstance(agentConfig); + + const detectResult = await newInstance.detect(); + if (!detectResult.available) { + throw new Error( + `Agent '${agentConfig.plugin}' not available: ${detectResult.error ?? 'detection failed'}` + ); + } + + if (normalizedModel !== undefined) { + const modelError = newInstance.validateModel(normalizedModel); + if (modelError) { + throw new Error(modelError); + } + } else { + const modelError = newInstance.validateModel(''); + if (modelError) { + throw new Error(modelError); + } + } + + const previousAgent = this.state.activeAgent?.plugin ?? this.config.agent.plugin; + + this.config.agent = { + ...agentConfig, + options: { ...agentConfig.options }, + }; + this.config.model = normalizedModel; + this.rateLimitConfig = { + ...DEFAULT_RATE_LIMIT_HANDLING, + ...this.config.agent.rateLimitHandling, + }; + this.agent = newInstance; + this.primaryAgentInstance = newInstance; + this.rateLimitedAgents.clear(); + this.state.currentModel = normalizedModel; + + this.switchAgent(agentConfig.plugin, 'user-selected', previousAgent); + } + /** * Get the next available fallback agent that hasn't been rate-limited. * Returns undefined if no fallback agents are configured or all are rate-limited. @@ -2206,6 +2276,10 @@ export class ExecutionEngine { return `${statusWord} on primary (${currentAgent}) after recovery`; } + if (lastSwitch && lastSwitch.reason === 'user-selected') { + return `${statusWord} on user-selected agent (${currentAgent})`; + } + // Generic summary for other cases return `${statusWord} with ${this.currentIterationAgentSwitches.length} agent switch(es)`; } diff --git a/src/engine/switch-to-user-agent.test.ts b/src/engine/switch-to-user-agent.test.ts new file mode 100644 index 00000000..5f8818ae --- /dev/null +++ b/src/engine/switch-to-user-agent.test.ts @@ -0,0 +1,165 @@ +/** + * ABOUTME: Tests for user-initiated agent switching in the execution engine. + * Verifies validation, state mutation, and agent switch events. + */ + +import { beforeAll, describe, expect, mock, test } from 'bun:test'; +import type { RalphConfig } from '../config/types.js'; +import { BaseAgentPlugin } from '../plugins/agents/base.js'; +import type { + AgentDetectResult, + AgentExecuteOptions, + AgentFileContext, + AgentPluginMeta, +} from '../plugins/agents/types.js'; + +let getAgentRegistry: typeof import('../plugins/agents/registry.js').getAgentRegistry; +let ExecutionEngine: typeof import('./index.js').ExecutionEngine; +let registryUsable = true; + +type TestAgentOptions = { + available?: boolean; + validateModel?: (model: string) => string | null; +}; + +class TestSwitchAgentPlugin extends BaseAgentPlugin { + readonly meta: AgentPluginMeta; + private readonly available: boolean; + private readonly validateModelFn: (model: string) => string | null; + + constructor(id: string, options: TestAgentOptions = {}) { + super(); + this.meta = { + id, + name: id, + description: 'Test switch agent', + version: '1.0.0', + defaultCommand: id, + supportsStreaming: false, + supportsInterrupt: true, + supportsFileContext: false, + supportsSubagentTracing: false, + }; + this.available = options.available ?? true; + this.validateModelFn = options.validateModel ?? (() => null); + } + + override async detect(): Promise { + return this.available + ? { available: true, version: '1.0.0' } + : { available: false, error: 'not installed' }; + } + + override validateModel(model: string): string | null { + return this.validateModelFn(model); + } + + protected buildArgs( + _prompt: string, + _files?: AgentFileContext[], + _options?: AgentExecuteOptions + ): string[] { + return []; + } +} + +function registerTestAgent(id: string, options: TestAgentOptions = {}): void { + getAgentRegistry().registerBuiltin(() => new TestSwitchAgentPlugin(id, options)); +} + +function createConfig(agentPlugin: string): RalphConfig { + return { + cwd: '/tmp/ralph-switch-test', + agent: { name: agentPlugin, plugin: agentPlugin, options: {} }, + tracker: { name: 'tracker', plugin: 'json', options: {} }, + maxIterations: 10, + iterationDelay: 0, + outputDir: '/tmp/ralph-switch-test/output', + progressFile: '/tmp/ralph-switch-test/progress.md', + showTui: false, + errorHandling: { + strategy: 'skip', + maxRetries: 3, + retryDelayMs: 0, + continueOnNonZeroExit: false, + }, + }; +} + +describe('ExecutionEngine.switchToUserAgent', () => { + beforeAll(async () => { + mock.restore(); + ({ getAgentRegistry } = await import('../plugins/agents/registry.js')); + registryUsable = typeof getAgentRegistry().createInstance === 'function'; + ({ ExecutionEngine } = await import('./index.js')); + }); + + test('validates model, mutates config, updates state, and emits user-selected switch', async () => { + if (!registryUsable) return; + registerTestAgent('switch-primary'); + registerTestAgent('switch-target', { + validateModel: (model) => (model === 'valid-model' ? null : 'invalid model'), + }); + const engine = new ExecutionEngine(createConfig('switch-primary')); + const events: string[] = []; + engine.on((event) => { + if (event.type === 'agent:switched') { + events.push(`${event.previousAgent}:${event.newAgent}:${event.reason}`); + } + }); + + await engine.switchToUserAgent( + { name: 'switch-target', plugin: 'switch-target', options: {} }, + 'valid-model' + ); + + const config = (engine as unknown as { config: RalphConfig }).config; + expect(config.agent.plugin).toBe('switch-target'); + expect(config.model).toBe('valid-model'); + expect(engine.getState().activeAgent).toMatchObject({ + plugin: 'switch-target', + reason: 'user-selected', + }); + expect(engine.getState().currentModel).toBe('valid-model'); + expect(events).toEqual(['switch-primary:switch-target:user-selected']); + }); + + test('throws on invalid model without mutating state', async () => { + if (!registryUsable) return; + registerTestAgent('switch-invalid-primary'); + registerTestAgent('switch-invalid-target', { + validateModel: () => 'bad model', + }); + const engine = new ExecutionEngine(createConfig('switch-invalid-primary')); + + await expect( + engine.switchToUserAgent( + { name: 'switch-invalid-target', plugin: 'switch-invalid-target', options: {} }, + 'bad-model' + ) + ).rejects.toThrow('bad model'); + + const config = (engine as unknown as { config: RalphConfig }).config; + expect(config.agent.plugin).toBe('switch-invalid-primary'); + expect(config.model).toBeUndefined(); + expect(engine.getState().activeAgent).toBeNull(); + }); + + test('throws on detect failure without mutating state', async () => { + if (!registryUsable) return; + registerTestAgent('switch-detect-primary'); + registerTestAgent('switch-detect-target', { available: false }); + const engine = new ExecutionEngine(createConfig('switch-detect-primary')); + + await expect( + engine.switchToUserAgent( + { name: 'switch-detect-target', plugin: 'switch-detect-target', options: {} }, + undefined + ) + ).rejects.toThrow("Agent 'switch-detect-target' not available"); + + const config = (engine as unknown as { config: RalphConfig }).config; + expect(config.agent.plugin).toBe('switch-detect-primary'); + expect(engine.getState().activeAgent).toBeNull(); + }); +}); diff --git a/src/engine/types.ts b/src/engine/types.ts index e8dc5426..5472f8bc 100644 --- a/src/engine/types.ts +++ b/src/engine/types.ts @@ -12,8 +12,9 @@ import type { TokenUsageSummary } from '../plugins/agents/usage.js'; * Reason why an agent is currently active. * - 'primary': The configured primary agent * - 'fallback': A fallback agent due to rate limiting of primary + * - 'user-selected': A user-selected agent for subsequent iterations */ -export type ActiveAgentReason = 'primary' | 'fallback'; +export type ActiveAgentReason = 'primary' | 'fallback' | 'user-selected'; /** * Tracks which agent is currently active and why. diff --git a/src/logs/persistence.ts b/src/logs/persistence.ts index d705ba1d..4132f0c0 100644 --- a/src/logs/persistence.ts +++ b/src/logs/persistence.ts @@ -370,7 +370,11 @@ function formatMetadataHeader(metadata: IterationLogMetadata): string { lines.push('## Agent Switches'); lines.push(''); for (const sw of metadata.agentSwitches) { - const switchType = sw.reason === 'fallback' ? 'Switched to fallback' : 'Recovered to primary'; + const switchType = sw.reason === 'fallback' + ? 'Switched to fallback' + : sw.reason === 'user-selected' + ? 'User-selected agent' + : 'Recovered to primary'; lines.push(`- **${switchType}**: ${sw.from} → ${sw.to} at ${sw.at}`); } } diff --git a/src/logs/types.ts b/src/logs/types.ts index a046167b..c56ade34 100644 --- a/src/logs/types.ts +++ b/src/logs/types.ts @@ -28,7 +28,7 @@ export interface IterationSummary { /** * Entry recording an agent switch during an iteration. - * Tracks when and why the engine switched between primary and fallback agents. + * Tracks when and why the engine switched between active agents. */ export interface AgentSwitchEntry { /** ISO 8601 timestamp when the switch occurred */ diff --git a/src/plugins/agents/base.ts b/src/plugins/agents/base.ts index 836e4082..65b6c7e6 100644 --- a/src/plugins/agents/base.ts +++ b/src/plugins/agents/base.ts @@ -1066,6 +1066,14 @@ export abstract class BaseAgentPlugin implements AgentPlugin { return null; } + /** + * List known model names for this agent. + * Default implementation returns an empty list for open-ended model names. + */ + listModels(): string[] { + return []; + } + /** * Run a preflight check to verify the agent is fully operational. * Default implementation runs a minimal test prompt and checks for any response. diff --git a/src/plugins/agents/builtin/claude.test.ts b/src/plugins/agents/builtin/claude.test.ts new file mode 100644 index 00000000..316bb103 --- /dev/null +++ b/src/plugins/agents/builtin/claude.test.ts @@ -0,0 +1,15 @@ +/** + * ABOUTME: Tests for the Claude Code agent plugin. + * Verifies model enumeration used by the agent/model picker. + */ + +import { describe, expect, test } from 'bun:test'; +import { ClaudeAgentPlugin } from './claude.js'; + +describe('ClaudeAgentPlugin', () => { + test('lists known Claude model aliases', () => { + const plugin = new ClaudeAgentPlugin(); + + expect(plugin.listModels()).toEqual(['sonnet', 'opus', 'haiku']); + }); +}); diff --git a/src/plugins/agents/builtin/claude.ts b/src/plugins/agents/builtin/claude.ts index 531b06de..d146e2f5 100644 --- a/src/plugins/agents/builtin/claude.ts +++ b/src/plugins/agents/builtin/claude.ts @@ -535,6 +535,13 @@ export class ClaudeAgentPlugin extends BaseAgentPlugin { */ static readonly VALID_MODELS = ['sonnet', 'opus', 'haiku'] as const; + /** + * List known Claude model aliases supported by the plugin. + */ + override listModels(): string[] { + return [...ClaudeAgentPlugin.VALID_MODELS]; + } + /** * Validate a model name for the Claude agent. * @param model The model name to validate diff --git a/src/plugins/agents/builtin/gemini.test.ts b/src/plugins/agents/builtin/gemini.test.ts index 3552a5df..e78cae39 100644 --- a/src/plugins/agents/builtin/gemini.test.ts +++ b/src/plugins/agents/builtin/gemini.test.ts @@ -151,6 +151,15 @@ describe('GeminiAgentPlugin', () => { expect(result).toContain('gemini-'); }); }); + + describe('listModels', () => { + test('lists known Gemini models', () => { + expect(plugin.listModels()).toEqual([ + 'gemini-2.5-pro', + 'gemini-2.5-flash', + ]); + }); + }); }); describe('GeminiAgentPlugin buildArgs', () => { diff --git a/src/plugins/agents/builtin/gemini.ts b/src/plugins/agents/builtin/gemini.ts index 55a7e158..dda2fffb 100644 --- a/src/plugins/agents/builtin/gemini.ts +++ b/src/plugins/agents/builtin/gemini.ts @@ -21,6 +21,8 @@ import type { // Re-export for backward compatibility with tests export { extractErrorMessage } from '../utils.js'; +const GEMINI_MODELS = ['gemini-2.5-pro', 'gemini-2.5-flash'] as const; + /** * Parse Gemini JSON line into standardized display events. * Returns AgentDisplayEvent[] - the shared processAgentEvents decides what to show. @@ -238,8 +240,8 @@ export class GeminiAgentPlugin extends BaseAgentPlugin { type: 'select', choices: [ { value: '', label: 'Default', description: 'Use configured default model' }, - { value: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', description: 'Most capable' }, - { value: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', description: 'Fast and efficient' }, + { value: GEMINI_MODELS[0], label: 'Gemini 2.5 Pro', description: 'Most capable' }, + { value: GEMINI_MODELS[1], label: 'Gemini 2.5 Flash', description: 'Fast and efficient' }, ], default: '', required: false, @@ -433,6 +435,10 @@ export class GeminiAgentPlugin extends BaseAgentPlugin { } return null; } + + override listModels(): string[] { + return [...GEMINI_MODELS]; + } } const createGeminiAgent: AgentPluginFactory = () => new GeminiAgentPlugin(); diff --git a/src/plugins/agents/builtin/kiro.test.ts b/src/plugins/agents/builtin/kiro.test.ts new file mode 100644 index 00000000..28e136ca --- /dev/null +++ b/src/plugins/agents/builtin/kiro.test.ts @@ -0,0 +1,20 @@ +/** + * ABOUTME: Tests for the Kiro CLI agent plugin. + * Verifies model enumeration used by the agent/model picker. + */ + +import { describe, expect, test } from 'bun:test'; +import { KiroAgentPlugin } from './kiro.js'; + +describe('KiroAgentPlugin', () => { + test('lists known non-empty Kiro models', () => { + const plugin = new KiroAgentPlugin(); + + expect(plugin.listModels()).toEqual([ + 'claude-sonnet4', + 'claude-sonnet4.5', + 'claude-haiku4.5', + 'claude-opus4.5', + ]); + }); +}); diff --git a/src/plugins/agents/builtin/kiro.ts b/src/plugins/agents/builtin/kiro.ts index 3a360898..0259271a 100644 --- a/src/plugins/agents/builtin/kiro.ts +++ b/src/plugins/agents/builtin/kiro.ts @@ -247,6 +247,10 @@ export class KiroAgentPlugin extends BaseAgentPlugin { } return null; } + + override listModels(): string[] { + return VALID_KIRO_MODELS.filter((model) => model.length > 0); + } } const createKiroAgent: AgentPluginFactory = () => new KiroAgentPlugin(); diff --git a/src/plugins/agents/types.ts b/src/plugins/agents/types.ts index 07c1d7f5..618fa930 100644 --- a/src/plugins/agents/types.ts +++ b/src/plugins/agents/types.ts @@ -444,6 +444,13 @@ export interface AgentPlugin { */ validateModel(model: string): string | null; + /** + * List known model names for this agent. + * Agents with open-ended model identifiers should return an empty array. + * @returns Array of known model identifiers + */ + listModels(): string[]; + /** * Run a preflight check to verify the agent is fully operational. * This goes beyond detect() by actually running a minimal test prompt diff --git a/src/tui/components/AgentModelPicker.test.ts b/src/tui/components/AgentModelPicker.test.ts new file mode 100644 index 00000000..05817354 --- /dev/null +++ b/src/tui/components/AgentModelPicker.test.ts @@ -0,0 +1,74 @@ +/** + * ABOUTME: Tests for AgentModelPicker helper behavior. + * Covers agent config resolution, model listing, validation, and model normalization. + */ + +import { beforeAll, describe, expect, mock, test } from 'bun:test'; + +let listModelsForAgent: typeof import('./AgentModelPicker.js').listModelsForAgent; +let normalizeModelValue: typeof import('./AgentModelPicker.js').normalizeModelValue; +let resolveAgentConfigForSelection: typeof import('./AgentModelPicker.js').resolveAgentConfigForSelection; +let validateModelForAgent: typeof import('./AgentModelPicker.js').validateModelForAgent; +let registryUsable = true; + +beforeAll(async () => { + mock.restore(); + const { getAgentRegistry } = await import('../../plugins/agents/registry.js'); + registryUsable = typeof getAgentRegistry().createInstance === 'function'; + const { registerBuiltinAgents } = await import('../../plugins/agents/builtin/index.js'); + const helpers = await import('./AgentModelPicker.js'); + listModelsForAgent = helpers.listModelsForAgent; + normalizeModelValue = helpers.normalizeModelValue; + resolveAgentConfigForSelection = helpers.resolveAgentConfigForSelection; + validateModelForAgent = helpers.validateModelForAgent; + if (registryUsable) { + registerBuiltinAgents(); + } +}); + +describe('AgentModelPicker helpers', () => { + test('resolves configured agent aliases to their plugin config', () => { + const config = resolveAgentConfigForSelection('work-claude', [ + { + name: 'work-claude', + plugin: 'claude', + options: { printMode: 'stream' }, + }, + ]); + + expect(config).toEqual({ + name: 'work-claude', + plugin: 'claude', + options: { printMode: 'stream' }, + }); + }); + + test('falls back to a minimal plugin config for bare plugin names', () => { + expect(resolveAgentConfigForSelection('codex')).toEqual({ + name: 'codex', + plugin: 'codex', + options: {}, + }); + }); + + test('returns known models for agents that enumerate them', () => { + if (!registryUsable) return; + expect(listModelsForAgent('claude')).toEqual(['sonnet', 'opus', 'haiku']); + }); + + test('returns an empty model list for open-ended agents', () => { + if (!registryUsable) return; + expect(listModelsForAgent('codex')).toEqual([]); + }); + + test('validates models with the selected agent plugin', () => { + if (!registryUsable) return; + expect(validateModelForAgent('claude', [], 'sonnet')).toBeNull(); + expect(validateModelForAgent('claude', [], 'gpt-4o')).toContain('Invalid model'); + }); + + test('normalizes blank model input to undefined', () => { + expect(normalizeModelValue(' ')).toBeUndefined(); + expect(normalizeModelValue(' opus ')).toBe('opus'); + }); +}); diff --git a/src/tui/components/AgentModelPicker.tsx b/src/tui/components/AgentModelPicker.tsx new file mode 100644 index 00000000..752f28c4 --- /dev/null +++ b/src/tui/components/AgentModelPicker.tsx @@ -0,0 +1,443 @@ +/** + * ABOUTME: Overlay component for switching the active agent and model. + * Provides a two-column picker with known model lists and free-text fallback. + */ + +import type { ReactNode } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useKeyboard } from '@opentui/react'; +import { colors } from '../theme.js'; +import { getAgentRegistry } from '../../plugins/agents/registry.js'; +import type { AgentPluginConfig } from '../../plugins/agents/types.js'; + +type PickerFocus = 'agent' | 'model'; + +/** + * User selection returned by the picker. + */ +export interface AgentModelSelection { + agentName: string; + model: string | undefined; + saveAsDefault: boolean; +} + +/** + * Props for the AgentModelPicker component. + */ +export interface AgentModelPickerProps { + visible: boolean; + agents: string[]; + agentConfigs?: AgentPluginConfig[]; + currentAgent?: string; + currentModel?: string; + onConfirm: (selection: AgentModelSelection) => Promise; + onClose: () => void; +} + +const MAX_VISIBLE_ROWS = 10; + +/** + * Normalize free-text model input for runtime use. + */ +export function normalizeModelValue(model: string | undefined): string | undefined { + const trimmed = model?.trim(); + return trimmed ? trimmed : undefined; +} + +/** + * Resolve a picker agent name to an agent config. + */ +export function resolveAgentConfigForSelection( + agentName: string, + agentConfigs: AgentPluginConfig[] = [] +): AgentPluginConfig { + const configured = agentConfigs.find( + (agent) => agent.name === agentName || agent.plugin === agentName + ); + if (configured) { + return { + ...configured, + options: { ...configured.options }, + }; + } + + return { + name: agentName, + plugin: agentName, + options: {}, + }; +} + +/** + * Return known model names for an agent, or an empty array for free-text agents. + */ +export function listModelsForAgent( + agentName: string, + agentConfigs: AgentPluginConfig[] = [] +): string[] { + const agentConfig = resolveAgentConfigForSelection(agentName, agentConfigs); + const plugin = getAgentRegistry().createInstance(agentConfig.plugin); + if (!plugin) { + return []; + } + + try { + return plugin.listModels(); + } finally { + void plugin.dispose(); + } +} + +/** + * Validate a model selection with the selected agent plugin. + */ +export function validateModelForAgent( + agentName: string, + agentConfigs: AgentPluginConfig[] = [], + model: string | undefined +): string | null { + const agentConfig = resolveAgentConfigForSelection(agentName, agentConfigs); + const plugin = getAgentRegistry().createInstance(agentConfig.plugin); + if (!plugin) { + return `Unknown agent plugin: ${agentConfig.plugin}`; + } + + try { + return plugin.validateModel(normalizeModelValue(model) ?? ''); + } finally { + void plugin.dispose(); + } +} + +function findInitialAgentIndex(agents: string[], currentAgent: string | undefined): number { + if (agents.length === 0) return 0; + const index = currentAgent ? agents.indexOf(currentAgent) : -1; + return index >= 0 ? index : 0; +} + +function getWindowStart(selectedIndex: number, itemCount: number): number { + if (itemCount <= MAX_VISIBLE_ROWS) return 0; + const halfWindow = Math.floor(MAX_VISIBLE_ROWS / 2); + return Math.min( + Math.max(0, selectedIndex - halfWindow), + itemCount - MAX_VISIBLE_ROWS + ); +} + +function renderListItem( + label: string, + selected: boolean, + focused: boolean, + key: string +): ReactNode { + return ( + + + {selected ? '> ' : ' '} + + + {label} + + + ); +} + +/** + * Agent and model picker overlay. + */ +export function AgentModelPicker({ + visible, + agents, + agentConfigs = [], + currentAgent, + currentModel, + onConfirm, + onClose, +}: AgentModelPickerProps): ReactNode { + const [focusedColumn, setFocusedColumn] = useState('agent'); + const [selectedAgentIndex, setSelectedAgentIndex] = useState(() => + findInitialAgentIndex(agents, currentAgent) + ); + const [selectedModelIndex, setSelectedModelIndex] = useState(0); + const [modelInput, setModelInput] = useState(currentModel ?? ''); + const [saveAsDefault, setSaveAsDefault] = useState(false); + const [applyError, setApplyError] = useState(null); + const [applying, setApplying] = useState(false); + + const selectedAgent = agents[selectedAgentIndex]; + + const modelOptions = useMemo(() => { + if (!selectedAgent) return []; + + const knownModels = listModelsForAgent(selectedAgent, agentConfigs); + const normalizedCurrent = normalizeModelValue(currentModel); + if ( + normalizedCurrent && + knownModels.length > 0 && + !knownModels.includes(normalizedCurrent) + ) { + return [normalizedCurrent, ...knownModels]; + } + return knownModels; + }, [selectedAgent, agentConfigs, currentModel]); + + const selectedModel = modelOptions[selectedModelIndex] ?? ''; + const candidateModel = modelOptions.length > 0 ? selectedModel : modelInput; + const validationError = useMemo(() => { + if (!selectedAgent) return 'No agent selected'; + return validateModelForAgent(selectedAgent, agentConfigs, candidateModel); + }, [selectedAgent, agentConfigs, candidateModel]); + + useEffect(() => { + if (!visible) return; + + const initialAgentIndex = findInitialAgentIndex(agents, currentAgent); + setSelectedAgentIndex(initialAgentIndex); + setFocusedColumn('agent'); + setModelInput(currentModel ?? ''); + setSaveAsDefault(false); + setApplyError(null); + setApplying(false); + }, [visible, agents, currentAgent, currentModel]); + + useEffect(() => { + const normalizedCurrent = normalizeModelValue(currentModel); + const currentIndex = normalizedCurrent + ? modelOptions.indexOf(normalizedCurrent) + : -1; + setSelectedModelIndex(currentIndex >= 0 ? currentIndex : 0); + setModelInput(currentModel ?? ''); + }, [selectedAgent, modelOptions, currentModel]); + + const handleApply = useCallback(async () => { + if (!selectedAgent || applying) return; + if (validationError) { + setApplyError(validationError); + return; + } + + setApplying(true); + setApplyError(null); + try { + await onConfirm({ + agentName: selectedAgent, + model: normalizeModelValue(candidateModel), + saveAsDefault, + }); + onClose(); + } catch (error) { + setApplyError(error instanceof Error ? error.message : 'Failed to switch agent'); + setApplying(false); + } + }, [ + applying, + candidateModel, + onClose, + onConfirm, + saveAsDefault, + selectedAgent, + validationError, + ]); + + const handleKeyboard = useCallback( + (key: { name: string; sequence?: string; shift?: boolean }) => { + if (!visible) return; + setApplyError(null); + + switch (key.name) { + case 'escape': + onClose(); + return; + + case 'tab': + setFocusedColumn((prev) => (prev === 'agent' ? 'model' : 'agent')); + return; + + case 'space': + setSaveAsDefault((prev) => !prev); + return; + + case 'return': + case 'enter': + void handleApply(); + return; + + case 'up': + case 'k': + if (focusedColumn === 'agent') { + setSelectedAgentIndex((prev) => Math.max(0, prev - 1)); + } else if (modelOptions.length > 0) { + setSelectedModelIndex((prev) => Math.max(0, prev - 1)); + } + return; + + case 'down': + case 'j': + if (focusedColumn === 'agent' && agents.length > 0) { + setSelectedAgentIndex((prev) => Math.min(agents.length - 1, prev + 1)); + } else if (modelOptions.length > 0) { + setSelectedModelIndex((prev) => + Math.min(modelOptions.length - 1, prev + 1) + ); + } + return; + + case 'backspace': + if (focusedColumn === 'model' && modelOptions.length === 0) { + setModelInput((prev) => prev.slice(0, -1)); + } + return; + + default: + if ( + focusedColumn === 'model' && + modelOptions.length === 0 && + key.sequence && + key.sequence.length === 1 + ) { + setModelInput((prev) => prev + key.sequence); + } + } + }, + [ + agents.length, + focusedColumn, + handleApply, + modelOptions.length, + onClose, + visible, + ] + ); + + useKeyboard(handleKeyboard); + + if (!visible) return null; + + const agentWindowStart = getWindowStart(selectedAgentIndex, agents.length); + const visibleAgents = agents.slice(agentWindowStart, agentWindowStart + MAX_VISIBLE_ROWS); + const modelWindowStart = getWindowStart(selectedModelIndex, modelOptions.length); + const visibleModels = modelOptions.slice(modelWindowStart, modelWindowStart + MAX_VISIBLE_ROWS); + const displayError = applyError ?? validationError; + + return ( + + + + Agent & Model + + + + + + + Agents + + + {visibleAgents.length > 0 ? ( + visibleAgents.map((agent, index) => + renderListItem( + agent, + agentWindowStart + index === selectedAgentIndex, + focusedColumn === 'agent', + agent + ) + ) + ) : ( + No agents configured + )} + + + + + + Models + + + + {modelOptions.length > 0 ? ( + visibleModels.map((model, index) => + renderListItem( + model, + modelWindowStart + index === selectedModelIndex, + focusedColumn === 'model', + model + ) + ) + ) : ( + + + {modelInput || '(default model)'} + {focusedColumn === 'model' ? '|' : ''} + + + )} + + {displayError && ( + + {displayError} + + )} + + + + + + [{saveAsDefault ? 'x' : ' '}] Save as default + + + + + + {applying ? 'Applying...' : 'Tab switch | Space save default | Enter apply | Esc cancel'} + + + + + ); +} diff --git a/src/tui/components/RunApp.tsx b/src/tui/components/RunApp.tsx index 46051253..32e5bc21 100644 --- a/src/tui/components/RunApp.tsx +++ b/src/tui/components/RunApp.tsx @@ -24,6 +24,11 @@ import { ProgressDashboard } from './ProgressDashboard.js'; import { ConfirmationDialog } from './ConfirmationDialog.js'; import { HelpOverlay } from './HelpOverlay.js'; import { SettingsView } from './SettingsView.js'; +import { + AgentModelPicker, + resolveAgentConfigForSelection, + type AgentModelSelection, +} from './AgentModelPicker.js'; import { EpicLoaderOverlay } from './EpicLoaderOverlay.js'; import type { EpicLoaderMode } from './EpicLoaderOverlay.js'; import { SubagentTreePanel } from './SubagentTreePanel.js'; @@ -632,6 +637,8 @@ export function RunApp({ const [showHelp, setShowHelp] = useState(false); // Settings view state const [showSettings, setShowSettings] = useState(false); + // Agent/model picker state + const [showAgentModelPicker, setShowAgentModelPicker] = useState(false); // Remote config view state const [showRemoteConfig, setShowRemoteConfig] = useState(false); const [remoteConfigData, setRemoteConfigData] = useState(null); @@ -1100,6 +1107,43 @@ export function RunApp({ const displayTrackerName = isViewingRemote ? (remoteTrackerName ?? trackerName) : trackerName; const displayModel = isViewingRemote ? (remoteModel ?? currentModel) : localModel; + const handleAgentModelConfirm = useCallback( + async (selection: AgentModelSelection): Promise => { + if (!engine) { + throw new Error('Agent switching is not available in this mode'); + } + + if (selection.saveAsDefault) { + if (!storedConfig || !onSaveSettings) { + throw new Error('Settings save is not available'); + } + + const nextConfig: StoredConfig = { + ...storedConfig, + agent: selection.agentName, + defaultAgent: selection.agentName, + }; + if (selection.model) { + nextConfig.model = selection.model; + } else { + delete nextConfig.model; + } + + await onSaveSettings(nextConfig); + setDetectedModel(selection.model ?? ''); + return; + } + + const agentConfig = resolveAgentConfigForSelection( + selection.agentName, + storedConfig?.agents ?? [] + ); + await engine.switchToUserAgent(agentConfig, selection.model); + setDetectedModel(selection.model ?? ''); + }, + [engine, onSaveSettings, storedConfig] + ); + // Resolve model context windows for live local/remote usage indicators. const modelContextCacheRef = useRef>(new Map()); const resolveModelContextWindow = useCallback( @@ -2090,6 +2134,11 @@ export function RunApp({ return; } + // When agent/model picker is showing, let it handle its own keyboard events + if (showAgentModelPicker) { + return; + } + // When remote config view is showing, let it handle its own keyboard events // Closing is handled by RemoteConfigView internally via onClose callback if (showRemoteConfig) { @@ -2619,12 +2668,24 @@ export function RunApp({ } break; - // Remote management: 'a' to add new remote + // Agent/model picker: 'a' opens the local picker. + // Shift+A keeps the remote add flow available without stealing lowercase 'a'. case 'a': - // Open add remote overlay - setRemoteManagementMode('add'); - setEditingRemote(undefined); - setShowRemoteManagement(true); + if (key.sequence === 'A') { + setRemoteManagementMode('add'); + setEditingRemote(undefined); + setShowRemoteManagement(true); + break; + } + if (isViewingRemote) { + setInfoFeedback('Agent/model picker is available on the local tab'); + break; + } + if (!engine) { + setInfoFeedback('Agent/model picker is not available in this mode'); + break; + } + setShowAgentModelPicker(true); break; // Remote management: 'e' to edit current remote (only when viewing a remote tab) @@ -2687,7 +2748,7 @@ export function RunApp({ break; } }, - [displayedTasks, selectedIndex, status, engine, onQuit, viewMode, iterations, iterationSelectedIndex, iterationHistoryLength, onIterationDrillDown, showInterruptDialog, onInterruptConfirm, onInterruptCancel, showHelp, showSettings, showQuitDialog, showKillDialog, showParallelSummaryOverlay, showEpicLoader, showRemoteManagement, onStart, storedConfig, onSaveSettings, onLoadEpics, subagentDetailLevel, onSubagentPanelVisibilityChange, currentIteration, maxIterations, renderer, detailsViewMode, subagentPanelVisible, focusedPane, navigateSubagentTree, instanceTabs, selectedTabIndex, onSelectTab, isViewingRemote, displayStatus, instanceManager, isParallelMode, parallelWorkers, parallelConflicts, showConflictPanel, onParallelKill, onParallelPause, onParallelResume, onParallelStart, parallelDerivedStatus, onRefreshTasks] + [displayedTasks, selectedIndex, status, engine, onQuit, viewMode, iterations, iterationSelectedIndex, iterationHistoryLength, onIterationDrillDown, showInterruptDialog, onInterruptConfirm, onInterruptCancel, showHelp, showSettings, showAgentModelPicker, showQuitDialog, showKillDialog, showParallelSummaryOverlay, showEpicLoader, showRemoteManagement, onStart, storedConfig, onSaveSettings, onLoadEpics, subagentDetailLevel, onSubagentPanelVisibilityChange, currentIteration, maxIterations, renderer, detailsViewMode, subagentPanelVisible, focusedPane, navigateSubagentTree, instanceTabs, selectedTabIndex, onSelectTab, isViewingRemote, displayStatus, instanceManager, isParallelMode, parallelWorkers, parallelConflicts, showConflictPanel, onParallelKill, onParallelPause, onParallelResume, onParallelStart, parallelDerivedStatus, onRefreshTasks] ); useKeyboard(handleKeyboard); @@ -3778,6 +3839,16 @@ export function RunApp({ /> )} + setShowAgentModelPicker(false)} + /> + {/* Remote Config View */} config.model, + setValue: (config, value) => ({ + ...config, + model: value as string, + }), + requiresRestart: false, }, { key: 'maxIterations', diff --git a/src/tui/theme.ts b/src/tui/theme.ts index a79bfd78..aafe714b 100644 --- a/src/tui/theme.ts +++ b/src/tui/theme.ts @@ -159,6 +159,7 @@ export const keyboardShortcuts = [ { key: '-', description: '-10 iters' }, { key: 'r', description: 'Refresh' }, { key: 'l', description: 'Load Epic' }, + { key: 'a', description: 'Agent/Model' }, { key: ',', description: 'Settings' }, { key: 'd', description: 'Dashboard' }, { key: 'o', description: 'Cycle Views' }, @@ -182,6 +183,7 @@ export const fullKeyboardShortcuts = [ { key: ',', description: 'Open settings', category: 'General' }, { key: 's', description: 'Start execution (when ready)', category: 'Execution' }, { key: 'p', description: 'Pause / Resume execution', category: 'Execution' }, + { key: 'a', description: 'Switch agent / model', category: 'Execution' }, { key: '+', description: 'Add 10 iterations', category: 'Execution' }, { key: '-', description: 'Remove 10 iterations', category: 'Execution' }, { key: 'r', description: 'Refresh task list from tracker', category: 'Execution' }, @@ -201,6 +203,7 @@ export const fullKeyboardShortcuts = [ { key: ']', description: 'Next tab', category: 'Instances' }, { key: 'Ctrl+Tab', description: 'Next tab (alternate)', category: 'Instances' }, { key: 'Ctrl+Shift+Tab', description: 'Previous tab (alternate)', category: 'Instances' }, + { key: 'A', description: 'Add remote instance', category: 'Instances' }, { key: 'Ctrl+C', description: 'Interrupt (with confirmation)', category: 'System' }, { key: 'Ctrl+C ×2', description: 'Force quit immediately', category: 'System' }, { key: 'w', description: 'Toggle parallel workers view', category: 'Parallel' }, diff --git a/website/content/docs/cli/run.mdx b/website/content/docs/cli/run.mdx index e28681bd..e87bddc3 100644 --- a/website/content/docs/cli/run.mdx +++ b/website/content/docs/cli/run.mdx @@ -93,6 +93,14 @@ Models use `provider/model` format. Valid providers: Model names within each provider are validated by the provider's API. If you specify an invalid model name, you'll see an error from the underlying agent CLI. +## Switching Agent or Model During a Run + +Press `a` in the local TUI to open the Agent & Model picker. Choose an agent in the left column and a model in the right column. Agents with known model lists show selectable models; agents that accept open-ended model names use a free-text model field. + +Use `Tab` to switch columns, `Enter` to apply, and `Esc` to cancel. The selected agent and model are used on the next iteration. + +Enable **Save as default** to write the selection to `.ralph-tui/config.toml`. Leave it unchecked for a session-only switch. + ## Examples ### Basic Usage with JSON Tracker diff --git a/website/content/docs/configuration/config-file.mdx b/website/content/docs/configuration/config-file.mdx index 685a3769..5b400e71 100644 --- a/website/content/docs/configuration/config-file.mdx +++ b/website/content/docs/configuration/config-file.mdx @@ -35,6 +35,9 @@ Here's a fully annotated configuration file showing all available options: # Default agent plugin name agent = "claude" +# Default model for the selected agent (optional) +model = "sonnet" + # Custom command (optional) - use wrapper tools like Claude Code Router # command = "ccr code" @@ -73,8 +76,8 @@ subagentTracingDetail = "minimal" # ───────────────────────────────────────────── [agentOptions] -# These options are passed to the selected agent plugin -model = "claude-sonnet-4-20250514" +# These options are passed to the selected agent plugin. +# Prefer top-level `model` for the default runtime model. # ───────────────────────────────────────────── # Tracker Options (shorthand) diff --git a/website/content/docs/configuration/options.mdx b/website/content/docs/configuration/options.mdx index 6bca4e2a..51d73d53 100644 --- a/website/content/docs/configuration/options.mdx +++ b/website/content/docs/configuration/options.mdx @@ -18,6 +18,7 @@ These control basic execution behavior. | Option | Type | Default | Description | |--------|------|---------|-------------| | `agent` | string | - | Agent plugin to use (e.g., "claude", "opencode") | +| `model` | string | - | Default model override for the selected agent. The TUI Agent & Model picker saves here when **Save as default** is enabled. | | `command` | string | - | Custom command/executable for the agent (e.g., "ccr code") | | `tracker` | string | - | Tracker plugin to use (e.g., "beads-bv", "json") | | `maxIterations` | number | `10` | Maximum iterations per session (0 = unlimited, max 1000) | @@ -49,14 +50,16 @@ The simplest way to configure an agent: ```toml agent = "claude" +model = "sonnet" [agentOptions] -model = "claude-sonnet-4-20250514" +# Plugin-specific options go here. ``` | Option | Type | Default | Description | |--------|------|---------|-------------| | `agent` | string | - | Agent plugin name | +| `model` | string | - | Model passed to the selected agent. If omitted, plugin-specific defaults apply. | | `command` | string | - | Custom command/executable path (see below) | | `agentOptions` | object | `{}` | Plugin-specific options | diff --git a/website/content/docs/getting-started/quick-start.mdx b/website/content/docs/getting-started/quick-start.mdx index b14827b9..fb3121aa 100644 --- a/website/content/docs/getting-started/quick-start.mdx +++ b/website/content/docs/getting-started/quick-start.mdx @@ -146,6 +146,7 @@ While Ralph is running, use these keyboard shortcuts: | `o` | Cycle right panel views (details → output → prompt) | | `O` | Jump directly to prompt preview | | `d` | Toggle progress dashboard | +| `a` | Open agent/model picker | | `h` | Toggle show/hide closed tasks | | `r` | Refresh task list from tracker | | `T` | Toggle subagent tree panel | @@ -185,7 +186,7 @@ The terminal interface shows: - **Footer**: Available keyboard shortcuts -Use `O` to preview the exact prompt being sent to your AI agent. Press `T` to see the subagent tree - this shows what autonomous subtasks the AI is spawning to complete your work. +Use `O` to preview the exact prompt being sent to your AI agent. Press `a` to switch the agent or model for the next iteration. Press `T` to see the subagent tree - this shows what autonomous subtasks the AI is spawning to complete your work. ## Next Steps From e8fe0cd44cd786520316efc177677da84516eb23 Mon Sep 17 00:00:00 2001 From: Subsy Date: Wed, 13 May 2026 08:55:58 +0100 Subject: [PATCH 2/3] Track user-selected agent switches in currentIterationAgentSwitches - Remove guard that excluded 'user-selected' reason from switch history - Add inline comments clarifying agent resolution priority and model normalization logic --- src/commands/run.tsx | 4 ++++ src/engine/index.ts | 4 +--- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/src/commands/run.tsx b/src/commands/run.tsx index 0c135623..c5ee2ebc 100644 --- a/src/commands/run.tsx +++ b/src/commands/run.tsx @@ -266,12 +266,14 @@ export async function propagateSettingsToEngine( ): Promise { if (!engine) return; + // Resolve agent priority: agent -> defaultAgent -> default agents[] entry -> first agents[] entry. const getConfiguredAgentName = (config: StoredConfig | undefined): string | undefined => config?.agent ?? config?.defaultAgent ?? config?.agents?.find((agent) => agent.default)?.name ?? config?.agents?.[0]?.name; + // Trim model values and treat empty strings as undefined. const normalizeModel = (model: string | undefined): string | undefined => { const trimmed = model?.trim(); return trimmed ? trimmed : undefined; @@ -281,6 +283,8 @@ export async function propagateSettingsToEngine( const nextAgentName = getConfiguredAgentName(newConfig); const previousModel = normalizeModel(previousConfig?.model); const nextModel = normalizeModel(newConfig.model); + // Switch on initial config with agent/model, or when resolved agent/model changed. + // When true, getDefaultAgentConfig feeds engine.switchToUserAgent(agentConfig, nextModel). const shouldSwitchAgent = previousConfig === undefined ? nextAgentName !== undefined || nextModel !== undefined diff --git a/src/engine/index.ts b/src/engine/index.ts index 6610fcb6..84895d24 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -1908,9 +1908,7 @@ export class ExecutionEngine { to: newAgentPlugin, reason, }; - if (reason !== 'user-selected') { - this.currentIterationAgentSwitches.push(switchEntry); - } + this.currentIterationAgentSwitches.push(switchEntry); // Log the switch to console for visibility if (reason === 'fallback') { From 7d123a1a0f3efa5b094b53d223f037043b760ac9 Mon Sep 17 00:00:00 2001 From: Subsy Date: Wed, 13 May 2026 09:49:27 +0100 Subject: [PATCH 3/3] Defer user agent switches until active execution completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Queue switchToUserAgent calls in pendingUserAgentSwap when an execution is running - Apply pending swap after execution finishes via applyPendingUserAgentSwap - Carry user-selected switch entries into the next iteration's tracking buffer - Drop empty-model validation when clearing model override (undefined → no call) --- src/engine/index.ts | 87 +++++++++++++++++++------ src/engine/switch-to-user-agent.test.ts | 74 +++++++++++++++++++++ 2 files changed, 142 insertions(+), 19 deletions(-) diff --git a/src/engine/index.ts b/src/engine/index.ts index 84895d24..12c8ccdd 100644 --- a/src/engine/index.ts +++ b/src/engine/index.ts @@ -79,6 +79,14 @@ const PRIMARY_RECOVERY_TEST_TIMEOUT_MS = 5000; */ const PRIMARY_RECOVERY_TEST_PROMPT = 'Reply with just the word "ok".'; +interface PendingUserAgentSwap { + agentConfig: AgentPluginConfig; + agent: AgentPlugin; + model: string | undefined; + rateLimitConfig: Required; + previousAgent: string; +} + /** * Maximum characters kept for live stdout/stderr buffers in engine state. * These buffers are for UI/remote progress display and should stay bounded. @@ -247,6 +255,10 @@ export class ExecutionEngine { private primaryAgentInstance: AgentPlugin | null = null; /** Track agent switches during the current iteration for logging */ private currentIterationAgentSwitches: AgentSwitchEntry[] = []; + /** Agent switches to carry into the next iteration after its reset */ + private nextIterationAgentSwitches: AgentSwitchEntry[] = []; + /** User-requested agent swap waiting for the active execution to finish */ + private pendingUserAgentSwap: PendingUserAgentSwap | null = null; /** Forced task for worker mode — engine only works on this one task */ private forcedTask: TrackerTask | null = null; /** Track if the forced task has been processed (prevents infinite loop on skip/fail) */ @@ -934,7 +946,8 @@ export class ExecutionEngine { this.subagentParser.reset(); // Reset agent switch tracking for this iteration - this.currentIterationAgentSwitches = []; + this.currentIterationAgentSwitches = this.nextIterationAgentSwitches; + this.nextIterationAgentSwitches = []; const startedAt = new Date(); const iteration = this.state.currentIteration; @@ -1443,6 +1456,8 @@ export class ExecutionEngine { // Ignore cleanup errors for temporary files }); } + this.currentExecution = null; + this.applyPendingUserAgentSwap(); this.state.currentTask = null; } } @@ -1869,7 +1884,8 @@ export class ExecutionEngine { private switchAgent( newAgentPlugin: string, reason: ActiveAgentReason, - previousAgentOverride?: string + previousAgentOverride?: string, + recordForNextIteration = false ): void { const previousAgent = previousAgentOverride ?? this.state.activeAgent?.plugin ?? this.config.agent.plugin; @@ -1908,7 +1924,11 @@ export class ExecutionEngine { to: newAgentPlugin, reason, }; - this.currentIterationAgentSwitches.push(switchEntry); + if (recordForNextIteration) { + this.nextIterationAgentSwitches.push(switchEntry); + } else { + this.currentIterationAgentSwitches.push(switchEntry); + } // Log the switch to console for visibility if (reason === 'fallback') { @@ -2125,30 +2145,59 @@ export class ExecutionEngine { if (modelError) { throw new Error(modelError); } - } else { - const modelError = newInstance.validateModel(''); - if (modelError) { - throw new Error(modelError); - } } const previousAgent = this.state.activeAgent?.plugin ?? this.config.agent.plugin; + const swap: PendingUserAgentSwap = { + agentConfig, + agent: newInstance, + model: normalizedModel, + rateLimitConfig: { + ...DEFAULT_RATE_LIMIT_HANDLING, + ...agentConfig.rateLimitHandling, + }, + previousAgent, + }; + + if (this.currentExecution || this.state.currentTask) { + this.pendingUserAgentSwap = swap; + return; + } + + this.applyUserAgentSwap(swap, true); + } + private applyUserAgentSwap( + swap: PendingUserAgentSwap, + recordForNextIteration: boolean + ): void { this.config.agent = { - ...agentConfig, - options: { ...agentConfig.options }, - }; - this.config.model = normalizedModel; - this.rateLimitConfig = { - ...DEFAULT_RATE_LIMIT_HANDLING, - ...this.config.agent.rateLimitHandling, + ...swap.agentConfig, + options: { ...swap.agentConfig.options }, }; - this.agent = newInstance; - this.primaryAgentInstance = newInstance; + this.config.model = swap.model; + this.rateLimitConfig = swap.rateLimitConfig; + this.agent = swap.agent; + this.primaryAgentInstance = swap.agent; this.rateLimitedAgents.clear(); - this.state.currentModel = normalizedModel; + this.state.currentModel = swap.model; + + this.switchAgent( + swap.agentConfig.plugin, + 'user-selected', + swap.previousAgent, + recordForNextIteration + ); + } + + private applyPendingUserAgentSwap(): void { + if (!this.pendingUserAgentSwap) { + return; + } - this.switchAgent(agentConfig.plugin, 'user-selected', previousAgent); + const swap = this.pendingUserAgentSwap; + this.pendingUserAgentSwap = null; + this.applyUserAgentSwap(swap, true); } /** diff --git a/src/engine/switch-to-user-agent.test.ts b/src/engine/switch-to-user-agent.test.ts index 5f8818ae..a8538608 100644 --- a/src/engine/switch-to-user-agent.test.ts +++ b/src/engine/switch-to-user-agent.test.ts @@ -114,6 +114,10 @@ describe('ExecutionEngine.switchToUserAgent', () => { ); const config = (engine as unknown as { config: RalphConfig }).config; + const switchBuffers = engine as unknown as { + currentIterationAgentSwitches: unknown[]; + nextIterationAgentSwitches: Array<{ reason: string }>; + }; expect(config.agent.plugin).toBe('switch-target'); expect(config.model).toBe('valid-model'); expect(engine.getState().activeAgent).toMatchObject({ @@ -122,6 +126,76 @@ describe('ExecutionEngine.switchToUserAgent', () => { }); expect(engine.getState().currentModel).toBe('valid-model'); expect(events).toEqual(['switch-primary:switch-target:user-selected']); + expect(switchBuffers.currentIterationAgentSwitches).toHaveLength(0); + expect(switchBuffers.nextIterationAgentSwitches).toEqual([ + expect.objectContaining({ reason: 'user-selected' }), + ]); + }); + + test('clears model override without validating an empty string', async () => { + if (!registryUsable) return; + const validatedModels: string[] = []; + registerTestAgent('switch-clear-primary'); + registerTestAgent('switch-clear-target', { + validateModel: (model) => { + validatedModels.push(model); + return model === '' ? 'empty model rejected' : null; + }, + }); + const engine = new ExecutionEngine(createConfig('switch-clear-primary')); + + await engine.switchToUserAgent( + { name: 'switch-clear-target', plugin: 'switch-clear-target', options: {} }, + undefined + ); + + const config = (engine as unknown as { config: RalphConfig }).config; + expect(config.agent.plugin).toBe('switch-clear-target'); + expect(config.model).toBeUndefined(); + expect(validatedModels).toEqual([]); + }); + + test('queues a user switch while an execution is active', async () => { + if (!registryUsable) return; + registerTestAgent('switch-queued-primary'); + registerTestAgent('switch-queued-target'); + const engine = new ExecutionEngine(createConfig('switch-queued-primary')); + const internals = engine as unknown as { + currentExecution: unknown; + pendingUserAgentSwap: unknown; + applyPendingUserAgentSwap: () => void; + config: RalphConfig; + currentIterationAgentSwitches: unknown[]; + nextIterationAgentSwitches: Array<{ reason: string }>; + }; + const events: string[] = []; + engine.on((event) => { + if (event.type === 'agent:switched') { + events.push(`${event.previousAgent}:${event.newAgent}:${event.reason}`); + } + }); + internals.currentExecution = { interrupt: () => {}, promise: Promise.resolve() }; + + await engine.switchToUserAgent( + { name: 'switch-queued-target', plugin: 'switch-queued-target', options: {} }, + 'queued-model' + ); + + expect(internals.config.agent.plugin).toBe('switch-queued-primary'); + expect(internals.config.model).toBeUndefined(); + expect(events).toEqual([]); + expect(internals.pendingUserAgentSwap).not.toBeNull(); + + internals.currentExecution = null; + internals.applyPendingUserAgentSwap(); + + expect(internals.config.agent.plugin).toBe('switch-queued-target'); + expect(internals.config.model).toBe('queued-model'); + expect(events).toEqual(['switch-queued-primary:switch-queued-target:user-selected']); + expect(internals.currentIterationAgentSwitches).toHaveLength(0); + expect(internals.nextIterationAgentSwitches).toEqual([ + expect.objectContaining({ reason: 'user-selected' }), + ]); }); test('throws on invalid model without mutating state', async () => {