diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0ba55d66de..de1ca3e1af 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -561,6 +561,15 @@ jobs: continue-on-error: true shell: bash run: sccache --show-stats + - name: Check core navigation source and normal dependency boundary + if: needs.changes.outputs.heavy == 'true' + shell: bash + run: | + python3 -B scripts/test_check_command_core_navigation_proof.py + graph=$(mktemp) + trap 'rm -f "$graph"' EXIT + cargo tree --locked -p codewhale-portable-core-navigation --edges normal --all-features --target all --prefix none > "$graph" + python3 -B scripts/check-command-core-navigation-proof.py --graph "$graph" - name: Check provider registry drift if: needs.changes.outputs.heavy == 'true' run: | @@ -875,6 +884,12 @@ jobs: run: | python3 -B scripts/test_check_command_config_proof.py sh scripts/check-portable-config.sh + - name: Check portable core navigation boundary + if: needs.changes.outputs.heavy == 'true' && matrix.os == 'ubuntu-latest' && (github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request') + shell: bash + run: | + python3 -B scripts/test_check_command_core_navigation_proof.py + sh scripts/check-portable-core-navigation.sh - uses: actions/setup-node@v7.1.0 # The extension-host integration tests spawn the real bundle under # Node >= 22.19; CODEWHALE_EXT_HOST_TESTS below makes a missing Node a diff --git a/Cargo.lock b/Cargo.lock index 330f7ee170..d64f0c7281 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1117,6 +1117,13 @@ dependencies = [ "serde_json", ] +[[package]] +name = "codewhale-portable-core-navigation" +version = "0.10.2" +dependencies = [ + "codewhale-command-contract", +] + [[package]] name = "codewhale-portable-session" version = "0.10.2" diff --git a/Cargo.toml b/Cargo.toml index 34f05eb443..0a7c82ec22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,7 @@ members = [ "crates/workflow-js", "tests/portable-session", "tests/portable-config", + "tests/portable-core-navigation", ] exclude = ["vendor/codewhale-ratatui"] default-members = ["crates/cli"] diff --git a/crates/command-contract/src/config.rs b/crates/command-contract/src/config.rs index 56aa6dd21f..8729dc6096 100644 --- a/crates/command-contract/src/config.rs +++ b/crates/command-contract/src/config.rs @@ -222,6 +222,8 @@ pub const EFFECT_REPLY_TEMPLATES: &[(&str, &[&str])] = &[ pub trait CommandConfigContext { /// Path of the active user configuration file, when one is resolved. fn config_path(&self) -> Option; + fn translation_enabled(&self) -> bool; + fn set_translation_enabled(&mut self, enabled: bool); /// `/config status|audit|editability|editable` — typed audit rows. fn audit(&mut self) -> Result; diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index 5f085cd9ba..1d37589f0b 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -107,9 +107,17 @@ pub trait CommandSkillsContext { fn refresh_skill_cache(&mut self); } -/// Workspace path and a bounded serialized work-state snapshot. +/// Workspace path, bounded work-state observations and confined anchor storage. pub trait CommandWorkspaceContext { fn workspace(&self) -> PathBuf; + fn effective_home_dir(&self) -> Option; + fn inspect_workspace_target(&self, candidate: &Path) -> crate::navigation::WorkspaceTarget; + fn read_anchor_document(&self) -> Result, String>; + fn append_anchor_bytes( + &mut self, + parts: &[&[u8]], + ) -> Result; + fn replace_anchor_document(&mut self, bytes: &[u8]) -> Result<(), String>; fn work_state_snapshot(&self) -> Result, String>; /// Session-aware canonical operation digest. Returns the final user-facing /// digest text or a safe explicit error; never a serialized snapshot. diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index f31c5289e2..b21f5dabf8 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -95,12 +95,14 @@ impl CommandCapabilities { /// general configuration and system settings. pub const PET: Self = Self(1 << 26); /// Workspace trust and trusted-path allow-list host data (FEAT-028 D3), - /// independent of the general config facet and of `WORKSPACE` path reads. + /// independent of the general config facet and of `WORKSPACE` operations. pub const TRUST: Self = Self(1 << 27); /// Account/provider session state for `/login` and `/logout` (FEAT-028 D3). pub const LOGIN: Self = Self(1 << 28); /// Workbar placement/panel state for `/workbar` (FEAT-028 D3). pub const WORK_SURFACE: Self = Self(1 << 29); + pub const NAVIGATION: Self = Self(1 << 30); + pub const VOICE_INPUT: Self = Self(1 << 31); /// Raw bit pattern, for tests that pin the capability-space capacity. /// @@ -174,6 +176,8 @@ pub struct CommandContexts<'a> { trust: Option<&'a mut dyn crate::config::CommandTrustContext>, login: Option<&'a mut dyn crate::config::CommandLoginContext>, work_surface: Option<&'a mut dyn crate::config::CommandWorkSurfaceContext>, + navigation: Option<&'a mut dyn crate::navigation::CommandNavigationContext>, + voice_input: Option<&'a mut dyn crate::navigation::CommandVoiceInputContext>, } /// Consumed envelope used when one handler needs several independent facets. @@ -208,6 +212,8 @@ pub struct ContextParts<'a> { pub trust: Option<&'a mut dyn crate::config::CommandTrustContext>, pub login: Option<&'a mut dyn crate::config::CommandLoginContext>, pub work_surface: Option<&'a mut dyn crate::config::CommandWorkSurfaceContext>, + pub navigation: Option<&'a mut dyn crate::navigation::CommandNavigationContext>, + pub voice_input: Option<&'a mut dyn crate::navigation::CommandVoiceInputContext>, } impl<'a> CommandContexts<'a> { @@ -243,6 +249,8 @@ impl<'a> CommandContexts<'a> { trust: None, login: None, work_surface: None, + navigation: None, + voice_input: None, } } @@ -278,6 +286,8 @@ impl<'a> CommandContexts<'a> { trust: self.trust, login: self.login, work_surface: self.work_surface, + navigation: self.navigation, + voice_input: self.voice_input, } } @@ -321,6 +331,28 @@ impl<'a> CommandContexts<'a> { self } + pub fn with_navigation( + mut self, + value: &'a mut dyn crate::navigation::CommandNavigationContext, + ) -> Self { + assert!( + self.navigation.replace(value).is_none(), + "navigation facet already set" + ); + self + } + + pub fn with_voice_input( + mut self, + value: &'a mut dyn crate::navigation::CommandVoiceInputContext, + ) -> Self { + assert!( + self.voice_input.replace(value).is_none(), + "voice-input facet already set" + ); + self + } + pub fn with_permissions(mut self, value: &'a mut dyn CommandPermissionsContext) -> Self { assert!( self.permissions.replace(value).is_none(), diff --git a/crates/command-contract/src/lib.rs b/crates/command-contract/src/lib.rs index 6c645fbe11..72817f6250 100644 --- a/crates/command-contract/src/lib.rs +++ b/crates/command-contract/src/lib.rs @@ -9,6 +9,7 @@ pub mod elapsed; pub mod facets; pub mod handler; pub mod metadata; +pub mod navigation; pub mod outcome; pub mod types; diff --git a/crates/command-contract/src/navigation.rs b/crates/command-contract/src/navigation.rs new file mode 100644 index 0000000000..b254ed02d1 --- /dev/null +++ b/crates/command-contract/src/navigation.rs @@ -0,0 +1,105 @@ +use std::path::PathBuf; + +use crate::types::CommandMode; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationUserHelp { + pub name: String, + pub description: Option, + pub usage: Option, + pub aliases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationBuiltinHelp { + pub name: String, + pub usage: String, + pub description: String, + pub aliases: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationHelpTopic { + pub user: Option, + pub builtin: Option, + pub shadowed_names: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationSkill { + pub name: String, + pub description: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationOverview { + pub model: String, + pub mode: CommandMode, + pub mode_label: String, + pub workspace: PathBuf, + pub history_count: usize, + pub total_tokens: u32, + pub queued_count: usize, + pub subagent_count: usize, + pub active_skill: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NavigationProviderKind { + Moonshot, + Other, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationProviderLinks { + pub id: String, + pub name: String, + pub credential_url: Option, + pub docs_url: Option, + pub guidance: String, + pub env_names: Vec, + pub kind: NavigationProviderKind, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NavigationLinks { + pub active_provider: String, + pub locale_tag: String, + pub providers: Vec, + pub kimi_route: String, + pub kimi_console: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum WorkspaceTarget { + Missing, + NotDirectory, + Directory { canonical_or_candidate: PathBuf }, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum AnchorWriteError { + Open(String), + Write(String), +} + +pub trait CommandNavigationContext { + fn help_topic(&self, topic: &str) -> NavigationHelpTopic; + fn cached_skills(&self) -> Vec; + fn help_is_open(&self) -> bool; + fn open_help(&mut self); + fn home_is_blocked(&self) -> bool; + fn restore_home(&mut self); + fn overview(&self) -> NavigationOverview; + fn links(&self) -> NavigationLinks; +} + +pub trait CommandVoiceInputContext { + fn recorder_available(&self) -> bool; + fn voice_enabled(&self) -> bool; + fn set_voice_enabled(&mut self, enabled: bool); + fn send_enabled(&self) -> bool; + fn set_send_enabled(&mut self, enabled: bool); + fn control_enabled(&self) -> bool; + fn set_control_enabled(&mut self, enabled: bool); +} diff --git a/crates/command-contract/src/outcome.rs b/crates/command-contract/src/outcome.rs index a13508361a..e7d5cb73d8 100644 --- a/crates/command-contract/src/outcome.rs +++ b/crates/command-contract/src/outcome.rs @@ -162,3 +162,17 @@ pub type ConfigCommandResult = CommandResult; pub type ConfigPolicyAction = ConfigAction; pub type ConfigPolicyCommandResult = ConfigCommandResult; pub type ConfigStatusCommandResult = CommandResult; + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CoreNavigationAction { + OpenHotbarSetup, + DisableHotbar, + RestoreHotbarDefaults, + OpenLiveTranscript, + OpenTurnInspector, + OpenWorktreeManager, + SwitchWorkspace { workspace: std::path::PathBuf }, + VoiceCapture, +} + +pub type CoreNavigationCommandResult = CommandResult; diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index cd5a8c7b02..1c834dbade 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -121,6 +121,24 @@ impl CommandSkillsContext for Skills { struct Workspace; impl CommandWorkspaceContext for Workspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by this fixture") + } + fn inspect_workspace_target(&self, _: &std::path::Path) -> crate::navigation::WorkspaceTarget { + unreachable!("navigation is not used by this fixture") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by this fixture") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result { + unreachable!("anchors are not used by this fixture") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by this fixture") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } @@ -646,6 +664,24 @@ impl CommandMediaContext for Media { struct DigestWorkspace; impl CommandWorkspaceContext for DigestWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by digest") + } + fn inspect_workspace_target(&self, _: &std::path::Path) -> crate::navigation::WorkspaceTarget { + unreachable!("navigation is not used by digest") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by digest") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result { + unreachable!("anchors are not used by digest") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by digest") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } @@ -3564,6 +3600,8 @@ fn structcopy_capability_preserves_all_published_identities() { CommandCapabilities::TRUST, CommandCapabilities::LOGIN, CommandCapabilities::WORK_SURFACE, + CommandCapabilities::NAVIGATION, + CommandCapabilities::VOICE_INPUT, ]; let exact = CommandCapabilities::SESSION_STRUCTCOPY | CommandCapabilities::PRESENTATION; for (index, capability) in capabilities.into_iter().enumerate() { @@ -3628,6 +3666,8 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { trust, login, work_surface, + navigation, + voice_input, } = CommandContexts::empty() .with_structcopy(&mut fixture) .with_presentation(&mut presentation) @@ -3677,6 +3717,8 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { trust.is_some(), login.is_some(), work_surface.is_some(), + navigation.is_some(), + voice_input.is_some(), ] { assert!(!present); } diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index bbb6b915d8..739400f417 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -14,7 +14,7 @@ //! //! ## Authoritative host-proxy design (D1) //! -//! `CommandContexts` has twenty-five independently optional facet slots, all +//! `CommandContexts` has independently optional facet slots, all //! constructed here. The diagnostics adapter joins the host bundle in FEAT-029. Important behavior (mode transitions, model //! invalidation, cost accounting, skill refresh) is authoritative on `App`. The adapters therefore share a //! synchronous TUI-owned host proxy. Each trait call borrows `App` only for the @@ -32,6 +32,8 @@ use std::path::{Path, PathBuf}; use std::rc::Rc; mod config_main; +mod core_navigation; +use core_navigation::{NavigationAdapter, VoiceInputAdapter}; pub(in crate::commands) mod config_policy; #[cfg(test)] mod config_policy_baseline; @@ -206,6 +208,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { "cmd_help_description" => MessageId::CmdHelpDescription, "cmd_hf_description" => MessageId::CmdHfDescription, "cmd_home_description" => MessageId::CmdHomeDescription, + "cmd_overview_description" => MessageId::CmdOverviewDescription, "cmd_hooks_description" => MessageId::CmdHooksDescription, "cmd_import_claude_description" => MessageId::CmdImportClaudeDescription, "cmd_inline_description" => MessageId::CmdInlineDescription, @@ -270,6 +273,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { "cmd_tree_description" => MessageId::CmdTreeDescription, "cmd_trust_description" => MessageId::CmdTrustDescription, "cmd_turn_inspect_description" => MessageId::CmdTurnInspectDescription, + "kb_live_transcript" => MessageId::KbLiveTranscript, "cmd_undo_description" => MessageId::CmdUndoDescription, "cmd_update_description" => MessageId::CmdUpdateDescription, "cmd_verbose_description" => MessageId::CmdVerboseDescription, @@ -2171,7 +2175,7 @@ impl CommandSkillsContext for SkillsAdapter<'_> { } } -/// Workspace path and bounded serialized work-state snapshot. +/// Workspace observations and confined anchor operations. pub(crate) struct WorkspaceAdapter<'a> { host: SharedCommandHost<'a>, } @@ -2181,6 +2185,43 @@ impl CommandWorkspaceContext for WorkspaceAdapter<'_> { self.host.app.borrow().workspace.clone() } + fn effective_home_dir(&self) -> Option { + crate::config::effective_home_dir() + } + + fn inspect_workspace_target( + &self, + candidate: &Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + use codewhale_command_contract::navigation::WorkspaceTarget; + if !candidate.exists() { + WorkspaceTarget::Missing + } else if !candidate.is_dir() { + WorkspaceTarget::NotDirectory + } else { + WorkspaceTarget::Directory { + canonical_or_candidate: candidate + .canonicalize() + .unwrap_or_else(|_| candidate.to_path_buf()), + } + } + } + + fn read_anchor_document(&self) -> Result, String> { + core_navigation::read_anchor_document(&self.workspace()) + } + + fn append_anchor_bytes( + &mut self, + parts: &[&[u8]], + ) -> Result { + core_navigation::append_anchor_bytes(&self.workspace(), parts) + } + + fn replace_anchor_document(&mut self, bytes: &[u8]) -> Result<(), String> { + core_navigation::replace_anchor_document(&self.workspace(), bytes) + } + fn work_state_snapshot(&self) -> Result, String> { self.host.app.borrow().work_state_snapshot().map(|state| { state.and_then(|state| crate::todo_snapshot::todo_snapshot_body(&state.todos)) @@ -2222,6 +2263,7 @@ impl CommandPresentationContext for PresentationAdapter<'_> { .or_else(|| diagnostics_messages::resolve(key)) .or_else(|| config_policy_messages::resolve(key)) .or_else(|| config_messages::resolve(key)) + .or_else(|| core_navigation::resolve_message(key)) else { return Err("unknown translation key".to_string()); }; @@ -4667,6 +4709,8 @@ pub(crate) struct CommandContextBundle<'a> { trust: TrustAdapter<'a>, login: LoginAdapter<'a>, work_surface: WorkSurfaceAdapter<'a>, + navigation: NavigationAdapter<'a>, + voice_input: VoiceInputAdapter<'a>, } impl<'a> CommandContextBundle<'a> { @@ -4763,6 +4807,12 @@ impl<'a> CommandContextBundle<'a> { if capabilities.contains(CommandCapabilities::WORK_SURFACE) { contexts = contexts.with_work_surface(&mut self.work_surface); } + if capabilities.contains(CommandCapabilities::NAVIGATION) { + contexts = contexts.with_navigation(&mut self.navigation); + } + if capabilities.contains(CommandCapabilities::VOICE_INPUT) { + contexts = contexts.with_voice_input(&mut self.voice_input); + } contexts } @@ -4798,7 +4848,9 @@ impl<'a> CommandContextBundle<'a> { .union(CommandCapabilities::PET) .union(CommandCapabilities::TRUST) .union(CommandCapabilities::LOGIN) - .union(CommandCapabilities::WORK_SURFACE); + .union(CommandCapabilities::WORK_SURFACE) + .union(CommandCapabilities::NAVIGATION) + .union(CommandCapabilities::VOICE_INPUT); self.contexts(all_test_capabilities).into_parts() } } @@ -4849,7 +4901,9 @@ impl App { pet: PetAdapter { host: host.clone() }, trust: TrustAdapter { host: host.clone() }, login: LoginAdapter { host: host.clone() }, - work_surface: WorkSurfaceAdapter { host }, + work_surface: WorkSurfaceAdapter { host: host.clone() }, + navigation: NavigationAdapter { host: host.clone() }, + voice_input: VoiceInputAdapter { host }, } } } diff --git a/crates/tui/src/commands/contract/config_main.rs b/crates/tui/src/commands/contract/config_main.rs index e1f550fc62..9450382b88 100644 --- a/crates/tui/src/commands/contract/config_main.rs +++ b/crates/tui/src/commands/contract/config_main.rs @@ -23,6 +23,14 @@ pub(crate) struct ConfigAdapter<'a> { } impl CommandConfigContext for ConfigAdapter<'_> { + fn translation_enabled(&self) -> bool { + self.host.app.borrow().translation_enabled + } + + fn set_translation_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().translation_enabled = enabled; + } + fn config_path(&self) -> Option { self.host.app.borrow().config_path.clone() } diff --git a/crates/tui/src/commands/contract/core_navigation.rs b/crates/tui/src/commands/contract/core_navigation.rs new file mode 100644 index 0000000000..b3651e743b --- /dev/null +++ b/crates/tui/src/commands/contract/core_navigation.rs @@ -0,0 +1,266 @@ +use super::{SharedCommandHost, to_command_mode}; +use crate::tui::app::AppModeUi; +use crate::tui::views::{HelpView, ModalKind}; +use codewhale_command_contract::navigation::{ + CommandNavigationContext, NavigationBuiltinHelp, NavigationHelpTopic, NavigationLinks, + NavigationOverview, NavigationProviderKind, NavigationProviderLinks, NavigationSkill, + NavigationUserHelp, +}; +use codewhale_localization::MessageId; + +pub(super) struct NavigationAdapter<'a> { + pub(super) host: SharedCommandHost<'a>, +} + +impl CommandNavigationContext for NavigationAdapter<'_> { + fn help_topic(&self, topic: &str) -> NavigationHelpTopic { + let app = self.host.app.borrow(); + let builtin = crate::commands::get_command_info(topic); + crate::commands::user_registry::with_registry_for_app(&app, |registry| { + NavigationHelpTopic { + user: registry.get(topic).map(|command| NavigationUserHelp { + name: command.name.clone(), + description: command.description.clone(), + usage: command.display_usage().map(str::to_owned), + aliases: command.aliases.clone(), + }), + shadowed_names: builtin + .map(|command| { + command + .aliases + .iter() + .filter(|alias| registry.get(alias).is_some()) + .map(|alias| (*alias).to_owned()) + .collect() + }) + .unwrap_or_default(), + builtin: builtin.map(|command| NavigationBuiltinHelp { + name: command.name.to_owned(), + usage: command.usage.to_owned(), + description: command.description_for(app.ui_locale).into_owned(), + aliases: command + .aliases + .iter() + .map(|alias| (*alias).to_owned()) + .collect(), + }), + } + }) + } + + fn cached_skills(&self) -> Vec { + self.host + .app + .borrow() + .cached_skills + .iter() + .map(|(name, description)| NavigationSkill { + name: name.clone(), + description: description.clone(), + }) + .collect() + } + + fn help_is_open(&self) -> bool { + self.host.app.borrow().view_stack.top_kind() == Some(ModalKind::Help) + } + + fn open_help(&mut self) { + let mut app = self.host.app.borrow_mut(); + let help = HelpView::new_for_app(&app, false).with_groups_expanded(app.help_expand_groups); + app.view_stack.push(help); + } + + fn home_is_blocked(&self) -> bool { + self.host.app.borrow().session_transition_blocked() + } + + fn restore_home(&mut self) { + let mut app = self.host.app.borrow_mut(); + app.launch.workspace = app.workspace.clone(); + app.launch.restore_card(); + app.launch.visible = true; + app.launch.return_to_session = true; + app.needs_redraw = true; + } + + fn overview(&self) -> NavigationOverview { + let app = self.host.app.borrow(); + NavigationOverview { + model: app.model.clone(), + mode: to_command_mode(app.mode), + mode_label: app.mode.display_name_localized(app.ui_locale).into_owned(), + workspace: app.workspace.clone(), + history_count: app.history.len(), + total_tokens: app.session.displayed_total_conversation_tokens(), + queued_count: app.queued_messages.len(), + subagent_count: app.subagent_cache.len(), + active_skill: app.active_skill.clone(), + } + } + + fn links(&self) -> NavigationLinks { + let app = self.host.app.borrow(); + NavigationLinks { + active_provider: app.api_provider.as_str().to_owned(), + locale_tag: app.ui_locale.tag().to_owned(), + providers: codewhale_config::provider::providers_sorted_for_display() + .into_iter() + .map(|provider| { + let links = provider.credential_help(); + NavigationProviderLinks { + id: provider.id().to_owned(), + name: provider.display_name().to_owned(), + credential_url: links.credential_url.map(str::to_owned), + docs_url: links.docs_url.map(str::to_owned), + guidance: links.guidance.to_owned(), + env_names: provider + .env_vars() + .iter() + .map(|name| (*name).to_owned()) + .collect(), + kind: if provider.kind() == codewhale_config::ProviderKind::Moonshot { + NavigationProviderKind::Moonshot + } else { + NavigationProviderKind::Other + }, + } + }) + .collect(), + kimi_route: crate::config::DEFAULT_KIMI_CODE_BASE_URL.to_owned(), + kimi_console: crate::config::KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL.to_owned(), + } + } +} + +pub(super) struct VoiceInputAdapter<'a> { + pub(super) host: SharedCommandHost<'a>, +} + +impl codewhale_command_contract::navigation::CommandVoiceInputContext for VoiceInputAdapter<'_> { + fn recorder_available(&self) -> bool { + crate::voice::is_available() + } + fn voice_enabled(&self) -> bool { + self.host.app.borrow().voice_enabled + } + fn set_voice_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().voice_enabled = enabled; + } + fn send_enabled(&self) -> bool { + self.host.app.borrow().voice_send_enabled + } + fn set_send_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().voice_send_enabled = enabled; + } + fn control_enabled(&self) -> bool { + self.host.app.borrow().voice_control_enabled + } + fn set_control_enabled(&mut self, enabled: bool) { + self.host.app.borrow_mut().voice_control_enabled = enabled; + } +} + +pub(super) fn anchors_path(workspace: &std::path::Path) -> std::path::PathBuf { + let primary = workspace.join(".codewhale").join("anchors.md"); + if primary.symlink_metadata().is_ok() || workspace.join(".codewhale").is_symlink() { + return primary; + } + workspace.join(".deepseek").join("anchors.md") +} + +pub(super) fn read_anchor_document(workspace: &std::path::Path) -> Result, String> { + match crate::fs_confined::read_to_string(workspace, &anchors_path(workspace)) { + Ok(content) => Ok(Some(content)), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(error) => Err(error.to_string()), + } +} + +pub(super) fn append_anchor_bytes( + workspace: &std::path::Path, + parts: &[&[u8]], +) -> Result { + use codewhale_command_contract::navigation::AnchorWriteError; + use std::io::Write; + let path = anchors_path(workspace); + let mut file = crate::fs_confined::open_append(workspace, &path) + .map_err(|error| AnchorWriteError::Open(error.to_string()))?; + for bytes in parts { + file.write_all(bytes) + .map_err(|error| AnchorWriteError::Write(error.to_string()))?; + } + Ok(path) +} + +pub(super) fn replace_anchor_document( + workspace: &std::path::Path, + bytes: &[u8], +) -> Result<(), String> { + crate::fs_confined::write(workspace, &anchors_path(workspace), bytes) + .map_err(|error| error.to_string()) +} + +pub(super) fn resolve_message(key: &str) -> Option { + Some(match key { + "voice_disabled" => MessageId::VoiceDisabled, + "voice_enabled" => MessageId::VoiceEnabled, + "voice_err_no_recorder" => MessageId::VoiceErrNoRecorder, + "voice_send_enabled" => MessageId::VoiceSendEnabled, + "voice_send_disabled" => MessageId::VoiceSendDisabled, + "voice_control_enabled" => MessageId::VoiceControlEnabled, + "voice_control_disabled" => MessageId::VoiceControlDisabled, + "cmd_translate_on" => MessageId::CmdTranslateOn, + "cmd_translate_off" => MessageId::CmdTranslateOff, + "workspace_current" => MessageId::WorkspaceCurrent, + "workspace_not_found" => MessageId::WorkspaceNotFound, + "workspace_not_directory" => MessageId::WorkspaceNotDirectory, + "workspace_switching" => MessageId::WorkspaceSwitching, + "workspace_home_unresolved" => MessageId::WorkspaceHomeUnresolved, + "help_aliases_label" => MessageId::HelpAliasesLabel, + "help_unknown_command" => MessageId::HelpUnknownCommand, + "config_help_discoverable" => MessageId::ConfigHelpDiscoverable, + "home_navigation_busy" => MessageId::HomeNavigationBusy, + "home_dashboard_title" => MessageId::HomeDashboardTitle, + "home_model" => MessageId::HomeModel, + "home_mode" => MessageId::HomeMode, + "home_workspace" => MessageId::HomeWorkspace, + "home_history" => MessageId::HomeHistory, + "home_tokens" => MessageId::HomeTokens, + "home_queued" => MessageId::HomeQueued, + "home_subagents" => MessageId::HomeSubagents, + "home_skill" => MessageId::HomeSkill, + "home_quick_actions" => MessageId::HomeQuickActions, + "home_quick_workspace" => MessageId::HomeQuickWorkspace, + "home_quick_restore" => MessageId::HomeQuickRestore, + "home_quick_tokens" => MessageId::HomeQuickTokens, + "home_quick_links" => MessageId::HomeQuickLinks, + "home_quick_skills" => MessageId::HomeQuickSkills, + "home_quick_config" => MessageId::HomeQuickConfig, + "home_quick_settings" => MessageId::HomeQuickSettings, + "home_quick_model" => MessageId::HomeQuickModel, + "home_quick_subagents" => MessageId::HomeQuickSubagents, + "home_quick_task_list" => MessageId::HomeQuickTaskList, + "home_quick_help" => MessageId::HomeQuickHelp, + "home_mode_tips" => MessageId::HomeModeTips, + "home_agent_mode_tip" => MessageId::HomeAgentModeTip, + "home_agent_mode_review_tip" => MessageId::HomeAgentModeReviewTip, + "home_agent_mode_yolo_tip" => MessageId::HomeAgentModeYoloTip, + "home_operate_mode_tip" => MessageId::HomeOperateModeTip, + "home_operate_mode_fleet_tip" => MessageId::HomeOperateModeFleetTip, + "home_plan_mode_tip" => MessageId::HomePlanModeTip, + "home_plan_mode_checklist_tip" => MessageId::HomePlanModeChecklistTip, + "links_project_title" => MessageId::LinksProjectTitle, + "links_documentation" => MessageId::LinksDocumentation, + "links_community" => MessageId::LinksCommunity, + "links_git_hub" => MessageId::LinksGitHub, + "links_managed_app" => MessageId::LinksManagedApp, + "links_managed_app_note" => MessageId::LinksManagedAppNote, + "links_title" => MessageId::LinksTitle, + "links_dashboard" => MessageId::LinksDashboard, + "links_docs" => MessageId::LinksDocs, + "links_kimi_code_route_note" => MessageId::LinksKimiCodeRouteNote, + "links_tip" => MessageId::LinksTip, + _ => return None, + }) +} diff --git a/crates/tui/src/commands/core_navigation_host.rs b/crates/tui/src/commands/core_navigation_host.rs new file mode 100644 index 0000000000..4e34c5da0d --- /dev/null +++ b/crates/tui/src/commands/core_navigation_host.rs @@ -0,0 +1,79 @@ +use super::CommandResult; +use super::groups::core::navigation::portable_handlers; +use super::traits::{Command, ContextualCommand}; +use crate::tui::app::{App, AppAction}; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{CoreNavigationAction, CoreNavigationCommandResult}; + +struct Registration; + +impl RegisterCommand for Registration { + fn info() -> &'static CommandInfo { + portable_handlers()[INDEX].0 + } + + fn handler() -> CommandHandler { + match portable_handlers()[INDEX].1 { + CommandHandler::Pure(_) => { + CommandHandler::Pure(|args| match portable_handlers()[INDEX].1 { + CommandHandler::Pure(run) => host_result(run(args)), + _ => CommandResult::error("navigation handler shape changed"), + }) + } + CommandHandler::Contextual { capabilities, .. } => CommandHandler::Contextual { + capabilities, + handler: |contexts, args| match portable_handlers()[INDEX].1 { + CommandHandler::Contextual { handler, .. } => { + host_result(handler(contexts, args)) + } + _ => CommandResult::error("navigation handler shape changed"), + }, + }, + } + } +} + +fn host_result(result: CoreNavigationCommandResult) -> CommandResult { + CommandResult { + message: result.message, + action: result.action.map(|action| match action { + CoreNavigationAction::OpenHotbarSetup => AppAction::OpenHotbarSetup, + CoreNavigationAction::DisableHotbar => AppAction::DisableHotbar, + CoreNavigationAction::RestoreHotbarDefaults => AppAction::RestoreHotbarDefaults, + CoreNavigationAction::OpenLiveTranscript => AppAction::OpenLiveTranscript, + CoreNavigationAction::OpenTurnInspector => AppAction::OpenTurnInspector, + CoreNavigationAction::OpenWorktreeManager => AppAction::OpenWorktreeManager, + CoreNavigationAction::SwitchWorkspace { workspace } => { + AppAction::SwitchWorkspace { workspace } + } + CoreNavigationAction::VoiceCapture => AppAction::VoiceCapture, + }), + is_error: result.is_error, + } +} + +pub(in crate::commands) fn dispatch(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { + let Some(handler) = super::registry() + .get(name) + .and_then(Command::contextual_handler) + else { + return CommandResult::error(format!("Navigation command unavailable: {name}")); + }; + match handler { + CommandHandler::Pure(run) => run(arg), + CommandHandler::Contextual { + capabilities, + handler, + } => { + let mut bundle = app.command_contexts_with_config(None); + handler(bundle.contexts(capabilities), arg) + } + } +} + +pub(in crate::commands) fn registration() -> Box { + Box::new( + ContextualCommand::from_contract::>().expect("navigation registration"), + ) +} diff --git a/crates/tui/src/commands/core_navigation_host_tests.rs b/crates/tui/src/commands/core_navigation_host_tests.rs new file mode 100644 index 0000000000..33b6d12c67 --- /dev/null +++ b/crates/tui/src/commands/core_navigation_host_tests.rs @@ -0,0 +1,810 @@ +use crate::commands::CommandResult; +use crate::config::Config; +use crate::tui::app::{App, AppAction, TuiOptions}; +use crate::tui::views::ModalKind; +use codewhale_config::AppMode; +use codewhale_localization::Locale; +use std::path::PathBuf; +use tempfile::tempdir; + +fn help(app: &mut App, topic: Option<&str>) -> CommandResult { + let input = topic + .map(|topic| format!("/help {topic}")) + .unwrap_or_else(|| "/help".into()); + crate::commands::execute(&input, app) +} + +fn codewhale_links(app: &mut App) -> CommandResult { + crate::commands::execute("/links", app) +} + +fn home_dashboard(app: &mut App) -> CommandResult { + crate::commands::execute("/overview", app) +} + +fn create_test_app() -> App { + let options = TuiOptions { + skills_dir: PathBuf::from("/tmp/test-skills"), + ..crate::tui::test_support::test_tui_options(PathBuf::from("/tmp/test-workspace")) + }; + let mut app = App::new(options, &Config::default()); + app.ui_locale = codewhale_localization::Locale::En; + app.api_provider = crate::config::ProviderKind::Deepseek; + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.model_ids_passthrough = false; + app +} + +#[test] +fn help_topic_resolves_a_discovered_skill_and_both_shapes() { + // #3912: `/help ` said "Unknown command" for a skill that + // executes and autocompletes. + let mut app = create_test_app(); + app.cached_skills = vec![( + "codereview".to_string(), + "Review a diff for defects".to_string(), + )]; + + for topic in [ + "codereview", + "CodeReview", + "$codereview", + "/skill codereview", + ] { + let result = help(&mut app, Some(topic)); + let message = result + .message + .as_deref() + .unwrap_or_else(|| panic!("{topic} should resolve to skill help")); + assert!(message.contains("Review a diff for defects"), "{message}"); + assert!(message.contains("$codereview"), "{message}"); + assert!(message.contains("/skill codereview"), "{message}"); + } +} + +#[test] +fn help_topic_still_errors_for_an_unknown_name() { + let mut app = create_test_app(); + app.cached_skills = vec![("codereview".to_string(), "Review a diff".to_string())]; + let result = help(&mut app, Some("definitely-not-a-thing")); + assert!(result.is_error, "unknown topics must still be an error"); + assert!( + !result + .message + .as_deref() + .unwrap_or_default() + .contains("Review a diff"), + "the skill fallback must not match an unrelated topic" + ); +} + +#[test] +fn test_help_unknown_command() { + let mut app = create_test_app(); + let result = help(&mut app, Some("nonexistent")); + assert!(result.message.is_some()); + assert!(result.message.unwrap().contains("Unknown command")); + assert!(result.action.is_none()); +} + +#[test] +fn test_help_known_command() { + let mut app = create_test_app(); + let result = help(&mut app, Some("clear")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("clear")); + assert!(msg.contains("Clear conversation history")); + assert!(msg.contains("Usage: /clear")); +} + +#[test] +fn test_help_config_topic_uses_interactive_editor_text() { + let mut app = create_test_app(); + let result = help(&mut app, Some("config")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("config")); + assert!(msg.contains("Inspect and change settings")); + assert!(msg.contains("Usage: /config")); + assert!(msg.contains("context_window = 262144")); + assert!(msg.contains("/config context_window")); + assert!(msg.contains("/config search.provider")); + assert!(msg.contains("/config prompt_suggestion")); + assert!(msg.contains("/config notifications")); +} + +#[test] +fn test_help_links_topic_shows_aliases() { + let mut app = create_test_app(); + let result = help(&mut app, Some("links")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("links")); + assert!(msg.contains("Show Codewhale, community, and provider links")); + assert!(msg.contains("Usage: /links")); + assert!(msg.contains("Aliases: dashboard, api")); + + let root = tempdir().unwrap(); + crate::test_support::trust_workspace(root.path()); + let directory = root.path().join(".codewhale/commands"); + std::fs::create_dir_all(&directory).unwrap(); + std::fs::write(directory.join("custom-links.md"), "---\ndescription: User link helper\nusage: /custom-links \naliases: api\n---\nuser link $ARGUMENTS").unwrap(); + let mut app = crate::tui::test_support::test_app_with_options( + crate::tui::test_support::test_tui_options(root.path()), + ); + app.ui_locale = Locale::En; + app.cached_skills = vec![ + ("custom-links".into(), "Wrong skill fallback".into()), + ("links".into(), "Wrong builtin fallback".into()), + ]; + crate::commands::user_registry::reload(Some(root.path())); + for topic in ["custom-links", "api"] { + let result = help(&mut app, Some(topic)); + assert!(!result.is_error); + let text = result.message.unwrap(); + assert!(text.starts_with("custom-links\n\n")); + assert!(text.contains("User link helper")); + assert!(text.contains("Usage: /custom-links ")); + assert!(text.contains("Aliases: api"), "{text}"); + assert!(!text.contains("Wrong skill fallback")); + } + let text = help(&mut app, Some("links")).message.unwrap(); + assert!(text.starts_with("links\n\n")); + assert!(text.contains("Aliases: dashboard, lianjie")); + assert!(!text.contains("Aliases: dashboard, api")); + assert!(!text.contains("Wrong builtin fallback")); +} + +#[test] +fn test_help_memory_topic_shows_usage_and_description() { + let mut app = create_test_app(); + let result = help(&mut app, Some("memory")); + let msg = result.message.expect("help topic should return message"); + assert!(msg.contains("memory")); + assert!(msg.contains("persistent structured user memory")); + assert!(msg.contains( + "Usage: /memory [status|path|search|get|remember|import|export|reindex|clear|help]" + )); +} + +#[test] +fn test_help_pushes_overlay() { + let mut app = create_test_app(); + assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help)); + let result = help(&mut app, None); + assert_eq!(result.message, None); + assert_eq!(result.action, None); + assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help)); +} + +#[test] +fn test_help_does_not_duplicate_overlay() { + let mut app = create_test_app(); + assert!(app.view_stack.is_empty()); + help(&mut app, None); + let initial_kind = app.view_stack.top_kind(); + help(&mut app, None); + assert_eq!(app.view_stack.top_kind(), initial_kind); + assert!(app.view_stack.pop().is_some()); + assert!( + app.view_stack.is_empty(), + "one dismissal must remove the only help modal" + ); +} + +#[test] +fn test_codewhale_links() { + let mut app = create_test_app(); + let result = codewhale_links(&mut app); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Codewhale & community")); + assert!(msg.contains("https://codewhale.net/en/docs")); + assert!(msg.contains("https://codewhale.net/en/community")); + assert!(msg.contains("https://github.com/codewhale-hq/CodeWhale")); + assert!(msg.contains("https://app.codewhale.net")); + assert!(msg.contains("separate sign-in")); + assert!(msg.contains("not connected to the current local session")); + assert!(msg.contains("Provider Links")); + assert!(msg.contains("DeepSeek (deepseek) <- current")); + assert!(msg.contains("https://platform.deepseek.com/api_keys")); + assert!(msg.contains("Xiaomi MiMo (xiaomi-mimo)")); + assert!(msg.contains("https://platform.xiaomimimo.com/token-plan")); + assert!(msg.contains("Moonshot/Kimi (moonshot)")); + assert!(msg.contains("https://platform.kimi.ai/console/api-keys")); + assert!(msg.contains("https://platform.kimi.ai/docs/overview")); + assert!(msg.contains("https://api.kimi.com/coding/v1")); + assert!(msg.contains("https://www.kimi.com/code/console")); + assert!(msg.contains("never imports Kimi CLI credentials")); + assert!(msg.contains("https://console.openmodel.ai/")); + assert!(msg.contains("https://docs.openmodel.ai/en/docs/getting-started/authentication")); + assert!(msg.contains("https://console.sakana.ai/api-keys")); + assert!(msg.contains("https://console.sakana.ai/get-started")); + assert!(msg.contains("Baidu Qianfan (qianfan)")); + assert!(msg.contains("https://cloud.baidu.com/doc/qianfan/index.html")); + assert!(msg.contains("Local Ollama is keyless by default")); + assert!(msg.contains("codewhale auth chatgpt")); + assert!( + !msg.contains("codex login"), + "official sign-in must not direct users to external CLI credentials" + ); + assert!(msg.contains("no canonical vendor credential page exists")); + assert!(msg.contains("OPENAI_API_KEY")); + assert!(msg.contains("XIAOMI_MIMO_TOKEN_PLAN_API_KEY")); + assert!(!msg.contains("https://codewhale.dev/docs/providers")); + assert!(result.action.is_none()); +} + +#[test] +fn provider_links_emit_urls_as_inline_code_for_narrow_transcripts() { + let mut app = create_test_app(); + let result = codewhale_links(&mut app); + let msg = result.message.expect("links should return a message"); + + assert!(msg.contains("`https://platform.openai.com/api-keys`")); + assert!( + msg.contains("`https://platform.minimax.io/user-center/basic-information/interface-key`") + ); + + for line in msg.lines().filter(|line| line.contains("http")) { + let Some(url_start) = line.find("http") else { + continue; + }; + assert!( + line[..url_start].ends_with('`') && line[url_start..].contains('`'), + "provider URL should be inline-code wrapped so narrow TUI renders do not emit oversized OSC8 link payloads: {line}" + ); + } +} + +#[test] +fn project_links_follow_the_available_public_site_locale() { + let mut app = create_test_app(); + app.ui_locale = Locale::ZhHans; + + let msg = codewhale_links(&mut app) + .message + .expect("links should return a message"); + + assert!(msg.contains("`https://codewhale.net/zh/docs`")); + assert!(msg.contains("`https://codewhale.net/zh/community`")); + assert!(msg.contains("`https://app.codewhale.net`")); +} + +#[test] +fn test_home_dashboard_includes_all_sections() { + let mut app = create_test_app(); + app.session.total_conversation_tokens = 1234; + let result = home_dashboard(&mut app); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("codewhale")); + assert!(!msg.contains("codewhale Home Dashboard")); + assert!(msg.contains("Model:")); + assert!(msg.contains("Mode:")); + assert!(msg.contains("Workspace:")); + assert!(msg.contains("History:")); + assert!(msg.contains("Tokens:")); + assert!(msg.contains("Quick Actions")); + assert!(msg.contains("Mode Tips")); + assert!(result.action.is_none()); +} + +#[test] +fn test_home_dashboard_shows_queued_when_present() { + let mut app = create_test_app(); + app.queued_messages + .push_back(crate::tui::app::QueuedMessage::new( + "test".to_string(), + None, + )); + let result = home_dashboard(&mut app); + let msg = result.message.unwrap(); + assert!(msg.contains("Queued:")); +} + +#[test] +fn test_home_dashboard_mode_tips_for_each_mode() { + let modes = [AppMode::Agent, AppMode::Plan, AppMode::Operate]; + for mode in modes { + let mut app = create_test_app(); + app.mode = mode; + let result = home_dashboard(&mut app); + let msg = result.message.unwrap(); + assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}"); + } +} + +#[test] +fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() { + let mut app = create_test_app(); + let result = home_dashboard(&mut app); + let msg = result + .message + .expect("home dashboard should return message"); + assert!(msg.contains("/workspace - Switch folders or worktrees")); + assert!(msg.contains("/restore - Roll files back to a turn snapshot")); + assert!(msg.contains("/tokens - Show session spend and context")); + assert!(msg.contains("/links - Codewhale, community & provider links")); + assert!(msg.contains("/config - Inspect and change settings")); + assert!( + !msg.lines() + .any(|line| line.trim_start().starts_with("/set ")) + ); + assert!(!msg.contains("/codewhale")); +} + +#[test] +fn home_dashboard_localizes_in_zh_hans() { + use codewhale_localization::Locale; + let mut app = create_test_app(); + app.ui_locale = Locale::ZhHans; + let result = home_dashboard(&mut app); + let msg = result + .message + .expect("home dashboard should return message"); + assert!( + msg.contains("codewhale"), + "missing canonical product title:\n{msg}" + ); + assert!(msg.contains("模型"), "missing zh-Hans model label:\n{msg}"); + assert!( + msg.contains("快捷操作"), + "missing zh-Hans quick actions:\n{msg}" + ); + assert!( + msg.contains("模式提示"), + "missing zh-Hans mode tips:\n{msg}" + ); +} + +#[cfg(test)] +mod home { + use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; + + #[test] + fn home_preserves_session_draft_and_reveal_and_escape_returns() { + let mut app = crate::tui::test_support::test_app_with_options( + crate::tui::test_support::test_tui_options("."), + ); + app.launch.dismiss(); + app.current_session_id = Some("retained-session".into()); + app.api_messages_mut().push(codewhale_models::Message { + role: codewhale_models::Role::User, + content: vec![codewhale_models::ContentBlock::Text { + text: "Retain this conversation".into(), + cache_control: None, + }], + }); + app.add_message(crate::tui::history::HistoryCell::System { + content: "Retain this transcript".into(), + }); + app.input = "unsent draft".into(); + app.cursor_position = app.input.chars().count(); + app.launch.mark_reveal_started_at = Some(std::time::Instant::now()); + let reveal = app.launch.mark_reveal_started_at; + let messages = app.api_messages.clone(); + let history_len = app.history.len(); + let result = crate::commands::execute("/home", &mut app); + assert!(!result.is_error, "{:?}", result.message); + assert!( + result.action.is_none(), + "navigation must not sync/reset the Engine" + ); + assert!(crate::tui::widgets::should_render_empty_state(&app)); + assert!(app.launch.return_to_session); + assert!(!crate::tui::underwater::launch_motion_active( + &app, false, true + )); + crate::tui::underwater::refresh_launch_row_hitboxes( + &mut app, + ratatui::layout::Rect::new(0, 0, 80, 20), + ); + let rows = crate::tui::underwater::launch_rows_for_app(&app); + assert_eq!( + crate::tui::underwater::launch_row_click_action(&rows[0].id), + crate::tui::underwater::LaunchAction::ReturnToSession, + ); + app.launch.menu_selected = Some(0); + assert_eq!( + crate::tui::underwater::handle_launch_composer_key( + &mut app, + KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), + ), + crate::tui::underwater::LaunchComposerKey::MenuRun, + ); + assert_eq!(app.input, "unsent draft"); + crate::tui::underwater::handle_launch_composer_key( + &mut app, + KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), + ); + assert!(!app.launch.visible); + assert!(!app.launch.return_to_session); + assert_eq!(app.current_session_id.as_deref(), Some("retained-session")); + assert_eq!(app.api_messages, messages); + assert_eq!(app.history.len(), history_len); + assert_eq!(app.input, "unsent draft"); + assert_eq!(app.launch.mark_reveal_started_at, reveal); + assert!(!crate::tui::widgets::should_render_empty_state(&app)); + + for command in ["/overview", "/stats"] { + let result = crate::commands::execute(command, &mut app); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Quick Actions")); + assert!(!app.launch.visible); + } + } + + #[test] + fn home_refuses_to_cover_active_work() { + let mut app = crate::tui::test_support::test_app_with_options( + crate::tui::test_support::test_tui_options("."), + ); + app.launch.dismiss(); + app.is_loading = true; + let result = crate::commands::execute("/home", &mut app); + assert!(result.is_error); + assert!(!app.launch.visible); + } +} + +fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { + let input = arg + .map(|arg| format!("/workspace {arg}")) + .unwrap_or_else(|| "/workspace".into()); + crate::commands::execute(&input, app) +} + +#[test] +fn workspace_without_arg_shows_current_workspace() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, None); + let msg = result.message.expect("workspace should be shown"); + assert!(msg.contains("Current workspace:")); + assert!(msg.contains("/tmp/test-workspace")); + assert!(result.action.is_none()); + let home = crate::config::effective_home_dir().expect("hermetic home"); + let result = workspace_switch(&mut app, Some("~")); + assert!( + matches!(result.action, Some(AppAction::SwitchWorkspace { workspace }) if workspace == home.canonicalize().unwrap_or(home.clone())) + ); + let child = tempfile::tempdir_in(&home).unwrap(); + let input = format!("~/{}", child.path().file_name().unwrap().to_str().unwrap()); + let result = workspace_switch(&mut app, Some(&input)); + assert!( + matches!(result.action, Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.path().canonicalize().unwrap()) + ); +} + +#[test] +fn workspace_existing_absolute_dir_returns_switch_action() { + let mut app = create_test_app(); + let dir = tempdir().expect("temp dir"); + let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() + )); +} + +#[test] +fn workspace_relative_dir_resolves_from_current_workspace() { + let root = tempdir().expect("temp dir"); + let child = root.path().join("child"); + std::fs::create_dir(&child).expect("child dir"); + let mut app = create_test_app(); + app.workspace = root.path().to_path_buf(); + + let result = workspace_switch(&mut app, Some("child")); + assert!(matches!( + result.action, + Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() + )); +} + +#[test] +fn workspace_rejects_missing_path() { + let mut app = create_test_app(); + let result = workspace_switch(&mut app, Some("definitely-missing")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); +} + +#[test] +fn workspace_rejects_file_path() { + let root = tempdir().expect("temp dir"); + let file = root.path().join("file.txt"); + std::fs::write(&file, "not a directory").expect("test file"); + let mut app = create_test_app(); + + let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); + assert!(result.is_error); + assert!(result.message.unwrap().contains("not a directory")); +} + +#[cfg(test)] +mod anchor { + use crate::commands::CommandResult; + use crate::config::Config; + use crate::tui::app::{App, TuiOptions}; + use tempfile::TempDir; + + fn anchor(app: &mut App, arg: Option<&str>) -> CommandResult { + let input = arg + .map(|arg| format!("/anchor {arg}")) + .unwrap_or_else(|| "/anchor".into()); + crate::commands::execute(&input, app) + } + + enum AnchorFault { + Read, + Open, + Write, + Replace, + } + + impl codewhale_command_contract::facets::CommandWorkspaceContext for AnchorFault { + fn workspace(&self) -> std::path::PathBuf { + unreachable!("unexpected workspace observation") + } + fn work_state_snapshot(&self) -> Result, String> { + unreachable!("unexpected work observation") + } + fn operation_digest(&mut self) -> Result { + unreachable!("unexpected work observation") + } + fn effective_home_dir(&self) -> Option { + unreachable!("unexpected home observation") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("unexpected path inspection") + } + fn read_anchor_document(&self) -> Result, String> { + match self { + Self::Read => Err("read denied".into()), + Self::Replace => Ok(Some("first\n---\nsecond".into())), + _ => unreachable!("append must not read the document"), + } + } + fn append_anchor_bytes( + &mut self, + parts: &[&[u8]], + ) -> Result + { + use codewhale_command_contract::navigation::AnchorWriteError; + let expected: &[&[u8]] = &[b"\n---\n", b"entry", b"\n"]; + assert_eq!(parts, expected); + match self { + Self::Open => Err(AnchorWriteError::Open("open denied".into())), + Self::Write => Err(AnchorWriteError::Write("partial write".into())), + _ => unreachable!("unexpected append"), + } + } + fn replace_anchor_document(&mut self, bytes: &[u8]) -> Result<(), String> { + assert!(matches!(self, Self::Replace)); + assert_eq!(bytes, b"second"); + Err("replace denied".into()) + } + } + + fn fault_result(fault: &mut AnchorFault, arg: &str) -> CommandResult { + use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; + let CommandHandler::Contextual { handler, .. } = crate::commands::registry() + .get("anchor") + .unwrap() + .contextual_handler() + .unwrap() + else { + panic!("anchor must be contextual"); + }; + handler(CommandContexts::empty().with_workspace(fault), Some(arg)) + } + + fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + skills_dir: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + ..crate::tui::test_support::test_tui_options(tmpdir.path()) + }; + App::new(options, &Config::default()) + } + + #[cfg(unix)] + #[test] + fn confined_anchors_refuse_linked_files_and_directories() { + use std::os::unix::fs::symlink; + + for directory in [".codewhale", ".deepseek"] { + for linked_directory in [false, true] { + let workspace = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&workspace); + let original = "first anchor\n---\nsecond anchor"; + let target = outside.path().join("anchors.md"); + std::fs::write(&target, original).unwrap(); + let parent = workspace.path().join(directory); + if linked_directory { + symlink(outside.path(), &parent).unwrap(); + } else { + std::fs::create_dir_all(&parent).unwrap(); + symlink(&target, parent.join("anchors.md")).unwrap(); + } + for command in ["list", "next anchor", "remove 1"] { + assert!(anchor(&mut app, Some(command)).is_error); + assert_eq!(std::fs::read_to_string(&target).unwrap(), original); + } + // Exercise the truncate path independently of the read refusal. + { + let mut bundle = app.command_contexts(); + let parts = bundle + .contexts( + codewhale_command_contract::handler::CommandCapabilities::WORKSPACE, + ) + .into_parts(); + assert!( + parts + .workspace + .unwrap() + .replace_anchor_document(&[]) + .is_err() + ); + } + assert_eq!(std::fs::read_to_string(&target).unwrap(), original); + } + } + } + + #[cfg(unix)] + #[test] + fn confined_anchors_refuse_dangling_links() { + for directory in [".codewhale", ".deepseek"] { + let workspace = TempDir::new().unwrap(); + let outside = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&workspace); + let target = outside.path().join("missing.md"); + let parent = workspace.path().join(directory); + std::fs::create_dir_all(&parent).unwrap(); + std::os::unix::fs::symlink(&target, parent.join("anchors.md")).unwrap(); + assert!(anchor(&mut app, Some("list")).is_error); + assert!(anchor(&mut app, Some("next anchor")).is_error); + { + let mut bundle = app.command_contexts(); + let parts = bundle + .contexts(codewhale_command_contract::handler::CommandCapabilities::WORKSPACE) + .into_parts(); + assert!( + parts + .workspace + .unwrap() + .replace_anchor_document(&[]) + .is_err() + ); + } + assert!(!target.exists()); + } + } + + #[test] + fn test_anchor_without_content_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, None); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage:")); + } + + #[test] + fn test_anchor_with_empty_content_returns_error() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some(" ")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage:")); + } + + #[test] + fn test_anchor_add() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("API status field is unreliable")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Anchor pinned")); + + let path = tmpdir.path().join(".deepseek").join("anchors.md"); + assert!(path.exists()); + let content = std::fs::read_to_string(&path).unwrap(); + assert!(content.contains("API status field is unreliable")); + for (mut fault, expected) in [ + ( + AnchorFault::Open, + "Error: Failed to open anchors file: open denied", + ), + ( + AnchorFault::Write, + "Error: Failed to write anchor: partial write", + ), + ] { + let result = fault_result(&mut fault, "entry"); + assert!(result.is_error && result.action.is_none()); + assert_eq!(result.message.as_deref(), Some(expected)); + } + } + + #[test] + fn test_anchor_list_empty() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("list")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("No anchors set")); + let result = fault_result(&mut AnchorFault::Read, "list"); + assert!(result.is_error && result.action.is_none()); + assert_eq!( + result.message.as_deref(), + Some("Error: Failed to read anchors file: read denied") + ); + } + + #[test] + fn test_anchor_list_with_items() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("First anchor")); + anchor(&mut app, Some("Second anchor")); + + let result = anchor(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("2 total")); + assert!(msg.contains("1. First anchor")); + assert!(msg.contains("2. Second anchor")); + } + + #[test] + fn test_anchor_remove() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("First anchor")); + anchor(&mut app, Some("Second anchor")); + + let result = anchor(&mut app, Some("remove 1")); + assert!(!result.is_error); + assert!(result.message.unwrap().contains("Removed anchor #1")); + + let result = anchor(&mut app, Some("list")); + let msg = result.message.unwrap(); + assert!(msg.contains("1 total")); + assert!(msg.contains("Second anchor")); + assert!(!msg.contains("First anchor")); + let result = fault_result(&mut AnchorFault::Replace, "remove 1"); + assert!(result.is_error && result.action.is_none()); + assert_eq!( + result.message.as_deref(), + Some("Error: Failed to write anchors file: replace denied") + ); + } + + #[test] + fn test_anchor_remove_invalid_index() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + anchor(&mut app, Some("Only anchor")); + + let result = anchor(&mut app, Some("remove 5")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("does not exist")); + } + + #[test] + fn test_anchor_remove_non_numeric() { + let tmpdir = TempDir::new().unwrap(); + let mut app = create_test_app_with_tmpdir(&tmpdir); + let result = anchor(&mut app, Some("remove abc")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Invalid index")); + } +} diff --git a/crates/tui/src/commands/core_navigation_voice_host.rs b/crates/tui/src/commands/core_navigation_voice_host.rs new file mode 100644 index 0000000000..5f7f24be7e --- /dev/null +++ b/crates/tui/src/commands/core_navigation_voice_host.rs @@ -0,0 +1,221 @@ +//! Host-owned voice capture and builtin shortcut transport. + +use crate::commands::CommandResult; +use crate::commands::groups::core::navigation::voice::VOICE_INFO; +use crate::config::Config; +use crate::tui::app::App; +use crate::voice::{ + is_available, record_audio, resolve_asr_choice, split_send_suffix, transcribe_selected, +}; +use codewhale_localization::{MessageId, tr}; +use std::time::Duration; + +pub fn voice(app: &mut App) -> CommandResult { + super::core_navigation_host::dispatch(app, VOICE_INFO.name, None) +} + +// --- Capture orchestration (UI event loop) --------------------------------- + +/// What the UI should do with a finished capture. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum VoiceCaptureOutcome { + /// Insert the transcribed text into the composer at the cursor. + Insert(String), + /// Submit this text as a message (auto-send). + Send(String), +} + +/// Status line while recording: the localized recording label, the latest +/// interim transcript once one exists, and how to stop. The capture is awaited +/// on the UI loop, so no key can end it — `record_audio` stops after a second +/// of silence (or `MAX_RECORD_SECS`), and the cue says exactly that. +fn recording_status(locale: codewhale_localization::Locale, interim: Option<&str>) -> String { + let label = tr(locale, MessageId::VoiceRecording); + let stop = tr(locale, MessageId::VoiceRecordingStopHint); + match interim.map(str::trim).filter(|text| !text.is_empty()) { + Some(text) => format!("{label} \u{2014} \u{201c}{text}\u{201d} \u{00b7} {stop}"), + None => format!("{label} \u{00b7} {stop}"), + } +} + +/// Perform a complete record + transcribe cycle with live interim display. +/// +/// Runs in the UI event loop (see [`crate::tui::app::AppAction::VoiceCapture`]) so provider +/// credentials come from the live [`Config`] rather than state cached on +/// [`App`]. Recording happens on a blocking thread; transcription uses the +/// shared async HTTP client. Every failure path returns a localized message +/// so callers can surface it as a status line. +pub async fn capture_and_transcribe( + app: &mut App, + config: &Config, +) -> Result { + let locale = app.ui_locale; + + if !is_available() { + return Err(tr(locale, MessageId::VoiceErrNoRecorder).to_string()); + } + // C01-10: the credential check follows the ASR backend that will run, + // not the chat provider. Local Whisper and Groq need no provider key, so + // resolve it lazily — the same contract as `crate::voice::dictate_once` + // — and only refuse before recording when provider ASR is the backend. + let provider_key = || { + config + .active_route_api_key() + .map_err(|_| tr(locale, MessageId::VoiceErrNoAuth).to_string()) + }; + let (asr_kind, _asr_model) = resolve_asr_choice(config); + if asr_kind == "provider" { + provider_key()?; + } + // Show the localized recording status plus the live interim in the composer. + let original_input = app.composer.input.clone(); + let original_cursor = app.composer.cursor_position; + app.status_message = Some(recording_status(locale, None)); + + // Streaming interim: poll every 700ms and show partial transcript like Grok Build's + // VoiceEvent::Interim → VoiceState::Recording{interim}. We re-transcribe the + // growing buffer (local-whisper is cheap; Groq is ~300ms; provider falls back). + let interim_enabled = true; // always show partials — feels alive like Spark + + // Spawn recorder on blocking thread with a shared buffer for interim polling. + let shared_buf: std::sync::Arc>> = + std::sync::Arc::new(parking_lot::Mutex::new(Vec::new())); + let shared_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); + let shared_buf_clone = std::sync::Arc::clone(&shared_buf); + let shared_done_clone = std::sync::Arc::clone(&shared_done); + let recorder_handle = tokio::task::spawn_blocking(move || { + // Bridge to existing record_audio but copy into shared buffer incrementally. + // For now we reuse the blocking recorder and then publish; interim will + // poll the final buffer. A true streaming recorder (cpal/pw-record) is + // the next step — see grokbuild's xai-grok-voice::audio for the subprocess + // isolation pattern we should mirror. + let result = record_audio(); + if let Some((samples, dur)) = result { + *shared_buf_clone.lock() = samples.clone(); + shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); + Some((samples, dur)) + } else { + shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); + None + } + }); + + // Interim polling loop — updates composer with "original + interim ▍" so text + // appears as you talk, just like Spark's live transcript. + let mut last_interim = String::new(); + let mut ticks: u32 = 0; + loop { + tokio::time::sleep(Duration::from_millis(700)).await; + ticks += 1; + if shared_done.load(std::sync::atomic::Ordering::SeqCst) { + break; + } + if !interim_enabled || ticks < 2 { + continue; // let a little audio accumulate before first interim + } + let snapshot = { shared_buf.lock().clone() }; + if snapshot.len() < 8000 { + // <0.5s of audio — not enough for meaningful ASR + continue; + } + // Try cheapest free ASR for interim; don't fail the whole capture on interim error. + let interim = transcribe_selected(config, &asr_kind, &snapshot, None) + .await + .unwrap_or_default(); + let trimmed = interim.trim(); + if !trimmed.is_empty() && trimmed != last_interim { + last_interim = trimmed.to_string(); + // Show interim inline — preserve cursor at original position, append interim with a block cursor + let display = if original_input.trim().is_empty() { + format!("{trimmed} ▍") + } else { + format!("{} {} ▍", original_input.trim_end(), trimmed) + }; + app.composer.input = display; + app.composer.cursor_position = original_cursor; + app.status_message = Some(recording_status(locale, Some(trimmed))); + } + if ticks > 40 { + break; // safety: ~28s max interim polling + } + } + + let (samples, _duration) = recorder_handle + .await + .ok() + .flatten() + .ok_or_else(|| tr(locale, MessageId::VoiceErrTooShort).to_string())?; + + // Restore composer to original before final insert (interim was preview only) + app.composer.input = original_input.clone(); + app.composer.cursor_position = original_cursor; + app.status_message = Some(tr(locale, MessageId::VoiceProcessing).to_string()); + + let text = transcribe_selected( + config, + &asr_kind, + &samples, + app.voice_control_enabled.then_some(original_input.as_str()), + ) + .await + .map_err(|error| format!("{}: {error}", tr(locale, MessageId::VoiceErrNetwork)))?; + + let clean = text.trim(); + if app.voice_send_enabled { + let (remainder, wants_send) = split_send_suffix(clean); + if wants_send { + // A bare "send it" submits whatever is already in the composer. + let outgoing = if remainder.is_empty() { + let existing = app.composer.input.trim().to_string(); + if !existing.is_empty() { + app.clear_input(); + } + existing + } else { + remainder.to_string() + }; + if outgoing.is_empty() { + return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); + } + return Ok(VoiceCaptureOutcome::Send(outgoing)); + } + } + if clean.is_empty() { + return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); + } + Ok(VoiceCaptureOutcome::Insert(clean.to_string())) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn recording_status_is_localized_and_keeps_the_interim() { + use codewhale_localization::Locale; + + for locale in [Locale::En, Locale::De, Locale::Ja] { + let label = tr(locale, MessageId::VoiceRecording).to_string(); + let stop = tr(locale, MessageId::VoiceRecordingStopHint).to_string(); + let idle = format!("{label} \u{00b7} {stop}"); + assert_eq!(recording_status(locale, None), idle); + assert_eq!(recording_status(locale, Some(" ")), idle); + + let with_interim = recording_status(locale, Some(" hello there ")); + assert!(with_interim.starts_with(&label), "{with_interim}"); + assert!(with_interim.contains("\u{201c}hello there\u{201d}")); + assert!( + with_interim.ends_with(&stop), + "the stop cue survives the interim: {with_interim}" + ); + assert!(!with_interim.contains("\u{2325}V"), "no hardcoded key hint"); + if locale != Locale::En { + assert!(!with_interim.contains("to finish"), "no English hint"); + } + } + assert_ne!( + recording_status(Locale::En, None), + recording_status(Locale::De, None) + ); + } +} diff --git a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs index 74c0fa8ae7..ef6e091914 100644 --- a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs +++ b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs @@ -258,6 +258,8 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { trust, login, work_surface, + navigation, + voice_input, } = parts; assert!( permissions.is_none() @@ -267,6 +269,8 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() + && voice_input.is_none() ); assert!(debug_diagnostics.is_some(), "/{spelling} needs diagnostics"); assert_eq!( diff --git a/crates/tui/src/commands/debug_mutation_host_tests.rs b/crates/tui/src/commands/debug_mutation_host_tests.rs index 649b40b694..d243a3701a 100644 --- a/crates/tui/src/commands/debug_mutation_host_tests.rs +++ b/crates/tui/src/commands/debug_mutation_host_tests.rs @@ -1646,6 +1646,8 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { trust, login, work_surface, + navigation, + voice_input, } = bundle.contexts(capabilities).into_parts(); assert!( permissions.is_none() @@ -1655,6 +1657,8 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() + && voice_input.is_none() ); for (name, present, capability) in [ ("session", session.is_some(), Caps::SESSION), diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 6056c8ae70..1d590aa1f2 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -1564,6 +1564,12 @@ mod tests { struct PanicConfig; impl CommandConfigContext for PanicConfig { + fn translation_enabled(&self) -> bool { + panic!("config facet touched") + } + fn set_translation_enabled(&mut self, _: bool) { + panic!("config facet touched") + } fn config_path(&self) -> Option { panic!("config facet touched") } diff --git a/crates/tui/src/commands/groups/core/anchor.rs b/crates/tui/src/commands/groups/core/anchor.rs index 4465152c1e..7a41a2cfcf 100644 --- a/crates/tui/src/commands/groups/core/anchor.rs +++ b/crates/tui/src/commands/groups/core/anchor.rs @@ -5,327 +5,124 @@ //! preserve invariants like "This API's status field is unreliable" or //! ".ssh/ must never be touched". -use std::io::Write; - -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::facets::CommandWorkspaceContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::AnchorWriteError; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; const USAGE: &str = "/anchor | /anchor list | /anchor remove "; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "anchor", aliases: &["maodian"], usage: USAGE, - description_id: MessageId::CmdAnchorDescription, + description_key: "cmd_anchor_description", }; -pub(in crate::commands) struct AnchorCmd; +pub struct AnchorCmd; -impl RegisterCommand for AnchorCmd { +impl RegisterCommand for AnchorCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - anchor(app, arg) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::WORKSPACE, + handler: anchor, + } } } -/// Handle the `/anchor` command with subcommands: -/// - `/anchor ` — add a new anchor -/// - `/anchor list` — list all anchors -/// - `/anchor remove ` — remove anchor by 1-based index -pub fn anchor(app: &mut App, content: Option<&str>) -> CommandResult { +fn anchor(contexts: CommandContexts<'_>, content: Option<&str>) -> CommandResult { let input = match content { - Some(c) => c.trim(), - None => { - return CommandResult::error(format!("Usage: {USAGE}")); - } + Some(content) => content.trim(), + None => return CommandResult::error(format!("Usage: {USAGE}")), }; - if input.is_empty() { return CommandResult::error(format!("Usage: {USAGE}")); } - - // Parse subcommands. + let Some(workspace) = contexts.into_parts().workspace else { + return CommandResult::error("Command capability unavailable: workspace"); + }; if input.eq_ignore_ascii_case("list") { - return list_anchors(app); + return list_anchors(workspace); } - if let Some(rest) = input .strip_prefix("remove ") .or_else(|| input.strip_prefix("rm ")) .or_else(|| input.strip_prefix("delete ")) { - return remove_anchor(app, rest.trim()); + return remove_anchor(workspace, rest.trim()); } - - // Default: add a new anchor. - add_anchor(app, input) -} - -fn anchors_path(app: &App) -> std::path::PathBuf { - let primary = app.workspace.join(".codewhale").join("anchors.md"); - if primary.symlink_metadata().is_ok() || app.workspace.join(".codewhale").is_symlink() { - return primary; + let parts: &[&[u8]] = &[b"\n---\n", input.as_bytes(), b"\n"]; + match workspace.append_anchor_bytes(parts) { + Ok(path) => { + CommandResult::message(format!("Anchor pinned.\nStored in: {}", path.display())) + } + Err(AnchorWriteError::Open(error)) => { + CommandResult::error(format!("Failed to open anchors file: {error}")) + } + Err(AnchorWriteError::Write(error)) => { + CommandResult::error(format!("Failed to write anchor: {error}")) + } } - app.workspace.join(".deepseek").join("anchors.md") } -/// Read and split anchors from the file. Each anchor is separated by "\n---\n". -fn read_anchors(app: &App) -> Result, String> { - let path = anchors_path(app); - let content = match crate::fs_confined::read_to_string(&app.workspace, &path) { - Ok(c) => c, - Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()), - Err(e) => return Err(format!("Failed to read anchors file: {e}")), +fn read_anchors(workspace: &dyn CommandWorkspaceContext) -> Result, String> { + let Some(content) = workspace + .read_anchor_document() + .map_err(|error| format!("Failed to read anchors file: {error}"))? + else { + return Ok(Vec::new()); }; - Ok(content .split("\n---\n") - .map(|s| s.trim().to_string()) - .filter(|s| !s.is_empty()) + .map(|text| text.trim().to_string()) + .filter(|text| !text.is_empty()) .collect()) } -/// Write anchors back to the file, joined by "\n---\n". -fn write_anchors(app: &App, anchors: &[String]) -> Result<(), String> { - let path = anchors_path(app); - - let content = anchors.join("\n---\n"); - crate::fs_confined::write(&app.workspace, &path, content.as_bytes()) - .map_err(|e| format!("Failed to write anchors file: {e}")) -} - -fn add_anchor(app: &mut App, text: &str) -> CommandResult { - let path = anchors_path(app); - - let mut file = match crate::fs_confined::open_append(&app.workspace, &path) { - Ok(f) => f, - Err(e) => { - return CommandResult::error(format!("Failed to open anchors file: {e}")); - } - }; - - // Write separator and anchor content. - if let Err(e) = writeln!(file, "\n---\n{text}") { - return CommandResult::error(format!("Failed to write anchor: {e}")); - } - - CommandResult::message(format!("Anchor pinned.\nStored in: {}", path.display())) -} - -fn list_anchors(app: &App) -> CommandResult { - let anchors = match read_anchors(app) { +fn list_anchors(workspace: &dyn CommandWorkspaceContext) -> CommandResult { + let anchors = match read_anchors(workspace) { Ok(anchors) => anchors, - Err(e) => return CommandResult::error(e), + Err(error) => return CommandResult::error(error), }; - if anchors.is_empty() { return CommandResult::message( "No anchors set. Use /anchor to pin a fact that survives compaction.", ); } - let mut output = format!("Pinned anchors ({} total):\n", anchors.len()); - for (i, anchor) in anchors.iter().enumerate() { - output.push_str(&format!("\n {}. {}", i + 1, anchor)); + for (index, anchor) in anchors.iter().enumerate() { + output.push_str(&format!("\n {}. {}", index + 1, anchor)); } output.push_str("\n\nUse /anchor remove to remove an anchor."); - CommandResult::message(output) } -fn remove_anchor(app: &mut App, index_str: &str) -> CommandResult { +fn remove_anchor(workspace: &mut dyn CommandWorkspaceContext, index_str: &str) -> CommandResult { let index: usize = match index_str.parse() { - Ok(n) if n >= 1 => n, + Ok(index) if index >= 1 => index, _ => { return CommandResult::error( "Invalid index. Use /anchor list to see anchor numbers, then /anchor remove .", ); } }; - - let mut anchors = match read_anchors(app) { + let mut anchors = match read_anchors(workspace) { Ok(anchors) => anchors, - Err(e) => return CommandResult::error(e), + Err(error) => return CommandResult::error(error), }; - if index > anchors.len() { return CommandResult::error(format!( "Anchor #{index} does not exist. You have {} anchor(s). Use /anchor list to see them.", anchors.len() )); } - let removed = anchors.remove(index - 1); - if let Err(e) = write_anchors(app, &anchors) { - return CommandResult::error(e); + if let Err(error) = workspace.replace_anchor_document(anchors.join("\n---\n").as_bytes()) { + return CommandResult::error(format!("Failed to write anchors file: {error}")); } - CommandResult::message(format!("Removed anchor #{index}: {removed}")) } - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use tempfile::TempDir; - - fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { - let options = TuiOptions { - skills_dir: tmpdir.path().join("skills"), - memory_path: tmpdir.path().join("memory.md"), - notes_path: tmpdir.path().join("notes.txt"), - mcp_config_path: tmpdir.path().join("mcp.json"), - ..crate::tui::test_support::test_tui_options(tmpdir.path()) - }; - App::new(options, &Config::default()) - } - - #[cfg(unix)] - #[test] - fn confined_anchors_refuse_linked_files_and_directories() { - use std::os::unix::fs::symlink; - - for directory in [".codewhale", ".deepseek"] { - for linked_directory in [false, true] { - let workspace = TempDir::new().unwrap(); - let outside = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&workspace); - let original = "first anchor\n---\nsecond anchor"; - let target = outside.path().join("anchors.md"); - std::fs::write(&target, original).unwrap(); - let parent = workspace.path().join(directory); - if linked_directory { - symlink(outside.path(), &parent).unwrap(); - } else { - std::fs::create_dir_all(&parent).unwrap(); - symlink(&target, parent.join("anchors.md")).unwrap(); - } - for command in ["list", "next anchor", "remove 1"] { - assert!(anchor(&mut app, Some(command)).is_error); - assert_eq!(std::fs::read_to_string(&target).unwrap(), original); - } - // Exercise the truncate path independently of the read refusal. - assert!(write_anchors(&app, &[]).is_err()); - assert_eq!(std::fs::read_to_string(&target).unwrap(), original); - } - } - } - - #[cfg(unix)] - #[test] - fn confined_anchors_refuse_dangling_links() { - for directory in [".codewhale", ".deepseek"] { - let workspace = TempDir::new().unwrap(); - let outside = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&workspace); - let target = outside.path().join("missing.md"); - let parent = workspace.path().join(directory); - std::fs::create_dir_all(&parent).unwrap(); - std::os::unix::fs::symlink(&target, parent.join("anchors.md")).unwrap(); - assert!(anchor(&mut app, Some("list")).is_error); - assert!(anchor(&mut app, Some("next anchor")).is_error); - assert!(write_anchors(&app, &[]).is_err()); - assert!(!target.exists()); - } - } - - #[test] - fn test_anchor_without_content_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, None); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Usage:")); - } - - #[test] - fn test_anchor_with_empty_content_returns_error() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some(" ")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Usage:")); - } - - #[test] - fn test_anchor_add() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some("API status field is unreliable")); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("Anchor pinned")); - - let path = tmpdir.path().join(".deepseek").join("anchors.md"); - assert!(path.exists()); - let content = std::fs::read_to_string(&path).unwrap(); - assert!(content.contains("API status field is unreliable")); - } - - #[test] - fn test_anchor_list_empty() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some("list")); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("No anchors set")); - } - - #[test] - fn test_anchor_list_with_items() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - anchor(&mut app, Some("First anchor")); - anchor(&mut app, Some("Second anchor")); - - let result = anchor(&mut app, Some("list")); - let msg = result.message.unwrap(); - assert!(msg.contains("2 total")); - assert!(msg.contains("1. First anchor")); - assert!(msg.contains("2. Second anchor")); - } - - #[test] - fn test_anchor_remove() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - anchor(&mut app, Some("First anchor")); - anchor(&mut app, Some("Second anchor")); - - let result = anchor(&mut app, Some("remove 1")); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("Removed anchor #1")); - - let result = anchor(&mut app, Some("list")); - let msg = result.message.unwrap(); - assert!(msg.contains("1 total")); - assert!(msg.contains("Second anchor")); - assert!(!msg.contains("First anchor")); - } - - #[test] - fn test_anchor_remove_invalid_index() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - anchor(&mut app, Some("Only anchor")); - - let result = anchor(&mut app, Some("remove 5")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("does not exist")); - } - - #[test] - fn test_anchor_remove_non_numeric() { - let tmpdir = TempDir::new().unwrap(); - let mut app = create_test_app_with_tmpdir(&tmpdir); - let result = anchor(&mut app, Some("remove abc")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Invalid index")); - } -} diff --git a/crates/tui/src/commands/groups/core/core.rs b/crates/tui/src/commands/groups/core/core.rs index 342b6feb17..08257bd37b 100644 --- a/crates/tui/src/commands/groups/core/core.rs +++ b/crates/tui/src/commands/groups/core/core.rs @@ -1,152 +1,15 @@ -//! Core commands: help, clear, exit, model +//! Remaining legacy core commands: clear, exit, model and related interactions. -use std::fmt::Write; -use std::path::PathBuf; - -use crate::config::{ - DEFAULT_KIMI_CODE_BASE_URL, KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL, ProviderKind, - normalize_custom_model_id, normalize_model_name_for_provider, -}; +use crate::config::{ProviderKind, normalize_custom_model_id, normalize_model_name_for_provider}; #[cfg(test)] use crate::reasoning_preference::ReasoningEffort; -use crate::tui::app::{App, AppAction, AppModeUi}; -use crate::tui::views::{HelpView, ModalKind, SubAgentsView, subagent_view_agents}; -use codewhale_config::AppMode; -use codewhale_localization::{Locale, MessageId, tr}; +use crate::tui::app::{App, AppAction}; +use crate::tui::views::{ModalKind, SubAgentsView, subagent_view_agents}; +use codewhale_localization::Locale; +use codewhale_localization::{MessageId, tr}; use super::CommandResult; -/// Show help information -pub fn help(app: &mut App, topic: Option<&str>) -> CommandResult { - if let Some(topic) = topic { - let user_commands = - crate::commands::user_registry::with_registry_for_app(app, Clone::clone); - if let Some(command) = user_commands.get(topic) { - return CommandResult::message(user_command_help(app.ui_locale, command)); - } - - // Show help for specific command - if let Some(cmd) = crate::commands::get_command_info(topic) { - let mut help = format!( - "{}\n\n {}\n\n {} {}", - cmd.name, - cmd.description_for(app.ui_locale), - tr(app.ui_locale, MessageId::HelpUsageLabel), - cmd.usage - ); - let visible_aliases = cmd - .aliases - .iter() - .filter(|alias| user_commands.get(alias).is_none()) - .copied() - .collect::>(); - if !visible_aliases.is_empty() { - let _ = write!( - help, - "\n {} {}", - tr(app.ui_locale, MessageId::HelpAliasesLabel), - visible_aliases.join(", ") - ); - } - if cmd.name == "config" { - help.push_str( - "\n\n Provider context window: set `context_window = 262144` under the active `[providers.]` table to cap a 1M model to 256K. Use `/config context_window` to inspect the configured and effective values.", - ); - help.push('\n'); - help.push_str(&tr(app.ui_locale, MessageId::ConfigHelpDiscoverable)); - } - return CommandResult::message(help); - } - - // Skills are user-invocable but were never a `/help` topic (#3912): - // they execute and autocomplete, yet the surface that teaches the - // product claimed they did not exist. - if let Some(help) = skill_help(app, topic) { - return CommandResult::message(help); - } - - return CommandResult::error( - tr(app.ui_locale, MessageId::HelpUnknownCommand).replace("{topic}", topic), - ); - } - - // Show help overlay - if app.view_stack.top_kind() != Some(ModalKind::Help) { - let help = HelpView::new_for_app(app, false).with_groups_expanded(app.help_expand_groups); - app.view_stack.push(help); - } - CommandResult::ok() -} - -/// `/help ` for a discovered skill (#3912). -/// -/// Matches the cached skill list case-insensitively, accepting the bare name -/// as well as either invocation shape the dispatcher supports, so `/help -/// $review` and `/help /skill review` resolve the same as `/help review`. -fn skill_help(app: &App, topic: &str) -> Option { - let needle = topic - .trim() - .trim_start_matches('$') - .trim_start_matches('/') - .trim_start_matches("skill ") - .trim(); - if needle.is_empty() { - return None; - } - - let (name, description) = app - .cached_skills - .iter() - .find(|(name, _)| name.eq_ignore_ascii_case(needle))?; - - let mut help = format!("${name}"); - if !description.trim().is_empty() { - let _ = write!(help, "\n\n {}", description.trim()); - } - // Both shapes are advertised because both dispatch (`commands::execute`). - let _ = write!( - help, - "\n\n {} ${name}\n {} /skill {name}", - tr(app.ui_locale, MessageId::HelpUsageLabel), - tr(app.ui_locale, MessageId::HelpUsageLabel), - ); - Some(help) -} - -fn user_command_help( - locale: Locale, - command: &crate::commands::user_registry::UserCommandMetadata, -) -> String { - let mut help = command.name.clone(); - if let Some(description) = command - .description - .as_deref() - .filter(|description| !description.trim().is_empty()) - { - let _ = write!(help, "\n\n {description}"); - } - - let usage = command - .display_usage() - .map(str::to_owned) - .unwrap_or_else(|| format!("/{}", command.name)); - let _ = write!( - help, - "\n\n {} {}", - tr(locale, MessageId::HelpUsageLabel), - usage - ); - if !command.aliases.is_empty() { - let _ = write!( - help, - "\n {} {}", - tr(locale, MessageId::HelpAliasesLabel), - command.aliases.join(", ") - ); - } - help -} - /// Clear conversation history pub fn clear(app: &mut App) -> CommandResult { if app.session_transition_blocked() { @@ -278,6 +141,15 @@ pub(crate) fn reset_conversation_state(app: &mut App) -> bool { true } +/// The site locale segment for a public Codewhale URL. +/// +/// The mapping itself lives in the portable `navigation::links` module, which +/// the navigation proof requires to stay free of host imports; this is the +/// host-side adapter for callers that hold a `Locale`. +pub(crate) fn public_site_locale_segment(locale: Locale) -> &'static str { + super::navigation::links::public_site_locale_segment(locale.tag()) +} + /// Exit the application pub fn exit() -> CommandResult { CommandResult::action(AppAction::Quit) @@ -493,296 +365,6 @@ pub fn profile_switch(app: &mut App, arg: Option<&str>) -> CommandResult { ) } -pub fn workspace_switch(app: &mut App, arg: Option<&str>) -> CommandResult { - let locale = app.ui_locale; - let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { - return CommandResult::message( - tr(locale, MessageId::WorkspaceCurrent) - .replace("{path}", &app.workspace.display().to_string()), - ); - }; - - let expanded = match expand_workspace_path(locale, raw_path) { - Ok(path) => path, - Err(message) => return CommandResult::error(message), - }; - let candidate = if expanded.is_absolute() { - expanded - } else { - app.workspace.join(expanded) - }; - - if !candidate.exists() { - return CommandResult::error( - tr(locale, MessageId::WorkspaceNotFound) - .replace("{path}", &candidate.display().to_string()), - ); - } - if !candidate.is_dir() { - return CommandResult::error( - tr(locale, MessageId::WorkspaceNotDirectory) - .replace("{path}", &candidate.display().to_string()), - ); - } - - let workspace = candidate.canonicalize().unwrap_or(candidate); - CommandResult::with_message_and_action( - tr(locale, MessageId::WorkspaceSwitching) - .replace("{path}", &workspace.display().to_string()), - AppAction::SwitchWorkspace { workspace }, - ) -} - -fn expand_workspace_path(locale: Locale, path: &str) -> Result { - let unresolved = || tr(locale, MessageId::WorkspaceHomeUnresolved).into_owned(); - if path == "~" { - return crate::config::effective_home_dir().ok_or_else(unresolved); - } - if let Some(rest) = path.strip_prefix("~/") { - let home = crate::config::effective_home_dir().ok_or_else(unresolved)?; - return Ok(home.join(rest)); - } - Ok(PathBuf::from(path)) -} - -pub(crate) fn public_site_locale_segment(locale: Locale) -> &'static str { - match locale { - Locale::ZhHans | Locale::ZhHant => "zh", - Locale::Ja => "ja", - Locale::Vi => "vi", - Locale::Ko => "ko", - Locale::Es419 => "es", - Locale::PtBr => "pt-BR", - Locale::Ru => "ru", - Locale::Uk => "uk", - // Not shipped on the website yet — English pages are the fallback. - Locale::En | Locale::Ca | Locale::De | Locale::Fr | Locale::Id | Locale::Hi => "en", - } -} - -/// Show Codewhale documentation, community, managed-app, and provider links. -pub fn codewhale_links(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - let active_provider = app.api_provider.as_str(); - let site_locale = public_site_locale_segment(locale); - let mut message = format!( - "{}\n─────────────────────────────\n", - tr(locale, MessageId::LinksProjectTitle) - ); - - let _ = writeln!( - message, - "{} `https://codewhale.net/{site_locale}/docs`", - tr(locale, MessageId::LinksDocumentation) - ); - let _ = writeln!( - message, - "{} `https://codewhale.net/{site_locale}/community`", - tr(locale, MessageId::LinksCommunity) - ); - let _ = writeln!( - message, - "{} `https://github.com/codewhale-hq/CodeWhale`", - tr(locale, MessageId::LinksGitHub) - ); - let _ = writeln!( - message, - "{} `https://app.codewhale.net`", - tr(locale, MessageId::LinksManagedApp) - ); - let _ = writeln!(message, "{}", tr(locale, MessageId::LinksManagedAppNote)); - - let _ = write!( - message, - "\n{}\n─────────────────────────────\n", - tr(locale, MessageId::LinksTitle) - ); - - for provider in codewhale_config::provider::providers_sorted_for_display() { - let links = provider.credential_help(); - let active_marker = if provider.id() == active_provider { - " <- current" - } else { - "" - }; - let _ = writeln!( - message, - "\n{} ({}){}", - provider.display_name(), - provider.id(), - active_marker - ); - if let Some(key_url) = links.credential_url { - let _ = writeln!( - message, - "{} `{}`", - tr(locale, MessageId::LinksDashboard), - key_url - ); - } else { - let _ = writeln!( - message, - "{} {}", - tr(locale, MessageId::LinksDashboard), - links.guidance - ); - } - if let Some(docs_url) = links.docs_url { - let _ = writeln!( - message, - "{} `{}`", - tr(locale, MessageId::LinksDocs), - docs_url - ); - } - if provider.kind() == codewhale_config::ProviderKind::Moonshot { - let _ = writeln!( - message, - "{}", - tr(locale, MessageId::LinksKimiCodeRouteNote) - .replace("{route}", DEFAULT_KIMI_CODE_BASE_URL) - .replace("{console}", KIMI_CODE_MEMBERSHIP_PLAN_CONSOLE_URL) - ); - } - let env_vars = provider.env_vars(); - if env_vars.is_empty() { - let _ = writeln!(message, "Env: none"); - } else { - let _ = writeln!(message, "Env: {}", env_vars.join(", ")); - } - } - - let _ = writeln!(message, "\n{}", tr(locale, MessageId::LinksTip)); - CommandResult::message(message) -} - -/// Show home dashboard with stats and quick actions -pub fn home_dashboard(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - let mut stats = String::new(); - - // Basic info - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeDashboardTitle)); - let _ = writeln!(stats, "============================================"); - - // Model & mode - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeModel), - app.model - ); - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeMode), - app.mode.display_name_localized(locale) - ); - let _ = writeln!( - stats, - "{} {}", - tr(locale, MessageId::HomeWorkspace), - app.workspace.display() - ); - - // Session stats - let history_count = app.history.len(); - let total_tokens = app.session.displayed_total_conversation_tokens(); - let queued_messages = app.queued_messages.len(); - let _ = writeln!( - stats, - "{} {} messages", - tr(locale, MessageId::HomeHistory), - history_count - ); - let _ = writeln!( - stats, - "{} {} (session)", - tr(locale, MessageId::HomeTokens), - total_tokens - ); - if queued_messages > 0 { - let _ = writeln!( - stats, - "{} {} messages", - tr(locale, MessageId::HomeQueued), - queued_messages - ); - } - - // Fleet role workers - let subagent_count = app.subagent_cache.len(); - if subagent_count > 0 { - let _ = writeln!( - stats, - "{} {} active", - tr(locale, MessageId::HomeSubagents), - subagent_count - ); - } - - // Active skill - if let Some(skill) = &app.active_skill { - let _ = writeln!( - stats, - "{} {} (active)", - tr(locale, MessageId::HomeSkill), - skill - ); - } - - // Quick actions section - let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeQuickActions)); - let _ = writeln!(stats, "--------------------------------------------"); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickWorkspace)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickRestore)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTokens)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickLinks)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSkills)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickConfig)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSettings)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickModel)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickSubagents)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickTaskList)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeQuickHelp)); - - // Mode-specific tips - let _ = writeln!(stats, "\n{}", tr(locale, MessageId::HomeModeTips)); - let _ = writeln!(stats, "--------------------------------------------"); - match app.mode { - AppMode::Agent => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeReviewTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeAgentModeYoloTip)); - } - AppMode::Operate => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeOperateModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomeOperateModeFleetTip)); - } - AppMode::Plan => { - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeTip)); - let _ = writeln!(stats, "{}", tr(locale, MessageId::HomePlanModeChecklistTip)); - } - } - - CommandResult::message(stats) -} - -/// Toggle output translation to the current system language on/off. -/// -/// When enabled, the model is instructed to respond in the current locale and an -/// interception layer translates any remaining English output before it -/// reaches the user. -pub fn translate(app: &mut App) -> CommandResult { - app.translation_enabled = !app.translation_enabled; - let locale = app.ui_locale; - if app.translation_enabled { - CommandResult::message(tr(locale, MessageId::CmdTranslateOn)) - } else { - CommandResult::message(tr(locale, MessageId::CmdTranslateOff)) - } -} - #[cfg(test)] mod tests { use super::*; @@ -790,56 +372,12 @@ mod tests { use crate::config::Config; use crate::tui::app::{App, TuiOptions, TurnCacheRecord}; use crate::tui::history::HistoryCell; - use codewhale_config::AppMode; use codewhale_models::Message; use codewhale_models::Role; use std::ffi::OsString; use std::path::PathBuf; use std::time::Instant; - use tempfile::{TempDir, tempdir}; - - #[test] - fn help_topic_resolves_a_discovered_skill_and_both_shapes() { - // #3912: `/help ` said "Unknown command" for a skill that - // executes and autocompletes. - let mut app = create_test_app(); - app.cached_skills = vec![( - "codereview".to_string(), - "Review a diff for defects".to_string(), - )]; - - for topic in [ - "codereview", - "CodeReview", - "$codereview", - "/skill codereview", - ] { - let result = help(&mut app, Some(topic)); - let message = result - .message - .as_deref() - .unwrap_or_else(|| panic!("{topic} should resolve to skill help")); - assert!(message.contains("Review a diff for defects"), "{message}"); - assert!(message.contains("$codereview"), "{message}"); - assert!(message.contains("/skill codereview"), "{message}"); - } - } - - #[test] - fn help_topic_still_errors_for_an_unknown_name() { - let mut app = create_test_app(); - app.cached_skills = vec![("codereview".to_string(), "Review a diff".to_string())]; - let result = help(&mut app, Some("definitely-not-a-thing")); - assert!(result.is_error, "unknown topics must still be an error"); - assert!( - !result - .message - .as_deref() - .unwrap_or_default() - .contains("Review a diff"), - "the skill fallback must not match an unrelated topic" - ); - } + use tempfile::TempDir; struct SettingsPathGuard { _tmp: TempDir, @@ -939,83 +477,6 @@ mod tests { } } - #[test] - fn test_help_unknown_command() { - let mut app = create_test_app(); - let result = help(&mut app, Some("nonexistent")); - assert!(result.message.is_some()); - assert!(result.message.unwrap().contains("Unknown command")); - assert!(result.action.is_none()); - } - - #[test] - fn test_help_known_command() { - let mut app = create_test_app(); - let result = help(&mut app, Some("clear")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("clear")); - assert!(msg.contains("Clear conversation history")); - assert!(msg.contains("Usage: /clear")); - } - - #[test] - fn test_help_config_topic_uses_interactive_editor_text() { - let mut app = create_test_app(); - let result = help(&mut app, Some("config")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("config")); - assert!(msg.contains("Inspect and change settings")); - assert!(msg.contains("Usage: /config")); - assert!(msg.contains("context_window = 262144")); - assert!(msg.contains("/config context_window")); - assert!(msg.contains("/config search.provider")); - assert!(msg.contains("/config prompt_suggestion")); - assert!(msg.contains("/config notifications")); - } - - #[test] - fn test_help_links_topic_shows_aliases() { - let mut app = create_test_app(); - let result = help(&mut app, Some("links")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("links")); - assert!(msg.contains("Show Codewhale, community, and provider links")); - assert!(msg.contains("Usage: /links")); - assert!(msg.contains("Aliases: dashboard, api")); - } - - #[test] - fn test_help_memory_topic_shows_usage_and_description() { - let mut app = create_test_app(); - let result = help(&mut app, Some("memory")); - let msg = result.message.expect("help topic should return message"); - assert!(msg.contains("memory")); - assert!(msg.contains("persistent structured user memory")); - assert!(msg.contains( - "Usage: /memory [status|path|search|get|remember|import|export|reindex|clear|help]" - )); - } - - #[test] - fn test_help_pushes_overlay() { - let mut app = create_test_app(); - assert_ne!(app.view_stack.top_kind(), Some(ModalKind::Help)); - let result = help(&mut app, None); - assert_eq!(result.message, None); - assert_eq!(result.action, None); - assert_eq!(app.view_stack.top_kind(), Some(ModalKind::Help)); - } - - #[test] - fn test_help_does_not_duplicate_overlay() { - let mut app = create_test_app(); - help(&mut app, None); - let initial_kind = app.view_stack.top_kind(); - help(&mut app, None); - assert_eq!(app.view_stack.top_kind(), initial_kind); - } - #[test] fn test_clear_resets_all_state() { let mut app = create_test_app(); @@ -1196,62 +657,6 @@ mod tests { assert!(matches!(result.action, Some(AppAction::Quit))); } - #[test] - fn workspace_without_arg_shows_current_workspace() { - let mut app = create_test_app(); - let result = workspace_switch(&mut app, None); - let msg = result.message.expect("workspace should be shown"); - assert!(msg.contains("Current workspace:")); - assert!(msg.contains("/tmp/test-workspace")); - assert!(result.action.is_none()); - } - - #[test] - fn workspace_existing_absolute_dir_returns_switch_action() { - let mut app = create_test_app(); - let dir = tempdir().expect("temp dir"); - let result = workspace_switch(&mut app, Some(dir.path().to_str().unwrap())); - assert!(matches!( - result.action, - Some(AppAction::SwitchWorkspace { workspace }) if workspace == dir.path().canonicalize().unwrap() - )); - } - - #[test] - fn workspace_relative_dir_resolves_from_current_workspace() { - let root = tempdir().expect("temp dir"); - let child = root.path().join("child"); - std::fs::create_dir(&child).expect("child dir"); - let mut app = create_test_app(); - app.workspace = root.path().to_path_buf(); - - let result = workspace_switch(&mut app, Some("child")); - assert!(matches!( - result.action, - Some(AppAction::SwitchWorkspace { workspace }) if workspace == child.canonicalize().unwrap() - )); - } - - #[test] - fn workspace_rejects_missing_path() { - let mut app = create_test_app(); - let result = workspace_switch(&mut app, Some("definitely-missing")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("does not exist")); - } - - #[test] - fn workspace_rejects_file_path() { - let root = tempdir().expect("temp dir"); - let file = root.path().join("file.txt"); - std::fs::write(&file, "not a directory").expect("test file"); - let mut app = create_test_app(); - - let result = workspace_switch(&mut app, Some(file.to_str().unwrap())); - assert!(result.is_error); - assert!(result.message.unwrap().contains("not a directory")); - } - #[test] fn test_model_change_updates_state() { let _settings = SettingsPathGuard::new(); @@ -1721,73 +1126,6 @@ mod tests { ); } - #[test] - fn test_codewhale_links() { - let mut app = create_test_app(); - let result = codewhale_links(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Codewhale & community")); - assert!(msg.contains("https://codewhale.net/en/docs")); - assert!(msg.contains("https://codewhale.net/en/community")); - assert!(msg.contains("https://github.com/codewhale-hq/CodeWhale")); - assert!(msg.contains("https://app.codewhale.net")); - assert!(msg.contains("separate sign-in")); - assert!(msg.contains("not connected to the current local session")); - assert!(msg.contains("Provider Links")); - assert!(msg.contains("DeepSeek (deepseek) <- current")); - assert!(msg.contains("https://platform.deepseek.com/api_keys")); - assert!(msg.contains("Xiaomi MiMo (xiaomi-mimo)")); - assert!(msg.contains("https://platform.xiaomimimo.com/token-plan")); - assert!(msg.contains("Moonshot/Kimi (moonshot)")); - assert!(msg.contains("https://platform.kimi.ai/console/api-keys")); - assert!(msg.contains("https://platform.kimi.ai/docs/overview")); - assert!(msg.contains("https://api.kimi.com/coding/v1")); - assert!(msg.contains("https://www.kimi.com/code/console")); - assert!(msg.contains("never imports Kimi CLI credentials")); - assert!(msg.contains("https://console.openmodel.ai/")); - assert!(msg.contains("https://docs.openmodel.ai/en/docs/getting-started/authentication")); - assert!(msg.contains("https://console.sakana.ai/api-keys")); - assert!(msg.contains("https://console.sakana.ai/get-started")); - assert!(msg.contains("Baidu Qianfan (qianfan)")); - assert!(msg.contains("https://cloud.baidu.com/doc/qianfan/index.html")); - assert!(msg.contains("Local Ollama is keyless by default")); - assert!(msg.contains("codewhale auth chatgpt")); - assert!( - !msg.contains("codex login"), - "official sign-in must not direct users to external CLI credentials" - ); - assert!(msg.contains("no canonical vendor credential page exists")); - assert!(msg.contains("OPENAI_API_KEY")); - assert!(msg.contains("XIAOMI_MIMO_TOKEN_PLAN_API_KEY")); - assert!(!msg.contains("https://codewhale.dev/docs/providers")); - assert!(result.action.is_none()); - } - - #[test] - fn provider_links_emit_urls_as_inline_code_for_narrow_transcripts() { - let mut app = create_test_app(); - let result = codewhale_links(&mut app); - let msg = result.message.expect("links should return a message"); - - assert!(msg.contains("`https://platform.openai.com/api-keys`")); - assert!( - msg.contains( - "`https://platform.minimax.io/user-center/basic-information/interface-key`" - ) - ); - - for line in msg.lines().filter(|line| line.contains("http")) { - let Some(url_start) = line.find("http") else { - continue; - }; - assert!( - line[..url_start].ends_with('`') && line[url_start..].contains('`'), - "provider URL should be inline-code wrapped so narrow TUI renders do not emit oversized OSC8 link payloads: {line}" - ); - } - } - #[test] fn provider_link_metadata_marks_custom_routes_as_configuration_owned() { let links = @@ -1801,105 +1139,4 @@ mod tests { assert_eq!(links.docs_url, None); assert_eq!(links.credential_url, None); } - - #[test] - fn project_links_follow_the_available_public_site_locale() { - let mut app = create_test_app(); - app.ui_locale = Locale::ZhHans; - - let msg = codewhale_links(&mut app) - .message - .expect("links should return a message"); - - assert!(msg.contains("`https://codewhale.net/zh/docs`")); - assert!(msg.contains("`https://codewhale.net/zh/community`")); - assert!(msg.contains("`https://app.codewhale.net`")); - } - - #[test] - fn test_home_dashboard_includes_all_sections() { - let mut app = create_test_app(); - app.session.total_conversation_tokens = 1234; - let result = home_dashboard(&mut app); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("codewhale")); - assert!(!msg.contains("codewhale Home Dashboard")); - assert!(msg.contains("Model:")); - assert!(msg.contains("Mode:")); - assert!(msg.contains("Workspace:")); - assert!(msg.contains("History:")); - assert!(msg.contains("Tokens:")); - assert!(msg.contains("Quick Actions")); - assert!(msg.contains("Mode Tips")); - assert!(result.action.is_none()); - } - - #[test] - fn test_home_dashboard_shows_queued_when_present() { - let mut app = create_test_app(); - app.queued_messages - .push_back(crate::tui::app::QueuedMessage::new( - "test".to_string(), - None, - )); - let result = home_dashboard(&mut app); - let msg = result.message.unwrap(); - assert!(msg.contains("Queued:")); - } - - #[test] - fn test_home_dashboard_mode_tips_for_each_mode() { - let modes = [AppMode::Agent, AppMode::Plan, AppMode::Operate]; - for mode in modes { - let mut app = create_test_app(); - app.mode = mode; - let result = home_dashboard(&mut app); - let msg = result.message.unwrap(); - assert!(msg.contains("Mode Tips"), "Missing tips for mode {mode:?}"); - } - } - - #[test] - fn test_home_dashboard_quick_actions_reflect_links_and_config_and_hide_removed_commands() { - let mut app = create_test_app(); - let result = home_dashboard(&mut app); - let msg = result - .message - .expect("home dashboard should return message"); - assert!(msg.contains("/workspace - Switch folders or worktrees")); - assert!(msg.contains("/restore - Roll files back to a turn snapshot")); - assert!(msg.contains("/tokens - Show session spend and context")); - assert!(msg.contains("/links - Codewhale, community & provider links")); - assert!(msg.contains("/config - Inspect and change settings")); - assert!( - !msg.lines() - .any(|line| line.trim_start().starts_with("/set ")) - ); - assert!(!msg.contains("/codewhale")); - } - - #[test] - fn home_dashboard_localizes_in_zh_hans() { - use codewhale_localization::Locale; - let mut app = create_test_app(); - app.ui_locale = Locale::ZhHans; - let result = home_dashboard(&mut app); - let msg = result - .message - .expect("home dashboard should return message"); - assert!( - msg.contains("codewhale"), - "missing canonical product title:\n{msg}" - ); - assert!(msg.contains("模型"), "missing zh-Hans model label:\n{msg}"); - assert!( - msg.contains("快捷操作"), - "missing zh-Hans quick actions:\n{msg}" - ); - assert!( - msg.contains("模式提示"), - "missing zh-Hans mode tips:\n{msg}" - ); - } } diff --git a/crates/tui/src/commands/groups/core/help.rs b/crates/tui/src/commands/groups/core/help.rs index e3c1e979a1..8d35d7f6df 100644 --- a/crates/tui/src/commands/groups/core/help.rs +++ b/crates/tui/src/commands/groups/core/help.rs @@ -1,26 +1,164 @@ //! `/help` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::facets::CommandPresentationContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::{ + CommandNavigationContext, NavigationSkill, NavigationUserHelp, +}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; +use std::fmt::Write; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "help", aliases: &["?", "bangzhu", "帮助"], usage: "/help [command]", - description_id: MessageId::CmdHelpDescription, + description_key: "cmd_help_description", }; -pub(in crate::commands) struct HelpCmd; +pub struct HelpCmd; -impl RegisterCommand for HelpCmd { +impl RegisterCommand for HelpCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: help, + } + } +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - super::core::help(app, arg) +fn help(contexts: CommandContexts<'_>, topic: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + render_help(navigation, presentation, topic).unwrap_or_else(CommandResult::error) +} + +fn render_help( + navigation: &mut dyn CommandNavigationContext, + presentation: &dyn CommandPresentationContext, + topic: Option<&str>, +) -> Result { + if let Some(topic) = topic { + let facts = navigation.help_topic(topic); + if let Some(command) = facts.user { + return Ok(CommandResult::message(user_command_help( + presentation, + &command, + )?)); + } + if let Some(command) = facts.builtin { + let mut help = format!( + "{}\n\n {}\n\n {} {}", + command.name, + command.description, + presentation.translate("help_usage_label", &[])?, + command.usage, + ); + let visible_aliases = command + .aliases + .iter() + .filter(|alias| !facts.shadowed_names.contains(alias)) + .map(String::as_str) + .collect::>(); + if !visible_aliases.is_empty() { + let _ = write!( + help, + "\n {} {}", + presentation.translate("help_aliases_label", &[])?, + visible_aliases.join(", ") + ); + } + if command.name == "config" { + help.push_str("\n\n Provider context window: set `context_window = 262144` under the active `[providers.]` table to cap a 1M model to 256K. Use `/config context_window` to inspect the configured and effective values."); + help.push('\n'); + help.push_str(&presentation.translate("config_help_discoverable", &[])?); + } + return Ok(CommandResult::message(help)); + } + if let Some(help) = skill_help(presentation, &navigation.cached_skills(), topic)? { + return Ok(CommandResult::message(help)); + } + return Ok(CommandResult::error( + presentation.translate("help_unknown_command", &[("topic", topic)])?, + )); + } + if !navigation.help_is_open() { + navigation.open_help(); + } + Ok(CommandResult::ok()) +} + +fn skill_help( + presentation: &dyn CommandPresentationContext, + skills: &[NavigationSkill], + topic: &str, +) -> Result, String> { + let needle = topic + .trim() + .trim_start_matches('$') + .trim_start_matches('/') + .trim_start_matches("skill ") + .trim(); + if needle.is_empty() { + return Ok(None); + } + let Some(skill) = skills + .iter() + .find(|skill| skill.name.eq_ignore_ascii_case(needle)) + else { + return Ok(None); + }; + let name = &skill.name; + let mut help = format!("${name}"); + if !skill.description.trim().is_empty() { + let _ = write!(help, "\n\n {}", skill.description.trim()); + } + let _ = write!( + help, + "\n\n {} ${name}\n {} /skill {name}", + presentation.translate("help_usage_label", &[])?, + presentation.translate("help_usage_label", &[])?, + ); + Ok(Some(help)) +} + +fn user_command_help( + presentation: &dyn CommandPresentationContext, + command: &NavigationUserHelp, +) -> Result { + let mut help = command.name.clone(); + if let Some(description) = command + .description + .as_deref() + .filter(|description| !description.trim().is_empty()) + { + let _ = write!(help, "\n\n {description}"); + } + let usage = command + .usage + .clone() + .unwrap_or_else(|| format!("/{}", command.name)); + let _ = write!( + help, + "\n\n {} {}", + presentation.translate("help_usage_label", &[])?, + usage + ); + if !command.aliases.is_empty() { + let _ = write!( + help, + "\n {} {}", + presentation.translate("help_aliases_label", &[])?, + command.aliases.join(", ") + ); } + Ok(help) } diff --git a/crates/tui/src/commands/groups/core/home.rs b/crates/tui/src/commands/groups/core/home.rs index 2b69ab940e..e0e0f4fd54 100644 --- a/crates/tui/src/commands/groups/core/home.rs +++ b/crates/tui/src/commands/groups/core/home.rs @@ -1,143 +1,160 @@ //! `/home` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; +use codewhale_command_contract::facets::CommandPresentationContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::NavigationOverview; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; +use codewhale_command_contract::types::CommandMode; +use std::fmt::Write; -use super::CommandResult; - -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "home", aliases: &["zhuye", "shouye"], usage: "/home", - description_id: MessageId::CmdHomeDescription, + description_key: "cmd_home_description", }; -pub(in crate::commands) struct HomeCmd; +pub struct HomeCmd; +pub struct OverviewCmd; -impl RegisterCommand for HomeCmd { +impl RegisterCommand for HomeCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - if app.session_transition_blocked() { - return CommandResult::error(app.tr(MessageId::HomeNavigationBusy).into_owned()); + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: home, } - app.launch.workspace = app.workspace.clone(); - app.launch.restore_card(); - app.launch.visible = true; - app.launch.return_to_session = true; - app.needs_redraw = true; - CommandResult::ok() } } -/// Preserve the old read-only statistics view under its descriptive aliases. -pub(in crate::commands) struct OverviewCmd; - -impl RegisterCommand for OverviewCmd { +impl RegisterCommand for OverviewCmd { fn info() -> &'static CommandInfo { &CommandInfo { name: "overview", aliases: &["stats"], usage: "/overview", - description_id: MessageId::CmdOverviewDescription, + description_key: "cmd_overview_description", + } + } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: overview, } } +} - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - super::core::home_dashboard(app) +fn home(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + if navigation.home_is_blocked() { + return match presentation.translate("home_navigation_busy", &[]) { + Ok(message) => CommandResult::error(message), + Err(error) => CommandResult::error(error), + }; } + navigation.restore_home(); + CommandResult::ok() } -#[cfg(test)] -mod tests { - use super::*; - use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; +fn overview(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + render_overview(presentation, navigation.overview()) + .map(CommandResult::message) + .unwrap_or_else(CommandResult::error) +} - #[test] - fn home_preserves_session_draft_and_reveal_and_escape_returns() { - let mut app = crate::tui::test_support::test_app_with_options( - crate::tui::test_support::test_tui_options("."), - ); - app.launch.dismiss(); - app.current_session_id = Some("retained-session".into()); - app.api_messages_mut().push(codewhale_models::Message { - role: codewhale_models::Role::User, - content: vec![codewhale_models::ContentBlock::Text { - text: "Retain this conversation".into(), - cache_control: None, - }], - }); - app.add_message(crate::tui::history::HistoryCell::System { - content: "Retain this transcript".into(), - }); - app.input = "unsent draft".into(); - app.cursor_position = app.input.chars().count(); - app.launch.mark_reveal_started_at = Some(std::time::Instant::now()); - let reveal = app.launch.mark_reveal_started_at; - let messages = app.api_messages.clone(); - let history_len = app.history.len(); - let result = crate::commands::execute("/home", &mut app); - assert!(!result.is_error, "{:?}", result.message); - assert!( - result.action.is_none(), - "navigation must not sync/reset the Engine" - ); - assert!(crate::tui::widgets::should_render_empty_state(&app)); - assert!(app.launch.return_to_session); - assert!(!crate::tui::underwater::launch_motion_active( - &app, false, true - )); - crate::tui::underwater::refresh_launch_row_hitboxes( - &mut app, - ratatui::layout::Rect::new(0, 0, 80, 20), +fn render_overview( + presentation: &dyn CommandPresentationContext, + facts: NavigationOverview, +) -> Result { + let label = |key| presentation.translate(key, &[]); + let mut stats = String::new(); + let _ = writeln!(stats, "{}", label("home_dashboard_title")?); + let _ = writeln!(stats, "============================================"); + let _ = writeln!(stats, "{} {}", label("home_model")?, facts.model); + let _ = writeln!(stats, "{} {}", label("home_mode")?, facts.mode_label); + let _ = writeln!( + stats, + "{} {}", + label("home_workspace")?, + facts.workspace.display() + ); + let _ = writeln!( + stats, + "{} {} messages", + label("home_history")?, + facts.history_count + ); + let _ = writeln!( + stats, + "{} {} (session)", + label("home_tokens")?, + facts.total_tokens + ); + if facts.queued_count > 0 { + let _ = writeln!( + stats, + "{} {} messages", + label("home_queued")?, + facts.queued_count ); - let rows = crate::tui::underwater::launch_rows_for_app(&app); - assert_eq!( - crate::tui::underwater::launch_row_click_action(&rows[0].id), - crate::tui::underwater::LaunchAction::ReturnToSession, - ); - app.launch.menu_selected = Some(0); - assert_eq!( - crate::tui::underwater::handle_launch_composer_key( - &mut app, - KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE), - ), - crate::tui::underwater::LaunchComposerKey::MenuRun, - ); - assert_eq!(app.input, "unsent draft"); - crate::tui::underwater::handle_launch_composer_key( - &mut app, - KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE), - ); - assert!(!app.launch.visible); - assert!(!app.launch.return_to_session); - assert_eq!(app.current_session_id.as_deref(), Some("retained-session")); - assert_eq!(app.api_messages, messages); - assert_eq!(app.history.len(), history_len); - assert_eq!(app.input, "unsent draft"); - assert_eq!(app.launch.mark_reveal_started_at, reveal); - assert!(!crate::tui::widgets::should_render_empty_state(&app)); - - for command in ["/overview", "/stats"] { - let result = crate::commands::execute(command, &mut app); - assert!(!result.is_error); - assert!(result.message.unwrap().contains("Quick Actions")); - assert!(!app.launch.visible); - } } - - #[test] - fn home_refuses_to_cover_active_work() { - let mut app = crate::tui::test_support::test_app_with_options( - crate::tui::test_support::test_tui_options("."), + if facts.subagent_count > 0 { + let _ = writeln!( + stats, + "{} {} active", + label("home_subagents")?, + facts.subagent_count ); - app.launch.dismiss(); - app.is_loading = true; - let result = HomeCmd::execute(&mut app, None); - assert!(result.is_error); - assert!(!app.launch.visible); } + if let Some(skill) = &facts.active_skill { + let _ = writeln!(stats, "{} {} (active)", label("home_skill")?, skill); + } + let _ = writeln!(stats, "\n{}", label("home_quick_actions")?); + let _ = writeln!(stats, "--------------------------------------------"); + for key in [ + "home_quick_workspace", + "home_quick_restore", + "home_quick_tokens", + "home_quick_links", + "home_quick_skills", + "home_quick_config", + "home_quick_settings", + "home_quick_model", + "home_quick_subagents", + "home_quick_task_list", + "home_quick_help", + ] { + let _ = writeln!(stats, "{}", label(key)?); + } + let _ = writeln!(stats, "\n{}", label("home_mode_tips")?); + let _ = writeln!(stats, "--------------------------------------------"); + let tips: &[&str] = match facts.mode { + CommandMode::Agent => &[ + "home_agent_mode_tip", + "home_agent_mode_review_tip", + "home_agent_mode_yolo_tip", + ], + CommandMode::Operate => &["home_operate_mode_tip", "home_operate_mode_fleet_tip"], + CommandMode::Plan => &["home_plan_mode_tip", "home_plan_mode_checklist_tip"], + }; + for key in tips { + let _ = writeln!(stats, "{}", label(key)?); + } + Ok(stats) } diff --git a/crates/tui/src/commands/groups/core/hotbar.rs b/crates/tui/src/commands/groups/core/hotbar.rs index f23d0e2961..478015bf2c 100644 --- a/crates/tui/src/commands/groups/core/hotbar.rs +++ b/crates/tui/src/commands/groups/core/hotbar.rs @@ -1,93 +1,78 @@ //! `/hotbar` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "hotbar", aliases: &["hotkeys"], usage: "/hotbar", - description_id: MessageId::CmdHotbarDescription, + description_key: "cmd_hotbar_description", }; -pub(in crate::commands) struct HotbarCmd; +pub struct HotbarCmd; -impl RegisterCommand for HotbarCmd { +impl RegisterCommand for HotbarCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - fn execute(_app: &mut App, arg: Option<&str>) -> CommandResult { - match arg.map(str::trim).filter(|arg| !arg.is_empty()) { - None | Some("setup" | "edit" | "configure" | "config") => { - CommandResult::action(AppAction::OpenHotbarSetup) - } - // Hide the Hotbar: persist `hotbar = []` and clear the live slots. - Some("off" | "disable" | "hide") => CommandResult::action(AppAction::DisableHotbar), - // Restore the default recommended slots (explicit reset). - Some("on" | "reset" | "defaults" | "default") => { - CommandResult::action(AppAction::RestoreHotbarDefaults) - } - Some("help" | "?") => CommandResult::message( - "Hotbar gives you Alt+1 through Alt+8 shortcuts (Option key on macOS, Alt \ - elsewhere). Use `/hotbar` to customize, `/hotbar off` to hide it \ - (`hotbar = []`), and `/hotbar on` to restore the default slots. \ - Hotbar slots dispatch only when no modal, inline picker, or \ - onboarding surface owns input. Bare 1-8 insert text in the \ - composer. Cmd-number and F-keys are not Hotbar shortcuts unless \ - a future release documents and implements them.", - ), - Some(other) => CommandResult::error(format!( - "Unknown /hotbar target '{other}'. Try `/hotbar`, `/hotbar off`, \ - `/hotbar on`, or `/hotbar help`." - )), + fn handler() -> CommandHandler { + CommandHandler::Pure(hotbar) + } +} + +fn hotbar(arg: Option<&str>) -> CommandResult { + match arg.map(str::trim).filter(|arg| !arg.is_empty()) { + None | Some("setup" | "edit" | "configure" | "config") => { + CommandResult::action(Action::OpenHotbarSetup) + } + // Hide the Hotbar: persist `hotbar = []` and clear the live slots. + Some("off" | "disable" | "hide") => CommandResult::action(Action::DisableHotbar), + // Restore the default recommended slots (explicit reset). + Some("on" | "reset" | "defaults" | "default") => { + CommandResult::action(Action::RestoreHotbarDefaults) } + Some("help" | "?") => CommandResult::message( + "Hotbar gives you Alt+1 through Alt+8 shortcuts (Option key on macOS, Alt \ + elsewhere). Use `/hotbar` to customize, `/hotbar off` to hide it \ + (`hotbar = []`), and `/hotbar on` to restore the default slots. \ + Hotbar slots dispatch only when no modal, inline picker, or \ + onboarding surface owns input. Bare 1-8 insert text in the \ + composer. Cmd-number and F-keys are not Hotbar shortcuts unless \ + a future release documents and implements them.", + ), + Some(other) => CommandResult::error(format!( + "Unknown /hotbar target '{other}'. Try `/hotbar`, `/hotbar off`, \ + `/hotbar on`, or `/hotbar help`." + )), } } #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::tui::app::TuiOptions; - use std::path::PathBuf; - - fn test_app() -> App { - let options = TuiOptions { - ..crate::tui::test_support::test_tui_options(PathBuf::from(".")) - }; - App::new(options, &Config::default()) - } #[test] fn hotbar_command_opens_setup_view() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, None); - - assert_eq!(result.action, Some(AppAction::OpenHotbarSetup)); + let result = hotbar(None); + assert_eq!(result.action, Some(Action::OpenHotbarSetup)); assert!(result.message.is_none()); } #[test] fn hotbar_setup_alias_opens_setup_view() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, Some("setup")); - - assert_eq!(result.action, Some(AppAction::OpenHotbarSetup)); + let result = hotbar(Some("setup")); + assert_eq!(result.action, Some(Action::OpenHotbarSetup)); assert!(result.message.is_none()); } #[test] fn hotbar_help_arg_explains_customize_and_disable() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, Some("help")); - + let result = hotbar(Some("help")); assert!(!result.is_error); assert!(result.action.is_none()); let message = result @@ -115,11 +100,10 @@ mod tests { #[test] fn hotbar_off_and_disable_aliases_return_disable_action() { for arg in ["off", "disable", "hide"] { - let mut app = test_app(); - let result = HotbarCmd::execute(&mut app, Some(arg)); + let result = hotbar(Some(arg)); assert_eq!( result.action, - Some(AppAction::DisableHotbar), + Some(Action::DisableHotbar), "`/hotbar {arg}` should disable the hotbar" ); assert!( @@ -132,11 +116,10 @@ mod tests { #[test] fn hotbar_on_and_reset_aliases_return_restore_action() { for arg in ["on", "reset", "defaults", "default"] { - let mut app = test_app(); - let result = HotbarCmd::execute(&mut app, Some(arg)); + let result = hotbar(Some(arg)); assert_eq!( result.action, - Some(AppAction::RestoreHotbarDefaults), + Some(Action::RestoreHotbarDefaults), "`/hotbar {arg}` should restore default hotbar slots" ); assert!(result.message.is_none()); @@ -145,10 +128,7 @@ mod tests { #[test] fn hotbar_unknown_arg_reports_error() { - let mut app = test_app(); - - let result = HotbarCmd::execute(&mut app, Some("bogus")); - + let result = hotbar(Some("bogus")); assert!(result.is_error); assert!(result.action.is_none()); assert!( diff --git a/crates/tui/src/commands/groups/core/links.rs b/crates/tui/src/commands/groups/core/links.rs index 0367bfbd67..d7002a9596 100644 --- a/crates/tui/src/commands/groups/core/links.rs +++ b/crates/tui/src/commands/groups/core/links.rs @@ -1,26 +1,139 @@ //! `/links` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; +use codewhale_command_contract::facets::CommandPresentationContext; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::{NavigationLinks, NavigationProviderKind}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; +use std::fmt::Write; -use super::CommandResult; - -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "links", aliases: &["dashboard", "api", "lianjie"], usage: "/links", - description_id: MessageId::CmdLinksDescription, + description_key: "cmd_links_description", }; -pub(in crate::commands) struct LinksCmd; +pub struct LinksCmd; -impl RegisterCommand for LinksCmd { +impl RegisterCommand for LinksCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION), + handler: links, + } + } +} + +fn links(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(navigation) = parts.navigation else { + return CommandResult::error("Command capability unavailable: navigation"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + render_links(presentation, navigation.links()) + .map(CommandResult::message) + .unwrap_or_else(CommandResult::error) +} + +pub(crate) fn public_site_locale_segment(tag: &str) -> &'static str { + match tag { + "zh-Hans" | "zh-Hant" => "zh", + "ja" => "ja", + "vi" => "vi", + "ko" => "ko", + "es-419" => "es", + "pt-BR" => "pt-BR", + "ru" => "ru", + "uk" => "uk", + _ => "en", + } +} - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - super::core::codewhale_links(app) +fn render_links( + presentation: &dyn CommandPresentationContext, + facts: NavigationLinks, +) -> Result { + let label = |key| presentation.translate(key, &[]); + let site_locale = public_site_locale_segment(&facts.locale_tag); + let mut message = format!( + "{}\n─────────────────────────────\n", + label("links_project_title")? + ); + let _ = writeln!( + message, + "{} `https://codewhale.net/{site_locale}/docs`", + label("links_documentation")? + ); + let _ = writeln!( + message, + "{} `https://codewhale.net/{site_locale}/community`", + label("links_community")? + ); + let _ = writeln!( + message, + "{} `https://github.com/codewhale-hq/CodeWhale`", + label("links_git_hub")? + ); + let _ = writeln!( + message, + "{} `https://app.codewhale.net`", + label("links_managed_app")? + ); + let _ = writeln!(message, "{}", label("links_managed_app_note")?); + let _ = write!( + message, + "\n{}\n─────────────────────────────\n", + label("links_title")? + ); + for provider in facts.providers { + let active_marker = if provider.id == facts.active_provider { + " <- current" + } else { + "" + }; + let _ = writeln!( + message, + "\n{} ({}){}", + provider.name, provider.id, active_marker + ); + if let Some(key_url) = provider.credential_url { + let _ = writeln!(message, "{} `{}`", label("links_dashboard")?, key_url); + } else { + let _ = writeln!( + message, + "{} {}", + label("links_dashboard")?, + provider.guidance + ); + } + if let Some(docs_url) = provider.docs_url { + let _ = writeln!(message, "{} `{}`", label("links_docs")?, docs_url); + } + if provider.kind == NavigationProviderKind::Moonshot { + let _ = writeln!( + message, + "{}", + presentation.translate( + "links_kimi_code_route_note", + &[ + ("route", &facts.kimi_route), + ("console", &facts.kimi_console), + ] + )? + ); + } + if provider.env_names.is_empty() { + let _ = writeln!(message, "Env: none"); + } else { + let _ = writeln!(message, "Env: {}", provider.env_names.join(", ")); + } } + let _ = writeln!(message, "\n{}", label("links_tip")?); + Ok(message) } diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs index 02424c2e05..eb05dc77bc 100644 --- a/crates/tui/src/commands/groups/core/mod.rs +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -5,7 +5,6 @@ mod acceptance; mod advisor; mod agent; -mod anchor; mod clear; mod constitution; mod copy; @@ -18,16 +17,13 @@ mod effort; mod exit; mod feedback; pub(in crate::commands) mod fleet; -mod help; mod hf; -mod home; mod hooks; -mod hotbar; mod lane; -mod links; mod model; mod modeldb; mod models; +pub(in crate::commands) mod navigation; mod pin; mod profile; mod provider; @@ -37,13 +33,8 @@ pub(in crate::commands) mod router; mod setup; mod stash; mod subagents; -mod transcript; -mod translate; -mod turn; pub mod util; -pub mod voice; mod workflow; -mod workspace; pub(crate) use self::core::public_site_locale_segment; pub(in crate::commands) use self::core::reset_conversation_state; @@ -57,18 +48,12 @@ pub struct CoreCommands; impl CommandGroup for CoreCommands { fn commands(&self) -> &'static [Box] { cached_command_list!(vec![ - Box::new(FunctionCommand::new( - anchor::AnchorCmd::info(), - anchor::AnchorCmd::execute, - )), + crate::commands::core_navigation_host::registration::<0>(), Box::new(FunctionCommand::new( advisor::AdvisorCmd::info(), advisor::AdvisorCmd::execute, )), - Box::new(FunctionCommand::new( - help::HelpCmd::info(), - help::HelpCmd::execute, - )), + crate::commands::core_navigation_host::registration::<1>(), Box::new(FunctionCommand::new( clear::ClearCmd::info(), clear::ClearCmd::execute, @@ -137,10 +122,7 @@ impl CommandGroup for CoreCommands { workflow::AutoCmd::info(), workflow::AutoCmd::execute, )), - Box::new(FunctionCommand::new( - hotbar::HotbarCmd::info(), - hotbar::HotbarCmd::execute, - )), + crate::commands::core_navigation_host::registration::<2>(), Box::new(FunctionCommand::new( setup::SetupCmd::info(), setup::SetupCmd::execute, @@ -153,35 +135,17 @@ impl CommandGroup for CoreCommands { agent::AgentCmd::info(), agent::AgentCmd::execute, )), - Box::new(FunctionCommand::new( - links::LinksCmd::info(), - links::LinksCmd::execute, - )), - Box::new(FunctionCommand::new( - transcript::TranscriptCmd::info(), - transcript::TranscriptCmd::execute, - )), - Box::new(FunctionCommand::new( - turn::TurnCmd::info(), - turn::TurnCmd::execute, - )), + crate::commands::core_navigation_host::registration::<3>(), + crate::commands::core_navigation_host::registration::<4>(), + crate::commands::core_navigation_host::registration::<5>(), Box::new(FunctionCommand::new( feedback::FeedbackCmd::info(), feedback::FeedbackCmd::execute, )), Box::new(FunctionCommand::new(hf::HfCmd::info(), hf::HfCmd::execute)), - Box::new(FunctionCommand::new( - home::HomeCmd::info(), - home::HomeCmd::execute, - )), - Box::new(FunctionCommand::new( - home::OverviewCmd::info(), - home::OverviewCmd::execute, - )), - Box::new(FunctionCommand::new( - workspace::WorkspaceCmd::info(), - workspace::WorkspaceCmd::execute, - )), + crate::commands::core_navigation_host::registration::<6>(), + crate::commands::core_navigation_host::registration::<7>(), + crate::commands::core_navigation_host::registration::<8>(), Box::new(FunctionCommand::new( profile::ProfileCmd::info(), profile::ProfileCmd::execute, @@ -190,23 +154,11 @@ impl CommandGroup for CoreCommands { rlm::RlmCmd::info(), rlm::RlmCmd::execute, )), - Box::new(FunctionCommand::new( - translate::TranslateCmd::info(), - translate::TranslateCmd::execute, - )), - Box::new(FunctionCommand::new( - voice::VoiceCmd::info(), - voice::VoiceCmd::execute, - )), + crate::commands::core_navigation_host::registration::<9>(), + crate::commands::core_navigation_host::registration::<10>(), Box::new(FunctionCommand::new(&effort::EFFORT_INFO, effort::effort,)), - Box::new(FunctionCommand::new( - voice::VoiceSendCmd::info(), - voice::VoiceSendCmd::execute, - )), - Box::new(FunctionCommand::new( - voice::VoiceControlCmd::info(), - voice::VoiceControlCmd::execute, - )), + crate::commands::core_navigation_host::registration::<11>(), + crate::commands::core_navigation_host::registration::<12>(), Box::new(FunctionCommand::new( pin::PinCmd::info(), pin::PinCmd::execute diff --git a/crates/tui/src/commands/groups/core/navigation.rs b/crates/tui/src/commands/groups/core/navigation.rs new file mode 100644 index 0000000000..d29bec321b --- /dev/null +++ b/crates/tui/src/commands/groups/core/navigation.rs @@ -0,0 +1,57 @@ +#[path = "anchor.rs"] +pub mod anchor; +#[path = "help.rs"] +pub mod help; +#[path = "home.rs"] +pub mod home; +#[path = "hotbar.rs"] +pub mod hotbar; +#[path = "links.rs"] +pub mod links; +#[path = "transcript.rs"] +pub mod transcript; +#[path = "translate.rs"] +pub mod translate; +#[path = "turn.rs"] +pub mod turn; +#[path = "voice.rs"] +pub mod voice; +#[path = "workspace.rs"] +pub mod workspace; + +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult; + +pub fn portable_handlers() -> [( + &'static CommandInfo, + CommandHandler, +); 13] { + [ + (anchor::AnchorCmd::info(), anchor::AnchorCmd::handler()), + (help::HelpCmd::info(), help::HelpCmd::handler()), + (hotbar::HotbarCmd::info(), hotbar::HotbarCmd::handler()), + (links::LinksCmd::info(), links::LinksCmd::handler()), + ( + transcript::TranscriptCmd::info(), + transcript::TranscriptCmd::handler(), + ), + (turn::TurnCmd::info(), turn::TurnCmd::handler()), + (home::HomeCmd::info(), home::HomeCmd::handler()), + (home::OverviewCmd::info(), home::OverviewCmd::handler()), + ( + workspace::WorkspaceCmd::info(), + workspace::WorkspaceCmd::handler(), + ), + ( + translate::TranslateCmd::info(), + translate::TranslateCmd::handler(), + ), + (voice::VoiceCmd::info(), voice::VoiceCmd::handler()), + (voice::VoiceSendCmd::info(), voice::VoiceSendCmd::handler()), + ( + voice::VoiceControlCmd::info(), + voice::VoiceControlCmd::handler(), + ), + ] +} diff --git a/crates/tui/src/commands/groups/core/transcript.rs b/crates/tui/src/commands/groups/core/transcript.rs index 542813e9e4..332a89397c 100644 --- a/crates/tui/src/commands/groups/core/transcript.rs +++ b/crates/tui/src/commands/groups/core/transcript.rs @@ -1,28 +1,28 @@ //! `/transcript` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "transcript", aliases: &[], usage: "/transcript", // Reuse the keybinding description so the command palette and shortcut // catalog describe the same live-transcript surface in every locale. - description_id: MessageId::KbLiveTranscript, + description_key: "kb_live_transcript", }; -pub(in crate::commands) struct TranscriptCmd; +pub struct TranscriptCmd; -impl RegisterCommand for TranscriptCmd { +impl RegisterCommand for TranscriptCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - fn execute(_app: &mut App, _arg: Option<&str>) -> CommandResult { - CommandResult::action(AppAction::OpenLiveTranscript) + fn handler() -> CommandHandler { + CommandHandler::Pure(|_| CommandResult::action(Action::OpenLiveTranscript)) } } diff --git a/crates/tui/src/commands/groups/core/translate.rs b/crates/tui/src/commands/groups/core/translate.rs index ea3195519e..78e0af41b2 100644 --- a/crates/tui/src/commands/groups/core/translate.rs +++ b/crates/tui/src/commands/groups/core/translate.rs @@ -1,26 +1,48 @@ //! `/translate` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::CoreNavigationCommandResult as CommandResult; -use super::CommandResult; - -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "translate", aliases: &["translation", "transale"], usage: "/translate", - description_id: MessageId::CmdTranslateDescription, + description_key: "cmd_translate_description", }; -pub(in crate::commands) struct TranslateCmd; +pub struct TranslateCmd; -impl RegisterCommand for TranslateCmd { +impl RegisterCommand for TranslateCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - super::core::translate(app) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::CONFIG.union(CommandCapabilities::PRESENTATION), + handler: translate, + } } } + +fn translate(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(config) = parts.config else { + return CommandResult::error("Command capability unavailable: config"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !config.translation_enabled(); + let key = if enabled { + "cmd_translate_on" + } else { + "cmd_translate_off" + }; + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), + }; + config.set_translation_enabled(enabled); + CommandResult::message(message) +} diff --git a/crates/tui/src/commands/groups/core/turn.rs b/crates/tui/src/commands/groups/core/turn.rs index 206ad06e10..669e255abd 100644 --- a/crates/tui/src/commands/groups/core/turn.rs +++ b/crates/tui/src/commands/groups/core/turn.rs @@ -1,72 +1,62 @@ //! `/turn` command — inspect the current or latest completed turn. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "turn", aliases: &["turns"], usage: "/turn inspect", - description_id: MessageId::CmdTurnInspectDescription, + description_key: "cmd_turn_inspect_description", }; -pub(in crate::commands) struct TurnCmd; +pub struct TurnCmd; -impl RegisterCommand for TurnCmd { +impl RegisterCommand for TurnCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } - fn execute(_app: &mut App, arg: Option<&str>) -> CommandResult { - let verb = arg.map(str::trim).unwrap_or(""); - if matches!(verb, "inspect" | "i" | "") { - return CommandResult::action(AppAction::OpenTurnInspector); - } - CommandResult::error(format!( - "Unknown /turn verb '{verb}'. Use /turn inspect to open the whole-turn inspector." - )) + fn handler() -> CommandHandler { + CommandHandler::Pure(turn) } } +fn turn(arg: Option<&str>) -> CommandResult { + let verb = arg.map(str::trim).unwrap_or(""); + if matches!(verb, "inspect" | "i" | "") { + return CommandResult::action(Action::OpenTurnInspector); + } + CommandResult::error(format!( + "Unknown /turn verb '{verb}'. Use /turn inspect to open the whole-turn inspector." + )) +} + #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::tui::app::{App, TuiOptions}; - use std::path::PathBuf; - - fn test_app() -> App { - let options = TuiOptions { - model: "deepseek-v4-flash".to_string(), - ..crate::tui::test_support::test_tui_options(PathBuf::from(".")) - }; - App::new(options, &Config::default()) - } #[test] fn bare_turn_inspect_opens_turn_inspector() { - let mut app = test_app(); - let result = TurnCmd::execute(&mut app, None); + let result = turn(None); assert!(!result.is_error); - assert_eq!(result.action, Some(AppAction::OpenTurnInspector)); + assert_eq!(result.action, Some(Action::OpenTurnInspector)); assert!(result.message.is_none()); } #[test] fn turn_inspect_verb_opens_turn_inspector() { - let mut app = test_app(); - let result = TurnCmd::execute(&mut app, Some("inspect")); + let result = turn(Some("inspect")); assert!(!result.is_error); - assert_eq!(result.action, Some(AppAction::OpenTurnInspector)); + assert_eq!(result.action, Some(Action::OpenTurnInspector)); } #[test] fn unknown_turn_verb_returns_error() { - let mut app = test_app(); - let result = TurnCmd::execute(&mut app, Some("bad")); + let result = turn(Some("bad")); assert!(result.is_error); assert!( result diff --git a/crates/tui/src/commands/groups/core/voice.rs b/crates/tui/src/commands/groups/core/voice.rs index 6b98b0a7c2..f22fa77069 100644 --- a/crates/tui/src/commands/groups/core/voice.rs +++ b/crates/tui/src/commands/groups/core/voice.rs @@ -1,340 +1,140 @@ //! Voice input commands — `/voice`, `/voice-send`, `/voice-control`. -//! -//! Records audio from the default microphone, sends it to the configured -//! provider's API for transcription, and inserts the transcribed text into -//! the composer. The interaction model mirrors MiMo Code's voice UX: -//! -//! `/voice` — toggle voice input on/off (records when toggled on) -//! `/voice-send` — toggle auto-send when the transcript ends with -//! "send it" / "发送" -//! `/voice-control` — toggle AI-assisted dictation that sees the current -//! composer text -//! -//! The slash commands only flip state and emit [`AppAction::VoiceCapture`]; -//! the actual capture runs in the UI event loop where the live [`Config`] -//! supplies provider credentials. That keeps the handlers side-effect free -//! (the registry smoke tests execute every command) and avoids caching -//! auth material on [`App`]. -//! -//! Recording, transcription and the headless dictation cycle live in -//! `crate::voice`; this module keeps the slash commands and the capture loop -//! that updates the composer while the user speaks. -use std::time::Duration; - -use crate::commands::CommandResult; -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::config::Config; -use crate::tui::app::{App, AppAction}; -use crate::voice::{ - is_available, record_audio, resolve_asr_choice, split_send_suffix, transcribe_selected, +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, }; -use codewhale_localization::{MessageId, tr}; -pub(in crate::commands) const VOICE_INFO: CommandInfo = CommandInfo { +pub const VOICE_INFO: CommandInfo = CommandInfo { name: "voice", aliases: &["yuyin", "语音"], usage: "/voice", - description_id: MessageId::CmdVoiceDescription, + description_key: "cmd_voice_description", }; - -pub(in crate::commands) const VOICE_SEND_INFO: CommandInfo = CommandInfo { +pub const VOICE_SEND_INFO: CommandInfo = CommandInfo { name: "voicesend", aliases: &["voice-send", "yuyinsend", "语音发送"], usage: "/voicesend", - description_id: MessageId::CmdVoiceSendDescription, + description_key: "cmd_voice_send_description", }; - -pub(in crate::commands) const VOICE_CONTROL_INFO: CommandInfo = CommandInfo { +pub const VOICE_CONTROL_INFO: CommandInfo = CommandInfo { name: "voicecontrol", aliases: &["voice-control", "yuyincontrol", "语音控制"], usage: "/voicecontrol", - description_id: MessageId::CmdVoiceControlDescription, + description_key: "cmd_voice_control_description", }; -pub(in crate::commands) struct VoiceCmd; -pub(in crate::commands) struct VoiceSendCmd; -pub(in crate::commands) struct VoiceControlCmd; +pub struct VoiceCmd; +pub struct VoiceSendCmd; +pub struct VoiceControlCmd; -impl RegisterCommand for VoiceCmd { +impl RegisterCommand for VoiceCmd { fn info() -> &'static CommandInfo { &VOICE_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - voice(app) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION), + handler: voice, + } } } - -impl RegisterCommand for VoiceSendCmd { +impl RegisterCommand for VoiceSendCmd { fn info() -> &'static CommandInfo { &VOICE_SEND_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - voice_send(app) + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION), + handler: voice_send, + } } } - -impl RegisterCommand for VoiceControlCmd { +impl RegisterCommand for VoiceControlCmd { fn info() -> &'static CommandInfo { &VOICE_CONTROL_INFO } - - fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { - voice_control(app) - } -} - -// --- Capture orchestration (UI event loop) --------------------------------- - -/// What the UI should do with a finished capture. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum VoiceCaptureOutcome { - /// Insert the transcribed text into the composer at the cursor. - Insert(String), - /// Submit this text as a message (auto-send). - Send(String), -} - -/// Status line while recording: the localized recording label, the latest -/// interim transcript once one exists, and how to stop. The capture is awaited -/// on the UI loop, so no key can end it — `record_audio` stops after a second -/// of silence (or `MAX_RECORD_SECS`), and the cue says exactly that. -fn recording_status(locale: codewhale_localization::Locale, interim: Option<&str>) -> String { - let label = tr(locale, MessageId::VoiceRecording); - let stop = tr(locale, MessageId::VoiceRecordingStopHint); - match interim.map(str::trim).filter(|text| !text.is_empty()) { - Some(text) => format!("{label} \u{2014} \u{201c}{text}\u{201d} \u{00b7} {stop}"), - None => format!("{label} \u{00b7} {stop}"), - } -} - -/// Perform a complete record + transcribe cycle with live interim display. -/// -/// Runs in the UI event loop (see [`AppAction::VoiceCapture`]) so provider -/// credentials come from the live [`Config`] rather than state cached on -/// [`App`]. Recording happens on a blocking thread; transcription uses the -/// shared async HTTP client. Every failure path returns a localized message -/// so callers can surface it as a status line. -pub async fn capture_and_transcribe( - app: &mut App, - config: &Config, -) -> Result { - let locale = app.ui_locale; - - if !is_available() { - return Err(tr(locale, MessageId::VoiceErrNoRecorder).to_string()); - } - // C01-10: the credential check follows the ASR backend that will run, - // not the chat provider. Local Whisper and Groq need no provider key, so - // resolve it lazily — the same contract as `crate::voice::dictate_once` - // — and only refuse before recording when provider ASR is the backend. - let provider_key = || { - config - .active_route_api_key() - .map_err(|_| tr(locale, MessageId::VoiceErrNoAuth).to_string()) - }; - let (asr_kind, _asr_model) = resolve_asr_choice(config); - if asr_kind == "provider" { - provider_key()?; - } - // Show the localized recording status plus the live interim in the composer. - let original_input = app.composer.input.clone(); - let original_cursor = app.composer.cursor_position; - app.status_message = Some(recording_status(locale, None)); - - // Streaming interim: poll every 700ms and show partial transcript like Grok Build's - // VoiceEvent::Interim → VoiceState::Recording{interim}. We re-transcribe the - // growing buffer (local-whisper is cheap; Groq is ~300ms; provider falls back). - let interim_enabled = true; // always show partials — feels alive like Spark - - // Spawn recorder on blocking thread with a shared buffer for interim polling. - let shared_buf: std::sync::Arc>> = - std::sync::Arc::new(parking_lot::Mutex::new(Vec::new())); - let shared_done = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); - let shared_buf_clone = std::sync::Arc::clone(&shared_buf); - let shared_done_clone = std::sync::Arc::clone(&shared_done); - let recorder_handle = tokio::task::spawn_blocking(move || { - // Bridge to existing record_audio but copy into shared buffer incrementally. - // For now we reuse the blocking recorder and then publish; interim will - // poll the final buffer. A true streaming recorder (cpal/pw-record) is - // the next step — see grokbuild's xai-grok-voice::audio for the subprocess - // isolation pattern we should mirror. - let result = record_audio(); - if let Some((samples, dur)) = result { - *shared_buf_clone.lock() = samples.clone(); - shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); - Some((samples, dur)) - } else { - shared_done_clone.store(true, std::sync::atomic::Ordering::SeqCst); - None - } - }); - - // Interim polling loop — updates composer with "original + interim ▍" so text - // appears as you talk, just like Spark's live transcript. - let mut last_interim = String::new(); - let mut ticks: u32 = 0; - loop { - tokio::time::sleep(Duration::from_millis(700)).await; - ticks += 1; - if shared_done.load(std::sync::atomic::Ordering::SeqCst) { - break; - } - if !interim_enabled || ticks < 2 { - continue; // let a little audio accumulate before first interim - } - let snapshot = { shared_buf.lock().clone() }; - if snapshot.len() < 8000 { - // <0.5s of audio — not enough for meaningful ASR - continue; - } - // Try cheapest free ASR for interim; don't fail the whole capture on interim error. - let interim = transcribe_selected(config, &asr_kind, &snapshot, None) - .await - .unwrap_or_default(); - let trimmed = interim.trim(); - if !trimmed.is_empty() && trimmed != last_interim { - last_interim = trimmed.to_string(); - // Show interim inline — preserve cursor at original position, append interim with a block cursor - let display = if original_input.trim().is_empty() { - format!("{trimmed} ▍") - } else { - format!("{} {} ▍", original_input.trim_end(), trimmed) - }; - app.composer.input = display; - app.composer.cursor_position = original_cursor; - app.status_message = Some(recording_status(locale, Some(trimmed))); - } - if ticks > 40 { - break; // safety: ~28s max interim polling + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION), + handler: voice_control, } } - - let (samples, _duration) = recorder_handle - .await - .ok() - .flatten() - .ok_or_else(|| tr(locale, MessageId::VoiceErrTooShort).to_string())?; - - // Restore composer to original before final insert (interim was preview only) - app.composer.input = original_input.clone(); - app.composer.cursor_position = original_cursor; - app.status_message = Some(tr(locale, MessageId::VoiceProcessing).to_string()); - - let text = transcribe_selected( - config, - &asr_kind, - &samples, - app.voice_control_enabled.then_some(original_input.as_str()), - ) - .await - .map_err(|error| format!("{}: {error}", tr(locale, MessageId::VoiceErrNetwork)))?; - - let clean = text.trim(); - if app.voice_send_enabled { - let (remainder, wants_send) = split_send_suffix(clean); - if wants_send { - // A bare "send it" submits whatever is already in the composer. - let outgoing = if remainder.is_empty() { - let existing = app.composer.input.trim().to_string(); - if !existing.is_empty() { - app.clear_input(); - } - existing - } else { - remainder.to_string() - }; - if outgoing.is_empty() { - return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); - } - return Ok(VoiceCaptureOutcome::Send(outgoing)); - } - } - if clean.is_empty() { - return Err(tr(locale, MessageId::VoiceErrEmptySend).to_string()); - } - Ok(VoiceCaptureOutcome::Insert(clean.to_string())) } -// --- Command handlers ------------------------------------------------------ - -/// Handle the `/voice` command: toggle voice input. Toggling on requests a -/// one-shot recording + transcription via [`AppAction::VoiceCapture`]. -pub fn voice(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - - if app.voice_enabled { - app.voice_enabled = false; - return CommandResult::message(tr(locale, MessageId::VoiceDisabled)); +fn voice(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(voice) = parts.voice_input else { + return CommandResult::error("Command capability unavailable: voice_input"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !voice.voice_enabled(); + if enabled && !voice.recorder_available() { + let message = presentation + .translate("voice_err_no_recorder", &[]) + .unwrap_or_else(std::convert::identity); + return CommandResult::error(message); } - if !is_available() { - return CommandResult::error(tr(locale, MessageId::VoiceErrNoRecorder)); + let key = if enabled { + "voice_enabled" + } else { + "voice_disabled" + }; + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), + }; + voice.set_voice_enabled(enabled); + if enabled { + CommandResult::with_message_and_action(message, Action::VoiceCapture) + } else { + CommandResult::message(message) } - app.voice_enabled = true; - CommandResult::with_message_and_action( - tr(locale, MessageId::VoiceEnabled), - AppAction::VoiceCapture, - ) } -/// Handle the `/voice-send` command: toggle auto-send after transcription. -pub fn voice_send(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - app.voice_send_enabled = !app.voice_send_enabled; - - let msg = if app.voice_send_enabled { - tr(locale, MessageId::VoiceSendEnabled) +fn voice_send(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(voice) = parts.voice_input else { + return CommandResult::error("Command capability unavailable: voice_input"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !voice.send_enabled(); + let key = if enabled { + "voice_send_enabled" } else { - tr(locale, MessageId::VoiceSendDisabled) + "voice_send_disabled" + }; + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), }; - CommandResult::message(msg) + voice.set_send_enabled(enabled); + CommandResult::message(message) } -/// Handle the `/voice-control` command: toggle AI-assisted dictation. -pub fn voice_control(app: &mut App) -> CommandResult { - let locale = app.ui_locale; - app.voice_control_enabled = !app.voice_control_enabled; - - let msg = if app.voice_control_enabled { - tr(locale, MessageId::VoiceControlEnabled) +fn voice_control(contexts: CommandContexts<'_>, _: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(voice) = parts.voice_input else { + return CommandResult::error("Command capability unavailable: voice_input"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + let enabled = !voice.control_enabled(); + let key = if enabled { + "voice_control_enabled" } else { - tr(locale, MessageId::VoiceControlDisabled) + "voice_control_disabled" }; - CommandResult::message(msg) -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn recording_status_is_localized_and_keeps_the_interim() { - use codewhale_localization::Locale; - - for locale in [Locale::En, Locale::De, Locale::Ja] { - let label = tr(locale, MessageId::VoiceRecording).to_string(); - let stop = tr(locale, MessageId::VoiceRecordingStopHint).to_string(); - let idle = format!("{label} \u{00b7} {stop}"); - assert_eq!(recording_status(locale, None), idle); - assert_eq!(recording_status(locale, Some(" ")), idle); - - let with_interim = recording_status(locale, Some(" hello there ")); - assert!(with_interim.starts_with(&label), "{with_interim}"); - assert!(with_interim.contains("\u{201c}hello there\u{201d}")); - assert!( - with_interim.ends_with(&stop), - "the stop cue survives the interim: {with_interim}" - ); - assert!(!with_interim.contains("\u{2325}V"), "no hardcoded key hint"); - if locale != Locale::En { - assert!(!with_interim.contains("to finish"), "no English hint"); - } - } - assert_ne!( - recording_status(Locale::En, None), - recording_status(Locale::De, None) - ); - } + let message = match presentation.translate(key, &[]) { + Ok(message) => message, + Err(error) => return CommandResult::error(error), + }; + voice.set_control_enabled(enabled); + CommandResult::message(message) } diff --git a/crates/tui/src/commands/groups/core/workspace.rs b/crates/tui/src/commands/groups/core/workspace.rs index 433178da8d..182ced1859 100644 --- a/crates/tui/src/commands/groups/core/workspace.rs +++ b/crates/tui/src/commands/groups/core/workspace.rs @@ -1,31 +1,108 @@ //! `/workspace` command. -use crate::commands::traits::{CommandInfo, RegisterCommand}; -use crate::tui::app::{App, AppAction}; -use codewhale_localization::MessageId; - -use super::CommandResult; +use codewhale_command_contract::facets::{CommandPresentationContext, CommandWorkspaceContext}; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::navigation::WorkspaceTarget; +use codewhale_command_contract::outcome::{ + CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult, +}; +use std::path::PathBuf; -pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { +pub const COMMAND_INFO: CommandInfo = CommandInfo { name: "workspace", aliases: &["cwd"], usage: "/workspace [path|worktrees]", - description_id: MessageId::CmdWorkspaceDescription, + description_key: "cmd_workspace_description", }; -pub(in crate::commands) struct WorkspaceCmd; +pub struct WorkspaceCmd; -impl RegisterCommand for WorkspaceCmd { +impl RegisterCommand for WorkspaceCmd { fn info() -> &'static CommandInfo { &COMMAND_INFO } + fn handler() -> CommandHandler { + CommandHandler::Contextual { + capabilities: CommandCapabilities::WORKSPACE.union(CommandCapabilities::PRESENTATION), + handler: workspace, + } + } +} + +fn workspace(contexts: CommandContexts<'_>, arg: Option<&str>) -> CommandResult { + let parts = contexts.into_parts(); + let Some(workspace) = parts.workspace else { + return CommandResult::error("Command capability unavailable: workspace"); + }; + let Some(presentation) = parts.presentation else { + return CommandResult::error("Command capability unavailable: presentation"); + }; + match arg.map(str::trim).filter(|arg| !arg.is_empty()) { + Some("worktrees" | "worktree" | "wt") => CommandResult::action(Action::OpenWorktreeManager), + other => { + workspace_switch(workspace, presentation, other).unwrap_or_else(CommandResult::error) + } + } +} - fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { - match arg.map(str::trim).filter(|a| !a.is_empty()) { - Some("worktrees" | "worktree" | "wt") => { - CommandResult::action(AppAction::OpenWorktreeManager) - } - other => super::core::workspace_switch(app, other), +fn workspace_switch( + workspace: &dyn CommandWorkspaceContext, + presentation: &dyn CommandPresentationContext, + arg: Option<&str>, +) -> Result { + let Some(raw_path) = arg.map(str::trim).filter(|path| !path.is_empty()) else { + return Ok(CommandResult::message(presentation.translate( + "workspace_current", + &[("path", &workspace.workspace().display().to_string())], + )?)); + }; + let expanded = expand_workspace_path(workspace, presentation, raw_path)?; + let candidate = if expanded.is_absolute() { + expanded + } else { + workspace.workspace().join(expanded) + }; + let target = match workspace.inspect_workspace_target(&candidate) { + WorkspaceTarget::Missing => { + return Ok(CommandResult::error(presentation.translate( + "workspace_not_found", + &[("path", &candidate.display().to_string())], + )?)); } + WorkspaceTarget::NotDirectory => { + return Ok(CommandResult::error(presentation.translate( + "workspace_not_directory", + &[("path", &candidate.display().to_string())], + )?)); + } + WorkspaceTarget::Directory { + canonical_or_candidate, + } => canonical_or_candidate, + }; + let message = presentation.translate( + "workspace_switching", + &[("path", &target.display().to_string())], + )?; + Ok(CommandResult::with_message_and_action( + message, + Action::SwitchWorkspace { workspace: target }, + )) +} + +fn expand_workspace_path( + workspace: &dyn CommandWorkspaceContext, + presentation: &dyn CommandPresentationContext, + path: &str, +) -> Result { + if path == "~" || path.starts_with("~/") { + let Some(home) = workspace.effective_home_dir() else { + return Err(presentation.translate("workspace_home_unresolved", &[])?); + }; + return Ok(match path.strip_prefix("~/") { + Some(rest) => home.join(rest), + None => home, + }); } + Ok(PathBuf::from(path)) } diff --git a/crates/tui/src/commands/groups/memory/note.rs b/crates/tui/src/commands/groups/memory/note.rs index 8f2fc30b4e..4f531c8c8d 100644 --- a/crates/tui/src/commands/groups/memory/note.rs +++ b/crates/tui/src/commands/groups/memory/note.rs @@ -345,6 +345,28 @@ mod tests { } impl CommandWorkspaceContext for FakeWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by note") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("navigation is not used by note") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by note") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result + { + unreachable!("anchors are not used by note") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by note") + } fn workspace(&self) -> PathBuf { self.path.clone() } diff --git a/crates/tui/src/commands/groups/utility/task.rs b/crates/tui/src/commands/groups/utility/task.rs index fa4bb7a955..e49bc4c938 100644 --- a/crates/tui/src/commands/groups/utility/task.rs +++ b/crates/tui/src/commands/groups/utility/task.rs @@ -86,6 +86,28 @@ mod tests { struct FakeWorkspace; impl codewhale_command_contract::facets::CommandWorkspaceContext for FakeWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by task") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("navigation is not used by task") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by task") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result + { + unreachable!("anchors are not used by task") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by task") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } @@ -99,6 +121,28 @@ mod tests { struct FailingWorkspace; impl codewhale_command_contract::facets::CommandWorkspaceContext for FailingWorkspace { + fn effective_home_dir(&self) -> Option { + unreachable!("navigation is not used by task") + } + fn inspect_workspace_target( + &self, + _: &std::path::Path, + ) -> codewhale_command_contract::navigation::WorkspaceTarget { + unreachable!("navigation is not used by task") + } + fn read_anchor_document(&self) -> Result, String> { + unreachable!("anchors are not used by task") + } + fn append_anchor_bytes( + &mut self, + _: &[&[u8]], + ) -> Result + { + unreachable!("anchors are not used by task") + } + fn replace_anchor_document(&mut self, _: &[u8]) -> Result<(), String> { + unreachable!("anchors are not used by task") + } fn workspace(&self) -> PathBuf { PathBuf::from(".") } diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 1974653017..d77c9c76c5 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -13,6 +13,9 @@ mod config_main_import_host; #[cfg(test)] mod config_policy_host; mod contract; +mod core_navigation_host; +#[cfg(test)] +mod core_navigation_host_tests; pub mod discovery; pub(crate) mod groups; @@ -85,7 +88,8 @@ pub(crate) use groups::memory::{notes_path, read_notes}; pub use groups::project::share; // Voice capture plumbing shared with the hotbar and the UI event loop. -pub use groups::core::voice; +#[path = "core_navigation_voice_host.rs"] +pub mod voice; #[cfg(test)] mod debug_diagnostics_baseline_tests; @@ -651,6 +655,22 @@ mod tests { Some(AppAction::SendMessage(message)) => assert_eq!(message, "user help now"), other => panic!("expected user command SendMessage action, got {other:?}"), } + std::fs::write(commands_dir.join("voice.md"), "user voice").unwrap(); + super::user_registry::reload(Some(temp.path())); + assert!( + matches!(execute("/voice", &mut app).action, Some(AppAction::SendMessage(message)) if message == "user voice") + ); + app.voice_enabled = true; + let shortcut = crate::commands::voice::voice(&mut app); + assert!(!shortcut.is_error && shortcut.action.is_none()); + assert!( + !app.voice_enabled, + "builtin voice shortcut must not dispatch a shadowing user command" + ); + let missing = + super::core_navigation_host::dispatch(&mut app, "__missing_navigation_builtin__", None); + assert!(missing.is_error && missing.action.is_none()); + assert!(!app.voice_enabled); } #[test] @@ -1348,6 +1368,182 @@ mod tests { #[test] fn command_info_resolves_canonical_names_and_aliases() { + use codewhale_command_contract::handler::{ + CommandCapabilities as Caps, CommandContexts, CommandHandler, ContextParts, + }; + struct MissingTemplate; + impl codewhale_command_contract::facets::CommandPresentationContext for MissingTemplate { + fn translate(&self, _: &str, _: &[(&str, &str)]) -> Result { + Err("template unavailable".into()) + } + } + for (name, service, needs_presentation) in [ + ("help", Caps::NAVIGATION, true), + ("home", Caps::NAVIGATION, true), + ("overview", Caps::NAVIGATION, true), + ("links", Caps::NAVIGATION, true), + ("anchor", Caps::WORKSPACE, false), + ("workspace", Caps::WORKSPACE, true), + ("translate", Caps::CONFIG, true), + ("voice", Caps::VOICE_INPUT, true), + ("voicesend", Caps::VOICE_INPUT, true), + ("voicecontrol", Caps::VOICE_INPUT, true), + ] { + let expected = if needs_presentation { + service | Caps::PRESENTATION + } else { + service + }; + let CommandHandler::Contextual { + capabilities, + handler, + } = registry().get(name).unwrap().contextual_handler().unwrap() + else { + panic!("/{name} must declare scoped authority"); + }; + assert_eq!(capabilities, expected); + let missing = handler(CommandContexts::empty(), Some("missing-facet-input")); + assert!(missing.is_error && missing.action.is_none()); + let mut app = create_test_app(); + app.launch.dismiss(); + let before_view = format!("{:?}", app.view_stack.top_kind()); + let before_translation = app.translation_enabled; + app.voice_enabled = name == "voice"; + let before_voice = ( + app.voice_enabled, + app.voice_send_enabled, + app.voice_control_enabled, + ); + if needs_presentation { + let missing_presentation = { + let mut bundle = app.command_contexts(); + handler(bundle.contexts(service), None) + }; + assert!(missing_presentation.is_error && missing_presentation.action.is_none()); + assert!( + missing_presentation + .message + .as_deref() + .is_some_and(|text| text.contains("presentation")) + ); + } + if name == "translate" || service == Caps::VOICE_INPUT { + let mut failing = MissingTemplate; + let failure = { + let mut bundle = app.command_contexts(); + handler( + bundle.contexts(service).with_presentation(&mut failing), + None, + ) + }; + assert!(failure.is_error && failure.action.is_none()); + } + assert!(!app.launch.visible); + assert_eq!(format!("{:?}", app.view_stack.top_kind()), before_view); + assert_eq!(app.translation_enabled, before_translation); + assert_eq!( + ( + app.voice_enabled, + app.voice_send_enabled, + app.voice_control_enabled + ), + before_voice + ); + let mut bundle = app.command_contexts(); + let ContextParts { + session, + model, + cost, + mode_policy, + system_prompt, + skills, + workspace, + presentation, + media, + memory, + project, + skill_group, + plugin, + lifecycle, + control, + export, + structcopy, + debug_receipts, + debug_change, + debug_history, + debug_diff, + debug_undo, + debug_diagnostics, + permissions, + config_status, + config, + pet, + trust, + login, + work_surface, + navigation, + voice_input, + } = bundle.contexts(capabilities).into_parts(); + for (capability, exposed) in [ + (Caps::SESSION, session.is_some()), + (Caps::MODEL, model.is_some()), + (Caps::COST, cost.is_some()), + (Caps::MODE_POLICY, mode_policy.is_some()), + (Caps::SYSTEM_PROMPT, system_prompt.is_some()), + (Caps::SKILLS, skills.is_some()), + (Caps::WORKSPACE, workspace.is_some()), + (Caps::PRESENTATION, presentation.is_some()), + (Caps::MEDIA, media.is_some()), + (Caps::MEMORY, memory.is_some()), + (Caps::PROJECT, project.is_some()), + (Caps::SKILL_GROUP, skill_group.is_some()), + (Caps::PLUGIN, plugin.is_some()), + (Caps::SESSION_LIFECYCLE, lifecycle.is_some()), + (Caps::SESSION_CONTROL, control.is_some()), + (Caps::SESSION_EXPORT, export.is_some()), + (Caps::SESSION_STRUCTCOPY, structcopy.is_some()), + (Caps::DEBUG_RECEIPTS, debug_receipts.is_some()), + (Caps::DEBUG_CHANGE, debug_change.is_some()), + (Caps::DEBUG_HISTORY, debug_history.is_some()), + (Caps::DEBUG_DIFF, debug_diff.is_some()), + (Caps::DEBUG_UNDO, debug_undo.is_some()), + (Caps::DEBUG_DIAGNOSTICS, debug_diagnostics.is_some()), + (Caps::PERMISSIONS, permissions.is_some()), + (Caps::CONFIG_STATUS, config_status.is_some()), + (Caps::CONFIG, config.is_some()), + (Caps::PET, pet.is_some()), + (Caps::TRUST, trust.is_some()), + (Caps::LOGIN, login.is_some()), + (Caps::WORK_SURFACE, work_surface.is_some()), + (Caps::NAVIGATION, navigation.is_some()), + (Caps::VOICE_INPUT, voice_input.is_some()), + ] { + assert_eq!( + exposed, + expected.contains(capability), + "/{name} facet {capability:?}" + ); + } + } + for (name, expected) in [ + ("hotbar", AppAction::OpenHotbarSetup), + ("transcript", AppAction::OpenLiveTranscript), + ("turn", AppAction::OpenTurnInspector), + ] { + let handler = registry().get(name).unwrap().contextual_handler().unwrap(); + let codewhale_command_contract::handler::CommandHandler::Pure(run) = handler else { + panic!("/{name} must be pure and require no host envelope"); + }; + let result = run(None); + assert_eq!(result.action, Some(expected.clone())); + assert!(result.message.is_none()); + assert!(!result.is_error); + let mut app = create_test_app(); + let shortcut = super::core_navigation_host::dispatch(&mut app, name, None); + assert_eq!(shortcut.action, Some(expected)); + assert!(shortcut.message.is_none()); + assert!(!shortcut.is_error); + } for command in command_infos() { for lookup in [command.name.to_string(), format!("/{}", command.name)] { let resolved = get_command_info(&lookup) @@ -1485,6 +1681,101 @@ mod tests { /// On hosts without a recorder it must fail gracefully instead. #[test] fn voice_command_toggles_on_and_off_or_fails_gracefully() { + use codewhale_command_contract::handler::{CommandContexts, CommandHandler}; + struct VoiceState { + enabled: bool, + available: bool, + probes: std::cell::Cell, + } + impl codewhale_command_contract::navigation::CommandVoiceInputContext for VoiceState { + fn recorder_available(&self) -> bool { + self.probes.set(self.probes.get() + 1); + self.available + } + fn voice_enabled(&self) -> bool { + self.enabled + } + fn set_voice_enabled(&mut self, enabled: bool) { + self.enabled = enabled; + } + fn send_enabled(&self) -> bool { + unreachable!("voice must not read send state") + } + fn set_send_enabled(&mut self, _: bool) { + unreachable!("voice must not set send state") + } + fn control_enabled(&self) -> bool { + unreachable!("voice must not read control state") + } + fn set_control_enabled(&mut self, _: bool) { + unreachable!("voice must not set control state") + } + } + struct Labels { + fail: bool, + } + impl codewhale_command_contract::facets::CommandPresentationContext for Labels { + fn translate(&self, key: &str, _: &[(&str, &str)]) -> Result { + if self.fail { + return Err("missing fixture template".into()); + } + match key { + "voice_enabled" => Ok("Voice enabled".into()), + "voice_disabled" => Ok("Voice disabled".into()), + "voice_err_no_recorder" => Ok("Recorder unavailable".into()), + _ => Err("unexpected voice label".into()), + } + } + } + let CommandHandler::Contextual { handler, .. } = registry() + .get("voice") + .unwrap() + .contextual_handler() + .unwrap() + else { + panic!("voice must be contextual"); + }; + for available in [false, true] { + let mut voice = VoiceState { + enabled: false, + available, + probes: std::cell::Cell::new(0), + }; + let mut labels = Labels { fail: false }; + let on = handler( + CommandContexts::empty() + .with_voice_input(&mut voice) + .with_presentation(&mut labels), + None, + ); + assert_eq!(on.is_error, !available); + assert_eq!(voice.enabled, available); + assert_eq!(on.action, available.then_some(AppAction::VoiceCapture)); + assert_eq!(voice.probes.get(), 1); + voice.enabled = true; + voice.available = false; + let off = handler( + CommandContexts::empty() + .with_voice_input(&mut voice) + .with_presentation(&mut labels), + None, + ); + assert!(!off.is_error && off.action.is_none() && !voice.enabled); + assert_eq!( + voice.probes.get(), + 1, + "voice-off must not probe recorder availability" + ); + voice.available = true; + labels.fail = true; + let failed = handler( + CommandContexts::empty() + .with_voice_input(&mut voice) + .with_presentation(&mut labels), + None, + ); + assert!(failed.is_error && failed.action.is_none() && !voice.enabled); + } let mut app = create_test_app(); let result = execute("/voice", &mut app); if app.voice_enabled { @@ -2171,6 +2462,19 @@ mod tests { // register bounded portable groups/slices; every entry outside the // explicit list must still use the original legacy dispatcher. const MIGRATED_GROUPS: &[&str] = &[ + "voice", + "voicesend", + "voicecontrol", + "translate", + "anchor", + "workspace", + "help", + "home", + "overview", + "links", + "hotbar", + "transcript", + "turn", // FEAT-018 utility group. "attach", "automation", diff --git a/crates/tui/src/commands/session_export_test_support.rs b/crates/tui/src/commands/session_export_test_support.rs index 2e2128eb11..e669c39b1c 100644 --- a/crates/tui/src/commands/session_export_test_support.rs +++ b/crates/tui/src/commands/session_export_test_support.rs @@ -126,6 +126,8 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { trust, login, work_surface, + navigation, + voice_input, } = parts; assert!( permissions.is_none() @@ -135,6 +137,8 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() + && voice_input.is_none() ); assert!(export.is_some(), "the export facet must be exposed"); diff --git a/crates/tui/src/commands/session_structcopy_host_tests.rs b/crates/tui/src/commands/session_structcopy_host_tests.rs index fc21c543e3..612d8497aa 100644 --- a/crates/tui/src/commands/session_structcopy_host_tests.rs +++ b/crates/tui/src/commands/session_structcopy_host_tests.rs @@ -374,6 +374,8 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross trust, login, work_surface, + navigation, + voice_input, } = bundle.contexts(capabilities).into_parts(); assert!( permissions.is_none() @@ -383,6 +385,8 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross && trust.is_none() && login.is_none() && work_surface.is_none() + && navigation.is_none() + && voice_input.is_none() ); assert!(presentation.is_some()); assert!( diff --git a/docs/LOCALIZATION.md b/docs/LOCALIZATION.md index 9f9a33dfd0..181e9fa674 100644 --- a/docs/LOCALIZATION.md +++ b/docs/LOCALIZATION.md @@ -196,11 +196,15 @@ carry an explicit `planned`/`partial`/`deferred` row in this matrix. from step 2), and the `/config` value list (`config_choice_values` in `crates/tui/src/tui/views/mod.rs`) and hint text (`configured_locale_values`) derive from `Locale::shipped()`, so those need - no edit. The exhaustive `match` arms the compiler will point at are the - setup wizard (`crates/tui/src/tui/setup/mod.rs`), `locale_display` in - `crates/tui/src/commands/groups/config/config.rs`, and - `public_site_locale_segment` (the `/links` site path) in - `crates/tui/src/commands/groups/core/core.rs`. Add a `LANGUAGE_OPTIONS` + no edit. Review the setup wizard (`crates/tui/src/tui/setup/mod.rs`) and + `locale_display` in `crates/tui/src/commands/groups/config/config.rs` for + explicit locale mappings. Also review `public_site_locale_segment` in + `crates/tui/src/commands/groups/core/links.rs`: the portable `/links` command + matches locale tags, not the host `Locale` enum. Unlisted tags fall back to + English website pages, so the compiler will not flag a newly added locale + here. Add an explicit tag mapping when that locale is available on the website, + and verify `/links` selects the intended documentation/community URLs. + Add a `LANGUAGE_OPTIONS` entry to the onboarding language picker (`crates/tui/src/tui/onboarding/language.rs`); its `picker_offers_every_shipped_locale` test fails until you do. Several no-English-leak tests diff --git a/docs/zh_hans/LOCALIZATION.md b/docs/zh_hans/LOCALIZATION.md index bd6aef8aa8..8680358e95 100644 --- a/docs/zh_hans/LOCALIZATION.md +++ b/docs/zh_hans/LOCALIZATION.md @@ -177,11 +177,14 @@ partial 区域设置在网站上还差的范围(下一波):首页之外的 `normalize_configured_locale` 校验(它复用第 2 步的 `parse_locale`);`/config` 的取值列表(`crates/tui/src/tui/views/mod.rs` 里的 `config_choice_values`)和提示文字 (`configured_locale_values`)都从 `Locale::shipped()` 派生,所以这两处不用改。 - 编译器会指出来的穷尽 `match` 分支有三处:设置向导 - (`crates/tui/src/tui/setup/mod.rs`)、 - `crates/tui/src/commands/groups/config/config.rs` 里的 `locale_display`, - 以及 `crates/tui/src/commands/groups/core/core.rs` 里的 `public_site_locale_segment` - (`/links` 的站点路径)。还要给引导流程的语言选择器 + 检查设置向导(`crates/tui/src/tui/setup/mod.rs`)以及 + `crates/tui/src/commands/groups/config/config.rs` 里的 `locale_display` + 是否需要更新显式区域设置映射。还要检查 + `crates/tui/src/commands/groups/core/links.rs` 里的 `public_site_locale_segment`: + 可移植的 `/links` 命令匹配的是区域设置 tag,而不是宿主的 `Locale` 枚举。 + 未列出的 tag 会回退到英文网站页面,因此编译器不会在这里提醒新增的区域设置。 + 当网站提供该语言版本时,添加显式 tag 映射,并验证 `/links` 选择了正确的文档和社区 URL。 + 还要给引导流程的语言选择器 (`crates/tui/src/tui/onboarding/language.rs`)加一条 `LANGUAGE_OPTIONS` 条目; 不加的话,它的 `picker_offers_every_shipped_locale` 测试会失败。有几个 “不泄漏英文”的测试(例如 `status_picker.rs` 和 `tool_card.rs` 里的)显式列出了 diff --git a/scripts/check-blocking-calls-budget.json b/scripts/check-blocking-calls-budget.json index 70b1a50cec..559521bd67 100644 --- a/scripts/check-blocking-calls-budget.json +++ b/scripts/check-blocking-calls-budget.json @@ -96,15 +96,12 @@ "std_fs": 2 }, "crates/tui/src/commands/contract.rs": { - "path_canonicalize": 4, + "path_canonicalize": 5, "std_fs": 3 }, "crates/tui/src/commands/groups/core/acceptance.rs": { "std_fs": 1 }, - "crates/tui/src/commands/groups/core/core.rs": { - "path_canonicalize": 1 - }, "crates/tui/src/commands/groups/plugins/render.rs": { "path_canonicalize": 1 }, diff --git a/scripts/check-command-core-navigation-proof.py b/scripts/check-command-core-navigation-proof.py new file mode 100644 index 0000000000..3a99047b0e --- /dev/null +++ b/scripts/check-command-core-navigation-proof.py @@ -0,0 +1,148 @@ +from pathlib import Path +import argparse +import re +import tomllib + +ROOT = Path(__file__).resolve().parent.parent +GROUP = "crates/tui/src/commands/groups/core" +ROOTS = (f"{GROUP}/navigation.rs",) +PROOF = "tests/portable-core-navigation/src/lib.rs" +MANIFEST = "tests/portable-core-navigation/Cargo.toml" +HOST = f"{GROUP}/mod.rs" +PAIRS = ( + ("anchor", "AnchorCmd"), ("help", "HelpCmd"), ("hotbar", "HotbarCmd"), + ("links", "LinksCmd"), ("transcript", "TranscriptCmd"), ("turn", "TurnCmd"), + ("home", "HomeCmd"), ("home", "OverviewCmd"), ("workspace", "WorkspaceCmd"), + ("translate", "TranslateCmd"), ("voice", "VoiceCmd"), + ("voice", "VoiceSendCmd"), ("voice", "VoiceControlCmd"), +) +NAMES = { + "anchor": ("anchor",), "help": ("help",), "hotbar": ("hotbar",), + "links": ("links",), "transcript": ("transcript",), "turn": ("turn",), + "home": ("home", "overview"), "workspace": ("workspace",), + "translate": ("translate",), "voice": ("voice", "voicesend", "voicecontrol"), +} +REQUIRED = {ROOTS[0]} | {f"{GROUP}/{name}.rs" for name in NAMES} +ALLOWED_NORMAL = { + "codewhale-portable-core-navigation", "codewhale-command-contract", "codewhale-protocol", + "chrono", "iana-time-zone", "android_system_properties", "libc", "core-foundation-sys", + "iana-time-zone-haiku", "js-sys", "cfg-if", "futures-util", "futures-core", "futures-task", + "pin-project-lite", "slab", "wasm-bindgen", "once_cell", "wasm-bindgen-macro", "quote", + "proc-macro2", "unicode-ident", "wasm-bindgen-macro-support", "bumpalo", "syn", + "wasm-bindgen-shared", "log", "windows-core", "windows-implement", "windows-interface", + "windows-link", "windows-result", "windows-strings", "num-traits", "serde", "serde_core", + "serde_derive", "serde_json", "indexmap", "equivalent", "hashbrown", "itoa", "memchr", + "zmij", "uuid", "getrandom", "r-efi", +} +HOST_IMPORT = re.compile( + r"\b(?:App|AppAction|codewhale_(?:tui|core|runtime|config|execpolicy|secrets|state|localization))\b" + r"|\bcrate::(?:tui|core|config|commands)\b|\bsuper::core\b|\bBox\s*<" + r"|\bstd::(?:fs|net|process|env)\b|\binclude(?:_str|_bytes)?\s*!" +) +MODULE = re.compile(r'(?:#\[path\s*=\s*"([^"]+)"\]\s*)?(?:pub(?:\([^)]*\))?\s+)?mod\s+(\w+)\s*;') +PAIR = re.compile(r'\(\s*(\w+)::(\w+)::info\(\),\s*\1::\2::handler\(\),?\s*\)') + + +def source_text(path): + return "\n".join(line.split("//")[0] for line in path.read_text().splitlines()) + + +def conditional_source(text): + without_test_module = re.sub(r'#\[cfg\(test\)\]\s*mod\s+tests\s*\{', 'mod tests {', text) + return bool(re.search(r'#\s*\[\s*cfg(?:_attr)?\b', without_test_module)) + + +def graph_violations(graph): + names = {line.split()[0] for line in graph.splitlines() if line.strip()} + errors = [f"unapproved normal dependency: {name}" for name in sorted(names - ALLOWED_NORMAL)] + for required in ("codewhale-portable-core-navigation", "codewhale-command-contract", "codewhale-protocol"): + if required not in names: + errors.append(f"missing actual normal graph package: {required}") + return errors + + +def source_closure(root): + pending = [root / name for name in ROOTS] + seen, errors = set(), [] + while pending: + path = pending.pop().resolve() + if path in seen: + continue + if not path.is_relative_to(root) or not path.is_file(): + errors.append(f"missing or out-of-tree navigation source: {path}") + continue + seen.add(path) + text = source_text(path) + if HOST_IMPORT.search(text): + errors.append(f"host/service in portable navigation source: {path.relative_to(root)}") + if conditional_source(text): + errors.append(f"conditional production source is not a normal proof: {path.relative_to(root)}") + for explicit, name in MODULE.findall(text): + if explicit: + child = path.parent / explicit + else: + base = path.parent if path.name in {"mod.rs", "lib.rs"} else path.with_suffix("") + child = base / f"{name}.rs" + if not child.is_file(): + child = base / name / "mod.rs" + pending.append(child) + return {str(path.relative_to(root)) for path in seen}, errors + + +def violations(root): + root = root.resolve() + errors = [] + wrapper = root / PROOF + mount = re.fullmatch(r'\s*#\[path\s*=\s*"([^"]+)"\]\s*pub mod navigation;\s*', wrapper.read_text()) + if not mount or (wrapper.parent / mount.group(1)).resolve() != (root / ROOTS[0]).resolve(): + errors.append("proof must mount only the actual complete production navigation root normally") + manifest = tomllib.loads((root / MANIFEST).read_text()) + if manifest.get("package", {}).get("publish") is not False: + errors.append("proof must remain unpublished") + deps = manifest.get("dependencies", {}) + contract = deps.get("codewhale-command-contract", {}) + if set(deps) != {"codewhale-command-contract"} or not isinstance(contract, dict) or (root / MANIFEST).parent.joinpath(contract.get("path", "")).resolve() != (root / "crates/command-contract").resolve(): + errors.append("actual shared contract must be the sole normal direct dependency") + if manifest.get("dev-dependencies") or manifest.get("build-dependencies") or manifest.get("target"): + errors.append("proof cannot hide a host or substitute behind test/build/target dependencies") + closure, source_errors = source_closure(root) + errors.extend(source_errors) + for name in sorted(REQUIRED - closure): + errors.append(f"incomplete navigation source closure: {name}") + for name in sorted(REQUIRED): + path = root / name + if path.is_file(): + text = source_text(path) + if HOST_IMPORT.search(text): + errors.append(f"host/service in declared movable source: {name}") + if conditional_source(text): + errors.append(f"conditional declared movable source: {name}") + inventory = source_text(root / ROOTS[0]) + if tuple(PAIR.findall(inventory)) != PAIRS or not re.search(r';\s*13\s*\]', inventory): + errors.append("production inventory must contain all 13 real matching registrations in original filtered order") + for module, expected in NAMES.items(): + path = root / GROUP / f"{module}.rs" + if path.is_file() and tuple(re.findall(r'\bname:\s*"([^"]+)"', source_text(path))) != expected: + errors.append(f"canonical metadata mismatch: {module}") + actual = [int(index) for index in re.findall( + r'core_navigation_host::registration::<(\d+)>\(\)', source_text(root / HOST) + )] + if actual != list(range(len(PAIRS))): + errors.append("real host core registry must contain all 13 inventory entries exactly once in relative order") + return errors + + +def main(): + parser = argparse.ArgumentParser(description="Verify the real complete core-navigation slice and its all-target normal graph") + parser.add_argument("--graph", type=Path, required=True) + args = parser.parse_args() + errors = violations(ROOT) + graph_violations(args.graph.read_text()) + for error in errors: + print(f"[core-navigation-proof] FAIL: {error}") + if not errors: + print("[core-navigation-proof] PASS: all 13 actual registrations, complete source closure, host parity and approved normal graph; core remains pending") + return bool(errors) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check-portable-core-navigation.sh b/scripts/check-portable-core-navigation.sh new file mode 100644 index 0000000000..7968a8136d --- /dev/null +++ b/scripts/check-portable-core-navigation.sh @@ -0,0 +1,10 @@ +#!/bin/sh +set -eu +cd "$(dirname "$0")/.." +graph=$(mktemp) +trap 'rm -f "$graph"' EXIT HUP INT TERM +cargo tree --locked -p codewhale-portable-core-navigation --edges normal --all-features --target all --prefix none > "$graph" +cat "$graph" +python3 -B scripts/check-command-core-navigation-proof.py --graph "$graph" +env CARGO_INCREMENTAL=0 RUSTFLAGS=-Dwarnings cargo check --locked -p codewhale-portable-core-navigation --lib --no-default-features +env CARGO_TERM_COLOR=always CARGO_INCREMENTAL=0 RUSTFLAGS=-Dwarnings RUST_MIN_STACK=16777216 CODEWHALE_EXT_HOST_TESTS=1 CODEWHALE_EXT_HOST_BUN_TESTS=1 sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-portable-core-navigation --lib --all-features --locked --profile ci --no-tests=fail diff --git a/scripts/dev-test.sh b/scripts/dev-test.sh index c0d105724d..71e894a60a 100755 --- a/scripts/dev-test.sh +++ b/scripts/dev-test.sh @@ -53,6 +53,7 @@ lane cargo test -p codewhale-lane --lib --locked mcp cargo test -p codewhale-mcp --lib --locked paths cargo test -p codewhale-paths --lib --locked portable-config cargo test -p codewhale-portable-config --lib --locked +portable-core-navigation cargo test -p codewhale-portable-core-navigation --lib --locked protocol cargo test -p codewhale-protocol --lib --locked release cargo test -p codewhale-release --lib --locked runtime cargo test -p codewhale-runtime --lib --locked @@ -114,6 +115,12 @@ if [ -e "$area" ] || printf '%s' "$area" | grep -q /; then tests/portable-config/*|tests/portable-config) area=portable-config ;; + tests/portable-core-navigation/*|tests/portable-core-navigation) + area=portable-core-navigation + ;; + crates/command-contract/src/navigation.rs) + area=command-contract + ;; crates/tui/tests/integration/*) area=tui-integration extra=$(basename "$rel" .rs) @@ -188,6 +195,7 @@ case $area in mcp) pkg=codewhale-mcp ;; paths) pkg=codewhale-paths ;; portable-config) pkg=codewhale-portable-config ;; + portable-core-navigation) pkg=codewhale-portable-core-navigation ;; protocol) pkg=codewhale-protocol ;; release) pkg=codewhale-release ;; runtime) pkg=codewhale-runtime ;; diff --git a/scripts/test_check_command_config_proof.py b/scripts/test_check_command_config_proof.py index 20801d8e1a..74049bb9f8 100644 --- a/scripts/test_check_command_config_proof.py +++ b/scripts/test_check_command_config_proof.py @@ -99,7 +99,7 @@ def test_shapes_cannot_move_to_dev_dependencies(self): def test_direct_and_transitive_runtime_or_storage_edges_are_rejected(self): allowed = ("codewhale-portable-config v0.10.2\ncodewhale-command-contract v0.10.2\n" - "codewhale-protocol v0.10.2\nserde v1.0.0\n") + "codewhale-protocol v0.10.2\ncodewhale-sanitize v0.10.2\nserde v1.0.0\n") self.assertEqual(proof.graph_violations(allowed), []) for package in ["codewhale-tui", "codewhale-config", "codewhale-core", "codewhale-runtime", "codewhale-execpolicy", "codewhale-secrets", "codewhale-state", diff --git a/scripts/test_check_command_core_navigation_proof.py b/scripts/test_check_command_core_navigation_proof.py new file mode 100644 index 0000000000..03c3307419 --- /dev/null +++ b/scripts/test_check_command_core_navigation_proof.py @@ -0,0 +1,125 @@ +import importlib.util +from pathlib import Path +import re +import tempfile +import unittest + +path = Path(__file__).with_name("check-command-core-navigation-proof.py") +spec = importlib.util.spec_from_file_location("navigation_proof", path) +proof = importlib.util.module_from_spec(spec) +spec.loader.exec_module(proof) + + +class NavigationProofTests(unittest.TestCase): + def fixture(self, root): + for relative in proof.REQUIRED | {proof.PROOF, proof.MANIFEST, proof.HOST}: + target = root / relative + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text((proof.ROOT / relative).read_text()) + + def test_actual_complete_group_is_included(self): + self.assertEqual(proof.violations(proof.ROOT), []) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + host = root / proof.HOST + source = host.read_text() + unrelated = 'Box::new(FunctionCommand::new(extra::ExtraCmd::info(), extra::ExtraCmd::execute)),\n' + host.write_text(source.replace( + 'crate::commands::core_navigation_host::registration::<0>(),', + unrelated + 'crate::commands::core_navigation_host::registration::<0>(),', + )) + self.assertEqual(proof.violations(root), []) + + def test_selected_leaf_stub_and_test_only_root_are_rejected(self): + actual = (proof.ROOT / proof.PROOF).read_text() + for source in [ + "pub mod navigation {}", + actual.replace("navigation.rs", "hotbar.rs"), + "#[cfg(test)]\n" + actual, + actual + "struct App;\n", + ]: + with self.subTest(source=source), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + (root / proof.PROOF).write_text(source) + self.assertTrue(proof.violations(root)) + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + source = root / proof.ROOTS[0] + source.write_text("#[cfg(test)]\n" + source.read_text()) + self.assertTrue(any("conditional" in error for error in proof.violations(root))) + + def test_omitted_root_or_helper_is_rejected(self): + for case in ["anchor", "voice", "inventory", "host_order", "host_duplicate", "host_reorder", "metadata", "include"]: + with self.subTest(case=case), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + source = root / proof.ROOTS[0] + if case in {"anchor", "voice"}: + source.write_text(re.sub(r'#\[path = "' + case + r'\.rs"\]\s*pub mod ' + case + r';', '', source.read_text())) + elif case == "inventory": + source.write_text(source.read_text().replace("(anchor::AnchorCmd::info(), anchor::AnchorCmd::handler()),", "")) + elif case == "host_order": + host = root / proof.HOST + host.write_text(host.read_text().replace("registration::<0>()", "registration::<99>()")) + elif case in {"host_duplicate", "host_reorder"}: + host = root / proof.HOST + text = host.read_text() + if case == "host_duplicate": + text = text.replace('registration::<1>()', 'registration::<0>()') + else: + text = text.replace('registration::<0>()', 'registration::()') + text = text.replace('registration::<1>()', 'registration::<0>()') + text = text.replace('registration::()', 'registration::<1>()') + host.write_text(text) + self.assertTrue(any('relative order' in error for error in proof.violations(root))) + elif case == "include": + source.write_text(source.read_text() + '\ninclude!("untracked-helper.rs");\n') + else: + leaf = root / proof.GROUP / "anchor.rs" + leaf.write_text(leaf.read_text().replace('name: "anchor"', 'name: "wrong"')) + self.assertTrue(proof.violations(root)) + + def test_host_import_in_any_mounted_file_is_rejected(self): + for name in sorted(proof.REQUIRED): + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + leaf = root / name + leaf.write_text(leaf.read_text() + "\nuse crate::tui::app::App;\n") + self.assertTrue(proof.violations(root)) + + def test_host_import_in_unmounted_group_file_is_rejected(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + source = root / proof.ROOTS[0] + source.write_text(re.sub(r'#\[path = "voice\.rs"\]\s*pub mod voice;', '', source.read_text())) + leaf = root / proof.GROUP / "voice.rs" + leaf.write_text(leaf.read_text() + "\nuse codewhale_runtime::Runtime;\n") + errors = proof.violations(root) + self.assertTrue(any("host/service in declared movable source" in error for error in errors)) + + def test_shapes_cannot_move_to_dev_dependencies(self): + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + manifest = root / proof.MANIFEST + manifest.write_text(manifest.read_text().replace("[dependencies]", "[dev-dependencies]")) + self.assertTrue(proof.violations(root)) + + def test_direct_and_transitive_runtime_or_storage_edges_are_rejected(self): + allowed = "codewhale-portable-core-navigation v0.10.2\ncodewhale-command-contract v0.10.2\ncodewhale-protocol v0.10.2\nserde v1.0.0\n" + self.assertEqual(proof.graph_violations(allowed), []) + for package in ["codewhale-tui", "codewhale-core", "codewhale-runtime", "codewhale-config", + "codewhale-secrets", "codewhale-state", "codewhale-localization", "tokio", + "reqwest", "rusqlite", "keyring", "ratatui", "crossterm", "unreviewed-helper"]: + with self.subTest(package=package): + self.assertEqual(len(proof.graph_violations(allowed + f"{package} v1.0.0\n")), 1) + self.assertTrue(proof.graph_violations("serde v1.0.0\n")) + + +if __name__ == "__main__": + unittest.main(verbosity=2) diff --git a/tests/portable-core-navigation/Cargo.toml b/tests/portable-core-navigation/Cargo.toml new file mode 100644 index 0000000000..63f2130472 --- /dev/null +++ b/tests/portable-core-navigation/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "codewhale-portable-core-navigation" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +publish = false + +[dependencies] +codewhale-command-contract = { path = "../../crates/command-contract", version = "0.10.2" } + +[lints] +workspace = true diff --git a/tests/portable-core-navigation/README.md b/tests/portable-core-navigation/README.md new file mode 100644 index 0000000000..6275e62cb5 --- /dev/null +++ b/tests/portable-core-navigation/README.md @@ -0,0 +1,58 @@ +# Core navigation normal-library proof + +This unpublished workspace package mounts the **actual production navigation +root**, all ten leaf modules and all 13 registrations. It contains no substitute +App, copied implementation, test-only root or host adapter. Shared metadata, +results/actions, facets and nested payloads are normal dependencies. + +Run sequentially from the repository root: + +```sh +python3 -B scripts/test_check_command_core_navigation_proof.py +sh scripts/check-portable-core-navigation.sh +``` + +The proof checks the declared complete source closure and real host inventory +relative order (without pinning unrelated core entries), an all-feature/all-target **normal** dependency graph, a no-default +normal-library build, and nonzero tests from the actual portable leaves. +Rejection controls cover omitted/reordered registrations, metadata drift, +selected-leaf/stub/test-only wrappers, conditional production modules and +unreviewed include macros, host imports (even in an omitted owned leaf), test-only +dependency substitution and unapproved transitive packages. + +The approved graph is deliberately explicit: command-contract/protocol data; +serde/JSON/indexed containers and their derive/parser support; chrono/UUID and +existing platform/timezone/random primitives, including their wasm/Windows +support. These are inherited shared data dependencies, not permission to add +TUI, application runtime, configuration, HTTP, terminal or credential-storage +services. New normal packages require source review, not automatic approval. + +Host registration, concrete filesystem/view/config effects, real-App tests and +voice recording/transcription remain outside the movable slice. The rest of core +is not claimed portable by this package: the top-level `core` frontier remains +pending for its other 27 registrations at adoption. Physical movement is a later feature. + +## Developer and CI coverage + +`sh scripts/dev-test.sh crates/tui/src/commands/groups/core/links.rs` (and the +other command source paths) runs the native `commands::` tests, including the +relocated host tests. The `portable-core-navigation` area or proof-package paths +select the fast portable tests; they do not replace host verification. +The heavy Lint job checks the source inventory and normal graph on main as well +as PRs; the Ubuntu PR test job additionally runs the full standalone proof above. +Dependency additions still require explicit review: tolerating unrelated registry +insertions must not silently admit runtime or storage services into the closure. + +## Capability limitations + +`NAVIGATION` and `VOICE_INPUT` occupy the last two bits of the existing `u32`. +No free bit remains. A future capability must first make an explicit, reviewed +representation/scoping decision; do not silently widen or renumber identities. + +The current `WORKSPACE` facet includes confined anchor read/append/replace, +path inspection and home-directory resolution, not just path reads. Other +first-party WORKSPACE consumers therefore have access to those operations even +though they do not call them. Exact facet selection is not per-operation least +authority. Splitting anchor authority is a separate contract decision; this +adoption preserves the agreed workspace facet and does not claim that narrowing +has already been implemented. diff --git a/tests/portable-core-navigation/src/lib.rs b/tests/portable-core-navigation/src/lib.rs new file mode 100644 index 0000000000..99b97d5274 --- /dev/null +++ b/tests/portable-core-navigation/src/lib.rs @@ -0,0 +1,2 @@ +#[path = "../../../crates/tui/src/commands/groups/core/navigation.rs"] +pub mod navigation;