mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-07 17:09:13 -10:00
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 after8867749f5. 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 mergefc5ac7d904. 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:
48 files changed
+3305
-1699
No files matched your search
@@ -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
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>;
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(".")
|
||||
}
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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` 里的)显式列出了
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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;
|
||||
Reference in new issue
Block a user