diff --git a/apps/rocm/src/bootstrap.rs b/apps/rocm/src/bootstrap.rs index ca21c23b..ff698c10 100644 --- a/apps/rocm/src/bootstrap.rs +++ b/apps/rocm/src/bootstrap.rs @@ -19,10 +19,10 @@ pub(crate) fn run(command: Option) -> Result<()> { fn run_setup() -> Result<()> { if interactive_terminal() { - crate::tui::run_bootstrap_setup() + crate::dash::run_bootstrap() } else { println!( - "ROCm setup needs an interactive terminal. Run `rocm` from a terminal to choose an install folder and set up ROCm/TheRock." + "ROCm setup needs an interactive terminal. Run `rocm bootstrap setup` from a terminal to set up ROCm/TheRock." ); Ok(()) } diff --git a/apps/rocm/src/comfyui.rs b/apps/rocm/src/comfyui.rs index 2e0971bf..1c6920c0 100644 --- a/apps/rocm/src/comfyui.rs +++ b/apps/rocm/src/comfyui.rs @@ -199,95 +199,14 @@ pub(crate) fn render_status(paths: &AppPaths, config: &RocmCliConfig) -> Result< Ok(output) } -pub(crate) fn render_tui_status(paths: &AppPaths, config: &RocmCliConfig) -> Result { - let mut output = String::new(); - writeln!(output, "{APP_NAME}")?; - writeln!(output)?; - if let Some(manifest) = load_manifest(paths)? { - writeln!(output, "Installed")?; - writeln!(output, " status: ready")?; - writeln!( - output, - " ROCm install: {}", - therock::runtime_version_display(&manifest.runtime_version) - )?; - writeln!( - output, - " models path: {}", - models_folder_for_manifest(&manifest).display() - )?; - writeln!( - output, - " AMD GPU check: {}", - if manifest.torch_cuda_available { - "ready" - } else { - "needs attention" - } - )?; - } else { - writeln!(output, "Not installed yet")?; - writeln!(output, " Choose Install ComfyUI below.")?; - } - - writeln!(output)?; - if let Some(state) = load_state(paths)? { - let run_report = evaluate_running_state(&state); - writeln!(output, "Running")?; - match run_report.state { - ComfyUiRunState::Running => { - writeln!(output, " status: running")?; - writeln!(output, " URL: {}", state.url)?; - } - ComfyUiRunState::Starting => { - writeln!(output, " status: starting")?; - writeln!(output, " URL: {}", state.url)?; - writeln!(output, " Waiting for the browser page to answer.")?; - } - ComfyUiRunState::Stopped => { - writeln!(output, " status: stopped")?; - writeln!(output, " Choose Start ComfyUI below to run it again.")?; - } - } - } else { - writeln!(output, "Running")?; - if let Some(url) = default_unmanaged_running_url() { - writeln!(output, " status: running outside rocm-cli")?; - writeln!(output, " URL: {url}")?; - writeln!(output, " ROCm CLI did not start this ComfyUI process.")?; - } else { - writeln!(output, " status: not started")?; - } - } - - if config.active_runtime_key.is_none() && load_manifest(paths)?.is_none() { - writeln!(output)?; - writeln!(output, "ROCm")?; - writeln!(output, " Install ROCm first from Set Up ROCm.")?; - } - Ok(output) -} - pub(crate) fn render_logs(paths: &AppPaths, line_limit: usize) -> Result { render_logs_with_options(paths, line_limit, true) } -pub(crate) fn render_tui_logs( - paths: &AppPaths, - line_limit: usize, - show_file_locations: bool, -) -> Result { - render_logs_with_options(paths, line_limit, show_file_locations) -} - pub(crate) fn models_folder(paths: &AppPaths) -> Result> { Ok(load_manifest(paths)?.map(|manifest| models_folder_for_manifest(&manifest))) } -pub(crate) fn is_installed(paths: &AppPaths) -> Result { - Ok(load_manifest(paths)?.is_some()) -} - pub(crate) fn render_models_path(paths: &AppPaths) -> Result { let Some(path) = models_folder(paths)? else { bail!("ComfyUI is not installed yet. Run `rocm comfyui install` first."); @@ -295,21 +214,6 @@ pub(crate) fn render_models_path(paths: &AppPaths) -> Result { Ok(format!("{}\n", path.display())) } -pub(crate) fn running_url(paths: &AppPaths) -> Result> { - let Some(state) = load_state(paths)? else { - return Ok(default_unmanaged_running_url()); - }; - let report = evaluate_running_state(&state); - if matches!( - report.state, - ComfyUiRunState::Running | ComfyUiRunState::Starting - ) { - Ok(Some(state.url)) - } else { - Ok(None) - } -} - fn default_unmanaged_running_url() -> Option { unmanaged_running_url(COMFYUI_DEFAULT_HOST, COMFYUI_DEFAULT_PORT) } @@ -1980,96 +1884,6 @@ mod tests { Ok(()) } - #[test] - fn tui_status_reports_stale_saved_state_plainly() -> Result<()> { - let paths = test_paths("comfyui-tui-stale-state"); - let logs = app_root(&paths).join("logs"); - fs::create_dir_all(&logs)?; - let port = unused_local_port()?; - save_state( - &paths, - &ComfyUiState { - app_id: APP_ID.to_owned(), - url: format!("http://127.0.0.1:{port}"), - host: "127.0.0.1".to_owned(), - port, - pid: 0, - source_path: source_path(&paths), - python_executable: paths.data_dir.join("runtimes").join("python.exe"), - log_path: logs.join("start-200.log"), - started_at_unix_ms: 200, - }, - )?; - - let rendered = render_tui_status(&paths, &RocmCliConfig::default())?; - - assert!(rendered.contains("status: stopped")); - assert!(rendered.contains("Choose Start ComfyUI below")); - assert!(!rendered.contains("starting or running")); - Ok(()) - } - - #[test] - fn tui_status_hides_technical_file_paths() -> Result<()> { - let paths = test_paths("comfyui-tui-status"); - let logs = app_root(&paths).join("logs"); - fs::create_dir_all(&logs)?; - let install_log = logs.join("install-100.log"); - fs::write(&install_log, "install output\n")?; - save_manifest( - &paths, - &ComfyUiManifest { - app_id: APP_ID.to_owned(), - runtime_key: "therock-release:gfx120X-all".to_owned(), - runtime_id: "therock-release".to_owned(), - runtime_version: "7.13.0a20260511".to_owned(), - runtime_root: paths.data_dir.join("runtimes").join("runtime"), - python_executable: paths.data_dir.join("runtimes").join("python.exe"), - source_url: COMFYUI_SOURCE_ARCHIVE_URL.to_owned(), - source_path: source_path(&paths), - requirements_path: source_path(&paths).join("requirements.txt"), - pip_cache_dir: None, - log_path: install_log.clone(), - torch_version: Some("2.10.0".to_owned()), - torch_cuda_available: true, - installed_at_unix_ms: 100, - }, - )?; - - let rendered = render_tui_status(&paths, &RocmCliConfig::default())?; - - assert!(rendered.contains("Installed")); - assert!(rendered.contains("AMD GPU check: ready")); - assert!(!rendered.contains("Use the rows on the left")); - assert!(!rendered.contains("python")); - assert!(!rendered.contains("torch")); - assert!(!rendered.contains("saved file:")); - assert!(!rendered.contains(&install_log.display().to_string())); - Ok(()) - } - - #[test] - fn tui_logs_hide_file_paths_until_requested() -> Result<()> { - let paths = test_paths("comfyui-tui-logs"); - let logs = app_root(&paths).join("logs"); - fs::create_dir_all(&logs)?; - let install_log = logs.join("install-100.log"); - fs::write(&install_log, "downloaded ComfyUI\ninstalled packages\n")?; - - let friendly = render_tui_logs(&paths, 10, false)?; - assert!(friendly.contains("ComfyUI logs")); - assert!(friendly.contains("Install log")); - assert!(friendly.contains("downloaded ComfyUI")); - assert!(friendly.contains("installed packages")); - assert!(!friendly.contains("saved file:")); - assert!(!friendly.contains(&install_log.display().to_string())); - - let with_files = render_tui_logs(&paths, 10, true)?; - assert!(with_files.contains("saved file:")); - assert!(with_files.contains(&install_log.display().to_string())); - Ok(()) - } - #[test] fn default_runtime_selection_uses_single_ready_runtime() -> Result<()> { let paths = test_paths("comfyui-single-ready-runtime"); diff --git a/apps/rocm/src/dash.rs b/apps/rocm/src/dash.rs index dbb3671a..27c43df2 100644 --- a/apps/rocm/src/dash.rs +++ b/apps/rocm/src/dash.rs @@ -12,8 +12,7 @@ //! runs the ratatui dashboard TUI. //! //! The rest of `rocm` is synchronous; the async daemon/TUI run on a tokio -//! runtime built here. The two ratatui majors (0.29 in `tui.rs`, 0.30 in -//! `rocm-dash-tui`) coexist, each confined to its crate. +//! runtime built here. The TUI lives entirely in the `rocm-dash-tui` crate. use std::path::PathBuf; use std::time::Duration; @@ -183,6 +182,7 @@ pub fn resolved_args( theme: t.theme.clone(), replay: None, initial_tab, + start_onboarding: false, chat_url: t.chat_url.clone(), chat_model: t.chat_model.clone(), chat_auth_header: t.chat_auth_header.clone(), @@ -281,6 +281,26 @@ pub fn run_chat(chat_mock: bool) -> Result<()> { rt.block_on(run_async(config, paths, args, None, chat_mock)) } +/// Entry point for `rocm bootstrap setup`. Opens the dashboard straight into the +/// first-run onboarding wizard (install ROCm SDK / adopt an existing folder). +/// Same runtime/`run_async` path as [`run`]; the keyring lookup in +/// `resolved_args` runs here on the synchronous thread before the runtime. +pub fn run_bootstrap() -> Result<()> { + let paths = AppPaths::discover()?; + let config = RocmCliConfig::load(&paths)?; + let args = bootstrap_args(&config, &paths); + let rt = build_dashboard_runtime()?; + rt.block_on(run_async(config, paths, args, None, false)) +} + +/// Resolve the dashboard args for `rocm bootstrap setup`: the standard +/// dashboard args with the first-run onboarding wizard opened on launch. +fn bootstrap_args(config: &RocmCliConfig, paths: &AppPaths) -> ResolvedArgs { + let mut args = resolved_args(config, paths, ActiveTab::Home); + args.start_onboarding = true; + args +} + async fn run_async( config: RocmCliConfig, paths: AppPaths, @@ -395,6 +415,27 @@ mod tests { assert!(!opts.enable_docker); } + #[test] + fn bootstrap_args_open_the_onboarding_wizard() { + // `rocm bootstrap setup` must launch the dashboard straight into the + // first-run onboarding overlay (install ROCm SDK / adopt existing). + let args = bootstrap_args(&cfg(), &paths()); + assert!( + args.start_onboarding, + "bootstrap must open the onboarding wizard on launch" + ); + assert_eq!(args.initial_tab, ActiveTab::Home); + assert!(!args.chat_mock); + assert!(args.replay.is_none()); + } + + #[test] + fn resolved_args_default_does_not_open_onboarding() { + // Normal `rocm dash` / `rocm chat` must NOT auto-open onboarding. + let args = resolved_args(&cfg(), &paths(), ActiveTab::Home); + assert!(!args.start_onboarding); + } + #[test] fn resolved_args_take_connect_and_theme_from_config() { let c = cfg(); diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index 2b24fa20..ab257a61 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -10,15 +10,8 @@ mod dash_seam; mod provider_keys; mod providers; mod therock; -mod tui; mod uninstall; -// tui.rs is RETAINED pending human go/no-go deletion. -// bare `rocm` and `rocm chat` now route to the dash chat (parity reached, Phases 3-8). -// This anchor keeps the frozen module compiling (referenced, never invoked) until deletion. -#[allow(dead_code)] -const _RETAINED_TUI_ENTRY: fn(Option) -> anyhow::Result<()> = tui::run; - // Per-command handler fns mechanically relocated into modules. // Dispatch call sites stay byte-identical via these re-imports (upstream-sync // mergeability); only the fn definitions moved out of main.rs. @@ -860,7 +853,8 @@ fn launch_default() -> Result<()> { if interactive_terminal() { // Bare `rocm` opens the minimal launcher front door; "Open full // dashboard" / `d` escalates into the unified dash, "Chat" reaches the - // Chat tab. `rocm dash` / `rocm chat` bypass the launcher. + // Chat tab. `rocm dash` / `rocm chat` bypass the launcher. The legacy + // TUI assistant has been retired. return dash::run_launcher(false); } @@ -1345,8 +1339,7 @@ fn dispatch(cli: Cli) -> Result<()> { // (parity reached, Phases 3-8). The dash auto-detects the // provider and supports `/provider` switching; --provider is // honored only on the non-interactive render path below. The - // legacy `tui::run` assistant is retained but no longer invoked - // here. + // legacy TUI assistant has been retired. if provider.is_some() { // --provider isn't threaded into the interactive dash; say so // instead of dropping it silently — the user can switch live. @@ -2344,69 +2337,11 @@ fn passive_render_node_check() -> DriverPassiveCheck { } } -fn driver_install_args_require_tui_approval(args: &[&str]) -> Result { - let flags = parse_driver_install_flags(args)?; - let examine = ExamineSummary::gather()?; - let os_release = read_os_release().unwrap_or_default(); - let plan = build_driver_install_plan(&examine, &os_release, flags.dkms); - Ok(driver_install_flags_require_tui_approval(&plan, &flags)) -} - -fn driver_install_flags_require_approval( - plan: &DriverInstallPlan, - flags: &DriverInstallFlags, -) -> bool { - if flags.reconcile { - return false; - } - driver_plan_approval_label(plan, flags.yes, flags.dry_run) == "required" -} - -fn driver_install_flags_require_tui_approval( - plan: &DriverInstallPlan, - flags: &DriverInstallFlags, -) -> bool { - let mut tui_flags = *flags; - tui_flags.yes = false; - driver_install_flags_require_approval(plan, &tui_flags) -} - -fn parse_driver_install_flags(args: &[&str]) -> Result { - if args.first() != Some(&"driver") { - bail!("Usage: /install driver [--dkms] [--dry-run] [--yes] [--reconcile]"); - } - let mut flags = DriverInstallFlags::default(); - for arg in args.iter().skip(1) { - match *arg { - "--dkms" => flags.dkms = true, - "--yes" => flags.yes = true, - "--dry-run" => flags.dry_run = true, - "--reconcile" => flags.reconcile = true, - value if value.starts_with('-') => bail!("Unknown driver install option: {value}"), - value => bail!("Unexpected driver install argument: {value}"), - } - } - if flags.reconcile && (flags.dkms || flags.yes || flags.dry_run) { - bail!( - "Usage: /install driver --reconcile\n\n--reconcile cannot be combined with --dkms, --yes, or --dry-run" - ); - } - Ok(flags) -} - struct DriverInstallResult { output: String, executed: bool, } -#[derive(Clone, Copy, Default)] -struct DriverInstallFlags { - dkms: bool, - yes: bool, - dry_run: bool, - reconcile: bool, -} - struct DriverInstallError { source: anyhow::Error, executed: bool, @@ -7350,10 +7285,6 @@ fn render_install_sdk_folder_needed_chat_text(prompt: &str, args: &[String]) -> output.trim_end().to_owned() } -pub(crate) fn chat_install_folder_from_prompt(prompt: &str) -> Option { - requested_install_prefix_from_prompt(prompt).or_else(|| clean_requested_install_prefix(prompt)) -} - fn prompt_requests_rocm_install_or_setup(normalized: &str) -> bool { let mentions_rocm_stack = any_substring( normalized, @@ -11346,16 +11277,6 @@ pub(crate) fn render_logs_browser_page_text( render_logs_browser_page_text_with_options(paths, query, page, page_size, true) } -pub(crate) fn render_logs_browser_page_text_for_tui( - paths: &AppPaths, - query: Option<&str>, - page: usize, - page_size: usize, - show_file_locations: bool, -) -> String { - render_logs_browser_page_text_with_options(paths, query, page, page_size, show_file_locations) -} - fn render_logs_browser_page_text_with_options( paths: &AppPaths, query: Option<&str>, @@ -11478,16 +11399,6 @@ fn render_logs_browser_page_text_with_options( output } -pub(crate) fn logs_browser_page_count( - paths: &AppPaths, - query: Option<&str>, - page_size: usize, -) -> usize { - let entries = collect_log_browser_entries(paths); - let matching_entries = filter_log_browser_entries(&entries, query); - logs_browser_total_pages(matching_entries.len(), page_size.max(1)) -} - fn logs_browser_total_pages(item_count: usize, page_size: usize) -> usize { item_count.div_ceil(page_size).max(1) } @@ -11661,14 +11572,6 @@ pub(crate) fn render_service_logs_text(paths: &AppPaths, service_id: &str) -> Re render_service_logs_text_with_options(paths, service_id, true) } -pub(crate) fn render_service_logs_text_for_tui( - paths: &AppPaths, - service_id: &str, - show_file_locations: bool, -) -> Result { - render_service_logs_text_with_options(paths, service_id, show_file_locations) -} - fn render_service_logs_text_with_options( paths: &AppPaths, service_id: &str, @@ -12707,76 +12610,6 @@ pub(crate) fn render_daemon_text(paths: &AppPaths, config: &RocmCliConfig) -> St output } -pub(crate) fn render_sidebar_text( - paths: &AppPaths, - config: &RocmCliConfig, - provider: &str, - setup_ready: bool, - host_gpu_summary: Option<&rocm_core::HostGpuSummary>, -) -> String { - let records = load_managed_services(paths).unwrap_or_default(); - let server_counts = managed_service_sidebar_counts(&records); - let default_engine = config - .default_engine - .as_deref() - .filter(|value| !value.trim().is_empty()) - .or_else(|| host_gpu_summary.and_then(preferred_serve_engine_for_host_gpu_summary)) - .unwrap_or(default_engine_for_platform()); - let mut output = String::new(); - let _ = writeln!(output, "ROCm CLI"); - let _ = writeln!( - output, - "Setup: {}", - if setup_ready { "ready" } else { "not set up" } - ); - let _ = writeln!(output, "Assistant: {}", friendly_provider_label(provider)); - let _ = writeln!( - output, - "Model runner: {}", - friendly_engine_label(default_engine) - ); - let _ = writeln!( - output, - "Changes: {}", - if config.permissions.full_access_enabled() { - "full access" - } else { - "ask first" - } - ); - let _ = writeln!( - output, - "Local servers: {}", - local_server_sidebar_status(&server_counts) - ); - let enabled_watchers = builtin_watchers() - .iter() - .filter(|watcher| config.watcher_enabled(watcher)) - .count(); - let _ = writeln!(output, "Background checks: {enabled_watchers}"); - let _ = writeln!( - output, - "Background helper: {}", - if config.automation_daemon_enabled() { - "needed" - } else { - "off" - } - ); - let _ = writeln!(output); - let _ = writeln!(output, "Choose Run setup check for details."); - output -} - -fn friendly_provider_label(provider: &str) -> &str { - match provider { - "local" => "local model", - "openai" => "OpenAI", - "anthropic" => "Anthropic", - other => other, - } -} - fn friendly_engine_label(engine: &str) -> &str { match engine { "lemonade" => "Lemonade", @@ -12933,142 +12766,6 @@ pub(crate) fn load_managed_services(paths: &AppPaths) -> Result String { - let mut output = String::new(); - let _ = writeln!(output, "slash commands"); - let _ = writeln!(output, " /home return to the main dashboard"); - let _ = writeln!(output, " /help show this help"); - let _ = writeln!( - output, - " /examine check this computer and ROCm setup" - ); - let _ = writeln!( - output, - " /setup reopen the simple ROCm setup screen" - ); - let _ = writeln!( - output, - " status, reset, and skip are available when needed" - ); - let _ = writeln!(output, " /permissions show or change confirmation mode"); - let _ = writeln!( - output, - " /runtimes install, choose, add, remove, or roll back ROCm installs" - ); - let _ = writeln!( - output, - " /install install ROCm or check driver setup" - ); - let _ = writeln!( - output, - " adopt adds an existing Python ROCm folder as read-only" - ); - let _ = writeln!( - output, - " /engine choose or install local model engines" - ); - let _ = writeln!( - output, - " /model [NAME] list recipes or choose a known model" - ); - let _ = writeln!(output, " /plan TEXT render an explicit request plan"); - let _ = writeln!(output, " /config show saved settings"); - let _ = writeln!( - output, - " set-planner-provider enables optional provider help for ambiguous plans" - ); - let _ = writeln!( - output, - " /automations show background checks and review requests" - ); - let _ = writeln!( - output, - " /reviews open review requests that need a decision" - ); - let _ = writeln!( - output, - " /approve review the selected request before approving" - ); - let _ = writeln!( - output, - " /reject review the selected request before rejecting" - ); - let _ = writeln!( - output, - " /edit open review request editing from Automations" - ); - let _ = writeln!(output, " /services show local model servers"); - let _ = writeln!(output, " /logs [QUERY] browse or search recent activity"); - let _ = writeln!( - output, - " use /logs --search when the query matches a service id" - ); - let _ = writeln!( - output, - " use /logs --service for managed service logs" - ); - let _ = writeln!( - output, - " use /logs next, /logs prev, or /logs refresh after opening the log browser" - ); - let _ = writeln!( - output, - " /gpu show the latest local GPU snapshot" - ); - let _ = writeln!(output, " /update show update policy"); - let _ = writeln!(output, " /daemon show background helper status"); - let _ = writeln!(output, " /chat TEXT stream a provider chat response"); - let _ = writeln!(output, " /provider X switch provider for this session"); - let _ = writeln!( - output, - " /uninstall preview what uninstall would remove" - ); - let _ = writeln!( - output, - " /serve MODEL open a guided local model server setup" - ); - let _ = writeln!(output, " /clear clear the transcript"); - let _ = writeln!(output, " /quit, /exit exit the TUI"); - let _ = writeln!(output); - let _ = writeln!(output, "keyboard"); - let _ = writeln!(output, " F1 open interactive help"); - let _ = writeln!(output, " F5 refresh the current screen"); - let _ = writeln!( - output, - " / open the command popup on normal menu screens" - ); - let _ = writeln!( - output, - " Tab complete slash commands and arguments" - ); - let _ = writeln!( - output, - " Up/Down choose items or recall input history" - ); - let _ = writeln!(output, " PgUp/PgDn scroll the current view"); - let _ = writeln!(output, " Mouse wheel scroll long text and log cards"); - let _ = writeln!(output, " Home/End jump to top or bottom"); - let _ = writeln!(output); - let _ = writeln!(output, "logs"); - let _ = writeln!( - output, - " Targeted logs such as /comfyui logs and service logs open in a scrollable card" - ); - let _ = writeln!(output); - let _ = writeln!(output, "natural language"); - let _ = writeln!(output, " serve qwen with lemonade"); - let _ = writeln!(output, " install ROCm"); - let _ = writeln!(output, " uninstall rocm-cli"); - let _ = writeln!(output); - let _ = writeln!(output, "examples"); - let _ = writeln!(output, " config set-default-engine lemonade"); - let _ = writeln!(output, " config set-telemetry local"); - let _ = writeln!(output, " /engine install lemonade --reinstall"); - let _ = writeln!(output, " automations enable server-recover"); - output -} - pub(crate) fn render_freeform_plan( request: &str, paths: &AppPaths, @@ -13078,11 +12775,6 @@ pub(crate) fn render_freeform_plan( render_structured_request_plan(&plan, paths) } -pub(crate) fn freeform_plan_uses_provider(request: &str, config: &RocmCliConfig) -> bool { - let plan = build_freeform_plan(request, config); - freeform_plan_needs_ambiguity_resolution(&plan) && configured_planner_provider(config).is_some() -} - #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct FreeformPlanAction { pub title: String, @@ -13198,6 +12890,7 @@ impl PlannedToolCall { } } +#[cfg(test)] fn build_freeform_plan(request: &str, config: &RocmCliConfig) -> StructuredRequestPlan { build_freeform_plan_with_recipes(request, config, None) } @@ -18993,7 +18686,6 @@ install therock"; let rendered = render_logs_browser_page_text(&paths, Some("alpha"), 1, 4); - assert_eq!(logs_browser_page_count(&paths, Some("alpha"), 4), 2); assert!(rendered.contains(" Page: 2 of 2")); assert!(rendered.contains(" Showing: 5-6 of 6")); assert!(!rendered.contains("alpha-1")); @@ -19934,56 +19626,6 @@ VERSION_CODENAME=noble assert!(rendered.contains("rerun with --yes")); } - #[test] - fn driver_install_approval_only_applies_to_supported_mutating_unapproved_plan() -> Result<()> { - let os_release = r#" -ID=ubuntu -VERSION_ID="24.04" -VERSION_CODENAME=noble -"#; - let dkms_plan = build_driver_install_plan(&test_examine("linux", false), os_release, true); - let preflight_plan = - build_driver_install_plan(&test_examine("linux", false), os_release, false); - let windows_plan = build_driver_install_plan(&test_examine("windows", false), "", true); - - let dkms_flags = parse_driver_install_flags(&["driver", "--dkms"])?; - let dry_run_flags = parse_driver_install_flags(&["driver", "--dkms", "--dry-run"])?; - let approved_flags = parse_driver_install_flags(&["driver", "--dkms", "--yes"])?; - let preflight_flags = parse_driver_install_flags(&["driver"])?; - - assert!(driver_install_flags_require_approval( - &dkms_plan, - &dkms_flags - )); - assert!(!driver_install_flags_require_approval( - &dkms_plan, - &dry_run_flags - )); - assert!(!driver_install_flags_require_approval( - &dkms_plan, - &approved_flags - )); - assert!(driver_install_flags_require_tui_approval( - &dkms_plan, - &approved_flags - )); - assert!(!driver_install_flags_require_approval( - &preflight_plan, - &preflight_flags - )); - assert!(!driver_install_flags_require_approval( - &windows_plan, - &dkms_flags - )); - let reconcile_flags = parse_driver_install_flags(&["driver", "--reconcile"])?; - assert!(!driver_install_flags_require_approval( - &dkms_plan, - &reconcile_flags - )); - assert!(parse_driver_install_flags(&["driver", "--reconcile", "--dkms"]).is_err()); - Ok(()) - } - #[test] fn driver_reconcile_without_state_gives_non_privileged_guidance() -> Result<()> { let (root, paths) = test_paths("driver-reconcile-empty"); @@ -21548,37 +21190,6 @@ VERSION_ID="41" Ok(()) } - #[test] - fn sidebar_omits_saved_failed_service_history() -> Result<()> { - let (root, paths) = test_paths("sidebar-service-counts"); - for index in 0..6 { - let mut record = ManagedServiceRecord::new( - &paths, - format!("svc-failed-{index}"), - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435 + index, - "managed", - 1000 + u32::from(index), - None, - None, - None, - ); - record.status = "failed".to_owned(); - record.write()?; - } - - let rendered = render_sidebar_text(&paths, &RocmCliConfig::default(), "local", true, None); - let _ = fs::remove_dir_all(root); - - assert!(rendered.contains("Local servers: none ready")); - assert!(!rendered.contains("past attempts")); - assert!(!rendered.contains("running servers: 6")); - Ok(()) - } - #[test] fn render_automations_text_uses_plain_proposal_history() -> Result<()> { let (root, paths) = test_paths("automation-plain-proposal-history"); @@ -22518,27 +22129,4 @@ VERSION_ID="41" "non-interactive no-prompt path must still call render_chat_text; body:\n{body}" ); } - - #[test] - fn tui_run_is_referenced_only_by_the_retention_anchor() { - // tui.rs is RETAINED (not deleted). The ONLY non-comment reference to - // `tui::run` in the PRODUCTION part of main.rs (before `mod tests`) must - // be the retention anchor; the interactive handlers must not invoke it. - // (The test module itself names `tui::run` in assertion strings.) - let full = main_rs_source(); - let prod = full - .split("mod tests {") - .next() - .expect("production source before the tests module"); - let src = strip_line_comments(prod); - let count = src.matches("tui::run").count(); - assert_eq!( - count, 1, - "expected exactly one tui::run reference (the retention anchor), found {count}" - ); - assert!( - src.contains("_RETAINED_TUI_ENTRY"), - "the retention anchor _RETAINED_TUI_ENTRY must keep tui::run reachable" - ); - } } diff --git a/apps/rocm/src/tui.rs b/apps/rocm/src/tui.rs deleted file mode 100644 index 7a482063..00000000 --- a/apps/rocm/src/tui.rs +++ /dev/null @@ -1,44997 +0,0 @@ -// Copyright © Advanced Micro Devices, Inc., or its affiliates. -// -// SPDX-License-Identifier: MIT - -use crate::providers::{self, ChatMessage, ChatRequest}; -use crate::{ - ChatToolApprovalRequest, FreeformPlanAction, activate_runtime, engine_inventory, - format_structured_tool_call, freeform_plan_next_action_with_context, - freeform_plan_uses_provider, load_managed_services, logs_browser_page_count, - managed_service_is_live, managed_service_sidebar_counts, provider_keys, - render_automations_text, render_chat_text, render_daemon_text, render_examine_text, - render_freeform_plan, render_logs_browser_page_text_for_tui, render_service_logs_text_for_tui, - render_sidebar_text, render_uninstall_dry_run, render_update_text, runtime_usability_status, - therock, -}; -use anyhow::{Context, Result, bail}; -use crossterm::{ - event::{ - self, DisableMouseCapture, EnableMouseCapture, Event, KeyCode, KeyEvent, KeyEventKind, - KeyModifiers, MouseButton, MouseEvent, MouseEventKind, - }, - execute, - terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, size, - }, -}; -use ratatui::{ - Frame, Terminal, TerminalOptions, Viewport, - backend::CrosstermBackend, - layout::{Constraint, Direction, Layout, Rect, Size}, - style::{Color, Modifier, Style}, - text::{Line, Span, Text}, - widgets::{ - Block, Borders, Clear, List, ListItem, ListState, Paragraph, Scrollbar, - ScrollbarOrientation, ScrollbarState, Wrap, - }, -}; -use rocm_core::{ - AppPaths, AuditEventRecord, AutomationProposalRecord, DEFAULT_LOCAL_HOST, DEFAULT_LOCAL_PORT, - HostGpuSummary, ManagedServiceRecord, ModelRecipeRecord, PERMISSIONS_MODE_ASK, - PERMISSIONS_MODE_FULL_ACCESS, RocmCliConfig, TELEMETRY_MODE_LOCAL, TELEMETRY_MODE_OFF, - WatcherMode, append_audit_event, builtin_watcher, builtin_watchers, current_executable_path, - daemon_binary_path, default_engine_for_platform, detect_host_gpu_summary, - find_automation_proposal, format_host_for_url, format_host_port, format_http_base_url, - load_model_recipe_registry, load_recent_automation_proposals, managed_pip_cache_dir, - managed_service_endpoint_model_ready, preferred_serve_engine_for_host_gpu_summary, - replace_automation_proposal, resolve_amd_smi_binary, runtime_directory_label, - runtime_drive_root_for_key, runtime_drive_roots, runtime_is_windows, - runtime_path_is_same_or_inside, runtime_path_sort_key, sanitize_component, unix_time_millis, - update_automation_proposal_status, -}; -use serde::Deserialize; -use serde_json::Value; -use std::borrow::Borrow; -use std::cell::Cell; -use std::collections::{BTreeMap, BTreeSet, VecDeque}; -use std::fmt::Write as _; -use std::fs; -use std::io::{self, BufReader, Read, Seek, SeekFrom, Stdout}; -use std::path::{Path, PathBuf}; -use std::process::{Command as ProcessCommand, Stdio}; -use std::sync::{ - Arc, - atomic::{AtomicBool, Ordering}, - mpsc::{self, Receiver, TryRecvError}, -}; -use std::time::{Duration, Instant}; - -const GPU_MONITOR_REFRESH_INTERVAL: Duration = Duration::from_secs(1); -const GPU_STATIC_REFRESH_INTERVAL: Duration = Duration::from_mins(5); -const GPU_ERROR_RETRY_INTERVAL: Duration = Duration::from_secs(5); -const GPU_AMD_SMI_TIMEOUT: Duration = Duration::from_millis(1_500); -const TUI_IDLE_POLL_INTERVAL: Duration = Duration::from_millis(250); -const TUI_ACTIVE_POLL_INTERVAL: Duration = Duration::from_millis(75); -const TUI_SERVICE_READY_TIMEOUT: Duration = Duration::from_millis(250); -const FALLBACK_ESCAPE_SEQUENCE_TIMEOUT: Duration = Duration::from_millis(30); -const MAX_COMMAND_OUTPUT_LINES: usize = 240; -const MAX_COMPLETION_MENU_ITEMS: usize = 8; -const MAX_ACTIVITY_LOG_ITEMS: usize = 3; -const MAX_STREAM_EVENTS_PER_POLL: usize = 40; -const ACTIVITY_LOG_TAIL_BYTES: u64 = 64 * 1024; -const TUI_LOG_BROWSER_PAGE_SIZE: usize = 24; -const LOG_FOLLOW_REFRESH_INTERVAL: Duration = Duration::from_secs(1); -const ONBOARDING_INSTALL_LOG_LINES: usize = 10; -const PAGE_SCROLL_LINES: i16 = 10; -const MOUSE_SCROLL_LINES: i16 = 3; -const CHAT_SESSION_MAX_TURNS: usize = 20; -const CHAT_SESSION_FOLLOW_SCROLL: u16 = 10_000; -const ACTIVE_SCREEN_RECENT_OUTPUT_LINES: usize = 120; -const RUNNING_JOB_OUTPUT_LINES: usize = 1000; -const FALLBACK_TERMINAL_COLUMNS: u16 = 120; -const FALLBACK_TERMINAL_ROWS: u16 = 36; -const VALIDATED_LOCAL_ASSISTANT_MODEL: &str = "Qwen3-4B-Instruct-2507-GGUF"; -const THEME_BG: Color = Color::Rgb(13, 15, 18); -const THEME_PANEL: Color = Color::Rgb(19, 20, 22); -const THEME_PANEL_2: Color = Color::Rgb(29, 31, 35); -const THEME_PANEL_3: Color = Color::Rgb(39, 39, 42); -const THEME_BORDER: Color = Color::Rgb(101, 107, 114); -const THEME_MUTED: Color = Color::Rgb(156, 160, 165); -const THEME_TEXT: Color = Color::Rgb(234, 235, 236); -const THEME_ACCENT: Color = Color::Rgb(0, 194, 222); -const THEME_GOOD: Color = Color::Rgb(26, 160, 26); -const THEME_WARN: Color = Color::Rgb(245, 158, 11); -const THEME_DANGER: Color = Color::Rgb(237, 28, 36); -const THEME_SHADOW: Color = Color::Rgb(4, 5, 7); -const RUNTIME_ADOPT_REQUIRED_OPTIONS: &[&str] = &["--python"]; -const RUNTIME_ADOPT_ALL_OPTIONS: &[&str] = &[ - "--python", - "--channel", - "--root", - "--runtime-id", - "--runtime-key", - "--replace", -]; -const RUNTIME_ADOPT_TUI_OPTIONS: &[&str] = &["--python", "--channel"]; -const UPDATE_USAGE: &str = - "Usage: /update\n /update --apply [--runtime ] [--preview] [--activate]"; - -#[derive(Debug, Clone, Copy)] -struct SlashCommandSpec { - name: &'static str, - usage: &'static str, -} - -const SLASH_COMMANDS: &[SlashCommandSpec] = &[ - SlashCommandSpec { - name: "home", - usage: "/home", - }, - SlashCommandSpec { - name: "help", - usage: "/help", - }, - SlashCommandSpec { - name: "?", - usage: "/?", - }, - SlashCommandSpec { - name: "examine", - usage: "/examine", - }, - SlashCommandSpec { - name: "setup", - usage: "/setup [status|reset|skip]", - }, - SlashCommandSpec { - name: "permissions", - usage: "/permissions [status|full-access|ask]", - }, - SlashCommandSpec { - name: "runtimes", - usage: "/runtimes", - }, - SlashCommandSpec { - name: "engine", - usage: "/engine [list|install ...]", - }, - SlashCommandSpec { - name: "model", - usage: "/model", - }, - SlashCommandSpec { - name: "plan", - usage: "/plan ", - }, - SlashCommandSpec { - name: "config", - usage: "/config [show|set ...]", - }, - SlashCommandSpec { - name: "automations", - usage: "/automations [list|reviews|approve|reject|edit|enable|disable]", - }, - SlashCommandSpec { - name: "reviews", - usage: "/reviews [id]", - }, - SlashCommandSpec { - name: "approve", - usage: "/approve [review-id]", - }, - SlashCommandSpec { - name: "reject", - usage: "/reject [review-id]", - }, - SlashCommandSpec { - name: "edit", - usage: "/edit [review-id]", - }, - SlashCommandSpec { - name: "install", - usage: "/install", - }, - SlashCommandSpec { - name: "comfyui", - usage: "/comfyui [status|install|start|logs]", - }, - SlashCommandSpec { - name: "services", - usage: "/services [logs|stop|restart]", - }, - SlashCommandSpec { - name: "logs", - usage: "/logs [query|follow|stop|next|prev|--search |--service ]", - }, - SlashCommandSpec { - name: "gpu", - usage: "/gpu [refresh]", - }, - SlashCommandSpec { - name: "update", - usage: "/update | /update --apply [--runtime ] [--preview] [--activate]", - }, - SlashCommandSpec { - name: "daemon", - usage: "/daemon", - }, - SlashCommandSpec { - name: "chat", - usage: "/chat [prompt]", - }, - SlashCommandSpec { - name: "provider", - usage: "/provider [local|anthropic|openai]", - }, - SlashCommandSpec { - name: "uninstall", - usage: "/uninstall", - }, - SlashCommandSpec { - name: "serve", - usage: "/serve ...", - }, - SlashCommandSpec { - name: "clear", - usage: "/clear", - }, - SlashCommandSpec { - name: "quit", - usage: "/quit", - }, - SlashCommandSpec { - name: "exit", - usage: "/exit", - }, -]; - -pub fn run(initial_provider: Option) -> Result<()> { - let paths = AppPaths::discover()?; - let mut app = App::new(paths, initial_provider); - run_app(&mut app) -} - -pub(crate) fn run_bootstrap_setup() -> Result<()> { - let paths = AppPaths::discover()?; - let mut app = App::new(paths, Some("local".to_owned())); - app.onboarding_active = true; - app.home_dashboard_visible = false; - app.reset_onboarding_selection(); - app.status = "Set up ROCm. Choose an option, then press Enter.".to_owned(); - run_app(&mut app) -} - -fn run_app(app: &mut App) -> Result<()> { - enable_raw_mode().context("failed to enable raw mode for rocm TUI")?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture) - .context("failed to enter alternate screen")?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = create_terminal(backend).context("failed to initialize terminal backend")?; - - let result = run_loop(&mut terminal, app); - - disable_raw_mode().context("failed to disable raw mode for rocm TUI")?; - execute!( - terminal.backend_mut(), - DisableMouseCapture, - LeaveAlternateScreen - ) - .context("failed to leave alternate screen")?; - terminal.show_cursor().context("failed to restore cursor")?; - - result -} - -fn create_terminal( - backend: CrosstermBackend, -) -> io::Result>> { - if let Some(area) = fallback_terminal_viewport() { - Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Fixed(area), - }, - ) - } else { - Terminal::new(backend) - } -} - -fn fallback_terminal_viewport() -> Option { - match size() { - Ok((width, height)) if width > 1 && height > 1 => None, - _ => { - let size = fallback_terminal_size_from_env().unwrap_or(Size { - width: FALLBACK_TERMINAL_COLUMNS, - height: FALLBACK_TERMINAL_ROWS, - }); - Some(Rect::new(0, 0, size.width, size.height)) - } - } -} - -fn fallback_terminal_size_from_env() -> Option { - fallback_terminal_size_from_values( - std::env::var("COLUMNS").ok().as_deref(), - std::env::var("LINES").ok().as_deref(), - ) -} - -/// Best-effort terminal dimensions for layout math performed outside the draw -/// loop (e.g. resolving a follow-the-bottom scroll before the first paint). -/// Falls back to the COLUMNS/LINES env or a sane default when the real terminal -/// size is unavailable, such as in a headless/no-TTY process (CI). -fn effective_terminal_size() -> (u16, u16) { - if let Ok((width, height)) = size() - && width > 1 - && height > 1 - { - return (width, height); - } - let fallback = fallback_terminal_size_from_env().unwrap_or(Size { - width: FALLBACK_TERMINAL_COLUMNS, - height: FALLBACK_TERMINAL_ROWS, - }); - (fallback.width, fallback.height) -} - -fn fallback_terminal_size_from_values(columns: Option<&str>, lines: Option<&str>) -> Option { - let width = parse_terminal_dimension(columns?)?; - let height = parse_terminal_dimension(lines?)?; - if width > 1 && height > 1 { - Some(Size { width, height }) - } else { - None - } -} - -fn parse_terminal_dimension(value: &str) -> Option { - value.trim().parse::().ok().filter(|value| *value > 1) -} - -struct App { - paths: AppPaths, - config: RocmCliConfig, - provider: String, - provider_key_store: Arc, - mode: TuiMode, - host_gpu_summary: HostGpuSummary, - gpu_telemetry: GpuTelemetry, - transcript: Vec, - transcript_scroll: u16, - home_dashboard_visible: bool, - home_selection: usize, - input: String, - input_cursor: usize, - chat_input_scroll: u16, - completion_selection: usize, - history: Vec, - history_index: Option, - activity_log: VecDeque, - status: String, - should_quit: bool, - pending_approval: Option, - running_job: Option, - running_job_log_scroll: usize, - running_job_output: VecDeque, - running_job_cancel_requested: Option>, - examine_manager: Option, - logs_view: Option, - runtime_manager: Option, - install_manager: Option, - engine_manager: Option, - model_picker: Option, - serve_wizard: Option, - update_manager: Option, - automations_manager: Option, - provider_manager: Option, - config_manager: Option, - services_manager: Option, - command_screen: Option, - command_screen_last_area: Cell>, - saved_chat_session: Option, - chat_stop_service_id: Option, - tui_owned_service_ids: BTreeSet, - tui_started_comfyui: bool, - overlay_card: Option, - folder_browser: Option, - onboarding_active: bool, - onboarding_selection: usize, - onboarding_path_editing: bool, - onboarding_install_log: VecDeque, - onboarding_install_log_scroll: usize, - onboarding_install_failure: Option, - onboarding_setup_error: Option, - onboarding_install_full_log: Option, - onboarding_show_log_location: bool, - onboarding_cancel_install_confirm: bool, - onboarding_cancel_install_selection: usize, - onboarding_success_modal: bool, - onboarding_channel: InstallSdkChannel, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum TuiMode { - Ask, - Act, - Serve, - Logs, - Automations, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HomeDashboardAction { - Setup, - Examine, - Serve, - Chat, - ComfyUi, - Services, - Engine, - Permissions, - Help, - Quit, -} - -fn tui_mode_for_command(head: &str, has_args: bool) -> TuiMode { - match head { - "automations" | "reviews" | "proposals" | "approve" | "reject" | "edit" => { - TuiMode::Automations - } - "logs" => TuiMode::Logs, - "serve" => TuiMode::Serve, - "model" if has_args => TuiMode::Serve, - "config" | "daemon" | "engine" | "install" | "permissions" | "runtimes" | "setup" - | "uninstall" | "update" => TuiMode::Act, - _ => TuiMode::Ask, - } -} - -fn is_examine_command_input(command: &str) -> bool { - let Some(rest) = command.strip_prefix("examine") else { - return false; - }; - rest.is_empty() || rest.chars().next().is_some_and(char::is_whitespace) -} - -fn tui_mode_for_cli_args(args: &[String]) -> TuiMode { - args.first().map_or(TuiMode::Ask, |head| { - tui_mode_for_command(head, args.len() > 1) - }) -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct TranscriptLine { - kind: TranscriptLineKind, - text: String, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum TranscriptLineKind { - #[allow(dead_code)] - UserInput, - BlockHeader { - title: String, - }, - BlockBody { - title: String, - }, - Stream { - stream: CommandOutputStream, - }, - Assistant, - Blank, -} - -impl TranscriptLine { - #[allow(dead_code)] - fn user_input(input: &str) -> Self { - Self { - kind: TranscriptLineKind::UserInput, - text: format!("> {input}"), - } - } - - fn block_header(title: &str) -> Self { - Self { - kind: TranscriptLineKind::BlockHeader { - title: title.to_owned(), - }, - text: format!("[{title}]"), - } - } - - fn block_body(title: &str, line: &str) -> Self { - Self { - kind: TranscriptLineKind::BlockBody { - title: title.to_owned(), - }, - text: line.to_owned(), - } - } - - fn stream(stream: CommandOutputStream, line: &str) -> Self { - Self { - kind: TranscriptLineKind::Stream { stream }, - text: format!("[{}] {line}", stream.label()), - } - } - - fn assistant(line: &str) -> Self { - Self { - kind: TranscriptLineKind::Assistant, - text: line.to_owned(), - } - } - - const fn blank() -> Self { - Self { - kind: TranscriptLineKind::Blank, - text: String::new(), - } - } -} - -impl std::ops::Deref for TranscriptLine { - type Target = str; - - fn deref(&self) -> &Self::Target { - &self.text - } -} - -impl Borrow for TranscriptLine { - fn borrow(&self) -> &str { - &self.text - } -} - -impl PartialEq<&str> for TranscriptLine { - fn eq(&self, other: &&str) -> bool { - self.text == *other - } -} - -impl PartialEq for TranscriptLine { - fn eq(&self, other: &str) -> bool { - self.text == other - } -} - -#[derive(Debug)] -struct CommandOutput { - ok: bool, - rendered: String, - chat_approval: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CompletionMenu { - title: String, - items: Vec, - replacements: Vec>, - selected: usize, -} - -impl CompletionMenu { - fn selectable_indices(&self) -> Vec { - self.replacements - .iter() - .enumerate() - .filter_map(|(index, replacement)| replacement.as_ref().map(|_| index)) - .collect() - } - - fn selected_replacement(&self) -> Option<&str> { - self.replacements - .get(self.selected) - .and_then(Option::as_deref) - } -} - -fn completion_visible_range(menu: &CompletionMenu) -> (usize, usize) { - let visible_len = menu.items.len().min(MAX_COMPLETION_MENU_ITEMS); - if visible_len == 0 { - return (0, 0); - } - let selected = menu.selected.min(menu.items.len().saturating_sub(1)); - let max_start = menu.items.len().saturating_sub(visible_len); - let start = selected.saturating_sub(visible_len / 2).min(max_start); - let end = start.saturating_add(visible_len).min(menu.items.len()); - (start, end) -} - -#[derive(Debug, Clone, Copy)] -enum CompletionDirection { - Next, - Previous, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct LogsViewState { - query: Option, - service_id: Option, - page: usize, - follow: bool, - show_file_locations: bool, - selected: usize, - detail_scroll: u16, - last_rendered: String, - last_refresh_at: Instant, - editing_search: bool, - detail_modal: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ExamineManagerState { - selected: usize, - detail_scroll: u16, - report: String, - message: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ExamineManagerChoice { - Overview, - Gpu, - Runtimes, - Engines, - DriverAndWsl, - Folders, - Back, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LogsViewAction { - PreviousPage, - NextPage, - Search, - ClearSearch, - ToggleFollow, - ToggleFileLocations, - Back, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CommandScreenState { - title: String, - detail: String, - selected: usize, - detail_scroll: u16, - detail_modal: Option, - refresh_target: Option, - actions: Vec, - plan_request: Option, - plan_action: Option, - chat_prompt: Option, - chat_tools: bool, - chat_model: Option, - chat_session: Option, - message: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct CommandScreenDetailModal { - title: String, - detail: String, - scroll: u16, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ChatSessionState { - provider: String, - model: Option, - service_id: Option, - endpoint_url: Option, - turns: Vec, - pending_install_args: Option>, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ChatSessionTurn { - role: ChatSessionRole, - content: String, - detail: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ChatSessionRole { - User, - Assistant, - Tool, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CommandScreenTarget { - Help, - Permissions, - Gpu, - Daemon, - UninstallPreview, - ChatProvider, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CommandScreenAction { - RunPlan, - EditPlan, - SendChat, - EditChat, - ViewChatConnection, - ViewChatResult, - EnableFullAccess, - ResetPermissions, - OpenCommand(&'static str), - Back, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct OverlayCardState { - title: String, - detail: String, - selected: usize, - detail_scroll: u16, - actions: Vec, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OverlayCardAction { - OpenCommand(&'static str), - HelpTopic(HelpTopic), - ClearTranscript, - ClearProviderKey, - ShowSetupAgain, - StopChatAssistant, - Quit, - Close, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum HelpTopic { - GettingStarted, - TuiBasics, - Setup, - LocalModels, - Assistant, - ComfyUi, - Commands, - KeyboardShortcuts, - Troubleshooting, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct FolderBrowserState { - title: String, - prompt: String, - current_dir: PathBuf, - entries: Vec, - selected: usize, - scroll_offset: usize, - message: Option, - context: FolderBrowserContext, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct FolderBrowserEntry { - label: String, - path: PathBuf, - kind: FolderBrowserEntryKind, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum FolderBrowserEntryKind { - UseCurrent, - NewChild, - Parent, - Drive, - Directory, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum FolderBrowserContext { - InstallSdk { args: Vec }, - InstallSdkForm, - OnboardingInstallFolder, -} - -#[derive(Debug, Clone)] -struct AutomationsManagerState { - items: Vec, - watchers: Vec, - rows: Vec, - selected: usize, - detail_scroll: u16, - overview: String, - editing_prefetch_max_bytes: Option, - message: Option, -} - -#[derive(Debug, Clone)] -struct AutomationReviewItem { - proposal: AutomationProposalRecord, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct AutomationWatcherItem { - id: String, - name: String, - enabled: bool, - mode: WatcherMode, - summary: String, - trigger: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AutomationManagerRow { - Review(usize), - PrefetchField(usize, AutomationPrefetchField), - Watcher(usize), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum AutomationManagerRowKey { - Review(String), - PrefetchField(String, AutomationPrefetchField), - Watcher(String), -} - -#[derive(Debug, Clone)] -enum AutomationManagerSelectedRow { - Review(AutomationProposalRecord), - PrefetchField(AutomationProposalRecord, AutomationPrefetchField), - Watcher(AutomationWatcherItem), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum AutomationPrefetchField { - AllowDownload, - MaxBytes, - AllowHuggingFace, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ProviderManagerState { - selected: usize, - detail_scroll: u16, - message: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ConfigManagerState { - selected: usize, - detail_scroll: u16, - message: Option, - screen: ConfigManagerScreen, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConfigMenuChoice { - Telemetry, - PlannerProvider, - OpenAiProvider, - AnthropicProvider, - DefaultEngine, - DefaultRuntime, - Permissions, - ShowSetupAgain, - Back, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ConfigManagerScreen { - Main, - Provider { - provider: String, - selected: usize, - editing_key: bool, - confirming_clear: bool, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ConfigProviderChoice { - PromptSending, - ApiKey, - ClearKey, - ConfirmClearKey, - CancelClearKey, - Back, -} - -#[derive(Debug, Clone)] -struct ServicesManagerState { - items: Vec, - selected: usize, - last_item_selected: usize, - detail_scroll: u16, - message: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServicesManagerAction { - Details, - Chat, - OpenLogs, - Stop, - Restart, - Back, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServiceLifecycleAction { - Stop, - Restart, -} - -impl ServiceLifecycleAction { - const fn command_word(self) -> &'static str { - match self { - Self::Stop => "stop", - Self::Restart => "restart", - } - } - - const fn tool_name(self) -> &'static str { - match self { - Self::Stop => "stop_server", - Self::Restart => "restart_server", - } - } - - fn approval_title(self, _service_id: &str) -> String { - match self { - Self::Stop => "Stop selected server".to_owned(), - Self::Restart => "Restart selected server".to_owned(), - } - } - - const fn success_title(self) -> &'static str { - match self { - Self::Stop => "Service stopped.", - Self::Restart => "Service restart started.", - } - } - - const fn failure_title(self) -> &'static str { - match self { - Self::Stop => "Service stop failed.", - Self::Restart => "Service restart failed.", - } - } -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RuntimeManagerState { - items: Vec, - selected: usize, - last_item_selected: usize, - detail_scroll: u16, - message: Option, - screen: RuntimeManagerScreen, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct RuntimeManagerItem { - runtime_key: String, - runtime_id: String, - channel: String, - version: String, - family: String, - format: String, - install_root: PathBuf, - status: String, - read_only: bool, - imported_from: Option, - active: bool, - previous: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum RuntimeManagerScreen { - List, - Advanced { - selected: usize, - }, - ImportManifest { - selected: usize, - manifest_path: String, - replace: bool, - editing_path: bool, - }, - AdoptExisting { - selected: usize, - python_path: String, - channel: RuntimeAdoptChannel, - replace: bool, - editing_python: bool, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeAdoptChannel { - Release, - Nightly, -} - -impl RuntimeAdoptChannel { - const fn as_str(self) -> &'static str { - match self { - Self::Release => "release", - Self::Nightly => "nightly", - } - } - - const fn toggle(self) -> Self { - match self { - Self::Release => Self::Nightly, - Self::Nightly => Self::Release, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeManagerAction { - Install, - RemoveSelected, - AdvancedOptions, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeAdvancedChoice { - AdoptExisting, - ImportManifest, - Back, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeImportChoice { - ManifestPath, - Replace, - Import, - Back, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum RuntimeAdoptChoice { - PythonPath, - Channel, - Replace, - Adopt, - Back, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct InstallManagerState { - selected: usize, - detail_scroll: u16, - message: Option, - screen: InstallManagerScreen, - return_to_runtime_manager_on_back: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum InstallManagerScreen { - Main, - Sdk { - selected: usize, - channel: InstallSdkChannel, - format: InstallSdkFormat, - folder: String, - editing_folder: bool, - }, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InstallSdkChannel { - Release, - Nightly, -} - -impl InstallSdkChannel { - const fn as_str(self) -> &'static str { - match self { - Self::Release => "release", - Self::Nightly => "nightly", - } - } - - const fn toggle(self) -> Self { - match self { - Self::Release => Self::Nightly, - Self::Nightly => Self::Release, - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InstallSdkFormat { - Wheel, - Tarball, -} - -impl InstallSdkFormat { - const fn as_str(self) -> &'static str { - match self { - Self::Wheel => "wheel", - Self::Tarball => "tarball", - } - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InstallSdkChoice { - Channel, - Folder, - Install, - Back, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct EngineManagerState { - items: Vec, - selected: usize, - last_item_selected: usize, - detail_scroll: u16, - message: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EngineManagerAction { - UseSelected, - InstallSelected, - ReinstallSelected, - InstallRocm, - Back, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum EngineManagerRow { - Item(usize), - Action(EngineManagerAction), -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ModelPickerState { - items: Vec, - selected: usize, - detail_scroll: u16, - message: Option, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct ServeWizardState { - recipes: Vec, - selected: usize, - detail_scroll: u16, - model_source: ServeModelSource, - model_index: usize, - local_model_path: String, - engine_index: usize, - device_index: usize, - host: String, - port: String, - managed: bool, - runtime_id: Option, - env_id: Option, - allow_public_bind: bool, - editing_field: Option, - message: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServeModelSource { - BuiltInRecipe, - LocalPath, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServeWizardEditField { - LocalPath, - Host, - Port, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum ServeWizardChoice { - Source, - Model, - LocalPath, - Engine, - Device, - Host, - Port, - Mode, - Review, - Back, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum ServeRuntimePreflight { - UseRuntime(String), - NeedsInstall, - NeedsChoice, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct EngineManagerItem { - name: String, - description: String, - installed_env_id: Option, - is_default: bool, - runtime_ready: bool, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -struct UpdateManagerState { - selected: usize, - detail_scroll: u16, - report: String, - message: Option, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum UpdateMenuChoice { - Refresh, - Preview, - Apply, - ApplyAndUse, - Back, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum InstallMenuChoice { - Runtime, - DriverPreview, - Back, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ProviderOption { - name: &'static str, - label: &'static str, - description: &'static str, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum LogPageDirection { - Next, - Previous, - Refresh, -} - -struct RunningJob { - title: String, - kind: RunningJobKind, - receiver: Receiver, - started_at: Instant, - streamed_lines: usize, - streamed_stdout_lines: usize, - streamed_stderr_lines: usize, -} - -#[derive(Debug, Clone)] -enum RunningJobKind { - Cli, - ExamineRefresh, - Chat { - provider: String, - rocm_tools: bool, - }, - Planner { - title: String, - request: String, - }, - Proposal { - proposal_id: String, - }, - ServiceLifecycle { - action: ServiceLifecycleAction, - service_id: String, - }, -} - -#[derive(Debug)] -enum RunningJobEvent { - Stream { - stream: CommandOutputStream, - line: String, - }, - ChatDelta { - content: String, - }, - Finished(Result), -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum CommandOutputStream { - Stdout, - Stderr, -} - -impl CommandOutputStream { - const fn label(self) -> &'static str { - match self { - Self::Stdout => "stdout", - Self::Stderr => "stderr", - } - } -} - -#[derive(Debug, Clone, Copy, Default)] -struct StreamedOutputCounts { - total: usize, - stdout: usize, - stderr: usize, -} - -#[derive(Debug, Clone, Default, PartialEq, Eq)] -struct ServeCommandPlan { - model: Option, - engine: Option, - device: Option, - runtime_id: Option, - env_id: Option, - host: Option, - port: Option, - foreground: bool, - managed: bool, - allow_public_bind: bool, -} - -impl ServeCommandPlan { - fn selected_engine<'a>(&'a self, config: &'a RocmCliConfig) -> &'a str { - self.engine - .as_deref() - .or(config.default_engine.as_deref()) - .unwrap_or(default_engine_for_platform()) - } - - fn engine_manages_own_runtime(&self, config: &RocmCliConfig) -> bool { - self.selected_engine(config) - .eq_ignore_ascii_case("lemonade") - } - - fn cli_args(&self) -> Vec { - let mut args = vec!["serve".to_owned()]; - if let Some(model) = self.model.as_ref() { - args.push(model.clone()); - } - push_optional_cli_arg(&mut args, "--engine", self.engine.as_deref()); - push_optional_cli_arg(&mut args, "--device", self.device.as_deref()); - push_optional_cli_arg(&mut args, "--runtime-id", self.runtime_id.as_deref()); - push_optional_cli_arg(&mut args, "--env-id", self.env_id.as_deref()); - push_optional_cli_arg(&mut args, "--host", self.host.as_deref()); - if let Some(port) = self.port { - args.push("--port".to_owned()); - args.push(port.to_string()); - } - if self.foreground { - args.push("--foreground".to_owned()); - } - if self.managed { - args.push("--managed".to_owned()); - } - if self.allow_public_bind { - args.push("--allow-public-bind".to_owned()); - } - args - } -} - -#[derive(Debug, Clone)] -struct PendingApproval { - title: String, - selected: usize, - detail_scroll: u16, - action: ApprovalAction, -} - -#[derive(Debug, Clone)] -enum ApprovalAction { - CliCommand { - title: String, - args: Vec, - display_command: Option, - explanation: Option, - }, - RocmdCommand { - title: String, - args: Vec, - kind: RunningJobKind, - }, - EnableFullAccess, - Proposal { - proposal_id: String, - }, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -enum UpdateCommandPlan { - Report, - DryRun(Vec), - Approval(Vec), -} - -#[derive(Default)] -struct GpuTelemetry { - static_cards: BTreeMap, - monitor_cards: BTreeMap, - last_static_refresh: Option, - last_monitor_refresh: Option, - last_error: Option, - last_error_at: Option, -} - -#[derive(Debug, Clone)] -struct GpuStaticCard { - market_name: Option, - gfx_target: Option, - compute_units: Option, -} - -#[derive(Debug, Clone)] -struct GpuMonitorCard { - power_watts: Option, - max_power_watts: Option, - hotspot_celsius: Option, - memory_celsius: Option, - gfx_mhz: Option, - gfx_percent: Option, - mem_percent: Option, - mem_mhz: Option, - vram_used_mb: Option, - vram_total_mb: Option, - vram_percent: Option, -} - -#[derive(Debug, Deserialize)] -struct AmdSmiStaticResponse { - #[serde(default)] - gpu_data: Vec, -} - -#[derive(Debug, Deserialize)] -struct AmdSmiStaticEntry { - gpu: u32, - #[serde(default)] - asic: Option, -} - -#[derive(Debug, Deserialize)] -struct AmdSmiAsic { - #[serde(default)] - market_name: Option, - #[serde(default)] - num_compute_units: Option, - #[serde(default)] - target_graphics_version: Option, -} - -#[derive(Debug, Deserialize)] -struct AmdSmiMetricValue { - #[serde(default)] - value: Value, -} - -#[derive(Debug, Deserialize)] -struct AmdSmiMonitorEntry { - gpu: u32, - #[serde(default)] - power_usage: Option, - #[serde(default)] - max_power: Option, - #[serde(default)] - hotspot_temperature: Option, - #[serde(default)] - memory_temperature: Option, - #[serde(default)] - gfx_clk: Option, - #[serde(default)] - gfx: Option, - #[serde(default)] - mem: Option, - #[serde(default)] - mem_clock: Option, - #[serde(default)] - vram_used: Option, - #[serde(default)] - vram_total: Option, - #[serde(default)] - vram_percent: Option, -} - -impl AmdSmiMetricValue { - fn as_f64(&self) -> Option { - match &self.value { - Value::Number(number) => number.as_f64(), - Value::String(text) => text.trim().parse::().ok(), - _ => None, - } - } -} - -impl GpuTelemetry { - fn maybe_refresh(&mut self) { - let now = Instant::now(); - if let Some(last_error_at) = self.last_error_at - && now.duration_since(last_error_at) < GPU_ERROR_RETRY_INTERVAL - { - return; - } - - let static_due = self.static_cards.is_empty() - || self - .last_static_refresh - .is_none_or(|last| now.duration_since(last) >= GPU_STATIC_REFRESH_INTERVAL); - if static_due { - match load_gpu_static_cards() { - Ok(cards) => { - self.static_cards = cards; - self.last_static_refresh = Some(now); - self.clear_error(); - } - Err(error) => self.record_error(format!("static: {error}")), - } - } - - let monitor_due = self.monitor_cards.is_empty() - || self - .last_monitor_refresh - .is_none_or(|last| now.duration_since(last) >= GPU_MONITOR_REFRESH_INTERVAL); - if monitor_due { - match load_gpu_monitor_cards() { - Ok(cards) => { - self.monitor_cards = cards; - self.last_monitor_refresh = Some(now); - self.clear_error(); - } - Err(error) => self.record_error(format!("monitor: {error}")), - } - } - } - - fn force_refresh(&mut self) { - self.last_static_refresh = None; - self.last_monitor_refresh = None; - self.last_error_at = None; - self.maybe_refresh(); - } - - fn clear(&mut self) { - self.static_cards.clear(); - self.monitor_cards.clear(); - self.last_static_refresh = None; - self.last_monitor_refresh = None; - self.last_error = None; - self.last_error_at = None; - } - - fn sidebar_text(&self) -> String { - let mut output = String::new(); - - let _ = writeln!(output, "GPU status:"); - if self.static_cards.is_empty() && self.monitor_cards.is_empty() { - if let Some(error) = &self.last_error { - let _ = writeln!(output, " status: live usage unavailable"); - let _ = writeln!(output, " note: {}", friendly_gpu_telemetry_error(error)); - } else { - let _ = writeln!(output, " status: checking GPU"); - } - return output.trim_end().to_owned(); - } - - let _ = writeln!( - output, - " GPUs: {}", - self.monitor_cards.len().max(self.static_cards.len()) - ); - if let Some(model) = self.primary_model_name() { - let _ = writeln!(output, " model: {model}"); - } - if let Some(age) = self.last_monitor_refresh.map(|value| value.elapsed()) { - let _ = writeln!(output, " updated: {}", format_elapsed(age)); - } - if let Some(summary) = self.aggregate_line() { - let _ = writeln!(output, " activity: {summary}"); - } else if self.monitor_cards.is_empty() { - let _ = writeln!(output, " live usage: unavailable"); - } - for line in self.per_gpu_lines() { - let _ = writeln!(output, " {line}"); - } - if let Some(error) = &self.last_error { - let _ = writeln!(output, " note: {}", friendly_gpu_telemetry_error(error)); - } - output.trim_end().to_owned() - } - - fn detail_text(&self) -> String { - let mut output = String::new(); - - let _ = writeln!(output, "GPU status"); - if self.static_cards.is_empty() && self.monitor_cards.is_empty() { - let _ = writeln!(output); - let _ = writeln!(output, "No GPU details are available yet."); - if let Some(error) = &self.last_error { - let _ = writeln!(output, "Note: {}", friendly_gpu_telemetry_error(error)); - } else { - let _ = writeln!(output, "Press F5 to check again."); - } - return output; - } - - let _ = writeln!(output); - if let Some(age) = self.last_monitor_refresh.map(|value| value.elapsed()) { - let _ = writeln!(output, "Last updated: {}", format_elapsed(age)); - } - if let Some(summary) = self.aggregate_line() { - let _ = writeln!(output, "Activity: {summary}"); - } - for gpu in self.all_gpu_ids() { - let static_card = self.static_cards.get(&gpu); - let monitor_card = self.monitor_cards.get(&gpu); - let _ = writeln!(output); - let _ = writeln!(output, "GPU {gpu}"); - let _ = writeln!( - output, - " Model: {}", - static_card - .and_then(|card| card.market_name.as_deref()) - .unwrap_or("unknown") - ); - let _ = writeln!( - output, - " ROCm target: {}", - static_card - .and_then(|card| card.gfx_target.as_deref()) - .unwrap_or("unknown") - ); - let _ = writeln!( - output, - " Compute units: {}", - static_card - .and_then(|card| card.compute_units) - .map_or_else(|| "unknown".to_owned(), |value| value.to_string()) - ); - let _ = writeln!( - output, - " Activity: {}", - format_optional_percent(monitor_card.and_then(|card| card.gfx_percent)) - ); - let _ = writeln!( - output, - " Memory activity: {}", - format_optional_percent(monitor_card.and_then(|card| card.mem_percent)) - ); - let _ = writeln!( - output, - " Power: {}", - format_optional_watts(monitor_card.and_then(|card| card.power_watts)) - ); - let _ = writeln!( - output, - " Temperature: {}", - format_optional_celsius(monitor_card.and_then(|card| card.hotspot_celsius)) - ); - let _ = writeln!( - output, - " Memory temperature: {}", - format_optional_celsius(monitor_card.and_then(|card| card.memory_celsius)) - ); - let _ = writeln!( - output, - " GPU clock: {}", - format_optional_mhz(monitor_card.and_then(|card| card.gfx_mhz)) - ); - let _ = writeln!( - output, - " Memory clock: {}", - format_optional_mhz(monitor_card.and_then(|card| card.mem_mhz)) - ); - let _ = writeln!(output, " VRAM: {}", format_vram_line(monitor_card)); - } - if let Some(error) = &self.last_error { - let _ = writeln!(output); - let _ = writeln!(output, "Note: {}", friendly_gpu_telemetry_error(error)); - } - output - } - - fn clear_error(&mut self) { - self.last_error = None; - self.last_error_at = None; - } - - fn record_error(&mut self, message: String) { - self.last_error = Some(message); - self.last_error_at = Some(Instant::now()); - } - - fn primary_model_name(&self) -> Option<&str> { - let first = self - .static_cards - .values() - .find_map(|card| card.market_name.as_deref())?; - if self - .static_cards - .values() - .all(|card| card.market_name.as_deref() == Some(first)) - { - Some(first) - } else { - Some("mixed") - } - } - - fn aggregate_line(&self) -> Option { - if self.monitor_cards.is_empty() { - return None; - } - - let avg_gfx = average( - self.monitor_cards - .values() - .filter_map(|card| card.gfx_percent), - ); - let avg_mem = average( - self.monitor_cards - .values() - .filter_map(|card| card.mem_percent), - ); - let avg_hot = average( - self.monitor_cards - .values() - .filter_map(|card| card.hotspot_celsius), - ); - let total_power = self - .monitor_cards - .values() - .filter_map(|card| card.power_watts) - .sum::(); - let total_max_power = self - .monitor_cards - .values() - .filter_map(|card| card.max_power_watts) - .sum::(); - let total_vram_used = self - .monitor_cards - .values() - .filter_map(|card| card.vram_used_mb) - .sum::(); - let total_vram_total = self - .monitor_cards - .values() - .filter_map(|card| card.vram_total_mb) - .sum::(); - let total_vram_percent = if total_vram_total > 0.0 { - Some((total_vram_used / total_vram_total) * 100.0) - } else { - None - }; - - Some(format!( - "avg gfx {:.0}% mem {:.0}% hot {:.0}C pwr {:.0}/{:.0}W vr {:.1}%", - avg_gfx.unwrap_or(0.0), - avg_mem.unwrap_or(0.0), - avg_hot.unwrap_or(0.0), - total_power, - total_max_power, - total_vram_percent.unwrap_or(0.0) - )) - } - - fn total_vram_gib(&self) -> Option { - let total_mb = self - .monitor_cards - .values() - .filter_map(|card| card.vram_total_mb) - .sum::(); - (total_mb > 0.0).then_some(total_mb / 1024.0) - } - - fn per_gpu_lines(&self) -> Vec { - self.all_gpu_ids() - .into_iter() - .map(|gpu| { - let card = self.monitor_cards.get(&gpu); - let gfx_target = self - .static_cards - .get(&gpu) - .and_then(|item| item.gfx_target.as_deref()) - .unwrap_or(""); - format!( - "g{gpu:02} {:>4} {:>5} {:>4} {:>5}{}{}", - format_optional_percent(card.and_then(|item| item.gfx_percent)), - format_optional_watts(card.and_then(|item| item.power_watts)), - format_optional_celsius(card.and_then(|item| item.hotspot_celsius)), - format_optional_percent(card.and_then(|item| item.vram_percent)), - if gfx_target.is_empty() { "" } else { " " }, - gfx_target, - ) - .trim_end() - .to_owned() - }) - .collect() - } - - fn all_gpu_ids(&self) -> Vec { - let mut ids = self.static_cards.keys().copied().collect::>(); - for gpu in self.monitor_cards.keys().copied() { - if !ids.contains(&gpu) { - ids.push(gpu); - } - } - ids.sort_unstable(); - ids - } -} - -fn friendly_gpu_telemetry_error(error: &str) -> &'static str { - let lower = error.to_ascii_lowercase(); - if lower.contains("amd-smi") { - "live GPU usage is not available in this terminal" - } else { - "live GPU usage is not available right now" - } -} - -fn disabled_gpu_telemetry_text(config: &RocmCliConfig) -> String { - let mut output = String::new(); - let _ = writeln!(output, "GPU status:"); - let _ = writeln!(output, " status: turned off"); - if config.telemetry.known_mode() { - let _ = writeln!( - output, - " local checks are off; rocm-cli still does not send GPU data anywhere" - ); - let _ = writeln!(output, " choose Settings to turn local checks on"); - } else { - let _ = writeln!( - output, - " saved telemetry setting `{}` is not recognized, so local checks stay off", - config.telemetry.mode_label() - ); - let _ = writeln!(output, " choose Settings to pick a valid option"); - } - output.trim_end().to_owned() -} - -fn render_permissions_text(config: &RocmCliConfig) -> String { - let mut output = String::new(); - let full_access = config.permissions.full_access_enabled(); - let _ = writeln!(output, "permissions"); - let _ = writeln!( - output, - " mode: {}", - if full_access { - "full access" - } else { - "ask before changes" - } - ); - let _ = writeln!(output); - if full_access { - let _ = writeln!( - output, - "rocm-cli can run local setup commands without asking again." - ); - let _ = writeln!( - output, - "It still cannot bypass Windows permission prompts, missing drivers, or missing GPU support." - ); - let _ = writeln!(output); - let _ = writeln!( - output, - "Choose Ask before changes to turn confirmations back on." - ); - } else { - let _ = writeln!( - output, - "rocm-cli asks before changing files or starting services." - ); - let _ = writeln!(output); - let _ = writeln!( - output, - "Choose Allow full access, then approve it, to let rocm-cli continue setup without repeated confirmations." - ); - } - output.trim_end().to_owned() -} - -fn runtime_manager_items( - paths: &AppPaths, - config: &RocmCliConfig, -) -> Result> { - let manifests = therock::load_runtime_manifests(paths)?; - let default_matches = config - .default_runtime_id - .as_deref() - .map(|runtime_id| { - manifests - .iter() - .filter(|manifest| manifest.runtime_id.eq_ignore_ascii_case(runtime_id)) - .collect::>() - }) - .unwrap_or_default(); - let single_default_runtime_key = - if config.active_runtime_key.is_none() && default_matches.len() == 1 { - Some(default_matches[0].runtime_key.clone()) - } else { - None - }; - drop(default_matches); - - Ok(manifests - .into_iter() - .map(|manifest| { - let active = config - .active_runtime_key - .as_deref() - .is_some_and(|runtime_key| runtime_key.eq_ignore_ascii_case(&manifest.runtime_key)) - || single_default_runtime_key - .as_deref() - .is_some_and(|runtime_key| { - runtime_key.eq_ignore_ascii_case(&manifest.runtime_key) - }); - let previous = config - .previous_runtime_key - .as_deref() - .is_some_and(|runtime_key| runtime_key.eq_ignore_ascii_case(&manifest.runtime_key)); - let status = runtime_usability_status(&manifest); - RuntimeManagerItem { - runtime_key: manifest.runtime_key, - runtime_id: manifest.runtime_id, - channel: manifest.channel, - version: manifest.version, - family: manifest.family, - format: manifest.format, - install_root: manifest.install_root, - status, - read_only: manifest.read_only, - imported_from: manifest.imported_from, - active, - previous, - } - }) - .collect()) -} - -fn engine_manager_items(paths: &AppPaths, config: &RocmCliConfig) -> Vec { - let default_engine = config - .default_engine - .clone() - .unwrap_or_else(|| default_engine_for_platform().to_owned()); - let runtime_ready = onboarding_runtime_selectors(paths, config).is_some(); - engine_inventory() - .iter() - .map(|(name, description)| EngineManagerItem { - name: (*name).to_owned(), - description: (*description).to_owned(), - installed_env_id: ready_engine_env_id(paths, config, name), - is_default: *name == default_engine.as_str(), - runtime_ready, - }) - .collect() -} - -fn render_update_manager_report(paths: &AppPaths) -> String { - match render_update_text(paths) { - Ok(text) => plain_update_report(&text), - Err(error) => format!("Update status\n\nCould not check updates.\n\n{error}"), - } -} - -fn plain_update_report(text: &str) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Update status"); - let _ = writeln!(output); - if update_report_has_no_managed_runtimes(text) { - let _ = writeln!(output, "ROCm installs: none found"); - let _ = writeln!(output, "Install ROCm first before applying ROCm updates."); - } else if update_report_has_runtime_update_available(text) { - let _ = writeln!(output, "ROCm installs: update available"); - let _ = writeln!(output, "Preview the update before applying it."); - } else { - let _ = writeln!(output, "ROCm installs: no update action needed right now"); - } - let _ = writeln!(output); - let _ = writeln!(output, "Other parts"); - let _ = writeln!(output, " Engines update with ROCm CLI."); - let _ = writeln!( - output, - " Model recipes are built in unless a signed index is configured." - ); - output.trim_end().to_owned() -} - -fn update_report_has_no_managed_runtimes(text: &str) -> bool { - text.contains("managed runtimes: none") || text.contains("runtimes: status=none_configured") -} - -fn update_report_has_runtime_update_available(text: &str) -> bool { - text.split_whitespace() - .any(|part| part == "status=update_available" || part == "update_available=true") -} - -fn initial_activity_log(paths: &AppPaths) -> VecDeque { - let mut log = recent_cli_lifecycle_activity(paths, MAX_ACTIVITY_LOG_ITEMS.saturating_sub(1)); - log.push_back("TUI ready.".to_owned()); - while log.len() > MAX_ACTIVITY_LOG_ITEMS { - log.pop_front(); - } - log -} - -fn recent_cli_lifecycle_activity(paths: &AppPaths, limit: usize) -> VecDeque { - if limit == 0 { - return VecDeque::new(); - } - let path = paths.data_dir.join("logs").join("cli-lifecycle.log"); - let Ok(text) = read_text_tail(&path, ACTIVITY_LOG_TAIL_BYTES) else { - return VecDeque::new(); - }; - text.lines() - .rev() - .filter(|line| !line.trim().is_empty()) - .take(limit) - .map(format_activity_log_line) - .collect::>() - .into_iter() - .rev() - .collect() -} - -fn read_text_tail(path: &std::path::Path, max_bytes: u64) -> Result { - let mut file = - fs::File::open(path).with_context(|| format!("failed to open {}", path.display()))?; - let len = file - .metadata() - .with_context(|| format!("failed to stat {}", path.display()))? - .len(); - let start = len.saturating_sub(max_bytes); - let drop_partial_line = if start > 0 { - file.seek(SeekFrom::Start(start - 1)) - .with_context(|| format!("failed to seek {}", path.display()))?; - let mut previous = [0_u8; 1]; - file.read_exact(&mut previous) - .with_context(|| format!("failed to inspect {}", path.display()))?; - previous[0] != b'\n' - } else { - false - }; - file.seek(SeekFrom::Start(start)) - .with_context(|| format!("failed to seek {}", path.display()))?; - let mut bytes = Vec::new(); - file.read_to_end(&mut bytes) - .with_context(|| format!("failed to read {}", path.display()))?; - let text = String::from_utf8_lossy(&bytes); - if drop_partial_line { - Ok(text - .split_once('\n') - .map(|(_, rest)| rest.to_owned()) - .unwrap_or_default()) - } else { - Ok(text.into_owned()) - } -} - -fn format_activity_log_line(line: &str) -> String { - let line = line.trim(); - if line.is_empty() { - return "log: ".to_owned(); - } - let action = line - .split_whitespace() - .find_map(|part| part.strip_prefix("action=")) - .unwrap_or("unknown"); - let message = line - .split_once(" message=") - .map_or(line, |(_, message)| message); - truncate_activity_message(format!("log: {action} - {message}")) -} - -fn truncate_activity_message(mut message: String) -> String { - if message.len() > 180 { - message.truncate(177); - message.push_str("..."); - } - message -} - -impl App { - fn new(paths: AppPaths, initial_provider: Option) -> Self { - let config = RocmCliConfig::load(&paths).unwrap_or_default(); - let provider = initial_provider.unwrap_or_else(|| "local".to_owned()); - let activity_log = initial_activity_log(&paths); - let host_gpu_summary = detect_host_gpu_summary(Some(&paths)); - let mut app = Self { - paths, - config, - provider, - provider_key_store: Arc::new(provider_keys::NativeProviderKeyStore), - mode: TuiMode::Ask, - host_gpu_summary, - gpu_telemetry: GpuTelemetry::default(), - transcript: Vec::new(), - transcript_scroll: 0, - home_dashboard_visible: true, - home_selection: 0, - input: String::new(), - input_cursor: 0, - chat_input_scroll: 0, - completion_selection: 0, - history: Vec::new(), - history_index: None, - activity_log, - status: "Ready.".to_owned(), - should_quit: false, - pending_approval: None, - running_job: None, - running_job_log_scroll: 0, - running_job_output: VecDeque::new(), - running_job_cancel_requested: None, - examine_manager: None, - logs_view: None, - runtime_manager: None, - install_manager: None, - engine_manager: None, - model_picker: None, - serve_wizard: None, - update_manager: None, - automations_manager: None, - provider_manager: None, - config_manager: None, - services_manager: None, - command_screen: None, - command_screen_last_area: std::cell::Cell::new(None), - saved_chat_session: None, - chat_stop_service_id: None, - tui_owned_service_ids: std::collections::BTreeSet::new(), - tui_started_comfyui: false, - overlay_card: None, - folder_browser: None, - onboarding_active: false, - onboarding_selection: 0, - onboarding_path_editing: false, - onboarding_install_log: VecDeque::new(), - onboarding_install_log_scroll: 0, - onboarding_install_failure: None, - onboarding_setup_error: None, - onboarding_install_full_log: None, - onboarding_show_log_location: false, - onboarding_cancel_install_confirm: false, - onboarding_cancel_install_selection: 0, - onboarding_success_modal: false, - onboarding_channel: InstallSdkChannel::Release, - }; - app.push_default_intro(); - if app.should_show_onboarding() { - app.onboarding_active = true; - app.reset_onboarding_selection(); - app.status = "First-time setup. Choose an option, then press Enter.".to_owned(); - } else { - app.transcript_scroll = 0; - app.status = "Ready. Type a request, or use /help when you want commands.".to_owned(); - } - app - } - - fn push_default_intro(&mut self) { - self.home_dashboard_visible = true; - self.push_block( - "Welcome", - &format!( - "ROCm CLI is ready.\n\nTry a plain request like `serve a small model`, or type /help for commands.\n\nprovider: {}", - self.provider - ), - ); - } - - fn reset_main_transcript(&mut self) { - self.transcript.clear(); - self.transcript_scroll = 0; - self.home_dashboard_visible = true; - self.push_default_intro(); - self.scroll_to_bottom(); - } - - fn refresh_config(&mut self) { - self.config = RocmCliConfig::load(&self.paths).unwrap_or_default(); - self.rebase_paths_to_saved_setup_folder(); - } - - fn rebase_paths_to_saved_setup_folder(&mut self) { - let Some(root) = self.config.setup.therock_venv.clone() else { - return; - }; - let keep_cache_dir = std::env::var_os("ROCM_CLI_CACHE_DIR").is_some(); - self.paths = self.paths.clone().with_managed_root(root, keep_cache_dir); - let _ = recover_setup_runtime_registration(&self.paths, &self.config); - } - - const fn should_show_onboarding(&self) -> bool { - !self.config.onboarding_dismissed - } - - fn running_job_blocks_screen_close(&mut self) -> bool { - self.discard_background_examine_refresh(); - let Some(job) = self.running_job.as_ref() else { - return false; - }; - self.status = format!( - "{} is still running. Wait for it to finish before going back.", - job.title - ); - true - } - - fn running_job_blocks_screen_switch(&mut self) -> bool { - self.discard_background_examine_refresh(); - let Some(job) = self.running_job.as_ref() else { - return false; - }; - self.status = format!( - "{} is still running. Wait for it to finish before opening another screen.", - job.title - ); - true - } - - fn running_job_blocks_action(&mut self) -> bool { - self.discard_background_examine_refresh(); - if self.running_job.is_some() { - self.status = "A command is already running; wait for it to finish.".to_owned(); - true - } else { - false - } - } - - fn examine_blocked_by_current_workflow(&mut self) -> bool { - if let Some(title) = self - .pending_approval - .as_ref() - .map(|pending| pending.title.clone()) - { - self.set_active_screen_message(format!( - "{title} is waiting for your review.\n\nApprove it, cancel it, or go Back before opening Examine." - )); - self.status = "Finish the current approval before opening Examine.".to_owned(); - return true; - } - - let Some((title, is_examine_refresh)) = self.running_job.as_ref().map(|job| { - ( - job.title.clone(), - matches!(job.kind, RunningJobKind::ExamineRefresh), - ) - }) else { - return false; - }; - if is_examine_refresh { - return false; - } - - let running_label = if title == "Services" { - "Service action" - } else { - title.as_str() - }; - self.set_active_screen_message(format!( - "{running_label} is still running.\n\nExamine can check this computer after it finishes. Keep this screen open so progress and results stay visible." - )); - self.status = - format!("{running_label} is still running. Examine can run after it finishes."); - true - } - - fn open_examine_manager(&mut self) { - self.refresh_config(); - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.examine_manager = Some(ExamineManagerState { - selected: 0, - detail_scroll: 0, - report: String::new(), - message: Some("Checking this computer...\n\nYou can keep using the arrow keys while ROCm CLI checks the GPU, ROCm installs, engines, driver, WSL, and folders.".to_owned()), - }); - self.status = "Examine opened. Checking this computer...".to_owned(); - self.start_examine_refresh_job(); - } - - fn close_examine_manager(&mut self) { - if self - .running_job - .as_ref() - .is_some_and(|job| matches!(job.kind, RunningJobKind::ExamineRefresh)) - { - self.examine_manager = None; - self.return_to_home_after_surface_close( - "Examine closed. The check will finish in the background.", - ); - return; - } - if self.running_job_blocks_screen_close() { - return; - } - self.examine_manager = None; - self.return_to_home_after_surface_close("Examine closed."); - } - - fn refresh_examine_manager(&mut self) { - let selected = self - .examine_manager - .as_ref() - .map_or(0, |state| state.selected); - if let Some(state) = self.examine_manager.as_mut() { - state.selected = selected.min(examine_manager_choices().len().saturating_sub(1)); - state.detail_scroll = 0; - state.message = Some( - "Checking this computer again...\n\nYou can keep using the arrow keys while ROCm CLI refreshes the checks." - .to_owned(), - ); - } else { - self.examine_manager = Some(ExamineManagerState { - selected: selected.min(examine_manager_choices().len().saturating_sub(1)), - detail_scroll: 0, - report: String::new(), - message: Some("Checking this computer...".to_owned()), - }); - } - self.status = "Examine refresh started.".to_owned(); - self.start_examine_refresh_job(); - } - - fn move_examine_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.examine_manager.as_mut() else { - return; - }; - state.message = None; - state.selected = cycle_index(state.selected, examine_manager_choices().len(), direction); - state.detail_scroll = 0; - self.status = match self.selected_examine_manager_choice() { - ExamineManagerChoice::Back => "Press Enter to go back.".to_owned(), - _ => "Examine check selected.".to_owned(), - }; - } - - fn selected_examine_manager_choice(&self) -> ExamineManagerChoice { - let selected = self - .examine_manager - .as_ref() - .map_or(0, |state| state.selected); - examine_manager_choices() - .get(selected) - .copied() - .unwrap_or(ExamineManagerChoice::Overview) - } - - const fn scroll_examine_manager_detail(&mut self, lines: i16) { - let Some(state) = self.examine_manager.as_mut() else { - return; - }; - if lines < 0 { - state.detail_scroll = state.detail_scroll.saturating_sub(lines.unsigned_abs()); - } else { - state.detail_scroll = state.detail_scroll.saturating_add(lines as u16); - } - } - - fn perform_examine_manager_selected_action(&mut self) { - match self.selected_examine_manager_choice() { - ExamineManagerChoice::Back => self.close_examine_manager(), - _ => { - self.status = "Use Up/Down to choose another check, F5 to refresh, Esc to go back." - .to_owned(); - } - } - } - - fn open_runtime_manager(&mut self) { - self.refresh_config(); - self.examine_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = None; - match runtime_manager_items(&self.paths, &self.config) { - Ok(items) => { - let status = if items.is_empty() { - "No ROCm installs found. Choose an action below.".to_owned() - } else { - "ROCm installs opened. Use arrows, Enter, or Esc.".to_owned() - }; - self.runtime_manager = Some(RuntimeManagerState { - items, - selected: 0, - last_item_selected: 0, - detail_scroll: 0, - message: None, - screen: RuntimeManagerScreen::List, - }); - self.mode = TuiMode::Act; - self.status = status; - } - Err(error) => { - self.runtime_manager = Some(RuntimeManagerState { - items: Vec::new(), - selected: 0, - last_item_selected: 0, - detail_scroll: 0, - message: Some(error.to_string()), - screen: RuntimeManagerScreen::List, - }); - self.mode = TuiMode::Act; - self.status = "Runtime manager could not load.".to_owned(); - } - } - } - - fn refresh_runtime_manager(&mut self) { - let selected_key = self - .runtime_manager - .as_ref() - .and_then(|state| { - state - .items - .get(state.selected) - .or_else(|| state.items.get(state.last_item_selected)) - }) - .map(|item| item.runtime_key.clone()); - let message = self - .runtime_manager - .as_ref() - .and_then(|state| state.message.clone()); - let screen = self - .runtime_manager - .as_ref() - .map_or(RuntimeManagerScreen::List, |state| state.screen.clone()); - self.refresh_config(); - match runtime_manager_items(&self.paths, &self.config) { - Ok(items) => { - let selected = selected_key - .and_then(|key| items.iter().position(|item| item.runtime_key == key)) - .unwrap_or(0) - .min(items.len().saturating_sub(1)); - if let Some(state) = self.runtime_manager.as_mut() { - state.items = items; - state.selected = selected; - state.last_item_selected = selected.min(state.items.len().saturating_sub(1)); - state.message = message; - state.screen = screen; - } else { - self.runtime_manager = Some(RuntimeManagerState { - items, - selected, - last_item_selected: selected, - detail_scroll: 0, - message, - screen, - }); - } - } - Err(error) => { - if let Some(state) = self.runtime_manager.as_mut() { - state.items.clear(); - state.selected = 0; - state.last_item_selected = 0; - state.message = Some(error.to_string()); - state.screen = RuntimeManagerScreen::List; - } else { - self.runtime_manager = Some(RuntimeManagerState { - items: Vec::new(), - selected: 0, - last_item_selected: 0, - detail_scroll: 0, - message: Some(error.to_string()), - screen: RuntimeManagerScreen::List, - }); - } - } - } - } - - fn close_runtime_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.runtime_manager = None; - self.return_to_home_after_surface_close("ROCm installs closed."); - } - - fn move_runtime_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.runtime_manager.as_mut() else { - return; - }; - match &mut state.screen { - RuntimeManagerScreen::List => { - let count = runtime_manager_choice_count(state); - if count == 0 { - return; - } - state.selected = cycle_index(state.selected, count, direction); - if state.selected < state.items.len() { - state.last_item_selected = state.selected; - } - state.detail_scroll = 0; - self.status = - "Runtime choice selected. Press Enter to continue, or Esc to go back." - .to_owned(); - } - RuntimeManagerScreen::Advanced { selected } => { - *selected = cycle_index(*selected, runtime_advanced_choices().len(), direction); - state.detail_scroll = 0; - self.status = "Advanced choice selected. Press Enter to continue.".to_owned(); - } - RuntimeManagerScreen::ImportManifest { selected, .. } => { - *selected = cycle_index(*selected, runtime_import_choices().len(), direction); - state.detail_scroll = 0; - self.status = "Import choice selected. Press Enter to continue.".to_owned(); - } - RuntimeManagerScreen::AdoptExisting { selected, .. } => { - *selected = cycle_index(*selected, runtime_adopt_choices().len(), direction); - state.detail_scroll = 0; - self.status = - "Existing Python choice selected. Press Enter to continue.".to_owned(); - } - } - } - - fn selected_runtime_manager_item(&self) -> Option { - let state = self.runtime_manager.as_ref()?; - if !matches!(state.screen, RuntimeManagerScreen::List) { - return None; - } - state.items.get(state.selected).cloned() - } - - fn selected_runtime_manager_target_item(&self) -> Option { - let state = self.runtime_manager.as_ref()?; - if !matches!(state.screen, RuntimeManagerScreen::List) { - return None; - } - state - .items - .get(state.selected) - .or_else(|| state.items.get(state.last_item_selected)) - .cloned() - } - - fn select_runtime_manager_item(&mut self, selector: &str) -> bool { - let Some(state) = self.runtime_manager.as_mut() else { - return false; - }; - if !matches!(state.screen, RuntimeManagerScreen::List) { - state.screen = RuntimeManagerScreen::List; - } - let Some(index) = state.items.iter().position(|item| { - item.runtime_key.eq_ignore_ascii_case(selector) - || item.runtime_id.eq_ignore_ascii_case(selector) - }) else { - state.message = Some(format!( - "I could not find `{selector}`.\n\nUse the arrow keys to choose a ROCm install from this list." - )); - state.detail_scroll = 0; - self.status = "Choose a ROCm install from the list.".to_owned(); - return false; - }; - state.selected = index; - state.last_item_selected = index; - state.message = None; - state.detail_scroll = 0; - self.status = "ROCm install selected. Press Enter to continue.".to_owned(); - true - } - - fn runtime_manager_editing_text(&self) -> bool { - self.runtime_manager.as_ref().is_some_and(|state| { - matches!( - state.screen, - RuntimeManagerScreen::ImportManifest { - editing_path: true, - .. - } | RuntimeManagerScreen::AdoptExisting { - editing_python: true, - .. - } - ) - }) - } - - fn selected_runtime_manager_action(&self) -> Option { - let state = self.runtime_manager.as_ref()?; - if !matches!(state.screen, RuntimeManagerScreen::List) { - return None; - } - state - .selected - .checked_sub(state.items.len()) - .and_then(|index| runtime_manager_actions(state).get(index).copied()) - } - - fn open_runtime_advanced_options(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() { - state.screen = RuntimeManagerScreen::Advanced { selected: 0 }; - state.message = - Some("Add a ROCm install that already exists on this computer.".to_owned()); - state.detail_scroll = 0; - } - self.clear_input(); - self.status = "Add existing ROCm opened. Use arrow keys, then Enter.".to_owned(); - } - - fn open_runtime_import_form(&mut self) { - self.open_runtime_import_form_with(false, None); - } - - fn open_runtime_import_form_with(&mut self, replace: bool, message: Option) { - if let Some(state) = self.runtime_manager.as_mut() { - state.screen = RuntimeManagerScreen::ImportManifest { - selected: 0, - manifest_path: String::new(), - replace, - editing_path: false, - }; - state.message = Some(message.unwrap_or_else(|| { - "Choose the saved ROCm install file that ROCm CLI should remember.".to_owned() - })); - } - self.clear_input(); - self.status = "Import opened. Use arrow keys, then Enter.".to_owned(); - } - - fn open_runtime_adopt_form(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() { - state.screen = RuntimeManagerScreen::AdoptExisting { - selected: 0, - python_path: String::new(), - channel: RuntimeAdoptChannel::Release, - replace: false, - editing_python: false, - }; - state.message = Some( - "Choose the Python folder that already has TheRock ROCm installed.".to_owned(), - ); - } - self.clear_input(); - self.status = "Existing Python setup opened. Use arrow keys, then Enter.".to_owned(); - } - - fn close_runtime_subscreen(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - if let Some(state) = self.runtime_manager.as_mut() { - state.screen = RuntimeManagerScreen::List; - state.message = None; - } - self.clear_input(); - self.status = "Back to ROCm installs.".to_owned(); - } - - fn start_runtime_import_path_edit(&mut self) { - let path = self - .runtime_manager - .as_ref() - .and_then(|state| match &state.screen { - RuntimeManagerScreen::ImportManifest { manifest_path, .. } => { - Some(manifest_path.clone()) - } - _ => None, - }) - .unwrap_or_default(); - if let Some(state) = self.runtime_manager.as_mut() - && let RuntimeManagerScreen::ImportManifest { editing_path, .. } = &mut state.screen - { - *editing_path = true; - } - self.set_input(path); - self.status = "Paste or type the saved ROCm install file, then press Enter.".to_owned(); - } - - fn commit_runtime_import_path_edit(&mut self) { - let value = normalize_runtime_path_input(&self.input); - let result = validate_runtime_manifest_path(&value); - let Some(state) = self.runtime_manager.as_mut() else { - return; - }; - let RuntimeManagerScreen::ImportManifest { - manifest_path, - editing_path, - .. - } = &mut state.screen - else { - return; - }; - match result { - Ok(path) => { - *manifest_path = path.display().to_string(); - *editing_path = false; - self.clear_input(); - self.status = "Saved ROCm install selected. Press Enter on Add install.".to_owned(); - } - Err(error) => { - state.message = Some(error.to_string()); - self.status = "Choose a saved ROCm install file that exists.".to_owned(); - } - } - } - - fn cancel_runtime_import_path_edit(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() - && let RuntimeManagerScreen::ImportManifest { editing_path, .. } = &mut state.screen - { - *editing_path = false; - } - self.clear_input(); - self.status = "Saved ROCm install unchanged.".to_owned(); - } - - fn start_runtime_adopt_python_edit(&mut self) { - let path = self - .runtime_manager - .as_ref() - .and_then(|state| match &state.screen { - RuntimeManagerScreen::AdoptExisting { python_path, .. } => { - Some(python_path.clone()) - } - _ => None, - }) - .unwrap_or_default(); - if let Some(state) = self.runtime_manager.as_mut() - && let RuntimeManagerScreen::AdoptExisting { editing_python, .. } = &mut state.screen - { - *editing_python = true; - } - self.set_input(path); - self.status = "Paste or type the Python folder, then press Enter.".to_owned(); - } - - fn commit_runtime_adopt_python_edit(&mut self) { - let value = normalize_runtime_path_input(&self.input); - let result = validate_runtime_python_path(&value); - let Some(state) = self.runtime_manager.as_mut() else { - return; - }; - let RuntimeManagerScreen::AdoptExisting { - python_path, - editing_python, - .. - } = &mut state.screen - else { - return; - }; - match result { - Ok(path) => { - *python_path = path.display().to_string(); - *editing_python = false; - self.clear_input(); - self.status = "Python folder selected. Press Enter on Add install.".to_owned(); - } - Err(error) => { - state.message = Some(error.to_string()); - self.status = "Choose a Python folder that exists.".to_owned(); - } - } - } - - fn cancel_runtime_adopt_python_edit(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() - && let RuntimeManagerScreen::AdoptExisting { editing_python, .. } = &mut state.screen - { - *editing_python = false; - } - self.clear_input(); - self.status = "Python folder unchanged.".to_owned(); - } - - fn toggle_runtime_import_replace(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() - && let RuntimeManagerScreen::ImportManifest { replace, .. } = &mut state.screen - { - *replace = !*replace; - self.status = if *replace { - "Matching saved ROCm installs will be replaced.".to_owned() - } else { - "Matching saved ROCm installs will be left alone.".to_owned() - }; - } - } - - fn toggle_runtime_adopt_replace(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() - && let RuntimeManagerScreen::AdoptExisting { replace, .. } = &mut state.screen - { - *replace = !*replace; - self.status = if *replace { - "Matching saved ROCm installs will be replaced.".to_owned() - } else { - "Matching saved ROCm installs will be left alone.".to_owned() - }; - } - } - - fn toggle_runtime_adopt_channel(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() - && let RuntimeManagerScreen::AdoptExisting { channel, .. } = &mut state.screen - { - *channel = channel.toggle(); - self.status = format!("Channel set to {}.", channel.as_str()); - } - } - - fn request_runtime_import_from_form(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(state) = self.runtime_manager.as_mut() else { - return; - }; - let RuntimeManagerScreen::ImportManifest { - manifest_path, - replace, - .. - } = &mut state.screen - else { - return; - }; - let manifest = match validate_runtime_manifest_path(manifest_path) { - Ok(path) => path, - Err(error) => { - state.message = Some(error.to_string()); - self.status = "Choose a saved ROCm install file first.".to_owned(); - return; - } - }; - let mut args = vec![ - "runtimes".to_owned(), - "import".to_owned(), - manifest.display().to_string(), - ]; - if *replace { - args.push("--replace".to_owned()); - } - self.request_screen_cli_approval("Import existing ROCm install", "Runtimes", args, None); - } - - fn request_runtime_adopt_from_form(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(state) = self.runtime_manager.as_mut() else { - return; - }; - let RuntimeManagerScreen::AdoptExisting { - python_path, - channel, - replace, - .. - } = &mut state.screen - else { - return; - }; - let python = match validate_runtime_python_path(python_path) { - Ok(path) => path, - Err(error) => { - state.message = Some(error.to_string()); - self.status = "Choose a Python folder first.".to_owned(); - return; - } - }; - let mut args = vec![ - "runtimes".to_owned(), - "adopt".to_owned(), - "--python".to_owned(), - python.display().to_string(), - "--channel".to_owned(), - channel.as_str().to_owned(), - ]; - if *replace { - args.push("--replace".to_owned()); - } - self.request_screen_cli_approval( - "Add existing Python ROCm install", - "Runtimes", - args, - None, - ); - } - - fn perform_runtime_manager_selected_action(&mut self) { - let Some(state) = self.runtime_manager.as_ref() else { - return; - }; - match &state.screen { - RuntimeManagerScreen::List => { - if self.selected_runtime_manager_item().is_some() { - self.request_runtime_manager_activation(); - return; - } - match self.selected_runtime_manager_action() { - Some(RuntimeManagerAction::Install) => self.request_runtime_manager_install(), - Some(RuntimeManagerAction::RemoveSelected) => { - self.request_runtime_manager_uninstall(); - } - Some(RuntimeManagerAction::AdvancedOptions) => { - self.open_runtime_advanced_options(); - } - None => self.request_runtime_manager_install(), - } - } - RuntimeManagerScreen::Advanced { selected } => { - match runtime_advanced_choices().get(*selected).copied() { - Some(RuntimeAdvancedChoice::AdoptExisting) => self.open_runtime_adopt_form(), - Some(RuntimeAdvancedChoice::ImportManifest) => self.open_runtime_import_form(), - Some(RuntimeAdvancedChoice::Back) | None => self.close_runtime_subscreen(), - } - } - RuntimeManagerScreen::ImportManifest { selected, .. } => { - match runtime_import_choices().get(*selected).copied() { - Some(RuntimeImportChoice::ManifestPath) => { - self.start_runtime_import_path_edit(); - } - Some(RuntimeImportChoice::Replace) => self.toggle_runtime_import_replace(), - Some(RuntimeImportChoice::Import) => self.request_runtime_import_from_form(), - Some(RuntimeImportChoice::Back) | None => self.close_runtime_subscreen(), - } - } - RuntimeManagerScreen::AdoptExisting { selected, .. } => { - match runtime_adopt_choices().get(*selected).copied() { - Some(RuntimeAdoptChoice::PythonPath) => self.start_runtime_adopt_python_edit(), - Some(RuntimeAdoptChoice::Channel) => self.toggle_runtime_adopt_channel(), - Some(RuntimeAdoptChoice::Replace) => self.toggle_runtime_adopt_replace(), - Some(RuntimeAdoptChoice::Adopt) => self.request_runtime_adopt_from_form(), - Some(RuntimeAdoptChoice::Back) | None => self.close_runtime_subscreen(), - } - } - } - } - - fn request_runtime_manager_activation(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(item) = self.selected_runtime_manager_item() else { - self.request_runtime_manager_install(); - return; - }; - if item.active { - self.status = "That ROCm install is already active.".to_owned(); - return; - } - if item.status != "ready" { - self.status = "That ROCm install needs repair before it can be used.".to_owned(); - return; - } - self.request_screen_cli_approval( - "Use this ROCm install", - "Runtimes", - vec![ - "runtimes".to_owned(), - "activate".to_owned(), - item.runtime_key.clone(), - ], - Some(format!("/runtimes activate {}", item.runtime_key)), - ); - } - - fn request_runtime_manager_uninstall(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(item) = self.selected_runtime_manager_target_item() else { - self.status = "No ROCm install is selected.".to_owned(); - return; - }; - let title = if item.read_only || item.imported_from.is_some() { - "Remove ROCm install from ROCm CLI" - } else { - "Uninstall this ROCm install" - }; - self.request_screen_cli_approval( - title, - "Runtimes", - vec![ - "runtimes".to_owned(), - "uninstall".to_owned(), - item.runtime_key.clone(), - ], - Some(format!("/runtimes uninstall {}", item.runtime_key)), - ); - } - - fn request_runtime_manager_install(&mut self) { - if self.running_job_blocks_action() { - return; - } - self.open_install_manager(); - if let Some(state) = self.install_manager.as_mut() { - state.return_to_runtime_manager_on_back = true; - } - self.open_install_sdk_form(None); - self.status = "Choose where to install ROCm, then press Enter on Install ROCm.".to_owned(); - } - - fn open_install_manager(&mut self) { - self.examine_manager = None; - self.runtime_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = None; - self.install_manager = Some(InstallManagerState { - selected: 0, - detail_scroll: 0, - message: None, - screen: InstallManagerScreen::Main, - return_to_runtime_manager_on_back: false, - }); - self.mode = TuiMode::Act; - self.status = "Install opened. Use arrow keys, then Enter.".to_owned(); - } - - fn close_install_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.install_manager = None; - self.return_to_home_after_surface_close("Install closed."); - } - - fn move_install_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.install_manager.as_mut() else { - return; - }; - match &mut state.screen { - InstallManagerScreen::Main => { - state.selected = - cycle_index(state.selected, install_menu_choices().len(), direction); - } - InstallManagerScreen::Sdk { selected, .. } => { - *selected = cycle_index(*selected, install_sdk_choices().len(), direction); - } - } - state.detail_scroll = 0; - self.status = "Install option selected. Press Enter to continue.".to_owned(); - } - - fn selected_install_menu_choice(&self) -> InstallMenuChoice { - let selected = self - .install_manager - .as_ref() - .map_or(0, |state| state.selected); - install_menu_choices() - .get(selected) - .copied() - .unwrap_or(InstallMenuChoice::Runtime) - } - - fn open_install_sdk_form(&mut self, message: Option) { - let folder = setup_install_root(&self.paths, &self.config) - .display() - .to_string(); - self.open_install_sdk_form_with( - InstallSdkChannel::Release, - InstallSdkFormat::Wheel, - folder, - message, - ); - } - - fn open_install_sdk_form_with( - &mut self, - channel: InstallSdkChannel, - format: InstallSdkFormat, - folder: String, - message: Option, - ) { - if let Some(state) = self.install_manager.as_mut() { - state.screen = InstallManagerScreen::Sdk { - selected: install_sdk_default_selection(), - channel, - format, - folder, - editing_folder: false, - }; - state.detail_scroll = 0; - state.message = message; - } - self.clear_input(); - self.status = "ROCm install options opened. Use arrows, then Enter.".to_owned(); - } - - fn open_install_sdk_form_from_args(&mut self, args: &[&str]) { - match parse_install_sdk_form_args(args) { - Ok(options) => self.open_install_sdk_form_with( - options.channel, - options.format, - options - .folder - .unwrap_or_else(|| setup_install_root(&self.paths, &self.config)) - .display() - .to_string(), - None, - ), - Err(error) => { - self.open_install_sdk_form(Some(plain_install_sdk_args_error(&error.to_string()))); - } - } - } - - fn install_manager_editing_text(&self) -> bool { - self.install_manager.as_ref().is_some_and(|state| { - matches!( - state.screen, - InstallManagerScreen::Sdk { - editing_folder: true, - .. - } - ) - }) - } - - fn selected_install_sdk_choice(&self) -> InstallSdkChoice { - let selected = self - .install_manager - .as_ref() - .and_then(|state| match &state.screen { - InstallManagerScreen::Sdk { selected, .. } => Some(*selected), - InstallManagerScreen::Main => None, - }) - .unwrap_or(0); - install_sdk_choices() - .get(selected) - .copied() - .unwrap_or(InstallSdkChoice::Install) - } - - fn cycle_install_sdk_folder_preset(&mut self, direction: CompletionDirection) { - let Some(current) = self.install_manager.as_ref().and_then(|state| { - if let InstallManagerScreen::Sdk { folder, .. } = &state.screen { - Some(PathBuf::from(folder)) - } else { - None - } - }) else { - return; - }; - let presets = install_folder_presets(&self.paths, &self.config, ¤t); - if presets.len() <= 1 { - self.status = "Press Enter to choose another install folder.".to_owned(); - return; - } - let current_key = onboarding_folder_preset_key(¤t); - let current_index = presets - .iter() - .position(|path| onboarding_folder_preset_key(path) == current_key) - .unwrap_or(0); - let next_index = cycle_index(current_index, presets.len(), direction); - if let Some(state) = self.install_manager.as_mut() - && let InstallManagerScreen::Sdk { - selected, - folder, - editing_folder, - .. - } = &mut state.screen - { - *selected = install_sdk_choice_index(InstallSdkChoice::Folder); - *folder = presets[next_index].display().to_string(); - *editing_folder = false; - state.message = None; - state.detail_scroll = 0; - } - self.clear_input(); - self.status = - "Install folder changed. Press Enter to browse, or Left/Right for another.".to_owned(); - } - - fn toggle_install_sdk_channel(&mut self) { - if let Some(state) = self.install_manager.as_mut() - && let InstallManagerScreen::Sdk { channel, .. } = &mut state.screen - { - *channel = channel.toggle(); - state.message = None; - state.detail_scroll = 0; - self.status = channel_changed_status().to_owned(); - } - } - - fn toggle_onboarding_channel(&mut self) { - self.onboarding_channel = self.onboarding_channel.toggle(); - self.status = channel_changed_status().to_owned(); - } - - fn open_install_sdk_form_folder_browser(&mut self) { - if self.running_job_blocks_action() { - return; - } - self.open_folder_browser( - "Choose ROCm Folder", - "Pick where ROCm/TheRock should be installed.", - FolderBrowserContext::InstallSdkForm, - ); - } - - fn start_install_sdk_folder_edit(&mut self) { - let folder = self - .install_manager - .as_ref() - .and_then(|state| match &state.screen { - InstallManagerScreen::Sdk { folder, .. } => Some(folder.clone()), - InstallManagerScreen::Main => None, - }) - .unwrap_or_else(|| { - setup_install_root(&self.paths, &self.config) - .display() - .to_string() - }); - if let Some(state) = self.install_manager.as_mut() - && let InstallManagerScreen::Sdk { editing_folder, .. } = &mut state.screen - { - *editing_folder = true; - state.message = None; - } - self.set_input(folder); - self.status = "Type or paste a folder for ROCm, then press Enter.".to_owned(); - } - - fn commit_install_sdk_folder_edit(&mut self) { - let value = self.input.trim(); - let path = match validate_onboarding_install_folder(value) { - Ok(path) => path, - Err(error) => { - if let Some(state) = self.install_manager.as_mut() { - state.message = Some(error.to_string()); - } - self.status = "Choose a folder you own.".to_owned(); - return; - } - }; - if let Some(state) = self.install_manager.as_mut() - && let InstallManagerScreen::Sdk { - folder, - editing_folder, - .. - } = &mut state.screen - { - *folder = path.display().to_string(); - *editing_folder = false; - state.message = None; - } - self.clear_input(); - self.status = "ROCm folder saved. Press Enter on Install ROCm.".to_owned(); - } - - fn cancel_install_sdk_folder_edit(&mut self) { - if let Some(state) = self.install_manager.as_mut() - && let InstallManagerScreen::Sdk { editing_folder, .. } = &mut state.screen - { - *editing_folder = false; - } - self.clear_input(); - self.status = "ROCm folder unchanged.".to_owned(); - } - - fn close_install_sdk_form(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - if self - .install_manager - .as_ref() - .is_some_and(|state| state.return_to_runtime_manager_on_back) - { - self.install_manager = None; - self.open_runtime_manager(); - self.status = "Back to ROCm installs.".to_owned(); - return; - } - if let Some(state) = self.install_manager.as_mut() { - state.screen = InstallManagerScreen::Main; - state.selected = 0; - state.detail_scroll = 0; - state.message = None; - } - self.clear_input(); - self.status = "Back to install options.".to_owned(); - } - - fn request_install_sdk_from_form(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(state) = self.install_manager.as_ref() else { - return; - }; - let InstallManagerScreen::Sdk { - channel, - format, - folder, - .. - } = &state.screen - else { - return; - }; - let folder = match validate_onboarding_install_folder(folder) { - Ok(folder) => folder, - Err(error) => { - if let Some(state) = self.install_manager.as_mut() { - state.message = Some(error.to_string()); - } - self.status = "Choose a folder you own before installing.".to_owned(); - return; - } - }; - let args = vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - channel.as_str().to_owned(), - "--format".to_owned(), - format.as_str().to_owned(), - "--prefix".to_owned(), - folder.display().to_string(), - ]; - let display = format!( - "/install sdk --channel {} --format {} --prefix {}", - channel.as_str(), - format.as_str(), - quote_tui_arg(&folder.display().to_string()) - ); - self.request_screen_cli_approval("Install ROCm", "Install", args, Some(display)); - } - - fn perform_install_manager_selected_action(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(state) = self.install_manager.as_ref() else { - return; - }; - match &state.screen { - InstallManagerScreen::Main => match self.selected_install_menu_choice() { - InstallMenuChoice::Runtime => self.open_install_sdk_form(None), - InstallMenuChoice::DriverPreview => { - self.start_cli_command_with_display( - "Install Preview", - vec![ - "install".to_owned(), - "driver".to_owned(), - "--dry-run".to_owned(), - ], - "/install driver --preview".to_owned(), - ); - } - InstallMenuChoice::Back => self.close_install_manager(), - }, - InstallManagerScreen::Sdk { selected, .. } => { - match install_sdk_choices().get(*selected).copied() { - Some(InstallSdkChoice::Channel) => self.toggle_install_sdk_channel(), - Some(InstallSdkChoice::Folder) => self.open_install_sdk_form_folder_browser(), - Some(InstallSdkChoice::Install) => self.request_install_sdk_from_form(), - Some(InstallSdkChoice::Back) | None => self.close_install_sdk_form(), - } - } - } - } - - fn open_engine_manager(&mut self) { - self.refresh_config(); - self.examine_manager = None; - self.runtime_manager = None; - self.install_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = None; - self.engine_manager = Some(EngineManagerState { - items: engine_manager_items(&self.paths, &self.config), - selected: 0, - last_item_selected: 0, - detail_scroll: 0, - message: None, - }); - self.mode = TuiMode::Act; - self.status = "Engines opened. Use arrow keys, then Enter.".to_owned(); - } - - fn close_engine_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.engine_manager = None; - self.return_to_home_after_surface_close("Engines closed."); - } - - fn refresh_engine_manager(&mut self) { - let selected_name = self - .engine_manager - .as_ref() - .and_then(|state| { - state - .items - .get(state.selected) - .or_else(|| state.items.get(state.last_item_selected)) - }) - .map(|item| item.name.clone()); - self.refresh_config(); - let items = engine_manager_items(&self.paths, &self.config); - let selected = selected_name - .and_then(|name| items.iter().position(|item| item.name == name)) - .unwrap_or(0) - .min(items.len().saturating_sub(1)); - self.engine_manager = Some(EngineManagerState { - items, - selected, - last_item_selected: selected, - detail_scroll: 0, - message: None, - }); - } - - fn move_engine_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.engine_manager.as_mut() else { - return; - }; - let rows = engine_manager_rows(state); - let count = rows.len(); - if count == 0 { - self.status = "No engines are available.".to_owned(); - return; - } - let selected = cycle_index(state.selected, count, direction); - match rows - .get(selected) - .copied() - .unwrap_or(EngineManagerRow::Item(0)) - { - EngineManagerRow::Item(index) => { - state.last_item_selected = index; - state.selected = index; - } - EngineManagerRow::Action(_) => { - state.selected = selected; - } - } - state.detail_scroll = 0; - self.status = "Engine option selected. Press Enter to continue.".to_owned(); - } - - fn selected_engine_manager_item(&self) -> Option { - let state = self.engine_manager.as_ref()?; - match engine_manager_selected_row(state)? { - EngineManagerRow::Item(index) => state.items.get(index).cloned(), - EngineManagerRow::Action(_) => None, - } - } - - fn selected_engine_manager_target_item(&self) -> Option { - let state = self.engine_manager.as_ref()?; - state - .items - .get(state.last_item_selected) - .or_else(|| state.items.first()) - .cloned() - } - - fn selected_engine_manager_action(&self) -> Option { - let state = self.engine_manager.as_ref()?; - match engine_manager_selected_row(state)? { - EngineManagerRow::Action(action) => Some(action), - EngineManagerRow::Item(_) => None, - } - } - - fn select_engine_manager_item(&mut self, engine: &str) -> bool { - let Some(state) = self.engine_manager.as_mut() else { - return false; - }; - let Some(index) = state - .items - .iter() - .position(|item| item.name.eq_ignore_ascii_case(engine)) - else { - state.message = Some(format!( - "I could not find engine `{engine}`.\n\nUse the arrow keys to choose one from this list." - )); - state.detail_scroll = 0; - self.status = "Choose an engine from the list.".to_owned(); - return false; - }; - state.selected = index; - state.last_item_selected = index; - state.message = None; - state.detail_scroll = 0; - self.status = "Engine selected. Press Enter to continue.".to_owned(); - true - } - - fn perform_engine_manager_selected_action(&mut self) { - if self.selected_engine_manager_item().is_some() { - self.perform_selected_engine_item_action(); - return; - } - match self.selected_engine_manager_action() { - Some(EngineManagerAction::UseSelected) => self.request_selected_engine_default(), - Some(EngineManagerAction::InstallSelected) => { - self.request_selected_engine_install(false); - } - Some(EngineManagerAction::ReinstallSelected) => { - self.request_selected_engine_install(true); - } - Some(EngineManagerAction::InstallRocm) => self.open_install_manager(), - Some(EngineManagerAction::Back) => self.close_engine_manager(), - None => self.perform_selected_engine_item_action(), - } - } - - fn perform_selected_engine_item_action(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(item) = self.selected_engine_manager_item() else { - self.status = "No engine is selected.".to_owned(); - return; - }; - if item.installed_env_id.is_some() { - self.request_engine_default(&item.name); - } else if !item.runtime_ready { - self.open_install_manager(); - } else { - self.request_engine_install(&item.name, false); - } - } - - fn request_selected_engine_default(&mut self) { - let Some(item) = self.selected_engine_manager_target_item() else { - self.status = "No engine is selected.".to_owned(); - return; - }; - if item.installed_env_id.is_none() { - self.status = "Install this engine before making it the default.".to_owned(); - return; - } - self.request_engine_default(&item.name); - } - - fn request_engine_default(&mut self, engine: &str) { - self.request_screen_cli_approval( - "Use this engine", - "Engine", - vec![ - "config".to_owned(), - "set-default-engine".to_owned(), - engine.to_owned(), - ], - Some(format!("/config set-default-engine {engine}")), - ); - } - - fn request_selected_engine_install(&mut self, reinstall: bool) { - let Some(item) = self.selected_engine_manager_target_item() else { - self.status = "No engine is selected.".to_owned(); - return; - }; - if !item.runtime_ready { - self.open_install_manager(); - return; - } - self.request_engine_install(&item.name, reinstall); - } - - fn request_engine_install(&mut self, engine: &str, reinstall: bool) { - if self.running_job_blocks_action() { - return; - } - let mut args = vec![ - "engines".to_owned(), - "install".to_owned(), - engine.to_owned(), - ]; - let mut display = format!("/engine install {engine}"); - if reinstall { - args.push("--reinstall".to_owned()); - display.push_str(" --reinstall"); - } - self.request_screen_cli_approval("Install engine", "Engine", args, Some(display)); - } - - fn open_model_picker(&mut self) { - self.refresh_config(); - self.examine_manager = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = None; - match load_model_recipe_registry() { - Ok(registry) => { - let recipes = tui_gpu_model_recipes(registry.recipes); - self.model_picker = Some(ModelPickerState { - items: recipes, - selected: 0, - detail_scroll: 0, - message: None, - }); - self.status = "Models opened. Use arrow keys, then Enter.".to_owned(); - } - Err(error) => { - self.model_picker = Some(ModelPickerState { - items: Vec::new(), - selected: 0, - detail_scroll: 0, - message: Some(format!("Model recipes could not load.\n\n{error}")), - }); - self.status = "Model recipes could not load.".to_owned(); - } - } - self.mode = TuiMode::Serve; - } - - fn focus_model_picker_recipe(&mut self, model_ref: &str) { - let query = model_ref.trim(); - if query.is_empty() { - return; - } - let Some(state) = self.model_picker.as_mut() else { - return; - }; - if let Some(index) = state - .items - .iter() - .position(|recipe| recipe.matches_ref(query)) - { - state.selected = index; - state.detail_scroll = 0; - state.message = None; - self.status = "Model selected. Press Enter to start setup.".to_owned(); - } else { - state.message = Some(format!( - "I could not find `{query}` in the built-in model list.\n\nUse the arrow keys to choose a model, then press Enter." - )); - self.status = "Choose a model from the list.".to_owned(); - } - } - - fn close_model_picker(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.model_picker = None; - self.status = "Models closed.".to_owned(); - } - - fn move_model_picker_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.model_picker.as_mut() else { - return; - }; - if state.items.is_empty() { - self.status = "No model recipes found.".to_owned(); - return; - } - state.message = None; - state.selected = match direction { - CompletionDirection::Next => (state.selected + 1) % state.items.len(), - CompletionDirection::Previous => { - if state.selected == 0 { - state.items.len().saturating_sub(1) - } else { - state.selected - 1 - } - } - }; - state.detail_scroll = 0; - self.status = "Model selected. Press Enter to start setup.".to_owned(); - } - - fn selected_model_recipe(&self) -> Option { - self.model_picker - .as_ref() - .and_then(|state| state.items.get(state.selected)) - .cloned() - } - - fn plan_selected_model_recipe(&mut self) { - let Some(recipe) = self.selected_model_recipe() else { - if let Some(state) = self.model_picker.as_mut() { - state.message = Some("No model recipes found.".to_owned()); - } - self.status = "No model recipe is selected.".to_owned(); - return; - }; - let plan = ServeCommandPlan { - model: Some(recipe.canonical_model_id), - engine: None, - device: None, - runtime_id: None, - env_id: None, - host: None, - port: None, - foreground: false, - managed: true, - allow_public_bind: false, - }; - self.open_serve_wizard_from_plan(plan); - } - - fn open_serve_wizard(&mut self) { - self.refresh_config(); - self.examine_manager = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = None; - - let engines = serve_wizard_engine_names(); - let default_engine = self - .config - .default_engine - .as_deref() - .unwrap_or(default_engine_for_platform()); - let default_engine_index = engines - .iter() - .position(|engine| engine == default_engine) - .unwrap_or(0); - match load_model_recipe_registry() { - Ok(registry) => { - let recipes = tui_recommended_assistant_recipes(registry.recipes); - let engine_index = - serve_wizard_preferred_engine_index(&self.host_gpu_summary, &recipes) - .unwrap_or(default_engine_index); - let message = recipes.is_empty().then(|| { - format!( - "The recommended assistant model was not found.\n\nExpected: {VALIDATED_LOCAL_ASSISTANT_MODEL}\n\nNo unverified model will be used automatically." - ) - }); - self.serve_wizard = Some(ServeWizardState { - recipes, - selected: 0, - detail_scroll: 0, - model_source: ServeModelSource::BuiltInRecipe, - model_index: 0, - local_model_path: String::new(), - engine_index, - device_index: 0, - host: DEFAULT_LOCAL_HOST.to_owned(), - port: DEFAULT_LOCAL_PORT.to_string(), - managed: true, - runtime_id: None, - env_id: None, - allow_public_bind: false, - editing_field: None, - message, - }); - self.status = if self - .serve_wizard - .as_ref() - .is_some_and(|state| state.recipes.is_empty()) - { - "Recommended assistant model was not found.".to_owned() - } else { - "Local AI opened with the recommended assistant model. Press Start.".to_owned() - }; - } - Err(error) => { - self.serve_wizard = Some(ServeWizardState { - recipes: Vec::new(), - selected: 0, - detail_scroll: 0, - model_source: ServeModelSource::LocalPath, - model_index: 0, - local_model_path: String::new(), - engine_index: default_engine_index, - device_index: 0, - host: DEFAULT_LOCAL_HOST.to_owned(), - port: DEFAULT_LOCAL_PORT.to_string(), - managed: true, - runtime_id: None, - env_id: None, - allow_public_bind: false, - editing_field: None, - message: Some(format!("Model recipes could not load.\n\n{error}")), - }); - self.status = "Local AI could not load model choices.".to_owned(); - } - } - self.mode = TuiMode::Serve; - } - - fn open_serve_wizard_from_plan(&mut self, plan: ServeCommandPlan) { - let typed_model = plan.model.clone(); - let typed_engine = plan.engine.clone(); - let runtime_id = plan.runtime_id.clone(); - let env_id = plan.env_id.clone(); - let allow_public_bind = plan.allow_public_bind; - self.open_serve_wizard(); - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - - let review_index = serve_wizard_choice_index(ServeWizardChoice::Review); - state.selected = review_index; - state.managed = !plan.foreground; - state.host = plan.host.unwrap_or_else(|| DEFAULT_LOCAL_HOST.to_owned()); - state.port = plan - .port - .map_or_else(|| DEFAULT_LOCAL_PORT.to_string(), |port| port.to_string()); - state.runtime_id = runtime_id; - state.env_id = env_id; - state.allow_public_bind = allow_public_bind; - - let mut messages = vec!["Loaded your serve request. Use arrows to adjust it.".to_owned()]; - if let Some(model) = typed_model { - if let Some(index) = state.recipes.iter().position(|recipe| { - recipe.canonical_model_id.eq_ignore_ascii_case(&model) - || recipe - .aliases - .iter() - .any(|alias| alias.eq_ignore_ascii_case(&model)) - }) { - state.model_source = ServeModelSource::BuiltInRecipe; - state.model_index = index; - } else { - state.model_source = ServeModelSource::LocalPath; - state.local_model_path = model.clone(); - if Path::new(&model) - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf")) - && typed_engine.is_none() - && let Some(index) = serve_wizard_engine_names() - .iter() - .position(|engine| engine == "llama.cpp") - { - state.engine_index = index; - } - } - } - - if let Some(engine) = typed_engine { - if let Some(index) = serve_wizard_engine_names() - .iter() - .position(|candidate| candidate == &engine) - { - state.engine_index = index; - } else { - messages.push(format!( - "Engine `{engine}` is not in the list. Choose one from the Engine row." - )); - if let Some(index) = serve_wizard_choices() - .iter() - .position(|choice| *choice == ServeWizardChoice::Engine) - { - state.selected = index; - } - } - } - - state.message = Some(messages.join("\n\n")); - self.status = "Local AI options loaded. Press Enter on Start to review.".to_owned(); - } - - fn close_serve_wizard(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.serve_wizard = None; - self.status = "Local AI screen closed.".to_owned(); - } - - fn move_serve_wizard_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - state.message = None; - state.selected = next_enabled_serve_wizard_index(state, direction); - state.detail_scroll = 0; - self.status = "Local AI option selected.".to_owned(); - } - - fn selected_serve_wizard_choice(&self) -> ServeWizardChoice { - let selected = self.serve_wizard.as_ref().map_or(0, |state| state.selected); - serve_wizard_choices() - .get(selected) - .copied() - .unwrap_or(ServeWizardChoice::Model) - } - - fn serve_wizard_editing_text(&self) -> bool { - self.serve_wizard - .as_ref() - .is_some_and(|state| state.editing_field.is_some()) - } - - fn serve_wizard_editing_title(&self) -> &'static str { - match self - .serve_wizard - .as_ref() - .and_then(|state| state.editing_field) - { - Some(ServeWizardEditField::LocalPath) => "Model Path", - Some(ServeWizardEditField::Host) => "Host", - Some(ServeWizardEditField::Port) => "Port", - None => "Prompt", - } - } - - fn cycle_serve_wizard_value(&mut self, direction: CompletionDirection) { - let Some(choice) = self - .serve_wizard - .as_ref() - .map(|_| self.selected_serve_wizard_choice()) - else { - return; - }; - match choice { - ServeWizardChoice::Source => self.toggle_serve_wizard_model_source(), - ServeWizardChoice::Model => self.cycle_serve_wizard_model(direction), - ServeWizardChoice::LocalPath => { - self.status = if self - .serve_wizard - .as_ref() - .is_some_and(|state| state.model_source == ServeModelSource::BuiltInRecipe) - { - "Change Model type before choosing a custom path.".to_owned() - } else { - "Press Enter to choose a local model file or folder.".to_owned() - }; - } - ServeWizardChoice::Engine => self.cycle_serve_wizard_engine(direction), - ServeWizardChoice::Device => self.cycle_serve_wizard_device(direction), - ServeWizardChoice::Host => self.cycle_serve_wizard_host(), - ServeWizardChoice::Port => self.cycle_serve_wizard_port(direction), - ServeWizardChoice::Mode => self.toggle_serve_wizard_mode(), - ServeWizardChoice::Review | ServeWizardChoice::Back => { - self.status = "Use Enter to select this row.".to_owned(); - } - } - } - - fn toggle_serve_wizard_model_source(&mut self) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - state.message = None; - state.model_source = match state.model_source { - ServeModelSource::BuiltInRecipe => ServeModelSource::LocalPath, - ServeModelSource::LocalPath => ServeModelSource::BuiltInRecipe, - }; - self.status = match state.model_source { - ServeModelSource::BuiltInRecipe => { - format!("Recommended assistant model selected: {VALIDATED_LOCAL_ASSISTANT_MODEL}.") - } - ServeModelSource::LocalPath => { - "Local model selected. Press Enter on Local path to choose a file or folder." - .to_owned() - } - }; - } - - fn cycle_serve_wizard_model(&mut self, _direction: CompletionDirection) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - if state.model_source == ServeModelSource::BuiltInRecipe { - state.message = Some(format!( - "Recommended model is fixed.\n\nROCm CLI uses {VALIDATED_LOCAL_ASSISTANT_MODEL} because it is the validated low-VRAM Lemonade assistant path." - )); - self.status = - "Recommended model is fixed. Use Model type for a custom file.".to_owned(); - return; - } - state.message = Some( - "Model row is not used for a custom file.\n\nUse the Path row to choose your model file or folder." - .to_owned(), - ); - self.status = "Use Path to choose a custom model.".to_owned(); - } - - fn cycle_serve_wizard_engine(&mut self, direction: CompletionDirection) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - let engines = serve_wizard_engine_names(); - state.message = None; - state.engine_index = cycle_index(state.engine_index, engines.len(), direction); - self.status = "Engine selected.".to_owned(); - } - - fn cycle_serve_wizard_device(&mut self, direction: CompletionDirection) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - let devices = serve_wizard_device_options(); - state.message = None; - state.device_index = cycle_index(state.device_index, devices.len(), direction); - self.status = "Device choice selected.".to_owned(); - } - - fn cycle_serve_wizard_host(&mut self) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - state.message = None; - state.host = if state.host.eq_ignore_ascii_case("localhost") { - DEFAULT_LOCAL_HOST.to_owned() - } else { - "localhost".to_owned() - }; - self.status = format!("Host set to {}.", state.host); - } - - fn cycle_serve_wizard_port(&mut self, direction: CompletionDirection) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - state.message = None; - let port = state.port.parse::().unwrap_or(DEFAULT_LOCAL_PORT); - let next = match direction { - CompletionDirection::Next => port.saturating_add(1).max(1), - CompletionDirection::Previous => port.saturating_sub(1).max(1), - }; - state.port = next.to_string(); - self.status = format!("Port set to {}.", state.port); - } - - fn toggle_serve_wizard_mode(&mut self) { - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - state.message = None; - state.managed = !state.managed; - self.status = if state.managed { - "Local AI will stay running in ROCm CLI after approval.".to_owned() - } else { - "Command-only mode will show the command without launching here.".to_owned() - }; - } - - fn perform_serve_wizard_selected_action(&mut self) { - match self.selected_serve_wizard_choice() { - ServeWizardChoice::Source => self.toggle_serve_wizard_model_source(), - ServeWizardChoice::Model => self.cycle_serve_wizard_model(CompletionDirection::Next), - ServeWizardChoice::Engine | ServeWizardChoice::Device | ServeWizardChoice::Mode => { - self.cycle_serve_wizard_value(CompletionDirection::Next); - } - ServeWizardChoice::LocalPath => { - if self - .serve_wizard - .as_ref() - .is_some_and(|state| state.model_source == ServeModelSource::BuiltInRecipe) - { - self.status = - "Change Model type to my own model file before editing Path.".to_owned(); - } else { - self.start_serve_wizard_field_edit(ServeWizardEditField::LocalPath); - } - } - ServeWizardChoice::Host => { - self.start_serve_wizard_field_edit(ServeWizardEditField::Host); - } - ServeWizardChoice::Port => { - self.start_serve_wizard_field_edit(ServeWizardEditField::Port); - } - ServeWizardChoice::Review => self.review_serve_wizard_plan(), - ServeWizardChoice::Back => self.close_serve_wizard(), - } - } - - fn start_serve_wizard_field_edit(&mut self, field: ServeWizardEditField) { - let value = self - .serve_wizard - .as_ref() - .map(|state| match field { - ServeWizardEditField::LocalPath => state.local_model_path.clone(), - ServeWizardEditField::Host => state.host.clone(), - ServeWizardEditField::Port => state.port.clone(), - }) - .unwrap_or_default(); - if let Some(state) = self.serve_wizard.as_mut() { - if field == ServeWizardEditField::LocalPath { - state.model_source = ServeModelSource::LocalPath; - } - state.editing_field = Some(field); - state.message = Some(match field { - ServeWizardEditField::LocalPath => { - "Choose a model file or folder below.\n\nPress Enter to save or Esc to cancel." - .to_owned() - } - ServeWizardEditField::Host => { - "Choose where the server listens.\n\nUse 127.0.0.1 or localhost for this computer." - .to_owned() - } - ServeWizardEditField::Port => { - "Choose the local server port.\n\nUse a number from 1 to 65535.".to_owned() - } - }); - } - self.set_input(value); - self.status = match field { - ServeWizardEditField::LocalPath => { - "Type or paste a local model file or folder, then press Enter.".to_owned() - } - ServeWizardEditField::Host => "Type a local host, then press Enter.".to_owned(), - ServeWizardEditField::Port => "Type a port number, then press Enter.".to_owned(), - }; - } - - fn commit_serve_wizard_field_edit(&mut self) { - let field = self - .serve_wizard - .as_ref() - .and_then(|state| state.editing_field); - let Some(field) = field else { - return; - }; - let value = normalize_runtime_path_input(&self.input); - let result = match field { - ServeWizardEditField::LocalPath => { - validate_serve_local_model_path(&value).map(|path| path.display().to_string()) - } - ServeWizardEditField::Host => validate_serve_wizard_host(&value), - ServeWizardEditField::Port => { - validate_serve_wizard_port(&value).map(|port| port.to_string()) - } - }; - let Some(state) = self.serve_wizard.as_mut() else { - return; - }; - match result { - Ok(value) => { - match field { - ServeWizardEditField::LocalPath => { - if Path::new(&value) - .extension() - .and_then(|extension| extension.to_str()) - .is_some_and(|extension| extension.eq_ignore_ascii_case("gguf")) - && let Some(index) = serve_wizard_engine_names() - .iter() - .position(|engine| engine == "llama.cpp") - { - state.engine_index = index; - } - state.local_model_path = value; - state.model_source = ServeModelSource::LocalPath; - self.status = "Local model selected. Press Start when ready.".to_owned(); - } - ServeWizardEditField::Host => { - state.host = value; - self.status = "Host saved.".to_owned(); - } - ServeWizardEditField::Port => { - state.port = value; - self.status = "Port saved.".to_owned(); - } - } - state.editing_field = None; - state.message = None; - self.clear_input(); - } - Err(error) => { - state.message = Some(error.to_string()); - self.status = match field { - ServeWizardEditField::LocalPath => { - "Choose a model file or folder that exists.".to_owned() - } - ServeWizardEditField::Host => "Use a local-only host.".to_owned(), - ServeWizardEditField::Port => "Use a port number from 1 to 65535.".to_owned(), - }; - } - } - } - - fn cancel_serve_wizard_field_edit(&mut self) { - if let Some(state) = self.serve_wizard.as_mut() { - state.editing_field = None; - state.message = None; - } - self.clear_input(); - self.status = "Serve option unchanged.".to_owned(); - } - - fn selected_serve_wizard_plan(&self) -> Result { - let state = self - .serve_wizard - .as_ref() - .context("serve wizard is not open")?; - let engines = serve_wizard_engine_names(); - let devices = serve_wizard_device_options(); - let model = match state.model_source { - ServeModelSource::BuiltInRecipe => selected_serve_wizard_recipe(state) - .map(|recipe| recipe.canonical_model_id.clone()) - .with_context(|| { - format!( - "The recommended assistant model is not available: {VALIDATED_LOCAL_ASSISTANT_MODEL}" - ) - })?, - ServeModelSource::LocalPath => { - validate_serve_local_model_path(&state.local_model_path)? - .display() - .to_string() - } - }; - let host = validate_serve_wizard_host(&state.host)?; - let port = validate_serve_wizard_port(&state.port)?; - Ok(ServeCommandPlan { - model: Some(model), - engine: engines.get(state.engine_index).cloned(), - device: devices - .get(state.device_index) - .map(|device| (*device).to_owned()), - runtime_id: state.runtime_id.clone(), - env_id: state.env_id.clone(), - host: (host != DEFAULT_LOCAL_HOST).then_some(host), - port: (port != DEFAULT_LOCAL_PORT).then_some(port), - foreground: !state.managed, - managed: state.managed, - allow_public_bind: state.allow_public_bind, - }) - } - - fn review_serve_wizard_plan(&mut self) { - let mut plan = match self.selected_serve_wizard_plan() { - Ok(plan) => plan, - Err(error) => { - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(error.to_string()); - } - self.status = "Local AI options need a fix.".to_owned(); - return; - } - }; - if plan.managed && !self.prepare_serve_wizard_runtime(&mut plan) { - return; - } - let rendered = render_serve_wizard_review(&plan, &self.config); - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(rendered); - } - if plan.managed { - self.request_screen_cli_approval("Serve", "Serve", plan.cli_args(), None); - } else { - self.status = "Plan shown. Change Mode to keep it running in ROCm CLI.".to_owned(); - } - } - - fn prepare_serve_wizard_runtime(&mut self, plan: &mut ServeCommandPlan) -> bool { - if plan.engine_manages_own_runtime(&self.config) { - plan.runtime_id = None; - if let Some(state) = self.serve_wizard.as_mut() { - state.runtime_id = None; - state.message = Some( - "Lemonade uses its own ROCm runner. No TheRock install has to be chosen here." - .to_owned(), - ); - } - return true; - } - if plan.runtime_id.is_some() || plan.env_id.is_some() { - return true; - } - match serve_runtime_preflight(&self.paths, &self.config) { - Ok(ServeRuntimePreflight::UseRuntime(runtime_key)) => { - plan.runtime_id = Some(runtime_key.clone()); - if let Some(state) = self.serve_wizard.as_mut() { - state.runtime_id = Some(runtime_key.clone()); - state.message = Some(format!( - "Using ROCm install `{runtime_key}` for this local model server." - )); - } - true - } - Ok(ServeRuntimePreflight::NeedsInstall) => { - self.open_install_manager(); - self.open_install_sdk_form(Some( - "Set up ROCm first.\n\nLocal AI needs a usable ROCm install before it can start. Choose an install folder, then press Enter on Install ROCm." - .to_owned(), - )); - self.status = "Set up ROCm before starting Local AI.".to_owned(); - false - } - Ok(ServeRuntimePreflight::NeedsChoice) => { - self.open_runtime_manager(); - if let Some(state) = self.runtime_manager.as_mut() { - state.message = Some( - "Choose a ROCm install for Local AI.\n\nSelect a ready install and press Enter to review making it the default, then start Local AI again." - .to_owned(), - ); - } - self.status = "Choose a ROCm install before starting Local AI.".to_owned(); - false - } - Err(error) => { - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(format!("Could not check ROCm installs.\n\n{error}")); - } - self.status = "ROCm install check failed.".to_owned(); - false - } - } - } - - fn open_update_manager(&mut self) { - self.examine_manager = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = None; - self.update_manager = Some(UpdateManagerState { - selected: 0, - detail_scroll: 0, - report: render_update_manager_report(&self.paths), - message: None, - }); - self.mode = TuiMode::Act; - self.status = "Updates opened. Use arrow keys, then Enter.".to_owned(); - } - - fn close_update_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.update_manager = None; - self.return_to_home_after_surface_close("Updates closed."); - } - - fn refresh_update_manager(&mut self) { - let message = self - .update_manager - .as_ref() - .and_then(|state| state.message.clone()); - if let Some(state) = self.update_manager.as_mut() { - state.report = render_update_manager_report(&self.paths); - state.message = message; - self.status = "Update status refreshed.".to_owned(); - } - } - - fn move_update_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.update_manager.as_mut() else { - return; - }; - let choices = update_menu_choices(); - state.selected = match direction { - CompletionDirection::Next => (state.selected + 1) % choices.len(), - CompletionDirection::Previous => { - if state.selected == 0 { - choices.len().saturating_sub(1) - } else { - state.selected - 1 - } - } - }; - state.detail_scroll = 0; - self.status = "Update option selected. Press Enter to continue.".to_owned(); - } - - fn selected_update_menu_choice(&self) -> UpdateMenuChoice { - let selected = self - .update_manager - .as_ref() - .map_or(0, |state| state.selected); - update_menu_choices() - .get(selected) - .copied() - .unwrap_or(UpdateMenuChoice::Refresh) - } - - fn perform_update_manager_selected_action(&mut self) { - if self.running_job_blocks_action() { - return; - } - match self.selected_update_menu_choice() { - UpdateMenuChoice::Refresh => self.refresh_update_manager(), - UpdateMenuChoice::Preview => self.start_cli_command_with_display( - "Update", - vec![ - "update".to_owned(), - "--apply".to_owned(), - "--dry-run".to_owned(), - ], - "/update --apply --preview".to_owned(), - ), - UpdateMenuChoice::Apply => self.request_screen_cli_approval( - "Apply ROCm update", - "Update", - vec!["update".to_owned(), "--apply".to_owned()], - Some("/update --apply".to_owned()), - ), - UpdateMenuChoice::ApplyAndUse => self.request_screen_cli_approval( - "Apply ROCm update and use it", - "Update", - vec![ - "update".to_owned(), - "--apply".to_owned(), - "--activate".to_owned(), - ], - Some("/update --apply --activate".to_owned()), - ), - UpdateMenuChoice::Back => self.close_update_manager(), - } - } - - fn normalize_update_runtime_args(&mut self, mut cli_args: Vec) -> Option> { - let Some(runtime_index) = cli_args.iter().position(|arg| arg == "--runtime") else { - return Some(cli_args); - }; - let Some(selector) = cli_args.get(runtime_index + 1).cloned() else { - self.set_active_screen_message("Choose a ROCm install before applying this update."); - self.status = "Choose a ROCm install before updating.".to_owned(); - return None; - }; - let items = match runtime_manager_items(&self.paths, &self.config) { - Ok(items) => items, - Err(error) => { - self.set_active_screen_message(format!( - "ROCm installs could not be loaded.\n\n{error}" - )); - self.status = "ROCm installs could not be loaded.".to_owned(); - return None; - } - }; - if let Some(item) = items - .iter() - .find(|item| item.runtime_key.eq_ignore_ascii_case(&selector)) - { - cli_args[runtime_index + 1] = item.runtime_key.clone(); - return Some(cli_args); - } - - let runtime_id_matches = items - .iter() - .filter(|item| item.runtime_id.eq_ignore_ascii_case(&selector)) - .collect::>(); - match runtime_id_matches.as_slice() { - [item] => { - cli_args[runtime_index + 1] = item.runtime_key.clone(); - Some(cli_args) - } - [] => { - self.set_active_screen_message(format!( - "I could not find ROCm install `{selector}`.\n\nOpen ROCm installs and choose one from the list." - )); - self.status = "Choose a ROCm install before updating.".to_owned(); - None - } - _ => { - self.set_active_screen_message(format!( - "More than one ROCm install matches `{selector}`.\n\nOpen ROCm installs and choose the exact install." - )); - self.status = "Choose the exact ROCm install before updating.".to_owned(); - None - } - } - } - - fn open_automations_manager(&mut self) { - self.refresh_config(); - self.examine_manager = None; - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - let previous_key = self - .automations_manager - .as_ref() - .and_then(selected_automation_manager_row_key); - let items = load_recent_automation_proposals(&self.paths, 32) - .unwrap_or_default() - .into_iter() - .map(|proposal| AutomationReviewItem { proposal }) - .collect::>(); - let watchers = automation_watcher_items(&self.config); - let rows = automation_manager_rows(&items, &watchers); - let selected = previous_key - .as_ref() - .and_then(|key| automation_manager_row_index(&items, &watchers, &rows, key)) - .or_else(|| { - items - .iter() - .position(|item| item.proposal.status == "pending") - .and_then(|index| { - rows.iter() - .position(|row| *row == AutomationManagerRow::Review(index)) - }) - }) - .unwrap_or(0); - self.automations_manager = Some(AutomationsManagerState { - selected: selected.min(rows.len().saturating_sub(1)), - detail_scroll: 0, - items, - watchers, - rows, - overview: render_automations_text(&self.paths, &self.config) - .unwrap_or_else(|error| format!("Automation check failed.\n\n{error}")), - editing_prefetch_max_bytes: None, - message: None, - }); - self.mode = TuiMode::Automations; - self.status = "Automations opened. Use arrow keys, then Enter.".to_owned(); - } - - fn close_automations_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.automations_manager = None; - self.pending_approval = None; - self.return_to_home_after_surface_close("Automations closed."); - } - - fn refresh_automations_manager(&mut self) { - let message = self - .automations_manager - .as_ref() - .and_then(|state| state.message.clone()); - let selected_key = self - .automations_manager - .as_ref() - .and_then(selected_automation_manager_row_key); - self.open_automations_manager(); - if let Some(state) = self.automations_manager.as_mut() { - if let Some(selected_key) = selected_key.as_ref() - && let Some(index) = automation_manager_row_index( - &state.items, - &state.watchers, - &state.rows, - selected_key, - ) - { - state.selected = index; - } - state.message = message; - } - self.status = "Automations refreshed.".to_owned(); - } - - fn move_automations_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.automations_manager.as_mut() else { - return; - }; - if state.rows.is_empty() { - self.status = "No automation checks found.".to_owned(); - return; - } - state.message = None; - state.selected = match direction { - CompletionDirection::Next => (state.selected + 1) % state.rows.len(), - CompletionDirection::Previous => { - if state.selected == 0 { - state.rows.len().saturating_sub(1) - } else { - state.selected - 1 - } - } - }; - state.detail_scroll = 0; - self.status = match selected_automation_manager_row(state) { - Some(AutomationManagerSelectedRow::Review(_)) => { - "Review request selected. Press Enter to review.".to_owned() - } - Some(AutomationManagerSelectedRow::PrefetchField(_, field)) => { - automation_prefetch_field_status(field).to_owned() - } - Some(AutomationManagerSelectedRow::Watcher(watcher)) => format!( - "{} selected. Press Enter to {} it.", - watcher.name, - if watcher.enabled { - "turn off" - } else { - "turn on" - } - ), - None => "Automation item selected.".to_owned(), - }; - } - - fn selected_automation_proposal(&self) -> Option { - let state = self.automations_manager.as_ref()?; - match selected_automation_manager_row(state)? { - AutomationManagerSelectedRow::Review(proposal) => Some(proposal), - AutomationManagerSelectedRow::PrefetchField(_, _) => None, - AutomationManagerSelectedRow::Watcher(_) => None, - } - } - - fn selected_automation_prefetch_field( - &self, - ) -> Option<(AutomationProposalRecord, AutomationPrefetchField)> { - let state = self.automations_manager.as_ref()?; - match selected_automation_manager_row(state)? { - AutomationManagerSelectedRow::PrefetchField(proposal, field) => Some((proposal, field)), - _ => None, - } - } - - fn automations_manager_editing_text(&self) -> bool { - self.automations_manager - .as_ref() - .is_some_and(|state| state.editing_prefetch_max_bytes.is_some()) - } - - fn automations_manager_editing_title(&self) -> &'static str { - if self.automations_manager_editing_text() { - "Max Download" - } else { - "Prompt" - } - } - - fn selected_automation_watcher(&self) -> Option { - let state = self.automations_manager.as_ref()?; - match selected_automation_manager_row(state)? { - AutomationManagerSelectedRow::Review(_) => None, - AutomationManagerSelectedRow::PrefetchField(_, _) => None, - AutomationManagerSelectedRow::Watcher(watcher) => Some(watcher), - } - } - - fn perform_automations_manager_selected_action(&mut self) { - if self.selected_automation_watcher().is_some() { - self.request_selected_automation_watcher_toggle(); - } else if let Some((proposal, field)) = self.selected_automation_prefetch_field() { - self.perform_prefetch_field_action(proposal, field); - } else { - self.focus_selected_automation_proposal(); - } - } - - fn perform_prefetch_field_action( - &mut self, - proposal: AutomationProposalRecord, - field: AutomationPrefetchField, - ) { - match field { - AutomationPrefetchField::AllowDownload => { - let current = proposal_bool_argument(&proposal, "allow_artifact_download"); - self.update_prefetch_bool_field(proposal, field, !current); - } - AutomationPrefetchField::AllowHuggingFace => { - let current = proposal_bool_argument(&proposal, "allow_huggingface_download"); - self.update_prefetch_bool_field(proposal, field, !current); - } - AutomationPrefetchField::MaxBytes => { - self.start_prefetch_max_bytes_edit(&proposal); - } - } - } - - fn update_prefetch_bool_field( - &mut self, - mut proposal: AutomationProposalRecord, - field: AutomationPrefetchField, - value: bool, - ) { - if field == AutomationPrefetchField::AllowHuggingFace - && value - && !proposal_bool_argument(&proposal, "allow_artifact_download") - { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some( - "Turn on Download first.\n\nHugging Face token permission is only used when this review may download the file." - .to_owned(), - ); - } - self.status = "Turn on Download first.".to_owned(); - return; - } - let key = match field { - AutomationPrefetchField::AllowDownload => "allow_artifact_download", - AutomationPrefetchField::AllowHuggingFace => "allow_huggingface_download", - AutomationPrefetchField::MaxBytes => { - self.status = "Use Enter to edit max download.".to_owned(); - return; - } - }; - set_proposal_bool_argument(&mut proposal, key, value); - self.save_prefetch_proposal_edit( - proposal, - field, - automation_prefetch_field_saved_message(field), - ); - } - - fn cycle_prefetch_max_bytes( - &mut self, - mut proposal: AutomationProposalRecord, - direction: CompletionDirection, - ) { - let next = cycle_prefetch_max_bytes_value( - proposal_u64_argument(&proposal, "artifact_max_bytes"), - direction, - ); - set_proposal_u64_argument(&mut proposal, "artifact_max_bytes", next); - self.save_prefetch_proposal_edit( - proposal, - AutomationPrefetchField::MaxBytes, - "Max download updated.", - ); - } - - fn save_prefetch_proposal_edit( - &mut self, - proposal: AutomationProposalRecord, - field: AutomationPrefetchField, - status: &str, - ) { - match self.save_prefetch_proposal_edit_result(proposal, field) { - Ok(()) => self.status = status.to_owned(), - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review edit failed.\n\n{error}")); - } - self.status = "Review edit failed.".to_owned(); - } - } - } - - fn save_prefetch_proposal_edit_result( - &mut self, - proposal: AutomationProposalRecord, - field: AutomationPrefetchField, - ) -> Result<()> { - ensure_prefetch_proposal(&proposal)?; - if proposal.status != "pending" { - bail!( - "review request `{}` is already {}", - proposal.proposal_id, - proposal_status_for_user(&proposal.status) - ); - } - let updated = replace_automation_proposal(&self.paths, &proposal)?; - record_proposal_audit( - &self.paths, - &updated, - "proposal_edited", - "info", - &format!("edited proposal {}", updated.proposal_id), - )?; - if let Some(state) = self.automations_manager.as_mut() { - if let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == updated.proposal_id) - { - item.proposal = updated.clone(); - } - select_automation_manager_row_by_key( - state, - &AutomationManagerRowKey::PrefetchField(updated.proposal_id.clone(), field), - ); - state.message = Some(render_prefetch_field_detail(&updated, field)); - } - Ok(()) - } - - fn start_prefetch_max_bytes_edit(&mut self, proposal: &AutomationProposalRecord) { - if let Some(state) = self.automations_manager.as_mut() { - state.editing_prefetch_max_bytes = Some(proposal.proposal_id.clone()); - state.message = Some( - "Set the largest download this review may approve.\n\nType bytes, then press Enter. Esc cancels." - .to_owned(), - ); - } - let value = proposal_u64_argument(proposal, "artifact_max_bytes") - .map(|bytes| bytes.to_string()) - .unwrap_or_default(); - self.set_input(value); - self.status = "Type max download bytes, then press Enter.".to_owned(); - } - - fn commit_prefetch_max_bytes_edit(&mut self) { - let proposal_id = self - .automations_manager - .as_ref() - .and_then(|state| state.editing_prefetch_max_bytes.clone()); - let Some(proposal_id) = proposal_id else { - return; - }; - let value = self.input.trim().to_owned(); - let bytes = match value.parse::() { - Ok(bytes) if bytes > 0 => bytes, - _ => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some( - "Max download was not saved.\n\nUse a number greater than zero.".to_owned(), - ); - } - self.status = "Use a number greater than zero.".to_owned(); - return; - } - }; - let mut proposal = match find_automation_proposal(&self.paths, &proposal_id) { - Ok(proposal) => proposal, - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review edit failed.\n\n{error}")); - state.editing_prefetch_max_bytes = None; - } - self.clear_input(); - self.status = "Review edit failed.".to_owned(); - return; - } - }; - set_proposal_u64_argument(&mut proposal, "artifact_max_bytes", bytes); - if let Some(state) = self.automations_manager.as_mut() { - state.editing_prefetch_max_bytes = None; - } - self.clear_input(); - self.save_prefetch_proposal_edit( - proposal, - AutomationPrefetchField::MaxBytes, - "Max download updated.", - ); - } - - fn cancel_prefetch_max_bytes_edit(&mut self) { - if let Some(state) = self.automations_manager.as_mut() { - state.editing_prefetch_max_bytes = None; - state.message = None; - } - self.clear_input(); - self.status = "Max download unchanged.".to_owned(); - } - - fn focus_selected_automation_proposal(&mut self) { - let Some(proposal) = self.selected_automation_proposal() else { - self.show_no_automation_review_selected_message(); - return; - }; - if proposal.status != "pending" { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!( - "{} is already {}.", - proposal.proposal_id, - proposal_status_for_user(&proposal.status) - )); - } - self.status = "Review request is not pending.".to_owned(); - return; - } - match self.focus_proposal(proposal) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review request failed.\n\n{error}")); - } - self.status = "Review request failed.".to_owned(); - } - } - } - - fn show_no_automation_review_selected_message(&mut self) { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some( - "No review request is selected.\n\nUse Up and Down to choose a review request, then press Enter to review it. Automation checks can be turned on or off from their own rows." - .to_owned(), - ); - } - self.status = "Choose a review request from the list.".to_owned(); - } - - fn request_selected_automation_watcher_toggle(&mut self) { - let Some(watcher) = self.selected_automation_watcher() else { - self.status = "No automation check is selected.".to_owned(); - return; - }; - self.request_automation_watcher_enabled(&watcher, !watcher.enabled); - } - - fn request_automation_watcher_enabled( - &mut self, - watcher: &AutomationWatcherItem, - enabled: bool, - ) { - if self.running_job_blocks_action() { - return; - } - let mut args = vec!["automations".to_owned()]; - let display; - let title; - if enabled { - args.push("enable".to_owned()); - args.push(watcher.id.clone()); - args.push("--mode".to_owned()); - args.push(watcher.mode.as_str().to_owned()); - display = format!( - "/automations enable {} --mode {}", - watcher.id, - watcher.mode.as_str() - ); - title = format!("Turn on {}", watcher.name); - } else { - args.push("disable".to_owned()); - args.push(watcher.id.clone()); - display = format!("/automations disable {}", watcher.id); - title = format!("Turn off {}", watcher.name); - } - self.request_screen_cli_approval(&title, "Automations", args, Some(display)); - } - - fn select_automation_watcher_by_id(&mut self, watcher_id: &str) -> bool { - let Some(state) = self.automations_manager.as_mut() else { - return false; - }; - if let Some(index) = state.rows.iter().position(|row| { - matches!( - row, - AutomationManagerRow::Watcher(watcher_index) - if state - .watchers - .get(*watcher_index) - .is_some_and(|watcher| watcher.id == watcher_id) - ) - }) { - state.selected = index; - state.message = None; - true - } else { - state.message = Some(format!("Automation check `{watcher_id}` was not found.")); - false - } - } - - fn open_provider_manager(&mut self) { - self.examine_manager = None; - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.provider_manager = Some(ProviderManagerState { - selected: provider_index(&self.provider), - detail_scroll: 0, - message: None, - }); - self.mode = TuiMode::Act; - self.status = "Providers opened. Use arrow keys, then Enter.".to_owned(); - } - - fn close_provider_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.provider_manager = None; - self.return_to_home_after_surface_close("Providers closed."); - } - - fn open_config_manager(&mut self) { - self.refresh_config(); - self.examine_manager = None; - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.config_manager = Some(ConfigManagerState { - selected: 0, - detail_scroll: 0, - message: None, - screen: ConfigManagerScreen::Main, - }); - self.mode = TuiMode::Act; - self.status = "Settings opened. Use arrow keys, then Enter.".to_owned(); - } - - fn close_config_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.config_manager = None; - self.return_to_home_after_surface_close("Settings closed."); - } - - fn move_config_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.config_manager.as_mut() else { - return; - }; - state.message = None; - match &mut state.screen { - ConfigManagerScreen::Main => { - state.selected = - cycle_index(state.selected, config_menu_choices().len(), direction); - state.detail_scroll = 0; - } - ConfigManagerScreen::Provider { - selected, - confirming_clear, - .. - } => { - *selected = cycle_index( - *selected, - config_provider_choices(*confirming_clear).len(), - direction, - ); - state.detail_scroll = 0; - } - } - self.status = "Setting selected. Press Enter to change it.".to_owned(); - } - - fn selected_config_menu_choice(&self) -> ConfigMenuChoice { - let selected = self - .config_manager - .as_ref() - .map_or(0, |state| state.selected); - config_menu_choices() - .get(selected) - .copied() - .unwrap_or(ConfigMenuChoice::Telemetry) - } - - fn select_config_menu_choice(&mut self, choice: ConfigMenuChoice) { - if self.config_manager.is_none() { - self.open_config_manager(); - } - if let Some(state) = self.config_manager.as_mut() - && let Some(index) = config_menu_choices() - .iter() - .position(|candidate| *candidate == choice) - { - state.screen = ConfigManagerScreen::Main; - state.selected = index; - state.detail_scroll = 0; - } - } - - fn select_config_provider_choice(&mut self, provider: &str, choice: ConfigProviderChoice) { - self.open_config_provider_settings(provider); - if let Some(state) = self.config_manager.as_mut() - && let ConfigManagerScreen::Provider { selected, .. } = &mut state.screen - && let Some(index) = config_provider_choices(false) - .iter() - .position(|candidate| *candidate == choice) - { - *selected = index; - state.detail_scroll = 0; - } - } - - fn selected_config_provider_choice(&self) -> ConfigProviderChoice { - let selected = self - .config_manager - .as_ref() - .and_then(|state| match &state.screen { - ConfigManagerScreen::Provider { - selected, - confirming_clear, - .. - } => Some((*selected, *confirming_clear)), - ConfigManagerScreen::Main => None, - }) - .unwrap_or((0, false)); - config_provider_choices(selected.1) - .get(selected.0) - .copied() - .unwrap_or(ConfigProviderChoice::PromptSending) - } - - fn config_manager_editing_secret(&self) -> bool { - self.config_manager.as_ref().is_some_and(|state| { - matches!( - state.screen, - ConfigManagerScreen::Provider { - editing_key: true, - .. - } - ) - }) - } - - fn config_manager_confirming_provider_clear(&self) -> bool { - self.config_manager.as_ref().is_some_and(|state| { - matches!( - state.screen, - ConfigManagerScreen::Provider { - confirming_clear: true, - .. - } - ) - }) - } - - fn save_config_from_manager(&mut self, message: impl Into) { - match self.config.save(&self.paths) { - Ok(()) => { - let message = message.into(); - if let Some(state) = self.config_manager.as_mut() { - state.message = Some(message.clone()); - } - self.status = message; - } - Err(error) => { - if let Some(state) = self.config_manager.as_mut() { - state.message = Some(format!("Settings could not be saved.\n\n{error}")); - } - self.status = "Settings could not be saved.".to_owned(); - } - } - } - - fn open_setup_again_confirmation(&mut self) { - self.overlay_card = Some(OverlayCardState { - title: "First-time Setup".to_owned(), - detail: [ - "Show first-time setup again?", - "", - "ROCm CLI will open the setup screen on the next run.", - "", - "ROCm installs stay on this computer.", - "Installed ROCm folders are not removed.", - "API keys stay saved.", - "Provider settings stay saved.", - ] - .join("\n"), - selected: 0, - detail_scroll: 0, - actions: vec![OverlayCardAction::Close, OverlayCardAction::ShowSetupAgain], - }); - self.status = "Show setup again? Choose an option.".to_owned(); - } - - fn reset_setup_prompt_state(&mut self) -> bool { - self.config.onboarding_dismissed = false; - self.config.setup.completed = false; - match self.config.save(&self.paths) { - Ok(()) => true, - Err(error) => { - let message = format!("Setup reset could not be saved.\n\n{error}"); - if let Some(state) = self.config_manager.as_mut() { - state.message = Some(message.clone()); - } - self.set_onboarding_setup_error(&message); - self.status = "Setup reset could not be saved.".to_owned(); - false - } - } - } - - fn open_onboarding_after_setup_reset(&mut self) { - self.examine_manager = None; - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.overlay_card = None; - self.pending_approval = None; - self.onboarding_active = true; - self.onboarding_path_editing = false; - self.reset_onboarding_install_output(); - self.reset_onboarding_selection(); - self.clear_input(); - self.status = "Setup will show again. ROCm installs were not deleted.".to_owned(); - } - - fn show_setup_again(&mut self) { - if self.running_job_blocks_screen_switch() { - return; - } - if self.reset_setup_prompt_state() { - self.open_onboarding_after_setup_reset(); - } - } - - fn set_config_telemetry_mode(&mut self, mode: &str) { - self.config.telemetry.mode = mode.to_owned(); - if !self.config.telemetry.local_inspection_enabled() { - self.gpu_telemetry.clear(); - } - self.save_config_from_manager(format!("GPU checks set to {mode}.")); - } - - fn set_config_default_runtime_if_ready(&mut self, runtime_id: &str) { - let matches = load_onboarding_runtimes(&self.paths) - .into_iter() - .filter(runtime_install_root_ready) - .filter(|runtime| runtime.runtime_id.eq_ignore_ascii_case(runtime_id)) - .collect::>(); - match matches.len() { - 1 => { - self.open_runtime_manager(); - if let Some(runtime_key) = matches[0].runtime_key.as_deref() { - self.select_runtime_manager_item(runtime_key); - } - self.set_active_screen_message(format!( - "ROCm install `{runtime_id}` is selected.\n\nNothing changed yet. Use the highlighted row and press Enter to review using this install." - )); - self.status = "ROCm install selected. Press Enter to continue.".to_owned(); - } - 0 => { - let looks_like_runtime_key = load_onboarding_runtimes(&self.paths) - .into_iter() - .filter(runtime_install_root_ready) - .any(|runtime| { - runtime - .runtime_key - .as_deref() - .is_some_and(|key| key.eq_ignore_ascii_case(runtime_id)) - }); - self.open_runtime_manager(); - let message = if looks_like_runtime_key { - "That looks like a specific ROCm install.\n\nChoose it from this list and press Enter to make it the default." - } else { - "I could not find a ready ROCm install with that name.\n\nChoose a ready ROCm install from this list, or install ROCm first." - }; - self.set_active_screen_message(message); - self.status = "Default ROCm install was not changed.".to_owned(); - } - _ => { - self.open_runtime_manager(); - self.set_active_screen_message( - "More than one ready ROCm install has that package name.\n\nChoose the exact install from this list and press Enter.", - ); - self.status = "Choose the exact ROCm install.".to_owned(); - } - } - } - - fn set_config_planner_provider(&mut self, provider: Option) { - self.config.planner_provider = provider.clone(); - match provider { - Some(provider) => { - self.save_config_from_manager(format!("Planning help set to {provider}.")); - } - None => self.save_config_from_manager("Planning help turned off."), - } - } - - fn set_config_provider_enabled(&mut self, provider: &str, enabled: bool) { - if provider == "local" { - if let Some(state) = self.config_manager.as_mut() { - state.message = Some("Local provider is always available.".to_owned()); - } - self.status = "Local provider is always available.".to_owned(); - return; - } - self.config.provider_config_mut(provider).enabled = enabled; - self.save_config_from_manager(format!( - "{} prompt sending {}.", - provider, - if enabled { "enabled" } else { "disabled" } - )); - } - - fn open_config_provider_settings(&mut self, provider: &str) { - let label = provider_label(provider); - if let Some(state) = self.config_manager.as_mut() { - state.screen = ConfigManagerScreen::Provider { - provider: provider.to_owned(), - selected: 0, - editing_key: false, - confirming_clear: false, - }; - state.message = None; - } - self.clear_input(); - self.status = format!("{label} settings opened. Use arrow keys, then Enter."); - } - - fn close_config_provider_settings(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - if let Some(state) = self.config_manager.as_mut() { - state.screen = ConfigManagerScreen::Main; - state.message = None; - } - self.clear_input(); - self.status = "Back to Settings.".to_owned(); - } - - fn selected_config_provider(&self) -> Option { - self.config_manager - .as_ref() - .and_then(|state| match &state.screen { - ConfigManagerScreen::Provider { provider, .. } => Some(provider.clone()), - ConfigManagerScreen::Main => None, - }) - } - - fn start_config_provider_key_edit(&mut self) { - let Some(provider) = self.selected_config_provider() else { - return; - }; - if let Some(state) = self.config_manager.as_mut() - && let ConfigManagerScreen::Provider { - editing_key, - confirming_clear, - .. - } = &mut state.screen - { - *editing_key = true; - *confirming_clear = false; - state.message = Some(format!( - "Enter the {} API key below.\n\nThe key is hidden while you type. Press Enter to save or Esc to cancel.", - provider_label(&provider) - )); - } - self.clear_input(); - self.status = format!( - "Enter {} API key. Input is hidden.", - provider_label(&provider) - ); - } - - fn cancel_config_provider_key_edit(&mut self) { - if let Some(state) = self.config_manager.as_mut() - && let ConfigManagerScreen::Provider { - editing_key, - confirming_clear, - .. - } = &mut state.screen - { - *editing_key = false; - *confirming_clear = false; - state.message = None; - } - self.clear_input(); - self.status = "API key entry canceled.".to_owned(); - } - - fn commit_config_provider_key_edit(&mut self) { - let Some(provider) = self.selected_config_provider() else { - self.clear_input(); - return; - }; - let key = self.input.clone(); - match provider_keys::set_provider_api_key_with_store( - self.provider_key_store.as_ref(), - &provider, - &key, - ) { - Ok(status) => { - if let Some(state) = self.config_manager.as_mut() { - if let ConfigManagerScreen::Provider { - editing_key, - confirming_clear, - .. - } = &mut state.screen - { - *editing_key = false; - *confirming_clear = false; - } - state.message = Some(format!( - "{} API key saved.\n\nKey: {}", - provider_label(&provider), - provider_keys::provider_key_status_label(&status) - )); - } - self.clear_input(); - self.status = format!("{} API key saved.", provider_label(&provider)); - } - Err(error) => { - if let Some(state) = self.config_manager.as_mut() { - state.message = Some(provider_key_failure_text(&provider, "save", &error)); - } - self.status = - "API key was not saved. Unlock secure storage and try again.".to_owned(); - } - } - } - - fn start_clear_config_provider_key_confirmation(&mut self) { - let Some(provider) = self.selected_config_provider() else { - return; - }; - let label = provider_label(&provider); - self.overlay_card = Some(OverlayCardState { - title: "Clear API Key".to_owned(), - detail: format!( - "Clear the saved {label} API key?\n\nROCm CLI will forget this key from secure storage. Prompt sending for {label} will need a new key before it can work again." - ), - selected: 0, - detail_scroll: 0, - actions: vec![ - OverlayCardAction::Close, - OverlayCardAction::ClearProviderKey, - ], - }); - if let Some(state) = self.config_manager.as_mut() - && let ConfigManagerScreen::Provider { - selected, - confirming_clear, - .. - } = &mut state.screen - { - *confirming_clear = false; - *selected = config_provider_choices(false) - .iter() - .position(|choice| *choice == ConfigProviderChoice::ClearKey) - .unwrap_or(*selected); - state.message = None; - } - self.clear_input(); - self.status = format!("Clear {label} API key? Choose an option."); - } - - fn cancel_clear_config_provider_key_confirmation(&mut self) { - let Some(provider) = self.selected_config_provider() else { - return; - }; - if let Some(state) = self.config_manager.as_mut() - && let ConfigManagerScreen::Provider { - selected, - confirming_clear, - .. - } = &mut state.screen - { - *confirming_clear = false; - *selected = config_provider_choices(false) - .iter() - .position(|choice| *choice == ConfigProviderChoice::ClearKey) - .unwrap_or(0); - state.message = Some(format!("{} API key kept.", provider_label(&provider))); - } - self.status = "API key was kept.".to_owned(); - } - - fn confirm_clear_config_provider_key(&mut self) { - let Some(provider) = self.selected_config_provider() else { - return; - }; - match provider_keys::clear_provider_api_key_with_store( - self.provider_key_store.as_ref(), - &provider, - ) { - Ok(status) => { - if let Some(state) = self.config_manager.as_mut() { - if let ConfigManagerScreen::Provider { - selected, - confirming_clear, - .. - } = &mut state.screen - { - *confirming_clear = false; - *selected = config_provider_choices(false) - .iter() - .position(|choice| *choice == ConfigProviderChoice::ClearKey) - .unwrap_or(0); - } - state.message = Some(format!( - "{} API key cleared.\n\nKey: {}", - provider_label(&provider), - provider_keys::provider_key_status_label(&status) - )); - } - self.status = format!("{} API key cleared.", provider_label(&provider)); - } - Err(error) => { - if let Some(state) = self.config_manager.as_mut() { - state.message = Some(provider_key_failure_text(&provider, "clear", &error)); - } - self.status = - "API key could not be cleared. Unlock secure storage and try again.".to_owned(); - } - } - } - - fn perform_config_provider_selected_action(&mut self) { - let Some(provider) = self.selected_config_provider() else { - return; - }; - match self.selected_config_provider_choice() { - ConfigProviderChoice::PromptSending => { - let enabled = !self.config.provider_enabled(&provider); - self.set_config_provider_enabled(&provider, enabled); - } - ConfigProviderChoice::ApiKey => self.start_config_provider_key_edit(), - ConfigProviderChoice::ClearKey => self.start_clear_config_provider_key_confirmation(), - ConfigProviderChoice::ConfirmClearKey => self.confirm_clear_config_provider_key(), - ConfigProviderChoice::CancelClearKey => { - self.cancel_clear_config_provider_key_confirmation(); - } - ConfigProviderChoice::Back => self.close_config_provider_settings(), - } - } - - fn cycle_config_planner_provider(&mut self) { - let current = self.config.planner_provider.as_deref().unwrap_or("off"); - let next = match current { - "off" | "" => Some("local"), - "local" => Some("openai"), - "openai" => Some("anthropic"), - _ => None, - } - .map(str::to_owned); - self.set_config_planner_provider(next); - } - - fn perform_config_manager_selected_action(&mut self) { - if self - .config_manager - .as_ref() - .is_some_and(|state| matches!(state.screen, ConfigManagerScreen::Provider { .. })) - { - self.perform_config_provider_selected_action(); - return; - } - match self.selected_config_menu_choice() { - ConfigMenuChoice::Telemetry => { - if self.config.telemetry.local_inspection_enabled() { - self.set_config_telemetry_mode(TELEMETRY_MODE_OFF); - } else { - self.set_config_telemetry_mode(TELEMETRY_MODE_LOCAL); - } - } - ConfigMenuChoice::PlannerProvider => self.cycle_config_planner_provider(), - ConfigMenuChoice::OpenAiProvider => self.open_config_provider_settings("openai"), - ConfigMenuChoice::AnthropicProvider => self.open_config_provider_settings("anthropic"), - ConfigMenuChoice::DefaultEngine => self.open_engine_manager(), - ConfigMenuChoice::DefaultRuntime => self.open_runtime_manager(), - ConfigMenuChoice::Permissions => { - self.open_command_screen_target(CommandScreenTarget::Permissions); - } - ConfigMenuChoice::ShowSetupAgain => self.open_setup_again_confirmation(), - ConfigMenuChoice::Back => self.close_config_manager(), - } - } - - fn handle_config_command_args(&mut self, args: &[&str]) { - self.open_config_manager(); - match args { - [] | ["show" | "status"] => {} - ["set-telemetry", mode] if matches!(*mode, "local" | "off") => { - self.select_config_menu_choice(ConfigMenuChoice::Telemetry); - self.set_active_screen_message(format!( - "GPU checks row selected.\n\nNothing changed yet. Use the highlighted row and press Enter to change GPU checks. You asked for: {mode}." - )); - self.status = "Telemetry selected. Press Enter to change it.".to_owned(); - } - ["set-telemetry", ..] => { - self.select_config_menu_choice(ConfigMenuChoice::Telemetry); - self.set_active_screen_message( - "Choose Telemetry in this settings screen, then press Enter to switch between local GPU checks and off.", - ); - self.status = "Choose Telemetry from Settings.".to_owned(); - } - ["set-default-engine", engine] => { - if engine_inventory() - .iter() - .any(|(candidate, _)| candidate == engine) - { - self.open_engine_manager(); - self.select_engine_manager_item(engine); - self.set_active_screen_message(format!( - "Engine `{engine}` is selected.\n\nNothing changed yet. Use the highlighted row and press Enter to review using this engine." - )); - self.status = "Engine selected. Press Enter to continue.".to_owned(); - } else { - self.open_engine_manager(); - self.set_active_screen_message(format!( - "Unknown engine `{engine}`.\n\nOpen /engine and choose one from the list." - )); - self.status = "Default engine was not changed.".to_owned(); - } - } - ["clear-default-engine"] => { - self.select_config_menu_choice(ConfigMenuChoice::DefaultEngine); - self.set_active_screen_message( - "Default engine was not changed.\n\nUse the Engine screen to choose the engine you want.", - ); - self.status = "Choose an engine from the Engine screen.".to_owned(); - } - ["set-default-runtime", runtime_id] => { - self.open_runtime_manager(); - if self.select_runtime_manager_item(runtime_id) { - self.set_active_screen_message(format!( - "ROCm install `{runtime_id}` is selected.\n\nNothing changed yet. Use the highlighted row and press Enter to review using this install." - )); - self.status = "ROCm install selected. Press Enter to continue.".to_owned(); - } else { - self.set_config_default_runtime_if_ready(runtime_id); - } - } - ["clear-default-runtime"] => { - self.open_runtime_manager(); - self.set_active_screen_message( - "Default ROCm install was not changed.\n\nChoose a ROCm install from this list, then press Enter to make it the default.", - ); - self.status = "Choose a ROCm install from the list.".to_owned(); - } - ["set-planner-provider", provider] - if matches!(*provider, "local" | "openai" | "anthropic") => - { - self.select_config_menu_choice(ConfigMenuChoice::PlannerProvider); - self.set_active_screen_message(format!( - "Planning help row selected.\n\nNothing changed yet. Use the highlighted row and press Enter until {provider} is shown." - )); - self.status = "Planning help selected. Press Enter to change it.".to_owned(); - } - ["clear-planner-provider"] => { - self.select_config_menu_choice(ConfigMenuChoice::PlannerProvider); - self.set_active_screen_message( - "Planning help row selected.\n\nNothing changed yet. Use the highlighted row and press Enter until Off is shown.", - ); - self.status = "Planning help selected. Press Enter to change it.".to_owned(); - } - ["enable-provider", provider] if matches!(*provider, "openai" | "anthropic") => { - self.select_config_provider_choice(provider, ConfigProviderChoice::PromptSending); - self.set_active_screen_message(format!( - "{} prompt sending row selected.\n\nNothing changed yet. Use the highlighted row and press Enter to change it.", - provider_label(provider) - )); - self.status = "Prompt sending selected. Press Enter to change it.".to_owned(); - } - ["disable-provider", provider] if matches!(*provider, "openai" | "anthropic") => { - self.select_config_provider_choice(provider, ConfigProviderChoice::PromptSending); - self.set_active_screen_message(format!( - "{} prompt sending row selected.\n\nNothing changed yet. Use the highlighted row and press Enter to change it.", - provider_label(provider) - )); - self.status = "Prompt sending selected. Press Enter to change it.".to_owned(); - } - ["set-provider-key", provider] if matches!(*provider, "openai" | "anthropic") => { - self.open_config_provider_settings(provider); - self.start_config_provider_key_edit(); - } - ["clear-provider-key", provider] if matches!(*provider, "openai" | "anthropic") => { - self.open_config_provider_settings(provider); - self.start_clear_config_provider_key_confirmation(); - } - ["set-engine", ..] => { - self.set_active_screen_message( - "Engine runtime preferences are managed from /engine and /runtimes.", - ); - self.status = "Use the Engine or Runtimes screen.".to_owned(); - } - _ => { - self.set_active_screen_message( - "Use arrow keys here for settings.\n\nCommon choices are available on this screen:\n telemetry\n default engine\n default ROCm install\n planner provider\n OpenAI and Anthropic settings\n\nTip: press Enter on OpenAI or Anthropic to enable prompt sending, save a key, or clear a saved key.", - ); - self.status = "Choose a setting from the list.".to_owned(); - } - } - } - - fn move_provider_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.provider_manager.as_mut() else { - return; - }; - let options = provider_options(); - state.message = None; - state.selected = match direction { - CompletionDirection::Next => (state.selected + 1) % options.len(), - CompletionDirection::Previous => { - if state.selected == 0 { - options.len().saturating_sub(1) - } else { - state.selected - 1 - } - } - }; - state.detail_scroll = 0; - self.status = "Provider selected. Press Enter to use it.".to_owned(); - } - - fn select_provider_by_name(&mut self, provider: &str) -> bool { - let Some(index) = provider_options() - .iter() - .position(|option| option.name == provider) - else { - if let Some(state) = self.provider_manager.as_mut() { - state.message = Some(format!( - "Unsupported provider: {provider}\n\nChoose local, anthropic, or openai." - )); - } - self.status = "Provider update failed.".to_owned(); - return false; - }; - if let Some(state) = self.provider_manager.as_mut() { - state.selected = index; - state.message = Some(format!( - "{} selected.\n\nPress Enter to use this provider for this session.", - provider_label(provider) - )); - } - self.status = "Provider selected. Press Enter to use it.".to_owned(); - true - } - - fn use_selected_provider(&mut self) { - let Some(state) = self.provider_manager.as_mut() else { - return; - }; - let option = provider_options() - .get(state.selected) - .unwrap_or(&provider_options()[0]); - self.provider = option.name.to_owned(); - state.message = Some(format!( - "{} is now the chat provider for this session.", - option.label - )); - self.status = format!("Provider set to {}.", option.label); - } - - fn open_services_manager(&mut self) { - self.remember_current_chat_session(); - self.examine_manager = None; - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.command_screen = None; - let items = load_visible_managed_services(&self.paths); - self.services_manager = Some(ServicesManagerState { - items, - selected: 0, - last_item_selected: 0, - detail_scroll: 0, - message: None, - }); - self.mode = TuiMode::Act; - self.status = "Services opened. Use arrow keys, then Enter for logs.".to_owned(); - } - - fn close_services_manager(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.services_manager = None; - self.return_to_home_after_surface_close("Services closed."); - } - - fn refresh_services_manager(&mut self) { - let selected_id = self - .services_manager - .as_ref() - .and_then(|state| { - state - .items - .get(state.selected) - .or_else(|| state.items.get(state.last_item_selected)) - }) - .map(|record| record.service_id.clone()); - let message = self - .services_manager - .as_ref() - .and_then(|state| state.message.clone()); - let items = load_visible_managed_services(&self.paths); - let selected = selected_id - .and_then(|id| items.iter().position(|record| record.service_id == id)) - .unwrap_or(0) - .min(items.len().saturating_sub(1)); - self.services_manager = Some(ServicesManagerState { - items, - selected, - last_item_selected: selected, - detail_scroll: 0, - message, - }); - self.status = "Services refreshed.".to_owned(); - } - - fn move_services_manager_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.services_manager.as_mut() else { - return; - }; - let count = services_manager_choice_count(state); - if count == 0 { - self.status = "No managed services found.".to_owned(); - return; - } - state.message = None; - state.selected = cycle_index(state.selected, count, direction); - if state.selected < state.items.len() { - state.last_item_selected = state.selected; - } - state.detail_scroll = 0; - self.status = "Service choice selected. Press Enter to continue.".to_owned(); - } - - fn selected_service_record(&self) -> Option { - self.services_manager - .as_ref() - .and_then(|state| state.items.get(state.selected)) - .cloned() - } - - fn selected_service_target_record(&self) -> Option { - self.services_manager - .as_ref() - .and_then(|state| { - state - .items - .get(state.selected) - .or_else(|| state.items.get(state.last_item_selected)) - }) - .cloned() - } - - fn first_ready_builtin_assistant_service(&self) -> Option { - load_visible_managed_services(&self.paths) - .into_iter() - .find(|record| { - managed_service_is_chat_ready(record) && tui_service_is_builtin_assistant(record) - }) - } - - fn selected_services_manager_action(&self) -> Option { - let state = self.services_manager.as_ref()?; - state - .selected - .checked_sub(state.items.len()) - .and_then(|index| services_manager_actions(state).get(index).copied()) - } - - fn select_service_by_id(&mut self, service_id: &str) -> bool { - let Some(state) = self.services_manager.as_mut() else { - return false; - }; - if let Some(index) = state - .items - .iter() - .position(|record| record.service_id == service_id) - { - state.selected = index; - state.last_item_selected = index; - state.message = None; - self.status = "Service selected. Press Enter to open logs.".to_owned(); - true - } else { - state.message = Some(format!("Managed service `{service_id}` was not found.")); - self.status = "Service not found.".to_owned(); - false - } - } - - fn open_selected_service_logs(&mut self) { - if self.running_job_blocks_screen_switch() { - return; - } - let Some(record) = self.selected_service_target_record() else { - if let Some(state) = self.services_manager.as_mut() { - state.message = - Some("No managed services found. Start one from /serve first.".to_owned()); - } - self.status = "No managed services found.".to_owned(); - return; - }; - let detail = render_service_logs_text_for_tui(&self.paths, &record.service_id, false) - .unwrap_or_else(|error| format!("Service log lookup failed.\n\n{error}")); - self.open_log_output_card("Service Logs", detail); - } - - fn managed_service_failure_message( - &self, - record: &ManagedServiceRecord, - summary: &str, - next_step: &str, - ) -> String { - let detail = render_service_logs_text_for_tui(&self.paths, &record.service_id, false) - .unwrap_or_else(|error| format!("Service log lookup failed.\n\n{error}")); - truncate_command_output(&format!("{summary}\n\n{detail}\n\n{next_step}")) - } - - fn open_selected_service_chat(&mut self) { - if self.running_job_blocks_screen_switch() { - return; - } - let Some(mut record) = self.selected_service_target_record() else { - if let Some(state) = self.services_manager.as_mut() { - state.message = - Some("No managed services found. Start one from /serve first.".to_owned()); - } - self.status = "No managed service is selected.".to_owned(); - return; - }; - if !managed_service_is_chat_ready(&record) { - let failure_message = self.managed_service_failure_message( - &record, - "This server did not start.", - "Restart selected server after fixing the problem, or open the full logs from Services.", - ); - if let Some(state) = self.services_manager.as_mut() { - state.message = Some(match record.status.as_str() { - "failed" | "unreachable" | "exited" => failure_message, - _ => { - "This server is still starting.\n\nOpen logs to watch progress, or press F5 until it is ready." - .to_owned() - } - }); - } - self.status = match record.status.as_str() { - "failed" | "unreachable" | "exited" => { - "Selected server failed before chat could start. Recent log output is shown above." - .to_owned() - } - _ => "Selected server is not ready for chat yet.".to_owned(), - }; - return; - } - record.canonical_model_id = VALIDATED_LOCAL_ASSISTANT_MODEL.to_owned(); - self.open_local_rocm_tools_chat_session(Some(record)); - } - - fn open_selected_service_details_overlay(&mut self) { - let Some(record) = self.selected_service_target_record() else { - if let Some(state) = self.services_manager.as_mut() { - state.message = - Some("No managed service is selected. Start one from /serve first.".to_owned()); - } - self.status = "No managed service is selected.".to_owned(); - return; - }; - self.open_detail_overlay("Service Details", managed_service_detail_text(&record)); - } - - fn open_local_rocm_tools_chat_session(&mut self, service: Option) { - self.provider = "local".to_owned(); - let detail = render_local_rocm_tools_chat_intro(service.as_ref()); - let service_model = service - .as_ref() - .map(|record| record.canonical_model_id.clone()); - let service_id = service.as_ref().map(|record| record.service_id.clone()); - let endpoint_url = service.as_ref().map(|record| record.endpoint_url.clone()); - self.open_command_screen("Chat", detail, None, Vec::new()); - if let Some(state) = self.command_screen.as_mut() { - let mut session = self - .saved_chat_session - .clone() - .unwrap_or_else(|| ChatSessionState { - provider: "local".to_owned(), - model: service_model.clone(), - service_id: service_id.clone(), - endpoint_url: endpoint_url.clone(), - turns: Vec::new(), - pending_install_args: None, - }); - session.provider = "local".to_owned(); - if service_model.is_some() { - session.model = service_model; - } - if service_id.is_some() || endpoint_url.is_some() { - session.service_id = service_id; - session.endpoint_url = endpoint_url.clone(); - } - state.chat_model = session.model.clone(); - state.chat_tools = true; - state.chat_session = Some(session); - } - self.sync_chat_session_actions(); - self.status = endpoint_url.map_or_else( - || "Local assistant ready.".to_owned(), - |url| format!("Local assistant ready at {url}."), - ); - } - - fn open_local_assistant_start_flow(&mut self, model: Option) { - self.remember_current_chat_session(); - let model = model.filter(|value| !value.trim().is_empty()); - if let Some(model) = model { - self.open_serve_wizard_from_plan(ServeCommandPlan { - model: Some(model), - engine: Some( - preferred_serve_engine_for_host_gpu_summary(&self.host_gpu_summary) - .unwrap_or(default_engine_for_platform()) - .to_owned(), - ), - device: Some("gpu_required".to_owned()), - managed: true, - ..ServeCommandPlan::default() - }); - } else { - self.open_serve_wizard(); - } - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(local_chat_needs_server_message()); - } - self.status = "Choose a local model, then press Enter on Start.".to_owned(); - } - - fn ready_local_chat_records(&self) -> Vec { - load_visible_managed_services(&self.paths) - .into_iter() - .filter(managed_service_is_chat_ready) - .collect() - } - - fn ready_local_chat_record_for_model( - &self, - model: Option<&str>, - ) -> Option { - let model = model.map(str::trim).filter(|value| !value.is_empty()); - self.ready_local_chat_records() - .into_iter() - .find(|record| match model { - Some(model) => { - record.canonical_model_id.eq_ignore_ascii_case(model) - || record.model_ref.eq_ignore_ascii_case(model) - } - None => record - .canonical_model_id - .eq_ignore_ascii_case(VALIDATED_LOCAL_ASSISTANT_MODEL), - }) - } - - fn ready_local_chat_record_for_session( - &self, - session: &ChatSessionState, - ) -> Option { - let services = self.ready_local_chat_records(); - if let Some(service_id) = session - .service_id - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - && let Some(record) = services - .iter() - .find(|record| record.service_id.eq_ignore_ascii_case(service_id)) - { - return Some(record.clone()); - } - if let Some(endpoint_url) = session - .endpoint_url - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - && let Some(record) = services - .iter() - .find(|record| record.endpoint_url.eq_ignore_ascii_case(endpoint_url)) - { - return Some(record.clone()); - } - let model = session - .model - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()); - services.into_iter().find(|record| match model { - Some(model) => { - record.canonical_model_id.eq_ignore_ascii_case(model) - || record.model_ref.eq_ignore_ascii_case(model) - } - None => record - .canonical_model_id - .eq_ignore_ascii_case(VALIDATED_LOCAL_ASSISTANT_MODEL), - }) - } - - fn local_chat_service_available(&self, model: Option<&str>) -> bool { - self.ready_local_chat_record_for_model(model).is_some() - } - - fn handle_managed_serve_completion(&mut self, rendered: &str) { - let service_id = managed_serve_service_id(rendered); - if let Some(service_id) = service_id.as_ref() { - self.tui_owned_service_ids.insert(service_id.clone()); - } - if let Some(service_id) = service_id.as_deref() - && let Some(record) = load_managed_services(&self.paths).ok().and_then(|records| { - records - .into_iter() - .find(|record| record.service_id == service_id) - }) - && managed_service_is_chat_ready(&record) - { - if self.command_screen_is_chat_session() { - let endpoint = record.endpoint_url.clone(); - if let Some(state) = self.command_screen.as_mut() - && let Some(session) = state.chat_session.as_mut() - { - state.chat_model = Some(record.canonical_model_id.clone()); - session.model = Some(record.canonical_model_id.clone()); - session.service_id = Some(record.service_id.clone()); - session.endpoint_url = Some(record.endpoint_url); - } - self.sync_chat_session_actions(); - self.status = format!("Local assistant ready at {endpoint}."); - return; - } - self.open_local_rocm_tools_chat_session(Some(record)); - return; - } - - self.open_services_manager(); - if let Some(service_id) = service_id.as_deref() { - self.select_service_by_id(service_id); - } - let selected_record = self.selected_service_target_record(); - let selected_status = selected_record - .as_ref() - .map(|record| record.status.clone()) - .unwrap_or_default(); - let failure_message = selected_record.as_ref().map(|record| { - self.managed_service_failure_message( - record, - "Local assistant did not start.", - "Open the full service logs or restart the service after fixing the problem.", - ) - }); - if let Some(state) = self.services_manager.as_mut() { - state.message = Some(match selected_status.as_str() { - "failed" | "unreachable" | "exited" => { - failure_message.unwrap_or_else(|| "Local assistant did not start.".to_owned()) - } - _ => selected_record.as_ref().map_or_else( - || "Local assistant is starting.".to_owned(), - |record| format!("Starting at {}.", record.endpoint_url), - ), - }); - } - self.status = match selected_status.as_str() { - "failed" | "unreachable" | "exited" => { - "Local assistant failed to start. Recent log output is shown above.".to_owned() - } - _ => selected_record.as_ref().map_or_else( - || "Local assistant is starting.".to_owned(), - |record| format!("Local assistant starting at {}.", record.endpoint_url), - ), - }; - } - - fn request_selected_service_lifecycle(&mut self, action: ServiceLifecycleAction) { - if self.running_job_blocks_action() { - return; - } - let Some(record) = self.selected_service_target_record() else { - if let Some(state) = self.services_manager.as_mut() { - state.message = - Some("No managed services found. Start one from /serve first.".to_owned()); - } - self.status = "No managed service is selected.".to_owned(); - return; - }; - if action == ServiceLifecycleAction::Stop && record.status == "stopped" { - if let Some(state) = self.services_manager.as_mut() { - state.message = Some("This server is already stopped.".to_owned()); - } - self.status = "Service is already stopped.".to_owned(); - return; - } - self.request_service_lifecycle_action(action, record.service_id); - } - - fn perform_services_manager_selected_action(&mut self) { - if let Some(record) = self.selected_service_record() { - if managed_service_is_chat_ready(&record) { - self.open_local_rocm_tools_chat_session(Some(record)); - } else { - self.open_selected_service_logs(); - } - return; - } - match self.selected_services_manager_action() { - Some(ServicesManagerAction::Details) => self.open_selected_service_details_overlay(), - Some(ServicesManagerAction::Chat) => self.open_selected_service_chat(), - Some(ServicesManagerAction::OpenLogs) => self.open_selected_service_logs(), - Some(ServicesManagerAction::Stop) => { - self.request_selected_service_lifecycle(ServiceLifecycleAction::Stop); - } - Some(ServicesManagerAction::Restart) => { - self.request_selected_service_lifecycle(ServiceLifecycleAction::Restart); - } - Some(ServicesManagerAction::Back) => self.close_services_manager(), - None => self.open_selected_service_logs(), - } - } - - fn request_service_lifecycle_action( - &mut self, - action: ServiceLifecycleAction, - service_id: String, - ) { - let args = vec![ - "sandbox-run".to_owned(), - action.tool_name().to_owned(), - "--allow-native-fallback".to_owned(), - "--service-id".to_owned(), - service_id.clone(), - ]; - self.request_screen_rocmd_approval( - &action.approval_title(&service_id), - "Services", - args, - RunningJobKind::ServiceLifecycle { action, service_id }, - ); - } - - fn handle_setup_command(&mut self, args: &[&str]) { - match args { - [] | ["status"] => { - self.onboarding_active = true; - self.reset_onboarding_selection(); - self.status = "Setup opened. Choose an option, then press Enter.".to_owned(); - } - ["skip"] => { - self.onboarding_active = true; - if setup_venv_ready(&self.paths, &self.config) { - self.select_onboarding_choice(OnboardingMenuChoice::Back); - self.status = "Setup is ready. Press Enter to return to ROCm CLI.".to_owned(); - } else { - self.reset_onboarding_selection(); - self.status = - "Setup is required before local ROCm can run. Press Enter to install ROCm." - .to_owned(); - } - } - ["reset"] => { - self.show_setup_again(); - } - [other, ..] => { - self.onboarding_active = true; - self.reset_onboarding_selection(); - self.set_onboarding_setup_error(&format!( - "I do not recognize setup option `{other}`. Use the choices below." - )); - self.status = - format!("I do not recognize setup option `{other}`. Use arrow keys here."); - } - } - } - - fn handle_permissions_command(&mut self, args: &[&str]) { - match args { - [] | ["status"] => { - self.open_command_screen_target(CommandScreenTarget::Permissions); - } - ["full-access" | "full"] => { - self.open_command_screen_target(CommandScreenTarget::Permissions); - if self.config.permissions.full_access_enabled() { - self.status = "Full access is already on.".to_owned(); - } else { - self.pending_approval = Some(PendingApproval { - title: "Full Access".to_owned(), - selected: 0, - detail_scroll: 0, - action: ApprovalAction::EnableFullAccess, - }); - self.status = - "Confirm full access with Enter/Y, or cancel with Esc/N.".to_owned(); - } - } - ["ask" | "reset"] => { - self.open_command_screen_target(CommandScreenTarget::Permissions); - if let Some(state) = self.command_screen.as_mut() { - if let Some(index) = state - .actions - .iter() - .position(|action| *action == CommandScreenAction::ResetPermissions) - { - state.selected = index; - state.message = Some( - "Press Enter to go back to asking before setup actions run.".to_owned(), - ); - self.status = - "Reset permissions selected. Press Enter to apply.".to_owned(); - } else { - state.message = - Some("Permissions are already set to ask first.".to_owned()); - self.status = "Permissions already ask first.".to_owned(); - } - } - } - [other, ..] => { - self.open_command_screen( - "Permissions", - format!( - "I do not recognize permissions option `{other}`.\n\nUse the choices on the left to allow full access or go back to asking first." - ), - Some(CommandScreenTarget::Permissions), - self.command_screen_actions_for_target(CommandScreenTarget::Permissions), - ); - self.status = "Permissions option was not recognized.".to_owned(); - } - } - } - - fn close_onboarding(&mut self) { - self.onboarding_active = false; - self.pending_approval = None; - self.onboarding_selection = 0; - self.onboarding_path_editing = false; - self.onboarding_success_modal = false; - self.clear_input(); - self.status = "Setup closed.".to_owned(); - } - - fn return_from_onboarding(&mut self) { - if setup_venv_ready(&self.paths, &self.config) { - self.finish_onboarding(); - } else if self.config.onboarding_dismissed { - self.close_onboarding(); - } else { - self.onboarding_active = true; - self.reset_onboarding_selection(); - self.status = "Finish setup to continue, or choose Quit setup.".to_owned(); - } - } - - fn finish_onboarding(&mut self) { - self.config.onboarding_dismissed = true; - self.reconcile_onboarding_engine_preference(); - match self.config.save(&self.paths) { - Ok(()) => { - self.onboarding_active = false; - self.pending_approval = None; - self.onboarding_selection = 0; - self.onboarding_path_editing = false; - self.onboarding_success_modal = false; - self.clear_input(); - self.reset_main_transcript(); - self.status = "Setup complete. ROCm CLI is ready.".to_owned(); - } - Err(error) => { - self.onboarding_success_modal = false; - self.set_onboarding_setup_error(&format!( - "Setup completion could not be saved: {error}" - )); - self.status = "Setup completion could not be saved.".to_owned(); - } - } - } - - fn onboarding_action(&self) -> OnboardingAction { - if setup_venv_ready(&self.paths, &self.config) { - OnboardingAction::Finish - } else { - OnboardingAction::InstallSdk - } - } - - fn onboarding_choice_count(&self) -> usize { - onboarding_menu_choices(self).len() - } - - fn reset_onboarding_selection(&mut self) { - self.onboarding_selection = onboarding_default_selection(self); - } - - fn move_onboarding_cancel_install_selection(&mut self, direction: CompletionDirection) { - let count = onboarding_cancel_install_choices().len(); - if count <= 1 { - self.onboarding_cancel_install_selection = 0; - return; - } - self.onboarding_cancel_install_selection = cycle_index( - self.onboarding_cancel_install_selection - .min(count.saturating_sub(1)), - count, - direction, - ); - self.status = onboarding_cancel_install_status(self.onboarding_cancel_install_selection); - } - - fn perform_onboarding_cancel_install_selection(&mut self) { - match self.onboarding_cancel_install_selection { - 0 => self.keep_waiting_for_onboarding_install(), - _ => self.cancel_onboarding_install(), - } - } - - fn select_onboarding_choice(&mut self, choice: OnboardingMenuChoice) { - if let Some(index) = onboarding_menu_choices(self) - .iter() - .position(|candidate| *candidate == choice) - { - self.onboarding_selection = index; - } - } - - fn selected_onboarding_choice(&self) -> OnboardingMenuChoice { - onboarding_selected_choice(self) - } - - fn move_onboarding_selection(&mut self, direction: CompletionDirection) { - let count = self.onboarding_choice_count(); - if count <= 1 { - self.onboarding_selection = 0; - return; - } - self.onboarding_selection = match direction { - CompletionDirection::Next => (self.onboarding_selection + 1) % count, - CompletionDirection::Previous => { - if self.onboarding_selection == 0 { - count - 1 - } else { - self.onboarding_selection - 1 - } - } - }; - self.status = onboarding_selection_status(self); - } - - fn start_onboarding_path_edit(&mut self) { - if self.pending_approval.is_some() || self.running_job.is_some() { - return; - } - self.onboarding_path_editing = true; - self.set_input( - setup_install_root(&self.paths, &self.config) - .display() - .to_string(), - ); - self.status = "Type a folder for ROCm, then press Enter.".to_owned(); - } - - fn commit_onboarding_path_edit(&mut self) { - let value = self.input.trim(); - let path = match validate_onboarding_install_folder(value) { - Ok(path) => path, - Err(error) => { - self.status = error.to_string(); - return; - } - }; - self.config.setup.therock_venv = Some(path); - match self.config.save(&self.paths) { - Ok(()) => { - self.rebase_paths_to_saved_setup_folder(); - self.onboarding_path_editing = false; - self.clear_input(); - self.select_onboarding_choice(OnboardingMenuChoice::Primary); - self.status = "ROCm folder saved. Press Enter to install.".to_owned(); - } - Err(error) => { - self.status = "ROCm folder could not be saved.".to_owned(); - self.set_onboarding_setup_error(&format!( - "ROCm folder could not be saved: {error}" - )); - } - } - } - - fn cancel_onboarding_path_edit(&mut self) { - self.onboarding_path_editing = false; - self.onboarding_cancel_install_confirm = false; - self.clear_input(); - self.status = "ROCm folder unchanged.".to_owned(); - } - - fn cycle_onboarding_folder_preset(&mut self, direction: CompletionDirection) { - if self.pending_approval.is_some() || self.running_job.is_some() { - return; - } - self.select_onboarding_choice(OnboardingMenuChoice::Folder); - let presets = onboarding_folder_presets(&self.paths, &self.config); - if presets.len() <= 1 { - self.status = "Press Enter to choose another install folder.".to_owned(); - return; - } - let current = setup_install_root(&self.paths, &self.config); - let current_key = onboarding_folder_preset_key(¤t); - let current_index = presets - .iter() - .position(|path| onboarding_folder_preset_key(path) == current_key) - .unwrap_or(0); - let next_index = cycle_index(current_index, presets.len(), direction); - let next_path = presets[next_index].clone(); - self.config.setup.therock_venv = Some(next_path); - match self.config.save(&self.paths) { - Ok(()) => { - self.rebase_paths_to_saved_setup_folder(); - self.onboarding_setup_error = None; - self.status = - "Install folder changed. Press Left/Right for another, or Down to install." - .to_owned(); - } - Err(error) => { - self.onboarding_setup_error = - Some(format!("Install folder could not be saved: {error}")); - self.status = "Install folder could not be saved.".to_owned(); - } - } - } - - fn open_onboarding_install_folder_browser(&mut self) { - if self.pending_approval.is_some() || self.running_job.is_some() { - return; - } - self.open_folder_browser( - "Choose ROCm Folder", - "Pick where ROCm/TheRock should be installed.", - FolderBrowserContext::OnboardingInstallFolder, - ); - } - - fn onboarding_install_running(&self) -> bool { - self.onboarding_active - && self - .running_job - .as_ref() - .is_some_and(|job| matches!(job.title.as_str(), "Install" | "Reinstall")) - } - - fn request_onboarding_install_cancel(&mut self) { - self.onboarding_cancel_install_confirm = true; - self.onboarding_cancel_install_selection = 0; - self.status = "ROCm is still installing. Choose what to do.".to_owned(); - } - - fn keep_waiting_for_onboarding_install(&mut self) { - self.onboarding_cancel_install_confirm = false; - self.onboarding_cancel_install_selection = 0; - self.status = "Still installing ROCm. Progress is shown in the install card.".to_owned(); - } - - fn cancel_onboarding_install(&mut self) { - let Some(cancel_requested) = self.running_job_cancel_requested.as_ref() else { - self.onboarding_cancel_install_confirm = false; - self.onboarding_cancel_install_selection = 0; - return; - }; - cancel_requested.store(true, Ordering::SeqCst); - self.onboarding_cancel_install_confirm = false; - self.onboarding_cancel_install_selection = 0; - self.push_onboarding_install_log("Stopping installer..."); - self.status = "Stopping ROCm install...".to_owned(); - } - - fn reset_onboarding_install_output(&mut self) { - self.onboarding_install_log.clear(); - self.onboarding_install_log_scroll = 0; - self.onboarding_install_failure = None; - self.onboarding_setup_error = None; - self.onboarding_install_full_log = None; - self.onboarding_show_log_location = false; - self.onboarding_cancel_install_confirm = false; - self.onboarding_cancel_install_selection = 0; - } - - fn push_onboarding_install_log(&mut self, line: &str) { - let line = sanitize_terminal_text(line).replace(['\r', '\n'], " "); - let line = truncate_stream_line(line.trim()); - if line.is_empty() { - return; - } - let following_live_output = - self.onboarding_install_log_scroll == 0 && self.running_job_log_scroll == 0; - self.onboarding_install_log.push_back(line); - if following_live_output { - self.onboarding_install_log_scroll = 0; - self.running_job_log_scroll = 0; - } else { - self.onboarding_install_log_scroll = - self.onboarding_install_log_scroll.saturating_add(1); - self.running_job_log_scroll = self.running_job_log_scroll.saturating_add(1); - } - while self.onboarding_install_log.len() > 80 { - self.onboarding_install_log.pop_front(); - } - } - - fn scroll_onboarding_install_log(&mut self, direction: LogPageDirection) { - let len = self.onboarding_scrollable_log_len(); - if len == 0 { - self.status = if self.onboarding_show_log_location { - "No saved setup log output to scroll.".to_owned() - } else { - "No installer output to scroll yet.".to_owned() - }; - return; - } - let max_offset = len.saturating_sub(1); - self.onboarding_install_log_scroll = match direction { - LogPageDirection::Previous => self - .onboarding_install_log_scroll - .saturating_add(8) - .min(max_offset), - LogPageDirection::Next => self.onboarding_install_log_scroll.saturating_sub(8), - LogPageDirection::Refresh => 0, - }; - } - - fn scroll_onboarding_install_log_by(&mut self, lines: i16) { - let len = self.onboarding_scrollable_log_len(); - if len == 0 { - self.status = if self.onboarding_show_log_location { - "No saved setup log output to scroll.".to_owned() - } else { - "No installer output to scroll yet.".to_owned() - }; - return; - } - let max_offset = len.saturating_sub(1); - if lines.is_negative() { - self.onboarding_install_log_scroll = self - .onboarding_install_log_scroll - .saturating_add(usize::from(lines.unsigned_abs())) - .min(max_offset); - } else { - self.onboarding_install_log_scroll = self - .onboarding_install_log_scroll - .saturating_sub(lines as usize); - } - } - - fn scroll_running_job_log(&mut self, lines: i16) { - let len = running_job_output_line_count(self); - if len == 0 { - self.status = "No live output to scroll yet.".to_owned(); - return; - } - let max_offset = self.running_job_log_max_offset(); - if self.onboarding_install_running() { - self.onboarding_install_log_scroll = - scroll_latest_offset_by(self.onboarding_install_log_scroll, lines, max_offset); - self.running_job_log_scroll = self.onboarding_install_log_scroll; - } else { - self.running_job_log_scroll = - scroll_latest_offset_by(self.running_job_log_scroll, lines, max_offset); - } - } - - fn running_job_log_max_offset(&self) -> usize { - running_job_output_visual_line_count(self, 8).saturating_sub(1) - } - - fn onboarding_scrollable_log_len(&self) -> usize { - if self.onboarding_show_log_location - && let Some(path) = self.onboarding_install_full_log.as_ref() - && let Some(len) = saved_onboarding_log_line_count(path) - { - return len; - } - self.onboarding_install_log.len() - } - - fn toggle_onboarding_log_file(&mut self) { - let Some(path) = self.onboarding_install_full_log.as_ref() else { - self.status = "No saved setup log yet.".to_owned(); - return; - }; - let detail = render_saved_onboarding_log_card(path); - self.overlay_card = Some(OverlayCardState { - title: "Saved Setup Log".to_owned(), - detail, - selected: 0, - detail_scroll: 0, - actions: Vec::new(), - }); - self.onboarding_show_log_location = false; - self.onboarding_install_log_scroll = 0; - self.status = "Saved setup log opened. Press Esc to close it.".to_owned(); - } - - fn push_onboarding_install_rendered_output(&mut self, rendered: &str) { - for line in rendered.lines() { - let trimmed = line.trim(); - if trimmed.is_empty() - || trimmed == "stdout:" - || trimmed == "stderr:" - || trimmed.starts_with("command:") - || trimmed.starts_with("status:") - || trimmed.starts_with("streamed_") - { - continue; - } - self.push_onboarding_install_log(trimmed); - } - } - - fn write_onboarding_install_full_log(&mut self, rendered: &str) { - match write_onboarding_install_full_log(&self.paths, rendered) { - Ok(path) => { - self.onboarding_install_full_log = Some(path); - } - Err(error) => { - self.push_onboarding_install_log(&format!("full log could not be saved: {error}")); - } - } - } - - fn perform_onboarding_primary_action(&mut self) { - self.refresh_config(); - match self.onboarding_action() { - OnboardingAction::InstallSdk => { - let title = if setup_has_current_install(&self.paths, &self.config) { - "Reinstall" - } else { - "Install" - }; - self.request_onboarding_install(title); - } - OnboardingAction::Finish => self.finish_onboarding(), - } - } - - fn request_onboarding_install(&mut self, title: &str) { - self.reset_onboarding_install_output(); - self.rebase_paths_to_saved_setup_folder(); - let channel = self.onboarding_channel.as_str(); - let mut args = vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - channel.to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - ]; - let venv_path = setup_install_root(&self.paths, &self.config); - args.push("--prefix".to_owned()); - args.push(venv_path.display().to_string()); - let mut display_command = - format!("/install sdk --channel {channel} --format wheel --prefix "); - display_command.push_str("e_tui_arg(&venv_path.display().to_string())); - let reason = if title == "Reinstall" { - "Reinstall ROCm into the selected folder." - } else { - "Install ROCm into the selected folder." - }; - self.request_cli_approval_with_display(title, args, reason, display_command); - self.reset_onboarding_selection(); - } - - fn request_onboarding_reinstall(&mut self) { - if self.running_job_blocks_action() { - return; - } - self.request_onboarding_install("Reinstall"); - self.status = "Review reinstall, then press Enter to start.".to_owned(); - } - - fn request_onboarding_uninstall(&mut self) { - if self.running_job_blocks_action() { - return; - } - let Some(runtime) = current_setup_runtime(&self.paths, &self.config) else { - self.set_onboarding_setup_error( - "I could not find the saved ROCm install to uninstall.", - ); - self.status = "ROCm install was not found.".to_owned(); - return; - }; - let Some(runtime_key) = runtime.runtime_key.as_deref() else { - self.set_onboarding_setup_error( - "This ROCm install is missing its saved install id. Open ROCm Installs to repair it.", - ); - self.status = "ROCm install needs repair before uninstall.".to_owned(); - return; - }; - if let Err(error) = ensure_setup_runtime_registered(&self.paths, &runtime) { - self.set_onboarding_setup_error(&format!( - "I found this ROCm install, but could not prepare uninstall: {error}" - )); - self.status = "ROCm uninstall needs repair.".to_owned(); - return; - } - self.reset_onboarding_install_output(); - self.request_cli_approval_with_display( - "Uninstall", - vec![ - "runtimes".to_owned(), - "uninstall".to_owned(), - runtime_key.to_owned(), - ], - "Uninstall the current ROCm install.", - format!("/runtimes uninstall {}", quote_tui_arg(runtime_key)), - ); - self.reset_onboarding_selection(); - self.status = "Review uninstall, then press Enter to remove ROCm.".to_owned(); - } - - fn perform_onboarding_selected_action(&mut self) { - match self.selected_onboarding_choice() { - OnboardingMenuChoice::Channel => self.toggle_onboarding_channel(), - OnboardingMenuChoice::Folder if self.pending_approval.is_none() => { - self.open_onboarding_install_folder_browser(); - } - OnboardingMenuChoice::Primary if self.pending_approval.is_some() => { - self.approve_focused_action(); - self.reset_onboarding_selection(); - } - OnboardingMenuChoice::Primary if self.running_job.is_none() => { - self.perform_onboarding_primary_action(); - } - OnboardingMenuChoice::Reinstall if self.pending_approval.is_none() => { - self.request_onboarding_reinstall(); - } - OnboardingMenuChoice::Uninstall if self.pending_approval.is_none() => { - self.request_onboarding_uninstall(); - } - OnboardingMenuChoice::ShowLog if self.pending_approval.is_none() => { - self.toggle_onboarding_log_file(); - } - OnboardingMenuChoice::Back if self.pending_approval.is_some() => { - self.cancel_focused_action(); - self.reset_onboarding_selection(); - } - OnboardingMenuChoice::Back if self.running_job.is_none() => { - self.return_from_onboarding(); - } - OnboardingMenuChoice::Quit if self.running_job.is_none() => { - self.request_quit_overlay_or_wait(); - } - _ => {} - } - } - - fn reconcile_onboarding_engine_preference(&mut self) { - let Some(engine) = self.config.default_engine.clone() else { - return; - }; - let Some(env_id) = ready_engine_env_id(&self.paths, &self.config, &engine) else { - return; - }; - let engine_config = self.config.engine_config_mut(&engine); - engine_config.preferred_env_id = Some(env_id); - engine_config.preferred_runtime_id = None; - } - - fn refresh_sidebar_state(&mut self) { - self.refresh_config(); - if self.onboarding_active { - self.poll_running_job(); - return; - } - if self.config.telemetry.local_inspection_enabled() { - self.gpu_telemetry.maybe_refresh(); - } else { - self.gpu_telemetry.clear(); - } - self.poll_running_job(); - self.refresh_following_logs(); - } - - fn sidebar_text(&self) -> String { - let mut output = render_sidebar_text( - &self.paths, - &self.config, - &self.provider, - setup_runtime_ready_for_sidebar(&self.paths, &self.config), - Some(&self.host_gpu_summary), - ); - let gpu_text = if self.config.telemetry.local_inspection_enabled() { - self.gpu_telemetry.sidebar_text() - } else { - disabled_gpu_telemetry_text(&self.config) - }; - if !gpu_text.is_empty() { - output.push_str("\n\n"); - output.push_str(&gpu_text); - } - output - } - - fn paste_text(&mut self, text: &str) { - if self.pending_approval.is_some() { - self.clear_input(); - self.status = "Review card is open. Use arrows, Enter/Y, N, or Esc.".to_owned(); - return; - } - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars() { - if ch == '\n' { - self.submit(); - } else { - self.insert_input_char(ch); - } - } - } - - #[allow(dead_code)] - fn push_user_input(&mut self, input: &str) { - self.home_dashboard_visible = false; - self.transcript.push(TranscriptLine::user_input(input)); - self.transcript.push(TranscriptLine::blank()); - self.record_activity(format!("input: {input}")); - self.trim_transcript(); - self.scroll_to_bottom(); - } - - fn push_block(&mut self, title: &str, body: &str) { - if title != "Welcome" { - self.home_dashboard_visible = false; - } - let block_start = self.transcript.len(); - self.transcript.push(TranscriptLine::block_header(title)); - self.transcript.extend( - body.lines() - .map(|line| TranscriptLine::block_body(title, line)), - ); - self.transcript.push(TranscriptLine::blank()); - let drained = self.trim_transcript(); - self.scroll_to_line(block_start.saturating_sub(drained)); - } - - fn trim_transcript(&mut self) -> usize { - const MAX_LINES: usize = 800; - if self.transcript.len() > MAX_LINES { - let drain = self.transcript.len() - MAX_LINES; - self.transcript.drain(0..drain); - self.transcript_scroll = self.transcript_scroll.saturating_sub(drain as u16); - drain - } else { - 0 - } - } - - fn transcript_text(&self) -> String { - if self.transcript.is_empty() { - "No activity yet.".to_owned() - } else { - self.transcript.join("\n") - } - } - - const fn should_draw_home_dashboard(&self) -> bool { - self.home_dashboard_visible - && !self.onboarding_active - && self.examine_manager.is_none() - && self.logs_view.is_none() - && self.runtime_manager.is_none() - && self.install_manager.is_none() - && self.engine_manager.is_none() - && self.model_picker.is_none() - && self.serve_wizard.is_none() - && self.update_manager.is_none() - && self.automations_manager.is_none() - && self.provider_manager.is_none() - && self.config_manager.is_none() - && self.services_manager.is_none() - && self.command_screen.is_none() - } - - fn max_scroll(&self) -> u16 { - if let Ok((terminal_width, terminal_height)) = size() { - let area = main_content_area(self, terminal_width, terminal_height); - return self.transcript_max_scroll_for_area(area); - } - self.transcript - .len() - .saturating_sub(1) - .min(u16::MAX as usize) as u16 - } - - fn transcript_max_scroll_for_area(&self, area: Rect) -> u16 { - text_scroll_metrics( - &self.transcript_text(), - area.width.saturating_sub(2).max(1) as usize, - area.height.saturating_sub(2).max(1) as usize, - u16::MAX, - ) - .offset_u16() - } - - fn scroll_to_bottom(&mut self) { - self.transcript_scroll = self.max_scroll(); - } - - fn scroll_to_line(&mut self, line: usize) { - self.transcript_scroll = line.min(u16::MAX as usize) as u16; - } - - fn clear_transcript(&mut self) { - self.transcript.clear(); - self.transcript_scroll = 0; - self.home_dashboard_visible = true; - self.home_selection = 0; - self.status = "Transcript cleared.".to_owned(); - self.record_activity("transcript cleared"); - } - - fn open_home_dashboard(&mut self) { - if self.pending_approval.is_some() { - self.status = "Finish the approval first, or press Esc to cancel it.".to_owned(); - return; - } - if self.running_job_blocks_screen_switch() { - return; - } - self.examine_manager = None; - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.home_dashboard_visible = true; - self.home_selection = self - .home_selection - .min(home_dashboard_actions(self).len().saturating_sub(1)); - self.status = "Home opened. Use arrow keys, then Enter.".to_owned(); - } - - fn return_to_home_after_surface_close(&mut self, status: impl Into) { - self.clear_input(); - self.home_dashboard_visible = true; - self.home_selection = self - .home_selection - .min(home_dashboard_actions(self).len().saturating_sub(1)); - self.status = status.into(); - } - - fn selected_home_dashboard_action(&self) -> Option { - let actions = home_dashboard_actions(self); - actions - .get(self.home_selection.min(actions.len().saturating_sub(1))) - .copied() - } - - fn move_home_dashboard_selection(&mut self, direction: CompletionDirection) { - let count = home_dashboard_actions(self).len(); - if count <= 1 { - self.home_selection = 0; - return; - } - self.home_selection = match direction { - CompletionDirection::Next => (self.home_selection + 1) % count, - CompletionDirection::Previous => { - if self.home_selection == 0 { - count - 1 - } else { - self.home_selection - 1 - } - } - }; - if let Some(action) = self.selected_home_dashboard_action() { - self.status = home_dashboard_action_status(action).to_owned(); - } - } - - fn perform_home_dashboard_action(&mut self) { - let Some(action) = self.selected_home_dashboard_action() else { - self.status = "Choose an action first.".to_owned(); - return; - }; - if action == HomeDashboardAction::Quit { - self.request_quit_overlay_or_wait(); - return; - } - if action == HomeDashboardAction::Help { - self.open_interactive_help_overlay(); - return; - } - let command = home_dashboard_action_command(action); - self.handle_command(command); - } - - fn record_activity(&mut self, message: impl Into) { - let message = truncate_activity_message(message.into()); - self.activity_log.push_back(message); - while self.activity_log.len() > MAX_ACTIVITY_LOG_ITEMS { - self.activity_log.pop_front(); - } - } - - #[cfg(test)] - fn activity_text(&self) -> String { - if self.activity_log.is_empty() { - "No activity yet.".to_owned() - } else { - self.activity_log - .iter() - .rev() - .map(String::as_str) - .collect::>() - .join("\n") - } - } - - fn refresh_status(&mut self) { - self.refresh_config(); - if self.config.telemetry.local_inspection_enabled() { - self.gpu_telemetry.force_refresh(); - self.status = "Status refreshed.".to_owned(); - } else { - self.gpu_telemetry.clear(); - self.status = "Status refreshed; GPU local checks are turned off.".to_owned(); - } - self.record_activity("status refreshed"); - } - - fn open_command_screen_target(&mut self, target: CommandScreenTarget) { - let (title, detail) = self.render_command_screen_target(target, false); - self.open_command_screen( - title, - detail, - Some(target), - self.command_screen_actions_for_target(target), - ); - } - - fn open_command_screen( - &mut self, - title: impl Into, - detail: impl Into, - refresh_target: Option, - actions: Vec, - ) { - self.remember_current_chat_session(); - self.command_screen_last_area.set(None); - self.overlay_card = None; - self.examine_manager = None; - self.logs_view = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - let title = title.into(); - self.command_screen = Some(CommandScreenState { - title: title.clone(), - detail: detail.into(), - selected: 0, - detail_scroll: 0, - detail_modal: None, - refresh_target, - actions, - plan_request: None, - plan_action: None, - chat_prompt: None, - chat_tools: false, - chat_model: None, - chat_session: None, - message: None, - }); - self.status = format!("{title} opened. Use arrow keys, then Enter."); - } - - fn remember_current_chat_session(&mut self) { - if let Some(session) = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.clone()) - { - self.saved_chat_session = Some(session); - } - } - - fn open_help_overlay(&mut self) { - self.overlay_card = Some(OverlayCardState { - title: "Command List".to_owned(), - detail: "Choose a command, then press Enter to open it. Press Esc to close this card." - .to_owned(), - selected: 0, - detail_scroll: 0, - actions: help_command_screen_actions() - .into_iter() - .filter_map(overlay_action_from_command_screen_action) - .collect(), - }); - self.status = "Command list opened. Use arrows, then Enter.".to_owned(); - } - - fn open_interactive_help_overlay(&mut self) { - self.folder_browser = None; - self.overlay_card = Some(OverlayCardState { - title: "Help".to_owned(), - detail: "Choose a topic on the left. Use PageUp/PageDown or the mouse wheel to scroll." - .to_owned(), - selected: 0, - detail_scroll: 0, - actions: help_topic_actions(), - }); - self.status = "Help opened. Choose a topic, or press Esc to close.".to_owned(); - } - - fn open_clear_overlay(&mut self) { - self.overlay_card = Some(OverlayCardState { - title: "Clear".to_owned(), - detail: "Clear the transcript shown on the main screen?\n\nThis only clears the visible conversation history in this app. It does not delete ROCm installs, settings, logs, or cached downloads.".to_owned(), - selected: 0, - detail_scroll: 0, - actions: vec![OverlayCardAction::Close, OverlayCardAction::ClearTranscript], - }); - self.status = "Clear transcript? Choose an option.".to_owned(); - } - - fn open_quit_overlay(&mut self) { - let owned = self.tui_owned_running_processes(); - let detail = if owned.is_empty() { - "Leave ROCm CLI?".to_owned() - } else { - let mut detail = "Leave ROCm CLI?\n\nThese will close too:".to_owned(); - for item in owned { - detail.push_str("\n "); - detail.push_str(&item); - } - detail - }; - self.overlay_card = Some(OverlayCardState { - title: "Quit".to_owned(), - detail, - selected: 0, - detail_scroll: 0, - actions: vec![OverlayCardAction::Close, OverlayCardAction::Quit], - }); - self.status = "Quit ROCm CLI? Choose an option.".to_owned(); - } - - fn open_stop_chat_assistant_overlay(&mut self, service_id: String) { - self.chat_stop_service_id = Some(service_id.clone()); - self.overlay_card = Some(OverlayCardState { - title: "Stop Assistant".to_owned(), - detail: format!("Stop the local assistant?\n\nServer: {service_id}"), - selected: 0, - detail_scroll: 0, - actions: vec![ - OverlayCardAction::Close, - OverlayCardAction::StopChatAssistant, - ], - }); - self.status = "Stop assistant? Choose an option.".to_owned(); - } - - fn open_detail_overlay(&mut self, title: impl Into, detail: impl Into) { - self.overlay_card = Some(OverlayCardState { - title: title.into(), - detail: detail.into(), - selected: 0, - detail_scroll: 0, - actions: Vec::new(), - }); - self.status = "Detail card opened. Press Esc to close it.".to_owned(); - } - - fn request_quit_overlay_or_wait(&mut self) { - if self.pending_approval.is_some() { - self.status = "Finish or cancel this review before quitting.".to_owned(); - return; - } - if let Some(job) = self - .running_job - .as_ref() - .filter(|job| !matches!(job.kind, RunningJobKind::ExamineRefresh)) - { - self.status = format!( - "{} is still running. Wait for it to finish before quitting.", - job.title - ); - return; - } - self.open_quit_overlay(); - } - - fn tui_owned_running_processes(&self) -> Vec { - let mut items = Vec::new(); - for record in self.tui_owned_running_service_records() { - items.push(format!( - "{}: {} at {}", - record.engine, record.canonical_model_id, record.endpoint_url - )); - } - if self.tui_started_comfyui - && let Ok(Some(url)) = crate::comfyui::running_url(&self.paths) - { - items.push(format!("ComfyUI at {url}")); - } - items - } - - fn tui_owned_running_service_records(&self) -> Vec { - if self.tui_owned_service_ids.is_empty() { - return Vec::new(); - } - load_managed_services(&self.paths) - .unwrap_or_default() - .into_iter() - .filter(|record| self.tui_owned_service_ids.contains(&record.service_id)) - .filter(managed_service_is_live) - .collect() - } - - fn stop_tui_owned_processes_for_quit(&mut self) -> Result<()> { - let mut errors = Vec::new(); - for record in self.tui_owned_running_service_records() { - match crate::run_internal_sandbox_tool( - &self.paths, - crate::SandboxToolArg::StopServer, - Some(record.service_id.clone()), - true, - ) { - Ok(_) => { - self.tui_owned_service_ids.remove(&record.service_id); - } - Err(error) => { - errors.push(format!("{}: {error}", record.service_id)); - } - } - } - if self.tui_started_comfyui { - match crate::comfyui::running_url(&self.paths) { - Ok(Some(_)) => match crate::comfyui::stop(&self.paths) { - Ok(_) => { - self.tui_started_comfyui = false; - } - Err(error) => errors.push(format!("ComfyUI: {error}")), - }, - Ok(None) => { - self.tui_started_comfyui = false; - } - Err(error) => errors.push(format!("ComfyUI: {error}")), - } - } - if errors.is_empty() { - Ok(()) - } else { - bail!(errors.join("\n")) - } - } - - fn open_unknown_command_overlay(&mut self, command: &str) { - let command = command.trim(); - self.overlay_card = Some(OverlayCardState { - title: "Command Not Found".to_owned(), - detail: format!( - "I do not recognize /{command}.\n\nChoose Help to see the commands ROCm CLI understands, or stay on this screen and try another command." - ), - selected: 0, - detail_scroll: 0, - actions: vec![ - OverlayCardAction::Close, - OverlayCardAction::OpenCommand("help"), - ], - }); - self.status = "Command not found. Open Help or stay here.".to_owned(); - } - - fn open_command_parse_error_overlay(&mut self, error: impl std::fmt::Display) { - self.overlay_card = Some(OverlayCardState { - title: "Command Needs Fix".to_owned(), - detail: format!( - "{error}\n\nCheck the quote marks, or stay here and try again. Help shows the commands ROCm CLI understands." - ), - selected: 0, - detail_scroll: 0, - actions: vec![ - OverlayCardAction::Close, - OverlayCardAction::OpenCommand("help"), - ], - }); - self.status = "Command needs a fix. Stay here or open Help.".to_owned(); - } - - fn close_overlay_card(&mut self) -> bool { - if let Some(state) = self.overlay_card.take() { - if state.title == "Stop Assistant" { - self.chat_stop_service_id = None; - } - self.status = if state.title == "Clear API Key" { - "API key was kept.".to_owned() - } else if state.title == "First-time Setup" { - "Setup settings were kept.".to_owned() - } else if state.title == "Stop Assistant" { - "Assistant kept running.".to_owned() - } else { - "Card closed.".to_owned() - }; - true - } else { - false - } - } - - fn move_overlay_card_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.overlay_card.as_mut() else { - return; - }; - let count = state.actions.len(); - if count == 0 { - state.selected = 0; - state.detail_scroll = 0; - self.status = "Use PageUp/PageDown to scroll, or Esc to close this card.".to_owned(); - return; - } - if count == 1 { - state.selected = 0; - } else { - state.selected = cycle_index(state.selected, count, direction); - } - state.detail_scroll = 0; - self.status = "Use Enter to choose, or Esc to close this card.".to_owned(); - } - - const fn scroll_overlay_card_detail(&mut self, lines: i16) { - let Some(state) = self.overlay_card.as_mut() else { - return; - }; - if lines.is_negative() { - state.detail_scroll = state.detail_scroll.saturating_sub(lines.unsigned_abs()); - } else { - state.detail_scroll = state.detail_scroll.saturating_add(lines as u16); - } - } - - fn perform_overlay_card_action(&mut self) { - let Some(action) = self - .overlay_card - .as_ref() - .and_then(|state| state.actions.get(state.selected)) - .copied() - else { - self.status = "Use PageUp/PageDown to scroll, or Esc to close this card.".to_owned(); - return; - }; - match action { - OverlayCardAction::OpenCommand(command) => { - self.overlay_card = None; - self.handle_command(command); - } - OverlayCardAction::HelpTopic(_) => { - self.status = "Choose a topic, then scroll the details on the right.".to_owned(); - } - OverlayCardAction::ClearTranscript => { - self.overlay_card = None; - self.clear_transcript(); - } - OverlayCardAction::ClearProviderKey => { - self.overlay_card = None; - self.confirm_clear_config_provider_key(); - } - OverlayCardAction::ShowSetupAgain => { - self.overlay_card = None; - self.show_setup_again(); - } - OverlayCardAction::StopChatAssistant => { - self.stop_chat_assistant_from_overlay(); - } - OverlayCardAction::Quit => match self.stop_tui_owned_processes_for_quit() { - Ok(()) => { - self.should_quit = true; - self.status = "Exiting ROCm CLI.".to_owned(); - } - Err(error) => { - if let Some(state) = self.overlay_card.as_mut() { - state.detail = - format!("ROCm CLI could not close everything it started.\n\n{error}"); - state.selected = 0; - state.detail_scroll = 0; - } - self.status = "Quit paused. A service could not be stopped.".to_owned(); - } - }, - OverlayCardAction::Close => { - self.close_overlay_card(); - } - } - } - - fn confirm_overlay_card_danger_action(&mut self) -> bool { - let Some(index) = self.overlay_card.as_ref().and_then(|state| { - state.actions.iter().position(|action| { - matches!( - action, - OverlayCardAction::ClearTranscript - | OverlayCardAction::ClearProviderKey - | OverlayCardAction::ShowSetupAgain - | OverlayCardAction::StopChatAssistant - | OverlayCardAction::Quit - ) - }) - }) else { - return false; - }; - if let Some(state) = self.overlay_card.as_mut() { - state.selected = index; - } - self.perform_overlay_card_action(); - true - } - - fn open_install_folder_browser_for_args(&mut self, args: Vec) { - self.open_folder_browser( - "Choose ROCm Folder", - "Choose an install folder.", - FolderBrowserContext::InstallSdk { args }, - ); - } - - fn open_folder_browser( - &mut self, - title: impl Into, - prompt: impl Into, - context: FolderBrowserContext, - ) { - let start = match &context { - FolderBrowserContext::InstallSdk { .. } | FolderBrowserContext::InstallSdkForm => { - let install_root = setup_install_root(&self.paths, &self.config); - if install_root.exists() { - install_root - } else { - install_root - .parent() - .filter(|parent| parent.exists()) - .map(Path::to_path_buf) - .unwrap_or(install_root) - } - } - FolderBrowserContext::OnboardingInstallFolder => { - let install_root = setup_install_root(&self.paths, &self.config); - if install_root.exists() { - install_root - } else { - install_root - .parent() - .filter(|parent| parent.exists()) - .map(Path::to_path_buf) - .unwrap_or(install_root) - } - } - }; - let current_dir = folder_browser_existing_dir(start); - let (entries, message) = folder_browser_entries(¤t_dir); - self.folder_browser = Some(FolderBrowserState { - title: title.into(), - prompt: prompt.into(), - current_dir, - entries, - selected: 0, - scroll_offset: 0, - message, - context, - }); - self.overlay_card = None; - self.status = "Folder picker opened.".to_owned(); - } - - fn close_folder_browser(&mut self) -> bool { - if self.folder_browser.take().is_some() { - self.status = - "Folder picker closed. Press Enter to open it again, or T to type a path." - .to_owned(); - true - } else { - false - } - } - - fn move_folder_browser_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.folder_browser.as_mut() else { - return; - }; - state.selected = cycle_index(state.selected, state.entries.len(), direction); - state.scroll_offset = state.scroll_offset.min(state.selected); - self.status = folder_browser_selected_status(state); - } - - fn page_folder_browser_selection(&mut self, lines: i16) { - let Some(state) = self.folder_browser.as_mut() else { - return; - }; - let len = state.entries.len(); - if len == 0 { - state.selected = 0; - self.status = "This folder could not be listed.".to_owned(); - return; - } - let step = lines.unsigned_abs() as usize; - if lines.is_negative() { - state.selected = state.selected.saturating_sub(step); - state.scroll_offset = state.scroll_offset.saturating_sub(step); - self.status = "Folder list moved up.".to_owned(); - } else { - state.selected = state - .selected - .saturating_add(step) - .min(len.saturating_sub(1)); - state.scroll_offset = state - .scroll_offset - .saturating_add(step) - .min(len.saturating_sub(1)); - self.status = "Folder list moved down.".to_owned(); - } - } - - fn navigate_folder_browser_to(&mut self, path: PathBuf) { - let Some(state) = self.folder_browser.as_mut() else { - return; - }; - let path = normalize_runtime_folder_path(path); - if !path.is_dir() { - state.message = Some(format!( - "That is not a folder: {}", - display_runtime_folder_path(&path) - )); - self.status = "Choose a folder.".to_owned(); - return; - } - let current_dir = folder_browser_existing_dir(path); - let (entries, message) = folder_browser_entries(¤t_dir); - state.current_dir = current_dir; - state.entries = entries; - state.selected = 0; - state.scroll_offset = 0; - state.message = message; - self.status = "Folder opened.".to_owned(); - } - - fn perform_folder_browser_selection(&mut self) { - let Some(selection) = self - .folder_browser - .as_ref() - .and_then(|state| state.entries.get(state.selected).cloned()) - else { - self.status = "No folder is selected.".to_owned(); - return; - }; - match selection.kind { - FolderBrowserEntryKind::Parent - | FolderBrowserEntryKind::Drive - | FolderBrowserEntryKind::Directory => { - self.navigate_folder_browser_to(selection.path); - } - FolderBrowserEntryKind::UseCurrent | FolderBrowserEntryKind::NewChild => { - self.use_folder_browser_path(selection.path); - } - } - } - - fn use_folder_browser_path(&mut self, folder: PathBuf) { - let folder = normalize_runtime_folder_path(folder); - let folder_text = folder.display().to_string(); - let folder = match validate_onboarding_install_folder(&folder_text) { - Ok(folder) => folder, - Err(error) => { - if let Some(state) = self.folder_browser.as_mut() { - state.message = Some(error.to_string()); - } - self.status = "Choose a different folder.".to_owned(); - return; - } - }; - let Some(context) = self.folder_browser.take().map(|state| state.context) else { - return; - }; - match context { - FolderBrowserContext::InstallSdk { args } => { - self.finish_install_sdk_folder_choice(args, folder); - } - FolderBrowserContext::InstallSdkForm => { - self.finish_install_sdk_form_folder_choice(folder); - } - FolderBrowserContext::OnboardingInstallFolder => { - self.finish_onboarding_install_folder_choice(folder); - } - } - } - - fn finish_onboarding_install_folder_choice(&mut self, folder: PathBuf) { - self.config.setup.therock_venv = Some(folder); - match self.config.save(&self.paths) { - Ok(()) => { - self.rebase_paths_to_saved_setup_folder(); - self.onboarding_setup_error = None; - self.select_onboarding_choice(OnboardingMenuChoice::Primary); - self.status = "ROCm folder saved. Press Enter to install.".to_owned(); - } - Err(error) => { - self.onboarding_setup_error = - Some(format!("ROCm folder could not be saved: {error}")); - self.status = "ROCm folder could not be saved.".to_owned(); - } - } - } - - fn finish_install_sdk_form_folder_choice(&mut self, folder: PathBuf) { - if let Some(state) = self.install_manager.as_mut() - && let InstallManagerScreen::Sdk { - selected, - folder: form_folder, - editing_folder, - .. - } = &mut state.screen - { - *selected = install_sdk_choice_index(InstallSdkChoice::Install); - *form_folder = folder.display().to_string(); - *editing_folder = false; - state.message = None; - state.detail_scroll = 0; - } - self.clear_input(); - self.status = "Install folder selected. Press Enter to review setup.".to_owned(); - } - - fn finish_install_sdk_folder_choice(&mut self, mut args: Vec, folder: PathBuf) { - let folder = folder.display().to_string(); - args.push("--prefix".to_owned()); - args.push(folder.clone()); - let display_command = format_structured_tool_call("rocm", &args); - self.set_current_chat_pending_install_args(None); - if self.command_screen_is_chat_session() { - self.push_chat_session_turn( - ChatSessionRole::Assistant, - format!( - "I can install ROCm/TheRock here:\n\n{folder}\n\nI will show the install card before anything downloads." - ), - ); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - } - self.request_screen_cli_approval_with_explanation( - "Install ROCm", - "Install", - args, - Some(display_command), - None, - ); - self.status = "Install folder selected. Review before installing.".to_owned(); - } - - fn open_plan_review_screen(&mut self, title: &str, request: &str) { - let action = freeform_plan_next_action_with_context(request, &self.paths, &self.config); - let cpu_mode_not_offered = action.is_none() && plan_request_mentions_cpu_mode(request); - self.mode = action - .as_ref() - .map_or(TuiMode::Ask, |action| tui_mode_for_cli_args(&action.args)); - let detail = if cpu_mode_not_offered { - render_plan_cpu_not_offered_text(request) - } else { - render_plan_review_text(request, action.as_ref()) - }; - let actions = if action.is_some() { - vec![ - CommandScreenAction::RunPlan, - CommandScreenAction::EditPlan, - CommandScreenAction::Back, - ] - } else { - vec![CommandScreenAction::EditPlan, CommandScreenAction::Back] - }; - self.open_command_screen(title, detail, None, actions); - if let Some(state) = self.command_screen.as_mut() { - state.plan_request = Some(request.to_owned()); - state.plan_action = action.clone(); - } - self.status = match action { - Some(action) if action.has_placeholders => { - "Plan needs more detail. Choose Edit request.".to_owned() - } - Some(_) => "Plan ready. Choose Run, Edit request, or Back.".to_owned(), - None if cpu_mode_not_offered => { - "CPU mode is not offered. Choose Edit request.".to_owned() - } - None => "Plan needs a clearer request. Choose Edit request.".to_owned(), - }; - } - - fn close_command_screen(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.remember_current_chat_session(); - self.command_screen_last_area.set(None); - self.command_screen = None; - self.return_to_home_after_surface_close("Screen closed."); - } - - fn request_stop_chat_assistant_or_close(&mut self) { - if !self.command_screen_is_chat_session() { - self.close_command_screen(); - return; - } - if self.running_job_blocks_screen_close() { - return; - } - let service_id = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .and_then(|session| { - session.service_id.clone().or_else(|| { - self.ready_local_chat_record_for_session(session) - .map(|record| record.service_id) - }) - }) - .or_else(|| { - self.first_ready_builtin_assistant_service() - .map(|record| record.service_id) - }); - if let Some(service_id) = service_id { - self.open_stop_chat_assistant_overlay(service_id); - } else { - self.close_command_screen(); - } - } - - fn stop_chat_assistant_from_overlay(&mut self) { - let Some(service_id) = self.chat_stop_service_id.take() else { - self.close_overlay_card(); - self.close_command_screen(); - return; - }; - self.overlay_card = None; - self.remember_current_chat_session(); - self.command_screen_last_area.set(None); - self.command_screen = None; - self.home_dashboard_visible = true; - let args = vec![ - "services".to_owned(), - "stop".to_owned(), - service_id.clone(), - "--yes".to_owned(), - ]; - self.start_cli_command_with_kind( - "Services", - args, - RunningJobKind::ServiceLifecycle { - action: ServiceLifecycleAction::Stop, - service_id, - }, - None, - ); - } - - fn command_screen_actions_for_target( - &self, - target: CommandScreenTarget, - ) -> Vec { - match target { - CommandScreenTarget::Permissions if self.config.permissions.full_access_enabled() => { - vec![ - CommandScreenAction::ResetPermissions, - CommandScreenAction::Back, - ] - } - CommandScreenTarget::Permissions => vec![ - CommandScreenAction::EnableFullAccess, - CommandScreenAction::Back, - ], - CommandScreenTarget::Help => help_command_screen_actions(), - CommandScreenTarget::UninstallPreview => { - vec![ - CommandScreenAction::OpenCommand("uninstall apply"), - CommandScreenAction::Back, - ] - } - CommandScreenTarget::ChatProvider => vec![ - CommandScreenAction::OpenCommand("provider"), - CommandScreenAction::OpenCommand("config"), - CommandScreenAction::Back, - ], - CommandScreenTarget::Gpu => vec![ - CommandScreenAction::OpenCommand("config"), - CommandScreenAction::Back, - ], - CommandScreenTarget::Daemon => vec![ - CommandScreenAction::OpenCommand("automations"), - CommandScreenAction::OpenCommand("services"), - CommandScreenAction::Back, - ], - } - } - - fn render_command_screen_target( - &mut self, - target: CommandScreenTarget, - force_refresh: bool, - ) -> (String, String) { - match target { - CommandScreenTarget::Help => ( - "Help".to_owned(), - "Choose a command on the left, then press Enter to open it.".to_owned(), - ), - CommandScreenTarget::Permissions => { - self.refresh_config(); - ( - "Permissions".to_owned(), - render_permissions_text(&self.config), - ) - } - CommandScreenTarget::Gpu => { - self.refresh_config(); - if self.config.telemetry.local_inspection_enabled() { - if force_refresh { - self.gpu_telemetry.force_refresh(); - } else { - self.gpu_telemetry.maybe_refresh(); - } - ("GPU".to_owned(), self.gpu_telemetry.detail_text()) - } else { - self.gpu_telemetry.clear(); - ("GPU".to_owned(), disabled_gpu_telemetry_text(&self.config)) - } - } - CommandScreenTarget::Daemon => { - self.refresh_config(); - ( - "Background Helper".to_owned(), - render_daemon_text(&self.paths, &self.config), - ) - } - CommandScreenTarget::UninstallPreview => ( - "Uninstall".to_owned(), - render_uninstall_dry_run(&self.paths) - .unwrap_or_else(|error| format!("Uninstall preview failed.\n\n{error}")), - ), - CommandScreenTarget::ChatProvider => ( - "Chat".to_owned(), - render_chat_text(&self.paths, &self.provider) - .unwrap_or_else(|error| format!("Chat setup check failed.\n\n{error}")), - ), - } - } - - fn refresh_command_screen(&mut self) { - let Some(target) = self - .command_screen - .as_ref() - .and_then(|state| state.refresh_target) - else { - self.status = "This screen does not need refresh.".to_owned(); - return; - }; - let (title, detail) = self.render_command_screen_target(target, true); - let actions = self.command_screen_actions_for_target(target); - if let Some(state) = self.command_screen.as_mut() { - state.title = title; - state.detail = detail; - state.actions = actions; - state.selected = state.selected.min(state.actions.len().saturating_sub(1)); - state.detail_scroll = 0; - state.detail_modal = None; - state.message = None; - } - self.status = "Screen refreshed.".to_owned(); - } - - fn move_command_screen_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.command_screen.as_mut() else { - return; - }; - if state.actions.is_empty() { - return; - } - state.selected = match direction { - CompletionDirection::Next => (state.selected + 1) % state.actions.len(), - CompletionDirection::Previous => { - if state.selected == 0 { - state.actions.len().saturating_sub(1) - } else { - state.selected - 1 - } - } - }; - self.status = "Option selected. Press Enter to continue.".to_owned(); - } - - fn selected_command_screen_action(&self) -> CommandScreenAction { - self.command_screen - .as_ref() - .and_then(|state| state.actions.get(state.selected).copied()) - .unwrap_or(CommandScreenAction::Back) - } - - fn scroll_command_screen_detail(&mut self, lines: i16) { - let chat_detail_max_scroll = if self.command_screen_is_chat_session() - && self - .command_screen - .as_ref() - .is_some_and(|state| state.detail_modal.is_none()) - { - Some(self.command_screen_chat_detail_max_scroll()) - } else { - None - }; - let Some(state) = self.command_screen.as_mut() else { - return; - }; - let scroll = if let Some(modal) = state.detail_modal.as_mut() { - &mut modal.scroll - } else { - &mut state.detail_scroll - }; - if let Some(max_scroll) = chat_detail_max_scroll { - *scroll = (*scroll).min(max_scroll); - } - if lines.is_negative() { - *scroll = scroll.saturating_sub(lines.unsigned_abs()); - } else { - *scroll = scroll.saturating_add(lines as u16); - if let Some(max_scroll) = chat_detail_max_scroll { - *scroll = (*scroll).min(max_scroll); - } - } - } - - fn command_screen_chat_detail_max_scroll(&self) -> u16 { - if let Some(area) = self.command_screen_last_area.get() { - return bounded_chat_session_scroll(&command_screen_detail_text(self), area, u16::MAX); - } - let (terminal_width, terminal_height) = effective_terminal_size(); - let area = main_content_area(self, terminal_width, terminal_height); - bounded_chat_session_scroll(&command_screen_detail_text(self), area, u16::MAX) - } - - fn command_screen_chat_should_follow_latest(&self) -> bool { - let Some(state) = self.command_screen.as_ref() else { - return false; - }; - if state.chat_session.is_none() { - return false; - } - state.detail_scroll == CHAT_SESSION_FOLLOW_SCROLL - || state.detail_scroll - >= self - .command_screen_chat_detail_max_scroll() - .saturating_sub(1) - } - - const fn set_chat_session_follow_if_needed(&mut self, should_follow: bool) { - if should_follow - && let Some(state) = self.command_screen.as_mut() - && state.chat_session.is_some() - { - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - } - - fn scroll_chat_input(&mut self, lines: i16) { - if !self.command_screen_is_chat_session() || self.input.is_empty() { - self.scroll_command_screen_detail(lines); - return; - } - let (terminal_width, terminal_height) = effective_terminal_size(); - let input_height = chat_input_area_height(self, terminal_width, terminal_height); - let max_scroll = bounded_chat_input_scroll( - &self.input, - self.input_cursor, - terminal_width.saturating_sub(2), - input_height.saturating_sub(2).max(1), - u16::MAX, - ); - self.chat_input_scroll = self.chat_input_scroll.min(max_scroll); - if lines.is_negative() { - self.chat_input_scroll = self.chat_input_scroll.saturating_sub(lines.unsigned_abs()); - } else { - self.chat_input_scroll = self - .chat_input_scroll - .saturating_add(lines as u16) - .min(max_scroll); - } - } - - fn open_command_screen_detail_modal( - &mut self, - title: impl Into, - detail: impl Into, - ) { - if let Some(state) = self.command_screen.as_mut() { - state.detail_modal = Some(CommandScreenDetailModal { - title: title.into(), - detail: detail.into(), - scroll: 0, - }); - } - } - - fn open_log_output_card(&mut self, title: impl Into, detail: impl Into) { - self.overlay_card = Some(OverlayCardState { - title: title.into(), - detail: detail.into(), - selected: 0, - detail_scroll: 0, - actions: Vec::new(), - }); - self.status = "Log opened. Scroll or press Esc to close.".to_owned(); - } - - fn close_command_screen_detail_modal(&mut self) -> bool { - let Some(state) = self.command_screen.as_mut() else { - return false; - }; - if state.detail_modal.take().is_some() { - state.detail_scroll = 0; - self.status = "Detail card closed.".to_owned(); - return true; - } - false - } - - const fn active_manager_detail_scroll_mut(&mut self) -> Option<&mut u16> { - if let Some(state) = self.runtime_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.install_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.engine_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.model_picker.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.serve_wizard.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.update_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.automations_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.config_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.provider_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - if let Some(state) = self.services_manager.as_mut() { - return Some(&mut state.detail_scroll); - } - None - } - - const fn scroll_active_manager_detail(&mut self, lines: i16) { - let Some(scroll) = self.active_manager_detail_scroll_mut() else { - return; - }; - if lines.is_negative() { - *scroll = scroll.saturating_sub(lines.unsigned_abs()); - } else { - *scroll = scroll.saturating_add(lines as u16); - } - } - - fn move_pending_approval_selection(&mut self, direction: CompletionDirection) { - let Some(pending) = self.pending_approval.as_mut() else { - return; - }; - pending.selected = match direction { - CompletionDirection::Next => (pending.selected + 1) % 2, - CompletionDirection::Previous => usize::from(pending.selected == 0), - }; - self.set_pending_approval_selection_status(); - } - - fn select_pending_approval_row(&mut self, approve: bool) { - let Some(pending) = self.pending_approval.as_mut() else { - return; - }; - pending.selected = usize::from(!approve); - self.set_pending_approval_selection_status(); - } - - fn set_pending_approval_selection_status(&mut self) { - let Some(pending) = self.pending_approval.as_ref() else { - return; - }; - self.status = if pending.selected == 0 { - "Approve selected. Press Enter to continue.".to_owned() - } else if matches!(pending.action, ApprovalAction::Proposal { .. }) { - "Reject selected. Press Enter to reject.".to_owned() - } else { - "Cancel selected. Press Enter to cancel.".to_owned() - }; - } - - fn scroll_pending_approval_detail(&mut self, lines: i16) { - let Some(pending) = self.pending_approval.as_mut() else { - return; - }; - if lines.is_positive() { - pending.detail_scroll = pending.detail_scroll.saturating_add(lines as u16); - } else { - pending.detail_scroll = pending.detail_scroll.saturating_sub(lines.unsigned_abs()); - } - self.status = if pending.detail_scroll == 0 { - "Review card moved to top.".to_owned() - } else { - "Review card scrolled.".to_owned() - }; - } - - fn perform_pending_approval_selection(&mut self) { - let approve = self - .pending_approval - .as_ref() - .is_none_or(|pending| pending.selected == 0); - if approve { - self.approve_focused_action(); - } else { - self.cancel_focused_action(); - } - } - - fn perform_command_screen_selected_action(&mut self) { - match self.selected_command_screen_action() { - CommandScreenAction::RunPlan => self.run_selected_plan_action(), - CommandScreenAction::EditPlan => self.edit_selected_plan_request(), - CommandScreenAction::SendChat => self.send_selected_chat_prompt(), - CommandScreenAction::EditChat => self.edit_selected_chat_prompt(), - CommandScreenAction::ViewChatConnection => self.open_chat_connection_detail(), - CommandScreenAction::ViewChatResult => self.open_chat_result_detail(), - CommandScreenAction::EnableFullAccess => { - if self.config.permissions.full_access_enabled() { - self.refresh_command_screen(); - self.status = "Full access is already on.".to_owned(); - return; - } - self.pending_approval = Some(PendingApproval { - title: "Full Access".to_owned(), - selected: 0, - detail_scroll: 0, - action: ApprovalAction::EnableFullAccess, - }); - self.status = - "Review the change. Press Enter or Y to approve; Esc or N cancels.".to_owned(); - } - CommandScreenAction::ResetPermissions => { - self.config.permissions.mode = PERMISSIONS_MODE_ASK.to_owned(); - match self.config.save(&self.paths) { - Ok(()) => { - self.refresh_command_screen(); - self.status = "Permissions reset to ask first.".to_owned(); - } - Err(error) => { - if let Some(state) = self.command_screen.as_mut() { - state.message = - Some(format!("Permissions could not be saved.\n\n{error}")); - } - self.status = "Permissions could not be saved.".to_owned(); - } - } - } - CommandScreenAction::OpenCommand(command) => { - let command = command.to_owned(); - let previous_title = self - .command_screen - .as_ref() - .map(|state| state.title.clone()) - .unwrap_or_default(); - let previous_selected = self - .command_screen - .as_ref() - .map(|state| state.selected) - .unwrap_or_default(); - self.remember_current_chat_session(); - self.command_screen = None; - self.handle_command(&command); - if let Some(state) = self.command_screen.as_mut() - && state.title == previous_title - { - state.selected = previous_selected.min(state.actions.len().saturating_sub(1)); - } - } - CommandScreenAction::Back => self.close_command_screen(), - } - } - - fn send_selected_chat_prompt(&mut self) { - if self.command_screen_is_chat_session() { - let prompt = self.input.trim().to_owned(); - if prompt.is_empty() { - if let Some(state) = self.command_screen.as_mut() { - state.message = Some( - "Type your message below, then press Enter. I can use ROCm commands after you approve them." - .to_owned(), - ); - state.detail_scroll = 0; - } - self.status = "Type a message to chat with the local assistant.".to_owned(); - return; - } - self.clear_input(); - self.submit_chat_session_prompt(prompt); - return; - } - - let prompt = self - .command_screen - .as_ref() - .and_then(|state| state.chat_prompt.clone()) - .unwrap_or_default(); - let rocm_tools = self - .command_screen - .as_ref() - .is_some_and(|state| state.chat_tools); - if prompt.trim().is_empty() { - if let Some(state) = self.command_screen.as_mut() { - state.message = - Some("The chat prompt is empty.\n\nChoose Edit prompt and type what you want to ask.".to_owned()); - } - self.status = "Chat prompt is empty.".to_owned(); - return; - } - let model = self - .command_screen - .as_ref() - .and_then(|state| state.chat_model.clone()); - if self.provider == "local" - && rocm_tools - && let Some(record) = self.first_ready_builtin_assistant_service() - { - self.open_local_rocm_tools_chat_session(Some(record)); - self.submit_chat_session_prompt(prompt); - return; - } - self.start_chat_stream(prompt, rocm_tools, model); - } - - fn edit_selected_chat_prompt(&mut self) { - let prompt = self - .command_screen - .as_ref() - .and_then(|state| state.chat_prompt.clone()) - .unwrap_or_default(); - let rocm_tools = self - .command_screen - .as_ref() - .is_some_and(|state| state.chat_tools); - self.close_command_screen(); - self.set_input(if prompt.trim().is_empty() { - if rocm_tools { - "/chat --tools ".to_owned() - } else { - "/chat ".to_owned() - } - } else if rocm_tools { - format!("/chat --tools {prompt}") - } else { - format!("/chat {prompt}") - }); - self.status = "Edit the chat prompt below, then press Enter.".to_owned(); - } - - fn command_screen_is_chat_session(&self) -> bool { - self.command_screen - .as_ref() - .is_some_and(|state| state.chat_session.is_some()) - } - - fn submit_chat_session_prompt(&mut self, prompt: String) -> bool { - let prompt = prompt.trim().to_owned(); - if prompt.is_empty() { - self.status = "Type a message to chat with the local assistant.".to_owned(); - return false; - } - if self.handle_pending_chat_install_folder(&prompt) { - return true; - } - if let Some(reply) = Self::simple_chat_session_reply(&prompt) { - self.push_chat_session_turn(ChatSessionRole::User, prompt); - self.push_chat_session_turn(ChatSessionRole::Assistant, reply); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - self.status = "Assistant replied.".to_owned(); - return true; - } - if let Some(approval) = crate::install_sdk_chat_approval_for_prompt(&prompt) { - self.push_chat_session_turn(ChatSessionRole::User, prompt); - if chat_install_sdk_approval_needs_prefix(&approval.args) { - self.set_current_chat_pending_install_args(Some(approval.args.clone())); - self.push_chat_session_turn( - ChatSessionRole::Assistant, - chat_session_install_folder_request_text(&approval.args), - ); - self.open_install_folder_browser_for_args(approval.args.clone()); - self.status = "Choose the install folder. Nothing downloads yet.".to_owned(); - } else { - self.set_current_chat_pending_install_args(None); - self.push_chat_session_turn( - ChatSessionRole::Assistant, - chat_session_install_review_text(&approval.args), - ); - self.request_screen_cli_approval_with_explanation( - &approval.pending_title, - &approval.command_title, - approval.args, - approval.display_command, - approval.explanation, - ); - self.status = "Review the ROCm install before anything downloads.".to_owned(); - } - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - return true; - } - let model = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .and_then(|session| session.model.clone()); - let model = if self.provider == "local" { - let session = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .cloned(); - let Some(session) = session else { - self.open_local_assistant_start_flow(model); - return false; - }; - let Some(record) = self.ready_local_chat_record_for_session(&session) else { - self.open_local_assistant_start_flow(model); - return false; - }; - if let Some(session) = self - .command_screen - .as_mut() - .and_then(|state| state.chat_session.as_mut()) - { - session.model = Some(record.canonical_model_id.clone()); - session.service_id = Some(record.service_id.clone()); - session.endpoint_url = Some(record.endpoint_url.clone()); - } - Some(record.canonical_model_id) - } else { - model - }; - if self.provider == "local" && !self.local_chat_service_available(model.as_deref()) { - self.open_local_assistant_start_flow(model); - return false; - } - if self.handle_direct_chat_action_prompt(&prompt) { - return true; - } - let request_prompt = self.chat_session_request_prompt(&prompt); - self.push_chat_session_turn(ChatSessionRole::User, prompt); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - self.start_chat_stream(request_prompt, true, model) - } - - fn handle_direct_chat_action_prompt(&mut self, prompt: &str) -> bool { - let lower = prompt.trim().to_ascii_lowercase(); - if chat_prompt_is_how_to_only(&lower) { - return false; - } - let action: Option<(&str, &str, Vec, &str, &str)> = if any_substring( - &lower, - &["comfyui", "comfy ui", "comfy"], - ) { - if any_substring( - &lower, - &[ - "install", "setup", "set up", "download", "can you", "please", "for me", - ], - ) { - Some(( - "Install ComfyUI", - "ComfyUI", - vec!["comfyui".to_owned(), "install".to_owned()], - "Sure. I can install ComfyUI into ROCm CLI's app folder. Nothing downloads until you approve it.", - "Install ComfyUI into ROCm CLI's managed app folder.", - )) - } else if any_substring(&lower, &["start", "run", "launch", "open"]) { - if crate::comfyui::is_installed(&self.paths).unwrap_or(false) { - Some(( - "Start ComfyUI", - "ComfyUI", - vec!["comfyui".to_owned(), "start".to_owned()], - "Sure. I can start ComfyUI and show the local URL. Nothing launches until you approve it.", - "Start ComfyUI locally and show its URL.", - )) - } else { - Some(( - "Install ComfyUI", - "ComfyUI", - vec!["comfyui".to_owned(), "install".to_owned()], - "ComfyUI is not installed yet. I can install it first.", - "Install ComfyUI before starting it.", - )) - } - } else { - None - } - } else if any_substring(&lower, &["llm", "model", "assistant"]) - && any_substring( - &lower, - &[ - "serve", - "server", - "start", - "launch", - "setup", - "set up", - "run locally", - "run a model", - "run an llm", - "run the assistant", - ], - ) - { - let engine = assistant_requested_engine_from_prompt(&lower).unwrap_or("lemonade"); - if self.engine_installed_for_assistant_start(engine) { - Some(( - "Start local model server", - "Serve", - vec![ - "serve".to_owned(), - VALIDATED_LOCAL_ASSISTANT_MODEL.to_owned(), - "--engine".to_owned(), - engine.to_owned(), - "--device".to_owned(), - "gpu_required".to_owned(), - "--managed".to_owned(), - ], - "Sure. I can start the local assistant on your AMD GPU. Nothing launches until you approve it.", - "Start the local assistant.", - )) - } else { - Some(( - "Install engine", - "Engine", - vec![ - "engines".to_owned(), - "install".to_owned(), - engine.to_owned(), - ], - "That model engine is not installed yet. I can install it first.", - "Install the selected model engine before starting it.", - )) - } - } else { - None - }; - let Some((pending_title, command_title, args, assistant_text, reason)) = action else { - return false; - }; - let display_command = crate::format_structured_tool_call("rocm", &args); - self.push_chat_session_turn(ChatSessionRole::User, prompt.to_owned()); - self.push_chat_session_turn(ChatSessionRole::Assistant, assistant_text); - self.request_screen_cli_approval_with_explanation( - pending_title, - command_title, - args, - Some(display_command), - Some(reason.to_owned()), - ); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - self.status = "Review the suggested action.".to_owned(); - true - } - - fn handle_pending_chat_install_folder(&mut self, prompt: &str) -> bool { - let Some(mut args) = self.current_chat_pending_install_args() else { - return false; - }; - let normalized = prompt.trim().to_ascii_lowercase(); - if matches!( - normalized.as_str(), - "cancel" | "never mind" | "nevermind" | "stop" | "back" - ) { - self.set_current_chat_pending_install_args(None); - self.push_chat_session_turn(ChatSessionRole::User, prompt.to_owned()); - self.push_chat_session_turn( - ChatSessionRole::Assistant, - "No problem. I cancelled the ROCm install request. Nothing changed.", - ); - self.status = "Install request cancelled.".to_owned(); - return true; - } - if pending_install_folder_prompt_wants_picker(&normalized) { - self.push_chat_session_turn(ChatSessionRole::User, prompt.to_owned()); - self.push_chat_session_turn( - ChatSessionRole::Assistant, - "Sure. Choose the install folder in the picker. I will show the install card after that, before anything downloads.", - ); - self.open_install_folder_browser_for_args(args.clone()); - self.status = "Choose the install folder. Nothing downloads yet.".to_owned(); - return true; - } - let Some(folder) = crate::chat_install_folder_from_prompt(prompt) else { - self.push_chat_session_turn(ChatSessionRole::User, prompt.to_owned()); - if let Some(reply) = pending_install_folder_chat_reply(prompt) { - self.push_chat_session_turn(ChatSessionRole::Assistant, reply); - self.status = "Choose the install folder when you are ready.".to_owned(); - return true; - } - self.push_chat_session_turn( - ChatSessionRole::Assistant, - "I still need the install folder. Use the folder picker, or send a full folder path. Nothing installs until you review the card.", - ); - self.status = "Install folder needed.".to_owned(); - return true; - }; - args.push("--prefix".to_owned()); - args.push(folder.clone()); - let display_command = format_structured_tool_call("rocm", &args); - self.set_current_chat_pending_install_args(None); - self.push_chat_session_turn(ChatSessionRole::User, prompt.to_owned()); - self.push_chat_session_turn( - ChatSessionRole::Assistant, - format!( - "I can install ROCm/TheRock into:\n\n{folder}\n\nReview the card before anything downloads or changes." - ), - ); - self.request_screen_cli_approval_with_explanation( - "Install ROCm", - "Install", - args, - Some(display_command), - Some("Install ROCm/TheRock into the folder you chose.".to_owned()), - ); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - true - } - - fn current_chat_pending_install_args(&self) -> Option> { - self.command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .and_then(|session| session.pending_install_args.clone()) - } - - fn set_current_chat_pending_install_args(&mut self, args: Option>) { - if let Some(session) = self - .command_screen - .as_mut() - .and_then(|state| state.chat_session.as_mut()) - { - session.pending_install_args = args; - } - } - - fn simple_chat_session_reply(prompt: &str) -> Option { - if !is_plain_casual_input(prompt) { - return None; - } - Some( - "Hi. I am here. Ask me about ROCm setup, local models, ComfyUI, llama.cpp, or what this machine can run. I will ask before making changes." - .to_owned(), - ) - } - - fn chat_session_request_prompt(&self, prompt: &str) -> String { - let Some(session) = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - else { - return prompt.to_owned(); - }; - if session.turns.is_empty() { - return prompt.to_owned(); - } - let mut output = String::new(); - let _ = writeln!(output, "Conversation so far:"); - let keep_from = session.turns.len().saturating_sub(CHAT_SESSION_MAX_TURNS); - for turn in session.turns.iter().skip(keep_from) { - let label = match turn.role { - ChatSessionRole::User => "User", - ChatSessionRole::Assistant => "Assistant", - ChatSessionRole::Tool => "ROCm command result", - }; - let _ = writeln!(output, "{label}: {}", turn.content.trim()); - if let Some(detail) = turn.detail.as_deref() { - let _ = writeln!(output, "{label} details:"); - for line in detail.trim().lines() { - let _ = writeln!(output, " {line}"); - } - } - } - let _ = writeln!(output); - let _ = writeln!(output, "New message:"); - let _ = write!(output, "{}", prompt.trim()); - output - } - - fn push_chat_session_turn(&mut self, role: ChatSessionRole, content: impl Into) { - self.push_chat_session_turn_with_detail(role, content, None); - } - - fn push_chat_session_turn_with_detail( - &mut self, - role: ChatSessionRole, - content: impl Into, - detail: Option, - ) { - let should_follow = self.command_screen_chat_should_follow_latest(); - let Some(session) = self - .command_screen - .as_mut() - .and_then(|state| state.chat_session.as_mut()) - else { - return; - }; - let content = content.into(); - if content.trim().is_empty() { - return; - } - session.turns.push(ChatSessionTurn { - role, - content, - detail, - }); - let keep_from = session.turns.len().saturating_sub(CHAT_SESSION_MAX_TURNS); - if keep_from > 0 { - session.turns.drain(0..keep_from); - } - if should_follow && let Some(state) = self.command_screen.as_mut() { - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - self.sync_chat_session_actions(); - } - - fn sync_chat_session_actions(&mut self) { - let Some(state) = self.command_screen.as_mut() else { - return; - }; - let Some(session) = state.chat_session.as_ref() else { - return; - }; - let mut actions = Vec::new(); - actions.push(CommandScreenAction::SendChat); - if chat_session_connection_detail(session).is_some() { - actions.push(CommandScreenAction::ViewChatConnection); - } - if latest_chat_session_tool_detail(session).is_some() { - actions.push(CommandScreenAction::ViewChatResult); - } - actions.push(CommandScreenAction::OpenCommand("serve")); - actions.push(CommandScreenAction::OpenCommand("services")); - actions.push(CommandScreenAction::Back); - state.actions = actions; - state.selected = state.selected.min(state.actions.len().saturating_sub(1)); - } - - fn open_chat_connection_detail(&mut self) { - let detail = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .and_then(chat_session_connection_detail); - if let Some(detail) = detail { - self.open_command_screen_detail_modal("Connection Details", detail); - } else { - self.status = "No local server is connected yet.".to_owned(); - } - } - - fn open_chat_result_detail(&mut self) { - let detail = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .and_then(latest_chat_session_tool_detail); - if let Some(detail) = detail { - self.open_command_screen_detail_modal("ROCm Result", detail); - } else { - self.status = "No ROCm command result to show yet.".to_owned(); - } - } - - fn open_chat_default_detail(&mut self) { - let has_result = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .and_then(latest_chat_session_tool_detail) - .is_some(); - if has_result { - self.open_chat_result_detail(); - return; - } - self.open_chat_connection_detail(); - } - - fn set_chat_session_working_message(&mut self) { - if let Some(state) = self.command_screen.as_mut() - && state.chat_session.is_some() - { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - } - - fn run_selected_plan_action(&mut self) { - let action = self - .command_screen - .as_ref() - .and_then(|state| state.plan_action.clone()); - let Some(action) = action else { - if let Some(state) = self.command_screen.as_mut() { - state.message = Some( - "This request needs a clearer plan first.\n\nChoose Edit request and say what you want to run." - .to_owned(), - ); - } - self.status = "Plan needs more detail.".to_owned(); - return; - }; - if action.has_placeholders { - if let Some(state) = self.command_screen.as_mut() { - state.message = Some( - "This plan needs more detail before it can run.\n\nChoose Edit request and replace the missing values." - .to_owned(), - ); - } - self.status = "Plan needs more detail.".to_owned(); - return; - } - if plan_action_uses_cpu_device(&action) { - if let Some(state) = self.command_screen.as_mut() { - state.message = Some( - "CPU mode is not offered here.\n\nrocm-cli runs local models with ROCm GPU execution. Choose Edit request and ask for GPU." - .to_owned(), - ); - } - self.status = "CPU mode is not offered.".to_owned(); - return; - } - if action.approval_required { - self.request_screen_cli_approval(&action.title, &action.title, action.args, None); - } else { - self.start_cli_command_with_kind(&action.title, action.args, RunningJobKind::Cli, None); - } - } - - fn edit_selected_plan_request(&mut self) { - let request = self - .command_screen - .as_ref() - .and_then(|state| state.plan_request.clone()) - .unwrap_or_default(); - self.close_command_screen(); - self.set_input(if request.trim().is_empty() { - "/plan ".to_owned() - } else { - format!("/plan {request}") - }); - self.status = "Edit the request below, then press Enter.".to_owned(); - } - - fn render_logs_browser_view( - &self, - query: Option<&str>, - page: usize, - follow: bool, - show_file_locations: bool, - ) -> (String, usize, usize) { - let page_count = logs_browser_page_count(&self.paths, query, TUI_LOG_BROWSER_PAGE_SIZE); - let page = if follow { - page_count.saturating_sub(1) - } else { - page.min(page_count.saturating_sub(1)) - }; - let mut rendered = render_logs_browser_page_text_for_tui( - &self.paths, - query, - page, - TUI_LOG_BROWSER_PAGE_SIZE, - show_file_locations, - ); - let _ = writeln!(rendered, " Follow: {}", if follow { "on" } else { "off" }); - (rendered, page, page_count) - } - - fn open_logs_browser(&mut self, query: Option, page: usize) { - self.open_logs_browser_with_follow(query, page, false); - } - - fn open_logs_browser_with_follow(&mut self, query: Option, page: usize, follow: bool) { - let (rendered, page, page_count) = - self.render_logs_browser_view(query.as_deref(), page, follow, false); - self.remember_current_chat_session(); - self.examine_manager = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.config_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = Some(LogsViewState { - query, - service_id: None, - page, - follow, - show_file_locations: false, - selected: if follow { - logs_view_actions() - .iter() - .position(|action| *action == LogsViewAction::ToggleFollow) - .unwrap_or(0) - } else { - 0 - }, - detail_scroll: 0, - last_rendered: rendered, - last_refresh_at: Instant::now(), - editing_search: false, - detail_modal: None, - }); - self.status = if follow { - format!("Following logs page {} of {}.", page + 1, page_count) - } else { - format!("Logs page {} of {}.", page + 1, page_count) - }; - } - - #[cfg(test)] - fn open_service_logs_browser(&mut self, service_id: String) { - let rendered = render_service_logs_text_for_tui(&self.paths, &service_id, false) - .unwrap_or_else(|error| format!("Service log lookup failed.\n\n{error}")); - self.remember_current_chat_session(); - self.examine_manager = None; - self.runtime_manager = None; - self.install_manager = None; - self.engine_manager = None; - self.model_picker = None; - self.serve_wizard = None; - self.update_manager = None; - self.automations_manager = None; - self.provider_manager = None; - self.services_manager = None; - self.command_screen = None; - self.logs_view = Some(LogsViewState { - query: None, - service_id: Some(service_id), - page: 0, - follow: false, - show_file_locations: false, - selected: 2, - detail_scroll: 0, - last_rendered: rendered, - last_refresh_at: Instant::now(), - editing_search: false, - detail_modal: None, - }); - self.status = "Service logs opened. Use F5 to refresh or Back.".to_owned(); - } - - fn stop_following_logs(&mut self) { - let Some(mut state) = self.logs_view.take() else { - self.open_logs_browser(None, 0); - return; - }; - if state.follow { - state.follow = false; - state.show_file_locations = false; - state.detail_modal = None; - self.logs_view = Some(state); - self.status = "Log following stopped.".to_owned(); - } else { - self.logs_view = Some(state); - self.status = "Log following is already stopped.".to_owned(); - } - } - - fn refresh_following_logs(&mut self) { - let Some(current) = self.logs_view.clone() else { - return; - }; - if !current.follow || current.last_refresh_at.elapsed() < LOG_FOLLOW_REFRESH_INTERVAL { - return; - } - let (rendered, page, page_count) = - self.render_logs_browser_view(current.query.as_deref(), current.page, true, false); - let changed = rendered != current.last_rendered; - self.logs_view = Some(LogsViewState { - query: current.query, - service_id: current.service_id, - page, - follow: true, - show_file_locations: false, - selected: current.selected, - detail_scroll: current.detail_scroll, - last_rendered: rendered, - last_refresh_at: Instant::now(), - editing_search: false, - detail_modal: None, - }); - if changed { - self.status = format!( - "Following logs; refreshed page {} of {}.", - page + 1, - page_count - ); - } - } - - fn page_logs_browser(&mut self, direction: LogPageDirection) { - let Some(current) = self.logs_view.clone() else { - self.open_logs_browser(None, 0); - return; - }; - if let Some(service_id) = current.service_id.clone() { - if matches!(direction, LogPageDirection::Refresh) { - let rendered = render_service_logs_text_for_tui(&self.paths, &service_id, false) - .unwrap_or_else(|error| format!("Service log lookup failed.\n\n{error}")); - self.logs_view = Some(LogsViewState { - query: None, - service_id: Some(service_id), - page: 0, - follow: current.follow, - show_file_locations: false, - selected: current - .selected - .min(logs_view_actions().len().saturating_sub(1)), - detail_scroll: current.detail_scroll, - last_rendered: rendered, - last_refresh_at: Instant::now(), - editing_search: false, - detail_modal: None, - }); - if let Some(state) = self.logs_view.as_mut() { - state.detail_scroll = current.detail_scroll; - } - self.status = "Service logs refreshed.".to_owned(); - } else { - self.logs_view = Some(current); - self.status = "Service logs have one page. Use F5 to refresh or Back.".to_owned(); - } - return; - } - let page_count = logs_browser_page_count( - &self.paths, - current.query.as_deref(), - TUI_LOG_BROWSER_PAGE_SIZE, - ); - let page = match direction { - LogPageDirection::Next => current.page.saturating_add(1), - LogPageDirection::Previous => current.page.saturating_sub(1), - LogPageDirection::Refresh => current.page, - } - .min(page_count.saturating_sub(1)); - let follow = matches!(direction, LogPageDirection::Refresh) && current.follow; - let selected = current.selected; - let (rendered, page, page_count) = - self.render_logs_browser_view(current.query.as_deref(), page, follow, false); - self.logs_view = Some(LogsViewState { - query: current.query, - service_id: None, - page, - follow, - show_file_locations: false, - selected: selected.min(logs_view_actions().len().saturating_sub(1)), - detail_scroll: 0, - last_rendered: rendered, - last_refresh_at: Instant::now(), - editing_search: false, - detail_modal: None, - }); - if let Some(state) = self.logs_view.as_mut() { - state.selected = selected.min(logs_view_actions().len().saturating_sub(1)); - } - self.status = format!("Logs page {} of {}.", page + 1, page_count); - } - - fn close_logs_browser(&mut self) { - if self.running_job_blocks_screen_close() { - return; - } - self.logs_view = None; - self.status = "Logs closed.".to_owned(); - } - - fn move_logs_view_selection(&mut self, direction: CompletionDirection) { - let Some(state) = self.logs_view.as_mut() else { - return; - }; - let actions = logs_view_actions(); - state.selected = match direction { - CompletionDirection::Next => (state.selected + 1) % actions.len(), - CompletionDirection::Previous => { - if state.selected == 0 { - actions.len().saturating_sub(1) - } else { - state.selected - 1 - } - } - }; - self.status = "Log action selected. Press Enter to continue.".to_owned(); - } - - const fn scroll_logs_view_detail(&mut self, lines: i16) { - let Some(state) = self.logs_view.as_mut() else { - return; - }; - if lines < 0 { - state.detail_scroll = state.detail_scroll.saturating_sub(lines.unsigned_abs()); - } else { - state.detail_scroll = state.detail_scroll.saturating_add(lines as u16); - } - } - - fn selected_logs_view_action(&self) -> LogsViewAction { - self.logs_view - .as_ref() - .and_then(|state| logs_view_actions().get(state.selected).copied()) - .unwrap_or(LogsViewAction::Back) - } - - fn logs_view_editing_text(&self) -> bool { - self.logs_view - .as_ref() - .is_some_and(|state| state.editing_search) - } - - fn start_logs_search_edit(&mut self) { - let query = self - .logs_view - .as_ref() - .and_then(|state| state.query.clone()) - .unwrap_or_default(); - if let Some(state) = self.logs_view.as_mut() { - if state.service_id.is_some() { - state.editing_search = false; - self.status = "Search is for the main log list.".to_owned(); - return; - } - state.editing_search = true; - } - self.set_input(query); - self.status = "Type a search term, then press Enter.".to_owned(); - } - - fn commit_logs_search_edit(&mut self) { - let query = self.input.trim().to_owned(); - self.clear_input(); - if query.is_empty() { - self.clear_logs_search(); - return; - } - self.open_logs_browser(Some(query), 0); - if let Some(state) = self.logs_view.as_mut() { - state.selected = logs_view_actions() - .iter() - .position(|action| *action == LogsViewAction::Search) - .unwrap_or(state.selected); - } - self.status = "Logs filtered.".to_owned(); - } - - fn cancel_logs_search_edit(&mut self) { - if let Some(state) = self.logs_view.as_mut() { - state.editing_search = false; - } - self.clear_input(); - self.status = "Log search unchanged.".to_owned(); - } - - fn clear_logs_search(&mut self) { - let selected = logs_view_actions() - .iter() - .position(|action| *action == LogsViewAction::ClearSearch) - .unwrap_or(0); - self.open_logs_browser(None, 0); - if let Some(state) = self.logs_view.as_mut() { - state.selected = selected; - } - self.status = "Log search cleared.".to_owned(); - } - - fn perform_logs_view_selected_action(&mut self) { - match self.selected_logs_view_action() { - LogsViewAction::PreviousPage => self.page_logs_browser(LogPageDirection::Previous), - LogsViewAction::NextPage => self.page_logs_browser(LogPageDirection::Next), - LogsViewAction::Search => self.start_logs_search_edit(), - LogsViewAction::ClearSearch => self.clear_logs_search(), - LogsViewAction::ToggleFollow => { - let Some(current) = self.logs_view.clone() else { - self.open_logs_browser(None, 0); - return; - }; - if current.service_id.is_some() { - self.logs_view = Some(current); - self.status = - "Following is for the main log list. Service logs can be refreshed." - .to_owned(); - return; - } - let follow = !current.follow; - let (rendered, page, _page_count) = self.render_logs_browser_view( - current.query.as_deref(), - current.page, - follow, - false, - ); - self.logs_view = Some(LogsViewState { - query: current.query, - service_id: None, - page, - follow, - show_file_locations: false, - selected: logs_view_actions() - .iter() - .position(|action| *action == LogsViewAction::ToggleFollow) - .unwrap_or(0), - detail_scroll: current.detail_scroll, - last_rendered: rendered, - last_refresh_at: Instant::now(), - editing_search: false, - detail_modal: None, - }); - self.status = if follow { - "Following logs. Choose Stop following to pause updates.".to_owned() - } else { - "Log following stopped.".to_owned() - }; - } - LogsViewAction::ToggleFileLocations => self.toggle_logs_view_file_locations(), - LogsViewAction::Back => self.close_logs_browser(), - } - } - - fn toggle_logs_view_file_locations(&mut self) { - let Some(current) = self.logs_view.clone() else { - self.open_logs_browser(None, 0); - return; - }; - let selected = logs_view_actions() - .iter() - .position(|action| *action == LogsViewAction::ToggleFileLocations) - .unwrap_or(current.selected); - if current.detail_modal.is_some() || current.show_file_locations { - let mut next = current; - next.show_file_locations = false; - next.selected = selected; - next.detail_modal = None; - self.logs_view = Some(next); - self.status = "File locations hidden.".to_owned(); - return; - } - let detail = self.logs_view_file_locations_detail(¤t); - let mut next = current; - next.show_file_locations = true; - next.selected = selected; - next.detail_modal = Some(CommandScreenDetailModal { - title: "File Locations".to_owned(), - detail, - scroll: 0, - }); - self.logs_view = Some(next); - self.status = "File locations shown. Press Esc to close the card.".to_owned(); - } - - fn logs_view_file_locations_detail(&self, state: &LogsViewState) -> String { - if let Some(service_id) = state.service_id.as_ref() { - return render_service_logs_text_for_tui(&self.paths, service_id, true) - .unwrap_or_else(|error| format!("Service log lookup failed.\n\n{error}")); - } - let (detail, _page, _page_count) = - self.render_logs_browser_view(state.query.as_deref(), state.page, state.follow, true); - detail - } - - fn close_logs_view_detail_modal(&mut self) -> bool { - let Some(state) = self.logs_view.as_mut() else { - return false; - }; - if state.detail_modal.take().is_some() { - state.show_file_locations = false; - self.status = "File locations hidden.".to_owned(); - return true; - } - false - } - - fn scroll_logs_view_detail_modal(&mut self, lines: i16) { - let Some(modal) = self - .logs_view - .as_mut() - .and_then(|state| state.detail_modal.as_mut()) - else { - return; - }; - if lines < 0 { - modal.scroll = modal.scroll.saturating_sub(lines.unsigned_abs()); - } else { - modal.scroll = modal.scroll.saturating_add(lines as u16); - } - } - - const fn scroll_up(&mut self, lines: u16) { - self.transcript_scroll = self.transcript_scroll.saturating_sub(lines); - } - - fn scroll_down(&mut self, lines: u16) { - self.transcript_scroll = self - .transcript_scroll - .saturating_add(lines) - .min(self.max_scroll()); - } - - const fn reset_completion_selection(&mut self) { - self.completion_selection = 0; - } - - fn set_input(&mut self, value: String) { - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - self.input = value; - self.input_cursor = self.input_len_chars(); - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - self.reset_completion_selection(); - self.history_index = None; - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - fn clear_input(&mut self) { - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - self.input.clear(); - self.input_cursor = 0; - self.chat_input_scroll = 0; - self.reset_completion_selection(); - self.history_index = None; - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - fn input_len_chars(&self) -> usize { - self.input.chars().count() - } - - fn input_cursor_byte_index(&self) -> usize { - if self.input_cursor >= self.input_len_chars() { - return self.input.len(); - } - self.input - .char_indices() - .nth(self.input_cursor) - .map_or(self.input.len(), |(index, _)| index) - } - - fn input_byte_index_for_char(&self, char_index: usize) -> usize { - if char_index >= self.input_len_chars() { - return self.input.len(); - } - self.input - .char_indices() - .nth(char_index) - .map_or(self.input.len(), |(index, _)| index) - } - - fn insert_input_char(&mut self, ch: char) { - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - let index = self.input_cursor_byte_index(); - self.input.insert(index, ch); - self.input_cursor = self.input_cursor.saturating_add(1); - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - self.reset_completion_selection(); - self.history_index = None; - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - fn backspace_input(&mut self) { - if self.input_cursor == 0 { - return; - } - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - let end = self.input_cursor_byte_index(); - let start = self.input_byte_index_for_char(self.input_cursor - 1); - self.input.replace_range(start..end, ""); - self.input_cursor -= 1; - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - self.reset_completion_selection(); - self.history_index = None; - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - fn delete_input_char(&mut self) { - if self.input_cursor >= self.input_len_chars() { - return; - } - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - let start = self.input_cursor_byte_index(); - let end = self.input_byte_index_for_char(self.input_cursor + 1); - self.input.replace_range(start..end, ""); - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - self.reset_completion_selection(); - self.history_index = None; - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - const fn move_input_left(&mut self) { - self.input_cursor = self.input_cursor.saturating_sub(1); - } - - fn move_input_right(&mut self) { - self.input_cursor = self - .input_cursor - .saturating_add(1) - .min(self.input_len_chars()); - } - - const fn move_input_start(&mut self) { - self.input_cursor = 0; - self.chat_input_scroll = 0; - } - - fn move_input_end(&mut self) { - self.input_cursor = self.input_len_chars(); - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - - fn delete_input_word_before_cursor(&mut self) { - if self.input_cursor == 0 { - return; - } - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - let chars = self.input.chars().collect::>(); - let mut start = self.input_cursor; - while start > 0 && chars[start - 1].is_whitespace() { - start -= 1; - } - while start > 0 && !chars[start - 1].is_whitespace() { - start -= 1; - } - let byte_start = self.input_byte_index_for_char(start); - let byte_end = self.input_cursor_byte_index(); - self.input.replace_range(byte_start..byte_end, ""); - self.input_cursor = start; - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - self.reset_completion_selection(); - self.history_index = None; - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - fn previous_history(&mut self) { - if self.history.is_empty() { - return; - } - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - let next_index = match self.history_index { - Some(index) if index > 0 => index - 1, - Some(index) => index, - None => self.history.len() - 1, - }; - self.history_index = Some(next_index); - self.input = self.history[next_index].clone(); - self.move_input_end(); - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - self.reset_completion_selection(); - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - fn next_history(&mut self) { - let Some(index) = self.history_index else { - return; - }; - let should_follow_chat = self.command_screen_chat_should_follow_latest(); - if index + 1 >= self.history.len() { - self.history_index = None; - self.clear_input(); - return; - } - let next_index = index + 1; - self.history_index = Some(next_index); - self.input = self.history[next_index].clone(); - self.move_input_end(); - self.chat_input_scroll = CHAT_SESSION_FOLLOW_SCROLL; - self.reset_completion_selection(); - self.set_chat_session_follow_if_needed(should_follow_chat); - } - - fn cycle_completion(&mut self, direction: CompletionDirection) -> bool { - let Some(menu) = self.slash_completion_menu() else { - return false; - }; - let selectable = menu.selectable_indices(); - if selectable.is_empty() { - return false; - } - let position = selectable - .iter() - .position(|index| *index == menu.selected) - .unwrap_or(0); - let next_position = match direction { - CompletionDirection::Next => (position + 1) % selectable.len(), - CompletionDirection::Previous => { - if position == 0 { - selectable.len() - 1 - } else { - position - 1 - } - } - }; - self.completion_selection = selectable[next_position]; - self.status = format!( - "Completion {}/{} selected.", - next_position + 1, - selectable.len() - ); - true - } - - fn accept_completion(&mut self) -> bool { - let Some(menu) = self.slash_completion_menu() else { - return false; - }; - let Some(replacement) = menu.selected_replacement().map(str::to_owned) else { - return false; - }; - self.set_input(replacement); - self.status = "Accepted completion.".to_owned(); - true - } - - fn slash_completion_menu(&self) -> Option { - let prefix = self.input.strip_prefix('/')?; - if prefix.contains(char::is_whitespace) { - return slash_argument_completion_menu( - &self.input, - &self.paths, - self.completion_selection, - ); - } - let matches = matching_slash_commands(prefix); - if matches.is_empty() { - return None; - } - if !prefix.is_empty() && matches.len() == 1 && matches[0].name.eq_ignore_ascii_case(prefix) - { - return None; - } - let items = matches - .iter() - .map(|command| slash_command_completion_label(command)) - .collect::>(); - let replacements = matches - .iter() - .map(|command| Some(format!("/{} ", command.name))) - .collect::>(); - let selected = selected_completion_index(self.completion_selection, &replacements); - Some(CompletionMenu { - title: "Completions".to_owned(), - items, - replacements, - selected, - }) - } - - fn submit(&mut self) { - let input = self.input.trim().to_owned(); - self.clear_input(); - self.history_index = None; - if input.is_empty() { - if self.pending_approval.is_some() { - self.status = - "Approval ready. Press Enter or Y to approve; Esc or N cancels.".to_owned(); - } else if let Some(job) = self.running_job.as_ref() { - self.status = format!("{} is still running. Wait for it to finish.", job.title); - } - return; - } - - if self - .running_job - .as_ref() - .is_some_and(|job| matches!(job.kind, RunningJobKind::ExamineRefresh)) - { - self.discard_background_examine_refresh(); - } - - if self - .running_job - .as_ref() - .is_some_and(|job| !matches!(job.kind, RunningJobKind::ExamineRefresh)) - && input - .strip_prefix('/') - .is_some_and(|command| is_examine_command_input(command.trim_start())) - { - self.examine_blocked_by_current_workflow(); - return; - } - - if let Some(job) = self.running_job.as_ref() { - self.status = format!( - "{} is still running. Wait for it to finish before opening another screen.", - job.title - ); - return; - } - - if self.history.last() != Some(&input) { - self.history.push(input.clone()); - } - - if self.command_screen_is_chat_session() && !input.starts_with('/') { - self.submit_chat_session_prompt(input); - return; - } - - if let Some(command) = input.strip_prefix('/') { - if !self.handle_command(command.trim()) { - self.open_unknown_command_overlay(command); - } - return; - } - - if let Some(approval) = crate::install_sdk_without_prefix_chat_approval(&input) { - if self.open_install_folder_choice_for_chat_approval(&approval) { - self.status = - "Choose where ROCm should be installed, then review the install.".to_owned(); - } - return; - } - - if let Some(approval) = crate::install_sdk_chat_approval_for_prompt(&input) { - if self.open_install_folder_choice_for_chat_approval(&approval) { - self.status = - "Choose the ROCm install folder, then press Enter on Install ROCm.".to_owned(); - } else { - self.request_screen_cli_approval_with_explanation( - &approval.pending_title, - &approval.command_title, - approval.args, - approval.display_command, - approval.explanation, - ); - self.status = "Review the ROCm install before anything downloads.".to_owned(); - } - return; - } - - if is_plain_local_assistant_support_request(&input) { - if let Some(record) = self.first_ready_builtin_assistant_service() { - self.open_local_rocm_tools_chat_session(Some(record)); - self.submit_chat_session_prompt(input); - } else { - self.open_serve_wizard(); - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(local_chat_needs_server_message()); - } - self.status = "Start the local assistant first, then ask that question.".to_owned(); - } - return; - } - - if is_plain_casual_input(&input) { - self.open_casual_input_screen(&input); - return; - } - - if should_parse_plain_input_as_command(&input) && self.handle_command(&input) { - return; - } - - if should_render_plain_input_as_plan(&input) { - self.push_freeform_plan("Plan", &input); - return; - } - - if self.open_plain_input_as_local_assistant(&input) { - return; - } - - if self.handle_command(&input) { - return; - } - - self.open_unclear_plain_input_screen(&input); - } - - fn open_plain_input_as_local_assistant(&mut self, input: &str) -> bool { - if input.trim().is_empty() { - return false; - } - if !plain_input_should_use_local_assistant(input) { - return false; - } - if let Some(record) = self.first_ready_builtin_assistant_service() { - self.open_local_rocm_tools_chat_session(Some(record)); - self.submit_chat_session_prompt(input.to_owned()); - return true; - } - self.open_serve_wizard(); - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(local_chat_needs_server_message()); - } - self.status = "Start the local assistant first, then send that message.".to_owned(); - true - } - - fn open_casual_input_screen(&mut self, input: &str) { - self.mode = TuiMode::Ask; - self.open_command_screen( - "Hello", - render_casual_input_screen_text(input), - None, - vec![ - CommandScreenAction::OpenCommand("setup"), - CommandScreenAction::OpenCommand("examine"), - CommandScreenAction::OpenCommand("serve"), - CommandScreenAction::OpenCommand("help"), - CommandScreenAction::Back, - ], - ); - self.status = "Hi. Choose an option with the arrows or mouse.".to_owned(); - } - - fn open_unclear_plain_input_screen(&mut self, input: &str) { - self.mode = TuiMode::Ask; - self.open_command_screen( - "Ask", - render_unclear_plain_input_text(input), - None, - vec![ - CommandScreenAction::OpenCommand("setup"), - CommandScreenAction::OpenCommand("examine"), - CommandScreenAction::OpenCommand("serve"), - CommandScreenAction::OpenCommand("help"), - CommandScreenAction::Back, - ], - ); - self.status = - "I need a clearer ROCm request. Choose an option or edit your request.".to_owned(); - } - - fn push_freeform_plan(&mut self, title: &str, input: &str) { - self.refresh_config(); - if freeform_plan_uses_provider(input, &self.config) { - self.open_command_screen( - title, - format!("Planning request...\n\n{input}"), - None, - vec![CommandScreenAction::Back], - ); - self.start_planner_job(title, input); - return; - } - self.push_freeform_plan_inline(title, input); - } - - fn push_freeform_plan_inline(&mut self, title: &str, input: &str) { - self.open_plan_review_screen(title, input); - } - - fn handle_command(&mut self, command: &str) -> bool { - let parts = match split_command_line(command) { - Ok(parts) => parts, - Err(error) => { - self.open_command_parse_error_overlay(error); - return true; - } - }; - let head = parts.first().map(String::as_str).unwrap_or_default(); - let args = parts.iter().skip(1).map(String::as_str).collect::>(); - if !head.is_empty() { - self.mode = tui_mode_for_command(head, !args.is_empty()); - } - if !matches!(head, "" | "examine") { - self.discard_background_examine_refresh(); - } - - match head { - "" => true, - "home" => { - self.open_home_dashboard(); - true - } - "help" | "?" => { - self.open_help_overlay(); - true - } - "examine" => { - if !self.examine_blocked_by_current_workflow() { - self.open_examine_manager(); - } - true - } - "setup" => { - self.handle_setup_command(&args); - true - } - "permissions" => { - self.handle_permissions_command(&args); - true - } - "runtimes" => { - if args.is_empty() || args == ["list"] { - self.open_runtime_manager(); - } else if args.first() == Some(&"activate") { - self.open_runtime_manager(); - if args.len() != 2 { - self.set_active_screen_message( - "Choose a ROCm install from this list, then press Enter to make it the default.", - ); - self.status = "Choose a ROCm install from the list.".to_owned(); - return true; - } - if self.select_runtime_manager_item(args[1]) { - self.request_runtime_manager_activation(); - } - } else if args == ["rollback"] { - self.open_runtime_manager(); - self.set_active_screen_message( - "Choose an install, then press Enter.\nNo rollback step is needed.", - ); - self.status = "Choose a ROCm install from the list.".to_owned(); - } else if args.first() == Some(&"uninstall") || args.first() == Some(&"remove") { - self.open_runtime_manager(); - if args.len() != 2 { - self.set_active_screen_message( - "Choose a ROCm install from this list, then press U to remove it.", - ); - self.status = "Choose a ROCm install to remove.".to_owned(); - return true; - } - if self.select_runtime_manager_item(args[1]) { - self.request_runtime_manager_uninstall(); - } - } else if args.first() == Some(&"import") { - self.open_runtime_manager(); - let (manifest, replace) = match parse_runtime_import_args(&args) { - Ok(parts) => parts, - Err(error) => { - self.open_runtime_import_form_with(false, Some(error.to_string())); - return true; - } - }; - let Some(manifest) = manifest else { - self.open_runtime_import_form_with(replace, None); - return true; - }; - if !manifest.is_file() { - self.open_runtime_import_form_with( - replace, - Some( - "I could not find that saved ROCm install file.\n\nChoose the file below." - .to_owned(), - ), - ); - return true; - } - let mut cli_args = vec!["runtimes".to_owned()]; - cli_args.push("import".to_owned()); - cli_args.push(manifest.display().to_string()); - if replace { - cli_args.push("--replace".to_owned()); - } - self.request_screen_cli_approval( - "Import ROCm runtime", - "Runtimes", - cli_args, - None, - ); - } else if args.first() == Some(&"adopt") { - self.open_runtime_manager(); - if let Err(error) = validate_runtime_adopt_args(&args) { - self.open_runtime_adopt_form(); - if let Some(state) = self.runtime_manager.as_mut() { - state.message = Some(plain_runtime_adopt_error(&error.to_string())); - } - return true; - } - let mut cli_args = vec!["runtimes".to_owned()]; - cli_args.extend(args.iter().map(|value| (*value).to_owned())); - self.request_screen_cli_approval( - "Adopt ROCm runtime", - "Runtimes", - cli_args, - Some(format_slash_command_for_display("runtimes", &args)), - ); - } else { - self.open_runtime_manager(); - self.set_active_screen_message( - "I do not recognize that runtime action.\n\nUse the arrow keys to choose a ROCm install or an action.", - ); - self.status = "Runtime action not recognized.".to_owned(); - } - true - } - "engine" => { - if args.is_empty() || args == ["list"] { - self.open_engine_manager(); - } else if args.first() == Some(&"install") { - self.open_engine_manager(); - if args.len() < 2 { - self.set_active_screen_message( - "Choose an engine from this list, then press Enter to install or update it.", - ); - self.status = "Choose an engine from the list.".to_owned(); - return true; - } - if self.select_engine_manager_item(args[1]) { - let reinstall = args.iter().skip(2).any(|arg| *arg == "--reinstall"); - if args.len() > if reinstall { 3 } else { 2 } { - self.set_active_screen_message( - "I do not recognize that engine install option.\n\nUse the arrow keys to choose an engine, then press Enter.", - ); - self.status = "Engine install option was not recognized.".to_owned(); - } else { - let engine = self - .selected_engine_manager_item() - .map_or_else(|| args[1].to_owned(), |item| item.name); - self.request_engine_install(&engine, reinstall); - } - } - } else { - self.open_engine_manager(); - self.set_active_screen_message( - "Choose an engine from this list, then press Enter to install, update, or use it.", - ); - self.status = "Engine option was not recognized.".to_owned(); - } - true - } - "model" => { - if args.is_empty() { - self.open_model_picker(); - } else if args - .iter() - .any(|arg| *arg == "with" || arg.starts_with("--")) - { - match parse_serve_command_args(&args) { - Ok(plan) => self.open_serve_wizard_from_plan(plan), - Err(error) => { - self.open_command_screen( - "Model", - plain_serve_parse_error(&error.to_string()), - None, - vec![CommandScreenAction::EditPlan, CommandScreenAction::Back], - ); - if let Some(state) = self.command_screen.as_mut() { - state.plan_request = Some(format!("model {}", args.join(" "))); - } - self.status = "Model request needs a fix.".to_owned(); - } - } - } else { - self.open_model_picker(); - self.focus_model_picker_recipe(&args.join(" ")); - } - true - } - "plan" => { - if args.is_empty() { - self.open_command_screen( - "Plan", - "Describe what you want ROCm CLI to do.\n\nExample: serve Qwen with llama.cpp", - None, - vec![CommandScreenAction::EditPlan, CommandScreenAction::Back], - ); - if let Some(state) = self.command_screen.as_mut() { - state.plan_request = Some(String::new()); - } - self.status = "Choose Edit request to start a plan.".to_owned(); - } else { - let request = args.join(" "); - self.push_freeform_plan("Plan", &request); - } - true - } - "config" => { - self.handle_config_command_args(&args); - true - } - "reviews" | "proposals" => { - self.open_automations_manager(); - if let Some(proposal_id) = args.first() { - match self.select_proposal_review_by_id(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review request failed.\n\n{error}")); - } - self.status = "Review request failed.".to_owned(); - } - } - } else { - self.status = "Review requests opened. Use arrow keys, then Enter.".to_owned(); - } - true - } - "approve" => { - if args.is_empty() && self.pending_approval.is_some() { - self.select_pending_approval_row(true); - return true; - } - if self.automations_manager.is_none() { - self.open_automations_manager(); - } - if let Some(proposal_id) = args.first() { - match self.focus_proposal_by_id(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review approval failed.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review approval failed.".to_owned(); - } - } - } else { - self.focus_selected_automation_proposal(); - } - true - } - "reject" => { - if args.is_empty() && self.pending_approval.is_some() { - self.select_pending_approval_row(false); - return true; - } - if self.automations_manager.is_none() { - self.open_automations_manager(); - } - if let Some(proposal_id) = args.first() { - match self.focus_proposal_by_id(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review reject failed.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review reject failed.".to_owned(); - } - } - } else if let Some(proposal) = self.selected_automation_proposal() { - if proposal.status == "pending" { - self.focus_selected_automation_proposal(); - } else if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!( - "{} is already {}.", - proposal.proposal_id, - proposal_status_for_user(&proposal.status) - )); - self.status = "Review request is not pending.".to_owned(); - } - } else { - self.show_no_automation_review_selected_message(); - } - true - } - "edit" => { - self.open_automations_manager(); - if args.is_empty() { - if self.selected_automation_proposal().is_some() { - self.focus_selected_automation_proposal(); - } else if let Some(state) = self.automations_manager.as_mut() { - state.message = Some( - "Choose a review request, then press Enter to review or edit it." - .to_owned(), - ); - self.status = "Choose a review request.".to_owned(); - } - } else if args.len() == 1 { - match self.select_proposal_review_by_id(args[0]) { - Ok(()) => { - self.status = - "Review request opened. Use arrow keys, then Enter.".to_owned(); - } - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review request failed.\n\n{error}")); - } - self.status = "Review request failed.".to_owned(); - } - } - } else if let Err(error) = self.open_proposal_edit_guidance(&args) { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review edit failed.\n\n{error}")); - } - self.status = "Review edit failed.".to_owned(); - } - true - } - "automations" => { - if args.is_empty() || args == ["list"] { - self.open_automations_manager(); - } else if matches!(args.first(), Some(&"reviews" | &"review" | &"proposals")) { - self.open_automations_manager(); - if let Some(proposal_id) = args.get(1) { - match self.select_proposal_review_by_id(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = - Some(format!("Review request failed.\n\n{error}")); - } - self.status = "Review request failed.".to_owned(); - } - } - } else { - self.status = - "Review requests opened. Use arrow keys, then Enter.".to_owned(); - } - } else if args.first() == Some(&"approve") { - if args.len() == 1 && self.pending_approval.is_some() { - self.select_pending_approval_row(true); - return true; - } - if self.automations_manager.is_none() { - self.open_automations_manager(); - } - if let Some(proposal_id) = args.get(1) { - match self.focus_proposal_by_id(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = - Some(format!("Review approval failed.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review approval failed.".to_owned(); - } - } - } else { - self.focus_selected_automation_proposal(); - } - } else if args.first() == Some(&"reject") { - if args.len() == 1 && self.pending_approval.is_some() { - self.select_pending_approval_row(false); - return true; - } - if self.automations_manager.is_none() { - self.open_automations_manager(); - } - if let Some(proposal_id) = args.get(1) { - match self.focus_proposal_by_id(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = - Some(format!("Review reject failed.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review reject failed.".to_owned(); - } - } - } else if let Some(proposal) = self.selected_automation_proposal() { - if proposal.status == "pending" { - self.focus_selected_automation_proposal(); - } else if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!( - "{} is already {}.", - proposal.proposal_id, - proposal_status_for_user(&proposal.status) - )); - self.status = "Review request is not pending.".to_owned(); - } - } else { - self.show_no_automation_review_selected_message(); - } - } else if args.first() == Some(&"edit") { - self.open_automations_manager(); - if args.len() == 1 { - if self.selected_automation_proposal().is_some() { - self.focus_selected_automation_proposal(); - } else if let Some(state) = self.automations_manager.as_mut() { - state.message = Some( - "Choose a review request, then press Enter to review or edit it." - .to_owned(), - ); - self.status = "Choose a review request.".to_owned(); - } - } else if args.len() == 2 { - match self.select_proposal_review_by_id(args[1]) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = - Some(format!("Review request failed.\n\n{error}")); - } - self.status = "Review request failed.".to_owned(); - } - } - } else if let Err(error) = self.open_proposal_edit_guidance(&args[1..]) { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review edit failed.\n\n{error}")); - } - self.status = "Review edit failed.".to_owned(); - } - } else if matches!(args.first(), Some(&"enable" | &"disable")) { - self.open_automations_manager(); - let action = args[0]; - let Some(watcher_id) = args.get(1) else { - self.set_active_screen_message( - "Choose an automation check with the arrow keys, then press Enter.", - ); - self.status = "Automation check selection needed.".to_owned(); - return true; - }; - let mode = if action == "enable" && args.len() > 2 { - if let ["enable", _, "--mode", mode] = args.as_slice() { - if let Some(mode) = parse_watcher_mode(mode) { - Some(mode) - } else { - self.set_active_screen_message( - "Choose one of these settings: observe, propose, contained.", - ); - self.status = "Automation setting was not recognized.".to_owned(); - return true; - } - } else { - self.set_active_screen_message( - "Choose an automation check with the arrow keys, then press Enter.", - ); - self.status = "Automation option was not recognized.".to_owned(); - return true; - } - } else if action == "disable" && args.len() > 2 { - self.set_active_screen_message( - "Choose an automation check with the arrow keys, then press Enter.", - ); - self.status = "Automation option was not recognized.".to_owned(); - return true; - } else { - None - }; - if builtin_watcher(watcher_id).is_none() - || !self.select_automation_watcher_by_id(watcher_id) - { - self.set_active_screen_message( - "I could not find that automation check.\n\nChoose one from this list.", - ); - self.status = "Automation check was not found.".to_owned(); - return true; - } - let Some(mut watcher) = self.selected_automation_watcher() else { - self.status = "Automation check was not found.".to_owned(); - return true; - }; - if let Some(mode) = mode { - watcher.mode = mode; - } - self.request_automation_watcher_enabled(&watcher, action == "enable"); - } else { - self.open_automations_manager(); - self.set_active_screen_message( - format!( - "I do not recognize automation option `{}`.\n\nUse the arrow keys to choose an automation check or review request.", - args[0] - ), - ); - self.status = "Automation option was not recognized.".to_owned(); - } - true - } - "install" => { - if args.is_empty() { - self.open_install_manager(); - return true; - } - self.open_install_manager(); - if args.first() == Some(&"sdk") { - self.open_install_sdk_form_from_args(&args); - return true; - } - let needs_approval = match install_args_require_approval(&args) { - Ok(value) => value, - Err(error) => { - self.set_active_screen_message(error.to_string()); - self.status = "Install option was not recognized.".to_owned(); - return true; - } - }; - let display_command = format_slash_command_for_display("install", &args); - if needs_approval { - let mut cli_args = vec!["install".to_owned()]; - cli_args.extend(approved_install_args(&args)); - self.request_screen_cli_approval( - "Install", - "Install", - cli_args, - Some(display_command), - ); - } else { - let mut cli_args = vec!["install".to_owned()]; - cli_args.extend(normalize_install_args_for_cli(&args)); - self.start_cli_command_with_display("Install", cli_args, display_command); - } - true - } - "comfyui" | "comfy" => { - let showing_logs = matches!(args.as_slice(), ["logs" | "log"]); - let showing_log_files = - matches!(args.as_slice(), ["log-files"] | ["logs" | "log", "files"]); - if showing_logs || showing_log_files { - let (title, detail, status) = if showing_logs { - ( - "ComfyUI Logs", - crate::comfyui::render_tui_logs(&self.paths, 160, false) - .unwrap_or_else(|error| { - format!("ComfyUI logs could not be opened.\n\n{error}") - }), - "ComfyUI logs shown.", - ) - } else { - ( - "ComfyUI Log Files", - crate::comfyui::render_tui_logs(&self.paths, 80, true).unwrap_or_else( - |error| { - format!( - "ComfyUI log file locations could not be opened.\n\n{error}" - ) - }, - ), - "ComfyUI log file locations shown.", - ) - }; - self.open_log_output_card(title, detail); - self.status = status.to_owned(); - return true; - } - let showing_models_path = matches!(args.as_slice(), ["models-path" | "models"]); - let mut detail = if showing_models_path { - crate::comfyui::render_models_path(&self.paths) - .map(|path| format!("Models path\n\n{}", path.trim())) - } else { - crate::comfyui::render_tui_status(&self.paths, &self.config) - } - .unwrap_or_else(|error| { - if showing_models_path { - format!("ComfyUI models path could not be opened.\n\n{error}") - } else { - format!("ComfyUI status failed.\n\n{error}") - } - }); - if !showing_logs && !showing_log_files && !showing_models_path { - append_comfyui_tui_paths(&mut detail, &self.paths, None); - } - let installed = crate::comfyui::is_installed(&self.paths).unwrap_or(false); - let running = crate::comfyui::running_url(&self.paths) - .ok() - .flatten() - .is_some(); - let actions = if showing_models_path { - vec![ - CommandScreenAction::OpenCommand("comfyui models-path"), - CommandScreenAction::Back, - ] - } else { - comfyui_command_screen_actions(installed, running) - }; - let selected_command = match args.as_slice() { - ["install"] => "comfyui install", - ["start"] => "comfyui start", - ["models-path" | "models"] => "comfyui models-path", - ["logs" | "log"] => "comfyui logs", - ["log-files"] | ["logs" | "log", "files"] => "comfyui log-files", - _ => "comfyui start", - }; - self.open_command_screen("ComfyUI", detail, None, actions); - if let Some(state) = self.command_screen.as_mut() { - state.selected = state - .actions - .iter() - .position(|action| { - matches!( - action, - CommandScreenAction::OpenCommand(command) - if *command == selected_command - ) - }) - .unwrap_or(0) - .min(state.actions.len().saturating_sub(1)); - } - match args.as_slice() { - [] | ["status"] => { - self.status = "ComfyUI status shown.".to_owned(); - } - ["logs" | "log"] => { - self.status = "ComfyUI logs shown.".to_owned(); - } - ["log-files"] | ["logs" | "log", "files"] => { - self.status = "ComfyUI log file locations shown.".to_owned(); - } - ["models-path" | "models"] => { - self.status = "ComfyUI models path shown.".to_owned(); - } - ["install"] => { - self.request_screen_cli_approval( - "Install ComfyUI", - "ComfyUI", - vec!["comfyui".to_owned(), "install".to_owned()], - Some("/comfyui install".to_owned()), - ); - } - ["start"] => { - self.request_screen_cli_approval( - "Start ComfyUI", - "ComfyUI", - vec!["comfyui".to_owned(), "start".to_owned()], - Some("/comfyui start".to_owned()), - ); - } - _ => { - self.set_active_screen_message( - "Choose Install ComfyUI, Start ComfyUI, or Open ComfyUI logs from this screen.", - ); - self.status = "ComfyUI option was not recognized.".to_owned(); - } - } - true - } - "services" => { - self.open_services_manager(); - match args.as_slice() { - [] | ["list"] => {} - ["logs" | "log"] => { - self.set_active_screen_message( - "Choose a service from this list, then press Enter to open its logs.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - ["logs" | "log", service_id] => { - if self.select_service_by_id(service_id) { - self.open_selected_service_logs(); - } else { - self.set_active_screen_message( - "I could not find that service.\n\nChoose a service from this list, then press Enter to open its logs.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - } - ["stop"] => { - self.set_active_screen_message( - "Choose a service from this list, then choose Stop selected server.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - ["stop", service_id] => { - if self.select_service_by_id(service_id) { - self.request_service_lifecycle_action( - ServiceLifecycleAction::Stop, - (*service_id).to_owned(), - ); - } else { - self.set_active_screen_message( - "I could not find that service.\n\nChoose a service from this list, then choose Stop selected server.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - } - ["restart"] => { - self.set_active_screen_message( - "Choose a service from this list, then choose Restart selected server.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - ["restart", service_id] => { - if self.select_service_by_id(service_id) { - self.request_service_lifecycle_action( - ServiceLifecycleAction::Restart, - (*service_id).to_owned(), - ); - } else { - self.set_active_screen_message( - "I could not find that service.\n\nChoose a service from this list, then choose Restart selected server.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - } - [service_id] => { - if !self.select_service_by_id(service_id) { - self.set_active_screen_message( - "I could not find that service.\n\nUse the arrow keys to choose one from this list.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - } - _ => { - self.set_active_screen_message( - "Choose a service from this list.\n\nUse the action rows to open logs, stop, restart, or go back. Press F5 to refresh.", - ); - self.status = "Choose a service from the list.".to_owned(); - } - } - true - } - "logs" => { - match logs_target_from_args(&self.paths, &args) { - Ok(LogsCommandTarget::Page(direction)) => { - self.page_logs_browser(direction); - } - Ok(LogsCommandTarget::Follow { query }) => { - self.open_logs_browser_with_follow(query, 0, true); - } - Ok(LogsCommandTarget::StopFollow) => { - self.stop_following_logs(); - } - Ok(LogsCommandTarget::Service(service_id)) => { - let detail = - render_service_logs_text_for_tui(&self.paths, &service_id, false) - .unwrap_or_else(|error| { - format!("Service log lookup failed.\n\n{error}") - }); - self.open_log_output_card("Service Logs", detail); - } - Ok(LogsCommandTarget::Browser { query }) => { - self.open_logs_browser(query, 0); - } - Err(error) => { - self.open_logs_browser(None, 0); - if let Some(state) = self.logs_view.as_mut() { - state.last_rendered = - format!("{error}\n\nUse the choices on the left, or press Back."); - state.selected = 0; - state.detail_scroll = 0; - } - self.status = "Logs option was not recognized.".to_owned(); - } - } - true - } - "gpu" => { - let refresh = match args.as_slice() { - [] => false, - ["refresh"] => true, - _ => { - self.open_command_screen( - "GPU", - "GPU status is shown here.\n\nPress F5 to update the snapshot, or Back to leave.", - Some(CommandScreenTarget::Gpu), - self.command_screen_actions_for_target(CommandScreenTarget::Gpu), - ); - self.status = "GPU option was not recognized.".to_owned(); - return true; - } - }; - let (title, detail) = - self.render_command_screen_target(CommandScreenTarget::Gpu, refresh); - self.open_command_screen( - title, - detail, - Some(CommandScreenTarget::Gpu), - self.command_screen_actions_for_target(CommandScreenTarget::Gpu), - ); - self.status = if !self.config.telemetry.local_inspection_enabled() { - "GPU local checks are turned off.".to_owned() - } else if refresh { - "GPU status refreshed.".to_owned() - } else { - "GPU status loaded.".to_owned() - }; - true - } - "update" => { - match parse_update_command_args(&args) { - Ok(UpdateCommandPlan::Report) => { - self.open_update_manager(); - } - Ok(UpdateCommandPlan::DryRun(cli_args)) => { - self.open_update_manager(); - if let Some(cli_args) = self.normalize_update_runtime_args(cli_args) { - self.start_cli_command("Update", cli_args); - self.status = "Previewing ROCm update...".to_owned(); - } - } - Ok(UpdateCommandPlan::Approval(cli_args)) => { - self.open_update_manager(); - if let Some(cli_args) = self.normalize_update_runtime_args(cli_args) { - self.request_screen_cli_approval( - "Update", - "Update", - cli_args, - Some(format_slash_command_for_display("update", &args)), - ); - } - } - Err(error) => { - self.open_update_manager(); - self.set_active_screen_message(plain_update_parse_error( - &error.to_string(), - )); - self.status = "Update option was not recognized.".to_owned(); - } - } - true - } - "daemon" => { - self.open_command_screen_target(CommandScreenTarget::Daemon); - self.status = "Background helper shown.".to_owned(); - true - } - "chat" => { - let (rocm_tools, prompt_args) = chat_command_prompt_args(&args); - if prompt_args.is_empty() { - if self.provider == "local" { - if self.saved_chat_session.is_some() { - self.open_local_rocm_tools_chat_session(None); - } else if let Some(record) = self.first_ready_builtin_assistant_service() { - self.open_local_rocm_tools_chat_session(Some(record)); - } else { - self.open_serve_wizard(); - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(local_chat_needs_server_message()); - } - self.status = - "Choose a local model, then press Enter on Start.".to_owned(); - } - } else { - self.open_command_screen_target(CommandScreenTarget::ChatProvider); - self.status = "Chat assistant setup shown.".to_owned(); - } - } else { - let prompt = prompt_args.join(" "); - if self.provider == "local" - && rocm_tools - && let Some(approval) = crate::install_sdk_chat_approval_for_prompt(&prompt) - { - if self.open_install_folder_choice_for_chat_approval(&approval) { - self.status = - "Choose where ROCm should be installed, then review the install." - .to_owned(); - } else { - self.request_screen_cli_approval_with_explanation( - &approval.pending_title, - &approval.command_title, - approval.args, - approval.display_command, - approval.explanation, - ); - } - return true; - } - let mut review_provider = self.provider.clone(); - let mut review_model = None; - let mut review_rocm_tools = rocm_tools; - if self.provider == "local" { - if let Some(record) = self.first_ready_builtin_assistant_service() { - review_provider = "local".to_owned(); - review_model = Some(record.canonical_model_id); - review_rocm_tools = true; - } else if self.saved_chat_session.is_some() { - self.open_local_rocm_tools_chat_session(None); - self.submit_chat_session_prompt(prompt); - return true; - } else { - self.open_serve_wizard(); - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(local_chat_needs_server_message()); - } - self.status = - "Choose a local model, then press Enter on Start.".to_owned(); - return true; - } - } - let detail = - render_chat_review_text(&review_provider, &prompt, review_rocm_tools); - self.open_command_screen( - "Chat", - detail, - None, - vec![ - CommandScreenAction::SendChat, - CommandScreenAction::EditChat, - CommandScreenAction::Back, - ], - ); - if let Some(state) = self.command_screen.as_mut() { - state.chat_prompt = Some(prompt); - state.chat_tools = review_rocm_tools; - state.chat_model = review_model; - } - self.status = - "Review chat prompt. Choose Send, Edit prompt, or Back.".to_owned(); - } - true - } - "provider" => { - self.open_provider_manager(); - if args.len() > 1 { - self.set_active_screen_message( - "Choose one provider from the list, then press Enter to use it.", - ); - self.status = "Choose one provider from the list.".to_owned(); - return true; - } - match args.first().copied() { - None => {} - Some("local" | "anthropic" | "openai") => { - self.select_provider_by_name(args[0]); - } - Some(other) => { - self.select_provider_by_name(other); - } - } - true - } - "uninstall" => { - if args - .iter() - .any(|value| *value == "--yes" || *value == "apply") - { - self.open_command_screen_target(CommandScreenTarget::UninstallPreview); - self.request_screen_cli_approval( - "Uninstall ROCm CLI state", - "Uninstall", - vec!["uninstall".to_owned(), "--yes".to_owned()], - Some(format_slash_command_for_display("uninstall", &args)), - ); - } else { - self.open_command_screen_target(CommandScreenTarget::UninstallPreview); - self.status = "Rendered uninstall preview.".to_owned(); - } - true - } - "serve" => { - if args.is_empty() { - self.open_serve_wizard(); - return true; - } - let plan = match parse_serve_command_args(&args) { - Ok(plan) => plan, - Err(error) => { - self.open_command_screen( - "Serve", - plain_serve_parse_error(&error.to_string()), - None, - vec![CommandScreenAction::EditPlan, CommandScreenAction::Back], - ); - if let Some(state) = self.command_screen.as_mut() { - state.plan_request = Some(format!("serve {}", args.join(" "))); - } - self.status = "Serve needs a model or option fix.".to_owned(); - return true; - } - }; - self.open_serve_wizard_from_plan(plan); - true - } - "clear" => { - if self.running_job_blocks_screen_switch() { - return true; - } - self.open_clear_overlay(); - true - } - "quit" | "exit" => { - if self.running_job_blocks_screen_switch() { - return true; - } - self.open_quit_overlay(); - true - } - _ => false, - } - } - - fn start_cli_command(&mut self, title: &str, args: Vec) { - self.start_cli_command_with_kind(title, args, RunningJobKind::Cli, None); - } - - fn start_examine_refresh_job(&mut self) -> bool { - if self.running_job.is_some() { - let message = if self - .running_job - .as_ref() - .is_some_and(|job| matches!(job.kind, RunningJobKind::ExamineRefresh)) - { - "Examine is already checking this computer.\n\nYou can keep using the arrow keys while it finishes." - } else { - "Another action is already running.\n\nWait for it to finish, then refresh Examine again." - }; - if let Some(state) = self.examine_manager.as_mut() { - state.message = Some(message.to_owned()); - } - self.status = "A command is already running; wait for it to finish.".to_owned(); - return false; - } - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let result = render_examine_text() - .map(|rendered| CommandOutput { - ok: true, - rendered, - chat_approval: None, - }) - .map_err(|error| error.to_string()); - let _ = sender.send(RunningJobEvent::Finished(result)); - }); - self.running_job = Some(RunningJob { - title: "Examine".to_owned(), - kind: RunningJobKind::ExamineRefresh, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - self.running_job_log_scroll = 0; - self.running_job_output.clear(); - self.running_job_cancel_requested = None; - self.record_activity("examine check started"); - self.status = "Checking this computer...".to_owned(); - true - } - - fn discard_background_examine_refresh(&mut self) { - if self - .running_job - .as_ref() - .is_some_and(|job| matches!(job.kind, RunningJobKind::ExamineRefresh)) - { - self.running_job = None; - self.running_job_cancel_requested = None; - self.record_activity("examine check superseded"); - } - } - - fn start_cli_command_with_display( - &mut self, - title: &str, - args: Vec, - display_command: String, - ) { - self.start_cli_command_with_kind(title, args, RunningJobKind::Cli, Some(display_command)); - } - - fn start_cli_command_with_kind( - &mut self, - title: &str, - args: Vec, - kind: RunningJobKind, - display_command: Option, - ) -> bool { - self.discard_background_examine_refresh(); - if self.running_job.is_some() { - self.status = "A command is already running; wait for it to finish.".to_owned(); - return false; - } - let display = display_command - .clone() - .unwrap_or_else(|| format_command_for_display("rocm", &args)); - let (sender, receiver) = mpsc::channel(); - let cancel_requested = Arc::new(AtomicBool::new(false)); - let cancel_for_thread = Arc::clone(&cancel_requested); - let args_for_thread = args; - let paths_for_thread = self.paths.clone(); - std::thread::spawn(move || { - let result = run_cli_command_streaming( - &paths_for_thread, - &args_for_thread, - display_command.as_deref(), - sender.clone(), - cancel_for_thread, - ) - .map_err(|error| error.to_string()); - let _ = sender.send(RunningJobEvent::Finished(result)); - }); - self.running_job = Some(RunningJob { - title: title.to_owned(), - kind, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - self.running_job_log_scroll = 0; - self.running_job_output.clear(); - self.running_job_cancel_requested = Some(cancel_requested); - let screen_command = self.active_screen_handles_command_output(title); - if screen_command { - self.clear_active_screen_message(); - } else if !self.onboarding_handles_command_output(title) { - self.push_block("Running", &format!("command: {display}")); - } - self.record_activity(format!("started: {display}")); - self.status = format!("{title} command running."); - true - } - - fn active_screen_handles_command_output(&self, title: &str) -> bool { - (self.examine_manager.is_some() && title == "Examine") - || (self.runtime_manager.is_some() && matches!(title, "Runtimes" | "Install")) - || (self.install_manager.is_some() && matches!(title, "Install" | "Install Preview")) - || (self.engine_manager.is_some() && title == "Engine") - || (self.serve_wizard.is_some() && title == "Serve") - || (self.update_manager.is_some() && title == "Update") - || (self.automations_manager.is_some() && matches!(title, "Automations" | "Review")) - || (self.services_manager.is_some() && title == "Services") - || self.command_screen.as_ref().is_some_and(|state| { - state.title == title - || (state.title == "Chat" && chat_session_owns_command_title(title)) - || state.title == "Plan" - || (state.title == "Automations" && title == "Review") - }) - } - - fn onboarding_handles_command_output(&self, title: &str) -> bool { - self.onboarding_active && matches!(title, "Install" | "Reinstall" | "Uninstall") - } - - fn start_rocmd_command_with_kind( - &mut self, - title: &str, - args: Vec, - kind: RunningJobKind, - ) -> bool { - self.discard_background_examine_refresh(); - if self.running_job.is_some() { - self.status = "A command is already running; wait for it to finish.".to_owned(); - return false; - } - let display = format_command_for_display("rocmd", &args); - let (sender, receiver) = mpsc::channel(); - let cancel_requested = Arc::new(AtomicBool::new(false)); - let cancel_for_thread = Arc::clone(&cancel_requested); - let args_for_thread = args; - let paths_for_thread = self.paths.clone(); - std::thread::spawn(move || { - let result = run_rocmd_command_streaming( - &paths_for_thread, - &args_for_thread, - sender.clone(), - cancel_for_thread, - ) - .map_err(|error| error.to_string()); - let _ = sender.send(RunningJobEvent::Finished(result)); - }); - self.running_job = Some(RunningJob { - title: title.to_owned(), - kind, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - self.running_job_log_scroll = 0; - self.running_job_output.clear(); - self.running_job_cancel_requested = Some(cancel_requested); - if self.active_screen_handles_command_output(title) { - self.clear_active_screen_message(); - } else { - self.push_block("Tool Call", &format!("command: {display}")); - } - self.record_activity(format!("started: {display}")); - self.status = format!("{title} command running."); - true - } - - fn start_chat_stream( - &mut self, - prompt: String, - rocm_tools: bool, - model: Option, - ) -> bool { - self.discard_background_examine_refresh(); - if self.running_job.is_some() { - self.status = "A command is already running; wait for it to finish.".to_owned(); - return false; - } - let provider = self.provider.clone(); - let paths = self.paths.clone(); - let current_session_has_ready_service = self - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .and_then(|session| self.ready_local_chat_record_for_session(session)) - .is_some(); - if provider == "local" - && !current_session_has_ready_service - && !self.local_chat_service_available(model.as_deref()) - { - self.open_local_assistant_start_flow(model); - return false; - } - let request = ChatRequest { - model, - messages: vec![ChatMessage { - role: "user".to_owned(), - content: prompt.clone(), - }], - max_tokens: Some(512), - rocm_tools, - }; - let (sender, receiver) = mpsc::channel(); - let cancel_requested = Arc::new(AtomicBool::new(false)); - let provider_for_thread = provider.clone(); - std::thread::spawn(move || { - let result = run_provider_chat_stream(&paths, &provider_for_thread, &request, &sender) - .map_err(|error| error.to_string()); - let _ = sender.send(RunningJobEvent::Finished(result)); - }); - self.running_job = Some(RunningJob { - title: "Chat".to_owned(), - kind: RunningJobKind::Chat { - provider: provider.clone(), - rocm_tools, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - self.running_job_log_scroll = 0; - self.running_job_output.clear(); - self.running_job_cancel_requested = Some(cancel_requested); - self.set_chat_session_working_message(); - if !self.active_screen_handles_command_output("Chat") { - self.push_block("Chat", &format!("provider: {provider}\nprompt: {prompt}")); - } - self.record_activity(format!("started chat: {provider}")); - self.status = if rocm_tools { - chat_tools_running_status(&provider) - } else { - format!("Chat request streaming from {provider}.") - }; - true - } - - fn start_planner_job(&mut self, title: &str, request: &str) -> bool { - self.discard_background_examine_refresh(); - if self.running_job.is_some() { - self.status = "A command is already running; wait for it to finish.".to_owned(); - return false; - } - let provider = self - .config - .planner_provider - .as_deref() - .unwrap_or("provider") - .to_owned(); - let paths = self.paths.clone(); - let config = self.config.clone(); - let request = request.to_owned(); - let request_for_thread = request.clone(); - let title = title.to_owned(); - let (sender, receiver) = mpsc::channel(); - let cancel_requested = Arc::new(AtomicBool::new(false)); - std::thread::spawn({ - move || { - let rendered = render_freeform_plan(&request_for_thread, &paths, &config); - let _ = sender.send(RunningJobEvent::Finished(Ok(CommandOutput { - ok: true, - rendered, - chat_approval: None, - }))); - } - }); - self.running_job = Some(RunningJob { - title: "Planning".to_owned(), - kind: RunningJobKind::Planner { - title, - request: request.clone(), - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - self.running_job_log_scroll = 0; - self.running_job_output.clear(); - self.running_job_cancel_requested = Some(cancel_requested); - if !self.active_screen_handles_command_output("Planning") { - self.push_block( - "Planning", - &format!("Using {provider} to help resolve this request.\nrequest: {request}"), - ); - } - self.record_activity(format!("provider planner started: {provider}")); - self.status = "Planning with provider. This may take a moment.".to_owned(); - true - } - - fn poll_running_job(&mut self) { - let mut streamed_this_poll = 0; - loop { - let Some(job) = self.running_job.as_ref() else { - return; - }; - match job.receiver.try_recv() { - Ok(RunningJobEvent::Stream { stream, line }) => { - let title = job.title.clone(); - self.push_stream_line_for_job(&title, stream, &line); - if let Some(job) = self.running_job.as_mut() { - job.streamed_lines = job.streamed_lines.saturating_add(1); - match stream { - CommandOutputStream::Stdout => { - job.streamed_stdout_lines = - job.streamed_stdout_lines.saturating_add(1); - } - CommandOutputStream::Stderr => { - job.streamed_stderr_lines = - job.streamed_stderr_lines.saturating_add(1); - } - } - } - streamed_this_poll += 1; - if streamed_this_poll >= MAX_STREAM_EVENTS_PER_POLL { - if self.onboarding_active - && self - .running_job - .as_ref() - .is_some_and(|job| job.title == "Install") - { - self.status = "Installing ROCm. Progress is shown in the install card." - .to_owned(); - } else { - self.status = "Streaming output...".to_owned(); - } - return; - } - } - Ok(RunningJobEvent::ChatDelta { content }) => { - self.push_chat_delta(&content); - if let Some(job) = self.running_job.as_mut() { - job.streamed_lines = job.streamed_lines.saturating_add(1); - } - streamed_this_poll += 1; - if streamed_this_poll >= MAX_STREAM_EVENTS_PER_POLL { - self.status = "Streaming chat response...".to_owned(); - return; - } - } - Ok(RunningJobEvent::Finished(result)) => { - let title = job.title.clone(); - let kind = job.kind.clone(); - let streamed_lines = job.streamed_lines; - let streamed_stdout_lines = job.streamed_stdout_lines; - let streamed_stderr_lines = job.streamed_stderr_lines; - self.running_job = None; - self.running_job_cancel_requested = None; - self.complete_running_job( - title, - kind, - result, - StreamedOutputCounts { - total: streamed_lines, - stdout: streamed_stdout_lines, - stderr: streamed_stderr_lines, - }, - ); - return; - } - Err(TryRecvError::Empty) => return, - Err(TryRecvError::Disconnected) => { - let title = job.title.clone(); - let kind = job.kind.clone(); - let streamed_lines = job.streamed_lines; - let streamed_stdout_lines = job.streamed_stdout_lines; - let streamed_stderr_lines = job.streamed_stderr_lines; - self.running_job = None; - self.running_job_cancel_requested = None; - self.complete_running_job( - title, - kind, - Err("command runner disconnected before producing output".to_owned()), - StreamedOutputCounts { - total: streamed_lines, - stdout: streamed_stdout_lines, - stderr: streamed_stderr_lines, - }, - ); - return; - } - } - } - } - - #[allow(dead_code)] - fn push_stream_line(&mut self, stream: CommandOutputStream, line: &str) { - let Some(line) = sanitized_stream_line(line) else { - return; - }; - self.push_transcript_stream_line(stream, &line); - } - - fn push_stream_line_for_job(&mut self, title: &str, stream: CommandOutputStream, line: &str) { - let Some(line) = sanitized_stream_line(line) else { - return; - }; - if self.onboarding_handles_command_output(title) { - self.push_onboarding_install_log(&line); - if self.running_job_uses_foreground_output(title) { - self.push_running_job_output_line(&line); - } - return; - } - if self.active_screen_handles_command_output(title) { - self.push_active_screen_stream_line(stream, &line); - return; - } - if self.running_job_uses_foreground_output(title) { - self.push_running_job_output_line(&line); - return; - } - self.push_transcript_stream_line(stream, &line); - } - - fn running_job_uses_foreground_output(&self, title: &str) -> bool { - self.running_job.as_ref().is_some_and(|job| { - job.title == title - && matches!( - job.kind, - RunningJobKind::Cli - | RunningJobKind::Planner { .. } - | RunningJobKind::Proposal { .. } - | RunningJobKind::ServiceLifecycle { .. } - ) - }) - } - - fn push_running_job_output_line(&mut self, line: &str) { - if line.is_empty() { - return; - } - self.running_job_output.push_back(line.to_owned()); - while self.running_job_output.len() > RUNNING_JOB_OUTPUT_LINES { - self.running_job_output.pop_front(); - } - } - - fn push_transcript_stream_line(&mut self, stream: CommandOutputStream, line: &str) { - if line.is_empty() { - return; - } - self.home_dashboard_visible = false; - let was_at_bottom = self.transcript_scroll >= self.max_scroll().saturating_sub(2); - self.transcript.push(TranscriptLine::stream(stream, line)); - self.trim_transcript(); - if was_at_bottom { - self.scroll_to_bottom(); - } - } - - fn push_active_screen_stream_line(&mut self, stream: CommandOutputStream, line: &str) { - let mut lines = self - .active_screen_message_text() - .map(|message| { - message - .lines() - .map(ToOwned::to_owned) - .collect::>() - }) - .unwrap_or_default(); - let line = match stream { - CommandOutputStream::Stdout => line.to_owned(), - CommandOutputStream::Stderr => format!("Warning: {line}"), - }; - lines.push(line); - let keep_from = lines - .len() - .saturating_sub(ACTIVE_SCREEN_RECENT_OUTPUT_LINES); - let message = lines - .into_iter() - .skip(keep_from) - .collect::>() - .join("\n"); - self.set_active_screen_message(message); - } - - fn push_chat_delta(&mut self, content: &str) { - let content = sanitize_chat_delta_text(content).replace('\r', "\n"); - if content.is_empty() { - return; - } - let should_follow = self.command_screen_chat_should_follow_latest(); - if let Some(state) = self.command_screen.as_mut() - && state.chat_session.is_some() - { - let session = state.chat_session.as_mut().expect("checked above"); - if let Some(turn) = session.turns.last_mut() - && turn.role == ChatSessionRole::Assistant - { - turn.content.push_str(&content); - } else { - session.turns.push(ChatSessionTurn { - role: ChatSessionRole::Assistant, - content, - detail: None, - }); - } - if should_follow { - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - return; - } - if let Some(state) = self.command_screen.as_mut() - && state.title == "Chat" - { - let message = state - .message - .get_or_insert_with(|| "Chat response\n\n".to_owned()); - message.push_str(&content); - state.detail_scroll = state.detail_scroll.saturating_add(1); - return; - } - self.home_dashboard_visible = false; - let was_at_bottom = self.transcript_scroll >= self.max_scroll().saturating_sub(2); - for fragment in content.split_inclusive('\n') { - let ends_line = fragment.ends_with('\n'); - let text = fragment.trim_end_matches('\n'); - if !text.is_empty() { - if let Some(last) = self.transcript.last_mut() { - if matches!(last.kind, TranscriptLineKind::Assistant) && !last.text.is_empty() { - last.text.push_str(text); - } else { - self.transcript - .push(TranscriptLine::assistant(&format!("assistant: {text}"))); - } - } else { - self.transcript - .push(TranscriptLine::assistant(&format!("assistant: {text}"))); - } - } - if ends_line { - self.transcript - .push(TranscriptLine::assistant("assistant: ")); - } - } - self.trim_transcript(); - if was_at_bottom { - self.scroll_to_bottom(); - } - } - - fn push_final_output_block(&mut self, title: &str, body: &str, streamed_lines: usize) { - let preserve_scroll = streamed_lines > 0 && !self.transcript_near_bottom(); - let previous_scroll = self.transcript_scroll; - self.push_block(title, &truncate_command_output(body)); - if preserve_scroll { - self.transcript_scroll = previous_scroll.min(self.max_scroll()); - } - } - - fn transcript_near_bottom(&self) -> bool { - self.transcript_scroll >= self.max_scroll().saturating_sub(2) - } - - fn set_active_screen_message(&mut self, message: impl Into) { - let message = truncate_command_output(&message.into()); - if let Some(state) = self.runtime_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.install_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.engine_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.model_picker.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.serve_wizard.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.update_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.provider_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.config_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.services_manager.as_mut() { - state.message = Some(message.clone()); - } - if let Some(state) = self.command_screen.as_mut() { - state.detail_scroll = 0; - state.message = Some(message); - } - } - - fn clear_active_screen_message(&mut self) { - if let Some(state) = self.runtime_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.install_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.engine_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.model_picker.as_mut() { - state.message = None; - } - if let Some(state) = self.serve_wizard.as_mut() { - state.message = None; - } - if let Some(state) = self.update_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.automations_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.provider_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.config_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.services_manager.as_mut() { - state.message = None; - } - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - } - } - - fn active_screen_message_text(&self) -> Option { - self.runtime_manager - .as_ref() - .and_then(|state| state.message.clone()) - .or_else(|| { - self.install_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.engine_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.model_picker - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.serve_wizard - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.update_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.automations_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.provider_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.config_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.services_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - self.command_screen - .as_ref() - .and_then(|state| state.message.clone()) - }) - } - - fn complete_running_job( - &mut self, - title: String, - kind: RunningJobKind, - result: Result, - streamed: StreamedOutputCounts, - ) { - let mut advance_onboarding_after_job = false; - let mut chat_auto_follow_up = false; - match (kind, result) { - (RunningJobKind::ExamineRefresh, Ok(output)) => { - if let Some(state) = self.examine_manager.as_mut() { - if output.ok { - state.report = output.rendered; - state.message = None; - state.detail_scroll = 0; - } else { - state.message = - Some(format!("Examine check failed.\n\n{}", output.rendered)); - } - } - self.status = if output.ok { - "Examine refreshed.".to_owned() - } else { - "Examine check failed.".to_owned() - }; - self.record_activity(if output.ok { - "examine check completed".to_owned() - } else { - "examine check failed".to_owned() - }); - } - (RunningJobKind::ExamineRefresh, Err(error)) => { - if let Some(state) = self.examine_manager.as_mut() { - state.message = Some(format!("Examine check failed.\n\n{error}")); - state.detail_scroll = 0; - } - self.status = "Examine check failed.".to_owned(); - self.record_activity("examine check failed"); - } - (RunningJobKind::Cli, Ok(output)) => { - let rendered = output.rendered.clone(); - let output_ok = output.ok; - let final_output = final_output_for_transcript(&rendered, streamed); - let onboarding_command = self.onboarding_handles_command_output(&title); - let screen_command = self.active_screen_handles_command_output(&title); - let recent_screen_output = screen_command - .then(|| self.active_screen_message_text()) - .flatten(); - let (screen_log_path, screen_log_error) = if screen_command { - match write_tui_command_full_log( - &self.paths, - &title, - &rendered, - recent_screen_output.as_deref(), - ) { - Ok(path) => (Some(path), None), - Err(error) => (None, Some(error.to_string())), - } - } else { - (None, None) - }; - let screen_summary = screen_command.then(|| { - plain_command_completion_text( - &title, - output_ok, - &rendered, - streamed, - recent_screen_output.as_deref(), - screen_log_path.as_deref(), - screen_log_error.as_deref(), - ) - }); - if screen_command { - if !output_ok && let Some(summary) = screen_summary.as_deref() { - self.set_active_screen_message(summary.to_owned()); - } - } else if onboarding_command { - self.write_onboarding_install_full_log(&rendered); - self.push_onboarding_install_rendered_output(&final_output); - } else { - self.push_final_output_block(&title, &final_output, streamed.total); - } - self.status = if output_ok { - format!("{title} command completed.") - } else if onboarding_command { - self.onboarding_install_failure = Some(install_failure_reason( - &final_output, - &self.onboarding_install_log, - )); - format!("{title} failed. See the details above.") - } else { - format!("{title} command failed.") - }; - self.record_activity(if output_ok { - format!("completed: {title}") - } else { - format!("failed: {title}") - }); - if output_ok && title == "Config" { - self.refresh_config(); - if rendered.contains("telemetry mode set to") { - if self.config.telemetry.local_inspection_enabled() { - self.status = - "Telemetry set to local; local amd-smi inspection is enabled." - .to_owned(); - } else { - self.gpu_telemetry.clear(); - self.status = "Telemetry disabled; amd-smi polling stopped.".to_owned(); - } - } - } - if screen_command { - if self.runtime_manager.is_some() { - self.refresh_runtime_manager(); - } - if self.engine_manager.is_some() { - self.refresh_engine_manager(); - } - if self.update_manager.is_some() { - self.refresh_update_manager(); - } - if self.automations_manager.is_some() { - self.refresh_automations_manager(); - } - let screen_success_message = if output_ok { - terse_screen_command_success_text(&self.paths, &title, &rendered) - } else { - None - }; - if output_ok { - if let Some(message) = screen_success_message { - self.set_active_screen_message(message); - } else { - self.clear_active_screen_message(); - } - } else if let Some(summary) = screen_summary.as_deref() { - self.set_active_screen_message(summary.to_owned()); - } - if output_ok { - self.status = if title == "Install" { - "ROCm installed. Choose it, then press Enter to use it.".to_owned() - } else if title == "Install Preview" { - "Driver setup preview finished.".to_owned() - } else if title == "Engine" { - "Engine change completed.".to_owned() - } else if title == "Update" { - "Update action finished.".to_owned() - } else if title == "Automations" || title == "Review" { - "Automation action finished.".to_owned() - } else if title == "ComfyUI" { - comfyui_success_status(&rendered) - .unwrap_or_else(|| "ComfyUI is ready.".to_owned()) - } else if self.command_screen.is_some() { - format!("{title} finished.") - } else { - "Runtime change completed.".to_owned() - }; - } - if output_ok && title == "ComfyUI" { - let installed = crate::comfyui::is_installed(&self.paths).unwrap_or(false) - || rendered.lines().any(|line| line.trim() == "installed: yes") - || keyed_output_value(&rendered, "URL").is_some(); - let running = crate::comfyui::running_url(&self.paths) - .ok() - .flatten() - .is_some() - || keyed_output_value(&rendered, "URL").is_some(); - if let Some(state) = self.command_screen.as_mut() { - state.actions = comfyui_command_screen_actions(installed, running); - state.selected = - state.selected.min(state.actions.len().saturating_sub(1)); - } - } - } - if output_ok && title == "ComfyUI" && keyed_output_value(&rendered, "URL").is_some() - { - self.tui_started_comfyui = true; - } - if self.command_screen_is_chat_session() - && chat_session_owns_command_title(title.as_str()) - { - let should_follow = self.command_screen_chat_should_follow_latest(); - let recent_live_output = if self.running_job_output.is_empty() { - recent_screen_output - .as_deref() - .unwrap_or_default() - .lines() - .map(|line| format!(" {}", display_stream_log_line(line.trim()))) - .collect::>() - .join("\n") - } else { - self.running_job_output - .iter() - .map(|line| format!(" {}", display_stream_log_line(line))) - .collect::>() - .join("\n") - }; - let mut summary_parts = Vec::new(); - if let Some(summary) = screen_summary - && !summary.trim().is_empty() - { - summary_parts.push(summary); - } - if !recent_live_output.trim().is_empty() { - summary_parts.push(format!("Recent output\n{recent_live_output}")); - } - if !final_output.trim().is_empty() { - summary_parts.push(format!("Command result\n{final_output}")); - } - if summary_parts.is_empty() { - summary_parts.push(final_output.clone()); - } - let summary = summary_parts.join("\n\n"); - self.push_chat_session_turn_with_detail( - ChatSessionRole::Tool, - chat_session_command_result_text(&title, output_ok, &summary), - Some(chat_session_command_result_detail( - &title, output_ok, &summary, - )), - ); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - } - self.set_chat_session_follow_if_needed(should_follow); - self.status = if output_ok { - "ROCm command finished. You can keep chatting.".to_owned() - } else { - "ROCm command failed. The result is in the chat.".to_owned() - }; - chat_auto_follow_up = true; - } - if output_ok && self.onboarding_handles_command_output(&title) { - if title == "Uninstall" { - self.config.setup.completed = false; - self.config.setup.therock_venv = None; - if let Err(error) = self.config.save(&self.paths) { - self.set_onboarding_setup_error(&format!( - "ROCm was uninstalled, but setup settings could not be saved: {error}" - )); - } else { - self.status = - "ROCm uninstalled. Choose an install folder to set it up again." - .to_owned(); - } - } else { - advance_onboarding_after_job = self.record_setup_runtime_after_install(); - } - } - if output_ok - && title == "Serve" - && managed_serve_command_launched_service(&rendered) - { - self.handle_managed_serve_completion(&rendered); - } - } - (RunningJobKind::Cli, Err(error)) => { - if self.onboarding_handles_command_output(&title) { - self.onboarding_install_failure = Some(error.clone()); - self.write_onboarding_install_full_log(&format!( - "setup install failed before command output was available\n\nerror: {error}" - )); - self.push_onboarding_install_log(&format!("error: {error}")); - self.status = format!("{title} failed to start. See the details above."); - } else if self.active_screen_handles_command_output(&title) { - self.set_active_screen_message(format!("{title} failed to start.\n\n{error}")); - self.status = format!("{title} command failed to launch."); - } else { - self.push_block("Error", &error); - self.status = format!("{title} command failed to launch."); - } - if self.command_screen_is_chat_session() - && chat_session_owns_command_title(title.as_str()) - { - let should_follow = self.command_screen_chat_should_follow_latest(); - let summary = format!("{title} failed to start.\n\n{error}"); - self.push_chat_session_turn_with_detail( - ChatSessionRole::Tool, - chat_session_command_result_text(&title, false, &summary), - Some(chat_session_command_result_detail(&title, false, &summary)), - ); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - } - self.set_chat_session_follow_if_needed(should_follow); - self.status = format!("{title} failed to start. The result is in the chat."); - chat_auto_follow_up = true; - } - self.record_activity(format!("failed to launch: {title}")); - } - ( - RunningJobKind::Chat { - provider, - rocm_tools, - }, - Ok(output), - ) => { - let chat_approval = output.chat_approval.clone(); - if self.command_screen_is_chat_session() { - let should_follow = self.command_screen_chat_should_follow_latest(); - if !chat_output_is_stream_completion_marker(&output.rendered) { - self.push_chat_session_turn( - ChatSessionRole::Assistant, - chat_session_display_text(&output.rendered), - ); - } - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - } - self.set_chat_session_follow_if_needed(should_follow); - } else if rocm_tools { - if self.active_screen_handles_command_output("Chat") { - self.set_active_screen_message(output.rendered.clone()); - } else { - self.push_final_output_block("Chat", &output.rendered, streamed.total); - } - } - self.status = if output.ok { - format!("Chat response completed from {provider}.") - } else { - format!("Chat response failed from {provider}.") - }; - self.record_activity(if output.ok { - format!("chat completed: {provider}") - } else { - format!("chat failed: {provider}") - }); - if output.ok - && let Some(approval) = chat_approval - { - self.request_chat_tool_approval_or_dependency_install(approval); - } - } - (RunningJobKind::Chat { provider, .. }, Err(error)) => { - if self.command_screen_is_chat_session() { - let should_follow = self.command_screen_chat_should_follow_latest(); - self.push_chat_session_turn( - ChatSessionRole::Assistant, - format!("Chat request failed.\n\n{error}"), - ); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - } - self.set_chat_session_follow_if_needed(should_follow); - } else if self.active_screen_handles_command_output("Chat") { - self.set_active_screen_message(format!("Chat request failed.\n\n{error}")); - } else { - self.push_block("Error", &error); - } - self.status = format!("Chat request failed from {provider}."); - self.record_activity(format!("chat failed: {provider}")); - } - (RunningJobKind::Planner { title, request }, Ok(output)) => { - if output.ok { - self.open_plan_review_screen(&title, &request); - } else if self.active_screen_handles_command_output("Planning") { - self.set_active_screen_message("Plan failed.\n\nTry editing the request."); - } else { - self.push_final_output_block(&title, &output.rendered, streamed.total); - } - self.status = if output.ok { - "Plan ready. Choose Run, Edit request, or Back.".to_owned() - } else { - "Plan failed. Try editing the request.".to_owned() - }; - self.record_activity(if output.ok { - "provider plan completed".to_owned() - } else { - "provider plan failed".to_owned() - }); - } - (RunningJobKind::Planner { .. }, Err(error)) => { - if self.active_screen_handles_command_output("Planning") { - self.set_active_screen_message(format!("Plan failed.\n\n{error}")); - } else { - self.push_block("Error", &error); - } - self.status = "Plan failed. See the details above.".to_owned(); - self.record_activity("provider plan failed"); - } - (RunningJobKind::Proposal { proposal_id }, Ok(output)) => { - let final_output = final_output_for_transcript(&output.rendered, streamed); - if self.active_screen_handles_command_output("Review") { - self.set_active_screen_message(final_output.clone()); - } else { - self.push_final_output_block("Tool Output", &final_output, streamed.total); - } - if let Err(error) = self.finish_proposal_execution(&proposal_id, output.ok) { - if self.active_screen_handles_command_output("Review") { - self.set_active_screen_message(format!( - "{final_output}\n\nReview request finalization failed.\n\n{error}" - )); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review request finalization failed.".to_owned(); - } - self.record_activity(if output.ok { - format!("review request completed: {proposal_id}") - } else { - format!("review request failed: {proposal_id}") - }); - } - (RunningJobKind::Proposal { proposal_id }, Err(error)) => { - if self.active_screen_handles_command_output("Review") { - self.set_active_screen_message(format!("Review request failed.\n\n{error}")); - } else { - self.push_block("Error", &error); - } - if let Err(error) = self.finish_proposal_execution(&proposal_id, false) { - if self.active_screen_handles_command_output("Review") { - self.set_active_screen_message(format!( - "Review request failed.\n\nFinalization also failed.\n\n{error}" - )); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review request finalization failed.".to_owned(); - } - self.record_activity(format!("review request failed: {proposal_id}")); - } - (RunningJobKind::ServiceLifecycle { action, service_id }, Ok(output)) => { - let final_output = final_output_for_transcript(&output.rendered, streamed); - if self.active_screen_handles_command_output("Services") && !output.ok { - self.set_active_screen_message(format!( - "{}\n\n{final_output}", - action.failure_title() - )); - } else if !output.ok { - self.push_final_output_block("Services", &final_output, streamed.total); - } - self.refresh_services_manager(); - if output.ok { - self.clear_active_screen_message(); - if action == ServiceLifecycleAction::Stop { - self.tui_owned_service_ids.remove(&service_id); - } - } - self.status = if output.ok { - action.success_title().to_owned() - } else { - action.failure_title().to_owned() - }; - self.record_activity(if output.ok { - format!("service {} completed: {service_id}", action.command_word()) - } else { - format!("service {} failed: {service_id}", action.command_word()) - }); - } - (RunningJobKind::ServiceLifecycle { action, service_id }, Err(error)) => { - if self.active_screen_handles_command_output("Services") { - self.set_active_screen_message(format!( - "{}\n\n{error}", - action.failure_title() - )); - } else { - self.push_block("Error", &error); - } - self.refresh_services_manager(); - self.status = action.failure_title().to_owned(); - self.record_activity(format!( - "service {} failed to launch: {service_id}", - action.command_word() - )); - } - } - if self.onboarding_active { - self.refresh_config(); - self.onboarding_cancel_install_confirm = false; - self.reset_onboarding_selection(); - if advance_onboarding_after_job { - self.onboarding_success_modal = true; - self.status = "ROCm installed. Press Enter to continue.".to_owned(); - } - } - if chat_auto_follow_up - && self.command_screen_is_chat_session() - && self.running_job.is_none() - { - self.status = - "ROCm command result is in the chat. Ask the assistant what to do next.".to_owned(); - } - } - - fn record_setup_runtime_after_install(&mut self) -> bool { - let Some(runtime) = latest_setup_runtime(&self.paths) else { - self.set_onboarding_setup_error( - "ROCm install finished, but setup could not find the new folder.", - ); - return false; - }; - if let Some(runtime_key) = runtime.runtime_key.as_deref() - && let Err(error) = activate_runtime(&self.paths, &mut self.config, runtime_key) - { - self.set_onboarding_setup_error(&format!( - "ROCm installed, but setup could not save it as the default: {error}" - )); - return false; - } - self.config.setup.completed = true; - self.config.setup.therock_venv = runtime.install_root; - if let Err(error) = self.config.save(&self.paths) { - self.set_onboarding_setup_error(&format!( - "ROCm installed, but setup settings could not be saved: {error}" - )); - return false; - } - self.rebase_paths_to_saved_setup_folder(); - true - } - - fn set_onboarding_setup_error(&mut self, message: &str) { - self.onboarding_setup_error = Some(message.to_owned()); - self.push_onboarding_install_log(&format!("error: {message}")); - self.status = message.to_owned(); - } - - #[allow(dead_code)] - fn request_cli_approval(&mut self, title: &str, args: Vec, reason: &str) { - self.request_cli_approval_with_optional_display(title, args, reason, None); - } - - fn request_cli_approval_with_display( - &mut self, - title: &str, - args: Vec, - reason: &str, - display_command: String, - ) { - self.request_cli_approval_with_optional_display(title, args, reason, Some(display_command)); - } - - fn request_cli_approval_with_optional_display( - &mut self, - title: &str, - args: Vec, - reason: &str, - display_command: Option, - ) { - let args = self.with_detected_therock_family_for_install_sdk(args); - if self.config.permissions.full_access_enabled() && !cli_approval_must_show_review(&args) { - let display = display_command - .clone() - .unwrap_or_else(|| format_command_for_display("rocm", &args)); - if !self.onboarding_handles_command_output(title) { - self.push_block( - "Permissions", - &format!( - "full access is on\n\nrocm-cli will run this without another confirmation:\n{display}\n\nreason: {reason}" - ), - ); - } - self.record_activity(format!("full access: {title}")); - self.start_cli_command_with_kind(title, args, RunningJobKind::Cli, display_command); - return; - } - self.pending_approval = Some(PendingApproval { - title: title.to_owned(), - selected: 0, - detail_scroll: 0, - action: ApprovalAction::CliCommand { - title: title.to_owned(), - args, - display_command, - explanation: None, - }, - }); - self.record_activity(format!("approval requested: {title}")); - self.status = - "Approval required. Press Enter or Y to approve; Esc or N cancels.".to_owned(); - } - - fn request_screen_cli_approval( - &mut self, - pending_title: &str, - command_title: &str, - args: Vec, - display_command: Option, - ) { - self.request_screen_cli_approval_with_explanation( - pending_title, - command_title, - args, - display_command, - None, - ); - } - - fn request_screen_cli_approval_with_explanation( - &mut self, - pending_title: &str, - command_title: &str, - args: Vec, - display_command: Option, - explanation: Option, - ) { - let args = self.with_detected_therock_family_for_install_sdk(args); - if self.config.permissions.full_access_enabled() && !cli_approval_must_show_review(&args) { - self.record_activity(format!("full access: {pending_title}")); - self.start_cli_command_with_kind( - command_title, - args, - RunningJobKind::Cli, - display_command, - ); - return; - } - self.pending_approval = Some(PendingApproval { - title: pending_title.to_owned(), - selected: 0, - detail_scroll: 0, - action: ApprovalAction::CliCommand { - title: command_title.to_owned(), - args, - display_command, - explanation: explanation - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned), - }, - }); - self.record_activity(format!("approval requested: {pending_title}")); - self.status = - "Review the change. Press Enter or Y to approve; Esc or N cancels.".to_owned(); - } - - fn with_detected_therock_family_for_install_sdk(&self, mut args: Vec) -> Vec { - let is_install_sdk = args - .first() - .is_some_and(|arg| arg.eq_ignore_ascii_case("install")) - && args - .get(1) - .is_some_and(|arg| arg.eq_ignore_ascii_case("sdk")); - let already_has_family = args.iter().any(|arg| { - arg.eq_ignore_ascii_case("--family") - || arg.to_ascii_lowercase().starts_with("--family=") - }); - if is_install_sdk - && !already_has_family - && let Some(family) = self.host_gpu_summary.therock_family.as_deref() - { - args.push("--family".to_owned()); - args.push(family.to_owned()); - } - args - } - - fn request_chat_tool_approval_or_dependency_install( - &mut self, - approval: ChatToolApprovalRequest, - ) { - if self.open_install_folder_choice_for_chat_approval(&approval) { - self.status = "Assistant needs an install folder. Choose one, then review the install." - .to_owned(); - return; - } - if self.request_missing_dependency_install_for_chat_approval(&approval) { - return; - } - self.request_screen_cli_approval_with_explanation( - &approval.pending_title, - &approval.command_title, - approval.args, - approval.display_command, - approval.explanation, - ); - self.status = "Assistant suggested a change. Review it before anything runs.".to_owned(); - } - - fn request_missing_dependency_install_for_chat_approval( - &mut self, - approval: &ChatToolApprovalRequest, - ) -> bool { - if chat_approval_requests_comfyui_start(&approval.args) - && !crate::comfyui::is_installed(&self.paths).unwrap_or(false) - { - self.note_assistant_dependency_redirect( - "ComfyUI is not installed yet. I can install it first.", - ); - self.request_screen_cli_approval_with_explanation( - "Install ComfyUI", - "ComfyUI", - vec!["comfyui".to_owned(), "install".to_owned()], - Some("/comfyui install".to_owned()), - Some("Install ComfyUI before starting it.".to_owned()), - ); - self.status = "Review ComfyUI install before starting it.".to_owned(); - return true; - } - - let Some(engine) = chat_approval_serve_engine(&approval.args, &self.config) else { - return false; - }; - if self.engine_installed_for_assistant_start(&engine) { - return false; - } - let engine = canonical_engine_name(&engine).unwrap_or(engine.as_str()); - self.note_assistant_dependency_redirect(format!( - "{} is not installed yet. I can install it first.", - assistant_engine_label(engine) - )); - self.request_screen_cli_approval_with_explanation( - "Install engine", - "Engine", - vec![ - "engines".to_owned(), - "install".to_owned(), - engine.to_owned(), - ], - Some(format!("/engine install {engine}")), - Some("Install the selected model engine before starting it.".to_owned()), - ); - self.status = "Review engine install before starting the model.".to_owned(); - true - } - - fn note_assistant_dependency_redirect(&mut self, message: impl Into) { - let message = message.into(); - if self.command_screen_is_chat_session() { - self.push_chat_session_turn(ChatSessionRole::Assistant, message); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - state.detail_scroll = CHAT_SESSION_FOLLOW_SCROLL; - } - } else { - self.set_active_screen_message(message); - } - } - - fn engine_installed_for_assistant_start(&self, engine: &str) -> bool { - if ready_engine_env_id(&self.paths, &self.config, engine).is_some() { - return true; - } - let Some(entry) = self.config.engine_config(engine) else { - return false; - }; - [ - entry.preferred_env_id.as_ref(), - entry.last_installed_env_id.as_ref(), - ] - .into_iter() - .flatten() - .any(|env_id| { - load_onboarding_engine_env(&self.paths, engine, env_id) - .is_some_and(|manifest| manifest.env_path.is_dir()) - }) - } - - fn open_install_folder_choice_for_chat_approval( - &mut self, - approval: &ChatToolApprovalRequest, - ) -> bool { - if !chat_install_sdk_approval_needs_prefix(&approval.args) { - return false; - } - if self.command_screen_is_chat_session() { - self.set_current_chat_pending_install_args(Some(approval.args.clone())); - } - self.open_install_folder_browser_for_args(approval.args.clone()); - true - } - - fn request_screen_rocmd_approval( - &mut self, - pending_title: &str, - command_title: &str, - args: Vec, - kind: RunningJobKind, - ) { - if self.config.permissions.full_access_enabled() { - self.record_activity(format!("full access: {pending_title}")); - self.start_rocmd_command_with_kind(command_title, args, kind); - return; - } - self.pending_approval = Some(PendingApproval { - title: pending_title.to_owned(), - selected: 0, - detail_scroll: 0, - action: ApprovalAction::RocmdCommand { - title: command_title.to_owned(), - args, - kind, - }, - }); - self.record_activity(format!("approval requested: {pending_title}")); - self.status = - "Review the change. Press Enter or Y to approve; Esc or N cancels.".to_owned(); - } - - fn focus_proposal_by_id(&mut self, proposal_id: &str) -> Result<()> { - let proposal = find_automation_proposal(&self.paths, proposal_id)?; - self.focus_proposal(proposal) - } - - fn select_proposal_review_by_id(&mut self, proposal_id: &str) -> Result<()> { - let proposal = find_automation_proposal(&self.paths, proposal_id)?; - if let Some(state) = self.automations_manager.as_mut() { - if let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == proposal.proposal_id) - { - item.proposal = proposal.clone(); - } - select_automation_manager_row_by_key( - state, - &AutomationManagerRowKey::Review(proposal.proposal_id.clone()), - ); - state.message = None; - } - self.pending_approval = None; - self.status = format!( - "Review request {} selected. Use arrow keys, then Enter to review.", - proposal.proposal_id - ); - Ok(()) - } - - fn focus_proposal(&mut self, proposal: AutomationProposalRecord) -> Result<()> { - if let Some(state) = self.automations_manager.as_mut() { - if let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == proposal.proposal_id) - { - item.proposal = proposal.clone(); - } - select_automation_manager_row_by_key( - state, - &AutomationManagerRowKey::Review(proposal.proposal_id.clone()), - ); - state.message = None; - } - if proposal.status != "pending" { - bail!( - "review request `{}` is `{}` and cannot be approved", - proposal.proposal_id, - proposal.status - ); - } - let body = render_proposal_approval(&proposal); - self.pending_approval = Some(PendingApproval { - title: proposal.title.clone(), - selected: 0, - detail_scroll: 0, - action: ApprovalAction::Proposal { - proposal_id: proposal.proposal_id.clone(), - }, - }); - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(body); - } else if let Some(state) = self.command_screen.as_mut() { - state.message = Some(body); - } else { - self.push_block("Approval", &body); - } - self.record_activity(format!("review request selected: {}", proposal.proposal_id)); - self.status = format!( - "Review request {} selected. Use rows below it to edit, Enter or Y to approve, N to reject.", - proposal.proposal_id - ); - Ok(()) - } - - fn approve_focused_action(&mut self) { - if self.running_job_blocks_action() { - if self.pending_approval.is_some() { - self.status = "A command is already running; approval is still waiting.".to_owned(); - } - return; - } - let Some(pending) = self.pending_approval.take() else { - self.status = "No approval is waiting.".to_owned(); - return; - }; - match pending.action { - ApprovalAction::CliCommand { - title, - args, - display_command, - explanation: _, - } => { - if !(self.active_screen_handles_command_output(&title) - || self.onboarding_handles_command_output(&title)) - { - self.push_block("Approval", &format!("approved: {}", pending.title)); - } - self.record_activity(format!("approved: {}", pending.title)); - self.start_cli_command_with_kind( - &title, - args, - RunningJobKind::Cli, - display_command, - ); - } - ApprovalAction::RocmdCommand { - title, args, kind, .. - } => { - if !self.active_screen_handles_command_output(&title) { - self.push_block("Approval", &format!("approved: {}", pending.title)); - } - self.record_activity(format!("approved: {}", pending.title)); - self.start_rocmd_command_with_kind(&title, args, kind); - } - ApprovalAction::EnableFullAccess => { - self.config.permissions.mode = PERMISSIONS_MODE_FULL_ACCESS.to_owned(); - match self.config.save(&self.paths) { - Ok(()) => { - if self.command_screen.is_some() { - self.refresh_command_screen(); - } else { - self.push_block("Permissions", &render_permissions_text(&self.config)); - } - self.status = "Full access enabled.".to_owned(); - } - Err(error) => { - if let Some(state) = self.command_screen.as_mut() { - state.message = - Some(format!("Permissions could not be saved.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Permissions could not be saved.".to_owned(); - } - } - } - ApprovalAction::Proposal { proposal_id } => { - self.approve_proposal(&proposal_id); - } - } - } - - fn cancel_focused_action(&mut self) { - let Some(pending) = self.pending_approval.take() else { - self.status = "No approval is waiting.".to_owned(); - return; - }; - match pending.action { - ApprovalAction::CliCommand { .. } | ApprovalAction::RocmdCommand { .. } => { - if self.command_screen_is_chat_session() { - let should_follow = self.command_screen_chat_should_follow_latest(); - self.push_chat_session_turn( - ChatSessionRole::Tool, - chat_session_cancelled_command_text(&pending.title), - ); - if let Some(state) = self.command_screen.as_mut() { - state.message = None; - } - self.set_chat_session_follow_if_needed(should_follow); - self.status = "Approval cancelled. You can keep chatting.".to_owned(); - } else if self.runtime_manager.is_none() - && !self.onboarding_active - && self.install_manager.is_none() - && self.engine_manager.is_none() - && self.model_picker.is_none() - && self.serve_wizard.is_none() - && self.update_manager.is_none() - && self.automations_manager.is_none() - && self.provider_manager.is_none() - && self.config_manager.is_none() - && self.services_manager.is_none() - && (self.command_screen.is_none() || !self.command_screen_is_chat_session()) - { - self.open_home_dashboard(); - self.status = format!("{} cancelled. Back at the main menu.", pending.title); - } else { - self.set_active_screen_message(format!("{} cancelled.", pending.title)); - if self.serve_wizard.is_some() { - self.status = - "Serve cancelled. Choose Start when you are ready.".to_owned(); - } else if self.onboarding_active { - self.reset_onboarding_selection(); - self.status = - format!("{} cancelled. You're still in setup.", pending.title); - } else { - self.status = "Approval cancelled.".to_owned(); - } - } - self.record_activity(format!("cancelled: {}", pending.title)); - } - ApprovalAction::EnableFullAccess => { - if let Some(state) = self.command_screen.as_mut() { - state.message = Some("Full access was not enabled.".to_owned()); - } else { - self.push_block("Permissions", "Full access was not enabled."); - } - self.status = "Full access cancelled.".to_owned(); - } - ApprovalAction::Proposal { proposal_id } => { - match find_automation_proposal(&self.paths, &proposal_id) { - Ok(proposal) => { - if let Some(state) = self.automations_manager.as_mut() { - if let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == proposal.proposal_id) - { - item.proposal = proposal.clone(); - } - select_automation_manager_row_by_key( - state, - &AutomationManagerRowKey::Review(proposal.proposal_id.clone()), - ); - state.message = None; - } else if let Some(state) = self.command_screen.as_mut() { - state.message = Some("Review unchanged.".to_owned()); - } else { - self.push_block("Review", "Review unchanged."); - } - self.record_activity(format!( - "review approval closed: {}", - proposal.proposal_id - )); - } - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review request failed.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - } - } - self.status = "Review unchanged.".to_owned(); - } - } - } - - fn cancel_focused_action_to_home(&mut self) { - let title = self - .pending_approval - .as_ref() - .map_or_else(|| "Approval".to_owned(), |pending| pending.title.clone()); - let stay_in_chat = self.command_screen_is_chat_session(); - let stay_in_setup = self.onboarding_active; - self.cancel_focused_action(); - if !stay_in_chat && !stay_in_setup && self.pending_approval.is_none() { - self.open_home_dashboard(); - self.status = format!("{title} cancelled. Back at the main menu."); - } - } - - fn reject_or_cancel_focused_action(&mut self) { - let proposal_id = - self.pending_approval - .as_ref() - .and_then(|pending| match &pending.action { - ApprovalAction::Proposal { proposal_id } => Some(proposal_id.clone()), - _ => None, - }); - if let Some(proposal_id) = proposal_id { - self.pending_approval = None; - self.reject_proposal(&proposal_id); - } else { - self.cancel_focused_action(); - } - } - - fn approve_proposal(&mut self, proposal_id: &str) { - match self.approve_proposal_result(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review approval failed.\n\n{error}")); - } else if let Some(state) = self.command_screen.as_mut() { - state.message = Some(format!("Review approval failed.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review approval failed.".to_owned(); - } - } - } - - fn approve_proposal_result(&mut self, proposal_id: &str) -> Result<()> { - let proposal = find_automation_proposal(&self.paths, proposal_id)?; - if proposal.status != "pending" { - bail!( - "review request `{}` is `{}` and cannot be approved", - proposal.proposal_id, - proposal.status - ); - } - let args = proposal_sandbox_args(&proposal)?; - let proposal = update_automation_proposal_status(&self.paths, proposal_id, "approved")?; - record_proposal_audit( - &self.paths, - &proposal, - "proposal_approved", - "info", - &format!("approved proposal {}", proposal.proposal_id), - )?; - if let Some(state) = self.automations_manager.as_mut() - && let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == proposal.proposal_id) - { - item.proposal = proposal.clone(); - select_automation_manager_row_by_key( - state, - &AutomationManagerRowKey::Review(proposal.proposal_id.clone()), - ); - } - self.start_rocmd_command_with_kind( - "Review", - args, - RunningJobKind::Proposal { - proposal_id: proposal.proposal_id, - }, - ); - self.status = "Review approved; working on it.".to_owned(); - Ok(()) - } - - fn finish_proposal_execution(&mut self, proposal_id: &str, ok: bool) -> Result<()> { - let final_status = if ok { "completed" } else { "failed" }; - let updated = update_automation_proposal_status(&self.paths, proposal_id, final_status)?; - record_proposal_audit( - &self.paths, - &updated, - if ok { - "proposal_completed" - } else { - "proposal_failed" - }, - if ok { "info" } else { "error" }, - &format!( - "proposal {} {}", - updated.proposal_id, - if ok { "completed" } else { "failed" } - ), - )?; - let outcome = format!( - "review request: {}\nstatus: {}", - updated.proposal_id, updated.status - ); - if let Some(state) = self.automations_manager.as_mut() - && let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == updated.proposal_id) - { - item.proposal = updated.clone(); - select_automation_manager_row_by_key( - state, - &AutomationManagerRowKey::Review(updated.proposal_id.clone()), - ); - } - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(outcome); - } else if let Some(state) = self.command_screen.as_mut() { - state.message = Some(outcome); - } else { - self.push_block("Outcome", &outcome); - } - self.status = if ok { - "Review request approved and completed.".to_owned() - } else { - "Review request approved but execution failed.".to_owned() - }; - Ok(()) - } - - fn reject_proposal(&mut self, proposal_id: &str) { - match self.reject_proposal_result(proposal_id) { - Ok(()) => {} - Err(error) => { - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(format!("Review rejection failed.\n\n{error}")); - } else if let Some(state) = self.command_screen.as_mut() { - state.message = Some(format!("Review rejection failed.\n\n{error}")); - } else { - self.push_block("Error", &error.to_string()); - } - self.status = "Review rejection failed.".to_owned(); - } - } - } - - fn reject_proposal_result(&mut self, proposal_id: &str) -> Result<()> { - let proposal = update_automation_proposal_status(&self.paths, proposal_id, "rejected")?; - record_proposal_audit( - &self.paths, - &proposal, - "proposal_rejected", - "info", - &format!("rejected proposal {}", proposal.proposal_id), - )?; - let outcome = format!("review request: {}\nstatus: rejected", proposal.proposal_id); - if let Some(state) = self.automations_manager.as_mut() - && let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == proposal.proposal_id) - { - item.proposal = proposal.clone(); - select_automation_manager_row_by_key( - state, - &AutomationManagerRowKey::Review(proposal.proposal_id.clone()), - ); - } - if let Some(state) = self.automations_manager.as_mut() { - state.message = Some(outcome); - } else if let Some(state) = self.command_screen.as_mut() { - state.message = Some(outcome); - } else { - self.push_block("Outcome", &outcome); - } - self.status = "Review request rejected.".to_owned(); - self.record_activity(format!("review request rejected: {}", proposal.proposal_id)); - Ok(()) - } - - fn open_proposal_edit_guidance(&mut self, args: &[&str]) -> Result<()> { - let Some(proposal_id) = args.first() else { - bail!( - "Choose a review request first.\n\nOpen Automations, select a review, then use the editable rows on that screen." - ); - }; - let proposal = find_automation_proposal(&self.paths, proposal_id)?; - if self.automations_manager.is_none() { - self.open_automations_manager(); - } - let field = args.get(1).copied(); - let target_key = field - .and_then(automation_prefetch_field_from_edit_name) - .filter(|_| is_pending_prefetch_proposal(&proposal)) - .map_or_else( - || AutomationManagerRowKey::Review(proposal.proposal_id.clone()), - |field| AutomationManagerRowKey::PrefetchField(proposal.proposal_id.clone(), field), - ); - if let Some(state) = self.automations_manager.as_mut() { - if let Some(item) = state - .items - .iter_mut() - .find(|item| item.proposal.proposal_id == proposal.proposal_id) - { - item.proposal = proposal.clone(); - } - select_automation_manager_row_by_key(state, &target_key); - state.detail_scroll = 0; - state.message = Some(proposal_edit_guidance_message(&proposal, field)); - } - self.pending_approval = None; - self.status = "Review opened. Use the highlighted rows to edit.".to_owned(); - Ok(()) - } -} - -enum LogsCommandTarget { - Browser { query: Option }, - Follow { query: Option }, - Service(String), - Page(LogPageDirection), - StopFollow, -} - -fn logs_target_from_args(paths: &AppPaths, args: &[&str]) -> Result { - match args { - [] => Ok(LogsCommandTarget::Browser { query: None }), - ["follow"] => Ok(LogsCommandTarget::Follow { query: None }), - ["follow", query @ ..] => Ok(LogsCommandTarget::Follow { - query: Some(query.join(" ")), - }), - ["stop" | "unfollow"] => Ok(LogsCommandTarget::StopFollow), - ["next"] => Ok(LogsCommandTarget::Page(LogPageDirection::Next)), - ["prev" | "previous"] => Ok(LogsCommandTarget::Page(LogPageDirection::Previous)), - ["refresh"] => Ok(LogsCommandTarget::Page(LogPageDirection::Refresh)), - ["--service" | "--service-id"] => { - bail!( - "Choose a service before opening service logs.\n\nUse /services to see managed servers, or open /logs and choose from the list." - ) - } - ["--service" | "--service-id", service_id] => { - Ok(LogsCommandTarget::Service((*service_id).to_owned())) - } - ["--service" | "--service-id", _, extra, ..] => { - bail!("unexpected /logs service argument `{extra}`") - } - ["--search"] => { - bail!( - "Type a search term after --search.\n\nYou can also open /logs and use the search choices there." - ) - } - ["--search", query @ ..] => Ok(LogsCommandTarget::Browser { - query: Some(query.join(" ")), - }), - [flag, ..] if flag.starts_with("--") => bail!( - "I do not recognize logs option `{flag}`.\n\nOpen /logs and use the choices on that screen." - ), - [candidate] if existing_service_manifest(paths, candidate) => { - Ok(LogsCommandTarget::Service((*candidate).to_owned())) - } - query => Ok(LogsCommandTarget::Browser { - query: Some(query.join(" ")), - }), - } -} - -fn existing_service_manifest(paths: &AppPaths, service_id: &str) -> bool { - !service_id.contains('/') - && !service_id.contains('\\') - && paths.service_manifest_path(service_id).is_file() -} - -fn split_command_line(command: &str) -> Result, String> { - let mut parts = Vec::new(); - let mut current = String::new(); - let mut quote: Option = None; - let mut chars = command.chars().peekable(); - - while let Some(ch) = chars.next() { - match quote { - Some(active) if ch == '\\' => { - if matches!(chars.peek(), Some(next) if *next == active || *next == '\\') { - if let Some(next) = chars.next() { - current.push(next); - } - } else { - current.push(ch); - } - } - Some(active) if ch == active => quote = None, - Some(_) => current.push(ch), - None if ch == '\'' || ch == '"' => quote = Some(ch), - None if ch.is_whitespace() => { - if !current.is_empty() { - parts.push(std::mem::take(&mut current)); - } - } - None => current.push(ch), - } - } - - if let Some(active) = quote { - return Err(format!("unterminated {active} quote")); - } - if !current.is_empty() { - parts.push(current); - } - Ok(parts) -} - -fn should_render_plain_input_as_plan(input: &str) -> bool { - let lower = input.trim().to_ascii_lowercase(); - lower == "serve" - || lower.starts_with("serve ") - || lower.starts_with("run ") - || lower.starts_with("install ") - || lower.starts_with("update ") - || lower.starts_with("upgrade ") - || lower.starts_with("uninstall ") - || lower.starts_with("remove ") - || lower.contains("local model") - || lower.contains("therock") - || is_plain_inspection_request(&lower) -} - -fn any_substring(haystack: &str, needles: &[&str]) -> bool { - needles.iter().any(|needle| haystack.contains(needle)) -} - -fn plain_input_should_use_local_assistant(input: &str) -> bool { - !input.trim().is_empty() -} - -fn chat_prompt_is_how_to_only(lower: &str) -> bool { - any_substring( - lower, - &[ - "how do i", - "how to", - "how can i", - "what do i need", - "what should i do", - ], - ) && !any_substring( - lower, - &[ - "for me", - "can you", - "please", - "do it", - "go ahead", - "install now", - "start now", - "launch now", - ], - ) -} - -fn is_plain_local_assistant_support_request(input: &str) -> bool { - let lower = input.trim().to_ascii_lowercase(); - if lower.starts_with("chat ") || lower.starts_with("serve ") { - return false; - } - let asks_rocm_setup = lower.contains("therock") - || lower.contains("rocm") - || lower.contains("comfyui") - || lower.contains("comfy ui"); - let asks_machine = lower.contains("which gpu") - || lower.contains("what gpu") - || lower.contains("gpu is") - || lower.contains("this machine") - || lower.contains("my machine"); - let asks_llm_support = (lower.contains("llm") || lower.contains("model")) - && (lower.contains("support") - || lower.contains("serve") - || lower.contains("setup") - || lower.contains("run")); - let asks_helpfully = lower.contains("how do i") - || lower.contains("can you") - || lower.contains("is ") - || lower.contains("which ") - || lower.contains("what "); - asks_helpfully && (asks_rocm_setup || asks_machine || asks_llm_support) -} - -fn should_parse_plain_input_as_command(input: &str) -> bool { - let Ok(parts) = split_command_line(input.trim()) else { - return false; - }; - let Some(head) = parts.first().map(String::as_str) else { - return false; - }; - let args = parts.iter().skip(1).map(String::as_str).collect::>(); - match head { - "home" | "help" | "?" | "examine" | "setup" | "permissions" | "runtimes" | "engine" - | "model" | "plan" | "config" | "automations" | "reviews" | "proposals" | "approve" - | "reject" | "edit" | "services" | "logs" | "gpu" | "daemon" | "chat" | "provider" - | "clear" | "quit" | "exit" => true, - "install" => plain_install_input_is_command(&args), - "update" => args.is_empty() || args.first().is_some_and(|arg| arg.starts_with("--")), - "uninstall" => { - args.is_empty() - || args - .first() - .is_some_and(|arg| *arg == "apply" || arg.starts_with("--")) - } - "serve" => plain_serve_input_is_command(&args), - _ => false, - } -} - -fn plain_install_input_is_command(args: &[&str]) -> bool { - args.is_empty() - || args - .first() - .is_some_and(|arg| matches!(*arg, "sdk" | "driver") || arg.starts_with("--")) -} - -#[allow(clippy::case_sensitive_file_extension_comparisons)] // `.gguf` heuristic on raw CLI args; behavior intentionally exact -fn plain_serve_input_is_command(args: &[&str]) -> bool { - args.iter().any(|arg| arg.starts_with("--")) - || args - .iter() - .any(|arg| arg.ends_with(".gguf") || arg.contains('/') || arg.contains('\\')) -} - -fn is_plain_casual_input(input: &str) -> bool { - let lower = input.trim().to_ascii_lowercase(); - let words = lower - .split(|ch: char| !ch.is_ascii_alphanumeric()) - .filter(|word| !word.is_empty()) - .collect::>(); - matches!( - words.as_slice(), - [word] - if matches!( - *word, - "hi" | "hey" | "hello" | "yo" | "howdy" | "thanks" | "thank" | "ok" | "okay" | "cool" - ) - ) || matches!( - words.as_slice(), - [first, second] - if matches!(*first, "hi" | "hey" | "hello") - && matches!(*second, "there" | "rocm") - ) || matches!( - words.as_slice(), - ["thank", "you"] | ["good", "morning" | "afternoon" | "evening"] - ) || matches!( - words.as_slice(), - ["can", "you", "hear", "me"] | ["are", "you", "there"] | ["testing" | "test"] - ) -} - -fn is_plain_inspection_request(lower: &str) -> bool { - let inspectish = lower - .split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '.') - .any(|word| matches!(word, "inspect" | "check" | "status" | "examine")) - || lower.contains("what is installed") - || lower.contains("what's installed") - || lower.contains("is installed"); - let target = lower.contains("rocm") - || lower.contains("gpu") - || lower.contains("driver") - || lower.contains("setup") - || lower.contains("installed") - || lower.contains("this computer") - || lower.contains("this machine"); - inspectish && target -} - -fn parse_serve_command_args(args: &[&str]) -> Result { - if args.is_empty() { - bail!(serve_usage_text()); - } - - let mut plan = ServeCommandPlan::default(); - let mut model_tokens = Vec::new(); - let mut used_with_engine = false; - let mut index = 0; - while index < args.len() { - let token = args[index]; - if token.eq_ignore_ascii_case("with") && plan.engine.is_none() { - let engine = args - .get(index + 1) - .filter(|value| !value.is_empty() && !value.starts_with("--")) - .with_context(|| "`with` must be followed by an engine name")?; - plan.engine = Some((*engine).to_owned()); - used_with_engine = true; - index += 2; - continue; - } - - let (option_name, inline_value) = split_inline_option(token); - match option_name { - "--engine" => { - plan.engine = Some(option_argument_value( - option_name, - inline_value, - args, - &mut index, - )?); - } - "--device" => { - plan.device = Some(option_argument_value( - option_name, - inline_value, - args, - &mut index, - )?); - } - "--runtime-id" => { - plan.runtime_id = Some(option_argument_value( - option_name, - inline_value, - args, - &mut index, - )?); - } - "--env-id" => { - plan.env_id = Some(option_argument_value( - option_name, - inline_value, - args, - &mut index, - )?); - } - "--host" => { - plan.host = Some(option_argument_value( - option_name, - inline_value, - args, - &mut index, - )?); - } - "--port" => { - let value = option_argument_value(option_name, inline_value, args, &mut index)?; - plan.port = Some( - value - .parse::() - .with_context(|| format!("invalid /serve --port `{value}`"))?, - ); - } - "--foreground" => { - flag_without_value(option_name, inline_value)?; - plan.foreground = true; - index += 1; - } - "--managed" => { - flag_without_value(option_name, inline_value)?; - plan.managed = true; - index += 1; - } - "--allow-public-bind" => { - flag_without_value(option_name, inline_value)?; - plan.allow_public_bind = true; - index += 1; - } - unknown if unknown.starts_with("--") => { - bail!( - "unknown /serve option `{unknown}`\n\n{}", - serve_usage_text() - ); - } - _ => { - if used_with_engine { - bail!( - "unexpected /serve argument `{token}`\n\n{}", - serve_usage_text() - ); - } - model_tokens.push(token.to_owned()); - index += 1; - } - } - } - - if !model_tokens.is_empty() { - plan.model = Some(model_tokens.join(" ")); - } - if plan.model.is_none() { - bail!(serve_usage_text()); - } - if let Some(device) = plan.device.as_deref() { - match device { - "gpu" => plan.device = Some("gpu_required".to_owned()), - "gpu_required" => {} - "gpu_preferred" => { - bail!( - "ROCm CLI serves with GPU required. `gpu_preferred` is not offered because no CPU fallback is used." - ); - } - "cpu" | "cpu_only" => { - bail!( - "ROCm CLI serves local models with ROCm GPU required. CPU mode is not offered here." - ); - } - other => bail!("unsupported /serve --device `{other}`"), - } - } - if plan.foreground && plan.managed { - bail!("/serve --foreground and --managed conflict; choose one launch mode"); - } - if plan.runtime_id.is_some() && plan.env_id.is_some() { - bail!("/serve --runtime-id and --env-id conflict; choose one runtime selector"); - } - - Ok(plan) -} - -fn split_inline_option(token: &str) -> (&str, Option<&str>) { - if let Some((name, value)) = token.split_once('=') { - (name, Some(value)) - } else { - (token, None) - } -} - -fn option_argument_value( - option_name: &str, - inline_value: Option<&str>, - args: &[&str], - index: &mut usize, -) -> Result { - if let Some(value) = inline_value { - if value.is_empty() { - bail!("{option_name} requires a value"); - } - *index += 1; - return Ok(value.to_owned()); - } - let value = args - .get(*index + 1) - .filter(|value| !value.is_empty() && !value.starts_with("--")) - .with_context(|| format!("{option_name} requires a value"))?; - *index += 2; - Ok((*value).to_owned()) -} - -fn flag_without_value(option_name: &str, inline_value: Option<&str>) -> Result<()> { - if inline_value.is_some() { - bail!("{option_name} does not accept a value"); - } - Ok(()) -} - -const fn serve_usage_text() -> &'static str { - "Usage: /serve [--engine ] [--device gpu|gpu_required] [--managed]\n\nExamples:\n /serve qwen with lemonade --managed\n /serve qwen --engine lemonade --device gpu_required" -} - -#[cfg(test)] -fn render_serve_command_plan(plan: &ServeCommandPlan, config: &RocmCliConfig) -> String { - let model = plan.model.as_deref().unwrap_or(""); - let engine = plan - .engine - .as_deref() - .or(config.default_engine.as_deref()) - .unwrap_or(default_engine_for_platform()); - let host = plan.host.as_deref().unwrap_or(DEFAULT_LOCAL_HOST); - let port = plan.port.unwrap_or(DEFAULT_LOCAL_PORT); - let mode = if plan.managed { - "managed (rocmd supervision)" - } else { - "foreground CLI" - }; - let command = format_command_for_display("rocm", &plan.cli_args()); - - let mut output = String::new(); - let _ = writeln!(output, "serve plan"); - let _ = writeln!(output, " requested model: {model}"); - let _ = writeln!(output, " engine: {engine}"); - let _ = writeln!( - output, - " device_policy: {}", - serve_plan_device_policy(plan.device.as_deref()) - ); - if let Some(runtime_id) = plan.runtime_id.as_deref() { - let _ = writeln!(output, " runtime_id: {runtime_id}"); - } - if let Some(env_id) = plan.env_id.as_deref() { - let _ = writeln!(output, " env_id: {env_id}"); - } - let _ = writeln!(output, " host: {host}"); - let _ = writeln!(output, " port: {port}"); - let _ = writeln!(output, " mode: {mode}"); - let _ = writeln!(output, " command: {command}"); - if engine == "llama.cpp" { - let _ = writeln!( - output, - " llama.cpp: expects a local GGUF path or a llama-server-compatible model reference" - ); - let _ = writeln!( - output, - " llama.cpp: set ROCM_CLI_LLAMA_CPP_SERVER if llama-server is not on PATH" - ); - } - if plan.managed { - let _ = writeln!(output); - let _ = writeln!( - output, - "Approve to launch the supervised service. Use /services and /logs after it starts." - ); - } else { - let _ = writeln!(output); - let _ = writeln!( - output, - "Foreground servers are not launched inside this app. Add --managed to launch here, or run the command in another shell." - ); - } - output.trim_end().to_owned() -} - -fn plain_serve_parse_error(error: &str) -> String { - let lower = error.to_ascii_lowercase(); - if lower.contains("unknown /serve option") { - let first = error - .lines() - .next() - .unwrap_or("That serve option was not recognized."); - return format!("{first}\n\nUse /serve without extra text for guided setup."); - } - if lower.contains("cpu mode") - || lower.contains("cpu fallback") - || lower.contains("gpu required") - { - let first = error - .lines() - .next() - .unwrap_or("ROCm CLI serves local models with ROCm GPU required."); - return format!("{first}\n\nUse /serve for the guided GPU setup."); - } - if lower.contains("requires a value") || lower.contains("must be followed") { - let first = error - .lines() - .next() - .unwrap_or("A serve option needs a value."); - return format!("{first}\n\nUse /serve without extra text for guided setup."); - } - if lower.contains("conflict") { - return format!("{error}\n\nChoose one setting, then try again."); - } - "Choose a model before starting.\n\nUse /serve without extra text for guided setup.".to_owned() -} - -fn plain_update_parse_error(error: &str) -> String { - let lower = error.to_ascii_lowercase(); - if lower.contains("missing value") || lower.contains("requires a value") { - return "Choose a ROCm install before applying an update.\n\nUse the Update screen actions or open ROCm installs to pick one." - .to_owned(); - } - if lower.contains("unknown") { - return "I do not recognize that update option.\n\nUse the choices on the left to refresh, preview, or apply an update." - .to_owned(); - } - "The update request could not be understood.\n\nUse the choices on the left to refresh, preview, or apply an update." - .to_owned() -} - -fn render_serve_wizard_review(plan: &ServeCommandPlan, config: &RocmCliConfig) -> String { - let model = plan.model.as_deref().unwrap_or(""); - let engine = plan.selected_engine(config); - let host = plan.host.as_deref().unwrap_or(DEFAULT_LOCAL_HOST); - let port = plan.port.unwrap_or(DEFAULT_LOCAL_PORT); - - let mut output = String::new(); - let _ = writeln!(output, "Start local model"); - let _ = writeln!(output, " model: {model}"); - let _ = writeln!(output, " engine: {engine}"); - if plan.engine_manages_own_runtime(config) { - let _ = writeln!(output, " runner: Lemonade manages its own ROCm runtime"); - } else if let Some(runtime_id) = plan.runtime_id.as_deref() { - let _ = writeln!(output, " ROCm install: {runtime_id}"); - } - if let Some(env_id) = plan.env_id.as_deref() { - let _ = writeln!(output, " Engine env: {env_id}"); - } - let _ = writeln!( - output, - " device: {}", - serve_wizard_device_label(serve_plan_device_policy(plan.device.as_deref())) - ); - let _ = writeln!(output, " address: {}", format_host_port(host, port)); - let _ = writeln!( - output, - " mode: {}", - if plan.managed { - "managed service" - } else { - "show command only" - } - ); - let _ = writeln!(output); - if plan.managed { - let _ = writeln!(output, "Approve to start it on this computer."); - let _ = writeln!(output, "First start may download model files."); - } else { - let _ = writeln!( - output, - "This mode only shows the plan. Switch mode to managed to launch here." - ); - } - output.trim_end().to_owned() -} - -fn push_optional_cli_arg(args: &mut Vec, name: &str, value: Option<&str>) { - if let Some(value) = value { - args.push(name.to_owned()); - args.push(value.to_owned()); - } -} - -#[derive(Debug, Clone, Deserialize)] -struct OnboardingRuntime { - runtime_id: String, - #[serde(default)] - version: Option, - #[serde(default)] - install_root: Option, - #[serde(default)] - python_executable: Option, - #[serde(default)] - rocm_sdk: Option, - #[serde(default)] - runtime_key: Option, - #[serde(default)] - installed_at_unix_ms: u128, -} - -#[derive(Debug, Clone, Deserialize)] -struct OnboardingRocmSdk { - #[serde(default)] - import_ok: bool, - #[serde(default)] - root_path: Option, - #[serde(default)] - bin_path: Option, -} - -#[derive(Debug, Clone)] -enum OnboardingAction { - InstallSdk, - Finish, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum OnboardingMenuChoice { - Channel, - Folder, - Primary, - Reinstall, - Uninstall, - ShowLog, - Back, - Quit, -} - -fn load_onboarding_runtimes(paths: &AppPaths) -> Vec { - let registry_dir = paths.data_dir.join("runtimes").join("registry"); - let Ok(entries) = fs::read_dir(®istry_dir) else { - return Vec::new(); - }; - let mut runtimes = Vec::new(); - for entry in entries.flatten() { - let path = entry.path(); - if path.extension().and_then(|value| value.to_str()) != Some("json") { - continue; - } - let Ok(bytes) = fs::read(&path) else { - continue; - }; - let Ok(runtime) = serde_json::from_slice::(&bytes) else { - continue; - }; - if runtime.runtime_id.trim().is_empty() { - continue; - } - runtimes.push(runtime); - } - runtimes.sort_by_key(|runtime| std::cmp::Reverse(runtime.installed_at_unix_ms)); - runtimes -} - -fn setup_venv_ready(paths: &AppPaths, config: &RocmCliConfig) -> bool { - if let Some(venv_path) = config.setup.therock_venv.as_ref() - && !folder_browser_is_internal_rocm_dir(paths, venv_path) - && setup_install_root_ready(venv_path) - { - return true; - } - load_onboarding_runtimes(paths) - .iter() - .any(runtime_install_root_ready) -} - -fn setup_runtime_ready_for_sidebar(paths: &AppPaths, config: &RocmCliConfig) -> bool { - if setup_venv_ready(paths, config) { - return true; - } - ready_serve_runtime_manifests(paths).is_ok_and(|runtimes| !runtimes.is_empty()) -} - -fn ready_serve_runtime_manifests( - paths: &AppPaths, -) -> Result> { - Ok(therock::load_runtime_manifests(paths)? - .into_iter() - .filter(|manifest| runtime_usability_status(manifest) == "ready") - .collect()) -} - -fn serve_runtime_preflight( - paths: &AppPaths, - config: &RocmCliConfig, -) -> Result { - let mut ready = ready_serve_runtime_manifests(paths)?; - if ready.is_empty() - && let Some(recovered_key) = recover_setup_runtime_registration(paths, config)? - { - ready = ready_serve_runtime_manifests(paths)? - .into_iter() - .filter(|manifest| manifest.runtime_key.eq_ignore_ascii_case(&recovered_key)) - .collect(); - } - if ready.is_empty() { - return Ok(ServeRuntimePreflight::NeedsInstall); - } - - if let Some(active_key) = config.active_runtime_key.as_deref() - && let Some(manifest) = ready - .iter() - .find(|manifest| manifest.runtime_key.eq_ignore_ascii_case(active_key)) - { - return Ok(ServeRuntimePreflight::UseRuntime( - manifest.runtime_key.clone(), - )); - } - - if let Some(default_runtime_id) = config.default_runtime_id.as_deref() { - let matches = ready - .iter() - .filter(|manifest| manifest.runtime_id.eq_ignore_ascii_case(default_runtime_id)) - .collect::>(); - match matches.as_slice() { - [manifest] => { - return Ok(ServeRuntimePreflight::UseRuntime( - manifest.runtime_key.clone(), - )); - } - [] => {} - _ => return Ok(ServeRuntimePreflight::NeedsChoice), - } - } - - match ready.as_slice() { - [manifest] => Ok(ServeRuntimePreflight::UseRuntime( - manifest.runtime_key.clone(), - )), - _ => Ok(ServeRuntimePreflight::NeedsChoice), - } -} - -fn recover_setup_runtime_registration( - paths: &AppPaths, - config: &RocmCliConfig, -) -> Result> { - let Some(runtime) = current_setup_runtime(paths, config) else { - return Ok(None); - }; - if !runtime_install_root_ready(&runtime) { - return Ok(None); - } - let runtime_key = runtime - .runtime_key - .clone() - .context("the setup ROCm install is missing its saved install id")?; - ensure_setup_runtime_registered(paths, &runtime).context( - "the setup ROCm install is present, but ROCm CLI could not add it back to ROCm Installs", - )?; - Ok(Some(runtime_key)) -} - -fn setup_has_current_install(paths: &AppPaths, config: &RocmCliConfig) -> bool { - current_setup_runtime(paths, config).is_some() -} - -fn latest_setup_runtime(paths: &AppPaths) -> Option { - load_onboarding_runtimes(paths) - .into_iter() - .find(runtime_install_root_ready) -} - -fn current_setup_runtime(paths: &AppPaths, config: &RocmCliConfig) -> Option { - let configured_root = config - .setup - .therock_venv - .as_ref() - .filter(|path| !folder_browser_is_internal_rocm_dir(paths, path)); - let active_key = config.active_runtime_key.as_deref(); - if let Some(configured_root) = configured_root - && let Some(mut runtime) = load_local_onboarding_runtime(configured_root) - { - if runtime.install_root.is_none() { - runtime.install_root = Some(configured_root.to_owned()); - } - return Some(runtime); - } - let mut fallback = None; - for runtime in load_onboarding_runtimes(paths) { - let root_matches = match (configured_root, runtime.install_root.as_ref()) { - (Some(configured_root), Some(install_root)) => { - paths_equivalent(configured_root, install_root) - } - _ => false, - }; - let active_matches = runtime - .runtime_key - .as_deref() - .is_some_and(|runtime_key| active_key == Some(runtime_key)); - if root_matches || active_matches { - return Some(runtime); - } - if fallback.is_none() && runtime_install_root_ready(&runtime) { - fallback = Some(runtime); - } - } - fallback -} - -fn ensure_setup_runtime_registered(paths: &AppPaths, runtime: &OnboardingRuntime) -> Result<()> { - let runtime_key = runtime - .runtime_key - .as_deref() - .context("this ROCm install is missing its saved install id")?; - let registry_path = setup_runtime_registry_path(paths, runtime_key); - if registry_path.is_file() { - return Ok(()); - } - - let install_root = runtime - .install_root - .as_ref() - .context("this ROCm install is missing its saved folder")?; - let local_manifest_path = install_root.join(".rocm-cli-runtime.json"); - let bytes = fs::read(&local_manifest_path) - .with_context(|| format!("failed to read {}", local_manifest_path.display()))?; - let manifest: therock::InstalledRuntimeManifest = serde_json::from_slice(&bytes) - .with_context(|| format!("failed to parse {}", local_manifest_path.display()))?; - if !manifest.runtime_key.eq_ignore_ascii_case(runtime_key) { - bail!("saved install id does not match the selected ROCm install"); - } - if !paths_equivalent(&manifest.install_root, install_root) { - bail!("saved install folder does not match the selected ROCm install"); - } - - let parent = registry_path - .parent() - .context("runtime registry path has no parent directory")?; - fs::create_dir_all(parent).with_context(|| format!("failed to create {}", parent.display()))?; - fs::write(®istry_path, bytes) - .with_context(|| format!("failed to write {}", registry_path.display()))?; - Ok(()) -} - -fn setup_runtime_registry_path(paths: &AppPaths, runtime_key: &str) -> PathBuf { - paths - .data_dir - .join("runtimes") - .join("registry") - .join(format!("{runtime_key}.json")) -} - -fn paths_equivalent(left: &Path, right: &Path) -> bool { - if left == right { - return true; - } - match (left.canonicalize(), right.canonicalize()) { - (Ok(left), Ok(right)) => left == right, - _ => false, - } -} - -fn folder_browser_is_internal_rocm_dir(paths: &AppPaths, path: &Path) -> bool { - let path = normalize_runtime_folder_path(path.to_path_buf()); - [ - "apps", - "audit", - "automations", - "cache", - "engines", - "launcher", - "logs", - "tools", - ] - .into_iter() - .map(|name| normalize_runtime_folder_path(paths.data_dir.join(name))) - .any(|internal| path_is_same_or_inside(&path, &internal)) -} - -fn default_setup_venv_path(paths: &AppPaths) -> PathBuf { - let config_dir = normalize_runtime_folder_path(paths.config_dir.clone()); - let data_dir = normalize_runtime_folder_path(paths.data_dir.clone()); - if data_dir == config_dir || path_is_same_or_inside(&data_dir, &config_dir) { - return config_dir - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .map_or_else( - || config_dir.join("rocm_venvs").join("default"), - |parent| parent.join("rocm_venvs").join("default"), - ); - } - paths.data_dir.join("envs").join("default") -} - -fn setup_install_root(paths: &AppPaths, config: &RocmCliConfig) -> PathBuf { - config - .setup - .therock_venv - .clone() - .filter(|path| !folder_browser_is_internal_rocm_dir(paths, path)) - .or_else(|| { - latest_setup_runtime(paths) - .and_then(|runtime| runtime.install_root) - .filter(|path| !folder_browser_is_internal_rocm_dir(paths, path)) - }) - .unwrap_or_else(|| default_setup_venv_path(paths)) -} - -fn onboarding_folder_presets(paths: &AppPaths, config: &RocmCliConfig) -> Vec { - let current = setup_install_root(paths, config); - install_folder_presets(paths, config, ¤t) -} - -fn install_folder_presets( - paths: &AppPaths, - config: &RocmCliConfig, - current: &Path, -) -> Vec { - let default = default_setup_venv_path(paths); - let mut presets = Vec::new(); - push_onboarding_folder_preset(&mut presets, current.to_path_buf()); - push_onboarding_folder_preset(&mut presets, setup_install_root(paths, config)); - push_onboarding_folder_preset(&mut presets, default.clone()); - if let Some(parent) = current - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - push_onboarding_folder_preset(&mut presets, parent.join("rocm_venvs")); - push_onboarding_folder_preset(&mut presets, parent.join("therock_venvs")); - push_onboarding_folder_preset(&mut presets, parent.join("rocm-cli-venvs")); - } - if let Some(parent) = default - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - push_onboarding_folder_preset(&mut presets, parent.join("rocm")); - push_onboarding_folder_preset(&mut presets, parent.join("therock")); - } - presets -} - -fn push_onboarding_folder_preset(presets: &mut Vec, path: PathBuf) { - let key = onboarding_folder_preset_key(&path); - if presets - .iter() - .any(|existing| onboarding_folder_preset_key(existing) == key) - { - return; - } - presets.push(path); -} - -fn folder_browser_existing_dir(path: PathBuf) -> PathBuf { - let path = normalize_runtime_folder_path(path); - if path.is_dir() { - return path; - } - let mut candidate = path.as_path(); - while let Some(parent) = candidate.parent() { - if parent.is_dir() { - return parent.to_path_buf(); - } - candidate = parent; - } - std::env::var_os("HOME") - .or_else(|| std::env::var_os("USERPROFILE")) - .map(PathBuf::from) - .map(normalize_runtime_folder_path) - .filter(|path| path.is_dir()) - .or_else(|| { - std::env::current_dir() - .ok() - .map(normalize_runtime_folder_path) - .filter(|path| path.is_dir()) - }) - .unwrap_or_else(|| PathBuf::from(if runtime_is_windows() { "C:/" } else { "/" })) -} - -fn folder_browser_entries(current_dir: &Path) -> (Vec, Option) { - let current_dir = normalize_runtime_folder_path(current_dir.to_path_buf()); - let mut entries = vec![FolderBrowserEntry { - label: "Use current folder".to_owned(), - path: current_dir.clone(), - kind: FolderBrowserEntryKind::UseCurrent, - }]; - entries.push(FolderBrowserEntry { - label: "+ therock_venvs".to_owned(), - path: normalize_runtime_folder_path(current_dir.join("therock_venvs")), - kind: FolderBrowserEntryKind::NewChild, - }); - entries.push(FolderBrowserEntry { - label: "+ rocm_venvs".to_owned(), - path: normalize_runtime_folder_path(current_dir.join("rocm_venvs")), - kind: FolderBrowserEntryKind::NewChild, - }); - if !windows_drive_path_is_root(¤t_dir) - && let Some(parent) = current_dir - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - { - entries.push(FolderBrowserEntry { - label: "..".to_owned(), - path: normalize_runtime_folder_path(parent.to_path_buf()), - kind: FolderBrowserEntryKind::Parent, - }); - } - for drive in folder_browser_drive_roots() { - if onboarding_folder_preset_key(&drive) == onboarding_folder_preset_key(¤t_dir) { - continue; - } - entries.push(FolderBrowserEntry { - label: display_runtime_folder_path(&drive), - path: drive, - kind: FolderBrowserEntryKind::Drive, - }); - } - - let mut message = None; - match fs::read_dir(¤t_dir) { - Ok(read_dir) => { - let mut dirs = read_dir - .filter_map(std::result::Result::ok) - .map(|entry| entry.path()) - .filter(|path| path.is_dir()) - .filter(|path| folder_browser_should_show_directory(¤t_dir, path)) - .collect::>(); - dirs.sort_by_key(|path| folder_browser_sort_key(path)); - for path in dirs { - let path = normalize_runtime_folder_path(path); - entries.push(FolderBrowserEntry { - label: folder_browser_directory_label(&path), - path, - kind: FolderBrowserEntryKind::Directory, - }); - } - } - Err(error) => { - message = Some(format!("I could not read this folder: {error}")); - } - } - (entries, message) -} - -fn folder_browser_should_show_directory(current_dir: &Path, path: &Path) -> bool { - if current_dir - .file_name() - .is_some_and(|name| name.eq_ignore_ascii_case(".rocm")) - { - let hidden = [ - "apps", - "audit", - "automations", - "cache", - "engines", - "launcher", - "logs", - "runtimes", - "tools", - ]; - if let Some(name) = path.file_name().and_then(|name| name.to_str()) - && hidden - .iter() - .any(|candidate| name.eq_ignore_ascii_case(candidate)) - { - return false; - } - } - true -} - -fn folder_browser_drive_roots() -> Vec { - runtime_drive_roots() -} - -fn folder_browser_drive_root_for_key(ch: char) -> Option { - runtime_drive_root_for_key(ch) -} - -fn folder_browser_directory_label(path: &Path) -> String { - runtime_directory_label(path) -} - -fn folder_browser_sort_key(path: &Path) -> String { - runtime_path_sort_key(path) -} - -fn folder_browser_selected_status(state: &FolderBrowserState) -> String { - match state.entries.get(state.selected).map(|entry| &entry.kind) { - Some(FolderBrowserEntryKind::UseCurrent | FolderBrowserEntryKind::NewChild) => { - "Enter selects this folder.".to_owned() - } - Some(FolderBrowserEntryKind::Parent) => "Enter opens the parent folder.".to_owned(), - Some(FolderBrowserEntryKind::Drive | FolderBrowserEntryKind::Directory) => { - "Enter opens this folder.".to_owned() - } - None => "Choose a folder.".to_owned(), - } -} - -fn wrap_plain_line(value: &str, width: usize) -> Vec { - let width = width.max(8); - if value.chars().count() <= width { - return vec![value.to_owned()]; - } - let mut lines = Vec::new(); - let mut current = String::new(); - for word in value.split_whitespace() { - let current_len = current.chars().count(); - let word_len = word.chars().count(); - if current_len > 0 && current_len + 1 + word_len > width { - lines.push(current); - current = String::new(); - } - if word_len > width { - if !current.is_empty() { - lines.push(current); - current = String::new(); - } - let mut chunk = String::new(); - for ch in word.chars() { - if chunk.chars().count() >= width { - lines.push(chunk); - chunk = String::new(); - } - chunk.push(ch); - } - if !chunk.is_empty() { - current = chunk; - } - continue; - } - if !current.is_empty() { - current.push(' '); - } - current.push_str(word); - } - if !current.is_empty() { - lines.push(current); - } - if lines.is_empty() { - vec![value.to_owned()] - } else { - lines - } -} - -fn onboarding_folder_preset_key(path: &Path) -> String { - let value = normalize_runtime_folder_path(path.to_path_buf()) - .display() - .to_string(); - if runtime_is_windows() { - value - .replace('/', "\\") - .trim_end_matches('\\') - .to_ascii_lowercase() - } else { - value.trim_end_matches('/').to_owned() - } -} - -fn validate_onboarding_install_folder(input: &str) -> Result { - let value = input - .trim() - .trim_matches(|ch| matches!(ch, '"' | '\'')) - .trim(); - if value.is_empty() { - bail!("Folder cannot be empty."); - } - - let path = normalize_runtime_folder_path(PathBuf::from(value)); - if !runtime_folder_path_is_absolute(&path) { - bail!("Use a full folder path."); - } - if onboarding_path_is_system_folder(&path) { - bail!("Choose a normal folder you own. System folders cannot be used."); - } - if path.exists() { - if !path.is_dir() { - bail!("That path is a file. Choose a folder."); - } - ensure_onboarding_folder_writable(&path)?; - return Ok(path); - } - - let Some(parent) = path - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - else { - bail!("Choose a folder inside a folder you own."); - }; - if !parent.exists() { - bail!("The parent folder does not exist: {}", parent.display()); - } - if !parent.is_dir() { - bail!("The parent path is not a folder: {}", parent.display()); - } - if onboarding_path_is_system_folder(parent) { - bail!("Choose a normal folder you own. System folders cannot be used."); - } - ensure_onboarding_folder_writable(parent)?; - Ok(path) -} - -fn normalize_runtime_folder_path(path: PathBuf) -> PathBuf { - rocm_core::normalize_runtime_path_for_host(&path) -} - -fn display_runtime_folder_path(path: &Path) -> String { - rocm_core::normalize_runtime_path_text_for_storage(&path.display().to_string()) -} - -fn runtime_folder_path_is_absolute(path: &Path) -> bool { - rocm_core::runtime_path_text_is_absolute_for_host(&path.display().to_string()) -} - -fn windows_drive_path_is_absolute_text(value: &str) -> bool { - let bytes = value.as_bytes(); - bytes.len() >= 3 - && bytes[0].is_ascii_alphabetic() - && bytes[1] == b':' - && matches!(bytes[2], b'/' | b'\\') -} - -fn windows_drive_path_is_root(path: &Path) -> bool { - if !runtime_is_windows() { - return false; - } - let value = rocm_core::normalize_runtime_path_text_for_host(&path.display().to_string()); - windows_drive_path_is_absolute_text(&value) && value[3..].is_empty() -} - -fn ensure_onboarding_folder_writable(dir: &Path) -> Result<()> { - let probe_path = dir.join(format!( - ".rocm-cli-write-test-{}", - rocm_core::unix_time_millis() - )); - match fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&probe_path) - { - Ok(_) => { - let _ = fs::remove_file(&probe_path); - Ok(()) - } - Err(_) => bail!( - "rocm-cli cannot write to {}. Choose a folder you own.", - dir.display() - ), - } -} - -fn onboarding_path_is_system_folder(path: &Path) -> bool { - system_folder_candidates() - .iter() - .any(|candidate| path_is_same_or_inside(path, candidate)) -} - -fn system_folder_candidates() -> Vec { - let mut candidates = Vec::new(); - if runtime_is_windows() { - for key in [ - "SystemRoot", - "WINDIR", - "ProgramFiles", - "ProgramFiles(x86)", - "ProgramData", - ] { - if let Some(value) = std::env::var_os(key) { - candidates.push(PathBuf::from(value)); - } - } - candidates.push(PathBuf::from(r"C:\Windows")); - candidates.push(PathBuf::from(r"C:\Program Files")); - candidates.push(PathBuf::from(r"C:\Program Files (x86)")); - candidates.push(PathBuf::from(r"C:\ProgramData")); - } else { - candidates.extend([ - PathBuf::from("/bin"), - PathBuf::from("/etc"), - PathBuf::from("/lib"), - PathBuf::from("/sbin"), - PathBuf::from("/usr"), - PathBuf::from("/System"), - PathBuf::from("/Library"), - ]); - } - candidates -} - -fn path_is_same_or_inside(path: &Path, base: &Path) -> bool { - runtime_path_is_same_or_inside(path, base) -} - -fn setup_pip_cache_dir(paths: &AppPaths, config: &RocmCliConfig) -> PathBuf { - managed_pip_cache_dir(&setup_install_root(paths, config)) -} - -fn venv_python_path(venv_path: &std::path::Path) -> PathBuf { - rocm_core::runtime_python_executable_in_env(venv_path) -} - -fn quote_tui_arg(value: &str) -> String { - if value - .chars() - .any(|ch| ch.is_whitespace() || matches!(ch, '"' | '&' | ';' | '|')) - { - format!("\"{}\"", value.replace('"', "\\\"")) - } else { - value.to_owned() - } -} - -fn normalize_runtime_path_input(input: &str) -> String { - input - .trim() - .trim_matches(|ch| matches!(ch, '"' | '\'')) - .trim() - .to_owned() -} - -fn validate_runtime_manifest_path(input: &str) -> Result { - if input.trim().is_empty() { - bail!("Choose a saved ROCm install file first."); - } - let path = PathBuf::from(input); - if !path.is_file() { - bail!("Saved ROCm install file is missing: {}", path.display()); - } - Ok(path) -} - -fn validate_runtime_python_path(input: &str) -> Result { - if input.trim().is_empty() { - bail!("Choose a Python folder first."); - } - let path = PathBuf::from(input); - if path.is_file() { - return Ok(path); - } - if path.is_dir() { - let python = venv_python_path(&path); - if python.is_file() { - return Ok(path); - } - bail!("Python executable is missing inside {}", path.display()); - } - bail!("Python folder is missing: {}", path.display()); -} - -fn validate_serve_local_model_path(input: &str) -> Result { - if input.trim().is_empty() { - bail!("Choose a local model file or folder first."); - } - let path = PathBuf::from(input); - if path.is_file() || path.is_dir() { - return Ok(path); - } - bail!("That model file or folder was not found."); -} - -fn validate_serve_wizard_host(input: &str) -> Result { - let host = input.trim(); - if host.is_empty() { - bail!("Use 127.0.0.1 or localhost so the server stays on this computer."); - } - if matches!(host, "127.0.0.1" | "localhost" | "::1") { - return Ok(host.to_owned()); - } - bail!( - "The wizard only serves on this computer. Use 127.0.0.1 or localhost. Public network serving needs an explicit advanced command." - ); -} - -fn validate_serve_wizard_port(input: &str) -> Result { - let value = input.trim(); - let port = value - .parse::() - .with_context(|| "Use a port number from 1 to 65535.")?; - if port == 0 { - bail!("Use a port number from 1 to 65535."); - } - Ok(port) -} - -fn runtime_install_root_ready(runtime: &OnboardingRuntime) -> bool { - let Some(install_root) = runtime.install_root.as_ref() else { - return false; - }; - if runtime - .runtime_key - .as_deref() - .is_none_or(|runtime_key| runtime_key.trim().is_empty()) - { - return false; - } - if !install_root.is_dir() - || !runtime - .runtime_id - .to_ascii_lowercase() - .starts_with("therock-") - { - return false; - } - if !runtime_python_ready(runtime, install_root) { - return false; - } - runtime.rocm_sdk.as_ref().is_some_and(rocm_sdk_ready) -} - -fn setup_install_root_ready(install_root: &Path) -> bool { - if !install_root.is_dir() { - return false; - } - let Some(mut runtime) = load_local_onboarding_runtime(install_root) else { - return false; - }; - if runtime.install_root.is_none() { - runtime.install_root = Some(install_root.to_owned()); - } - runtime_install_root_ready(&runtime) -} - -fn load_local_onboarding_runtime(install_root: &Path) -> Option { - let bytes = fs::read(install_root.join(".rocm-cli-runtime.json")).ok()?; - serde_json::from_slice::(&bytes).ok() -} - -fn runtime_python_ready(runtime: &OnboardingRuntime, install_root: &Path) -> bool { - runtime - .python_executable - .as_ref() - .is_some_and(|path| path.is_file()) - || venv_python_path(install_root).is_file() -} - -fn rocm_sdk_ready(sdk: &OnboardingRocmSdk) -> bool { - sdk.import_ok - && sdk.root_path.as_ref().is_some_and(|path| path.is_dir()) - && sdk.bin_path.as_ref().is_some_and(|path| path.is_dir()) -} - -#[derive(Debug, Clone, Deserialize)] -struct OnboardingEngineEnvManifest { - env_path: PathBuf, - runtime_id: String, -} - -#[cfg(test)] -fn engine_runtime_ready(paths: &AppPaths, config: &RocmCliConfig, engine: &str) -> bool { - ready_engine_env_id(paths, config, engine).is_some() -} - -fn ready_engine_env_id(paths: &AppPaths, config: &RocmCliConfig, engine: &str) -> Option { - let runtime_selectors = onboarding_runtime_selectors(paths, config)?; - if engine == "pytorch" - && let Some(runtime_id) = ready_pytorch_runtime_id(paths, &runtime_selectors) - { - return Some(runtime_id); - } - let entry = config.engine_config(engine)?; - let mut env_ids = Vec::new(); - if let Some(env_id) = entry.preferred_env_id.as_ref() { - env_ids.push(env_id); - } - if let Some(env_id) = entry.last_installed_env_id.as_ref() - && !env_ids.contains(&env_id) - { - env_ids.push(env_id); - } - env_ids.into_iter().find_map(|env_id| { - load_onboarding_engine_env(paths, engine, env_id).and_then(|manifest| { - if runtime_selectors - .iter() - .any(|selector| selector == &manifest.runtime_id) - && manifest.env_path.is_dir() - { - Some(env_id.clone()) - } else { - None - } - }) - }) -} - -fn ready_pytorch_runtime_id(paths: &AppPaths, runtime_selectors: &[String]) -> Option { - load_onboarding_runtimes(paths) - .into_iter() - .filter(runtime_install_root_ready) - .find_map(|runtime| { - let runtime_key = runtime.runtime_key.as_deref(); - let selector_matches = runtime_selectors.iter().any(|selector| { - runtime_key.is_some_and(|key| selector == key) - || selector.eq_ignore_ascii_case(&runtime.runtime_id) - }); - selector_matches.then_some( - runtime - .runtime_key - .unwrap_or_else(|| runtime.runtime_id.clone()), - ) - }) -} - -fn onboarding_runtime_selectors(paths: &AppPaths, config: &RocmCliConfig) -> Option> { - if let Some(active_runtime_key) = config.active_runtime_key.as_deref() { - return Some(vec![active_runtime_key.to_owned()]); - } - let runtimes = load_onboarding_runtimes(paths); - let matches = ready_default_runtime_matches(config, &runtimes); - match matches.as_slice() { - [runtime] => { - let mut selectors = Vec::new(); - if let Some(runtime_key) = runtime.runtime_key.as_deref() { - selectors.push(runtime_key.to_owned()); - } - selectors.push(runtime.runtime_id.clone()); - Some(selectors) - } - _ => None, - } -} - -fn ready_default_runtime_matches<'a>( - config: &RocmCliConfig, - runtimes: &'a [OnboardingRuntime], -) -> Vec<&'a OnboardingRuntime> { - let Some(default_runtime_id) = config.default_runtime_id.as_deref() else { - return Vec::new(); - }; - runtimes - .iter() - .filter(|runtime| { - runtime.runtime_id == default_runtime_id && runtime_install_root_ready(runtime) - }) - .collect() -} - -fn load_onboarding_engine_env( - paths: &AppPaths, - engine: &str, - env_id: &str, -) -> Option { - let path = paths - .engine_manifests_dir(engine) - .join(format!("{env_id}.json")); - let bytes = fs::read(path).ok()?; - serde_json::from_slice::(&bytes).ok() -} - -fn onboarding_menu_choices(app: &App) -> Vec { - if app.running_job.is_some() { - return vec![OnboardingMenuChoice::Primary]; - } - if app.pending_approval.is_some() { - return vec![OnboardingMenuChoice::Primary, OnboardingMenuChoice::Back]; - } - let maybe_show_log = app - .onboarding_install_full_log - .as_ref() - .map(|_| OnboardingMenuChoice::ShowLog); - if setup_venv_ready(&app.paths, &app.config) { - let mut choices = vec![ - OnboardingMenuChoice::Folder, - OnboardingMenuChoice::Channel, - OnboardingMenuChoice::Primary, - OnboardingMenuChoice::Reinstall, - OnboardingMenuChoice::Uninstall, - ]; - choices.extend(maybe_show_log); - choices.push(OnboardingMenuChoice::Back); - return choices; - } - if setup_has_current_install(&app.paths, &app.config) { - let mut choices = vec![ - OnboardingMenuChoice::Folder, - OnboardingMenuChoice::Channel, - OnboardingMenuChoice::Primary, - OnboardingMenuChoice::Uninstall, - ]; - choices.extend(maybe_show_log); - choices.push(OnboardingMenuChoice::Back); - return choices; - } - if onboarding_can_return(app) { - let mut choices = vec![ - OnboardingMenuChoice::Folder, - OnboardingMenuChoice::Channel, - OnboardingMenuChoice::Primary, - ]; - choices.extend(maybe_show_log); - choices.push(OnboardingMenuChoice::Back); - return choices; - } - let mut choices = vec![ - OnboardingMenuChoice::Folder, - OnboardingMenuChoice::Channel, - OnboardingMenuChoice::Primary, - ]; - choices.extend(maybe_show_log); - choices.push(OnboardingMenuChoice::Quit); - choices -} - -const fn onboarding_cancel_install_choices() -> &'static [&'static str] { - &["Keep waiting", "Stop install"] -} - -fn onboarding_cancel_install_status(selected: usize) -> String { - match selected { - 0 => "Press Enter to keep waiting for ROCm to install.".to_owned(), - _ => "Press Enter to stop the ROCm install.".to_owned(), - } -} - -fn onboarding_can_return(app: &App) -> bool { - app.config.onboarding_dismissed || setup_venv_ready(&app.paths, &app.config) -} - -fn onboarding_default_selection(app: &App) -> usize { - onboarding_menu_choices(app) - .iter() - .position(|choice| *choice == OnboardingMenuChoice::Primary) - .unwrap_or(0) -} - -fn onboarding_selected_choice(app: &App) -> OnboardingMenuChoice { - let choices = onboarding_menu_choices(app); - choices - .get( - app.onboarding_selection - .min(choices.len().saturating_sub(1)), - ) - .copied() - .unwrap_or(OnboardingMenuChoice::Primary) -} - -fn onboarding_selection_status(app: &App) -> String { - match onboarding_selected_choice(app) { - OnboardingMenuChoice::Channel => { - "Press Enter or Left/Right to switch between stable (release) and nightly.".to_owned() - } - OnboardingMenuChoice::Folder => { - "Press Enter to choose a folder, Left/Right for quick choices, or T to type.".to_owned() - } - OnboardingMenuChoice::Primary if app.pending_approval.is_some() => { - let title = app - .pending_approval - .as_ref() - .map_or("setup", |pending| pending.title.as_str()); - if title == "Uninstall" { - "Press Enter to approve uninstall.".to_owned() - } else if title == "Reinstall" { - "Press Enter to approve reinstall.".to_owned() - } else { - "Press Enter to approve setup.".to_owned() - } - } - OnboardingMenuChoice::Primary if setup_venv_ready(&app.paths, &app.config) => { - "Press Enter to start ROCm CLI.".to_owned() - } - OnboardingMenuChoice::Primary if setup_has_current_install(&app.paths, &app.config) => { - "Press Enter to reinstall ROCm in this folder.".to_owned() - } - OnboardingMenuChoice::Primary => "Press Enter to install ROCm.".to_owned(), - OnboardingMenuChoice::Reinstall => "Press Enter to reinstall ROCm here.".to_owned(), - OnboardingMenuChoice::Uninstall => "Press Enter to uninstall this ROCm install.".to_owned(), - OnboardingMenuChoice::ShowLog if app.onboarding_show_log_location => { - "Press Enter to hide the saved install log.".to_owned() - } - OnboardingMenuChoice::ShowLog => "Press Enter to show the saved install log.".to_owned(), - OnboardingMenuChoice::Back if app.pending_approval.is_some() => { - "Press Enter to cancel setup.".to_owned() - } - OnboardingMenuChoice::Back if setup_venv_ready(&app.paths, &app.config) => { - "Press Enter to return to ROCm CLI.".to_owned() - } - OnboardingMenuChoice::Back => "Press Enter to return without changing setup.".to_owned(), - OnboardingMenuChoice::Quit => "Press Enter to quit ROCm CLI.".to_owned(), - } -} - -fn onboarding_menu_label(app: &App, choice: OnboardingMenuChoice) -> String { - match choice { - OnboardingMenuChoice::Channel => format!( - "{:<14} {}", - "Channel:", - install_sdk_channel_summary(app.onboarding_channel) - ), - OnboardingMenuChoice::Folder => { - let folder = display_runtime_folder_path(&setup_install_root(&app.paths, &app.config)); - format!("Install folder: {folder}") - } - OnboardingMenuChoice::Primary if app.pending_approval.is_some() => { - match app - .pending_approval - .as_ref() - .map(|pending| pending.title.as_str()) - { - Some("Uninstall") => "Approve uninstall".to_owned(), - Some("Reinstall") => "Approve reinstall".to_owned(), - _ => "Approve setup".to_owned(), - } - } - OnboardingMenuChoice::Primary => onboarding_primary_label(app), - OnboardingMenuChoice::Reinstall => "Reinstall ROCm".to_owned(), - OnboardingMenuChoice::Uninstall => "Uninstall ROCm".to_owned(), - OnboardingMenuChoice::ShowLog if app.onboarding_show_log_location => { - "Hide install log".to_owned() - } - OnboardingMenuChoice::ShowLog => "Show install log".to_owned(), - OnboardingMenuChoice::Back if app.pending_approval.is_some() => "Cancel".to_owned(), - OnboardingMenuChoice::Back => "Back".to_owned(), - OnboardingMenuChoice::Quit => "Quit setup".to_owned(), - } -} - -#[cfg(test)] -fn onboarding_install_current_step(app: &App) -> String { - let Some(line) = app.onboarding_install_log.back() else { - return "Starting the installer...".to_owned(); - }; - let lower = line.to_ascii_lowercase(); - if lower.contains("checking therock") || lower.contains("found therock package") { - "Checking ROCm packages...".to_owned() - } else if lower.contains("checking python for") - || lower.contains("using python from") - || lower.contains("using existing python") - || lower.contains("python") && lower.contains("is ready at") - || lower.contains("finding python") - || lower.contains("preparing python") - { - "Checking Python...".to_owned() - } else if lower.contains("installing python") && lower.contains("via uv") { - "Installing Python...".to_owned() - } else if lower.contains("creating virtual environment") - || lower.contains("creating python environment") - || lower.contains("-m venv") - { - "Setting up Python...".to_owned() - } else if lower.contains("collecting") || lower.contains("looking in indexes") { - "Finding ROCm and PyTorch packages...".to_owned() - } else if lower.contains("downloading") { - "Downloading packages...".to_owned() - } else if lower.contains("installing rocm[libraries,devel]") - || lower.contains("installing torch") - || lower.contains("installing torch torchvision torchaudio") - { - "Installing ROCm packages...".to_owned() - } else if lower.contains("installing collected packages") { - "Installing packages...".to_owned() - } else if lower.contains("checking the installed rocm sdk") - || lower.contains("successfully installed") - { - "Checking the ROCm install...".to_owned() - } else if lower.contains("error") || lower.contains("failed") { - "The installer reported an error.".to_owned() - } else { - line.to_owned() - } -} - -fn render_onboarding_install_output(app: &App, limit: usize) -> Vec { - let len = app.onboarding_install_log.len(); - if len <= limit { - return app.onboarding_install_log.iter().cloned().collect(); - } - let max_offset = len.saturating_sub(limit); - let offset = app.onboarding_install_log_scroll.min(max_offset); - let end = len.saturating_sub(offset); - let start = end.saturating_sub(limit); - app.onboarding_install_log - .iter() - .skip(start) - .take(end.saturating_sub(start)) - .cloned() - .collect() -} - -fn saved_onboarding_log_line_count(path: &Path) -> Option { - Some(read_saved_onboarding_log_lines(path).ok()?.len()) -} - -fn read_saved_onboarding_log_lines(path: &Path) -> Result> { - let text = - fs::read_to_string(path).with_context(|| format!("failed to read {}", path.display()))?; - Ok(text - .lines() - .map(|line| sanitize_terminal_text(line).replace(['\r', '\n'], " ")) - .map(|line| truncate_stream_line(line.trim())) - .filter(|line| !line.is_empty()) - .collect()) -} - -fn render_saved_onboarding_log_output( - path: &Path, - scroll: usize, - limit: usize, -) -> Result> { - let lines = read_saved_onboarding_log_lines(path)?; - let len = lines.len(); - if len <= limit { - return Ok(lines); - } - let max_offset = len.saturating_sub(limit); - let offset = scroll.min(max_offset); - let end = len.saturating_sub(offset); - let start = end.saturating_sub(limit); - Ok(lines.into_iter().skip(start).take(end - start).collect()) -} - -fn onboarding_install_log_limit(body_height: u16) -> usize { - let inner_height = body_height.saturating_sub(2) as usize; - inner_height - .saturating_sub(13) - .clamp(3, ONBOARDING_INSTALL_LOG_LINES) -} - -fn install_failure_reason(rendered: &str, log: &VecDeque) -> String { - if let Some(line) = log.iter().rev().find(|line| { - let lower = line.to_ascii_lowercase(); - lower.contains("error") || lower.contains("failed") - }) { - return plain_install_failure_reason(line) - .unwrap_or(line) - .to_owned(); - } - let preferred = rendered - .lines() - .rev() - .map(str::trim) - .find(|line| { - !line.is_empty() - && !matches!(*line, "stdout:" | "stderr:") - && !line.starts_with("command:") - && !line.starts_with("streamed_") - && !line.starts_with("status:") - }) - .unwrap_or("The installer exited with an error."); - plain_install_failure_reason(preferred) - .unwrap_or(preferred) - .to_owned() -} - -fn plain_install_failure_reason(line: &str) -> Option<&'static str> { - let lower = line.to_ascii_lowercase(); - if lower.contains("rocm_sdk") - || lower.contains("rocm sdk") - || lower.contains("therock torch wheels") - { - Some( - "ROCm installed, but rocm-cli could not find the runtime files it needs. The installer output below has the technical details.", - ) - } else if lower.contains("no matching distribution found") && lower.contains("torch") { - Some( - "ROCm packages were not available for this GPU or Python version. The installer output below has the package detail.", - ) - } else if lower.contains("access is denied") || lower.contains("permission denied") { - Some("rocm-cli could not write to the selected folder. Choose a folder you own.") - } else if lower.contains("no amd gpu") - || lower.contains("no supported amd gpu") - || lower.contains("could not detect") - && (lower.contains("gfx") || lower.contains("amd gpu") || lower.contains("gpu")) - { - Some("rocm-cli could not find a supported AMD GPU for this ROCm package.") - } else if lower.contains("dns") - || lower.contains("name resolution") - || lower.contains("temporary failure in name resolution") - || lower.contains("failed to establish a new connection") - || lower.contains("connection timed out") - || lower.contains("connection refused") - || lower.contains("network is unreachable") - { - Some("rocm-cli could not reach the download server. Check your network and try again.") - } else if lower.contains("ssl") || lower.contains("certificate") || lower.contains("tls") { - Some( - "The secure download check failed. Check your network security software or certificates, then try again.", - ) - } else if lower.contains("no space left") - || lower.contains("not enough space") - || lower.contains("disk full") - { - Some("The selected drive is out of space. Free some space or choose another folder.") - } else if lower.contains("python") - && (lower.contains("download") || lower.contains("extract") || lower.contains("archive")) - && (lower.contains("failed") || lower.contains("error")) - { - Some( - "rocm-cli could not set up its local Python. The installer output below has the detail.", - ) - } else if lower.contains("unsupported platform") - || lower.contains("unsupported python") - || lower.contains("requires python") - { - Some("This ROCm package is not available for this Windows, Linux, or Python version.") - } else if lower.contains("cancelled") - || lower.contains("canceled") - || lower.contains("terminated") - || lower.contains("stopping installer") - { - Some("The install was stopped before it finished.") - } else { - None - } -} - -fn onboarding_install_full_log_path(paths: &AppPaths) -> PathBuf { - paths - .data_dir - .join("logs") - .join("setup") - .join(format!("install-{}.log", unix_time_millis())) -} - -fn write_onboarding_install_full_log(paths: &AppPaths, rendered: &str) -> Result { - let path = onboarding_install_full_log_path(paths); - let parent = path - .parent() - .context("setup install log path has no parent directory")?; - fs::create_dir_all(parent) - .with_context(|| format!("failed to create setup log directory {}", parent.display()))?; - fs::write(&path, rendered).with_context(|| format!("failed to write {}", path.display()))?; - Ok(path) -} - -fn render_onboarding_screen_text_with_log_limit(app: &App, install_log_limit: usize) -> String { - let setup_ready = setup_venv_ready(&app.paths, &app.config); - let has_current_install = setup_has_current_install(&app.paths, &app.config); - let current_runtime = current_setup_runtime(&app.paths, &app.config); - let install_folder = setup_install_root(&app.paths, &app.config); - let pip_cache_dir = setup_pip_cache_dir(&app.paths, &app.config); - let mut output = String::new(); - - if app.onboarding_show_log_location && app.onboarding_install_full_log.is_some() { - if let Some(reason) = app.onboarding_setup_error.as_deref() { - let _ = writeln!(output, "Setup needs attention"); - let _ = writeln!(output, " {reason}"); - } else if let Some(reason) = app.onboarding_install_failure.as_deref() { - let _ = writeln!(output, "Install failed"); - let _ = writeln!(output, " {reason}"); - } else { - let _ = writeln!(output, "Saved setup output"); - let _ = writeln!( - output, - " This is the saved output from the last setup run." - ); - } - append_onboarding_saved_log_section(&mut output, app, install_log_limit); - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " PageUp/PageDown scrolls the saved log."); - let _ = writeln!(output, " Enter hides the saved log."); - let _ = writeln!(output, " Up/Down chooses another action."); - return output.trim_end().to_owned(); - } - - let _ = writeln!(output, "Let's get your AMD GPU ready for local AI."); - let _ = writeln!( - output, - "ROCm CLI will install the ROCm pieces it needs into one folder." - ); - let _ = writeln!(output, "Nothing starts until you review it."); - let _ = writeln!(output); - append_onboarding_gpu_section(&mut output, app); - let _ = writeln!(output); - let _ = writeln!(output, "Install location"); - let _ = writeln!( - output, - " Folder: {}", - display_runtime_folder_path(&install_folder) - ); - let _ = writeln!( - output, - " Downloaded files: {}", - display_runtime_folder_path(&pip_cache_dir) - ); - let _ = writeln!(output); - let _ = writeln!(output, "Status"); - let marker = if setup_ready { - "[x]" - } else if has_current_install { - "[!]" - } else { - "[ ]" - }; - let status = if setup_ready { - "ready" - } else if has_current_install { - "needs reinstall" - } else { - "not installed" - }; - let _ = writeln!(output, " {marker} ROCm: {status}"); - if let Some(runtime) = current_runtime.as_ref() - && let Some(version) = runtime.version.as_deref() - && !version.trim().is_empty() - { - let _ = writeln!( - output, - " version: {}", - therock::runtime_version_display(version) - ); - } - if app.onboarding_path_editing { - let _ = writeln!(output); - let _ = writeln!(output, "Choose another folder"); - let _ = writeln!(output, " Type or paste the folder path below."); - let _ = writeln!(output, " Choose a folder you own. Avoid system folders."); - let _ = writeln!( - output, - " Press Enter to use it, or Esc to keep the current folder." - ); - return output.trim_end().to_owned(); - } - - if app.running_job.is_some() { - let _ = writeln!(output); - let _ = writeln!(output, "Setup is running."); - return output.trim_end().to_owned(); - } - - if let Some(reason) = app.onboarding_setup_error.as_deref() { - let _ = writeln!(output); - let _ = writeln!(output, "Setup needs attention"); - let _ = writeln!(output, " {reason}"); - if app.onboarding_install_full_log.is_some() { - append_onboarding_saved_log_section(&mut output, app, install_log_limit); - let _ = writeln!( - output, - " Need the full detail? Choose Show install log below." - ); - } - let lines = render_onboarding_install_output(app, install_log_limit); - if !lines.is_empty() && !app.onboarding_show_log_location { - let _ = writeln!(output); - let _ = writeln!(output, "Recent log"); - for line in lines { - let _ = writeln!(output, " {}", display_stream_log_line(&line)); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Up/Down chooses, Enter selects."); - let _ = writeln!(output, " PageUp/PageDown scrolls installer output."); - let _ = writeln!( - output, - " On the folder row, Enter opens folders, Left/Right changes quick choices, and T lets you type." - ); - return output.trim_end().to_owned(); - } - - if let Some(reason) = app.onboarding_install_failure.as_deref() { - let _ = writeln!(output); - let _ = writeln!(output, "Install failed"); - let _ = writeln!(output, " {reason}"); - if app.onboarding_install_full_log.is_some() { - append_onboarding_saved_log_section(&mut output, app, install_log_limit); - let _ = writeln!( - output, - " Need the full detail? Choose Show install log below." - ); - } - let lines = render_onboarding_install_output(app, install_log_limit); - if !lines.is_empty() && !app.onboarding_show_log_location { - let _ = writeln!(output); - let _ = writeln!(output, "Installer output"); - for line in lines { - let _ = writeln!(output, " {}", display_stream_log_line(&line)); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Up/Down chooses, Enter selects."); - let _ = writeln!(output, " PageUp/PageDown scrolls installer output."); - let _ = writeln!( - output, - " On the folder row, Enter opens folders, Left/Right changes quick choices, and T lets you type." - ); - return output.trim_end().to_owned(); - } - - if app.onboarding_install_full_log.is_some() { - append_onboarding_saved_log_section(&mut output, app, install_log_limit); - } - - let _ = writeln!(output); - match app.onboarding_action() { - OnboardingAction::InstallSdk if has_current_install => { - let _ = writeln!(output, "Next step"); - let _ = writeln!(output, " Reinstall ROCm in this folder."); - let _ = writeln!( - output, - " You can also choose Uninstall ROCm below to remove it." - ); - } - OnboardingAction::InstallSdk => { - let _ = writeln!(output, "Next step"); - let _ = writeln!(output, " Install ROCm now."); - let _ = writeln!( - output, - " ROCm CLI will choose the correct AMD GPU package." - ); - } - OnboardingAction::Finish => { - let _ = writeln!(output, "Ready"); - let _ = writeln!(output, " ROCm is installed and ready."); - let _ = writeln!(output, " Press Enter to start ROCm CLI."); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Up/Down chooses, Enter selects."); - let _ = writeln!( - output, - " On the folder row, Enter opens folders, Left/Right changes quick choices, and T lets you type." - ); - output.trim_end().to_owned() -} - -fn append_onboarding_saved_log_section(output: &mut String, app: &App, install_log_limit: usize) { - let Some(path) = app.onboarding_install_full_log.as_ref() else { - return; - }; - let _ = writeln!(output); - if app.onboarding_show_log_location { - let _ = writeln!(output, "Saved setup log"); - match render_saved_onboarding_log_output( - path, - app.onboarding_install_log_scroll, - install_log_limit, - ) { - Ok(lines) => { - let _ = writeln!(output, " file: {}", path.display()); - if lines.is_empty() { - let _ = writeln!(output, " The saved setup log is empty."); - } else { - for line in lines { - let _ = writeln!(output, " {}", display_stream_log_line(&line)); - } - } - } - Err(error) => { - if let Some(file_name) = path.file_name().and_then(|value| value.to_str()) { - let _ = writeln!(output, " log file: {file_name}"); - } - let _ = writeln!(output, " Could not read the saved setup log: {error}"); - let _ = writeln!(output, " file: {}", path.display()); - } - } - } else { - let _ = writeln!( - output, - "Saved setup log: choose Show install log below to read it." - ); - } -} - -fn render_saved_onboarding_log_card(path: &Path) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Saved setup log"); - let _ = writeln!(output); - let _ = writeln!(output, "File"); - if let Some(file_name) = path.file_name().and_then(|value| value.to_str()) { - let _ = writeln!(output, " log file: {file_name}"); - } - let _ = writeln!(output, " {}", path.display()); - let _ = writeln!(output); - match read_saved_onboarding_log_lines(path) { - Ok(lines) if lines.is_empty() => { - let _ = writeln!(output, "Output"); - let _ = writeln!(output, " The saved setup log is empty."); - } - Ok(lines) => { - if lines.len() > 80 { - let _ = writeln!(output, "Latest output"); - let _ = writeln!( - output, - " newest lines first; showing 80 of {} saved lines", - lines.len() - ); - for line in lines.iter().rev().take(80) { - let _ = writeln!(output, " {line}"); - } - let _ = writeln!(output); - let _ = writeln!(output, "Full output"); - } else { - let _ = writeln!(output, "Output"); - } - for line in &lines { - let _ = writeln!(output, " {line}"); - } - } - Err(error) => { - let _ = writeln!(output, "Output"); - let _ = writeln!(output, " Could not read the saved setup log."); - let _ = writeln!(output, " {error}"); - } - } - output.trim_end().to_owned() -} - -fn append_onboarding_gpu_section(output: &mut String, app: &App) { - append_gpu_summary_section(output, &app.host_gpu_summary); -} - -fn append_gpu_summary_section(output: &mut String, summary: &HostGpuSummary) { - let gpu_name = summary - .name - .as_deref() - .filter(|value| !value.trim().is_empty()); - let gfx_target = summary - .gfx_target - .as_deref() - .filter(|value| !value.trim().is_empty()); - let therock_family = summary - .therock_family - .as_deref() - .filter(|value| !value.trim().is_empty()); - - if gpu_name.is_none() && gfx_target.is_none() && therock_family.is_none() { - return; - } - - let _ = writeln!(output, "Detected GPU"); - match (gpu_name, gfx_target, therock_family) { - (Some(name), Some(gfx), Some(family)) => { - let _ = writeln!(output, " GPU: {name}"); - let _ = writeln!(output, " Target: {gfx}"); - let _ = writeln!(output, " ROCm package: {family}"); - } - (Some(name), Some(gfx), None) => { - let _ = writeln!(output, " GPU: {name}"); - let _ = writeln!(output, " Target: {gfx}"); - let _ = writeln!( - output, - " ROCm package: selected automatically during install" - ); - } - (Some(name), None, Some(family)) => { - let _ = writeln!(output, " GPU: {name}"); - let _ = writeln!(output, " ROCm package: {family}"); - } - (Some(name), None, None) => { - let _ = writeln!(output, " GPU: {name}"); - let _ = writeln!( - output, - " ROCm package: selected automatically during install" - ); - } - (None, Some(gfx), Some(family)) => { - let _ = writeln!(output, " Target: {gfx}"); - let _ = writeln!(output, " ROCm package: {family}"); - } - (None, Some(gfx), None) => { - let _ = writeln!(output, " Target: {gfx}"); - let _ = writeln!( - output, - " ROCm package: selected automatically during install" - ); - } - (None, None, Some(family)) => { - let _ = writeln!(output, " ROCm package: {family}"); - } - (None, None, None) => {} - } -} - -fn onboarding_gpu_install_sentence(summary: &HostGpuSummary) -> String { - let name = summary - .name - .as_deref() - .filter(|value| !value.trim().is_empty()); - let gfx = summary - .gfx_target - .as_deref() - .filter(|value| !value.trim().is_empty()); - let family = summary - .therock_family - .as_deref() - .filter(|value| !value.trim().is_empty()); - - match (name, gfx, family) { - (Some(name), Some(_gfx), Some(_family)) => { - format!("It will install the matching ROCm package for {name}.") - } - (Some(name), Some(_gfx), None) => { - format!("It will choose the matching ROCm package for {name}.") - } - (Some(name), None, _) => { - format!("It will choose the matching ROCm package for {name}.") - } - (None, Some(gfx), Some(_family)) => { - format!("It will install the matching ROCm package for AMD GPU target {gfx}.") - } - (None, Some(gfx), None) => { - format!("It will choose the matching ROCm package for AMD GPU target {gfx}.") - } - (None, None, Some(_family)) => "It will install the matching ROCm package.".to_owned(), - (None, None, None) => "It will detect the AMD GPU package before installing.".to_owned(), - } -} - -fn onboarding_should_show_log_controls(app: &App) -> bool { - if app.running_job.is_some() - || app.onboarding_show_log_location && app.onboarding_install_full_log.is_some() - { - return true; - } - (app.onboarding_install_failure.is_some() || app.onboarding_setup_error.is_some()) - && !app.onboarding_install_log.is_empty() -} - -fn onboarding_primary_label(app: &App) -> String { - if let Some(pending) = app.pending_approval.as_ref() { - return format!("Approve {}", pending.title); - } - if let Some(job) = app.running_job.as_ref() { - if job.title == "Install" { - return "Installing ROCm...".to_owned(); - } - return format!("Running {}...", job.title); - } - if app.onboarding_path_editing { - return app.input.clone(); - } - match app.onboarding_action() { - OnboardingAction::InstallSdk if setup_has_current_install(&app.paths, &app.config) => { - "Reinstall ROCm".to_owned() - } - OnboardingAction::InstallSdk => "Install ROCm".to_owned(), - OnboardingAction::Finish => "Start ROCm CLI".to_owned(), - } -} - -fn validate_runtime_adopt_args(args: &[&str]) -> Result<()> { - if args.first() != Some(&"adopt") { - bail!("internal error: runtime adopt validation received non-adopt command"); - } - - let mut seen = Vec::new(); - let mut index = 1; - while index < args.len() { - let token = args[index]; - let (option_name, inline_value) = split_inline_option(token); - if option_name == "--replace" { - flag_without_value(option_name, inline_value)?; - index += 1; - continue; - } - if RUNTIME_ADOPT_ALL_OPTIONS.contains(&option_name) { - if seen.iter().any(|seen| seen == option_name) { - bail!("duplicate /runtimes adopt option: {option_name}"); - } - let value = option_argument_value(option_name, inline_value, args, &mut index)?; - if option_name == "--channel" && !matches!(value.as_str(), "release" | "nightly") { - bail!("runtime adopt channel must be release or nightly"); - } - seen.push(option_name.to_owned()); - continue; - } - bail!("unknown /runtimes adopt option: {option_name}"); - } - - for required in RUNTIME_ADOPT_REQUIRED_OPTIONS { - if !seen.iter().any(|seen| seen == required) { - bail!("missing required /runtimes adopt option: {required}"); - } - } - - Ok(()) -} - -fn plain_runtime_adopt_error(error: &str) -> String { - let lower = error.to_ascii_lowercase(); - if lower.contains("--python") { - "Choose the Python folder below.".to_owned() - } else if lower.contains("channel") { - "Choose release or nightly for the channel row below.".to_owned() - } else { - "Choose the Python folder and options below.".to_owned() - } -} - -#[derive(Debug, Clone)] -struct InstallSdkFormOptions { - channel: InstallSdkChannel, - format: InstallSdkFormat, - folder: Option, -} - -fn parse_install_sdk_form_args(args: &[&str]) -> Result { - if args.first() != Some(&"sdk") { - bail!("internal error: SDK install parser received a non-SDK command"); - } - let mut channel = InstallSdkChannel::Release; - let mut format = InstallSdkFormat::Wheel; - let mut folder = None; - let mut seen = Vec::new(); - let mut index = 1; - while index < args.len() { - let token = args[index]; - let (option_name, inline_value) = split_inline_option(token); - match option_name { - "--channel" => { - if seen.iter().any(|seen| seen == option_name) { - bail!("channel was already selected"); - } - let value = option_argument_value(option_name, inline_value, args, &mut index)?; - channel = match value.as_str() { - "release" => InstallSdkChannel::Release, - "nightly" => InstallSdkChannel::Nightly, - _ => bail!("channel must be release or nightly"), - }; - seen.push(option_name.to_owned()); - } - "--format" => { - if seen.iter().any(|seen| seen == option_name) { - bail!("install type was already selected"); - } - let value = option_argument_value(option_name, inline_value, args, &mut index)?; - format = match value.as_str() { - "wheel" => InstallSdkFormat::Wheel, - "tarball" => InstallSdkFormat::Tarball, - _ => bail!("install type must be pip or tarball"), - }; - seen.push(option_name.to_owned()); - } - "--prefix" => { - if seen.iter().any(|seen| seen == option_name) { - bail!("folder was already selected"); - } - let value = option_argument_value(option_name, inline_value, args, &mut index)?; - folder = Some(PathBuf::from(value)); - seen.push(option_name.to_owned()); - } - value if value.starts_with("--") => { - bail!("unknown install option: {value}"); - } - value => { - bail!("unexpected install value: {value}"); - } - } - } - Ok(InstallSdkFormOptions { - channel, - format, - folder, - }) -} - -fn plain_install_sdk_args_error(error: &str) -> String { - let lower = error.to_ascii_lowercase(); - if lower.contains("channel") { - "Use the recommended release install below. Advanced channels are for command-line testing." - .to_owned() - } else if lower.contains("format") || lower.contains("install type") { - "Use the recommended Python install below. Advanced install types are for command-line testing." - .to_owned() - } else if lower.contains("prefix") || lower.contains("folder") { - "Choose the install folder below.".to_owned() - } else if lower.contains("unknown") || lower.contains("unexpected") { - "I do not recognize that install option.\n\nUse the rows below to choose how ROCm should be installed." - .to_owned() - } else { - "Use the rows below to choose how ROCm should be installed.".to_owned() - } -} - -fn parse_runtime_import_args(args: &[&str]) -> Result<(Option, bool)> { - if args.first() != Some(&"import") { - bail!("internal error: runtime import validation received non-import command"); - } - let mut manifest: Option = None; - let mut replace = false; - let mut index = 1; - while index < args.len() { - let token = args[index]; - if token == "--replace" { - if replace { - bail!("Replace was already selected."); - } - replace = true; - index += 1; - continue; - } - if token.starts_with("--") { - bail!("I do not recognize that import option."); - } - if manifest.is_some() { - bail!("Choose one saved ROCm install file."); - } - manifest = Some(PathBuf::from(token)); - index += 1; - } - Ok((manifest, replace)) -} - -fn slash_argument_candidates( - command: &str, - arg_index: usize, - parts: &[String], - paths: &AppPaths, -) -> Vec { - match (command, arg_index) { - ("provider", 0) => ["local", "anthropic", "openai"] - .into_iter() - .map(str::to_owned) - .collect(), - ("gpu", 0) => vec!["refresh".to_owned()], - ("install", _) => install_argument_candidates(arg_index, parts), - ("update", _) => update_argument_candidates(arg_index, parts, paths), - ("model", 0) => model_recipe_candidates(), - ("config", 0) => vec![ - "show".to_owned(), - "set-engine".to_owned(), - "set-default-engine".to_owned(), - "clear-default-engine".to_owned(), - "set-default-runtime".to_owned(), - "clear-default-runtime".to_owned(), - "set-telemetry".to_owned(), - "set-planner-provider".to_owned(), - "clear-planner-provider".to_owned(), - "enable-provider".to_owned(), - "disable-provider".to_owned(), - "set-provider-key".to_owned(), - "clear-provider-key".to_owned(), - ], - ("config", 1) if parts.get(1).is_some_and(|value| value == "set-telemetry") => { - vec!["local".to_owned(), "off".to_owned()] - } - ("config", 1) - if parts - .get(1) - .is_some_and(|value| value == "set-planner-provider") => - { - vec![ - "local".to_owned(), - "openai".to_owned(), - "anthropic".to_owned(), - ] - } - ("config", 1) - if parts.get(1).is_some_and(|value| { - matches!( - value.as_str(), - "enable-provider" - | "disable-provider" - | "set-provider-key" - | "clear-provider-key" - ) - }) => - { - vec!["openai".to_owned(), "anthropic".to_owned()] - } - ("config", 1) - if parts - .get(1) - .is_some_and(|value| value == "set-default-engine") => - { - engine_inventory() - .iter() - .map(|(engine, _)| (*engine).to_owned()) - .collect() - } - ("config", 1) if parts.get(1).is_some_and(|value| value == "set-engine") => { - engine_inventory() - .iter() - .map(|(engine, _)| (*engine).to_owned()) - .collect() - } - ("config", 1) - if parts - .get(1) - .is_some_and(|value| value == "set-default-runtime") => - { - load_onboarding_runtimes(paths) - .into_iter() - .filter(runtime_install_root_ready) - .map(|runtime| runtime.runtime_id) - .collect::>() - .into_iter() - .collect() - } - ("permissions", 0) => vec![ - "status".to_owned(), - "full-access".to_owned(), - "ask".to_owned(), - ], - ("setup", 0) => vec!["status".to_owned(), "reset".to_owned(), "skip".to_owned()], - ("runtimes", 0) => vec![ - "list".to_owned(), - "activate".to_owned(), - "uninstall".to_owned(), - "remove".to_owned(), - "import".to_owned(), - "adopt".to_owned(), - ], - ("runtimes", 1) - if parts.get(1).is_some_and(|value| { - value == "activate" || value == "uninstall" || value == "remove" - }) => - { - load_onboarding_runtimes(paths) - .into_iter() - .filter(|runtime| { - parts - .get(1) - .is_some_and(|value| value == "uninstall" || value == "remove") - || runtime_install_root_ready(runtime) - }) - .map(|runtime| runtime.runtime_key.unwrap_or(runtime.runtime_id)) - .collect() - } - ("runtimes", _) if parts.get(1).is_some_and(|value| value == "adopt") => { - runtime_adopt_argument_candidates(arg_index, parts) - } - ("engine", 0) => vec!["list".to_owned(), "install".to_owned()], - ("engine", 1) if parts.get(1).is_some_and(|value| value == "install") => engine_inventory() - .iter() - .map(|(engine, _)| (*engine).to_owned()) - .collect(), - ("comfyui" | "comfy", 0) => vec![ - "status".to_owned(), - "install".to_owned(), - "start".to_owned(), - "models-path".to_owned(), - "logs".to_owned(), - "log-files".to_owned(), - ], - ("serve", _) => serve_argument_candidates(arg_index, parts), - ("automations", 0) => [ - "list", - "reviews", - "review", - "proposals", - "approve", - "reject", - "edit", - "enable", - "disable", - ] - .into_iter() - .map(str::to_owned) - .collect(), - ("services", _) => services_argument_candidates(arg_index, parts, paths), - ("logs", _) => logs_argument_candidates(arg_index, parts, paths), - ("approve" | "reject" | "reviews" | "proposals", 0) => pending_proposal_ids(paths), - ("automations", 1) - if parts.get(1).is_some_and(|value| { - matches!( - value.as_str(), - "approve" | "reject" | "edit" | "review" | "reviews" | "proposals" - ) - }) => - { - pending_proposal_ids(paths) - } - ("automations", 1) - if parts - .get(1) - .is_some_and(|value| value == "enable" || value == "disable") => - { - builtin_watchers() - .iter() - .map(|watcher| watcher.id.to_owned()) - .collect() - } - ("automations", 2) if parts.get(1).is_some_and(|value| value == "enable") => { - vec!["--mode".to_owned()] - } - ("automations", 3) - if parts.get(1).is_some_and(|value| value == "enable") - && (parts.get(3).is_some_and(|value| value == "--mode") - || parts.get(2).is_some_and(|value| value == "--mode")) => - { - vec![ - "observe".to_owned(), - "propose".to_owned(), - "contained".to_owned(), - ] - } - _ => Vec::new(), - } -} - -fn install_argument_candidates(arg_index: usize, parts: &[String]) -> Vec { - if arg_index == 0 { - return vec!["sdk".to_owned(), "driver".to_owned()]; - } - match parts.get(1).map(String::as_str) { - Some("sdk") => install_sdk_argument_candidates(arg_index, parts), - Some("driver") => install_driver_argument_candidates(parts), - _ => Vec::new(), - } -} - -fn install_sdk_argument_candidates(arg_index: usize, parts: &[String]) -> Vec { - match parts.get(arg_index).map(String::as_str) { - Some("--channel") => return vec!["release".to_owned(), "nightly".to_owned()], - Some("--format") => return vec!["wheel".to_owned(), "tarball".to_owned()], - Some("--prefix") => return Vec::new(), - _ => {} - } - - ["--channel", "--format", "--prefix"] - .into_iter() - .filter(|option| !slash_option_used(parts, option)) - .map(str::to_owned) - .collect() -} - -fn install_driver_argument_candidates(parts: &[String]) -> Vec { - std::iter::once("--preview") - .filter(|option| !slash_option_used(parts, option)) - .map(str::to_owned) - .collect() -} - -fn slash_option_used(parts: &[String], option: &str) -> bool { - parts.iter().skip(2).any(|part| { - part == option - || part - .strip_prefix(option) - .is_some_and(|rest| rest.starts_with('=')) - }) -} - -fn services_argument_candidates( - arg_index: usize, - parts: &[String], - paths: &AppPaths, -) -> Vec { - if arg_index == 0 { - let mut candidates = vec![ - "list".to_owned(), - "logs".to_owned(), - "log".to_owned(), - "stop".to_owned(), - "restart".to_owned(), - ]; - if let Ok(services) = load_managed_services(paths) { - candidates.extend(services.into_iter().map(|service| service.service_id)); - } - return candidates; - } - if arg_index == 1 - && parts - .get(1) - .is_some_and(|value| matches!(value.as_str(), "logs" | "log" | "stop" | "restart")) - { - return load_managed_services(paths) - .map(|services| { - services - .into_iter() - .map(|service| service.service_id) - .collect() - }) - .unwrap_or_default(); - } - Vec::new() -} - -fn model_recipe_candidates() -> Vec { - let mut candidates = Vec::new(); - let Ok(registry) = load_model_recipe_registry() else { - return candidates; - }; - for recipe in registry.recipes { - candidates.push(recipe.canonical_model_id); - candidates.extend(recipe.aliases); - } - candidates.sort(); - candidates.dedup(); - candidates -} - -fn logs_argument_candidates(arg_index: usize, parts: &[String], paths: &AppPaths) -> Vec { - if arg_index == 0 { - let mut candidates = vec![ - "--service".to_owned(), - "--search".to_owned(), - "next".to_owned(), - "prev".to_owned(), - "refresh".to_owned(), - "follow".to_owned(), - "stop".to_owned(), - ]; - if let Ok(services) = load_managed_services(paths) { - candidates.extend(services.into_iter().map(|service| service.service_id)); - } - candidates - } else if arg_index == 1 - && parts - .get(1) - .is_some_and(|value| value == "--service" || value == "--service-id") - { - load_managed_services(paths) - .map(|services| { - services - .into_iter() - .map(|service| service.service_id) - .collect() - }) - .unwrap_or_default() - } else { - Vec::new() - } -} - -fn runtime_adopt_argument_candidates(arg_index: usize, parts: &[String]) -> Vec { - let current_index = arg_index + 1; - if current_index >= parts.len() - && parts.last().is_some_and(|last| { - let (option_name, inline_value) = split_inline_option(last); - inline_value.is_none() - && RUNTIME_ADOPT_TUI_OPTIONS.contains(&option_name) - && option_name != "--replace" - }) - { - return if parts.last().is_some_and(|last| { - let (option_name, _) = split_inline_option(last); - option_name == "--channel" - }) { - vec!["release".to_owned(), "nightly".to_owned()] - } else { - Vec::new() - }; - } - - RUNTIME_ADOPT_TUI_OPTIONS - .iter() - .copied() - .filter(|candidate| { - !parts.iter().skip(2).any(|part| { - let (option_name, _) = split_inline_option(part); - option_name == *candidate - }) - }) - .map(str::to_owned) - .collect() -} - -fn update_argument_candidates(arg_index: usize, parts: &[String], paths: &AppPaths) -> Vec { - if arg_index == 0 { - return vec!["--apply".to_owned()]; - } - if parts - .get(arg_index) - .is_some_and(|value| value == "--runtime") - { - return load_onboarding_runtimes(paths) - .into_iter() - .filter(runtime_install_root_ready) - .map(|runtime| runtime.runtime_key.unwrap_or(runtime.runtime_id)) - .collect(); - } - let used = parts - .iter() - .filter(|part| part.starts_with("--")) - .cloned() - .collect::>(); - let used_preview = used - .iter() - .any(|used| used == "--preview" || used == "--dry-run"); - ["--runtime", "--preview", "--activate"] - .into_iter() - .filter(|option| { - if *option == "--preview" { - !used_preview - } else { - !used.iter().any(|used| used == option) - } - }) - .map(str::to_owned) - .collect() -} - -fn parse_update_command_args(args: &[&str]) -> Result { - if args.is_empty() { - return Ok(UpdateCommandPlan::Report); - } - - let mut apply = false; - let mut dry_run = false; - let mut activate = false; - let mut runtime: Option = None; - let mut index = 0; - while index < args.len() { - match args[index] { - "--apply" => { - if apply { - bail!("{UPDATE_USAGE}\n\nDuplicate option: --apply"); - } - apply = true; - index += 1; - } - "--preview" | "--dry-run" => { - if dry_run { - bail!("{UPDATE_USAGE}\n\nDuplicate option: --preview"); - } - dry_run = true; - index += 1; - } - "--activate" => { - if activate { - bail!("{UPDATE_USAGE}\n\nDuplicate option: --activate"); - } - activate = true; - index += 1; - } - "--runtime" => { - if runtime.is_some() { - bail!("{UPDATE_USAGE}\n\nDuplicate option: --runtime"); - } - let Some(value) = args.get(index + 1) else { - bail!("{UPDATE_USAGE}\n\nMissing value for --runtime"); - }; - if value.starts_with("--") { - bail!("{UPDATE_USAGE}\n\nMissing value for --runtime"); - } - runtime = Some((*value).to_owned()); - index += 2; - } - value if value.starts_with("--runtime=") => { - if runtime.is_some() { - bail!("{UPDATE_USAGE}\n\nDuplicate option: --runtime"); - } - let value = value - .split_once('=') - .map(|(_, value)| value.trim()) - .unwrap_or_default(); - if value.is_empty() { - bail!("{UPDATE_USAGE}\n\nMissing value for --runtime"); - } - runtime = Some(value.to_owned()); - index += 1; - } - value if value.starts_with("--") => { - bail!("{UPDATE_USAGE}\n\nUnknown update option: {value}"); - } - value => { - bail!("{UPDATE_USAGE}\n\nUnexpected update argument: {value}"); - } - } - } - - if !apply { - bail!("{UPDATE_USAGE}\n\nUse --apply before runtime update options"); - } - - let mut cli_args = vec!["update".to_owned(), "--apply".to_owned()]; - if let Some(runtime) = runtime { - cli_args.push("--runtime".to_owned()); - cli_args.push(runtime); - } - if dry_run { - cli_args.push("--dry-run".to_owned()); - } - if activate { - cli_args.push("--activate".to_owned()); - } - if dry_run { - Ok(UpdateCommandPlan::DryRun(cli_args)) - } else { - Ok(UpdateCommandPlan::Approval(cli_args)) - } -} - -fn slash_argument_completion_menu( - input: &str, - paths: &AppPaths, - requested_selection: usize, -) -> Option { - let trimmed = input.trim_start_matches('/'); - let trailing_space = input.ends_with(' '); - let Ok(parts) = split_command_line(trimmed) else { - return None; - }; - let command = parts.first()?.as_str(); - let current = if trailing_space { - "" - } else { - parts.last().map(String::as_str).unwrap_or_default() - }; - let arg_index = if trailing_space { - parts.len().saturating_sub(1) - } else { - parts.len().saturating_sub(2) - }; - let current_lower = current.to_ascii_lowercase(); - let matches = slash_argument_candidates(command, arg_index, &parts, paths) - .into_iter() - .filter(|candidate| candidate.to_ascii_lowercase().starts_with(¤t_lower)) - .collect::>(); - if matches.is_empty() { - return None; - } - if !trailing_space && matches.len() == 1 && matches[0].eq_ignore_ascii_case(current) { - return None; - } - let items = matches - .iter() - .map(|candidate| slash_argument_completion_label(command, arg_index, &parts, candidate)) - .collect::>(); - let replacements = matches - .iter() - .map(|candidate| Some(replace_current_token(input, current, candidate))) - .collect::>(); - let selected = selected_completion_index(requested_selection, &replacements); - Some(CompletionMenu { - title: "Completions".to_owned(), - items, - replacements, - selected, - }) -} - -fn slash_argument_completion_label( - command: &str, - arg_index: usize, - parts: &[String], - candidate: &str, -) -> String { - if command == "install" - && parts.get(1).is_some_and(|value| value == "sdk") - && parts - .get(arg_index) - .is_some_and(|value| value == "--format") - { - return match candidate { - "wheel" => install_sdk_format_label(InstallSdkFormat::Wheel).to_owned(), - "tarball" => install_sdk_format_label(InstallSdkFormat::Tarball).to_owned(), - _ => candidate.to_owned(), - }; - } - candidate.to_owned() -} - -fn selected_completion_index(requested: usize, replacements: &[Option]) -> usize { - if replacements.is_empty() { - return 0; - } - if replacements - .get(requested) - .is_some_and(std::option::Option::is_some) - { - return requested; - } - replacements.iter().position(Option::is_some).unwrap_or(0) -} - -fn serve_argument_candidates(arg_index: usize, parts: &[String]) -> Vec { - let part_index = arg_index + 1; - let previous = part_index.checked_sub(1).and_then(|index| parts.get(index)); - match previous.map(String::as_str) { - Some("--engine" | "with") => engine_inventory() - .iter() - .map(|(engine, _)| (*engine).to_owned()) - .collect(), - Some("--device") => ["gpu", "gpu_required"] - .into_iter() - .map(str::to_owned) - .collect(), - Some("--host") => ["127.0.0.1", "localhost"] - .into_iter() - .map(str::to_owned) - .collect(), - Some("--port") => vec![DEFAULT_LOCAL_PORT.to_string()], - _ => ["--engine", "--device", "--managed", "--host", "--port"] - .into_iter() - .map(str::to_owned) - .collect(), - } -} - -fn pending_proposal_ids(paths: &AppPaths) -> Vec { - load_recent_automation_proposals(paths, 20) - .map(|proposals| { - proposals - .into_iter() - .filter(|proposal| proposal.status == "pending") - .map(|proposal| proposal.proposal_id) - .collect() - }) - .unwrap_or_default() -} - -fn replace_current_token(input: &str, current: &str, replacement: &str) -> String { - if current.is_empty() || input.ends_with(' ') { - format!("{input}{replacement} ") - } else if let Some(prefix) = input.strip_suffix(current) { - format!("{prefix}{replacement} ") - } else { - format!("{input}{replacement} ") - } -} - -fn matching_slash_commands(prefix: &str) -> Vec<&'static SlashCommandSpec> { - let normalized = prefix.to_ascii_lowercase(); - SLASH_COMMANDS - .iter() - .filter(|command| command.name.starts_with(&normalized)) - .collect() -} - -fn slash_command_completion_label(command: &SlashCommandSpec) -> String { - let description = match command.name { - "help" | "?" => "choose a command", - "examine" => "check this computer", - "setup" => "set up ROCm", - "permissions" => "change approvals", - "runtimes" => "choose ROCm install", - "engine" => "choose model engine", - "model" => "choose model recipe", - "plan" => "review a request", - "config" => "open settings", - "automations" => "review background checks", - "reviews" => "open review requests", - "approve" => "approve review", - "reject" => "reject review", - "edit" => "edit review", - "install" => "install ROCm", - "services" => "local servers", - "logs" => "browse logs", - "gpu" => "GPU status", - "update" => "check updates", - "daemon" => "background helper", - "chat" => "chat provider status", - "provider" => "choose provider", - "uninstall" => "preview uninstall", - "serve" => "start local server", - "clear" => "clear transcript", - "quit" | "exit" => "exit", - _ => command.usage, - }; - format!("/{:<12} - {description}", command.name) -} - -fn status_sidebar_width(total_width: u16) -> u16 { - if total_width < 100 { - return total_width / 3; - } - (total_width / 3) - .clamp(42, 64) - .min(total_width.saturating_sub(60)) -} - -fn input_viewport(input: &str, cursor: usize, width: u16) -> (String, u16) { - let width = usize::from(width); - if width == 0 { - return (String::new(), 0); - } - let chars = input.chars().collect::>(); - let total = chars.len(); - let cursor = cursor.min(total); - if total <= width { - return (input.to_owned(), cursor.min(width.saturating_sub(1)) as u16); - } - - let mut start = if cursor >= width { - cursor + 1 - width - } else { - 0 - }; - if start + width > total { - start = total.saturating_sub(width); - } - let end = (start + width).min(total); - let visible = chars[start..end].iter().collect::(); - let cursor_column = cursor.saturating_sub(start).min(width.saturating_sub(1)); - (visible, cursor_column as u16) -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct ScrollMetrics { - content_len: usize, - viewport_len: usize, - offset: usize, -} - -impl ScrollMetrics { - const fn max_offset(self) -> usize { - self.content_len.saturating_sub(self.viewport_len) - } - - fn offset_u16(self) -> u16 { - self.offset.min(u16::MAX as usize) as u16 - } - - const fn should_draw(self) -> bool { - self.content_len > self.viewport_len - } -} - -fn scroll_metrics( - content_len: usize, - viewport_len: usize, - requested_offset: usize, -) -> ScrollMetrics { - let viewport_len = viewport_len.max(1); - let content_len = content_len.max(1); - let max_offset = content_len.saturating_sub(viewport_len); - ScrollMetrics { - content_len, - viewport_len, - offset: requested_offset.min(max_offset), - } -} - -fn text_scroll_metrics( - text: &str, - content_width: usize, - viewport_height: usize, - requested_offset: u16, -) -> ScrollMetrics { - let content_len = paragraph_visual_line_count(text, content_width); - let viewport_height = scrollable_text_viewport_len(content_len, viewport_height); - scroll_metrics(content_len, viewport_height, usize::from(requested_offset)) -} - -fn styled_text_scroll_metrics( - text: &Text<'_>, - content_width: usize, - viewport_height: usize, - requested_offset: u16, -) -> ScrollMetrics { - let content_len = styled_text_visual_line_count(text, content_width); - let viewport_height = scrollable_text_viewport_len(content_len, viewport_height); - scroll_metrics(content_len, viewport_height, usize::from(requested_offset)) -} - -fn scrollable_text_viewport_len(content_len: usize, viewport_height: usize) -> usize { - let viewport_height = viewport_height.max(1); - if content_len > viewport_height { - viewport_height.saturating_sub(1).max(1) - } else { - viewport_height - } -} - -fn scroll_latest_offset_by(current: usize, lines: i16, max_offset: usize) -> usize { - let current = current.min(max_offset); - if lines.is_negative() { - current - .saturating_add(usize::from(lines.unsigned_abs())) - .min(max_offset) - } else { - current.saturating_sub(lines as usize) - } -} - -fn draw_scrollbar(frame: &mut Frame<'_>, area: Rect, metrics: ScrollMetrics) { - if area.width == 0 || area.height == 0 || !metrics.should_draw() { - return; - } - let mut scrollbar_state = ScrollbarState::new(metrics.max_offset().saturating_add(1)) - .position(metrics.offset) - .viewport_content_length(metrics.viewport_len); - frame.render_stateful_widget( - Scrollbar::default().orientation(ScrollbarOrientation::VerticalRight), - area, - &mut scrollbar_state, - ); -} - -fn draw_completion_menu(frame: &mut Frame<'_>, menu: &CompletionMenu, area: Rect) { - if area.width == 0 || area.height < 2 { - return; - } - let (visible_start, visible_end) = completion_visible_range(menu); - let items = menu - .items - .iter() - .enumerate() - .skip(visible_start) - .take(visible_end.saturating_sub(visible_start)) - .map(|(index, item)| { - let style = if menu - .replacements - .get(index) - .is_some_and(std::option::Option::is_some) - { - Style::default() - } else { - Style::default().fg(Color::DarkGray) - }; - ListItem::new(item.as_str()).style(style) - }) - .collect::>(); - let mut state = ListState::default(); - if menu - .replacements - .get(menu.selected) - .is_some_and(std::option::Option::is_some) - { - state.select(Some(menu.selected.saturating_sub(visible_start))); - } - let completions = List::new(items) - .block( - Block::default() - .borders(Borders::ALL) - .title(menu.title.clone()) - .border_style(Style::default().fg(THEME_ACCENT)), - ) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(completions, area, &mut state); - let visible_len = visible_end.saturating_sub(visible_start); - draw_scrollbar( - frame, - area, - scroll_metrics(menu.items.len(), visible_len.max(1), visible_start), - ); -} - -fn centered_size_rect(width: u16, height: u16, area: Rect) -> Rect { - let width = width.min(area.width); - let height = height.min(area.height); - Rect { - x: area.x.saturating_add(area.width.saturating_sub(width) / 2), - y: area - .y - .saturating_add(area.height.saturating_sub(height) / 2), - width, - height, - } -} - -fn draw_command_prompt_popup( - frame: &mut Frame<'_>, - app: &App, - area: Rect, - completion_menu: Option<&CompletionMenu>, -) { - if area.width < 12 || area.height < 8 { - return; - } - let completion_height = completion_menu.map_or(0, |menu| { - menu.items.len().min(MAX_COMPLETION_MENU_ITEMS) as u16 + 2 - }); - let wanted_width = area.width.saturating_sub(4).min(96).max(48.min(area.width)); - let wanted_height = completion_height - .saturating_add(6) - .min(area.height.saturating_sub(2).max(8)) - .max(7); - let modal = centered_size_rect(wanted_width, wanted_height, area); - draw_modal_clear(frame, modal, area); - let block = surface_block(" Command ", THEME_ACCENT); - let inner = block.inner(modal); - frame.render_widget(block, modal); - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(completion_height), - Constraint::Length(3), - Constraint::Length(1), - ]) - .split(inner); - if let Some(menu) = completion_menu { - draw_completion_menu(frame, menu, rows[0]); - } - - let input_inner_width = rows[1].width.saturating_sub(2); - let (visible_input, visible_cursor) = - input_viewport(&app.input, app.input_cursor, input_inner_width); - let input = Paragraph::new(visible_input) - .block(surface_block("Run a / command", THEME_ACCENT)) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3)); - frame.render_widget(input, rows[1]); - let hint = Paragraph::new("Tab suggestions | Enter run | Esc close") - .style(Style::default().fg(THEME_MUTED).bg(THEME_PANEL_3)); - frame.render_widget(hint, rows[2]); - frame.set_cursor_position(( - rows[1] - .x - .saturating_add(1) - .saturating_add(visible_cursor) - .min(rows[1].right().saturating_sub(2)), - rows[1].y.saturating_add(1), - )); -} - -fn chat_input_area_height(app: &App, terminal_width: u16, terminal_height: u16) -> u16 { - if !app.command_screen_is_chat_session() || app.input.is_empty() { - return 3; - } - let inner_width = terminal_width.saturating_sub(2).max(1) as usize; - let wanted = chat_input_visual_line_count(&app.input, app.input_cursor, inner_width) - .min(6) - .saturating_add(2) as u16; - let max_height = terminal_height.saturating_sub(10).clamp(3, 8); - wanted.max(3).min(max_height) -} - -fn mouse_over_chat_input(app: &App, terminal_width: u16, terminal_height: u16, row: u16) -> bool { - let input_height = chat_input_area_height(app, terminal_width, terminal_height); - row >= terminal_height.saturating_sub(input_height.saturating_add(1)) - && row < terminal_height.saturating_sub(1) -} - -fn chat_input_visual_line_count(input: &str, cursor: usize, width: usize) -> usize { - let lines = wrapped_input_lines(input, width).len(); - let (cursor_row, _) = wrapped_input_cursor_position(input, cursor, width); - lines.max(cursor_row.saturating_add(1)).max(1) -} - -fn wrapped_input_lines(input: &str, width: usize) -> Vec { - let width = width.max(1); - let mut lines = Vec::new(); - let mut current = String::new(); - let mut current_width = 0usize; - for ch in input.chars() { - if ch == '\n' { - lines.push(current); - current = String::new(); - current_width = 0; - continue; - } - if current_width >= width { - lines.push(current); - current = String::new(); - current_width = 0; - } - current.push(ch); - current_width = current_width.saturating_add(1); - } - lines.push(current); - lines -} - -fn wrapped_input_cursor_position(input: &str, cursor: usize, width: usize) -> (usize, usize) { - let width = width.max(1); - let mut row = 0usize; - let mut col = 0usize; - for ch in input.chars().take(cursor.min(input.chars().count())) { - if ch == '\n' { - row = row.saturating_add(1); - col = 0; - continue; - } - if col >= width { - row = row.saturating_add(1); - col = 0; - } - col = col.saturating_add(1); - } - if col >= width { - row = row.saturating_add(1); - col = 0; - } - (row, col.min(width.saturating_sub(1))) -} - -fn bounded_chat_input_scroll( - input: &str, - cursor: usize, - width: u16, - inner_height: u16, - requested: u16, -) -> u16 { - let width = usize::from(width.max(1)); - let inner_height = usize::from(inner_height.max(1)); - let line_count = chat_input_visual_line_count(input, cursor, width); - let max_scroll = line_count - .saturating_sub(inner_height) - .min(u16::MAX as usize) as u16; - requested.min(max_scroll) -} - -fn chat_input_cursor_position( - input: &str, - cursor: usize, - width: u16, - scroll: u16, - inner_height: u16, -) -> Option<(u16, u16)> { - let (row, col) = wrapped_input_cursor_position(input, cursor, usize::from(width.max(1))); - let scroll = usize::from(scroll); - let inner_height = usize::from(inner_height.max(1)); - if row < scroll || row >= scroll.saturating_add(inner_height) { - return None; - } - Some((col as u16, row.saturating_sub(scroll) as u16)) -} - -fn run_loop(terminal: &mut Terminal>, app: &mut App) -> Result<()> { - let mut fallback_events: Option> = None; - while !app.should_quit { - app.refresh_sidebar_state(); - terminal - .draw(|frame| draw(frame, app)) - .context("failed to render rocm TUI")?; - - let poll_interval = if app.running_job.is_some() { - TUI_ACTIVE_POLL_INTERVAL - } else { - TUI_IDLE_POLL_INTERVAL - }; - let next_event = match event::poll(poll_interval) { - Ok(false) => None, - Ok(true) => match event::read() { - Ok(event) => Some(event), - Err(error) => fallback_terminal_event( - &mut fallback_events, - poll_interval, - &mut app.status, - error, - )?, - }, - Err(error) => fallback_terminal_event( - &mut fallback_events, - poll_interval, - &mut app.status, - error, - )?, - }; - - if let Some(event) = next_event { - handle_terminal_event(app, event); - } - } - Ok(()) -} - -fn fallback_terminal_event( - receiver: &mut Option>, - poll_interval: Duration, - status: &mut String, - error: std::io::Error, -) -> Result> { - if receiver.is_none() { - *status = "Keyboard ready. Arrow keys and Enter still work.".to_owned(); - *receiver = Some(spawn_fallback_terminal_reader()); - let _ = error; - } - let Some(receiver) = receiver else { - return Ok(None); - }; - match receiver.recv_timeout(poll_interval) { - Ok(event) => Ok(Some(event)), - Err(mpsc::RecvTimeoutError::Timeout) => Ok(None), - Err(mpsc::RecvTimeoutError::Disconnected) => { - bail!("portable terminal input reader stopped") - } - } -} - -fn spawn_fallback_terminal_reader() -> Receiver { - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let (byte_sender, byte_receiver) = mpsc::channel(); - std::thread::spawn(move || { - let stdin = io::stdin(); - let mut stdin = stdin.lock(); - let mut byte = [0u8; 1]; - while stdin.read_exact(&mut byte).is_ok() { - if byte_sender.send(byte[0]).is_err() { - break; - } - } - }); - while let Some(event) = read_fallback_terminal_event_from_receiver( - &byte_receiver, - FALLBACK_ESCAPE_SEQUENCE_TIMEOUT, - ) { - if sender.send(event).is_err() { - break; - } - } - }); - receiver -} - -fn read_fallback_terminal_event_from_receiver( - receiver: &Receiver, - escape_timeout: Duration, -) -> Option { - let byte = receiver.recv().ok()?; - fallback_terminal_event_from_byte(byte, &mut |timeout| { - receiver.recv_timeout(timeout.min(escape_timeout)).ok() - }) -} - -#[cfg(test)] -fn read_fallback_terminal_event(reader: &mut R) -> Option { - let mut byte = [0u8; 1]; - reader.read_exact(&mut byte).ok()?; - fallback_terminal_event_from_byte(byte[0], &mut |_| { - let mut next = [0u8; 1]; - reader.read_exact(&mut next).ok()?; - Some(next[0]) - }) -} - -fn fallback_terminal_event_from_byte( - byte: u8, - next_byte: &mut impl FnMut(Duration) -> Option, -) -> Option { - match byte { - b'\r' | b'\n' => Some(fallback_key(KeyCode::Enter)), - b'\t' => Some(fallback_key(KeyCode::Tab)), - 0x08 | 0x7f => Some(fallback_key(KeyCode::Backspace)), - 0x03 => Some(Event::Key(KeyEvent::new( - KeyCode::Char('c'), - KeyModifiers::CONTROL, - ))), - 0x1b => { - read_fallback_escape_sequence(next_byte).or_else(|| Some(fallback_key(KeyCode::Esc))) - } - value if value.is_ascii_control() => None, - value => Some(fallback_key(KeyCode::Char(value as char))), - } -} - -fn read_fallback_escape_sequence( - next_byte: &mut impl FnMut(Duration) -> Option, -) -> Option { - let second = next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?; - if second == b'O' { - let third = next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?; - return Some(fallback_key(match third { - b'P' => KeyCode::F(1), - b'Q' => KeyCode::F(2), - b'R' => KeyCode::F(3), - b'S' => KeyCode::F(4), - _ => KeyCode::Esc, - })); - } - if second != b'[' { - return Some(fallback_key(KeyCode::Esc)); - } - let third = next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?; - match third { - b'A' => Some(fallback_key(KeyCode::Up)), - b'B' => Some(fallback_key(KeyCode::Down)), - b'C' => Some(fallback_key(KeyCode::Right)), - b'D' => Some(fallback_key(KeyCode::Left)), - b'<' => read_fallback_sgr_mouse_sequence(next_byte), - b'M' => read_fallback_x10_mouse_sequence(next_byte), - b'5' => { - let _ = next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT); - Some(fallback_key(KeyCode::PageUp)) - } - b'6' => { - let _ = next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT); - Some(fallback_key(KeyCode::PageDown)) - } - value if value.is_ascii_digit() => read_fallback_csi_numbered_key(next_byte, value), - _ => Some(fallback_key(KeyCode::Esc)), - } -} - -fn read_fallback_csi_numbered_key( - next_byte: &mut impl FnMut(Duration) -> Option, - first: u8, -) -> Option { - let mut bytes = vec![first]; - loop { - let byte = next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?; - if byte == b'~' { - break; - } - if !byte.is_ascii_digit() || bytes.len() > 3 { - return Some(fallback_key(KeyCode::Esc)); - } - bytes.push(byte); - } - let number = std::str::from_utf8(&bytes).ok()?.parse::().ok()?; - let code = match number { - 1 => KeyCode::Home, - 2 => KeyCode::Insert, - 3 => KeyCode::Delete, - 4 => KeyCode::End, - 15 => KeyCode::F(5), - 17 => KeyCode::F(6), - 18 => KeyCode::F(7), - 19 => KeyCode::F(8), - 20 => KeyCode::F(9), - 21 => KeyCode::F(10), - 23 => KeyCode::F(11), - 24 => KeyCode::F(12), - 11 => KeyCode::F(1), - 12 => KeyCode::F(2), - 13 => KeyCode::F(3), - 14 => KeyCode::F(4), - 5 => KeyCode::PageUp, - 6 => KeyCode::PageDown, - _ => KeyCode::Esc, - }; - Some(fallback_key(code)) -} - -fn read_fallback_sgr_mouse_sequence( - next_byte: &mut impl FnMut(Duration) -> Option, -) -> Option { - let mut bytes = Vec::new(); - let final_byte = loop { - let byte = next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?; - if matches!(byte, b'M' | b'm') { - break byte; - } - if bytes.len() > 64 { - return Some(fallback_noop_event()); - } - bytes.push(byte); - }; - let text = String::from_utf8(bytes).ok()?; - let mut parts = text.split(';'); - let code = parts.next()?.parse::().ok()?; - let column = parts.next()?.parse::().ok()?.saturating_sub(1); - let row = parts.next()?.parse::().ok()?.saturating_sub(1); - fallback_mouse_event(code, column, row, final_byte) -} - -fn read_fallback_x10_mouse_sequence( - next_byte: &mut impl FnMut(Duration) -> Option, -) -> Option { - let code = u16::from(next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?.saturating_sub(32)); - let column = u16::from(next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?.saturating_sub(33)); - let row = u16::from(next_byte(FALLBACK_ESCAPE_SEQUENCE_TIMEOUT)?.saturating_sub(33)); - fallback_mouse_event(code, column, row, b'M') -} - -fn fallback_mouse_event(code: u16, column: u16, row: u16, final_byte: u8) -> Option { - let button = code & !(4 | 8 | 16 | 32); - let kind = match button { - 64 => MouseEventKind::ScrollUp, - 65 => MouseEventKind::ScrollDown, - _ => return Some(fallback_noop_event()), - }; - if final_byte == b'm' { - return Some(fallback_noop_event()); - } - Some(Event::Mouse(MouseEvent { - kind, - column, - row, - modifiers: fallback_mouse_modifiers(code), - })) -} - -fn fallback_mouse_modifiers(code: u16) -> KeyModifiers { - let mut modifiers = KeyModifiers::empty(); - if code & 4 != 0 { - modifiers.insert(KeyModifiers::SHIFT); - } - if code & 8 != 0 { - modifiers.insert(KeyModifiers::ALT); - } - if code & 16 != 0 { - modifiers.insert(KeyModifiers::CONTROL); - } - modifiers -} - -const fn fallback_noop_event() -> Event { - Event::FocusGained -} - -const fn fallback_key(code: KeyCode) -> Event { - Event::Key(KeyEvent::new(code, KeyModifiers::NONE)) -} - -fn handle_terminal_event(app: &mut App, event: Event) { - match event { - Event::Key(key) => handle_key(app, key), - Event::Mouse(mouse) => handle_mouse(app, mouse), - Event::Paste(text) if app.onboarding_active && app.onboarding_path_editing => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars().filter(|ch| *ch != '\n') { - app.insert_input_char(ch); - } - } - Event::Paste(text) if app.install_manager_editing_text() => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars().filter(|ch| *ch != '\n') { - app.insert_input_char(ch); - } - } - Event::Paste(text) if app.logs_view_editing_text() => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars().filter(|ch| *ch != '\n') { - app.insert_input_char(ch); - } - } - Event::Paste(text) if app.runtime_manager_editing_text() => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars().filter(|ch| *ch != '\n') { - app.insert_input_char(ch); - } - } - Event::Paste(text) if app.serve_wizard_editing_text() => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars().filter(|ch| *ch != '\n') { - app.insert_input_char(ch); - } - } - Event::Paste(text) if app.automations_manager_editing_text() => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars().filter(|ch| *ch != '\n') { - app.insert_input_char(ch); - } - } - Event::Paste(text) if app.config_manager_editing_secret() => { - let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); - for ch in normalized.chars().filter(|ch| *ch != '\n') { - app.insert_input_char(ch); - } - } - Event::Paste(text) if surface_command_prompt_active(app) || should_draw_prompt_box(app) => { - app.paste_text(&text); - } - Event::Paste(_) => { - app.status = - "Use the visible choices here; there is no text box on this screen.".to_owned(); - } - _ => {} - } -} - -fn handle_mouse(app: &mut App, mouse: MouseEvent) { - if app.onboarding_success_modal { - if matches!(mouse.kind, MouseEventKind::Down(_)) { - app.finish_onboarding(); - } - return; - } - - match mouse.kind { - MouseEventKind::Down(MouseButton::Left) => { - let (terminal_width, terminal_height) = size().unwrap_or((0, 0)); - if app.folder_browser.is_some() - && click_folder_browser( - app, - mouse.column, - mouse.row, - terminal_width, - terminal_height, - ) - { - return; - } - if app.should_draw_home_dashboard() - && click_home_dashboard( - app, - mouse.column, - mouse.row, - terminal_width, - terminal_height, - ) - { - return; - } - } - MouseEventKind::Drag(_) | MouseEventKind::Moved | MouseEventKind::Up(_) => { - return; - } - _ => {} - } - - let lines = match mouse.kind { - MouseEventKind::ScrollUp => -MOUSE_SCROLL_LINES, - MouseEventKind::ScrollDown => MOUSE_SCROLL_LINES, - _ => return, - }; - - if should_draw_running_job_modal(app) { - app.scroll_running_job_log(lines); - return; - } - if app.folder_browser.is_some() { - app.page_folder_browser_selection(lines); - return; - } - if app.overlay_card.is_some() { - app.scroll_overlay_card_detail(lines); - return; - } - if app.pending_approval.is_some() { - app.scroll_pending_approval_detail(lines); - return; - } - if app.onboarding_active { - app.scroll_onboarding_install_log_by(lines); - return; - } - let command_detail_modal_open = app - .command_screen - .as_ref() - .is_some_and(|state| state.detail_modal.is_some()); - if app.command_screen_is_chat_session() || command_detail_modal_open { - let (terminal_width, terminal_height) = size().unwrap_or((0, 0)); - if !command_detail_modal_open - && app.command_screen_is_chat_session() - && !app.input.is_empty() - && mouse_over_chat_input(app, terminal_width, terminal_height, mouse.row) - { - app.scroll_chat_input(lines); - } else { - app.scroll_command_screen_detail(lines); - } - return; - } - if app - .logs_view - .as_ref() - .is_some_and(|state| state.detail_modal.is_some()) - { - app.scroll_logs_view_detail_modal(lines); - return; - } - if app.examine_manager.is_some() { - app.scroll_examine_manager_detail(lines); - } else if app.logs_view.is_some() { - app.scroll_logs_view_detail(lines); - } else if app.runtime_manager.is_some() - || app.install_manager.is_some() - || app.engine_manager.is_some() - || app.model_picker.is_some() - || app.serve_wizard.is_some() - || app.update_manager.is_some() - || app.automations_manager.is_some() - || app.config_manager.is_some() - || app.provider_manager.is_some() - || app.services_manager.is_some() - { - app.scroll_active_manager_detail(lines); - } else if app.command_screen.is_some() { - app.scroll_command_screen_detail(lines); - } else if lines.is_negative() { - app.scroll_up(lines.unsigned_abs()); - } else { - app.scroll_down(lines as u16); - } -} - -fn click_home_dashboard(app: &mut App, column: u16, row: u16, width: u16, height: u16) -> bool { - let area = main_content_area(app, width, height); - let Some(list_inner) = home_dashboard_list_inner_rect(app, area) else { - return false; - }; - if !rect_contains(list_inner, column, row) { - return false; - } - let index = usize::from(row.saturating_sub(list_inner.y)); - let actions = home_dashboard_actions(app); - if index >= actions.len() { - return false; - } - app.home_selection = index; - app.perform_home_dashboard_action(); - true -} - -fn click_folder_browser(app: &mut App, column: u16, row: u16, width: u16, height: u16) -> bool { - let area = Rect { - x: 0, - y: 0, - width, - height, - }; - let Some(list_inner) = folder_browser_list_inner_rect(area) else { - return false; - }; - if !rect_contains(list_inner, column, row) { - return false; - } - let Some(state) = app.folder_browser.as_ref() else { - return false; - }; - let (visible_start, visible_end) = - folder_browser_visible_range(state, usize::from(list_inner.height)); - let index = visible_start.saturating_add(usize::from(row.saturating_sub(list_inner.y))); - if index >= visible_end || index >= state.entries.len() { - return false; - } - if let Some(state) = app.folder_browser.as_mut() { - state.selected = index; - state.scroll_offset = visible_start; - } - app.perform_folder_browser_selection(); - true -} - -fn main_content_area(app: &App, width: u16, height: u16) -> Rect { - let area = Rect { - x: 0, - y: 0, - width, - height, - }; - let command_prompt_popup = surface_command_prompt_active(app); - let prompt_box_visible = should_draw_prompt_box(app) && !command_prompt_popup; - let completion_height = if prompt_box_visible { - app.slash_completion_menu().as_ref().map_or(0, |menu| { - menu.items.len().min(MAX_COMPLETION_MENU_ITEMS) as u16 + 2 - }) - } else { - 0 - }; - let input_height = if prompt_box_visible { - chat_input_area_height(app, width, height) - } else { - 0 - }; - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(8), - Constraint::Length(completion_height), - Constraint::Length(input_height), - Constraint::Length(1), - ]) - .split(area); - let sidebar_width = if app.command_screen_is_chat_session() { - 0 - } else { - status_sidebar_width(layout[0].width) - }; - let body = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(60), Constraint::Length(sidebar_width)]) - .split(layout[0]); - body[0] -} - -fn home_dashboard_list_inner_rect(app: &App, area: Rect) -> Option { - let overview_text = render_home_dashboard_overview_text(app); - let overview_height = (overview_text.lines().count() as u16).saturating_add(2); - let action_height = (home_dashboard_actions(app).len() as u16).saturating_add(2); - if area.width < 78 || area.height < overview_height.saturating_add(action_height) { - return None; - } - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(overview_height), Constraint::Min(8)]) - .split(area); - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(40), Constraint::Min(40)]) - .split(rows[1]); - Some(rect_inner(panes[0])) -} - -fn folder_browser_list_inner_rect(area: Rect) -> Option { - if area.width == 0 || area.height == 0 { - return None; - } - let modal = centered_rect(92, 86, area); - let inner = rect_inner(modal); - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), - Constraint::Min(8), - Constraint::Length(3), - ]) - .split(inner); - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(58), Constraint::Min(28)]) - .split(rows[1]); - Some(rect_inner(panes[0])) -} - -const fn rect_inner(rect: Rect) -> Rect { - Rect { - x: rect.x.saturating_add(1), - y: rect.y.saturating_add(1), - width: rect.width.saturating_sub(2), - height: rect.height.saturating_sub(2), - } -} - -const fn rect_contains(rect: Rect, column: u16, row: u16) -> bool { - column >= rect.x && column < rect.right() && row >= rect.y && row < rect.bottom() -} - -fn handle_key(app: &mut App, key: KeyEvent) { - if key.kind == KeyEventKind::Release { - return; - } - - clear_prompt_for_active_surface_action(app, key); - - if app.onboarding_success_modal { - app.finish_onboarding(); - return; - } - - if app.pending_approval.is_some() { - if app.onboarding_active { - if handle_onboarding_key(app, key) { - return; - } - } else if handle_pending_approval_key(app, key) { - return; - } - app.status = if matches!( - (key.modifiers, key.code), - (KeyModifiers::CONTROL, KeyCode::Char('c')) - ) { - "Finish or cancel this review before quitting.".to_owned() - } else { - "Review card is open. Use arrows, Enter/Y, N, or Esc.".to_owned() - }; - return; - } - - if matches!(key.code, KeyCode::F(1)) { - app.open_interactive_help_overlay(); - return; - } - - if app.overlay_card.is_some() && handle_overlay_card_key(app, key) { - return; - } - - if app.folder_browser.is_some() && handle_folder_browser_key(app, key) { - return; - } - - if should_draw_running_job_modal(app) && handle_running_job_modal_key(app, key) { - return; - } - - if app.onboarding_active && handle_onboarding_key(app, key) { - return; - } - if app.onboarding_active { - return; - } - - if app.examine_manager.is_some() && handle_examine_manager_key(app, key) { - return; - } - - if app.logs_view.is_some() && handle_logs_view_key(app, key) { - return; - } - - if app.runtime_manager.is_some() && handle_runtime_manager_key(app, key) { - return; - } - - if app.install_manager.is_some() && handle_install_manager_key(app, key) { - return; - } - - if app.engine_manager.is_some() && handle_engine_manager_key(app, key) { - return; - } - - if app.model_picker.is_some() && handle_model_picker_key(app, key) { - return; - } - - if app.serve_wizard.is_some() && handle_serve_wizard_key(app, key) { - return; - } - - if app.update_manager.is_some() && handle_update_manager_key(app, key) { - return; - } - - if app.automations_manager.is_some() && handle_automations_manager_key(app, key) { - return; - } - - if app.config_manager.is_some() && handle_config_manager_key(app, key) { - return; - } - - if app.provider_manager.is_some() && handle_provider_manager_key(app, key) { - return; - } - - if app.services_manager.is_some() && handle_services_manager_key(app, key) { - return; - } - - if app.command_screen.is_some() && handle_command_screen_key(app, key) { - return; - } - - if app.should_draw_home_dashboard() && handle_home_dashboard_key(app, key) { - return; - } - - if !should_draw_prompt_box(app) && handle_hidden_prompt_key(app, key) { - return; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.request_quit_overlay_or_wait(); - } - (KeyModifiers::CONTROL, KeyCode::Char('a')) => { - app.move_input_start(); - } - (KeyModifiers::CONTROL, KeyCode::Char('e')) => { - app.move_input_end(); - } - (KeyModifiers::CONTROL, KeyCode::Char('u')) => { - app.clear_input(); - app.status = "Input cleared.".to_owned(); - } - (KeyModifiers::CONTROL, KeyCode::Char('w')) => { - app.delete_input_word_before_cursor(); - app.status = "Deleted previous word.".to_owned(); - } - (KeyModifiers::CONTROL, KeyCode::Char('l')) => { - app.open_clear_overlay(); - } - (KeyModifiers::CONTROL, KeyCode::Char('r')) => { - app.refresh_status(); - } - (_, KeyCode::F(5)) => { - app.refresh_status(); - } - (_, KeyCode::Tab) if !app.cycle_completion(CompletionDirection::Next) => { - app.status = "No completion menu available.".to_owned(); - } - (_, KeyCode::BackTab) if !app.cycle_completion(CompletionDirection::Previous) => { - app.status = "No completion menu available.".to_owned(); - } - (_, KeyCode::Char('y' | 'Y')) if app.pending_approval.is_some() && app.input.is_empty() => { - app.approve_focused_action(); - } - (_, KeyCode::Char('n' | 'N')) if app.pending_approval.is_some() && app.input.is_empty() => { - app.reject_or_cancel_focused_action(); - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - if surface_command_prompt_active(app) && command_popup_should_submit_on_enter(app) { - app.submit(); - return; - } - if app.accept_completion() { - return; - } - if app.pending_approval.is_some() && app.input.is_empty() { - app.approve_focused_action(); - } else { - app.submit(); - } - } - (_, KeyCode::Char('?')) if app.input.is_empty() => { - app.handle_command("?"); - } - (_, KeyCode::Backspace) => { - app.backspace_input(); - } - (_, KeyCode::Delete) => { - app.delete_input_char(); - } - (_, KeyCode::Esc) => { - if app.pending_approval.is_some() { - app.cancel_focused_action(); - } else if app.input.is_empty() { - app.status = "Input already empty. Use /quit or Ctrl-C to exit.".to_owned(); - } else { - app.clear_input(); - app.status = "Input cleared.".to_owned(); - } - } - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Up) if !app.cycle_completion(CompletionDirection::Previous) => { - app.previous_history(); - } - (_, KeyCode::Down) if !app.cycle_completion(CompletionDirection::Next) => { - app.next_history(); - } - (_, KeyCode::PageUp) => { - app.scroll_up(10); - } - (_, KeyCode::PageDown) => { - app.scroll_down(10); - } - (_, KeyCode::Home) => { - if app.input.is_empty() { - app.transcript_scroll = 0; - app.status = "Transcript moved to top.".to_owned(); - } else { - app.move_input_start(); - } - } - (_, KeyCode::End) => { - if app.input.is_empty() { - app.scroll_to_bottom(); - app.status = "Transcript moved to bottom.".to_owned(); - } else { - app.move_input_end(); - } - } - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.insert_input_char(ch); - } - _ => {} - } -} - -fn handle_home_dashboard_key(app: &mut App, key: KeyEvent) -> bool { - if !app.input.is_empty() { - return false; - } - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) | (_, KeyCode::Enter) => { - app.perform_home_dashboard_action(); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_home_dashboard_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_home_dashboard_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Home) => { - app.home_selection = 0; - if let Some(action) = app.selected_home_dashboard_action() { - app.status = home_dashboard_action_status(action).to_owned(); - } - true - } - (_, KeyCode::End) => { - app.home_selection = home_dashboard_actions(app).len().saturating_sub(1); - if let Some(action) = app.selected_home_dashboard_action() { - app.status = home_dashboard_action_status(action).to_owned(); - } - true - } - (_, KeyCode::Esc) => { - app.request_quit_overlay_or_wait(); - true - } - (_, KeyCode::Backspace) => { - app.status = "Main menu is open. Use arrows or mouse.".to_owned(); - true - } - _ => false, - } -} - -fn handle_hidden_prompt_key(app: &mut App, key: KeyEvent) -> bool { - if surface_command_prompt_active(app) { - return false; - } - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Esc) => { - app.open_home_dashboard(); - true - } - (_, KeyCode::Char('/')) if app.input.is_empty() => { - app.insert_input_char('/'); - app.status = "Command prompt opened. Type a / command, then Enter.".to_owned(); - true - } - (_, KeyCode::Char('?')) if app.input.is_empty() => { - app.handle_command("?"); - true - } - (modifiers, KeyCode::Char(_)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.status = "Use the menu. Press Esc for the main menu.".to_owned(); - true - } - (_, KeyCode::Backspace | KeyCode::Delete | KeyCode::Left | KeyCode::Right) => { - app.status = "Use the menu. Press Esc for the main menu.".to_owned(); - true - } - _ => false, - } -} - -fn active_surface_should_defer_to_prompt(app: &App, key: KeyEvent) -> bool { - app.pending_approval.is_some() - || surface_command_prompt_active(app) - || (app.input.is_empty() - && !app.onboarding_active - && matches!(key.code, KeyCode::Char('/'))) - || (!app.input.is_empty() && !active_surface_navigation_key(key)) -} - -fn command_popup_should_submit_on_enter(app: &App) -> bool { - let trimmed = app.input.trim(); - let Some(name) = trimmed.strip_prefix('/') else { - return false; - }; - if name.is_empty() { - return false; - } - if app - .slash_completion_menu() - .as_ref() - .and_then(CompletionMenu::selected_replacement) - .is_some() - { - return false; - } - if name.chars().any(char::is_whitespace) { - return true; - } - SLASH_COMMANDS.iter().any(|command| command.name == name) -} - -fn surface_command_prompt_active(app: &App) -> bool { - app.input.starts_with('/') - && !app.onboarding_active - && !should_draw_prompt_box(app) - && app.pending_approval.is_none() - && app.overlay_card.is_none() - && app.folder_browser.is_none() - && !should_draw_running_job_modal(app) -} - -const fn active_surface_navigation_key(key: KeyEvent) -> bool { - active_surface_action_key(key) - || matches!( - key.code, - KeyCode::Up - | KeyCode::Down - | KeyCode::Tab - | KeyCode::BackTab - | KeyCode::Home - | KeyCode::End - | KeyCode::PageUp - | KeyCode::PageDown - | KeyCode::F(5) - | KeyCode::Left - | KeyCode::Right - | KeyCode::Esc - ) -} - -const fn active_surface_action_key(key: KeyEvent) -> bool { - matches!( - (key.modifiers, key.code), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) - ) -} - -fn clear_prompt_for_active_surface_action(app: &mut App, key: KeyEvent) { - if app.input.is_empty() - || app.pending_approval.is_some() - || app.overlay_card.is_some() - || app.folder_browser.is_some() - || should_draw_running_job_modal(app) - || !active_surface_action_key(key) - || app.onboarding_path_editing - || app.install_manager_editing_text() - || app.logs_view_editing_text() - || app.runtime_manager_editing_text() - || app.serve_wizard_editing_text() - || app.automations_manager_editing_text() - || app.config_manager_editing_secret() - || surface_command_prompt_active(app) - { - return; - } - - if app.onboarding_active - || app.examine_manager.is_some() - || app.logs_view.is_some() - || app.runtime_manager.is_some() - || app.install_manager.is_some() - || app.engine_manager.is_some() - || app.model_picker.is_some() - || app.serve_wizard.is_some() - || app.update_manager.is_some() - || app.automations_manager.is_some() - || app.config_manager.is_some() - || app.provider_manager.is_some() - || app.services_manager.is_some() - || (app.command_screen.is_some() && !app.command_screen_is_chat_session()) - { - app.clear_input(); - } -} - -fn handle_pending_approval_key(app: &mut App, key: KeyEvent) -> bool { - app.clear_input(); - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_pending_approval_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_pending_approval_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Home) => { - if let Some(pending) = app.pending_approval.as_mut() { - pending.selected = 0; - } - app.status = "Approve selected. Press Enter to continue.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(pending) = app.pending_approval.as_mut() { - pending.selected = 1; - } - app.status = - if app.pending_approval.as_ref().is_some_and(|pending| { - matches!(pending.action, ApprovalAction::Proposal { .. }) - }) { - "Reject selected. Press Enter to reject.".to_owned() - } else { - "Cancel selected. Press Enter to cancel.".to_owned() - }; - true - } - (_, KeyCode::PageUp) => { - app.scroll_pending_approval_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_pending_approval_detail(10); - true - } - (_, KeyCode::Char('y' | 'Y')) => { - app.approve_focused_action(); - true - } - (_, KeyCode::Char('n' | 'N')) => { - app.reject_or_cancel_focused_action(); - true - } - (_, KeyCode::Esc) => { - app.cancel_focused_action_to_home(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_pending_approval_selection(); - true - } - _ => false, - } -} - -fn handle_overlay_card_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_overlay_card_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_overlay_card_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.overlay_card.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - } - app.status = "Moved to first card option.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.overlay_card.as_mut() { - state.selected = state.actions.len().saturating_sub(1); - state.detail_scroll = 0; - } - app.status = "Moved to last card option.".to_owned(); - true - } - (_, KeyCode::PageUp) => { - app.scroll_overlay_card_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_overlay_card_detail(10); - true - } - (_, KeyCode::Esc) => { - app.close_overlay_card(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_overlay_card_action(); - true - } - (_, KeyCode::Char('y' | 'Y')) => { - if !app.confirm_overlay_card_danger_action() { - app.status = "Use Enter to choose, or Esc to close this card.".to_owned(); - } - true - } - (_, KeyCode::Char('n' | 'N')) => { - app.close_overlay_card(); - true - } - _ => { - app.status = "Use arrows, Enter, or Esc while this card is open.".to_owned(); - true - } - } -} - -fn handle_folder_browser_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_folder_browser_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_folder_browser_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.folder_browser.as_mut() { - state.selected = 0; - } - app.status = "Moved to the top of the folder list.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.folder_browser.as_mut() { - state.selected = state.entries.len().saturating_sub(1); - } - app.status = "Moved to the bottom of the folder list.".to_owned(); - true - } - (_, KeyCode::PageUp) => { - app.page_folder_browser_selection(-PAGE_SCROLL_LINES); - true - } - (_, KeyCode::PageDown) => { - app.page_folder_browser_selection(PAGE_SCROLL_LINES); - true - } - (_, KeyCode::Left | KeyCode::Backspace) => { - let parent = app.folder_browser.as_ref().and_then(|state| { - state - .current_dir - .parent() - .filter(|parent| !parent.as_os_str().is_empty()) - .map(Path::to_path_buf) - }); - if let Some(parent) = parent { - app.navigate_folder_browser_to(parent); - } else { - app.status = "Already at the top.".to_owned(); - } - true - } - (_, KeyCode::Esc) => { - app.close_folder_browser(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_folder_browser_selection(); - true - } - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - if let Some(drive) = folder_browser_drive_root_for_key(ch) { - app.navigate_folder_browser_to(drive); - } else { - app.status = "Choose a folder, or type a drive letter like D.".to_owned(); - } - true - } - _ => { - app.status = "Choose a folder, then press Enter.".to_owned(); - true - } - } -} - -fn handle_running_job_modal_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.request_quit_overlay_or_wait(); - } - (_, KeyCode::PageUp) => { - app.scroll_running_job_log(-PAGE_SCROLL_LINES); - } - (_, KeyCode::PageDown) => { - app.scroll_running_job_log(PAGE_SCROLL_LINES); - } - (_, KeyCode::Esc) => { - app.status = "Wait for this work to finish before leaving this screen.".to_owned(); - } - (_, KeyCode::Up | KeyCode::Down | KeyCode::Tab | KeyCode::BackTab) => { - if app.onboarding_install_running() { - let lines = if matches!(key.code, KeyCode::Up | KeyCode::BackTab) { - -1 - } else { - 1 - }; - app.scroll_running_job_log(lines); - } else { - app.status = "This work is still running. Choices are paused for now.".to_owned(); - } - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.status = - "Wait for the running work to finish before choosing another action.".to_owned(); - } - _ => { - app.status = "This work is still running.".to_owned(); - } - } - true -} - -fn handle_examine_manager_key(app: &mut App, key: KeyEvent) -> bool { - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_examine_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_examine_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_examine_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_examine_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.examine_manager.as_mut() { - state.selected = 0; - state.message = None; - state.detail_scroll = 0; - } - app.status = "Moved to first examine check.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.examine_manager.as_mut() { - state.selected = examine_manager_choices().len().saturating_sub(1); - state.message = None; - state.detail_scroll = 0; - } - app.status = "Moved to last examine choice.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.refresh_examine_manager(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_examine_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_examine_manager(); - true - } - _ => false, - } -} - -fn handle_runtime_manager_key(app: &mut App, key: KeyEvent) -> bool { - if app.runtime_manager_editing_text() { - return handle_runtime_manager_text_key(app, key); - } - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_runtime_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_runtime_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.runtime_manager.as_mut() { - match &mut state.screen { - RuntimeManagerScreen::List => { - state.selected = 0; - state.last_item_selected = 0; - } - RuntimeManagerScreen::Advanced { selected } => *selected = 0, - RuntimeManagerScreen::ImportManifest { selected, .. } - | RuntimeManagerScreen::AdoptExisting { selected, .. } => *selected = 0, - } - state.detail_scroll = 0; - } - app.status = "Moved to first choice.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.runtime_manager.as_mut() { - match &mut state.screen { - RuntimeManagerScreen::List => { - state.selected = runtime_manager_choice_count(state).saturating_sub(1); - } - RuntimeManagerScreen::Advanced { selected } => { - *selected = runtime_advanced_choices().len().saturating_sub(1); - } - RuntimeManagerScreen::ImportManifest { selected, .. } => { - *selected = runtime_import_choices().len().saturating_sub(1); - } - RuntimeManagerScreen::AdoptExisting { selected, .. } => { - *selected = runtime_adopt_choices().len().saturating_sub(1); - } - } - if matches!(state.screen, RuntimeManagerScreen::List) - && state.selected < state.items.len() - { - state.last_item_selected = state.selected; - } - state.detail_scroll = 0; - } - app.status = "Moved to last choice.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.refresh_runtime_manager(); - app.status = "Runtime list refreshed.".to_owned(); - true - } - (_, KeyCode::Char('i' | 'I')) => { - if app - .runtime_manager - .as_ref() - .is_some_and(|state| matches!(state.screen, RuntimeManagerScreen::List)) - { - app.request_runtime_manager_install(); - } else { - app.status = "Use arrow keys and Enter on this screen.".to_owned(); - } - true - } - (_, KeyCode::Char('u' | 'U') | KeyCode::Delete) => { - if app - .runtime_manager - .as_ref() - .is_some_and(|state| matches!(state.screen, RuntimeManagerScreen::List)) - { - app.request_runtime_manager_uninstall(); - } else { - app.status = "Use arrow keys and Enter on this screen.".to_owned(); - } - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_runtime_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - if app - .runtime_manager - .as_ref() - .is_some_and(|state| !matches!(state.screen, RuntimeManagerScreen::List)) - { - app.close_runtime_subscreen(); - } else { - app.close_runtime_manager(); - } - true - } - _ => false, - } -} - -fn handle_runtime_manager_text_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.close_runtime_subscreen(); - } - (KeyModifiers::CONTROL, KeyCode::Char('a')) => app.move_input_start(), - (KeyModifiers::CONTROL, KeyCode::Char('e')) => app.move_input_end(), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => app.clear_input(), - (KeyModifiers::CONTROL, KeyCode::Char('w')) => app.delete_input_word_before_cursor(), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - let import_editing = app.runtime_manager.as_ref().is_some_and(|state| { - matches!( - state.screen, - RuntimeManagerScreen::ImportManifest { - editing_path: true, - .. - } - ) - }); - if import_editing { - app.commit_runtime_import_path_edit(); - } else { - app.commit_runtime_adopt_python_edit(); - } - } - (_, KeyCode::Esc) => { - let import_editing = app.runtime_manager.as_ref().is_some_and(|state| { - matches!( - state.screen, - RuntimeManagerScreen::ImportManifest { - editing_path: true, - .. - } - ) - }); - if import_editing { - app.cancel_runtime_import_path_edit(); - } else { - app.cancel_runtime_adopt_python_edit(); - } - } - (_, KeyCode::Backspace) => app.backspace_input(), - (_, KeyCode::Delete) => app.delete_input_char(), - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Home) => app.move_input_start(), - (_, KeyCode::End) => app.move_input_end(), - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.insert_input_char(ch); - } - _ => {} - } - true -} - -fn handle_install_manager_key(app: &mut App, key: KeyEvent) -> bool { - if app.install_manager_editing_text() { - return handle_install_manager_text_key(app, key); - } - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_install_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_install_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Left) => { - match app.selected_install_sdk_choice() { - InstallSdkChoice::Channel => app.toggle_install_sdk_channel(), - InstallSdkChoice::Folder => { - app.cycle_install_sdk_folder_preset(CompletionDirection::Previous); - } - _ => { - app.status = "Use Up and Down to choose an install option.".to_owned(); - } - } - true - } - (_, KeyCode::Right) => { - match app.selected_install_sdk_choice() { - InstallSdkChoice::Channel => app.toggle_install_sdk_channel(), - InstallSdkChoice::Folder => { - app.cycle_install_sdk_folder_preset(CompletionDirection::Next); - } - _ => { - app.status = "Use Up and Down to choose an install option.".to_owned(); - } - } - true - } - (_, KeyCode::Char('t' | 'T')) - if app.selected_install_sdk_choice() == InstallSdkChoice::Folder => - { - app.start_install_sdk_folder_edit(); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.install_manager.as_mut() { - match &mut state.screen { - InstallManagerScreen::Main => state.selected = 0, - InstallManagerScreen::Sdk { selected, .. } => *selected = 0, - } - state.detail_scroll = 0; - } - app.status = "Moved to first install option.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.install_manager.as_mut() { - match &mut state.screen { - InstallManagerScreen::Main => { - state.selected = install_menu_choices().len().saturating_sub(1); - } - InstallManagerScreen::Sdk { selected, .. } => { - *selected = install_sdk_choices().len().saturating_sub(1); - } - } - state.detail_scroll = 0; - } - app.status = "Moved to last install option.".to_owned(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_install_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - if app - .install_manager - .as_ref() - .is_some_and(|state| matches!(state.screen, InstallManagerScreen::Sdk { .. })) - { - app.close_install_sdk_form(); - } else { - app.close_install_manager(); - } - true - } - _ => false, - } -} - -fn handle_install_manager_text_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => app.cancel_install_sdk_folder_edit(), - (KeyModifiers::CONTROL, KeyCode::Char('a')) => app.move_input_start(), - (KeyModifiers::CONTROL, KeyCode::Char('e')) => app.move_input_end(), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => app.clear_input(), - (KeyModifiers::CONTROL, KeyCode::Char('w')) => app.delete_input_word_before_cursor(), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => app.commit_install_sdk_folder_edit(), - (_, KeyCode::Esc) => app.cancel_install_sdk_folder_edit(), - (_, KeyCode::Backspace) => app.backspace_input(), - (_, KeyCode::Delete) => app.delete_input_char(), - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Home) => app.move_input_start(), - (_, KeyCode::End) => app.move_input_end(), - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.insert_input_char(ch); - } - _ => {} - } - true -} - -fn handle_engine_manager_key(app: &mut App, key: KeyEvent) -> bool { - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_engine_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_engine_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.engine_manager.as_mut() { - state.selected = 0; - state.last_item_selected = 0; - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to first engine option.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.engine_manager.as_mut() { - state.selected = engine_manager_choice_count(state).saturating_sub(1); - if state.selected < state.items.len() { - state.last_item_selected = state.selected; - } - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to last engine option.".to_owned(); - true - } - (_, KeyCode::Char('i' | 'I')) => { - app.request_selected_engine_install(false); - true - } - (_, KeyCode::Char('r' | 'R')) => { - app.request_selected_engine_install(true); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_engine_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_engine_manager(); - true - } - _ => false, - } -} - -fn handle_model_picker_key(app: &mut App, key: KeyEvent) -> bool { - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_model_picker_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_model_picker_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.model_picker.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to first model.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.model_picker.as_mut() { - state.selected = state.items.len().saturating_sub(1); - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to last model.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.open_model_picker(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.plan_selected_model_recipe(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_model_picker(); - true - } - _ => false, - } -} - -fn handle_serve_wizard_key(app: &mut App, key: KeyEvent) -> bool { - if app.serve_wizard_editing_text() { - return handle_serve_wizard_text_key(app, key); - } - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_serve_wizard_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_serve_wizard_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Left) => { - app.cycle_serve_wizard_value(CompletionDirection::Previous); - true - } - (_, KeyCode::Right) => { - app.cycle_serve_wizard_value(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.serve_wizard.as_mut() { - state.selected = first_enabled_serve_wizard_index(state); - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to model choice.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.serve_wizard.as_mut() { - state.selected = last_enabled_serve_wizard_index(state); - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to back.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.open_serve_wizard(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_serve_wizard_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_serve_wizard(); - true - } - _ => false, - } -} - -fn handle_serve_wizard_text_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => app.cancel_serve_wizard_field_edit(), - (KeyModifiers::CONTROL, KeyCode::Char('a')) => app.move_input_start(), - (KeyModifiers::CONTROL, KeyCode::Char('e')) => app.move_input_end(), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => app.clear_input(), - (KeyModifiers::CONTROL, KeyCode::Char('w')) => app.delete_input_word_before_cursor(), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => app.commit_serve_wizard_field_edit(), - (_, KeyCode::Esc) => app.cancel_serve_wizard_field_edit(), - (_, KeyCode::Backspace) => app.backspace_input(), - (_, KeyCode::Delete) => app.delete_input_char(), - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Home) => app.move_input_start(), - (_, KeyCode::End) => app.move_input_end(), - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.insert_input_char(ch); - } - _ => {} - } - true -} - -fn handle_update_manager_key(app: &mut App, key: KeyEvent) -> bool { - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_update_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_update_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.update_manager.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - } - app.status = "Moved to first update option.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.update_manager.as_mut() { - state.selected = update_menu_choices().len().saturating_sub(1); - state.detail_scroll = 0; - } - app.status = "Moved to last update option.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.refresh_update_manager(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_update_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_update_manager(); - true - } - _ => false, - } -} - -fn handle_automations_manager_key(app: &mut App, key: KeyEvent) -> bool { - if app.automations_manager_editing_text() { - return handle_automations_manager_text_key(app, key); - } - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_automations_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_automations_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Left) => { - if let Some((proposal, field)) = app.selected_automation_prefetch_field() { - match field { - AutomationPrefetchField::AllowDownload - | AutomationPrefetchField::AllowHuggingFace => { - app.update_prefetch_bool_field(proposal, field, false); - } - AutomationPrefetchField::MaxBytes => { - app.cycle_prefetch_max_bytes(proposal, CompletionDirection::Previous); - } - } - } else { - app.status = "Use Up and Down to choose an automation item.".to_owned(); - } - true - } - (_, KeyCode::Right) => { - if let Some((proposal, field)) = app.selected_automation_prefetch_field() { - match field { - AutomationPrefetchField::AllowDownload - | AutomationPrefetchField::AllowHuggingFace => { - app.update_prefetch_bool_field(proposal, field, true); - } - AutomationPrefetchField::MaxBytes => { - app.cycle_prefetch_max_bytes(proposal, CompletionDirection::Next); - } - } - } else { - app.status = "Use Up and Down to choose an automation item.".to_owned(); - } - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.automations_manager.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to first automation item.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.automations_manager.as_mut() { - state.selected = state.rows.len().saturating_sub(1); - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to last automation item.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.refresh_automations_manager(); - true - } - (_, KeyCode::Char('y' | 'Y' | 'a' | 'A')) => { - if let Some((proposal, field)) = app.selected_automation_prefetch_field() { - match field { - AutomationPrefetchField::AllowDownload - | AutomationPrefetchField::AllowHuggingFace => { - app.update_prefetch_bool_field(proposal, field, true); - } - AutomationPrefetchField::MaxBytes => { - app.start_prefetch_max_bytes_edit(&proposal); - } - } - } else if let Some(watcher) = app.selected_automation_watcher() { - if watcher.enabled { - app.status = format!("{} is already on.", watcher.name); - } else { - app.request_automation_watcher_enabled(&watcher, true); - } - } else { - app.focus_selected_automation_proposal(); - } - true - } - (_, KeyCode::Char('n' | 'N') | KeyCode::Delete) => { - if let Some((proposal, field)) = app.selected_automation_prefetch_field() { - match field { - AutomationPrefetchField::AllowDownload - | AutomationPrefetchField::AllowHuggingFace => { - app.update_prefetch_bool_field(proposal, field, false); - } - AutomationPrefetchField::MaxBytes => { - app.status = "Press Enter to edit the max download.".to_owned(); - } - } - } else if let Some(watcher) = app.selected_automation_watcher() { - if watcher.enabled { - app.request_automation_watcher_enabled(&watcher, false); - } else { - app.status = format!("{} is already off.", watcher.name); - } - } else if let Some(proposal) = app.selected_automation_proposal() { - if proposal.status == "pending" { - app.focus_selected_automation_proposal(); - } else { - if let Some(state) = app.automations_manager.as_mut() { - state.message = Some(format!( - "{} is already {}.", - proposal.proposal_id, - proposal_status_for_user(&proposal.status) - )); - } - app.status = "Review request is not pending.".to_owned(); - } - } else { - app.show_no_automation_review_selected_message(); - } - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_automations_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_automations_manager(); - true - } - _ => false, - } -} - -fn handle_automations_manager_text_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => app.cancel_prefetch_max_bytes_edit(), - (KeyModifiers::CONTROL, KeyCode::Char('a')) => app.move_input_start(), - (KeyModifiers::CONTROL, KeyCode::Char('e')) => app.move_input_end(), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => app.clear_input(), - (KeyModifiers::CONTROL, KeyCode::Char('w')) => app.delete_input_word_before_cursor(), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => app.commit_prefetch_max_bytes_edit(), - (_, KeyCode::Esc) => app.cancel_prefetch_max_bytes_edit(), - (_, KeyCode::Backspace) => app.backspace_input(), - (_, KeyCode::Delete) => app.delete_input_char(), - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Home) => app.move_input_start(), - (_, KeyCode::End) => app.move_input_end(), - (modifiers, KeyCode::Char(ch)) - if ch.is_ascii_digit() - && !modifiers.intersects( - KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER, - ) => - { - app.insert_input_char(ch); - } - _ => {} - } - true -} - -fn handle_config_manager_key(app: &mut App, key: KeyEvent) -> bool { - if app.config_manager_editing_secret() { - return handle_config_manager_secret_key(app, key); - } - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_config_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_config_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.config_manager.as_mut() { - match &mut state.screen { - ConfigManagerScreen::Main => state.selected = 0, - ConfigManagerScreen::Provider { selected, .. } => *selected = 0, - } - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to first setting.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.config_manager.as_mut() { - match &mut state.screen { - ConfigManagerScreen::Main => { - state.selected = config_menu_choices().len().saturating_sub(1); - } - ConfigManagerScreen::Provider { - selected, - confirming_clear, - .. - } => { - *selected = config_provider_choices(*confirming_clear) - .len() - .saturating_sub(1); - } - } - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to last setting.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.refresh_config(); - if let Some(state) = app.config_manager.as_mut() { - state.message = None; - } - app.status = "Settings refreshed.".to_owned(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_config_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - if app.config_manager_confirming_provider_clear() { - app.cancel_clear_config_provider_key_confirmation(); - return true; - } - if app - .config_manager - .as_ref() - .is_some_and(|state| matches!(state.screen, ConfigManagerScreen::Provider { .. })) - { - app.close_config_provider_settings(); - } else { - app.close_config_manager(); - } - true - } - _ => false, - } -} - -fn handle_config_manager_secret_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => app.cancel_config_provider_key_edit(), - (KeyModifiers::CONTROL, KeyCode::Char('a')) => app.move_input_start(), - (KeyModifiers::CONTROL, KeyCode::Char('e')) => app.move_input_end(), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => app.clear_input(), - (KeyModifiers::CONTROL, KeyCode::Char('w')) => app.delete_input_word_before_cursor(), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => app.commit_config_provider_key_edit(), - (_, KeyCode::Esc) => app.cancel_config_provider_key_edit(), - (_, KeyCode::Backspace) => app.backspace_input(), - (_, KeyCode::Delete) => app.delete_input_char(), - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Home) => app.move_input_start(), - (_, KeyCode::End) => app.move_input_end(), - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.insert_input_char(ch); - } - _ => {} - } - true -} - -fn handle_provider_manager_key(app: &mut App, key: KeyEvent) -> bool { - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_provider_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_provider_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.provider_manager.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to first provider.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.provider_manager.as_mut() { - state.selected = provider_options().len().saturating_sub(1); - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to last provider.".to_owned(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.use_selected_provider(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_provider_manager(); - true - } - _ => false, - } -} - -fn handle_services_manager_key(app: &mut App, key: KeyEvent) -> bool { - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_services_manager_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_services_manager_selection(CompletionDirection::Next); - true - } - (_, KeyCode::PageUp) => { - app.scroll_active_manager_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_active_manager_detail(10); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.services_manager.as_mut() { - state.selected = 0; - state.last_item_selected = 0; - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to first service choice.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.services_manager.as_mut() { - state.selected = services_manager_choice_count(state).saturating_sub(1); - if state.selected < state.items.len() { - state.last_item_selected = state.selected; - } - state.detail_scroll = 0; - state.message = None; - } - app.status = "Moved to last service choice.".to_owned(); - true - } - (_, KeyCode::F(5)) => { - app.refresh_services_manager(); - true - } - (_, KeyCode::Char('S' | 's') | KeyCode::Delete) => { - app.request_selected_service_lifecycle(ServiceLifecycleAction::Stop); - true - } - (_, KeyCode::Char('R')) => { - app.request_selected_service_lifecycle(ServiceLifecycleAction::Restart); - true - } - (_, KeyCode::Char('l' | 'L' | '\n' | '\r') | KeyCode::Enter) - | (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) => { - app.perform_services_manager_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_services_manager(); - true - } - _ => false, - } -} - -fn handle_command_screen_key(app: &mut App, key: KeyEvent) -> bool { - if handle_command_screen_detail_modal_key(app, key) { - return true; - } - if app.command_screen_is_chat_session() { - if app.input.is_empty() { - return match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (KeyModifiers::CONTROL, KeyCode::Char('d')) => { - app.open_chat_default_detail(); - true - } - (_, KeyCode::Up) => { - app.scroll_command_screen_detail(-1); - true - } - (_, KeyCode::Down) => { - app.scroll_command_screen_detail(1); - true - } - (_, KeyCode::PageUp) => { - app.scroll_command_screen_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_command_screen_detail(10); - true - } - (_, KeyCode::Char('?')) => { - app.insert_input_char('?'); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.status = "Type a message, then press Enter.".to_owned(); - true - } - (_, KeyCode::Esc) => { - app.request_stop_chat_assistant_or_close(); - true - } - _ => false, - }; - } - if !app.input.is_empty() { - return match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (KeyModifiers::CONTROL, KeyCode::PageUp) => { - app.scroll_chat_input(-10); - true - } - (KeyModifiers::CONTROL, KeyCode::PageDown) => { - app.scroll_chat_input(10); - true - } - (_, KeyCode::Up) => { - app.scroll_command_screen_detail(-1); - true - } - (_, KeyCode::Down) => { - app.scroll_command_screen_detail(1); - true - } - (_, KeyCode::PageUp) => { - app.scroll_command_screen_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_command_screen_detail(10); - true - } - (_, KeyCode::Esc) => { - app.request_stop_chat_assistant_or_close(); - true - } - _ => false, - }; - } - return match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_command_screen_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_command_screen_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.command_screen.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - } - app.status = "Moved to first option.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.command_screen.as_mut() { - state.selected = state.actions.len().saturating_sub(1); - state.detail_scroll = 0; - } - app.status = "Moved to last option.".to_owned(); - true - } - (_, KeyCode::PageUp) => { - app.scroll_command_screen_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_command_screen_detail(10); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_command_screen_selected_action(); - true - } - (_, KeyCode::Esc) => { - app.request_stop_chat_assistant_or_close(); - true - } - _ => false, - }; - } - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_command_screen_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_command_screen_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.command_screen.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - } - app.status = "Moved to first option.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.command_screen.as_mut() { - state.selected = state.actions.len().saturating_sub(1); - state.detail_scroll = 0; - } - app.status = "Moved to last option.".to_owned(); - true - } - (_, KeyCode::PageUp) => { - app.scroll_command_screen_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_command_screen_detail(10); - true - } - (_, KeyCode::F(5)) => { - app.refresh_command_screen(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_command_screen_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_command_screen(); - true - } - _ => false, - } -} - -fn handle_command_screen_detail_modal_key(app: &mut App, key: KeyEvent) -> bool { - if app - .command_screen - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - .is_none() - { - return false; - } - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.status = "Detail card is open. Press Esc to close it.".to_owned(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_command_screen_detail_modal(); - true - } - (_, KeyCode::PageUp) => { - app.scroll_command_screen_detail(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_command_screen_detail(10); - true - } - (_, KeyCode::Up) => { - app.scroll_command_screen_detail(-1); - true - } - (_, KeyCode::Down) => { - app.scroll_command_screen_detail(1); - true - } - (_, KeyCode::Home) => { - if let Some(modal) = app - .command_screen - .as_mut() - .and_then(|state| state.detail_modal.as_mut()) - { - modal.scroll = 0; - } - app.status = "Detail card moved to the top.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(modal) = app - .command_screen - .as_mut() - .and_then(|state| state.detail_modal.as_mut()) - { - modal.scroll = u16::MAX; - } - app.status = "Detail card moved to the bottom.".to_owned(); - true - } - ( - _, - KeyCode::Left - | KeyCode::Right - | KeyCode::Tab - | KeyCode::BackTab - | KeyCode::Char('\n' | '\r') - | KeyCode::Enter, - ) - | (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) => { - app.status = "Detail card is open. Press Esc to close it.".to_owned(); - true - } - _ => { - app.status = "Detail card is open. Press Esc to close it.".to_owned(); - true - } - } -} - -fn handle_logs_view_key(app: &mut App, key: KeyEvent) -> bool { - if app.logs_view_editing_text() { - return handle_logs_view_text_key(app, key); - } - if handle_logs_view_detail_modal_key(app, key) { - return true; - } - if active_surface_should_defer_to_prompt(app, key) { - return false; - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => false, - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_logs_view_selection(CompletionDirection::Previous); - true - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_logs_view_selection(CompletionDirection::Next); - true - } - (_, KeyCode::Home) => { - if let Some(state) = app.logs_view.as_mut() { - state.selected = 0; - state.detail_scroll = 0; - } - app.status = "Moved to first log option.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(state) = app.logs_view.as_mut() { - state.selected = logs_view_actions().len().saturating_sub(1); - state.detail_scroll = 0; - } - app.status = "Moved to last log option.".to_owned(); - true - } - (_, KeyCode::PageUp) => { - app.scroll_logs_view_detail(-8); - true - } - (_, KeyCode::PageDown) => { - app.scroll_logs_view_detail(8); - true - } - (_, KeyCode::Left) => { - app.page_logs_browser(LogPageDirection::Previous); - true - } - (_, KeyCode::Right) => { - app.page_logs_browser(LogPageDirection::Next); - true - } - (_, KeyCode::F(5)) => { - app.page_logs_browser(LogPageDirection::Refresh); - true - } - (_, KeyCode::Char('f' | 'F')) => { - if let Some(current) = app.logs_view.clone() { - if current.service_id.is_none() { - let (rendered, page, _page_count) = app.render_logs_browser_view( - current.query.as_deref(), - current.page, - true, - false, - ); - app.logs_view = Some(LogsViewState { - query: current.query, - service_id: None, - page, - follow: true, - show_file_locations: false, - selected: current.selected, - detail_scroll: current.detail_scroll, - last_rendered: rendered, - last_refresh_at: Instant::now(), - editing_search: false, - detail_modal: None, - }); - app.status = "Following logs.".to_owned(); - } else { - app.status = - "Following is for the main log list. Service logs can be refreshed." - .to_owned(); - } - } - true - } - (_, KeyCode::Char('s' | 'S')) => { - app.stop_following_logs(); - true - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_logs_view_selected_action(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_logs_browser(); - true - } - _ => false, - } -} - -fn handle_logs_view_detail_modal_key(app: &mut App, key: KeyEvent) -> bool { - if app - .logs_view - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - .is_none() - { - return false; - } - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.status = "File location card is open. Press Esc to close it.".to_owned(); - true - } - (_, KeyCode::Esc | KeyCode::Char('q' | 'Q')) => { - app.close_logs_view_detail_modal(); - true - } - (_, KeyCode::PageUp) => { - app.scroll_logs_view_detail_modal(-10); - true - } - (_, KeyCode::PageDown) => { - app.scroll_logs_view_detail_modal(10); - true - } - (_, KeyCode::Up) => { - app.scroll_logs_view_detail_modal(-1); - true - } - (_, KeyCode::Down) => { - app.scroll_logs_view_detail_modal(1); - true - } - (_, KeyCode::Home) => { - if let Some(modal) = app - .logs_view - .as_mut() - .and_then(|state| state.detail_modal.as_mut()) - { - modal.scroll = 0; - } - app.status = "File location card moved to the top.".to_owned(); - true - } - (_, KeyCode::End) => { - if let Some(modal) = app - .logs_view - .as_mut() - .and_then(|state| state.detail_modal.as_mut()) - { - modal.scroll = u16::MAX; - } - app.status = "File location card moved to the bottom.".to_owned(); - true - } - ( - _, - KeyCode::Left - | KeyCode::Right - | KeyCode::Tab - | KeyCode::BackTab - | KeyCode::Char('\n' | '\r') - | KeyCode::Enter, - ) - | (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) => { - app.status = "File location card is open. Press Esc to close it.".to_owned(); - true - } - _ => { - app.status = "File location card is open. Press Esc to close it.".to_owned(); - true - } - } -} - -fn handle_logs_view_text_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => app.cancel_logs_search_edit(), - (KeyModifiers::CONTROL, KeyCode::Char('a')) => app.move_input_start(), - (KeyModifiers::CONTROL, KeyCode::Char('e')) => app.move_input_end(), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => app.clear_input(), - (KeyModifiers::CONTROL, KeyCode::Char('w')) => app.delete_input_word_before_cursor(), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => app.commit_logs_search_edit(), - (_, KeyCode::Esc) => app.cancel_logs_search_edit(), - (_, KeyCode::Backspace) => app.backspace_input(), - (_, KeyCode::Delete) => app.delete_input_char(), - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Home) => app.move_input_start(), - (_, KeyCode::End) => app.move_input_end(), - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.insert_input_char(ch); - } - _ => {} - } - true -} - -fn handle_onboarding_key(app: &mut App, key: KeyEvent) -> bool { - if app.overlay_card.is_some() && handle_overlay_card_key(app, key) { - return true; - } - if app.onboarding_path_editing { - return handle_onboarding_path_key(app, key); - } - - if app.onboarding_cancel_install_confirm { - match (key.modifiers, key.code) { - (_, KeyCode::Char('y' | 'Y')) => app.cancel_onboarding_install(), - (_, KeyCode::Char('n' | 'N') | KeyCode::Esc) => { - app.keep_waiting_for_onboarding_install(); - } - (_, KeyCode::Up | KeyCode::BackTab) => { - app.move_onboarding_cancel_install_selection(CompletionDirection::Previous); - } - (_, KeyCode::Down | KeyCode::Tab) => { - app.move_onboarding_cancel_install_selection(CompletionDirection::Next); - } - (_, KeyCode::Home) => { - app.onboarding_cancel_install_selection = 0; - app.status = onboarding_cancel_install_status(0); - } - (_, KeyCode::End) => { - app.onboarding_cancel_install_selection = - onboarding_cancel_install_choices().len().saturating_sub(1); - app.status = - onboarding_cancel_install_status(app.onboarding_cancel_install_selection); - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => { - app.perform_onboarding_cancel_install_selection(); - } - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.status = "Choose Keep waiting or Stop install.".to_owned(); - } - _ => {} - } - return true; - } - - if app.pending_approval.is_some() - && !matches!( - (key.modifiers, key.code), - (KeyModifiers::CONTROL, KeyCode::Char('c')) - ) - { - let handled = handle_pending_approval_key(app, key); - if handled { - if app.pending_approval.is_none() { - app.reset_onboarding_selection(); - } - return true; - } - } - - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) if app.onboarding_install_running() => { - app.request_onboarding_install_cancel(); - } - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.request_quit_overlay_or_wait(); - } - (_, KeyCode::Up | KeyCode::BackTab) if app.running_job.is_none() => { - app.move_onboarding_selection(CompletionDirection::Previous); - } - (_, KeyCode::Down | KeyCode::Tab) if app.running_job.is_none() => { - app.move_onboarding_selection(CompletionDirection::Next); - } - (_, KeyCode::PageUp) => { - app.scroll_onboarding_install_log(LogPageDirection::Previous); - } - (_, KeyCode::PageDown) => { - app.scroll_onboarding_install_log(LogPageDirection::Next); - } - (_, KeyCode::Left | KeyCode::Right) - if app.pending_approval.is_none() - && app.running_job.is_none() - && app.selected_onboarding_choice() == OnboardingMenuChoice::Channel => - { - app.toggle_onboarding_channel(); - } - (_, KeyCode::Left) - if app.pending_approval.is_none() - && app.running_job.is_none() - && app.selected_onboarding_choice() == OnboardingMenuChoice::Folder => - { - app.cycle_onboarding_folder_preset(CompletionDirection::Previous); - } - (_, KeyCode::Right) - if app.pending_approval.is_none() - && app.running_job.is_none() - && app.selected_onboarding_choice() == OnboardingMenuChoice::Folder => - { - app.cycle_onboarding_folder_preset(CompletionDirection::Next); - } - (_, KeyCode::Home) if app.running_job.is_none() => { - app.onboarding_selection = 0; - app.status = onboarding_selection_status(app); - } - (_, KeyCode::End) if app.running_job.is_none() => { - app.onboarding_selection = app.onboarding_choice_count().saturating_sub(1); - app.status = onboarding_selection_status(app); - } - (_, KeyCode::Char(ch)) - if app.pending_approval.is_none() - && app.running_job.is_none() - && ch.is_ascii_digit() - && ch != '0' => - { - let index = (ch as u8 - b'1') as usize; - if index < app.onboarding_choice_count() { - app.onboarding_selection = index; - app.perform_onboarding_selected_action(); - } - } - (_, KeyCode::Char('c' | 'C' | 'f' | 'F')) - if app.pending_approval.is_none() && app.running_job.is_none() => - { - app.select_onboarding_choice(OnboardingMenuChoice::Folder); - app.open_onboarding_install_folder_browser(); - } - (_, KeyCode::Char('t' | 'T')) - if app.pending_approval.is_none() - && app.running_job.is_none() - && app.selected_onboarding_choice() == OnboardingMenuChoice::Folder => - { - app.start_onboarding_path_edit(); - } - (_, KeyCode::Char('?')) if app.pending_approval.is_none() && app.running_job.is_none() => { - app.status = "Setup is open. Use Up/Down, then Enter.".to_owned(); - } - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) - if app.pending_approval.is_none() && app.running_job.is_none() => - { - app.perform_onboarding_selected_action(); - } - (_, KeyCode::Char('s' | 'S')) - if app.pending_approval.is_none() && app.running_job.is_none() => - { - app.status = "Finish setup to continue, or choose Quit setup.".to_owned(); - } - (_, KeyCode::Esc) if app.pending_approval.is_none() && app.running_job.is_none() => { - if onboarding_can_return(app) { - app.return_from_onboarding(); - } else { - app.select_onboarding_choice(OnboardingMenuChoice::Quit); - app.status = "Quit setup selected. Press Enter to confirm.".to_owned(); - } - } - (_, KeyCode::Char('q' | 'Q')) - if app.pending_approval.is_none() && app.running_job.is_none() => - { - app.request_quit_overlay_or_wait(); - } - _ => {} - } - true -} - -fn handle_onboarding_path_key(app: &mut App, key: KeyEvent) -> bool { - match (key.modifiers, key.code) { - (KeyModifiers::CONTROL, KeyCode::Char('c')) => { - app.request_quit_overlay_or_wait(); - } - (KeyModifiers::CONTROL, KeyCode::Char('a')) => app.move_input_start(), - (KeyModifiers::CONTROL, KeyCode::Char('e')) => app.move_input_end(), - (KeyModifiers::CONTROL, KeyCode::Char('u')) => app.clear_input(), - (KeyModifiers::CONTROL, KeyCode::Char('w')) => app.delete_input_word_before_cursor(), - (KeyModifiers::CONTROL, KeyCode::Char('j' | 'm')) - | (_, KeyCode::Char('\n' | '\r') | KeyCode::Enter) => app.commit_onboarding_path_edit(), - (_, KeyCode::Esc) => app.cancel_onboarding_path_edit(), - (_, KeyCode::Backspace) => app.backspace_input(), - (_, KeyCode::Delete) => app.delete_input_char(), - (_, KeyCode::Left) => app.move_input_left(), - (_, KeyCode::Right) => app.move_input_right(), - (_, KeyCode::Home) => app.move_input_start(), - (_, KeyCode::End) => app.move_input_end(), - (modifiers, KeyCode::Char(ch)) - if !modifiers - .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER) => - { - app.insert_input_char(ch); - } - _ => {} - } - true -} - -fn draw(frame: &mut Frame<'_>, app: &App) { - if app.onboarding_active { - draw_onboarding(frame, app); - return; - } - frame.render_widget(Clear, frame.area()); - - let command_prompt_popup = surface_command_prompt_active(app); - let prompt_box_visible = should_draw_prompt_box(app) && !command_prompt_popup; - let completion_menu = if (prompt_box_visible || command_prompt_popup) - && app.overlay_card.is_none() - && app.folder_browser.is_none() - && !should_draw_running_job_modal(app) - { - app.slash_completion_menu() - } else { - None - }; - let completion_open = completion_menu - .as_ref() - .is_some_and(|menu| !menu.selectable_indices().is_empty()); - let completion_height = if command_prompt_popup { - 0 - } else { - completion_menu.as_ref().map_or(0, |menu| { - menu.items.len().min(MAX_COMPLETION_MENU_ITEMS) as u16 + 2 - }) - }; - let input_height = if prompt_box_visible { - chat_input_area_height(app, frame.area().width, frame.area().height) - } else { - 0 - }; - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(8), - Constraint::Length(completion_height), - Constraint::Length(input_height), - Constraint::Length(1), - ]) - .split(frame.area()); - let sidebar_width = if app.command_screen_is_chat_session() { - 0 - } else { - status_sidebar_width(layout[0].width) - }; - let body = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Min(60), Constraint::Length(sidebar_width)]) - .split(layout[0]); - - if app.examine_manager.is_some() { - draw_examine_manager(frame, app, body[0]); - } else if app.logs_view.is_some() { - draw_logs_view(frame, app, body[0]); - } else if app.runtime_manager.is_some() { - draw_runtime_manager(frame, app, body[0]); - } else if app.install_manager.is_some() { - draw_install_manager(frame, app, body[0]); - } else if app.engine_manager.is_some() { - draw_engine_manager(frame, app, body[0]); - } else if app.model_picker.is_some() { - draw_model_picker(frame, app, body[0]); - } else if app.serve_wizard.is_some() { - draw_serve_wizard(frame, app, body[0]); - } else if app.update_manager.is_some() { - draw_update_manager(frame, app, body[0]); - } else if app.automations_manager.is_some() { - draw_automations_manager(frame, app, body[0]); - } else if app.config_manager.is_some() { - draw_config_manager(frame, app, body[0]); - } else if app.provider_manager.is_some() { - draw_provider_manager(frame, app, body[0]); - } else if app.services_manager.is_some() { - draw_services_manager(frame, app, body[0]); - } else if app.command_screen.is_some() { - draw_command_screen(frame, app, body[0]); - } else if app.should_draw_home_dashboard() { - draw_home_dashboard(frame, app, body[0]); - } else { - draw_scrollable_text_with_block( - frame, - app.transcript_text(), - body[0], - surface_block(transcript_title(app), THEME_MUTED), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - app.transcript_scroll, - ); - } - - if sidebar_width > 0 { - let sidebar = Paragraph::new(app.sidebar_text()) - .block(surface_block("Status", sidebar_border_color(app))) - .style(Style::default().fg(THEME_TEXT)) - .wrap(Wrap { trim: false }); - frame.render_widget(sidebar, body[1]); - } - - if let Some(menu) = completion_menu.as_ref() - && !command_prompt_popup - { - draw_completion_menu(frame, menu, layout[1]); - } - - let input_area = layout[2]; - let mut cursor_position = None; - if prompt_box_visible && input_area.height > 0 { - let input_inner_width = input_area.width.saturating_sub(2); - let input_inner_height = input_area.height.saturating_sub(2).max(1); - let display_input = if app.config_manager_editing_secret() { - "*".repeat(app.input_len_chars()) - } else { - app.input.clone() - }; - let chat_input_mode = app.command_screen_is_chat_session(); - let chat_input_scroll = if chat_input_mode { - bounded_chat_input_scroll( - &display_input, - app.input_cursor, - input_inner_width, - input_inner_height, - app.chat_input_scroll, - ) - } else { - 0 - }; - let (input_text, input_cursor_position) = if chat_input_mode { - ( - display_input.clone(), - chat_input_cursor_position( - &display_input, - app.input_cursor, - input_inner_width, - chat_input_scroll, - input_inner_height, - ), - ) - } else { - let (visible_input, visible_cursor) = - input_viewport(&display_input, app.input_cursor, input_inner_width); - (visible_input, Some((visible_cursor, 0))) - }; - cursor_position = input_cursor_position; - let input = Paragraph::new(input_text) - .block(surface_block(input_title(app), THEME_ACCENT)) - .style(Style::default().fg(THEME_TEXT)) - .wrap(Wrap { trim: false }) - .scroll((chat_input_scroll, 0)); - frame.render_widget(input, input_area); - if chat_input_mode { - let line_count = chat_input_visual_line_count( - &display_input, - app.input_cursor, - usize::from(input_inner_width.max(1)), - ); - draw_scrollbar( - frame, - input_area, - scroll_metrics( - line_count, - usize::from(input_inner_height), - usize::from(chat_input_scroll), - ), - ); - } - } - - let key_hint = if app.folder_browser.is_some() { - "folder picker | Enter select | Backspace up | PageUp/PageDown scroll | Esc cancel" - } else if app.overlay_card.is_some() { - overlay_card_key_hint(app) - } else if should_draw_running_job_modal(app) { - "running | live output shown | wait for finish" - } else if command_prompt_popup { - "Command popup | Tab suggestions | Enter run | Esc close" - } else if completion_open { - "Tab/Down next | Shift+Tab/Up previous | Enter choose | Ctrl-C quit" - } else if app.config_manager_editing_secret() { - "Enter save key | Esc cancel | input hidden" - } else if app.install_manager_editing_text() { - "Enter use folder | Esc cancel | Left/Right move cursor" - } else if app.logs_view_editing_text() { - "Enter search | Esc cancel | Left/Right move cursor" - } else if app.serve_wizard_editing_text() { - "Enter save | Esc cancel | Left/Right move cursor" - } else if app.automations_manager_editing_text() { - "Enter save limit | Esc cancel | digits only" - } else if app - .command_screen - .as_ref() - .is_some_and(|state| state.detail_modal.is_some()) - || app - .logs_view - .as_ref() - .is_some_and(|state| state.detail_modal.is_some()) - { - "Up/Down scroll | PageUp/PageDown scroll | Esc close" - } else if app.examine_manager.is_some() { - "Up/Down choose | Enter select | PageUp/PageDown scroll | F5 refresh | Esc back" - } else if app.runtime_manager.is_some() && app.pending_approval.is_none() { - "Up/Down choose | Enter use highlighted row | PageUp/PageDown scroll | Esc back" - } else if app.install_manager.is_some() && app.pending_approval.is_none() { - "Up/Down choose | Left/Right change | Enter select | PageUp/PageDown scroll | Esc back" - } else if app.engine_manager.is_some() && app.pending_approval.is_none() { - "Up/Down choose | Enter use highlighted row | PageUp/PageDown scroll | Esc back" - } else if app.model_picker.is_some() { - "Up/Down choose | Enter start | PageUp/PageDown scroll | F5 refresh | Esc back" - } else if app.serve_wizard.is_some() && app.pending_approval.is_none() { - "Up/Down choose row | Left/Right change | Enter edit/start | PageUp/PageDown scroll | F5 refresh | Esc back" - } else if app.update_manager.is_some() && app.pending_approval.is_none() { - "Up/Down choose | Enter select | PageUp/PageDown scroll | F5 refresh | Esc back" - } else if app.automations_manager.is_some() && app.pending_approval.is_none() { - "Up/Down choose | Left/Right change | Enter select/edit | PageUp/PageDown scroll | Y yes/approve | N no/reject | F5 refresh | Esc back" - } else if app.config_manager.is_some() { - "Up/Down choose | Enter change | PageUp/PageDown scroll | F5 refresh | Esc back" - } else if app.provider_manager.is_some() { - "Up/Down choose | Enter use | PageUp/PageDown scroll | Esc back" - } else if app.services_manager.is_some() && app.pending_approval.is_none() { - "Up/Down choose | Enter use highlighted row | PageUp/PageDown scroll | Esc back" - } else if app.command_screen.is_some() && app.pending_approval.is_none() { - if app.command_screen_is_chat_session() { - if app.input.is_empty() { - "Type message, Enter sends | Up/Down/PageUp/PageDown scroll chat | Esc back" - } else { - "Enter sends | PageUp/PageDown scroll chat | Ctrl+PageUp/PageDown scroll draft | Esc back" - } - } else { - "Up/Down choose | Enter select | PageUp/PageDown scroll | Esc back" - } - } else if app.logs_view.is_some() { - "Up/Down choose | Enter select/search | PageUp/PageDown scroll | Left/Right page | F5 refresh | Esc back" - } else if app - .pending_approval - .as_ref() - .is_some_and(|pending| matches!(pending.action, ApprovalAction::Proposal { .. })) - { - "Up/Down choose | Enter/Y approve | N reject | PageUp/PageDown scroll | Esc back | Ctrl-C quit" - } else if app.pending_approval.is_some() { - "Up/Down choose | Enter/Y approve | N/Esc cancel | PageUp/PageDown scroll | Ctrl-C quit" - } else if app - .running_job - .as_ref() - .is_some_and(|job| matches!(job.kind, RunningJobKind::Planner { .. })) - { - "planning | Ctrl-C quit" - } else if app.running_job.is_some() { - "command running | live log shown | wait for finish" - } else if app.should_draw_home_dashboard() { - "Up/Down choose | Enter open | Tab next | F1 help | Ctrl-L clear | Ctrl-C quit" - } else { - "Enter submit | Tab suggestions | F1 help | F5 refresh | Ctrl-L clear | Ctrl-C quit" - }; - let footer = Paragraph::new(footer_line(&app.status, key_hint, layout[3].width)).style( - Style::default() - .fg(THEME_MUTED) - .bg(THEME_BG) - .add_modifier(Modifier::DIM), - ); - frame.render_widget(footer, layout[3]); - - let cursor = cursor_position.map(|(cursor_x, cursor_y)| { - ( - input_area - .x - .saturating_add(1) - .saturating_add(cursor_x) - .min(input_area.right().saturating_sub(2)), - input_area - .y - .saturating_add(1) - .saturating_add(cursor_y) - .min(input_area.bottom().saturating_sub(2)), - ) - }); - let running_modal = should_draw_running_job_modal(app); - if app.pending_approval.is_none() - && app.overlay_card.is_none() - && app.folder_browser.is_none() - && !running_modal - && prompt_box_visible - && let Some(cursor) = cursor - { - frame.set_cursor_position(cursor); - } - - if running_modal { - draw_running_job_modal(frame, app, body[0]); - } - - if app.overlay_card.is_some() { - draw_overlay_card(frame, app, body[0]); - } - - if app.folder_browser.is_some() { - draw_folder_browser_modal(frame, app, frame.area()); - } - - if app.pending_approval.is_some() && should_draw_modal_approval(app) { - draw_pending_approval_modal(frame, app, body[0]); - } - - if command_prompt_popup { - draw_command_prompt_popup(frame, app, frame.area(), completion_menu.as_ref()); - } -} - -fn footer_line(status: &str, key_hint: &str, width: u16) -> String { - let width = usize::from(width); - if width == 0 { - return String::new(); - } - let key_hint_len = key_hint.chars().count(); - if status.trim().is_empty() { - return truncate_chars(key_hint, width); - } - if key_hint_len >= width { - return truncate_chars(key_hint, width); - } - let separator = " | "; - let reserved = key_hint_len + separator.chars().count(); - if reserved >= width { - return truncate_chars(key_hint, width); - } - let status = truncate_chars(status.trim(), width - reserved); - format!("{status}{separator}{key_hint}") -} - -fn truncate_chars(value: &str, max_chars: usize) -> String { - if value.chars().count() <= max_chars { - return value.to_owned(); - } - if max_chars <= 3 { - return value.chars().take(max_chars).collect(); - } - let mut output = value.chars().take(max_chars - 3).collect::(); - output.push_str("..."); - output -} - -fn input_title(app: &App) -> &'static str { - if app.config_manager_editing_secret() { - "API Key" - } else if app.install_manager_editing_text() { - "Folder" - } else if app.logs_view_editing_text() { - "Search" - } else if app.serve_wizard_editing_text() { - app.serve_wizard_editing_title() - } else if app.automations_manager_editing_text() { - app.automations_manager_editing_title() - } else if app.command_screen_is_chat_session() { - "Message" - } else { - "Prompt" - } -} - -fn should_draw_prompt_box(app: &App) -> bool { - if app.onboarding_active { - return false; - } - if app.config_manager_editing_secret() - || app.install_manager_editing_text() - || app.logs_view_editing_text() - || app.runtime_manager_editing_text() - || app.serve_wizard_editing_text() - || app.automations_manager_editing_text() - || app.command_screen_is_chat_session() - { - return true; - } - app.examine_manager.is_none() - && app.logs_view.is_none() - && app.runtime_manager.is_none() - && app.install_manager.is_none() - && app.engine_manager.is_none() - && app.model_picker.is_none() - && app.serve_wizard.is_none() - && app.update_manager.is_none() - && app.automations_manager.is_none() - && app.provider_manager.is_none() - && app.config_manager.is_none() - && app.services_manager.is_none() - && app.command_screen.is_none() - && !app.should_draw_home_dashboard() -} - -const fn transcript_title(app: &App) -> &'static str { - if app.transcript.is_empty() { - "Home" - } else { - "Conversation" - } -} - -fn draw_home_dashboard(frame: &mut Frame<'_>, app: &App, area: Rect) { - let overview_text = render_home_dashboard_overview_text(app); - let overview_height = (overview_text.lines().count() as u16).saturating_add(2); - let action_height = (home_dashboard_actions(app).len() as u16).saturating_add(2); - if area.width < 78 || area.height < overview_height.saturating_add(action_height) { - let dashboard = Paragraph::new(render_home_dashboard_text(app)) - .block(surface_block("ROCm CLI", THEME_ACCENT)) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_2)) - .wrap(Wrap { trim: false }); - frame.render_widget(dashboard, area); - return; - } - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([Constraint::Length(overview_height), Constraint::Min(8)]) - .split(area); - let overview = Paragraph::new(render_home_dashboard_overview_styled(&overview_text)) - .block(surface_block("ROCm CLI", THEME_ACCENT)) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_2)) - .wrap(Wrap { trim: false }); - frame.render_widget(overview, rows[0]); - - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(40), Constraint::Min(40)]) - .split(rows[1]); - let actions = home_dashboard_actions(app); - let items = actions - .iter() - .map(|action| { - ListItem::new(Line::from(Span::styled( - home_dashboard_action_label(*action), - Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD), - ))) - }) - .collect::>(); - let mut list_state = ListState::default(); - if !items.is_empty() { - list_state.select(Some(app.home_selection.min(items.len().saturating_sub(1)))); - } - let list = List::new(items) - .block(surface_block("Choose", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - let detail = Paragraph::new(render_home_dashboard_detail_text(app)) - .block(detail_block("Next Step")) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL)) - .wrap(Wrap { trim: false }); - frame.render_widget(detail, panes[1]); -} - -fn render_home_dashboard_text(app: &App) -> String { - let mut output = render_home_dashboard_overview_text(app); - let _ = writeln!(output); - let _ = writeln!(output, "Choose"); - for (index, action) in home_dashboard_actions(app).into_iter().enumerate() { - let marker = if index == app.home_selection { - ">" - } else { - " " - }; - let label = home_dashboard_action_label(action); - let _ = writeln!(output, "{marker} {label}"); - } - let _ = writeln!(output); - output.push_str(&render_home_dashboard_detail_text(app)); - output.trim_end().to_owned() -} - -fn render_home_dashboard_overview_styled(overview_text: &str) -> Text<'static> { - let lines = overview_text - .lines() - .enumerate() - .map(|(index, line)| { - if index == 0 { - return Line::from(Span::styled( - line.to_owned(), - Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD), - )); - } - if line == "At a glance" { - return Line::from(Span::styled( - line.to_owned(), - Style::default() - .fg(THEME_ACCENT) - .add_modifier(Modifier::BOLD), - )); - } - if let Some(rest) = line.strip_prefix(" [READY]") { - return home_dashboard_badge_line("READY", rest, THEME_GOOD); - } - if let Some(rest) = line.strip_prefix(" [SETUP NEEDED]") { - return home_dashboard_badge_line("SETUP NEEDED", rest, THEME_WARN); - } - if let Some(rest) = line.strip_prefix(" [RUNNER]") { - return home_dashboard_badge_line("RUNNER", rest, THEME_ACCENT); - } - if let Some(rest) = line.strip_prefix(" [MODELS]") { - return home_dashboard_badge_line("MODELS", rest, THEME_ACCENT); - } - if let Some(rest) = line.strip_prefix(" [PERMISSIONS]") { - return home_dashboard_badge_line("PERMISSIONS", rest, THEME_ACCENT); - } - if line.starts_with(" ") || line.starts_with(" ") { - return Line::from(Span::styled( - line.to_owned(), - Style::default().fg(THEME_MUTED), - )); - } - Line::from(line.to_owned()) - }) - .collect::>(); - Text::from(lines) -} - -fn home_dashboard_badge_line(label: &'static str, rest: &str, color: Color) -> Line<'static> { - Line::from(vec![ - Span::raw(" "), - Span::styled( - format!("[{label}]"), - Style::default().fg(color).add_modifier(Modifier::BOLD), - ), - Span::styled( - rest.to_owned(), - Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD), - ), - ]) -} - -fn render_home_dashboard_overview_text(app: &App) -> String { - let setup_ready = setup_runtime_ready_for_sidebar(&app.paths, &app.config); - let runtime_count = - therock::load_runtime_manifests(&app.paths).map_or(0, |manifests| manifests.len()); - let services = load_visible_managed_services(&app.paths); - let service_counts = managed_service_sidebar_counts(&services); - let default_engine = app - .config - .default_engine - .as_deref() - .unwrap_or(default_engine_for_platform()); - let default_engine_label = match default_engine { - "pytorch" => "PyTorch", - "lemonade" => "Lemonade", - "llama.cpp" => "llama.cpp", - other => other, - }; - let permission_mode = if app.config.permissions.full_access_enabled() { - "full access is on" - } else { - "asks before changes" - }; - let setup_badge = if setup_ready { "READY" } else { "SETUP NEEDED" }; - let setup_line = if setup_ready { - format!("{runtime_count} install(s) saved") - } else { - "Install ROCm first".to_owned() - }; - let mut server_bits = Vec::new(); - if service_counts.ready > 0 { - server_bits.push(format!("{} ready", service_counts.ready)); - } - if service_counts.starting > 0 { - server_bits.push(format!("{} starting", service_counts.starting)); - } - if server_bits.is_empty() { - server_bits.push("none yet".to_owned()); - } - - let mut output = String::new(); - let _ = writeln!(output, "Local AI on your AMD GPU"); - let _ = writeln!(output, " Use the arrows or mouse to choose."); - let _ = writeln!(output); - let _ = writeln!(output, "At a glance"); - let _ = writeln!(output, " [{setup_badge}] AMD GPU setup - {setup_line}"); - let _ = writeln!(output, " [RUNNER] Model runner - {default_engine_label}"); - let _ = writeln!( - output, - " [MODELS] Local models - {}", - server_bits.join(", ") - ); - let _ = writeln!(output, " [PERMISSIONS] Changes - {permission_mode}"); - output.trim_end().to_owned() -} - -fn render_home_dashboard_detail_text(app: &App) -> String { - let Some(action) = app.selected_home_dashboard_action() else { - return "Choose a row on the left.".to_owned(); - }; - let title = home_dashboard_action_label(action); - let body = match action { - HomeDashboardAction::Setup => { - "Install ROCm into a Python folder managed by rocm-cli.\n\nThis is the first thing to do on a new machine." - } - HomeDashboardAction::Examine => { - "Check your GPU, ROCm install, model runner, driver, and useful folders.\n\nThis does not change your computer." - } - HomeDashboardAction::Serve => { - "Start the recommended Lemonade local model server on your AMD GPU.\n\nYou will see a review card before it starts." - } - HomeDashboardAction::Chat => { - "Talk to a local assistant inside this TUI.\n\nThe assistant can ask to use rocm commands, and you approve changes first." - } - HomeDashboardAction::ComfyUi => { - "Install or start ComfyUI in the ROCm folder managed by rocm-cli.\n\nAfter it starts, rocm-cli shows the URL to open." - } - HomeDashboardAction::Services => { - "See local model servers, open their logs, stop them, or restart them.\n\nOnly real running or starting servers are counted." - } - HomeDashboardAction::Engine => { - "Choose how local models run.\n\nLemonade is the default local assistant runner. Use llama.cpp for local model files through llama-server." - } - HomeDashboardAction::Permissions => { - "Choose whether rocm-cli asks before changes or can run with full access.\n\nYou can reset this whenever you want." - } - HomeDashboardAction::Help => { - "Open the interactive help browser.\n\nUse F1 anytime to see topics, shortcuts, and common commands." - } - HomeDashboardAction::Quit => "Exit ROCm CLI.", - }; - let assistant_line = home_dashboard_assistant_line(app); - if action == HomeDashboardAction::Quit { - format!("{title}\n\n{body}\n\nPress Enter to quit.") - } else { - format!("{title}\n\n{body}\n\nStatus\n {assistant_line}") - } -} - -fn home_dashboard_assistant_line(app: &App) -> String { - let services = load_visible_managed_services(&app.paths); - let service_counts = managed_service_sidebar_counts(&services); - if service_counts.ready > 0 { - format!("{} local model(s) ready to chat.", service_counts.ready) - } else if service_counts.starting > 0 { - format!("{} local model(s) starting.", service_counts.starting) - } else { - "No local model is running yet.".to_owned() - } -} - -fn home_dashboard_actions(app: &App) -> Vec { - if setup_venv_ready(&app.paths, &app.config) { - vec![ - HomeDashboardAction::Serve, - HomeDashboardAction::Chat, - HomeDashboardAction::ComfyUi, - HomeDashboardAction::Services, - HomeDashboardAction::Examine, - HomeDashboardAction::Engine, - HomeDashboardAction::Help, - HomeDashboardAction::Quit, - ] - } else { - vec![ - HomeDashboardAction::Setup, - HomeDashboardAction::Examine, - HomeDashboardAction::Permissions, - HomeDashboardAction::Help, - HomeDashboardAction::Quit, - ] - } -} - -const fn home_dashboard_action_label(action: HomeDashboardAction) -> &'static str { - match action { - HomeDashboardAction::Setup => "Set up ROCm", - HomeDashboardAction::Examine => "Run setup check", - HomeDashboardAction::Serve => "Start a local model", - HomeDashboardAction::Chat => "Chat with assistant", - HomeDashboardAction::ComfyUi => "Open ComfyUI", - HomeDashboardAction::Services => "List currently running models", - HomeDashboardAction::Engine => "Choose model runner", - HomeDashboardAction::Permissions => "Permissions", - HomeDashboardAction::Help => "Help", - HomeDashboardAction::Quit => "Quit", - } -} - -const fn home_dashboard_action_status(action: HomeDashboardAction) -> &'static str { - match action { - HomeDashboardAction::Setup => "Set up ROCm selected. Press Enter.", - HomeDashboardAction::Examine => "Setup check selected. Press Enter.", - HomeDashboardAction::Serve => "Start a local model selected. Press Enter.", - HomeDashboardAction::Chat => "Local assistant selected. Press Enter.", - HomeDashboardAction::ComfyUi => "ComfyUI selected. Press Enter.", - HomeDashboardAction::Services => "Running model list selected. Press Enter.", - HomeDashboardAction::Engine => "Model runner picker selected. Press Enter.", - HomeDashboardAction::Permissions => "Permissions selected. Press Enter.", - HomeDashboardAction::Help => "Help selected. Press Enter.", - HomeDashboardAction::Quit => "Quit selected. Press Enter.", - } -} - -const fn home_dashboard_action_command(action: HomeDashboardAction) -> &'static str { - match action { - HomeDashboardAction::Setup => "setup", - HomeDashboardAction::Examine => "examine", - HomeDashboardAction::Serve => "serve", - HomeDashboardAction::Chat => "chat --tools", - HomeDashboardAction::ComfyUi => "comfyui", - HomeDashboardAction::Services => "services", - HomeDashboardAction::Engine => "engine", - HomeDashboardAction::Permissions => "permissions", - HomeDashboardAction::Help => "help", - HomeDashboardAction::Quit => "quit", - } -} - -fn selection_style() -> Style { - Style::default() - .fg(Color::Black) - .bg(THEME_ACCENT) - .add_modifier(Modifier::BOLD) -} - -fn surface_block(title: &str, color: Color) -> Block<'_> { - Block::default() - .borders(Borders::ALL) - .title(title) - .border_style(Style::default().fg(color)) - .style(Style::default().bg(THEME_PANEL)) -} - -fn detail_block(title: &str) -> Block<'_> { - surface_block(title, THEME_BORDER) -} - -fn draw_scrollable_text_with_block( - frame: &mut Frame<'_>, - text: String, - area: Rect, - block: Block<'_>, - style: Style, - requested_scroll: u16, -) -> ScrollMetrics { - let inner = block.inner(area); - let metrics = text_scroll_metrics( - &text, - usize::from(inner.width.max(1)), - usize::from(inner.height.max(1)), - requested_scroll, - ); - let paragraph = Paragraph::new(text) - .block(block) - .style(style) - .wrap(Wrap { trim: false }) - .scroll((metrics.offset_u16(), 0)); - frame.render_widget(paragraph, area); - draw_scrollbar(frame, area, metrics); - metrics -} - -fn draw_scrollable_text( - frame: &mut Frame<'_>, - text: String, - area: Rect, - style: Style, - requested_scroll: u16, -) -> ScrollMetrics { - let metrics = text_scroll_metrics( - &text, - usize::from(area.width.max(1)), - usize::from(area.height.max(1)), - requested_scroll, - ); - let paragraph = Paragraph::new(text) - .style(style) - .wrap(Wrap { trim: false }) - .scroll((metrics.offset_u16(), 0)); - frame.render_widget(paragraph, area); - draw_scrollbar(frame, area, metrics); - metrics -} - -fn draw_scrollable_styled_text_with_block( - frame: &mut Frame<'_>, - styled_text: Text<'static>, - area: Rect, - block: Block<'_>, - style: Style, - requested_scroll: u16, -) -> ScrollMetrics { - let inner = block.inner(area); - let metrics = styled_text_scroll_metrics( - &styled_text, - usize::from(inner.width.max(1)), - usize::from(inner.height.max(1)), - requested_scroll, - ); - let paragraph = Paragraph::new(styled_text) - .block(block) - .style(style) - .wrap(Wrap { trim: false }) - .scroll((metrics.offset_u16(), 0)); - frame.render_widget(paragraph, area); - draw_scrollbar(frame, area, metrics); - metrics -} - -fn draw_modal_shadow(frame: &mut Frame<'_>, modal: Rect, bounds: Rect) { - let x = modal.x.saturating_add(2); - let y = modal.y.saturating_add(1); - if x >= bounds.right() || y >= bounds.bottom() { - return; - } - let width = modal.width.min(bounds.right().saturating_sub(x)); - let height = modal.height.min(bounds.bottom().saturating_sub(y)); - if width == 0 || height == 0 { - return; - } - let shadow = Rect { - x, - y, - width, - height, - }; - frame.render_widget( - Block::default().style(Style::default().bg(THEME_SHADOW)), - shadow, - ); -} - -fn draw_modal_clear(frame: &mut Frame<'_>, modal: Rect, bounds: Rect) { - draw_modal_shadow(frame, modal, bounds); - frame.render_widget(Clear, modal); -} - -fn approval_modal_border_color(pending: &PendingApproval) -> Color { - let title = pending.title.to_ascii_lowercase(); - if title.contains("uninstall") || title.contains("stop") || title.contains("disable") { - THEME_DANGER - } else { - THEME_WARN - } -} - -fn sidebar_border_color(app: &App) -> Color { - if app.pending_approval.is_some() { - THEME_WARN - } else if app.running_job.is_some() { - THEME_ACCENT - } else if app.status.to_ascii_lowercase().contains("ready") - || app.status.to_ascii_lowercase().contains("complete") - { - THEME_GOOD - } else if app.status.to_ascii_lowercase().contains("fail") - || app.status.to_ascii_lowercase().contains("error") - { - THEME_DANGER - } else { - THEME_MUTED - } -} - -const fn should_draw_modal_approval(_app: &App) -> bool { - true -} - -fn centered_rect(percent_x: u16, percent_y: u16, area: Rect) -> Rect { - let vertical = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(area); - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(vertical[1])[1] -} - -fn draw_pending_approval_modal(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(pending) = app.pending_approval.as_ref() else { - return; - }; - let modal = centered_rect(72, 82, area); - draw_modal_clear(frame, modal, area); - let title = format!(" Review: {} ", pending.title); - draw_scrollable_styled_text_with_block( - frame, - render_modal_approval_text(app, pending), - modal, - surface_block(&title, approval_modal_border_color(pending)), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3), - modal_detail_scroll(app), - ); -} - -fn render_modal_approval_text(app: &App, pending: &PendingApproval) -> Text<'static> { - let rendered = render_modal_approval(app, pending); - let lines = rendered - .lines() - .map(|line| { - let trimmed = line.trim_start(); - let leading = line.len().saturating_sub(trimmed.len()); - if let Some(selected) = trimmed.strip_prefix("> ") { - return Line::from(vec![ - Span::raw(" ".repeat(leading)), - Span::styled(format!(" {selected} "), selection_style()), - ]); - } - if trimmed.starts_with("Enter or Y:") - || trimmed.starts_with("Esc or N:") - || trimmed.starts_with("PageUp/PageDown:") - { - return Line::from(vec![ - Span::raw(" ".repeat(leading)), - Span::styled(trimmed.to_owned(), Style::default().fg(THEME_MUTED)), - ]); - } - Line::from(line.to_owned()) - }) - .collect::>(); - Text::from(lines) -} - -fn draw_overlay_card(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.overlay_card.as_ref() else { - return; - }; - let modal = centered_rect(82, 74, area); - draw_modal_clear(frame, modal, area); - let title = format!(" {} ", state.title); - let block = surface_block(&title, THEME_ACCENT); - let inner = block.inner(modal); - frame.render_widget(block, modal); - if state.actions.is_empty() { - draw_scrollable_text( - frame, - state.detail.clone(), - inner, - Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3), - state.detail_scroll, - ); - return; - } - - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(36), Constraint::Min(28)]) - .split(inner); - let items = state - .actions - .iter() - .map(|action| ListItem::new(overlay_card_action_label(state, *action))) - .collect::>(); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let list = List::new(items) - .block(surface_block("Choose", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - overlay_card_detail_text(state), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3), - state.detail_scroll, - ); -} - -fn draw_folder_browser_modal(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.folder_browser.as_ref() else { - return; - }; - frame.render_widget(Clear, area); - frame.render_widget(Block::default().style(Style::default().bg(THEME_BG)), area); - let modal = centered_rect(92, 86, area); - draw_modal_clear(frame, modal, area); - let title = format!(" {} ", state.title); - let block = surface_block(&title, THEME_ACCENT); - let inner = block.inner(modal); - frame.render_widget(block, modal); - - let rows = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Length(5), - Constraint::Min(8), - Constraint::Length(3), - ]) - .split(inner); - - let header = Paragraph::new(folder_browser_header_text(state)) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3)) - .wrap(Wrap { trim: false }); - frame.render_widget(header, rows[0]); - - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(58), Constraint::Min(28)]) - .split(rows[1]); - - let list_inner_height = panes[0].height.saturating_sub(2) as usize; - let (visible_start, visible_end) = folder_browser_visible_range(state, list_inner_height); - let items = state - .entries - .iter() - .skip(visible_start) - .take(visible_end.saturating_sub(visible_start)) - .map(folder_browser_list_item) - .collect::>(); - let mut list_state = ListState::default(); - if !items.is_empty() { - list_state.select(Some(state.selected.saturating_sub(visible_start))); - } - let list = List::new(items) - .block(surface_block("Folders", THEME_ACCENT)) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL)) - .highlight_symbol(" ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - draw_scrollbar( - frame, - panes[0], - scroll_metrics(state.entries.len(), list_inner_height.max(1), visible_start), - ); - - let detail = Paragraph::new(folder_browser_detail_text(state)) - .block(detail_block("Selected")) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3)) - .wrap(Wrap { trim: false }); - frame.render_widget(detail, panes[1]); - - let footer = - Paragraph::new("Enter open/select | Backspace up | PageUp/PageDown scroll | Esc cancel") - .style(Style::default().fg(THEME_MUTED).bg(THEME_PANEL)); - frame.render_widget(footer, rows[2]); -} - -fn folder_browser_header_text(state: &FolderBrowserState) -> Text<'static> { - Text::from(vec![ - Line::from(vec![Span::styled( - state.prompt.clone(), - Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD), - )]), - Line::from(""), - Line::from(vec![ - Span::styled("Current ", Style::default().fg(THEME_MUTED)), - Span::styled( - display_runtime_folder_path(&state.current_dir), - Style::default().fg(THEME_ACCENT), - ), - ]), - ]) -} - -fn folder_browser_list_item(entry: &FolderBrowserEntry) -> ListItem<'static> { - let style = match entry.kind { - FolderBrowserEntryKind::UseCurrent => { - Style::default().fg(THEME_GOOD).add_modifier(Modifier::BOLD) - } - FolderBrowserEntryKind::NewChild => { - Style::default().fg(THEME_WARN).add_modifier(Modifier::BOLD) - } - FolderBrowserEntryKind::Parent => Style::default().fg(THEME_MUTED), - FolderBrowserEntryKind::Drive => Style::default() - .fg(THEME_ACCENT) - .add_modifier(Modifier::BOLD), - FolderBrowserEntryKind::Directory => Style::default().fg(THEME_TEXT), - }; - ListItem::new(Line::from(Span::styled(entry.label.clone(), style))) -} - -fn folder_browser_detail_text(state: &FolderBrowserState) -> Text<'static> { - let selected = state.entries.get(state.selected); - let mut lines = Vec::new(); - if let Some(entry) = selected { - for line in wrap_plain_line(&display_runtime_folder_path(&entry.path), 54) { - lines.push(Line::from(line)); - } - match &state.context { - FolderBrowserContext::InstallSdk { args } => { - if let Some(date) = cli_arg_value(args, "--build-date") { - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("TheRock build date ", Style::default().fg(THEME_MUTED)), - Span::raw(date.to_owned()), - ])); - } - if let Some(version) = cli_arg_value(args, "--version") { - lines.push(Line::from("")); - lines.push(Line::from(vec![ - Span::styled("TheRock version ", Style::default().fg(THEME_MUTED)), - Span::raw(version.to_owned()), - ])); - } - if matches!( - entry.kind, - FolderBrowserEntryKind::UseCurrent | FolderBrowserEntryKind::NewChild - ) { - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Next: review install", - Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD), - )])); - } - } - FolderBrowserContext::InstallSdkForm => { - if matches!( - entry.kind, - FolderBrowserEntryKind::UseCurrent | FolderBrowserEntryKind::NewChild - ) { - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Next: use in install form", - Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD), - )])); - } - } - FolderBrowserContext::OnboardingInstallFolder => { - if matches!( - entry.kind, - FolderBrowserEntryKind::UseCurrent | FolderBrowserEntryKind::NewChild - ) { - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - "Next: use for ROCm setup", - Style::default().fg(THEME_TEXT).add_modifier(Modifier::BOLD), - )])); - } - } - } - } - if let Some(message) = state.message.as_deref() { - lines.push(Line::from("")); - lines.push(Line::from(vec![Span::styled( - message.to_owned(), - Style::default().fg(THEME_WARN), - )])); - } - Text::from(lines) -} - -fn folder_browser_visible_range(state: &FolderBrowserState, visible_len: usize) -> (usize, usize) { - let visible_len = state.entries.len().min(visible_len); - if visible_len == 0 { - return (0, 0); - } - let selected = state.selected.min(state.entries.len().saturating_sub(1)); - let max_start = state.entries.len().saturating_sub(visible_len); - let mut start = state.scroll_offset.min(max_start); - if selected < start { - start = selected; - } else if selected >= start.saturating_add(visible_len) { - start = selected.saturating_add(1).saturating_sub(visible_len); - } - start = start.min(max_start); - let end = start.saturating_add(visible_len).min(state.entries.len()); - (start, end) -} - -fn should_draw_running_job_modal(app: &App) -> bool { - app.overlay_card.is_none() - && app.folder_browser.is_none() - && app.pending_approval.is_none() - && !app.onboarding_cancel_install_confirm - && app.running_job.as_ref().is_some_and(|job| { - matches!( - job.kind, - RunningJobKind::Cli - | RunningJobKind::Planner { .. } - | RunningJobKind::Proposal { .. } - | RunningJobKind::ServiceLifecycle { .. } - ) - }) -} - -#[derive(Debug, Clone, Copy)] -struct RunningJobModalLayout { - header_area: Rect, - log_area: Rect, - live_output_width: usize, - compact: bool, -} - -fn running_job_modal_layout(app: &App, job: &RunningJob, area: Rect) -> RunningJobModalLayout { - let modal = centered_rect(92, 88, area); - let inner = rect_inner(modal); - let live_output_width = inner.width.saturating_sub(3).max(8) as usize; - let compact = inner.height < 10; - let header = running_job_modal_header_text(app, job, compact); - let desired_header_height = header.lines().count().max(1) as u16; - let header_height = desired_header_height - .min(inner.height.saturating_sub(1)) - .max(1) - .min(inner.height); - let header_area = Rect { - x: inner.x, - y: inner.y, - width: inner.width, - height: header_height, - }; - let log_area = Rect { - x: inner.x, - y: inner.y.saturating_add(header_height), - width: inner.width, - height: inner.height.saturating_sub(header_height), - }; - RunningJobModalLayout { - header_area, - log_area, - live_output_width, - compact, - } -} - -fn draw_running_job_modal(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(job) = app.running_job.as_ref() else { - return; - }; - let modal = centered_rect(92, 88, area); - draw_modal_clear(frame, modal, area); - let title = format!(" {} ", job.title); - let block = surface_block(&title, THEME_WARN); - frame.render_widget(block, modal); - let layout = running_job_modal_layout(app, job, area); - let header = running_job_modal_header_text(app, job, layout.compact); - let header = Paragraph::new(header) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3)) - .wrap(Wrap { trim: false }) - .scroll((0, 0)); - frame.render_widget(header, layout.header_area); - if layout.log_area.height == 0 { - return; - } - let viewport = usize::from(layout.log_area.height).max(1); - let scroll = running_job_visible_output_start(app, viewport, layout.live_output_width); - draw_scrollable_text( - frame, - running_job_modal_full_output_text(app, layout.live_output_width), - layout.log_area, - Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3), - scroll.min(u16::MAX as usize) as u16, - ); -} - -fn render_modal_approval(app: &App, pending: &PendingApproval) -> String { - if app.onboarding_active { - return render_onboarding_approval(app, pending); - } - if app.runtime_manager.is_some() { - return render_runtime_manager_approval(app, pending); - } - render_screen_approval(app, pending) -} - -fn modal_detail_scroll(app: &App) -> u16 { - app.pending_approval - .as_ref() - .map_or(0, |pending| pending.detail_scroll) -} - -fn draw_examine_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.examine_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(34), Constraint::Min(38)]) - .split(area); - let choices = examine_manager_choices(); - let items = choices - .iter() - .map(|choice| ListItem::new(examine_manager_label(*choice))) - .collect::>(); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let title = format!( - "Examine {}/{}", - state.selected.saturating_add(1).min(choices.len()), - choices.len() - ); - let list = List::new(items) - .block(surface_block(&title, THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - examine_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_runtime_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.runtime_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(42), Constraint::Min(32)]) - .split(area); - - let items = runtime_manager_list_items(state); - let mut list_state = ListState::default(); - list_state.select(Some(runtime_manager_selected_index(state, items.len()))); - let title = runtime_manager_title(state); - let list = List::new(items) - .block(surface_block(&title, THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - runtime_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_install_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.install_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(38), Constraint::Min(34)]) - .split(area); - let (title, selected, items) = match &state.screen { - InstallManagerScreen::Main => ( - "Install".to_owned(), - state.selected, - install_menu_choices() - .iter() - .map(|choice| ListItem::new(install_menu_label(*choice))) - .collect::>(), - ), - InstallManagerScreen::Sdk { - selected, - channel, - folder, - .. - } => ( - format!( - "ROCm Install {}/{}", - selected.saturating_add(1).min(install_sdk_choices().len()), - install_sdk_choices().len() - ), - *selected, - install_sdk_choices() - .iter() - .map(|choice| ListItem::new(install_sdk_choice_label(*choice, *channel, folder))) - .collect::>(), - ), - }; - let mut list_state = ListState::default(); - list_state.select(Some(selected.min(items.len().saturating_sub(1)))); - let list = List::new(items) - .block(surface_block(&title, THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - install_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_engine_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.engine_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(38), Constraint::Min(34)]) - .split(area); - let rows = engine_manager_rows(state); - let items = rows - .iter() - .map(|row| match row { - EngineManagerRow::Item(index) => state - .items - .get(*index) - .map_or_else(|| "Engine".to_owned(), engine_manager_row_label), - EngineManagerRow::Action(action) => engine_manager_action_label(*action).to_owned(), - }) - .map(ListItem::new) - .collect::>(); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(rows.len().saturating_sub(1)))); - let list = List::new(items) - .block(surface_block("Engines", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - engine_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_model_picker(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.model_picker.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(42), Constraint::Min(34)]) - .split(area); - let items = if state.items.is_empty() { - vec![ListItem::new("No model recipes")] - } else { - state - .items - .iter() - .map(|recipe| ListItem::new(model_picker_row_label(recipe))) - .collect::>() - }; - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let title = if state.items.is_empty() { - "Models".to_owned() - } else { - format!( - "Models {}/{}", - state.selected.saturating_add(1).min(state.items.len()), - state.items.len() - ) - }; - let list = List::new(items) - .block(surface_block(&title, THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - model_picker_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_serve_wizard(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.serve_wizard.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(42), Constraint::Min(34)]) - .split(area); - let choices = serve_wizard_choices(); - let items = choices - .iter() - .map(|choice| serve_wizard_list_item(app, *choice)) - .collect::>(); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let list = List::new(items) - .block(surface_block("Local AI", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - serve_wizard_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn serve_wizard_list_item(app: &App, choice: ServeWizardChoice) -> ListItem<'static> { - let label = serve_wizard_row_label(app, choice); - let enabled = app - .serve_wizard - .as_ref() - .is_some_and(|state| serve_wizard_choice_enabled(state, choice)); - if enabled { - ListItem::new(label) - } else { - ListItem::new(Line::from(Span::styled( - label, - Style::default().fg(THEME_MUTED), - ))) - } -} - -fn draw_update_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.update_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(38), Constraint::Min(34)]) - .split(area); - let choices = update_menu_choices(); - let items = choices - .iter() - .map(|choice| ListItem::new(update_menu_label(*choice))) - .collect::>(); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let list = List::new(items) - .block(surface_block("Updates", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - update_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_automations_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.automations_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(38), Constraint::Min(34)]) - .split(area); - let items = if state.rows.is_empty() { - vec![ListItem::new("No automation checks")] - } else { - state - .rows - .iter() - .map(|row| ListItem::new(automation_manager_row_label(state, *row))) - .collect::>() - }; - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let title = if state.rows.is_empty() { - "Automations".to_owned() - } else { - format!( - "Automations {}/{}", - state.selected.saturating_add(1).min(state.rows.len()), - state.rows.len() - ) - }; - let list = List::new(items) - .block(surface_block(&title, THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - automations_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_config_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.config_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(38), Constraint::Min(34)]) - .split(area); - let items = config_manager_list_items(app); - let mut list_state = ListState::default(); - list_state.select(Some(config_manager_selected_index(state, items.len()))); - let title = config_manager_title(state); - let list = List::new(items) - .block(surface_block(&title, THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - config_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_provider_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.provider_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(34), Constraint::Min(36)]) - .split(area); - let options = provider_options(); - let items = options - .iter() - .map(|option| ListItem::new(provider_row_label(*option, &app.provider))) - .collect::>(); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let list = List::new(items) - .block(surface_block("Providers", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - provider_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_services_manager(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.services_manager.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(42), Constraint::Min(34)]) - .split(area); - let mut items = state - .items - .iter() - .map(|record| ListItem::new(service_row_label(record))) - .collect::>(); - items.extend( - services_manager_actions(state) - .iter() - .map(|action| ListItem::new(services_manager_action_label(*action))), - ); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let title = if state.items.is_empty() { - "Services".to_owned() - } else { - format!( - "Services {}/{}", - state - .selected - .saturating_add(1) - .min(services_manager_choice_count(state)), - services_manager_choice_count(state) - ) - }; - let list = List::new(items) - .block(surface_block(&title, THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - services_manager_detail_text(app), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); -} - -fn draw_command_screen(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.command_screen.as_ref() else { - return; - }; - if state.chat_session.is_some() { - app.command_screen_last_area.set(Some(area)); - let detail_text = command_screen_detail_text(app); - draw_scrollable_styled_text_with_block( - frame, - styled_chat_detail_text(&detail_text), - area, - surface_block("ROCm Assistant", THEME_ACCENT), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); - if let Some(title) = command_screen_detail_modal_title(app, state) { - draw_command_screen_detail_modal(frame, app, area, title); - } - return; - } - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(32), Constraint::Min(40)]) - .split(area); - let items = state - .actions - .iter() - .map(|action| ListItem::new(command_screen_action_label(*action))) - .collect::>(); - let mut list_state = ListState::default(); - if !items.is_empty() { - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - } - let list = List::new(items) - .block(surface_block(state.title.as_str(), THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - let detail_text = command_screen_detail_text(app); - draw_scrollable_text_with_block( - frame, - detail_text, - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); - - if let Some(title) = command_screen_detail_modal_title(app, state) { - draw_command_screen_detail_modal(frame, app, area, title); - } -} - -fn bounded_chat_session_scroll(text: &str, area: Rect, requested: u16) -> u16 { - if requested == 0 || text.trim().is_empty() { - return 0; - } - let styled_text = styled_chat_detail_text(text); - let metrics = styled_text_scroll_metrics( - &styled_text, - area.width.saturating_sub(2).max(1) as usize, - area.height.saturating_sub(2).max(1) as usize, - requested, - ); - metrics.offset_u16() -} - -fn paragraph_visual_line_count(text: &str, width: usize) -> usize { - Paragraph::new(text.to_owned()) - .wrap(Wrap { trim: false }) - .line_count(width.min(u16::MAX as usize) as u16) - .max(1) -} - -fn styled_text_visual_line_count(text: &Text<'_>, width: usize) -> usize { - Paragraph::new(text.clone()) - .wrap(Wrap { trim: false }) - .line_count(width.min(u16::MAX as usize) as u16) - .max(1) -} - -fn draw_logs_view(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(state) = app.logs_view.as_ref() else { - return; - }; - let panes = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(30), Constraint::Min(42)]) - .split(area); - let actions = logs_view_actions(); - let items = actions - .iter() - .map(|action| { - ListItem::new(logs_view_action_label( - *action, - state.follow, - state.show_file_locations, - )) - }) - .collect::>(); - let mut list_state = ListState::default(); - list_state.select(Some(state.selected.min(items.len().saturating_sub(1)))); - let list = List::new(items) - .block(surface_block("Logs", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, panes[0], &mut list_state); - - draw_scrollable_text_with_block( - frame, - state.last_rendered.clone(), - panes[1], - detail_block("Details"), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL), - state.detail_scroll, - ); - - if state.detail_modal.is_some() { - draw_logs_view_detail_modal(frame, app, area); - } -} - -fn command_screen_detail_modal_title<'a>( - app: &App, - state: &'a CommandScreenState, -) -> Option<&'a str> { - if app.pending_approval.is_some() || should_draw_running_job_modal(app) { - return None; - } - state - .detail_modal - .as_ref() - .map(|modal| modal.title.as_str()) -} - -fn draw_command_screen_detail_modal(frame: &mut Frame<'_>, app: &App, area: Rect, title: &str) { - let Some(modal_state) = app - .command_screen - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - else { - return; - }; - let modal = centered_rect(78, 72, area); - draw_modal_clear(frame, modal, area); - let block_title = format!(" {title} "); - draw_scrollable_text_with_block( - frame, - modal_state.detail.clone(), - modal, - surface_block(&block_title, THEME_ACCENT), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3), - modal_state.scroll, - ); -} - -fn draw_logs_view_detail_modal(frame: &mut Frame<'_>, app: &App, area: Rect) { - let Some(modal_state) = app - .logs_view - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - else { - return; - }; - let modal = centered_rect(78, 72, area); - draw_modal_clear(frame, modal, area); - let block_title = format!(" {} ", modal_state.title); - draw_scrollable_text_with_block( - frame, - modal_state.detail.clone(), - modal, - surface_block(&block_title, THEME_ACCENT), - Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3), - modal_state.scroll, - ); -} - -const fn examine_manager_choices() -> &'static [ExamineManagerChoice] { - &[ - ExamineManagerChoice::Overview, - ExamineManagerChoice::Gpu, - ExamineManagerChoice::Runtimes, - ExamineManagerChoice::Engines, - ExamineManagerChoice::DriverAndWsl, - ExamineManagerChoice::Folders, - ExamineManagerChoice::Back, - ] -} - -const fn examine_manager_label(choice: ExamineManagerChoice) -> &'static str { - match choice { - ExamineManagerChoice::Overview => "Overview", - ExamineManagerChoice::Gpu => "GPU", - ExamineManagerChoice::Runtimes => "ROCm installs", - ExamineManagerChoice::Engines => "Engines", - ExamineManagerChoice::DriverAndWsl => "Driver and WSL", - ExamineManagerChoice::Folders => "Folders", - ExamineManagerChoice::Back => "Back", - } -} - -fn runtime_manager_row_label(item: &RuntimeManagerItem) -> String { - let state = if item.active { - "Default" - } else if item.status == "ready" { - "Installed" - } else { - "Needs fix" - }; - let version = runtime_manager_row_version_label(&item.version); - if version.trim().is_empty() || version == "unknown" { - state.to_owned() - } else { - format!("{state} - {version}") - } -} - -fn runtime_manager_row_version_label(version: &str) -> String { - if let Some(date) = therock::runtime_version_build_date(version) { - date - } else { - therock::runtime_version_display(version) - } -} - -fn runtime_manager_actions(state: &RuntimeManagerState) -> Vec { - let mut actions = vec![RuntimeManagerAction::Install]; - if !state.items.is_empty() { - actions.push(RuntimeManagerAction::RemoveSelected); - } - actions.push(RuntimeManagerAction::AdvancedOptions); - actions -} - -const fn runtime_advanced_choices() -> &'static [RuntimeAdvancedChoice] { - &[ - RuntimeAdvancedChoice::AdoptExisting, - RuntimeAdvancedChoice::ImportManifest, - RuntimeAdvancedChoice::Back, - ] -} - -const fn runtime_import_choices() -> &'static [RuntimeImportChoice] { - &[ - RuntimeImportChoice::ManifestPath, - RuntimeImportChoice::Replace, - RuntimeImportChoice::Import, - RuntimeImportChoice::Back, - ] -} - -const fn runtime_adopt_choices() -> &'static [RuntimeAdoptChoice] { - &[ - RuntimeAdoptChoice::PythonPath, - RuntimeAdoptChoice::Channel, - RuntimeAdoptChoice::Replace, - RuntimeAdoptChoice::Adopt, - RuntimeAdoptChoice::Back, - ] -} - -fn runtime_manager_choice_count(state: &RuntimeManagerState) -> usize { - state.items.len() + runtime_manager_actions(state).len() -} - -fn runtime_manager_selected_index(state: &RuntimeManagerState, row_count: usize) -> usize { - let selected = match &state.screen { - RuntimeManagerScreen::List => state.selected, - RuntimeManagerScreen::Advanced { selected } => *selected, - RuntimeManagerScreen::ImportManifest { selected, .. } => *selected, - RuntimeManagerScreen::AdoptExisting { selected, .. } => *selected, - }; - selected.min(row_count.saturating_sub(1)) -} - -fn runtime_manager_title(state: &RuntimeManagerState) -> String { - match &state.screen { - RuntimeManagerScreen::List => "ROCm Installs".to_owned(), - RuntimeManagerScreen::Advanced { .. } => "Add Existing ROCm".to_owned(), - RuntimeManagerScreen::ImportManifest { .. } => "Add From Saved File".to_owned(), - RuntimeManagerScreen::AdoptExisting { .. } => "Add Existing Python".to_owned(), - } -} - -fn runtime_manager_list_items(state: &RuntimeManagerState) -> Vec> { - match &state.screen { - RuntimeManagerScreen::List => { - let mut items = state - .items - .iter() - .map(|item| ListItem::new(runtime_manager_row_label(item))) - .collect::>(); - items.extend( - runtime_manager_actions(state) - .iter() - .map(|action| ListItem::new(runtime_manager_action_label(*action))), - ); - items - } - RuntimeManagerScreen::Advanced { .. } => runtime_advanced_choices() - .iter() - .map(|choice| ListItem::new(runtime_advanced_choice_label(*choice))) - .collect(), - RuntimeManagerScreen::ImportManifest { - manifest_path, - replace, - .. - } => runtime_import_choices() - .iter() - .map(|choice| { - ListItem::new(runtime_import_choice_label( - *choice, - manifest_path, - *replace, - )) - }) - .collect(), - RuntimeManagerScreen::AdoptExisting { - python_path, - channel, - replace, - .. - } => runtime_adopt_choices() - .iter() - .map(|choice| { - ListItem::new(runtime_adopt_choice_label( - *choice, - python_path, - *channel, - *replace, - )) - }) - .collect(), - } -} - -fn runtime_manager_action_label(action: RuntimeManagerAction) -> String { - match action { - RuntimeManagerAction::Install => "Install ROCm".to_owned(), - RuntimeManagerAction::RemoveSelected => "Uninstall selected install".to_owned(), - RuntimeManagerAction::AdvancedOptions => "Add existing install".to_owned(), - } -} - -fn runtime_advanced_choice_label(choice: RuntimeAdvancedChoice) -> String { - match choice { - RuntimeAdvancedChoice::AdoptExisting => "Add existing Python folder".to_owned(), - RuntimeAdvancedChoice::ImportManifest => "Add from saved ROCm file".to_owned(), - RuntimeAdvancedChoice::Back => "Back".to_owned(), - } -} - -fn runtime_import_choice_label( - choice: RuntimeImportChoice, - manifest_path: &str, - replace: bool, -) -> String { - match choice { - RuntimeImportChoice::ManifestPath => format!( - "{:<14} {}", - "Saved file", - if manifest_path.trim().is_empty() { - "choose file" - } else { - manifest_path - } - ), - RuntimeImportChoice::Replace => { - format!("{:<14} {}", "Replace", if replace { "yes" } else { "no" }) - } - RuntimeImportChoice::Import => "Add install".to_owned(), - RuntimeImportChoice::Back => "Back".to_owned(), - } -} - -fn runtime_adopt_choice_label( - choice: RuntimeAdoptChoice, - python_path: &str, - channel: RuntimeAdoptChannel, - replace: bool, -) -> String { - match choice { - RuntimeAdoptChoice::PythonPath => format!( - "{:<14} {}", - "Python folder", - if python_path.trim().is_empty() { - "choose folder" - } else { - python_path - } - ), - RuntimeAdoptChoice::Channel => format!("{:<14} {}", "Channel", channel.as_str()), - RuntimeAdoptChoice::Replace => { - format!("{:<14} {}", "Replace", if replace { "yes" } else { "no" }) - } - RuntimeAdoptChoice::Adopt => "Add install".to_owned(), - RuntimeAdoptChoice::Back => "Back".to_owned(), - } -} - -fn engine_manager_row_label(item: &EngineManagerItem) -> String { - let state = if item.is_default { - "Default" - } else if item.installed_env_id.is_some() { - "Installed" - } else { - "Not set up" - }; - format!("{state} {}", item.name) -} - -fn engine_manager_actions_for_item(item: Option<&EngineManagerItem>) -> Vec { - let mut actions = Vec::new(); - if let Some(item) = item { - if item.installed_env_id.is_some() { - actions.push(EngineManagerAction::UseSelected); - actions.push(EngineManagerAction::ReinstallSelected); - } else if item.runtime_ready { - actions.push(EngineManagerAction::InstallSelected); - } else { - actions.push(EngineManagerAction::InstallRocm); - } - } - actions.push(EngineManagerAction::Back); - actions -} - -fn engine_manager_rows(state: &EngineManagerState) -> Vec { - let selected_item = state - .last_item_selected - .min(state.items.len().saturating_sub(1)); - let mut rows = Vec::new(); - for (index, item) in state.items.iter().enumerate() { - rows.push(EngineManagerRow::Item(index)); - if index == selected_item { - rows.extend( - engine_manager_actions_for_item(Some(item)) - .into_iter() - .map(EngineManagerRow::Action), - ); - } - } - if state.items.is_empty() { - rows.extend( - engine_manager_actions_for_item(None) - .into_iter() - .map(EngineManagerRow::Action), - ); - } - rows -} - -fn engine_manager_selected_row(state: &EngineManagerState) -> Option { - engine_manager_rows(state).get(state.selected).copied() -} - -fn engine_manager_choice_count(state: &EngineManagerState) -> usize { - engine_manager_rows(state).len() -} - -const fn engine_manager_action_label(action: EngineManagerAction) -> &'static str { - match action { - EngineManagerAction::UseSelected => "Use selected engine", - EngineManagerAction::InstallSelected => "Install selected engine", - EngineManagerAction::ReinstallSelected => "Reinstall selected engine", - EngineManagerAction::InstallRocm => "Install ROCm first", - EngineManagerAction::Back => "Back", - } -} - -fn model_picker_row_label(recipe: &ModelRecipeRecord) -> String { - let gpu = recipe - .min_gpu_mem_gb - .map_or_else(|| "small".to_owned(), |gb| format!("{gb}GB GPU")); - format!("{:<10} {}", gpu, recipe.canonical_model_id) -} - -fn tui_gpu_model_recipes(recipes: Vec) -> Vec { - recipes - .into_iter() - .filter(|recipe| recipe.device_policy != "cpu_only") - .collect() -} - -fn tui_recommended_assistant_recipes(recipes: Vec) -> Vec { - tui_gpu_model_recipes(recipes) - .into_iter() - .filter(|recipe| recipe.canonical_model_id == VALIDATED_LOCAL_ASSISTANT_MODEL) - .collect() -} - -fn serve_wizard_preferred_engine_index( - host_gpu_summary: &HostGpuSummary, - recipes: &[ModelRecipeRecord], -) -> Option { - let engines = serve_wizard_engine_names(); - if let Some(engine) = preferred_serve_engine_for_host_gpu_summary(host_gpu_summary) - && let Some(index) = engines.iter().position(|candidate| candidate == engine) - { - return Some(index); - } - recipes.first().and_then(|recipe| { - recipe - .preferred_engines - .iter() - .find_map(|preferred| engines.iter().position(|engine| engine == preferred)) - }) -} - -const fn serve_wizard_choices() -> &'static [ServeWizardChoice] { - &[ - ServeWizardChoice::Source, - ServeWizardChoice::Model, - ServeWizardChoice::LocalPath, - ServeWizardChoice::Engine, - ServeWizardChoice::Device, - ServeWizardChoice::Host, - ServeWizardChoice::Port, - ServeWizardChoice::Mode, - ServeWizardChoice::Review, - ServeWizardChoice::Back, - ] -} - -fn serve_wizard_choice_index(choice: ServeWizardChoice) -> usize { - serve_wizard_choices() - .iter() - .position(|candidate| *candidate == choice) - .unwrap_or(0) -} - -fn serve_wizard_choice_enabled(state: &ServeWizardState, choice: ServeWizardChoice) -> bool { - match choice { - ServeWizardChoice::Model => false, - ServeWizardChoice::LocalPath => state.model_source == ServeModelSource::LocalPath, - ServeWizardChoice::Source - | ServeWizardChoice::Engine - | ServeWizardChoice::Device - | ServeWizardChoice::Host - | ServeWizardChoice::Port - | ServeWizardChoice::Mode - | ServeWizardChoice::Review - | ServeWizardChoice::Back => true, - } -} - -fn first_enabled_serve_wizard_index(state: &ServeWizardState) -> usize { - serve_wizard_choices() - .iter() - .position(|choice| serve_wizard_choice_enabled(state, *choice)) - .unwrap_or(0) -} - -fn last_enabled_serve_wizard_index(state: &ServeWizardState) -> usize { - serve_wizard_choices() - .iter() - .rposition(|choice| serve_wizard_choice_enabled(state, *choice)) - .unwrap_or_else(|| serve_wizard_choices().len().saturating_sub(1)) -} - -fn next_enabled_serve_wizard_index( - state: &ServeWizardState, - direction: CompletionDirection, -) -> usize { - let choices = serve_wizard_choices(); - if choices.is_empty() { - return 0; - } - let mut candidate = state.selected.min(choices.len().saturating_sub(1)); - for _ in 0..choices.len() { - candidate = match direction { - CompletionDirection::Next => (candidate + 1) % choices.len(), - CompletionDirection::Previous => { - if candidate == 0 { - choices.len().saturating_sub(1) - } else { - candidate - 1 - } - } - }; - if serve_wizard_choice_enabled(state, choices[candidate]) { - return candidate; - } - } - state.selected.min(choices.len().saturating_sub(1)) -} - -const fn serve_wizard_device_options() -> &'static [&'static str] { - &["gpu_required"] -} - -fn serve_wizard_engine_names() -> Vec { - engine_inventory() - .iter() - .map(|(engine, _)| (*engine).to_owned()) - .collect() -} - -const fn cycle_index(current: usize, len: usize, direction: CompletionDirection) -> usize { - if len == 0 { - return 0; - } - match direction { - CompletionDirection::Next => (current + 1) % len, - CompletionDirection::Previous => { - if current == 0 { - len.saturating_sub(1) - } else { - current - 1 - } - } - } -} - -fn selected_serve_wizard_recipe(state: &ServeWizardState) -> Option<&ModelRecipeRecord> { - state - .recipes - .get(state.model_index) - .or_else(|| state.recipes.first()) -} - -fn selected_serve_wizard_engine(state: &ServeWizardState) -> String { - let engines = serve_wizard_engine_names(); - engines - .get(state.engine_index) - .cloned() - .or_else(|| engines.first().cloned()) - .unwrap_or_else(|| default_engine_for_platform().to_owned()) -} - -fn selected_serve_wizard_device(state: &ServeWizardState) -> &'static str { - serve_wizard_device_options() - .get(state.device_index) - .copied() - .unwrap_or("gpu_required") -} - -const fn serve_wizard_source_label(source: ServeModelSource) -> &'static str { - match source { - ServeModelSource::BuiltInRecipe => "recommended model", - ServeModelSource::LocalPath => "my own model file", - } -} - -fn serve_wizard_local_path_label(path: &str) -> String { - if path.trim().is_empty() { - "choose a file or folder".to_owned() - } else { - path.to_owned() - } -} - -fn serve_wizard_row_label(app: &App, choice: ServeWizardChoice) -> String { - let Some(state) = app.serve_wizard.as_ref() else { - return String::new(); - }; - match choice { - ServeWizardChoice::Source => format!( - "{:<10} {}", - "Model type", - serve_wizard_source_label(state.model_source) - ), - ServeWizardChoice::Model => format!( - "{:<10} {}", - "Model", - if state.model_source == ServeModelSource::BuiltInRecipe { - selected_serve_wizard_recipe(state).map_or_else( - || "no recommended models".to_owned(), - |recipe| format!("fixed {}", compact_model_label(&recipe.canonical_model_id)), - ) - } else { - "not used".to_owned() - } - ), - ServeWizardChoice::LocalPath => format!( - "{:<10} {}", - "Path", - if state.model_source == ServeModelSource::BuiltInRecipe { - "custom model only".to_owned() - } else { - serve_wizard_local_path_label(&state.local_model_path) - } - ), - ServeWizardChoice::Engine => { - format!("{:<10} {}", "Engine", selected_serve_wizard_engine(state)) - } - ServeWizardChoice::Device => format!( - "{:<10} {}", - "Device", - serve_wizard_device_label(selected_serve_wizard_device(state)) - ), - ServeWizardChoice::Host => format!("{:<10} {}", "Host", state.host), - ServeWizardChoice::Port => format!("{:<10} {}", "Port", state.port), - ServeWizardChoice::Mode => format!( - "{:<10} {}", - "Mode", - if state.managed { - "managed" - } else { - "command only" - } - ), - ServeWizardChoice::Review => "Start".to_owned(), - ServeWizardChoice::Back => "Back".to_owned(), - } -} - -fn serve_wizard_device_label(device: &str) -> &'static str { - match device { - "gpu" | "gpu_required" | "gpu_preferred" => "GPU required", - "cpu" | "cpu_only" => "CPU mode is not offered", - _ => "GPU required", - } -} - -const fn install_menu_choices() -> &'static [InstallMenuChoice] { - &[ - InstallMenuChoice::Runtime, - InstallMenuChoice::DriverPreview, - InstallMenuChoice::Back, - ] -} - -const fn install_menu_label(choice: InstallMenuChoice) -> &'static str { - match choice { - InstallMenuChoice::Runtime => "Install ROCm", - InstallMenuChoice::DriverPreview => "Check driver setup", - InstallMenuChoice::Back => "Back", - } -} - -const fn install_sdk_choices() -> &'static [InstallSdkChoice] { - &[ - InstallSdkChoice::Channel, - InstallSdkChoice::Folder, - InstallSdkChoice::Install, - InstallSdkChoice::Back, - ] -} - -fn install_sdk_default_selection() -> usize { - install_sdk_choice_index(InstallSdkChoice::Install) -} - -fn install_sdk_choice_index(choice: InstallSdkChoice) -> usize { - install_sdk_choices() - .iter() - .position(|candidate| *candidate == choice) - .unwrap_or(0) -} - -const fn install_sdk_format_label(format: InstallSdkFormat) -> &'static str { - match format { - InstallSdkFormat::Wheel => "Recommended Python install", - InstallSdkFormat::Tarball => "Advanced archive install", - } -} - -fn install_sdk_choice_label( - choice: InstallSdkChoice, - channel: InstallSdkChannel, - folder: &str, -) -> String { - match choice { - InstallSdkChoice::Channel => format!( - "{:<14} {}", - "Channel:", - install_sdk_channel_summary(channel) - ), - InstallSdkChoice::Folder => { - if folder.trim().is_empty() { - "Install folder: choose".to_owned() - } else { - let folder = display_runtime_folder_path(Path::new(folder)); - format!("Install folder: {folder}") - } - } - InstallSdkChoice::Install => "Install ROCm".to_owned(), - InstallSdkChoice::Back => "Back".to_owned(), - } -} - -const fn update_menu_choices() -> &'static [UpdateMenuChoice] { - &[ - UpdateMenuChoice::Refresh, - UpdateMenuChoice::Preview, - UpdateMenuChoice::Apply, - UpdateMenuChoice::ApplyAndUse, - UpdateMenuChoice::Back, - ] -} - -const fn install_sdk_channel_summary(channel: InstallSdkChannel) -> &'static str { - if matches!(channel, InstallSdkChannel::Release) { - "Stable (default)" - } else { - "Nightly (preview)" - } -} - -const fn channel_changed_status() -> &'static str { - "ROCm channel changed. Release is stable, Nightly is preview." -} - -const fn update_menu_label(choice: UpdateMenuChoice) -> &'static str { - match choice { - UpdateMenuChoice::Refresh => "Check for updates", - UpdateMenuChoice::Preview => "Preview update", - UpdateMenuChoice::Apply => "Install update", - UpdateMenuChoice::ApplyAndUse => "Install and use", - UpdateMenuChoice::Back => "Back", - } -} - -const fn config_menu_choices() -> &'static [ConfigMenuChoice] { - &[ - ConfigMenuChoice::Telemetry, - ConfigMenuChoice::PlannerProvider, - ConfigMenuChoice::OpenAiProvider, - ConfigMenuChoice::AnthropicProvider, - ConfigMenuChoice::DefaultEngine, - ConfigMenuChoice::DefaultRuntime, - ConfigMenuChoice::Permissions, - ConfigMenuChoice::ShowSetupAgain, - ConfigMenuChoice::Back, - ] -} - -const fn config_provider_choices(confirming_clear: bool) -> &'static [ConfigProviderChoice] { - if confirming_clear { - &[ - ConfigProviderChoice::CancelClearKey, - ConfigProviderChoice::ConfirmClearKey, - ] - } else { - &[ - ConfigProviderChoice::PromptSending, - ConfigProviderChoice::ApiKey, - ConfigProviderChoice::ClearKey, - ConfigProviderChoice::Back, - ] - } -} - -fn config_manager_selected_index(state: &ConfigManagerState, row_count: usize) -> usize { - let selected = match &state.screen { - ConfigManagerScreen::Main => state.selected, - ConfigManagerScreen::Provider { selected, .. } => *selected, - }; - selected.min(row_count.saturating_sub(1)) -} - -fn config_manager_title(state: &ConfigManagerState) -> String { - match &state.screen { - ConfigManagerScreen::Main => "Settings".to_owned(), - ConfigManagerScreen::Provider { provider, .. } => { - format!("{} Provider", provider_label(provider)) - } - } -} - -fn config_manager_list_items(app: &App) -> Vec> { - let Some(state) = app.config_manager.as_ref() else { - return Vec::new(); - }; - match &state.screen { - ConfigManagerScreen::Main => config_menu_choices() - .iter() - .map(|choice| ListItem::new(config_menu_label(app, *choice))) - .collect(), - ConfigManagerScreen::Provider { - provider, - confirming_clear, - .. - } => config_provider_choices(*confirming_clear) - .iter() - .map(|choice| ListItem::new(config_provider_choice_label(app, provider, *choice))) - .collect(), - } -} - -fn config_menu_label(app: &App, choice: ConfigMenuChoice) -> String { - match choice { - ConfigMenuChoice::Telemetry => format!( - "{:<12} {}", - if app.config.telemetry.local_inspection_enabled() { - "On" - } else { - "Off" - }, - "GPU checks" - ), - ConfigMenuChoice::PlannerProvider => format!( - "{:<12} {}", - app.config.planner_provider.as_deref().unwrap_or("Off"), - "Planning help" - ), - ConfigMenuChoice::OpenAiProvider => format!( - "{:<12} {}", - if app.config.provider_enabled("openai") { - "Enabled" - } else { - "Disabled" - }, - "OpenAI prompts" - ), - ConfigMenuChoice::AnthropicProvider => format!( - "{:<12} {}", - if app.config.provider_enabled("anthropic") { - "Enabled" - } else { - "Disabled" - }, - "Anthropic prompts" - ), - ConfigMenuChoice::DefaultEngine => format!( - "{:<12} {}", - app.config - .default_engine - .as_deref() - .unwrap_or(default_engine_for_platform()), - "Default engine" - ), - ConfigMenuChoice::DefaultRuntime => format!( - "{:<12} {}", - app.config - .active_runtime_key - .as_deref() - .or(app.config.default_runtime_id.as_deref()) - .unwrap_or("Automatic"), - "Default ROCm" - ), - ConfigMenuChoice::Permissions => format!( - "{:<12} {}", - if app.config.permissions.full_access_enabled() { - "Full" - } else { - "Ask" - }, - "Permissions" - ), - ConfigMenuChoice::ShowSetupAgain => { - format!("{:<12} {}", "Reset", "Show setup again") - } - ConfigMenuChoice::Back => "Back".to_owned(), - } -} - -fn config_provider_choice_label(app: &App, provider: &str, choice: ConfigProviderChoice) -> String { - match choice { - ConfigProviderChoice::PromptSending => format!( - "{:<12} {}", - if app.config.provider_enabled(provider) { - "Enabled" - } else { - "Disabled" - }, - "Prompt sending" - ), - ConfigProviderChoice::ApiKey => "Enter or update API key".to_owned(), - ConfigProviderChoice::ClearKey => "Clear saved API key".to_owned(), - ConfigProviderChoice::ConfirmClearKey => "Clear API key".to_owned(), - ConfigProviderChoice::CancelClearKey => "Cancel".to_owned(), - ConfigProviderChoice::Back => "Back".to_owned(), - } -} - -fn provider_label(provider: &str) -> &'static str { - match provider { - "openai" => "OpenAI", - "anthropic" => "Anthropic", - _ => "Provider", - } -} - -const fn provider_options() -> &'static [ProviderOption] { - &[ - ProviderOption { - name: "local", - label: "Local", - description: "Use a managed local model server.", - }, - ProviderOption { - name: "anthropic", - label: "Anthropic", - description: "Use Anthropic after you configure its API key.", - }, - ProviderOption { - name: "openai", - label: "OpenAI", - description: "Use OpenAI after you configure its API key.", - }, - ] -} - -fn provider_index(provider: &str) -> usize { - provider_options() - .iter() - .position(|option| option.name == provider) - .unwrap_or(0) -} - -fn provider_row_label(option: ProviderOption, current: &str) -> String { - let state = if option.name == current { - "Current" - } else { - "Available" - }; - format!("{:<10} {}", state, option.label) -} - -fn service_row_label(record: &ManagedServiceRecord) -> String { - format!( - "{:<11} {:<10} {}", - friendly_service_status(&record.status), - record.engine, - friendly_model_label(&record.model_ref) - ) -} - -fn friendly_service_status(status: &str) -> &'static str { - match status { - "ready" | "running" => "Ready", - "starting" | "recovering" => "Starting", - "stopped" | "exited" => "Stopped", - "failed" | "unreachable" => "Needs help", - _ => "Unknown", - } -} - -fn managed_service_detail_text(record: &ManagedServiceRecord) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Local model server"); - let _ = writeln!(output); - let _ = writeln!(output, "Server id: {}", record.service_id); - let _ = writeln!(output, "Status: {}", record.status); - let _ = writeln!(output, "Engine: {}", record.engine); - let _ = writeln!(output, "Model: {}", record.model_ref); - let _ = writeln!(output, "Address: {}", record.endpoint_url); - if let Some(policy) = record.device_policy.as_deref() { - let _ = writeln!(output, "Device: {}", serve_wizard_device_label(policy)); - } - if let Some(runtime_id) = record.runtime_id.as_deref() { - let _ = writeln!(output, "ROCm install: {runtime_id}"); - } - let _ = writeln!(output, "Log file: {}", record.log_path.display()); - output.trim_end().to_owned() -} - -fn services_manager_actions(state: &ServicesManagerState) -> Vec { - let mut actions = Vec::new(); - if !state.items.is_empty() { - actions.push(ServicesManagerAction::Details); - if services_manager_selected_target_record(state).is_some_and(managed_service_is_chat_ready) - { - actions.push(ServicesManagerAction::Chat); - } - actions.push(ServicesManagerAction::OpenLogs); - actions.push(ServicesManagerAction::Stop); - actions.push(ServicesManagerAction::Restart); - } - actions.push(ServicesManagerAction::Back); - actions -} - -fn services_manager_selected_target_record( - state: &ServicesManagerState, -) -> Option<&ManagedServiceRecord> { - state - .items - .get(state.selected) - .or_else(|| state.items.get(state.last_item_selected)) -} - -fn services_manager_choice_count(state: &ServicesManagerState) -> usize { - state.items.len() + services_manager_actions(state).len() -} - -const fn services_manager_action_label(action: ServicesManagerAction) -> &'static str { - match action { - ServicesManagerAction::Details => "Service details", - ServicesManagerAction::Chat => "Chat with selected server", - ServicesManagerAction::OpenLogs => "Show server logs", - ServicesManagerAction::Stop => "Stop selected server", - ServicesManagerAction::Restart => "Restart selected server", - ServicesManagerAction::Back => "Back", - } -} - -fn selected_automation_manager_row_key( - state: &AutomationsManagerState, -) -> Option { - match state.rows.get(state.selected)? { - AutomationManagerRow::Review(index) => { - let proposal = state.items.get(*index)?; - Some(AutomationManagerRowKey::Review( - proposal.proposal.proposal_id.clone(), - )) - } - AutomationManagerRow::PrefetchField(index, field) => { - let proposal = state.items.get(*index)?; - Some(AutomationManagerRowKey::PrefetchField( - proposal.proposal.proposal_id.clone(), - *field, - )) - } - AutomationManagerRow::Watcher(index) => { - let watcher = state.watchers.get(*index)?; - Some(AutomationManagerRowKey::Watcher(watcher.id.clone())) - } - } -} - -fn automation_manager_row_index( - items: &[AutomationReviewItem], - watchers: &[AutomationWatcherItem], - rows: &[AutomationManagerRow], - key: &AutomationManagerRowKey, -) -> Option { - rows.iter().position(|row| match (row, key) { - (AutomationManagerRow::Review(index), AutomationManagerRowKey::Review(id)) => items - .get(*index) - .is_some_and(|item| item.proposal.proposal_id == *id), - ( - AutomationManagerRow::PrefetchField(index, field), - AutomationManagerRowKey::PrefetchField(id, key_field), - ) => { - field == key_field - && items - .get(*index) - .is_some_and(|item| item.proposal.proposal_id == *id) - } - (AutomationManagerRow::Watcher(index), AutomationManagerRowKey::Watcher(id)) => watchers - .get(*index) - .is_some_and(|watcher| watcher.id == *id), - _ => false, - }) -} - -fn selected_automation_manager_row( - state: &AutomationsManagerState, -) -> Option { - match state.rows.get(state.selected)? { - AutomationManagerRow::Review(index) => state - .items - .get(*index) - .map(|item| AutomationManagerSelectedRow::Review(item.proposal.clone())), - AutomationManagerRow::PrefetchField(index, field) => state - .items - .get(*index) - .map(|item| AutomationManagerSelectedRow::PrefetchField(item.proposal.clone(), *field)), - AutomationManagerRow::Watcher(index) => state - .watchers - .get(*index) - .cloned() - .map(AutomationManagerSelectedRow::Watcher), - } -} - -fn select_automation_manager_row_by_key( - state: &mut AutomationsManagerState, - key: &AutomationManagerRowKey, -) -> bool { - state.rows = automation_manager_rows(&state.items, &state.watchers); - if let Some(index) = - automation_manager_row_index(&state.items, &state.watchers, &state.rows, key) - { - state.selected = index; - true - } else { - state.selected = state.selected.min(state.rows.len().saturating_sub(1)); - false - } -} - -fn automation_manager_rows( - items: &[AutomationReviewItem], - watchers: &[AutomationWatcherItem], -) -> Vec { - let mut rows = Vec::new(); - for (index, item) in items.iter().enumerate() { - if item.proposal.status != "pending" { - continue; - } - rows.push(AutomationManagerRow::Review(index)); - if is_pending_prefetch_proposal(&item.proposal) { - rows.extend( - automation_prefetch_fields() - .iter() - .copied() - .map(|field| AutomationManagerRow::PrefetchField(index, field)), - ); - } - } - let watcher_rows = watchers - .iter() - .enumerate() - .map(|(index, _)| AutomationManagerRow::Watcher(index)); - rows.extend(watcher_rows); - rows.extend( - items - .iter() - .enumerate() - .filter(|(_, item)| item.proposal.status != "pending") - .map(|(index, _)| AutomationManagerRow::Review(index)), - ); - rows -} - -fn automation_watcher_items(config: &RocmCliConfig) -> Vec { - builtin_watchers() - .iter() - .map(|watcher| AutomationWatcherItem { - id: watcher.id.to_owned(), - name: automation_watcher_plain_name(watcher.id).to_owned(), - enabled: config.watcher_enabled(watcher), - mode: config.effective_watcher_mode(watcher), - summary: automation_watcher_plain_action(watcher.id).to_owned(), - trigger: automation_watcher_plain_trigger(watcher.id).to_owned(), - }) - .collect() -} - -fn automation_manager_row_label( - state: &AutomationsManagerState, - row: AutomationManagerRow, -) -> String { - match row { - AutomationManagerRow::Review(index) => { - let Some(item) = state.items.get(index) else { - return "Review request".to_owned(); - }; - let status = proposal_status_for_user(&item.proposal.status); - let title = if item.proposal.title.trim().is_empty() { - item.proposal.proposal_id.as_str() - } else { - item.proposal.title.as_str() - }; - format!("{status:<18} {title}") - } - AutomationManagerRow::PrefetchField(index, field) => { - let Some(item) = state.items.get(index) else { - return " Download setting".to_owned(); - }; - automation_prefetch_field_row_label(&item.proposal, field) - } - AutomationManagerRow::Watcher(index) => { - let Some(watcher) = state.watchers.get(index) else { - return "Automation check".to_owned(); - }; - let status = if watcher.enabled { "On" } else { "Off" }; - format!("{:<18} {}", status, watcher.name) - } - } -} - -const fn automation_prefetch_fields() -> &'static [AutomationPrefetchField] { - &[ - AutomationPrefetchField::AllowDownload, - AutomationPrefetchField::MaxBytes, - AutomationPrefetchField::AllowHuggingFace, - ] -} - -fn automation_prefetch_field_from_edit_name(field: &str) -> Option { - match field { - "allow-download" | "allow_artifact_download" | "allow-artifact-download" => { - Some(AutomationPrefetchField::AllowDownload) - } - "artifact-max-bytes" | "artifact_max_bytes" => Some(AutomationPrefetchField::MaxBytes), - "allow-huggingface-download" | "allow_huggingface_download" => { - Some(AutomationPrefetchField::AllowHuggingFace) - } - _ => None, - } -} - -fn proposal_edit_guidance_message( - proposal: &AutomationProposalRecord, - field: Option<&str>, -) -> String { - if let Some(prefetch_field) = field.and_then(automation_prefetch_field_from_edit_name) - && is_pending_prefetch_proposal(proposal) - { - let mut output = render_prefetch_field_detail(proposal, prefetch_field); - let _ = writeln!(output); - let _ = writeln!(output); - let _ = writeln!( - output, - "Nothing changed yet. Use the highlighted row and press Enter to change this setting." - ); - return output.trim_end().to_owned(); - } - - let mut output = String::new(); - let _ = writeln!(output, "Review request opened."); - let _ = writeln!(output); - if proposal.status != "pending" { - let _ = writeln!( - output, - "This request is already {}.", - proposal_status_for_user(&proposal.status) - ); - } else if let Some(field) = field { - let _ = writeln!(output, "Nothing changed yet. `{field}` was not changed."); - let _ = writeln!(output); - let _ = writeln!( - output, - "Use the visible rows on this screen to review or change request settings." - ); - } else { - let _ = writeln!( - output, - "Use the visible rows on this screen to review or change request settings." - ); - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Up/Down chooses a row."); - let _ = writeln!( - output, - " Enter opens the selected review or editable setting." - ); - let _ = writeln!(output, " Esc goes back."); - output.trim_end().to_owned() -} - -fn is_pending_prefetch_proposal(proposal: &AutomationProposalRecord) -> bool { - proposal.status == "pending" && proposal_tool_name(proposal) == Some("prefetch_artifact") -} - -fn automation_prefetch_field_row_label( - proposal: &AutomationProposalRecord, - field: AutomationPrefetchField, -) -> String { - match field { - AutomationPrefetchField::AllowDownload => format!( - " {:<16} {}", - "Allow download", - yes_no_label(proposal_bool_argument(proposal, "allow_artifact_download")) - ), - AutomationPrefetchField::MaxBytes => format!( - " {:<16} {}", - "Max download", - proposal_u64_argument(proposal, "artifact_max_bytes") - .map_or_else(|| "not set".to_owned(), format_bytes_for_tui) - ), - AutomationPrefetchField::AllowHuggingFace => format!( - " {:<16} {}", - "Hugging Face", - yes_no_label(proposal_bool_argument( - proposal, - "allow_huggingface_download", - )) - ), - } -} - -const fn yes_no_label(value: bool) -> &'static str { - if value { "yes" } else { "no" } -} - -const fn automation_prefetch_field_status(field: AutomationPrefetchField) -> &'static str { - match field { - AutomationPrefetchField::AllowDownload => { - "Download setting selected. Press Enter or Left/Right to change it." - } - AutomationPrefetchField::MaxBytes => { - "Max download selected. Press Enter to type bytes, or Left/Right for presets." - } - AutomationPrefetchField::AllowHuggingFace => { - "Hugging Face setting selected. Press Enter or Left/Right to change it." - } - } -} - -const fn automation_prefetch_field_saved_message(field: AutomationPrefetchField) -> &'static str { - match field { - AutomationPrefetchField::AllowDownload => "Download permission updated.", - AutomationPrefetchField::MaxBytes => "Max download updated.", - AutomationPrefetchField::AllowHuggingFace => "Hugging Face permission updated.", - } -} - -fn cycle_prefetch_max_bytes_value(current: Option, direction: CompletionDirection) -> u64 { - const PRESETS: &[u64] = &[1_073_741_824, 2_147_483_648, 5_368_709_120, 10_737_418_240]; - let current_index = - current.and_then(|value| PRESETS.iter().position(|preset| *preset == value)); - match (direction, current_index) { - (CompletionDirection::Next, Some(index)) => PRESETS[(index + 1) % PRESETS.len()], - (CompletionDirection::Previous, Some(0)) => *PRESETS.last().expect("presets are nonempty"), - (CompletionDirection::Previous, Some(index)) => PRESETS[index - 1], - (CompletionDirection::Next, None) => PRESETS[0], - (CompletionDirection::Previous, None) => *PRESETS.last().expect("presets are nonempty"), - } -} - -fn render_prefetch_field_detail( - proposal: &AutomationProposalRecord, - selected_field: AutomationPrefetchField, -) -> String { - let mut output = render_proposal_approval(proposal); - let _ = writeln!(output); - let _ = writeln!(output, "Selected download setting"); - match selected_field { - AutomationPrefetchField::AllowDownload => { - let _ = writeln!( - output, - " Allow download: {}", - yes_no_label(proposal_bool_argument(proposal, "allow_artifact_download")) - ); - let _ = writeln!( - output, - " Enter or Left/Right changes whether rocm-cli may download this file." - ); - } - AutomationPrefetchField::MaxBytes => { - let _ = writeln!( - output, - " Max download: {}", - proposal_u64_argument(proposal, "artifact_max_bytes") - .map_or_else(|| "not set".to_owned(), format_bytes_for_tui) - ); - let _ = writeln!( - output, - " Enter types a custom limit. Left/Right chooses common limits." - ); - } - AutomationPrefetchField::AllowHuggingFace => { - let _ = writeln!( - output, - " Hugging Face token: {}", - yes_no_label(proposal_bool_argument( - proposal, - "allow_huggingface_download", - )) - ); - let _ = writeln!(output, " This is only for gated Hugging Face HTTPS files."); - } - } - output.trim_end().to_owned() -} - -const fn automation_watcher_mode_plain_label(mode: WatcherMode) -> &'static str { - match mode { - WatcherMode::Observe => "record only", - WatcherMode::Propose => "ask before taking action", - WatcherMode::Contained => "ask before changes; keep actions limited", - } -} - -fn parse_watcher_mode(value: &str) -> Option { - match value { - "observe" => Some(WatcherMode::Observe), - "propose" => Some(WatcherMode::Propose), - "contained" => Some(WatcherMode::Contained), - _ => None, - } -} - -fn automation_watcher_plain_name(watcher_id: &str) -> &'static str { - match watcher_id { - "therock-update" => "ROCm update checks", - "server-recover" => "Server recovery", - "gpu-metrics" => "GPU status checks", - "gpu-thermal-protect" => "GPU pressure protection", - "cache-warm" => "Model file preparation", - "driver-upgrade" => "Driver update review", - _ => "Automation check", - } -} - -fn automation_watcher_plain_trigger(watcher_id: &str) -> &'static str { - match watcher_id { - "therock-update" => "a scheduled check", - "server-recover" => "a server that stops or becomes unhealthy", - "gpu-metrics" => "local GPU status updates", - "gpu-thermal-protect" => "high GPU temperature or memory pressure", - "cache-warm" => "a request to prepare a model file", - "driver-upgrade" => "a driver update signal", - _ => "local automation activity", - } -} - -fn automation_watcher_plain_action(watcher_id: &str) -> &'static str { - match watcher_id { - "therock-update" => "checks for ROCm updates without installing them", - "server-recover" => "asks before restarting a managed server", - "gpu-metrics" => "records local GPU status only", - "gpu-thermal-protect" => "asks before stopping a managed server", - "cache-warm" => "asks before preparing or downloading a model file", - "driver-upgrade" => "shows a driver plan only", - _ => "records the event", - } -} - -fn automation_watcher_policy_note(watcher_id: &str) -> Option<&'static str> { - match watcher_id { - "gpu-metrics" => { - Some("This is read-only. It records status and does not change services or files.") - } - "gpu-thermal-protect" => { - Some("This asks before stopping a server. It never stops one by itself.") - } - "cache-warm" => Some("This asks before preparing or downloading a model file."), - "driver-upgrade" => Some("This shows a driver plan only. It does not install drivers."), - _ => None, - } -} - -fn config_manager_detail_text(app: &App) -> String { - let Some(state) = app.config_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - if let ConfigManagerScreen::Provider { - provider, - confirming_clear: true, - .. - } = &state.screen - { - return config_provider_clear_confirmation_text(app, provider); - } - if let ConfigManagerScreen::Provider { provider, .. } = &state.screen { - return config_provider_detail_text(app, provider); - } - match config_menu_choices() - .get(state.selected) - .copied() - .unwrap_or(ConfigMenuChoice::Telemetry) - { - ConfigMenuChoice::Telemetry => { - let mode = if app.config.telemetry.local_inspection_enabled() { - "on" - } else { - "off" - }; - [ - "GPU checks", - "", - &format!("Current: {mode}"), - "", - "When this is on, rocm-cli reads local AMD GPU status for examine and model-fit screens.", - "No cloud service is used.", - "", - "Press Enter to toggle this.", - ] - .join("\n") - } - ConfigMenuChoice::PlannerProvider => { - let current = app.config.planner_provider.as_deref().unwrap_or("off"); - [ - "Planning help", - "", - &format!("Current: {current}"), - "", - "This only helps interpret fuzzy requests. rocm-cli still shows the command before running anything.", - "", - "Press Enter to cycle: off, local, OpenAI, Anthropic.", - ] - .join("\n") - } - ConfigMenuChoice::OpenAiProvider => provider_prompt_detail(app, "openai", "OpenAI"), - ConfigMenuChoice::AnthropicProvider => { - provider_prompt_detail(app, "anthropic", "Anthropic") - } - ConfigMenuChoice::DefaultEngine => [ - "Default engine", - "", - &format!( - "Current: {}", - app.config - .default_engine - .as_deref() - .unwrap_or(default_engine_for_platform()) - ), - "", - "This is the engine rocm-cli picks first for new local model runs.", - "", - "Press Enter to open the Engines screen.", - ] - .join("\n"), - ConfigMenuChoice::DefaultRuntime => [ - "Default ROCm install", - "", - &format!( - "Current: {}", - app.config - .active_runtime_key - .as_deref() - .or(app.config.default_runtime_id.as_deref()) - .unwrap_or("automatic") - ), - "", - "New engines use this ROCm install. Running services keep their current runtime until restarted.", - "", - "Press Enter to open the ROCm installs screen.", - ] - .join("\n"), - ConfigMenuChoice::Permissions => [ - "Permissions", - "", - &format!( - "Current: {}", - if app.config.permissions.full_access_enabled() { - "full access" - } else { - "ask before changes" - } - ), - "", - "Full access lets reviewed actions run without asking each time.", - "", - "Press Enter to open permissions.", - ] - .join("\n"), - ConfigMenuChoice::ShowSetupAgain => [ - "Show setup again", - "", - "Use this when you want ROCm CLI to show the setup flow again.", - "", - "This does not uninstall ROCm.", - "Installed ROCm folders stay where they are.", - "API keys stay saved.", - "Provider settings stay saved.", - "", - "Press Enter to review this reset.", - ] - .join("\n"), - ConfigMenuChoice::Back => "Return to the main screen.".to_owned(), - } -} - -fn provider_prompt_detail(app: &App, provider: &str, label: &str) -> String { - let enabled = app.config.provider_enabled(provider); - [ - format!("{label} prompt sending"), - String::new(), - format!("Current: {}", if enabled { "enabled" } else { "disabled" }), - String::new(), - "This controls whether planning or chat may send prompts to this provider.".to_owned(), - "API keys are saved separately.".to_owned(), - String::new(), - "Press Enter to toggle prompt sending.".to_owned(), - ] - .join("\n") -} - -fn config_provider_detail_text(app: &App, provider: &str) -> String { - let label = provider_label(provider); - let enabled = app.config.provider_enabled(provider); - let key_status = providers::provider_key_status_text(provider).unwrap_or_else(|error| { - format!("key storage unavailable; unlock your OS password manager and try again ({error})") - }); - [ - format!("{label} provider"), - String::new(), - format!( - "Prompt sending: {}", - if enabled { "enabled" } else { "disabled" } - ), - format!("API key: {key_status}"), - String::new(), - "Prompt sending lets planning and chat send text to this provider.".to_owned(), - "The API key is saved in secure storage, not in the transcript.".to_owned(), - String::new(), - "Use the rows on the left to change this provider.".to_owned(), - "Press Esc or choose Back to return to Settings.".to_owned(), - ] - .join("\n") -} - -fn config_provider_clear_confirmation_text(app: &App, provider: &str) -> String { - let label = provider_label(provider); - let selected = app.selected_config_provider_choice(); - [ - format!("Clear {label} API key?"), - String::new(), - "This removes the saved key from secure storage.".to_owned(), - "Prompt sending can stay enabled, but this provider will not work until a key is saved again.".to_owned(), - String::new(), - format!( - "Selected: {}", - match selected { - ConfigProviderChoice::ConfirmClearKey => "clear the saved key", - ConfigProviderChoice::CancelClearKey => "cancel and keep the key", - _ => "choose an option", - } - ), - String::new(), - "Use Up/Down to choose. Press Enter to select. Press Esc to cancel.".to_owned(), - ] - .join("\n") -} - -fn provider_key_failure_text(provider: &str, action: &str, error: &anyhow::Error) -> String { - let label = provider_label(provider); - let env_name = provider_api_key_env_name(provider).unwrap_or("the provider API key env var"); - let action_past = match action { - "save" => "saved", - "clear" => "cleared", - _ => "changed", - }; - [ - format!("{label} API key was not {action_past}."), - String::new(), - "Try again after unlocking your operating system's password manager or credential store." - .to_owned(), - format!("For a temporary session, set {env_name} before starting rocm-cli."), - String::new(), - format!("Details: {error}"), - ] - .join("\n") -} - -fn provider_api_key_env_name(provider: &str) -> Option<&'static str> { - match provider { - "openai" => Some("OPENAI_API_KEY"), - "anthropic" => Some("ANTHROPIC_API_KEY"), - _ => None, - } -} - -fn command_screen_action_label(action: CommandScreenAction) -> &'static str { - match action { - CommandScreenAction::RunPlan => "Run", - CommandScreenAction::EditPlan => "Edit request", - CommandScreenAction::SendChat => "Send message", - CommandScreenAction::EditChat => "Edit prompt", - CommandScreenAction::ViewChatConnection => "Server info", - CommandScreenAction::ViewChatResult => "ROCm result", - CommandScreenAction::EnableFullAccess => "Allow full access", - CommandScreenAction::ResetPermissions => "Ask before changes", - CommandScreenAction::OpenCommand(command) => help_command_label(command), - CommandScreenAction::Back => "Back", - } -} - -const fn overlay_action_from_command_screen_action( - action: CommandScreenAction, -) -> Option { - match action { - CommandScreenAction::OpenCommand(command) => Some(OverlayCardAction::OpenCommand(command)), - CommandScreenAction::Back => Some(OverlayCardAction::Close), - _ => None, - } -} - -fn help_topic_actions() -> Vec { - [ - HelpTopic::GettingStarted, - HelpTopic::TuiBasics, - HelpTopic::Setup, - HelpTopic::LocalModels, - HelpTopic::Assistant, - HelpTopic::ComfyUi, - HelpTopic::Commands, - HelpTopic::KeyboardShortcuts, - HelpTopic::Troubleshooting, - ] - .into_iter() - .map(OverlayCardAction::HelpTopic) - .collect() -} - -fn overlay_card_key_hint(app: &App) -> &'static str { - let Some(state) = app.overlay_card.as_ref() else { - return "Up/Down choose | Enter select | Esc close"; - }; - if state.actions.iter().any(|action| { - matches!( - action, - OverlayCardAction::ClearTranscript - | OverlayCardAction::ClearProviderKey - | OverlayCardAction::ShowSetupAgain - | OverlayCardAction::StopChatAssistant - | OverlayCardAction::Quit - ) - }) { - "Up/Down choose | Enter select | Y confirm | N/Esc close" - } else if state - .actions - .iter() - .any(|action| matches!(action, OverlayCardAction::HelpTopic(_))) - { - "Up/Down topic | PageUp/PageDown or wheel scroll | Esc close" - } else if state.actions.is_empty() { - "PageUp/PageDown scroll | Esc close" - } else { - "Up/Down choose | Enter open | PageUp/PageDown scroll | Esc close" - } -} - -fn overlay_card_action_label(state: &OverlayCardState, action: OverlayCardAction) -> &'static str { - match action { - OverlayCardAction::OpenCommand(command) => help_command_label(command), - OverlayCardAction::HelpTopic(topic) => help_topic_label(topic), - OverlayCardAction::ClearTranscript => "Clear transcript", - OverlayCardAction::ClearProviderKey => "Clear API key", - OverlayCardAction::ShowSetupAgain => "Show setup again", - OverlayCardAction::StopChatAssistant => "Stop assistant", - OverlayCardAction::Quit => "Quit ROCm CLI", - OverlayCardAction::Close if state.title == "Clear" => "Keep transcript", - OverlayCardAction::Close if state.title == "Quit" => "Stay in ROCm CLI", - OverlayCardAction::Close if state.title == "Stop Assistant" => "Keep chatting", - OverlayCardAction::Close if state.title == "Clear API Key" => "Keep key", - OverlayCardAction::Close if state.title == "First-time Setup" => "Keep settings", - OverlayCardAction::Close if state.title == "Command Not Found" => "Stay here", - OverlayCardAction::Close if state.title == "Command Needs Fix" => "Stay here", - OverlayCardAction::Close if state.title == "Help" => "Close help", - OverlayCardAction::Close => "Back", - } -} - -fn overlay_card_detail_text(state: &OverlayCardState) -> String { - match state.actions.get(state.selected).copied() { - Some(OverlayCardAction::OpenCommand(command)) => help_command_detail(command), - Some(OverlayCardAction::HelpTopic(topic)) => help_topic_detail(topic), - _ => state.detail.clone(), - } -} - -const fn help_topic_label(topic: HelpTopic) -> &'static str { - match topic { - HelpTopic::GettingStarted => "Start here", - HelpTopic::TuiBasics => "Using the TUI", - HelpTopic::Setup => "ROCm setup", - HelpTopic::LocalModels => "Local models", - HelpTopic::Assistant => "Assistant", - HelpTopic::ComfyUi => "ComfyUI", - HelpTopic::Commands => "CLI commands", - HelpTopic::KeyboardShortcuts => "Keyboard shortcuts", - HelpTopic::Troubleshooting => "Troubleshooting", - } -} - -fn help_topic_detail(topic: HelpTopic) -> String { - match topic { - HelpTopic::GettingStarted => [ - "Start here", - "", - "ROCm CLI helps set up AMD GPU software for local AI.", - "", - "Normal first run:", - "1. Choose Set up ROCm.", - "2. Pick the folder where ROCm/TheRock should live.", - "3. Review the install card.", - "4. Approve it and watch the live install log.", - "5. Continue into ROCm CLI when setup finishes.", - "", - "After setup:", - "- Start a local model.", - "- Chat with the assistant.", - "- Open ComfyUI.", - "- Check this computer with Examine.", - "", - "ROCm CLI keeps settings in ~/.rocm.", - "The pip cache stays inside the ROCm install folder you choose.", - ] - .join("\n"), - HelpTopic::TuiBasics => [ - "Using the TUI", - "", - "Use the arrow keys to move through menus.", - "Press Enter to open the highlighted row.", - "Press Esc once to go back. From the main menu it opens Quit.", - "Press / on any normal menu to open a command popup.", - "The command popup is disabled during setup so setup stays simple.", - "", - "Mouse basics:", - "- Click a menu row to open it.", - "- Use the mouse wheel to scroll long text and logs.", - "", - "Logs are only shown while work is running or in a foreground log card.", - "Use PageUp/PageDown or the mouse wheel inside log cards.", - "Most screens keep details short so the app stays readable.", - "", - "When a command changes your computer, ROCm CLI shows a review card first.", - "Read-only checks do not need approval.", - ] - .join("\n"), - HelpTopic::Setup => [ - "ROCm setup", - "", - "Setup installs TheRock ROCm wheels into a Python folder managed by ROCm CLI.", - "", - "In the TUI:", - "- Choose Set up ROCm.", - "- Pick an install folder with the folder picker.", - "- Approve the install card.", - "- Live pip output appears only while installation is running.", - "", - "From a terminal:", - " rocm setup reset", - " rocm install sdk --channel release --format wheel --prefix D:\\ROCm\\therock_venvs", - " rocm runtimes list", - " rocm runtimes activate ", - "", - "Use a full path for --prefix.", - "On Linux or WSL, use a Linux path such as /home/user/rocm_venvs.", - ] - .join("\n"), - HelpTopic::LocalModels => [ - "Local models", - "", - "The default model runner is Lemonade.", - "Start a model only after ROCm setup is ready.", - "", - "In the TUI:", - "- Choose Start a local model.", - "- Review the model, engine, device, address, and ROCm install.", - "- Approve the start request.", - "- After it starts, choose Chat with assistant.", - "", - "From a terminal:", - " rocm serve qwen --engine lemonade --device gpu_required --managed", - " rocm services list", - " rocm services list --all", - " rocm services stop ", - "", - "The Services screen lists running or starting servers.", - "Use services list --all when you need old failed or stopped records.", - ] - .join("\n"), - HelpTopic::Assistant => [ - "Assistant", - "", - "The assistant is for ROCm setup, local models, ComfyUI, and basic troubleshooting.", - "", - "Good prompts:", - "- Is ROCm installed?", - "- Where is ROCm installed?", - "- What GPU is on this machine?", - "- What models can this machine support?", - "- Set up ComfyUI.", - "", - "If the assistant needs to change something, it shows a review card first.", - "For questions that do not need ROCm commands, it should answer naturally.", - "", - "From a terminal:", - " rocm chat --tools --provider local", - ] - .join("\n"), - HelpTopic::ComfyUi => [ - "ComfyUI", - "", - "ComfyUI is installed into ROCm CLI's managed app folder.", - "Starting ComfyUI shows the local URL to open.", - "", - "In the TUI:", - "- Choose Open ComfyUI.", - "- If it is not installed, choose Install ComfyUI.", - "- If it is installed, choose Start ComfyUI.", - "- When it runs, ROCm CLI shows the URL and models folder.", - "", - "From a terminal:", - " rocm comfyui install", - " rocm comfyui start", - " rocm comfyui models-path", - " rocm comfyui logs", - "", - "Download ComfyUI model files yourself and put them in the models path.", - ] - .join("\n"), - HelpTopic::Commands => [ - "CLI commands", - "", - "Common commands:", - " rocm", - " rocm examine", - " rocm setup reset", - " rocm install sdk --channel release --format wheel --prefix ", - " rocm runtimes list", - " rocm runtimes activate ", - " rocm engine list", - " rocm serve qwen --engine lemonade --device gpu_required --managed", - " rocm services list", - " rocm services list --all", - " rocm comfyui install", - " rocm comfyui start", - " rocm comfyui models-path", - "", - "Inside the TUI, slash commands still work:", - " /setup", - " /examine", - " /engine", - " /serve", - " /comfyui", - " /comfyui logs", - " /services", - " /permissions", - "", - "Press / on a normal menu to open a command popup with suggestions.", - "Use /help for the full command picker.", - ] - .join("\n"), - HelpTopic::KeyboardShortcuts => [ - "Keyboard shortcuts", - "", - "Everywhere:", - " F1 open this help", - " F5 refresh the current screen", - " Esc go back or close the current card", - " Ctrl-C quit or ask to quit safely", - "", - "Menus:", - " Up/Down move selection", - " Enter open or choose", - " Tab next item when available", - " / open command popup", - "", - "Long details and logs:", - " PageUp/PageDown scroll", - " Mouse wheel scroll", - " Home/End jump to top or bottom where supported", - "", - "Text boxes:", - " Left/Right move cursor", - " Ctrl-A/Ctrl-E move to start/end", - " Ctrl-U clear input", - " Ctrl-W delete previous word", - "", - "Review cards:", - " Enter or Y approve", - " N or Esc cancel", - ] - .join("\n"), - HelpTopic::Troubleshooting => [ - "Troubleshooting", - "", - "If setup did not start:", - "- Make sure you chose a full folder path.", - "- Open the install log from the setup screen.", - "- Use F5 to refresh setup status.", - "", - "If GPU checks fail:", - "- Run rocm examine.", - "- On Windows, make sure the AMD driver is installed.", - "- On WSL, run from the Linux filesystem when possible, not /mnt.", - "", - "If a local model does not start:", - "- Check that a ROCm runtime is active.", - "- Open Services and inspect the running server.", - "- Use rocm services list --all for failed records.", - "", - "If ComfyUI starts but you do not see models:", - "- Run rocm comfyui models-path.", - "- Put model files in that folder.", - ] - .join("\n"), - } -} - -fn comfyui_command_screen_actions(installed: bool, running: bool) -> Vec { - let commands = if installed { - if running { - vec!["comfyui models-path"] - } else { - vec!["comfyui start", "comfyui models-path"] - } - } else { - vec!["comfyui install"] - }; - commands - .into_iter() - .map(CommandScreenAction::OpenCommand) - .chain(std::iter::once(CommandScreenAction::Back)) - .collect() -} - -fn help_command_screen_actions() -> Vec { - [ - "home", - "setup", - "examine", - "install", - "runtimes", - "engine", - "comfyui", - "model", - "serve", - "plan", - "services", - "logs", - "automations", - "reviews", - "approve", - "reject", - "edit", - "config", - "permissions", - "gpu", - "update", - "provider", - "chat", - "clear", - "uninstall", - "daemon", - "quit", - ] - .into_iter() - .map(CommandScreenAction::OpenCommand) - .chain(std::iter::once(CommandScreenAction::Back)) - .collect() -} - -fn help_command_label(command: &str) -> &'static str { - match command { - "home" => "Home dashboard", - "help" => "Command list", - "setup" => "Set up ROCm", - "examine" => "Check this computer", - "install" => "Install ROCm", - "runtimes" => "Choose ROCm install", - "engine" => "Choose engine", - "comfyui" => "ComfyUI", - "comfyui install" => "Install ComfyUI", - "comfyui start" => "Start ComfyUI", - "comfyui models-path" => "Show models path", - "comfyui logs" => "Open ComfyUI logs", - "comfyui log-files" => "Show ComfyUI log files", - "model" => "Choose model", - "serve" => "Start a local model", - "plan" => "Plan a request", - "services" => "List currently running models", - "logs" => "Recent logs", - "automations" => "Automations", - "reviews" => "Review requests", - "approve" => "Approve request", - "reject" => "Reject request", - "edit" => "Edit request", - "config" => "Settings", - "permissions" => "Permissions", - "gpu" => "GPU status", - "update" => "Updates", - "provider" => "Chat source", - "chat" => "Chat status", - "clear" => "Clear transcript", - "uninstall" => "Uninstall preview", - "uninstall apply" => "Review uninstall", - "daemon" => "Background helper", - "quit" | "exit" => "Quit", - _ => "Command", - } -} - -fn help_command_detail(command: &str) -> String { - let (title, body) = match command { - "home" => ( - "Home dashboard", - "Go back to the main dashboard. It shows setup status, local models, and the next useful actions.", - ), - "help" => ( - "Command list", - "Open the command menu and choose a command with the arrow keys.", - ), - "setup" => ( - "Set up ROCm", - "Install ROCm into a Python folder managed by rocm-cli. This is the first screen new users see.", - ), - "examine" => ( - "Check this computer", - "Inspect GPU detection, driver state, ROCm installs, engines, WSL, and folders.", - ), - "install" => ( - "Install ROCm", - "Install the TheRock ROCm package, or preview driver setup checks.", - ), - "runtimes" => ( - "Choose ROCm install", - "View installed ROCm packages, choose the default install, add an existing Python folder, or remove an install.", - ), - "engine" => ( - "Choose engine", - "Choose or install local model engines such as PyTorch or llama.cpp.", - ), - "comfyui" => ( - "ComfyUI", - "Install ComfyUI into ROCm CLI's app folder, start it, and open the local URL.", - ), - "comfyui install" => ( - "Install ComfyUI", - "Download ComfyUI and add the packages it needs while keeping ROCm's GPU packages in place.", - ), - "comfyui start" => ( - "Start ComfyUI", - "Start ComfyUI on this computer, open the browser when possible, and show the local URL.", - ), - "comfyui models-path" => ( - "Show models path", - "Print the folder where ComfyUI looks for downloaded models.", - ), - "comfyui logs" => ( - "Open ComfyUI logs", - "Read the latest ComfyUI install and run output here without copying a file path.", - ), - "comfyui log-files" => ( - "Show ComfyUI log files", - "Show where ComfyUI saved its install and run logs.", - ), - "model" => ( - "Choose model", - "Pick a built-in model recipe, then review how it will run locally.", - ), - "serve" => ( - "Start a local model", - "Open the guided local model setup. GPU ROCm support is required.", - ), - "plan" => ( - "Plan a request", - "Review what rocm-cli would do for a plain English request before running anything.", - ), - "services" => ( - "List currently running models", - "View local model servers, open logs, stop a server, or restart it.", - ), - "logs" => ( - "Recent logs", - "Browse recent setup, command, service, and automation logs.", - ), - "automations" => ( - "Automations", - "Review proposed background actions and choose which checks may run.", - ), - "reviews" => ( - "Review requests", - "Open proposed actions that need a decision. Approve, reject, and edit actions are available from that screen.", - ), - "approve" => ( - "Approve request", - "Open review requests and approve the selected pending request after reviewing it.", - ), - "reject" => ( - "Reject request", - "Open review requests and reject the selected pending request after reviewing it.", - ), - "edit" => ( - "Edit request", - "Open review request editing from the Automations screen.", - ), - "config" => ( - "Settings", - "Change saved settings such as telemetry, provider keys, default engine, default ROCm install, and permissions.", - ), - "permissions" => ( - "Permissions", - "Choose whether rocm-cli asks before local changes or can run setup commands without asking each time.", - ), - "gpu" => ( - "GPU status", - "Show the latest local GPU status when local checks are enabled.", - ), - "update" => ( - "Updates", - "Check ROCm update status, preview an update, or review applying it.", - ), - "provider" => ( - "Chat source", - "Choose whether chat uses a local model here or a remote provider for this session.", - ), - "chat" => ( - "Chat status", - "Check whether the selected chat provider is ready.", - ), - "clear" => ( - "Clear transcript", - "Open a confirmation screen before clearing the visible transcript.", - ), - "uninstall" => ( - "Uninstall preview", - "Review what rocm-cli would remove before approving any uninstall.", - ), - "daemon" => ( - "Background helper", - "Show the background helper policy and state.", - ), - "quit" | "exit" => ( - "Quit", - "Use /quit or /exit to open a confirmation screen before leaving the app.", - ), - _ => ("Command", "Open an existing rocm-cli screen."), - }; - format!("{title}\n\n{body}\n\nPress Enter to open this screen.") -} - -const fn logs_view_actions() -> &'static [LogsViewAction] { - &[ - LogsViewAction::PreviousPage, - LogsViewAction::NextPage, - LogsViewAction::Search, - LogsViewAction::ClearSearch, - LogsViewAction::ToggleFollow, - LogsViewAction::ToggleFileLocations, - LogsViewAction::Back, - ] -} - -const fn logs_view_action_label( - action: LogsViewAction, - follow: bool, - show_file_locations: bool, -) -> &'static str { - match action { - LogsViewAction::PreviousPage => "Previous page", - LogsViewAction::NextPage => "Next page", - LogsViewAction::Search => "Search logs", - LogsViewAction::ClearSearch => "Clear search", - LogsViewAction::ToggleFollow if follow => "Stop following", - LogsViewAction::ToggleFollow => "Follow new logs", - LogsViewAction::ToggleFileLocations if show_file_locations => "Hide file locations", - LogsViewAction::ToggleFileLocations => "Show file locations", - LogsViewAction::Back => "Back", - } -} - -fn provider_manager_detail_text(app: &App) -> String { - let Some(state) = app.provider_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - let option = provider_options() - .get(state.selected) - .unwrap_or(&provider_options()[0]); - let mut output = String::new(); - let _ = writeln!(output, "Provider"); - let _ = writeln!(output, " name: {}", option.label); - let _ = writeln!( - output, - " current: {}", - if app.provider == option.name { - "yes" - } else { - "no" - } - ); - let _ = writeln!(output, " purpose: {}", option.description); - let _ = writeln!(output); - let _ = writeln!(output, "Action"); - if app.provider == option.name { - let _ = writeln!(output, " Enter: keep using this provider"); - } else { - let _ = writeln!(output, " Enter: use this provider for this session"); - } - let _ = writeln!(output); - let _ = writeln!(output, "Note"); - match option.name { - "local" => { - let _ = writeln!(output, " Local chat needs a running managed model server."); - } - "anthropic" | "openai" => { - let _ = writeln!( - output, - " Remote providers require opt-in and a saved provider key." - ); - } - _ => {} - } - output.trim_end().to_owned() -} - -fn services_manager_detail_text(app: &App) -> String { - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - return runtime_manager_running_text(app, &job.title); - } - let Some(state) = app.services_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - if let Some(action) = app.selected_services_manager_action() { - return services_manager_action_detail_text(app, action); - } - let Some(record) = state - .items - .get(state.selected) - .or_else(|| state.items.first()) - else { - return [ - "No local model servers are running.", - "", - "Start one from the main menu when you are ready.", - "Press F5 to check again.", - ] - .join("\n"); - }; - let mut output = String::new(); - let _ = writeln!(output, "Local model server"); - let _ = writeln!( - output, - " Status: {}", - friendly_service_status(&record.status) - ); - let _ = writeln!(output, " Engine: {}", record.engine); - let _ = writeln!( - output, - " Model: {}", - friendly_model_label(&record.model_ref) - ); - if let Some(policy) = record.device_policy.as_deref() { - let _ = writeln!(output, " Device: {}", serve_wizard_device_label(policy)); - } - let _ = writeln!(output); - let _ = writeln!(output, "Actions"); - let _ = writeln!(output, " Press Enter to open logs for this server."); - let _ = writeln!( - output, - " Choose Service details for the address, server id, and ROCm install." - ); - let _ = writeln!( - output, - " Use the rows below to chat, open logs, stop, restart, or go back." - ); - output.trim_end().to_owned() -} - -fn services_manager_action_detail_text(app: &App, action: ServicesManagerAction) -> String { - let mut output = String::new(); - match action { - ServicesManagerAction::Details => { - let _ = writeln!(output, "Service details"); - let _ = writeln!(output); - if let Some(record) = app.selected_service_target_record() { - let _ = writeln!(output, "Open the detailed server card."); - let _ = writeln!(output); - let _ = writeln!(output, "Selected server"); - let _ = writeln!( - output, - " Status: {}", - friendly_service_status(&record.status) - ); - let _ = writeln!( - output, - " Model: {}", - friendly_model_label(&record.model_ref) - ); - } else { - let _ = writeln!(output, "No managed servers are running yet."); - } - } - ServicesManagerAction::OpenLogs => { - let _ = writeln!(output, "Show server logs"); - let _ = writeln!(output); - if let Some(record) = app.selected_service_target_record() { - let _ = writeln!(output, "Selected server"); - let _ = writeln!( - output, - " Status: {}", - friendly_service_status(&record.status) - ); - let _ = writeln!( - output, - " Model: {}", - friendly_model_label(&record.model_ref) - ); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to open its logs."); - } else { - let _ = writeln!(output, "No managed servers are running yet."); - } - } - ServicesManagerAction::Chat => { - let _ = writeln!(output, "Chat with selected server"); - let _ = writeln!(output); - if let Some(record) = app.selected_service_target_record() { - let _ = writeln!(output, "Selected server"); - let _ = writeln!( - output, - " Status: {}", - friendly_service_status(&record.status) - ); - let _ = writeln!( - output, - " Model: {}", - friendly_model_label(&record.canonical_model_id) - ); - let _ = writeln!(output); - if managed_service_is_chat_ready(&record) { - let _ = writeln!(output, "Press Enter to ask this local model for ROCm help."); - let _ = writeln!( - output, - "It can check this computer. Changes still need your OK." - ); - } else { - let _ = writeln!(output, "This server is not ready yet."); - let _ = writeln!(output, "Open logs or press F5, then try Chat again."); - } - } else { - let _ = writeln!(output, "No managed server is selected."); - } - } - ServicesManagerAction::Stop => { - let _ = writeln!(output, "Stop selected server"); - let _ = writeln!(output); - if let Some(record) = app.selected_service_target_record() { - let _ = writeln!(output, "Selected server"); - let _ = writeln!( - output, - " Status: {}", - friendly_service_status(&record.status) - ); - let _ = writeln!( - output, - " Model: {}", - friendly_model_label(&record.model_ref) - ); - let _ = writeln!(output); - if record.status == "stopped" { - let _ = writeln!(output, "This server is already stopped."); - } else { - let _ = writeln!(output, "Press Enter to review stopping it."); - } - } else { - let _ = writeln!(output, "No managed server is selected."); - } - } - ServicesManagerAction::Restart => { - let _ = writeln!(output, "Restart selected server"); - let _ = writeln!(output); - if let Some(record) = app.selected_service_target_record() { - let _ = writeln!(output, "Selected server"); - let _ = writeln!( - output, - " Status: {}", - friendly_service_status(&record.status) - ); - let _ = writeln!( - output, - " Model: {}", - friendly_model_label(&record.model_ref) - ); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to review restarting it."); - } else { - let _ = writeln!(output, "No managed server is selected."); - } - } - ServicesManagerAction::Back => { - let _ = writeln!(output, "Back"); - let _ = writeln!(output); - let _ = writeln!(output, "Return to the main screen."); - } - } - output.trim_end().to_owned() -} - -fn automations_manager_detail_text(app: &App) -> String { - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - return runtime_manager_running_text(app, &job.title); - } - let Some(state) = app.automations_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - let Some(row) = selected_automation_manager_row(state) else { - let mut output = String::new(); - let _ = writeln!(output, "No automation checks are available."); - let _ = writeln!(output, "Press F5 to refresh."); - if !state.overview.trim().is_empty() { - let _ = writeln!(output); - let _ = writeln!(output, "Details"); - let _ = writeln!(output, "{}", state.overview.trim()); - } - return output.trim_end().to_owned(); - }; - let proposal = match row { - AutomationManagerSelectedRow::Review(proposal) => proposal, - AutomationManagerSelectedRow::PrefetchField(proposal, field) => { - let mut output = render_prefetch_field_detail(&proposal, field); - let _ = writeln!(output); - let _ = writeln!(output); - let _ = writeln!(output, "Review request"); - let _ = writeln!(output, " id: {}", proposal.proposal_id); - let _ = writeln!(output, " status: waiting for review"); - return output.trim_end().to_owned(); - } - AutomationManagerSelectedRow::Watcher(watcher) => { - return automation_watcher_detail_text(&watcher); - } - }; - if proposal.status == "pending" { - let mut output = render_proposal_approval(&proposal); - let _ = writeln!(output); - let _ = writeln!(output, "Selected request"); - let _ = writeln!(output, " id: {}", proposal.proposal_id); - let _ = writeln!(output, " status: waiting for review"); - let _ = writeln!(output); - let _ = writeln!( - output, - "Press Enter to review, Y to approve, or N to reject." - ); - return output.trim_end().to_owned(); - } - let mut output = String::new(); - let _ = writeln!(output, "Review request"); - let _ = writeln!(output, " id: {}", proposal.proposal_id); - let _ = writeln!( - output, - " status: {}", - proposal_status_for_user(&proposal.status) - ); - if !proposal.title.trim().is_empty() { - let _ = writeln!(output, " title: {}", proposal.title); - } - if !proposal.message.trim().is_empty() { - let _ = writeln!(output, " note: {}", proposal.message); - } - let _ = writeln!(output, " watcher: {}", proposal.watcher_id); - if let Some(service_id) = proposal.service_id.as_deref() { - let _ = writeln!(output, " service: {service_id}"); - } - let _ = writeln!(output); - let _ = writeln!(output, "This request has already been handled."); - let _ = writeln!( - output, - "Use Up and Down to choose another request, or F5 to refresh." - ); - output.trim_end().to_owned() -} - -fn automation_watcher_detail_text(watcher: &AutomationWatcherItem) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Automation check"); - let _ = writeln!(output, " name: {}", watcher.name); - let _ = writeln!( - output, - " status: {}", - if watcher.enabled { "on" } else { "off" } - ); - let _ = writeln!( - output, - " setting: {}", - automation_watcher_mode_plain_label(watcher.mode) - ); - let _ = writeln!(output, " watches: {}", watcher.trigger); - let _ = writeln!(output, " does: {}", watcher.summary); - if let Some(note) = automation_watcher_policy_note(&watcher.id) { - let _ = writeln!(output, " note: {note}"); - } - let _ = writeln!(output); - let _ = writeln!(output, "Actions"); - if watcher.enabled { - let _ = writeln!(output, " Enter or N/Delete: turn this off"); - let _ = writeln!(output, " Y: leave this on"); - } else { - let _ = writeln!(output, " Enter or Y: turn this on"); - let _ = writeln!(output, " N/Delete: leave this off"); - } - let _ = writeln!(output, " F5: refresh"); - let _ = writeln!(output, " Esc: back"); - output.trim_end().to_owned() -} - -fn install_manager_detail_text(app: &App) -> String { - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - return runtime_manager_running_text(app, &job.title); - } - let Some(state) = app.install_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - if matches!(state.screen, InstallManagerScreen::Sdk { .. }) { - return install_sdk_detail_text(app); - } - match install_menu_choices() - .get(state.selected) - .copied() - .unwrap_or(InstallMenuChoice::Runtime) - { - InstallMenuChoice::Runtime => [ - "Install ROCm", - "", - "ROCm CLI will install ROCm into a Python folder that it manages.", - "This is the normal setup needed for PyTorch, llama.cpp, and local engines.", - "", - "Press Enter to review and start.", - ] - .join("\n"), - InstallMenuChoice::DriverPreview => [ - "Check driver setup", - "", - "ROCm CLI will inspect this computer and show what driver setup would need.", - "It will not install or change a driver from this screen.", - "", - "Press Enter to run the check.", - ] - .join("\n"), - InstallMenuChoice::Back => "Return to the main screen.".to_owned(), - } -} - -fn install_sdk_detail_text(app: &App) -> String { - let Some(state) = app.install_manager.as_ref() else { - return String::new(); - }; - let InstallManagerScreen::Sdk { - selected, - channel, - format, - folder, - editing_folder, - } = &state.screen - else { - return String::new(); - }; - let mut output = String::new(); - let _ = writeln!(output, "Install ROCm"); - let _ = writeln!(output); - let _ = writeln!( - output, - "ROCm CLI will install ROCm into a Python folder it manages." - ); - let _ = writeln!( - output, - "It will choose the AMD GPU package for this computer." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Choices"); - if *channel == InstallSdkChannel::Release && *format == InstallSdkFormat::Wheel { - let _ = writeln!(output, " Using: Recommended Python install"); - } else { - let _ = writeln!(output, " Advanced settings from command:"); - let _ = writeln!(output, " Channel: {}", channel.as_str()); - let _ = writeln!( - output, - " Install type: {}", - install_sdk_format_label(*format) - ); - } - let _ = writeln!(output); - let _ = writeln!(output, "Selected folder"); - let _ = writeln!(output, " {folder}"); - let _ = writeln!(output, "Downloaded files"); - let _ = writeln!( - output, - " {}", - display_runtime_folder_path(&managed_pip_cache_dir(std::path::Path::new(folder))) - ); - let _ = writeln!(output); - let _ = writeln!(output, "Selected row"); - match install_sdk_choices() - .get(*selected) - .copied() - .unwrap_or(InstallSdkChoice::Install) - { - InstallSdkChoice::Channel => { - let _ = writeln!( - output, - " Left/Right or Enter switches Release and Nightly." - ); - let _ = writeln!(output, " Release is stable and selected by default."); - } - InstallSdkChoice::Folder => { - if *editing_folder { - let _ = writeln!( - output, - " Type or paste the folder below, then press Enter." - ); - } else { - let _ = writeln!(output, " Left/Right changes common folders."); - let _ = writeln!(output, " Enter opens the folder picker."); - let _ = writeln!(output, " T lets you type or paste a folder."); - } - } - InstallSdkChoice::Install => { - let _ = writeln!(output, " Enter reviews the install before it starts."); - } - InstallSdkChoice::Back => { - let _ = writeln!(output, " Enter returns to install options."); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Up/Down chooses a row."); - let _ = writeln!( - output, - " On the Channel row, Enter or Left/Right switches channel." - ); - let _ = writeln!( - output, - " On the Folder row, Enter browses and Left/Right changes common folders." - ); - let _ = writeln!(output, " Esc goes back."); - output.trim_end().to_owned() -} - -fn examine_manager_detail_text(app: &App) -> String { - let Some(state) = app.examine_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - let report = &state.report; - match examine_manager_choices() - .get(state.selected) - .copied() - .unwrap_or(ExamineManagerChoice::Overview) - { - ExamineManagerChoice::Overview => examine_overview_text(report), - ExamineManagerChoice::Gpu => examine_gpu_text(report), - ExamineManagerChoice::Runtimes => examine_runtimes_text(report), - ExamineManagerChoice::Engines => examine_engines_text(report), - ExamineManagerChoice::DriverAndWsl => examine_driver_wsl_text(report), - ExamineManagerChoice::Folders => examine_folders_text(report), - ExamineManagerChoice::Back => "Return to the main screen.".to_owned(), - } -} - -fn examine_overview_text(report: &str) -> String { - let os = examine_report_value(report, "os"); - let arch = examine_report_value(report, "arch"); - let distro = examine_report_value(report, "distro"); - let cpu = examine_report_value(report, "cpu"); - let ram = examine_report_value(report, "system_ram"); - let gfx = examine_report_value(report, "detected_gfx_target"); - let family = examine_report_value(report, "compatible_therock_family"); - let installed_family = examine_report_value(report, "detected_therock_family"); - let runtime_status = examine_report_value(report, "active_runtime_status"); - let driver_status = examine_report_value(report, "driver_status"); - let default_engine = examine_report_value(report, "effective_default_engine"); - let mut output = String::new(); - let _ = writeln!(output, "ROCm check"); - let _ = writeln!(output); - let _ = writeln!(output, "Computer"); - let _ = writeln!(output, " System: {os} {arch}"); - let _ = writeln!(output, " Linux distro: {distro}"); - let _ = writeln!(output, " CPU: {cpu}"); - let _ = writeln!(output, " Memory: {ram}"); - let _ = writeln!(output); - let _ = writeln!(output, "GPU"); - let _ = writeln!(output, " AMD GPU: {}", examine_gpu_display_name(report)); - let _ = writeln!(output, " Target: {gfx}"); - let _ = writeln!(output, " ROCm package: {family}"); - let _ = writeln!(output, " Installed ROCm package: {installed_family}"); - let _ = writeln!(output); - let _ = writeln!(output, "ROCm"); - let _ = writeln!( - output, - " Default install: {}", - friendly_runtime_status(&runtime_status) - ); - let _ = writeln!( - output, - " Driver: {}", - friendly_driver_status(&driver_status) - ); - let _ = writeln!(output, " Default engine: {default_engine}"); - let _ = writeln!(output); - let _ = writeln!( - output, - "Use Up/Down to inspect a check. Press F5 to refresh." - ); - output.trim_end().to_owned() -} - -fn examine_gpu_text(report: &str) -> String { - let gfx = examine_report_value(report, "detected_gfx_target"); - let family = examine_report_value(report, "compatible_therock_family"); - let installed_family = examine_report_value(report, "detected_therock_family"); - let driver_status = examine_report_value(report, "driver_status"); - let mut output = String::new(); - let _ = writeln!(output, "GPU"); - let _ = writeln!(output); - let _ = writeln!(output, "Detected AMD GPU"); - let _ = writeln!(output, " {}", examine_gpu_display_name(report)); - let _ = writeln!(output); - let _ = writeln!(output, "Target"); - let _ = writeln!(output, " {gfx}"); - let _ = writeln!(output); - let _ = writeln!(output, "ROCm package for this GPU"); - let _ = writeln!(output, " {family}"); - let _ = writeln!(output); - let _ = writeln!(output, "Installed ROCm package"); - let _ = writeln!(output, " {installed_family}"); - let _ = writeln!(output); - let _ = writeln!(output, "Driver"); - let _ = writeln!(output, " {}", friendly_driver_status(&driver_status)); - if installed_family == "not detected" && family != "not checked" { - let _ = writeln!(output); - let _ = writeln!( - output, - "This is normal before setup. ROCm CLI can choose the package above when it installs ROCm." - ); - } - output.trim_end().to_owned() -} - -fn examine_gpu_display_name(report: &str) -> String { - let detail = examine_report_value(report, "driver_detail"); - if detail != "not checked" && detail != "not detected" && detail != "none found" { - return detail; - } - let gfx = examine_report_value(report, "detected_gfx_target"); - if gfx != "not checked" && gfx != "not detected" && gfx != "none found" { - return "detected AMD GPU".to_owned(); - } - "not detected yet".to_owned() -} - -fn examine_runtimes_text(report: &str) -> String { - let count = examine_report_value(report, "managed_runtimes"); - let active_id = examine_report_value(report, "active_runtime_id"); - let active_status = examine_report_value(report, "active_runtime_status"); - let active_root = examine_report_value(report, "active_runtime_root"); - let active_version = examine_report_value(report, "active_runtime_version"); - let active_family = examine_report_value(report, "active_runtime_family"); - let registered = examine_report_value(report, "registered_runtime_keys"); - let mut output = String::new(); - let _ = writeln!(output, "ROCm installs"); - let _ = writeln!(output); - let _ = writeln!(output, "Installed by ROCm CLI"); - let _ = writeln!(output, " {count}"); - let _ = writeln!(output); - let _ = writeln!(output, "Default install"); - let _ = writeln!(output, " Name: {active_id}"); - let _ = writeln!( - output, - " Status: {}", - friendly_runtime_status(&active_status) - ); - let _ = writeln!(output, " Version: {active_version}"); - let _ = writeln!(output, " GPU package: {active_family}"); - let _ = writeln!(output, " Folder: {active_root}"); - let _ = writeln!(output, "Saved installs"); - let _ = writeln!(output, " {registered}"); - let _ = writeln!(output); - let _ = writeln!( - output, - "Use /runtimes to choose an installed ROCm version from the list." - ); - output.trim_end().to_owned() -} - -fn examine_engines_text(report: &str) -> String { - let configured = examine_report_value(report, "configured_default_engine"); - let effective = examine_report_value(report, "effective_default_engine"); - let services = examine_report_value(report, "managed_services"); - let mut output = String::new(); - let _ = writeln!(output, "Engines"); - let _ = writeln!(output); - let _ = writeln!(output, "Default engine"); - let _ = writeln!(output, " Selected setting: {configured}"); - let _ = writeln!(output, " Engine ROCm CLI will use: {effective}"); - let _ = writeln!(output); - let _ = writeln!(output, "Local model servers"); - let _ = writeln!(output, " Managed services: {services}"); - let _ = writeln!(output); - let _ = writeln!(output, "Use /engine to install or choose an engine."); - let _ = writeln!( - output, - "Use /services to view local model servers and logs." - ); - output.trim_end().to_owned() -} - -fn examine_driver_wsl_text(report: &str) -> String { - let driver_policy = examine_report_value(report, "driver_policy"); - let driver_status = examine_report_value(report, "driver_status"); - let driver_detail = examine_report_value(report, "driver_detail"); - let wsl = examine_report_bool_value(report, "wsl"); - let dxg = examine_report_bool_value(report, "wsl_dxg_device"); - let dxcore = examine_report_bool_value(report, "wsl_dxcore"); - let librocdxg = examine_report_bool_value(report, "wsl_librocdxg"); - let dids = examine_report_bool_value(report, "wsl_rocdxg_dids"); - let cargo = examine_report_bool_value(report, "wsl_cargo"); - let detail = examine_report_value(report, "wsl_detail"); - let mut output = String::new(); - let _ = writeln!(output, "Driver and WSL"); - let _ = writeln!(output); - let _ = writeln!(output, "Driver"); - let _ = writeln!(output, " Policy: {driver_policy}"); - let _ = writeln!( - output, - " Status: {}", - friendly_driver_status(&driver_status) - ); - let _ = writeln!(output, " Details: {driver_detail}"); - let _ = writeln!(output); - let _ = writeln!(output, "WSL GPU support"); - let _ = writeln!(output, " Running inside WSL: {wsl}"); - let _ = writeln!(output, " GPU device visible: {dxg}"); - let _ = writeln!(output, " DXCore available: {dxcore}"); - let _ = writeln!(output, " ROCDXG library installed: {librocdxg}"); - let _ = writeln!(output, " Device IDs installed: {dids}"); - let _ = writeln!(output, " Build tools available: {cargo}"); - let _ = writeln!(output, " Details: {detail}"); - output.trim_end().to_owned() -} - -fn examine_folders_text(report: &str) -> String { - let config = examine_report_value(report, "config_dir"); - let data = examine_report_value(report, "data_dir"); - let cache = examine_report_value(report, "cache_dir"); - let rocm_downloads = examine_report_raw_value(report, "active_runtime_pip_cache_dir") - .or_else(|| examine_report_raw_value(report, "setup_runtime_pip_cache_dir")) - .map_or_else( - || "choose a ROCm install folder first".to_owned(), - friendly_examine_value, - ); - let models = examine_report_value(report, "model_cache_entries"); - let active_status = examine_report_value(report, "active_runtime_status"); - let active_install = examine_report_raw_value(report, "active_runtime_root") - .or_else(|| examine_report_raw_value(report, "setup_runtime_root")) - .map_or_else( - || friendly_runtime_status(&active_status), - friendly_examine_value, - ); - let mut output = String::new(); - let _ = writeln!(output, "Folders"); - let _ = writeln!(output); - let _ = writeln!( - output, - "ROCm CLI keeps settings, logs, downloads, and ROCm installs in separate places." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Settings: {config}"); - let _ = writeln!(output, "Logs and app data: {data}"); - let _ = writeln!(output, "App metadata cache: {cache}"); - let _ = writeln!(output, "ROCm install downloads: {rocm_downloads}"); - let _ = writeln!(output, "Active ROCm install: {active_install}"); - let _ = writeln!(output, "Cached models: {models}"); - output.trim_end().to_owned() -} - -fn examine_report_value(report: &str, key: &str) -> String { - examine_report_raw_value(report, key) - .map_or_else(|| "not checked".to_owned(), friendly_examine_value) -} - -fn examine_report_raw_value<'a>(report: &'a str, key: &str) -> Option<&'a str> { - let prefix = format!("{key}:"); - report.lines().find_map(|line| { - let trimmed = line.trim(); - trimmed - .strip_prefix(&prefix) - .map(str::trim) - .filter(|value| !value.is_empty()) - }) -} - -fn examine_report_bool_value(report: &str, key: &str) -> &'static str { - match examine_report_raw_value(report, key) { - Some("true") => "yes", - Some("false") => "no", - Some(_) => "not checked", - None => "not checked", - } -} - -fn friendly_examine_value(value: &str) -> String { - match value { - "" | "" => "not checked".to_owned(), - "" => "not set".to_owned(), - "" => "none found".to_owned(), - "" => "not detected".to_owned(), - "" => "not running in WSL".to_owned(), - "" => "automatic".to_owned(), - other => other.to_owned(), - } -} - -fn friendly_runtime_status(status: &str) -> String { - match status { - "ready" => "ready".to_owned(), - "unset" | "not set" => "not installed yet".to_owned(), - "missing_manifest" => "saved install is missing".to_owned(), - "ambiguous_runtime_id" => "more than one saved install matches".to_owned(), - "not checked" => "not checked".to_owned(), - other => other.replace('_', " "), - } -} - -fn friendly_driver_status(status: &str) -> String { - match status { - "ready" => "ready".to_owned(), - "unsupported" => "not supported here".to_owned(), - "not checked" => "not checked".to_owned(), - other => other.replace('_', " "), - } -} - -fn command_screen_detail_text(app: &App) -> String { - if let Some(pending) = app.pending_approval.as_ref() - && app.command_screen.is_some() - && matches!(pending.action, ApprovalAction::Proposal { .. }) - && let Some(message) = app - .command_screen - .as_ref() - .and_then(|state| state.message.as_deref()) - { - return message.to_owned(); - } - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - if let RunningJobKind::Chat { - provider, - rocm_tools: true, - } = &job.kind - { - let progress = active_screen_message_text(app); - if app.command_screen_is_chat_session() { - let running_text = chat_session_running_text(provider, progress.as_deref()); - return render_chat_session_detail(app, Some(running_text)); - } - let running_text = chat_tools_running_text_with_progress(provider, progress.as_deref()); - return running_text; - } - if app.command_screen_is_chat_session() && matches!(job.kind, RunningJobKind::Chat { .. }) { - return render_chat_session_detail(app, Some("Assistant is answering.".to_owned())); - } - return runtime_manager_running_text(app, &job.title); - } - let Some(state) = app.command_screen.as_ref() else { - return String::new(); - }; - if state.chat_session.is_some() { - return render_chat_session_detail(app, None); - } - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - if state.refresh_target == Some(CommandScreenTarget::Help) - && let Some(CommandScreenAction::OpenCommand(command)) = state.actions.get(state.selected) - { - return help_command_detail(command); - } - state.detail.clone() -} - -fn update_manager_detail_text(app: &App) -> String { - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - return runtime_manager_running_text(app, &job.title); - } - let Some(state) = app.update_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - - let mut output = state.report.clone(); - let _ = writeln!(output); - let _ = writeln!(output); - let _ = writeln!(output, "Selected action"); - match update_menu_choices() - .get(state.selected) - .copied() - .unwrap_or(UpdateMenuChoice::Refresh) - { - UpdateMenuChoice::Refresh => { - let _ = writeln!(output, " Press Enter to check update status again."); - } - UpdateMenuChoice::Preview => { - let _ = writeln!( - output, - " Press Enter to preview the ROCm update without changing files." - ); - } - UpdateMenuChoice::Apply => { - let _ = writeln!( - output, - " Press Enter to review installing the newest ROCm package." - ); - } - UpdateMenuChoice::ApplyAndUse => { - let _ = writeln!( - output, - " Press Enter to review installing the newest ROCm package and using it by default." - ); - } - UpdateMenuChoice::Back => { - let _ = writeln!(output, " Press Enter to return to the main screen."); - } - } - output.trim_end().to_owned() -} - -fn engine_manager_detail_text(app: &App) -> String { - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - return runtime_manager_running_text(app, &job.title); - } - let Some(state) = app.engine_manager.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - if let Some(action) = app.selected_engine_manager_action() { - return engine_manager_action_detail_text(app, action); - } - let Some(item) = app.selected_engine_manager_item() else { - return "No engines are available.".to_owned(); - }; - let mut output = String::new(); - let _ = writeln!(output, "Engine"); - let _ = writeln!(output, " name: {}", item.name); - let _ = writeln!(output, " role: {}", item.description); - let _ = writeln!( - output, - " status: {}", - if item.installed_env_id.is_some() { - "installed" - } else if item.runtime_ready { - "not installed" - } else { - "needs ROCm first" - } - ); - let _ = writeln!( - output, - " default: {}", - if item.is_default { "yes" } else { "no" } - ); - let _ = writeln!(output); - let _ = writeln!(output, "Actions"); - if item.installed_env_id.is_some() { - let _ = writeln!(output, " Enter: use this engine by default"); - let _ = writeln!( - output, - " Use the action rows below to reinstall or go back." - ); - } else if item.runtime_ready { - let _ = writeln!(output, " Enter: install this engine"); - let _ = writeln!(output, " Use the action rows below to install or go back."); - } else { - let _ = writeln!(output, " Enter: open ROCm install options first"); - let _ = writeln!( - output, - " Use the action rows below to open setup or go back." - ); - } - output.trim_end().to_owned() -} - -fn engine_manager_action_detail_text(app: &App, action: EngineManagerAction) -> String { - let mut output = String::new(); - match action { - EngineManagerAction::UseSelected => { - let _ = writeln!(output, "Use selected engine"); - let _ = writeln!(output); - if let Some(item) = app.selected_engine_manager_target_item() { - let _ = writeln!(output, "Selected engine"); - let _ = writeln!(output, " name: {}", item.name); - let _ = writeln!(output, " status: installed"); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to review making it the default."); - } else { - let _ = writeln!(output, "No engine is selected."); - } - } - EngineManagerAction::InstallSelected => { - let _ = writeln!(output, "Install selected engine"); - let _ = writeln!(output); - if let Some(item) = app.selected_engine_manager_target_item() { - let _ = writeln!(output, "Selected engine"); - let _ = writeln!(output, " name: {}", item.name); - let _ = writeln!(output, " role: {}", item.description); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to review installing it."); - } else { - let _ = writeln!(output, "No engine is selected."); - } - } - EngineManagerAction::ReinstallSelected => { - let _ = writeln!(output, "Reinstall selected engine"); - let _ = writeln!(output); - if let Some(item) = app.selected_engine_manager_target_item() { - let _ = writeln!(output, "Selected engine"); - let _ = writeln!(output, " name: {}", item.name); - let _ = writeln!(output, " status: installed"); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to review reinstalling it."); - } else { - let _ = writeln!(output, "No engine is selected."); - } - } - EngineManagerAction::InstallRocm => { - let _ = writeln!(output, "Install ROCm first"); - let _ = writeln!(output); - let _ = writeln!( - output, - "Engines need a ready ROCm install before they can be installed." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to open ROCm install options."); - } - EngineManagerAction::Back => { - let _ = writeln!(output, "Back"); - let _ = writeln!(output); - let _ = writeln!(output, "Return to the main screen."); - } - } - output.trim_end().to_owned() -} - -fn model_picker_detail_text(app: &App) -> String { - let Some(state) = app.model_picker.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - let Some(recipe) = state - .items - .get(state.selected) - .or_else(|| state.items.first()) - else { - return "No model recipes found.\n\nPress F5 to refresh.".to_owned(); - }; - let mut output = String::new(); - let _ = writeln!(output, "Model"); - let _ = writeln!(output, " name: {}", recipe.canonical_model_id); - if !recipe.aliases.is_empty() { - let _ = writeln!(output, " aliases: {}", recipe.aliases.join(", ")); - } - let _ = writeln!(output, " task: {}", recipe.task); - let _ = writeln!( - output, - " device: {}", - model_device_policy_label(&recipe.device_policy) - ); - let _ = writeln!( - output, - " GPU memory: {}", - recipe.min_gpu_mem_gb.map_or_else( - || "not required".to_owned(), - |gb| format!("{gb} GB minimum") - ) - ); - if !recipe.preferred_engines.is_empty() { - let _ = writeln!(output, " engines: {}", recipe.preferred_engines.join(", ")); - } - let _ = writeln!(output); - let _ = writeln!(output, "Fit"); - let _ = writeln!( - output, - " {}", - model_recipe_fit_summary(recipe, app.gpu_telemetry.total_vram_gib()) - ); - let _ = writeln!(output); - let _ = writeln!(output, "Action"); - let _ = writeln!(output, " Enter: open local model setup for this model"); - let _ = writeln!(output, " F5: refresh model recipes"); - let _ = writeln!(output, " Esc: back"); - if !recipe.warnings.is_empty() { - let _ = writeln!(output); - let _ = writeln!(output, "Notes"); - for warning in recipe.warnings.iter().take(3) { - let _ = writeln!(output, " {warning}"); - } - } - output.trim_end().to_owned() -} - -fn serve_wizard_detail_text(app: &App) -> String { - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - return runtime_manager_running_text(app, &job.title); - } - let Some(state) = app.serve_wizard.as_ref() else { - return String::new(); - }; - if let Some(message) = state.message.as_deref() { - return message.to_owned(); - } - let engine = selected_serve_wizard_engine(state); - let device = selected_serve_wizard_device(state); - let mut output = String::new(); - let _ = writeln!(output, "Start a local AI model"); - let _ = writeln!( - output, - " Model type: {}", - serve_wizard_source_label(state.model_source) - ); - match state.model_source { - ServeModelSource::BuiltInRecipe => { - if let Some(recipe) = selected_serve_wizard_recipe(state) { - let _ = writeln!(output, " Model: {}", recipe.canonical_model_id); - if !recipe.aliases.is_empty() { - let _ = writeln!(output, " Also called: {}", recipe.aliases.join(", ")); - } - } else { - let _ = writeln!(output, " Model: no recommended models found"); - let _ = writeln!(output, " Next: choose your own model file"); - } - } - ServeModelSource::LocalPath => { - let _ = writeln!( - output, - " Model: {}", - serve_wizard_local_path_label(&state.local_model_path) - ); - let _ = writeln!( - output, - " Next: press Enter on Path to choose a file or folder" - ); - } - } - let _ = writeln!(output, " Runs on: {}", serve_wizard_device_label(device)); - let _ = writeln!( - output, - " Address: http://{}:{}", - format_host_for_url(&state.host), - state.port - ); - let _ = writeln!( - output, - " After start: {}", - if state.managed { - "ROCm CLI keeps it running" - } else { - "show command only" - } - ); - let _ = writeln!(output); - if let Some(recipe) = selected_serve_wizard_recipe(state) - && state.model_source == ServeModelSource::BuiltInRecipe - { - let _ = writeln!(output, "Fit"); - let _ = writeln!( - output, - " {}", - model_recipe_fit_summary(recipe, app.gpu_telemetry.total_vram_gib()) - ); - if !recipe.preferred_engines.is_empty() { - let _ = writeln!( - output, - " Works best with: {}", - recipe.preferred_engines.join(", ") - ); - } - let _ = writeln!(output); - } - let _ = writeln!(output, "Advanced"); - let _ = writeln!(output, " Engine: {engine}"); - if let Some(runtime_id) = state.runtime_id.as_deref() { - let _ = writeln!(output, " ROCm install: {runtime_id}"); - } - let _ = writeln!( - output, - " Listen on: http://{}:{}", - format_host_for_url(&state.host), - state.port - ); - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Up/Down: choose a row"); - let _ = writeln!(output, " Left/Right: change the highlighted choice"); - if state.model_source == ServeModelSource::LocalPath { - let _ = writeln!(output, " Enter on Path, Host, or Port: edit that value"); - } else { - let _ = writeln!( - output, - " Recommended model is fixed. Change Model type to use a custom file." - ); - let _ = writeln!(output, " Enter on Host or Port: edit that value"); - } - let _ = writeln!( - output, - " Enter on Start: show a review card before anything runs" - ); - if !state.managed { - let _ = writeln!( - output, - " Command-only mode does not launch here. Change Mode to keep it running in ROCm CLI." - ); - } - output.trim_end().to_owned() -} - -fn model_recipe_fit_summary(recipe: &ModelRecipeRecord, total_vram_gib: Option) -> String { - match (recipe.min_gpu_mem_gb, total_vram_gib) { - (None, _) => "This recipe does not require GPU memory.".to_owned(), - (Some(required), Some(available)) if available >= (f64::from(required) + 2.0) => { - format!( - "Good fit: {} GPU memory available; recipe asks for {}.", - format_gib_for_tui(available), - format_gib_for_tui(f64::from(required)) - ) - } - (Some(required), Some(available)) if available >= f64::from(required) => { - format!( - "Tight but usable: {} GPU memory available; recipe asks for {}.", - format_gib_for_tui(available), - format_gib_for_tui(f64::from(required)) - ) - } - (Some(required), Some(available)) => format!( - "Likely not enough GPU memory: {} available; recipe asks for {}.", - format_gib_for_tui(available), - format_gib_for_tui(f64::from(required)) - ), - (Some(required), None) => format!( - "GPU memory is unknown; recipe asks for {}. Start may fail if this APU has too little shared memory.", - format_gib_for_tui(f64::from(required)) - ), - } -} - -fn model_device_policy_label(policy: &str) -> &'static str { - match policy { - "gpu_required" => "GPU required", - "gpu_preferred" => "GPU required", - "cpu_only" => "not shown here", - "cpu_supported" => "not shown here", - _ => "See recipe details", - } -} - -fn format_gib_for_tui(value: f64) -> String { - if (value.fract()).abs() < f64::EPSILON { - format!("{value:.0} GB") - } else { - format!("{value:.1} GB") - } -} - -fn format_bytes_for_tui(bytes: u64) -> String { - const KIB: u64 = 1024; - const MIB: u64 = KIB * 1024; - const GIB: u64 = MIB * 1024; - let unit_text = if bytes >= GIB { - format_gib_for_tui(bytes as f64 / GIB as f64) - } else if bytes >= MIB { - format!("{:.1} MB", bytes as f64 / MIB as f64) - } else if bytes >= KIB { - format!("{:.1} KB", bytes as f64 / KIB as f64) - } else if bytes == 1 { - "1 byte".to_owned() - } else { - format!("{bytes} bytes") - }; - if bytes >= KIB { - format!("{unit_text} ({bytes} bytes)") - } else { - unit_text - } -} - -fn runtime_manager_detail_text(app: &App) -> String { - if let Some(job) = app.running_job.as_ref() - && app.active_screen_handles_command_output(&job.title) - { - return runtime_manager_running_text(app, &job.title); - } - - let Some(state) = app.runtime_manager.as_ref() else { - return String::new(); - }; - match &state.screen { - RuntimeManagerScreen::Advanced { .. } => { - return runtime_advanced_detail_text(state); - } - RuntimeManagerScreen::ImportManifest { .. } => { - return runtime_import_detail_text(state); - } - RuntimeManagerScreen::AdoptExisting { .. } => { - return runtime_adopt_detail_text(state); - } - RuntimeManagerScreen::List => {} - } - - let selected_action = app.selected_runtime_manager_action(); - if let Some(message) = state.message.as_deref() - && let Some(action) = selected_action - { - return format!("{message}\n\n{}", runtime_action_detail_text(app, action)); - } - - if let Some(action) = selected_action { - return runtime_action_detail_text(app, action); - } - - let Some(item) = state - .items - .get(state.selected) - .or_else(|| state.items.first()) - else { - return runtime_action_detail_text(app, RuntimeManagerAction::Install); - }; - let mut output = String::new(); - if let Some(message) = state.message.as_deref() { - let _ = writeln!(output, "Note"); - for line in message.lines().take(8) { - let _ = writeln!(output, " {line}"); - } - let _ = writeln!(output); - } - let _ = writeln!(output, "ROCm install"); - let _ = writeln!( - output, - " state: {}", - if item.active { "active" } else { "available" } - ); - let _ = writeln!( - output, - " health: {}", - if item.status == "ready" { - "ready" - } else { - "needs repair" - } - ); - if item.status != "ready" { - let _ = writeln!(output, " reason: {}", item.status); - let _ = writeln!( - output, - " fix: reinstall ROCm from Set Up ROCm, or uninstall this install" - ); - } - let _ = writeln!( - output, - " version: {}", - therock::runtime_version_display(&item.version) - ); - let _ = writeln!(output, " GPU package: {}", item.family); - let _ = writeln!(output, " folder: {}", item.install_root.display()); - if item.read_only || item.imported_from.is_some() { - let _ = writeln!( - output, - " source: existing folder; ROCm CLI will not change that folder" - ); - } else { - let _ = writeln!(output, " source: managed by ROCm CLI"); - } - let _ = writeln!(output); - let _ = writeln!(output, "Actions"); - if item.active { - let _ = writeln!(output, " Enter: already default"); - } else if item.status == "ready" { - let _ = writeln!(output, " Enter: use this ROCm install"); - } else { - let _ = writeln!(output, " Enter: unavailable until reinstalled"); - } - let _ = writeln!( - output, - " Choose a row below the list to install, uninstall, or add an existing install." - ); - if item.read_only || item.imported_from.is_some() { - let _ = writeln!(output, " Uninstall only forgets this external folder."); - } else { - let _ = writeln!(output, " Uninstall removes the selected ROCm folder."); - } - output.trim_end().to_owned() -} - -fn runtime_action_detail_text(app: &App, action: RuntimeManagerAction) -> String { - let mut output = String::new(); - match action { - RuntimeManagerAction::Install => { - let _ = writeln!(output, "Install ROCm"); - let _ = writeln!(output); - let _ = writeln!( - output, - "ROCm CLI will install ROCm into a Python folder it manages." - ); - let _ = writeln!( - output, - "It will choose the AMD GPU package for this computer." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to review and start."); - } - RuntimeManagerAction::RemoveSelected => { - let _ = writeln!(output, "Uninstall selected install"); - let _ = writeln!(output); - if let Some(item) = app.selected_runtime_manager_target_item() { - let _ = writeln!(output, "Selected ROCm install"); - let _ = writeln!( - output, - " version: {}", - therock::runtime_version_display(&item.version) - ); - let _ = writeln!(output, " GPU package: {}", item.family); - let _ = writeln!(output, " folder: {}", item.install_root.display()); - let _ = writeln!(output); - if item.read_only || item.imported_from.is_some() { - let _ = writeln!( - output, - "ROCm CLI will forget this external folder. The folder stays on disk." - ); - } else { - let _ = writeln!(output, "ROCm CLI will uninstall this managed ROCm folder."); - } - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to review uninstalling it."); - } else { - let _ = writeln!(output, "No ROCm install is selected yet."); - let _ = writeln!(output, "Choose an install row first."); - } - } - RuntimeManagerAction::AdvancedOptions => { - let _ = writeln!(output, "Add existing install"); - let _ = writeln!(output); - let _ = writeln!( - output, - "Use this only if ROCm is already installed somewhere else." - ); - let _ = writeln!( - output, - "Most users should choose Install ROCm and let ROCm CLI manage it." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Press Enter to choose how to add it."); - } - } - output.trim_end().to_owned() -} - -fn runtime_advanced_detail_text(state: &RuntimeManagerState) -> String { - let RuntimeManagerScreen::Advanced { selected } = &state.screen else { - return String::new(); - }; - let mut output = String::new(); - let _ = writeln!(output, "Add existing ROCm"); - let _ = writeln!(output); - let _ = writeln!( - output, - "These choices are for ROCm installs that already exist on this computer." - ); - let _ = writeln!( - output, - "ROCm CLI will remember them, but it will not change external folders." - ); - if let Some(message) = state.message.as_deref() { - let _ = writeln!(output); - let _ = writeln!(output, "Note"); - for line in message.lines().take(6) { - let _ = writeln!(output, " {line}"); - } - } - let _ = writeln!(output); - match runtime_advanced_choices().get(*selected).copied() { - Some(RuntimeAdvancedChoice::AdoptExisting) => { - let _ = writeln!(output, "Selected: add existing Python folder"); - let _ = writeln!( - output, - "Choose this if that Python already has TheRock ROCm installed." - ); - } - Some(RuntimeAdvancedChoice::ImportManifest) => { - let _ = writeln!(output, "Selected: add from saved ROCm file"); - let _ = writeln!( - output, - "Choose this if you have a saved ROCm install file from another setup." - ); - } - Some(RuntimeAdvancedChoice::Back) | None => { - let _ = writeln!(output, "Selected: back"); - let _ = writeln!(output, "Return to the normal ROCm install list."); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Enter opens the highlighted option."); - let _ = writeln!(output, " Esc goes back to ROCm installs."); - output.trim_end().to_owned() -} - -fn runtime_import_detail_text(state: &RuntimeManagerState) -> String { - let RuntimeManagerScreen::ImportManifest { - manifest_path, - replace, - editing_path, - .. - } = &state.screen - else { - return String::new(); - }; - let mut output = String::new(); - let _ = writeln!(output, "Import saved ROCm install"); - let _ = writeln!(output); - let _ = writeln!( - output, - "ROCm CLI will remember an existing ROCm install from a saved ROCm install file." - ); - let _ = writeln!(output, "The external ROCm folder will not be changed."); - let _ = writeln!(output); - let _ = writeln!(output, "Selected file"); - let _ = writeln!( - output, - " {}", - if manifest_path.trim().is_empty() { - "not chosen yet" - } else { - manifest_path - } - ); - let _ = writeln!( - output, - " replace matching saved install: {}", - if *replace { "yes" } else { "no" } - ); - if let Some(message) = state.message.as_deref() { - let _ = writeln!(output); - let _ = writeln!(output, "Message"); - for line in message.lines().take(6) { - let _ = writeln!(output, " {line}"); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - if *editing_path { - let _ = writeln!(output, " Type or paste a file path in the prompt below."); - let _ = writeln!(output, " Enter saves it. Esc cancels editing."); - } else { - let _ = writeln!(output, " Enter edits or selects the highlighted row."); - let _ = writeln!(output, " Esc goes back to ROCm installs."); - } - output.trim_end().to_owned() -} - -fn runtime_adopt_detail_text(state: &RuntimeManagerState) -> String { - let RuntimeManagerScreen::AdoptExisting { - python_path, - channel, - replace, - editing_python, - .. - } = &state.screen - else { - return String::new(); - }; - let mut output = String::new(); - let _ = writeln!(output, "Add existing Python folder"); - let _ = writeln!(output); - let _ = writeln!( - output, - "ROCm CLI will check that Python can see TheRock ROCm and then remember it as read-only." - ); - let _ = writeln!(output, "The Python folder will not be modified."); - let _ = writeln!(output); - let _ = writeln!(output, "Selected Python"); - let _ = writeln!( - output, - " {}", - if python_path.trim().is_empty() { - "not chosen yet" - } else { - python_path - } - ); - let _ = writeln!(output, " channel: {}", channel.as_str()); - let _ = writeln!( - output, - " replace matching saved install: {}", - if *replace { "yes" } else { "no" } - ); - if let Some(message) = state.message.as_deref() { - let _ = writeln!(output); - let _ = writeln!(output, "Message"); - for line in message.lines().take(6) { - let _ = writeln!(output, " {line}"); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - if *editing_python { - let _ = writeln!( - output, - " Type or paste a Python folder or python executable below." - ); - let _ = writeln!(output, " Enter saves it. Esc cancels editing."); - } else { - let _ = writeln!(output, " Enter edits or selects the highlighted row."); - let _ = writeln!(output, " Esc goes back to ROCm installs."); - } - output.trim_end().to_owned() -} - -fn render_screen_approval(app: &App, pending: &PendingApproval) -> String { - let approve_marker = if pending.selected == 0 { ">" } else { " " }; - let cancel_marker = if pending.selected == 1 { ">" } else { " " }; - let is_proposal = matches!(pending.action, ApprovalAction::Proposal { .. }); - let cancel_label = if is_proposal { - "N: reject" - } else { - "Esc or N: cancel" - }; - let mut lines = vec![pending.title.clone(), String::new()]; - lines.extend(plain_approval_lines(app, pending).into_iter().map(|line| { - if line.is_empty() { - String::new() - } else { - format!(" {line}") - } - })); - lines.extend([ - String::new(), - format!(" {approve_marker} Enter or Y: approve"), - format!(" {cancel_marker} {cancel_label}"), - ]); - if is_proposal { - lines.push(" Esc: back without changing".to_owned()); - } - lines.join("\n") -} - -fn render_onboarding_approval(app: &App, pending: &PendingApproval) -> String { - let approve_marker = if pending.selected == 0 { ">" } else { " " }; - let cancel_marker = if pending.selected == 1 { ">" } else { " " }; - let install_folder = setup_install_root(&app.paths, &app.config); - let pip_cache_dir = setup_pip_cache_dir(&app.paths, &app.config); - let mut output = String::new(); - if pending.title == "Uninstall" { - let _ = writeln!(output, "Uninstall ROCm"); - let _ = writeln!(output); - let _ = writeln!(output, "Folder"); - let _ = writeln!(output, " {}", install_folder.display()); - let _ = writeln!(output); - let _ = writeln!(output, " ROCm CLI will uninstall this ROCm install."); - let _ = writeln!(output, " You can install ROCm again from this screen."); - } else { - let verb = if pending.title == "Reinstall" { - "reinstall" - } else { - "install" - }; - let _ = writeln!( - output, - "{} ROCm", - if verb == "install" { - "Install" - } else { - "Reinstall" - } - ); - let _ = writeln!(output); - let _ = writeln!(output, "Folder"); - let _ = writeln!(output, " {}", install_folder.display()); - let _ = writeln!(output); - let _ = writeln!(output, " ROCm CLI will {verb} ROCm into this folder."); - let _ = writeln!( - output, - " {}", - onboarding_gpu_install_sentence(&app.host_gpu_summary) - ); - let _ = writeln!(output, " It will add the local AI pieces ROCm CLI needs."); - let _ = writeln!( - output, - " Downloaded files: {}", - display_runtime_folder_path(&pip_cache_dir) - ); - } - let _ = writeln!(output); - if pending.title == "Uninstall" { - let _ = writeln!(output, " {approve_marker} Enter or Y: uninstall ROCm"); - } else { - let _ = writeln!(output, " {approve_marker} Enter or Y: start setup"); - } - let _ = writeln!(output, " {cancel_marker} Esc or N: cancel"); - output.trim_end().to_owned() -} - -fn plan_request_mentions_cpu_mode(request: &str) -> bool { - request - .split(|ch: char| !ch.is_ascii_alphanumeric()) - .any(|word| word.eq_ignore_ascii_case("cpu")) -} - -fn plan_action_uses_cpu_device(action: &FreeformPlanAction) -> bool { - action.args.windows(2).any(|pair| { - pair.first().map(String::as_str) == Some("--device") - && matches!(pair.get(1).map(String::as_str), Some("cpu" | "cpu_only")) - }) -} - -fn render_plan_cpu_not_offered_text(request: &str) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Plan"); - let _ = writeln!(output); - let _ = writeln!(output, "Request"); - let _ = writeln!(output, " {}", request.trim()); - let _ = writeln!(output); - let _ = writeln!(output, "CPU mode is not offered"); - let _ = writeln!( - output, - " rocm-cli runs local models with ROCm GPU execution." - ); - let _ = writeln!( - output, - " Choose Edit request and ask for GPU, or pick a ROCm-capable model." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Edit request: change the request text"); - let _ = writeln!(output, " Back: leave without running anything"); - output.trim_end().to_owned() -} - -fn render_casual_input_screen_text(input: &str) -> String { - let mut output = String::new(); - let trimmed = input.trim(); - if trimmed.eq_ignore_ascii_case("thanks") || trimmed.eq_ignore_ascii_case("thank you") { - let _ = writeln!(output, "You're welcome."); - } else { - let _ = writeln!(output, "Hi."); - } - let _ = writeln!(output); - let _ = writeln!( - output, - "Tell ROCm CLI what you want to do, or choose an option from the list." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Common choices"); - let _ = writeln!(output, " Set up ROCm: install the ROCm Python folder"); - let _ = writeln!( - output, - " Check this computer: inspect the GPU and ROCm setup" - ); - let _ = writeln!(output, " Start a local model: run a model on your AMD GPU"); - let _ = writeln!(output, " Command list: show every command"); - output.trim_end().to_owned() -} - -fn render_unclear_plain_input_text(input: &str) -> String { - let mut output = String::new(); - let _ = writeln!(output, "I need a clearer ROCm request."); - let _ = writeln!(output); - let _ = writeln!(output, "You typed"); - let _ = writeln!(output, " {}", input.trim()); - let _ = writeln!(output); - let _ = writeln!(output, "Try one of these"); - let _ = writeln!(output, " Set up ROCm"); - let _ = writeln!(output, " Check this computer"); - let _ = writeln!(output, " Start a local model"); - let _ = writeln!(output); - let _ = writeln!(output, "Or type /help for all commands."); - output.trim_end().to_owned() -} - -fn render_plan_review_text(request: &str, action: Option<&FreeformPlanAction>) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Plan"); - let _ = writeln!(output); - let _ = writeln!(output, "Request"); - let _ = writeln!(output, " {}", request.trim()); - let _ = writeln!(output); - if let Some(action) = action { - let _ = writeln!(output, "Ready action"); - let _ = writeln!(output, " {}", action.title); - for line in plan_action_summary_lines(action) { - let _ = writeln!(output, " {line}"); - } - let _ = writeln!(output); - if action.has_placeholders { - let _ = writeln!(output, "Needs more detail"); - let _ = writeln!( - output, - " Some values are still missing. Choose Edit request and be more specific." - ); - } else if action.approval_required { - let _ = writeln!(output, "Before it runs"); - let _ = writeln!( - output, - " ROCm CLI will ask you to approve the change before it starts." - ); - } else { - let _ = writeln!(output, "Before it runs"); - let _ = writeln!(output, " This action only reads local information."); - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Run: review and start this action"); - let _ = writeln!(output, " Edit request: change the request text"); - let _ = writeln!(output, " Back: leave without running anything"); - } else { - let _ = writeln!(output, "Needs more detail"); - let _ = writeln!( - output, - " I could not turn this into a supported ROCm CLI action yet." - ); - let _ = writeln!( - output, - " Choose Edit request and include the model, engine, or task you want." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Edit request: change the request text"); - let _ = writeln!(output, " Back: leave without running anything"); - } - output.trim_end().to_owned() -} - -fn chat_command_prompt_args<'a>(args: &'a [&'a str]) -> (bool, Vec<&'a str>) { - let mut rocm_tools = false; - let mut prompt_args = Vec::new(); - for arg in args { - if *arg == "--tools" { - rocm_tools = true; - } else { - prompt_args.push(*arg); - } - } - (rocm_tools, prompt_args) -} - -fn render_chat_review_text(provider: &str, prompt: &str, rocm_tools: bool) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Chat"); - let _ = writeln!(output); - let _ = writeln!(output, "Assistant"); - if provider == "local" { - let _ = writeln!(output, " Local model on this computer"); - } else { - let _ = writeln!(output, " {provider}"); - } - let _ = writeln!(output); - let _ = writeln!(output, "ROCm help"); - if rocm_tools { - let _ = writeln!(output, " On. Read-only checks can run now."); - let _ = writeln!( - output, - " Installs, starts, stops, and changes still need a review card." - ); - } else { - let _ = writeln!(output, " Off."); - } - let _ = writeln!(output); - let _ = writeln!(output, "Prompt"); - let trimmed = prompt.trim(); - if trimmed.is_empty() { - let _ = writeln!(output, " "); - } else { - for line in trimmed.lines() { - let _ = writeln!(output, " {line}"); - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - let _ = writeln!(output, " Send: send this prompt to the assistant"); - let _ = writeln!(output, " Edit prompt: change the prompt text"); - let _ = writeln!(output, " Back: leave without sending anything"); - output.trim_end().to_owned() -} - -fn plan_action_summary_lines(action: &FreeformPlanAction) -> Vec { - if action.args.first().map(String::as_str) == Some("serve") { - let refs = action - .args - .iter() - .skip(1) - .map(String::as_str) - .collect::>(); - if let Ok(plan) = parse_serve_command_args(&refs) { - let engine = plan - .engine - .as_deref() - .unwrap_or(default_engine_for_platform()); - let host = plan.host.as_deref().unwrap_or(DEFAULT_LOCAL_HOST); - let port = plan.port.unwrap_or(DEFAULT_LOCAL_PORT); - let managed = if plan.managed { - "ROCm CLI will manage the server." - } else { - "This will only show the command unless you choose managed mode." - }; - return vec![ - format!( - "Model: {}", - plan.model.as_deref().unwrap_or("not chosen yet") - ), - format!("Engine: {engine}"), - format!( - "Device: {}", - serve_wizard_device_label(serve_plan_device_policy(plan.device.as_deref())) - ), - format!("Address: {}", format_http_base_url(host, port)), - managed.to_owned(), - ]; - } - } - plain_cli_approval_lines(&action.args) -} - -fn plain_approval_lines(app: &App, pending: &PendingApproval) -> Vec { - match &pending.action { - ApprovalAction::CliCommand { - args, explanation, .. - } => { - let mut lines = Vec::new(); - if let Some(explanation) = explanation - .as_deref() - .filter(|value| !value.trim().is_empty()) - { - lines.extend( - explanation - .trim() - .lines() - .take(4) - .map(|line| line.trim().to_owned()), - ); - } - lines.extend(plain_cli_approval_lines_with_gpu( - args, - Some(&app.host_gpu_summary), - )); - lines - } - ApprovalAction::RocmdCommand { kind, .. } => match kind { - RunningJobKind::ServiceLifecycle { action, service_id } => vec![format!( - "{} the managed service `{service_id}`.", - match action { - ServiceLifecycleAction::Stop => "Stop", - ServiceLifecycleAction::Restart => "Restart", - } - )], - _ => vec!["Run the selected managed service action.".to_owned()], - }, - ApprovalAction::EnableFullAccess => vec![ - "Allow ROCm CLI to run approved actions without asking every time.".to_owned(), - "You can turn this off later from Permissions.".to_owned(), - ], - ApprovalAction::Proposal { .. } => vec![ - "Run the reviewed automation action.".to_owned(), - "The result will stay in this review screen.".to_owned(), - ], - } -} - -fn plain_cli_approval_lines(args: &[String]) -> Vec { - plain_cli_approval_lines_with_gpu(args, None) -} - -fn plain_cli_approval_lines_with_gpu( - args: &[String], - gpu_summary: Option<&HostGpuSummary>, -) -> Vec { - match args.first().map(String::as_str) { - Some("install") if args.get(1).map(String::as_str) == Some("sdk") => { - let mut lines = - vec!["Install ROCm into a Python folder managed by ROCm CLI.".to_owned()]; - if let Some(summary) = gpu_summary { - lines.push(onboarding_gpu_install_sentence(summary)); - let mut gpu_lines = String::new(); - append_gpu_summary_section(&mut gpu_lines, summary); - lines.extend(gpu_lines.lines().map(str::to_owned)); - } else { - lines - .push("ROCm CLI will choose the AMD GPU package for this computer.".to_owned()); - } - if let Some(folder) = cli_arg_value(args, "--prefix") { - lines.push(format!("Folder: {folder}")); - if cli_arg_value(args, "--format").unwrap_or("wheel") == "wheel" { - lines.push(format!( - "Downloaded files: {}", - display_runtime_folder_path(&managed_pip_cache_dir(Path::new(folder))) - )); - } - } - lines - } - Some("runtimes") => match args.get(1).map(String::as_str) { - Some("activate") => vec![ - "Use this ROCm install as the default for new local engines.".to_owned(), - "Already-running services keep their current ROCm until restarted.".to_owned(), - ], - Some("uninstall" | "remove") => vec![ - "Remove the selected ROCm install from ROCm CLI.".to_owned(), - "External read-only folders are left on disk.".to_owned(), - ], - Some("rollback") => vec![ - "Switch the default ROCm install back to the previous one.".to_owned(), - "Already-running services keep their current ROCm until restarted.".to_owned(), - ], - Some("import") => vec![ - "Remember an existing ROCm install from a saved ROCm install file.".to_owned(), - "The external ROCm folder will not be changed.".to_owned(), - ], - Some("adopt") => vec![ - "Check an existing Python folder for TheRock ROCm.".to_owned(), - "If it is usable, remember it as a read-only ROCm install.".to_owned(), - ], - _ => vec!["Run the selected ROCm install action.".to_owned()], - }, - Some("engines") => match args.get(1).map(String::as_str) { - Some("install") => vec![ - "Set up the selected local AI engine.".to_owned(), - "It will use the active ROCm install.".to_owned(), - ], - _ => vec!["Run the selected engine action.".to_owned()], - }, - Some("comfyui") => match args.get(1).map(String::as_str) { - Some("install") => vec![ - "Download ComfyUI into ROCm CLI's app folder.".to_owned(), - "Keep the GPU packages ROCm already installed.".to_owned(), - "Show live install output here while it runs.".to_owned(), - ], - Some("start") => vec![ - "Start ComfyUI on this computer.".to_owned(), - format!( - "Address: {}", - format_http_base_url( - cli_arg_value(args, "--host").unwrap_or("127.0.0.1"), - cli_arg_value(args, "--port") - .and_then(|value| value.parse::().ok()) - .unwrap_or(8188) - ) - ), - "ROCm CLI will try to open the browser and will show the URL either way." - .to_owned(), - ], - _ => vec!["Show the ComfyUI status screen.".to_owned()], - }, - Some("update") => vec![ - "Check for a newer ROCm runtime and install it side-by-side if needed.".to_owned(), - "The current default only changes if you chose that option.".to_owned(), - ], - Some("automations") => match args.get(1).map(String::as_str) { - Some("enable") => vec![ - "Turn on this automation check.".to_owned(), - "ROCm CLI will still ask before making contained changes.".to_owned(), - ], - Some("disable") => vec!["Turn off this automation check.".to_owned()], - _ => vec!["Run the selected automation action.".to_owned()], - }, - Some("serve") => plain_serve_approval_lines(args), - Some("uninstall") => vec![ - "Remove the selected ROCm CLI-managed pieces.".to_owned(), - "The preview screen shows what will change.".to_owned(), - ], - _ => vec!["Run the selected action.".to_owned()], - } -} - -fn cli_approval_must_show_review(args: &[String]) -> bool { - matches!( - args.first().map(String::as_str), - Some("install") if args.get(1).map(String::as_str) == Some("sdk") - ) || matches!( - args.first().map(String::as_str), - Some("serve") if !args.iter().any(|arg| arg == "--foreground") - ) || matches!( - ( - args.first().map(String::as_str), - args.get(1).map(String::as_str) - ), - (Some("runtimes"), Some("uninstall" | "remove")) - ) -} - -fn cli_arg_value<'a>(args: &'a [String], name: &str) -> Option<&'a str> { - let mut index = 0; - while index < args.len() { - let arg = args[index].as_str(); - if let Some((option, value)) = arg.split_once('=') - && option == name - { - return Some(value); - } - if arg == name { - return args.get(index + 1).map(String::as_str); - } - index += 1; - } - None -} - -fn chat_approval_requests_comfyui_start(args: &[String]) -> bool { - matches!( - args, - [command, action, ..] - if command.eq_ignore_ascii_case("comfyui") - && action.eq_ignore_ascii_case("start") - ) -} - -fn chat_approval_serve_engine(args: &[String], config: &RocmCliConfig) -> Option { - if !args - .first() - .is_some_and(|command| command.eq_ignore_ascii_case("serve")) - { - return None; - } - cli_arg_value(args, "--engine") - .or(config.default_engine.as_deref()) - .or(Some(default_engine_for_platform())) - .and_then(|engine| canonical_engine_name(engine).map(str::to_owned)) -} - -fn canonical_engine_name(engine: &str) -> Option<&'static str> { - engine_inventory() - .iter() - .find_map(|(name, _)| name.eq_ignore_ascii_case(engine).then_some(*name)) -} - -fn assistant_requested_engine_from_prompt(lower: &str) -> Option<&'static str> { - for (name, _) in engine_inventory() { - if lower.contains(name) { - return Some(*name); - } - } - if lower.contains("llama cpp") { - return Some("llama.cpp"); - } - None -} - -fn assistant_engine_label(engine: &str) -> &str { - match engine { - "lemonade" => "Lemonade", - "pytorch" => "PyTorch", - "llama.cpp" => "llama.cpp", - "vllm" => "vLLM", - "sglang" => "SGLang", - "atom" => "ATOM", - _ => "This engine", - } -} - -fn plain_serve_approval_lines(args: &[String]) -> Vec { - let refs = args.iter().skip(1).map(String::as_str).collect::>(); - if let Ok(plan) = parse_serve_command_args(&refs) { - let engine = plan - .engine - .as_deref() - .unwrap_or(default_engine_for_platform()); - let host = plan.host.as_deref().unwrap_or(DEFAULT_LOCAL_HOST); - let port = plan.port.unwrap_or(DEFAULT_LOCAL_PORT); - let mut lines = vec![ - "Start the local model.".to_owned(), - format!( - "Model: {}", - plan.model.as_deref().unwrap_or("not chosen yet") - ), - format!("Engine: {engine}"), - ]; - if engine.eq_ignore_ascii_case("lemonade") { - lines.push("Runner: Lemonade manages its own ROCm runtime.".to_owned()); - } else { - lines.push( - plan.runtime_id - .as_deref() - .map(|runtime_id| format!("ROCm install: {runtime_id}")) - .or_else(|| { - plan.env_id - .as_deref() - .map(|env_id| format!("Engine env: {env_id}")) - }) - .unwrap_or_else(|| "ROCm install: active/default install".to_owned()), - ); - } - lines.extend([ - format!( - "Device: {}", - serve_wizard_device_label(serve_plan_device_policy(plan.device.as_deref())) - ), - format!("Address: {}", format_http_base_url(host, port)), - "First start may download model files.".to_owned(), - ]); - return lines; - } - vec![ - "Start the local model.".to_owned(), - "The server will use GPU ROCm support.".to_owned(), - "First start may download model files.".to_owned(), - ] -} - -fn render_runtime_manager_approval(app: &App, pending: &PendingApproval) -> String { - let mut output = render_screen_approval(app, pending); - let Some(action) = pending_runtime_action(pending) else { - return output; - }; - output.push_str("\n\nPlain English\n"); - match action { - "activate" => { - let Some(item) = app.selected_runtime_manager_item() else { - return output; - }; - let _ = writeln!( - output, - " New engines will use ROCm {} for {}.", - therock::runtime_version_display(&item.version), - item.family - ); - let _ = writeln!( - output, - " Already-running services keep their current runtime until restarted." - ); - } - "uninstall" | "remove" => { - let Some(item) = app.selected_runtime_manager_item() else { - return output; - }; - if item.active { - let _ = writeln!( - output, - " This is the active runtime, so ROCm CLI will clear the default runtime." - ); - } - if item.read_only || item.imported_from.is_some() { - let _ = writeln!( - output, - " ROCm CLI will forget this runtime. The external folder will stay." - ); - } else { - let _ = writeln!( - output, - " ROCm CLI will remove its managed SDK folder after checking ownership." - ); - } - } - "adopt" => { - let _ = writeln!( - output, - " ROCm CLI will remember an existing Python folder as a read-only ROCm install." - ); - let _ = writeln!(output, " It will not modify that external folder."); - let _ = writeln!( - output, - " Use it later from this Runtimes screen when you are ready." - ); - } - "import" => { - let _ = writeln!(output, " ROCm CLI will remember an existing ROCm install."); - let _ = writeln!(output, " It will not change the external ROCm folder."); - } - "rollback" => { - let _ = writeln!( - output, - " ROCm CLI will switch back to the previous runtime." - ); - } - _ => {} - } - output -} - -fn pending_runtime_action(pending: &PendingApproval) -> Option<&str> { - match &pending.action { - ApprovalAction::CliCommand { args, .. } - if args.first().map(String::as_str) == Some("runtimes") => - { - args.get(1).map(String::as_str) - } - _ => None, - } -} - -fn runtime_manager_running_text(app: &App, title: &str) -> String { - let mut output = String::new(); - let _ = writeln!(output, "{title} is running."); - if should_draw_running_job_modal(app) { - return output.trim_end().to_owned(); - } - let _ = writeln!(output); - let _ = writeln!(output, "Recent output"); - let recent = active_screen_message_text(app) - .map(|message| { - message - .lines() - .map(ToOwned::to_owned) - .collect::>() - }) - .unwrap_or_default(); - if recent.is_empty() { - let _ = writeln!(output, " Waiting for output..."); - } else { - for line in recent { - let _ = writeln!(output, " {}", display_stream_log_line(&line)); - } - } - output.trim_end().to_owned() -} - -#[cfg(test)] -#[cfg(test)] -fn running_job_modal_text( - app: &App, - job: &RunningJob, - live_output_limit: usize, - live_output_width: usize, - compact: bool, -) -> String { - let header = running_job_modal_header_text(app, job, compact); - let output = running_job_modal_output_text(app, live_output_limit, live_output_width); - if output.trim().is_empty() { - header - } else { - format!("{header}\n{output}") - } -} - -fn running_job_modal_header_text(app: &App, job: &RunningJob, compact: bool) -> String { - let mut output = String::new(); - if compact { - let _ = writeln!(output, "{} is working.", job.title); - let _ = writeln!(output, " {}", running_progress_bar(job.started_at)); - let _ = writeln!(output, "Live output"); - } else { - if let RunningJobKind::Planner { request, .. } = &job.kind { - let provider = app.config.planner_provider.as_deref().unwrap_or("provider"); - let request = sanitize_terminal_text(request).replace(['\r', '\n'], " "); - let _ = writeln!(output, "Planning with {provider}."); - let _ = writeln!(output); - let _ = writeln!(output, "Request"); - let _ = writeln!(output, " {request}"); - } else { - let _ = writeln!(output, "{} is working.", job.title); - } - let _ = writeln!(output); - let _ = writeln!(output, "Progress"); - let _ = writeln!(output, " {}", running_progress_bar(job.started_at)); - let _ = writeln!(output); - let _ = writeln!(output, "Live output"); - } - output.trim_end().to_owned() -} - -#[cfg(test)] -fn running_job_modal_output_text( - app: &App, - live_output_limit: usize, - live_output_width: usize, -) -> String { - let mut output = String::new(); - let recent = running_job_recent_output_visual_lines(app, live_output_limit, live_output_width); - if recent.is_empty() { - let _ = writeln!(output, "{}", running_job_empty_output_text(app)); - } else { - for line in recent { - let _ = writeln!(output, "{line}"); - } - } - output.trim_end().to_owned() -} - -fn running_job_modal_full_output_text(app: &App, live_output_width: usize) -> String { - let mut output = String::new(); - let lines = running_job_output_visual_lines(app, live_output_width); - if lines.is_empty() { - let _ = writeln!(output, "{}", running_job_empty_output_text(app)); - } else { - for line in lines { - let _ = writeln!(output, "{line}"); - } - } - output.trim_end().to_owned() -} - -fn running_job_empty_output_text(app: &App) -> &'static str { - match app.running_job.as_ref().map(|job| &job.kind) { - Some(RunningJobKind::ServiceLifecycle { - action: ServiceLifecycleAction::Stop, - .. - }) => " Stopping...", - Some(RunningJobKind::ServiceLifecycle { - action: ServiceLifecycleAction::Restart, - .. - }) => " Restarting...", - _ => " Starting...", - } -} - -fn running_progress_bar(started_at: Instant) -> String { - let width = 24usize; - let marker = ((started_at.elapsed().as_millis() / 75) as usize) % width; - let mut bar = String::with_capacity(width + 2); - bar.push('['); - for index in 0..width { - let distance = if index >= marker { - index - marker - } else { - width - marker + index - }; - bar.push(if distance < 6 { '#' } else { '-' }); - } - bar.push(']'); - format!("{bar} working") -} - -fn running_job_output_lines(app: &App) -> Vec { - if app.onboarding_install_running() { - if !app.running_job_output.is_empty() { - return app.running_job_output.iter().cloned().collect::>(); - } - return app - .onboarding_install_log - .iter() - .cloned() - .collect::>(); - } - let mut lines = app.running_job_output.iter().cloned().collect::>(); - if !lines.is_empty() { - return lines; - } - lines = active_screen_message_text(app) - .map(|message| { - message - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .map(ToOwned::to_owned) - .collect::>() - }) - .unwrap_or_default(); - if lines.is_empty() && !app.onboarding_install_log.is_empty() { - lines = app - .onboarding_install_log - .iter() - .cloned() - .collect::>(); - } - lines -} - -fn running_job_output_line_count(app: &App) -> usize { - running_job_output_lines(app).len() -} - -fn running_job_output_visual_lines(app: &App, width: usize) -> Vec { - let width = width.max(8); - let content_width = width.saturating_sub(2).max(8); - let mut visual = Vec::new(); - for line in running_job_output_lines(app) { - let line = display_stream_log_line(&line); - for wrapped in wrap_plain_line(&line, content_width) { - visual.push(format!(" {wrapped}")); - } - } - visual -} - -fn running_job_output_visual_line_count(app: &App, width: usize) -> usize { - running_job_output_visual_lines(app, width).len() -} - -fn running_job_visible_output_start(app: &App, limit: usize, width: usize) -> usize { - let lines = running_job_output_visual_lines(app, width); - let len = lines.len(); - if len <= limit { - return 0; - } - let max_offset = len.saturating_sub(limit); - let offset = running_job_log_scroll_offset(app).min(max_offset); - len.saturating_sub(offset) - .saturating_sub(limit) - .min(max_offset) -} - -fn running_job_log_scroll_offset(app: &App) -> usize { - if app.onboarding_install_running() { - app.onboarding_install_log_scroll - } else { - app.running_job_log_scroll - } -} - -#[cfg(test)] -fn running_job_recent_output_visual_lines(app: &App, limit: usize, width: usize) -> Vec { - let lines = running_job_output_visual_lines(app, width); - let len = lines.len(); - if len <= limit { - return lines; - } - let start = running_job_visible_output_start(app, limit, width); - lines - .into_iter() - .skip(start) - .take(limit) - .collect::>() -} - -fn display_stream_log_line(line: &str) -> String { - line.strip_prefix("output:") - .or_else(|| line.strip_prefix("Output:")) - .map_or(line, str::trim_start) - .to_owned() -} - -fn chat_tools_running_text_with_progress(provider: &str, progress: Option<&str>) -> String { - let mut output = String::new(); - if provider == "local" { - let _ = writeln!(output, "Local assistant is working."); - } else { - let _ = writeln!(output, "Assistant is working with {provider}."); - } - let _ = writeln!(output); - let _ = writeln!(output, "Progress"); - let _ = writeln!(output, " Asking what to check."); - let _ = writeln!(output, " Read-only ROCm checks may run now."); - if let Some(progress) = progress.filter(|value| !value.trim().is_empty()) { - let _ = writeln!(output); - let _ = writeln!(output, "Live progress"); - for line in progress.lines().take(10) { - let line = line.trim(); - if !line.is_empty() { - let _ = writeln!(output, " {line}"); - } - } - } - let _ = writeln!(output); - let _ = writeln!(output, "Safety"); - let _ = writeln!( - output, - " Installs, server starts, and other changes will pause for review." - ); - output.trim_end().to_owned() -} - -fn chat_session_running_text(provider: &str, progress: Option<&str>) -> String { - let mut output = String::new(); - if provider == "local" { - let _ = writeln!(output, "Assistant is thinking."); - } else { - let _ = writeln!(output, "Assistant is thinking with {provider}."); - } - let _ = writeln!( - output, - "ROCm checks may run quietly. Changes still need your OK." - ); - if progress.is_some_and(|value| !value.trim().is_empty()) { - let _ = writeln!(output, "Use ROCm result for details when a check finishes."); - } - output.trim_end().to_owned() -} - -fn local_chat_needs_server_message() -> String { - [ - "I need a local model before I can chat.", - "", - "Choose a model here, then press Enter on Start. ROCm CLI will show a review card before anything runs.", - "", - "First-time ROCm setup does not need chat. Use Set Up ROCm for that.", - "", - "Controls", - " Up/Down chooses a row.", - " Left/Right changes the highlighted choice.", - " Enter continues.", - ] - .join("\n") -} - -fn chat_tools_running_status(provider: &str) -> String { - if provider == "local" { - "Local assistant is checking ROCm. Read-only checks may run; changes still ask first." - .to_owned() - } else { - format!( - "Assistant is checking ROCm with {provider}. Read-only checks may run; changes still ask first." - ) - } -} - -fn active_screen_message_text(app: &App) -> Option { - app.runtime_manager - .as_ref() - .and_then(|state| state.message.clone()) - .or_else(|| { - app.install_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.engine_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.model_picker - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.serve_wizard - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.update_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.automations_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.provider_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.config_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.services_manager - .as_ref() - .and_then(|state| state.message.clone()) - }) - .or_else(|| { - app.command_screen - .as_ref() - .and_then(|state| state.message.clone()) - }) -} - -fn draw_onboarding(frame: &mut Frame<'_>, app: &App) { - if app.onboarding_success_modal { - frame.render_widget( - Block::default().style(Style::default().bg(THEME_BG)), - frame.area(), - ); - draw_onboarding_success_modal(frame, app, frame.area()); - return; - } - if should_draw_running_job_modal(app) { - frame.render_widget( - Block::default().style(Style::default().bg(THEME_BG)), - frame.area(), - ); - draw_running_job_modal(frame, app, frame.area()); - return; - } - - let action_height = if app.onboarding_success_modal { - 0 - } else if app.onboarding_path_editing || app.running_job.is_some() { - 3 - } else { - let row_count = onboarding_menu_choices(app).len(); - row_count as u16 + 2 - }; - let layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(8), - Constraint::Length(action_height), - Constraint::Length(1), - ]) - .split(frame.area()); - - let install_log_limit = onboarding_install_log_limit(layout[0].height); - let body = Paragraph::new(render_onboarding_screen_text_with_log_limit( - app, - install_log_limit, - )) - .block(surface_block( - "Set Up ROCm", - if setup_venv_ready(&app.paths, &app.config) { - THEME_GOOD - } else if app.onboarding_install_failure.is_some() || app.onboarding_setup_error.is_some() { - THEME_DANGER - } else if app.running_job.is_some() { - THEME_ACCENT - } else { - THEME_WARN - }, - )) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL)) - .wrap(Wrap { trim: false }); - frame.render_widget(body, layout[0]); - - let action_area = layout[1]; - let action_inner_width = action_area.width.saturating_sub(2); - let cursor_column = if app.onboarding_path_editing { - let (visible, cursor) = input_viewport(&app.input, app.input_cursor, action_inner_width); - let action = Paragraph::new(visible) - .block(surface_block("Folder", THEME_ACCENT)) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL)) - .wrap(Wrap { trim: false }); - frame.render_widget(action, action_area); - Some(cursor) - } else if app.running_job.is_some() { - let action = Paragraph::new(onboarding_primary_label(app)) - .block(surface_block("Setup", THEME_ACCENT)) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL)) - .wrap(Wrap { trim: false }); - frame.render_widget(action, action_area); - None - } else if app.pending_approval.is_some() { - let action = Paragraph::new("Review is open.") - .block(surface_block("Choose", THEME_BORDER)) - .style(Style::default().fg(THEME_MUTED).bg(THEME_PANEL)) - .wrap(Wrap { trim: false }); - frame.render_widget(action, action_area); - None - } else { - let choices = onboarding_menu_choices(app); - let items = choices - .iter() - .map(|choice| ListItem::new(onboarding_menu_label(app, *choice))) - .collect::>(); - let mut state = ListState::default(); - state.select(Some( - app.onboarding_selection - .min(choices.len().saturating_sub(1)), - )); - let menu = List::new(items) - .block(surface_block("Choose", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(menu, action_area, &mut state); - None - }; - - let footer_text = if app.folder_browser.is_some() { - "Enter open/use | Wheel/Page scroll | Esc" - } else if app.overlay_card.is_some() { - overlay_card_key_hint(app) - } else if app.onboarding_cancel_install_confirm { - "Up/Down choose | Enter select | Y stop | N/Esc keep waiting" - } else if app.pending_approval.is_some() { - "Up/Down choose | Enter/Y approve | N/Esc cancel" - } else if app.onboarding_path_editing { - "Enter use folder | Esc cancel | Ctrl-C review quit" - } else if app.onboarding_install_running() { - "PageUp/PageDown log | Ctrl-C stop install" - } else if app.running_job.is_some() { - "PageUp/PageDown log | Ctrl-C quit" - } else if onboarding_can_return(app) && onboarding_should_show_log_controls(app) { - "Up/Down choose | PageUp/PageDown log | Enter select | Esc back" - } else if onboarding_can_return(app) { - "Up/Down choose | Enter select | Esc back" - } else if onboarding_should_show_log_controls(app) { - "Up/Down choose | Left/Right folder | PageUp/PageDown log | Enter select | Esc quit" - } else { - "Up/Down choose | Left/Right folder | Enter select | Esc quit" - }; - let footer = Paragraph::new(footer_line(&app.status, footer_text, layout[2].width)).style( - Style::default() - .fg(Color::DarkGray) - .bg(THEME_BG) - .add_modifier(Modifier::DIM), - ); - frame.render_widget(footer, layout[2]); - - if let Some(cursor_column) = cursor_column.filter(|_| { - app.overlay_card.is_none() - && app.pending_approval.is_none() - && app.folder_browser.is_none() - && !app.onboarding_cancel_install_confirm - && !should_draw_running_job_modal(app) - }) { - frame.set_cursor_position(( - action_area - .x - .saturating_add(1) - .saturating_add(cursor_column) - .min(action_area.right().saturating_sub(2)), - action_area.y.saturating_add(1), - )); - } - - if app.pending_approval.is_some() { - draw_pending_approval_modal(frame, app, frame.area()); - } - if app.onboarding_cancel_install_confirm { - draw_onboarding_cancel_install_modal(frame, app, frame.area()); - } - if app.overlay_card.is_some() { - draw_overlay_card(frame, app, frame.area()); - } - if app.folder_browser.is_some() { - draw_folder_browser_modal(frame, app, frame.area()); - } -} - -fn draw_onboarding_success_modal(frame: &mut Frame<'_>, _app: &App, area: Rect) { - let modal = centered_rect(58, 36, area); - draw_modal_clear(frame, modal, area); - let block = surface_block(" Installed ", THEME_GOOD); - let inner = block.inner(modal); - frame.render_widget(block, modal); - let text = "ROCm installed successfully.\n\nPress any key to continue to ROCm CLI."; - let details = Paragraph::new(text) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3)) - .wrap(Wrap { trim: false }); - frame.render_widget(details, inner); -} - -fn draw_onboarding_cancel_install_modal(frame: &mut Frame<'_>, app: &App, area: Rect) { - let modal = centered_rect(64, 46, area); - draw_modal_clear(frame, modal, area); - let block = surface_block(" Stop ROCm Install? ", THEME_WARN); - let inner = block.inner(modal); - frame.render_widget(block, modal); - let chunks = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Min(6), - Constraint::Length(onboarding_cancel_install_choices().len() as u16 + 2), - ]) - .split(inner); - let details = Paragraph::new( - "ROCm is still installing.\n\nKeep waiting leaves the installer alone.\nStop install asks the running command to stop.", - ) - .style(Style::default().fg(THEME_TEXT).bg(THEME_PANEL_3)) - .wrap(Wrap { trim: false }); - frame.render_widget(details, chunks[0]); - - let choices = onboarding_cancel_install_choices(); - let items = choices - .iter() - .map(|choice| ListItem::new(*choice)) - .collect::>(); - let mut state = ListState::default(); - state.select(Some( - app.onboarding_cancel_install_selection - .min(choices.len().saturating_sub(1)), - )); - let list = List::new(items) - .block(surface_block("Choose", THEME_ACCENT)) - .highlight_symbol("> ") - .highlight_style(selection_style()); - frame.render_stateful_widget(list, chunks[1], &mut state); -} - -fn run_cli_command_streaming( - paths: &AppPaths, - args: &[String], - display_command: Option<&str>, - sender: mpsc::Sender, - cancel_requested: Arc, -) -> Result { - let current_exe = - current_executable_path().context("failed to resolve current rocm executable path")?; - let output = run_process_streaming(¤t_exe, args, sender, cancel_requested, Some(paths)) - .with_context(|| { - format!( - "failed to execute `{}`", - format_command_for_display(current_exe.as_os_str().to_string_lossy().as_ref(), args) - ) - })?; - render_command_output("rocm", args, output, display_command) -} - -fn run_rocmd_command_streaming( - paths: &AppPaths, - args: &[String], - sender: mpsc::Sender, - cancel_requested: Arc, -) -> Result { - let rocmd_binary = daemon_binary_path()?; - let output = run_process_streaming(&rocmd_binary, args, sender, cancel_requested, Some(paths)) - .with_context(|| { - format!( - "failed to execute `{}`", - format_command_for_display( - rocmd_binary.as_os_str().to_string_lossy().as_ref(), - args - ) - ) - })?; - render_command_output("rocmd", args, output, None) -} - -fn run_provider_chat_stream( - paths: &AppPaths, - provider: &str, - request: &ChatRequest, - sender: &mpsc::Sender, -) -> Result { - if request.rocm_tools { - let prompt = request - .messages - .iter() - .rev() - .find(|message| message.role == "user") - .map(|message| message.content.as_str()) - .unwrap_or_default(); - let mut progress = |line: String| { - let _ = sender.send(RunningJobEvent::Stream { - stream: CommandOutputStream::Stdout, - line, - }); - }; - let result = crate::render_chat_prompt_result_with_progress( - paths, - provider, - request.model.as_deref(), - prompt, - true, - Some(&mut progress), - )?; - return Ok(CommandOutput { - ok: true, - rendered: result.rendered, - chat_approval: result.approval, - }); - } - let mut emitted = 0_usize; - providers::provider_stream_chat_with_callback(paths, provider, request, &mut |event| { - if !event.content.is_empty() { - emitted = emitted.saturating_add(1); - let _ = sender.send(RunningJobEvent::ChatDelta { - content: event.content, - }); - } - Ok(()) - })?; - Ok(CommandOutput { - ok: true, - rendered: format!("provider: {provider}\nstreamed_chat: {emitted} event(s) shown in chat"), - chat_approval: None, - }) -} - -fn render_local_rocm_tools_chat_intro(service: Option<&ManagedServiceRecord>) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Local assistant"); - let _ = writeln!(output); - if let Some(record) = service { - let _ = writeln!(output, " Ready on this computer."); - let _ = writeln!( - output, - " Model: {}", - friendly_model_label(&record.canonical_model_id) - ); - let _ = writeln!(output, " Server details are available from the left."); - let _ = writeln!(output); - } - let _ = writeln!( - output, - "Ask me what you want to do. I can check ROCm, look at local model servers, read logs, and prepare safe actions." - ); - let _ = writeln!( - output, - "If something would install, start, stop, or change anything, ROCm CLI will show a review card first." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Try asking"); - let _ = writeln!(output, " Is my AMD GPU ready for local AI?"); - let _ = writeln!(output, " Which local models can this GPU run?"); - let _ = writeln!( - output, - " Help me choose a ROCm install folder, then install TheRock." - ); - let _ = writeln!( - output, - " Install the TheRock build from 06052026 into D:\\ROCm\\therock_venvs." - ); - let _ = writeln!(output, " Start a small local model."); - let _ = writeln!(output, " What local models are running?"); - let _ = writeln!(output, " Install or start ComfyUI for me."); - let _ = writeln!(output, " Set up llama.cpp or vLLM serving."); - output.trim_end().to_owned() -} - -fn chat_session_install_folder_request_text(args: &[String]) -> String { - let mut output = String::new(); - let _ = writeln!(output, "Sure. I can install ROCm/TheRock for you."); - let _ = writeln!(output); - let _ = writeln!( - output, - "Choose an install folder in the picker. Nothing downloads until the next review card." - ); - if let Some(date) = cli_arg_value(args, "--build-date") { - let _ = writeln!(output); - let _ = writeln!(output, "Requested build date: {date}"); - } - if let Some(version) = cli_arg_value(args, "--version") { - let _ = writeln!(output); - let _ = writeln!(output, "Requested version: {version}"); - } - let _ = writeln!(output); - let _ = writeln!( - output, - "Press Esc in the picker if you prefer typing or pasting a path." - ); - output.trim_end().to_owned() -} - -fn pending_install_folder_chat_reply(prompt: &str) -> Option<&'static str> { - let lower = prompt.trim().to_ascii_lowercase(); - if lower.is_empty() { - return Some( - "I am waiting for the install folder. Use the folder picker, or type a folder path.", - ); - } - if any_substring( - &lower, - &[ - "hello", - "hi", - "hey", - "are you there", - "can you hear me", - "you there", - ], - ) { - return Some( - "I am here. I have not installed ROCm yet. Choose an install folder when you are ready.", - ); - } - if any_substring( - &lower, - &[ - "installed", - "is it installed", - "did you install", - "already install", - "is rocm ready", - "is therock ready", - "is it ready", - ], - ) { - return Some( - "Not yet. ROCm/TheRock will not install until you choose a folder and approve the install card.", - ); - } - if any_substring( - &lower, - &["what do i do", "what now", "next", "how do i", "where"], - ) { - return Some( - "Choose a folder in the picker. A normal user folder is best. ROCm CLI will show the install card after that.", - ); - } - None -} - -fn pending_install_folder_prompt_wants_picker(lower: &str) -> bool { - matches!( - lower, - "yes" | "y" | "ok" | "okay" | "sure" | "go ahead" | "continue" - ) || any_substring( - lower, - &[ - "let me choose", - "let me pick", - "choose folder", - "choose a folder", - "pick folder", - "pick a folder", - "open picker", - "open folder picker", - "folder picker", - "ask me", - "show picker", - "use picker", - ], - ) -} - -fn chat_session_install_review_text(args: &[String]) -> String { - let mut output = String::new(); - let _ = writeln!( - output, - "I can install ROCm/TheRock into the folder you chose." - ); - if let Some(folder) = cli_arg_value(args, "--prefix") { - let _ = writeln!(output); - let _ = writeln!(output, "{folder}"); - } - if let Some(date) = cli_arg_value(args, "--build-date") { - let _ = writeln!(output); - let _ = writeln!(output, "Requested build date: {date}"); - } - if let Some(version) = cli_arg_value(args, "--version") { - let _ = writeln!(output); - let _ = writeln!(output, "Requested version: {version}"); - } - let _ = writeln!(output); - let _ = writeln!( - output, - "Review the card before anything downloads or changes." - ); - output.trim_end().to_owned() -} - -fn friendly_model_label(model: &str) -> String { - model - .rsplit(['/', '\\']) - .find(|part| !part.trim().is_empty()) - .unwrap_or(model) - .to_owned() -} - -fn compact_model_label(model: &str) -> String { - friendly_model_label(model) - .trim_end_matches("-Instruct") - .replace('-', " ") -} - -fn chat_session_connection_detail(session: &ChatSessionState) -> Option { - if session.service_id.is_none() && session.endpoint_url.is_none() && session.model.is_none() { - return None; - } - let mut output = String::new(); - let _ = writeln!(output, "Local assistant connection"); - let _ = writeln!(output); - if let Some(service_id) = session.service_id.as_deref() { - let _ = writeln!(output, "Server id: {service_id}"); - } - if let Some(endpoint) = session.endpoint_url.as_deref() { - let _ = writeln!(output, "Address: {endpoint}"); - } - if let Some(model) = session.model.as_deref() { - let _ = writeln!(output, "Model: {model}"); - } - let _ = writeln!(output); - let _ = writeln!( - output, - "These details are useful for debugging or connecting another app." - ); - Some(output.trim_end().to_owned()) -} - -fn latest_chat_session_tool_detail(session: &ChatSessionState) -> Option { - session - .turns - .iter() - .rev() - .find(|turn| turn.role == ChatSessionRole::Tool) - .and_then(|turn| turn.detail.clone()) -} - -fn chat_session_owns_command_title(title: &str) -> bool { - matches!( - title, - "Install" - | "Engine" - | "Serve" - | "Services" - | "Automations" - | "Update" - | "Runtimes" - | "Config" - | "Uninstall" - | "ComfyUI" - ) -} - -fn styled_chat_detail_text(raw: &str) -> Text<'static> { - let mut lines = Vec::new(); - for line in raw.lines() { - let trimmed = line.trim(); - let styled = match trimmed { - "You" => Line::from(vec![Span::styled( - "You", - Style::default() - .fg(THEME_ACCENT) - .add_modifier(Modifier::BOLD), - )]), - "Assistant" => Line::from(vec![Span::styled( - "Assistant", - Style::default().fg(THEME_WARN).add_modifier(Modifier::BOLD), - )]), - "ROCm command result" => Line::from(vec![Span::styled( - "ROCm command result", - Style::default().fg(THEME_GOOD).add_modifier(Modifier::BOLD), - )]), - _ if trimmed.starts_with("Qwen is starting") => Line::from(vec![Span::styled( - line.to_owned(), - Style::default().fg(THEME_WARN), - )]), - _ if trimmed.starts_with("Type naturally") - || trimmed.starts_with("Nothing changes") => - { - Line::from(vec![Span::styled( - line.to_owned(), - Style::default().fg(THEME_MUTED), - )]) - } - _ => Line::from(line.to_owned()), - }; - lines.push(styled); - } - if lines.is_empty() { - lines.push(Line::from(vec![Span::styled( - "Type a message below.", - Style::default().fg(THEME_MUTED), - )])); - } - Text::from(lines) -} - -fn render_chat_session_detail(app: &App, running_message: Option) -> String { - let Some(state) = app.command_screen.as_ref() else { - return String::new(); - }; - let Some(session) = state.chat_session.as_ref() else { - return state.detail.clone(); - }; - let mut output = String::new(); - if session.turns.is_empty() { - let _ = writeln!(output, "Type your message below."); - if let Some(model) = session.model.as_deref() { - let _ = writeln!(output, "Model: {}", friendly_model_label(model)); - let _ = writeln!(output); - } - let _ = writeln!( - output, - "Ask about ROCm setup, local models, engines, or app readiness." - ); - let _ = writeln!( - output, - "For installs, say the exact date or version you want. I will show a review card first." - ); - let _ = writeln!(output); - let _ = writeln!(output, "Try"); - let _ = writeln!(output, " Is my AMD GPU ready?"); - let _ = writeln!(output, " Which local models can this GPU run?"); - let _ = writeln!(output, " Install ComfyUI for me."); - } else { - for turn in &session.turns { - match turn.role { - ChatSessionRole::User => { - let _ = writeln!(output, "You"); - } - ChatSessionRole::Assistant => { - let _ = writeln!(output, "Assistant"); - } - ChatSessionRole::Tool => { - let _ = writeln!(output, "ROCm command result"); - } - } - for line in turn.content.trim().lines() { - let _ = writeln!(output, " {line}"); - } - let _ = writeln!(output); - } - } - if running_message.is_none() - && let Some(message) = state.message.as_deref() - { - let _ = writeln!(output); - let _ = writeln!(output, "{message}"); - } - if let Some(message) = running_message { - let _ = writeln!(output); - let _ = writeln!(output, "{message}"); - } - output.trim_end().to_owned() -} - -fn chat_session_display_text(rendered: &str) -> String { - let body = chat_rendered_body(rendered); - if let Some(answer) = chat_section_after(&body, "Assistant after ROCm checks") { - return format!("Here is what I found.\n\n{}", answer.trim()) - .trim_end() - .to_owned(); - } - if let Some(summary) = chat_rocm_cli_summary_text(&body) { - return format!( - "Here is what I found on this computer.\n\n{}", - summary.trim() - ) - .trim_end() - .to_owned(); - } - - let initial = chat_initial_response_text(&body); - let checks = chat_tool_summary_lines(&body); - let review_lines = checks - .iter() - .filter(|line| line.contains("needs your review")) - .cloned() - .collect::>(); - if !review_lines.is_empty() { - let mut output = String::new(); - let _ = writeln!( - output, - "The assistant wants to make a ROCm change. Review the approval card before anything runs." - ); - let _ = writeln!(output); - for line in review_lines { - let _ = writeln!(output, "{line}"); - } - return output.trim_end().to_owned(); - } - - let checked_lines = checks - .into_iter() - .filter(|line| line != "none requested") - .collect::>(); - if !checked_lines.is_empty() { - let mut output = String::new(); - if !initial.is_empty() { - let _ = writeln!(output, "{initial}"); - let _ = writeln!(output); - } - let _ = writeln!(output, "Here is what ROCm CLI checked."); - for line in checked_lines { - let _ = writeln!(output, " {line}"); - } - return output.trim_end().to_owned(); - } - - if !initial.is_empty() { - return initial; - } - - small_assistant_limit_message() -} - -fn chat_output_is_stream_completion_marker(rendered: &str) -> bool { - let mut has_provider = false; - let mut has_stream_marker = false; - for line in rendered.lines().map(str::trim) { - has_provider |= line.starts_with("provider:"); - has_stream_marker |= line.starts_with("streamed_chat:"); - } - has_provider && has_stream_marker -} - -fn small_assistant_limit_message() -> String { - "I did not get a useful answer from the local assistant.\n\nTry asking to check your GPU, install ROCm, install ComfyUI, or start a local model. For deeper troubleshooting, start the recommended larger assistant after ROCm/TheRock is installed.".to_owned() -} - -fn chat_session_cancelled_command_text(title: &str) -> String { - format!("User cancelled {title}. Nothing changed.") -} - -fn chat_session_command_result_text(title: &str, ok: bool, summary: &str) -> String { - if ok - && title == "ComfyUI" - && let Some(display) = chat_session_comfyui_success_text(summary) - { - return display; - } - let mut output = String::new(); - if ok { - let _ = writeln!(output, "{title} finished."); - } else { - let _ = writeln!(output, "{title} failed."); - } - let reason = chat_command_failure_reason(summary); - if let Some(reason) = reason.as_deref() { - let _ = writeln!(output, "{reason}"); - } - output.trim_end().to_owned() -} - -fn chat_session_command_result_detail(title: &str, ok: bool, summary: &str) -> String { - let mut output = String::new(); - if ok && title == "ComfyUI" { - if let Some(display) = chat_session_comfyui_success_text(summary) { - let _ = writeln!(output, "{display}"); - } else { - let _ = writeln!(output, "{title} finished."); - } - } else if ok { - let _ = writeln!(output, "{title} finished."); - } else { - let _ = writeln!(output, "{title} failed."); - } - let reason = chat_command_failure_reason(summary); - if let Some(reason) = reason.as_deref() { - let _ = writeln!(output, "{reason}"); - let _ = writeln!(output); - } - let detail_lines = chat_session_command_result_lines(title, ok, summary, reason.as_deref()); - if detail_lines.is_empty() { - let _ = writeln!(output, "No extra output was captured."); - } else { - for line in detail_lines { - let _ = writeln!(output, "{line}"); - } - } - output.trim_end().to_owned() -} - -fn chat_session_comfyui_success_text(summary: &str) -> Option { - let url = keyed_output_value(summary, "URL").or_else(|| keyed_output_value(summary, "url")); - let models = keyed_output_value(summary, "models path") - .or_else(|| keyed_output_value(summary, "models folder")); - if let Some(url) = url { - let mut output = "ComfyUI is running.".to_owned(); - output.push_str("\n\nOpen this URL:\n "); - output.push_str(&url); - if let Some(models) = models { - output.push_str("\n\nPut models here:\n "); - output.push_str(&models); - } - return Some(output); - } - - if comfyui_rendered_is_installed(summary) { - let mut output = "ComfyUI installed.".to_owned(); - output.push_str("\n\nStart ComfyUI:\n /comfyui start"); - if let Some(models) = models { - output.push_str("\n\nPut models here:\n "); - output.push_str(&models); - } - return Some(output); - } - - None -} - -fn chat_session_command_result_lines( - title: &str, - ok: bool, - summary: &str, - reason: Option<&str>, -) -> Vec { - const MAX_CHAT_COMMAND_RESULT_LINES: usize = 18; - let status_line = if ok { - format!("{title} finished.") - } else { - format!("{title} failed.") - }; - let mut lines = Vec::new(); - let mut skip_next_log_path = false; - for line in summary.lines() { - let trimmed = line.trim(); - if skip_next_log_path { - skip_next_log_path = false; - if looks_like_log_path(trimmed) { - continue; - } - } - if trimmed.is_empty() - || trimmed == status_line - || reason.is_some_and(|reason| trimmed == reason) - || trimmed.eq_ignore_ascii_case("Use /logs to inspect the full output.") - || trimmed.eq_ignore_ascii_case("Use /logs to browse saved ROCm CLI logs.") - || trimmed.starts_with("streamed_stdout:") - || trimmed.starts_with("streamed_stderr:") - { - continue; - } - if trimmed.eq_ignore_ascii_case("Full log") { - if !lines - .iter() - .any(|line| line == "More details are available in Logs.") - { - lines.push("More details are available in Logs.".to_owned()); - } - skip_next_log_path = true; - continue; - } - lines.push(trimmed.to_owned()); - } - if lines.len() <= MAX_CHAT_COMMAND_RESULT_LINES { - return lines; - } - let omitted = lines.len() - MAX_CHAT_COMMAND_RESULT_LINES; - let mut kept = lines - .into_iter() - .take(MAX_CHAT_COMMAND_RESULT_LINES) - .collect::>(); - kept.push(format!("... {omitted} more line(s) not shown here.")); - kept -} - -#[allow(clippy::case_sensitive_file_extension_comparisons)] // `text` is already lowercased above -fn looks_like_log_path(text: &str) -> bool { - let lower = text.to_ascii_lowercase(); - lower.ends_with(".log") - && (lower.contains("\\logs\\") - || lower.contains("/logs/") - || lower.contains("\\tui\\") - || lower.contains("/tui/")) -} - -fn chat_command_failure_reason(summary: &str) -> Option { - let candidates = summary - .lines() - .map(str::trim) - .filter(|line| { - !line.is_empty() - && !line.eq_ignore_ascii_case("Live output") - && !line.eq_ignore_ascii_case("Recent output") - && !line.eq_ignore_ascii_case("Command result") - && !line.eq_ignore_ascii_case("Full log") - && !looks_like_log_path(line) - }) - .collect::>(); - candidates - .iter() - .find(|line| { - let lower = line.to_ascii_lowercase(); - lower.starts_with("error:") - || lower.contains("error:") - || lower.contains("could not") - || lower.contains("not found") - || lower.contains("no matching distribution") - || lower.contains("access is denied") - || lower.contains("permission denied") - }) - .or_else(|| { - candidates.iter().find(|line| { - let lower = line.to_ascii_lowercase(); - lower.contains(" failed") && !lower.ends_with(" failed.") - }) - }) - .map(|line| (*line).to_owned()) -} - -fn chat_rendered_body(rendered: &str) -> String { - let mut lines = rendered.lines(); - let Some(first) = lines.next() else { - return String::new(); - }; - if first.trim() != "chat response" { - return rendered.trim().to_owned(); - } - for line in &mut lines { - if line.trim().is_empty() { - break; - } - } - lines.collect::>().join("\n").trim().to_owned() -} - -fn chat_section_after(body: &str, marker: &str) -> Option { - let mut found = false; - let mut lines = Vec::new(); - for line in body.lines() { - if found { - lines.push(line); - } else if line.trim() == marker { - found = true; - } - } - found - .then(|| lines.join("\n").trim().to_owned()) - .filter(|value| !value.is_empty()) -} - -fn chat_initial_response_text(body: &str) -> String { - let mut lines = Vec::new(); - for line in body.lines() { - if matches!( - line.trim(), - "ROCm checks used" | "ROCm CLI summary" | "Assistant after ROCm checks" - ) { - break; - } - if matches!( - line.trim(), - "Assistant is checking ROCm first." | "Assistant is preparing the next step." - ) { - continue; - } - lines.push(line); - } - lines.join("\n").trim().to_owned() -} - -fn chat_tool_summary_lines(body: &str) -> Vec { - let mut in_checks = false; - let mut lines = Vec::new(); - for line in body.lines() { - let trimmed = line.trim(); - if trimmed == "ROCm checks used" { - in_checks = true; - continue; - } - if trimmed == "ROCm CLI summary" || trimmed == "Assistant after ROCm checks" { - break; - } - if !in_checks || trimmed.is_empty() { - continue; - } - if line.starts_with(" ") && !line.starts_with(" ") { - lines.push(trimmed.to_owned()); - } - } - lines -} - -fn chat_rocm_cli_summary_text(body: &str) -> Option { - let mut in_summary = false; - let mut lines = Vec::new(); - for line in body.lines() { - let trimmed = line.trim(); - if trimmed == "ROCm CLI summary" { - in_summary = true; - continue; - } - if in_summary && trimmed == "Assistant after ROCm checks" { - break; - } - if !in_summary { - continue; - } - lines.push(line); - } - let summary = lines.join("\n").trim().to_owned(); - (!summary.is_empty()).then_some(summary) -} - -fn managed_service_is_chat_ready(record: &ManagedServiceRecord) -> bool { - matches!(record.status.as_str(), "ready" | "running") - && managed_service_endpoint_model_ready(record, TUI_SERVICE_READY_TIMEOUT).unwrap_or(false) -} - -fn load_visible_managed_services(paths: &AppPaths) -> Vec { - load_managed_services(paths) - .unwrap_or_default() - .into_iter() - .filter(managed_service_is_live) - .collect() -} - -fn tui_service_is_builtin_assistant(record: &ManagedServiceRecord) -> bool { - record - .canonical_model_id - .eq_ignore_ascii_case(VALIDATED_LOCAL_ASSISTANT_MODEL) -} - -fn managed_serve_command_launched_service(rendered: &str) -> bool { - rendered - .lines() - .any(|line| line.trim() == "managed service launched") -} - -fn managed_serve_service_id(rendered: &str) -> Option { - rendered.lines().find_map(|line| { - let trimmed = line.trim(); - trimmed - .strip_prefix("service_id:") - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_owned) - }) -} - -fn run_process_streaming( - binary: &PathBuf, - args: &[String], - sender: mpsc::Sender, - cancel_requested: Arc, - app_paths_env: Option<&AppPaths>, -) -> Result { - let mut command = ProcessCommand::new(binary); - command - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()); - if let Some(paths) = app_paths_env { - apply_app_path_env(&mut command, paths); - } - #[cfg(unix)] - { - use std::os::unix::process::CommandExt; - command.process_group(0); - } - let mut child = command.spawn()?; - - let stdout = child.stdout.take().context("child stdout was not piped")?; - let stderr = child.stderr.take().context("child stderr was not piped")?; - let stdout_sender = sender.clone(); - let stdout_reader = std::thread::spawn(move || { - read_streaming_pipe(stdout, CommandOutputStream::Stdout, stdout_sender) - }); - let stderr_reader = std::thread::spawn(move || { - read_streaming_pipe(stderr, CommandOutputStream::Stderr, sender) - }); - let mut cancelled = false; - let status = loop { - if cancel_requested.load(Ordering::SeqCst) && !cancelled { - cancelled = true; - terminate_child_process(&mut child); - } - if let Some(status) = child.try_wait().context("failed to poll command")? { - break status; - } - std::thread::sleep(Duration::from_millis(50)); - }; - let stdout = stdout_reader.join().unwrap_or_default(); - let stderr = stderr_reader.join().unwrap_or_default(); - - if cancelled { - bail!("command cancelled by user"); - } - - Ok(std::process::Output { - status, - stdout, - stderr, - }) -} - -fn apply_app_path_env(command: &mut ProcessCommand, paths: &AppPaths) { - command - .env("ROCM_CLI_CONFIG_DIR", &paths.config_dir) - .env("ROCM_CLI_DATA_DIR", &paths.data_dir) - .env("ROCM_CLI_CACHE_DIR", &paths.cache_dir); -} - -fn terminate_child_process(child: &mut std::process::Child) { - #[cfg(windows)] - { - let pid = child.id().to_string(); - let _ = ProcessCommand::new("taskkill") - .args(["/PID", pid.as_str(), "/T", "/F"]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } - #[cfg(unix)] - { - let process_group = format!("-{}", child.id()); - let _ = ProcessCommand::new("kill") - .args(["-TERM", "--", process_group.as_str()]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - std::thread::sleep(Duration::from_millis(250)); - let _ = ProcessCommand::new("kill") - .args(["-KILL", "--", process_group.as_str()]) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status(); - } - let _ = child.kill(); -} - -fn read_streaming_pipe( - pipe: R, - stream: CommandOutputStream, - sender: mpsc::Sender, -) -> Vec { - let mut reader = BufReader::new(pipe); - let mut collected = Vec::new(); - let mut fragment = Vec::new(); - let mut byte = [0_u8; 1]; - loop { - let read = reader.read(&mut byte).unwrap_or(0); - if read == 0 { - emit_stream_fragment(&fragment, stream, &sender); - break; - } - collected.push(byte[0]); - match byte[0] { - b'\r' | b'\n' => { - emit_stream_fragment(&fragment, stream, &sender); - fragment.clear(); - } - value => fragment.push(value), - } - } - collected -} - -fn emit_stream_fragment( - fragment: &[u8], - stream: CommandOutputStream, - sender: &mpsc::Sender, -) { - let text = String::from_utf8_lossy(fragment).trim().to_owned(); - if !text.is_empty() { - let _ = sender.send(RunningJobEvent::Stream { stream, line: text }); - } -} - -fn render_command_output( - display_binary: &str, - args: &[String], - output: std::process::Output, - display_command: Option<&str>, -) -> Result { - let stdout = sanitize_terminal_text(String::from_utf8_lossy(&output.stdout).as_ref()); - let stderr = sanitize_terminal_text(String::from_utf8_lossy(&output.stderr).as_ref()); - let mut rendered = String::new(); - rendered.push_str("command: "); - if let Some(display_command) = display_command { - rendered.push_str(display_command); - } else { - rendered.push_str(&format_command_for_display(display_binary, args)); - } - rendered.push('\n'); - rendered.push_str("status: "); - rendered.push_str(if output.status.success() { - "ok" - } else { - "failed" - }); - if let Some(code) = output.status.code() { - let _ = write!(rendered, " ({code})"); - } - rendered.push('\n'); - - if !stdout.is_empty() { - rendered.push('\n'); - rendered.push_str("stdout:\n"); - rendered.push_str(stdout.as_str()); - if !stdout.ends_with('\n') { - rendered.push('\n'); - } - } - if !stderr.is_empty() { - rendered.push('\n'); - rendered.push_str("stderr:\n"); - rendered.push_str(stderr.as_str()); - if !stderr.ends_with('\n') { - rendered.push('\n'); - } - } - - Ok(CommandOutput { - ok: output.status.success(), - rendered, - chat_approval: None, - }) -} - -fn sanitize_terminal_text(text: &str) -> String { - sanitize_terminal_text_inner(text.trim()) -} - -fn sanitize_chat_delta_text(text: &str) -> String { - sanitize_terminal_text_inner(text) -} - -fn sanitize_terminal_text_inner(text: &str) -> String { - let mut output = String::new(); - let mut chars = text.chars().peekable(); - while let Some(ch) = chars.next() { - if ch == '\u{1b}' { - if chars.peek() == Some(&'[') { - chars.next(); - for next in chars.by_ref() { - if ('@'..='~').contains(&next) { - break; - } - } - } - continue; - } - if ch == '\t' || ch == '\n' || ch == '\r' || !ch.is_control() { - output.push(ch); - } - } - output -} - -fn sanitized_stream_line(line: &str) -> Option { - let line = sanitize_terminal_text(line).replace(['\r', '\n'], " "); - let line = truncate_stream_line(&line); - if line.is_empty() { None } else { Some(line) } -} - -fn truncate_stream_line(line: &str) -> String { - const MAX_STREAM_LINE_CHARS: usize = 220; - if line.chars().count() <= MAX_STREAM_LINE_CHARS { - return line.to_owned(); - } - let mut output = line - .chars() - .take(MAX_STREAM_LINE_CHARS - 3) - .collect::(); - output.push_str("..."); - output -} - -fn final_output_for_transcript(rendered: &str, streamed: StreamedOutputCounts) -> String { - if streamed.total == 0 { - return rendered.to_owned(); - } - let mut lines = Vec::new(); - let mut section: Option = None; - for line in rendered.lines() { - match line { - "stdout:" => { - section = Some(CommandOutputStream::Stdout); - if streamed.stdout == 0 { - lines.push(line.to_owned()); - } - continue; - } - "stderr:" => { - section = Some(CommandOutputStream::Stderr); - if streamed.stderr == 0 { - lines.push(line.to_owned()); - } - continue; - } - _ => {} - } - if matches!(section, Some(CommandOutputStream::Stdout)) && streamed.stdout > 0 { - continue; - } - if matches!(section, Some(CommandOutputStream::Stderr)) && streamed.stderr > 0 { - continue; - } - if !line.trim().is_empty() || section.is_some() { - lines.push(line.to_owned()); - } - } - if streamed.stdout > 0 { - lines.push(format!( - "streamed_stdout: {} line(s) shown in live output while it ran", - streamed.stdout - )); - } - if streamed.stderr > 0 { - lines.push(format!( - "streamed_stderr: {} line(s) shown in live output while it ran", - streamed.stderr - )); - } - lines.join("\n") -} - -fn plain_command_completion_text( - title: &str, - ok: bool, - rendered: &str, - _streamed: StreamedOutputCounts, - recent_output: Option<&str>, - _full_log_path: Option<&Path>, - _full_log_error: Option<&str>, -) -> String { - let mut output = String::new(); - if ok { - let _ = writeln!(output, "{title} finished."); - return output.trim_end().to_owned(); - } - let _ = writeln!(output, "{title} failed."); - if title == "Serve" - && let Some(detail) = plain_command_failure_detail(rendered, recent_output) - { - let _ = writeln!(output); - let _ = writeln!(output, "{detail}"); - return output.trim_end().to_owned(); - } - if let Some(reason) = plain_command_failure_reason(rendered, recent_output) { - let _ = writeln!(output); - if reason.to_ascii_lowercase().starts_with("error:") { - let _ = writeln!(output, "{reason}"); - } else { - let _ = writeln!(output, "Error: {reason}"); - } - } - output.trim_end().to_owned() -} - -fn plain_command_failure_detail(rendered: &str, recent_output: Option<&str>) -> Option { - let mut lines = plain_command_failure_lines(rendered, recent_output); - let first = lines.first_mut()?; - if !first.to_ascii_lowercase().starts_with("error:") { - *first = format!("Error: {first}"); - } - Some(lines.join("\n")) -} - -fn plain_command_failure_reason(rendered: &str, recent_output: Option<&str>) -> Option { - plain_command_failure_lines(rendered, recent_output) - .into_iter() - .next() -} - -fn plain_command_failure_lines(rendered: &str, recent_output: Option<&str>) -> Vec { - recent_output - .filter(|recent| !recent.trim().is_empty()) - .into_iter() - .chain(std::iter::once(rendered)) - .flat_map(str::lines) - .map(str::trim) - .filter(|line| !line.is_empty()) - .filter(|line| { - !matches!(*line, "stdout:" | "stderr:") - && !line.starts_with("command:") - && !line.starts_with("status:") - && !line.starts_with("streamed_stdout:") - && !line.starts_with("streamed_stderr:") - && !line.eq_ignore_ascii_case("recent output") - && !line.eq_ignore_ascii_case("live output") - }) - .map(display_stream_log_line) - .filter(|line| !line.trim().is_empty()) - .collect() -} - -fn terse_screen_command_success_text( - paths: &AppPaths, - title: &str, - rendered: &str, -) -> Option { - match title { - "ComfyUI" => terse_comfyui_success_text(paths, rendered), - "Engine" => terse_engine_success_text(rendered), - _ => Some(generic_screen_command_success_text(title, rendered)), - } -} - -fn generic_screen_command_success_text(title: &str, rendered: &str) -> String { - let mut output = format!("{title} finished."); - let lines = command_rendered_user_lines(rendered); - if !lines.is_empty() { - output.push_str("\n\n"); - output.push_str(&lines.into_iter().take(6).collect::>().join("\n")); - } - output -} - -fn command_rendered_user_lines(rendered: &str) -> Vec { - rendered - .lines() - .map(str::trim) - .filter(|line| !line.is_empty()) - .filter(|line| { - !matches!(*line, "stdout:" | "stderr:") - && !line.starts_with("command:") - && !line.starts_with("status:") - && !line.starts_with("streamed_stdout:") - && !line.starts_with("streamed_stderr:") - }) - .map(display_stream_log_line) - .filter(|line| !line.trim().is_empty()) - .collect() -} - -fn terse_engine_success_text(rendered: &str) -> Option { - let engine = keyed_output_value(rendered, "engine"); - let env_path = keyed_output_value(rendered, "env_path"); - let is_ready = rendered - .lines() - .any(|line| line.trim().eq_ignore_ascii_case("engine ready")); - let is_install = rendered - .lines() - .any(|line| line.trim().eq_ignore_ascii_case("engine install")); - if !is_ready && !is_install { - return None; - } - - let mut output = match engine.as_deref() { - Some("pytorch") if is_ready => "PyTorch is ready from the ROCm folder.".to_owned(), - Some(engine) => format!("{engine} is ready."), - None => "Engine is ready.".to_owned(), - }; - if let Some(env_path) = env_path { - output.push_str("\n\nFolder: "); - output.push_str(&env_path); - } - Some(output) -} - -fn terse_comfyui_success_text(paths: &AppPaths, rendered: &str) -> Option { - let status = keyed_output_value(rendered, "status"); - let url = keyed_output_value(rendered, "URL").or_else(|| keyed_output_value(rendered, "url")); - if let Some(url) = url { - let mut output = String::from("ComfyUI is running."); - output.push_str("\n\nOpen this URL:\n "); - output.push_str(&url); - append_comfyui_tui_paths(&mut output, paths, Some(rendered)); - if let Some(status) = status - && !friendly_comfyui_status_is_noise(&status) - { - output.push_str("\nStatus: "); - output.push_str(&status); - } - return Some(output); - } - if comfyui_rendered_is_installed(rendered) { - let mut output = "ComfyUI installed.\n\nStart ComfyUI:\n /comfyui start".to_owned(); - append_comfyui_tui_paths(&mut output, paths, Some(rendered)); - return Some(output); - } - status.map(|value| format!("ComfyUI status: {value}.")) -} - -fn append_comfyui_tui_paths(output: &mut String, paths: &AppPaths, rendered: Option<&str>) { - if let Some(models) = comfyui_models_path(paths, rendered) { - let _ = writeln!(output); - let _ = writeln!(output, "Put models here:"); - let _ = writeln!(output, " {}", display_runtime_folder_path(&models)); - let _ = writeln!(output); - let _ = writeln!(output, "Show models path:"); - let _ = writeln!(output, " rocm comfyui models-path"); - } -} - -fn friendly_comfyui_status_is_noise(status: &str) -> bool { - let trimmed = status.trim(); - trimmed.eq_ignore_ascii_case("running") - || trimmed.eq_ignore_ascii_case("ok") - || trimmed.eq_ignore_ascii_case("ok (0)") - || trimmed.starts_with("ok ") -} - -fn comfyui_models_path(paths: &AppPaths, rendered: Option<&str>) -> Option { - if let Ok(Some(path)) = crate::comfyui::models_folder(paths) { - return Some(path); - } - - rendered - .and_then(|rendered| { - keyed_output_value(rendered, "models path") - .or_else(|| keyed_output_value(rendered, "models folder")) - }) - .filter(|value| !value.trim().is_empty()) - .map(PathBuf::from) - .or_else(|| { - rendered - .and_then(|rendered| keyed_output_value(rendered, "folder")) - .filter(|value| !value.trim().is_empty()) - .map(|folder| PathBuf::from(folder).join("source").join("models")) - }) -} - -fn comfyui_success_status(rendered: &str) -> Option { - keyed_output_value(rendered, "URL") - .or_else(|| keyed_output_value(rendered, "url")) - .map(|url| format!("ComfyUI is running at {url}")) - .or_else(|| { - comfyui_rendered_is_installed(rendered).then(|| "ComfyUI installed.".to_owned()) - }) - .or_else(|| { - keyed_output_value(rendered, "status").map(|status| format!("ComfyUI: {status}")) - }) -} - -fn comfyui_rendered_is_installed(rendered: &str) -> bool { - rendered.lines().any(|line| line.trim() == "installed: yes") - || keyed_output_values(rendered, "status") - .iter() - .any(|status| status.eq_ignore_ascii_case("installed")) - || keyed_output_values(rendered, "next step") - .iter() - .any(|step| { - step.eq_ignore_ascii_case("rocm comfyui start") - || step.eq_ignore_ascii_case("/comfyui start") - }) -} - -fn keyed_output_value(rendered: &str, key: &str) -> Option { - rendered.lines().find_map(|line| { - let trimmed = line.trim(); - let (name, value) = trimmed.split_once(':')?; - name.trim() - .eq_ignore_ascii_case(key) - .then(|| value.trim().to_owned()) - .filter(|value| !value.is_empty()) - }) -} - -fn keyed_output_values(rendered: &str, key: &str) -> Vec { - rendered - .lines() - .filter_map(|line| { - let trimmed = line.trim(); - let (name, value) = trimmed.split_once(':')?; - name.trim() - .eq_ignore_ascii_case(key) - .then(|| value.trim().to_owned()) - .filter(|value| !value.is_empty()) - }) - .collect() -} - -fn write_tui_command_full_log( - paths: &AppPaths, - title: &str, - rendered: &str, - recent_output: Option<&str>, -) -> Result { - let logs_dir = paths.data_dir.join("logs").join("tui"); - fs::create_dir_all(&logs_dir) - .with_context(|| format!("failed to create {}", logs_dir.display()))?; - let title_component = sanitize_component(title).trim_matches('-').to_owned(); - let title_component = if title_component.is_empty() { - "command".to_owned() - } else { - title_component - }; - let path = logs_dir.join(format!("{}-{title_component}.log", unix_time_millis())); - let mut body = String::new(); - let _ = writeln!(body, "title: {title}"); - let _ = writeln!(body, "created_ms: {}", unix_time_millis()); - if let Some(recent) = recent_output.filter(|recent| !recent.trim().is_empty()) { - let _ = writeln!(body); - let _ = writeln!(body, "recent_live_output:"); - for line in recent.lines() { - let _ = writeln!(body, "{line}"); - } - } - let _ = writeln!(body); - let _ = writeln!(body, "command_output:"); - body.push_str(rendered.trim_end()); - body.push('\n'); - fs::write(&path, body).with_context(|| format!("failed to write {}", path.display()))?; - Ok(path) -} - -fn serve_plan_device_policy(device: Option<&str>) -> &str { - match device { - Some("cpu" | "cpu_only") => "gpu_required", - Some("gpu_preferred") => "gpu_required", - Some("gpu" | "gpu_required") | None => "gpu_required", - Some(other) => other, - } -} - -fn truncate_command_output(rendered: &str) -> String { - let lines = rendered.lines().collect::>(); - if lines.len() <= MAX_COMMAND_OUTPUT_LINES { - return rendered.trim_end().to_owned(); - } - let mut output = lines[..MAX_COMMAND_OUTPUT_LINES].join("\n"); - let _ = write!( - output, - "\n... truncated {} line(s); use /logs or service logs for full output", - lines.len() - MAX_COMMAND_OUTPUT_LINES - ); - output -} - -fn format_command_for_display(binary: &str, args: &[String]) -> String { - let mut rendered = String::from(binary); - for arg in args { - rendered.push(' '); - if arg.contains(' ') { - rendered.push('"'); - rendered.push_str(arg); - rendered.push('"'); - } else { - rendered.push_str(arg); - } - } - rendered -} - -fn format_slash_command_for_display(command: &str, args: &[&str]) -> String { - let args = args.iter().map(|arg| (*arg).to_owned()).collect::>(); - format_command_for_display(&format!("/{command}"), &args) -} - -fn proposal_tool_name(proposal: &AutomationProposalRecord) -> Option<&str> { - proposal - .tool - .as_deref() - .or_else(|| fallback_tool_for_proposal_action(&proposal.action)) -} - -fn proposal_status_for_user(status: &str) -> &'static str { - match status { - "pending" => "waiting for review", - "approved" => "approved", - "completed" => "done", - "rejected" => "rejected", - "failed" => "failed", - _ => "not pending", - } -} - -fn ensure_prefetch_proposal(proposal: &AutomationProposalRecord) -> Result<()> { - if proposal_tool_name(proposal) == Some("prefetch_artifact") { - Ok(()) - } else { - bail!( - "proposal `{}` is not an artifact prefetch proposal", - proposal.proposal_id - ) - } -} - -fn proposal_arguments_object_mut( - proposal: &mut AutomationProposalRecord, -) -> &mut serde_json::Map { - if !proposal.arguments.is_object() { - proposal.arguments = serde_json::json!({}); - } - proposal - .arguments - .as_object_mut() - .expect("proposal arguments were normalized to an object") -} - -fn set_proposal_bool_argument(proposal: &mut AutomationProposalRecord, key: &str, value: bool) { - proposal_arguments_object_mut(proposal).insert(key.to_owned(), Value::Bool(value)); -} - -fn set_proposal_u64_argument(proposal: &mut AutomationProposalRecord, key: &str, value: u64) { - proposal_arguments_object_mut(proposal).insert( - key.to_owned(), - Value::Number(serde_json::Number::from(value)), - ); -} - -fn proposal_bool_argument(proposal: &AutomationProposalRecord, key: &str) -> bool { - proposal - .arguments - .get(key) - .and_then(Value::as_bool) - .unwrap_or(false) -} - -fn proposal_u64_argument(proposal: &AutomationProposalRecord, key: &str) -> Option { - proposal.arguments.get(key).and_then(Value::as_u64) -} - -fn render_proposal_approval(proposal: &AutomationProposalRecord) -> String { - let mut output = String::new(); - let tool = proposal_tool_name(proposal); - let _ = writeln!(output, "Review this request"); - let _ = writeln!(output, " {}", proposal_plain_title(tool)); - let _ = writeln!(output); - let _ = writeln!(output, "Why"); - let _ = writeln!(output, " {}", proposal_plain_reason(tool)); - let _ = writeln!(output); - let _ = writeln!(output, "What will happen"); - match tool { - Some("restart_server") => { - let _ = writeln!( - output, - " rocm-cli will restart the affected managed model server." - ); - let _ = writeln!( - output, - " It can only perform this reviewed restart action." - ); - } - Some("stop_server") => { - let _ = writeln!( - output, - " rocm-cli will stop the affected managed model server." - ); - if let Some(service_id) = proposal - .service_id - .as_deref() - .or_else(|| proposal.arguments.get("service_id").and_then(Value::as_str)) - { - let _ = writeln!(output, " Service: {service_id}"); - } - if let Some(model) = proposal.arguments.get("model_ref").and_then(Value::as_str) { - let _ = writeln!(output, " Model: {model}"); - } - if let Some(endpoint) = proposal - .arguments - .get("endpoint_url") - .and_then(Value::as_str) - { - let _ = writeln!(output, " Server: {endpoint}"); - } - if let Some(summary) = proposal - .arguments - .get("pressure_summary") - .and_then(Value::as_str) - { - let _ = writeln!(output, " Reason: {summary}"); - } - let _ = writeln!(output, " It can only perform this reviewed stop action."); - let _ = writeln!( - output, - " This will not shut down your computer, GPU, or driver." - ); - } - Some("driver_plan") => { - let _ = writeln!(output, " rocm-cli will show a driver plan only."); - let _ = writeln!(output, " No driver will be installed."); - } - Some("prefetch_artifact") => { - let artifact = proposal - .arguments - .get("artifact_ref") - .and_then(Value::as_str) - .unwrap_or(""); - let _ = writeln!(output, " rocm-cli will check this model file:"); - let _ = writeln!(output, " {artifact}"); - } - _ => { - let _ = writeln!( - output, - " rocm-cli will run the reviewed automation action." - ); - let _ = writeln!( - output, - " It can only perform this specific reviewed action." - ); - } - } - - if tool == Some("prefetch_artifact") { - let download_allowed = proposal_bool_argument(proposal, "allow_artifact_download"); - let hf_allowed = proposal_bool_argument(proposal, "allow_huggingface_download"); - let max_bytes = proposal_u64_argument(proposal, "artifact_max_bytes"); - let _ = writeln!(output); - let _ = writeln!(output, "Download permission"); - let _ = writeln!( - output, - " Downloads: {}", - if download_allowed { - "approved" - } else { - "not approved" - } - ); - if let Some(max_bytes) = max_bytes { - let _ = writeln!(output, " Max download: {max_bytes} bytes"); - } else { - let _ = writeln!(output, " Max download: not set"); - } - let _ = writeln!( - output, - " Hugging Face token: {}", - if hf_allowed { - "approved" - } else { - "not approved" - } - ); - } - - let _ = writeln!(output); - let _ = writeln!(output, "Controls"); - if tool == Some("driver_plan") { - let _ = writeln!( - output, - " Enter or Y: show the driver plan only; no driver will be installed" - ); - } else if tool == Some("prefetch_artifact") { - if proposal_bool_argument(proposal, "allow_artifact_download") { - let _ = writeln!( - output, - " Enter or Y: download this model file using the approved limits" - ); - } else { - let _ = writeln!( - output, - " Enter or Y: check the local cache only; no download will start" - ); - } - } else { - let _ = writeln!(output, " Enter or Y: approve and run this reviewed action"); - } - let _ = writeln!(output, " N: reject"); - let _ = writeln!(output, " Esc: back"); - if tool == Some("prefetch_artifact") { - let _ = writeln!(output); - let _ = writeln!(output, "Download settings"); - let _ = writeln!( - output, - " Select the rows under this request to change download permission and limits." - ); - let _ = writeln!( - output, - " Hugging Face token permission is only for gated Hugging Face HTTPS files." - ); - } - output.trim_end().to_owned() -} - -fn proposal_plain_title(tool: Option<&str>) -> &'static str { - match tool { - Some("restart_server") => "Restart a managed model server", - Some("stop_server") => "Stop a managed model server", - Some("driver_plan") => "Show a driver plan", - Some("prefetch_artifact") => "Check or download a model file", - _ => "Review an automation request", - } -} - -fn proposal_plain_reason(tool: Option<&str>) -> &'static str { - match tool { - Some("restart_server") => "rocm-cli noticed that a managed model server may need recovery.", - Some("stop_server") => { - "rocm-cli noticed that a managed model server may need to be stopped." - } - Some("driver_plan") => "A driver update signal was received.", - Some("prefetch_artifact") => "rocm-cli was asked to prepare this model file.", - _ => "An enabled automation asked for this reviewed action.", - } -} - -fn proposal_sandbox_args(proposal: &AutomationProposalRecord) -> Result> { - let tool = proposal - .tool - .as_deref() - .or_else(|| fallback_tool_for_proposal_action(&proposal.action)) - .with_context(|| { - format!( - "review request `{}` cannot be run; it does not include a supported action", - proposal.proposal_id - ) - })?; - let mut args = vec![ - "sandbox-run".to_owned(), - tool.to_owned(), - "--allow-native-fallback".to_owned(), - ]; - match tool { - "restart_server" | "stop_server" => { - let service_id = proposal - .service_id - .as_deref() - .or_else(|| proposal.arguments.get("service_id").and_then(Value::as_str)) - .with_context(|| { - format!( - "proposal `{}` requires a service_id for `{tool}`", - proposal.proposal_id - ) - })?; - args.push("--service-id".to_owned()); - args.push(service_id.to_owned()); - } - "prefetch_artifact" => { - let artifact_ref = proposal - .arguments - .get("artifact_ref") - .and_then(Value::as_str) - .with_context(|| { - format!( - "review request `{}` is missing the model file it should prepare", - proposal.proposal_id - ) - })?; - args.push("--artifact-ref".to_owned()); - args.push(artifact_ref.to_owned()); - let allow_download = proposal_bool_argument(proposal, "allow_artifact_download"); - let allow_huggingface = proposal_bool_argument(proposal, "allow_huggingface_download"); - if allow_huggingface && !allow_download { - bail!( - "Turn on Download before allowing Hugging Face token use for review request `{}`.", - proposal.proposal_id - ); - } - if allow_download { - let max_bytes = proposal_u64_argument(proposal, "artifact_max_bytes") - .with_context(|| { - format!( - "Set a max download limit before approving review request `{}`.", - proposal.proposal_id - ) - })?; - if max_bytes == 0 { - bail!( - "Set a max download limit greater than zero before approving review request `{}`.", - proposal.proposal_id - ); - } - args.push("--allow-artifact-download".to_owned()); - args.push("--artifact-max-bytes".to_owned()); - args.push(max_bytes.to_string()); - if allow_huggingface { - args.push("--allow-huggingface-download".to_owned()); - } - } - } - "notify_user" => { - args.push("--message".to_owned()); - args.push(proposal.message.clone()); - } - "check_updates" | "driver_plan" | "examine_snapshot" | "list_servers" => {} - _ => bail!( - "review request `{}` cannot be run; it asks for an unsupported action", - proposal.proposal_id - ), - } - Ok(args) -} - -fn approved_install_args(args: &[&str]) -> Vec { - let mut output = normalize_install_args_for_cli(args); - if args.first() == Some(&"driver") - && !args.contains(&"--yes") - && !args.contains(&"--dry-run") - && !args.contains(&"--preview") - && !args.contains(&"--reconcile") - { - output.push("--yes".to_owned()); - } - output -} - -fn chat_install_sdk_approval_needs_prefix(args: &[String]) -> bool { - args.first().map(String::as_str) == Some("install") - && args.get(1).map(String::as_str) == Some("sdk") - && cli_arg_value(args, "--prefix") - .map(str::trim) - .as_ref() - .is_none_or(|value| value.is_empty()) -} - -fn install_args_require_approval(args: &[&str]) -> Result { - if args.first() == Some(&"driver") { - let normalized = normalize_install_args_for_cli(args); - let normalized_refs = normalized.iter().map(String::as_str).collect::>(); - return super::driver_install_args_require_tui_approval(&normalized_refs); - } - Ok(true) -} - -fn normalize_install_args_for_cli(args: &[&str]) -> Vec { - args.iter() - .map(|value| { - if *value == "--preview" { - "--dry-run".to_owned() - } else { - (*value).to_owned() - } - }) - .collect() -} - -fn fallback_tool_for_proposal_action(action: &str) -> Option<&'static str> { - match action { - "queue_restart_proposal" => Some("restart_server"), - "queue_stop_server_proposal" => Some("stop_server"), - "queue_update_proposal" => Some("check_updates"), - "queue_prefetch_proposal" => Some("prefetch_artifact"), - "prepare_driver_plan" => Some("driver_plan"), - _ => None, - } -} - -fn record_proposal_audit( - paths: &AppPaths, - proposal: &AutomationProposalRecord, - action: &str, - level: &str, - message: &str, -) -> Result<()> { - append_audit_event( - paths, - &AuditEventRecord { - at_unix_ms: unix_time_millis(), - source: "rocm".to_owned(), - category: "proposal".to_owned(), - actor: "tui".to_owned(), - level: level.to_owned(), - action: action.to_owned(), - message: message.to_owned(), - watcher_id: Some(proposal.watcher_id.clone()), - service_id: proposal.service_id.clone(), - }, - ) -} - -fn load_gpu_static_cards() -> Result> { - let output = run_amd_smi_json(&["static", "-a", "-g", "all", "--json"])?; - load_gpu_static_cards_from_json(&output) -} - -fn load_gpu_static_cards_from_json(output: &str) -> Result> { - let payload = serde_json::from_str::(output) - .context("failed to parse amd-smi static JSON")?; - let mut cards = BTreeMap::new(); - for entry in payload.gpu_data { - cards.insert( - entry.gpu, - GpuStaticCard { - market_name: entry - .asic - .as_ref() - .and_then(|asic| asic.market_name.clone()), - gfx_target: entry - .asic - .as_ref() - .and_then(|asic| asic.target_graphics_version.clone()), - compute_units: entry.asic.as_ref().and_then(|asic| asic.num_compute_units), - }, - ); - } - Ok(cards) -} - -fn load_gpu_monitor_cards() -> Result> { - let output = run_amd_smi_json(&[ - "monitor", "-p", "-t", "-u", "-m", "-v", "-g", "all", "--json", - ])?; - load_gpu_monitor_cards_from_json(&output) -} - -fn load_gpu_monitor_cards_from_json(output: &str) -> Result> { - let payload = serde_json::from_str::>(output) - .context("failed to parse amd-smi monitor JSON")?; - let mut cards = BTreeMap::new(); - for entry in payload { - cards.insert( - entry.gpu, - GpuMonitorCard { - power_watts: entry - .power_usage - .as_ref() - .and_then(AmdSmiMetricValue::as_f64), - max_power_watts: entry.max_power.as_ref().and_then(AmdSmiMetricValue::as_f64), - hotspot_celsius: entry - .hotspot_temperature - .as_ref() - .and_then(AmdSmiMetricValue::as_f64), - memory_celsius: entry - .memory_temperature - .as_ref() - .and_then(AmdSmiMetricValue::as_f64), - gfx_mhz: entry.gfx_clk.as_ref().and_then(AmdSmiMetricValue::as_f64), - gfx_percent: entry.gfx.as_ref().and_then(AmdSmiMetricValue::as_f64), - mem_percent: entry.mem.as_ref().and_then(AmdSmiMetricValue::as_f64), - mem_mhz: entry.mem_clock.as_ref().and_then(AmdSmiMetricValue::as_f64), - vram_used_mb: entry.vram_used.as_ref().and_then(AmdSmiMetricValue::as_f64), - vram_total_mb: entry - .vram_total - .as_ref() - .and_then(AmdSmiMetricValue::as_f64), - vram_percent: entry - .vram_percent - .as_ref() - .and_then(AmdSmiMetricValue::as_f64), - }, - ); - } - Ok(cards) -} - -fn run_amd_smi_json(args: &[&str]) -> Result { - let amd_smi_binary = resolve_amd_smi_binary(); - let mut child = ProcessCommand::new(&amd_smi_binary) - .args(args) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .stderr(Stdio::piped()) - .spawn() - .context("failed to launch amd-smi")?; - let started = Instant::now(); - loop { - if let Some(_status) = child.try_wait().context("failed to poll amd-smi")? { - break; - } - if started.elapsed() >= GPU_AMD_SMI_TIMEOUT { - let _ = child.kill(); - let _ = child.wait(); - anyhow::bail!( - "amd-smi {} timed out after {}ms", - args.join(" "), - GPU_AMD_SMI_TIMEOUT.as_millis() - ); - } - std::thread::sleep(Duration::from_millis(25)); - } - - let output = child - .wait_with_output() - .context("failed to collect amd-smi output")?; - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_owned(); - let stdout = String::from_utf8_lossy(&output.stdout).trim().to_owned(); - let detail = if !stderr.is_empty() { - stderr - } else if !stdout.is_empty() { - stdout - } else { - format!("exit status {}", output.status) - }; - anyhow::bail!("amd-smi {} failed: {}", args.join(" "), detail); - } - String::from_utf8(output.stdout).context("amd-smi output was not valid utf-8") -} - -fn average(values: I) -> Option -where - I: Iterator, -{ - let mut sum = 0.0; - let mut count = 0_u32; - for value in values { - sum += value; - count += 1; - } - if count == 0 { - None - } else { - Some(sum / f64::from(count)) - } -} - -fn format_elapsed(duration: Duration) -> String { - if duration.as_secs() >= 60 { - format!( - "{}m {}s ago", - duration.as_secs() / 60, - duration.as_secs() % 60 - ) - } else if duration.as_secs() > 0 { - format!("{}s ago", duration.as_secs()) - } else { - format!("{}ms ago", duration.as_millis()) - } -} - -fn format_optional_percent(value: Option) -> String { - value.map_or_else(|| "--".to_owned(), |value| format!("{value:.0}%")) -} - -fn format_optional_watts(value: Option) -> String { - value.map_or_else(|| "--".to_owned(), |value| format!("{value:.0}W")) -} - -fn format_optional_celsius(value: Option) -> String { - value.map_or_else(|| "--".to_owned(), |value| format!("{value:.0}C")) -} - -fn format_optional_mhz(value: Option) -> String { - value.map_or_else(|| "--".to_owned(), |value| format!("{value:.0}MHz")) -} - -fn format_vram_line(card: Option<&GpuMonitorCard>) -> String { - let Some(card) = card else { - return "".to_owned(); - }; - match (card.vram_used_mb, card.vram_total_mb, card.vram_percent) { - (Some(used), Some(total), Some(percent)) => { - format!("{used:.0}/{total:.0}MB ({percent:.1}%)") - } - _ => "".to_owned(), - } -} - -#[cfg(test)] -mod tests { - use std::fmt::Write as _; - - use super::{ - App, ChatToolApprovalRequest, GpuMonitorCard, GpuTelemetry, InstallSdkChannel, - LOG_FOLLOW_REFRESH_INTERVAL, TuiMode, handle_key, load_gpu_monitor_cards_from_json, - load_gpu_static_cards_from_json, render_serve_command_plan, - }; - use crossterm::event::{ - Event, KeyCode, KeyEvent, KeyEventKind, KeyEventState, KeyModifiers, MouseEvent, - MouseEventKind, - }; - use ratatui::{Terminal, backend::TestBackend, buffer::Buffer, layout::Rect}; - use rocm_core::{ - AppPaths, AutomationProposalRecord, HostGpuSummary, ManagedServiceRecord, - PERMISSIONS_MODE_FULL_ACCESS, RocmCliConfig, TELEMETRY_MODE_LOCAL, TELEMETRY_MODE_OFF, - append_automation_proposal, find_automation_proposal, update_automation_proposal_status, - }; - use serde_json::Value; - use std::collections::BTreeMap; - use std::fs; - use std::io::{Cursor, Read as _, Write as _}; - use std::net::TcpListener; - use std::path::Path; - use std::sync::{ - Arc, Mutex, - atomic::{AtomicBool, AtomicU64, Ordering}, - mpsc, - }; - use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH}; - - static TEST_PATH_COUNTER: AtomicU64 = AtomicU64::new(0); - - #[derive(Default)] - struct TestProviderKeyStore { - secrets: Mutex>>, - fail: Option<&'static str>, - } - - impl TestProviderKeyStore { - fn failing(message: &'static str) -> Self { - Self { - secrets: Mutex::new(BTreeMap::new()), - fail: Some(message), - } - } - - fn with_secret(provider: &str, secret: &[u8]) -> Self { - let mut secrets = BTreeMap::new(); - secrets.insert(provider.to_owned(), secret.to_vec()); - Self { - secrets: Mutex::new(secrets), - fail: None, - } - } - - fn secret(&self, provider: &str) -> Option> { - self.secrets.lock().unwrap().get(provider).cloned() - } - } - - impl crate::provider_keys::ProviderKeyStore for TestProviderKeyStore { - fn label(&self) -> &'static str { - "test keychain" - } - - fn get_secret(&self, provider: &str) -> anyhow::Result>> { - if let Some(message) = self.fail { - anyhow::bail!("{message}"); - } - Ok(self.secrets.lock().unwrap().get(provider).cloned()) - } - - fn set_secret(&self, provider: &str, secret: &[u8]) -> anyhow::Result<()> { - if let Some(message) = self.fail { - anyhow::bail!("{message}"); - } - self.secrets - .lock() - .unwrap() - .insert(provider.to_owned(), secret.to_vec()); - Ok(()) - } - - fn clear_secret(&self, provider: &str) -> anyhow::Result<()> { - if let Some(message) = self.fail { - anyhow::bail!("{message}"); - } - self.secrets.lock().unwrap().remove(provider); - Ok(()) - } - } - - #[test] - fn parses_amd_smi_static_json() { - let payload = r#"{ - "gpu_data": [ - { - "gpu": 0, - "asic": { - "market_name": "Radeon RX", - "num_compute_units": 40, - "target_graphics_version": "gfx1103" - } - } - ] - }"#; - let cards = load_gpu_static_cards_from_json(payload).expect("static json should parse"); - let card = cards.get(&0).expect("gpu 0 should exist"); - assert_eq!(card.market_name.as_deref(), Some("Radeon RX")); - assert_eq!(card.gfx_target.as_deref(), Some("gfx1103")); - assert_eq!(card.compute_units, Some(40)); - } - - #[test] - fn parses_amd_smi_monitor_json() { - let payload = r#"[ - { - "gpu": 0, - "power_usage": { "value": 245, "unit": "W" }, - "hotspot_temperature": { "value": 63, "unit": "C" }, - "gfx": { "value": 98, "unit": "%" }, - "vram_total": { "value": 16384, "unit": "MB" }, - "vram_used": { "value": 8192, "unit": "MB" }, - "vram_percent": { "value": 50.0, "unit": "%" } - } - ]"#; - let cards = load_gpu_monitor_cards_from_json(payload).expect("monitor json should parse"); - let card = cards.get(&0).expect("gpu 0 should exist"); - assert_eq!(card.power_watts, Some(245.0)); - assert_eq!(card.hotspot_celsius, Some(63.0)); - assert_eq!(card.gfx_percent, Some(98.0)); - assert_eq!(card.vram_percent, Some(50.0)); - } - - #[test] - fn fallback_terminal_decodes_f1_sequences() { - let event = super::read_fallback_terminal_event(&mut Cursor::new(b"\x1bOP".as_slice())) - .expect("SS3 F1 sequence should decode"); - assert!(matches!( - event, - Event::Key(KeyEvent { - code: KeyCode::F(1), - .. - }) - )); - - let event = super::read_fallback_terminal_event(&mut Cursor::new(b"\x1b[11~".as_slice())) - .expect("CSI F1 sequence should decode"); - assert!(matches!( - event, - Event::Key(KeyEvent { - code: KeyCode::F(1), - .. - }) - )); - } - - #[test] - fn fallback_terminal_decodes_single_escape_without_second_key() { - let (sender, receiver) = mpsc::channel(); - sender.send(0x1b).expect("escape byte should send"); - let started = Instant::now(); - - let event = - super::read_fallback_terminal_event_from_receiver(&receiver, Duration::from_millis(1)) - .expect("single escape should decode"); - - assert!( - started.elapsed() < Duration::from_millis(100), - "single Escape should not wait for another key" - ); - assert!(matches!( - event, - Event::Key(KeyEvent { - code: KeyCode::Esc, - .. - }) - )); - } - - #[test] - fn fallback_terminal_decodes_mouse_wheel_sequences() { - let event = - super::read_fallback_terminal_event(&mut Cursor::new(b"\x1b[<64;10;5M".as_slice())) - .expect("scroll-up sequence should decode"); - assert!(matches!( - event, - Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 9, - row: 4, - .. - }) - )); - - let event = - super::read_fallback_terminal_event(&mut Cursor::new(b"\x1b[<65;10;5M".as_slice())) - .expect("scroll-down sequence should decode"); - assert!(matches!( - event, - Event::Mouse(MouseEvent { - kind: MouseEventKind::ScrollDown, - column: 9, - row: 4, - .. - }) - )); - } - - #[test] - fn fallback_terminal_size_uses_columns_and_lines_values() { - let size = super::fallback_terminal_size_from_values(Some("119"), Some("37")) - .expect("valid terminal env size"); - assert_eq!(size.width, 119); - assert_eq!(size.height, 37); - - assert!(super::fallback_terminal_size_from_values(Some("1"), Some("37")).is_none()); - assert!(super::fallback_terminal_size_from_values(Some("119"), Some("0")).is_none()); - assert!(super::fallback_terminal_size_from_values(Some("wide"), Some("37")).is_none()); - } - - #[test] - fn gpu_sidebar_reports_unavailable_after_amd_smi_error() { - let mut telemetry = GpuTelemetry::default(); - telemetry.record_error("monitor: failed to launch amd-smi".to_owned()); - - let rendered = telemetry.sidebar_text(); - - assert!(rendered.contains("status: live usage unavailable")); - assert!(!rendered.contains("status: checking GPU")); - assert!(rendered.contains("note: live GPU usage is not available in this terminal")); - assert!(!rendered.contains("failed to launch amd-smi")); - } - - #[test] - fn gpu_command_respects_disabled_telemetry_policy() -> anyhow::Result<()> { - let mut app = test_app(); - app.config.telemetry.mode = rocm_core::TELEMETRY_MODE_OFF.to_owned(); - app.config.save(&app.paths)?; - - assert!(app.handle_command("gpu refresh")); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let detail = app - .command_screen - .as_ref() - .expect("gpu command should open a screen") - .detail - .clone(); - assert!(detail.contains("GPU status:")); - assert!(detail.contains("status: turned off")); - assert!(detail.contains("does not send GPU data anywhere")); - assert_eq!(app.status, "GPU local checks are turned off."); - Ok(()) - } - - #[test] - fn gpu_command_usage_is_not_masked_when_telemetry_is_disabled() -> anyhow::Result<()> { - let mut app = test_app(); - app.config.telemetry.mode = rocm_core::TELEMETRY_MODE_OFF.to_owned(); - app.config.save(&app.paths)?; - - assert!(app.handle_command("gpu nonsense")); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let detail = app - .command_screen - .as_ref() - .expect("gpu usage should open a screen") - .detail - .clone(); - assert!(detail.contains("GPU status is shown here.")); - assert!(!detail.contains("status: disabled")); - assert_eq!(app.status, "GPU option was not recognized."); - Ok(()) - } - - #[test] - fn sidebar_disabled_telemetry_clears_stale_gpu_readings() -> anyhow::Result<()> { - let mut app = test_app(); - app.gpu_telemetry.monitor_cards = load_gpu_monitor_cards_from_json( - r#"[{"gpu":0,"gfx":{"value":99},"vram_total":{"value":4096}}]"#, - )?; - app.config.telemetry.mode = rocm_core::TELEMETRY_MODE_OFF.to_owned(); - app.config.save(&app.paths)?; - - app.refresh_sidebar_state(); - let rendered = app.sidebar_text(); - - assert!(app.gpu_telemetry.monitor_cards.is_empty()); - assert!(rendered.contains("status: turned off")); - assert!(rendered.contains("choose Settings to turn local checks on")); - assert!(!rendered.contains("GPUs:")); - assert!(!rendered.contains("99%")); - Ok(()) - } - - #[test] - fn ctrl_r_with_disabled_telemetry_clears_gpu_cache() -> anyhow::Result<()> { - let mut app = test_app(); - app.gpu_telemetry.monitor_cards = load_gpu_monitor_cards_from_json( - r#"[{"gpu":0,"gfx":{"value":88},"vram_total":{"value":4096}}]"#, - )?; - app.config.telemetry.mode = rocm_core::TELEMETRY_MODE_OFF.to_owned(); - app.config.save(&app.paths)?; - - handle_key( - &mut app, - key_event(KeyCode::Char('r'), KeyModifiers::CONTROL), - ); - - assert!(app.gpu_telemetry.monitor_cards.is_empty()); - assert_eq!( - app.status, - "Status refreshed; GPU local checks are turned off." - ); - Ok(()) - } - - #[test] - fn unknown_telemetry_mode_shows_fix_command() { - let mut app = test_app(); - app.config.telemetry.mode = "mystery-mode".to_owned(); - - let rendered = app.sidebar_text(); - - assert!(rendered.contains("saved telemetry setting `mystery-mode` is not recognized")); - assert!(rendered.contains("choose Settings to pick a valid option")); - } - - #[test] - fn sidebar_shows_current_tui_mode() { - let app = test_app(); - - assert!(!app.sidebar_text().contains("mode: ask")); - } - - #[test] - fn sidebar_hides_internal_paths_and_runtime_keys_by_default() { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - ) - .expect("ready runtime fixture should be written"); - app.config.setup.therock_venv = Some( - app.paths - .data_dir - .join("runtimes") - .join("wheel") - .join("release-pip-gfx120x-all-7-14-0"), - ); - - let rendered = app.sidebar_text(); - - assert!(rendered.contains("ROCm CLI")); - assert!(rendered.contains("Setup: ready")); - assert!(rendered.contains("Choose Run setup check for details.")); - assert!(!rendered.contains("config:")); - assert!(!rendered.contains("data:")); - assert!(!rendered.contains("cache:")); - assert!(!rendered.contains("active runtime key")); - assert!(!rendered.contains("release-pip-gfx120x-all")); - } - - #[test] - fn sidebar_does_not_treat_stale_runtime_keys_or_plain_folders_as_ready() { - let mut app = test_app(); - let plain_folder = app.paths.data_dir.join("plain-folder"); - fs::create_dir_all(&plain_folder).expect("plain folder should be created"); - app.config.setup.therock_venv = Some(plain_folder); - app.config.default_runtime_id = Some("therock-release:gfx120X-all".to_owned()); - app.config.active_runtime_key = Some("missing-runtime".to_owned()); - - let rendered = app.sidebar_text(); - - assert!(rendered.contains("Setup: not set up")); - } - - #[test] - fn sidebar_recovers_ready_setup_from_local_runtime_manifest_when_registry_is_missing() - -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-local-only"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root); - app.config.save(&app.paths)?; - let old_registry_dir = app.paths.data_dir.join("runtimes").join("registry"); - fs::remove_dir_all(&old_registry_dir)?; - - app.rebase_paths_to_saved_setup_folder(); - let restored_registry_path = app - .paths - .data_dir - .join("runtimes") - .join("registry") - .join(format!("{runtime_key}.json")); - - assert!(super::setup_venv_ready(&app.paths, &app.config)); - assert!( - restored_registry_path.is_file(), - "setup runtime registry should be restored from the venv-local runtime manifest" - ); - let rendered = app.sidebar_text(); - - assert!(rendered.contains("Setup: ready")); - Ok(()) - } - - #[test] - fn slash_commands_update_tui_mode() { - let mut app = test_app(); - - assert!(app.handle_command("logs")); - assert_eq!(app.mode, TuiMode::Logs); - assert!(!app.sidebar_text().contains("mode: logs")); - - assert!(app.handle_command("serve tiny.gguf --engine llama.cpp")); - assert_eq!(app.mode, TuiMode::Serve); - - assert!(app.handle_command("automations")); - assert_eq!(app.mode, TuiMode::Automations); - - assert!(app.handle_command("install")); - assert_eq!(app.mode, TuiMode::Act); - } - - #[test] - fn config_telemetry_completion_accepts_unique_mode() { - let mut app = test_app(); - app.input = "/config set-telemetry l".to_owned(); - app.input_cursor = app.input.len(); - - assert!(app.accept_completion()); - - assert_eq!(app.input, "/config set-telemetry local "); - } - - #[test] - fn config_telemetry_completion_status_updates_after_command() -> anyhow::Result<()> { - let mut app = test_app(); - app.gpu_telemetry.monitor_cards = load_gpu_monitor_cards_from_json( - r#"[{"gpu":0,"gfx":{"value":77},"vram_total":{"value":4096}}]"#, - )?; - app.config.telemetry.mode = rocm_core::TELEMETRY_MODE_OFF.to_owned(); - app.config.save(&app.paths)?; - - app.complete_running_job( - "Config".to_owned(), - super::RunningJobKind::Cli, - Ok(super::CommandOutput { - ok: true, - rendered: "telemetry mode set to off".to_owned(), - chat_approval: None, - }), - super::StreamedOutputCounts::default(), - ); - - assert!(app.gpu_telemetry.monitor_cards.is_empty()); - assert_eq!(app.status, "Telemetry disabled; amd-smi polling stopped."); - Ok(()) - } - - #[test] - fn config_telemetry_local_completion_status_updates_after_command() -> anyhow::Result<()> { - let mut app = test_app(); - app.config.telemetry.mode = rocm_core::TELEMETRY_MODE_LOCAL.to_owned(); - app.config.save(&app.paths)?; - - app.complete_running_job( - "Config".to_owned(), - super::RunningJobKind::Cli, - Ok(super::CommandOutput { - ok: true, - rendered: "telemetry mode set to local".to_owned(), - chat_approval: None, - }), - super::StreamedOutputCounts::default(), - ); - - assert_eq!( - app.status, - "Telemetry set to local; local amd-smi inspection is enabled." - ); - Ok(()) - } - - #[test] - fn activity_log_keeps_recent_bounded_events() { - let mut app = test_app(); - for index in 0..(super::MAX_ACTIVITY_LOG_ITEMS + 2) { - app.record_activity(format!("event-{index}")); - } - - let rendered = app.activity_text(); - - assert!(!rendered.contains("event-0")); - assert!(!rendered.contains("event-1")); - assert!(rendered.contains("event-2")); - assert!(rendered.contains(&format!("event-{}", super::MAX_ACTIVITY_LOG_ITEMS + 1))); - let newest = format!("event-{}", super::MAX_ACTIVITY_LOG_ITEMS + 1); - assert_eq!(rendered.lines().next(), Some(newest.as_str())); - assert_eq!(app.activity_log.len(), super::MAX_ACTIVITY_LOG_ITEMS); - } - - #[test] - fn activity_log_records_cli_completion() { - let mut app = test_app(); - - app.complete_running_job( - "Examine".to_owned(), - super::RunningJobKind::Cli, - Ok(super::CommandOutput { - ok: true, - rendered: "examine ok".to_owned(), - chat_approval: None, - }), - super::StreamedOutputCounts::default(), - ); - - assert!(app.activity_text().contains("completed: Examine")); - } - - #[test] - fn initial_activity_log_loads_recent_lifecycle_lines() -> anyhow::Result<()> { - let (root, paths) = test_paths("activity-lifecycle"); - let logs_dir = paths.data_dir.join("logs"); - fs::create_dir_all(&logs_dir)?; - fs::write( - logs_dir.join("cli-lifecycle.log"), - "1 level=info category=a action=one message=first\n2 level=info category=b action=two message=second\n3 level=info category=c action=three message=third\n", - )?; - - let log = super::initial_activity_log(&paths); - let rendered = log.iter().cloned().collect::>().join("\n"); - let display = { - let mut app = test_app(); - app.activity_log = log; - app.activity_text() - }; - - assert!(rendered.contains("log: two - second")); - assert!(rendered.contains("log: three - third")); - assert!(rendered.contains("TUI ready.")); - assert!(!rendered.contains("log: one")); - assert_eq!(display.lines().next(), Some("TUI ready.")); - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn initial_activity_log_first_run_does_not_create_logs_dir() { - let (root, paths) = test_paths("activity-first-run"); - - let log = super::initial_activity_log(&paths); - - assert_eq!(log.len(), 1); - assert_eq!(log.front().map(String::as_str), Some("TUI ready.")); - assert!(!paths.data_dir.join("logs").exists()); - fs::remove_dir_all(root).ok(); - } - - #[test] - fn initial_activity_log_truncates_long_lifecycle_lines() -> anyhow::Result<()> { - let (root, paths) = test_paths("activity-lifecycle-long"); - let logs_dir = paths.data_dir.join("logs"); - fs::create_dir_all(&logs_dir)?; - fs::write( - logs_dir.join("cli-lifecycle.log"), - format!( - "1 level=info category=a action=long service_id= message={}\n", - "x".repeat(260) - ), - )?; - - let log = super::initial_activity_log(&paths); - let lifecycle = log - .iter() - .find(|line| line.starts_with("log: long")) - .expect("lifecycle line should be present"); - - assert_eq!(lifecycle.len(), 180); - assert!(lifecycle.ends_with("...")); - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn activity_lifecycle_reader_uses_bounded_tail() -> anyhow::Result<()> { - let (root, paths) = test_paths("activity-lifecycle-tail"); - let logs_dir = paths.data_dir.join("logs"); - fs::create_dir_all(&logs_dir)?; - let log_path = logs_dir.join("cli-lifecycle.log"); - fs::write( - &log_path, - format!( - "{}\n9 level=info category=z action=last service_id= message=tail\n", - "old".repeat(80_000) - ), - )?; - - let tail = super::read_text_tail(&log_path, 256)?; - - assert!(!tail.starts_with("oldoldold")); - assert!(tail.contains("action=last")); - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn initial_activity_log_drops_partial_tail_line() -> anyhow::Result<()> { - let (root, paths) = test_paths("activity-lifecycle-partial-tail"); - let logs_dir = paths.data_dir.join("logs"); - fs::create_dir_all(&logs_dir)?; - fs::write( - logs_dir.join("cli-lifecycle.log"), - format!( - "{}\n8 level=info category=y action=recent service_id= message=valid\n9 level=info category=z action=last service_id= message=tail\n", - "old".repeat(80_000) - ), - )?; - - let log = super::initial_activity_log(&paths); - let rendered = log.iter().cloned().collect::>().join("\n"); - - assert!(rendered.contains("log: recent - valid")); - assert!(rendered.contains("log: last - tail")); - assert!(rendered.contains("TUI ready.")); - assert!(!rendered.contains("unknown")); - assert!(!rendered.contains("oldold")); - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn activity_lifecycle_reader_keeps_boundary_aligned_line() -> anyhow::Result<()> { - let (root, paths) = test_paths("activity-lifecycle-boundary-tail"); - let logs_dir = paths.data_dir.join("logs"); - fs::create_dir_all(&logs_dir)?; - let log_path = logs_dir.join("cli-lifecycle.log"); - let first = "1 level=info category=x action=old service_id= message=old\n"; - let second = "2 level=info category=y action=boundary service_id= message=kept\n"; - fs::write(&log_path, format!("{first}{second}"))?; - - let tail = super::read_text_tail(&log_path, second.len() as u64)?; - - assert_eq!(tail, second); - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn activity_log_truncates_long_messages() { - let mut app = test_app(); - app.record_activity("x".repeat(256)); - - let rendered = app.activity_text(); - let line = rendered.lines().next().expect("activity line should exist"); - - assert_eq!(line.len(), 180); - assert!(line.ends_with("...")); - } - - #[test] - fn completion_menu_does_not_show_activity_pane() { - let mut app = test_app(); - app.home_dashboard_visible = false; - app.input = "/".to_owned(); - app.input_cursor = app.input.len(); - app.record_activity("event-new"); - - let rendered = render_test_terminal(&app, 100, 22); - - assert!(rendered.contains("Completions")); - assert!(rendered.contains("Prompt")); - assert!(rendered.contains("Enter choose")); - assert!(!rendered.contains("Activity")); - assert!(!rendered.contains("event-new")); - } - - #[test] - fn activity_pane_is_not_rendered_by_default() { - let mut app = test_app(); - app.record_activity("event-old"); - app.record_activity("event-mid"); - app.record_activity("event-new"); - - let rendered = render_test_terminal(&app, 100, 24); - - assert!(!rendered.contains("Activity")); - assert!(!rendered.contains("event-new")); - assert_eq!(app.activity_text().lines().next(), Some("event-new")); - } - - #[test] - fn config_completion_suggests_telemetry_modes() { - let mut app = test_app(); - app.input = "/config set-telemetry ".to_owned(); - app.input_cursor = app.input.len(); - - let menu = app - .slash_completion_menu() - .expect("telemetry config should complete modes"); - - assert!(menu.items.contains(&"local".to_owned())); - assert!(menu.items.contains(&"off".to_owned())); - } - - #[test] - fn serve_plan_renders_effective_gpu_required_policy_by_default() { - let plan = super::ServeCommandPlan { - model: Some("tiny.gguf".to_owned()), - engine: Some("llama.cpp".to_owned()), - managed: true, - ..Default::default() - }; - let rendered = render_serve_command_plan(&plan, &RocmCliConfig::default()); - - assert!(rendered.contains("device_policy: gpu_required")); - } - - #[test] - fn ctrl_j_submits_prompt() { - let mut app = test_app(); - app.home_dashboard_visible = false; - app.input = "/quit".to_owned(); - handle_key( - &mut app, - key_event(KeyCode::Char('j'), KeyModifiers::CONTROL), - ); - assert!(app.overlay_card.is_some()); - assert!(!app.should_quit); - handle_key(&mut app, key_event(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(app.should_quit); - } - - #[test] - fn enter_submits_prompt() { - let mut app = test_app(); - app.input = "/quit".to_owned(); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.overlay_card.is_some()); - assert!(!app.should_quit); - handle_key(&mut app, key_event(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(app.should_quit); - } - - #[test] - fn paste_with_newline_submits_prompt() { - let mut app = test_app(); - app.paste_text("/quit\n"); - assert!(app.overlay_card.is_some()); - assert!(!app.should_quit); - handle_key(&mut app, key_event(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(app.should_quit); - } - - #[test] - fn ctrl_c_opens_quit_card_instead_of_exiting_immediately() { - let mut app = test_app(); - - handle_key( - &mut app, - key_event(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - - assert!(!app.should_quit); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Quit") - ); - handle_key(&mut app, key_event(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(app.should_quit); - } - - #[test] - fn ctrl_c_while_command_runs_keeps_running_screen() { - let mut app = test_app(); - let (_sender, receiver) = mpsc::channel::(); - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - handle_key( - &mut app, - key_event(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - - assert!(!app.should_quit); - assert!(app.overlay_card.is_none()); - assert!(app.status.contains("Install is still running")); - } - - #[test] - fn running_job_modal_consumes_arrows_and_preserves_underlying_selection() { - let mut app = test_app(); - app.open_command_screen( - "Plan", - "Review the plan.", - None, - vec![ - super::CommandScreenAction::RunPlan, - super::CommandScreenAction::Back, - ], - ); - attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Install is working.")); - assert!(rendered.contains("running | live output shown | wait for finish")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!( - app.command_screen.as_ref().map(|state| state.selected), - Some(0) - ); - assert!(app.status.contains("Choices are paused")); - } - - #[test] - fn running_job_modal_suppresses_prompt_completion_until_finished() { - let mut app = test_app(); - app.input = "/he".to_owned(); - app.input_cursor = app.input.len(); - attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Install is working.")); - assert!(!rendered.contains("Completions")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.input, "/he"); - assert!(app.status.contains("Wait for the running work")); - } - - #[test] - fn paste_while_pending_approval_does_not_poison_prompt_or_submit_commands() { - let mut app = test_app(); - app.request_cli_approval("Install", vec!["version".to_owned()], "test approval"); - - app.paste_text("/quit\n"); - - assert!(app.pending_approval.is_some()); - assert!(app.input.is_empty()); - assert!(!app.should_quit); - assert!(app.overlay_card.is_none()); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.selected), - Some(1) - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.input.is_empty()); - } - - #[test] - fn pending_approval_scroll_is_owned_by_foreground_modal() { - let mut app = test_app(); - app.open_install_manager(); - app.install_manager.as_mut().unwrap().detail_scroll = 33; - app.request_screen_cli_approval( - "Install ROCm", - "Install", - vec!["install".to_owned(), "sdk".to_owned()], - None, - ); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.detail_scroll), - Some(10) - ); - assert_eq!( - app.install_manager - .as_ref() - .map(|state| state.detail_scroll), - Some(33) - ); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.detail_scroll), - Some(0) - ); - } - - #[test] - fn foreground_running_job_keeps_live_output_out_of_background_transcript() { - let mut app = test_app(); - let sender = attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "Downloading ROCm wheel".to_owned(), - }) - .expect("test should send stream line"); - app.poll_running_job(); - - assert!( - app.running_job_output - .iter() - .any(|line| line == "Downloading ROCm wheel") - ); - assert!( - !app.transcript - .iter() - .any(|line| line.text.contains("Downloading ROCm wheel")) - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Downloading ROCm wheel")); - } - - #[test] - fn ctrl_c_during_pending_approval_does_not_open_quit_overlay() { - let mut app = test_app(); - app.request_cli_approval("Install", vec!["version".to_owned()], "test approval"); - - handle_key( - &mut app, - key_event(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - - assert!(!app.should_quit); - assert!(app.pending_approval.is_some()); - assert!(app.overlay_card.is_none()); - assert_eq!(app.status, "Finish or cancel this review before quitting."); - } - - #[test] - fn push_block_scrolls_to_new_block_header() { - let mut app = test_app(); - app.push_block("Old", "one\ntwo"); - app.push_block("Examine", "summary\nmore"); - - let examine_line = app - .transcript - .iter() - .position(|line| line == "[Examine]") - .expect("examine block should be present"); - assert_eq!(usize::from(app.transcript_scroll), examine_line); - } - - #[test] - fn typed_transcript_records_line_kinds() { - let mut app = test_app(); - - app.push_user_input("/examine"); - app.push_block("Examine", "summary"); - app.push_stream_line(super::CommandOutputStream::Stdout, "line"); - - assert!(matches!( - app.transcript[0].kind, - super::TranscriptLineKind::UserInput - )); - assert!(matches!( - &app.transcript[2].kind, - super::TranscriptLineKind::BlockHeader { title } if title == "Examine" - )); - assert!(matches!( - &app.transcript[3].kind, - super::TranscriptLineKind::BlockBody { title } if title == "Examine" - )); - assert!(matches!( - app.transcript.last().map(|line| &line.kind), - Some(super::TranscriptLineKind::Stream { - stream: super::CommandOutputStream::Stdout - }) - )); - } - - #[test] - fn chat_delta_appends_to_assistant_transcript_line() { - let mut app = test_app(); - - app.push_chat_delta("Hel"); - app.push_chat_delta("lo"); - - assert!(app.transcript.iter().any(|line| line == "assistant: Hello")); - assert!(matches!( - app.transcript.last().map(|line| &line.kind), - Some(super::TranscriptLineKind::Assistant) - )); - } - - #[test] - fn enter_accepts_unique_slash_completion() { - let mut app = test_app(); - app.input = "/exa".to_owned(); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.input, "/examine "); - assert_eq!(app.status, "Accepted completion."); - } - - #[test] - fn exact_command_enter_submits_without_completion_menu() { - let mut app = test_app(); - app.input = "/examine".to_owned(); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.examine_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(!app.transcript.iter().any(|line| line == "[Examine]")); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Examine")); - assert!(rendered.contains("Overview")); - assert!(rendered.contains("ROCm installs")); - assert!(!rendered.contains("runtime_state:")); - assert!(!rendered.contains("active_runtime_key")); - assert!(!rendered.contains("plugin_dirs")); - assert!(app.input.is_empty()); - } - - #[test] - fn examine_screen_is_arrow_key_navigable() { - let mut app = test_app(); - assert!(app.handle_command("examine")); - - assert!(matches!( - app.running_job.as_ref().map(|job| &job.kind), - Some(super::RunningJobKind::ExamineRefresh) - )); - assert_eq!( - app.examine_manager.as_ref().map(|state| state.selected), - Some(0) - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Checking this computer")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.examine_manager.as_ref().map(|state| state.selected), - Some(1) - ); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> GPU") || rendered.contains("GPU")); - assert!(app.transcript.is_empty()); - } - - #[test] - fn examine_background_completion_updates_report_without_transcript() { - let mut app = test_app(); - assert!(app.handle_command("examine")); - app.running_job = None; - - app.complete_running_job( - "Examine".to_owned(), - super::RunningJobKind::ExamineRefresh, - Ok(super::CommandOutput { - ok: true, - rendered: "os: windows\narch: x86_64\ndetected_gfx_target: gfx1201\ncompatible_therock_family: gfx120X-all\ndetected_therock_family: \nactive_runtime_status: ready\ndriver_status: ready\neffective_default_engine: llama.cpp".to_owned(), - chat_approval: None, - }), - super::StreamedOutputCounts::default(), - ); - - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Examine refreshed."); - assert_eq!( - app.examine_manager - .as_ref() - .and_then(|state| state.message.as_deref()), - None - ); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Target")); - assert!(rendered.contains("gfx1201")); - } - - #[test] - fn examine_folders_separates_app_data_from_custom_rocm_install_folder() { - let report = [ - r"config_dir: C:\Users\user\.rocm", - r"data_dir: C:\Users\user\.rocm", - r"cache_dir: C:\Users\user\.rocm\cache", - r"active_runtime_status: ready", - r"active_runtime_root: D:\ROCm\therock_venvs", - r"active_runtime_pip_cache_dir: D:\ROCm\therock_venvs\pip-cache", - "model_cache_entries: 0", - ] - .join("\n"); - - let rendered = super::examine_folders_text(&report); - - assert!(rendered.contains(r"Settings: C:\Users\user\.rocm")); - assert!(rendered.contains(r"Logs and app data: C:\Users\user\.rocm")); - assert!(rendered.contains(r"App metadata cache: C:\Users\user\.rocm\cache")); - assert!(rendered.contains(r"ROCm install downloads: D:\ROCm\therock_venvs\pip-cache")); - assert!(rendered.contains(r"Active ROCm install: D:\ROCm\therock_venvs")); - assert!(!rendered.contains(r"Downloaded files: C:\Users\user\.rocm\cache")); - assert!(!rendered.contains("ROCm installs and logs")); - } - - #[test] - fn examine_folders_uses_setup_folder_for_rocm_downloads_before_activation() { - let report = [ - r"config_dir: C:\Users\user\.rocm", - r"data_dir: C:\Users\user\.rocm", - r"cache_dir: C:\Users\user\.rocm\cache", - r"active_runtime_status: unset", - r"setup_runtime_root: D:\ROCm\therock_venvs", - r"setup_runtime_pip_cache_dir: D:\ROCm\therock_venvs\pip-cache", - "model_cache_entries: 0", - ] - .join("\n"); - - let rendered = super::examine_folders_text(&report); - - assert!(rendered.contains(r"App metadata cache: C:\Users\user\.rocm\cache")); - assert!(rendered.contains(r"ROCm install downloads: D:\ROCm\therock_venvs\pip-cache")); - assert!(rendered.contains(r"Active ROCm install: D:\ROCm\therock_venvs")); - } - - #[test] - fn hidden_examine_refresh_does_not_block_next_prompt_commands() -> anyhow::Result<()> { - let mut app = test_app(); - open_hidden_examine_refresh_from_prompt(&mut app); - submit_prompt_command(&mut app, "/install"); - assert!(app.install_manager.is_some()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!( - app.install_manager.as_ref().map(|state| &state.screen), - Some(super::InstallManagerScreen::Sdk { .. }) - )); - assert_no_background_examine_block(&app, "install"); - - let mut app = test_app(); - open_hidden_examine_refresh_from_prompt(&mut app); - submit_prompt_command(&mut app, "/runtimes"); - assert!(app.runtime_manager.is_some()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!( - app.install_manager.as_ref().map(|state| &state.screen), - Some(super::InstallManagerScreen::Sdk { .. }) - )); - assert_no_background_examine_block(&app, "runtimes"); - - let mut app = test_app(); - open_hidden_examine_refresh_from_prompt(&mut app); - submit_prompt_command(&mut app, "/update"); - assert!(app.update_manager.is_some()); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec!["update".to_owned(), "--apply".to_owned()] - )); - assert_no_background_examine_block(&app, "update"); - - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - open_hidden_examine_refresh_from_prompt(&mut app); - submit_prompt_command(&mut app, "/services"); - assert!(app.services_manager.is_some()); - let stop_index = app - .services_manager - .as_ref() - .and_then(|state| { - super::services_manager_actions(state) - .iter() - .position(|action| *action == super::ServicesManagerAction::Stop) - .map(|index| state.items.len() + index) - }) - .expect("stop action row should exist"); - for _ in 0..stop_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::RocmdCommand { args, kind, .. }) - if args == &vec![ - "sandbox-run".to_owned(), - "stop_server".to_owned(), - "--allow-native-fallback".to_owned(), - "--service-id".to_owned(), - "svc-qwen".to_owned(), - ] - && matches!( - kind, - super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Stop, - service_id, - } if service_id == "svc-qwen" - ) - )); - assert_no_background_examine_block(&app, "services"); - - let mut app = test_app(); - open_hidden_examine_refresh_from_prompt(&mut app); - submit_prompt_command(&mut app, "/clear"); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Clear") - ); - assert_no_background_examine_block(&app, "clear"); - - let mut app = test_app(); - open_hidden_examine_refresh_from_prompt(&mut app); - submit_prompt_command(&mut app, "/quit"); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Quit") - ); - assert_no_background_examine_block(&app, "quit"); - - Ok(()) - } - - #[test] - fn examine_command_does_not_steal_running_workflow_screens() -> anyhow::Result<()> { - let mut app = test_app(); - assert!(app.handle_command("install")); - let sender = attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - - submit_prompt_command(&mut app, "/examine"); - - assert!(app.install_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Install is still running") - && message.contains("Examine can check this computer after it finishes") - }) - ); - finish_running_job(&mut app, sender, "install finished"); - assert!(app.install_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("install")); - let sender = attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - - assert!(app.handle_command("examine")); - - assert!(app.install_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Install is still running") - && message.contains("Examine can check this computer after it finishes") - }) - ); - finish_running_job(&mut app, sender, "install finished"); - assert!(app.install_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("update")); - let sender = attach_running_job(&mut app, "Update", super::RunningJobKind::Cli); - - assert!(app.handle_command("examine")); - - assert!(app.update_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Update is still running") - && message.contains("Examine can check this computer after it finishes") - }) - ); - finish_running_job(&mut app, sender, "update finished"); - assert!(app.update_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - let sender = attach_running_job( - &mut app, - "Services", - super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Stop, - service_id: "svc-qwen".to_owned(), - }, - ); - - assert!(app.handle_command("examine")); - - assert!(app.services_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Service action is still running") - && message.contains("Examine can check this computer after it finishes") - }) - ); - finish_running_job(&mut app, sender, "service stopped"); - assert!(app.services_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - app.open_command_screen( - "Chat", - "Sending prompt...", - Some(super::CommandScreenTarget::ChatProvider), - vec![super::CommandScreenAction::Back], - ); - let sender = attach_running_job( - &mut app, - "Chat", - super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: false, - }, - ); - - assert!(app.handle_command("examine")); - - assert_eq!( - app.command_screen - .as_ref() - .map(|state| state.title.as_str()), - Some("Chat") - ); - assert!(app.examine_manager.is_none()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Chat is still running") - && message.contains("Examine can check this computer after it finishes") - }) - ); - finish_running_job(&mut app, sender, "provider: local"); - assert_eq!( - app.command_screen - .as_ref() - .map(|state| state.title.as_str()), - Some("Chat") - ); - assert!(app.examine_manager.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - app.open_command_screen( - "Plan", - "Planning request...\n\nstart a local model", - None, - vec![super::CommandScreenAction::Back], - ); - let sender = attach_running_job( - &mut app, - "Planning", - super::RunningJobKind::Planner { - title: "Plan".to_owned(), - request: "start a local model".to_owned(), - }, - ); - - assert!(app.handle_command("examine")); - - assert_eq!( - app.command_screen - .as_ref() - .map(|state| state.title.as_str()), - Some("Plan") - ); - assert!(app.examine_manager.is_none()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Planning is still running") - && message.contains("Examine can check this computer after it finishes") - }) - ); - finish_running_job(&mut app, sender, "plan ready"); - assert_eq!( - app.command_screen - .as_ref() - .map(|state| state.title.as_str()), - Some("Plan") - ); - assert!(app.examine_manager.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - app.open_serve_wizard(); - let sender = attach_running_job(&mut app, "Serve", super::RunningJobKind::Cli); - - assert!(app.handle_command("examine")); - - assert!(app.serve_wizard.is_some()); - assert!(app.examine_manager.is_none()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Serve is still running") - && message.contains("Examine can check this computer after it finishes") - }) - ); - finish_running_job(&mut app, sender, "serve finished"); - assert!(app.serve_wizard.is_some()); - assert!(app.examine_manager.is_none()); - assert!(app.transcript.is_empty()); - - Ok(()) - } - - #[test] - fn examine_command_does_not_hide_pending_approval() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("runtimes")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.install_manager.is_some()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_some()); - - assert!(app.handle_command("examine")); - - assert!(app.install_manager.is_some()); - assert!(app.examine_manager.is_none()); - assert!(app.pending_approval.is_some()); - assert!( - super::active_screen_message_text(&app).is_some_and(|message| { - message.contains("Install ROCm is waiting for your review") - && message.contains("before opening Examine") - }) - ); - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.pending_approval.is_none()); - assert!(app.install_manager.is_none()); - assert!(app.home_dashboard_visible); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn examine_page_keys_scroll_details_without_moving_selection() { - let mut app = test_app(); - assert!(app.handle_command("examine")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let selected = app.examine_manager.as_ref().map(|state| state.selected); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert_eq!( - app.examine_manager.as_ref().map(|state| state.selected), - selected - ); - assert_eq!( - app.examine_manager - .as_ref() - .map(|state| state.detail_scroll), - Some(10) - ); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - assert_eq!( - app.examine_manager - .as_ref() - .map(|state| state.detail_scroll), - Some(0) - ); - } - - #[test] - fn slash_input_exposes_vertical_completion_menu() { - let mut app = test_app(); - app.input = "/".to_owned(); - - let menu = app - .slash_completion_menu() - .expect("slash input should render suggestions"); - - assert_eq!(menu.title, "Completions"); - assert!(menu.items.iter().any(|item| item.contains("/examine"))); - assert!(menu.items.iter().any(|item| item.contains("/help"))); - assert!(menu.items.iter().any(|item| item.contains("/reviews"))); - assert!(!menu.items.iter().any(|item| item.starts_with("/proposals"))); - assert!(menu.items.len() > 1); - assert!(!menu.items.iter().any(|item| item.contains("more"))); - } - - #[test] - fn friendly_slash_completion_inserts_exact_command() { - let mut app = test_app(); - app.input = "/ex".to_owned(); - app.input_cursor = app.input_len_chars(); - - let menu = app - .slash_completion_menu() - .expect("/ex should render the /examine suggestion"); - assert!(menu.items[menu.selected].contains("check this computer")); - - assert!(app.accept_completion()); - - assert_eq!(app.input, "/examine "); - assert_eq!(app.input_cursor, app.input_len_chars()); - } - - #[test] - fn slash_argument_completion_menu_uses_one_row_per_candidate() { - let mut app = test_app(); - app.set_input("/provider ".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("argument input should render suggestions"); - - assert_eq!( - menu.items, - vec![ - "local".to_owned(), - "anthropic".to_owned(), - "openai".to_owned(), - ] - ); - assert_eq!(menu.selected, 0); - } - - #[test] - fn slash_opens_command_popup_from_home_and_runs_exact_command() { - let mut app = test_app(); - - handle_key(&mut app, key_event(KeyCode::Char('/'), KeyModifiers::NONE)); - - assert_eq!(app.input, "/"); - assert!(super::surface_command_prompt_active(&app)); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("Command")); - assert!(rendered.contains("/examine")); - assert!(!rendered.contains("Prompt")); - - for ch in "examine".chars() { - handle_key(&mut app, key_event(KeyCode::Char(ch), KeyModifiers::NONE)); - } - assert_eq!(app.input, "/examine"); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.examine_manager.is_some()); - assert!(app.input.is_empty()); - assert!(app.overlay_card.is_none()); - } - - #[test] - fn slash_popup_works_from_comfyui_but_not_setup() { - let mut app = test_app(); - submit_prompt_command(&mut app, "/comfyui"); - assert!(app.command_screen.is_some()); - - handle_key(&mut app, key_event(KeyCode::Char('/'), KeyModifiers::NONE)); - assert_eq!(app.input, "/"); - assert!(super::surface_command_prompt_active(&app)); - - for ch in "comfyui logs".chars() { - handle_key(&mut app, key_event(KeyCode::Char(ch), KeyModifiers::NONE)); - } - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.command_screen.is_some()); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("ComfyUI Logs") - ); - - let mut setup_app = test_app(); - setup_app.onboarding_active = true; - handle_key( - &mut setup_app, - key_event(KeyCode::Char('/'), KeyModifiers::NONE), - ); - assert!(setup_app.input.is_empty()); - assert!(setup_app.overlay_card.is_none()); - assert!(!super::surface_command_prompt_active(&setup_app)); - } - - #[test] - fn comfyui_slash_surface_is_navigable_and_friendly() { - let mut app = test_app(); - - submit_prompt_command(&mut app, "/comfyui"); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let actions = &app.command_screen.as_ref().unwrap().actions; - assert_eq!( - actions, - &vec![ - super::CommandScreenAction::OpenCommand("comfyui install"), - super::CommandScreenAction::Back, - ] - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("ComfyUI")); - assert!(rendered.contains("> Install ComfyUI")); - assert!(!rendered.contains("Start ComfyUI")); - assert!(!rendered.contains("Show models path")); - assert!(!rendered.contains("Open ComfyUI logs")); - assert!(rendered.contains("Not installed yet")); - assert!(!rendered.contains("last install log")); - assert!(!rendered.contains("[ComfyUI]")); - } - - #[test] - fn comfyui_menu_hides_install_after_setup() -> anyhow::Result<()> { - let mut app = test_app(); - write_comfyui_manifest_for_test(&app)?; - - submit_prompt_command(&mut app, "/comfyui"); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(!rendered.contains("Install ComfyUI")); - assert!(rendered.contains("Show models path")); - Ok(()) - } - - #[test] - fn comfyui_install_requests_friendly_approval_without_transcript_dump() { - let mut app = test_app(); - - submit_prompt_command(&mut app, "/comfyui install"); - - assert!(app.command_screen.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { title, args, .. }) - if title == "ComfyUI" - && args == &vec!["comfyui".to_owned(), "install".to_owned()] - )); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Install ComfyUI")); - assert!(rendered.contains("Download ComfyUI")); - assert!(rendered.contains("Keep the GPU packages ROCm already installed")); - assert!(rendered.contains("Show live install output here")); - assert!(!rendered.contains("Run the selected action.")); - } - - #[test] - fn comfyui_start_approval_uses_requested_address() { - let pending = super::PendingApproval { - title: "Start ComfyUI".to_owned(), - selected: 0, - detail_scroll: 0, - action: super::ApprovalAction::CliCommand { - title: "ComfyUI".to_owned(), - args: vec![ - "comfyui".to_owned(), - "start".to_owned(), - "--host".to_owned(), - "localhost".to_owned(), - "--port".to_owned(), - "18188".to_owned(), - ], - display_command: Some("/comfyui start --host localhost --port 18188".to_owned()), - explanation: None, - }, - }; - - let app = test_app(); - let rendered = super::render_screen_approval(&app, &pending); - - assert!(rendered.contains("Address: http://localhost:18188")); - assert!(!rendered.contains("Address: http://127.0.0.1:8188")); - } - - #[test] - fn comfyui_start_completion_keeps_url_visible() { - let mut app = test_app(); - submit_prompt_command(&mut app, "/comfyui"); - - app.complete_running_job( - "ComfyUI".to_owned(), - super::RunningJobKind::Cli, - Ok(super::CommandOutput { - ok: true, - rendered: "ComfyUI\n status: running\n URL: http://127.0.0.1:8188\n models folder: D:\\ROCm\\ComfyUI\\models\n".to_owned(), - chat_approval: None, - }), - super::StreamedOutputCounts { - total: 0, - stdout: 0, - stderr: 0, - }, - ); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("ComfyUI is running.")); - assert!(rendered.contains("Open this URL:")); - assert!(rendered.contains("http://127.0.0.1:8188")); - assert!(rendered.contains("Show models path")); - assert!(rendered.contains("D:\\ROCm\\ComfyUI\\models")); - assert!(rendered.contains("rocm comfyui models-path")); - assert!(rendered.contains("Put models here:")); - assert!(!rendered.contains("Status: ok")); - assert!(!rendered.contains("ComfyUI finished.")); - assert!(!rendered.contains("Recent output")); - assert!(!rendered.contains("Live output")); - assert!(!rendered.contains("Output:")); - } - - #[test] - fn comfyui_models_path_command_shows_path() -> anyhow::Result<()> { - let mut app = test_app(); - write_comfyui_manifest_for_test(&app)?; - - submit_prompt_command(&mut app, "/comfyui models-path"); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Show models path")); - assert!(rendered.contains("models")); - assert!(!rendered.contains("Recent output")); - assert!(!rendered.contains("Output:")); - Ok(()) - } - - #[test] - fn comfyui_completion_lists_user_actions() { - let mut app = test_app(); - app.set_input("/comfyui ".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("ComfyUI command should suggest actions"); - - assert_eq!( - menu.items, - vec![ - "status".to_owned(), - "install".to_owned(), - "start".to_owned(), - "models-path".to_owned(), - "logs".to_owned(), - "log-files".to_owned(), - ] - ); - } - - #[test] - fn comfyui_logs_open_inline_without_transcript_dump() -> anyhow::Result<()> { - let mut app = test_app(); - let logs = app.paths.data_dir.join("apps").join("comfyui").join("logs"); - fs::create_dir_all(&logs)?; - fs::write( - logs.join("install-100.log"), - "downloaded ComfyUI\ninstalled packages\n", - )?; - submit_prompt_command(&mut app, "/comfyui logs"); - app.clear_input(); - - assert!(app.command_screen.is_none()); - assert!(app.overlay_card.is_some()); - assert!(app.transcript.is_empty()); - let detail = &app.overlay_card.as_ref().unwrap().detail; - assert!(!detail.contains("saved file:")); - assert!(!detail.contains("install-100.log")); - let rendered = render_test_terminal(&app, 140, 30); - assert!(rendered.contains("ComfyUI Logs")); - assert!(rendered.contains("Install log")); - assert!(rendered.contains("downloaded ComfyUI")); - assert!(rendered.contains("installed packages")); - assert!(!rendered.contains("ComfyUI Log Files")); - Ok(()) - } - - #[test] - fn comfyui_log_files_are_explicitly_reachable() -> anyhow::Result<()> { - let mut app = test_app(); - let logs = app.paths.data_dir.join("apps").join("comfyui").join("logs"); - fs::create_dir_all(&logs)?; - let install_log = logs.join("install-100.log"); - fs::write(&install_log, "downloaded ComfyUI\ninstalled packages\n")?; - - submit_prompt_command(&mut app, "/comfyui log-files"); - - assert!(app.command_screen.is_none()); - assert!(app.overlay_card.is_some()); - assert!(app.transcript.is_empty()); - let state = app.overlay_card.as_ref().unwrap(); - assert_eq!(state.title, "ComfyUI Log Files"); - let detail = &state.detail; - assert!(detail.contains("saved file:")); - assert!(detail.contains(&install_log.display().to_string())); - let rendered = render_test_terminal(&app, 140, 30); - assert!(rendered.contains("ComfyUI Log Files")); - assert!(rendered.contains("ComfyUI logs")); - assert!(rendered.contains("saved file:")); - Ok(()) - } - - #[test] - fn comfyui_log_files_are_reachable_with_arrow_keys() -> anyhow::Result<()> { - let mut app = test_app(); - let logs = app.paths.data_dir.join("apps").join("comfyui").join("logs"); - fs::create_dir_all(&logs)?; - let install_log = logs.join("install-100.log"); - let long_log = (0..80) - .map(|index| format!("line {index}: downloaded ComfyUI package")) - .collect::>() - .join("\n"); - fs::write(&install_log, long_log)?; - - submit_prompt_command(&mut app, "/comfyui"); - assert!(app.command_screen.is_some()); - assert!(app.overlay_card.is_none()); - - assert!(app.handle_command("comfyui logs")); - assert!(app.command_screen.is_some()); - assert_eq!( - app.command_screen - .as_ref() - .map(|state| state.title.as_str()), - Some("ComfyUI") - ); - let state = app.overlay_card.as_ref().expect("logs should open as card"); - assert_eq!(state.title, "ComfyUI Logs"); - assert!(state.detail.contains("line 0: downloaded ComfyUI package")); - let rendered = render_test_terminal(&app, 140, 30); - assert!(rendered.contains("ComfyUI Logs")); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert!( - app.overlay_card - .as_ref() - .is_some_and(|state| state.detail_scroll > 0) - ); - let after_page = app.overlay_card.as_ref().unwrap().detail_scroll; - - super::handle_mouse( - &mut app, - MouseEvent { - kind: MouseEventKind::ScrollDown, - column: 60, - row: 10, - modifiers: KeyModifiers::NONE, - }, - ); - assert!( - app.overlay_card.as_ref().unwrap().detail_scroll > after_page, - "mouse wheel should scroll the log card" - ); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - assert!(app.command_screen.is_some()); - Ok(()) - } - - #[test] - fn comfyui_log_file_modal_esc_returns_to_comfyui_screen() -> anyhow::Result<()> { - let mut app = test_app(); - let logs = app.paths.data_dir.join("apps").join("comfyui").join("logs"); - fs::create_dir_all(&logs)?; - fs::write(logs.join("install-100.log"), "downloaded ComfyUI\n")?; - - submit_prompt_command(&mut app, "/comfyui"); - assert!(app.command_screen.is_some()); - - assert!(app.handle_command("comfyui log-files")); - - assert!(app.overlay_card.is_some()); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - let state = app - .command_screen - .as_ref() - .expect("ComfyUI screen should remain open"); - assert_eq!(state.title, "ComfyUI"); - assert_eq!(app.status, "Card closed."); - let rendered = render_test_terminal(&app, 140, 30); - assert!(!rendered.contains("ComfyUI Log Files")); - assert!(rendered.contains("ComfyUI")); - Ok(()) - } - - #[test] - fn logs_completion_suggests_service_flag_before_service_ids() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - let record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.write()?; - - app.set_input("/logs ".to_owned()); - let menu = app - .slash_completion_menu() - .expect("logs command should suggest flags and services"); - - assert_eq!(menu.items.first().map(String::as_str), Some("--service")); - assert!(menu.items.iter().any(|item| item == "--search")); - assert!(menu.items.iter().any(|item| item == "next")); - assert!(menu.items.iter().any(|item| item == "prev")); - assert!(menu.items.iter().any(|item| item == "refresh")); - assert!(menu.items.iter().any(|item| item == "svc-qwen")); - - app.set_input("/logs --service ".to_owned()); - let menu = app - .slash_completion_menu() - .expect("service flag should suggest service ids"); - assert_eq!(menu.items, vec!["svc-qwen".to_owned()]); - Ok(()) - } - - #[test] - fn slash_completion_scrolls_through_full_suggestion_set() { - let mut app = test_app(); - app.set_input("/".to_owned()); - - for _ in 0..super::MAX_COMPLETION_MENU_ITEMS { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - - let menu = app - .slash_completion_menu() - .expect("slash input should still render suggestions"); - let (start, end) = super::completion_visible_range(&menu); - - assert!(menu.items.len() > super::MAX_COMPLETION_MENU_ITEMS); - assert_eq!(menu.selected, super::MAX_COMPLETION_MENU_ITEMS); - assert!(start > 0); - assert_eq!(end - start, super::MAX_COMPLETION_MENU_ITEMS); - assert!(menu.selected > start); - assert!(menu.selected < end - 1); - assert!( - !menu.items[start..end] - .iter() - .any(|item| item.contains("more")) - ); - } - - #[test] - fn slash_no_match_does_not_render_completion_menu() { - let mut app = test_app(); - app.set_input("/zz".to_owned()); - - assert!(app.slash_completion_menu().is_none()); - } - - #[test] - fn slash_engine_is_the_only_engine_command_completion() { - let mut app = test_app(); - app.set_input("/engi".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("/engi should render the /engine completion"); - - assert_eq!( - menu.items, - vec![super::slash_command_completion_label( - &super::SlashCommandSpec { - name: "engine", - usage: "/engine [list|install ...]", - } - )] - ); - assert!(!menu.items.iter().any(|item| item.contains("/engines"))); - } - - #[test] - fn slash_completion_advertises_guided_model_and_install_defaults() { - let mut app = test_app(); - app.set_input("/".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("slash input should render suggestions"); - - assert!(menu.items.iter().any(|item| item.contains("/model"))); - assert!(menu.items.iter().any(|item| item.contains("/install"))); - assert!(!menu.items.contains(&"/model ".to_owned())); - assert!(!menu.items.contains(&"/install ...".to_owned())); - } - - #[test] - fn setup_completion_usage_includes_status_reset_and_skip() { - let mut app = test_app(); - let setup_usage = super::SLASH_COMMANDS - .iter() - .find(|command| command.name == "setup") - .map(|command| command.usage); - assert_eq!(setup_usage, Some("/setup [status|reset|skip]")); - let help = crate::tui_help_text(); - assert!(help.contains("status, reset, and skip")); - - app.set_input("/setup ".to_owned()); - let menu = app - .slash_completion_menu() - .expect("setup arguments should complete"); - assert_eq!( - menu.items, - vec!["status".to_owned(), "reset".to_owned(), "skip".to_owned()] - ); - } - - #[test] - fn serve_device_completion_only_offers_gpu_required_choices() { - let mut app = test_app(); - app.set_input("/serve tiny.gguf --device ".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("serve device should render completions"); - - assert!(menu.items.contains(&"gpu".to_owned())); - assert!(menu.items.contains(&"gpu_required".to_owned())); - assert!(!menu.items.contains(&"gpu_preferred".to_owned())); - assert!(!menu.items.contains(&"cpu".to_owned())); - } - - #[test] - fn engine_argument_completion_does_not_suggest_interactive_shell() { - let mut app = test_app(); - app.set_input("/engine ".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("/engine should render argument completions"); - - assert!(menu.items.contains(&"list".to_owned())); - assert!(menu.items.contains(&"install".to_owned())); - assert!(!menu.items.contains(&"shell".to_owned())); - } - - #[test] - fn tui_help_teaches_engine_not_engines() { - let help = crate::tui_help_text(); - - assert!(help.contains("/engine")); - assert!(help.contains("/install")); - assert!(help.contains("/reviews")); - assert!(help.contains("/approve")); - assert!(help.contains("/reject")); - assert!(help.contains("/edit")); - assert!(help.contains("/serve MODEL open a guided local model server setup")); - assert!(help.contains("/quit, /exit")); - assert!(help.contains("/engine install lemonade --reinstall")); - assert!(help.contains("/runtimes")); - assert!(help.contains("adopt")); - assert!(help.contains("read-only")); - assert!(help.contains("preview what uninstall would remove")); - assert!(!help.contains("venv")); - assert!(!help.contains("manifest")); - assert!(!help.contains("amd-smi")); - assert!(!help.contains("dry-run")); - assert!(!help.contains("/engines")); - assert!(!help.contains("engines install")); - } - - #[test] - fn default_intro_is_simple_until_help_is_requested() { - let mut app = test_app(); - app.push_default_intro(); - let transcript = app.transcript_text(); - - assert!(transcript.contains("Try a plain request")); - assert!(!transcript.contains("[Help]")); - assert!(!transcript.contains("slash commands")); - assert!(!transcript.contains("venv")); - assert!(!transcript.contains("manifest")); - - assert!(app.handle_command("help")); - assert!(app.overlay_card.is_some()); - assert!(app.transcript_text().contains("Try a plain request")); - assert!(render_test_terminal(&app, 120, 28).contains("Set up ROCm")); - } - - #[test] - fn finishing_onboarding_opens_simple_main_intro() { - let mut app = test_app(); - app.onboarding_active = true; - app.finish_onboarding(); - - let transcript = app.transcript_text(); - assert!(!app.onboarding_active); - assert!(transcript.contains("Try a plain request")); - assert!(!transcript.contains("[Help]")); - assert!(!transcript.contains("slash commands")); - } - - #[test] - fn home_dashboard_initial_render_is_arrow_first() { - let app = test_app(); - - assert!(app.should_draw_home_dashboard()); - assert!(!super::should_draw_prompt_box(&app)); - let rendered = render_test_terminal(&app, 120, 30); - - assert!(rendered.contains("Local AI on your AMD GPU")); - assert!(rendered.contains("Use the arrows or mouse to choose.")); - assert!(rendered.contains("At a glance")); - assert!(rendered.contains("[SETUP NEEDED] AMD GPU setup")); - assert!(rendered.contains("[PERMISSIONS] Changes")); - assert!(rendered.contains("> Set up ROCm")); - assert!(rendered.contains("No local model is running yet.")); - assert!(rendered.contains("Up/Down choose")); - assert!(rendered.contains("Enter open")); - assert!(!rendered.contains("No activity yet")); - assert!(!rendered.contains("active_runtime_key")); - assert!(!rendered.contains("manifest")); - assert!(!rendered.contains("amd-smi")); - assert!(!rendered.contains("Prompt")); - assert!(!rendered.contains("Message")); - - let compact = render_test_terminal(&app, 120, 24); - assert!(compact.contains("[PERMISSIONS] Changes")); - assert!(compact.contains("> Set up ROCm")); - } - - #[test] - fn home_dashboard_hides_prompt_box_and_ignores_text_input() { - let mut app = test_app(); - - handle_key(&mut app, key_event(KeyCode::Char('h'), KeyModifiers::NONE)); - - assert!(app.input.is_empty()); - assert!(app.should_draw_home_dashboard()); - assert_eq!(app.status, "Use the menu. Press Esc for the main menu."); - assert!(!super::should_draw_prompt_box(&app)); - let rendered = render_test_terminal(&app, 120, 30); - assert!(!rendered.contains("Prompt")); - assert!(!rendered.contains("Message")); - } - - #[test] - fn home_dashboard_ignores_paste_when_prompt_is_hidden() { - let mut app = test_app(); - - super::handle_terminal_event(&mut app, crossterm::event::Event::Paste("hello".to_owned())); - - assert!(app.input.is_empty()); - assert!(app.should_draw_home_dashboard()); - assert_eq!( - app.status, - "Use the visible choices here; there is no text box on this screen." - ); - } - - #[test] - fn home_dashboard_left_click_opens_selected_action() { - let mut app = test_app(); - let area = Rect { - x: 0, - y: 0, - width: 120, - height: 30, - }; - let list = super::home_dashboard_list_inner_rect(&app, area).expect("home list"); - let examine_row = 1; - - assert!(super::click_home_dashboard( - &mut app, - list.x, - list.y + examine_row, - area.width, - area.height, - )); - - assert!(app.examine_manager.is_some()); - assert!(!app.should_draw_home_dashboard()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn home_dashboard_arrows_cycle_and_enter_opens_setup() { - let mut app = test_app(); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!(app.home_selection, 1); - assert!(app.status.contains("Setup check selected")); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("> Run setup check")); - - handle_key(&mut app, key_event(KeyCode::Home, KeyModifiers::NONE)); - assert_eq!(app.home_selection, 0); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.onboarding_active); - assert!(!app.should_draw_home_dashboard()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn home_dashboard_preserves_transcript_when_reopened() { - let mut app = test_app(); - app.push_block("Examine", "GPU check output"); - - assert!(!app.should_draw_home_dashboard()); - assert!(app.transcript_text().contains("GPU check output")); - - assert!(app.handle_command("home")); - - assert!(app.should_draw_home_dashboard()); - assert!(app.transcript_text().contains("GPU check output")); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("Local AI on your AMD GPU")); - assert!(!rendered.contains("GPU check output")); - } - - #[test] - fn ready_home_dashboard_offers_model_and_app_actions() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-home-ready"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root); - app.config.active_runtime_key = Some(runtime_key.to_owned()); - app.config.save(&app.paths)?; - - let rendered = render_test_terminal(&app, 120, 32); - - assert!(rendered.contains("[READY] AMD GPU setup")); - assert!(rendered.contains("> Start a local model")); - assert!(rendered.contains("Chat with assistant")); - assert!(rendered.contains("Open ComfyUI")); - assert!(rendered.contains("Choose model runner")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.serve_wizard.is_some()); - assert!(!app.should_draw_home_dashboard()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn home_dashboard_menu_uses_single_column_labels_on_narrow_panes() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-home-fit"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root); - app.config.active_runtime_key = Some(runtime_key.to_owned()); - app.config.save(&app.paths)?; - - let rendered = render_test_terminal(&app, 96, 24); - - assert!(rendered.contains("> Start a local model")); - assert!(rendered.contains("Chat with assistant")); - assert!(rendered.contains("Open ComfyUI")); - for clipped_helper in [ - "run a model on your AMD GPU", - "ask a local model for help", - "install or start image UI", - "choose how models run", - ] { - assert!( - !rendered.contains(clipped_helper), - "home menu helper text should not render in the narrow action pane: {clipped_helper}\n{rendered}" - ); - } - Ok(()) - } - - #[test] - fn home_command_is_navigable_without_transcript_dump() { - let mut app = test_app(); - app.push_block("Before", "old output"); - - assert!(app.handle_command("home")); - - assert_eq!(active_tui_surface_count(&app), 1); - let selection = active_surface_selection(&app).expect("home should have a selected row"); - assert_eq!(selection.surface, "home"); - assert!(selection.row_count > 1); - assert!(!app.transcript.iter().any(|line| line == "[Commands]")); - assert!(!app.transcript.iter().any(|line| line == "[Error]")); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("> Set up ROCm")); - } - - #[test] - fn runtimes_command_opens_navigable_manager_instead_of_transcript_dump() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("release-pip-gfx120x-all-7-14-0".to_owned()); - app.config.save(&app.paths)?; - - assert!(app.handle_command("runtimes")); - - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 36); - assert!(rendered.contains("ROCm Installs")); - assert!(rendered.contains("Details")); - assert!(rendered.contains("Default")); - assert!(rendered.contains("Enter: already default")); - assert!(rendered.contains("Add existing install")); - assert!(!rendered.contains("Add existing Python folder")); - assert!(!rendered.contains("Import saved ROCm install")); - assert!(!rendered.contains("registered ROCm runtimes")); - assert!(!rendered.contains("ROCm runtime")); - assert!(!rendered.contains("runtime key:")); - assert!(!rendered.contains("registry:")); - assert!(!rendered.contains("install id:")); - Ok(()) - } - - #[test] - fn runtime_manager_mentions_build_date_from_version_string() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0a20260601"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - set_test_runtime_version(&app.paths, runtime_key, "7.14.0a20260601")?; - app.config.active_runtime_key = Some(runtime_key.to_owned()); - app.config.save(&app.paths)?; - - assert!(app.handle_command("runtimes")); - - let rendered = render_test_terminal(&app, 140, 36); - assert!(rendered.contains("7.14.0a20260601 (build 2026-06-01)")); - assert!(rendered.contains("version: 7.14.0a20260601 (build 2026-06-01)")); - Ok(()) - } - - #[test] - fn runtime_manager_list_keeps_metadata_in_details_on_narrow_panes() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0a20260601"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - set_test_runtime_version(&app.paths, runtime_key, "7.14.0a20260601")?; - app.config.active_runtime_key = Some(runtime_key.to_owned()); - app.config.save(&app.paths)?; - - assert!(app.handle_command("runtimes")); - - let rendered = render_test_terminal(&app, 96, 28); - assert!(rendered.contains("> Default - 2026-06-01")); - assert!(rendered.contains("GPU package: gfx120X-all")); - assert!(rendered.contains("source: managed by ROCm CLI")); - assert!(!rendered.contains("> Default - 7.14.0a20260601")); - assert!(!rendered.contains("> Default - 2026-06-01 gfx120X-all release")); - Ok(()) - } - - #[test] - fn runtime_manager_advanced_options_are_arrow_key_navigable() -> anyhow::Result<()> { - let mut app = test_app(); - assert!(app.handle_command("runtimes")); - - handle_key(&mut app, key_event(KeyCode::End, KeyModifiers::NONE)); - assert_eq!( - app.selected_runtime_manager_action(), - Some(super::RuntimeManagerAction::AdvancedOptions) - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.runtime_manager.as_ref().map(|state| &state.screen), - Some(super::RuntimeManagerScreen::Advanced { .. }) - )); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Add Existing ROCm")); - assert!(rendered.contains("Add existing Python folder")); - assert!(rendered.contains("Add from saved ROCm file")); - assert!(rendered.contains("Enter opens the highlighted option")); - assert!(app.transcript.is_empty()); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let selection = active_surface_selection(&app).expect("advanced screen remains selected"); - assert_eq!(selection.surface, "runtimes"); - assert_eq!(selection.selected, 1); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(matches!( - app.runtime_manager.as_ref().map(|state| &state.screen), - Some(super::RuntimeManagerScreen::ImportManifest { .. }) - )); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn runtime_manager_arrow_keys_change_selected_runtime() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "old-release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 10, - )?; - write_test_runtime_with_key( - &app.paths, - "new-release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("runtimes")); - assert_eq!( - app.selected_runtime_manager_item() - .as_ref() - .map(|item| item.runtime_key.as_str()), - Some("new-release-pip-gfx120x-all") - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!( - app.selected_runtime_manager_item() - .as_ref() - .map(|item| item.runtime_key.as_str()), - Some("old-release-pip-gfx120x-all") - ); - Ok(()) - } - - #[test] - fn runtime_manager_selected_install_detail_shows_folder() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-folder-detail"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - - assert!(app.handle_command("runtimes")); - - let details = super::runtime_manager_detail_text(&app); - assert!(details.contains("folder:")); - assert!(details.contains(&install_root.display().to_string())); - Ok(()) - } - - #[test] - fn runtime_manager_duplicate_versions_are_distinguishable_by_folder() -> anyhow::Result<()> { - let mut app = test_app(); - let old_key = "release-pip-gfx120x-all-duplicate-old"; - let new_key = "release-pip-gfx120x-all-duplicate-new"; - write_test_runtime_with_key(&app.paths, old_key, "therock-release:gfx120X-all", 10)?; - write_test_runtime_with_key(&app.paths, new_key, "therock-release:gfx120X-all", 20)?; - let old_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(old_key); - let new_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(new_key); - - assert!(app.handle_command("runtimes")); - - let details = super::runtime_manager_detail_text(&app); - assert!(details.contains(&new_root.display().to_string())); - assert!(!details.contains(&old_root.display().to_string())); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - let details = super::runtime_manager_detail_text(&app); - assert!(details.contains(&old_root.display().to_string())); - assert!(!details.contains(&new_root.display().to_string())); - Ok(()) - } - - #[test] - fn runtime_manager_needs_fix_detail_shows_folder_and_reinstall_guidance() -> anyhow::Result<()> - { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-needs-fix"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - fs::remove_dir_all(install_root.join("_rocm_sdk_devel"))?; - - assert!(app.handle_command("runtimes")); - - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("Needs fix")); - let details = super::runtime_manager_detail_text(&app); - assert!(details.contains("folder:")); - assert!(details.contains(&install_root.display().to_string())); - assert!(details.contains("fix: reinstall ROCm from Set Up ROCm")); - Ok(()) - } - - #[test] - fn runtime_manager_enter_requests_activation_without_transcript_approval() -> anyhow::Result<()> - { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("runtimes")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "activate".to_owned(), - "release-pip-gfx120x-all-7-14-0".to_owned(), - ] - )); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 36); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Use this ROCm install")); - assert!(rendered.contains("Use this ROCm install as the default")); - assert!(!rendered.contains("/runtimes activate")); - Ok(()) - } - - #[test] - fn runtime_manager_uninstall_requests_selected_runtime() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("runtimes")); - - handle_key(&mut app, key_event(KeyCode::Char('u'), KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "uninstall".to_owned(), - "release-pip-gfx120x-all-7-14-0".to_owned(), - ] - )); - let detail = super::runtime_manager_detail_text(&app); - assert!(!detail.contains("Review this change")); - assert!(!detail.contains("Uninstall this ROCm install")); - assert!(detail.contains("ROCm install")); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Review: Uninstall this ROCm install")); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Uninstall this ROCm install")); - assert!(rendered.contains("Plain English")); - Ok(()) - } - - #[test] - fn runtime_manager_action_rows_are_reachable_with_arrows() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("runtimes")); - - let remove_index = app - .runtime_manager - .as_ref() - .and_then(|state| { - super::runtime_manager_actions(state) - .iter() - .position(|action| *action == super::RuntimeManagerAction::RemoveSelected) - .map(|index| state.items.len() + index) - }) - .expect("remove action row should exist"); - for _ in 0..remove_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("> Uninstall selected install")); - assert!(rendered.contains("Press Enter to review uninstalling it.")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "uninstall".to_owned(), - "release-pip-gfx120x-all-7-14-0".to_owned(), - ] - )); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn targeted_runtime_commands_reject_unknown_runtime_before_approval() -> anyhow::Result<()> { - for command in [ - "runtimes activate missing-runtime", - "runtimes uninstall missing-runtime", - ] { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - - assert!(app.handle_command(command), "{command}"); - - assert!(app.runtime_manager.is_some(), "{command}"); - assert!(app.pending_approval.is_none(), "{command}"); - assert!(app.running_job.is_none(), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - let rendered = render_test_terminal(&app, 120, 28); - assert!( - rendered.contains("I could not find `missing-runtime`"), - "{command}" - ); - assert!(rendered.contains("Use the arrow keys"), "{command}"); - assert!(!rendered.contains("Review this change"), "{command}"); - } - Ok(()) - } - - #[test] - fn targeted_runtime_activate_selects_matching_runtime_id() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - - assert!(app.handle_command("runtimes activate therock-release:gfx120X-all")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "activate".to_owned(), - "release-pip-gfx120x-all-7-14-0".to_owned(), - ] - )); - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn runtime_manager_b_key_does_not_trigger_hidden_rollback() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "old-release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 10, - )?; - write_test_runtime_with_key( - &app.paths, - "new-release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("new-release-pip-gfx120x-all".to_owned()); - app.config.previous_runtime_key = Some("old-release-pip-gfx120x-all".to_owned()); - app.config.save(&app.paths)?; - assert!(app.handle_command("runtimes")); - - handle_key(&mut app, key_event(KeyCode::Char('b'), KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(!rendered.contains("Roll back")); - assert!(!rendered.contains("rollback")); - Ok(()) - } - - #[test] - fn runtimes_rollback_typed_command_guides_back_to_picker() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "old-release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 10, - )?; - write_test_runtime_with_key( - &app.paths, - "new-release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("new-release-pip-gfx120x-all".to_owned()); - app.config.previous_runtime_key = Some("old-release-pip-gfx120x-all".to_owned()); - app.config.save(&app.paths)?; - - assert!(app.handle_command("runtimes rollback")); - - assert!(app.runtime_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Choose an install, then press Enter.")); - assert!(rendered.contains("No rollback step is needed.")); - assert!(rendered.contains("Default")); - assert!(rendered.contains("Installed")); - Ok(()) - } - - #[test] - fn runtimes_rollback_command_without_previous_runtime_still_opens_picker() -> anyhow::Result<()> - { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 10, - )?; - app.config.active_runtime_key = Some("release-pip-gfx120x-all-7-14-0".to_owned()); - app.config.previous_runtime_key = None; - app.config.save(&app.paths)?; - - assert!(app.handle_command("runtimes rollback")); - - assert!(app.runtime_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Choose an install, then press Enter.")); - assert_eq!(app.status, "Choose a ROCm install from the list."); - Ok(()) - } - - #[test] - fn empty_runtime_manager_offers_install_action() { - let mut app = test_app(); - assert!(app.handle_command("runtimes")); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Install ROCm")); - assert!(rendered.contains("Add existing install")); - assert!(!rendered.contains("Add existing Python folder")); - assert!(!rendered.contains("Import saved ROCm install")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.runtime_manager.is_none()); - assert!(matches!( - app.install_manager.as_ref().map(|state| &state.screen), - Some(super::InstallManagerScreen::Sdk { .. }) - )); - assert!(app.transcript.is_empty()); - } - - #[test] - fn runtime_manager_import_form_is_arrow_key_navigable() -> anyhow::Result<()> { - let mut app = test_app(); - let manifest = app.paths.data_dir.join("external-runtime.json"); - fs::create_dir_all(&app.paths.data_dir)?; - fs::write(&manifest, "{}")?; - assert!(app.handle_command("runtimes")); - - handle_key(&mut app, key_event(KeyCode::End, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.runtime_manager.as_ref().map(|state| &state.screen), - Some(super::RuntimeManagerScreen::ImportManifest { .. }) - )); - assert!(render_test_terminal(&app, 120, 28).contains("Import saved ROCm install")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.runtime_manager_editing_text()); - app.set_input(manifest.display().to_string()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "import".to_owned(), - manifest.display().to_string(), - ] - )); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Remember an existing ROCm install")); - assert!(!rendered.contains("/runtimes import")); - Ok(()) - } - - #[test] - fn runtime_manager_adopt_form_builds_simple_adopt_approval() -> anyhow::Result<()> { - let mut app = test_app(); - let env_dir = app.paths.data_dir.join("external-python"); - let bin_dir = env_dir.join(if cfg!(windows) { "Scripts" } else { "bin" }); - let python = bin_dir.join(if cfg!(windows) { - "python.exe" - } else { - "python" - }); - fs::create_dir_all(&bin_dir)?; - fs::write(&python, "python")?; - assert!(app.handle_command("runtimes")); - - handle_key(&mut app, key_event(KeyCode::End, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.runtime_manager.as_ref().map(|state| &state.screen), - Some(super::RuntimeManagerScreen::AdoptExisting { .. }) - )); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - app.set_input(env_dir.display().to_string()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "adopt".to_owned(), - "--python".to_owned(), - env_dir.display().to_string(), - "--channel".to_owned(), - "nightly".to_owned(), - ] - )); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Check an existing Python folder")); - assert!(!rendered.contains("--python")); - Ok(()) - } - - #[test] - fn runtimes_import_without_args_opens_guided_form() { - let mut app = test_app(); - - assert!(app.handle_command("runtimes import")); - - assert!(matches!( - app.runtime_manager.as_ref().map(|state| &state.screen), - Some(super::RuntimeManagerScreen::ImportManifest { .. }) - )); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Choose the saved ROCm install file")); - assert!(rendered.contains("Import saved ROCm install")); - assert!(!rendered.to_ascii_lowercase().contains("record")); - } - - #[test] - fn runtimes_import_replace_without_file_opens_guided_form() { - let mut app = test_app(); - - assert!(app.handle_command("runtimes import --replace")); - - assert!(matches!( - app.runtime_manager.as_ref().map(|state| &state.screen), - Some(super::RuntimeManagerScreen::ImportManifest { replace: true, .. }) - )); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Replace")); - assert!(rendered.contains("yes")); - } - - #[test] - fn runtimes_import_replace_form_preserves_replace_flag() -> anyhow::Result<()> { - let mut app = test_app(); - let manifest = app.paths.data_dir.join("saved-runtime.json"); - fs::create_dir_all(&app.paths.data_dir)?; - fs::write(&manifest, "{}")?; - - assert!(app.handle_command("runtimes import --replace")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - app.set_input(manifest.display().to_string()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "import".to_owned(), - manifest.display().to_string(), - "--replace".to_owned(), - ] - )); - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn runtime_manager_install_opens_guided_sdk_form_without_approval() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("runtimes")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.runtime_manager.is_none()); - assert!(app.install_manager.is_some()); - assert!(matches!( - app.install_manager.as_ref().map(|state| &state.screen), - Some(super::InstallManagerScreen::Sdk { .. }) - )); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!( - app.selected_install_sdk_choice(), - super::InstallSdkChoice::Folder - ); - let rendered = render_test_terminal(&app, 140, 30); - assert!(rendered.contains("Install ROCm")); - assert!(rendered.contains("Install folder")); - assert!(rendered.contains("Recommended Python install")); - assert!(rendered.contains("Stable (default)")); - assert!(!rendered.contains("Advanced archive install")); - assert_eq!(super::install_sdk_choices().len(), 4); - let detail = super::install_manager_detail_text(&app); - assert!(detail.contains("Left/Right changes common folders")); - assert!(detail.contains("Enter opens the folder picker")); - assert!(!rendered.contains("Review this change")); - Ok(()) - } - - #[test] - fn runtime_manager_install_folder_row_cycles_presets_with_arrows() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("runtimes")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!( - app.selected_install_sdk_choice(), - super::InstallSdkChoice::Folder - ); - let original = match app.install_manager.as_ref().map(|state| &state.screen) { - Some(super::InstallManagerScreen::Sdk { folder, .. }) => folder.clone(), - _ => String::new(), - }; - - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - - let changed = match app.install_manager.as_ref().map(|state| &state.screen) { - Some(super::InstallManagerScreen::Sdk { folder, .. }) => folder.clone(), - _ => String::new(), - }; - assert_ne!(changed, original); - assert_eq!( - app.selected_install_sdk_choice(), - super::InstallSdkChoice::Folder - ); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn install_sdk_folder_row_enter_opens_folder_picker() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("install sdk")); - - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!( - app.selected_install_sdk_choice(), - super::InstallSdkChoice::Folder - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.folder_browser.is_some()); - assert!(app.install_manager.is_some()); - assert!(!app.install_manager_editing_text()); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!( - rendered.contains("Pick where ROCm/TheRock should be installed."), - "{rendered}" - ); - assert!(app.pending_approval.is_none()); - Ok(()) - } - - #[test] - fn runtime_manager_install_approval_shows_exact_folder_and_pip_cache() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("runtimes")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Char('t'), KeyModifiers::NONE)); - let folder = app.paths.data_dir.join("custom-runtime"); - app.set_input(folder.display().to_string()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - folder.display().to_string(), - ] - )); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(modal.contains("Folder:")); - assert!(modal.contains("custom-runtime")); - assert!(modal.contains("Downloaded files:")); - assert!(modal.contains("pip-cache")); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn runtime_manager_install_esc_returns_to_rocm_installs() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("runtimes")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.install_manager.is_some()); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.runtime_manager.is_some()); - assert!(app.install_manager.is_none()); - assert!(app.pending_approval.is_none()); - assert_eq!(app.status, "Back to ROCm installs."); - assert!(render_test_terminal(&app, 140, 30).contains("ROCm Installs")); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn install_command_opens_navigable_screen_without_args() { - let mut app = test_app(); - - assert!(app.handle_command("install")); - - assert!(app.install_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Install")); - assert!(rendered.contains("> Install ROCm")); - assert!(rendered.contains("Check driver setup")); - assert!(rendered.contains("Up/Down choose")); - assert!(!rendered.contains("Usage: /install")); - } - - #[test] - fn slash_first_views_hide_backend_jargon() { - for command in ["install", "runtimes", "daemon"] { - let mut app = test_app(); - assert!(app.handle_command(command), "{command}"); - - let rendered = render_test_terminal(&app, 140, 30); - for forbidden in [ - "Python wheels", - "ROCm tarball", - "registered ROCm runtimes", - "runtime key:", - "registry:", - "daemon policy", - ] { - assert!( - !rendered.contains(forbidden), - "{command} first view should hide `{forbidden}`\n{rendered}" - ); - } - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn install_sdk_bad_format_uses_friendly_labels_without_backend_jargon() { - let mut app = test_app(); - - assert!(app.handle_command("install sdk --format nope")); - - assert!(matches!( - app.install_manager.as_ref().map(|state| &state.screen), - Some(super::InstallManagerScreen::Sdk { .. }) - )); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("recommended Python install below")); - assert!(!rendered.contains("Advanced archive install")); - assert!(rendered.contains("Use the recommended Python install below")); - for forbidden in ["pip", "tarball", "wheels", "venv"] { - assert!( - !rendered.to_ascii_lowercase().contains(forbidden), - "install repair text should hide `{forbidden}`\n{rendered}" - ); - } - } - - #[test] - fn daemon_status_uses_background_helper_language() { - let mut app = test_app(); - - assert!(app.handle_command("daemon")); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Background Helper")); - assert_eq!(app.status, "Background helper shown."); - assert!(!rendered.contains("daemon policy")); - assert!(!app.status.to_ascii_lowercase().contains("daemon policy")); - assert!(app.transcript.is_empty()); - } - - #[test] - fn status_commands_open_navigable_screens_without_transcript_dumps() { - for (command, title) in [ - ("help", "Help"), - ("permissions status", "Permissions"), - ("daemon", "Background Helper"), - ("uninstall", "Uninstall"), - ] { - let mut app = test_app(); - - assert!(app.handle_command(command), "{command}"); - - if command == "help" { - let state = app - .overlay_card - .as_ref() - .unwrap_or_else(|| panic!("{command} should open an overlay card")); - assert_eq!(state.title, "Command List", "{command}"); - } else { - let state = app - .command_screen - .as_ref() - .unwrap_or_else(|| panic!("{command} should open a command screen")); - assert_eq!(state.title, title, "{command}"); - } - assert!(app.transcript.is_empty(), "{command}"); - let rendered = render_test_terminal(&app, 120, 24); - if command == "help" { - assert!(rendered.contains("Command List"), "{command}"); - assert!(rendered.contains("Set up ROCm"), "{command}"); - } else { - assert!(rendered.contains(title), "{command}"); - assert!(rendered.contains("Back"), "{command}"); - } - } - - let mut app = test_app(); - assert!(app.handle_command("chat")); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("I need a local model before I can chat")); - assert!(rendered.contains("Model type recommended model")); - - let mut app = test_app(); - assert!(app.handle_command("examine")); - assert!(app.examine_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Examine")); - assert!(rendered.contains("Overview")); - assert!(!rendered.contains("> Refresh")); - assert!(!rendered.contains(" Refresh")); - assert!(rendered.contains("F5 refresh")); - - let mut app = test_app(); - assert!(app.handle_command("config")); - assert!(app.config_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Settings")); - assert!(rendered.contains("GPU checks")); - assert!(rendered.contains("Up/Down choose")); - } - - #[test] - fn help_menu_opens_selected_command_with_arrows() { - let mut app = test_app(); - - assert!(app.handle_command("help")); - assert!(app.overlay_card.is_some()); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.examine_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.overlay_card.is_none()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn f1_opens_scrollable_help_topics() { - let mut app = test_app(); - - handle_key(&mut app, key_event(KeyCode::F(1), KeyModifiers::NONE)); - - let state = app.overlay_card.as_ref().expect("F1 help should open"); - assert_eq!(state.title, "Help"); - let labels = state - .actions - .iter() - .map(|action| super::overlay_card_action_label(state, *action)) - .collect::>(); - assert!(labels.contains(&"Keyboard shortcuts")); - assert!(labels.contains(&"CLI commands")); - assert!(super::overlay_card_detail_text(state).contains("Start here")); - - handle_key(&mut app, key_event(KeyCode::End, KeyModifiers::NONE)); - let state = app.overlay_card.as_ref().expect("help should stay open"); - assert!(super::overlay_card_detail_text(state).contains("Troubleshooting")); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert!( - app.overlay_card - .as_ref() - .is_some_and(|state| state.detail_scroll > 0) - ); - let rendered = render_test_terminal(&app, 80, 18); - assert!(rendered.contains("Help")); - assert!(rendered.contains("Keyboard")); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - } - - #[test] - fn home_help_opens_interactive_help_browser() { - let mut app = test_app(); - app.home_selection = super::home_dashboard_actions(&app) - .iter() - .position(|action| *action == super::HomeDashboardAction::Help) - .expect("home should include help"); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Help") - ); - assert!(app.command_screen.is_none()); - } - - #[test] - fn every_help_menu_row_opens_navigable_surface_without_transcript_dump() { - let actions = super::help_command_screen_actions(); - for (index, action) in actions.iter().enumerate() { - if *action == super::CommandScreenAction::Back { - continue; - } - let mut app = test_app(); - assert!(app.handle_command("help")); - app.overlay_card - .as_mut() - .expect("help card should open") - .selected = index; - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - active_tui_surface_count(&app), - 1, - "{action:?} should open exactly one TUI surface" - ); - assert!( - active_surface_selection(&app).is_some(), - "{action:?} should leave a highlighted row" - ); - assert!( - !app.transcript.iter().any(|line| line == "[Commands]") - && !app.transcript.iter().any(|line| line == "[Error]"), - "{action:?} should not dump command output into the transcript" - ); - } - } - - #[test] - fn help_menu_includes_plan_and_review_commands() { - let mut app = test_app(); - - assert!(app.handle_command("help")); - assert!(app.overlay_card.is_some()); - - let state = app.overlay_card.as_ref().expect("help card should open"); - let labels = state - .actions - .iter() - .map(|action| super::overlay_card_action_label(state, *action)) - .collect::>(); - assert!(labels.contains(&"Plan a request")); - assert!(labels.contains(&"Review requests")); - assert!(labels.contains(&"Approve request")); - assert!(labels.contains(&"Reject request")); - assert!(labels.contains(&"Edit request")); - assert!(labels.contains(&"Quit")); - - let rendered = render_test_terminal(&app, 120, 36); - assert!(rendered.contains("Plan a request")); - assert!(rendered.contains("Review requests")); - assert!(rendered.contains("Approve request")); - assert!(rendered.contains("Reject request")); - assert!(rendered.contains("Edit request")); - - handle_key(&mut app, key_event(KeyCode::End, KeyModifiers::NONE)); - let rendered = render_test_terminal(&app, 120, 36); - assert!(rendered.contains("Quit")); - let quit_detail = super::help_command_detail("quit"); - assert!(quit_detail.contains("/quit")); - assert!(quit_detail.contains("/exit")); - assert!(app.transcript.is_empty()); - } - - #[test] - fn polished_command_screens_offer_navigable_next_actions() -> anyhow::Result<()> { - let mut app = test_app(); - assert!(app.handle_command("chat")); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("I need a local model before I can chat")); - assert!(rendered.contains("Choose a model here")); - assert!(rendered.contains("Model type recommended model")); - assert!(app.serve_wizard.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - app.config.telemetry.mode = rocm_core::TELEMETRY_MODE_OFF.to_owned(); - app.config.save(&app.paths)?; - assert!(app.handle_command("gpu")); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("GPU status")); - assert!(rendered.contains("Settings")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.config_manager.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("daemon")); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Background helper")); - assert!(rendered.contains("Automations")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.automations_manager.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("daemon")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.services_manager.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("uninstall")); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Uninstall review")); - assert!(rendered.contains("Review uninstall")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn slash_subcommands_stay_on_navigable_surfaces_without_transcript_dumps() { - for (command, surface, message) in [ - ( - "runtimes activate", - "runtimes", - "Choose a ROCm install from this list", - ), - ( - "runtimes uninstall", - "runtimes", - "Choose a ROCm install from this list", - ), - ("runtimes import", "runtimes", "Import saved ROCm install"), - ("runtimes adopt", "runtimes", "Add existing Python folder"), - ( - "engine install", - "engine", - "Choose an engine from this list", - ), - ( - "automations enable", - "automations", - "Choose an automation check", - ), - ( - "services logs", - "services", - "Choose a service from this list", - ), - ("services stop", "services", "Stop selected server"), - ( - "services restart", - "services", - "Choose a service from this list", - ), - ] { - let mut app = test_app(); - - assert!(app.handle_command(command), "{command}"); - - let selection = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should keep a selected row")); - assert_eq!(selection.surface, surface, "{command}"); - assert_eq!( - active_tui_surface_count(&app), - 1, - "{command} should open exactly one TUI surface" - ); - assert!(app.pending_approval.is_none(), "{command}"); - assert!(app.running_job.is_none(), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains(message), "{command}\n{rendered}"); - assert!( - rendered.contains("Up/Down choose") || rendered.contains("Enter edits"), - "{command} should show navigable controls\n{rendered}" - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should stay selected after Down")); - assert_eq!(after.surface, surface, "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn slash_command_variants_stay_navigable_without_transcript_dumps() { - for command in [ - "setup status", - "setup reset", - "setup skip", - "setup nope", - "permissions status", - "permissions ask", - "permissions nope", - "runtimes list", - "runtimes activate", - "runtimes remove", - "runtimes rollback", - "runtimes import --replace", - "runtimes adopt --channel nightly", - "engine list", - "engine bogus", - "engine install", - "engine install not-real", - "model tiny-gpt2", - "model does-not-exist", - "plan", - "config show", - "config set-telemetry nope", - "config set-engine pytorch", - "automations list", - "automations reviews", - "automations enable", - "automations bogus", - "reviews missing-review", - "approve missing-review", - "reject missing-review", - "edit missing-review", - "install sdk --channel sideways", - "install driver --bogus", - "services list", - "services logs", - "services stop", - "services restart", - "services bogus", - "logs stop", - "logs refresh", - "logs --service", - "logs --search", - "logs --bogus", - "gpu refresh", - "gpu nonsense", - "update", - "update --apply --runtime", - "update --apply --bogus", - "daemon", - "chat hello", - "provider local", - "provider no-such extra", - "uninstall apply", - "serve", - "serve --bogus", - "serve tiny.gguf --device cpu", - ] { - let mut app = test_app(); - - assert!(app.handle_command(command), "{command}"); - - assert_eq!( - active_tui_surface_count(&app), - 1, - "{command} should open exactly one TUI surface" - ); - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should leave a highlighted row")); - assert!( - before.row_count > 0, - "{command} should expose at least one selectable row" - ); - assert!( - app.running_job.is_none(), - "{command} should not launch work before the user chooses an action" - ); - assert!( - !app.transcript.iter().any(|line| line == "[Commands]") - && !app.transcript.iter().any(|line| line == "[Error]") - && app.transcript.is_empty(), - "{command} should not dump output into the transcript" - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should remain highlighted after Down")); - assert_eq!( - before.surface, after.surface, - "{command} should keep focus on the same screen after Down" - ); - if before.row_count > 1 { - assert_ne!( - before.selected, after.selected, - "{command} should move the highlighted row with Down" - ); - } - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn slash_actions_stream_inside_active_surface_without_transcript_dump() { - for (command, title) in [ - ("runtimes", "Runtimes"), - ("install", "Install"), - ("engine", "Engine"), - ("serve", "Serve"), - ("update", "Update"), - ("automations", "Automations"), - ("services", "Services"), - ("chat hello", "Serve"), - ("plan serve a model", "Plan"), - ("uninstall", "Uninstall"), - ] { - let mut app = test_app(); - assert!(app.handle_command(command), "{command}"); - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should open a selected surface")); - - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: title.to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "progress shown live".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: format!( - "command: rocm {command}\nstatus: ok\n\nstdout:\nfinished {command}" - ), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should keep its active surface")); - assert_eq!(after.surface, before.surface, "{command}"); - assert_eq!(active_tui_surface_count(&app), 1, "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - let rendered = render_test_terminal(&app, 140, 32); - assert!( - !rendered.contains("command: rocm") && !rendered.contains("status: ok"), - "{command} should hide raw command labels\n{rendered}" - ); - assert!( - !rendered.contains("Live output") && !rendered.contains("Recent output"), - "{command} should stay terse after completion\n{rendered}" - ); - } - } - - #[test] - fn hidden_proposals_alias_stays_navigable_without_being_advertised() -> anyhow::Result<()> { - let mut app = test_app(); - append_test_proposal(&app.paths, "proposal-hidden-alias")?; - - assert!(app.handle_command("proposals")); - - let before = active_surface_selection(&app) - .expect("hidden proposals alias should open a selected surface"); - assert_eq!(before.surface, "automations"); - assert_eq!(active_tui_surface_count(&app), 1); - assert!(app.automations_manager.is_some()); - assert!(app.transcript.is_empty()); - assert!( - super::SLASH_COMMANDS - .iter() - .all(|command| command.name != "proposals") - ); - assert!(!super::help_command_screen_actions().iter().any(|action| { - matches!(action, super::CommandScreenAction::OpenCommand("proposals")) - })); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .expect("hidden proposals alias should stay selected after Down"); - assert_eq!(after.surface, "automations"); - if before.row_count > 1 { - assert_ne!(before.selected, after.selected); - } - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn logs_option_errors_stay_on_navigable_logs_screen_without_transcript_dump() { - for (command, message) in [ - ("logs --bogus", "I do not recognize logs option"), - ( - "logs --service", - "Choose a service before opening service logs", - ), - ("logs --search", "Type a search term after --search"), - ] { - let mut app = test_app(); - - assert!(app.handle_command(command), "{command}"); - - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should open logs")); - assert_eq!(before.surface, "logs", "{command}"); - assert_eq!(active_tui_surface_count(&app), 1, "{command}"); - assert!(app.command_screen.is_none(), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - let rendered = render_test_terminal(&app, 120, 26); - assert!(rendered.contains(message), "{command}\n{rendered}"); - assert!( - rendered.contains("Previous page") && rendered.contains("Back"), - "{command} should keep selectable log choices visible\n{rendered}" - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should keep logs selected")); - assert_eq!(after.surface, "logs", "{command}"); - assert_ne!(before.selected, after.selected, "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn clear_and_quit_commands_confirm_from_navigable_screens() { - let mut app = test_app(); - app.push_block("Before", "old transcript"); - assert!(app.handle_command("clear")); - - assert!(app.overlay_card.is_some()); - assert!(app.command_screen.is_none()); - assert!(!app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Clear transcript")); - assert!(rendered.contains("Keep transcript")); - assert!(!rendered.contains("this TUI")); - assert!(rendered.contains("this app")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - assert!(!app.transcript.is_empty()); - assert!(app.handle_command("clear")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("exit")); - assert!(app.overlay_card.is_some()); - assert!(app.command_screen.is_none()); - assert!(!app.should_quit); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Quit")); - assert!(rendered.contains("Stay in ROCm CLI")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(!app.should_quit); - assert!(app.handle_command("exit")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.should_quit); - } - - #[test] - fn help_clear_and_quit_cards_preserve_current_screen() { - for command in ["help", "clear", "quit"] { - let mut app = test_app(); - assert!(app.handle_command("logs")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should start on logs")); - assert_eq!(before.surface, "logs", "{command}"); - - assert!(app.handle_command(command), "{command}"); - - let card = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should focus an overlay card")); - assert_eq!(card.surface, "overlay", "{command}"); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Esc close"), "{command}"); - assert!(!rendered.contains("Left/Right page"), "{command}"); - assert!(app.logs_view.is_some(), "{command}"); - assert!(app.command_screen.is_none(), "{command}"); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should return to logs")); - assert_eq!(after.surface, "logs", "{command}"); - assert_eq!(after.selected, before.selected, "{command}"); - assert!(app.overlay_card.is_none(), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn overlay_card_suppresses_prompt_completion_until_closed() { - let mut app = test_app(); - app.push_block("Before", "line"); - app.set_input("/he".to_owned()); - assert!(app.slash_completion_menu().is_some()); - - handle_key( - &mut app, - key_event(KeyCode::Char('l'), KeyModifiers::CONTROL), - ); - - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Clear") - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Keep transcript")); - assert!(!rendered.contains("/help")); - assert!(!rendered.contains("Tab/Down next")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert!(app.command_screen.is_none()); - assert_eq!(app.input, "/he"); - assert!(!app.transcript.is_empty()); - } - - #[test] - fn overlay_card_enter_preserves_prompt_and_underlying_surface_selection() { - let mut app = test_app(); - assert!(app.handle_command("logs")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let before = active_surface_selection(&app).expect("logs selection"); - app.set_input("/he".to_owned()); - app.open_unknown_command_overlay("wat"); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert_eq!(app.input, "/he"); - let after = active_surface_selection(&app).expect("logs selection after card"); - assert_eq!(after.surface, before.surface); - assert_eq!(after.selected, before.selected); - assert!(app.logs_view.is_some()); - } - - #[test] - fn read_only_overlay_card_enter_and_arrows_do_not_close_or_choose() { - let mut app = test_app(); - app.overlay_card = Some(super::OverlayCardState { - title: "Saved Setup Log".to_owned(), - detail: "line 1\nline 2\nline 3".to_owned(), - selected: 0, - detail_scroll: 0, - actions: Vec::new(), - }); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("PageUp/PageDown scroll")); - assert!(!rendered.contains("Enter open")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert!(app.overlay_card.is_some()); - assert_eq!( - app.status, - "Use PageUp/PageDown to scroll, or Esc to close this card." - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.overlay_card.is_some()); - assert_eq!( - app.status, - "Use PageUp/PageDown to scroll, or Esc to close this card." - ); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - } - - #[test] - fn approval_modal_keeps_keyboard_focus_over_overlay_card() { - let mut app = test_app(); - app.open_help_overlay(); - app.request_screen_cli_approval_with_explanation( - "Install ROCm", - "Install", - vec!["install".to_owned(), "sdk".to_owned()], - Some("rocm install sdk".to_owned()), - Some("Install ROCm into the selected folder.".to_owned()), - ); - - assert!(app.overlay_card.is_some()); - assert!(app.pending_approval.is_some()); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Review: Install ROCm")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.selected), - Some(1) - ); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.selected), - Some(0) - ); - } - - #[test] - fn approval_modal_consumes_non_approval_shortcuts() { - let mut app = test_app(); - app.request_screen_cli_approval_with_explanation( - "Install ROCm", - "Install", - vec!["install".to_owned(), "sdk".to_owned()], - Some("rocm install sdk".to_owned()), - Some("Install ROCm into the selected folder.".to_owned()), - ); - - assert!(app.pending_approval.is_some()); - assert!(app.overlay_card.is_none()); - - for key in [ - key_event(KeyCode::Char('l'), KeyModifiers::CONTROL), - key_event(KeyCode::Char('?'), KeyModifiers::NONE), - key_event(KeyCode::Char('x'), KeyModifiers::NONE), - ] { - handle_key(&mut app, key); - assert!(app.pending_approval.is_some()); - assert!(app.overlay_card.is_none()); - assert!(app.input.is_empty()); - } - assert_eq!( - app.status, - "Review card is open. Use arrows, Enter/Y, N, or Esc." - ); - } - - #[test] - fn clear_and_quit_do_not_steal_running_install_screen() { - for command in ["clear", "quit", "exit"] { - let mut app = test_app(); - assert!(app.handle_command("install")); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - assert!(app.handle_command(command), "{command}"); - - assert!(app.install_manager.is_some(), "{command}"); - assert!(app.command_screen.is_none(), "{command}"); - assert!(!app.should_quit, "{command}"); - assert!( - app.status - .contains("Install is still running. Wait for it to finish"), - "{command}: {}", - app.status - ); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: format!("{command} did not steal the install screen"), - }) - .unwrap(); - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Install is working."), "{command}"); - assert!( - rendered.contains(&format!("{command} did not steal the install screen")), - "{command}" - ); - assert!( - app.install_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message - .contains(&format!("{command} did not steal the install screen"))), - "{command}" - ); - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn advertised_slash_commands_open_navigable_surface() { - for command in super::SLASH_COMMANDS { - let mut app = test_app(); - app.push_block("Before", "old transcript"); - - assert!(app.handle_command(command.name), "{}", command.name); - - assert_eq!( - active_tui_surface_count(&app), - 1, - "{} should open exactly one TUI surface", - command.name - ); - assert!( - !app.transcript.iter().any(|line| line == "[Commands]") - && !app.transcript.iter().any(|line| line == "[Error]"), - "{} should not dump command output into the transcript", - command.name - ); - let rendered = render_test_terminal(&app, 120, 30); - assert!( - rendered.contains("> "), - "{} should render a visible selection", - command.name - ); - } - } - - #[test] - fn advertised_slash_commands_accept_arrow_navigation_on_opened_surface() { - for command in super::SLASH_COMMANDS { - let mut app = test_app(); - - assert!(app.handle_command(command.name), "{}", command.name); - - assert_eq!( - active_tui_surface_count(&app), - 1, - "{} should open exactly one TUI surface", - command.name - ); - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{} should have a selected row", command.name)); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - let after = active_surface_selection(&app).unwrap_or_else(|| { - panic!("{} should keep a selected row after Down", command.name) - }); - assert_eq!( - before.surface, after.surface, - "{} should not switch screens while moving selection", - command.name - ); - if before.row_count > 1 { - assert_ne!( - before.selected, after.selected, - "{} should move the highlighted row with Down", - command.name - ); - } else { - assert_eq!( - after.selected, before.selected, - "{} single-row screen should keep its one highlighted row", - command.name - ); - } - assert!(app.input.is_empty(), "{}", command.name); - assert!(app.transcript.is_empty(), "{}", command.name); - } - } - - #[test] - fn advertised_slash_commands_accept_shift_tab_reverse_navigation_on_opened_surface() { - for command in super::SLASH_COMMANDS { - let mut app = test_app(); - - assert!(app.handle_command(command.name), "{}", command.name); - - assert_eq!( - active_tui_surface_count(&app), - 1, - "{} should open exactly one TUI surface", - command.name - ); - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{} should have a selected row", command.name)); - - handle_key(&mut app, key_event(KeyCode::BackTab, KeyModifiers::SHIFT)); - - let after = active_surface_selection(&app).unwrap_or_else(|| { - panic!( - "{} should keep a selected row after Shift+Tab", - command.name - ) - }); - assert_eq!( - before.surface, after.surface, - "{} should not switch screens while moving backward", - command.name - ); - if before.row_count > 1 { - assert_ne!( - before.selected, after.selected, - "{} should move the highlighted row with Shift+Tab", - command.name - ); - } else { - assert_eq!( - after.selected, before.selected, - "{} single-row screen should keep its one highlighted row", - command.name - ); - } - assert!(app.input.is_empty(), "{}", command.name); - assert!(app.transcript.is_empty(), "{}", command.name); - } - } - - #[test] - fn advertised_slash_commands_accept_tab_home_and_end_navigation_on_opened_surface() { - for command in super::SLASH_COMMANDS { - let mut app = test_app(); - - assert!(app.handle_command(command.name), "{}", command.name); - - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{} should have a selected row", command.name)); - - handle_key(&mut app, key_event(KeyCode::Tab, KeyModifiers::NONE)); - let after_tab = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{} should keep focus after Tab", command.name)); - assert_eq!(after_tab.surface, before.surface, "{}", command.name); - if before.row_count > 1 { - assert_ne!( - after_tab.selected, before.selected, - "{} should move the highlighted row with Tab", - command.name - ); - } - - handle_key(&mut app, key_event(KeyCode::Home, KeyModifiers::NONE)); - let after_home = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{} should keep focus after Home", command.name)); - assert_eq!(after_home.surface, before.surface, "{}", command.name); - if before.row_count > 0 { - assert_eq!( - after_home.selected, 0, - "{} should move to the first row with Home", - command.name - ); - } - - handle_key(&mut app, key_event(KeyCode::End, KeyModifiers::NONE)); - let after_end = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{} should keep focus after End", command.name)); - assert_eq!(after_end.surface, before.surface, "{}", command.name); - if before.row_count > 0 { - assert_eq!( - after_end.selected, - before.row_count.saturating_sub(1), - "{} should move to the last row with End", - command.name - ); - } - - assert!(app.input.is_empty(), "{}", command.name); - assert!(app.transcript.is_empty(), "{}", command.name); - } - } - - #[test] - fn advertised_slash_commands_keep_arrow_focus_after_accidental_typing() { - for command in super::SLASH_COMMANDS { - let mut app = test_app(); - - assert!(app.handle_command(command.name), "{}", command.name); - - let before = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{} should have a selected row", command.name)); - - handle_key(&mut app, key_event(KeyCode::Char('z'), KeyModifiers::NONE)); - let prompt_visible = super::should_draw_prompt_box(&app); - if prompt_visible { - assert_eq!(app.input, "z", "{}", command.name); - } else { - assert!(app.input.is_empty(), "{}", command.name); - } - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - let after = active_surface_selection(&app).unwrap_or_else(|| { - panic!("{} should keep a selected row after Down", command.name) - }); - assert_eq!( - before.surface, after.surface, - "{} should keep the visible screen focused", - command.name - ); - if before.row_count > 1 { - if command.name == "home" { - assert_ne!( - before.selected, after.selected, - "home should keep arrow focus because no prompt is visible" - ); - } else { - assert_ne!( - before.selected, after.selected, - "{} should move the screen highlight even after accidental typing", - command.name - ); - } - } - if prompt_visible { - assert_eq!(app.input, "z", "{}", command.name); - } else { - assert!(app.input.is_empty(), "{}", command.name); - } - assert!(app.transcript.is_empty(), "{}", command.name); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - if command.name == "setup" { - if app.folder_browser.is_some() { - assert!( - !app.onboarding_path_editing, - "{} should use the folder picker before manual typing", - command.name - ); - } else { - assert_eq!( - active_surface_selection(&app).unwrap().surface, - "overlay", - "{} should keep setup inside a focused confirmation card", - command.name - ); - } - } else { - assert!( - app.input.is_empty(), - "{} should clear accidental prompt text before screen action", - command.name - ); - } - assert!(app.transcript.is_empty(), "{}", command.name); - } - } - - #[test] - fn advertised_slash_commands_escape_once_after_accidental_typing_returns_home() { - for command in super::SLASH_COMMANDS { - let mut app = test_app(); - - assert!(app.handle_command(command.name), "{}", command.name); - - handle_key(&mut app, key_event(KeyCode::Char('z'), KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - if command.name == "setup" { - assert!(app.onboarding_active, "{}", command.name); - assert!( - app.status.contains("Quit setup selected"), - "setup should select the visible Quit setup row before ROCm is ready" - ); - } else if command.name == "home" { - assert_eq!(active_tui_surface_count(&app), 1, "{}", command.name); - assert_eq!( - active_surface_selection(&app).unwrap().surface, - "overlay", - "{} should focus the quit card", - command.name - ); - } else { - assert!(app.input.is_empty(), "{}", command.name); - assert_eq!(active_tui_surface_count(&app), 1, "{}", command.name); - assert_eq!( - active_surface_selection(&app).unwrap().surface, - "home", - "{} should land on Home after one Esc", - command.name - ); - } - assert!(app.transcript.is_empty(), "{}", command.name); - } - } - - #[test] - fn advertised_slash_commands_have_escape_path_or_required_setup_guard() { - for command in super::SLASH_COMMANDS { - let mut app = test_app(); - - assert!(app.handle_command(command.name), "{}", command.name); - - assert_eq!( - active_tui_surface_count(&app), - 1, - "{} should open exactly one TUI surface", - command.name - ); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - if command.name == "setup" { - assert!(app.onboarding_active, "required setup should stay visible"); - assert!( - app.status.contains("Quit setup selected"), - "setup should select the visible Quit setup row before ROCm is ready" - ); - } else if command.name == "home" { - assert_eq!( - active_tui_surface_count(&app), - 1, - "home Esc should open the quit card as the only focused surface" - ); - assert_eq!( - active_surface_selection(&app).unwrap().surface, - "overlay", - "home Esc should focus the quit card" - ); - } else { - assert_eq!( - active_tui_surface_count(&app), - 1, - "{} should return to Home with Esc", - command.name - ); - assert_eq!( - active_surface_selection(&app).unwrap().surface, - "home", - "{} should land on Home after Esc", - command.name - ); - } - assert!( - !app.transcript.iter().any(|line| line == "[Commands]") - && !app.transcript.iter().any(|line| line == "[Error]"), - "{} should not dump command output into the transcript", - command.name - ); - } - } - - #[test] - fn static_command_screens_page_keys_scroll_details_without_changing_selection() { - for command in ["examine", "logs", "help", "permissions"] { - let mut app = test_app(); - - assert!(app.handle_command(command), "{command}"); - let selected = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should have a selected row")); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should remain active")); - assert_eq!(after.surface, selected.surface, "{command}"); - assert_eq!(after.selected, selected.selected, "{command}"); - assert!( - active_surface_detail_scroll(&app).expect("surface should expose detail scroll") - > 0, - "{command}" - ); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should remain active")); - assert_eq!(after.selected, selected.selected, "{command}"); - assert_eq!(active_surface_detail_scroll(&app), Some(0), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn overlay_card_home_and_end_reset_detail_scroll() { - let mut app = test_app(); - assert!(app.handle_command("help")); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert!( - app.overlay_card - .as_ref() - .expect("help card should stay open") - .detail_scroll - > 0 - ); - - handle_key(&mut app, key_event(KeyCode::End, KeyModifiers::NONE)); - assert_eq!( - app.overlay_card - .as_ref() - .expect("help card should stay open") - .detail_scroll, - 0 - ); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert!( - app.overlay_card - .as_ref() - .expect("help card should stay open") - .detail_scroll - > 0 - ); - - handle_key(&mut app, key_event(KeyCode::Home, KeyModifiers::NONE)); - assert_eq!( - app.overlay_card - .as_ref() - .expect("help card should stay open") - .detail_scroll, - 0 - ); - assert!(app.transcript.is_empty()); - } - - #[test] - fn manager_screens_page_keys_scroll_details_without_changing_selection() { - for command in [ - "runtimes", - "install", - "engine", - "model", - "serve", - "update", - "automations", - "config", - "provider", - "services", - ] { - let mut app = test_app(); - - assert!(app.handle_command(command), "{command}"); - let selected = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should have a selected row")); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should remain active")); - assert_eq!(after.surface, selected.surface, "{command}"); - assert_eq!(after.selected, selected.selected, "{command}"); - assert_eq!(active_surface_detail_scroll(&app), Some(10), "{command}"); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - - let after = active_surface_selection(&app) - .unwrap_or_else(|| panic!("{command} should remain active")); - assert_eq!(after.selected, selected.selected, "{command}"); - assert_eq!(active_surface_detail_scroll(&app), Some(0), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - } - } - - #[test] - fn config_manager_toggles_telemetry_without_transcript_or_subprocess() -> anyhow::Result<()> { - let mut app = test_app(); - app.config.telemetry.mode = TELEMETRY_MODE_LOCAL.to_owned(); - app.config.save(&app.paths)?; - - assert!(app.handle_command("config")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.config_manager.is_some()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!(app.config.telemetry.mode, TELEMETRY_MODE_OFF); - let saved = RocmCliConfig::load(&app.paths)?; - assert_eq!(saved.telemetry.mode, TELEMETRY_MODE_OFF); - assert!(render_test_terminal(&app, 120, 24).contains("GPU checks set to off.")); - Ok(()) - } - - #[test] - fn config_command_set_telemetry_selects_settings_row_without_saving() -> anyhow::Result<()> { - let mut app = test_app(); - app.config.telemetry.mode = TELEMETRY_MODE_LOCAL.to_owned(); - app.config.save(&app.paths)?; - - assert!(app.handle_command("config set-telemetry off")); - - assert!(app.config_manager.is_some()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.selected_config_menu_choice(), - super::ConfigMenuChoice::Telemetry - ); - assert_eq!( - RocmCliConfig::load(&app.paths)?.telemetry.mode, - TELEMETRY_MODE_LOCAL - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Nothing changed yet")); - assert!(rendered.contains("Use the highlighted row")); - Ok(()) - } - - #[test] - fn config_provider_key_command_never_launches_stdin_blocked_subprocess() { - let mut app = test_app(); - - assert!(app.handle_command("config set-provider-key openai")); - - assert!(app.config_manager.is_some()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!(app.config_manager_editing_secret()); - let message = app - .config_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .expect("settings message"); - assert!(message.contains("The key is hidden while you type")); - app.set_input("sk-secret-value".to_owned()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("API Key")); - assert!(rendered.contains("***************")); - assert!(!rendered.contains("sk-secret-value")); - assert!(!rendered.contains("separate terminal")); - } - - #[test] - fn config_provider_settings_are_arrow_key_navigable() { - let mut app = test_app(); - assert!(app.handle_command("config")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.config_manager.as_ref().map(|state| &state.screen), - Some(super::ConfigManagerScreen::Provider { provider, .. }) - if provider == "openai" - )); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("OpenAI Provider")); - assert!(rendered.contains("Prompt sending")); - assert!(rendered.contains("Enter or update API key")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.config_manager_editing_secret()); - assert!(app.transcript.is_empty()); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!app.config_manager_editing_secret()); - assert!(app.config_manager.is_some()); - } - - #[test] - fn config_clear_provider_key_requires_arrow_key_confirmation() { - let store = Arc::new(TestProviderKeyStore::with_secret("openai", b"sk-test")); - let mut app = test_app(); - app.provider_key_store = store.clone(); - - assert!(app.handle_command("config clear-provider-key openai")); - - assert!(!app.config_manager_confirming_provider_clear()); - assert!(app.overlay_card.is_some()); - assert_eq!(store.secret("openai"), Some(b"sk-test".to_vec())); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Clear API Key")); - assert!(rendered.contains("Clear the saved OpenAI API key?")); - assert!(rendered.contains("Clear API key")); - assert!(rendered.contains("Keep key")); - assert!(app.transcript.is_empty()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.config_manager_confirming_provider_clear()); - assert!(app.overlay_card.is_none()); - assert_eq!(store.secret("openai"), Some(b"sk-test".to_vec())); - assert!(app.status.contains("kept")); - - assert!(app.handle_command("config clear-provider-key openai")); - assert!(app.overlay_card.is_some()); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(store.secret("openai"), None); - assert!(app.overlay_card.is_none()); - assert!(!app.config_manager_confirming_provider_clear()); - assert!(app.status.contains("cleared")); - let message = app - .config_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .expect("settings message"); - assert!(message.contains("OpenAI API key cleared")); - assert!(app.transcript.is_empty()); - } - - #[test] - fn config_provider_key_storage_failure_shows_retry_guidance() { - let mut app = test_app(); - app.provider_key_store = Arc::new(TestProviderKeyStore::failing("locked keychain")); - - assert!(app.handle_command("config set-provider-key openai")); - app.set_input("sk-secret-value".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let save_message = app - .config_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .expect("settings message"); - assert!(save_message.contains("OpenAI API key was not saved")); - assert!(save_message.contains("unlocking your operating system")); - assert!(save_message.contains("OPENAI_API_KEY")); - assert!(save_message.contains("failed to save openai API key")); - assert!(!save_message.contains("sk-secret-value")); - - assert!(app.handle_command("config clear-provider-key openai")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let clear_message = app - .config_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .expect("settings message"); - assert!(clear_message.contains("OpenAI API key was not cleared")); - assert!(clear_message.contains("unlocking your operating system")); - assert!(clear_message.contains("OPENAI_API_KEY")); - assert!(clear_message.contains("failed to clear openai API key")); - assert!(app.transcript.is_empty()); - } - - #[test] - fn config_manager_can_open_engine_and_runtime_screens_with_arrows() { - let mut app = test_app(); - assert!(app.handle_command("config")); - - let engine_index = super::config_menu_choices() - .iter() - .position(|choice| *choice == super::ConfigMenuChoice::DefaultEngine) - .expect("engine config row should exist"); - for _ in 0..engine_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.engine_manager.is_some()); - assert!(app.config_manager.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("config")); - let runtime_index = super::config_menu_choices() - .iter() - .position(|choice| *choice == super::ConfigMenuChoice::DefaultRuntime) - .expect("runtime config row should exist"); - for _ in 0..runtime_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.runtime_manager.is_some()); - assert!(app.config_manager.is_none()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn config_manager_show_setup_again_requires_confirmation_and_defaults_to_keep() - -> anyhow::Result<()> { - let mut app = test_app(); - app.config.onboarding_dismissed = true; - app.config.setup.completed = true; - app.config.save(&app.paths)?; - - assert!(app.handle_command("config")); - let setup_index = super::config_menu_choices() - .iter() - .position(|choice| *choice == super::ConfigMenuChoice::ShowSetupAgain) - .expect("setup reset row should exist"); - for _ in 0..setup_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Show setup again")); - assert!(rendered.contains("This does not uninstall ROCm")); - assert!(rendered.contains("API keys stay saved")); - assert!(rendered.contains("Provider settings stay saved")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_some()); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("First-time Setup") - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Keep settings")); - assert!(rendered.contains("Show setup again")); - assert!(rendered.contains("ROCm installs stay on this computer")); - assert!(app.config_manager.is_some()); - assert!(app.transcript.is_empty()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert!(app.config_manager.is_some()); - assert!(!app.onboarding_active); - assert!(RocmCliConfig::load(&app.paths)?.onboarding_dismissed); - assert_eq!(app.status, "Setup settings were kept."); - - app.select_config_menu_choice(super::ConfigMenuChoice::ShowSetupAgain); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.overlay_card.is_some()); - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert!(app.config_manager.is_some()); - assert!(!app.onboarding_active); - assert!(RocmCliConfig::load(&app.paths)?.onboarding_dismissed); - assert_eq!(app.status, "Setup settings were kept."); - Ok(()) - } - - #[test] - fn config_manager_show_setup_again_is_arrow_key_confirmed_and_non_destructive() - -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-settings-reset"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.onboarding_dismissed = true; - app.config.setup.completed = true; - app.config.setup.therock_venv = Some(install_root.clone()); - app.config.provider_config_mut("anthropic").enabled = true; - app.config.save(&app.paths)?; - - assert!(app.handle_command("config")); - app.select_config_menu_choice(super::ConfigMenuChoice::ShowSetupAgain); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert!(app.config_manager.is_none()); - assert!(app.onboarding_active); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!(install_root.join(".rocm-cli-runtime.json").is_file()); - assert_eq!( - app.status, - "Setup will show again. ROCm installs were not deleted." - ); - let saved = RocmCliConfig::load(&app.paths)?; - assert!(!saved.onboarding_dismissed); - assert!(!saved.setup.completed); - assert!(saved.provider_enabled("anthropic")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Set Up ROCm")); - assert!(!rendered.contains("Review: Uninstall")); - Ok(()) - } - - #[test] - fn config_set_default_runtime_selects_runtime_without_saving() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - - assert!(app.handle_command("config set-default-runtime therock-release:gfx120X-all")); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(config.default_runtime_id.is_none()); - assert!(app.config_manager.is_none()); - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.selected_runtime_manager_item() - .map(|runtime| runtime.runtime_key), - Some("release-pip-gfx120x-all-7-14-0".to_owned()) - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Nothing changed yet")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn config_set_default_runtime_guides_typos_and_runtime_keys_without_transcript() - -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - - assert!(app.handle_command("config set-default-runtime missing-runtime")); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(config.default_runtime_id.is_none()); - assert!(app.runtime_manager.is_some()); - assert!(app.config_manager.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("could not find a ready ROCm install")); - - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - - assert!(app.handle_command("config set-default-runtime release-pip-gfx120x-all-7-14-0")); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(config.default_runtime_id.is_none()); - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Nothing changed yet")); - assert_eq!( - app.selected_runtime_manager_item() - .map(|runtime| runtime.runtime_key), - Some("release-pip-gfx120x-all-7-14-0".to_owned()) - ); - Ok(()) - } - - #[test] - fn provider_command_opens_arrow_key_selector() { - let mut app = test_app(); - assert!(app.handle_command("provider")); - - assert!(app.provider_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("Providers")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.provider, "anthropic"); - assert!(app.provider_manager.is_some()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn provider_name_selects_row_without_switching_until_enter() { - let mut app = test_app(); - assert_eq!(app.provider, "local"); - - assert!(app.handle_command("provider anthropic")); - - assert_eq!(app.provider, "local"); - assert!(app.provider_manager.is_some()); - assert!(app.transcript.is_empty()); - let state = app.provider_manager.as_ref().expect("provider manager"); - assert_eq!( - super::provider_options() - .get(state.selected) - .map(|option| option.name), - Some("anthropic") - ); - assert!( - state - .message - .as_deref() - .is_some_and(|message| message.contains("Press Enter to use this provider")) - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.provider, "anthropic"); - assert!(app.transcript.is_empty()); - } - - #[test] - fn services_command_opens_navigable_manager() -> anyhow::Result<()> { - let mut app = test_app(); - let record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - Some("gpu_required".to_owned()), - ); - record.write()?; - - assert!(app.handle_command("services")); - - assert!(app.services_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Services")); - assert!(!rendered.contains("svc-qwen")); - assert!(rendered.contains("pytorch")); - assert!(rendered.contains("qwen")); - assert!(rendered.contains("Service details")); - assert!(rendered.contains("Show server logs")); - assert!(rendered.contains("Stop selected server")); - assert!(rendered.contains("Press Enter to open logs")); - Ok(()) - } - - #[test] - fn services_manager_hides_failed_service_but_direct_logs_still_work() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "failed".to_owned(); - record.write()?; - fs::write(&record.log_path, "service ready\n")?; - assert!(app.handle_command("services")); - - let state = app.services_manager.as_ref().expect("services screen"); - assert!(state.items.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(!rendered.contains("svc-qwen"), "{rendered}"); - - app.open_service_logs_browser(record.service_id.clone()); - assert!(app.logs_view.is_some()); - assert!(app.services_manager.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Service Log")); - assert!(rendered.contains("service ready")); - assert!(rendered.contains("Show file locations")); - assert!(!rendered.contains("service_id:")); - assert!(!rendered.contains("manifest:")); - assert!(!rendered.contains("log:")); - assert!(!rendered.contains(&record.manifest_path.display().to_string())); - assert!(!rendered.contains(&record.log_path.display().to_string())); - - let file_locations_index = super::logs_view_actions() - .iter() - .position(|action| *action == super::LogsViewAction::ToggleFileLocations) - .expect("file locations action should exist"); - let current_index = app.logs_view.as_ref().map_or(0, |state| state.selected); - let steps = (file_locations_index + super::logs_view_actions().len() - current_index) - % super::logs_view_actions().len(); - for _ in 0..steps { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("File Locations"), "{rendered}"); - let state = app.logs_view.as_ref().expect("logs view should stay open"); - assert!(state.show_file_locations); - let selected_before = state.selected; - let modal = state - .detail_modal - .as_ref() - .expect("file locations should open a modal card"); - assert!( - modal - .detail - .contains(&record.log_path.display().to_string()) - ); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let state = app.logs_view.as_ref().expect("logs view should stay open"); - assert_eq!(state.selected, selected_before); - assert!( - state - .detail_modal - .as_ref() - .is_some_and(|modal| modal.scroll > 0) - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - let state = app.logs_view.as_ref().expect("logs view should stay open"); - assert_eq!(state.selected, selected_before); - assert!(state.detail_modal.is_some()); - for key in [ - key_event(KeyCode::Char('r'), KeyModifiers::NONE), - key_event(KeyCode::Char('f'), KeyModifiers::NONE), - key_event(KeyCode::Char('s'), KeyModifiers::NONE), - key_event(KeyCode::Char('?'), KeyModifiers::NONE), - key_event(KeyCode::Char('l'), KeyModifiers::CONTROL), - key_event(KeyCode::Char('x'), KeyModifiers::NONE), - ] { - handle_key(&mut app, key); - let state = app.logs_view.as_ref().expect("logs view should stay open"); - assert_eq!(state.selected, selected_before); - assert!(state.detail_modal.is_some()); - assert!(!state.follow); - assert!(app.overlay_card.is_none()); - assert!(app.input.is_empty()); - } - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert!( - app.logs_view - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - .is_some_and(|modal| modal.scroll > 0) - ); - assert!( - !app.logs_view - .as_ref() - .expect("logs view should stay open") - .last_rendered - .contains(&record.log_path.display().to_string()) - ); - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Show file locations"), "{rendered}"); - assert!(!rendered.contains(&record.log_path.display().to_string())); - assert!( - app.logs_view - .as_ref() - .expect("logs view should stay open") - .detail_modal - .is_none() - ); - Ok(()) - } - - #[test] - fn services_manager_enter_opens_ready_service_chat() -> anyhow::Result<()> { - let mut app = test_app(); - let (port, _request_receiver) = spawn_fake_local_chat_server_for_model( - "ROCm is ready for local work.", - "Qwen/Qwen2.5-1.5B-Instruct", - )?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen-ready", - "pytorch", - "qwen", - "Qwen/Qwen2.5-1.5B-Instruct", - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.logs_view.is_none()); - assert!(app.services_manager.is_none()); - let state = app.command_screen.as_ref().expect("chat should open"); - assert_eq!(state.title, "Chat"); - assert!(state.chat_session.is_some()); - assert_eq!(app.provider, "local"); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn service_log_details_scroll_without_changing_selected_action() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - let mut log = String::new(); - for index in 1..=90 { - let _ = writeln!(log, "service-line-{index:03}"); - } - fs::write(&record.log_path, log)?; - assert!(app.handle_command("services")); - - for _ in 0..super::services_manager_choice_count(app.services_manager.as_ref().unwrap()) { - if app.selected_services_manager_action() - == Some(super::ServicesManagerAction::OpenLogs) - { - break; - } - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - assert_eq!( - app.selected_services_manager_action(), - Some(super::ServicesManagerAction::OpenLogs) - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.services_manager.is_some()); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Service Logs") - ); - let selected = app.services_manager.as_ref().map(|state| state.selected); - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert_eq!( - app.services_manager.as_ref().map(|state| state.selected), - selected - ); - assert!( - app.overlay_card - .as_ref() - .is_some_and(|state| state.detail_scroll > 0) - ); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.detail_scroll), - Some(0) - ); - Ok(()) - } - - #[test] - fn services_manager_stop_requests_rocmd_approval_without_transcript() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - handle_key(&mut app, key_event(KeyCode::Char('s'), KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::RocmdCommand { args, kind, .. }) - if args == &vec![ - "sandbox-run".to_owned(), - "stop_server".to_owned(), - "--allow-native-fallback".to_owned(), - "--service-id".to_owned(), - "svc-qwen".to_owned(), - ] - && matches!( - kind, - super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Stop, - service_id, - } if service_id == "svc-qwen" - ) - )); - assert!(app.services_manager.is_some()); - assert!(app.transcript.is_empty()); - let detail = super::services_manager_detail_text(&app); - assert!(!detail.contains("Review this change")); - assert!(!detail.contains("Stop the managed service `svc-qwen`.")); - assert!(detail.contains("Local model server")); - assert!(detail.contains("Choose Service details")); - assert!(!detail.contains("svc-qwen")); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Review: Stop selected server")); - assert!(rendered.contains("Stop selected server")); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Stop the managed service `svc-qwen`.")); - assert!(!rendered.contains("Stop service svc-qwen")); - assert!(!rendered.contains("/services stop svc-qwen")); - Ok(()) - } - - #[test] - fn services_manager_action_rows_are_reachable_with_arrows() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - let stop_index = app - .services_manager - .as_ref() - .and_then(|state| { - super::services_manager_actions(state) - .iter() - .position(|action| *action == super::ServicesManagerAction::Stop) - .map(|index| state.items.len() + index) - }) - .expect("stop action row should exist"); - for _ in 0..stop_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("> Stop selected server")); - assert!(rendered.contains("Press Enter to review stopping it.")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::RocmdCommand { args, kind, .. }) - if args == &vec![ - "sandbox-run".to_owned(), - "stop_server".to_owned(), - "--allow-native-fallback".to_owned(), - "--service-id".to_owned(), - "svc-qwen".to_owned(), - ] - && matches!( - kind, - super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Stop, - service_id, - } if service_id == "svc-qwen" - ) - )); - assert!(app.services_manager.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn services_manager_service_details_open_overlay_card() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - Some("therock-release".to_owned()), - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - let details_index = app - .services_manager - .as_ref() - .and_then(|state| { - super::services_manager_actions(state) - .iter() - .position(|action| *action == super::ServicesManagerAction::Details) - .map(|index| state.items.len() + index) - }) - .expect("details action row should exist"); - app.services_manager.as_mut().unwrap().selected = details_index; - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let card = app - .overlay_card - .as_ref() - .expect("service detail overlay should open"); - assert_eq!(card.title, "Service Details"); - assert!(card.detail.contains("Server id: svc-qwen")); - assert!(card.detail.contains("Address: http://127.0.0.1:11435")); - assert!(card.detail.contains("ROCm install: therock-release")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Service Details")); - assert!(rendered.contains("PageUp/PageDown scroll | Esc close")); - assert!(!rendered.contains("Enter open")); - Ok(()) - } - - #[test] - fn services_manager_chat_action_opens_local_tools_chat() -> anyhow::Result<()> { - let mut app = test_app(); - let (port, _request_receiver) = - spawn_fake_local_chat_server("ROCm is ready for local work.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - let chat_index = app - .services_manager - .as_ref() - .and_then(|state| { - super::services_manager_actions(state) - .iter() - .position(|action| *action == super::ServicesManagerAction::Chat) - .map(|index| state.items.len() + index) - }) - .expect("chat action row should exist"); - for _ in 0..chat_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("> Chat with selected server")); - assert!(rendered.contains("It can check this computer")); - let detail = super::services_manager_detail_text(&app); - assert!(detail.contains("Changes still need your OK")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let state = app - .command_screen - .as_ref() - .expect("chat screen should open"); - assert_eq!(state.title, "Chat"); - assert_eq!(app.provider, "local"); - assert!(state.chat_tools); - assert!(state.chat_prompt.is_none()); - let session = state - .chat_session - .as_ref() - .expect("chat session should open"); - assert_eq!(session.service_id.as_deref(), Some("svc-qwen")); - assert_eq!( - session.model.as_deref(), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - assert!(state.detail.contains("Local assistant")); - assert!(state.detail.contains("Server details")); - assert!(!state.detail.contains("svc-qwen")); - assert!(state.detail.contains("Ask me what you want to do")); - assert!(state.detail.contains("review card first")); - assert!( - state - .actions - .contains(&super::CommandScreenAction::ViewChatConnection) - ); - assert!( - state - .actions - .contains(&super::CommandScreenAction::SendChat) - ); - assert!(app.services_manager.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn served_model_chat_accepts_typed_messages_and_uses_selected_model() -> anyhow::Result<()> { - let mut app = test_app(); - let (port, request_receiver) = - spawn_fake_local_chat_server_repeated("ROCm is ready for local work.", 2)?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - let chat_index = app - .services_manager - .as_ref() - .and_then(|state| { - super::services_manager_actions(state) - .iter() - .position(|action| *action == super::ServicesManagerAction::Chat) - .map(|index| state.items.len() + index) - }) - .expect("chat action row should exist"); - for _ in 0..chat_index { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - let rendered = render_test_terminal(&app, 120, 32); - assert!(app.command_screen_is_chat_session(), "{rendered}"); - assert!( - app.command_screen - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - .is_none(), - "empty Enter should keep focus in the chat prompt" - ); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Type your message below")); - assert!(rendered.contains("Assistant")); - assert!(!rendered.contains("> Send message")); - assert!(!rendered.contains("Connection Details")); - - app.set_input("Is ROCm installed?".to_owned()); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Enter sends | PageUp/PageDown scroll chat")); - assert!(!rendered.contains("Up/Down choose")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!(app.input, "Is ROCm installed?"); - assert_ne!(app.status, "Option selected. Press Enter to continue."); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("You")); - assert!(rendered.contains("Is ROCm installed?")); - assert!(rendered.contains("Assistant is thinking.")); - assert!(!rendered.contains("Live progress")); - - let request = request_receiver.recv_timeout(Duration::from_secs(5))?; - assert_eq!( - request.get("model").and_then(Value::as_str), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - assert_eq!( - request.get("tool_choice").and_then(Value::as_str), - Some("auto") - ); - let messages = request - .get("messages") - .and_then(Value::as_array) - .expect("chat request should include messages"); - assert!(messages.iter().any(|message| { - message.get("role").and_then(Value::as_str) == Some("user") - && message - .get("content") - .and_then(Value::as_str) - .is_some_and(|content| content.contains("Is ROCm installed?")) - })); - - poll_app_until_idle(&mut app); - assert!(app.running_job.is_none()); - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - assert!(session.turns.iter().any(|turn| { - turn.role == super::ChatSessionRole::User && turn.content.contains("Is ROCm installed?") - })); - let assistant_content = session - .turns - .iter() - .find(|turn| turn.role == super::ChatSessionRole::Assistant) - .map_or("", |turn| turn.content.as_str()); - assert!( - assistant_content.contains("Here is what I found on this computer.") - && (assistant_content.contains("GPU:") - || assistant_content.contains("WSL DXCore and ROCDXG plumbing detected")) - && assistant_content.contains("ROCm/TheRock:"), - "{assistant_content}" - ); - let assistant = session - .turns - .iter() - .find(|turn| turn.role == super::ChatSessionRole::Assistant) - .expect("assistant turn should be stored"); - assert!(!assistant.content.contains("chat response")); - assert!(!assistant.content.contains("provider:")); - assert!(!assistant.content.contains("ROCm checks used")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("You")); - assert!(rendered.contains("Is ROCm installed?")); - assert!(rendered.contains("Assistant")); - assert!(rendered.contains("Here is what I found on this computer.")); - assert!( - rendered.contains("GPU:") - || rendered.contains("WSL DXCore and ROCDXG plumbing detected") - ); - assert!(!rendered.contains("Live progress")); - assert!(app.transcript.is_empty()); - - app.set_input("What local models are running?".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.command_screen_is_chat_session()); - assert!(app.serve_wizard.is_none()); - assert!(app.running_job.is_some()); - let followup_request = request_receiver.recv_timeout(Duration::from_secs(5))?; - assert_eq!( - followup_request.get("model").and_then(Value::as_str), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - let followup_messages = followup_request - .get("messages") - .and_then(Value::as_array) - .expect("follow-up request should include messages"); - assert!(followup_messages.iter().any(|message| { - message.get("role").and_then(Value::as_str) == Some("user") - && message - .get("content") - .and_then(Value::as_str) - .is_some_and(|content| { - content.contains("Conversation so far:") - && content.contains("What local models are running?") - }) - })); - poll_app_until_idle(&mut app); - assert!(app.command_screen_is_chat_session()); - assert!(app.serve_wizard.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn chat_session_followup_uses_attached_service_when_model_label_is_stale() -> anyhow::Result<()> - { - let mut app = test_app(); - let (port, request_receiver) = - spawn_fake_local_chat_server("ROCm is ready for local work.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - if let Some(session) = app - .command_screen - .as_mut() - .and_then(|state| state.chat_session.as_mut()) - { - session.model = Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL.to_owned()); - } - - app.set_input("hi again".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.command_screen_is_chat_session()); - assert!(app.serve_wizard.is_none()); - assert!(app.running_job.is_some()); - let request = request_receiver.recv_timeout(Duration::from_secs(5))?; - assert_eq!( - request.get("model").and_then(Value::as_str), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - poll_app_until_idle(&mut app); - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - assert_eq!( - session.model.as_deref(), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - assert_eq!(session.service_id.as_deref(), Some("svc-qwen")); - assert!(app.serve_wizard.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn chat_install_prompt_waits_for_folder_then_opens_review_without_model_guess() - -> anyhow::Result<()> { - let mut app = test_app(); - let (port, request_receiver) = - spawn_fake_local_chat_server("This model response should not be used.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen25", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - - app.set_input("how do I get it to install therock?".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err() - ); - assert!(app.command_screen_is_chat_session()); - assert!(app.running_job.is_none()); - assert!(app.pending_approval.is_none()); - assert!(app.folder_browser.is_some()); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("I checked ROCm"), "{rendered}"); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - app.set_input("use D:\\ROCm\\therock_venvs".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err() - ); - assert!(app.command_screen_is_chat_session()); - assert!(app.running_job.is_none()); - let pending = app - .pending_approval - .as_ref() - .expect("folder reply should open review card"); - assert_eq!(pending.title, "Install ROCm"); - assert!( - matches!( - &pending.action, - super::ApprovalAction::CliCommand { - args, - display_command: Some(display), - .. - } if args.windows(2).any(|pair| { - pair[0] == "--prefix" && pair[1] == "D:\\ROCm\\therock_venvs" - }) - && display.contains("D:\\ROCm\\therock_venvs") - ), - "{pending:#?}" - ); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Install ROCm"), "{rendered}"); - assert!(!rendered.contains("Review this change"), "{rendered}"); - assert!(!rendered.contains("What will happen"), "{rendered}"); - assert!(rendered.contains("D:\\ROCm\\therock_venvs"), "{rendered}"); - assert!(!rendered.contains("I checked ROCm"), "{rendered}"); - Ok(()) - } - - #[test] - fn assistant_tui_support_prompts_reach_validated_local_model() -> anyhow::Result<()> { - for prompt in [ - "is therock setup on my machine", - "which gpu is on my machine", - "how do i setup comfyui", - "which llms can my machine support", - ] { - let mut app = test_app(); - let (port, request_receiver) = spawn_fake_local_chat_server_repeated( - "I can help with that from inside ROCm CLI.", - 2, - )?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen25", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - - app.set_input(prompt.to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let request = request_receiver - .recv_timeout(Duration::from_secs(5)) - .map_err(|error| { - anyhow::anyhow!("expected model request for prompt `{prompt}`: {error}") - })?; - assert_eq!( - request.get("model").and_then(Value::as_str), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL), - "{prompt}" - ); - assert_eq!( - request.get("tool_choice").and_then(Value::as_str), - Some("auto"), - "{prompt}" - ); - let messages = request - .get("messages") - .and_then(Value::as_array) - .expect("chat request should include messages"); - assert!( - !messages - .iter() - .any(|message| message.get("role").and_then(Value::as_str) == Some("system")), - "local server request should fold system guidance into user content for {prompt}" - ); - assert!( - messages.iter().any(|message| { - message.get("role").and_then(Value::as_str) == Some("user") - && message - .get("content") - .and_then(Value::as_str) - .is_some_and(|content| { - content.contains("ROCm CLI guidance") - && content.contains("which GPU is on this machine") - && content.contains("Qwen3-4B-Instruct-2507-GGUF") - && content.contains("ComfyUI") - && content.contains(prompt) - }) - }), - "user prompt and ROCm guidance should be sent in one local-compatible message for {prompt}" - ); - - poll_app_until_idle(&mut app); - assert!(app.running_job.is_none(), "{prompt}"); - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - assert_eq!( - session.model.as_deref(), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - assert!(session.turns.iter().any(|turn| { - turn.role == super::ChatSessionRole::User && turn.content == prompt - })); - let assistant_turn = session - .turns - .iter() - .find(|turn| turn.role == super::ChatSessionRole::Assistant) - .expect("assistant turn should be stored"); - assert!( - !assistant_turn.content.contains("I checked ROCm"), - "{prompt}: {}", - assistant_turn.content - ); - if prompt == "how do i setup comfyui" { - assert!( - assistant_turn - .content - .contains("I can help with that from inside ROCm CLI.") - || assistant_turn.content.contains("ComfyUI"), - "{prompt}: {}", - assistant_turn.content - ); - } else { - assert!( - assistant_turn - .content - .contains("Here is what I found on this computer.") - || assistant_turn - .content - .contains("I can help with that from inside ROCm CLI."), - "{prompt}: {}", - assistant_turn.content - ); - } - assert!(app.transcript.is_empty()); - } - Ok(()) - } - - #[test] - fn assistant_support_prompts_from_home_route_to_chat_or_guided_start() -> anyhow::Result<()> { - for prompt in [ - "is therock setup on my machine", - "which gpu is on my machine", - "how do i setup comfyui", - "can you setup comfyui for me", - "can you setup and serve an llm for me", - "which llms can my machine support", - ] { - let mut app = test_app(); - let (port, request_receiver) = - spawn_fake_local_chat_server("I can help with ROCm CLI for you.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen15", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - - app.set_input(prompt.to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - if let Ok(request) = request_receiver.recv_timeout(Duration::from_millis(300)) { - assert_eq!( - request.get("model").and_then(Value::as_str), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL), - "{prompt}" - ); - assert!(app.command_screen_is_chat_session(), "{prompt}"); - assert!(app.transcript.is_empty(), "{prompt}"); - assert!(app.services_manager.is_none(), "{prompt}"); - assert!(app.examine_manager.is_none(), "{prompt}"); - assert!(app.install_manager.is_none(), "{prompt}"); - poll_app_until_idle(&mut app); - } else { - let rendered = render_test_terminal(&app, 140, 32); - assert!( - app.pending_approval.is_some() - || app.install_manager.is_some() - || app.serve_wizard.is_some(), - "{prompt}: {rendered}" - ); - assert!(app.transcript.is_empty(), "{prompt}: {rendered}"); - assert!(!rendered.contains("I checked ROCm"), "{prompt}: {rendered}"); - assert!( - !rendered.contains("ROCm CLI summary"), - "{prompt}: {rendered}" - ); - } - } - - let mut app = test_app(); - app.set_input("which gpu is on my machine".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert!( - app.serve_wizard - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains("local model before I can chat")) - ); - - let mut app = test_app(); - let (port, request_receiver) = spawn_fake_local_chat_server_for_model( - "This non-assistant model should not be used.", - "Qwen/Qwen3.5", - )?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-other-ready", - "pytorch", - "other", - "Qwen/Qwen3.5", - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.set_input("which gpu is on my machine".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err() - ); - Ok(()) - } - - #[test] - fn home_install_words_open_folder_picker_without_needing_chat() { - for prompt in [ - "install therock", - "i need to install rocm", - "How do I setup TheRock?", - "how do I get it to install therock?", - "rocm please", - "make my AMD GPU ready", - ] { - let mut app = test_app(); - app.set_input(prompt.to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none(), "{prompt}"); - assert!(app.install_manager.is_none(), "{prompt}"); - assert!(app.folder_browser.is_some(), "{prompt}"); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("I checked ROCm"), "{rendered}"); - } - } - - #[test] - fn home_install_prompt_with_folder_opens_review_card_without_chat() { - let mut app = test_app(); - app.set_input("install therock into D:\\ROCm\\therock_venvs".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.install_manager.is_none()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let pending = app - .pending_approval - .as_ref() - .expect("explicit folder install should open review"); - assert_eq!(pending.title, "Install ROCm"); - assert!(matches!( - &pending.action, - super::ApprovalAction::CliCommand { - args, - display_command: Some(display), - .. - } if args.windows(2).any(|pair| { - pair[0] == "--prefix" && pair[1] == "D:\\ROCm\\therock_venvs" - }) && display.contains("D:\\ROCm\\therock_venvs") - )); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Install ROCm"), "{rendered}"); - assert!(!rendered.contains("Review this change"), "{rendered}"); - assert!(!rendered.contains("What will happen"), "{rendered}"); - assert!(!rendered.contains("I checked ROCm"), "{rendered}"); - } - - #[test] - fn home_non_standard_prompt_routes_to_ready_local_assistant() -> anyhow::Result<()> { - let mut app = test_app(); - let prompt = "write a tiny poem about GPU fans"; - let (port, request_receiver) = spawn_fake_local_chat_server("Sure, here is a tiny poem.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen15", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - - app.set_input(prompt.to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let request = request_receiver.recv_timeout(Duration::from_secs(5))?; - assert_eq!( - request.get("model").and_then(Value::as_str), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - assert!(app.command_screen_is_chat_session()); - assert!(app.transcript.is_empty()); - assert!( - request - .get("messages") - .and_then(Value::as_array) - .is_some_and(|messages| messages.iter().any(|message| { - message.get("role").and_then(Value::as_str) == Some("user") - && message - .get("content") - .and_then(Value::as_str) - .is_some_and(|content| content.contains(prompt)) - })) - ); - poll_app_until_idle(&mut app); - let rendered = render_test_terminal(&app, 120, 32); - assert!( - rendered.contains("Sure, here is a tiny poem."), - "{rendered}" - ); - Ok(()) - } - - #[test] - fn assistant_tui_setup_prompts_open_review_cards() -> anyhow::Result<()> { - for (prompt, response, expected_title, expected_command) in [ - ( - "can you setup comfyui for me", - serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "I can install ComfyUI after you approve it.", - "tool_calls": [{ - "id": "call-comfy", - "type": "function", - "function": { - "name": "rocm_command", - "arguments": "{\"args\":[\"comfyui\",\"install\"],\"reason\":\"Install ComfyUI into ROCm CLI's app folder.\"}" - } - }] - } - }] - }), - "Install ComfyUI", - "rocm comfyui install", - ), - ( - "can you setup and serve an llm for me", - serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "I can start the recommended local assistant after you approve it.", - "tool_calls": [{ - "id": "call-serve", - "type": "function", - "function": { - "name": "launch_server", - "arguments": "{\"model\":\"qwen\",\"engine\":\"lemonade\",\"device\":\"gpu_required\"}" - } - }] - } - }] - }), - "Install engine", - "rocm engines install lemonade", - ), - ( - "install this specific TheRock wheel from date 06052026 into D:\\ROCm\\therock_venvs", - serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "I can install the TheRock build from June 5, 2026 after you approve it.", - "tool_calls": [{ - "id": "call-therock-date", - "type": "function", - "function": { - "name": "rocm_command", - "arguments": "{\"args\":[\"install\",\"sdk\",\"--channel\",\"release\",\"--format\",\"pip\",\"--prefix\",\"D:\\\\ROCm\\\\therock_venvs\",\"--build-date\",\"2026-06-05\"],\"reason\":\"Install the requested TheRock build date into the user's folder.\"}" - } - }] - } - }] - }), - "Install ROCm", - "rocm install sdk --channel release --format wheel --prefix D:\\ROCm\\therock_venvs --build-date 2026-06-05", - ), - ( - "install TheRock version 7.13.0a20260605 into D:\\ROCm\\therock_venvs", - serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "I can install that exact TheRock wheel version after you approve it.", - "tool_calls": [{ - "id": "call-therock-version", - "type": "function", - "function": { - "name": "rocm_command", - "arguments": "{\"args\":[\"install\",\"sdk\",\"--channel\",\"release\",\"--format\",\"pip\",\"--prefix\",\"D:\\\\ROCm\\\\therock_venvs\",\"--version\",\"7.13.0a20260605\"],\"reason\":\"Install the requested exact TheRock version into the user's folder.\"}" - } - }] - } - }] - }), - "Install ROCm", - "rocm install sdk --channel release --format wheel --prefix D:\\ROCm\\therock_venvs --version 7.13.0a20260605", - ), - ] { - let mut app = test_app(); - let (port, request_receiver) = spawn_fake_local_chat_server_with_response(response)?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen25", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - - app.set_input(prompt.to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err(), - "{prompt}" - ); - poll_app_until_idle(&mut app); - - let pending = app - .pending_approval - .as_ref() - .expect("assistant should open a review card"); - assert_eq!(pending.title, expected_title); - let command = match &pending.action { - super::ApprovalAction::CliCommand { - display_command: Some(command), - .. - } => command, - other => panic!("unexpected pending action: {other:?}"), - }; - for token in expected_command.split_whitespace() { - assert!( - command.contains(token), - "expected `{command}` to contain `{token}` from `{expected_command}`" - ); - } - assert!(app.command_screen_is_chat_session()); - assert!(app.transcript.is_empty()); - } - Ok(()) - } - - #[test] - fn assistant_tui_asks_for_folder_before_therock_review_card() -> anyhow::Result<()> { - let mut app = test_app(); - let (port, request_receiver) = spawn_fake_local_chat_server_with_response( - serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "I can install TheRock after you approve it.", - "tool_calls": [{ - "id": "call-therock-missing-folder", - "type": "function", - "function": { - "name": "rocm_command", - "arguments": "{\"args\":[\"install\",\"sdk\",\"--channel\",\"release\",\"--format\",\"pip\",\"--build-date\",\"2026-06-05\"],\"reason\":\"Install the requested TheRock build.\"}" - } - }] - } - }] - }), - )?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen25", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - - app.set_input("install this specific TheRock wheel from date 06052026".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err() - ); - poll_app_until_idle(&mut app); - - assert!( - app.pending_approval.is_none(), - "folderless install should not open approval" - ); - assert!(app.command_screen_is_chat_session()); - assert!(app.folder_browser.is_some()); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("Example"), "{rendered}"); - Ok(()) - } - - #[test] - fn assistant_tui_install_words_open_folder_picker_without_rocm_check() -> anyhow::Result<()> { - for prompt in [ - "i need to install rocm", - "install therock", - "How do I get it to install therock?", - "How do I setup TheRock?", - "rocm please", - "make my AMD GPU ready", - ] { - let mut app = test_app(); - let (port, request_receiver) = - spawn_fake_local_chat_server("This should not be called.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen25", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - app.push_chat_session_turn( - super::ChatSessionRole::Assistant, - "Use /examine to refresh actual GPU memory fit before starting anything large.\nNative Windows note: Qwen/Qwen3.5-4B uses WSL/Linux through Windows.", - ); - - app.set_input(prompt.to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - poll_app_until_idle(&mut app); - - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err(), - "{prompt}" - ); - assert!(app.pending_approval.is_none(), "{prompt}"); - assert!(app.command_screen_is_chat_session(), "{prompt}"); - assert!(app.install_manager.is_none(), "{prompt}"); - assert!(app.folder_browser.is_some(), "{prompt}"); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("I checked ROCm"), "{rendered}"); - assert!(!rendered.contains("ROCm CLI summary"), "{rendered}"); - assert!(!rendered.contains("Example"), "{rendered}"); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_some(), "{prompt}"); - assert!(app.folder_browser.is_none(), "{prompt}"); - assert!(app.command_screen_is_chat_session(), "{prompt}"); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("Install ROCm"), "{rendered}"); - assert!(!rendered.contains("Review this change"), "{rendered}"); - assert!(!rendered.contains("What will happen"), "{rendered}"); - } - Ok(()) - } - - #[test] - fn pending_install_folder_answers_status_questions_without_losing_picker() -> anyhow::Result<()> - { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - - app.set_input("install therock".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.folder_browser.is_some()); - assert!(app.pending_approval.is_none()); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.folder_browser.is_none()); - app.set_input("did you install rocm?".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.command_screen_is_chat_session()); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Not yet"), "{rendered}"); - assert!(!rendered.contains("I checked ROCm"), "{rendered}"); - assert!(!rendered.contains("Choose ROCm result"), "{rendered}"); - - app.set_input("let me choose".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.folder_browser.is_some()); - assert!(app.command_screen_is_chat_session()); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - Ok(()) - } - - #[test] - fn chat_prompt_with_ready_non_assistant_server_opens_guided_start() -> anyhow::Result<()> { - let mut app = test_app(); - let (port, request_receiver) = - spawn_fake_local_chat_server_for_model("ROCm is installed and ready.", "Qwen/Qwen3.5")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.provider = "local".to_owned(); - - assert!(app.handle_command("chat hi")); - - assert!(app.serve_wizard.is_some()); - assert!(!app.command_screen_is_chat_session()); - assert!(app.running_job.is_none()); - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err() - ); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("I need a local model before I can chat.")); - assert!(rendered.contains("Model type recommended model")); - assert!(!rendered.contains("Review chat prompt")); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn tool_enabled_chat_emits_progress_and_keeps_approval_reason() -> anyhow::Result<()> { - let app = test_app(); - let (port, _request_receiver) = spawn_fake_local_chat_server_with_response( - serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": "ROCm is not installed yet, so I need to install TheRock first.", - "tool_calls": [{ - "id": "call-1", - "type": "function", - "function": { - "name": "install_sdk", - "arguments": "{\"channel\":\"release\",\"format\":\"pip\",\"prefix\":\"D:\\\\ROCm\\\\therock_venvs\"}" - } - }] - } - }] - }), - )?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - let request = crate::providers::ChatRequest { - model: Some("Qwen/Qwen3.5".to_owned()), - messages: vec![crate::providers::ChatMessage { - role: "user".to_owned(), - content: "help me install ROCm in D:\\ROCm\\therock_venvs".to_owned(), - }], - max_tokens: None, - rocm_tools: true, - }; - let (sender, receiver) = mpsc::channel(); - - let output = super::run_provider_chat_stream(&app.paths, "local", &request, &sender)?; - - let progress = receiver - .try_iter() - .filter_map(|event| match event { - super::RunningJobEvent::Stream { line, .. } => Some(line), - _ => None, - }) - .collect::>(); - assert!( - progress - .iter() - .any(|line| line.contains("Preparing a review card")), - "{progress:#?}" - ); - assert!( - progress - .iter() - .any(|line| line.contains("Review needed: rocm command")) - ); - let approval = output - .chat_approval - .expect("mutating tool call should request approval"); - assert_eq!( - approval.explanation.as_deref(), - Some( - "I can install ROCm/TheRock into the folder you chose. Review the card before anything downloads or changes." - ) - ); - Ok(()) - } - - #[test] - fn chat_session_running_tool_chat_keeps_logs_out_of_conversation() -> anyhow::Result<()> { - let mut app = test_app(); - let record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen25", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - app.open_local_rocm_tools_chat_session(Some(record)); - let sender = attach_running_job( - &mut app, - "Chat", - super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: true, - }, - ); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "Asking the assistant what to check.".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "Checked this computer: done".to_owned(), - }) - .unwrap(); - - app.poll_running_job(); - - let detail = super::command_screen_detail_text(&app); - assert!(detail.contains("Type your message below.")); - assert!(detail.contains("Assistant is thinking.")); - assert!(detail.contains("Changes still need your OK.")); - assert!(!detail.contains("Live progress")); - assert!(!detail.contains("Output: Asking the assistant what to check.")); - assert!(!detail.contains("Output: Checked this computer: done")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Assistant is thinking.")); - assert!(rendered.contains("Changes still need your OK.")); - assert!(!rendered.contains("Live progress")); - assert!(!rendered.contains("Checked this computer: done")); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn chat_session_display_text_hides_raw_tool_output_headers() { - let rendered = "\ -chat response - provider: local - model: Qwen/Qwen3.5 - rocm tools: enabled - -Assistant is checking ROCm first. -ROCm checks used - Examine: ok - os: windows - data_dir: C:\\Users\\user\\.rocm - cache_dir: C:\\Users\\user\\.rocm\\cache - -Assistant after ROCm checks -ROCm is installed and ready."; - - let display = super::chat_session_display_text(rendered); - - assert!(display.contains("Here is what I found.")); - assert!(display.contains("ROCm is installed and ready.")); - assert!(!display.contains("I checked ROCm")); - assert!(!display.contains("provider:")); - assert!(!display.contains("model:")); - assert!(!display.contains("data_dir:")); - assert!(!display.contains("ROCm checks used")); - } - - #[test] - fn chat_session_display_text_promotes_rocm_cli_summary() { - let rendered = "\ -chat response - provider: local - model: Qwen/Qwen3.5 - rocm tools: enabled - -Assistant is checking ROCm first. -ROCm checks used - Examine: ok - os: windows - data_dir: C:\\Users\\user\\.rocm - -ROCm CLI summary - GPU: AMD Radeon RX 9070 XT - ROCm/TheRock: installed and active for ROCm CLI (gfx120X-all), 7.13.0a20260511 - Install folder: D:\\ROCm\\therock_venvs - Downloads/cache: D:\\ROCm\\therock_venvs\\pip-cache - Note: no global legacy ROCm install was found; ROCm CLI is using its managed TheRock runtime."; - - let display = super::chat_session_display_text(rendered); - - assert!(display.contains("Here is what I found on this computer.")); - assert!(display.contains("AMD Radeon RX 9070 XT")); - assert!(display.contains("installed and active for ROCm CLI")); - assert!(display.contains("D:\\ROCm\\therock_venvs")); - assert!(display.contains("D:\\ROCm\\therock_venvs\\pip-cache")); - assert!(!display.contains("I checked ROCm")); - assert!(!display.contains("provider:")); - assert!(!display.contains("data_dir:")); - assert!(!display.contains("ROCm checks used")); - assert!(!display.contains("ROCm CLI summary")); - } - - #[test] - fn chat_session_command_result_opens_details_without_inline_output() { - let summary = "\ -Install failed. -Error: No matching distribution found for torch - -Live output - 2 output line(s) were shown while it ran. - -Recent output - Output: resolving torch wheels - Warning: pip is checking candidates - -Full log - C:\\Users\\user\\.rocm\\logs\\tui\\123-install.log"; - - let display = super::chat_session_command_result_text("Install", false, summary); - let detail = super::chat_session_command_result_detail("Install", false, summary); - - assert!(display.contains("Install failed.")); - assert!(display.contains("Error: No matching distribution found for torch")); - assert!(!display.contains("Recent command output")); - assert!(!display.contains("Output: resolving torch wheels")); - assert!(!display.contains("Warning: pip is checking candidates")); - assert!(!display.contains("C:\\Users\\user\\.rocm\\logs\\tui\\123-install.log")); - - assert!(detail.contains("Error: No matching distribution found for torch")); - assert!(detail.contains("Live output")); - assert!(detail.contains("Output: resolving torch wheels")); - assert!(detail.contains("Warning: pip is checking candidates")); - assert!(detail.contains("More details are available in Logs.")); - assert!(!detail.contains("C:\\Users\\user\\.rocm\\logs\\tui\\123-install.log")); - } - - #[test] - fn chat_session_next_prompt_includes_rocm_command_result() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let summary = "Install finished.\n\nRecent output\n Output: ROCm installed into D:\\ROCm\\therock_venvs\n\nFull log\n C:\\Users\\user\\.rocm\\logs\\tui\\123-install.log"; - app.push_chat_session_turn_with_detail( - super::ChatSessionRole::Tool, - super::chat_session_command_result_text("Install", true, summary), - Some(super::chat_session_command_result_detail( - "Install", true, summary, - )), - ); - - let prompt = app.chat_session_request_prompt("what should I do next?"); - - assert!(prompt.contains("ROCm command result: Install finished.")); - assert!(prompt.contains("ROCm installed into D:\\ROCm\\therock_venvs")); - assert!(prompt.contains("ROCm command result details:")); - assert!(prompt.contains("More details are available in Logs.")); - assert!(!prompt.contains("C:\\Users\\user\\.rocm\\logs\\tui\\123-install.log")); - assert!(prompt.contains("New message:")); - assert!(prompt.contains("what should I do next?")); - } - - #[test] - fn chat_session_view_result_opens_detail_modal() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let summary = "Services finished.\n\nRecent output\n Output: Local server stopped\n service: svc-qwen"; - app.push_chat_session_turn_with_detail( - super::ChatSessionRole::Tool, - super::chat_session_command_result_text("Services", true, summary), - Some(super::chat_session_command_result_detail( - "Services", true, summary, - )), - ); - handle_key( - &mut app, - key_event(KeyCode::Char('d'), KeyModifiers::CONTROL), - ); - - let modal = app - .command_screen - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - .expect("ROCm result modal should open"); - assert_eq!(modal.title, "ROCm Result"); - assert!(modal.detail.contains("Local server stopped")); - assert!(modal.detail.contains("svc-qwen")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("ROCm Result")); - assert!(rendered.contains("Local server stopped")); - assert!(rendered.contains("Up/Down scroll | PageUp/PageDown scroll | Esc close")); - } - - #[test] - fn chat_session_returns_with_conversation_after_navigation() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - app.push_chat_session_turn(super::ChatSessionRole::User, "Which GPU is on my machine?"); - app.push_chat_session_turn(super::ChatSessionRole::Assistant, "Your AMD GPU is ready."); - - assert!(app.handle_command("services")); - assert!(app.services_manager.is_some()); - assert!(app.command_screen.is_none()); - - assert!(app.handle_command("chat --tools")); - assert!(app.command_screen_is_chat_session()); - let rendered = render_test_terminal(&app, 120, 32); - assert!( - rendered.contains("Which GPU is on my machine?"), - "{rendered}" - ); - assert!(rendered.contains("Your AMD GPU is ready."), "{rendered}"); - Ok(()) - } - - #[test] - fn chat_session_running_chat_can_show_connection_detail_modal() -> anyhow::Result<()> { - let mut app = test_app(); - let record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - app.open_local_rocm_tools_chat_session(Some(record)); - let _sender = attach_running_job( - &mut app, - "Chat", - super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: true, - }, - ); - - handle_key( - &mut app, - key_event(KeyCode::Char('d'), KeyModifiers::CONTROL), - ); - - let modal = app - .command_screen - .as_ref() - .and_then(|state| state.detail_modal.as_ref()) - .expect("connection detail modal should be stored"); - assert_eq!(modal.title, "Connection Details"); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Connection Details")); - assert!(rendered.contains("svc-qwen")); - Ok(()) - } - - #[test] - fn chat_session_cancelled_approval_is_remembered_for_next_prompt() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - app.request_screen_cli_approval_with_explanation( - "Install ROCm", - "Install", - vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - ], - Some("rocm install sdk --channel release --format wheel".to_owned()), - Some("The assistant thinks ROCm needs to be installed.".to_owned()), - ); - - app.cancel_focused_action(); - - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - assert!(session.turns.iter().any(|turn| { - turn.role == super::ChatSessionRole::Tool - && turn.content.contains("User cancelled Install ROCm") - && turn.content.contains("Nothing changed") - })); - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Approval cancelled. You can keep chatting."); - - let prompt = app.chat_session_request_prompt("what now?"); - assert!(prompt.contains("ROCm command result: User cancelled Install ROCm.")); - assert!(prompt.contains("Nothing changed.")); - assert!(prompt.contains("what now?")); - } - - #[test] - fn chat_session_approved_command_result_uses_detail_card_without_auto_followup() - -> anyhow::Result<()> { - let mut app = test_app(); - let (port, request_receiver) = spawn_fake_local_chat_server("Next, run Examine.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - - let sender = attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "ROCm installed into D:\\ROCm\\therock_venvs".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "command: rocm install sdk\nstatus: ok\n\nstdout:\ninstalled".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - let tool_turn = session - .turns - .iter() - .find(|turn| turn.role == super::ChatSessionRole::Tool) - .expect("approved command result should be in chat"); - assert!(tool_turn.content.contains("Install finished.")); - assert!( - !tool_turn - .content - .contains("ROCm installed into D:\\ROCm\\therock_venvs") - ); - assert!(!tool_turn.content.contains("Choose ROCm result")); - assert!(tool_turn.detail.as_deref().is_some_and(|detail| { - detail.contains("ROCm installed into D:\\ROCm\\therock_venvs") - })); - assert!( - super::latest_chat_session_tool_detail(session).is_some_and( - |detail| detail.contains("ROCm installed into D:\\ROCm\\therock_venvs") - ) - ); - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err() - ); - assert_eq!( - app.status, - "ROCm command result is in the chat. Ask the assistant what to do next." - ); - Ok(()) - } - - #[test] - fn chat_session_approved_service_result_uses_detail_card_without_auto_followup() - -> anyhow::Result<()> { - let mut app = test_app(); - let (port, request_receiver) = spawn_fake_local_chat_server("The server is stopped.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - - let sender = attach_running_job(&mut app, "Services", super::RunningJobKind::Cli); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "Local server stopped".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "Local server stopped\n service: svc-qwen\n status: stopped".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - let tool_turn = session - .turns - .iter() - .find(|turn| turn.role == super::ChatSessionRole::Tool) - .expect("approved service result should be in chat"); - assert!(tool_turn.content.contains("Services finished.")); - assert!(!tool_turn.content.contains("Local server stopped")); - assert!(!tool_turn.content.contains("svc-qwen")); - assert!(!tool_turn.content.contains("Choose ROCm result")); - assert!(tool_turn.detail.as_deref().is_some_and(|detail| { - detail.contains("Local server stopped") && detail.contains("svc-qwen") - })); - assert!(app.transcript.is_empty()); - assert!( - request_receiver - .recv_timeout(Duration::from_millis(150)) - .is_err() - ); - assert_eq!( - app.status, - "ROCm command result is in the chat. Ask the assistant what to do next." - ); - Ok(()) - } - - #[test] - fn services_manager_starting_service_is_not_chat_ready() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-loading", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "starting".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - assert!(app.services_manager.as_ref().is_some_and(|state| { - !super::services_manager_actions(state).contains(&super::ServicesManagerAction::Chat) - })); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.command_screen.is_none()); - assert!(app.services_manager.is_some()); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Service Logs") - ); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn managed_serve_completion_opens_local_tools_chat_when_ready() -> anyhow::Result<()> { - let mut app = test_app(); - app.open_serve_wizard(); - let (port, _request_receiver) = - spawn_fake_local_chat_server("ROCm is ready for local work.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - let sender = attach_running_job(&mut app, "Serve", super::RunningJobKind::Cli); - - finish_running_job( - &mut app, - sender, - &format!( - "managed service launched\n service_id: svc-qwen\n endpoint: http://127.0.0.1:{port}/v1\n readiness: ready\n" - ), - ); - - let state = app - .command_screen - .as_ref() - .expect("chat screen should open after serve"); - assert_eq!(state.title, "Chat"); - assert_eq!(app.provider, "local"); - assert!(state.chat_tools); - let session = state - .chat_session - .as_ref() - .expect("chat session should open"); - assert_eq!(session.service_id.as_deref(), Some("svc-qwen")); - assert_eq!( - session.model.as_deref(), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - assert!(!state.detail.contains("svc-qwen")); - assert!(state.detail.contains("Server details")); - assert!( - state - .actions - .contains(&super::CommandScreenAction::ViewChatConnection) - ); - assert!(app.serve_wizard.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn managed_serve_completion_keeps_new_warming_service_selected() -> anyhow::Result<()> { - let mut app = test_app(); - app.open_serve_wizard(); - let mut old_ready = ManagedServiceRecord::new( - &app.paths, - "svc-old-ready", - "pytorch", - "old", - "Old/Ready", - "127.0.0.1", - 11435, - "managed", - 123, - None, - None, - None, - ); - old_ready.status = "ready".to_owned(); - old_ready.write()?; - let mut new_warming = ManagedServiceRecord::new( - &app.paths, - "svc-new-loading", - "pytorch", - "new", - "New/Loading", - "127.0.0.1", - 11436, - "managed", - std::process::id(), - None, - None, - None, - ); - new_warming.status = "starting".to_owned(); - new_warming.write()?; - let sender = attach_running_job(&mut app, "Serve", super::RunningJobKind::Cli); - - finish_running_job( - &mut app, - sender, - "managed service launched\n service_id: svc-new-loading\n endpoint: http://127.0.0.1:11436/v1\n readiness: starting\n", - ); - - assert!(app.command_screen.is_none()); - let state = app - .services_manager - .as_ref() - .expect("services screen should open"); - assert_eq!( - state - .items - .get(state.selected) - .map(|record| record.service_id.as_str()), - Some("svc-new-loading") - ); - assert!( - state - .message - .as_deref() - .is_some_and(|message| message.contains("Starting at http://127.0.0.1:11436/v1.")) - ); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn services_command_restart_requests_rocmd_approval_without_transcript() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - - assert!(app.handle_command("services restart svc-qwen")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::RocmdCommand { args, kind, .. }) - if args == &vec![ - "sandbox-run".to_owned(), - "restart_server".to_owned(), - "--allow-native-fallback".to_owned(), - "--service-id".to_owned(), - "svc-qwen".to_owned(), - ] - && matches!( - kind, - super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Restart, - service_id, - } if service_id == "svc-qwen" - ) - )); - assert!(app.services_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Restart selected server")); - assert!(rendered.contains("Restart the managed service `svc-qwen`.")); - assert!(!rendered.contains("Restart service svc-qwen")); - assert!(!rendered.contains("/services restart svc-qwen")); - Ok(()) - } - - #[test] - fn services_lifecycle_output_stays_inside_services_screen() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - app.complete_running_job( - "Services".to_owned(), - super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Stop, - service_id: "svc-qwen".to_owned(), - }, - Ok(super::CommandOutput { - ok: true, - rendered: "status: stopped".to_owned(), - chat_approval: None, - }), - super::StreamedOutputCounts::default(), - ); - - assert!(app.services_manager.is_some()); - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Service stopped."); - let rendered = render_test_terminal(&app, 120, 28); - assert!(!rendered.contains("status: stopped"), "{rendered}"); - Ok(()) - } - - #[test] - fn stopping_service_modal_uses_stopping_placeholder_before_output() { - let mut app = test_app(); - let (_sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Services".to_owned(), - kind: super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Stop, - service_id: "svc-qwen".to_owned(), - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - let output = super::running_job_modal_output_text(&app, 10, 80); - - assert!(output.contains("Stopping..."), "{output}"); - assert!(!output.contains("Starting..."), "{output}"); - } - - #[test] - fn services_screen_hides_stopped_records() -> anyhow::Result<()> { - let app = test_app(); - let mut ready = ManagedServiceRecord::new( - &app.paths, - "svc-ready", - "lemonade", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - ready.status = "ready".to_owned(); - ready.write()?; - let mut stopped = ManagedServiceRecord::new( - &app.paths, - "svc-stopped", - "lemonade", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11436, - "managed", - 123, - None, - None, - None, - ); - stopped.status = "stopped".to_owned(); - stopped.write()?; - - let services = super::load_visible_managed_services(&app.paths); - - assert!( - services - .iter() - .any(|record| record.service_id == "svc-ready") - ); - assert!( - !services - .iter() - .any(|record| record.service_id == "svc-stopped") - ); - Ok(()) - } - - #[test] - fn chat_escape_with_attached_service_opens_stop_overlay() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "lemonade", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - app.open_local_rocm_tools_chat_session(Some(record)); - - super::handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - let overlay = app.overlay_card.as_ref().expect("stop overlay should open"); - assert_eq!(overlay.title, "Stop Assistant"); - assert_eq!(overlay.selected, 0); - assert_eq!( - overlay.actions, - vec![ - super::OverlayCardAction::Close, - super::OverlayCardAction::StopChatAssistant - ] - ); - assert!(app.command_screen_is_chat_session()); - - super::handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - assert!(app.command_screen_is_chat_session()); - Ok(()) - } - - #[test] - fn home_menu_has_quit_and_escape_opens_quit_overlay() { - let mut app = test_app(); - app.open_home_dashboard(); - - let actions = super::home_dashboard_actions(&app); - assert_eq!(actions.last(), Some(&super::HomeDashboardAction::Quit)); - - super::handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Quit") - ); - } - - #[test] - fn services_running_job_blocks_log_switch_without_transcript_fallback() -> anyhow::Result<()> { - let mut app = test_app(); - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - assert!(app.handle_command("services")); - - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Services".to_owned(), - kind: super::RunningJobKind::ServiceLifecycle { - action: super::ServiceLifecycleAction::Stop, - service_id: "svc-qwen".to_owned(), - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.services_manager.is_some()); - assert!(app.logs_view.is_none()); - assert!(app.status.contains("Wait for the running work")); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "stopping svc-qwen".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "status: stopped".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!(app.services_manager.is_some()); - assert!(app.logs_view.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(!rendered.contains("status: stopped"), "{rendered}"); - Ok(()) - } - - #[test] - fn uninstall_apply_requests_screen_approval_without_cli_redirect() { - let mut app = test_app(); - assert!(app.handle_command("uninstall apply")); - - assert!(app.command_screen.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Remove the selected ROCm CLI-managed pieces.")); - assert!(!rendered.contains("/uninstall apply")); - assert!(!rendered.contains("Use `rocm uninstall --yes` from the CLI")); - } - - #[test] - fn direct_mutating_commands_use_screen_approval_without_transcript() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("runtimes activate release-pip-gfx120x-all")); - assert!(app.runtime_manager.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("runtimes uninstall release-pip-gfx120x-all")); - assert!(app.runtime_manager.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("install sdk --channel release")); - assert!(app.install_manager.is_some()); - assert!(app.pending_approval.is_none()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("engine install llama.cpp")); - assert!(app.engine_manager.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("update --apply --runtime release-pip-gfx120x-all")); - assert!(app.update_manager.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - assert!(app.handle_command("uninstall apply")); - assert!(app.command_screen.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn overlay_card_page_keys_scroll_long_details() { - let mut app = test_app(); - assert!(app.handle_command("help")); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.detail_scroll), - Some(0) - ); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - - assert_eq!( - app.overlay_card.as_ref().map(|state| state.detail_scroll), - Some(10) - ); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - - assert_eq!( - app.overlay_card.as_ref().map(|state| state.detail_scroll), - Some(0) - ); - } - - #[test] - fn serve_parse_errors_open_screen_without_transcript_dump() { - let mut app = test_app(); - assert!(app.handle_command("serve tiny.gguf --port nope")); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.command_screen - .as_ref() - .map(|state| state.title.as_str()), - Some("Serve") - ); - } - - #[test] - fn chat_prompt_streams_inside_command_screen() { - let mut app = test_app(); - app.provider = "openai".to_owned(); - assert!(app.handle_command("chat hello")); - - assert!(app.running_job.is_none()); - assert!(app.command_screen.is_some()); - assert_eq!( - app.command_screen - .as_ref() - .and_then(|state| state.chat_prompt.as_deref()), - Some("hello") - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Send")); - assert!(rendered.contains("Edit prompt")); - assert!(rendered.contains("hello")); - assert!(app.transcript.is_empty()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.running_job.as_ref().map(|job| &job.kind), - Some(super::RunningJobKind::Chat { .. }) - )); - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - - app.push_chat_delta("hi there"); - - assert!(app.transcript.is_empty()); - assert!( - app.command_screen - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains("hi there")) - ); - let rendered = render_test_terminal(&app, 80, 24); - assert!(rendered.contains("hi there"), "{rendered}"); - - for index in 0..80 { - app.push_chat_delta(&format!("\nstream chunk {index}")); - } - let rendered = render_test_terminal(&app, 80, 24); - assert!(rendered.contains("stream chunk 79"), "{rendered}"); - } - - #[test] - fn chat_tools_prompt_opens_navigable_review_screen() { - let mut app = test_app(); - assert!(app.handle_command("chat --tools help me set up TheRock")); - - assert!(app.running_job.is_none()); - assert!(app.folder_browser.is_some()); - assert!(app.install_manager.is_none()); - assert!(app.serve_wizard.is_none()); - assert!(app.command_screen.is_none()); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("Choose ROCm Folder")); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("I checked ROCm")); - assert!(app.transcript.is_empty()); - } - - #[test] - fn chat_tools_running_status_is_plain_and_safe() { - let local = super::chat_tools_running_status("local"); - assert!(local.contains("Local assistant is checking ROCm")); - assert!(local.contains("Read-only checks may run")); - assert!(local.contains("changes still ask first")); - assert!(!local.contains("tools")); - - let remote = super::chat_tools_running_status("openai"); - assert!(remote.contains("Assistant is checking ROCm with openai")); - assert!(remote.contains("changes still ask first")); - assert!(!remote.contains("tools")); - } - - #[test] - fn chat_tools_running_screen_shows_progress_and_safety_copy() { - let mut app = test_app(); - app.open_command_screen( - "Chat", - "Sending prompt...", - Some(super::CommandScreenTarget::ChatProvider), - vec![super::CommandScreenAction::Back], - ); - let (_sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Chat".to_owned(), - kind: super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: true, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - let detail = super::command_screen_detail_text(&app); - - assert!(detail.contains("Local assistant is working")); - assert!(detail.contains("Read-only ROCm checks")); - assert!(detail.contains("changes will pause for review")); - assert!(!detail.contains("Waiting for output")); - } - - #[test] - fn chat_tools_without_local_server_opens_serve_instead_of_failed_transcript() { - let mut app = test_app(); - app.provider = "local".to_owned(); - assert!(app.handle_command("chat --tools help me inspect ROCm")); - - assert!(app.running_job.is_none()); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("I need a local model before I can chat")); - assert!(rendered.contains("First-time ROCm setup does not need chat")); - assert!(rendered.contains("Enter continues")); - } - - #[test] - fn chat_tools_install_prompt_does_not_require_local_server_first() { - let mut app = test_app(); - app.provider = "local".to_owned(); - - assert!(app.handle_command("chat --tools install therock")); - - assert!(app.folder_browser.is_some()); - assert!(app.install_manager.is_none()); - assert!(app.serve_wizard.is_none()); - assert!(app.running_job.is_none()); - assert!(app.pending_approval.is_none()); - assert!(app.command_screen.is_none()); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("I need a local model before I can chat")); - } - - #[test] - fn chat_session_prompt_without_ready_remembered_model_opens_start_flow() { - let mut app = test_app(); - app.provider = "local".to_owned(); - app.saved_chat_session = Some(super::ChatSessionState { - provider: "local".to_owned(), - model: Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL.to_owned()), - service_id: None, - endpoint_url: None, - turns: Vec::new(), - pending_install_args: None, - }); - assert!(app.handle_command("chat")); - assert!(app.command_screen_is_chat_session()); - assert!( - app.command_screen - .as_ref() - .expect("chat session should be open") - .actions - .contains(&super::CommandScreenAction::OpenCommand("serve")) - ); - - app.set_input("hi".to_owned()); - app.submit(); - - assert!(app.command_screen_is_chat_session()); - assert!(app.serve_wizard.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!(app.saved_chat_session.is_some()); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("You")); - assert!(rendered.contains("hi")); - assert!(rendered.contains("Ask me about ROCm setup")); - assert!(!rendered.contains("I need a local model before I can chat")); - } - - #[test] - fn chat_tools_install_without_prefix_opens_guided_folder_choice() { - let mut app = test_app(); - app.open_command_screen( - "Chat", - "Waiting for local assistant...", - Some(super::CommandScreenTarget::ChatProvider), - vec![super::CommandScreenAction::Back], - ); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Chat".to_owned(), - kind: super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: true, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "chat response\n\nROCm tools\n install_sdk: approval required" - .to_owned(), - chat_approval: Some(ChatToolApprovalRequest { - pending_title: "Install ROCm".to_owned(), - command_title: "Install".to_owned(), - args: vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - ], - display_command: Some( - "rocm install sdk --channel release --format wheel".to_owned(), - ), - explanation: Some( - "ROCm is missing, so I need to install TheRock before serving models." - .to_owned(), - ), - }), - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!( - app.status.to_ascii_lowercase().contains("folder"), - "{}", - app.status - ); - assert!(app.pending_approval.is_none()); - assert!(app.folder_browser.is_some()); - assert!(app.install_manager.is_none()); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("Review this change")); - } - - #[test] - fn chat_tools_install_with_prefix_opens_cli_approval() { - let mut app = test_app(); - let prefix = app.paths.data_dir.join("explicit-rocm"); - app.open_command_screen( - "Chat", - "Waiting for local assistant...", - Some(super::CommandScreenTarget::ChatProvider), - vec![super::CommandScreenAction::Back], - ); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Chat".to_owned(), - kind: super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: true, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "chat response\n\nROCm tools\n install_sdk: approval required" - .to_owned(), - chat_approval: Some(ChatToolApprovalRequest { - pending_title: "Install ROCm".to_owned(), - command_title: "Install".to_owned(), - args: vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - prefix.display().to_string(), - ], - display_command: Some(format!( - "rocm install sdk --channel release --format wheel --prefix {}", - prefix.display() - )), - explanation: Some("Install TheRock in the requested folder.".to_owned()), - }), - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!(app.install_manager.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.status, - "Assistant suggested a change. Review it before anything runs." - ); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - explanation: Some(explanation), - }) if title == "Install" - && args == &vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - prefix.display().to_string(), - ] - && display_command.contains("--prefix") - && display_command.contains(&prefix.display().to_string()) - && explanation.contains("requested folder") - )); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(modal.contains("Folder:")); - assert!(modal.contains(&prefix.display().to_string())); - } - - #[test] - fn assistant_start_comfyui_installs_first_when_missing() -> anyhow::Result<()> { - let mut app = test_app(); - let (port, _request_receiver) = - spawn_fake_local_chat_server("This should not be needed for direct actions.")?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen-direct", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - port, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - app.open_local_rocm_tools_chat_session(Some(record)); - - assert!(app.submit_chat_session_prompt("start comfyui".to_owned())); - - assert!(app.command_screen_is_chat_session()); - let pending_action = app.pending_approval.as_ref().map(|pending| &pending.action); - let rendered = render_test_terminal(&app, 140, 32); - assert!( - matches!( - pending_action, - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - .. - }) if title == "ComfyUI" - && args == &vec!["comfyui".to_owned(), "install".to_owned()] - && display_command.ends_with("comfyui install") - ), - "{pending_action:#?}\n{rendered}" - ); - let chat_detail = super::command_screen_detail_text(&app); - assert!(chat_detail.contains("ComfyUI is not installed yet")); - assert!(!rendered.contains("/comfyui start"), "{rendered}"); - Ok(()) - } - - #[test] - fn assistant_comfyui_install_result_prompts_to_start() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let sender = attach_running_job(&mut app, "ComfyUI", super::RunningJobKind::Cli); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "ComfyUI\n status: installed\n models path: C:\\Users\\user\\.rocm\\apps\\comfyui\\source\\models\n next step: rocm comfyui start\n".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - let tool_turn = session - .turns - .iter() - .find(|turn| turn.role == super::ChatSessionRole::Tool) - .expect("ComfyUI result should be shown in chat"); - assert!(tool_turn.content.contains("ComfyUI installed.")); - assert!(tool_turn.content.contains("Start ComfyUI:")); - assert!(tool_turn.content.contains("/comfyui start")); - assert!( - tool_turn - .content - .contains("C:\\Users\\user\\.rocm\\apps\\comfyui\\source\\models") - ); - assert!(!tool_turn.content.contains("ComfyUI finished.")); - } - - #[test] - fn assistant_comfyui_start_result_shows_url_models_and_quit_prompt() -> anyhow::Result<()> { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - let url = format!("http://127.0.0.1:{port}"); - write_comfyui_running_state_for_test(&app, port)?; - let sender = attach_running_job(&mut app, "ComfyUI", super::RunningJobKind::Cli); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: format!( - "ComfyUI\n status: running\n URL: {url}\n models path: C:\\Users\\user\\.rocm\\apps\\comfyui\\source\\models\n" - ), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - let session = app - .command_screen - .as_ref() - .and_then(|state| state.chat_session.as_ref()) - .expect("chat session should stay open"); - let tool_turn = session - .turns - .iter() - .find(|turn| turn.role == super::ChatSessionRole::Tool) - .expect("ComfyUI start result should be shown in chat"); - assert!(tool_turn.content.contains("ComfyUI is running.")); - assert!(tool_turn.content.contains(&url)); - assert!( - tool_turn - .content - .contains("C:\\Users\\user\\.rocm\\apps\\comfyui\\source\\models") - ); - assert!(!tool_turn.content.contains("ComfyUI finished.")); - - app.request_quit_overlay_or_wait(); - - let overlay = app.overlay_card.as_ref().expect("quit overlay should open"); - assert_eq!(overlay.title, "Quit"); - assert!(overlay.detail.contains(&format!("ComfyUI at {url}"))); - drop(listener); - Ok(()) - } - - #[test] - fn assistant_serve_tool_call_installs_missing_engine_first() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let sender = attach_running_job( - &mut app, - "Chat", - super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: true, - }, - ); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "I can start the local model.".to_owned(), - chat_approval: Some(ChatToolApprovalRequest { - pending_title: "Start local model server".to_owned(), - command_title: "Serve".to_owned(), - args: vec![ - "serve".to_owned(), - "qwen".to_owned(), - "--engine".to_owned(), - "lemonade".to_owned(), - "--device".to_owned(), - "gpu_required".to_owned(), - "--managed".to_owned(), - ], - display_command: Some( - "rocm serve qwen --engine lemonade --device gpu_required --managed" - .to_owned(), - ), - explanation: Some("Start the local assistant.".to_owned()), - }), - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.command_screen_is_chat_session()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - .. - }) if title == "Engine" - && args == &vec![ - "engines".to_owned(), - "install".to_owned(), - "lemonade".to_owned(), - ] - && display_command == "/engine install lemonade" - )); - let rendered = render_test_terminal(&app, 140, 32); - let chat_detail = super::command_screen_detail_text(&app); - assert!(chat_detail.contains("Lemonade is not installed yet")); - assert!(!rendered.contains("Start local model server"), "{rendered}"); - } - - #[test] - fn install_screen_arrow_keys_select_driver_preview() { - let mut app = test_app(); - assert!(app.handle_command("install")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!( - app.selected_install_menu_choice(), - super::InstallMenuChoice::DriverPreview - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Check driver setup")); - assert!(rendered.contains("will not install or change a driver")); - } - - #[test] - fn install_screen_enter_opens_guided_sdk_form_then_requests_approval() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("install")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.install_manager.as_ref().map(|state| &state.screen), - Some(super::InstallManagerScreen::Sdk { .. }) - )); - assert!(app.pending_approval.is_none()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - app.paths.data_dir.join("envs").join("default").display().to_string(), - ] - )); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Install ROCm")); - Ok(()) - } - - #[test] - fn runtimes_adopt_missing_python_opens_guided_form() { - let mut app = test_app(); - - assert!(app.handle_command( - "runtimes adopt --root C:\\venv --runtime-id therock-release:gfx120X-all --runtime-key adopted" - )); - - assert!(app.pending_approval.is_none()); - assert!(matches!( - app.runtime_manager.as_ref().map(|state| &state.screen), - Some(super::RuntimeManagerScreen::AdoptExisting { .. }) - )); - assert!(app.transcript.is_empty()); - let message = app - .runtime_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .unwrap_or_default(); - assert!(message.contains("Choose the Python folder below.")); - assert!(!message.contains("--python")); - assert!(!message.contains("runtime-id")); - } - - #[test] - fn runtimes_adopt_valid_command_requests_read_only_approval() { - let mut app = test_app(); - - assert!(app.handle_command( - "runtimes adopt --python C:\\venv\\Scripts\\python.exe --root C:\\venv --runtime-id therock-release:gfx120X-all --runtime-key adopted-release" - )); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "adopt".to_owned(), - "--python".to_owned(), - "C:\\venv\\Scripts\\python.exe".to_owned(), - "--root".to_owned(), - "C:\\venv".to_owned(), - "--runtime-id".to_owned(), - "therock-release:gfx120X-all".to_owned(), - "--runtime-key".to_owned(), - "adopted-release".to_owned(), - ] - )); - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Review:")); - assert!(!rendered.contains("/runtimes adopt")); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(modal.contains("read-only ROCm install")); - assert!(rendered.contains("Check an existing Python folder")); - } - - #[test] - fn runtimes_adopt_python_only_command_requests_approval() { - let mut app = test_app(); - - assert!(app.handle_command("runtimes adopt --python C:\\venv\\Scripts\\python.exe")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "runtimes".to_owned(), - "adopt".to_owned(), - "--python".to_owned(), - "C:\\venv\\Scripts\\python.exe".to_owned(), - ] - )); - assert!(app.runtime_manager.is_some()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn runtimes_adopt_completion_suggests_simple_guided_flags() { - let mut app = test_app(); - app.set_input("/runtimes adopt ".to_owned()); - let menu = app - .slash_completion_menu() - .expect("adopt command should suggest flags"); - - assert!(menu.items.iter().any(|item| item == "--python")); - assert!(menu.items.iter().any(|item| item == "--channel")); - assert!(!menu.items.iter().any(|item| item == "--root")); - assert!(!menu.items.iter().any(|item| item == "--runtime-id")); - assert!(!menu.items.iter().any(|item| item == "--runtime-key")); - assert!(!menu.items.iter().any(|item| item == "--replace")); - - app.set_input("/runtimes adopt --python C:\\venv\\Scripts\\python.exe ".to_owned()); - let menu = app - .slash_completion_menu() - .expect("adopt command should suggest remaining flags"); - - assert!(!menu.items.iter().any(|item| item == "--python")); - assert!(menu.items.iter().any(|item| item == "--channel")); - assert!(!menu.items.iter().any(|item| item == "--root")); - assert!(!menu.items.iter().any(|item| item == "--runtime-id")); - assert!(!menu.items.iter().any(|item| item == "--runtime-key")); - - app.set_input("/runtimes adopt --channel ".to_owned()); - let menu = app - .slash_completion_menu() - .expect("channel should suggest release/nightly"); - assert_eq!(menu.items, vec!["release".to_owned(), "nightly".to_owned()]); - } - - #[test] - fn runtimes_activate_completion_includes_read_only_runtime() -> anyhow::Result<()> { - let (root, paths) = test_paths("read-only-runtime-completion"); - let mut app = App { - paths: paths.clone(), - ..test_app() - }; - write_test_runtime(&paths, "therock-release:gfx120X-all", 20)?; - let registry_path = paths - .data_dir - .join("runtimes") - .join("registry") - .join("therock-release-gfx120X-all.json"); - let mut manifest: Value = serde_json::from_slice(&fs::read(®istry_path)?)?; - manifest["read_only"] = Value::Bool(true); - fs::write(®istry_path, serde_json::to_vec_pretty(&manifest)?)?; - - app.set_input("/runtimes activate ".to_owned()); - let menu = app - .slash_completion_menu() - .expect("activation should suggest runtime keys"); - - assert!( - menu.items - .iter() - .any(|item| item == "therock-release-gfx120X-all") - ); - - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn model_without_args_lists_builtin_recipe_registry() { - let mut app = test_app(); - - assert!(app.handle_command("model")); - - assert_eq!(app.status, "Models opened. Use arrow keys, then Enter."); - assert!(app.pending_approval.is_none()); - assert!(app.model_picker.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Models")); - assert!(rendered.contains("Qwen/Qwen2.5-1.5B-Instruct")); - assert!(rendered.contains("Enter: open local model setup")); - } - - #[test] - fn model_completion_uses_builtin_recipe_aliases() { - let mut app = test_app(); - app.set_input("/model qw".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("model aliases should complete"); - - assert!(menu.items.iter().any(|item| item == "qwen")); - assert!(menu.items.iter().any(|item| item == "qwen3.5")); - } - - #[test] - fn engines_slash_command_is_not_supported() { - let mut app = test_app(); - - assert!(!app.handle_command("engines")); - } - - #[test] - fn first_run_shows_dedicated_setup_screen_when_rocm_is_not_installed() { - let (_root, paths) = test_paths("first-run"); - - let app = App::new(paths, None); - let rendered = render_test_terminal(&app, 100, 24); - - assert!(app.onboarding_active); - assert!(rendered.contains("Set Up ROCm")); - assert!(rendered.contains("Let's get your AMD GPU ready for local AI")); - assert!(rendered.contains("Nothing starts until you review it")); - assert!(rendered.contains("Install folder:")); - assert!(rendered.contains("Downloaded files:")); - assert!(rendered.contains("Up/Down choose")); - assert!(rendered.contains("Left/Right folder")); - assert!(rendered.contains("> Install ROCm")); - assert!(rendered.contains("Quit setup")); - assert!(!rendered.contains("Skip setup")); - assert!(!rendered.contains("Esc back")); - assert!(rendered.contains("Esc quit")); - assert!(!rendered.contains("Transcript")); - assert!(!rendered.contains("Activity")); - assert!(!rendered.contains("/setup next")); - assert!(!rendered.contains("ROCM_CLI_THEROCK_FAMILY")); - } - - #[test] - fn setup_command_reopens_dedicated_setup_screen() { - let mut app = test_app(); - - assert!(app.handle_command("setup")); - - assert!(app.onboarding_active); - let rendered = render_test_terminal(&app, 100, 24); - assert!(rendered.contains("Set Up ROCm")); - assert!(!rendered.contains("/setup next")); - assert!(!rendered.contains("/setup runtime")); - assert!(!rendered.contains("/setup engine")); - } - - #[test] - fn setup_question_mark_does_not_open_command_list() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - handle_key(&mut app, key_event(KeyCode::Char('?'), KeyModifiers::NONE)); - - assert!(app.onboarding_active); - assert!(app.overlay_card.is_none()); - assert!(app.command_screen.is_none()); - assert!(!app.status.contains("Command list")); - assert!(app.status.contains("Setup is open")); - } - - #[test] - fn setup_reset_persists_first_time_setup_prompt_without_deleting_installs() -> anyhow::Result<()> - { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-reset-setup"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.onboarding_dismissed = true; - app.config.setup.completed = true; - app.config.setup.therock_venv = Some(install_root.clone()); - app.config.provider_config_mut("openai").enabled = true; - app.config.save(&app.paths)?; - - assert!(app.handle_command("setup reset")); - - assert!(app.onboarding_active); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!(install_root.join(".rocm-cli-runtime.json").is_file()); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Primary - ); - assert!(!app.config.onboarding_dismissed); - assert!(!app.config.setup.completed); - assert!(app.config.provider_enabled("openai")); - let saved = RocmCliConfig::load(&app.paths)?; - assert!(!saved.onboarding_dismissed); - assert!(!saved.setup.completed); - assert!(saved.provider_enabled("openai")); - assert_eq!( - app.status, - "Setup will show again. ROCm installs were not deleted." - ); - let body = super::render_onboarding_screen_text_with_log_limit(&app, 8); - let rendered = render_test_terminal(&app, 120, 36); - assert!(rendered.contains("Set Up ROCm")); - assert!(body.contains("Up/Down chooses, Enter selects.")); - assert!(!rendered.contains("Review: Uninstall")); - - let restarted = App::new(app.paths.clone(), None); - assert!(restarted.onboarding_active); - Ok(()) - } - - #[test] - fn ready_setup_screen_can_return_with_back_or_escape() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-ready-setup"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root); - app.config.save(&app.paths)?; - - assert!(app.handle_command("setup")); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(app.onboarding_active); - assert!(rendered.contains("Start ROCm CLI")); - assert!(rendered.contains("Reinstall ROCm")); - assert!(rendered.contains("Uninstall ROCm")); - assert!(rendered.contains("Back")); - assert!(rendered.contains("Esc back")); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(!app.onboarding_active); - assert_eq!(active_tui_surface_count(&app), 1); - assert_eq!(active_surface_selection(&app).unwrap().surface, "home"); - let config = RocmCliConfig::load(&app.paths)?; - assert!(config.onboarding_dismissed); - assert_eq!(app.status, "Setup complete. ROCm CLI is ready."); - Ok(()) - } - - #[test] - fn setup_show_log_file_toggles_path_on_ready_screen() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0a20260601-ready-setup"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - set_test_runtime_version(&app.paths, runtime_key, "7.14.0a20260601")?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - let log_path = app - .paths - .data_dir - .join("logs") - .join("setup") - .join("install.log"); - fs::create_dir_all(log_path.parent().expect("log path should have a parent"))?; - fs::write( - &log_path, - "Looking in indexes: https://example.invalid/simple\nSuccessfully installed torch rocm\n", - )?; - app.config.setup.therock_venv = Some(install_root); - app.onboarding_install_full_log = Some(log_path.clone()); - app.push_onboarding_install_log("old installer output kept in memory"); - app.config.save(&app.paths)?; - - assert!(app.handle_command("setup")); - - let hidden_body = super::render_onboarding_screen_text_with_log_limit(&app, 8); - let hidden = render_test_terminal(&app, 140, 32); - assert!(hidden_body.contains("version: 7.14.0a20260601 (build 2026-06-01)")); - assert!(hidden_body.contains("Saved setup log: choose Show install log below to read it.")); - assert!(hidden.contains("Show install log")); - assert!(!hidden.contains(&log_path.display().to_string())); - assert!(!hidden.contains("Successfully installed torch rocm")); - assert!(!hidden.contains("old installer output kept in memory")); - assert!(!hidden.contains("PageUp/PageDown log")); - - app.select_onboarding_choice(super::OnboardingMenuChoice::ShowLog); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::ShowLog - ); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let shown = render_test_terminal(&app, 140, 32); - assert!(shown.contains("Saved Setup Log")); - assert!(shown.contains("install.log")); - assert!(shown.contains("Successfully installed torch rocm")); - assert!(!shown.contains("old installer output kept in memory")); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::ShowLog - ); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::ShowLog - ); - assert!(app.overlay_card.is_some()); - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - let hidden_again = render_test_terminal(&app, 140, 32); - assert!(hidden_again.contains("Show install log")); - assert!(!hidden_again.contains(&log_path.display().to_string())); - Ok(()) - } - - #[test] - fn setup_screen_for_current_broken_install_offers_reinstall_and_uninstall() -> anyhow::Result<()> - { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-broken-setup"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - fs::remove_dir_all(install_root.join("_rocm_sdk_devel"))?; - app.config.setup.therock_venv = Some(install_root.clone()); - app.config.active_runtime_key = Some(runtime_key.to_owned()); - app.config.save(&app.paths)?; - - assert!(app.handle_command("setup")); - - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("ROCm: needs reinstall")); - assert!(rendered.contains("> Reinstall ROCm")); - assert!(rendered.contains("Uninstall ROCm")); - assert!(rendered.contains("Reinstall ROCm in this folder.")); - assert!(!rendered.contains("Start ROCm CLI")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command, - .. - }) if title == "Reinstall" - && args == &vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - install_root.display().to_string(), - ] - && display_command - .as_deref() - .is_some_and(|value| value.contains("--prefix")) - )); - Ok(()) - } - - #[test] - fn ready_setup_reinstall_requests_install_in_current_folder() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-reinstall-setup"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root.clone()); - app.config.save(&app.paths)?; - app.onboarding_active = true; - app.select_onboarding_choice(super::OnboardingMenuChoice::Reinstall); - - app.perform_onboarding_selected_action(); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command, - .. - }) if title == "Reinstall" - && args == &vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - install_root.display().to_string(), - ] - && display_command - .as_deref() - .is_some_and(|value| value.contains("--prefix")) - )); - let rendered = render_test_terminal(&app, 140, 32); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(!rendered.contains("> Approve reinstall")); - assert!(!rendered.contains("> Cancel")); - assert!(rendered.contains("Review: Reinstall")); - assert!(modal.contains("ROCm CLI will reinstall ROCm into this folder.")); - assert!(!rendered.contains("command:")); - Ok(()) - } - - #[test] - fn ready_setup_uninstall_requests_current_install_uninstall() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-uninstall-setup"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root); - app.config.active_runtime_key = Some(runtime_key.to_owned()); - app.config.save(&app.paths)?; - app.onboarding_active = true; - app.select_onboarding_choice(super::OnboardingMenuChoice::Uninstall); - - app.perform_onboarding_selected_action(); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command, - .. - }) if title == "Uninstall" - && args == &vec![ - "runtimes".to_owned(), - "uninstall".to_owned(), - runtime_key.to_owned(), - ] - && display_command - .as_deref() - .is_some_and(|value| value.contains("runtimes uninstall")) - )); - let rendered = render_test_terminal(&app, 140, 32); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(!rendered.contains("> Approve uninstall")); - assert!(!rendered.contains("> Cancel")); - assert!(rendered.contains("Review: Uninstall")); - assert!(modal.contains("ROCm CLI will uninstall this ROCm install.")); - assert!(modal.contains("Enter or Y: uninstall ROCm")); - assert!(!rendered.contains("command:")); - Ok(()) - } - - #[test] - fn ready_setup_uninstall_restores_missing_registry_from_local_manifest() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-local-only-uninstall"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - let registry_path = app - .paths - .data_dir - .join("runtimes") - .join("registry") - .join(format!("{runtime_key}.json")); - fs::remove_file(®istry_path)?; - app.config.setup.therock_venv = Some(install_root); - app.config.active_runtime_key = None; - app.config.save(&app.paths)?; - app.onboarding_active = true; - app.select_onboarding_choice(super::OnboardingMenuChoice::Uninstall); - - app.perform_onboarding_selected_action(); - - assert!(registry_path.is_file()); - assert!(app.onboarding_setup_error.is_none()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command, - .. - }) if title == "Uninstall" - && args == &vec![ - "runtimes".to_owned(), - "uninstall".to_owned(), - runtime_key.to_owned(), - ] - && display_command - .as_deref() - .is_some_and(|value| value.contains("runtimes uninstall")) - )); - let rendered = render_test_terminal(&app, 140, 32); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(!rendered.contains("> Approve uninstall")); - assert!(!rendered.contains("> Cancel")); - assert!(rendered.contains("Review: Uninstall")); - assert!(modal.contains("ROCm CLI will uninstall this ROCm install.")); - assert!(!rendered.contains("could not find the saved ROCm install")); - Ok(()) - } - - #[test] - fn ready_setup_treats_current_exe_as_rocm_cli() -> anyhow::Result<()> { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-7-14-0-ready-cli"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root.clone()); - app.config.save(&app.paths)?; - app.onboarding_active = true; - app.reset_onboarding_selection(); - - let body = super::render_onboarding_screen_text_with_log_limit(&app, 8); - let rendered = render_test_terminal(&app, 120, 32); - - assert!(body.contains("ROCm is installed and ready.")); - assert!(body.contains("Press Enter to start ROCm CLI.")); - assert!(body.contains(&install_root.display().to_string())); - let folder_label = format!( - "Install folder: {}", - super::display_runtime_folder_path(&install_root) - ); - assert_eq!( - super::onboarding_menu_label(&app, super::OnboardingMenuChoice::Folder), - folder_label - ); - assert!(rendered.contains("Install folder:"), "{rendered}"); - assert!(rendered.contains("Start ROCm CLI")); - assert!(!rendered.contains("ROCm CLI folder")); - assert!(!rendered.contains("Install ROCm CLI")); - assert!(!rendered.contains("Add ROCm CLI to PATH")); - Ok(()) - } - - #[test] - fn onboarding_folder_row_cycles_presets_with_arrow_keys() -> anyhow::Result<()> { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - let original = super::setup_install_root(&app.paths, &app.config); - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Folder - ); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - - let changed = super::setup_install_root(&app.paths, &app.config); - assert_ne!(changed, original); - assert!(!app.onboarding_path_editing); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - assert!(app.status.contains("Install folder changed")); - let saved = RocmCliConfig::load(&app.paths)?; - assert_eq!(saved.setup.therock_venv.as_deref(), Some(changed.as_path())); - Ok(()) - } - - #[test] - fn onboarding_enter_requests_rocm_install_with_folder_prefix() { - let mut app = test_app(); - app.host_gpu_summary = HostGpuSummary { - name: Some("AMD Radeon 780M Graphics".to_owned()), - gfx_target: Some("gfx1103".to_owned()), - therock_family: Some("gfx110X-all".to_owned()), - }; - app.onboarding_active = true; - app.reset_onboarding_selection(); - let install_root = super::setup_install_root(&app.paths, &app.config); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.onboarding_active); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, display_command, .. }) - if args == &vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - install_root.display().to_string(), - "--family".to_owned(), - "gfx110X-all".to_owned(), - ] - && display_command - .as_deref() - .is_some_and(|value| value.contains("--prefix")) - )); - } - - #[test] - fn onboarding_install_keeps_auto_detect_when_family_is_unknown() { - let mut app = test_app(); - app.host_gpu_summary = HostGpuSummary::default(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let Some(super::ApprovalAction::CliCommand { args, .. }) = - app.pending_approval.as_ref().map(|pending| &pending.action) - else { - panic!("expected install approval"); - }; - assert!(!args.iter().any(|arg| arg == "--family")); - } - - #[test] - fn onboarding_approval_escape_closes_in_one_press() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_some()); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.onboarding_active); - let rendered = render_test_terminal(&app, 120, 24); - assert!(!rendered.contains("Review:"), "{rendered}"); - assert!(rendered.contains("Install ROCm"), "{rendered}"); - } - - #[test] - fn onboarding_pending_approval_shows_plain_install_details() { - let mut app = test_app(); - app.host_gpu_summary = HostGpuSummary { - name: Some("AMD Radeon RX 9070 XT".to_owned()), - gfx_target: Some("gfx1201".to_owned()), - therock_family: Some("gfx120X-all".to_owned()), - }; - app.onboarding_active = true; - app.reset_onboarding_selection(); - let install_root = super::setup_install_root(&app.paths, &app.config); - let pip_cache = super::setup_pip_cache_dir(&app.paths, &app.config); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 160, 32); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(rendered.contains("Review: Install")); - assert!(modal.contains("Install ROCm")); - assert!(!modal.contains("Review setup")); - assert!(!modal.contains("What will happen")); - assert!(modal.contains("ROCm CLI will install ROCm into this folder.")); - assert!(modal.contains(&install_root.display().to_string())); - assert!( - modal.contains("It will install the matching ROCm package for AMD Radeon RX 9070 XT.") - ); - assert!(modal.contains("It will add the local AI pieces ROCm CLI needs.")); - assert!(modal.contains("Downloaded files:")); - assert!(modal.contains(&super::display_runtime_folder_path(&pip_cache))); - assert!(modal.contains("> Enter or Y: start setup")); - assert!(modal.contains(" Esc or N: cancel")); - assert!(!modal.contains("PageUp")); - assert!(!rendered.contains("> Approve setup")); - assert!(!rendered.contains("> Cancel")); - assert!(rendered.contains("Review: Install")); - assert!(!rendered.contains("Approval needed")); - assert!(!rendered.contains("/setup")); - assert!(!rendered.contains("/install")); - assert!(!rendered.contains("command:")); - assert!(!rendered.contains("rocm install")); - assert!(!rendered.contains("--prefix")); - assert!(!rendered.contains("--channel")); - assert!(!rendered.contains("ROCM_CLI_THEROCK_FAMILY")); - assert!(!rendered.contains("Python folder")); - assert!(!rendered.contains("wheels")); - assert!(!rendered.contains("torch, torchvision")); - } - - #[test] - fn assistant_install_approval_shows_gpu_package_and_folder() { - let mut app = test_app(); - app.host_gpu_summary = HostGpuSummary { - name: Some("AMD Radeon RX 9070 XT".to_owned()), - gfx_target: Some("gfx1201".to_owned()), - therock_family: Some("gfx120X-all".to_owned()), - }; - let folder = r"D:\ROCm\therock_venvs"; - app.request_screen_cli_approval_with_explanation( - "Install ROCm", - "Install", - vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - folder.to_owned(), - ], - Some(format!( - "/install sdk --channel release --format wheel --prefix {folder}" - )), - Some("Install ROCm/TheRock into the folder the user selected.".to_owned()), - ); - - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - - assert!(modal.contains("Install ROCm")); - assert!(modal.contains("Install ROCm/TheRock into the folder the user selected.")); - assert!(!modal.contains("Assistant reason:")); - assert!( - modal.contains("It will install the matching ROCm package for AMD Radeon RX 9070 XT.") - ); - assert!(modal.contains("Detected GPU")); - assert!(modal.contains("GPU: AMD Radeon RX 9070 XT")); - assert!(modal.contains("Target: gfx1201")); - assert!(modal.contains("ROCm package: gfx120X-all")); - assert!(modal.contains(&format!("Folder: {folder}"))); - assert!(modal.contains(&format!( - "Downloaded files: {}", - super::display_runtime_folder_path(&Path::new(folder).join("pip-cache")) - ))); - } - - #[test] - fn full_access_still_reviews_rocm_install_and_remove() { - let mut app = test_app(); - app.config.permissions.mode = PERMISSIONS_MODE_FULL_ACCESS.to_owned(); - let folder = r"D:\ROCm\therock_venvs"; - - app.request_screen_cli_approval_with_explanation( - "Install ROCm", - "Install", - vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - "--prefix".to_owned(), - folder.to_owned(), - ], - None, - None, - ); - - assert!(app.pending_approval.is_some()); - assert!(app.running_job.is_none()); - assert!(app.status.contains("Review")); - - app.pending_approval = None; - app.request_screen_cli_approval_with_explanation( - "Start model", - "Serve", - vec![ - "serve".to_owned(), - "qwen".to_owned(), - "--engine".to_owned(), - "lemonade".to_owned(), - ], - None, - None, - ); - - assert!(app.pending_approval.is_some()); - assert!(app.running_job.is_none()); - assert!(app.status.contains("Review")); - - app.pending_approval = None; - app.request_screen_cli_approval_with_explanation( - "Uninstall ROCm", - "Uninstall", - vec![ - "runtimes".to_owned(), - "uninstall".to_owned(), - "therock-release".to_owned(), - ], - None, - None, - ); - - assert!(app.pending_approval.is_some()); - assert!(app.running_job.is_none()); - assert!(app.status.contains("Review")); - } - - #[test] - fn onboarding_screen_mentions_detected_gpu_name_and_package() { - let mut app = test_app(); - app.host_gpu_summary = HostGpuSummary { - name: Some("AMD Radeon RX 9070 XT".to_owned()), - gfx_target: Some("gfx1201".to_owned()), - therock_family: Some("gfx120X-all".to_owned()), - }; - app.onboarding_active = true; - app.reset_onboarding_selection(); - - let rendered = super::render_onboarding_screen_text_with_log_limit(&app, 8); - - assert!(rendered.contains("Detected GPU")); - assert!(rendered.contains("GPU: AMD Radeon RX 9070 XT")); - assert!(rendered.contains("Target: gfx1201")); - assert!(rendered.contains("ROCm package: gfx120X-all")); - } - - #[test] - fn onboarding_custom_folder_approval_uses_selected_folder() -> anyhow::Result<()> { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - fs::create_dir_all(&app.paths.data_dir)?; - let folder = app.paths.data_dir.join("custom-rocm-folder"); - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.folder_browser.is_some()); - app.use_folder_browser_path(folder.clone()); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 160, 32); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(app.pending_approval.is_some()); - assert!(rendered.contains("Review: Install")); - assert!(modal.contains("Install ROCm")); - assert!(!modal.contains("Review setup")); - assert!(modal.contains(&folder.display().to_string())); - assert!(modal.contains("Downloaded files:")); - assert!(modal.contains(&super::display_runtime_folder_path( - &folder.join("pip-cache") - ))); - assert!(!modal.contains("PageUp")); - assert!(!rendered.contains("> Approve setup")); - assert!(!rendered.contains("> Cancel")); - assert!(rendered.contains("Review: Install")); - assert!(!rendered.contains("--prefix")); - assert!(!rendered.contains("command:")); - Ok(()) - } - - #[test] - fn onboarding_folder_choice_persists_install_folder() -> anyhow::Result<()> { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - fs::create_dir_all(&app.paths.data_dir)?; - let folder = app.paths.data_dir.join("custom-rocm-folder"); - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Folder - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> Install folder:")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.folder_browser.is_some()); - let picker = render_test_terminal(&app, 120, 30); - assert!(picker.contains("Choose ROCm Folder"), "{picker}"); - app.use_folder_browser_path(folder.clone()); - - assert!(app.folder_browser.is_none()); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Primary - ); - let config = RocmCliConfig::load(&app.paths)?; - assert_eq!(config.setup.therock_venv.as_deref(), Some(folder.as_path())); - assert_eq!(app.status, "ROCm folder saved. Press Enter to install."); - let rendered = render_test_terminal(&app, 160, 24); - assert!(rendered.contains(&format!( - "Folder: {}", - super::display_runtime_folder_path(&folder) - ))); - assert!(rendered.contains("Downloaded files:")); - assert!(rendered.contains(&super::display_runtime_folder_path( - &folder.join("pip-cache") - ))); - Ok(()) - } - - #[test] - fn onboarding_folder_row_enter_opens_folder_picker() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Folder - ); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.folder_browser.is_some()); - assert!(!app.onboarding_path_editing); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Pick where ROCm/TheRock should be installed.")); - } - - #[test] - fn onboarding_folder_edit_top_level_enter_keeps_typed_folder() -> anyhow::Result<()> { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - fs::create_dir_all(&app.paths.data_dir)?; - let folder = app.paths.data_dir.join("top-level-custom-rocm-folder"); - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Folder - ); - handle_key(&mut app, key_event(KeyCode::Char('t'), KeyModifiers::NONE)); - assert!(app.onboarding_path_editing); - - app.set_input(folder.display().to_string()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.onboarding_path_editing); - assert!(app.input.is_empty()); - let config = RocmCliConfig::load(&app.paths)?; - assert_eq!(config.setup.therock_venv.as_deref(), Some(folder.as_path())); - assert_eq!(app.status, "ROCm folder saved. Press Enter to install."); - Ok(()) - } - - #[test] - fn onboarding_path_edit_ctrl_c_opens_quit_overlay_and_preserves_input() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - let folder = app - .paths - .data_dir - .join("custom-folder-while-reviewing-quit"); - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - handle_key(&mut app, key_event(KeyCode::Char('t'), KeyModifiers::NONE)); - assert!(app.onboarding_path_editing); - app.set_input(folder.display().to_string()); - let editing_rendered = render_test_terminal(&app, 120, 30); - assert!(editing_rendered.contains("Ctrl-C review quit")); - - handle_key( - &mut app, - key_event(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - - assert!(!app.should_quit); - assert!(app.onboarding_path_editing); - assert_eq!(app.input, folder.display().to_string()); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Quit") - ); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("Quit")); - assert!(rendered.contains("Stay in ROCm CLI")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.should_quit); - assert!(app.overlay_card.is_none()); - assert!(app.onboarding_path_editing); - assert_eq!(app.input, folder.display().to_string()); - } - - #[test] - fn onboarding_folder_rejects_system_folder() { - let system_path = if cfg!(windows) { - std::env::var_os("WINDIR").map_or_else( - || std::path::PathBuf::from(r"C:\Windows"), - std::path::PathBuf::from, - ) - } else { - std::path::PathBuf::from("/usr") - }; - - let error = super::validate_onboarding_install_folder(&system_path.display().to_string()) - .unwrap_err(); - - assert!(error.to_string().contains("System folders cannot be used")); - } - - #[test] - fn onboarding_folder_rejects_file_path() -> anyhow::Result<()> { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - fs::create_dir_all(&app.paths.data_dir)?; - let file_path = app.paths.data_dir.join("not-a-folder.txt"); - fs::write(&file_path, "not a folder")?; - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Char('t'), KeyModifiers::NONE)); - app.set_input(file_path.display().to_string()); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.onboarding_path_editing); - assert!(app.status.contains("That path is a file")); - assert!(app.config.setup.therock_venv.is_none()); - Ok(()) - } - - #[test] - fn onboarding_folder_rejects_missing_parent() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - let folder = app - .paths - .data_dir - .join("missing-parent") - .join("rocm-folder"); - - app.select_onboarding_choice(super::OnboardingMenuChoice::Folder); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Char('t'), KeyModifiers::NONE)); - app.set_input(folder.display().to_string()); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.onboarding_path_editing); - assert!(app.status.contains("parent folder does not exist")); - assert!(app.config.setup.therock_venv.is_none()); - } - - #[test] - fn onboarding_folder_picker_click_uses_visible_folder_choice() -> anyhow::Result<()> { - let mut app = test_app(); - app.onboarding_active = true; - let start_parent = app.paths.data_dir.join("folder-picker-start"); - fs::create_dir_all(&start_parent)?; - app.config.setup.therock_venv = Some(start_parent.join("default-rocm")); - app.config.save(&app.paths)?; - - app.open_onboarding_install_folder_browser(); - let area = super::Rect { - x: 0, - y: 0, - width: 120, - height: 32, - }; - let list = super::folder_browser_list_inner_rect(area).expect("folder browser list"); - - assert!(super::click_folder_browser( - &mut app, - list.x, - list.y + 1, - area.width, - area.height, - )); - - assert!(app.folder_browser.is_none()); - assert_eq!( - app.config.setup.therock_venv.as_deref(), - Some(start_parent.join("therock_venvs").as_path()) - ); - assert_eq!(app.status, "ROCm folder saved. Press Enter to install."); - Ok(()) - } - - #[test] - fn onboarding_arrows_cycle_folder_and_install_rows() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Primary - ); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Channel - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> Channel:")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Folder - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> Install folder:")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Channel - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> Channel:")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Primary - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> Install ROCm")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Quit - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> Quit setup")); - } - - #[test] - fn onboarding_skip_key_does_not_enter_unconfigured_main_tui() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Char('s'), KeyModifiers::NONE)); - - assert!(app.onboarding_active); - assert_eq!( - app.status, - "Finish setup to continue, or choose Quit setup." - ); - } - - #[test] - fn onboarding_escape_does_not_skip_setup() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.onboarding_active); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Quit - ); - assert_eq!(app.status, "Quit setup selected. Press Enter to confirm."); - } - - #[test] - fn onboarding_approval_menu_supports_arrow_cancel() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_some()); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(!rendered.contains("> Approve setup")); - assert!(!rendered.contains("> Cancel")); - assert!(rendered.contains("Review: Install")); - assert!(rendered.contains("Enter or Y: start setup")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.selected), - Some(1) - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Esc or N: cancel")); - assert!(!rendered.contains("> Cancel")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_none()); - assert!(app.onboarding_active); - assert_eq!(app.status, "Install cancelled. You're still in setup."); - } - - #[test] - fn onboarding_number_keys_activate_selected_menu_row() { - let mut app = test_app(); - app.onboarding_active = true; - app.reset_onboarding_selection(); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Char('1'), KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.folder_browser.is_some()); - assert!(!app.onboarding_path_editing); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Folder - ); - - app.folder_browser = None; - app.select_onboarding_choice(super::OnboardingMenuChoice::Primary); - - // '2' jumps directly to the second actionable onboarding row, which toggles the channel. - super::handle_onboarding_key(&mut app, key_event(KeyCode::Char('2'), KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.folder_browser.is_none()); - assert!(matches!( - app.onboarding_channel, - super::InstallSdkChannel::Nightly - )); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Channel - ); - } - - #[test] - fn onboarding_running_install_shows_progress_and_live_output() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "Collecting torch".to_owned(), - }) - .unwrap(); - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Install is working.")); - assert!(rendered.contains("Progress")); - assert!(rendered.contains("Live output")); - assert!(rendered.contains('[')); - assert!(rendered.contains("Collecting torch")); - assert_eq!( - rendered.matches("Collecting torch").count(), - 1, - "{rendered}" - ); - assert!(!rendered.contains("command: /install")); - assert!(!rendered.contains("[Approval]")); - } - - #[test] - fn onboarding_running_install_modal_ignores_stale_manager_text() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.open_automations_manager(); - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "Collecting torch from TheRock".to_owned(), - }) - .unwrap(); - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Install is working."), "{rendered}"); - assert!( - rendered.contains("Collecting torch from TheRock"), - "{rendered}" - ); - assert!( - !rendered.contains("Choose a review request"), - "setup install modal should not show stale manager text:\n{rendered}" - ); - } - - #[test] - fn onboarding_running_install_keeps_logs_out_of_background_screen() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "unique background duplicate guard".to_owned(), - }) - .unwrap(); - app.poll_running_job(); - - let body = super::render_onboarding_screen_text_with_log_limit(&app, 8); - assert!( - !body.contains("unique background duplicate guard"), - "{body}" - ); - assert!(!body.contains("Installer output"), "{body}"); - assert!(body.contains("Setup is running."), "{body}"); - - let rendered = render_test_terminal(&app, 90, 18); - assert_eq!( - rendered - .matches("unique background duplicate guard") - .count(), - 1, - "{rendered}" - ); - assert!(!rendered.contains("Installing ROCm..."), "{rendered}"); - } - - #[test] - fn running_install_modal_bottom_reaches_wrapped_live_output() { - let mut app = test_app(); - let sender = attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - app.onboarding_active = true; - - for index in 0..18 { - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: format!( - "Downloading https://rocm.nightlies.amd.com/v2/gfx120X-all/package-{index}-with-a-very-long-wheel-name-that-wraps-on-small-terminals.whl" - ), - }) - .unwrap(); - } - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "FINAL LIVE OUTPUT SENTINEL".to_owned(), - }) - .unwrap(); - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 64, 12); - assert!( - rendered.contains("FINAL LIVE OUTPUT SENTINEL"), - "{rendered}" - ); - - super::handle_mouse( - &mut app, - MouseEvent { - kind: MouseEventKind::ScrollUp, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }, - ); - let earlier = render_test_terminal(&app, 64, 12); - assert!(!earlier.contains("FINAL LIVE OUTPUT SENTINEL"), "{earlier}"); - - super::handle_mouse( - &mut app, - MouseEvent { - kind: MouseEventKind::ScrollDown, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }, - ); - let latest = render_test_terminal(&app, 64, 12); - assert!(latest.contains("FINAL LIVE OUTPUT SENTINEL"), "{latest}"); - } - - #[test] - fn running_install_modal_page_keys_scroll_setup_live_output() { - let mut app = test_app(); - let sender = attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - app.onboarding_active = true; - - for index in 0..18 { - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: format!( - "Downloading https://rocm.nightlies.amd.com/v2/gfx120X-all/package-{index}-with-a-very-long-wheel-name-that-wraps-on-small-terminals.whl" - ), - }) - .unwrap(); - } - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "FINAL LIVE OUTPUT SENTINEL".to_owned(), - }) - .unwrap(); - app.poll_running_job(); - - let latest = render_test_terminal(&app, 64, 12); - assert!(latest.contains("FINAL LIVE OUTPUT SENTINEL"), "{latest}"); - - super::handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - assert!(app.onboarding_install_log_scroll > 0); - assert_eq!( - app.running_job_log_scroll, - app.onboarding_install_log_scroll - ); - let earlier = render_test_terminal(&app, 64, 12); - assert!(!earlier.contains("FINAL LIVE OUTPUT SENTINEL"), "{earlier}"); - - super::handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - let latest_again = render_test_terminal(&app, 64, 12); - assert!( - latest_again.contains("FINAL LIVE OUTPUT SENTINEL"), - "{latest_again}" - ); - } - - #[test] - fn running_install_modal_page_up_reaches_old_wrapped_output_in_small_window() { - let mut app = test_app(); - let sender = attach_running_job(&mut app, "Install", super::RunningJobKind::Cli); - app.onboarding_active = true; - - for index in 0..28 { - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: format!( - "Downloading https://rocm.nightlies.amd.com/v2/gfx120X-all/package-{index:02}-with-a-long-wheel-name-that-wraps-several-times-on-small-terminals.whl" - ), - }) - .unwrap(); - } - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "FINAL LIVE OUTPUT SENTINEL".to_owned(), - }) - .unwrap(); - app.poll_running_job(); - - let latest = render_test_terminal(&app, 48, 12); - assert!(latest.contains("FINAL LIVE OUTPUT SENTINEL"), "{latest}"); - - for _ in 0..30 { - super::handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - } - - let earliest = render_test_terminal(&app, 48, 12); - assert!( - earliest.contains("package-00"), - "PageUp should reach the oldest wrapped output in a small window:\n{earliest}" - ); - assert!( - !earliest.contains("FINAL LIVE OUTPUT SENTINEL"), - "{earliest}" - ); - } - - #[test] - fn scroll_metrics_reaches_visual_bottom() { - let top = super::scroll_metrics(100, 20, 0); - assert_eq!(top.offset, 0); - assert_eq!(top.max_offset(), 80); - assert!(top.should_draw()); - - let bottom = super::scroll_metrics(100, 20, 200); - assert_eq!(bottom.offset, 80); - assert_eq!(bottom.viewport_len, 20); - assert_eq!(bottom.content_len, 100); - } - - #[test] - fn onboarding_install_primary_status_avoids_pip_jargon() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - for index in 0..=super::MAX_STREAM_EVENTS_PER_POLL { - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: format!("pip progress line {index}"), - }) - .unwrap(); - } - - app.poll_running_job(); - - assert_eq!( - app.status, - "Installing ROCm. Progress is shown in the install card." - ); - assert!(!app.status.to_ascii_lowercase().contains("pip")); - } - - #[test] - fn onboarding_install_current_step_covers_pre_pip_phases() { - let mut app = test_app(); - let cases = [ - ( - "Checking TheRock release packages for this AMD GPU...", - "Checking ROCm packages...", - ), - ( - "Checking Python for the ROCm install...", - "Checking Python...", - ), - ("Preparing Python 3.12...", "Checking Python..."), - ( - "Using existing Python 3.12 at C:\\Users\\user\\.rocm\\tools\\python\\python.exe.", - "Checking Python...", - ), - ( - "Python 3.12 is ready at C:\\Users\\user\\.rocm\\tools\\python\\python.exe.", - "Checking Python...", - ), - ("Finding Python 3.12...", "Checking Python..."), - ("Installing Python 3.12 via uv...", "Installing Python..."), - ( - "Creating Python environment at C:\\Users\\user\\.rocm\\envs\\default.", - "Setting up Python...", - ), - ( - "Installing rocm[libraries,devel]==7.13.0a20260513 torch==2.10.0+rocm7.13.0a20260513 torchvision==0.25.0+rocm7.13.0a20260513 torchaudio==2.10.0+rocm7.13.0a20260513 from https://rocm.nightlies.amd.com/v2/gfx120X-all", - "Installing ROCm packages...", - ), - ( - "Checking the installed ROCm SDK...", - "Checking the ROCm install...", - ), - ]; - - for (line, expected) in cases { - app.onboarding_install_log.clear(); - app.push_onboarding_install_log(line); - assert_eq!( - super::onboarding_install_current_step(&app), - expected, - "{line}" - ); - } - } - - #[test] - fn onboarding_running_install_empty_log_uses_general_starting_text() { - let mut app = test_app(); - let (_sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Install is working.")); - assert!(rendered.contains("Live output")); - assert!(rendered.contains("Starting...")); - assert!(!rendered.contains("Waiting for pip to start")); - } - - #[test] - fn onboarding_refresh_does_not_probe_gpu_telemetry() { - let mut app = test_app(); - app.onboarding_active = true; - - app.refresh_sidebar_state(); - - assert!(app.gpu_telemetry.last_static_refresh.is_none()); - assert!(app.gpu_telemetry.last_monitor_refresh.is_none()); - assert!(app.gpu_telemetry.last_error.is_none()); - } - - #[test] - fn onboarding_ctrl_c_during_install_confirms_before_cancelling() { - let mut app = test_app(); - let (_sender, receiver) = mpsc::channel(); - let cancel_requested = Arc::new(AtomicBool::new(false)); - app.onboarding_active = true; - app.running_job_cancel_requested = Some(Arc::clone(&cancel_requested)); - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - app.onboarding_install_log - .push_back("installer is downloading packages".to_owned()); - - super::handle_onboarding_key( - &mut app, - key_event(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - - assert!(!app.should_quit); - assert!(app.onboarding_cancel_install_confirm); - assert!(!cancel_requested.load(Ordering::SeqCst)); - let setup_body = super::render_onboarding_screen_text_with_log_limit(&app, 8); - assert!(setup_body.contains("Setup is running.")); - assert!(!setup_body.contains("installer is downloading packages")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Stop ROCm Install?")); - assert!(rendered.contains("Installing ROCm")); - assert!(rendered.contains("> Keep waiting")); - assert!(rendered.contains(" Stop install")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(!app.onboarding_cancel_install_confirm); - assert!(!cancel_requested.load(Ordering::SeqCst)); - - super::handle_onboarding_key( - &mut app, - key_event(KeyCode::Char('c'), KeyModifiers::CONTROL), - ); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains(" Keep waiting")); - assert!(rendered.contains("> Stop install")); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(cancel_requested.load(Ordering::SeqCst)); - assert!(!app.onboarding_cancel_install_confirm); - assert_eq!(app.onboarding_cancel_install_selection, 0); - assert!( - app.onboarding_install_log - .iter() - .any(|line| line.contains("Stopping installer")) - ); - assert!(app.transcript.is_empty()); - } - - #[test] - fn provider_assisted_plan_runs_as_background_job() { - let mut app = test_app(); - app.config.planner_provider = Some("local".to_owned()); - app.config - .save(&app.paths) - .expect("planner config should save"); - - app.push_freeform_plan("Plan", "make my ROCm setup better"); - - assert!(matches!( - app.running_job.as_ref().map(|job| &job.kind), - Some(super::RunningJobKind::Planner { title, .. }) if title == "Plan" - )); - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - assert!( - app.command_screen - .as_ref() - .expect("plan screen") - .detail - .contains("Planning request") - ); - assert_eq!( - app.status, - "Planning with provider. This may take a moment." - ); - let rendered = render_test_terminal(&app, 120, 30); - assert!(rendered.contains("Planning with local."), "{rendered}"); - assert!(rendered.contains("Progress"), "{rendered}"); - assert!(rendered.contains("Request"), "{rendered}"); - let selected_before = app.command_screen.as_ref().unwrap().selected; - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.command_screen.as_ref().unwrap().selected, - selected_before - ); - assert_eq!( - app.status, - "This work is still running. Choices are paused for now." - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - app.command_screen.as_ref().unwrap().selected, - selected_before - ); - assert_eq!( - app.status, - "Wait for the running work to finish before choosing another action." - ); - - for _ in 0..20 { - app.poll_running_job(); - if app.running_job.is_none() { - break; - } - std::thread::sleep(Duration::from_millis(10)); - } - - assert!(app.running_job.is_none()); - let state = app.command_screen.as_ref().expect("plan screen stays open"); - assert!(state.detail.contains("Plan")); - assert!(state.detail.contains("Edit request")); - assert!(!state.detail.contains("tool_call:")); - assert!(!state.detail.contains("intent:")); - assert!(app.transcript.is_empty()); - } - - #[cfg(unix)] - #[test] - fn cancelled_process_streaming_kills_unix_process_group() -> anyhow::Result<()> { - use std::os::unix::fs::PermissionsExt; - - let root = workspace_test_artifact_dir().join(format!( - "rocm-cli-cancel-test-{}", - next_test_unique_suffix() - )); - fs::create_dir_all(&root)?; - let marker = root.join("child-alive"); - let script = root.join("spawn-child.sh"); - let sleep_bin = if std::path::Path::new("/bin/sleep").exists() { - "/bin/sleep" - } else { - "sleep" - }; - fs::write( - &script, - format!( - "#!/bin/sh\n({sleep_bin} 30; echo alive > '{}') &\nwait\n", - marker.display() - ), - )?; - let mut permissions = fs::metadata(&script)?.permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&script, permissions)?; - let (sender, _receiver) = mpsc::channel(); - let cancel = Arc::new(AtomicBool::new(false)); - let cancel_for_thread = Arc::clone(&cancel); - std::thread::spawn(move || { - std::thread::sleep(Duration::from_millis(100)); - cancel_for_thread.store(true, Ordering::SeqCst); - }); - - let error = super::run_process_streaming(&script, &[], sender, cancel, None) - .unwrap_err() - .to_string(); - std::thread::sleep(Duration::from_millis(1500)); - - assert!( - error.contains("command cancelled"), - "unexpected cancellation error: {error}" - ); - assert!(!marker.exists()); - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn onboarding_running_install_keeps_latest_pip_lines_visible_on_short_terminal() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - for index in 1..=15 { - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: format!("pip progress line {index:02}"), - }) - .unwrap(); - } - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("pip progress line 15")); - assert!(!rendered.contains("pip progress line 01")); - } - - #[test] - fn onboarding_failed_install_shows_reason_and_retry_menu() { - let mut app = test_app(); - app.config.setup.therock_venv = Some(short_test_install_root()); - app.config.save(&app.paths).expect("config should save"); - app.reset_onboarding_selection(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stderr, - line: "ERROR: No matching distribution found for torch".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: false, - rendered: "command: rocm install sdk\nstatus: failed (1)\nstreamed_stderr: 1 line(s) shown in live output while it ran\n\nstderr:\nERROR: No matching distribution found for torch".to_owned(), - chat_approval: None, - }))) - .unwrap(); - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(app.running_job.is_none()); - assert!(rendered.contains("Install failed")); - assert!(rendered.contains("ROCm packages were not available")); - assert!(rendered.contains("ERROR: No matching distribution found for torch")); - assert!(rendered.contains("Installer output")); - assert!(rendered.contains("> Install ROCm")); - assert!(!rendered.contains("command: rocm install sdk")); - assert!(!rendered.contains("status: failed")); - assert!(!rendered.contains("streamed_stderr")); - assert!(!rendered.contains("[Running]")); - } - - #[test] - fn onboarding_failure_translates_therock_sdk_jargon_in_primary_reason() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - let raw_error = - "Error: TheRock packages exposed rocm_sdk but did not report an SDK root path"; - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stderr, - line: raw_error.to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: false, - rendered: format!( - "command: rocm install sdk\nstatus: failed (1)\n\nstderr:\n{raw_error}" - ), - chat_approval: None, - }))) - .unwrap(); - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 160, 32); - let failed_section = rendered - .split("Installer output") - .next() - .expect("failure section should be present"); - - assert!(failed_section.contains("ROCm installed, but rocm-cli could not find")); - assert!(!failed_section.contains("TheRock packages")); - assert!(!failed_section.contains("rocm_sdk")); - assert!(rendered.contains(raw_error)); - } - - #[test] - fn onboarding_failure_translates_common_setup_errors() { - let cases = [ - ( - "ERROR: No matching distribution found for torch", - "ROCm packages were not available for this GPU or Python version.", - ), - ( - "Temporary failure in name resolution", - "could not reach the download server", - ), - ( - "SSL certificate verify failed", - "secure download check failed", - ), - ( - "OSError: [Errno 28] No space left on device", - "out of space", - ), - ( - "Python archive extract failed", - "could not set up its local Python", - ), - ( - "unsupported platform", - "not available for this Windows, Linux, or Python version", - ), - ("installer cancelled by user", "stopped before it finished"), - ]; - - for (line, expected) in cases { - let reason = super::plain_install_failure_reason(line).unwrap_or(line); - assert!( - reason.contains(expected), - "line `{line}` produced `{reason}`, expected `{expected}`" - ); - } - } - - #[test] - fn onboarding_failure_render_keeps_raw_errors_below_plain_reason() { - let cases = [ - ( - "Temporary failure in name resolution", - "could not reach the download server", - ), - ( - "SSL certificate verify failed", - "secure download check failed", - ), - ( - "OSError: [Errno 28] No space left on device", - "out of space", - ), - ( - "Python archive extract failed", - "could not set up its local Python", - ), - ]; - - for (raw_error, expected_reason) in cases { - let mut app = test_app(); - app.onboarding_active = true; - let command_output = super::render_command_output( - "rocm", - &["install".to_owned(), "sdk".to_owned()], - std::process::Output { - status: failed_exit_status(), - stdout: Vec::new(), - stderr: raw_error.as_bytes().to_vec(), - }, - None, - ) - .expect("command output should render"); - - app.complete_running_job( - "Install".to_owned(), - super::RunningJobKind::Cli, - Ok(command_output), - super::StreamedOutputCounts::default(), - ); - - let rendered = render_test_terminal(&app, 160, 34); - let primary = rendered - .split("Installer output") - .next() - .expect("primary failure section should render"); - let log_path = app - .onboarding_install_full_log - .clone() - .expect("full log path should be saved"); - let full_log = fs::read_to_string(&log_path).expect("full log should be readable"); - - assert!( - primary.contains(expected_reason), - "primary section `{primary}` did not contain `{expected_reason}`" - ); - assert!( - !primary.contains(raw_error), - "raw error leaked into primary section: {primary}" - ); - assert!(rendered.contains("Installer output")); - assert!(rendered.contains(raw_error)); - assert!(rendered.contains("Saved setup log")); - assert!(!rendered.contains(&log_path.display().to_string())); - assert!(full_log.contains(raw_error)); - } - } - - #[test] - fn onboarding_failed_install_hides_log_path_until_requested() { - let mut app = test_app(); - app.onboarding_active = true; - let mut stdout = String::new(); - for index in 1..=1_000 { - let _ = writeln!(stdout, "pip progress line {index:04}"); - } - let command_output = super::render_command_output( - "rocm", - &["install".to_owned(), "sdk".to_owned()], - std::process::Output { - status: failed_exit_status(), - stdout: stdout.into_bytes(), - stderr: b"ERROR: wheel install failed\n".to_vec(), - }, - None, - ) - .expect("command output should render"); - - app.complete_running_job( - "Install".to_owned(), - super::RunningJobKind::Cli, - Ok(command_output), - super::StreamedOutputCounts::default(), - ); - - let log_path = app - .onboarding_install_full_log - .clone() - .expect("setup failure should save a full log path"); - let full_log = fs::read_to_string(&log_path).expect("full setup log should be readable"); - let rendered = render_test_terminal(&app, 180, 36); - - assert!(rendered.contains("Install failed")); - assert!(rendered.contains("Saved setup log")); - assert!(rendered.contains("Show install log")); - assert!(!rendered.contains(&log_path.display().to_string())); - assert!(rendered.contains("ERROR: wheel install failed")); - assert!(!rendered.contains("showing lines")); - assert!(full_log.contains("pip progress line 0001")); - assert!(full_log.contains("pip progress line 1000")); - assert!(full_log.contains("ERROR: wheel install failed")); - assert!(!full_log.contains("truncated")); - assert!(app.onboarding_install_log.len() <= 80); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::ShowLog - ); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - let revealed = render_test_terminal(&app, 180, 36); - assert!(revealed.contains("Saved Setup Log")); - assert!( - revealed.contains( - log_path - .file_name() - .and_then(|name| name.to_str()) - .expect("log path should have a file name") - ) - ); - assert!(revealed.contains("pip progress line 1000")); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert!( - app.overlay_card - .as_ref() - .is_some_and(|card| card.detail_scroll > 0) - ); - let earlier = render_test_terminal(&app, 180, 36); - assert!(earlier.contains("pip progress line")); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - let hidden_again = render_test_terminal(&app, 180, 36); - assert!(hidden_again.contains("Show install log")); - assert!(!hidden_again.contains(&log_path.display().to_string())); - } - - #[test] - fn onboarding_show_log_reports_missing_saved_file() -> anyhow::Result<()> { - let mut app = test_app(); - app.onboarding_active = true; - app.onboarding_install_failure = Some("The installer failed.".to_owned()); - let log_path = app - .paths - .data_dir - .join("logs") - .join("setup") - .join("deleted-install.log"); - fs::create_dir_all(log_path.parent().expect("log path should have a parent"))?; - fs::write(&log_path, "installer details\n")?; - fs::remove_file(&log_path)?; - app.onboarding_install_full_log = Some(log_path.clone()); - app.reset_onboarding_selection(); - - let hidden = render_test_terminal(&app, 110, 24); - assert!(hidden.contains("Show install log")); - assert!(!hidden.contains(&log_path.display().to_string())); - - super::handle_onboarding_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - super::handle_onboarding_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let shown = render_test_terminal(&app, 110, 24); - assert!(shown.contains("Saved Setup Log")); - assert!(shown.contains("Could not read the saved setup log")); - assert!(shown.contains("deleted-install.log")); - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - let hidden_again = render_test_terminal(&app, 110, 24); - assert!(hidden_again.contains("Show install log")); - assert!(!hidden_again.contains(&log_path.display().to_string())); - Ok(()) - } - - #[test] - fn onboarding_failed_install_output_scrolls_in_place() { - let mut app = test_app(); - app.onboarding_active = true; - app.onboarding_install_failure = Some("The installer failed.".to_owned()); - for index in 1..=80 { - app.push_onboarding_install_log(&format!("installer line {index:02}")); - } - - let latest = super::render_onboarding_screen_text_with_log_limit(&app, 6); - assert!(latest.contains("installer line 80")); - assert!(!latest.contains("installer line 01")); - - app.scroll_onboarding_install_log(super::LogPageDirection::Previous); - let earlier = super::render_onboarding_screen_text_with_log_limit(&app, 6); - assert!(!earlier.contains("showing lines")); - assert!(!earlier.contains("installer line 80")); - - app.scroll_onboarding_install_log(super::LogPageDirection::Next); - let latest_again = super::render_onboarding_screen_text_with_log_limit(&app, 6); - assert!(latest_again.contains("installer line 80")); - } - - #[test] - fn onboarding_success_does_not_hide_setup_finalization_failure() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.onboarding_active = true; - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "sdk install\n installed_version: 7.14.0".to_owned(), - chat_approval: None, - }))) - .unwrap(); - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!( - app.status.contains("setup could not find the new folder"), - "{}", - app.status - ); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Setup needs attention")); - assert!(rendered.contains("setup could not find the new folder")); - assert!(!rendered.contains("ROCm installed. Press Enter to continue.")); - } - - #[test] - fn onboarding_success_modal_blocks_setup_menu_until_key_press() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - app.onboarding_active = true; - app.onboarding_success_modal = true; - app.config.setup.therock_venv = Some(app.paths.data_dir.join("envs").join("ready-rocm")); - - let rendered = render_test_terminal(&app, 120, 32); - - assert!(rendered.contains("ROCm installed successfully.")); - assert!(rendered.contains("Press any key to continue to ROCm CLI.")); - assert!(!rendered.contains("> Start ROCm CLI")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.onboarding_active); - assert!(!app.onboarding_success_modal); - assert!(app.should_draw_home_dashboard()); - assert_eq!(app.status, "Setup complete. ROCm CLI is ready."); - Ok(()) - } - - #[test] - fn setup_venv_ready_rejects_python_only_folder() -> anyhow::Result<()> { - let (_root, paths) = test_paths("setup-python-only"); - let install_root = paths.data_dir.join("envs").join("python-only"); - let scripts_dir = install_root.join(if cfg!(windows) { "Scripts" } else { "bin" }); - fs::create_dir_all(&scripts_dir)?; - fs::write( - scripts_dir.join(if cfg!(windows) { - "python.exe" - } else { - "python" - }), - "python", - )?; - let mut config = RocmCliConfig::default(); - config.setup.therock_venv = Some(install_root); - config.save(&paths)?; - - assert!(!super::setup_venv_ready(&paths, &config)); - - let app = App::new(paths, None); - assert!(app.onboarding_active); - Ok(()) - } - - #[test] - fn setup_venv_ready_rejects_runtime_without_saved_install_id() -> anyhow::Result<()> { - let (_root, paths) = test_paths("setup-runtime-missing-key"); - let runtime_key = "release-pip-gfx120x-all-missing-key"; - write_test_runtime_with_key(&paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - let local_manifest_path = install_root.join(".rocm-cli-runtime.json"); - let mut manifest: Value = serde_json::from_slice(&fs::read(&local_manifest_path)?)?; - manifest - .as_object_mut() - .expect("test manifest should be an object") - .remove("runtime_key"); - fs::write(&local_manifest_path, serde_json::to_vec_pretty(&manifest)?)?; - fs::remove_dir_all(paths.data_dir.join("runtimes").join("registry"))?; - let mut config = RocmCliConfig::default(); - config.setup.therock_venv = Some(install_root); - - assert!(!super::setup_venv_ready(&paths, &config)); - assert_eq!( - super::serve_runtime_preflight(&paths, &config)?, - super::ServeRuntimePreflight::NeedsInstall - ); - Ok(()) - } - - #[test] - fn setup_runtime_ready_requires_therock_sdk_root_and_bin() -> anyhow::Result<()> { - let (_root, paths) = test_paths("setup-runtime-missing-sdk"); - let registry = paths.data_dir.join("runtimes").join("registry"); - fs::create_dir_all(®istry)?; - let runtime_key = "therock-release-gfx120X-all"; - let install_root = paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - let scripts_dir = install_root.join(if cfg!(windows) { "Scripts" } else { "bin" }); - fs::create_dir_all(&scripts_dir)?; - fs::write( - scripts_dir.join(if cfg!(windows) { - "python.exe" - } else { - "python" - }), - "python", - )?; - let manifest = serde_json::json!({ - "runtime_key": runtime_key, - "runtime_id": "therock-release:gfx120X-all", - "install_root": install_root, - "python_executable": scripts_dir.join(if cfg!(windows) { "python.exe" } else { "python" }), - "rocm_sdk": { - "import_ok": true, - "root_path": paths.data_dir.join("missing-sdk-root"), - "bin_path": paths.data_dir.join("missing-sdk-root").join("bin") - }, - "installed_at_unix_ms": 1 - }); - fs::write( - registry.join(format!("{runtime_key}.json")), - serde_json::to_vec_pretty(&manifest)?, - )?; - - let config = RocmCliConfig::default(); - - assert!(!super::setup_venv_ready(&paths, &config)); - Ok(()) - } - - #[test] - fn setup_subcommands_do_not_expose_old_runtime_engine_wizard() { - let mut app = test_app(); - - assert!(app.handle_command("setup runtime therock-release:gfx120X-all")); - assert!(app.handle_command("setup engine pytorch")); - assert!(app.handle_command("setup next")); - - let transcript = app.transcript_text(); - let rendered = render_test_terminal(&app, 120, 32); - assert!(app.transcript.is_empty()); - assert!(app.status.contains("I do not recognize setup option")); - assert!(rendered.contains("Setup needs attention")); - assert!(rendered.contains("I do not recognize setup option")); - assert!(!transcript.contains("/setup runtime ")); - assert!(!transcript.contains("/setup engine ")); - } - - #[test] - fn permissions_status_shows_ask_mode_by_default() { - let mut app = test_app(); - - assert!(app.handle_command("permissions status")); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("permissions")); - assert!(rendered.contains("ask before changes")); - assert!(rendered.contains("Allow full access")); - assert!(!rendered.contains("ROCM_CLI_THEROCK_FAMILY")); - } - - #[test] - fn permissions_full_access_requires_confirmation_before_persisting() -> anyhow::Result<()> { - let mut app = test_app(); - - assert!(app.handle_command("permissions full-access")); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(!config.permissions.full_access_enabled()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::EnableFullAccess) - )); - assert_eq!( - app.status, - "Confirm full access with Enter/Y, or cancel with Esc/N." - ); - - app.approve_focused_action(); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(config.permissions.full_access_enabled()); - assert_eq!(app.status, "Full access enabled."); - Ok(()) - } - - #[test] - fn permissions_full_access_cancel_keeps_ask_mode() -> anyhow::Result<()> { - let mut app = test_app(); - - assert!(app.handle_command("permissions full-access")); - app.cancel_focused_action(); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(!config.permissions.full_access_enabled()); - assert_eq!(app.status, "Full access cancelled."); - Ok(()) - } - - #[test] - fn permissions_ask_selects_reset_row_before_persisting() -> anyhow::Result<()> { - let mut app = test_app(); - app.config.permissions.mode = PERMISSIONS_MODE_FULL_ACCESS.to_owned(); - app.config.save(&app.paths)?; - - assert!(app.handle_command("permissions ask")); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(config.permissions.full_access_enabled()); - assert!(app.command_screen.is_some()); - assert!(matches!( - app.selected_command_screen_action(), - super::CommandScreenAction::ResetPermissions - )); - assert!(app.transcript.is_empty()); - assert_eq!( - app.status, - "Reset permissions selected. Press Enter to apply." - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(!config.permissions.full_access_enabled()); - assert_eq!(app.status, "Permissions reset to ask first."); - Ok(()) - } - - #[test] - fn permissions_completion_lists_status_full_access_and_ask() { - let mut app = test_app(); - app.set_input("/permissions ".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("permissions command should complete actions"); - - assert!(menu.items.iter().any(|item| item == "status")); - assert!(menu.items.iter().any(|item| item == "full-access")); - assert!(menu.items.iter().any(|item| item == "ask")); - } - - #[test] - fn setup_engine_readiness_accepts_valid_last_env_when_preferred_is_stale() -> anyhow::Result<()> - { - let mut app = test_app(); - write_test_runtime(&app.paths, "therock-release:gfx120X-all", 20)?; - app.config.default_runtime_id = Some("therock-release:gfx120X-all".to_owned()); - let engine_config = app.config.engine_config_mut("pytorch"); - engine_config.preferred_env_id = Some("stale-env".to_owned()); - engine_config.last_installed_env_id = Some("ready-env".to_owned()); - write_test_engine_env( - &app.paths, - "pytorch", - "ready-env", - "therock-release:gfx120X-all", - )?; - - assert!(super::engine_runtime_ready( - &app.paths, - &app.config, - "pytorch" - )); - Ok(()) - } - - #[test] - fn setup_engine_readiness_rejects_env_for_different_runtime() -> anyhow::Result<()> { - let mut app = test_app(); - app.config.default_runtime_id = Some("therock-release:gfx120X-all".to_owned()); - app.config.engine_config_mut("pytorch").preferred_env_id = - Some("other-runtime-env".to_owned()); - write_test_engine_env( - &app.paths, - "pytorch", - "other-runtime-env", - "therock-release:gfx110X-all", - )?; - - assert!(!super::engine_runtime_ready( - &app.paths, - &app.config, - "pytorch" - )); - Ok(()) - } - - #[test] - fn setup_skip_without_ready_runtime_keeps_setup_required() -> anyhow::Result<()> { - let mut app = test_app(); - - assert!(app.handle_command("setup skip")); - - let config = RocmCliConfig::load(&app.paths).unwrap_or_default(); - assert!(!config.onboarding_dismissed); - assert!(app.onboarding_active); - assert_eq!( - app.status, - "Setup is required before local ROCm can run. Press Enter to install ROCm." - ); - Ok(()) - } - - #[test] - fn setup_skip_after_ready_runtime_selects_back_before_persisting() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime(&app.paths, "therock-release:gfx120X-all", 20)?; - - assert!(app.handle_command("setup skip")); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(!config.onboarding_dismissed); - assert!(app.onboarding_active); - assert_eq!( - app.selected_onboarding_choice(), - super::OnboardingMenuChoice::Back - ); - assert_eq!( - app.status, - "Setup is ready. Press Enter to return to ROCm CLI." - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let config = RocmCliConfig::load(&app.paths)?; - assert!(config.onboarding_dismissed); - assert!(!app.onboarding_active); - Ok(()) - } - - #[test] - fn history_navigation_does_not_replace_footer_status() { - let mut app = test_app(); - app.history = vec!["/examine".to_owned(), "/gpu".to_owned()]; - app.status = "Ready.".to_owned(); - app.home_dashboard_visible = false; - - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - - assert_eq!(app.input, "/gpu"); - assert_eq!(app.status, "Ready."); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert!(app.input.is_empty()); - assert_eq!(app.status, "Ready."); - } - - #[test] - fn arrow_keys_cycle_completion_instead_of_history_when_menu_is_open() { - let mut app = test_app(); - app.history = vec!["/examine".to_owned()]; - app.set_input("/".to_owned()); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!(app.input, "/"); - assert_eq!(app.completion_selection, 1); - assert_eq!(app.history_index, None); - - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - - assert_eq!(app.input, "/"); - assert_eq!(app.completion_selection, 0); - assert_eq!(app.history_index, None); - } - - #[test] - fn status_sidebar_width_scales_with_terminal_width() { - assert_eq!(super::status_sidebar_width(90), 30); - assert_eq!(super::status_sidebar_width(150), 50); - assert_eq!(super::status_sidebar_width(240), 64); - } - - #[test] - fn input_viewport_follows_cursor_for_long_paths() { - let input = r"/serve C:\models\very-long-folder\tiny.gguf --engine llama.cpp"; - let cursor = input.chars().count(); - - let (visible, cursor_column) = super::input_viewport(input, cursor, 24); - - assert!(visible.contains("llama.cpp")); - assert_eq!(cursor_column, 23); - } - - #[test] - fn prompt_supports_midline_editing() { - let mut app = test_app(); - app.home_dashboard_visible = false; - app.set_input("/exmine".to_owned()); - app.input_cursor = 3; - - handle_key(&mut app, key_event(KeyCode::Char('a'), KeyModifiers::NONE)); - - assert_eq!(app.input, "/examine"); - assert_eq!(app.input_cursor, 4); - - handle_key(&mut app, key_event(KeyCode::Left, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Backspace, KeyModifiers::NONE)); - - assert_eq!(app.input, "/eamine"); - assert_eq!(app.input_cursor, 2); - - handle_key(&mut app, key_event(KeyCode::Delete, KeyModifiers::NONE)); - - assert_eq!(app.input, "/emine"); - } - - #[test] - fn prompt_supports_line_and_word_shortcuts() { - let mut app = test_app(); - app.home_dashboard_visible = false; - app.set_input("serve qwen with llama.cpp".to_owned()); - - handle_key( - &mut app, - key_event(KeyCode::Char('a'), KeyModifiers::CONTROL), - ); - assert_eq!(app.input_cursor, 0); - - handle_key( - &mut app, - key_event(KeyCode::Char('e'), KeyModifiers::CONTROL), - ); - assert_eq!(app.input_cursor, app.input.chars().count()); - - handle_key( - &mut app, - key_event(KeyCode::Char('w'), KeyModifiers::CONTROL), - ); - assert_eq!(app.input, "serve qwen with "); - assert_eq!(app.input_cursor, app.input.chars().count()); - } - - #[test] - fn tab_cycles_and_enter_accepts_slash_arguments() { - let mut app = test_app(); - app.set_input("/provider ".to_owned()); - - handle_key(&mut app, key_event(KeyCode::Tab, KeyModifiers::NONE)); - - assert_eq!(app.input, "/provider "); - assert_eq!(app.completion_selection, 1); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.input, "/provider anthropic "); - - app.set_input("/engine install l".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.input, "/engine install llama.cpp "); - } - - #[test] - fn slash_completion_covers_supported_aliases_and_settings() -> anyhow::Result<()> { - let (_root, paths) = test_paths("slash-completion-supported-aliases"); - write_test_runtime_with_key(&paths, "runtime-a", "therock-release:gfx120X-all", 1)?; - - for (input, expected) in [ - ( - "/config ", - vec![ - "set-provider-key", - "clear-provider-key", - "set-default-runtime", - ], - ), - ("/config set-provider-key ", vec!["openai", "anthropic"]), - ("/config clear-provider-key ", vec!["openai", "anthropic"]), - ( - "/config set-default-runtime ", - vec!["therock-release:gfx120X-all"], - ), - ("/runtimes ", vec!["list", "remove"]), - ("/setup ", vec!["status", "reset", "skip"]), - ("/automations ", vec!["proposals"]), - ("/services ", vec!["log"]), - ("/logs ", vec!["--service", "prev", "follow"]), - ("/install sdk ", vec!["--channel", "--format", "--prefix"]), - ("/install sdk --channel ", vec!["release", "nightly"]), - ( - "/install sdk --format ", - vec!["Recommended Python install", "Advanced archive install"], - ), - ("/install driver ", vec!["--preview"]), - ] { - let menu = super::slash_argument_completion_menu(input, &paths, 0) - .unwrap_or_else(|| panic!("{input} should have completions")); - for item in expected { - assert!( - menu.items.contains(&item.to_owned()), - "{input} should offer {item}; got {:?}", - menu.items - ); - } - } - - let menu = super::slash_argument_completion_menu("/install sdk --format ", &paths, 0) - .expect("install type completions should be available"); - assert!( - !menu - .items - .iter() - .any(|item| item == "wheel" || item == "tarball") - ); - assert_eq!( - menu.replacements, - vec![ - Some("/install sdk --format wheel ".to_owned()), - Some("/install sdk --format tarball ".to_owned()), - ] - ); - - for (input, hidden) in [ - ("/logs ", vec!["--service-id", "previous", "unfollow"]), - ( - "/install driver ", - vec!["--dkms", "--dry-run", "--yes", "--reconcile"], - ), - ( - "/runtimes adopt ", - vec!["--root", "--runtime-id", "--runtime-key", "--replace"], - ), - ( - "/serve tiny.gguf ", - vec!["--runtime-id", "--env-id", "--allow-public-bind"], - ), - ] { - let menu = super::slash_argument_completion_menu(input, &paths, 0) - .unwrap_or_else(|| panic!("{input} should have completions")); - for item in hidden { - assert!( - !menu.items.contains(&item.to_owned()), - "{input} should not advertise advanced option {item}; got {:?}", - menu.items - ); - } - } - - Ok(()) - } - - #[test] - fn enter_accepts_serve_engine_argument_completion() { - let mut app = test_app(); - app.set_input("/serve tiny.gguf --engine l".to_owned()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.input, "/serve tiny.gguf --engine llama.cpp "); - } - - #[test] - fn enter_accepts_serve_with_engine_argument_completion() { - let mut app = test_app(); - app.set_input("/serve tiny.gguf with l".to_owned()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!(app.input, "/serve tiny.gguf with llama.cpp "); - } - - #[test] - fn empty_enter_approves_focused_proposal() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-enter")?; - assert!(app.handle_command("reviews proposal-enter")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let proposal = find_automation_proposal(&app.paths, "proposal-enter")?; - assert_eq!(proposal.status, "approved"); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_some()); - assert_eq!(app.status, "Review approved; working on it."); - Ok(()) - } - - #[test] - fn command_parser_preserves_quoted_args() { - let parts = super::split_command_line(r#"plan serve "tiny model.gguf" with llama.cpp"#) - .expect("quoted command should parse"); - - assert_eq!( - parts, - vec![ - "plan".to_owned(), - "serve".to_owned(), - "tiny model.gguf".to_owned(), - "with".to_owned(), - "llama.cpp".to_owned(), - ] - ); - } - - #[test] - fn command_parser_preserves_windows_path_backslashes() { - let parts = super::split_command_line(r"serve C:\models\tiny.gguf --engine llama.cpp") - .expect("windows path should parse"); - - assert_eq!(parts[1], r"C:\models\tiny.gguf"); - } - - #[test] - fn command_parser_preserves_quoted_windows_path_backslashes() { - let parts = super::split_command_line( - r#"serve "C:\Users\user\My Models\tiny.gguf" --engine llama.cpp"#, - ) - .expect("quoted windows path should parse"); - - assert_eq!(parts[1], r"C:\Users\user\My Models\tiny.gguf"); - } - - #[test] - fn provider_rejects_extra_args() { - let mut app = test_app(); - assert!(app.handle_command("provider local extra")); - - assert_eq!(app.provider, "local"); - assert!(app.provider_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("Choose one provider")); - } - - #[test] - fn logs_service_flag_requires_value() { - let mut app = test_app(); - assert!(app.handle_command("logs --service")); - - assert!(app.logs_view.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Choose a service before opening service logs")); - assert!(rendered.contains("Previous page")); - assert!(!rendered.contains("Usage: /logs")); - } - - #[test] - fn logs_rejects_unknown_flag() { - let mut app = test_app(); - assert!(app.handle_command("logs --tail")); - - assert!(app.logs_view.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("I do not recognize logs option `--tail`")); - assert!(rendered.contains("Use the choices on the left")); - assert!(!rendered.contains("Usage: /logs")); - } - - #[test] - fn logs_search_accepts_multi_word_query() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("logs"))?; - fs::write( - app.paths.data_dir.join("logs").join("cli-lifecycle.log"), - "1 level=info category=service action=serve message=model ready\n", - )?; - - assert!(app.handle_command("logs model ready")); - - assert!(app.logs_view.is_some()); - assert!(app.transcript.is_empty()); - let rendered = app - .logs_view - .as_ref() - .expect("logs should open") - .last_rendered - .clone(); - assert!(rendered.contains("Search: model ready")); - assert!(rendered.contains("Service event: model ready")); - Ok(()) - } - - #[test] - fn logs_browser_supports_stateful_next_prev_refresh() -> anyhow::Result<()> { - let mut app = test_app(); - let action_dir = app.paths.data_dir.join("logs").join("cli"); - fs::create_dir_all(&action_dir)?; - for file_index in 0..3 { - let mut body = String::new(); - for line_index in 0..12 { - let _ = writeln!(body, "entry-{file_index}-{line_index}"); - } - fs::write(action_dir.join(format!("test-{file_index}.log")), body)?; - } - - assert!(app.handle_command("logs")); - assert_eq!(app.logs_view.as_ref().map(|state| state.page), Some(0)); - assert!(app.transcript.is_empty()); - assert!( - app.logs_view - .as_ref() - .expect("logs should open") - .last_rendered - .contains("Page: 1 of 2") - ); - - assert!(app.handle_command("logs next")); - assert_eq!(app.logs_view.as_ref().map(|state| state.page), Some(1)); - assert!( - app.logs_view - .as_ref() - .expect("logs should stay open") - .last_rendered - .contains("Page: 2 of 2") - ); - - assert!(app.handle_command("logs prev")); - assert_eq!(app.logs_view.as_ref().map(|state| state.page), Some(0)); - - assert!(app.handle_command("logs refresh")); - assert_eq!(app.logs_view.as_ref().map(|state| state.page), Some(0)); - Ok(()) - } - - #[test] - fn logs_tui_hides_file_locations_until_user_reveals_them() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("logs"))?; - fs::write( - app.paths.data_dir.join("logs").join("cli-lifecycle.log"), - "1 level=info category=service action=serve message=model ready\n", - )?; - let action_dir = app.paths.data_dir.join("logs").join("cli"); - fs::create_dir_all(&action_dir)?; - fs::write(action_dir.join("service-serve.log"), "model ready\n")?; - - assert!(app.handle_command("logs")); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Recent activity")); - assert!(rendered.contains("Search: none")); - assert!(rendered.contains("Show file locations")); - assert!(rendered.contains("PageUp/PageDown scroll")); - assert!(rendered.contains("Left/Right page")); - assert!(!rendered.contains("PageUp/PageDown page")); - assert!(!rendered.contains("cli_lifecycle:")); - assert!(!rendered.contains("cli_command_dir:")); - assert!(!rendered.contains("audit_events:")); - assert!(!rendered.contains("command log")); - assert!(!rendered.contains("service-serve.log")); - assert!(!rendered.contains(&app.paths.data_dir.display().to_string())); - - let file_locations_index = super::logs_view_actions() - .iter() - .position(|action| *action == super::LogsViewAction::ToggleFileLocations) - .expect("file locations action should exist"); - let current_index = app.logs_view.as_ref().map_or(0, |state| state.selected); - let steps = (file_locations_index + super::logs_view_actions().len() - current_index) - % super::logs_view_actions().len(); - for _ in 0..steps { - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("File Locations")); - let state = app.logs_view.as_ref().expect("logs view should stay open"); - assert!(state.show_file_locations); - let selected_before = state.selected; - let modal = state - .detail_modal - .as_ref() - .expect("file locations should open a modal card"); - assert!(modal.detail.contains("service-serve.log")); - assert!( - modal - .detail - .contains(&app.paths.data_dir.join("logs").display().to_string()) - ); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let state = app.logs_view.as_ref().expect("logs view should stay open"); - assert_eq!(state.selected, selected_before); - assert!( - state - .detail_modal - .as_ref() - .is_some_and(|modal| modal.scroll > 0) - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - let state = app.logs_view.as_ref().expect("logs view should stay open"); - assert_eq!(state.selected, selected_before); - assert!(state.detail_modal.is_some()); - assert!( - !app.logs_view - .as_ref() - .expect("logs view should stay open") - .last_rendered - .contains(&app.paths.data_dir.join("logs").display().to_string()) - ); - assert!( - !app.logs_view - .as_ref() - .expect("logs view should stay open") - .last_rendered - .contains("service-serve.log") - ); - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Show file locations")); - assert!(!rendered.contains("service-serve.log")); - assert!( - app.logs_view - .as_ref() - .expect("logs view should stay open") - .detail_modal - .is_none() - ); - Ok(()) - } - - #[test] - fn logs_follow_refreshes_when_log_content_changes() -> anyhow::Result<()> { - let mut app = test_app(); - let action_dir = app.paths.data_dir.join("logs").join("cli"); - fs::create_dir_all(&action_dir)?; - let log_path = action_dir.join("follow-test.log"); - fs::write(&log_path, "first line\n")?; - - assert!(app.handle_command("logs follow")); - assert_eq!(app.mode, TuiMode::Logs); - assert_eq!(app.logs_view.as_ref().map(|state| state.follow), Some(true)); - assert!(app.transcript.is_empty()); - assert!( - app.logs_view - .as_ref() - .expect("logs should open") - .last_rendered - .contains("first line") - ); - - fs::write(&log_path, "first line\nsecond line\n")?; - app.logs_view - .as_mut() - .expect("follow state should be open") - .last_refresh_at = Instant::now() - .checked_sub(LOG_FOLLOW_REFRESH_INTERVAL) - .unwrap() - .checked_sub(Duration::from_millis(1)) - .unwrap(); - app.refresh_following_logs(); - - assert!( - app.logs_view - .as_ref() - .expect("logs should stay open") - .last_rendered - .contains("second line") - ); - assert!(app.status.contains("Following logs; refreshed")); - Ok(()) - } - - #[test] - fn logs_follow_can_be_stopped_or_paged() -> anyhow::Result<()> { - let mut app = test_app(); - let action_dir = app.paths.data_dir.join("logs").join("cli"); - fs::create_dir_all(&action_dir)?; - fs::write(action_dir.join("follow-test.log"), "line\n")?; - - assert!(app.handle_command("logs follow")); - assert_eq!(app.logs_view.as_ref().map(|state| state.follow), Some(true)); - assert_eq!( - app.selected_logs_view_action(), - super::LogsViewAction::ToggleFollow - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert_eq!( - app.logs_view.as_ref().map(|state| state.follow), - Some(false) - ); - - assert!(app.handle_command("logs next")); - assert_eq!( - app.logs_view.as_ref().map(|state| state.follow), - Some(false) - ); - - assert!(app.handle_command("logs follow")); - assert!(app.handle_command("logs stop")); - assert_eq!( - app.logs_view.as_ref().map(|state| state.follow), - Some(false) - ); - assert_eq!(app.status, "Log following stopped."); - Ok(()) - } - - #[test] - fn logs_next_without_open_browser_opens_logs() { - let mut app = test_app(); - assert!(app.handle_command("logs next")); - - assert!(app.logs_view.is_some()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("Logs")); - } - - #[test] - fn install_without_target_opens_screen_without_approval() { - let mut app = test_app(); - assert!(app.handle_command("install")); - - assert!(app.pending_approval.is_none()); - assert!(app.install_manager.is_some()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn engine_install_without_engine_shows_usage_without_approval() { - let mut app = test_app(); - assert!(app.handle_command("engine install")); - - assert!(app.pending_approval.is_none()); - assert!(app.engine_manager.is_some()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("Choose an engine")); - } - - #[test] - fn engine_command_opens_navigable_manager_without_dump() { - let mut app = test_app(); - assert!(app.handle_command("engine")); - - assert!(app.engine_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Engines")); - assert!(rendered.contains("pytorch")); - assert!(rendered.contains("llama.cpp")); - assert!(rendered.contains("Up/Down choose")); - assert!(!rendered.contains("engine inventory")); - assert!(!rendered.contains("plugin_dirs")); - } - - #[test] - fn engine_unknown_option_stays_in_manager_without_backend_command() { - let mut app = test_app(); - assert!(app.handle_command("engine bogus")); - - assert!(app.engine_manager.is_some()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Choose an engine")); - assert!(!rendered.contains("command:")); - } - - #[test] - fn engine_install_rejects_unknown_engine_before_approval() { - let mut app = test_app(); - - assert!(app.handle_command("engine install not-real")); - - assert!(app.engine_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("I could not find engine `not-real`")); - assert!(rendered.contains("Use the arrow keys")); - assert!(!rendered.contains("Review this change")); - } - - #[test] - fn engine_install_rejects_unknown_option_without_leaving_engine_list() { - let mut app = test_app(); - - assert!(app.handle_command("engine install llama.cpp --bogus")); - - assert!(app.engine_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("I do not recognize that engine install option")); - assert!(!rendered.contains("Review this change")); - } - - #[test] - fn engine_install_uses_canonical_engine_name_after_case_insensitive_match() { - let mut app = test_app(); - - assert!(app.handle_command("engine install LLAMA.CPP --reinstall")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "engines".to_owned(), - "install".to_owned(), - "llama.cpp".to_owned(), - "--reinstall".to_owned(), - ] - )); - assert!(app.engine_manager.is_some()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn engine_manager_arrow_keys_change_selected_engine() { - let mut app = test_app(); - assert!(app.handle_command("engine")); - assert_eq!( - app.selected_engine_manager_item() - .as_ref() - .map(|item| item.name.as_str()), - Some("pytorch") - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!( - app.selected_engine_manager_action(), - Some(super::EngineManagerAction::InstallRocm) - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!( - app.selected_engine_manager_item() - .as_ref() - .map(|item| item.name.as_str()), - Some("llama.cpp") - ); - } - - #[test] - fn engine_manager_enter_without_rocm_opens_install_screen() { - let mut app = test_app(); - assert!(app.handle_command("engine")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.install_manager.is_some()); - assert!(app.engine_manager.is_none()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Install ROCm")); - } - - #[test] - fn engine_manager_enter_with_ready_rocm_uses_pytorch_runtime() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("release-pip-gfx120x-all-7-14-0".to_owned()); - app.config.save(&app.paths)?; - assert!(app.handle_command("engine")); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Installed pytorch"), "{rendered}"); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "config".to_owned(), - "set-default-engine".to_owned(), - "pytorch".to_owned(), - ] - )); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn engine_manager_install_action_row_is_reachable_with_arrows() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("release-pip-gfx120x-all-7-14-0".to_owned()); - app.config.save(&app.paths)?; - assert!(app.handle_command("engine")); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - for _ in 0..super::engine_manager_choice_count(app.engine_manager.as_ref().unwrap()) { - if app.selected_engine_manager_action() - == Some(super::EngineManagerAction::InstallSelected) - { - break; - } - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - let target_name = app - .selected_engine_manager_target_item() - .expect("engine row should stay selected for action rows") - .name; - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("> Install selected engine")); - assert!(rendered.contains("Press Enter to review installing it.")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let pending_action = app.pending_approval.as_ref().map(|pending| &pending.action); - assert!( - matches!( - pending_action, - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "engines".to_owned(), - "install".to_owned(), - target_name, - ] - ), - "{pending_action:?}" - ); - assert!(app.engine_manager.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn engine_manager_enter_with_installed_engine_requests_default_switch() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("release-pip-gfx120x-all-7-14-0".to_owned()); - app.config - .engine_config_mut("pytorch") - .last_installed_env_id = Some("ready-env".to_owned()); - app.config.save(&app.paths)?; - write_test_engine_env( - &app.paths, - "pytorch", - "ready-env", - "release-pip-gfx120x-all-7-14-0", - )?; - assert!(app.handle_command("engine")); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("installed")); - assert!(!rendered.contains("install id:")); - assert!(!rendered.contains("ready-env")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "config".to_owned(), - "set-default-engine".to_owned(), - "pytorch".to_owned(), - ] - )); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Use this engine")); - Ok(()) - } - - #[test] - fn engine_manager_reinstall_action_row_is_reachable_with_arrows() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("release-pip-gfx120x-all-7-14-0".to_owned()); - app.config - .engine_config_mut("pytorch") - .last_installed_env_id = Some("ready-env".to_owned()); - app.config.save(&app.paths)?; - write_test_engine_env( - &app.paths, - "pytorch", - "ready-env", - "release-pip-gfx120x-all-7-14-0", - )?; - assert!(app.handle_command("engine")); - - for _ in 0..super::engine_manager_choice_count(app.engine_manager.as_ref().unwrap()) { - if app.selected_engine_manager_action() - == Some(super::EngineManagerAction::ReinstallSelected) - { - break; - } - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("> Reinstall selected engine")); - assert!(rendered.contains("Press Enter to review reinstalling it.")); - assert!(!rendered.contains("I install")); - assert!(!rendered.contains("R reinstall")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "engines".to_owned(), - "install".to_owned(), - "pytorch".to_owned(), - "--reinstall".to_owned(), - ] - )); - assert!(app.engine_manager.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn engine_approval_displays_tui_command_not_backend_cli_name() { - let mut app = test_app(); - assert!(app.handle_command("engine install llama.cpp")); - - assert!(app.pending_approval.is_some()); - assert!(app.engine_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Set up the selected local AI engine.")); - assert!(!rendered.contains("/engine install llama.cpp")); - assert!(!rendered.contains("rocm engines")); - } - - #[test] - fn gpu_rejects_unknown_args() { - let mut app = test_app(); - assert!(app.handle_command("gpu now")); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("GPU status is shown here.")); - } - - #[test] - fn command_output_text_is_sanitized_and_bounded() { - let clean = super::sanitize_terminal_text("\u{1b}[31mred\u{1b}[0m\tok\u{7}"); - assert_eq!(clean, "red\tok"); - assert_eq!(super::truncate_stream_line(&"x".repeat(240)).len(), 220); - - let long = (0..300) - .map(|index| format!("line {index}")) - .collect::>() - .join("\n"); - let truncated = super::truncate_command_output(&long); - assert!(truncated.contains("truncated")); - assert!(truncated.lines().count() < 300); - } - - #[test] - fn serve_failure_summary_keeps_multiline_log_output() { - let rendered = [ - "error: vLLM server did not become ready within 30s", - "vLLM startup log tail (/tmp/vllm.log):", - "RuntimeError: HIP out of memory", - "trace line", - ] - .join("\n"); - - let summary = super::plain_command_completion_text( - "Serve", - false, - &rendered, - super::StreamedOutputCounts::default(), - None, - None, - None, - ); - - assert!(summary.contains("Serve failed.")); - assert!(summary.contains("vLLM startup log tail")); - assert!(summary.contains("RuntimeError: HIP out of memory")); - assert!(summary.contains("trace line")); - } - - #[test] - fn render_command_output_keeps_untruncated_output_for_full_logs() -> anyhow::Result<()> { - let stdout = (0..300) - .map(|index| format!("line {index}")) - .collect::>() - .join("\n"); - - let output = super::render_command_output( - "rocm", - &["install".to_owned(), "sdk".to_owned()], - std::process::Output { - status: failed_exit_status(), - stdout: stdout.into_bytes(), - stderr: b"ERROR: failed\n".to_vec(), - }, - None, - )?; - - assert!(output.rendered.contains("line 0")); - assert!(output.rendered.contains("line 299")); - assert!(output.rendered.contains("ERROR: failed")); - assert!(!output.rendered.contains("truncated")); - let visible = super::truncate_command_output(&output.rendered); - assert!(visible.contains("truncated")); - assert!(!visible.contains("line 299")); - Ok(()) - } - - #[test] - fn streaming_pipe_sends_lines_and_collects_bytes() { - let (sender, receiver) = mpsc::channel(); - - let collected = super::read_streaming_pipe( - std::io::Cursor::new(b"one\ntwo\n".to_vec()), - super::CommandOutputStream::Stdout, - sender, - ); - let events = receiver.try_iter().collect::>(); - - assert_eq!(collected, b"one\ntwo\n"); - assert_eq!(events.len(), 2); - assert!(matches!( - &events[0], - super::RunningJobEvent::Stream { stream, line } - if matches!(stream, super::CommandOutputStream::Stdout) && line == "one" - )); - } - - #[test] - fn streaming_pipe_splits_carriage_return_progress() { - let (sender, receiver) = mpsc::channel(); - - let collected = super::read_streaming_pipe( - std::io::Cursor::new(b"10%\r20%\rdone\n".to_vec()), - super::CommandOutputStream::Stdout, - sender, - ); - let lines = receiver - .try_iter() - .filter_map(|event| match event { - super::RunningJobEvent::Stream { line, .. } => Some(line), - _ => None, - }) - .collect::>(); - - assert_eq!(collected, b"10%\r20%\rdone\n"); - assert_eq!(lines, vec!["10%", "20%", "done"]); - } - - #[test] - fn poll_running_job_appends_stream_lines_before_completion() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Examine".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "checking runtime".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stderr, - line: "\u{1b}[31mwarning\u{1b}[0m".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "examine ok".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!( - app.running_job_output - .iter() - .any(|line| line == "checking runtime") - ); - assert!(app.running_job_output.iter().any(|line| line == "warning")); - assert!(app.transcript.iter().any(|line| line == "[Examine]")); - assert!(!app.transcript.iter().any(|line| line == "stdout:")); - assert!( - app.transcript - .iter() - .any(|line| line == "streamed_stdout: 1 line(s) shown in live output while it ran") - ); - assert!( - app.transcript - .iter() - .any(|line| line == "streamed_stderr: 1 line(s) shown in live output while it ran") - ); - assert!(app.activity_text().contains("completed: Examine")); - } - - #[test] - fn poll_running_job_appends_chat_deltas_before_completion() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Chat".to_owned(), - kind: super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: false, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::ChatDelta { - content: "Hel".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::ChatDelta { - content: "lo".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "provider: local\nstreamed_chat: 2 event(s) shown in chat".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!(app.transcript.iter().any(|line| line == "assistant: Hello")); - assert!(!app.transcript.iter().any(|line| { - line.text - .contains("streamed_chat: 2 event(s) shown in chat") - })); - assert!(app.activity_text().contains("chat completed: local")); - assert_eq!(app.status, "Chat response completed from local."); - } - - #[test] - fn running_command_screen_cannot_close_into_transcript_fallback() { - let mut app = test_app(); - app.open_command_screen( - "Chat", - "Sending prompt...", - Some(super::CommandScreenTarget::ChatProvider), - vec![super::CommandScreenAction::Back], - ); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Chat".to_owned(), - kind: super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: false, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - assert!(app.command_screen.is_some()); - assert!(app.status.contains("Chat is still running")); - - sender - .send(super::RunningJobEvent::ChatDelta { - content: "hello".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "provider: local".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!( - app.command_screen - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains("hello")) - ); - } - - #[test] - fn chat_completion_does_not_scroll_away_from_streamed_answer() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Chat".to_owned(), - kind: super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: false, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 1, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - app.push_chat_delta("answer"); - let answer_scroll = app.transcript_scroll; - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "provider: local\nstreamed_chat: 1 event(s) shown in chat".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert_eq!(app.transcript_scroll, answer_scroll); - assert_eq!( - app.transcript.last().map(|line| line.text.as_str()), - Some("assistant: answer") - ); - } - - #[test] - fn chat_session_keeps_streamed_answer_after_completion() { - let mut app = test_app(); - let record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - 123, - None, - None, - None, - ); - app.open_local_rocm_tools_chat_session(Some(record)); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Chat".to_owned(), - kind: super::RunningJobKind::Chat { - provider: "local".to_owned(), - rocm_tools: false, - }, - receiver, - started_at: Instant::now(), - streamed_lines: 1, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - app.push_chat_delta("I can help you install ROCm/TheRock."); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "provider: local\nstreamed_chat: 1 event(s) shown in chat".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("I can help you install ROCm/TheRock.")); - assert!(!rendered.contains("The assistant did not return a message.")); - assert!(app.command_screen_is_chat_session()); - } - - #[test] - fn chat_session_long_answer_follows_bottom_and_can_scroll_up() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let answer = (0..80) - .map(|index| format!("answer line {index:02}")) - .collect::>() - .join("\n"); - app.push_chat_session_turn(super::ChatSessionRole::Assistant, answer); - - let rendered = render_test_terminal(&app, 100, 18); - assert!( - rendered.contains("answer line 79"), - "latest assistant output should be visible at the followed bottom:\n{rendered}" - ); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::NONE)); - let scroll = app - .command_screen - .as_ref() - .map(|state| state.detail_scroll) - .expect("chat screen should stay open"); - assert!( - scroll < super::CHAT_SESSION_FOLLOW_SCROLL, - "PageUp should move from the actual bottom, not remain near the follow sentinel" - ); - } - - #[test] - fn chat_session_typing_new_prompt_keeps_latest_visible() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let answer = (0..140) - .map(|index| format!("typing follow line {index:03}")) - .collect::>() - .join("\n"); - app.push_chat_session_turn(super::ChatSessionRole::Assistant, answer); - if let Some(state) = app.command_screen.as_mut() { - state.detail_scroll = 0; - } - let _ = render_test_terminal(&app, 92, 20); - - for _ in 0..80 { - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - } - assert!( - app.command_screen - .as_ref() - .is_some_and(|state| state.detail_scroll < super::CHAT_SESSION_FOLLOW_SCROLL), - "test should use the real bottom offset, not the follow sentinel" - ); - - let prompt = - "please keep the newest assistant answer visible while I type this longer follow up " - .repeat(8); - for ch in prompt.chars() { - handle_key(&mut app, key_event(KeyCode::Char(ch), KeyModifiers::NONE)); - } - - let rendered = render_test_terminal(&app, 92, 20); - assert!( - rendered.contains("typing follow line 139"), - "typing a new prompt should keep the latest answer visible, not jump upward:\n{rendered}" - ); - assert!( - !rendered.contains("typing follow line 000"), - "typing a new prompt should not jump back to the top:\n{rendered}" - ); - } - - #[test] - fn chat_session_message_box_wraps_and_scrolls_long_prompt() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let prompt = "install ROCm into D:\\ROCm\\therock_venvs and then explain exactly what folder will be used before you do anything ".repeat(8); - app.set_input(prompt); - - assert!( - super::chat_input_area_height(&app, 100, 28) > 3, - "chat composer should grow for long typed prompts" - ); - let rendered = render_test_terminal(&app, 100, 28); - assert!(rendered.contains("Message"), "{rendered}"); - assert!(rendered.contains("therock_venvs"), "{rendered}"); - - handle_key(&mut app, key_event(KeyCode::PageUp, KeyModifiers::CONTROL)); - assert!( - app.chat_input_scroll < super::CHAT_SESSION_FOLLOW_SCROLL, - "Ctrl+PageUp should scroll the typed message, not the conversation sentinel" - ); - assert!(!app.status.to_ascii_lowercase().contains("scrolled")); - } - - #[test] - fn chat_session_page_down_scrolls_conversation_even_with_draft_message() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let answer = (0..90) - .map(|index| format!("assistant detail line {index:02}")) - .collect::>() - .join("\n"); - app.push_chat_session_turn(super::ChatSessionRole::Assistant, answer); - app.set_input("draft text should not steal PageDown".to_owned()); - - if let Some(state) = app.command_screen.as_mut() { - state.detail_scroll = 0; - } - let _ = render_test_terminal(&app, 100, 18); - - for _ in 0..20 { - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - } - let rendered = render_test_terminal(&app, 100, 18); - - assert!( - rendered.contains("assistant detail line 89"), - "PageDown should reach the bottom of the conversation even with a draft:\n{rendered}" - ); - assert_eq!( - app.chat_input_scroll, - super::CHAT_SESSION_FOLLOW_SCROLL, - "plain PageDown must not scroll the draft composer" - ); - } - - #[test] - fn chat_session_mouse_wheel_scrolls_conversation_to_bottom_with_draft_message() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let answer = (0..90) - .map(|index| format!("wheel detail line {index:02}")) - .collect::>() - .join("\n"); - app.push_chat_session_turn(super::ChatSessionRole::Assistant, answer); - app.set_input("draft text should not steal transcript wheel scroll".to_owned()); - if let Some(state) = app.command_screen.as_mut() { - state.detail_scroll = 0; - } - let _ = render_test_terminal(&app, 100, 18); - - for _ in 0..40 { - super::handle_mouse( - &mut app, - MouseEvent { - kind: MouseEventKind::ScrollDown, - column: 2, - row: 2, - modifiers: KeyModifiers::NONE, - }, - ); - } - let rendered = render_test_terminal(&app, 100, 18); - - assert!( - rendered.contains("wheel detail line 89"), - "mouse wheel over the conversation should reach the bottom:\n{rendered}" - ); - } - - #[test] - fn chat_session_scrollbar_thumb_reaches_bottom_at_odd_size() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - let answer = (0..110) - .map(|index| format!("odd-size scrollbar line {index:03}: wide prompt marker ok")) - .collect::>() - .join("\n\n"); - app.push_chat_session_turn(super::ChatSessionRole::Assistant, answer); - app.set_input("draft".to_owned()); - if let Some(state) = app.command_screen.as_mut() { - state.detail_scroll = 0; - } - let width = 107; - let height = 23; - let _ = render_test_buffer(&app, width, height); - - for _ in 0..140 { - super::handle_mouse( - &mut app, - MouseEvent { - kind: MouseEventKind::ScrollDown, - column: 2, - row: 2, - modifiers: KeyModifiers::NONE, - }, - ); - } - let rendered = render_test_terminal(&app, width, height); - let buffer = render_test_buffer(&app, width, height); - let scrollbar_x = width - 1; - let bottom_arrow_y = (0..height) - .rev() - .find(|row| buffer[(scrollbar_x, *row)].symbol() == "▼") - .expect("chat scrollbar should render a bottom arrow"); - let above_bottom = buffer[(scrollbar_x, bottom_arrow_y - 1)].symbol(); - - assert!( - rendered.contains("odd-size scrollbar line 109"), - "mouse wheel should reach the bottom at odd terminal sizes:\n{rendered}" - ); - assert_eq!( - above_bottom, "█", - "scrollbar thumb should touch the bottom arrow at the bottom scroll position:\n{rendered}" - ); - } - - #[test] - fn question_mark_stays_in_chat_composer_instead_of_opening_help() { - let mut app = test_app(); - app.open_local_rocm_tools_chat_session(None); - - handle_key(&mut app, key_event(KeyCode::Char('?'), KeyModifiers::NONE)); - - assert_eq!(app.input, "?"); - assert!(app.overlay_card.is_none()); - assert!(app.command_screen_is_chat_session()); - } - - #[test] - fn chat_session_scroll_bound_keeps_last_line_reachable_after_resize() { - let detail = (0..64) - .map(|index| format!("Assistant\n answer line {index:02}\n")) - .collect::>() - .join("\n"); - let small = super::Rect { - x: 0, - y: 0, - width: 96, - height: 12, - }; - let tall = super::Rect { - x: 0, - y: 0, - width: 96, - height: 28, - }; - - let small_scroll = super::bounded_chat_session_scroll(&detail, small, u16::MAX); - let tall_scroll = super::bounded_chat_session_scroll(&detail, tall, u16::MAX); - - let small_width = small.width.saturating_sub(2).max(1) as usize; - let small_height = small.height.saturating_sub(2).max(1) as usize; - let small_visual_lines = super::paragraph_visual_line_count(&detail, small_width); - let small_scroll_height = - super::scrollable_text_viewport_len(small_visual_lines, small_height); - assert_eq!( - usize::from(small_scroll), - small_visual_lines.saturating_sub(small_scroll_height), - "chat should bottom-align instead of overscrolling past the last line" - ); - - let tall_width = tall.width.saturating_sub(2).max(1) as usize; - let tall_height = tall.height.saturating_sub(2).max(1) as usize; - let tall_visual_lines = super::paragraph_visual_line_count(&detail, tall_width); - let tall_scroll_height = - super::scrollable_text_viewport_len(tall_visual_lines, tall_height); - assert_eq!( - usize::from(tall_scroll), - tall_visual_lines.saturating_sub(tall_scroll_height), - "resizing should clamp to the actual chat bottom" - ); - } - - #[test] - fn folder_picker_rows_are_plain_and_direct() { - let mut app = test_app(); - app.open_install_folder_browser_for_args(vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - ]); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("drive "), "{rendered}"); - assert!(!rendered.contains("open "), "{rendered}"); - assert!(!rendered.contains("up "), "{rendered}"); - assert!(!rendered.contains("Drive "), "{rendered}"); - assert!(!rendered.contains("Open "), "{rendered}"); - assert!(!rendered.contains("Go up one folder"), "{rendered}"); - } - - #[test] - fn setup_snapshot_is_quiet_and_path_specific() { - let mut app = test_app(); - app.onboarding_active = true; - let selected = short_test_install_root(); - app.config.setup.therock_venv = Some(selected); - app.reset_onboarding_selection(); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Set Up ROCm"), "{rendered}"); - assert!(rendered.contains("Install location"), "{rendered}"); - assert!(rendered.contains("Folder:"), "{rendered}"); - assert!(rendered.contains("therock"), "{rendered}"); - assert!(rendered.contains("venvs"), "{rendered}"); - assert!(rendered.contains("pip-cache"), "{rendered}"); - assert!(rendered.contains("Channel:"), "{rendered}"); - assert!(rendered.contains("Stable (default)"), "{rendered}"); - assert!(!rendered.contains("Install folder: selected"), "{rendered}"); - assert!( - !rendered.contains("Install folder: recommended"), - "{rendered}" - ); - assert!(!rendered.contains("Command List"), "{rendered}"); - assert!(!rendered.contains("request plan"), "{rendered}"); - assert!(!rendered.contains("What will happen"), "{rendered}"); - } - - #[test] - fn engine_completion_snapshot_returns_to_quiet_engine_list() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - app.config.active_runtime_key = Some("release-pip-gfx120x-all-7-14-0".to_owned()); - app.config.save(&app.paths)?; - assert!(app.handle_command("engine")); - let sender = attach_running_job(&mut app, "Engine", super::RunningJobKind::Cli); - app.set_active_screen_message( - "Output: Downloading package\nOutput: Installing package".to_owned(), - ); - - finish_running_job( - &mut app, - sender, - "command: rocm engines install pytorch\nstatus: ok\n\nstdout:\nengine ready", - ); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Engines"), "{rendered}"); - assert!(!rendered.contains("Engine finished"), "{rendered}"); - assert!(!rendered.contains("Recent output"), "{rendered}"); - assert!(!rendered.contains("Output: Downloading"), "{rendered}"); - assert!(!rendered.contains("command: rocm"), "{rendered}"); - Ok(()) - } - - #[test] - fn folder_picker_snapshot_keeps_scrolled_window_stable() -> anyhow::Result<()> { - let (root, _paths) = test_paths("folder-picker-render-scroll"); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root)?; - for index in 0..60 { - fs::create_dir_all(root.join(format!("child-{index:03}")))?; - } - let mut app = test_app(); - app.open_folder_browser( - "Choose ROCm Folder", - "Pick where ROCm/TheRock should be installed.", - super::FolderBrowserContext::OnboardingInstallFolder, - ); - { - let state = app.folder_browser.as_mut().expect("folder picker"); - let (entries, message) = super::folder_browser_entries(&root); - state.current_dir = root.clone(); - state.entries = entries; - state.message = message; - state.selected = 30; - state.scroll_offset = 25; - } - - let rendered = render_test_terminal(&app, 90, 18); - assert!(rendered.contains("Choose ROCm Folder"), "{rendered}"); - assert!(rendered.contains("child-024"), "{rendered}"); - assert!(!rendered.contains("Use current folder"), "{rendered}"); - assert!(!rendered.contains("drive "), "{rendered}"); - assert!(!rendered.contains("open "), "{rendered}"); - - let _ = fs::remove_dir_all(&root); - Ok(()) - } - - #[test] - fn folder_picker_pins_create_folder_rows_before_busy_directory_listing() -> anyhow::Result<()> { - let (root, _paths) = test_paths("folder-picker-busy"); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root)?; - for index in 0..100 { - fs::create_dir_all(root.join(format!("child-{index:03}")))?; - } - - let (entries, _) = super::folder_browser_entries(&root); - let labels = entries - .iter() - .take(4) - .map(|entry| entry.label.as_str()) - .collect::>(); - assert_eq!(labels[0], "Use current folder"); - assert_eq!(labels[1], "+ therock_venvs"); - assert_eq!(labels[2], "+ rocm_venvs"); - assert!(labels.contains(&"+ therock_venvs")); - assert!(labels.contains(&"+ rocm_venvs")); - - let _ = fs::remove_dir_all(&root); - Ok(()) - } - - #[test] - fn folder_picker_hides_internal_rocm_launcher_cache() -> anyhow::Result<()> { - let (root, _paths) = test_paths("folder-picker-internal-rocm"); - let rocm_dir = root.join(".rocm"); - let visible = rocm_dir.join("my_rocm_envs"); - fs::create_dir_all(rocm_dir.join("launcher"))?; - fs::create_dir_all(rocm_dir.join("cache"))?; - fs::create_dir_all(&visible)?; - - let (entries, _) = super::folder_browser_entries(&rocm_dir); - let labels = entries - .iter() - .map(|entry| entry.label.as_str()) - .collect::>(); - assert!(labels.iter().any(|label| label.starts_with("my_rocm_envs"))); - assert!(!labels.iter().any(|label| label.starts_with("launcher"))); - assert!(!labels.iter().any(|label| label.starts_with("cache"))); - - let _ = fs::remove_dir_all(&root); - Ok(()) - } - - #[test] - fn setup_install_root_ignores_saved_internal_launcher_folder() -> anyhow::Result<()> { - let mut app = test_app(); - let launcher_dir = app.paths.data_dir.join("launcher"); - fs::create_dir_all(&launcher_dir)?; - app.config.setup.therock_venv = Some(launcher_dir.clone()); - - let root = super::setup_install_root(&app.paths, &app.config); - - assert_eq!(root, app.paths.data_dir.join("envs").join("default")); - assert!(super::folder_browser_is_internal_rocm_dir( - &app.paths, - &launcher_dir - )); - assert!(!super::folder_browser_is_internal_rocm_dir( - &app.paths, &root - )); - Ok(()) - } - - #[test] - fn folder_picker_accepts_mixed_windows_drive_paths() -> anyhow::Result<()> { - if !super::runtime_is_windows() { - return Ok(()); - } - let (root, _paths) = test_paths("folder-picker-mixed-drive"); - let _ = fs::remove_dir_all(&root); - fs::create_dir_all(&root)?; - - let slash_root = root.display().to_string().replace('\\', "/"); - let mixed_root = slash_root.replacen(":/", r":\/", 1); - let (entries, _) = super::folder_browser_entries(Path::new(&mixed_root)); - let selected = entries - .iter() - .find(|entry| entry.label == "+ therock_venvs") - .expect("therock_venvs row should be pinned"); - let display = super::display_runtime_folder_path(&selected.path); - assert!(!display.contains('/'), "{display}"); - - let chosen = - super::validate_onboarding_install_folder(&selected.path.display().to_string())?; - assert!(super::runtime_folder_path_is_absolute(&chosen)); - assert!( - super::onboarding_folder_preset_key(&chosen).ends_with(r"\therock_venvs"), - "{}", - chosen.display() - ); - - let _ = fs::remove_dir_all(&root); - Ok(()) - } - - #[test] - fn folder_picker_drive_letter_opens_drive_directly() { - let Some(drive) = super::folder_browser_drive_root_for_key('d') else { - return; - }; - let mut app = test_app(); - app.open_install_folder_browser_for_args(vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "release".to_owned(), - "--format".to_owned(), - "wheel".to_owned(), - ]); - - handle_key(&mut app, key_event(KeyCode::Char('d'), KeyModifiers::NONE)); - - assert_eq!( - app.folder_browser - .as_ref() - .map(|state| state.current_dir.as_path()), - Some(drive.as_path()) - ); - } - - #[test] - fn slash_completion_advertises_chat_prompt() { - let mut app = test_app(); - app.set_input("/cha".to_owned()); - - let menu = app - .slash_completion_menu() - .expect("chat command should be suggested"); - - assert!(menu.items.iter().any(|item| item.contains("/chat"))); - } - - #[test] - fn poll_running_job_caps_stream_events_per_tick() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Examine".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - for index in 0..(super::MAX_STREAM_EVENTS_PER_POLL + 5) { - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: format!("line-{index}"), - }) - .unwrap(); - } - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "examine ok".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.running_job.is_some()); - assert_eq!( - app.running_job_output - .iter() - .filter(|line| line.starts_with("line-")) - .count(), - super::MAX_STREAM_EVENTS_PER_POLL - ); - assert_eq!(app.status, "Streaming output..."); - - app.poll_running_job(); - - assert!(app.running_job.is_none()); - assert!(app.transcript.iter().any(|line| line == "[Examine]")); - } - - #[test] - fn stream_line_does_not_force_scroll_when_user_is_reading_history() { - let mut app = test_app(); - for index in 0..80 { - app.transcript.push(super::TranscriptLine::block_body( - "Test", - &format!("line {index}"), - )); - } - app.transcript_scroll = 0; - - app.push_stream_line(super::CommandOutputStream::Stdout, "new output"); - - assert_eq!(app.transcript_scroll, 0); - assert!( - app.transcript - .iter() - .any(|line| line == "[stdout] new output") - ); - } - - #[test] - fn final_streaming_completion_does_not_force_scroll_when_user_is_reading_history() { - let mut app = test_app(); - for index in 0..80 { - app.transcript.push(super::TranscriptLine::block_body( - "Test", - &format!("line {index}"), - )); - } - app.transcript_scroll = 0; - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Examine".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 1, - streamed_stdout_lines: 1, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "command: rocm examine\nstatus: ok\n\nstdout:\nready".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert_eq!(app.transcript_scroll, 0); - assert!(app.transcript.iter().any(|line| line == "[Examine]")); - } - - #[test] - fn failed_streaming_command_keeps_stream_and_failed_status() { - let mut app = test_app(); - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: "Install".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "starting install".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: false, - rendered: "command: rocm install sdk\nstatus: failed (1)\n\nstdout:\nstarting install\n\nstderr:\nbroken".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!( - app.running_job_output - .iter() - .any(|line| line == "starting install") - ); - assert!( - app.transcript - .iter() - .any(|line| line == "status: failed (1)") - ); - assert!(!app.transcript.iter().any(|line| line == "stdout:")); - assert!(app.transcript.iter().any(|line| line == "stderr:")); - assert!(app.transcript.iter().any(|line| line == "broken")); - assert_eq!(app.status, "Install command failed."); - assert!(app.activity_text().contains("failed: Install")); - } - - #[test] - fn plan_command_renders_request_plan() { - let mut app = test_app(); - assert!(app.handle_command("plan serve qwen with pytorch")); - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let rendered = app - .command_screen - .as_ref() - .expect("plan should open") - .detail - .clone(); - assert!(rendered.contains("Plan")); - assert!(rendered.contains("Request")); - assert!(rendered.contains("Ready action")); - assert!(rendered.contains("Run: review and start this action")); - assert!(rendered.contains("Edit request: change the request text")); - assert!(!rendered.contains("intent:")); - assert!(!rendered.contains("planner:")); - assert!(!rendered.contains("tool_call:")); - assert!(app.pending_approval.is_none()); - } - - #[test] - fn natural_plan_cpu_request_does_not_offer_approval() { - let mut app = test_app(); - assert!(app.handle_command("plan run a small local model on cpu")); - - assert!(app.command_screen.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("CPU mode is not offered")); - assert!(!rendered.contains("Ready action")); - assert!(!rendered.contains("Run: review and start this action")); - assert!(!rendered.contains("Review this change")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn plan_screen_owns_approved_command_output() { - let mut app = test_app(); - app.open_command_screen( - "Plan", - "Plan rendered. Review the tool call before running it.", - None, - vec![super::CommandScreenAction::Back], - ); - - assert!(app.active_screen_handles_command_output("Install TheRock SDK")); - - let (sender, receiver) = mpsc::channel::(); - app.running_job = Some(super::RunningJob { - title: "Install TheRock SDK".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "command: rocm update\nstatus: ok\n\nstdout:\nplanned command finished\n" - .to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.transcript.is_empty()); - assert!(app.command_screen.is_some()); - assert!( - app.command_screen - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| { - message.contains("planned command finished") - && !message.contains("command:") - && !message.contains("stdout:") - && !message.contains("status:") - }) - ); - } - - #[test] - fn plan_screen_owns_streaming_command_output() { - let mut app = test_app(); - app.open_command_screen( - "Plan", - "Plan rendered. Review the tool call before running it.", - None, - vec![super::CommandScreenAction::Back], - ); - - let (sender, receiver) = mpsc::channel::(); - app.running_job = Some(super::RunningJob { - title: "Install TheRock SDK".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "downloading package".to_owned(), - }) - .unwrap(); - - app.poll_running_job(); - - assert!(app.transcript.is_empty()); - assert!(app.command_screen.is_some()); - let message = app - .command_screen - .as_ref() - .and_then(|state| state.message.as_deref()) - .expect("screen should show live output"); - assert!(message.contains("downloading package")); - assert!(!message.contains("Output: downloading package")); - let rendered = super::command_screen_detail_text(&app); - assert!(!rendered.contains("Live output is shown in the foreground card")); - assert!(!rendered.contains("Output: downloading package")); - let modal = super::running_job_modal_text( - &app, - app.running_job.as_ref().expect("running job"), - 8, - 80, - false, - ); - assert!(modal.contains("Live output")); - assert!(modal.contains("downloading package")); - assert!(!modal.contains("Output: downloading package")); - } - - #[test] - fn screen_command_completion_stays_terse_and_saves_full_log() -> anyhow::Result<()> { - let mut app = test_app(); - app.open_command_screen( - "Plan", - "Plan rendered. Review the tool call before running it.", - None, - vec![super::CommandScreenAction::Back], - ); - - let (sender, receiver) = mpsc::channel::(); - app.running_job = Some(super::RunningJob { - title: "Install TheRock SDK".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stdout, - line: "resolving torch wheels".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Stream { - stream: super::CommandOutputStream::Stderr, - line: "pip is checking candidates".to_owned(), - }) - .unwrap(); - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: "command: rocm install sdk\nstatus: ok\n\nstdout:\nresolved torch\n\nstderr:\npip warning".to_owned(), - chat_approval: None, - }))) - .unwrap(); - - app.poll_running_job(); - - assert!(app.transcript.is_empty()); - let message = app - .command_screen - .as_ref() - .and_then(|state| state.message.as_deref()) - .expect("completion should stay on the plan screen"); - assert!(message.contains("Install TheRock SDK finished.")); - assert!(message.contains("resolved torch")); - assert!(!message.contains("Live output")); - assert!(!message.contains("Recent output")); - assert!(!message.contains("Output: resolving torch wheels")); - assert!(!message.contains("More details are available in Logs.")); - assert!(!message.contains("Use View details here first")); - assert!( - !message.contains( - &app.paths - .data_dir - .join("logs") - .join("tui") - .display() - .to_string() - ) - ); - - let log_dir = app.paths.data_dir.join("logs").join("tui"); - let logs = fs::read_dir(&log_dir)? - .map(|entry| entry.map(|entry| entry.path())) - .collect::, _>>()?; - assert_eq!(logs.len(), 1); - let log = fs::read_to_string(&logs[0])?; - assert!(log.contains("recent_live_output:")); - assert!(log.contains("resolving torch wheels")); - assert!(!log.contains("Output: resolving torch wheels")); - assert!(log.contains("command_output:")); - assert!(log.contains("stdout:\nresolved torch")); - Ok(()) - } - - #[test] - fn plan_command_uses_background_job_when_provider_planner_is_enabled() { - let mut app = test_app(); - app.config.planner_provider = Some("local".to_owned()); - app.config - .save(&app.paths) - .expect("planner config should save"); - - assert!(app.handle_command("plan make my ROCm setup better")); - - assert!(matches!( - app.running_job.as_ref().map(|job| &job.kind), - Some(super::RunningJobKind::Planner { title, .. }) if title == "Plan" - )); - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - assert!( - app.command_screen - .as_ref() - .expect("plan should open") - .detail - .contains("Planning request") - ); - } - - #[test] - fn natural_serve_command_opens_plan_screen_without_transcript() { - let mut app = test_app(); - app.set_input("serve qwen with llama.cpp".to_owned()); - app.submit(); - - assert_eq!(app.mode, TuiMode::Serve); - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let plan = app - .command_screen - .as_ref() - .expect("plan screen should open") - .detail - .clone(); - assert!(plan.contains("Model: Qwen")); - assert!(plan.contains("Engine: llama.cpp")); - assert!(plan.contains("Run: review and start this action")); - assert!(!plan.contains("planner:")); - assert!(!plan.contains("next_tool_approval:")); - assert!(app.pending_approval.is_none()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Start the local model.")); - assert!(rendered.contains("Model: Qwen")); - assert!(rendered.contains("Engine: llama.cpp")); - assert!(rendered.contains("Address: http://")); - assert!(rendered.contains("First start may download model files")); - assert!(!rendered.contains("rocm serve")); - assert!(!rendered.contains("gpu_required")); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args.iter().any(|arg| arg == "--managed") - && args.iter().any(|arg| arg == "llama.cpp") - )); - assert!(app.running_job.is_none()); - } - - #[test] - fn plain_casual_inputs_open_friendly_screen_without_inspection_plan() { - for input in ["hi", "hello", "thanks", "ok", "good morning"] { - let mut app = test_app(); - app.set_input(input.to_owned()); - app.submit(); - - assert_eq!(app.mode, TuiMode::Ask, "{input}"); - assert!(app.command_screen.is_some(), "{input}"); - assert!(app.transcript.is_empty(), "{input}"); - assert!(app.pending_approval.is_none(), "{input}"); - assert!(app.running_job.is_none(), "{input}"); - let screen = app.command_screen.as_ref().expect("casual screen"); - assert_eq!(screen.title, "Hello", "{input}"); - assert!(screen.detail.contains("Common choices"), "{input}"); - assert!(screen.plan_request.is_none(), "{input}"); - assert!(screen.plan_action.is_none(), "{input}"); - assert!( - screen - .actions - .contains(&super::CommandScreenAction::OpenCommand("setup")), - "{input}" - ); - assert!( - screen - .actions - .contains(&super::CommandScreenAction::OpenCommand("help")), - "{input}" - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(!rendered.contains("Inspect local ROCm state"), "{input}"); - assert!(!screen.detail.contains("Run"), "{input}"); - } - } - - #[test] - fn unclear_plain_input_does_not_become_inspect_plan() { - let mut app = test_app(); - app.set_input("tell me something interesting".to_owned()); - app.submit(); - - assert_eq!(app.mode, TuiMode::Serve); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(!rendered.contains("Inspect local ROCm state")); - assert!(!super::serve_wizard_detail_text(&app).contains("Run")); - assert!(rendered.contains("I need a local model before I can chat")); - } - - #[test] - fn explicit_inspection_text_still_opens_read_only_plan() { - let mut app = test_app(); - app.set_input("what is installed here".to_owned()); - app.submit(); - - assert_eq!(app.mode, TuiMode::Ask); - assert!(app.command_screen.is_some()); - let screen = app.command_screen.as_ref().expect("inspection plan"); - assert!(screen.detail.contains("Inspect local ROCm state")); - assert!(screen.detail.contains("Run: review and start this action")); - assert!(matches!( - screen.plan_action.as_ref().map(|action| action.args.as_slice()), - Some(args) if args.len() == 1 && args[0] == "examine" - )); - } - - #[test] - fn plain_command_routing_distinguishes_commands_from_requests() { - for input in [ - "install", - "install sdk --channel release", - "install driver --dry-run", - "update", - "update --apply --runtime release-pip-gfx120x-all", - "uninstall", - "uninstall apply", - "serve tiny.gguf --engine llama.cpp", - "serve C:\\models\\tiny.gguf", - "services logs svc-qwen", - "runtimes activate release-pip-gfx120x-all", - ] { - assert!( - super::should_parse_plain_input_as_command(input), - "{input} should be treated as command-shaped input" - ); - } - - for input in [ - "install ROCm", - "update ROCm", - "uninstall ROCm", - "remove old ROCm", - "serve", - "serve Qwen with llama.cpp", - "serve Llama 3.1 8B with llama.cpp", - ] { - assert!( - !super::should_parse_plain_input_as_command(input), - "{input} should stay a plain-English request" - ); - } - } - - #[test] - fn plain_structured_commands_route_to_navigable_tui_before_plan() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - submit_prompt_command(&mut app, "install sdk --channel release"); - assert!(app.install_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - submit_prompt_command(&mut app, "update --apply --runtime release-pip-gfx120x-all"); - assert!(app.update_manager.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - assert!(!render_test_terminal(&app, 120, 24).contains("Plan")); - - let mut app = test_app(); - submit_prompt_command(&mut app, "uninstall apply"); - assert!(app.command_screen.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("Review:")); - - let mut app = test_app(); - submit_prompt_command(&mut app, "serve tiny.gguf --engine llama.cpp"); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn natural_serve_with_missing_model_does_not_focus_approval() { - let mut app = test_app(); - app.set_input("serve".to_owned()); - app.submit(); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - assert!( - app.command_screen - .as_ref() - .expect("plan screen should open") - .detail - .contains("Needs more detail") - ); - assert!(app.pending_approval.is_none()); - assert!(app.status.contains("Plan needs more detail")); - } - - #[test] - fn natural_serve_command_accepts_multi_word_model_name() { - let mut app = test_app(); - app.set_input("serve Llama 3.1 8B with llama.cpp".to_owned()); - app.submit(); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let plan = &app.command_screen.as_ref().expect("plan screen").detail; - assert!(plan.contains("Model: Llama 3.1 8B")); - assert!(plan.contains("Engine: llama.cpp")); - } - - #[test] - fn slash_serve_accepts_quoted_gguf_and_engine() { - let mut app = test_app(); - assert!(app.handle_command(r#"serve "tiny model.gguf" --engine llama.cpp"#)); - - let state = app.serve_wizard.as_ref().expect("serve wizard"); - assert_eq!(state.model_source, super::ServeModelSource::LocalPath); - assert_eq!(state.local_model_path, "tiny model.gguf"); - assert_eq!(super::selected_serve_wizard_engine(state), "llama.cpp"); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("tiny model.gguf")); - assert!(rendered.contains("llama.cpp")); - assert!(!rendered.contains("command:")); - assert!(!rendered.contains("device_policy")); - } - - #[test] - fn slash_serve_accepts_unquoted_multi_word_model_name() { - let mut app = test_app(); - assert!(app.handle_command("serve Llama 3.1 8B --engine llama.cpp")); - - let state = app.serve_wizard.as_ref().expect("serve wizard"); - assert_eq!(state.model_source, super::ServeModelSource::LocalPath); - assert_eq!(state.local_model_path, "Llama 3.1 8B"); - assert_eq!(super::selected_serve_wizard_engine(state), "llama.cpp"); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 28).contains("Llama 3.1 8B")); - } - - #[test] - fn slash_serve_rejects_cpu_device_without_transcript_dump() { - let mut app = test_app(); - assert!(app.handle_command("serve tiny.gguf --engine llama.cpp --device cpu")); - - assert!(app.command_screen.is_some()); - assert!(app.transcript.is_empty()); - let detail = app - .command_screen - .as_ref() - .expect("serve error screen") - .detail - .clone(); - assert!(detail.contains("ROCm GPU required")); - assert!(detail.contains("guided GPU setup")); - assert!(!detail.contains("Usage: /serve")); - } - - #[test] - fn serve_without_args_opens_navigable_wizard() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Local AI")); - assert!(rendered.contains("Start a local AI model")); - assert!(rendered.contains("Start")); - assert!(rendered.contains("GPU required")); - } - - #[test] - fn serve_wizard_arrow_keys_change_rows_and_values() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - let initial = app.serve_wizard.as_ref().expect("serve wizard").clone(); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.serve_wizard.as_ref().map(|state| state.selected), - Some(super::serve_wizard_choice_index( - super::ServeWizardChoice::Engine - )) - ); - - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - assert_ne!( - app.serve_wizard.as_ref().map(|state| state.engine_index), - Some(initial.engine_index) - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - assert_eq!( - app.serve_wizard.as_ref().map(|state| state.device_index), - Some(initial.device_index) - ); - assert_eq!( - app.serve_wizard - .as_ref() - .map(super::selected_serve_wizard_device), - Some("gpu_required") - ); - assert!(app.transcript.is_empty()); - } - - #[test] - fn serve_wizard_recommended_model_rows_are_informational() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Model")); - let model_row = super::serve_wizard_row_label(&app, super::ServeWizardChoice::Model); - assert!(model_row.contains("fixed")); - assert_eq!( - app.serve_wizard - .as_ref() - .and_then(super::selected_serve_wizard_recipe) - .map(|recipe| recipe.canonical_model_id.as_str()), - Some(super::VALIDATED_LOCAL_ASSISTANT_MODEL) - ); - assert!( - super::serve_wizard_row_label(&app, super::ServeWizardChoice::LocalPath) - .contains("custom model only") - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_serve_wizard_choice(), - super::ServeWizardChoice::Engine - ); - - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!( - app.selected_serve_wizard_choice(), - super::ServeWizardChoice::Source - ); - } - - #[test] - fn serve_wizard_start_requests_approval_without_launching() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-7-14-0", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("serve")); - set_serve_wizard_engine(&mut app, "pytorch"); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.serve_wizard.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args.first().map(String::as_str) == Some("serve") - && args.iter().any(|arg| arg == "--managed") - && args.iter().any(|arg| arg == "--device") - && args.iter().any(|arg| arg == "gpu_required") - && args.windows(2).any(|pair| pair == ["--runtime-id", "release-pip-gfx120x-all-7-14-0"]) - )); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Start the local model.")); - assert!(rendered.contains("ROCm install: release-pip-gfx120x-all-7-14-0")); - assert!(rendered.contains("First start may download model files")); - assert!(!rendered.contains("rocm serve")); - Ok(()) - } - - #[test] - fn serve_wizard_start_recovers_setup_runtime_registry_from_local_manifest() -> anyhow::Result<()> - { - let mut app = test_app(); - let runtime_key = "release-pip-gfx120x-all-local-manifest"; - write_test_runtime_with_key(&app.paths, runtime_key, "therock-release:gfx120X-all", 20)?; - let install_root = app - .paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - app.config.setup.therock_venv = Some(install_root); - app.config.save(&app.paths)?; - app.rebase_paths_to_saved_setup_folder(); - let registry_dir = app.paths.data_dir.join("runtimes").join("registry"); - let _ = fs::remove_dir_all(®istry_dir); - - assert!(super::setup_venv_ready(&app.paths, &app.config)); - assert!(super::setup_runtime_ready_for_sidebar( - &app.paths, - &app.config - )); - assert!(app.handle_command("serve")); - set_serve_wizard_engine(&mut app, "pytorch"); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!( - registry_dir.join(format!("{runtime_key}.json")).is_file(), - "status: {}; serve: {}; install: {}; pending: {}", - app.status, - app.serve_wizard.is_some(), - app.install_manager.is_some(), - app.pending_approval.is_some() - ); - assert!(app.serve_wizard.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.install_manager.is_none()); - assert!(app.running_job.is_none()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args.windows(2).any(|pair| pair == ["--runtime-id", runtime_key]) - )); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("ROCm install: release-pip-gfx120x-all-local-manifest")); - assert!(!rendered.contains("Set up ROCm first")); - assert!(!rendered.contains("Serve failed")); - Ok(()) - } - - #[test] - fn serve_wizard_default_lemonade_starts_without_therock_runtime() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.serve_wizard.is_some()); - assert!(app.install_manager.is_none()); - assert!(app.pending_approval.is_some()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args.windows(2).any(|pair| pair == ["--engine", "lemonade"]) - && !args.iter().any(|arg| arg == "--runtime-id") - )); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Lemonade manages its own ROCm runtime")); - assert!(!rendered.contains("Set up ROCm first")); - assert!(!rendered.contains("Serve failed")); - } - - #[test] - fn serve_wizard_explicit_pytorch_without_runtime_opens_install_setup_path() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - set_serve_wizard_engine(&mut app, "pytorch"); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.install_manager.is_some()); - assert!(app.serve_wizard.is_none()); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Set up ROCm before starting Local AI."); - let rendered = render_test_terminal(&app, 140, 32); - assert!(rendered.contains("Install ROCm")); - assert!(rendered.contains("Set up ROCm first")); - assert!(rendered.contains("Local AI needs a usable ROCm install")); - assert!(!rendered.contains("Serve failed")); - } - - #[test] - fn serve_wizard_approval_n_stays_on_wizard_without_transcript_dump() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_some()); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - - assert!(app.serve_wizard.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.status, - "Serve cancelled. Choose Start when you are ready." - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Start")); - assert!(!rendered.contains("[Approval]")); - assert!(!rendered.contains("cancelled: Serve")); - } - - #[test] - fn serve_wizard_approval_esc_returns_to_main_menu() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.pending_approval.is_some()); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.serve_wizard.is_none()); - assert!(app.should_draw_home_dashboard()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Local AI on your AMD GPU")); - assert!(!rendered.contains("Prompt")); - } - - #[test] - fn serve_wizard_lemonade_does_not_require_therock_runtime() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - { - let engines = super::serve_wizard_engine_names(); - let lemonade_index = engines - .iter() - .position(|engine| engine == "lemonade") - .expect("lemonade engine option"); - let state = app.serve_wizard.as_mut().expect("serve wizard"); - state.engine_index = lemonade_index; - } - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.serve_wizard.is_some()); - assert!(app.install_manager.is_none()); - assert!(app.pending_approval.is_some()); - assert!(app.running_job.is_none()); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args.windows(2).any(|pair| pair == ["--engine", "lemonade"]) - && !args.iter().any(|arg| arg == "--runtime-id") - )); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Lemonade manages its own ROCm runtime")); - assert!(!rendered.contains("Set up ROCm first")); - assert!(!rendered.contains("Local AI needs a usable ROCm install")); - } - - #[test] - fn serve_wizard_arrow_keys_do_not_start_or_close_on_action_rows() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - - assert!(app.serve_wizard.is_some()); - assert!(app.pending_approval.is_none()); - assert_eq!(app.status, "Use Enter to select this row."); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Left, KeyModifiers::NONE)); - - assert!(app.serve_wizard.is_some()); - assert!(app.pending_approval.is_none()); - assert_eq!(app.status, "Use Enter to select this row."); - } - - #[test] - fn serve_wizard_does_not_offer_cpu_only_device_choice() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Device); - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - - let state = app.serve_wizard.as_ref().expect("serve wizard"); - assert_eq!(super::selected_serve_wizard_device(state), "gpu_required"); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("GPU required")); - assert!(!rendered.contains("CPU only")); - } - - #[test] - fn serve_wizard_local_model_path_and_port_build_approval() -> anyhow::Result<()> { - let mut app = test_app(); - let model_path = app.paths.data_dir.join("tiny model.gguf"); - fs::create_dir_all(&app.paths.data_dir)?; - fs::write(&model_path, "gguf")?; - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-local-model", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("serve")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::LocalPath); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.serve_wizard_editing_text()); - app.set_input(model_path.display().to_string()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.serve_wizard_editing_text()); - assert_eq!( - app.serve_wizard - .as_ref() - .map(super::selected_serve_wizard_engine) - .as_deref(), - Some("llama.cpp") - ); - - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Port); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - app.set_input("11436".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let actual_args = match app.pending_approval.as_ref().map(|pending| &pending.action) { - Some(super::ApprovalAction::CliCommand { args, .. }) => args.clone(), - other => panic!("unexpected pending approval: {other:?}"), - }; - assert_eq!( - actual_args, - vec![ - "serve".to_owned(), - model_path.display().to_string(), - "--engine".to_owned(), - "llama.cpp".to_owned(), - "--device".to_owned(), - "gpu_required".to_owned(), - "--runtime-id".to_owned(), - "release-pip-gfx120x-all-local-model".to_owned(), - "--port".to_owned(), - "11436".to_owned(), - "--managed".to_owned(), - ] - ); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Start local model")); - assert!( - app.serve_wizard - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains(&model_path.display().to_string())) - ); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(modal.contains("tiny model.gguf")); - assert!(modal.contains("Address: http://127.0.0.1:11436")); - assert!(modal.contains("Start the local model.")); - assert!(modal.contains("First start may download model files")); - assert!(!rendered.contains("rocm serve")); - Ok(()) - } - - #[test] - fn serve_wizard_local_model_requires_existing_path_without_transcript() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.serve_wizard.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Choose a local model file or folder first.")); - assert!(!rendered.contains("Usage: /serve")); - } - - #[test] - fn serve_wizard_local_model_rejects_missing_typed_path() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::LocalPath); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - app.set_input(r"C:\missing\model.gguf".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.serve_wizard_editing_text()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("That model file or folder was not found.")); - assert!(!rendered.contains("Usage: /serve")); - } - - #[test] - fn serve_wizard_host_rejects_public_bind_in_screen() { - let mut app = test_app(); - assert!(app.handle_command("serve")); - - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Host); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - app.set_input("0.0.0.0".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.serve_wizard_editing_text()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("The wizard only serves on this computer")); - assert!(!rendered.contains("Usage: /serve")); - } - - #[test] - fn serve_wizard_host_accepts_localhost_and_shows_address_in_review() { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-localhost", - "therock-release:gfx120X-all", - 20, - ) - .expect("ready runtime fixture should be written"); - assert!(app.handle_command("serve")); - - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Host); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - app.set_input("localhost".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.serve_wizard_editing_text()); - move_serve_wizard_to_choice(&mut app, super::ServeWizardChoice::Review); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Start local model")); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(modal.contains("Address: http://localhost:11435")); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args.windows(2).any(|pair| pair == ["--host", "localhost"]) - )); - } - - #[test] - fn typed_serve_prefills_wizard_and_start_requests_approval_without_prompt_editing() - -> anyhow::Result<()> { - let mut app = test_app(); - let model_path = app.paths.data_dir.join("typed.gguf"); - fs::create_dir_all(&app.paths.data_dir)?; - fs::write(&model_path, "gguf")?; - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-typed", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command(&format!( - "serve {} --engine llama.cpp", - model_path.display() - ))); - - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 28).contains("> Start")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_some()); - assert!(app.serve_wizard.is_some()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Review:")); - assert!( - app.serve_wizard - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains(&model_path.display().to_string())) - ); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(modal.contains("typed.gguf")); - assert!(modal.contains("Engine: llama.cpp")); - assert!(modal.contains("Start the local model.")); - assert!(!rendered.contains("rocm serve")); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "serve".to_owned(), - model_path.display().to_string(), - "--engine".to_owned(), - "llama.cpp".to_owned(), - "--device".to_owned(), - "gpu_required".to_owned(), - "--runtime-id".to_owned(), - "release-pip-gfx120x-all-typed".to_owned(), - "--managed".to_owned(), - ] - )); - Ok(()) - } - - #[test] - fn explicit_foreground_serve_prefills_wizard_without_launching() -> anyhow::Result<()> { - let mut app = test_app(); - let model_path = app.paths.data_dir.join("foreground.gguf"); - fs::create_dir_all(&app.paths.data_dir)?; - fs::write(&model_path, "gguf")?; - assert!(app.handle_command(&format!( - "serve {} --engine llama.cpp --foreground", - model_path.display() - ))); - - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.serve_wizard.as_ref().map(|state| state.managed), - Some(false) - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.serve_wizard.is_some()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("This mode only shows the plan")); - assert!(rendered.contains("show command only")); - Ok(()) - } - - #[test] - fn approval_status_says_enter_or_y_approve() { - let mut app = test_app(); - app.request_cli_approval("Serve", vec!["version".to_owned()], "test approval"); - - assert!(app.status.contains("Enter or Y")); - } - - #[test] - fn cancelled_non_chat_approval_returns_home_without_prompt_box() { - let mut app = test_app(); - app.open_command_screen_target(super::CommandScreenTarget::Gpu); - app.request_cli_approval("Serve", vec!["version".to_owned()], "test approval"); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.command_screen.is_none()); - assert!(app.should_draw_home_dashboard()); - assert!(!super::should_draw_prompt_box(&app)); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("ROCm CLI")); - assert!(!rendered.contains("Prompt")); - assert!(app.status.contains("Back at the main menu")); - } - - #[test] - fn approval_card_is_arrow_key_navigable_and_scrollable() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("install")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_some()); - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.selected), - Some(0) - ); - let detail = super::install_manager_detail_text(&app); - assert!(!detail.contains("Review this change")); - assert!(!detail.contains("Enter or Y: approve")); - assert!(detail.contains("Install ROCm")); - let rendered = render_test_terminal(&app, 160, 28); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(rendered.contains("Review: Install")); - assert!(modal.contains("Enter or Y: approve")); - - handle_key(&mut app, key_event(KeyCode::PageDown, KeyModifiers::NONE)); - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.detail_scroll), - Some(10) - ); - assert_eq!( - app.install_manager - .as_ref() - .map(|state| state.detail_scroll), - Some(0) - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.selected), - Some(1) - ); - let modal = super::render_modal_approval(&app, app.pending_approval.as_ref().unwrap()); - assert!(modal.contains("Esc or N: cancel")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_none()); - assert!(app.install_manager.is_some()); - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Approval cancelled."); - Ok(()) - } - - #[test] - fn approval_is_preserved_while_command_is_running() { - let mut app = test_app(); - let (_sender, receiver) = mpsc::channel::(); - app.running_job = Some(super::RunningJob { - title: "Config".to_owned(), - kind: super::RunningJobKind::Cli, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - app.pending_approval = Some(super::PendingApproval { - title: "Install".to_owned(), - selected: 0, - detail_scroll: 0, - action: super::ApprovalAction::CliCommand { - title: "Install".to_owned(), - args: vec!["install".to_owned(), "sdk".to_owned()], - display_command: None, - explanation: None, - }, - }); - - app.approve_focused_action(); - - assert!(app.pending_approval.is_some()); - assert!(app.status.contains("approval is still waiting")); - } - - #[test] - fn proposal_job_completion_updates_status() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-complete")?; - update_automation_proposal_status(&app.paths, "proposal-complete", "approved")?; - - app.complete_running_job( - "Review".to_owned(), - super::RunningJobKind::Proposal { - proposal_id: "proposal-complete".to_owned(), - }, - Ok(super::CommandOutput { - ok: true, - rendered: "status: ok".to_owned(), - chat_approval: None, - }), - super::StreamedOutputCounts::default(), - ); - - let proposal = find_automation_proposal(&app.paths, "proposal-complete")?; - assert_eq!(proposal.status, "completed"); - assert!( - app.transcript - .iter() - .any(|line| line.contains("review request: proposal-complete")) - ); - Ok(()) - } - - #[test] - fn model_command_with_engine_prefills_serve_wizard() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all-model-command", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("model qwen with pytorch")); - assert!(app.serve_wizard.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.model_picker.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.serve_wizard - .as_ref() - .map(super::selected_serve_wizard_engine) - .as_deref(), - Some("pytorch") - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("> Start")); - assert!(app.pending_approval.is_none()); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args.first().map(String::as_str) == Some("serve") - && args.windows(2).any(|pair| pair == ["--runtime-id", "release-pip-gfx120x-all-model-command"]) - )); - Ok(()) - } - - #[test] - fn model_command_with_known_alias_focuses_picker() { - let mut app = test_app(); - - assert!(app.handle_command("model qwen")); - - assert!(app.model_picker.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.serve_wizard.is_none()); - assert!(app.transcript.is_empty()); - let selected = app.selected_model_recipe().expect("qwen model selected"); - assert_eq!(selected.canonical_model_id, "Qwen3-4B-Instruct-2507-GGUF"); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Qwen3-4B-Instruct-2507-GGUF")); - assert!(rendered.contains("Enter: open local model setup")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.command_screen.is_none()); - assert!(app.serve_wizard.is_some()); - assert!(app.model_picker.is_none()); - assert!(app.transcript.is_empty()); - let wizard = app.serve_wizard.as_ref().expect("serve wizard"); - assert_eq!( - super::selected_serve_wizard_recipe(wizard) - .expect("selected serve recipe") - .canonical_model_id, - "Qwen3-4B-Instruct-2507-GGUF" - ); - } - - #[test] - fn model_command_opens_selectable_recipe_picker_and_enter_plans() { - let mut app = test_app(); - app.gpu_telemetry.monitor_cards.insert( - 0, - GpuMonitorCard { - power_watts: None, - max_power_watts: None, - hotspot_celsius: None, - memory_celsius: None, - gfx_mhz: None, - gfx_percent: None, - mem_percent: None, - mem_mhz: None, - vram_used_mb: None, - vram_total_mb: Some(16_384.0), - vram_percent: None, - }, - ); - - assert!(app.handle_command("model")); - - assert!(app.model_picker.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Models")); - assert!(rendered.contains("Enter: open local model setup")); - assert!(rendered.contains("16 GB GPU memory available")); - assert!(!rendered.contains("tiny-gpt2")); - assert!(!rendered.contains("CPU only")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.command_screen.is_none()); - assert!(app.serve_wizard.is_some()); - assert!(app.model_picker.is_none()); - assert!(app.transcript.is_empty()); - let detail = super::serve_wizard_detail_text(&app); - assert!(detail.contains("Loaded your serve request")); - assert!(!detail.contains("intent:")); - assert!(!detail.contains("next_tool_approval:")); - } - - #[test] - fn logs_command_renders_service_logs() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - let mut record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "pytorch", - "qwen", - "Qwen/Qwen3.5", - "127.0.0.1", - 11435, - "managed", - 123, - None, - None, - None, - ); - record.status = "ready".to_owned(); - record.write()?; - fs::write(&record.log_path, "service ready\n")?; - - assert!(app.handle_command("logs svc-qwen")); - - assert!(app.overlay_card.is_some()); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Service Logs") - ); - assert!(app.logs_view.is_none()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("service ready")); - Ok(()) - } - - #[test] - fn logs_screen_searches_and_clears_with_arrow_menu() -> anyhow::Result<()> { - let mut app = test_app(); - let logs_dir = app.paths.data_dir.join("logs"); - fs::create_dir_all(&logs_dir)?; - fs::write( - logs_dir.join("cli-lifecycle.log"), - "1 level=info category=test action=alpha message=alpha ready\n2 level=info category=test action=beta message=beta ready\n", - )?; - assert!(app.handle_command("logs")); - - for _ in 0..super::logs_view_actions().len() { - if app.selected_logs_view_action() == super::LogsViewAction::Search { - break; - } - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - assert_eq!( - app.selected_logs_view_action(), - super::LogsViewAction::Search - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.logs_view_editing_text()); - app.set_input("beta".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(!app.logs_view_editing_text()); - assert_eq!( - app.logs_view - .as_ref() - .and_then(|state| state.query.as_deref()), - Some("beta") - ); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("beta ready")); - assert!(!rendered.contains("alpha ready")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_logs_view_action(), - super::LogsViewAction::ClearSearch - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert_eq!( - app.logs_view - .as_ref() - .and_then(|state| state.query.as_deref()), - None - ); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("alpha ready")); - assert!(rendered.contains("beta ready")); - Ok(()) - } - - #[test] - fn install_command_requires_approval_before_execution() -> anyhow::Result<()> { - let mut app = test_app(); - fs::create_dir_all(app.paths.data_dir.join("envs"))?; - assert!(app.handle_command("install sdk --channel release")); - - assert!(app.pending_approval.is_none()); - assert!(app.install_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("ROCm Install")); - assert!(rendered.contains("Install ROCm")); - assert!(rendered.contains("Channel:")); - assert!(!rendered.contains("Advanced archive install")); - assert!(!rendered.contains("Review this change")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.pending_approval.is_some()); - assert!(app.install_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Install ROCm into a Python folder")); - assert!(!rendered.contains("/install sdk --channel release")); - Ok(()) - } - - #[test] - fn install_folder_row_shows_selected_path() -> anyhow::Result<()> { - let mut app = test_app(); - let folder = app - .paths - .data_dir - .join("very-long-rocm-install-folder-that-should-stay-readable-in-details") - .join("nested") - .join("therock_venvs"); - let folder_text = folder.display().to_string(); - - app.open_install_manager(); - app.open_install_sdk_form_from_args(&[ - "sdk", - "--channel", - "release", - "--format", - "wheel", - "--prefix", - folder_text.as_str(), - ]); - - let row_label = super::install_sdk_choice_label( - super::InstallSdkChoice::Folder, - super::InstallSdkChannel::Release, - &folder_text, - ); - assert_eq!( - row_label, - format!( - "Install folder: {}", - super::display_runtime_folder_path(&folder) - ) - ); - assert!(row_label.contains(&folder_text)); - let rendered = render_test_terminal(&app, 220, 28); - assert!(rendered.contains("Install folder:"), "{rendered}"); - assert!(!rendered.contains("Install folder: selected"), "{rendered}"); - let details = super::install_manager_detail_text(&app); - assert!(details.contains("Selected folder"), "{details}"); - assert!(details.contains(&folder_text), "{details}"); - assert!(details.contains("Downloaded files"), "{details}"); - assert!( - details.contains(&folder.join("pip-cache").display().to_string()), - "{details}" - ); - Ok(()) - } - - #[test] - fn install_sdk_channel_row_uses_neutral_label_for_all_channels() { - assert_eq!( - super::install_sdk_choice_label( - super::InstallSdkChoice::Channel, - super::InstallSdkChannel::Release, - "" - ), - format!("{:<14} {}", "Channel:", "Stable (default)") - ); - assert_eq!( - super::install_sdk_choice_label( - super::InstallSdkChoice::Channel, - super::InstallSdkChannel::Nightly, - "" - ), - format!("{:<14} {}", "Channel:", "Nightly (preview)") - ); - } - - #[test] - fn install_sdk_channel_toggle_does_not_report_success_outside_sdk_screen() { - let mut app = test_app(); - app.status = "unchanged".to_owned(); - - app.toggle_install_sdk_channel(); - - assert_eq!(app.status, "unchanged"); - } - - #[test] - fn install_sdk_bad_args_stay_on_guided_screen_without_approval() { - for command in [ - "install sdk --channel", - "install sdk --channel preview", - "install sdk --unknown", - ] { - let mut app = test_app(); - - assert!(app.handle_command(command), "{command}"); - - assert!(app.install_manager.is_some(), "{command}"); - assert!(matches!( - app.install_manager.as_ref().map(|state| &state.screen), - Some(super::InstallManagerScreen::Sdk { .. }) - )); - assert!(app.pending_approval.is_none(), "{command}"); - assert!(app.running_job.is_none(), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - let rendered = render_test_terminal(&app, 120, 28); - assert!( - rendered.contains("Choose") - || rendered.contains("Use the recommended") - || rendered.contains("I do not recognize"), - "{command}\n{rendered}" - ); - assert!(!rendered.contains("Review this change"), "{command}"); - } - } - - #[test] - fn install_sdk_form_is_arrow_key_editable_and_requests_approval() { - let mut app = test_app(); - fs::create_dir_all(&app.paths.data_dir).expect("test data dir"); - assert!(app.handle_command("install sdk --channel nightly --format tarball")); - - assert!(app.install_manager.is_some()); - assert!(app.pending_approval.is_none()); - let rendered = render_test_terminal(&app, 140, 28); - assert!(rendered.contains("nightly")); - assert!(rendered.contains("Advanced archive install")); - - handle_key(&mut app, key_event(KeyCode::Up, KeyModifiers::NONE)); - assert_eq!( - app.selected_install_sdk_choice(), - super::InstallSdkChoice::Folder - ); - handle_key(&mut app, key_event(KeyCode::Char('t'), KeyModifiers::NONE)); - assert!(app.install_manager_editing_text()); - app.set_input(app.paths.data_dir.join("custom-rocm").display().to_string()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(!app.install_manager_editing_text()); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - assert_eq!( - app.selected_install_sdk_choice(), - super::InstallSdkChoice::Install - ); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "install".to_owned(), - "sdk".to_owned(), - "--channel".to_owned(), - "nightly".to_owned(), - "--format".to_owned(), - "tarball".to_owned(), - "--prefix".to_owned(), - app.paths.data_dir.join("custom-rocm").display().to_string(), - ] - )); - assert!(app.transcript.is_empty()); - } - - #[test] - fn install_driver_approval_helpers_match_cli_plan_semantics() -> anyhow::Result<()> { - assert_eq!( - super::approved_install_args(&["driver", "--dkms"]), - vec!["driver".to_owned(), "--dkms".to_owned(), "--yes".to_owned()] - ); - assert_eq!( - super::approved_install_args(&["driver", "--dkms", "--dry-run"]), - vec![ - "driver".to_owned(), - "--dkms".to_owned(), - "--dry-run".to_owned() - ] - ); - assert_eq!( - super::approved_install_args(&["driver", "--dkms", "--preview"]), - vec![ - "driver".to_owned(), - "--dkms".to_owned(), - "--dry-run".to_owned() - ] - ); - assert_eq!( - super::approved_install_args(&["driver", "--dkms", "--yes"]), - vec!["driver".to_owned(), "--dkms".to_owned(), "--yes".to_owned()] - ); - assert_eq!( - super::approved_install_args(&["driver", "--reconcile"]), - vec!["driver".to_owned(), "--reconcile".to_owned()] - ); - assert!(!super::install_args_require_approval(&[ - "driver", - "--dry-run" - ])?); - assert!(!super::install_args_require_approval(&[ - "driver", - "--preview" - ])?); - assert!(!super::install_args_require_approval(&["driver"])?); - assert!(!super::install_args_require_approval(&[ - "driver", - "--reconcile" - ])?); - Ok(()) - } - - #[test] - fn update_apply_requests_approval_before_execution() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - assert!(app.handle_command("update --apply --runtime release-pip-gfx120x-all --activate")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "update".to_owned(), - "--apply".to_owned(), - "--runtime".to_owned(), - "release-pip-gfx120x-all".to_owned(), - "--activate".to_owned(), - ] - )); - assert!(app.update_manager.is_some()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("Review:")); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - display_command, - .. - }) if display_command.as_deref() - == Some("/update --apply --runtime release-pip-gfx120x-all --activate") - )); - Ok(()) - } - - #[test] - fn update_apply_rejects_unknown_runtime_before_approval() { - let mut app = test_app(); - - assert!(app.handle_command("update --apply --runtime missing-runtime --activate")); - - assert!(app.update_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!( - rendered.contains("I could not find ROCm install") - && rendered.contains("`missing-runtime`"), - "{rendered}" - ); - assert!(!rendered.contains("Review this change")); - } - - #[test] - fn update_apply_runtime_id_resolves_to_exact_runtime_key() -> anyhow::Result<()> { - let mut app = test_app(); - write_test_runtime_with_key( - &app.paths, - "release-pip-gfx120x-all", - "therock-release:gfx120X-all", - 20, - )?; - - assert!(app.handle_command("update --apply --runtime therock-release:gfx120X-all")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec![ - "update".to_owned(), - "--apply".to_owned(), - "--runtime".to_owned(), - "release-pip-gfx120x-all".to_owned(), - ] - )); - assert!(app.update_manager.is_some()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn update_without_args_opens_navigable_manager_without_dump() { - let mut app = test_app(); - assert!(app.handle_command("update")); - - assert!(app.pending_approval.is_none()); - assert!(app.running_job.is_none()); - assert!(app.update_manager.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Updates")); - assert!(rendered.contains("Update status")); - assert!(rendered.contains("> Check for updates")); - assert!(rendered.contains("Preview update")); - assert!(rendered.contains("Up/Down choose")); - assert!(!rendered.contains("update_available=")); - } - - #[test] - fn plain_update_report_detects_status_update_available() { - let report = super::plain_update_report( - "update\n runtime release-pip-gfx120x-all status=update_available installed=7.13.0 latest=7.14.0\n runtimes: status=checked_above\n", - ); - - assert!(report.contains("ROCm installs: update available")); - assert!(report.contains("Preview the update before applying it.")); - assert!(!report.contains("status=update_available")); - } - - #[test] - fn update_manager_arrow_keys_change_selected_action() { - let mut app = test_app(); - assert!(app.handle_command("update")); - assert_eq!( - app.selected_update_menu_choice(), - super::UpdateMenuChoice::Refresh - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - - assert_eq!( - app.selected_update_menu_choice(), - super::UpdateMenuChoice::Preview - ); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("> Preview update")); - assert!(rendered.contains("without changing files")); - } - - #[test] - fn update_manager_apply_requests_screen_approval_without_transcript() { - let mut app = test_app(); - assert!(app.handle_command("update")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { args, .. }) - if args == &vec!["update".to_owned(), "--apply".to_owned()] - )); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Review:")); - assert!(rendered.contains("Apply ROCm update")); - } - - #[test] - fn update_apply_preview_parses_as_inline_command() -> anyhow::Result<()> { - let plan = - super::parse_update_command_args(&["--apply", "--runtime", "runtime-a", "--preview"])?; - - assert_eq!( - plan, - super::UpdateCommandPlan::DryRun(vec![ - "update".to_owned(), - "--apply".to_owned(), - "--runtime".to_owned(), - "runtime-a".to_owned(), - "--dry-run".to_owned(), - ]) - ); - Ok(()) - } - - #[test] - fn update_without_apply_rejects_mutating_options() { - let mut app = test_app(); - assert!(app.handle_command("update --activate")); - - assert!(app.pending_approval.is_none()); - assert!(app.update_manager.is_some()); - assert!(app.transcript.is_empty()); - assert!( - app.update_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains("update request")) - ); - } - - #[test] - fn update_apply_rejects_missing_runtime_value_and_unknown_options() { - let mut app = test_app(); - assert!(app.handle_command("update --apply --runtime")); - assert!(app.pending_approval.is_none()); - assert!(app.update_manager.is_some()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 24).contains("Choose a ROCm install")); - - let mut app = test_app(); - assert!(app.handle_command("update --apply --bogus")); - assert!(app.pending_approval.is_none()); - assert!(app.update_manager.is_some()); - assert!(app.transcript.is_empty()); - assert!( - render_test_terminal(&app, 120, 24).contains("I do not recognize that update option") - ); - } - - #[test] - fn update_completion_suggests_apply_and_runtime_keys() -> anyhow::Result<()> { - let (root, paths) = test_paths("update-completion-runtime"); - write_test_runtime(&paths, "therock-release:gfx120X-all", 1)?; - - let top = super::slash_argument_completion_menu("/update ", &paths, 0) - .expect("update should complete --apply"); - assert!(top.items.contains(&"--apply".to_owned())); - - let runtime = - super::slash_argument_completion_menu("/update --apply --runtime ", &paths, 0) - .expect("update runtime should complete runtime keys"); - assert!( - runtime - .items - .contains(&"therock-release-gfx120X-all".to_owned()) - ); - let options = super::slash_argument_completion_menu( - "/update --apply --runtime therock-release-gfx120X-all ", - &paths, - 0, - ) - .expect("update should complete remaining options"); - assert!(!options.items.contains(&"--apply".to_owned())); - assert!(!options.items.contains(&"--runtime".to_owned())); - assert!(options.items.contains(&"--preview".to_owned())); - assert!(!options.items.contains(&"--dry-run".to_owned())); - assert!(options.items.contains(&"--activate".to_owned())); - fs::remove_dir_all(root).ok(); - Ok(()) - } - - #[test] - fn automations_command_opens_navigable_review_inbox_without_transcript() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-1")?; - - assert!(app.handle_command("automations")); - - assert!(app.automations_manager.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.selected_automation_proposal() - .map(|proposal| proposal.proposal_id), - Some("proposal-1".to_owned()) - ); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Automations")); - assert!(rendered.contains("Review this request")); - Ok(()) - } - - #[test] - fn automations_manager_enter_focuses_selected_review() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-1")?; - assert!(app.handle_command("automations")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::Proposal { proposal_id }) - if proposal_id == "proposal-1" - )); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_manager_arrows_change_selected_review() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-a")?; - append_test_proposal(&app.paths, "proposal-b")?; - assert!(app.handle_command("automations")); - - let first = app - .selected_automation_proposal() - .map(|proposal| proposal.proposal_id) - .expect("selected proposal"); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - let second = app - .selected_automation_proposal() - .map(|proposal| proposal.proposal_id) - .expect("selected proposal"); - - assert_ne!(first, second); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_manager_reject_shortcut_opens_review_before_rejecting() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-reject")?; - assert!(app.handle_command("automations")); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - - let proposal = find_automation_proposal(&app.paths, "proposal-reject")?; - assert_eq!(proposal.status, "pending"); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::Proposal { proposal_id }) - if proposal_id == "proposal-reject" - )); - assert!(app.automations_manager.is_some()); - assert!(app.transcript.is_empty()); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - - let proposal = find_automation_proposal(&app.paths, "proposal-reject")?; - assert_eq!(proposal.status, "rejected"); - assert!(app.automations_manager.is_some()); - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Review request rejected."); - Ok(()) - } - - #[test] - fn approve_and_reject_commands_open_review_before_mutating() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-approve-command")?; - - assert!(app.handle_command("approve proposal-approve-command")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::Proposal { proposal_id }) - if proposal_id == "proposal-approve-command" - )); - assert_eq!( - find_automation_proposal(&app.paths, "proposal-approve-command")?.status, - "pending" - ); - assert!(app.transcript.is_empty()); - - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-reject-command")?; - - assert!(app.handle_command("reject proposal-reject-command")); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::Proposal { proposal_id }) - if proposal_id == "proposal-reject-command" - )); - assert_eq!( - find_automation_proposal(&app.paths, "proposal-reject-command")?.status, - "pending" - ); - assert!(app.transcript.is_empty()); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - assert_eq!( - find_automation_proposal(&app.paths, "proposal-reject-command")?.status, - "rejected" - ); - Ok(()) - } - - #[test] - fn typed_approval_commands_select_open_confirmation_without_mutating() -> anyhow::Result<()> { - for (command, selected, status) in [ - ("approve", 0, "Approve selected. Press Enter to continue."), - ("reject", 1, "Reject selected. Press Enter to reject."), - ( - "automations approve", - 0, - "Approve selected. Press Enter to continue.", - ), - ( - "automations reject", - 1, - "Reject selected. Press Enter to reject.", - ), - ] { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-open-approval")?; - assert!(app.handle_command("reviews proposal-open-approval")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::Proposal { proposal_id }) - if proposal_id == "proposal-open-approval" - )); - - assert!(app.handle_command(command), "{command}"); - - assert_eq!( - app.pending_approval - .as_ref() - .map(|pending| pending.selected), - Some(selected), - "{command}" - ); - assert_eq!(app.status, status, "{command}"); - assert_eq!( - find_automation_proposal(&app.paths, "proposal-open-approval")?.status, - "pending", - "{command}" - ); - assert!(app.running_job.is_none(), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - } - Ok(()) - } - - #[test] - fn edit_command_opens_review_surface_without_usage_dump() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-edit-command")?; - - assert!(app.handle_command("edit")); - - assert!(app.automations_manager.is_some()); - assert!(app.pending_approval.is_some()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 28).contains("Review this request")); - assert!(!render_test_terminal(&app, 120, 28).contains("usage:")); - - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-edit-id")?; - - assert!(app.handle_command("edit proposal-edit-id")); - - assert!(app.automations_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert_eq!( - app.selected_automation_proposal() - .map(|proposal| proposal.proposal_id), - Some("proposal-edit-id".to_owned()) - ); - assert!(app.transcript.is_empty()); - assert!(!render_test_terminal(&app, 120, 28).contains("/automations edit")); - Ok(()) - } - - #[test] - fn reviews_id_selects_request_without_arming_esc_reject() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-safe-review")?; - - assert!(app.handle_command("reviews proposal-safe-review")); - - assert!(app.automations_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert_eq!( - app.selected_automation_proposal() - .map(|proposal| proposal.proposal_id), - Some("proposal-safe-review".to_owned()) - ); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - let proposal = find_automation_proposal(&app.paths, "proposal-safe-review")?; - assert_eq!(proposal.status, "pending"); - assert!(app.automations_manager.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_prefetch_review_fields_are_arrow_key_editable() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_prefetch_proposal(&app.paths, "proposal-prefetch-ui")?; - assert!(app.handle_command("automations")); - - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Allow download")); - assert!(rendered.contains("Max download")); - assert!(rendered.contains("Hugging Face")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - let proposal = find_automation_proposal(&app.paths, "proposal-prefetch-ui")?; - assert_eq!( - proposal - .arguments - .get("allow_artifact_download") - .and_then(Value::as_bool), - Some(true) - ); - assert!(app.pending_approval.is_none()); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - let proposal = find_automation_proposal(&app.paths, "proposal-prefetch-ui")?; - assert_eq!( - proposal - .arguments - .get("artifact_max_bytes") - .and_then(Value::as_u64), - Some(1_073_741_824) - ); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - app.set_input("2048".to_owned()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - let proposal = find_automation_proposal(&app.paths, "proposal-prefetch-ui")?; - assert_eq!( - proposal - .arguments - .get("artifact_max_bytes") - .and_then(Value::as_u64), - Some(2048) - ); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - let proposal = find_automation_proposal(&app.paths, "proposal-prefetch-ui")?; - assert_eq!( - proposal - .arguments - .get("allow_huggingface_download") - .and_then(Value::as_bool), - Some(true) - ); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_prefetch_huggingface_requires_download_first() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_prefetch_proposal(&app.paths, "proposal-prefetch-hf")?; - assert!(app.handle_command("automations")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Right, KeyModifiers::NONE)); - - let proposal = find_automation_proposal(&app.paths, "proposal-prefetch-hf")?; - assert_eq!( - proposal - .arguments - .get("allow_huggingface_download") - .and_then(Value::as_bool), - None - ); - assert_eq!(app.status, "Turn on Download first."); - assert!(render_test_terminal(&app, 120, 32).contains("Turn on Download first")); - Ok(()) - } - - #[test] - fn automations_command_without_reviews_shows_watcher_controls() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - - assert!(app.handle_command("automations")); - - assert!(app.automations_manager.is_some()); - assert!(app.selected_automation_proposal().is_none()); - assert_eq!( - app.selected_automation_watcher().map(|watcher| watcher.id), - Some("therock-update".to_owned()) - ); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Automations")); - assert!(rendered.contains("ROCm update checks")); - assert!(rendered.contains("turn this on")); - Ok(()) - } - - #[test] - fn automations_review_actions_without_review_show_body_guidance() -> anyhow::Result<()> { - for command in ["approve", "automations reject"] { - let mut app = test_app(); - app.paths.ensure()?; - - assert!(app.handle_command(command), "{command}"); - - assert!(app.automations_manager.is_some(), "{command}"); - assert!(app.pending_approval.is_none(), "{command}"); - assert!(app.transcript.is_empty(), "{command}"); - let message = app - .automations_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .expect("automations should show body guidance"); - assert!(message.contains("Use Up and Down to choose a review request")); - let rendered = render_test_terminal(&app, 180, 28); - assert!( - rendered.contains("No review request is selected"), - "{command}" - ); - assert!(rendered.contains("Use Up and Down"), "{command}"); - assert!(rendered.contains("Automation checks"), "{command}"); - } - Ok(()) - } - - #[test] - fn automations_manager_enter_requests_watcher_enable_without_transcript() -> anyhow::Result<()> - { - let mut app = test_app(); - app.paths.ensure()?; - assert!(app.handle_command("automations")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - .. - }) if title == "Automations" - && args == &vec![ - "automations".to_owned(), - "enable".to_owned(), - "therock-update".to_owned(), - "--mode".to_owned(), - "observe".to_owned(), - ] - && display_command == "/automations enable therock-update --mode observe" - )); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 28).contains("Turn on ROCm update checks")); - Ok(()) - } - - #[test] - fn automations_manager_delete_requests_watcher_disable_without_transcript() -> anyhow::Result<()> - { - let mut app = test_app(); - app.paths.ensure()?; - app.config.watcher_config_mut("server-recover").enabled = true; - app.config.save(&app.paths)?; - assert!(app.handle_command("automations")); - assert!(app.select_automation_watcher_by_id("server-recover")); - - handle_key(&mut app, key_event(KeyCode::Delete, KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - .. - }) if title == "Automations" - && args == &vec![ - "automations".to_owned(), - "disable".to_owned(), - "server-recover".to_owned(), - ] - && display_command == "/automations disable server-recover" - )); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 28).contains("Turn off Server recovery")); - Ok(()) - } - - #[test] - fn automations_enable_command_uses_watcher_screen_approval() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - - assert!(app.handle_command("automations enable cache-warm --mode contained")); - - assert_eq!( - app.selected_automation_watcher().map(|watcher| watcher.id), - Some("cache-warm".to_owned()) - ); - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - .. - }) if title == "Automations" - && args == &vec![ - "automations".to_owned(), - "enable".to_owned(), - "cache-warm".to_owned(), - "--mode".to_owned(), - "contained".to_owned(), - ] - && display_command == "/automations enable cache-warm --mode contained" - )); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 28).contains("Turn on Model file preparation")); - Ok(()) - } - - #[test] - fn automations_unknown_subcommand_stays_screen_local() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - - assert!(app.handle_command("automations mystery")); - - assert!(app.automations_manager.is_some()); - assert!(app.running_job.is_none()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - assert!(render_test_terminal(&app, 120, 28).contains("Use the arrow keys")); - Ok(()) - } - - #[test] - fn automations_with_only_handled_history_selects_watcher_first() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - for index in 0..12 { - let proposal_id = format!("proposal-old-{index}"); - append_test_proposal(&app.paths, &proposal_id)?; - update_automation_proposal_status(&app.paths, &proposal_id, "completed")?; - } - - assert!(app.handle_command("automations")); - - assert_eq!( - app.selected_automation_watcher().map(|watcher| watcher.id), - Some("therock-update".to_owned()) - ); - assert!(app.selected_automation_proposal().is_none()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("ROCm update checks")); - assert!(rendered.contains("turn this on")); - Ok(()) - } - - #[test] - fn automations_reject_moves_handled_review_below_checks() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-reorder")?; - assert!(app.handle_command("automations")); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - assert_eq!( - find_automation_proposal(&app.paths, "proposal-reorder")?.status, - "pending" - ); - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - - let state = app - .automations_manager - .as_ref() - .expect("automation manager stays open"); - assert!(matches!( - state.rows.first(), - Some(super::AutomationManagerRow::Watcher(_)) - )); - assert!( - state - .rows - .iter() - .position(|row| matches!(row, super::AutomationManagerRow::Review(_))) - .expect("handled review row remains visible") - > 0 - ); - assert_eq!( - app.selected_automation_proposal() - .map(|proposal| proposal.status), - Some("rejected".to_owned()) - ); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_completion_moves_handled_review_below_checks() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-complete-reorder")?; - assert!(app.handle_command("automations")); - - app.finish_proposal_execution("proposal-complete-reorder", true)?; - - let state = app - .automations_manager - .as_ref() - .expect("automation manager stays open"); - assert!(matches!( - state.rows.first(), - Some(super::AutomationManagerRow::Watcher(_)) - )); - assert_eq!( - app.selected_automation_proposal() - .map(|proposal| proposal.status), - Some("completed".to_owned()) - ); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_review_id_focuses_correct_row_with_handled_history() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-pending")?; - append_test_proposal(&app.paths, "proposal-handled")?; - update_automation_proposal_status(&app.paths, "proposal-handled", "completed")?; - - assert!(app.handle_command("automations reviews proposal-pending")); - - assert!(app.pending_approval.is_none()); - assert_eq!( - app.selected_automation_proposal() - .map(|proposal| proposal.proposal_id), - Some("proposal-pending".to_owned()) - ); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_manager_y_requests_watcher_enable_without_transcript() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - assert!(app.handle_command("automations")); - - handle_key(&mut app, key_event(KeyCode::Char('y'), KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - .. - }) if title == "Automations" - && args == &vec![ - "automations".to_owned(), - "enable".to_owned(), - "therock-update".to_owned(), - "--mode".to_owned(), - "observe".to_owned(), - ] - && display_command == "/automations enable therock-update --mode observe" - )); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_manager_n_requests_watcher_disable_without_transcript() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - app.config.watcher_config_mut("server-recover").enabled = true; - app.config.save(&app.paths)?; - assert!(app.handle_command("automations")); - assert!(app.select_automation_watcher_by_id("server-recover")); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - - assert!(matches!( - app.pending_approval.as_ref().map(|pending| &pending.action), - Some(super::ApprovalAction::CliCommand { - title, - args, - display_command: Some(display_command), - .. - }) if title == "Automations" - && args == &vec![ - "automations".to_owned(), - "disable".to_owned(), - "server-recover".to_owned(), - ] - && display_command == "/automations disable server-recover" - )); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn automations_enable_mode_completion_suggests_modes() { - let app = test_app(); - - let menu = super::slash_argument_completion_menu( - "/automations enable cache-warm --mode ", - &app.paths, - 0, - ) - .expect("automation mode completion should be available"); - - assert!(menu.items.contains(&"observe".to_owned())); - assert!(menu.items.contains(&"propose".to_owned())); - assert!(menu.items.contains(&"contained".to_owned())); - } - - #[test] - fn automations_bad_watcher_and_mode_stay_screen_local() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - - assert!(app.handle_command("automations enable missing-check")); - - assert!(app.automations_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Automation check was not found."); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("I could not find that automation check")); - assert!(rendered.contains("Choose one from this list")); - assert!(rendered.contains('>')); - - assert!(app.handle_command("automations enable cache-warm --mode turbo")); - - assert!(app.automations_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!(app.status, "Automation setting was not recognized."); - Ok(()) - } - - #[test] - fn n_rejects_focused_proposal() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-esc")?; - assert!(app.handle_command("reviews proposal-esc")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - handle_key(&mut app, key_event(KeyCode::Char('n'), KeyModifiers::NONE)); - - let proposal = find_automation_proposal(&app.paths, "proposal-esc")?; - assert_eq!(proposal.status, "rejected"); - assert!( - app.activity_text() - .contains("review request rejected: proposal-esc") - ); - Ok(()) - } - - #[test] - fn esc_backs_out_of_focused_proposal_without_rejecting() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-esc-back")?; - assert!(app.handle_command("reviews proposal-esc-back")); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - let proposal = find_automation_proposal(&app.paths, "proposal-esc-back")?; - assert_eq!(proposal.status, "pending"); - assert!(app.pending_approval.is_none()); - assert!(app.automations_manager.is_none()); - assert!(app.home_dashboard_visible); - assert_eq!(active_surface_selection(&app).unwrap().surface, "home"); - Ok(()) - } - - #[test] - fn typed_proposal_edit_opens_review_without_mutating() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - append_test_proposal(&app.paths, "proposal-edit")?; - - assert!(app.handle_command("automations edit proposal-edit service-id svc-2")); - - let proposal = find_automation_proposal(&app.paths, "proposal-edit")?; - assert_eq!(proposal.service_id.as_deref(), Some("svc-1")); - assert_eq!( - proposal.arguments.get("service_id").and_then(Value::as_str), - Some("svc-1") - ); - assert!(app.automations_manager.is_some()); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - assert_eq!( - app.selected_automation_proposal() - .map(|proposal| proposal.proposal_id), - Some("proposal-edit".to_owned()) - ); - assert!( - app.automations_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains("`service-id` was not changed")) - ); - Ok(()) - } - - #[test] - fn typed_prefetch_edit_highlights_editable_rows_without_mutating() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - let mut proposal = test_proposal("proposal-prefetch-edit"); - proposal.watcher_id = "cache-warm".to_owned(); - proposal.action = "queue_prefetch_proposal".to_owned(); - proposal.tool = Some("prefetch_artifact".to_owned()); - proposal.service_id = None; - proposal.arguments = serde_json::json!({ - "artifact_ref": "Qwen/Test-1B#hf-main", - }); - rocm_core::append_automation_proposal(&app.paths, &proposal)?; - - assert!(app.handle_command("automations edit proposal-prefetch-edit allow-download yes")); - assert!(matches!( - app.selected_automation_prefetch_field(), - Some((proposal, super::AutomationPrefetchField::AllowDownload)) - if proposal.proposal_id == "proposal-prefetch-edit" - )); - assert!(app.transcript.is_empty()); - assert!( - app.automations_manager - .as_ref() - .and_then(|state| state.message.as_deref()) - .is_some_and(|message| message.contains("Nothing changed yet")) - ); - - assert!( - app.handle_command("automations edit proposal-prefetch-edit artifact-max-bytes 1024") - ); - assert!(matches!( - app.selected_automation_prefetch_field(), - Some((proposal, super::AutomationPrefetchField::MaxBytes)) - if proposal.proposal_id == "proposal-prefetch-edit" - )); - - assert!(app.handle_command( - "automations edit proposal-prefetch-edit allow-huggingface-download yes" - )); - assert!(matches!( - app.selected_automation_prefetch_field(), - Some((proposal, super::AutomationPrefetchField::AllowHuggingFace)) - if proposal.proposal_id == "proposal-prefetch-edit" - )); - - let proposal = find_automation_proposal(&app.paths, "proposal-prefetch-edit")?; - assert_eq!( - proposal - .arguments - .get("artifact_ref") - .and_then(Value::as_str), - Some("Qwen/Test-1B#hf-main") - ); - assert_eq!( - proposal - .arguments - .get("allow_artifact_download") - .and_then(Value::as_bool), - None - ); - assert_eq!( - proposal - .arguments - .get("artifact_max_bytes") - .and_then(Value::as_u64), - None - ); - assert_eq!( - proposal - .arguments - .get("allow_huggingface_download") - .and_then(Value::as_bool), - None - ); - assert!(app.pending_approval.is_none()); - assert!(app.transcript.is_empty()); - Ok(()) - } - - #[test] - fn invalid_prefetch_source_policy_approval_keeps_proposal_pending() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - let mut proposal = test_proposal("proposal-prefetch-invalid"); - proposal.watcher_id = "cache-warm".to_owned(); - proposal.action = "queue_prefetch_proposal".to_owned(); - proposal.tool = Some("prefetch_artifact".to_owned()); - proposal.service_id = None; - proposal.arguments = serde_json::json!({ - "artifact_ref": "Qwen/Test-1B#hf-main", - "allow_artifact_download": true, - }); - rocm_core::append_automation_proposal(&app.paths, &proposal)?; - - let error = app - .approve_proposal_result("proposal-prefetch-invalid") - .unwrap_err(); - let proposal = find_automation_proposal(&app.paths, "proposal-prefetch-invalid")?; - - assert!( - error - .to_string() - .contains("Set a max download limit before approving") - ); - assert_eq!(proposal.status, "pending"); - assert!(app.running_job.is_none()); - Ok(()) - } - - #[test] - fn proposal_sandbox_args_use_restricted_tool_api() -> anyhow::Result<()> { - let proposal = test_proposal("proposal-args"); - let args = super::proposal_sandbox_args(&proposal)?; - - assert_eq!( - args, - vec![ - "sandbox-run".to_owned(), - "restart_server".to_owned(), - "--allow-native-fallback".to_owned(), - "--service-id".to_owned(), - "svc-1".to_owned(), - ] - ); - Ok(()) - } - - #[test] - fn proposal_sandbox_args_support_cache_warm_prefetch() -> anyhow::Result<()> { - let mut proposal = test_proposal("proposal-prefetch"); - proposal.watcher_id = "cache-warm".to_owned(); - proposal.action = "queue_prefetch_proposal".to_owned(); - proposal.tool = None; - proposal.service_id = None; - proposal.arguments = serde_json::json!({ - "artifact_ref": "Qwen/Test-1B#hf-main", - }); - - let args = super::proposal_sandbox_args(&proposal)?; - - assert_eq!( - args, - vec![ - "sandbox-run".to_owned(), - "prefetch_artifact".to_owned(), - "--allow-native-fallback".to_owned(), - "--artifact-ref".to_owned(), - "Qwen/Test-1B#hf-main".to_owned(), - ] - ); - Ok(()) - } - - #[test] - fn proposal_sandbox_args_support_update_check() -> anyhow::Result<()> { - let mut proposal = test_proposal("proposal-update-check"); - proposal.watcher_id = "therock-update".to_owned(); - proposal.action = "queue_update_proposal".to_owned(); - proposal.tool = None; - proposal.service_id = None; - proposal.arguments = serde_json::json!({}); - - let args = super::proposal_sandbox_args(&proposal)?; - - assert_eq!( - args, - vec![ - "sandbox-run".to_owned(), - "check_updates".to_owned(), - "--allow-native-fallback".to_owned(), - ] - ); - Ok(()) - } - - #[test] - fn prefetch_proposal_approval_shows_plain_artifact_download_controls() { - let mut proposal = test_proposal("proposal-prefetch-help"); - proposal.watcher_id = "cache-warm".to_owned(); - proposal.action = "queue_prefetch_proposal".to_owned(); - proposal.tool = Some("prefetch_artifact".to_owned()); - proposal.title = "queue prefetch proposal".to_owned(); - proposal.message = "cache warm requested; contained mode still queues a review because artifact downloads require explicit source-policy approval".to_owned(); - proposal.service_id = None; - proposal.arguments = serde_json::json!({ - "artifact_ref": "Qwen/Test-1B#hf-main", - }); - - let rendered = super::render_proposal_approval(&proposal); - - assert!(rendered.contains("Review this request")); - assert!(rendered.contains("What will happen")); - assert!(rendered.contains("Qwen/Test-1B#hf-main")); - assert!(rendered.contains("check the local cache only; no download will start")); - assert!(rendered.contains("Download settings")); - assert!(rendered.contains("Select the rows under this request")); - assert!(rendered.contains("Hugging Face token permission")); - assert_proposal_card_has_no_backend_terms(&rendered); - } - - #[test] - fn restart_proposal_approval_uses_plain_review_language() { - let mut proposal = test_proposal("proposal-restart-plain"); - proposal.title = "queue restart proposal".to_owned(); - proposal.message = - "managed service svc-1 needs recovery; queueing restart proposal".to_owned(); - - let rendered = super::render_proposal_approval(&proposal); - - assert!(rendered.contains("Review this request")); - assert!(rendered.contains("Why")); - assert!(rendered.contains("What will happen")); - assert!(rendered.contains("Restart a managed model server")); - assert!(rendered.contains("rocm-cli will restart the affected managed model server.")); - assert!(rendered.contains("It can only perform this reviewed restart action.")); - assert!(rendered.contains("Enter or Y: approve and run this reviewed action")); - assert!(rendered.contains("N: reject")); - assert!(rendered.contains("Esc: back")); - assert_proposal_card_has_no_backend_terms(&rendered); - } - - #[test] - fn stop_proposal_approval_explains_gpu_pressure_without_system_shutdown() { - let mut proposal = test_proposal("proposal-stop-gpu-pressure"); - proposal.watcher_id = "gpu-thermal-protect".to_owned(); - proposal.action = "queue_stop_server_proposal".to_owned(); - proposal.tool = None; - proposal.title = "Review GPU pressure stop".to_owned(); - proposal.message = - "GPU 0 hotspot temperature is 96 C (limit 95 C); asking before stopping anything" - .to_owned(); - proposal.service_id = Some("svc-hot".to_owned()); - proposal.arguments = serde_json::json!({ - "service_id": "svc-hot", - "model_ref": "tiny-shakespeare.gguf", - "endpoint_url": "http://127.0.0.1:11435/v1", - "pressure_summary": "GPU 0 hotspot temperature is 96 C (limit 95 C)", - }); - - let rendered = super::render_proposal_approval(&proposal); - let args = super::proposal_sandbox_args(&proposal).expect("stop proposal args"); - - assert!(rendered.contains("Stop a managed model server")); - assert!(rendered.contains("Service: svc-hot")); - assert!(rendered.contains("Model: tiny-shakespeare.gguf")); - assert!(rendered.contains("Server: http://127.0.0.1:11435/v1")); - assert!(rendered.contains("Reason: GPU 0 hotspot temperature is 96 C")); - assert!(rendered.contains("This will not shut down your computer, GPU, or driver.")); - assert!(rendered.contains("Enter or Y: approve and run this reviewed action")); - assert_eq!( - args, - vec![ - "sandbox-run".to_owned(), - "stop_server".to_owned(), - "--allow-native-fallback".to_owned(), - "--service-id".to_owned(), - "svc-hot".to_owned(), - ] - ); - } - - #[test] - fn proposal_sandbox_args_support_cache_warm_prefetch_source_policy() -> anyhow::Result<()> { - let mut proposal = test_proposal("proposal-prefetch-approved"); - proposal.watcher_id = "cache-warm".to_owned(); - proposal.action = "queue_prefetch_proposal".to_owned(); - proposal.tool = None; - proposal.service_id = None; - proposal.arguments = serde_json::json!({ - "artifact_ref": "Qwen/Test-1B#hf-main", - "allow_artifact_download": true, - "artifact_max_bytes": 1024, - "allow_huggingface_download": true, - }); - - let args = super::proposal_sandbox_args(&proposal)?; - - assert_eq!( - args, - vec![ - "sandbox-run".to_owned(), - "prefetch_artifact".to_owned(), - "--allow-native-fallback".to_owned(), - "--artifact-ref".to_owned(), - "Qwen/Test-1B#hf-main".to_owned(), - "--allow-artifact-download".to_owned(), - "--artifact-max-bytes".to_owned(), - "1024".to_owned(), - "--allow-huggingface-download".to_owned(), - ] - ); - Ok(()) - } - - #[test] - fn proposal_sandbox_args_reject_cache_warm_download_without_max_bytes() { - let mut proposal = test_proposal("proposal-prefetch-missing-limit"); - proposal.watcher_id = "cache-warm".to_owned(); - proposal.action = "queue_prefetch_proposal".to_owned(); - proposal.tool = None; - proposal.service_id = None; - proposal.arguments = serde_json::json!({ - "artifact_ref": "Qwen/Test-1B#hf-main", - "allow_artifact_download": true, - }); - - let error = super::proposal_sandbox_args(&proposal).unwrap_err(); - - assert!( - error - .to_string() - .contains("Set a max download limit before approving") - ); - } - - #[test] - fn proposal_sandbox_args_reject_unsupported_action_with_plain_error() { - let mut proposal = test_proposal("proposal-unsupported"); - proposal.action = "queue_unknown_backend_action".to_owned(); - proposal.tool = None; - - let error = super::proposal_sandbox_args(&proposal) - .unwrap_err() - .to_string(); - - assert!(error.contains("review request `proposal-unsupported` cannot be run")); - assert!(error.contains("supported action")); - assert!(!error.contains("sandbox tool")); - assert!(!error.contains("queue_unknown_backend_action")); - } - - #[test] - fn proposal_sandbox_args_reject_unknown_tool_with_plain_error() { - let mut proposal = test_proposal("proposal-unknown-action"); - proposal.action = "queue_restart_proposal".to_owned(); - proposal.tool = Some("unknown_backend_tool".to_owned()); - - let error = super::proposal_sandbox_args(&proposal) - .unwrap_err() - .to_string(); - - assert!(error.contains("review request `proposal-unknown-action` cannot be run")); - assert!(error.contains("supported action")); - assert!(!error.contains("sandbox")); - assert!(!error.contains("tool")); - assert!(!error.contains("unknown_backend_tool")); - } - - #[test] - fn proposal_sandbox_args_support_driver_upgrade_plan() -> anyhow::Result<()> { - let mut proposal = test_proposal("proposal-driver-plan"); - proposal.watcher_id = "driver-upgrade".to_owned(); - proposal.action = "prepare_driver_plan".to_owned(); - proposal.tool = None; - proposal.service_id = None; - proposal.arguments = serde_json::json!({}); - - let args = super::proposal_sandbox_args(&proposal)?; - - assert_eq!( - args, - vec![ - "sandbox-run".to_owned(), - "driver_plan".to_owned(), - "--allow-native-fallback".to_owned(), - ] - ); - Ok(()) - } - - #[test] - fn driver_plan_proposal_approval_says_no_driver_install() { - let mut proposal = test_proposal("proposal-driver-plan"); - proposal.watcher_id = "driver-upgrade".to_owned(); - proposal.action = "prepare_driver_plan".to_owned(); - proposal.tool = None; - proposal.service_id = None; - proposal.arguments = serde_json::json!({}); - - let rendered = super::render_proposal_approval(&proposal); - - assert!(rendered.contains("Review this request")); - assert!(rendered.contains("Why")); - assert!(rendered.contains("What will happen")); - assert!(rendered.contains("Controls")); - assert!(rendered.contains("show the driver plan only")); - assert!(rendered.contains("no driver will be installed")); - assert!(!rendered.contains("driver_plan")); - assert!(!rendered.contains("prepare_driver_plan")); - assert!(!rendered.contains("approve and run through rocmd sandbox-run")); - assert_proposal_card_has_no_backend_terms(&rendered); - } - - #[test] - fn question_mark_opens_help() { - let mut app = test_app(); - handle_key(&mut app, key_event(KeyCode::Char('?'), KeyModifiers::NONE)); - assert!(app.overlay_card.is_some()); - assert!(app.command_screen.is_none()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Set up ROCm")); - assert!(rendered.contains("Press Enter to open this screen")); - } - - #[test] - fn question_mark_shows_setup_hint_without_opening_command_list() { - let mut app = test_app(); - app.onboarding_active = true; - - handle_key(&mut app, key_event(KeyCode::Char('?'), KeyModifiers::NONE)); - - assert!(app.onboarding_active); - assert!(app.overlay_card.is_none()); - assert!(app.command_screen.is_none()); - assert!(app.status.contains("Setup is open")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Set Up ROCm")); - assert!(!rendered.contains("Command List")); - assert!(!app.transcript.iter().any(|line| line == "[Commands]")); - } - - #[test] - fn unknown_slash_command_opens_help_without_transcript() { - let mut app = test_app(); - app.input = "/wat".to_owned(); - app.input_cursor = app.input.len(); - - app.submit(); - - assert!(app.command_screen.is_none()); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Command Not Found") - ); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("I do not recognize /wat")); - assert!(rendered.contains("Stay here")); - - handle_key(&mut app, key_event(KeyCode::Down, KeyModifiers::NONE)); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Command List")); - assert!(app.transcript.is_empty()); - } - - #[test] - fn unmatched_quote_opens_fix_screen_without_transcript() { - let mut app = test_app(); - assert!(app.handle_command("engine")); - assert!(app.engine_manager.is_some()); - - assert!(app.handle_command("serve \"tiny.gguf")); - - assert!(app.engine_manager.is_some()); - assert!(app.overlay_card.is_some()); - assert!(app.transcript.is_empty()); - let rendered = render_test_terminal(&app, 120, 24); - assert!(rendered.contains("Command Needs Fix")); - assert!(rendered.contains("unterminated")); - assert!(rendered.contains("Check the quote marks")); - assert!(rendered.contains("Stay here")); - - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert!(app.engine_manager.is_some()); - assert!(app.transcript.is_empty()); - } - - #[test] - fn bad_setup_subcommand_stays_on_setup_without_transcript() { - let mut app = test_app(); - - assert!(app.handle_command("setup next")); - - assert!(app.onboarding_active); - assert!(app.transcript.is_empty()); - assert!(app.status.contains("I do not recognize setup option")); - let rendered = render_test_terminal(&app, 120, 32); - assert!(rendered.contains("Setup needs attention")); - assert!(rendered.contains("I do not recognize setup option `next`")); - assert!(rendered.contains("> Install ROCm")); - } - - #[test] - fn home_dashboard_quit_is_last_and_esc_opens_one_focused_card() { - let mut app = test_app(); - app.open_home_dashboard(); - - let actions = super::home_dashboard_actions(&app); - assert_eq!( - actions.last().copied(), - Some(super::HomeDashboardAction::Quit) - ); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Quit") - ); - assert!(app.should_draw_home_dashboard()); - assert!(app.command_screen.is_none()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Quit")); - assert!(rendered.contains("Stay in ROCm CLI")); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert!(app.should_draw_home_dashboard()); - } - - #[test] - fn running_models_hides_stopped_services_from_main_list() -> anyhow::Result<()> { - let mut app = test_app(); - app.paths.ensure()?; - for (service_id, status) in [ - ("svc-ready", "ready"), - ("svc-stopped", "stopped"), - ("svc-exited", "exited"), - ] { - let mut record = ManagedServiceRecord::new( - &app.paths, - service_id, - "lemonade", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - std::process::id(), - None, - None, - None, - ); - record.status = status.to_owned(); - record.write()?; - } - - app.open_services_manager(); - - let state = app.services_manager.as_ref().expect("services screen"); - let service_ids = state - .items - .iter() - .map(|record| record.service_id.as_str()) - .collect::>(); - assert_eq!(service_ids, vec!["svc-ready"]); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Starting"), "{rendered}"); - assert!(rendered.contains("lemonade"), "{rendered}"); - assert!(!rendered.contains("svc-stopped"), "{rendered}"); - assert!(!rendered.contains("svc-exited"), "{rendered}"); - assert!(!rendered.contains("past attempts"), "{rendered}"); - Ok(()) - } - - #[test] - fn assistant_chat_esc_asks_before_leaving_and_closes_in_one_more_esc() { - let mut app = test_app(); - let record = ManagedServiceRecord::new( - &app.paths, - "svc-qwen", - "lemonade", - "qwen", - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - "127.0.0.1", - 11435, - "managed", - 123, - None, - None, - None, - ); - app.open_local_rocm_tools_chat_session(Some(record)); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Stop Assistant") - ); - assert!(app.command_screen_is_chat_session()); - let rendered = render_test_terminal(&app, 120, 28); - assert!(rendered.contains("Stop Assistant"), "{rendered}"); - assert!(!rendered.contains("Command List"), "{rendered}"); - - handle_key(&mut app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.overlay_card.is_none()); - assert!(app.command_screen_is_chat_session()); - } - - #[test] - fn ctrl_l_clears_transcript_and_ctrl_r_refreshes_status() { - let mut app = test_app(); - app.push_block("Test", "line"); - handle_key( - &mut app, - key_event(KeyCode::Char('l'), KeyModifiers::CONTROL), - ); - assert_eq!( - app.overlay_card.as_ref().map(|state| state.title.as_str()), - Some("Clear") - ); - assert!(!app.transcript.is_empty()); - handle_key(&mut app, key_event(KeyCode::Enter, KeyModifiers::NONE)); - assert!(app.overlay_card.is_none()); - assert!(!app.transcript.is_empty()); - handle_key( - &mut app, - key_event(KeyCode::Char('l'), KeyModifiers::CONTROL), - ); - handle_key(&mut app, key_event(KeyCode::Char('y'), KeyModifiers::NONE)); - assert!(app.transcript.is_empty()); - handle_key( - &mut app, - key_event(KeyCode::Char('r'), KeyModifiers::CONTROL), - ); - assert_eq!(app.status, "Status refreshed."); - } - - fn test_app() -> App { - let unique = next_test_unique_suffix(); - let root = workspace_test_artifact_dir().join(format!("rocm-cli-tui-test-{unique}")); - let paths = AppPaths { - config_dir: root.join("config"), - data_dir: root.join("data"), - cache_dir: root.join("cache"), - }; - App { - paths, - config: RocmCliConfig::default(), - provider: "local".to_owned(), - provider_key_store: Arc::new(TestProviderKeyStore::default()), - mode: TuiMode::Ask, - host_gpu_summary: HostGpuSummary::default(), - gpu_telemetry: GpuTelemetry::default(), - transcript: Vec::new(), - transcript_scroll: 0, - input: String::new(), - input_cursor: 0, - chat_input_scroll: 0, - completion_selection: 0, - history: Vec::new(), - history_index: None, - home_dashboard_visible: true, - home_selection: 0, - activity_log: std::collections::VecDeque::new(), - status: String::new(), - should_quit: false, - pending_approval: None, - running_job: None, - running_job_log_scroll: 0, - running_job_output: std::collections::VecDeque::new(), - running_job_cancel_requested: None, - examine_manager: None, - logs_view: None, - runtime_manager: None, - install_manager: None, - engine_manager: None, - model_picker: None, - serve_wizard: None, - update_manager: None, - automations_manager: None, - provider_manager: None, - config_manager: None, - services_manager: None, - command_screen: None, - command_screen_last_area: std::cell::Cell::new(None), - saved_chat_session: None, - chat_stop_service_id: None, - overlay_card: None, - folder_browser: None, - onboarding_active: false, - onboarding_selection: 0, - onboarding_path_editing: false, - onboarding_install_log: std::collections::VecDeque::new(), - onboarding_install_log_scroll: 0, - onboarding_install_failure: None, - onboarding_setup_error: None, - onboarding_install_full_log: None, - onboarding_show_log_location: false, - onboarding_cancel_install_confirm: false, - onboarding_cancel_install_selection: 0, - onboarding_success_modal: false, - onboarding_channel: InstallSdkChannel::Release, - tui_owned_service_ids: std::collections::BTreeSet::new(), - tui_started_comfyui: false, - } - } - - fn write_comfyui_manifest_for_test(app: &App) -> anyhow::Result<()> { - let app_root = app.paths.data_dir.join("apps").join("comfyui"); - let source_path = app_root.join("source"); - let log_path = app_root.join("logs").join("install-1.log"); - fs::create_dir_all(source_path.join("models"))?; - fs::create_dir_all(log_path.parent().expect("test log should have parent"))?; - fs::write(&log_path, "installed ComfyUI\n")?; - let manifest_dir = app_root.join("manifests"); - fs::create_dir_all(&manifest_dir)?; - fs::write( - manifest_dir.join("current.json"), - serde_json::json!({ - "app_id": "comfyui", - "runtime_id": "runtime", - "runtime_key": "release", - "runtime_version": "7.13.0a20260511", - "runtime_root": "D:\\ROCm\\therock_venvs", - "python_executable": "python", - "source_url": "https://github.com/comfyanonymous/ComfyUI/archive/refs/heads/master.tar.gz", - "source_path": source_path, - "requirements_path": "requirements.txt", - "pip_cache_dir": "D:\\ROCm\\therock_venvs\\pip-cache", - "log_path": log_path, - "torch_version": "2.10.0", - "torch_cuda_available": true, - "installed_at_unix_ms": 1 - }) - .to_string(), - )?; - Ok(()) - } - - fn write_comfyui_running_state_for_test(app: &App, port: u16) -> anyhow::Result<()> { - let app_root = app.paths.data_dir.join("apps").join("comfyui"); - let state_dir = app_root.join("state"); - let source_path = app_root.join("source"); - let log_path = app_root.join("logs").join("start-1.log"); - fs::create_dir_all(&state_dir)?; - fs::create_dir_all(log_path.parent().expect("test log should have parent"))?; - fs::write( - state_dir.join("running.json"), - serde_json::json!({ - "app_id": "comfyui", - "url": format!("http://127.0.0.1:{port}"), - "host": "127.0.0.1", - "port": port, - "pid": std::process::id(), - "source_path": source_path, - "python_executable": "python", - "log_path": log_path, - "started_at_unix_ms": 1 - }) - .to_string(), - )?; - Ok(()) - } - - fn submit_prompt_command(app: &mut App, command: &str) { - app.input = command.to_owned(); - app.input_cursor = app.input.len(); - app.submit(); - } - - fn move_serve_wizard_to_choice(app: &mut App, choice: super::ServeWizardChoice) { - for _ in 0..=super::serve_wizard_choices().len() { - if app.selected_serve_wizard_choice() == choice { - return; - } - handle_key(app, key_event(KeyCode::Down, KeyModifiers::NONE)); - } - panic!("serve wizard choice {choice:?} was not reachable"); - } - - fn set_serve_wizard_engine(app: &mut App, engine: &str) { - let engines = super::serve_wizard_engine_names(); - let index = engines - .iter() - .position(|candidate| candidate == engine) - .unwrap_or_else(|| panic!("{engine} engine option")); - app.serve_wizard - .as_mut() - .expect("serve wizard") - .engine_index = index; - } - - fn open_hidden_examine_refresh_from_prompt(app: &mut App) { - submit_prompt_command(app, "/examine"); - assert!(matches!( - app.running_job.as_ref().map(|job| &job.kind), - Some(super::RunningJobKind::ExamineRefresh) - )); - - handle_key(app, key_event(KeyCode::Esc, KeyModifiers::NONE)); - - assert!(app.examine_manager.is_none()); - assert!(matches!( - app.running_job.as_ref().map(|job| &job.kind), - Some(super::RunningJobKind::ExamineRefresh) - )); - assert!(app.transcript.is_empty()); - } - - fn spawn_fake_local_chat_server( - content: &'static str, - ) -> anyhow::Result<(u16, mpsc::Receiver)> { - spawn_fake_local_chat_server_for_model(content, super::VALIDATED_LOCAL_ASSISTANT_MODEL) - } - - fn spawn_fake_local_chat_server_for_model( - content: &'static str, - model_id: &'static str, - ) -> anyhow::Result<(u16, mpsc::Receiver)> { - spawn_fake_local_chat_server_repeated_for_model(content, 1, model_id) - } - - fn spawn_fake_local_chat_server_repeated( - content: &'static str, - response_count: usize, - ) -> anyhow::Result<(u16, mpsc::Receiver)> { - spawn_fake_local_chat_server_repeated_for_model( - content, - response_count, - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - ) - } - - fn spawn_fake_local_chat_server_repeated_for_model( - content: &'static str, - response_count: usize, - model_id: &'static str, - ) -> anyhow::Result<(u16, mpsc::Receiver)> { - spawn_fake_local_chat_server_with_response_repeated( - serde_json::json!({ - "choices": [{ - "message": { - "role": "assistant", - "content": content - } - }] - }), - response_count, - model_id, - ) - } - - fn spawn_fake_local_chat_server_with_response( - response: Value, - ) -> anyhow::Result<(u16, mpsc::Receiver)> { - spawn_fake_local_chat_server_with_response_for_model( - response, - super::VALIDATED_LOCAL_ASSISTANT_MODEL, - ) - } - - fn spawn_fake_local_chat_server_with_response_for_model( - response: Value, - model_id: &'static str, - ) -> anyhow::Result<(u16, mpsc::Receiver)> { - spawn_fake_local_chat_server_with_response_repeated(response, 1, model_id) - } - - fn spawn_fake_local_chat_server_with_response_repeated( - response: Value, - response_count: usize, - model_id: &'static str, - ) -> anyhow::Result<(u16, mpsc::Receiver)> { - let listener = TcpListener::bind("127.0.0.1:0")?; - let port = listener.local_addr()?.port(); - let (sender, receiver) = mpsc::channel(); - std::thread::spawn(move || { - let mut served_chat_responses = 0; - while served_chat_responses < response_count.max(1) { - let Ok((mut stream, _)) = listener.accept() else { - return; - }; - let _ = stream.set_read_timeout(Some(Duration::from_secs(5))); - let mut request = Vec::new(); - let mut buffer = [0_u8; 512]; - let header_end = loop { - let Ok(read) = stream.read(&mut buffer) else { - return; - }; - if read == 0 { - return; - } - request.extend_from_slice(&buffer[..read]); - if let Some(index) = request.windows(4).position(|window| window == b"\r\n\r\n") - { - break index + 4; - } - }; - let headers = String::from_utf8_lossy(&request[..header_end]); - let request_line = headers.lines().next().unwrap_or_default(); - if request_line.starts_with("GET /v1/models ") { - let response = serde_json::json!({ "data": [{ "id": model_id }] }).to_string(); - let header = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n", - response.len() - ); - let _ = stream.write_all(header.as_bytes()); - let _ = stream.write_all(response.as_bytes()); - continue; - } - let content_length = headers - .lines() - .find_map(|line| { - let (name, value) = line.split_once(':')?; - name.eq_ignore_ascii_case("content-length") - .then(|| value.trim().parse::().ok()) - .flatten() - }) - .unwrap_or(0); - while request.len().saturating_sub(header_end) < content_length { - let Ok(read) = stream.read(&mut buffer) else { - return; - }; - if read == 0 { - return; - } - request.extend_from_slice(&buffer[..read]); - } - let body = &request[header_end..header_end + content_length]; - let request_value = serde_json::from_slice::(body).ok(); - if let Some(value) = request_value.as_ref() { - let _ = sender.send(value.clone()); - } - served_chat_responses += 1; - if request_value - .as_ref() - .and_then(|value| value.get("stream")) - .and_then(Value::as_bool) - .unwrap_or(false) - { - let content = response - .get("choices") - .and_then(Value::as_array) - .and_then(|choices| choices.first()) - .and_then(|choice| choice.get("message")) - .and_then(|message| message.get("content")) - .and_then(Value::as_str) - .unwrap_or_default(); - let chunk = serde_json::json!({ - "choices": [{ - "delta": { - "content": content - } - }] - }); - let response_body = format!("data: {chunk}\n\ndata: [DONE]\n\n"); - let header = format!( - "HTTP/1.1 200 OK\r\ncontent-type: text/event-stream\r\nconnection: close\r\ncontent-length: {}\r\n\r\n", - response_body.len() - ); - let _ = stream.write_all(header.as_bytes()); - let _ = stream.write_all(response_body.as_bytes()); - } else { - let response = response.to_string(); - let header = format!( - "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nconnection: close\r\ncontent-length: {}\r\n\r\n", - response.len() - ); - let _ = stream.write_all(header.as_bytes()); - let _ = stream.write_all(response.as_bytes()); - } - } - }); - Ok((port, receiver)) - } - - fn poll_app_until_idle(app: &mut App) { - // A chat turn issues up to two sequential HTTP requests, each with a 30s - // client timeout. On a CPU-saturated runner (the Windows CI box) the - // fake-server thread and the worker thread can be starved well past a - // few seconds, so the budget must exceed the worst-case request time; - // otherwise the job is still running when we assert and the test flakes. - for _ in 0..6_000 { - app.poll_running_job(); - if app.running_job.is_none() { - return; - } - std::thread::sleep(Duration::from_millis(10)); - } - app.poll_running_job(); - } - - fn attach_running_job( - app: &mut App, - title: &str, - kind: super::RunningJobKind, - ) -> mpsc::Sender { - let (sender, receiver) = mpsc::channel(); - app.running_job = Some(super::RunningJob { - title: title.to_owned(), - kind, - receiver, - started_at: Instant::now(), - streamed_lines: 0, - streamed_stdout_lines: 0, - streamed_stderr_lines: 0, - }); - app.running_job_log_scroll = 0; - app.running_job_output.clear(); - sender - } - - fn finish_running_job( - app: &mut App, - sender: mpsc::Sender, - rendered: &str, - ) { - sender - .send(super::RunningJobEvent::Finished(Ok(super::CommandOutput { - ok: true, - rendered: rendered.to_owned(), - chat_approval: None, - }))) - .expect("test should send running job completion"); - app.poll_running_job(); - assert!(app.running_job.is_none()); - } - - fn assert_no_background_examine_block(app: &App, command: &str) { - assert!( - !app.status.contains("already running") - && !app.status.contains("still running. Wait for it to finish"), - "{command} should not be blocked by a hidden Examine refresh: {}", - app.status - ); - assert!( - app.running_job - .as_ref() - .is_none_or(|job| !matches!(job.kind, super::RunningJobKind::ExamineRefresh)), - "{command} should discard hidden Examine refresh before continuing" - ); - assert!( - app.transcript.is_empty(), - "{command} should remain a navigable screen, not a transcript dump" - ); - } - - fn active_tui_surface_count(app: &App) -> usize { - if app.overlay_card.is_some() { - return 1; - } - [ - app.examine_manager.is_some(), - app.logs_view.is_some(), - app.runtime_manager.is_some(), - app.install_manager.is_some(), - app.engine_manager.is_some(), - app.model_picker.is_some(), - app.serve_wizard.is_some(), - app.update_manager.is_some(), - app.automations_manager.is_some(), - app.provider_manager.is_some(), - app.config_manager.is_some(), - app.services_manager.is_some(), - app.command_screen.is_some(), - app.onboarding_active, - app.should_draw_home_dashboard(), - ] - .into_iter() - .filter(|active| *active) - .count() - } - - #[derive(Debug, PartialEq, Eq)] - struct SurfaceSelection { - surface: &'static str, - selected: usize, - row_count: usize, - } - - fn active_surface_selection(app: &App) -> Option { - if let Some(pending) = app.pending_approval.as_ref() { - return Some(SurfaceSelection { - surface: "approval", - selected: pending.selected, - row_count: 2, - }); - } - if let Some(state) = app.overlay_card.as_ref() { - return Some(SurfaceSelection { - surface: "overlay", - selected: state.selected, - row_count: state.actions.len(), - }); - } - if let Some(state) = app.examine_manager.as_ref() { - return Some(SurfaceSelection { - surface: "examine", - selected: state.selected, - row_count: super::examine_manager_choices().len(), - }); - } - if let Some(state) = app.logs_view.as_ref() { - return Some(SurfaceSelection { - surface: "logs", - selected: state.selected, - row_count: super::logs_view_actions().len(), - }); - } - if let Some(state) = app.runtime_manager.as_ref() { - let (selected, row_count) = match &state.screen { - super::RuntimeManagerScreen::List => { - (state.selected, super::runtime_manager_choice_count(state)) - } - super::RuntimeManagerScreen::Advanced { selected } => { - (*selected, super::runtime_advanced_choices().len()) - } - super::RuntimeManagerScreen::ImportManifest { selected, .. } => { - (*selected, super::runtime_import_choices().len()) - } - super::RuntimeManagerScreen::AdoptExisting { selected, .. } => { - (*selected, super::runtime_adopt_choices().len()) - } - }; - return Some(SurfaceSelection { - surface: "runtimes", - selected, - row_count, - }); - } - if let Some(state) = app.install_manager.as_ref() { - let (selected, row_count) = match &state.screen { - super::InstallManagerScreen::Main => { - (state.selected, super::install_menu_choices().len()) - } - super::InstallManagerScreen::Sdk { selected, .. } => { - (*selected, super::install_sdk_choices().len()) - } - }; - return Some(SurfaceSelection { - surface: "install", - selected, - row_count, - }); - } - if let Some(state) = app.engine_manager.as_ref() { - return Some(SurfaceSelection { - surface: "engine", - selected: state.selected, - row_count: super::engine_manager_choice_count(state), - }); - } - if let Some(state) = app.model_picker.as_ref() { - return Some(SurfaceSelection { - surface: "model", - selected: state.selected, - row_count: state.items.len(), - }); - } - if let Some(state) = app.serve_wizard.as_ref() { - return Some(SurfaceSelection { - surface: "serve", - selected: state.selected, - row_count: super::serve_wizard_choices().len(), - }); - } - if let Some(state) = app.update_manager.as_ref() { - return Some(SurfaceSelection { - surface: "update", - selected: state.selected, - row_count: super::update_menu_choices().len(), - }); - } - if let Some(state) = app.automations_manager.as_ref() { - return Some(SurfaceSelection { - surface: "automations", - selected: state.selected, - row_count: state.rows.len(), - }); - } - if let Some(state) = app.provider_manager.as_ref() { - return Some(SurfaceSelection { - surface: "provider", - selected: state.selected, - row_count: super::provider_options().len(), - }); - } - if let Some(state) = app.config_manager.as_ref() { - let (selected, row_count) = match &state.screen { - super::ConfigManagerScreen::Main => { - (state.selected, super::config_menu_choices().len()) - } - super::ConfigManagerScreen::Provider { - selected, - confirming_clear, - .. - } => ( - *selected, - super::config_provider_choices(*confirming_clear).len(), - ), - }; - return Some(SurfaceSelection { - surface: "config", - selected, - row_count, - }); - } - if let Some(state) = app.services_manager.as_ref() { - return Some(SurfaceSelection { - surface: "services", - selected: state.selected, - row_count: super::services_manager_choice_count(state), - }); - } - if let Some(state) = app.command_screen.as_ref() { - return Some(SurfaceSelection { - surface: "command", - selected: state.selected, - row_count: state.actions.len(), - }); - } - if app.onboarding_active { - return Some(SurfaceSelection { - surface: "setup", - selected: app.onboarding_selection, - row_count: app.onboarding_choice_count(), - }); - } - if app.should_draw_home_dashboard() { - return Some(SurfaceSelection { - surface: "home", - selected: app.home_selection, - row_count: super::home_dashboard_actions(app).len(), - }); - } - None - } - - fn active_surface_detail_scroll(app: &App) -> Option { - if let Some(state) = app.overlay_card.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.examine_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.logs_view.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.runtime_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.install_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.engine_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.model_picker.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.serve_wizard.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.update_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.automations_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.config_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.provider_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.services_manager.as_ref() { - return Some(state.detail_scroll); - } - if let Some(state) = app.command_screen.as_ref() { - return Some(state.detail_scroll); - } - None - } - - fn render_test_terminal(app: &App, width: u16, height: u16) -> String { - render_test_buffer(app, width, height) - .content() - .iter() - .map(ratatui::buffer::Cell::symbol) - .collect::() - } - - fn render_test_buffer(app: &App, width: u16, height: u16) -> Buffer { - let backend = TestBackend::new(width, height); - let mut terminal = Terminal::new(backend).expect("test terminal should initialize"); - terminal - .draw(|frame| super::draw(frame, app)) - .expect("test terminal should draw"); - terminal.backend().buffer().clone() - } - - fn test_paths(name: &str) -> (std::path::PathBuf, AppPaths) { - let unique = next_test_unique_suffix(); - let root = workspace_test_artifact_dir().join(format!("rocm-cli-tui-test-{name}-{unique}")); - let paths = AppPaths { - config_dir: root.join("config"), - data_dir: root.join("data"), - cache_dir: root.join("cache"), - }; - (root, paths) - } - - fn short_test_install_root() -> std::path::PathBuf { - #[cfg(windows)] - { - std::path::PathBuf::from(r"D:\t\therock_venvs") - } - #[cfg(not(windows))] - { - std::path::PathBuf::from("/t/therock_venvs") - } - } - - fn next_test_unique_suffix() -> String { - let timestamp = SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock should be after unix epoch") - .as_nanos(); - let seq = TEST_PATH_COUNTER.fetch_add(1, Ordering::Relaxed); - format!("{timestamp}-{seq}") - } - - fn workspace_test_artifact_dir() -> std::path::PathBuf { - // Use the system temp dir rather than a path under the workspace: the - // latter is long enough (especially on CI checkouts) that rendered - // install paths wrap inside the TUI panels, breaking snapshot assertions. - std::env::temp_dir().join("rocm-cli-tests").join("tui") - } - - #[cfg(unix)] - fn failed_exit_status() -> std::process::ExitStatus { - use std::os::unix::process::ExitStatusExt; - std::process::ExitStatus::from_raw(1 << 8) - } - - #[cfg(windows)] - fn failed_exit_status() -> std::process::ExitStatus { - use std::os::windows::process::ExitStatusExt; - std::process::ExitStatus::from_raw(1) - } - - fn assert_proposal_card_has_no_backend_terms(rendered: &str) { - let lower = rendered.to_ascii_lowercase(); - for term in [ - "watcher:", - "action:", - "tool:", - "service_id", - "sandbox-run", - "restart_server", - "prefetch_artifact", - "driver_plan", - "queue_", - "queueing", - "cache warm", - "contained mode", - "source-policy", - "proposal", - "svc-1", - "restricted safety runner", - ] { - assert!(!lower.contains(term), "{term} leaked in:\n{rendered}"); - } - } - - fn write_test_runtime( - paths: &AppPaths, - runtime_id: &str, - installed_at_unix_ms: u128, - ) -> anyhow::Result<()> { - let runtime_key = runtime_id.replace([':', '.', '/'], "-"); - write_test_runtime_with_key(paths, &runtime_key, runtime_id, installed_at_unix_ms) - } - - fn write_test_runtime_with_key( - paths: &AppPaths, - runtime_key: &str, - runtime_id: &str, - installed_at_unix_ms: u128, - ) -> anyhow::Result<()> { - let registry = paths.data_dir.join("runtimes").join("registry"); - fs::create_dir_all(®istry)?; - let install_root = paths - .data_dir - .join("runtimes") - .join("wheel") - .join(runtime_key); - let scripts_dir = install_root.join(if cfg!(windows) { "Scripts" } else { "bin" }); - let python_executable = scripts_dir.join(if cfg!(windows) { - "python.exe" - } else { - "python" - }); - let sdk_root = install_root.join("_rocm_sdk_devel"); - let sdk_bin = sdk_root.join("bin"); - fs::create_dir_all(&scripts_dir)?; - fs::create_dir_all(&sdk_bin)?; - let amdhip = sdk_bin.join(if cfg!(windows) { - "amdhip64_7.dll" - } else { - "libamdhip64.so" - }); - let hipblas = sdk_bin.join(if cfg!(windows) { - "hipblas.dll" - } else { - "libhipblas.so" - }); - fs::write(&python_executable, "python")?; - fs::write(&amdhip, "amdhip")?; - fs::write(&hipblas, "hipblas")?; - let manifest = serde_json::json!({ - "runtime_key": runtime_key, - "runtime_id": runtime_id, - "install_root": install_root, - "selected_artifact_url": "https://example.invalid/therock", - "index_url": "https://example.invalid/therock", - "python_executable": python_executable, - "rocm_sdk": { - "import_ok": true, - "root_path": sdk_root, - "bin_path": sdk_bin, - "resolved_libraries": [ - { "shortname": "amdhip64", "paths": [amdhip] }, - { "shortname": "hipblas", "paths": [hipblas] } - ] - }, - "channel": "release", - "format": "wheel", - "family": runtime_id.split(':').next_back().unwrap_or("gfx120X-all"), - "family_source": "test", - "version": "6.4.0", - "installed_at_unix_ms": installed_at_unix_ms - }); - fs::write( - registry.join(format!("{runtime_key}.json")), - serde_json::to_vec_pretty(&manifest)?, - )?; - fs::write( - install_root.join(".rocm-cli-runtime.json"), - serde_json::to_vec_pretty(&manifest)?, - )?; - Ok(()) - } - - fn set_test_runtime_version( - paths: &AppPaths, - runtime_key: &str, - version: &str, - ) -> anyhow::Result<()> { - let registry_path = paths - .data_dir - .join("runtimes") - .join("registry") - .join(format!("{runtime_key}.json")); - let mut manifest: Value = serde_json::from_slice(&fs::read(®istry_path)?)?; - manifest["version"] = Value::String(version.to_owned()); - fs::write(®istry_path, serde_json::to_vec_pretty(&manifest)?)?; - let install_root = manifest - .get("install_root") - .and_then(Value::as_str) - .map(std::path::PathBuf::from) - .expect("test runtime should have install_root"); - fs::write( - install_root.join(".rocm-cli-runtime.json"), - serde_json::to_vec_pretty(&manifest)?, - )?; - Ok(()) - } - - fn write_test_engine_env( - paths: &AppPaths, - engine: &str, - env_id: &str, - runtime_id: &str, - ) -> anyhow::Result<()> { - let env_path = paths - .data_dir - .join("engines") - .join(engine.replace('.', "-")) - .join("envs") - .join(env_id); - fs::create_dir_all(&env_path)?; - let manifest_dir = paths.engine_manifests_dir(engine); - fs::create_dir_all(&manifest_dir)?; - fs::write( - manifest_dir.join(format!("{env_id}.json")), - serde_json::to_vec_pretty(&serde_json::json!({ - "env_id": env_id, - "runtime_id": runtime_id, - "python_executable": env_path.join("python.exe"), - "env_path": env_path - }))?, - )?; - Ok(()) - } - - fn append_test_proposal(paths: &AppPaths, proposal_id: &str) -> anyhow::Result<()> { - append_automation_proposal(paths, &test_proposal(proposal_id)) - } - - fn append_test_prefetch_proposal(paths: &AppPaths, proposal_id: &str) -> anyhow::Result<()> { - append_automation_proposal(paths, &test_prefetch_proposal(proposal_id)) - } - - fn test_prefetch_proposal(proposal_id: &str) -> AutomationProposalRecord { - let mut proposal = test_proposal(proposal_id); - proposal.watcher_id = "cache-warm".to_owned(); - proposal.action = "queue_prefetch_proposal".to_owned(); - proposal.title = "Prepare model file".to_owned(); - proposal.message = "Prepare Qwen/Test-1B#hf-main for a later run.".to_owned(); - proposal.service_id = None; - proposal.tool = Some("prefetch_artifact".to_owned()); - proposal.arguments = serde_json::json!({ - "artifact_ref": "Qwen/Test-1B#hf-main", - }); - proposal - } - - fn test_proposal(proposal_id: &str) -> AutomationProposalRecord { - AutomationProposalRecord { - at_unix_ms: 1, - proposal_id: proposal_id.to_owned(), - watcher_id: "server-recover".to_owned(), - action: "queue_restart_proposal".to_owned(), - title: "Restart service".to_owned(), - message: "restart svc-1".to_owned(), - status: "pending".to_owned(), - service_id: Some("svc-1".to_owned()), - tool: Some("restart_server".to_owned()), - arguments: serde_json::json!({ "service_id": "svc-1" }), - reviewed_at_unix_ms: None, - } - } - - fn key_event(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent { - KeyEvent { - code, - modifiers, - kind: KeyEventKind::Press, - state: KeyEventState::empty(), - } - } -} diff --git a/crates/rocm-dash-tui/src/app/mod.rs b/crates/rocm-dash-tui/src/app/mod.rs index f316e3fe..ccc0a6ec 100644 --- a/crates/rocm-dash-tui/src/app/mod.rs +++ b/crates/rocm-dash-tui/src/app/mod.rs @@ -53,6 +53,10 @@ pub struct ResolvedArgs { /// Which tab is active when the TUI opens. `Chat` for the chat-first launch /// (bare `rocm` / `rocm chat`); `Home` for the dashboard (`rocm dash`). pub initial_tab: ActiveTab, + /// Open the first-run onboarding wizard immediately on launch. Set by + /// `rocm bootstrap setup`, which enters the dashboard straight into the + /// install/adopt onboarding overlay. + pub start_onboarding: bool, /// Chat endpoint base URL, CLI-flag value already merged over config. pub chat_url: Option, /// Chat model, CLI-flag value already merged over config. @@ -1362,6 +1366,11 @@ async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Resu let mut state = AppState::new(connect_label, args.theme.clone()); // Honor the chat-first vs dashboard launch choice (rocm-cli semantics). state.active_tab = args.initial_tab; + // `rocm bootstrap setup` launches the dashboard straight into the first-run + // onboarding wizard (install ROCm SDK / adopt an existing folder). + if args.start_onboarding { + state.onboarding = Some(crate::ui::onboarding::OnboardingState::default()); + } // Serve-wizard recipe picker source (Phase 3 Wave 1), adapted by the bin. state.model_recipes = args.model_recipes.clone(); // Runtime manager source (Phase 3 Wave 2), adapted by the bin. @@ -4127,6 +4136,7 @@ mod tests { theme: "default-dark".into(), replay: None, initial_tab: ActiveTab::Chat, + start_onboarding: false, chat_url: None, chat_model: None, chat_auth_header: None, diff --git a/crates/rocm-dash-tui/src/llm.rs b/crates/rocm-dash-tui/src/llm.rs index 2f1a06fb..c151376b 100644 --- a/crates/rocm-dash-tui/src/llm.rs +++ b/crates/rocm-dash-tui/src/llm.rs @@ -13,7 +13,14 @@ use std::time::Duration; /// Probed-default endpoint: the local OpenAI-compatible serving endpoint the /// dashboard already watches. Used only when no URL is configured anywhere. -pub const DEFAULT_CHAT_BASE_URL: &str = "http://127.0.0.1:8000"; +/// +/// The `/v1` suffix is required: the Rig OpenAI client appends `chat/completions` +/// directly to `base_url` (`base_url + "/" + path`), so a base without `/v1` +/// POSTs to `/chat/completions` and a vLLM/Lemonade server answers `404 Not +/// Found`. Matches the `/v1` convention of `VLLM_ENDPOINT` / `LEMONADE_ENDPOINT` +/// (those use `localhost`; `127.0.0.1` is the unambiguous IPv4 loopback and +/// matches the live endpoint asserted in `agent.rs`). +pub const DEFAULT_CHAT_BASE_URL: &str = "http://127.0.0.1:8000/v1"; /// Fallback model name when none is configured. Many local endpoints ignore /// the model field or expose a single model, so a neutral default is safe. @@ -261,6 +268,16 @@ mod tests { fn probed_default_used_when_nothing_configured_but_reachable() { let r = resolve_llm_config(None, None, None, None, None, None, None, true).unwrap(); assert_eq!(r.base_url, DEFAULT_CHAT_BASE_URL); + // Regression guard for the 404 bug: Rig appends `chat/completions` to + // `base_url`, so the probed default must resolve to a `/v1` base or every + // completion POSTs to `/chat/completions` and the server returns 404. + // Asserting the *resolved* value (not the constant) fails on a revert of + // the suffix rather than being tautological. + assert!( + r.base_url.ends_with("/v1"), + "probed default base_url must end in /v1, got {}", + r.base_url + ); assert_eq!(r.model, DEFAULT_CHAT_MODEL); assert_eq!(r.api_key, None); } diff --git a/crates/rocm-dash-tui/src/ui/examine_manager.rs b/crates/rocm-dash-tui/src/ui/examine_manager.rs index 1eb00b97..8e80f133 100644 --- a/crates/rocm-dash-tui/src/ui/examine_manager.rs +++ b/crates/rocm-dash-tui/src/ui/examine_manager.rs @@ -205,14 +205,18 @@ mod tests { } #[test] - fn esc_dismisses_console_only_when_terminal() { + fn esc_closes_overlay_while_running_dismisses_when_terminal() { + // Running: Esc closes the whole overlay (the read-only job keeps running). let mut d = Some(ExamineManagerState::default()); let mut jobs = State::default(); on_key(&mut d, &mut jobs, key(KeyCode::Enter)); - // Running: Esc does not dismiss. - on_key(&mut d, &mut jobs, key(KeyCode::Esc)); assert!(d.as_ref().unwrap().active_job.is_some()); - // Terminal: Esc returns to the intro card. + on_key(&mut d, &mut jobs, key(KeyCode::Esc)); + assert!(d.is_none(), "Esc closes the overlay while the job runs"); + // Terminal: Esc returns to the intro card (overlay stays open). + let mut d = Some(ExamineManagerState::default()); + let mut jobs = State::default(); + on_key(&mut d, &mut jobs, key(KeyCode::Enter)); jobs.apply(StateEvent::JobDone { id: "examine".into(), code: 0, diff --git a/crates/rocm-dash-tui/src/ui/job_console.rs b/crates/rocm-dash-tui/src/ui/job_console.rs index 1ad7c3fd..753acf58 100644 --- a/crates/rocm-dash-tui/src/ui/job_console.rs +++ b/crates/rocm-dash-tui/src/ui/job_console.rs @@ -49,6 +49,14 @@ pub fn on_console_key(job_id: &str, jobs: &mut State, key: KeyEvent) -> ConsoleO // `q` always closes the overlay so the user is never trapped mid-job // (the job keeps running in the background). KeyCode::Char('q') => ConsoleOutcome::Closed, + // Esc on a still-running job leaves the overlay (the job keeps running in + // the background) — the conventional "get me out" key, so the user is + // never trapped during a long step (e.g. a managed serve readiness wait). + KeyCode::Esc if jobs.job(job_id).is_some_and(|j| !j.is_terminal()) => { + ConsoleOutcome::Closed + } + // On a finished (or vanished) job, Esc/Enter dismiss the console back to + // the screen body. KeyCode::Esc | KeyCode::Enter if jobs .job(job_id) @@ -124,9 +132,12 @@ pub fn draw_job_console( .collect(); f.render_widget(Paragraph::new(lines).scroll(scroll), rows[1]); - // Footer: key hints (cancel only while running). + // Footer: key hints. While running, Esc leaves the overlay (the job keeps + // running in the background) and Ctrl+C cancels — advertise both so the user + // is never left feeling trapped during a long step (e.g. a managed serve's + // readiness wait). let hints = if matches!(job.status, JobStatus::Running) { - "Ctrl+C cancel · wheel / PgUp·PgDn scroll" + "Esc close (keeps running) · Ctrl+C cancel · wheel / PgUp·PgDn scroll" } else { "Enter/Esc close · wheel / PgUp·PgDn scroll" }; @@ -172,7 +183,7 @@ mod tests { on_console_key("j", &mut s, k(KeyCode::Char('q'))), ConsoleOutcome::Closed )); - // Esc on a RUNNING job is not a dismiss (Unhandled). + // Esc on a RUNNING job closes the overlay (job keeps running in the bg). let mut s2 = State::default(); s2.apply(StateEvent::StartJob { id: "j".into(), @@ -181,6 +192,11 @@ mod tests { }); assert!(matches!( on_console_key("j", &mut s2, k(KeyCode::Esc)), + ConsoleOutcome::Closed + )); + // Enter on a RUNNING job stays Unhandled (avoids accidental dismissal). + assert!(matches!( + on_console_key("j", &mut s2, k(KeyCode::Enter)), ConsoleOutcome::Unhandled )); // Esc on a TERMINAL job dismisses. diff --git a/crates/rocm-dash-tui/src/ui/serve_wizard.rs b/crates/rocm-dash-tui/src/ui/serve_wizard.rs index 142fbc3e..236017d1 100644 --- a/crates/rocm-dash-tui/src/ui/serve_wizard.rs +++ b/crates/rocm-dash-tui/src/ui/serve_wizard.rs @@ -728,19 +728,27 @@ mod tests { } #[test] - fn esc_is_ignored_while_job_is_running_then_dismisses_when_terminal() { + fn esc_closes_overlay_while_running_then_dismisses_when_terminal() { + // Running: Esc leaves the whole overlay (the launch keeps running in the + // background) — the user is never trapped during the readiness wait. let mut wiz = Some(ServeWizardState::default()); let mut jobs = State::default(); wiz.as_mut().unwrap().model = "m".into(); wiz.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('y'))); - let job_id = wiz.as_ref().unwrap().active_job.clone().unwrap(); - // Job is Running: Esc must NOT dismiss the console or close the overlay. + assert!(wiz.as_ref().unwrap().active_job.is_some()); on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Esc)); - assert_eq!(wiz.as_ref().unwrap().active_job.as_ref(), Some(&job_id)); - assert!(wiz.is_some()); - // Once terminal, Esc dismisses the console back to the form. + assert!(wiz.is_none(), "Esc closes the overlay even mid-job"); + + // Terminal: Esc dismisses the console back to the form (wizard stays open). + let mut wiz = Some(ServeWizardState::default()); + let mut jobs = State::default(); + wiz.as_mut().unwrap().model = "m".into(); + wiz.as_mut().unwrap().field = FIELDS.iter().position(|f| *f == Field::Launch).unwrap(); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Enter)); + on_key(&mut wiz, &mut jobs, &[], key(KeyCode::Char('y'))); + let job_id = wiz.as_ref().unwrap().active_job.clone().unwrap(); jobs.apply(StateEvent::JobDone { id: job_id, code: 0, @@ -749,7 +757,7 @@ mod tests { assert!(wiz.as_ref().unwrap().active_job.is_none()); assert!( wiz.is_some(), - "dismissing the console keeps the wizard open" + "dismissing a finished console keeps the wizard open" ); } diff --git a/docs/testing.md b/docs/testing.md index fb150e08..8fc28ecc 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -84,46 +84,9 @@ cargo test -p rocm-core examine_render cargo test -p rocm-core managed_sdk_probe cargo test -p rocm --bin rocm examine_runtime_state_reports_ambiguous_default_runtime_id cargo test -p rocm --bin rocm engine_runtime_selection_rejects_ambiguous_default_runtime_id -cargo test -p rocm --bin rocm first_run_shows_dedicated_setup_screen_when_rocm_is_not_installed -cargo test -p rocm --bin rocm onboarding_enter_requests_rocm_install_with_folder_prefix -cargo test -p rocm --bin rocm setup_subcommands_do_not_expose_old_runtime_engine_wizard cargo test -p rocm --bin rocm permissions_ ``` -Focused TUI command navigability coverage: - -```bash -cargo test -p rocm --bin rocm tui::tests:: -- --nocapture -cargo test -p rocm tui::tests::advertised_slash_commands_ -- --nocapture -cargo test -p rocm tui::tests::slash_subcommands_stay_on_navigable_surfaces_without_transcript_dumps -- --nocapture -cargo test -p rocm tui::tests::slash_command_variants_stay_navigable_without_transcript_dumps -- --nocapture -cargo test -p rocm tui::tests::slash_actions_stream_inside_active_surface_without_transcript_dump -- --nocapture -cargo test -p rocm tui::tests::slash_first_views_hide_backend_jargon -- --nocapture -cargo test -p rocm tui::tests::install_sdk_bad_format_uses_friendly_labels_without_backend_jargon -- --nocapture -cargo test -p rocm tui::tests::onboarding_ctrl_c_during_install_confirms_before_cancelling -- --nocapture -cargo test -p rocm tui::tests::automations_review_actions_without_review_show_body_guidance -- --nocapture -cargo test -p rocm tui::tests::examine_background_completion_updates_report_without_transcript -- --nocapture -cargo test -p rocm tui::tests::hidden_examine_refresh_does_not_block_next_prompt_commands -- --nocapture -cargo test -p rocm tui::tests::examine_command_does_not_steal_running_workflow_screens -- --nocapture -cargo test -p rocm tui::tests::examine_command_does_not_hide_pending_approval -- --nocapture -cargo test -p rocm tui::tests::daemon_status_uses_background_helper_language -- --nocapture -cargo test -p rocm tui::tests::automations_bad_watcher_and_mode_stay_screen_local -- --nocapture -cargo test -p rocm tui::tests::services_manager_stop_requests_rocmd_approval_without_transcript -- --nocapture -cargo test -p rocm tui::tests::services_command_restart_requests_rocmd_approval_without_transcript -- --nocapture -cargo test -p rocm --bin rocm services_is_structured_not_freeform -- --nocapture -cargo test -p rocm --bin rocm top_level_cli_commands_are_not_treated_as_freeform -- --nocapture -cargo test -p rocm --bin rocm render_model_registry_text_lists_builtin_recipes -- --nocapture -cargo test -p rocm --bin rocm plain_command_routing_distinguishes_commands_from_requests -- --nocapture -cargo test -p rocm --bin rocm plain_structured_commands_route_to_navigable_tui_before_plan -- --nocapture -cargo test -p rocm --bin rocm render_services_text_separates_ready_starting_and_past_attempts -- --nocapture -cargo test -p rocm --bin rocm service_actions_require_yes_and_render_sandbox_result -- --nocapture -cargo test -p rocm tui::tests::runtimes_import_without_args_opens_guided_form -- --nocapture -cargo test -p rocm tui::tests::approval_card_is_arrow_key_navigable_and_scrollable -- --nocapture -cargo test -p rocm tui::tests::typed_serve_prefills_wizard_and_start_requests_approval_without_prompt_editing -- --nocapture -cargo test -p rocm tui::tests::explicit_foreground_serve_prefills_wizard_without_launching -- --nocapture -cargo test -p rocm tui::tests::tui_help_teaches_engine_not_engines -- --nocapture -``` - Self-hosted GPU CI smoke is intentionally non-mutating. The MI300X job builds the workspace, runs `rocm examine`, then runs `detect` and `capabilities` for all first-party engine adapters: PyTorch, llama.cpp, ATOM, vLLM, and SGLang. @@ -424,7 +387,6 @@ cargo test -p rocm --bin rocm missing_packaged_engine_reason cargo test -p rocm --bin rocm model_registry cargo test -p rocm --bin rocm render_model_registry_text_reports_host_ram_fit cargo test -p rocm --bin rocm model_recipe -cargo test -p rocm --bin rocm model_completion cargo test -p rocm --bin rocm logs cargo test -p rocm-core model_recipe cargo test -p rocm-core load_model_recipe_index @@ -483,10 +445,6 @@ TUI log pagination focused tests: ```bash cargo test -p rocm --bin rocm logs_browser -cargo test -p rocm --bin rocm logs_next -cargo test -p rocm --bin rocm logs_completion_suggests_service_flag_before_service_ids -cargo test -p rocm --bin rocm logs_tui_hides_file_locations_until_user_reveals_them -cargo test -p rocm --bin rocm service_log_details_scroll_without_changing_selected_action ``` After opening `/logs` or `/logs --search ` in the TUI, use the arrow-key @@ -578,7 +536,6 @@ cargo test -p rocmd sandbox_tool_stop_server_reports_missing_service cargo test -p rocmd sandbox_tool_stop_server_updates_manifest_and_skips_current_pid cargo test -p rocmd sandbox_tool_notify_user_is_read_only cargo test -p rocmd sandbox_runner_native_fallback_records_audit -cargo test -p rocm --bin rocm proposal_sandbox_args_support_update_check ``` These cover the plan-listed restricted internal tool API: `check_updates`, @@ -698,52 +655,16 @@ tests use local HTTP fixtures to prove OpenAI and Anthropic send `stream: true` requests and deliver the first SSE delta to the callback before the server closes the connection. -TUI focused approval tests: - -```bash -cargo test -p rocm --bin rocm onboarding_pending_approval_shows_plain_install_details -cargo test -p rocm --bin rocm onboarding_custom_folder_approval_uses_selected_folder -cargo test -p rocm --bin rocm onboarding_folder_rejects_system_folder -cargo test -p rocm --bin rocm onboarding_folder_rejects_file_path -cargo test -p rocm --bin rocm onboarding_folder_rejects_missing_parent -cargo test -p rocm --bin rocm onboarding_install_current_step_covers_pre_pip_phases -cargo test -p rocm --bin rocm onboarding_running_install_empty_log_uses_general_starting_text -cargo test -p rocm --bin rocm onboarding_failed_install_shows_full_log_path_for_noisy_output -cargo test -p rocm --bin rocm render_command_output_keeps_untruncated_output_for_full_logs -cargo test -p rocm --bin rocm empty_enter_approves_focused_proposal -cargo test -p rocm --bin rocm restart_proposal_approval_uses_plain_review_language -cargo test -p rocm --bin rocm prefetch_proposal_approval_shows_plain_artifact_download_controls -cargo test -p rocm --bin rocm driver_plan_proposal_approval_says_no_driver_install +Proposal history rendering: + +```bash cargo test -p rocm --bin rocm render_automations_text_uses_plain_proposal_history -cargo test -p rocm --bin rocm tab_cycles_and_enter_accepts_slash_arguments -cargo test -p rocm --bin rocm approval_status_says_enter_or_y_approve -cargo test -p rocm --bin rocm esc_rejects_focused_proposal -``` - -The onboarding approval test verifies that first-run setup shows the selected -install folder, the downloads/cache folder, TheRock release wheel source, -package summary, and plain approve/cancel controls before installing ROCm. -The install-step test verifies that pre-pip phases render friendly labels for -checking ROCm packages, checking/downloading/installing Python, creating the -Python environment, installing pip, installing ROCm packages, and the final SDK -check. -The folder validation tests verify that setup rejects obvious system folders, -file paths, and folders whose parent does not exist before the installer starts. -The noisy-failure test verifies that long setup installs still save a complete -log under `data/logs/setup/` and show that full-log path on the failure screen. -The command-output test verifies that command rendering keeps the raw output -available for full logs while the transcript display remains bounded by the -separate truncation step. -The proposal approval tests verify that restart, artifact prefetch, and driver -plan reviews use plain-language sections and do not expose backend watcher, -action, tool, service id, sandbox-run, or raw internal tool names in the -default review card. +``` + The plain proposal history test verifies that `/automations` and `rocm automations` show recent review requests with friendly action, reason, server/artifact details, and approve/reject controls instead of raw backend action or restricted-tool names. -Enter accepts the highlighted completion first. If no completion is accepted -and a focused approval is visible with an empty input, Enter approves it. Natural-language plan handoff tests: @@ -757,7 +678,6 @@ cargo test -p rocm --bin rocm freeform_execution_validation_rejects_placeholder_ cargo test -p rocm --bin rocm freeform_execution_header_surfaces_explicit_approval_and_tool_call cargo test -p rocm --bin rocm hybrid_planner_driver_action_includes_yes_for_approved_execution cargo test -p rocm --bin rocm render_freeform_plan_exposes_structured_tool_calls -cargo test -p rocm --bin rocm natural_serve_command_renders_llama_plan cargo test -p rocm --bin rocm natural_serve_with_missing_model_does_not_focus_approval ``` @@ -798,45 +718,16 @@ sent in-process so API keys are not passed through `curl` command arguments. In the TUI, clearing a saved API key defaults to `Cancel`; users must move the highlight to `Clear API key` before Enter removes anything. -Focused TUI provider-key coverage: - -```bash -cargo test -p rocm --bin rocm config_clear_provider_key_requires_arrow_key_confirmation -cargo test -p rocm --bin rocm config_provider_key_storage_failure_shows_retry_guidance -``` - Update-surface report test: ```bash cargo test -p rocm --bin rocm render_update_text_reports_all_update_surfaces -cargo test -p rocm --bin rocm plain_update_report_detects_status_update_available ``` `rocm update` must report runtime checks plus CLI, engine, and model-recipe surfaces honestly. `rocm update --apply` remains runtime-only until production metadata feeds exist for the other surfaces. -TUI mode-state checks: - -```bash -cargo test -p rocm tui_mode -cargo test -p rocm natural_serve_command_renders_llama_plan -cargo test -p rocm logs_follow -cargo test -p rocm --bin rocm advertised_slash_commands -cargo test -p rocm --bin rocm manager_screens_page_keys_scroll_details_without_changing_selection -cargo test -p rocm --bin rocm approve_and_reject_commands_open_review_before_mutating -cargo test -p rocm --bin rocm slash_completion_advertises_guided_model_and_install_defaults -``` - -The sidebar must show the current mode (`ask`, `act`, `serve`, `logs`, or -`automations`), mode changes must not launch commands in tests, and `/logs -follow [query]` must refresh the current log browser without creating first-run -log directories. Advertised TUI commands should keep arrow-key focus on the -visible screen even after accidental prompt typing, manager PageUp/PageDown -should scroll details without changing the highlighted row, and automation -review approve/reject commands should open the visible review card before -mutating proposal status. - Model recipe engine metadata checks: ```bash @@ -928,10 +819,6 @@ cargo test -p rocmd local_webhook_cache_warm_rejects_other_cache_events cargo test -p rocmd cache_warm_propose_mode_queues_prefetch_proposal cargo test -p rocmd cache_warm_unknown_artifact_does_not_queue_proposal cargo test -p rocmd cache_warm_contained_mode_still_requires_reviewed_source_policy -cargo test -p rocm --bin rocm proposal_sandbox_args_support_cache_warm_prefetch -cargo test -p rocm --bin rocm proposal_sandbox_args_support_cache_warm_prefetch_source_policy -cargo test -p rocm --bin rocm proposal_sandbox_args_reject_cache_warm_download_without_max_bytes -cargo test -p rocm --bin rocm proposal_edit_updates_prefetch_source_policy ``` The `cache-warm` watcher accepts exactly `cache.warm`, requires @@ -952,7 +839,6 @@ cargo test -p rocmd driver_upgrade_contained_mode_runs_restricted_driver_plan cargo test -p rocmd driver_upgrade_contained_mode_requires_restricted_driver_plan_tool cargo test -p rocmd sandbox_driver_plan_value_is_read_only_and_preserves_output cargo test -p rocm-core builtin_watchers_include_reviewed_driver_upgrade -cargo test -p rocm --bin rocm proposal_sandbox_args_support_driver_upgrade_plan ``` The `driver-upgrade` watcher accepts exactly `update.available` with