From 586857e801d0ddc4367ec683852f463eae3515dc Mon Sep 17 00:00:00 2001 From: Jussi Elo Date: Thu, 1 Oct 2026 10:18:18 +0000 Subject: [PATCH 1/6] ROCMAI-84: Modularize crates/rocm-dash-tui/src/app/mod.rs Split the 8,967-line app/mod.rs into app/{types,event_loop,scrollbar,actions}.rs following the full-domain-extraction convention, keeping mod.rs to AppState and its apply_event/apply_action-adjacent reducer impl. crate::app::* paths for everything moved out are preserved via re-exports. Updates the architecture module map in the same PR per the modularization plan's rules. Signed-off-by: Jussi Elo --- crates/rocm-dash-tui/src/app/actions.rs | 1822 ++++++ crates/rocm-dash-tui/src/app/event_loop.rs | 2594 +++++++++ crates/rocm-dash-tui/src/app/mod.rs | 6068 +------------------- crates/rocm-dash-tui/src/app/scrollbar.rs | 930 +++ crates/rocm-dash-tui/src/app/types.rs | 459 ++ docs/architecture.md | 2 +- 6 files changed, 6002 insertions(+), 5873 deletions(-) create mode 100644 crates/rocm-dash-tui/src/app/actions.rs create mode 100644 crates/rocm-dash-tui/src/app/event_loop.rs create mode 100644 crates/rocm-dash-tui/src/app/scrollbar.rs create mode 100644 crates/rocm-dash-tui/src/app/types.rs diff --git a/crates/rocm-dash-tui/src/app/actions.rs b/crates/rocm-dash-tui/src/app/actions.rs new file mode 100644 index 000000000..1810b2c1d --- /dev/null +++ b/crates/rocm-dash-tui/src/app/actions.rs @@ -0,0 +1,1822 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! `KeyAction` dispatch: translating a key press (or a resolved mouse hit) +//! into a `KeyAction`, and applying it to reducer state. Split out of +//! `app/mod.rs` to keep the core reducer + event loop focused. + +use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind}; + +use crate::ui; + +use super::AppState; +use super::scrollbar::{PaneFocus, ScrollTarget}; +use super::summary::summarize_json_value; +use super::types::{ActiveTab, ChatConsent, ChatKeyCtx, Modal}; +#[cfg(test)] +use super::types::{ChatProvider, ChatRole}; + +/// Lines per PageUp/PageDown step in the chat transcript. +const CHAT_SCROLL_STEP: i16 = 5; + +/// Run an approved mutating action across the seam and render a concise summary +/// (never a raw JSON dump). Sync + executor-generic so the approve path is +/// unit-testable without tokio; the event loop calls it inside spawn_blocking. +pub(crate) fn run_approved( + executor: &crate::tool_exec::SharedRocmToolExecutor, + name: &str, + args: &serde_json::Value, +) -> String { + use crate::tool_exec::RocmToolOutcome; + match executor.execute_approved(name, args) { + RocmToolOutcome::Result(v) => { + let body = summarize_json_value(&v); + if body.is_empty() { + format!("Approved · {name}: done") + } else { + format!("Approved · {name}:\n{body}") + } + } + RocmToolOutcome::Error(e) => format!("Approved · {name} failed: {e}"), + // A mutating tool's approved replay should not re-request approval; if it + // somehow does, surface it plainly rather than silently looping. + RocmToolOutcome::ApprovalRequired(_) => { + format!("Approved · {name}: unexpected second approval request (not run)") + } + } +} + +/// Wrap a list cursor by `delta`, cycling within `0..len`. `len == 0` → 0. +const fn wrap_cursor(cur: usize, delta: isize, len: usize) -> usize { + if len == 0 { + return 0; + } + let n = len.cast_signed(); + (cur.cast_signed() + delta).rem_euclid(n) as usize +} + +/// Apply a `KeyAction` to mutable state. Returns `true` when the action +/// requests application exit (Quit). +pub(crate) fn apply_action(state: &mut AppState, action: KeyAction) -> bool { + match action { + KeyAction::Quit => return true, + KeyAction::SwitchTab(t) => { + state.active_tab = t; + state.modal = Modal::None; + // A fresh tab always starts with focus on its Actions list, never + // stranded in the Details pane from a previous visit. + state.pane_focus = PaneFocus::Actions; + } + KeyAction::Move(d) => { + if state.modal == Modal::ThemePicker { + state.theme_picker_move(d); + } else { + // Changing the verb selection snaps focus back to the Actions + // list so Details re-previews the newly selected operation. + if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { + state.pane_focus = PaneFocus::Actions; + } + state.move_selection(d); + } + } + KeyAction::PaneFocusDetail => { + if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { + state.pane_focus = PaneFocus::Detail; + } + } + KeyAction::PaneFocusActions => { + if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { + state.pane_focus = PaneFocus::Actions; + } + } + KeyAction::PaneActivate => { + if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { + match state.pane_focus { + // From the Actions list, Enter steps INTO the Details pane. + PaneFocus::Actions => state.pane_focus = PaneFocus::Detail, + // From Details, Enter opens the operation's manager. + PaneFocus::Detail => { + let verb = state.pane_verb_action(); + return apply_action(state, verb); + } + } + } + } + KeyAction::PaneEscape => { + // Esc backs out one level: Details → Actions, then Actions → menu. + if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) + && state.pane_focus == PaneFocus::Detail + { + state.pane_focus = PaneFocus::Actions; + } else { + return apply_action(state, KeyAction::OpenMenu); + } + } + KeyAction::PaneSelect(i) => { + if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { + let last = state.pane_verb_count().saturating_sub(1); + state.set_selection(state.active_tab, i.min(last)); + state.pane_focus = PaneFocus::Actions; + } + } + KeyAction::SelectFirst => { + if state.modal == Modal::ThemePicker { + state.theme_picker_first(); + } else { + state.select_first(); + } + } + KeyAction::SelectLast => { + if state.modal == Modal::ThemePicker { + state.theme_picker_last(); + } else { + state.select_last(); + } + } + KeyAction::OpenDetail => { + if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { + // Verb rows open the matching manager via the existing seam; + // there is no detail modal on the ROCm/Serving tabs. + let verb = state.pane_verb_action(); + return apply_action(state, verb); + } + if state.selection_len() > 0 { + state.modal = Modal::Detail; + state.reset_instance_detail_scroll(); + } + } + KeyAction::ToggleHelp => { + state.modal = if state.modal == Modal::Help { + Modal::None + } else { + state.close_overlays(); + Modal::Help + }; + } + KeyAction::CloseModal => state.modal = Modal::None, + // The operational overlays are mutually exclusive: opening any one first + // closes the rest (see `close_overlays`), so no open path — key, mouse, + // or effect — can ever leave two `Some` at once. + KeyAction::OpenServices => { + state.close_overlays(); + state.services = Some(crate::ui::services_manager::ServicesManagerState::default()); + } + KeyAction::OpenServeWizard => { + state.close_overlays(); + state.serve_wizard = Some(crate::ui::serve_wizard::ServeWizardState::default()); + } + KeyAction::OpenEngineManager => { + state.close_overlays(); + state.engine_manager = Some(crate::ui::engine_manager::EngineManagerState::default()); + } + KeyAction::OpenExamine => { + state.close_overlays(); + state.examine_manager = + Some(crate::ui::examine_manager::ExamineManagerState::default()); + } + KeyAction::OpenUpdate => { + state.close_overlays(); + state.update_manager = Some(crate::ui::update_manager::UpdateManagerState::default()); + } + KeyAction::OpenInstall => { + state.close_overlays(); + state.install_manager = + Some(crate::ui::install_manager::InstallManagerState::default()); + } + KeyAction::OpenLogs => { + state.close_overlays(); + state.logs_view = Some(crate::ui::logs_view::LogsViewState::default()); + } + KeyAction::OpenRuntimes => { + state.close_overlays(); + state.runtime_manager = + Some(crate::ui::runtime_manager::RuntimeManagerState::default()); + } + KeyAction::OpenOnboarding => { + state.close_overlays(); + state.onboarding = Some(crate::ui::onboarding::OnboardingState::default()); + } + KeyAction::OpenAutomations => { + state.close_overlays(); + state.automations_manager = + Some(crate::ui::automations_manager::AutomationsManagerState::default()); + } + KeyAction::OpenCommand => { + state.close_overlays(); + state.command_screen = Some(crate::ui::command_screen::CommandScreenState::default()); + } + KeyAction::OpenConfig => { + state.close_overlays(); + state.config_manager = Some(crate::ui::config_manager::ConfigManagerState::default()); + } + KeyAction::OpenBenchRun => { + let bench_csv = state.bench_results_dir.clone(); + state.close_overlays(); + state.bench_run = Some(crate::ui::bench_run::BenchRunState::new( + bench_csv.as_deref(), + )); + } + KeyAction::OpenThemePicker => state.open_theme_picker(), + KeyAction::ApplyThemePick => state.apply_theme_pick(), + KeyAction::OpenMenu => { + state.modal = Modal::Menu; + state.menu_sel = 0; + } + KeyAction::OpenPalette => { + state.modal = Modal::Palette; + state.palette_sel = 0; + } + KeyAction::MenuMove(d) => match state.modal { + Modal::Menu => { + state.menu_sel = wrap_cursor(state.menu_sel, d, crate::ui::modal::MENU_ITEMS); + } + Modal::Palette => { + state.palette_sel = + wrap_cursor(state.palette_sel, d, crate::ui::modal::PALETTE_DESTS.len()); + } + _ => {} + }, + KeyAction::OptionsTab(d) => { + if state.modal == Modal::Options { + state.options_tab = + wrap_cursor(state.options_tab, d, crate::ui::modal::OPTIONS_TABS.len()); + } + } + KeyAction::MenuActivate => match state.modal { + Modal::Menu => match state.menu_sel { + 0 => { + state.modal = Modal::Options; + state.options_tab = 0; + } + 1 => { + state.modal = Modal::GlobalHelp; + } + _ => return true, // Quit + }, + Modal::Palette => { + if let Some((_, tab)) = crate::ui::modal::PALETTE_DESTS.get(state.palette_sel) { + state.active_tab = *tab; + } + state.modal = Modal::None; + } + _ => {} + }, + KeyAction::ScrollModal(delta) if state.modal == Modal::Detail => { + state.scroll_instance_detail(delta); + } + KeyAction::ScrollModal(_) => {} + KeyAction::ScrollConsole(dv, dh) => state.scroll_console(dv, dh), + KeyAction::ScrollDock(dv) => state.scroll_dock(dv), + KeyAction::ScrollGrab(target, pos, grab_offset) => { + state.apply_scroll_grab(target, pos, grab_offset); + } + KeyAction::ScrollRelease => state.scroll_drag = None, + KeyAction::ReplayTogglePause => { + if let Some(r) = state.replay.as_mut() { + r.paused = !r.paused; + if r.paused { + r.controller.pause(); + } else { + r.controller.resume(); + } + } + } + KeyAction::ReplaySpeedUp => { + if let Some(r) = state.replay.as_mut() { + r.speed = crate::replay::next_speed(r.speed); + r.controller.set_speed(r.speed); + } + } + KeyAction::ReplaySpeedDown => { + if let Some(r) = state.replay.as_mut() { + r.speed = crate::replay::prev_speed(r.speed); + r.controller.set_speed(r.speed); + } + } + KeyAction::ReplayJump(delta_s) => { + if let Some(r) = state.replay.as_ref() { + r.controller.jump(delta_s); + } + } + KeyAction::ChatInput(c) => state.chat_input.push(c), + KeyAction::ChatBackspace => { + state.chat_input.pop(); + } + KeyAction::ChatSubmit => state.submit_chat(), + KeyAction::ChatFocus => state.chat_focused = true, + KeyAction::ChatBlur => state.chat_focused = false, + KeyAction::ChatConsentAccept => state.accept_chat_consent(), + KeyAction::ChatConsentDecline => state.decline_chat_consent(), + KeyAction::ChatDetect => state.request_detect(), + KeyAction::ChatDetectAccept => state.accept_detect_offer(), + KeyAction::ChatDetectSave => state.save_detect_offer(), + KeyAction::ChatDetectDismiss => state.dismiss_detect_offer(), + KeyAction::ChatScroll(d) => { + let next = (i32::from(state.chat_scroll) + i32::from(d)).max(0) as usize; + state.set_chat_scroll(next); + } + KeyAction::Nothing => {} + } + false +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum KeyAction { + Nothing, + Quit, + SwitchTab(ActiveTab), + /// ROCm/Serving tab: move focus into the Details pane (`→`). + PaneFocusDetail, + /// ROCm/Serving tab: move focus back to the Actions list (`←`). + PaneFocusActions, + /// ROCm/Serving tab: activate the current focus — from the Actions list, + /// focus the Details pane; from Details, open the operation's manager. + PaneActivate, + /// ROCm/Serving tab: Esc — step out of Details back to the Actions list, or, + /// when already on the list, fall through to the main menu. + PaneEscape, + /// ROCm/Serving tab: select the verb at this index and park focus on the + /// Actions list (from a mouse click on a verb row). + PaneSelect(usize), + Move(isize), + SelectFirst, + SelectLast, + OpenDetail, + ToggleHelp, + CloseModal, + OpenThemePicker, + ApplyThemePick, + /// Vertical scroll inside the active modal body (positive = down). + ScrollModal(i16), + /// Pan the active job console: `(vertical_lines, horizontal_cols)`, negative + /// = up/left. No-op when no console is showing. + ScrollConsole(i16, i16), + /// Scroll the wide-layout right LOGS dock by N lines (negative = toward the + /// newest line). No-op when the dock isn't showing. + ScrollDock(i16), + /// Grab a scrollbar at `position`, retaining the pointer's offset inside the + /// thumb so subsequent drag events track without a jump. + ScrollGrab(ScrollTarget, usize, u16), + /// Release the active scrollbar drag (mouse button up). + ScrollRelease, + /// Toggle replay pause / resume. No-op when not replaying. + ReplayTogglePause, + /// Step replay speed up or down (clamped). No-op when not replaying. + ReplaySpeedUp, + ReplaySpeedDown, + /// Move the replay playhead by `delta_s` seconds (negative = rewind). + ReplayJump(i64), + /// Chat insert-mode: append a character to `chat_input`. + ChatInput(char), + /// Chat insert-mode: pop the last character from `chat_input`. + ChatBackspace, + /// Chat: submit the current input buffer as a user turn. + ChatSubmit, + /// Chat: enter text-entry focus. + ChatFocus, + /// Chat: leave text-entry focus. + ChatBlur, + /// Chat: accept the detected endpoint (one-time consent). + ChatConsentAccept, + /// Chat: decline the detected endpoint. + ChatConsentDecline, + /// Chat: probe for a local engine and offer it (in-TUI auto-detect). + ChatDetect, + /// Chat: accept the detected local endpoint for this session. + ChatDetectAccept, + /// Chat: accept the detected endpoint and persist it to config. + ChatDetectSave, + /// Chat: dismiss the detected-endpoint offer, keeping the prior config. + ChatDetectDismiss, + /// Chat: scroll the transcript by N lines (positive = down). + ChatScroll(i16), + /// Open the btop-style Esc main menu (P4). + OpenMenu, + /// Open the "Go to…" command palette (P4). + OpenPalette, + /// Move the cursor within the active overlay (Menu / Palette) by N rows. + MenuMove(isize), + /// Cycle the Options panel's tab by N (left/right). + OptionsTab(isize), + /// Activate the highlighted row in the active overlay (Menu / Palette). + MenuActivate, + /// Open the services-manager overlay (Phase 3 Wave 1). + OpenServices, + /// Open the serve-wizard overlay (Phase 3 Wave 1). + OpenServeWizard, + /// Open the engine-manager overlay (Phase 3 Wave 1). + OpenEngineManager, + /// Open the examine overlay (Phase 3 Wave 2). + OpenExamine, + /// Open the update overlay (Phase 3 Wave 2). + OpenUpdate, + /// Open the install overlay (Phase 3 Wave 2). + OpenInstall, + /// Open the runtime manager overlay. + OpenRuntimes, + /// Open the onboarding wizard overlay. + OpenOnboarding, + /// Open the automations manager overlay. + OpenAutomations, + /// Open the command runner overlay. + OpenCommand, + /// Open the config & provider manager overlay. + OpenConfig, + /// Open the logs overlay (Phase 3 Wave 3). + OpenLogs, + /// Open the bench-run form overlay. + OpenBenchRun, +} + +/// Whether a crossterm key event should be acted on. Terminals emit +/// Release/Repeat events in addition to Press (notably Windows Terminal / +/// ConPTY under WSL, and any terminal advertising the kitty keyboard protocol). +/// +/// The whole TUI acts on Press only. Both the general [`handle_key`] and the +/// event loop's operational-overlay dispatch share this gate — without it, a +/// single keystroke reaches an overlay's `on_key` more than once, which made +/// Enter in the serve wizard's model picker re-open the picker (seeded with the +/// just-chosen model as a filter) instead of choosing it. +pub(crate) const fn is_actionable_key(kind: KeyEventKind) -> bool { + matches!(kind, KeyEventKind::Press) +} + +pub(crate) fn handle_key( + k: KeyEvent, + current: ActiveTab, + modal: &Modal, + chat: ChatKeyCtx, +) -> KeyAction { + if !is_actionable_key(k.kind) { + return KeyAction::Nothing; + } + // Chat tab key handling, placed BEFORE the global hotkey match so focused + // text entry and the consent prompt absorb keys (the short-circuit that + // stops `q`, `1`–`5`, etc. from firing while typing / deciding consent). + if current == ActiveTab::Chat && *modal == Modal::None { + // A detected-endpoint offer (gate-only) absorbs its decision keys before + // the normal consent prompt: y use now, n/Esc dismiss. ([s] use & save + // is wired with persistence.) Other keys fall through to the globals. + if chat.offer_pending && chat.consent != ChatConsent::Accepted { + match k.code { + KeyCode::Char('y' | 'Y') | KeyCode::Enter => { + return KeyAction::ChatDetectAccept; + } + KeyCode::Char('s' | 'S') => { + return KeyAction::ChatDetectSave; + } + KeyCode::Char('n' | 'N') | KeyCode::Esc => { + return KeyAction::ChatDetectDismiss; + } + _ => {} + } + } + match chat.consent { + ChatConsent::Accepted => { + // History scroll works whether or not the input is focused. + match k.code { + KeyCode::PageUp => return KeyAction::ChatScroll(-CHAT_SCROLL_STEP), + KeyCode::PageDown => return KeyAction::ChatScroll(CHAT_SCROLL_STEP), + _ => {} + } + if chat.focused { + return match k.code { + KeyCode::Esc => KeyAction::ChatBlur, + KeyCode::Enter => KeyAction::ChatSubmit, + KeyCode::Backspace => KeyAction::ChatBackspace, + KeyCode::Char(c) => KeyAction::ChatInput(c), + _ => KeyAction::Nothing, + }; + } + // Not focused: `i`/`Enter` enter insert mode; other keys fall + // through to the global hotkeys below. + if let KeyCode::Char('i') | KeyCode::Enter = k.code { + return KeyAction::ChatFocus; + } + } + ChatConsent::Pending | ChatConsent::Declined => { + // Consent gate: y/Enter accept, n decline, d detect a local + // engine. Other keys (q, digits, Tab, ?) fall through to the + // globals so the user isn't trapped. + match k.code { + KeyCode::Char('y' | 'Y') | KeyCode::Enter => { + return KeyAction::ChatConsentAccept; + } + KeyCode::Char('n' | 'N') => { + return KeyAction::ChatConsentDecline; + } + KeyCode::Char('d' | 'D') => { + return KeyAction::ChatDetect; + } + _ => {} + } + } + // No endpoint configured: the only gate action is to detect one. + ChatConsent::Unavailable => { + if let KeyCode::Char('d' | 'D') = k.code { + return KeyAction::ChatDetect; + } + } + } + } + // ThemePicker is a navigable modal — j/k/g/G move the cursor, Enter applies. + if *modal == Modal::ThemePicker { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc | KeyCode::Char('t') => KeyAction::CloseModal, + KeyCode::Enter => KeyAction::ApplyThemePick, + KeyCode::Char('j') | KeyCode::Down => KeyAction::Move(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::Move(-1), + KeyCode::Char('g') | KeyCode::Home => KeyAction::SelectFirst, + KeyCode::Char('G') | KeyCode::End => KeyAction::SelectLast, + _ => KeyAction::Nothing, + }; + } + // Detail modal: vertical scroll keys, plus quit/close. + if *modal == Modal::Detail { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, + KeyCode::Char('j') | KeyCode::Down => KeyAction::ScrollModal(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::ScrollModal(-1), + KeyCode::PageDown => KeyAction::ScrollModal(10), + KeyCode::PageUp => KeyAction::ScrollModal(-10), + KeyCode::Char('g') | KeyCode::Home => KeyAction::ScrollModal(i16::MIN), + KeyCode::Char('G') | KeyCode::End => KeyAction::ScrollModal(i16::MAX), + _ => KeyAction::Nothing, + }; + } + // Help absorbs everything except quit / close / ? toggle — the popup is + // always sized to fit its content, so there's nothing to scroll. + if *modal == Modal::Help { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, + _ => KeyAction::Nothing, + }; + } + // Global help overlay (opened from the Esc menu): close, same as the + // contextual Help above. + if *modal == Modal::GlobalHelp { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, + _ => KeyAction::Nothing, + }; + } + // Esc main menu: ↑↓ cycle Options/Help/Quit, Enter activates, Esc closes. + if *modal == Modal::Menu { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc => KeyAction::CloseModal, + KeyCode::Char('j') | KeyCode::Down => KeyAction::MenuMove(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::MenuMove(-1), + KeyCode::Enter => KeyAction::MenuActivate, + _ => KeyAction::Nothing, + }; + } + // Command palette: ↑↓ choose destination, Enter goes, Esc closes. + if *modal == Modal::Palette { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc => KeyAction::CloseModal, + KeyCode::Char('j') | KeyCode::Down => KeyAction::MenuMove(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::MenuMove(-1), + KeyCode::Enter => KeyAction::MenuActivate, + _ => KeyAction::Nothing, + }; + } + // Options panel: ←→ switch settings tab, Esc closes. + if *modal == Modal::Options { + return match k.code { + KeyCode::Char('q') => KeyAction::Quit, + KeyCode::Esc => KeyAction::CloseModal, + KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => KeyAction::OptionsTab(-1), + KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => KeyAction::OptionsTab(1), + _ => KeyAction::Nothing, + }; + } + match k.code { + KeyCode::Char('q') => KeyAction::Quit, + // Esc opens the main menu when idle — managers/approval are routed + // upstream, and Chat-focused Esc is handled by the short-circuit above. + // On ROCm/Serving, Esc first steps out of the detail pane (resolved + // against focus in `apply_action`); elsewhere it opens the main menu. + KeyCode::Esc if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { + KeyAction::PaneEscape + } + KeyCode::Esc => KeyAction::OpenMenu, + KeyCode::Char(':') => KeyAction::OpenPalette, + KeyCode::Char('?') => KeyAction::ToggleHelp, + KeyCode::Char('t') => KeyAction::OpenThemePicker, + KeyCode::BackTab => KeyAction::SwitchTab(current.prev()), + KeyCode::Tab => { + if k.modifiers.contains(KeyModifiers::SHIFT) { + KeyAction::SwitchTab(current.prev()) + } else { + KeyAction::SwitchTab(current.next()) + } + } + KeyCode::Char(c @ '1'..='5') => match ActiveTab::from_digit(c) { + Some(t) => KeyAction::SwitchTab(t), + None => KeyAction::Nothing, + }, + KeyCode::PageDown => KeyAction::Move(10), + KeyCode::PageUp => KeyAction::Move(-10), + KeyCode::Char(' ') => KeyAction::ReplayTogglePause, + KeyCode::Char('+' | '=') => KeyAction::ReplaySpeedUp, + KeyCode::Char('-' | '_') => KeyAction::ReplaySpeedDown, + KeyCode::Char('[') => KeyAction::ReplayJump(-10), + KeyCode::Char(']') => KeyAction::ReplayJump(10), + KeyCode::Char('{') => KeyAction::ReplayJump(-60), + KeyCode::Char('}') => KeyAction::ReplayJump(60), + KeyCode::Char('j') | KeyCode::Down => KeyAction::Move(1), + KeyCode::Char('k') | KeyCode::Up => KeyAction::Move(-1), + KeyCode::Char('g') | KeyCode::Home => KeyAction::SelectFirst, + KeyCode::Char('G') | KeyCode::End => KeyAction::SelectLast, + // The guided-action letter hotkeys live ONLY on Observe (the telemetry + // surface) — quick jumps into the managers via the existing seam. On + // ROCm/Serving the Actions list is the single interaction path, so the + // per-tab letter hotkeys are retired there. + // Services manager: open where servers live. + KeyCode::Char('s') if current == ActiveTab::Observe => KeyAction::OpenServices, + // Serve wizard: launch a model. + KeyCode::Char('w') if current == ActiveTab::Observe => KeyAction::OpenServeWizard, + // Engine manager: use/install/reinstall serving engines. + KeyCode::Char('e') if current == ActiveTab::Observe => KeyAction::OpenEngineManager, + // Examine: read-only environment check. + KeyCode::Char('d') if current == ActiveTab::Observe => KeyAction::OpenExamine, + // Update: check/preview/apply ROCm package updates. + KeyCode::Char('u') if current == ActiveTab::Observe => KeyAction::OpenUpdate, + // Install: ROCm SDK (TheRock) install / dry-run. + KeyCode::Char('i') if current == ActiveTab::Observe => KeyAction::OpenInstall, + // Logs: browse recent ROCm CLI logs. + KeyCode::Char('l') if current == ActiveTab::Observe => KeyAction::OpenLogs, + // Bench-run: launch a bench sweep from the TUI. + KeyCode::Char('b') if current == ActiveTab::Observe => KeyAction::OpenBenchRun, + // Runtimes: list/activate/adopt/import ROCm runtimes. + KeyCode::Char('r') if current == ActiveTab::Observe => KeyAction::OpenRuntimes, + // Onboarding: first-run setup wizard (install / adopt). + KeyCode::Char('n') if current == ActiveTab::Observe => KeyAction::OpenOnboarding, + // Automations: list/enable/disable background checks. + KeyCode::Char('a') if current == ActiveTab::Observe => KeyAction::OpenAutomations, + // Command runner: run any ROCm CLI subcommand (gated). + KeyCode::Char('c') if current == ActiveTab::Observe => KeyAction::OpenCommand, + // Config & providers. + KeyCode::Char('p') if current == ActiveTab::Observe => KeyAction::OpenConfig, + // ROCm/Serving tabs: arrow keys drive the focus-into-detail interaction; + // Enter is focus-aware (list → focus detail, detail → open the manager). + KeyCode::Right if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { + KeyAction::PaneFocusDetail + } + KeyCode::Left if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { + KeyAction::PaneFocusActions + } + KeyCode::Enter if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { + KeyAction::PaneActivate + } + KeyCode::Enter => KeyAction::OpenDetail, + _ => KeyAction::Nothing, + } +} + +/// Translate a `MouseEvent` into the existing `KeyAction` vocabulary. +/// +/// The caller is responsible for the surrounding state context: +/// - `last_tab_bar_area` / `last_body_area` are read off `AppState` by the +/// event loop so this function stays pure on the input event. +/// - Per-tab body clicks are dispatched to the active tab module's +/// `hit_test` from the event loop. +/// +/// We only translate the parts of mouse handling that are tab-agnostic: +/// the scroll wheel, and (in the event loop) the tab-bar click. Per-tab +/// click is handled in tab modules. +/// Map a navigation key to a job-console pan delta `(lines, cols)` while a +/// console is showing. `None` for non-scroll keys so they fall through to the +/// console's own action handler (Ctrl+C / q / Esc / Enter). A page is 10 lines. +pub(crate) const fn console_scroll_delta(code: KeyCode) -> Option<(i16, i16)> { + match code { + KeyCode::PageDown => Some((10, 0)), + KeyCode::PageUp => Some((-10, 0)), + KeyCode::Down => Some((1, 0)), + KeyCode::Up => Some((-1, 0)), + KeyCode::Right => Some((0, 4)), + KeyCode::Left => Some((0, -4)), + _ => None, + } +} + +pub fn handle_mouse(ev: MouseEvent, modal: &Modal, tab: ActiveTab) -> KeyAction { + // Domain-tab (ROCm/Serving) and overlay/console scroll is resolved in + // `resolve_mouse` (it needs `&AppState` for hit-testing and overlay state). + // This handles the remaining position-independent targets: the scrollable + // modal body and the Observe instances list. One row per wheel notch. + let delta: i16 = match ev.kind { + MouseEventKind::ScrollDown => 1, + MouseEventKind::ScrollUp => -1, + _ => return KeyAction::Nothing, + }; + if *modal == Modal::Detail { + KeyAction::ScrollModal(delta) + } else if *modal == Modal::ThemePicker || (*modal == Modal::None && tab == ActiveTab::Observe) { + KeyAction::Move(delta as isize) + } else { + KeyAction::Nothing + } +} + +/// Resolve a left-click at `(x, y)` against `tab_bar_area`. Returns the tab +/// to switch to, or `None` if the click is outside or doesn't land on a chip. +/// +/// Uses [`ui::tabs::compute_chip_layout`] so the hit-test geometry exactly +/// mirrors what `draw_tab_bar` rendered. Separator gaps (` · `) between +/// chips are intentional dead zones — clicking the dot does nothing. +pub fn tab_bar_hit(tab_bar_area: ratatui::layout::Rect, x: u16, y: u16) -> Option { + if y != tab_bar_area.y { + return None; + } + let chips = ui::tabs::compute_chip_layout(tab_bar_area.x); + let bar_right = tab_bar_area.x.saturating_add(tab_bar_area.width); + for chip in chips { + if chip.x_end > bar_right { + // Chip overflows the bar — terminal too narrow to show it; skip. + continue; + } + if x >= chip.x_start && x < chip.x_end { + return Some(chip.tab); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::layout::Rect; + + fn press(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + fn hk(c: KeyCode, tab: ActiveTab) -> KeyAction { + handle_key(press(c), tab, &Modal::None, ChatKeyCtx::default()) + } + + #[test] + fn q_quits_esc_does_not() { + assert_eq!(hk(KeyCode::Char('q'), ActiveTab::Home), KeyAction::Quit); + // P4: Esc opens the main menu (it never quits). + assert_eq!(hk(KeyCode::Esc, ActiveTab::Observe), KeyAction::OpenMenu); + } + + #[test] + fn q_quits_menu_palette_and_options_too() { + // Menu/Palette/Options used to have no `q` arm at all, silently + // swallowing the key instead of quitting like every other modal. + let with_modal = |modal: &Modal| { + handle_key( + press(KeyCode::Char('q')), + ActiveTab::Home, + modal, + ChatKeyCtx::default(), + ) + }; + assert_eq!(with_modal(&Modal::Menu), KeyAction::Quit); + assert_eq!(with_modal(&Modal::Palette), KeyAction::Quit); + assert_eq!(with_modal(&Modal::Options), KeyAction::Quit); + } + + #[test] + fn chat_esc_then_q_still_quits_via_the_menu() { + // A terminal that decodes "Alt+q" as a bare Esc followed by a plain + // `q` (rather than a single Alt-modified KeyEvent) used to quit + // immediately on Chat, because Esc was a no-op there and `q` fell + // through to the global `Quit` arm. This PR makes Esc open the main + // menu on Chat too, so the second event now needs Menu's own `q` + // arm (added above) to still reach `Quit` instead of being + // swallowed by the menu. + let ctx = ChatKeyCtx { + consent: ChatConsent::Accepted, + focused: false, + ..Default::default() + }; + let after_esc = handle_key(press(KeyCode::Esc), ActiveTab::Chat, &Modal::None, ctx); + assert_eq!(after_esc, KeyAction::OpenMenu); + let after_q = handle_key( + press(KeyCode::Char('q')), + ActiveTab::Chat, + &Modal::Menu, + ctx, + ); + assert_eq!(after_q, KeyAction::Quit); + } + + #[test] + fn tab_cycles_forward_and_wraps() { + // 5-tab IA: Home → ROCm → Serving → Observe → Chat → Home. + assert_eq!( + hk(KeyCode::Tab, ActiveTab::Home), + KeyAction::SwitchTab(ActiveTab::Rocm) + ); + assert_eq!( + hk(KeyCode::Tab, ActiveTab::Serving), + KeyAction::SwitchTab(ActiveTab::Observe) + ); + assert_eq!( + hk(KeyCode::Tab, ActiveTab::Observe), + KeyAction::SwitchTab(ActiveTab::Chat) + ); + // Chat wraps back to Home. + assert_eq!( + hk(KeyCode::Tab, ActiveTab::Chat), + KeyAction::SwitchTab(ActiveTab::Home) + ); + } + + #[test] + fn action_tab_arrows_and_enter_drive_focus() { + // → steps into the detail pane, ← steps back, Enter is focus-aware. + assert_eq!( + hk(KeyCode::Right, ActiveTab::Rocm), + KeyAction::PaneFocusDetail + ); + assert_eq!( + hk(KeyCode::Left, ActiveTab::Rocm), + KeyAction::PaneFocusActions + ); + assert_eq!(hk(KeyCode::Enter, ActiveTab::Rocm), KeyAction::PaneActivate); + // Arrows are inert on other tabs (no focus model there). + assert_eq!(hk(KeyCode::Right, ActiveTab::Observe), KeyAction::Nothing); + // Enter elsewhere keeps its detail-modal meaning. + assert_eq!( + hk(KeyCode::Enter, ActiveTab::Observe), + KeyAction::OpenDetail + ); + } + + #[test] + fn action_activate_is_two_step_list_then_open() { + // Serving verb 0 = "Serve a model" → OpenServeWizard. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Serving; + s.serving_sel = 0; + assert_eq!(s.pane_focus, PaneFocus::Actions); + // First activate steps into the detail pane; no overlay yet. + apply_action(&mut s, KeyAction::PaneActivate); + assert_eq!(s.pane_focus, PaneFocus::Detail); + assert!(s.serve_wizard.is_none(), "must not open before stepping in"); + // Second activate opens the operation's manager. + apply_action(&mut s, KeyAction::PaneActivate); + assert!( + s.serve_wizard.is_some(), + "detail-focus Enter opens the manager" + ); + // ROCm verb 2 = "Diagnose (doctor)" → OpenExamine (the other mapping). + let mut r = AppState::new("t".into(), "default-dark".into()); + r.active_tab = ActiveTab::Rocm; + r.rocm_sel = 2; + r.pane_focus = PaneFocus::Detail; + apply_action(&mut r, KeyAction::PaneActivate); + assert!( + r.examine_manager.is_some(), + "ROCm Diagnose opens the doctor" + ); + } + + #[test] + fn action_focus_resets_on_move_and_tab_switch() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.pane_focus = PaneFocus::Detail; + apply_action(&mut s, KeyAction::Move(1)); + assert_eq!(s.pane_focus, PaneFocus::Actions, "Move snaps back to list"); + s.pane_focus = PaneFocus::Detail; + apply_action(&mut s, KeyAction::SwitchTab(ActiveTab::Home)); + assert_eq!(s.pane_focus, PaneFocus::Actions, "tab switch resets focus"); + } + + #[test] + fn action_esc_backs_out_of_detail_then_opens_menu() { + // Esc on Action is intercepted (not the global OpenMenu) so it can back + // out of the detail pane first. + assert_eq!(hk(KeyCode::Esc, ActiveTab::Rocm), KeyAction::PaneEscape); + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.pane_focus = PaneFocus::Detail; + apply_action(&mut s, KeyAction::PaneEscape); + assert_eq!(s.pane_focus, PaneFocus::Actions, "first Esc → list"); + assert_eq!(s.modal, Modal::None, "first Esc does not open the menu"); + apply_action(&mut s, KeyAction::PaneEscape); + assert_eq!(s.modal, Modal::Menu, "second Esc opens the menu"); + } + + #[test] + fn action_select_sets_verb_and_parks_on_list() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.pane_focus = PaneFocus::Detail; + apply_action(&mut s, KeyAction::PaneSelect(2)); + assert_eq!(s.rocm_sel, 2); + assert_eq!(s.pane_focus, PaneFocus::Actions); + // Out-of-range clamps rather than panicking. + apply_action(&mut s, KeyAction::PaneSelect(999)); + assert!(s.rocm_sel < crate::ui::tabs::rocm::VERB_COUNT); + } + + #[test] + fn inline_manager_opens_in_detail_then_backs_out() { + // Activating a ROCm verb opens its manager inline (focus stays in + // Details); `←`/Esc backs out — closing the manager and returning focus + // to the Actions list. This mirrors the event-loop back-out arm. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.rocm_sel = 0; // Set up / Install ROCm → OpenInstall + apply_action(&mut s, KeyAction::PaneActivate); // → Details + assert_eq!(s.pane_focus, PaneFocus::Detail); + assert!(!s.has_open_overlay(), "no manager before second activate"); + apply_action(&mut s, KeyAction::PaneActivate); // opens install_manager + assert!(s.install_manager.is_some(), "verb opens its manager inline"); + assert!(s.has_open_overlay()); + + // Esc backs out on a domain tab while a manager is open. + assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); + // `←` is left to the manager (it may cycle options), not a back-out. + assert!(!s.should_pane_back_out(crossterm::event::KeyCode::Left)); + // A normal key does not back out (routes to the manager instead). + assert!(!s.should_pane_back_out(crossterm::event::KeyCode::Char('j'))); + + // The event-loop arm closes the manager + parks focus on Actions. + s.close_overlays(); + s.pane_focus = PaneFocus::Actions; + assert!(!s.has_open_overlay(), "back-out closed the inline manager"); + assert_eq!(s.pane_focus, PaneFocus::Actions); + } + + #[test] + fn console_scroll_delta_maps_nav_keys_only() { + use crossterm::event::KeyCode; + assert_eq!(console_scroll_delta(KeyCode::PageDown), Some((10, 0))); + assert_eq!(console_scroll_delta(KeyCode::PageUp), Some((-10, 0))); + assert_eq!(console_scroll_delta(KeyCode::Down), Some((1, 0))); + assert_eq!(console_scroll_delta(KeyCode::Right), Some((0, 4))); + // Console action keys are NOT scroll keys (they reach on_console_key). + assert_eq!(console_scroll_delta(KeyCode::Esc), None); + assert_eq!(console_scroll_delta(KeyCode::Enter), None); + assert_eq!(console_scroll_delta(KeyCode::Char('q')), None); + } + + #[test] + fn back_tab_and_shift_tab_both_cycle_backward() { + // prev(Observe) = Serving in the 5-tab IA. + assert_eq!( + hk(KeyCode::BackTab, ActiveTab::Observe), + KeyAction::SwitchTab(ActiveTab::Serving) + ); + // Home's previous tab is Chat (the last tab). + assert_eq!( + hk(KeyCode::BackTab, ActiveTab::Home), + KeyAction::SwitchTab(ActiveTab::Chat) + ); + let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); + assert_eq!( + handle_key( + shift_tab, + ActiveTab::Rocm, + &Modal::None, + ChatKeyCtx::default() + ), + KeyAction::SwitchTab(ActiveTab::Home) + ); + } + + #[test] + fn number_keys_jump_to_tab() { + // 5-tab: '1'→Home, '2'→ROCm, '3'→Serving, '4'→Observe, '5'→Chat. + assert_eq!( + hk(KeyCode::Char('1'), ActiveTab::Home), + KeyAction::SwitchTab(ActiveTab::Home) + ); + assert_eq!( + hk(KeyCode::Char('2'), ActiveTab::Home), + KeyAction::SwitchTab(ActiveTab::Rocm) + ); + assert_eq!( + hk(KeyCode::Char('3'), ActiveTab::Home), + KeyAction::SwitchTab(ActiveTab::Serving) + ); + assert_eq!( + hk(KeyCode::Char('4'), ActiveTab::Home), + KeyAction::SwitchTab(ActiveTab::Observe) + ); + // `5` reaches the Chat tab (digit guard widened to '1'..='5'). + assert_eq!( + hk(KeyCode::Char('5'), ActiveTab::Home), + KeyAction::SwitchTab(ActiveTab::Chat) + ); + assert_eq!(hk(KeyCode::Char('6'), ActiveTab::Home), KeyAction::Nothing); + } + + #[test] + fn release_events_are_ignored() { + let release = KeyEvent::new_with_kind( + KeyCode::Char('q'), + KeyModifiers::NONE, + KeyEventKind::Release, + ); + assert_eq!( + handle_key( + release, + ActiveTab::Home, + &Modal::None, + ChatKeyCtx::default() + ), + KeyAction::Nothing + ); + } + + #[test] + fn only_press_key_events_are_actionable() { + // The event loop gates overlay dispatch on this predicate so a single + // keystroke isn't processed twice by an overlay's `on_key` (Release / + // Repeat echoes on Windows Terminal / ConPTY / kitty keyboard). The + // double-fire re-opened the serve wizard's model picker on Enter instead + // of choosing — this pins Press-only routing. + assert!(is_actionable_key(KeyEventKind::Press)); + assert!(!is_actionable_key(KeyEventKind::Release)); + assert!(!is_actionable_key(KeyEventKind::Repeat)); + } + + #[test] + fn jk_arrows_and_g_drive_selection() { + assert_eq!( + hk(KeyCode::Char('j'), ActiveTab::Observe), + KeyAction::Move(1) + ); + assert_eq!( + hk(KeyCode::Char('k'), ActiveTab::Observe), + KeyAction::Move(-1) + ); + assert_eq!(hk(KeyCode::Down, ActiveTab::Rocm), KeyAction::Move(1)); + assert_eq!(hk(KeyCode::Up, ActiveTab::Rocm), KeyAction::Move(-1)); + assert_eq!( + hk(KeyCode::Char('g'), ActiveTab::Observe), + KeyAction::SelectFirst + ); + assert_eq!( + hk(KeyCode::Char('G'), ActiveTab::Observe), + KeyAction::SelectLast + ); + assert_eq!( + hk(KeyCode::Enter, ActiveTab::Observe), + KeyAction::OpenDetail + ); + } + + #[test] + fn operational_open_keys_are_tab_scoped() { + // `s` opens services only on Observe; Nothing elsewhere. + assert_eq!( + hk(KeyCode::Char('s'), ActiveTab::Observe), + KeyAction::OpenServices + ); + assert_eq!(hk(KeyCode::Char('s'), ActiveTab::Home), KeyAction::Nothing); + // The letter hotkeys fire ONLY on Observe now — quick jumps into the + // managers. They open the matching overlay via the seam. + assert_eq!( + hk(KeyCode::Char('w'), ActiveTab::Observe), + KeyAction::OpenServeWizard + ); + assert_eq!( + hk(KeyCode::Char('e'), ActiveTab::Observe), + KeyAction::OpenEngineManager + ); + assert_eq!( + hk(KeyCode::Char('d'), ActiveTab::Observe), + KeyAction::OpenExamine + ); + assert_eq!( + hk(KeyCode::Char('i'), ActiveTab::Observe), + KeyAction::OpenInstall + ); + // Retired on the domain tabs: the Actions list is the single path there, + // so the letter hotkeys are inert on ROCm/Serving (and Home/Chat). + for c in ['w', 'e', 'd', 'u', 'i', 'l', 'r', 'n', 'a', 'c', 'p', 's'] { + assert_eq!( + hk(KeyCode::Char(c), ActiveTab::Rocm), + KeyAction::Nothing, + "key {c} must be retired on the ROCm tab" + ); + assert_eq!( + hk(KeyCode::Char(c), ActiveTab::Serving), + KeyAction::Nothing, + "key {c} must be retired on the Serving tab" + ); + } + assert_eq!(hk(KeyCode::Char('w'), ActiveTab::Home), KeyAction::Nothing); + // On the Chat tab none of these open an overlay. `i` means insert mode. + for c in ['w', 'e', 'd', 'u', 'l'] { + assert_eq!( + hk(KeyCode::Char(c), ActiveTab::Chat), + KeyAction::Nothing, + "key {c} must not open an overlay from Chat" + ); + } + assert_eq!( + hk(KeyCode::Char('i'), ActiveTab::Chat), + KeyAction::ChatFocus, + "i is chat-insert on Chat, never OpenInstall" + ); + } + + #[test] + fn opening_an_overlay_closes_the_others() { + let mut s = AppState::new("t".into(), "default-dark".into()); + apply_action(&mut s, KeyAction::OpenServices); + assert!(s.services.is_some() && s.serve_wizard.is_none() && s.engine_manager.is_none()); + // Opening another overlay (defensive path) clears the prior one. + apply_action(&mut s, KeyAction::OpenServeWizard); + assert!(s.serve_wizard.is_some() && s.services.is_none() && s.engine_manager.is_none()); + apply_action(&mut s, KeyAction::OpenEngineManager); + assert!(s.engine_manager.is_some() && s.services.is_none() && s.serve_wizard.is_none()); + // Wave 2/3 overlays join the mutual-exclusion set. + apply_action(&mut s, KeyAction::OpenExamine); + assert!(s.examine_manager.is_some() && s.engine_manager.is_none()); + apply_action(&mut s, KeyAction::OpenUpdate); + assert!(s.update_manager.is_some() && s.examine_manager.is_none()); + apply_action(&mut s, KeyAction::OpenInstall); + assert!(s.install_manager.is_some() && s.update_manager.is_none()); + apply_action(&mut s, KeyAction::OpenLogs); + assert!(s.logs_view.is_some() && s.install_manager.is_none()); + apply_action(&mut s, KeyAction::OpenRuntimes); + assert!(s.runtime_manager.is_some() && s.logs_view.is_none()); + apply_action(&mut s, KeyAction::OpenOnboarding); + assert!(s.onboarding.is_some() && s.runtime_manager.is_none()); + apply_action(&mut s, KeyAction::OpenAutomations); + assert!(s.automations_manager.is_some() && s.onboarding.is_none()); + apply_action(&mut s, KeyAction::OpenCommand); + assert!(s.command_screen.is_some() && s.automations_manager.is_none()); + apply_action(&mut s, KeyAction::OpenConfig); + assert!(s.config_manager.is_some() && s.command_screen.is_none()); + // T13: OpenBenchRun joins the mutual-exclusion set. + apply_action(&mut s, KeyAction::OpenBenchRun); + assert!(s.bench_run.is_some() && s.config_manager.is_none()); + } + + #[test] + fn esc_opens_menu_when_idle_on_any_tab() { + // Idle tabs: Esc opens the btop main menu, Chat included when unfocused + // (Chat-focused Esc is handled by the short-circuit above this match). + assert_eq!(hk(KeyCode::Esc, ActiveTab::Home), KeyAction::OpenMenu); + assert_eq!(hk(KeyCode::Esc, ActiveTab::Observe), KeyAction::OpenMenu); + assert_eq!(hk(KeyCode::Esc, ActiveTab::Chat), KeyAction::OpenMenu); + // While an overlay modal owns the screen, Esc closes it (not OpenMenu). + assert_eq!( + handle_key( + press(KeyCode::Esc), + ActiveTab::Home, + &Modal::Menu, + ChatKeyCtx::default() + ), + KeyAction::CloseModal + ); + assert_eq!( + handle_key( + press(KeyCode::Esc), + ActiveTab::Home, + &Modal::Options, + ChatKeyCtx::default() + ), + KeyAction::CloseModal + ); + } + + #[test] + fn colon_opens_command_palette() { + assert_eq!( + hk(KeyCode::Char(':'), ActiveTab::Home), + KeyAction::OpenPalette + ); + } + + #[test] + fn menu_navigation_and_activation() { + let mut s = AppState::new("t".into(), "default-dark".into()); + apply_action(&mut s, KeyAction::OpenMenu); + assert_eq!(s.modal, Modal::Menu); + // ↓ from Options(0) → Help(1); activate opens the global help. + apply_action(&mut s, KeyAction::MenuMove(1)); + assert_eq!(s.menu_sel, 1); + apply_action(&mut s, KeyAction::MenuActivate); + assert_eq!(s.modal, Modal::GlobalHelp); + // Menu → Options activation opens the Options panel. + apply_action(&mut s, KeyAction::OpenMenu); + apply_action(&mut s, KeyAction::MenuActivate); // sel 0 = Options + assert_eq!(s.modal, Modal::Options); + // Options tab cycles and wraps. + apply_action(&mut s, KeyAction::OptionsTab(-1)); + assert_eq!(s.options_tab, crate::ui::modal::OPTIONS_TABS.len() - 1); + } + + #[test] + fn palette_activation_switches_tab() { + let mut s = AppState::new("t".into(), "default-dark".into()); + apply_action(&mut s, KeyAction::OpenPalette); + apply_action(&mut s, KeyAction::MenuMove(3)); // Home→ROCm→Serving→Observe + apply_action(&mut s, KeyAction::MenuActivate); + assert_eq!(s.active_tab, ActiveTab::Observe); + assert_eq!(s.modal, Modal::None); + } + + #[test] + fn question_mark_toggles_help() { + assert_eq!( + hk(KeyCode::Char('?'), ActiveTab::Home), + KeyAction::ToggleHelp + ); + } + + #[test] + fn t_opens_theme_picker() { + assert_eq!( + hk(KeyCode::Char('t'), ActiveTab::Home), + KeyAction::OpenThemePicker + ); + } + + #[test] + fn theme_picker_absorbs_navigation_keys() { + let with_picker = |c| { + handle_key( + press(c), + ActiveTab::Home, + &Modal::ThemePicker, + ChatKeyCtx::default(), + ) + }; + assert_eq!(with_picker(KeyCode::Char('j')), KeyAction::Move(1)); + assert_eq!(with_picker(KeyCode::Char('k')), KeyAction::Move(-1)); + assert_eq!(with_picker(KeyCode::Enter), KeyAction::ApplyThemePick); + assert_eq!(with_picker(KeyCode::Esc), KeyAction::CloseModal); + assert_eq!(with_picker(KeyCode::Char('t')), KeyAction::CloseModal); + assert_eq!(with_picker(KeyCode::Char('q')), KeyAction::Quit); + assert_eq!(with_picker(KeyCode::Char('1')), KeyAction::Nothing); + } + + #[test] + fn tab_bar_hit_matches_per_chip_extents() { + // 5-tab layout: Home 0..10, ROCm 11..21, Serving 22..35, Observe 36..49, + // Chat 50..60. + let bar = Rect::new(0, 0, 80, 1); + assert_eq!(tab_bar_hit(bar, 5, 0), Some(ActiveTab::Home)); + assert_eq!(tab_bar_hit(bar, 15, 0), Some(ActiveTab::Rocm)); + assert_eq!(tab_bar_hit(bar, 28, 0), Some(ActiveTab::Serving)); + assert_eq!(tab_bar_hit(bar, 42, 0), Some(ActiveTab::Observe)); + assert_eq!(tab_bar_hit(bar, 55, 0), Some(ActiveTab::Chat)); + // Separator gap between Home (ends 10 excl.) and ROCm (starts 11). + assert_eq!(tab_bar_hit(bar, 10, 0), None); + // Wrong row. + assert_eq!(tab_bar_hit(bar, 5, 2), None); + } + + #[test] + fn tab_bar_hit_skips_chips_that_overflow_a_narrow_bar() { + // Bar can only fit the first two chips (ROCm ends at 21). + let bar = Rect::new(0, 0, 25, 1); + assert_eq!(tab_bar_hit(bar, 5, 0), Some(ActiveTab::Home)); + assert_eq!(tab_bar_hit(bar, 15, 0), Some(ActiveTab::Rocm)); + // Serving chip would be at 22..35 — overflows the 25-wide bar → None. + assert_eq!(tab_bar_hit(bar, 28, 0), None); + } + + #[test] + fn tab_bar_hit_honors_x_offset() { + // Bar offset 10 columns to the right: Home chip now spans 10..19. + let bar = Rect::new(10, 0, 80, 1); + assert_eq!(tab_bar_hit(bar, 15, 0), Some(ActiveTab::Home)); + // Absolute x=5 is left of the offset bar. + assert_eq!(tab_bar_hit(bar, 5, 0), None); + } + + #[test] + fn handle_mouse_routes_scroll_by_modal_and_tab() { + let scroll_down = MouseEvent { + kind: MouseEventKind::ScrollDown, + column: 0, + row: 0, + modifiers: KeyModifiers::NONE, + }; + // No modal, non-interactive tab → Nothing + assert_eq!( + handle_mouse(scroll_down, &Modal::None, ActiveTab::Home), + KeyAction::Nothing + ); + // No modal, Observe → Move by ONE (drives the instances selection) + assert_eq!( + handle_mouse(scroll_down, &Modal::None, ActiveTab::Observe), + KeyAction::Move(1) + ); + // Detail modal → ScrollModal by one line + assert_eq!( + handle_mouse(scroll_down, &Modal::Detail, ActiveTab::Observe), + KeyAction::ScrollModal(1) + ); + // Help / GlobalHelp popups are sized to fit their content — nothing to + // scroll, so the wheel is a no-op there. + assert_eq!( + handle_mouse(scroll_down, &Modal::Help, ActiveTab::Home), + KeyAction::Nothing + ); + assert_eq!( + handle_mouse(scroll_down, &Modal::GlobalHelp, ActiveTab::Home), + KeyAction::Nothing + ); + // ThemePicker → Move (drives picker cursor) + assert_eq!( + handle_mouse(scroll_down, &Modal::ThemePicker, ActiveTab::Home), + KeyAction::Move(1) + ); + // Domain-tab scroll is NOT routed here (resolve_mouse owns it) → Nothing. + assert_eq!( + handle_mouse(scroll_down, &Modal::None, ActiveTab::Rocm), + KeyAction::Nothing + ); + } + + #[test] + fn detail_modal_j_k_emit_scroll() { + let with_detail = |c| { + handle_key( + press(c), + ActiveTab::Observe, + &Modal::Detail, + ChatKeyCtx::default(), + ) + }; + assert_eq!(with_detail(KeyCode::Char('j')), KeyAction::ScrollModal(1)); + assert_eq!(with_detail(KeyCode::Char('k')), KeyAction::ScrollModal(-1)); + assert_eq!(with_detail(KeyCode::PageDown), KeyAction::ScrollModal(10)); + assert_eq!( + with_detail(KeyCode::Char('g')), + KeyAction::ScrollModal(i16::MIN) + ); + assert_eq!( + with_detail(KeyCode::Char('G')), + KeyAction::ScrollModal(i16::MAX) + ); + assert_eq!(with_detail(KeyCode::Esc), KeyAction::CloseModal); + } + + #[test] + fn help_modal_absorbs_navigation() { + let with_help = |c| { + handle_key( + press(c), + ActiveTab::Observe, + &Modal::Help, + ChatKeyCtx::default(), + ) + }; + // The popup is sized to fit its content, so navigation keys are inert. + assert_eq!(with_help(KeyCode::Char('j')), KeyAction::Nothing); + assert_eq!(with_help(KeyCode::Char('k')), KeyAction::Nothing); + assert_eq!(with_help(KeyCode::Tab), KeyAction::Nothing); + assert_eq!(with_help(KeyCode::Esc), KeyAction::CloseModal); + assert_eq!(with_help(KeyCode::Enter), KeyAction::CloseModal); + assert_eq!(with_help(KeyCode::Char('q')), KeyAction::Quit); + } + + #[test] + fn global_help_modal_absorbs_navigation() { + let with_global_help = |c| { + handle_key( + press(c), + ActiveTab::Observe, + &Modal::GlobalHelp, + ChatKeyCtx::default(), + ) + }; + assert_eq!(with_global_help(KeyCode::Char('j')), KeyAction::Nothing); + assert_eq!(with_global_help(KeyCode::Char('k')), KeyAction::Nothing); + assert_eq!(with_global_help(KeyCode::PageDown), KeyAction::Nothing); + assert_eq!(with_global_help(KeyCode::Char('g')), KeyAction::Nothing); + assert_eq!(with_global_help(KeyCode::Char('G')), KeyAction::Nothing); + assert_eq!(with_global_help(KeyCode::Esc), KeyAction::CloseModal); + assert_eq!(with_global_help(KeyCode::Char('q')), KeyAction::Quit); + } + + #[test] + fn bracket_keys_emit_replay_jump() { + assert_eq!( + hk(KeyCode::Char('['), ActiveTab::Home), + KeyAction::ReplayJump(-10) + ); + assert_eq!( + hk(KeyCode::Char(']'), ActiveTab::Home), + KeyAction::ReplayJump(10) + ); + assert_eq!( + hk(KeyCode::Char('{'), ActiveTab::Home), + KeyAction::ReplayJump(-60) + ); + assert_eq!( + hk(KeyCode::Char('}'), ActiveTab::Home), + KeyAction::ReplayJump(60) + ); + } + + #[test] + fn chat_insert_mode_captures_text_and_shortcircuits_hotkeys() { + let accepted_focused = ChatKeyCtx { + focused: true, + consent: ChatConsent::Accepted, + offer_pending: false, + }; + let focused = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, accepted_focused); + // Printable chars become input, including ones that are global hotkeys. + assert_eq!(focused(KeyCode::Char('h')), KeyAction::ChatInput('h')); + assert_eq!(focused(KeyCode::Char('q')), KeyAction::ChatInput('q')); + assert_eq!(focused(KeyCode::Char('5')), KeyAction::ChatInput('5')); + assert_eq!(focused(KeyCode::Backspace), KeyAction::ChatBackspace); + assert_eq!(focused(KeyCode::Enter), KeyAction::ChatSubmit); + assert_eq!(focused(KeyCode::Esc), KeyAction::ChatBlur); + + // Accepted but NOT focused: `q` still quits and `i`/Enter enter insert mode. + let accepted = ChatKeyCtx { + focused: false, + consent: ChatConsent::Accepted, + offer_pending: false, + }; + let unfocused = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, accepted); + assert_eq!(unfocused(KeyCode::Char('q')), KeyAction::Quit); + assert_eq!(unfocused(KeyCode::Char('i')), KeyAction::ChatFocus); + assert_eq!(unfocused(KeyCode::Enter), KeyAction::ChatFocus); + assert_eq!( + unfocused(KeyCode::Char('1')), + KeyAction::SwitchTab(ActiveTab::Home) + ); + } + + #[test] + fn chat_consent_gate_maps_keys_and_lets_globals_through() { + let pending = ChatKeyCtx { + focused: false, + consent: ChatConsent::Pending, + offer_pending: false, + }; + let gate = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, pending); + // y / Y / Enter accept; n / N decline. + assert_eq!(gate(KeyCode::Char('y')), KeyAction::ChatConsentAccept); + assert_eq!(gate(KeyCode::Enter), KeyAction::ChatConsentAccept); + assert_eq!(gate(KeyCode::Char('n')), KeyAction::ChatConsentDecline); + // Globals not trapped by the gate: q quits, digit switches tab. + assert_eq!(gate(KeyCode::Char('q')), KeyAction::Quit); + assert_eq!( + gate(KeyCode::Char('2')), + KeyAction::SwitchTab(ActiveTab::Rocm) + ); + } + + #[test] + fn chat_consent_accept_and_decline_transition_state() { + let mut s = AppState::new("t".into(), "default-dark".into()); + // No endpoint → Unavailable; accept/decline are no-ops. + s.set_chat_config(None, false); + assert_eq!(s.chat_consent, ChatConsent::Unavailable); + apply_action(&mut s, KeyAction::ChatConsentAccept); + assert_eq!(s.chat_consent, ChatConsent::Unavailable); + + // Endpoint present, no pre-consent → Pending. + let llm = crate::llm::LlmConfig { + base_url: "http://127.0.0.1:8000".into(), + model: "m".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(llm.clone()), false); + assert_eq!(s.chat_consent, ChatConsent::Pending); + // Accept → Accepted + focused. + apply_action(&mut s, KeyAction::ChatConsentAccept); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + assert!(s.chat_focused); + // Decline → Declined + unfocused. + apply_action(&mut s, KeyAction::ChatConsentDecline); + assert_eq!(s.chat_consent, ChatConsent::Declined); + assert!(!s.chat_focused); + + // Pre-consent → Accepted immediately. + s.set_chat_config(Some(llm), true); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + } + + #[test] + fn detect_offer_lifecycle_accept_switches_chat() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Chat; + // Gateway-configured chat, pending consent. + let gw = crate::llm::LlmConfig { + base_url: "https://gw/OpenAI".into(), + model: "gpt-4o-mini".into(), + api_key: Some("k".into()), + auth_header: Some("Ocp-Apim-Subscription-Key".into()), + }; + s.set_chat_config(Some(gw), false); + // Simulate a prior `/provider openai` so the realignment to Local on + // accept is observable (Local is the default, so starting there would + // make the assertion below tautological). + s.active_provider = ChatProvider::Openai; + + // request_detect raises the dispatch edge + detecting flag. + apply_action(&mut s, KeyAction::ChatDetect); + assert!(s.chat_detecting && s.chat_detect_dispatch); + + // event_loop reports a detected local engine. + let local = crate::llm::detected_llm_config("http://localhost:13305/v1", "Llama-3.2-3B"); + s.set_detect_result(Some(local.clone())); + assert!(!s.chat_detecting); + assert_eq!(s.chat_detect_offer.as_ref(), Some(&local)); + + // Accept the offer → chat switches to the local endpoint + enabled. + apply_action(&mut s, KeyAction::ChatDetectAccept); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + assert_eq!(s.chat_llm.as_ref(), Some(&local)); + assert!(s.chat_detect_offer.is_none()); + assert_eq!( + s.chat_endpoint_rebuild, + Some(ChatProvider::Openai), + "accept raises the rebuild edge carrying the previous provider" + ); + assert_eq!(s.active_provider, ChatProvider::Local); + } + + #[test] + fn detect_offer_dismiss_keeps_prior_config() { + let mut s = AppState::new("t".into(), "default-dark".into()); + let gw = crate::llm::LlmConfig { + base_url: "https://gw/OpenAI".into(), + model: "gpt-4o-mini".into(), + api_key: None, + auth_header: None, + }; + s.set_chat_config(Some(gw.clone()), false); + s.set_detect_result(Some(crate::llm::detected_llm_config( + "http://localhost:8000/v1", + "x", + ))); + // Dismiss → offer gone, gateway config + Pending consent intact. + apply_action(&mut s, KeyAction::ChatDetectDismiss); + assert!(s.chat_detect_offer.is_none()); + assert_eq!(s.chat_llm.as_ref(), Some(&gw)); + assert_eq!(s.chat_consent, ChatConsent::Pending); + } + + #[test] + fn save_detect_offer_accepts_and_raises_persist_edge() { + let mut s = AppState::new("t".into(), "default-dark".into()); + // Start off-Local so the realignment on accept is observable (Local is + // the default provider; asserting it without this would be tautological). + s.active_provider = ChatProvider::Openai; + s.set_detect_result(Some(crate::llm::detected_llm_config( + "http://localhost:13305/v1", + "Llama-3.2-3B", + ))); + apply_action(&mut s, KeyAction::ChatDetectSave); + assert_eq!(s.chat_consent, ChatConsent::Accepted); + assert!(s.chat_persist_dispatch, "save raises the persist edge"); + assert_eq!( + s.chat_endpoint_rebuild, + Some(ChatProvider::Openai), + "save also raises the rebuild edge carrying the previous provider" + ); + assert_eq!(s.active_provider, ChatProvider::Local); + assert_eq!( + s.chat_llm.as_ref().map(|c| c.base_url.as_str()), + Some("http://localhost:13305/v1") + ); + // No offer → save is a no-op (no edge). + let mut s2 = AppState::new("t".into(), "default-dark".into()); + apply_action(&mut s2, KeyAction::ChatDetectSave); + assert!(!s2.chat_persist_dispatch); + assert!(s2.chat_endpoint_rebuild.is_none()); + } + + #[test] + fn detect_key_available_on_gate_and_offer_keys_take_precedence() { + // `d` triggers detect from the Unavailable empty-state. + let unavail = ChatKeyCtx { + focused: false, + consent: ChatConsent::Unavailable, + offer_pending: false, + }; + assert_eq!( + handle_key( + press(KeyCode::Char('d')), + ActiveTab::Chat, + &Modal::None, + unavail + ), + KeyAction::ChatDetect + ); + // With an offer pending, y/n map to the offer (not consent). + let offering = ChatKeyCtx { + focused: false, + consent: ChatConsent::Pending, + offer_pending: true, + }; + assert_eq!( + handle_key( + press(KeyCode::Char('y')), + ActiveTab::Chat, + &Modal::None, + offering + ), + KeyAction::ChatDetectAccept + ); + assert_eq!( + handle_key( + press(KeyCode::Char('n')), + ActiveTab::Chat, + &Modal::None, + offering + ), + KeyAction::ChatDetectDismiss + ); + } + + #[test] + fn chat_input_actions_mutate_buffer() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Chat; + s.chat_focused = true; + apply_action(&mut s, KeyAction::ChatInput('h')); + apply_action(&mut s, KeyAction::ChatInput('i')); + assert_eq!(s.chat_input, "hi"); + apply_action(&mut s, KeyAction::ChatBackspace); + assert_eq!(s.chat_input, "h"); + apply_action(&mut s, KeyAction::ChatBlur); + assert!(!s.chat_focused); + apply_action(&mut s, KeyAction::ChatFocus); + assert!(s.chat_focused); + } + + #[test] + fn chat_submit_pushes_user_turn_and_raises_dispatch() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "what's GPU-2 doing?".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + // Only the user turn is pushed; the agent reply arrives async. + assert_eq!(s.chat.len(), 1); + assert_eq!(s.chat[0].role, ChatRole::User); + assert_eq!(s.chat[0].content, "what's GPU-2 doing?"); + assert!(s.chat_input.is_empty()); + assert!(s.chat_sending, "submit marks the request in flight"); + assert!(s.chat_dispatch, "submit raises the spawn edge"); + } + + #[test] + fn chat_submit_ignores_empty_input() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = " ".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat.is_empty()); + assert!(!s.chat_sending); + assert!(!s.chat_dispatch); + } + + #[test] + fn chat_submit_ignored_while_request_in_flight() { + // A second submit before the first reply lands must be a no-op — no + // second user turn, no second spawn (prevents a racing double request). + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "first".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat_sending); + assert_eq!(s.chat.len(), 1); + s.chat_dispatch = false; // simulate event_loop consuming the edge + s.chat_input = "second".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert_eq!(s.chat.len(), 1, "second submit ignored while in flight"); + assert!(!s.chat_dispatch, "no second dispatch edge raised"); + // After the reply clears the flag, submits work again. + s.on_chat_reply("done".into()); + assert!(!s.chat_sending); + s.chat_input = "third".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat_dispatch); + } + + #[test] + fn scroll_modal_action_reaches_scroll_instance_detail_for_detail_modal() { + // Regression: `apply_action`'s ScrollModal dispatch only matched + // `Modal::Help | Modal::GlobalHelp`, silently dropping the action for + // `Modal::Detail` even though both `handle_key` and `handle_mouse` + // emit `ScrollModal` for it (see `detail_modal_j_k_emit_scroll` / + // `handle_mouse_routes_scroll_by_modal_and_tab`) and the instance + // Detail modal's body (launch_args/env_vars) can genuinely overflow. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.modal = Modal::Detail; + s.instance_detail_max_scroll = 10; + apply_action(&mut s, KeyAction::ScrollModal(3)); + assert_eq!( + s.instance_detail_scroll, 3, + "Detail modal scrolls via apply_action" + ); + } + + #[tokio::test] + async fn chat_reply_path_appends_agent_turn_and_clears_sending() { + // The wired ChatSubmit→reply path using the MockAgentClient (no LLM). + let agent = crate::agent::MockAgentClient::new("GPU-2: 87% util, 71°C"); + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "what's GPU-2 doing?".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert!(s.chat_sending); + // Simulate event_loop: run the agent over the history, deliver the reply. + let snapshot = s.state_snapshot(); + let reply = crate::agent::AgentClient::complete(&agent, &s.chat, snapshot) + .await + .expect("mock reply"); + s.on_chat_reply(reply); + assert_eq!(s.chat.last().unwrap().role, ChatRole::Agent); + assert_eq!(s.chat.last().unwrap().content, "GPU-2: 87% util, 71°C"); + assert!(!s.chat_sending); + } + + #[test] + fn chat_input_handles_unicode_and_long_text() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_focused = true; + // Multi-byte / emoji chars push as single chars, no panic. + for c in "héllo 🚀 café ∑".chars() { + apply_action(&mut s, KeyAction::ChatInput(c)); + } + assert_eq!(s.chat_input, "héllo 🚀 café ∑"); + // Backspace removes the trailing multi-byte char correctly. + apply_action(&mut s, KeyAction::ChatBackspace); + assert_eq!(s.chat_input, "héllo 🚀 café "); + // Very long input is accepted. + for _ in 0..5000 { + apply_action(&mut s, KeyAction::ChatInput('x')); + } + assert!(s.chat_input.len() > 5000); + // Submitting unicode pushes one user turn, no panic. + s.chat_input = "什么是 GPU-2?".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + assert_eq!(s.chat[0].content, "什么是 GPU-2?"); + } + + #[test] + fn chat_scroll_clamps_and_updates_follow_state() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_max_scroll = 20; + s.chat_scroll = 20; + apply_action(&mut s, KeyAction::ChatScroll(-100)); + assert_eq!(s.chat_scroll, 0, "scroll clamps at top"); + assert!(!s.chat_follow, "scrolling above the bottom disables follow"); + apply_action(&mut s, KeyAction::ChatScroll(7)); + assert_eq!(s.chat_scroll, 7); + assert!(!s.chat_follow); + apply_action(&mut s, KeyAction::ChatScroll(100)); + assert_eq!(s.chat_scroll, 20, "scroll clamps at measured bottom"); + assert!(s.chat_follow, "scrolling to the bottom restores follow"); + // PageUp/PageDown map to ChatScroll on the Chat tab when accepted. + let accepted = ChatKeyCtx { + focused: false, + consent: ChatConsent::Accepted, + offer_pending: false, + }; + assert_eq!( + handle_key( + press(KeyCode::PageDown), + ActiveTab::Chat, + &Modal::None, + accepted + ), + KeyAction::ChatScroll(CHAT_SCROLL_STEP) + ); + assert_eq!( + handle_key( + press(KeyCode::PageUp), + ActiveTab::Chat, + &Modal::None, + accepted + ), + KeyAction::ChatScroll(-CHAT_SCROLL_STEP) + ); + } + + #[tokio::test] + async fn chat_error_path_appends_error_turn_no_panic() { + let agent = crate::agent::MockAgentClient::failing(); + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_input = "hi".into(); + apply_action(&mut s, KeyAction::ChatSubmit); + let snapshot = s.state_snapshot(); + let err = crate::agent::AgentClient::complete(&agent, &s.chat, snapshot) + .await + .unwrap_err(); + s.on_chat_error(err.to_string()); + assert_eq!(s.chat.last().unwrap().role, ChatRole::Error); + assert!(!s.chat_sending); + } +} diff --git a/crates/rocm-dash-tui/src/app/event_loop.rs b/crates/rocm-dash-tui/src/app/event_loop.rs new file mode 100644 index 000000000..dd864dd28 --- /dev/null +++ b/crates/rocm-dash-tui/src/app/event_loop.rs @@ -0,0 +1,2594 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Terminal lifecycle, signal handling, and the tick loop that drives the +//! dashboard: `run`, `event_loop`, the termination-signal watcher, and the +//! startup-focus / Updates-tile tick helpers. Split out of `app/mod.rs` to +//! keep the core reducer focused. + +use std::io; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::time::Duration; + +use crossterm::event::{ + DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream, KeyCode, KeyEvent, + KeyModifiers, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; +use futures::StreamExt; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; + +use tokio::sync::mpsc; +use tokio::time::interval; + +use crate::client::{self, ClientMsg}; +use crate::ui; + +use super::actions::{ + apply_action, console_scroll_delta, handle_key, is_actionable_key, run_approved, +}; +use super::chat::{ + self, StartupChatOutcome, build_chat_agent, build_local_agent, detect_local_chat, + discover_configured_chat_model, persist_chat_endpoint, startup_chat_outcome, +}; +use super::scrollbar::{PaneFocus, resolve_mouse}; +use super::summary::{parse_plan_result, summarize_slash_tool}; +use super::types::{ + ChatKeyCtx, ChatProvider, ChatTurn, ConnState, Focus, NO_CHAT_BACKEND_MSG, ReplayState, + ResolvedArgs, UpdateStatus, +}; +use super::{AppState, ProviderSwitch}; + +type Tui = Terminal>; + +/// How often the background Updates-tile check re-runs. +const UPDATE_CHECK_INTERVAL: Duration = Duration::from_hours(6); + +/// Job id for the periodic background update check driven off the tick loop. +/// Deliberately distinct from `update_manager`'s interactive `"update-check"` +/// so the two never clobber each other's job slot / console output. +/// `pub(crate)` so the Home tab's activity feed (`ui::tabs::home`) can filter +/// this job out — it is the tile's own plumbing, not user activity. +pub(crate) const HOME_UPDATE_CHECK_JOB_ID: &str = "home-update-check"; + +/// Bound on the background update check's own per-runtime index lookups, so a +/// slow/unreachable index can't leave the job running indefinitely — the same +/// principle as the CLI's own `STARTUP_UPDATE_CHECK_TIMEOUT_SECS`. The two +/// crates can't share the constant (`apps/rocm` depends on `rocm-dash-tui`, +/// not the reverse), so this value isn't required to match it. +const HOME_UPDATE_CHECK_TIMEOUT_SECS: u64 = 5; + +/// Whether the event loop should skip the embedded daemon client AND the chat +/// backend resolution. True exactly when a [`Focus`] is set: a focused host runs +/// one overlay that streams its own job through the job-bridge, so it needs +/// neither live telemetry nor an LLM. `focus == None` (the dashboard) keeps both. +/// Pure predicate → unit-testable without a runtime; also names the render branch +/// (`draw_focused` when true, `draw` when false). +const fn should_skip_daemon(focus: Option) -> bool { + focus.is_some() +} + +/// In a focused host, whether a console "close" key must be SWALLOWED because +/// the active job is still running. +/// +/// In the dashboard, `q` / running-`Esc` detach the console and leave the job +/// running in the background (the app persists). A focused host has no +/// background: closing the overlay trips the [`AppState::focused_should_exit`] +/// gate and returns from `event_loop`, tearing down the runtime and killing the +/// child via `kill_on_drop` — truncating a mutating install/serve mid-write. So +/// while the job is non-terminal we swallow those keys; the user stops a job +/// explicitly with `Ctrl+C` (never blocked here), and once it is terminal `q` / +/// `Esc` exit normally. Always `false` for the dashboard (`focus == None`). +fn focused_close_key_blocked(state: &AppState, focus: Option, code: KeyCode) -> bool { + if !should_skip_daemon(focus) { + return false; + } + let running = state + .active_job_id() + .is_some_and(|id| ui::job_console::console_esc_closes(state.jobs.job(id))); + running && matches!(code, KeyCode::Char('q') | KeyCode::Esc) +} + +/// Open the single overlay a focused host should host, returning any initial +/// job-bridge side effects to pump (Examine auto-runs `rocm examine` on open; +/// Setup/Serve open their form and wait for input). Clears any other overlay +/// first (mutually-exclusive invariant). Pure w.r.t. process I/O — the caller +/// runs the returned effects through [`crate::jobs::run_effects`]. +fn open_overlay_for_focus( + state: &mut AppState, + focus: Focus, +) -> Vec { + state.close_overlays(); + match focus { + Focus::Setup => { + state.onboarding = Some(crate::ui::onboarding::OnboardingState::default()); + Vec::new() + } + Focus::Serve => { + state.serve_wizard = Some(crate::ui::serve_wizard::ServeWizardState::default()); + Vec::new() + } + Focus::Examine => { + let (mgr, fx) = crate::ui::examine_manager::open_running(&mut state.jobs); + state.examine_manager = Some(mgr); + fx + } + } +} + +/// Reduce a parsed `rocm update --json` document's `runtimes` array into an +/// [`UpdateStatus`]. Empty ⇒ nothing managed to check; any update-available or +/// repair-available row wins over up-to-date/error rows (the tile surfaces the +/// most actionable state — a repair is as actionable as an update); otherwise +/// `UpToDate` only if every row resolved cleanly — a mixed result (some rows +/// errored, some unrecognized) can't honestly assert freshness for the +/// runtimes that didn't resolve, so it's `Error` too. +fn reduce_update_json(document: &serde_json::Value) -> UpdateStatus { + fn status_of(row: &serde_json::Value) -> Option<&str> { + row.get("status").and_then(serde_json::Value::as_str) + } + let Some(runtimes) = document + .get("runtimes") + .and_then(serde_json::Value::as_array) + else { + return UpdateStatus::Error; + }; + if runtimes.is_empty() { + return UpdateStatus::NoManagedRuntimes; + } + if let Some(row) = runtimes.iter().find(|row| { + matches!( + status_of(row), + Some("update_available" | "repair_available") + ) + }) { + // A missing/null `latest_version` must not silently fall through to + // the up-to-date/error checks below — that would misreport a real, + // actionable update as "check failed". Fall back to a placeholder + // instead of losing the actionable status. + let latest_version = row + .get("latest_version") + .and_then(serde_json::Value::as_str) + .unwrap_or("(version unknown)") + .to_owned(); + return UpdateStatus::UpdateAvailable { latest_version }; + } + if runtimes + .iter() + .all(|row| matches!(status_of(row), Some("up_to_date" | "ahead_of_index"))) + { + return UpdateStatus::UpToDate; + } + UpdateStatus::Error +} + +/// Spawn/consume the periodic `home-update-check` job that backs the Home +/// tab's Updates tile, and return any job-bridge side effects to pump. +/// +/// Pure w.r.t. process I/O — like [`open_overlay_for_focus`], the caller runs +/// the returned effects through [`crate::jobs::run_effects`]. Called once per +/// tick from `event_loop`, skipped entirely under `state.simulated`. +fn refresh_update_status(state: &mut AppState) -> Vec { + if state.update_status_pending { + let Some(job) = state.jobs.job(HOME_UPDATE_CHECK_JOB_ID) else { + // Re-arm the same as the terminal-job path below: without this, + // a vanished job would leave `update_check_due_at` in the past, + // so every subsequent tick would spawn a new check immediately. + state.update_status_pending = false; + state.update_check_due_at = std::time::Instant::now() + UPDATE_CHECK_INTERVAL; + return Vec::new(); + }; + if !job.is_terminal() { + return Vec::new(); + } + state.update_status = match &job.status { + rocm_dash_core::state::JobStatus::Done { code: 0 } => job + .output + .iter() + .rev() + .find_map(|line| serde_json::from_str::(line).ok()) + .map_or(UpdateStatus::Error, |doc| reduce_update_json(&doc)), + _ => UpdateStatus::Error, + }; + state.update_status_pending = false; + state.update_check_due_at = std::time::Instant::now() + UPDATE_CHECK_INTERVAL; + return Vec::new(); + } + + if std::time::Instant::now() < state.update_check_due_at { + return Vec::new(); + } + + if std::env::var_os("ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK").is_some() { + return Vec::new(); + } + + let fx = state + .jobs + .apply(rocm_dash_core::state::StateEvent::StartJob { + id: HOME_UPDATE_CHECK_JOB_ID.to_owned(), + cmd: crate::ui::exec::resolve_exe(), + args: vec![ + "update".to_owned(), + "--json".to_owned(), + "--timeout-secs".to_owned(), + HOME_UPDATE_CHECK_TIMEOUT_SECS.to_string(), + ], + }); + if !fx.is_empty() { + state.update_status_pending = true; + } + fx +} + +/// The dashboard's entire startup gate: an overlay opens only when an +/// explicit `Focus` was resolved from the command line. `event_loop` calls +/// this directly (rather than inlining the `match`) so a regression test can +/// exercise the actual gate instead of `AppState::new`, which takes no focus +/// argument and can't observe it either way. +fn apply_startup_focus( + state: &mut AppState, + focus: Option, +) -> Vec { + match focus { + Some(focus) => open_overlay_for_focus(state, focus), + None => Vec::new(), + } +} + +pub async fn run(args: ResolvedArgs) -> color_eyre::Result<()> { + // Install the termination-signal watcher BEFORE switching the terminal into + // raw/alternate-screen mode. A signal that arrives during startup must find + // the listeners already registered; otherwise it takes the default + // disposition and kills the process while the terminal is still in raw mode + // — the exact broken-terminal state this guards against. Registration + // failure is propagated here (via `?`), before any terminal state is + // mutated, so we never enter raw mode without a working restore path. See + // `spawn_termination_watcher` for the exit-code and no-unwind semantics. + let signal_task = spawn_termination_watcher()?; + + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; + let backend = CrosstermBackend::new(stdout); + let mut terminal = Terminal::new(backend)?; + + let res = event_loop(&mut terminal, &args).await; + + // The session ended on its own — the signal watcher is no longer needed and + // must not linger to fire (and re-restore/exit) after a clean return. + // What `abort()` buys is cancelling a watcher still parked on its `.await`: + // `spawn_termination_watcher`'s body runs straight from `await_termination` + // into `process::exit` with no await point in between, so once the signal + // has resolved there is nowhere for the cancellation to land. A signal + // arriving in the instant before this call therefore still ends the process + // with `128 + signo` instead of returning `res`. Narrow window, accepted: + // the process is exiting either way, and the terminal is restored on both + // paths. + signal_task.abort(); + + restore_after_session(&SHUTTING_DOWN, restore_terminal); + res +} + +/// Teardown for a dash *session* that has ended — the counterpart to the +/// watcher's and [`exit_on_ctrl_c`]'s teardown, for the one restore path that +/// does **not** end the process. +/// +/// Best-effort, reusing the exact teardown the signal path runs so the two +/// cannot drift, and never letting teardown failures override the session +/// result: if the controlling terminal already went away (e.g. the PTY closed on +/// quit), these writes can fail with a broken pipe — that must not turn a clean +/// exit into a non-zero one (every step inside `restore` is best-effort). +/// +/// # Why this reads the latch instead of claiming it +/// +/// [`SHUTTING_DOWN`] is a one-shot *process-exit* arbiter: whoever claims it +/// restores the terminal and calls `process::exit`, and nothing ever releases +/// it because there is no "after" to release into. `run` returning is the one +/// teardown with an after — bare `rocm` is a persistent hub, so `run` hands +/// control back to a live launcher menu that must keep painting, keep honouring +/// Ctrl-C, and keep being killable. Claiming the latch here wedged all three at +/// once: the render gate refused every subsequent launcher frame (a blank front +/// door), [`exit_on_ctrl_c`] parked forever, and every later `await_termination` +/// lost the claim and returned without exiting — a signal-swallowing, blank, +/// unkillable hub after the user's first flow. +/// +/// So the latch is *read*: if a watcher (or a typed Ctrl-C) has already claimed +/// the exit, it owns the teardown and is microseconds from ending the process, +/// and a second restore here would be pure redundancy. Otherwise this session +/// restores the terminal and leaves the latch untouched for the windows that +/// come after it. +/// +/// Single-writer is preserved by [`RESTORE_LOCK`] inside [`restore_terminal`] +/// rather than by this read, which is deliberately racy on its own: `abort()` +/// above cannot stop a watcher already resumed past its `.await` and inside its +/// own synchronous `restore_terminal(); process::exit(code)`, so a SIGTERM +/// landing in the same instant the user presses `q` can still put two threads on +/// this path. They serialise on the lock; they no longer contend for the latch. +/// +/// `restore` is a parameter so a unit test can assert both halves of the +/// contract — that the teardown runs, and that it leaves the latch unclaimed — +/// without writing escape sequences to the stdout every other test shares. +pub(crate) fn restore_after_session(latch: &AtomicBool, restore: impl FnOnce()) { + if shutdown_claimed_on(latch) { + return; + } + restore(); +} + +/// Best-effort teardown of the terminal modes `run` set up. Disables raw mode +/// (process-global terminal state, so this can safely run from the signal +/// watcher even though the [`Terminal`] backend owns its own `stdout` clone), +/// then writes the alt-screen/mouse/cursor restore sequences to a fresh +/// `stdout`. Every step is best-effort: on a signal we are about to exit anyway, +/// and a vanished controlling terminal must not turn teardown into a panic. +/// +/// # Ordering against the renderer +/// +/// This writer is genuinely concurrent with the renderer: the watcher runs on a +/// Tokio worker thread while frames are drawn on the thread that owns the loop +/// (`block_on`'s thread for a dashboard session, the synchronous menu thread for +/// the launcher hub). A frame that lands *after* this function's bytes cannot +/// re-enter the alternate screen — `EnterAlternateScreen` is emitted exactly +/// once at startup and `Terminal::draw` never re-emits it — but every frame ends +/// by hiding the cursor (`Terminal::draw` emits `Hide` unconditionally when the +/// frame sets no cursor position), so a late frame undoes the show-cursor half of +/// this restore and leaves the user with an invisible cursor. +/// +/// Gating the loops on [`shutdown_claimed_on`] is necessary but *not* sufficient, +/// and the gap is not cosmetic: the claim cannot stop a frame that already passed +/// the gate, and that frame's tail is written after these bytes. Measured, rather +/// than argued — SIGINT delivered to a real `rocm dash --demo` under a pty, 300 +/// runs on a loaded Linux box: 6 of them ended with this restore spliced into the +/// middle of a frame (`…;48;` `ESC[?1049l … ESC[?25h` `2;19;20;22m qui…ESC[?25l`), +/// leaving the alternate screen with the cursor still hidden. That is exactly +/// what the E2E scenario `dash-sigint-restores-terminal` reported from the WSL2 +/// lane, and it reproduces identically on the commits before this PR, so it is +/// the long-standing shape of the race and not a new one. +/// +/// So the window is closed rather than accepted: [`TERMINAL_WRITE_LOCK`] is held +/// across one whole frame and across one whole restore, and both render loops +/// re-check the latch *while holding it*. Every interleaving then ends with these +/// bytes last — either the frame completes first and this restore follows it, or +/// this restore goes first and the gate suppresses the frame behind it. +/// +/// This adds no new hang: a renderer blocked mid-frame on a full terminal blocks +/// this restore's own writes to that same terminal just as surely, so the lock +/// can only make us wait where we were already waiting. +/// +/// # Ordering against another restore +/// +/// The same lock covers the second hazard. Two teardowns can run at once: a +/// signal watcher resumed past its `.await` races [`run`]'s clean-quit teardown +/// (which deliberately does not claim the exit latch — see +/// [`restore_after_session`]), and two watchers on two runtimes both wake on one +/// process-global signal. Holding [`TERMINAL_WRITE_LOCK`] makes this function the +/// single writer for the duration of one teardown, so two threads can never +/// interleave `write_restore_sequences` on the same stdout. +pub(crate) fn restore_terminal() { + // Poisoning is irrelevant here: nothing inside can panic (every step is + // best-effort), and a teardown skipped because some *other* thread panicked + // mid-restore is strictly worse than running it again. + let _guard = lock_terminal_writer(); + // `disable_raw_mode` mutates the real terminal (there is no in-memory + // equivalent), so it stays outside the testable sequence writer below. + let _ = disable_raw_mode(); + let _ = write_restore_sequences(&mut io::stdout()); +} + +/// Serialises everything that writes to the process's one stdout while the TUI +/// owns it: one whole frame from either render loop, and one whole teardown in +/// [`restore_terminal`]. +/// +/// Distinct from [`SHUTTING_DOWN`], and deliberately so: the latch answers "is +/// the process exiting" (one-shot, never released), this answers "is someone +/// writing the terminal right now" (re-armable, held for the length of one frame +/// or one teardown). Conflating them is what made a clean session exit +/// permanently wedge the launcher hub. +/// +/// The two are used *together* in the render gates — the latch is re-read under +/// this lock — because neither alone is enough: the lock without the latch would +/// merely order a late frame after the restore, and the latch without the lock +/// cannot stop a frame that has already passed the gate. See the ordering notes +/// on [`restore_terminal`]. +static TERMINAL_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + +/// Take exclusive ownership of the terminal for the length of one frame or one +/// teardown. Poisoning is recovered from rather than propagated: a teardown or a +/// frame skipped because some *other* thread panicked while holding the lock is +/// strictly worse than doing it anyway. +/// +/// `pub(crate)` so the launcher's menu loop — the crate's other render loop — +/// can take the same lock around its own frame. +pub(crate) fn lock_terminal_writer() -> std::sync::MutexGuard<'static, ()> { + TERMINAL_WRITE_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) +} + +/// Write the escape sequences that undo `run`'s terminal setup — leave the +/// alternate screen, disable mouse capture, show the cursor — to `out`. Split +/// from [`restore_terminal`] so a unit test can drive an in-memory sink and +/// assert the emitted bytes, rather than writing to the process's shared stdout +/// (which races every other test in the single-process `cargo test` lane). +fn write_restore_sequences(out: &mut W) -> io::Result<()> { + execute!( + out, + LeaveAlternateScreen, + DisableMouseCapture, + crossterm::cursor::Show + ) +} + +/// Register the termination-signal listeners on the current Tokio runtime and +/// spawn a detached watcher that restores the terminal and exits `128 + signo`. +/// +/// Returns the task handle so a caller whose process ends with the session +/// (`rocm dash`) can `abort()` it on a clean return; the persistent launcher hub +/// instead keeps its runtime alive and drops the handle, letting the watcher +/// live for the whole process so bare `rocm` stays killable across the sessions +/// it builds and drops (Tokio never unregisters its libc handler, so a +/// per-session watcher would go deaf the moment its runtime is dropped — see +/// `dash::run_launcher`). +/// +/// Registration happens here, synchronously, and is surfaced via `?`; callers +/// invoke this BEFORE entering raw/alternate-screen mode so a failure never +/// leaves the terminal switched with no restore path, and a signal arriving +/// during startup is latched by the already-installed OS handlers. +/// +/// The watcher ends the process with [`std::process::exit`], which does not +/// unwind — this is deliberate. SIGTERM/SIGINT means "stop now", so we restore +/// the terminal and leave promptly rather than racing an orderly teardown +/// against an imminent default-disposition kill. Two consequences are accepted +/// as the intended behavior: (1) an in-flight focused install/serve child is +/// left to the OS rather than reaped via `kill_on_drop`, matching the +/// pre-existing default disposition and avoiding truncating a mid-write child on +/// the way out; and (2) `run_async`'s embedded-daemon socket is not unlinked +/// here, but the daemon unlinks a stale socket on its next bind, so it self-heals. +/// +/// More than one watcher can be live at once — bare `rocm` escalates from the +/// hub into a dashboard session, so the hub's process-lifetime watcher and +/// `run`'s session watcher coexist — and Tokio's signal registry is +/// process-global: one `kill` notifies *every* subscriber regardless of which +/// runtime registered it. Both watchers therefore wake on the same signal. The +/// [`SHUTTING_DOWN`] latch arbitrates: only the first one through restores the +/// terminal and exits, so two threads never race unsynchronised writes to +/// stdout nor call `std::process::exit` concurrently. See [`await_termination`]. +pub fn spawn_termination_watcher() -> color_eyre::Result> { + let termination = TerminationSignals::register()?; + Ok(tokio::spawn(async move { + let Some(code) = await_termination(termination, &SHUTTING_DOWN).await else { + // A sibling watcher already claimed the shutdown and is about to + // `exit`; this one must do nothing at all. + return; + }; + restore_terminal(); + std::process::exit(code); + })) +} + +/// Process-global "some watcher has claimed the termination path" latch. +/// +/// Deliberately process-global rather than threaded through `ResolvedArgs`: the +/// hazard is two watchers on two *runtimes*, and a path-independent latch covers +/// every call site (present and future) without each one having to know whether +/// an outer watcher already exists. +/// +/// One-shot by construction: it is claimed only by paths that go on to call +/// `std::process::exit` (the watcher body and [`exit_on_ctrl_c`]), so there is +/// no "after" to release it into, and every reader — the two render gates, +/// [`await_termination`]'s arbitration, [`restore_after_session`] — may treat a +/// claimed latch as "this process is ending". A teardown that does *not* end the +/// process must therefore never claim it; see [`restore_after_session`] for the +/// three separate ways that wedged the launcher hub. Mutual exclusion between +/// concurrent restores is [`RESTORE_LOCK`]'s job, not this latch's. +pub(crate) static SHUTTING_DOWN: AtomicBool = AtomicBool::new(false); + +/// Claim the single-shot shutdown path on `latch`. Returns `true` exactly once — +/// for whichever caller wins the swap — and `false` for every caller after it. +/// +/// `SeqCst` because correctness here is "exactly one winner across threads", not +/// ordering of surrounding data; the stronger ordering costs nothing on a path +/// that runs at most once per process. +/// +/// Parameterised over the latch (rather than reading [`SHUTTING_DOWN`] directly) +/// so tests can drive a fresh latch and stay order-independent — the process +/// global cannot be reset once a test has set it. +fn claim_shutdown(latch: &AtomicBool) -> bool { + !latch.swap(true, Ordering::SeqCst) +} + +/// Whether the shutdown path has already been claimed on `latch` — by a signal +/// watcher or by a typed Ctrl-C — meaning the terminal is being restored and the +/// process is about to exit. +/// +/// Both render gates ([`draw_frame_unless_shutting_down`] and the launcher's +/// `draw_menu_unless_shutting_down`) consult this before every frame so a `draw` +/// cannot land after [`restore_terminal`] has run and undo it. See the ordering +/// note on [`restore_terminal`] for why that is the chosen fix rather than a lock +/// on every terminal write. +/// +/// Takes the latch explicitly rather than reading [`SHUTTING_DOWN`] directly so +/// a test can drive a fresh one and stay order-independent — the process global +/// cannot be reset once a test has set it (same reason [`claim_shutdown`] is +/// parameterised). Production callers pass [`SHUTTING_DOWN`]. +pub(crate) fn shutdown_claimed_on(latch: &AtomicBool) -> bool { + latch.load(Ordering::SeqCst) +} + +/// Whether `k` is a typed Ctrl-C. +/// +/// Raw mode is why this must be a key match at all. `enable_raw_mode` clears +/// `ISIG` on Unix and `ENABLE_PROCESSED_INPUT` on Windows; with those off the +/// terminal driver does NOT translate the keystroke into SIGINT (or raise +/// `CTRL_C_EVENT`) — it hands the application the byte `0x03` like any other +/// key. So while the TUI is up, [`TerminationSignals`] covers an *externally* +/// delivered `kill -INT` / `GenerateConsoleCtrlEvent` (and a Ctrl-C during the +/// startup window before raw mode is entered), but never the gesture a user +/// performs inside the dashboard. That one arrives here. +/// +/// Matches lowercase `c` only, mirroring +/// [`crate::ui::job_console::on_console_key`]: terminals commonly bind +/// Ctrl+Shift+C to copy, and claiming it would break a paste workflow. +pub(crate) const fn is_ctrl_c(k: KeyEvent) -> bool { + matches!(k.code, KeyCode::Char('c')) && k.modifiers.contains(KeyModifiers::CONTROL) +} + +/// Whether a key event must end the dashboard session: a typed Ctrl-C, unless a +/// console for a *still-running* job is displayed. +/// +/// Split out of the event loop's match guard so the precedence is testable +/// without a terminal. The console exception is the whole reason this is not +/// simply [`is_ctrl_c`]: while a job is running, Ctrl+C already means "cancel +/// this running job" (`ui::job_console::on_console_key`), which is the documented +/// way to stop a focused install/serve without truncating it — killing the +/// process instead would be a regression. +/// +/// That justification stops the moment the job reaches a terminal state, and the +/// exception has to stop with it. No manager clears its `active_job` on +/// completion — it is cleared only when the user dismisses the console with +/// Esc/Enter — so a finished console stays on screen indefinitely. Exempting it +/// unconditionally made Ctrl-C fall through to `on_console_key`, which emits +/// `CancelJob`, which the reducer ignores on a terminal job: the keystroke was a +/// **silent no-op**, leaving the user in raw mode on the alternate screen. Gating +/// on the job being non-terminal keeps "cancel the job" winning only while there +/// is a job left to cancel. +fn ctrl_c_should_exit(state: &AppState, k: KeyEvent) -> bool { + let job = state.active_job_id().and_then(|id| state.jobs.job(id)); + // The uncodified invariant this predicate leans on: a manager's `active_job` + // is the id of a job it just spawned into `state.jobs`, so the lookup above + // resolves. Thirteen manager modules set `active_job`; nothing enforces the + // pairing at a type level. If it ever breaks, `is_none_or` below silently + // reads "no job console is up" and Ctrl-C would quit out from under a + // *running* job — the one case this function exists to prevent. Assert it in + // debug builds so a manager that sets an id without a matching job trips the + // suite rather than shipping the wrong precedence. + debug_assert!( + state.active_job_id().is_none() || job.is_some(), + "active_job id {:?} is not in the jobs map; ctrl_c_should_exit would \ + treat a live job console as absent", + state.active_job_id() + ); + // Reads as: no job console is up, or the one that is has already finished. + is_ctrl_c(k) && job.is_none_or(rocm_dash_core::state::JobState::is_terminal) +} + +/// End the process from a typed Ctrl-C, taking exactly the path an externally +/// delivered SIGINT takes: claim the shutdown latch, restore the terminal, exit +/// [`EXIT_CODE_SIGINT`]. Shared by the dashboard event loop and the launcher +/// menu — the two key loops are separate, and routing both here is what stops +/// the gesture from meaning different things in the two windows. +/// +/// Never returns, and (like the watcher) exits without unwinding; see +/// [`spawn_termination_watcher`] for the consequences that are accepted there +/// and apply identically here. +pub(crate) fn exit_on_ctrl_c() -> ! { + if claim_shutdown(&SHUTTING_DOWN) { + restore_terminal(); + std::process::exit(EXIT_CODE_SIGINT); + } + // Lost the race to a watcher that has already claimed the shutdown and is + // microseconds from `exit`. Park rather than racing a second + // `std::process::exit`; the winner ends the process. `park` is allowed to + // wake spuriously, hence the loop. + loop { + std::thread::park(); + } +} + +/// Park until a termination signal arrives, then arbitrate on `latch`. +/// +/// Returns `Some(128 + signo)` for the single watcher that wins the latch — that +/// caller must restore the terminal and exit with the code — and `None` for any +/// other watcher woken by the same process-global signal delivery. +/// +/// Split out of [`spawn_termination_watcher`]'s task body so a test can drive the +/// whole register → receive → arbitrate path in-process; the body itself ends in +/// `std::process::exit` and can never be unit-tested. +async fn await_termination(termination: TerminationSignals, latch: &AtomicBool) -> Option { + let code = termination.recv().await; + claim_shutdown(latch).then_some(code) +} + +/// Termination-signal listeners, registered up front so a signal that arrives +/// during terminal setup is latched by the OS/Tokio rather than taking the +/// default disposition. +/// +/// [`TerminationSignals::register`] installs the OS handlers and is called +/// BEFORE the terminal is switched into raw/alternate-screen mode, so a +/// registration failure is surfaced (via `?`) before any terminal state is +/// mutated. [`TerminationSignals::recv`] then parks the watcher task until a +/// signal fires, returning the conventional `128 + signo` exit code the process +/// should report (SIGINT → 130, SIGTERM → 143). +/// +/// Scope, stated narrowly because it is easy to overclaim: this covers signals +/// that arrive as signals — `kill -TERM` / `kill -INT` from a supervisor or +/// another process, and anything delivered during the startup window before +/// `enable_raw_mode`. It does NOT cover a Ctrl-C typed at the running TUI: raw +/// mode clears `ISIG`, so the terminal driver never turns that keystroke into a +/// SIGINT and it arrives as a key event instead. See [`is_ctrl_c`], which both +/// key loops route to this same restore-and-exit path. +#[cfg(unix)] +struct TerminationSignals { + sigterm: tokio::signal::unix::Signal, + sigint: tokio::signal::unix::Signal, +} + +#[cfg(unix)] +impl TerminationSignals { + fn register() -> color_eyre::Result { + use tokio::signal::unix::{SignalKind, signal}; + + // Register both handlers before entering raw mode. `?` on each + // propagates a setup failure instead of parking forever; and if the + // second registration fails, the first is dropped (unregistered) as we + // return the error, so we never leave one signal silently swallowed. + Ok(Self { + sigterm: signal(SignalKind::terminate())?, + sigint: signal(SignalKind::interrupt())?, + }) + } + + async fn recv(mut self) -> i32 { + tokio::select! { + _ = self.sigterm.recv() => EXIT_CODE_SIGTERM, + _ = self.sigint.recv() => EXIT_CODE_SIGINT, + } + } +} + +/// Windows analog: console control events, which would otherwise skip terminal +/// restoration the same way. Tokio exposes the two as separate streams, so BOTH +/// are registered — `ctrl_c()` subscribes only to `CTRL_C_EVENT` and would miss +/// a Ctrl-Break. Ctrl-Break is the harder "terminate" gesture and maps to the +/// SIGTERM code; Ctrl-C maps to the SIGINT code. +/// +/// Scope, narrowly, because the two arms differ and the difference matters: +/// +/// - Ctrl-Break: `ENABLE_PROCESSED_INPUT` does not affect `CTRL_BREAK_EVENT`, so +/// this arm is live for the whole session and is what actually terminates a +/// running TUI through the signal path. +/// - Ctrl-C: crossterm's raw mode clears `ENABLE_PROCESSED_INPUT`, and with that +/// flag off the console delivers a typed Ctrl+C into the input buffer as a KEY +/// EVENT and never raises `CTRL_C_EVENT`. This stream therefore cannot fire +/// while the TUI is up; it is kept for the startup window before +/// `enable_raw_mode` and for a `GenerateConsoleCtrlEvent` sent by another +/// process. The typed gesture is handled as a key instead — see [`is_ctrl_c`], +/// which routes it to the same restore path. +#[cfg(windows)] +struct TerminationSignals { + ctrl_c: tokio::signal::windows::CtrlC, + ctrl_break: tokio::signal::windows::CtrlBreak, +} + +#[cfg(windows)] +impl TerminationSignals { + fn register() -> color_eyre::Result { + use tokio::signal::windows::{ctrl_break, ctrl_c}; + + // `?` propagates a registration failure before raw mode is entered, + // rather than the old `let _ = ctrl_c().await` which treated a failed + // registration as a received Ctrl-C and exited immediately. + Ok(Self { + ctrl_c: ctrl_c()?, + ctrl_break: ctrl_break()?, + }) + } + + async fn recv(mut self) -> i32 { + tokio::select! { + _ = self.ctrl_c.recv() => EXIT_CODE_SIGINT, + _ = self.ctrl_break.recv() => EXIT_CODE_SIGTERM, + } + } +} + +/// Conventional shell exit code for a process terminated by SIGINT (128 + 2). +const EXIT_CODE_SIGINT: i32 = 130; +/// Conventional shell exit code for a process terminated by SIGTERM (128 + 15). +const EXIT_CODE_SIGTERM: i32 = 143; + +/// Draw one dashboard frame, unless a shutdown has already been claimed on +/// `latch`. +/// +/// This is the render gate. A termination may be in flight on another thread +/// (the signal watcher, or a typed Ctrl-C): the terminal is being restored, so +/// the frame must not land after the restore and undo it. See the ordering note +/// on [`restore_terminal`]. +/// +/// The gate is the latch read **plus** [`lock_terminal_writer`], and needs both. +/// The lock is taken first and held across the whole frame, so the restore can +/// neither splice its bytes into the middle of this frame nor be overtaken by its +/// tail; the latch is then read *under* that lock, so a restore that got there +/// first suppresses this frame entirely instead of merely preceding it. +/// +/// Extracted from the event loop's body — and parameterised over the backend and +/// the latch — purely so the gate is *testable*: a test can drive a +/// `TestBackend`, claim a local latch, and assert no cells were painted. Inlined +/// in the loop it was unreachable from any test, and deleting it turned nothing +/// red. +/// +/// Focused host renders overlay-only (no header / tabs / dock / footer chrome); +/// the dashboard renders the full shell. +fn draw_frame_unless_shutting_down( + terminal: &mut Terminal, + state: &mut AppState, + focus: Option, + latch: &AtomicBool, +) -> Result<(), ::Error> { + let _writer = lock_terminal_writer(); + if shutdown_claimed_on(latch) { + return Ok(()); + } + if should_skip_daemon(focus) { + terminal.draw(|f| ui::draw_focused(f, state))?; + } else { + terminal.draw(|f| ui::draw(f, state))?; + } + Ok(()) +} + +async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Result<()> { + let (tx, mut rx) = mpsc::unbounded_channel::(); + // Job-bridge channel (Phase 3 Wave 1): the async runtime streams + // `StateEvent`s (JobLine/JobDone/JobErr) for operational screens here. + let (job_tx, mut job_rx) = mpsc::unbounded_channel::(); + // Retain a sender for chat replies BEFORE `tx` is moved into the client / + // replay task below — the spawned agent task feeds replies back through the + // same `rx.recv()` arm the daemon events already use (no new plumbing). + let chat_tx = tx.clone(); + let replay_controller = if let Some(path) = args.replay.clone() { + Some(crate::replay::spawn(path, tx)) + } else if should_skip_daemon(args.focus) { + // Focused host: no daemon client. The overlay streams its own job via + // the job-bridge and the telemetry chrome isn't drawn, so a live + // connection would only spawn an unused embedded daemon. Drop `tx` + // (its `chat_tx` clone keeps `rx` alive for the loop); nothing is sent. + drop(tx); + None + } else { + client::spawn(args.connect.clone(), tx); + None + }; + + let mut events = EventStream::new(); + let mut tick = interval(Duration::from_millis(250)); + let connect_label = match &args.replay { + Some(p) => format!( + "replay:{}", + p.file_name().and_then(|n| n.to_str()).unwrap_or("?") + ), + None => args.connect.clone(), + }; + 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; + // 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. + state.runtimes = args.runtimes.clone(); + // Automations manager source (Phase 3 Wave 3), adapted by the bin. + state.automations = args.automations.clone(); + // Tool-executor seam (Phase 2 plumbing), injected by the bin; None for + // demo/replay/mock. Phase 3 will use it. + state.tool_executor = args.tool_executor.clone(); + // Daemon-tailed bench CSV path for the bench-run form's default --out. + state.bench_results_dir = args.bench_results_dir.clone(); + // Managed-service records that are no longer running, counted by the bin. + state.services_past_attempts = args.services_past_attempts; + // Focused host: open exactly the overlay for the requested flow (Examine + // also auto-runs its read-only job). `Focus::Setup` opens the onboarding + // overlay — the same wizard `rocm bootstrap setup` routes to. + let fx = apply_startup_focus(&mut state, args.focus); + crate::jobs::run_effects(fx, &job_tx); + state.replay = replay_controller.map(ReplayState::new); + // Both `--demo` (a generated session replayed) and `--replay ` present + // non-live data, so mark the session simulated for the honesty chrome. + state.simulated = state.replay.is_some(); + + // Resolve the chat backend. `--chat-mock` short-circuits detection with a + // deterministic offline MockAgentClient (no live LLM, no network); otherwise + // we auto-detect the endpoint (the std-TCP probe runs once on a blocking + // thread before the first frame) and build the Rig backend. + let mut agent: Option> = if should_skip_daemon( + args.focus, + ) { + // Focused host: Setup/Serve/Diagnose never chat. Skip endpoint detection + // and backend construction entirely — no probe, no network, no OAuth + // default. `chat_llm` stays `None` and the Chat tab is never drawn here. + None + } else if args.chat_mock { + state.set_chat_config( + Some(crate::llm::LlmConfig { + base_url: "mock://offline-demo".to_string(), + model: "mock-agent".to_string(), + api_key: None, + auth_header: None, + }), + true, + ); + Some(std::sync::Arc::new( + crate::agent::MockAgentClient::with_tool_call_and_approval_trigger( + "GPU-2 is running hot: 87% util, 71°C, drawing 250 W (90 GB/192 GB VRAM).", + "gpu_status", + "install the sdk", + crate::tool_exec::ApprovalIntent { + title: "Install TheRock ROCm SDK?".to_string(), + body: vec![ + "install_sdk --channel release --format wheel --prefix ~/rocm-sdk" + .to_string(), + ], + name: "install_sdk".to_string(), + arguments: serde_json::json!({ + "channel": "release", + "format": "wheel", + "prefix": "~/rocm-sdk", + }), + }, + chat_tx.clone(), + ), + ) as std::sync::Arc) + } else { + // An endpoint we launched ourselves (managed-services registry) takes + // priority over the well-known default port — this is how a tool-launched + // engine on a non-default port (e.g. vLLM on :11435) is found. It does + // NOT override an explicitly configured `chat_url`/env URL, so config + // precedence is preserved (we only consult the registry when neither is + // set, i.e. where the well-known default would otherwise be probed). + // When neither an explicit URL (CLI/config) nor an env URL is set, run + // the SAME full local-engine detection the manual 'd' path uses: + // registry-first (an engine we launched ourselves, on whatever port it + // bound), then a probe of the well-known Lemonade/vLLM/rocm-serve + // ports (parallelized — see `llm::detect_local_endpoint` — so a cold + // start with no server doesn't pay 3x the probe timeout), plus a + // best-effort served-model fetch. This is what lets a local server win + // over the ChatGPT cloud default at startup instead of only the single + // well-known :8000 port that a bare `resolve_llm_config` probe covers. + // + // NOTE: unmerged PR #97 also touches this branch (model discovery when + // `chat_model` is None, inside `resolve_llm_config`'s own fallback + // path) — this change is conflict-minimal by leaving the + // `resolve_llm_config` call below untouched. + // + // Gate on `chat_api_key.is_none()` too: local detection returns a + // keyless `detected_llm_config` (api_key/auth_header forced to None), + // so firing it when the user configured a key would SILENTLY DROP that + // key and 401 at request time. A configured key means "use my + // configured backend", so skip the swap and let `resolve_llm_config` + // carry the key through its normal precedence. + let detection_ran = chat::should_detect_local_chat( + args.chat_url.as_deref(), + args.chat_env_url.as_deref(), + args.chat_api_key.as_deref(), + ); + let detected = if detection_ran { + detect_local_chat(state.tool_executor.clone()).await + } else { + None + }; + let probe_target = args + .chat_url + .clone() + .or_else(|| args.chat_env_url.clone()) + .unwrap_or_else(|| crate::llm::DEFAULT_CHAT_BASE_URL.to_string()); + // A detected endpoint (managed or probed) is already verified. When + // detection ran and found nothing it already probed the well-known + // vLLM :8000 port (== `DEFAULT_CHAT_BASE_URL`), so re-probing the same + // fallback target here is redundant and just burns another probe + // timeout on a cold start — treat that as unreachable directly. + // Otherwise (an explicit URL/env/key path) TCP-probe the target. + let startup_outcome = startup_chat_outcome(detection_ran, detected.is_some()); + let probe_ok = match startup_outcome { + StartupChatOutcome::Local => true, + StartupChatOutcome::OAuth => false, + StartupChatOutcome::Configured => tokio::task::spawn_blocking(move || { + crate::llm::probe_endpoint(&probe_target, crate::llm::PROBE_TIMEOUT) + }) + .await + .unwrap_or(false), + }; + let llm = detected.or_else(|| { + crate::llm::resolve_llm_config( + args.chat_url.as_deref(), + args.chat_model.as_deref(), + None, + None, + args.chat_api_key.as_deref(), + args.chat_env_url.as_deref(), + args.chat_auth_header.as_deref(), + probe_ok, + ) + }); + // PR #97 port onto PR #100's startup flow: a *configured* URL (CLI/env) + // with no explicit model resolves to the `local-model` placeholder, + // which 404s on servers that register the model under its real id. Only + // the `Configured` outcome needs this — the `Local` outcome already + // carries a `/v1/models`-discovered model from `detect_local_chat`, and + // `OAuth` has no config. Discovery is gated inside the helper on + // `probe_ok` (an unreachable endpoint is never probed nor replaced) and + // on the absence of an explicit model (config precedence wins). + let llm = match llm { + Some(cfg) if startup_outcome == StartupChatOutcome::Configured => Some( + discover_configured_chat_model(cfg, args.chat_model.as_deref(), probe_ok).await, + ), + other => other, + }; + state.set_chat_config(llm, args.chat_auto_consent); + // No reachable local endpoint AND no key/url configured → the no-key + // ChatGPT OAuth default (device-code login surfaced in the chat tab). + // This restores the no-key login the vendored Codex path provided; it + // takes NO api_key (env-only invariant untouched — OAuth, not a key). + let no_key_no_endpoint = startup_outcome == StartupChatOutcome::OAuth; + if no_key_no_endpoint { + let oauth_tx = chat_tx.clone(); + crate::agent::ChatGptAgentClient::new( + args.chat_model.clone(), + args.inference_params(), + move |url, code| { + let _ = oauth_tx.send(ClientMsg::ChatReply { + text: format!( + "To enable chat, sign in to ChatGPT: open {url} and enter the code {code}" + ), + }); + }, + state.tool_executor.clone(), + Some(chat_tx.clone()), + ) + .ok() + .map(|c| c.with_preamble(args.chat_system_prompt.clone())) + .map(|c| std::sync::Arc::new(c) as std::sync::Arc) + } else { + // A build failure leaves `agent` None; a submit surfaces an error turn. + match &state.chat_llm { + Some(cfg) => build_local_agent( + cfg.clone(), + args.inference_params(), + state.tool_executor.clone(), + chat_tx.clone(), + args.chat_system_prompt.clone(), + ) + .ok(), + None => None, + } + } + }; + + // Snapshot the auto-detected local backend so `/provider local` can restore + // it after a switch to a remote provider. Without this, switching to OpenAI + // and back to local would leave `agent` pointing at the OpenAI backend + // (silent wrong-backend bug) — `build_chat_agent(Local)` returns None by + // design (Local is the inline-built backend), so the caller must restore the + // saved clone here. `Option>` clone is a cheap Arc refcount bump. + let mut local_agent = agent.clone(); + + loop { + draw_frame_unless_shutting_down(terminal, &mut state, args.focus, &SHUTTING_DOWN)?; + tokio::select! { + _ = tick.tick() => { + // Advance the animation clock so spinners cycle even while a + // job produces no new output. + state.tick_count = state.tick_count.wrapping_add(1); + // Drive the Home tab's Updates tile off a real periodic check. + // Never in `--demo`/`--replay` sessions: simulated data must + // never shell out or look live (see `AppState::simulated`). + if !state.simulated { + let fx = refresh_update_status(&mut state); + crate::jobs::run_effects(fx, &job_tx); + } + } + maybe_msg = rx.recv() => { + match maybe_msg { + Some(ClientMsg::Connecting) => state.conn = ConnState::Connecting, + Some(ClientMsg::Connected { host, daemon_version }) => { + state.conn = ConnState::Connected { host, version: daemon_version }; + } + Some(ClientMsg::Disconnected { reason }) => { + state.conn = ConnState::Disconnected { reason }; + state.latest = None; + } + Some(ClientMsg::Event(ev)) => state.apply_event(*ev), + Some(ClientMsg::ReplaySeek) => state.reset_for_seek(), + Some(ClientMsg::ReplayPosition { elapsed_s, total_s }) => { + if let Some(r) = state.replay.as_mut() { + r.elapsed_s = elapsed_s; + r.total_s = total_s; + } + } + Some(ClientMsg::ChatReply { text }) => state.on_chat_reply(text), + Some(ClientMsg::SlashToolReply { text }) => state.on_slash_tool_reply(text), + Some(ClientMsg::ChatError { message }) => state.on_chat_error(message), + Some(ClientMsg::ChatDetectResult { offer }) => state.set_detect_result(offer), + // A mutating tool (or slash command) surfaced an approval — + // open the modal; nothing executes until the operator approves. + Some(ClientMsg::ChatApprovalRequired { intent }) => state.open_approval(intent), + // An approved action finished: append the result turn and + // fire exactly one automatic follow-up agent turn. + Some(ClientMsg::ChatApprovalResult { text }) => state.on_approval_result(text), + // A `/plan` plan completed: render the review and (for a + // complete mutating action) hand it to the approval modal. + Some(ClientMsg::PlanReady { text, action }) => { + state.on_plan_ready(text, action); + } + None => break, + } + } + // Job-bridge events feed the operational-screen job model (Wave 1). + maybe_job = job_rx.recv() => { + if let Some(ev) = maybe_job { + let fx = state.jobs.apply(ev); + crate::jobs::run_effects(fx, &job_tx); + } + } + maybe_ev = events.next() => { + match maybe_ev { + // Only ACT on key presses. Terminals (notably Windows + // Terminal / ConPTY under WSL, and any with the kitty + // keyboard protocol) also emit Release/Repeat events; the + // general `handle_key` already drops non-Press, but the + // operational-overlay arms below dispatch straight to their + // managers and would otherwise process the SAME keystroke + // twice. That double-fire is what made Enter in the serve + // wizard's model picker re-open the picker (seeding it with + // the just-chosen model as a filter) instead of choosing. + // Swallow non-Press key events here, above every key arm, so + // the Press-only invariant holds for overlays too. + Some(Ok(CtEvent::Key(k))) if !is_actionable_key(k.kind) => { + let _ = k; + } + // A typed Ctrl-C. Raw mode means this reaches the app as a + // key event and never as SIGINT / `CTRL_C_EVENT` (see + // `is_ctrl_c`), so the signal watcher cannot see it: without + // this arm the first gesture a user reaches for does nothing + // at all and leaves them in a raw-mode terminal. Handled + // above every overlay so no screen can trap it, and routed + // through the same restore-and-exit path a real SIGINT takes. + // + // The one exception is a displayed job console, where Ctrl+C + // already means "cancel this running job" + // (`ui::job_console::on_console_key`, dispatched by the + // overlay arms below). Exiting the process there would be a + // regression, so that established meaning wins. + Some(Ok(CtEvent::Key(k))) if ctrl_c_should_exit(&state, k) => { + exit_on_ctrl_c(); + } + // The approval modal, when open, owns every remaining key + // (above every operational overlay and the general handler) + // so the operator's decision can't be pre-empted by a screen + // behind it. Only the Ctrl-C arm above outranks it. + // + // Spelling out the consequence, because it is the one that + // surprises: a typed Ctrl-C while an approval is pending + // QUITS the dashboard. It is not consumed by the modal and + // it is not a decline. That is deliberate — Ctrl-C is the + // gesture a user reaches for to get out of a program, and a + // modal that swallowed it would recreate the wedge this PR + // fixes (the pre-fix dashboard ignored Ctrl-C entirely and + // left the user in a raw-mode terminal), which is worse here + // than anywhere: the approval modal is exactly where someone + // wants out in a hurry. Nothing is lost by quitting — the + // pending action has NOT run (approval is what would run + // it), so declining and quitting leave identical state on + // disk; only the chat turn differs. The help surfaces say + // "quit" for Ctrl-C without exception for this modal, which + // is therefore accurate. + // + // To decline without quitting there are `n` (deny) and + // `Esc` / `q` (cancel) — `ui::approval::approval_key`, which + // ignores Ctrl-C, so without the arm above the gesture would + // be a silent no-op on this screen. + // On Approve: replay the approved action off the + // event loop (spawn_blocking) and post ChatApprovalResult. + // On Deny/Cancel: a declined turn, no execution. + Some(Ok(CtEvent::Key(k))) if state.approval_pending() => { + use crate::ui::approval::ApprovalVerdict; + match state.on_approval_key(k.code) { + Some(ApprovalVerdict::Approve) => { + if let Some((name, args)) = state.take_approval() { + match state.tool_executor.clone() { + Some(executor) => { + let reply_tx = chat_tx.clone(); + tokio::task::spawn_blocking(move || { + let text = run_approved(&executor, &name, &args); + let _ = reply_tx + .send(ClientMsg::ChatApprovalResult { text }); + }); + } + None => state.on_approval_result( + "ROCm tools unavailable in this mode".to_string(), + ), + } + } + } + Some(ApprovalVerdict::Deny | ApprovalVerdict::Cancel) => { + state.on_approval_declined(); + } + None => { /* cursor moved or key ignored — modal stays open */ } + } + } + // De-modal back-out: on any tab, when an inline manager is + // open at its root screen, Esc closes it and returns focus + // to the Actions list — intercepted BEFORE the per-manager + // key arms so the manager doesn't eat Esc first. `←` is left + // to the manager (some use it to cycle options). See + // `should_pane_back_out`'s doc comment for why this is no + // longer gated to ROCm/Serving. + Some(Ok(CtEvent::Key(k))) if state.should_pane_back_out(k.code) => { + state.close_overlays(); + state.pane_focus = PaneFocus::Actions; + } + // While a manager is showing its job console, the navigation + // keys pan the log (PgUp/PgDn = page, arrows = line). Routed + // BEFORE the per-manager arms (which would ignore them); the + // console action keys (Ctrl+C/q/Esc/Enter) are NOT scroll keys + // so they still fall through to `on_console_key`. + Some(Ok(CtEvent::Key(k))) + if state.has_active_console() && console_scroll_delta(k.code).is_some() => + { + let (dv, dh) = console_scroll_delta(k.code).unwrap_or((0, 0)); + state.scroll_console(dv, dh); + } + // Focused host only: while the hosted job is still RUNNING, + // swallow the console close keys (`q`, running-`Esc`) so the + // overlay is never nulled mid-job — which would trip the + // focused exit gate and tear the runtime down, killing the + // child via kill_on_drop. `Ctrl+C` (cancel) and the scroll + // keys above still flow, so the user can always stop a job; + // once it is terminal, `q`/`Esc` exit normally. Routed BEFORE + // the per-manager arms so the manager can't close first. + Some(Ok(CtEvent::Key(k))) + if focused_close_key_blocked(&state, args.focus, k.code) => {} + // The services-manager overlay, when open, owns all keys + // (and may spawn lifecycle jobs through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.services.is_some() => { + let fx = crate::ui::services_manager::on_key( + &mut state.services, + &mut state.jobs, + &state.instances, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The serve-wizard overlay, when open, owns all keys (and may + // spawn a launch job through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.serve_wizard.is_some() => { + let fx = crate::ui::serve_wizard::on_key( + &mut state.serve_wizard, + &mut state.jobs, + &state.model_recipes, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The engine-manager overlay, when open, owns all keys (and + // may stream an install job through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.engine_manager.is_some() => { + let fx = crate::ui::engine_manager::on_key( + &mut state.engine_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The examine overlay, when open, owns all keys (read-only + // `rocm examine` job through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.examine_manager.is_some() => { + let fx = crate::ui::examine_manager::on_key( + &mut state.examine_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The update overlay, when open, owns all keys (check/preview + // read-only; apply gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.update_manager.is_some() => { + let fx = crate::ui::update_manager::on_key( + &mut state.update_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The install overlay, when open, owns all keys (dry-run + // read-only; install gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.install_manager.is_some() => { + let fx = crate::ui::install_manager::on_key( + &mut state.install_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The logs overlay, when open, owns all keys (read-only + // `rocm logs` through the job-bridge). + Some(Ok(CtEvent::Key(k))) if state.logs_view.is_some() => { + let fx = crate::ui::logs_view::on_key( + &mut state.logs_view, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The runtime manager, when open, owns all keys (refresh + // read-only; activate/rollback/uninstall/adopt/import gated). + Some(Ok(CtEvent::Key(k))) if state.runtime_manager.is_some() => { + let fx = crate::ui::runtime_manager::on_key( + &mut state.runtime_manager, + &state.runtimes, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The onboarding wizard, when open, owns all keys (install / + // adopt gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.onboarding.is_some() => { + let fx = crate::ui::onboarding::on_key( + &mut state.onboarding, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The automations manager, when open, owns all keys (refresh + // read-only; enable/disable gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.automations_manager.is_some() => { + let fx = crate::ui::automations_manager::on_key( + &mut state.automations_manager, + &state.automations, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The command runner, when open, owns all keys (every + // command gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.command_screen.is_some() => { + let fx = crate::ui::command_screen::on_key( + &mut state.command_screen, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // The config & provider manager, when open, owns all keys + // (show read-only; provider toggles gated → job-bridge). + Some(Ok(CtEvent::Key(k))) if state.config_manager.is_some() => { + let fx = crate::ui::config_manager::on_key( + &mut state.config_manager, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + // Bench-run form, when open, owns all keys. + Some(Ok(CtEvent::Key(k))) if state.bench_run.is_some() => { + let fx = crate::ui::bench_run::on_key( + &mut state.bench_run, + &mut state.jobs, + k, + ); + crate::jobs::run_effects(fx, &job_tx); + } + Some(Ok(CtEvent::Key(k))) => { + let chat_ctx = ChatKeyCtx { + focused: state.chat_focused, + consent: state.chat_consent, + offer_pending: state.chat_detect_offer.is_some(), + }; + let action = handle_key(k, state.active_tab, &state.modal, chat_ctx); + if apply_action(&mut state, action) { + break; + } + } + Some(Ok(CtEvent::Mouse(me))) => { + let action = resolve_mouse(me, &state); + if apply_action(&mut state, action) { + break; + } + } + Some(Ok(CtEvent::Resize(_, _))) => { /* repaint */ } + // A terminal event-source error means the controlling + // terminal went away (e.g. the PTY/stdin closed) — the + // session is over, so quit cleanly rather than propagating a + // fatal error. Propagating it made `rocm chat` exit non-zero + // when its terminal closed before the first key was read + // (e.g. the acceptance PTY smoke under the embedded-daemon + // start delay); the legacy blocking reader treated this as + // end-of-session too. Mirrors the `None => break` EOF arm. + Some(Err(e)) => { + tracing::debug!(error = %e, "terminal event stream ended; quitting"); + break; + } + None => break, + _ => {} + } + } + } + + // A `/quit` (or `/exit`) slash command sets `should_quit` from inside + // the reducer; honor it here (mirrors the `KeyAction::Quit` break). + if state.should_quit { + break; + } + + // Focused host: the launcher hosts exactly one overlay. Once the user + // backs out of it at root (the per-manager `on_key` set its state to + // `None`), return so `app::run` hands control back to the launcher menu. + // `focused_should_exit` stays `false` while any sub-popup / job console + // keeps the overlay `Some`, so this never ejects mid-flow. No-op for the + // dashboard (`focus == None`). + if state.focused_should_exit(args.focus) { + break; + } + + // Drain a pending executor-backed read-only slash command (`/model`, + // `/daemon`). Off-thread (spawn_blocking) so the seam's synchronous + // execute() never blocks the async event loop; the concise summary + // returns via ClientMsg::SlashToolReply — its own message variant, so + // the slash-tool path never disturbs the agent's `chat_sending` flag. + if let Some(req) = state.slash_tool.take() { + match state.tool_executor.clone() { + Some(executor) => { + let reply_tx = chat_tx.clone(); + tokio::task::spawn_blocking(move || { + // One path for read-only AND mutating slash commands: + // `Result`/`Error` → a concise reply turn; an + // `ApprovalRequired` (mutating) → open the approval modal + // via ChatApprovalRequired (nothing executes yet). + let msg = match executor.execute(&req.name, &req.args) { + crate::tool_exec::RocmToolOutcome::ApprovalRequired(intent) => { + ClientMsg::ChatApprovalRequired { intent } + } + outcome => ClientMsg::SlashToolReply { + text: summarize_slash_tool(&req.label, &outcome), + }, + }; + let _ = reply_tx.send(msg); + }); + } + None => { + state.on_slash_tool_reply("ROCm tools unavailable in this mode".to_string()); + } + } + } + + // Drain a pending `/plan` natural-language plan. Off-thread + // (spawn_blocking) so the read-only `natural_language_plan` tool's + // synchronous execute() never blocks the async loop. The rendered plan + + // structured next action return via ClientMsg::PlanReady; the tool only + // PLANS — no mutation happens here. A complete mutating action is handed + // to the approval modal by `on_plan_ready`. + if let Some(req) = state.plan_request.take() { + match state.tool_executor.clone() { + Some(executor) => { + let reply_tx = chat_tx.clone(); + tokio::task::spawn_blocking(move || { + let args = serde_json::json!({ "request": req }); + let msg = match executor.execute("natural_language_plan", &args) { + crate::tool_exec::RocmToolOutcome::Result(v) => { + match parse_plan_result(&v) { + Some((text, action)) => ClientMsg::PlanReady { text, action }, + None => ClientMsg::SlashToolReply { + text: "/plan: planner returned no usable plan".to_string(), + }, + } + } + crate::tool_exec::RocmToolOutcome::Error(e) => { + ClientMsg::SlashToolReply { + text: format!("/plan failed: {e}"), + } + } + crate::tool_exec::RocmToolOutcome::ApprovalRequired(_) => { + ClientMsg::SlashToolReply { + text: + "/plan: planning is read-only and should not need approval" + .to_string(), + } + } + }; + let _ = reply_tx.send(msg); + }); + } + None => { + state.on_slash_tool_reply("ROCm tools unavailable in this mode".to_string()); + } + } + } + + // Drain a `/provider` switch (Phase 8). Rebuild the live `agent` for the + // newly-selected backend. `Local` reuses whatever the inline launch path + // built (it owns the auto-detect probe). `Openai`/`Anthropic` are built + // from `ResolvedArgs` keys (in-process seam, never argv). A build failure + // (e.g. missing key) leaves `agent` unchanged and surfaces an actionable + // error turn. Construction only — no network until the next submit. + if let Some(ProviderSwitch { previous, target }) = state.provider_switch.take() { + match target { + ChatProvider::Local => { + // Restore the auto-detected local backend saved before the + // event loop. `build_chat_agent(Local)` returns None by + // design, so the restore must happen here — otherwise a prior + // `/provider openai` would leave requests routed to OpenAI. + agent = local_agent.clone(); + state + .chat + .push(ChatTurn::system("switched to local".to_string())); + } + ChatProvider::Openai | ChatProvider::Anthropic => { + if let Some(new_agent) = + build_chat_agent(target, args, state.tool_executor.clone(), chat_tx.clone()) + { + agent = Some(new_agent); + state + .chat + .push(ChatTurn::system(format!("switched to {}", target.label()))); + } else { + // Revert the optimistic `active_provider` set by the slash + // handler back to the provider active BEFORE the switch + // attempt — not unconditionally Local — so the displayed + // provider stays honest (e.g. a failed openai→anthropic + // switch stays on openai). `agent` is never reassigned on a + // failed build, so it already matches `previous`; the two + // stay consistent (no stale-remote routing under a wrong + // label). + state.active_provider = previous; + let hint = if target == ChatProvider::Anthropic { + "anthropic requires ANTHROPIC_API_KEY in env or secure store" + } else { + "openai requires OPENAI_API_KEY in the environment" + }; + state.chat.push(ChatTurn::error(format!( + "could not switch to {}: {hint}", + target.label() + ))); + } + } + } + } + + // Drain the endpoint-rebuild edge (Phase 8 sibling). An accepted + // detected-local offer must re-point the LIVE `agent` — and the + // `/provider local` restore snapshot — at the new local backend. + // `accept_detect_offer` swaps `chat_llm` to the auth-free local config + // but stays I/O-free, so without this the stale startup agent keeps + // routing chat to the cloud gateway (wrong-backend 401 bug). The edge + // carries the provider active BEFORE the optimistic switch to `Local`; + // on failure we revert `active_provider` to it (mirrors the + // `provider_switch` drain) so the tab never shows `Local` while `agent` + // still points elsewhere. Construction only — no network until submit. + if let Some(previous) = state.chat_endpoint_rebuild.take() { + // `revert` restores the optimistic switch and surfaces an actionable + // error turn so the tab does not sit on `Local` with the old agent. + let revert = |state: &mut AppState, msg: String| { + state.active_provider = previous; + state.chat.push(ChatTurn::error(msg)); + }; + match state.chat_llm.clone() { + Some(cfg) => { + match build_local_agent( + cfg, + args.inference_params(), + state.tool_executor.clone(), + chat_tx.clone(), + args.chat_system_prompt.clone(), + ) { + Ok(arc) => { + agent = Some(arc.clone()); + // Refresh the restore snapshot so a later `/provider + // local` restores THIS accepted backend, not the + // stale startup one. + local_agent = Some(arc); + state + .chat + .push(ChatTurn::system("switched to local".to_string())); + } + Err(e) => revert( + &mut state, + format!("could not switch to the detected local endpoint: {e}"), + ), + } + } + // Edge raised but `chat_llm` is None (shouldn't happen after a + // real accept, but don't leave the tab stuck on `Local` with the + // old agent and no feedback). + None => revert( + &mut state, + "could not switch to the detected local endpoint: no endpoint configured" + .to_string(), + ), + } + } + + // Spawn the agent round-trip on the submit edge — keeps `apply_action` + // I/O-free. `chat_dispatch` is raised once by `submit_chat`; consume it + // so the in-flight request is spawned exactly once (not every tick). + if state.chat_dispatch { + state.chat_dispatch = false; + match agent.clone() { + Some(agent) => { + let history = state.chat.clone(); + let snapshot = state.state_snapshot(); + let reply_tx = chat_tx.clone(); + tokio::spawn(async move { + let msg = match agent.complete(&history, snapshot).await { + Ok(text) => ClientMsg::ChatReply { text }, + Err(e) => ClientMsg::ChatError { + message: e.to_string(), + }, + }; + let _ = reply_tx.send(msg); + }); + } + None => state.on_chat_error(NO_CHAT_BACKEND_MSG.to_string()), + } + } + + // Run the local-engine probe + `/v1/models` query on the detect edge, + // off the reducer. Raised once by `request_detect`; result returns via + // `ClientMsg::ChatDetectResult`. + if state.chat_detect_dispatch { + state.chat_detect_dispatch = false; + let reply_tx = chat_tx.clone(); + let executor = state.tool_executor.clone(); + tokio::spawn(async move { + let offer = detect_local_chat(executor).await; + let _ = reply_tx.send(ClientMsg::ChatDetectResult { offer }); + }); + } + + // Persist the accepted endpoint on the save edge (a small synchronous + // file write; the message surfaces success/failure on the gate is not + // shown once Accepted, so we keep it terse via tracing + chat_detect_msg). + if state.chat_persist_dispatch { + state.chat_persist_dispatch = false; + if let Some(cfg) = state.chat_llm.clone() { + match persist_chat_endpoint(&cfg.base_url, &cfg.model) { + Ok(path) => { + tracing::info!(?path, "saved chat endpoint to config"); + } + Err(e) => { + tracing::warn!(error = %e, "failed to save chat endpoint"); + state.chat_detect_msg = Some(format!("could not save config: {e}")); + } + } + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn st() -> AppState { + AppState::new("t".into(), "default-dark".into()) + } + fn press(code: KeyCode) -> KeyEvent { + KeyEvent::new(code, KeyModifiers::NONE) + } + + /// Serialises every test that touches the process-global signal machinery. + /// + /// The two lanes differ: Linux CI runs `cargo nextest` (one process per + /// test, so this lock is a no-op), while the required Windows lane runs + /// `cargo test`, which runs the whole binary's tests as THREADS IN ONE + /// PROCESS. There, `termination_watcher_parks_until_aborted` has a live + /// watcher whose body ends in `std::process::exit`; if the self-`kill` test + /// below ran concurrently, that watcher would wake on the other test's + /// signal and take the entire test binary down with exit 143. It would also + /// steal the signal the other test is asserting on. Holding this lock for + /// the whole of each test — including the runtime's `block_on` — makes the + /// two strictly sequential. + /// + /// The tests are written as plain `#[test]` + an explicit runtime (rather + /// than `#[tokio::test]`) precisely so the guard is held across `block_on` + /// without holding a `std` lock across an `.await`. + /// + /// One residue this lock cannot undo, recorded so it is not rediscovered as + /// a mystery: `TerminationSignals::register` installs Tokio's libc handler + /// for SIGINT/SIGTERM process-wide, and Tokio never unregisters it — not on + /// drop of the `Signal`, not on drop of the runtime. So from the first of + /// these tests onward, the rest of a single-process `cargo test` run (the + /// required Windows lane) is deaf to those signals: they are caught and + /// discarded instead of terminating the binary. Harmless for the suite as it + /// stands — nothing signals the test process except the test that does so + /// deliberately, under this lock — but any future test that expects a signal + /// to actually kill the test binary, or a CI step that relies on cancelling + /// it with SIGINT, must not assume the default disposition is still in place. + static SIGNAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + /// Upper bound on any `await_termination` in a test. The signal it waits for + /// is already queued before the await starts, so the real latency is + /// microseconds; this only exists so a broken registration fails the test + /// instead of parking the `.await` forever and burning the lane's job + /// timeout. A hanging test is worse than a failing one — it reports nothing. + /// + /// Gated because its only callers are: ungated, it is dead code on Windows + /// and `-D warnings` fails that lane. + #[cfg(unix)] + const SIGNAL_AWAIT_TIMEOUT: Duration = Duration::from_secs(10); + + /// A current-thread runtime with the signal driver enabled, which + /// `TerminationSignals::register` needs. + fn signal_test_runtime() -> tokio::runtime::Runtime { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("building a current-thread runtime for the signal tests") + } + + #[test] + fn only_the_first_caller_claims_the_shutdown_latch() { + // The guard that stops the hub's process-lifetime watcher and a + // session's watcher from both restoring the terminal and both calling + // `process::exit` on one signal. Driven on a local latch so the test + // never touches (or depends on the state of) the process global. + let latch = AtomicBool::new(false); + assert!(claim_shutdown(&latch), "the first claim must win"); + assert!(!claim_shutdown(&latch), "a second claim must lose"); + assert!(!claim_shutdown(&latch), "and so must every later one"); + + // Under contention there must still be exactly one winner: a + // non-atomic read-then-write would let several threads through. + let contended = AtomicBool::new(false); + let winners = std::sync::atomic::AtomicUsize::new(0); + std::thread::scope(|scope| { + for _ in 0..16 { + scope.spawn(|| { + if claim_shutdown(&contended) { + winners.fetch_add(1, Ordering::SeqCst); + } + }); + } + }); + assert_eq!( + winners.load(Ordering::SeqCst), + 1, + "exactly one of 16 racing watchers may claim the shutdown" + ); + } + + #[test] + fn claiming_the_shutdown_latch_suspends_rendering() { + // The narrow serialization that stops a frame landing after + // `restore_terminal()` has run and undoing it: both render loops gate on + // the SAME latch the shutdown path claims, and the claim happens before + // the restore begins. Driven on a local latch so the test never touches + // the process global. + let latch = AtomicBool::new(false); + assert!( + !shutdown_claimed_on(&latch), + "rendering must be allowed while no shutdown has been claimed" + ); + assert!(claim_shutdown(&latch), "the first claim must win"); + assert!( + shutdown_claimed_on(&latch), + "claiming the shutdown must suspend rendering, or a late frame can \ + repaint over the restored terminal" + ); + } + + #[test] + fn a_clean_session_teardown_restores_without_claiming_the_exit_latch() { + // `run` returning is the one restore path with an "after": bare `rocm` is + // a persistent hub, so control goes back to a live launcher menu. Claiming + // the one-shot exit latch here wedged that hub three ways at once — the + // render gate refused every later frame (blank front door), a typed Ctrl-C + // parked forever in `exit_on_ctrl_c`, and every later signal lost the + // claim in `await_termination` and was swallowed. So this path must + // restore the terminal and leave the latch exactly as it found it. + let latch = AtomicBool::new(false); + let restored = std::cell::Cell::new(false); + restore_after_session(&latch, || restored.set(true)); + assert!( + restored.get(), + "a clean session must restore the terminal it put into raw mode" + ); + assert!( + !shutdown_claimed_on(&latch), + "a teardown that returns to a live process must not claim the exit \ + latch — nothing ever releases it, so the hub is wedged from here on" + ); + + // The one thing it may key on the latch: when a watcher or a typed + // Ctrl-C has already claimed the exit, that owner is microseconds from + // `process::exit` and owns the teardown; restoring again is redundant. + let claimed = AtomicBool::new(true); + let restored_again = std::cell::Cell::new(false); + restore_after_session(&claimed, || restored_again.set(true)); + assert!( + !restored_again.get(), + "an exiting process's teardown belongs to whoever claimed the exit" + ); + } + + #[test] + fn ctrl_c_is_recognised_as_a_key_because_raw_mode_suppresses_the_signal() { + // Raw mode clears ISIG (and ENABLE_PROCESSED_INPUT on Windows), so a + // typed Ctrl-C never becomes a signal — it arrives here as a key event. + let ctrl = |c: char| KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL); + assert!(is_ctrl_c(ctrl('c')), "Ctrl+C must be recognised"); + assert!( + !is_ctrl_c(press(KeyCode::Char('c'))), + "a bare `c` must not terminate the session" + ); + assert!(!is_ctrl_c(ctrl('d')), "Ctrl+D is a different key"); + // Terminals commonly bind Ctrl+Shift+C to copy; claiming it would kill + // the session on a copy. + assert!( + !is_ctrl_c(KeyEvent::new( + KeyCode::Char('C'), + KeyModifiers::CONTROL | KeyModifiers::SHIFT + )), + "Ctrl+Shift+C is copy, not terminate" + ); + } + + #[test] + fn ctrl_c_exits_the_session_but_a_job_console_keeps_cancelling_the_job() { + // Precedence for the event loop's Ctrl-C arm. With no console up, the + // gesture ends the session; with one up it must fall through to + // `job_console::on_console_key`, whose Ctrl+C cancels the running job — + // the documented way to stop a focused install/serve without killing the + // process mid-write. + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let mut s = st(); + assert!( + ctrl_c_should_exit(&s, ctrl_c), + "Ctrl+C with no job console must end the session" + ); + assert!( + !ctrl_c_should_exit(&s, press(KeyCode::Char('q'))), + "an unrelated key must not take the terminate path" + ); + + let _ = open_overlay_for_focus(&mut s, Focus::Examine); // auto-runs a job + assert!(s.has_active_console(), "examine console is live"); + assert!( + !ctrl_c_should_exit(&s, ctrl_c), + "Ctrl+C over a live job console must cancel the job, not the process" + ); + } + + /// Every cell a `TestBackend` frame painted, trimmed — `""` for a frame the + /// render gate suppressed. Shared by the two gate tests below so they assert + /// on the same thing. + fn painted(term: &ratatui::Terminal) -> String { + term.backend() + .buffer() + .content() + .iter() + .map(ratatui::buffer::Cell::symbol) + .collect::() + .trim() + .to_string() + } + + #[test] + fn a_claimed_shutdown_stops_the_dashboard_painting_another_frame() { + // The render gate itself, not just the latch predicate underneath it. + // `restore_terminal()` runs on a Tokio worker while frames are drawn on + // the `block_on` thread, and nothing locks the terminal — so a frame that + // *starts* after the restore would hide the cursor again and repaint a + // stale dashboard over the restored screen. The claim happens before the + // restore begins, so gating on it is what makes that impossible. + // + // Driven against a `TestBackend` and a local latch: no process-global + // state, no real terminal, and the assertion is on painted cells rather + // than on the predicate the gate happens to call. + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + // Control: with nothing claimed the gate must let the frame through, + // otherwise the assertion below would pass on a helper that never draws. + let mut s = st(); + let open = AtomicBool::new(false); + let mut term = Terminal::new(TestBackend::new(120, 32)).unwrap(); + draw_frame_unless_shutting_down(&mut term, &mut s, None, &open) + .expect("drawing to a TestBackend cannot fail"); + assert!( + !painted(&term).is_empty(), + "with no shutdown claimed the dashboard must paint a frame" + ); + + // The real case: a watcher (or a typed Ctrl-C) has claimed the shutdown + // and the restore is under way. + let mut s = st(); + let claimed = AtomicBool::new(false); + assert!(claim_shutdown(&claimed), "the test must win its own latch"); + let mut term = Terminal::new(TestBackend::new(120, 32)).unwrap(); + draw_frame_unless_shutting_down(&mut term, &mut s, None, &claimed) + .expect("the gate must not turn a suppressed frame into an error"); + assert_eq!( + painted(&term), + "", + "once the shutdown is claimed no further frame may be painted — a \ + late frame lands after `restore_terminal()` and undoes it" + ); + } + + #[test] + fn a_frame_cannot_paint_while_a_teardown_owns_the_terminal() { + // The half of the gate the latch cannot provide on its own, and the + // defect the WSL2 E2E lane caught as `dash-sigint-restores-terminal`: + // "alternate_screen=false, cursor_hidden=true" — the alt-screen left, but + // the cursor still invisible. + // + // Reading the latch before drawing stops a frame that *starts* after the + // claim. It cannot stop the frame already in flight when the claim lands, + // and that frame is the problem: `Terminal::draw` ends by emitting `Hide` + // unconditionally, so its tail undoes the restore's `Show` while the + // alt-screen stays left (`EnterAlternateScreen` is never re-emitted) — + // exactly the half-restored terminal the lane reported. Reproduced + // outside the harness by signalling a real `rocm dash --demo` under a + // pty: the restore landed *inside* a frame's bytes, with `ESC[?25l` last. + // + // So the gate must take `lock_terminal_writer()` FIRST and read the latch + // under it. Modelled with the teardown's half of that lock held by another + // thread: this thread asks to draw while the teardown owns the terminal, + // and must paint nothing, because the claim happens-before the unlock it + // is waiting on. Delete the lock from the gate and the frame paints + // immediately instead, which is the bug. + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + let latch = AtomicBool::new(false); + let mut s = st(); + let mut term = Terminal::new(TestBackend::new(120, 32)).unwrap(); + let (held_tx, held_rx) = std::sync::mpsc::channel::<()>(); + let (drawing_tx, drawing_rx) = std::sync::mpsc::channel::<()>(); + + std::thread::scope(|scope| { + // `mpsc::Receiver` is `Send` but not `Sync`, so the halves the + // teardown thread uses are moved into it; the latch is shared as a + // plain reference (the whole point is that both threads see it). + let latch = &latch; + scope.spawn(move || { + // Stands in for `restore_terminal()`. It writes nothing: the + // claim is what this test is about, not the escape bytes (those + // are covered by `write_restore_sequences_leaves_alt_screen_…`). + let guard = lock_terminal_writer(); + held_tx.send(()).expect("the drawing thread is alive"); + drawing_rx.recv().expect("the drawing thread is alive"); + // Only to make the unfixed code reliably red: an ungated draw + // paints in microseconds, so it would certainly have painted + // within this window. The fixed path does not depend on the + // duration — the claim below happens-before the unlock either + // way, so the assertion holds even if this were zero. + std::thread::sleep(std::time::Duration::from_millis(200)); + assert!( + claim_shutdown(latch), + "the teardown must win a latch nothing else can see" + ); + drop(guard); + }); + + held_rx.recv().expect("the teardown thread is alive"); + drawing_tx.send(()).expect("the teardown thread is alive"); + draw_frame_unless_shutting_down(&mut term, &mut s, None, latch) + .expect("the gate must not turn a suppressed frame into an error"); + }); + + assert_eq!( + painted(&term), + "", + "a frame asked for while a teardown owned the terminal must not paint \ + — its trailing `Hide` would land after the restore's `Show` and leave \ + the user on the normal screen with an invisible cursor" + ); + } + + #[test] + fn ctrl_c_exits_once_the_console_job_has_finished() { + // The gesture the PR exists to fix, in the state that used to swallow it. + // Nothing clears `active_job` when a job completes (only an Esc/Enter + // dismissal does), so the console stays on screen after the job is done. + // While the exemption keyed on "a console is displayed" rather than "a + // job is running", Ctrl-C there fell through to `on_console_key` → + // `CancelJob`, which the reducer drops on a terminal job: nothing + // happened at all, and the user stayed in raw mode on the alt-screen. + let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); + let mut s = st(); + let _ = open_overlay_for_focus(&mut s, Focus::Examine); // auto-runs a job + let job_id = s + .active_job_id() + .expect("opening Examine must start a job and show its console") + .to_string(); + assert!( + !ctrl_c_should_exit(&s, ctrl_c), + "while the job is still running, Ctrl+C must cancel the job" + ); + + // The job finishes. The console is NOT dismissed — this is the review + // state the user is left sitting in. + s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { + id: job_id.clone(), + code: 0, + }); + assert!( + s.jobs + .job(&job_id) + .is_some_and(rocm_dash_core::state::JobState::is_terminal), + "the job must have reached a terminal state" + ); + assert!( + s.has_active_console(), + "the finished console must still be displayed — that is the whole \ + point of this case" + ); + assert!( + ctrl_c_should_exit(&s, ctrl_c), + "Ctrl+C over a FINISHED job console must end the session; there is no \ + job left to cancel, so exempting it makes the keystroke a silent \ + no-op and traps the user in raw mode" + ); + } + + #[test] + fn termination_watcher_parks_until_aborted() { + let _guard = SIGNAL_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + signal_test_runtime().block_on(async { + // The real wiring `run` depends on: registration must succeed, and + // the spawned task must stay parked on `recv()` (never resolving on + // its own and exiting the process), until the clean-return path + // aborts it. + let handle = spawn_termination_watcher() + .expect("registering the termination-signal listeners must succeed"); + // Let the task actually start and park; on a current-thread runtime + // a freshly spawned task has not been polled yet, so without this + // `is_finished` would be trivially false. + tokio::task::yield_now().await; + assert!( + !handle.is_finished(), + "the watcher must stay parked while no signal has arrived" + ); + + handle.abort(); + let err = handle + .await + .expect_err("an aborted watcher must not report completion"); + assert!( + err.is_cancelled(), + "the watcher must end by cancellation, not by panicking: {err:?}" + ); + }); + } + + // Unix-only. This test sends real signals to its own process, which is safe + // ONLY because it drives `await_termination` directly: the watcher body that + // calls `std::process::exit` is never run here. `TerminationSignals::register` + // installs the handlers *before* the `kill`, so the signal is caught rather + // than taking its default (fatal) disposition. Both listeners are registered + // before the single `kill` on purpose — that is exactly the hub-watcher + + // session-watcher shape, and Tokio's process-global registry wakes both. + #[cfg(unix)] + #[test] + fn termination_signals_yield_shell_exit_codes_and_only_one_watcher_shuts_down() { + let _guard = SIGNAL_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + signal_test_runtime().block_on(async { + // Scope, so nobody reads more into this than it proves: the + // expectation is built from the same `EXIT_CODE_*` constants the + // code under test returns, so this pins the SIGTERM→sigterm-code / + // SIGINT→sigint-code *mapping* (swapping the two arms turns it red) + // but not the literal values. Editing `EXIT_CODE_SIGINT` to 7 leaves + // this green. The literals 130/143 are pinned by the e2e scenarios + // `dash-17` … `dash-21` in `tests/e2e-cucumber/features/dash.feature`, + // which assert the shell-visible exit status of a real process. + for (signo, expected) in [ + (libc::SIGTERM, EXIT_CODE_SIGTERM), + (libc::SIGINT, EXIT_CODE_SIGINT), + ] { + // A fresh latch per kind keeps the test order-independent. + let latch = AtomicBool::new(false); + let hub_watcher = TerminationSignals::register() + .expect("registering the hub listeners must succeed"); + let session_watcher = TerminationSignals::register() + .expect("registering the session listeners must succeed"); + + // SAFETY: `raise` is an async-signal-safe libc call with no + // arguments to get wrong, and both listeners above are already + // installed, so the signal is delivered to Tokio's handler + // instead of terminating the test binary. + #[allow(unsafe_code)] // libc FFI + let rc = unsafe { libc::raise(signo) }; + assert_eq!(rc, 0, "raise({signo}) failed"); + + // Both `await_termination`s are bounded. A regression that breaks + // *registration* of one signal kind (rather than mis-mapping its + // exit code) leaves the `.await` parked forever, and an unbounded + // await would burn the required lane's job timeout instead of + // reporting a failure. The bound is generous — the signal is + // already queued by the `raise` above, so the await resolves in + // microseconds; anything near 10 s is a genuine hang. + let received = tokio::time::timeout( + SIGNAL_AWAIT_TIMEOUT, + await_termination(hub_watcher, &latch), + ) + .await + .unwrap_or_else(|_| { + panic!( + "the first watcher never received signal {signo} within \ + {SIGNAL_AWAIT_TIMEOUT:?} — the listener for it is not \ + registered, so the watcher would park forever instead of \ + restoring the terminal" + ) + }); + assert_eq!( + received, + Some(expected), + "the first watcher must receive signal {signo} and map it to \ + the conventional 128 + signo exit code" + ); + + let stood_down = tokio::time::timeout( + SIGNAL_AWAIT_TIMEOUT, + await_termination(session_watcher, &latch), + ) + .await + .unwrap_or_else(|_| { + panic!( + "the second watcher never woke for signal {signo} within \ + {SIGNAL_AWAIT_TIMEOUT:?} — Tokio's registry must wake \ + every listener registered for a kind, not just the first" + ) + }); + assert_eq!( + stood_down, None, + "the second watcher woken by the same signal must stand down \ + rather than race a concurrent restore + exit" + ); + } + }); + } + + // Unix-only: crossterm emits ANSI escape sequences to a generic writer on + // Unix, so an in-memory sink captures the real bytes. On Windows crossterm + // drives the console via the WinAPI backend instead of writing ANSI, and + // `execute!` to a `Vec` errors with "Initial console modes not set" — there + // is no console to configure. Production `restore_terminal()` passes a real + // stdout handle, so the Windows path is exercised there, not by this sink. + #[cfg(unix)] + #[test] + fn write_restore_sequences_leaves_alt_screen_disables_mouse_and_shows_cursor() { + // The restore path must undo all three things `run` set up: leave the + // alternate screen, disable mouse capture, show the cursor. Driving an + // in-memory sink asserts the actual emitted bytes without touching the + // process's shared terminal state — the global `disable_raw_mode()` half + // is deliberately outside this function, so nothing here races other + // tests in the single-process `cargo test` lane. + let mut sink: Vec = Vec::new(); + write_restore_sequences(&mut sink).expect("writing to a Vec cannot fail"); + let emitted = String::from_utf8(sink).expect("restore sequences are ASCII escapes"); + assert!( + emitted.contains("\x1b[?1049l"), + "expected the leave-alt-screen sequence in {emitted:?}" + ); + // `DisableMouseCapture` is one command but five terminal modes: + // crossterm 0.28 expands it to SGR-encoding, urxvt-encoding, any-motion, + // button-event and normal tracking, turned off in that order. Assert + // the whole block rather than a single mode so dropping the command from + // `write_restore_sequences` cannot leave this test green — a terminal + // left reporting mouse events after `rocm dash` exits is exactly the + // broken-terminal state this restore path exists to prevent. If a + // crossterm bump changes the expansion, re-derive it from a sink run + // rather than weakening the assertion. + let disable_mouse = "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; + assert!( + emitted.contains(disable_mouse), + "expected the disable-mouse-capture sequences {disable_mouse:?} in {emitted:?}" + ); + assert!( + emitted.contains("\x1b[?25h"), + "expected the show-cursor sequence in {emitted:?}" + ); + } + + #[test] + fn startup_focus_gate_only_opens_onboarding_for_explicit_setup_focus() { + // Regression guard: this calls `apply_startup_focus`, the same gate + // `event_loop` uses, not just `AppState::new` — which takes no focus + // argument and hardcodes `onboarding: None` regardless, so it cannot + // exhibit an auto-open regression either way. + let mut s = st(); + assert!(apply_startup_focus(&mut s, None).is_empty()); + assert!( + s.onboarding.is_none(), + "no --focus flag must not open onboarding" + ); + + let mut s = st(); + assert!(apply_startup_focus(&mut s, Some(Focus::Setup)).is_empty()); + assert!( + s.onboarding.is_some(), + "an explicit Focus::Setup must open onboarding" + ); + } + + #[test] + fn should_skip_daemon_predicate_matches_focus() { + // The dashboard (focus=None) keeps the daemon client + chat backend; any + // focus skips both. The render branch reuses this same predicate. + assert!(!should_skip_daemon(None)); + assert!(should_skip_daemon(Some(Focus::Setup))); + assert!(should_skip_daemon(Some(Focus::Serve))); + assert!(should_skip_daemon(Some(Focus::Examine))); + // focus=None never self-exits — the dash loop only breaks on Quit/EOF. + assert!(!st().focused_should_exit(None)); + } + + #[test] + fn open_overlay_for_focus_opens_the_right_overlay() { + let mut s = st(); + assert!(open_overlay_for_focus(&mut s, Focus::Setup).is_empty()); + assert!(s.onboarding.is_some()); + assert!(s.serve_wizard.is_none() && s.examine_manager.is_none()); + + let mut s = st(); + assert!(open_overlay_for_focus(&mut s, Focus::Serve).is_empty()); + assert!(s.serve_wizard.is_some()); + assert!(s.onboarding.is_none() && s.examine_manager.is_none()); + + let mut s = st(); + let fx = open_overlay_for_focus(&mut s, Focus::Examine); + assert!(s.examine_manager.is_some()); + assert!(s.onboarding.is_none() && s.serve_wizard.is_none()); + assert_eq!(fx.len(), 1, "examine auto-runs on open"); + } + + #[test] + fn focused_examine_auto_runs_rocm_examine() { + let mut s = st(); + let fx = open_overlay_for_focus(&mut s, Focus::Examine); + assert_eq!(fx.len(), 1, "exactly one spawn side effect on open"); + match &fx[0] { + rocm_dash_core::state::SideEffect::SpawnJob { cmd, args, .. } => { + assert!(cmd.contains("rocm"), "cmd resolves to the rocm exe: {cmd}"); + assert!( + args.iter().any(|a| a == "examine"), + "examine in args: {args:?}" + ); + } + other => panic!("expected SpawnJob, got {other:?}"), + } + assert_eq!( + s.examine_manager.as_ref().unwrap().active_job.as_deref(), + Some("examine"), + "the auto-run wires the active job" + ); + } + + #[test] + fn focused_exit_gate_holds_until_examine_closed_at_root() { + let mut s = st(); + // Focused Diagnose: examine opens AND auto-runs → a job-console sub-state. + let _ = open_overlay_for_focus(&mut s, Focus::Examine); + assert!(s.examine_manager.as_ref().unwrap().active_job.is_some()); + assert!( + !s.focused_should_exit(Some(Focus::Examine)), + "a running job keeps the launcher out" + ); + + // Job terminal → first Esc dismisses the console back to the intro card; + // the overlay is still open, so the gate stays shut. + s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { + id: "examine".into(), + code: 0, + }); + let _ = crate::ui::examine_manager::on_key( + &mut s.examine_manager, + &mut s.jobs, + press(KeyCode::Esc), + ); + assert!( + s.examine_manager.is_some(), + "console dismissed, overlay stays" + ); + assert!( + !s.focused_should_exit(Some(Focus::Examine)), + "at the intro (not root-closed) the gate is still shut" + ); + + // Second Esc at the intro (root) closes the overlay → now exit to menu. + let _ = crate::ui::examine_manager::on_key( + &mut s.examine_manager, + &mut s.jobs, + press(KeyCode::Esc), + ); + assert!(s.examine_manager.is_none(), "root Esc closes the overlay"); + assert!( + s.focused_should_exit(Some(Focus::Examine)), + "closed at root → return to the launcher" + ); + } + + #[test] + fn focused_close_keys_swallowed_while_job_runs() { + // Regression for the mid-job ejection defect: `q` and running-`Esc` must + // be swallowed by the focused host while the job is non-terminal, so the + // overlay is never nulled (which would tear the runtime down and kill the + // child via kill_on_drop mid-write). + let mut s = st(); + let _ = open_overlay_for_focus(&mut s, Focus::Examine); // auto-runs a job + assert!(s.has_active_console(), "examine console is live"); + // Running job → q and Esc are blocked; Ctrl+C ('c') is NOT (it cancels). + assert!(focused_close_key_blocked( + &s, + Some(Focus::Examine), + KeyCode::Char('q') + )); + assert!(focused_close_key_blocked( + &s, + Some(Focus::Examine), + KeyCode::Esc + )); + assert!(!focused_close_key_blocked( + &s, + Some(Focus::Examine), + KeyCode::Char('c') + )); + // The dashboard (focus=None) never blocks — behavior is unchanged there. + assert!(!focused_close_key_blocked(&s, None, KeyCode::Char('q'))); + + // Because those keys are swallowed (never routed to the manager), the + // overlay stays open and the exit gate stays shut mid-job. + assert!(s.examine_manager.is_some()); + assert!(!s.focused_should_exit(Some(Focus::Examine))); + + // Once the job is terminal, close keys are allowed again → normal exit. + s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { + id: "examine".into(), + code: 0, + }); + assert!( + !focused_close_key_blocked(&s, Some(Focus::Examine), KeyCode::Char('q')), + "a terminal job no longer blocks exit (the child already exited)" + ); + } + + #[test] + fn focused_gate_shut_across_serve_sub_states() { + // Exit-at-root (b)+(c): the focused gate stays shut while a folder + // browser / model picker / approval is open — it only opens at root. + let recipes: Vec = Vec::new(); + let mut s = st(); + let _ = open_overlay_for_focus(&mut s, Focus::Serve); + + // (b) Tab on the Model field opens the folder-browser sub-popup. + let _ = crate::ui::serve_wizard::on_key( + &mut s.serve_wizard, + &mut s.jobs, + &recipes, + press(KeyCode::Tab), + ); + assert!(s.serve_wizard.as_ref().unwrap().browser.is_some()); + assert!( + !s.focused_should_exit(Some(Focus::Serve)), + "gate shut while the folder browser is open" + ); + // Esc closes the sub-popup, not the wizard → still shut. + let _ = crate::ui::serve_wizard::on_key( + &mut s.serve_wizard, + &mut s.jobs, + &recipes, + press(KeyCode::Esc), + ); + assert!(s.serve_wizard.as_ref().unwrap().browser.is_none()); + assert!(s.serve_wizard.is_some()); + assert!(!s.focused_should_exit(Some(Focus::Serve))); + + // (c) Stage an approval (valid model, Launch field, Enter). + { + let w = s.serve_wizard.as_mut().unwrap(); + w.model = "org/model".to_string(); + w.field = crate::ui::serve_wizard::FIELDS.len() - 1; // Launch + } + let _ = crate::ui::serve_wizard::on_key( + &mut s.serve_wizard, + &mut s.jobs, + &recipes, + press(KeyCode::Enter), + ); + assert!( + s.serve_wizard.as_ref().unwrap().approval.is_some(), + "a launch approval is pending" + ); + assert!( + !s.focused_should_exit(Some(Focus::Serve)), + "gate shut while an approval is pending" + ); + + // Only a root close (wizard → None) opens the gate. + s.serve_wizard = None; + assert!(s.focused_should_exit(Some(Focus::Serve))); + } + + // --- Home tab update check (background job-bridge trigger) --- + + // Serializes every test in this group against + // `refresh_update_status_skips_spawn_when_disabled_via_env`, which toggles + // `ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK` — process env is shared across + // test threads, so an unguarded test can observe the var mid-toggle and + // spuriously see `refresh_update_status` skip the spawn it expects. + static UPDATE_CHECK_ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + + #[test] + fn refresh_update_status_spawns_on_first_due_tick() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = st(); + assert!(!s.update_status_pending); + let fx = refresh_update_status(&mut s); + assert!(!fx.is_empty(), "a due check spawns a job"); + assert!(s.update_status_pending); + assert!(s.jobs.job(HOME_UPDATE_CHECK_JOB_ID).is_some()); + } + + #[test] + fn refresh_update_status_does_not_duplicate_spawn_while_running() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = st(); + let fx = refresh_update_status(&mut s); + assert!(!fx.is_empty()); + assert!(s.update_status_pending); + + // Still pending, job still running (non-terminal) → no-op, no second spawn. + let fx2 = refresh_update_status(&mut s); + assert!( + fx2.is_empty(), + "no duplicate spawn while pending and running" + ); + assert!(s.update_status_pending); + assert_eq!(s.update_status, UpdateStatus::Unknown); + } + + #[test] + fn refresh_update_status_rearms_due_at_when_pending_job_vanishes() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = st(); + let _ = refresh_update_status(&mut s); + assert!(s.update_status_pending); + + // Not reachable today (jobs are never removed), but if it ever is, + // `update_check_due_at` must still be pushed out — otherwise every + // subsequent tick would spawn a new check immediately. + s.jobs.jobs.remove(HOME_UPDATE_CHECK_JOB_ID); + let fx = refresh_update_status(&mut s); + assert!(fx.is_empty()); + assert!(!s.update_status_pending); + assert!( + s.update_check_due_at > std::time::Instant::now(), + "due_at must be re-armed, not left in the past" + ); + } + + #[test] + fn refresh_update_status_resolves_from_terminal_success_json() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = st(); + let _ = refresh_update_status(&mut s); + assert!(s.update_status_pending); + + s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { + id: HOME_UPDATE_CHECK_JOB_ID.into(), + line: serde_json::json!({ + "runtimes": [{ + "runtime_key": "rocm", + "channel": "stable", + "family": "rocm", + "installed_version": "7.0.0", + "latest_version": "7.1.0", + "status": "update_available", + "message": null, + }] + }) + .to_string(), + }); + s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { + id: HOME_UPDATE_CHECK_JOB_ID.into(), + code: 0, + }); + + let fx = refresh_update_status(&mut s); + assert!(fx.is_empty(), "resolving a terminal job spawns nothing"); + assert!(!s.update_status_pending); + assert_eq!( + s.update_status, + UpdateStatus::UpdateAvailable { + latest_version: "7.1.0".into() + } + ); + } + + #[test] + fn refresh_update_status_resolves_to_error_on_terminal_failure() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = st(); + let _ = refresh_update_status(&mut s); + assert!(s.update_status_pending); + + // Nonzero exit → Error, regardless of any output on the ring. + s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { + id: HOME_UPDATE_CHECK_JOB_ID.into(), + code: 1, + }); + let fx = refresh_update_status(&mut s); + assert!(fx.is_empty()); + assert!(!s.update_status_pending); + assert_eq!(s.update_status, UpdateStatus::Error); + } + + #[test] + fn refresh_update_status_resolves_to_error_on_unparsable_success_output() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = st(); + let _ = refresh_update_status(&mut s); + + // Exit 0 but no valid JSON line on the ring → Error, not a silent hang. + s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { + id: HOME_UPDATE_CHECK_JOB_ID.into(), + line: "not json".into(), + }); + s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { + id: HOME_UPDATE_CHECK_JOB_ID.into(), + code: 0, + }); + let fx = refresh_update_status(&mut s); + assert!(fx.is_empty()); + assert!(!s.update_status_pending); + assert_eq!(s.update_status, UpdateStatus::Error); + } + + #[test] + fn refresh_update_status_spawn_args_include_bounded_timeout() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + let mut s = st(); + let _ = refresh_update_status(&mut s); + let job = s + .jobs + .job(HOME_UPDATE_CHECK_JOB_ID) + .expect("job spawned on first due tick"); + // Pinned to a literal, not `HOME_UPDATE_CHECK_TIMEOUT_SECS`: comparing + // the constant to itself can never catch an unintentional change to + // its value. A literal forces a deliberate test update (and a second + // thought) whenever the bound changes. + assert_eq!( + job.args.last().map(String::as_str), + Some("5"), + "the background check's timeout bound must stay a deliberate choice: {:?}", + job.args + ); + assert!(job.args.iter().any(|a| a == "--timeout-secs")); + } + + #[test] + fn refresh_update_status_skips_spawn_when_disabled_via_env() { + let _guard = UPDATE_CHECK_ENV_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); + // SAFETY: serialized by `UPDATE_CHECK_ENV_TEST_LOCK`; no other thread + // reads/writes this var concurrently. + #[allow(unsafe_code)] + unsafe { + std::env::set_var("ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK", "1"); + } + let result = std::panic::catch_unwind(|| { + let mut s = st(); + let fx = refresh_update_status(&mut s); + assert!(fx.is_empty(), "a disabled check must not spawn a job"); + assert!(!s.update_status_pending); + assert!(s.jobs.job(HOME_UPDATE_CHECK_JOB_ID).is_none()); + }); + #[allow(unsafe_code)] + unsafe { + std::env::remove_var("ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK"); + } + result.unwrap(); + } + + #[test] + fn reduce_update_json_all_up_to_date_or_ahead_is_up_to_date() { + let doc = serde_json::json!({ + "runtimes": [ + {"status": "up_to_date"}, + {"status": "ahead_of_index"}, + ] + }); + assert_eq!(reduce_update_json(&doc), UpdateStatus::UpToDate); + } + + #[test] + fn reduce_update_json_mixed_up_to_date_and_error_is_error_not_up_to_date() { + // One runtime resolved cleanly, one didn't — asserting "Up to date" + // here would be a false claim about the runtime that errored. + let doc = serde_json::json!({ + "runtimes": [ + {"status": "up_to_date"}, + {"status": "error", "message": "boom"}, + ] + }); + assert_eq!(reduce_update_json(&doc), UpdateStatus::Error); + } + + #[test] + fn reduce_update_json_unrecognized_status_is_error() { + let doc = serde_json::json!({ + "runtimes": [{"status": "something_new"}] + }); + assert_eq!(reduce_update_json(&doc), UpdateStatus::Error); + } + + #[test] + fn reduce_update_json_repair_available_is_update_available_not_error() { + // A same-version composition repair is as actionable as a version + // bump — the tile must not report "check failed" for it. + let doc = serde_json::json!({ + "runtimes": [{"status": "repair_available", "latest_version": "6.4.0"}] + }); + assert_eq!( + reduce_update_json(&doc), + UpdateStatus::UpdateAvailable { + latest_version: "6.4.0".to_owned() + } + ); + } + + #[test] + fn reduce_update_json_missing_latest_version_is_still_update_available() { + // A row with an actionable status but no `latest_version` must not be + // silently skipped in favor of the up-to-date/error checks below it — + // that would misreport a real update as "check failed". + let doc = serde_json::json!({ + "runtimes": [{"status": "update_available"}] + }); + assert_eq!( + reduce_update_json(&doc), + UpdateStatus::UpdateAvailable { + latest_version: "(version unknown)".to_owned() + } + ); + } +} diff --git a/crates/rocm-dash-tui/src/app/mod.rs b/crates/rocm-dash-tui/src/app/mod.rs index aa6bdcc8e..ea3cb94ee 100644 --- a/crates/rocm-dash-tui/src/app/mod.rs +++ b/crates/rocm-dash-tui/src/app/mod.rs @@ -2,162 +2,52 @@ // // SPDX-License-Identifier: MIT -//! Event loop. Sets up the terminal, spawns the client task, drives renders. +//! Dashboard reducer: `AppState` and its `apply_event`/`apply_action` entry +//! points. +//! +//! Split into focused submodules to keep this file to the reducer's core: +//! `app/types.rs` (shared type/enum defs), `app/event_loop.rs` (terminal +//! lifecycle + tick loop), `app/scrollbar.rs` (mouse hit-testing), and +//! `app/actions.rs` (`KeyAction` dispatch). `crate::app::*` paths for +//! everything moved out are unchanged via the re-exports below. -use std::collections::VecDeque; -use std::io; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use crossterm::event::{ - DisableMouseCapture, EnableMouseCapture, Event as CtEvent, EventStream, KeyCode, KeyEvent, - KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, -}; -use crossterm::execute; -use crossterm::terminal::{ - EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, -}; -use futures::StreamExt; -use ratatui::Terminal; -use ratatui::backend::CrosstermBackend; -use std::collections::HashMap; +use std::collections::{HashMap, VecDeque}; use rocm_dash_core::bench_schema::BenchmarkRow; use rocm_dash_core::metrics::{Instance, Snapshot}; use rocm_dash_core::protocol::Event; -use tokio::sync::mpsc; -use tokio::time::interval; -use crate::client::{self, ClientMsg}; -use crate::ui; use crate::ui::theme::Theme; -// Submodules holding cohesive pieces of `AppState` + free fns split out of this -// file to keep the core reducer + event loop focused (a file→dir module move: -// `crate::app::*` paths are unchanged). +// Submodules holding cohesive pieces of `AppState` + free fns split out of +// this file to keep the core reducer + event loop focused (a file→dir module +// move: `crate::app::*` paths are unchanged). +mod actions; mod chat; +mod event_loop; +mod scrollbar; mod slash; mod summary; +mod types; -use chat::{ - StartupChatOutcome, build_chat_agent, build_local_agent, detect_local_chat, - discover_configured_chat_model, persist_chat_endpoint, startup_chat_outcome, +pub use actions::KeyAction; +pub(crate) use event_loop::{ + HOME_UPDATE_CHECK_JOB_ID, SHUTTING_DOWN, exit_on_ctrl_c, is_ctrl_c, lock_terminal_writer, + restore_terminal, shutdown_claimed_on, }; -use summary::{parse_plan_result, summarize_json_value, summarize_slash_tool}; - -/// Which single flow a *focused host* runs. -/// -/// The bare-`rocm` launcher opens one overlay to completion — no embedded -/// daemon, no tab shell — then returns to the menu. `None` on -/// [`ResolvedArgs::focus`] is the normal full dashboard, so every existing -/// dash/chat path is byte-identical when focus is unset. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Focus { - /// First-run onboarding (install / adopt ROCm) — the launcher's - /// `Set up this system` row and `rocm bootstrap setup`. - Setup, - /// The serve-a-model wizard — the launcher's `Serve a model` row. - Serve, - /// Read-only `rocm examine` environment check — the launcher's - /// `Diagnose & fix` row. Auto-runs on open. - Examine, -} - -/// Args after CLI + config resolution. Consumed by `run`. -#[derive(Debug, Clone)] -pub struct ResolvedArgs { - pub connect: String, - pub token: Option, - pub theme: String, - /// When `Some`, replay events from a file instead of connecting to a - /// live daemon. Mutually exclusive with `connect` (enforced by clap). - pub replay: Option, - /// 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, - /// When `Some`, run as a *focused host*: open exactly the overlay for this - /// flow, skip the embedded daemon + chat backend, render overlay-only, and - /// exit back to the launcher when the overlay is closed at its root. `None` - /// (the default) is the normal full dashboard — every path stays unchanged. - pub focus: Option, - /// Chat endpoint base URL, CLI-flag value already merged over config. - pub chat_url: Option, - /// Chat model, CLI-flag value already merged over config. - pub chat_model: Option, - /// Custom auth header NAME (CLI-flag value merged over config), e.g. - /// `Ocp-Apim-Subscription-Key` for Azure APIM gateways. - pub chat_auth_header: Option, - /// Sampling temperature for chat requests, CLI-flag value merged over - /// config. `None` leaves the endpoint default untouched. - pub chat_temperature: Option, - /// Nucleus-sampling `top_p` for chat requests, CLI merged over config. - pub chat_top_p: Option, - /// Max generated tokens for chat requests, CLI merged over config. - pub chat_max_tokens: Option, - /// Chat endpoint base URL from the environment (`OPENAI_BASE_URL`). - /// A separate, lower-precedence tier than `chat_url`. - pub chat_env_url: Option, - /// Chat api key, sourced from the environment ONLY (never TOML/CLI/source). - /// Used by the local/OpenAI backends. - pub chat_api_key: Option, - /// Anthropic API key, sourced by the bin (env-first then OS secure store — - /// NEVER argv) and carried in-process via this seam. `None` when absent; - /// the Anthropic backend then surfaces an actionable error on switch. - pub anthropic_api_key: Option, - /// Pre-consent to using the detected endpoint (`--chat-yes`), skipping the - /// one-time in-TUI prompt for the demo. - pub chat_auto_consent: bool, - /// Use the offline `MockAgentClient` for chat (`--chat-mock`) — a - /// deterministic, fully-offline demo with no live LLM. - pub chat_mock: bool, - /// Built-in model recipes for the serve wizard's picker (Phase 3 Wave 1). - /// Adapted by the bin (`apps/rocm`, which has `rocm-core`) so this crate - /// needs no `rocm-core` dep. Empty when none are available. - pub model_recipes: Vec, - /// Registered ROCm runtimes for the runtime manager (Phase 3 Wave 2). - /// Adapted by the bin (`apps/rocm`, which has `rocm-core`) so this crate - /// needs no `rocm-core` dep. Empty when none are available. - pub runtimes: Vec, - /// Background checks for the automations manager (Phase 3 Wave 3). Adapted - /// by the bin. Empty when none are available. - pub automations: Vec, - /// System prompt for the chat assistant: the ROCm tool-use prompt plus this - /// machine's detected facts (OS, WSL, AMD GPU, available engines). Composed - /// by the bin (`apps/rocm`, which has `rocm-core`) so this crate needs no - /// `rocm-core` dep. `None` for demo/replay/`--chat-mock`, which have no bin - /// seam and keep the agent's built-in default preamble. - pub chat_system_prompt: Option, - /// Bin-injected tool-executor seam; None for demo/replay/mock — dash behaves - /// as today. Stored here (Phase 2 plumbing); Phase 3 will use it. - pub tool_executor: Option, - /// Daemon-tailed bench CSV path (`config.dashboard.daemon.bench_results_dir`). - /// - /// When `Some`, the bench-run form defaults `--out` to this path so appended - /// rows appear live in the bench tab. Adapted by the bin (owns `rocm-core`). - pub bench_results_dir: Option, - /// Managed-service records that are no longer running, counted from the - /// registry by the bin at launch (the same seam `model_recipes` / `runtimes` - /// / `automations` use - a snapshot, not a live feed). The services overlay - /// only ever renders the live instances the daemon surfaces, so without this - /// a host whose servers had all failed showed an empty overlay and no sign - /// that any record existed. 0 when there are none. - pub services_past_attempts: usize, -} - -impl ResolvedArgs { - /// The optional sampling controls (temperature/top_p/max_tokens) resolved - /// for chat, bundled for the agent builders. CLI-over-config merge already - /// happened in the bin, so these are the final values. - pub(crate) const fn inference_params(&self) -> crate::agent::InferenceParams { - crate::agent::InferenceParams { - temperature: self.chat_temperature, - top_p: self.chat_top_p, - max_tokens: self.chat_max_tokens, - } - } -} -type Tui = Terminal>; +pub use event_loop::{run, spawn_termination_watcher}; +// Only reached today via a test (`launcher.rs`'s +// `the_front_door_comes_back_after_a_session_ends_cleanly`), so a plain +// (non-test) build sees no caller through this `crate::app::` path. +#[allow(unused_imports)] +pub(crate) use event_loop::restore_after_session; +pub use scrollbar::{FooterChip, PaneFocus, ScrollDrag, ScrollTarget, ScrollbarHandle}; +pub use types::{ + ActiveTab, ChatConsent, ChatKeyCtx, ChatRole, ChatTurn, ConnState, Focus, Modal, PlannedAction, + ReplayState, ResolvedArgs, UpdateStatus, format_mmss, +}; +pub(crate) use types::{ChatProvider, PendingApproval, SlashOutcome, SlashToolRequest}; /// How many snapshots to keep for sparklines. pub const HISTORY_CAP: usize = 240; @@ -165,347 +55,6 @@ pub const HISTORY_CAP: usize = 240; /// How many benchmark rows to keep client-side for the bench panel. pub const BENCH_CAP: usize = 200; -/// Lines per PageUp/PageDown step in the chat transcript. -const CHAT_SCROLL_STEP: i16 = 5; - -#[derive(Debug, Clone, Default)] -pub enum ConnState { - #[default] - Initial, - Connecting, - Connected { - host: String, - version: String, - }, - Disconnected { - reason: String, - }, -} - -#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] -pub enum ActiveTab { - // 5-tab IA. Home is the default; ROCm and Serving are the two domain tabs - // (Actions list + inline Details); Observe folds the host/instance/bench - // telemetry; Chat is the assistant. The former single Action tab is gone — - // its guided verbs are split across ROCm + Serving. - #[default] - Home, - Rocm, - Serving, - Observe, - Chat, -} - -impl ActiveTab { - #[must_use] - pub const fn next(self) -> Self { - match self { - Self::Home => Self::Rocm, - Self::Rocm => Self::Serving, - Self::Serving => Self::Observe, - Self::Observe => Self::Chat, - Self::Chat => Self::Home, - } - } - #[must_use] - pub const fn prev(self) -> Self { - match self { - Self::Home => Self::Chat, - Self::Rocm => Self::Home, - Self::Serving => Self::Rocm, - Self::Observe => Self::Serving, - Self::Chat => Self::Observe, - } - } - pub const fn from_digit(d: char) -> Option { - match d { - '1' => Some(Self::Home), - '2' => Some(Self::Rocm), - '3' => Some(Self::Serving), - '4' => Some(Self::Observe), - '5' => Some(Self::Chat), - _ => None, - } - } -} - -/// Who authored a chat turn. Plain TUI-local data — `rocm-dash-core` carries -/// no chat types; chat is owned by the TUI crate. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ChatRole { - User, - Agent, - Error, - /// Operational notice generated by the TUI itself (e.g. "switched to - /// local"), rendered in the transcript but **never** sent to the model — - /// `build_messages` drops it so it can't masquerade as a prior assistant - /// turn and corrupt the model's context. - System, -} - -/// One line in the chat transcript. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct ChatTurn { - pub role: ChatRole, - pub content: String, -} - -impl ChatTurn { - pub fn user(content: impl Into) -> Self { - Self { - role: ChatRole::User, - content: content.into(), - } - } - pub fn agent(content: impl Into) -> Self { - Self { - role: ChatRole::Agent, - content: content.into(), - } - } - pub fn error(content: impl Into) -> Self { - Self { - role: ChatRole::Error, - content: content.into(), - } - } - /// A TUI-generated operational notice. Rendered but dropped from the LLM - /// history by [`build_messages`](crate::agent::build_messages). - pub fn system(content: impl Into) -> Self { - Self { - role: ChatRole::System, - content: content.into(), - } - } -} - -/// Consent state for using the auto-detected LLM endpoint. The chat surface -/// asks once before any request leaves the machine. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum ChatConsent { - /// No endpoint detected from any source — actionable empty-state. - #[default] - Unavailable, - /// Endpoint detected; awaiting the user's one-time accept/decline. - Pending, - /// User accepted — chat is enabled. - Accepted, - /// User declined — chat stays off until re-enabled. - Declined, -} - -/// Inputs `handle_key` needs to interpret keys on the Chat tab without holding -/// `&AppState` (keeps the function pure and unit-testable). -#[derive(Debug, Clone, Copy)] -pub struct ChatKeyCtx { - pub focused: bool, - pub consent: ChatConsent, - /// A locally-detected endpoint is awaiting use/dismiss — its keys take - /// precedence over the normal consent prompt. - pub offer_pending: bool, -} - -impl Default for ChatKeyCtx { - fn default() -> Self { - // Default to a usable, unfocused surface for tests that don't exercise - // consent/insert specifics. - Self { - focused: false, - consent: ChatConsent::Accepted, - offer_pending: false, - } - } -} - -/// Replay scrubber state. Only present when `--replay` was given. -#[derive(Debug, Clone)] -pub struct ReplayState { - pub controller: crate::replay::ReplayController, - pub paused: bool, - pub speed: f64, - /// Current playhead in seconds since the start of the recording. - pub elapsed_s: u64, - /// Total length of the recording in seconds. - pub total_s: u64, -} - -impl ReplayState { - pub const fn new(controller: crate::replay::ReplayController) -> Self { - Self { - controller, - paused: false, - speed: 1.0, - elapsed_s: 0, - total_s: 0, - } - } -} - -/// Format a duration in seconds as `M:SS` (or `H:MM:SS` past an hour). -pub fn format_mmss(secs: u64) -> String { - if secs >= 3600 { - let h = secs / 3600; - let m = (secs % 3600) / 60; - let s = secs % 60; - format!("{h}:{m:02}:{s:02}") - } else { - let m = secs / 60; - let s = secs % 60; - format!("{m}:{s:02}") - } -} - -/// Which chat LLM backend is active. The dash can switch live via `/provider` -/// (Phase 8); every backend calls the SAME ROCm tools through the seam. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub(crate) enum ChatProvider { - /// The auto-detected local OpenAI-compatible endpoint (or the no-key ChatGPT - /// OAuth default). This is the launch default and reuses the inline build. - #[default] - Local, - /// OpenAI's hosted Chat Completions API (`OPENAI_API_KEY`). - Openai, - /// Anthropic's Claude API (`ANTHROPIC_API_KEY`). - Anthropic, -} - -impl ChatProvider { - /// Parse the `/provider ` argument (case-insensitive). `None` for an - /// unrecognized name so the handler can hint instead of switching silently. - pub(crate) fn parse(s: &str) -> Option { - match s.trim().to_lowercase().as_str() { - "local" => Some(Self::Local), - "openai" => Some(Self::Openai), - "anthropic" => Some(Self::Anthropic), - _ => None, - } - } - - /// The lowercase label used in turns and hints. - pub(crate) const fn label(self) -> &'static str { - match self { - Self::Local => "local", - Self::Openai => "openai", - Self::Anthropic => "anthropic", - } - } -} - -/// Actionable empty-state shown when a chat is submitted with no agent built -/// (no detected endpoint and no provider key). Surfaced as an error turn — never -/// an error dump or a panic — and names the two concrete recovery actions. -pub(crate) const NO_CHAT_BACKEND_MSG: &str = "no chat backend is configured. Press d to detect a local engine, or use \ - /provider openai|anthropic with the matching API key set."; - -/// Result of routing a chat-input line through the slash-command handler. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(crate) enum SlashOutcome { - /// The line was a slash command and was handled in-reducer (state mutated, - /// or a slash-tool request raised). It must NOT be sent to the LLM. - Handled, - /// The line is not a slash command — fall through to normal agent dispatch. - NotCommand, -} - -/// A pending read-only slash command that needs the bin executor (no overlay). -/// `submit_chat` sets it; the event loop drains it once, off the async thread. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct SlashToolRequest { - /// Tool name to execute across the seam (e.g. `rocm_command`). - pub name: String, - /// JSON args for the tool (e.g. `{"args":["model"]}`). - pub args: serde_json::Value, - /// Human label for the chat turn header (e.g. `model`). - pub label: String, -} - -/// The structured next action from a natural-language plan (Phase 7). -/// -/// Plain data mirrored from the bin's `freeform_plan_next_action_with_context` -/// so the reducer can decide whether to hand a complete mutating action to the -/// approval modal. A placeholder action (`has_placeholders`) stays plan-only. -/// `pub` (not `pub(crate)`) because it is a payload of the `pub` [`ClientMsg`] -/// enum (mirrors [`crate::tool_exec::ApprovalIntent`]); the reducer entrypoints -/// that consume it stay crate-private. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct PlannedAction { - /// The rocm CLI argv to run (e.g. `["install","sdk","--prefix","/x"]`). - pub args: Vec, - /// Whether the planned action mutates local ROCm state (needs approval). - pub approval_required: bool, - /// Whether any arg is still a `` (the plan is incomplete). - pub has_placeholders: bool, - /// Whether a planner provider produced this plan. Provider-assisted plans - /// stay review-only (never auto-forwarded to execution), mirroring the - /// bin's `validate_freeform_execution_action` guard. - pub provider_assisted: bool, -} - -/// A surfaced mutating-tool approval awaiting the operator's decision (Phase 4). -/// Reusable for any [`crate::tool_exec::ApprovalIntent`] (the same modal serves -/// later phases: update/uninstall, permissions, plan). The modal owns keyboard -/// focus while `Some`; on Approve the `(name, arguments)` are replayed through -/// `execute_approved`; on Deny/Cancel nothing runs. -#[derive(Debug, Clone)] -pub(crate) struct PendingApproval { - pub req: crate::ui::approval::ApprovalRequest, - pub choice: crate::ui::approval::ApprovalChoice, - /// Tool name to re-execute on Approve (the validator already accepted it). - pub name: String, - /// JSON args for the approved re-execution. - pub arguments: serde_json::Value, -} - -/// Modal overlays. Only one is shown at a time, on top of the active tab body. -#[derive(Debug, Clone, Default, PartialEq, Eq)] -pub enum Modal { - #[default] - None, - Help, - Detail, - ThemePicker, - /// btop-style Esc main menu (Options / Help / Quit). - Menu, - /// "Go to…" command palette (tab/destination switch). - Palette, - /// Tabbed Options panel (General / CPU / GPU / Engines). - Options, - /// Global 2-column keyboard reference (distinct from the contextual `?`). - GlobalHelp, -} - -/// Reduction of a completed `rocm update --json` check, for the Home tab's -/// Updates tile. Distinct from `update_manager`'s interactive job state — this -/// tracks the periodic background check only. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum UpdateStatus { - /// No check has completed yet (startup, or `state.simulated`). - Unknown, - NoManagedRuntimes, - UpToDate, - UpdateAvailable { - latest_version: String, - }, - Error, -} - -/// How often the background Updates-tile check re-runs. -const UPDATE_CHECK_INTERVAL: Duration = Duration::from_hours(6); - -/// Job id for the periodic background update check driven off the tick loop. -/// Deliberately distinct from `update_manager`'s interactive `"update-check"` -/// so the two never clobber each other's job slot / console output. -/// `pub(crate)` so the Home tab's activity feed (`ui::tabs::home`) can filter -/// this job out — it is the tile's own plumbing, not user activity. -pub(crate) const HOME_UPDATE_CHECK_JOB_ID: &str = "home-update-check"; - -/// Bound on the background update check's own per-runtime index lookups, so a -/// slow/unreachable index can't leave the job running indefinitely — the same -/// principle as the CLI's own `STARTUP_UPDATE_CHECK_TIMEOUT_SECS`. The two -/// crates can't share the constant (`apps/rocm` depends on `rocm-dash-tui`, -/// not the reverse), so this value isn't required to match it. -const HOME_UPDATE_CHECK_TIMEOUT_SECS: u64 = 5; - pub struct AppState { pub connect: String, pub conn: ConnState, @@ -1675,4250 +1224,164 @@ impl AppState { } } -/// Whether the event loop should skip the embedded daemon client AND the chat -/// backend resolution. True exactly when a [`Focus`] is set: a focused host runs -/// one overlay that streams its own job through the job-bridge, so it needs -/// neither live telemetry nor an LLM. `focus == None` (the dashboard) keeps both. -/// Pure predicate → unit-testable without a runtime; also names the render branch -/// (`draw_focused` when true, `draw` when false). -const fn should_skip_daemon(focus: Option) -> bool { - focus.is_some() -} - -/// In a focused host, whether a console "close" key must be SWALLOWED because -/// the active job is still running. -/// -/// In the dashboard, `q` / running-`Esc` detach the console and leave the job -/// running in the background (the app persists). A focused host has no -/// background: closing the overlay trips the [`AppState::focused_should_exit`] -/// gate and returns from `event_loop`, tearing down the runtime and killing the -/// child via `kill_on_drop` — truncating a mutating install/serve mid-write. So -/// while the job is non-terminal we swallow those keys; the user stops a job -/// explicitly with `Ctrl+C` (never blocked here), and once it is terminal `q` / -/// `Esc` exit normally. Always `false` for the dashboard (`focus == None`). -fn focused_close_key_blocked(state: &AppState, focus: Option, code: KeyCode) -> bool { - if !should_skip_daemon(focus) { - return false; - } - let running = state - .active_job_id() - .is_some_and(|id| ui::job_console::console_esc_closes(state.jobs.job(id))); - running && matches!(code, KeyCode::Char('q') | KeyCode::Esc) -} +#[cfg(test)] +mod tests { + use super::*; + use ratatui::layout::Rect; + use tokio::sync::mpsc; -/// Open the single overlay a focused host should host, returning any initial -/// job-bridge side effects to pump (Examine auto-runs `rocm examine` on open; -/// Setup/Serve open their form and wait for input). Clears any other overlay -/// first (mutually-exclusive invariant). Pure w.r.t. process I/O — the caller -/// runs the returned effects through [`crate::jobs::run_effects`]. -fn open_overlay_for_focus( - state: &mut AppState, - focus: Focus, -) -> Vec { - state.close_overlays(); - match focus { - Focus::Setup => { - state.onboarding = Some(crate::ui::onboarding::OnboardingState::default()); - Vec::new() - } - Focus::Serve => { - state.serve_wizard = Some(crate::ui::serve_wizard::ServeWizardState::default()); - Vec::new() - } - Focus::Examine => { - let (mgr, fx) = crate::ui::examine_manager::open_running(&mut state.jobs); - state.examine_manager = Some(mgr); - fx - } - } -} + use crate::client::ClientMsg; -/// Reduce a parsed `rocm update --json` document's `runtimes` array into an -/// [`UpdateStatus`]. Empty ⇒ nothing managed to check; any update-available or -/// repair-available row wins over up-to-date/error rows (the tile surfaces the -/// most actionable state — a repair is as actionable as an update); otherwise -/// `UpToDate` only if every row resolved cleanly — a mixed result (some rows -/// errored, some unrecognized) can't honestly assert freshness for the -/// runtimes that didn't resolve, so it's `Error` too. -fn reduce_update_json(document: &serde_json::Value) -> UpdateStatus { - fn status_of(row: &serde_json::Value) -> Option<&str> { - row.get("status").and_then(serde_json::Value::as_str) - } - let Some(runtimes) = document - .get("runtimes") - .and_then(serde_json::Value::as_array) - else { - return UpdateStatus::Error; - }; - if runtimes.is_empty() { - return UpdateStatus::NoManagedRuntimes; - } - if let Some(row) = runtimes.iter().find(|row| { - matches!( - status_of(row), - Some("update_available" | "repair_available") - ) - }) { - // A missing/null `latest_version` must not silently fall through to - // the up-to-date/error checks below — that would misreport a real, - // actionable update as "check failed". Fall back to a placeholder - // instead of losing the actionable status. - let latest_version = row - .get("latest_version") - .and_then(serde_json::Value::as_str) - .unwrap_or("(version unknown)") - .to_owned(); - return UpdateStatus::UpdateAvailable { latest_version }; - } - if runtimes - .iter() - .all(|row| matches!(status_of(row), Some("up_to_date" | "ahead_of_index"))) - { - return UpdateStatus::UpToDate; - } - UpdateStatus::Error -} + use super::actions::run_approved; + use super::chat::build_chat_agent; + use super::summary::summarize_slash_tool; + use super::types::NO_CHAT_BACKEND_MSG; -/// Spawn/consume the periodic `home-update-check` job that backs the Home -/// tab's Updates tile, and return any job-bridge side effects to pump. -/// -/// Pure w.r.t. process I/O — like [`open_overlay_for_focus`], the caller runs -/// the returned effects through [`crate::jobs::run_effects`]. Called once per -/// tick from `event_loop`, skipped entirely under `state.simulated`. -fn refresh_update_status(state: &mut AppState) -> Vec { - if state.update_status_pending { - let Some(job) = state.jobs.job(HOME_UPDATE_CHECK_JOB_ID) else { - // Re-arm the same as the terminal-job path below: without this, - // a vanished job would leave `update_check_due_at` in the past, - // so every subsequent tick would spawn a new check immediately. - state.update_status_pending = false; - state.update_check_due_at = std::time::Instant::now() + UPDATE_CHECK_INTERVAL; - return Vec::new(); - }; - if !job.is_terminal() { - return Vec::new(); - } - state.update_status = match &job.status { - rocm_dash_core::state::JobStatus::Done { code: 0 } => job - .output - .iter() - .rev() - .find_map(|line| serde_json::from_str::(line).ok()) - .map_or(UpdateStatus::Error, |doc| reduce_update_json(&doc)), - _ => UpdateStatus::Error, - }; - state.update_status_pending = false; - state.update_check_due_at = std::time::Instant::now() + UPDATE_CHECK_INTERVAL; - return Vec::new(); + #[test] + fn back_out_requires_an_open_manager_on_any_tab() { + let mut s = AppState::new("t".into(), "default-dark".into()); + // No manager open → never backs out, even on a domain tab. + s.active_tab = ActiveTab::Rocm; + assert!(!s.should_pane_back_out(crossterm::event::KeyCode::Esc)); + // Manager open on a non-domain tab (opened from Observe hotkey) → + // Esc backs out uniformly regardless of tab, now that the + // Rocm/Serving-only gate is gone. New coverage of the generalized + // behavior — the manager's own event-loop arm already closed it on + // this tab before the gate was removed, so this isn't a regression + // test for a prior bug. + s.active_tab = ActiveTab::Observe; + s.examine_manager = Some(crate::ui::examine_manager::ExamineManagerState::default()); + assert!(s.has_open_overlay()); + assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); } - if std::time::Instant::now() < state.update_check_due_at { - return Vec::new(); + #[test] + fn esc_defers_to_manager_when_a_subscreen_is_open() { + // With a job console (or sub-popup / approval) open inside an inline + // manager, Esc must reach the manager (cancel the inner layer / be + // ignored while running), NOT eject the whole manager. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.install_manager = Some(crate::ui::install_manager::InstallManagerState { + active_job: Some("install-job".into()), // a console is up + ..Default::default() + }); + assert!(s.has_open_overlay()); + assert!( + !s.should_pane_back_out(crossterm::event::KeyCode::Esc), + "Esc must defer to the manager while a job console is open" + ); + // Once the console is dismissed (back at root), Esc backs out. + s.install_manager.as_mut().unwrap().active_job = None; + assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); } - if std::env::var_os("ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK").is_some() { - return Vec::new(); + #[test] + fn esc_defers_to_onboarding_install_config_subview() { + // Regression coverage for the `install_config` nesting field: the + // onboarding wizard's Configure sub-view is a nested sub-view just + // like a manager's job console, so root Esc must defer to it instead + // of ejecting the whole wizard. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.onboarding = Some(crate::ui::onboarding::OnboardingState { + install_config: Some(crate::ui::onboarding::InstallConfig::default()), + ..Default::default() + }); + assert!(s.has_open_overlay()); + assert!( + !s.should_pane_back_out(crossterm::event::KeyCode::Esc), + "Esc must defer to onboarding while the Configure sub-view is open" + ); + // Once the sub-view is closed (back at root), Esc backs out again. + s.onboarding.as_mut().unwrap().install_config = None; + assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); } - let fx = state - .jobs - .apply(rocm_dash_core::state::StateEvent::StartJob { - id: HOME_UPDATE_CHECK_JOB_ID.to_owned(), - cmd: crate::ui::exec::resolve_exe(), - args: vec![ - "update".to_owned(), - "--json".to_owned(), - "--timeout-secs".to_owned(), - HOME_UPDATE_CHECK_TIMEOUT_SECS.to_string(), - ], + #[test] + fn scroll_dock_clamps_against_buffer() { + let mut s = AppState::new("t".into(), "default-dark".into()); + // Dock 10 rows tall → ~7 visible lines after border/padding. + s.last_dock_area = Some(Rect::new(0, 0, 52, 10)); + s.jobs.apply(rocm_dash_core::state::StateEvent::StartJob { + id: "logs".into(), + cmd: "rocm".into(), + args: vec!["logs".into()], }); - if !fx.is_empty() { - state.update_status_pending = true; + for i in 0..20 { + s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { + id: "logs".into(), + line: format!("line {i}"), + }); + } + // 20 lines, ~7 visible → max scroll-up is bounded, never past the top. + s.scroll_dock(100); + assert!(s.dock_logs_scroll <= 13, "clamped: {}", s.dock_logs_scroll); + assert!(s.dock_logs_scroll > 0, "scrolled up some"); + s.scroll_dock(-100); + assert_eq!(s.dock_logs_scroll, 0, "back to the tail"); } - fx -} -/// The dashboard's entire startup gate: an overlay opens only when an -/// explicit `Focus` was resolved from the command line. `event_loop` calls -/// this directly (rather than inlining the `match`) so a regression test can -/// exercise the actual gate instead of `AppState::new`, which takes no focus -/// argument and can't observe it either way. -fn apply_startup_focus( - state: &mut AppState, - focus: Option, -) -> Vec { - match focus { - Some(focus) => open_overlay_for_focus(state, focus), - None => Vec::new(), + #[test] + fn scroll_instance_detail_clamps_to_measured_max() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.instance_detail_max_scroll = 9; + // i16::MAX is the jump-to-end gesture; it must land on the measured + // max, not overflow past it. + s.scroll_instance_detail(i16::MAX); + assert_eq!(s.instance_detail_scroll, 9, "jump-to-end clamps to max"); + // i16::MIN is jump-to-start; it must land on 0, not underflow. + s.scroll_instance_detail(i16::MIN); + assert_eq!(s.instance_detail_scroll, 0, "jump-to-start clamps to 0"); } -} - -pub async fn run(args: ResolvedArgs) -> color_eyre::Result<()> { - // Install the termination-signal watcher BEFORE switching the terminal into - // raw/alternate-screen mode. A signal that arrives during startup must find - // the listeners already registered; otherwise it takes the default - // disposition and kills the process while the terminal is still in raw mode - // — the exact broken-terminal state this guards against. Registration - // failure is propagated here (via `?`), before any terminal state is - // mutated, so we never enter raw mode without a working restore path. See - // `spawn_termination_watcher` for the exit-code and no-unwind semantics. - let signal_task = spawn_termination_watcher()?; - - enable_raw_mode()?; - let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen, EnableMouseCapture)?; - let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::new(backend)?; - - let res = event_loop(&mut terminal, &args).await; - - // The session ended on its own — the signal watcher is no longer needed and - // must not linger to fire (and re-restore/exit) after a clean return. - // What `abort()` buys is cancelling a watcher still parked on its `.await`: - // `spawn_termination_watcher`'s body runs straight from `await_termination` - // into `process::exit` with no await point in between, so once the signal - // has resolved there is nowhere for the cancellation to land. A signal - // arriving in the instant before this call therefore still ends the process - // with `128 + signo` instead of returning `res`. Narrow window, accepted: - // the process is exiting either way, and the terminal is restored on both - // paths. - signal_task.abort(); - - restore_after_session(&SHUTTING_DOWN, restore_terminal); - res -} - -/// Teardown for a dash *session* that has ended — the counterpart to the -/// watcher's and [`exit_on_ctrl_c`]'s teardown, for the one restore path that -/// does **not** end the process. -/// -/// Best-effort, reusing the exact teardown the signal path runs so the two -/// cannot drift, and never letting teardown failures override the session -/// result: if the controlling terminal already went away (e.g. the PTY closed on -/// quit), these writes can fail with a broken pipe — that must not turn a clean -/// exit into a non-zero one (every step inside `restore` is best-effort). -/// -/// # Why this reads the latch instead of claiming it -/// -/// [`SHUTTING_DOWN`] is a one-shot *process-exit* arbiter: whoever claims it -/// restores the terminal and calls `process::exit`, and nothing ever releases -/// it because there is no "after" to release into. `run` returning is the one -/// teardown with an after — bare `rocm` is a persistent hub, so `run` hands -/// control back to a live launcher menu that must keep painting, keep honouring -/// Ctrl-C, and keep being killable. Claiming the latch here wedged all three at -/// once: the render gate refused every subsequent launcher frame (a blank front -/// door), [`exit_on_ctrl_c`] parked forever, and every later `await_termination` -/// lost the claim and returned without exiting — a signal-swallowing, blank, -/// unkillable hub after the user's first flow. -/// -/// So the latch is *read*: if a watcher (or a typed Ctrl-C) has already claimed -/// the exit, it owns the teardown and is microseconds from ending the process, -/// and a second restore here would be pure redundancy. Otherwise this session -/// restores the terminal and leaves the latch untouched for the windows that -/// come after it. -/// -/// Single-writer is preserved by [`RESTORE_LOCK`] inside [`restore_terminal`] -/// rather than by this read, which is deliberately racy on its own: `abort()` -/// above cannot stop a watcher already resumed past its `.await` and inside its -/// own synchronous `restore_terminal(); process::exit(code)`, so a SIGTERM -/// landing in the same instant the user presses `q` can still put two threads on -/// this path. They serialise on the lock; they no longer contend for the latch. -/// -/// `restore` is a parameter so a unit test can assert both halves of the -/// contract — that the teardown runs, and that it leaves the latch unclaimed — -/// without writing escape sequences to the stdout every other test shares. -pub(crate) fn restore_after_session(latch: &AtomicBool, restore: impl FnOnce()) { - if shutdown_claimed_on(latch) { - return; - } - restore(); -} -/// Best-effort teardown of the terminal modes `run` set up. Disables raw mode -/// (process-global terminal state, so this can safely run from the signal -/// watcher even though the [`Terminal`] backend owns its own `stdout` clone), -/// then writes the alt-screen/mouse/cursor restore sequences to a fresh -/// `stdout`. Every step is best-effort: on a signal we are about to exit anyway, -/// and a vanished controlling terminal must not turn teardown into a panic. -/// -/// # Ordering against the renderer -/// -/// This writer is genuinely concurrent with the renderer: the watcher runs on a -/// Tokio worker thread while frames are drawn on the thread that owns the loop -/// (`block_on`'s thread for a dashboard session, the synchronous menu thread for -/// the launcher hub). A frame that lands *after* this function's bytes cannot -/// re-enter the alternate screen — `EnterAlternateScreen` is emitted exactly -/// once at startup and `Terminal::draw` never re-emits it — but every frame ends -/// by hiding the cursor (`Terminal::draw` emits `Hide` unconditionally when the -/// frame sets no cursor position), so a late frame undoes the show-cursor half of -/// this restore and leaves the user with an invisible cursor. -/// -/// Gating the loops on [`shutdown_claimed_on`] is necessary but *not* sufficient, -/// and the gap is not cosmetic: the claim cannot stop a frame that already passed -/// the gate, and that frame's tail is written after these bytes. Measured, rather -/// than argued — SIGINT delivered to a real `rocm dash --demo` under a pty, 300 -/// runs on a loaded Linux box: 6 of them ended with this restore spliced into the -/// middle of a frame (`…;48;` `ESC[?1049l … ESC[?25h` `2;19;20;22m qui…ESC[?25l`), -/// leaving the alternate screen with the cursor still hidden. That is exactly -/// what the E2E scenario `dash-sigint-restores-terminal` reported from the WSL2 -/// lane, and it reproduces identically on the commits before this PR, so it is -/// the long-standing shape of the race and not a new one. -/// -/// So the window is closed rather than accepted: [`TERMINAL_WRITE_LOCK`] is held -/// across one whole frame and across one whole restore, and both render loops -/// re-check the latch *while holding it*. Every interleaving then ends with these -/// bytes last — either the frame completes first and this restore follows it, or -/// this restore goes first and the gate suppresses the frame behind it. -/// -/// This adds no new hang: a renderer blocked mid-frame on a full terminal blocks -/// this restore's own writes to that same terminal just as surely, so the lock -/// can only make us wait where we were already waiting. -/// -/// # Ordering against another restore -/// -/// The same lock covers the second hazard. Two teardowns can run at once: a -/// signal watcher resumed past its `.await` races [`run`]'s clean-quit teardown -/// (which deliberately does not claim the exit latch — see -/// [`restore_after_session`]), and two watchers on two runtimes both wake on one -/// process-global signal. Holding [`TERMINAL_WRITE_LOCK`] makes this function the -/// single writer for the duration of one teardown, so two threads can never -/// interleave `write_restore_sequences` on the same stdout. -pub(crate) fn restore_terminal() { - // Poisoning is irrelevant here: nothing inside can panic (every step is - // best-effort), and a teardown skipped because some *other* thread panicked - // mid-restore is strictly worse than running it again. - let _guard = lock_terminal_writer(); - // `disable_raw_mode` mutates the real terminal (there is no in-memory - // equivalent), so it stays outside the testable sequence writer below. - let _ = disable_raw_mode(); - let _ = write_restore_sequences(&mut io::stdout()); -} + #[test] + fn scroll_console_clamps_and_tracks_output() { + let mut s = AppState::new("t".into(), "default-dark".into()); + // No console → both axes clamp to 0 (no content to pan over). + s.scroll_console(5, 5); + assert_eq!(s.console_scroll, 0); + assert_eq!(s.console_hscroll, 0, "no console → horizontal clamps to 0"); + // With a console of N lines, vertical clamps to N-1. + s.jobs.apply(rocm_dash_core::state::StateEvent::StartJob { + id: "logs".into(), + cmd: "rocm".into(), + args: vec!["logs".into()], + }); + for i in 0..4 { + s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { + id: "logs".into(), + line: format!("line {i}"), + }); + } + s.logs_view = Some(crate::ui::logs_view::LogsViewState { + active_job: Some("logs".into()), + ..Default::default() + }); + s.console_scroll = 0; + s.scroll_console(100, 0); + assert_eq!(s.console_scroll, 3, "clamped to output.len()-1 (4 lines)"); + s.scroll_console(-100, 0); + assert_eq!(s.console_scroll, 0); + // Horizontal clamps to the widest line minus one ("line 0" = 6 chars). + s.scroll_console(0, 100); + assert_eq!(s.console_hscroll, 5, "clamped to max line width - 1"); + s.scroll_console(0, -100); + assert_eq!(s.console_hscroll, 0); + } -/// Serialises everything that writes to the process's one stdout while the TUI -/// owns it: one whole frame from either render loop, and one whole teardown in -/// [`restore_terminal`]. -/// -/// Distinct from [`SHUTTING_DOWN`], and deliberately so: the latch answers "is -/// the process exiting" (one-shot, never released), this answers "is someone -/// writing the terminal right now" (re-armable, held for the length of one frame -/// or one teardown). Conflating them is what made a clean session exit -/// permanently wedge the launcher hub. -/// -/// The two are used *together* in the render gates — the latch is re-read under -/// this lock — because neither alone is enough: the lock without the latch would -/// merely order a late frame after the restore, and the latch without the lock -/// cannot stop a frame that has already passed the gate. See the ordering notes -/// on [`restore_terminal`]. -static TERMINAL_WRITE_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - -/// Take exclusive ownership of the terminal for the length of one frame or one -/// teardown. Poisoning is recovered from rather than propagated: a teardown or a -/// frame skipped because some *other* thread panicked while holding the lock is -/// strictly worse than doing it anyway. -/// -/// `pub(crate)` so the launcher's menu loop — the crate's other render loop — -/// can take the same lock around its own frame. -pub(crate) fn lock_terminal_writer() -> std::sync::MutexGuard<'static, ()> { - TERMINAL_WRITE_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner) -} + // ---------- T13: bench_run overlay invariants ---------- -/// Write the escape sequences that undo `run`'s terminal setup — leave the -/// alternate screen, disable mouse capture, show the cursor — to `out`. Split -/// from [`restore_terminal`] so a unit test can drive an in-memory sink and -/// assert the emitted bytes, rather than writing to the process's shared stdout -/// (which races every other test in the single-process `cargo test` lane). -fn write_restore_sequences(out: &mut W) -> io::Result<()> { - execute!( - out, - LeaveAlternateScreen, - DisableMouseCapture, - crossterm::cursor::Show - ) -} - -/// Register the termination-signal listeners on the current Tokio runtime and -/// spawn a detached watcher that restores the terminal and exits `128 + signo`. -/// -/// Returns the task handle so a caller whose process ends with the session -/// (`rocm dash`) can `abort()` it on a clean return; the persistent launcher hub -/// instead keeps its runtime alive and drops the handle, letting the watcher -/// live for the whole process so bare `rocm` stays killable across the sessions -/// it builds and drops (Tokio never unregisters its libc handler, so a -/// per-session watcher would go deaf the moment its runtime is dropped — see -/// `dash::run_launcher`). -/// -/// Registration happens here, synchronously, and is surfaced via `?`; callers -/// invoke this BEFORE entering raw/alternate-screen mode so a failure never -/// leaves the terminal switched with no restore path, and a signal arriving -/// during startup is latched by the already-installed OS handlers. -/// -/// The watcher ends the process with [`std::process::exit`], which does not -/// unwind — this is deliberate. SIGTERM/SIGINT means "stop now", so we restore -/// the terminal and leave promptly rather than racing an orderly teardown -/// against an imminent default-disposition kill. Two consequences are accepted -/// as the intended behavior: (1) an in-flight focused install/serve child is -/// left to the OS rather than reaped via `kill_on_drop`, matching the -/// pre-existing default disposition and avoiding truncating a mid-write child on -/// the way out; and (2) `run_async`'s embedded-daemon socket is not unlinked -/// here, but the daemon unlinks a stale socket on its next bind, so it self-heals. -/// -/// More than one watcher can be live at once — bare `rocm` escalates from the -/// hub into a dashboard session, so the hub's process-lifetime watcher and -/// `run`'s session watcher coexist — and Tokio's signal registry is -/// process-global: one `kill` notifies *every* subscriber regardless of which -/// runtime registered it. Both watchers therefore wake on the same signal. The -/// [`SHUTTING_DOWN`] latch arbitrates: only the first one through restores the -/// terminal and exits, so two threads never race unsynchronised writes to -/// stdout nor call `std::process::exit` concurrently. See [`await_termination`]. -pub fn spawn_termination_watcher() -> color_eyre::Result> { - let termination = TerminationSignals::register()?; - Ok(tokio::spawn(async move { - let Some(code) = await_termination(termination, &SHUTTING_DOWN).await else { - // A sibling watcher already claimed the shutdown and is about to - // `exit`; this one must do nothing at all. - return; - }; - restore_terminal(); - std::process::exit(code); - })) -} - -/// Process-global "some watcher has claimed the termination path" latch. -/// -/// Deliberately process-global rather than threaded through `ResolvedArgs`: the -/// hazard is two watchers on two *runtimes*, and a path-independent latch covers -/// every call site (present and future) without each one having to know whether -/// an outer watcher already exists. -/// -/// One-shot by construction: it is claimed only by paths that go on to call -/// `std::process::exit` (the watcher body and [`exit_on_ctrl_c`]), so there is -/// no "after" to release it into, and every reader — the two render gates, -/// [`await_termination`]'s arbitration, [`restore_after_session`] — may treat a -/// claimed latch as "this process is ending". A teardown that does *not* end the -/// process must therefore never claim it; see [`restore_after_session`] for the -/// three separate ways that wedged the launcher hub. Mutual exclusion between -/// concurrent restores is [`RESTORE_LOCK`]'s job, not this latch's. -pub(crate) static SHUTTING_DOWN: AtomicBool = AtomicBool::new(false); - -/// Claim the single-shot shutdown path on `latch`. Returns `true` exactly once — -/// for whichever caller wins the swap — and `false` for every caller after it. -/// -/// `SeqCst` because correctness here is "exactly one winner across threads", not -/// ordering of surrounding data; the stronger ordering costs nothing on a path -/// that runs at most once per process. -/// -/// Parameterised over the latch (rather than reading [`SHUTTING_DOWN`] directly) -/// so tests can drive a fresh latch and stay order-independent — the process -/// global cannot be reset once a test has set it. -fn claim_shutdown(latch: &AtomicBool) -> bool { - !latch.swap(true, Ordering::SeqCst) -} - -/// Whether the shutdown path has already been claimed on `latch` — by a signal -/// watcher or by a typed Ctrl-C — meaning the terminal is being restored and the -/// process is about to exit. -/// -/// Both render gates ([`draw_frame_unless_shutting_down`] and the launcher's -/// `draw_menu_unless_shutting_down`) consult this before every frame so a `draw` -/// cannot land after [`restore_terminal`] has run and undo it. See the ordering -/// note on [`restore_terminal`] for why that is the chosen fix rather than a lock -/// on every terminal write. -/// -/// Takes the latch explicitly rather than reading [`SHUTTING_DOWN`] directly so -/// a test can drive a fresh one and stay order-independent — the process global -/// cannot be reset once a test has set it (same reason [`claim_shutdown`] is -/// parameterised). Production callers pass [`SHUTTING_DOWN`]. -pub(crate) fn shutdown_claimed_on(latch: &AtomicBool) -> bool { - latch.load(Ordering::SeqCst) -} - -/// Whether `k` is a typed Ctrl-C. -/// -/// Raw mode is why this must be a key match at all. `enable_raw_mode` clears -/// `ISIG` on Unix and `ENABLE_PROCESSED_INPUT` on Windows; with those off the -/// terminal driver does NOT translate the keystroke into SIGINT (or raise -/// `CTRL_C_EVENT`) — it hands the application the byte `0x03` like any other -/// key. So while the TUI is up, [`TerminationSignals`] covers an *externally* -/// delivered `kill -INT` / `GenerateConsoleCtrlEvent` (and a Ctrl-C during the -/// startup window before raw mode is entered), but never the gesture a user -/// performs inside the dashboard. That one arrives here. -/// -/// Matches lowercase `c` only, mirroring -/// [`crate::ui::job_console::on_console_key`]: terminals commonly bind -/// Ctrl+Shift+C to copy, and claiming it would break a paste workflow. -pub(crate) const fn is_ctrl_c(k: KeyEvent) -> bool { - matches!(k.code, KeyCode::Char('c')) && k.modifiers.contains(KeyModifiers::CONTROL) -} - -/// Whether a key event must end the dashboard session: a typed Ctrl-C, unless a -/// console for a *still-running* job is displayed. -/// -/// Split out of the event loop's match guard so the precedence is testable -/// without a terminal. The console exception is the whole reason this is not -/// simply [`is_ctrl_c`]: while a job is running, Ctrl+C already means "cancel -/// this running job" (`ui::job_console::on_console_key`), which is the documented -/// way to stop a focused install/serve without truncating it — killing the -/// process instead would be a regression. -/// -/// That justification stops the moment the job reaches a terminal state, and the -/// exception has to stop with it. No manager clears its `active_job` on -/// completion — it is cleared only when the user dismisses the console with -/// Esc/Enter — so a finished console stays on screen indefinitely. Exempting it -/// unconditionally made Ctrl-C fall through to `on_console_key`, which emits -/// `CancelJob`, which the reducer ignores on a terminal job: the keystroke was a -/// **silent no-op**, leaving the user in raw mode on the alternate screen. Gating -/// on the job being non-terminal keeps "cancel the job" winning only while there -/// is a job left to cancel. -fn ctrl_c_should_exit(state: &AppState, k: KeyEvent) -> bool { - let job = state.active_job_id().and_then(|id| state.jobs.job(id)); - // The uncodified invariant this predicate leans on: a manager's `active_job` - // is the id of a job it just spawned into `state.jobs`, so the lookup above - // resolves. Thirteen manager modules set `active_job`; nothing enforces the - // pairing at a type level. If it ever breaks, `is_none_or` below silently - // reads "no job console is up" and Ctrl-C would quit out from under a - // *running* job — the one case this function exists to prevent. Assert it in - // debug builds so a manager that sets an id without a matching job trips the - // suite rather than shipping the wrong precedence. - debug_assert!( - state.active_job_id().is_none() || job.is_some(), - "active_job id {:?} is not in the jobs map; ctrl_c_should_exit would \ - treat a live job console as absent", - state.active_job_id() - ); - // Reads as: no job console is up, or the one that is has already finished. - is_ctrl_c(k) && job.is_none_or(rocm_dash_core::state::JobState::is_terminal) -} - -/// End the process from a typed Ctrl-C, taking exactly the path an externally -/// delivered SIGINT takes: claim the shutdown latch, restore the terminal, exit -/// [`EXIT_CODE_SIGINT`]. Shared by the dashboard event loop and the launcher -/// menu — the two key loops are separate, and routing both here is what stops -/// the gesture from meaning different things in the two windows. -/// -/// Never returns, and (like the watcher) exits without unwinding; see -/// [`spawn_termination_watcher`] for the consequences that are accepted there -/// and apply identically here. -pub(crate) fn exit_on_ctrl_c() -> ! { - if claim_shutdown(&SHUTTING_DOWN) { - restore_terminal(); - std::process::exit(EXIT_CODE_SIGINT); - } - // Lost the race to a watcher that has already claimed the shutdown and is - // microseconds from `exit`. Park rather than racing a second - // `std::process::exit`; the winner ends the process. `park` is allowed to - // wake spuriously, hence the loop. - loop { - std::thread::park(); - } -} - -/// Park until a termination signal arrives, then arbitrate on `latch`. -/// -/// Returns `Some(128 + signo)` for the single watcher that wins the latch — that -/// caller must restore the terminal and exit with the code — and `None` for any -/// other watcher woken by the same process-global signal delivery. -/// -/// Split out of [`spawn_termination_watcher`]'s task body so a test can drive the -/// whole register → receive → arbitrate path in-process; the body itself ends in -/// `std::process::exit` and can never be unit-tested. -async fn await_termination(termination: TerminationSignals, latch: &AtomicBool) -> Option { - let code = termination.recv().await; - claim_shutdown(latch).then_some(code) -} - -/// Termination-signal listeners, registered up front so a signal that arrives -/// during terminal setup is latched by the OS/Tokio rather than taking the -/// default disposition. -/// -/// [`TerminationSignals::register`] installs the OS handlers and is called -/// BEFORE the terminal is switched into raw/alternate-screen mode, so a -/// registration failure is surfaced (via `?`) before any terminal state is -/// mutated. [`TerminationSignals::recv`] then parks the watcher task until a -/// signal fires, returning the conventional `128 + signo` exit code the process -/// should report (SIGINT → 130, SIGTERM → 143). -/// -/// Scope, stated narrowly because it is easy to overclaim: this covers signals -/// that arrive as signals — `kill -TERM` / `kill -INT` from a supervisor or -/// another process, and anything delivered during the startup window before -/// `enable_raw_mode`. It does NOT cover a Ctrl-C typed at the running TUI: raw -/// mode clears `ISIG`, so the terminal driver never turns that keystroke into a -/// SIGINT and it arrives as a key event instead. See [`is_ctrl_c`], which both -/// key loops route to this same restore-and-exit path. -#[cfg(unix)] -struct TerminationSignals { - sigterm: tokio::signal::unix::Signal, - sigint: tokio::signal::unix::Signal, -} - -#[cfg(unix)] -impl TerminationSignals { - fn register() -> color_eyre::Result { - use tokio::signal::unix::{SignalKind, signal}; - - // Register both handlers before entering raw mode. `?` on each - // propagates a setup failure instead of parking forever; and if the - // second registration fails, the first is dropped (unregistered) as we - // return the error, so we never leave one signal silently swallowed. - Ok(Self { - sigterm: signal(SignalKind::terminate())?, - sigint: signal(SignalKind::interrupt())?, - }) - } - - async fn recv(mut self) -> i32 { - tokio::select! { - _ = self.sigterm.recv() => EXIT_CODE_SIGTERM, - _ = self.sigint.recv() => EXIT_CODE_SIGINT, - } - } -} - -/// Windows analog: console control events, which would otherwise skip terminal -/// restoration the same way. Tokio exposes the two as separate streams, so BOTH -/// are registered — `ctrl_c()` subscribes only to `CTRL_C_EVENT` and would miss -/// a Ctrl-Break. Ctrl-Break is the harder "terminate" gesture and maps to the -/// SIGTERM code; Ctrl-C maps to the SIGINT code. -/// -/// Scope, narrowly, because the two arms differ and the difference matters: -/// -/// - Ctrl-Break: `ENABLE_PROCESSED_INPUT` does not affect `CTRL_BREAK_EVENT`, so -/// this arm is live for the whole session and is what actually terminates a -/// running TUI through the signal path. -/// - Ctrl-C: crossterm's raw mode clears `ENABLE_PROCESSED_INPUT`, and with that -/// flag off the console delivers a typed Ctrl+C into the input buffer as a KEY -/// EVENT and never raises `CTRL_C_EVENT`. This stream therefore cannot fire -/// while the TUI is up; it is kept for the startup window before -/// `enable_raw_mode` and for a `GenerateConsoleCtrlEvent` sent by another -/// process. The typed gesture is handled as a key instead — see [`is_ctrl_c`], -/// which routes it to the same restore path. -#[cfg(windows)] -struct TerminationSignals { - ctrl_c: tokio::signal::windows::CtrlC, - ctrl_break: tokio::signal::windows::CtrlBreak, -} - -#[cfg(windows)] -impl TerminationSignals { - fn register() -> color_eyre::Result { - use tokio::signal::windows::{ctrl_break, ctrl_c}; - - // `?` propagates a registration failure before raw mode is entered, - // rather than the old `let _ = ctrl_c().await` which treated a failed - // registration as a received Ctrl-C and exited immediately. - Ok(Self { - ctrl_c: ctrl_c()?, - ctrl_break: ctrl_break()?, - }) - } - - async fn recv(mut self) -> i32 { - tokio::select! { - _ = self.ctrl_c.recv() => EXIT_CODE_SIGINT, - _ = self.ctrl_break.recv() => EXIT_CODE_SIGTERM, - } - } -} - -/// Conventional shell exit code for a process terminated by SIGINT (128 + 2). -const EXIT_CODE_SIGINT: i32 = 130; -/// Conventional shell exit code for a process terminated by SIGTERM (128 + 15). -const EXIT_CODE_SIGTERM: i32 = 143; - -/// Draw one dashboard frame, unless a shutdown has already been claimed on -/// `latch`. -/// -/// This is the render gate. A termination may be in flight on another thread -/// (the signal watcher, or a typed Ctrl-C): the terminal is being restored, so -/// the frame must not land after the restore and undo it. See the ordering note -/// on [`restore_terminal`]. -/// -/// The gate is the latch read **plus** [`lock_terminal_writer`], and needs both. -/// The lock is taken first and held across the whole frame, so the restore can -/// neither splice its bytes into the middle of this frame nor be overtaken by its -/// tail; the latch is then read *under* that lock, so a restore that got there -/// first suppresses this frame entirely instead of merely preceding it. -/// -/// Extracted from the event loop's body — and parameterised over the backend and -/// the latch — purely so the gate is *testable*: a test can drive a -/// `TestBackend`, claim a local latch, and assert no cells were painted. Inlined -/// in the loop it was unreachable from any test, and deleting it turned nothing -/// red. -/// -/// Focused host renders overlay-only (no header / tabs / dock / footer chrome); -/// the dashboard renders the full shell. -fn draw_frame_unless_shutting_down( - terminal: &mut Terminal, - state: &mut AppState, - focus: Option, - latch: &AtomicBool, -) -> Result<(), ::Error> { - let _writer = lock_terminal_writer(); - if shutdown_claimed_on(latch) { - return Ok(()); - } - if should_skip_daemon(focus) { - terminal.draw(|f| ui::draw_focused(f, state))?; - } else { - terminal.draw(|f| ui::draw(f, state))?; - } - Ok(()) -} - -async fn event_loop(terminal: &mut Tui, args: &ResolvedArgs) -> color_eyre::Result<()> { - let (tx, mut rx) = mpsc::unbounded_channel::(); - // Job-bridge channel (Phase 3 Wave 1): the async runtime streams - // `StateEvent`s (JobLine/JobDone/JobErr) for operational screens here. - let (job_tx, mut job_rx) = mpsc::unbounded_channel::(); - // Retain a sender for chat replies BEFORE `tx` is moved into the client / - // replay task below — the spawned agent task feeds replies back through the - // same `rx.recv()` arm the daemon events already use (no new plumbing). - let chat_tx = tx.clone(); - let replay_controller = if let Some(path) = args.replay.clone() { - Some(crate::replay::spawn(path, tx)) - } else if should_skip_daemon(args.focus) { - // Focused host: no daemon client. The overlay streams its own job via - // the job-bridge and the telemetry chrome isn't drawn, so a live - // connection would only spawn an unused embedded daemon. Drop `tx` - // (its `chat_tx` clone keeps `rx` alive for the loop); nothing is sent. - drop(tx); - None - } else { - client::spawn(args.connect.clone(), tx); - None - }; - - let mut events = EventStream::new(); - let mut tick = interval(Duration::from_millis(250)); - let connect_label = match &args.replay { - Some(p) => format!( - "replay:{}", - p.file_name().and_then(|n| n.to_str()).unwrap_or("?") - ), - None => args.connect.clone(), - }; - 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; - // 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. - state.runtimes = args.runtimes.clone(); - // Automations manager source (Phase 3 Wave 3), adapted by the bin. - state.automations = args.automations.clone(); - // Tool-executor seam (Phase 2 plumbing), injected by the bin; None for - // demo/replay/mock. Phase 3 will use it. - state.tool_executor = args.tool_executor.clone(); - // Daemon-tailed bench CSV path for the bench-run form's default --out. - state.bench_results_dir = args.bench_results_dir.clone(); - // Managed-service records that are no longer running, counted by the bin. - state.services_past_attempts = args.services_past_attempts; - // Focused host: open exactly the overlay for the requested flow (Examine - // also auto-runs its read-only job). `Focus::Setup` opens the onboarding - // overlay — the same wizard `rocm bootstrap setup` routes to. - let fx = apply_startup_focus(&mut state, args.focus); - crate::jobs::run_effects(fx, &job_tx); - state.replay = replay_controller.map(ReplayState::new); - // Both `--demo` (a generated session replayed) and `--replay ` present - // non-live data, so mark the session simulated for the honesty chrome. - state.simulated = state.replay.is_some(); - - // Resolve the chat backend. `--chat-mock` short-circuits detection with a - // deterministic offline MockAgentClient (no live LLM, no network); otherwise - // we auto-detect the endpoint (the std-TCP probe runs once on a blocking - // thread before the first frame) and build the Rig backend. - let mut agent: Option> = if should_skip_daemon( - args.focus, - ) { - // Focused host: Setup/Serve/Diagnose never chat. Skip endpoint detection - // and backend construction entirely — no probe, no network, no OAuth - // default. `chat_llm` stays `None` and the Chat tab is never drawn here. - None - } else if args.chat_mock { - state.set_chat_config( - Some(crate::llm::LlmConfig { - base_url: "mock://offline-demo".to_string(), - model: "mock-agent".to_string(), - api_key: None, - auth_header: None, - }), - true, - ); - Some(std::sync::Arc::new( - crate::agent::MockAgentClient::with_tool_call_and_approval_trigger( - "GPU-2 is running hot: 87% util, 71°C, drawing 250 W (90 GB/192 GB VRAM).", - "gpu_status", - "install the sdk", - crate::tool_exec::ApprovalIntent { - title: "Install TheRock ROCm SDK?".to_string(), - body: vec![ - "install_sdk --channel release --format wheel --prefix ~/rocm-sdk" - .to_string(), - ], - name: "install_sdk".to_string(), - arguments: serde_json::json!({ - "channel": "release", - "format": "wheel", - "prefix": "~/rocm-sdk", - }), - }, - chat_tx.clone(), - ), - ) as std::sync::Arc) - } else { - // An endpoint we launched ourselves (managed-services registry) takes - // priority over the well-known default port — this is how a tool-launched - // engine on a non-default port (e.g. vLLM on :11435) is found. It does - // NOT override an explicitly configured `chat_url`/env URL, so config - // precedence is preserved (we only consult the registry when neither is - // set, i.e. where the well-known default would otherwise be probed). - // When neither an explicit URL (CLI/config) nor an env URL is set, run - // the SAME full local-engine detection the manual 'd' path uses: - // registry-first (an engine we launched ourselves, on whatever port it - // bound), then a probe of the well-known Lemonade/vLLM/rocm-serve - // ports (parallelized — see `llm::detect_local_endpoint` — so a cold - // start with no server doesn't pay 3x the probe timeout), plus a - // best-effort served-model fetch. This is what lets a local server win - // over the ChatGPT cloud default at startup instead of only the single - // well-known :8000 port that a bare `resolve_llm_config` probe covers. - // - // NOTE: unmerged PR #97 also touches this branch (model discovery when - // `chat_model` is None, inside `resolve_llm_config`'s own fallback - // path) — this change is conflict-minimal by leaving the - // `resolve_llm_config` call below untouched. - // - // Gate on `chat_api_key.is_none()` too: local detection returns a - // keyless `detected_llm_config` (api_key/auth_header forced to None), - // so firing it when the user configured a key would SILENTLY DROP that - // key and 401 at request time. A configured key means "use my - // configured backend", so skip the swap and let `resolve_llm_config` - // carry the key through its normal precedence. - let detection_ran = chat::should_detect_local_chat( - args.chat_url.as_deref(), - args.chat_env_url.as_deref(), - args.chat_api_key.as_deref(), - ); - let detected = if detection_ran { - detect_local_chat(state.tool_executor.clone()).await - } else { - None - }; - let probe_target = args - .chat_url - .clone() - .or_else(|| args.chat_env_url.clone()) - .unwrap_or_else(|| crate::llm::DEFAULT_CHAT_BASE_URL.to_string()); - // A detected endpoint (managed or probed) is already verified. When - // detection ran and found nothing it already probed the well-known - // vLLM :8000 port (== `DEFAULT_CHAT_BASE_URL`), so re-probing the same - // fallback target here is redundant and just burns another probe - // timeout on a cold start — treat that as unreachable directly. - // Otherwise (an explicit URL/env/key path) TCP-probe the target. - let startup_outcome = startup_chat_outcome(detection_ran, detected.is_some()); - let probe_ok = match startup_outcome { - StartupChatOutcome::Local => true, - StartupChatOutcome::OAuth => false, - StartupChatOutcome::Configured => tokio::task::spawn_blocking(move || { - crate::llm::probe_endpoint(&probe_target, crate::llm::PROBE_TIMEOUT) - }) - .await - .unwrap_or(false), - }; - let llm = detected.or_else(|| { - crate::llm::resolve_llm_config( - args.chat_url.as_deref(), - args.chat_model.as_deref(), - None, - None, - args.chat_api_key.as_deref(), - args.chat_env_url.as_deref(), - args.chat_auth_header.as_deref(), - probe_ok, - ) - }); - // PR #97 port onto PR #100's startup flow: a *configured* URL (CLI/env) - // with no explicit model resolves to the `local-model` placeholder, - // which 404s on servers that register the model under its real id. Only - // the `Configured` outcome needs this — the `Local` outcome already - // carries a `/v1/models`-discovered model from `detect_local_chat`, and - // `OAuth` has no config. Discovery is gated inside the helper on - // `probe_ok` (an unreachable endpoint is never probed nor replaced) and - // on the absence of an explicit model (config precedence wins). - let llm = match llm { - Some(cfg) if startup_outcome == StartupChatOutcome::Configured => Some( - discover_configured_chat_model(cfg, args.chat_model.as_deref(), probe_ok).await, - ), - other => other, - }; - state.set_chat_config(llm, args.chat_auto_consent); - // No reachable local endpoint AND no key/url configured → the no-key - // ChatGPT OAuth default (device-code login surfaced in the chat tab). - // This restores the no-key login the vendored Codex path provided; it - // takes NO api_key (env-only invariant untouched — OAuth, not a key). - let no_key_no_endpoint = startup_outcome == StartupChatOutcome::OAuth; - if no_key_no_endpoint { - let oauth_tx = chat_tx.clone(); - crate::agent::ChatGptAgentClient::new( - args.chat_model.clone(), - args.inference_params(), - move |url, code| { - let _ = oauth_tx.send(ClientMsg::ChatReply { - text: format!( - "To enable chat, sign in to ChatGPT: open {url} and enter the code {code}" - ), - }); - }, - state.tool_executor.clone(), - Some(chat_tx.clone()), - ) - .ok() - .map(|c| c.with_preamble(args.chat_system_prompt.clone())) - .map(|c| std::sync::Arc::new(c) as std::sync::Arc) - } else { - // A build failure leaves `agent` None; a submit surfaces an error turn. - match &state.chat_llm { - Some(cfg) => build_local_agent( - cfg.clone(), - args.inference_params(), - state.tool_executor.clone(), - chat_tx.clone(), - args.chat_system_prompt.clone(), - ) - .ok(), - None => None, - } - } - }; - - // Snapshot the auto-detected local backend so `/provider local` can restore - // it after a switch to a remote provider. Without this, switching to OpenAI - // and back to local would leave `agent` pointing at the OpenAI backend - // (silent wrong-backend bug) — `build_chat_agent(Local)` returns None by - // design (Local is the inline-built backend), so the caller must restore the - // saved clone here. `Option>` clone is a cheap Arc refcount bump. - let mut local_agent = agent.clone(); - - loop { - draw_frame_unless_shutting_down(terminal, &mut state, args.focus, &SHUTTING_DOWN)?; - tokio::select! { - _ = tick.tick() => { - // Advance the animation clock so spinners cycle even while a - // job produces no new output. - state.tick_count = state.tick_count.wrapping_add(1); - // Drive the Home tab's Updates tile off a real periodic check. - // Never in `--demo`/`--replay` sessions: simulated data must - // never shell out or look live (see `AppState::simulated`). - if !state.simulated { - let fx = refresh_update_status(&mut state); - crate::jobs::run_effects(fx, &job_tx); - } - } - maybe_msg = rx.recv() => { - match maybe_msg { - Some(ClientMsg::Connecting) => state.conn = ConnState::Connecting, - Some(ClientMsg::Connected { host, daemon_version }) => { - state.conn = ConnState::Connected { host, version: daemon_version }; - } - Some(ClientMsg::Disconnected { reason }) => { - state.conn = ConnState::Disconnected { reason }; - state.latest = None; - } - Some(ClientMsg::Event(ev)) => state.apply_event(*ev), - Some(ClientMsg::ReplaySeek) => state.reset_for_seek(), - Some(ClientMsg::ReplayPosition { elapsed_s, total_s }) => { - if let Some(r) = state.replay.as_mut() { - r.elapsed_s = elapsed_s; - r.total_s = total_s; - } - } - Some(ClientMsg::ChatReply { text }) => state.on_chat_reply(text), - Some(ClientMsg::SlashToolReply { text }) => state.on_slash_tool_reply(text), - Some(ClientMsg::ChatError { message }) => state.on_chat_error(message), - Some(ClientMsg::ChatDetectResult { offer }) => state.set_detect_result(offer), - // A mutating tool (or slash command) surfaced an approval — - // open the modal; nothing executes until the operator approves. - Some(ClientMsg::ChatApprovalRequired { intent }) => state.open_approval(intent), - // An approved action finished: append the result turn and - // fire exactly one automatic follow-up agent turn. - Some(ClientMsg::ChatApprovalResult { text }) => state.on_approval_result(text), - // A `/plan` plan completed: render the review and (for a - // complete mutating action) hand it to the approval modal. - Some(ClientMsg::PlanReady { text, action }) => { - state.on_plan_ready(text, action); - } - None => break, - } - } - // Job-bridge events feed the operational-screen job model (Wave 1). - maybe_job = job_rx.recv() => { - if let Some(ev) = maybe_job { - let fx = state.jobs.apply(ev); - crate::jobs::run_effects(fx, &job_tx); - } - } - maybe_ev = events.next() => { - match maybe_ev { - // Only ACT on key presses. Terminals (notably Windows - // Terminal / ConPTY under WSL, and any with the kitty - // keyboard protocol) also emit Release/Repeat events; the - // general `handle_key` already drops non-Press, but the - // operational-overlay arms below dispatch straight to their - // managers and would otherwise process the SAME keystroke - // twice. That double-fire is what made Enter in the serve - // wizard's model picker re-open the picker (seeding it with - // the just-chosen model as a filter) instead of choosing. - // Swallow non-Press key events here, above every key arm, so - // the Press-only invariant holds for overlays too. - Some(Ok(CtEvent::Key(k))) if !is_actionable_key(k.kind) => { - let _ = k; - } - // A typed Ctrl-C. Raw mode means this reaches the app as a - // key event and never as SIGINT / `CTRL_C_EVENT` (see - // `is_ctrl_c`), so the signal watcher cannot see it: without - // this arm the first gesture a user reaches for does nothing - // at all and leaves them in a raw-mode terminal. Handled - // above every overlay so no screen can trap it, and routed - // through the same restore-and-exit path a real SIGINT takes. - // - // The one exception is a displayed job console, where Ctrl+C - // already means "cancel this running job" - // (`ui::job_console::on_console_key`, dispatched by the - // overlay arms below). Exiting the process there would be a - // regression, so that established meaning wins. - Some(Ok(CtEvent::Key(k))) if ctrl_c_should_exit(&state, k) => { - exit_on_ctrl_c(); - } - // The approval modal, when open, owns every remaining key - // (above every operational overlay and the general handler) - // so the operator's decision can't be pre-empted by a screen - // behind it. Only the Ctrl-C arm above outranks it. - // - // Spelling out the consequence, because it is the one that - // surprises: a typed Ctrl-C while an approval is pending - // QUITS the dashboard. It is not consumed by the modal and - // it is not a decline. That is deliberate — Ctrl-C is the - // gesture a user reaches for to get out of a program, and a - // modal that swallowed it would recreate the wedge this PR - // fixes (the pre-fix dashboard ignored Ctrl-C entirely and - // left the user in a raw-mode terminal), which is worse here - // than anywhere: the approval modal is exactly where someone - // wants out in a hurry. Nothing is lost by quitting — the - // pending action has NOT run (approval is what would run - // it), so declining and quitting leave identical state on - // disk; only the chat turn differs. The help surfaces say - // "quit" for Ctrl-C without exception for this modal, which - // is therefore accurate. - // - // To decline without quitting there are `n` (deny) and - // `Esc` / `q` (cancel) — `ui::approval::approval_key`, which - // ignores Ctrl-C, so without the arm above the gesture would - // be a silent no-op on this screen. - // On Approve: replay the approved action off the - // event loop (spawn_blocking) and post ChatApprovalResult. - // On Deny/Cancel: a declined turn, no execution. - Some(Ok(CtEvent::Key(k))) if state.approval_pending() => { - use crate::ui::approval::ApprovalVerdict; - match state.on_approval_key(k.code) { - Some(ApprovalVerdict::Approve) => { - if let Some((name, args)) = state.take_approval() { - match state.tool_executor.clone() { - Some(executor) => { - let reply_tx = chat_tx.clone(); - tokio::task::spawn_blocking(move || { - let text = run_approved(&executor, &name, &args); - let _ = reply_tx - .send(ClientMsg::ChatApprovalResult { text }); - }); - } - None => state.on_approval_result( - "ROCm tools unavailable in this mode".to_string(), - ), - } - } - } - Some(ApprovalVerdict::Deny | ApprovalVerdict::Cancel) => { - state.on_approval_declined(); - } - None => { /* cursor moved or key ignored — modal stays open */ } - } - } - // De-modal back-out: on any tab, when an inline manager is - // open at its root screen, Esc closes it and returns focus - // to the Actions list — intercepted BEFORE the per-manager - // key arms so the manager doesn't eat Esc first. `←` is left - // to the manager (some use it to cycle options). See - // `should_pane_back_out`'s doc comment for why this is no - // longer gated to ROCm/Serving. - Some(Ok(CtEvent::Key(k))) if state.should_pane_back_out(k.code) => { - state.close_overlays(); - state.pane_focus = PaneFocus::Actions; - } - // While a manager is showing its job console, the navigation - // keys pan the log (PgUp/PgDn = page, arrows = line). Routed - // BEFORE the per-manager arms (which would ignore them); the - // console action keys (Ctrl+C/q/Esc/Enter) are NOT scroll keys - // so they still fall through to `on_console_key`. - Some(Ok(CtEvent::Key(k))) - if state.has_active_console() && console_scroll_delta(k.code).is_some() => - { - let (dv, dh) = console_scroll_delta(k.code).unwrap_or((0, 0)); - state.scroll_console(dv, dh); - } - // Focused host only: while the hosted job is still RUNNING, - // swallow the console close keys (`q`, running-`Esc`) so the - // overlay is never nulled mid-job — which would trip the - // focused exit gate and tear the runtime down, killing the - // child via kill_on_drop. `Ctrl+C` (cancel) and the scroll - // keys above still flow, so the user can always stop a job; - // once it is terminal, `q`/`Esc` exit normally. Routed BEFORE - // the per-manager arms so the manager can't close first. - Some(Ok(CtEvent::Key(k))) - if focused_close_key_blocked(&state, args.focus, k.code) => {} - // The services-manager overlay, when open, owns all keys - // (and may spawn lifecycle jobs through the job-bridge). - Some(Ok(CtEvent::Key(k))) if state.services.is_some() => { - let fx = crate::ui::services_manager::on_key( - &mut state.services, - &mut state.jobs, - &state.instances, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The serve-wizard overlay, when open, owns all keys (and may - // spawn a launch job through the job-bridge). - Some(Ok(CtEvent::Key(k))) if state.serve_wizard.is_some() => { - let fx = crate::ui::serve_wizard::on_key( - &mut state.serve_wizard, - &mut state.jobs, - &state.model_recipes, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The engine-manager overlay, when open, owns all keys (and - // may stream an install job through the job-bridge). - Some(Ok(CtEvent::Key(k))) if state.engine_manager.is_some() => { - let fx = crate::ui::engine_manager::on_key( - &mut state.engine_manager, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The examine overlay, when open, owns all keys (read-only - // `rocm examine` job through the job-bridge). - Some(Ok(CtEvent::Key(k))) if state.examine_manager.is_some() => { - let fx = crate::ui::examine_manager::on_key( - &mut state.examine_manager, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The update overlay, when open, owns all keys (check/preview - // read-only; apply gated → job-bridge). - Some(Ok(CtEvent::Key(k))) if state.update_manager.is_some() => { - let fx = crate::ui::update_manager::on_key( - &mut state.update_manager, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The install overlay, when open, owns all keys (dry-run - // read-only; install gated → job-bridge). - Some(Ok(CtEvent::Key(k))) if state.install_manager.is_some() => { - let fx = crate::ui::install_manager::on_key( - &mut state.install_manager, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The logs overlay, when open, owns all keys (read-only - // `rocm logs` through the job-bridge). - Some(Ok(CtEvent::Key(k))) if state.logs_view.is_some() => { - let fx = crate::ui::logs_view::on_key( - &mut state.logs_view, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The runtime manager, when open, owns all keys (refresh - // read-only; activate/rollback/uninstall/adopt/import gated). - Some(Ok(CtEvent::Key(k))) if state.runtime_manager.is_some() => { - let fx = crate::ui::runtime_manager::on_key( - &mut state.runtime_manager, - &state.runtimes, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The onboarding wizard, when open, owns all keys (install / - // adopt gated → job-bridge). - Some(Ok(CtEvent::Key(k))) if state.onboarding.is_some() => { - let fx = crate::ui::onboarding::on_key( - &mut state.onboarding, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The automations manager, when open, owns all keys (refresh - // read-only; enable/disable gated → job-bridge). - Some(Ok(CtEvent::Key(k))) if state.automations_manager.is_some() => { - let fx = crate::ui::automations_manager::on_key( - &mut state.automations_manager, - &state.automations, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The command runner, when open, owns all keys (every - // command gated → job-bridge). - Some(Ok(CtEvent::Key(k))) if state.command_screen.is_some() => { - let fx = crate::ui::command_screen::on_key( - &mut state.command_screen, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // The config & provider manager, when open, owns all keys - // (show read-only; provider toggles gated → job-bridge). - Some(Ok(CtEvent::Key(k))) if state.config_manager.is_some() => { - let fx = crate::ui::config_manager::on_key( - &mut state.config_manager, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - // Bench-run form, when open, owns all keys. - Some(Ok(CtEvent::Key(k))) if state.bench_run.is_some() => { - let fx = crate::ui::bench_run::on_key( - &mut state.bench_run, - &mut state.jobs, - k, - ); - crate::jobs::run_effects(fx, &job_tx); - } - Some(Ok(CtEvent::Key(k))) => { - let chat_ctx = ChatKeyCtx { - focused: state.chat_focused, - consent: state.chat_consent, - offer_pending: state.chat_detect_offer.is_some(), - }; - let action = handle_key(k, state.active_tab, &state.modal, chat_ctx); - if apply_action(&mut state, action) { - break; - } - } - Some(Ok(CtEvent::Mouse(me))) => { - let action = resolve_mouse(me, &state); - if apply_action(&mut state, action) { - break; - } - } - Some(Ok(CtEvent::Resize(_, _))) => { /* repaint */ } - // A terminal event-source error means the controlling - // terminal went away (e.g. the PTY/stdin closed) — the - // session is over, so quit cleanly rather than propagating a - // fatal error. Propagating it made `rocm chat` exit non-zero - // when its terminal closed before the first key was read - // (e.g. the acceptance PTY smoke under the embedded-daemon - // start delay); the legacy blocking reader treated this as - // end-of-session too. Mirrors the `None => break` EOF arm. - Some(Err(e)) => { - tracing::debug!(error = %e, "terminal event stream ended; quitting"); - break; - } - None => break, - _ => {} - } - } - } - - // A `/quit` (or `/exit`) slash command sets `should_quit` from inside - // the reducer; honor it here (mirrors the `KeyAction::Quit` break). - if state.should_quit { - break; - } - - // Focused host: the launcher hosts exactly one overlay. Once the user - // backs out of it at root (the per-manager `on_key` set its state to - // `None`), return so `app::run` hands control back to the launcher menu. - // `focused_should_exit` stays `false` while any sub-popup / job console - // keeps the overlay `Some`, so this never ejects mid-flow. No-op for the - // dashboard (`focus == None`). - if state.focused_should_exit(args.focus) { - break; - } - - // Drain a pending executor-backed read-only slash command (`/model`, - // `/daemon`). Off-thread (spawn_blocking) so the seam's synchronous - // execute() never blocks the async event loop; the concise summary - // returns via ClientMsg::SlashToolReply — its own message variant, so - // the slash-tool path never disturbs the agent's `chat_sending` flag. - if let Some(req) = state.slash_tool.take() { - match state.tool_executor.clone() { - Some(executor) => { - let reply_tx = chat_tx.clone(); - tokio::task::spawn_blocking(move || { - // One path for read-only AND mutating slash commands: - // `Result`/`Error` → a concise reply turn; an - // `ApprovalRequired` (mutating) → open the approval modal - // via ChatApprovalRequired (nothing executes yet). - let msg = match executor.execute(&req.name, &req.args) { - crate::tool_exec::RocmToolOutcome::ApprovalRequired(intent) => { - ClientMsg::ChatApprovalRequired { intent } - } - outcome => ClientMsg::SlashToolReply { - text: summarize_slash_tool(&req.label, &outcome), - }, - }; - let _ = reply_tx.send(msg); - }); - } - None => { - state.on_slash_tool_reply("ROCm tools unavailable in this mode".to_string()); - } - } - } - - // Drain a pending `/plan` natural-language plan. Off-thread - // (spawn_blocking) so the read-only `natural_language_plan` tool's - // synchronous execute() never blocks the async loop. The rendered plan + - // structured next action return via ClientMsg::PlanReady; the tool only - // PLANS — no mutation happens here. A complete mutating action is handed - // to the approval modal by `on_plan_ready`. - if let Some(req) = state.plan_request.take() { - match state.tool_executor.clone() { - Some(executor) => { - let reply_tx = chat_tx.clone(); - tokio::task::spawn_blocking(move || { - let args = serde_json::json!({ "request": req }); - let msg = match executor.execute("natural_language_plan", &args) { - crate::tool_exec::RocmToolOutcome::Result(v) => { - match parse_plan_result(&v) { - Some((text, action)) => ClientMsg::PlanReady { text, action }, - None => ClientMsg::SlashToolReply { - text: "/plan: planner returned no usable plan".to_string(), - }, - } - } - crate::tool_exec::RocmToolOutcome::Error(e) => { - ClientMsg::SlashToolReply { - text: format!("/plan failed: {e}"), - } - } - crate::tool_exec::RocmToolOutcome::ApprovalRequired(_) => { - ClientMsg::SlashToolReply { - text: - "/plan: planning is read-only and should not need approval" - .to_string(), - } - } - }; - let _ = reply_tx.send(msg); - }); - } - None => { - state.on_slash_tool_reply("ROCm tools unavailable in this mode".to_string()); - } - } - } - - // Drain a `/provider` switch (Phase 8). Rebuild the live `agent` for the - // newly-selected backend. `Local` reuses whatever the inline launch path - // built (it owns the auto-detect probe). `Openai`/`Anthropic` are built - // from `ResolvedArgs` keys (in-process seam, never argv). A build failure - // (e.g. missing key) leaves `agent` unchanged and surfaces an actionable - // error turn. Construction only — no network until the next submit. - if let Some(ProviderSwitch { previous, target }) = state.provider_switch.take() { - match target { - ChatProvider::Local => { - // Restore the auto-detected local backend saved before the - // event loop. `build_chat_agent(Local)` returns None by - // design, so the restore must happen here — otherwise a prior - // `/provider openai` would leave requests routed to OpenAI. - agent = local_agent.clone(); - state - .chat - .push(ChatTurn::system("switched to local".to_string())); - } - ChatProvider::Openai | ChatProvider::Anthropic => { - if let Some(new_agent) = - build_chat_agent(target, args, state.tool_executor.clone(), chat_tx.clone()) - { - agent = Some(new_agent); - state - .chat - .push(ChatTurn::system(format!("switched to {}", target.label()))); - } else { - // Revert the optimistic `active_provider` set by the slash - // handler back to the provider active BEFORE the switch - // attempt — not unconditionally Local — so the displayed - // provider stays honest (e.g. a failed openai→anthropic - // switch stays on openai). `agent` is never reassigned on a - // failed build, so it already matches `previous`; the two - // stay consistent (no stale-remote routing under a wrong - // label). - state.active_provider = previous; - let hint = if target == ChatProvider::Anthropic { - "anthropic requires ANTHROPIC_API_KEY in env or secure store" - } else { - "openai requires OPENAI_API_KEY in the environment" - }; - state.chat.push(ChatTurn::error(format!( - "could not switch to {}: {hint}", - target.label() - ))); - } - } - } - } - - // Drain the endpoint-rebuild edge (Phase 8 sibling). An accepted - // detected-local offer must re-point the LIVE `agent` — and the - // `/provider local` restore snapshot — at the new local backend. - // `accept_detect_offer` swaps `chat_llm` to the auth-free local config - // but stays I/O-free, so without this the stale startup agent keeps - // routing chat to the cloud gateway (wrong-backend 401 bug). The edge - // carries the provider active BEFORE the optimistic switch to `Local`; - // on failure we revert `active_provider` to it (mirrors the - // `provider_switch` drain) so the tab never shows `Local` while `agent` - // still points elsewhere. Construction only — no network until submit. - if let Some(previous) = state.chat_endpoint_rebuild.take() { - // `revert` restores the optimistic switch and surfaces an actionable - // error turn so the tab does not sit on `Local` with the old agent. - let revert = |state: &mut AppState, msg: String| { - state.active_provider = previous; - state.chat.push(ChatTurn::error(msg)); - }; - match state.chat_llm.clone() { - Some(cfg) => { - match build_local_agent( - cfg, - args.inference_params(), - state.tool_executor.clone(), - chat_tx.clone(), - args.chat_system_prompt.clone(), - ) { - Ok(arc) => { - agent = Some(arc.clone()); - // Refresh the restore snapshot so a later `/provider - // local` restores THIS accepted backend, not the - // stale startup one. - local_agent = Some(arc); - state - .chat - .push(ChatTurn::system("switched to local".to_string())); - } - Err(e) => revert( - &mut state, - format!("could not switch to the detected local endpoint: {e}"), - ), - } - } - // Edge raised but `chat_llm` is None (shouldn't happen after a - // real accept, but don't leave the tab stuck on `Local` with the - // old agent and no feedback). - None => revert( - &mut state, - "could not switch to the detected local endpoint: no endpoint configured" - .to_string(), - ), - } - } - - // Spawn the agent round-trip on the submit edge — keeps `apply_action` - // I/O-free. `chat_dispatch` is raised once by `submit_chat`; consume it - // so the in-flight request is spawned exactly once (not every tick). - if state.chat_dispatch { - state.chat_dispatch = false; - match agent.clone() { - Some(agent) => { - let history = state.chat.clone(); - let snapshot = state.state_snapshot(); - let reply_tx = chat_tx.clone(); - tokio::spawn(async move { - let msg = match agent.complete(&history, snapshot).await { - Ok(text) => ClientMsg::ChatReply { text }, - Err(e) => ClientMsg::ChatError { - message: e.to_string(), - }, - }; - let _ = reply_tx.send(msg); - }); - } - None => state.on_chat_error(NO_CHAT_BACKEND_MSG.to_string()), - } - } - - // Run the local-engine probe + `/v1/models` query on the detect edge, - // off the reducer. Raised once by `request_detect`; result returns via - // `ClientMsg::ChatDetectResult`. - if state.chat_detect_dispatch { - state.chat_detect_dispatch = false; - let reply_tx = chat_tx.clone(); - let executor = state.tool_executor.clone(); - tokio::spawn(async move { - let offer = detect_local_chat(executor).await; - let _ = reply_tx.send(ClientMsg::ChatDetectResult { offer }); - }); - } - - // Persist the accepted endpoint on the save edge (a small synchronous - // file write; the message surfaces success/failure on the gate is not - // shown once Accepted, so we keep it terse via tracing + chat_detect_msg). - if state.chat_persist_dispatch { - state.chat_persist_dispatch = false; - if let Some(cfg) = state.chat_llm.clone() { - match persist_chat_endpoint(&cfg.base_url, &cfg.model) { - Ok(path) => { - tracing::info!(?path, "saved chat endpoint to config"); - } - Err(e) => { - tracing::warn!(error = %e, "failed to save chat endpoint"); - state.chat_detect_msg = Some(format!("could not save config: {e}")); - } - } - } - } - } - Ok(()) -} - -/// Run an approved mutating action across the seam and render a concise summary -/// (never a raw JSON dump). Sync + executor-generic so the approve path is -/// unit-testable without tokio; the event loop calls it inside spawn_blocking. -fn run_approved( - executor: &crate::tool_exec::SharedRocmToolExecutor, - name: &str, - args: &serde_json::Value, -) -> String { - use crate::tool_exec::RocmToolOutcome; - match executor.execute_approved(name, args) { - RocmToolOutcome::Result(v) => { - let body = summarize_json_value(&v); - if body.is_empty() { - format!("Approved · {name}: done") - } else { - format!("Approved · {name}:\n{body}") - } - } - RocmToolOutcome::Error(e) => format!("Approved · {name} failed: {e}"), - // A mutating tool's approved replay should not re-request approval; if it - // somehow does, surface it plainly rather than silently looping. - RocmToolOutcome::ApprovalRequired(_) => { - format!("Approved · {name}: unexpected second approval request (not run)") - } - } -} - -/// Wrap a list cursor by `delta`, cycling within `0..len`. `len == 0` → 0. -const fn wrap_cursor(cur: usize, delta: isize, len: usize) -> usize { - if len == 0 { - return 0; - } - let n = len.cast_signed(); - (cur.cast_signed() + delta).rem_euclid(n) as usize -} - -/// Apply a `KeyAction` to mutable state. Returns `true` when the action -/// requests application exit (Quit). -fn apply_action(state: &mut AppState, action: KeyAction) -> bool { - match action { - KeyAction::Quit => return true, - KeyAction::SwitchTab(t) => { - state.active_tab = t; - state.modal = Modal::None; - // A fresh tab always starts with focus on its Actions list, never - // stranded in the Details pane from a previous visit. - state.pane_focus = PaneFocus::Actions; - } - KeyAction::Move(d) => { - if state.modal == Modal::ThemePicker { - state.theme_picker_move(d); - } else { - // Changing the verb selection snaps focus back to the Actions - // list so Details re-previews the newly selected operation. - if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { - state.pane_focus = PaneFocus::Actions; - } - state.move_selection(d); - } - } - KeyAction::PaneFocusDetail => { - if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { - state.pane_focus = PaneFocus::Detail; - } - } - KeyAction::PaneFocusActions => { - if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { - state.pane_focus = PaneFocus::Actions; - } - } - KeyAction::PaneActivate => { - if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { - match state.pane_focus { - // From the Actions list, Enter steps INTO the Details pane. - PaneFocus::Actions => state.pane_focus = PaneFocus::Detail, - // From Details, Enter opens the operation's manager. - PaneFocus::Detail => { - let verb = state.pane_verb_action(); - return apply_action(state, verb); - } - } - } - } - KeyAction::PaneEscape => { - // Esc backs out one level: Details → Actions, then Actions → menu. - if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) - && state.pane_focus == PaneFocus::Detail - { - state.pane_focus = PaneFocus::Actions; - } else { - return apply_action(state, KeyAction::OpenMenu); - } - } - KeyAction::PaneSelect(i) => { - if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { - let last = state.pane_verb_count().saturating_sub(1); - state.set_selection(state.active_tab, i.min(last)); - state.pane_focus = PaneFocus::Actions; - } - } - KeyAction::SelectFirst => { - if state.modal == Modal::ThemePicker { - state.theme_picker_first(); - } else { - state.select_first(); - } - } - KeyAction::SelectLast => { - if state.modal == Modal::ThemePicker { - state.theme_picker_last(); - } else { - state.select_last(); - } - } - KeyAction::OpenDetail => { - if matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) { - // Verb rows open the matching manager via the existing seam; - // there is no detail modal on the ROCm/Serving tabs. - let verb = state.pane_verb_action(); - return apply_action(state, verb); - } - if state.selection_len() > 0 { - state.modal = Modal::Detail; - state.reset_instance_detail_scroll(); - } - } - KeyAction::ToggleHelp => { - state.modal = if state.modal == Modal::Help { - Modal::None - } else { - state.close_overlays(); - Modal::Help - }; - } - KeyAction::CloseModal => state.modal = Modal::None, - // The operational overlays are mutually exclusive: opening any one first - // closes the rest (see `close_overlays`), so no open path — key, mouse, - // or effect — can ever leave two `Some` at once. - KeyAction::OpenServices => { - state.close_overlays(); - state.services = Some(crate::ui::services_manager::ServicesManagerState::default()); - } - KeyAction::OpenServeWizard => { - state.close_overlays(); - state.serve_wizard = Some(crate::ui::serve_wizard::ServeWizardState::default()); - } - KeyAction::OpenEngineManager => { - state.close_overlays(); - state.engine_manager = Some(crate::ui::engine_manager::EngineManagerState::default()); - } - KeyAction::OpenExamine => { - state.close_overlays(); - state.examine_manager = - Some(crate::ui::examine_manager::ExamineManagerState::default()); - } - KeyAction::OpenUpdate => { - state.close_overlays(); - state.update_manager = Some(crate::ui::update_manager::UpdateManagerState::default()); - } - KeyAction::OpenInstall => { - state.close_overlays(); - state.install_manager = - Some(crate::ui::install_manager::InstallManagerState::default()); - } - KeyAction::OpenLogs => { - state.close_overlays(); - state.logs_view = Some(crate::ui::logs_view::LogsViewState::default()); - } - KeyAction::OpenRuntimes => { - state.close_overlays(); - state.runtime_manager = - Some(crate::ui::runtime_manager::RuntimeManagerState::default()); - } - KeyAction::OpenOnboarding => { - state.close_overlays(); - state.onboarding = Some(crate::ui::onboarding::OnboardingState::default()); - } - KeyAction::OpenAutomations => { - state.close_overlays(); - state.automations_manager = - Some(crate::ui::automations_manager::AutomationsManagerState::default()); - } - KeyAction::OpenCommand => { - state.close_overlays(); - state.command_screen = Some(crate::ui::command_screen::CommandScreenState::default()); - } - KeyAction::OpenConfig => { - state.close_overlays(); - state.config_manager = Some(crate::ui::config_manager::ConfigManagerState::default()); - } - KeyAction::OpenBenchRun => { - let bench_csv = state.bench_results_dir.clone(); - state.close_overlays(); - state.bench_run = Some(crate::ui::bench_run::BenchRunState::new( - bench_csv.as_deref(), - )); - } - KeyAction::OpenThemePicker => state.open_theme_picker(), - KeyAction::ApplyThemePick => state.apply_theme_pick(), - KeyAction::OpenMenu => { - state.modal = Modal::Menu; - state.menu_sel = 0; - } - KeyAction::OpenPalette => { - state.modal = Modal::Palette; - state.palette_sel = 0; - } - KeyAction::MenuMove(d) => match state.modal { - Modal::Menu => { - state.menu_sel = wrap_cursor(state.menu_sel, d, crate::ui::modal::MENU_ITEMS); - } - Modal::Palette => { - state.palette_sel = - wrap_cursor(state.palette_sel, d, crate::ui::modal::PALETTE_DESTS.len()); - } - _ => {} - }, - KeyAction::OptionsTab(d) => { - if state.modal == Modal::Options { - state.options_tab = - wrap_cursor(state.options_tab, d, crate::ui::modal::OPTIONS_TABS.len()); - } - } - KeyAction::MenuActivate => match state.modal { - Modal::Menu => match state.menu_sel { - 0 => { - state.modal = Modal::Options; - state.options_tab = 0; - } - 1 => { - state.modal = Modal::GlobalHelp; - } - _ => return true, // Quit - }, - Modal::Palette => { - if let Some((_, tab)) = crate::ui::modal::PALETTE_DESTS.get(state.palette_sel) { - state.active_tab = *tab; - } - state.modal = Modal::None; - } - _ => {} - }, - KeyAction::ScrollModal(delta) if state.modal == Modal::Detail => { - state.scroll_instance_detail(delta); - } - KeyAction::ScrollModal(_) => {} - KeyAction::ScrollConsole(dv, dh) => state.scroll_console(dv, dh), - KeyAction::ScrollDock(dv) => state.scroll_dock(dv), - KeyAction::ScrollGrab(target, pos, grab_offset) => { - state.apply_scroll_grab(target, pos, grab_offset); - } - KeyAction::ScrollRelease => state.scroll_drag = None, - KeyAction::ReplayTogglePause => { - if let Some(r) = state.replay.as_mut() { - r.paused = !r.paused; - if r.paused { - r.controller.pause(); - } else { - r.controller.resume(); - } - } - } - KeyAction::ReplaySpeedUp => { - if let Some(r) = state.replay.as_mut() { - r.speed = crate::replay::next_speed(r.speed); - r.controller.set_speed(r.speed); - } - } - KeyAction::ReplaySpeedDown => { - if let Some(r) = state.replay.as_mut() { - r.speed = crate::replay::prev_speed(r.speed); - r.controller.set_speed(r.speed); - } - } - KeyAction::ReplayJump(delta_s) => { - if let Some(r) = state.replay.as_ref() { - r.controller.jump(delta_s); - } - } - KeyAction::ChatInput(c) => state.chat_input.push(c), - KeyAction::ChatBackspace => { - state.chat_input.pop(); - } - KeyAction::ChatSubmit => state.submit_chat(), - KeyAction::ChatFocus => state.chat_focused = true, - KeyAction::ChatBlur => state.chat_focused = false, - KeyAction::ChatConsentAccept => state.accept_chat_consent(), - KeyAction::ChatConsentDecline => state.decline_chat_consent(), - KeyAction::ChatDetect => state.request_detect(), - KeyAction::ChatDetectAccept => state.accept_detect_offer(), - KeyAction::ChatDetectSave => state.save_detect_offer(), - KeyAction::ChatDetectDismiss => state.dismiss_detect_offer(), - KeyAction::ChatScroll(d) => { - let next = (i32::from(state.chat_scroll) + i32::from(d)).max(0) as usize; - state.set_chat_scroll(next); - } - KeyAction::Nothing => {} - } - false -} - -/// Convert a displayed position to the target's own offset units. Dock logs are -/// tail-anchored, so their displayed top-to-bottom position is inverted. -fn target_offset(h: &ScrollbarHandle, displayed: usize) -> usize { - if h.target == ScrollTarget::DockLogs { - h.max_position().saturating_sub(displayed) - } else { - displayed - } -} - -fn target_position(state: &AppState, h: &ScrollbarHandle) -> usize { - let position = match h.target { - ScrollTarget::Console => usize::from(state.console_scroll), - ScrollTarget::ConsoleH => usize::from(state.console_hscroll), - ScrollTarget::Chat => usize::from(state.chat_scroll), - ScrollTarget::DockLogs => h - .max_position() - .saturating_sub(usize::from(state.dock_logs_scroll)), - ScrollTarget::InstanceDetail => usize::from(state.instance_detail_scroll), - }; - position.min(h.max_position()) -} - -/// If `(col, row)` lands on a recorded scrollbar, preserve a thumb grab or use -/// a proportional full-track jump for a track click. -fn scrollbar_hit(state: &AppState, col: u16, row: u16) -> Option { - let bars = state.scrollbars.borrow(); - let h = bars.iter().find(|h| point_in(h.track, col, row))?; - let displayed = target_position(state, h); - let grab_offset = h.grab_offset(col, row, displayed); - let next = grab_offset.map_or_else(|| h.track_position_at(col, row), |_| displayed); - Some(KeyAction::ScrollGrab( - h.target, - target_offset(h, next), - grab_offset.unwrap_or(0), - )) -} - -fn resolve_mouse(me: MouseEvent, state: &AppState) -> KeyAction { - // A held drag on a scrollbar keeps updating that offset until release, even - // when the pointer slides off the narrow track. - if me.kind == MouseEventKind::Drag(MouseButton::Left) { - // A drag can start before an approval becomes pending (it's only - // gated at the click that starts it, via `scrollbar_hit`'s own - // `approval_pending()` check below) and then have an approval land - // asynchronously mid-drag. Swallow it here too, or the drag would - // keep mutating a scroll position hidden behind the approval modal — - // "a pending approval owns the body with no exception" (see the - // wheel-scroll swallow further down) applies to an in-flight drag - // just as much as to input that starts fresh. - if state.approval_pending() { - return KeyAction::Nothing; - } - if let Some(drag) = state.scroll_drag - && let Some(h) = state - .scrollbars - .borrow() - .iter() - .find(|h| h.target == drag.target) - { - let current = target_position(state, h); - let displayed = h.position_at(me.column, me.row, drag.grab_offset, current); - return KeyAction::ScrollGrab( - drag.target, - target_offset(h, displayed), - drag.grab_offset, - ); - } - return KeyAction::Nothing; - } - // Any button release ends an active scrollbar drag. - if matches!(me.kind, MouseEventKind::Up(_)) { - return if state.scroll_drag.is_some() { - KeyAction::ScrollRelease - } else { - KeyAction::Nothing - }; - } - - if me.kind == MouseEventKind::Down(MouseButton::Left) { - // Scrollbar tracks win over a plain open overlay (incl. its console - // bar), so a click on the bar grabs it instead of falling through — - // but NOT over a pending approval: nothing registers a scrollbar for - // the approval modal itself, so any handle on screen while one is - // pending belongs to content underneath it, which the swallow below - // must still catch rather than let a scrollbar drag bypass it. - if !state.approval_pending() - && let Some(a) = scrollbar_hit(state, me.column, me.row) - { - return a; - } - if let Some(area) = state.last_tab_bar_area - && let Some(tab) = tab_bar_hit(area, me.column, me.row) - { - return KeyAction::SwitchTab(tab); - } - // Footer legend: a click on a key chip acts exactly like the key press. - if let Some(chip) = footer_chip_hit(&state.last_footer_chips, me.column, me.row) { - return chip; - } - // While an operational manager is open — or a chat tool-call approval - // is pending — it owns the body: swallow body clicks so they can't - // fall THROUGH to the obscured Actions/Details list (which would - // silently change the selection, re-open a verb, or switch tabs - // underneath the approval modal). Tab-bar and footer-chip clicks - // above still work, matching the manager-overlay swallow this - // mirrors (see the analogous `overlay_or_approval()` check in - // ui/mod.rs's footer-chip gating). - if state.overlay_or_approval() { - return KeyAction::Nothing; - } - if state.modal == Modal::None - && let Some(area) = state.last_body_area - { - // ponytail: Observe folds the instances table into a stacked region; - // body-click hit-testing best-efforts the instances rows. Keyboard - // selection is the primary path. - let action = match state.active_tab { - // Observe's AI table is keyboard + scroll-wheel driven (the - // scroll path maps to Move in `handle_mouse`); left-click select - // is intentionally not wired (the table sits below the hero band, - // so a body-relative row map would be wrong). No-op here. - ActiveTab::Rocm => ui::tabs::rocm::hit_test(area, me.column, me.row), - ActiveTab::Serving => ui::tabs::serving::hit_test(area, me.column, me.row), - _ => None, - }; - if let Some(a) = action { - return a; - } - } - return KeyAction::Nothing; - } - - // Scroll wheel (incl. horizontal wheel where the device emits it). Per-notch - // deltas: ±1 line / ±1 col here, scaled per target below. - let (dv, dh): (i16, i16) = match me.kind { - MouseEventKind::ScrollDown => (1, 0), - MouseEventKind::ScrollUp => (-1, 0), - MouseEventKind::ScrollRight => (0, 1), - MouseEventKind::ScrollLeft => (0, -1), - // Not a scroll (e.g. moves / other buttons): nothing to route. - _ => return KeyAction::Nothing, - }; - - // A pending approval owns the body with no exception (mirrors the click - // swallow a few lines above) — unlike a plain manager overlay, it never - // has its own console to pan, so there is nothing to fall through to. - if state.approval_pending() { - return KeyAction::Nothing; - } - // An open manager owns the body. When it is showing its job console, the - // wheel pans that log (bigger vertical step, wider horizontal step so long - // command lines come into view). On a form screen there is nothing to pan — - // swallow it so the wheel can't move the obscured Actions list underneath. - if state.has_open_overlay() { - return if state.has_active_console() { - KeyAction::ScrollConsole(dv * 3, dh * 6) - } else { - KeyAction::Nothing - }; - } - - // Wide-layout right LOGS dock: the wheel pans the log stream when the pointer - // is over it (vertical only — it's a tail-anchored log). - if state.modal == Modal::None - && dv != 0 - && let Some(dock) = state.last_dock_area - && point_in(dock, me.column, me.row) - { - return KeyAction::ScrollDock(dv * 3); - } - - // No overlay: on a domain tab the wheel moves the Actions selection by ONE - // row — but only while the pointer is actually over the Actions column, so - // hovering the Details pane doesn't nudge the list. Anything else falls - // through to the modal/tab scroll routing. - if state.modal == Modal::None - && matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) - { - if dv != 0 - && let Some(body) = state.last_body_area - && point_in(crate::ui::tabs::pane::actions_rect(body), me.column, me.row) - { - return KeyAction::Move(dv as isize); - } - return KeyAction::Nothing; - } - - handle_mouse(me, &state.modal, state.active_tab) -} - -/// Whether `(x, y)` lies inside `r` (end-exclusive on both axes). -const fn point_in(r: ratatui::layout::Rect, x: u16, y: u16) -> bool { - x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height -} - -/// Resolve a pointer `(col, row)` against the recorded footer-legend chips. -/// Returns the chip's action when the pointer lands inside a chip span. -fn footer_chip_hit(chips: &[FooterChip], col: u16, row: u16) -> Option { - chips - .iter() - .find(|c| row == c.y && col >= c.x0 && col < c.x1) - .map(|c| c.action) -} - -/// Where a domain tab's (ROCm/Serving) keyboard focus currently sits. Shared by -/// both tabs; each keeps its own selection cursor (`rocm_sel`/`serving_sel`). -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PaneFocus { - /// Browsing the Actions list (left column). - #[default] - Actions, - /// Inside the Details pane (right column), ready to start the operation. - Detail, -} - -/// A clickable footer-legend chip: an absolute screen span on the footer row -/// plus the action a left-click should dispatch. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct FooterChip { - pub x0: u16, - /// End-exclusive. - pub x1: u16, - pub y: u16, - pub action: KeyAction, -} - -/// Active pointer drag for a scrollbar thumb. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScrollDrag { - pub target: ScrollTarget, - pub grab_offset: u16, -} - -/// Which scrollable surface a drawn scrollbar controls. Lets a mouse click on a -/// scrollbar track write the right offset field. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ScrollTarget { - /// Job console vertical (`console_scroll`). - Console, - /// Job console horizontal (`console_hscroll`). - ConsoleH, - /// Wide-layout LOGS dock (`dock_logs_scroll`, tail-anchored / inverted). - DockLogs, - /// Chat transcript (`chat_scroll`). - Chat, - /// Instance detail modal's launch_args/env_vars panes (`instance_detail_scroll`). - InstanceDetail, -} - -/// A scrollbar drawn this frame, recorded so a mouse click/drag can hit-test it. -/// -/// `track` is the screen rect of the bar; `content_len`/`viewport_len` size the -/// thumb; `target` says which offset to move. Vertical bars map the mouse row, -/// horizontal bars the column. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ScrollbarHandle { - pub track: ratatui::layout::Rect, - pub horizontal: bool, - pub content_len: usize, - pub viewport_len: usize, - pub target: ScrollTarget, -} - -impl ScrollbarHandle { - /// Build a handle from the rect passed to a scrollbar helper (`area`) and its - /// returned content rect (`drawn`). Returns `None` when they're equal — i.e. - /// no bar was drawn because the content fit — so nothing gets hit-tested. - pub(crate) fn new( - area: ratatui::layout::Rect, - drawn: ratatui::layout::Rect, - horizontal: bool, - content_len: usize, - viewport_len: usize, - target: ScrollTarget, - ) -> Option { - if drawn == area { - return None; - } - let track = if horizontal { - ratatui::layout::Rect::new(area.x, area.y + area.height - 1, area.width, 1) - } else { - ratatui::layout::Rect::new(area.x + area.width - 1, area.y, 1, area.height) - }; - Some(Self { - track, - horizontal, - content_len, - viewport_len, - target, - }) - } - - const fn max_position(&self) -> usize { - self.content_len.saturating_sub(self.viewport_len) - } - - const fn axis(&self, col: u16, row: u16) -> (u16, u16, u16) { - if self.horizontal { - (col, self.track.x, self.track.width) - } else { - (row, self.track.y, self.track.height) - } - } - - /// Ratatui 0.30.2 `Scrollbar::part_lengths` geometry for the logical - /// first-visible-unit position used by the dashboard. - fn thumb_geometry(&self, logical_position: usize) -> (u16, u16) { - let (_, _, span) = self.axis(0, 0); - if span == 0 || self.content_len == 0 { - return (0, 0); - } - let track_len = usize::from(span); - let rendered_max = self.content_len.saturating_sub(1); - let rendered_position = - logical_position.min(self.max_position()) * rendered_max / self.max_position().max(1); - let denominator = rendered_max.saturating_add(self.viewport_len); - let rounded_divide = - |numerator: usize| numerator.saturating_add(denominator / 2) / denominator.max(1); - let thumb_len = - rounded_divide(self.viewport_len.saturating_mul(track_len)).clamp(1, track_len); - let thumb_start = rounded_divide(rendered_position.saturating_mul(track_len)) - .clamp(0, track_len.saturating_sub(thumb_len)); - (thumb_start as u16, thumb_len as u16) - } - - fn grab_offset(&self, col: u16, row: u16, position: usize) -> Option { - let (coord, track_start, _) = self.axis(col, row); - let relative = coord.saturating_sub(track_start); - let (thumb_start, thumb_len) = self.thumb_geometry(position); - (relative >= thumb_start && relative < thumb_start.saturating_add(thumb_len)) - .then(|| relative - thumb_start) - } - - /// Proportional track-click mapping. The endpoints map exactly to the - /// logical endpoints and do not depend on thumb geometry. - fn track_position_at(&self, col: u16, row: u16) -> usize { - let max_position = self.max_position(); - let (coord, start, span) = self.axis(col, row); - if max_position == 0 || span <= 1 { - return 0; - } - usize::from(coord.saturating_sub(start).min(span - 1)) * max_position - / usize::from(span - 1) - } - - /// Invert Ratatui's rounded thumb-start mapping. When several logical - /// positions render at the requested start, retain `current_position` if it - /// lies on that plateau; otherwise choose the nearest plateau endpoint. - fn position_at(&self, col: u16, row: u16, grab_offset: u16, current_position: usize) -> usize { - let max_position = self.max_position(); - if max_position == 0 { - return 0; - } - let (coord, start, span) = self.axis(col, row); - let (_, thumb_len) = self.thumb_geometry(0); - let desired_start = coord - .saturating_sub(start) - .saturating_sub(grab_offset) - .min(span.saturating_sub(thumb_len)); - if desired_start == 0 { - return 0; - } - if desired_start == span.saturating_sub(thumb_len) { - return max_position; - } - - let first_at_or_after = |wanted: u16| { - let mut low = 0usize; - let mut high = max_position; - while low < high { - let mid = low + (high - low) / 2; - if self.thumb_geometry(mid).0 < wanted { - low = mid + 1; - } else { - high = mid; - } - } - low - }; - let first = first_at_or_after(desired_start); - if self.thumb_geometry(first).0 != desired_start { - if first == 0 { - return 0; - } - let before = first - 1; - let before_start = self.thumb_geometry(before).0; - let after_start = self.thumb_geometry(first).0; - return if desired_start - before_start <= after_start - desired_start { - before - } else { - first - }; - } - let after = first_at_or_after(desired_start.saturating_add(1)); - let last = if after == max_position && self.thumb_geometry(after).0 == desired_start { - after - } else { - after.saturating_sub(1) - }; - current_position.clamp(first, last) - } -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum KeyAction { - Nothing, - Quit, - SwitchTab(ActiveTab), - /// ROCm/Serving tab: move focus into the Details pane (`→`). - PaneFocusDetail, - /// ROCm/Serving tab: move focus back to the Actions list (`←`). - PaneFocusActions, - /// ROCm/Serving tab: activate the current focus — from the Actions list, - /// focus the Details pane; from Details, open the operation's manager. - PaneActivate, - /// ROCm/Serving tab: Esc — step out of Details back to the Actions list, or, - /// when already on the list, fall through to the main menu. - PaneEscape, - /// ROCm/Serving tab: select the verb at this index and park focus on the - /// Actions list (from a mouse click on a verb row). - PaneSelect(usize), - Move(isize), - SelectFirst, - SelectLast, - OpenDetail, - ToggleHelp, - CloseModal, - OpenThemePicker, - ApplyThemePick, - /// Vertical scroll inside the active modal body (positive = down). - ScrollModal(i16), - /// Pan the active job console: `(vertical_lines, horizontal_cols)`, negative - /// = up/left. No-op when no console is showing. - ScrollConsole(i16, i16), - /// Scroll the wide-layout right LOGS dock by N lines (negative = toward the - /// newest line). No-op when the dock isn't showing. - ScrollDock(i16), - /// Grab a scrollbar at `position`, retaining the pointer's offset inside the - /// thumb so subsequent drag events track without a jump. - ScrollGrab(ScrollTarget, usize, u16), - /// Release the active scrollbar drag (mouse button up). - ScrollRelease, - /// Toggle replay pause / resume. No-op when not replaying. - ReplayTogglePause, - /// Step replay speed up or down (clamped). No-op when not replaying. - ReplaySpeedUp, - ReplaySpeedDown, - /// Move the replay playhead by `delta_s` seconds (negative = rewind). - ReplayJump(i64), - /// Chat insert-mode: append a character to `chat_input`. - ChatInput(char), - /// Chat insert-mode: pop the last character from `chat_input`. - ChatBackspace, - /// Chat: submit the current input buffer as a user turn. - ChatSubmit, - /// Chat: enter text-entry focus. - ChatFocus, - /// Chat: leave text-entry focus. - ChatBlur, - /// Chat: accept the detected endpoint (one-time consent). - ChatConsentAccept, - /// Chat: decline the detected endpoint. - ChatConsentDecline, - /// Chat: probe for a local engine and offer it (in-TUI auto-detect). - ChatDetect, - /// Chat: accept the detected local endpoint for this session. - ChatDetectAccept, - /// Chat: accept the detected endpoint and persist it to config. - ChatDetectSave, - /// Chat: dismiss the detected-endpoint offer, keeping the prior config. - ChatDetectDismiss, - /// Chat: scroll the transcript by N lines (positive = down). - ChatScroll(i16), - /// Open the btop-style Esc main menu (P4). - OpenMenu, - /// Open the "Go to…" command palette (P4). - OpenPalette, - /// Move the cursor within the active overlay (Menu / Palette) by N rows. - MenuMove(isize), - /// Cycle the Options panel's tab by N (left/right). - OptionsTab(isize), - /// Activate the highlighted row in the active overlay (Menu / Palette). - MenuActivate, - /// Open the services-manager overlay (Phase 3 Wave 1). - OpenServices, - /// Open the serve-wizard overlay (Phase 3 Wave 1). - OpenServeWizard, - /// Open the engine-manager overlay (Phase 3 Wave 1). - OpenEngineManager, - /// Open the examine overlay (Phase 3 Wave 2). - OpenExamine, - /// Open the update overlay (Phase 3 Wave 2). - OpenUpdate, - /// Open the install overlay (Phase 3 Wave 2). - OpenInstall, - /// Open the runtime manager overlay. - OpenRuntimes, - /// Open the onboarding wizard overlay. - OpenOnboarding, - /// Open the automations manager overlay. - OpenAutomations, - /// Open the command runner overlay. - OpenCommand, - /// Open the config & provider manager overlay. - OpenConfig, - /// Open the logs overlay (Phase 3 Wave 3). - OpenLogs, - /// Open the bench-run form overlay. - OpenBenchRun, -} - -/// Whether a crossterm key event should be acted on. Terminals emit -/// Release/Repeat events in addition to Press (notably Windows Terminal / -/// ConPTY under WSL, and any terminal advertising the kitty keyboard protocol). -/// -/// The whole TUI acts on Press only. Both the general [`handle_key`] and the -/// event loop's operational-overlay dispatch share this gate — without it, a -/// single keystroke reaches an overlay's `on_key` more than once, which made -/// Enter in the serve wizard's model picker re-open the picker (seeded with the -/// just-chosen model as a filter) instead of choosing it. -const fn is_actionable_key(kind: KeyEventKind) -> bool { - matches!(kind, KeyEventKind::Press) -} - -fn handle_key(k: KeyEvent, current: ActiveTab, modal: &Modal, chat: ChatKeyCtx) -> KeyAction { - if !is_actionable_key(k.kind) { - return KeyAction::Nothing; - } - // Chat tab key handling, placed BEFORE the global hotkey match so focused - // text entry and the consent prompt absorb keys (the short-circuit that - // stops `q`, `1`–`5`, etc. from firing while typing / deciding consent). - if current == ActiveTab::Chat && *modal == Modal::None { - // A detected-endpoint offer (gate-only) absorbs its decision keys before - // the normal consent prompt: y use now, n/Esc dismiss. ([s] use & save - // is wired with persistence.) Other keys fall through to the globals. - if chat.offer_pending && chat.consent != ChatConsent::Accepted { - match k.code { - KeyCode::Char('y' | 'Y') | KeyCode::Enter => { - return KeyAction::ChatDetectAccept; - } - KeyCode::Char('s' | 'S') => { - return KeyAction::ChatDetectSave; - } - KeyCode::Char('n' | 'N') | KeyCode::Esc => { - return KeyAction::ChatDetectDismiss; - } - _ => {} - } - } - match chat.consent { - ChatConsent::Accepted => { - // History scroll works whether or not the input is focused. - match k.code { - KeyCode::PageUp => return KeyAction::ChatScroll(-CHAT_SCROLL_STEP), - KeyCode::PageDown => return KeyAction::ChatScroll(CHAT_SCROLL_STEP), - _ => {} - } - if chat.focused { - return match k.code { - KeyCode::Esc => KeyAction::ChatBlur, - KeyCode::Enter => KeyAction::ChatSubmit, - KeyCode::Backspace => KeyAction::ChatBackspace, - KeyCode::Char(c) => KeyAction::ChatInput(c), - _ => KeyAction::Nothing, - }; - } - // Not focused: `i`/`Enter` enter insert mode; other keys fall - // through to the global hotkeys below. - if let KeyCode::Char('i') | KeyCode::Enter = k.code { - return KeyAction::ChatFocus; - } - } - ChatConsent::Pending | ChatConsent::Declined => { - // Consent gate: y/Enter accept, n decline, d detect a local - // engine. Other keys (q, digits, Tab, ?) fall through to the - // globals so the user isn't trapped. - match k.code { - KeyCode::Char('y' | 'Y') | KeyCode::Enter => { - return KeyAction::ChatConsentAccept; - } - KeyCode::Char('n' | 'N') => { - return KeyAction::ChatConsentDecline; - } - KeyCode::Char('d' | 'D') => { - return KeyAction::ChatDetect; - } - _ => {} - } - } - // No endpoint configured: the only gate action is to detect one. - ChatConsent::Unavailable => { - if let KeyCode::Char('d' | 'D') = k.code { - return KeyAction::ChatDetect; - } - } - } - } - // ThemePicker is a navigable modal — j/k/g/G move the cursor, Enter applies. - if *modal == Modal::ThemePicker { - return match k.code { - KeyCode::Char('q') => KeyAction::Quit, - KeyCode::Esc | KeyCode::Char('t') => KeyAction::CloseModal, - KeyCode::Enter => KeyAction::ApplyThemePick, - KeyCode::Char('j') | KeyCode::Down => KeyAction::Move(1), - KeyCode::Char('k') | KeyCode::Up => KeyAction::Move(-1), - KeyCode::Char('g') | KeyCode::Home => KeyAction::SelectFirst, - KeyCode::Char('G') | KeyCode::End => KeyAction::SelectLast, - _ => KeyAction::Nothing, - }; - } - // Detail modal: vertical scroll keys, plus quit/close. - if *modal == Modal::Detail { - return match k.code { - KeyCode::Char('q') => KeyAction::Quit, - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, - KeyCode::Char('j') | KeyCode::Down => KeyAction::ScrollModal(1), - KeyCode::Char('k') | KeyCode::Up => KeyAction::ScrollModal(-1), - KeyCode::PageDown => KeyAction::ScrollModal(10), - KeyCode::PageUp => KeyAction::ScrollModal(-10), - KeyCode::Char('g') | KeyCode::Home => KeyAction::ScrollModal(i16::MIN), - KeyCode::Char('G') | KeyCode::End => KeyAction::ScrollModal(i16::MAX), - _ => KeyAction::Nothing, - }; - } - // Help absorbs everything except quit / close / ? toggle — the popup is - // always sized to fit its content, so there's nothing to scroll. - if *modal == Modal::Help { - return match k.code { - KeyCode::Char('q') => KeyAction::Quit, - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, - _ => KeyAction::Nothing, - }; - } - // Global help overlay (opened from the Esc menu): close, same as the - // contextual Help above. - if *modal == Modal::GlobalHelp { - return match k.code { - KeyCode::Char('q') => KeyAction::Quit, - KeyCode::Esc | KeyCode::Enter | KeyCode::Char('?') => KeyAction::CloseModal, - _ => KeyAction::Nothing, - }; - } - // Esc main menu: ↑↓ cycle Options/Help/Quit, Enter activates, Esc closes. - if *modal == Modal::Menu { - return match k.code { - KeyCode::Char('q') => KeyAction::Quit, - KeyCode::Esc => KeyAction::CloseModal, - KeyCode::Char('j') | KeyCode::Down => KeyAction::MenuMove(1), - KeyCode::Char('k') | KeyCode::Up => KeyAction::MenuMove(-1), - KeyCode::Enter => KeyAction::MenuActivate, - _ => KeyAction::Nothing, - }; - } - // Command palette: ↑↓ choose destination, Enter goes, Esc closes. - if *modal == Modal::Palette { - return match k.code { - KeyCode::Char('q') => KeyAction::Quit, - KeyCode::Esc => KeyAction::CloseModal, - KeyCode::Char('j') | KeyCode::Down => KeyAction::MenuMove(1), - KeyCode::Char('k') | KeyCode::Up => KeyAction::MenuMove(-1), - KeyCode::Enter => KeyAction::MenuActivate, - _ => KeyAction::Nothing, - }; - } - // Options panel: ←→ switch settings tab, Esc closes. - if *modal == Modal::Options { - return match k.code { - KeyCode::Char('q') => KeyAction::Quit, - KeyCode::Esc => KeyAction::CloseModal, - KeyCode::Char('h') | KeyCode::Left | KeyCode::BackTab => KeyAction::OptionsTab(-1), - KeyCode::Char('l') | KeyCode::Right | KeyCode::Tab => KeyAction::OptionsTab(1), - _ => KeyAction::Nothing, - }; - } - match k.code { - KeyCode::Char('q') => KeyAction::Quit, - // Esc opens the main menu when idle — managers/approval are routed - // upstream, and Chat-focused Esc is handled by the short-circuit above. - // On ROCm/Serving, Esc first steps out of the detail pane (resolved - // against focus in `apply_action`); elsewhere it opens the main menu. - KeyCode::Esc if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { - KeyAction::PaneEscape - } - KeyCode::Esc => KeyAction::OpenMenu, - KeyCode::Char(':') => KeyAction::OpenPalette, - KeyCode::Char('?') => KeyAction::ToggleHelp, - KeyCode::Char('t') => KeyAction::OpenThemePicker, - KeyCode::BackTab => KeyAction::SwitchTab(current.prev()), - KeyCode::Tab => { - if k.modifiers.contains(KeyModifiers::SHIFT) { - KeyAction::SwitchTab(current.prev()) - } else { - KeyAction::SwitchTab(current.next()) - } - } - KeyCode::Char(c @ '1'..='5') => match ActiveTab::from_digit(c) { - Some(t) => KeyAction::SwitchTab(t), - None => KeyAction::Nothing, - }, - KeyCode::PageDown => KeyAction::Move(10), - KeyCode::PageUp => KeyAction::Move(-10), - KeyCode::Char(' ') => KeyAction::ReplayTogglePause, - KeyCode::Char('+' | '=') => KeyAction::ReplaySpeedUp, - KeyCode::Char('-' | '_') => KeyAction::ReplaySpeedDown, - KeyCode::Char('[') => KeyAction::ReplayJump(-10), - KeyCode::Char(']') => KeyAction::ReplayJump(10), - KeyCode::Char('{') => KeyAction::ReplayJump(-60), - KeyCode::Char('}') => KeyAction::ReplayJump(60), - KeyCode::Char('j') | KeyCode::Down => KeyAction::Move(1), - KeyCode::Char('k') | KeyCode::Up => KeyAction::Move(-1), - KeyCode::Char('g') | KeyCode::Home => KeyAction::SelectFirst, - KeyCode::Char('G') | KeyCode::End => KeyAction::SelectLast, - // The guided-action letter hotkeys live ONLY on Observe (the telemetry - // surface) — quick jumps into the managers via the existing seam. On - // ROCm/Serving the Actions list is the single interaction path, so the - // per-tab letter hotkeys are retired there. - // Services manager: open where servers live. - KeyCode::Char('s') if current == ActiveTab::Observe => KeyAction::OpenServices, - // Serve wizard: launch a model. - KeyCode::Char('w') if current == ActiveTab::Observe => KeyAction::OpenServeWizard, - // Engine manager: use/install/reinstall serving engines. - KeyCode::Char('e') if current == ActiveTab::Observe => KeyAction::OpenEngineManager, - // Examine: read-only environment check. - KeyCode::Char('d') if current == ActiveTab::Observe => KeyAction::OpenExamine, - // Update: check/preview/apply ROCm package updates. - KeyCode::Char('u') if current == ActiveTab::Observe => KeyAction::OpenUpdate, - // Install: ROCm SDK (TheRock) install / dry-run. - KeyCode::Char('i') if current == ActiveTab::Observe => KeyAction::OpenInstall, - // Logs: browse recent ROCm CLI logs. - KeyCode::Char('l') if current == ActiveTab::Observe => KeyAction::OpenLogs, - // Bench-run: launch a bench sweep from the TUI. - KeyCode::Char('b') if current == ActiveTab::Observe => KeyAction::OpenBenchRun, - // Runtimes: list/activate/adopt/import ROCm runtimes. - KeyCode::Char('r') if current == ActiveTab::Observe => KeyAction::OpenRuntimes, - // Onboarding: first-run setup wizard (install / adopt). - KeyCode::Char('n') if current == ActiveTab::Observe => KeyAction::OpenOnboarding, - // Automations: list/enable/disable background checks. - KeyCode::Char('a') if current == ActiveTab::Observe => KeyAction::OpenAutomations, - // Command runner: run any ROCm CLI subcommand (gated). - KeyCode::Char('c') if current == ActiveTab::Observe => KeyAction::OpenCommand, - // Config & providers. - KeyCode::Char('p') if current == ActiveTab::Observe => KeyAction::OpenConfig, - // ROCm/Serving tabs: arrow keys drive the focus-into-detail interaction; - // Enter is focus-aware (list → focus detail, detail → open the manager). - KeyCode::Right if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { - KeyAction::PaneFocusDetail - } - KeyCode::Left if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { - KeyAction::PaneFocusActions - } - KeyCode::Enter if matches!(current, ActiveTab::Rocm | ActiveTab::Serving) => { - KeyAction::PaneActivate - } - KeyCode::Enter => KeyAction::OpenDetail, - _ => KeyAction::Nothing, - } -} - -/// Translate a `MouseEvent` into the existing `KeyAction` vocabulary. -/// -/// The caller is responsible for the surrounding state context: -/// - `last_tab_bar_area` / `last_body_area` are read off `AppState` by the -/// event loop so this function stays pure on the input event. -/// - Per-tab body clicks are dispatched to the active tab module's -/// `hit_test` from the event loop. -/// -/// We only translate the parts of mouse handling that are tab-agnostic: -/// the scroll wheel, and (in the event loop) the tab-bar click. Per-tab -/// click is handled in tab modules. -/// Map a navigation key to a job-console pan delta `(lines, cols)` while a -/// console is showing. `None` for non-scroll keys so they fall through to the -/// console's own action handler (Ctrl+C / q / Esc / Enter). A page is 10 lines. -const fn console_scroll_delta(code: KeyCode) -> Option<(i16, i16)> { - match code { - KeyCode::PageDown => Some((10, 0)), - KeyCode::PageUp => Some((-10, 0)), - KeyCode::Down => Some((1, 0)), - KeyCode::Up => Some((-1, 0)), - KeyCode::Right => Some((0, 4)), - KeyCode::Left => Some((0, -4)), - _ => None, - } -} - -pub fn handle_mouse(ev: MouseEvent, modal: &Modal, tab: ActiveTab) -> KeyAction { - // Domain-tab (ROCm/Serving) and overlay/console scroll is resolved in - // `resolve_mouse` (it needs `&AppState` for hit-testing and overlay state). - // This handles the remaining position-independent targets: the scrollable - // modal body and the Observe instances list. One row per wheel notch. - let delta: i16 = match ev.kind { - MouseEventKind::ScrollDown => 1, - MouseEventKind::ScrollUp => -1, - _ => return KeyAction::Nothing, - }; - if *modal == Modal::Detail { - KeyAction::ScrollModal(delta) - } else if *modal == Modal::ThemePicker || (*modal == Modal::None && tab == ActiveTab::Observe) { - KeyAction::Move(delta as isize) - } else { - KeyAction::Nothing - } -} - -/// Resolve a left-click at `(x, y)` against `tab_bar_area`. Returns the tab -/// to switch to, or `None` if the click is outside or doesn't land on a chip. -/// -/// Uses [`ui::tabs::compute_chip_layout`] so the hit-test geometry exactly -/// mirrors what `draw_tab_bar` rendered. Separator gaps (` · `) between -/// chips are intentional dead zones — clicking the dot does nothing. -pub fn tab_bar_hit(tab_bar_area: ratatui::layout::Rect, x: u16, y: u16) -> Option { - if y != tab_bar_area.y { - return None; - } - let chips = ui::tabs::compute_chip_layout(tab_bar_area.x); - let bar_right = tab_bar_area.x.saturating_add(tab_bar_area.width); - for chip in chips { - if chip.x_end > bar_right { - // Chip overflows the bar — terminal too narrow to show it; skip. - continue; - } - if x >= chip.x_start && x < chip.x_end { - return Some(chip.tab); - } - } - None -} - -#[cfg(test)] -mod tests { - use super::*; - use ratatui::layout::Rect; - - fn press(code: KeyCode) -> KeyEvent { - KeyEvent::new(code, KeyModifiers::NONE) - } - - fn hk(c: KeyCode, tab: ActiveTab) -> KeyAction { - handle_key(press(c), tab, &Modal::None, ChatKeyCtx::default()) - } - - #[test] - fn q_quits_esc_does_not() { - assert_eq!(hk(KeyCode::Char('q'), ActiveTab::Home), KeyAction::Quit); - // P4: Esc opens the main menu (it never quits). - assert_eq!(hk(KeyCode::Esc, ActiveTab::Observe), KeyAction::OpenMenu); - } - - /// Serialises every test that touches the process-global signal machinery. - /// - /// The two lanes differ: Linux CI runs `cargo nextest` (one process per - /// test, so this lock is a no-op), while the required Windows lane runs - /// `cargo test`, which runs the whole binary's tests as THREADS IN ONE - /// PROCESS. There, `termination_watcher_parks_until_aborted` has a live - /// watcher whose body ends in `std::process::exit`; if the self-`kill` test - /// below ran concurrently, that watcher would wake on the other test's - /// signal and take the entire test binary down with exit 143. It would also - /// steal the signal the other test is asserting on. Holding this lock for - /// the whole of each test — including the runtime's `block_on` — makes the - /// two strictly sequential. - /// - /// The tests are written as plain `#[test]` + an explicit runtime (rather - /// than `#[tokio::test]`) precisely so the guard is held across `block_on` - /// without holding a `std` lock across an `.await`. - /// - /// One residue this lock cannot undo, recorded so it is not rediscovered as - /// a mystery: `TerminationSignals::register` installs Tokio's libc handler - /// for SIGINT/SIGTERM process-wide, and Tokio never unregisters it — not on - /// drop of the `Signal`, not on drop of the runtime. So from the first of - /// these tests onward, the rest of a single-process `cargo test` run (the - /// required Windows lane) is deaf to those signals: they are caught and - /// discarded instead of terminating the binary. Harmless for the suite as it - /// stands — nothing signals the test process except the test that does so - /// deliberately, under this lock — but any future test that expects a signal - /// to actually kill the test binary, or a CI step that relies on cancelling - /// it with SIGINT, must not assume the default disposition is still in place. - static SIGNAL_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - /// Upper bound on any `await_termination` in a test. The signal it waits for - /// is already queued before the await starts, so the real latency is - /// microseconds; this only exists so a broken registration fails the test - /// instead of parking the `.await` forever and burning the lane's job - /// timeout. A hanging test is worse than a failing one — it reports nothing. - /// - /// Gated because its only callers are: ungated, it is dead code on Windows - /// and `-D warnings` fails that lane. - #[cfg(unix)] - const SIGNAL_AWAIT_TIMEOUT: Duration = Duration::from_secs(10); - - /// A current-thread runtime with the signal driver enabled, which - /// `TerminationSignals::register` needs. - fn signal_test_runtime() -> tokio::runtime::Runtime { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .expect("building a current-thread runtime for the signal tests") - } - - #[test] - fn only_the_first_caller_claims_the_shutdown_latch() { - // The guard that stops the hub's process-lifetime watcher and a - // session's watcher from both restoring the terminal and both calling - // `process::exit` on one signal. Driven on a local latch so the test - // never touches (or depends on the state of) the process global. - let latch = AtomicBool::new(false); - assert!(claim_shutdown(&latch), "the first claim must win"); - assert!(!claim_shutdown(&latch), "a second claim must lose"); - assert!(!claim_shutdown(&latch), "and so must every later one"); - - // Under contention there must still be exactly one winner: a - // non-atomic read-then-write would let several threads through. - let contended = AtomicBool::new(false); - let winners = std::sync::atomic::AtomicUsize::new(0); - std::thread::scope(|scope| { - for _ in 0..16 { - scope.spawn(|| { - if claim_shutdown(&contended) { - winners.fetch_add(1, Ordering::SeqCst); - } - }); - } - }); - assert_eq!( - winners.load(Ordering::SeqCst), - 1, - "exactly one of 16 racing watchers may claim the shutdown" - ); - } - - #[test] - fn claiming_the_shutdown_latch_suspends_rendering() { - // The narrow serialization that stops a frame landing after - // `restore_terminal()` has run and undoing it: both render loops gate on - // the SAME latch the shutdown path claims, and the claim happens before - // the restore begins. Driven on a local latch so the test never touches - // the process global. - let latch = AtomicBool::new(false); - assert!( - !shutdown_claimed_on(&latch), - "rendering must be allowed while no shutdown has been claimed" - ); - assert!(claim_shutdown(&latch), "the first claim must win"); - assert!( - shutdown_claimed_on(&latch), - "claiming the shutdown must suspend rendering, or a late frame can \ - repaint over the restored terminal" - ); - } - - #[test] - fn a_clean_session_teardown_restores_without_claiming_the_exit_latch() { - // `run` returning is the one restore path with an "after": bare `rocm` is - // a persistent hub, so control goes back to a live launcher menu. Claiming - // the one-shot exit latch here wedged that hub three ways at once — the - // render gate refused every later frame (blank front door), a typed Ctrl-C - // parked forever in `exit_on_ctrl_c`, and every later signal lost the - // claim in `await_termination` and was swallowed. So this path must - // restore the terminal and leave the latch exactly as it found it. - let latch = AtomicBool::new(false); - let restored = std::cell::Cell::new(false); - restore_after_session(&latch, || restored.set(true)); - assert!( - restored.get(), - "a clean session must restore the terminal it put into raw mode" - ); - assert!( - !shutdown_claimed_on(&latch), - "a teardown that returns to a live process must not claim the exit \ - latch — nothing ever releases it, so the hub is wedged from here on" - ); - - // The one thing it may key on the latch: when a watcher or a typed - // Ctrl-C has already claimed the exit, that owner is microseconds from - // `process::exit` and owns the teardown; restoring again is redundant. - let claimed = AtomicBool::new(true); - let restored_again = std::cell::Cell::new(false); - restore_after_session(&claimed, || restored_again.set(true)); - assert!( - !restored_again.get(), - "an exiting process's teardown belongs to whoever claimed the exit" - ); - } - - #[test] - fn ctrl_c_is_recognised_as_a_key_because_raw_mode_suppresses_the_signal() { - // Raw mode clears ISIG (and ENABLE_PROCESSED_INPUT on Windows), so a - // typed Ctrl-C never becomes a signal — it arrives here as a key event. - let ctrl = |c: char| KeyEvent::new(KeyCode::Char(c), KeyModifiers::CONTROL); - assert!(is_ctrl_c(ctrl('c')), "Ctrl+C must be recognised"); - assert!( - !is_ctrl_c(press(KeyCode::Char('c'))), - "a bare `c` must not terminate the session" - ); - assert!(!is_ctrl_c(ctrl('d')), "Ctrl+D is a different key"); - // Terminals commonly bind Ctrl+Shift+C to copy; claiming it would kill - // the session on a copy. - assert!( - !is_ctrl_c(KeyEvent::new( - KeyCode::Char('C'), - KeyModifiers::CONTROL | KeyModifiers::SHIFT - )), - "Ctrl+Shift+C is copy, not terminate" - ); - } - - #[test] - fn ctrl_c_exits_the_session_but_a_job_console_keeps_cancelling_the_job() { - // Precedence for the event loop's Ctrl-C arm. With no console up, the - // gesture ends the session; with one up it must fall through to - // `job_console::on_console_key`, whose Ctrl+C cancels the running job — - // the documented way to stop a focused install/serve without killing the - // process mid-write. - let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - let mut s = st(); - assert!( - ctrl_c_should_exit(&s, ctrl_c), - "Ctrl+C with no job console must end the session" - ); - assert!( - !ctrl_c_should_exit(&s, press(KeyCode::Char('q'))), - "an unrelated key must not take the terminate path" - ); - - let _ = open_overlay_for_focus(&mut s, Focus::Examine); // auto-runs a job - assert!(s.has_active_console(), "examine console is live"); - assert!( - !ctrl_c_should_exit(&s, ctrl_c), - "Ctrl+C over a live job console must cancel the job, not the process" - ); - } - - /// Every cell a `TestBackend` frame painted, trimmed — `""` for a frame the - /// render gate suppressed. Shared by the two gate tests below so they assert - /// on the same thing. - fn painted(term: &ratatui::Terminal) -> String { - term.backend() - .buffer() - .content() - .iter() - .map(ratatui::buffer::Cell::symbol) - .collect::() - .trim() - .to_string() - } - - #[test] - fn a_claimed_shutdown_stops_the_dashboard_painting_another_frame() { - // The render gate itself, not just the latch predicate underneath it. - // `restore_terminal()` runs on a Tokio worker while frames are drawn on - // the `block_on` thread, and nothing locks the terminal — so a frame that - // *starts* after the restore would hide the cursor again and repaint a - // stale dashboard over the restored screen. The claim happens before the - // restore begins, so gating on it is what makes that impossible. - // - // Driven against a `TestBackend` and a local latch: no process-global - // state, no real terminal, and the assertion is on painted cells rather - // than on the predicate the gate happens to call. - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - // Control: with nothing claimed the gate must let the frame through, - // otherwise the assertion below would pass on a helper that never draws. - let mut s = st(); - let open = AtomicBool::new(false); - let mut term = Terminal::new(TestBackend::new(120, 32)).unwrap(); - draw_frame_unless_shutting_down(&mut term, &mut s, None, &open) - .expect("drawing to a TestBackend cannot fail"); - assert!( - !painted(&term).is_empty(), - "with no shutdown claimed the dashboard must paint a frame" - ); - - // The real case: a watcher (or a typed Ctrl-C) has claimed the shutdown - // and the restore is under way. - let mut s = st(); - let claimed = AtomicBool::new(false); - assert!(claim_shutdown(&claimed), "the test must win its own latch"); - let mut term = Terminal::new(TestBackend::new(120, 32)).unwrap(); - draw_frame_unless_shutting_down(&mut term, &mut s, None, &claimed) - .expect("the gate must not turn a suppressed frame into an error"); - assert_eq!( - painted(&term), - "", - "once the shutdown is claimed no further frame may be painted — a \ - late frame lands after `restore_terminal()` and undoes it" - ); - } - - #[test] - fn a_frame_cannot_paint_while_a_teardown_owns_the_terminal() { - // The half of the gate the latch cannot provide on its own, and the - // defect the WSL2 E2E lane caught as `dash-sigint-restores-terminal`: - // "alternate_screen=false, cursor_hidden=true" — the alt-screen left, but - // the cursor still invisible. - // - // Reading the latch before drawing stops a frame that *starts* after the - // claim. It cannot stop the frame already in flight when the claim lands, - // and that frame is the problem: `Terminal::draw` ends by emitting `Hide` - // unconditionally, so its tail undoes the restore's `Show` while the - // alt-screen stays left (`EnterAlternateScreen` is never re-emitted) — - // exactly the half-restored terminal the lane reported. Reproduced - // outside the harness by signalling a real `rocm dash --demo` under a - // pty: the restore landed *inside* a frame's bytes, with `ESC[?25l` last. - // - // So the gate must take `lock_terminal_writer()` FIRST and read the latch - // under it. Modelled with the teardown's half of that lock held by another - // thread: this thread asks to draw while the teardown owns the terminal, - // and must paint nothing, because the claim happens-before the unlock it - // is waiting on. Delete the lock from the gate and the frame paints - // immediately instead, which is the bug. - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - let latch = AtomicBool::new(false); - let mut s = st(); - let mut term = Terminal::new(TestBackend::new(120, 32)).unwrap(); - let (held_tx, held_rx) = std::sync::mpsc::channel::<()>(); - let (drawing_tx, drawing_rx) = std::sync::mpsc::channel::<()>(); - - std::thread::scope(|scope| { - // `mpsc::Receiver` is `Send` but not `Sync`, so the halves the - // teardown thread uses are moved into it; the latch is shared as a - // plain reference (the whole point is that both threads see it). - let latch = &latch; - scope.spawn(move || { - // Stands in for `restore_terminal()`. It writes nothing: the - // claim is what this test is about, not the escape bytes (those - // are covered by `write_restore_sequences_leaves_alt_screen_…`). - let guard = lock_terminal_writer(); - held_tx.send(()).expect("the drawing thread is alive"); - drawing_rx.recv().expect("the drawing thread is alive"); - // Only to make the unfixed code reliably red: an ungated draw - // paints in microseconds, so it would certainly have painted - // within this window. The fixed path does not depend on the - // duration — the claim below happens-before the unlock either - // way, so the assertion holds even if this were zero. - std::thread::sleep(std::time::Duration::from_millis(200)); - assert!( - claim_shutdown(latch), - "the teardown must win a latch nothing else can see" - ); - drop(guard); - }); - - held_rx.recv().expect("the teardown thread is alive"); - drawing_tx.send(()).expect("the teardown thread is alive"); - draw_frame_unless_shutting_down(&mut term, &mut s, None, latch) - .expect("the gate must not turn a suppressed frame into an error"); - }); - - assert_eq!( - painted(&term), - "", - "a frame asked for while a teardown owned the terminal must not paint \ - — its trailing `Hide` would land after the restore's `Show` and leave \ - the user on the normal screen with an invisible cursor" - ); - } - - #[test] - fn ctrl_c_exits_once_the_console_job_has_finished() { - // The gesture the PR exists to fix, in the state that used to swallow it. - // Nothing clears `active_job` when a job completes (only an Esc/Enter - // dismissal does), so the console stays on screen after the job is done. - // While the exemption keyed on "a console is displayed" rather than "a - // job is running", Ctrl-C there fell through to `on_console_key` → - // `CancelJob`, which the reducer drops on a terminal job: nothing - // happened at all, and the user stayed in raw mode on the alt-screen. - let ctrl_c = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL); - let mut s = st(); - let _ = open_overlay_for_focus(&mut s, Focus::Examine); // auto-runs a job - let job_id = s - .active_job_id() - .expect("opening Examine must start a job and show its console") - .to_string(); - assert!( - !ctrl_c_should_exit(&s, ctrl_c), - "while the job is still running, Ctrl+C must cancel the job" - ); - - // The job finishes. The console is NOT dismissed — this is the review - // state the user is left sitting in. - s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { - id: job_id.clone(), - code: 0, - }); - assert!( - s.jobs - .job(&job_id) - .is_some_and(rocm_dash_core::state::JobState::is_terminal), - "the job must have reached a terminal state" - ); - assert!( - s.has_active_console(), - "the finished console must still be displayed — that is the whole \ - point of this case" - ); - assert!( - ctrl_c_should_exit(&s, ctrl_c), - "Ctrl+C over a FINISHED job console must end the session; there is no \ - job left to cancel, so exempting it makes the keystroke a silent \ - no-op and traps the user in raw mode" - ); - } - - #[test] - fn termination_watcher_parks_until_aborted() { - let _guard = SIGNAL_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - signal_test_runtime().block_on(async { - // The real wiring `run` depends on: registration must succeed, and - // the spawned task must stay parked on `recv()` (never resolving on - // its own and exiting the process), until the clean-return path - // aborts it. - let handle = spawn_termination_watcher() - .expect("registering the termination-signal listeners must succeed"); - // Let the task actually start and park; on a current-thread runtime - // a freshly spawned task has not been polled yet, so without this - // `is_finished` would be trivially false. - tokio::task::yield_now().await; - assert!( - !handle.is_finished(), - "the watcher must stay parked while no signal has arrived" - ); - - handle.abort(); - let err = handle - .await - .expect_err("an aborted watcher must not report completion"); - assert!( - err.is_cancelled(), - "the watcher must end by cancellation, not by panicking: {err:?}" - ); - }); - } - - // Unix-only. This test sends real signals to its own process, which is safe - // ONLY because it drives `await_termination` directly: the watcher body that - // calls `std::process::exit` is never run here. `TerminationSignals::register` - // installs the handlers *before* the `kill`, so the signal is caught rather - // than taking its default (fatal) disposition. Both listeners are registered - // before the single `kill` on purpose — that is exactly the hub-watcher + - // session-watcher shape, and Tokio's process-global registry wakes both. - #[cfg(unix)] - #[test] - fn termination_signals_yield_shell_exit_codes_and_only_one_watcher_shuts_down() { - let _guard = SIGNAL_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - signal_test_runtime().block_on(async { - // Scope, so nobody reads more into this than it proves: the - // expectation is built from the same `EXIT_CODE_*` constants the - // code under test returns, so this pins the SIGTERM→sigterm-code / - // SIGINT→sigint-code *mapping* (swapping the two arms turns it red) - // but not the literal values. Editing `EXIT_CODE_SIGINT` to 7 leaves - // this green. The literals 130/143 are pinned by the e2e scenarios - // `dash-17` … `dash-21` in `tests/e2e-cucumber/features/dash.feature`, - // which assert the shell-visible exit status of a real process. - for (signo, expected) in [ - (libc::SIGTERM, EXIT_CODE_SIGTERM), - (libc::SIGINT, EXIT_CODE_SIGINT), - ] { - // A fresh latch per kind keeps the test order-independent. - let latch = AtomicBool::new(false); - let hub_watcher = TerminationSignals::register() - .expect("registering the hub listeners must succeed"); - let session_watcher = TerminationSignals::register() - .expect("registering the session listeners must succeed"); - - // SAFETY: `raise` is an async-signal-safe libc call with no - // arguments to get wrong, and both listeners above are already - // installed, so the signal is delivered to Tokio's handler - // instead of terminating the test binary. - #[allow(unsafe_code)] // libc FFI - let rc = unsafe { libc::raise(signo) }; - assert_eq!(rc, 0, "raise({signo}) failed"); - - // Both `await_termination`s are bounded. A regression that breaks - // *registration* of one signal kind (rather than mis-mapping its - // exit code) leaves the `.await` parked forever, and an unbounded - // await would burn the required lane's job timeout instead of - // reporting a failure. The bound is generous — the signal is - // already queued by the `raise` above, so the await resolves in - // microseconds; anything near 10 s is a genuine hang. - let received = tokio::time::timeout( - SIGNAL_AWAIT_TIMEOUT, - await_termination(hub_watcher, &latch), - ) - .await - .unwrap_or_else(|_| { - panic!( - "the first watcher never received signal {signo} within \ - {SIGNAL_AWAIT_TIMEOUT:?} — the listener for it is not \ - registered, so the watcher would park forever instead of \ - restoring the terminal" - ) - }); - assert_eq!( - received, - Some(expected), - "the first watcher must receive signal {signo} and map it to \ - the conventional 128 + signo exit code" - ); - - let stood_down = tokio::time::timeout( - SIGNAL_AWAIT_TIMEOUT, - await_termination(session_watcher, &latch), - ) - .await - .unwrap_or_else(|_| { - panic!( - "the second watcher never woke for signal {signo} within \ - {SIGNAL_AWAIT_TIMEOUT:?} — Tokio's registry must wake \ - every listener registered for a kind, not just the first" - ) - }); - assert_eq!( - stood_down, None, - "the second watcher woken by the same signal must stand down \ - rather than race a concurrent restore + exit" - ); - } - }); - } - - // Unix-only: crossterm emits ANSI escape sequences to a generic writer on - // Unix, so an in-memory sink captures the real bytes. On Windows crossterm - // drives the console via the WinAPI backend instead of writing ANSI, and - // `execute!` to a `Vec` errors with "Initial console modes not set" — there - // is no console to configure. Production `restore_terminal()` passes a real - // stdout handle, so the Windows path is exercised there, not by this sink. - #[cfg(unix)] - #[test] - fn write_restore_sequences_leaves_alt_screen_disables_mouse_and_shows_cursor() { - // The restore path must undo all three things `run` set up: leave the - // alternate screen, disable mouse capture, show the cursor. Driving an - // in-memory sink asserts the actual emitted bytes without touching the - // process's shared terminal state — the global `disable_raw_mode()` half - // is deliberately outside this function, so nothing here races other - // tests in the single-process `cargo test` lane. - let mut sink: Vec = Vec::new(); - write_restore_sequences(&mut sink).expect("writing to a Vec cannot fail"); - let emitted = String::from_utf8(sink).expect("restore sequences are ASCII escapes"); - assert!( - emitted.contains("\x1b[?1049l"), - "expected the leave-alt-screen sequence in {emitted:?}" - ); - // `DisableMouseCapture` is one command but five terminal modes: - // crossterm 0.28 expands it to SGR-encoding, urxvt-encoding, any-motion, - // button-event and normal tracking, turned off in that order. Assert - // the whole block rather than a single mode so dropping the command from - // `write_restore_sequences` cannot leave this test green — a terminal - // left reporting mouse events after `rocm dash` exits is exactly the - // broken-terminal state this restore path exists to prevent. If a - // crossterm bump changes the expansion, re-derive it from a sink run - // rather than weakening the assertion. - let disable_mouse = "\x1b[?1006l\x1b[?1015l\x1b[?1003l\x1b[?1002l\x1b[?1000l"; - assert!( - emitted.contains(disable_mouse), - "expected the disable-mouse-capture sequences {disable_mouse:?} in {emitted:?}" - ); - assert!( - emitted.contains("\x1b[?25h"), - "expected the show-cursor sequence in {emitted:?}" - ); - } - - #[test] - fn q_quits_menu_palette_and_options_too() { - // Menu/Palette/Options used to have no `q` arm at all, silently - // swallowing the key instead of quitting like every other modal. - let with_modal = |modal: &Modal| { - handle_key( - press(KeyCode::Char('q')), - ActiveTab::Home, - modal, - ChatKeyCtx::default(), - ) - }; - assert_eq!(with_modal(&Modal::Menu), KeyAction::Quit); - assert_eq!(with_modal(&Modal::Palette), KeyAction::Quit); - assert_eq!(with_modal(&Modal::Options), KeyAction::Quit); - } - - #[test] - fn chat_esc_then_q_still_quits_via_the_menu() { - // A terminal that decodes "Alt+q" as a bare Esc followed by a plain - // `q` (rather than a single Alt-modified KeyEvent) used to quit - // immediately on Chat, because Esc was a no-op there and `q` fell - // through to the global `Quit` arm. This PR makes Esc open the main - // menu on Chat too, so the second event now needs Menu's own `q` - // arm (added above) to still reach `Quit` instead of being - // swallowed by the menu. - let ctx = ChatKeyCtx { - consent: ChatConsent::Accepted, - focused: false, - ..Default::default() - }; - let after_esc = handle_key(press(KeyCode::Esc), ActiveTab::Chat, &Modal::None, ctx); - assert_eq!(after_esc, KeyAction::OpenMenu); - let after_q = handle_key( - press(KeyCode::Char('q')), - ActiveTab::Chat, - &Modal::Menu, - ctx, - ); - assert_eq!(after_q, KeyAction::Quit); - } - - #[test] - fn tab_cycles_forward_and_wraps() { - // 5-tab IA: Home → ROCm → Serving → Observe → Chat → Home. - assert_eq!( - hk(KeyCode::Tab, ActiveTab::Home), - KeyAction::SwitchTab(ActiveTab::Rocm) - ); - assert_eq!( - hk(KeyCode::Tab, ActiveTab::Serving), - KeyAction::SwitchTab(ActiveTab::Observe) - ); - assert_eq!( - hk(KeyCode::Tab, ActiveTab::Observe), - KeyAction::SwitchTab(ActiveTab::Chat) - ); - // Chat wraps back to Home. - assert_eq!( - hk(KeyCode::Tab, ActiveTab::Chat), - KeyAction::SwitchTab(ActiveTab::Home) - ); - } - - #[test] - fn action_tab_arrows_and_enter_drive_focus() { - // → steps into the detail pane, ← steps back, Enter is focus-aware. - assert_eq!( - hk(KeyCode::Right, ActiveTab::Rocm), - KeyAction::PaneFocusDetail - ); - assert_eq!( - hk(KeyCode::Left, ActiveTab::Rocm), - KeyAction::PaneFocusActions - ); - assert_eq!(hk(KeyCode::Enter, ActiveTab::Rocm), KeyAction::PaneActivate); - // Arrows are inert on other tabs (no focus model there). - assert_eq!(hk(KeyCode::Right, ActiveTab::Observe), KeyAction::Nothing); - // Enter elsewhere keeps its detail-modal meaning. - assert_eq!( - hk(KeyCode::Enter, ActiveTab::Observe), - KeyAction::OpenDetail - ); - } - - #[test] - fn action_activate_is_two_step_list_then_open() { - // Serving verb 0 = "Serve a model" → OpenServeWizard. - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Serving; - s.serving_sel = 0; - assert_eq!(s.pane_focus, PaneFocus::Actions); - // First activate steps into the detail pane; no overlay yet. - apply_action(&mut s, KeyAction::PaneActivate); - assert_eq!(s.pane_focus, PaneFocus::Detail); - assert!(s.serve_wizard.is_none(), "must not open before stepping in"); - // Second activate opens the operation's manager. - apply_action(&mut s, KeyAction::PaneActivate); - assert!( - s.serve_wizard.is_some(), - "detail-focus Enter opens the manager" - ); - // ROCm verb 2 = "Diagnose (doctor)" → OpenExamine (the other mapping). - let mut r = AppState::new("t".into(), "default-dark".into()); - r.active_tab = ActiveTab::Rocm; - r.rocm_sel = 2; - r.pane_focus = PaneFocus::Detail; - apply_action(&mut r, KeyAction::PaneActivate); - assert!( - r.examine_manager.is_some(), - "ROCm Diagnose opens the doctor" - ); - } - - #[test] - fn action_focus_resets_on_move_and_tab_switch() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.pane_focus = PaneFocus::Detail; - apply_action(&mut s, KeyAction::Move(1)); - assert_eq!(s.pane_focus, PaneFocus::Actions, "Move snaps back to list"); - s.pane_focus = PaneFocus::Detail; - apply_action(&mut s, KeyAction::SwitchTab(ActiveTab::Home)); - assert_eq!(s.pane_focus, PaneFocus::Actions, "tab switch resets focus"); - } - - #[test] - fn action_esc_backs_out_of_detail_then_opens_menu() { - // Esc on Action is intercepted (not the global OpenMenu) so it can back - // out of the detail pane first. - assert_eq!(hk(KeyCode::Esc, ActiveTab::Rocm), KeyAction::PaneEscape); - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.pane_focus = PaneFocus::Detail; - apply_action(&mut s, KeyAction::PaneEscape); - assert_eq!(s.pane_focus, PaneFocus::Actions, "first Esc → list"); - assert_eq!(s.modal, Modal::None, "first Esc does not open the menu"); - apply_action(&mut s, KeyAction::PaneEscape); - assert_eq!(s.modal, Modal::Menu, "second Esc opens the menu"); - } - - #[test] - fn action_select_sets_verb_and_parks_on_list() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.pane_focus = PaneFocus::Detail; - apply_action(&mut s, KeyAction::PaneSelect(2)); - assert_eq!(s.rocm_sel, 2); - assert_eq!(s.pane_focus, PaneFocus::Actions); - // Out-of-range clamps rather than panicking. - apply_action(&mut s, KeyAction::PaneSelect(999)); - assert!(s.rocm_sel < crate::ui::tabs::rocm::VERB_COUNT); - } - - #[test] - fn inline_manager_opens_in_detail_then_backs_out() { - // Activating a ROCm verb opens its manager inline (focus stays in - // Details); `←`/Esc backs out — closing the manager and returning focus - // to the Actions list. This mirrors the event-loop back-out arm. - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.rocm_sel = 0; // Set up / Install ROCm → OpenInstall - apply_action(&mut s, KeyAction::PaneActivate); // → Details - assert_eq!(s.pane_focus, PaneFocus::Detail); - assert!(!s.has_open_overlay(), "no manager before second activate"); - apply_action(&mut s, KeyAction::PaneActivate); // opens install_manager - assert!(s.install_manager.is_some(), "verb opens its manager inline"); - assert!(s.has_open_overlay()); - - // Esc backs out on a domain tab while a manager is open. - assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); - // `←` is left to the manager (it may cycle options), not a back-out. - assert!(!s.should_pane_back_out(crossterm::event::KeyCode::Left)); - // A normal key does not back out (routes to the manager instead). - assert!(!s.should_pane_back_out(crossterm::event::KeyCode::Char('j'))); - - // The event-loop arm closes the manager + parks focus on Actions. - s.close_overlays(); - s.pane_focus = PaneFocus::Actions; - assert!(!s.has_open_overlay(), "back-out closed the inline manager"); - assert_eq!(s.pane_focus, PaneFocus::Actions); - } - - #[test] - fn back_out_requires_an_open_manager_on_any_tab() { - let mut s = AppState::new("t".into(), "default-dark".into()); - // No manager open → never backs out, even on a domain tab. - s.active_tab = ActiveTab::Rocm; - assert!(!s.should_pane_back_out(crossterm::event::KeyCode::Esc)); - // Manager open on a non-domain tab (opened from Observe hotkey) → - // Esc backs out uniformly regardless of tab, now that the - // Rocm/Serving-only gate is gone. New coverage of the generalized - // behavior — the manager's own event-loop arm already closed it on - // this tab before the gate was removed, so this isn't a regression - // test for a prior bug. - s.active_tab = ActiveTab::Observe; - s.examine_manager = Some(crate::ui::examine_manager::ExamineManagerState::default()); - assert!(s.has_open_overlay()); - assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); - } - - #[test] - fn esc_defers_to_manager_when_a_subscreen_is_open() { - // With a job console (or sub-popup / approval) open inside an inline - // manager, Esc must reach the manager (cancel the inner layer / be - // ignored while running), NOT eject the whole manager. - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.install_manager = Some(crate::ui::install_manager::InstallManagerState { - active_job: Some("install-job".into()), // a console is up - ..Default::default() - }); - assert!(s.has_open_overlay()); - assert!( - !s.should_pane_back_out(crossterm::event::KeyCode::Esc), - "Esc must defer to the manager while a job console is open" - ); - // Once the console is dismissed (back at root), Esc backs out. - s.install_manager.as_mut().unwrap().active_job = None; - assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); - } - - #[test] - fn esc_defers_to_onboarding_install_config_subview() { - // Regression coverage for the `install_config` nesting field: the - // onboarding wizard's Configure sub-view is a nested sub-view just - // like a manager's job console, so root Esc must defer to it instead - // of ejecting the whole wizard. - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.onboarding = Some(crate::ui::onboarding::OnboardingState { - install_config: Some(crate::ui::onboarding::InstallConfig::default()), - ..Default::default() - }); - assert!(s.has_open_overlay()); - assert!( - !s.should_pane_back_out(crossterm::event::KeyCode::Esc), - "Esc must defer to onboarding while the Configure sub-view is open" - ); - // Once the sub-view is closed (back at root), Esc backs out again. - s.onboarding.as_mut().unwrap().install_config = None; - assert!(s.should_pane_back_out(crossterm::event::KeyCode::Esc)); - } - - #[test] - fn body_clicks_are_swallowed_while_a_manager_is_open() { - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - let click = MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 90, - row: 10, - modifiers: KeyModifiers::NONE, - }; - // No manager open → the click resolves against the tab's hit-test. - assert_ne!(resolve_mouse(click, &s), KeyAction::Nothing); - // Manager open → the body click is swallowed (no click-through). - s.install_manager = Some(crate::ui::install_manager::InstallManagerState::default()); - assert_eq!(resolve_mouse(click, &s), KeyAction::Nothing); - } - - #[test] - fn body_clicks_are_swallowed_while_an_approval_is_pending() { - use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - let click = MouseEvent { - kind: MouseEventKind::Down(MouseButton::Left), - column: 90, - row: 10, - modifiers: KeyModifiers::NONE, - }; - // No approval pending → the click resolves against the tab's hit-test. - assert_ne!(resolve_mouse(click, &s), KeyAction::Nothing); - // `open_approval` clears every manager overlay (so `has_open_overlay()` - // is false) but never touches `modal` — the body click must still be - // swallowed instead of falling through to the obscured Actions/Details - // list underneath the approval modal. - s.open_approval(crate::tool_exec::ApprovalIntent { - title: "run a command".into(), - body: vec!["echo hi".into()], - name: "shell".into(), - arguments: serde_json::Value::Null, - }); - assert!(!s.has_open_overlay()); - assert_eq!(s.modal, Modal::None); - assert_eq!(resolve_mouse(click, &s), KeyAction::Nothing); - } - - /// Build a ScrollDown/Up/Left/Right event at a pointer position. - fn wheel(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent { - MouseEvent { - kind, - column: col, - row, - modifiers: KeyModifiers::NONE, - } - } - - #[test] - fn scrollbar_position_maps_proportionally() { - let h = ScrollbarHandle { - track: Rect::new(50, 0, 1, 10), - horizontal: false, - content_len: 100, - viewport_len: 10, - target: ScrollTarget::Console, - }; - assert_eq!(h.track_position_at(50, 0), 0); - assert_eq!(h.track_position_at(50, 9), 90); - assert_eq!(h.track_position_at(50, 5), 90 * 5 / 9); - assert_eq!(h.track_position_at(50, 99), 90); - } - - #[test] - fn scrollbar_click_grabs_drag_scrolls_then_releases() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.scrollbars.borrow_mut().push(ScrollbarHandle { - track: Rect::new(60, 0, 1, 10), - horizontal: false, - content_len: 100, - viewport_len: 10, - target: ScrollTarget::Console, - }); - // Click near the bottom of the track → grab + jump near the end. - let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); - let a = resolve_mouse(down, &s); - assert_eq!(a, KeyAction::ScrollGrab(ScrollTarget::Console, 90, 0)); - apply_action(&mut s, a); - assert_eq!(s.console_scroll, 90); - assert_eq!( - s.scroll_drag, - Some(ScrollDrag { - target: ScrollTarget::Console, - grab_offset: 0, - }) - ); - // Drag to the top — the off-axis column is ignored, so it still tracks. - let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 40, 0); - let a = resolve_mouse(drag, &s); - assert_eq!(a, KeyAction::ScrollGrab(ScrollTarget::Console, 0, 0)); - apply_action(&mut s, a); - assert_eq!(s.console_scroll, 0); - // Release clears the drag. - let up = wheel(MouseEventKind::Up(MouseButton::Left), 40, 0); - let a = resolve_mouse(up, &s); - assert_eq!(a, KeyAction::ScrollRelease); - apply_action(&mut s, a); - assert_eq!(s.scroll_drag, None); - } - - #[test] - fn scrollbar_hit_is_swallowed_while_an_approval_is_pending() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.scrollbars.borrow_mut().push(ScrollbarHandle { - track: Rect::new(60, 0, 1, 10), - horizontal: false, - content_len: 100, - viewport_len: 10, - target: ScrollTarget::Console, - }); - let click = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); - // No approval pending → the scrollbar still wins, same as - // `scrollbar_click_grabs_drag_scrolls_then_releases`. - assert_eq!( - resolve_mouse(click, &s), - KeyAction::ScrollGrab(ScrollTarget::Console, 90, 0) - ); - // Nothing registers a scrollbar for the approval modal itself, so a - // handle on screen while one is pending belongs to content - // underneath it — the click must be swallowed like every other body - // click, not resolve to a drag on the obscured bar. - s.open_approval(crate::tool_exec::ApprovalIntent { - title: "run a command".into(), - body: vec!["echo hi".into()], - name: "shell".into(), - arguments: serde_json::Value::Null, - }); - assert_eq!(resolve_mouse(click, &s), KeyAction::Nothing); - } - - #[test] - fn drag_is_swallowed_once_an_approval_becomes_pending_mid_drag() { - // A drag can only start while no approval is pending (the click that - // starts it goes through `scrollbar_hit`, which is itself gated), but - // an approval can land asynchronously (a chat tool call) while a drag - // started earlier is still in flight. The Drag branch must not keep - // updating the scroll position once that happens — "a pending - // approval owns the body with no exception" applies to an in-flight - // drag, not just to input that starts fresh. - let mut s = AppState::new("t".into(), "default-dark".into()); - s.scrollbars.borrow_mut().push(ScrollbarHandle { - track: Rect::new(60, 0, 1, 10), - horizontal: false, - content_len: 100, - viewport_len: 10, - target: ScrollTarget::Console, - }); - let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); - let a = resolve_mouse(down, &s); - apply_action(&mut s, a); - assert!(s.scroll_drag.is_some(), "drag must have started"); - assert_eq!(s.console_scroll, 90); - - s.open_approval(crate::tool_exec::ApprovalIntent { - title: "run a command".into(), - body: vec!["echo hi".into()], - name: "shell".into(), - arguments: serde_json::Value::Null, - }); - - let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 40, 0); - assert_eq!( - resolve_mouse(drag, &s), - KeyAction::Nothing, - "a drag in flight when an approval becomes pending must be swallowed" - ); - } - - #[test] - fn rendered_thumb_cells_are_grabbable() { - use ratatui::Terminal; - use ratatui::backend::TestBackend; - - for horizontal in [false, true] { - let mut s = AppState::new("t".into(), "default-dark".into()); - let area = if horizontal { - Rect::new(0, 0, 4, 2) - } else { - Rect::new(0, 0, 2, 4) - }; - let target = if horizontal { - ScrollTarget::ConsoleH - } else { - ScrollTarget::Console - }; - if horizontal { - s.console_hscroll = 1; - } else { - s.console_scroll = 1; - } - let backend = TestBackend::new(area.width, area.height); - let mut terminal = Terminal::new(backend).unwrap(); - let mut body = area; - terminal - .draw(|frame| { - body = if horizontal { - crate::ui::panel::horizontal_scrollbar(frame, area, 4, 2, 1, &s.theme) - } else { - crate::ui::panel::vertical_scrollbar(frame, area, 4, 2, 1, &s.theme) - }; - }) - .unwrap(); - s.record_scrollbar(area, body, horizontal, 4, 2, target); - let handle = s.scrollbars.borrow()[0]; - let (thumb_start, thumb_len) = handle.thumb_geometry(1); - assert_eq!((thumb_start, thumb_len), (1, 2)); - for cell in thumb_start..thumb_start + thumb_len { - let (col, row) = if horizontal { - (cell, handle.track.y) - } else { - (handle.track.x, cell) - }; - assert_eq!( - resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), col, row), &s), - KeyAction::ScrollGrab(target, 1, cell - thumb_start) - ); - } - let (before_col, before_row) = if horizontal { - (0, handle.track.y) - } else { - (handle.track.x, 0) - }; - assert_eq!( - resolve_mouse( - wheel( - MouseEventKind::Down(MouseButton::Left), - before_col, - before_row - ), - &s, - ), - KeyAction::ScrollGrab(target, 0, 0) - ); - let (after_col, after_row) = if horizontal { - (3, handle.track.y) - } else { - (handle.track.x, 3) - }; - assert_eq!( - resolve_mouse( - wheel( - MouseEventKind::Down(MouseButton::Left), - after_col, - after_row - ), - &s, - ), - KeyAction::ScrollGrab(target, 2, 0) - ); - } - } - - #[test] - fn stationary_thumb_drag_preserves_logical_position() { - for target in [ScrollTarget::Console, ScrollTarget::DockLogs] { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.console_scroll = 5; - s.dock_logs_scroll = 5; - s.scrollbars.borrow_mut().push(ScrollbarHandle { - track: Rect::new(60, 0, 1, 10), - horizontal: false, - content_len: 20, - viewport_len: 10, - target, - }); - let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 4); - let action = resolve_mouse(down, &s); - apply_action(&mut s, action); - let before = if target == ScrollTarget::DockLogs { - s.dock_logs_scroll - } else { - s.console_scroll - }; - let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 60, 4); - let action = resolve_mouse(drag, &s); - apply_action(&mut s, action); - let after = if target == ScrollTarget::DockLogs { - s.dock_logs_scroll - } else { - s.console_scroll - }; - assert_eq!(after, before, "stationary {target:?} drag moved"); - } - } - - #[test] - fn horizontal_thumb_drag_tracks_pointer_column() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.console_hscroll = 20; - s.scrollbars.borrow_mut().push(ScrollbarHandle { - track: Rect::new(10, 20, 20, 1), - horizontal: true, - content_len: 100, - viewport_len: 20, - target: ScrollTarget::ConsoleH, - }); - let action = resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), 14, 20), &s); - apply_action(&mut s, action); - assert_eq!(s.console_hscroll, 20); - let action = resolve_mouse(wheel(MouseEventKind::Drag(MouseButton::Left), 19, 99), &s); - apply_action(&mut s, action); - assert!(s.console_hscroll > 20); - } - - #[test] - fn dock_scrollbar_grab_inverts_tail_anchored_offset() { - let s = AppState::new("t".into(), "default-dark".into()); - s.scrollbars.borrow_mut().push(ScrollbarHandle { - track: Rect::new(0, 0, 1, 10), - horizontal: false, - content_len: 100, - viewport_len: 10, - target: ScrollTarget::DockLogs, - }); - // Top of the bar = oldest lines = fully scrolled up from the tail (max). - let top = resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), 0, 0), &s); - assert_eq!(top, KeyAction::ScrollGrab(ScrollTarget::DockLogs, 90, 0)); - // Bottom of the bar = newest tail = offset 0. - let bot = resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), 0, 9), &s); - assert_eq!(bot, KeyAction::ScrollGrab(ScrollTarget::DockLogs, 0, 0)); - } - - #[test] - fn wheel_over_actions_list_moves_selection_by_one() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - // Left column (Actions) is the first ~46% — a low column is over the list. - let over_list = wheel(MouseEventKind::ScrollDown, 10, 12); - assert_eq!(resolve_mouse(over_list, &s), KeyAction::Move(1)); - let up = wheel(MouseEventKind::ScrollUp, 10, 12); - assert_eq!(resolve_mouse(up, &s), KeyAction::Move(-1)); - } - - #[test] - fn wheel_over_details_pane_does_not_move_the_list() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Serving; - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - // A high column lands in the Details pane (right ~54%) → no list move. - let over_detail = wheel(MouseEventKind::ScrollDown, 140, 12); - assert_eq!(resolve_mouse(over_detail, &s), KeyAction::Nothing); - } - - #[test] - fn wheel_over_open_console_pans_the_log_not_the_list() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - s.logs_view = Some(crate::ui::logs_view::LogsViewState { - active_job: Some("logs".into()), - ..Default::default() - }); - // Console showing → vertical wheel pans the log (×3 lines), not the list. - assert_eq!( - resolve_mouse(wheel(MouseEventKind::ScrollDown, 10, 12), &s), - KeyAction::ScrollConsole(3, 0) - ); - // Horizontal wheel pans columns (×6) for off-screen-wide log lines. - assert_eq!( - resolve_mouse(wheel(MouseEventKind::ScrollRight, 10, 12), &s), - KeyAction::ScrollConsole(0, 6) - ); - } - - #[test] - fn wheel_over_logs_dock_scrolls_the_dock() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Serving; - // A recorded dock rect off to the right of the body. - s.last_dock_area = Some(Rect::new(160, 4, 52, 30)); - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - let over_dock = wheel(MouseEventKind::ScrollDown, 180, 12); - assert_eq!(resolve_mouse(over_dock, &s), KeyAction::ScrollDock(3)); - // A point outside the dock does not scroll it. - let over_body = wheel(MouseEventKind::ScrollDown, 10, 12); - assert_ne!(resolve_mouse(over_body, &s), KeyAction::ScrollDock(3)); - } - - #[test] - fn scroll_dock_clamps_against_buffer() { - let mut s = AppState::new("t".into(), "default-dark".into()); - // Dock 10 rows tall → ~7 visible lines after border/padding. - s.last_dock_area = Some(Rect::new(0, 0, 52, 10)); - s.jobs.apply(rocm_dash_core::state::StateEvent::StartJob { - id: "logs".into(), - cmd: "rocm".into(), - args: vec!["logs".into()], - }); - for i in 0..20 { - s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { - id: "logs".into(), - line: format!("line {i}"), - }); - } - // 20 lines, ~7 visible → max scroll-up is bounded, never past the top. - s.scroll_dock(100); - assert!(s.dock_logs_scroll <= 13, "clamped: {}", s.dock_logs_scroll); - assert!(s.dock_logs_scroll > 0, "scrolled up some"); - s.scroll_dock(-100); - assert_eq!(s.dock_logs_scroll, 0, "back to the tail"); - } - - #[test] - fn scroll_instance_detail_clamps_to_measured_max() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.instance_detail_max_scroll = 9; - // i16::MAX is the jump-to-end gesture; it must land on the measured - // max, not overflow past it. - s.scroll_instance_detail(i16::MAX); - assert_eq!(s.instance_detail_scroll, 9, "jump-to-end clamps to max"); - // i16::MIN is jump-to-start; it must land on 0, not underflow. - s.scroll_instance_detail(i16::MIN); - assert_eq!(s.instance_detail_scroll, 0, "jump-to-start clamps to 0"); - } - - #[test] - fn instance_detail_scrollbar_click_grabs_drag_scrolls_then_releases() { - // Mirrors `scrollbar_click_grabs_drag_scrolls_then_releases` for the - // instance Detail modal's scrollbar (`instances.rs::render_body` - // registers one for each of its two panes) — proves the - // `ScrollTarget::InstanceDetail` wiring added for mouse-drag support - // actually moves `instance_detail_scroll`, not just that the bar - // renders. - let mut s = AppState::new("t".into(), "default-dark".into()); - s.scrollbars.borrow_mut().push(ScrollbarHandle { - track: Rect::new(60, 0, 1, 10), - horizontal: false, - content_len: 100, - viewport_len: 10, - target: ScrollTarget::InstanceDetail, - }); - let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); - let a = resolve_mouse(down, &s); - assert_eq!( - a, - KeyAction::ScrollGrab(ScrollTarget::InstanceDetail, 90, 0) - ); - apply_action(&mut s, a); - assert_eq!(s.instance_detail_scroll, 90); - let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 40, 0); - let a = resolve_mouse(drag, &s); - assert_eq!(a, KeyAction::ScrollGrab(ScrollTarget::InstanceDetail, 0, 0)); - apply_action(&mut s, a); - assert_eq!(s.instance_detail_scroll, 0); - let up = wheel(MouseEventKind::Up(MouseButton::Left), 40, 0); - let a = resolve_mouse(up, &s); - assert_eq!(a, KeyAction::ScrollRelease); - apply_action(&mut s, a); - assert_eq!(s.scroll_drag, None); - } - - #[test] - fn wheel_over_form_screen_overlay_is_swallowed() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - // Overlay open but on its form (no active_job) → nothing to pan, and the - // obscured Actions list must NOT move. - s.install_manager = Some(crate::ui::install_manager::InstallManagerState::default()); - assert_eq!( - resolve_mouse(wheel(MouseEventKind::ScrollDown, 10, 12), &s), - KeyAction::Nothing - ); - } - - #[test] - fn wheel_is_swallowed_while_an_approval_is_pending() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Rocm; - s.last_body_area = Some(Rect::new(2, 4, 150, 30)); - s.open_approval(crate::tool_exec::ApprovalIntent { - title: "run a command".into(), - body: vec!["echo hi".into()], - name: "shell".into(), - arguments: serde_json::Value::Null, - }); - // `open_approval` clears every manager overlay (`has_open_overlay()` is - // false) but never touches `modal` — the wheel must still be swallowed - // instead of falling through to whatever's obscured underneath, the - // same gap the click path was already fixed for (see - // `body_clicks_are_swallowed_while_an_approval_is_pending`). - assert!(!s.has_open_overlay()); - assert_eq!( - resolve_mouse(wheel(MouseEventKind::ScrollDown, 10, 12), &s), - KeyAction::Nothing - ); - } - - #[test] - fn scroll_console_clamps_and_tracks_output() { - let mut s = AppState::new("t".into(), "default-dark".into()); - // No console → both axes clamp to 0 (no content to pan over). - s.scroll_console(5, 5); - assert_eq!(s.console_scroll, 0); - assert_eq!(s.console_hscroll, 0, "no console → horizontal clamps to 0"); - // With a console of N lines, vertical clamps to N-1. - s.jobs.apply(rocm_dash_core::state::StateEvent::StartJob { - id: "logs".into(), - cmd: "rocm".into(), - args: vec!["logs".into()], - }); - for i in 0..4 { - s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { - id: "logs".into(), - line: format!("line {i}"), - }); - } - s.logs_view = Some(crate::ui::logs_view::LogsViewState { - active_job: Some("logs".into()), - ..Default::default() - }); - s.console_scroll = 0; - s.scroll_console(100, 0); - assert_eq!(s.console_scroll, 3, "clamped to output.len()-1 (4 lines)"); - s.scroll_console(-100, 0); - assert_eq!(s.console_scroll, 0); - // Horizontal clamps to the widest line minus one ("line 0" = 6 chars). - s.scroll_console(0, 100); - assert_eq!(s.console_hscroll, 5, "clamped to max line width - 1"); - s.scroll_console(0, -100); - assert_eq!(s.console_hscroll, 0); - } - - #[test] - fn console_scroll_delta_maps_nav_keys_only() { - use crossterm::event::KeyCode; - assert_eq!(console_scroll_delta(KeyCode::PageDown), Some((10, 0))); - assert_eq!(console_scroll_delta(KeyCode::PageUp), Some((-10, 0))); - assert_eq!(console_scroll_delta(KeyCode::Down), Some((1, 0))); - assert_eq!(console_scroll_delta(KeyCode::Right), Some((0, 4))); - // Console action keys are NOT scroll keys (they reach on_console_key). - assert_eq!(console_scroll_delta(KeyCode::Esc), None); - assert_eq!(console_scroll_delta(KeyCode::Enter), None); - assert_eq!(console_scroll_delta(KeyCode::Char('q')), None); - } - - #[test] - fn footer_chip_hit_maps_click_to_action() { - let chips = vec![ - FooterChip { - x0: 0, - x1: 5, - y: 49, - action: KeyAction::Quit, - }, - FooterChip { - x0: 6, - x1: 9, - y: 49, - action: KeyAction::ToggleHelp, - }, - ]; - // Inside the first chip. - assert_eq!(footer_chip_hit(&chips, 2, 49), Some(KeyAction::Quit)); - // End-exclusive: column 5 is past the first chip, before the second. - assert_eq!(footer_chip_hit(&chips, 5, 49), None); - assert_eq!(footer_chip_hit(&chips, 7, 49), Some(KeyAction::ToggleHelp)); - // Wrong row never matches. - assert_eq!(footer_chip_hit(&chips, 2, 48), None); - } - - #[test] - fn back_tab_and_shift_tab_both_cycle_backward() { - // prev(Observe) = Serving in the 5-tab IA. - assert_eq!( - hk(KeyCode::BackTab, ActiveTab::Observe), - KeyAction::SwitchTab(ActiveTab::Serving) - ); - // Home's previous tab is Chat (the last tab). - assert_eq!( - hk(KeyCode::BackTab, ActiveTab::Home), - KeyAction::SwitchTab(ActiveTab::Chat) - ); - let shift_tab = KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT); - assert_eq!( - handle_key( - shift_tab, - ActiveTab::Rocm, - &Modal::None, - ChatKeyCtx::default() - ), - KeyAction::SwitchTab(ActiveTab::Home) - ); - } - - #[test] - fn number_keys_jump_to_tab() { - // 5-tab: '1'→Home, '2'→ROCm, '3'→Serving, '4'→Observe, '5'→Chat. - assert_eq!( - hk(KeyCode::Char('1'), ActiveTab::Home), - KeyAction::SwitchTab(ActiveTab::Home) - ); - assert_eq!( - hk(KeyCode::Char('2'), ActiveTab::Home), - KeyAction::SwitchTab(ActiveTab::Rocm) - ); - assert_eq!( - hk(KeyCode::Char('3'), ActiveTab::Home), - KeyAction::SwitchTab(ActiveTab::Serving) - ); - assert_eq!( - hk(KeyCode::Char('4'), ActiveTab::Home), - KeyAction::SwitchTab(ActiveTab::Observe) - ); - // `5` reaches the Chat tab (digit guard widened to '1'..='5'). - assert_eq!( - hk(KeyCode::Char('5'), ActiveTab::Home), - KeyAction::SwitchTab(ActiveTab::Chat) - ); - assert_eq!(hk(KeyCode::Char('6'), ActiveTab::Home), KeyAction::Nothing); - } - - #[test] - fn from_digit_maps_five_to_chat() { - // 5-tab digit map; Chat is now '5', '6'/'0' are out of range. - assert_eq!(ActiveTab::from_digit('1'), Some(ActiveTab::Home)); - assert_eq!(ActiveTab::from_digit('5'), Some(ActiveTab::Chat)); - assert_eq!(ActiveTab::from_digit('6'), None); - assert_eq!(ActiveTab::from_digit('0'), None); - } - - #[test] - fn release_events_are_ignored() { - let release = KeyEvent::new_with_kind( - KeyCode::Char('q'), - KeyModifiers::NONE, - KeyEventKind::Release, - ); - assert_eq!( - handle_key( - release, - ActiveTab::Home, - &Modal::None, - ChatKeyCtx::default() - ), - KeyAction::Nothing - ); - } - - #[test] - fn only_press_key_events_are_actionable() { - // The event loop gates overlay dispatch on this predicate so a single - // keystroke isn't processed twice by an overlay's `on_key` (Release / - // Repeat echoes on Windows Terminal / ConPTY / kitty keyboard). The - // double-fire re-opened the serve wizard's model picker on Enter instead - // of choosing — this pins Press-only routing. - assert!(is_actionable_key(KeyEventKind::Press)); - assert!(!is_actionable_key(KeyEventKind::Release)); - assert!(!is_actionable_key(KeyEventKind::Repeat)); - } - - #[test] - fn jk_arrows_and_g_drive_selection() { - assert_eq!( - hk(KeyCode::Char('j'), ActiveTab::Observe), - KeyAction::Move(1) - ); - assert_eq!( - hk(KeyCode::Char('k'), ActiveTab::Observe), - KeyAction::Move(-1) - ); - assert_eq!(hk(KeyCode::Down, ActiveTab::Rocm), KeyAction::Move(1)); - assert_eq!(hk(KeyCode::Up, ActiveTab::Rocm), KeyAction::Move(-1)); - assert_eq!( - hk(KeyCode::Char('g'), ActiveTab::Observe), - KeyAction::SelectFirst - ); - assert_eq!( - hk(KeyCode::Char('G'), ActiveTab::Observe), - KeyAction::SelectLast - ); - assert_eq!( - hk(KeyCode::Enter, ActiveTab::Observe), - KeyAction::OpenDetail - ); - } - - #[test] - fn operational_open_keys_are_tab_scoped() { - // `s` opens services only on Observe; Nothing elsewhere. - assert_eq!( - hk(KeyCode::Char('s'), ActiveTab::Observe), - KeyAction::OpenServices - ); - assert_eq!(hk(KeyCode::Char('s'), ActiveTab::Home), KeyAction::Nothing); - // The letter hotkeys fire ONLY on Observe now — quick jumps into the - // managers. They open the matching overlay via the seam. - assert_eq!( - hk(KeyCode::Char('w'), ActiveTab::Observe), - KeyAction::OpenServeWizard - ); - assert_eq!( - hk(KeyCode::Char('e'), ActiveTab::Observe), - KeyAction::OpenEngineManager - ); - assert_eq!( - hk(KeyCode::Char('d'), ActiveTab::Observe), - KeyAction::OpenExamine - ); - assert_eq!( - hk(KeyCode::Char('i'), ActiveTab::Observe), - KeyAction::OpenInstall - ); - // Retired on the domain tabs: the Actions list is the single path there, - // so the letter hotkeys are inert on ROCm/Serving (and Home/Chat). - for c in ['w', 'e', 'd', 'u', 'i', 'l', 'r', 'n', 'a', 'c', 'p', 's'] { - assert_eq!( - hk(KeyCode::Char(c), ActiveTab::Rocm), - KeyAction::Nothing, - "key {c} must be retired on the ROCm tab" - ); - assert_eq!( - hk(KeyCode::Char(c), ActiveTab::Serving), - KeyAction::Nothing, - "key {c} must be retired on the Serving tab" - ); - } - assert_eq!(hk(KeyCode::Char('w'), ActiveTab::Home), KeyAction::Nothing); - // On the Chat tab none of these open an overlay. `i` means insert mode. - for c in ['w', 'e', 'd', 'u', 'l'] { - assert_eq!( - hk(KeyCode::Char(c), ActiveTab::Chat), - KeyAction::Nothing, - "key {c} must not open an overlay from Chat" - ); - } - assert_eq!( - hk(KeyCode::Char('i'), ActiveTab::Chat), - KeyAction::ChatFocus, - "i is chat-insert on Chat, never OpenInstall" - ); - } - - #[test] - fn startup_focus_gate_only_opens_onboarding_for_explicit_setup_focus() { - // Regression guard: this calls `apply_startup_focus`, the same gate - // `event_loop` uses, not just `AppState::new` — which takes no focus - // argument and hardcodes `onboarding: None` regardless, so it cannot - // exhibit an auto-open regression either way. - let mut s = st(); - assert!(apply_startup_focus(&mut s, None).is_empty()); - assert!( - s.onboarding.is_none(), - "no --focus flag must not open onboarding" - ); - - let mut s = st(); - assert!(apply_startup_focus(&mut s, Some(Focus::Setup)).is_empty()); - assert!( - s.onboarding.is_some(), - "an explicit Focus::Setup must open onboarding" - ); - } - - #[test] - fn opening_an_overlay_closes_the_others() { - let mut s = AppState::new("t".into(), "default-dark".into()); - apply_action(&mut s, KeyAction::OpenServices); - assert!(s.services.is_some() && s.serve_wizard.is_none() && s.engine_manager.is_none()); - // Opening another overlay (defensive path) clears the prior one. - apply_action(&mut s, KeyAction::OpenServeWizard); - assert!(s.serve_wizard.is_some() && s.services.is_none() && s.engine_manager.is_none()); - apply_action(&mut s, KeyAction::OpenEngineManager); - assert!(s.engine_manager.is_some() && s.services.is_none() && s.serve_wizard.is_none()); - // Wave 2/3 overlays join the mutual-exclusion set. - apply_action(&mut s, KeyAction::OpenExamine); - assert!(s.examine_manager.is_some() && s.engine_manager.is_none()); - apply_action(&mut s, KeyAction::OpenUpdate); - assert!(s.update_manager.is_some() && s.examine_manager.is_none()); - apply_action(&mut s, KeyAction::OpenInstall); - assert!(s.install_manager.is_some() && s.update_manager.is_none()); - apply_action(&mut s, KeyAction::OpenLogs); - assert!(s.logs_view.is_some() && s.install_manager.is_none()); - apply_action(&mut s, KeyAction::OpenRuntimes); - assert!(s.runtime_manager.is_some() && s.logs_view.is_none()); - apply_action(&mut s, KeyAction::OpenOnboarding); - assert!(s.onboarding.is_some() && s.runtime_manager.is_none()); - apply_action(&mut s, KeyAction::OpenAutomations); - assert!(s.automations_manager.is_some() && s.onboarding.is_none()); - apply_action(&mut s, KeyAction::OpenCommand); - assert!(s.command_screen.is_some() && s.automations_manager.is_none()); - apply_action(&mut s, KeyAction::OpenConfig); - assert!(s.config_manager.is_some() && s.command_screen.is_none()); - // T13: OpenBenchRun joins the mutual-exclusion set. - apply_action(&mut s, KeyAction::OpenBenchRun); - assert!(s.bench_run.is_some() && s.config_manager.is_none()); - } - - // ---------- T13: bench_run overlay invariants ---------- - - #[test] - fn t13_bench_run_in_has_open_overlay() { - let mut s = AppState::new("t".into(), "default-dark".into()); - assert!(!s.has_open_overlay(), "no overlay initially"); - s.bench_run = Some(crate::ui::bench_run::BenchRunState::new(None)); - assert!( - s.has_open_overlay(), - "bench_run must be in has_open_overlay" - ); - } + #[test] + fn t13_bench_run_in_has_open_overlay() { + let mut s = AppState::new("t".into(), "default-dark".into()); + assert!(!s.has_open_overlay(), "no overlay initially"); + s.bench_run = Some(crate::ui::bench_run::BenchRunState::new(None)); + assert!( + s.has_open_overlay(), + "bench_run must be in has_open_overlay" + ); + } #[test] fn t13_bench_run_cleared_by_close_overlays() { @@ -6086,106 +1549,6 @@ mod tests { assert!(active_job.is_none()); } - #[test] - fn esc_opens_menu_when_idle_on_any_tab() { - // Idle tabs: Esc opens the btop main menu, Chat included when unfocused - // (Chat-focused Esc is handled by the short-circuit above this match). - assert_eq!(hk(KeyCode::Esc, ActiveTab::Home), KeyAction::OpenMenu); - assert_eq!(hk(KeyCode::Esc, ActiveTab::Observe), KeyAction::OpenMenu); - assert_eq!(hk(KeyCode::Esc, ActiveTab::Chat), KeyAction::OpenMenu); - // While an overlay modal owns the screen, Esc closes it (not OpenMenu). - assert_eq!( - handle_key( - press(KeyCode::Esc), - ActiveTab::Home, - &Modal::Menu, - ChatKeyCtx::default() - ), - KeyAction::CloseModal - ); - assert_eq!( - handle_key( - press(KeyCode::Esc), - ActiveTab::Home, - &Modal::Options, - ChatKeyCtx::default() - ), - KeyAction::CloseModal - ); - } - - #[test] - fn colon_opens_command_palette() { - assert_eq!( - hk(KeyCode::Char(':'), ActiveTab::Home), - KeyAction::OpenPalette - ); - } - - #[test] - fn menu_navigation_and_activation() { - let mut s = AppState::new("t".into(), "default-dark".into()); - apply_action(&mut s, KeyAction::OpenMenu); - assert_eq!(s.modal, Modal::Menu); - // ↓ from Options(0) → Help(1); activate opens the global help. - apply_action(&mut s, KeyAction::MenuMove(1)); - assert_eq!(s.menu_sel, 1); - apply_action(&mut s, KeyAction::MenuActivate); - assert_eq!(s.modal, Modal::GlobalHelp); - // Menu → Options activation opens the Options panel. - apply_action(&mut s, KeyAction::OpenMenu); - apply_action(&mut s, KeyAction::MenuActivate); // sel 0 = Options - assert_eq!(s.modal, Modal::Options); - // Options tab cycles and wraps. - apply_action(&mut s, KeyAction::OptionsTab(-1)); - assert_eq!(s.options_tab, crate::ui::modal::OPTIONS_TABS.len() - 1); - } - - #[test] - fn palette_activation_switches_tab() { - let mut s = AppState::new("t".into(), "default-dark".into()); - apply_action(&mut s, KeyAction::OpenPalette); - apply_action(&mut s, KeyAction::MenuMove(3)); // Home→ROCm→Serving→Observe - apply_action(&mut s, KeyAction::MenuActivate); - assert_eq!(s.active_tab, ActiveTab::Observe); - assert_eq!(s.modal, Modal::None); - } - - #[test] - fn question_mark_toggles_help() { - assert_eq!( - hk(KeyCode::Char('?'), ActiveTab::Home), - KeyAction::ToggleHelp - ); - } - - #[test] - fn t_opens_theme_picker() { - assert_eq!( - hk(KeyCode::Char('t'), ActiveTab::Home), - KeyAction::OpenThemePicker - ); - } - - #[test] - fn theme_picker_absorbs_navigation_keys() { - let with_picker = |c| { - handle_key( - press(c), - ActiveTab::Home, - &Modal::ThemePicker, - ChatKeyCtx::default(), - ) - }; - assert_eq!(with_picker(KeyCode::Char('j')), KeyAction::Move(1)); - assert_eq!(with_picker(KeyCode::Char('k')), KeyAction::Move(-1)); - assert_eq!(with_picker(KeyCode::Enter), KeyAction::ApplyThemePick); - assert_eq!(with_picker(KeyCode::Esc), KeyAction::CloseModal); - assert_eq!(with_picker(KeyCode::Char('t')), KeyAction::CloseModal); - assert_eq!(with_picker(KeyCode::Char('q')), KeyAction::Quit); - assert_eq!(with_picker(KeyCode::Char('1')), KeyAction::Nothing); - } - #[test] fn open_theme_picker_places_cursor_on_active_theme() { let mut s = AppState::new("test".into(), "dracula".into()); @@ -6221,110 +1584,6 @@ mod tests { assert_eq!(s.theme.bg, nord.bg); } - #[test] - fn tab_bar_hit_matches_per_chip_extents() { - // 5-tab layout: Home 0..10, ROCm 11..21, Serving 22..35, Observe 36..49, - // Chat 50..60. - let bar = Rect::new(0, 0, 80, 1); - assert_eq!(tab_bar_hit(bar, 5, 0), Some(ActiveTab::Home)); - assert_eq!(tab_bar_hit(bar, 15, 0), Some(ActiveTab::Rocm)); - assert_eq!(tab_bar_hit(bar, 28, 0), Some(ActiveTab::Serving)); - assert_eq!(tab_bar_hit(bar, 42, 0), Some(ActiveTab::Observe)); - assert_eq!(tab_bar_hit(bar, 55, 0), Some(ActiveTab::Chat)); - // Separator gap between Home (ends 10 excl.) and ROCm (starts 11). - assert_eq!(tab_bar_hit(bar, 10, 0), None); - // Wrong row. - assert_eq!(tab_bar_hit(bar, 5, 2), None); - } - - #[test] - fn tab_bar_hit_skips_chips_that_overflow_a_narrow_bar() { - // Bar can only fit the first two chips (ROCm ends at 21). - let bar = Rect::new(0, 0, 25, 1); - assert_eq!(tab_bar_hit(bar, 5, 0), Some(ActiveTab::Home)); - assert_eq!(tab_bar_hit(bar, 15, 0), Some(ActiveTab::Rocm)); - // Serving chip would be at 22..35 — overflows the 25-wide bar → None. - assert_eq!(tab_bar_hit(bar, 28, 0), None); - } - - #[test] - fn tab_bar_hit_honors_x_offset() { - // Bar offset 10 columns to the right: Home chip now spans 10..19. - let bar = Rect::new(10, 0, 80, 1); - assert_eq!(tab_bar_hit(bar, 15, 0), Some(ActiveTab::Home)); - // Absolute x=5 is left of the offset bar. - assert_eq!(tab_bar_hit(bar, 5, 0), None); - } - - #[test] - fn handle_mouse_routes_scroll_by_modal_and_tab() { - let scroll_down = MouseEvent { - kind: MouseEventKind::ScrollDown, - column: 0, - row: 0, - modifiers: KeyModifiers::NONE, - }; - // No modal, non-interactive tab → Nothing - assert_eq!( - handle_mouse(scroll_down, &Modal::None, ActiveTab::Home), - KeyAction::Nothing - ); - // No modal, Observe → Move by ONE (drives the instances selection) - assert_eq!( - handle_mouse(scroll_down, &Modal::None, ActiveTab::Observe), - KeyAction::Move(1) - ); - // Detail modal → ScrollModal by one line - assert_eq!( - handle_mouse(scroll_down, &Modal::Detail, ActiveTab::Observe), - KeyAction::ScrollModal(1) - ); - // Help / GlobalHelp popups are sized to fit their content — nothing to - // scroll, so the wheel is a no-op there. - assert_eq!( - handle_mouse(scroll_down, &Modal::Help, ActiveTab::Home), - KeyAction::Nothing - ); - assert_eq!( - handle_mouse(scroll_down, &Modal::GlobalHelp, ActiveTab::Home), - KeyAction::Nothing - ); - // ThemePicker → Move (drives picker cursor) - assert_eq!( - handle_mouse(scroll_down, &Modal::ThemePicker, ActiveTab::Home), - KeyAction::Move(1) - ); - // Domain-tab scroll is NOT routed here (resolve_mouse owns it) → Nothing. - assert_eq!( - handle_mouse(scroll_down, &Modal::None, ActiveTab::Rocm), - KeyAction::Nothing - ); - } - - #[test] - fn detail_modal_j_k_emit_scroll() { - let with_detail = |c| { - handle_key( - press(c), - ActiveTab::Observe, - &Modal::Detail, - ChatKeyCtx::default(), - ) - }; - assert_eq!(with_detail(KeyCode::Char('j')), KeyAction::ScrollModal(1)); - assert_eq!(with_detail(KeyCode::Char('k')), KeyAction::ScrollModal(-1)); - assert_eq!(with_detail(KeyCode::PageDown), KeyAction::ScrollModal(10)); - assert_eq!( - with_detail(KeyCode::Char('g')), - KeyAction::ScrollModal(i16::MIN) - ); - assert_eq!( - with_detail(KeyCode::Char('G')), - KeyAction::ScrollModal(i16::MAX) - ); - assert_eq!(with_detail(KeyCode::Esc), KeyAction::CloseModal); - } - #[test] fn unknown_initial_theme_falls_back_to_default_dark() { let s = AppState::new("test".into(), "nope".into()); @@ -6332,44 +1591,6 @@ mod tests { assert_eq!(s.theme.bg, dark.bg); } - #[test] - fn help_modal_absorbs_navigation() { - let with_help = |c| { - handle_key( - press(c), - ActiveTab::Observe, - &Modal::Help, - ChatKeyCtx::default(), - ) - }; - // The popup is sized to fit its content, so navigation keys are inert. - assert_eq!(with_help(KeyCode::Char('j')), KeyAction::Nothing); - assert_eq!(with_help(KeyCode::Char('k')), KeyAction::Nothing); - assert_eq!(with_help(KeyCode::Tab), KeyAction::Nothing); - assert_eq!(with_help(KeyCode::Esc), KeyAction::CloseModal); - assert_eq!(with_help(KeyCode::Enter), KeyAction::CloseModal); - assert_eq!(with_help(KeyCode::Char('q')), KeyAction::Quit); - } - - #[test] - fn global_help_modal_absorbs_navigation() { - let with_global_help = |c| { - handle_key( - press(c), - ActiveTab::Observe, - &Modal::GlobalHelp, - ChatKeyCtx::default(), - ) - }; - assert_eq!(with_global_help(KeyCode::Char('j')), KeyAction::Nothing); - assert_eq!(with_global_help(KeyCode::Char('k')), KeyAction::Nothing); - assert_eq!(with_global_help(KeyCode::PageDown), KeyAction::Nothing); - assert_eq!(with_global_help(KeyCode::Char('g')), KeyAction::Nothing); - assert_eq!(with_global_help(KeyCode::Char('G')), KeyAction::Nothing); - assert_eq!(with_global_help(KeyCode::Esc), KeyAction::CloseModal); - assert_eq!(with_global_help(KeyCode::Char('q')), KeyAction::Quit); - } - #[test] fn move_selection_clamps_to_bounds() { // P3: Observe's selectable list is the instances table. @@ -6395,239 +1616,34 @@ mod tests { assert_eq!(s.instance_sel, 4); } - #[test] - fn format_mmss_renders_minutes_and_hours() { - assert_eq!(format_mmss(0), "0:00"); - assert_eq!(format_mmss(7), "0:07"); - assert_eq!(format_mmss(65), "1:05"); - assert_eq!(format_mmss(599), "9:59"); - assert_eq!(format_mmss(3600), "1:00:00"); - assert_eq!(format_mmss(3661), "1:01:01"); - } - - #[test] - fn bracket_keys_emit_replay_jump() { - assert_eq!( - hk(KeyCode::Char('['), ActiveTab::Home), - KeyAction::ReplayJump(-10) - ); - assert_eq!( - hk(KeyCode::Char(']'), ActiveTab::Home), - KeyAction::ReplayJump(10) - ); - assert_eq!( - hk(KeyCode::Char('{'), ActiveTab::Home), - KeyAction::ReplayJump(-60) - ); - assert_eq!( - hk(KeyCode::Char('}'), ActiveTab::Home), - KeyAction::ReplayJump(60) - ); - } - #[test] fn reset_for_seek_clears_event_derived_state() { let mut s = AppState::new("t".into(), "default-dark".into()); - s.history - .push_back(rocm_dash_core::metrics::Snapshot::default()); - s.latest = Some(rocm_dash_core::metrics::Snapshot::default()); - s.bench_rows.push_back(BenchmarkRow::default()); - s.reset_for_seek(); - assert!(s.history.is_empty()); - assert!(s.latest.is_none()); - assert!(s.bench_rows.is_empty()); - assert!(s.instances.is_empty()); - } - - #[test] - fn selectors_reclamp_after_pop() { - let mut s = AppState::new("test".into(), "default-dark".into()); - s.active_tab = ActiveTab::Observe; - for i in 0..3 { - s.bench_rows.push_back(BenchmarkRow { - cell: format!("c{i}"), - ..Default::default() - }); - } - s.bench_sel = 2; - s.bench_rows.clear(); - s.clamp_selectors(); - assert_eq!(s.bench_sel, 0); - } - - #[test] - fn chat_insert_mode_captures_text_and_shortcircuits_hotkeys() { - let accepted_focused = ChatKeyCtx { - focused: true, - consent: ChatConsent::Accepted, - offer_pending: false, - }; - let focused = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, accepted_focused); - // Printable chars become input, including ones that are global hotkeys. - assert_eq!(focused(KeyCode::Char('h')), KeyAction::ChatInput('h')); - assert_eq!(focused(KeyCode::Char('q')), KeyAction::ChatInput('q')); - assert_eq!(focused(KeyCode::Char('5')), KeyAction::ChatInput('5')); - assert_eq!(focused(KeyCode::Backspace), KeyAction::ChatBackspace); - assert_eq!(focused(KeyCode::Enter), KeyAction::ChatSubmit); - assert_eq!(focused(KeyCode::Esc), KeyAction::ChatBlur); - - // Accepted but NOT focused: `q` still quits and `i`/Enter enter insert mode. - let accepted = ChatKeyCtx { - focused: false, - consent: ChatConsent::Accepted, - offer_pending: false, - }; - let unfocused = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, accepted); - assert_eq!(unfocused(KeyCode::Char('q')), KeyAction::Quit); - assert_eq!(unfocused(KeyCode::Char('i')), KeyAction::ChatFocus); - assert_eq!(unfocused(KeyCode::Enter), KeyAction::ChatFocus); - assert_eq!( - unfocused(KeyCode::Char('1')), - KeyAction::SwitchTab(ActiveTab::Home) - ); - } - - #[test] - fn chat_consent_gate_maps_keys_and_lets_globals_through() { - let pending = ChatKeyCtx { - focused: false, - consent: ChatConsent::Pending, - offer_pending: false, - }; - let gate = |c| handle_key(press(c), ActiveTab::Chat, &Modal::None, pending); - // y / Y / Enter accept; n / N decline. - assert_eq!(gate(KeyCode::Char('y')), KeyAction::ChatConsentAccept); - assert_eq!(gate(KeyCode::Enter), KeyAction::ChatConsentAccept); - assert_eq!(gate(KeyCode::Char('n')), KeyAction::ChatConsentDecline); - // Globals not trapped by the gate: q quits, digit switches tab. - assert_eq!(gate(KeyCode::Char('q')), KeyAction::Quit); - assert_eq!( - gate(KeyCode::Char('2')), - KeyAction::SwitchTab(ActiveTab::Rocm) - ); - } - - #[test] - fn chat_consent_accept_and_decline_transition_state() { - let mut s = AppState::new("t".into(), "default-dark".into()); - // No endpoint → Unavailable; accept/decline are no-ops. - s.set_chat_config(None, false); - assert_eq!(s.chat_consent, ChatConsent::Unavailable); - apply_action(&mut s, KeyAction::ChatConsentAccept); - assert_eq!(s.chat_consent, ChatConsent::Unavailable); - - // Endpoint present, no pre-consent → Pending. - let llm = crate::llm::LlmConfig { - base_url: "http://127.0.0.1:8000".into(), - model: "m".into(), - api_key: None, - auth_header: None, - }; - s.set_chat_config(Some(llm.clone()), false); - assert_eq!(s.chat_consent, ChatConsent::Pending); - // Accept → Accepted + focused. - apply_action(&mut s, KeyAction::ChatConsentAccept); - assert_eq!(s.chat_consent, ChatConsent::Accepted); - assert!(s.chat_focused); - // Decline → Declined + unfocused. - apply_action(&mut s, KeyAction::ChatConsentDecline); - assert_eq!(s.chat_consent, ChatConsent::Declined); - assert!(!s.chat_focused); - - // Pre-consent → Accepted immediately. - s.set_chat_config(Some(llm), true); - assert_eq!(s.chat_consent, ChatConsent::Accepted); - } - - #[test] - fn detect_offer_lifecycle_accept_switches_chat() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Chat; - // Gateway-configured chat, pending consent. - let gw = crate::llm::LlmConfig { - base_url: "https://gw/OpenAI".into(), - model: "gpt-4o-mini".into(), - api_key: Some("k".into()), - auth_header: Some("Ocp-Apim-Subscription-Key".into()), - }; - s.set_chat_config(Some(gw), false); - // Simulate a prior `/provider openai` so the realignment to Local on - // accept is observable (Local is the default, so starting there would - // make the assertion below tautological). - s.active_provider = ChatProvider::Openai; - - // request_detect raises the dispatch edge + detecting flag. - apply_action(&mut s, KeyAction::ChatDetect); - assert!(s.chat_detecting && s.chat_detect_dispatch); - - // event_loop reports a detected local engine. - let local = crate::llm::detected_llm_config("http://localhost:13305/v1", "Llama-3.2-3B"); - s.set_detect_result(Some(local.clone())); - assert!(!s.chat_detecting); - assert_eq!(s.chat_detect_offer.as_ref(), Some(&local)); - - // Accept the offer → chat switches to the local endpoint + enabled. - apply_action(&mut s, KeyAction::ChatDetectAccept); - assert_eq!(s.chat_consent, ChatConsent::Accepted); - assert_eq!(s.chat_llm.as_ref(), Some(&local)); - assert!(s.chat_detect_offer.is_none()); - assert_eq!( - s.chat_endpoint_rebuild, - Some(ChatProvider::Openai), - "accept raises the rebuild edge carrying the previous provider" - ); - assert_eq!(s.active_provider, ChatProvider::Local); - } - - #[test] - fn detect_offer_dismiss_keeps_prior_config() { - let mut s = AppState::new("t".into(), "default-dark".into()); - let gw = crate::llm::LlmConfig { - base_url: "https://gw/OpenAI".into(), - model: "gpt-4o-mini".into(), - api_key: None, - auth_header: None, - }; - s.set_chat_config(Some(gw.clone()), false); - s.set_detect_result(Some(crate::llm::detected_llm_config( - "http://localhost:8000/v1", - "x", - ))); - // Dismiss → offer gone, gateway config + Pending consent intact. - apply_action(&mut s, KeyAction::ChatDetectDismiss); - assert!(s.chat_detect_offer.is_none()); - assert_eq!(s.chat_llm.as_ref(), Some(&gw)); - assert_eq!(s.chat_consent, ChatConsent::Pending); + s.history + .push_back(rocm_dash_core::metrics::Snapshot::default()); + s.latest = Some(rocm_dash_core::metrics::Snapshot::default()); + s.bench_rows.push_back(BenchmarkRow::default()); + s.reset_for_seek(); + assert!(s.history.is_empty()); + assert!(s.latest.is_none()); + assert!(s.bench_rows.is_empty()); + assert!(s.instances.is_empty()); } #[test] - fn save_detect_offer_accepts_and_raises_persist_edge() { - let mut s = AppState::new("t".into(), "default-dark".into()); - // Start off-Local so the realignment on accept is observable (Local is - // the default provider; asserting it without this would be tautological). - s.active_provider = ChatProvider::Openai; - s.set_detect_result(Some(crate::llm::detected_llm_config( - "http://localhost:13305/v1", - "Llama-3.2-3B", - ))); - apply_action(&mut s, KeyAction::ChatDetectSave); - assert_eq!(s.chat_consent, ChatConsent::Accepted); - assert!(s.chat_persist_dispatch, "save raises the persist edge"); - assert_eq!( - s.chat_endpoint_rebuild, - Some(ChatProvider::Openai), - "save also raises the rebuild edge carrying the previous provider" - ); - assert_eq!(s.active_provider, ChatProvider::Local); - assert_eq!( - s.chat_llm.as_ref().map(|c| c.base_url.as_str()), - Some("http://localhost:13305/v1") - ); - // No offer → save is a no-op (no edge). - let mut s2 = AppState::new("t".into(), "default-dark".into()); - apply_action(&mut s2, KeyAction::ChatDetectSave); - assert!(!s2.chat_persist_dispatch); - assert!(s2.chat_endpoint_rebuild.is_none()); + fn selectors_reclamp_after_pop() { + let mut s = AppState::new("test".into(), "default-dark".into()); + s.active_tab = ActiveTab::Observe; + for i in 0..3 { + s.bench_rows.push_back(BenchmarkRow { + cell: format!("c{i}"), + ..Default::default() + }); + } + s.bench_sel = 2; + s.bench_rows.clear(); + s.clamp_selectors(); + assert_eq!(s.bench_sel, 0); } #[test] @@ -6781,111 +1797,6 @@ mod tests { assert!(last.content.contains("no detected endpoint")); } - #[test] - fn detect_key_available_on_gate_and_offer_keys_take_precedence() { - // `d` triggers detect from the Unavailable empty-state. - let unavail = ChatKeyCtx { - focused: false, - consent: ChatConsent::Unavailable, - offer_pending: false, - }; - assert_eq!( - handle_key( - press(KeyCode::Char('d')), - ActiveTab::Chat, - &Modal::None, - unavail - ), - KeyAction::ChatDetect - ); - // With an offer pending, y/n map to the offer (not consent). - let offering = ChatKeyCtx { - focused: false, - consent: ChatConsent::Pending, - offer_pending: true, - }; - assert_eq!( - handle_key( - press(KeyCode::Char('y')), - ActiveTab::Chat, - &Modal::None, - offering - ), - KeyAction::ChatDetectAccept - ); - assert_eq!( - handle_key( - press(KeyCode::Char('n')), - ActiveTab::Chat, - &Modal::None, - offering - ), - KeyAction::ChatDetectDismiss - ); - } - - #[test] - fn chat_input_actions_mutate_buffer() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.active_tab = ActiveTab::Chat; - s.chat_focused = true; - apply_action(&mut s, KeyAction::ChatInput('h')); - apply_action(&mut s, KeyAction::ChatInput('i')); - assert_eq!(s.chat_input, "hi"); - apply_action(&mut s, KeyAction::ChatBackspace); - assert_eq!(s.chat_input, "h"); - apply_action(&mut s, KeyAction::ChatBlur); - assert!(!s.chat_focused); - apply_action(&mut s, KeyAction::ChatFocus); - assert!(s.chat_focused); - } - - #[test] - fn chat_submit_pushes_user_turn_and_raises_dispatch() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_input = "what's GPU-2 doing?".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - // Only the user turn is pushed; the agent reply arrives async. - assert_eq!(s.chat.len(), 1); - assert_eq!(s.chat[0].role, ChatRole::User); - assert_eq!(s.chat[0].content, "what's GPU-2 doing?"); - assert!(s.chat_input.is_empty()); - assert!(s.chat_sending, "submit marks the request in flight"); - assert!(s.chat_dispatch, "submit raises the spawn edge"); - } - - #[test] - fn chat_submit_ignores_empty_input() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_input = " ".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - assert!(s.chat.is_empty()); - assert!(!s.chat_sending); - assert!(!s.chat_dispatch); - } - - #[test] - fn chat_submit_ignored_while_request_in_flight() { - // A second submit before the first reply lands must be a no-op — no - // second user turn, no second spawn (prevents a racing double request). - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_input = "first".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - assert!(s.chat_sending); - assert_eq!(s.chat.len(), 1); - s.chat_dispatch = false; // simulate event_loop consuming the edge - s.chat_input = "second".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - assert_eq!(s.chat.len(), 1, "second submit ignored while in flight"); - assert!(!s.chat_dispatch, "no second dispatch edge raised"); - // After the reply clears the flag, submits work again. - s.on_chat_reply("done".into()); - assert!(!s.chat_sending); - s.chat_input = "third".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - assert!(s.chat_dispatch); - } - // --- Slash-command dispatch (Phase 3 nav/session + read-only) --- fn st() -> AppState { @@ -6901,59 +1812,6 @@ mod tests { assert!(args_with_anthropic_key(None).focus.is_none()); } - #[test] - fn should_skip_daemon_predicate_matches_focus() { - // The dashboard (focus=None) keeps the daemon client + chat backend; any - // focus skips both. The render branch reuses this same predicate. - assert!(!should_skip_daemon(None)); - assert!(should_skip_daemon(Some(Focus::Setup))); - assert!(should_skip_daemon(Some(Focus::Serve))); - assert!(should_skip_daemon(Some(Focus::Examine))); - // focus=None never self-exits — the dash loop only breaks on Quit/EOF. - assert!(!st().focused_should_exit(None)); - } - - #[test] - fn open_overlay_for_focus_opens_the_right_overlay() { - let mut s = st(); - assert!(open_overlay_for_focus(&mut s, Focus::Setup).is_empty()); - assert!(s.onboarding.is_some()); - assert!(s.serve_wizard.is_none() && s.examine_manager.is_none()); - - let mut s = st(); - assert!(open_overlay_for_focus(&mut s, Focus::Serve).is_empty()); - assert!(s.serve_wizard.is_some()); - assert!(s.onboarding.is_none() && s.examine_manager.is_none()); - - let mut s = st(); - let fx = open_overlay_for_focus(&mut s, Focus::Examine); - assert!(s.examine_manager.is_some()); - assert!(s.onboarding.is_none() && s.serve_wizard.is_none()); - assert_eq!(fx.len(), 1, "examine auto-runs on open"); - } - - #[test] - fn focused_examine_auto_runs_rocm_examine() { - let mut s = st(); - let fx = open_overlay_for_focus(&mut s, Focus::Examine); - assert_eq!(fx.len(), 1, "exactly one spawn side effect on open"); - match &fx[0] { - rocm_dash_core::state::SideEffect::SpawnJob { cmd, args, .. } => { - assert!(cmd.contains("rocm"), "cmd resolves to the rocm exe: {cmd}"); - assert!( - args.iter().any(|a| a == "examine"), - "examine in args: {args:?}" - ); - } - other => panic!("expected SpawnJob, got {other:?}"), - } - assert_eq!( - s.examine_manager.as_ref().unwrap().active_job.as_deref(), - Some("examine"), - "the auto-run wires the active job" - ); - } - #[test] fn draw_focused_shows_overlay_without_tab_chrome() { use ratatui::Terminal; @@ -7012,151 +1870,6 @@ mod tests { ); } - #[test] - fn focused_exit_gate_holds_until_examine_closed_at_root() { - let mut s = st(); - // Focused Diagnose: examine opens AND auto-runs → a job-console sub-state. - let _ = open_overlay_for_focus(&mut s, Focus::Examine); - assert!(s.examine_manager.as_ref().unwrap().active_job.is_some()); - assert!( - !s.focused_should_exit(Some(Focus::Examine)), - "a running job keeps the launcher out" - ); - - // Job terminal → first Esc dismisses the console back to the intro card; - // the overlay is still open, so the gate stays shut. - s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { - id: "examine".into(), - code: 0, - }); - let _ = crate::ui::examine_manager::on_key( - &mut s.examine_manager, - &mut s.jobs, - press(KeyCode::Esc), - ); - assert!( - s.examine_manager.is_some(), - "console dismissed, overlay stays" - ); - assert!( - !s.focused_should_exit(Some(Focus::Examine)), - "at the intro (not root-closed) the gate is still shut" - ); - - // Second Esc at the intro (root) closes the overlay → now exit to menu. - let _ = crate::ui::examine_manager::on_key( - &mut s.examine_manager, - &mut s.jobs, - press(KeyCode::Esc), - ); - assert!(s.examine_manager.is_none(), "root Esc closes the overlay"); - assert!( - s.focused_should_exit(Some(Focus::Examine)), - "closed at root → return to the launcher" - ); - } - - #[test] - fn focused_close_keys_swallowed_while_job_runs() { - // Regression for the mid-job ejection defect: `q` and running-`Esc` must - // be swallowed by the focused host while the job is non-terminal, so the - // overlay is never nulled (which would tear the runtime down and kill the - // child via kill_on_drop mid-write). - let mut s = st(); - let _ = open_overlay_for_focus(&mut s, Focus::Examine); // auto-runs a job - assert!(s.has_active_console(), "examine console is live"); - // Running job → q and Esc are blocked; Ctrl+C ('c') is NOT (it cancels). - assert!(focused_close_key_blocked( - &s, - Some(Focus::Examine), - KeyCode::Char('q') - )); - assert!(focused_close_key_blocked( - &s, - Some(Focus::Examine), - KeyCode::Esc - )); - assert!(!focused_close_key_blocked( - &s, - Some(Focus::Examine), - KeyCode::Char('c') - )); - // The dashboard (focus=None) never blocks — behavior is unchanged there. - assert!(!focused_close_key_blocked(&s, None, KeyCode::Char('q'))); - - // Because those keys are swallowed (never routed to the manager), the - // overlay stays open and the exit gate stays shut mid-job. - assert!(s.examine_manager.is_some()); - assert!(!s.focused_should_exit(Some(Focus::Examine))); - - // Once the job is terminal, close keys are allowed again → normal exit. - s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { - id: "examine".into(), - code: 0, - }); - assert!( - !focused_close_key_blocked(&s, Some(Focus::Examine), KeyCode::Char('q')), - "a terminal job no longer blocks exit (the child already exited)" - ); - } - - #[test] - fn focused_gate_shut_across_serve_sub_states() { - // Exit-at-root (b)+(c): the focused gate stays shut while a folder - // browser / model picker / approval is open — it only opens at root. - let recipes: Vec = Vec::new(); - let mut s = st(); - let _ = open_overlay_for_focus(&mut s, Focus::Serve); - - // (b) Tab on the Model field opens the folder-browser sub-popup. - let _ = crate::ui::serve_wizard::on_key( - &mut s.serve_wizard, - &mut s.jobs, - &recipes, - press(KeyCode::Tab), - ); - assert!(s.serve_wizard.as_ref().unwrap().browser.is_some()); - assert!( - !s.focused_should_exit(Some(Focus::Serve)), - "gate shut while the folder browser is open" - ); - // Esc closes the sub-popup, not the wizard → still shut. - let _ = crate::ui::serve_wizard::on_key( - &mut s.serve_wizard, - &mut s.jobs, - &recipes, - press(KeyCode::Esc), - ); - assert!(s.serve_wizard.as_ref().unwrap().browser.is_none()); - assert!(s.serve_wizard.is_some()); - assert!(!s.focused_should_exit(Some(Focus::Serve))); - - // (c) Stage an approval (valid model, Launch field, Enter). - { - let w = s.serve_wizard.as_mut().unwrap(); - w.model = "org/model".to_string(); - w.field = crate::ui::serve_wizard::FIELDS.len() - 1; // Launch - } - let _ = crate::ui::serve_wizard::on_key( - &mut s.serve_wizard, - &mut s.jobs, - &recipes, - press(KeyCode::Enter), - ); - assert!( - s.serve_wizard.as_ref().unwrap().approval.is_some(), - "a launch approval is pending" - ); - assert!( - !s.focused_should_exit(Some(Focus::Serve)), - "gate shut while an approval is pending" - ); - - // Only a root close (wizard → None) opens the gate. - s.serve_wizard = None; - assert!(s.focused_should_exit(Some(Focus::Serve))); - } - #[test] fn draw_focused_serve_with_empty_recipes_does_not_panic() { // Edge input: the serve wizard must render (and the focused host must @@ -7197,24 +1910,6 @@ mod tests { assert_eq!(s.modal, Modal::Help); } - #[test] - fn scroll_modal_action_reaches_scroll_instance_detail_for_detail_modal() { - // Regression: `apply_action`'s ScrollModal dispatch only matched - // `Modal::Help | Modal::GlobalHelp`, silently dropping the action for - // `Modal::Detail` even though both `handle_key` and `handle_mouse` - // emit `ScrollModal` for it (see `detail_modal_j_k_emit_scroll` / - // `handle_mouse_routes_scroll_by_modal_and_tab`) and the instance - // Detail modal's body (launch_args/env_vars) can genuinely overflow. - let mut s = AppState::new("t".into(), "default-dark".into()); - s.modal = Modal::Detail; - s.instance_detail_max_scroll = 10; - apply_action(&mut s, KeyAction::ScrollModal(3)); - assert_eq!( - s.instance_detail_scroll, 3, - "Detail modal scrolls via apply_action" - ); - } - #[test] fn slash_clear_empties_transcript() { let mut s = st(); @@ -8608,375 +3303,4 @@ mod tests { assert!(!s.chat_sending); assert!(s.chat_input.is_empty()); } - - #[tokio::test] - async fn chat_reply_path_appends_agent_turn_and_clears_sending() { - // The wired ChatSubmit→reply path using the MockAgentClient (no LLM). - let agent = crate::agent::MockAgentClient::new("GPU-2: 87% util, 71°C"); - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_input = "what's GPU-2 doing?".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - assert!(s.chat_sending); - // Simulate event_loop: run the agent over the history, deliver the reply. - let snapshot = s.state_snapshot(); - let reply = crate::agent::AgentClient::complete(&agent, &s.chat, snapshot) - .await - .expect("mock reply"); - s.on_chat_reply(reply); - assert_eq!(s.chat.last().unwrap().role, ChatRole::Agent); - assert_eq!(s.chat.last().unwrap().content, "GPU-2: 87% util, 71°C"); - assert!(!s.chat_sending); - } - - #[test] - fn chat_input_handles_unicode_and_long_text() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_focused = true; - // Multi-byte / emoji chars push as single chars, no panic. - for c in "héllo 🚀 café ∑".chars() { - apply_action(&mut s, KeyAction::ChatInput(c)); - } - assert_eq!(s.chat_input, "héllo 🚀 café ∑"); - // Backspace removes the trailing multi-byte char correctly. - apply_action(&mut s, KeyAction::ChatBackspace); - assert_eq!(s.chat_input, "héllo 🚀 café "); - // Very long input is accepted. - for _ in 0..5000 { - apply_action(&mut s, KeyAction::ChatInput('x')); - } - assert!(s.chat_input.len() > 5000); - // Submitting unicode pushes one user turn, no panic. - s.chat_input = "什么是 GPU-2?".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - assert_eq!(s.chat[0].content, "什么是 GPU-2?"); - } - - #[test] - fn chat_scroll_clamps_and_updates_follow_state() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_max_scroll = 20; - s.chat_scroll = 20; - apply_action(&mut s, KeyAction::ChatScroll(-100)); - assert_eq!(s.chat_scroll, 0, "scroll clamps at top"); - assert!(!s.chat_follow, "scrolling above the bottom disables follow"); - apply_action(&mut s, KeyAction::ChatScroll(7)); - assert_eq!(s.chat_scroll, 7); - assert!(!s.chat_follow); - apply_action(&mut s, KeyAction::ChatScroll(100)); - assert_eq!(s.chat_scroll, 20, "scroll clamps at measured bottom"); - assert!(s.chat_follow, "scrolling to the bottom restores follow"); - // PageUp/PageDown map to ChatScroll on the Chat tab when accepted. - let accepted = ChatKeyCtx { - focused: false, - consent: ChatConsent::Accepted, - offer_pending: false, - }; - assert_eq!( - handle_key( - press(KeyCode::PageDown), - ActiveTab::Chat, - &Modal::None, - accepted - ), - KeyAction::ChatScroll(CHAT_SCROLL_STEP) - ); - assert_eq!( - handle_key( - press(KeyCode::PageUp), - ActiveTab::Chat, - &Modal::None, - accepted - ), - KeyAction::ChatScroll(-CHAT_SCROLL_STEP) - ); - } - - #[test] - fn chat_scrollbar_grab_updates_follow_state() { - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_max_scroll = 20; - s.chat_scroll = 20; - - s.apply_scroll_grab(ScrollTarget::Chat, 5, 0); - assert_eq!(s.chat_scroll, 5); - assert!(!s.chat_follow, "dragging above the bottom disables follow"); - - s.apply_scroll_grab(ScrollTarget::Chat, 20, 0); - assert_eq!(s.chat_scroll, 20); - assert!(s.chat_follow, "dragging to the bottom restores follow"); - } - - #[tokio::test] - async fn chat_error_path_appends_error_turn_no_panic() { - let agent = crate::agent::MockAgentClient::failing(); - let mut s = AppState::new("t".into(), "default-dark".into()); - s.chat_input = "hi".into(); - apply_action(&mut s, KeyAction::ChatSubmit); - let snapshot = s.state_snapshot(); - let err = crate::agent::AgentClient::complete(&agent, &s.chat, snapshot) - .await - .unwrap_err(); - s.on_chat_error(err.to_string()); - assert_eq!(s.chat.last().unwrap().role, ChatRole::Error); - assert!(!s.chat_sending); - } - - // --- Home tab update check (background job-bridge trigger) --- - - // Serializes every test in this group against - // `refresh_update_status_skips_spawn_when_disabled_via_env`, which toggles - // `ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK` — process env is shared across - // test threads, so an unguarded test can observe the var mid-toggle and - // spuriously see `refresh_update_status` skip the spawn it expects. - static UPDATE_CHECK_ENV_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - - #[test] - fn refresh_update_status_spawns_on_first_due_tick() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut s = st(); - assert!(!s.update_status_pending); - let fx = refresh_update_status(&mut s); - assert!(!fx.is_empty(), "a due check spawns a job"); - assert!(s.update_status_pending); - assert!(s.jobs.job(HOME_UPDATE_CHECK_JOB_ID).is_some()); - } - - #[test] - fn refresh_update_status_does_not_duplicate_spawn_while_running() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut s = st(); - let fx = refresh_update_status(&mut s); - assert!(!fx.is_empty()); - assert!(s.update_status_pending); - - // Still pending, job still running (non-terminal) → no-op, no second spawn. - let fx2 = refresh_update_status(&mut s); - assert!( - fx2.is_empty(), - "no duplicate spawn while pending and running" - ); - assert!(s.update_status_pending); - assert_eq!(s.update_status, UpdateStatus::Unknown); - } - - #[test] - fn refresh_update_status_rearms_due_at_when_pending_job_vanishes() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut s = st(); - let _ = refresh_update_status(&mut s); - assert!(s.update_status_pending); - - // Not reachable today (jobs are never removed), but if it ever is, - // `update_check_due_at` must still be pushed out — otherwise every - // subsequent tick would spawn a new check immediately. - s.jobs.jobs.remove(HOME_UPDATE_CHECK_JOB_ID); - let fx = refresh_update_status(&mut s); - assert!(fx.is_empty()); - assert!(!s.update_status_pending); - assert!( - s.update_check_due_at > std::time::Instant::now(), - "due_at must be re-armed, not left in the past" - ); - } - - #[test] - fn refresh_update_status_resolves_from_terminal_success_json() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut s = st(); - let _ = refresh_update_status(&mut s); - assert!(s.update_status_pending); - - s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { - id: HOME_UPDATE_CHECK_JOB_ID.into(), - line: serde_json::json!({ - "runtimes": [{ - "runtime_key": "rocm", - "channel": "stable", - "family": "rocm", - "installed_version": "7.0.0", - "latest_version": "7.1.0", - "status": "update_available", - "message": null, - }] - }) - .to_string(), - }); - s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { - id: HOME_UPDATE_CHECK_JOB_ID.into(), - code: 0, - }); - - let fx = refresh_update_status(&mut s); - assert!(fx.is_empty(), "resolving a terminal job spawns nothing"); - assert!(!s.update_status_pending); - assert_eq!( - s.update_status, - UpdateStatus::UpdateAvailable { - latest_version: "7.1.0".into() - } - ); - } - - #[test] - fn refresh_update_status_resolves_to_error_on_terminal_failure() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut s = st(); - let _ = refresh_update_status(&mut s); - assert!(s.update_status_pending); - - // Nonzero exit → Error, regardless of any output on the ring. - s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { - id: HOME_UPDATE_CHECK_JOB_ID.into(), - code: 1, - }); - let fx = refresh_update_status(&mut s); - assert!(fx.is_empty()); - assert!(!s.update_status_pending); - assert_eq!(s.update_status, UpdateStatus::Error); - } - - #[test] - fn refresh_update_status_resolves_to_error_on_unparsable_success_output() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut s = st(); - let _ = refresh_update_status(&mut s); - - // Exit 0 but no valid JSON line on the ring → Error, not a silent hang. - s.jobs.apply(rocm_dash_core::state::StateEvent::JobLine { - id: HOME_UPDATE_CHECK_JOB_ID.into(), - line: "not json".into(), - }); - s.jobs.apply(rocm_dash_core::state::StateEvent::JobDone { - id: HOME_UPDATE_CHECK_JOB_ID.into(), - code: 0, - }); - let fx = refresh_update_status(&mut s); - assert!(fx.is_empty()); - assert!(!s.update_status_pending); - assert_eq!(s.update_status, UpdateStatus::Error); - } - - #[test] - fn refresh_update_status_spawn_args_include_bounded_timeout() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - let mut s = st(); - let _ = refresh_update_status(&mut s); - let job = s - .jobs - .job(HOME_UPDATE_CHECK_JOB_ID) - .expect("job spawned on first due tick"); - // Pinned to a literal, not `HOME_UPDATE_CHECK_TIMEOUT_SECS`: comparing - // the constant to itself can never catch an unintentional change to - // its value. A literal forces a deliberate test update (and a second - // thought) whenever the bound changes. - assert_eq!( - job.args.last().map(String::as_str), - Some("5"), - "the background check's timeout bound must stay a deliberate choice: {:?}", - job.args - ); - assert!(job.args.iter().any(|a| a == "--timeout-secs")); - } - - #[test] - fn refresh_update_status_skips_spawn_when_disabled_via_env() { - let _guard = UPDATE_CHECK_ENV_TEST_LOCK - .lock() - .unwrap_or_else(std::sync::PoisonError::into_inner); - // SAFETY: serialized by `UPDATE_CHECK_ENV_TEST_LOCK`; no other thread - // reads/writes this var concurrently. - #[allow(unsafe_code)] - unsafe { - std::env::set_var("ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK", "1"); - } - let result = std::panic::catch_unwind(|| { - let mut s = st(); - let fx = refresh_update_status(&mut s); - assert!(fx.is_empty(), "a disabled check must not spawn a job"); - assert!(!s.update_status_pending); - assert!(s.jobs.job(HOME_UPDATE_CHECK_JOB_ID).is_none()); - }); - #[allow(unsafe_code)] - unsafe { - std::env::remove_var("ROCM_CLI_DISABLE_STARTUP_UPDATE_CHECK"); - } - result.unwrap(); - } - - #[test] - fn reduce_update_json_all_up_to_date_or_ahead_is_up_to_date() { - let doc = serde_json::json!({ - "runtimes": [ - {"status": "up_to_date"}, - {"status": "ahead_of_index"}, - ] - }); - assert_eq!(reduce_update_json(&doc), UpdateStatus::UpToDate); - } - - #[test] - fn reduce_update_json_mixed_up_to_date_and_error_is_error_not_up_to_date() { - // One runtime resolved cleanly, one didn't — asserting "Up to date" - // here would be a false claim about the runtime that errored. - let doc = serde_json::json!({ - "runtimes": [ - {"status": "up_to_date"}, - {"status": "error", "message": "boom"}, - ] - }); - assert_eq!(reduce_update_json(&doc), UpdateStatus::Error); - } - - #[test] - fn reduce_update_json_unrecognized_status_is_error() { - let doc = serde_json::json!({ - "runtimes": [{"status": "something_new"}] - }); - assert_eq!(reduce_update_json(&doc), UpdateStatus::Error); - } - - #[test] - fn reduce_update_json_repair_available_is_update_available_not_error() { - // A same-version composition repair is as actionable as a version - // bump — the tile must not report "check failed" for it. - let doc = serde_json::json!({ - "runtimes": [{"status": "repair_available", "latest_version": "6.4.0"}] - }); - assert_eq!( - reduce_update_json(&doc), - UpdateStatus::UpdateAvailable { - latest_version: "6.4.0".to_owned() - } - ); - } - - #[test] - fn reduce_update_json_missing_latest_version_is_still_update_available() { - // A row with an actionable status but no `latest_version` must not be - // silently skipped in favor of the up-to-date/error checks below it — - // that would misreport a real update as "check failed". - let doc = serde_json::json!({ - "runtimes": [{"status": "update_available"}] - }); - assert_eq!( - reduce_update_json(&doc), - UpdateStatus::UpdateAvailable { - latest_version: "(version unknown)".to_owned() - } - ); - } } diff --git a/crates/rocm-dash-tui/src/app/scrollbar.rs b/crates/rocm-dash-tui/src/app/scrollbar.rs new file mode 100644 index 000000000..c7e82a033 --- /dev/null +++ b/crates/rocm-dash-tui/src/app/scrollbar.rs @@ -0,0 +1,930 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Mouse/scroll hit-testing: resolving a raw `MouseEvent` against recorded +//! scrollbar tracks, the tab bar, and footer-legend chips into a `KeyAction`. +//! Split out of `app/mod.rs` to keep the core reducer + event loop focused. + +#[cfg(test)] +use crossterm::event::KeyModifiers; +use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + +use crate::ui; + +#[cfg(test)] +use super::actions::apply_action; +use super::actions::{KeyAction, handle_mouse, tab_bar_hit}; +use super::{ActiveTab, AppState, Modal}; + +/// Convert a displayed position to the target's own offset units. Dock logs are +/// tail-anchored, so their displayed top-to-bottom position is inverted. +fn target_offset(h: &ScrollbarHandle, displayed: usize) -> usize { + if h.target == ScrollTarget::DockLogs { + h.max_position().saturating_sub(displayed) + } else { + displayed + } +} + +fn target_position(state: &AppState, h: &ScrollbarHandle) -> usize { + let position = match h.target { + ScrollTarget::Console => usize::from(state.console_scroll), + ScrollTarget::ConsoleH => usize::from(state.console_hscroll), + ScrollTarget::Chat => usize::from(state.chat_scroll), + ScrollTarget::DockLogs => h + .max_position() + .saturating_sub(usize::from(state.dock_logs_scroll)), + ScrollTarget::InstanceDetail => usize::from(state.instance_detail_scroll), + }; + position.min(h.max_position()) +} + +/// If `(col, row)` lands on a recorded scrollbar, preserve a thumb grab or use +/// a proportional full-track jump for a track click. +fn scrollbar_hit(state: &AppState, col: u16, row: u16) -> Option { + let bars = state.scrollbars.borrow(); + let h = bars.iter().find(|h| point_in(h.track, col, row))?; + let displayed = target_position(state, h); + let grab_offset = h.grab_offset(col, row, displayed); + let next = grab_offset.map_or_else(|| h.track_position_at(col, row), |_| displayed); + Some(KeyAction::ScrollGrab( + h.target, + target_offset(h, next), + grab_offset.unwrap_or(0), + )) +} + +pub(crate) fn resolve_mouse(me: MouseEvent, state: &AppState) -> KeyAction { + // A held drag on a scrollbar keeps updating that offset until release, even + // when the pointer slides off the narrow track. + if me.kind == MouseEventKind::Drag(MouseButton::Left) { + // A drag can start before an approval becomes pending (it's only + // gated at the click that starts it, via `scrollbar_hit`'s own + // `approval_pending()` check below) and then have an approval land + // asynchronously mid-drag. Swallow it here too, or the drag would + // keep mutating a scroll position hidden behind the approval modal — + // "a pending approval owns the body with no exception" (see the + // wheel-scroll swallow further down) applies to an in-flight drag + // just as much as to input that starts fresh. + if state.approval_pending() { + return KeyAction::Nothing; + } + if let Some(drag) = state.scroll_drag + && let Some(h) = state + .scrollbars + .borrow() + .iter() + .find(|h| h.target == drag.target) + { + let current = target_position(state, h); + let displayed = h.position_at(me.column, me.row, drag.grab_offset, current); + return KeyAction::ScrollGrab( + drag.target, + target_offset(h, displayed), + drag.grab_offset, + ); + } + return KeyAction::Nothing; + } + // Any button release ends an active scrollbar drag. + if matches!(me.kind, MouseEventKind::Up(_)) { + return if state.scroll_drag.is_some() { + KeyAction::ScrollRelease + } else { + KeyAction::Nothing + }; + } + + if me.kind == MouseEventKind::Down(MouseButton::Left) { + // Scrollbar tracks win over a plain open overlay (incl. its console + // bar), so a click on the bar grabs it instead of falling through — + // but NOT over a pending approval: nothing registers a scrollbar for + // the approval modal itself, so any handle on screen while one is + // pending belongs to content underneath it, which the swallow below + // must still catch rather than let a scrollbar drag bypass it. + if !state.approval_pending() + && let Some(a) = scrollbar_hit(state, me.column, me.row) + { + return a; + } + if let Some(area) = state.last_tab_bar_area + && let Some(tab) = tab_bar_hit(area, me.column, me.row) + { + return KeyAction::SwitchTab(tab); + } + // Footer legend: a click on a key chip acts exactly like the key press. + if let Some(chip) = footer_chip_hit(&state.last_footer_chips, me.column, me.row) { + return chip; + } + // While an operational manager is open — or a chat tool-call approval + // is pending — it owns the body: swallow body clicks so they can't + // fall THROUGH to the obscured Actions/Details list (which would + // silently change the selection, re-open a verb, or switch tabs + // underneath the approval modal). Tab-bar and footer-chip clicks + // above still work, matching the manager-overlay swallow this + // mirrors (see the analogous `overlay_or_approval()` check in + // ui/mod.rs's footer-chip gating). + if state.overlay_or_approval() { + return KeyAction::Nothing; + } + if state.modal == Modal::None + && let Some(area) = state.last_body_area + { + // ponytail: Observe folds the instances table into a stacked region; + // body-click hit-testing best-efforts the instances rows. Keyboard + // selection is the primary path. + let action = match state.active_tab { + // Observe's AI table is keyboard + scroll-wheel driven (the + // scroll path maps to Move in `handle_mouse`); left-click select + // is intentionally not wired (the table sits below the hero band, + // so a body-relative row map would be wrong). No-op here. + ActiveTab::Rocm => ui::tabs::rocm::hit_test(area, me.column, me.row), + ActiveTab::Serving => ui::tabs::serving::hit_test(area, me.column, me.row), + _ => None, + }; + if let Some(a) = action { + return a; + } + } + return KeyAction::Nothing; + } + + // Scroll wheel (incl. horizontal wheel where the device emits it). Per-notch + // deltas: ±1 line / ±1 col here, scaled per target below. + let (dv, dh): (i16, i16) = match me.kind { + MouseEventKind::ScrollDown => (1, 0), + MouseEventKind::ScrollUp => (-1, 0), + MouseEventKind::ScrollRight => (0, 1), + MouseEventKind::ScrollLeft => (0, -1), + // Not a scroll (e.g. moves / other buttons): nothing to route. + _ => return KeyAction::Nothing, + }; + + // A pending approval owns the body with no exception (mirrors the click + // swallow a few lines above) — unlike a plain manager overlay, it never + // has its own console to pan, so there is nothing to fall through to. + if state.approval_pending() { + return KeyAction::Nothing; + } + // An open manager owns the body. When it is showing its job console, the + // wheel pans that log (bigger vertical step, wider horizontal step so long + // command lines come into view). On a form screen there is nothing to pan — + // swallow it so the wheel can't move the obscured Actions list underneath. + if state.has_open_overlay() { + return if state.has_active_console() { + KeyAction::ScrollConsole(dv * 3, dh * 6) + } else { + KeyAction::Nothing + }; + } + + // Wide-layout right LOGS dock: the wheel pans the log stream when the pointer + // is over it (vertical only — it's a tail-anchored log). + if state.modal == Modal::None + && dv != 0 + && let Some(dock) = state.last_dock_area + && point_in(dock, me.column, me.row) + { + return KeyAction::ScrollDock(dv * 3); + } + + // No overlay: on a domain tab the wheel moves the Actions selection by ONE + // row — but only while the pointer is actually over the Actions column, so + // hovering the Details pane doesn't nudge the list. Anything else falls + // through to the modal/tab scroll routing. + if state.modal == Modal::None + && matches!(state.active_tab, ActiveTab::Rocm | ActiveTab::Serving) + { + if dv != 0 + && let Some(body) = state.last_body_area + && point_in(crate::ui::tabs::pane::actions_rect(body), me.column, me.row) + { + return KeyAction::Move(dv as isize); + } + return KeyAction::Nothing; + } + + handle_mouse(me, &state.modal, state.active_tab) +} + +/// Whether `(x, y)` lies inside `r` (end-exclusive on both axes). +const fn point_in(r: ratatui::layout::Rect, x: u16, y: u16) -> bool { + x >= r.x && x < r.x + r.width && y >= r.y && y < r.y + r.height +} + +/// Resolve a pointer `(col, row)` against the recorded footer-legend chips. +/// Returns the chip's action when the pointer lands inside a chip span. +fn footer_chip_hit(chips: &[FooterChip], col: u16, row: u16) -> Option { + chips + .iter() + .find(|c| row == c.y && col >= c.x0 && col < c.x1) + .map(|c| c.action) +} + +/// Where a domain tab's (ROCm/Serving) keyboard focus currently sits. Shared by +/// both tabs; each keeps its own selection cursor (`rocm_sel`/`serving_sel`). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PaneFocus { + /// Browsing the Actions list (left column). + #[default] + Actions, + /// Inside the Details pane (right column), ready to start the operation. + Detail, +} + +/// A clickable footer-legend chip: an absolute screen span on the footer row +/// plus the action a left-click should dispatch. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct FooterChip { + pub x0: u16, + /// End-exclusive. + pub x1: u16, + pub y: u16, + pub action: KeyAction, +} + +/// Active pointer drag for a scrollbar thumb. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollDrag { + pub target: ScrollTarget, + pub grab_offset: u16, +} + +/// Which scrollable surface a drawn scrollbar controls. Lets a mouse click on a +/// scrollbar track write the right offset field. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ScrollTarget { + /// Job console vertical (`console_scroll`). + Console, + /// Job console horizontal (`console_hscroll`). + ConsoleH, + /// Wide-layout LOGS dock (`dock_logs_scroll`, tail-anchored / inverted). + DockLogs, + /// Chat transcript (`chat_scroll`). + Chat, + /// Instance detail modal's launch_args/env_vars panes (`instance_detail_scroll`). + InstanceDetail, +} + +/// A scrollbar drawn this frame, recorded so a mouse click/drag can hit-test it. +/// +/// `track` is the screen rect of the bar; `content_len`/`viewport_len` size the +/// thumb; `target` says which offset to move. Vertical bars map the mouse row, +/// horizontal bars the column. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ScrollbarHandle { + pub track: ratatui::layout::Rect, + pub horizontal: bool, + pub content_len: usize, + pub viewport_len: usize, + pub target: ScrollTarget, +} + +impl ScrollbarHandle { + /// Build a handle from the rect passed to a scrollbar helper (`area`) and its + /// returned content rect (`drawn`). Returns `None` when they're equal — i.e. + /// no bar was drawn because the content fit — so nothing gets hit-tested. + pub(crate) fn new( + area: ratatui::layout::Rect, + drawn: ratatui::layout::Rect, + horizontal: bool, + content_len: usize, + viewport_len: usize, + target: ScrollTarget, + ) -> Option { + if drawn == area { + return None; + } + let track = if horizontal { + ratatui::layout::Rect::new(area.x, area.y + area.height - 1, area.width, 1) + } else { + ratatui::layout::Rect::new(area.x + area.width - 1, area.y, 1, area.height) + }; + Some(Self { + track, + horizontal, + content_len, + viewport_len, + target, + }) + } + + const fn max_position(&self) -> usize { + self.content_len.saturating_sub(self.viewport_len) + } + + const fn axis(&self, col: u16, row: u16) -> (u16, u16, u16) { + if self.horizontal { + (col, self.track.x, self.track.width) + } else { + (row, self.track.y, self.track.height) + } + } + + /// Ratatui 0.30.2 `Scrollbar::part_lengths` geometry for the logical + /// first-visible-unit position used by the dashboard. + fn thumb_geometry(&self, logical_position: usize) -> (u16, u16) { + let (_, _, span) = self.axis(0, 0); + if span == 0 || self.content_len == 0 { + return (0, 0); + } + let track_len = usize::from(span); + let rendered_max = self.content_len.saturating_sub(1); + let rendered_position = + logical_position.min(self.max_position()) * rendered_max / self.max_position().max(1); + let denominator = rendered_max.saturating_add(self.viewport_len); + let rounded_divide = + |numerator: usize| numerator.saturating_add(denominator / 2) / denominator.max(1); + let thumb_len = + rounded_divide(self.viewport_len.saturating_mul(track_len)).clamp(1, track_len); + let thumb_start = rounded_divide(rendered_position.saturating_mul(track_len)) + .clamp(0, track_len.saturating_sub(thumb_len)); + (thumb_start as u16, thumb_len as u16) + } + + fn grab_offset(&self, col: u16, row: u16, position: usize) -> Option { + let (coord, track_start, _) = self.axis(col, row); + let relative = coord.saturating_sub(track_start); + let (thumb_start, thumb_len) = self.thumb_geometry(position); + (relative >= thumb_start && relative < thumb_start.saturating_add(thumb_len)) + .then(|| relative - thumb_start) + } + + /// Proportional track-click mapping. The endpoints map exactly to the + /// logical endpoints and do not depend on thumb geometry. + fn track_position_at(&self, col: u16, row: u16) -> usize { + let max_position = self.max_position(); + let (coord, start, span) = self.axis(col, row); + if max_position == 0 || span <= 1 { + return 0; + } + usize::from(coord.saturating_sub(start).min(span - 1)) * max_position + / usize::from(span - 1) + } + + /// Invert Ratatui's rounded thumb-start mapping. When several logical + /// positions render at the requested start, retain `current_position` if it + /// lies on that plateau; otherwise choose the nearest plateau endpoint. + fn position_at(&self, col: u16, row: u16, grab_offset: u16, current_position: usize) -> usize { + let max_position = self.max_position(); + if max_position == 0 { + return 0; + } + let (coord, start, span) = self.axis(col, row); + let (_, thumb_len) = self.thumb_geometry(0); + let desired_start = coord + .saturating_sub(start) + .saturating_sub(grab_offset) + .min(span.saturating_sub(thumb_len)); + if desired_start == 0 { + return 0; + } + if desired_start == span.saturating_sub(thumb_len) { + return max_position; + } + + let first_at_or_after = |wanted: u16| { + let mut low = 0usize; + let mut high = max_position; + while low < high { + let mid = low + (high - low) / 2; + if self.thumb_geometry(mid).0 < wanted { + low = mid + 1; + } else { + high = mid; + } + } + low + }; + let first = first_at_or_after(desired_start); + if self.thumb_geometry(first).0 != desired_start { + if first == 0 { + return 0; + } + let before = first - 1; + let before_start = self.thumb_geometry(before).0; + let after_start = self.thumb_geometry(first).0; + return if desired_start - before_start <= after_start - desired_start { + before + } else { + first + }; + } + let after = first_at_or_after(desired_start.saturating_add(1)); + let last = if after == max_position && self.thumb_geometry(after).0 == desired_start { + after + } else { + after.saturating_sub(1) + }; + current_position.clamp(first, last) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::layout::Rect; + + #[test] + fn body_clicks_are_swallowed_while_a_manager_is_open() { + use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 90, + row: 10, + modifiers: KeyModifiers::NONE, + }; + // No manager open → the click resolves against the tab's hit-test. + assert_ne!(resolve_mouse(click, &s), KeyAction::Nothing); + // Manager open → the body click is swallowed (no click-through). + s.install_manager = Some(crate::ui::install_manager::InstallManagerState::default()); + assert_eq!(resolve_mouse(click, &s), KeyAction::Nothing); + } + + #[test] + fn body_clicks_are_swallowed_while_an_approval_is_pending() { + use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + let click = MouseEvent { + kind: MouseEventKind::Down(MouseButton::Left), + column: 90, + row: 10, + modifiers: KeyModifiers::NONE, + }; + // No approval pending → the click resolves against the tab's hit-test. + assert_ne!(resolve_mouse(click, &s), KeyAction::Nothing); + // `open_approval` clears every manager overlay (so `has_open_overlay()` + // is false) but never touches `modal` — the body click must still be + // swallowed instead of falling through to the obscured Actions/Details + // list underneath the approval modal. + s.open_approval(crate::tool_exec::ApprovalIntent { + title: "run a command".into(), + body: vec!["echo hi".into()], + name: "shell".into(), + arguments: serde_json::Value::Null, + }); + assert!(!s.has_open_overlay()); + assert_eq!(s.modal, Modal::None); + assert_eq!(resolve_mouse(click, &s), KeyAction::Nothing); + } + + /// Build a ScrollDown/Up/Left/Right event at a pointer position. + fn wheel(kind: MouseEventKind, col: u16, row: u16) -> MouseEvent { + MouseEvent { + kind, + column: col, + row, + modifiers: KeyModifiers::NONE, + } + } + + #[test] + fn scrollbar_position_maps_proportionally() { + let h = ScrollbarHandle { + track: Rect::new(50, 0, 1, 10), + horizontal: false, + content_len: 100, + viewport_len: 10, + target: ScrollTarget::Console, + }; + assert_eq!(h.track_position_at(50, 0), 0); + assert_eq!(h.track_position_at(50, 9), 90); + assert_eq!(h.track_position_at(50, 5), 90 * 5 / 9); + assert_eq!(h.track_position_at(50, 99), 90); + } + + #[test] + fn scrollbar_click_grabs_drag_scrolls_then_releases() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.scrollbars.borrow_mut().push(ScrollbarHandle { + track: Rect::new(60, 0, 1, 10), + horizontal: false, + content_len: 100, + viewport_len: 10, + target: ScrollTarget::Console, + }); + // Click near the bottom of the track → grab + jump near the end. + let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); + let a = resolve_mouse(down, &s); + assert_eq!(a, KeyAction::ScrollGrab(ScrollTarget::Console, 90, 0)); + apply_action(&mut s, a); + assert_eq!(s.console_scroll, 90); + assert_eq!( + s.scroll_drag, + Some(ScrollDrag { + target: ScrollTarget::Console, + grab_offset: 0, + }) + ); + // Drag to the top — the off-axis column is ignored, so it still tracks. + let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 40, 0); + let a = resolve_mouse(drag, &s); + assert_eq!(a, KeyAction::ScrollGrab(ScrollTarget::Console, 0, 0)); + apply_action(&mut s, a); + assert_eq!(s.console_scroll, 0); + // Release clears the drag. + let up = wheel(MouseEventKind::Up(MouseButton::Left), 40, 0); + let a = resolve_mouse(up, &s); + assert_eq!(a, KeyAction::ScrollRelease); + apply_action(&mut s, a); + assert_eq!(s.scroll_drag, None); + } + + #[test] + fn scrollbar_hit_is_swallowed_while_an_approval_is_pending() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.scrollbars.borrow_mut().push(ScrollbarHandle { + track: Rect::new(60, 0, 1, 10), + horizontal: false, + content_len: 100, + viewport_len: 10, + target: ScrollTarget::Console, + }); + let click = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); + // No approval pending → the scrollbar still wins, same as + // `scrollbar_click_grabs_drag_scrolls_then_releases`. + assert_eq!( + resolve_mouse(click, &s), + KeyAction::ScrollGrab(ScrollTarget::Console, 90, 0) + ); + // Nothing registers a scrollbar for the approval modal itself, so a + // handle on screen while one is pending belongs to content + // underneath it — the click must be swallowed like every other body + // click, not resolve to a drag on the obscured bar. + s.open_approval(crate::tool_exec::ApprovalIntent { + title: "run a command".into(), + body: vec!["echo hi".into()], + name: "shell".into(), + arguments: serde_json::Value::Null, + }); + assert_eq!(resolve_mouse(click, &s), KeyAction::Nothing); + } + + #[test] + fn drag_is_swallowed_once_an_approval_becomes_pending_mid_drag() { + // A drag can only start while no approval is pending (the click that + // starts it goes through `scrollbar_hit`, which is itself gated), but + // an approval can land asynchronously (a chat tool call) while a drag + // started earlier is still in flight. The Drag branch must not keep + // updating the scroll position once that happens — "a pending + // approval owns the body with no exception" applies to an in-flight + // drag, not just to input that starts fresh. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.scrollbars.borrow_mut().push(ScrollbarHandle { + track: Rect::new(60, 0, 1, 10), + horizontal: false, + content_len: 100, + viewport_len: 10, + target: ScrollTarget::Console, + }); + let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); + let a = resolve_mouse(down, &s); + apply_action(&mut s, a); + assert!(s.scroll_drag.is_some(), "drag must have started"); + assert_eq!(s.console_scroll, 90); + + s.open_approval(crate::tool_exec::ApprovalIntent { + title: "run a command".into(), + body: vec!["echo hi".into()], + name: "shell".into(), + arguments: serde_json::Value::Null, + }); + + let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 40, 0); + assert_eq!( + resolve_mouse(drag, &s), + KeyAction::Nothing, + "a drag in flight when an approval becomes pending must be swallowed" + ); + } + + #[test] + fn rendered_thumb_cells_are_grabbable() { + use ratatui::Terminal; + use ratatui::backend::TestBackend; + + for horizontal in [false, true] { + let mut s = AppState::new("t".into(), "default-dark".into()); + let area = if horizontal { + Rect::new(0, 0, 4, 2) + } else { + Rect::new(0, 0, 2, 4) + }; + let target = if horizontal { + ScrollTarget::ConsoleH + } else { + ScrollTarget::Console + }; + if horizontal { + s.console_hscroll = 1; + } else { + s.console_scroll = 1; + } + let backend = TestBackend::new(area.width, area.height); + let mut terminal = Terminal::new(backend).unwrap(); + let mut body = area; + terminal + .draw(|frame| { + body = if horizontal { + crate::ui::panel::horizontal_scrollbar(frame, area, 4, 2, 1, &s.theme) + } else { + crate::ui::panel::vertical_scrollbar(frame, area, 4, 2, 1, &s.theme) + }; + }) + .unwrap(); + s.record_scrollbar(area, body, horizontal, 4, 2, target); + let handle = s.scrollbars.borrow()[0]; + let (thumb_start, thumb_len) = handle.thumb_geometry(1); + assert_eq!((thumb_start, thumb_len), (1, 2)); + for cell in thumb_start..thumb_start + thumb_len { + let (col, row) = if horizontal { + (cell, handle.track.y) + } else { + (handle.track.x, cell) + }; + assert_eq!( + resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), col, row), &s), + KeyAction::ScrollGrab(target, 1, cell - thumb_start) + ); + } + let (before_col, before_row) = if horizontal { + (0, handle.track.y) + } else { + (handle.track.x, 0) + }; + assert_eq!( + resolve_mouse( + wheel( + MouseEventKind::Down(MouseButton::Left), + before_col, + before_row + ), + &s, + ), + KeyAction::ScrollGrab(target, 0, 0) + ); + let (after_col, after_row) = if horizontal { + (3, handle.track.y) + } else { + (handle.track.x, 3) + }; + assert_eq!( + resolve_mouse( + wheel( + MouseEventKind::Down(MouseButton::Left), + after_col, + after_row + ), + &s, + ), + KeyAction::ScrollGrab(target, 2, 0) + ); + } + } + + #[test] + fn stationary_thumb_drag_preserves_logical_position() { + for target in [ScrollTarget::Console, ScrollTarget::DockLogs] { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.console_scroll = 5; + s.dock_logs_scroll = 5; + s.scrollbars.borrow_mut().push(ScrollbarHandle { + track: Rect::new(60, 0, 1, 10), + horizontal: false, + content_len: 20, + viewport_len: 10, + target, + }); + let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 4); + let action = resolve_mouse(down, &s); + apply_action(&mut s, action); + let before = if target == ScrollTarget::DockLogs { + s.dock_logs_scroll + } else { + s.console_scroll + }; + let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 60, 4); + let action = resolve_mouse(drag, &s); + apply_action(&mut s, action); + let after = if target == ScrollTarget::DockLogs { + s.dock_logs_scroll + } else { + s.console_scroll + }; + assert_eq!(after, before, "stationary {target:?} drag moved"); + } + } + + #[test] + fn horizontal_thumb_drag_tracks_pointer_column() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.console_hscroll = 20; + s.scrollbars.borrow_mut().push(ScrollbarHandle { + track: Rect::new(10, 20, 20, 1), + horizontal: true, + content_len: 100, + viewport_len: 20, + target: ScrollTarget::ConsoleH, + }); + let action = resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), 14, 20), &s); + apply_action(&mut s, action); + assert_eq!(s.console_hscroll, 20); + let action = resolve_mouse(wheel(MouseEventKind::Drag(MouseButton::Left), 19, 99), &s); + apply_action(&mut s, action); + assert!(s.console_hscroll > 20); + } + + #[test] + fn dock_scrollbar_grab_inverts_tail_anchored_offset() { + let s = AppState::new("t".into(), "default-dark".into()); + s.scrollbars.borrow_mut().push(ScrollbarHandle { + track: Rect::new(0, 0, 1, 10), + horizontal: false, + content_len: 100, + viewport_len: 10, + target: ScrollTarget::DockLogs, + }); + // Top of the bar = oldest lines = fully scrolled up from the tail (max). + let top = resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), 0, 0), &s); + assert_eq!(top, KeyAction::ScrollGrab(ScrollTarget::DockLogs, 90, 0)); + // Bottom of the bar = newest tail = offset 0. + let bot = resolve_mouse(wheel(MouseEventKind::Down(MouseButton::Left), 0, 9), &s); + assert_eq!(bot, KeyAction::ScrollGrab(ScrollTarget::DockLogs, 0, 0)); + } + + #[test] + fn wheel_over_actions_list_moves_selection_by_one() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + // Left column (Actions) is the first ~46% — a low column is over the list. + let over_list = wheel(MouseEventKind::ScrollDown, 10, 12); + assert_eq!(resolve_mouse(over_list, &s), KeyAction::Move(1)); + let up = wheel(MouseEventKind::ScrollUp, 10, 12); + assert_eq!(resolve_mouse(up, &s), KeyAction::Move(-1)); + } + + #[test] + fn wheel_over_details_pane_does_not_move_the_list() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Serving; + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + // A high column lands in the Details pane (right ~54%) → no list move. + let over_detail = wheel(MouseEventKind::ScrollDown, 140, 12); + assert_eq!(resolve_mouse(over_detail, &s), KeyAction::Nothing); + } + + #[test] + fn wheel_over_open_console_pans_the_log_not_the_list() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + s.logs_view = Some(crate::ui::logs_view::LogsViewState { + active_job: Some("logs".into()), + ..Default::default() + }); + // Console showing → vertical wheel pans the log (×3 lines), not the list. + assert_eq!( + resolve_mouse(wheel(MouseEventKind::ScrollDown, 10, 12), &s), + KeyAction::ScrollConsole(3, 0) + ); + // Horizontal wheel pans columns (×6) for off-screen-wide log lines. + assert_eq!( + resolve_mouse(wheel(MouseEventKind::ScrollRight, 10, 12), &s), + KeyAction::ScrollConsole(0, 6) + ); + } + + #[test] + fn wheel_over_logs_dock_scrolls_the_dock() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Serving; + // A recorded dock rect off to the right of the body. + s.last_dock_area = Some(Rect::new(160, 4, 52, 30)); + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + let over_dock = wheel(MouseEventKind::ScrollDown, 180, 12); + assert_eq!(resolve_mouse(over_dock, &s), KeyAction::ScrollDock(3)); + // A point outside the dock does not scroll it. + let over_body = wheel(MouseEventKind::ScrollDown, 10, 12); + assert_ne!(resolve_mouse(over_body, &s), KeyAction::ScrollDock(3)); + } + + #[test] + fn instance_detail_scrollbar_click_grabs_drag_scrolls_then_releases() { + // Mirrors `scrollbar_click_grabs_drag_scrolls_then_releases` for the + // instance Detail modal's scrollbar (`instances.rs::render_body` + // registers one for each of its two panes) — proves the + // `ScrollTarget::InstanceDetail` wiring added for mouse-drag support + // actually moves `instance_detail_scroll`, not just that the bar + // renders. + let mut s = AppState::new("t".into(), "default-dark".into()); + s.scrollbars.borrow_mut().push(ScrollbarHandle { + track: Rect::new(60, 0, 1, 10), + horizontal: false, + content_len: 100, + viewport_len: 10, + target: ScrollTarget::InstanceDetail, + }); + let down = wheel(MouseEventKind::Down(MouseButton::Left), 60, 9); + let a = resolve_mouse(down, &s); + assert_eq!( + a, + KeyAction::ScrollGrab(ScrollTarget::InstanceDetail, 90, 0) + ); + apply_action(&mut s, a); + assert_eq!(s.instance_detail_scroll, 90); + let drag = wheel(MouseEventKind::Drag(MouseButton::Left), 40, 0); + let a = resolve_mouse(drag, &s); + assert_eq!(a, KeyAction::ScrollGrab(ScrollTarget::InstanceDetail, 0, 0)); + apply_action(&mut s, a); + assert_eq!(s.instance_detail_scroll, 0); + let up = wheel(MouseEventKind::Up(MouseButton::Left), 40, 0); + let a = resolve_mouse(up, &s); + assert_eq!(a, KeyAction::ScrollRelease); + apply_action(&mut s, a); + assert_eq!(s.scroll_drag, None); + } + + #[test] + fn wheel_over_form_screen_overlay_is_swallowed() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + // Overlay open but on its form (no active_job) → nothing to pan, and the + // obscured Actions list must NOT move. + s.install_manager = Some(crate::ui::install_manager::InstallManagerState::default()); + assert_eq!( + resolve_mouse(wheel(MouseEventKind::ScrollDown, 10, 12), &s), + KeyAction::Nothing + ); + } + + #[test] + fn wheel_is_swallowed_while_an_approval_is_pending() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.active_tab = ActiveTab::Rocm; + s.last_body_area = Some(Rect::new(2, 4, 150, 30)); + s.open_approval(crate::tool_exec::ApprovalIntent { + title: "run a command".into(), + body: vec!["echo hi".into()], + name: "shell".into(), + arguments: serde_json::Value::Null, + }); + // `open_approval` clears every manager overlay (`has_open_overlay()` is + // false) but never touches `modal` — the wheel must still be swallowed + // instead of falling through to whatever's obscured underneath, the + // same gap the click path was already fixed for (see + // `body_clicks_are_swallowed_while_an_approval_is_pending`). + assert!(!s.has_open_overlay()); + assert_eq!( + resolve_mouse(wheel(MouseEventKind::ScrollDown, 10, 12), &s), + KeyAction::Nothing + ); + } + + #[test] + fn footer_chip_hit_maps_click_to_action() { + let chips = vec![ + FooterChip { + x0: 0, + x1: 5, + y: 49, + action: KeyAction::Quit, + }, + FooterChip { + x0: 6, + x1: 9, + y: 49, + action: KeyAction::ToggleHelp, + }, + ]; + // Inside the first chip. + assert_eq!(footer_chip_hit(&chips, 2, 49), Some(KeyAction::Quit)); + // End-exclusive: column 5 is past the first chip, before the second. + assert_eq!(footer_chip_hit(&chips, 5, 49), None); + assert_eq!(footer_chip_hit(&chips, 7, 49), Some(KeyAction::ToggleHelp)); + // Wrong row never matches. + assert_eq!(footer_chip_hit(&chips, 2, 48), None); + } + + #[test] + fn chat_scrollbar_grab_updates_follow_state() { + let mut s = AppState::new("t".into(), "default-dark".into()); + s.chat_max_scroll = 20; + s.chat_scroll = 20; + + s.apply_scroll_grab(ScrollTarget::Chat, 5, 0); + assert_eq!(s.chat_scroll, 5); + assert!(!s.chat_follow, "dragging above the bottom disables follow"); + + s.apply_scroll_grab(ScrollTarget::Chat, 20, 0); + assert_eq!(s.chat_scroll, 20); + assert!(s.chat_follow, "dragging to the bottom restores follow"); + } +} diff --git a/crates/rocm-dash-tui/src/app/types.rs b/crates/rocm-dash-tui/src/app/types.rs new file mode 100644 index 000000000..40907b17b --- /dev/null +++ b/crates/rocm-dash-tui/src/app/types.rs @@ -0,0 +1,459 @@ +// Copyright © Advanced Micro Devices, Inc., or its affiliates. +// +// SPDX-License-Identifier: MIT + +//! Shared type and enum definitions for the dashboard reducer: `Focus`, +//! `ResolvedArgs`, connection/tab/chat/replay state, `Modal`, `UpdateStatus`, +//! and the slash/plan/approval payload types. No `AppState` access — split +//! out of `app/mod.rs` to keep the core reducer + event loop focused. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + /// First-run onboarding (install / adopt ROCm) — the launcher's + /// `Set up this system` row and `rocm bootstrap setup`. + Setup, + /// The serve-a-model wizard — the launcher's `Serve a model` row. + Serve, + /// Read-only `rocm examine` environment check — the launcher's + /// `Diagnose & fix` row. Auto-runs on open. + Examine, +} + +/// Args after CLI + config resolution. Consumed by `run`. +#[derive(Debug, Clone)] +pub struct ResolvedArgs { + pub connect: String, + pub token: Option, + pub theme: String, + /// When `Some`, replay events from a file instead of connecting to a + /// live daemon. Mutually exclusive with `connect` (enforced by clap). + pub replay: Option, + /// 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, + /// When `Some`, run as a *focused host*: open exactly the overlay for this + /// flow, skip the embedded daemon + chat backend, render overlay-only, and + /// exit back to the launcher when the overlay is closed at its root. `None` + /// (the default) is the normal full dashboard — every path stays unchanged. + pub focus: Option, + /// Chat endpoint base URL, CLI-flag value already merged over config. + pub chat_url: Option, + /// Chat model, CLI-flag value already merged over config. + pub chat_model: Option, + /// Custom auth header NAME (CLI-flag value merged over config), e.g. + /// `Ocp-Apim-Subscription-Key` for Azure APIM gateways. + pub chat_auth_header: Option, + /// Sampling temperature for chat requests, CLI-flag value merged over + /// config. `None` leaves the endpoint default untouched. + pub chat_temperature: Option, + /// Nucleus-sampling `top_p` for chat requests, CLI merged over config. + pub chat_top_p: Option, + /// Max generated tokens for chat requests, CLI merged over config. + pub chat_max_tokens: Option, + /// Chat endpoint base URL from the environment (`OPENAI_BASE_URL`). + /// A separate, lower-precedence tier than `chat_url`. + pub chat_env_url: Option, + /// Chat api key, sourced from the environment ONLY (never TOML/CLI/source). + /// Used by the local/OpenAI backends. + pub chat_api_key: Option, + /// Anthropic API key, sourced by the bin (env-first then OS secure store — + /// NEVER argv) and carried in-process via this seam. `None` when absent; + /// the Anthropic backend then surfaces an actionable error on switch. + pub anthropic_api_key: Option, + /// Pre-consent to using the detected endpoint (`--chat-yes`), skipping the + /// one-time in-TUI prompt for the demo. + pub chat_auto_consent: bool, + /// Use the offline `MockAgentClient` for chat (`--chat-mock`) — a + /// deterministic, fully-offline demo with no live LLM. + pub chat_mock: bool, + /// Built-in model recipes for the serve wizard's picker (Phase 3 Wave 1). + /// Adapted by the bin (`apps/rocm`, which has `rocm-core`) so this crate + /// needs no `rocm-core` dep. Empty when none are available. + pub model_recipes: Vec, + /// Registered ROCm runtimes for the runtime manager (Phase 3 Wave 2). + /// Adapted by the bin (`apps/rocm`, which has `rocm-core`) so this crate + /// needs no `rocm-core` dep. Empty when none are available. + pub runtimes: Vec, + /// Background checks for the automations manager (Phase 3 Wave 3). Adapted + /// by the bin. Empty when none are available. + pub automations: Vec, + /// System prompt for the chat assistant: the ROCm tool-use prompt plus this + /// machine's detected facts (OS, WSL, AMD GPU, available engines). Composed + /// by the bin (`apps/rocm`, which has `rocm-core`) so this crate needs no + /// `rocm-core` dep. `None` for demo/replay/`--chat-mock`, which have no bin + /// seam and keep the agent's built-in default preamble. + pub chat_system_prompt: Option, + /// Bin-injected tool-executor seam; None for demo/replay/mock — dash behaves + /// as today. Stored here (Phase 2 plumbing); Phase 3 will use it. + pub tool_executor: Option, + /// Daemon-tailed bench CSV path (`config.dashboard.daemon.bench_results_dir`). + /// + /// When `Some`, the bench-run form defaults `--out` to this path so appended + /// rows appear live in the bench tab. Adapted by the bin (owns `rocm-core`). + pub bench_results_dir: Option, + /// Managed-service records that are no longer running, counted from the + /// registry by the bin at launch (the same seam `model_recipes` / `runtimes` + /// / `automations` use - a snapshot, not a live feed). The services overlay + /// only ever renders the live instances the daemon surfaces, so without this + /// a host whose servers had all failed showed an empty overlay and no sign + /// that any record existed. 0 when there are none. + pub services_past_attempts: usize, +} + +impl ResolvedArgs { + /// The optional sampling controls (temperature/top_p/max_tokens) resolved + /// for chat, bundled for the agent builders. CLI-over-config merge already + /// happened in the bin, so these are the final values. + pub(crate) const fn inference_params(&self) -> crate::agent::InferenceParams { + crate::agent::InferenceParams { + temperature: self.chat_temperature, + top_p: self.chat_top_p, + max_tokens: self.chat_max_tokens, + } + } +} + +#[derive(Debug, Clone, Default)] +pub enum ConnState { + #[default] + Initial, + Connecting, + Connected { + host: String, + version: String, + }, + Disconnected { + reason: String, + }, +} + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum ActiveTab { + // 5-tab IA. Home is the default; ROCm and Serving are the two domain tabs + // (Actions list + inline Details); Observe folds the host/instance/bench + // telemetry; Chat is the assistant. The former single Action tab is gone — + // its guided verbs are split across ROCm + Serving. + #[default] + Home, + Rocm, + Serving, + Observe, + Chat, +} + +impl ActiveTab { + #[must_use] + pub const fn next(self) -> Self { + match self { + Self::Home => Self::Rocm, + Self::Rocm => Self::Serving, + Self::Serving => Self::Observe, + Self::Observe => Self::Chat, + Self::Chat => Self::Home, + } + } + #[must_use] + pub const fn prev(self) -> Self { + match self { + Self::Home => Self::Chat, + Self::Rocm => Self::Home, + Self::Serving => Self::Rocm, + Self::Observe => Self::Serving, + Self::Chat => Self::Observe, + } + } + pub const fn from_digit(d: char) -> Option { + match d { + '1' => Some(Self::Home), + '2' => Some(Self::Rocm), + '3' => Some(Self::Serving), + '4' => Some(Self::Observe), + '5' => Some(Self::Chat), + _ => None, + } + } +} + +/// Who authored a chat turn. Plain TUI-local data — `rocm-dash-core` carries +/// no chat types; chat is owned by the TUI crate. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ChatRole { + User, + Agent, + Error, + /// Operational notice generated by the TUI itself (e.g. "switched to + /// local"), rendered in the transcript but **never** sent to the model — + /// `build_messages` drops it so it can't masquerade as a prior assistant + /// turn and corrupt the model's context. + System, +} + +/// One line in the chat transcript. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ChatTurn { + pub role: ChatRole, + pub content: String, +} + +impl ChatTurn { + pub fn user(content: impl Into) -> Self { + Self { + role: ChatRole::User, + content: content.into(), + } + } + pub fn agent(content: impl Into) -> Self { + Self { + role: ChatRole::Agent, + content: content.into(), + } + } + pub fn error(content: impl Into) -> Self { + Self { + role: ChatRole::Error, + content: content.into(), + } + } + /// A TUI-generated operational notice. Rendered but dropped from the LLM + /// history by [`build_messages`](crate::agent::build_messages). + pub fn system(content: impl Into) -> Self { + Self { + role: ChatRole::System, + content: content.into(), + } + } +} + +/// Consent state for using the auto-detected LLM endpoint. The chat surface +/// asks once before any request leaves the machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum ChatConsent { + /// No endpoint detected from any source — actionable empty-state. + #[default] + Unavailable, + /// Endpoint detected; awaiting the user's one-time accept/decline. + Pending, + /// User accepted — chat is enabled. + Accepted, + /// User declined — chat stays off until re-enabled. + Declined, +} + +/// Inputs `handle_key` needs to interpret keys on the Chat tab without holding +/// `&AppState` (keeps the function pure and unit-testable). +#[derive(Debug, Clone, Copy)] +pub struct ChatKeyCtx { + pub focused: bool, + pub consent: ChatConsent, + /// A locally-detected endpoint is awaiting use/dismiss — its keys take + /// precedence over the normal consent prompt. + pub offer_pending: bool, +} + +impl Default for ChatKeyCtx { + fn default() -> Self { + // Default to a usable, unfocused surface for tests that don't exercise + // consent/insert specifics. + Self { + focused: false, + consent: ChatConsent::Accepted, + offer_pending: false, + } + } +} + +/// Replay scrubber state. Only present when `--replay` was given. +#[derive(Debug, Clone)] +pub struct ReplayState { + pub controller: crate::replay::ReplayController, + pub paused: bool, + pub speed: f64, + /// Current playhead in seconds since the start of the recording. + pub elapsed_s: u64, + /// Total length of the recording in seconds. + pub total_s: u64, +} + +impl ReplayState { + pub const fn new(controller: crate::replay::ReplayController) -> Self { + Self { + controller, + paused: false, + speed: 1.0, + elapsed_s: 0, + total_s: 0, + } + } +} + +/// Format a duration in seconds as `M:SS` (or `H:MM:SS` past an hour). +pub fn format_mmss(secs: u64) -> String { + if secs >= 3600 { + let h = secs / 3600; + let m = (secs % 3600) / 60; + let s = secs % 60; + format!("{h}:{m:02}:{s:02}") + } else { + let m = secs / 60; + let s = secs % 60; + format!("{m}:{s:02}") + } +} + +/// Which chat LLM backend is active. The dash can switch live via `/provider` +/// (Phase 8); every backend calls the SAME ROCm tools through the seam. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub(crate) enum ChatProvider { + /// The auto-detected local OpenAI-compatible endpoint (or the no-key ChatGPT + /// OAuth default). This is the launch default and reuses the inline build. + #[default] + Local, + /// OpenAI's hosted Chat Completions API (`OPENAI_API_KEY`). + Openai, + /// Anthropic's Claude API (`ANTHROPIC_API_KEY`). + Anthropic, +} + +impl ChatProvider { + /// Parse the `/provider ` argument (case-insensitive). `None` for an + /// unrecognized name so the handler can hint instead of switching silently. + pub(crate) fn parse(s: &str) -> Option { + match s.trim().to_lowercase().as_str() { + "local" => Some(Self::Local), + "openai" => Some(Self::Openai), + "anthropic" => Some(Self::Anthropic), + _ => None, + } + } + + /// The lowercase label used in turns and hints. + pub(crate) const fn label(self) -> &'static str { + match self { + Self::Local => "local", + Self::Openai => "openai", + Self::Anthropic => "anthropic", + } + } +} + +/// Actionable empty-state shown when a chat is submitted with no agent built +/// (no detected endpoint and no provider key). Surfaced as an error turn — never +/// an error dump or a panic — and names the two concrete recovery actions. +pub(crate) const NO_CHAT_BACKEND_MSG: &str = "no chat backend is configured. Press d to detect a local engine, or use \ + /provider openai|anthropic with the matching API key set."; + +/// Result of routing a chat-input line through the slash-command handler. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum SlashOutcome { + /// The line was a slash command and was handled in-reducer (state mutated, + /// or a slash-tool request raised). It must NOT be sent to the LLM. + Handled, + /// The line is not a slash command — fall through to normal agent dispatch. + NotCommand, +} + +/// A pending read-only slash command that needs the bin executor (no overlay). +/// `submit_chat` sets it; the event loop drains it once, off the async thread. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct SlashToolRequest { + /// Tool name to execute across the seam (e.g. `rocm_command`). + pub name: String, + /// JSON args for the tool (e.g. `{"args":["model"]}`). + pub args: serde_json::Value, + /// Human label for the chat turn header (e.g. `model`). + pub label: String, +} + +/// The structured next action from a natural-language plan (Phase 7). +/// +/// Plain data mirrored from the bin's `freeform_plan_next_action_with_context` +/// so the reducer can decide whether to hand a complete mutating action to the +/// approval modal. A placeholder action (`has_placeholders`) stays plan-only. +/// `pub` (not `pub(crate)`) because it is a payload of the `pub` [`ClientMsg`] +/// enum (mirrors [`crate::tool_exec::ApprovalIntent`]); the reducer entrypoints +/// that consume it stay crate-private. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PlannedAction { + /// The rocm CLI argv to run (e.g. `["install","sdk","--prefix","/x"]`). + pub args: Vec, + /// Whether the planned action mutates local ROCm state (needs approval). + pub approval_required: bool, + /// Whether any arg is still a `` (the plan is incomplete). + pub has_placeholders: bool, + /// Whether a planner provider produced this plan. Provider-assisted plans + /// stay review-only (never auto-forwarded to execution), mirroring the + /// bin's `validate_freeform_execution_action` guard. + pub provider_assisted: bool, +} + +/// A surfaced mutating-tool approval awaiting the operator's decision (Phase 4). +/// Reusable for any [`crate::tool_exec::ApprovalIntent`] (the same modal serves +/// later phases: update/uninstall, permissions, plan). The modal owns keyboard +/// focus while `Some`; on Approve the `(name, arguments)` are replayed through +/// `execute_approved`; on Deny/Cancel nothing runs. +#[derive(Debug, Clone)] +pub(crate) struct PendingApproval { + pub req: crate::ui::approval::ApprovalRequest, + pub choice: crate::ui::approval::ApprovalChoice, + /// Tool name to re-execute on Approve (the validator already accepted it). + pub name: String, + /// JSON args for the approved re-execution. + pub arguments: serde_json::Value, +} + +/// Modal overlays. Only one is shown at a time, on top of the active tab body. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub enum Modal { + #[default] + None, + Help, + Detail, + ThemePicker, + /// btop-style Esc main menu (Options / Help / Quit). + Menu, + /// "Go to…" command palette (tab/destination switch). + Palette, + /// Tabbed Options panel (General / CPU / GPU / Engines). + Options, + /// Global 2-column keyboard reference (distinct from the contextual `?`). + GlobalHelp, +} + +/// Reduction of a completed `rocm update --json` check, for the Home tab's +/// Updates tile. Distinct from `update_manager`'s interactive job state — this +/// tracks the periodic background check only. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UpdateStatus { + /// No check has completed yet (startup, or `state.simulated`). + Unknown, + NoManagedRuntimes, + UpToDate, + UpdateAvailable { + latest_version: String, + }, + Error, +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn from_digit_maps_five_to_chat() { + // 5-tab digit map; Chat is now '5', '6'/'0' are out of range. + assert_eq!(ActiveTab::from_digit('1'), Some(ActiveTab::Home)); + assert_eq!(ActiveTab::from_digit('5'), Some(ActiveTab::Chat)); + assert_eq!(ActiveTab::from_digit('6'), None); + assert_eq!(ActiveTab::from_digit('0'), None); + } + + #[test] + fn format_mmss_renders_minutes_and_hours() { + assert_eq!(format_mmss(0), "0:00"); + assert_eq!(format_mmss(7), "0:07"); + assert_eq!(format_mmss(65), "1:05"); + assert_eq!(format_mmss(599), "9:59"); + assert_eq!(format_mmss(3600), "1:00:00"); + assert_eq!(format_mmss(3661), "1:01:01"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 82431214c..a165a73a1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,7 @@ Already-extracted subsystem modules include `diagnose.rs`, `examine.rs`, and sev ### `crates/rocm-dash-core`, `rocm-dash-collectors`, `rocm-dash-daemon`, `rocm-dash-tui` — dashboard/telemetry -`rocm-dash-tui`'s `agent/mod.rs`/`agent/snapshot.rs`/`agent/tools.rs`/`agent/clients.rs` and `app/mod.rs`/`app/chat.rs`/`app/slash.rs`/`app/summary.rs` are every file this phase's dashboard-TUI split touches. The old agent.rs was split into `agent/mod.rs` (the `AgentClient` seam, `AgentError`, `StateSnapshot`, `InferenceParams`, `REQUEST_TIMEOUT`), `agent/snapshot.rs` (pure JSON telemetry helpers with no `rig` dependency), `agent/tools.rs` (the `rig::tool::Tool` "Skill" wrappers and ROCm read/mutating tool dispatch), and `agent/clients.rs` (the `RigAgentClient`/`ChatGptAgentClient`/`AnthropicAgentClient`/`MockAgentClient` backends) — a mechanical relocation, since the shared seam types stay in `agent/mod.rs` and are reached from the split-out files via `super::`. `app/chat.rs`, `app/slash.rs`, and `app/summary.rs` were previously extracted from `app/mod.rs` following the same mechanical-relocation convention this phase's agent.rs split mirrors, but `app/mod.rs` itself is **not yet modularized** — see EAI-7768. `crates/rocm-dash-tui/src/ui/approval.rs` is the shared component for approval-state prompts — reuse it rather than hand-rolling new approval UI. +`rocm-dash-tui`'s `agent/mod.rs`/`agent/snapshot.rs`/`agent/tools.rs`/`agent/clients.rs` and `app/mod.rs`/`app/types.rs`/`app/event_loop.rs`/`app/scrollbar.rs`/`app/actions.rs`/`app/chat.rs`/`app/slash.rs`/`app/summary.rs` are every file this phase's dashboard-TUI split touches. The old agent.rs was split into `agent/mod.rs` (the `AgentClient` seam, `AgentError`, `StateSnapshot`, `InferenceParams`, `REQUEST_TIMEOUT`), `agent/snapshot.rs` (pure JSON telemetry helpers with no `rig` dependency), `agent/tools.rs` (the `rig::tool::Tool` "Skill" wrappers and ROCm read/mutating tool dispatch), and `agent/clients.rs` (the `RigAgentClient`/`ChatGptAgentClient`/`AnthropicAgentClient`/`MockAgentClient` backends) — a mechanical relocation, since the shared seam types stay in `agent/mod.rs` and are reached from the split-out files via `super::`. `app/mod.rs` is modularized (ROCMAI-84, Phase 4 of EAI-7768's sequencing) into an app/ directory following full domain extraction: `app/types.rs` (shared type/enum defs — `Focus`, `ResolvedArgs`, connection/tab/chat/replay state, `Modal`, `UpdateStatus`, and the slash/plan/approval payload types; no `AppState` access), `app/event_loop.rs` (terminal lifecycle, signal handling, and the tick loop — `run`, `event_loop`, the termination-signal watcher, and the startup-focus / Updates-tile tick helpers), `app/scrollbar.rs` (mouse/scroll hit-testing: resolving a raw `MouseEvent` against recorded scrollbar tracks, the tab bar, and footer-legend chips into a `KeyAction`), `app/actions.rs` (`KeyAction` dispatch: translating a key press or resolved mouse hit into a `KeyAction` and applying it to reducer state). `app/mod.rs` keeps only `AppState` and its `apply_event`/`apply_action`-adjacent reducer impl — the modularization effort's "reducer's reason to exist." `crate::app::*` paths for everything moved out are unchanged via re-exports from `app/mod.rs`. `app/chat.rs`, `app/slash.rs`, and `app/summary.rs` were previously extracted from `app/mod.rs` following the same mechanical-relocation convention this phase's agent.rs split mirrors. `crates/rocm-dash-tui/src/ui/approval.rs` is the shared component for approval-state prompts — reuse it rather than hand-rolling new approval UI. ### `crates/rocm-engine-protocol` — engine IPC protocol From f5eed84e8668ce05295325cf42f8928bf1ba8dcb Mon Sep 17 00:00:00 2001 From: Jussi Elo Date: Thu, 1 Oct 2026 10:47:00 +0000 Subject: [PATCH 2/6] ROCMAI-84: restore API surface dropped by the mod.rs split Review (Copilot) caught two regressions from the mechanical move: - handle_mouse/tab_bar_hit were pub at crate::app:: before extraction but landed in the private actions module unre-exported, silently shrinking the public API surface this PR claims to preserve. - The ClientMsg intra-doc link in PlannedAction's doc comment stopped resolving once the comment moved to types.rs, which doesn't import it. Signed-off-by: Jussi Elo --- crates/rocm-dash-tui/src/app/mod.rs | 2 +- crates/rocm-dash-tui/src/app/types.rs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/crates/rocm-dash-tui/src/app/mod.rs b/crates/rocm-dash-tui/src/app/mod.rs index ea3cb94ee..0a0ae9a13 100644 --- a/crates/rocm-dash-tui/src/app/mod.rs +++ b/crates/rocm-dash-tui/src/app/mod.rs @@ -30,7 +30,7 @@ mod slash; mod summary; mod types; -pub use actions::KeyAction; +pub use actions::{KeyAction, handle_mouse, tab_bar_hit}; pub(crate) use event_loop::{ HOME_UPDATE_CHECK_JOB_ID, SHUTTING_DOWN, exit_on_ctrl_c, is_ctrl_c, lock_terminal_writer, restore_terminal, shutdown_claimed_on, diff --git a/crates/rocm-dash-tui/src/app/types.rs b/crates/rocm-dash-tui/src/app/types.rs index 40907b17b..786043082 100644 --- a/crates/rocm-dash-tui/src/app/types.rs +++ b/crates/rocm-dash-tui/src/app/types.rs @@ -369,9 +369,9 @@ pub(crate) struct SlashToolRequest { /// Plain data mirrored from the bin's `freeform_plan_next_action_with_context` /// so the reducer can decide whether to hand a complete mutating action to the /// approval modal. A placeholder action (`has_placeholders`) stays plan-only. -/// `pub` (not `pub(crate)`) because it is a payload of the `pub` [`ClientMsg`] -/// enum (mirrors [`crate::tool_exec::ApprovalIntent`]); the reducer entrypoints -/// that consume it stay crate-private. +/// `pub` (not `pub(crate)`) because it is a payload of the `pub` +/// [`crate::client::ClientMsg`] enum (mirrors [`crate::tool_exec::ApprovalIntent`]); +/// the reducer entrypoints that consume it stay crate-private. #[derive(Debug, Clone, PartialEq, Eq)] pub struct PlannedAction { /// The rocm CLI argv to run (e.g. `["install","sdk","--prefix","/x"]`). From 94e251dc2de7ce5a12ce8d6056a89a083b705580 Mon Sep 17 00:00:00 2001 From: Jussi Elo Date: Thu, 1 Oct 2026 11:17:56 +0000 Subject: [PATCH 3/6] ROCMAI-84: fix re-export and doc-link regressions from the mod.rs split Code review on PR #476 caught two more instances of the same bug classes already fixed once in 96a44fd1: NO_CHAT_BACKEND_MSG was moved to types.rs but dropped from the pub(crate) re-export list, and the focused_should_exit doc comment's intra-doc link to focused_close_key_blocked no longer resolves now that the two live in separate modules (the fn is private to event_loop, so de-link rather than widen its visibility). Signed-off-by: Jussi Elo --- crates/rocm-dash-tui/src/app/mod.rs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/crates/rocm-dash-tui/src/app/mod.rs b/crates/rocm-dash-tui/src/app/mod.rs index 0a0ae9a13..3e8bdf4ef 100644 --- a/crates/rocm-dash-tui/src/app/mod.rs +++ b/crates/rocm-dash-tui/src/app/mod.rs @@ -47,6 +47,11 @@ pub use types::{ ActiveTab, ChatConsent, ChatKeyCtx, ChatRole, ChatTurn, ConnState, Focus, Modal, PlannedAction, ReplayState, ResolvedArgs, UpdateStatus, format_mmss, }; +// `NO_CHAT_BACKEND_MSG` has no current caller through this `crate::app::` +// path (call sites use `super::types::` directly), but it was reachable here +// pre-split and the re-export list claims parity with `crate::app::*`. +#[allow(unused_imports)] +pub(crate) use types::NO_CHAT_BACKEND_MSG; pub(crate) use types::{ChatProvider, PendingApproval, SlashOutcome, SlashToolRequest}; /// How many snapshots to keep for sparklines. @@ -606,7 +611,7 @@ impl AppState { /// while the user is inside one of those. It does NOT by itself protect a /// running job console: the shared console maps `q` / running-`Esc` to /// "close overlay", which would null the manager mid-job. That case is - /// handled upstream in `event_loop` by [`focused_close_key_blocked`], which + /// handled upstream in `event_loop` by `focused_close_key_blocked`, which /// swallows those keys while the job is non-terminal — so by the time this /// gate is checked, a focused overlay only ever closed at its root (form /// screen or a terminal job). Always `false` for the normal From 04289b0a2c68d49ff0c3acc42762fb65ab6da981 Mon Sep 17 00:00:00 2001 From: Jussi Elo Date: Fri, 2 Oct 2026 13:51:06 +0000 Subject: [PATCH 4/6] ROCMAI-84: fix stale module paths left by the mod.rs split Signed-off-by: Jussi Elo --- apps/rocm/src/main.rs | 2 +- crates/rocm-dash-tui/src/agent/mod.rs | 8 ++++---- crates/rocm-dash-tui/tests/dash_journeys.rs | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/apps/rocm/src/main.rs b/apps/rocm/src/main.rs index e600faf20..e2e553ed5 100644 --- a/apps/rocm/src/main.rs +++ b/apps/rocm/src/main.rs @@ -29730,7 +29730,7 @@ install therock"; // The claim itself (onboarding only opens via an explicit `n` on the // Observe tab, never automatically) is proven by - // `crates/rocm-dash-tui/src/app/mod.rs`'s + // `crates/rocm-dash-tui/src/app/event_loop.rs`'s // `startup_focus_gate_only_opens_onboarding_for_explicit_setup_focus` // test and the `onboarding.rs` module doc — this assertion only // guards the string, not the behavior. diff --git a/crates/rocm-dash-tui/src/agent/mod.rs b/crates/rocm-dash-tui/src/agent/mod.rs index 197845c0e..319876234 100644 --- a/crates/rocm-dash-tui/src/agent/mod.rs +++ b/crates/rocm-dash-tui/src/agent/mod.rs @@ -22,10 +22,10 @@ //! `slash.rs`/`summary.rs` mechanical-relocation convention: this file holds //! the shared `AgentClient` seam; `snapshot` holds the pure JSON telemetry //! helpers; `tools` holds the rig `Tool` wrappers and dispatch; `clients` -//! holds the four backend implementations. Unlike `app/mod.rs`'s private -//! siblings, this file re-exports the submodules' public items — this module -//! (and its pre-split `crate::agent::*` surface) has in-crate and cross-crate -//! consumers, so the existing paths must keep resolving. +//! holds the four backend implementations. Like `app/mod.rs`, this file +//! re-exports the submodules' public items — this module (and its pre-split +//! `crate::agent::*` surface) has in-crate and cross-crate consumers, so the +//! existing paths must keep resolving. use async_trait::async_trait; diff --git a/crates/rocm-dash-tui/tests/dash_journeys.rs b/crates/rocm-dash-tui/tests/dash_journeys.rs index f72c8a0cb..31117a8b4 100644 --- a/crates/rocm-dash-tui/tests/dash_journeys.rs +++ b/crates/rocm-dash-tui/tests/dash_journeys.rs @@ -14,8 +14,8 @@ //! characterization in `dash_characterization.rs`. //! //! Note: the key→action reducer (`handle_key`/`apply_action`) is module-private, -//! so its keystroke-level tests live in-module (`src/app/mod.rs`); here we drive -//! the equivalent public state mutations an integration crate can reach. +//! so its keystroke-level tests live in-module (`src/app/actions.rs`); here we +//! drive the equivalent public state mutations an integration crate can reach. use ratatui::Terminal; use ratatui::backend::TestBackend; From a647de47d0faa109668b212ea4a2e15ec31635b0 Mon Sep 17 00:00:00 2001 From: Jussi Elo Date: Fri, 2 Oct 2026 15:01:38 +0000 Subject: [PATCH 5/6] ROCMAI-84: drop dead NO_CHAT_BACKEND_MSG re-export from app/mod.rs Signed-off-by: Jussi Elo --- crates/rocm-dash-tui/src/app/mod.rs | 5 ----- docs/architecture.md | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/crates/rocm-dash-tui/src/app/mod.rs b/crates/rocm-dash-tui/src/app/mod.rs index 3e8bdf4ef..59758b899 100644 --- a/crates/rocm-dash-tui/src/app/mod.rs +++ b/crates/rocm-dash-tui/src/app/mod.rs @@ -47,11 +47,6 @@ pub use types::{ ActiveTab, ChatConsent, ChatKeyCtx, ChatRole, ChatTurn, ConnState, Focus, Modal, PlannedAction, ReplayState, ResolvedArgs, UpdateStatus, format_mmss, }; -// `NO_CHAT_BACKEND_MSG` has no current caller through this `crate::app::` -// path (call sites use `super::types::` directly), but it was reachable here -// pre-split and the re-export list claims parity with `crate::app::*`. -#[allow(unused_imports)] -pub(crate) use types::NO_CHAT_BACKEND_MSG; pub(crate) use types::{ChatProvider, PendingApproval, SlashOutcome, SlashToolRequest}; /// How many snapshots to keep for sparklines. diff --git a/docs/architecture.md b/docs/architecture.md index a165a73a1..b937592b0 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,7 @@ Already-extracted subsystem modules include `diagnose.rs`, `examine.rs`, and sev ### `crates/rocm-dash-core`, `rocm-dash-collectors`, `rocm-dash-daemon`, `rocm-dash-tui` — dashboard/telemetry -`rocm-dash-tui`'s `agent/mod.rs`/`agent/snapshot.rs`/`agent/tools.rs`/`agent/clients.rs` and `app/mod.rs`/`app/types.rs`/`app/event_loop.rs`/`app/scrollbar.rs`/`app/actions.rs`/`app/chat.rs`/`app/slash.rs`/`app/summary.rs` are every file this phase's dashboard-TUI split touches. The old agent.rs was split into `agent/mod.rs` (the `AgentClient` seam, `AgentError`, `StateSnapshot`, `InferenceParams`, `REQUEST_TIMEOUT`), `agent/snapshot.rs` (pure JSON telemetry helpers with no `rig` dependency), `agent/tools.rs` (the `rig::tool::Tool` "Skill" wrappers and ROCm read/mutating tool dispatch), and `agent/clients.rs` (the `RigAgentClient`/`ChatGptAgentClient`/`AnthropicAgentClient`/`MockAgentClient` backends) — a mechanical relocation, since the shared seam types stay in `agent/mod.rs` and are reached from the split-out files via `super::`. `app/mod.rs` is modularized (ROCMAI-84, Phase 4 of EAI-7768's sequencing) into an app/ directory following full domain extraction: `app/types.rs` (shared type/enum defs — `Focus`, `ResolvedArgs`, connection/tab/chat/replay state, `Modal`, `UpdateStatus`, and the slash/plan/approval payload types; no `AppState` access), `app/event_loop.rs` (terminal lifecycle, signal handling, and the tick loop — `run`, `event_loop`, the termination-signal watcher, and the startup-focus / Updates-tile tick helpers), `app/scrollbar.rs` (mouse/scroll hit-testing: resolving a raw `MouseEvent` against recorded scrollbar tracks, the tab bar, and footer-legend chips into a `KeyAction`), `app/actions.rs` (`KeyAction` dispatch: translating a key press or resolved mouse hit into a `KeyAction` and applying it to reducer state). `app/mod.rs` keeps only `AppState` and its `apply_event`/`apply_action`-adjacent reducer impl — the modularization effort's "reducer's reason to exist." `crate::app::*` paths for everything moved out are unchanged via re-exports from `app/mod.rs`. `app/chat.rs`, `app/slash.rs`, and `app/summary.rs` were previously extracted from `app/mod.rs` following the same mechanical-relocation convention this phase's agent.rs split mirrors. `crates/rocm-dash-tui/src/ui/approval.rs` is the shared component for approval-state prompts — reuse it rather than hand-rolling new approval UI. +`rocm-dash-tui`'s `agent/mod.rs`/`agent/snapshot.rs`/`agent/tools.rs`/`agent/clients.rs` and `app/mod.rs`/`app/types.rs`/`app/event_loop.rs`/`app/scrollbar.rs`/`app/actions.rs`/`app/chat.rs`/`app/slash.rs`/`app/summary.rs` are every file this phase's dashboard-TUI split touches. The old agent.rs was split into `agent/mod.rs` (the `AgentClient` seam, `AgentError`, `StateSnapshot`, `InferenceParams`, `REQUEST_TIMEOUT`), `agent/snapshot.rs` (pure JSON telemetry helpers with no `rig` dependency), `agent/tools.rs` (the `rig::tool::Tool` "Skill" wrappers and ROCm read/mutating tool dispatch), and `agent/clients.rs` (the `RigAgentClient`/`ChatGptAgentClient`/`AnthropicAgentClient`/`MockAgentClient` backends) — a mechanical relocation, since the shared seam types stay in `agent/mod.rs` and are reached from the split-out files via `super::`. `app/mod.rs` is modularized (ROCMAI-84, Phase 4 of EAI-7768's sequencing) into an app/ directory following full domain extraction: `app/types.rs` (shared type/enum defs — `Focus`, `ResolvedArgs`, connection/tab/chat/replay state, `Modal`, `UpdateStatus`, and the slash/plan/approval payload types; no `AppState` access), `app/event_loop.rs` (terminal lifecycle, signal handling, and the tick loop — `run`, `event_loop`, the termination-signal watcher, and the startup-focus / Updates-tile tick helpers), `app/scrollbar.rs` (mouse/scroll hit-testing: resolving a raw `MouseEvent` against recorded scrollbar tracks, the tab bar, and footer-legend chips into a `KeyAction`), `app/actions.rs` (`KeyAction` dispatch: translating a key press or resolved mouse hit into a `KeyAction` and applying it to reducer state). `app/mod.rs` keeps only `AppState` and its `apply_event`/`apply_action`-adjacent reducer impl — the modularization effort's "reducer's reason to exist." `crate::app::*` paths for the public surface moved out are unchanged via re-exports from `app/mod.rs`; a `pub(crate)` item with no caller through that path isn't re-exported just because it was reachable there pre-split. `app/chat.rs`, `app/slash.rs`, and `app/summary.rs` were previously extracted from `app/mod.rs` following the same mechanical-relocation convention this phase's agent.rs split mirrors. `crates/rocm-dash-tui/src/ui/approval.rs` is the shared component for approval-state prompts — reuse it rather than hand-rolling new approval UI. ### `crates/rocm-engine-protocol` — engine IPC protocol From 49c0785f0cb1ed43f43e1a2af1cb42777e9639e4 Mon Sep 17 00:00:00 2001 From: Jussi Elo Date: Mon, 5 Oct 2026 07:22:04 +0000 Subject: [PATCH 6/6] ROCMAI-84: fix stale split-rationale comments flagged by review - Module doc on app/mod.rs no longer claims apply_action is an AppState entry point (it's a free fn in actions.rs). - "crate::app::* paths ... unchanged" claims narrowed to the public surface (NO_CHAT_BACKEND_MSG proved the broader claim false); moved the re-export completeness rationale out of architecture.md (a structure doc, not a decision log) into a comment on the re-export block it actually describes. - architecture.md's "app/mod.rs keeps only AppState ..." now also names ProviderSwitch/HISTORY_CAP/BENCH_CAP, which stay there too. - Dropped "+ event loop" from five submodules' split-rationale comments now that event_loop.rs is itself a sibling module, not part of mod.rs's core. - slash.rs's header no longer claims SlashOutcome/SlashToolRequest stay in mod.rs; they live in types.rs now, only ProviderSwitch stays. - Fixed a stale "(mirrors app.rs)" test comment to point at event_loop.rs, where that logic actually lives post-split. - Gated the restore_after_session re-export with #[cfg(test)] instead of #[allow(unused_imports)], so the compiler (not a hand-maintained comment) catches it if its only caller disappears. Signed-off-by: Jussi Elo --- crates/rocm-dash-tui/src/app/actions.rs | 2 +- crates/rocm-dash-tui/src/app/chat.rs | 6 ++--- crates/rocm-dash-tui/src/app/mod.rs | 28 ++++++++++++++--------- crates/rocm-dash-tui/src/app/scrollbar.rs | 2 +- crates/rocm-dash-tui/src/app/slash.rs | 7 +++--- crates/rocm-dash-tui/src/app/types.rs | 2 +- docs/architecture.md | 2 +- 7 files changed, 28 insertions(+), 21 deletions(-) diff --git a/crates/rocm-dash-tui/src/app/actions.rs b/crates/rocm-dash-tui/src/app/actions.rs index 1810b2c1d..3b6b02b2d 100644 --- a/crates/rocm-dash-tui/src/app/actions.rs +++ b/crates/rocm-dash-tui/src/app/actions.rs @@ -4,7 +4,7 @@ //! `KeyAction` dispatch: translating a key press (or a resolved mouse hit) //! into a `KeyAction`, and applying it to reducer state. Split out of -//! `app/mod.rs` to keep the core reducer + event loop focused. +//! `app/mod.rs` to keep the core reducer focused. use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind}; diff --git a/crates/rocm-dash-tui/src/app/chat.rs b/crates/rocm-dash-tui/src/app/chat.rs index 4eceb662a..fc0224b07 100644 --- a/crates/rocm-dash-tui/src/app/chat.rs +++ b/crates/rocm-dash-tui/src/app/chat.rs @@ -7,9 +7,9 @@ //! The provider→agent factory ([`build_chat_agent`]), the local-engine probe //! ([`detect_local_chat`] + [`fetch_first_model`]), and the config persistence //! for an accepted endpoint ([`persist_chat_endpoint`] + [`config_with_chat`]). -//! Split out of `app/mod.rs` to keep the core reducer + event loop focused. The -//! one reducer method here, [`AppState::set_chat_config`], lives with the rest -//! of the chat-backend resolution group it configures. +//! Split out of `app/mod.rs` to keep the core reducer focused. The one +//! reducer method here, [`AppState::set_chat_config`], lives with the rest of +//! the chat-backend resolution group it configures. use tokio::sync::mpsc; diff --git a/crates/rocm-dash-tui/src/app/mod.rs b/crates/rocm-dash-tui/src/app/mod.rs index 59758b899..467ff9746 100644 --- a/crates/rocm-dash-tui/src/app/mod.rs +++ b/crates/rocm-dash-tui/src/app/mod.rs @@ -2,14 +2,15 @@ // // SPDX-License-Identifier: MIT -//! Dashboard reducer: `AppState` and its `apply_event`/`apply_action` entry -//! points. +//! Dashboard reducer: `AppState` and its `apply_event` entry point (the +//! `apply_action`-adjacent reducer impl it dispatches into lives in +//! `actions.rs`). //! //! Split into focused submodules to keep this file to the reducer's core: //! `app/types.rs` (shared type/enum defs), `app/event_loop.rs` (terminal //! lifecycle + tick loop), `app/scrollbar.rs` (mouse hit-testing), and -//! `app/actions.rs` (`KeyAction` dispatch). `crate::app::*` paths for -//! everything moved out are unchanged via the re-exports below. +//! `app/actions.rs` (`KeyAction` dispatch). `crate::app::*` paths for the +//! public surface moved out are unchanged via the re-exports below. use std::collections::{HashMap, VecDeque}; @@ -20,8 +21,8 @@ use rocm_dash_core::protocol::Event; use crate::ui::theme::Theme; // Submodules holding cohesive pieces of `AppState` + free fns split out of -// this file to keep the core reducer + event loop focused (a file→dir module -// move: `crate::app::*` paths are unchanged). +// this file to keep the core reducer focused (a file→dir module move: +// `crate::app::*` paths for the public surface are unchanged). mod actions; mod chat; mod event_loop; @@ -30,6 +31,10 @@ mod slash; mod summary; mod types; +// Re-exports restoring the pre-split `crate::app::*` public surface. A +// `pub(crate)` item with no caller through that path isn't re-exported just +// because it was reachable there pre-split (see the removed +// `NO_CHAT_BACKEND_MSG` re-export this rule cost). pub use actions::{KeyAction, handle_mouse, tab_bar_hit}; pub(crate) use event_loop::{ HOME_UPDATE_CHECK_JOB_ID, SHUTTING_DOWN, exit_on_ctrl_c, is_ctrl_c, lock_terminal_writer, @@ -37,10 +42,11 @@ pub(crate) use event_loop::{ }; pub use event_loop::{run, spawn_termination_watcher}; -// Only reached today via a test (`launcher.rs`'s -// `the_front_door_comes_back_after_a_session_ends_cleanly`), so a plain -// (non-test) build sees no caller through this `crate::app::` path. -#[allow(unused_imports)] +// Only reached via a test (`launcher.rs`'s +// `the_front_door_comes_back_after_a_session_ends_cleanly`); gated to that +// build so the compiler (not a hand-maintained `#[allow]`) flags this as dead +// if that caller ever disappears. +#[cfg(test)] pub(crate) use event_loop::restore_after_session; pub use scrollbar::{FooterChip, PaneFocus, ScrollDrag, ScrollTarget, ScrollbarHandle}; pub use types::{ @@ -2301,7 +2307,7 @@ mod tests { agent.as_ref().unwrap(), local_agent.as_ref().unwrap() )); - // The Local arm's restore line (mirrors app.rs): the factory cannot help. + // The Local arm's restore line (mirrors event_loop.rs): the factory cannot help. let args = args_with_anthropic_key(Some("k")); assert!(build_chat_agent(ChatProvider::Local, &args, None, tx).is_none()); agent = local_agent.clone(); diff --git a/crates/rocm-dash-tui/src/app/scrollbar.rs b/crates/rocm-dash-tui/src/app/scrollbar.rs index c7e82a033..b2da85bf9 100644 --- a/crates/rocm-dash-tui/src/app/scrollbar.rs +++ b/crates/rocm-dash-tui/src/app/scrollbar.rs @@ -4,7 +4,7 @@ //! Mouse/scroll hit-testing: resolving a raw `MouseEvent` against recorded //! scrollbar tracks, the tab bar, and footer-legend chips into a `KeyAction`. -//! Split out of `app/mod.rs` to keep the core reducer + event loop focused. +//! Split out of `app/mod.rs` to keep the core reducer focused. #[cfg(test)] use crossterm::event::KeyModifiers; diff --git a/crates/rocm-dash-tui/src/app/slash.rs b/crates/rocm-dash-tui/src/app/slash.rs index b136709f5..a1181bfed 100644 --- a/crates/rocm-dash-tui/src/app/slash.rs +++ b/crates/rocm-dash-tui/src/app/slash.rs @@ -8,9 +8,10 @@ //! either mutates reducer state directly (nav / overlays), raises a one-shot //! executor edge (`slash_tool` / `plan_request` / `provider_switch`) for the //! event loop to drain off-thread, or pushes a usage/error turn. Stays I/O-free. -//! Split out of `app/mod.rs` to keep the core reducer focused; the slash-command -//! payload types it raises (`SlashOutcome`, `SlashToolRequest`, `ProviderSwitch`) -//! stay in `mod.rs` alongside the `AppState` fields that carry them. +//! Split out of `app/mod.rs` to keep the core reducer focused. Of the +//! slash-command payload types it raises, `SlashOutcome` and +//! `SlashToolRequest` live in `types.rs`; `ProviderSwitch` stays in +//! `mod.rs`, alongside the `AppState` fields that carry them. use super::{ ActiveTab, AppState, ChatProvider, ChatTurn, Modal, ProviderSwitch, SlashOutcome, diff --git a/crates/rocm-dash-tui/src/app/types.rs b/crates/rocm-dash-tui/src/app/types.rs index 786043082..0566a3571 100644 --- a/crates/rocm-dash-tui/src/app/types.rs +++ b/crates/rocm-dash-tui/src/app/types.rs @@ -5,7 +5,7 @@ //! Shared type and enum definitions for the dashboard reducer: `Focus`, //! `ResolvedArgs`, connection/tab/chat/replay state, `Modal`, `UpdateStatus`, //! and the slash/plan/approval payload types. No `AppState` access — split -//! out of `app/mod.rs` to keep the core reducer + event loop focused. +//! out of `app/mod.rs` to keep the core reducer focused. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Focus { diff --git a/docs/architecture.md b/docs/architecture.md index b937592b0..f5a21caac 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -39,7 +39,7 @@ Already-extracted subsystem modules include `diagnose.rs`, `examine.rs`, and sev ### `crates/rocm-dash-core`, `rocm-dash-collectors`, `rocm-dash-daemon`, `rocm-dash-tui` — dashboard/telemetry -`rocm-dash-tui`'s `agent/mod.rs`/`agent/snapshot.rs`/`agent/tools.rs`/`agent/clients.rs` and `app/mod.rs`/`app/types.rs`/`app/event_loop.rs`/`app/scrollbar.rs`/`app/actions.rs`/`app/chat.rs`/`app/slash.rs`/`app/summary.rs` are every file this phase's dashboard-TUI split touches. The old agent.rs was split into `agent/mod.rs` (the `AgentClient` seam, `AgentError`, `StateSnapshot`, `InferenceParams`, `REQUEST_TIMEOUT`), `agent/snapshot.rs` (pure JSON telemetry helpers with no `rig` dependency), `agent/tools.rs` (the `rig::tool::Tool` "Skill" wrappers and ROCm read/mutating tool dispatch), and `agent/clients.rs` (the `RigAgentClient`/`ChatGptAgentClient`/`AnthropicAgentClient`/`MockAgentClient` backends) — a mechanical relocation, since the shared seam types stay in `agent/mod.rs` and are reached from the split-out files via `super::`. `app/mod.rs` is modularized (ROCMAI-84, Phase 4 of EAI-7768's sequencing) into an app/ directory following full domain extraction: `app/types.rs` (shared type/enum defs — `Focus`, `ResolvedArgs`, connection/tab/chat/replay state, `Modal`, `UpdateStatus`, and the slash/plan/approval payload types; no `AppState` access), `app/event_loop.rs` (terminal lifecycle, signal handling, and the tick loop — `run`, `event_loop`, the termination-signal watcher, and the startup-focus / Updates-tile tick helpers), `app/scrollbar.rs` (mouse/scroll hit-testing: resolving a raw `MouseEvent` against recorded scrollbar tracks, the tab bar, and footer-legend chips into a `KeyAction`), `app/actions.rs` (`KeyAction` dispatch: translating a key press or resolved mouse hit into a `KeyAction` and applying it to reducer state). `app/mod.rs` keeps only `AppState` and its `apply_event`/`apply_action`-adjacent reducer impl — the modularization effort's "reducer's reason to exist." `crate::app::*` paths for the public surface moved out are unchanged via re-exports from `app/mod.rs`; a `pub(crate)` item with no caller through that path isn't re-exported just because it was reachable there pre-split. `app/chat.rs`, `app/slash.rs`, and `app/summary.rs` were previously extracted from `app/mod.rs` following the same mechanical-relocation convention this phase's agent.rs split mirrors. `crates/rocm-dash-tui/src/ui/approval.rs` is the shared component for approval-state prompts — reuse it rather than hand-rolling new approval UI. +`rocm-dash-tui`'s `agent/mod.rs`/`agent/snapshot.rs`/`agent/tools.rs`/`agent/clients.rs` and `app/mod.rs`/`app/types.rs`/`app/event_loop.rs`/`app/scrollbar.rs`/`app/actions.rs`/`app/chat.rs`/`app/slash.rs`/`app/summary.rs` are every file this phase's dashboard-TUI split touches. The old agent.rs was split into `agent/mod.rs` (the `AgentClient` seam, `AgentError`, `StateSnapshot`, `InferenceParams`, `REQUEST_TIMEOUT`), `agent/snapshot.rs` (pure JSON telemetry helpers with no `rig` dependency), `agent/tools.rs` (the `rig::tool::Tool` "Skill" wrappers and ROCm read/mutating tool dispatch), and `agent/clients.rs` (the `RigAgentClient`/`ChatGptAgentClient`/`AnthropicAgentClient`/`MockAgentClient` backends) — a mechanical relocation, since the shared seam types stay in `agent/mod.rs` and are reached from the split-out files via `super::`. `app/mod.rs` is modularized (ROCMAI-84, Phase 4 of EAI-7768's sequencing) into an app/ directory following full domain extraction: `app/types.rs` (shared type/enum defs — `Focus`, `ResolvedArgs`, connection/tab/chat/replay state, `Modal`, `UpdateStatus`, and the slash/plan/approval payload types; no `AppState` access), `app/event_loop.rs` (terminal lifecycle, signal handling, and the tick loop — `run`, `event_loop`, the termination-signal watcher, and the startup-focus / Updates-tile tick helpers), `app/scrollbar.rs` (mouse/scroll hit-testing: resolving a raw `MouseEvent` against recorded scrollbar tracks, the tab bar, and footer-legend chips into a `KeyAction`), `app/actions.rs` (`KeyAction` dispatch: translating a key press or resolved mouse hit into a `KeyAction` and applying it to reducer state). `app/mod.rs` keeps only `AppState`, its `apply_event`/`apply_action`-adjacent reducer impl, and the handful of items that stay with it (`ProviderSwitch`, `HISTORY_CAP`, `BENCH_CAP`) — the modularization effort's "reducer's reason to exist." `crate::app::*` paths for the public surface moved out are unchanged via re-exports from `app/mod.rs` (see the re-export block's comment for what counts as "public surface"). `app/chat.rs`, `app/slash.rs`, and `app/summary.rs` were previously extracted from `app/mod.rs` following the same mechanical-relocation convention this phase's agent.rs split mirrors. `crates/rocm-dash-tui/src/ui/approval.rs` is the shared component for approval-state prompts — reuse it rather than hand-rolling new approval UI. ### `crates/rocm-engine-protocol` — engine IPC protocol