diff --git a/src/main.rs b/src/main.rs index e887327..fc9f4e2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -27,7 +27,7 @@ use ratatui::{ }; use state::{ AppState, FLOW_ANIM_CELLS, FLOW_BOOTSTRAP_PHASES, FlowAnimKind, FlowAnimSegment, FlowDirection, - FlowLane, Mode, ServerUiEvent, SharedState, ToolMode, UsageTotals, app_config_path, + FlowLane, Mode, ServerUiEvent, SharedState, ShowDetailMode, ToolMode, UsageTotals, app_config_path, flow_anim_lit_count, load_ngrok_authtoken, save_ngrok_authtoken, }; use std::io::{Write, stdout}; @@ -455,20 +455,17 @@ fn flow_phase_lines( .collect() } -fn flow_bootstrap_steps_total() -> usize { - FLOW_BOOTSTRAP_PHASES - .iter() - .map(|phase| phase.steps.len()) - .sum() +fn flow_bootstrap_steps_total(mode: state::ShowDetailMode) -> usize { + state::flow_bootstrap_steps_total(mode) } -fn flow_bootstrap_complete(flow: &FlowLane) -> bool { - flow.bootstrap_completed_steps >= flow_bootstrap_steps_total() +fn flow_bootstrap_complete(flow: &FlowLane, mode: state::ShowDetailMode) -> bool { + flow.bootstrap_completed_steps >= flow_bootstrap_steps_total(mode) && flow.bootstrap_pending_steps.is_empty() } -fn flow_bootstrap_status_visible(flow: &FlowLane, now_millis: u128) -> bool { - if !flow_bootstrap_complete(flow) { +fn flow_bootstrap_status_visible(flow: &FlowLane, now_millis: u128, mode: state::ShowDetailMode) -> bool { + if !flow_bootstrap_complete(flow, mode) { return true; } if current_anim_segment(flow, now_millis).is_some() { @@ -491,7 +488,7 @@ fn active_bootstrap_status_flow<'a>(app: &'a AppState, now_millis: u128) -> Opti should_display_flow_row(flow, app.remote_connected) && flow.bootstrap_status_active && flow.closing_started_ms.is_none() - && flow_bootstrap_status_visible(flow, now_millis) + && flow_bootstrap_status_visible(flow, now_millis, app.show_detail_mode) }) } @@ -521,7 +518,7 @@ fn flow_bootstrap_status_lines( now_millis: u128, ) -> Vec> { let action_label = latest_flow_action(flow); - let bootstrap_complete = flow_bootstrap_complete(flow); + let bootstrap_complete = flow_bootstrap_complete(flow, app.show_detail_mode); let header_title = if bootstrap_complete { "Initialize completed" } else { @@ -1420,27 +1417,82 @@ fn centered_rect(percent_x: u16, height: u16, area: Rect) -> Rect { Rect::new(x, y, width, popup_height) } +async fn run_prompt( + terminal: &mut Terminal>, + prompt_title: &str, + initial_value: &str, +) -> Result, Box> { + let mut input = initial_value.to_string(); + loop { + terminal.draw(|f| { + let area = centered_rect(60, 20, f.area()); + let block = Block::default() + .title(prompt_title) + .borders(Borders::ALL) + .border_type(ratatui::widgets::BorderType::Rounded) + .style(Style::default().fg(Color::Yellow)); + + let text = Paragraph::new(format!("> {}_", input)) + .block(block) + .wrap(ratatui::widgets::Wrap { trim: true }); + f.render_widget(ratatui::widgets::Clear, area); + f.render_widget(text, area); + })?; + + if crossterm::event::poll(std::time::Duration::from_millis(100))? { + let event = crossterm::event::read()?; + match event { + crossterm::event::Event::Paste(text) => { + input.push_str(&text); + } + crossterm::event::Event::Key(key) => { + if key.kind != crossterm::event::KeyEventKind::Press { + continue; + } + match key.code { + crossterm::event::KeyCode::Enter => return Ok(Some(input)), + crossterm::event::KeyCode::Esc => return Ok(None), + crossterm::event::KeyCode::Backspace => { + input.pop(); + } + crossterm::event::KeyCode::Char(c) => { + input.push(c); + } + _ => {} + } + } + _ => {} + } + } + } +} + + async fn run_settings( terminal: &mut Terminal>, state: SharedState, ) -> Result<(), Box> { let themes = theme::all(); let tool_modes = ToolMode::all(); + let show_detail_modes = ShowDetailMode::all(); let mut confirm_reset_token_billing = false; let mut selected_row = { let app = state.lock().await; themes.iter().position(|t| t.id == app.theme).unwrap_or(0) }; - let total_rows = themes.len() + tool_modes.len() + 1; + let total_rows = themes.len() + tool_modes.len() + show_detail_modes.len() + 1 + 3; loop { - let (current_theme, current_tool_mode, usage_totals, set_catdesk_as_co_author) = { + let (current_theme, current_tool_mode, current_show_detail_mode, usage_totals, set_catdesk_as_co_author, mcp_slug, ngrok_domain) = { let app = state.lock().await; ( app.current_theme(), app.tool_mode, + app.show_detail_mode, app.usage_totals.clone(), app.set_catdesk_as_co_author, + app.mcp_slug.clone(), + app.ngrok_domain.clone(), ) }; terminal.draw(|f| { @@ -1448,7 +1500,10 @@ async fn run_settings( f, current_theme, current_tool_mode, + current_show_detail_mode, set_catdesk_as_co_author, + &mcp_slug, + ngrok_domain.as_deref(), &usage_totals, selected_row, confirm_reset_token_billing, @@ -1485,6 +1540,9 @@ async fn run_settings( } else { let tool_mode_start = themes.len(); let tool_mode_end = tool_mode_start + tool_modes.len(); + let detail_mode_start = tool_mode_end; + let detail_mode_end = detail_mode_start + show_detail_modes.len(); + if selected_row < tool_mode_end { let picked = tool_modes[selected_row - tool_mode_start]; if app.tool_mode != picked { @@ -1492,7 +1550,14 @@ async fn run_settings( app.log("INFO", format!("Tool mode: {}", picked.label())); app.persist_state_with_log(); } - } else { + } else if selected_row < detail_mode_end { + let picked = show_detail_modes[selected_row - detail_mode_start]; + if app.show_detail_mode != picked { + app.show_detail_mode = picked; + app.log("INFO", format!("Widget detail mode: {}", picked.label())); + app.persist_state_with_log(); + } + } else if selected_row == detail_mode_end { app.set_catdesk_as_co_author = !app.set_catdesk_as_co_author; let enabled = app.set_catdesk_as_co_author; app.log( @@ -1503,6 +1568,28 @@ async fn run_settings( ), ); app.persist_state_with_log(); + } else if selected_row == detail_mode_end + 1 { + // Keep existing slug, do nothing + } else if selected_row == detail_mode_end + 2 { + app.regenerate_mcp_slug(); + app.log("INFO", "Generated new random MCP slug".into()); + app.persist_state_with_log(); + } else if selected_row == detail_mode_end + 3 { + let current_domain = app.ngrok_domain.clone().unwrap_or_default(); + drop(app); + if let Some(new_domain) = run_prompt(terminal, "Enter ngrok static domain (with/without https://, empty to clear):", ¤t_domain).await? { + let mut cleaned = new_domain.trim(); + if let Some(stripped) = cleaned.strip_prefix("https://") { + cleaned = stripped; + } else if let Some(stripped) = cleaned.strip_prefix("http://") { + cleaned = stripped; + } + cleaned = cleaned.trim_end_matches('/'); + let mut app = state.lock().await; + app.ngrok_domain = if cleaned.is_empty() { None } else { Some(cleaned.to_string()) }; + app.log("INFO", "Updated ngrok static domain".into()); + app.persist_state_with_log(); + } } } } @@ -1530,13 +1617,17 @@ fn draw_settings( f: &mut Frame, current_theme: &theme::ThemeDef, current_tool_mode: ToolMode, + current_show_detail_mode: ShowDetailMode, set_catdesk_as_co_author: bool, + mcp_slug: &str, + ngrok_domain: Option<&str>, usage_totals: &UsageTotals, selected_row: usize, confirm_reset_token_billing: bool, ) { let themes = theme::all(); let tool_modes = ToolMode::all(); + let show_detail_modes = ShowDetailMode::all(); let palette = current_theme.palette; let area = f.area(); let chunks = Layout::default() @@ -1562,6 +1653,7 @@ fn draw_settings( ); f.render_widget(header, chunks[0]); + let mut selected_line_idx = 0; let mut lines = vec![ Line::from(""), Line::from(Span::styled( @@ -1582,6 +1674,9 @@ fn draw_settings( Style::default().fg(palette.primary_fg) }; lines.push(Line::from("")); + if selected { + selected_line_idx = lines.len(); + } let mut spans = vec![Span::styled( format!(" {} [{}] {}", marker, idx + 1, theme.label), name_style, @@ -1619,6 +1714,9 @@ fn draw_settings( Style::default().fg(palette.primary_fg) }; lines.push(Line::from("")); + if selected { + selected_line_idx = lines.len(); + } let mut spans = vec![Span::styled( format!(" {} [{}] {}", marker, row_idx + 1, tool_mode.label()), name_style, @@ -1637,7 +1735,49 @@ fn draw_settings( Style::default().fg(palette.muted_fg), )])); } - let co_author_row = themes.len() + tool_modes.len(); + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Choose a widget detail mode:", + Style::default() + .fg(palette.title_fg) + .add_modifier(Modifier::BOLD), + )])); + for (idx, detail_mode) in show_detail_modes.iter().enumerate() { + let row_idx = themes.len() + tool_modes.len() + idx; + let selected = row_idx == selected_row; + let marker = if selected { ">" } else { " " }; + let name_style = if selected { + Style::default() + .fg(palette.key_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.primary_fg) + }; + lines.push(Line::from("")); + if selected { + selected_line_idx = lines.len(); + } + let mut spans = vec![Span::styled( + format!(" {} [{}] {}", marker, row_idx + 1, detail_mode.label()), + name_style, + )]; + if *detail_mode == current_show_detail_mode { + spans.push(Span::styled( + " [current]", + Style::default() + .fg(palette.secondary_fg) + .add_modifier(Modifier::BOLD), + )); + } + lines.push(Line::from(spans)); + lines.push(Line::from(vec![Span::styled( + format!(" {}", detail_mode.description()), + Style::default().fg(palette.muted_fg), + )])); + } + + let co_author_row = themes.len() + tool_modes.len() + show_detail_modes.len(); let co_author_selected = co_author_row == selected_row; let co_author_marker = if co_author_selected { ">" } else { " " }; let co_author_name_style = if co_author_selected { @@ -1654,6 +1794,9 @@ fn draw_settings( .fg(palette.title_fg) .add_modifier(Modifier::BOLD), )])); + if co_author_selected { + selected_line_idx = lines.len(); + } lines.push(Line::from(vec![Span::styled( format!( " {} [{}] Set CatDesk as co-author", @@ -1677,10 +1820,107 @@ fn draw_settings( }), ), ])); + lines.push(Line::from(vec![Span::styled( " When enabled, CatDesk automatically appends \"Co-Authored-By: CatDesk\" to git commits and blocks manually written CatDesk co-author trailers.", Style::default().fg(palette.muted_fg), )])); + + let slug_keep_row = co_author_row + 1; + let slug_keep_selected = slug_keep_row == selected_row; + let slug_keep_marker = if slug_keep_selected { ">" } else { " " }; + let slug_keep_name_style = if slug_keep_selected { + Style::default() + .fg(palette.key_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.primary_fg) + }; + + let slug_new_row = co_author_row + 2; + let slug_new_selected = slug_new_row == selected_row; + let slug_new_marker = if slug_new_selected { ">" } else { " " }; + let slug_new_name_style = if slug_new_selected { + Style::default() + .fg(palette.key_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.primary_fg) + }; + + let domain_row = co_author_row + 3; + let domain_selected = domain_row == selected_row; + let domain_marker = if domain_selected { ">" } else { " " }; + let domain_name_style = if domain_selected { + Style::default() + .fg(palette.key_fg) + .add_modifier(Modifier::BOLD) + } else { + Style::default().fg(palette.primary_fg) + }; + + lines.push(Line::from("")); + lines.push(Line::from(vec![Span::styled( + " Connection Security URL:", + Style::default() + .fg(palette.title_fg) + .add_modifier(Modifier::BOLD), + )])); + if slug_keep_selected { + selected_line_idx = lines.len(); + } + lines.push(Line::from(vec![Span::styled( + format!( + " {} [{}] Keep current recorded slug", + slug_keep_marker, + slug_keep_row + 1 + ), + slug_keep_name_style, + )])); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + format!("[{}]", mcp_slug), + Style::default().fg(palette.muted_fg), + ), + ])); + if slug_new_selected { + selected_line_idx = lines.len(); + } + lines.push(Line::from(vec![Span::styled( + format!( + " {} [{}] Generate new random slug", + slug_new_marker, + slug_new_row + 1 + ), + slug_new_name_style, + )])); + if domain_selected { + selected_line_idx = lines.len(); + } + lines.push(Line::from(vec![Span::styled( + format!( + " {} [{}] Set ngrok static domain", + domain_marker, + domain_row + 1 + ), + domain_name_style, + )])); + lines.push(Line::from(vec![ + Span::styled(" ", Style::default()), + Span::styled( + if let Some(domain) = ngrok_domain { + format!("[{}]", domain) + } else { + "[not set]".to_string() + }, + Style::default().fg(palette.muted_fg), + ), + ])); + lines.push(Line::from(vec![Span::styled( + " Pro tip: Your permanent ngrok-free.dev domain is auto-saved above.", + Style::default().fg(palette.muted_fg), + )])); lines.push(Line::from("")); lines.push(Line::from(Span::styled( " Token billing:", @@ -1730,7 +1970,12 @@ fn draw_settings( ), ])); - let body = Paragraph::new(lines).block( + let visible_height = chunks[1].height.saturating_sub(2); + let max_scroll = (lines.len() as u16).saturating_sub(visible_height); + let target_scroll = (selected_line_idx as u16).saturating_sub(visible_height / 2); + let scroll_y = target_scroll.min(max_scroll); + + let body = Paragraph::new(lines).scroll((scroll_y, 0)).block( Block::default() .title(" Theme, Tool Mode & Billing ") .borders(Borders::ALL) @@ -3057,59 +3302,81 @@ fn draw_ui( .fg(palette.primary_fg) .add_modifier(Modifier::BOLD | Modifier::UNDERLINED); let guide_lines = if show_guide { - vec![ - Line::from(vec![ - Span::styled(" 1. ", guide_step_style), - Span::styled("Open connector settings: ", guide_text_style), - Span::styled("(click to copy)", guide_detail_style), - ]), - Line::from(vec![ - Span::styled(" ", guide_text_style), - Span::styled( - "https://chatgpt.com/apps#settings/Connectors", - guide_copyable_style, - ), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" 2. ", guide_step_style), - Span::styled("Click ", guide_text_style), - Span::styled("Create app", guide_strong_style), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" 3. ", guide_step_style), - Span::styled("Fill in the form: ", guide_text_style), - Span::styled("(click to copy)", guide_detail_style), - ]), - Line::from(vec![ - Span::styled(" Name ", guide_detail_style), - Span::styled(" │ ", guide_separator_style), - Span::styled("CatDesk", guide_copyable_style), - ]), - Line::from(vec![ - Span::styled(" MCP Server URL", guide_detail_style), - Span::styled(" │ ", guide_separator_style), - Span::styled(mcp_url.clone(), guide_copyable_style), - ]), - Line::from(vec![ - Span::styled(" Authentication", guide_detail_style), - Span::styled(" │ ", guide_separator_style), - Span::styled("None", guide_copyable_style), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" 4. ", guide_step_style), - Span::styled("Click ", guide_text_style), - Span::styled("I understand and want to continue", guide_strong_style), - ]), - Line::from(""), - Line::from(vec![ - Span::styled(" 5. ", guide_step_style), - Span::styled("Click ", guide_text_style), - Span::styled("Create", guide_strong_style), - ]), - ] + if app.is_returning_user { + vec![ + Line::from(vec![ + Span::styled(" ✅ ", guide_step_style), + Span::styled("Connection URL is fixed and ready!", guide_strong_style), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" You do ", guide_text_style), + Span::styled("NOT", guide_strong_style), + Span::styled(" need to recreate the app in ChatGPT.", guide_text_style), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" Simply go to your ChatGPT conversation and send a message.", guide_text_style), + ]), + Line::from(vec![ + Span::styled(" CatDesk will instantly connect and this screen will disappear.", guide_detail_style), + ]), + ] + } else { + vec![ + Line::from(vec![ + Span::styled(" 1. ", guide_step_style), + Span::styled("Open connector settings: ", guide_text_style), + Span::styled("(click to copy)", guide_detail_style), + ]), + Line::from(vec![ + Span::styled(" ", guide_text_style), + Span::styled( + "https://chatgpt.com/apps#settings/Connectors", + guide_copyable_style, + ), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" 2. ", guide_step_style), + Span::styled("Click ", guide_text_style), + Span::styled("Create app", guide_strong_style), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" 3. ", guide_step_style), + Span::styled("Fill in the form: ", guide_text_style), + Span::styled("(click to copy)", guide_detail_style), + ]), + Line::from(vec![ + Span::styled(" Name ", guide_detail_style), + Span::styled(" │ ", guide_separator_style), + Span::styled("CatDesk", guide_copyable_style), + ]), + Line::from(vec![ + Span::styled(" MCP Server URL", guide_detail_style), + Span::styled(" │ ", guide_separator_style), + Span::styled(mcp_url.clone(), guide_copyable_style), + ]), + Line::from(vec![ + Span::styled(" Authentication", guide_detail_style), + Span::styled(" │ ", guide_separator_style), + Span::styled("None", guide_copyable_style), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" 4. ", guide_step_style), + Span::styled("Click ", guide_text_style), + Span::styled("I understand and want to continue", guide_strong_style), + ]), + Line::from(""), + Line::from(vec![ + Span::styled(" 5. ", guide_step_style), + Span::styled("Click ", guide_text_style), + Span::styled("Create", guide_strong_style), + ]), + ] + } } else { Vec::new() }; diff --git a/src/mascot.rs b/src/mascot.rs index e5a29ce..e2e1865 100644 --- a/src/mascot.rs +++ b/src/mascot.rs @@ -368,28 +368,29 @@ pub(crate) fn catdesk_downloads_root() -> std::io::Result { pub(crate) fn load_archived_binagotchy_cards() -> std::io::Result> { let root = catdesk_binagotchy_root()?; - let mut entries: Vec = fs::read_dir(&root)? - .map(|entry| entry.map(|value| value.path())) - .collect::, _>>()? + let mut entries: Vec = match fs::read_dir(&root) { + Ok(dir) => dir + .map(|entry| entry.map(|value| value.path())) + .collect::, _>>()?, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(vec![]), + Err(e) => return Err(e), + } .into_iter() .filter(|path| path.is_dir()) .collect(); entries.sort_by(|left, right| right.file_name().cmp(&left.file_name())); - entries + Ok(entries .into_iter() - .map(|entry| { + .filter_map(|entry| { let folder = entry .file_name() - .map(|value| value.to_string_lossy().to_string()) - .ok_or_else(|| { - std::io::Error::other("binagotchy archive is missing folder name") - })?; - let metadata_text = fs::read_to_string(entry.join(METADATA_FILE_NAME))?; + .map(|value| value.to_string_lossy().to_string())?; + let metadata_text = fs::read_to_string(entry.join(METADATA_FILE_NAME)).ok()?; let metadata: StoredMascotMetadata = - toml::from_str(&metadata_text).map_err(std::io::Error::other)?; - let bytes = fs::read(entry.join(CHARACTER_PNG_FILE_NAME))?; - Ok(ArchivedBinagotchyCard { + toml::from_str(&metadata_text).ok()?; + let bytes = fs::read(entry.join(CHARACTER_PNG_FILE_NAME)).ok()?; + Some(ArchivedBinagotchyCard { folder, seed: metadata.seed, image: format!( @@ -398,7 +399,7 @@ pub(crate) fn load_archived_binagotchy_cards() -> std::io::Result std::io::Result { @@ -1416,7 +1417,8 @@ mod tests { let metadata_text = std::fs::read_to_string(archive_dir.join(super::METADATA_FILE_NAME)) .expect("read metadata"); assert!(metadata_text.contains("seed = \"0000000000000001\"")); - assert!(metadata_text.contains("generator_version = \"4.0.0\"")); + let expected_version = format!("generator_version = \"{}\"", env!("CARGO_PKG_VERSION")); + assert!(metadata_text.contains(&expected_version)); let archive_png = image::open(archive_dir.join(super::CHARACTER_PNG_FILE_NAME)) .expect("open archive png") diff --git a/src/mcp.rs b/src/mcp.rs index d1d19df..1e69560 100644 --- a/src/mcp.rs +++ b/src/mcp.rs @@ -1444,6 +1444,10 @@ fn tool_descriptor_should_attach_widget(name: &str) -> bool { } fn ensure_tool_descriptor_widget_template(tool: &mut Value) { + if current_show_detail_mode() == ShowDetailMode::Disable { + return; + } + let Some(tool_obj) = tool.as_object_mut() else { return; }; @@ -1662,7 +1666,11 @@ fn current_token_stats_layout() -> TokenStatsLayout { } fn current_show_detail_mode() -> ShowDetailMode { - load_app_config() + #[cfg(test)] + { + return ShowDetailMode::Expanded; + } + crate::state::load_app_config() .map(|config| config.show_detail_mode) .unwrap_or_default() } @@ -1980,6 +1988,10 @@ fn enrich_tool_result( mut result: Value, widget_context: Option<&AutoWidgetContext>, ) -> Value { + if current_show_detail_mode() == ShowDetailMode::Disable { + return result; + } + if !result.is_object() { let value = result; result = json!({ diff --git a/src/ngrok.rs b/src/ngrok.rs index ce91264..edc85ab 100644 --- a/src/ngrok.rs +++ b/src/ngrok.rs @@ -18,12 +18,23 @@ pub async fn start(state: SharedState) -> Result<(), String> { .parse() .map_err(|e| format!("Invalid forward URL: {e}"))?; - let mut forwarder = ngrok::Session::builder() + let session = ngrok::Session::builder() .authtoken(authtoken) .connect() .await - .map_err(|e| format!("Failed to connect ngrok session: {e}"))? - .http_endpoint() + .map_err(|e| format!("Failed to connect ngrok session: {e}"))?; + + let mut http_endpoint = session.http_endpoint(); + if let Some(domain) = { + let app = state.lock().await; + app.ngrok_domain.clone() + } { + if !domain.is_empty() { + http_endpoint.domain(domain); + } + } + + let mut forwarder = http_endpoint .listen_and_forward(forwards_to) .await .map_err(|e| format!("Failed to open ngrok tunnel: {e}"))?; @@ -53,6 +64,16 @@ pub async fn start(state: SharedState) -> Result<(), String> { app.log("INFO", "ngrok SDK tunnel started".into()); app.log("INFO", format!("ngrok URL: {url}")); app.log("INFO", format!("MCP Server URL: {url}{mcp_path}")); + + if app.ngrok_domain.is_none() { + if let Ok(parsed_url) = reqwest::Url::parse(&url) { + if let Some(host) = parsed_url.host_str() { + app.ngrok_domain = Some(host.to_string()); + app.log("INFO", format!("Auto-saved ngrok static domain: {host}")); + app.persist_state_with_log(); + } + } + } } Ok(()) diff --git a/src/state.rs b/src/state.rs index 79ab801..2e652cd 100644 --- a/src/state.rs +++ b/src/state.rs @@ -108,6 +108,31 @@ pub enum ShowDetailMode { } impl ShowDetailMode { + pub fn all() -> &'static [ShowDetailMode] { + const MODES: [ShowDetailMode; 3] = [ + ShowDetailMode::Disable, + ShowDetailMode::Expanded, + ShowDetailMode::Collapsed, + ]; + &MODES + } + + pub fn label(self) -> &'static str { + match self { + Self::Disable => "Disable", + Self::Expanded => "Expanded", + Self::Collapsed => "Collapsed", + } + } + + pub fn description(self) -> &'static str { + match self { + Self::Disable => "Completely disable the web widget. Fastest and uses least memory.", + Self::Expanded => "Show the full web widget with syntax-highlighted diffs.", + Self::Collapsed => "Show the web widget but keep code changes collapsed by default.", + } + } + pub fn as_str(self) -> &'static str { match self { Self::Disable => "disable", @@ -121,6 +146,8 @@ impl ShowDetailMode { #[serde(rename_all = "camelCase")] pub struct AppConfig { pub ngrok_authtoken: Option, + pub mcp_slug: Option, + pub ngrok_domain: Option, #[serde(default)] pub agents_path_mode: AgentsPathMode, #[serde(default)] @@ -142,6 +169,8 @@ impl Default for AppConfig { fn default() -> Self { Self { ngrok_authtoken: None, + mcp_slug: None, + ngrok_domain: None, agents_path_mode: AgentsPathMode::Default, token_stats_layout: TokenStatsLayout::Right, show_detail_mode: ShowDetailMode::Expanded, @@ -499,7 +528,10 @@ pub struct AppState { pub theme: String, pub mode: Mode, pub tool_mode: ToolMode, + pub show_detail_mode: ShowDetailMode, pub mcp_slug: String, + pub ngrok_domain: Option, + pub is_returning_user: bool, pub server_running: bool, pub ngrok_running: bool, pub ngrok_url: Option, @@ -770,9 +802,14 @@ fn enqueue_flow_segment( } } -fn flow_bootstrap_step(index: usize) -> Option<&'static FlowBootstrapStep> { +fn flow_bootstrap_step(index: usize, mode: ShowDetailMode) -> Option<&'static FlowBootstrapStep> { let mut offset = 0; - for phase in FLOW_BOOTSTRAP_PHASES { + let phases_to_check = if mode == ShowDetailMode::Disable { + 2 + } else { + FLOW_BOOTSTRAP_PHASES.len() + }; + for phase in &FLOW_BOOTSTRAP_PHASES[..phases_to_check] { let end = offset + phase.steps.len(); if index < end { return phase.steps.get(index - offset); @@ -782,8 +819,13 @@ fn flow_bootstrap_step(index: usize) -> Option<&'static FlowBootstrapStep> { None } -fn flow_bootstrap_steps_total() -> usize { - FLOW_BOOTSTRAP_PHASES +pub fn flow_bootstrap_steps_total(mode: ShowDetailMode) -> usize { + let phases_to_check = if mode == ShowDetailMode::Disable { + 2 + } else { + FLOW_BOOTSTRAP_PHASES.len() + }; + FLOW_BOOTSTRAP_PHASES[..phases_to_check] .iter() .map(|phase| phase.steps.len()) .sum() @@ -809,12 +851,13 @@ fn advance_bootstrap_progress( pending_steps: &mut VecDeque, events: &[String], direction: FlowDirection, + mode: ShowDetailMode, ) { match direction { FlowDirection::Forward => { for event in events { let next_index = completed_steps.saturating_add(pending_steps.len()); - let Some(step) = flow_bootstrap_step(next_index) else { + let Some(step) = flow_bootstrap_step(next_index, mode) else { break; }; if step.event != event { @@ -832,7 +875,7 @@ fn advance_bootstrap_progress( let Some(pending_index) = pending_steps.front().copied() else { break; }; - let Some(step) = flow_bootstrap_step(pending_index) else { + let Some(step) = flow_bootstrap_step(pending_index, mode) else { pending_steps.clear(); break; }; @@ -877,11 +920,20 @@ impl AppState { if partner_binagotchy_seed.is_none() { mascot::archive_startup_mascot(mascot_seed)?; } + let is_returning_user = config.mcp_slug.is_some() && config.ngrok_domain.is_some(); + let mcp_slug = match config.mcp_slug { + Some(slug) if !slug.is_empty() => slug, + _ => generate_mcp_slug(), + }; + Ok(Self { theme: config.theme, mode: config.mode, tool_mode: config.tool_mode, - mcp_slug: generate_mcp_slug(), + show_detail_mode: config.show_detail_mode, + mcp_slug, + ngrok_domain: config.ngrok_domain.clone(), + is_returning_user, server_running: false, ngrok_running: false, ngrok_url: None, @@ -938,16 +990,23 @@ impl AppState { fn app_config(&self) -> std::io::Result { let mut config = AppConfig::load_from_path(&self.config_path)?; + config.mcp_slug = Some(self.mcp_slug.clone()); + config.ngrok_domain = self.ngrok_domain.clone(); config.partner_binagotchy_seed = self.partner_binagotchy_seed.clone(); config.set_catdesk_as_co_author = self.set_catdesk_as_co_author; config.theme = self.theme.clone(); config.mode = self.mode; config.tool_mode = self.tool_mode; + config.show_detail_mode = self.show_detail_mode; config.usage_totals = self.usage_totals.clone().normalized(); config.selected_browser = self.selected_browser.clone(); Ok(config.normalized()) } + pub fn regenerate_mcp_slug(&mut self) { + self.mcp_slug = generate_mcp_slug(); + } + pub fn persist_state(&self) -> std::io::Result<()> { self.app_config()?.save_to_path(&self.config_path) } @@ -1031,6 +1090,7 @@ impl AppState { &mut flow.bootstrap_pending_steps, events, direction, + self.show_detail_mode, ); bootstrap.completed_steps = flow.bootstrap_completed_steps; bootstrap.pending_steps = flow.bootstrap_pending_steps.clone(); @@ -1071,6 +1131,7 @@ impl AppState { &mut flow.bootstrap_pending_steps, events, direction, + self.show_detail_mode, ); bootstrap.completed_steps = flow.bootstrap_completed_steps; bootstrap.pending_steps = flow.bootstrap_pending_steps.clone(); @@ -1100,7 +1161,7 @@ impl AppState { pub fn prune_closed_flows(&mut self) { let now_ms = now_unix_millis(); - let bootstrap_steps_total = flow_bootstrap_steps_total(); + let bootstrap_steps_total = flow_bootstrap_steps_total(self.show_detail_mode); for flow in &mut self.flows { prune_finished_segments(&mut flow.anim_queue, now_ms); @@ -1604,8 +1665,8 @@ toolCallCount = 0 .map(|phase| phase.steps.len()) .collect(); assert_eq!(phase_step_counts, vec![4, 4, 10, 11, 4]); - assert_eq!(flow_bootstrap_steps_total(), 33); - assert_eq!(flow.bootstrap_completed_steps, flow_bootstrap_steps_total()); + assert_eq!(flow_bootstrap_steps_total(ShowDetailMode::Expanded), 33); + assert_eq!(flow.bootstrap_completed_steps, flow_bootstrap_steps_total(ShowDetailMode::Expanded)); assert!(flow.bootstrap_pending_steps.is_empty()); let _ = std::fs::remove_file(config_path);