refactor(commands): adopt portable core navigation Shapes (FEAT-031) (#7009)

* test(commands): reconcile baseline capability and config proof guards

Match /init's existing WORKSPACE|PRESENTATION authority after 8867749f5. Approve only the already-declared pure sanitizer in the config normal graph; preserve all existing service rejection controls.

FEAT-031 P0 baseline: 19066 nextest passed, 34 skipped; 3 doctests passed, 8 ignored; config proof 35 passed and 7 controls. Full Clippy, debug/release builds and format passed. No navigation implementation or product behavior change. Refs #5316.

* refactor(commands): adopt pure core navigation outcomes and registration

FEAT-031 P2 replaces hotbar/transcript/turn FunctionCommand App wrappers with shared pure handlers, real navigation inventory and exhaustive host action conversion. Preserve the original metadata, grammar, output and nine leaf assertions; core remains pending.

Verified: 79 contract and 482 scoped native tests pass; 1125 actual observations across 15 locales plus all 40 metadata entries replay byte-identically. Full Clippy, rustfmt, 30 boundary and 63 migration controls pass. Complete 13-command portability proof remains P4; no physical move. Refs #5316.

* refactor(commands): adopt stateful core navigation Shapes

FEAT-031 P3 replaces the App-bound help/home/overview/links/anchor/workspace/translate wrappers and nine core.rs helpers with command-owned parsing/rendering, NAVIGATION bit30, narrow workspace/config operations and explicit host adapters. Retain all34 relocated host tests, old capability identities and confinement controls. No physical move or complete-core claim.

Verified:79 contract and1289 commands/help/voice tests pass;1125 native observations across15 locales plus40 metadata entries remain byte-identical. Full Clippy, formatting,30 boundary/63 migration controls and locale guards pass; all failures/retries retained. Refs #5316.

* refactor(commands): complete portable core navigation voice boundary

FEAT-031 P4 replaces the remaining voice App handlers with VOICE_INPUT bit31 Shapes, preserving u32 identities and exact authority. Relocate the unchanged capture runtime outside the movable group and retain builtin shortcut semantics. The real complete13-entry root compiles independently as an unpublished normal library; core remains pending for other slices.

Verified:79 contract and1289 native command/help/voice tests;1125 observations/15 locales and40 metadata entries byte-identical;9 actual-source portable tests and7 rejection controls. Normal graph/build, full Clippy/fmt,30 boundary/63 migration controls, locales, developer routing and publication-order validation pass. No physical move or release. Refs #5316.

* test(commands): strengthen navigation preservation assertions

FEAT-031 P5 extends existing host tests for user help precedence/alias shadowing, one-dismissal help behavior, home-directory routes and exact anchor read/open/partial-write/replacement failures. Production code and all original test identities remain unchanged.

Verified:1289 commands/help/voice tests pass after correcting the fixture's comma-separated alias syntax;58 native/PTY acceptance tests pass against rebuilt current CLI. Contract79, navigation9+7 controls, config35+7, session65+5, boundary30/migration63 and locale/format checks pass. Numeric final coverage remains pending. Refs #5316.

* docs(web): refresh generated agent docs from current main

* fix(commands): address core navigation extraction review

Relocate the existing canonicalization budget without increasing total authority or blocking-call allowance. Correct both localization guides, restore native command tests for command source paths, and make the complete navigation proof tolerate unrelated registry insertions while rejecting omitted/duplicate/reordered entries. Enforce source and normal graph checks on heavy main and PR lint jobs; retain explicit dependency review.

Verified: 1215 native command tests (including all 34 relocated host tests), 9 portable tests, 7 proof controls, 34 blocking-budget controls, workflow regression, docs/facts and formatting pass. Preserved maintainer merge fc5ac7d904. Workspace operation-level authority remains an explicitly documented follow-up; no capability renumbering or widening. Refs #5316.
Signed-off-by: Paulo Aboim Pinto <[email protected]>

* fix(commands): resolve builtin voice shortcut by canonical name

Address the non-blocking PR #7009 review nits: replace dispatch::<10> with builtin-registry lookup using VOICE_INFO.name, preserve capability-scoped dispatch and user-command shadowing isolation, and fix all 13 proof-message spacing.

Verified 1215 native command tests, including existing shadowed-voice and pure action assertions plus safe missing-name rejection; 9 portable tests and 7 proof controls pass. Scoped TUI Clippy and formatting pass. No workspace authority redesign; maintainers own that follow-up and the post-v0.10.2 merge timing. Refs #5316.

Signed-off-by: Paulo Aboim Pinto <[email protected]>

* fix(security): patch follow-redirects in Feishu and WeCom bridges

Resolve the two red npm-audit checks on PR #7009 (GHSA-q3jh-wrc6-jgq8 and GHSA-8r9p-f939-6h3c). Update only the follow-redirects lock entries from 1.16.0 to 1.16.1; keep SDK versions, manifests and all other packages unchanged. No audit exclusions or threshold changes.

Verified on Node 22.22.1: clean locked installs, syntax checks, Feishu 19/19 tests and WeCom 17/17 tests, zero vulnerabilities in both npm audit JSON reports, and installed dependency trees at 1.16.1. Original failing reports retained. Refs #5316.

Signed-off-by: Paulo Aboim Pinto <[email protected]>

---------

Signed-off-by: Paulo Aboim Pinto <[email protected]>
Co-authored-by: Paulo Aboim Pinto <[email protected]>
Co-authored-by: CodeWhale Bot <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Hunter Bown <[email protected]>
This commit is contained in:
authored and GitHub committed 2026-10-22 21:20:49 +00:00
1 parent e9eac753cb
commit 6c2b1d83be
48 files changed
+3305 -1699

No files matched your search

+15
View File
@@ -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/[email protected]
# The extension-host integration tests spawn the real bundle under
# Node >= 22.19; CODEWHALE_EXT_HOST_TESTS below makes a missing Node a
Generated
+7
View File
@@ -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"
+1
View File
@@ -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"]
+2
View File
@@ -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<PathBuf>;
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<CommandConfigAuditReport, String>;
+9 -1
View File
@@ -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<PathBuf>;
fn inspect_workspace_target(&self, candidate: &Path) -> crate::navigation::WorkspaceTarget;
fn read_anchor_document(&self) -> Result<Option<String>, String>;
fn append_anchor_bytes(
&mut self,
parts: &[&[u8]],
) -> Result<PathBuf, crate::navigation::AnchorWriteError>;
fn replace_anchor_document(&mut self, bytes: &[u8]) -> Result<(), String>;
fn work_state_snapshot(&self) -> Result<Option<String>, String>;
/// Session-aware canonical operation digest. Returns the final user-facing
/// digest text or a safe explicit error; never a serialized snapshot.
+33 -1
View File
@@ -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(),
+1
View File
@@ -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;
+105
View File
@@ -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<String>,
pub usage: Option<String>,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavigationBuiltinHelp {
pub name: String,
pub usage: String,
pub description: String,
pub aliases: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavigationHelpTopic {
pub user: Option<NavigationUserHelp>,
pub builtin: Option<NavigationBuiltinHelp>,
pub shadowed_names: Vec<String>,
}
#[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<String>,
}
#[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<String>,
pub docs_url: Option<String>,
pub guidance: String,
pub env_names: Vec<String>,
pub kind: NavigationProviderKind,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct NavigationLinks {
pub active_provider: String,
pub locale_tag: String,
pub providers: Vec<NavigationProviderLinks>,
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<NavigationSkill>;
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);
}
+14
View File
@@ -162,3 +162,17 @@ pub type ConfigCommandResult = CommandResult<ConfigAction>;
pub type ConfigPolicyAction = ConfigAction;
pub type ConfigPolicyCommandResult = ConfigCommandResult;
pub type ConfigStatusCommandResult = CommandResult<std::convert::Infallible>;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum CoreNavigationAction {
OpenHotbarSetup,
DisableHotbar,
RestoreHotbarDefaults,
OpenLiveTranscript,
OpenTurnInspector,
OpenWorktreeManager,
SwitchWorkspace { workspace: std::path::PathBuf },
VoiceCapture,
}
pub type CoreNavigationCommandResult = CommandResult<CoreNavigationAction>;
+42
View File
@@ -121,6 +121,24 @@ impl CommandSkillsContext for Skills {
struct Workspace;
impl CommandWorkspaceContext for Workspace {
fn effective_home_dir(&self) -> Option<std::path::PathBuf> {
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<Option<String>, String> {
unreachable!("anchors are not used by this fixture")
}
fn append_anchor_bytes(
&mut self,
_: &[&[u8]],
) -> Result<std::path::PathBuf, crate::navigation::AnchorWriteError> {
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<std::path::PathBuf> {
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<Option<String>, String> {
unreachable!("anchors are not used by digest")
}
fn append_anchor_bytes(
&mut self,
_: &[&[u8]],
) -> Result<std::path::PathBuf, crate::navigation::AnchorWriteError> {
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);
}
+58 -4
View File
@@ -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<MessageId> {
"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<MessageId> {
"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<PathBuf> {
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<Option<String>, String> {
core_navigation::read_anchor_document(&self.workspace())
}
fn append_anchor_bytes(
&mut self,
parts: &[&[u8]],
) -> Result<PathBuf, codewhale_command_contract::navigation::AnchorWriteError> {
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<Option<String>, 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 },
}
}
}
@@ -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<PathBuf> {
self.host.app.borrow().config_path.clone()
}
@@ -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<NavigationSkill> {
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<Option<String>, 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<std::path::PathBuf, codewhale_command_contract::navigation::AnchorWriteError> {
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<MessageId> {
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,
})
}
@@ -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<const INDEX: usize>;
impl<const INDEX: usize> RegisterCommand<CommandResult> for Registration<INDEX> {
fn info() -> &'static CommandInfo {
portable_handlers()[INDEX].0
}
fn handler() -> CommandHandler<CommandResult> {
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<const INDEX: usize>() -> Box<dyn Command> {
Box::new(
ContextualCommand::from_contract::<Registration<INDEX>>().expect("navigation registration"),
)
}
@@ -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 <skill>` 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 <target>\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 <target>"));
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<Option<String>, String> {
unreachable!("unexpected work observation")
}
fn operation_digest(&mut self) -> Result<String, String> {
unreachable!("unexpected work observation")
}
fn effective_home_dir(&self) -> Option<std::path::PathBuf> {
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<Option<String>, 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<std::path::PathBuf, codewhale_command_contract::navigation::AnchorWriteError>
{
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"));
}
}
@@ -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<VoiceCaptureOutcome, String> {
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<parking_lot::Mutex<Vec<i16>>> =
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)
);
}
}
@@ -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!(
@@ -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),
@@ -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<std::path::PathBuf> {
panic!("config facet touched")
}
+53 -256
View File
@@ -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 <text> | /anchor list | /anchor remove <n>";
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<CommandResult> for AnchorCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
anchor(app, arg)
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: CommandCapabilities::WORKSPACE,
handler: anchor,
}
}
}
/// Handle the `/anchor` command with subcommands:
/// - `/anchor <text>` — add a new anchor
/// - `/anchor list` — list all anchors
/// - `/anchor remove <n>` — 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());
}
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}"))
}
}
// 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;
}
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<Vec<String>, 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<Vec<String>, 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 <text> 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 <n> 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 <n>.",
);
}
};
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"));
}
}
+16 -779
View File
@@ -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::<Vec<_>>();
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.<name>]` 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 <skill>` 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<String> {
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<PathBuf, String> {
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 <skill>` 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}"
);
}
}
+150 -12
View File
@@ -1,26 +1,164 @@
//! `/help` 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::{
CommandNavigationContext, NavigationSkill, NavigationUserHelp,
};
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: "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<CommandResult> for HelpCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, arg: Option<&str>) -> CommandResult {
super::core::help(app, arg)
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION),
handler: help,
}
}
}
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<CommandResult, String> {
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::<Vec<_>>();
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.<name>]` 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<Option<String>, 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<String, 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
.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)
}
+132 -115
View File
@@ -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<CommandResult> 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<CommandResult> {
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<CommandResult> for OverviewCmd {
fn info() -> &'static CommandInfo {
&CommandInfo {
name: "overview",
aliases: &["stats"],
usage: "/overview",
description_id: MessageId::CmdOverviewDescription,
description_key: "cmd_overview_description",
}
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
super::core::home_dashboard(app)
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: CommandCapabilities::NAVIGATION.union(CommandCapabilities::PRESENTATION),
handler: overview,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
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 = HomeCmd::execute(&mut app, None);
assert!(result.is_error);
assert!(!app.launch.visible);
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()
}
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)
}
fn render_overview(
presentation: &dyn CommandPresentationContext,
facts: NavigationOverview,
) -> Result<String, String> {
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
);
}
if facts.subagent_count > 0 {
let _ = writeln!(
stats,
"{} {} active",
label("home_subagents")?,
facts.subagent_count
);
}
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)
}
+47 -67
View File
@@ -1,93 +1,78 @@
//! `/hotbar` command.
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::tui::app::{App, AppAction};
use codewhale_localization::MessageId;
use codewhale_command_contract::handler::CommandHandler;
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use codewhale_command_contract::outcome::{
CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult,
};
use super::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<CommandResult> 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<CommandResult> {
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!(
+125 -12
View File
@@ -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<CommandResult> for LinksCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
super::core::codewhale_links(app)
fn handler() -> CommandHandler<CommandResult> {
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 render_links(
presentation: &dyn CommandPresentationContext,
facts: NavigationLinks,
) -> Result<String, String> {
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)
}
+14 -62
View File
@@ -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<dyn Command>] {
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
@@ -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<CoreNavigationCommandResult>,
); 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(),
),
]
}
@@ -1,28 +1,28 @@
//! `/transcript` command.
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::tui::app::{App, AppAction};
use codewhale_localization::MessageId;
use codewhale_command_contract::handler::CommandHandler;
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use codewhale_command_contract::outcome::{
CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult,
};
use super::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<CommandResult> for TranscriptCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(_app: &mut App, _arg: Option<&str>) -> CommandResult {
CommandResult::action(AppAction::OpenLiveTranscript)
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Pure(|_| CommandResult::action(Action::OpenLiveTranscript))
}
}
@@ -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<CommandResult> for TranslateCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
super::core::translate(app)
fn handler() -> CommandHandler<CommandResult> {
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)
}
+26 -36
View File
@@ -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 codewhale_command_contract::handler::CommandHandler;
use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand};
use codewhale_command_contract::outcome::{
CoreNavigationAction as Action, CoreNavigationCommandResult as CommandResult,
};
use super::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<CommandResult> 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<CommandResult> {
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
+99 -299
View File
@@ -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<CommandResult> for VoiceCmd {
fn info() -> &'static CommandInfo {
&VOICE_INFO
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
voice(app)
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION),
handler: voice,
}
}
}
impl RegisterCommand for VoiceSendCmd {
impl RegisterCommand<CommandResult> for VoiceSendCmd {
fn info() -> &'static CommandInfo {
&VOICE_SEND_INFO
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
voice_send(app)
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION),
handler: voice_send,
}
}
}
impl RegisterCommand for VoiceControlCmd {
impl RegisterCommand<CommandResult> for VoiceControlCmd {
fn info() -> &'static CommandInfo {
&VOICE_CONTROL_INFO
}
fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult {
voice_control(app)
fn handler() -> CommandHandler<CommandResult> {
CommandHandler::Contextual {
capabilities: CommandCapabilities::VOICE_INPUT.union(CommandCapabilities::PRESENTATION),
handler: voice_control,
}
}
}
// --- 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<VoiceCaptureOutcome, String> {
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())
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 (asr_kind, _asr_model) = resolve_asr_choice(config);
if asr_kind == "provider" {
provider_key()?;
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);
}
// 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<parking_lot::Mutex<Vec<i16>>> =
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()))
}
// --- 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));
}
if !is_available() {
return CommandResult::error(tr(locale, MessageId::VoiceErrNoRecorder));
}
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)
let key = if enabled {
"voice_enabled"
} else {
tr(locale, MessageId::VoiceSendDisabled)
"voice_disabled"
};
CommandResult::message(msg)
}
/// 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)
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 {
tr(locale, MessageId::VoiceControlDisabled)
};
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)
);
CommandResult::message(message)
}
}
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 {
"voice_send_disabled"
};
let message = match presentation.translate(key, &[]) {
Ok(message) => message,
Err(error) => return CommandResult::error(error),
};
voice.set_send_enabled(enabled);
CommandResult::message(message)
}
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 {
"voice_control_disabled"
};
let message = match presentation.translate(key, &[]) {
Ok(message) => message,
Err(error) => return CommandResult::error(error),
};
voice.set_control_enabled(enabled);
CommandResult::message(message)
}
@@ -1,31 +1,108 @@
//! `/workspace` command.
use crate::commands::traits::{CommandInfo, RegisterCommand};
use crate::tui::app::{App, AppAction};
use codewhale_localization::MessageId;
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;
use super::CommandResult;
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<CommandResult> for WorkspaceCmd {
fn info() -> &'static CommandInfo {
&COMMAND_INFO
}
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 handler() -> CommandHandler<CommandResult> {
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 workspace_switch(
workspace: &dyn CommandWorkspaceContext,
presentation: &dyn CommandPresentationContext,
arg: Option<&str>,
) -> Result<CommandResult, String> {
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<PathBuf, String> {
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))
}
@@ -345,6 +345,28 @@ mod tests {
}
impl CommandWorkspaceContext for FakeWorkspace {
fn effective_home_dir(&self) -> Option<std::path::PathBuf> {
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<Option<String>, String> {
unreachable!("anchors are not used by note")
}
fn append_anchor_bytes(
&mut self,
_: &[&[u8]],
) -> Result<std::path::PathBuf, codewhale_command_contract::navigation::AnchorWriteError>
{
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()
}
@@ -86,6 +86,28 @@ mod tests {
struct FakeWorkspace;
impl codewhale_command_contract::facets::CommandWorkspaceContext for FakeWorkspace {
fn effective_home_dir(&self) -> Option<std::path::PathBuf> {
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<Option<String>, String> {
unreachable!("anchors are not used by task")
}
fn append_anchor_bytes(
&mut self,
_: &[&[u8]],
) -> Result<std::path::PathBuf, codewhale_command_contract::navigation::AnchorWriteError>
{
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<std::path::PathBuf> {
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<Option<String>, String> {
unreachable!("anchors are not used by task")
}
fn append_anchor_bytes(
&mut self,
_: &[&[u8]],
) -> Result<std::path::PathBuf, codewhale_command_contract::navigation::AnchorWriteError>
{
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(".")
}
+305 -1
View File
@@ -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<String, String> {
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<usize>,
}
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<String, String> {
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",
@@ -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");
@@ -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!(
+9 -5
View File
@@ -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
+8 -5
View File
@@ -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` 里的)显式列出了
+1 -4
View File
@@ -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
},
@@ -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())
+10
View File
@@ -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
+8
View File
@@ -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 ;;
+1 -1
View File
@@ -99,7 +99,7 @@ class ConfigProofTests(unittest.TestCase):
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",
@@ -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::<TEMP>()')
text = text.replace('registration::<1>()', 'registration::<0>()')
text = text.replace('registration::<TEMP>()', '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)
+14
View File
@@ -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
+58
View File
@@ -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.
@@ -0,0 +1,2 @@
#[path = "../../../crates/tui/src/commands/groups/core/navigation.rs"]
pub mod navigation;