use std::collections::{HashMap, HashSet}; use std::path::PathBuf; use std::str::FromStr; use std::sync::Arc; use std::time::Duration; use anyhow::{Context, Result}; use colored::Colorize; use console::style; use convert_case::{Case, Casing}; use forge_api::{ API, AgentId, AnyProvider, ApiKeyRequest, AuthContextRequest, AuthContextResponse, ChatRequest, ChatResponse, CodeRequest, ConfigOperation, Conversation, ConversationId, DeviceCodeRequest, Event, InterruptionReason, ModelId, Provider, ProviderId, TextMessage, UserPrompt, }; use forge_app::utils::{format_display_path, truncate_key}; use forge_app::{CommitResult, ToolResolver}; use forge_config::ForgeConfig; use forge_display::MarkdownFormat; use forge_domain::{ AuthMethod, ChatResponseContent, ConsoleWriter, ContextMessage, Role, TitleFormat, UserCommand, }; use forge_fs::ForgeFS; use forge_select::{ForgeWidget, SelectRow}; use forge_spinner::SpinnerManager; use forge_tracker::ToolCallPayload; use forge_walker::Walker; use futures::future; use strum::IntoEnumIterator; use tokio_stream::StreamExt; use url::Url; use crate::cli::{ Cli, CommitCommandGroup, ConversationCommand, ListCommand, McpCommand, SelectCommand, TopLevelCommand, }; use crate::conversation_selector::ConversationSelector; use crate::display_constants::{CommandType, headers, markers, status}; use crate::editor::ReadLineError; use crate::error::UIError; use crate::info::Info; use crate::input::Console; use crate::model::{AppCommand, ForgeCommandManager}; use crate::porcelain::Porcelain; use crate::prompt::ForgePrompt; use crate::state::UIState; use crate::stream_renderer::{SharedSpinner, StreamingWriter}; use crate::sync_display::SyncProgressDisplay; use crate::title_display::TitleDisplayExt; use crate::tools_display::format_tools; use crate::update::on_update; use crate::utils::humanize_time; use crate::zsh::ZshRPrompt; use crate::{TRACKER, banner, tracker}; // File-specific constants const MISSING_AGENT_TITLE: &str = ""; /// Conversation dump format used by the /dump command #[derive(Debug, serde::Deserialize, serde::Serialize)] struct ConversationDump { conversation: Conversation, related_conversations: Vec, } /// Formats an MCP server config for display, redacting sensitive information. /// Returns the command/URL string only. fn format_mcp_server(server: &forge_domain::McpServerConfig) -> String { match server { forge_domain::McpServerConfig::Stdio(stdio) => { let mut output = format!("{} ", stdio.command); for arg in &stdio.args { output.push_str(&format!("{arg} ")); } for key in stdio.env.keys() { output.push_str(&format!("{key}=*** ")); } output.trim().to_string() } forge_domain::McpServerConfig::Http(http) => http.url.clone(), } } /// Formats HTTP headers for display, redacting values. /// Returns None if there are no headers. fn format_mcp_headers(server: &forge_domain::McpServerConfig) -> Option { match server { forge_domain::McpServerConfig::Stdio(_) => None, forge_domain::McpServerConfig::Http(http) => { if http.headers.is_empty() { None } else { Some( http.headers .keys() .map(|k| format!("{k}=***")) .collect::>() .join(", "), ) } } } } pub struct UI A> { markdown: MarkdownFormat, state: UIState, api: Arc, new_api: Arc, console: Console, command: Arc, cli: Cli, spinner: SharedSpinner, config: ForgeConfig, #[allow(dead_code)] // The guard is kept alive by being held in the struct _guard: forge_tracker::Guard, } impl A + Send + Sync> UI { /// Writes a line to the console output /// Takes anything that implements ToString trait fn writeln(&mut self, content: T) -> anyhow::Result<()> { self.spinner.write_ln(content) } /// Writes a TitleFormat to the console output with proper formatting fn writeln_title(&mut self, title: TitleFormat) -> anyhow::Result<()> { self.spinner.write_ln(title.display()) } fn writeln_to_stderr(&mut self, title: String) -> anyhow::Result<()> { self.spinner.ewrite_ln(title) } /// Helper to get provider for an optional agent, defaulting to the current /// active agent's provider async fn get_provider(&self, agent_id: Option) -> Result> { match agent_id { Some(agent_id) => self.api.get_agent_provider(agent_id).await, None => self.api.get_default_provider().await, } } /// Helper to get model for an optional agent, defaulting to the current /// active agent's model async fn get_agent_model(&self, agent_id: Option) -> Option { match agent_id { Some(agent_id) => self.api.get_agent_model(agent_id).await, None => self.api.get_session_config().await.map(|c| c.model), } } fn select_raw_row( &self, prompt: &str, query: Option, rows: Vec, header_lines: usize, initial_raw: Option, ) -> Result> { ForgeWidget::select_rows(prompt, rows) .query(query) .header_lines(header_lines) .initial_raw(initial_raw) .prompt() } fn select_row_output( &mut self, prompt: &str, query: Option, rows: Vec, ) -> Result<()> { if let Some(row) = self.select_raw_row(prompt, query, rows, 1, None)? { self.writeln(row.raw)?; } Ok(()) } fn porcelain_rows(porcelain: impl ToString) -> Result> { let porcelain = porcelain.to_string(); let mut lines = porcelain.lines(); let Some(header) = lines.next() else { return Ok(Vec::new()); }; let mut rows = vec![SelectRow::header(header.to_string())]; rows.extend(lines.filter_map(|line| { line.split_whitespace() .next() .map(|raw| SelectRow::new(raw.to_string(), line.to_string())) })); Ok(rows) } /// Displays banner only if user is in interactive mode. fn display_banner(&self) -> Result<()> { if self.cli.is_interactive() { banner::display(false)?; } Ok(()) } // Handle creating a new conversation async fn on_new(&mut self) -> Result<()> { let config = forge_config::ForgeConfig::read().unwrap_or_default(); self.config = config.clone(); self.api = Arc::new((self.new_api)(config)); self.init_state(false).await?; // Set agent if provided via CLI if let Some(agent_id) = self.cli.agent.clone() { self.api.set_active_agent(agent_id).await?; } // Reset previously set CLI parameters by the user self.cli.conversation = None; self.cli.conversation_id = None; self.spinner.reset(); self.display_banner()?; self.trace_user(); self.hydrate_caches(); Ok(()) } // Set the current mode and update conversation variable async fn on_agent_change(&mut self, agent_id: AgentId) -> Result<()> { // Convert string to AgentId for validation let agent = self .api .get_agent_infos() .await? .into_iter() .find(|info| info.id == agent_id) .ok_or(anyhow::anyhow!("Undefined agent: {agent_id}"))?; // Update the app config with the new operating agent. self.api.set_active_agent(agent.id.clone()).await?; // Update model tracking to reflect the new agent's model let model = self.get_agent_model(Some(agent.id.clone())).await; self.update_model(model.clone()); let name = agent.id.as_str().to_case(Case::UpperSnake).bold(); let title = format!( "∙ {}", agent.title.as_deref().unwrap_or(MISSING_AGENT_TITLE) ) .dimmed(); // Show model info if agent uses a specific model let model_info = model .map(|m| format!(" ∙ model: {m}").dimmed().to_string()) .unwrap_or_default(); self.writeln_title(TitleFormat::action(format!("{name} {title}{model_info}")))?; Ok(()) } /// Initialises the UI with the provided CLI arguments and API factory. /// /// # Arguments /// * `cli` - Parsed command-line arguments /// * `config` - Pre-read application configuration for the initial API /// instance /// * `f` - Factory closure invoked once at startup and again on each `/new` /// command; receives the latest [`ForgeConfig`] so that config changes /// from `forge config set` are reflected in new conversations pub fn init(cli: Cli, config: ForgeConfig, f: F) -> Result { // Parse CLI arguments first to get flags let api = Arc::new(f(config.clone())); let env = api.environment(); let command = Arc::new(ForgeCommandManager::default()); let spinner = SharedSpinner::new(SpinnerManager::new(api.clone())); Ok(Self { state: UIState::new(env.clone()), api, new_api: Arc::new(f), console: Console::new( env.clone(), config.custom_history_path.clone(), command.clone(), ), cli, command, spinner, markdown: MarkdownFormat::new(), config, _guard: forge_tracker::init_tracing(env.log_path(), TRACKER.clone())?, }) } async fn prompt(&self) -> Result { // Get usage from current conversation if available. // Use the last message's usage for token count (context window size), // but replace cost with the accumulated session cost so the cost // shown reflects the total spend rather than just the last request. let usage = if let Some(conversation_id) = &self.state.conversation_id { self.api .conversation(conversation_id) .await .ok() .flatten() .and_then(|conv| { conv.usage().map(|mut u| { u.cost = conv.accumulated_cost(); u }) }) } else { None }; // Prompt the user for input let agent_id = self.api.get_active_agent().await.unwrap_or_default(); let model = self .get_agent_model(self.api.get_active_agent().await) .await; let reasoning_effort = self.api.get_reasoning_effort().await.ok().flatten(); let mut forge_prompt = ForgePrompt::new(self.state.cwd.clone(), agent_id); if let Some(u) = usage { forge_prompt.usage(u); } if let Some(m) = model { forge_prompt.model(m); } if let Some(e) = reasoning_effort { forge_prompt.reasoning_effort(e); } self.console.prompt(&mut forge_prompt).await } pub async fn run(&mut self) { match self.run_inner().await { Ok(_) => {} Err(error) => { tracing::error!(error = ?error); // Display the full error chain for better debugging let mut error_message = error.to_string(); let mut source = error.source(); while let Some(err) = source { error_message.push_str(&format!("\n Caused by: {}", err)); source = err.source(); } let _ = self.writeln_to_stderr(TitleFormat::error(error_message).display().to_string()); } } } async fn run_inner(&mut self) -> Result<()> { if let Some(cmd) = self.cli.subcommands.clone() { return self.handle_subcommands(cmd).await; } // Display the banner in dimmed colors since we're in interactive mode self.display_banner()?; self.init_state(true).await?; self.trace_user(); self.hydrate_caches(); self.init_conversation().await?; // Check for dispatch flag first if let Some(dispatch_json) = self.cli.event.clone() { return self.handle_dispatch(dispatch_json).await; } // Handle direct prompt or piped input if provided (raw text messages) let input = self.cli.prompt.clone().or(self.cli.piped_input.clone()); if let Some(input) = input { tracker::prompt(input.clone()); self.spinner.start(None)?; tokio::select! { _ = tokio::signal::ctrl_c() => { tracing::info!("User interrupted operation with Ctrl+C"); self.spinner.reset(); return Ok(()); } result = self.on_message(Some(input)) => { result?; } } return Ok(()); } // Get initial input from prompt // Prompt can fail if it doesn't have access to TTY. If it fails the first time, // we will stop everything and bubble up the error. let mut command = self.prompt().await; loop { match command { Ok(command) => { tokio::select! { _ = tokio::signal::ctrl_c() => { self.spinner.reset(); tracing::info!("User interrupted operation with Ctrl+C"); } result = self.on_command(command) => { match result { Ok(exit) => if exit {return Ok(())}, Err(error) => { if let Some(conversation_id) = self.state.conversation_id.as_ref() && let Some(conversation) = self.api.conversation(conversation_id).await.ok().flatten() { TRACKER.set_conversation(conversation).await; } tracker::error(&error); tracing::error!(error = ?error); self.spinner.stop(None)?; self.writeln_to_stderr(TitleFormat::error(format!("{error:?}")).display().to_string())?; }, } } } self.spinner.stop(None)?; } Err(error) => { tracker::error(&error); tracing::error!(error = ?error); self.spinner.stop(None)?; match error.downcast::() { Ok(error) => { Err(error)?; } Err(error) => self.writeln_to_stderr( TitleFormat::error(error.to_string()).display().to_string(), )?, } } } // Centralized prompt call at the end of the loop command = self.prompt().await; } } // Improve startup time by hydrating caches fn hydrate_caches(&self) { let api = self.api.clone(); tokio::spawn(async move { api.get_models().await }); let api = self.api.clone(); tokio::spawn(async move { api.get_tools().await }); let api = self.api.clone(); tokio::spawn(async move { api.get_agent_infos().await }); let api = self.api.clone(); tokio::spawn(async move { let _ = api.hydrate_channel(); }); } async fn handle_generate_conversation_id(&mut self) -> Result<()> { let conversation_id = forge_domain::ConversationId::generate(); println!("{}", conversation_id.into_string()); Ok(()) } async fn handle_subcommands(&mut self, subcommand: TopLevelCommand) -> anyhow::Result<()> { match subcommand { TopLevelCommand::Agent(agent_group) => { match agent_group.command { crate::cli::AgentCommand::List => { self.on_show_agents(agent_group.porcelain, false).await?; } } return Ok(()); } TopLevelCommand::List(list_group) => { let porcelain = list_group.porcelain; match list_group.command { ListCommand::Agent { custom } => { self.on_show_agents(porcelain, custom).await?; } ListCommand::Provider { types } => { self.on_show_providers(porcelain, types).await?; } ListCommand::Model => { self.on_show_models(porcelain).await?; } ListCommand::Command { custom } => { if custom { self.on_show_custom_commands(porcelain).await?; } else { self.on_show_commands(porcelain).await?; } } ListCommand::Config => { self.on_show_config(porcelain).await?; } ListCommand::Tool { agent } => { self.on_show_tools(agent, porcelain).await?; } ListCommand::Mcp => { self.on_show_mcp_servers(porcelain).await?; } ListCommand::Conversation { parent } => { if let Some(parent_id) = parent { let parent_conv = self.validate_conversation_exists(&parent_id).await?; let children = self.fetch_related_conversations(&parent_conv).await; if children.is_empty() { self.writeln_title(TitleFormat::info( "No child conversations found.", ))?; } else { let mut info = Info::new(); for conv in children.into_iter() { let title = conv .title .as_deref() .map(|t| t.to_string()) .unwrap_or_else(|| markers::EMPTY.to_string()); let duration = chrono::Utc::now().signed_duration_since( conv.metadata .updated_at .unwrap_or(conv.metadata.created_at), ); let duration = std::time::Duration::from_secs( (duration.num_minutes() * 60).max(0) as u64, ); let time_ago = if duration.is_zero() { "now".to_string() } else { format!("{} ago", humantime::format_duration(duration)) }; info = info .add_title(conv.id) .add_key_value("Title", title) .add_key_value("Updated", time_ago); } let porcelain = Porcelain::from(&info) .drop_col(3) .truncate(1, 60) .uppercase_headers(); self.writeln(porcelain)?; } } else { self.on_show_conversations(porcelain).await?; } } ListCommand::Cmd => { self.on_show_custom_commands(porcelain).await?; } ListCommand::Skill { custom } => { self.on_show_skills(porcelain, custom).await?; } ListCommand::File => { self.on_list_files(porcelain).await?; } } return Ok(()); } TopLevelCommand::Zsh(terminal_group) => { match terminal_group { crate::cli::ZshCommandGroup::Plugin => { self.on_zsh_plugin().await?; } crate::cli::ZshCommandGroup::Theme => { self.on_zsh_theme().await?; } crate::cli::ZshCommandGroup::Doctor => { self.on_zsh_doctor().await?; } crate::cli::ZshCommandGroup::Rprompt => { if let Some(text) = self.handle_zsh_rprompt_command().await { print!("{}", text) } return Ok(()); } crate::cli::ZshCommandGroup::Setup => { self.on_zsh_setup().await?; } crate::cli::ZshCommandGroup::Keyboard => { self.on_zsh_keyboard().await?; } crate::cli::ZshCommandGroup::Format { buffer } => { print!("{}", crate::zsh::paste::wrap_pasted_text(&buffer)); return Ok(()); } } return Ok(()); } TopLevelCommand::Mcp(mcp_command) => match mcp_command.command { McpCommand::Import(import_args) => { let scope: forge_domain::Scope = import_args.scope.into(); // Parse the incoming MCP configuration let incoming_config: forge_domain::McpConfig = serde_json::from_str(&import_args.json) .context("Failed to parse MCP configuration JSON. Expected format: {\"mcpServers\": {...}}")?; // Read only the scope-specific config (not merged) let mut scope_config = self.api.read_mcp_config(Some(&scope)).await?; // Merge the incoming servers with scope-specific config only let mut added_servers = Vec::new(); for (server_name, server_config) in incoming_config.mcp_servers { scope_config .mcp_servers .insert(server_name.clone(), server_config); added_servers.push(server_name); } // Write back to the specific scope only self.api.write_mcp_config(&scope, &scope_config).await?; // Log each added server after successful write for server_name in added_servers { self.writeln_title(TitleFormat::info(format!( "Added MCP server '{server_name}'" )))?; } } McpCommand::List => { self.on_show_mcp_servers(mcp_command.porcelain).await?; } McpCommand::Remove(rm) => { let name = forge_api::ServerName::from(rm.name); let scope: forge_domain::Scope = rm.scope.into(); // Read only the scope-specific config (not merged) let mut scope_config = self.api.read_mcp_config(Some(&scope)).await?; // Remove the server from scope-specific config only scope_config.mcp_servers.remove(&name); // Write back to the specific scope only self.api.write_mcp_config(&scope, &scope_config).await?; self.writeln_title(TitleFormat::info(format!("Removed server: {name}")))?; } McpCommand::Show(val) => { let name = forge_api::ServerName::from(val.name); let config = self.api.read_mcp_config(None).await?; let server = config .mcp_servers .get(&name) .ok_or(anyhow::anyhow!("Server not found"))?; // Get MCP servers to check for failures let tools = self.api.get_tools().await?; // Display server configuration self.writeln_title(TitleFormat::info(format!( "{name}: {}", format_mcp_server(server) )))?; // Display error if the server failed to initialize if let Some(error) = tools.mcp.get_failures().get(&name) { self.writeln_title(TitleFormat::error(error))?; } } McpCommand::Reload => { self.spinner.start(Some("Reloading MCPs"))?; self.api.reload_mcp().await?; self.writeln_title(TitleFormat::info("MCP reloaded"))?; } McpCommand::Login(args) => { self.handle_mcp_login(&args.name).await?; } McpCommand::Logout(args) => { self.handle_mcp_logout(&args.name).await?; } }, TopLevelCommand::Info { porcelain, conversation_id } => { // Only initialize state (agent/provider/model resolution). // Avoid on_new() which also spawns fire-and-forget background // tasks via hydrate_caches() that race with process exit and // cause "JoinHandle polled after completion" panics. self.init_state(false).await?; self.on_info(porcelain, conversation_id).await?; return Ok(()); } TopLevelCommand::Banner => { banner::display(true)?; return Ok(()); } TopLevelCommand::Config(config_group) => { self.handle_config_command(config_group.command.clone(), config_group.porcelain) .await?; return Ok(()); } TopLevelCommand::Provider(provider_group) => { self.handle_provider_command(provider_group).await?; return Ok(()); } TopLevelCommand::Conversation(conversation_group) => { self.handle_conversation_command(conversation_group).await?; return Ok(()); } TopLevelCommand::Suggest { prompt } => { self.on_cmd(UserPrompt::from(prompt)).await?; return Ok(()); } TopLevelCommand::Cmd(run_group) => { let porcelain = run_group.porcelain; match run_group.command { crate::cli::CmdCommand::List { custom } => { if custom { self.on_show_custom_commands(porcelain).await?; } else { self.on_show_commands(porcelain).await?; } } crate::cli::CmdCommand::Execute { commands: args } => { // Execute the custom command self.init_state(false).await?; // If conversation_id is provided, set it in CLI before initializing if let Some(ref cid) = run_group.conversation_id { self.cli.conversation_id = Some(*cid); } self.init_conversation().await?; self.spinner.start(None)?; // Join all args into a single command string let command_str = args.join(" "); // Add slash prefix if not present let command_with_slash = if command_str.starts_with('/') { command_str } else { format!("/{command_str}") }; let command = self.command.parse(&command_with_slash)?; self.on_command(command).await?; } } return Ok(()); } TopLevelCommand::Workspace(index_group) => { match index_group.command { crate::cli::WorkspaceCommand::Sync { path, init } => { self.on_index(path, init).await?; } crate::cli::WorkspaceCommand::List { porcelain } => { self.on_list_workspaces(porcelain).await?; } crate::cli::WorkspaceCommand::Query { query, path, limit, top_k, use_case, starts_with, ends_with, } => { let mut params = forge_domain::SearchParams::new(&query, &use_case).limit(limit); if let Some(k) = top_k { params = params.top_k(k); } if let Some(prefix) = starts_with { params = params.starts_with(prefix); } if let Some(suffix) = ends_with { params = params.ends_with(vec![suffix]); } self.on_query(path, params).await?; } crate::cli::WorkspaceCommand::Info { path } => { self.on_workspace_info(path).await?; } crate::cli::WorkspaceCommand::Delete { workspace_ids } => { self.on_delete_workspaces(workspace_ids).await?; } crate::cli::WorkspaceCommand::Status { path, porcelain } => { self.on_workspace_status(path, porcelain).await?; } crate::cli::WorkspaceCommand::Init { path, yes } => { self.on_workspace_init(path, yes).await?; } } return Ok(()); } TopLevelCommand::Commit(commit_group) => { self.init_state(false).await?; let preview = commit_group.preview; let result = self.handle_commit_command(commit_group).await?; if preview { self.writeln(&result.message)?; } else if !result.git_output.is_empty() { self.writeln_to_stderr(result.git_output.trim_end().to_string())?; } else { self.writeln_to_stderr(result.message.trim_end().to_string())?; } return Ok(()); } TopLevelCommand::Data(data_command_group) => { let mut stream = self.api.generate_data(data_command_group.into()).await?; while let Some(data) = stream.next().await { self.writeln(data?)?; } } TopLevelCommand::Vscode(vscode_command) => { match vscode_command { crate::cli::VscodeCommand::InstallExtension => { self.on_vscode_extension_install().await?; } } return Ok(()); } TopLevelCommand::Update(args) => { let update = forge_config::Update::default().auto_update(args.no_confirm); on_update(self.api.clone(), Some(&update)).await; return Ok(()); } TopLevelCommand::Setup => { self.on_zsh_setup().await?; return Ok(()); } TopLevelCommand::Doctor => { self.on_zsh_doctor().await?; return Ok(()); } TopLevelCommand::Logs(args) => { let log_dir = self.api.environment().log_path(); crate::logs::run(args, log_dir).await?; return Ok(()); } TopLevelCommand::Select(cmd) => { if !matches!(&cmd.command, SelectCommand::File { .. }) { self.init_state(false).await?; } match &cmd.command { SelectCommand::File { query } => { if let Some(file) = crate::completer::select_workspace_file(&self.state.cwd, query.clone())? { self.writeln(file)?; } } SelectCommand::Model { query } => { if let Some((model_id, provider_id)) = self.select_model(None, query.clone()).await? { self.writeln(model_id.as_str())?; self.writeln(provider_id.as_ref())?; } } SelectCommand::Agent { query } => { if let Some(agent_id) = self.select_agent(query.clone()).await? { self.writeln(agent_id.as_str())?; } } SelectCommand::Provider { query, configured } => { if let Some(provider) = self.select_provider(query.clone(), *configured).await? { self.writeln(provider.id().as_ref())?; } } SelectCommand::ReasoningEffort { query } => { if let Some(effort) = self .select_reasoning_effort("Reasoning Effort", query.clone()) .await? { self.writeln(effort)?; } } SelectCommand::Command { query } => { let rows = Self::porcelain_rows(self.commands_porcelain().await?)?; if !rows.is_empty() { self.select_row_output("Command", query.clone(), rows)?; } } SelectCommand::Conversation { query, parent } => { let conversations = if let Some(parent_id) = parent { let parent_conv = self.validate_conversation_exists(parent_id).await?; self.fetch_related_conversations(&parent_conv).await } else { let max_conversations = self.config.max_conversations; let conversations = self.api.get_conversations(Some(max_conversations)).await?; Self::user_initiated_conversations(conversations) }; if !conversations.is_empty() && let Some(conversation) = ConversationSelector::select_conversation( &conversations, self.state.conversation_id, query.clone(), ) .await? { self.writeln(conversation.id)?; } } } return Ok(()); } } Ok(()) } async fn handle_conversation_command( &mut self, conversation_group: crate::cli::ConversationCommandGroup, ) -> anyhow::Result<()> { match conversation_group.command { ConversationCommand::List { porcelain } => { self.on_show_conversations(porcelain).await?; } ConversationCommand::New => { self.handle_generate_conversation_id().await?; } ConversationCommand::Dump { id, html } => { self.validate_conversation_exists(&id).await?; let original_id = self.state.conversation_id; self.state.conversation_id = Some(id); self.spinner.start(Some("Dumping"))?; self.on_dump(html).await?; self.state.conversation_id = original_id; } ConversationCommand::Compact { id } => { self.validate_conversation_exists(&id).await?; let original_id = self.state.conversation_id; self.state.conversation_id = Some(id); self.spinner.start(Some("Compacting"))?; self.on_compaction().await?; self.state.conversation_id = original_id; } ConversationCommand::Delete { id } => { let conversation_id = ConversationId::parse(&id).context(format!("Invalid conversation ID: {id}"))?; self.validate_conversation_exists(&conversation_id).await?; self.on_conversation_delete(conversation_id).await?; } ConversationCommand::Retry { id } => { self.validate_conversation_exists(&id).await?; let original_id = self.state.conversation_id; self.state.conversation_id = Some(id); self.spinner.start(None)?; self.on_message(None).await?; self.state.conversation_id = original_id; } ConversationCommand::Resume { id } => { self.validate_conversation_exists(&id).await?; self.state.conversation_id = Some(id); self.writeln_title(TitleFormat::info(format!("Resumed conversation: {id}")))?; // Interactive mode will be handled by the main loop } ConversationCommand::Show { id, md } => { let conversation = self.validate_conversation_exists(&id).await?; self.on_show_last_message(conversation, md).await?; } ConversationCommand::Info { id } => { let conversation = self.validate_conversation_exists(&id).await?; self.on_show_conv_info(conversation).await?; } ConversationCommand::Stats { id, porcelain } => { let conversation = self.validate_conversation_exists(&id).await?; self.on_show_conv_stats(conversation, porcelain).await?; } ConversationCommand::Clone { id, porcelain } => { let conversation = self.validate_conversation_exists(&id).await?; self.spinner.start(Some("Cloning"))?; self.on_clone_conversation(conversation, porcelain).await?; self.spinner.stop(None)?; } ConversationCommand::Rename { id, name } => { self.validate_conversation_exists(&id).await?; let name = name.trim().to_string(); if name.is_empty() { return Err(anyhow::anyhow!( "Please provide a name for the conversation." )); } self.api.rename_conversation(&id, name.clone()).await?; self.writeln_title(TitleFormat::info(format!( "Conversation renamed to '{}'", name.bold() )))?; } } Ok(()) } async fn validate_conversation_exists( &self, conversation_id: &ConversationId, ) -> anyhow::Result { let conversation = self.api.conversation(conversation_id).await?; conversation.ok_or_else(|| anyhow::anyhow!("Conversation '{conversation_id}' not found")) } async fn on_conversation_delete( &mut self, conversation_id: ConversationId, ) -> anyhow::Result<()> { self.spinner.start(Some("Deleting conversation"))?; self.api.delete_conversation(&conversation_id).await?; self.spinner.stop(None)?; self.writeln_title(TitleFormat::debug(format!( "Successfully deleted conversation '{}'", conversation_id )))?; Ok(()) } /// Handle `mcp login ` command. /// /// Triggers the OAuth authentication flow for the specified MCP server. /// Uses the API layer which delegates to rmcp's OAuth state machine /// for metadata discovery, dynamic registration, PKCE, and token exchange. async fn handle_mcp_login(&mut self, name: &str) -> anyhow::Result<()> { let server_name = forge_api::ServerName::from(name.to_string()); let config = self.api.read_mcp_config(None).await?; let server = config.mcp_servers.get(&server_name); match server { Some(forge_domain::McpServerConfig::Http(http)) => { // Check auth status first let status = self.api.mcp_auth_status(&http.url).await?; if status == "authenticated" { self.writeln_title(TitleFormat::info( format!("MCP server '{}' is already authenticated. Use 'mcp logout {}' first to re-authenticate.", name, name) ))?; return Ok(()); } // Force re-auth by removing any stale credentials let _ = self.api.mcp_logout(Some(&http.url)).await; // Run the OAuth flow (opens browser, waits for callback) match self.api.mcp_auth(&http.url).await { Ok(()) => { self.writeln_title(TitleFormat::info(format!( "Successfully authenticated with MCP server '{}'", name )))?; // Reload MCP to reconnect with new credentials self.spinner.start(Some("Reloading MCPs"))?; match self.api.reload_mcp().await { Ok(()) => { self.writeln_title(TitleFormat::info("MCP reloaded"))?; } Err(e) => { self.writeln_title(TitleFormat::error(format!( "MCP reload failed: {}", e )))?; } } } Err(e) => { self.writeln_title(TitleFormat::error(format!( "Authentication with MCP server '{}' failed: {}", name, e )))?; } } } Some(_) => { self.writeln_title(TitleFormat::error(format!( "MCP server '{}' is not an HTTP server (OAuth only applies to HTTP servers)", name )))?; } None => { self.writeln_title(TitleFormat::error(format!( "MCP server '{}' not found. Use 'mcp list' to see available servers.", name )))?; } } Ok(()) } /// Handle `mcp logout ` command. /// /// Removes stored OAuth credentials for the specified MCP server /// or all servers if "all" is specified. /// Automatically reloads MCPs after logout to reflect auth state change. async fn handle_mcp_logout(&mut self, name: &str) -> anyhow::Result<()> { if name == "all" { self.api.mcp_logout(None).await?; self.writeln_title(TitleFormat::info("Removed all MCP OAuth credentials"))?; } else { let server_name = forge_api::ServerName::from(name.to_string()); let config = self.api.read_mcp_config(None).await?; let server = config.mcp_servers.get(&server_name); match server { Some(forge_domain::McpServerConfig::Http(http)) => { self.api.mcp_logout(Some(&http.url)).await?; self.writeln_title(TitleFormat::info(format!( "Removed OAuth credentials for MCP server '{}'", name )))?; } Some(_) => { self.writeln_title(TitleFormat::error(format!( "MCP server '{}' is not an HTTP server", name )))?; return Ok(()); } None => { self.writeln_title(TitleFormat::error(format!( "MCP server '{}' not found. Use 'mcp list' to see available servers.", name )))?; return Ok(()); } } } // Reload MCPs to reflect auth state change self.spinner.start(Some("Reloading MCPs"))?; self.api.reload_mcp().await?; self.writeln_title(TitleFormat::info("MCP reloaded"))?; Ok(()) } async fn handle_provider_command( &mut self, provider_group: crate::cli::ProviderCommandGroup, ) -> anyhow::Result<()> { use crate::cli::ProviderCommand; match provider_group.command { ProviderCommand::Login { provider } => { self.handle_provider_login(provider.as_ref()).await?; } ProviderCommand::Logout { provider } => { self.handle_provider_logout(provider.as_ref()).await?; } ProviderCommand::List { types } => { self.on_show_providers(provider_group.porcelain, types) .await?; } } Ok(()) } async fn handle_provider_login( &mut self, provider_id: Option<&ProviderId>, ) -> anyhow::Result<()> { // Get the provider to login to let any_provider = if let Some(id) = provider_id { // Specific provider requested self.api.get_provider(id).await? } else { // Fetch all providers for selection (no type filter, like shell :login) let providers = self.api.get_providers().await?; match self.select_provider_from_list(providers, "Provider", None, None)? { Some(provider) => provider, None => { self.writeln_title(TitleFormat::info("Cancelled"))?; return Ok(()); } } }; // For login, always configure (even if already configured) to allow // re-authentication let provider = match self .configure_provider(any_provider.id(), any_provider.auth_methods().to_vec()) .await? { Some(provider) => provider, None => return Ok(()), }; // Set as default and handle model selection self.finalize_provider_activation(provider, None).await } async fn handle_provider_logout( &mut self, provider_id: Option<&ProviderId>, ) -> anyhow::Result { // If provider_id is specified, logout from that specific provider if let Some(id) = provider_id { let provider = self.api.get_provider(id).await?; if !provider.is_configured() { return Err(anyhow::anyhow!("Provider '{id}' is not configured")); } self.api.remove_provider(id).await?; self.writeln_title(TitleFormat::debug(format!( "Successfully logged out from {id}" )))?; return Ok(true); } // Fetch and filter configured providers (like shell :logout filters to status // [yes]) let configured_providers: Vec = self .api .get_providers() .await? .into_iter() .filter(|p| p.is_configured()) .collect(); if configured_providers.is_empty() { self.writeln_title(TitleFormat::info("No configured providers found"))?; return Ok(false); } match self.select_provider_from_list(configured_providers, "Provider", None, None)? { Some(provider) => { let provider_id = provider.id(); self.api.remove_provider(&provider_id).await?; self.writeln_title(TitleFormat::debug(format!( "Successfully logged out from {provider_id}" )))?; return Ok(true); } None => { self.writeln_title(TitleFormat::info("Cancelled"))?; } } Ok(false) } async fn handle_commit_command( &mut self, commit_group: CommitCommandGroup, ) -> anyhow::Result { self.spinner.start(Some("Creating commit"))?; // Convert Vec to Option by joining with spaces let additional_context = if commit_group.text.is_empty() { None } else { Some(commit_group.text.join(" ")) }; // Handle the commit command let result = self .api .commit( commit_group.preview, commit_group.max_diff_size, commit_group.diff, additional_context, ) .await; match result { Ok(result) => { self.spinner.stop(None)?; Ok(result) } Err(e) => { self.spinner.stop(None)?; Err(e) } } } /// Builds an Info structure for agents with their details async fn build_agents_info(&self, custom: bool) -> anyhow::Result { let mut agents = self.api.get_agents().await?; // Sort agents alphabetically by ID agents.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str())); // Filter agents based on custom flag if custom { agents.retain(|agent| agent.path.is_some()); } let mut info = Info::new(); for agent in agents.iter() { let id = agent.id.as_str().to_string(); let title = agent .title .as_deref() .map(|title| title.lines().collect::>().join(" ")); // Get provider and model for this agent let provider_name = match self.get_provider(Some(agent.id.clone())).await { Ok(p) => p.id.to_string(), Err(e) => format!("Error: [{}]", e), }; let model_name = agent.model.as_str().to_string(); let reasoning = if agent .reasoning .as_ref() .and_then(|a| a.enabled) .unwrap_or_default() { status::YES } else { status::NO }; let location = agent .path .as_ref() .map(|s| s.to_string()) .unwrap_or_else(|| markers::BUILT_IN.to_string()); info = info .add_title(id.to_case(Case::UpperSnake)) .add_key_value("Id", id) .add_key_value("Title", title) .add_key_value("Location", location) .add_key_value("Provider", provider_name) .add_key_value("Model", model_name) .add_key_value("Reasoning Enabled", reasoning); } Ok(info) } async fn on_show_agents(&mut self, porcelain: bool, custom: bool) -> anyhow::Result<()> { let agents = self.api.get_agent_infos().await?; if agents.is_empty() { return Ok(()); } let info = self.build_agents_info(custom).await?; if porcelain { let porcelain = Porcelain::from(&info) .drop_col(0) .truncate(3, 60) .uppercase_headers(); self.writeln(porcelain)?; } else { self.writeln(info)?; } Ok(()) } /// Lists all the providers async fn on_show_providers( &mut self, porcelain: bool, types: Vec, ) -> anyhow::Result<()> { let mut providers = self.api.get_providers().await?; // Filter by type if specified if !types.is_empty() { providers.retain(|p| types.contains(p.provider_type())); } if providers.is_empty() { return Ok(()); } let mut info = Info::new(); for provider in providers.iter() { let id: &str = &provider.id(); let display_name = provider.id().to_string(); let domain = if let Some(url) = provider.url() { url.domain().map(|d| d.to_string()).unwrap_or_default() } else { markers::EMPTY.to_string() }; let configured = provider.is_configured(); info = info .add_title(id.to_case(Case::UpperSnake)) .add_key_value("name", display_name) .add_key_value("id", id) .add_key_value("host", domain); if configured { info = info.add_key_value("logged in", status::YES); }; } if porcelain { let porcelain = Porcelain::from(&info).drop_col(0).uppercase_headers(); self.writeln(porcelain)?; } else { self.writeln(info)?; } Ok(()) } /// Lists all the models async fn on_show_models(&mut self, porcelain: bool) -> anyhow::Result<()> { self.spinner.start(Some("Fetching Models"))?; let mut all_provider_models = match self.api.get_all_provider_models().await { Ok(provider_models) => provider_models, Err(err) => { self.spinner.stop(None)?; return Err(err); } }; if all_provider_models.is_empty() { return Ok(()); } // Sort models and then providers all_provider_models .iter_mut() .for_each(|pm| pm.models.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()))); all_provider_models.sort_by(|a, b| a.provider_id.as_ref().cmp(b.provider_id.as_ref())); let mut info = Info::new(); for pm in &all_provider_models { let provider_id: &str = &pm.provider_id; let provider_display = pm.provider_id.to_string(); for model in &pm.models { let id = model.id.to_string(); info = info .add_title(&id) .add_key_value("Model", model.name.as_ref().unwrap_or(&id)) .add_key_value("Provider", &provider_display) .add_key_value("Provider Id", provider_id); // Add context length if available, otherwise use "unknown" if let Some(limit) = model.context_length { let context = if limit >= 1_000_000 { format!("{}M", limit / 1_000_000) } else if limit >= 1000 { format!("{}k", limit / 1000) } else { format!("{limit}") }; info = info.add_key_value("Context Window", context); } else { info = info.add_key_value("Context Window", markers::EMPTY) } // Add tools support indicator if explicitly supported if let Some(supported) = model.tools_supported { info = info.add_key_value( "Tool Supported", if supported { status::YES } else { status::NO }, ) } else { info = info.add_key_value("Tools", markers::EMPTY) } // Add image modality support indicator let supports_image = model .input_modalities .contains(&forge_domain::InputModality::Image); info = info.add_key_value( "Image", if supports_image { status::YES } else { status::NO }, ); } } if porcelain { self.writeln(Porcelain::from(&info).truncate(1, 40).uppercase_headers())?; } else { self.writeln(info)?; } Ok(()) } async fn commands_porcelain(&self) -> Result { let custom_commands = self.api.get_commands().await?; let mut info = Info::new(); for cmd in AppCommand::iter().filter(|c| !c.is_internal() && !c.is_agent_switch()) { info = info .add_title(cmd.name()) .add_key_value("type", CommandType::Command) .add_key_value("description", cmd.usage()); } info = info .add_title("ask") .add_key_value("type", CommandType::Agent) .add_key_value( "description", "Research and investigation agent [alias for: sage]", ) .add_title("plan") .add_key_value("type", CommandType::Agent) .add_key_value( "description", "Planning and strategy agent [alias for: muse]", ); let agent_infos = self.api.get_agent_infos().await?; for agent_info in agent_infos { let title = agent_info .title .map(|title| title.lines().collect::>().join(" ")); info = info .add_title(agent_info.id.to_string()) .add_key_value("type", CommandType::Agent) .add_key_value("description", title); } for command in custom_commands { info = info .add_title(command.name.clone()) .add_key_value("type", CommandType::Custom) .add_key_value("description", command.description.clone()); } Ok(Porcelain::from(&info) .uppercase_headers() .sort_by(&[1, 0]) .to_case(&[1], Case::UpperSnake) .map_col(0, |col| { if col.as_deref() == Some(headers::ID) { Some("COMMAND".to_string()) } else { col } })) } /// Lists all the commands async fn on_show_commands(&mut self, porcelain: bool) -> anyhow::Result<()> { let custom_commands = self.api.get_commands().await?; if porcelain { self.writeln(self.commands_porcelain().await?)?; } else { // Non-porcelain: render in the same flat format as :help in the REPL. let command_manager = ForgeCommandManager::default(); command_manager.register_all(custom_commands); let info = Info::from(&command_manager); self.writeln(info)?; } Ok(()) } /// Lists only custom commands (used by `forge run`) async fn on_show_custom_commands(&mut self, porcelain: bool) -> anyhow::Result<()> { let custom_commands = self.api.get_commands().await?; let mut info = Info::new(); for command in custom_commands { info = info .add_title(command.name.clone()) .add_key_value("description", command.description.clone()); } if porcelain { let porcelain = Porcelain::from(&info).uppercase_headers(); self.writeln(porcelain)?; } else { self.writeln(info)?; } Ok(()) } /// Lists available skills async fn on_show_skills(&mut self, porcelain: bool, custom: bool) -> anyhow::Result<()> { let skills = self.api.get_skills().await?; // Filter skills based on custom flag let skills = if custom { skills .into_iter() .filter(|skill| skill.path.is_some()) .collect() } else { skills }; let mut info = Info::new(); let env = self.api.environment(); for skill in skills { info = info .add_title(skill.name.clone().to_case(Case::Sentence).to_uppercase()) .add_key_value("name", skill.name); if let Some(path) = skill.path { info = info.add_key_value("path", format_display_path(&path, &env.cwd)); } info = info.add_key_value("description", skill.description); } if porcelain { let porcelain = Porcelain::from(&info) .drop_col(0) .truncate(2, 60) .uppercase_headers(); self.writeln(porcelain)?; } else { self.writeln(info)?; } Ok(()) } /// Lists files and directories in the current workspace. /// /// Uses the same `Walker::max_all()` configuration as the REPL file picker /// and the shell plugin (`fd --type f --type d --hidden --exclude .git`): /// hidden files included, respects `.gitignore`, directories suffixed with /// `/`. async fn on_list_files(&mut self, porcelain: bool) -> anyhow::Result<()> { let env = self.api.environment(); let files = Walker::max_all() .cwd(env.cwd.clone()) .get() .await .context("Failed to walk workspace files")?; if porcelain { for file in files { self.writeln(file.path)?; } } else { let mut info = Info::new(); for file in &files { info = info.add_key_value("path", file.path.clone()); } self.writeln(info)?; } Ok(()) } /// Lists current configuration values async fn on_show_config(&mut self, porcelain: bool) -> anyhow::Result<()> { // Get the effective resolved config let config = &self.config; // Serialize to TOML pretty format let config_toml = toml_edit::ser::to_string_pretty(config) .map_err(|e| anyhow::anyhow!("Failed to serialize config: {}", e))?; if porcelain { // For porcelain mode, output raw TOML without highlighting self.writeln(config_toml)?; } else { // For human-readable mode, add a title and syntax-highlight the TOML self.writeln("\nCONFIGURATION\n".bold().dimmed())?; let highlighted = forge_display::SyntaxHighlighter::default().highlight(&config_toml, "toml"); self.writeln(highlighted)?; } Ok(()) } /// Displays available tools for the current agent async fn on_show_tools(&mut self, agent_id: AgentId, porcelain: bool) -> anyhow::Result<()> { self.spinner.start(Some("Loading"))?; let all_tools = self.api.get_tools().await?; let agents = self.api.get_agents().await?; let agent = agents.into_iter().find(|agent| agent.id == agent_id); let agent_tools = if let Some(agent) = agent { let resolver = ToolResolver::new(all_tools.clone().into()); resolver .resolve(&agent) .into_iter() .map(|def| def.name.clone()) .collect() } else { Vec::new() }; let info = format_tools(&agent_tools, &all_tools); if porcelain { self.writeln( Porcelain::from(&info) .into_long() .drop_col(1) .uppercase_headers(), )?; } else { self.writeln(info)?; } Ok(()) } /// Displays all MCP servers with their available tools async fn on_show_mcp_servers(&mut self, porcelain: bool) -> anyhow::Result<()> { self.spinner.start(Some("Loading MCP servers"))?; let mcp_servers = self.api.read_mcp_config(None).await?; let all_tools = self.api.get_tools().await?; let mut info = Info::new(); for (name, server) in mcp_servers.mcp_servers { let label = match server { forge_domain::McpServerConfig::Stdio(_) => "Command", forge_domain::McpServerConfig::Http(_) => "URL", }; info = info .add_title(name.to_uppercase()) .add_key_value("Type", server.server_type()) .add_key_value(label, format_mcp_server(&server)); // Add headers for HTTP servers if present if let Some(headers) = format_mcp_headers(&server) { info = info.add_key_value("Headers", headers); } if server.is_disabled() { info = info.add_key_value("Status", status::NO); } // Add tools for this MCP server if let Some(tools) = all_tools.mcp.get_servers().get(&name) && !tools.is_empty() { info = info.add_key_value("Tools", tools.len().to_string()); for tool in tools { info = info.add_value(tool.name.to_string()); } } } if porcelain { self.writeln(Porcelain::from(&info).uppercase_headers().truncate(3, 60))?; } else { self.writeln(info)?; } // Show failed MCP servers if !porcelain && !all_tools.mcp.get_failures().is_empty() { self.writeln("MCP FAILURES\n".dimmed().bold())?; for error in all_tools.mcp.get_failures().values() { let error = style(error).red(); self.writeln(error)?; } } Ok(()) } async fn on_info( &mut self, porcelain: bool, conversation_id: Option, ) -> anyhow::Result<()> { let mut info = Info::new(); // Fetch conversation let conversation = match conversation_id { Some(conversation_id) => self.api.conversation(&conversation_id).await.ok().flatten(), None => None, }; // Fetch agent let agent = self.api.get_active_agent().await; // Fetch model (resolved with default model if unset) let model = self.get_agent_model(agent.clone()).await; // Fetch agent-specific provider or default provider if unset let agent_provider = self.get_provider(agent.clone()).await.ok(); // Fetch default provider (could be different from the set provider) let default_provider = self.api.get_default_provider().await.ok(); // Add agent information info = info.add_title("AGENT"); if let Some(agent) = agent { info = info.add_key_value("ID", agent.as_str().to_uppercase()); } // Add model information if available if let Some(model) = model { info = info.add_key_value("Model", model.as_str()); } // Add provider information match (default_provider, agent_provider) { (Some(default), Some(agent_specific)) if default.id != agent_specific.id => { // Show both providers if they're different info = info.add_key_value("Agent Provider (URL)", agent_specific.url.as_str()); if let Some(api_key) = agent_specific.api_key() { info = info.add_key_value("Agent API Key", truncate_key(api_key.as_str())); } info = info.add_key_value("Default Provider (URL)", default.url.as_str()); if let Some(api_key) = default.api_key() { info = info.add_key_value("Default API Key", truncate_key(api_key.as_str())); } } (Some(provider), _) | (_, Some(provider)) => { // Show single provider (either default or agent-specific) info = info.add_key_value("Provider (URL)", provider.url.as_str()); if let Some(api_key) = provider.api_key() { info = info.add_key_value("API Key", truncate_key(api_key.as_str())); } } _ => { // No provider available } } // Add conversation information if available if let Some(conversation) = conversation { info = info.extend(Info::from(&conversation)); } else { info = info.extend(Info::new().add_title("CONVERSATION").add_key("ID")); } if porcelain { self.writeln(Porcelain::from(&info).into_long().skip(1))?; } else { self.writeln(info)?; } Ok(()) } /// Generate ZSH plugin script async fn on_zsh_plugin(&self) -> anyhow::Result<()> { let plugin = crate::zsh::generate_zsh_plugin()?; println!("{plugin}"); Ok(()) } /// Generate ZSH theme async fn on_zsh_theme(&self) -> anyhow::Result<()> { let theme = crate::zsh::generate_zsh_theme()?; println!("{theme}"); Ok(()) } /// Run ZSH environment diagnostics async fn on_zsh_doctor(&mut self) -> anyhow::Result<()> { // Stop spinner before streaming output to avoid interference self.spinner.stop(None)?; // Stream the diagnostic output in real-time crate::zsh::run_zsh_doctor()?; Ok(()) } /// Show ZSH keyboard shortcuts async fn on_zsh_keyboard(&mut self) -> anyhow::Result<()> { // Stop spinner before streaming output to avoid interference self.spinner.stop(None)?; // Stream the keyboard shortcuts output in real-time crate::zsh::run_zsh_keyboard()?; Ok(()) } /// Install the Forge VS Code extension async fn on_vscode_extension_install(&mut self) -> anyhow::Result<()> { self.spinner .start(Some("Installing Forge VS Code extension"))?; match crate::vscode::install_extension() { Ok(true) => { self.spinner.stop(None)?; self.writeln_title(TitleFormat::info( "Forge VS Code extension installed successfully", ))?; } Ok(false) => { self.spinner.stop(None)?; self.writeln_title(TitleFormat::error( "Failed to install Forge VS Code extension.", ))?; } Err(e) => { self.spinner.stop(None)?; self.writeln_title(TitleFormat::error(format!( "Failed to install Forge VS Code extension: {e}" )))?; } } Ok(()) } /// Setup ZSH integration by updating .zshrc async fn on_zsh_setup(&mut self) -> anyhow::Result<()> { // Check nerd font support println!(); println!( "{} {} {}", "󱙺".bold(), "FORGE 33.0k".bold(), " tonic-1.0".cyan() ); let can_see_nerd_fonts = ForgeWidget::confirm("Can you see all the icons clearly without any overlap?") .with_default(true) .prompt()?; let disable_nerd_font = match can_see_nerd_fonts { Some(true) => { println!(); false } Some(false) => { println!(); println!(" {} Nerd Fonts will be disabled", "⚠".yellow()); println!(); println!(" You can enable them later by:"); println!( " 1. Installing a Nerd Font from: {}", "https://www.nerdfonts.com/".dimmed() ); println!(" 2. Configuring your terminal to use a Nerd Font"); println!( " 3. Removing {} from your ~/.zshrc", "NERD_FONT=0".dimmed() ); println!(); true } None => { // User interrupted, default to not disabling println!(); false } }; // Ask about editor preference let editor_options = vec![ "Use system default ($EDITOR)", "VS Code (code --wait)", "Vim", "Neovim (nvim)", "Nano", "Emacs", "Sublime Text (subl --wait)", "Skip - I'll configure it later", ]; let selected_editor = ForgeWidget::select( "Which editor would you like to use for editing prompts?", editor_options, ) .prompt()?; let forge_editor = match selected_editor { Some("Use system default ($EDITOR)") => None, Some("VS Code (code --wait)") => Some("code --wait"), Some("Vim") => Some("vim"), Some("Neovim (nvim)") => Some("nvim"), Some("Nano") => Some("nano"), Some("Emacs") => Some("emacs"), Some("Sublime Text (subl --wait)") => Some("subl --wait"), Some("Skip - I'll configure it later") => None, _ => None, }; // Setup ZSH integration with nerd font and editor configuration self.spinner.start(Some("Configuring ZSH"))?; let result = crate::zsh::setup_zsh_integration(disable_nerd_font, forge_editor)?; self.spinner.stop(None)?; // Log backup creation if one was made if let Some(backup_path) = result.backup_path { self.writeln_title(TitleFormat::debug(format!( "backup created at {}", backup_path.display() )))?; } self.writeln_title(TitleFormat::info(result.message))?; self.writeln_title(TitleFormat::debug("running forge zsh doctor"))?; println!(); let doctor_result = self.on_zsh_doctor().await; if doctor_result.is_ok() { self.writeln_title(TitleFormat::action( "run `exec zsh` now (or open a new terminal window) to load the updated shell config", ))?; self.writeln_title(TitleFormat::action( "run `: Hi` after restarting your shell to confirm everything works", ))?; } doctor_result } /// Handle the cmd command - generates shell command from natural language async fn on_cmd(&mut self, prompt: UserPrompt) -> anyhow::Result<()> { self.spinner.start(Some("Generating"))?; match self.api.generate_command(prompt).await { Ok(command) => { self.spinner.stop(None)?; self.writeln(command)?; Ok(()) } Err(err) => { self.spinner.stop(None)?; Err(err) } } } async fn list_conversations(&mut self) -> anyhow::Result<()> { self.spinner.start(Some("Loading Conversations"))?; let max_conversations = self.config.max_conversations; let conversations = self.api.get_conversations(Some(max_conversations)).await?; let conversations = Self::user_initiated_conversations(conversations); self.spinner.stop(None)?; if conversations.is_empty() { self.writeln_title(TitleFormat::error( "No conversations found in this workspace.", ))?; return Ok(()); } if let Some(conversation) = ConversationSelector::select_conversation( &conversations, self.state.conversation_id, None, ) .await? { let conversation_id = conversation.id; self.state.conversation_id = Some(conversation_id); // Show conversation content self.on_show_last_message(conversation, false).await?; // Print log about conversation switching self.writeln_title(TitleFormat::info(format!( "Switched to conversation {}", conversation_id.into_string().bold() )))?; // Show conversation info self.on_info(false, Some(conversation_id)).await?; } Ok(()) } async fn on_show_conversations(&mut self, porcelain: bool) -> anyhow::Result<()> { let max_conversations = self.config.max_conversations; let conversations = self.api.get_conversations(Some(max_conversations)).await?; let conversations = Self::user_initiated_conversations(conversations); if conversations.is_empty() { return Ok(()); } let mut info = Info::new(); for conv in conversations.into_iter() { if conv.context.is_none() { continue; } let title = conv .title .as_deref() .map(|t| t.to_string()) .unwrap_or_else(|| markers::EMPTY.to_string()); // Format time using humantime library (same as conversation_selector.rs) let duration = chrono::Utc::now().signed_duration_since( conv.metadata.updated_at.unwrap_or(conv.metadata.created_at), ); let duration = std::time::Duration::from_secs((duration.num_minutes() * 60).max(0) as u64); let time_ago = if duration.is_zero() { "now".to_string() } else { format!("{} ago", humantime::format_duration(duration)) }; // Add conversation: Title=, Updated=<time_ago>, with ID as section title info = info .add_title(conv.id) .add_key_value("Title", title) .add_key_value("Updated", time_ago); } // In porcelain mode, skip the top-level "SESSIONS" title if porcelain { let porcelain = Porcelain::from(&info) .drop_col(3) .truncate(1, 60) .uppercase_headers(); self.writeln(porcelain)?; } else { self.writeln(info)?; } Ok(()) } fn user_initiated_conversations(conversations: Vec<Conversation>) -> Vec<Conversation> { let related_ids: HashSet<ConversationId> = conversations .iter() .flat_map(Conversation::related_conversation_ids) .collect(); conversations .into_iter() .filter(|conversation| { conversation .context .as_ref() .and_then(|context| context.initiator.as_deref()) .is_none_or(|initiator| initiator == "user") && !related_ids.contains(&conversation.id) }) .collect() } async fn on_command(&mut self, command: AppCommand) -> anyhow::Result<bool> { match command { AppCommand::Conversations { id } => { if let Some(raw_id) = id { let conversation_id = ConversationId::parse(&raw_id) .context(format!("Invalid conversation ID: {raw_id}"))?; let conversation = self.validate_conversation_exists(&conversation_id).await?; self.state.conversation_id = Some(conversation_id); self.on_show_last_message(conversation, false).await?; self.writeln_title(TitleFormat::info(format!( "Switched to conversation {}", conversation_id.into_string().bold() )))?; self.on_info(false, Some(conversation_id)).await?; } else { self.list_conversations().await?; } } AppCommand::ConversationTree => { let conversation_id = self .state .conversation_id .ok_or_else(|| anyhow::anyhow!("No active conversation"))?; let parent = self.validate_conversation_exists(&conversation_id).await?; let children = self.fetch_related_conversations(&parent).await; if children.is_empty() { self.writeln_title(TitleFormat::info("No child conversations found."))?; } else if let Some(conversation) = ConversationSelector::select_conversation( &children, self.state.conversation_id, None, ) .await? { let conversation_id = conversation.id; self.state.conversation_id = Some(conversation_id); self.on_show_last_message(conversation, false).await?; self.writeln_title(TitleFormat::info(format!( "Switched to conversation {}", conversation_id.into_string().bold() )))?; self.on_info(false, Some(conversation_id)).await?; } } AppCommand::Compact => { self.spinner.start(Some("Compacting"))?; self.on_compaction().await?; } AppCommand::Delete => { self.handle_delete_conversation().await?; } AppCommand::Rename { ref name } => { self.handle_rename_conversation(name.join(" ")).await?; } AppCommand::Dump { html, .. } => { self.spinner.start(Some("Dumping"))?; self.on_dump(html).await?; } AppCommand::New => { self.on_new().await?; } AppCommand::Info => { self.on_info(false, self.state.conversation_id).await?; } AppCommand::Usage => { self.on_usage().await?; } AppCommand::Message(ref content) => { self.spinner.start(None)?; self.on_message(Some(content.clone())).await?; } AppCommand::Forge => { self.on_agent_change(AgentId::FORGE).await?; } AppCommand::Muse => { self.on_agent_change(AgentId::MUSE).await?; } AppCommand::Sage => { self.on_agent_change(AgentId::SAGE).await?; } AppCommand::Help => { let info = Info::from(self.command.as_ref()); self.writeln(info)?; } AppCommand::Tools => { let agent_id = self.api.get_active_agent().await.unwrap_or_default(); self.on_show_tools(agent_id, false).await?; } AppCommand::Update => { on_update(self.api.clone(), None).await; } AppCommand::Exit => { return Ok(true); } AppCommand::Custom(event) => { self.spinner.start(None)?; self.on_custom_event(event.into()).await?; } AppCommand::Model => { self.on_model_selection(None).await?; } AppCommand::Shell(ref command) => { self.api.execute_shell_command_raw(command).await?; } AppCommand::Commit { max_diff_size, .. } => { let args = CommitCommandGroup { preview: false, max_diff_size: max_diff_size.or(Some(100_000)), diff: None, text: Vec::new(), }; let result = self.handle_commit_command(args).await?; if !result.git_output.is_empty() { self.writeln(result.git_output.trim_end())?; } else { self.writeln(result.message.trim_end())?; } } AppCommand::Agent => { if let Some(selected_agent) = self.select_agent(None).await? { self.on_agent_change(selected_agent).await?; } } AppCommand::Login => { self.handle_provider_login(None).await?; } AppCommand::Logout => { return self.handle_provider_logout(None).await; } AppCommand::Retry => { self.spinner.start(None)?; self.on_message(None).await?; } AppCommand::Index => { let working_dir = self.state.cwd.clone(); self.on_index(working_dir, false).await?; } AppCommand::AgentSwitch(agent_id) => { // Validate that the agent exists by checking against loaded agents let agents = self.api.get_agent_infos().await?; let agent_exists = agents.iter().any(|agent| agent.id.as_str() == agent_id); if agent_exists { self.on_agent_change(AgentId::new(agent_id)).await?; } else { return Err(anyhow::anyhow!( "Agent '{agent_id}' not found or unavailable" )); } } AppCommand::Config => { self.on_show_config(false).await?; } AppCommand::ConfigModel => { self.on_model_selection(None).await?; } AppCommand::ConfigReload => { self.writeln_title(TitleFormat::info( "No session overrides in REPL mode. Use :model to switch the active model.", ))?; } AppCommand::ReasoningEffort => { self.on_reasoning_effort_selection(false).await?; } AppCommand::ConfigReasoningEffort => { self.on_reasoning_effort_selection(true).await?; } AppCommand::ConfigCommitModel => { self.on_config_commit_model().await?; } AppCommand::ConfigSuggestModel => { self.on_config_suggest_model().await?; } AppCommand::ConfigEdit => { self.on_config_edit().await?; } AppCommand::Skill => { self.on_show_skills(false, false).await?; } AppCommand::Edit { content } => { let initial = if content.is_empty() { None } else { Some(content.join(" ").trim().to_string()) }; self.on_edit_buffer(initial).await?; } AppCommand::CommitPreview => { let args = CommitCommandGroup { preview: true, max_diff_size: Some(100_000), diff: None, text: Vec::new(), }; let result = self.handle_commit_command(args).await?; let flags = if result.has_staged_files { "" } else { " -a" }; let commit_command = format!("!git commit{flags} -m '{}'", result.message); self.console.set_buffer(commit_command); } AppCommand::Suggest { description } => { let desc = if description.is_empty() { None } else { Some(description.join(" ").trim().to_string()) }; self.on_suggest(desc).await?; } AppCommand::Clone { id } => { self.on_slash_clone(id).await?; } AppCommand::ConversationRename { name } => { let args = if name.is_empty() { None } else { Some(name.join(" ").trim().to_string()) }; self.on_slash_conversation_rename(args).await?; } AppCommand::Copy => { self.on_copy().await?; } AppCommand::WorkspaceSync => { let working_dir = self.state.cwd.clone(); self.on_index(working_dir, true).await?; } AppCommand::WorkspaceStatus => { let cwd = self.state.cwd.clone(); self.on_workspace_status(cwd, false).await?; } AppCommand::WorkspaceInfo => { let cwd = self.state.cwd.clone(); self.on_workspace_info(cwd).await?; } AppCommand::WorkspaceInit => { let cwd = self.state.cwd.clone(); self.on_workspace_init(cwd, false).await?; } } Ok(false) } async fn on_compaction(&mut self) -> Result<(), anyhow::Error> { let conversation_id = self.init_conversation().await?; let compaction_result = self.api.compact_conversation(&conversation_id).await?; let token_reduction = compaction_result.token_reduction_percentage(); let message_reduction = compaction_result.message_reduction_percentage(); let content = TitleFormat::action(format!( "Context size reduced by {token_reduction:.1}% (tokens), {message_reduction:.1}% (messages)" )); self.writeln_title(content)?; Ok(()) } async fn handle_delete_conversation(&mut self) -> anyhow::Result<()> { let conversation_id = self.init_conversation().await?; self.on_conversation_delete(conversation_id).await?; Ok(()) } async fn handle_rename_conversation(&mut self, name: String) -> anyhow::Result<()> { let conversation_id = self.init_conversation().await?; self.api .rename_conversation(&conversation_id, name.clone()) .await?; self.writeln_title(TitleFormat::info(format!( "Conversation renamed to '{}'", name.bold() )))?; Ok(()) } /// Selects and sets the reasoning effort level interactively. /// /// # Arguments /// * `global` - If true, persists the change to the global config file. If /// false, applies to the session (REPL has no separate session scope, so /// this always writes to the config). async fn on_reasoning_effort_selection(&mut self, global: bool) -> anyhow::Result<()> { use std::str::FromStr; let prompt = if global { "Config Reasoning Effort" } else { "Reasoning Effort" }; let selected = self.select_reasoning_effort(prompt, None).await?; if let Some(effort_str) = selected { let effort = forge_domain::Effort::from_str(&effort_str) .map_err(|_| anyhow::anyhow!("Invalid effort level: {effort_str}"))?; self.api .update_config(vec![ConfigOperation::SetReasoningEffort(effort.clone())]) .await?; self.writeln_title( TitleFormat::action(effort_str).sub_title("is now the reasoning effort"), )?; } Ok(()) } async fn select_reasoning_effort( &self, prompt: &str, query: Option<String>, ) -> anyhow::Result<Option<String>> { let effort_levels = ["none", "minimal", "low", "medium", "high", "xhigh", "max"]; let current_effort = self.api.get_reasoning_effort().await.ok().flatten(); let current_str = current_effort.as_ref().map(|e| e.to_string()); let rows = effort_levels .iter() .map(|level| SelectRow::new(*level, *level)) .collect(); Ok(self .select_raw_row(prompt, query, rows, 0, current_str)? .map(|row| row.raw)) } /// Selects and sets the commit model via interactive model picker. async fn on_config_commit_model(&mut self) -> anyhow::Result<()> { let selection = self.select_model(None, None).await?; if let Some((model, provider_id)) = selection { let commit_config = forge_domain::ModelConfig::new(provider_id.clone(), model.clone()); self.api .update_config(vec![ConfigOperation::SetCommitConfig(Some(commit_config))]) .await?; self.writeln_title(TitleFormat::action(model.as_str()).sub_title(format!( "is now the commit model for provider '{provider_id}'" )))?; } Ok(()) } /// Selects and sets the suggest model via interactive model picker. async fn on_config_suggest_model(&mut self) -> anyhow::Result<()> { let selection = self.select_model(None, None).await?; if let Some((model, provider_id)) = selection { let suggest_config = forge_domain::ModelConfig::new(provider_id.clone(), model.clone()); self.api .update_config(vec![ConfigOperation::SetSuggestConfig(suggest_config)]) .await?; self.writeln_title(TitleFormat::action(model.as_str()).sub_title(format!( "is now the suggest model for provider '{provider_id}'" )))?; } Ok(()) } /// Opens the global config file in the system editor. async fn on_config_edit(&mut self) -> anyhow::Result<()> { let config_path = forge_config::ConfigReader::config_path(); // Ensure parent directory exists if let Some(parent) = config_path.parent() { std::fs::create_dir_all(parent)?; } // Create config file if it does not exist if !config_path.exists() { std::fs::File::create(&config_path)?; } let editor = std::env::var("FORGE_EDITOR") .or_else(|_| std::env::var("EDITOR")) .unwrap_or_else(|_| "nano".to_string()); let editor_binary = editor .split_whitespace() .next() .unwrap_or("nano") .to_string(); let status = std::process::Command::new(&editor_binary) .arg(&config_path) .status() .map_err(|e| { anyhow::anyhow!( "Failed to open editor '{}': {}. Set FORGE_EDITOR or EDITOR.", editor_binary, e ) })?; if !status.success() { return Err(anyhow::anyhow!("Editor exited with error code: {}", status)); } self.writeln_title(TitleFormat::info(format!( "Config saved: {}", config_path.display() )))?; Ok(()) } /// Opens an external editor to compose a prompt and sets it in the REPL /// buffer on exit. /// /// # Arguments /// * `initial` - Optional text to pre-populate the editor with. async fn on_edit_buffer(&mut self, initial: Option<String>) -> anyhow::Result<()> { use std::io::Write as _; let editor = std::env::var("FORGE_EDITOR") .or_else(|_| std::env::var("EDITOR")) .unwrap_or_else(|_| "nano".to_string()); // Split the editor string into binary + pre-configured flags // (e.g. "code --wait" → binary="code", extra_args=["--wait"]) let mut editor_parts = editor.split_whitespace(); let editor_binary = editor_parts.next().unwrap_or("nano").to_string(); let editor_flags: Vec<&str> = editor_parts.collect(); // Create .forge directory for the temp file let forge_dir = self.state.cwd.join(".forge"); std::fs::create_dir_all(&forge_dir)?; let temp_file = forge_dir.join("FORGE_EDITMSG.md"); // Write initial content let mut file = std::fs::File::create(&temp_file)?; if let Some(text) = initial { file.write_all(text.as_bytes())?; } drop(file); let status = std::process::Command::new(&editor_binary) .args(&editor_flags) .arg(&temp_file) .status() .map_err(|e| { anyhow::anyhow!( "Failed to open editor '{}': {}. Set FORGE_EDITOR or EDITOR.", editor_binary, e ) })?; if !status.success() { return Err(anyhow::anyhow!("Editor exited with error code: {}", status)); } let content = std::fs::read_to_string(&temp_file)?; let content = content.trim().to_string(); if content.is_empty() { self.writeln_title(TitleFormat::info("Editor closed with no content"))?; return Ok(()); } // Pre-fill the REPL buffer so the user can review/edit before sending self.console.set_buffer(content); Ok(()) } /// Generates a shell command from a natural language description and sets /// it in the REPL buffer. /// /// # Arguments /// * `description` - Optional natural language description. If `None`, an /// interactive prompt is shown. async fn on_suggest(&mut self, description: Option<String>) -> anyhow::Result<()> { let description = match description { Some(d) if !d.is_empty() => d, _ => { let input = ForgeWidget::input("Describe the command you want") .allow_empty(false) .prompt()?; match input { Some(d) if !d.is_empty() => d, _ => { self.writeln_title(TitleFormat::error( "No description provided. Usage: :suggest <description>", ))?; return Ok(()); } } } }; self.spinner.start(Some("Generating"))?; let prompt = UserPrompt::from(description); match self.api.generate_command(prompt).await { Ok(command) => { self.spinner.stop(None)?; self.writeln(command.clone())?; // Set the generated command in the buffer for review self.console.set_buffer(command); Ok(()) } Err(err) => { self.spinner.stop(None)?; Err(err) } } } /// Clones a conversation (current or selected) and switches to the clone. /// /// # Arguments /// * `id` - Optional conversation ID to clone. If `None`, the current /// conversation is used; if no active conversation, an interactive picker /// is shown. async fn on_slash_clone(&mut self, id: Option<String>) -> anyhow::Result<()> { let target_id = if let Some(id_str) = id { ConversationId::parse(&id_str) .map_err(|_| anyhow::anyhow!("Invalid conversation ID: {id_str}"))? } else { // Show conversation picker let conversations = self .api .get_conversations(Some(self.config.max_conversations)) .await?; if conversations.is_empty() { self.writeln_title(TitleFormat::error( "No conversations found. Start a conversation first.", ))?; return Ok(()); } let selected = ConversationSelector::select_conversation( &conversations, self.state.conversation_id, None, ) .await?; match selected { Some(conv) => conv.id, None => return Ok(()), } }; // Fetch the conversation to clone let original = self .api .conversation(&target_id) .await? .ok_or_else(|| anyhow::anyhow!("Conversation '{target_id}' not found"))?; let original_id = original.id; // Create the clone let new_id = ConversationId::generate(); let mut cloned = original; cloned.id = new_id; self.api.upsert_conversation(cloned).await?; // Switch to the cloned conversation self.state.conversation_id = Some(new_id); self.writeln_title( TitleFormat::info("Cloned").sub_title(format!("[{original_id} → {new_id}]")), )?; Ok(()) } /// Renames any conversation interactively or by explicit ID and name. /// /// # Arguments /// * `args` - Optional `"<id> <name>"` string. If `None`, shows a /// conversation picker and prompts for a new name. async fn on_slash_conversation_rename(&mut self, args: Option<String>) -> anyhow::Result<()> { if let Some(args) = args { // Parse as "<id> <name>" let mut parts = args.splitn(2, ' '); let id_str = parts.next().unwrap_or("").trim(); let name = parts.next().unwrap_or("").trim(); if id_str.is_empty() || name.is_empty() { return Err(anyhow::anyhow!("Usage: :conversation-rename <id> <name>")); } let conversation_id = ConversationId::parse(id_str) .map_err(|_| anyhow::anyhow!("Invalid conversation ID: {id_str}"))?; self.api .rename_conversation(&conversation_id, name.to_string()) .await?; self.writeln_title(TitleFormat::info(format!( "Conversation '{}' renamed to '{}'", conversation_id.into_string().bold(), name.bold() )))?; } else { // Interactive: show picker then prompt for new name let conversations = self .api .get_conversations(Some(self.config.max_conversations)) .await?; if conversations.is_empty() { self.writeln_title(TitleFormat::error("No conversations found."))?; return Ok(()); } let selected = ConversationSelector::select_conversation( &conversations, self.state.conversation_id, None, ) .await?; if let Some(conv) = selected { let name_result = ForgeWidget::input("New name").allow_empty(false).prompt()?; if let Some(name) = name_result && !name.is_empty() { self.api.rename_conversation(&conv.id, name.clone()).await?; self.writeln_title(TitleFormat::info(format!( "Conversation renamed to '{}'", name.bold() )))?; } } } Ok(()) } /// Copies the last AI response from the active conversation to the /// system clipboard. async fn on_copy(&mut self) -> anyhow::Result<()> { let conversation_id = match &self.state.conversation_id { Some(cid) => *cid, None => { self.writeln_title(TitleFormat::error( "No active conversation. Start a conversation first.", ))?; return Ok(()); } }; let conversation = match self.api.conversation(&conversation_id).await? { Some(conv) => conv, None => { self.writeln_title(TitleFormat::error("Conversation not found."))?; return Ok(()); } }; let context = match &conversation.context { Some(ctx) => ctx.clone(), None => { self.writeln_title(TitleFormat::error("Conversation has no messages."))?; return Ok(()); } }; // Find the last assistant message let content = context.messages.iter().rev().find_map(|msg| match &**msg { forge_domain::ContextMessage::Text(forge_api::TextMessage { content, role: forge_domain::Role::Assistant, .. }) => Some(content.clone()), _ => None, }); match content { None => { self.writeln_title(TitleFormat::error( "No assistant message found in this conversation.", ))?; } Some(content) => { #[cfg(not(target_os = "android"))] let copied = arboard::Clipboard::new() .and_then(|mut cb| cb.set_text(content.clone())) .is_ok(); #[cfg(target_os = "android")] let copied = false; if copied { let line_count = content.lines().count(); let byte_count = content.len(); self.writeln_title(TitleFormat::info(format!( "Copied to clipboard [{line_count} lines, {byte_count} bytes]" )))?; } else { self.writeln_title(TitleFormat::error( "Failed to copy to clipboard. Ensure xclip/xsel (Linux) or pbcopy (macOS) is available.", ))?; } } } Ok(()) } async fn select_agent(&self, query: Option<String>) -> Result<Option<AgentId>> { let rows = self.agent_select_rows().await?; let initial_raw = self .api .get_active_agent() .await .map(|current| current.as_str().to_string()); Ok(self .select_raw_row("Agent", query, rows, 1, initial_raw)? .map(|row| AgentId::new(row.raw))) } async fn agent_select_rows(&self) -> Result<Vec<SelectRow>> { let info = self.build_agents_info(false).await?; let porcelain = Porcelain::from(&info) .drop_cols(&[0, 3]) .truncate(3, 30) .uppercase_headers(); Self::porcelain_rows(porcelain) } /// Select a model from all configured providers using porcelain-style /// tabular display matching the shell plugin's `:model` UI. /// /// Shows columns: MODEL, PROVIDER, CONTEXT WINDOW, TOOL SUPPORTED, IMAGE /// with a non-selectable header row. /// /// When `provider_filter` is `Some`, only models belonging to that provider /// are shown. This is used during onboarding so that after a provider is /// selected the model list is scoped to that provider only. /// /// # Returns /// - `Ok(Some((ModelId, ProviderId)))` if a model was selected, carrying /// both the model and the provider it belongs to /// - `Ok(None)` if selection was canceled #[async_recursion::async_recursion] async fn select_model( &mut self, provider_filter: Option<ProviderId>, query: Option<String>, ) -> Result<Option<(ModelId, ProviderId)>> { // Check if provider is set otherwise first ask to select a provider if provider_filter.is_none() && self.api.get_session_config().await.is_none() { if !self.on_provider_selection().await? { return Ok(None); } // Provider activation may have already completed model selection. // If it did not, continue below and show the full cross-provider // model list. if self.api.get_session_config().await.is_some() { return Ok(None); } } // Fetch models from ALL configured providers (matches shell plugin's // `forge list models --porcelain`), then optionally filter by provider. self.spinner.start(Some("Loading"))?; let mut all_provider_models = self.api.get_all_provider_models().await?; self.spinner.stop(None)?; // When a provider filter is specified (e.g. during onboarding after a // provider was just selected), restrict the list to that provider's // models so the user cannot accidentally pick a model from a different // provider. if let Some(ref filter_id) = provider_filter { all_provider_models.retain(|pm| &pm.provider_id == filter_id); } if all_provider_models.is_empty() { return Ok(None); } // Sort models and providers (same as on_show_models) all_provider_models .iter_mut() .for_each(|pm| pm.models.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()))); all_provider_models.sort_by(|a, b| a.provider_id.as_ref().cmp(b.provider_id.as_ref())); // Build the same Info structure as on_show_models, then convert to // Porcelain for tabular display. let mut info = Info::new(); for pm in &all_provider_models { let provider_display = pm.provider_id.to_string(); for model in &pm.models { let id = model.id.to_string(); info = info .add_title(&id) .add_key_value("Model", model.name.as_ref().unwrap_or(&id)) .add_key_value("Provider", &provider_display); if let Some(limit) = model.context_length { let context = if limit >= 1_000_000 { format!("{}M", limit / 1_000_000) } else if limit >= 1000 { format!("{}k", limit / 1000) } else { format!("{limit}") }; info = info.add_key_value("Context Window", context); } else { info = info.add_key_value("Context Window", markers::EMPTY); } if let Some(supported) = model.tools_supported { info = info.add_key_value( "Tool Supported", if supported { status::YES } else { status::NO }, ); } else { info = info.add_key_value("Tools", markers::EMPTY); } let supports_image = model .input_modalities .contains(&forge_domain::InputModality::Image); info = info.add_key_value( "Image", if supports_image { status::YES } else { status::NO }, ); } } // Convert to porcelain format (same as on_show_models --porcelain) let porcelain_output = Porcelain::from(&info) .drop_col(0) .truncate(0, 40) .uppercase_headers(); let porcelain_str = porcelain_output.to_string(); // Split into header + data lines let all_lines: Vec<&str> = porcelain_str.lines().collect(); if all_lines.is_empty() { return Ok(None); } // Build a flat list of (ModelId, ProviderId) for the data rows. // The first line is the header; data rows follow in the same order as // the Info entries (sorted by provider, then model within provider). let mut model_entries: Vec<(ModelId, ProviderId)> = Vec::new(); for pm in &all_provider_models { for model in &pm.models { model_entries.push((model.id.clone(), pm.provider_id.clone())); } } let mut rows = Vec::with_capacity(all_lines.len()); // Header row (non-selectable via header_lines=1) let Some(header) = all_lines.first() else { return Err(UIError::MissingHeaderLine.into()); }; rows.push(SelectRow::header(header.to_string())); // Data rows for (i, line) in all_lines.iter().skip(1).enumerate() { let Some((model_id, provider_id)) = model_entries.get(i) else { continue; }; let dotted_id = model_id.as_str().replace(['-', '_'], "."); rows.push(SelectRow { raw: format!("{}\t{}", model_id.as_str(), provider_id.as_ref()), display: line.to_string(), search: format!( "{} {} {} {}", model_id.as_str(), dotted_id, provider_id.as_ref(), line ), fields: vec![model_id.to_string(), provider_id.as_ref().to_string()], }); } // Find starting cursor position for the current model. let current_model = self .get_agent_model(self.api.get_active_agent().await) .await; let current_provider = self .get_provider(self.api.get_active_agent().await) .await .ok() .map(|provider| provider.id); let initial_raw = current_model.as_ref().and_then(|current| { model_entries .iter() .find(|(model_id, provider_id)| { model_id == current && current_provider .as_ref() .map(|provider| provider_id == provider) .unwrap_or(true) }) .map(|(model_id, provider_id)| { format!("{}\t{}", model_id.as_str(), provider_id.as_ref()) }) }); let selected = self.select_raw_row("Model ❯ ", query, rows, 1, initial_raw)?; let Some(selected) = selected else { return Ok(None); }; let mut parts = selected.raw.splitn(2, '\t'); let selection = match (parts.next(), parts.next()) { (Some(model_id), Some(provider_id)) => Some(( ModelId::new(model_id.to_string()), ProviderId::from(provider_id.to_string()), )), _ => None, }; Ok(selection) } async fn handle_api_key_input( &mut self, provider_id: ProviderId, request: &ApiKeyRequest, ) -> anyhow::Result<()> { use anyhow::Context; self.spinner.stop(None)?; // Extract existing API key and URL params for prefilling let existing_url_params = request.existing_params.as_ref(); // Collect URL parameters if required let url_params = request .required_params .iter() .map(|param| { let param_value = if let Some(options) = ¶m.options { // Dropdown path: user selects from preset options let starting = existing_url_params .and_then(|p| p.get(¶m.name)) .and_then(|v| options.iter().position(|o| o.as_str() == v.as_str())) .unwrap_or(0); ForgeWidget::select(format!("Select {}", param.name), options.clone()) .with_starting_cursor(starting) .prompt()? .context("Parameter selection cancelled")? } else { // Free-text path let label = if param.optional { format!("Enter {} (optional, press Enter to skip)", param.name) } else { format!("Enter {}", param.name) }; let mut input = ForgeWidget::input(label); // Add default value if it exists in the credential if let Some(params) = existing_url_params && let Some(default_value) = params.get(¶m.name) { input = input.with_default(default_value.as_str()); } if param.optional { input = input.allow_empty(true); } let param_value = input.prompt()?.context("Parameter input cancelled")?; if !param.optional { anyhow::ensure!( !param_value.trim().is_empty(), "{} cannot be empty", param.name ); } param_value.trim_end_matches('/').to_string() }; Ok((param.name.to_string(), param_value)) }) .collect::<anyhow::Result<HashMap<_, _>>>()?; let allows_local_api_key = matches!( provider_id.as_ref().as_ref(), "ollama" | "vllm" | "lm_studio" | "llama_cpp" | "jan_ai" ); // Check if API key is already provided // For Google ADC, we use a marker to skip prompting // For other providers, we use the existing key as a default value (autofill) let api_key_str = if let Some(default_key) = &request.api_key { let key_str = default_key.as_ref(); // Skip prompting for markers that indicate non-API-key auth if key_str == "google_adc_marker" || key_str == "aws_profile_marker" { key_str.to_string() } else if allows_local_api_key { let input = ForgeWidget::input(format!( "Enter your {provider_id} API key (press Enter to skip)" )) .allow_empty(true); let api_key = input.prompt()?.context("API key input cancelled")?; let api_key_str = api_key.trim(); if api_key_str.is_empty() { "local".to_string() } else { api_key_str.to_string() } } else { // For other providers, show the existing key as default (autofill) let input = ForgeWidget::input(format!("Enter your {provider_id} API key")) .with_default(key_str); let api_key = input.prompt()?.context("API key input cancelled")?; let api_key_str = api_key.trim(); anyhow::ensure!(!api_key_str.is_empty(), "API key cannot be empty"); api_key_str.to_string() } } else if allows_local_api_key { let input = ForgeWidget::input(format!( "Enter your {provider_id} API key (press Enter to skip)" )) .allow_empty(true); let api_key = input.prompt()?.context("API key input cancelled")?; let api_key_str = api_key.trim(); if api_key_str.is_empty() { "local".to_string() } else { api_key_str.to_string() } } else { // Prompt for API key input (no existing key) let input = ForgeWidget::input(format!("Enter your {provider_id} API key")); let api_key = input.prompt()?.context("API key input cancelled")?; let api_key_str = api_key.trim(); anyhow::ensure!(!api_key_str.is_empty(), "API key cannot be empty"); api_key_str.to_string() }; // Update the context with collected data let response = AuthContextResponse::api_key(request.clone(), &api_key_str, url_params); self.api .complete_provider_auth( provider_id, response, Duration::from_secs(0), // No timeout needed since we have the data ) .await?; Ok(()) } fn display_oauth_device_info_new( &mut self, user_code: &str, verification_uri: &str, verification_uri_complete: Option<&str>, ) -> anyhow::Result<()> { use colored::Colorize; let display_uri = verification_uri_complete.unwrap_or(verification_uri); self.writeln("")?; self.writeln(format!( "{} Please visit: {}", "→".blue(), display_uri.blue().underline() ))?; // Try to copy code to clipboard automatically (not available on Android) #[cfg(not(target_os = "android"))] let clipboard_copied = arboard::Clipboard::new() .and_then(|mut clipboard| clipboard.set_text(user_code)) .is_ok(); #[cfg(target_os = "android")] let clipboard_copied = false; if clipboard_copied { self.writeln(format!( "{} Code copied to clipboard: {}", "✓".green().bold(), user_code.bold().yellow() ))?; } else { self.writeln(format!( "{} Enter code: {}", "→".blue(), user_code.bold().yellow() ))?; } self.writeln("")?; // Try to open browser automatically if let Err(e) = open::that(display_uri) { self.writeln_title(TitleFormat::error(format!( "Failed to open browser automatically: {e}" )))?; } Ok(()) } async fn handle_device_flow( &mut self, provider_id: ProviderId, request: &DeviceCodeRequest, ) -> Result<()> { use std::time::Duration; let user_code = request.user_code.clone(); let verification_uri = request.verification_uri.clone(); let verification_uri_complete = request.verification_uri_complete.clone(); self.spinner.stop(None)?; // Display OAuth device information self.display_oauth_device_info_new( user_code.as_ref(), verification_uri.as_ref(), verification_uri_complete.as_ref().map(|v| v.as_ref()), )?; // Step 2: Complete authentication (polls if needed for OAuth flows) self.spinner.start(Some("Completing authentication..."))?; let response = AuthContextResponse::device_code(request.clone()); self.api .complete_provider_auth(provider_id, response, Duration::from_secs(600)) .await?; self.spinner.stop(None)?; Ok(()) } async fn display_credential_success(&mut self, provider_id: ProviderId) -> anyhow::Result<()> { self.writeln_title(TitleFormat::info(format!( "{provider_id} configured successfully" )))?; Ok(()) } async fn handle_code_flow( &mut self, provider_id: ProviderId, request: &CodeRequest, ) -> anyhow::Result<()> { use colored::Colorize; self.spinner.stop(None)?; self.writeln(format!( "{}", format!("Authenticate using your {provider_id} account").dimmed() ))?; let callback_server = match crate::oauth_callback::LocalhostOAuthCallbackServer::start(request) { Ok(Some(server)) => { self.writeln(format!( "{} Waiting for browser callback on {}", "→".blue(), server.redirect_uri().as_str().blue().underline() ))?; Some(server) } Ok(None) | Err(_) => { // Not a localhost callback flow, or the listener could not be // started — fall back to manual code paste. None } }; // Display authorization URL self.writeln(format!( "{} Please visit: {}", "→".blue(), request.authorization_url.as_str().blue().underline() ))?; // Try to open browser automatically if let Err(e) = open::that(request.authorization_url.as_str()) { self.writeln_title(TitleFormat::error(format!( "Failed to open browser automatically: {e}" )))?; } let code = if let Some(server) = callback_server { server.wait_for_code().await? } else { // Prompt user to paste authorization code let code = ForgeWidget::input(format!("Paste the authorization code for {provider_id}")) .prompt()? .ok_or_else(|| anyhow::anyhow!("Authorization code input cancelled"))?; if code.trim().is_empty() { anyhow::bail!("Authorization code cannot be empty"); } code }; self.spinner .start(Some("Exchanging authorization code..."))?; let response = AuthContextResponse::code(request.clone(), &code); self.api .complete_provider_auth( provider_id, response, Duration::from_secs(0), // No timeout needed since we have the data ) .await?; self.spinner.stop(None)?; Ok(()) } /// Helper method to select an authentication method when multiple are /// available async fn select_auth_method( &mut self, provider_id: ProviderId, auth_methods: &[AuthMethod], ) -> Result<Option<AuthMethod>> { use colored::Colorize; if auth_methods.is_empty() { return Err(UIError::NoAuthMethodsAvailable { provider: provider_id.clone() }.into()); } // If only one auth method, use it directly if auth_methods.len() == 1 { let Some(method) = auth_methods.first() else { return Err( UIError::NoAuthMethodsAvailable { provider: provider_id.clone() }.into(), ); }; return Ok(Some(method.clone())); } // Multiple auth methods - ask user to choose self.spinner.stop(None)?; self.writeln_title(TitleFormat::action(format!("Configure {provider_id}")))?; self.writeln("Multiple authentication methods available".dimmed())?; let method_names: Vec<String> = auth_methods .iter() .map(|method| match method { AuthMethod::ApiKey => "API Key".to_string(), AuthMethod::OAuthDevice(_) => "OAuth Device Flow".to_string(), AuthMethod::OAuthCode(_) => "OAuth Authorization Code".to_string(), AuthMethod::GoogleAdc => "Google Application Default Credentials (ADC)".to_string(), AuthMethod::AwsProfile => "AWS Profile (SSO/IAM)".to_string(), AuthMethod::CodexDevice(_) => "OpenAI Codex Device Flow".to_string(), }) .collect(); match ForgeWidget::select("Select authentication method:", method_names.clone()) .with_help_message("Use arrow keys to navigate and Enter to select") .prompt()? { Some(selected_name) => { // Find the corresponding auth method let Some(index) = method_names.iter().position(|name| name == &selected_name) else { return Err(UIError::AuthMethodNotFound.into()); }; let Some(method) = auth_methods.get(index) else { return Err(UIError::AuthMethodNotFound.into()); }; Ok(Some(method.clone())) } None => Ok(None), } } /// Creates ForgeCode Services credentials if not already authenticated and /// displays the credentials file location to the user. async fn init_forge_services(&mut self) -> Result<()> { self.api.create_auth_credentials().await?; let env = self.api.environment(); let credentials_path = crate::info::format_path_for_display(&env, &env.credentials_path()); self.writeln_title( TitleFormat::info("ForgeCode Services enabled").sub_title(&credentials_path), )?; Ok(()) } /// Handle authentication flow for an unavailable provider async fn configure_provider( &mut self, provider_id: ProviderId, auth_methods: Vec<AuthMethod>, ) -> Result<Option<Provider<Url>>> { if provider_id == ProviderId::FORGE_SERVICES { self.init_forge_services().await?; return Ok(None); } // Select auth method (or use the only one available) let auth_method = match self .select_auth_method(provider_id.clone(), &auth_methods) .await? { Some(method) => method, None => return Ok(None), // User cancelled }; // Show warning for Claude Code provider about account ban risk if provider_id == ProviderId::CLAUDE_CODE { self.writeln_title( TitleFormat::warning( "Using Claude Code subscription in third-party tools violates Anthropic's Terms of Service." ) .sub_title("Your account may be suspended or banned. Continue at your own risk."), )?; let confirmed = ForgeWidget::confirm("Do you want to continue with this provider?") .with_default(false) .prompt()?; if !confirmed.unwrap_or(false) { return Ok(None); } } self.spinner.start(Some("Initiating authentication..."))?; // Initiate the authentication flow let auth_request = self .api .init_provider_auth(provider_id.clone(), auth_method) .await?; // Handle the specific authentication flow based on the request type match auth_request { AuthContextRequest::ApiKey(request) => { self.handle_api_key_input(provider_id.clone(), &request) .await?; } AuthContextRequest::DeviceCode(request) => { self.handle_device_flow(provider_id.clone(), &request) .await?; } AuthContextRequest::Code(request) => { self.handle_code_flow(provider_id.clone(), &request).await?; } } // Verify by fetching the configured provider let provider = self.api.get_provider(&provider_id).await?; self.display_credential_success(provider_id.clone()).await?; Ok(provider.into_configured()) } /// Builds a porcelain-style provider selection list from a set of /// providers, displays it in the interactive picker, and returns the /// selected provider. /// /// The display matches the shell plugin's `_forge_select_provider`: /// columns NAME, HOST, TYPE, LOGGED IN (hiding the raw ID column). fn select_provider_from_list( &self, providers: Vec<AnyProvider>, prompt: &str, current_provider_id: Option<ProviderId>, query: Option<String>, ) -> Result<Option<AnyProvider>> { if providers.is_empty() { return Ok(None); } // Sort providers alphabetically by display name let mut sorted = providers; sorted.sort_by_key(|a| a.id().to_string()); // Build Info structure (same as on_show_providers) let mut info = Info::new(); for provider in &sorted { let id: &str = &provider.id(); let display_name = provider.id().to_string(); let domain = if let Some(url) = provider.url() { url.domain().map(|d| d.to_string()).unwrap_or_default() } else { markers::EMPTY.to_string() }; let provider_type = provider.provider_type().to_string(); let configured = provider.is_configured(); info = info .add_title(id.to_case(Case::UpperSnake)) .add_key_value("name", display_name) .add_key_value("id", id) .add_key_value("host", domain) .add_key_value("type", provider_type); if configured { info = info.add_key_value("logged in", status::YES); } } // Convert to porcelain, drop title (col 0) and raw id (col 2) let porcelain_output = Porcelain::from(&info) .drop_cols(&[0, 2]) .uppercase_headers(); let porcelain_str = porcelain_output.to_string(); let all_lines: Vec<&str> = porcelain_str.lines().collect(); if all_lines.is_empty() { return Ok(None); } let Some(header) = all_lines.first() else { return Err(UIError::MissingHeaderLine.into()); }; let mut rows = vec![SelectRow::header(header.to_string())]; for (index, line) in all_lines.iter().skip(1).enumerate() { if let Some(provider) = sorted.get(index) { rows.push(SelectRow::new( provider.id().as_ref().to_string(), line.to_string(), )); } } let selected = self.select_raw_row( prompt, query, rows, 1, current_provider_id.map(|current| current.as_ref().to_string()), )?; Ok(selected.and_then(|row| { sorted .into_iter() .find(|provider| provider.id().as_ref().as_ref() == row.raw) })) } /// Selects a provider, optionally configuring it if not already configured. async fn select_provider( &mut self, query: Option<String>, configured_only: bool, ) -> Result<Option<AnyProvider>> { let mut providers: Vec<AnyProvider> = self .api .get_providers() .await? .into_iter() .filter(|p| { let filter = forge_domain::ProviderType::Llm; match &p { AnyProvider::Url(provider) => provider.provider_type == filter, AnyProvider::Template(provider) => provider.provider_type == filter, } }) .collect(); if configured_only { providers.retain(|provider| provider.is_configured()); } if providers.is_empty() { return Err(anyhow::anyhow!("No AI provider API keys configured")); } let current_provider_id = self .get_provider(self.api.get_active_agent().await) .await .ok() .map(|p| p.id); self.select_provider_from_list(providers, "Provider", current_provider_id, query) } // Helper method to handle model selection and update the conversation. // When `provider_filter` is `Some`, only models from that provider are shown. // The model and provider returned by the selector are always set as one // atomic operation. #[async_recursion::async_recursion] async fn on_model_selection( &mut self, provider_filter: Option<ProviderId>, ) -> Result<Option<ModelId>> { // Select a model; the selector returns both the model and its provider let selection = self.select_model(provider_filter, None).await?; // If no model was selected (user canceled), return early let (model, provider_id) = match selection { Some(pair) => pair, None => return Ok(None), }; // Set model and provider atomically as a single config operation self.api .update_config(vec![ConfigOperation::SetSessionConfig( forge_domain::ModelConfig::new(provider_id, model.clone()), )]) .await?; // Update the UI state with the new model self.update_model(Some(model.clone())); self.writeln_title(TitleFormat::action(format!("Switched to model: {model}")))?; Ok(Some(model)) } async fn on_provider_selection(&mut self) -> Result<bool> { // Select a provider // If no provider was selected (user canceled), return early let any_provider = match self.select_provider(None, false).await? { Some(provider) => provider, None => return Ok(false), }; self.activate_provider(any_provider).await?; // Check if provider was actually saved — if user cancelled model selection // inside activate_provider, nothing was written Ok(self.api.get_session_config().await.is_some()) } /// Activates a provider by configuring it if needed, setting it as default, /// and ensuring a compatible model is selected. async fn activate_provider(&mut self, any_provider: AnyProvider) -> Result<()> { self.activate_provider_with_model(any_provider, None).await } /// Activates a provider with an optional pre-selected model. /// When `model` is provided, the interactive model selection prompt is /// skipped and the specified model is set directly. async fn activate_provider_with_model( &mut self, any_provider: AnyProvider, model: Option<ModelId>, ) -> Result<()> { // Trigger authentication for the selected provider only if not configured let provider = if !any_provider.is_configured() { match self .configure_provider(any_provider.id(), any_provider.auth_methods().to_vec()) .await? { Some(provider) => provider, None => return Ok(()), } } else { // Provider is already configured, convert it match any_provider.into_configured() { Some(provider) => provider, None => return Ok(()), } }; // Set as default and handle model selection self.finalize_provider_activation(provider, model).await } /// Finalizes provider activation by setting it as default and ensuring /// a compatible model is selected. /// When `model` is `Some`, the interactive model selection is skipped and /// the provided model is validated and set directly. async fn finalize_provider_activation( &mut self, provider: Provider<Url>, model: Option<ModelId>, ) -> Result<()> { // If a model was pre-selected (e.g. from :model), validate and set it // directly without prompting if let Some(model) = model { let model_id = self .validate_model(model.as_str(), Some(&provider.id)) .await?; self.api .update_config(vec![ConfigOperation::SetSessionConfig( forge_domain::ModelConfig::new(provider.id.clone(), model_id.clone()), )]) .await?; self.writeln_title( TitleFormat::action(format!("{}", provider.id)) .sub_title("is now the default provider"), )?; self.writeln_title( TitleFormat::action(model_id.as_str()).sub_title("is now the default model"), )?; return Ok(()); } // Check if the current model is available for the new provider let current_model = self.api.get_session_config().await.map(|c| c.model); let (needs_model_selection, compatible_model) = match current_model { None => (true, None), Some(current_model) => { let provider_models = self.api.get_all_provider_models().await?; let model_available = provider_models .iter() .find(|pm| pm.provider_id == provider.id) .map(|pm| pm.models.iter().any(|m| m.id == current_model)) .unwrap_or(false); if model_available { (false, Some(current_model)) } else { (true, None) } } }; if needs_model_selection { let selected = self.on_model_selection(Some(provider.id.clone())).await?; if selected.is_none() { // User cancelled — preserve existing config untouched return Ok(()); } } else { // The current model is compatible with the new provider — write both // atomically so the session always stores a consistent pair. let model = compatible_model.expect("compatible_model is Some when !needs_model_selection"); self.api .update_config(vec![ConfigOperation::SetSessionConfig( forge_domain::ModelConfig::new(provider.id.clone(), model), )]) .await?; self.writeln_title( TitleFormat::action(format!("{}", provider.id)) .sub_title("is now the default provider"), )?; } Ok(()) } // Handle dispatching events from the CLI async fn handle_dispatch(&mut self, json: String) -> Result<()> { // Initialize the conversation let conversation_id = self.init_conversation().await?; // Parse the JSON to determine the event name and value let event: UserCommand = serde_json::from_str(&json)?; // Create the chat request with the event let chat = ChatRequest::new(event.into(), conversation_id); self.on_chat(chat).await } /// Initializes and returns a conversation ID for the current session. /// /// Handles conversation setup for both interactive and headless modes: /// - **Interactive**: Reuses existing conversation, loads from file, or /// creates new /// - **Headless**: Uses environment variables or generates new conversation /// /// Displays initialization status and updates UI state with the /// conversation ID. async fn init_conversation(&mut self) -> Result<ConversationId> { // Set agent if provided via CLI if let Some(agent_id) = self.cli.agent.clone() { self.api.set_active_agent(agent_id).await?; } let mut is_new = false; let id = if let Some(id) = self.state.conversation_id { id } else if let Some(id) = self.cli.conversation_id { // Use the provided conversation ID // Check if conversation exists, if not create it if self.api.conversation(&id).await?.is_none() { let conversation = Conversation::new(id); self.api.upsert_conversation(conversation).await?; is_new = true; } id } else if let Some(ref path) = self.cli.conversation { let content = ForgeFS::read_utf8(path).await?; // Try to parse as a dump file first (with "conversation" wrapper) let conversation: Conversation = if let Ok(dump) = serde_json::from_str::<ConversationDump>(&content) { dump.conversation } else { // Fall back to parsing as direct Conversation object serde_json::from_str(&content) .context("Failed to parse conversation file. Expected either a ConversationDump or Conversation format")? }; let id = conversation.id; self.api.upsert_conversation(conversation).await?; id } else { let conversation = Conversation::generate(); let id = conversation.id; is_new = true; self.api.upsert_conversation(conversation).await?; id }; // Print if the state is being reinitialized if self.state.conversation_id.is_none() { self.print_conversation_status(is_new, id)?; } // Always set the conversation id in state self.state.conversation_id = Some(id); Ok(id) } fn print_conversation_status( &mut self, new_conversation: bool, id: ConversationId, ) -> Result<(), anyhow::Error> { let mut title = if new_conversation { "Initialize".to_string() } else { "Continue".to_string() }; title.push_str(format!(" {}", id.into_string()).as_str()); self.writeln_title(TitleFormat::debug(title))?; Ok(()) } /// Initialize the state of the UI async fn init_state(&mut self, first: bool) -> Result<()> { let _ = self.handle_migrate_credentials().await; // Ensure we have a model selected before proceeding with initialization let active_agent = self.api.get_active_agent().await; // Validate provider is configured before loading agents // If provider is set in config but not configured (no credentials), prompt user // to login if self.api.get_session_config().await.is_none() && !self.on_provider_selection().await? { return Ok(()); } let mut operating_model = self.get_agent_model(active_agent.clone()).await; if operating_model.is_none() { // Use the model returned from selection instead of re-fetching operating_model = self.on_model_selection(None).await?; } if first { // For chat, we are trying to get active agent or setting it to default. // So for default values, `/info` doesn't show active provider, model, etc. // So my default, on new, we should set the active agent. self.api .set_active_agent(active_agent.clone().unwrap_or_default()) .await?; // only call on_update if this is the first initialization on_update(self.api.clone(), self.config.updates.as_ref()).await; // Apply the MCP trust gate. Servers are NOT connected here — // connections remain lazy and happen on first tool use. self.api.init_mcp().await?; } // Execute independent operations in parallel to improve performance let (agents_result, commands_result) = tokio::join!(self.api.get_agent_infos(), self.api.get_commands()); // Register agent commands with proper error handling and user feedback match agents_result { Ok(agents) => { let registration_result = self.command.register_agent_commands(agents); // Show warning for any skipped agents due to conflicts for skipped_command in registration_result.skipped_conflicts { self.writeln_title(TitleFormat::error(format!( "Skipped agent command '{skipped_command}' due to name conflict with built-in command" )))?; } } Err(e) => { self.writeln_title(TitleFormat::error(format!( "Failed to load agents for command registration: {e}" )))?; } } // Register all the commands self.command.register_all(commands_result?); self.state = UIState::new(self.api.environment()); self.update_model(operating_model); Ok(()) } async fn on_message(&mut self, content: Option<String>) -> Result<()> { let conversation_id = self.init_conversation().await?; if self.config.auto_install_vscode_extension { self.install_vscode_extension(); } // Track if content was provided to decide whether to use piped input as // additional context let has_content = content.is_some(); // Create a ChatRequest with the appropriate event type let mut event = match content { Some(text) => Event::new(text), None => Event::empty(), }; // Only use CLI piped_input as additional context when BOTH --prompt and piped // input are provided. This handles the case: `echo "context" | forge -p // "question"` where piped input provides context and --prompt provides // the actual question. // // When only piped input is provided (no --prompt), it's already used as the // main content (passed via the `content` parameter). We must NOT add it again // as additional_context, otherwise the input appears twice in the // conversation. We detect this by checking if cli.prompt exists - if it // does, the content came from --prompt and piped input should be // additional context. let piped_input = self.cli.piped_input.clone(); let has_explicit_prompt = self.cli.prompt.is_some(); if let Some(piped) = piped_input && has_content && has_explicit_prompt { event = event.additional_context(piped); } // Create the chat request with the event let chat = ChatRequest::new(event, conversation_id); self.on_chat(chat).await } async fn on_chat(&mut self, chat: ChatRequest) -> Result<()> { let mut stream = self.api.chat(chat).await?; // Always use streaming content writer let mut writer = StreamingWriter::new(self.spinner.clone(), self.api.clone()); while let Some(message) = stream.next().await { match message { Ok(message) => self.handle_chat_response(message, &mut writer).await?, Err(err) => { writer.finish()?; self.spinner.stop(None)?; self.spinner.reset(); return Err(err); } } } writer.finish()?; self.spinner.stop(None)?; self.spinner.reset(); Ok(()) } /// Fetches related conversations for a given conversation in parallel. /// /// Returns a vector of related conversations that could be successfully /// fetched. async fn fetch_related_conversations(&self, conversation: &Conversation) -> Vec<Conversation> { let related_ids = conversation.related_conversation_ids(); // Fetch all related conversations in parallel let related_futures: Vec<_> = related_ids .iter() .map(|id| { let api = self.api.clone(); let id = *id; async move { api.conversation(&id).await } }) .collect(); future::join_all(related_futures) .await .into_iter() .filter_map(|result| result.ok().flatten()) .collect() } /// Modified version of handle_dump that supports HTML format async fn on_dump(&mut self, html: bool) -> Result<()> { if let Some(conversation_id) = self.state.conversation_id { let conversation = self.api.conversation(&conversation_id).await?; if let Some(conversation) = conversation { let timestamp = chrono::Local::now().format("%Y-%m-%d_%H-%M-%S"); // Collect related conversations from agent tool calls let related_conversations = self.fetch_related_conversations(&conversation).await; if html { // Create a single HTML with all conversations let html_content = if related_conversations.is_empty() { // No related conversations, just render the main one conversation.to_html() } else { // Render main conversation with related conversations in the same HTML conversation.to_html_with_related(&related_conversations) }; let path = format!("{timestamp}-dump.html"); tokio::fs::write(path.as_str(), &html_content).await?; let subtitle = if related_conversations.is_empty() { path.to_string() } else { format!("{} (+ {} related)", path, related_conversations.len()) }; self.writeln_title( TitleFormat::action("Conversation HTML dump created".to_string()) .sub_title(subtitle), )?; if self.config.auto_open_dump { open::that(path.as_str()).ok(); } } else { let dump_data = ConversationDump { conversation: conversation.clone(), related_conversations: related_conversations.clone(), }; let path = format!("{timestamp}-dump.json"); let content = serde_json::to_string_pretty(&dump_data)?; tokio::fs::write(path.as_str(), content).await?; let subtitle = if related_conversations.is_empty() { path.to_string() } else { format!("{} (+ {} related)", path, related_conversations.len()) }; self.writeln_title( TitleFormat::action("Conversation JSON dump created".to_string()) .sub_title(subtitle), )?; if self.config.auto_open_dump { open::that(path.as_str()).ok(); } }; } else { return Err(anyhow::anyhow!("Could not create dump")) .context(format!("Conversation: {conversation_id} was not found")); } } else { return Err(anyhow::anyhow!("No conversation initiated yet")) .context("Could not create dump"); } Ok(()) } async fn handle_chat_response( &mut self, message: ChatResponse, writer: &mut StreamingWriter<A>, ) -> Result<()> { if message.is_empty() { return Ok(()); } match message { ChatResponse::TaskMessage { content } => match content { ChatResponseContent::ToolInput(title) => { writer.finish()?; self.writeln(title.display())?; } ChatResponseContent::ToolOutput(text) => { writer.finish()?; self.writeln(text)?; } ChatResponseContent::Markdown { text, partial: _ } => { writer.write(&text)?; } }, ChatResponse::ToolCallStart { tool_call, notifier } => { // Scope guard to ensure notification happens even on error. // If writer.finish() or spinner.stop() fails, the guard's drop // will still notify orch, preventing the deadlock. struct NotifyGuard<'a>(&'a tokio::sync::Notify); impl<'a> Drop for NotifyGuard<'a> { fn drop(&mut self) { self.0.notify_one(); } } let _guard = NotifyGuard(¬ifier); writer.finish()?; // Stop spinner only for tools that require stdout/stderr access if tool_call.requires_stdout() { self.spinner.stop(None)?; } // Notify orch that the UI has rendered the tool header. // Orch awaits this before executing the tool, preventing tool // stdout from appearing before the tool name is printed. drop(_guard); } ChatResponse::ToolCallEnd(toolcall_result) => { // Only track toolcall name in case of success else track the error. let payload = if toolcall_result.is_error() { let mut r = ToolCallPayload::new(toolcall_result.name.to_string()); if let Some(cause) = toolcall_result.output.as_str() { r = r.with_cause(cause.to_string()); } r } else { ToolCallPayload::new(toolcall_result.name.to_string()) }; tracker::tool_call(payload); self.spinner.start(None)?; if !self.cli.verbose { return Ok(()); } } ChatResponse::RetryAttempt { cause, duration: _ } => { if !self .config .retry .as_ref() .is_some_and(|r| r.suppress_errors) { writer.finish()?; self.spinner.start(Some("Retrying"))?; self.writeln_title(TitleFormat::error(cause.as_str()))?; } } ChatResponse::Interrupt { reason } => { writer.finish()?; self.spinner.stop(None)?; let title = match reason { InterruptionReason::MaxRequestPerTurnLimitReached { limit } => { format!("Maximum request ({limit}) per turn achieved") } InterruptionReason::MaxToolFailurePerTurnLimitReached { limit, .. } => { format!("Maximum tool failure limit ({limit}) reached for this turn") } }; self.writeln_title(TitleFormat::action(title))?; let continued = self.should_continue().await?; if !continued && let Some(conversation_id) = self.state.conversation_id { self.writeln_title( TitleFormat::debug("Finished").sub_title(conversation_id.into_string()), )?; } } ChatResponse::TaskReasoning { content } => { writer.write_dimmed(&content)?; } ChatResponse::TaskComplete => { writer.finish()?; if let Some(conversation_id) = self.state.conversation_id { self.writeln_title( TitleFormat::debug("Finished").sub_title(conversation_id.into_string()), )?; } if let Some(format) = self.config.auto_dump.clone() { let html = matches!(format, forge_config::AutoDumpFormat::Html); self.on_dump(html).await?; } } } Ok(()) } async fn should_continue(&mut self) -> anyhow::Result<bool> { let should_continue = ForgeWidget::confirm("Do you want to continue anyway?") .with_default(true) .prompt()?; if should_continue.unwrap_or(false) { self.spinner.start(None)?; Box::pin(self.on_message(None)).await?; Ok(true) } else { Ok(false) } } async fn on_show_conv_info(&mut self, conversation: Conversation) -> anyhow::Result<()> { self.spinner.start(Some("Loading Summary"))?; let info = Info::default().extend(&conversation); self.writeln(info)?; self.spinner.stop(None)?; Ok(()) } async fn on_show_conv_stats( &mut self, conversation: Conversation, porcelain: bool, ) -> anyhow::Result<()> { let mut info = Info::new().add_title("CONVERSATION"); // Add conversation ID info = info.add_key_value("ID", conversation.id.to_string()); // Calculate duration let created_at = conversation.metadata.created_at; let updated_at = conversation.metadata.updated_at.unwrap_or(created_at); let duration = updated_at.signed_duration_since(created_at); // Format duration let duration_str = if duration.num_hours() > 0 { format!("{}h {}m", duration.num_hours(), duration.num_minutes() % 60) } else if duration.num_minutes() > 0 { format!( "{}m {}s", duration.num_minutes(), duration.num_seconds() % 60 ) } else { format!("{}s", duration.num_seconds()) }; info = info.add_key_value("Total Duration", duration_str); // Add message statistics if context exists if let Some(context) = &conversation.context { info = info .add_key_value("Total Messages", context.total_messages().to_string()) .add_key_value("User Messages", context.user_message_count().to_string()) .add_key_value( "Assistant Messages", context.assistant_message_count().to_string(), ) .add_key_value("Tool Calls", context.tool_call_count().to_string()); } // Add token usage if available if let Some(usage) = conversation.usage().as_ref() { info = info .add_title("TOKEN") .add_key_value("Prompt Tokens", usage.prompt_tokens.to_string()) .add_key_value("Completion Tokens", usage.completion_tokens.to_string()) .add_key_value("Total Tokens", usage.total_tokens.to_string()); } if let Some(cost) = conversation.accumulated_cost() { info = info.add_key_value("Cost", format!("${cost:.4}")); } if porcelain { use convert_case::Case; self.writeln( Porcelain::from(&info) .into_long() .skip(1) .to_case(&[0, 1], Case::Snake) .sort_by(&[0, 1]), )?; } else { self.writeln(info)?; } Ok(()) } /// Clones a conversation with a new ID /// /// # Arguments /// * `original` - The conversation to clone /// * `porcelain` - If true, output only the new conversation ID async fn on_clone_conversation( &mut self, original: Conversation, porcelain: bool, ) -> anyhow::Result<()> { // Create a new conversation with a new ID but same content let new_id = ConversationId::generate(); let mut cloned = original.clone(); cloned.id = new_id; // Upsert the cloned conversation self.api.upsert_conversation(cloned.clone()).await?; // Output based on format if porcelain { println!("{new_id}"); } else { self.writeln_title( TitleFormat::info("Cloned").sub_title(format!("[{} → {}]", original.id, cloned.id)), )?; } Ok(()) } fn update_model(&mut self, model: Option<ModelId>) { if let Some(ref model) = model { tracker::set_model(model.to_string()); } } async fn on_custom_event(&mut self, event: Event) -> Result<()> { let conversation_id = self.init_conversation().await?; let chat = ChatRequest::new(event, conversation_id); self.on_chat(chat).await } async fn on_usage(&mut self) -> anyhow::Result<()> { self.spinner.start(Some("Loading Usage"))?; // Get usage from current conversation if available let conversation_usage = if let Some(conversation_id) = &self.state.conversation_id { self.api .conversation(conversation_id) .await .ok() .flatten() .and_then(|conv| conv.accumulated_usage()) } else { None }; let mut info = if let Some(usage) = conversation_usage { Info::from(&usage) } else { Info::new() }; if let Ok(Some(user_usage)) = self.api.user_usage().await { info = info.extend(Info::from(&user_usage)); } self.writeln(info)?; self.spinner.stop(None)?; Ok(()) } fn trace_user(&self) { let api = self.api.clone(); // NOTE: Spawning required so that we don't block the user while querying user // info tokio::spawn(async move { if let Ok(Some(user_info)) = api.user_info().await { tracker::login(user_info.auth_provider_id.into_string()); } }); } /// Handle config command async fn handle_config_command( &mut self, command: crate::cli::ConfigCommand, porcelain: bool, ) -> Result<()> { match command { crate::cli::ConfigCommand::Set(args) => self.handle_config_set(args).await?, crate::cli::ConfigCommand::Get(args) => self.handle_config_get(args).await?, crate::cli::ConfigCommand::List => { self.on_show_config(porcelain).await?; } crate::cli::ConfigCommand::Path => { let path = forge_config::ConfigReader::config_path(); self.writeln(path.display().to_string())?; } crate::cli::ConfigCommand::Migrate => { self.handle_config_migrate()?; } } Ok(()) } /// Rename `~/forge` to `~/.forge`. /// /// Errors if the legacy directory does not exist, if the new directory /// already exists, or if the rename fails. fn handle_config_migrate(&mut self) -> Result<()> { let home = dirs::home_dir() .ok_or_else(|| anyhow::anyhow!("Could not determine home directory"))?; let legacy = home.join("forge"); let new = home.join(".forge"); if !legacy.exists() { anyhow::bail!( "Legacy directory {} does not exist — nothing to migrate", legacy.display() ); } if new.exists() { anyhow::bail!( "Target directory {} already exists — remove it first or migrate manually", new.display() ); } std::fs::rename(&legacy, &new).map_err(|e| { anyhow::anyhow!( "Failed to rename {} to {}: {}", legacy.display(), new.display(), e ) })?; self.writeln_title(TitleFormat::info("Migration Completed").sub_title(format!( "{} → {}", legacy.display(), new.display() )))?; Ok(()) } /// Handle config set command async fn handle_config_set(&mut self, args: crate::cli::ConfigSetArgs) -> Result<()> { use crate::cli::ConfigSetField; match args.field { ConfigSetField::Model { provider, model } => { let provider = self.api.get_provider(&provider).await?; self.activate_provider_with_model(provider, Some(model)) .await?; } ConfigSetField::Commit { provider, model } => { // Validate provider exists and model belongs to that specific provider let validated_model = self.validate_model(model.as_str(), Some(&provider)).await?; let commit_config = forge_domain::ModelConfig::new(provider.clone(), validated_model.clone()); self.api .update_config(vec![ConfigOperation::SetCommitConfig(Some(commit_config))]) .await?; self.writeln_title( TitleFormat::action(validated_model.as_str()) .sub_title(format!("is now the commit model for provider '{provider}'")), )?; } ConfigSetField::Suggest { provider, model } => { // Validate provider exists and model belongs to that specific provider let validated_model = self.validate_model(model.as_str(), Some(&provider)).await?; let suggest_config = forge_domain::ModelConfig::new(provider.clone(), validated_model.clone()); self.api .update_config(vec![ConfigOperation::SetSuggestConfig(suggest_config)]) .await?; self.writeln_title(TitleFormat::action(validated_model.as_str()).sub_title( format!("is now the suggest model for provider '{provider}'"), ))?; } ConfigSetField::ReasoningEffort { effort } => { self.api .update_config(vec![ConfigOperation::SetReasoningEffort(effort.clone())]) .await?; self.writeln_title( TitleFormat::action(effort.to_string()) .sub_title("is now the reasoning effort"), )?; } } Ok(()) } /// Handle config get command async fn handle_config_get(&mut self, args: crate::cli::ConfigGetArgs) -> Result<()> { use crate::cli::ConfigGetField; match args.field { ConfigGetField::Model => { let model = self .api .get_session_config() .await .map(|c| c.model.as_str().to_string()); match model { Some(v) => self.writeln(v.to_string())?, None => self.writeln("Model: Not set")?, } } ConfigGetField::Provider => { let provider = self .api .get_session_config() .await .map(|c| c.provider.to_string()); match provider { Some(v) => self.writeln(v.to_string())?, None => self.writeln("Provider: Not set")?, } } ConfigGetField::Commit => { let commit_config = self.api.get_commit_config().await?; match commit_config { Some(config) => { self.writeln(config.provider.as_ref())?; self.writeln(config.model.as_str().to_string())?; } None => self.writeln("Commit: Not set")?, } } ConfigGetField::Suggest => { let suggest_config = self.api.get_suggest_config().await?; match suggest_config { Some(config) => { self.writeln(config.provider.as_ref())?; self.writeln(config.model.as_str().to_string())?; } None => self.writeln("Suggest: Not set")?, } } ConfigGetField::ReasoningEffort => { let effort = self.api.get_reasoning_effort().await?; match effort { Some(e) => self.writeln(e.to_string())?, None => self.writeln("ReasoningEffort: Not set")?, } } } Ok(()) } /// Handle prompt command - returns model and conversation stats for shell /// integration async fn handle_zsh_rprompt_command(&mut self) -> Option<String> { let cid = std::env::var("_FORGE_CONVERSATION_ID") .ok() .filter(|text| !text.trim().is_empty()) .and_then(|str| ConversationId::from_str(str.as_str()).ok()); let agent_id = std::env::var("_FORGE_ACTIVE_AGENT") .ok() .filter(|text| !text.trim().is_empty()) .map(AgentId::new); // Make IO calls in parallel let (model_id, conversation, reasoning_effort) = tokio::join!( self.get_agent_model(agent_id.clone()), async { if let Some(cid) = cid { self.api.conversation(&cid).await.ok().flatten() } else { None } }, async { self.api.get_reasoning_effort().await.ok().flatten() } ); // Calculate total cost including related conversations let cost = if let Some(ref conv) = conversation { let related_conversations = self.fetch_related_conversations(conv).await; let all_conversations: Vec<_> = std::iter::once(conv) .chain(related_conversations.iter()) .cloned() .collect(); Conversation::total_cost(&all_conversations) } else { None }; // Check if nerd fonts should be used (NERD_FONT or USE_NERD_FONT set to "1") let use_nerd_font = std::env::var("NERD_FONT") .or_else(|_| std::env::var("USE_NERD_FONT")) .map(|val| val == "1") .unwrap_or(true); // Default to true // Read terminal width from COLUMNS (propagated by the zsh shell plugin) // so the rprompt can pick a compact or full-length reasoning effort // label. Missing or unparseable values fall back to the full-length // form in the renderer. let terminal_width = std::env::var("COLUMNS") .ok() .and_then(|s| s.parse::<usize>().ok()); let rprompt = ZshRPrompt::from_config(&self.config) .agent(agent_id) .model(model_id) .token_count(conversation.and_then(|conversation| conversation.token_count())) .cost(cost) .reasoning_effort(reasoning_effort) .terminal_width(terminal_width) .use_nerd_font(use_nerd_font); Some(rprompt.to_string()) } /// Validates that a model exists, optionally scoped to a specific provider. /// When `provider` is `None`, models are fetched from the default provider. async fn validate_model( &self, model_str: &str, provider: Option<&forge_domain::ProviderId>, ) -> Result<ModelId> { let models = match provider { None => self.api.get_models().await?, Some(provider_id) => { self.api .get_all_provider_models() .await? .into_iter() .find(|pm| &pm.provider_id == provider_id) .with_context(|| { format!("Provider '{provider_id}' not found or returned no models") })? .models } }; let model_id = ModelId::new(model_str); models .iter() .find(|m| m.id == model_id) .map(|_| model_id) .with_context(|| { let hints = models .iter() .take(10) .map(|m| m.id.as_str()) .collect::<Vec<_>>() .join(", "); let suggestion = if models.len() > 10 { format!("{hints} (and {} more)", models.len() - 10) } else { hints }; format!("Model '{model_str}' not found. Available models: {suggestion}") }) } /// Shows the last message from a conversation /// /// When `md` is true, the raw markdown content is printed without /// rendering. When `md` is false, the content is rendered through the /// markdown renderer. /// /// # Errors /// - If the conversation doesn't exist /// - If the conversation has no messages async fn on_show_last_message(&mut self, conversation: Conversation, md: bool) -> Result<()> { let context = conversation .context .as_ref() .ok_or_else(|| anyhow::anyhow!("Conversation has no context"))?; // Find the last assistant message let message = context.messages.iter().rev().find_map(|msg| match &**msg { ContextMessage::Text(TextMessage { content, role: Role::Assistant, .. }) => { Some(content) } _ => None, }); // Format and display the message using the message_display module if let Some(message) = message { if md { self.writeln(message)?; } else { self.writeln(self.markdown.render(message))?; } } Ok(()) } async fn on_index(&mut self, path: std::path::PathBuf, init: bool) -> anyhow::Result<()> { use forge_domain::SyncProgress; use forge_spinner::ProgressBarManager; // Check if auth already exists and create if needed if !self.api.is_authenticated().await? { self.init_forge_services().await?; } // When init is set, check if the workspace is already initialized // via get_workspace_info before calling init, so we only initialize // when a workspace does not yet exist for the given path. if init { let workspace_info = self.api.get_workspace_info(path.clone()).await?; if workspace_info.is_none() { self.on_workspace_init(path.clone(), false).await?; // If the workspace still does not exist after init (e.g. user // declined the consent prompt), abort the sync. let workspace_info = self.api.get_workspace_info(path.clone()).await?; if workspace_info.is_none() { return Ok(()); } } } let mut stream = self.api.sync_workspace(path.clone()).await?; let mut progress_bar = ProgressBarManager::default(); while let Some(event) = stream.next().await { match event { Ok(ref progress @ SyncProgress::Completed { .. }) => { progress_bar.set_position(100)?; progress_bar.stop(None).await?; if let Some(msg) = progress.message() { self.writeln_title(TitleFormat::debug(msg))?; } } Ok(ref progress @ SyncProgress::Syncing { .. }) => { if !progress_bar.is_active() { progress_bar.start(100, "Indexing workspace")?; } if let Some(msg) = progress.message() { progress_bar.set_message(&msg)?; } if let Some(weight) = progress.weight() { progress_bar.set_position(weight)?; } } Ok(ref progress) => { if let Some(msg) = progress.message() { self.writeln_title(TitleFormat::debug(msg))?; } } Err(e) => { progress_bar.stop(None).await?; return Err(e); } } } Ok(()) } async fn on_query( &mut self, path: PathBuf, params: forge_domain::SearchParams<'_>, ) -> anyhow::Result<()> { self.spinner.start(Some("Searching workspace..."))?; let results = match self.api.query_workspace(path.clone(), params).await { Ok(results) => results, Err(e) => { self.spinner.stop(None)?; return Err(e); } }; self.spinner.stop(None)?; let mut info = Info::new().add_title(format!("FILES [{} RESULTS]", results.len())); for result in results.iter() { match &result.node { forge_domain::NodeData::FileChunk(chunk) => { info = info.add_key_value( "File", format!( "{}:{}-{}", chunk.file_path, chunk.start_line, chunk.end_line ), ); } forge_domain::NodeData::File(file) => { info = info.add_key_value("File", format!("{} (full file)", file.file_path)); } forge_domain::NodeData::FileRef(file_ref) => { info = info.add_key_value("File", format!("{} (reference)", file_ref.file_path)); } forge_domain::NodeData::Note(note) => { info = info.add_key_value("Note", ¬e.content); } forge_domain::NodeData::Task(task) => { info = info.add_key_value("Task", &task.task); } } } self.writeln(info)?; Ok(()) } /// Helper function to format workspace information consistently fn format_workspace_info(workspace: &forge_domain::WorkspaceInfo, is_active: bool) -> Info { let updated_time = workspace .last_updated .map_or("NEVER".to_string(), humanize_time); let mut info = Info::new(); let title = if is_active { "Workspace [Current]".to_string() } else { "Workspace".to_string() }; info = info.add_title(title); info.add_key_value("ID", workspace.workspace_id.to_string()) .add_key_value("Path", workspace.working_dir.to_string()) .add_key_value("Created At", humanize_time(workspace.created_at)) .add_key_value("Updated At", updated_time) } async fn on_list_workspaces(&mut self, porcelain: bool) -> anyhow::Result<()> { if !porcelain { self.spinner.start(Some("Fetching workspaces..."))?; } // Fetch workspaces and current workspace info in parallel let env = self.api.environment(); let (workspaces_result, current_workspace_result) = tokio::join!( self.api.list_workspaces(), self.api.get_workspace_info(env.cwd) ); match workspaces_result { Ok(workspaces) => { if !porcelain { self.spinner.stop(None)?; } // Get active workspace ID if current workspace info is available let current_workspace = current_workspace_result.ok().flatten(); let active_workspace_id = current_workspace.as_ref().map(|ws| &ws.workspace_id); // Build Info object once let mut info = Info::new(); for workspace in &workspaces { let is_active = active_workspace_id == Some(&workspace.workspace_id); info = info.extend(Self::format_workspace_info(workspace, is_active)); } // Output based on mode if porcelain { // Skip header row in porcelain mode (consistent with conversation list) self.writeln(Porcelain::from(info).skip(1).drop_cols(&[0, 4, 5]))?; } else { self.writeln(info)?; } Ok(()) } Err(e) => { self.spinner.stop(None)?; Err(e) } } } /// Displays workspace information for a given path. async fn on_workspace_info(&mut self, path: std::path::PathBuf) -> anyhow::Result<()> { self.spinner.start(Some("Fetching workspace info..."))?; // Fetch workspace info and status in parallel let (workspace, statuses) = tokio::try_join!( self.api.get_workspace_info(path.clone()), self.api.get_workspace_status(path) )?; self.spinner.stop(None)?; match workspace { Some(workspace) => { // When viewing a specific workspace's info, it's implicitly the active one let mut info = Self::format_workspace_info(&workspace, true); // Add sync status summary if available use forge_domain::SyncStatus; let in_sync = statuses .iter() .filter(|s| s.status == SyncStatus::InSync) .count(); let modified = statuses .iter() .filter(|s| s.status == SyncStatus::Modified) .count(); let added = statuses .iter() .filter(|s| s.status == SyncStatus::New) .count(); let deleted = statuses .iter() .filter(|s| s.status == SyncStatus::Deleted) .count(); let failed = statuses .iter() .filter(|s| s.status == SyncStatus::Failed) .count(); // Add sync status section info = info.add_title("Sync Status"); info = info.add_key_value("Total Files", statuses.len().to_string()); if in_sync > 0 { info = info.add_key_value("In Sync", in_sync.to_string()); } if modified > 0 { info = info.add_key_value("Modified", modified.to_string()); } if added > 0 { info = info.add_key_value("Added", added.to_string()); } if deleted > 0 { info = info.add_key_value("Deleted", deleted.to_string()); } if failed > 0 { info = info.add_key_value("Failed", failed.to_string()); } self.writeln(info) } None => self.writeln_to_stderr( TitleFormat::error("No workspace found") .display() .to_string(), ), } } async fn on_delete_workspaces(&mut self, workspace_ids: Vec<String>) -> anyhow::Result<()> { if workspace_ids.is_empty() { anyhow::bail!("At least one workspace ID is required"); } // Parse all workspace IDs let parsed_ids: Vec<forge_domain::WorkspaceId> = workspace_ids .iter() .map(|id| { forge_domain::WorkspaceId::from_string(id) .with_context(|| format!("Invalid workspace ID format: {}", id)) }) .collect::<anyhow::Result<Vec<_>>>()?; let total = parsed_ids.len(); self.spinner.start(Some(&format!( "Deleting {} workspace{}...", total, if total > 1 { "s" } else { "" } )))?; match self.api.delete_workspaces(parsed_ids.clone()).await { Ok(()) => { self.spinner.stop(None)?; for id in &parsed_ids { self.writeln_title(TitleFormat::debug(format!( "Successfully deleted workspace {}", id )))?; } Ok(()) } Err(e) => { self.spinner.stop(None)?; Err(e) } } } /// Displays sync status for all files in the workspace. async fn on_workspace_status( &mut self, path: std::path::PathBuf, porcelain: bool, ) -> anyhow::Result<()> { use forge_domain::SyncStatus; if !porcelain { self.spinner.start(Some("Checking file status..."))?; } let mut statuses = self.api.get_workspace_status(path.clone()).await?; statuses.sort_by(|a, b| a.status.cmp(&b.status)); if !porcelain { self.spinner.stop(None)?; } // Calculate out of sync count let out_of_sync = statuses .iter() .filter(|s| { s.status == SyncStatus::Modified || s.status == SyncStatus::New || s.status == SyncStatus::Deleted || s.status == SyncStatus::Failed }) .count(); // When all files are in sync, show a simple log message if out_of_sync == 0 { if porcelain { // In porcelain mode, output empty result self.writeln( Porcelain::from(Info::new()) .into_long() .set_headers(["STATUS", "FILE"]) .uppercase_headers(), )?; } else { // Show log info message when all files are in sync self.writeln_title(TitleFormat::info(format!( "All {} files are in sync", statuses.len() )))?; } return Ok(()); } // Build file list info only when there are files out of sync let mut info = Info::new().add_title(format!("File Status [{} out of sync]", out_of_sync)); // Add file list (skip in-sync files) for (status, label) in statuses.iter().filter_map(|status| match status.status { SyncStatus::InSync => None, SyncStatus::Modified => Some((status, "modified")), SyncStatus::New => Some((status, "added")), SyncStatus::Deleted => Some((status, "deleted")), SyncStatus::Failed => Some((status, "failed")), }) { info = info.add_key_value(&status.path, label); } // Output based on mode if porcelain { self.writeln( Porcelain::from(info) .into_long() .drop_col(0) .swap_cols(0, 1) .set_headers(["STATUS", "FILE"]) .sort_by(&[0]) .uppercase_headers(), )?; } else { self.writeln(info)?; } Ok(()) } /// Initialize workspace for a directory without syncing files async fn on_workspace_init( &mut self, path: std::path::PathBuf, yes: bool, ) -> anyhow::Result<()> { // Ask for user consent before syncing and sharing directory contents // with the ForgeCode Service. let display_path = path.display().to_string(); let confirmed = if yes { Some(true) } else { ForgeWidget::confirm(format!( "This will sync and share the contents of '{}' with ForgeCode Services. Do you wish to continue?", display_path )) .with_default(true) .prompt()? }; if !confirmed.unwrap_or(false) { self.writeln_title(TitleFormat::info("Workspace initialization cancelled"))?; return Ok(()); } // Check if auth already exists and create if needed if !self.api.is_authenticated().await? { self.init_forge_services().await?; } self.spinner.start(Some("Initializing workspace"))?; let workspace_id = self.api.init_workspace(path.clone()).await?; self.spinner.stop(None)?; self.writeln_title( TitleFormat::info("Workspace initialized successfully") .sub_title(format!("{}", workspace_id)), )?; Ok(()) } /// Handle credential migration async fn handle_migrate_credentials(&mut self) -> Result<()> { // Perform the migration self.spinner.start(Some("Migrating credentials"))?; let result = self.api.migrate_env_credentials().await?; self.spinner.stop(None)?; // Display results based on whether migration occurred if let Some(result) = result { self.writeln_title( TitleFormat::warning("Forge no longer reads API keys from environment variables.") .sub_title("Learn more: https://forgecode.dev/docs/custom-providers/"), )?; let count = result.migrated_providers.len(); let message = if count == 1 { "Migrated 1 provider from environment variables".to_string() } else { format!("Migrated {count} providers from environment variables") }; self.writeln_title(TitleFormat::info(message))?; } Ok(()) } /// Silently install VS Code extension if in VS Code and extension not /// installed. /// NOTE: This is a non-cancellable and a slow task. We should only run this /// if the user has provided a prompt because that is guaranteed to run for /// at least a few seconds. fn install_vscode_extension(&self) { tokio::task::spawn_blocking(|| { if crate::vscode::should_install_extension() { let _ = crate::vscode::install_extension(); } }); } } #[cfg(test)] mod tests { // Note: Tests for confirm_delete_conversation are disabled because // ForgeSelect::confirm is not easily mockable in the current // architecture. The functionality is tested through integration tests // instead. }