mirror of
https://github.com/getcompanion-ai/co-mono.git
synced 2026-04-17 05:00:16 +00:00
Add --models parameter for quick model cycling with Ctrl+P
- Add --models CLI arg accepting comma-separated patterns - Implement smart matching: prefers aliases over dated versions - Add Ctrl+P to cycle through scoped models (or all if no scope) - Show model scope hint at startup - Update help text with examples Co-authored-by: Tino Ehrich <tino.ehrich@hey.com>
This commit is contained in:
parent
097ff25ed4
commit
fecf9734b0
3 changed files with 170 additions and 3 deletions
|
|
@ -55,6 +55,7 @@ interface Args {
|
||||||
mode?: Mode;
|
mode?: Mode;
|
||||||
noSession?: boolean;
|
noSession?: boolean;
|
||||||
session?: string;
|
session?: string;
|
||||||
|
models?: string[];
|
||||||
messages: string[];
|
messages: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,6 +90,8 @@ function parseArgs(args: string[]): Args {
|
||||||
result.noSession = true;
|
result.noSession = true;
|
||||||
} else if (arg === "--session" && i + 1 < args.length) {
|
} else if (arg === "--session" && i + 1 < args.length) {
|
||||||
result.session = args[++i];
|
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("-")) {
|
} else if (!arg.startsWith("-")) {
|
||||||
result.messages.push(arg);
|
result.messages.push(arg);
|
||||||
}
|
}
|
||||||
|
|
@ -113,6 +116,7 @@ ${chalk.bold("Options:")}
|
||||||
--resume, -r Select a session to resume
|
--resume, -r Select a session to resume
|
||||||
--session <path> Use specific session file
|
--session <path> Use specific session file
|
||||||
--no-session Don't save session (ephemeral)
|
--no-session Don't save session (ephemeral)
|
||||||
|
--models <patterns> Comma-separated model patterns for quick cycling with Ctrl+P
|
||||||
--help, -h Show this help
|
--help, -h Show this help
|
||||||
|
|
||||||
${chalk.bold("Examples:")}
|
${chalk.bold("Examples:")}
|
||||||
|
|
@ -131,6 +135,9 @@ ${chalk.bold("Examples:")}
|
||||||
# Use different model
|
# Use different model
|
||||||
coding-agent --provider openai --model gpt-4o-mini "Help me refactor this code"
|
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:")}
|
${chalk.bold("Environment Variables:")}
|
||||||
GEMINI_API_KEY - Google Gemini API key
|
GEMINI_API_KEY - Google Gemini API key
|
||||||
OPENAI_API_KEY - OpenAI API key
|
OPENAI_API_KEY - OpenAI API key
|
||||||
|
|
@ -328,6 +335,70 @@ async function checkForNewVersion(currentVersion: string): Promise<string | null
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve model patterns to actual Model objects
|
||||||
|
* For each pattern, finds all matching models and picks the best version:
|
||||||
|
* 1. Prefer alias (e.g., claude-sonnet-4-5) over dated versions (claude-sonnet-4-5-20250929)
|
||||||
|
* 2. If no alias, pick the latest dated version
|
||||||
|
*/
|
||||||
|
async function resolveModelScope(patterns: string[]): Promise<Model<Api>[]> {
|
||||||
|
const { models: availableModels, error } = await getAvailableModels();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.warn(chalk.yellow(`Warning: Error loading models: ${error}`));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedModels: Model<Api>[] = [];
|
||||||
|
|
||||||
|
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<Api>;
|
||||||
|
|
||||||
|
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<string | null> {
|
async function selectSession(sessionManager: SessionManager): Promise<string | null> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const ui = new TUI(new ProcessTerminal());
|
const ui = new TUI(new ProcessTerminal());
|
||||||
|
|
@ -365,8 +436,17 @@ async function runInteractiveMode(
|
||||||
changelogMarkdown: string | null = null,
|
changelogMarkdown: string | null = null,
|
||||||
modelFallbackMessage: string | null = null,
|
modelFallbackMessage: string | null = null,
|
||||||
newVersion: string | null = null,
|
newVersion: string | null = null,
|
||||||
|
scopedModels: Model<Api>[] = [],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const renderer = new TuiRenderer(agent, sessionManager, settingsManager, version, changelogMarkdown, newVersion);
|
const renderer = new TuiRenderer(
|
||||||
|
agent,
|
||||||
|
sessionManager,
|
||||||
|
settingsManager,
|
||||||
|
version,
|
||||||
|
changelogMarkdown,
|
||||||
|
newVersion,
|
||||||
|
scopedModels,
|
||||||
|
);
|
||||||
|
|
||||||
// Initialize TUI
|
// Initialize TUI
|
||||||
await renderer.init();
|
await renderer.init();
|
||||||
|
|
@ -813,6 +893,18 @@ export async function main(args: string[]) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve model scope if provided
|
||||||
|
let scopedModels: Model<Api>[] = [];
|
||||||
|
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
|
// No messages and not RPC - use TUI
|
||||||
await runInteractiveMode(
|
await runInteractiveMode(
|
||||||
agent,
|
agent,
|
||||||
|
|
@ -822,6 +914,7 @@ export async function main(args: string[]) {
|
||||||
changelogMarkdown,
|
changelogMarkdown,
|
||||||
modelFallbackMessage,
|
modelFallbackMessage,
|
||||||
newVersion,
|
newVersion,
|
||||||
|
scopedModels,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
// CLI mode with messages
|
// CLI mode with messages
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,15 @@ export class CustomEditor extends Editor {
|
||||||
public onEscape?: () => void;
|
public onEscape?: () => void;
|
||||||
public onCtrlC?: () => void;
|
public onCtrlC?: () => void;
|
||||||
public onShiftTab?: () => void;
|
public onShiftTab?: () => void;
|
||||||
|
public onCtrlP?: () => void;
|
||||||
|
|
||||||
handleInput(data: string): 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
|
// Intercept Shift+Tab for thinking level cycling
|
||||||
if (data === "\x1b[Z" && this.onShiftTab) {
|
if (data === "\x1b[Z" && this.onShiftTab) {
|
||||||
this.onShiftTab();
|
this.onShiftTab();
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { Agent, AgentEvent, AgentState, ThinkingLevel } from "@mariozechner/pi-agent";
|
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 type { SlashCommand } from "@mariozechner/pi-tui";
|
||||||
import {
|
import {
|
||||||
CombinedAutocompleteProvider,
|
CombinedAutocompleteProvider,
|
||||||
|
|
@ -16,7 +16,7 @@ import chalk from "chalk";
|
||||||
import { exec } from "child_process";
|
import { exec } from "child_process";
|
||||||
import { getChangelogPath, parseChangelog } from "../changelog.js";
|
import { getChangelogPath, parseChangelog } from "../changelog.js";
|
||||||
import { exportSessionToHtml } from "../export-html.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 { listOAuthProviders, login, logout } from "../oauth/index.js";
|
||||||
import type { SessionManager } from "../session-manager.js";
|
import type { SessionManager } from "../session-manager.js";
|
||||||
import type { SettingsManager } from "../settings-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)
|
// Track if this is the first user message (to skip spacer)
|
||||||
private isFirstUserMessage = true;
|
private isFirstUserMessage = true;
|
||||||
|
|
||||||
|
// Model scope for quick cycling
|
||||||
|
private scopedModels: Model<any>[] = [];
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
agent: Agent,
|
agent: Agent,
|
||||||
sessionManager: SessionManager,
|
sessionManager: SessionManager,
|
||||||
|
|
@ -81,6 +84,7 @@ export class TuiRenderer {
|
||||||
version: string,
|
version: string,
|
||||||
changelogMarkdown: string | null = null,
|
changelogMarkdown: string | null = null,
|
||||||
newVersion: string | null = null,
|
newVersion: string | null = null,
|
||||||
|
scopedModels: Model<any>[] = [],
|
||||||
) {
|
) {
|
||||||
this.agent = agent;
|
this.agent = agent;
|
||||||
this.sessionManager = sessionManager;
|
this.sessionManager = sessionManager;
|
||||||
|
|
@ -88,6 +92,7 @@ export class TuiRenderer {
|
||||||
this.version = version;
|
this.version = version;
|
||||||
this.newVersion = newVersion;
|
this.newVersion = newVersion;
|
||||||
this.changelogMarkdown = changelogMarkdown;
|
this.changelogMarkdown = changelogMarkdown;
|
||||||
|
this.scopedModels = scopedModels;
|
||||||
this.ui = new TUI(new ProcessTerminal());
|
this.ui = new TUI(new ProcessTerminal());
|
||||||
this.chatContainer = new Container();
|
this.chatContainer = new Container();
|
||||||
this.statusContainer = new Container();
|
this.statusContainer = new Container();
|
||||||
|
|
@ -175,6 +180,9 @@ export class TuiRenderer {
|
||||||
chalk.dim("shift+tab") +
|
chalk.dim("shift+tab") +
|
||||||
chalk.gray(" to cycle thinking") +
|
chalk.gray(" to cycle thinking") +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
chalk.dim("ctrl+p") +
|
||||||
|
chalk.gray(" to cycle models") +
|
||||||
|
"\n" +
|
||||||
chalk.dim("/") +
|
chalk.dim("/") +
|
||||||
chalk.gray(" for commands") +
|
chalk.gray(" for commands") +
|
||||||
"\n" +
|
"\n" +
|
||||||
|
|
@ -236,6 +244,10 @@ export class TuiRenderer {
|
||||||
this.cycleThinkingLevel();
|
this.cycleThinkingLevel();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
this.editor.onCtrlP = () => {
|
||||||
|
this.cycleModel();
|
||||||
|
};
|
||||||
|
|
||||||
// Handle editor submission
|
// Handle editor submission
|
||||||
this.editor.onSubmit = async (text: string) => {
|
this.editor.onSubmit = async (text: string) => {
|
||||||
text = text.trim();
|
text = text.trim();
|
||||||
|
|
@ -656,6 +668,61 @@ export class TuiRenderer {
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cycleModel(): Promise<void> {
|
||||||
|
// Use scoped models if available, otherwise all available models
|
||||||
|
let modelsToUse: Model<any>[];
|
||||||
|
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 {
|
clearEditor(): void {
|
||||||
this.editor.setText("");
|
this.editor.setText("");
|
||||||
this.ui.requestRender();
|
this.ui.requestRender();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue