diff --git a/CHANGELOG.md b/CHANGELOG.md index b9915c95d3..f5d933df1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -25,8 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 entry for it — membership (`codewhale login`) is the only door. - Add the Tideline component family from the ratatui translation spec (#5698's screens, riding the #5699 work-strip layout): hero startup - surface with quick actions and option strip, composer restyle with the - fluke cap, notifications inbox, merged footer band, pod ledger, receipt + surface with quick actions and option strip, notifications inbox, merged + footer band, pod ledger, receipt stream, theme list with motion toggles, live preview, settings rail, and the left rail — each a standalone render module pinned by 28 new byte-exact golden buffers. Frame wiring follows the Tideline acceptance diff --git a/crates/tui/CHANGELOG.md b/crates/tui/CHANGELOG.md index ecfd1b3f7e..d436284568 100644 --- a/crates/tui/CHANGELOG.md +++ b/crates/tui/CHANGELOG.md @@ -25,8 +25,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 entry for it — membership (`codewhale login`) is the only door. - Add the Tideline component family from the ratatui translation spec (#5698's screens, riding the #5699 work-strip layout): hero startup - surface with quick actions and option strip, composer restyle with the - fluke cap, notifications inbox, merged footer band, pod ledger, receipt + surface with quick actions and option strip, notifications inbox, merged + footer band, pod ledger, receipt stream, theme list with motion toggles, live preview, settings rail, and the left rail — each a standalone render module pinned by 28 new byte-exact golden buffers. Frame wiring follows the Tideline acceptance diff --git a/crates/tui/locales/ca.json b/crates/tui/locales/ca.json index ad828780e7..1377cb4ed2 100644 --- a/crates/tui/locales/ca.json +++ b/crates/tui/locales/ca.json @@ -1213,6 +1213,9 @@ "PhaseDone": "fet", "PhaseFailed": "fallit", "PhaseFinishing": "acabant", + "TidelineRuns": "EXECUCIONS", + "TidelineWhales": "BALENES", + "TidelineRunningCount": "{count} en execució", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/de.json b/crates/tui/locales/de.json index 1c3f9e4633..e0d7da6f98 100644 --- a/crates/tui/locales/de.json +++ b/crates/tui/locales/de.json @@ -1213,6 +1213,9 @@ "PhaseDone": "fertig", "PhaseFailed": "fehlgeschlagen", "PhaseFinishing": "schließt ab", + "TidelineRuns": "LÄUFE", + "TidelineWhales": "WALE", + "TidelineRunningCount": "{count} laufen", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/en.json b/crates/tui/locales/en.json index afb5fbcdfb..6c260e1a4d 100644 --- a/crates/tui/locales/en.json +++ b/crates/tui/locales/en.json @@ -1236,6 +1236,9 @@ "PhaseDone": "done", "PhaseFailed": "failed", "PhaseFinishing": "finishing", + "TidelineRuns": "RUNS", + "TidelineWhales": "WHALES", + "TidelineRunningCount": "{count} running", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/es-419.json b/crates/tui/locales/es-419.json index bf08ce794f..7b318026eb 100644 --- a/crates/tui/locales/es-419.json +++ b/crates/tui/locales/es-419.json @@ -1236,6 +1236,9 @@ "PhaseDone": "listo", "PhaseFailed": "falló", "PhaseFinishing": "finalizando", + "TidelineRuns": "EJECUCIONES", + "TidelineWhales": "BALLENAS", + "TidelineRunningCount": "{count} en ejecución", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/fr.json b/crates/tui/locales/fr.json index 485db040d2..76c4e8038e 100644 --- a/crates/tui/locales/fr.json +++ b/crates/tui/locales/fr.json @@ -1213,6 +1213,9 @@ "PhaseDone": "terminé", "PhaseFailed": "échoué", "PhaseFinishing": "finalisation", + "TidelineRuns": "EXÉCUTIONS", + "TidelineWhales": "BALEINES", + "TidelineRunningCount": "{count} en cours", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/hi.json b/crates/tui/locales/hi.json index be6877c2b1..50b14d783f 100644 --- a/crates/tui/locales/hi.json +++ b/crates/tui/locales/hi.json @@ -1213,6 +1213,9 @@ "PhaseDone": "पूर्ण", "PhaseFailed": "विफल", "PhaseFinishing": "समापन हो रहा", + "TidelineRuns": "रन", + "TidelineWhales": "व्हेल", + "TidelineRunningCount": "{count} चल रहे हैं", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/id.json b/crates/tui/locales/id.json index ddad8fecbb..a33288403a 100644 --- a/crates/tui/locales/id.json +++ b/crates/tui/locales/id.json @@ -1213,6 +1213,9 @@ "PhaseDone": "selesai", "PhaseFailed": "gagal", "PhaseFinishing": "menyelesaikan", + "TidelineRuns": "JALAN", + "TidelineWhales": "PAUS", + "TidelineRunningCount": "{count} berjalan", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/ja.json b/crates/tui/locales/ja.json index 04656f725b..0ef858caa7 100644 --- a/crates/tui/locales/ja.json +++ b/crates/tui/locales/ja.json @@ -1236,6 +1236,9 @@ "PhaseDone": "完了", "PhaseFailed": "失敗", "PhaseFinishing": "仕上げ中", + "TidelineRuns": "実行", + "TidelineWhales": "クジラ", + "TidelineRunningCount": "{count} 実行中", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/ko.json b/crates/tui/locales/ko.json index 04da63ad2c..af3c34d5df 100644 --- a/crates/tui/locales/ko.json +++ b/crates/tui/locales/ko.json @@ -1236,6 +1236,9 @@ "PhaseDone": "완료", "PhaseFailed": "실패", "PhaseFinishing": "마무리 중", + "TidelineRuns": "실행", + "TidelineWhales": "고래", + "TidelineRunningCount": "{count}개 실행 중", "ChipModeAct": "실행", "ChipModePlan": "계획", "ChipModeOperate": "운영", diff --git a/crates/tui/locales/pt-BR.json b/crates/tui/locales/pt-BR.json index 5bebcafb1c..db26daf0fa 100644 --- a/crates/tui/locales/pt-BR.json +++ b/crates/tui/locales/pt-BR.json @@ -1236,6 +1236,9 @@ "PhaseDone": "concluído", "PhaseFailed": "falhou", "PhaseFinishing": "finalizando", + "TidelineRuns": "EXECUÇÕES", + "TidelineWhales": "BALEIAS", + "TidelineRunningCount": "{count} em execução", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/ru.json b/crates/tui/locales/ru.json index f9b7101696..93e0ccccdc 100644 --- a/crates/tui/locales/ru.json +++ b/crates/tui/locales/ru.json @@ -1213,6 +1213,9 @@ "PhaseDone": "готово", "PhaseFailed": "ошибка", "PhaseFinishing": "завершение", + "TidelineRuns": "ЗАПУСКИ", + "TidelineWhales": "КИТЫ", + "TidelineRunningCount": "{count} выполняются", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/uk.json b/crates/tui/locales/uk.json index b77760614d..d169b04993 100644 --- a/crates/tui/locales/uk.json +++ b/crates/tui/locales/uk.json @@ -1213,6 +1213,9 @@ "PhaseDone": "готово", "PhaseFailed": "невдача", "PhaseFinishing": "завершення", + "TidelineRuns": "ЗАПУСКИ", + "TidelineWhales": "КИТИ", + "TidelineRunningCount": "{count} виконуються", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/vi.json b/crates/tui/locales/vi.json index f552281a9d..943d0537cd 100644 --- a/crates/tui/locales/vi.json +++ b/crates/tui/locales/vi.json @@ -1236,6 +1236,9 @@ "PhaseDone": "xong", "PhaseFailed": "thất bại", "PhaseFinishing": "đang hoàn tất", + "TidelineRuns": "LƯỢT CHẠY", + "TidelineWhales": "CÁ VOI", + "TidelineRunningCount": "{count} đang chạy", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/zh-Hans.json b/crates/tui/locales/zh-Hans.json index 23972f4e8b..324d46d6e9 100644 --- a/crates/tui/locales/zh-Hans.json +++ b/crates/tui/locales/zh-Hans.json @@ -1236,6 +1236,9 @@ "PhaseDone": "完成", "PhaseFailed": "失败", "PhaseFinishing": "收尾中", + "TidelineRuns": "运行", + "TidelineWhales": "鲸群", + "TidelineRunningCount": "{count} 个运行中", "ChipModeAct": "work", "ChipModePlan": "plan", "ChipModeOperate": "operate", diff --git a/crates/tui/locales/zh-Hant.json b/crates/tui/locales/zh-Hant.json index 85b9fa6499..efd2d12673 100644 --- a/crates/tui/locales/zh-Hant.json +++ b/crates/tui/locales/zh-Hant.json @@ -1143,6 +1143,9 @@ "PhaseDraft": "草稿", "PhaseFailed": "失敗", "PhaseFinishing": "收尾中", + "TidelineRuns": "執行", + "TidelineWhales": "鯨群", + "TidelineRunningCount": "{count} 個執行中", "PhaseIdle": "閒置", "PhaseReading": "讀取中", "PhaseReasoning": "推理中", diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index 0863644361..2e8afb865f 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -1406,6 +1406,10 @@ pub enum MessageId { PhaseDone, PhaseFailed, PhaseFinishing, + // Active-session Tideline rail group labels. + TidelineRuns, + TidelineWhales, + TidelineRunningCount, // Underwater header chips: mode and permission words. ChipModeAct, ChipModePlan, @@ -3344,6 +3348,9 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::PhaseDone, MessageId::PhaseFailed, MessageId::PhaseFinishing, + MessageId::TidelineRuns, + MessageId::TidelineWhales, + MessageId::TidelineRunningCount, MessageId::ChipModeAct, MessageId::ChipModePlan, MessageId::ChipModeOperate, diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 8143f07dc8..b4c63b2b42 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -421,10 +421,6 @@ pub struct Settings { pub composer_vim_mode: String, /// Transcript spacing rhythm: compact, comfortable, spacious pub transcript_spacing: String, - /// Show the pre-session launch menu. When false, Codewhale enters a new - /// session directly; resume remains available in-session. - #[serde(default)] - pub launch_screen: bool, /// Default mode: "agent" (Act), "plan", or "operate". Legacy permission /// shorthands are accepted for migration but never advertised as modes. pub default_mode: String, @@ -608,7 +604,6 @@ impl Default for Settings { composer_multiline_mode: false, composer_vim_mode: "normal".to_string(), transcript_spacing: "comfortable".to_string(), - launch_screen: false, default_mode: "agent".to_string(), sidebar_width_percent: 28, sidebar_focus: "auto".to_string(), @@ -1482,9 +1477,6 @@ impl Settings { } self.transcript_spacing = normalized.to_string(); } - "launch_screen" | "launch" => { - self.launch_screen = parse_bool(value)?; - } "status_indicator" | "indicator" => { let normalized = normalize_status_indicator(value); if !["cw", "whale", "dots", "off"].contains(&normalized) { @@ -1698,7 +1690,6 @@ impl Settings { self.workspace_follow_symlinks )); lines.push(format!(" default_mode: {}", self.default_mode)); - lines.push(format!(" launch_screen: {}", self.launch_screen)); lines.push(format!(" context_panel: {}", self.context_panel)); lines.push(format!(" cost_currency: {}", self.cost_currency)); lines.push(format!(" max_history: {}", self.max_input_history)); @@ -1876,10 +1867,6 @@ impl Settings { "transcript_spacing", "Transcript spacing: compact, comfortable, spacious", ), - ( - "launch_screen", - "Show the pre-session launch menu on startup: on/off", - ), ( "status_indicator", "Header status mark, shown before the route: cw, whale, dots, off", @@ -3588,9 +3575,24 @@ mod tests { ); assert!(!settings.low_motion); assert_eq!(settings.transcript_spacing, "comfortable"); + } + + #[test] + fn retired_launch_screen_setting_is_accepted_and_dropped_on_save() { + let tmp = tempfile::tempdir().expect("tempdir"); + let path = tmp.path().join("settings.toml"); + std::fs::write(&path, "launch_screen = false\n").expect("legacy settings"); + + let settings = Settings::load_persisted_from_candidates(Some(path.clone()), None, None) + .expect("legacy setting must remain readable"); + settings + .save_to_path(&path) + .expect("save normalized settings"); + + let saved = std::fs::read_to_string(&path).expect("read normalized settings"); assert!( - !settings.launch_screen, - "returning users enter a session directly" + !saved.contains("launch_screen"), + "the retired setting must not be written back: {saved}" ); } diff --git a/crates/tui/src/test_support.rs b/crates/tui/src/test_support.rs index f8ae799a84..85ddc0641f 100644 --- a/crates/tui/src/test_support.rs +++ b/crates/tui/src/test_support.rs @@ -324,11 +324,18 @@ pub(crate) fn test_tui_options(workspace: impl AsRef) -> crate::tui::app:: /// `App::new` consults real persisted settings (provider/model maps, /// auto-model, route limits, locale, currency), so an un-pinned fixture /// computes against whatever the developer last configured. Every pin below -/// exists because some test was observed to depend on it. +/// exists because some test was observed to depend on it. This fixture models +/// a session after the user has chosen a Startup action; direct `App::new` +/// tests remain the clean-launch authority. pub(crate) fn test_app_with_options(options: crate::tui::app::TuiOptions) -> crate::tui::app::App { let config = crate::config::Config::default(); let mut app = crate::tui::app::App::new(options, &config); + // Shared behavior tests operate on the live session surface. Do not make + // the production startup conditional for them: clean launches are covered + // by direct `App::new` tests that retain the Tideline Startup Hero. + app.launch.visible = false; + // Deterministic presentation regardless of host locale. app.cost_currency = crate::pricing::CostCurrency::Usd; app.ui_locale = crate::localization::Locale::En; diff --git a/crates/tui/src/tui/app/init.rs b/crates/tui/src/tui/app/init.rs index 0870490682..0a84ca4a03 100644 --- a/crates/tui/src/tui/app/init.rs +++ b/crates/tui/src/tui/app/init.rs @@ -113,8 +113,12 @@ impl App { false }; settings.apply_env_overrides(); - let launch_visible = - settings.launch_screen && resume_session_id.is_none() && initial_input.is_none(); + // Tideline Startup is the fresh interactive landing surface. It must + // not be bypassed by a stale historical `launch_screen = false`, a + // provider/config notice, or a previous session record: only an + // intentional resume or explicit initial input enters the live session + // path directly. + let launch_visible = resume_session_id.is_none() && initial_input.is_none(); let launch = LaunchState::new(launch_visible, &workspace); // If settings.toml exists on disk but couldn't be parsed (we fell back diff --git a/crates/tui/src/tui/app/tests.rs b/crates/tui/src/tui/app/tests.rs index 3d041ad652..5f5a0bff34 100644 --- a/crates/tui/src/tui/app/tests.rs +++ b/crates/tui/src/tui/app/tests.rs @@ -134,6 +134,10 @@ fn initial_input_prefill_waits_for_manual_submit() { let app = App::new(options, &Config::default()); + assert!( + !app.launch.visible, + "an intentional prefilled prompt must enter the live composer instead of the startup hero" + ); assert_eq!(app.input, "review this PR"); assert_eq!(app.cursor_position, "review this PR".chars().count()); assert!(!app.auto_submit_initial_input); @@ -148,6 +152,10 @@ fn initial_input_submit_marks_startup_dispatch() { let app = App::new(options, &Config::default()); + assert!( + !app.launch.visible, + "an intentional submitted prompt must bypass the startup hero" + ); assert_eq!(app.input, "阅读项目 and wait for instructions"); assert_eq!( app.cursor_position, @@ -156,6 +164,60 @@ fn initial_input_submit_marks_startup_dispatch() { assert!(app.auto_submit_initial_input); } +#[test] +fn clean_launch_keeps_startup_hero_despite_a_startup_notice() { + let _env_lock = lock_test_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); + std::fs::write(tmp.path().join("settings.toml"), "launch_screen = false\n") + .expect("legacy settings"); + let mut options = test_options(false); + options.startup_notice = + Some("Provider route changed; inspect the route before sending".into()); + + let app = App::new(options, &Config::default()); + + assert!( + app.launch.visible, + "a fresh interactive launch must keep the Tideline startup hero visible; a notice is not an intentional resume or prompt" + ); +} + +#[test] +fn explicit_resume_bypasses_startup_hero() { + let _env_lock = lock_test_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); + let mut options = test_options(false); + options.resume_session_id = Some("explicit-resume".into()); + + let app = App::new(options, &Config::default()); + + assert!( + !app.launch.visible, + "an explicit resume must preserve the existing session path" + ); +} + +#[test] +fn remote_control_initial_input_bypasses_startup_hero() { + let _env_lock = lock_test_env(); + let tmp = tempfile::tempdir().expect("tempdir"); + let config_path = tmp.path().join("config.toml"); + let _config_env = EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", &config_path); + let mut options = test_options(false); + options.initial_input = Some(InitialInput::RemoteControl); + + let app = App::new(options, &Config::default()); + + assert!( + !app.launch.visible, + "an intentional remote-control launch must preserve its existing direct-session path" + ); +} + #[test] fn composer_arrows_scroll_default_is_true_without_mouse_capture() { assert!(default_composer_arrows_scroll_for_platform(false, false)); diff --git a/crates/tui/src/tui/background_indicator.rs b/crates/tui/src/tui/background_indicator.rs index 47f65b0c20..e0cf7e0b00 100644 --- a/crates/tui/src/tui/background_indicator.rs +++ b/crates/tui/src/tui/background_indicator.rs @@ -40,7 +40,7 @@ use ratatui::{ use unicode_width::UnicodeWidthStr; use crate::localization::truncate_to_width; -use crate::tui::app::{App, TaskPanelEntryKind}; +use crate::tui::app::{App, TaskPanelEntry, TaskPanelEntryKind}; /// Per-item label cap so one long command or objective cannot eat the whole /// row before the whole-line truncation kicks in. @@ -57,6 +57,17 @@ pub enum PendingItemKind { Agent, } +/// Lifecycle state carried by the App's background-work projection. +/// +/// The task panel currently receives wire-status tokens, so normalize them at +/// the projection boundary. Renderers then consume this typed state instead +/// of re-interpreting status strings. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PendingItemState { + Queued, + Running, +} + impl PendingItemKind { /// Singular noun used for the count summary ("1 shell", "2 agents"). #[must_use] @@ -81,6 +92,7 @@ impl PendingItemKind { #[derive(Debug, Clone, PartialEq, Eq)] pub struct PendingItem { pub kind: PendingItemKind, + pub state: PendingItemState, /// Short human label (task id / role / name / command), pre-truncated. pub label: String, } @@ -104,6 +116,11 @@ impl PendingWork { self.items.iter().filter(|item| item.kind == kind).count() } + #[must_use] + pub fn count_state(&self, state: PendingItemState) -> usize { + self.items.iter().filter(|item| item.state == state).count() + } + /// Compact one-line chip text. `None` when nothing is pending (the /// caller then reserves zero layout rows). `Some(String::new())` for a /// zero-width budget is never rendered. @@ -148,10 +165,25 @@ fn truncate_label(label: &str) -> String { } } -/// Build the pending-work snapshot from the same state the Work strip and -/// `/jobs` surface render. Read-only; no locks, no registries. +/// Build the composer pending-work snapshot from the same state the Work +/// strip and `/jobs` surface render. Read-only; no locks, no registries. +/// +/// Live shells deliberately remain only on the detailed Work strip, rather +/// than being repeated in the composer crumb. Use [`live_work_from_app`] for +/// a whole-session status surface that must account for them. #[must_use] pub fn pending_work_from_app(app: &App) -> PendingWork { + collect_pending_work(app, false) +} + +/// Build the complete live-work projection for an active-session status +/// surface. Unlike [`pending_work_from_app`], this includes live shell jobs. +#[must_use] +pub(crate) fn live_work_from_app(app: &App) -> PendingWork { + collect_pending_work(app, true) +} + +fn collect_pending_work(app: &App, include_shells: bool) -> PendingWork { let mut items: Vec = Vec::new(); // Background shells and durable tasks: the merged task_panel snapshot @@ -159,20 +191,30 @@ pub fn pending_work_from_app(app: &App) -> PendingWork { // carry a `shell: ` summary; durable/RLM tasks carry their own // prompt summary and their task id is the stable label. for entry in &app.task_panel { - if entry.kind != TaskPanelEntryKind::Background { + let Some(state) = pending_item_state(entry) else { continue; - } - if !matches!(entry.status.as_str(), "running" | "queued") { - continue; - } + }; // Live shells belong on the work strip (`▾ Shells N`), not this // composer crumb. A dual surface hid the PTY behind hourglasses. - if entry.prompt_summary.starts_with("shell: ") || entry.id.starts_with("shell_") { + let is_shell = is_live_shell_entry(entry); + if is_shell && !include_shells { continue; } - let (kind, raw_label) = (PendingItemKind::Task, entry.id.as_str()); + let (kind, raw_label) = if is_shell { + ( + PendingItemKind::Shell, + entry + .prompt_summary + .strip_prefix("shell: ") + .filter(|command| !command.trim().is_empty()) + .unwrap_or(entry.id.as_str()), + ) + } else { + (PendingItemKind::Task, entry.id.as_str()) + }; items.push(PendingItem { kind, + state, label: truncate_label(raw_label), }); } @@ -211,6 +253,7 @@ pub fn pending_work_from_app(app: &App) -> PendingWork { }; items.push(PendingItem { kind: PendingItemKind::Agent, + state: PendingItemState::Running, label: truncate_label(&label), }); } @@ -221,6 +264,7 @@ pub fn pending_work_from_app(app: &App) -> PendingWork { let label = app.agent_display_label(id); items.push(PendingItem { kind: PendingItemKind::Agent, + state: PendingItemState::Running, label: truncate_label(&label), }); } @@ -228,6 +272,30 @@ pub fn pending_work_from_app(app: &App) -> PendingWork { PendingWork { items } } +/// Normalize the task-panel's serialized lifecycle token once at the +/// projection boundary. Consumers should use [`PendingItemState`] rather than +/// comparing these wire values in their render paths. +#[must_use] +pub(crate) fn pending_item_state(entry: &TaskPanelEntry) -> Option { + if entry.kind != TaskPanelEntryKind::Background { + return None; + } + match entry.status.as_str() { + "queued" => Some(PendingItemState::Queued), + "running" => Some(PendingItemState::Running), + _ => None, + } +} + +/// Whether a task-panel row is a currently live shell job. This is shared by +/// the detailed Work strip and compact live-status projections so a shell +/// cannot be omitted or classified differently between surfaces. +#[must_use] +pub(crate) fn is_live_shell_entry(entry: &TaskPanelEntry) -> bool { + pending_item_state(entry).is_some() + && (entry.prompt_summary.starts_with("shell: ") || entry.id.starts_with("shell_")) +} + /// Paint the one-row pending-work chip. No-op when `area` is empty or `work` /// holds no items (callers normally gate on `is_empty` for the row budget). pub fn render(area: Rect, buf: &mut Buffer, app: &App, work: &PendingWork) { @@ -270,6 +338,7 @@ mod tests { fn shell(label: &str) -> PendingItem { PendingItem { kind: PendingItemKind::Shell, + state: PendingItemState::Running, label: truncate_label(label), } } @@ -277,6 +346,7 @@ mod tests { fn task(label: &str) -> PendingItem { PendingItem { kind: PendingItemKind::Task, + state: PendingItemState::Running, label: truncate_label(label), } } @@ -284,6 +354,7 @@ mod tests { fn agent(label: &str) -> PendingItem { PendingItem { kind: PendingItemKind::Agent, + state: PendingItemState::Running, label: truncate_label(label), } } @@ -441,6 +512,55 @@ mod tests { assert!(cleared.is_empty(), "completion clears the indicator"); } + #[test] + fn live_work_projection_keeps_shells_and_typed_task_states() { + use crate::tui::app::TaskPanelEntry; + let options = crate::test_support::test_tui_options(std::path::PathBuf::from(".")); + let mut app = crate::test_support::test_app_with_options(options); + app.task_panel.extend([ + TaskPanelEntry { + id: "shell_a1b2c3d4".to_string(), + status: "running".to_string(), + prompt_summary: "shell: cargo test -p codewhale-tui".to_string(), + duration_ms: Some(42_000), + kind: TaskPanelEntryKind::Background, + stale: false, + elapsed_since_output_ms: None, + owner_agent_id: None, + owner_agent_name: None, + current_tool: None, + role: None, + files_touched: 0, + }, + TaskPanelEntry { + id: "durable-queued".to_string(), + status: "queued".to_string(), + prompt_summary: "durable work".to_string(), + duration_ms: None, + kind: TaskPanelEntryKind::Background, + stale: false, + elapsed_since_output_ms: None, + owner_agent_id: None, + owner_agent_name: None, + current_tool: None, + role: None, + files_touched: 0, + }, + ]); + + let live = live_work_from_app(&app); + assert_eq!(live.count(PendingItemKind::Shell), 1); + assert_eq!(live.count_state(PendingItemState::Queued), 1); + assert_eq!(live.count_state(PendingItemState::Running), 1); + assert!( + live.items + .iter() + .any(|item| item.kind == PendingItemKind::Shell + && item.state == PendingItemState::Running), + "live shell must retain its running state: {live:?}" + ); + } + #[test] fn pending_agents_are_labelled_by_the_name_they_were_dispatched_under() { use crate::tools::subagent::{ diff --git a/crates/tui/src/tui/composer_chrome.rs b/crates/tui/src/tui/composer_chrome.rs index 213fa875d0..ee3f63a059 100644 --- a/crates/tui/src/tui/composer_chrome.rs +++ b/crates/tui/src/tui/composer_chrome.rs @@ -147,7 +147,6 @@ use unicode_width::UnicodeWidthStr; use crate::palette::{ChromeInk, UiTheme, chrome_style}; /// The composer's fixed docked height in the work-screen shell (spec §5b). -#[allow(dead_code)] // translation scaffolding: wired by the landing slice pub const TIDELINE_COMPOSER_HEIGHT: u16 = 4; /// What the caller owes the composer chrome. Draft, queued-crumb, and @@ -227,33 +226,95 @@ fn put(buf: &mut Buffer, x: u16, y: u16, text: &str, style: Style) { buf.set_stringn(x, y, text, width, style); } -/// Paint the composer chrome. Deterministic: the caller owns the caret clock -/// (a `low_motion` caller passes the still `_`); this render shows the draft -/// and a terminal caret block. -#[allow(dead_code)] // translation scaffolding: wired by the landing slice -pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &TidelineComposer<'_>) { +fn symbol(glyph: &str, ascii_safe: bool) -> String { + if !ascii_safe { + return glyph.to_string(); + } + if let Some(fallback) = crate::tui::glyphs::ascii_fallback(glyph) { + return fallback.to_string(); + } + glyph + .chars() + .map(|ch| { + crate::tui::glyphs::ascii_fallback(&ch.to_string()) + .map(str::to_string) + .unwrap_or_else(|| ch.to_string()) + }) + .collect() +} + +/// Shared geometry for the rounded Tideline composer shell. +/// +/// Rendering, launch hit-testing, and the live composer must derive their +/// interior and submit rect from this one cell map. Otherwise a visible +/// `[↑]` can drift away from the mouse target at a terminal width boundary. +#[derive(Debug, Clone, Copy)] +pub struct TidelineComposerGeometry { + /// Interior rows, excluding the one-cell rails and the breathing space + /// immediately inside each rail. + pub content: Rect, + /// The visible three-cell `[↑]` submit affordance. + pub submit: Rect, + /// The full rounded shell; clicking it focuses the composer. + pub focus: Rect, +} + +/// Derive the fixed shell geometry. The caller must only paint the rounded +/// shell when the area is at least three rows tall. +#[must_use] +pub fn tideline_composer_geometry(area: Rect) -> TidelineComposerGeometry { + let rail_width = 1; + let send_width = 3; + let content = Rect { + x: area.x.saturating_add(2), + y: area.y.saturating_add(1), + width: area.width.saturating_sub(2 + rail_width * 2), + height: area.height.saturating_sub(2), + }; + let submit = Rect { + x: area + .x + .saturating_add(area.width.saturating_sub(rail_width + 1 + send_width)), + y: area.y.saturating_add(area.height.saturating_sub(2)), + width: send_width.min(area.width), + height: 1.min(area.height), + }; + TidelineComposerGeometry { + content, + submit, + focus: area, + } +} + +/// Paint only the shared rounded shell and its visible `[↑]` submit target. +/// +/// Content remains caller-owned: the launch surface supplies its localized +/// placeholder/caret/hint projection, while the live composer supplies its +/// multiline editor. Sharing this shell keeps the visual component and exact +/// submit geometry coherent without creating a second input authority. +pub fn render_tideline_composer_shell( + area: Rect, + buf: &mut Buffer, + theme: &UiTheme, + focused: bool, + ascii_safe: bool, +) { if area.width < 6 || area.height < 3 { return; } - let theme = composer.theme; - let border_ink = if composer.focused { + let border_ink = if focused { ChromeInk::Info } else { ChromeInk::MetadataDim }; let border = chrome(theme, border_ink); - - // Rounded border. Top row: `╭──…──╮` — the hand-drawn crown fluke that - // used to replace the top-right corner was deleted by the founder - // decree; the corner is a plain `╮` again, waking with the border only. let top_fill = usize::from(area.width.saturating_sub(2).max(1)); let top: String = std::iter::once('╭') .chain(std::iter::repeat_n('─', top_fill)) .chain(std::iter::once('╮')) .collect(); - put(buf, area.x, area.y, &composer.sym(&top), border); + put(buf, area.x, area.y, &symbol(&top, ascii_safe), border); - // Bottom row: `╰──…──╯`. let bottom_fill = usize::from(area.width.saturating_sub(2)); let bottom: String = std::iter::once('╰') .chain(std::iter::repeat_n('─', bottom_fill)) @@ -263,23 +324,67 @@ pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &Tidelin buf, area.x, area.y + area.height - 1, - &composer.sym(&bottom), + &symbol(&bottom, ascii_safe), border, ); - // Side rails. - let rail = composer.sym("│"); - let rail_w = rail.width() as u16; + let rail = symbol("│", ascii_safe); + let rail_width = rail.width() as u16; for y in (area.y + 1)..(area.y + area.height - 1) { put(buf, area.x, y, &rail, border); - put(buf, area.x + area.width - rail_w, y, &rail, border); + put(buf, area.x + area.width - rail_width, y, &rail, border); } - let inner_x = area.x + 2; - let inner_w = area.width.saturating_sub(2 + rail_w * 2).max(1); - let content_top = area.y + 1; + render_tideline_composer_submit(area, buf, theme, focused, ascii_safe); +} + +/// Paint or restore the visible `[↑]` affordance above caller-owned content. +/// +/// The standalone shell paints it immediately. The multiline work composer +/// calls this again after it has painted a long input or queued crumb, so that +/// content can never overwrite the one cell target the user is meant to click. +pub fn render_tideline_composer_submit( + area: Rect, + buf: &mut Buffer, + theme: &UiTheme, + focused: bool, + ascii_safe: bool, +) { + if area.width < 6 || area.height < 3 { + return; + } + let geometry = tideline_composer_geometry(area); + let send = symbol("[↑]", ascii_safe); + let send_ink = if focused { + ChromeInk::Active + } else { + ChromeInk::MetadataDim + }; + put( + buf, + geometry.submit.x, + geometry.submit.y, + &send, + chrome(theme, send_ink), + ); +} + +/// Paint the composer chrome. Deterministic: the caller owns the caret clock +/// (a `low_motion` caller passes the still `_`); this render shows the draft +/// and a terminal caret block. +pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &TidelineComposer<'_>) { + if area.width < 6 || area.height < 3 { + return; + } + let theme = composer.theme; + render_tideline_composer_shell(area, buf, theme, composer.focused, composer.ascii_safe); + + let geometry = tideline_composer_geometry(area); + let inner_x = geometry.content.x; + let inner_w = geometry.content.width.max(1); + let content_top = geometry.content.y; // Last row *inside* the border (the bottom border owns the final row). - let content_bottom = area.y + area.height - 2; + let content_bottom = geometry.content.bottom().saturating_sub(1); // Content rows: the crumb (if any) sits one row above the input line // (spec §3 slot-3 merge); without a crumb the input takes the first @@ -327,24 +432,9 @@ pub fn render_tideline_composer(area: Rect, buf: &mut Buffer, composer: &Tidelin put(buf, inner_x, input_y, &line, chrome(theme, ink)); } - // Send hitbox `[↑]`, right-aligned inside the border on the last - // content row — its own quiet row unless height collapsed to one. - let send = composer.sym("[↑]"); - let send_w = send.width() as u16; - let send_x = area.x + area.width - rail_w - 1 - send_w; - let send_y = if input_y == content_bottom { - input_y - } else { - content_bottom - }; - if send_x > inner_x { - let send_ink = if composer.focused { - ChromeInk::Active - } else { - ChromeInk::MetadataDim - }; - put(buf, send_x, send_y, &send, chrome(theme, send_ink)); - } + // `render_tideline_composer_shell` paints the action for standalone + // callers; restore it after content so a long draft cannot erase it. + render_tideline_composer_submit(area, buf, theme, composer.focused, composer.ascii_safe); } /// Truncate a rendered string to `width` cells on a char boundary (never @@ -366,7 +456,6 @@ fn truncate_cells(text: &str, width: usize) -> String { /// Recorded hitboxes for one rendered composer (spec §6): the `[↑]` submit /// rect and the top-border ring (click = focus the composer). #[derive(Debug, Clone, Copy)] -#[allow(dead_code)] // translation scaffolding: wired by the landing slice pub struct TidelineComposerHitboxes { pub submit: Rect, pub border: Rect, @@ -375,24 +464,11 @@ pub struct TidelineComposerHitboxes { /// Compute the composer hitboxes for one render area; same inputs as /// [`render_tideline_composer`] so the submit rect matches painted cells. #[must_use] -#[allow(dead_code)] // translation scaffolding: wired by the landing slice pub fn tideline_composer_hitboxes(area: Rect) -> TidelineComposerHitboxes { - let rail_w = 1; - let send_w = 3; - let send_x = area.x + area.width.saturating_sub(rail_w + 1 + send_w); + let geometry = tideline_composer_geometry(area); TidelineComposerHitboxes { - submit: Rect { - x: send_x, - y: area.y + area.height.saturating_sub(2), - width: send_w, - height: 1, - }, - border: Rect { - x: area.x, - y: area.y, - width: area.width, - height: 1, - }, + submit: geometry.submit, + border: geometry.focus, } } diff --git a/crates/tui/src/tui/composer_chrome/tideline_tests.rs b/crates/tui/src/tui/composer_chrome/tideline_tests.rs index eae1b136fa..650a2283fc 100644 --- a/crates/tui/src/tui/composer_chrome/tideline_tests.rs +++ b/crates/tui/src/tui/composer_chrome/tideline_tests.rs @@ -58,6 +58,21 @@ fn composer_border_is_rounded_with_send_hitbox_and_no_crown() { assert!(send_row.contains("[↑]"), "send hitbox: {send_row:?}"); } +#[test] +fn composer_send_hitbox_survives_a_long_draft() { + let draft = "x".repeat(240); + let text = draw_docked( + 80, + 24, + &TidelineComposer::new(&UI_THEME, &draft).focused(true), + ); + let send_row = text.lines().nth(22).unwrap_or_default(); + assert!( + send_row.contains("[↑]"), + "caller-owned text must not overwrite the submit target: {send_row:?}" + ); +} + #[test] fn composer_focus_states_change_ink_not_cells() { let draft = "same draft"; diff --git a/crates/tui/src/tui/goldens/startup_100x30.txt b/crates/tui/src/tui/goldens/startup_100x30.txt index 4e23c328b5..a774d7d604 100644 --- a/crates/tui/src/tui/goldens/startup_100x30.txt +++ b/crates/tui/src/tui/goldens/startup_100x30.txt @@ -1,10 +1,10 @@ - ▚▄▄▖ ▗▄▄▟ - ▝▜███▙▟███▛▘ - ▝▀▜██▛▀▘ - ▜█ - ██▖ - ▝▀▜█▙▄▖ + ▄▄▄▄██▌ + ▜████▀▘ + ▟██▄▄ + ▟█████▖ + ▐█▟▀█████ + ▐█▜██████ What are we working on? welcome back · 4 saved sessions in this workspace @@ -24,7 +24,7 @@ Ctrl+N C T F1 - ❯ Type a message… ↑ - Tab to type -──────────────────────────────────────────────────────────────────────────────────────────────────── - +╭──────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ❯ Type a message… │ +│ Tab to type [↑] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/crates/tui/src/tui/goldens/startup_120x32.txt b/crates/tui/src/tui/goldens/startup_120x32.txt index 7773598aa6..c2503260ee 100644 --- a/crates/tui/src/tui/goldens/startup_120x32.txt +++ b/crates/tui/src/tui/goldens/startup_120x32.txt @@ -1,11 +1,11 @@ - ▚▄▄▖ ▗▄▄▟ - ▝▜███▙▟███▛▘ - ▝▀▜██▛▀▘ - ▜█ - ██▖ - ▝▀▜█▙▄▖ + ▄▄▄▄██▌ + ▜████▀▘ + ▟██▄▄ + ▟█████▖ + ▐█▟▀█████ + ▐█▜██████ What are we working on? welcome back · 4 saved sessions in this workspace @@ -26,7 +26,7 @@ Ctrl+N C T - ❯ Type a message… ↑ - Tab to type -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ❯ Type a message… │ +│ Tab to type [↑] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/crates/tui/src/tui/goldens/startup_160x40.txt b/crates/tui/src/tui/goldens/startup_160x40.txt index 2813d64ee6..2bc065fdec 100644 --- a/crates/tui/src/tui/goldens/startup_160x40.txt +++ b/crates/tui/src/tui/goldens/startup_160x40.txt @@ -1,12 +1,12 @@ - ▚▄▄▖ ▗▄▄▟ - ▝▜███▙▟███▛▘ - ▝▀▜██▛▀▘ - ▜█ - ██▖ - ▝▀▜█▙▄▖ + ▄▄▄▄██▌ + ▜████▀▘ + ▟██▄▄ + ▟█████▖ + ▐█▟▀█████ + ▐█▜██████ What are we working on? welcome back · 4 saved sessions in this workspace @@ -34,7 +34,7 @@ Ctrl+N C - ❯ Type a message… ↑ - Tab to type -──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── - +╭──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╮ +│ ❯ Type a message… │ +│ Tab to type [↑] │ +╰──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────╯ diff --git a/crates/tui/src/tui/goldens/startup_80x24.txt b/crates/tui/src/tui/goldens/startup_80x24.txt index b95d3c7214..c3e130a1ec 100644 --- a/crates/tui/src/tui/goldens/startup_80x24.txt +++ b/crates/tui/src/tui/goldens/startup_80x24.txt @@ -1,9 +1,9 @@ - ▚▄▄▖ ▗▄▄▟ - ▝▜███▙▟███▛▘ - ▝▀▜██▛▀▘ - ▜█ - ██▖ - ▝▀▜█▙▄▖ + ▄▄▄▄██▌ + ▜████▀▘ + ▟██▄▄ + ▟█████▖ + ▐█▟▀█████ + ▐█▜██████ What are we working on? welcome back · 4 saved sessions in this workspace @@ -18,7 +18,7 @@ ⑂ New worktree ◌ Chat only ◐ Theme ? Help Ctrl+N C T F1 - ❯ Type a message… ↑ - Tab to type -──────────────────────────────────────────────────────────────────────────────── - +╭──────────────────────────────────────────────────────────────────────────────╮ +│ ❯ Type a message… │ +│ Tab to type [↑] │ +╰──────────────────────────────────────────────────────────────────────────────╯ diff --git a/crates/tui/src/tui/keybindings.rs b/crates/tui/src/tui/keybindings.rs index b47578be30..c5b601284b 100644 --- a/crates/tui/src/tui/keybindings.rs +++ b/crates/tui/src/tui/keybindings.rs @@ -241,6 +241,13 @@ pub const KEYBINDINGS: &[KeybindingEntry] = &[ description_id: crate::localization::MessageId::KbCompactInspector, section: KeybindingSection::Submission, }, + KeybindingEntry { + // `/provider` remains the portable command route; F3 mirrors the + // clickable topbar route segment without consuming composer text. + chord: "F3 / /provider", + description_id: crate::localization::MessageId::CmdProviderDescription, + section: KeybindingSection::Submission, + }, KeybindingEntry { chord: "Alt+L", description_id: crate::localization::MessageId::KbLastMessagePager, @@ -531,10 +538,15 @@ mod tests { binding(ShellBindingId::ContextInspector).catalog_chord, "/context" ); + assert_eq!( + binding(ShellBindingId::ProviderRoute).catalog_chord, + "F3 / /provider" + ); assert_eq!(binding(ShellBindingId::Help).catalog_chord, "F1 / Ctrl+/"); for id in [ ShellBindingId::ToolDetails, ShellBindingId::ContextInspector, + ShellBindingId::ProviderRoute, ShellBindingId::Help, ] { let chord = binding(id).catalog_chord; diff --git a/crates/tui/src/tui/mouse_ui.rs b/crates/tui/src/tui/mouse_ui.rs index 4920078910..85ca388d93 100644 --- a/crates/tui/src/tui/mouse_ui.rs +++ b/crates/tui/src/tui/mouse_ui.rs @@ -459,6 +459,29 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec { + open_context_inspector(app); + Vec::new() + } + InteractionAction::OpenProviderPicker => { + vec![ViewEvent::TopbarRoutePickerRequested] + } + }; + } + } + // The launch surface owns the whole frame until a session is chosen. // Consume every mouse event here so wheel input cannot leak into the // transcript or composer behind the splash. @@ -542,25 +565,6 @@ pub(crate) fn handle_mouse_event(app: &mut App, mouse: MouseEvent) -> Vec open_context_inspector(app), - } - app.needs_redraw = true; - return Vec::new(); - } - } - // Ocean work surface owns its rect, scrolling, focus, and row actions. // Route it before workflow/composer/transcript so wheel events never leak // into an unrelated viewport. @@ -1830,7 +1834,7 @@ mod tests { ContextBudgetSnapshot, InspectDetail, InteractionAction, InteractionFocus, InteractionTarget, InteractionTargetId, }; - use crate::tui::views::{ContextMenuAction, ModalKind}; + use crate::tui::views::{ContextMenuAction, ModalKind, ViewEvent}; use crossterm::event::{ KeyCode, KeyEvent, KeyModifiers, MouseButton, MouseEvent, MouseEventKind, }; @@ -2060,6 +2064,33 @@ mod tests { ); } + #[test] + fn topbar_route_click_emits_provider_picker_request() { + let mut app = create_test_app(); + // The launch screen shares the same header, so this specifically + // protects against its old catch-all mouse route swallowing the + // topbar affordance before it reached the event handler. + app.launch.visible = true; + app.viewport + .interaction_targets + .register(InteractionTarget { + id: InteractionTargetId::HEADER_ROUTE, + area: Rect::new(20, 0, 24, 1), + focus: InteractionFocus::Direct, + keyboard_action: Some(InteractionAction::OpenProviderPicker), + mouse_action: Some(InteractionAction::OpenProviderPicker), + inspect_detail: InspectDetail::Route, + }); + + let events = handle_mouse_event(&mut app, left_click(24, 0)); + + assert!(matches!( + events.as_slice(), + [ViewEvent::TopbarRoutePickerRequested] + )); + assert!(app.view_stack.is_empty()); + } + #[test] fn quick_action_row_clicks_select_their_launch_table_rows() { let mut app = create_test_app(); diff --git a/crates/tui/src/tui/phase_strip.rs b/crates/tui/src/tui/phase_strip.rs index 26233c11ed..e10af588da 100644 --- a/crates/tui/src/tui/phase_strip.rs +++ b/crates/tui/src/tui/phase_strip.rs @@ -194,6 +194,17 @@ fn status_toast_ink(level: crate::tui::app::StatusToastLevel) -> ChromeInk { } } +/// Map the boot surface's typed severity through the same semantic palette as +/// every other footer fact. Keeping this conversion closed makes the plugin +/// warning/failure distinction testable without guessing from its text. +fn boot_activity_ink(level: crate::tui::session_boot::SessionBootActivityLevel) -> ChromeInk { + match level { + crate::tui::session_boot::SessionBootActivityLevel::Active => ChromeInk::Active, + crate::tui::session_boot::SessionBootActivityLevel::Attention => ChromeInk::Attention, + crate::tui::session_boot::SessionBootActivityLevel::Failure => ChromeInk::Failure, + } +} + /// Pick the notice a band owes its row to right now, if any. Shared by the /// classic activity band and the Tideline merged footer so the two can never /// disagree about which toast is live. Completion may land in the same event @@ -324,6 +335,24 @@ mod tests { ); } + #[test] + fn boot_activity_levels_keep_plugin_attention_and_failure_distinct() { + use crate::tui::session_boot::SessionBootActivityLevel; + + assert_eq!( + boot_activity_ink(SessionBootActivityLevel::Active), + ChromeInk::Active + ); + assert_eq!( + boot_activity_ink(SessionBootActivityLevel::Attention), + ChromeInk::Attention + ); + assert_eq!( + boot_activity_ink(SessionBootActivityLevel::Failure), + ChromeInk::Failure + ); + } + #[test] fn notice_clauses_split_on_sentences_and_keep_versions_and_paths_whole() { assert_eq!( @@ -578,7 +607,7 @@ pub struct TidelineFooter<'a> { /// Permission chip (`ask` / `auto review` / `full access`, plus the /// filesystem scope notice when it deviates) in its Permission ink. pub permission_chip: Option<(&'a str, crate::palette::ChromeInk)>, - /// Urgent session notice (status toast / MCP boot chip) that owns the + /// Urgent session notice (status toast / boot activity chip) that owns the /// right-hand keys slot while it is live. pub notice: Option<(&'a str, crate::palette::ChromeInk)>, pub ascii_safe: bool, @@ -930,8 +959,9 @@ pub fn tideline_footer_depth_hitbox(area: Rect, footer: &TidelineFooter<'_>) -> /// the activity band's `Esc to interrupt` while a turn is live. /// - `mode_chip`/`permission_chip` — the old header's posture lockup /// (`underwater::posture_chips`, same words, same inks). -/// - `notice` — the activity band's status toast, or the MCP boot chip when -/// no toast is live; it owns the trailing right slot while present. +/// - `notice` — the activity band's status toast, or the compact MCP/plugin +/// boot chip when no toast is live; it owns the trailing right slot while +/// present. /// /// Session metrics (turns/steps/TTFT/cache) move behind `/cost` per spec §3. pub(crate) struct TidelineFooterFacts { @@ -1029,28 +1059,18 @@ pub(crate) fn tideline_footer_from_app(app: &mut App, width: u16) -> TidelineFoo chip.map(|(text, ink)| (text.into_owned(), ink)) }; - // The notice: the live status toast if one is owed, else the MCP boot - // chip (a slow optional server must not look like a hung turn). Clause- - // shed against half the row — the depth line owns the other half. + // The notice: the live status toast if one is owed, else the compact MCP + // or plugin boot chip. A slow optional server must not look like a hung + // turn, while plugin diagnostics stay available through `/plugins` rather + // than taking rows from the transcript. Clause-shed against half the row + // — the depth line owns the other half. let notice_budget = (usize::from(width) / 2).max(8); let notice = selected_notice(app.active_status_toast(), phase, &phase_word) .map(|(text, ink, _urgent)| (text, ink)) .or_else(|| { - crate::tui::session_boot::activity_chip(app, notice_budget).map(|chip| { - let boot = crate::tui::session_boot::SessionBootSurface::from_app(app); - let ink = if boot.servers.iter().any(|row| { - matches!( - row.state, - crate::tui::session_boot::McpServerBootState::Failed - | crate::tui::session_boot::McpServerBootState::NeedsLogin - ) - }) { - crate::palette::ChromeInk::Failure - } else { - crate::palette::ChromeInk::Active - }; - (chip, ink) - }) + let boot = crate::tui::session_boot::SessionBootSurface::from_app(app); + boot.activity_notice(app.ui_locale, notice_budget) + .map(|chip| (chip.text, boot_activity_ink(chip.level))) }) .and_then(|(text, ink)| fit_notice(&text, notice_budget).map(|fitted| (fitted, ink))); diff --git a/crates/tui/src/tui/session_boot.rs b/crates/tui/src/tui/session_boot.rs index ff7fabeb99..6e8297f6fe 100644 --- a/crates/tui/src/tui/session_boot.rs +++ b/crates/tui/src/tui/session_boot.rs @@ -1,30 +1,20 @@ //! Session-page MCP + plugin boot surface. //! //! Plugin discovery and every enabled MCP server boot as a **set**, not a -//! toast per name. The activity strip carries the compact pulse -//! (`MCP · 4 connecting`); the receipt under it keeps per-server outcomes -//! and next actions until retry succeeds. Slack is one server in that set. - -use std::borrow::Cow; - -use ratatui::{ - buffer::Buffer, - layout::Rect, - style::Style, - text::{Line, Span}, - widgets::{Block, Paragraph, Widget}, -}; +//! toast per name. The Tideline footer carries the compact pulse +//! (`MCP · 4 connecting` or `Plugins · Problems: 2 · /plugins`); detailed +//! diagnosis and actions belong in `/mcp` or `/plugins`, never as multi-row +//! boot output between the transcript and composer. + use unicode_width::UnicodeWidthStr; use crate::localization::{Locale, MessageId, tr}; use crate::mcp::{McpManagerSnapshot, McpServerSnapshot}; -use crate::palette::ChromeInk; use crate::plugins::PluginRegistry; use crate::plugins::types::{PluginDiagnosticLevel, PluginTrustStatus}; use crate::tui::app::App; const ITEM_SEPARATOR: &str = " · "; -const MAX_RECEIPT_ROWS: u16 = 6; const MAX_NAMED_CHIPS: usize = 4; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -43,19 +33,10 @@ pub enum McpServerBootState { Disabled, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum McpServerAction { - Retry, - Login, - Diagnose, - None, -} - #[derive(Debug, Clone, PartialEq, Eq)] pub struct McpServerBootRow { pub name: String, pub state: McpServerBootState, - pub action: McpServerAction, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] @@ -69,7 +50,17 @@ pub struct PluginBootSummary { impl PluginBootSummary { #[must_use] pub fn is_quiet(self) -> bool { - self.loaded == 0 && self.invalid == 0 && self.duplicate == 0 && self.needs_setup == 0 + self.problem_count() == 0 + } + + #[must_use] + pub fn problem_count(self) -> usize { + self.invalid + self.duplicate + self.needs_setup + } + + #[must_use] + pub fn has_failures(self) -> bool { + self.invalid > 0 || self.duplicate > 0 } #[must_use] @@ -92,10 +83,7 @@ impl PluginBootSummary { .any(|diagnostic| diagnostic.level == PluginDiagnosticLevel::Error) { invalid += 1; - } else if matches!( - plugin.trust_status, - PluginTrustStatus::NeverReviewed | PluginTrustStatus::CapabilitiesChanged - ) { + } else if plugin_trust_needs_setup(plugin.trust_status) { needs_setup += 1; } } @@ -108,6 +96,33 @@ impl PluginBootSummary { } } +fn plugin_trust_needs_setup(status: PluginTrustStatus) -> bool { + matches!( + status, + PluginTrustStatus::NeverReviewed + | PluginTrustStatus::ContentChanged + | PluginTrustStatus::CapabilitiesChanged + ) +} + +/// Semantic severity for the compact boot activity notice. +/// +/// The text carries no color names or inferred state. Its consumer maps this +/// closed state into the Tideline palette, so a plugin warning cannot inherit +/// an unrelated MCP color merely because both use the same footer slot. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SessionBootActivityLevel { + Active, + Attention, + Failure, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionBootActivityChip { + pub text: String, + pub level: SessionBootActivityLevel, +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct SessionBootSurface { pub phase: SessionBootPhase, @@ -152,7 +167,6 @@ impl SessionBootSurface { .map(|name| McpServerBootRow { name, state: McpServerBootState::Connecting, - action: McpServerAction::None, }) .collect() } else { @@ -185,12 +199,11 @@ impl SessionBootSurface { } #[must_use] - pub fn is_hidden(&self) -> bool { - self.phase == SessionBootPhase::Hidden - } - - #[must_use] - pub fn activity_chip(&self, locale: Locale, budget: usize) -> Option { + pub fn activity_notice( + &self, + locale: Locale, + budget: usize, + ) -> Option { if self.phase == SessionBootPhase::Hidden || budget == 0 { return None; } @@ -216,141 +229,73 @@ impl SessionBootSurface { .filter(|row| row.state == McpServerBootState::Connected) .count(); - let mut candidates = Vec::new(); if !connecting.is_empty() { let count = connecting.len(); let named = named_chip_line("MCP", count, "connecting", &connecting); - candidates.push(named); - candidates.push(format!("MCP{ITEM_SEPARATOR}{count} connecting")); - } else if failed > 0 { - candidates.push(format!( - "MCP{ITEM_SEPARATOR}{connected} {}{ITEM_SEPARATOR}{failed} {}", - tr(locale, MessageId::ExtensionsStateConnected), - tr(locale, MessageId::PhaseFailed) - )); - candidates.push(format!("MCP{ITEM_SEPARATOR}{failed} failed")); - } else if self.phase == SessionBootPhase::Booting { + return activity_notice_from_candidates( + SessionBootActivityLevel::Active, + vec![named, format!("MCP{ITEM_SEPARATOR}{count} connecting")], + budget, + ); + } + if failed > 0 { + return activity_notice_from_candidates( + SessionBootActivityLevel::Failure, + vec![ + format!( + "MCP{ITEM_SEPARATOR}{connected} {}{ITEM_SEPARATOR}{failed} {}", + tr(locale, MessageId::ExtensionsStateConnected), + tr(locale, MessageId::PhaseFailed) + ), + format!("MCP{ITEM_SEPARATOR}{failed} failed"), + ], + budget, + ); + } + if self.phase == SessionBootPhase::Booting { let count = self.servers.len().max(self.unnamed_connecting); if count > 0 { - candidates.push(format!("MCP{ITEM_SEPARATOR}{count} connecting")); + return activity_notice_from_candidates( + SessionBootActivityLevel::Active, + vec![format!("MCP{ITEM_SEPARATOR}{count} connecting")], + budget, + ); } } - candidates.into_iter().find(|line| line.width() <= budget) - } - - #[must_use] - pub fn receipt_lines(&self, locale: Locale, width: usize) -> Vec { - if self.phase == SessionBootPhase::Hidden || width == 0 { - return Vec::new(); - } - let mut lines = Vec::new(); - if let Some(plugin_line) = plugin_receipt_line(self.plugins, locale, width) { - lines.push(plugin_line); + if self.plugins.is_quiet() { + return None; } - match self.phase { - SessionBootPhase::Hidden => {} - SessionBootPhase::Booting => { - let connecting: Vec<&str> = self - .servers - .iter() - .filter(|row| row.state == McpServerBootState::Connecting) - .map(|row| row.name.as_str()) - .collect(); - if connecting.is_empty() && self.servers.is_empty() { - if self.unnamed_connecting > 0 { - lines.push(format!( - "MCP{ITEM_SEPARATOR}{} connecting", - self.unnamed_connecting - )); - } - } else { - let count = if connecting.is_empty() { - self.servers.len() - } else { - connecting.len() - }; - let named = named_chip_line("MCP", count, "connecting", &connecting); - lines.push(truncate_to_width(&named, width)); - } - } - SessionBootPhase::Settled => { - if self.servers.len() == 1 { - lines.push(truncate_to_width( - &server_row_text(&self.servers[0], locale), - width, - )); - } else { - let mut remaining = - MAX_RECEIPT_ROWS.saturating_sub(lines.len() as u16) as usize; - if remaining == 0 { - return lines; - } - let notable: Vec<&McpServerBootRow> = self - .servers - .iter() - .filter(|row| { - matches!( - row.state, - McpServerBootState::Failed - | McpServerBootState::NeedsLogin - | McpServerBootState::Disabled - ) - }) - .collect(); - let connected = self - .servers - .iter() - .filter(|row| row.state == McpServerBootState::Connected) - .count(); - if notable.is_empty() { - if connected > 0 { - lines.push(truncate_to_width( - &format!( - "MCP{ITEM_SEPARATOR}{connected} {}", - tr(locale, MessageId::ExtensionsStateConnected) - ), - width, - )); - } - } else { - if connected > 0 && remaining > 1 { - lines.push(format!( - "MCP{ITEM_SEPARATOR}{connected} {}", - tr(locale, MessageId::ExtensionsStateConnected) - )); - remaining = remaining.saturating_sub(1); - } - let overflow = notable.len() > remaining; - let show = if overflow { - remaining.saturating_sub(1) - } else { - notable.len() - }; - for row in notable.iter().take(show) { - lines.push(truncate_to_width(&server_row_text(row, locale), width)); - } - let hidden = notable.len().saturating_sub(show); - if hidden > 0 { - lines.push(format!("+{hidden} more · /mcp")); - } - } - } - } - } - lines.truncate(MAX_RECEIPT_ROWS as usize); - lines + let plugins = tr(locale, MessageId::ExtensionsTabPlugins); + let problems = tr(locale, MessageId::ExtensionsGroupProblems); + let count = self.plugins.problem_count(); + let level = if self.plugins.has_failures() { + SessionBootActivityLevel::Failure + } else { + SessionBootActivityLevel::Attention + }; + activity_notice_from_candidates( + level, + vec![ + format!("{plugins}{ITEM_SEPARATOR}{problems}: {count}{ITEM_SEPARATOR}/plugins"), + format!("{plugins}{ITEM_SEPARATOR}{problems}: {count}"), + format!("{plugins}{ITEM_SEPARATOR}{count}"), + ], + budget, + ) } +} - #[must_use] - pub fn receipt_height(&self, locale: Locale, width: u16) -> u16 { - if self.is_hidden() { - return 0; - } - let lines = self.receipt_lines(locale, usize::from(width)); - (lines.len() as u16).min(MAX_RECEIPT_ROWS) - } +fn activity_notice_from_candidates( + level: SessionBootActivityLevel, + candidates: Vec, + budget: usize, +) -> Option { + candidates + .into_iter() + .find(|line| line.width() <= budget) + .map(|text| SessionBootActivityChip { text, level }) } fn row_from_snapshot( @@ -358,22 +303,16 @@ fn row_from_snapshot( initializing: bool, connecting: &[String], ) -> McpServerBootRow { - let valid_name = server - .name - .chars() - .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.')); if !server.enabled { return McpServerBootRow { name: server.name.clone(), state: McpServerBootState::Disabled, - action: McpServerAction::None, }; } if server.connected { return McpServerBootRow { name: server.name.clone(), state: McpServerBootState::Connected, - action: McpServerAction::None, }; } if let Some(error) = server.error.as_deref() { @@ -381,21 +320,11 @@ fn row_from_snapshot( return McpServerBootRow { name: server.name.clone(), state: McpServerBootState::NeedsLogin, - action: if valid_name { - McpServerAction::Login - } else { - McpServerAction::Diagnose - }, }; } return McpServerBootRow { name: server.name.clone(), state: McpServerBootState::Failed, - action: if valid_name { - McpServerAction::Retry - } else { - McpServerAction::Diagnose - }, }; } let connecting_now = initializing || connecting.iter().any(|name| name == &server.name); @@ -406,13 +335,6 @@ fn row_from_snapshot( } else { McpServerBootState::Failed }, - action: if connecting_now { - McpServerAction::None - } else if valid_name { - McpServerAction::Retry - } else { - McpServerAction::Diagnose - }, } } @@ -442,108 +364,6 @@ fn named_chip_line(kind: &str, count: usize, verb: &str, names: &[&str]) -> Stri line } -fn server_row_text(row: &McpServerBootRow, locale: Locale) -> String { - let state = match row.state { - McpServerBootState::Connecting => Cow::Borrowed("connecting"), - McpServerBootState::Connected => tr(locale, MessageId::ExtensionsStateConnected), - McpServerBootState::Failed => tr(locale, MessageId::PhaseFailed), - McpServerBootState::NeedsLogin => Cow::Borrowed("needs login"), - McpServerBootState::Disabled => tr(locale, MessageId::HotbarSetupStatusDisabled), - }; - let action = match row.action { - McpServerAction::Retry => format!(" · /mcp retry {}", row.name), - McpServerAction::Login => format!(" · /mcp login {}", row.name), - McpServerAction::Diagnose => " · /mcp doctor".to_string(), - McpServerAction::None => String::new(), - }; - format!("{}{ITEM_SEPARATOR}{state}{action}", row.name) -} - -fn plugin_receipt_line(summary: PluginBootSummary, locale: Locale, width: usize) -> Option { - if summary.is_quiet() { - return None; - } - let mut parts = vec![format!( - "{}{ITEM_SEPARATOR}{} {}", - tr(locale, MessageId::ExtensionsTabPlugins), - summary.loaded, - "loaded" - )]; - if summary.invalid > 0 { - parts.push(format!( - "{} {}", - summary.invalid, - tr(locale, MessageId::ExtensionsStateInvalid) - )); - } - if summary.duplicate > 0 { - parts.push(format!("{} duplicate", summary.duplicate)); - } - if summary.needs_setup > 0 { - parts.push(format!("{} need setup", summary.needs_setup)); - } - Some(truncate_to_width(&parts.join(ITEM_SEPARATOR), width)) -} - -fn truncate_to_width(text: &str, width: usize) -> String { - crate::localization::truncate_to_width(text, width) -} - -/// Activity-strip chip for the current session boot set. -#[must_use] -pub fn activity_chip(app: &App, budget: usize) -> Option { - SessionBootSurface::from_app(app).activity_chip(app.ui_locale, budget) -} - -/// Rows the compact boot receipt wants above the activity band. -#[must_use] -pub fn receipt_height(app: &App, width: u16, budget: u16) -> u16 { - if budget == 0 { - return 0; - } - SessionBootSurface::from_app(app) - .receipt_height(app.ui_locale, width) - .min(budget) -} - -/// Paint the compact boot receipt. Text only: Reduced/Still skip any spin. -pub fn render(area: Rect, buf: &mut Buffer, app: &App) { - if area.width == 0 || area.height == 0 { - return; - } - let surface = SessionBootSurface::from_app(app); - let lines = surface.receipt_lines(app.ui_locale, usize::from(area.width)); - if lines.is_empty() { - return; - } - Block::default() - .style(Style::default().bg(app.ui_theme.surface_bg)) - .render(area, buf); - let ink = if surface.servers.iter().any(|row| { - matches!( - row.state, - McpServerBootState::Failed | McpServerBootState::NeedsLogin - ) - }) { - ChromeInk::Failure - } else if surface.phase == SessionBootPhase::Booting { - ChromeInk::Active - } else { - ChromeInk::Metadata - }; - let rendered: Vec> = lines - .into_iter() - .take(area.height as usize) - .map(|line| { - Line::from(Span::styled( - line, - Style::default().fg(ink.color(&app.ui_theme)), - )) - }) - .collect(); - Paragraph::new(rendered).render(area, buf); -} - #[cfg(test)] mod tests { use super::*; @@ -588,9 +408,120 @@ mod tests { let surface = SessionBootSurface::from_parts(None, false, &[], 0, PluginBootSummary::default()); assert_eq!(surface.phase, SessionBootPhase::Hidden); - assert!(surface.activity_chip(Locale::En, 80).is_none()); - assert!(surface.receipt_lines(Locale::En, 80).is_empty()); - assert_eq!(surface.receipt_height(Locale::En, 80), 0); + assert!( + surface + .activity_notice(Locale::En, 80) + .map(|notice| notice.text) + .is_none() + ); + } + + #[test] + fn healthy_loaded_plugins_do_not_claim_the_boot_surface() { + let surface = SessionBootSurface::from_parts( + None, + false, + &[], + 0, + PluginBootSummary { + loaded: 2, + ..PluginBootSummary::default() + }, + ); + assert_eq!(surface.phase, SessionBootPhase::Hidden); + assert!(surface.activity_notice(Locale::En, 80).is_none()); + } + + #[test] + fn changed_plugin_content_requires_setup() { + assert!(plugin_trust_needs_setup(PluginTrustStatus::ContentChanged)); + assert!(plugin_trust_needs_setup(PluginTrustStatus::NeverReviewed)); + assert!(plugin_trust_needs_setup( + PluginTrustStatus::CapabilitiesChanged + )); + assert!(!plugin_trust_needs_setup(PluginTrustStatus::Trusted)); + } + + #[test] + fn plugin_problems_have_a_compact_footer_action() { + let surface = SessionBootSurface::from_parts( + None, + false, + &[], + 0, + PluginBootSummary { + loaded: 3, + invalid: 1, + duplicate: 1, + needs_setup: 1, + }, + ); + assert_eq!(surface.phase, SessionBootPhase::Settled); + assert_eq!( + surface.activity_notice(Locale::En, 40), + Some(SessionBootActivityChip { + text: "Plugins · Problems: 3 · /plugins".to_string(), + level: SessionBootActivityLevel::Failure, + }) + ); + } + + #[test] + fn plugin_review_notice_uses_attention_and_sheds_whole_fields() { + let surface = SessionBootSurface::from_parts( + None, + false, + &[], + 0, + PluginBootSummary { + loaded: 1, + needs_setup: 1, + ..PluginBootSummary::default() + }, + ); + assert_eq!( + surface.activity_notice(Locale::En, 40), + Some(SessionBootActivityChip { + text: "Plugins · Problems: 1 · /plugins".to_string(), + level: SessionBootActivityLevel::Attention, + }) + ); + assert_eq!( + surface + .activity_notice(Locale::En, 22) + .map(|notice| notice.text) + .as_deref(), + Some("Plugins · Problems: 1") + ); + assert_eq!( + surface + .activity_notice(Locale::En, 12) + .map(|notice| notice.text) + .as_deref(), + Some("Plugins · 1") + ); + } + + #[test] + fn mcp_activity_outranks_plugin_problems() { + let snap = snapshot(vec![server("alpha", true, false, None)]); + let surface = SessionBootSurface::from_parts( + Some(&snap), + true, + &["alpha".to_string()], + 1, + PluginBootSummary { + invalid: 1, + ..PluginBootSummary::default() + }, + ); + assert_eq!( + surface.activity_notice(Locale::En, 80), + Some(SessionBootActivityChip { + text: "MCP · 1 connecting · alpha".to_string(), + level: SessionBootActivityLevel::Active, + }) + ); } #[test] @@ -606,12 +537,12 @@ mod tests { assert_eq!(surface.phase, SessionBootPhase::Booting); assert_eq!(surface.servers.len(), 1); assert_eq!(surface.servers[0].state, McpServerBootState::Connecting); - let chip = surface.activity_chip(Locale::En, 80).expect("chip"); + let chip = surface + .activity_notice(Locale::En, 80) + .map(|notice| notice.text) + .expect("chip"); assert!(chip.contains("alpha"), "{chip}"); assert!(!chip.to_ascii_lowercase().contains("slack"), "{chip}"); - let receipt = surface.receipt_lines(Locale::En, 80); - assert_eq!(receipt.len(), 1); - assert!(receipt[0].contains("alpha"), "{receipt:?}"); } #[test] @@ -634,18 +565,18 @@ mod tests { PluginBootSummary::default(), ); assert_eq!(surface.phase, SessionBootPhase::Booting); - let chip = surface.activity_chip(Locale::En, 80).expect("chip"); + let chip = surface + .activity_notice(Locale::En, 80) + .map(|notice| notice.text) + .expect("chip"); assert!(chip.contains("4 connecting"), "{chip}"); assert!(chip.contains("alpha"), "{chip}"); assert!(chip.contains("docs"), "{chip}"); assert!(!chip.to_ascii_lowercase().contains("slack"), "{chip}"); - let receipt = surface.receipt_lines(Locale::En, 80); - assert_eq!(receipt.len(), 1, "{receipt:?}"); - assert!(receipt[0].contains("4 connecting"), "{receipt:?}"); } #[test] - fn settled_failures_keep_retry_and_login_on_the_row() { + fn settled_failures_remain_classified_for_the_footer_chip() { let snap = snapshot(vec![ server("alpha", true, true, None), server("beta", true, false, Some("protocol negotiation timed out")), @@ -662,12 +593,7 @@ mod tests { false, &[], 4, - PluginBootSummary { - loaded: 12, - invalid: 1, - duplicate: 2, - needs_setup: 0, - }, + PluginBootSummary::default(), ); assert_eq!(surface.phase, SessionBootPhase::Settled); assert_eq!( @@ -675,27 +601,17 @@ mod tests { .servers .iter() .find(|row| row.name == "beta") - .map(|row| (row.state, row.action)), - Some((McpServerBootState::Failed, McpServerAction::Retry)) + .map(|row| row.state), + Some(McpServerBootState::Failed) ); assert_eq!( surface .servers .iter() .find(|row| row.name == "gamma") - .map(|row| (row.state, row.action)), - Some((McpServerBootState::NeedsLogin, McpServerAction::Login)) + .map(|row| row.state), + Some(McpServerBootState::NeedsLogin) ); - let receipt = surface.receipt_lines(Locale::En, 100); - let joined = receipt.join("\n"); - assert!(joined.contains("Plugins"), "{joined}"); - assert!(joined.contains("12 loaded"), "{joined}"); - assert!(joined.contains("1 invalid"), "{joined}"); - assert!(joined.contains("2 duplicate"), "{joined}"); - assert!(joined.contains("/mcp retry beta"), "{joined}"); - assert!(joined.contains("/mcp login gamma"), "{joined}"); - assert!(!joined.contains("/mcp auth"), "{joined}"); - assert!(!joined.to_ascii_lowercase().contains("slack"), "{joined}"); } #[test] @@ -716,7 +632,10 @@ mod tests { 3, PluginBootSummary::default(), ); - let chip = surface.activity_chip(Locale::En, 22).expect("chip"); + let chip = surface + .activity_notice(Locale::En, 22) + .map(|notice| notice.text) + .expect("chip"); assert_eq!(chip, "MCP · 3 connecting"); } @@ -742,15 +661,14 @@ mod tests { .collect::>(), vec!["alpha", "docs", "gamma"] ); - let chip = surface.activity_chip(Locale::En, 80).expect("chip"); + let chip = surface + .activity_notice(Locale::En, 80) + .map(|notice| notice.text) + .expect("chip"); assert!(chip.contains("3 connecting"), "{chip}"); assert!(chip.contains("alpha"), "{chip}"); assert!(chip.contains("gamma"), "{chip}"); assert!(!chip.to_ascii_lowercase().contains("slack"), "{chip}"); - let receipt = surface.receipt_lines(Locale::En, 80); - assert_eq!(receipt.len(), 1, "{receipt:?}"); - assert!(receipt[0].contains("alpha"), "{receipt:?}"); - assert!(receipt[0].contains("docs"), "{receipt:?}"); } #[test] @@ -760,116 +678,11 @@ mod tests { assert_eq!(surface.phase, SessionBootPhase::Booting); assert!(surface.servers.is_empty()); assert_eq!( - surface.activity_chip(Locale::En, 80).as_deref(), + surface + .activity_notice(Locale::En, 80) + .map(|notice| notice.text) + .as_deref(), Some("MCP · 4 connecting") ); - assert_eq!( - surface.receipt_lines(Locale::En, 80), - vec!["MCP · 4 connecting".to_string()] - ); - } - - #[test] - fn settled_single_server_keeps_the_name_and_next_action() { - let snap = snapshot(vec![server( - "alpha", - true, - false, - Some("protocol negotiation timed out"), - )]); - let surface = SessionBootSurface::from_parts( - Some(&snap), - false, - &[], - 1, - PluginBootSummary::default(), - ); - assert_eq!(surface.phase, SessionBootPhase::Settled); - assert_eq!( - surface.receipt_lines(Locale::En, 80), - vec!["alpha · failed · /mcp retry alpha".to_string()] - ); - } - - #[test] - fn settled_all_connected_collapses_to_the_count() { - let snap = snapshot(vec![ - server("alpha", true, true, None), - server("beta", true, true, None), - ]); - let surface = SessionBootSurface::from_parts( - Some(&snap), - false, - &[], - 2, - PluginBootSummary::default(), - ); - assert_eq!(surface.phase, SessionBootPhase::Settled); - assert_eq!( - surface.receipt_lines(Locale::En, 80), - vec!["MCP · 2 connected".to_string()] - ); - assert!(surface.activity_chip(Locale::En, 80).is_none()); - } - - #[test] - fn overflow_receipt_keeps_a_plus_more_row() { - let snap = snapshot( - (0..8) - .map(|i| { - server( - &format!("s{i}"), - true, - false, - Some("protocol negotiation timed out"), - ) - }) - .collect(), - ); - let surface = SessionBootSurface::from_parts( - Some(&snap), - false, - &[], - 8, - PluginBootSummary::default(), - ); - let receipt = surface.receipt_lines(Locale::En, 80); - assert_eq!(receipt.len(), 6, "{receipt:?}"); - assert!( - receipt.last().is_some_and(|line| line.contains("+3 more")), - "{receipt:?}" - ); - assert!( - receipt.iter().any(|line| line.contains("/mcp retry s0")), - "{receipt:?}" - ); - assert!(!receipt.join("\n").contains("/mcp auth"), "{receipt:?}"); - } - - #[test] - fn plugin_line_sits_beside_connecting_mcp_names() { - let connecting = ["alpha", "beta"] - .into_iter() - .map(str::to_string) - .collect::>(); - let surface = SessionBootSurface::from_parts( - None, - true, - &connecting, - 2, - PluginBootSummary { - loaded: 12, - invalid: 1, - duplicate: 2, - needs_setup: 0, - }, - ); - let receipt = surface.receipt_lines(Locale::En, 100); - let joined = receipt.join("\n"); - assert!(joined.contains("Plugins"), "{joined}"); - assert!(joined.contains("12 loaded"), "{joined}"); - assert!(joined.contains("alpha"), "{joined}"); - assert!(joined.contains("beta"), "{joined}"); - assert!(!joined.to_ascii_lowercase().contains("slack"), "{joined}"); } } diff --git a/crates/tui/src/tui/shell_key_routing.rs b/crates/tui/src/tui/shell_key_routing.rs index 58c9c0b524..21247e7174 100644 --- a/crates/tui/src/tui/shell_key_routing.rs +++ b/crates/tui/src/tui/shell_key_routing.rs @@ -6,6 +6,8 @@ //! panel, or modal (TUI-DOG-002). Details/output fires only on //! Option+V / Alt+V, and macOS renders the label as `⌥V`, never `Alt`/`Cmd`. //! Help is `F1` (with `/help`); `Ctrl+/` stays as a secondary fallback. +//! Provider/route is `F3` (with `/provider`); it is non-printable so it can +//! remain available while the composer owns ordinary text input. //! `Alt+?` and `Alt+C` are still accepted where terminals deliver them but //! are never advertised until proven in real terminals (TUI-DOG-003); //! `/context` is the guaranteed context path. @@ -23,6 +25,7 @@ use crate::tui::key_shortcuts; pub enum ShellBindingId { ToolDetails, ContextInspector, + ProviderRoute, Help, } @@ -51,6 +54,12 @@ pub const SHELL_BINDINGS: &[ShellBinding] = &[ catalog_chord: "/context", footer_chord: "/context", }, + ShellBinding { + id: ShellBindingId::ProviderRoute, + // `/provider` remains the portable, explicit command path. + catalog_chord: "F3 / /provider", + footer_chord: "F3", + }, ShellBinding { id: ShellBindingId::Help, // `/help` also opens this; Ctrl+/ is the secondary fallback. @@ -168,6 +177,14 @@ pub fn is_context_inspector_shortcut(key: &KeyEvent) -> bool { && key_shortcuts::alt_nav_modifiers(key.modifiers) } +/// Route entry stays on a non-printable function key so it never steals a +/// model/provider name from the composer. `/provider` remains available in +/// terminals that do not forward function keys. +#[must_use] +pub fn is_provider_route_shortcut(key: &KeyEvent) -> bool { + matches!(key.code, KeyCode::F(3)) && key.modifiers.is_empty() +} + #[must_use] pub fn is_help_shortcut(key: &KeyEvent) -> bool { if matches!(key.code, KeyCode::F(1)) { @@ -346,6 +363,22 @@ mod tests { assert!(!is_context_inspector_shortcut(¸la)); } + #[test] + fn topbar_route_f3_requires_a_plain_function_key() { + assert!(is_provider_route_shortcut(&KeyEvent::new( + KeyCode::F(3), + KeyModifiers::NONE + ))); + assert!(!is_provider_route_shortcut(&KeyEvent::new( + KeyCode::F(3), + KeyModifiers::ALT + ))); + assert!(!is_provider_route_shortcut(&KeyEvent::new( + KeyCode::Char('3'), + KeyModifiers::NONE + ))); + } + #[test] fn catalog_chords_match_final_contract() { assert_eq!(binding(ShellBindingId::Help).catalog_chord, "F1 / Ctrl+/"); @@ -354,6 +387,10 @@ mod tests { "/context" ); assert_eq!(binding(ShellBindingId::ToolDetails).catalog_chord, "Alt+V"); + assert_eq!( + binding(ShellBindingId::ProviderRoute).catalog_chord, + "F3 / /provider" + ); for binding in SHELL_BINDINGS { assert!(!binding.catalog_chord.contains("Alt+?")); assert_ne!(binding.catalog_chord, "v"); diff --git a/crates/tui/src/tui/tideline.rs b/crates/tui/src/tui/tideline.rs index 02b353af28..b10e1135c4 100644 --- a/crates/tui/src/tui/tideline.rs +++ b/crates/tui/src/tui/tideline.rs @@ -164,12 +164,19 @@ pub struct InteractionTargetId(&'static str); impl InteractionTargetId { pub const HEADER_CONTEXT: Self = Self("header.context"); + /// The rendered route/model segment. This is intentionally an affordance + /// id only: the provider picker remains the owner of route catalog and + /// readiness facts. + pub const HEADER_ROUTE: Self = Self("header.route"); } /// Typed destination shared by keyboard and mouse input routes. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InteractionAction { InspectContext, + /// Open the existing provider/route picker without making this chrome + /// target another source of catalog or runtime authority. + OpenProviderPicker, } /// Focus metadata for a selectable target. @@ -189,6 +196,9 @@ pub enum InteractionFocus { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum InspectDetail { ContextBudget(ContextBudgetSnapshot), + /// The topbar exposes a route entry point, not a copied route snapshot. + /// `ProviderPickerView` remains the authoritative presentation owner. + Route, } /// A selectable region painted in the current frame. @@ -261,6 +271,26 @@ mod tests { } } + #[test] + fn topbar_route_target_is_typed_without_copying_route_facts() { + let target = InteractionTarget { + id: InteractionTargetId::HEADER_ROUTE, + area: Rect::new(20, 0, 24, 1), + focus: InteractionFocus::Direct, + keyboard_action: Some(InteractionAction::OpenProviderPicker), + mouse_action: Some(InteractionAction::OpenProviderPicker), + inspect_detail: InspectDetail::Route, + }; + + assert_eq!(target.id, InteractionTargetId::HEADER_ROUTE); + assert_eq!( + target.keyboard_action, + Some(InteractionAction::OpenProviderPicker) + ); + assert_eq!(target.mouse_action, target.keyboard_action); + assert_eq!(target.inspect_detail, InspectDetail::Route); + } + #[test] fn ui_snapshot_uses_active_route_without_claiming_saved_defaults() { let mut app = diff --git a/crates/tui/src/tui/ui/apply.rs b/crates/tui/src/tui/ui/apply.rs index 7fd72d865d..904fc32693 100644 --- a/crates/tui/src/tui/ui/apply.rs +++ b/crates/tui/src/tui/ui/apply.rs @@ -1716,28 +1716,7 @@ pub(crate) async fn apply_command_result( } } AppAction::OpenProviderPicker => { - if app.onboarding == OnboardingState::Provider { - let recover_configured_route = app.onboarding_missing_key_recovery; - open_onboarding_provider_picker( - app, - config, - engine_handle, - recover_configured_route, - ) - .await; - } else if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) { - let runtime_status = query_provider_runtime_status(engine_handle).await; - app.view_stack.push( - crate::tui::provider_picker::ProviderPickerView::new_with_runtime_status_and_memory( - app.api_provider, - config, - runtime_status, - app.provider_picker_memory.as_ref(), - ) - .with_locale(app.ui_locale) - .with_provider_health(&app.provider_health), - ); - } + open_provider_picker(app, config, engine_handle).await; } AppAction::OpenProviderSetup { provider } => { if app.view_stack.top_kind() != Some(ModalKind::ProviderPicker) { diff --git a/crates/tui/src/tui/ui/event_loop.rs b/crates/tui/src/tui/ui/event_loop.rs index c02bf14a2e..e4141a07dd 100644 --- a/crates/tui/src/tui/ui/event_loop.rs +++ b/crates/tui/src/tui/ui/event_loop.rs @@ -4407,6 +4407,29 @@ pub(crate) async fn run_event_loop( continue; } + // F3 is the non-printable keyboard counterpart to the clickable + // route segment in the shared topbar. Route it through the same + // typed event as mouse input; `/provider` remains the portable + // direct command path for terminals that do not forward F-keys. + if crate::tui::shell_key_routing::is_provider_route_shortcut(&key) + && app.view_stack.is_empty() + { + if handle_view_events_boxed( + terminal, + app, + config, + &task_manager, + &mut engine_handle, + &mut web_config_session, + vec![ViewEvent::TopbarRoutePickerRequested], + ) + .await? + { + return Ok(()); + } + continue; + } + // The pre-session launch menu owns every key until the user has // chosen a real session/worktree action. Resume and changelog may // place a shared surface above it; those views keep their normal @@ -6214,7 +6237,8 @@ mod session_boot_event_tests { assert_eq!(app.mcp_configured_count, 2); let surface = crate::tui::session_boot::SessionBootSurface::from_app(&app); let chip = surface - .activity_chip(crate::localization::Locale::En, 80) + .activity_notice(crate::localization::Locale::En, 80) + .map(|notice| notice.text) .expect("chip"); assert!(chip.contains("alpha"), "{chip}"); assert!(chip.contains("beta"), "{chip}"); diff --git a/crates/tui/src/tui/ui/frame.rs b/crates/tui/src/tui/ui/frame.rs index b02dbab810..2d949c47cc 100644 --- a/crates/tui/src/tui/ui/frame.rs +++ b/crates/tui/src/tui/ui/frame.rs @@ -127,22 +127,29 @@ pub(crate) fn topbar_segments(app: &App, width: u16) -> Vec { segments } +/// The topbar controls that actually painted in this frame. +/// +/// The route target intentionally contains no copied route metadata. The +/// provider picker retains catalog, readiness, credential, and apply +/// authority; chrome only exposes its entry point. +#[derive(Debug, Clone, Copy, Default)] +struct TopbarInteractionHitboxes { + context: Option, + route: Option, +} + /// Render the Tideline topbar into one header row and record its segment /// hitboxes (spec §5b `Constraint::Length(1)`). The ONE header on every /// screen: the session shell and the launch screen both call this, so the /// brand lockup, contextual segments, and the pinned meter + clock never /// change identity between pre- and post-session states. Segment rects are /// recorded for hover (this frame's highlight resolves against the previous -/// frame's rects, the standard one-frame-lag registry pattern) and for click -/// routing in a follow-up slice. -/// Render the Tideline topbar row and record its segment hitboxes. Returns -/// the pinned context meter's hitbox — the chrome row's one always-present -/// inspector target (`Alt+C`'s mouse route) — or `None` when the meter could -/// not paint whole and clear of the brand at this width. -pub(crate) fn render_topbar_row(f: &mut Frame, app: &mut App, area: Rect) -> Option { +/// frame's rects, the standard one-frame-lag registry pattern) and for typed +/// click routing. +fn render_topbar_row(f: &mut Frame, app: &mut App, area: Rect) -> TopbarInteractionHitboxes { if area.height == 0 { app.viewport.last_topbar_hitboxes.clear(); - return None; + return TopbarInteractionHitboxes::default(); } let segments = topbar_segments(app, area.width); let clock = topbar_clock(); @@ -162,7 +169,13 @@ pub(crate) fn render_topbar_row(f: &mut Frame, app: &mut App, area: Rect) -> Opt .ascii_safe(crate::tui::color_compat::ascii_safe_enabled()) .hovered(hovered); let hitboxes = topbar_hitboxes(&topbar, area); - let context_hitbox = crate::tui::topbar::context_meter_hitbox(&topbar, area); + let interaction_hitboxes = TopbarInteractionHitboxes { + context: crate::tui::topbar::context_meter_hitbox(&topbar, area), + route: hitboxes + .iter() + .find(|hitbox| hitbox.id == TopbarSegmentId::Model) + .map(|hitbox| hitbox.area), + }; // Keep the header row's quiet background under the widget itself. let buf = f.buffer_mut(); Block::default() @@ -170,7 +183,75 @@ pub(crate) fn render_topbar_row(f: &mut Frame, app: &mut App, area: Rect) -> Opt .render(area, buf); ratatui::widgets::Widget::render(topbar, area, buf); app.viewport.last_topbar_hitboxes = hitboxes; - context_hitbox + interaction_hitboxes +} + +/// Register the topbar's drawn controls as one typed input surface. +/// +/// Both the launch stage and a live session use this exact registration, so +/// mouse routing cannot advertise a header segment on only one shell state. +fn register_topbar_interaction_targets(app: &mut App, hitboxes: TopbarInteractionHitboxes) { + if let (Some(hitbox), Some(context_budget)) = ( + hitboxes.context, + crate::tui::tideline::ContextBudgetSnapshot::from_app(app), + ) { + app.viewport + .interaction_targets + .register(crate::tui::tideline::InteractionTarget { + id: crate::tui::tideline::InteractionTargetId::HEADER_CONTEXT, + area: hitbox, + focus: crate::tui::tideline::InteractionFocus::Direct, + keyboard_action: Some(crate::tui::tideline::InteractionAction::InspectContext), + mouse_action: Some(crate::tui::tideline::InteractionAction::InspectContext), + inspect_detail: crate::tui::tideline::InspectDetail::ContextBudget(context_budget), + }); + } + if let Some(hitbox) = hitboxes.route { + app.viewport + .interaction_targets + .register(crate::tui::tideline::InteractionTarget { + id: crate::tui::tideline::InteractionTargetId::HEADER_ROUTE, + area: hitbox, + focus: crate::tui::tideline::InteractionFocus::Direct, + keyboard_action: Some(crate::tui::tideline::InteractionAction::OpenProviderPicker), + mouse_action: Some(crate::tui::tideline::InteractionAction::OpenProviderPicker), + inspect_detail: crate::tui::tideline::InspectDetail::Route, + }); + } + + for target in app.viewport.interaction_targets.iter() { + let label = match target.mouse_action { + Some(crate::tui::tideline::InteractionAction::InspectContext) => format!( + "{} · {}", + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::CtxMenuContextInspector, + ), + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::CtxMenuContextInspectorDesc, + ), + ), + Some(crate::tui::tideline::InteractionAction::OpenProviderPicker) => format!( + "{} · {}", + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::RoutePanelHeader, + ), + crate::localization::tr( + app.ui_locale, + crate::localization::MessageId::CmdProviderDescription, + ), + ), + None => continue, + }; + crate::tui::hover_layer::register_rect( + crate::tui::hover_hit::HoverTargetKind::Link, + target.area, + label, + false, + ); + } } /// Map the host terminal rect onto the session shell canvas. @@ -917,15 +998,20 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( Constraint::Length(1), // merged footer (slots 6+8) ]) .areas(size); - render_topbar_row(f, app, topbar_area); + let topbar_interactions = render_topbar_row(f, app, topbar_area); + register_topbar_interaction_targets(app, topbar_interactions); let startup = crate::tui::underwater::tideline_startup_from_app(app); - let hitboxes = crate::tui::underwater::tideline_startup_hitboxes(stage_area); + let hitboxes = if startup.composer.enclosed { + crate::tui::underwater::tideline_startup_hitboxes(stage_area) + } else { + crate::tui::underwater::tideline_startup_hitboxes_with_composer(stage_area, false) + }; crate::tui::underwater::render_tideline_startup(stage_area, f.buffer_mut(), &startup); // The completion popup paints above the docked composer's input row, // over the stage rows it needs — the same caller-computed entries // the session popup rides. if let Some(input_row) = hitboxes - .composer + .input .map(|area| area.y.saturating_sub(stage_area.y)) { crate::tui::underwater::render_launch_completion_popup( @@ -1063,7 +1149,9 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( // shed chat/composer space. Mini mode hides it with the rest of the // chrome. let pending_work = crate::tui::background_indicator::pending_work_from_app(app); - let composer_floor = MIN_COMPOSER_HEIGHT.saturating_add(u16::from(app.composer_border)); + let composer_floor = MIN_COMPOSER_HEIGHT.saturating_add(u16::from( + crate::tui::widgets::composer_enclosure_enabled(app), + )); let indicator_height = if mini { 0 } else { @@ -1106,20 +1194,8 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( // up to three compact rows at the release floor. let preview_cap = if size.height >= 20 { 4 } else { 3 }; let preview_height = desired_preview_height.min(auxiliary_budget.min(preview_cap)); - let session_boot_height = if mini { - 0 - } else { - crate::tui::session_boot::receipt_height( - app, - shell_area.width, - auxiliary_budget.saturating_sub(preview_height), - ) - }; - let workflow_panel_height = desired_workflow_panel_height.min( - auxiliary_budget - .saturating_sub(preview_height) - .saturating_sub(session_boot_height), - ); + let workflow_panel_height = + desired_workflow_panel_height.min(auxiliary_budget.saturating_sub(preview_height)); // One pinned footer row brackets the composer from below (spec §3: the // activity band and identity band merged into it): phase · live detail · @@ -1138,18 +1214,16 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( Constraint::Length(workflow_panel_height), // Workflow panel (#4121) Constraint::Length(preview_height), // Pending input preview (0 if empty) Constraint::Length(indicator_height), // Background-work chip (#5286, 0 if idle) - Constraint::Length(session_boot_height), // MCP+plugin boot receipt (0 if quiet) Constraint::Length(plugin_cta_height), // Live plugin CTA (0 unless matched) Constraint::Length(composer_height), // Composer Constraint::Length(footer_height), // Merged Tideline footer (slots 6+8) ]) .split(body_area); - let session_boot_slot = 5; - let plugin_cta_slot = 6; - let composer_slot = 7; - let footer_slot = 8; + let plugin_cta_slot = 5; + let composer_slot = 6; + let footer_slot = 7; - let (work_chat_area, side_work_area) = if mini && !mini_cfg.keep_sidebar { + let (legacy_work_chat_area, side_work_area) = if mini && !mini_cfg.keep_sidebar { // Mini mode without the side rail: the transcript takes the whole // chat row. split_chat is skipped so the rail never reserves columns. (body_chunks[1], None) @@ -1157,60 +1231,56 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( crate::tui::work_surface::split_chat(app, body_chunks[1], rail_min_chat_width(idle_empty)) }; + // The legacy work surface continues to own its configured placement and + // detailed rows. In a normal active session, add the compact Tideline + // state rail beside the transcript even when the normal default Left + // surface has no rows to reserve. A populated legacy side surface wins, + // so the two rails can never overlap or duplicate each other. The summary + // is deliberately passive until its controls have registered keyboard + + // mouse behavior. + let tideline_rail_width = if mini { + 0 + } else { + crate::tui::work_surface::tideline::active_session_tideline_rail_width( + app, + legacy_work_chat_area, + side_work_area.is_some(), + ) + }; + let (tideline_live_rail_area, work_chat_area) = if tideline_rail_width == 0 { + (None, legacy_work_chat_area) + } else { + let [rail_area, chat_area] = + Layout::horizontal([Constraint::Length(tideline_rail_width), Constraint::Min(1)]) + .areas(legacy_work_chat_area); + (Some(rail_area), chat_area) + }; + if top_work_strip_height > 0 { crate::tui::work_surface::render(f, body_chunks[0], app); } else if let Some(work_area) = side_work_area { crate::tui::work_surface::render(f, work_area, app); } + if let Some(rail_area) = tideline_live_rail_area { + crate::tui::work_surface::tideline::render_active_session_tideline_rail( + rail_area, + f.buffer_mut(), + app, + ); + } // The Tideline topbar owns the header slot (spec §3: it replaces // `underwater::render_header`). One row: brand wordmark, contextual // segments, then the pinned context meter + clock. The route identity // the old identity band carried below the composer now lives here as // the Model segment — its new permanent home. - let mut topbar_context_hitbox = None; + let mut topbar_interactions = TopbarInteractionHitboxes::default(); if header_height > 0 { - topbar_context_hitbox = render_topbar_row(f, app, header_area); + topbar_interactions = render_topbar_row(f, app, header_area); } else { app.viewport.last_topbar_hitboxes.clear(); } - if let (Some(hitbox), Some(context_budget)) = ( - topbar_context_hitbox, - crate::tui::tideline::ContextBudgetSnapshot::from_app(app), - ) { - app.viewport - .interaction_targets - .register(crate::tui::tideline::InteractionTarget { - id: crate::tui::tideline::InteractionTargetId::HEADER_CONTEXT, - area: hitbox, - focus: crate::tui::tideline::InteractionFocus::Direct, - keyboard_action: Some(crate::tui::app::HeaderActionTarget::InspectContext), - mouse_action: Some(crate::tui::app::HeaderActionTarget::InspectContext), - inspect_detail: crate::tui::tideline::InspectDetail::ContextBudget(context_budget), - }); - } - for target in app.viewport.interaction_targets.iter() { - let label = match target.mouse_action { - Some(crate::tui::tideline::InteractionAction::InspectContext) => format!( - "{} · {}", - crate::localization::tr( - app.ui_locale, - crate::localization::MessageId::CtxMenuContextInspector, - ), - crate::localization::tr( - app.ui_locale, - crate::localization::MessageId::CtxMenuContextInspectorDesc, - ), - ), - None => continue, - }; - crate::tui::hover_layer::register_rect( - crate::tui::hover_hit::HoverTargetKind::Link, - target.area, - label, - false, - ); - } + register_topbar_interaction_targets(app, topbar_interactions); // Render the transcript and optional file-tree sidecar. The underwater // default deliberately has no legacy right sidebar: Tasks and To-do own @@ -1313,11 +1383,6 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( crate::tui::background_indicator::render(body_chunks[4], buf, app, &pending_work); } - if session_boot_height > 0 { - let buf = f.buffer_mut(); - crate::tui::session_boot::render(body_chunks[session_boot_slot], buf, app); - } - if plugin_cta_height > 0 { let buf = f.buffer_mut(); crate::tui::plugin_suggestions::draw_plugin_cta(app, body_chunks[plugin_cta_slot], buf); @@ -1348,17 +1413,7 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( &slash_menu_entries, &mention_menu_entries, ); - let inner = if composer_widget.has_panel(area) { - ratatui::widgets::Block::default() - .borders(ratatui::widgets::Borders::TOP | ratatui::widgets::Borders::BOTTOM) - .inner(area) - } else if area.height >= 2 { - ratatui::widgets::Block::default() - .borders(ratatui::widgets::Borders::TOP) - .inner(area) - } else { - area - }; + let inner = composer_widget.inner_area(area); app.viewport.last_composer_content = Some(inner); // Compute scroll offset and top padding for mouse coordinate mapping. @@ -1437,16 +1492,12 @@ pub(crate) fn render(f: &mut Frame, app: &mut App, _config: &Config) -> Option<( if let Some(side_area) = side_work_area { column.paint_matching(side_area, f.buffer_mut(), app.ui_theme.surface_bg); } + if let Some(rail_area) = tideline_live_rail_area { + column.paint_matching(rail_area, f.buffer_mut(), app.ui_theme.surface_bg); + } column.paint_matching(work_chat_area, f.buffer_mut(), app.ui_theme.surface_bg); column.paint_matching(body_chunks[2], f.buffer_mut(), app.ui_theme.surface_bg); column.paint_matching(body_chunks[3], f.buffer_mut(), app.ui_theme.surface_bg); - if session_boot_height > 0 { - column.paint_matching( - body_chunks[session_boot_slot], - f.buffer_mut(), - app.ui_theme.surface_bg, - ); - } if plugin_cta_height > 0 { column.paint_matching( body_chunks[plugin_cta_slot], @@ -1745,7 +1796,48 @@ pub(crate) fn workflow_tool_is_running(app: &App) -> bool { #[cfg(test)] mod tests { - use super::short_title_truncate; + use super::{register_topbar_interaction_targets, render_topbar_row, short_title_truncate}; + use ratatui::{Terminal, backend::TestBackend}; + + #[test] + fn topbar_route_segment_registers_interaction_target() { + let mut app = + crate::test_support::test_app_with_options(crate::test_support::test_tui_options(".")); + let mut terminal = + Terminal::new(TestBackend::new(160, 1)).expect("topbar test terminal should build"); + + terminal + .draw(|frame| { + let area = frame.area(); + let hitboxes = render_topbar_row(frame, &mut app, area); + register_topbar_interaction_targets(&mut app, hitboxes); + }) + .expect("topbar should render"); + + let segment = app + .viewport + .last_topbar_hitboxes + .iter() + .find(|hitbox| hitbox.id == crate::tui::topbar::TopbarSegmentId::Model) + .expect("wide topbar should paint its model segment"); + let target = app + .viewport + .interaction_targets + .iter() + .find(|target| target.id == crate::tui::tideline::InteractionTargetId::HEADER_ROUTE) + .expect("painted route segment should have a typed target"); + + assert_eq!(target.area, segment.area); + assert_eq!( + target.keyboard_action, + Some(crate::tui::tideline::InteractionAction::OpenProviderPicker) + ); + assert_eq!(target.mouse_action, target.keyboard_action); + assert_eq!( + target.inspect_detail, + crate::tui::tideline::InspectDetail::Route + ); + } #[test] fn truncates_at_ascii_word_boundary() { diff --git a/crates/tui/src/tui/ui/handlers.rs b/crates/tui/src/tui/ui/handlers.rs index 1d69d726e7..02c489e2de 100644 --- a/crates/tui/src/tui/ui/handlers.rs +++ b/crates/tui/src/tui/ui/handlers.rs @@ -1909,6 +1909,9 @@ pub(crate) async fn handle_view_events( app.status_message = Some(message); app.needs_redraw = true; } + ViewEvent::TopbarRoutePickerRequested => { + open_provider_picker(app, config, engine_handle).await; + } ViewEvent::ProviderPickerDismissed { catalog_view, selected_provider_id, diff --git a/crates/tui/src/tui/ui/motion.rs b/crates/tui/src/tui/ui/motion.rs index 52b754c517..98b6ceccaa 100644 --- a/crates/tui/src/tui/ui/motion.rs +++ b/crates/tui/src/tui/ui/motion.rs @@ -117,7 +117,9 @@ pub(crate) fn rail_row_budget( } else { MIN_CHAT_HEIGHT }; - let composer_floor = MIN_COMPOSER_HEIGHT.saturating_add(u16::from(app.composer_border)); + let composer_floor = MIN_COMPOSER_HEIGHT.saturating_add(u16::from( + crate::tui::widgets::composer_enclosure_enabled(app), + )); terminal_height .saturating_sub(header_height_for(terminal_height)) // The merged Tideline footer is one row (spec §3: slots 6+8 diff --git a/crates/tui/src/tui/ui/overlays.rs b/crates/tui/src/tui/ui/overlays.rs index 6f6f3db57f..66c74f72c3 100644 --- a/crates/tui/src/tui/ui/overlays.rs +++ b/crates/tui/src/tui/ui/overlays.rs @@ -278,6 +278,30 @@ pub(crate) async fn open_launch_provider_picker( app.needs_redraw = true; } +/// Open the existing provider/route surface from any shell entry point. +/// +/// The chrome and `/provider` command both delegate here, so they expose the +/// same picker without duplicating catalog or runtime-readiness facts. A +/// picker preview remains non-authoritative until its normal apply handler +/// commits a route. +pub(crate) async fn open_provider_picker( + app: &mut App, + config: &Config, + engine_handle: &EngineHandle, +) { + if app.onboarding == OnboardingState::Provider { + open_onboarding_provider_picker( + app, + config, + engine_handle, + app.onboarding_missing_key_recovery, + ) + .await; + } else { + open_launch_provider_picker(app, config, engine_handle).await; + } +} + pub(crate) fn open_text_pager(app: &mut App, title: String, content: String) { let width = app .viewport diff --git a/crates/tui/src/tui/ui/tests.rs b/crates/tui/src/tui/ui/tests.rs index 8cdee9865d..6bdff62cf6 100644 --- a/crates/tui/src/tui/ui/tests.rs +++ b/crates/tui/src/tui/ui/tests.rs @@ -4134,6 +4134,63 @@ fn wide_underwater_shell_aligns_transcript_and_composer_on_the_shared_canvas() { assert_eq!((composer.x, composer.width), (0, 160)); } +#[test] +fn failed_mcp_is_a_footer_chip_not_multiline_chat_boot_output() { + fn app() -> App { + let mut app = create_test_app(); + app.onboarding_workspace_trust_gate = false; + app.onboarding = OnboardingState::None; + app.launch.visible = false; + app + } + + let mut baseline = app(); + let _ = render_underwater_test_app(&mut baseline, 100, 30); + let baseline_composer = baseline + .viewport + .last_composer_area + .expect("baseline composer area"); + + let mut failed = app(); + failed.mcp_snapshot = Some(crate::mcp::McpManagerSnapshot { + config_path: PathBuf::from("mcp.json"), + config_exists: true, + reload_required: false, + servers: vec![crate::mcp::McpServerSnapshot { + name: "alpha".to_string(), + enabled: true, + required: false, + transport: "stdio".to_string(), + command_or_url: "alpha-mcp".to_string(), + connect_timeout: 5, + execute_timeout: 5, + read_timeout: 5, + connected: false, + error: Some("protocol negotiation timed out".to_string()), + capability_metadata: crate::mcp::McpServerCapabilityMetadata::NotObserved, + tools: Vec::new(), + resources: Vec::new(), + prompts: Vec::new(), + }], + }); + let rendered = render_underwater_test_app(&mut failed, 100, 30); + + assert_eq!( + failed + .viewport + .last_composer_area + .expect("failed-MCP composer area"), + baseline_composer, + "MCP diagnostics must not reserve chat/composer rows" + ); + assert!( + rendered.contains("MCP · 0 connected · 1 failed"), + "{rendered}" + ); + assert!(!rendered.contains("alpha · failed"), "{rendered}"); + assert!(!rendered.contains("/mcp retry alpha"), "{rendered}"); +} + #[test] fn wide_underwater_canvas_carries_the_ocean_to_both_terminal_edges() { let mut app = create_test_app(); @@ -23019,6 +23076,7 @@ fn backtrack_cut_index_skips_tool_result_user_messages() { /// name the same filter reaches them: `tui::ui::tests::work_surface::…`. mod work_surface { use super::*; + use crate::test_support::{EnvVarGuard, lock_test_env}; use crate::tui::ui::{rail_min_chat_width, rail_row_budget}; use crate::tui::underwater::AMBIENT_MIN_CHAT_WIDTH; use crate::tui::widgets::should_render_empty_state; @@ -23030,6 +23088,7 @@ mod work_surface { fn idle_rail_app(panel: RailPanel) -> App { let mut app = create_test_app(); + app.ui_locale = crate::localization::Locale::En; // Pin the chrome the budget charges for: `App::new` reads the developer's // real settings.toml, and a host with `composer_border = false` would // shift every threshold below by a row. @@ -23046,6 +23105,182 @@ mod work_surface { app } + fn active_tideline_app() -> App { + let mut app = idle_rail_app(RailPanel::Tasks); + app.launch.visible = false; + app.current_session_id = Some("tideline-live-rail".to_string()); + assert!( + app.todos + .try_lock() + .expect("todos lock") + .snapshot() + .is_empty(), + "the active-session rail must not depend on a checklist item" + ); + app + } + + #[test] + fn active_session_tideline_rail_uses_the_width_ladder_without_fake_controls() { + let mut narrow = active_tideline_app(); + let narrow_text = render_underwater_test_app(&mut narrow, 80, 32); + assert!( + !narrow_text.contains("RUNS"), + "the Tideline rail must yield below 100 columns:\n{narrow_text}" + ); + assert_eq!( + narrow + .viewport + .last_transcript_area + .expect("80-column transcript") + .x, + 0, + "80 columns retain the existing compact transcript layout" + ); + + let mut medium = active_tideline_app(); + let medium_text = render_underwater_test_app(&mut medium, 100, 32); + for label in ["RUNS", "WHALES", "POD", "WORK", "CONTEXT"] { + assert!( + medium_text.contains(label), + "100-column rail misses {label}:\n{medium_text}" + ); + } + assert!(medium_text.contains("idle"), "{medium_text}"); + assert!( + !medium_text.contains("« collapse") && !medium_text.contains("? help"), + "passive state summaries must not paint inert controls:\n{medium_text}" + ); + assert_eq!( + medium + .viewport + .last_transcript_area + .expect("100-column transcript") + .x, + 16, + "100–119 columns reserve the compact 16-column rail" + ); + + let mut wide = active_tideline_app(); + let wide_text = render_underwater_test_app(&mut wide, 120, 32); + assert!(wide_text.contains("RUNS"), "{wide_text}"); + assert_eq!( + wide.viewport + .last_transcript_area + .expect("120-column transcript") + .x, + 22, + "120+ columns reserve the full 22-column rail" + ); + } + + #[test] + fn default_left_active_session_uses_tideline_when_legacy_surface_is_empty() { + // Exercise the real Settings default through App initialization rather + // than forcing a placement in the fixture. The process-wide env lock + // keeps the isolated CODEWHALE_HOME from racing other settings tests. + let _env_lock = lock_test_env(); + let settings_root = tempfile::tempdir().expect("isolated settings root"); + let default_home = settings_root.path().join("codewhale-home"); + let _home = EnvVarGuard::set("CODEWHALE_HOME", &default_home); + assert_eq!( + crate::settings::Settings::load_persisted() + .expect("load default settings") + .work_surface_placement, + "left", + "the app-level regression must start from the shipped Settings default" + ); + + for (width, expected_x) in [(100, 16), (120, 22)] { + let mut app = App::new( + crate::test_support::test_tui_options(std::path::PathBuf::from(".")), + &Config::default(), + ); + assert_eq!( + app.work_surface.placement, + WorkSurfacePlacement::Left, + "App must retain the shipped default placement at {width} columns" + ); + app.launch.visible = false; + app.ui_locale = crate::localization::Locale::En; + app.current_session_id = Some(format!("tideline-default-left-{width}")); + assert!( + app.todos + .try_lock() + .expect("todos lock") + .snapshot() + .is_empty(), + "the default Left rail must not need an active checklist" + ); + + let rendered = render_underwater_test_app(&mut app, width, 32); + for label in ["RUNS", "WHALES", "POD", "WORK", "CONTEXT"] { + assert!( + rendered.contains(label), + "default Left rail misses {label} at {width} columns:\n{rendered}" + ); + } + assert_eq!( + app.viewport + .last_transcript_area + .expect("default Left transcript") + .x, + expected_x, + "the default Left layout must use the Tideline width ladder" + ); + } + } + + #[test] + fn occupied_left_work_surface_wins_over_the_tideline_summary() { + let mut app = active_tideline_app(); + app.work_surface.placement = WorkSurfacePlacement::Left; + app.todos.try_lock().expect("todos lock").add( + "legacy side work owns this column".to_string(), + crate::tools::todo::TodoStatus::InProgress, + ); + + let rendered = render_underwater_test_app(&mut app, 120, 32); + assert!( + rendered.contains("legacy side work"), + "the existing detailed work surface must still render:\n{rendered}" + ); + assert!( + !rendered.contains("RUNS"), + "an occupied legacy Left surface must not receive a duplicate Tideline rail:\n{rendered}" + ); + assert_eq!( + app.viewport + .last_transcript_area + .expect("legacy Left transcript") + .x, + app.work_surface.side_width, + "the legacy side surface, not the compact Tideline width, owns this layout" + ); + } + + #[test] + fn explicit_right_and_off_do_not_enable_the_tideline_summary() { + for placement in [WorkSurfacePlacement::Right, WorkSurfacePlacement::Off] { + let mut app = active_tideline_app(); + app.work_surface.placement = placement; + + let rendered = render_underwater_test_app(&mut app, 120, 32); + assert!( + !rendered.contains("RUNS"), + "explicit {placement:?} must not be replaced by the Tideline rail:\n{rendered}" + ); + assert_eq!( + app.viewport + .last_transcript_area + .expect("explicit placement transcript") + .x, + 0, + "explicit {placement:?} retains the full transcript when its legacy surface is empty" + ); + } + } + /// The nickname the seeded sub-agent renders under. A marker the test owns, /// rather than chrome the shell might stop drawing. const AGENT_MARK: &str = "railprobe"; diff --git a/crates/tui/src/tui/underwater.rs b/crates/tui/src/tui/underwater.rs index c560c3991c..cd6580f82c 100644 --- a/crates/tui/src/tui/underwater.rs +++ b/crates/tui/src/tui/underwater.rs @@ -941,21 +941,6 @@ fn truncate_to_width(text: &str, width: usize) -> String { result } -fn render_launch_line(area: Rect, buf: &mut Buffer, y: u16, spans: Vec>) { - if y >= area.height { - return; - } - Paragraph::new(Line::from(spans)).render( - Rect { - x: area.x, - y: area.y.saturating_add(y), - width: area.width, - height: 1, - }, - buf, - ); -} - fn render_launch_content_line( area: Rect, buf: &mut Buffer, @@ -982,11 +967,22 @@ fn render_launch_content_line( /// /// The dock owns the stage spacer's bottom rows (spec §5b: composer /// `Length(4)` incl. border, below the option strip and above the merged -/// footer): `[input, hint, rule, prompt]` at four rows, shedding the rule -/// first, then the hint shares the prompt row — the tiers the classic -/// screen's strip authored, re-derived against `startup_layout`. Rows are -/// stage-relative; `None` when the stage cannot fit even the input row. +/// footer). At its full size, the shared rounded shell uses the two interior +/// rows for input and localized submit guidance. Compact terminals retain the +/// one-line projection rather than claiming borders they cannot render. +/// Rows are stage-relative; `None` when the stage cannot fit even the input +/// row. fn launch_composer_rows(stage: Rect) -> Option<(u16, u16)> { + let dock = startup_layout(stage).dock; + let input_y = if dock.height >= crate::tui::composer_chrome::TIDELINE_COMPOSER_HEIGHT { + dock.y.saturating_sub(stage.y).saturating_add(1) + } else { + dock.y.saturating_sub(stage.y) + }; + (dock.height >= 1).then_some((input_y, input_y.saturating_add(1))) +} + +fn launch_compact_composer_rows(stage: Rect) -> Option<(u16, u16)> { let dock = startup_layout(stage).dock; let input_y = dock.y.saturating_sub(stage.y); (dock.height >= 1).then_some((input_y, input_y.saturating_add(1))) @@ -1164,7 +1160,7 @@ pub fn render_launch_completion_popup( /// [`LaunchComposerDisplay::from_app`]; the row painting itself is /// `render_launch_composer` — #5698's docked strip, reused line-for-line /// and re-docked below the option strip. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] pub struct LaunchComposerDisplay<'a> { /// Whether the composer holds keyboard focus (`launch.composer_focus`). pub focused: bool, @@ -1180,6 +1176,25 @@ pub struct LaunchComposerDisplay<'a> { pub hint_focused: std::borrow::Cow<'a, str>, /// The blurred composer's hint line. pub hint_blurred: std::borrow::Cow<'a, str>, + /// Mirrors the shared composer preference. The default Tideline startup + /// surface uses the rounded enclosure; an explicit compact opt-out keeps + /// the legacy one-line projection only where the setting asks for it. + pub enclosed: bool, +} + +impl Default for LaunchComposerDisplay<'_> { + fn default() -> Self { + Self { + focused: false, + input: "", + caret: 0, + low_motion: false, + placeholder: Cow::Borrowed(""), + hint_focused: Cow::Borrowed(""), + hint_blurred: Cow::Borrowed(""), + enclosed: true, + } + } } impl<'a> LaunchComposerDisplay<'a> { @@ -1195,6 +1210,7 @@ impl<'a> LaunchComposerDisplay<'a> { placeholder: tr(app.ui_locale, MessageId::ComposerPlaceholder), hint_focused: tr(app.ui_locale, MessageId::LaunchComposerHint), hint_blurred: tr(app.ui_locale, MessageId::LaunchComposerFocusHint), + enclosed: app.composer_border, } } } @@ -1212,8 +1228,104 @@ fn render_launch_composer( display: &LaunchComposerDisplay<'_>, input_y: u16, hint_y: u16, + panel_area: Option, + status_line: Option<&str>, + ascii_safe: bool, ) { let focused = display.focused; + if let Some(panel_area) = panel_area { + crate::tui::composer_chrome::render_tideline_composer_shell( + panel_area, buf, theme, focused, ascii_safe, + ); + let geometry = crate::tui::composer_chrome::tideline_composer_geometry(panel_area); + let content_width = usize::from( + geometry + .submit + .x + .saturating_sub(geometry.content.x) + .saturating_sub(1), + ); + if content_width == 0 { + return; + } + let text_budget = content_width.saturating_sub(2); + let prompt_style = if focused { + theme.accent_primary + } else { + theme.text_hint + }; + let input = display.input; + let caret = launch_cursor_glyph(display.low_motion); + let body = if input.is_empty() { + if focused { + caret.to_string() + } else { + display.placeholder.to_string() + } + } else if focused { + let (line, col) = launch_cursor_line(input, display.caret); + let (before, after) = launch_caret_window(line, col, text_budget); + format!("{before}{caret}{after}") + } else { + let (line, _) = launch_cursor_line(input, display.caret); + line.to_string() + }; + let body_style = if focused { + theme.text_body + } else if input.is_empty() { + theme.text_hint + } else { + theme.text_muted + }; + Paragraph::new(Line::from(vec![ + Span::styled("❯", Style::default().fg(prompt_style)), + Span::raw(" "), + Span::styled( + truncate_to_width(&body, text_budget), + Style::default().fg(body_style), + ), + ])) + .render( + Rect { + x: geometry.content.x, + y: geometry.content.y, + width: content_width as u16, + height: 1, + }, + buf, + ); + + let hint = status_line.map(Cow::Borrowed).unwrap_or_else(|| { + if focused { + display.hint_focused.clone() + } else { + display.hint_blurred.clone() + } + }); + if geometry.content.height >= 2 { + Paragraph::new(Line::from(Span::styled( + truncate_to_width(hint.as_ref(), content_width), + Style::default().fg(if status_line.is_some() { + theme.text_body + } else if focused { + theme.text_hint + } else { + theme.text_dim + }), + ))) + .render( + Rect { + x: geometry.content.x, + y: geometry.content.y.saturating_add(1), + width: content_width as u16, + height: 1, + }, + buf, + ); + } + return; + } + let content_width = usize::from(area.width).saturating_sub(4); if content_width == 0 { return; @@ -2223,8 +2335,8 @@ mod launch_composer_tests { let (buf, area) = render(&app, width, height); let input_row = row_text(&buf, area, input_y); assert!( - input_row.contains('❯') && input_row.contains('↑'), - "{width}x{height}: composer row lacks its prompt/send anchors: {input_row:?}" + input_row.contains('❯'), + "{width}x{height}: composer row lacks its prompt anchor: {input_row:?}" ); assert!( input_row.contains(&tr(Locale::En, MessageId::ComposerPlaceholder).into_owned()), @@ -2245,12 +2357,19 @@ mod launch_composer_tests { // Hitboxes mirror the rendered row, and send sits at its end. let composer = app.launch.composer_area.expect("composer hitbox"); let send = app.launch.send_area.expect("send hitbox"); - assert_eq!(composer.y, area.y + input_y); - assert_eq!(send.y, composer.y); - assert_eq!(send.right(), composer.right()); + assert!( + composer.y <= area.y + input_y && area.y + input_y < composer.bottom(), + "{width}x{height}: input row must live inside the focus surface" + ); + assert!( + composer.y <= send.y && send.y < composer.bottom(), + "{width}x{height}: send target must live inside the focus surface" + ); + assert!(send.right() <= composer.right()); + let expected_send = if send.width == 3 { "[↑]" } else { " ↑" }; assert_eq!( row_cells(&buf, area, send.y - area.y, send.x, send.right()), - " ↑", + expected_send, "{width}x{height}: send hitbox must cover the rendered send glyph" ); assert!( @@ -2892,34 +3011,39 @@ use ratatui::layout::{Constraint, Layout}; use crate::palette::UiTheme; -/// The founder's fluke mark — the generated 12x6 cell rendition from the -/// brand master path (`designs/brand/20260829-fluke-founder/TUI_GLYPHS.md`, -/// produced by `build-tui-glyph.py`; never hand-drawn). The hand-projected -/// three-cell crown was deleted by founder decree; this block is its -/// replacement everywhere in the startup path. The ASCII-safe projection maps each -/// quadrant block through its declared `glyphs::ascii_fallback` (`#`, `.`, -/// `\`) — a legible silhouette, not a smear. -const FLUKE_BLOCK: [&str; 6] = [ - "▚▄▄▖ ▗▄▄▟", - "▝▜███▙▟███▛▘", - " ▝▀▜██▛▀▘", - " ▜█", - " ██▖", - " ▝▀▜█▙▄▖", +/// The current Codewhale diving-whale mark — the generated 10x6 cell +/// rendition from `designs/brand/20260829-codewhale-mark/TUI_GLYPHS.md`, +/// produced by `build-tui-glyph.py`; never hand-drawn. Its wave is the `>` +/// shell-prompt motif. The retired fluke-ring proposal is deliberately absent +/// from this startup path. The ASCII-safe projection maps each quadrant block +/// through its declared `glyphs::ascii_fallback` — a legible silhouette, not a +/// smear. +const CURRENT_MARK_BLOCK: [&str; 6] = [ + "▄▄▄▄██▌", + "▜████▀▘", + " ▟██▄▄", + " ▟█████▖", + " ▐█▟▀█████", + " ▐█▜██████", ]; -/// Rows of fluke-mark ink, one `String` per terminal row. `ascii_safe` +/// The generated glyph has a seven-cell first row, but all rows share this +/// ten-cell canvas. Use the canvas for centering and hitboxes so later rows do +/// not shift or escape their recorded area. +const CURRENT_MARK_WIDTH: u16 = 10; + +/// Rows of current-mark ink, one `String` per terminal row. `ascii_safe` /// projects each cell through the declared fallbacks (spec §2: every /// authored glyph has one). -fn fluke_rows(ascii_safe: bool) -> Vec { - FLUKE_BLOCK +fn current_mark_rows(ascii_safe: bool) -> Vec { + CURRENT_MARK_BLOCK .iter() - .map(|row| fluke_row(row, ascii_safe)) + .map(|row| current_mark_row(row, ascii_safe)) .collect() } /// Single-row projection: the unicode row verbatim, or each block through /// its declared ASCII fallback. -fn fluke_row(row: &str, ascii_safe: bool) -> String { +fn current_mark_row(row: &str, ascii_safe: bool) -> String { if !ascii_safe { return row.to_string(); } @@ -3121,8 +3245,8 @@ impl<'a> TidelineStartup<'a> { .collect() } - fn fluke(&self) -> Vec { - fluke_rows(self.ascii_safe) + fn current_mark(&self) -> Vec { + current_mark_rows(self.ascii_safe) } } @@ -3164,8 +3288,9 @@ struct StartupLayout { quick_rows_start: u16, /// Row within `hero` where the centered hero block starts. hero_top: u16, - /// Whether the 12x6 fluke mark fits (`FLUKE_BLOCK` + heading + subtitle). - fluke_shown: bool, + /// Whether the 10x6 current mark fits (the fixed canvas, heading, and + /// subtitle). + current_mark_shown: bool, /// Option-strip column count: 4 tiles need ~14 cells each to name /// themselves whole, so below 56 stage columns the strip sheds to 2 /// (§5b shed ⑩) rather than truncate every label mid-word. @@ -3194,10 +3319,11 @@ fn startup_layout(stage: Rect) -> StartupLayout { height: dock_h, ..tail }; - let fluke_h = FLUKE_BLOCK.len() as u16; - let fluke_shown = hero.height >= fluke_h.saturating_add(2); - let block_h = if fluke_shown { - fluke_h + 2 + let current_mark_h = CURRENT_MARK_BLOCK.len() as u16; + let current_mark_shown = + hero.width >= CURRENT_MARK_WIDTH && hero.height >= current_mark_h.saturating_add(2); + let block_h = if current_mark_shown { + current_mark_h + 2 } else { u16::min(hero.height, 2) }; @@ -3210,7 +3336,7 @@ fn startup_layout(stage: Rect) -> StartupLayout { dock, quick_rows_start: quick.height.saturating_sub(3), hero_top: hero.height.saturating_sub(block_h) / 2, - fluke_shown, + current_mark_shown, strip_columns: if strip.width < 56 { 2 } else { 4 }, } } @@ -3224,16 +3350,18 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline let theme = startup.theme; let layout = startup_layout(stage); - // Hero: the generated fluke mark (when the vertical budget admits it), - // the heading, and one dim subtitle (first-run vs returning) as one - // vertically centered block. + // Hero: the generated current mark (when the cell budget admits it), the + // heading, and one dim subtitle (first-run vs returning) as one vertically + // centered block. Each mark row is left-aligned on the same fixed-width + // canvas, rather than individually centered by its visible ink width. let mut hero_row = layout.hero_top; - if layout.fluke_shown { - for row in startup.fluke() { - centered( + if layout.current_mark_shown { + let mark_x = layout.hero.x + layout.hero.width.saturating_sub(CURRENT_MARK_WIDTH) / 2; + for row in startup.current_mark() { + set_span( buf, - layout.hero, - hero_row, + mark_x, + layout.hero.y + hero_row, &Span::styled(row, chrome(theme, ChromeInk::Attention)), ); hero_row = hero_row.saturating_add(1); @@ -3380,27 +3508,33 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline ); } - // The docked pre-session composer (§5b: composer Length(4) incl. - // border): #5698's strip re-docked below the option strip, in the - // spacer's bottom rows. Paint order is precedence — the strip first, - // then the stage's one transient line (the worktree-name prompt or a - // launch status message) over the dock's last row, so a modal prompt - // outranks the hint exactly as the classic screen's prompt row did. - if let Some((input_row, hint_row)) = launch_composer_rows(stage) { - render_launch_composer(stage, buf, theme, &startup.composer, input_row, hint_row); - if layout.dock.height == 4 { - // The dock's border row between hint and prompt. - render_launch_line( - stage, - buf, - input_row + 2, - vec![Span::styled( - "─".repeat(usize::from(stage.width)), - chrome(theme, ChromeInk::MetadataDim), - )], - ); - } - if let Some(line) = startup.status_line.as_deref() { + // The docked pre-session composer (§5b) is the same rounded Tideline + // shell used by the work surface whenever the full four-row dock fits. + // Its shell owns both the visible `[↑]` affordance and the matching + // geometry; the launch renderer owns only localized input/hint content. + // Tiny terminals retain the compact strip rather than drawing fake + // corners with no interior cells. + let enclosed = startup.composer.enclosed + && layout.dock.height >= crate::tui::composer_chrome::TIDELINE_COMPOSER_HEIGHT + && layout.dock.width >= 6; + let composer_rows = if enclosed { + launch_composer_rows(stage) + } else { + launch_compact_composer_rows(stage) + }; + if let Some((input_row, hint_row)) = composer_rows { + render_launch_composer( + stage, + buf, + theme, + &startup.composer, + input_row, + hint_row, + enclosed.then_some(layout.dock), + startup.status_line.as_deref(), + startup.ascii_safe, + ); + if !enclosed && let Some(line) = startup.status_line.as_deref() { let y = if layout.dock.height == 1 { input_row } else { @@ -3423,16 +3557,18 @@ pub fn render_tideline_startup(stage: Rect, buf: &mut Buffer, startup: &Tideline } } -/// Recorded hitboxes for the startup stage (spec §6): the hero fluke, each -/// quick action row, and each option-strip tile. Same shapes as the painted -/// cells. +/// Recorded interactive hitboxes for the startup stage (spec §6): each quick +/// action row and option-strip tile, plus the docked composer's focus and send +/// targets. The hero brand mark is deliberately decorative; it has no action +/// or hitbox until the product defines one with keyboard parity. #[derive(Debug, Clone, Default)] pub struct TidelineStartupHitboxes { - pub fluke: Rect, pub actions: Vec, pub options: Vec, - /// The docked composer's input row (click focuses, exactly like Tab). + /// The docked composer focus surface (click focuses, exactly like Tab). pub composer: Option, + /// The actual text-input row; completion menus anchor directly above it. + pub input: Option, /// The send glyph inside the composer row (click submits). pub send: Option, } @@ -3442,21 +3578,22 @@ pub struct TidelineStartupHitboxes { /// cells wherever both run on the same stage. #[must_use] pub fn tideline_startup_hitboxes(stage: Rect) -> TidelineStartupHitboxes { + tideline_startup_hitboxes_with_composer(stage, true) +} + +/// Same geometry as [`tideline_startup_hitboxes`], respecting the explicit +/// compact-composer preference used by the current `App` projection. +#[must_use] +pub fn tideline_startup_hitboxes_with_composer( + stage: Rect, + enclosed: bool, +) -> TidelineStartupHitboxes { let mut out = TidelineStartupHitboxes::default(); if stage.width < 8 || stage.height < 5 { return out; } let layout = startup_layout(stage); - if layout.fluke_shown { - let fluke_w = FLUKE_BLOCK[0].width() as u16; - out.fluke = Rect { - x: layout.hero.x + (layout.hero.width.saturating_sub(fluke_w)) / 2, - y: layout.hero.y + layout.hero_top, - width: fluke_w, - height: FLUKE_BLOCK.len() as u16, - }; - } out.actions = (0..3) .map(|index| Rect { x: layout.quick.x + 2, @@ -3475,22 +3612,40 @@ pub fn tideline_startup_hitboxes(stage: Rect) -> TidelineStartupHitboxes { height: 2, }) .collect(); - // The docked composer's hitboxes: the input row, and the trailing - // " ↑" send affordance at its end — #5698's shapes, re-anchored to the - // dock's geometry through the same `startup_layout` arithmetic. + // The four-row dock reuses the exact rounded shell geometry, including + // the visible three-cell `[↑]` submit rect. Compact terminals preserve + // the older one-line target because they cannot host an enclosed shell. if layout.dock.height >= 1 { - out.composer = Some(Rect { - x: stage.x.saturating_add(2), - y: layout.dock.y, - width: stage.width.saturating_sub(4), - height: 1, - }); - out.send = Some(Rect { - x: stage.x.saturating_add(stage.width.saturating_sub(4)), - y: layout.dock.y, - width: 2.min(stage.width), - height: 1, - }); + let use_enclosed = enclosed + && layout.dock.height >= crate::tui::composer_chrome::TIDELINE_COMPOSER_HEIGHT + && layout.dock.width >= 6; + if use_enclosed { + let geometry = crate::tui::composer_chrome::tideline_composer_geometry(layout.dock); + let hitboxes = crate::tui::composer_chrome::tideline_composer_hitboxes(layout.dock); + out.composer = Some(hitboxes.border); + out.input = Some(Rect { + x: geometry.content.x, + y: geometry.content.y, + width: geometry.content.width, + height: 1, + }); + out.send = Some(hitboxes.submit); + } else { + let input = Rect { + x: stage.x.saturating_add(2), + y: layout.dock.y, + width: stage.width.saturating_sub(4), + height: 1, + }; + out.composer = Some(input); + out.input = Some(input); + out.send = Some(Rect { + x: stage.x.saturating_add(stage.width.saturating_sub(4)), + y: layout.dock.y, + width: 2.min(stage.width), + height: 1, + }); + } } out } diff --git a/crates/tui/src/tui/underwater/tideline_tests.rs b/crates/tui/src/tui/underwater/tideline_tests.rs index 8f3e77d68c..0860a3c102 100644 --- a/crates/tui/src/tui/underwater/tideline_tests.rs +++ b/crates/tui/src/tui/underwater/tideline_tests.rs @@ -8,8 +8,8 @@ use ratatui::layout::Rect; use unicode_width::UnicodeWidthChar; use super::{ - FLUKE_BLOCK, LaunchAction, TidelineStartup, handle_launch_key, render_tideline_startup, - tideline_startup_hitboxes, + CURRENT_MARK_BLOCK, CURRENT_MARK_WIDTH, LaunchAction, TidelineStartup, handle_launch_key, + render_tideline_startup, tideline_startup_hitboxes, }; use crate::palette::UI_THEME; use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text}; @@ -87,7 +87,7 @@ fn startup_matches_golden_at_the_40x12_terminal_floor() { // §5b shed order proven at the floor. A 40x12 terminal leaves the stage // 10 rows after the topbar and merged footer: the QUICK ACTIONS label // row and the wave rules collapse, the hero keeps heading + subtitle - // (the 12x6 fluke needs its 8-row budget), and the strip sheds to 2 + // (the 10x6 current mark needs its 8-row budget), and the strip sheds to 2 // columns so tile labels stay whole. let fixture = returning(); let startup = fixture.widget(&UI_THEME); @@ -123,26 +123,29 @@ fn startup_hero_states_first_run_vs_returning() { } #[test] -fn startup_hero_paints_the_generated_fluke_block_and_sheds_it_at_short_stages() { - // The generated 12x6 mark (never a hand-drawn crown): centered, one row - // per FLUKE_BLOCK line, above the heading. It sheds below its 8-row - // budget instead of being clipped mid-mark. +fn startup_hero_paints_the_generated_current_mark_on_its_fixed_canvas_and_sheds_it_at_short_stages() +{ + // The generated 10x6 current mark: each row starts on the same ten-cell + // canvas, above the heading. It sheds below its 8-row budget instead of + // being clipped mid-mark. let startup = TidelineStartup::new(&UI_THEME, 4, true); let wide = draw(80, 24, &startup); - for row in FLUKE_BLOCK { - let row_w = unicode_width::UnicodeWidthStr::width(row); - let expected = format!("{}{row}", " ".repeat((80 - row_w) / 2)); + for row in CURRENT_MARK_BLOCK { + let expected = format!( + "{}{row}", + " ".repeat((80 - usize::from(CURRENT_MARK_WIDTH)) / 2) + ); assert!( wide.lines().any(|line| line.starts_with(&expected)), - "fluke row {row:?} must paint centered at 80x24:\n{wide}" + "current mark row {row:?} must paint on its fixed canvas at 80x24:\n{wide}" ); } // 60x16: stage 14 rows -> hero 5 < 8, mark sheds, heading stays. let short = draw(60, 16, &startup); assert!(short.contains("What are we working on?"), "{short}"); assert!( - !short.contains(FLUKE_BLOCK[0]), - "the fluke must not clip mid-mark at short stages:\n{short}" + !short.contains(CURRENT_MARK_BLOCK[0]), + "the current mark must not clip mid-mark at short stages:\n{short}" ); } @@ -285,13 +288,26 @@ fn startup_option_strip_sheds_to_two_columns_when_narrow() { fn startup_ascii_safe_has_no_wide_or_unsupported_glyphs() { let startup = TidelineStartup::new(&UI_THEME, 4, true).ascii_safe(true); let text = draw(100, 30, &startup); - // The generated fluke projects through the declared quadrant-block - // fallbacks (`#`, `.`, `\`) — a legible silhouette, not a smear. - assert!( - text.lines() - .any(|line| line.contains("\\###") || line.contains("###.")), - "ascii fluke block must paint:\n{text}" - ); + // The generated current mark projects through the declared quadrant-block + // fallbacks — a legible silhouette, not a hand-made ASCII substitute. + for row in CURRENT_MARK_BLOCK { + let projected: String = row + .chars() + .map(|ch| { + if ch == ' ' { + ch.to_string() + } else { + crate::tui::glyphs::ascii_fallback(&ch.to_string()) + .map(str::to_string) + .unwrap_or_else(|| ch.to_string()) + } + }) + .collect(); + assert!( + text.lines().any(|line| line.contains(projected.trim_end())), + "ascii current-mark row {projected:?} must paint:\n{text}" + ); + } assert!(text.contains(". ~~~ ."), "wave rule projects to ASCII"); assert!(text.contains("Enter >"), "chevron projects to >"); for ch in text.chars() { @@ -311,7 +327,6 @@ fn startup_hitboxes_match_painted_cells() { let (w, h) = (100, 30); let area = Rect::new(0, 0, w, h); let hitboxes = tideline_startup_hitboxes(area); - assert!(hitboxes.fluke.width > 0, "fluke is a hitbox at 100x30"); assert_eq!(hitboxes.actions.len(), 3, "one rect per quick action row"); assert_eq!(hitboxes.options.len(), 4, "one rect per option tile"); let mut buf = Buffer::empty(area); @@ -329,11 +344,6 @@ fn startup_hitboxes_match_painted_cells() { assert!(rect.x + rect.width <= w); assert!(rect.y + rect.height <= h); } - let fluke_cells = painted(hitboxes.fluke); - assert!( - !fluke_cells.trim().is_empty(), - "fluke hitbox covers the mark" - ); } #[test] diff --git a/crates/tui/src/tui/views/mod.rs b/crates/tui/src/tui/views/mod.rs index 3f26da3cea..8ca6593147 100644 --- a/crates/tui/src/tui/views/mod.rs +++ b/crates/tui/src/tui/views/mod.rs @@ -786,6 +786,10 @@ pub enum ViewEvent { StatusMessage { message: String, }, + /// The Tideline topbar's route segment requested the normal `/provider` + /// surface. It carries no catalog, readiness, or selected-route payload: + /// those facts remain owned by the provider picker and its apply path. + TopbarRoutePickerRequested, /// Emitted by the `/provider` picker on Esc so the next open can restore /// the browsing context — view mode and highlighted row. ProviderPickerDismissed { @@ -2169,14 +2173,6 @@ impl ConfigView { .effective(app.fancy_animations.to_string()), ), }, - ConfigRow { - section: ConfigSection::Display, - key: "launch_screen".to_string(), - value: settings.launch_screen.to_string(), - editable: true, - scope: ConfigScope::Saved, - facts: ConfigRowFacts::saved_setting().apply(SettingApplySemantics::NextSession), - }, ConfigRow { section: ConfigSection::Display, key: "show_thinking".to_string(), @@ -3357,7 +3353,6 @@ fn config_label_message(key: &str) -> Option { "calm_mode" => MessageId::ConfigLabelCalmMode, "low_motion" => MessageId::ConfigLabelLowMotion, "fancy_animations" => MessageId::ConfigLabelFancyAnimations, - "launch_screen" => MessageId::ConfigLabelLaunchScreen, "show_thinking" => MessageId::ConfigLabelShowThinking, "thinking_highlight" => MessageId::ConfigLabelThinkingHighlight, "show_tool_details" => MessageId::ConfigLabelShowToolDetails, @@ -3461,11 +3456,9 @@ fn config_hint_for_key(locale: Locale, key: &str) -> Cow<'static, str> { "managed_allow_shell" => MessageId::ConfigHintManagedAllowShell, "allow_shell" => MessageId::ConfigHintAllowShell, "composer_multiline_mode" => MessageId::ConfigHintComposerMultilineMode, - "auto_compact" - | "launch_screen" - | "show_tool_details" - | "composer_border" - | "paste_burst_detection" => MessageId::ConfigHintBooleanValues, + "auto_compact" | "show_tool_details" | "composer_border" | "paste_burst_detection" => { + MessageId::ConfigHintBooleanValues + } "composer_density" | "transcript_spacing" => MessageId::ConfigHintDensity, "inline_diffs" => MessageId::ConfigHintInlineDiffs, "tool_collapse" => MessageId::ConfigHintToolCollapse, @@ -3532,7 +3525,6 @@ fn config_boolean_key(key: &str) -> bool { | "calm_mode" | "low_motion" | "fancy_animations" - | "launch_screen" | "show_thinking" | "thinking_default_expanded" | "thinking_highlight" diff --git a/crates/tui/src/tui/widgets/mod.rs b/crates/tui/src/tui/widgets/mod.rs index 4afc86af3b..22347c1d46 100644 --- a/crates/tui/src/tui/widgets/mod.rs +++ b/crates/tui/src/tui/widgets/mod.rs @@ -1109,6 +1109,45 @@ fn render_jump_to_latest_button( const COMPOSER_PROMPT_GUTTER_WIDTH: u16 = 2; const COMPOSER_PANEL_MIN_WIDTH: u16 = 12; +/// Whether the active composer should use its full rounded enclosure. +/// +/// `composer_border` is a legacy configuration name, but its compatibility +/// policy is deliberate: the default `true` means the Tideline enclosure; +/// `false` is an explicit compact/quiet opt-out. Keep every layout consumer +/// behind this helper so the reserved floor, measured height, and rendered +/// geometry cannot drift apart. +#[must_use] +pub(crate) fn composer_enclosure_enabled(app: &App) -> bool { + app.composer_border +} + +/// Restore the rounded corners after the semantic top/bottom passes. +/// +/// Ratatui renders a `TOP`-only (or `BOTTOM`-only) block through the corner +/// cells as horizontal line glyphs. The live composer needs those passes for +/// its localized titles and independent permission/mode color ramps, so put +/// the four rounded joins back afterward rather than replacing its mature +/// input widget with the unfinished translation scaffold. +fn render_composer_panel_corners( + area: Rect, + buf: &mut Buffer, + background: Style, + permission_color: Color, + mode_color: Color, +) { + let top_style = background.fg(permission_color); + let bottom_style = background.fg(mode_color); + let left = area.left(); + let right = area.right().saturating_sub(1); + let top = area.top(); + let bottom = area.bottom().saturating_sub(1); + + buf[(left, top)].set_symbol("╭").set_style(top_style); + buf[(right, top)].set_symbol("╮").set_style(top_style); + buf[(left, bottom)].set_symbol("╰").set_style(bottom_style); + buf[(right, bottom)].set_symbol("╯").set_style(bottom_style); +} + /// Whether the outer composer rect can carry both semantic border rows. /// /// Keep this policy in outer-area coordinates. Input wrapping subtracts the @@ -1239,17 +1278,20 @@ impl<'a> ComposerWidget<'a> { } fn wants_enclosed_panel(&self) -> bool { - self.app.composer_border + composer_enclosure_enabled(self.app) } pub(crate) fn has_panel(&self, area: Rect) -> bool { enclosed_composer_panel_fits(self.wants_enclosed_panel(), area.width, area.height) } - fn inner_area(&self, area: Rect) -> Rect { + /// The border-aware content rectangle shared by rendering, cursor mapping, + /// and the frame's persistent mouse geometry. + pub(crate) fn inner_area(&self, area: Rect) -> Rect { if self.has_panel(area) { Block::default() - .borders(Borders::TOP | Borders::BOTTOM) + .borders(Borders::ALL) + .border_type(BorderType::Rounded) .inner(area) } else if area.height >= 2 { Block::default().borders(Borders::TOP).inner(area) @@ -1357,14 +1399,28 @@ impl Renderable for ComposerWidget<'_> { ApprovalMode::Auto => self.app.ui_theme.permission_auto_review, ApprovalMode::Bypass => self.app.ui_theme.permission_full_access, }; + // Paint the enclosure first so the live composer gets actual + // rounded side rails. The semantic top/bottom blocks below keep + // their existing permission/mode color ramps and titles while the + // neutral rails stay legible on every supported theme. + Block::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(Style::default().fg(self.app.ui_theme.border)) + .style(background) + .render(area, buf); let mut top_border = Block::default() .borders(Borders::TOP) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(permission_color)) .style(background); if self.app.is_history_search_active() { top_border = top_border.title(Line::from(Span::styled( - self.app - .tr(crate::localization::MessageId::HistorySearchTitle), + format!( + " {} ", + self.app + .tr(crate::localization::MessageId::HistorySearchTitle) + ), Style::default().fg(palette::TEXT_MUTED), ))); } @@ -1385,12 +1441,20 @@ impl Renderable for ComposerWidget<'_> { let mut bottom_border = Block::default() .borders(Borders::BOTTOM) + .border_type(BorderType::Rounded) .border_style(Style::default().fg(self.mode_color())) .style(background); if let Some(hint_line) = hint_line { bottom_border = bottom_border.title_bottom(hint_line); } bottom_border.render(area, buf); + render_composer_panel_corners( + area, + buf, + background, + permission_color, + self.mode_color(), + ); } else if area.height >= 2 { let mut block = Block::default() .borders(Borders::TOP) @@ -3422,8 +3486,13 @@ fn composer_height( show_panel: bool, ) -> u16 { let has_panel = enclosed_composer_panel_fits(show_panel, area_width, available_height); + // A full enclosure spends one column on each side before the prompt + // gutter. Use the same width as `inner_area` + `composer_content_geometry` + // so measured height and rendered wrapping agree at every boundary. + let panel_side_inset = u16::from(has_panel).saturating_mul(2); let content_width = usize::from( area_width + .saturating_sub(panel_side_inset) .saturating_sub(COMPOSER_PROMPT_GUTTER_WIDTH) .max(1), ); @@ -4350,6 +4419,9 @@ mod tests { ..crate::test_support::test_tui_options(PathBuf::from(".")) }; let mut app = App::new(options, &Config::default()); + // Widget contracts below exercise the post-Startup conversation + // surface. Startup rendering has its own explicit fixture/tests. + app.launch.visible = false; app.ui_locale = Locale::En; app.composer.vim_enabled = false; // Most widget fixtures exercise the explicitly selected underwater @@ -5799,7 +5871,13 @@ mod tests { } else { 1 }; - let content_width = usize::from(width.saturating_sub(COMPOSER_PROMPT_GUTTER_WIDTH).max(1)); + let horizontal_enclosure = u16::from(has_panel).saturating_mul(2); + let content_width = usize::from( + width + .saturating_sub(horizontal_enclosure) + .saturating_sub(COMPOSER_PROMPT_GUTTER_WIDTH) + .max(1), + ); let input_height_budget = usize::from(height) .saturating_sub(menu_lines) .saturating_sub(chrome_height) @@ -5855,9 +5933,64 @@ mod tests { expected_panel, "width={width} bottom border disagrees with height policy" ); + if expected_panel { + assert_eq!( + widget.inner_area(area), + Rect::new(1, 1, width.saturating_sub(2), 1), + "width={width} panel inner area must reserve both side rails" + ); + assert_eq!(buf[(area.left(), area.top())].symbol(), "\u{256d}"); + assert_eq!( + buf[(area.right().saturating_sub(1), area.top())].symbol(), + "\u{256e}" + ); + assert_eq!( + buf[(area.left(), area.bottom().saturating_sub(1))].symbol(), + "\u{2570}" + ); + assert_eq!( + buf[( + area.right().saturating_sub(1), + area.bottom().saturating_sub(1) + )] + .symbol(), + "\u{256f}" + ); + assert_eq!( + buf[(area.left(), area.y.saturating_add(1))].symbol(), + "\u{2502}" + ); + assert_eq!( + buf[(area.right().saturating_sub(1), area.y.saturating_add(1))].symbol(), + "\u{2502}" + ); + } else { + assert_eq!( + widget.inner_area(area), + Rect::new(area.x, area.y.saturating_add(1), area.width, 1), + "width={width} compact fallback must keep its full input width" + ); + assert_ne!(buf[(area.left(), area.top())].symbol(), "\u{256d}"); + } } } + #[test] + fn composer_height_wraps_to_the_rounded_panel_content_width() { + // At the minimum viable panel width, the two side rails and prompt + // gutter leave eight text columns. Measuring against the old ten + // columns would render a second line without allocating its row. + let height = composer_height( + "123456789", + super::COMPOSER_PANEL_MIN_WIDTH, + 8, + 0, + ComposerDensity::Comfortable, + true, + ); + assert_eq!(height, 4); + } + #[test] fn composer_expands_for_multiline_input_and_collapses_again() { let height_for = @@ -6006,14 +6139,14 @@ mod tests { }; // The two border rows carry independent permission/mode signals. - // inner_area: {x:0, y:1, w:40, h:3} + // inner_area: {x:1, y:1, w:38, h:3} // input_rows_budget = 3 // The prompt and hint share one quiet row. assert_eq!( empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 40, 3), 1 ); - assert_eq!(widget.cursor_pos(area), Some((2, 2))); + assert_eq!(widget.cursor_pos(area), Some((3, 2))); } #[test] @@ -6032,17 +6165,17 @@ mod tests { height: 5, }; - // inner_area: {x:0, y:1, w:14, h:3} + // inner_area: {x:1, y:1, w:12, h:3} // input_rows_budget = 3 - // placeholder_visual_lines(14) = 2 + // placeholder_visual_lines(12) = 3 // The narrow fallback still reserves one composer row; Paragraph // clipping keeps it from growing the shell. - assert_eq!(placeholder_visual_lines(14), 2); + assert_eq!(placeholder_visual_lines(12), 3); assert_eq!( empty_composer_visual_rows(Some(COMPOSER_PLACEHOLDER), 14, 3), 1 ); - assert_eq!(widget.cursor_pos(area), Some((2, 2))); + assert_eq!(widget.cursor_pos(area), Some((3, 2))); } #[test] @@ -6092,11 +6225,12 @@ mod tests { row_text(&buf, area, cursor_y).contains(&placeholder), "prompt and hint should share one row: {rendered}" ); + let inner = widget.inner_area(area); + let quiet_row = cursor_y.saturating_add(1); assert!( - row_text(&buf, area, cursor_y.saturating_add(1)) - .trim() - .is_empty(), - "comfortable composer should keep a quiet row before the footer: {rendered}" + quiet_row < inner.bottom() + && (inner.x..inner.right()).all(|x| buf[(x, quiet_row)].symbol() == " "), + "comfortable composer should keep a quiet content row before the footer: {rendered}" ); } @@ -6117,9 +6251,9 @@ mod tests { .cursor_pos(area) .expect("composer with input should expose a cursor"); - assert_eq!(buf[(0, cursor_y)].symbol(), "❯"); - assert_eq!(buf[(2, cursor_y)].symbol(), "h"); - assert_eq!(cursor_x, 7, "cursor keeps the prompt gutter reserved"); + assert_eq!(buf[(1, cursor_y)].symbol(), "❯"); + assert_eq!(buf[(3, cursor_y)].symbol(), "h"); + assert_eq!(cursor_x, 8, "cursor keeps the prompt gutter reserved"); } fn render_composer(app: &App, width: u16, height: u16) -> String { diff --git a/crates/tui/src/tui/work_surface/model.rs b/crates/tui/src/tui/work_surface/model.rs index fe2b4725df..4c60288d32 100644 --- a/crates/tui/src/tui/work_surface/model.rs +++ b/crates/tui/src/tui/work_surface/model.rs @@ -10,8 +10,8 @@ use crate::tools::canonical_action::canonical_action_alias; use crate::tools::subagent::{AgentWorkerStatus, SubAgentResult, SubAgentStatus}; use crate::tui::app::{ AgentCurrentActivityStatus, AgentProgressMeta, App, SidebarRowAction, TaskPanelEntry, - TaskPanelEntryKind, }; +use crate::tui::background_indicator::is_live_shell_entry; use crate::tui::history::{ FileActivityKind, FileActivitySummary, FileMutationReceipt, HistoryCell, ToolCell, }; @@ -2054,16 +2054,6 @@ fn push_shell_group(out: &mut Vec, shells: Vec) { out.extend(shells); } -fn is_live_shell_entry(entry: &TaskPanelEntry) -> bool { - if entry.kind != TaskPanelEntryKind::Background { - return false; - } - if !matches!(entry.status.as_str(), "running" | "queued") { - return false; - } - entry.prompt_summary.starts_with("shell: ") || entry.id.starts_with("shell_") -} - fn shell_work_rows(app: &App) -> Vec { app.task_panel .iter() diff --git a/crates/tui/src/tui/work_surface/tideline.rs b/crates/tui/src/tui/work_surface/tideline.rs index ce6312eb71..3cf58b5702 100644 --- a/crates/tui/src/tui/work_surface/tideline.rs +++ b/crates/tui/src/tui/work_surface/tideline.rs @@ -9,6 +9,9 @@ //! Also hosts the Tideline work-stage composite (`rail │ receipt stream`) //! whose golden buffers are `work_{w}x{h}`. +use std::borrow::Cow; +use std::collections::HashSet; + use ratatui::{ buffer::Buffer, layout::{Constraint, Layout, Rect}, @@ -16,9 +19,22 @@ use ratatui::{ }; use unicode_width::UnicodeWidthStr; +use crate::localization::{Locale, MessageId, tr}; use crate::palette::{ChromeInk, UiTheme, chrome_style}; +use crate::tui::app::App; +use crate::tui::background_indicator::{ + PendingItemKind, PendingItemState, PendingWork, live_work_from_app, +}; use crate::tui::history::TidelineStream; +use super::WorkSurfacePlacement; + +/// A compact live rail needs one row for each of its five labels and facts. +/// When the transcript slot is shorter than this, leave the current compact +/// work-surface behavior intact instead of reserving a column that cannot +/// truthfully show its full state. +const LIVE_TIDELINE_MIN_HEIGHT: u16 = 10; + /// Rail width ladder (spec §5b): 22 at ≥120, 16 at ≥100, hidden below. #[must_use] #[allow(dead_code)] // translation scaffolding: wired by the landing slice @@ -36,7 +52,7 @@ pub fn tideline_rail_width(host_width: u16) -> u16 { #[derive(Debug, Clone)] #[allow(dead_code)] // translation scaffolding: wired by the landing slice pub struct TidelineRailGroup { - pub label: &'static str, + pub label: Cow<'static, str>, /// (fact line, ink) pairs, already summarized by the caller. pub lines: Vec<(String, ChromeInk)>, } @@ -52,6 +68,13 @@ pub struct TidelineRail<'a> { /// Focused (keyboard Tab target per §6). pub focused: bool, pub ascii_safe: bool, + /// Whether the caller has registered real keyboard and mouse behavior for + /// the rail's help/settings/collapse affordances. Passive summaries omit + /// those controls rather than painting inert UI. + interactive: bool, + /// Use a dense label/fact cadence. The live summary uses this to keep all + /// five factual groups visible in a normal terminal-height chat slot. + compact: bool, } #[allow(dead_code)] // translation scaffolding: builder methods feed tests + the landing slice @@ -64,6 +87,8 @@ impl<'a> TidelineRail<'a> { collapsed: false, focused: false, ascii_safe: false, + interactive: true, + compact: false, } } @@ -85,6 +110,19 @@ impl<'a> TidelineRail<'a> { self } + /// Render the rail as a passive state summary. + /// + /// The live landing slice has no registered rail input targets yet, so it + /// deliberately shows no `? help`, settings, or collapse glyph. This + /// preserves the mockup's information architecture without implying that + /// an unimplemented control can be clicked or focused. + #[must_use] + pub fn summary(mut self) -> Self { + self.interactive = false; + self.compact = true; + self + } + fn sym(&self, glyph: &str) -> String { if !self.ascii_safe { return glyph.to_string(); @@ -140,7 +178,7 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_ ChromeInk::MetadataDim }; - if rail.collapsed { + if rail.interactive && rail.collapsed { // Expander spine: `»` at the top, dim focus edge down the column. rput( buf, @@ -153,21 +191,23 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_ } let width = area.width as usize; + let controls_height = if rail.interactive { 2 } else { 0 }; + let content_bottom = area.y + area.height.saturating_sub(controls_height); let mut y = area.y; for group in rail.groups { - if y >= area.y + area.height.saturating_sub(2) { + if y >= content_bottom { break; } rput( buf, area.x, y, - &rtruncate(group.label, width), + &rtruncate(group.label.as_ref(), width), rchrome(theme, ChromeInk::MetadataDim).add_modifier(Modifier::BOLD), ); y += 1; for (line, ink) in &group.lines { - if y >= area.y + area.height.saturating_sub(2) { + if y >= content_bottom { break; } rput( @@ -179,11 +219,13 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_ ); y += 1; } - y += 1; + if !rail.compact { + y += 1; + } } // Meta rows then the collapse toggle. - if area.height >= 3 { + if rail.interactive && area.height >= 3 { // One row above the collapse toggle. let bottom = area.y + area.height - 2; rput( @@ -194,7 +236,7 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_ rchrome(theme, ChromeInk::MetadataHint), ); } - if area.height >= 2 { + if rail.interactive && area.height >= 2 { rput( buf, area.x, @@ -205,6 +247,222 @@ pub fn render_tideline_rail(area: Rect, buf: &mut Buffer, rail: &TidelineRail<'_ } } +/// Return the Tideline reservation for a real active session. +/// +/// `work_surface` remains the owner of placement and detailed work rows. The +/// default Left placement gets this summary only when its legacy side surface +/// has nothing to render; an occupied legacy rail is never overlaid or +/// duplicated. Top also receives the summary, while explicit Right and Off +/// keep their existing behavior. +#[must_use] +pub(crate) fn active_session_tideline_rail_width( + app: &App, + chat_area: Rect, + legacy_side_rail_visible: bool, +) -> u16 { + if app.launch.visible + || app.current_session_id.is_none() + || legacy_side_rail_visible + || !matches!( + app.work_surface.placement, + WorkSurfacePlacement::Top | WorkSurfacePlacement::Left + ) + || chat_area.height < LIVE_TIDELINE_MIN_HEIGHT + { + return 0; + } + tideline_rail_width(chat_area.width) +} + +/// Paint the non-interactive active-session Tideline summary and its visual +/// divider. The rail owns no hitboxes until its controls have keyboard and +/// pointer parity; all facts are read from existing App projections. +pub(crate) fn render_active_session_tideline_rail(area: Rect, buf: &mut Buffer, app: &App) { + if area.width < 2 || area.height < LIVE_TIDELINE_MIN_HEIGHT { + return; + } + + let theme = &app.ui_theme; + let blank = " ".repeat(usize::from(area.width)); + for y in area.y..area.y.saturating_add(area.height) { + rput( + buf, + area.x, + y, + &blank, + Style::default().bg(theme.surface_bg), + ); + } + + let content_area = Rect { + width: area.width.saturating_sub(1), + ..area + }; + let groups = active_session_tideline_rail_groups(app); + let rail = TidelineRail::new(theme, &groups) + .summary() + .ascii_safe(crate::tui::color_compat::ascii_safe_enabled()); + render_tideline_rail(content_area, buf, &rail); + + let divider = rail.sym("│"); + let divider_x = area.x.saturating_add(area.width.saturating_sub(1)); + for y in area.y..area.y.saturating_add(area.height) { + rput( + buf, + divider_x, + y, + ÷r, + rchrome(theme, ChromeInk::MetadataDim).bg(theme.surface_bg), + ); + } +} + +/// Derive the five groups from the existing App state. This is intentionally +/// a read-only projection: no independent Tideline store, catalog, or async +/// refresh path is introduced here. +#[must_use] +pub(crate) fn active_session_tideline_rail_groups(app: &App) -> Vec { + let live_work = live_work_from_app(app); + let running_whales = live_work.count(PendingItemKind::Agent); + // A progress event may precede the cache snapshot. The denominator is the + // deduped union of both authoritative snapshots, rather than `max`ing + // their counts and silently losing a known member. + let pod_total = known_pod_member_count(app); + let whale_capacity = app.max_subagents.max(1); + + let run_label = if app.is_loading || app.dispatch_in_flight { + tr(app.ui_locale, MessageId::AutomationRunStatusRunning).into_owned() + } else if let Some(panel) = app + .workflow_panel + .as_ref() + .filter(|panel| panel.lifecycle.is_running()) + { + panel.top_bar_chip() + } else { + live_work_status_line(app.ui_locale, &live_work) + }; + let whales = format!("{running_whales}/{whale_capacity}"); + let pod_label = format!("{running_whales}/{pod_total}"); + let work_line = active_session_work_line(app, &live_work); + + localized_tideline_rail_groups( + app.ui_locale, + &run_label, + &whales, + &pod_label, + &[work_line.as_str()], + crate::tui::phase_strip::context_percent_from_app(app), + ) +} + +fn known_pod_member_count(app: &App) -> usize { + let mut ids: HashSet<&str> = app + .subagent_cache + .iter() + .map(|agent| agent.agent_id.as_str()) + .collect(); + ids.extend(app.agent_progress.keys().map(String::as_str)); + ids.len() +} + +fn has_foreground_activity(app: &App) -> bool { + app.is_loading + || app.dispatch_in_flight + || app + .workflow_panel + .as_ref() + .is_some_and(|panel| panel.lifecycle.is_running()) +} + +fn live_work_status_line(locale: Locale, work: &PendingWork) -> String { + let queued = work.count_state(PendingItemState::Queued); + let running = work.count_state(PendingItemState::Running); + let mut states = Vec::new(); + if queued > 0 { + states.push( + tr(locale, MessageId::AgentRailQueuedCount).replace("{count}", &queued.to_string()), + ); + } + if running > 0 { + states.push( + tr(locale, MessageId::TidelineRunningCount).replace("{count}", &running.to_string()), + ); + } + if states.is_empty() { + tr(locale, MessageId::PhaseIdle).into_owned() + } else { + states.join(" · ") + } +} + +fn active_session_work_line(app: &App, work: &PendingWork) -> String { + let has_activity = !work.is_empty() || has_foreground_activity(app); + let work_status = if work.is_empty() && has_foreground_activity(app) { + tr(app.ui_locale, MessageId::AutomationRunStatusRunning).into_owned() + } else { + live_work_status_line(app.ui_locale, work) + }; + let Ok(todos) = app.todos.try_lock() else { + return if has_activity { + work_status + } else { + "?".to_string() + }; + }; + let snapshot = todos.snapshot(); + if !snapshot.items.is_empty() { + let total = snapshot.items.len(); + let open = snapshot + .items + .iter() + .filter(|item| !item.status.is_settled()) + .count(); + let checklist = format!("{open}/{total}"); + return if has_activity { + format!("{work_status} · {checklist}") + } else { + checklist + }; + } + + if has_activity { + work_status + } else { + tr(app.ui_locale, MessageId::PhaseIdle).into_owned() + } +} + +fn localized_tideline_rail_groups( + locale: Locale, + run_label: &str, + whales: &str, + pod_label: &str, + work_lines: &[&str], + context_percent: u8, +) -> Vec { + tideline_rail_groups_with_labels( + [ + tideline_heading(locale, MessageId::TidelineRuns), + tideline_heading(locale, MessageId::TidelineWhales), + tideline_heading(locale, MessageId::ConfigCategoryPod), + tideline_heading(locale, MessageId::ConfigCategoryWork), + tideline_heading(locale, MessageId::CtxInspContext), + ], + run_label, + whales, + pod_label, + work_lines, + context_percent, + ) +} + +/// Rail group names use an all-caps display treatment in the approved +/// Tideline chrome. Translation owns the words; this helper owns only that +/// shared visual treatment. +fn tideline_heading(locale: Locale, message_id: MessageId) -> Cow<'static, str> { + Cow::Owned(tr(locale, message_id).to_uppercase()) +} + /// The five-group fixture projection used by goldens and the preview pane: /// RUNS / WHALES / POD / WORK / CONTEXT in display order. #[must_use] @@ -215,6 +473,30 @@ pub fn tideline_rail_groups( pod_label: &str, work_lines: &[&str], context_percent: u8, +) -> Vec { + tideline_rail_groups_with_labels( + [ + Cow::Borrowed("RUNS"), + Cow::Borrowed("WHALES"), + Cow::Borrowed("POD"), + Cow::Borrowed("WORK"), + Cow::Borrowed("CONTEXT"), + ], + run_label, + whales, + pod_label, + work_lines, + context_percent, + ) +} + +fn tideline_rail_groups_with_labels( + [runs, whales_label, pod, work, context]: [Cow<'static, str>; 5], + run_label: &str, + whales: &str, + pod_label: &str, + work_lines: &[&str], + context_percent: u8, ) -> Vec { let meter_cells = 5usize; let filled = (usize::from(context_percent) * meter_cells / 100).min(meter_cells); @@ -223,26 +505,26 @@ pub fn tideline_rail_groups( .collect(); vec![ TidelineRailGroup { - label: "RUNS", + label: runs, lines: vec![(run_label.to_string(), ChromeInk::Identity)], }, TidelineRailGroup { - label: "WHALES", + label: whales_label, lines: vec![(whales.to_string(), ChromeInk::Info)], }, TidelineRailGroup { - label: "POD", + label: pod, lines: vec![(pod_label.to_string(), ChromeInk::Active)], }, TidelineRailGroup { - label: "WORK", + label: work, lines: work_lines .iter() .map(|line| (line.to_string(), ChromeInk::MetadataValue)) .collect(), }, TidelineRailGroup { - label: "CONTEXT", + label: context, lines: vec![( format!("{meter} {context_percent}%"), if context_percent >= 80 { @@ -296,6 +578,9 @@ pub fn render_tideline_work_stage(area: Rect, buf: &mut Buffer, stage: &Tideline #[allow(dead_code)] // translation scaffolding: wired by the landing slice pub fn tideline_rail_hitboxes(area: Rect, rail: &TidelineRail<'_>) -> Vec { let mut out = Vec::new(); + if !rail.interactive { + return out; + } if area.width < 2 || area.height < 2 || rail.collapsed { out.push(Rect { x: area.x, diff --git a/crates/tui/src/tui/work_surface/tideline/tests.rs b/crates/tui/src/tui/work_surface/tideline/tests.rs index fef8a806a0..0195b48f63 100644 --- a/crates/tui/src/tui/work_surface/tideline/tests.rs +++ b/crates/tui/src/tui/work_surface/tideline/tests.rs @@ -10,10 +10,11 @@ use ratatui::layout::Rect; use unicode_width::UnicodeWidthChar; use super::{ - TidelineRail, TidelineWorkStage, render_tideline_rail, render_tideline_work_stage, - tideline_rail_groups, tideline_rail_hitboxes, tideline_rail_width, + TidelineRail, TidelineWorkStage, active_session_tideline_rail_groups, render_tideline_rail, + render_tideline_work_stage, tideline_rail_groups, tideline_rail_hitboxes, tideline_rail_width, }; use crate::palette::UI_THEME; +use crate::tui::app::{TaskPanelEntry, TaskPanelEntryKind}; use crate::tui::golden_harness::{BLOCKER_SIZES, assert_matches_golden, render_golden_text}; use crate::tui::history::tideline_exports::{ TidelineReceiptState, TidelineStream, TidelineStreamEvent, render_tideline_stream, @@ -70,6 +71,171 @@ fn groups() -> Vec { ) } +fn active_rail_app() -> crate::tui::app::App { + let options = crate::test_support::test_tui_options(std::path::PathBuf::from(".")); + let mut app = crate::test_support::test_app_with_options(options); + app.launch.visible = false; + app.current_session_id = Some("tideline-live-rail-facts".to_string()); + app.ui_locale = crate::localization::Locale::En; + app +} + +fn pending_task(id: &str, status: &str) -> TaskPanelEntry { + TaskPanelEntry { + id: id.to_string(), + status: status.to_string(), + prompt_summary: "durable work".to_string(), + duration_ms: None, + kind: TaskPanelEntryKind::Background, + stale: false, + elapsed_since_output_ms: None, + owner_agent_id: None, + owner_agent_name: None, + current_tool: None, + role: None, + files_touched: 0, + } +} + +fn rail_fact<'a>(groups: &'a [super::TidelineRailGroup], label: &str) -> &'a str { + groups + .iter() + .find(|group| group.label == label) + .and_then(|group| group.lines.first()) + .map(|(line, _)| line.as_str()) + .unwrap_or_else(|| panic!("missing {label} fact")) +} + +fn subagent( + id: &str, + status: crate::tools::subagent::SubAgentStatus, +) -> crate::tools::subagent::SubAgentResult { + crate::tools::subagent::SubAgentResult { + name: id.to_string(), + agent_id: id.to_string(), + context_mode: "fresh".to_string(), + fork_context: false, + workspace: None, + git_branch: None, + agent_type: crate::tools::subagent::FleetRole::Worker, + assignment: crate::tools::subagent::SubAgentAssignment { + objective: format!("objective-{id}"), + role: Some("worker".to_string()), + }, + model: "deepseek-v4-flash".to_string(), + nickname: None, + status, + worker_status: None, + runtime_permissions: None, + parent_run_id: None, + spawn_depth: 0, + child_route: None, + result: None, + steps_taken: 0, + checkpoint: None, + needs_input: None, + duration_ms: 0, + started_at: None, + from_prior_session: false, + } +} + +#[test] +fn live_rail_reports_pending_durable_tasks_as_a_run() { + let mut app = active_rail_app(); + app.task_panel.push(pending_task("durable-1", "queued")); + + let groups = active_session_tideline_rail_groups(&app); + assert_eq!(rail_fact(&groups, "RUNS"), "1 queued"); + assert_eq!(rail_fact(&groups, "WORK"), "1 queued"); +} + +#[test] +fn live_rail_keeps_pending_work_visible_alongside_a_completed_checklist() { + let mut app = active_rail_app(); + app.todos.try_lock().expect("todos lock").add( + "already done".to_string(), + crate::tools::todo::TodoStatus::Completed, + ); + app.task_panel.push(pending_task("durable-1", "running")); + + let groups = active_session_tideline_rail_groups(&app); + assert_eq!(rail_fact(&groups, "RUNS"), "1 running"); + assert_eq!(rail_fact(&groups, "WORK"), "1 running · 0/1"); +} + +#[test] +fn live_rail_dedupes_cached_and_progress_only_pod_members() { + let mut app = active_rail_app(); + app.subagent_cache = vec![ + subagent( + "agent-running", + crate::tools::subagent::SubAgentStatus::Running, + ), + subagent( + "agent-completed", + crate::tools::subagent::SubAgentStatus::Completed, + ), + ]; + app.agent_progress + .insert("agent-running".to_string(), "working".to_string()); + app.agent_progress + .insert("agent-progress-only".to_string(), "planning".to_string()); + + let groups = active_session_tideline_rail_groups(&app); + assert_eq!(rail_fact(&groups, "RUNS"), "2 running"); + assert_eq!(rail_fact(&groups, "POD"), "2/3"); + assert_eq!( + rail_fact(&groups, "WHALES"), + format!("2/{}", app.max_subagents.max(1)) + ); +} + +#[test] +fn live_rail_keeps_runs_and_work_consistent_for_a_foreground_turn() { + let mut app = active_rail_app(); + app.is_loading = true; + + let groups = active_session_tideline_rail_groups(&app); + assert_eq!(rail_fact(&groups, "RUNS"), "running"); + assert_eq!(rail_fact(&groups, "WORK"), "running"); +} + +#[test] +fn live_rail_counts_a_running_shell_without_calling_it_idle() { + let mut app = active_rail_app(); + app.task_panel.push(TaskPanelEntry { + id: "shell_a1b2c3d4".to_string(), + status: "running".to_string(), + prompt_summary: "shell: cargo test -p codewhale-tui".to_string(), + duration_ms: Some(42_000), + kind: TaskPanelEntryKind::Background, + stale: false, + elapsed_since_output_ms: None, + owner_agent_id: None, + owner_agent_name: None, + current_tool: None, + role: None, + files_touched: 0, + }); + + let groups = active_session_tideline_rail_groups(&app); + assert_eq!(rail_fact(&groups, "RUNS"), "1 running"); + assert_eq!(rail_fact(&groups, "WORK"), "1 running"); +} + +#[test] +fn live_rail_uses_the_selected_locale_for_its_group_labels() { + let mut app = active_rail_app(); + app.ui_locale = crate::localization::Locale::Ja; + + let labels = active_session_tideline_rail_groups(&app) + .into_iter() + .map(|group| group.label.into_owned()) + .collect::>(); + assert_eq!(labels, ["実行", "クジラ", "POD", "作業", "コンテキスト"]); +} + /// Render the stage directly (the fixture borrows cannot outlive the fn). fn draw_stage(width: u16, height: u16) -> String { render_golden_text(width, height, |buf| { @@ -121,6 +287,26 @@ fn rail_shows_five_groups_help_and_collapse() { assert!(text.contains("« collapse"), "{text}"); } +#[test] +fn passive_summary_shows_five_groups_without_inert_controls_or_hitboxes() { + let groups = groups(); + let rail = TidelineRail::new(&UI_THEME, &groups).summary(); + let area = Rect::new(0, 0, 16, 10); + let text = render_golden_text(area.width, area.height, |buf| { + render_tideline_rail(area, buf, &rail); + }); + + for label in ["RUNS", "WHALES", "POD", "WORK", "CONTEXT"] { + assert!(text.contains(label), "missing {label}: {text}"); + } + assert!(!text.contains("? help"), "{text}"); + assert!(!text.contains("« collapse"), "{text}"); + assert!( + tideline_rail_hitboxes(area, &rail).is_empty(), + "passive visual state must not advertise a clickable target" + ); +} + #[test] fn rail_collapsed_leaves_only_the_expander() { let groups = groups(); diff --git a/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs b/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs index 288f76140b..7c7812cc06 100644 --- a/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs +++ b/crates/tui/tests/cucumber/plugin_e2e_acceptance.rs @@ -795,6 +795,29 @@ fn wait_for_composer_ready(tui: &mut Harness) { } } +/// This acceptance starts as a fresh interactive launch. Select the real +/// Startup "New session" action before testing commands that belong to a +/// live conversation; a focused pre-session composer is not itself a session. +#[cfg(all(unix, feature = "long-running-tests"))] +fn begin_new_session_from_startup(tui: &mut Harness) { + expect_visible(tui, "What are we working on?", "show Tideline Startup"); + tui.send(keys::key::ch('w')) + .expect("choose Startup New session"); + if tui + .wait_for( + |frame| !frame.text().contains("What are we working on?"), + BINARY_ACCEPTANCE_TIMEOUT, + ) + .is_err() + { + panic!( + "Startup New session did not enter the live shell within {:?}\n{}", + qa_harness::harness::ci_scaled(BINARY_ACCEPTANCE_TIMEOUT), + short_diagnostics(tui, None) + ); + } +} + #[cfg(all(unix, feature = "long-running-tests"))] fn wait_for_log(tui: &mut Harness, path: &std::path::Path, needle: &str) { let budget = qa_harness::harness::ci_scaled(BINARY_ACCEPTANCE_TIMEOUT); @@ -851,6 +874,9 @@ async fn plugin_toml_binary_lifecycle_skill_and_stdio_mcp_acceptance() { .expect("start distributed TUI binary"); // Readiness is a painted, focused composer—not localized placeholder copy. + // The binary begins at Tideline Startup, so choose its real New Session + // action before exercising the existing-session plugin contract. + begin_new_session_from_startup(&mut tui); wait_for_composer_ready(&mut tui); submit_tui_command(&mut tui, "/plugin show demo"); expect_visible( diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 1e10ba1d9e..6fab750791 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -1684,11 +1684,11 @@ Common settings keys: context panel, `/cost`, `/tokens`, and long-turn notification summaries. The aliases `rmb` and `yuan` normalize to `cny`. - `default_mode` (`agent`, `plan`, or `operate`; legacy values are accepted for migration but are not live mode vocabulary) -- `launch_screen` (`on`/`off`; default `off`): show the pre-session Work/Chat/ - Resume/Worktree menu. Work uses the current folder under the configured - approval policy; Chat starts a read-only conversation. With the launch - screen off, Codewhale enters a new session directly; resume remains - available in-session. +- `launch_screen` (legacy, migration-only): this historical `on`/`off` value + is still accepted when reading an existing settings file, but it no longer + changes behavior and is omitted from new saves. A fresh interactive launch + always opens Tideline Startup; only an explicit resume or an explicit + initial prompt enters the live session directly. - `sidebar_focus` (legacy, migration-only): the classic right sidebar this key configured was removed in the 0.9.4 rail unification. The key is still read once so old settings carry forward, then folds into the live keys: diff --git a/docs/KEYBINDINGS.md b/docs/KEYBINDINGS.md index 044842e004..1c25fa7679 100644 --- a/docs/KEYBINDINGS.md +++ b/docs/KEYBINDINGS.md @@ -12,6 +12,7 @@ Global key chords are not yet user-configurable — tracked for a future release |----------------------|---------------------------------------------------------------| | `F1` or `Ctrl-/` | Toggle the help overlay | | `F2` | Toggle the typed Settings editor | +| `F3` | Open the provider/model picker (same as `/provider`) | | `Ctrl-K` | Open the command palette (slash-command finder) | | `Ctrl-C` | Cancel current turn / dismiss modal / arm-then-confirm quit | | `Ctrl-B` | Move a supported foreground shell wait into `/jobs` so the turn can continue; use `/jobs` or `Bash` with `action: "wait"` to inspect it | diff --git a/docs/design/TIDELINE_RATATUI_TRANSLATION.md b/docs/design/TIDELINE_RATATUI_TRANSLATION.md index d41465a995..3bc4202ad1 100644 --- a/docs/design/TIDELINE_RATATUI_TRANSLATION.md +++ b/docs/design/TIDELINE_RATATUI_TRANSLATION.md @@ -17,11 +17,12 @@ prose > the recovered motion sketch (motion language) > `tideline-redesign.html` Cell-inventory read of the references (startup, work+pod, settings/appearance): -- **Topbar (all three).** One row: fluke + `CODEWHALE` wordmark; contextual +- **Topbar (all three).** One row: `CODEWHALE` wordmark; contextual segments (`run …`, `pod …`, `3/4 whales`, `model …`, `theme …`, `Settings / Appearance`, `folder …`); pinned right = `context NN% ▰▰▱▱▱` + full clock. Segment set varies per screen; brand/meter/clock never move. -- **Startup.** Centered hero: fluke mark, "What are we working on?", one dim +- **Startup.** Centered hero: the current diving-whale mark (whose wave forms + a `>` prompt), "What are we working on?", one dim subtitle; `QUICK ACTIONS` band with 3 rows (icon · label · description · command + `›`); a 4-column option strip (New worktree / Chat only / Theme / Help); whale-outline composer; footer with route · cost · keys. @@ -41,7 +42,7 @@ Cell-inventory read of the references (startup, work+pod, settings/appearance): | In the reference | Why it cannot ship | Decision | |---|---|---| -| Composer drawn as a stroked whale outline | No bezier strokes; only box-drawing glyphs | **Rounded border + fluke cap.** `╭─╮│╰╯` border (dim at rest, Info on focus), fluke glyph (`▚△▞`, ASCII `<.>`) set into the top-right corner cell as the cap. The hull taper silhouette is **dropped** — it is sub-cell vector work, and the fluke cap carries the identity at 1/50th the cells. The send `↑` becomes a 3-cell hitbox `[↑]` right-aligned inside the border. | +| Composer drawn as a stroked whale outline | No bezier strokes; only box-drawing glyphs | **Rounded border.** `╭─╮│╰╯` border (dim at rest, Info on focus); the old fluke cap is retired and no brand glyph is hand-drawn into the composer. The send `↑` becomes a 3-cell hitbox `[↑]` right-aligned inside the border. The approved mark appears only where its full generated terminal projection fits. | | Translucent whale silhouettes behind text (Deepsea preview) | No alpha; painting over text destroys it | **Empty-cells-only compositing**, the `ambient_life.rs` rule verbatim: write only cells that are open water (`is_open_water` + `TEXT_CLEARANCE_ROWS = 1` clearance from `occupied_text_bounds`). Eviction order when water is scarce: bubbles first, then fish school, then jellyfish; the whale cameo is evicted last (highest identity value). Caustic-style tinting stays bg-only on `cell.symbol() == " "`. Deepsea ambient runs only under `MotionMode::Full`. | | SVG icons (plug, clock, folder, palette, chat) | Not renderable | One glyph per action, added to `glyphs.rs` with declared ASCII fallbacks via `ascii_fallback`: plug `⌁`→`+`, resume `↺`→`<`, folder `▤`→`=`, palette `◐`→`*`, chat `◌`→`o`, worktree `⑂`→`y`, help `?`→`?` (identity). Each is 1 cell, no wide glyphs. | | Ledger cells wrapping to two lines | Table columns are exact integer cells | Fixed column widths + per-column truncation: WHALE 10 (never truncates — names are short by contract), ASSIGNMENT = remainder (truncate with `…`, never wrap), STATE 12 (glyph + word), ELAPSED 8, RECEIPTS 8, LAST UPDATE 8 (`HH:MM:SS`). **At 80 columns** the rail is hidden and ledger sheds to `WHALE │ ASSIGNMENT │ STATE` — ELAPSED, RECEIPTS, LAST UPDATE drop in that order before ASSIGNMENT loses cells. | @@ -65,7 +66,7 @@ constraints ~:928). The references collapse the bottom into one footer: | 4 background-work chip | **Deleted as a band**; the fact moves to the topbar `pod n/m` segment and the rail WORK group (one surface owns each fact). | | 5 session boot receipt | **Deleted as a band**; boot lines become ordinary transcript receipts. | | 6 activity band | **Merged into the footer** (left half: phase chip + echolocation + cost). | -| 7 composer | **Extends** — rounded border + fluke cap + `[↑]` hitbox; composer authority logic untouched. | +| 7 composer | **Extends** — rounded border + `[↑]` hitbox; composer authority logic untouched. | | 8 identity band | **Merged into the footer** (right half: depth line + key legend). `phase_strip::render_identity` is the merge target; `render_footer` delegates today already. | Orphaned facts, each with exactly one home: cost/token ledger → footer; @@ -109,7 +110,7 @@ where the `Rect` is stored for `mouse_ui` (existing pattern: | Component | What it does | States | Data source | Replaces | Owning file | Keys | Mouse hitbox | Golden name | |---|---|---|---|---|---|---|---|---| | Topbar | One-row status surface | per-screen segment set; hover; shed | `effective_route_identity_display()`, run/pod summaries, `context_budget` pct, injected clock | `underwater::render_header` | `tui/topbar.rs` ✅ | Tab⇄, Enter activate | brand/menu + per-segment rects → `viewport.last_topbar_hitboxes` | `topbar_{startup,work,settings}_{w}x{h}` ✅ | -| Hero (startup) | Centered fluke + prompt | first-run vs returning | `LaunchState`, `workspace_session_count` | `render_launch_screen` | `tui/underwater.rs` | — | fluke = open menu (`launch.row_areas`) | `startup_{w}x{h}` | +| Hero (startup) | Centered current mark + prompt | first-run vs returning | `LaunchState`, `workspace_session_count` | `render_launch_screen` | `tui/underwater.rs` | — | decorative identity; no action until keyboard parity exists | `startup_{w}x{h}` | | Quick actions | 3 command rows | selected/hover/disabled (no model) | `LaunchAction`, provider state | launch menu rows | `tui/underwater.rs` + `mouse_ui.rs:441` | ↑/↓, Enter, Esc | row rects (exists) | `startup_*` | | Option strip | 4 columns (worktree/chat/theme/help) | hover/selected | `LaunchState` | launch options row | same | Tab, Enter | 4 col rects | `startup_*` | | Rail | Left column, 5 groups + collapse | expanded/collapsed/focused | `WorkSurfaceState`, `subagent_cache`, run list, git status | work strip + `sidebar` remnants | `tui/work_surface/` (#5699 territory) | Tab, ↑/↓, Enter, `«` | `WorkHitbox{WorkRowId,row_y}` (exists) | `work_{w}x{h}` | @@ -118,7 +119,7 @@ where the `Rect` is stored for `mouse_ui` (existing pattern: | Theme list | 13 themes + motion toggles | selected/preview/applying | `ThemeId`, `ocean_treatment`, `low_motion`, `fancy_animations` | `theme_picker.rs` | `tui/theme_picker.rs`, `views/` | ↑/↓, Enter preview/apply | row rects | `settings_{w}x{h}` | | Live preview | Projection of a real screen in chosen theme | mirrors screen state; never a second store | same render fns, `TestBackend`-style projection into the pane | settings preview | `tui/views/` settings | — | none (passive) | `settings_*` | | Settings rail | 8 categories + meta rows | selected | `ConfigView` | `ConfigView` nav | `tui/views/mod.rs` | ↑/↓, Tab | category rects | `settings_*` | -| Composer | Input with fluke cap + send hitbox | focus, pending crumb, approval-replaced | `ComposerState`, pending preview | composer_ui/chrome (extends) | existing composer files | Enter, ⇧Enter, Esc | `[↑]` submit rect; border focus click | `composer_{w}x{h}` | +| Composer | Input with rounded border + send hitbox | focus, pending crumb, approval-replaced | `ComposerState`, pending preview | composer_ui/chrome (extends) | existing composer files | Enter, ⇧Enter, Esc | `[↑]` submit rect; border focus click | `composer_{w}x{h}` | | Footer | One band: phase·cost (left), depth line·keys (right) | per-phase ink; 80% warn | `SessionState` cost, phase, `context_budget` | slots 6+8 merged | `tui/phase_strip.rs` | — | depth segment → context inspector | `footer_{w}x{h}` | | Notifications inbox | Attention rows (gold ◆) | unread/read; per-kind | `status_toasts`/`sticky_status` → typed records | toast soup | `tui/notifications.rs` | Enter, `r`, Esc | row rects | `notifications_{w}x{h}` | @@ -141,7 +142,7 @@ Startup stage: ```rust let [hero, rule_a, quick, rule_b, strip, spacer] = Layout::vertical([ - Constraint::Percentage(38), // hero: fluke + heading + subtitle + Constraint::Percentage(38), // hero: current mark + heading + subtitle Constraint::Length(1), // wave rule `⋯ ∼∼∼ ⋯` (dim, static) Constraint::Length(3 + 2), // QUICK ACTIONS: label row + 3 rows + margins Constraint::Length(1), // wave rule @@ -220,7 +221,7 @@ families obey `STATUS_BAR_COLOR_GRAMMAR.md`: | Element | ChromeInk | Family | |---|---|---| -| Fluke + wordmark | `Attention` (gold) | Cognition — the sanctioned whale-mark gold | +| Current mark + wordmark | `Attention` (gold) | Cognition — the sanctioned whale-mark gold | | Segment labels / separators / clock | `Metadata` / `MetadataDim` / `MetadataHint` | Metadata | | Route · model · run | `Identity` | Identity | | Pod (live) / `3/4` / context meter / theme name | `Active` / `Info` | Active / Identity | @@ -281,8 +282,8 @@ authored, everything else staying calm: **spout** — the one payoff moment; suppressed if work resumes instantly. *Failure:* flat `✗` + the model segment says what to do next (microcopy: "no route yet — /connect"), never "Error". *Exit into work:* composer - keeps focus — no modal re-orientation. Surprise budget: spout, the fluke - cap waking on composer focus, hero breath. + keeps focus — no modal re-orientation. Surprise budget: spout and hero + breath; composer focus changes only its border and send affordance. - **Work + Pod.** *Orient:* the pod-formation tree draws its `├──└──` rows in one ≤600 ms top-down reveal, then is forever still (continuity: the tree is the same object in the ledger below). *Waiting:* receipt rows carry typed diff --git a/docs/design/WHALE_TEAMS_TUI.md b/docs/design/WHALE_TEAMS_TUI.md index 8f17116582..f958f2e2a4 100644 --- a/docs/design/WHALE_TEAMS_TUI.md +++ b/docs/design/WHALE_TEAMS_TUI.md @@ -5,6 +5,12 @@ gives every agent role a species-led whale and every whale one of six runtime states. This document is the contract for how that identity appears in the Codewhale terminal UI. The implementation is `crates/tui/src/tui/whales.rs`. +> **Status — runtime badges current; portrait reference retired.** The current +> Codewhale product mark is the generated diving whale whose wave forms a +> `>` prompt, sourced from `designs/brand/20260829-codewhale-mark/`. The old +> hand-drawn crown/fluke treatment described in the historical portrait record +> below is not a current product mark and does not render in `underwater.rs`. + The source artwork (six species × six states × four colorways of 384×192 rasters, plus concept boards and the visual brief) lives in the CWC repository. No CWC file is copied here: the terminal whales are authored fresh as glyph @@ -92,29 +98,24 @@ accents only and must never be used for status, mode, or permission. ## Art -The drawing decisions — including the empty-state hero mark in -`underwater.rs`, which shares this glyph vocabulary — live in -[`WHALE_TEAMS_TUI_ART.md`](WHALE_TEAMS_TUI_ART.md). - -Portraits are 3 rows × 14 columns: a two-column state-cue lane on the left, -the head at the left, the crown fluke `▚△▞` on the right (the same vocabulary -as the idle Codewhale mark in `underwater.rs`), and a wake lane under the tail. -Badges are two cells: a species feature glyph in the role accent plus a body -cell. +The current startup mark is intentionally separate from Whale Teams artwork: +it is the approved generated diving-whale terminal projection in +`underwater.rs`. [`WHALE_TEAMS_TUI_ART.md`](WHALE_TEAMS_TUI_ART.md), its text +preview, and its generator are retained as an **archived portrait reference** +for removed artwork; they must not be used as a product-mark source. -Every glyph has a `glyphs::ascii_fallback` entry, and `portrait_ascii` / -`badge_ascii` expose the narrowed silhouettes. With `CODEWHALE_ASCII_SAFE=1` -the seven badges stay distinct: `<#` `#]` `#\` `:#` `#-` `*#` `.#`. +The live runtime uses two-cell badges: a species feature glyph in the role +accent plus a body cell. With `CODEWHALE_ASCII_SAFE=1` the seven badges stay +distinct: `<#` `#]` `#\` `:#` `#-` `*#` `.#`. ## Surfaces -- `/fleet` roster: species badge on every member row; the detail pane opens - with the portrait (≥ 60 columns) and the `badge Name · species · job` line - — no caption labels. +- `/fleet` roster: species badge on every member row, with the + `badge Name · species · job` line — no retired portrait is rendered. - `/fleet` workers (`SubAgentsView`): badge on every worker row, plus a second line with the badge and the state cue and word — nothing else. -- `whales::portrait` / `whales::badge` are public for other surfaces (the - Fleet setup role pane is the intended next consumer). +- `whales::badge` is public for other surfaces (the Fleet setup role pane is + the intended next consumer). Not represented: the deep-current, reef-shift, and night-signal colorways; the legacy WhalePet atlas and Rive companion contracts (which have no artwork in diff --git a/docs/design/WHALE_TEAMS_TUI_ART.md b/docs/design/WHALE_TEAMS_TUI_ART.md index a32343aae2..2b398de728 100644 --- a/docs/design/WHALE_TEAMS_TUI_ART.md +++ b/docs/design/WHALE_TEAMS_TUI_ART.md @@ -1,9 +1,16 @@ -# Whale Teams TUI — art translation +# Whale Teams TUI — archived portrait-art reference -How the Signal Cut mascots become terminal glyph art. The runtime contract -(role → species, state → evidence, inks) is [`WHALE_TEAMS_TUI.md`](WHALE_TEAMS_TUI.md); -this file is the *drawing* decision record for the empty-state hero and the -roster art it must match. +> **Status — retired reference, not current brand authority.** This record +> preserves the removed hand-drawn portrait/crown treatment for historical +> comparison. Do not use its `▚△▞` treatment as a Codewhale mark or infer that +> `underwater.rs` renders it. The current approved terminal mark is the +> generated diving whale from `designs/brand/20260829-codewhale-mark/`, whose +> wave forms a `>` prompt. + +How the former Signal Cut mascots became terminal glyph art. The remaining +live runtime contract (role → species, state → evidence, inks) is +[`WHALE_TEAMS_TUI.md`](WHALE_TEAMS_TUI.md); this file is historical drawing +evidence only, not a specification for the startup hero or roster. Source of truth (CWC repo, read-only from here): `docs/design/whale-teams/VISUAL-BRIEF.md`, the Signal Cut concept boards and @@ -24,11 +31,11 @@ Read off the matrix and the working keyframes: - **Working** adds bounded cyan wake dots *behind* the tail — never a change of pose, expression, or scale. -## (a) The empty-state hero +## (a) Historical empty-state portrait -`crates/tui/src/tui/underwater.rs` — `IDLE_WHALE_SPOUT_ROW` / `IDLE_WHALE_ROWS`. -Four rows, 17 columns (uwu: 16), so it stays quiet inside the 60-column empty -state. +Former `crates/tui/src/tui/underwater.rs` idle-art constants. The current +startup hero no longer uses this artwork; it renders the approved generated +diving-whale mark instead. ``` ˚ diff --git a/docs/design/tideline-redesign.html b/docs/design/tideline-redesign.html index 2d3cfe30af..0fe737724c 100644 --- a/docs/design/tideline-redesign.html +++ b/docs/design/tideline-redesign.html @@ -208,7 +208,7 @@ /* Startup: a quiet first choice, not a blank terminal. */ .startup { min-height: 650px; display: grid; grid-template-rows: 1fr auto; padding: 0 10%; } .startup-center { display: flex; flex-direction: column; align-items: center; justify-content: center; text-align: center; padding: 72px 0 38px; } - .startup-mark { min-height: 50px; color: var(--gold); border: 0; background: transparent; cursor: pointer; font: inherit; font-size: 37px; letter-spacing: .18em; } + .startup-mark { min-width: 10ch; min-height: 6lh; color: var(--gold); font: 700 13px/1 var(--mono); letter-spacing: 0; white-space: pre; } .startup h2 { margin: 20px 0 7px; font-size: 23px; letter-spacing: -.025em; font-weight: 650; } .startup p { margin: 0; color: var(--t-dim); } .quick-actions { border-top: 1px solid var(--t-faint); padding: 13px 0 0; } @@ -276,7 +276,7 @@ .doc-section { margin-top: 70px; }.doc-section h2 { margin: 0 0 7px; font-size: 24px; letter-spacing: -.025em; }.doc-section > p { max-width: 870px; margin: 0; color: var(--page-dim); }.rule-grid { display: grid; grid-template-columns: repeat(3,minmax(0,1fr)); gap: 12px; margin-top: 17px; }.rule { padding: 14px; border: 1px solid var(--card-edge); border-radius: 10px; background: var(--card); }.rule b { color: var(--accent); }.rule p { margin: 6px 0 0; color: var(--page-dim); font-size: 13px; } .spec-table { width: 100%; margin-top: 17px; border-collapse: collapse; font-size: 12px; }.spec-table th, .spec-table td { padding: 9px 10px; border-bottom: 1px solid var(--card-edge); text-align: left; vertical-align: top; }.spec-table th { color: var(--page-dim); font-size: 10px; letter-spacing: .08em; text-transform: uppercase; }.spec-table code { color: var(--accent); font-size: 11px; }.status-existing { color: var(--ok); }.status-proposed { color: var(--gold); }.status-guard { color: var(--warn); } .manifest-note { margin-top: 13px; padding: 12px 14px; border-left: 2px solid var(--accent2); background: color-mix(in srgb, var(--accent) 5%, var(--card)); color: var(--page-dim); font-size: 13px; }.manifest-note b { color: var(--page-fg); } - .glyph-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 10px; margin-top: 17px; }.glyph-card { padding: 12px; border: 1px solid var(--card-edge); background: var(--card); }.glyph-sample { color: var(--accent2); font: 700 20px/1 var(--mono); }.glyph-card:nth-child(2) .glyph-sample { color: var(--gold); }.glyph-card:nth-child(3) .glyph-sample { color: var(--accent); }.glyph-card:nth-child(4) .glyph-sample { color: var(--reason); }.glyph-card:nth-child(5) .glyph-sample { color: var(--ok); }.glyph-card b { display: block; margin-top: 8px; }.glyph-card p { margin: 3px 0 0; color: var(--page-dim); font-size: 12px; } + .glyph-grid { display: grid; grid-template-columns: repeat(5,minmax(0,1fr)); gap: 10px; margin-top: 17px; }.glyph-card { padding: 12px; border: 1px solid var(--card-edge); background: var(--card); }.glyph-sample { color: var(--accent2); font: 700 20px/1 var(--mono); }.glyph-sample.terminal-mark { font-size: 11px; line-height: 1; white-space: pre; }.glyph-card:nth-child(2) .glyph-sample { color: var(--gold); }.glyph-card:nth-child(3) .glyph-sample { color: var(--accent); }.glyph-card:nth-child(4) .glyph-sample { color: var(--reason); }.glyph-card:nth-child(5) .glyph-sample { color: var(--ok); }.glyph-card b { display: block; margin-top: 8px; }.glyph-card p { margin: 3px 0 0; color: var(--page-dim); font-size: 12px; } .acceptance { margin-top: 15px; columns: 2; column-gap: 32px; }.acceptance label { display: block; break-inside: avoid; margin: 0 0 9px; color: var(--page-dim); font-size: 13px; }.acceptance input { margin-right: 8px; accent-color: var(--accent); } .hidden { display: none !important; } @@ -332,7 +332,7 @@

A normal coding workbench.
One unmistakable whale.

- + @@ -375,7 +375,7 @@

Context

Make release 0.9.12 ready.
- +
CODEWHALE
I’ll prepare release 0.9.12. Spawning a pod to run checks in parallel.
Plan: fix CI, audit contributor ancestry, draft release notes.
@@ -420,7 +420,12 @@

Context

- +

What are we working on?

Folder and tool access are asked when needed.

@@ -537,7 +542,7 @@

Sound

What makes it Codewhale

-

It should feel as legible as Codex, Claude Code, or a sober native workbench before it feels branded. The brand arrives through timing, language, a small current of cyan, and the fluke at moments of authorship or return.

+

It should feel as legible as Codex, Claude Code, or a sober native workbench before it feels branded. The brand arrives through timing, language, a small current of cyan, and the approved diving-whale mark at moments of authorship or return.

Host first

Match Terminal, Dark, and Light use Color::Reset for the background. Their foreground palette adapts; the user’s terminal remains the surface.

Conversation first

The transcript is the default lens. The rail and ledger can fold; receipts are quiet until a person asks for evidence.

@@ -550,9 +555,14 @@

What makes it Codewhale

Small whale grammar

-

The fluke is the new mark. It appears at the wordmark, startup, a Codewhale turn, and a settled resurface—not in every button or table row. The rest of the glyphs are working notation. All retain an ASCII fallback in the terminal projection.

+

The current diving-whale mark, whose wave forms a > prompt, appears at the startup hero and other sparse identity moments—not in every button or table row. The rest of the glyphs are working notation. All retain an ASCII fallback in the terminal projection.

-
▚△▞
Fluke

Identity and a successful resurface. Source is an approved asset; the terminal gets a cell projection.

+
▄▄▄▄██▌ +▜████▀▘ + ▟██▄▄ + ▟█████▖ + ▐█▟▀█████ + ▐█▜██████
Current mark

Identity at startup and other sparse moments. This is the approved generated terminal projection, not a hand-made substitute.

Pod formation

Parallel work beginning. Never a generic button icon.

Wake / live work

Active receipt or a running worker. Static in low-motion and ANSI.

Spout / attention

A real user decision is needed. Gold and words; not an error.

@@ -580,7 +590,7 @@

Ratatui wiring manifest

Promptcrates/tui/src/prompts/text.rsBASE_PROMPT untouchedout of scope -
CWC parity: the web app shares the information architecture, terms, state grammar, typed notification facts, provider truth, and fluke asset manifest. It does not imitate a terminal. Web controls render as normal app controls; the TUI renders the same contracts as cells.
+
CWC parity: the web app shares the information architecture, terms, state grammar, typed notification facts, provider truth, and current-mark asset manifest. It does not imitate a terminal. Web controls render as normal app controls; the TUI renders the same contracts as cells.
@@ -596,7 +606,7 @@

Visual acceptance before “matches”

- +
@@ -611,8 +621,8 @@

Visual acceptance before “matches”

"deepsea": "explicit OceanTreatment::Deepsea surface" }, "targets": { - "brand.primary": {"title":"Primary fluke mark","owner":"glyphs.rs + underwater.rs","state":"BrandMark::Primary","action":"open product menu","hitbox":"header brand Rect","notes":"Approved asset manifest; terminal uses cell projection."}, - "brand.startup": {"title":"Startup fluke","owner":"underwater.rs + onboarding/mod.rs","state":"OnboardingState","action":"open identity/help","hitbox":"startup mark Rect","notes":"One of the sparse identity placements."}, + "brand.primary": {"title":"Primary Codewhale wordmark","owner":"glyphs.rs + underwater.rs","state":"BrandMark::Primary","action":"open product menu","hitbox":"header brand Rect","notes":"One-row header preserves readable wordmark; the full approved mark belongs where its cell projection fits."}, + "brand.startup": {"title":"Startup diving-whale mark","owner":"underwater.rs","state":"OnboardingState","action":"decorative identity","hitbox":"none","notes":"The approved generated 10x6 terminal projection, used at a sparse identity placement. It becomes interactive only with a defined keyboard-parity action."}, "header.workspace": {"title":"Workspace identity","owner":"underwater.rs","state":"GitStatusSnapshot","action":"open workspace details","hitbox":"header workspace Rect","notes":"Metadata family; truncate before wrapping."}, "header.route": {"title":"Route","owner":"underwater.rs","state":"route identity","action":"open provider/route inspector","hitbox":"header route Rect","notes":"Identity-blue, not a status light."}, "header.model": {"title":"Effective model","owner":"underwater.rs + App","state":"effective provider/model route","action":"open provider inspector","hitbox":"header model Rect","notes":"Must come from current App route, not persisted config."}, diff --git a/docs/design/whale-art-preview.txt b/docs/design/whale-art-preview.txt index 11e952203a..0a87c5a4d5 100644 --- a/docs/design/whale-art-preview.txt +++ b/docs/design/whale-art-preview.txt @@ -1,4 +1,8 @@ -Codewhale empty-state whale mark — CURRENT (main) vs NEW (Signal Cut) +ARCHIVED REFERENCE — removed portrait artwork, not the current Codewhale mark. +For the approved startup identity, use `designs/brand/20260829-codewhale-mark/`: +the generated diving whale whose wave forms a `>` prompt. + +Former Codewhale empty-state whale portrait — CURRENT (main) vs NEW (Signal Cut) == classic == @@ -43,4 +47,3 @@ Codewhale empty-state whale mark — CURRENT (main) vs NEW (Signal Cut) .#######. block width: 21 -> 16 columns - diff --git a/scripts/preview-whale-art.py b/scripts/preview-whale-art.py index e2b394ab36..59c0e28e7d 100755 --- a/scripts/preview-whale-art.py +++ b/scripts/preview-whale-art.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 -"""Preview the Codewhale empty-state whale mark: before vs after. +"""Preview archived Codewhale empty-state portrait art: before vs after. -Reads the live art constants out of `crates/tui/src/tui/underwater.rs` so the -preview cannot drift from what the TUI actually renders, and prints them beside -the art this branch replaced. +This script is historical comparison evidence only. It is not a preview of the +current Codewhale mark: that mark is the generated diving whale from +`designs/brand/20260829-codewhale-mark/`, whose wave forms a `>` prompt. +The historical rows are held below as a fixed record because the live +`underwater.rs` constants were intentionally deleted with the old artwork. python3 scripts/preview-whale-art.py # ANSI true colour python3 scripts/preview-whale-art.py --plain # no escape codes @@ -16,12 +18,6 @@ from __future__ import annotations import argparse -import pathlib -import re -import sys - -ROOT = pathlib.Path(__file__).resolve().parents[1] -UNDERWATER = ROOT / "crates" / "tui" / "src" / "tui" / "underwater.rs" GOLD = (246, 196, 83) # WHALE_HUMAN_RGB — Signal Gold CYAN = (72, 215, 255) # WHALE_CYAN_RGB — Signal Current @@ -44,6 +40,22 @@ ]), } +# The former Signal Cut revision, kept as a static record. It must not be +# inferred from live source: the runtime portrait constants were deliberately +# removed when the product mark changed. +AFTER = { + "classic": (" ˚", [ + " ▗▄▄▟▄▄▄▄▄▖ ▚△▞", + " ▐█·████████▙▄▄▞", + " ▝▀▀▀▀▀▀▀▀▘", + ]), + "uwu": (" ˚✦", [ + " ▗▄▄▟▄▄▄▄▖ ▚△▞", + " ▐█░·░█████▙▄▄▞", + " ▝▀▀▀▀▀▀▀▘", + ]), +} + # Same table the TUI narrows through (`glyphs::ascii_fallback`). ASCII_FALLBACK = { "━": "-", "▐": "|", "█": "#", "▄": "#", "▟": "#", @@ -55,28 +67,6 @@ CURRENT_ROW = 2 # IDLE_WHALE_CURRENT_ROW -def parse_art() -> dict[str, tuple[str, list[str]]]: - """Pull the four art constants out of underwater.rs.""" - src = UNDERWATER.read_text(encoding="utf-8") - - def one(name: str) -> str: - match = re.search(rf'const {name}: &str = "(.*?)";', src) - if not match: - sys.exit(f"could not find {name} in {UNDERWATER}") - return match.group(1) - - def rows(name: str) -> list[str]: - match = re.search(rf"const {name}: \[&str; 3\] = \[(.*?)\];", src, re.S) - if not match: - sys.exit(f"could not find {name} in {UNDERWATER}") - return re.findall(r'"(.*?)"', match.group(1)) - - return { - "classic": (one("IDLE_WHALE_SPOUT_ROW"), rows("IDLE_WHALE_ROWS")), - "uwu": (one("UWU_IDLE_WHALE_SPOUT_ROW"), rows("UWU_IDLE_WHALE_ROWS")), - } - - def ink(variant: str, row: int, ch: str, old: bool = False) -> tuple[int, int, int]: """The colour the TUI resolves for one glyph, without the caustic sweep.""" if ch in "·░✦△": @@ -156,7 +146,7 @@ def render_png(after: dict[str, tuple[str, list[str]]], path: str) -> None: cell_w, cell_h, pad = 18, 34, 30 panels = [ ("BEFORE · main", BEFORE["classic"], True), - ("AFTER · Signal Cut", after["classic"], False), + ("ARCHIVED AFTER · Signal Cut", after["classic"], False), ] cols = max(len(row) for _, (spout, body), _ in panels for row in [spout] + body) width = pad * 2 + cols * cell_w @@ -210,8 +200,8 @@ def main() -> None: args = parser.parse_args() colour = not args.plain and sys.stdout.isatty() - after = parse_art() - print("Codewhale empty-state whale mark — CURRENT (main) vs NEW (Signal Cut)\n") + after = AFTER + print("ARCHIVED: former Codewhale empty-state portrait — main vs Signal Cut\n") for variant in ("classic", "uwu"): label = "classic" if variant == "classic" else "uwu" print(f"== {label} ==\n")