diff --git a/packages/coding-agent/src/main.ts b/packages/coding-agent/src/main.ts index d84f6cb3..810f5912 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -55,6 +55,7 @@ interface Args { mode?: Mode; noSession?: boolean; session?: string; + models?: string[]; messages: string[]; } @@ -89,6 +90,8 @@ function parseArgs(args: string[]): Args { result.noSession = true; } else if (arg === "--session" && i + 1 < args.length) { result.session = args[++i]; + } else if (arg === "--models" && i + 1 < args.length) { + result.models = args[++i].split(",").map((s) => s.trim()); } else if (!arg.startsWith("-")) { result.messages.push(arg); } @@ -113,6 +116,7 @@ ${chalk.bold("Options:")} --resume, -r Select a session to resume --session Use specific session file --no-session Don't save session (ephemeral) + --models Comma-separated model patterns for quick cycling with Ctrl+P --help, -h Show this help ${chalk.bold("Examples:")} @@ -131,6 +135,9 @@ ${chalk.bold("Examples:")} # Use different model coding-agent --provider openai --model gpt-4o-mini "Help me refactor this code" + # Limit model cycling to specific models + coding-agent --models claude-sonnet,claude-haiku,gpt-4o + ${chalk.bold("Environment Variables:")} GEMINI_API_KEY - Google Gemini API key OPENAI_API_KEY - OpenAI API key @@ -328,6 +335,70 @@ async function checkForNewVersion(currentVersion: string): Promise[]> { + const { models: availableModels, error } = await getAvailableModels(); + + if (error) { + console.warn(chalk.yellow(`Warning: Error loading models: ${error}`)); + return []; + } + + const scopedModels: Model[] = []; + + for (const pattern of patterns) { + // Find all models matching this pattern (case-insensitive partial match) + const matches = availableModels.filter( + (m) => + m.id.toLowerCase().includes(pattern.toLowerCase()) || m.name?.toLowerCase().includes(pattern.toLowerCase()), + ); + + if (matches.length === 0) { + console.warn(chalk.yellow(`Warning: No models match pattern "${pattern}"`)); + continue; + } + + // Helper to check if a model ID looks like an alias (no date suffix) + // Dates are typically in format: -20241022 or -20250929 + const isAlias = (id: string): boolean => { + // Check if ID ends with -latest + if (id.endsWith("-latest")) return true; + + // Check if ID ends with a date pattern (-YYYYMMDD) + const datePattern = /-\d{8}$/; + return !datePattern.test(id); + }; + + // Separate into aliases and dated versions + const aliases = matches.filter((m) => isAlias(m.id)); + const datedVersions = matches.filter((m) => !isAlias(m.id)); + + let bestMatch: Model; + + if (aliases.length > 0) { + // Prefer alias - if multiple aliases, pick the one that sorts highest + aliases.sort((a, b) => b.id.localeCompare(a.id)); + bestMatch = aliases[0]; + } else { + // No alias found, pick latest dated version + datedVersions.sort((a, b) => b.id.localeCompare(a.id)); + bestMatch = datedVersions[0]; + } + + // Avoid duplicates + if (!scopedModels.find((m) => m.id === bestMatch.id && m.provider === bestMatch.provider)) { + scopedModels.push(bestMatch); + } + } + + return scopedModels; +} + async function selectSession(sessionManager: SessionManager): Promise { return new Promise((resolve) => { const ui = new TUI(new ProcessTerminal()); @@ -365,8 +436,17 @@ async function runInteractiveMode( changelogMarkdown: string | null = null, modelFallbackMessage: string | null = null, newVersion: string | null = null, + scopedModels: Model[] = [], ): Promise { - const renderer = new TuiRenderer(agent, sessionManager, settingsManager, version, changelogMarkdown, newVersion); + const renderer = new TuiRenderer( + agent, + sessionManager, + settingsManager, + version, + changelogMarkdown, + newVersion, + scopedModels, + ); // Initialize TUI await renderer.init(); @@ -813,6 +893,18 @@ export async function main(args: string[]) { } } + // Resolve model scope if provided + let scopedModels: Model[] = []; + if (parsed.models && parsed.models.length > 0) { + scopedModels = await resolveModelScope(parsed.models); + + if (scopedModels.length > 0) { + console.log( + chalk.dim(`Model scope: ${scopedModels.map((m) => m.id).join(", ")} ${chalk.gray("(Ctrl+P to cycle)")}`), + ); + } + } + // No messages and not RPC - use TUI await runInteractiveMode( agent, @@ -822,6 +914,7 @@ export async function main(args: string[]) { changelogMarkdown, modelFallbackMessage, newVersion, + scopedModels, ); } else { // CLI mode with messages diff --git a/packages/coding-agent/src/tui/custom-editor.ts b/packages/coding-agent/src/tui/custom-editor.ts index 08527dff..1b1e84cd 100644 --- a/packages/coding-agent/src/tui/custom-editor.ts +++ b/packages/coding-agent/src/tui/custom-editor.ts @@ -7,8 +7,15 @@ export class CustomEditor extends Editor { public onEscape?: () => void; public onCtrlC?: () => void; public onShiftTab?: () => void; + public onCtrlP?: () => void; handleInput(data: string): void { + // Intercept Ctrl+P for model cycling + if (data === "\x10" && this.onCtrlP) { + this.onCtrlP(); + return; + } + // Intercept Shift+Tab for thinking level cycling if (data === "\x1b[Z" && this.onShiftTab) { this.onShiftTab(); diff --git a/packages/coding-agent/src/tui/tui-renderer.ts b/packages/coding-agent/src/tui/tui-renderer.ts index 3d690e3f..e2e1b8df 100644 --- a/packages/coding-agent/src/tui/tui-renderer.ts +++ b/packages/coding-agent/src/tui/tui-renderer.ts @@ -1,5 +1,5 @@ import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent"; -import type { AssistantMessage, Message } from "@mariozechner/pi-ai"; +import type { AssistantMessage, Message, Model } from "@mariozechner/pi-ai"; import type { SlashCommand } from "@mariozechner/pi-tui"; import { CombinedAutocompleteProvider, @@ -16,7 +16,7 @@ import chalk from "chalk"; import { exec } from "child_process"; import { getChangelogPath, parseChangelog } from "../changelog.js"; import { exportSessionToHtml } from "../export-html.js"; -import { getApiKeyForModel } from "../model-config.js"; +import { getApiKeyForModel, getAvailableModels } from "../model-config.js"; import { listOAuthProviders, login, logout } from "../oauth/index.js"; import type { SessionManager } from "../session-manager.js"; import type { SettingsManager } from "../settings-manager.js"; @@ -74,6 +74,9 @@ export class TuiRenderer { // Track if this is the first user message (to skip spacer) private isFirstUserMessage = true; + // Model scope for quick cycling + private scopedModels: Model[] = []; + constructor( agent: Agent, sessionManager: SessionManager, @@ -81,6 +84,7 @@ export class TuiRenderer { version: string, changelogMarkdown: string | null = null, newVersion: string | null = null, + scopedModels: Model[] = [], ) { this.agent = agent; this.sessionManager = sessionManager; @@ -88,6 +92,7 @@ export class TuiRenderer { this.version = version; this.newVersion = newVersion; this.changelogMarkdown = changelogMarkdown; + this.scopedModels = scopedModels; this.ui = new TUI(new ProcessTerminal()); this.chatContainer = new Container(); this.statusContainer = new Container(); @@ -175,6 +180,9 @@ export class TuiRenderer { chalk.dim("shift+tab") + chalk.gray(" to cycle thinking") + "\n" + + chalk.dim("ctrl+p") + + chalk.gray(" to cycle models") + + "\n" + chalk.dim("/") + chalk.gray(" for commands") + "\n" + @@ -236,6 +244,10 @@ export class TuiRenderer { this.cycleThinkingLevel(); }; + this.editor.onCtrlP = () => { + this.cycleModel(); + }; + // Handle editor submission this.editor.onSubmit = async (text: string) => { text = text.trim(); @@ -656,6 +668,61 @@ export class TuiRenderer { this.ui.requestRender(); } + private async cycleModel(): Promise { + // Use scoped models if available, otherwise all available models + let modelsToUse: Model[]; + if (this.scopedModels.length > 0) { + modelsToUse = this.scopedModels; + } else { + const { models: availableModels, error } = await getAvailableModels(); + if (error) { + this.showError(`Failed to load models: ${error}`); + return; + } + modelsToUse = availableModels; + } + + if (modelsToUse.length === 0) { + this.showError("No models available to cycle"); + return; + } + + if (modelsToUse.length === 1) { + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(chalk.dim("Only one model in scope"), 1, 0)); + this.ui.requestRender(); + return; + } + + const currentModel = this.agent.state.model; + let currentIndex = modelsToUse.findIndex( + (m) => m.id === currentModel?.id && m.provider === currentModel?.provider, + ); + + // If current model not in scope, start from first + if (currentIndex === -1) { + currentIndex = 0; + } + + const nextIndex = (currentIndex + 1) % modelsToUse.length; + const nextModel = modelsToUse[nextIndex]; + + // Validate API key + const apiKey = await getApiKeyForModel(nextModel); + if (!apiKey) { + this.showError(`No API key for ${nextModel.provider}/${nextModel.id}`); + return; + } + + // Switch model + this.agent.setModel(nextModel); + + // Show notification + this.chatContainer.addChild(new Spacer(1)); + this.chatContainer.addChild(new Text(chalk.dim(`Switched to ${nextModel.name || nextModel.id}`), 1, 0)); + this.ui.requestRender(); + } + clearEditor(): void { this.editor.setText(""); this.ui.requestRender();