From fc04615e7a2d754710264834cfa022cb74e321e0 Mon Sep 17 00:00:00 2001 From: xlx1212 Date: Thu, 6 Aug 2026 23:45:06 +0800 Subject: [PATCH] feat: add --agent/--model/--session CLI startup commands (fixes #2095) --- src/apps/desktop/src/appearance.rs | 13 ++- src/apps/desktop/src/lib.rs | 79 +++++++++++++++++++ src/web-ui/src/flow_chat/hooks/useFlowChat.ts | 41 +++++++--- src/web-ui/src/shared/types/global-state.ts | 44 +++++++++++ 4 files changed, 167 insertions(+), 10 deletions(-) diff --git a/src/apps/desktop/src/appearance.rs b/src/apps/desktop/src/appearance.rs index bfd5bca04..7c1fc5fd0 100644 --- a/src/apps/desktop/src/appearance.rs +++ b/src/apps/desktop/src/appearance.rs @@ -187,6 +187,7 @@ struct StartupBootstrapConfig { const MAX_BOOTSTRAP_KEYBINDINGS_JSON_BYTES: usize = 64 * 1024; const MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES: usize = 64 * 1024; +const MAX_BOOTSTRAP_STARTUP_COMMAND_JSON_BYTES: usize = 4 * 1024; impl Default for AppearanceConfig { fn default() -> Self { @@ -363,6 +364,7 @@ impl AppearanceConfig { startup_trace_id: &str, bootstrap_config: &StartupBootstrapConfig, workspace_startup_state: Option<&serde_json::Value>, + startup_command: Option<&serde_json::Value>, ) -> String { let appearance_mode = if self.is_light { "light" } else { "dark" }; let startup_locale = &bootstrap_config.locale; @@ -396,6 +398,11 @@ impl AppearanceConfig { .filter(|json| json.len() <= MAX_BOOTSTRAP_WORKSPACE_STATE_JSON_BYTES) .map(|json| format!("window.__BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__ = {json};")) .unwrap_or_default(); + let bootstrap_startup_command_assignment = startup_command + .and_then(|cmd| serde_json::to_string(cmd).ok()) + .filter(|json| json.len() <= MAX_BOOTSTRAP_STARTUP_COMMAND_JSON_BYTES) + .map(|json| format!("window.__BITFUN_STARTUP_COMMAND__ = {json};")) + .unwrap_or_default(); format!( r#" @@ -410,6 +417,7 @@ impl AppearanceConfig { window.__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__ = {bootstrap_appearance_selection_json}; {bootstrap_keybindings_assignment} {bootstrap_workspace_startup_state_assignment} + {bootstrap_startup_command_assignment} function applyAppearance() {{ var root = document.documentElement; if (!root) return false; @@ -462,6 +470,7 @@ impl AppearanceConfig { bootstrap_keybindings_assignment = bootstrap_keybindings_assignment, bootstrap_workspace_startup_state_assignment = bootstrap_workspace_startup_state_assignment, + bootstrap_startup_command_assignment = bootstrap_startup_command_assignment, ) } @@ -497,7 +506,7 @@ mod startup_appearance_tests { keybindings: None, }; - let script = appearance.generate_init_script("trace-id", &bootstrap, None); + let script = appearance.generate_init_script("trace-id", &bootstrap, None, None); assert!(script.contains("__BITFUN_BOOTSTRAP_APPEARANCE_ID__")); assert!(script.contains("__BITFUN_BOOTSTRAP_APPEARANCE_SELECTION__")); @@ -537,6 +546,7 @@ pub fn create_main_window( startup_trace_id: &str, startup_trace: &DesktopStartupTrace, workspace_startup_state: Option, + startup_command: Option, ) { let total_started_at = Instant::now(); let bootstrap_config = AppearanceConfig::load_startup_bootstrap_config(); @@ -546,6 +556,7 @@ pub fn create_main_window( startup_trace_id, &bootstrap_config, workspace_startup_state.as_ref(), + startup_command.as_ref(), ); startup_trace.record_step( "native_step_end", diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b05a671fd..7e1e45a0b 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -51,6 +51,71 @@ use tauri::Emitter; use tauri::Manager; use tauri_plugin_window_state::{AppHandleExt, StateFlags, WindowExt}; +/// Parsed CLI startup command (--agent, --model, --session). +/// +/// When the desktop app is launched from the command line, the user may pass +/// `--agent `, `--model `, or `--session ` to pre-select the +/// agent, model, or session for the initial conversation. These are injected +/// into the frontend bootstrap as `window.__BITFUN_STARTUP_COMMAND__`. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct StartupCommand { + /// Override the initial agent/mode (e.g. "agentic", "plan", "debug"). + #[serde(skip_serializing_if = "Option::is_none")] + pub agent: Option, + /// Override the initial model id. + #[serde(skip_serializing_if = "Option::is_none")] + pub model: Option, + /// Restore an existing session by id instead of creating a new one. + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, +} + +/// Parse `--agent`, `--model`, and `--session` from process arguments. +/// +/// Returns `None` when none of the flags are present. Unknown flags are +/// silently ignored so that Tauri's own arguments (e.g. `--dev`) are not +/// disturbed. +fn parse_startup_command() -> Option { + let args: Vec = std::env::args().collect(); + let mut agent: Option = None; + let mut model: Option = None; + let mut session: Option = None; + + let mut i = 1; // skip program name + while i < args.len() { + match args[i].as_str() { + "--agent" if i + 1 < args.len() => { + agent = Some(args[i + 1].clone()); + i += 2; + continue; + } + "--model" if i + 1 < args.len() => { + model = Some(args[i + 1].clone()); + i += 2; + continue; + } + "--session" if i + 1 < args.len() => { + session = Some(args[i + 1].clone()); + i += 2; + continue; + } + _ => {} + } + i += 1; + } + + if agent.is_none() && model.is_none() && session.is_none() { + return None; + } + + Some(StartupCommand { + agent, + model, + session, + }) +} + // Re-export API pub use api::*; @@ -426,6 +491,19 @@ fn get_startup_native_trace( /// Tauri application entry point #[cfg_attr(mobile, tauri::mobile_entry_point)] pub async fn run() { + let startup_command = parse_startup_command(); + let startup_command_json = startup_command + .as_ref() + .and_then(|cmd| serde_json::to_value(cmd).ok()); + if let Some(ref cmd) = startup_command { + log::info!( + "Startup command parsed: agent={:?}, model={:?}, session={:?}", + cmd.agent, + cmd.model, + cmd.session + ); + } + let startup_started = Instant::now(); let startup_trace_id = SystemTime::now() .duration_since(UNIX_EPOCH) @@ -913,6 +991,7 @@ pub async fn run() { &startup_trace_id, &startup_trace, workspace_startup_bootstrap_snapshot, + startup_command_json, ); let window_duration_ms = elapsed_ms(window_started); startup_trace.record_step( diff --git a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts index 588d843a0..7791452af 100644 --- a/src/web-ui/src/flow_chat/hooks/useFlowChat.ts +++ b/src/web-ui/src/flow_chat/hooks/useFlowChat.ts @@ -29,6 +29,7 @@ import { getNextDefaultSessionTitleCount, normalizeDefaultSessionTitleMode, } from '../utils/sessionTitle'; +import { globalStateAPI } from '@/shared/types/global-state'; const log = createLogger('useFlowChat'); @@ -65,18 +66,40 @@ export const useFlowChat = () => { // Create a session using Agentic API v2. const createSession = useCallback(async (config?: Partial): Promise => { - + + // Apply CLI startup overrides (--agent/--model) on the first session only. + // consumeBootstrapStartupCommand is one-shot: it clears the global after + // reading, so subsequent calls return undefined. + const startupCommand = globalStateAPI.getStartupCommand(); + const effectiveAgentType = (config?.agentType || startupCommand?.agent || 'agentic').trim() || 'agentic'; + const effectiveModelName = config?.modelName || startupCommand?.model; + try { if (!workspacePath) { throw new Error('Workspace path is required to create a session'); } - + const isRemote = workspace?.workspaceKind === WorkspaceKind.Remote; const remoteConnectionId = isRemote ? workspace?.connectionId : undefined; const remoteSshHost = isRemote ? workspace?.sshHost : undefined; - const agentTypeForSession = (config?.agentType || 'agentic').trim() || 'agentic'; - const maxContextTokens = await getModelMaxTokens(config?.modelName, agentTypeForSession); + // If --session is specified, try to restore that session instead of creating new. + if (startupCommand?.session) { + try { + const sessions = await agentAPI.listSessions(workspacePath, remoteConnectionId, remoteSshHost); + if (sessions.some(s => s.sessionId === startupCommand.session)) { + await flowChatManager.switchChatSession(startupCommand.session); + log.info('Restored startup session', { sessionId: startupCommand.session }); + return startupCommand.session; + } + log.warn('Startup session not found, creating new session', { requestedSession: startupCommand.session }); + } catch (error) { + log.warn('Failed to restore startup session, creating new', { requestedSession: startupCommand.session, error }); + } + } + + const agentTypeForSession = effectiveAgentType; + const maxContextTokens = await getModelMaxTokens(effectiveModelName, agentTypeForSession); const sessionTitleMode = workspace?.workspaceKind === WorkspaceKind.Assistant ? 'claw' @@ -106,7 +129,7 @@ export const useFlowChat = () => { remoteConnectionId, remoteSshHost, config: { - modelName: config?.modelName || 'default', + modelName: effectiveModelName || 'default', enableTools: true, safeMode: true, autoCompact: true, @@ -156,8 +179,8 @@ export const useFlowChat = () => { try { await aiApi.createAISession({ - agent_type: config?.agentType || 'agentic', - model_name: config?.modelName || 'default', + agent_type: effectiveAgentType, + model_name: effectiveModelName || 'default', description: `FlowChat session ${sessionId}` }); } catch (snapshotError) { @@ -165,12 +188,12 @@ export const useFlowChat = () => { } const sessionConfig: SessionConfig = { - modelName: config?.modelName || 'default', + modelName: effectiveModelName || 'default', ...config, workspaceId: workspace?.id ?? config?.workspaceId, }; - const fallbackAgentType = (config?.agentType || 'agentic').trim() || 'agentic'; + const fallbackAgentType = effectiveAgentType; const fallbackTitleMode = workspace?.workspaceKind === WorkspaceKind.Assistant ? 'claw' diff --git a/src/web-ui/src/shared/types/global-state.ts b/src/web-ui/src/shared/types/global-state.ts index 1d00c638a..5007fca93 100644 --- a/src/web-ui/src/shared/types/global-state.ts +++ b/src/web-ui/src/shared/types/global-state.ts @@ -20,6 +20,16 @@ declare global { var __BITFUN_BOOTSTRAP_WORKSPACE_STARTUP_STATE__: | APIWorkspaceStartupStateSnapshot | undefined; + // Native startup may inject this once when the app is launched with + // --agent/--model/--session CLI flags. + var __BITFUN_STARTUP_COMMAND__: StartupCommand | undefined; +} + +/// CLI startup overrides parsed from `--agent`, `--model`, `--session`. +export interface StartupCommand { + agent?: string; + model?: string; + session?: string; } @@ -225,6 +235,11 @@ export interface WorkspaceStartupState { export interface GlobalStateAPI { initializeWorkspaceStartupState(): Promise; + + /// Returns CLI startup overrides (`--agent`, `--model`, `--session`) if the + /// app was launched with them, then clears the bootstrap global. Only + /// meaningful on the first call; subsequent calls return undefined. + getStartupCommand(): StartupCommand | undefined; getAppState(): Promise; @@ -481,10 +496,39 @@ function consumeBootstrapWorkspaceStartupStateSnapshot(): return snapshot; } +function consumeBootstrapStartupCommand(): StartupCommand | undefined { + if ( + !Object.prototype.hasOwnProperty.call(globalThis, '__BITFUN_STARTUP_COMMAND__') + ) { + return undefined; + } + + const cmd = globalThis.__BITFUN_STARTUP_COMMAND__; + delete globalThis.__BITFUN_STARTUP_COMMAND__; + + if ( + cmd !== undefined && + cmd !== null && + typeof cmd === 'object' && + (typeof cmd.agent === 'string' || cmd.agent === undefined) && + (typeof cmd.model === 'string' || cmd.model === undefined) && + (typeof cmd.session === 'string' || cmd.session === undefined) + ) { + return cmd as StartupCommand; + } + + logger.warn('Ignored invalid bootstrap startup command'); + return undefined; +} + export function createGlobalStateAPI(): GlobalStateAPI { return { + getStartupCommand(): StartupCommand | undefined { + return consumeBootstrapStartupCommand(); + }, + async initializeWorkspaceStartupState(): Promise { const bootstrapSnapshot = consumeBootstrapWorkspaceStartupStateSnapshot(); if (bootstrapSnapshot) {