From 593d7d71715885ea44b654ce688764d044b93443 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Tue, 13 Oct 2026 16:26:59 +0200 Subject: [PATCH 1/5] refactor(FEAT-028): adopt portable config shapes for the complete config group Reconciled onto current origin/main (39f073346). The config Slash Command Group no longer depends on App/TUI: every command parses, branches and renders in the portable group, and the host returns typed facts and operation outcomes. Carries upstream behavior added since the original base: - /connect and /disconnect (now portable commands in the group inventory, 19 registrations); - /pet style and /pet language (new portable pet appearance controls); - mode receipts use the localized display name and the Full Access (Work) label; - legacy sandbox token clearing (LEGACY_SANDBOX_TOKEN_SLOT) instead of Daytona. Contract: capability bits and facet traits for the group; unified ConfigAction with portable payload mirrors; ConfigReply with keyed/literal ConfigMessage (self-recursive, no Box). Safe rendering: capabilities and required templates are validated before any effect; prepared rendering substitutes validated templates and never re-translates after a mutation; a missing capability, invalid replacement contract or unprepared key fails loudly with no action; the interactive reply renderer shares the same policy. /settings, the audit and the subagent report return structured rows the group renders; /workbar surfaces setter errors; /import-claude plans use the host display projection. Guards: the migration frontier advances from [config, core] to [core], and the live-gate expectation moves with it. CI lane fixes: the portable proof imports, the moved test helper path, the settings row formatting (to_string instead of a useless format!), the contributor credit surface, and the preflight large-Err allowance. Verification: clippy (workspace, all-targets, all-features, -D warnings with the project's allowances) clean; config-proof PASS (35 portable tests) + 7 negative controls; migration-manifest tests PASS; boundary guard PASS; 353 config/settings tests pass; broad commands/app/ui/settings 2796 pass with only the pre-existing upstream failure tui::ui::tests::persisted_queued_native_skill_is_denied_after_host_withdrawal (reproduces on clean origin/main); cargo fmt clean. --- .github/workflows/ci.yml | 4 +- Cargo.lock | 2 +- Cargo.toml | 2 +- crates/command-contract/src/config.rs | 728 ++ crates/command-contract/src/facets.rs | 21 + crates/command-contract/src/handler.rs | 75 + crates/command-contract/src/lib.rs | 1 + crates/command-contract/src/outcome.rs | 58 +- crates/command-contract/src/tests.rs | 39 + crates/command-contract/src/types.rs | 81 + crates/tui/src/commands/config_group.rs | 119 + crates/tui/src/commands/config_host.rs | 513 ++ crates/tui/src/commands/config_main_host.rs | 6231 +++++++++++++ .../src/commands/config_main_import_host.rs | 260 + crates/tui/src/commands/config_policy_host.rs | 20 +- .../src/commands/config_policy_host_tests.rs | 2 +- .../config_policy_permissions_tests.rs | 18 +- crates/tui/src/commands/contract.rs | 125 +- .../tui/src/commands/contract/config_main.rs | 544 ++ .../src/commands/contract/config_messages.rs | 39 + .../debug_diagnostics_surface_tests.rs | 1 + .../src/commands/debug_mutation_host_tests.rs | 1 + .../tui/src/commands/groups/config/config.rs | 8052 ++++------------- .../commands/groups/config/import_claude.rs | 478 +- crates/tui/src/commands/groups/config/mod.rs | 291 +- .../tui/src/commands/groups/config/policy.rs | 2 +- .../tui/src/commands/groups/config/status.rs | 2 +- .../tui/src/commands/groups/core/workflow.rs | 4 +- crates/tui/src/commands/groups/mod.rs | 2 +- crates/tui/src/commands/mod.rs | 43 +- .../commands/session_export_test_support.rs | 1 + .../commands/session_structcopy_host_tests.rs | 1 + crates/tui/src/import_claude.rs | 308 +- crates/tui/src/settings.rs | 385 +- ...proof.py => check-command-config-proof.py} | 64 +- scripts/check-portable-config-policy.sh | 11 - scripts/check-portable-config.sh | 13 + scripts/command-migration-topology.json | 1 - scripts/dev-test.sh | 8 +- .../test_check_command_config_policy_proof.py | 75 - scripts/test_check_command_config_proof.py | 114 + .../test_check_command_migration_manifest.py | 4 +- tests/portable-config-policy/src/lib.rs | 3 - .../Cargo.toml | 3 +- tests/portable-config/src/lib.rs | 7 + web/lib/release-credits.ts | 4 +- 46 files changed, 11405 insertions(+), 7355 deletions(-) create mode 100644 crates/command-contract/src/config.rs create mode 100644 crates/tui/src/commands/config_group.rs create mode 100644 crates/tui/src/commands/config_host.rs create mode 100644 crates/tui/src/commands/config_main_host.rs create mode 100644 crates/tui/src/commands/config_main_import_host.rs create mode 100644 crates/tui/src/commands/contract/config_main.rs create mode 100644 crates/tui/src/commands/contract/config_messages.rs rename scripts/{check-command-config-policy-proof.py => check-command-config-proof.py} (62%) delete mode 100644 scripts/check-portable-config-policy.sh create mode 100644 scripts/check-portable-config.sh delete mode 100644 scripts/test_check_command_config_policy_proof.py create mode 100644 scripts/test_check_command_config_proof.py delete mode 100644 tests/portable-config-policy/src/lib.rs rename tests/{portable-config-policy => portable-config}/Cargo.toml (86%) create mode 100644 tests/portable-config/src/lib.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 30dcb66e74..c97f95d090 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -907,8 +907,8 @@ jobs: 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_config_policy_proof.py - sh scripts/check-portable-config-policy.sh + python3 -B scripts/test_check_command_config_proof.py + sh scripts/check-portable-config.sh - uses: actions/setup-node@v7 # The extension-host integration tests spawn the real bundle under # Node >= 22.19; CODEWHALE_EXT_HOST_TESTS below makes a missing Node a diff --git a/Cargo.lock b/Cargo.lock index b0d1c7a58b..38a3db3bff 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1081,7 +1081,7 @@ dependencies = [ ] [[package]] -name = "codewhale-portable-config-policy" +name = "codewhale-portable-config" version = "0.10.2" dependencies = [ "codewhale-command-contract", diff --git a/Cargo.toml b/Cargo.toml index 668df68158..34f05eb443 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -30,7 +30,7 @@ members = [ "crates/workflow", "crates/workflow-js", "tests/portable-session", - "tests/portable-config-policy", + "tests/portable-config", ] exclude = ["vendor/codewhale-ratatui"] default-members = ["crates/cli"] diff --git a/crates/command-contract/src/config.rs b/crates/command-contract/src/config.rs new file mode 100644 index 0000000000..c8070a3716 --- /dev/null +++ b/crates/command-contract/src/config.rs @@ -0,0 +1,728 @@ +//! Config-group command facets (FEAT-028 D3/D4). +//! +//! These traits let `/config`, `/settings`, `/mode`, `/theme`, `/verbose`, +//! `/login`, `/logout`, `/pet` and `/trust` express their intent without naming +//! `App`, a service locator, a callback, or any concrete host type. Hosts own +//! persistence, services and I/O; the values here grant no terminal, TUI or +//! runtime authority, and a missing facet must fail safely rather than panic. + +use crate::outcome::ConfigAction; +use std::path::{Path, PathBuf}; + +/// A message the host raises for the command to render. +/// +/// The host never renders user-facing text for the config group: it returns a +/// stable key plus named replacements (each replacement is itself a message, +/// so a host never pre-renders a localized fragment), and the command resolves +/// it through the `presentation` shape. Non-localized text (protocol/config/ +/// command names, computed values) stays a `Literal`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigMessage { + /// Resolved by the presentation shape via its stable snake_case key. + Keyed { + key: String, + params: Vec<(String, ConfigMessage)>, + }, + /// Non-localized text. + Literal(String), + /// A sequence rendered in order, for literal framing around keyed parts. + Concat(Vec), +} + +impl ConfigMessage { + pub fn keyed(key: impl Into) -> Self { + Self::Keyed { + key: key.into(), + params: Vec::new(), + } + } + pub fn keyed_with(key: impl Into, params: Vec<(String, ConfigMessage)>) -> Self { + Self::Keyed { + key: key.into(), + params, + } + } + pub fn literal(text: impl Into) -> Self { + Self::Literal(text.into()) + } + pub fn concat(parts: Vec) -> Self { + Self::Concat(parts) + } +} + +impl From for ConfigMessage { + fn from(value: String) -> Self { + Self::Literal(value) + } +} + +impl From<&str> for ConfigMessage { + fn from(value: &str) -> Self { + Self::Literal(value.to_string()) + } +} + +impl From> for ConfigMessage { + fn from(value: std::borrow::Cow<'_, str>) -> Self { + Self::Literal(value.into_owned()) + } +} + +/// Portable result of a config-group host operation. +/// +/// The host raises a [`ConfigMessage`] and the single action the request +/// implies; the command resolves the message through the presentation shape and +/// maps the reply to `CommandResult` without touching `App`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ConfigReply { + pub message: Option, + pub action: Option, + pub is_error: bool, +} + +impl ConfigReply { + pub fn ok() -> Self { + Self { + message: None, + action: None, + is_error: false, + } + } + pub fn message(msg: impl Into) -> Self { + Self { + message: Some(msg.into()), + action: None, + is_error: false, + } + } + pub fn error(msg: impl Into) -> Self { + let msg = match msg.into() { + ConfigMessage::Literal(text) => ConfigMessage::Literal(format!("Error: {text}")), + other => other, + }; + Self { + message: Some(msg), + action: None, + is_error: true, + } + } + pub fn action(action: ConfigAction) -> Self { + Self { + message: None, + action: Some(action), + is_error: false, + } + } + pub fn with_message_and_action(msg: impl Into, action: ConfigAction) -> Self { + Self { + message: Some(msg.into()), + action: Some(action), + is_error: false, + } + } +} + +/// One structured `/settings` row: the label, its column width and its value. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandSettingsRow { + pub label: String, + pub value: String, + pub label_width: usize, +} + +/// Typed `/settings text` report. +/// +/// The host supplies display rows and the config-file path; the command owns +/// the title, separator, footer and joining. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandSettingsReport { + pub rows: Vec, + pub config_file: String, + pub from_settings: Vec, + pub session_overrides: Vec, +} + +/// Typed `/config subagents [status]` report. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandSubagentsReport { + pub disabled_reason: Option, + pub config_path: String, + pub provider_key: String, + pub provider_label: String, + pub explicit_enabled: Option, + pub limits: Vec, + /// Per-provider override rows for the active provider, when configured. + pub provider_rows: Option>, +} + +/// One of the five sub-agent limits: raw, resolved global and active-provider. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandSubagentsLimit { + pub key: &'static str, + pub raw: String, + pub global: String, + pub provider: String, +} + +/// One per-provider override row (already display-formatted). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandSubagentsProviderRow { + pub key: &'static str, + pub value: String, +} + +/// One row of the `/config status` editability audit. +/// +/// The host supplies typed facts; the command owns the table layout. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandConfigAuditRow { + pub key: String, + pub current: String, + pub editability: String, + pub command: String, + /// Keyed when the note is localized, literal otherwise. + pub note: ConfigMessage, +} + +/// Typed `/config status` audit: the resolved config path and its rows. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandConfigAuditReport { + pub config_path: String, + pub rows: Vec, +} + +/// The complete reply contract for mutating config operations. +/// +/// A mutating [`CommandConfigContext`] operation MUST return a [`ConfigReply`] +/// whose keyed messages use only these keys, each with exactly these parameter +/// names. The command validates every entry (and prepares the substituted +/// template) *before* the effect, so rendering a mutating reply is pure +/// substitution and never translates after the effect. A key or parameter set +/// outside this contract is a host bug. +pub const EFFECT_REPLY_TEMPLATES: &[(&str, &[&str])] = &[ + ("config_command_invalid_value", &["key", "value", "choices"]), + ("config_notification_updated", &["key", "value", "scope"]), + ("config_notifications_set_hint", &[]), + ("config_prompt_suggestion_updated", &["value", "scope"]), + ("config_scope_saved", &[]), + ("config_scope_session", &[]), + ("config_search_updated", &["value", "scope"]), + ("contextual_tips_not_saved", &["error"]), + ("help_usage_label", &[]), + ("startup_default_not_saved", &["setting", "error"]), +]; + +/// Read/write configuration, presets, the editability audit, login state and +/// the config-owned view/display commands (`/mode`, `/theme`, `/verbose`, +/// `/fullscreen`, `/inline`, `/statusbar`, `/workbar`, `/import-claude`). +/// +/// The host owns per-key validation, normalization, persistence and the exact +/// message; the portable handler owns argument parsing and subcommand routing. +/// A missing facet must fail safely rather than panic. +pub trait CommandConfigContext { + /// Path of the active user configuration file, when one is resolved. + fn config_path(&self) -> Option; + + /// `/config status|audit|editability|editable` — typed audit rows. + fn audit(&mut self) -> Result; + /// `/config workflow` / `/config goal` — typed effective facts. + fn workflow_summary(&mut self) -> CommandWorkflowSummary; + /// `/config subagents status` — typed subagent facts. + fn subagents_status(&mut self) -> Result; + /// `/config subagents ` — show one subagent setting. + fn subagent(&mut self, key: &str) -> ConfigReply; + /// `/config subagents [--save]`. + fn set_subagent(&mut self, key: &str, value: &str, persist: bool) -> ConfigReply; + /// `/config search.provider` — the current provider and source. + fn search_provider_status(&mut self) -> ConfigReply; + /// Accepted `search.provider` values, for the portable usage message. + fn search_provider_names(&self) -> &'static str; + /// `/config search.provider [--save]`. + fn set_search_provider(&mut self, value: &str, persist: bool) -> ConfigReply; + /// `/config notifications` — the notifications report. + fn notifications_status(&mut self) -> ConfigReply; + /// `/config notifications ` — show one notification setting. + fn notification(&mut self, key: &str) -> ConfigReply; + /// `/config notifications [--save]`. + fn set_notification(&mut self, key: &str, value: &str, persist: bool) -> ConfigReply; + /// `/config preset [--save]`. + fn apply_preset(&mut self, name: &str, persist: bool) -> ConfigReply; + /// `/config ` — show the current value. + fn get_value(&mut self, key: &str) -> ConfigReply; + /// `/config [--save]`. + fn set_value(&mut self, key: &str, value: &str, persist: bool) -> ConfigReply; + /// `/settings text` — typed settings facts. + fn show_settings(&mut self) -> Result; + /// `/theme schema` — the user theme JSON schema. + fn theme_schema(&self) -> String; + /// `/theme path` — the user themes directory, rendered for display. + fn themes_dir(&self) -> Result; + /// Current verbose-transcript flag. + fn verbose_enabled(&self) -> bool; + /// Set the verbose-transcript flag (the host also marks history updated). + fn set_verbose_enabled(&mut self, enabled: bool); + /// Current terminal screen mode, for `/fullscreen` and `/inline`. + fn screen_mode(&self) -> crate::types::CommandScreenMode; + /// `/import-claude` — build the plan from the host's home/workspace. + fn build_plan(&mut self) -> Option; + /// `/import-claude` — persist the rendered report and optional bundle. + fn write_artifacts(&mut self, report: &str, bundle: Option<&str>) -> CommandClaudeArtifacts; + /// `/import-claude --apply` — carry over the standing instructions. + fn apply_import( + &mut self, + plan: &CommandClaudePlan, + report_path: &Path, + wrote_report: bool, + ) -> CommandClaudeApplyOutcome; + /// Host display form of a path. + fn display_path(&self, path: &Path) -> String; +} + +/// Pet presence, mode, sound and habitat actions for `/pet`. +/// +/// The command owns the verb parsing and message composition; the facet only +/// observes and mutates the pet state and resolves the localized labels. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandPetControl { + Sound, + Browser, + Window, + Select, + FocusAgents, + /// Body style and/or text language for `/pet style|language`. + Appearance { + body: Option, + language: Option, + }, +} + +/// Portable pet body style for `/pet style`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandPetBodyStyle { + Smooth, + Pixel, +} + +impl CommandPetBodyStyle { + /// Canonical `pet_watch` token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Smooth => "smooth", + Self::Pixel => "pixel", + } + } + + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "smooth" => Some(Self::Smooth), + "pixel" => Some(Self::Pixel), + _ => None, + } + } +} + +/// Portable pet text language for `/pet language`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandPetTextLanguage { + Auto, + En, + ZhHans, + ZhHant, + Ja, +} + +impl CommandPetTextLanguage { + /// Canonical `pet_watch` token. + #[must_use] + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::En => "en", + Self::ZhHans => "zh-hans", + Self::ZhHant => "zh-hant", + Self::Ja => "ja", + } + } + + #[must_use] + pub fn parse(value: &str) -> Option { + match value { + "auto" => Some(Self::Auto), + "en" => Some(Self::En), + "zh-hans" => Some(Self::ZhHans), + "zh-hant" => Some(Self::ZhHant), + "ja" => Some(Self::Ja), + _ => None, + } + } +} + +/// Localized pet labels the command composes into its messages. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandPetLabel { + ModeOn, + ModeOff, + ModeOnLabel, + ModeOffLabel, + ViewOpen, + ViewClosed, + HabitatQueued, + ExportQueued, + ExportUnavailable, + SoundOn, + /// The habitat control could not be queued. + WatchUnavailable, +} + +pub trait CommandPetContext { + fn enabled(&self) -> bool; + fn is_open(&self) -> bool; + fn status(&self) -> String; + fn avatar_choices(&self) -> String; + fn select_avatar(&mut self, key: &str) -> bool; + fn preview_avatar(&mut self, name: &str, view: bool) -> bool; + fn set_enabled(&mut self, enabled: bool, persist: bool); + fn set_sound(&mut self, enabled: bool); + fn sound_label(&self) -> String; + fn export(&mut self) -> bool; + fn open_habitat(&mut self); + /// Queue a habitat control; `false` when it could not be delivered. + fn queue(&mut self, control: CommandPetControl) -> bool; + fn label(&self, which: CommandPetLabel) -> String; +} + +/// Account sign-in state for `/login`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandAccountState { + SignedOut, + Authenticated, + OfflineCached, + Expired, + Revoked, + Unavailable(String), +} + +/// `/login` status projection (the command composes the report text). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandLoginStatus { + pub account: CommandAccountState, + pub api_base: String, + pub provider: String, +} + +/// `/logout` result; each entry is `Ok(true)` cleared, `Ok(false)` absent, +/// `Err` the host error text. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandLogoutOutcome { + pub provider: String, + pub account: Result, + pub legacy_sandbox: Result, +} + +/// Failure to clear the active provider key during `/logout`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LogoutFailure { + pub provider: String, + pub error: String, +} + +/// Account/provider session state for `/login` and `/logout`. +pub trait CommandLoginContext { + fn login_status(&self) -> CommandLoginStatus; + fn logout(&mut self) -> Result; + /// `/connect []` — plugin provider sign-in. + fn connect(&mut self, provider: Option<&str>) -> ConfigReply; + /// `/disconnect ` — plugin provider sign-out. + fn disconnect(&mut self, provider: &str) -> ConfigReply; +} + +/// Workbar placement/panel observation and mutation for `/workbar`. +pub trait CommandWorkSurfaceContext { + fn placement(&self) -> crate::types::CommandWorkSurfacePlacement; + fn panel(&self) -> crate::types::CommandRailPanel; + fn effective_placement(&self) -> crate::types::CommandWorkSurfacePlacement; + fn area_present(&self) -> bool; + fn panel_title(&self, panel: crate::types::CommandRailPanel) -> String; + /// Apply a placement; when `persist`, also writes the setting. The reply is + /// the key-engine result (ignored on success, returned on error). + fn set_placement( + &mut self, + placement: crate::types::CommandWorkSurfacePlacement, + persist: bool, + ) -> ConfigReply; + fn set_panel(&mut self, panel: crate::types::CommandRailPanel, persist: bool) -> ConfigReply; + fn mark_redraw(&mut self); +} + +/// A path problem surfaced by `/trust add|remove`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TrustPathError { + /// The supplied path does not exist. + NotFound(PathBuf), + /// The allow-list could not be updated; carries the host error text. + UpdateFailed(String), +} + +/// Workspace trust and the per-path allow-list for `/trust`. +pub trait CommandTrustContext { + fn trust_mode(&self) -> bool; + fn trusted_paths(&self) -> Vec; + /// Add a path (the host expands `~` and validates existence). + fn add_trusted_path(&mut self, raw: &str) -> Result; + /// Returns the resolved path and whether it was removed. + fn remove_trusted_path(&mut self, raw: &str) -> Result<(PathBuf, bool), TrustPathError>; + /// The localized `Usage:` label used by the `/trust on|off` usage error. + fn usage_label(&self) -> String; +} + +// --------------------------------------------------------------------------- +// Portable payloads for host effects that cross the config boundary (D5). +// +// The TUI conversion layer has no access to `App`, so engine-affecting actions +// cannot be re-derived at conversion time. These mirrors carry the exact data +// the host needs, and the host converts each direction faithfully. +// --------------------------------------------------------------------------- + +/// Route capability fact, mirroring the config layer's `CapabilityState`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandCapabilityState { + Supported, + Unsupported, + Unknown, +} + +/// Compaction configuration carried by `ConfigAction::CompactionChanged`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandCompactionConfig { + pub enabled: bool, + pub token_threshold: usize, + pub model: String, + pub image_input: CommandCapabilityState, + pub effective_context_window: Option, + pub cache_summary: bool, + pub focus: Option, + pub runtime_cost_owner: Option, + pub workspace: Option, + pub summary_instructions: Option, + pub retained_user_message_tokens: usize, +} + +/// Subagent runtime configuration carried by `ConfigAction::SubagentRuntimeChanged`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CommandSubagentRuntimeConfig { + pub enabled: bool, + pub max_subagents: usize, + pub launch_concurrency: usize, + pub max_spawn_depth: u32, + pub api_timeout_secs: u64, + pub heartbeat_timeout_secs: u64, +} + +/// Search provider carried by `ConfigAction::SearchProviderChanged`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandSearchProvider { + Bing, + DuckDuckGo, + Firecrawl, + Tavily, + Bocha, + Metaso, + Searxng, + Baidu, + Volcengine, + Sofya, + Serply, +} + +/// Operator-notification delivery method. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandNotificationMethod { + Auto, + Osc9, + Bel, + Kitty, + Ghostty, + Off, +} + +/// Turn-completion sound choice. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandCompletionSound { + Off, + Beep, + Bell, + Whale, + File, +} + +/// When operator notifications are allowed. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandNotificationCondition { + Always, + Unfocused, + Never, +} + +/// Subagent-completion notification policy. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandSubagentCompletion { + Always, + FinalOnly, + Off, +} + +/// One notification event kind. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandNotificationEvent { + TurnComplete, + SubagentTerminal, + ApprovalNeeded, + InputNeeded, + ElevationNeeded, + ModelNotify, +} + +/// One validated notification edit, mirroring `NotificationConfigUpdate`. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandNotificationUpdate { + Method(CommandNotificationMethod), + ThresholdSecs(u64), + IncludeSummary(bool), + Quiet(bool), + Sound(Option), + Condition(CommandNotificationCondition), + CompletionSound(CommandCompletionSound), + SubagentCompletion(CommandSubagentCompletion), + SoundFile(PathBuf), + Event(CommandNotificationEvent, bool), + EventSoundEnabled(bool), + EventSoundEvents(Vec), + EventSoundMinIntervalMs(u64), + EventSoundQuiet(bool), +} + +// --------------------------------------------------------------------------- +// `/import-claude` plan model (portable). The host builds the plan from the +// Claude sources and performs the writes/apply; the command renders it. +// --------------------------------------------------------------------------- + +/// Effective `[workflow]`/`[goal]` facts for `/config workflow`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct CommandWorkflowSummary { + pub automatic: bool, + pub auto_start_read_only: bool, + pub require_approval_for_writes: bool, + pub max_children: u32, + pub max_concurrent: u32, + pub max_depth: u32, + pub default_token_budget: u64, + pub goal_max_continuations: u32, + pub goal_enforce_token_budget: bool, +} + +/// Outcome of reading one Claude source file. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandSourceStatus { + Found, + NotFound, + Oversize(u64), + InvalidJson, + Unreadable(String), +} + +impl CommandSourceStatus { + /// The one-line provenance entry used by the rendered plan and report. + /// + /// `display_path` is the host's display projection (for example `~/...`), + /// so the portable layer never leaks an absolute home path. + #[must_use] + pub fn describe(&self, display_path: &str) -> String { + match self { + Self::Found => format!("found: {display_path}"), + Self::NotFound => format!("not found: {display_path}"), + Self::Oversize(bytes) => { + format!("skipped, too large ({bytes} bytes): {display_path}") + } + Self::InvalidJson => format!("invalid JSON: {display_path}"), + Self::Unreadable(error) => format!("unreadable: {display_path} ({error})"), + } + } +} + +/// One MCP server discovered in the Claude sources. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandMcpCandidate { + pub name: String, + pub summary: String, + pub hard_blocked: bool, +} + +/// The full `/import-claude` plan. Pure data: building it mutates nothing. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommandClaudePlan { + pub sources: Vec<(PathBuf, CommandSourceStatus)>, + pub mcp_candidates: Vec, + pub per_project_mcp: Vec, + /// Safe env entries proposed for the portable bundle, key-sorted. + pub env_safe: Vec<(String, String)>, + pub env_skipped: Vec, + pub permissions_default_mode: Option, + pub permissions_allow: Vec, + pub permissions_ask: Vec, + pub permissions_deny: Vec, + pub hook_events: Vec, + pub has_claude_md: bool, + pub claude_projects: Vec, +} + +impl CommandClaudePlan { + #[must_use] + pub fn is_empty(&self) -> bool { + self.mcp_candidates.is_empty() + && self.per_project_mcp.is_empty() + && self.env_safe.is_empty() + && self.env_skipped.is_empty() + && self.permissions_default_mode.is_none() + && self.permissions_allow.is_empty() + && self.permissions_ask.is_empty() + && self.permissions_deny.is_empty() + && self.hook_events.is_empty() + && !self.has_claude_md + && self.claude_projects.is_empty() + } +} + +/// One typed `/import-claude --apply` follow-up. +/// +/// The host supplies the facts; the command composes the sentences. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CommandImportFollowUp { + InstructionsCopied { destination: String }, + InstructionsKeptExisting { destination: String, source: String }, + InstructionsCopyFailed { source: String, destination: String }, + HomeUnresolved, + McpBlocked { name: String }, + McpNeedsReview { name: String }, + HookEvents { count: usize }, + PermissionRules, +} + +/// Paths and write outcomes from persisting the report/bundle. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CommandClaudeArtifacts { + pub report_path: PathBuf, + pub bundle_path: PathBuf, + pub wrote_report: bool, + pub wrote_bundle: bool, +} + +/// Typed `--apply` outcome; the command composes the final message. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct CommandClaudeApplyOutcome { + pub done: Vec, + pub manual: Vec, + pub report_path: PathBuf, + pub wrote_report: bool, +} diff --git a/crates/command-contract/src/facets.rs b/crates/command-contract/src/facets.rs index 0d6d45c329..dbcff81640 100644 --- a/crates/command-contract/src/facets.rs +++ b/crates/command-contract/src/facets.rs @@ -72,6 +72,27 @@ pub trait CommandModePolicyContext { fn allow_shell(&self) -> bool; fn set_shell_access(&mut self, allow: bool); fn policy_locked(&self) -> bool; + /// Apply a mode selection the way `/mode` does, reporting which of the + /// three outcomes occurred (changed, same-but-persisted, refused). + fn select_mode(&mut self, mode: CommandMode) -> CommandModeSelection; + /// Apply the legacy YOLO alias (invisible Act + full access). + fn select_yolo_compat(&mut self) -> CommandModeSelection; + /// The receipt shown when the same mode was persisted as the default. + fn mode_startup_default_receipt(&self, mode: CommandMode) -> String; + /// The refusal text when a locked policy blocks the change. + fn setting_locked_mode_message(&self) -> String; + /// Localized display name for a mode (e.g. `Work`, `Plan`). + fn mode_label(&self, mode: CommandMode) -> String; + /// Localized label for the legacy YOLO alias (`Full Access (Work)`). + fn yolo_compat_label(&self) -> String; +} + +/// Outcome of a `/mode` selection (mirrors the host `SettingSelection`). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandModeSelection { + Changed, + PersistedSame, + Refused, } /// Read access to the effective system prompt. diff --git a/crates/command-contract/src/handler.rs b/crates/command-contract/src/handler.rs index 99f8b96548..f31c5289e2 100644 --- a/crates/command-contract/src/handler.rs +++ b/crates/command-contract/src/handler.rs @@ -86,6 +86,21 @@ impl CommandCapabilities { pub const PERMISSIONS: Self = Self(1 << 23); /// Read-only status observations, with no permission mutation authority. pub const CONFIG_STATUS: Self = Self(1 << 24); + /// Config-group host data (FEAT-028 D3), the next non-conflicting bit after + /// `CONFIG_STATUS`. Read/write settings, keys, presets, the editability + /// audit and login/logout observations for the config command group. It does + /// not grant pet, trust, mode, or workspace authority. + pub const CONFIG: Self = Self(1 << 25); + /// Pet presence/mode/sound/habitat host data (FEAT-028 D3), independent of + /// 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. + 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); /// Raw bit pattern, for tests that pin the capability-space capacity. /// @@ -154,6 +169,11 @@ pub struct CommandContexts<'a> { debug_diagnostics: Option<&'a mut dyn CommandDebugDiagnosticsContext>, permissions: Option<&'a mut dyn CommandPermissionsContext>, config_status: Option<&'a mut dyn CommandConfigStatusContext>, + config: Option<&'a mut dyn crate::config::CommandConfigContext>, + pet: Option<&'a mut dyn crate::config::CommandPetContext>, + 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>, } /// Consumed envelope used when one handler needs several independent facets. @@ -183,6 +203,11 @@ pub struct ContextParts<'a> { pub debug_diagnostics: Option<&'a mut dyn CommandDebugDiagnosticsContext>, pub permissions: Option<&'a mut dyn CommandPermissionsContext>, pub config_status: Option<&'a mut dyn CommandConfigStatusContext>, + pub config: Option<&'a mut dyn crate::config::CommandConfigContext>, + pub pet: Option<&'a mut dyn crate::config::CommandPetContext>, + 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>, } impl<'a> CommandContexts<'a> { @@ -213,6 +238,11 @@ impl<'a> CommandContexts<'a> { debug_diagnostics: None, permissions: None, config_status: None, + config: None, + pet: None, + trust: None, + login: None, + work_surface: None, } } @@ -243,9 +273,54 @@ impl<'a> CommandContexts<'a> { debug_diagnostics: self.debug_diagnostics, permissions: self.permissions, config_status: self.config_status, + config: self.config, + pet: self.pet, + trust: self.trust, + login: self.login, + work_surface: self.work_surface, } } + pub fn with_config(mut self, value: &'a mut dyn crate::config::CommandConfigContext) -> Self { + assert!( + self.config.replace(value).is_none(), + "config facet already set" + ); + self + } + + pub fn with_pet(mut self, value: &'a mut dyn crate::config::CommandPetContext) -> Self { + assert!(self.pet.replace(value).is_none(), "pet facet already set"); + self + } + + pub fn with_trust(mut self, value: &'a mut dyn crate::config::CommandTrustContext) -> Self { + assert!( + self.trust.replace(value).is_none(), + "trust facet already set" + ); + self + } + + pub fn with_login(mut self, value: &'a mut dyn crate::config::CommandLoginContext) -> Self { + assert!( + self.login.replace(value).is_none(), + "login facet already set" + ); + self + } + + pub fn with_work_surface( + mut self, + value: &'a mut dyn crate::config::CommandWorkSurfaceContext, + ) -> Self { + assert!( + self.work_surface.replace(value).is_none(), + "work-surface facet already set" + ); + self + } + pub fn with_permissions(mut self, value: &'a mut dyn CommandPermissionsContext) -> Self { assert!( self.permissions.replace(value).is_none(), diff --git a/crates/command-contract/src/lib.rs b/crates/command-contract/src/lib.rs index 870497db7a..6c645fbe11 100644 --- a/crates/command-contract/src/lib.rs +++ b/crates/command-contract/src/lib.rs @@ -3,6 +3,7 @@ //! Host registration and adapters remain with their clients. Decoupled command //! groups move into `codewhale-commands` with all consumers migrated per slice. +pub mod config; pub mod config_policy; pub mod elapsed; pub mod facets; diff --git a/crates/command-contract/src/outcome.rs b/crates/command-contract/src/outcome.rs index a622b6aaf0..a13508361a 100644 --- a/crates/command-contract/src/outcome.rs +++ b/crates/command-contract/src/outcome.rs @@ -105,10 +105,60 @@ pub enum SessionRemoteControlAction { pub type SessionCommandResult = CommandResult; -/// Only permission removal can request a host action in the config policy slice. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ConfigPolicyAction { +/// Complete config-group action vocabulary (FEAT-028 D5). Unifies the FEAT-027 +/// policy action with the auth, picker, view, screen-mode, trust and +/// host-effect actions of the main config slice. Constructing one never +/// executes it; the TUI host adapter converts it to a concrete `AppAction` +/// outside the movable closure. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ConfigAction { + /// A permission rule was safely removed; the host reloads active rules. PermissionRulesChanged, + // Auth flows (no host observation needed). + StartXaiDeviceLogin, + StartClaudeLogin, + StartClaudeRevoke, + StartChatgptPkceLogin, + StartChatgptRevoke, + StartOrcarouterPkceLogin, + StartOrcarouterRevoke, + // Views and pickers. + OpenConfigView, + OpenModePicker, + OpenThemePicker, + OpenStatusPicker, + OpenProviderPicker, + StartPluginLogin { + provider: String, + }, + StartPluginLogout { + provider: String, + }, + SetScreenMode(crate::types::CommandScreenMode), + SetWorkspaceTrust { + trusted: bool, + save: bool, + }, + // Engine/route effects carrying the exact host payload. + ModeChanged(crate::types::CommandMode), + ApprovalPolicyChanged { + policy: Option, + }, + SwitchProvider { + provider: crate::types::CommandProviderId, + model: Option, + }, + CompactionChanged(crate::config::CommandCompactionConfig), + StreamChunkTimeoutChanged(u64), + SubagentRuntimeChanged(crate::config::CommandSubagentRuntimeConfig), + SearchProviderChanged(crate::config::CommandSearchProvider), + PromptSuggestionChanged { + enabled: bool, + }, + NotificationChanged(crate::config::CommandNotificationUpdate), } -pub type ConfigPolicyCommandResult = CommandResult; +pub type ConfigCommandResult = CommandResult; +/// FEAT-027 compatibility alias; the whole group now standardizes on `ConfigAction`. +pub type ConfigPolicyAction = ConfigAction; +pub type ConfigPolicyCommandResult = ConfigCommandResult; pub type ConfigStatusCommandResult = CommandResult; diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index f7c2f93ea1..0910d32707 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -80,6 +80,25 @@ impl CommandModePolicyContext for Policy { fn policy_locked(&self) -> bool { false } + fn select_mode(&mut self, _mode: CommandMode) -> crate::facets::CommandModeSelection { + crate::facets::CommandModeSelection::Changed + } + fn select_yolo_compat(&mut self) -> crate::facets::CommandModeSelection { + crate::facets::CommandModeSelection::Changed + } + fn mode_startup_default_receipt(&self, _mode: CommandMode) -> String { + String::new() + } + fn setting_locked_mode_message(&self) -> String { + String::new() + } + fn mode_label(&self, mode: CommandMode) -> String { + format!("{mode:?}") + } + + fn yolo_compat_label(&self) -> String { + "Full Access (Work)".to_string() + } } struct Prompt; @@ -3532,6 +3551,9 @@ fn structcopy_capability_preserves_all_published_identities() { CommandCapabilities::SESSION_STRUCTCOPY, CommandCapabilities::PERMISSIONS, CommandCapabilities::CONFIG_STATUS, + CommandCapabilities::CONFIG, + CommandCapabilities::PET, + CommandCapabilities::TRUST, ]; let exact = CommandCapabilities::SESSION_STRUCTCOPY | CommandCapabilities::PRESENTATION; for (index, capability) in capabilities.into_iter().enumerate() { @@ -3541,6 +3563,22 @@ fn structcopy_capability_preserves_all_published_identities() { assert!(!exact.contains(CommandCapabilities::NONE)); } +#[test] +fn config_slots_are_optional_and_independent() { + assert_eq!(CommandCapabilities::CONFIG.bits_for_test(), 1 << 25); + assert_eq!(CommandCapabilities::PET.bits_for_test(), 1 << 26); + assert_eq!(CommandCapabilities::TRUST.bits_for_test(), 1 << 27); + let config = CommandCapabilities::CONFIG | CommandCapabilities::PRESENTATION; + let pet = CommandCapabilities::PET | CommandCapabilities::PRESENTATION; + let trust = CommandCapabilities::TRUST | CommandCapabilities::WORKSPACE; + assert!(!config.contains(CommandCapabilities::PET)); + assert!(!config.contains(CommandCapabilities::TRUST)); + assert!(!pet.contains(CommandCapabilities::CONFIG)); + assert!(!pet.contains(CommandCapabilities::TRUST)); + assert!(!trust.contains(CommandCapabilities::CONFIG)); + assert!(!trust.contains(CommandCapabilities::PET)); +} + #[test] fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { assert!(CommandContexts::empty().into_parts().structcopy.is_none()); @@ -3575,6 +3613,7 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { debug_diagnostics, permissions, config_status, + .. } = CommandContexts::empty() .with_structcopy(&mut fixture) .with_presentation(&mut presentation) diff --git a/crates/command-contract/src/types.rs b/crates/command-contract/src/types.rs index 3f8c8941ac..0d49ed27d2 100644 --- a/crates/command-contract/src/types.rs +++ b/crates/command-contract/src/types.rs @@ -20,6 +20,39 @@ pub enum CommandMode { Operate, } +impl CommandMode { + /// Parse a `/mode` argument, mirroring the config layer's `AppMode::parse`. + #[must_use] + pub fn parse(value: &str) -> Option { + match value.trim().to_ascii_lowercase().as_str() { + "agent" | "act" | "work" | "auto" | "1" => Some(Self::Agent), + "plan" | "2" => Some(Self::Plan), + "operate" | "operation" | "ops" | "3" => Some(Self::Operate), + other if Self::is_legacy_bypass_alias(other) => Some(Self::Agent), + _ => None, + } + } + + /// Legacy bypass spellings (`yolo`, `4`, `bypass`, …) that carry full access. + #[must_use] + pub fn is_legacy_bypass_alias(value: &str) -> bool { + matches!( + value.trim().to_ascii_lowercase().as_str(), + "yolo" | "4" | "bypass" | "bypass-permissions" | "bypasspermissions" + ) + } + + /// Short UI label used by the `/mode` switch confirmation. + #[must_use] + pub fn display_name(self) -> &'static str { + match self { + Self::Agent => "Act", + Self::Plan => "Plan", + Self::Operate => "Operate", + } + } +} + /// Tool-approval posture visible to commands. #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum CommandApprovalMode { @@ -36,3 +69,51 @@ pub enum CommandCurrency { Usd, Cny, } + +/// Terminal screen mode visible to `/fullscreen` and `/inline` (FEAT-028 D5). +/// Mirrors the TUI `ScreenMode`; the host adapter owns the actual viewport swap. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandScreenMode { + /// Alternate screen buffer. + Fullscreen, + /// Full-height inline viewport with no alternate screen. + Inline, +} + +/// Workbar placement, mirroring the TUI `WorkSurfacePlacement`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandWorkSurfacePlacement { + Bottom, + Top, + Left, + Right, + Off, +} + +impl CommandWorkSurfacePlacement { + /// Config-token spelling used by the `/workbar` status line and persistence. + #[must_use] + pub const fn as_setting(self) -> &'static str { + match self { + Self::Bottom => "bottom", + Self::Top => "top", + Self::Left => "left", + Self::Right => "right", + Self::Off => "off", + } + } +} + +/// Workbar panel, mirroring the TUI `RailPanel`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CommandRailPanel { + Agents, + Tasks, + Background, + Terminal, + Files, + Notepad, + Context, + Git, + Price, +} diff --git a/crates/tui/src/commands/config_group.rs b/crates/tui/src/commands/config_group.rs new file mode 100644 index 0000000000..e87d596be0 --- /dev/null +++ b/crates/tui/src/commands/config_group.rs @@ -0,0 +1,119 @@ +//! Host registration/action adapter for the portable config group (FEAT-028). +//! +//! Replaces the group's former concrete-`App` dispatcher. This file is the only +//! place that knows the TUI `Command`/`CommandGroup` traits and `AppAction`; the +//! movable group (`groups/config/`) imports none of them. It mirrors +//! `commands/session_group.rs` and `commands/debug_group.rs`. + +use super::CommandResult; +use super::groups::config::portable_handlers; +use super::traits::{Command, CommandGroup, ContextualCommand}; +use crate::tui::app::App; +use codewhale_command_contract::handler::CommandHandler; +use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; +use codewhale_command_contract::outcome::ConfigCommandResult; + +pub(super) struct ConfigCommands; + +struct Registration; + +impl RegisterCommand for Registration { + fn info() -> &'static CommandInfo { + portable_handlers()[INDEX].0 + } + + fn handler() -> CommandHandler { + match portable_handlers()[INDEX].1 { + CommandHandler::Contextual { capabilities, .. } => CommandHandler::Contextual { + capabilities, + handler: |contexts, args| match portable_handlers()[INDEX].1 { + CommandHandler::Contextual { handler, .. } => { + host_result(handler(contexts, args)) + } + _ => CommandResult::error("config handler shape changed"), + }, + }, + CommandHandler::Pure(_) => { + CommandHandler::Pure(|args| match portable_handlers()[INDEX].1 { + CommandHandler::Pure(run) => host_result(run(args)), + _ => CommandResult::error("config handler shape changed"), + }) + } + } + } +} + +fn host_result(result: ConfigCommandResult) -> CommandResult { + CommandResult { + message: result.message, + action: result + .action + .map(super::config_host::config_action_to_app_action), + is_error: result.is_error, + } +} + +impl CommandGroup for ConfigCommands { + fn commands(&self) -> &'static [Box] { + static COMMANDS: std::sync::OnceLock>> = std::sync::OnceLock::new(); + COMMANDS + .get_or_init(|| { + let commands: Vec> = vec![ + registration::<0>(), // /config + registration::<1>(), // /import-claude + registration::<2>(), // /permissions + registration::<3>(), // /login + registration::<4>(), // /connect + registration::<5>(), // /disconnect + registration::<6>(), // /auth + registration::<7>(), // /workbar (+ /rail, /sidebar) + registration::<8>(), // /pet + registration::<9>(), // /settings + registration::<10>(), // /status + registration::<11>(), // /statusline + registration::<12>(), // /mode (+ /jihua, /zidong) + registration::<13>(), // /fullscreen + registration::<14>(), // /inline + registration::<15>(), // /theme + registration::<16>(), // /verbose + registration::<17>(), // /trust (+ /xinren) + registration::<18>(), // /logout + ]; + assert_eq!( + commands + .iter() + .map(|command| command.info().name) + .collect::>(), + portable_handlers() + .iter() + .map(|(info, _)| info.name) + .collect::>(), + "host registry must cover the complete portable config inventory" + ); + commands + }) + .as_slice() + } +} + +fn registration() -> Box { + Box::new( + ContextualCommand::from_contract::>().expect("config registration"), + ) +} + +/// Dispatch a `/jihua` (`plan`) or `/zidong` (`yolo`) mode alias through the +/// registered `/mode` handler and its declared capability envelope. +pub(super) fn mode_alias(app: &mut App, arg: &str) -> CommandResult { + match Registration::<10>::handler() { + CommandHandler::Contextual { + capabilities, + handler, + } => handler( + app.command_contexts_with_config(None) + .contexts(capabilities), + Some(arg), + ), + _ => CommandResult::error("mode handler shape changed"), + } +} diff --git a/crates/tui/src/commands/config_host.rs b/crates/tui/src/commands/config_host.rs new file mode 100644 index 0000000000..3abeea4bfc --- /dev/null +++ b/crates/tui/src/commands/config_host.rs @@ -0,0 +1,513 @@ +//! Shared conversion between the config-group contract actions and the TUI +//! `AppAction` vocabulary (FEAT-028 D5). +//! +//! The portable handlers and the movable config closure depend only on +//! `codewhale-command-contract`. This adapter module is host-owned: it maps the +//! unified `ConfigAction` to the concrete `AppAction` the event loop applies, +//! and back for the host helpers that still return a TUI `CommandResult`. +//! +//! The reverse `AppAction` -> `ConfigAction` direction and its `from_*` helpers +//! are consumed by the Phase 5 host adapters; until then they are kept ready. +#![allow(dead_code)] + +use crate::compaction::CompactionConfig; +use crate::config::SearchProvider; +use crate::tui::app::{AppAction, ScreenMode}; +use codewhale_command_contract::config::{ + CommandCapabilityState, CommandCompactionConfig, CommandCompletionSound, + CommandNotificationCondition, CommandNotificationEvent, CommandNotificationMethod, + CommandNotificationUpdate, CommandSearchProvider, CommandSubagentCompletion, + CommandSubagentRuntimeConfig, +}; +use codewhale_command_contract::outcome::ConfigAction; +use codewhale_command_contract::types::{CommandMode, CommandProviderId, CommandScreenMode}; +use codewhale_config::AppMode; +use codewhale_config::ProviderId; +use codewhale_config::notifications::{ + CompletionSound, NotificationCondition, NotificationConfigUpdate, NotificationEvent, + NotificationMethod, SubagentCompletionNotification, +}; +use codewhale_config::route::CapabilityState; + +/// Convert a portable config action to the concrete TUI action it requests. +pub(crate) fn config_action_to_app_action(action: ConfigAction) -> AppAction { + match action { + ConfigAction::PermissionRulesChanged => AppAction::PermissionRulesChanged, + ConfigAction::StartXaiDeviceLogin => AppAction::StartXaiDeviceLogin, + ConfigAction::StartClaudeLogin => AppAction::StartClaudeLogin, + ConfigAction::StartClaudeRevoke => AppAction::StartClaudeRevoke, + ConfigAction::StartChatgptPkceLogin => AppAction::StartChatgptPkceLogin, + ConfigAction::StartChatgptRevoke => AppAction::StartChatgptRevoke, + ConfigAction::StartOrcarouterPkceLogin => AppAction::StartOrcarouterPkceLogin, + ConfigAction::StartOrcarouterRevoke => AppAction::StartOrcarouterRevoke, + ConfigAction::OpenConfigView => AppAction::OpenConfigView, + ConfigAction::OpenModePicker => AppAction::OpenModePicker, + ConfigAction::OpenThemePicker => AppAction::OpenThemePicker, + ConfigAction::OpenStatusPicker => AppAction::OpenStatusPicker, + ConfigAction::OpenProviderPicker => AppAction::OpenProviderPicker, + ConfigAction::StartPluginLogin { provider } => AppAction::StartPluginLogin { provider }, + ConfigAction::StartPluginLogout { provider } => AppAction::StartPluginLogout { provider }, + ConfigAction::SetScreenMode(mode) => AppAction::SetScreenMode(to_screen_mode(mode)), + ConfigAction::SetWorkspaceTrust { trusted, save } => { + AppAction::SetWorkspaceTrust { trusted, save } + } + ConfigAction::ModeChanged(mode) => AppAction::ModeChanged(to_app_mode(mode)), + ConfigAction::ApprovalPolicyChanged { policy } => { + AppAction::ApprovalPolicyPersisted { policy } + } + ConfigAction::SwitchProvider { provider, model } => AppAction::SwitchProvider { + provider: ProviderId::from(provider.0), + model, + }, + ConfigAction::CompactionChanged(config) => { + AppAction::UpdateCompaction(to_compaction_config(config)) + } + ConfigAction::StreamChunkTimeoutChanged(timeout) => { + AppAction::UpdateStreamChunkTimeout(timeout) + } + ConfigAction::SubagentRuntimeChanged(config) => AppAction::UpdateSubagentRuntimeConfig { + enabled: config.enabled, + max_subagents: config.max_subagents, + launch_concurrency: config.launch_concurrency, + max_spawn_depth: config.max_spawn_depth, + api_timeout_secs: config.api_timeout_secs, + heartbeat_timeout_secs: config.heartbeat_timeout_secs, + }, + ConfigAction::SearchProviderChanged(provider) => AppAction::UpdateSearchProvider { + provider: to_search_provider(provider), + }, + ConfigAction::PromptSuggestionChanged { enabled } => { + AppAction::UpdatePromptSuggestion { enabled } + } + ConfigAction::NotificationChanged(update) => AppAction::UpdateNotification { + update: to_notification_update(update), + }, + } +} + +/// Convert a TUI action produced by a moved config helper back to the contract +/// vocabulary. Returns `None` for actions the config group never emits. +pub(crate) fn app_action_to_config_action(action: AppAction) -> Option { + Some(match action { + AppAction::PermissionRulesChanged => ConfigAction::PermissionRulesChanged, + AppAction::StartXaiDeviceLogin => ConfigAction::StartXaiDeviceLogin, + AppAction::StartClaudeLogin => ConfigAction::StartClaudeLogin, + AppAction::StartClaudeRevoke => ConfigAction::StartClaudeRevoke, + AppAction::StartChatgptPkceLogin => ConfigAction::StartChatgptPkceLogin, + AppAction::StartChatgptRevoke => ConfigAction::StartChatgptRevoke, + AppAction::StartOrcarouterPkceLogin => ConfigAction::StartOrcarouterPkceLogin, + AppAction::StartOrcarouterRevoke => ConfigAction::StartOrcarouterRevoke, + AppAction::OpenConfigView => ConfigAction::OpenConfigView, + AppAction::OpenModePicker => ConfigAction::OpenModePicker, + AppAction::OpenThemePicker => ConfigAction::OpenThemePicker, + AppAction::OpenStatusPicker => ConfigAction::OpenStatusPicker, + AppAction::OpenProviderPicker => ConfigAction::OpenProviderPicker, + AppAction::StartPluginLogin { provider } => ConfigAction::StartPluginLogin { provider }, + AppAction::StartPluginLogout { provider } => ConfigAction::StartPluginLogout { provider }, + AppAction::SetScreenMode(mode) => ConfigAction::SetScreenMode(from_screen_mode(mode)), + AppAction::SetWorkspaceTrust { trusted, save } => { + ConfigAction::SetWorkspaceTrust { trusted, save } + } + AppAction::ModeChanged(mode) => ConfigAction::ModeChanged(from_app_mode(mode)), + AppAction::ApprovalPolicyPersisted { policy } => { + ConfigAction::ApprovalPolicyChanged { policy } + } + AppAction::SwitchProvider { provider, model } => ConfigAction::SwitchProvider { + provider: CommandProviderId(provider.as_str().to_string()), + model, + }, + AppAction::UpdateCompaction(config) => { + ConfigAction::CompactionChanged(from_compaction_config(config)) + } + AppAction::UpdateStreamChunkTimeout(timeout) => { + ConfigAction::StreamChunkTimeoutChanged(timeout) + } + AppAction::UpdateSubagentRuntimeConfig { + enabled, + max_subagents, + launch_concurrency, + max_spawn_depth, + api_timeout_secs, + heartbeat_timeout_secs, + } => ConfigAction::SubagentRuntimeChanged(CommandSubagentRuntimeConfig { + enabled, + max_subagents, + launch_concurrency, + max_spawn_depth, + api_timeout_secs, + heartbeat_timeout_secs, + }), + AppAction::UpdateSearchProvider { provider } => { + ConfigAction::SearchProviderChanged(from_search_provider(provider)) + } + AppAction::UpdatePromptSuggestion { enabled } => { + ConfigAction::PromptSuggestionChanged { enabled } + } + AppAction::UpdateNotification { update } => { + ConfigAction::NotificationChanged(from_notification_update(update)) + } + _ => return None, + }) +} + +fn to_screen_mode(mode: CommandScreenMode) -> ScreenMode { + match mode { + CommandScreenMode::Fullscreen => ScreenMode::Fullscreen, + CommandScreenMode::Inline => ScreenMode::Inline, + } +} + +fn from_screen_mode(mode: ScreenMode) -> CommandScreenMode { + match mode { + ScreenMode::Fullscreen => CommandScreenMode::Fullscreen, + ScreenMode::Inline => CommandScreenMode::Inline, + } +} + +fn to_app_mode(mode: CommandMode) -> AppMode { + match mode { + CommandMode::Agent => AppMode::Agent, + CommandMode::Plan => AppMode::Plan, + CommandMode::Operate => AppMode::Operate, + } +} + +fn from_app_mode(mode: AppMode) -> CommandMode { + match mode { + AppMode::Agent => CommandMode::Agent, + AppMode::Plan => CommandMode::Plan, + AppMode::Operate => CommandMode::Operate, + } +} + +fn to_capability_state(state: CommandCapabilityState) -> CapabilityState { + match state { + CommandCapabilityState::Supported => CapabilityState::Supported, + CommandCapabilityState::Unsupported => CapabilityState::Unsupported, + CommandCapabilityState::Unknown => CapabilityState::Unknown, + } +} + +fn from_capability_state(state: CapabilityState) -> CommandCapabilityState { + match state { + CapabilityState::Supported => CommandCapabilityState::Supported, + CapabilityState::Unsupported => CommandCapabilityState::Unsupported, + CapabilityState::Unknown => CommandCapabilityState::Unknown, + } +} + +fn to_compaction_config(config: CommandCompactionConfig) -> CompactionConfig { + CompactionConfig { + enabled: config.enabled, + token_threshold: config.token_threshold, + model: config.model, + image_input: to_capability_state(config.image_input), + effective_context_window: config.effective_context_window, + cache_summary: config.cache_summary, + focus: config.focus, + runtime_cost_owner: config.runtime_cost_owner, + workspace: config.workspace, + summary_instructions: config.summary_instructions, + retained_user_message_tokens: config.retained_user_message_tokens, + } +} + +fn from_compaction_config(config: CompactionConfig) -> CommandCompactionConfig { + CommandCompactionConfig { + enabled: config.enabled, + token_threshold: config.token_threshold, + model: config.model, + image_input: from_capability_state(config.image_input), + effective_context_window: config.effective_context_window, + cache_summary: config.cache_summary, + focus: config.focus, + runtime_cost_owner: config.runtime_cost_owner, + workspace: config.workspace, + summary_instructions: config.summary_instructions, + retained_user_message_tokens: config.retained_user_message_tokens, + } +} + +fn to_search_provider(provider: CommandSearchProvider) -> SearchProvider { + match provider { + CommandSearchProvider::Bing => SearchProvider::Bing, + CommandSearchProvider::DuckDuckGo => SearchProvider::DuckDuckGo, + CommandSearchProvider::Firecrawl => SearchProvider::Firecrawl, + CommandSearchProvider::Tavily => SearchProvider::Tavily, + CommandSearchProvider::Bocha => SearchProvider::Bocha, + CommandSearchProvider::Metaso => SearchProvider::Metaso, + CommandSearchProvider::Searxng => SearchProvider::Searxng, + CommandSearchProvider::Baidu => SearchProvider::Baidu, + CommandSearchProvider::Volcengine => SearchProvider::Volcengine, + CommandSearchProvider::Sofya => SearchProvider::Sofya, + CommandSearchProvider::Serply => SearchProvider::Serply, + } +} + +fn from_search_provider(provider: SearchProvider) -> CommandSearchProvider { + match provider { + SearchProvider::Bing => CommandSearchProvider::Bing, + SearchProvider::DuckDuckGo => CommandSearchProvider::DuckDuckGo, + SearchProvider::Firecrawl => CommandSearchProvider::Firecrawl, + SearchProvider::Tavily => CommandSearchProvider::Tavily, + SearchProvider::Bocha => CommandSearchProvider::Bocha, + SearchProvider::Metaso => CommandSearchProvider::Metaso, + SearchProvider::Searxng => CommandSearchProvider::Searxng, + SearchProvider::Baidu => CommandSearchProvider::Baidu, + SearchProvider::Volcengine => CommandSearchProvider::Volcengine, + SearchProvider::Sofya => CommandSearchProvider::Sofya, + SearchProvider::Serply => CommandSearchProvider::Serply, + } +} + +fn to_notification_method(method: CommandNotificationMethod) -> NotificationMethod { + match method { + CommandNotificationMethod::Auto => NotificationMethod::Auto, + CommandNotificationMethod::Osc9 => NotificationMethod::Osc9, + CommandNotificationMethod::Bel => NotificationMethod::Bel, + CommandNotificationMethod::Kitty => NotificationMethod::Kitty, + CommandNotificationMethod::Ghostty => NotificationMethod::Ghostty, + CommandNotificationMethod::Off => NotificationMethod::Off, + } +} + +fn from_notification_method(method: NotificationMethod) -> CommandNotificationMethod { + match method { + NotificationMethod::Auto => CommandNotificationMethod::Auto, + NotificationMethod::Osc9 => CommandNotificationMethod::Osc9, + NotificationMethod::Bel => CommandNotificationMethod::Bel, + NotificationMethod::Kitty => CommandNotificationMethod::Kitty, + NotificationMethod::Ghostty => CommandNotificationMethod::Ghostty, + NotificationMethod::Off => CommandNotificationMethod::Off, + } +} + +fn to_completion_sound(sound: CommandCompletionSound) -> CompletionSound { + match sound { + CommandCompletionSound::Off => CompletionSound::Off, + CommandCompletionSound::Beep => CompletionSound::Beep, + CommandCompletionSound::Bell => CompletionSound::Bell, + CommandCompletionSound::Whale => CompletionSound::Whale, + CommandCompletionSound::File => CompletionSound::File, + } +} + +fn from_completion_sound(sound: CompletionSound) -> CommandCompletionSound { + match sound { + CompletionSound::Off => CommandCompletionSound::Off, + CompletionSound::Beep => CommandCompletionSound::Beep, + CompletionSound::Bell => CommandCompletionSound::Bell, + CompletionSound::Whale => CommandCompletionSound::Whale, + CompletionSound::File => CommandCompletionSound::File, + } +} + +fn to_notification_condition(condition: CommandNotificationCondition) -> NotificationCondition { + match condition { + CommandNotificationCondition::Always => NotificationCondition::Always, + CommandNotificationCondition::Unfocused => NotificationCondition::Unfocused, + CommandNotificationCondition::Never => NotificationCondition::Never, + } +} + +fn from_notification_condition(condition: NotificationCondition) -> CommandNotificationCondition { + match condition { + NotificationCondition::Always => CommandNotificationCondition::Always, + NotificationCondition::Unfocused => CommandNotificationCondition::Unfocused, + NotificationCondition::Never => CommandNotificationCondition::Never, + } +} + +fn to_subagent_completion(completion: CommandSubagentCompletion) -> SubagentCompletionNotification { + match completion { + CommandSubagentCompletion::Always => SubagentCompletionNotification::Always, + CommandSubagentCompletion::FinalOnly => SubagentCompletionNotification::FinalOnly, + CommandSubagentCompletion::Off => SubagentCompletionNotification::Off, + } +} + +fn from_subagent_completion( + completion: SubagentCompletionNotification, +) -> CommandSubagentCompletion { + match completion { + SubagentCompletionNotification::Always => CommandSubagentCompletion::Always, + SubagentCompletionNotification::FinalOnly => CommandSubagentCompletion::FinalOnly, + SubagentCompletionNotification::Off => CommandSubagentCompletion::Off, + } +} + +fn to_notification_event(event: CommandNotificationEvent) -> NotificationEvent { + match event { + CommandNotificationEvent::TurnComplete => NotificationEvent::TurnComplete, + CommandNotificationEvent::SubagentTerminal => NotificationEvent::SubagentTerminal, + CommandNotificationEvent::ApprovalNeeded => NotificationEvent::ApprovalNeeded, + CommandNotificationEvent::InputNeeded => NotificationEvent::InputNeeded, + CommandNotificationEvent::ElevationNeeded => NotificationEvent::ElevationNeeded, + CommandNotificationEvent::ModelNotify => NotificationEvent::ModelNotify, + } +} + +fn from_notification_event(event: NotificationEvent) -> CommandNotificationEvent { + match event { + NotificationEvent::TurnComplete => CommandNotificationEvent::TurnComplete, + NotificationEvent::SubagentTerminal => CommandNotificationEvent::SubagentTerminal, + NotificationEvent::ApprovalNeeded => CommandNotificationEvent::ApprovalNeeded, + NotificationEvent::InputNeeded => CommandNotificationEvent::InputNeeded, + NotificationEvent::ElevationNeeded => CommandNotificationEvent::ElevationNeeded, + NotificationEvent::ModelNotify => CommandNotificationEvent::ModelNotify, + } +} + +fn to_notification_update(update: CommandNotificationUpdate) -> NotificationConfigUpdate { + match update { + CommandNotificationUpdate::Method(method) => { + NotificationConfigUpdate::Method(to_notification_method(method)) + } + CommandNotificationUpdate::ThresholdSecs(value) => { + NotificationConfigUpdate::ThresholdSecs(value) + } + CommandNotificationUpdate::IncludeSummary(value) => { + NotificationConfigUpdate::IncludeSummary(value) + } + CommandNotificationUpdate::Quiet(value) => NotificationConfigUpdate::Quiet(value), + CommandNotificationUpdate::Sound(sound) => { + NotificationConfigUpdate::Sound(sound.map(to_completion_sound)) + } + CommandNotificationUpdate::Condition(condition) => { + NotificationConfigUpdate::Condition(to_notification_condition(condition)) + } + CommandNotificationUpdate::CompletionSound(sound) => { + NotificationConfigUpdate::CompletionSound(to_completion_sound(sound)) + } + CommandNotificationUpdate::SubagentCompletion(completion) => { + NotificationConfigUpdate::SubagentCompletion(to_subagent_completion(completion)) + } + CommandNotificationUpdate::SoundFile(path) => NotificationConfigUpdate::SoundFile(path), + CommandNotificationUpdate::Event(event, enabled) => { + NotificationConfigUpdate::Event(to_notification_event(event), enabled) + } + CommandNotificationUpdate::EventSoundEnabled(enabled) => { + NotificationConfigUpdate::EventSoundEnabled(enabled) + } + CommandNotificationUpdate::EventSoundEvents(events) => { + NotificationConfigUpdate::EventSoundEvents(events) + } + CommandNotificationUpdate::EventSoundMinIntervalMs(value) => { + NotificationConfigUpdate::EventSoundMinIntervalMs(value) + } + CommandNotificationUpdate::EventSoundQuiet(quiet) => { + NotificationConfigUpdate::EventSoundQuiet(quiet) + } + } +} + +fn from_notification_update(update: NotificationConfigUpdate) -> CommandNotificationUpdate { + match update { + NotificationConfigUpdate::Method(method) => { + CommandNotificationUpdate::Method(from_notification_method(method)) + } + NotificationConfigUpdate::ThresholdSecs(value) => { + CommandNotificationUpdate::ThresholdSecs(value) + } + NotificationConfigUpdate::IncludeSummary(value) => { + CommandNotificationUpdate::IncludeSummary(value) + } + NotificationConfigUpdate::Quiet(value) => CommandNotificationUpdate::Quiet(value), + NotificationConfigUpdate::Sound(sound) => { + CommandNotificationUpdate::Sound(sound.map(from_completion_sound)) + } + NotificationConfigUpdate::Condition(condition) => { + CommandNotificationUpdate::Condition(from_notification_condition(condition)) + } + NotificationConfigUpdate::CompletionSound(sound) => { + CommandNotificationUpdate::CompletionSound(from_completion_sound(sound)) + } + NotificationConfigUpdate::SubagentCompletion(completion) => { + CommandNotificationUpdate::SubagentCompletion(from_subagent_completion(completion)) + } + NotificationConfigUpdate::SoundFile(path) => CommandNotificationUpdate::SoundFile(path), + NotificationConfigUpdate::Event(event, enabled) => { + CommandNotificationUpdate::Event(from_notification_event(event), enabled) + } + NotificationConfigUpdate::EventSoundEnabled(enabled) => { + CommandNotificationUpdate::EventSoundEnabled(enabled) + } + NotificationConfigUpdate::EventSoundEvents(events) => { + CommandNotificationUpdate::EventSoundEvents(events) + } + NotificationConfigUpdate::EventSoundMinIntervalMs(value) => { + CommandNotificationUpdate::EventSoundMinIntervalMs(value) + } + NotificationConfigUpdate::EventSoundQuiet(quiet) => { + CommandNotificationUpdate::EventSoundQuiet(quiet) + } + } +} + +/// Render a contract config reply for a host-side caller (interactive UI views +/// and tests) that needs a concrete `CommandResult`. +pub(crate) fn render_config_reply( + reply: codewhale_command_contract::config::ConfigReply, + locale: codewhale_localization::Locale, +) -> crate::commands::CommandResult { + use codewhale_command_contract::config::ConfigMessage; + + /// Render a message, propagating an unknown key or an invalid replacement + /// contract instead of emitting a raw key or an untranslated template. + fn message_text( + message: ConfigMessage, + locale: codewhale_localization::Locale, + ) -> Result { + match message { + ConfigMessage::Literal(text) => Ok(text), + ConfigMessage::Concat(parts) => { + let mut out = String::new(); + for part in parts { + out.push_str(&message_text(part, locale)?); + } + Ok(out) + } + ConfigMessage::Keyed { key, params } => { + let mut resolved: Vec<(String, String)> = Vec::with_capacity(params.len()); + for (name, param) in params { + resolved.push((name, message_text(param, locale)?)); + } + let refs: Vec<(&str, &str)> = resolved + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + let Some(id) = crate::commands::contract::config_messages::resolve(&key) else { + return Err("unknown translation key".to_string()); + }; + let template = codewhale_localization::tr(locale, id); + crate::commands::contract::apply_named_replacements(&template, &refs) + .ok_or_else(|| "invalid translation replacement contract".to_string()) + } + } + } + + // Same failure policy as the portable renderer: a failed render is a + // failure with no action, never a successful reply with a raw key. + let message = match reply.message { + None => None, + Some(message) => match message_text(message, locale) { + Ok(text) => Some(text), + Err(error) => { + return crate::commands::CommandResult { + message: Some(format!("Error: {error}")), + action: None, + is_error: true, + }; + } + }, + }; + let message = match (message, reply.is_error) { + (Some(text), true) if !text.starts_with("Error: ") => Some(format!("Error: {text}")), + (other, _) => other, + }; + crate::commands::CommandResult { + message, + action: reply.action.map(config_action_to_app_action), + is_error: reply.is_error, + } +} diff --git a/crates/tui/src/commands/config_main_host.rs b/crates/tui/src/commands/config_main_host.rs new file mode 100644 index 0000000000..fdd0f16da0 --- /dev/null +++ b/crates/tui/src/commands/config_main_host.rs @@ -0,0 +1,6231 @@ +//! Config commands: config, settings, mode switches, trust, logout + +use crate::commands::CommandResult; +use crate::config::{ + Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, DEFAULT_SUBAGENT_API_TIMEOUT_SECS, + DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, DEFAULT_XIAOMI_MIMO_BASE_URL, + MAX_STREAM_CHUNK_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS, + MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, MAX_SUBAGENTS, MIN_STREAM_CHUNK_TIMEOUT_SECS, + MIN_SUBAGENT_API_TIMEOUT_SECS, MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, NotificationConfigUpdate, + NotificationSetting, NotificationsConfig, ProviderKind, SearchProvider, SearchProviderSource, + SubagentsConfig, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, normalize_custom_model_id, + normalize_model_name_for_provider, validate_route, +}; +use crate::config_persistence::{ + persist_root_bool_key, persist_root_string_key, persist_subagents_bool_key, + persist_subagents_integer_key, persist_table_string_key, persist_table_value_key, + persist_unset_root_key, +}; +use crate::reasoning_preference::ReasoningEffort; +use crate::settings::Settings; +use crate::tui::app::{App, AppAction, AppModeUi, SettingSelection, VimMode}; +use anyhow::Result; +use codewhale_command_contract::config::{CommandWorkflowSummary, ConfigMessage, ConfigReply}; +use codewhale_config::AppMode; +use codewhale_execpolicy::ApprovalMode; +use codewhale_localization::{MessageId, resolve_locale, tr}; +use std::path::PathBuf; + +/// Reject a preset bundle *before* anything is written, returning the message +/// to show, or `None` when every field can be applied. +/// +/// The bundle is persisted in one transaction and then mirrored field by field +/// into the live session. A per-field refusal during that mirror pass therefore +/// arrives *after* the file has already been rewritten — the user gets an error +/// and a saved file, which is the partial apply this preflight exists to make +/// impossible. Both refusals a field can raise are knowable up front: +/// +/// 1. A live-route key while a turn is running (#2982). +/// 2. A value the setter would reject, checked against a throwaway `Settings` +/// so the real file is never touched by the check. +fn preset_preflight(app: &App, fields: &[(&str, &str)]) -> Option { + for (key, value) in fields { + if app.is_loading + && let Some(subject) = live_route_setting_subject(&key.to_lowercase()) + { + return Some(app.setting_locked_message(subject)); + } + if let Err(e) = Settings::default().set(key, value) { + return Some(format!("Failed to apply preset field {key}={value}: {e}")); + } + } + None +} + +/// Apply a bundled settings preset, e.g. `/config preset calm [--save]` (#3478). +/// +/// The preset is applied to the live session through the same per-key setter a +/// single `/config ` uses, so app state mirroring and (with +/// `--save`) persistence stay consistent. The preset name is validated before +/// any field is touched. +/// Typed effective `[workflow]`/`[goal]` facts (the config facet projects these). +/// Build a config reply carrying an `AppAction` converted to the contract action. +fn config_action_reply(message: impl Into, action: AppAction) -> ConfigReply { + ConfigReply { + message: Some(message.into()), + action: crate::commands::config_host::app_action_to_config_action(action), + is_error: false, + } +} + +pub(in crate::commands) fn workflow_summary(app: &App) -> CommandWorkflowSummary { + let refreshed = crate::tools::workflow::session_workflow_config(&app.workspace); + let cfg = refreshed.as_ref().unwrap_or(&app.workflow_config); + CommandWorkflowSummary { + automatic: cfg.automatic, + auto_start_read_only: cfg.auto_start_read_only, + require_approval_for_writes: cfg.require_approval_for_writes, + max_children: cfg.max_children, + max_concurrent: cfg.max_concurrent, + max_depth: cfg.max_depth, + default_token_budget: cfg.default_token_budget, + goal_max_continuations: app.goal_max_continuations, + goal_enforce_token_budget: app.goal_enforce_token_budget, + } +} + +/// `/workflow settings` / `/config workflow`: render the typed facts with the +/// portable helper (one render implementation). +pub(in crate::commands) fn workflow_settings(app: &App) -> CommandResult { + CommandResult::message( + crate::commands::groups::config::config::render_workflow_summary(workflow_summary(app)), + ) +} + +pub(crate) fn apply_preset(app: &mut App, name: &str, persist: bool) -> ConfigReply { + if name.is_empty() || name.starts_with('-') { + return ConfigReply::message( + "Usage: /config preset [--save]. Available presets: calm.", + ); + } + + let Some(fields) = crate::settings::preset_fields(name) else { + return ConfigReply::error(format!("Unknown preset '{name}'. Available presets: calm.")); + }; + + if let Some(refusal) = preset_preflight(app, fields) { + return ConfigReply::error(refusal); + } + + // Persist the whole bundle atomically when requested (one load/apply/save), + // now that every field is known to be applicable. + if persist { + // `Settings::transact` is what makes "one load/apply/save" true against + // the *other* writers in this process, not just against a second preset + // apply: an unsynchronized load/save pair here would write back a + // pre-image that reverts a concurrent mode/thinking/posture write. + if let Err(e) = Settings::transact(|settings| settings.apply_preset(name)) { + return ConfigReply::error(format!("Failed to save settings: {e}")); + } + } + + // Mirror the bundle into the live session via the per-key setter (the + // persisted write, if any, already happened atomically above, so this pass + // is session-only). + let mut applied = Vec::with_capacity(fields.len()); + for (key, value) in fields { + let result = set_config_value(app, key, value, false); + if result.is_error { + let message = result + .message + .unwrap_or_else(|| ConfigMessage::literal("unknown apply error")); + return ConfigReply { + message: Some(ConfigMessage::concat(vec![ + ConfigMessage::literal(format!( + "Error: Failed to apply preset field {key}={value}: " + )), + message, + ])), + action: None, + is_error: true, + }; + } + applied.push(format!("{key}={value}")); + } + + let suffix = if persist { + " (saved)" + } else { + " (session only — add --save to persist)" + }; + ConfigReply::message(format!( + "Applied '{name}' transcript preset{suffix}: {}. Thinking stays visible and tool runs stay expandable.", + applied.join(", ") + )) +} + +/// Show the current value of a single setting. +fn config_context_window_override(app: &App) -> Option { + let mut config = Config::load(app.config_path.clone(), app.config_profile.as_deref()).ok()?; + let identity = app.admitted_provider_identity().ok()?; + config.scope_to_provider_identity(identity).ok()?; + config.context_window_for_provider_config(identity) +} + +pub(crate) fn show_single_setting(app: &App, key: &str) -> ConfigReply { + let key = key.to_lowercase(); + if let Some(subagent_key) = key.strip_prefix("subagents.") { + return show_subagents_setting(app, subagent_key); + } + if let Some(notifications_key) = key.strip_prefix("notifications.") { + return show_notifications_setting(app, notifications_key); + } + fn locale_display(l: codewhale_localization::Locale) -> &'static str { + match l { + codewhale_localization::Locale::En => "en", + codewhale_localization::Locale::ZhHans => "zh-Hans", + codewhale_localization::Locale::ZhHant => "zh-Hant", + codewhale_localization::Locale::Ja => "ja", + codewhale_localization::Locale::PtBr => "pt-BR", + codewhale_localization::Locale::Es419 => "es-419", + codewhale_localization::Locale::Vi => "vi", + codewhale_localization::Locale::Ko => "ko", + codewhale_localization::Locale::Ca => "ca", + codewhale_localization::Locale::De => "de", + codewhale_localization::Locale::Fr => "fr", + codewhale_localization::Locale::Id => "id", + codewhale_localization::Locale::Hi => "hi", + codewhale_localization::Locale::Ru => "ru", + codewhale_localization::Locale::Uk => "uk", + } + } + fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str { + match d { + crate::tui::app::ComposerDensity::Compact => "compact", + crate::tui::app::ComposerDensity::Comfortable => "comfortable", + crate::tui::app::ComposerDensity::Spacious => "spacious", + } + } + fn spacing_display(s: crate::tui::app::TranscriptSpacing) -> &'static str { + match s { + crate::tui::app::TranscriptSpacing::Compact => "compact", + crate::tui::app::TranscriptSpacing::Comfortable => "comfortable", + crate::tui::app::TranscriptSpacing::Spacious => "spacious", + } + } + let value = match key.as_str() { + "model" => { + if app.auto_model { + let mut label = "auto (auto-select model per turn)".to_string(); + if let Some(effective) = app.last_effective_model.as_deref() + && effective != "auto" + { + label.push_str(&format!("; last: {effective}")); + } + Some(label) + } else { + Some(app.model.clone()) + } + } + "provider" => Some(app.provider_identity_for_persistence().to_string()), + "approval_mode" | "approval" => Some(app.approval_mode.permission_chip_label().to_string()), + "allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()), + "base_url" => { + let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) + { + Ok(config) => config, + Err(err) => { + return ConfigReply::error(format!("Failed to load config: {err}")); + } + }; + Some(config.active_route_base_url()) + } + // `/config title` reports the config-level default, not a session's + // `/title` override. The latter is intentionally a separate setting + // and is reported by bare `/title`. + "title" | "window_title" | "tab_title" => Some( + app.title_default + .clone() + .unwrap_or_else(|| "(unset)".to_string()), + ), + "provider_url" | "provider_base_url" | "endpoint" => { + let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) + { + Ok(mut config) => { + config.provider = Some(app.provider_identity_for_persistence().to_string()); + config + } + Err(err) => { + return ConfigReply::error(format!("Failed to load config: {err}")); + } + }; + Some(config.active_route_base_url()) + } + "context_window" | "context_window_tokens" => Some(format!( + "{} (effective {} from {})", + config_context_window_override(app) + .map_or_else(|| "not set".to_string(), |tokens| tokens.to_string()), + crate::route_budget::route_context_window_tokens( + app.api_provider, + app.effective_model_for_budget(), + app.active_route_limits, + ), + app.active_context_window_source.display_label(), + )), + "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()), + "posture_bar" => Some(app.posture_bar.as_setting().to_string()), + "metrics_line" => Some(app.metrics_line.as_setting().to_string()), + "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), + "theme" | "ui_theme" => Some( + if app + .theme_name + .starts_with(codewhale_palette::USER_THEME_PREFIX) + { + app.theme_name.clone() + } else { + codewhale_palette::theme_label_for_mode(app.ui_theme.mode).to_string() + }, + ), + "background_color" | "background" | "bg" => { + codewhale_palette::hex_rgb_string(app.ui_theme.surface_bg) + .or_else(|| Some("(default)".to_string())) + } + "auto_compact" | "compact" => { + Some(if app.auto_compact { "true" } else { "false" }.to_string()) + } + "calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()), + "pet_mode" => Some(app.pet_watch.enabled.to_string()), + "low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()), + "fancy_animations" | "fancy" | "animations" => Some( + if app.fancy_animations { + "true" + } else { + "false" + } + .to_string(), + ), + "bracketed_paste" | "paste" => Some( + if app.use_bracketed_paste { + "true" + } else { + "false" + } + .to_string(), + ), + "paste_burst_detection" | "paste_burst" => Some( + if app.use_paste_burst_detection { + "true" + } else { + "false" + } + .to_string(), + ), + "show_thinking" | "thinking" => { + Some(if app.show_thinking { "true" } else { "false" }.to_string()) + } + "thinking_default_expanded" | "thinking_expanded" => Some( + if app.thinking_default_expanded { + "true" + } else { + "false" + } + .to_string(), + ), + "thinking_preview_lines" | "thinking_preview" => { + Some(app.thinking_preview_lines.to_string()) + } + "help_expand_groups" | "help_expanded" => Some( + if app.help_expand_groups { + "true" + } else { + "false" + } + .to_string(), + ), + "contextual_tips" => Some(app.behavioral_tips.enabled().to_string()), + "pin_last_prompt" | "pin_prompt" => { + Some(if app.pin_last_prompt { "true" } else { "false" }.to_string()) + } + "thinking_highlight" | "reasoning_highlight" => Some( + if app.thinking_highlight { + "true" + } else { + "false" + } + .to_string(), + ), + "show_tool_details" | "tool_details" => Some( + if app.show_tool_details { + "true" + } else { + "false" + } + .to_string(), + ), + "inline_diffs" | "inline_diff" | "diffs" => { + Some(app.inline_diff_mode.as_setting().to_string()) + } + "mode" | "default_mode" => Some(app.mode.as_setting().to_string()), + "max_history" | "history" => Some(app.max_input_history.to_string()), + "work_surface_placement" | "work_surface" | "work_rail" => { + Some(app.work_surface.placement.as_setting().to_string()) + } + "rail_panel" | "rail" => Some(app.work_surface.panel.as_setting().to_string()), + "work_surface_top_height" | "work_top_height" => { + Some(app.work_surface.top_height.to_string()) + } + "work_surface_side_width" | "work_side_width" => { + Some(app.work_surface.side_width.to_string()) + } + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + Some(app.tool_collapse_mode.as_setting().to_string()) + } + "context_panel" | "context" | "session_panel" => { + Some(if app.context_panel { "true" } else { "false" }.to_string()) + } + "sessions_rail" | "sessions_panel" | "session_rail" => { + Some(if app.sessions_rail { "true" } else { "false" }.to_string()) + } + // Read the persisted value rather than reporting a hard-coded default: + // this setting is consumed at startup by `main`, so `App` has no live + // copy, and printing "false" unconditionally would misreport a user who + // has it on. + "session_auto_resume" | "auto_resume" => Some( + if crate::settings::Settings::load_persisted() + .map(|settings| settings.session_auto_resume) + .unwrap_or(false) + { + "true" + } else { + "false" + } + .to_string(), + ), + "composer_density" | "composer" => Some(density_display(app.composer_density).to_string()), + "composer_border" | "border" => { + Some(if app.composer_border { "true" } else { "false" }.to_string()) + } + "composer_multiline_mode" | "multiline_mode" | "multiline" => Some( + if app.composer_multiline_mode { + "true" + } else { + "false" + } + .to_string(), + ), + "composer_vim_mode" | "vim_mode" | "vim" => Some( + if app.composer.vim_enabled { + "vim" + } else { + "normal" + } + .to_string(), + ), + "transcript_spacing" | "spacing" => { + Some(spacing_display(app.transcript_spacing).to_string()) + } + "status_indicator" | "indicator" => Some(app.status_indicator.clone()), + "synchronized_output" | "sync_output" | "sync" => Some( + if app.synchronized_output_enabled { + "on" + } else { + "off" + } + .to_string(), + ), + "cost_currency" | "currency" => Some( + match app.cost_currency { + crate::pricing::CostCurrency::Usd => "usd", + crate::pricing::CostCurrency::Cny => "cny", + } + .to_string(), + ), + "default_model" => match saved_deepseek_default_model(app) { + Ok(model) => Some(model), + Err(error) => return ConfigReply::error(error), + }, + "reasoning_effort" | "effort" => Some( + app.reasoning_effort + .as_setting_for_provider(app.api_provider) + .to_string(), + ), + "workspace_follow_symlinks" | "follow_symlinks" => Settings::load().ok().map(|settings| { + format!( + "{} (restart required for engine tools)", + settings.workspace_follow_symlinks + ) + }), + "search" | "search.provider" | "search_provider" => load_command_config(app) + .ok() + .map(|config| search_provider_display(&config, app.ui_locale)), + "telemetry" => load_command_config(app) + .ok() + .map(|config| crate::telemetry_notice::saved_preference_enabled(&config).to_string()), + "prompt_suggestion" => load_command_config(app) + .ok() + .map(|config| prompt_suggestion_display(&config)), + "notifications" => Some(notifications_summary_value(&app.notification_settings)), + _ => { + // Any spelling `/set` accepts; internal flags, actions and + // receipts in the schema are not settings a user can look up. + let known = Settings::canonical_key(&key).is_some(); + if known { + Some("(see /settings for current value)".to_string()) + } else { + None + } + } + }; + match value { + Some(v) => ConfigReply::message(format!("{key} = {v}")), + None => ConfigReply::error(unknown_setting_message(&key)), + } +} + +/// Error for `/config ` when `key` is not a known setting: name the +/// closest real key when there is one, and point at the full list. +fn unknown_setting_message(key: &str) -> String { + // Suggest only keys `/set` accepts, the same set `known` checks above + // (#6563). + let nearest = crate::config_keys::nearest_key(key, crate::config_keys::settings_toml_keys()); + match nearest { + Some(candidate) => format!( + "Unknown setting '{key}'. Did you mean `/config {candidate}`? Run `/settings text` to list every setting." + ), + None => format!("Unknown setting '{key}'. Run `/settings text` to list every setting."), + } +} + +/// Typed settings facts for `/settings text` (the command renders them). +pub fn show_settings( + _app: &mut App, +) -> Result { + let settings = Settings::load().map_err(|e| format!("Failed to load settings: {e}"))?; + Ok(settings.command_report()) +} + +fn select_pet_mode(app: &mut App, enabled: bool, persist: bool) -> ConfigReply { + crate::tui::pet_watch::set_enabled(app, enabled); + if persist { + app.startup_defaults + .spawn(crate::tui::startup_defaults::StartupDefaults::pet_mode( + enabled, + )); + } + ConfigReply::message(tr( + app.ui_locale, + if enabled { + MessageId::PetModeOn + } else { + MessageId::PetModeOff + }, + )) +} + +fn resolve_provider_url_value(provider: ProviderKind, value: &str) -> Result { + let trimmed = value.trim(); + if trimmed.is_empty() { + return Err("provider_url cannot be empty".to_string()); + } + + if provider == ProviderKind::XiaomiMimo { + match trimmed.to_ascii_lowercase().as_str() { + "token" | "token-plan" | "token_plan" | "token-plan-sgp" | "sgp" => { + return Ok(DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()); + } + "payg" | "pay-go" | "paygo" | "pay-as-you-go" | "pay_as_you_go" | "api" => { + return Ok(XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()); + } + _ => {} + } + } + + if trimmed.contains("://") { + Ok(trimmed.to_string()) + } else if provider == ProviderKind::XiaomiMimo { + Err("provider_url for Xiaomi MiMo must be token-plan, pay-as-you-go, or a URL".to_string()) + } else { + Err("provider_url must be a URL".to_string()) + } +} + +fn parse_config_bool(value: &str) -> Result { + match value.trim().to_ascii_lowercase().as_str() { + "on" | "true" | "yes" | "1" | "enabled" => Ok(true), + "off" | "false" | "no" | "0" | "disabled" => Ok(false), + _ => Err(format!( + "Failed to parse boolean '{value}': expected on/off, true/false, yes/no." + )), + } +} + +fn approval_mode_config_value(mode: ApprovalMode) -> &'static str { + match mode { + ApprovalMode::Auto => "auto", + ApprovalMode::Bypass => "bypass", + ApprovalMode::Suggest => "on-request", + ApprovalMode::Never => "never", + } +} + +pub(crate) fn config_editability_audit( + app: &App, +) -> Result { + let config = load_command_config(app)?; + let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| "(unresolved)".to_string()); + + let mut provider_config = config.clone(); + provider_config.provider = Some(app.provider_identity_for_persistence().to_string()); + let model = if app.auto_model { + "auto".to_string() + } else { + app.model.clone() + }; + let saved_permission_posture = Settings::load() + .ok() + .and_then(|settings| settings.permission_posture) + .unwrap_or_else(|| "(unset)".to_string()); + let configured_approval_policy = config + .approval_policy + .clone() + .unwrap_or_else(|| "(unset)".to_string()); + let effective_permissions = if app.mode == AppMode::Plan { + "Read Only" + } else { + app.approval_mode.permission_chip_label() + }; + + let rows = [ + ( + "provider", + app.provider_identity_for_persistence().to_string(), + "session", + "/config provider ", + codewhale_command_contract::config::ConfigMessage::literal( + "Switches the active provider now; edit provider in config.toml for startup default.", + ), + ), + ( + "model", + model, + "session", + "/config model ", + codewhale_command_contract::config::ConfigMessage::literal( + "Switches the active model now; use default_text_model in config.toml for startup default.", + ), + ), + ( + "effective_permissions", + effective_permissions.to_string(), + "runtime", + "Shift+Tab", + codewhale_command_contract::config::ConfigMessage::literal( + "Shows the effective Act permission posture; Plan remains Read Only.", + ), + ), + ( + "permission_posture", + saved_permission_posture, + "TUI settings", + "Shift+Tab", + codewhale_command_contract::config::ConfigMessage::literal( + "Saved in settings.toml and ignored when config/requirements manage approval policy.", + ), + ), + ( + "approval_policy", + configured_approval_policy, + "persisted config", + "/config approval_mode --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Top-level managed policy; Full Access is not a valid value here.", + ), + ), + ( + "allow_shell", + app.allow_shell.to_string(), + "runtime+persisted", + "/config allow_shell --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Writes top-level allow_shell and applies to subsequent turns.", + ), + ), + ( + "stream_chunk_timeout_secs", + app.stream_chunk_timeout_secs.to_string(), + "runtime+persisted", + "/config stream_chunk_timeout_secs <0|1..3600> --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Writes [stream].chunk_timeout_secs and updates the running stream timeout.", + ), + ), + ( + "posture_bar", + app.posture_bar.as_setting().to_string(), + "runtime+persisted", + "/config posture_bar --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Writes [tui].posture_bar; hidden gives the row to the transcript, compact keeps the posture chips only.", + ), + ), + ( + "metrics_line", + app.metrics_line.as_setting().to_string(), + "runtime+persisted", + "/config metrics_line --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Writes [tui].metrics_line; hidden gives the row to the transcript, compact drops secondary counts and help while keeping selected TTFT/rate readings when they fit. Choose readings with /statusline.", + ), + ), + ( + "subagents.enabled", + subagents_config_display_value(&config, "enabled"), + "runtime+persisted", + "/config subagents on|off --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Writes [subagents].enabled and updates subsequent sub-agent launches.", + ), + ), + ( + "subagents.max_concurrent", + subagents_config_display_value(&config, "max_concurrent"), + "runtime+persisted", + "/config subagents max_concurrent --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Clamped with Config::max_subagents and written to [subagents].max_concurrent.", + ), + ), + ( + "subagents.max_depth", + subagents_config_display_value(&config, "max_depth"), + "runtime+persisted", + "/config subagents max_depth --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Clamped to the configured spawn-depth ceiling.", + ), + ), + ( + "subagents.launch_concurrency", + subagents_config_display_value(&config, "launch_concurrency"), + "runtime+persisted", + "/config subagents launch_concurrency --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Clamped to the resolved sub-agent concurrency cap.", + ), + ), + ( + "subagents.api_timeout_secs", + subagents_config_display_value(&config, "api_timeout_secs"), + "runtime+persisted", + "/config subagents api_timeout_secs --save", + codewhale_command_contract::config::ConfigMessage::literal( + "0 means the compiled default; non-zero values are clamped to the documented range.", + ), + ), + ( + "subagents.heartbeat_timeout_secs", + subagents_config_display_value(&config, "heartbeat_timeout_secs"), + "runtime+persisted", + "/config subagents heartbeat_timeout_secs --save", + codewhale_command_contract::config::ConfigMessage::literal( + "0 means the compiled default; non-zero values are clamped to the documented range.", + ), + ), + ( + "base_url", + config.active_route_base_url(), + "persisted restart", + "/config base_url --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Writes top-level base_url; model clients read it on startup.", + ), + ), + ( + "providers..base_url", + provider_config.active_route_base_url(), + "persisted restart", + "/config provider_url --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Writes the active provider table; model clients read it on startup.", + ), + ), + ( + "providers..context_window", + config_context_window_override(app) + .map_or_else(|| "(unset)".to_string(), |tokens| tokens.to_string()), + "persisted restart", + "edit [providers.] context_window = ", + codewhale_command_contract::config::ConfigMessage::literal( + "Overrides compaction, context-pressure, header, and preflight input budgets; use 262144 to cap a 1M route to 256K.", + ), + ), + ( + "effective_context_window", + format!( + "{} ({})", + crate::route_budget::route_context_window_tokens( + app.api_provider, + app.effective_model_for_budget(), + app.active_route_limits, + ), + app.active_context_window_source.display_label(), + ), + "runtime", + "/config context_window", + codewhale_command_contract::config::ConfigMessage::literal( + "The shared resolved window used by every active-route budget surface.", + ), + ), + ( + "mcp_config_path", + app.mcp_config_path.display().to_string(), + "persisted live reload", + "/config mcp_config_path --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Run /mcp reload to rebuild the live model-visible tool pool.", + ), + ), + ( + "workspace_follow_symlinks", + app.workspace_follow_symlinks.to_string(), + "partial restart", + "/config workspace_follow_symlinks --save", + codewhale_command_contract::config::ConfigMessage::literal( + "Updates TUI file completion now; engine tools require restart.", + ), + ), + ( + "search.provider", + search_provider_display(&config, app.ui_locale), + "runtime+persisted", + "/config search.provider --save", + codewhale_command_contract::config::ConfigMessage::keyed( + "config_audit_search_provider", + ), + ), + ( + "prompt_suggestion", + prompt_suggestion_display(&config), + "runtime+persisted", + "/config prompt_suggestion --save", + codewhale_command_contract::config::ConfigMessage::keyed( + "config_audit_prompt_suggestion", + ), + ), + ( + "notifications", + notifications_summary(&config), + "runtime+persisted", + "/config notifications --save", + codewhale_command_contract::config::ConfigMessage::keyed("config_audit_notifications"), + ), + ( + "instructions", + file_only_status(config.instructions.as_ref().map(|v| !v.is_empty())), + "file-only restart", + "edit config.toml", + codewhale_command_contract::config::ConfigMessage::literal( + "Prompt layers are loaded before the first turn.", + ), + ), + ( + "hooks", + file_only_status(config.hooks.as_ref().map(|_| true)), + "file-only", + "edit config.toml", + codewhale_command_contract::config::ConfigMessage::literal( + "Hook definitions are structured TOML, not a scalar runtime setting.", + ), + ), + ( + "network", + file_only_status(config.network.as_ref().map(|_| true)), + "file-only", + "edit config.toml", + codewhale_command_contract::config::ConfigMessage::literal( + "Network policy is evaluated by tool dispatch and should be reviewed as TOML.", + ), + ), + ( + "tools", + file_only_status(config.tools.as_ref().map(|_| true)), + "file-only restart", + "edit config.toml", + codewhale_command_contract::config::ConfigMessage::literal( + "Tool catalog policy is built before model/tool negotiation.", + ), + ), + ( + "memory", + file_only_status(config.memory.as_ref().map(|_| true)), + "file-only restart", + "edit config.toml", + codewhale_command_contract::config::ConfigMessage::literal( + "Memory loading changes prompt context and is resolved at startup.", + ), + ), + ( + "runtime_api", + file_only_status(config.runtime_api.as_ref().map(|_| true)), + "file-only restart", + "edit config.toml", + codewhale_command_contract::config::ConfigMessage::literal( + "Serve/API tuning belongs to the runtime server startup path.", + ), + ), + ( + "vision_model", + file_only_status(config.vision_model.as_ref().map(|_| true)), + "file-only restart", + "edit config.toml", + codewhale_command_contract::config::ConfigMessage::literal( + "Image-analysis provider clients are configured outside the scalar /config editor.", + ), + ), + ]; + + Ok( + codewhale_command_contract::config::CommandConfigAuditReport { + config_path, + rows: rows + .into_iter() + .map(|(key, current, editability, command, note)| { + codewhale_command_contract::config::CommandConfigAuditRow { + key: key.to_string(), + current, + editability: editability.to_string(), + command: command.to_string(), + note, + } + }) + .collect(), + }, + ) +} + +fn file_only_status(configured: Option) -> String { + match configured { + Some(true) => "configured".to_string(), + Some(false) => "empty".to_string(), + None => "unset".to_string(), + } +} + +fn search_provider_display(config: &Config, locale: codewhale_localization::Locale) -> String { + let resolved = config.search_provider_resolution(); + let source = match resolved.source { + SearchProviderSource::Default => tr(locale, MessageId::ConfigDefaultValue) + .trim_matches(&['(', ')'][..]) + .to_string(), + SearchProviderSource::Config => "config.toml".to_string(), + SearchProviderSource::EnvOverride => "CODEWHALE_SEARCH_PROVIDER".to_string(), + // Same token doctor prints: the signal is a Tavily key, not a disk + // pin, so never name `TAVILY_API_KEY` (the winner may have been a + // generic `tvly-` `[search] api_key`). + SearchProviderSource::TavilyKey => "tavily key".to_string(), + }; + tr(locale, MessageId::ConfigCommandSource) + .replace("{value}", resolved.provider.as_str()) + .replace("{source}", &source) +} + +fn prompt_suggestion_display(config: &Config) -> String { + config.prompt_suggestion_enabled().to_string() +} + +fn notifications_summary(config: &Config) -> String { + notifications_summary_value(&config.notifications_config()) +} + +fn notifications_summary_value(notifications: &NotificationsConfig) -> String { + format!( + "method={} threshold={}s sound={} quiet={}", + notifications.method.as_str(), + notifications.threshold_secs, + notifications.display(NotificationSetting::Sound), + notifications.quiet + ) +} + +pub(crate) fn set_search_provider(app: &mut App, value: &str, persist: bool) -> ConfigReply { + let Some(provider) = SearchProvider::parse(value) else { + return ConfigReply::error(ConfigMessage::keyed_with( + "config_command_invalid_value", + vec![ + ("key".to_string(), ConfigMessage::literal("search.provider")), + ("value".to_string(), ConfigMessage::literal(value)), + ( + "choices".to_string(), + ConfigMessage::literal(SearchProvider::names_hint()), + ), + ], + )); + }; + + let scope = if persist { + match persist_table_string_key( + app.config_path.as_deref(), + "search", + "provider", + provider.as_str(), + ) { + Ok(path) => ConfigMessage::concat(vec![ + ConfigMessage::keyed("config_scope_saved"), + ConfigMessage::literal(format!(" {}", path.display())), + ]), + Err(err) => { + return ConfigReply::error(ConfigMessage::keyed_with( + "startup_default_not_saved", + vec![ + ( + "setting".to_string(), + ConfigMessage::literal("search.provider"), + ), + ("error".to_string(), ConfigMessage::literal(err.to_string())), + ], + )); + } + } + } else { + ConfigMessage::keyed("config_scope_session") + }; + + config_action_reply( + ConfigMessage::keyed_with( + "config_search_updated", + vec![ + ( + "value".to_string(), + ConfigMessage::literal(provider.as_str()), + ), + ("scope".to_string(), scope), + ], + ), + AppAction::UpdateSearchProvider { provider }, + ) +} + +fn set_prompt_suggestion(app: &mut App, value: &str, persist: bool) -> ConfigReply { + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(_) => { + return ConfigReply::error(ConfigMessage::keyed_with( + "config_command_invalid_value", + vec![ + ( + "key".to_string(), + ConfigMessage::literal("prompt_suggestion"), + ), + ("value".to_string(), ConfigMessage::literal(value)), + ( + "choices".to_string(), + ConfigMessage::literal("on, off, true, false, yes, no"), + ), + ], + )); + } + }; + let scope = if persist { + match persist_root_bool_key(app.config_path.as_deref(), "prompt_suggestion", enabled) { + Ok(path) => ConfigMessage::concat(vec![ + ConfigMessage::keyed("config_scope_saved"), + ConfigMessage::literal(format!(" {}", path.display())), + ]), + Err(err) => { + return ConfigReply::error(ConfigMessage::keyed_with( + "startup_default_not_saved", + vec![ + ( + "setting".to_string(), + ConfigMessage::literal("prompt_suggestion"), + ), + ("error".to_string(), ConfigMessage::literal(err.to_string())), + ], + )); + } + } + } else { + ConfigMessage::keyed("config_scope_session") + }; + config_action_reply( + ConfigMessage::keyed_with( + "config_prompt_suggestion_updated", + vec![ + ( + "value".to_string(), + ConfigMessage::literal(enabled.to_string()), + ), + ("scope".to_string(), scope), + ], + ), + AppAction::UpdatePromptSuggestion { enabled }, + ) +} + +pub(crate) fn show_notifications_status(app: &App) -> ConfigReply { + let mut lines = vec!["[notifications]".to_string()]; + lines.extend(NotificationSetting::ALL.into_iter().map(|setting| { + format!( + "{} = {}", + setting.key(), + app.notification_settings.display(setting) + ) + })); + ConfigReply::message(ConfigMessage::concat(vec![ + ConfigMessage::literal(format!("{}\n", lines.join("\n"))), + ConfigMessage::keyed("config_notifications_set_hint"), + ])) +} + +pub(crate) fn show_notifications_setting(app: &App, key: &str) -> ConfigReply { + let Some(setting) = NotificationSetting::parse(key) else { + return invalid_notification_value(app, key, key, "/config notifications status"); + }; + ConfigReply::message(format!( + "notifications.{} = {}", + setting.key(), + app.notification_settings.display(setting) + )) +} + +fn invalid_notification_value(_app: &App, key: &str, value: &str, choices: &str) -> ConfigReply { + ConfigReply::error(ConfigMessage::keyed_with( + "config_command_invalid_value", + vec![ + ( + "key".to_string(), + ConfigMessage::literal(format!("notifications.{key}")), + ), + ("value".to_string(), ConfigMessage::literal(value)), + ("choices".to_string(), ConfigMessage::literal(choices)), + ], + )) +} + +pub(crate) fn set_notifications_value( + app: &mut App, + key: &str, + value: &str, + persist: bool, +) -> ConfigReply { + let Some(setting) = NotificationSetting::parse(key) else { + return invalid_notification_value(app, key, value, "/config notifications status"); + }; + let update = match NotificationConfigUpdate::parse(setting, value) { + Ok(update) => update, + Err(_) => return invalid_notification_value(app, setting.key(), value, setting.choices()), + }; + let scope = if persist { + let result = crate::config_persistence::config_toml_path(app.config_path.as_deref()) + .and_then(|path| { + update.persist_for_profile(&path, app.config_profile.as_deref())?; + Ok(path) + }); + match result { + Ok(path) => ConfigMessage::concat(vec![ + ConfigMessage::keyed("config_scope_saved"), + ConfigMessage::literal(format!(" {}", path.display())), + ]), + Err(error) => { + return ConfigReply::error(ConfigMessage::keyed_with( + "startup_default_not_saved", + vec![ + ( + "setting".to_string(), + ConfigMessage::literal(format!("notifications.{}", setting.key())), + ), + ( + "error".to_string(), + ConfigMessage::literal(error.to_string()), + ), + ], + )); + } + } + } else { + ConfigMessage::keyed("config_scope_session") + }; + config_action_reply( + ConfigMessage::keyed_with( + "config_notification_updated", + vec![ + ("key".to_string(), ConfigMessage::literal(setting.key())), + ( + "value".to_string(), + ConfigMessage::literal(update.display()), + ), + ("scope".to_string(), scope), + ], + ), + AppAction::UpdateNotification { update }, + ) +} + +fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { + if raw == 0 { + format!("0 (default {resolved})") + } else { + resolved.to_string() + } +} + +fn load_command_config(app: &App) -> Result { + Config::load(app.config_path.clone(), app.config_profile.as_deref()) + .map_err(|err| format!("Failed to load config: {err}")) +} + +/// The compatibility default_model command describes saved DeepSeek config, +/// independent of the active provider and one-launch environment overrides. +fn saved_deepseek_default_model(app: &App) -> Result { + let path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) + .map_err(|error| format!("Failed to resolve config: {error}"))?; + let read = || -> anyhow::Result { + let store = codewhale_config::ConfigStore::load(Some(path.clone()))?; + let mut config = Config::from_saved_document( + store.original_body().unwrap_or(""), + app.config_profile.as_deref(), + )?; + if app.config_profile.is_none() && crate::config::is_home_config_path(&path) { + config.apply_saved_selection(&Settings::load_legacy_route_preferences_read_only()?); + } + let key = if app.admitted_provider_identity().is_ok_and(|identity| { + identity.key.as_str() == codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id + }) { + codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id + } else { + ProviderKind::Deepseek.as_str() + }; + let identity = config + .resolve_provider_pin_identity(key) + .map_err(anyhow::Error::msg)?; + anyhow::ensure!( + identity.provider == ProviderKind::Deepseek, + "The saved provider identity is shadowed by another route" + ); + config + .scope_to_provider_identity(&identity) + .map_err(anyhow::Error::msg)?; + Ok(config.default_model()) + }; + read().map_err(|error| format!("Failed to read saved model config: {error}")) +} + +pub(crate) fn subagents_status( + app: &App, +) -> Result { + let config = load_command_config(app)?; + let path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) + .map(|path| path.display().to_string()) + .unwrap_or_else(|_| "(unresolved)".to_string()); + let disabled_reason = config.subagents_disabled_reason(); + let identity = app + .admitted_provider_identity() + .and_then(|identity| config.verify_provider_identity(identity).map(|()| identity)) + .map_err(|error| error.to_string())?; + let subagents = config.subagents.as_ref(); + let provider_subagents = config.subagent_provider_config(identity); + let provider_label = identity + .compatibility() + .map_or(identity.key.as_str(), |row| row.label) + .to_string(); + let limits = vec![ + codewhale_command_contract::config::CommandSubagentsLimit { + key: "max_concurrent", + raw: option_display(subagents.and_then(|cfg| cfg.max_concurrent)), + global: config.max_subagents().to_string(), + provider: config.max_subagents_for_provider(identity).to_string(), + }, + codewhale_command_contract::config::CommandSubagentsLimit { + key: "max_depth", + raw: option_display(subagents.and_then(|cfg| cfg.max_depth)), + global: config.subagent_max_spawn_depth().to_string(), + provider: config + .subagent_max_spawn_depth_for_provider(identity) + .to_string(), + }, + codewhale_command_contract::config::CommandSubagentsLimit { + key: "launch_concurrency", + raw: option_display(subagents.and_then(|cfg| cfg.launch_concurrency)), + global: config.launch_concurrency().to_string(), + provider: config.launch_concurrency_for_provider(identity).to_string(), + }, + codewhale_command_contract::config::CommandSubagentsLimit { + key: "api_timeout_secs", + raw: option_display(subagents.and_then(|cfg| cfg.api_timeout_secs)), + global: config.subagent_api_timeout_secs().to_string(), + provider: config + .subagent_api_timeout_secs_for_provider(identity) + .to_string(), + }, + codewhale_command_contract::config::CommandSubagentsLimit { + key: "heartbeat_timeout_secs", + raw: option_display(subagents.and_then(|cfg| cfg.heartbeat_timeout_secs)), + global: config.subagent_heartbeat_timeout_secs().to_string(), + provider: config + .subagent_heartbeat_timeout_secs_for_provider(identity) + .to_string(), + }, + ]; + let provider_rows = provider_subagents.map(|provider| { + vec![ + codewhale_command_contract::config::CommandSubagentsProviderRow { + key: "enabled", + value: provider + .enabled + .map(|value| value.to_string()) + .unwrap_or_else(|| "inherits".to_string()), + }, + codewhale_command_contract::config::CommandSubagentsProviderRow { + key: "max_concurrent", + value: option_display(provider.max_concurrent), + }, + codewhale_command_contract::config::CommandSubagentsProviderRow { + key: "max_depth", + value: option_display(provider.max_depth), + }, + codewhale_command_contract::config::CommandSubagentsProviderRow { + key: "launch_concurrency", + value: option_display(provider.launch_concurrency), + }, + codewhale_command_contract::config::CommandSubagentsProviderRow { + key: "max_admitted", + value: option_display(provider.max_admitted), + }, + ] + }); + Ok(codewhale_command_contract::config::CommandSubagentsReport { + disabled_reason: disabled_reason.map(str::to_string), + config_path: path, + provider_key: identity.key.as_str().to_string(), + provider_label, + explicit_enabled: subagents.and_then(|cfg| cfg.enabled), + limits, + provider_rows, + }) +} + +pub(crate) fn show_subagents_setting(app: &App, key: &str) -> ConfigReply { + let config = match load_command_config(app) { + Ok(config) => config, + Err(err) => return ConfigReply::error(err), + }; + let Some(key) = canonical_subagents_key(key) else { + return ConfigReply::error(format!( + "Unknown subagents setting '{key}'. Use `/config subagents status`." + )); + }; + let identity = match app + .admitted_provider_identity() + .and_then(|identity| config.verify_provider_identity(identity).map(|()| identity)) + { + Ok(identity) => identity, + Err(error) => return ConfigReply::error(error), + }; + let subagents = config.subagents.as_ref(); + let value = match key { + "enabled" => subagents + .and_then(|cfg| cfg.enabled) + .map(|value| value.to_string()) + .unwrap_or_else(|| "default true".to_string()), + "max_concurrent" => format!( + "{} (resolved global {}; active provider {})", + option_display(subagents.and_then(|cfg| cfg.max_concurrent)), + config.max_subagents(), + config.max_subagents_for_provider(identity) + ), + "max_depth" => format!( + "{} (resolved global {}; active provider {})", + option_display(subagents.and_then(|cfg| cfg.max_depth)), + config.subagent_max_spawn_depth(), + config.subagent_max_spawn_depth_for_provider(identity) + ), + "launch_concurrency" => format!( + "{} (resolved global {}; active provider {})", + option_display(subagents.and_then(|cfg| cfg.launch_concurrency)), + config.launch_concurrency(), + config.launch_concurrency_for_provider(identity) + ), + "api_timeout_secs" => format!( + "{} (resolved global {}; active provider {})", + option_display(subagents.and_then(|cfg| cfg.api_timeout_secs)), + config.subagent_api_timeout_secs(), + config.subagent_api_timeout_secs_for_provider(identity) + ), + "heartbeat_timeout_secs" => format!( + "{} (resolved global {}; active provider {})", + option_display(subagents.and_then(|cfg| cfg.heartbeat_timeout_secs)), + config.subagent_heartbeat_timeout_secs(), + config.subagent_heartbeat_timeout_secs_for_provider(identity) + ), + _ => unreachable!("canonical subagent key"), + }; + ConfigReply::message(format!("subagents.{key} = {value}")) +} + +fn option_display(value: Option) -> String { + value + .map(|value| value.to_string()) + .unwrap_or_else(|| "default".to_string()) +} + +fn canonical_subagents_key(key: &str) -> Option<&'static str> { + let normalized = key.trim().to_ascii_lowercase(); + let key = normalized + .strip_prefix("subagents.") + .unwrap_or(normalized.as_str()); + match key { + "enabled" | "enable" => Some("enabled"), + "max_concurrent" | "max_subagents" | "concurrency" | "cap" => Some("max_concurrent"), + "max_depth" | "depth" | "spawn_depth" => Some("max_depth"), + "launch_concurrency" | "launches" | "launch" => Some("launch_concurrency"), + "api_timeout_secs" | "api_timeout" | "step_timeout_secs" => Some("api_timeout_secs"), + "heartbeat_timeout_secs" | "heartbeat_timeout" | "heartbeat" => { + Some("heartbeat_timeout_secs") + } + _ => None, + } +} + +pub(crate) fn set_subagents_config_value( + app: &mut App, + key: &str, + value: &str, + persist: bool, +) -> ConfigReply { + let Some(key) = canonical_subagents_key(key) else { + return ConfigReply::error(format!( + "Unknown subagents setting '{key}'. Use `/config subagents status`." + )); + }; + let mut config = match load_command_config(app) { + Ok(config) => config, + Err(err) => return ConfigReply::error(err), + }; + let identity = match app.admitted_provider_identity().and_then(|identity| { + config + .verify_provider_identity(identity) + .map(|()| identity.clone()) + }) { + Ok(identity) => identity, + Err(error) => return ConfigReply::error(error), + }; + let current_max_subagents = config.max_subagents() as u64; + let subagents = config + .subagents + .get_or_insert_with(SubagentsConfig::default); + + let mut note = None; + let save_result = match key { + "enabled" => { + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(err) => return ConfigReply::error(err), + }; + subagents.enabled = Some(enabled); + if persist { + Some(persist_subagents_bool_key( + app.config_path.as_deref(), + "enabled", + enabled, + )) + } else { + None + } + } + "max_concurrent" => { + let raw = match parse_subagents_u64(key, value) { + Ok(raw) => raw, + Err(err) => return ConfigReply::error(err), + }; + let clamped = raw.min(MAX_SUBAGENTS as u64); + if clamped != raw { + note = Some(format!("clamped from {raw} to {clamped}")); + } + subagents.max_concurrent = Some(clamped as usize); + if persist { + Some(persist_subagents_integer_key( + app.config_path.as_deref(), + "max_concurrent", + clamped, + )) + } else { + None + } + } + "max_depth" => { + let raw = match parse_subagents_u64(key, value) { + Ok(raw) => raw, + Err(err) => return ConfigReply::error(err), + }; + let ceiling = u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING); + let clamped = raw.min(ceiling); + if clamped != raw { + note = Some(format!("clamped from {raw} to {clamped}")); + } + subagents.max_depth = Some(clamped as u32); + if persist { + Some(persist_subagents_integer_key( + app.config_path.as_deref(), + "max_depth", + clamped, + )) + } else { + None + } + } + "launch_concurrency" => { + let raw = match parse_subagents_u64(key, value) { + Ok(raw) => raw, + Err(err) => return ConfigReply::error(err), + }; + let clamped = raw.clamp(1, current_max_subagents); + if clamped != raw { + note = Some(format!("clamped from {raw} to {clamped}")); + } + subagents.launch_concurrency = Some(clamped as usize); + if persist { + Some(persist_subagents_integer_key( + app.config_path.as_deref(), + "launch_concurrency", + clamped, + )) + } else { + None + } + } + "api_timeout_secs" => { + let raw = match parse_subagents_u64(key, value) { + Ok(raw) => raw, + Err(err) => return ConfigReply::error(err), + }; + let stored = if raw == 0 { + 0 + } else { + raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS) + }; + if stored != raw { + note = Some(format!("clamped from {raw} to {stored}")); + } + subagents.api_timeout_secs = Some(stored); + if persist { + Some(persist_subagents_integer_key( + app.config_path.as_deref(), + "api_timeout_secs", + stored, + )) + } else { + None + } + } + "heartbeat_timeout_secs" => { + let raw = match parse_subagents_u64(key, value) { + Ok(raw) => raw, + Err(err) => return ConfigReply::error(err), + }; + let stored = if raw == 0 { + 0 + } else { + raw.clamp( + MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, + ) + }; + if stored != raw { + note = Some(format!("clamped from {raw} to {stored}")); + } + subagents.heartbeat_timeout_secs = Some(stored); + if persist { + Some(persist_subagents_integer_key( + app.config_path.as_deref(), + "heartbeat_timeout_secs", + stored, + )) + } else { + None + } + } + _ => unreachable!("canonical subagent key"), + }; + + let save_suffix = if let Some(result) = save_result { + match result { + Ok(path) => format!("saved to {}", path.display()), + Err(err) => return ConfigReply::error(format!("Failed to save: {err}")), + } + } else { + "session only, add --save to persist".to_string() + }; + + if key == "max_concurrent" { + app.max_subagents = config.max_subagents_for_provider(&identity); + } + let display_value = subagents_config_display_value(&config, key); + let note = note.map(|note| format!("; {note}")).unwrap_or_default(); + config_action_reply( + format!( + "subagents.{key} = {display_value} ({save_suffix}; runtime updated for subsequent turns{note})" + ), + subagents_runtime_action(&identity, &config), + ) +} + +fn parse_subagents_u64(key: &str, value: &str) -> Result { + value + .trim() + .parse::() + .map_err(|_| format!("subagents.{key} must be a whole number")) +} + +fn subagents_config_display_value(config: &Config, key: &str) -> String { + let subagents = config.subagents.as_ref(); + match key { + "enabled" => subagents + .and_then(|cfg| cfg.enabled) + .map(|value| value.to_string()) + .unwrap_or_else(|| "default true".to_string()), + "max_concurrent" => { + if subagents.and_then(|cfg| cfg.max_concurrent) == Some(0) { + "0 (disabled)".to_string() + } else { + config.max_subagents().to_string() + } + } + "max_depth" => { + if subagents.and_then(|cfg| cfg.max_depth) == Some(0) { + "0 (agent tool disabled)".to_string() + } else { + config.subagent_max_spawn_depth().to_string() + } + } + "launch_concurrency" => config.launch_concurrency().to_string(), + "api_timeout_secs" => { + let raw = subagents.and_then(|cfg| cfg.api_timeout_secs); + if raw == Some(0) { + format!("0 (default {DEFAULT_SUBAGENT_API_TIMEOUT_SECS})") + } else { + config.subagent_api_timeout_secs().to_string() + } + } + "heartbeat_timeout_secs" => { + let raw = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs); + if raw == Some(0) { + format!("0 (default {DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS})") + } else { + config.subagent_heartbeat_timeout_secs().to_string() + } + } + _ => unreachable!("canonical subagent key"), + } +} + +fn subagents_runtime_action( + identity: &crate::config::ProviderIdentity, + config: &Config, +) -> AppAction { + let max_subagents = config + .max_subagents_for_provider(identity) + .clamp(1, MAX_SUBAGENTS); + AppAction::UpdateSubagentRuntimeConfig { + enabled: config.subagents_enabled_for_provider(identity), + max_subagents, + launch_concurrency: config.launch_concurrency_for_provider(identity), + max_spawn_depth: config.subagent_max_spawn_depth_for_provider(identity), + api_timeout_secs: config.subagent_api_timeout_secs_for_provider(identity), + heartbeat_timeout_secs: config.subagent_heartbeat_timeout_secs_for_provider(identity), + } +} + +/// The subject a live-route key belongs to, or `None` if the key does not touch +/// the route the engine is currently acting on. +/// +/// This is the single list the #2982 turn lock is enforced from. It exists +/// because the lock used to live in the *selectors* — the Tab cycle, the +/// pickers, the hotbar — while `/set ` and `/config ` +/// reached the same live state through a different door. A slash command is +/// reachable mid-turn (the composer accepts Shift+Enter and the slash menu while +/// `is_loading`), so during a running turn `/set model …` could swap the route +/// out from under the engine and persist it. +/// +/// `default_mode` is deliberately absent: it is a restart default that +/// `set_config_value` explicitly does *not* apply to the live session, so +/// refusing it would lock a key that cannot affect the turn. +fn live_route_setting_subject(key: &str) -> Option { + match key { + "mode" => Some(MessageId::SettingSubjectMode), + // `default_model` is not merely a startup default: for the DeepSeek + // routes `set_config_value` installs it as the live model. + "model" | "default_model" => Some(MessageId::SettingSubjectModel), + "reasoning_effort" | "effort" => Some(MessageId::SettingSubjectThinking), + "provider" => Some(MessageId::SettingSubjectProvider), + "approval_mode" | "approval_policy" | "approval" => { + Some(MessageId::SettingSubjectPermissions) + } + _ => None, + } +} + +/// Modify a setting at runtime +pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> ConfigReply { + let key = key.to_lowercase(); + if let Some(subagent_key) = key.strip_prefix("subagents.") { + return set_subagents_config_value(app, subagent_key, value, persist); + } + if let Some(notifications_key) = key.strip_prefix("notifications.") { + return set_notifications_value(app, notifications_key, value, persist); + } + + // Refuse before *anything* — before the disk write, and before the live + // `App` mutation each arm performs. Placing the check at the top is what + // makes it central: every caller of this function (`/set`, `/config k v`, + // the preset mirror, the schema-driven config editor, the runtime + // `ConfigUpdated` event) inherits it, and none of them can half-apply. + if let Some(subject) = live_route_setting_subject(key.as_str()) + && app.is_loading + { + return ConfigReply::error(app.setting_locked_message(subject)); + } + + match key.as_str() { + "pet_mode" => { + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(_) => { + return ConfigReply::error(ConfigMessage::keyed_with( + "config_command_invalid_value", + vec![ + ("key".to_string(), ConfigMessage::literal(key.clone())), + ("value".to_string(), ConfigMessage::literal(value)), + ("choices".to_string(), ConfigMessage::literal("on/off")), + ], + )); + } + }; + // /pet and the settings editor share the ordered background writer. + // A failed save is reported by the existing warning mailbox. + return select_pet_mode(app, enabled, persist); + } + "contextual_tips" => { + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(_) => { + return ConfigReply::error(ConfigMessage::keyed_with( + "config_command_invalid_value", + vec![ + ("key".to_string(), ConfigMessage::literal(key.clone())), + ("value".to_string(), ConfigMessage::literal(value)), + ("choices".to_string(), ConfigMessage::literal("on/off")), + ], + )); + } + }; + // Apply the opt-out even when the settings file cannot be read + // or saved. Only the existing single-key transaction may claim + // persistence; a failure leaves the live preference in effect. + app.set_contextual_tips_enabled(enabled); + if persist && let Err(error) = persist_single_setting(&key, &enabled.to_string()) { + let toast = tr(app.ui_locale, MessageId::ContextualTipsNotSaved) + .replace("{error}", &error.to_string()); + app.push_status_toast(toast, crate::tui::app::StatusToastLevel::Error, Some(8_000)); + return ConfigReply::error(ConfigMessage::keyed_with( + "contextual_tips_not_saved", + vec![( + "error".to_string(), + ConfigMessage::literal(error.to_string()), + )], + )); + } + let scope_key = if persist { + "config_scope_saved" + } else { + "config_scope_session" + }; + return ConfigReply::message(ConfigMessage::concat(vec![ + ConfigMessage::literal(format!("contextual_tips = {enabled} (")), + ConfigMessage::keyed(scope_key), + ConfigMessage::literal(")"), + ])); + } + "telemetry" => { + if !persist { + return ConfigReply::error( + "Telemetry is a durable privacy preference. Change it in /settings or add --save.", + ); + } + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(err) => return ConfigReply::error(err), + }; + let applied = crate::telemetry_notice::apply_persistent_preference( + app.config_path.clone(), + enabled, + ); + let message = applied.message(app.ui_locale); + return if applied.is_error() { + ConfigReply { + message: Some(ConfigMessage::literal(message)), + action: None, + is_error: true, + } + } else { + ConfigReply::message(message) + }; + } + "default_model" => { + let value = value.trim(); + let value = if value.is_empty() + || matches!( + value.to_ascii_lowercase().as_str(), + "none" | "default" | "(default)" + ) { + crate::config::DEFAULT_TEXT_MODEL + } else { + value + }; + if matches!(app.api_provider, ProviderKind::Deepseek) { + return set_config_value(app, "model", value, persist); + } + if !persist { + return ConfigReply::error(format!( + "default_model is the DeepSeek startup fallback and cannot change the active {} session. Use /model for the current provider, or add --save to change only future DeepSeek sessions.", + app.api_provider.as_str() + )); + } + let saved = match load_command_config(app) { + Ok(config) => config, + Err(err) => return ConfigReply::error(err), + }; + let identity = match saved.builtin_provider_identity(ProviderKind::Deepseek) { + Ok(identity) => identity, + Err(error) => return ConfigReply::error(error), + }; + let model = if value.eq_ignore_ascii_case("auto") { + "auto".to_string() + } else { + // Route-aware, matching POST /v1/config: a custom DeepSeek + // endpoint owns its model namespace, so an id declared for the + // saved DeepSeek route validates verbatim before the catalog + // normalization runs. + if crate::provider_lake::configured_model_for_route( + &saved, + ProviderKind::Deepseek, + identity.key.as_str(), + &saved.base_url_for_route(&identity), + value.trim(), + ) + .is_some() + { + value.trim().to_string() + } else { + let Some(model) = + normalize_model_name_for_provider(ProviderKind::Deepseek, value) + else { + return ConfigReply::error(format!("Invalid DeepSeek model '{value}'.")); + }; + if let Err(error) = validate_route(ProviderKind::Deepseek, &model) { + return ConfigReply::error(error); + } + model + } + }; + return match crate::config_persistence::persist_provider_model_key( + app.config_path.as_deref(), + &identity, + &model, + ) { + Ok(path) => ConfigReply::message(format!( + "default_model = {model} (saved to {}); DeepSeek fallback only — active {}/{} is unchanged", + path.display(), + app.api_provider.as_str(), + app.model_display_label() + )), + Err(error) => ConfigReply::error(format!("Failed to save model: {error}")), + }; + } + "model" => { + // Route-aware: a custom DeepSeek (or other) endpoint owns its model + // namespace. Provider-only normalization would reject a non-DeepSeek + // id that the live session is already allowed to use via `/model`. + // OpenCode Go stays protocol-strict even on a custom host. + let auto_select = value.trim().eq_ignore_ascii_case("auto"); + let model = if auto_select { + "auto".to_string() + } else if app.api_provider == ProviderKind::OpencodeGo { + let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else { + return ConfigReply::error(format!( + "Invalid model '{value}' for provider {}.", + app.api_provider.as_str() + )); + }; + if let Err(reason) = validate_route(app.api_provider, &model) { + return ConfigReply::error(reason); + } + model + } else if app.accepts_custom_model_ids() + || (app.api_provider != ProviderKind::OpenaiCodex + && app.configured_models.iter().any(|row| { + row.id == value.trim() + && row.matches_route( + app.provider_identity_for_persistence(), + &app.active_route_base_url, + ) + })) + { + let Some(model) = normalize_custom_model_id(value) else { + return ConfigReply::error(format!( + "Invalid model '{value}' for provider {}.", + app.api_provider.as_str() + )); + }; + model + } else { + let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else { + return ConfigReply::error(format!( + "Invalid model '{value}' for provider {}.", + app.api_provider.as_str() + )); + }; + if let Err(reason) = validate_route(app.api_provider, &model) { + return ConfigReply::error(reason); + } + model + }; + let saved = if persist { + match crate::config_persistence::persist_provider_selection( + app.config_path.as_deref(), + match app.admitted_provider_identity() { + Ok(identity) => identity, + Err(error) => return ConfigReply::error(error), + }, + Some(&model), + ) { + Ok((path, _)) => Some(path), + Err(error) => { + return ConfigReply::error(format!("Failed to save model: {error}")); + } + } + } else { + None + }; + app.set_model_selection(model.clone()); + app.update_model_compaction_budget(); + app.session.last_prompt_tokens = None; + app.session.last_completion_tokens = None; + let mut message = if model == "auto" { + format!( + "model = auto (auto-select model per turn; thinking = {})", + app.reasoning_effort_display_label() + ) + } else { + format!("model = {model}") + }; + if let Some(path) = saved { + message.push_str(&format!(" (saved to {})", path.display())); + } + return config_action_reply( + message, + AppAction::UpdateCompaction(app.compaction_config()), + ); + } + "provider" => { + let value = value.trim(); + let Some(row) = codewhale_config::descriptors::compatibility_for_selector(value) else { + return ConfigReply::error(format!( + "Unknown provider '{value}'. Use: {}.", + ProviderKind::names_hint() + )); + }; + if app + .provider_identity + .as_ref() + .is_some_and(|identity| identity.key.as_str() == row.id) + { + return ConfigReply::message(format!("provider = {}", row.id)); + } + return config_action_reply( + format!("provider = {}", row.id), + AppAction::SwitchProvider { + provider: row.id.into(), + model: None, + }, + ); + } + "approval_mode" | "approval_policy" | "approval" => { + let use_tui_default = matches!( + value + .trim() + .to_ascii_lowercase() + .replace([' ', '_'], "-") + .as_str(), + "default" | "tui-default" | "use-tui-default" + ); + if use_tui_default { + if !persist { + return ConfigReply::error( + "Removing the config approval override requires --save.", + ); + } + let control = match load_command_config(app) { + Ok(config) => config.approval_policy_control( + app.config_path.as_deref(), + app.config_profile.as_deref(), + &app.workspace, + ), + Err(err) => return ConfigReply::error(err), + }; + if !matches!( + control, + crate::config::ApprovalPolicyControl::RootConfig + | crate::config::ApprovalPolicyControl::Unset + ) { + return ConfigReply::error(format!( + "Approval posture is controlled by {}; change that source first.", + control.label() + )); + } + return match persist_unset_root_key(app.config_path.as_deref(), "approval_policy") { + Ok(path) => { + let saved_mode = Settings::load_persisted() + .ok() + .and_then(|settings| settings.permission_posture) + .as_deref() + .and_then(ApprovalMode::from_config_value) + .unwrap_or(ApprovalMode::Suggest); + app.set_agent_approval_posture(saved_mode); + app.clear_saved_approval_policy_lock(); + config_action_reply( + format!( + "approval_policy removed from {}; new sessions use the TUI {} default", + path.display(), + saved_mode.permission_chip_label() + ), + AppAction::ApprovalPolicyPersisted { policy: None }, + ) + } + Err(err) => ConfigReply::error(format!("Failed to save: {err}")), + }; + } + let control = match load_command_config(app) { + Ok(config) => config.approval_policy_control( + app.config_path.as_deref(), + app.config_profile.as_deref(), + &app.workspace, + ), + Err(err) => return ConfigReply::error(err), + }; + let control_allows_change = if persist { + control.editable_root() + } else { + matches!(control, crate::config::ApprovalPolicyControl::Unset) + }; + if !control_allows_change { + return ConfigReply::error(format!( + "Approval posture is controlled by {}; {}.", + control.label(), + if matches!(control, crate::config::ApprovalPolicyControl::RootConfig) { + "save a new config value or choose Use TUI permission default" + } else { + "change that source first" + } + )); + } + let mode = ApprovalMode::from_config_value(value); + return match mode { + Some(ApprovalMode::Bypass) + if persist + && matches!(control, crate::config::ApprovalPolicyControl::RootConfig) => + { + match app.adopt_root_approval_posture(ApprovalMode::Bypass) { + Ok(()) => config_action_reply( + "approval_mode = Full Access (saved as the TUI permission posture; removed the root approval_policy override)", + AppAction::ApprovalPolicyPersisted { policy: None }, + ), + Err(reason) => { + ConfigReply::error(format!("Failed to save Full Access: {reason}")) + } + } + } + Some(ApprovalMode::Bypass) if persist => ConfigReply::error( + "Full Access is saved as the TUI permission posture, not as a top-level approval_policy. Remove the controlling policy first.", + ), + Some(m) => { + if persist { + let saved = approval_mode_config_value(m); + match persist_root_string_key( + app.config_path.as_deref(), + "approval_policy", + saved, + ) { + Ok(path) => { + app.set_agent_approval_posture(m); + app.mark_approval_policy_locked(); + config_action_reply( + format!( + "approval_mode = {} (saved to {} as approval_policy = \"{}\")", + m.permission_chip_label(), + path.display(), + saved + ), + AppAction::ApprovalPolicyPersisted { + policy: Some(saved.to_string()), + }, + ) + } + Err(err) => ConfigReply::error(format!("Failed to save: {err}")), + } + } else { + app.set_agent_approval_posture(m); + config_action_reply( + format!( + "approval_mode = {} (session only, add --save to persist)", + m.permission_chip_label() + ), + AppAction::ModeChanged(app.mode), + ) + } + } + None => ConfigReply::error( + "Invalid approval_mode. Use: auto-review/auto, ask/suggest/on-request, full-access, never/deny", + ), + }; + } + "allow_shell" | "shell" | "exec_shell" => { + let control = match load_command_config(app) { + Ok(config) => config.allow_shell_control( + app.config_path.as_deref(), + app.config_profile.as_deref(), + &app.workspace, + ), + Err(err) => return ConfigReply::error(err), + }; + if !control.editable_root() { + return ConfigReply::error(format!( + "Shell access is controlled by {}; change that source first.", + control.label() + )); + } + let enabled = match parse_config_bool(value) { + Ok(enabled) => enabled, + Err(err) => return ConfigReply::error(err), + }; + let suffix = if persist { + match persist_root_bool_key(app.config_path.as_deref(), "allow_shell", enabled) { + Ok(path) => format!(" (saved to {})", path.display()), + Err(err) => return ConfigReply::error(format!("Failed to save: {err}")), + } + } else { + " (session only, add --save to persist)".to_string() + }; + app.set_agent_shell_access(enabled); + let mode_hint = if enabled { + " Work mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves." + } else { + " Shell tools will be hidden on the next turn. Re-enable with `/config allow_shell true`." + }; + return ConfigReply::message(format!("allow_shell = {enabled}{suffix}.{mode_hint}")); + } + "mcp_config_path" | "mcp" => { + if value.trim().is_empty() { + return ConfigReply::error("mcp_config_path cannot be empty"); + } + let next_path = PathBuf::from(expand_tilde(value)); + let path_changed = next_path != app.mcp_config_path; + let reload_note = if path_changed { + "; run /mcp reload to rebuild the live tool pool" + } else { + "" + }; + // Persist before touching live state (C01-08): a failed save + // must leave the session exactly as it was, not report failure + // over a path and reload flag that already moved. + let saved_to = if persist { + match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value) + { + Ok(path) => Some(path), + Err(err) => return ConfigReply::error(format!("Failed to save: {err}")), + } + } else { + None + }; + app.mcp_config_path = next_path; + if path_changed { + app.mcp_reload_required = true; + } + let message = match saved_to { + Some(path) => format!( + "mcp_config_path = {} (saved to {}){}", + app.mcp_config_path.display(), + path.display(), + reload_note + ), + None => format!( + "mcp_config_path = {} (session only){}", + app.mcp_config_path.display(), + reload_note + ), + }; + return ConfigReply::message(message); + } + "title" | "window_title" | "tab_title" => { + // Keep the config setter under the same terminal-control and + // bidi/zero-width policy as `/title` and `/rename`. Persist the + // normalized value too, so a restart cannot reintroduce bytes the + // live session already discarded. + let sanitized = crate::session_manager::sanitize_session_title(value); + let value = sanitized.trim(); + if value.is_empty() { + return ConfigReply::error( + "title cannot be empty; use /title off to clear a session title", + ); + } + if value.chars().count() > 100 { + return ConfigReply::error("Title too long (max 100 characters)"); + } + let suffix = if persist { + match persist_root_string_key(app.config_path.as_deref(), "title", value) { + Ok(path) => format!(" (saved to {})", path.display()), + Err(err) => return ConfigReply::error(format!("Failed to save: {err}")), + } + } else { + " (session only, add --save to persist)".to_string() + }; + app.title_default = Some(value.to_string()); + app.needs_redraw = true; + return ConfigReply::message(format!( + "title = {value}{suffix} — terminal window titles now read [\"{value}\"] … until /title overrides this session" + )); + } + // `base_url` is the older spelling. It used to write a top-level key + // that every DeepSeek-family route inherited; it now writes the + // active route's own `[providers.]` table like `provider_url` + // (#6394). + url_key @ ("base_url" | "provider_url" | "provider_base_url" | "endpoint") => { + let value = match resolve_provider_url_value(app.api_provider, value) { + Ok(value) => value, + Err(err) => return ConfigReply::error(err), + }; + if persist { + match crate::config_persistence::persist_route_base_url( + app.config_path.as_deref(), + match app.admitted_provider_identity() { + Ok(identity) => identity, + Err(error) => return ConfigReply::error(error), + }, + &value, + ) { + Ok(path) => { + return ConfigReply::message(format!( + "{url_key} = {value} for {} (saved to {}; restart required)", + app.api_provider.as_str(), + path.display() + )); + } + Err(err) => return ConfigReply::error(format!("Failed to save: {err}")), + } + } + return ConfigReply::error(format!( + "{url_key} must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." + )); + } + // The two bottom-chrome rows' size presets (`tui.posture_bar`, + // `tui.metrics_line`, #5950). Live on the next frame; `--save` + // writes the owning `[tui]` table. `/statusline` composes what is in a row; + // this only decides whether and how much of it paints. + row_key @ ("posture_bar" | "metrics_line") => { + let Some(preset) = crate::config::ChromeRowPreset::from_setting(value) else { + return ConfigReply::error(ConfigMessage::keyed_with( + "config_command_invalid_value", + vec![ + ("key".to_string(), ConfigMessage::literal(row_key)), + ("value".to_string(), ConfigMessage::literal(value)), + ( + "choices".to_string(), + ConfigMessage::literal( + crate::config::ChromeRowPreset::SETTINGS.join(", "), + ), + ), + ], + )); + }; + let value = preset.as_setting(); + let scope = if persist { + let saved = crate::config_persistence::config_toml_path(app.config_path.as_deref()) + .and_then(|path| { + crate::config_persistence::mutate_config_document(&path, |doc| { + // Profiles replace the whole TUI table on load. Edit its + // existing owner without creating an empty override that + // would reset the other inherited display settings. + let mut segments = Vec::new(); + if let Some(profile) = app.config_profile.as_deref() { + let table = doc + .get("profiles") + .and_then(|v| v.get(profile)) + .and_then(toml_edit::Item::as_table_like) + .ok_or_else(|| { + anyhow::anyhow!("active profile is missing or malformed") + })?; + if table.contains_key("tui") { + segments.extend(["profiles", profile]); + } + } + segments.extend(["tui", row_key]); + crate::config_persistence::set_document_value(doc, &segments, value) + })?; + Ok(path) + }); + match saved { + Ok(path) => ConfigMessage::concat(vec![ + ConfigMessage::keyed("config_scope_saved"), + ConfigMessage::literal(format!(" {}", path.display())), + ]), + Err(error) => { + return ConfigReply::error(ConfigMessage::keyed_with( + "startup_default_not_saved", + vec![ + ("setting".to_string(), ConfigMessage::literal(row_key)), + ( + "error".to_string(), + ConfigMessage::literal(error.to_string()), + ), + ], + )); + } + } + } else { + ConfigMessage::keyed("config_scope_session") + }; + if row_key == "posture_bar" { + app.posture_bar = preset; + } else { + app.metrics_line = preset; + } + app.needs_redraw = true; + return ConfigReply::message(ConfigMessage::concat(vec![ + ConfigMessage::literal(format!("{row_key} = {value} (")), + scope, + ConfigMessage::literal(")"), + ])); + } + "stream_chunk_timeout_secs" => { + let raw = match value.trim().parse::() { + Ok(value) => value, + Err(_) => { + return ConfigReply::error("stream_chunk_timeout_secs must be a whole number"); + } + }; + if raw != 0 + && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw) + { + return ConfigReply::error(format!( + "stream_chunk_timeout_secs must be 0 or {MIN_STREAM_CHUNK_TIMEOUT_SECS}..={MAX_STREAM_CHUNK_TIMEOUT_SECS}" + )); + } + let resolved = if raw == 0 { + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + } else { + raw + }; + let value_label = stream_chunk_timeout_value_label(raw, resolved); + if persist { + match persist_table_value_key( + app.config_path.as_deref(), + "stream", + "chunk_timeout_secs", + (raw as i64).into(), // validated above: at most 3600 + ) { + Ok(path) => { + // Live state moves only with the engine action and + // only after the save landed (C01-08). + app.stream_chunk_timeout_secs = resolved; + return config_action_reply( + format!( + "stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)", + path.display() + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } + Err(err) => return ConfigReply::error(format!("Failed to save: {err}")), + } + } + app.stream_chunk_timeout_secs = resolved; + return config_action_reply( + format!( + "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)" + ), + AppAction::UpdateStreamChunkTimeout(resolved), + ); + } + "search" | "search.provider" | "search_provider" => { + return set_search_provider(app, value, persist); + } + "prompt_suggestion" => return set_prompt_suggestion(app, value, persist), + _ => {} + } + + // This copy exists to validate the value and to project it onto live `App` + // state. It is deliberately *not* what gets saved: see + // [`persist_single_setting`]. + let mut settings = match Settings::load_persisted() { + Ok(s) => s, + Err(e) if !persist => { + app.status_message = Some(format!( + "Settings unavailable; applying session-only override ({e})" + )); + Settings::default() + } + Err(e) => return ConfigReply::error(format!("Failed to load settings: {e}")), + }; + + if let Err(e) = settings.set(&key, value) { + return ConfigReply::error(format!("{e}")); + } + // Runtime/environment constraints are an effective projection, not saved + // preferences. Keep the persisted copy pristine so NO_ANIMATIONS or a + // terminal quirk cannot become permanent during an unrelated edit. + let mut effective_settings = settings.clone(); + effective_settings.apply_env_overrides(); + + let mut action = None; + match key.as_str() { + "auto_compact" | "compact" => { + app.auto_compact = settings.auto_compact; + app.auto_compact_user_configured = true; + action = Some(AppAction::UpdateCompaction(app.compaction_config())); + } + "auto_compact_threshold" | "auto_compact_threshold_percent" => { + app.auto_compact = true; + app.auto_compact_user_configured = true; + app.auto_compact_threshold_percent = settings.auto_compact_threshold_percent; + app.update_model_compaction_budget(); + action = Some(AppAction::UpdateCompaction(app.compaction_config())); + } + "calm_mode" | "calm" => { + app.calm_mode = settings.calm_mode; + app.mark_history_updated(); + } + "low_motion" | "motion" => { + app.low_motion = effective_settings.low_motion; + app.needs_redraw = true; + } + "fancy_animations" | "fancy" | "animations" => { + app.fancy_animations = effective_settings.fancy_animations; + app.needs_redraw = true; + } + "focus_texture" | "texture" => { + app.focus_texture = + crate::tui::focus_texture::FocusTextureMode::parse(&settings.focus_texture) + .unwrap_or_default(); + app.needs_redraw = true; + } + "work_surface_placement" | "work_surface" | "work_rail" => { + app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::parse( + &settings.work_surface_placement, + ); + app.work_surface.focused = false; + app.work_surface.last_area = None; + app.needs_redraw = true; + } + "rail_panel" | "rail" => { + app.work_surface.panel = + crate::tui::work_surface::RailPanel::parse(&settings.rail_panel); + app.needs_redraw = true; + } + "work_surface_top_height" | "work_top_height" => { + app.work_surface.top_height = settings.work_surface_top_height; + app.needs_redraw = true; + } + "work_surface_side_width" | "work_side_width" => { + app.work_surface.side_width = settings.work_surface_side_width; + app.needs_redraw = true; + } + "bracketed_paste" | "paste" => { + app.use_bracketed_paste = settings.bracketed_paste; + app.needs_redraw = true; + } + "status_indicator" | "indicator" => { + app.status_indicator = settings.status_indicator.clone(); + app.needs_redraw = true; + } + "synchronized_output" | "sync_output" | "sync" => { + app.synchronized_output_enabled = effective_settings.synchronized_output_enabled(); + app.needs_redraw = true; + } + "show_thinking" | "thinking" => { + app.show_thinking = settings.show_thinking; + app.mark_history_updated(); + } + "thinking_default_expanded" | "thinking_expanded" => { + app.thinking_default_expanded = settings.thinking_default_expanded; + app.mark_history_updated(); + } + "thinking_preview_lines" | "thinking_preview" => { + app.thinking_preview_lines = settings.thinking_preview_lines; + app.mark_history_updated(); + } + "help_expand_groups" | "help_expanded" => { + app.help_expand_groups = settings.help_expand_groups; + app.needs_redraw = true; + } + "pin_last_prompt" | "pin_prompt" => { + app.pin_last_prompt = settings.pin_last_prompt; + app.needs_redraw = true; + } + "thinking_highlight" | "reasoning_highlight" => { + app.thinking_highlight = settings.thinking_highlight; + app.mark_history_updated(); + } + "show_tool_details" | "tool_details" => { + app.show_tool_details = settings.show_tool_details; + app.mark_history_updated(); + } + "inline_diffs" | "inline_diff" | "diffs" => { + app.inline_diff_mode = crate::settings::InlineDiffMode::parse(&settings.inline_diffs); + app.mark_history_updated(); + app.needs_redraw = true; + } + "locale" | "language" => { + app.ui_locale = resolve_locale(&settings.locale); + app.mark_history_updated(); + app.needs_redraw = true; + } + "theme" | "ui_theme" | "background_color" | "background" | "bg" => { + // Theme previews reload persisted settings for each cursor move. + // Keep a session-only background overlay live unless this command + // is itself updating (or clearing) the background. + let background_color_override = if matches!(key.as_str(), "theme" | "ui_theme") { + app.background_color_override + } else { + settings + .background_color + .as_deref() + .and_then(codewhale_palette::parse_hex_rgb_color) + }; + let background_setting = + background_color_override.and_then(codewhale_palette::hex_rgb_string); + let (theme_name, theme_id, ui_theme) = match codewhale_palette::resolve_theme_setting( + &settings.theme, + background_setting.as_deref(), + ) { + Ok(resolved) => resolved, + Err(error) => { + return ConfigReply::error(format!("Failed to apply theme: {error}")); + } + }; + app.background_color_override = background_color_override; + app.theme_id = theme_id; + app.theme_name = theme_name; + app.ui_theme = ui_theme; + app.needs_redraw = true; + } + "cost_currency" | "currency" => { + app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency) + .unwrap_or(crate::pricing::CostCurrency::Usd); + app.needs_redraw = true; + } + key @ ("mini_window.keep_header" + | "mini_window.keep_input" + | "mini_window.keep_todo" + | "mini_window.keep_sidebar" + | "mini_window.keep_footer") => { + let field = key.strip_prefix("mini_window.").unwrap_or(key); + let value = match parse_config_bool(value) { + Ok(value) => value, + Err(err) => return ConfigReply::error(err), + }; + // Persist first: a failed save leaves the live layout as it was + // (C01-08). + if persist + && let Err(err) = crate::config_persistence::persist_mini_window_bool_key( + app.config_path.as_deref(), + field, + value, + ) + { + return ConfigReply::error(format!("Failed to persist: {err}")); + } + match field { + "keep_header" => app.mini_window.keep_header = value, + "keep_input" => app.mini_window.keep_input = value, + "keep_todo" => app.mini_window.keep_todo = value, + "keep_sidebar" => app.mini_window.keep_sidebar = value, + "keep_footer" => app.mini_window.keep_footer = value, + _ => unreachable!("mini_window field matched above"), + } + app.needs_redraw = true; + } + "composer_density" | "composer" => { + app.composer_density = + crate::tui::app::ComposerDensity::from_setting(&settings.composer_density); + app.needs_redraw = true; + } + "composer_border" | "border" => { + app.composer_border = settings.composer_border; + app.needs_redraw = true; + } + "composer_multiline_mode" | "multiline_mode" | "multiline" => { + app.composer_multiline_mode = settings.composer_multiline_mode; + app.needs_redraw = true; + } + "composer_vim_mode" | "vim_mode" | "vim" => { + app.composer.vim_enabled = settings.composer_vim_mode == "vim"; + app.composer.vim_mode = if app.composer.vim_enabled { + VimMode::Normal + } else { + VimMode::Insert + }; + app.composer.vim_pending_d = false; + app.needs_redraw = true; + } + "paste_burst_detection" | "paste_burst" => { + app.use_paste_burst_detection = settings.paste_burst_detection; + if !app.use_paste_burst_detection { + app.paste_burst.clear_after_explicit_paste(); + } + } + "mention_menu_limit" | "mention_limit" => { + app.mention_menu_limit = settings.mention_menu_limit; + app.composer.mention_completion_cache = None; + app.composer.mention_discovery.invalidate(); + app.needs_redraw = true; + } + "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { + app.mention_menu_behavior = settings.mention_menu_behavior.clone(); + app.composer.mention_completion_cache = None; + app.composer.mention_discovery.invalidate(); + app.needs_redraw = true; + } + "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => { + app.mention_walk_depth = settings.mention_walk_depth; + app.composer.mention_completion_cache = None; + app.composer.mention_discovery.invalidate(); + app.needs_redraw = true; + } + "workspace_follow_symlinks" | "follow_symlinks" => { + // Persist first: a failed save leaves the live value as it was + // (C01-08). + if persist && let Err(e) = persist_single_setting(&key, value) { + return ConfigReply::error(format!("Failed to save: {e}")); + } + app.workspace_follow_symlinks = settings.workspace_follow_symlinks; + app.composer.mention_completion_cache = None; + app.composer.mention_discovery.invalidate(); + app.needs_redraw = true; + // Engine tools use EngineConfig which is fixed at startup + return ConfigReply::message(if persist { + format!( + "workspace_follow_symlinks = {} (saved; restart required for engine tools)", + settings.workspace_follow_symlinks + ) + } else { + format!( + "workspace_follow_symlinks = {} (session only for UI; restart required for engine tools)", + settings.workspace_follow_symlinks + ) + }); + } + "transcript_spacing" | "spacing" => { + app.transcript_spacing = + crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing); + app.mark_history_updated(); + } + "tool_collapse" | "tool_collapse_mode" | "collapse" => { + app.tool_collapse_mode = + crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode); + app.expanded_tool_runs.clear(); + app.mark_history_updated(); + } + // `default_mode` is a restart default, not a live mode switch. The + // `/mode` command owns synchronized session transitions. + "default_mode" => {} + "mode" => { + let mode = AppMode::from_setting(&settings.default_mode); + app.set_mode(mode); + action = Some(AppAction::ModeChanged(mode)); + } + "max_history" | "history" => { + app.max_input_history = settings.max_input_history; + } + "reasoning_effort" | "effort" => { + app.reasoning_effort_preference = settings + .reasoning_effort + .as_deref() + .map(ReasoningEffort::from_setting); + app.reasoning_effort = app.reasoning_effort_preference.map_or_else( + || { + if app.auto_model { + ReasoningEffort::Auto + } else { + ReasoningEffort::default() + } + }, + |requested| { + if app.auto_model { + requested + } else { + requested.normalize_for_provider(app.api_provider) + } + }, + ); + app.invalidate_route_receipts_for_reasoning_change(); + app.update_model_compaction_budget(); + action = Some(AppAction::UpdateCompaction(app.compaction_config())); + } + "context_panel" | "context" | "session_panel" => { + app.context_panel = settings.context_panel; + app.needs_redraw = true; + } + "sessions_rail" | "sessions_panel" | "session_rail" => { + app.sessions_rail = settings.sessions_rail; + app.needs_redraw = true; + } + _ => {} + } + + let display_value = match key.as_str() { + "default_mode" | "mode" => settings.default_mode.clone(), + "cost_currency" | "currency" => settings.cost_currency.clone(), + "theme" | "ui_theme" => settings.theme.clone(), + "synchronized_output" | "sync_output" | "sync" => settings.synchronized_output.clone(), + "background_color" | "background" | "bg" => settings + .background_color + .clone() + .unwrap_or_else(|| "default".to_string()), + "reasoning_effort" | "effort" => settings.reasoning_effort.as_deref().map_or_else( + || "config/default".to_string(), + |value| { + ReasoningEffort::from_setting_for_provider(value, app.api_provider) + .as_setting_for_provider(app.api_provider) + .to_string() + }, + ), + "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(), + "composer_multiline_mode" | "multiline_mode" | "multiline" => { + settings.composer_multiline_mode.to_string() + } + "low_motion" | "motion" => settings.low_motion.to_string(), + "fancy_animations" | "fancy" | "animations" => settings.fancy_animations.to_string(), + _ => value.to_string(), + }; + + let message = if persist { + if let Err(e) = persist_single_setting(&key, value) { + // C01-08: the projection above already changed live state (and + // some arms validate only while projecting, so saving first could + // persist a value that never applied). Report exactly that partial + // effect, and keep the action so the engine matches the UI instead + // of silently diverging from it. + return ConfigReply { + message: Some(ConfigMessage::literal(format!( + "Error: {key} = {display_value} applies to this session only; saving failed: {e}" + ))), + action: action.and_then(crate::commands::config_host::app_action_to_config_action), + is_error: true, + }; + } + format!("{key} = {display_value} (saved)") + } else { + format!("{key} = {display_value} (session only, add --save to persist)") + }; + ConfigReply { + message: Some(ConfigMessage::literal(message)), + action: action.and_then(crate::commands::config_host::app_action_to_config_action), + is_error: false, + } +} + +/// Persist exactly the one key `/set --save` changed. +/// +/// `/set` loads a `Settings` copy up front to validate the value and to project +/// it onto live `App` state, and a lot of `App` mutation happens in between. That +/// copy is a stale snapshot by the time we get here, so saving *it* would write +/// back every other field as it looked before — reverting any mode, thinking, +/// model, or permission write that landed in the meantime. Re-applying the single +/// key inside [`Settings::transact`] persists the user's actual edit and nothing +/// else. `Settings::set` is the same normalizer the copy above already accepted +/// the value through, so this cannot fail for a value that validated. +fn persist_single_setting(key: &str, value: &str) -> anyhow::Result<()> { + Settings::transact(|settings| settings.set(key, value)) +} + +pub fn switch_mode(app: &mut App, mode: AppMode) -> String { + switch_mode_with_status(app, mode).0 +} + +/// Returns the user-facing sentence and whether live mode moved (the caller +/// emits `AppAction::ModeChanged` only for the latter). +/// +/// The three outcomes read differently on purpose. Before the typed +/// [`SettingSelection`], a refusal and a same-mode selection that *did* persist +/// the startup default both came back as "Already in X mode." — so the one case +/// where `/mode` had written something looked exactly like the case where it had +/// written nothing. +fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) { + match app.select_mode(mode) { + SettingSelection::Changed => ( + format!( + "Switched to {} mode.", + mode.display_name_localized(app.ui_locale) + ), + true, + ), + SettingSelection::PersistedSame => (app.mode_startup_default_receipt(mode), false), + SettingSelection::Refused => ( + app.setting_locked_message(MessageId::SettingSubjectMode), + false, + ), + } +} + +pub(crate) async fn set_workspace_trust(app: &mut App, trusted: bool, save: bool) -> Result<()> { + if !save { + app.trust_mode = trusted; + return Ok(()); + } + // Revocation restricts live file access even if the saved decision cannot be updated. + if !trusted { + app.trust_mode = false; + } + let workspace = app.workspace.clone(); + #[cfg(test)] + let ticket = crate::test_support::env_scope_ticket(); + tokio::task::spawn_blocking(move || { + #[cfg(test)] + let _membership = crate::test_support::join_env_scope(ticket); + crate::config::set_workspace_trust(&workspace, trusted) + }) + .await??; + app.trust_mode = trusted; + Ok(()) +} + +/// What `/trust on|off [--save]` just did, for the transcript. The toast +/// alone fades, and the footer does not show trust mode, so without this +/// line the command appeared to do nothing. +pub(crate) fn trust_change_note(trusted: bool, save: bool) -> String { + let session = if trusted { + "Trust mode is on for this session: file tools may now reach files outside this folder. `/trust off` turns it off." + } else { + "Trust mode is off for this session: it no longer lets file tools reach files outside this folder." + }; + let saved = match (save, trusted) { + (false, _) => return session.to_string(), + (true, true) => { + "Saved for this folder: its project skills, commands, hooks, MCP servers and context are trusted in later sessions too." + } + (true, false) => { + "Saved for this folder: its project skills, commands, hooks, MCP servers and context are no longer trusted." + } + }; + format!("{session}\n{saved}") +} + +pub(crate) fn expand_tilde(raw: &str) -> String { + if let Some(rest) = raw.strip_prefix("~/") + && let Some(home) = crate::config::effective_home_dir() + { + return home.join(rest).to_string_lossy().into_owned(); + } else if raw == "~" + && let Some(home) = crate::config::effective_home_dir() + { + return home.to_string_lossy().into_owned(); + } + raw.to_string() +} + +/// Toggle LSP diagnostics on/off or show status. +/// +/// - `/lsp on` — enable inline LSP diagnostics +/// - `/lsp off` — disable inline LSP diagnostics +/// - `/lsp status` — show whether diagnostics are currently enabled +pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + // Access lsp_manager config through the App's engine handle + let current_enabled = app.lsp_enabled; + + match raw { + "" | "status" => { + let status = if current_enabled { "on" } else { "off" }; + CommandResult::message(format!( + "LSP diagnostics are currently **{status}**.\n\n\ + Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits." + )) + } + "on" | "enable" | "1" | "true" => { + app.lsp_enabled = true; + CommandResult::message( + "LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.", + ) + } + "off" | "disable" | "0" | "false" => { + app.lsp_enabled = false; + CommandResult::message("LSP diagnostics disabled.") + } + other => CommandResult::error(format!( + "Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`." + )), + } +} + +pub(crate) fn clear_local_account_session() -> Result { + use codewhale_secrets::account::{ + ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE, + secure_account_session_secrets, + }; + let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?; + let api_base = std::env::var(ACCOUNT_API_BASE_ENV) + .ok() + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string()); + let store = AccountSessionStore::new(secrets, None, &api_base); + let had = store.load().map_err(|error| error.to_string())?.is_some(); + store.clear().map_err(|error| error.to_string())?; + Ok(had) +} + +pub(crate) fn clear_legacy_sandbox_slot() -> Result { + let secrets = codewhale_secrets::Secrets::auto_detect(); + let had = secrets + .get(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT) + .map_err(|error| error.to_string())? + .is_some_and(|value| !value.trim().is_empty()); + if had { + secrets + .delete(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT) + .map_err(|error| error.to_string())?; + } + Ok(had) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::config::Config; + use crate::config::NotificationMethod; + use crate::test_support::{EnvVarGuard, TestEnvLock, lock_test_env}; + use crate::tui::app::OnboardingState; + use crate::tui::app::ScreenMode; + use crate::tui::app::{App, TuiOptions}; + use std::env; + + /// Invoke the portable `/logout` handler through a real capability envelope + /// and convert the contract result back, mirroring the host registration. + fn logout_command(app: &mut App) -> CommandResult { + let result = crate::commands::groups::config::config::logout( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::LOGIN_CAPS), + None, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + + /// Invoke the portable `/trust` handler through a real capability envelope + /// and convert the contract result back, mirroring the host registration. + fn trust_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::trust( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::TRUST_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + /// Invoke the portable `/mode` handler through the real MODE_POLICY + /// envelope and convert the contract result back. + fn mode_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::mode( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::MODE_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + /// Invoke the portable `/workbar` handler through the real WORK_SURFACE + /// envelope and convert the contract result back. + fn workbar_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::workbar( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::WORK_SURFACE_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + /// Invoke the portable `/pet` handler through the real PET envelope and + /// convert the contract result back. + fn pet_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::pet( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::PET_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + /// Invoke the portable `/theme` handler through the real CONFIG envelope + /// and convert the contract result back. + fn theme_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::theme( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::CONFIG_PRESENTATION_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + /// Invoke the portable `/config` handler through the real CONFIG envelope + /// and convert the contract result back. + fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::config( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::CONFIG_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + /// Invoke the portable `/settings` handler through the real CONFIG envelope. + fn settings_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::settings( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::CONFIG_PRESENTATION_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + /// Invoke the portable `/config` handler (editor routing) through the CONFIG + /// envelope for the legacy `show_config` tests. + fn show_config(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::config( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::CONFIG_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { + let locale = app.ui_locale; + crate::commands::config_host::render_config_reply( + super::set_config_value(app, key, value, persist), + locale, + ) + } + + fn show_single_setting(app: &App, key: &str) -> CommandResult { + let locale = app.ui_locale; + crate::commands::config_host::render_config_reply( + super::show_single_setting(app, key), + locale, + ) + } + use std::fs; + use std::path::Path; + use std::path::PathBuf; + use std::time::{SystemTime, UNIX_EPOCH}; + + struct EnvGuard { + _vars: Vec, + _lock: TestEnvLock, + } + + impl EnvGuard { + fn new(home: &Path) -> Self { + let lock = lock_test_env(); + let config_path = home.join(".deepseek").join("config.toml"); + let vars = vec![ + EnvVarGuard::set("HOME", home), + EnvVarGuard::set("USERPROFILE", home), + EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")), + EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"), + EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", config_path), + EnvVarGuard::remove("CODEWHALE_ALLOW_SHELL"), + EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL"), + EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY"), + EnvVarGuard::remove("NO_ANIMATIONS"), + EnvVarGuard::remove("TERM_PROGRAM"), + EnvVarGuard::remove("PTYXIS_VERSION"), + EnvVarGuard::remove("CODEWHALE_SEARCH_PROVIDER"), + EnvVarGuard::remove("DEEPSEEK_SEARCH_PROVIDER"), + EnvVarGuard::remove("TAVILY_API_KEY"), + ]; + Self { + _vars: vars, + _lock: lock, + } + } + } + + fn create_test_app_with_config(config: &Config) -> App { + let options = TuiOptions { + model: "test-model".to_string(), + // Keep command tests independent from the developer's saved + // `default_mode` setting: with `false`, App::new starts in the + // saved mode, so a machine with `default_mode = "yolo"` flips + // `allow_shell` on and breaks the allow_shell assertions. + start_in_agent_mode: true, + skip_onboarding: false, + ..crate::tui::test_support::test_tui_options(PathBuf::from(".")) + }; + let mut app = App::new(options, config); + // App::new folds in saved TUI settings from the developer machine. + // Pin command tests back to DeepSeek semantics so model aliases are + // not normalized through a provider selected in an interactive run. + app.model = "test-model".to_string(); + app.auto_model = false; + app.api_provider = crate::config::ProviderKind::Deepseek; + app.model_ids_passthrough = false; + app + } + + fn create_test_app() -> App { + create_test_app_with_config(&Config::default()) + } + + #[test] + fn contextual_tips_disable_only_guidance_and_keep_session_cap() { + use crate::tui::app::{StatusToast, StatusToastKind, StatusToastLevel}; + use crate::tui::behavioral_tips::BehavioralTip; + + let temp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new(temp.path()); + let mut app = create_test_app(); + app.status_toasts.clear(); + assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); + app.push_status_toast("warning receipt", StatusToastLevel::Warning, None); + app.push_status_toast("error receipt", StatusToastLevel::Error, None); + app.sticky_status = Some(StatusToast::context_pressure( + "context warning", + crate::context_budget::PressureLevel::High, + )); + + let result = crate::commands::execute("/config contextual_tips off", &mut app); + assert!(!result.is_error); + assert!(!app.behavioral_tips.enabled()); + assert_eq!( + app.status_toasts + .iter() + .map(|toast| toast.text.as_str()) + .collect::>(), + ["warning receipt", "error receipt"] + ); + assert!(matches!( + app.sticky_status.as_ref().unwrap().kind, + StatusToastKind::ContextPressure(_) + )); + assert!(!app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); + + assert!(!crate::commands::execute("/config contextual_tips on", &mut app).is_error); + assert!(app.behavioral_tips.enabled()); + assert!( + !app.maybe_show_behavioral_tip(BehavioralTip::McpValidation), + "reenabling must not reset the session cap" + ); + } + + #[test] + fn contextual_tips_command_persists_and_reports_failed_save() { + let temp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new(temp.path()); + let mut app = create_test_app(); + Settings::transact(|settings| { + settings.theme = "terminal".into(); + settings + .behavioral_tip_impressions + .insert("planning_mode".into(), 2); + Ok(()) + }) + .unwrap(); + + let result = crate::commands::execute("/config contextual_tips off --save", &mut app); + assert!(!result.is_error); + let saved = Settings::load_persisted().unwrap(); + assert!(!saved.contextual_tips); + assert_eq!(saved.theme, "terminal"); + assert_eq!( + saved.behavioral_tip_impressions.get("planning_mode"), + Some(&2) + ); + assert!( + !create_test_app().behavioral_tips.enabled(), + "restart must load the saved opt-out" + ); + + assert!(!crate::commands::execute("/config contextual_tips on --save", &mut app).is_error); + assert!(create_test_app().behavioral_tips.enabled()); + assert_eq!( + Settings::load_persisted() + .unwrap() + .behavioral_tip_impressions + .get("planning_mode"), + Some(&2) + ); + let path = Settings::path().unwrap(); + let before = fs::read(&path).unwrap(); + assert!( + crate::commands::execute("/config contextual_tips invalid --save", &mut app).is_error + ); + assert_eq!(fs::read(&path).unwrap(), before); + assert!(app.behavioral_tips.enabled()); + + let malformed = "contextual_tips = [private_fixture_payload\n"; + fs::write(&path, malformed).unwrap(); + let failed = crate::commands::execute("/config contextual_tips off --save", &mut app); + assert!(failed.is_error); + assert!( + !app.behavioral_tips.enabled(), + "failed persistence still honors the session opt-out" + ); + assert_eq!(fs::read_to_string(path).unwrap(), malformed); + let message = failed.message.unwrap(); + assert!(message.contains("could not be saved"), "{message}"); + assert!( + !message.contains("private_fixture_payload"), + "parse errors must not echo settings contents" + ); + assert!(message.ends_with(&app.status_toasts.back().unwrap().text)); + } + + #[test] + fn screen_commands_dispatch_to_the_matching_screen_mode() { + let mut app = create_test_app(); + assert_eq!(app.screen_mode, ScreenMode::Fullscreen); + + let switched = crate::commands::execute("/inline", &mut app); + assert_eq!( + switched.action, + Some(AppAction::SetScreenMode(ScreenMode::Inline)), + "/inline must ask for the inline screen" + ); + assert!(!switched.is_error, "/inline must not be an error"); + + // The action is applied where the terminal lives, so the app is still + // fullscreen here; asking for the current mode reports, it does not + // emit a second action. + let repeated = crate::commands::execute("/fullscreen", &mut app); + assert!( + repeated.action.is_none(), + "already-current mode must not re-switch" + ); + assert!( + repeated + .message + .as_deref() + .is_some_and(|msg| msg.contains("Already on the fullscreen screen")), + "{:?}", + repeated.message + ); + + let rejected = crate::commands::execute("/inline sideways", &mut app); + assert!(rejected.is_error, "/inline takes no argument"); + } + + #[test] + fn config_unknown_setting_suggests_nearest_key() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("auto_compcat")); + assert!(result.is_error); + let text = result.message.as_deref().unwrap_or_default(); + assert!( + text.contains("Did you mean `/config auto_compact`?"), + "{text}" + ); + assert!(text.contains("/settings text"), "{text}"); + + let result = config_command(&mut app, Some("zzqqxxyy")); + assert!(result.is_error); + let text = result.message.as_deref().unwrap_or_default(); + assert!(!text.contains("Did you mean"), "{text}"); + assert!(text.contains("/settings text"), "{text}"); + + // Internal flags, actions and retired schema defs are not settings a + // user can look up, and are never suggested. + for internal in ["feature_intro_shown", "mcp_open", "fast_model"] { + let result = config_command(&mut app, Some(internal)); + assert!(result.is_error, "{internal}: {:?}", result.message); + let text = result.message.as_deref().unwrap_or_default(); + assert!(!text.contains(&format!("`/config {internal}`")), "{text}"); + } + } + + #[test] + fn config_workflow_and_goal_explain_the_effective_tables() { + let mut app = create_test_app(); + for token in ["workflow", "goal"] { + let result = config_command(&mut app, Some(token)); + assert!( + result.action.is_none(), + "{token} must not spend a model turn" + ); + let text = result.message.as_deref().unwrap_or_default(); + assert!( + text.contains("require_approval_for_writes"), + "{token}: {text}" + ); + assert!(text.contains("max_continuations"), "{token}: {text}"); + } + } + + #[test] + fn title_config_reports_the_default_not_the_session_override() { + let config = Config { + title: Some(" workspace\u{1b}]0;ignored\u{7}\u{202e}-default ".to_string()), + ..Config::default() + }; + let mut app = create_test_app_with_config(&config); + assert_eq!( + app.title_default.as_deref(), + Some("workspace]0;ignored-default") + ); + app.window_title = Some("session-override".to_string()); + + let shown = show_single_setting(&app, "title"); + + assert_eq!( + shown.message.as_deref(), + Some("title = workspace]0;ignored-default") + ); + } + + #[test] + fn title_config_normalizes_the_live_and_persisted_default() { + let dir = tempfile::tempdir().expect("isolated config dir"); + let config_path = dir.path().join("config.toml"); + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + + let result = set_config_value( + &mut app, + "title", + " Ev\u{1b}]0;PWNED\u{7}il\u{202e} Beta ", + true, + ); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.title_default.as_deref(), Some("Ev]0;PWNEDil Beta")); + assert!(app.needs_redraw); + let loaded = Config::load(Some(config_path), None).expect("reload saved config"); + assert_eq!(loaded.title.as_deref(), Some("Ev]0;PWNEDil Beta")); + } + + /// The shipped preset must survive its own preflight, or `/config preset + /// calm` would be refused for a reason the user cannot act on. + #[test] + fn the_shipped_preset_passes_its_own_preflight() { + let app = create_test_app(); + let fields = crate::settings::preset_fields("calm").expect("the calm preset exists"); + assert_eq!(preset_preflight(&app, fields), None); + } + + /// A field the setter would reject must be caught *before* the transaction + /// opens. Previously the bundle was saved first and the per-field mirror + /// pass then failed, leaving the user with an error message and a rewritten + /// settings file. + #[test] + fn preset_preflight_refuses_an_invalid_field_before_any_write() { + let app = create_test_app(); + let refusal = preset_preflight(&app, &[("calm_mode", "true"), ("low_motion", "banana")]) + .expect("an invalid value must be refused"); + assert!( + refusal.contains("low_motion"), + "the refusal must name the offending field, got {refusal:?}" + ); + } + + /// A preset carrying a live-route key is refused whole while a turn runs, + /// rather than saving the bundle and then failing on that one field. + #[test] + fn preset_preflight_refuses_a_live_route_field_while_a_turn_runs() { + let mut app = create_test_app(); + app.is_loading = true; + let bundle = [("calm_mode", "true"), ("reasoning_effort", "high")]; + let refusal = + preset_preflight(&app, &bundle).expect("a live-route field must be refused mid-turn"); + assert!( + refusal.contains("locked while a turn is running"), + "got {refusal:?}" + ); + + app.is_loading = false; + assert_eq!( + preset_preflight(&app, &bundle), + None, + "the same bundle must apply once the turn ends" + ); + } + + /// The refusal list is the contract for #2982 on the slash surfaces. Keep + /// restart-only `default_mode` out of it: `set_config_value` deliberately + /// does not apply that key to the live session. + #[test] + fn live_route_key_list_covers_every_route_mutating_alias() { + for key in [ + "mode", + "model", + "default_model", + "reasoning_effort", + "effort", + "provider", + "approval_mode", + "approval_policy", + "approval", + ] { + assert!( + live_route_setting_subject(key).is_some(), + "{key} mutates the active route and must be locked mid-turn" + ); + } + for key in ["default_mode", "theme", "calm_mode", "rail_panel"] { + assert!( + live_route_setting_subject(key).is_none(), + "{key} does not mutate the active route and must stay settable" + ); + } + } + + #[test] + fn approval_aliases_are_inert_while_a_turn_is_running() { + let mut app = create_test_app(); + app.approval_mode = ApprovalMode::Suggest; + app.is_loading = true; + + for key in ["approval_mode", "approval_policy", "approval"] { + let result = set_config_value(&mut app, key, "never", false); + assert!(result.is_error, "{key} must be refused mid-turn"); + assert!( + result + .message + .as_deref() + .is_some_and(|message| message.contains("locked while a turn is running")), + "unexpected refusal for {key}: {:?}", + result.message + ); + assert_eq!(app.approval_mode, ApprovalMode::Suggest); + } + } + + #[test] + fn config_preset_calm_applies_bundle_to_session_and_keeps_evidence() { + let mut app = create_test_app(); + app.calm_mode = false; + app.show_thinking = true; + app.show_tool_details = true; + app.fancy_animations = true; + + let result = config_command(&mut app, Some("preset calm")); + let message = result.message.unwrap_or_default(); + assert!( + message.contains("calm"), + "summary should name the preset: {message}" + ); + + assert!(app.calm_mode); + assert!(!app.show_tool_details); + assert!(app.low_motion); + assert!(!app.fancy_animations); + assert_eq!( + app.tool_collapse_mode, + crate::tui::app::ToolCollapseMode::Calm + ); + assert_eq!( + app.transcript_spacing, + crate::tui::app::TranscriptSpacing::Compact + ); + // Evidence preserved: thinking is not hidden by the preset. + assert!(app.show_thinking, "calm preset must not hide thinking"); + } + + #[test] + fn config_preset_unknown_name_reports_error() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("preset turbo")); + let message = result.message.unwrap_or_default(); + assert!( + message.to_lowercase().contains("unknown preset"), + "expected unknown-preset error, got: {message}" + ); + } + + #[test] + fn config_preset_save_without_name_reports_usage() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("preset --save")); + let message = result.message.unwrap_or_default(); + assert!( + message.contains("Usage: /config preset"), + "expected usage hint, got: {message}" + ); + assert!(!result.is_error); + } + + #[test] + fn work_surface_config_applies_live_and_accepts_bottom() { + let mut app = create_test_app(); + + let result = set_config_value(&mut app, "work_surface_placement", "left", false); + assert!(!result.is_error, "{:?}", result.message); + assert_eq!( + app.work_surface.placement, + crate::tui::work_surface::WorkSurfacePlacement::Left + ); + let shown = show_single_setting(&app, "work_surface_placement"); + assert_eq!( + shown.message.as_deref(), + Some("work_surface_placement = left") + ); + + let result = set_config_value(&mut app, "work_surface_placement", "bottom", false); + assert!(!result.is_error, "{:?}", result.message); + assert_eq!( + app.work_surface.placement, + crate::tui::work_surface::WorkSurfacePlacement::Bottom + ); + } + + #[test] + fn rail_command_on_restores_default_bottom_placement() { + let mut app = create_test_app(); + app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off; + + let result = workbar_command(&mut app, Some("on")); + + assert!(!result.is_error); + assert_eq!( + app.work_surface.placement, + crate::tui::work_surface::WorkSurfacePlacement::Bottom + ); + let message = result.message.unwrap_or_default(); + assert!(message.contains("bottom placement"), "got: {message}"); + } + + #[test] + fn pet_sound_command_is_opt_in_and_rejects_invalid_changes() { + let mut app = create_test_app(); + let status = pet_command(&mut app, Some("sound")); + assert!(!status.is_error); + assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff); + + assert!(!pet_command(&mut app, Some(" SOUND ON ")).is_error); + assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused); + for invalid in ["sound yes", "sound off extra", "sound on --save"] { + assert!(pet_command(&mut app, Some(invalid)).is_error, "{invalid}"); + assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused); + } + assert!(!pet_command(&mut app, Some("sound off")).is_error); + assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff); + } + + #[test] + fn pet_command_selects_the_main_view_without_taking_composer_focus() { + let mut app = create_test_app(); + app.onboarding = crate::tui::app::OnboardingState::None; + app.redaction_gate = false; + app.input = "kept draft".into(); + app.pet_watch.detach_for_test(); + + let on = pet_command(&mut app, None); + assert!(!on.is_error); + assert!(app.pet_watch.enabled); + assert!(crate::tui::pet_watch::is_open(&app)); + assert_eq!( + on.message.as_deref(), + Some(&*tr(app.ui_locale, MessageId::PetModeOn)) + ); + assert!(app.view_stack.is_empty()); + // Repeating `on` leaves the composer in the same shell focus. + assert!(!pet_command(&mut app, Some(" ON ")).is_error); + assert!(app.pet_watch.enabled); + assert!(crate::tui::pet_watch::is_open(&app)); + + let off = pet_command(&mut app, Some("off")); + assert!(!off.is_error); + assert!(!app.pet_watch.enabled); + assert!(!crate::tui::pet_watch::is_open(&app)); + assert!(app.view_stack.is_empty()); + assert_eq!(app.input, "kept draft"); + assert_eq!( + off.message.as_deref(), + Some(&*tr(app.ui_locale, MessageId::PetModeOff)) + ); + + // Bare /pet toggles back on; unknown verbs are refused with usage. + app.pet_watch.detach_for_test(); + assert!(!pet_command(&mut app, None).is_error); + assert!(app.pet_watch.enabled); + assert!(pet_command(&mut app, Some("bogus")).is_error); + let status = pet_command(&mut app, Some("status")); + assert!(!status.is_error); + let message = status.message.unwrap_or_default(); + assert!( + message.contains(&*tr(app.ui_locale, MessageId::PetModeOnLabel)), + "{message}" + ); + assert!( + message.contains(&*tr(app.ui_locale, MessageId::PetViewOpen)), + "{message}" + ); + } + + #[test] + fn rail_command_reports_narrow_terminal_top_fallback() { + let mut app = create_test_app(); + app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Left; + // A 60-column host is below the side-rail floor, so the effective + // placement falls back to top; the status must say so rather than + // claim a left workbar renders. + let _ = crate::tui::work_surface::height(&mut app, 60, 24, u16::MAX); + + let result = workbar_command(&mut app, None); + + assert!(!result.is_error); + let message = result.message.unwrap_or_default(); + assert!(message.contains("left placement"), "got: {message}"); + assert!(message.contains("showing top for now"), "got: {message}"); + } + + #[test] + fn rail_command_off_never_claims_visibility() { + let mut app = create_test_app(); + + let result = workbar_command(&mut app, Some("off")); + + assert!(!result.is_error); + assert_eq!( + app.work_surface.placement, + crate::tui::work_surface::WorkSurfacePlacement::Off + ); + let message = result.message.unwrap_or_default(); + assert!(message.contains("Workbar is off"), "got: {message}"); + assert!( + !message.contains("Workbar is visible"), + "the readout must never claim a hidden surface renders: {message}" + ); + } + + #[test] + fn rail_command_rejects_retired_auto_mode() { + let mut app = create_test_app(); + + let result = workbar_command(&mut app, Some("auto")); + + assert!(result.is_error); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("Usage: /workbar") + ); + } + + #[test] + fn test_mode_yolo_sets_all_flags() { + let mut app = create_test_app(); + // Switch to Agent first to guarantee a clean starting state regardless of + // user settings on the host machine. + let _ = mode_command(&mut app, Some("agent")); + let result = mode_command(&mut app, Some("yolo")); + // YOLO is invisible Act+Bypass shorthand — user-facing copy says Act. + assert!(result.message.unwrap().contains("Switched to Act mode")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); + assert!(app.allow_shell); + assert!(app.trust_mode); + assert!(app.yolo); + assert_eq!(app.approval_mode, ApprovalMode::Bypass); + // The deprecated YOLO alias remaps to Agent mode (M6 compat shim). + assert_eq!(app.mode, AppMode::Agent); + } + + #[test] + fn test_mode_switch_command_accepts_names_and_numbers() { + let mut app = create_test_app(); + let _ = mode_command(&mut app, Some("agent")); + assert_eq!(app.mode, AppMode::Agent); + let result = mode_command(&mut app, Some("2")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan))); + assert_eq!(app.mode, AppMode::Plan); + let result = mode_command(&mut app, Some("act")); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); + assert_eq!(app.mode, AppMode::Agent); + let _ = mode_command(&mut app, Some("plan")); + assert_eq!(app.mode, AppMode::Plan); + let result = mode_command(&mut app, Some("3")); + assert_eq!( + result.action, + Some(AppAction::ModeChanged(AppMode::Operate)) + ); + assert_eq!(app.mode, AppMode::Operate); + let result = mode_command(&mut app, Some("5")); + assert!(result.is_error); + assert_eq!(app.mode, AppMode::Operate); + let result = mode_command(&mut app, Some("9")); + assert!(result.is_error); + assert_eq!(app.mode, AppMode::Operate); + let result = mode_command(&mut app, Some("4")); + // "4" still routes to the deprecated YOLO alias, which lands in Agent + // mode with bypass approvals (M6 compat shim). + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); + assert_eq!(app.mode, AppMode::Agent); + assert!(app.yolo); + } + + #[test] + fn test_mode_without_arg_opens_picker() { + let mut app = create_test_app(); + let result = mode_command(&mut app, None); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::OpenModePicker))); + } + + #[test] + fn test_mode_rejects_unknown_value() { + let mut app = create_test_app(); + let result = mode_command(&mut app, Some("fast")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Usage: /mode")); + } + + #[test] + fn test_show_config_defaults_to_native() { + let mut app = create_test_app(); + app.session.total_tokens = 1234; + let result = show_config(&mut app, None); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::OpenConfigView))); + } + + #[test] + fn test_show_config_native_opens_config_view() { + let mut app = create_test_app(); + let result = show_config(&mut app, Some("native")); + assert!(result.message.is_none()); + assert!(matches!(result.action, Some(AppAction::OpenConfigView))); + } + + #[test] + fn test_show_config_tui_and_web_open_the_same_config_view() { + let mut app = create_test_app(); + for arg in ["tui", "web", "TUI", " Web "] { + let result = show_config(&mut app, Some(arg)); + assert!(result.message.is_none(), "{arg}"); + assert!( + matches!(result.action, Some(AppAction::OpenConfigView)), + "{arg}" + ); + } + } + + #[test] + fn test_show_config_non_editor_token_is_a_key_lookup() { + // `tui`/`web`/`native` open the config view; every other single token is + // a `/config ` lookup (unknown keys fail as settings, not editors). + let mut app = create_test_app(); + let result = show_config(&mut app, Some("zzqqxxyy")); + assert!(result.is_error); + assert!(result.message.unwrap().contains("Unknown setting")); + } + + #[test] + fn test_show_settings_loads_from_file() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let report = show_settings(&mut app).expect("settings facts load"); + // Settings should load (may use defaults if file doesn't exist) + assert!(!report.rows.is_empty()); + assert!(!report.config_file.is_empty()); + } + + #[test] + fn settings_command_opens_typed_editor_and_preserves_text_mode() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let modal = settings_command(&mut app, None); + assert!(modal.message.is_none()); + assert!(matches!(modal.action, Some(AppAction::OpenConfigView))); + + let text = settings_command(&mut app, Some("text")); + let message = text.message.as_deref().expect("settings diagnostic text"); + assert!(message.contains("Settings:"), "{message}"); + assert!( + message.contains("model defaults: config.toml"), + "{message}" + ); + assert!(!message.contains("provider_models:"), "{message}"); + assert!(message.contains("Config file:"), "{message}"); + assert!(text.action.is_none()); + } + + #[test] + fn config_model_updates_app_state() { + let mut app = create_test_app(); + let _old_model = app.model.clone(); + let result = config_command(&mut app, Some("model deepseek-v4-flash")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("model = deepseek-v4-flash")); + assert_eq!(app.model, "deepseek-v4-flash"); + assert!(matches!( + result.action, + Some(AppAction::UpdateCompaction(_)) + )); + } + + #[test] + fn config_model_rejects_foreign_model_for_direct_provider() { + let mut app = create_test_app(); + app.api_provider = ProviderKind::Zai; + app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); + + let result = set_config_value(&mut app, "model", "deepseek-v4-pro", false); + + assert!(result.is_error); + assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL); + assert!(result.action.is_none()); + let message = result.message.as_deref().expect("rejection message"); + assert!( + message.contains("not compatible with provider 'zai'") + || message.contains("not served by direct provider zai"), + "unexpected rejection message: {message}" + ); + assert!(message.contains("deepseek-v4-pro"), "{message}"); + } + + #[test] + fn config_model_auto_preserves_explicit_thinking() { + let mut app = create_test_app(); + app.reasoning_effort = ReasoningEffort::Off; + app.reasoning_effort_preference = Some(ReasoningEffort::Off); + + let result = config_command(&mut app, Some("model auto")); + + assert!(result.message.is_some()); + assert!(app.auto_model); + assert_eq!(app.model, "auto"); + assert_eq!(app.reasoning_effort, ReasoningEffort::Off); + assert!( + result + .message + .as_deref() + .is_some_and(|message| message.contains("thinking = off")) + ); + assert!(app.last_effective_model.is_none()); + assert!(app.last_effective_reasoning_effort.is_none()); + } + + #[test] + fn config_model_auto_releases_implicit_fixed_model_thinking() { + let mut app = create_test_app(); + app.reasoning_effort = ReasoningEffort::Max; + app.reasoning_effort_preference = None; + + let result = config_command(&mut app, Some("model auto")); + + assert!(result.message.is_some()); + assert!(app.auto_model); + assert_eq!(app.reasoning_effort, ReasoningEffort::Auto); + assert_eq!(app.reasoning_effort_preference, None); + assert!( + result + .message + .as_deref() + .is_some_and(|message| message.contains("thinking = auto")) + ); + } + + #[test] + fn config_reasoning_effort_applies_while_model_routing_is_auto() { + let mut app = create_test_app(); + app.set_model_selection("auto".to_string()); + app.reasoning_effort = ReasoningEffort::Auto; + app.reasoning_effort_preference = None; + + let result = set_config_value(&mut app, "reasoning_effort", "low", false); + + assert!(!result.is_error); + assert_eq!(app.reasoning_effort, ReasoningEffort::Low); + assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Low)); + assert!(matches!( + result.action, + Some(AppAction::UpdateCompaction(_)) + )); + } + + #[test] + fn config_default_model_cannot_replace_a_non_deepseek_live_route() { + let temp_root = tempfile::tempdir().expect("isolated configuration"); + let _guard = EnvGuard::new(temp_root.path()); + let config_path = crate::config_persistence::config_toml_path(None).expect("config path"); + fs::create_dir_all(config_path.parent().expect("config directory")).expect("mkdir"); + fs::write( + &config_path, + "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n", + ) + .expect("config"); + let mut app = create_test_app(); + app.api_provider = ProviderKind::Zai; + app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); + app.auto_model = false; + + let session_only = set_config_value(&mut app, "default_model", "deepseek-v4-flash", false); + + assert!(session_only.is_error); + assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL); + assert!(session_only.action.is_none()); + assert!( + session_only + .message + .as_deref() + .is_some_and(|message| message.contains("DeepSeek startup fallback")) + ); + + let saved = set_config_value(&mut app, "default_model", "deepseek-v4-flash", true); + + assert!(!saved.is_error); + assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL); + assert!(saved.action.is_none()); + assert!( + saved + .message + .as_deref() + .is_some_and(|message| message.contains("active zai/GLM-5.2 is unchanged")) + ); + let persisted: toml::Value = + toml::from_str(&fs::read_to_string(&config_path).expect("saved config")).expect("toml"); + assert_eq!( + persisted["providers"]["deepseek"]["model"].as_str(), + Some("deepseek-v4-flash") + ); + assert_eq!(persisted["provider"].as_str(), Some("zai")); + assert_eq!( + saved_deepseek_default_model(&app).expect("saved value"), + "deepseek-v4-flash" + ); + assert!( + Settings::load_persisted() + .expect("settings") + .default_model + .is_none() + ); + } + + #[test] + fn saved_automatic_model_selection_round_trips_every_provider_route() { + let temp_root = tempfile::tempdir().expect("isolated configuration"); + let _guard = EnvGuard::new(temp_root.path()); + let config_path = crate::config_persistence::config_toml_path(None).unwrap(); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + for (provider, selector, table, initial) in [ + ( + ProviderKind::Deepseek, + "deepseek", + "deepseek", + "deepseek-v4-pro", + ), + ( + ProviderKind::Deepseek, + "deepseek-cn", + "deepseek_cn", + "deepseek-v4-pro", + ), + (ProviderKind::Zai, "zai", "zai", "GLM-5.3"), + ] { + fs::write( + &config_path, + format!("provider = '{selector}'\n[providers.{table}]\nmodel = '{initial}'\n"), + ) + .unwrap(); + let mut app = create_test_app(); + app.set_provider_identity(provider, selector); + app.model = initial.to_string(); + app.auto_model = false; + let result = set_config_value(&mut app, "model", "auto", true); + assert!(!result.is_error, "{:?}", result.message); + assert!(app.auto_model); + assert!( + result + .message + .as_deref() + .is_some_and(|m| m.contains("saved")) + ); + let persisted: toml::Value = + toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); + assert_eq!( + persisted["providers"][table]["model"].as_str(), + Some("auto") + ); + let loaded = Config::load(Some(config_path.clone()), None).unwrap(); + assert_eq!( + loaded.default_model(), + "auto", + "{selector} must consume its saved choice" + ); + } + } + + #[test] + fn config_default_model_save_accepts_models_declared_for_the_deepseek_route() { + let temp_root = tempfile::tempdir().expect("isolated configuration"); + let _guard = EnvGuard::new(temp_root.path()); + // The declaration matches on the saved DeepSeek route's base URL; keep + // ambient endpoint overrides out of the comparison. + let _base_url_env = [ + EnvVarGuard::remove("CODEWHALE_BASE_URL"), + EnvVarGuard::remove("DEEPSEEK_BASE_URL"), + ]; + let config_path = crate::config_persistence::config_toml_path(None).expect("config path"); + fs::create_dir_all(config_path.parent().expect("config directory")).expect("mkdir"); + fs::write( + &config_path, + "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.deepseek]\nbase_url = 'https://my-gateway.example/v1'\n[[custom_models]]\nprovider = 'deepseek'\nbase_url = 'https://my-gateway.example/v1'\nid = 'my-llm'\n", + ) + .expect("config"); + + let mut app = create_test_app(); + app.api_provider = ProviderKind::Zai; + app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); + app.auto_model = false; + + let result = set_config_value(&mut app, "default_model", "my-llm", true); + + assert!(!result.is_error, "{:?}", result.message); + let persisted: toml::Value = + toml::from_str(&fs::read_to_string(&config_path).expect("saved config")).expect("toml"); + assert_eq!( + persisted["providers"]["deepseek"]["model"].as_str(), + Some("my-llm") + ); + // The same id on a different DeepSeek endpoint is not declared for the + // saved route and must still be rejected by catalog normalization. + fs::write( + &config_path, + "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.deepseek]\nbase_url = 'https://other-gateway.example/v1'\n[[custom_models]]\nprovider = 'deepseek'\nbase_url = 'https://my-gateway.example/v1'\nid = 'my-llm'\n", + ) + .expect("config"); + let rejected = set_config_value(&mut app, "default_model", "my-llm", true); + assert!(rejected.is_error, "{:?}", rejected.message); + } + + #[test] + fn saved_model_display_uses_the_same_profile_root_precedence_as_startup() { + let temp_root = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new(temp_root.path()); + let path = crate::config_persistence::config_toml_path(None).unwrap(); + fs::create_dir_all(path.parent().unwrap()).unwrap(); + let mut app = create_test_app(); + app.config_path = Some(path.clone()); + app.config_profile = Some("pro".to_string()); + for field in ["default_text_model", "model"] { + fs::write(&path, format!("route_preferences_version = 1\nprovider = 'deepseek'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n[profiles.pro]\n{field} = 'deepseek-v4-pro'\n")).unwrap(); + let configured = + Config::from_saved_document(&fs::read_to_string(&path).unwrap(), Some("pro")) + .unwrap(); + assert_eq!(configured.default_model(), "deepseek-v4-pro"); + assert_eq!( + saved_deepseek_default_model(&app).unwrap(), + configured.default_model() + ); + } + } + + #[test] + fn config_reasoning_effort_uses_codex_provider_labels() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-codex-effort-config-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let mut app = create_test_app(); + app.api_provider = ProviderKind::OpenaiCodex; + app.reasoning_effort = ReasoningEffort::High; + + let result = set_config_value(&mut app, "reasoning_effort", "off", false); + + assert_eq!(app.reasoning_effort, ReasoningEffort::Low); + assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off)); + assert_eq!( + result.message.as_deref(), + Some("reasoning_effort = low (session only, add --save to persist)") + ); + + let result = set_config_value(&mut app, "reasoning_effort", "xhigh", false); + + // `xhigh` stopped collapsing into `Max` when the ladder gave it a rung. + assert_eq!(app.reasoning_effort, ReasoningEffort::XHigh); + assert_eq!( + result.message.as_deref(), + Some("reasoning_effort = xhigh (session only, add --save to persist)") + ); + } + + #[test] + fn config_fancy_animations_keeps_ghostty_full_motion() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-ghostty-fancy-config-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + // Neutralize the SSH markers: production intentionally caps motion + // over SSH, and the suite routinely runs inside one. + let _ssh_client = EnvVarGuard::remove("SSH_CLIENT"); + let _ssh_connection = EnvVarGuard::remove("SSH_CONNECTION"); + let _ssh_tty = EnvVarGuard::remove("SSH_TTY"); + let prev_term_program = env::var_os("TERM_PROGRAM"); + // Safety: test-only environment mutation guarded by EnvGuard's lock. + unsafe { + env::set_var("TERM_PROGRAM", "Ghostty"); + } + + let mut app = create_test_app(); + assert!(app.fancy_animations); + assert!(!app.constrained_frame_rate); + + let result = set_config_value(&mut app, "fancy_animations", "true", false); + + assert!(!result.is_error); + assert!( + app.fancy_animations, + "Ghostty must keep authored motion enabled" + ); + assert_eq!( + result.message.as_deref(), + Some("fancy_animations = true (session only, add --save to persist)") + ); + + // Safety: cleanup under EnvGuard's lock. + unsafe { + match prev_term_program { + Some(v) => env::set_var("TERM_PROGRAM", v), + None => env::remove_var("TERM_PROGRAM"), + } + } + } + + #[test] + fn config_model_accepts_future_deepseek_model_id() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("model deepseek-v4")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("model = deepseek-v4")); + assert_eq!(app.model, "deepseek-v4"); + } + + #[test] + fn config_model_with_save_flag() { + let temp_root = tempfile::tempdir().expect("isolated settings dir"); + let _guard = EnvGuard::new(temp_root.path()); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("model deepseek-v4-flash --save")); + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.model, "deepseek-v4-flash"); + let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) + .expect("config path"); + let persisted: toml::Value = + toml::from_str(&fs::read_to_string(config_path).expect("saved config")).expect("toml"); + assert_eq!(persisted["provider"].as_str(), Some("deepseek")); + assert_eq!( + persisted["providers"]["deepseek"]["model"].as_str(), + Some("deepseek-v4-flash") + ); + assert!( + Settings::load_persisted() + .expect("settings") + .provider_models + .is_none() + ); + } + + #[test] + fn failed_model_save_keeps_the_live_selection() { + let temp_root = tempfile::tempdir().expect("isolated config"); + let _guard = EnvGuard::new(temp_root.path()); + let mut app = create_test_app(); + let previous = app.model.clone(); + let blocked_path = temp_root.path().join("config-directory"); + fs::create_dir(&blocked_path).expect("blocked config path"); + app.config_path = Some(blocked_path); + + let result = config_command(&mut app, Some("model deepseek-v4-flash --save")); + + assert!(result.is_error); + assert!(result.action.is_none()); + assert_eq!(app.model, previous); + } + + #[test] + fn hosted_ollama_model_saves_keep_the_legacy_provider_slot() { + use crate::tui::app::PendingRouteSave; + use crate::tui::views::route_save_prompt::RouteSaveChoice; + + let temp_root = tempfile::tempdir().expect("isolated config"); + let _guard = EnvGuard::new(temp_root.path()); + let config_path = temp_root.path().join(".codewhale/config.toml"); + fs::create_dir_all(config_path.parent().expect("config parent")).expect("config home"); + fs::write( + &config_path, + "provider = \"ollama\"\n[providers.ollama]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"original:cloud\"\napi_key_env = \"TEST_OLLAMA_KEY\"\n", + ) + .expect("legacy hosted config"); + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + app.set_provider_identity_record( + Config::load(Some(config_path.clone()), None) + .expect("captured hosted config") + .active_provider_identity() + .expect("captured hosted identity"), + ); + app.active_route_base_url = "https://ollama.com/v1".to_string(); + app.model_ids_passthrough = true; + + for (index, model) in ["live:cloud", "pending:cloud", "command:cloud"] + .into_iter() + .enumerate() + { + app.model = model.to_string(); + match index { + 0 => { + let receipt = app + .try_save_live_route_as_startup_default() + .expect("remember live hosted route"); + assert!(receipt.contains("ollama-cloud/live:cloud"), "{receipt}"); + } + 1 => { + app.pending_route_save = Some(PendingRouteSave { + provider_identity: "ollama-cloud".to_string(), + model: model.to_string(), + fleet: None, + }); + let receipt = app.apply_route_save_choice(RouteSaveChoice::SaveAsDefault); + assert!(receipt.starts_with("Remembered "), "{receipt}"); + } + _ => { + let result = set_config_value(&mut app, "model", model, true); + assert!(!result.is_error, "{:?}", result.message); + } + } + let saved: toml::Value = + toml::from_str(&fs::read_to_string(&config_path).expect("saved config")) + .expect("valid config"); + assert_eq!(saved["provider"].as_str(), Some("ollama")); + assert_eq!(saved["providers"]["ollama"]["model"].as_str(), Some(model)); + assert_eq!( + saved["providers"]["ollama"]["base_url"].as_str(), + Some("https://ollama.com/v1") + ); + assert_eq!( + saved["providers"]["ollama"]["api_key_env"].as_str(), + Some("TEST_OLLAMA_KEY") + ); + assert!(saved["providers"].get("ollama_cloud").is_none()); + assert!(saved["providers"].get("ollama-cloud").is_none()); + assert_eq!(app.provider_identity_for_persistence(), "ollama-cloud"); + assert_eq!(app.provider_id_for_persistence(), Some("ollama")); + } + } + + #[test] + fn config_default_mode_normal_save_reports_normalized_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-default-mode-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("default_mode normal --save")); + let msg = result.message.unwrap(); + assert_eq!(msg, "default_mode = agent (saved)"); + assert_eq!(app.mode, AppMode::Agent); + + let settings_path = Settings::path().unwrap(); + let saved = fs::read_to_string(settings_path).unwrap(); + assert!(saved.contains("default_mode = \"agent\"")); + } + + #[test] + fn config_command_cost_currency_save_persists_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-cost-currency-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("cost_currency cny --save")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "cost_currency = cny (saved)"); + assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny); + + let settings_path = Settings::path().unwrap(); + let saved = fs::read_to_string(settings_path).unwrap(); + assert!(saved.contains("cost_currency = \"cny\"")); + } + + #[test] + fn config_command_base_url_save_persists_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = config_command( + &mut app, + Some("base_url https://example.internal.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved_path = crate::config_persistence::config_toml_path(None).unwrap(); + let saved = fs::read_to_string(&saved_path).unwrap(); + + // The active DeepSeek route's own table, not a top-level key (#6394). + assert_eq!( + msg, + format!( + "base_url = https://example.internal.local/v1 for deepseek (saved to {}; restart required)", + saved_path.display() + ) + ); + let table: toml::Table = toml::from_str(&saved).unwrap(); + assert_eq!( + table["providers"]["deepseek"]["base_url"].as_str(), + Some("https://example.internal.local/v1") + ); + assert!(table.get("base_url").is_none(), "{saved}"); + } + + #[test] + fn config_command_provider_emits_switch_action() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("provider openrouter")); + + assert!(!result.is_error); + assert_eq!(result.message.as_deref(), Some("provider = openrouter")); + match result.action { + Some(AppAction::SwitchProvider { provider, model }) => { + assert_eq!(provider.as_str(), ProviderKind::Openrouter.as_str()); + assert_eq!(model, None); + } + other => panic!("expected SwitchProvider action, got {other:?}"), + } + } + + #[test] + fn config_command_provider_rejects_unknown_provider() { + let mut app = create_test_app(); + // "anthropic" became a real provider in #3014; probe with an id that + // stays unknown. + let result = config_command(&mut app, Some("provider not-a-provider")); + assert!(result.is_error); + let msg = result.message.unwrap(); + assert!(msg.contains("Unknown provider 'not-a-provider'")); + assert!(msg.contains("openrouter")); + assert!(msg.contains("xiaomi-mimo")); + } + + #[test] + fn config_command_allow_shell_enables_agent_shell_session_only() { + let mut app = create_test_app(); + assert!(!app.allow_shell); + + let result = config_command(&mut app, Some("allow_shell true")); + assert!(!result.is_error); + assert!(app.allow_shell); + let msg = result.message.unwrap(); + + assert!(msg.contains("allow_shell = true")); + assert!(msg.contains("session only")); + assert!(msg.contains("Work mode")); + assert!(msg.contains("approval gating")); + assert!(msg.contains("next turn")); + assert!(msg.contains("Full Access (Shift+Tab) also enables shell and auto-approves")); + } + + #[test] + fn config_command_allow_shell_save_persists_root_boolean() { + let temp_root = tempfile::tempdir().expect("isolated config dir"); + let _guard = EnvGuard::new(temp_root.path()); + + let config_path = temp_root.path().join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("allow_shell true --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert!(app.allow_shell); + assert_eq!( + msg, + format!( + "allow_shell = true (saved to {}). Work mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves.", + config_path.display() + ) + ); + assert!(saved.contains("allow_shell = true")); + } + + #[test] + fn config_command_allow_shell_rejects_invalid_boolean() { + let mut app = create_test_app(); + let result = config_command(&mut app, Some("allow_shell maybe")); + assert!(result.is_error); + assert!(!app.allow_shell); + let msg = result.message.unwrap(); + assert!(msg.contains("Failed to parse boolean 'maybe'")); + } + + #[test] + fn config_command_cannot_bypass_project_shell_constraint() { + let temp_root = env::temp_dir().join(format!( + "codewhale-project-shell-control-test-{}", + std::process::id() + )); + fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let root_config = temp_root.join(".deepseek").join("config.toml"); + fs::write(&root_config, "# user root\n").unwrap(); + let workspace = temp_root.join("workspace"); + fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap(); + fs::write( + workspace + .join(codewhale_config::CODEWHALE_APP_DIR) + .join("config.toml"), + "allow_shell = false\n", + ) + .unwrap(); + let mut app = create_test_app(); + app.config_path = Some(root_config.clone()); + app.workspace = workspace; + app.set_agent_shell_access(false); + + let result = config_command(&mut app, Some("allow_shell true --save")); + + assert!(result.is_error, "{:?}", result.message); + assert!(!app.allow_shell); + assert!( + result + .message + .as_deref() + .is_some_and(|message| message.contains("project configuration")) + ); + assert!( + !fs::read_to_string(root_config) + .unwrap() + .contains("allow_shell") + ); + } + + #[test] + fn config_command_cannot_bypass_environment_shell_constraint() { + let temp_root = env::temp_dir().join(format!( + "codewhale-env-shell-control-test-{}", + std::process::id() + )); + fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::write(&config_path, "# root\n").unwrap(); + // Safety: EnvGuard holds the process-wide environment lock and restores + // this variable on drop. + unsafe { env::set_var("DEEPSEEK_ALLOW_SHELL", "false") }; + let config = Config::load(Some(config_path.clone()), None).unwrap(); + let mut app = create_test_app_with_config(&config); + app.config_path = Some(config_path); + app.set_agent_shell_access(false); + + let result = config_command(&mut app, Some("allow_shell true")); + + assert!(result.is_error, "{:?}", result.message); + assert!(!app.allow_shell); + assert!( + result + .message + .as_deref() + .is_some_and(|message| message.contains("DEEPSEEK_ALLOW_SHELL")) + ); + } + + #[test] + fn config_command_cannot_bypass_project_or_environment_approval() { + let temp_root = env::temp_dir().join(format!( + "codewhale-external-approval-control-test-{}", + std::process::id() + )); + fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let root_config = temp_root.join(".deepseek").join("config.toml"); + fs::write(&root_config, "# root\n").unwrap(); + let workspace = temp_root.join("workspace"); + fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap(); + fs::write( + workspace + .join(codewhale_config::CODEWHALE_APP_DIR) + .join("config.toml"), + "approval_policy = \"never\"\n", + ) + .unwrap(); + let mut app = create_test_app(); + app.config_path = Some(root_config.clone()); + app.workspace = workspace; + app.set_agent_approval_posture(ApprovalMode::Never); + + let project_result = config_command(&mut app, Some("approval_mode full-access")); + assert!(project_result.is_error, "{:?}", project_result.message); + assert_eq!(app.approval_mode, ApprovalMode::Never); + + // Move outside the project and make the environment the controlling + // source for the second half of the regression. + app.workspace = temp_root.join("clean-workspace"); + fs::create_dir_all(&app.workspace).unwrap(); + // Safety: EnvGuard holds the process-wide environment lock and restores + // this variable on drop. + unsafe { env::set_var("DEEPSEEK_APPROVAL_POLICY", "never") }; + let env_result = config_command(&mut app, Some("approval_mode auto")); + assert!(env_result.is_error, "{:?}", env_result.message); + assert_eq!(app.approval_mode, ApprovalMode::Never); + assert!( + env_result + .message + .as_deref() + .is_some_and(|message| message.contains("DEEPSEEK_APPROVAL_POLICY")) + ); + } + + #[test] + fn config_command_shell_choice_survives_plan_round_trip() { + let mut app = create_test_app(); + app.set_agent_approval_posture(ApprovalMode::Bypass); + + let result = config_command(&mut app, Some("allow_shell true")); + + assert!(!result.is_error, "{:?}", result.message); + app.set_mode(AppMode::Plan); + assert!(!app.allow_shell); + app.set_mode(AppMode::Agent); + assert!(app.allow_shell); + assert_eq!(app.approval_mode, ApprovalMode::Bypass); + } + + #[test] + fn config_command_subagents_off_save_persists_and_updates_runtime() { + let temp_root = env::temp_dir().join(format!( + "codewhale-subagents-off-save-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("subagents off --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert!(!result.is_error); + assert!(msg.contains("subagents.enabled = false")); + assert!(msg.contains("saved to")); + assert!(saved.contains("[subagents]")); + assert!(saved.contains("enabled = false")); + match result.action { + Some(AppAction::UpdateSubagentRuntimeConfig { enabled, .. }) => { + assert!(!enabled); + } + other => panic!("expected subagent runtime update, got {other:?}"), + } + } + + #[test] + fn config_command_subagents_depth_save_clamps_to_ceiling() { + let temp_root = env::temp_dir().join(format!( + "codewhale-subagents-depth-save-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("subagents max_depth 99 --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; + + assert!(!result.is_error); + assert!(msg.contains(&format!("subagents.max_depth = {ceiling}"))); + assert!(msg.contains(&format!("clamped from 99 to {ceiling}"))); + assert!(saved.contains(&format!("max_depth = {ceiling}"))); + match result.action { + Some(AppAction::UpdateSubagentRuntimeConfig { + max_spawn_depth, .. + }) => { + assert_eq!(max_spawn_depth, ceiling); + } + other => panic!("expected subagent runtime update, got {other:?}"), + } + } + + #[test] + fn config_command_subagents_status_shows_raw_and_resolved_values() { + let temp_root = env::temp_dir().join(format!( + "codewhale-subagents-status-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + r#" +[subagents] +enabled = true +max_concurrent = 2 +max_depth = 0 +launch_concurrency = 5 +api_timeout_secs = 0 +heartbeat_timeout_secs = 1 +"#, + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path); + let result = config_command(&mut app, Some("subagents status")); + let msg = result.message.unwrap(); + + assert!(!result.is_error); + assert!(msg.contains("Sub-agents: disabled (subagents.max_depth=0)")); + assert!(msg.contains("Active provider: deepseek")); + assert!( + msg.contains("subagents.max_concurrent = 2 (resolved global 2; active provider 2)") + ); + assert!( + msg.contains("subagents.launch_concurrency = 5 (resolved global 2; active provider 2)") + ); + assert!( + msg.contains( + "subagents.api_timeout_secs = 0 (resolved global 600; active provider 600)" + ) + ); + assert!(msg.contains( + "subagents.heartbeat_timeout_secs = 1 (resolved global 630; active provider 630)" + )); + assert!(msg.contains("subagents.providers.deepseek = inherits global")); + } + + #[test] + fn config_command_audit_lists_editability_and_current_values() { + let temp_root = env::temp_dir().join(format!( + "codewhale-config-audit-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + // Hermetic: the audit reads Settings::load(); without this guard the + // developer's real saved permission_posture leaks in and the + // "(unset)" assertion below becomes machine-dependent. + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + r#" +base_url = "https://api.from-config.local/v1" +instructions = ["~/global.md"] +prompt_suggestion = true + +[subagents] +enabled = false +max_concurrent = 4 + +[search] +provider = "bing" + +[notifications] +method = "osc9" +threshold_secs = 45 +quiet = true +completion_sound = "off" +"#, + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + app.approval_mode = ApprovalMode::Never; + app.stream_chunk_timeout_secs = 45; + + let result = config_command(&mut app, Some("audit")); + let msg = result.message.unwrap(); + + assert!(!result.is_error); + assert!(msg.contains("Config editability audit")); + assert!(msg.contains(&format!("Config path: {}", config_path.display()))); + assert!(msg.contains("effective_permissions | Never | runtime")); + assert!(msg.contains("permission_posture | (unset) | TUI settings")); + assert!(msg.contains("approval_policy | (unset) | persisted config")); + assert!(msg.contains("stream_chunk_timeout_secs | 45 | runtime+persisted")); + assert!(msg.contains("subagents.enabled | false | runtime+persisted")); + assert!(msg.contains("subagents.max_concurrent | 4 | runtime+persisted")); + assert!(msg.contains("base_url | https://api.from-config.local/v1 | persisted restart")); + assert!(msg.contains("providers..context_window | (unset) | persisted restart")); + assert!(msg.contains("effective_context_window |"), "{msg}"); + assert!(msg.contains("| runtime | /config context_window"), "{msg}"); + assert!(msg.contains("instructions | configured | file-only restart")); + assert!(msg.contains("network | unset | file-only")); + assert!( + msg.contains("search.provider | bing (source: config.toml) | runtime+persisted"), + "{msg}" + ); + assert!( + msg.contains("prompt_suggestion | true | runtime+persisted"), + "{msg}" + ); + assert!( + msg.contains( + "notifications | method=osc9 threshold=45s sound=legacy quiet=true | runtime+persisted" + ), + "{msg}" + ); + + app.mode = AppMode::Plan; + let plan_msg = config_command(&mut app, Some("audit")) + .message + .expect("Plan audit message"); + assert!( + plan_msg.contains("effective_permissions | Read Only | runtime"), + "{plan_msg}" + ); + } + + #[test] + fn config_command_shows_search_prompt_suggestion_and_notifications() { + let temp_root = env::temp_dir().join(format!( + "codewhale-config-discovery-show-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + r#" +prompt_suggestion = true + +[search] +provider = "tavily" + +[notifications] +method = "bel" +threshold_secs = 12 +quiet = false +completion_sound = "bell" +"#, + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + // This fixture starts App with defaults; seed its current Config view + // as the real constructor does before asking a live-session query. + app.notification_settings = Config::load(Some(config_path), None) + .unwrap() + .notifications_config(); + + let search = config_command(&mut app, Some("search.provider")); + assert!(!search.is_error, "{:?}", search.message); + assert_eq!( + search.message.as_deref(), + Some("search.provider = tavily (source: config.toml)") + ); + + let suggestion = config_command(&mut app, Some("prompt_suggestion")); + assert!(!suggestion.is_error, "{:?}", suggestion.message); + assert_eq!( + suggestion.message.as_deref(), + Some("prompt_suggestion = true") + ); + + let notifications = config_command(&mut app, Some("notifications")); + let notifications_msg = notifications.message.expect("notifications status"); + assert!(!notifications.is_error, "{notifications_msg}"); + assert!( + notifications_msg.contains("method = bel"), + "{notifications_msg}" + ); + assert!( + notifications_msg.contains("threshold_secs = 12"), + "{notifications_msg}" + ); + assert!( + notifications_msg.contains("completion_sound = bell"), + "{notifications_msg}" + ); + } + + #[test] + fn config_command_shows_autodetected_tavily_key_source() { + let temp_root = tempfile::tempdir().expect("isolated config dir"); + let _guard = EnvGuard::new(temp_root.path()); + let config_path = temp_root.path().join("custom-config.toml"); + fs::write( + &config_path, + r#" +[search] +api_key = "tvly-autodetected" +"#, + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path); + + let search = config_command(&mut app, Some("search.provider")); + assert!(!search.is_error, "{:?}", search.message); + let message = search.message.expect("search provider display"); + assert_eq!(message, "search.provider = tavily (source: tavily key)"); + assert!( + !message.contains("TAVILY_API_KEY"), + "a generic `tvly-` key must not be reported as the env var: {message}" + ); + } + + #[test] + fn config_command_sets_search_prompt_suggestion_and_notifications() { + let temp_root = tempfile::tempdir().expect("isolated config dir"); + let _guard = EnvGuard::new(temp_root.path()); + let config_path = temp_root.path().join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + + let search = config_command(&mut app, Some("search.provider duckduckgo --save")); + assert!(!search.is_error, "{:?}", search.message); + match search.action { + Some(AppAction::UpdateSearchProvider { provider }) => { + assert_eq!(provider, SearchProvider::DuckDuckGo); + } + other => panic!("expected UpdateSearchProvider, got {other:?}"), + } + + let suggestion = config_command(&mut app, Some("prompt_suggestion true --save")); + assert!(!suggestion.is_error, "{:?}", suggestion.message); + match suggestion.action { + Some(AppAction::UpdatePromptSuggestion { enabled }) => assert!(enabled), + other => panic!("expected UpdatePromptSuggestion, got {other:?}"), + } + + let notifications = config_command(&mut app, Some("notifications method osc9 --save")); + assert!(!notifications.is_error, "{:?}", notifications.message); + match notifications.action { + Some(AppAction::UpdateNotification { + update: NotificationConfigUpdate::Method(method), + }) => assert_eq!(method, NotificationMethod::Osc9), + other => panic!("expected UpdateNotification method, got {other:?}"), + } + + let saved = fs::read_to_string(&config_path).unwrap(); + assert!(saved.contains("provider = \"duckduckgo\""), "{saved}"); + assert!(saved.contains("prompt_suggestion = true"), "{saved}"); + assert!(saved.contains("method = \"osc9\""), "{saved}"); + + let loaded = Config::load(Some(config_path), None).expect("reloaded config"); + assert_eq!(loaded.search_provider(), SearchProvider::DuckDuckGo); + assert!(loaded.prompt_suggestion_enabled()); + assert_eq!( + loaded.notifications_config().method, + NotificationMethod::Osc9 + ); + } + + #[test] + fn session_only_notification_commands_emit_composable_field_deltas() { + let temp_root = tempfile::tempdir().expect("isolated config dir"); + let _guard = EnvGuard::new(temp_root.path()); + let config_path = temp_root.path().join("custom-config.toml"); + fs::write( + &config_path, + "[notifications]\nmethod = \"bel\"\nthreshold_secs = 12\nquiet = false\n", + ) + .expect("persisted notification config"); + + let mut app = create_test_app(); + app.config_path = Some(config_path); + let mut live = NotificationsConfig { + threshold_secs: 12, + ..NotificationsConfig::default() + }; + + for command in ["notifications method osc9", "notifications quiet true"] { + let result = config_command(&mut app, Some(command)); + assert!(!result.is_error, "{:?}", result.message); + let Some(AppAction::UpdateNotification { update }) = result.action else { + panic!("expected notification field delta for {command}"); + }; + live.apply_update(update).unwrap(); + } + + assert_eq!(live.method, NotificationMethod::Osc9); + assert!(live.quiet); + assert_eq!(live.threshold_secs, 12); + } + + #[test] + fn config_command_rejects_invalid_search_and_notification_values() { + let mut app = create_test_app(); + let search = config_command(&mut app, Some("search.provider not-a-backend")); + assert!(search.is_error); + let search_msg = search.message.unwrap(); + assert!( + search_msg.contains("Can't use 'not-a-backend' for search.provider"), + "{search_msg}" + ); + assert!(search_msg.contains("firecrawl"), "{search_msg}"); + + let notifications = config_command(&mut app, Some("notifications method semaphore")); + assert!(notifications.is_error); + let notifications_msg = notifications.message.unwrap(); + assert!( + notifications_msg.contains("Can't use 'semaphore' for notifications.method"), + "{notifications_msg}" + ); + assert!(notifications_msg.contains("osc9"), "{notifications_msg}"); + } + + #[test] + fn config_context_window_query_shows_override_and_effective_source() { + let temp_root = env::temp_dir().join(format!( + "codewhale-context-window-query-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + r#" +provider = "moonshot" +[providers.moonshot] +model = "kimi-k3" +context_window = 262144 +"#, + ) + .unwrap(); + let mut app = create_test_app(); + app.config_path = Some(config_path); + app.set_provider_identity_record( + crate::config::Config::default() + .resolve_provider_identity(ProviderKind::Moonshot.as_str()) + .expect("captured fixture provider"), + ); + app.model = "kimi-k3".to_string(); + app.active_route_limits = Some(codewhale_config::route::RouteLimits { + context_tokens: Some(262_144), + ..Default::default() + }); + app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured; + + let result = config_command(&mut app, Some("context_window")); + let message = result.message.expect("context window message"); + + assert!(!result.is_error, "{message}"); + assert!( + message.contains("262144 (effective 262144 from configured)"), + "{message}" + ); + } + + #[test] + fn config_command_base_url_without_save_requires_save() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url https://example.internal.local/v1")); + assert!(result.is_error); + let msg = result.message.unwrap(); + + assert!( + msg.contains("base_url must be saved with --save"), + "got {msg}" + ); + } + + #[test] + fn config_command_base_url_reads_current_value_from_config() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-show-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write( + &config_path, + "base_url = \"https://api.from-config.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-config.local/v1"); + } + + #[test] + fn config_command_base_url_reads_current_value_from_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-app-config-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + "base_url = \"https://api.from-app-path.local/v1\"\n", + ) + .unwrap(); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("base_url")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "base_url = https://api.from-app-path.local/v1"); + } + + #[test] + fn config_command_base_url_save_persists_to_app_config_path() { + let temp_root = env::temp_dir().join(format!( + "deepseek-tui-base-url-save-app-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command( + &mut app, + Some("base_url https://example.session.local/v1 --save"), + ); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "base_url = https://example.session.local/v1 for deepseek (saved to {}; restart required)", + config_path.display() + ) + ); + let table: toml::Table = toml::from_str(&saved).unwrap(); + assert_eq!( + table["providers"]["deepseek"]["base_url"].as_str(), + Some("https://example.session.local/v1") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_session_query_uses_live_value() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90")); + assert!(!result.is_error); + assert_eq!(app.stream_chunk_timeout_secs, 90); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(90)) + )); + + let query = config_command(&mut app, Some("stream_chunk_timeout_secs")); + assert_eq!( + query.message.as_deref(), + Some("stream_chunk_timeout_secs = 90") + ); + } + + #[test] + fn failed_save_leaves_live_config_state_untouched() { + // C01-08: a `--save` that cannot write must not report failure over + // live state that already moved. + let temp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new(temp.path()); + let blocker = temp.path().join("not-a-directory"); + fs::write(&blocker, "a file where the config directory should be").unwrap(); + let mut app = create_test_app(); + app.config_path = Some(blocker.join("config.toml")); + let mcp_before = app.mcp_config_path.clone(); + app.mcp_reload_required = false; + let timeout_before = app.stream_chunk_timeout_secs; + + let result = set_config_value(&mut app, "mcp_config_path", "/elsewhere/mcp.json", true); + assert!(result.is_error, "{:?}", result.message); + assert_eq!(app.mcp_config_path, mcp_before); + assert!(!app.mcp_reload_required); + + let next_timeout = if timeout_before == 120 { "121" } else { "120" }; + let result = set_config_value(&mut app, "stream_chunk_timeout_secs", next_timeout, true); + assert!(result.is_error, "{:?}", result.message); + assert!( + result.action.is_none(), + "no engine update for an unsaved value" + ); + assert_eq!(app.stream_chunk_timeout_secs, timeout_before); + } + + #[test] + fn config_command_stream_chunk_timeout_save_overrides_canonical_value() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-stream-timeout-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join("custom-config.toml"); + fs::write( + &config_path, + "[stream]\nchunk_timeout_secs=45\n[tui]\nstream_chunk_timeout_secs=30\n", + ) + .unwrap(); + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)", + config_path.display() + ) + ); + let reopened: Config = toml::from_str(&saved).unwrap(); + assert_eq!( + reopened.stream_chunk_timeout_secs(), + 120, + "saved value must survive reopening when a canonical value already existed" + ); + assert_eq!( + reopened.tui.unwrap().stream_chunk_timeout_secs, + Some(30), + "legacy compatibility input is preserved" + ); + assert_eq!(app.stream_chunk_timeout_secs, 120); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout(120)) + )); + } + + /// The bottom-chrome row presets (#5950) apply on the next frame and + /// `--save` writes the `[tui]` key; an unknown preset names the three. + #[test] + fn config_command_row_presets_apply_live_and_persist_to_tui_table() { + use crate::config::ChromeRowPreset; + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-row-presets-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + assert_eq!(app.posture_bar, ChromeRowPreset::Full); + assert_eq!(app.metrics_line, ChromeRowPreset::Compact); + + let live = config_command(&mut app, Some("posture_bar compact")); + assert!(!live.is_error, "{live:?}"); + assert_eq!(app.posture_bar, ChromeRowPreset::Compact); + assert_eq!( + live.message.as_deref(), + Some("posture_bar = compact (SESSION)") + ); + assert_eq!( + config_command(&mut app, Some("posture_bar")) + .message + .as_deref(), + Some("posture_bar = compact") + ); + + let saved = config_command(&mut app, Some("metrics_line HIDDEN --save")); + assert!(!saved.is_error, "{saved:?}"); + assert_eq!(app.metrics_line, ChromeRowPreset::Hidden); + let body = fs::read_to_string(&config_path).unwrap(); + assert!(body.contains("[tui]"), "{body}"); + assert!(body.contains("metrics_line = \"hidden\""), "{body}"); + assert!( + !body.contains("posture_bar"), + "session-only value must not be saved: {body}" + ); + + let bad = config_command(&mut app, Some("metrics_line tiny")); + assert!(bad.is_error); + assert!( + bad.message + .as_deref() + .is_some_and(|m| m.contains("metrics_line. Try: full, compact, hidden")), + "{bad:?}" + ); + assert_eq!( + app.metrics_line, + ChromeRowPreset::Hidden, + "a bad value changes nothing" + ); + } + + #[test] + fn row_preset_save_failure_preserves_live_state_and_uses_current_locale() { + let temp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new(temp.path()); + let path = temp.path().join("config.toml"); + // A directory in place of the file fails on every supported OS. + fs::create_dir(&path).unwrap(); + let mut app = create_test_app(); + app.config_path = Some(path); + app.ui_locale = codewhale_localization::Locale::ZhHans; + let before = (app.posture_bar, app.metrics_line); + for key in ["posture_bar", "metrics_line"] { + let result = config_command(&mut app, Some(&format!("{key} hidden --save"))); + assert!(result.is_error, "{result:?}"); + assert!(result.message.unwrap().contains("未能保存")); + assert_eq!((app.posture_bar, app.metrics_line), before); + let invalid = config_command(&mut app, Some(&format!("{key} tiny"))); + assert!(invalid.is_error); + assert_eq!( + invalid.message, + CommandResult::error( + tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) + .replace("{key}", key) + .replace("{value}", "tiny") + .replace("{choices}", "full, compact, hidden") + ) + .message + ); + } + // A session-only change needs no writable file and uses the new locale. + let result = config_command(&mut app, Some("posture_bar compact")); + assert!(!result.is_error); + assert_eq!( + result.message.as_deref(), + Some("posture_bar = compact (会话)") + ); + } + + #[test] + fn row_preset_saved_in_active_profile_reloads_without_resetting_other_rows() { + use crate::config::ChromeRowPreset; + let temp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new(temp.path()); + let path = temp.path().join("selected.toml"); + for owns_tui in [false, true] { + let mut body = "# Keep this comment\n[tui]\nposture_bar = \"full\"\nmetrics_line = \"hidden\"\n[profiles.\"work.team\"]\nmax_subagents = 4\n".to_string(); + if owns_tui { + body.push_str("[profiles.\"work.team\".tui]\nposture_bar = \"hidden\"\nmetrics_line = \"compact\"\n"); + } + fs::write(&path, body).unwrap(); + let config = Config::load(Some(path.clone()), Some("work.team")).unwrap(); + let mut app = create_test_app_with_config(&config); + app.config_path = Some(path.clone()); + app.config_profile = Some("work.team".to_string()); + let metrics_before = app.metrics_line; + let result = config_command(&mut app, Some("posture_bar compact --save")); + assert!(!result.is_error, "{result:?}"); + assert_eq!(app.posture_bar, ChromeRowPreset::Compact); + let reloaded = Config::load(Some(path.clone()), Some("work.team")).unwrap(); + let restarted = create_test_app_with_config(&reloaded); + assert_eq!(restarted.posture_bar, app.posture_bar); + assert_eq!(restarted.metrics_line, metrics_before); + let saved = fs::read_to_string(&path).unwrap(); + assert!(saved.starts_with("# Keep this comment")); + let document: toml::Value = toml::from_str(&saved).unwrap(); + assert_eq!( + document["profiles"]["work.team"].get("tui").is_some(), + owns_tui + ); + if owns_tui { + assert_eq!(document["tui"]["posture_bar"].as_str(), Some("full")); + } + } + } + + #[test] + fn config_command_stream_chunk_timeout_rejects_invalid_input() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc")); + assert!(text.is_error); + assert!( + text.message + .unwrap() + .contains("stream_chunk_timeout_secs must be a whole number") + ); + + let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601")); + assert!(high.is_error); + assert!( + high.message + .unwrap() + .contains("stream_chunk_timeout_secs must be 0 or 1..=3600") + ); + } + + #[test] + fn config_command_stream_chunk_timeout_zero_reports_effective_default() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + + let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0")); + + assert!(!result.is_error); + assert_eq!( + app.stream_chunk_timeout_secs, + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + ); + assert_eq!( + result.message.as_deref(), + Some( + "stream_chunk_timeout_secs = 0 (default 900) (session only; affects subsequent turns in this session)" + ) + ); + assert!(matches!( + result.action, + Some(AppAction::UpdateStreamChunkTimeout( + DEFAULT_STREAM_CHUNK_TIMEOUT_SECS + )) + )); + } + + #[test] + fn config_command_provider_url_token_plan_persists_provider_base_url() { + let temp_root = env::temp_dir().join(format!( + "codewhale-provider-url-save-app-path-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.set_provider_identity_record( + crate::config::Config::default() + .resolve_provider_identity(ProviderKind::XiaomiMimo.as_str()) + .expect("captured fixture provider"), + ); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("provider_url token-plan --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert_eq!( + msg, + format!( + "provider_url = {} for xiaomi-mimo (saved to {}; restart required)", + DEFAULT_XIAOMI_MIMO_BASE_URL, + config_path.display() + ) + ); + assert!(saved.contains("[providers.xiaomi_mimo]")); + assert!(saved.contains(&format!("base_url = \"{DEFAULT_XIAOMI_MIMO_BASE_URL}\""))); + } + + #[test] + fn config_command_provider_url_without_save_requires_save() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + app.api_provider = ProviderKind::XiaomiMimo; + let result = config_command(&mut app, Some("provider_url token-plan")); + assert!(result.is_error); + let msg = result.message.unwrap(); + + assert!( + msg.contains("provider_url must be saved with --save"), + "got {msg}" + ); + } + + #[test] + fn theme_command_accepts_grayscale_arg() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-theme-command-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = theme_command(&mut app, Some("grayscale")); + + assert_eq!(result.message.unwrap(), "theme = grayscale (saved)"); + assert_eq!(app.theme_id, codewhale_palette::ThemeId::Grayscale); + assert_eq!(app.ui_theme.mode, codewhale_palette::PaletteMode::Grayscale); + assert!(app.needs_redraw); + } + + #[test] + fn theme_command_underwater_alias_selects_the_underwater_theme() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-theme-underwater-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + for alias in ["underwater", "Deepsea", "deep-sea", "ombre"] { + let result = theme_command(&mut app, Some(alias)); + assert!(!result.is_error, "{alias}: {:?}", result.message); + assert_eq!( + result.message.as_deref(), + Some("theme = underwater (saved)"), + "{alias}" + ); + assert_eq!( + app.theme_id, + codewhale_palette::ThemeId::Underwater, + "{alias}" + ); + assert_eq!(app.ui_theme.name, "underwater", "{alias}"); + assert!( + crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(), + "{alias}: the underwater theme owns the painted field" + ); + } + } + + #[test] + fn underwater_theme_selection_updates_live_state_and_persists_one_field() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-underwater-selection-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); + let _guard = EnvGuard::new(&temp_root); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "theme = \"light\"\nmax_input_history = 77\n", + ) + .expect("seed settings"); + + let mut app = create_test_app(); + let result = set_config_value(&mut app, "theme", "underwater", true); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.theme_id, codewhale_palette::ThemeId::Underwater); + let persisted = Settings::load_persisted().expect("persisted selection"); + assert_eq!(persisted.theme, "underwater"); + assert_eq!( + persisted.max_input_history, 77, + "the theme save must not overwrite unrelated settings" + ); + } + + #[test] + fn custom_theme_selection_keeps_the_raw_selector_in_live_app_state() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-custom-theme-selection-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + let _guard = EnvGuard::new(&temp_root); + let themes = temp_root.join(".codewhale").join("themes"); + fs::create_dir_all(&themes).expect("themes dir"); + fs::write( + themes.join("midnight.json"), + r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##, + ) + .expect("theme overlay"); + + let mut app = create_test_app(); + let result = set_config_value(&mut app, "theme", "custom:midnight", false); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.theme_name, "custom:midnight"); + assert_eq!(app.theme_id, codewhale_palette::ThemeId::Whale); + assert_eq!( + app.ui_theme.accent_primary, + ratatui::style::Color::Rgb(0x12, 0x34, 0x56) + ); + } + + #[test] + fn invalid_theme_name_changes_nothing() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-theme-preflight-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); + let _guard = EnvGuard::new(&temp_root); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "theme = \"light\"\n", + ) + .expect("seed settings"); + + let mut app = create_test_app(); + let original_theme = app.theme_id; + let result = set_config_value(&mut app, "theme", "kelp", true); + + assert!(result.is_error); + assert_eq!(app.theme_id, original_theme); + let persisted = Settings::load_persisted().expect("unchanged persisted settings"); + assert_eq!(persisted.theme, "light"); + } + + #[test] + fn explicit_default_background_override_survives_theme_preview() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-background-override-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); + let _guard = EnvGuard::new(&temp_root); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "theme = \"solarized-light\"\nbackground_color = \"#fdf6e3\"\n", + ) + .expect("seed settings"); + + let mut app = create_test_app(); + let explicit_base3 = ratatui::style::Color::Rgb(0xfd, 0xf6, 0xe3); + assert_eq!(app.background_color_override, Some(explicit_base3)); + + let result = set_config_value(&mut app, "theme", "dark", false); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.theme_id, codewhale_palette::ThemeId::Whale); + assert_eq!(app.background_color_override, Some(explicit_base3)); + assert_eq!(app.ui_theme.surface_bg, explicit_base3); + assert!( + crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_none(), + "only the underwater theme owns a painted field" + ); + } + + #[test] + fn underwater_theme_keeps_its_field_under_a_background_override() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-underwater-override-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26); + let background = set_config_value(&mut app, "background_color", "#1a1b26", false); + assert!(!background.is_error, "{:?}", background.message); + + let preview = set_config_value(&mut app, "theme", "underwater", false); + assert!(!preview.is_error, "{:?}", preview.message); + assert_eq!(app.theme_id, codewhale_palette::ThemeId::Underwater); + assert_eq!(app.background_color_override, Some(custom)); + assert_eq!(app.ui_theme.surface_bg, custom); + assert!( + crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(), + "the underwater theme's field survives a background override" + ); + } + + #[test] + fn session_only_background_override_survives_theme_preview() { + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-session-background-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); + let _guard = EnvGuard::new(&temp_root); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "theme = \"solarized-light\"\n", + ) + .expect("seed settings"); + + let mut app = create_test_app(); + let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26); + let background = set_config_value(&mut app, "background_color", "#1a1b26", false); + assert!(!background.is_error, "{:?}", background.message); + assert_eq!(app.background_color_override, Some(custom)); + + let preview = set_config_value(&mut app, "theme", "dark", false); + assert!(!preview.is_error, "{:?}", preview.message); + assert_eq!(app.background_color_override, Some(custom)); + assert_eq!(app.ui_theme.surface_bg, custom); + + let solarized_preview = set_config_value(&mut app, "theme", "solarized-light", false); + assert!( + !solarized_preview.is_error, + "{:?}", + solarized_preview.message + ); + assert_eq!(app.background_color_override, Some(custom)); + assert_eq!(app.ui_theme.surface_bg, custom); + assert!(crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_none()); + + let saved_theme = set_config_value(&mut app, "theme", "dark", true); + assert!(!saved_theme.is_error, "{:?}", saved_theme.message); + assert_eq!(app.background_color_override, Some(custom)); + assert_eq!(app.ui_theme.surface_bg, custom); + let persisted = Settings::load_persisted().expect("persisted settings"); + assert_eq!(persisted.theme, "dark"); + assert_eq!( + persisted.background_color, None, + "saving a theme must not persist the session-only background" + ); + } + + #[test] + fn set_theme_save_updates_live_app_and_persists() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-theme-save-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("theme grayscale --save")); + let msg = result.message.unwrap(); + + assert_eq!(msg, "theme = grayscale (saved)"); + assert_eq!(app.ui_theme.mode, codewhale_palette::PaletteMode::Grayscale); + + let settings_path = Settings::path().unwrap(); + let saved = fs::read_to_string(settings_path).unwrap(); + assert!(saved.contains("theme = \"grayscale\"")); + } + + #[test] + fn unrelated_save_does_not_persist_no_animations_runtime_overlay() { + let temp_root = env::temp_dir().join(format!( + "codewhale-no-animations-save-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); + let _guard = EnvGuard::new(&temp_root); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "low_motion = false\nfancy_animations = true\ntheme = \"system\"\n", + ) + .expect("seed settings"); + // Safety: test-only environment mutation is serialized by EnvGuard. + unsafe { + env::set_var("NO_ANIMATIONS", "1"); + } + + let mut app = create_test_app(); + assert!(app.low_motion, "runtime overlay should reduce motion"); + assert!( + !app.fancy_animations, + "runtime overlay should disable ocean animations" + ); + + let result = set_config_value(&mut app, "theme", "grayscale", true); + assert!(!result.is_error, "{:?}", result.message); + let saved = Settings::load_persisted().expect("persisted settings"); + assert_eq!(saved.theme, "grayscale"); + assert!( + !saved.low_motion, + "NO_ANIMATIONS must not become a saved preference" + ); + assert!( + saved.fancy_animations, + "NO_ANIMATIONS must not overwrite the saved animation preference" + ); + assert!(app.low_motion); + assert!(!app.fancy_animations); + } + + #[test] + fn preset_save_does_not_persist_runtime_environment_overlays() { + let temp_root = env::temp_dir().join(format!( + "codewhale-preset-env-overlay-test-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .expect("clock") + .as_nanos() + )); + fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); + let _guard = EnvGuard::new(&temp_root); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "low_motion = false\nfancy_animations = true\nsynchronized_output = \"auto\"\n", + ) + .expect("seed settings"); + // NO_ANIMATIONS exercises the reported path. Ptyxis supplies an + // unrelated effective-only field, making an accidental + // apply_env_overrides()+save observable even though the calm preset + // intentionally selects reduced motion itself. + unsafe { + env::set_var("NO_ANIMATIONS", "1"); + env::set_var("PTYXIS_VERSION", "50.0"); + } + + let mut app = create_test_app(); + let result = config_command(&mut app, Some("preset calm --save")); + assert!(!result.is_error, "{:?}", result.message); + + let saved = Settings::load_persisted().expect("persisted settings"); + assert!(saved.low_motion, "calm preset should save reduced motion"); + assert!( + !saved.fancy_animations, + "calm preset should save static ocean chrome" + ); + assert_eq!( + saved.synchronized_output, "auto", + "Ptyxis runtime override must not leak into a preset save" + ); + } + + #[test] + fn config_approval_mode_valid_values() { + let dir = tempfile::tempdir().expect("isolated config dir"); + let mut app = create_test_app(); + app.config_path = Some(dir.path().join("config.toml")); + // Test auto + let result = config_command(&mut app, Some("approval_mode auto")); + assert!(result.message.is_some()); + assert_eq!(app.approval_mode, ApprovalMode::Auto); + + // Test suggest + let result = config_command(&mut app, Some("approval_mode suggest")); + assert!(result.message.is_some()); + assert_eq!(app.approval_mode, ApprovalMode::Suggest); + + // Test never + let result = config_command(&mut app, Some("approval_mode never")); + assert!(result.message.is_some()); + assert_eq!(app.approval_mode, ApprovalMode::Never); + } + + #[test] + fn config_approval_mode_save_persists_top_level_policy() { + let temp_root = env::temp_dir().join(format!( + "codewhale-approval-policy-save-test-{}", + std::process::id() + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + + let mut app = create_test_app(); + app.config_path = Some(config_path.clone()); + let result = config_command(&mut app, Some("approval_mode suggest --save")); + let msg = result.message.unwrap(); + let saved = fs::read_to_string(&config_path).unwrap(); + + assert!(!result.is_error); + assert_eq!(app.approval_mode, ApprovalMode::Suggest); + assert_eq!( + msg, + format!( + "approval_mode = Ask (saved to {} as approval_policy = \"on-request\")", + config_path.display() + ) + ); + assert!(saved.contains("approval_policy = \"on-request\"")); + + let loaded = Config::load(Some(config_path.clone()), None).unwrap(); + assert_eq!(loaded.approval_policy.as_deref(), Some("on-request")); + + let mut restarted = create_test_app_with_config(&loaded); + restarted.config_path = Some(config_path.clone()); + assert!(restarted.approval_policy_locked()); + assert!(!restarted.approval_policy_requirements_managed()); + let changed = config_command(&mut restarted, Some("approval_mode auto --save")); + assert!(!changed.is_error, "{:?}", changed.message); + assert_eq!( + changed.action, + Some(AppAction::ApprovalPolicyPersisted { + policy: Some("auto".to_string()) + }) + ); + assert_eq!(restarted.approval_mode, ApprovalMode::Auto); + let reloaded = Config::load(Some(config_path), None).unwrap(); + assert_eq!(reloaded.approval_policy.as_deref(), Some("auto")); + } + + #[test] + fn config_approval_policy_can_return_to_saved_tui_permission_default() { + let temp_root = env::temp_dir().join(format!( + "codewhale-approval-policy-tui-default-test-{}", + std::process::id() + )); + fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + fs::write(&config_path, "# keep\napproval_policy = \"auto\"\n").unwrap(); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "permission_posture = \"full-access\"\n", + ) + .unwrap(); + let loaded = Config::load(Some(config_path.clone()), None).unwrap(); + let mut app = create_test_app_with_config(&loaded); + app.config_path = Some(config_path.clone()); + + let result = set_config_value(&mut app, "approval_policy", "use-tui-default", true); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.approval_mode, ApprovalMode::Bypass); + assert!(!app.approval_policy_locked()); + assert_eq!( + result.action, + Some(AppAction::ApprovalPolicyPersisted { policy: None }) + ); + let saved = fs::read_to_string(config_path).unwrap(); + assert!(saved.contains("# keep")); + assert!(!saved.contains("approval_policy")); + } + + #[test] + fn config_approval_policy_full_access_adopts_tui_posture_and_releases_root_override() { + let temp_root = env::temp_dir().join(format!( + "codewhale-approval-policy-full-access-test-{}", + std::process::id() + )); + fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join("custom-config.toml"); + fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n").unwrap(); + fs::write( + temp_root.join(".deepseek").join("settings.toml"), + "permission_posture = \"ask\"\n", + ) + .unwrap(); + let loaded = Config::load(Some(config_path.clone()), None).unwrap(); + let mut app = create_test_app_with_config(&loaded); + app.config_path = Some(config_path.clone()); + // The production constructor receives the path up front and marks a + // user-owned root policy editable. This focused fixture attaches the + // path after construction, so mirror that resolved ownership here. + app.mark_approval_policy_locked(); + assert!(app.approval_policy_locked()); + + let result = set_config_value(&mut app, "approval_policy", "full-access", true); + + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(app.approval_mode, ApprovalMode::Bypass); + assert!(!app.approval_policy_locked()); + assert_eq!( + result.action, + Some(AppAction::ApprovalPolicyPersisted { policy: None }) + ); + let saved_config = fs::read_to_string(config_path).unwrap(); + assert!(saved_config.contains("# keep")); + assert!(!saved_config.contains("approval_policy")); + let saved_settings = Settings::load_persisted().expect("saved TUI settings"); + assert_eq!( + saved_settings.permission_posture.as_deref(), + Some("full-access") + ); + } + + #[test] + fn config_approval_mode_invalid_value() { + let dir = tempfile::tempdir().expect("isolated config dir"); + let mut app = create_test_app(); + app.config_path = Some(dir.path().join("config.toml")); + let result = config_command(&mut app, Some("approval_mode invalid")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("Invalid approval_mode")); + } + + #[test] + fn config_without_save_flag() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + let result = config_command(&mut app, Some("auto_compact true")); + assert!(result.message.is_some()); + let msg = result.message.unwrap(); + assert!(msg.contains("(session only")); + } + + #[test] + fn config_threshold_enables_and_updates_live_auto_compaction() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + app.auto_compact = false; + app.auto_compact_user_configured = false; + + let result = config_command(&mut app, Some("auto_compact_threshold_percent 65")); + + assert!(!result.is_error, "{:?}", result.message); + assert!(app.auto_compact); + assert!(app.auto_compact_user_configured); + assert_eq!(app.auto_compact_threshold_percent, 65.0); + assert_eq!( + app.compact_threshold, + crate::route_budget::compaction_threshold_for_route_at_percent( + app.api_provider, + app.effective_model_for_budget(), + app.active_route_limits, + 65.0, + ) + ); + assert!(matches!( + result.action, + Some(AppAction::UpdateCompaction(_)) + )); + } + + #[test] + fn config_composer_border_updates_live_app() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + app.composer_border = true; + + let result = config_command(&mut app, Some("composer_border false")); + + assert!(result.message.is_some()); + assert!(!app.composer_border); + assert!(app.needs_redraw); + } + + #[test] + fn config_composer_multiline_mode_updates_live_app() { + let _lock = lock_test_env(); + let mut app = create_test_app(); + app.composer_multiline_mode = false; + + let result = config_command(&mut app, Some("composer_multiline_mode true")); + + assert!(!result.is_error, "{:?}", result.message); + assert!(app.composer_multiline_mode); + assert!(app.needs_redraw); + } + + /// `/trust on` and `/trust off` printed nothing (0.10.1 tutorial, lesson + /// 3). Each change now has a line that says what it did and how to undo + /// it, and only `--save` claims anything was saved. + #[test] + fn trust_change_note_says_what_changed() { + let on = trust_change_note(true, false); + assert!(on.starts_with("Trust mode is on for this session"), "{on}"); + assert!(on.contains("/trust off"), "{on}"); + let off = trust_change_note(false, false); + assert!( + off.starts_with("Trust mode is off for this session"), + "{off}" + ); + for note in [&on, &off] { + assert!(!note.contains("Saved"), "{note}"); + } + let saved_on = trust_change_note(true, true); + assert!( + saved_on.starts_with(&on) && saved_on.contains("are trusted in later sessions"), + "{saved_on}" + ); + let saved_off = trust_change_note(false, true); + assert!( + saved_off.starts_with(&off) && saved_off.contains("are no longer trusted"), + "{saved_off}" + ); + } + + #[tokio::test] + async fn trust_only_persists_with_save() { + let tmp = tempfile::tempdir().unwrap(); + let _guard = EnvGuard::new(tmp.path()); + let config_path = tmp.path().join("config.toml"); + let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path); + let workspace = tmp.path().join("workspace"); + let commands = workspace.join(".claude/commands"); + let skills = workspace.join(".claude/skills/review-example"); + fs::create_dir_all(&commands).unwrap(); + fs::create_dir_all(&skills).unwrap(); + fs::write(commands.join("review-example.md"), "Review the example").unwrap(); + fs::write( + skills.join("SKILL.md"), + "---\nname: review-example\ndescription: Review the example\n---\nRead the example.", + ) + .unwrap(); + let mut app = create_test_app(); + app.workspace = workspace.clone(); + app.trust_mode = false; + for trusted in [false, true, false] { + if trusted || app.trust_mode { + let result = trust_command( + &mut app, + Some(if trusted { "on --save" } else { "off --save" }), + ); + assert_eq!( + result.action, + Some(AppAction::SetWorkspaceTrust { + trusted, + save: true + }) + ); + set_workspace_trust(&mut app, trusted, true).await.unwrap(); + } + let saved = fs::read(&config_path).ok(); + for session_trusted in [true, false] { + let result = + trust_command(&mut app, Some(if session_trusted { "on" } else { "off" })); + assert_eq!( + result.action, + Some(AppAction::SetWorkspaceTrust { + trusted: session_trusted, + save: false + }) + ); + set_workspace_trust(&mut app, session_trusted, false) + .await + .unwrap(); + assert_eq!(app.trust_mode, session_trusted); + assert_eq!( + fs::read(&config_path).ok(), + saved, + "session toggle must not write config" + ); + assert_eq!(crate::config::is_workspace_trusted(&workspace), trusted); + } + app.trust_mode = trusted; + assert!(trust_command(&mut app, Some("on --typo")).is_error); + assert_eq!(crate::config::is_workspace_trusted(&workspace), trusted); + crate::commands::user_registry::with_registry_for_workspace( + Some(&workspace), + |registry| { + assert_eq!(registry.get("review-example").is_some(), trusted); + }, + ); + assert_eq!( + crate::skills::discover_in_workspace(&workspace) + .get("review-example") + .is_some(), + trusted + ); + } + // A failed write must not grant trust in memory. + fs::create_dir_all(tmp.path().join("bad-config")).unwrap(); + let _bad_config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("bad-config")); + assert!(set_workspace_trust(&mut app, true, true).await.is_err()); + assert!(!app.trust_mode); + app.trust_mode = true; + assert!(set_workspace_trust(&mut app, false, true).await.is_err()); + assert!(!app.trust_mode); + } + + #[test] + fn test_trust_status_default_lists_state() { + let mut app = create_test_app(); + let result = trust_command(&mut app, None); + let msg = result.message.expect("status message"); + assert!(msg.contains("Workspace trust mode")); + } + + #[test] + fn test_trust_add_requires_path() { + let mut app = create_test_app(); + let result = trust_command(&mut app, Some("add")); + let msg = result.message.expect("error message"); + assert!(msg.starts_with("Error:"), "got {msg:?}"); + } + + #[test] + fn test_logout_clears_api_key_state() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-tui-logout-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write(&config_path, "api_key = \"test-key\"\n").unwrap(); + + let mut app = create_test_app(); + let result = logout_command(&mut app); + assert!(result.message.is_some()); + assert_eq!(app.onboarding, OnboardingState::Provider); + assert!(app.onboarding_needs_api_key); + assert!(app.onboarding_missing_key_recovery); + assert_eq!(result.action, Some(AppAction::OpenProviderPicker)); + + let updated = fs::read_to_string(config_path).unwrap(); + assert!(!updated.contains("api_key")); + } + + #[test] + fn logout_clears_only_exact_named_custom_provider_key() { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_nanos(); + let temp_root = env::temp_dir().join(format!( + "codewhale-custom-logout-test-{}-{}", + std::process::id(), + nanos + )); + fs::create_dir_all(&temp_root).unwrap(); + let _guard = EnvGuard::new(&temp_root); + let config_path = temp_root.join(".deepseek").join("config.toml"); + fs::create_dir_all(config_path.parent().unwrap()).unwrap(); + fs::write( + &config_path, + "[providers.custom-a]\napi_key = \"a-key\"\n\n[providers.custom-b]\napi_key = \"b-key\"\n", + ) + .unwrap(); + let mut app = create_test_app(); + app.set_provider_identity(ProviderKind::Custom, "custom-a"); + + let result = logout_command(&mut app); + + assert!(result.message.is_some()); + let updated = fs::read_to_string(config_path).unwrap(); + assert!(!updated.contains("a-key"), "{updated}"); + assert!(updated.contains("b-key"), "{updated}"); + } + + #[test] + fn named_custom_provider_url_write_fails_closed() { + let mut app = create_test_app(); + app.set_provider_identity(ProviderKind::Custom, "custom-a"); + + let result = config_command( + &mut app, + Some("provider_url http://127.0.0.1:18181/v1 --save"), + ); + let message = result.message.expect("error message"); + + assert!(result.is_error); + assert!(result.action.is_none()); + assert!( + message.contains("[providers.custom-a]") && message.contains("missing"), + "{message}" + ); + } +} diff --git a/crates/tui/src/commands/config_main_import_host.rs b/crates/tui/src/commands/config_main_import_host.rs new file mode 100644 index 0000000000..4352bdce78 --- /dev/null +++ b/crates/tui/src/commands/config_main_import_host.rs @@ -0,0 +1,260 @@ +//! `/import-claude` host operations (FEAT-028). +//! +//! The command owns the flow and the messages; this host module builds the plan +//! from the Claude sources, persists the rendered artifacts and carries over the +//! standing instructions. The plan is the contract model, so the command +//! renders it without any host type. + +use crate::tui::app::App; +use codewhale_command_contract::config::{ + CommandClaudeApplyOutcome, CommandClaudeArtifacts, CommandClaudePlan, CommandMcpCandidate, +}; +use std::path::Path; + +/// Build the import plan from the host's home/workspace; `None` when nothing +/// was found. +pub(in crate::commands) fn build_plan(app: &mut App) -> Option { + let home = crate::config::effective_home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); + let (sources, claude, settings) = crate::import_claude::read_sources(&home); + + // MCP candidates come from the same discovery the `/mcp import` consent + // flow uses, filtered to the Claude sources: provenance stays single-sourced. + let markets = Vec::new(); + let mcp_candidates = + crate::mcp::external_import::discover_external_sources(&home, &app.workspace, &markets) + .into_iter() + .filter(|candidate| { + matches!( + candidate.source_kind, + crate::mcp::external_import::ExternalMcpSourceKind::ClaudeJson + ) + }) + .map(|candidate| CommandMcpCandidate { + summary: candidate.summary, + hard_blocked: candidate.hard_blocked, + name: candidate.name, + }) + .collect::>(); + + let plan = crate::import_claude::build_plan(sources, claude, settings, &home, mcp_candidates); + if plan.is_empty() { None } else { Some(plan) } +} + +/// Persist the rendered report and optional bundle. +pub(in crate::commands) fn write_artifacts( + report: &str, + bundle: Option<&str>, +) -> CommandClaudeArtifacts { + let imports_dir = codewhale_config::codewhale_home() + .map(|home| home.join("imports")) + .unwrap_or_else(|_| std::path::PathBuf::from(".codewhale/imports")); + let report_path = imports_dir.join("claude-import-report.md"); + let bundle_path = imports_dir.join("claude-portable-bundle.json"); + let wrote_report = + codewhale_config::persistence::atomic_write(&report_path, report.as_bytes()).is_ok(); + let wrote_bundle = match bundle { + Some(json) => { + codewhale_config::persistence::atomic_write(&bundle_path, json.as_bytes()).is_ok() + } + None => false, + }; + CommandClaudeArtifacts { + report_path, + bundle_path, + wrote_report, + wrote_bundle, + } +} + +/// Carry over the standing instructions and report what remains manual. +/// +/// `--apply` is the consent: this path never overwrites an existing file, never +/// imports MCP servers, and never touches hooks or permission rules — those run +/// code, so they stay a human decision. +pub(in crate::commands) fn apply_plan( + _app: &mut App, + plan: &CommandClaudePlan, + report_path: &Path, + wrote_report: bool, +) -> CommandClaudeApplyOutcome { + use codewhale_command_contract::config::CommandImportFollowUp; + let mut done: Vec = Vec::new(); + let mut manual: Vec = Vec::new(); + + if plan.has_claude_md { + match codewhale_config::codewhale_home() { + Ok(home) => { + let destination = home.join("instructions.md"); + let source = crate::config::effective_home_dir() + .unwrap_or_else(|| std::path::PathBuf::from(".")) + .join(".claude") + .join("CLAUDE.md"); + if destination.exists() { + manual.push(CommandImportFollowUp::InstructionsKeptExisting { + destination: crate::utils::display_path(&destination).to_string(), + source: crate::utils::display_path(&source).to_string(), + }); + } else { + match std::fs::read(&source) { + Ok(bytes) + if codewhale_config::persistence::atomic_write( + &destination, + &bytes, + ) + .is_ok() => + { + done.push(CommandImportFollowUp::InstructionsCopied { + destination: crate::utils::display_path(&destination).to_string(), + }); + } + _ => manual.push(CommandImportFollowUp::InstructionsCopyFailed { + source: crate::utils::display_path(&source).to_string(), + destination: crate::utils::display_path(&destination).to_string(), + }), + } + } + } + Err(_) => manual.push(CommandImportFollowUp::HomeUnresolved), + } + } + + // MCP approval must bind the exact bytes and current managed-config + // revision shown by the shared preview. Blanket migration cannot grant it. + for candidate in &plan.mcp_candidates { + manual.push(if candidate.hard_blocked { + CommandImportFollowUp::McpBlocked { + name: candidate.name.clone(), + } + } else { + CommandImportFollowUp::McpNeedsReview { + name: candidate.name.clone(), + } + }); + } + + if !plan.hook_events.is_empty() { + manual.push(CommandImportFollowUp::HookEvents { + count: plan.hook_events.len(), + }); + } + if !plan.permissions_allow.is_empty() + || !plan.permissions_ask.is_empty() + || !plan.permissions_deny.is_empty() + { + manual.push(CommandImportFollowUp::PermissionRules); + } + + CommandClaudeApplyOutcome { + done, + manual, + report_path: report_path.to_path_buf(), + wrote_report, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::commands::CommandResult; + use crate::config::Config; + use crate::tui::app::TuiOptions; + use std::fs; + use tempfile::TempDir; + + /// Run the portable `/import-claude` handler through the real CONFIG + /// envelope (the flow and messages now live in the command). + fn import_claude_command(app: &mut App, arg: Option<&str>) -> CommandResult { + let result = crate::commands::groups::config::config::import_claude( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::CONFIG_ONLY_CAPS), + arg, + ); + CommandResult { + message: result.message, + action: result + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: result.is_error, + } + } + + /// A home with a Claude `CLAUDE.md`, plus a Codewhale home to import into. + fn sandbox() -> (TempDir, App) { + let temp = TempDir::new().expect("tempdir"); + let home = temp.path().join("home"); + let claude = home.join(".claude"); + fs::create_dir_all(&claude).expect("claude dir"); + fs::write(claude.join("CLAUDE.md"), b"be excellent\n").expect("CLAUDE.md"); + fs::write(claude.join("settings.json"), b"{}").expect("settings"); + fs::write(home.join(".claude.json"), b"{}").expect("claude.json"); + + let workspace = temp.path().join("ws"); + fs::create_dir_all(&workspace).expect("workspace"); + let options = TuiOptions { + config_path: Some(temp.path().join("config.toml")), + skills_dir: temp.path().join("skills"), + memory_path: temp.path().join("memory.md"), + notes_path: temp.path().join("notes.txt"), + mcp_config_path: temp.path().join("mcp.json"), + ..crate::tui::test_support::test_tui_options(&workspace) + }; + let app = App::new(options, &Config::default()); + (temp, app) + } + + #[test] + fn the_bare_command_applies_nothing_and_says_how_to_apply() { + let _lock = crate::test_support::lock_test_env(); + let (temp, mut app) = sandbox(); + let home = temp.path().join("home"); + let _home = crate::test_support::EnvVarGuard::set("HOME", &home); + let _profile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home); + let cw_home = temp.path().join("cw"); + let _cw = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &cw_home); + + let result = import_claude_command(&mut app, None); + let message = result.message.expect("a plan"); + assert!( + message.contains("/import-claude --apply"), + "the plan must say how to apply it: {message}" + ); + assert!( + !cw_home.join("instructions.md").exists(), + "the bare command must not write instructions.md" + ); + } + + #[test] + fn apply_carries_the_standing_instructions_and_never_clobbers_them() { + let _lock = crate::test_support::lock_test_env(); + let (temp, mut app) = sandbox(); + let home = temp.path().join("home"); + let _home = crate::test_support::EnvVarGuard::set("HOME", &home); + let _profile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home); + let cw_home = temp.path().join("cw"); + let _cw = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &cw_home); + + let result = import_claude_command(&mut app, Some("--apply")); + let message = result.message.expect("an outcome"); + let destination = cw_home.join("instructions.md"); + assert!(destination.exists(), "instructions were copied: {message}"); + assert_eq!( + fs::read_to_string(&destination).expect("read"), + "be excellent\n" + ); + + // Running it again must not overwrite the operator's own file. + fs::write(&destination, b"mine now\n").expect("overwrite"); + let again = import_claude_command(&mut app, Some("--apply")); + let message = again.message.expect("an outcome"); + assert_eq!( + fs::read_to_string(&destination).expect("read"), + "mine now\n", + "an existing instructions.md is never clobbered: {message}" + ); + assert!( + message.contains("already exists"), + "and the reason is said out loud: {message}" + ); + } +} diff --git a/crates/tui/src/commands/config_policy_host.rs b/crates/tui/src/commands/config_policy_host.rs index 417d944b93..01d7e66928 100644 --- a/crates/tui/src/commands/config_policy_host.rs +++ b/crates/tui/src/commands/config_policy_host.rs @@ -1,11 +1,10 @@ //! Host registration/action conversion for the portable policy inventory. use super::CommandResult; use super::groups::config::policy::portable_handlers; -use super::traits::{Command, ContextualCommand}; -use crate::tui::app::{App, AppAction}; +use crate::tui::app::App; use codewhale_command_contract::handler::CommandHandler; use codewhale_command_contract::metadata::{CommandInfo, RegisterCommand}; -use codewhale_command_contract::outcome::{ConfigPolicyAction, ConfigPolicyCommandResult}; +use codewhale_command_contract::outcome::ConfigPolicyCommandResult; struct Registration; impl RegisterCommand for Registration { @@ -29,17 +28,10 @@ impl RegisterCommand for Registration } } } -pub(super) fn permissions_registration() -> Box { - Box::new( - ContextualCommand::from_contract::>().expect("permissions registration"), - ) -} -pub(super) fn status_registration() -> Box { - Box::new(ContextualCommand::from_contract::>().expect("status registration")) -} pub(super) fn permissions(app: &mut App, args: Option<&str>) -> CommandResult { execute::<0>(app, args) } +#[cfg(test)] pub(super) fn status(app: &mut App, args: Option<&str>) -> CommandResult { execute::<1>(app, args) } @@ -60,8 +52,8 @@ fn host_result(result: ConfigPolicyCommandResult) -> CommandResult { CommandResult { message: result.message, is_error: result.is_error, - action: result.action.map(|action| match action { - ConfigPolicyAction::PermissionRulesChanged => AppAction::PermissionRulesChanged, - }), + action: result + .action + .map(super::config_host::config_action_to_app_action), } } diff --git a/crates/tui/src/commands/config_policy_host_tests.rs b/crates/tui/src/commands/config_policy_host_tests.rs index 04b7f29483..e58b57228d 100644 --- a/crates/tui/src/commands/config_policy_host_tests.rs +++ b/crates/tui/src/commands/config_policy_host_tests.rs @@ -4,7 +4,7 @@ use codewhale_command_contract::handler::{CommandCapabilities as Caps, CommandHa #[test] fn config_policy_keeps_host_registry_order_metadata_and_exact_capabilities() { - let group = crate::commands::groups::config::ConfigCommands; + let group = crate::commands::config_group::ConfigCommands; assert_eq!( group .commands() diff --git a/crates/tui/src/commands/config_policy_permissions_tests.rs b/crates/tui/src/commands/config_policy_permissions_tests.rs index 91621bee49..72b9b7cb8f 100644 --- a/crates/tui/src/commands/config_policy_permissions_tests.rs +++ b/crates/tui/src/commands/config_policy_permissions_tests.rs @@ -157,10 +157,20 @@ workspace = {other:?} .expect("write permissions"); let mut app = test_app(config_path, dir.path().to_path_buf()); - let result = crate::commands::groups::config::config::config_command( - &mut app, - Some("ask-rules list"), - ); + let result = { + let contract = crate::commands::groups::config::config::config( + app.command_contexts_with_config(None) + .contexts(crate::commands::groups::config::config::CONFIG_CAPS), + Some("ask-rules list"), + ); + crate::commands::CommandResult { + message: contract.message, + action: contract + .action + .map(crate::commands::config_host::config_action_to_app_action), + is_error: contract.is_error, + } + }; let message = result.message.expect("compatibility list message"); assert!(!result.is_error); diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index 59da651c42..39f69459be 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -31,13 +31,16 @@ use std::cell::RefCell; use std::path::{Path, PathBuf}; use std::rc::Rc; +mod config_main; pub(in crate::commands) mod config_policy; #[cfg(test)] mod config_policy_baseline; +use config_main::{ConfigAdapter, LoginAdapter, PetAdapter, TrustAdapter, WorkSurfaceAdapter}; use config_policy::{ConfigStatusAdapter, PermissionsAdapter}; mod debug_diagnostics; pub(in crate::commands) mod debug_operations; use debug_operations::DebugOperationsAdapter; +pub(in crate::commands) mod config_messages; mod config_policy_messages; mod diagnostics_messages; #[cfg(test)] @@ -106,7 +109,7 @@ use codewhale_localization::{MessageId, tr}; /// (`scripts/check-command-migration-manifest.py`) reads this exact /// declaration by source regex and the Rust frontier tests assert it. #[cfg_attr(not(test), expect(dead_code))] -pub(crate) const PENDING_GROUPS: &[&str] = &["config", "core"]; +pub(crate) const PENDING_GROUPS: &[&str] = &["core"]; // --------------------------------------------------------------------------- // Boundary-value mappings (D8) @@ -184,6 +187,8 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { "cmd_clear_description" => MessageId::CmdClearDescription, "cmd_compact_description" => MessageId::CmdCompactDescription, "cmd_config_description" => MessageId::CmdConfigDescription, + "cmd_connect_description" => MessageId::CmdConnectDescription, + "cmd_disconnect_description" => MessageId::CmdDisconnectDescription, "cmd_constitution_description" => MessageId::CmdConstitutionDescription, "cmd_context_description" => MessageId::CmdContextDescription, "cmd_cost_description" => MessageId::CmdCostDescription, @@ -195,17 +200,21 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { "cmd_feedback_description" => MessageId::CmdFeedbackDescription, "cmd_fleet_description" => MessageId::CmdFleetDescription, "cmd_fork_description" => MessageId::CmdForkDescription, + "cmd_fullscreen_description" => MessageId::CmdFullscreenDescription, "cmd_goal_description" => MessageId::CmdGoalDescription, "cmd_help_description" => MessageId::CmdHelpDescription, "cmd_hf_description" => MessageId::CmdHfDescription, "cmd_home_description" => MessageId::CmdHomeDescription, "cmd_hooks_description" => MessageId::CmdHooksDescription, + "cmd_import_claude_description" => MessageId::CmdImportClaudeDescription, + "cmd_inline_description" => MessageId::CmdInlineDescription, "cmd_hotbar_description" => MessageId::CmdHotbarDescription, "cmd_init_description" => MessageId::CmdInitDescription, "cmd_jobs_description" => MessageId::CmdJobsDescription, "cmd_lane_description" => MessageId::CmdLaneDescription, "cmd_links_description" => MessageId::CmdLinksDescription, "cmd_load_description" => MessageId::CmdLoadDescription, + "cmd_login_description" => MessageId::CmdLoginDescription, "cmd_logout_description" => MessageId::CmdLogoutDescription, "cmd_lsp_description" => MessageId::CmdLspDescription, "cmd_mcp_description" => MessageId::CmdMcpDescription, @@ -218,6 +227,7 @@ pub(crate) fn key_to_message_id(key: &'static str) -> Option { "cmd_new_description" => MessageId::CmdNewDescription, "cmd_note_description" => MessageId::CmdNoteDescription, "cmd_permissions_description" => MessageId::CmdPermissionsDescription, + "cmd_pet_description" => MessageId::CmdPetDescription, "cmd_pin_description" => MessageId::CmdPinDescription, "cmd_plugin_description" => MessageId::CmdPluginDescription, "cmd_plugin_detail_description" => MessageId::CmdPluginDetailDescription, @@ -2029,6 +2039,59 @@ impl CommandModePolicyContext for ModePolicyAdapter<'_> { fn policy_locked(&self) -> bool { self.host.app.borrow().approval_policy_locked() } + + fn select_mode( + &mut self, + mode: CommandMode, + ) -> codewhale_command_contract::facets::CommandModeSelection { + use codewhale_command_contract::facets::CommandModeSelection; + match self + .host + .app + .borrow_mut() + .select_mode(from_command_mode(mode)) + { + crate::tui::app::SettingSelection::Changed => CommandModeSelection::Changed, + crate::tui::app::SettingSelection::PersistedSame => CommandModeSelection::PersistedSame, + crate::tui::app::SettingSelection::Refused => CommandModeSelection::Refused, + } + } + + fn select_yolo_compat(&mut self) -> codewhale_command_contract::facets::CommandModeSelection { + use codewhale_command_contract::facets::CommandModeSelection; + match self.host.app.borrow_mut().select_yolo_compat() { + crate::tui::app::SettingSelection::Changed => CommandModeSelection::Changed, + crate::tui::app::SettingSelection::PersistedSame => CommandModeSelection::PersistedSame, + crate::tui::app::SettingSelection::Refused => CommandModeSelection::Refused, + } + } + + fn mode_startup_default_receipt(&self, mode: CommandMode) -> String { + self.host + .app + .borrow() + .mode_startup_default_receipt(from_command_mode(mode)) + } + + fn setting_locked_mode_message(&self) -> String { + self.host + .app + .borrow() + .setting_locked_message(MessageId::SettingSubjectMode) + } + + fn mode_label(&self, mode: CommandMode) -> String { + use crate::tui::app::AppModeUi; + let locale = self.host.app.borrow().ui_locale; + from_command_mode(mode) + .display_name_localized(locale) + .to_string() + } + + fn yolo_compat_label(&self) -> String { + let locale = self.host.app.borrow().ui_locale; + codewhale_localization::tr(locale, MessageId::HotbarActionModeYoloName).to_string() + } } /// Read access to the effective system prompt. @@ -2116,6 +2179,7 @@ impl CommandPresentationContext for PresentationAdapter<'_> { .or_else(|| key_to_session_message_id(key)) .or_else(|| diagnostics_messages::resolve(key)) .or_else(|| config_policy_messages::resolve(key)) + .or_else(|| config_messages::resolve(key)) else { return Err("unknown translation key".to_string()); }; @@ -2260,7 +2324,10 @@ pub(crate) fn key_to_project_message_id(key: &str) -> Option { /// /// Returns `None` when the replacement set does not exactly cover every /// placeholder in the template (missing, extra, or duplicate names). -fn apply_named_replacements(template: &str, replacements: &[(&str, &str)]) -> Option { +pub(in crate::commands) fn apply_named_replacements( + template: &str, + replacements: &[(&str, &str)], +) -> Option { let supplied: std::collections::BTreeMap<&str, &str> = replacements.iter().copied().collect(); if supplied.len() != replacements.len() { return None; // duplicate replacement name @@ -2282,11 +2349,19 @@ fn apply_named_replacements(template: &str, replacements: &[(&str, &str)]) -> Op if placeholders != supplied.keys().copied().collect() { return None; } - let mut out = template.to_string(); - for (name, value) in replacements { - out = out.replace(&format!("{{{name}}}"), value); - } - Some(out) + let placeholders: Vec<_> = replacements + .iter() + .map(|(name, _)| format!("{{{name}}}")) + .collect(); + let replacements: Vec<_> = placeholders + .iter() + .zip(replacements) + .map(|(placeholder, (_, value))| (placeholder.as_str(), *value)) + .collect(); + Some(codewhale_protocol::display::interpolate( + template, + &replacements, + )) } /// Atomic composer/media adapter (FEAT-018 D4). @@ -2563,7 +2638,7 @@ impl CommandProjectContext for ProjectAdapter<'_> { // state, so the formatted result is intentionally not forwarded. let mut app = self.host.app.borrow_mut(); let arg = if enabled { "on" } else { "off" }; - let _ = crate::commands::groups::config::config::lsp_command(&mut app, Some(arg)); + let _ = crate::commands::config_main_host::lsp_command(&mut app, Some(arg)); Ok(()) } @@ -4541,6 +4616,11 @@ pub(crate) struct CommandContextBundle<'a> { debug_diagnostics: DebugDiagnosticsAdapter<'a>, permissions: PermissionsAdapter<'a>, config_status: ConfigStatusAdapter<'a>, + config: ConfigAdapter<'a>, + pet: PetAdapter<'a>, + trust: TrustAdapter<'a>, + login: LoginAdapter<'a>, + work_surface: WorkSurfaceAdapter<'a>, } impl<'a> CommandContextBundle<'a> { @@ -4622,6 +4702,21 @@ impl<'a> CommandContextBundle<'a> { if capabilities.contains(CommandCapabilities::CONFIG_STATUS) { contexts = contexts.with_config_status(&mut self.config_status); } + if capabilities.contains(CommandCapabilities::CONFIG) { + contexts = contexts.with_config(&mut self.config); + } + if capabilities.contains(CommandCapabilities::PET) { + contexts = contexts.with_pet(&mut self.pet); + } + if capabilities.contains(CommandCapabilities::TRUST) { + contexts = contexts.with_trust(&mut self.trust); + } + if capabilities.contains(CommandCapabilities::LOGIN) { + contexts = contexts.with_login(&mut self.login); + } + if capabilities.contains(CommandCapabilities::WORK_SURFACE) { + contexts = contexts.with_work_surface(&mut self.work_surface); + } contexts } @@ -4652,7 +4747,12 @@ impl<'a> CommandContextBundle<'a> { .union(CommandCapabilities::DEBUG_UNDO) .union(CommandCapabilities::DEBUG_DIAGNOSTICS) .union(CommandCapabilities::PERMISSIONS) - .union(CommandCapabilities::CONFIG_STATUS); + .union(CommandCapabilities::CONFIG_STATUS) + .union(CommandCapabilities::CONFIG) + .union(CommandCapabilities::PET) + .union(CommandCapabilities::TRUST) + .union(CommandCapabilities::LOGIN) + .union(CommandCapabilities::WORK_SURFACE); self.contexts(all_test_capabilities).into_parts() } } @@ -4698,7 +4798,12 @@ impl App { debug_undo: DebugOperationsAdapter { host: host.clone() }, debug_diagnostics: DebugDiagnosticsAdapter { host: host.clone() }, permissions: PermissionsAdapter { host: host.clone() }, - config_status: ConfigStatusAdapter { host }, + config_status: ConfigStatusAdapter { host: host.clone() }, + config: ConfigAdapter { host: host.clone() }, + pet: PetAdapter { host: host.clone() }, + trust: TrustAdapter { host: host.clone() }, + login: LoginAdapter { host: host.clone() }, + work_surface: WorkSurfaceAdapter { host }, } } } diff --git a/crates/tui/src/commands/contract/config_main.rs b/crates/tui/src/commands/contract/config_main.rs new file mode 100644 index 0000000000..e1f550fc62 --- /dev/null +++ b/crates/tui/src/commands/contract/config_main.rs @@ -0,0 +1,544 @@ +//! Host adapters for the config command group (FEAT-028 Phase 4). +//! +//! Sole host owner of concrete machinery for the config-group commands. Each +//! delegate calls the existing command implementation and converts its TUI +//! `CommandResult` (message + `AppAction`) into a portable `ConfigReply` +//! (message + `ConfigAction`). The portable handlers depend only on the +//! `CommandConfigContext`/`CommandPetContext`/`CommandTrustContext` facets. + +use super::SharedCommandHost; +use crate::tui::app::ScreenMode; +use codewhale_command_contract::config::{ + CommandAccountState, CommandClaudeApplyOutcome, CommandClaudeArtifacts, CommandClaudePlan, + CommandConfigContext, CommandLoginContext, CommandLoginStatus, CommandLogoutOutcome, + CommandPetContext, CommandPetControl, CommandPetLabel, CommandTrustContext, + CommandWorkSurfaceContext, CommandWorkflowSummary, ConfigReply, LogoutFailure, TrustPathError, +}; +use codewhale_command_contract::types::CommandScreenMode; +use codewhale_command_contract::types::{CommandRailPanel, CommandWorkSurfacePlacement}; +use std::path::PathBuf; + +pub(crate) struct ConfigAdapter<'a> { + pub(crate) host: SharedCommandHost<'a>, +} + +impl CommandConfigContext for ConfigAdapter<'_> { + fn config_path(&self) -> Option { + self.host.app.borrow().config_path.clone() + } + + fn audit( + &mut self, + ) -> Result { + let app = self.host.app.borrow(); + crate::commands::config_main_host::config_editability_audit(&app) + } + + fn workflow_summary(&mut self) -> CommandWorkflowSummary { + let app = self.host.app.borrow(); + crate::commands::config_main_host::workflow_summary(&app) + } + + fn subagents_status( + &mut self, + ) -> Result { + let app = self.host.app.borrow(); + crate::commands::config_main_host::subagents_status(&app) + } + + fn subagent(&mut self, key: &str) -> ConfigReply { + let app = self.host.app.borrow(); + crate::commands::config_main_host::show_subagents_setting(&app, key) + } + + fn set_subagent(&mut self, key: &str, value: &str, persist: bool) -> ConfigReply { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_host::set_subagents_config_value(&mut app, key, value, persist) + } + + fn search_provider_status(&mut self) -> ConfigReply { + let app = self.host.app.borrow(); + crate::commands::config_main_host::show_single_setting(&app, "search.provider") + } + + fn search_provider_names(&self) -> &'static str { + crate::config::SearchProvider::names_hint() + } + + fn set_search_provider(&mut self, value: &str, persist: bool) -> ConfigReply { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_host::set_search_provider(&mut app, value, persist) + } + + fn notifications_status(&mut self) -> ConfigReply { + let app = self.host.app.borrow(); + crate::commands::config_main_host::show_notifications_status(&app) + } + + fn notification(&mut self, key: &str) -> ConfigReply { + let app = self.host.app.borrow(); + crate::commands::config_main_host::show_notifications_setting(&app, key) + } + + fn set_notification(&mut self, key: &str, value: &str, persist: bool) -> ConfigReply { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_host::set_notifications_value(&mut app, key, value, persist) + } + + fn apply_preset(&mut self, name: &str, persist: bool) -> ConfigReply { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_host::apply_preset(&mut app, name, persist) + } + + fn get_value(&mut self, key: &str) -> ConfigReply { + let app = self.host.app.borrow(); + crate::commands::config_main_host::show_single_setting(&app, key) + } + + fn set_value(&mut self, key: &str, value: &str, persist: bool) -> ConfigReply { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_host::set_config_value(&mut app, key, value, persist) + } + + fn show_settings( + &mut self, + ) -> Result { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_host::show_settings(&mut app) + } + + fn theme_schema(&self) -> String { + codewhale_palette::user_theme_schema_json().to_string() + } + + fn themes_dir(&self) -> Result { + codewhale_palette::user_themes_dir().map(|path| path.display().to_string()) + } + + fn verbose_enabled(&self) -> bool { + self.host.app.borrow().verbose_transcript + } + + fn set_verbose_enabled(&mut self, enabled: bool) { + let mut app = self.host.app.borrow_mut(); + app.verbose_transcript = enabled; + app.mark_history_updated(); + } + + fn screen_mode(&self) -> CommandScreenMode { + match self.host.app.borrow().screen_mode { + ScreenMode::Fullscreen => CommandScreenMode::Fullscreen, + ScreenMode::Inline => CommandScreenMode::Inline, + } + } + + fn build_plan(&mut self) -> Option { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_import_host::build_plan(&mut app) + } + + fn write_artifacts(&mut self, report: &str, bundle: Option<&str>) -> CommandClaudeArtifacts { + crate::commands::config_main_import_host::write_artifacts(report, bundle) + } + + fn apply_import( + &mut self, + plan: &CommandClaudePlan, + report_path: &std::path::Path, + wrote_report: bool, + ) -> CommandClaudeApplyOutcome { + let mut app = self.host.app.borrow_mut(); + crate::commands::config_main_import_host::apply_plan( + &mut app, + plan, + report_path, + wrote_report, + ) + } + + fn display_path(&self, path: &std::path::Path) -> String { + crate::utils::display_path(path).to_string() + } +} + +pub(crate) struct PetAdapter<'a> { + pub(crate) host: SharedCommandHost<'a>, +} + +impl CommandPetContext for PetAdapter<'_> { + fn enabled(&self) -> bool { + self.host.app.borrow().pet_watch.enabled + } + + fn is_open(&self) -> bool { + let app = self.host.app.borrow(); + crate::tui::pet_watch::is_open(&app) + } + + fn status(&self) -> String { + self.host.app.borrow().pet_watch.status() + } + + fn avatar_choices(&self) -> String { + self.host.app.borrow().pet_watch.avatar_choices() + } + + fn select_avatar(&mut self, key: &str) -> bool { + let mut app = self.host.app.borrow_mut(); + let ok = app.pet_watch.select_avatar(key); + if ok { + app.needs_redraw = true; + } + ok + } + + fn preview_avatar(&mut self, name: &str, view: bool) -> bool { + let mut app = self.host.app.borrow_mut(); + let ok = app.pet_watch.preview_avatar(name, view); + if ok { + app.needs_redraw = true; + } + ok + } + + fn set_enabled(&mut self, enabled: bool, persist: bool) { + let mut app = self.host.app.borrow_mut(); + crate::tui::pet_watch::set_enabled(&mut app, enabled); + if persist { + app.startup_defaults + .spawn(crate::tui::startup_defaults::StartupDefaults::pet_mode( + enabled, + )); + } + } + + fn set_sound(&mut self, enabled: bool) { + let mut app = self.host.app.borrow_mut(); + app.pet_watch.set_sound(enabled); + app.needs_redraw = true; + } + + fn sound_label(&self) -> String { + let app = self.host.app.borrow(); + codewhale_localization::tr(app.ui_locale, app.pet_watch.sound_label()).into_owned() + } + + fn export(&mut self) -> bool { + self.host.app.borrow_mut().pet_watch.export() + } + + fn open_habitat(&mut self) { + let mut app = self.host.app.borrow_mut(); + crate::tui::pet_watch::open_habitat(&mut app); + } + + fn queue(&mut self, control: CommandPetControl) -> bool { + use crate::tui::pet_watch::{BodyStyle, Control, TextLanguage}; + let control = match control { + CommandPetControl::Sound => Control::Sound, + CommandPetControl::Browser => Control::Browser, + CommandPetControl::Window => Control::Window, + CommandPetControl::Select => Control::Select, + CommandPetControl::FocusAgents => Control::FocusAgents, + CommandPetControl::Appearance { body, language } => Control::Appearance { + body: body.and_then(|body| BodyStyle::parse(body.as_str())), + language: language.and_then(|language| TextLanguage::parse(language.as_str())), + }, + }; + let mut app = self.host.app.borrow_mut(); + crate::tui::pet_watch::command(&mut app, control) + } + + fn label(&self, which: CommandPetLabel) -> String { + use codewhale_localization::MessageId; + let app = self.host.app.borrow(); + let id = match which { + CommandPetLabel::ModeOn => MessageId::PetModeOn, + CommandPetLabel::ModeOff => MessageId::PetModeOff, + CommandPetLabel::ModeOnLabel => MessageId::PetModeOnLabel, + CommandPetLabel::ModeOffLabel => MessageId::PetModeOffLabel, + CommandPetLabel::ViewOpen => MessageId::PetViewOpen, + CommandPetLabel::ViewClosed => MessageId::PetViewClosed, + CommandPetLabel::HabitatQueued => MessageId::PetHabitatQueued, + CommandPetLabel::ExportQueued => MessageId::PetWatchExportQueued, + CommandPetLabel::ExportUnavailable => MessageId::PetWatchExportUnavailable, + CommandPetLabel::SoundOn => MessageId::PetWatchSoundOn, + CommandPetLabel::WatchUnavailable => MessageId::PetWatchUnavailable, + }; + codewhale_localization::tr(app.ui_locale, id).into_owned() + } +} + +pub(crate) struct TrustAdapter<'a> { + pub(crate) host: SharedCommandHost<'a>, +} + +impl CommandTrustContext for TrustAdapter<'_> { + fn trust_mode(&self) -> bool { + self.host.app.borrow().trust_mode + } + + fn trusted_paths(&self) -> Vec { + let workspace = self.host.app.borrow().workspace.clone(); + crate::workspace_trust::WorkspaceTrust::load_for(&workspace) + .paths() + .to_vec() + } + + fn add_trusted_path(&mut self, raw: &str) -> Result { + let workspace = self.host.app.borrow().workspace.clone(); + let path = PathBuf::from(crate::commands::config_main_host::expand_tilde(raw)); + if !path.exists() { + return Err(TrustPathError::NotFound(path)); + } + crate::workspace_trust::add(&workspace, &path) + .map_err(|err| TrustPathError::UpdateFailed(err.to_string())) + } + + fn remove_trusted_path(&mut self, raw: &str) -> Result<(PathBuf, bool), TrustPathError> { + let workspace = self.host.app.borrow().workspace.clone(); + let path = PathBuf::from(crate::commands::config_main_host::expand_tilde(raw)); + crate::workspace_trust::remove(&workspace, &path) + .map(|removed| (path, removed)) + .map_err(|err| TrustPathError::UpdateFailed(err.to_string())) + } + + fn usage_label(&self) -> String { + let app = self.host.app.borrow(); + codewhale_localization::tr( + app.ui_locale, + codewhale_localization::MessageId::HelpUsageLabel, + ) + .into_owned() + } +} + +pub(crate) struct LoginAdapter<'a> { + pub(crate) host: SharedCommandHost<'a>, +} + +fn is_enabled_plugin_provider(app: &crate::tui::app::App, provider: &str) -> bool { + app.plugin_registry + .active_plugins() + .into_iter() + .any(|plugin| plugin.manifest.providers.contains_key(provider)) +} + +impl CommandLoginContext for LoginAdapter<'_> { + fn login_status(&self) -> CommandLoginStatus { + use codewhale_secrets::account::{ + ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, + DEFAULT_ACCOUNT_API_BASE, secure_account_session_secrets, + }; + let app = self.host.app.borrow(); + let api_base = std::env::var(ACCOUNT_API_BASE_ENV) + .ok() + .map(|value| value.trim().trim_end_matches('/').to_string()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string()); + let account = match secure_account_session_secrets() { + Ok(secrets) => { + match AccountSessionStore::new(secrets, None, &api_base) + .runtime_info_at(chrono::Utc::now()) + { + Ok(info) => match info.state { + AccountSessionState::SignedOut => CommandAccountState::SignedOut, + AccountSessionState::Authenticated => CommandAccountState::Authenticated, + AccountSessionState::OfflineCached => CommandAccountState::OfflineCached, + AccountSessionState::Expired => CommandAccountState::Expired, + AccountSessionState::Revoked => CommandAccountState::Revoked, + }, + Err(error) => CommandAccountState::Unavailable(error.to_string()), + } + } + Err(error) => CommandAccountState::Unavailable(error.to_string()), + }; + CommandLoginStatus { + account, + api_base, + provider: app.provider_identity_for_persistence().to_string(), + } + } + + fn logout(&mut self) -> Result { + let mut app = self.host.app.borrow_mut(); + let provider = app.provider_identity_for_persistence().to_string(); + match crate::config::clear_active_provider_api_key(&provider) { + Ok(()) => { + app.onboarding = crate::tui::app::OnboardingState::Provider; + app.onboarding_needs_api_key = true; + app.onboarding_provider = app.api_provider; + app.onboarding_missing_key_recovery = true; + app.api_key_env_only = false; + let account = crate::commands::config_main_host::clear_local_account_session(); + let legacy_sandbox = crate::commands::config_main_host::clear_legacy_sandbox_slot(); + Ok(CommandLogoutOutcome { + provider, + account, + legacy_sandbox, + }) + } + Err(error) => Err(LogoutFailure { + provider, + error: error.to_string(), + }), + } + } + fn connect( + &mut self, + provider: Option<&str>, + ) -> codewhale_command_contract::config::ConfigReply { + use codewhale_command_contract::config::ConfigReply; + let app = self.host.app.borrow(); + match provider { + None => ConfigReply::action( + codewhale_command_contract::outcome::ConfigAction::OpenProviderPicker, + ), + Some(provider) if is_enabled_plugin_provider(&app, provider) => ConfigReply::action( + codewhale_command_contract::outcome::ConfigAction::StartPluginLogin { + provider: provider.to_owned(), + }, + ), + Some(other) => ConfigReply::error(format!( + "Usage: /connect []\nUnknown or inactive plugin provider: {other}\nCodewhale account sign-in is `/login`." + )), + } + } + + fn disconnect(&mut self, provider: &str) -> codewhale_command_contract::config::ConfigReply { + use codewhale_command_contract::config::ConfigReply; + let app = self.host.app.borrow(); + if is_enabled_plugin_provider(&app, provider) { + ConfigReply::action( + codewhale_command_contract::outcome::ConfigAction::StartPluginLogout { + provider: provider.to_owned(), + }, + ) + } else { + ConfigReply::error(format!( + "Usage: /disconnect \nUnknown or inactive plugin provider: {provider}\nSigning out of Codewhale is `/logout`." + )) + } + } +} + +pub(crate) struct WorkSurfaceAdapter<'a> { + pub(crate) host: SharedCommandHost<'a>, +} + +fn to_placement( + placement: crate::tui::work_surface::WorkSurfacePlacement, +) -> CommandWorkSurfacePlacement { + use crate::tui::work_surface::WorkSurfacePlacement as P; + match placement { + P::Bottom => CommandWorkSurfacePlacement::Bottom, + P::Top => CommandWorkSurfacePlacement::Top, + P::Left => CommandWorkSurfacePlacement::Left, + P::Right => CommandWorkSurfacePlacement::Right, + P::Off => CommandWorkSurfacePlacement::Off, + } +} + +fn from_placement( + placement: CommandWorkSurfacePlacement, +) -> crate::tui::work_surface::WorkSurfacePlacement { + use crate::tui::work_surface::WorkSurfacePlacement as P; + match placement { + CommandWorkSurfacePlacement::Bottom => P::Bottom, + CommandWorkSurfacePlacement::Top => P::Top, + CommandWorkSurfacePlacement::Left => P::Left, + CommandWorkSurfacePlacement::Right => P::Right, + CommandWorkSurfacePlacement::Off => P::Off, + } +} + +fn to_rail_panel(panel: crate::tui::work_surface::RailPanel) -> CommandRailPanel { + use crate::tui::work_surface::RailPanel as P; + match panel { + P::Agents => CommandRailPanel::Agents, + P::Tasks => CommandRailPanel::Tasks, + P::Background => CommandRailPanel::Background, + P::Terminal => CommandRailPanel::Terminal, + P::Files => CommandRailPanel::Files, + P::Notepad => CommandRailPanel::Notepad, + P::Context => CommandRailPanel::Context, + P::Git => CommandRailPanel::Git, + P::Price => CommandRailPanel::Price, + } +} + +fn from_rail_panel(panel: CommandRailPanel) -> crate::tui::work_surface::RailPanel { + use crate::tui::work_surface::RailPanel as P; + match panel { + CommandRailPanel::Agents => P::Agents, + CommandRailPanel::Tasks => P::Tasks, + CommandRailPanel::Background => P::Background, + CommandRailPanel::Terminal => P::Terminal, + CommandRailPanel::Files => P::Files, + CommandRailPanel::Notepad => P::Notepad, + CommandRailPanel::Context => P::Context, + CommandRailPanel::Git => P::Git, + CommandRailPanel::Price => P::Price, + } +} + +impl CommandWorkSurfaceContext for WorkSurfaceAdapter<'_> { + fn placement(&self) -> CommandWorkSurfacePlacement { + to_placement(self.host.app.borrow().work_surface.placement) + } + + fn panel(&self) -> CommandRailPanel { + to_rail_panel(self.host.app.borrow().work_surface.panel) + } + + fn effective_placement(&self) -> CommandWorkSurfacePlacement { + to_placement(self.host.app.borrow().work_surface.effective_placement()) + } + + fn area_present(&self) -> bool { + self.host.app.borrow().work_surface.last_area.is_some() + } + + fn panel_title(&self, panel: CommandRailPanel) -> String { + from_rail_panel(panel).title().to_string() + } + + fn set_placement( + &mut self, + placement: CommandWorkSurfacePlacement, + persist: bool, + ) -> ConfigReply { + let mut app = self.host.app.borrow_mut(); + app.work_surface.placement = from_placement(placement); + app.work_surface.focused = false; + if persist { + let result = crate::commands::config_main_host::set_config_value( + &mut app, + "work_surface_placement", + from_placement(placement).as_setting(), + true, + ); + return result; + } + ConfigReply::ok() + } + + fn set_panel(&mut self, panel: CommandRailPanel, persist: bool) -> ConfigReply { + let mut app = self.host.app.borrow_mut(); + crate::tui::work_surface::select_dock_panel(&mut app, from_rail_panel(panel)); + if persist { + let result = crate::commands::config_main_host::set_config_value( + &mut app, + "rail_panel", + from_rail_panel(panel).as_setting(), + true, + ); + return result; + } + ConfigReply::ok() + } + + fn mark_redraw(&mut self) { + self.host.app.borrow_mut().needs_redraw = true; + } +} diff --git a/crates/tui/src/commands/contract/config_messages.rs b/crates/tui/src/commands/contract/config_messages.rs new file mode 100644 index 0000000000..38cdf2f5fe --- /dev/null +++ b/crates/tui/src/commands/contract/config_messages.rs @@ -0,0 +1,39 @@ +//! Host localization keys for the portable config group (FEAT-028). +//! +//! The command renders `ConfigMessage::Keyed` through the presentation shape; +//! this resolver maps each stable snake_case key to its `MessageId`. +use codewhale_localization::MessageId; + +pub(in crate::commands) fn resolve(key: &str) -> Option { + Some(match key { + "config_audit_notifications" => MessageId::ConfigAuditNotifications, + "config_audit_prompt_suggestion" => MessageId::ConfigAuditPromptSuggestion, + "config_audit_search_provider" => MessageId::ConfigAuditSearchProvider, + "config_command_invalid_value" => MessageId::ConfigCommandInvalidValue, + "config_command_source" => MessageId::ConfigCommandSource, + "config_default_value" => MessageId::ConfigDefaultValue, + "config_notification_updated" => MessageId::ConfigNotificationUpdated, + "config_notifications_set_hint" => MessageId::ConfigNotificationsSetHint, + "config_prompt_suggestion_updated" => MessageId::ConfigPromptSuggestionUpdated, + "config_scope_saved" => MessageId::ConfigScopeSaved, + "config_scope_session" => MessageId::ConfigScopeSession, + "config_search_updated" => MessageId::ConfigSearchUpdated, + "contextual_tips_not_saved" => MessageId::ContextualTipsNotSaved, + "help_usage_label" => MessageId::HelpUsageLabel, + "pet_mode_off" => MessageId::PetModeOff, + "pet_mode_on" => MessageId::PetModeOn, + "pet_mode_on_label" => MessageId::PetModeOnLabel, + "pet_view_open" => MessageId::PetViewOpen, + "pet_watch_sound_off" => MessageId::PetWatchSoundOff, + "pet_watch_sound_paused" => MessageId::PetWatchSoundPaused, + "setting_subject_mode" => MessageId::SettingSubjectMode, + "setting_subject_model" => MessageId::SettingSubjectModel, + "settings_config_file" => MessageId::SettingsConfigFile, + "settings_title" => MessageId::SettingsTitle, + "setting_subject_permissions" => MessageId::SettingSubjectPermissions, + "setting_subject_provider" => MessageId::SettingSubjectProvider, + "setting_subject_thinking" => MessageId::SettingSubjectThinking, + "startup_default_not_saved" => MessageId::StartupDefaultNotSaved, + _ => return None, + }) +} diff --git a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs index b0c1a5ed1e..011953088c 100644 --- a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs +++ b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs @@ -253,6 +253,7 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { debug_diagnostics, permissions, config_status, + .. } = parts; assert!(permissions.is_none() && config_status.is_none()); assert!(debug_diagnostics.is_some(), "/{spelling} needs diagnostics"); diff --git a/crates/tui/src/commands/debug_mutation_host_tests.rs b/crates/tui/src/commands/debug_mutation_host_tests.rs index dbe2921476..9aef6a804d 100644 --- a/crates/tui/src/commands/debug_mutation_host_tests.rs +++ b/crates/tui/src/commands/debug_mutation_host_tests.rs @@ -1606,6 +1606,7 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { debug_undo, permissions, config_status, + .. } = bundle.contexts(capabilities).into_parts(); assert!(permissions.is_none() && config_status.is_none()); for (name, present, capability) in [ diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 8cdabaee35..7f3fd0b322 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -1,115 +1,477 @@ -//! Config commands: config, settings, mode switches, trust, logout - -use super::CommandResult; -use crate::config::{ - Config, DEFAULT_STREAM_CHUNK_TIMEOUT_SECS, DEFAULT_SUBAGENT_API_TIMEOUT_SECS, - DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, DEFAULT_XIAOMI_MIMO_BASE_URL, - MAX_STREAM_CHUNK_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS, - MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, MAX_SUBAGENTS, MIN_STREAM_CHUNK_TIMEOUT_SECS, - MIN_SUBAGENT_API_TIMEOUT_SECS, MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, NotificationConfigUpdate, - NotificationSetting, NotificationsConfig, ProviderKind, SearchProvider, SearchProviderSource, - SubagentsConfig, XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL, clear_active_provider_api_key, - normalize_custom_model_id, normalize_model_name_for_provider, validate_route, -}; -use crate::config_persistence::{ - persist_root_bool_key, persist_root_string_key, persist_subagents_bool_key, - persist_subagents_integer_key, persist_table_string_key, persist_table_value_key, - persist_unset_root_key, +//! Portable config command handlers (FEAT-028 Phase 5). +//! +//! Every handler consumes only the `CONFIG`/`PET`/`TRUST` capability facets and +//! returns a contract-owned `CommandResult`. No handler names +//! `App`, `AppAction` or any concrete host type, so the whole group compiles +//! without `codewhale-tui` and can move into `codewhale-commands` without a +//! circular dependency. The concrete implementations live in +//! `crate::commands::config_main_host`, reached only through the host adapters. + +use codewhale_command_contract::config::{ + CommandAccountState, CommandPetBodyStyle, CommandPetControl, CommandPetLabel, + CommandPetTextLanguage, CommandWorkSurfaceContext, CommandWorkflowSummary, ConfigMessage, + ConfigReply, TrustPathError, }; -use crate::reasoning_preference::ReasoningEffort; -use crate::settings::Settings; -use crate::tui::app::{ - App, AppAction, AppModeUi, OnboardingState, ScreenMode, SettingSelection, VimMode, +use codewhale_command_contract::facets::CommandModeSelection; +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts}; +use codewhale_command_contract::outcome::{ConfigAction, ConfigCommandResult}; +use codewhale_command_contract::types::{ + CommandMode, CommandRailPanel, CommandScreenMode, CommandWorkSurfacePlacement, }; -use anyhow::Result; -use codewhale_config::AppMode; -use codewhale_execpolicy::ApprovalMode; -use codewhale_localization::{MessageId, resolve_locale, tr}; -use std::path::{Path, PathBuf}; -/// Open the interactive config editor. +/// `/config` only: config authority plus `PRESENTATION` for its keyed messages +/// and `PERMISSIONS` because `ask-rules` forwards to the permission-rules editor. +pub const CONFIG_CAPS: CommandCapabilities = CommandCapabilities::CONFIG + .union(CommandCapabilities::PRESENTATION) + .union(CommandCapabilities::PERMISSIONS); +/// Commands that observe config and render keyed messages (`/theme`). +pub const CONFIG_PRESENTATION_CAPS: CommandCapabilities = + CommandCapabilities::CONFIG.union(CommandCapabilities::PRESENTATION); +/// Commands that observe config and render only literals +/// (`/settings`, `/verbose`, `/fullscreen`, `/inline`, `/import-claude`). +pub const CONFIG_ONLY_CAPS: CommandCapabilities = CommandCapabilities::CONFIG; +/// Capabilities declared by `/pet`. +pub const PET_CAPS: CommandCapabilities = CommandCapabilities::PET; +/// Capabilities declared by `/trust`. +pub const TRUST_CAPS: CommandCapabilities = CommandCapabilities::TRUST; +/// Capabilities declared by `/login` and `/logout`. +pub const LOGIN_CAPS: CommandCapabilities = CommandCapabilities::LOGIN; +/// Capabilities declared by `/mode` (reuses the mode-policy facet). +pub const MODE_CAPS: CommandCapabilities = CommandCapabilities::MODE_POLICY; +/// Capabilities declared by `/workbar`. +pub const WORK_SURFACE_CAPS: CommandCapabilities = CommandCapabilities::WORK_SURFACE; + +/// Resolve a host reply into a command result. /// -/// One settings surface: bare `/config` and `/config tui|web|native` all -/// open the canonical ConfigView (the `OpenConfigView` action). The legacy -/// schemaui editors are gone; the mode words remain accepted so muscle -/// memory lands in the right place instead of an error. -pub fn show_config(_app: &mut App, arg: Option<&str>) -> CommandResult { - match arg.unwrap_or("").trim().to_ascii_lowercase().as_str() { - "" | "native" | "tui" | "web" => CommandResult::action(AppAction::OpenConfigView), - other => CommandResult::error(format!( - "Usage: /config [native|tui|web] — unknown editor `{other}`" - )), +/// Literal messages never require the presentation shape. A `Keyed` message +/// needs it, and a missing required capability fails loudly (it must not be +/// substituted while the reply keeps `is_error == false`). +fn to_result( + reply: ConfigReply, + parts: &mut codewhale_command_contract::handler::ContextParts<'_>, + prepared: &PreparedTemplates, +) -> ConfigCommandResult { + let message = match reply.message { + None => None, + Some(message) => { + if message_needs_presentation(&message) { + match parts.presentation.as_deref_mut() { + Some(_) => match render_message(message, prepared) { + Ok(text) => Some(text), + Err(error) => { + return ConfigCommandResult { + message: Some(error), + action: None, + is_error: true, + }; + } + }, + None => { + return ConfigCommandResult { + message: Some( + "Error: Command capability unavailable: presentation".to_string(), + ), + action: None, + is_error: true, + }; + } + } + } else { + render_literals(message) + } + } + }; + let message = match (message, reply.is_error) { + (Some(text), true) if !text.starts_with("Error: ") => Some(format!("Error: {text}")), + (other, _) => other, + }; + ConfigCommandResult { + message, + action: reply.action, + is_error: reply.is_error, + } +} + +/// Whether any part of the message needs the presentation shape. +fn message_needs_presentation(message: &ConfigMessage) -> bool { + match message { + ConfigMessage::Literal(_) => false, + ConfigMessage::Keyed { .. } => true, + ConfigMessage::Concat(parts) => parts.iter().any(message_needs_presentation), + } +} + +/// Render a literal-only message without the presentation shape. +fn render_literals(message: ConfigMessage) -> Option { + match message { + ConfigMessage::Literal(text) => Some(text), + ConfigMessage::Concat(parts) => { + let mut out = String::new(); + for part in parts { + out.push_str(&render_literals(part)?); + } + Some(out) + } + ConfigMessage::Keyed { .. } => None, + } +} + +/// The parameter names left as sentinels in a prepared template. +fn sentinel_names(template: &str) -> Vec { + let mut names = Vec::new(); + let mut rest = template; + while let Some(start) = rest.find('\u{1}') { + let after = &rest[start + 1..]; + let Some(end) = after.find('\u{1}') else { + break; + }; + names.push(after[..end].to_string()); + rest = &after[end + 1..]; + } + names +} + +/// Sentinel left in place of a template placeholder during preflight. +fn sentinel(name: &str) -> String { + format!("\u{1}{name}\u{1}") +} + +/// Localized templates validated before any effect, keyed by message key. +/// +/// Rendering never translates again. Replies outside the prepared key/parameter +/// contract fail without an action; inserted values remain literal. +#[derive(Default)] +struct PreparedTemplates { + templates: std::collections::HashMap, +} + +impl PreparedTemplates { + fn get(&self, key: &str) -> Option<&str> { + self.templates.get(key).map(String::as_str) + } +} + +/// Render a reply message, propagating any translation failure (including one +/// nested inside a parameter) instead of converting it into ordinary text. +fn render_message(message: ConfigMessage, prepared: &PreparedTemplates) -> Result { + match message { + ConfigMessage::Literal(text) => Ok(text), + ConfigMessage::Concat(parts) => { + let mut out = String::new(); + for part in parts { + out.push_str(&render_message(part, prepared)?); + } + Ok(out) + } + ConfigMessage::Keyed { key, params } => { + let mut resolved: Vec<(String, String)> = Vec::with_capacity(params.len()); + for (name, param) in params { + resolved.push((name, render_message(param, prepared)?)); + } + match prepared.get(&key) { + // Validated before any effect: substitute the sentinel left by + // preflight, never re-translate. The reply's parameter names + // must match the prepared template exactly. + Some(template) => { + // The template may repeat a placeholder; only the *set* of + // names has to match, and the reply must not duplicate one. + let expected: std::collections::BTreeSet = + sentinel_names(template).into_iter().collect(); + let mut seen = std::collections::BTreeSet::new(); + for (name, _) in &resolved { + if !seen.insert(name.clone()) { + return Err(format!( + "invalid translation replacement contract for {key}: duplicate {name}" + )); + } + } + if seen != expected { + return Err(format!( + "invalid translation replacement contract for {key}" + )); + } + let mut out = String::with_capacity(template.len()); + let mut rest = template; + while let Some(start) = rest.find('\u{1}') { + let after = &rest[start + 1..]; + let Some(end) = after.find('\u{1}') else { + break; + }; + out.push_str(&rest[..start]); + let name = &after[..end]; + if let Some((_, value)) = resolved.iter().find(|(key, _)| key == name) { + out.push_str(value); + } + rest = &after[end + 1..]; + } + out.push_str(rest); + Ok(out) + } + // The effect-reply contract is prepared before the mutation; an + // unprepared key is a host contract violation, never a reason to + // translate after the effect. + None => Err(format!("unprepared message key: {key}")), + } + } + } +} + +/// The complete effect-reply contract, validated before every mutation. +const REQUIRED_TEMPLATES: &[(&str, &[&str])] = + codewhale_command_contract::config::EFFECT_REPLY_TEMPLATES; + +/// Templates `/settings` renders through the presentation shape. +const SETTINGS_TEMPLATES: &[(&str, &[&str])] = + &[("settings_title", &[]), ("settings_config_file", &[])]; + +/// Keyed explanatory notes the `/config status` audit renders. +const AUDIT_TEMPLATES: &[(&str, &[&str])] = &[ + ("config_audit_search_provider", &[]), + ("config_audit_prompt_suggestion", &[]), + ("config_audit_notifications", &[]), +]; + +/// Validate required capabilities and translation templates before any effect. +#[allow(clippy::result_large_err)] +fn preflight( + parts: &mut codewhale_command_contract::handler::ContextParts<'_>, +) -> Result { + preflight_with(parts, REQUIRED_TEMPLATES) +} + +/// As [`preflight`], with a caller-supplied template set. Returns the +/// validated templates so rendering cannot translate again after an effect. +#[allow(clippy::result_large_err)] +fn preflight_with( + parts: &mut codewhale_command_contract::handler::ContextParts<'_>, + templates: &[(&str, &[&str])], +) -> Result { + let Some(presentation) = parts.presentation.as_deref_mut() else { + return Err(missing("presentation")); + }; + let mut prepared = PreparedTemplates::default(); + for (key, params) in templates { + let dummies: Vec = params.iter().map(|name| sentinel(name)).collect(); + let refs: Vec<(&str, &str)> = params + .iter() + .zip(&dummies) + .map(|(name, dummy)| (*name, dummy.as_str())) + .collect(); + let template = presentation + .translate(key, &refs) + .map_err(|error| ConfigCommandResult { + message: Some(error), + action: None, + is_error: true, + })?; + prepared.templates.insert((*key).to_string(), template); + } + Ok(prepared) +} + +fn missing(facet: &str) -> ConfigCommandResult { + ConfigCommandResult { + message: Some(format!("Command capability unavailable: {facet}")), + action: None, + is_error: true, } } -/// Dispatch `/config` with optional args. +/// `/config [ask-rules|status| [value]|preset|workflow|...]`. +/// `/config [ask-rules|status| [value]|preset|workflow|subagents|search|notifications|tui|web|native]`. /// -/// - `/config` (no args) — opens the canonical ConfigView. -/// - `/config tui` / `/config web` / `/config native` — the same ConfigView -/// (the words are accepted for muscle memory, not separate editors). -/// - `/config ask-rules` — compatibility entry for `/permissions`. -/// - `/config ` — shows the current value of a setting. -/// - `/config ` — sets a runtime value (session only, add --save to persist). -pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult { +/// The command owns the top-level subcommand routing, the `--save` parse and the +/// ask-rules forwarding; the facet performs the per-key work behind typed +/// operations. +pub fn config(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { let raw = arg.map(str::trim).unwrap_or(""); if raw.is_empty() { - return show_config(app, None); + return ConfigCommandResult::action(ConfigAction::OpenConfigView); } if matches!( raw.to_ascii_lowercase().as_str(), "audit" | "editability" | "editable" | "status" ) { - return config_editability_audit(app); + let mut parts = contexts.into_parts(); + if parts.config.is_none() { + return missing("config"); + } + let prepared = match preflight_with(&mut parts, AUDIT_TEMPLATES) { + Ok(prepared) => prepared, + Err(result) => return result, + }; + let report = match parts.config.as_deref_mut() { + Some(facet) => match facet.audit() { + Ok(report) => report, + Err(error) => return ConfigCommandResult::error(error), + }, + None => return missing("config"), + }; + let mut message = vec![ConfigMessage::literal(format!( + "Config editability audit\nConfig path: {}\nKey | Current | Editability | Command / reason", + report.config_path + ))]; + for row in &report.rows { + message.push(ConfigMessage::literal(format!( + "\n{} | {} | {} | {}\n ", + row.key, row.current, row.editability, row.command + ))); + message.push(row.note.clone()); + } + return to_result( + ConfigReply::message(ConfigMessage::concat(message)), + &mut parts, + &prepared, + ); } let mut raw_words = raw.splitn(2, char::is_whitespace); let first_word = raw_words.next(); if first_word.is_some_and(is_ask_rules_config_token) { let rest = raw_words.next().unwrap_or("").trim(); - return crate::commands::config_policy_host::permissions(app, Some(rest)); + return super::policy::permissions::execute(contexts, Some(rest)); } - if first_word.is_some_and(|token| { + let is_workflow = first_word.is_some_and(|token| { token.eq_ignore_ascii_case("workflow") || token.eq_ignore_ascii_case("goal") }) && raw_words .clone() .next() - .is_none_or(|rest| rest.trim().is_empty()) - { - return super::workflow_settings(app); + .is_none_or(|rest| rest.trim().is_empty()); + let is_subagents = first_word.is_some_and(|token| token.eq_ignore_ascii_case("subagents")); + let is_search = first_word.is_some_and(|token| token.eq_ignore_ascii_case("search")); + let is_notifications = first_word.is_some_and(|token| { + token.eq_ignore_ascii_case("notifications") || token.eq_ignore_ascii_case("notification") + }); + let is_preset = first_word.is_some_and(|token| token.eq_ignore_ascii_case("preset")); + + let mut parts = contexts.into_parts(); + if parts.config.is_none() { + return missing("config"); } - if first_word.is_some_and(|token| token.eq_ignore_ascii_case("subagents")) { - let rest = raw_words.next().unwrap_or("").trim(); - return subagents_config_command(app, rest); + let prepared = match preflight(&mut parts) { + Ok(prepared) => prepared, + Err(result) => return result, + }; + let Some(facet) = parts.config.as_deref_mut() else { + return missing("config"); + }; + if is_workflow { + return ConfigCommandResult::message(render_workflow_summary(facet.workflow_summary())); } - if first_word.is_some_and(|token| token.eq_ignore_ascii_case("search")) { + if is_subagents { let rest = raw_words.next().unwrap_or("").trim(); - return search_config_command(app, rest); + let (op, persist) = parse_subagent_rest(rest); + let reply = match op { + SubagentOp::Status => { + return match facet.subagents_status() { + Ok(report) => { + let mut lines = vec![ + format!( + "Sub-agents: {}", + report.disabled_reason.as_deref().map_or_else( + || "enabled".to_string(), + |reason| format!("disabled ({reason})"), + ) + ), + format!("Config path: {}", report.config_path), + format!( + "Active provider: {} ({})", + report.provider_key, report.provider_label + ), + format!( + "subagents.enabled = {}", + report.explicit_enabled.map_or_else( + || "default true".to_string(), + |value| value.to_string() + ) + ), + ]; + for limit in &report.limits { + lines.push(format!( + "subagents.{} = {} (resolved global {}; active provider {})", + limit.key, limit.raw, limit.global, limit.provider + )); + } + match &report.provider_rows { + Some(rows) => { + for row in rows { + lines.push(format!( + "subagents.providers.{}.{} = {}", + report.provider_key, row.key, row.value + )); + } + } + None => lines.push(format!( + "subagents.providers.{} = inherits global", + report.provider_key + )), + } + ConfigCommandResult::message(lines.join("\n")) + } + Err(error) => ConfigCommandResult::error(error), + }; + } + SubagentOp::Get(key) => facet.subagent(key), + SubagentOp::Set { key, value } => facet.set_subagent(key, value, persist), + SubagentOp::Usage => { + return ConfigCommandResult::error( + "Usage: /config subagents [status|on|off|enabled|max_concurrent|max_depth|launch_concurrency|api_timeout_secs|heartbeat_timeout_secs ] [--save]", + ); + } + }; + return to_result(reply, &mut parts, &prepared); } - if first_word.is_some_and(|token| { - token.eq_ignore_ascii_case("notifications") || token.eq_ignore_ascii_case("notification") - }) { + if is_search { let rest = raw_words.next().unwrap_or("").trim(); - return notifications_config_command(app, rest); + let (op, persist) = parse_search_rest(rest); + let reply = match op { + SearchOp::Status => facet.search_provider_status(), + SearchOp::Set(value) => facet.set_search_provider(value, persist), + SearchOp::Usage => { + return to_result( + ConfigReply::error(ConfigMessage::concat(vec![ + ConfigMessage::keyed("help_usage_label"), + ConfigMessage::literal(format!( + " /config search.provider <{}> [--save]", + facet.search_provider_names(), + )), + ])), + &mut parts, + &prepared, + ); + } + }; + return to_result(reply, &mut parts, &prepared); } - // `/config preset [--save|-s]` — apply a bundled settings preset (#3478). - if first_word.is_some_and(|token| token.eq_ignore_ascii_case("preset")) { + if is_notifications { let rest = raw_words.next().unwrap_or("").trim(); - return config_preset_command(app, rest); + let (op, persist) = parse_notifications_rest(rest); + let reply = match op { + NotificationsOp::Status => facet.notifications_status(), + NotificationsOp::Get(key) => facet.notification(key), + NotificationsOp::Set { key, value } => facet.set_notification(key, value, persist), + }; + return to_result(reply, &mut parts, &prepared); } - let parts: Vec<&str> = raw.splitn(2, ' ').collect(); - if parts.len() == 1 { - // Single arg: editor-mode shortcut OR show-value request. - let token = parts[0]; + + if is_preset { + let rest = raw_words.next().unwrap_or("").trim(); + let tokens = rest.split_whitespace().collect::>(); + let persist = matches!(tokens.last(), Some(&"--save") | Some(&"-s")); + let name = tokens.first().copied().unwrap_or(""); + return to_result(facet.apply_preset(name, persist), &mut parts, &prepared); + } + let parts2 = raw.splitn(2, ' ').collect::>(); + if parts2.len() == 1 { + let token = parts2[0]; if matches!( token.to_ascii_lowercase().as_str(), "tui" | "web" | "native" ) { - return show_config(app, Some(token)); + return ConfigCommandResult::action(ConfigAction::OpenConfigView); } - // `/config ` — show current value - show_single_setting(app, token) + to_result(facet.get_value(token), &mut parts, &prepared) } else { - // `/config [--save|-s]` — set value, optionally persist - let raw_value = parts[1]; + let raw_value = parts2[1]; let persist = raw_value.ends_with(" --save") || raw_value.ends_with(" -s"); let value = if persist { raw_value @@ -119,515 +481,321 @@ pub fn config_command(app: &mut App, arg: Option<&str>) -> CommandResult { } else { raw_value }; - set_config_value(app, parts[0], value, persist) + to_result( + facet.set_value(parts2[0], value, persist), + &mut parts, + &prepared, + ) } } -/// Reject a preset bundle *before* anything is written, returning the message -/// to show, or `None` when every field can be applied. -/// -/// The bundle is persisted in one transaction and then mirrored field by field -/// into the live session. A per-field refusal during that mirror pass therefore -/// arrives *after* the file has already been rewritten — the user gets an error -/// and a saved file, which is the partial apply this preflight exists to make -/// impossible. Both refusals a field can raise are knowable up front: -/// -/// 1. A live-route key while a turn is running (#2982). -/// 2. A value the setter would reject, checked against a throwaway `Settings` -/// so the real file is never touched by the check. -fn preset_preflight(app: &App, fields: &[(&str, &str)]) -> Option { - for (key, value) in fields { - if app.is_loading - && let Some(subject) = live_route_setting_subject(&key.to_lowercase()) - { - return Some(app.setting_locked_message(subject)); - } - if let Err(e) = Settings::default().set(key, value) { - return Some(format!("Failed to apply preset field {key}={value}: {e}")); +/// Whether a `/config` token forwards to the permission-rules editor. +fn is_ask_rules_config_token(token: &str) -> bool { + matches!( + token.to_ascii_lowercase().as_str(), + "ask-rules" + | "ask_rules" + | "askrules" + | "rules" + | "permission-rules" + | "permission_rules" + | "permissions" + ) +} + +/// `/settings [text]`. +/// `/settings [text]`. The command owns the argument routing; the facet returns +/// the resolved settings text. +pub fn settings(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + match arg.map(str::trim).filter(|value| !value.is_empty()) { + None => ConfigCommandResult::action(ConfigAction::OpenConfigView), + Some("text" | "show" | "diagnostic" | "diagnostics") => { + let mut parts = contexts.into_parts(); + if parts.config.is_none() { + return missing("config"); + } + let prepared = match preflight_with(&mut parts, SETTINGS_TEMPLATES) { + Ok(prepared) => prepared, + Err(result) => return result, + }; + let Some(facet) = parts.config.as_deref_mut() else { + return missing("config"); + }; + let reply = match facet.show_settings() { + Ok(report) => { + let mut message = vec![ + ConfigMessage::keyed("settings_title"), + ConfigMessage::literal("\n─────────────────────────────\n"), + ]; + let mut rows = String::new(); + for row in &report.rows { + rows.push_str(&format!( + " {: ConfigReply::error(error), + }; + to_result(reply, &mut parts, &prepared) } + Some(_) => ConfigCommandResult::error("Usage: /settings [text]"), } - None } -/// Apply a bundled settings preset, e.g. `/config preset calm [--save]` (#3478). +/// `/mode [act|plan|operate|1|2|3]` and the `/jihua`/`/zidong` aliases. +/// `/mode [act|plan|operate|1|2|3]` (aliases `/jihua`, `/zidong`). /// -/// The preset is applied to the live session through the same per-key setter a -/// single `/config ` uses, so app state mirroring and (with -/// `--save`) persistence stay consistent. The preset name is validated before -/// any field is touched. -fn config_preset_command(app: &mut App, rest: &str) -> CommandResult { - let tokens: Vec<&str> = rest.split_whitespace().collect(); - let persist = matches!(tokens.last(), Some(&"--save") | Some(&"-s")); - let name = tokens.first().copied().unwrap_or(""); - if name.is_empty() || name.starts_with('-') { - return CommandResult::message( - "Usage: /config preset [--save]. Available presets: calm.", - ); - } - - let Some(fields) = crate::settings::preset_fields(name) else { - return CommandResult::error(format!("Unknown preset '{name}'. Available presets: calm.")); +/// The command owns the alias handling, parse and message composition; the +/// mode-policy facet applies the selection and reports the outcome. +pub fn mode(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let Some(arg) = arg.filter(|value| !value.trim().is_empty()) else { + return ConfigCommandResult::action(ConfigAction::OpenModePicker); }; - - if let Some(refusal) = preset_preflight(app, fields) { - return CommandResult::error(refusal); + let mut parts = contexts.into_parts(); + let Some(facet) = parts.mode_policy.as_deref_mut() else { + return missing("mode_policy"); + }; + if CommandMode::is_legacy_bypass_alias(arg) { + // The legacy alias is a one-way permission shorthand: its receipt names + // Full Access (Work), not the hidden mode it folds to. + return match facet.select_yolo_compat() { + CommandModeSelection::Changed => ConfigCommandResult::with_message_and_action( + format!("Switched to {}.", facet.yolo_compat_label()), + ConfigAction::ModeChanged(CommandMode::Agent), + ), + CommandModeSelection::PersistedSame => { + ConfigCommandResult::message(facet.mode_startup_default_receipt(CommandMode::Agent)) + } + CommandModeSelection::Refused => { + ConfigCommandResult::message(facet.setting_locked_mode_message()) + } + }; } - - // Persist the whole bundle atomically when requested (one load/apply/save), - // now that every field is known to be applicable. - if persist { - // `Settings::transact` is what makes "one load/apply/save" true against - // the *other* writers in this process, not just against a second preset - // apply: an unsynchronized load/save pair here would write back a - // pre-image that reverts a concurrent mode/thinking/posture write. - if let Err(e) = Settings::transact(|settings| settings.apply_preset(name)) { - return CommandResult::error(format!("Failed to save settings: {e}")); + match CommandMode::parse(arg) { + Some(mode) => { + let selection = facet.select_mode(mode); + let label = facet.mode_label(mode); + match selection { + CommandModeSelection::Changed => ConfigCommandResult::with_message_and_action( + format!("Switched to {label} mode."), + ConfigAction::ModeChanged(mode), + ), + CommandModeSelection::PersistedSame => { + ConfigCommandResult::message(facet.mode_startup_default_receipt(mode)) + } + CommandModeSelection::Refused => { + ConfigCommandResult::message(facet.setting_locked_mode_message()) + } + } } + None => ConfigCommandResult::error(format!("Usage: {MODE_USAGE}")), } +} - // Mirror the bundle into the live session via the per-key setter (the - // persisted write, if any, already happened atomically above, so this pass - // is session-only). - let mut applied = Vec::with_capacity(fields.len()); - for (key, value) in fields { - let result = set_config_value(app, key, value, false); - if result.is_error { - let message = result - .message - .unwrap_or_else(|| "unknown apply error".to_string()); - return CommandResult::error(format!( - "Failed to apply preset field {key}={value}: {message}" - )); - } - applied.push(format!("{key}={value}")); - } +/// `/mode` usage, shared with the registration metadata. +const MODE_USAGE: &str = "/mode [work|plan|operate|1|2|3]"; - let suffix = if persist { - " (saved)" - } else { - " (session only — add --save to persist)" +/// `/pet` usage, shared with the registration metadata. +pub const PET_USAGE: &str = "/pet [on|off|inspect|status|appearance|style [smooth|pixel]|language [auto|en|zh-Hans|zh-Hant|ja]|window|source|export|sound [on|off]|avatar [whale|cove|braille|pixels|particles|whale-girl|key]|action [name|live]|view [name|live]]"; + +/// `/connect []` — sign in to an enabled plugin provider. +pub fn connect(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + if raw.split_whitespace().count() > 1 { + return ConfigCommandResult::error("Usage: /connect []"); + } + let mut parts = contexts.into_parts(); + let Some(facet) = parts.login.as_deref_mut() else { + return missing("login"); }; - CommandResult::message(format!( - "Applied '{name}' transcript preset{suffix}: {}. Thinking stays visible and tool runs stay expandable.", - applied.join(", ") - )) + let provider = (!raw.is_empty()).then_some(raw); + let reply = facet.connect(provider); + to_result(reply, &mut parts, &PreparedTemplates::default()) } -/// Show the current value of a single setting. -fn config_context_window_override(app: &App) -> Option { - let mut config = Config::load(app.config_path.clone(), app.config_profile.as_deref()).ok()?; - let identity = app.admitted_provider_identity().ok()?; - config.scope_to_provider_identity(identity).ok()?; - config.context_window_for_provider_config(identity) +/// `/disconnect ` — sign out of an enabled plugin provider. +pub fn disconnect(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + if raw.is_empty() || raw.split_whitespace().count() > 1 { + return ConfigCommandResult::error("Usage: /disconnect "); + } + let mut parts = contexts.into_parts(); + let Some(facet) = parts.login.as_deref_mut() else { + return missing("login"); + }; + let reply = facet.disconnect(raw); + to_result(reply, &mut parts, &PreparedTemplates::default()) } -fn show_single_setting(app: &App, key: &str) -> CommandResult { - let key = key.to_lowercase(); - if let Some(subagent_key) = key.strip_prefix("subagents.") { - return show_subagents_setting(app, subagent_key); - } - if let Some(notifications_key) = key.strip_prefix("notifications.") { - return show_notifications_setting(app, notifications_key); - } - fn locale_display(l: codewhale_localization::Locale) -> &'static str { - match l { - codewhale_localization::Locale::En => "en", - codewhale_localization::Locale::ZhHans => "zh-Hans", - codewhale_localization::Locale::ZhHant => "zh-Hant", - codewhale_localization::Locale::Ja => "ja", - codewhale_localization::Locale::PtBr => "pt-BR", - codewhale_localization::Locale::Es419 => "es-419", - codewhale_localization::Locale::Vi => "vi", - codewhale_localization::Locale::Ko => "ko", - codewhale_localization::Locale::Ca => "ca", - codewhale_localization::Locale::De => "de", - codewhale_localization::Locale::Fr => "fr", - codewhale_localization::Locale::Id => "id", - codewhale_localization::Locale::Hi => "hi", - codewhale_localization::Locale::Ru => "ru", - codewhale_localization::Locale::Uk => "uk", - } - } - fn density_display(d: crate::tui::app::ComposerDensity) -> &'static str { - match d { - crate::tui::app::ComposerDensity::Compact => "compact", - crate::tui::app::ComposerDensity::Comfortable => "comfortable", - crate::tui::app::ComposerDensity::Spacious => "spacious", - } +/// `/theme [name|underwater|custom:|schema|path]`. +/// `/theme [name|underwater|custom:|schema|path]`. +pub fn theme(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let mut parts = contexts.into_parts(); + if parts.config.is_none() { + return missing("config"); } - fn spacing_display(s: crate::tui::app::TranscriptSpacing) -> &'static str { - match s { - crate::tui::app::TranscriptSpacing::Compact => "compact", - crate::tui::app::TranscriptSpacing::Comfortable => "comfortable", - crate::tui::app::TranscriptSpacing::Spacious => "spacious", - } - } - let value = match key.as_str() { - "model" => { - if app.auto_model { - let mut label = "auto (auto-select model per turn)".to_string(); - if let Some(effective) = app.last_effective_model.as_deref() - && effective != "auto" - { - label.push_str(&format!("; last: {effective}")); - } - Some(label) - } else { - Some(app.model.clone()) + match arg.map(str::trim).filter(|s| !s.is_empty()) { + // `schema`/`path`/no-arg render no config message, so they do not + // depend on the config translation templates. + None => ConfigCommandResult::action(ConfigAction::OpenThemePicker), + Some("schema") => match parts.config.as_deref_mut() { + Some(facet) => ConfigCommandResult::message(facet.theme_schema()), + None => missing("config"), + }, + Some("path") => { + let Some(facet) = parts.config.as_deref_mut() else { + return missing("config"); + }; + match facet.themes_dir() { + Ok(path) => ConfigCommandResult::message(format!( + "User themes: {path}\nSelect with: /theme custom:" + )), + Err(error) => ConfigCommandResult::error(error), } } - "provider" => Some(app.provider_identity_for_persistence().to_string()), - "approval_mode" | "approval" => Some(app.approval_mode.permission_chip_label().to_string()), - "allow_shell" | "shell" | "exec_shell" => Some(app.allow_shell.to_string()), - "base_url" => { - let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) - { - Ok(config) => config, - Err(err) => { - return CommandResult::error(format!("Failed to load config: {err}")); - } + Some(name) => { + let prepared = match preflight(&mut parts) { + Ok(prepared) => prepared, + Err(result) => return result, }; - Some(config.active_route_base_url()) - } - // `/config title` reports the config-level default, not a session's - // `/title` override. The latter is intentionally a separate setting - // and is reported by bare `/title`. - "title" | "window_title" | "tab_title" => Some( - app.title_default - .clone() - .unwrap_or_else(|| "(unset)".to_string()), - ), - "provider_url" | "provider_base_url" | "endpoint" => { - let config = match Config::load(app.config_path.clone(), app.config_profile.as_deref()) - { - Ok(mut config) => { - config.provider = Some(app.provider_identity_for_persistence().to_string()); - config - } - Err(err) => { - return CommandResult::error(format!("Failed to load config: {err}")); - } + let Some(facet) = parts.config.as_deref_mut() else { + return missing("config"); }; - Some(config.active_route_base_url()) - } - "context_window" | "context_window_tokens" => Some(format!( - "{} (effective {} from {})", - config_context_window_override(app) - .map_or_else(|| "not set".to_string(), |tokens| tokens.to_string()), - crate::route_budget::route_context_window_tokens( - app.api_provider, - app.effective_model_for_budget(), - app.active_route_limits, - ), - app.active_context_window_source.display_label(), - )), - "stream_chunk_timeout_secs" => Some(app.stream_chunk_timeout_secs.to_string()), - "posture_bar" => Some(app.posture_bar.as_setting().to_string()), - "metrics_line" => Some(app.metrics_line.as_setting().to_string()), - "locale" | "language" => Some(locale_display(app.ui_locale).to_string()), - "theme" | "ui_theme" => Some( - if app - .theme_name - .starts_with(codewhale_palette::USER_THEME_PREFIX) - { - app.theme_name.clone() - } else { - codewhale_palette::theme_label_for_mode(app.ui_theme.mode).to_string() - }, - ), - "background_color" | "background" | "bg" => { - codewhale_palette::hex_rgb_string(app.ui_theme.surface_bg) - .or_else(|| Some("(default)".to_string())) - } - "auto_compact" | "compact" => { - Some(if app.auto_compact { "true" } else { "false" }.to_string()) - } - "calm_mode" | "calm" => Some(if app.calm_mode { "true" } else { "false" }.to_string()), - "pet_mode" => Some(app.pet_watch.enabled.to_string()), - "low_motion" | "motion" => Some(if app.low_motion { "true" } else { "false" }.to_string()), - "fancy_animations" | "fancy" | "animations" => Some( - if app.fancy_animations { - "true" - } else { - "false" - } - .to_string(), - ), - "bracketed_paste" | "paste" => Some( - if app.use_bracketed_paste { - "true" - } else { - "false" - } - .to_string(), - ), - "paste_burst_detection" | "paste_burst" => Some( - if app.use_paste_burst_detection { - "true" - } else { - "false" - } - .to_string(), - ), - "show_thinking" | "thinking" => { - Some(if app.show_thinking { "true" } else { "false" }.to_string()) - } - "thinking_default_expanded" | "thinking_expanded" => Some( - if app.thinking_default_expanded { - "true" - } else { - "false" - } - .to_string(), - ), - "thinking_preview_lines" | "thinking_preview" => { - Some(app.thinking_preview_lines.to_string()) - } - "help_expand_groups" | "help_expanded" => Some( - if app.help_expand_groups { - "true" - } else { - "false" - } - .to_string(), - ), - "contextual_tips" => Some(app.behavioral_tips.enabled().to_string()), - "pin_last_prompt" | "pin_prompt" => { - Some(if app.pin_last_prompt { "true" } else { "false" }.to_string()) - } - "thinking_highlight" | "reasoning_highlight" => Some( - if app.thinking_highlight { - "true" - } else { - "false" - } - .to_string(), - ), - "show_tool_details" | "tool_details" => Some( - if app.show_tool_details { - "true" - } else { - "false" - } - .to_string(), - ), - "inline_diffs" | "inline_diff" | "diffs" => { - Some(app.inline_diff_mode.as_setting().to_string()) - } - "mode" | "default_mode" => Some(app.mode.as_setting().to_string()), - "max_history" | "history" => Some(app.max_input_history.to_string()), - "work_surface_placement" | "work_surface" | "work_rail" => { - Some(app.work_surface.placement.as_setting().to_string()) - } - "rail_panel" | "rail" => Some(app.work_surface.panel.as_setting().to_string()), - "work_surface_top_height" | "work_top_height" => { - Some(app.work_surface.top_height.to_string()) - } - "work_surface_side_width" | "work_side_width" => { - Some(app.work_surface.side_width.to_string()) - } - "tool_collapse" | "tool_collapse_mode" | "collapse" => { - Some(app.tool_collapse_mode.as_setting().to_string()) - } - "context_panel" | "context" | "session_panel" => { - Some(if app.context_panel { "true" } else { "false" }.to_string()) - } - "sessions_rail" | "sessions_panel" | "session_rail" => { - Some(if app.sessions_rail { "true" } else { "false" }.to_string()) - } - // Read the persisted value rather than reporting a hard-coded default: - // this setting is consumed at startup by `main`, so `App` has no live - // copy, and printing "false" unconditionally would misreport a user who - // has it on. - "session_auto_resume" | "auto_resume" => Some( - if crate::settings::Settings::load_persisted() - .map(|settings| settings.session_auto_resume) - .unwrap_or(false) - { - "true" - } else { - "false" - } - .to_string(), - ), - "composer_density" | "composer" => Some(density_display(app.composer_density).to_string()), - "composer_border" | "border" => { - Some(if app.composer_border { "true" } else { "false" }.to_string()) - } - "composer_multiline_mode" | "multiline_mode" | "multiline" => Some( - if app.composer_multiline_mode { - "true" - } else { - "false" - } - .to_string(), - ), - "composer_vim_mode" | "vim_mode" | "vim" => Some( - if app.composer.vim_enabled { - "vim" - } else { - "normal" - } - .to_string(), - ), - "transcript_spacing" | "spacing" => { - Some(spacing_display(app.transcript_spacing).to_string()) - } - "status_indicator" | "indicator" => Some(app.status_indicator.clone()), - "synchronized_output" | "sync_output" | "sync" => Some( - if app.synchronized_output_enabled { - "on" - } else { - "off" - } - .to_string(), - ), - "cost_currency" | "currency" => Some( - match app.cost_currency { - crate::pricing::CostCurrency::Usd => "usd", - crate::pricing::CostCurrency::Cny => "cny", - } - .to_string(), - ), - "default_model" => match saved_deepseek_default_model(app) { - Ok(model) => Some(model), - Err(error) => return CommandResult::error(error), - }, - "reasoning_effort" | "effort" => Some( - app.reasoning_effort - .as_setting_for_provider(app.api_provider) - .to_string(), - ), - "workspace_follow_symlinks" | "follow_symlinks" => Settings::load().ok().map(|settings| { - format!( - "{} (restart required for engine tools)", - settings.workspace_follow_symlinks - ) - }), - "search" | "search.provider" | "search_provider" => load_command_config(app) - .ok() - .map(|config| search_provider_display(&config, app.ui_locale)), - "telemetry" => load_command_config(app) - .ok() - .map(|config| crate::telemetry_notice::saved_preference_enabled(&config).to_string()), - "prompt_suggestion" => load_command_config(app) - .ok() - .map(|config| prompt_suggestion_display(&config)), - "notifications" => Some(notifications_summary_value(&app.notification_settings)), - _ => { - // Any spelling `/set` accepts; internal flags, actions and - // receipts in the schema are not settings a user can look up. - let known = Settings::canonical_key(&key).is_some(); - if known { - Some("(see /settings for current value)".to_string()) - } else { - None - } + to_result(facet.set_value("theme", name, true), &mut parts, &prepared) } - }; - match value { - Some(v) => CommandResult::message(format!("{key} = {v}")), - None => CommandResult::error(unknown_setting_message(&key)), - } -} - -/// Error for `/config ` when `key` is not a known setting: name the -/// closest real key when there is one, and point at the full list. -fn unknown_setting_message(key: &str) -> String { - // Suggest only keys `/set` accepts, the same set `known` checks above - // (#6563). - let nearest = crate::config_keys::nearest_key(key, crate::config_keys::settings_toml_keys()); - match nearest { - Some(candidate) => format!( - "Unknown setting '{key}'. Did you mean `/config {candidate}`? Run `/settings text` to list every setting." - ), - None => format!("Unknown setting '{key}'. Run `/settings text` to list every setting."), } } -/// Open the typed settings editor. `text` preserves the legacy diagnostic -/// output for scripts and terminals that cannot render the modal. -pub fn settings_command(app: &mut App, arg: Option<&str>) -> CommandResult { - match arg.map(str::trim).filter(|value| !value.is_empty()) { - None => CommandResult::action(AppAction::OpenConfigView), - Some("text" | "show" | "diagnostic" | "diagnostics") => show_settings(app), - Some(_) => CommandResult::error("Usage: /settings [text]"), - } -} - -/// Show persistent settings as plain text (legacy compatibility path). -pub fn show_settings(app: &mut App) -> CommandResult { - match Settings::load() { - Ok(settings) => CommandResult::message(settings.display(app.ui_locale)), - Err(e) => CommandResult::error(format!("Failed to load settings: {e}")), - } -} - -/// Open the `/statusline` multi-select picker for configuring footer items. -pub fn status_line(_app: &mut App) -> CommandResult { - CommandResult::action(AppAction::OpenStatusPicker) -} - -/// Toggle whether the live transcript renders full thinking detail. -pub fn verbose(app: &mut App, arg: Option<&str>) -> CommandResult { +/// `/verbose [on|off]`. +/// +/// The command keeps its own parsing and message composition; the facet only +/// reads and writes the flag (FEAT-028: keep logic in the command, replace only +/// the surgical host touch). +pub fn verbose(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let mut parts = contexts.into_parts(); + if parts.config.is_none() { + return missing("config"); + } + let Some(facet) = parts.config.as_deref_mut() else { + return missing("config"); + }; let next = match arg.map(str::trim).filter(|s| !s.is_empty()) { - None => !app.verbose_transcript, + None => !facet.verbose_enabled(), Some(raw) => match raw.to_ascii_lowercase().as_str() { "on" | "true" | "1" | "yes" => true, "off" | "false" | "0" | "no" => false, - "toggle" => !app.verbose_transcript, + "toggle" => !facet.verbose_enabled(), _ => { - return CommandResult::error( - "Usage: /verbose [on|off]. Compact thinking remains available when verbose is off.", - ); + return ConfigCommandResult { + message: Some( + "Usage: /verbose [on|off]. Compact thinking remains available when verbose is off." + .to_string(), + ), + action: None, + is_error: true, + }; } }, }; - - app.verbose_transcript = next; - app.mark_history_updated(); - CommandResult::message(if next { + facet.set_verbose_enabled(next); + ConfigCommandResult::message(if next { "Verbose transcript on: live thinking renders in full." } else { "Verbose transcript off: live thinking stays compact." }) } -/// `/fullscreen` and `/inline`: move the TUI between the alternate screen and -/// a full-height inline viewport. -/// -/// The terminal transition happens where the ratatui terminal lives — this -/// only emits the action, so a switch the terminal refuses can roll back and -/// explain itself there. -pub fn screen(app: &mut App, target: ScreenMode, arg: Option<&str>) -> CommandResult { +/// `/fullscreen`. +pub fn fullscreen(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + screen(contexts, false, arg) +} + +/// `/inline`. +pub fn inline(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + screen(contexts, true, arg) +} + +/// The command keeps its argument check and the "already on" message; the facet +/// only reports the current screen mode. +fn screen(contexts: CommandContexts<'_>, inline: bool, arg: Option<&str>) -> ConfigCommandResult { + let target = if inline { + CommandScreenMode::Inline + } else { + CommandScreenMode::Fullscreen + }; + let name = if inline { "inline" } else { "fullscreen" }; if let Some(extra) = arg.map(str::trim).filter(|value| !value.is_empty()) { - return CommandResult::error(format!( - "/{} takes no argument (got {extra:?}). Use /fullscreen or /inline.", - target.as_str() - )); + return ConfigCommandResult { + message: Some(format!( + "/{name} takes no argument (got {extra:?}). Use /fullscreen or /inline." + )), + action: None, + is_error: true, + }; + } + let mut parts = contexts.into_parts(); + if parts.config.is_none() { + return missing("config"); } - if target == app.screen_mode { - return CommandResult::message(match target { - ScreenMode::Fullscreen => { + let Some(facet) = parts.config.as_deref_mut() else { + return missing("config"); + }; + if target == facet.screen_mode() { + return ConfigCommandResult::message(match target { + CommandScreenMode::Fullscreen => { "Already on the fullscreen screen (alternate screen). /inline keeps the terminal's own scrollback instead." } - ScreenMode::Inline => { + CommandScreenMode::Inline => { "Already inline: a full-height viewport with no alternate screen, so this terminal's scrollback survives the session. /fullscreen returns to the alternate screen." } }); } - CommandResult::action(AppAction::SetScreenMode(target)) + ConfigCommandResult::action(ConfigAction::SetScreenMode(target)) } -/// Place the workbar or pick its panel. -/// -/// `/workbar bottom|top|left|right|off` sets placement; `/workbar -/// tasks|agents|context|pinned` picks the panel. The two are orthogonal: -/// where the workbar sits and what it shows. `/rail` and `/sidebar` remain -/// registered as the aliases users know. -/// Bare `/workbar` reports the workbar's *actual* rendered state — never a -/// claim about a surface that cannot render. -pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult { +/// `/statusline` — pure: it only opens the statusline picker, so it declares no +/// capability and needs no host facet. +pub fn statusline(_arg: Option<&str>) -> ConfigCommandResult { + ConfigCommandResult { + message: None, + action: Some(ConfigAction::OpenStatusPicker), + is_error: false, + } +} + +/// `/workbar` (aliases `/rail`, `/sidebar`). +/// `/workbar [bottom|top|left|right|off|panel] [--save]` (aliases `/rail`, +/// `/sidebar`). The command owns token parsing and the status line; the facet +/// observes and applies the work-surface state. +pub fn workbar(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { const USAGE: &str = "Usage: /workbar [bottom|top|left|right|off|tasks|agents|terminal|context|pinned] [--save]"; let raw = arg.map(str::trim).unwrap_or(""); @@ -636,103 +804,261 @@ pub fn sidebar(app: &mut App, arg: Option<&str>) -> CommandResult { if persist { tokens.pop(); } - + let mut parts = contexts.into_parts(); + // `/workbar` status and its setter errors are literal, so it needs no + // presentation and must not depend on the config translation templates. + let prepared = PreparedTemplates::default(); + let Some(facet) = parts.work_surface.as_deref_mut() else { + return missing("work_surface"); + }; match tokens.as_slice() { - [] => return CommandResult::message(rail_status_message(app)), + [] => return ConfigCommandResult::message(rail_status(facet)), [value] => { let value = value.to_ascii_lowercase(); - // Legacy focus words map onto the closest workbar concept so muscle - // memory keeps working: "on" restores the default bottom workbar, - // "off" hides it, panel names select panels. let placement = match value.as_str() { - "top" => Some(crate::tui::work_surface::WorkSurfacePlacement::Top), - "bottom" | "on" | "show" | "visible" => { - Some(crate::tui::work_surface::WorkSurfacePlacement::Bottom) - } - "left" => Some(crate::tui::work_surface::WorkSurfacePlacement::Left), - "right" => Some(crate::tui::work_surface::WorkSurfacePlacement::Right), + "top" => Some(CommandWorkSurfacePlacement::Top), + "bottom" | "on" | "show" | "visible" => Some(CommandWorkSurfacePlacement::Bottom), + "left" => Some(CommandWorkSurfacePlacement::Left), + "right" => Some(CommandWorkSurfacePlacement::Right), "off" | "hide" | "hidden" | "closed" | "none" => { - Some(crate::tui::work_surface::WorkSurfacePlacement::Off) + Some(CommandWorkSurfacePlacement::Off) } _ => None, }; let panel = match value.as_str() { "tasks" | "activity" | "live" | "running" | "pinned" | "work" | "plan" - | "todos" => Some(crate::tui::work_surface::RailPanel::Tasks), - "agents" | "subagents" | "sub-agents" => { - Some(crate::tui::work_surface::RailPanel::Agents) - } - "background" | "shells" | "jobs" => { - Some(crate::tui::work_surface::RailPanel::Background) - } - "files" | "changes" => Some(crate::tui::work_surface::RailPanel::Files), - "terminal" | "terminals" => Some(crate::tui::work_surface::RailPanel::Terminal), - "notepad" | "notes" => Some(crate::tui::work_surface::RailPanel::Notepad), - "context" | "session" => Some(crate::tui::work_surface::RailPanel::Context), - "git" | "branch" => Some(crate::tui::work_surface::RailPanel::Git), - "price" | "cost" => Some(crate::tui::work_surface::RailPanel::Price), + | "todos" => Some(CommandRailPanel::Tasks), + "agents" | "subagents" | "sub-agents" => Some(CommandRailPanel::Agents), + "background" | "shells" | "jobs" => Some(CommandRailPanel::Background), + "files" | "changes" => Some(CommandRailPanel::Files), + "terminal" | "terminals" => Some(CommandRailPanel::Terminal), + "notepad" | "notes" => Some(CommandRailPanel::Notepad), + "context" | "session" => Some(CommandRailPanel::Context), + "git" | "branch" => Some(CommandRailPanel::Git), + "price" | "cost" => Some(CommandRailPanel::Price), _ => None, }; match (placement, panel) { (Some(placement), None) => { - app.work_surface.placement = placement; - app.work_surface.focused = false; - if persist { - let result = set_config_value( - app, - "work_surface_placement", - placement.as_setting(), - true, - ); - if result.is_error { - return result; - } + // Surface a setter error whether or not `--save` was given; + // discarding it would report a success that did not happen. + let reply = facet.set_placement(placement, persist); + if reply.is_error { + return to_result(reply, &mut parts, &prepared); } } (None, Some(panel)) => { - crate::tui::work_surface::select_dock_panel(app, panel); - if persist { - let result = set_config_value(app, "rail_panel", panel.as_setting(), true); - if result.is_error { - return result; - } + let reply = facet.set_panel(panel, persist); + if reply.is_error { + return to_result(reply, &mut parts, &prepared); } } - _ => return CommandResult::error(USAGE), + _ => return ConfigCommandResult::error(USAGE), } } - _ => return CommandResult::error(USAGE), + _ => return ConfigCommandResult::error(USAGE), + } + facet.mark_redraw(); + ConfigCommandResult::message(rail_status(facet)) +} + +fn rail_status(facet: &dyn CommandWorkSurfaceContext) -> String { + let placement = facet.placement(); + if placement == CommandWorkSurfacePlacement::Off { + return "Workbar is off — no panel renders (/workbar bottom|top|left|right to show it)" + .to_string(); + } + let panel = facet.panel(); + let mut message = format!( + "Workbar: {} placement, {} panel", + placement.as_setting(), + facet.panel_title(panel) + ); + let effective = facet.effective_placement(); + if effective != placement && effective == CommandWorkSurfacePlacement::Top { + message.push_str(" — side placements need a wider terminal, showing top for now"); + } + if !facet.area_present() { + if panel == CommandRailPanel::Tasks { + message.push_str(" (currently hidden — no work to show)"); + } else { + message.push_str(" (renders next frame)"); + } } + message +} - app.needs_redraw = true; - CommandResult::message(rail_status_message(app)) +/// `/login [status|account|key]`. +/// `/login [status|account|key]`. The command composes the report; the login +/// facet returns the typed account state and provider identity. +pub fn login(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + let token = raw.split_whitespace().next().unwrap_or(""); + let mut parts = contexts.into_parts(); + let Some(facet) = parts.login.as_deref_mut() else { + return missing("login"); + }; + match token { + "" | "status" => { + let status = facet.login_status(); + let account = match status.account { + CommandAccountState::SignedOut => { + format!("not signed in (api {})", status.api_base) + } + CommandAccountState::Authenticated => { + format!("signed in (api {})", status.api_base) + } + CommandAccountState::OfflineCached => { + format!("offline cached (api {})", status.api_base) + } + CommandAccountState::Expired => format!("expired (api {})", status.api_base), + CommandAccountState::Revoked => format!("revoked (api {})", status.api_base), + CommandAccountState::Unavailable(error) => format!("unavailable ({error})"), + }; + ConfigCommandResult::message(format!( + "Codewhale login\nAccount: {account}\nActive provider: {}\n\n\ + Sign in: `codewhale login`\n\ + Provider key: `codewhale auth set --provider ` or `/login key`\n\ + Sign out: `/logout` or `codewhale logout`", + status.provider + )) + } + "key" | "provider" => ConfigCommandResult::with_message_and_action( + "Open the provider picker to store an API key. Account sign-in is `codewhale login`.", + ConfigAction::OpenProviderPicker, + ), + "account" => ConfigCommandResult::message( + "TUI cannot start the browser device flow without freezing the session.\n\ + Run `codewhale login` (same as `codewhale account login`) in a terminal.\n\ + Then `/login` to confirm the session landed." + .to_string(), + ), + other => ConfigCommandResult::error(format!( + "Usage: /login [status|account|key]\nUnknown argument: {other}" + )), + } } -fn select_pet_mode(app: &mut App, enabled: bool, persist: bool) -> CommandResult { - crate::tui::pet_watch::set_enabled(app, enabled); - if persist { - app.startup_defaults - .spawn(crate::tui::startup_defaults::StartupDefaults::pet_mode( - enabled, - )); +/// `/logout`. The command composes the confirmation; the login facet clears the +/// provider key, account session and internal token and reports each result. +pub fn logout(contexts: CommandContexts<'_>, _arg: Option<&str>) -> ConfigCommandResult { + let mut parts = contexts.into_parts(); + let Some(facet) = parts.login.as_deref_mut() else { + return missing("login"); + }; + match facet.logout() { + Ok(outcome) => { + let mut cleared = vec![format!("provider key ({})", outcome.provider)]; + match outcome.account { + Ok(true) => cleared.push("Codewhale account session".to_string()), + Ok(false) => {} + Err(error) => cleared.push(format!("account session not cleared ({error})")), + } + match outcome.legacy_sandbox { + Ok(true) => cleared.push("legacy sandbox token".to_string()), + Ok(false) => {} + Err(error) => cleared.push(format!("legacy sandbox token not cleared ({error})")), + } + ConfigCommandResult::with_message_and_action( + format!( + "Cleared {}. Use `codewhale login` to sign in again, or `codewhale auth set --provider ` to store a provider key.", + cleared.join(", ") + ), + ConfigAction::OpenProviderPicker, + ) + } + Err(failure) => ConfigCommandResult::error(format!( + "Failed to clear API key for {}: {}", + failure.provider, failure.error + )), } - CommandResult::message(tr( - app.ui_locale, - if enabled { - MessageId::PetModeOn +} + +/// `/import-claude [--apply]`. +/// `/import-claude [--apply]`. The command owns the flow and messages; the facet +/// builds the plan, writes the artifacts and carries over the standing +/// instructions. +pub fn import_claude(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let apply = arg.map(str::trim).is_some_and(|arg| { + arg.eq_ignore_ascii_case("--apply") || arg.eq_ignore_ascii_case("apply") + }); + let mut parts = contexts.into_parts(); + if parts.config.is_none() { + return missing("config"); + } + let Some(facet) = parts.config.as_deref_mut() else { + return missing("config"); + }; + let Some(plan) = facet.build_plan() else { + return ConfigCommandResult::message( + "No Claude configuration found to import (looked for ~/.claude.json and ~/.claude/settings.json).", + ); + }; + let report = super::import_claude::report_markdown(&plan, &|path| facet.display_path(path)); + let bundle = if plan.env_safe.is_empty() { + None + } else { + Some(super::import_claude::portable_bundle_json(&plan)) + }; + let artifacts = facet.write_artifacts(&report, bundle.as_deref()); + if apply { + let outcome = facet.apply_import(&plan, &artifacts.report_path, artifacts.wrote_report); + let mut message = String::new(); + if outcome.done.is_empty() { + message.push_str("Nothing was applied.\n"); } else { - MessageId::PetModeOff - }, - )) + message.push_str("Applied:\n"); + for item in &outcome.done { + message.push_str(&format!(" · {}\n", render_follow_up(item))); + } + } + if !outcome.manual.is_empty() { + message.push_str("\nStill yours to do:\n"); + for item in &outcome.manual { + message.push_str(&format!(" · {}\n", render_follow_up(item))); + } + } + if !outcome.done.is_empty() { + message.push_str( + "\nRun /mcp reload to connect the imported servers after reviewing them.", + ); + } + if outcome.wrote_report { + message.push_str(&format!( + "\nFull report: {}", + facet.display_path(&outcome.report_path) + )); + } + return ConfigCommandResult::message(message); + } + let mut message = super::import_claude::render_plan(&plan, &|path| facet.display_path(path)); + message.push_str( + "\n\nNothing above is applied yet. `/import-claude --apply` carries over the \ + standing instructions. Review MCP servers individually with `/mcp import`; hooks and \ + permission rules stay manual because they run code.", + ); + if artifacts.wrote_report { + message.push_str(&format!( + "\nFull report: {}", + facet.display_path(&artifacts.report_path) + )); + } else { + message.push_str("\n(the report could not be written; the plan above is complete)"); + } + if artifacts.wrote_bundle { + message.push_str(&format!( + "\nPortable bundle (review, then `codewhale config import {}`): apply it there for the consent/rollback path.", + facet.display_path(&artifacts.bundle_path) + )); + } + ConfigCommandResult::message(message) } -/// `/pet on` makes the pet the main shell surface, with the real composer -/// always available. `/pet inspect` opens replies and agents on demand; -/// Escape returns to the same backdrop and draft. Companion verbs keep the -/// existing shared simulation, appearance, replay and single audio lease. -pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult { - use crate::tui::pet_watch::{self, Control}; - let usage = || CommandResult::error(format!("Usage: {}", super::PET_INFO.usage)); +/// `/pet [on|off|...]`. +/// `/pet [on|off|inspect|status|appearance|window|source|export|sound on|off|avatar [key]|action [name|live]|view [name|live]]`. +/// The command owns the verb parsing and message composition; the pet facet +/// observes and mutates the pet state and resolves the localized labels. +pub fn pet(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { let words = arg .map(str::trim) .unwrap_or("") @@ -740,92 +1066,101 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult { .map(str::to_ascii_lowercase) .collect::>(); let words = words.iter().map(String::as_str).collect::>(); - let mode = |app: &mut App, enabled: bool| select_pet_mode(app, enabled, true); - let queued = |app: &mut App, control: Control| { - if pet_watch::command(app, control) { - CommandResult::message(tr(app.ui_locale, MessageId::PetHabitatQueued)) + let mut parts = contexts.into_parts(); + let Some(facet) = parts.pet.as_deref_mut() else { + return missing("pet"); + }; + let usage = || ConfigCommandResult::error(format!("Usage: {PET_USAGE}")); + let queued = |facet: &mut dyn codewhale_command_contract::config::CommandPetContext, + control: CommandPetControl| + -> ConfigCommandResult { + if facet.queue(control) { + ConfigCommandResult::message(facet.label(CommandPetLabel::HabitatQueued)) } else { - CommandResult::error(tr(app.ui_locale, MessageId::PetWatchUnavailable)) + ConfigCommandResult::error(facet.label(CommandPetLabel::WatchUnavailable)) } }; match words.as_slice() { ["inspect"] => { - pet_watch::open_habitat(app); - CommandResult::ok() + facet.open_habitat(); + ConfigCommandResult::ok() } [] => { - let enabled = !app.pet_watch.enabled; - mode(app, enabled) + let enabled = !facet.enabled(); + facet.set_enabled(enabled, true); + ConfigCommandResult::message(facet.label(if enabled { + CommandPetLabel::ModeOn + } else { + CommandPetLabel::ModeOff + })) + } + ["on"] => { + facet.set_enabled(true, true); + ConfigCommandResult::message(facet.label(CommandPetLabel::ModeOn)) + } + ["off"] => { + facet.set_enabled(false, true); + ConfigCommandResult::message(facet.label(CommandPetLabel::ModeOff)) } - ["on"] => mode(app, true), - ["off"] => mode(app, false), - ["status"] => CommandResult::message(format!( + ["status"] => ConfigCommandResult::message(format!( "{} · {} · {}", - tr( - app.ui_locale, - if app.pet_watch.enabled { - MessageId::PetModeOnLabel - } else { - MessageId::PetModeOffLabel - } - ), - tr( - app.ui_locale, - if pet_watch::is_open(app) { - MessageId::PetViewOpen - } else { - MessageId::PetViewClosed - } - ), - app.pet_watch.status() + facet.label(if facet.enabled() { + CommandPetLabel::ModeOnLabel + } else { + CommandPetLabel::ModeOffLabel + }), + facet.label(if facet.is_open() { + CommandPetLabel::ViewOpen + } else { + CommandPetLabel::ViewClosed + }), + facet.status() )), - ["avatar"] => CommandResult::message(app.pet_watch.avatar_choices()), + ["avatar"] => ConfigCommandResult::message(facet.avatar_choices()), ["avatar", key] => { - if !app.pet_watch.select_avatar(key) { + if !facet.select_avatar(key) { return usage(); } - app.needs_redraw = true; - CommandResult::message(format!("/pet avatar {key}")) + ConfigCommandResult::message(format!("/pet avatar {key}")) } [kind @ ("action" | "view"), name] => { - if !app.pet_watch.preview_avatar(name, *kind == "view") { + if !facet.preview_avatar(name, *kind == "view") { return usage(); } - app.needs_redraw = true; - CommandResult::message(format!("/pet {kind} {name}")) + ConfigCommandResult::message(format!("/pet {kind} {name}")) } - ["appearance"] => queued(app, Control::Browser), + ["appearance"] => queued(facet, CommandPetControl::Browser), ["style", value] => { - let Some(body) = pet_watch::BodyStyle::parse(value) else { + let Some(body) = CommandPetBodyStyle::parse(value) else { return usage(); }; queued( - app, - Control::Appearance { + facet, + CommandPetControl::Appearance { body: Some(body), language: None, }, ) } ["language", value] => { - let Some(language) = pet_watch::TextLanguage::parse(value) else { + let Some(language) = CommandPetTextLanguage::parse(value) else { return usage(); }; queued( - app, - Control::Appearance { + facet, + CommandPetControl::Appearance { body: None, language: Some(language), }, ) } - ["window"] => queued(app, Control::Window), - ["source"] => queued(app, Control::Select), + ["window"] => queued(facet, CommandPetControl::Window), + ["source"] => queued(facet, CommandPetControl::Select), ["export"] => { - if app.pet_watch.export() { - CommandResult::message(tr(app.ui_locale, MessageId::PetWatchExportQueued)) + if facet.export() { + ConfigCommandResult::message(facet.label(CommandPetLabel::ExportQueued)) } else { - CommandResult::error(tr(app.ui_locale, MessageId::PetWatchExportUnavailable)) + ConfigCommandResult::error(facet.label(CommandPetLabel::ExportUnavailable)) } } ["sound", rest @ ..] => { @@ -836,5874 +1171,899 @@ pub fn pet(app: &mut App, arg: Option<&str>) -> CommandResult { _ => return usage(), }; if let Some(enabled) = enabled { - app.pet_watch.set_sound(enabled); - app.needs_redraw = true; + facet.set_sound(enabled); } let label = if enabled == Some(true) { - MessageId::PetWatchSoundOn + facet.label(CommandPetLabel::SoundOn) } else { - app.pet_watch.sound_label() + facet.sound_label() }; - CommandResult::message(format!("{} · /pet sound on|off", tr(app.ui_locale, label))) + ConfigCommandResult::message(format!("{label} · /pet sound on|off")) } _ => usage(), } } -/// Truthful workbar readout: the placement and panel that actually render, -/// with the narrow-terminal fallback and an empty-Tasks collapse spelled out. -/// Never claims a panel is visible when no workbar area was produced. -fn rail_status_message(app: &App) -> String { - use crate::tui::work_surface::{RailPanel, WorkSurfacePlacement}; - - let placement = app.work_surface.placement; - if placement == WorkSurfacePlacement::Off { - return "Workbar is off — no panel renders (/workbar bottom|top|left|right to show it)" - .to_string(); - } - let panel = app.work_surface.panel; - let mut message = format!( - "Workbar: {} placement, {} panel", - placement.as_setting(), - panel.title() - ); - let effective = app.work_surface.effective_placement(); - if effective != placement && effective == WorkSurfacePlacement::Top { - message.push_str(" — side placements need a wider terminal, showing top for now"); - } - if app.work_surface.last_area.is_none() { - if panel == RailPanel::Tasks { - message.push_str(" (currently hidden — no work to show)"); - } else { - message.push_str(" (renders next frame)"); - } - } - message -} +/// `/trust [on|off|add|remove|list]` (alias `/xinren`). +/// +/// The command owns subcommand parsing and message composition; the facet only +/// reads and mutates the trust state and the allow-list. +pub fn trust(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandResult { + let raw = arg.map(str::trim).unwrap_or(""); + let mut split = raw.splitn(2, char::is_whitespace); + let sub = split.next().unwrap_or("").to_lowercase(); + let rest = split.next().map(str::trim).unwrap_or(""); -fn resolve_provider_url_value(provider: ProviderKind, value: &str) -> Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - return Err("provider_url cannot be empty".to_string()); - } + let mut parts = contexts.into_parts(); + let Some(facet) = parts.trust.as_deref_mut() else { + return missing("trust"); + }; - if provider == ProviderKind::XiaomiMimo { - match trimmed.to_ascii_lowercase().as_str() { - "token" | "token-plan" | "token_plan" | "token-plan-sgp" | "sgp" => { - return Ok(DEFAULT_XIAOMI_MIMO_BASE_URL.to_string()); + match sub.as_str() { + "" | "status" | "list" => { + let force_paths = sub == "list"; + let mut lines = Vec::new(); + lines.push(format!( + "Workspace trust mode: {}", + if facet.trust_mode() { + "enabled" + } else { + "disabled" + } + )); + let paths = facet.trusted_paths(); + if paths.is_empty() { + lines.push(if force_paths { + "No external paths trusted from this workspace.".to_string() + } else { + "No external paths trusted yet. Use `/trust add ` to allow a directory." + .to_string() + }); + } else { + lines.push(format!("Trusted external paths ({}):", paths.len())); + for path in paths { + lines.push(format!(" • {}", path.display())); + } + } + ConfigCommandResult::message(lines.join("\n")) + } + "on" | "enable" | "yes" | "y" | "off" | "disable" | "no" | "n" => { + if !matches!(rest, "" | "--save") { + return ConfigCommandResult::error(format!( + "{} /trust on|off [--save]", + facet.usage_label() + )); + } + ConfigCommandResult::action(ConfigAction::SetWorkspaceTrust { + trusted: matches!(sub.as_str(), "on" | "enable" | "yes" | "y"), + save: rest == "--save", + }) + } + "add" => { + if rest.is_empty() { + return ConfigCommandResult::error( + "Usage: /trust add . Supply an absolute path or a path relative to the workspace.", + ); } - "payg" | "pay-go" | "paygo" | "pay-as-you-go" | "pay_as_you_go" | "api" => { - return Ok(XIAOMI_MIMO_PAY_AS_YOU_GO_BASE_URL.to_string()); + match facet.add_trusted_path(rest) { + Ok(stored) => ConfigCommandResult::message(format!( + "Added to trust list for this workspace: {}", + stored.display() + )), + Err(TrustPathError::NotFound(path)) => ConfigCommandResult::error(format!( + "Path not found: {} — supply an existing directory or file.", + path.display() + )), + Err(TrustPathError::UpdateFailed(err)) => { + ConfigCommandResult::error(format!("Failed to update trust list: {err}")) + } } - _ => {} } - } - - if trimmed.contains("://") { - Ok(trimmed.to_string()) - } else if provider == ProviderKind::XiaomiMimo { - Err("provider_url for Xiaomi MiMo must be token-plan, pay-as-you-go, or a URL".to_string()) - } else { - Err("provider_url must be a URL".to_string()) - } -} - -fn parse_config_bool(value: &str) -> Result { - match value.trim().to_ascii_lowercase().as_str() { - "on" | "true" | "yes" | "1" | "enabled" => Ok(true), - "off" | "false" | "no" | "0" | "disabled" => Ok(false), - _ => Err(format!( - "Failed to parse boolean '{value}': expected on/off, true/false, yes/no." + "remove" | "rm" | "del" | "delete" => { + if rest.is_empty() { + return ConfigCommandResult::error("Usage: /trust remove "); + } + match facet.remove_trusted_path(rest) { + Ok((path, true)) => ConfigCommandResult::message(format!( + "Removed from trust list: {}", + path.display() + )), + Ok((path, false)) => { + ConfigCommandResult::message(format!("Not in trust list: {}", path.display())) + } + Err(TrustPathError::UpdateFailed(err)) => { + ConfigCommandResult::error(format!("Failed to update trust list: {err}")) + } + Err(TrustPathError::NotFound(_)) => { + ConfigCommandResult::error("Usage: /trust remove ") + } + } + } + other => ConfigCommandResult::error(format!( + "Unknown /trust action `{other}`. Use `/trust`, `/trust on|off [--save]`, `/trust add `, or `/trust remove `." )), } } -fn approval_mode_config_value(mode: ApprovalMode) -> &'static str { - match mode { - ApprovalMode::Auto => "auto", - ApprovalMode::Bypass => "bypass", - ApprovalMode::Suggest => "on-request", - ApprovalMode::Never => "never", +/// `/auth xai-device|chatgpt|chatgpt-revoke|claude|claude-revoke|orcarouter|orcarouter-revoke`. +/// Pure: selects a capability-launch action, no host observation. +pub fn auth(arg: Option<&str>) -> ConfigCommandResult { + let action = match arg.map(str::trim) { + Some("xai-device") | Some("xai_device") => ConfigAction::StartXaiDeviceLogin, + Some("claude") | Some("anthropic") => ConfigAction::StartClaudeLogin, + Some("claude-revoke") => ConfigAction::StartClaudeRevoke, + Some("chatgpt") | Some("openai-codex") | Some("openai_codex") => { + ConfigAction::StartChatgptPkceLogin + } + Some("chatgpt-revoke") | Some("chatgpt_revoke") => ConfigAction::StartChatgptRevoke, + Some("orcarouter") | Some("orca") => ConfigAction::StartOrcarouterPkceLogin, + Some("orcarouter-revoke") | Some("orcarouter_revoke") | Some("orca-revoke") => { + ConfigAction::StartOrcarouterRevoke + } + _ => { + return ConfigCommandResult { + message: Some( + "Usage: /auth xai-device|chatgpt|chatgpt-revoke|claude|claude-revoke|orcarouter|orcarouter-revoke" + .to_string(), + ), + action: None, + is_error: true, + }; + } + }; + ConfigCommandResult { + message: None, + action: Some(action), + is_error: false, } } -fn is_ask_rules_config_token(token: &str) -> bool { - matches!( - token.to_ascii_lowercase().as_str(), - "ask-rules" - | "ask_rules" - | "askrules" - | "rules" - | "permission-rules" - | "permission_rules" - | "permissions" - ) -} - -fn config_editability_audit(app: &App) -> CommandResult { - let config = match load_command_config(app) { - Ok(config) => config, - Err(err) => return CommandResult::error(err), - }; - let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) - .map(|path| path.display().to_string()) - .unwrap_or_else(|_| "(unresolved)".to_string()); - - let mut provider_config = config.clone(); - provider_config.provider = Some(app.provider_identity_for_persistence().to_string()); - let model = if app.auto_model { - "auto".to_string() - } else { - app.model.clone() - }; - let saved_permission_posture = Settings::load() - .ok() - .and_then(|settings| settings.permission_posture) - .unwrap_or_else(|| "(unset)".to_string()); - let configured_approval_policy = config - .approval_policy - .clone() - .unwrap_or_else(|| "(unset)".to_string()); - let effective_permissions = if app.mode == AppMode::Plan { - "Read Only" - } else { - app.approval_mode.permission_chip_label() - }; - let search_audit_note = tr(app.ui_locale, MessageId::ConfigAuditSearchProvider); - let prompt_audit_note = tr(app.ui_locale, MessageId::ConfigAuditPromptSuggestion); - let notifications_audit_note = tr(app.ui_locale, MessageId::ConfigAuditNotifications); - - let rows = [ - ( - "provider", - app.provider_identity_for_persistence().to_string(), - "session", - "/config provider ", - "Switches the active provider now; edit provider in config.toml for startup default.", - ), - ( - "model", - model, - "session", - "/config model ", - "Switches the active model now; use default_text_model in config.toml for startup default.", - ), - ( - "effective_permissions", - effective_permissions.to_string(), - "runtime", - "Shift+Tab", - "Shows the effective Act permission posture; Plan remains Read Only.", - ), - ( - "permission_posture", - saved_permission_posture, - "TUI settings", - "Shift+Tab", - "Saved in settings.toml and ignored when config/requirements manage approval policy.", - ), - ( - "approval_policy", - configured_approval_policy, - "persisted config", - "/config approval_mode --save", - "Top-level managed policy; Full Access is not a valid value here.", - ), - ( - "allow_shell", - app.allow_shell.to_string(), - "runtime+persisted", - "/config allow_shell --save", - "Writes top-level allow_shell and applies to subsequent turns.", - ), - ( - "stream_chunk_timeout_secs", - app.stream_chunk_timeout_secs.to_string(), - "runtime+persisted", - "/config stream_chunk_timeout_secs <0|1..3600> --save", - "Writes [stream].chunk_timeout_secs and updates the running stream timeout.", - ), - ( - "posture_bar", - app.posture_bar.as_setting().to_string(), - "runtime+persisted", - "/config posture_bar --save", - "Writes [tui].posture_bar; hidden gives the row to the transcript, compact keeps the posture chips only.", - ), - ( - "metrics_line", - app.metrics_line.as_setting().to_string(), - "runtime+persisted", - "/config metrics_line --save", - "Writes [tui].metrics_line; hidden gives the row to the transcript, compact drops secondary counts and help while keeping selected TTFT/rate readings when they fit. Choose readings with /statusline.", - ), - ( - "subagents.enabled", - subagents_config_display_value(&config, "enabled"), - "runtime+persisted", - "/config subagents on|off --save", - "Writes [subagents].enabled and updates subsequent sub-agent launches.", - ), - ( - "subagents.max_concurrent", - subagents_config_display_value(&config, "max_concurrent"), - "runtime+persisted", - "/config subagents max_concurrent --save", - "Clamped with Config::max_subagents and written to [subagents].max_concurrent.", - ), - ( - "subagents.max_depth", - subagents_config_display_value(&config, "max_depth"), - "runtime+persisted", - "/config subagents max_depth --save", - "Clamped to the configured spawn-depth ceiling.", - ), - ( - "subagents.launch_concurrency", - subagents_config_display_value(&config, "launch_concurrency"), - "runtime+persisted", - "/config subagents launch_concurrency --save", - "Clamped to the resolved sub-agent concurrency cap.", - ), - ( - "subagents.api_timeout_secs", - subagents_config_display_value(&config, "api_timeout_secs"), - "runtime+persisted", - "/config subagents api_timeout_secs --save", - "0 means the compiled default; non-zero values are clamped to the documented range.", - ), - ( - "subagents.heartbeat_timeout_secs", - subagents_config_display_value(&config, "heartbeat_timeout_secs"), - "runtime+persisted", - "/config subagents heartbeat_timeout_secs --save", - "0 means the compiled default; non-zero values are clamped to the documented range.", - ), - ( - "base_url", - config.active_route_base_url(), - "persisted restart", - "/config base_url --save", - "Writes top-level base_url; model clients read it on startup.", - ), - ( - "providers..base_url", - provider_config.active_route_base_url(), - "persisted restart", - "/config provider_url --save", - "Writes the active provider table; model clients read it on startup.", - ), - ( - "providers..context_window", - config_context_window_override(app) - .map_or_else(|| "(unset)".to_string(), |tokens| tokens.to_string()), - "persisted restart", - "edit [providers.] context_window = ", - "Overrides compaction, context-pressure, header, and preflight input budgets; use 262144 to cap a 1M route to 256K.", - ), - ( - "effective_context_window", - format!( - "{} ({})", - crate::route_budget::route_context_window_tokens( - app.api_provider, - app.effective_model_for_budget(), - app.active_route_limits, - ), - app.active_context_window_source.display_label(), - ), - "runtime", - "/config context_window", - "The shared resolved window used by every active-route budget surface.", - ), - ( - "mcp_config_path", - app.mcp_config_path.display().to_string(), - "persisted live reload", - "/config mcp_config_path --save", - "Run /mcp reload to rebuild the live model-visible tool pool.", - ), - ( - "workspace_follow_symlinks", - app.workspace_follow_symlinks.to_string(), - "partial restart", - "/config workspace_follow_symlinks --save", - "Updates TUI file completion now; engine tools require restart.", - ), - ( - "search.provider", - search_provider_display(&config, app.ui_locale), - "runtime+persisted", - "/config search.provider --save", - search_audit_note.as_ref(), +/// Render the `/config workflow` report from the typed effective facts. +pub fn render_workflow_summary(summary: CommandWorkflowSummary) -> String { + let on = |value: bool| if value { "on" } else { "off" }; + [ + "[workflow] — config.toml".to_string(), + format!( + "automatic = {} · the agent may start a workflow itself for broad or staged work; off means only /workflow starts one", + on(summary.automatic) ), - ( - "prompt_suggestion", - prompt_suggestion_display(&config), - "runtime+persisted", - "/config prompt_suggestion --save", - prompt_audit_note.as_ref(), + format!( + "auto_start_read_only = {} · read-only plans start without an approval card", + on(summary.auto_start_read_only) ), - ( - "notifications", - notifications_summary(&config), - "runtime+persisted", - "/config notifications --save", - notifications_audit_note.as_ref(), + format!( + "require_approval_for_writes = {} · plans that write, use shell/network, or elevate show an approval card first", + on(summary.require_approval_for_writes) ), - ( - "instructions", - file_only_status(config.instructions.as_ref().map(|v| !v.is_empty())), - "file-only restart", - "edit config.toml", - "Prompt layers are loaded before the first turn.", + format!( + "max_children = {} · max_concurrent = {} · max_depth = {} · hard ceilings for one run", + summary.max_children, summary.max_concurrent, summary.max_depth ), - ( - "hooks", - file_only_status(config.hooks.as_ref().map(|_| true)), - "file-only", - "edit config.toml", - "Hook definitions are structured TOML, not a scalar runtime setting.", + format!( + "default_token_budget = {} · shared admission cap for a run and its children (0 = none)", + summary.default_token_budget ), - ( - "network", - file_only_status(config.network.as_ref().map(|_| true)), - "file-only", - "edit config.toml", - "Network policy is evaluated by tool dispatch and should be reviewed as TOML.", + String::new(), + "[goal] — config.toml".to_string(), + format!( + "max_continuations = {} · automatic continuation passes before a goal pauses; 0 = unlimited (completion, blocked, or you stop it)", + summary.goal_max_continuations ), - ( - "tools", - file_only_status(config.tools.as_ref().map(|_| true)), - "file-only restart", - "edit config.toml", - "Tool catalog policy is built before model/tool negotiation.", + format!( + "enforce_token_budget = {} · true = a goal's token budget is a hard stop; false = advisory telemetry", + on(summary.goal_enforce_token_budget) ), - ( - "memory", - file_only_status(config.memory.as_ref().map(|_| true)), - "file-only restart", - "edit config.toml", - "Memory loading changes prompt context and is resolved at startup.", + ] + .join("\n") +} + +/// Compose the `/import-claude --apply` follow-up sentence for a typed fact. +fn render_follow_up(item: &codewhale_command_contract::config::CommandImportFollowUp) -> String { + use codewhale_command_contract::config::CommandImportFollowUp as FollowUp; + match item { + FollowUp::InstructionsCopied { destination } => { + format!("Standing instructions copied to {destination}.") + } + FollowUp::InstructionsKeptExisting { + destination, + source, + } => format!( + "{destination} already exists — left untouched; merge from {source} by hand if you want both." ), - ( - "runtime_api", - file_only_status(config.runtime_api.as_ref().map(|_| true)), - "file-only restart", - "edit config.toml", - "Serve/API tuning belongs to the runtime server startup path.", + FollowUp::InstructionsCopyFailed { + source, + destination, + } => format!("Could not copy {source} — copy it to {destination} by hand."), + FollowUp::HomeUnresolved => { + "Could not resolve the Codewhale home, so standing instructions were not copied." + .to_string() + } + FollowUp::McpBlocked { name } => { + format!("MCP `{name}` is disabled at its source and was not imported.") + } + FollowUp::McpNeedsReview { name } => format!( + "MCP `{name}` needs a separate review: run /mcp import, then use its displayed approval command. It will be imported OFF." ), - ( - "vision_model", - file_only_status(config.vision_model.as_ref().map(|_| true)), - "file-only restart", - "edit config.toml", - "Image-analysis provider clients are configured outside the scalar /config editor.", + FollowUp::HookEvents { count } => format!( + "{count} hook event(s) still need mapping with /hooks — hooks run code, so they are never imported for you." ), - ]; - - let mut lines = Vec::new(); - lines.push("Config editability audit".to_string()); - lines.push(format!("Config path: {config_path}")); - lines.push("Key | Current | Editability | Command / reason".to_string()); - for (key, current, editability, command, note) in rows { - lines.push(format!("{key} | {current} | {editability} | {command}")); - lines.push(format!(" {note}")); + FollowUp::PermissionRules => { + "Permission rules are listed in the report; apply the ones you want with /permissions." + .to_string() + } } - CommandResult::message(lines.join("\n")) } -fn file_only_status(configured: Option) -> String { - match configured { - Some(true) => "configured".to_string(), - Some(false) => "empty".to_string(), - None => "unset".to_string(), - } +/// A parsed `/config subagents` tail. +#[derive(Debug, PartialEq, Eq)] +enum SubagentOp<'a> { + Status, + Get(&'a str), + Set { key: &'a str, value: &'a str }, + Usage, } -fn search_provider_display(config: &Config, locale: codewhale_localization::Locale) -> String { - let resolved = config.search_provider_resolution(); - let source = match resolved.source { - SearchProviderSource::Default => tr(locale, MessageId::ConfigDefaultValue) - .trim_matches(&['(', ')'][..]) - .to_string(), - SearchProviderSource::Config => "config.toml".to_string(), - SearchProviderSource::EnvOverride => "CODEWHALE_SEARCH_PROVIDER".to_string(), - // Same token doctor prints: the signal is a Tavily key, not a disk - // pin, so never name `TAVILY_API_KEY` (the winner may have been a - // generic `tvly-` `[search] api_key`). - SearchProviderSource::TavilyKey => "tavily key".to_string(), +/// Parse the `/config subagents …` grammar in the command. +fn parse_subagent_rest(rest: &str) -> (SubagentOp<'_>, bool) { + let mut tokens = rest.split_whitespace().collect::>(); + let persist = matches!(tokens.last(), Some(&"--save" | &"-s")); + if persist { + tokens.pop(); + } + let op = match tokens.as_slice() { + [] | ["status"] => SubagentOp::Status, + ["on"] | ["enable"] | ["enabled"] => SubagentOp::Set { + key: "enabled", + value: "true", + }, + ["off"] | ["disable"] | ["disabled"] => SubagentOp::Set { + key: "enabled", + value: "false", + }, + [key] => SubagentOp::Get(key), + [key, value] => SubagentOp::Set { key, value }, + _ => SubagentOp::Usage, }; - tr(locale, MessageId::ConfigCommandSource) - .replace("{value}", resolved.provider.as_str()) - .replace("{source}", &source) -} - -fn prompt_suggestion_display(config: &Config) -> String { - config.prompt_suggestion_enabled().to_string() + (op, persist) } -fn notifications_summary(config: &Config) -> String { - notifications_summary_value(&config.notifications_config()) +/// A parsed `/config search…` tail. +#[derive(Debug, PartialEq, Eq)] +enum SearchOp<'a> { + Status, + Set(&'a str), + Usage, } -fn notifications_summary_value(notifications: &NotificationsConfig) -> String { - format!( - "method={} threshold={}s sound={} quiet={}", - notifications.method.as_str(), - notifications.threshold_secs, - notifications.display(NotificationSetting::Sound), - notifications.quiet - ) -} - -fn search_config_command(app: &mut App, raw: &str) -> CommandResult { - let mut tokens = raw.split_whitespace().collect::>(); +/// Parse the `/config search[.provider] …` grammar in the command. +fn parse_search_rest(rest: &str) -> (SearchOp<'_>, bool) { + let mut tokens = rest.split_whitespace().collect::>(); let persist = matches!(tokens.last(), Some(&"--save" | &"-s")); if persist { tokens.pop(); } - - match tokens.as_slice() { - [] | ["status"] | ["provider"] => show_single_setting(app, "search.provider"), - ["provider", value] | [value] => set_search_provider(app, value, persist), - _ => CommandResult::error(format!( - "{} /config search.provider <{}> [--save]", - tr(app.ui_locale, MessageId::HelpUsageLabel), - SearchProvider::names_hint() - )), - } + let op = match tokens.as_slice() { + [] | ["status"] | ["provider"] => SearchOp::Status, + ["provider", value] | [value] => SearchOp::Set(value), + _ => SearchOp::Usage, + }; + (op, persist) } -fn set_search_provider(app: &mut App, value: &str, persist: bool) -> CommandResult { - let Some(provider) = SearchProvider::parse(value) else { - return CommandResult::error( - tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) - .replace("{key}", "search.provider") - .replace("{value}", value) - .replace("{choices}", SearchProvider::names_hint()), - ); - }; +/// A parsed `/config notifications…` tail. +#[derive(Debug, PartialEq, Eq)] +enum NotificationsOp<'a> { + Status, + Get(&'a str), + Set { key: &'a str, value: &'a str }, +} - let scope = if persist { - match persist_table_string_key( - app.config_path.as_deref(), - "search", - "provider", - provider.as_str(), - ) { - Ok(path) => format!( - "{} {}", - tr(app.ui_locale, MessageId::ConfigScopeSaved), - path.display() - ), - Err(err) => { - return CommandResult::error( - tr(app.ui_locale, MessageId::StartupDefaultNotSaved) - .replace("{setting}", "search.provider") - .replace("{error}", &err.to_string()), - ); - } - } +/// Parse the `/config notifications …` grammar in the command. +fn parse_notifications_rest(rest: &str) -> (NotificationsOp<'_>, bool) { + let (rest, persist) = rest + .strip_suffix(" --save") + .or_else(|| rest.strip_suffix(" -s")) + .map_or((rest, false), |value| (value.trim_end(), true)); + let op = if rest.is_empty() || rest == "status" { + NotificationsOp::Status } else { - tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned() + match rest.split_once(char::is_whitespace) { + Some((key, value)) => NotificationsOp::Set { + key, + value: value.trim(), + }, + None => NotificationsOp::Get(rest), + } }; - - CommandResult::with_message_and_action( - tr(app.ui_locale, MessageId::ConfigSearchUpdated) - .replace("{value}", provider.as_str()) - .replace("{scope}", &scope), - AppAction::UpdateSearchProvider { provider }, - ) + (op, persist) } -fn set_prompt_suggestion(app: &mut App, value: &str, persist: bool) -> CommandResult { - let enabled = match parse_config_bool(value) { - Ok(enabled) => enabled, - Err(_) => { - return CommandResult::error( - tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) - .replace("{key}", "prompt_suggestion") - .replace("{value}", value) - .replace("{choices}", "on, off, true, false, yes, no"), - ); - } - }; - let scope = if persist { - match persist_root_bool_key(app.config_path.as_deref(), "prompt_suggestion", enabled) { - Ok(path) => format!( - "{} {}", - tr(app.ui_locale, MessageId::ConfigScopeSaved), - path.display() - ), - Err(err) => { - return CommandResult::error( - tr(app.ui_locale, MessageId::StartupDefaultNotSaved) - .replace("{setting}", "prompt_suggestion") - .replace("{error}", &err.to_string()), - ); - } - } - } else { - tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned() +#[cfg(test)] +mod tests { + use super::*; + use codewhale_command_contract::config::{ + CommandClaudeApplyOutcome, CommandClaudeArtifacts, CommandClaudePlan, CommandConfigContext, }; - CommandResult::with_message_and_action( - tr(app.ui_locale, MessageId::ConfigPromptSuggestionUpdated) - .replace("{value}", &enabled.to_string()) - .replace("{scope}", &scope), - AppAction::UpdatePromptSuggestion { enabled }, - ) -} + use codewhale_command_contract::facets::CommandPresentationContext; -fn notifications_config_command(app: &mut App, raw: &str) -> CommandResult { - let raw = raw.trim(); - let (raw, persist) = raw - .strip_suffix(" --save") - .or_else(|| raw.strip_suffix(" -s")) - .map_or((raw, false), |value| (value.trim_end(), true)); - if raw.is_empty() || raw == "status" { - return show_notifications_status(app); - } - match raw.split_once(char::is_whitespace) { - Some((key, value)) => set_notifications_value(app, key, value.trim(), persist), - None => show_notifications_setting(app, raw), + #[test] + fn missing_facets_fail_safely_with_the_inherited_error_contract() { + for (name, result) in [ + ("config", config(CommandContexts::empty(), Some("model"))), + ("pet", pet(CommandContexts::empty(), None)), + ("trust", trust(CommandContexts::empty(), None)), + ] { + assert!(result.is_error, "{name} must fail without its facet"); + assert_eq!( + result.message.as_deref(), + Some(format!("Command capability unavailable: {name}").as_str()) + ); + assert!(result.action.is_none(), "no action may cross on failure"); + } + } + + #[test] + fn capability_declarations_are_exact() { + assert!(CONFIG_CAPS.contains(CommandCapabilities::CONFIG)); + assert!(CONFIG_CAPS.contains(CommandCapabilities::PRESENTATION)); + assert!(CONFIG_CAPS.contains(CommandCapabilities::PERMISSIONS)); + // Commands that never consume permission authority must not expose it. + assert!(CONFIG_ONLY_CAPS.contains(CommandCapabilities::CONFIG)); + assert!(!CONFIG_ONLY_CAPS.contains(CommandCapabilities::PRESENTATION)); + assert!(!CONFIG_ONLY_CAPS.contains(CommandCapabilities::PERMISSIONS)); + assert!(CONFIG_PRESENTATION_CAPS.contains(CommandCapabilities::PRESENTATION)); + assert!(!CONFIG_PRESENTATION_CAPS.contains(CommandCapabilities::PERMISSIONS)); + assert!(PET_CAPS.contains(CommandCapabilities::PET)); + assert!(!PET_CAPS.contains(CommandCapabilities::CONFIG)); + assert!(TRUST_CAPS.contains(CommandCapabilities::TRUST)); + assert!(!TRUST_CAPS.contains(CommandCapabilities::WORKSPACE)); + assert!(!TRUST_CAPS.contains(CommandCapabilities::PRESENTATION)); + assert!(!TRUST_CAPS.contains(CommandCapabilities::CONFIG)); + } + + #[test] + fn literal_error_needs_no_presentation_capability() { + // `/workbar` declares only WORK_SURFACE, but its save-error message is a + // literal: it must render without the presentation facet. + let mut parts = CommandContexts::empty().into_parts(); + let prepared = PreparedTemplates::default(); + let reply = ConfigReply { + message: Some(ConfigMessage::literal("Failed to save: permission denied")), + action: None, + is_error: true, + }; + let result = to_result(reply, &mut parts, &prepared); + assert!(result.is_error); + assert_eq!( + result.message.as_deref(), + Some("Error: Failed to save: permission denied") + ); } -} - -fn show_notifications_status(app: &App) -> CommandResult { - let mut lines = vec!["[notifications]".to_string()]; - lines.extend(NotificationSetting::ALL.into_iter().map(|setting| { - format!( - "{} = {}", - setting.key(), - app.notification_settings.display(setting) - ) - })); - lines.push(tr(app.ui_locale, MessageId::ConfigNotificationsSetHint).into_owned()); - CommandResult::message(lines.join("\n")) -} -fn show_notifications_setting(app: &App, key: &str) -> CommandResult { - let Some(setting) = NotificationSetting::parse(key) else { - return invalid_notification_value(app, key, key, "/config notifications status"); - }; - CommandResult::message(format!( - "notifications.{} = {}", - setting.key(), - app.notification_settings.display(setting) - )) -} - -fn invalid_notification_value(app: &App, key: &str, value: &str, choices: &str) -> CommandResult { - CommandResult::error( - tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) - .replace("{key}", &format!("notifications.{key}")) - .replace("{value}", value) - .replace("{choices}", choices), - ) -} - -fn set_notifications_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { - let Some(setting) = NotificationSetting::parse(key) else { - return invalid_notification_value(app, key, value, "/config notifications status"); - }; - let update = match NotificationConfigUpdate::parse(setting, value) { - Ok(update) => update, - Err(_) => return invalid_notification_value(app, setting.key(), value, setting.choices()), - }; - let scope = if persist { - let result = crate::config_persistence::config_toml_path(app.config_path.as_deref()) - .and_then(|path| { - update.persist_for_profile(&path, app.config_profile.as_deref())?; - Ok(path) - }); - match result { - Ok(path) => format!( - "{} {}", - tr(app.ui_locale, MessageId::ConfigScopeSaved), - path.display() - ), - Err(error) => { - return CommandResult::error( - tr(app.ui_locale, MessageId::StartupDefaultNotSaved) - .replace("{setting}", &format!("notifications.{}", setting.key())) - .replace("{error}", &error.to_string()), - ); - } - } - } else { - tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned() - }; - CommandResult::with_message_and_action( - tr(app.ui_locale, MessageId::ConfigNotificationUpdated) - .replace("{key}", setting.key()) - .replace("{value}", &update.display()) - .replace("{scope}", &scope), - AppAction::UpdateNotification { update }, - ) -} - -fn stream_chunk_timeout_value_label(raw: u64, resolved: u64) -> String { - if raw == 0 { - format!("0 (default {resolved})") - } else { - resolved.to_string() + #[test] + fn keyed_message_without_presentation_fails_loudly() { + let mut parts = CommandContexts::empty().into_parts(); + let prepared = PreparedTemplates::default(); + let reply = ConfigReply { + message: Some(ConfigMessage::keyed("config_scope_session")), + action: Some(ConfigAction::OpenConfigView), + is_error: false, + }; + let result = to_result(reply, &mut parts, &prepared); + assert!(result.is_error, "a missing required capability must fail"); + assert!(result.action.is_none(), "no action on a failed capability"); + assert_eq!( + result.message.as_deref(), + Some("Error: Command capability unavailable: presentation") + ); } -} -fn subagents_config_command(app: &mut App, raw: &str) -> CommandResult { - let mut tokens = raw.split_whitespace().collect::>(); - let persist = matches!(tokens.last(), Some(&"--save" | &"-s")); - if persist { - tokens.pop(); - } + /// A config facet that panics the moment any method is touched, so a test + /// can prove the command validated capabilities before any effect. + struct PanicConfig; - match tokens.as_slice() { - [] | ["status"] => subagents_status(app), - ["on"] | ["enable"] | ["enabled"] => { - set_subagents_config_value(app, "enabled", "true", persist) + impl CommandConfigContext for PanicConfig { + fn config_path(&self) -> Option { + panic!("config facet touched") } - ["off"] | ["disable"] | ["disabled"] => { - set_subagents_config_value(app, "enabled", "false", persist) + fn audit( + &mut self, + ) -> Result { + panic!("config facet touched") } - [key] => show_subagents_setting(app, key), - [key, value] => set_subagents_config_value(app, key, value, persist), - _ => CommandResult::error( - "Usage: /config subagents [status|on|off|enabled|max_concurrent|max_depth|launch_concurrency|api_timeout_secs|heartbeat_timeout_secs ] [--save]", - ), - } -} - -fn load_command_config(app: &App) -> Result { - Config::load(app.config_path.clone(), app.config_profile.as_deref()) - .map_err(|err| format!("Failed to load config: {err}")) -} - -/// The compatibility default_model command describes saved DeepSeek config, -/// independent of the active provider and one-launch environment overrides. -fn saved_deepseek_default_model(app: &App) -> Result { - let path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) - .map_err(|error| format!("Failed to resolve config: {error}"))?; - let read = || -> anyhow::Result { - let store = codewhale_config::ConfigStore::load(Some(path.clone()))?; - let mut config = Config::from_saved_document( - store.original_body().unwrap_or(""), - app.config_profile.as_deref(), - )?; - if app.config_profile.is_none() && crate::config::is_home_config_path(&path) { - config.apply_saved_selection(&Settings::load_legacy_route_preferences_read_only()?); + fn workflow_summary(&mut self) -> CommandWorkflowSummary { + panic!("config facet touched") } - let key = if app.admitted_provider_identity().is_ok_and(|identity| { - identity.key.as_str() == codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id - }) { - codewhale_config::descriptors::LEGACY_DEEPSEEK_CN.id - } else { - ProviderKind::Deepseek.as_str() - }; - let identity = config - .resolve_provider_pin_identity(key) - .map_err(anyhow::Error::msg)?; - anyhow::ensure!( - identity.provider == ProviderKind::Deepseek, - "The saved provider identity is shadowed by another route" - ); - config - .scope_to_provider_identity(&identity) - .map_err(anyhow::Error::msg)?; - Ok(config.default_model()) - }; - read().map_err(|error| format!("Failed to read saved model config: {error}")) -} - -fn subagents_status(app: &App) -> CommandResult { - let config = match load_command_config(app) { - Ok(config) => config, - Err(err) => return CommandResult::error(err), - }; - let path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) - .map(|path| path.display().to_string()) - .unwrap_or_else(|_| "(unresolved)".to_string()); - let disabled_reason = config.subagents_disabled_reason(); - let identity = match app - .admitted_provider_identity() - .and_then(|identity| config.verify_provider_identity(identity).map(|()| identity)) - { - Ok(identity) => identity, - Err(error) => return CommandResult::error(error), - }; - let subagents = config.subagents.as_ref(); - let provider_subagents = config.subagent_provider_config(identity); - let explicit_enabled = subagents.and_then(|cfg| cfg.enabled); - let raw_max_concurrent = subagents.and_then(|cfg| cfg.max_concurrent); - let raw_max_depth = subagents.and_then(|cfg| cfg.max_depth); - let raw_launch = subagents.and_then(|cfg| cfg.launch_concurrency); - let raw_api = subagents.and_then(|cfg| cfg.api_timeout_secs); - let raw_heartbeat = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs); - let mut lines = Vec::new(); - lines.push(format!( - "Sub-agents: {}", - disabled_reason - .map(|reason| format!("disabled ({reason})")) - .unwrap_or_else(|| "enabled".to_string()) - )); - lines.push(format!("Config path: {path}")); - lines.push(format!( - "Active provider: {} ({})", - identity.key.as_str(), - identity - .compatibility() - .map_or(identity.key.as_str(), |row| row.label) - )); - lines.push(format!( - "subagents.enabled = {}", - explicit_enabled - .map(|value| value.to_string()) - .unwrap_or_else(|| "default true".to_string()) - )); - lines.push(format!( - "subagents.max_concurrent = {} (resolved global {}; active provider {})", - option_display(raw_max_concurrent), - config.max_subagents(), - config.max_subagents_for_provider(identity) - )); - lines.push(format!( - "subagents.max_depth = {} (resolved global {}; active provider {})", - option_display(raw_max_depth), - config.subagent_max_spawn_depth(), - config.subagent_max_spawn_depth_for_provider(identity) - )); - lines.push(format!( - "subagents.launch_concurrency = {} (resolved global {}; active provider {})", - option_display(raw_launch), - config.launch_concurrency(), - config.launch_concurrency_for_provider(identity) - )); - lines.push(format!( - "subagents.api_timeout_secs = {} (resolved global {}; active provider {})", - option_display(raw_api), - config.subagent_api_timeout_secs(), - config.subagent_api_timeout_secs_for_provider(identity) - )); - lines.push(format!( - "subagents.heartbeat_timeout_secs = {} (resolved global {}; active provider {})", - option_display(raw_heartbeat), - config.subagent_heartbeat_timeout_secs(), - config.subagent_heartbeat_timeout_secs_for_provider(identity) - )); - if let Some(provider_subagents) = provider_subagents { - lines.push(format!( - "subagents.providers.{}.enabled = {}", - identity.key.as_str(), - provider_subagents - .enabled - .map(|value| value.to_string()) - .unwrap_or_else(|| "inherits".to_string()) - )); - lines.push(format!( - "subagents.providers.{}.max_concurrent = {}", - identity.key.as_str(), - option_display(provider_subagents.max_concurrent) - )); - lines.push(format!( - "subagents.providers.{}.max_depth = {}", - identity.key.as_str(), - option_display(provider_subagents.max_depth) - )); - lines.push(format!( - "subagents.providers.{}.launch_concurrency = {}", - identity.key.as_str(), - option_display(provider_subagents.launch_concurrency) - )); - lines.push(format!( - "subagents.providers.{}.max_admitted = {}", - identity.key.as_str(), - option_display(provider_subagents.max_admitted) - )); - } else { - lines.push(format!( - "subagents.providers.{} = inherits global", - identity.key.as_str() - )); - } - CommandResult::message(lines.join("\n")) -} - -fn show_subagents_setting(app: &App, key: &str) -> CommandResult { - let config = match load_command_config(app) { - Ok(config) => config, - Err(err) => return CommandResult::error(err), - }; - let Some(key) = canonical_subagents_key(key) else { - return CommandResult::error(format!( - "Unknown subagents setting '{key}'. Use `/config subagents status`." - )); - }; - let identity = match app - .admitted_provider_identity() - .and_then(|identity| config.verify_provider_identity(identity).map(|()| identity)) - { - Ok(identity) => identity, - Err(error) => return CommandResult::error(error), - }; - let subagents = config.subagents.as_ref(); - let value = match key { - "enabled" => subagents - .and_then(|cfg| cfg.enabled) - .map(|value| value.to_string()) - .unwrap_or_else(|| "default true".to_string()), - "max_concurrent" => format!( - "{} (resolved global {}; active provider {})", - option_display(subagents.and_then(|cfg| cfg.max_concurrent)), - config.max_subagents(), - config.max_subagents_for_provider(identity) - ), - "max_depth" => format!( - "{} (resolved global {}; active provider {})", - option_display(subagents.and_then(|cfg| cfg.max_depth)), - config.subagent_max_spawn_depth(), - config.subagent_max_spawn_depth_for_provider(identity) - ), - "launch_concurrency" => format!( - "{} (resolved global {}; active provider {})", - option_display(subagents.and_then(|cfg| cfg.launch_concurrency)), - config.launch_concurrency(), - config.launch_concurrency_for_provider(identity) - ), - "api_timeout_secs" => format!( - "{} (resolved global {}; active provider {})", - option_display(subagents.and_then(|cfg| cfg.api_timeout_secs)), - config.subagent_api_timeout_secs(), - config.subagent_api_timeout_secs_for_provider(identity) - ), - "heartbeat_timeout_secs" => format!( - "{} (resolved global {}; active provider {})", - option_display(subagents.and_then(|cfg| cfg.heartbeat_timeout_secs)), - config.subagent_heartbeat_timeout_secs(), - config.subagent_heartbeat_timeout_secs_for_provider(identity) - ), - _ => unreachable!("canonical subagent key"), - }; - CommandResult::message(format!("subagents.{key} = {value}")) -} - -fn option_display(value: Option) -> String { - value - .map(|value| value.to_string()) - .unwrap_or_else(|| "default".to_string()) -} - -fn canonical_subagents_key(key: &str) -> Option<&'static str> { - let normalized = key.trim().to_ascii_lowercase(); - let key = normalized - .strip_prefix("subagents.") - .unwrap_or(normalized.as_str()); - match key { - "enabled" | "enable" => Some("enabled"), - "max_concurrent" | "max_subagents" | "concurrency" | "cap" => Some("max_concurrent"), - "max_depth" | "depth" | "spawn_depth" => Some("max_depth"), - "launch_concurrency" | "launches" | "launch" => Some("launch_concurrency"), - "api_timeout_secs" | "api_timeout" | "step_timeout_secs" => Some("api_timeout_secs"), - "heartbeat_timeout_secs" | "heartbeat_timeout" | "heartbeat" => { - Some("heartbeat_timeout_secs") + fn subagents_status( + &mut self, + ) -> Result { + panic!("config facet touched") } - _ => None, - } -} - -fn set_subagents_config_value( - app: &mut App, - key: &str, - value: &str, - persist: bool, -) -> CommandResult { - let Some(key) = canonical_subagents_key(key) else { - return CommandResult::error(format!( - "Unknown subagents setting '{key}'. Use `/config subagents status`." - )); - }; - let mut config = match load_command_config(app) { - Ok(config) => config, - Err(err) => return CommandResult::error(err), - }; - let identity = match app.admitted_provider_identity().and_then(|identity| { - config - .verify_provider_identity(identity) - .map(|()| identity.clone()) - }) { - Ok(identity) => identity, - Err(error) => return CommandResult::error(error), - }; - let current_max_subagents = config.max_subagents() as u64; - let subagents = config - .subagents - .get_or_insert_with(SubagentsConfig::default); - - let mut note = None; - let save_result = match key { - "enabled" => { - let enabled = match parse_config_bool(value) { - Ok(enabled) => enabled, - Err(err) => return CommandResult::error(err), - }; - subagents.enabled = Some(enabled); - if persist { - Some(persist_subagents_bool_key( - app.config_path.as_deref(), - "enabled", - enabled, - )) - } else { - None - } + fn subagent(&mut self, _key: &str) -> ConfigReply { + panic!("config facet touched") } - "max_concurrent" => { - let raw = match parse_subagents_u64(key, value) { - Ok(raw) => raw, - Err(err) => return CommandResult::error(err), - }; - let clamped = raw.min(MAX_SUBAGENTS as u64); - if clamped != raw { - note = Some(format!("clamped from {raw} to {clamped}")); - } - subagents.max_concurrent = Some(clamped as usize); - if persist { - Some(persist_subagents_integer_key( - app.config_path.as_deref(), - "max_concurrent", - clamped, - )) - } else { - None - } + fn set_subagent(&mut self, _key: &str, _value: &str, _persist: bool) -> ConfigReply { + panic!("config facet touched") } - "max_depth" => { - let raw = match parse_subagents_u64(key, value) { - Ok(raw) => raw, - Err(err) => return CommandResult::error(err), - }; - let ceiling = u64::from(codewhale_config::MAX_SPAWN_DEPTH_CEILING); - let clamped = raw.min(ceiling); - if clamped != raw { - note = Some(format!("clamped from {raw} to {clamped}")); - } - subagents.max_depth = Some(clamped as u32); - if persist { - Some(persist_subagents_integer_key( - app.config_path.as_deref(), - "max_depth", - clamped, - )) - } else { - None - } + fn search_provider_status(&mut self) -> ConfigReply { + panic!("config facet touched") } - "launch_concurrency" => { - let raw = match parse_subagents_u64(key, value) { - Ok(raw) => raw, - Err(err) => return CommandResult::error(err), - }; - let clamped = raw.clamp(1, current_max_subagents); - if clamped != raw { - note = Some(format!("clamped from {raw} to {clamped}")); - } - subagents.launch_concurrency = Some(clamped as usize); - if persist { - Some(persist_subagents_integer_key( - app.config_path.as_deref(), - "launch_concurrency", - clamped, - )) - } else { - None - } + fn search_provider_names(&self) -> &'static str { + panic!("config facet touched") } - "api_timeout_secs" => { - let raw = match parse_subagents_u64(key, value) { - Ok(raw) => raw, - Err(err) => return CommandResult::error(err), - }; - let stored = if raw == 0 { - 0 - } else { - raw.clamp(MIN_SUBAGENT_API_TIMEOUT_SECS, MAX_SUBAGENT_API_TIMEOUT_SECS) - }; - if stored != raw { - note = Some(format!("clamped from {raw} to {stored}")); - } - subagents.api_timeout_secs = Some(stored); - if persist { - Some(persist_subagents_integer_key( - app.config_path.as_deref(), - "api_timeout_secs", - stored, - )) - } else { - None - } + fn set_search_provider(&mut self, _value: &str, _persist: bool) -> ConfigReply { + panic!("config facet touched") } - "heartbeat_timeout_secs" => { - let raw = match parse_subagents_u64(key, value) { - Ok(raw) => raw, - Err(err) => return CommandResult::error(err), - }; - let stored = if raw == 0 { - 0 - } else { - raw.clamp( - MIN_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, - MAX_SUBAGENT_HEARTBEAT_TIMEOUT_SECS, - ) - }; - if stored != raw { - note = Some(format!("clamped from {raw} to {stored}")); - } - subagents.heartbeat_timeout_secs = Some(stored); - if persist { - Some(persist_subagents_integer_key( - app.config_path.as_deref(), - "heartbeat_timeout_secs", - stored, - )) - } else { - None - } + fn notifications_status(&mut self) -> ConfigReply { + panic!("config facet touched") } - _ => unreachable!("canonical subagent key"), - }; - - let save_suffix = if let Some(result) = save_result { - match result { - Ok(path) => format!("saved to {}", path.display()), - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), + fn notification(&mut self, _key: &str) -> ConfigReply { + panic!("config facet touched") } - } else { - "session only, add --save to persist".to_string() - }; - - if key == "max_concurrent" { - app.max_subagents = config.max_subagents_for_provider(&identity); - } - let display_value = subagents_config_display_value(&config, key); - let note = note.map(|note| format!("; {note}")).unwrap_or_default(); - CommandResult::with_message_and_action( - format!( - "subagents.{key} = {display_value} ({save_suffix}; runtime updated for subsequent turns{note})" - ), - subagents_runtime_action(&identity, &config), - ) -} - -fn parse_subagents_u64(key: &str, value: &str) -> Result { - value - .trim() - .parse::() - .map_err(|_| format!("subagents.{key} must be a whole number")) -} - -fn subagents_config_display_value(config: &Config, key: &str) -> String { - let subagents = config.subagents.as_ref(); - match key { - "enabled" => subagents - .and_then(|cfg| cfg.enabled) - .map(|value| value.to_string()) - .unwrap_or_else(|| "default true".to_string()), - "max_concurrent" => { - if subagents.and_then(|cfg| cfg.max_concurrent) == Some(0) { - "0 (disabled)".to_string() - } else { - config.max_subagents().to_string() - } + fn set_notification(&mut self, _key: &str, _value: &str, _persist: bool) -> ConfigReply { + panic!("config facet touched") } - "max_depth" => { - if subagents.and_then(|cfg| cfg.max_depth) == Some(0) { - "0 (agent tool disabled)".to_string() - } else { - config.subagent_max_spawn_depth().to_string() - } + fn apply_preset(&mut self, _name: &str, _persist: bool) -> ConfigReply { + panic!("config facet touched") } - "launch_concurrency" => config.launch_concurrency().to_string(), - "api_timeout_secs" => { - let raw = subagents.and_then(|cfg| cfg.api_timeout_secs); - if raw == Some(0) { - format!("0 (default {DEFAULT_SUBAGENT_API_TIMEOUT_SECS})") - } else { - config.subagent_api_timeout_secs().to_string() - } + fn get_value(&mut self, _key: &str) -> ConfigReply { + panic!("config facet touched") } - "heartbeat_timeout_secs" => { - let raw = subagents.and_then(|cfg| cfg.heartbeat_timeout_secs); - if raw == Some(0) { - format!("0 (default {DEFAULT_SUBAGENT_HEARTBEAT_TIMEOUT_SECS})") - } else { - config.subagent_heartbeat_timeout_secs().to_string() - } + fn set_value(&mut self, _key: &str, _value: &str, _persist: bool) -> ConfigReply { + panic!("config facet touched") } - _ => unreachable!("canonical subagent key"), - } -} - -fn subagents_runtime_action( - identity: &crate::config::ProviderIdentity, - config: &Config, -) -> AppAction { - let max_subagents = config - .max_subagents_for_provider(identity) - .clamp(1, MAX_SUBAGENTS); - AppAction::UpdateSubagentRuntimeConfig { - enabled: config.subagents_enabled_for_provider(identity), - max_subagents, - launch_concurrency: config.launch_concurrency_for_provider(identity), - max_spawn_depth: config.subagent_max_spawn_depth_for_provider(identity), - api_timeout_secs: config.subagent_api_timeout_secs_for_provider(identity), - heartbeat_timeout_secs: config.subagent_heartbeat_timeout_secs_for_provider(identity), - } -} - -/// The subject a live-route key belongs to, or `None` if the key does not touch -/// the route the engine is currently acting on. -/// -/// This is the single list the #2982 turn lock is enforced from. It exists -/// because the lock used to live in the *selectors* — the Tab cycle, the -/// pickers, the hotbar — while `/set ` and `/config ` -/// reached the same live state through a different door. A slash command is -/// reachable mid-turn (the composer accepts Shift+Enter and the slash menu while -/// `is_loading`), so during a running turn `/set model …` could swap the route -/// out from under the engine and persist it. -/// -/// `default_mode` is deliberately absent: it is a restart default that -/// `set_config_value` explicitly does *not* apply to the live session, so -/// refusing it would lock a key that cannot affect the turn. -fn live_route_setting_subject(key: &str) -> Option { - match key { - "mode" => Some(MessageId::SettingSubjectMode), - // `default_model` is not merely a startup default: for the DeepSeek - // routes `set_config_value` installs it as the live model. - "model" | "default_model" => Some(MessageId::SettingSubjectModel), - "reasoning_effort" | "effort" => Some(MessageId::SettingSubjectThinking), - "provider" => Some(MessageId::SettingSubjectProvider), - "approval_mode" | "approval_policy" | "approval" => { - Some(MessageId::SettingSubjectPermissions) + fn show_settings( + &mut self, + ) -> Result { + panic!("config facet touched") } - _ => None, - } -} - -/// Modify a setting at runtime -pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { - let key = key.to_lowercase(); - if let Some(subagent_key) = key.strip_prefix("subagents.") { - return set_subagents_config_value(app, subagent_key, value, persist); - } - if let Some(notifications_key) = key.strip_prefix("notifications.") { - return set_notifications_value(app, notifications_key, value, persist); - } - - // Refuse before *anything* — before the disk write, and before the live - // `App` mutation each arm performs. Placing the check at the top is what - // makes it central: every caller of this function (`/set`, `/config k v`, - // the preset mirror, the schema-driven config editor, the runtime - // `ConfigUpdated` event) inherits it, and none of them can half-apply. - if let Some(subject) = live_route_setting_subject(key.as_str()) - && app.is_loading - { - return CommandResult::error(app.setting_locked_message(subject)); - } - - match key.as_str() { - "pet_mode" => { - let enabled = match parse_config_bool(value) { - Ok(enabled) => enabled, - Err(_) => { - return CommandResult::error( - tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) - .replace("{key}", &key) - .replace("{value}", value) - .replace("{choices}", "on/off"), - ); - } - }; - // /pet and the settings editor share the ordered background writer. - // A failed save is reported by the existing warning mailbox. - return select_pet_mode(app, enabled, persist); + fn theme_schema(&self) -> String { + panic!("config facet touched") } - "contextual_tips" => { - let enabled = match parse_config_bool(value) { - Ok(enabled) => enabled, - Err(_) => { - return CommandResult::error( - tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) - .replace("{key}", &key) - .replace("{value}", value) - .replace("{choices}", "on/off"), - ); - } - }; - // Apply the opt-out even when the settings file cannot be read - // or saved. Only the existing single-key transaction may claim - // persistence; a failure leaves the live preference in effect. - app.set_contextual_tips_enabled(enabled); - if persist && let Err(error) = persist_single_setting(&key, &enabled.to_string()) { - let message = tr(app.ui_locale, MessageId::ContextualTipsNotSaved) - .replace("{error}", &error.to_string()); - app.push_status_toast( - message.clone(), - crate::tui::app::StatusToastLevel::Error, - Some(8_000), - ); - return CommandResult::error(message); - } - let scope = if persist { - MessageId::ConfigScopeSaved - } else { - MessageId::ConfigScopeSession - }; - return CommandResult::message(format!( - "contextual_tips = {enabled} ({})", - tr(app.ui_locale, scope) - )); + fn themes_dir(&self) -> Result { + panic!("config facet touched") } - "telemetry" => { - if !persist { - return CommandResult::error( - "Telemetry is a durable privacy preference. Change it in /settings or add --save.", - ); - } - let enabled = match parse_config_bool(value) { - Ok(enabled) => enabled, - Err(err) => return CommandResult::error(err), - }; - let applied = crate::telemetry_notice::apply_persistent_preference( - app.config_path.clone(), - enabled, - ); - let message = applied.message(app.ui_locale); - return if applied.is_error() { - CommandResult { - message: Some(message), - action: None, - is_error: true, - } - } else { - CommandResult::message(message) - }; + fn verbose_enabled(&self) -> bool { + panic!("config facet touched") } - "default_model" => { - let value = value.trim(); - let value = if value.is_empty() - || matches!( - value.to_ascii_lowercase().as_str(), - "none" | "default" | "(default)" - ) { - crate::config::DEFAULT_TEXT_MODEL - } else { - value - }; - if matches!(app.api_provider, ProviderKind::Deepseek) { - return set_config_value(app, "model", value, persist); - } - if !persist { - return CommandResult::error(format!( - "default_model is the DeepSeek startup fallback and cannot change the active {} session. Use /model for the current provider, or add --save to change only future DeepSeek sessions.", - app.api_provider.as_str() - )); - } - let saved = match load_command_config(app) { - Ok(config) => config, - Err(err) => return CommandResult::error(err), - }; - let identity = match saved.builtin_provider_identity(ProviderKind::Deepseek) { - Ok(identity) => identity, - Err(error) => return CommandResult::error(error), - }; - let model = if value.eq_ignore_ascii_case("auto") { - "auto".to_string() - } else { - // Route-aware, matching POST /v1/config: a custom DeepSeek - // endpoint owns its model namespace, so an id declared for the - // saved DeepSeek route validates verbatim before the catalog - // normalization runs. - if crate::provider_lake::configured_model_for_route( - &saved, - ProviderKind::Deepseek, - identity.key.as_str(), - &saved.base_url_for_route(&identity), - value.trim(), - ) - .is_some() - { - value.trim().to_string() - } else { - let Some(model) = - normalize_model_name_for_provider(ProviderKind::Deepseek, value) - else { - return CommandResult::error(format!("Invalid DeepSeek model '{value}'.")); - }; - if let Err(error) = validate_route(ProviderKind::Deepseek, &model) { - return CommandResult::error(error); - } - model - } - }; - return match crate::config_persistence::persist_provider_model_key( - app.config_path.as_deref(), - &identity, - &model, - ) { - Ok(path) => CommandResult::message(format!( - "default_model = {model} (saved to {}); DeepSeek fallback only — active {}/{} is unchanged", - path.display(), - app.api_provider.as_str(), - app.model_display_label() - )), - Err(error) => CommandResult::error(format!("Failed to save model: {error}")), - }; + fn set_verbose_enabled(&mut self, _enabled: bool) { + panic!("config facet touched") } - "model" => { - // Route-aware: a custom DeepSeek (or other) endpoint owns its model - // namespace. Provider-only normalization would reject a non-DeepSeek - // id that the live session is already allowed to use via `/model`. - // OpenCode Go stays protocol-strict even on a custom host. - let auto_select = value.trim().eq_ignore_ascii_case("auto"); - let model = if auto_select { - "auto".to_string() - } else if app.api_provider == ProviderKind::OpencodeGo { - let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else { - return CommandResult::error(format!( - "Invalid model '{value}' for provider {}.", - app.api_provider.as_str() - )); - }; - if let Err(reason) = validate_route(app.api_provider, &model) { - return CommandResult::error(reason); - } - model - } else if app.accepts_custom_model_ids() - || (app.api_provider != ProviderKind::OpenaiCodex - && app.configured_models.iter().any(|row| { - row.id == value.trim() - && row.matches_route( - app.provider_identity_for_persistence(), - &app.active_route_base_url, - ) - })) - { - let Some(model) = normalize_custom_model_id(value) else { - return CommandResult::error(format!( - "Invalid model '{value}' for provider {}.", - app.api_provider.as_str() - )); - }; - model - } else { - let Some(model) = normalize_model_name_for_provider(app.api_provider, value) else { - return CommandResult::error(format!( - "Invalid model '{value}' for provider {}.", - app.api_provider.as_str() - )); - }; - if let Err(reason) = validate_route(app.api_provider, &model) { - return CommandResult::error(reason); - } - model - }; - let saved = if persist { - match crate::config_persistence::persist_provider_selection( - app.config_path.as_deref(), - match app.admitted_provider_identity() { - Ok(identity) => identity, - Err(error) => return CommandResult::error(error), - }, - Some(&model), - ) { - Ok((path, _)) => Some(path), - Err(error) => { - return CommandResult::error(format!("Failed to save model: {error}")); - } - } - } else { - None - }; - app.set_model_selection(model.clone()); - app.update_model_compaction_budget(); - app.session.last_prompt_tokens = None; - app.session.last_completion_tokens = None; - let mut message = if model == "auto" { - format!( - "model = auto (auto-select model per turn; thinking = {})", - app.reasoning_effort_display_label() - ) - } else { - format!("model = {model}") - }; - if let Some(path) = saved { - message.push_str(&format!(" (saved to {})", path.display())); - } - return CommandResult::with_message_and_action( - message, - AppAction::UpdateCompaction(app.compaction_config()), - ); + fn screen_mode(&self) -> codewhale_command_contract::types::CommandScreenMode { + panic!("config facet touched") } - "provider" => { - let value = value.trim(); - let Some(row) = codewhale_config::descriptors::compatibility_for_selector(value) else { - return CommandResult::error(format!( - "Unknown provider '{value}'. Use: {}.", - ProviderKind::names_hint() - )); - }; - if app - .provider_identity - .as_ref() - .is_some_and(|identity| identity.key.as_str() == row.id) - { - return CommandResult::message(format!("provider = {}", row.id)); - } - return CommandResult::with_message_and_action( - format!("provider = {}", row.id), - AppAction::SwitchProvider { - provider: row.id.into(), - model: None, - }, - ); + fn build_plan(&mut self) -> Option { + panic!("config facet touched") } - "approval_mode" | "approval_policy" | "approval" => { - let use_tui_default = matches!( - value - .trim() - .to_ascii_lowercase() - .replace([' ', '_'], "-") - .as_str(), - "default" | "tui-default" | "use-tui-default" - ); - if use_tui_default { - if !persist { - return CommandResult::error( - "Removing the config approval override requires --save.", - ); - } - let control = match load_command_config(app) { - Ok(config) => config.approval_policy_control( - app.config_path.as_deref(), - app.config_profile.as_deref(), - &app.workspace, - ), - Err(err) => return CommandResult::error(err), - }; - if !matches!( - control, - crate::config::ApprovalPolicyControl::RootConfig - | crate::config::ApprovalPolicyControl::Unset - ) { - return CommandResult::error(format!( - "Approval posture is controlled by {}; change that source first.", - control.label() - )); - } - return match persist_unset_root_key(app.config_path.as_deref(), "approval_policy") { - Ok(path) => { - let saved_mode = Settings::load_persisted() - .ok() - .and_then(|settings| settings.permission_posture) - .as_deref() - .and_then(ApprovalMode::from_config_value) - .unwrap_or(ApprovalMode::Suggest); - app.set_agent_approval_posture(saved_mode); - app.clear_saved_approval_policy_lock(); - CommandResult::with_message_and_action( - format!( - "approval_policy removed from {}; new sessions use the TUI {} default", - path.display(), - saved_mode.permission_chip_label() - ), - AppAction::ApprovalPolicyPersisted { policy: None }, - ) - } - Err(err) => CommandResult::error(format!("Failed to save: {err}")), - }; - } - let control = match load_command_config(app) { - Ok(config) => config.approval_policy_control( - app.config_path.as_deref(), - app.config_profile.as_deref(), - &app.workspace, - ), - Err(err) => return CommandResult::error(err), - }; - let control_allows_change = if persist { - control.editable_root() - } else { - matches!(control, crate::config::ApprovalPolicyControl::Unset) - }; - if !control_allows_change { - return CommandResult::error(format!( - "Approval posture is controlled by {}; {}.", - control.label(), - if matches!(control, crate::config::ApprovalPolicyControl::RootConfig) { - "save a new config value or choose Use TUI permission default" - } else { - "change that source first" - } - )); - } - let mode = ApprovalMode::from_config_value(value); - return match mode { - Some(ApprovalMode::Bypass) - if persist - && matches!(control, crate::config::ApprovalPolicyControl::RootConfig) => - { - match app.adopt_root_approval_posture(ApprovalMode::Bypass) { - Ok(()) => CommandResult::with_message_and_action( - "approval_mode = Full Access (saved as the TUI permission posture; removed the root approval_policy override)", - AppAction::ApprovalPolicyPersisted { policy: None }, - ), - Err(reason) => { - CommandResult::error(format!("Failed to save Full Access: {reason}")) - } - } - } - Some(ApprovalMode::Bypass) if persist => CommandResult::error( - "Full Access is saved as the TUI permission posture, not as a top-level approval_policy. Remove the controlling policy first.", - ), - Some(m) => { - if persist { - let saved = approval_mode_config_value(m); - match persist_root_string_key( - app.config_path.as_deref(), - "approval_policy", - saved, - ) { - Ok(path) => { - app.set_agent_approval_posture(m); - app.mark_approval_policy_locked(); - CommandResult::with_message_and_action( - format!( - "approval_mode = {} (saved to {} as approval_policy = \"{}\")", - m.permission_chip_label(), - path.display(), - saved - ), - AppAction::ApprovalPolicyPersisted { - policy: Some(saved.to_string()), - }, - ) - } - Err(err) => CommandResult::error(format!("Failed to save: {err}")), - } - } else { - app.set_agent_approval_posture(m); - CommandResult::with_message_and_action( - format!( - "approval_mode = {} (session only, add --save to persist)", - m.permission_chip_label() - ), - AppAction::ModeChanged(app.mode), - ) - } - } - None => CommandResult::error( - "Invalid approval_mode. Use: auto-review/auto, ask/suggest/on-request, full-access, never/deny", - ), - }; + fn write_artifacts( + &mut self, + _report: &str, + _bundle: Option<&str>, + ) -> CommandClaudeArtifacts { + panic!("config facet touched") } - "allow_shell" | "shell" | "exec_shell" => { - let control = match load_command_config(app) { - Ok(config) => config.allow_shell_control( - app.config_path.as_deref(), - app.config_profile.as_deref(), - &app.workspace, - ), - Err(err) => return CommandResult::error(err), - }; - if !control.editable_root() { - return CommandResult::error(format!( - "Shell access is controlled by {}; change that source first.", - control.label() - )); - } - let enabled = match parse_config_bool(value) { - Ok(enabled) => enabled, - Err(err) => return CommandResult::error(err), - }; - let suffix = if persist { - match persist_root_bool_key(app.config_path.as_deref(), "allow_shell", enabled) { - Ok(path) => format!(" (saved to {})", path.display()), - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), - } - } else { - " (session only, add --save to persist)".to_string() - }; - app.set_agent_shell_access(enabled); - let mode_hint = if enabled { - " Work mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves." - } else { - " Shell tools will be hidden on the next turn. Re-enable with `/config allow_shell true`." - }; - return CommandResult::message(format!("allow_shell = {enabled}{suffix}.{mode_hint}")); - } - "mcp_config_path" | "mcp" => { - if value.trim().is_empty() { - return CommandResult::error("mcp_config_path cannot be empty"); - } - let next_path = PathBuf::from(expand_tilde(value)); - let path_changed = next_path != app.mcp_config_path; - let reload_note = if path_changed { - "; run /mcp reload to rebuild the live tool pool" - } else { - "" - }; - // Persist before touching live state (C01-08): a failed save - // must leave the session exactly as it was, not report failure - // over a path and reload flag that already moved. - let saved_to = if persist { - match persist_root_string_key(app.config_path.as_deref(), "mcp_config_path", value) - { - Ok(path) => Some(path), - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), - } - } else { - None - }; - app.mcp_config_path = next_path; - if path_changed { - app.mcp_reload_required = true; - } - let message = match saved_to { - Some(path) => format!( - "mcp_config_path = {} (saved to {}){}", - app.mcp_config_path.display(), - path.display(), - reload_note - ), - None => format!( - "mcp_config_path = {} (session only){}", - app.mcp_config_path.display(), - reload_note - ), - }; - return CommandResult::message(message); - } - "title" | "window_title" | "tab_title" => { - // Keep the config setter under the same terminal-control and - // bidi/zero-width policy as `/title` and `/rename`. Persist the - // normalized value too, so a restart cannot reintroduce bytes the - // live session already discarded. - let sanitized = crate::session_manager::sanitize_session_title(value); - let value = sanitized.trim(); - if value.is_empty() { - return CommandResult::error( - "title cannot be empty; use /title off to clear a session title", - ); - } - if value.chars().count() > 100 { - return CommandResult::error("Title too long (max 100 characters)"); - } - let suffix = if persist { - match persist_root_string_key(app.config_path.as_deref(), "title", value) { - Ok(path) => format!(" (saved to {})", path.display()), - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), - } - } else { - " (session only, add --save to persist)".to_string() - }; - app.title_default = Some(value.to_string()); - app.needs_redraw = true; - return CommandResult::message(format!( - "title = {value}{suffix} — terminal window titles now read [\"{value}\"] … until /title overrides this session" - )); - } - // `base_url` is the older spelling. It used to write a top-level key - // that every DeepSeek-family route inherited; it now writes the - // active route's own `[providers.]` table like `provider_url` - // (#6394). - url_key @ ("base_url" | "provider_url" | "provider_base_url" | "endpoint") => { - let value = match resolve_provider_url_value(app.api_provider, value) { - Ok(value) => value, - Err(err) => return CommandResult::error(err), - }; - if persist { - match crate::config_persistence::persist_route_base_url( - app.config_path.as_deref(), - match app.admitted_provider_identity() { - Ok(identity) => identity, - Err(error) => return CommandResult::error(error), - }, - &value, - ) { - Ok(path) => { - return CommandResult::message(format!( - "{url_key} = {value} for {} (saved to {}; restart required)", - app.api_provider.as_str(), - path.display() - )); - } - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), - } - } - return CommandResult::error(format!( - "{url_key} must be saved with --save; client base URL is loaded from config on startup. Restart and re-open your session after saving." - )); - } - // The two bottom-chrome rows' size presets (`tui.posture_bar`, - // `tui.metrics_line`, #5950). Live on the next frame; `--save` - // writes the owning `[tui]` table. `/statusline` composes what is in a row; - // this only decides whether and how much of it paints. - row_key @ ("posture_bar" | "metrics_line") => { - let Some(preset) = crate::config::ChromeRowPreset::from_setting(value) else { - return CommandResult::error( - tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) - .replace("{key}", row_key) - .replace("{value}", value) - .replace( - "{choices}", - &crate::config::ChromeRowPreset::SETTINGS.join(", "), - ), - ); - }; - let value = preset.as_setting(); - let scope = if persist { - let saved = crate::config_persistence::config_toml_path(app.config_path.as_deref()) - .and_then(|path| { - crate::config_persistence::mutate_config_document(&path, |doc| { - // Profiles replace the whole TUI table on load. Edit its - // existing owner without creating an empty override that - // would reset the other inherited display settings. - let mut segments = Vec::new(); - if let Some(profile) = app.config_profile.as_deref() { - let table = doc - .get("profiles") - .and_then(|v| v.get(profile)) - .and_then(toml_edit::Item::as_table_like) - .ok_or_else(|| { - anyhow::anyhow!("active profile is missing or malformed") - })?; - if table.contains_key("tui") { - segments.extend(["profiles", profile]); - } - } - segments.extend(["tui", row_key]); - crate::config_persistence::set_document_value(doc, &segments, value) - })?; - Ok(path) - }); - match saved { - Ok(path) => format!( - "{} {}", - tr(app.ui_locale, MessageId::ConfigScopeSaved), - path.display() - ), - Err(error) => { - return CommandResult::error( - tr(app.ui_locale, MessageId::StartupDefaultNotSaved) - .replace("{setting}", row_key) - .replace("{error}", &error.to_string()), - ); - } - } - } else { - tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned() - }; - if row_key == "posture_bar" { - app.posture_bar = preset; - } else { - app.metrics_line = preset; - } - app.needs_redraw = true; - return CommandResult::message(format!("{row_key} = {value} ({scope})")); - } - "stream_chunk_timeout_secs" => { - let raw = match value.trim().parse::() { - Ok(value) => value, - Err(_) => { - return CommandResult::error( - "stream_chunk_timeout_secs must be a whole number", - ); - } - }; - if raw != 0 - && !(MIN_STREAM_CHUNK_TIMEOUT_SECS..=MAX_STREAM_CHUNK_TIMEOUT_SECS).contains(&raw) - { - return CommandResult::error(format!( - "stream_chunk_timeout_secs must be 0 or {MIN_STREAM_CHUNK_TIMEOUT_SECS}..={MAX_STREAM_CHUNK_TIMEOUT_SECS}" - )); - } - let resolved = if raw == 0 { - DEFAULT_STREAM_CHUNK_TIMEOUT_SECS - } else { - raw - }; - let value_label = stream_chunk_timeout_value_label(raw, resolved); - if persist { - match persist_table_value_key( - app.config_path.as_deref(), - "stream", - "chunk_timeout_secs", - (raw as i64).into(), // validated above: at most 3600 - ) { - Ok(path) => { - // Live state moves only with the engine action and - // only after the save landed (C01-08). - app.stream_chunk_timeout_secs = resolved; - return CommandResult::with_message_and_action( - format!( - "stream_chunk_timeout_secs = {value_label} (saved to {}; affects subsequent turns in this session)", - path.display() - ), - AppAction::UpdateStreamChunkTimeout(resolved), - ); - } - Err(err) => return CommandResult::error(format!("Failed to save: {err}")), - } - } - app.stream_chunk_timeout_secs = resolved; - return CommandResult::with_message_and_action( - format!( - "stream_chunk_timeout_secs = {value_label} (session only; affects subsequent turns in this session)" - ), - AppAction::UpdateStreamChunkTimeout(resolved), - ); - } - "search" | "search.provider" | "search_provider" => { - return set_search_provider(app, value, persist); - } - "prompt_suggestion" => return set_prompt_suggestion(app, value, persist), - "notifications" => return notifications_config_command(app, value), - _ => {} - } - - // This copy exists to validate the value and to project it onto live `App` - // state. It is deliberately *not* what gets saved: see - // [`persist_single_setting`]. - let mut settings = match Settings::load_persisted() { - Ok(s) => s, - Err(e) if !persist => { - app.status_message = Some(format!( - "Settings unavailable; applying session-only override ({e})" - )); - Settings::default() - } - Err(e) => return CommandResult::error(format!("Failed to load settings: {e}")), - }; - - if let Err(e) = settings.set(&key, value) { - return CommandResult::error(format!("{e}")); - } - // Runtime/environment constraints are an effective projection, not saved - // preferences. Keep the persisted copy pristine so NO_ANIMATIONS or a - // terminal quirk cannot become permanent during an unrelated edit. - let mut effective_settings = settings.clone(); - effective_settings.apply_env_overrides(); - - let mut action = None; - match key.as_str() { - "auto_compact" | "compact" => { - app.auto_compact = settings.auto_compact; - app.auto_compact_user_configured = true; - action = Some(AppAction::UpdateCompaction(app.compaction_config())); - } - "auto_compact_threshold" | "auto_compact_threshold_percent" => { - app.auto_compact = true; - app.auto_compact_user_configured = true; - app.auto_compact_threshold_percent = settings.auto_compact_threshold_percent; - app.update_model_compaction_budget(); - action = Some(AppAction::UpdateCompaction(app.compaction_config())); - } - "calm_mode" | "calm" => { - app.calm_mode = settings.calm_mode; - app.mark_history_updated(); - } - "low_motion" | "motion" => { - app.low_motion = effective_settings.low_motion; - app.needs_redraw = true; - } - "fancy_animations" | "fancy" | "animations" => { - app.fancy_animations = effective_settings.fancy_animations; - app.needs_redraw = true; - } - "focus_texture" | "texture" => { - app.focus_texture = - crate::tui::focus_texture::FocusTextureMode::parse(&settings.focus_texture) - .unwrap_or_default(); - app.needs_redraw = true; - } - "work_surface_placement" | "work_surface" | "work_rail" => { - app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::parse( - &settings.work_surface_placement, - ); - app.work_surface.focused = false; - app.work_surface.last_area = None; - app.needs_redraw = true; - } - "rail_panel" | "rail" => { - app.work_surface.panel = - crate::tui::work_surface::RailPanel::parse(&settings.rail_panel); - app.needs_redraw = true; - } - "work_surface_top_height" | "work_top_height" => { - app.work_surface.top_height = settings.work_surface_top_height; - app.needs_redraw = true; - } - "work_surface_side_width" | "work_side_width" => { - app.work_surface.side_width = settings.work_surface_side_width; - app.needs_redraw = true; - } - "bracketed_paste" | "paste" => { - app.use_bracketed_paste = settings.bracketed_paste; - app.needs_redraw = true; - } - "status_indicator" | "indicator" => { - app.status_indicator = settings.status_indicator.clone(); - app.needs_redraw = true; - } - "synchronized_output" | "sync_output" | "sync" => { - app.synchronized_output_enabled = effective_settings.synchronized_output_enabled(); - app.needs_redraw = true; - } - "show_thinking" | "thinking" => { - app.show_thinking = settings.show_thinking; - app.mark_history_updated(); - } - "thinking_default_expanded" | "thinking_expanded" => { - app.thinking_default_expanded = settings.thinking_default_expanded; - app.mark_history_updated(); - } - "thinking_preview_lines" | "thinking_preview" => { - app.thinking_preview_lines = settings.thinking_preview_lines; - app.mark_history_updated(); - } - "help_expand_groups" | "help_expanded" => { - app.help_expand_groups = settings.help_expand_groups; - app.needs_redraw = true; - } - "pin_last_prompt" | "pin_prompt" => { - app.pin_last_prompt = settings.pin_last_prompt; - app.needs_redraw = true; - } - "thinking_highlight" | "reasoning_highlight" => { - app.thinking_highlight = settings.thinking_highlight; - app.mark_history_updated(); - } - "show_tool_details" | "tool_details" => { - app.show_tool_details = settings.show_tool_details; - app.mark_history_updated(); - } - "inline_diffs" | "inline_diff" | "diffs" => { - app.inline_diff_mode = crate::settings::InlineDiffMode::parse(&settings.inline_diffs); - app.mark_history_updated(); - app.needs_redraw = true; - } - "locale" | "language" => { - app.ui_locale = resolve_locale(&settings.locale); - app.mark_history_updated(); - app.needs_redraw = true; - } - "theme" | "ui_theme" | "background_color" | "background" | "bg" => { - // Theme previews reload persisted settings for each cursor move. - // Keep a session-only background overlay live unless this command - // is itself updating (or clearing) the background. - let background_color_override = if matches!(key.as_str(), "theme" | "ui_theme") { - app.background_color_override - } else { - settings - .background_color - .as_deref() - .and_then(codewhale_palette::parse_hex_rgb_color) - }; - let background_setting = - background_color_override.and_then(codewhale_palette::hex_rgb_string); - let (theme_name, theme_id, ui_theme) = match codewhale_palette::resolve_theme_setting( - &settings.theme, - background_setting.as_deref(), - ) { - Ok(resolved) => resolved, - Err(error) => { - return CommandResult::error(format!("Failed to apply theme: {error}")); - } - }; - app.background_color_override = background_color_override; - app.theme_id = theme_id; - app.theme_name = theme_name; - app.ui_theme = ui_theme; - app.needs_redraw = true; - } - "cost_currency" | "currency" => { - app.cost_currency = crate::pricing::CostCurrency::from_setting(&settings.cost_currency) - .unwrap_or(crate::pricing::CostCurrency::Usd); - app.needs_redraw = true; - } - key @ ("mini_window.keep_header" - | "mini_window.keep_input" - | "mini_window.keep_todo" - | "mini_window.keep_sidebar" - | "mini_window.keep_footer") => { - let field = key.strip_prefix("mini_window.").unwrap_or(key); - let value = match parse_config_bool(value) { - Ok(value) => value, - Err(err) => return CommandResult::error(err), - }; - // Persist first: a failed save leaves the live layout as it was - // (C01-08). - if persist - && let Err(err) = crate::config_persistence::persist_mini_window_bool_key( - app.config_path.as_deref(), - field, - value, - ) - { - return CommandResult::error(format!("Failed to persist: {err}")); - } - match field { - "keep_header" => app.mini_window.keep_header = value, - "keep_input" => app.mini_window.keep_input = value, - "keep_todo" => app.mini_window.keep_todo = value, - "keep_sidebar" => app.mini_window.keep_sidebar = value, - "keep_footer" => app.mini_window.keep_footer = value, - _ => unreachable!("mini_window field matched above"), - } - app.needs_redraw = true; - } - "composer_density" | "composer" => { - app.composer_density = - crate::tui::app::ComposerDensity::from_setting(&settings.composer_density); - app.needs_redraw = true; - } - "composer_border" | "border" => { - app.composer_border = settings.composer_border; - app.needs_redraw = true; - } - "composer_multiline_mode" | "multiline_mode" | "multiline" => { - app.composer_multiline_mode = settings.composer_multiline_mode; - app.needs_redraw = true; - } - "composer_vim_mode" | "vim_mode" | "vim" => { - app.composer.vim_enabled = settings.composer_vim_mode == "vim"; - app.composer.vim_mode = if app.composer.vim_enabled { - VimMode::Normal - } else { - VimMode::Insert - }; - app.composer.vim_pending_d = false; - app.needs_redraw = true; - } - "paste_burst_detection" | "paste_burst" => { - app.use_paste_burst_detection = settings.paste_burst_detection; - if !app.use_paste_burst_detection { - app.paste_burst.clear_after_explicit_paste(); - } - } - "mention_menu_limit" | "mention_limit" => { - app.mention_menu_limit = settings.mention_menu_limit; - app.composer.mention_completion_cache = None; - app.composer.mention_discovery.invalidate(); - app.needs_redraw = true; - } - "mention_menu_behavior" | "mention_behavior" | "mention_menu" => { - app.mention_menu_behavior = settings.mention_menu_behavior.clone(); - app.composer.mention_completion_cache = None; - app.composer.mention_discovery.invalidate(); - app.needs_redraw = true; - } - "mention_walk_depth" | "mention_depth" | "completions_walk_depth" => { - app.mention_walk_depth = settings.mention_walk_depth; - app.composer.mention_completion_cache = None; - app.composer.mention_discovery.invalidate(); - app.needs_redraw = true; - } - "workspace_follow_symlinks" | "follow_symlinks" => { - // Persist first: a failed save leaves the live value as it was - // (C01-08). - if persist && let Err(e) = persist_single_setting(&key, value) { - return CommandResult::error(format!("Failed to save: {e}")); - } - app.workspace_follow_symlinks = settings.workspace_follow_symlinks; - app.composer.mention_completion_cache = None; - app.composer.mention_discovery.invalidate(); - app.needs_redraw = true; - // Engine tools use EngineConfig which is fixed at startup - return CommandResult::message(if persist { - format!( - "workspace_follow_symlinks = {} (saved; restart required for engine tools)", - settings.workspace_follow_symlinks - ) - } else { - format!( - "workspace_follow_symlinks = {} (session only for UI; restart required for engine tools)", - settings.workspace_follow_symlinks - ) - }); - } - "transcript_spacing" | "spacing" => { - app.transcript_spacing = - crate::tui::app::TranscriptSpacing::from_setting(&settings.transcript_spacing); - app.mark_history_updated(); - } - "tool_collapse" | "tool_collapse_mode" | "collapse" => { - app.tool_collapse_mode = - crate::tui::app::ToolCollapseMode::from_setting(&settings.tool_collapse_mode); - app.expanded_tool_runs.clear(); - app.mark_history_updated(); - } - // `default_mode` is a restart default, not a live mode switch. The - // `/mode` command owns synchronized session transitions. - "default_mode" => {} - "mode" => { - let mode = AppMode::from_setting(&settings.default_mode); - app.set_mode(mode); - action = Some(AppAction::ModeChanged(mode)); - } - "max_history" | "history" => { - app.max_input_history = settings.max_input_history; - } - "reasoning_effort" | "effort" => { - app.reasoning_effort_preference = settings - .reasoning_effort - .as_deref() - .map(ReasoningEffort::from_setting); - app.reasoning_effort = app.reasoning_effort_preference.map_or_else( - || { - if app.auto_model { - ReasoningEffort::Auto - } else { - ReasoningEffort::default() - } - }, - |requested| { - if app.auto_model { - requested - } else { - requested.normalize_for_provider(app.api_provider) - } - }, - ); - app.invalidate_route_receipts_for_reasoning_change(); - app.update_model_compaction_budget(); - action = Some(AppAction::UpdateCompaction(app.compaction_config())); - } - "context_panel" | "context" | "session_panel" => { - app.context_panel = settings.context_panel; - app.needs_redraw = true; - } - "sessions_rail" | "sessions_panel" | "session_rail" => { - app.sessions_rail = settings.sessions_rail; - app.needs_redraw = true; - } - _ => {} - } - - let display_value = match key.as_str() { - "default_mode" | "mode" => settings.default_mode.clone(), - "cost_currency" | "currency" => settings.cost_currency.clone(), - "theme" | "ui_theme" => settings.theme.clone(), - "synchronized_output" | "sync_output" | "sync" => settings.synchronized_output.clone(), - "background_color" | "background" | "bg" => settings - .background_color - .clone() - .unwrap_or_else(|| "default".to_string()), - "reasoning_effort" | "effort" => settings.reasoning_effort.as_deref().map_or_else( - || "config/default".to_string(), - |value| { - ReasoningEffort::from_setting_for_provider(value, app.api_provider) - .as_setting_for_provider(app.api_provider) - .to_string() - }, - ), - "composer_vim_mode" | "vim_mode" | "vim" => settings.composer_vim_mode.clone(), - "composer_multiline_mode" | "multiline_mode" | "multiline" => { - settings.composer_multiline_mode.to_string() - } - "low_motion" | "motion" => settings.low_motion.to_string(), - "fancy_animations" | "fancy" | "animations" => settings.fancy_animations.to_string(), - _ => value.to_string(), - }; - - let message = if persist { - if let Err(e) = persist_single_setting(&key, value) { - // C01-08: the projection above already changed live state (and - // some arms validate only while projecting, so saving first could - // persist a value that never applied). Report exactly that partial - // effect, and keep the action so the engine matches the UI instead - // of silently diverging from it. - return CommandResult { - message: Some(format!( - "Error: {key} = {display_value} applies to this session only; saving failed: {e}" - )), - action, - is_error: true, - }; - } - format!("{key} = {display_value} (saved)") - } else { - format!("{key} = {display_value} (session only, add --save to persist)") - }; - CommandResult { - message: Some(message), - action, - is_error: false, - } -} - -/// Persist exactly the one key `/set --save` changed. -/// -/// `/set` loads a `Settings` copy up front to validate the value and to project -/// it onto live `App` state, and a lot of `App` mutation happens in between. That -/// copy is a stale snapshot by the time we get here, so saving *it* would write -/// back every other field as it looked before — reverting any mode, thinking, -/// model, or permission write that landed in the meantime. Re-applying the single -/// key inside [`Settings::transact`] persists the user's actual edit and nothing -/// else. `Settings::set` is the same normalizer the copy above already accepted -/// the value through, so this cannot fail for a value that validated. -fn persist_single_setting(key: &str, value: &str) -> anyhow::Result<()> { - Settings::transact(|settings| settings.set(key, value)) -} - -/// Select the TUI operating mode. -pub fn mode(app: &mut App, arg: Option<&str>) -> CommandResult { - let Some(arg) = arg.filter(|value| !value.trim().is_empty()) else { - return CommandResult::action(AppAction::OpenModePicker); - }; - // The legacy YOLO spellings are a one-way permission shorthand, not a - // mode: route them to the full-access compat path before parse folds - // them to Act. - if AppMode::is_legacy_bypass_alias(arg) { - let (message, changed) = switch_yolo_compat_with_status(app); - if changed { - CommandResult::with_message_and_action(message, AppAction::ModeChanged(app.mode)) - } else { - CommandResult::message(message) - } - } else { - mode_selection(app, arg) - } -} - -/// `/mode ` for the real modes (Plan/Act/Operate). -fn mode_selection(app: &mut App, arg: &str) -> CommandResult { - match AppMode::parse(arg) { - Some(mode) => { - let (message, changed) = switch_mode_with_status(app, mode); - if changed { - CommandResult::with_message_and_action(message, AppAction::ModeChanged(mode)) - } else { - CommandResult::message(message) - } - } - None => CommandResult::error(format!("Usage: {}", super::MODE_INFO.usage)), - } -} - -pub fn switch_mode(app: &mut App, mode: AppMode) -> String { - switch_mode_with_status(app, mode).0 -} - -/// Returns the user-facing sentence and whether live mode moved (the caller -/// emits `AppAction::ModeChanged` only for the latter). -/// -/// The three outcomes read differently on purpose. Before the typed -/// [`SettingSelection`], a refusal and a same-mode selection that *did* persist -/// the startup default both came back as "Already in X mode." — so the one case -/// where `/mode` had written something looked exactly like the case where it had -/// written nothing. -fn switch_mode_with_status(app: &mut App, mode: AppMode) -> (String, bool) { - match app.select_mode(mode) { - SettingSelection::Changed => ( - format!( - "Switched to {} mode.", - mode.display_name_localized(app.ui_locale) - ), - true, - ), - SettingSelection::PersistedSame => (app.mode_startup_default_receipt(mode), false), - SettingSelection::Refused => ( - app.setting_locked_message(MessageId::SettingSubjectMode), - false, - ), - } -} - -/// Status for the legacy YOLO alias: user-facing copy names Full Access (Work), -/// because the alias is invisible Work + Full Access. -fn switch_yolo_compat_with_status(app: &mut App) -> (String, bool) { - match app.select_yolo_compat() { - SettingSelection::Changed => ( - format!( - "Switched to {}.", - tr(app.ui_locale, MessageId::HotbarActionModeYoloName) - ), - true, - ), - SettingSelection::PersistedSame => { - (app.mode_startup_default_receipt(AppMode::Agent), false) - } - SettingSelection::Refused => ( - app.setting_locked_message(MessageId::SettingSubjectMode), - false, - ), - } -} - -/// `/theme [name]` — with no argument, open the interactive picker (arrow -/// keys, live preview, Enter to persist, Esc to revert). With an argument, -/// route through `set_config_value("theme", ...)` so the apply + save flow is -/// shared with `/config`. -pub fn theme(app: &mut App, arg: Option<&str>) -> CommandResult { - match arg.map(str::trim).filter(|s| !s.is_empty()) { - None => CommandResult::action(AppAction::OpenThemePicker), - Some("schema") => CommandResult::message(codewhale_palette::user_theme_schema_json()), - Some("path") => match codewhale_palette::user_themes_dir() { - Ok(path) => CommandResult::message(format!( - "User themes: {}\nSelect with: /theme custom:", - path.display() - )), - Err(error) => CommandResult::error(error), - }, - // `underwater` is an ordinary theme (aliases `deepsea`/`deep-sea`/ - // `ombre` fold through the same normalizer); the painted ocean field - // is the theme itself, not a treatment beside it. - Some(name) => set_config_value(app, "theme", name, true), - } -} - -/// Manage workspace-level trust and the per-path allowlist. -/// -/// Subcommands: -/// - `/trust` – show current state and trusted external paths -/// - `/trust on|off` – change file-tool trust for this session only -/// - `/trust on|off --save` – also persist workspace trust for project sources -/// - `/trust add ` – add a directory to the allowlist (#29) -/// - `/trust remove ` (alias `rm`) – remove a path from the allowlist -/// - `/trust list` – list trusted external paths for this workspace -pub fn trust(app: &mut App, arg: Option<&str>) -> CommandResult { - let raw = arg.map(str::trim).unwrap_or(""); - let mut parts = raw.splitn(2, char::is_whitespace); - let sub = parts.next().unwrap_or("").to_lowercase(); - let rest = parts.next().map(str::trim).unwrap_or(""); - let workspace = app.workspace.clone(); - - match sub.as_str() { - "" | "status" | "list" => trust_status(&workspace, app, sub == "list"), - "on" | "enable" | "yes" | "y" | "off" | "disable" | "no" | "n" => { - if !matches!(rest, "" | "--save") { - return CommandResult::error(format!( - "{} /trust on|off [--save]", - tr(app.ui_locale, MessageId::HelpUsageLabel) - )); - } - CommandResult::action(AppAction::SetWorkspaceTrust { - trusted: matches!(sub.as_str(), "on" | "enable" | "yes" | "y"), - save: rest == "--save", - }) - } - "add" => trust_add(&workspace, rest), - "remove" | "rm" | "del" | "delete" => trust_remove(&workspace, rest), - other => CommandResult::error(format!( - "Unknown /trust action `{other}`. Use `/trust`, `/trust on|off [--save]`, `/trust add `, or `/trust remove `." - )), - } -} - -pub(crate) async fn set_workspace_trust(app: &mut App, trusted: bool, save: bool) -> Result<()> { - if !save { - app.trust_mode = trusted; - return Ok(()); - } - // Revocation restricts live file access even if the saved decision cannot be updated. - if !trusted { - app.trust_mode = false; - } - let workspace = app.workspace.clone(); - #[cfg(test)] - let ticket = crate::test_support::env_scope_ticket(); - tokio::task::spawn_blocking(move || { - #[cfg(test)] - let _membership = crate::test_support::join_env_scope(ticket); - crate::config::set_workspace_trust(&workspace, trusted) - }) - .await??; - app.trust_mode = trusted; - Ok(()) -} - -/// What `/trust on|off [--save]` just did, for the transcript. The toast -/// alone fades, and the footer does not show trust mode, so without this -/// line the command appeared to do nothing. -pub(crate) fn trust_change_note(trusted: bool, save: bool) -> String { - let session = if trusted { - "Trust mode is on for this session: file tools may now reach files outside this folder. `/trust off` turns it off." - } else { - "Trust mode is off for this session: it no longer lets file tools reach files outside this folder." - }; - let saved = match (save, trusted) { - (false, _) => return session.to_string(), - (true, true) => { - "Saved for this folder: its project skills, commands, hooks, MCP servers and context are trusted in later sessions too." - } - (true, false) => { - "Saved for this folder: its project skills, commands, hooks, MCP servers and context are no longer trusted." - } - }; - format!("{session}\n{saved}") -} - -fn trust_status(workspace: &Path, app: &App, force_paths: bool) -> CommandResult { - let trust = crate::workspace_trust::WorkspaceTrust::load_for(workspace); - let mut lines = Vec::new(); - lines.push(format!( - "Workspace trust mode: {}", - if app.trust_mode { - "enabled" - } else { - "disabled" - } - )); - if trust.paths().is_empty() { - if force_paths { - lines.push("No external paths trusted from this workspace.".to_string()); - } else { - lines.push( - "No external paths trusted yet. Use `/trust add ` to allow a directory." - .to_string(), - ); - } - } else { - lines.push(format!("Trusted external paths ({}):", trust.paths().len())); - for path in trust.paths() { - lines.push(format!(" • {}", path.display())); - } - } - CommandResult::message(lines.join("\n")) -} - -fn trust_add(workspace: &Path, raw: &str) -> CommandResult { - if raw.is_empty() { - return CommandResult::error( - "Usage: /trust add . Supply an absolute path or a path relative to the workspace.", - ); - } - let path = PathBuf::from(expand_tilde(raw)); - if !path.exists() { - return CommandResult::error(format!( - "Path not found: {} — supply an existing directory or file.", - path.display() - )); - } - match crate::workspace_trust::add(workspace, &path) { - Ok(stored) => CommandResult::message(format!( - "Added to trust list for this workspace: {}", - stored.display() - )), - Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")), - } -} - -fn trust_remove(workspace: &Path, raw: &str) -> CommandResult { - if raw.is_empty() { - return CommandResult::error("Usage: /trust remove "); - } - let path = PathBuf::from(expand_tilde(raw)); - match crate::workspace_trust::remove(workspace, &path) { - Ok(true) => CommandResult::message(format!("Removed from trust list: {}", path.display())), - Ok(false) => CommandResult::message(format!("Not in trust list: {}", path.display())), - Err(err) => CommandResult::error(format!("Failed to update trust list: {err}")), - } -} - -fn expand_tilde(raw: &str) -> String { - if let Some(rest) = raw.strip_prefix("~/") - && let Some(home) = crate::config::effective_home_dir() - { - return home.join(rest).to_string_lossy().into_owned(); - } else if raw == "~" - && let Some(home) = crate::config::effective_home_dir() - { - return home.to_string_lossy().into_owned(); - } - raw.to_string() -} - -/// Toggle LSP diagnostics on/off or show status. -/// -/// - `/lsp on` — enable inline LSP diagnostics -/// - `/lsp off` — disable inline LSP diagnostics -/// - `/lsp status` — show whether diagnostics are currently enabled -pub fn lsp_command(app: &mut App, arg: Option<&str>) -> CommandResult { - let raw = arg.map(str::trim).unwrap_or(""); - // Access lsp_manager config through the App's engine handle - let current_enabled = app.lsp_enabled; - - match raw { - "" | "status" => { - let status = if current_enabled { "on" } else { "off" }; - CommandResult::message(format!( - "LSP diagnostics are currently **{status}**.\n\n\ - Use `/lsp on` to enable or `/lsp off` to disable inline diagnostics after file edits." - )) - } - "on" | "enable" | "1" | "true" => { - app.lsp_enabled = true; - CommandResult::message( - "LSP diagnostics enabled — file edit results will include compiler errors and warnings when available.", - ) - } - "off" | "disable" | "0" | "false" => { - app.lsp_enabled = false; - CommandResult::message("LSP diagnostics disabled.") - } - other => CommandResult::error(format!( - "Unknown /lsp argument `{other}`. Use `/lsp on`, `/lsp off`, or `/lsp status`." - )), - } -} - -pub fn login(app: &mut App, arg: Option<&str>) -> CommandResult { - let raw = arg.map(str::trim).unwrap_or(""); - let token = raw.split_whitespace().next().unwrap_or(""); - match token { - "" | "status" => CommandResult::message(login_status_text(app)), - "key" | "provider" => CommandResult::with_message_and_action( - "Open the provider picker to store an API key. Account sign-in is `codewhale login`.", - AppAction::OpenProviderPicker, - ), - "account" => CommandResult::message( - "TUI cannot start the browser device flow without freezing the session.\n\ - Run `codewhale login` (same as `codewhale account login`) in a terminal.\n\ - Then `/login` to confirm the session landed." - .to_string(), - ), - other => CommandResult::error(format!( - "Usage: /login [status|account|key]\nUnknown argument: {other}\n\ - To sign in to a plugin provider, use `/connect `." - )), - } -} - -fn is_enabled_plugin_provider(app: &App, provider: &str) -> bool { - app.plugin_registry - .active_plugins() - .into_iter() - .any(|plugin| plugin.manifest.providers.contains_key(provider)) -} - -pub fn connect(app: &mut App, arg: Option<&str>) -> CommandResult { - let raw = arg.map(str::trim).unwrap_or(""); - if raw.split_whitespace().count() > 1 { - return CommandResult::error("Usage: /connect []"); - } - match raw { - "" => CommandResult::action(AppAction::OpenProviderPicker), - provider if is_enabled_plugin_provider(app, provider) => { - CommandResult::action(AppAction::StartPluginLogin { - provider: provider.to_owned(), - }) - } - other => CommandResult::error(format!( - "Usage: /connect []\nUnknown or inactive plugin provider: {other}\n\ - Codewhale account sign-in is `/login`." - )), - } -} - -pub fn disconnect(app: &mut App, arg: Option<&str>) -> CommandResult { - let raw = arg.map(str::trim).unwrap_or(""); - if raw.is_empty() || raw.split_whitespace().count() > 1 { - return CommandResult::error("Usage: /disconnect "); - } - if is_enabled_plugin_provider(app, raw) { - CommandResult::action(AppAction::StartPluginLogout { - provider: raw.to_owned(), - }) - } else { - CommandResult::error(format!( - "Usage: /disconnect \nUnknown or inactive plugin provider: {raw}\n\ - Signing out of Codewhale is `/logout`." - )) - } -} - -fn login_status_text(app: &App) -> String { - use codewhale_secrets::account::{ - ACCOUNT_API_BASE_ENV, AccountSessionState, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE, - secure_account_session_secrets, - }; - - let api_base = std::env::var(ACCOUNT_API_BASE_ENV) - .ok() - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string()); - let account = match secure_account_session_secrets() { - Ok(secrets) => { - match AccountSessionStore::new(secrets, None, &api_base) - .runtime_info_at(chrono::Utc::now()) - { - Ok(info) => match info.state { - AccountSessionState::SignedOut => format!("not signed in (api {api_base})"), - AccountSessionState::Authenticated => format!("signed in (api {api_base})"), - AccountSessionState::OfflineCached => { - format!("offline cached (api {api_base})") - } - AccountSessionState::Expired => format!("expired (api {api_base})"), - AccountSessionState::Revoked => format!("revoked (api {api_base})"), - }, - Err(error) => format!("unavailable ({error})"), - } - } - Err(error) => format!("unavailable ({error})"), - }; - let provider = app.provider_identity_for_persistence(); - format!( - "Codewhale login\n\ - Account: {account}\n\ - Active provider: {provider}\n\ - \n\ - Sign in: `codewhale login`\n\ - Provider key: `codewhale auth set --provider ` or `/login key`\n\ - Sign out: `/logout` or `codewhale logout`" - ) -} - -fn clear_local_account_session() -> Result { - use codewhale_secrets::account::{ - ACCOUNT_API_BASE_ENV, AccountSessionStore, DEFAULT_ACCOUNT_API_BASE, - secure_account_session_secrets, - }; - let secrets = secure_account_session_secrets().map_err(|error| error.to_string())?; - let api_base = std::env::var(ACCOUNT_API_BASE_ENV) - .ok() - .map(|value| value.trim().trim_end_matches('/').to_string()) - .filter(|value| !value.is_empty()) - .unwrap_or_else(|| DEFAULT_ACCOUNT_API_BASE.to_string()); - let store = AccountSessionStore::new(secrets, None, &api_base); - let had = store.load().map_err(|error| error.to_string())?.is_some(); - store.clear().map_err(|error| error.to_string())?; - Ok(had) -} - -fn clear_legacy_sandbox_slot() -> Result { - let secrets = codewhale_secrets::Secrets::auto_detect(); - let had = secrets - .get(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT) - .map_err(|error| error.to_string())? - .is_some_and(|value| !value.trim().is_empty()); - if had { - secrets - .delete(codewhale_secrets::LEGACY_SANDBOX_TOKEN_SLOT) - .map_err(|error| error.to_string())?; - } - Ok(had) -} - -/// Logout — clear the active provider key, the Codewhale account session, -/// and any legacy sandbox token. Named custom providers still clear only their own -/// table. For a full every-provider wipe, use `codewhale logout`. -pub fn logout(app: &mut App) -> CommandResult { - let provider_name = app.provider_identity_for_persistence().to_string(); - let legacy_sandbox = clear_legacy_sandbox_slot(); - match clear_active_provider_api_key(&provider_name) { - Ok(()) => { - app.onboarding = OnboardingState::Provider; - app.onboarding_needs_api_key = true; - app.onboarding_provider = app.api_provider; - app.onboarding_missing_key_recovery = true; - app.api_key_env_only = false; - let mut cleared = vec![format!("provider key ({provider_name})")]; - match clear_local_account_session() { - Ok(true) => cleared.push("Codewhale account session".to_string()), - Ok(false) => {} - Err(error) => cleared.push(format!("account session not cleared ({error})")), - } - match legacy_sandbox { - Ok(true) => cleared.push("legacy sandbox token".to_string()), - Ok(false) => {} - Err(error) => cleared.push(format!("legacy sandbox token not cleared ({error})")), - } - CommandResult::with_message_and_action( - format!( - "Cleared {}. \ - Use `codewhale login` to sign in again, or `codewhale auth set --provider ` to store a provider key.", - cleared.join(", ") - ), - AppAction::OpenProviderPicker, - ) - } - Err(e) => CommandResult::error(format!("Failed to clear API key for {provider_name}: {e}")), - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::config::Config; - use crate::config::NotificationMethod; - use crate::test_support::{EnvVarGuard, TestEnvLock, lock_test_env}; - use crate::tui::app::{App, TuiOptions}; - use std::env; - use std::fs; - use std::path::Path; - use std::path::PathBuf; - use std::time::{SystemTime, UNIX_EPOCH}; - - struct EnvGuard { - _vars: Vec, - _lock: TestEnvLock, - } - - impl EnvGuard { - fn new(home: &Path) -> Self { - let lock = lock_test_env(); - let config_path = home.join(".deepseek").join("config.toml"); - let vars = vec![ - EnvVarGuard::set("HOME", home), - EnvVarGuard::set("USERPROFILE", home), - EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")), - EnvVarGuard::remove("CODEWHALE_CONFIG_PATH"), - EnvVarGuard::set("DEEPSEEK_CONFIG_PATH", config_path), - EnvVarGuard::remove("CODEWHALE_ALLOW_SHELL"), - EnvVarGuard::remove("DEEPSEEK_ALLOW_SHELL"), - EnvVarGuard::remove("DEEPSEEK_APPROVAL_POLICY"), - EnvVarGuard::remove("NO_ANIMATIONS"), - EnvVarGuard::remove("TERM_PROGRAM"), - EnvVarGuard::remove("PTYXIS_VERSION"), - EnvVarGuard::remove("CODEWHALE_SEARCH_PROVIDER"), - EnvVarGuard::remove("DEEPSEEK_SEARCH_PROVIDER"), - EnvVarGuard::remove("TAVILY_API_KEY"), - ]; - Self { - _vars: vars, - _lock: lock, - } - } - } - - fn create_test_app_with_config(config: &Config) -> App { - let options = TuiOptions { - model: "test-model".to_string(), - // Keep command tests independent from the developer's saved - // `default_mode` setting: with `false`, App::new starts in the - // saved mode, so a machine with `default_mode = "yolo"` flips - // `allow_shell` on and breaks the allow_shell assertions. - start_in_agent_mode: true, - skip_onboarding: false, - ..crate::tui::test_support::test_tui_options(PathBuf::from(".")) - }; - let mut app = App::new(options, config); - // App::new folds in saved TUI settings from the developer machine. - // Pin command tests back to DeepSeek semantics so model aliases are - // not normalized through a provider selected in an interactive run. - app.model = "test-model".to_string(); - app.auto_model = false; - app.api_provider = crate::config::ProviderKind::Deepseek; - app.model_ids_passthrough = false; - app - } - - fn create_test_app() -> App { - create_test_app_with_config(&Config::default()) - } - - #[test] - fn contextual_tips_disable_only_guidance_and_keep_session_cap() { - use crate::tui::app::{StatusToast, StatusToastKind, StatusToastLevel}; - use crate::tui::behavioral_tips::BehavioralTip; - - let temp = tempfile::tempdir().unwrap(); - let _guard = EnvGuard::new(temp.path()); - let mut app = create_test_app(); - app.status_toasts.clear(); - assert!(app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); - app.push_status_toast("warning receipt", StatusToastLevel::Warning, None); - app.push_status_toast("error receipt", StatusToastLevel::Error, None); - app.sticky_status = Some(StatusToast::context_pressure( - "context warning", - crate::context_budget::PressureLevel::High, - )); - - let result = crate::commands::execute("/config contextual_tips off", &mut app); - assert!(!result.is_error); - assert!(!app.behavioral_tips.enabled()); - assert_eq!( - app.status_toasts - .iter() - .map(|toast| toast.text.as_str()) - .collect::>(), - ["warning receipt", "error receipt"] - ); - assert!(matches!( - app.sticky_status.as_ref().unwrap().kind, - StatusToastKind::ContextPressure(_) - )); - assert!(!app.maybe_show_behavioral_tip(BehavioralTip::McpValidation)); - - assert!(!crate::commands::execute("/config contextual_tips on", &mut app).is_error); - assert!(app.behavioral_tips.enabled()); - assert!( - !app.maybe_show_behavioral_tip(BehavioralTip::McpValidation), - "reenabling must not reset the session cap" - ); - } - - #[test] - fn contextual_tips_command_persists_and_reports_failed_save() { - let temp = tempfile::tempdir().unwrap(); - let _guard = EnvGuard::new(temp.path()); - let mut app = create_test_app(); - Settings::transact(|settings| { - settings.theme = "terminal".into(); - settings - .behavioral_tip_impressions - .insert("planning_mode".into(), 2); - Ok(()) - }) - .unwrap(); - - let result = crate::commands::execute("/config contextual_tips off --save", &mut app); - assert!(!result.is_error); - let saved = Settings::load_persisted().unwrap(); - assert!(!saved.contextual_tips); - assert_eq!(saved.theme, "terminal"); - assert_eq!( - saved.behavioral_tip_impressions.get("planning_mode"), - Some(&2) - ); - assert!( - !create_test_app().behavioral_tips.enabled(), - "restart must load the saved opt-out" - ); - - assert!(!crate::commands::execute("/config contextual_tips on --save", &mut app).is_error); - assert!(create_test_app().behavioral_tips.enabled()); - assert_eq!( - Settings::load_persisted() - .unwrap() - .behavioral_tip_impressions - .get("planning_mode"), - Some(&2) - ); - let path = Settings::path().unwrap(); - let before = fs::read(&path).unwrap(); - assert!( - crate::commands::execute("/config contextual_tips invalid --save", &mut app).is_error - ); - assert_eq!(fs::read(&path).unwrap(), before); - assert!(app.behavioral_tips.enabled()); - - let malformed = "contextual_tips = [private_fixture_payload\n"; - fs::write(&path, malformed).unwrap(); - let failed = crate::commands::execute("/config contextual_tips off --save", &mut app); - assert!(failed.is_error); - assert!( - !app.behavioral_tips.enabled(), - "failed persistence still honors the session opt-out" - ); - assert_eq!(fs::read_to_string(path).unwrap(), malformed); - let message = failed.message.unwrap(); - assert!(message.contains("could not be saved"), "{message}"); - assert!( - !message.contains("private_fixture_payload"), - "parse errors must not echo settings contents" - ); - assert!(message.ends_with(&app.status_toasts.back().unwrap().text)); - } - - #[test] - fn screen_commands_dispatch_to_the_matching_screen_mode() { - let mut app = create_test_app(); - assert_eq!(app.screen_mode, ScreenMode::Fullscreen); - - let switched = crate::commands::execute("/inline", &mut app); - assert_eq!( - switched.action, - Some(AppAction::SetScreenMode(ScreenMode::Inline)), - "/inline must ask for the inline screen" - ); - assert!(!switched.is_error, "/inline must not be an error"); - - // The action is applied where the terminal lives, so the app is still - // fullscreen here; asking for the current mode reports, it does not - // emit a second action. - let repeated = crate::commands::execute("/fullscreen", &mut app); - assert!( - repeated.action.is_none(), - "already-current mode must not re-switch" - ); - assert!( - repeated - .message - .as_deref() - .is_some_and(|msg| msg.contains("Already on the fullscreen screen")), - "{:?}", - repeated.message - ); - - let rejected = crate::commands::execute("/inline sideways", &mut app); - assert!(rejected.is_error, "/inline takes no argument"); - } - - #[test] - fn config_unknown_setting_suggests_nearest_key() { - let mut app = create_test_app(); - let result = config_command(&mut app, Some("auto_compcat")); - assert!(result.is_error); - let text = result.message.as_deref().unwrap_or_default(); - assert!( - text.contains("Did you mean `/config auto_compact`?"), - "{text}" - ); - assert!(text.contains("/settings text"), "{text}"); - - let result = config_command(&mut app, Some("zzqqxxyy")); - assert!(result.is_error); - let text = result.message.as_deref().unwrap_or_default(); - assert!(!text.contains("Did you mean"), "{text}"); - assert!(text.contains("/settings text"), "{text}"); - - // Internal flags, actions and retired schema defs are not settings a - // user can look up, and are never suggested. - for internal in ["feature_intro_shown", "mcp_open", "fast_model"] { - let result = config_command(&mut app, Some(internal)); - assert!(result.is_error, "{internal}: {:?}", result.message); - let text = result.message.as_deref().unwrap_or_default(); - assert!(!text.contains(&format!("`/config {internal}`")), "{text}"); - } - } - - #[test] - fn config_workflow_and_goal_explain_the_effective_tables() { - let mut app = create_test_app(); - for token in ["workflow", "goal"] { - let result = config_command(&mut app, Some(token)); - assert!( - result.action.is_none(), - "{token} must not spend a model turn" - ); - let text = result.message.as_deref().unwrap_or_default(); - assert!( - text.contains("require_approval_for_writes"), - "{token}: {text}" - ); - assert!(text.contains("max_continuations"), "{token}: {text}"); - } - } - - #[test] - fn title_config_reports_the_default_not_the_session_override() { - let config = Config { - title: Some(" workspace\u{1b}]0;ignored\u{7}\u{202e}-default ".to_string()), - ..Config::default() - }; - let mut app = create_test_app_with_config(&config); - assert_eq!( - app.title_default.as_deref(), - Some("workspace]0;ignored-default") - ); - app.window_title = Some("session-override".to_string()); - - let shown = show_single_setting(&app, "title"); - - assert_eq!( - shown.message.as_deref(), - Some("title = workspace]0;ignored-default") - ); - } - - #[test] - fn title_config_normalizes_the_live_and_persisted_default() { - let dir = tempfile::tempdir().expect("isolated config dir"); - let config_path = dir.path().join("config.toml"); - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - - let result = set_config_value( - &mut app, - "title", - " Ev\u{1b}]0;PWNED\u{7}il\u{202e} Beta ", - true, - ); - - assert!(!result.is_error, "{:?}", result.message); - assert_eq!(app.title_default.as_deref(), Some("Ev]0;PWNEDil Beta")); - assert!(app.needs_redraw); - let loaded = Config::load(Some(config_path), None).expect("reload saved config"); - assert_eq!(loaded.title.as_deref(), Some("Ev]0;PWNEDil Beta")); - } - - /// The shipped preset must survive its own preflight, or `/config preset - /// calm` would be refused for a reason the user cannot act on. - #[test] - fn the_shipped_preset_passes_its_own_preflight() { - let app = create_test_app(); - let fields = crate::settings::preset_fields("calm").expect("the calm preset exists"); - assert_eq!(preset_preflight(&app, fields), None); - } - - /// A field the setter would reject must be caught *before* the transaction - /// opens. Previously the bundle was saved first and the per-field mirror - /// pass then failed, leaving the user with an error message and a rewritten - /// settings file. - #[test] - fn preset_preflight_refuses_an_invalid_field_before_any_write() { - let app = create_test_app(); - let refusal = preset_preflight(&app, &[("calm_mode", "true"), ("low_motion", "banana")]) - .expect("an invalid value must be refused"); - assert!( - refusal.contains("low_motion"), - "the refusal must name the offending field, got {refusal:?}" - ); - } - - /// A preset carrying a live-route key is refused whole while a turn runs, - /// rather than saving the bundle and then failing on that one field. - #[test] - fn preset_preflight_refuses_a_live_route_field_while_a_turn_runs() { - let mut app = create_test_app(); - app.is_loading = true; - let bundle = [("calm_mode", "true"), ("reasoning_effort", "high")]; - let refusal = - preset_preflight(&app, &bundle).expect("a live-route field must be refused mid-turn"); - assert!( - refusal.contains("locked while a turn is running"), - "got {refusal:?}" - ); - - app.is_loading = false; - assert_eq!( - preset_preflight(&app, &bundle), - None, - "the same bundle must apply once the turn ends" - ); - } - - /// The refusal list is the contract for #2982 on the slash surfaces. Keep - /// restart-only `default_mode` out of it: `set_config_value` deliberately - /// does not apply that key to the live session. - #[test] - fn live_route_key_list_covers_every_route_mutating_alias() { - for key in [ - "mode", - "model", - "default_model", - "reasoning_effort", - "effort", - "provider", - "approval_mode", - "approval_policy", - "approval", - ] { - assert!( - live_route_setting_subject(key).is_some(), - "{key} mutates the active route and must be locked mid-turn" - ); - } - for key in ["default_mode", "theme", "calm_mode", "rail_panel"] { - assert!( - live_route_setting_subject(key).is_none(), - "{key} does not mutate the active route and must stay settable" - ); - } - } - - #[test] - fn approval_aliases_are_inert_while_a_turn_is_running() { - let mut app = create_test_app(); - app.approval_mode = ApprovalMode::Suggest; - app.is_loading = true; - - for key in ["approval_mode", "approval_policy", "approval"] { - let result = set_config_value(&mut app, key, "never", false); - assert!(result.is_error, "{key} must be refused mid-turn"); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("locked while a turn is running")), - "unexpected refusal for {key}: {:?}", - result.message - ); - assert_eq!(app.approval_mode, ApprovalMode::Suggest); - } - } - - #[test] - fn config_preset_calm_applies_bundle_to_session_and_keeps_evidence() { - let mut app = create_test_app(); - app.calm_mode = false; - app.show_thinking = true; - app.show_tool_details = true; - app.fancy_animations = true; - - let result = config_command(&mut app, Some("preset calm")); - let message = result.message.unwrap_or_default(); - assert!( - message.contains("calm"), - "summary should name the preset: {message}" - ); - - assert!(app.calm_mode); - assert!(!app.show_tool_details); - assert!(app.low_motion); - assert!(!app.fancy_animations); - assert_eq!( - app.tool_collapse_mode, - crate::tui::app::ToolCollapseMode::Calm - ); - assert_eq!( - app.transcript_spacing, - crate::tui::app::TranscriptSpacing::Compact - ); - // Evidence preserved: thinking is not hidden by the preset. - assert!(app.show_thinking, "calm preset must not hide thinking"); - } - - #[test] - fn config_preset_unknown_name_reports_error() { - let mut app = create_test_app(); - let result = config_command(&mut app, Some("preset turbo")); - let message = result.message.unwrap_or_default(); - assert!( - message.to_lowercase().contains("unknown preset"), - "expected unknown-preset error, got: {message}" - ); - } - - #[test] - fn config_preset_save_without_name_reports_usage() { - let mut app = create_test_app(); - let result = config_command(&mut app, Some("preset --save")); - let message = result.message.unwrap_or_default(); - assert!( - message.contains("Usage: /config preset"), - "expected usage hint, got: {message}" - ); - assert!(!result.is_error); - } - - #[test] - fn work_surface_config_applies_live_and_accepts_bottom() { - let mut app = create_test_app(); - - let result = set_config_value(&mut app, "work_surface_placement", "left", false); - assert!(!result.is_error, "{:?}", result.message); - assert_eq!( - app.work_surface.placement, - crate::tui::work_surface::WorkSurfacePlacement::Left - ); - let shown = show_single_setting(&app, "work_surface_placement"); - assert_eq!( - shown.message.as_deref(), - Some("work_surface_placement = left") - ); - - let result = set_config_value(&mut app, "work_surface_placement", "bottom", false); - assert!(!result.is_error, "{:?}", result.message); - assert_eq!( - app.work_surface.placement, - crate::tui::work_surface::WorkSurfacePlacement::Bottom - ); - } - - #[test] - fn rail_command_on_restores_default_bottom_placement() { - let mut app = create_test_app(); - app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Off; - - let result = sidebar(&mut app, Some("on")); - - assert!(!result.is_error); - assert_eq!( - app.work_surface.placement, - crate::tui::work_surface::WorkSurfacePlacement::Bottom - ); - let message = result.message.unwrap_or_default(); - assert!(message.contains("bottom placement"), "got: {message}"); - } - - #[test] - fn pet_sound_command_is_opt_in_and_rejects_invalid_changes() { - let mut app = create_test_app(); - let status = pet(&mut app, Some("sound")); - assert!(!status.is_error); - assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff); - - assert!(!pet(&mut app, Some(" SOUND ON ")).is_error); - assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused); - for invalid in ["sound yes", "sound off extra", "sound on --save"] { - assert!(pet(&mut app, Some(invalid)).is_error, "{invalid}"); - assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundPaused); - } - assert!(!pet(&mut app, Some("sound off")).is_error); - assert_eq!(app.pet_watch.sound_label(), MessageId::PetWatchSoundOff); - } - - #[test] - fn pet_command_selects_the_main_view_without_taking_composer_focus() { - let mut app = create_test_app(); - app.onboarding = crate::tui::app::OnboardingState::None; - app.redaction_gate = false; - app.input = "kept draft".into(); - app.pet_watch.detach_for_test(); - - let on = pet(&mut app, None); - assert!(!on.is_error); - assert!(app.pet_watch.enabled); - assert!(crate::tui::pet_watch::is_open(&app)); - assert_eq!( - on.message.as_deref(), - Some(&*tr(app.ui_locale, MessageId::PetModeOn)) - ); - assert!(app.view_stack.is_empty()); - // Repeating `on` leaves the composer in the same shell focus. - assert!(!pet(&mut app, Some(" ON ")).is_error); - assert!(app.pet_watch.enabled); - assert!(crate::tui::pet_watch::is_open(&app)); - - let off = pet(&mut app, Some("off")); - assert!(!off.is_error); - assert!(!app.pet_watch.enabled); - assert!(!crate::tui::pet_watch::is_open(&app)); - assert!(app.view_stack.is_empty()); - assert_eq!(app.input, "kept draft"); - assert_eq!( - off.message.as_deref(), - Some(&*tr(app.ui_locale, MessageId::PetModeOff)) - ); - - // Bare /pet toggles back on; unknown verbs are refused with usage. - app.pet_watch.detach_for_test(); - assert!(!pet(&mut app, None).is_error); - assert!(app.pet_watch.enabled); - assert!(pet(&mut app, Some("bogus")).is_error); - let status = pet(&mut app, Some("status")); - assert!(!status.is_error); - let message = status.message.unwrap_or_default(); - assert!( - message.contains(&*tr(app.ui_locale, MessageId::PetModeOnLabel)), - "{message}" - ); - assert!( - message.contains(&*tr(app.ui_locale, MessageId::PetViewOpen)), - "{message}" - ); - } - - #[test] - fn rail_command_reports_narrow_terminal_top_fallback() { - let mut app = create_test_app(); - app.work_surface.placement = crate::tui::work_surface::WorkSurfacePlacement::Left; - // A 60-column host is below the side-rail floor, so the effective - // placement falls back to top; the status must say so rather than - // claim a left workbar renders. - let _ = crate::tui::work_surface::height(&mut app, 60, 24, u16::MAX); - - let result = sidebar(&mut app, None); - - assert!(!result.is_error); - let message = result.message.unwrap_or_default(); - assert!(message.contains("left placement"), "got: {message}"); - assert!(message.contains("showing top for now"), "got: {message}"); - } - - #[test] - fn rail_command_off_never_claims_visibility() { - let mut app = create_test_app(); - - let result = sidebar(&mut app, Some("off")); - - assert!(!result.is_error); - assert_eq!( - app.work_surface.placement, - crate::tui::work_surface::WorkSurfacePlacement::Off - ); - let message = result.message.unwrap_or_default(); - assert!(message.contains("Workbar is off"), "got: {message}"); - assert!( - !message.contains("Workbar is visible"), - "the readout must never claim a hidden surface renders: {message}" - ); - } - - #[test] - fn rail_command_rejects_retired_auto_mode() { - let mut app = create_test_app(); - - let result = sidebar(&mut app, Some("auto")); - - assert!(result.is_error); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("Usage: /workbar") - ); - } - - #[test] - fn test_mode_yolo_sets_all_flags() { - let mut app = create_test_app(); - // Switch to Agent first to guarantee a clean starting state regardless of - // user settings on the host machine. - let _ = mode(&mut app, Some("agent")); - let result = mode(&mut app, Some("yolo")); - // YOLO is invisible Work+Bypass shorthand — user-facing copy names Full Access (Work). - assert!( - result - .message - .unwrap() - .contains("Switched to Full Access (Work)") - ); - assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); - assert!(app.allow_shell); - assert!(app.trust_mode); - assert!(app.yolo); - assert_eq!(app.approval_mode, ApprovalMode::Bypass); - // The deprecated YOLO alias remaps to Agent mode (M6 compat shim). - assert_eq!(app.mode, AppMode::Agent); - } - - #[test] - fn test_mode_switch_command_accepts_names_and_numbers() { - let mut app = create_test_app(); - let _ = mode(&mut app, Some("agent")); - assert_eq!(app.mode, AppMode::Agent); - let result = mode(&mut app, Some("2")); - assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan))); - assert_eq!(app.mode, AppMode::Plan); - let result = mode(&mut app, Some("act")); - assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); - assert_eq!(app.mode, AppMode::Agent); - let _ = mode(&mut app, Some("plan")); - assert_eq!(app.mode, AppMode::Plan); - let result = mode(&mut app, Some("3")); - assert_eq!( - result.action, - Some(AppAction::ModeChanged(AppMode::Operate)) - ); - assert_eq!(app.mode, AppMode::Operate); - let result = mode(&mut app, Some("5")); - assert!(result.is_error); - assert_eq!(app.mode, AppMode::Operate); - let result = mode(&mut app, Some("9")); - assert!(result.is_error); - assert_eq!(app.mode, AppMode::Operate); - let result = mode(&mut app, Some("4")); - // "4" still routes to the deprecated YOLO alias, which lands in Agent - // mode with bypass approvals (M6 compat shim). - assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); - assert_eq!(app.mode, AppMode::Agent); - assert!(app.yolo); - } - - #[test] - fn test_mode_without_arg_opens_picker() { - let mut app = create_test_app(); - let result = mode(&mut app, None); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::OpenModePicker))); - } - - #[test] - fn test_mode_rejects_unknown_value() { - let mut app = create_test_app(); - let result = mode(&mut app, Some("fast")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Usage: /mode")); - } - - #[test] - fn test_show_config_defaults_to_native() { - let mut app = create_test_app(); - app.session.total_tokens = 1234; - let result = show_config(&mut app, None); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::OpenConfigView))); - } - - #[test] - fn test_show_config_native_opens_config_view() { - let mut app = create_test_app(); - let result = show_config(&mut app, Some("native")); - assert!(result.message.is_none()); - assert!(matches!(result.action, Some(AppAction::OpenConfigView))); - } - - #[test] - fn test_show_config_tui_and_web_open_the_same_config_view() { - let mut app = create_test_app(); - for arg in ["tui", "web", "TUI", " Web "] { - let result = show_config(&mut app, Some(arg)); - assert!(result.message.is_none(), "{arg}"); - assert!( - matches!(result.action, Some(AppAction::OpenConfigView)), - "{arg}" - ); - } - } - - #[test] - fn test_show_config_rejects_unknown_editor() { - let mut app = create_test_app(); - let result = show_config(&mut app, Some("vim")); - assert!(result.is_error); - assert!(result.message.unwrap().contains("Usage: /config")); - } - - #[test] - fn test_show_settings_loads_from_file() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let result = show_settings(&mut app); - // Settings should load (may use defaults if file doesn't exist) - assert!(result.message.is_some()); - } - - #[test] - fn settings_command_opens_typed_editor_and_preserves_text_mode() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - - let modal = settings_command(&mut app, None); - assert!(modal.message.is_none()); - assert!(matches!(modal.action, Some(AppAction::OpenConfigView))); - - let text = settings_command(&mut app, Some("text")); - let message = text.message.as_deref().expect("settings diagnostic text"); - assert!(message.contains("Settings:"), "{message}"); - assert!( - message.contains("model defaults: config.toml"), - "{message}" - ); - assert!(!message.contains("provider_models:"), "{message}"); - assert!(message.contains("Config file:"), "{message}"); - assert!(text.action.is_none()); - } - - #[test] - fn config_model_updates_app_state() { - let mut app = create_test_app(); - let _old_model = app.model.clone(); - let result = config_command(&mut app, Some("model deepseek-v4-flash")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("model = deepseek-v4-flash")); - assert_eq!(app.model, "deepseek-v4-flash"); - assert!(matches!( - result.action, - Some(AppAction::UpdateCompaction(_)) - )); - } - - #[test] - fn config_model_rejects_foreign_model_for_direct_provider() { - let mut app = create_test_app(); - app.api_provider = ProviderKind::Zai; - app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); - - let result = set_config_value(&mut app, "model", "deepseek-v4-pro", false); - - assert!(result.is_error); - assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL); - assert!(result.action.is_none()); - let message = result.message.as_deref().expect("rejection message"); - assert!( - message.contains("not compatible with provider 'zai'") - || message.contains("not served by direct provider zai"), - "unexpected rejection message: {message}" - ); - assert!(message.contains("deepseek-v4-pro"), "{message}"); - } - - #[test] - fn config_model_auto_preserves_explicit_thinking() { - let mut app = create_test_app(); - app.reasoning_effort = ReasoningEffort::Off; - app.reasoning_effort_preference = Some(ReasoningEffort::Off); - - let result = config_command(&mut app, Some("model auto")); - - assert!(result.message.is_some()); - assert!(app.auto_model); - assert_eq!(app.model, "auto"); - assert_eq!(app.reasoning_effort, ReasoningEffort::Off); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("thinking = off")) - ); - assert!(app.last_effective_model.is_none()); - assert!(app.last_effective_reasoning_effort.is_none()); - } - - #[test] - fn config_model_auto_releases_implicit_fixed_model_thinking() { - let mut app = create_test_app(); - app.reasoning_effort = ReasoningEffort::Max; - app.reasoning_effort_preference = None; - - let result = config_command(&mut app, Some("model auto")); - - assert!(result.message.is_some()); - assert!(app.auto_model); - assert_eq!(app.reasoning_effort, ReasoningEffort::Auto); - assert_eq!(app.reasoning_effort_preference, None); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("thinking = auto")) - ); - } - - #[test] - fn config_reasoning_effort_applies_while_model_routing_is_auto() { - let mut app = create_test_app(); - app.set_model_selection("auto".to_string()); - app.reasoning_effort = ReasoningEffort::Auto; - app.reasoning_effort_preference = None; - - let result = set_config_value(&mut app, "reasoning_effort", "low", false); - - assert!(!result.is_error); - assert_eq!(app.reasoning_effort, ReasoningEffort::Low); - assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Low)); - assert!(matches!( - result.action, - Some(AppAction::UpdateCompaction(_)) - )); - } - - #[test] - fn config_default_model_cannot_replace_a_non_deepseek_live_route() { - let temp_root = tempfile::tempdir().expect("isolated configuration"); - let _guard = EnvGuard::new(temp_root.path()); - let config_path = crate::config_persistence::config_toml_path(None).expect("config path"); - fs::create_dir_all(config_path.parent().expect("config directory")).expect("mkdir"); - fs::write( - &config_path, - "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n", - ) - .expect("config"); - let mut app = create_test_app(); - app.api_provider = ProviderKind::Zai; - app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); - app.auto_model = false; - - let session_only = set_config_value(&mut app, "default_model", "deepseek-v4-flash", false); - - assert!(session_only.is_error); - assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL); - assert!(session_only.action.is_none()); - assert!( - session_only - .message - .as_deref() - .is_some_and(|message| message.contains("DeepSeek startup fallback")) - ); - - let saved = set_config_value(&mut app, "default_model", "deepseek-v4-flash", true); - - assert!(!saved.is_error); - assert_eq!(app.model, crate::config::ZAI_GLM_5_2_MODEL); - assert!(saved.action.is_none()); - assert!( - saved - .message - .as_deref() - .is_some_and(|message| message.contains("active zai/GLM-5.2 is unchanged")) - ); - let persisted: toml::Value = - toml::from_str(&fs::read_to_string(&config_path).expect("saved config")).expect("toml"); - assert_eq!( - persisted["providers"]["deepseek"]["model"].as_str(), - Some("deepseek-v4-flash") - ); - assert_eq!(persisted["provider"].as_str(), Some("zai")); - assert_eq!( - saved_deepseek_default_model(&app).expect("saved value"), - "deepseek-v4-flash" - ); - assert!( - Settings::load_persisted() - .expect("settings") - .default_model - .is_none() - ); - } - - #[test] - fn saved_automatic_model_selection_round_trips_every_provider_route() { - let temp_root = tempfile::tempdir().expect("isolated configuration"); - let _guard = EnvGuard::new(temp_root.path()); - let config_path = crate::config_persistence::config_toml_path(None).unwrap(); - fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - for (provider, selector, table, initial) in [ - ( - ProviderKind::Deepseek, - "deepseek", - "deepseek", - "deepseek-v4-pro", - ), - ( - ProviderKind::Deepseek, - "deepseek-cn", - "deepseek_cn", - "deepseek-v4-pro", - ), - (ProviderKind::Zai, "zai", "zai", "GLM-5.3"), - ] { - fs::write( - &config_path, - format!("provider = '{selector}'\n[providers.{table}]\nmodel = '{initial}'\n"), - ) - .unwrap(); - let mut app = create_test_app(); - app.set_provider_identity(provider, selector); - app.model = initial.to_string(); - app.auto_model = false; - let result = set_config_value(&mut app, "model", "auto", true); - assert!(!result.is_error, "{:?}", result.message); - assert!(app.auto_model); - assert!( - result - .message - .as_deref() - .is_some_and(|m| m.contains("saved")) - ); - let persisted: toml::Value = - toml::from_str(&fs::read_to_string(&config_path).unwrap()).unwrap(); - assert_eq!( - persisted["providers"][table]["model"].as_str(), - Some("auto") - ); - let loaded = Config::load(Some(config_path.clone()), None).unwrap(); - assert_eq!( - loaded.default_model(), - "auto", - "{selector} must consume its saved choice" - ); - } - } - - #[test] - fn config_default_model_save_accepts_models_declared_for_the_deepseek_route() { - let temp_root = tempfile::tempdir().expect("isolated configuration"); - let _guard = EnvGuard::new(temp_root.path()); - // The declaration matches on the saved DeepSeek route's base URL; keep - // ambient endpoint overrides out of the comparison. - let _base_url_env = [ - EnvVarGuard::remove("CODEWHALE_BASE_URL"), - EnvVarGuard::remove("DEEPSEEK_BASE_URL"), - ]; - let config_path = crate::config_persistence::config_toml_path(None).expect("config path"); - fs::create_dir_all(config_path.parent().expect("config directory")).expect("mkdir"); - fs::write( - &config_path, - "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.deepseek]\nbase_url = 'https://my-gateway.example/v1'\n[[custom_models]]\nprovider = 'deepseek'\nbase_url = 'https://my-gateway.example/v1'\nid = 'my-llm'\n", - ) - .expect("config"); - - let mut app = create_test_app(); - app.api_provider = ProviderKind::Zai; - app.model = crate::config::ZAI_GLM_5_2_MODEL.to_string(); - app.auto_model = false; - - let result = set_config_value(&mut app, "default_model", "my-llm", true); - - assert!(!result.is_error, "{:?}", result.message); - let persisted: toml::Value = - toml::from_str(&fs::read_to_string(&config_path).expect("saved config")).expect("toml"); - assert_eq!( - persisted["providers"]["deepseek"]["model"].as_str(), - Some("my-llm") - ); - // The same id on a different DeepSeek endpoint is not declared for the - // saved route and must still be rejected by catalog normalization. - fs::write( - &config_path, - "provider = 'zai'\n[providers.zai]\nmodel = 'GLM-5.2'\n[providers.deepseek]\nbase_url = 'https://other-gateway.example/v1'\n[[custom_models]]\nprovider = 'deepseek'\nbase_url = 'https://my-gateway.example/v1'\nid = 'my-llm'\n", - ) - .expect("config"); - let rejected = set_config_value(&mut app, "default_model", "my-llm", true); - assert!(rejected.is_error, "{:?}", rejected.message); - } - - #[test] - fn saved_model_display_uses_the_same_profile_root_precedence_as_startup() { - let temp_root = tempfile::tempdir().unwrap(); - let _guard = EnvGuard::new(temp_root.path()); - let path = crate::config_persistence::config_toml_path(None).unwrap(); - fs::create_dir_all(path.parent().unwrap()).unwrap(); - let mut app = create_test_app(); - app.config_path = Some(path.clone()); - app.config_profile = Some("pro".to_string()); - for field in ["default_text_model", "model"] { - fs::write(&path, format!("route_preferences_version = 1\nprovider = 'deepseek'\n[providers.deepseek]\nmodel = 'deepseek-v4-flash'\n[profiles.pro]\n{field} = 'deepseek-v4-pro'\n")).unwrap(); - let configured = - Config::from_saved_document(&fs::read_to_string(&path).unwrap(), Some("pro")) - .unwrap(); - assert_eq!(configured.default_model(), "deepseek-v4-pro"); - assert_eq!( - saved_deepseek_default_model(&app).unwrap(), - configured.default_model() - ); - } - } - - #[test] - fn config_reasoning_effort_uses_codex_provider_labels() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-codex-effort-config-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let mut app = create_test_app(); - app.api_provider = ProviderKind::OpenaiCodex; - app.reasoning_effort = ReasoningEffort::High; - - let result = set_config_value(&mut app, "reasoning_effort", "off", false); - - assert_eq!(app.reasoning_effort, ReasoningEffort::Low); - assert_eq!(app.reasoning_effort_preference, Some(ReasoningEffort::Off)); - assert_eq!( - result.message.as_deref(), - Some("reasoning_effort = low (session only, add --save to persist)") - ); - - let result = set_config_value(&mut app, "reasoning_effort", "xhigh", false); - - // `xhigh` stopped collapsing into `Max` when the ladder gave it a rung. - assert_eq!(app.reasoning_effort, ReasoningEffort::XHigh); - assert_eq!( - result.message.as_deref(), - Some("reasoning_effort = xhigh (session only, add --save to persist)") - ); - } - - #[test] - fn config_fancy_animations_keeps_ghostty_full_motion() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-ghostty-fancy-config-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - // Neutralize the SSH markers: production intentionally caps motion - // over SSH, and the suite routinely runs inside one. - let _ssh_client = EnvVarGuard::remove("SSH_CLIENT"); - let _ssh_connection = EnvVarGuard::remove("SSH_CONNECTION"); - let _ssh_tty = EnvVarGuard::remove("SSH_TTY"); - let prev_term_program = env::var_os("TERM_PROGRAM"); - // Safety: test-only environment mutation guarded by EnvGuard's lock. - unsafe { - env::set_var("TERM_PROGRAM", "Ghostty"); - } - - let mut app = create_test_app(); - assert!(app.fancy_animations); - assert!(!app.constrained_frame_rate); - - let result = set_config_value(&mut app, "fancy_animations", "true", false); - - assert!(!result.is_error); - assert!( - app.fancy_animations, - "Ghostty must keep authored motion enabled" - ); - assert_eq!( - result.message.as_deref(), - Some("fancy_animations = true (session only, add --save to persist)") - ); - - // Safety: cleanup under EnvGuard's lock. - unsafe { - match prev_term_program { - Some(v) => env::set_var("TERM_PROGRAM", v), - None => env::remove_var("TERM_PROGRAM"), - } - } - } - - #[test] - fn config_model_accepts_future_deepseek_model_id() { - let mut app = create_test_app(); - let result = config_command(&mut app, Some("model deepseek-v4")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("model = deepseek-v4")); - assert_eq!(app.model, "deepseek-v4"); - } - - #[test] - fn config_model_with_save_flag() { - let temp_root = tempfile::tempdir().expect("isolated settings dir"); - let _guard = EnvGuard::new(temp_root.path()); - let mut app = create_test_app(); - let result = config_command(&mut app, Some("model deepseek-v4-flash --save")); - assert!(!result.is_error, "{:?}", result.message); - assert_eq!(app.model, "deepseek-v4-flash"); - let config_path = crate::config_persistence::config_toml_path(app.config_path.as_deref()) - .expect("config path"); - let persisted: toml::Value = - toml::from_str(&fs::read_to_string(config_path).expect("saved config")).expect("toml"); - assert_eq!(persisted["provider"].as_str(), Some("deepseek")); - assert_eq!( - persisted["providers"]["deepseek"]["model"].as_str(), - Some("deepseek-v4-flash") - ); - assert!( - Settings::load_persisted() - .expect("settings") - .provider_models - .is_none() - ); - } - - #[test] - fn failed_model_save_keeps_the_live_selection() { - let temp_root = tempfile::tempdir().expect("isolated config"); - let _guard = EnvGuard::new(temp_root.path()); - let mut app = create_test_app(); - let previous = app.model.clone(); - let blocked_path = temp_root.path().join("config-directory"); - fs::create_dir(&blocked_path).expect("blocked config path"); - app.config_path = Some(blocked_path); - - let result = config_command(&mut app, Some("model deepseek-v4-flash --save")); - - assert!(result.is_error); - assert!(result.action.is_none()); - assert_eq!(app.model, previous); - } - - #[test] - fn hosted_ollama_model_saves_keep_the_legacy_provider_slot() { - use crate::tui::app::PendingRouteSave; - use crate::tui::views::route_save_prompt::RouteSaveChoice; - - let temp_root = tempfile::tempdir().expect("isolated config"); - let _guard = EnvGuard::new(temp_root.path()); - let config_path = temp_root.path().join(".codewhale/config.toml"); - fs::create_dir_all(config_path.parent().expect("config parent")).expect("config home"); - fs::write( - &config_path, - "provider = \"ollama\"\n[providers.ollama]\nbase_url = \"https://ollama.com/v1\"\nmodel = \"original:cloud\"\napi_key_env = \"TEST_OLLAMA_KEY\"\n", - ) - .expect("legacy hosted config"); - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - app.set_provider_identity_record( - Config::load(Some(config_path.clone()), None) - .expect("captured hosted config") - .active_provider_identity() - .expect("captured hosted identity"), - ); - app.active_route_base_url = "https://ollama.com/v1".to_string(); - app.model_ids_passthrough = true; - - for (index, model) in ["live:cloud", "pending:cloud", "command:cloud"] - .into_iter() - .enumerate() - { - app.model = model.to_string(); - match index { - 0 => { - let receipt = app - .try_save_live_route_as_startup_default() - .expect("remember live hosted route"); - assert!(receipt.contains("ollama-cloud/live:cloud"), "{receipt}"); - } - 1 => { - app.pending_route_save = Some(PendingRouteSave { - provider_identity: "ollama-cloud".to_string(), - model: model.to_string(), - fleet: None, - }); - let receipt = app.apply_route_save_choice(RouteSaveChoice::SaveAsDefault); - assert!(receipt.starts_with("Remembered "), "{receipt}"); - } - _ => { - let result = set_config_value(&mut app, "model", model, true); - assert!(!result.is_error, "{:?}", result.message); - } - } - let saved: toml::Value = - toml::from_str(&fs::read_to_string(&config_path).expect("saved config")) - .expect("valid config"); - assert_eq!(saved["provider"].as_str(), Some("ollama")); - assert_eq!(saved["providers"]["ollama"]["model"].as_str(), Some(model)); - assert_eq!( - saved["providers"]["ollama"]["base_url"].as_str(), - Some("https://ollama.com/v1") - ); - assert_eq!( - saved["providers"]["ollama"]["api_key_env"].as_str(), - Some("TEST_OLLAMA_KEY") - ); - assert!(saved["providers"].get("ollama_cloud").is_none()); - assert!(saved["providers"].get("ollama-cloud").is_none()); - assert_eq!(app.provider_identity_for_persistence(), "ollama-cloud"); - assert_eq!(app.provider_id_for_persistence(), Some("ollama")); - } - } - - #[test] - fn config_default_mode_normal_save_reports_normalized_value() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-default-mode-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let mut app = create_test_app(); - let result = config_command(&mut app, Some("default_mode normal --save")); - let msg = result.message.unwrap(); - assert_eq!(msg, "default_mode = agent (saved)"); - assert_eq!(app.mode, AppMode::Agent); - - let settings_path = Settings::path().unwrap(); - let saved = fs::read_to_string(settings_path).unwrap(); - assert!(saved.contains("default_mode = \"agent\"")); - } - - #[test] - fn config_command_cost_currency_save_persists_value() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-cost-currency-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let mut app = create_test_app(); - let result = config_command(&mut app, Some("cost_currency cny --save")); - let msg = result.message.unwrap(); - - assert_eq!(msg, "cost_currency = cny (saved)"); - assert_eq!(app.cost_currency, crate::pricing::CostCurrency::Cny); - - let settings_path = Settings::path().unwrap(); - let saved = fs::read_to_string(settings_path).unwrap(); - assert!(saved.contains("cost_currency = \"cny\"")); - } - - #[test] - fn config_command_base_url_save_persists_value() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "deepseek-tui-base-url-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let mut app = create_test_app(); - let result = config_command( - &mut app, - Some("base_url https://example.internal.local/v1 --save"), - ); - let msg = result.message.unwrap(); - let saved_path = crate::config_persistence::config_toml_path(None).unwrap(); - let saved = fs::read_to_string(&saved_path).unwrap(); - - // The active DeepSeek route's own table, not a top-level key (#6394). - assert_eq!( - msg, - format!( - "base_url = https://example.internal.local/v1 for deepseek (saved to {}; restart required)", - saved_path.display() - ) - ); - let table: toml::Table = toml::from_str(&saved).unwrap(); - assert_eq!( - table["providers"]["deepseek"]["base_url"].as_str(), - Some("https://example.internal.local/v1") - ); - assert!(table.get("base_url").is_none(), "{saved}"); - } - - #[test] - fn config_command_provider_emits_switch_action() { - let mut app = create_test_app(); - let result = config_command(&mut app, Some("provider openrouter")); - - assert!(!result.is_error); - assert_eq!(result.message.as_deref(), Some("provider = openrouter")); - match result.action { - Some(AppAction::SwitchProvider { provider, model }) => { - assert_eq!(provider.as_str(), ProviderKind::Openrouter.as_str()); - assert_eq!(model, None); - } - other => panic!("expected SwitchProvider action, got {other:?}"), - } - } - - #[test] - fn config_command_provider_rejects_unknown_provider() { - let mut app = create_test_app(); - // "anthropic" became a real provider in #3014; probe with an id that - // stays unknown. - let result = config_command(&mut app, Some("provider not-a-provider")); - assert!(result.is_error); - let msg = result.message.unwrap(); - assert!(msg.contains("Unknown provider 'not-a-provider'")); - assert!(msg.contains("openrouter")); - assert!(msg.contains("xiaomi-mimo")); - } - - #[test] - fn config_command_allow_shell_enables_agent_shell_session_only() { - let mut app = create_test_app(); - assert!(!app.allow_shell); - - let result = config_command(&mut app, Some("allow_shell true")); - assert!(!result.is_error); - assert!(app.allow_shell); - let msg = result.message.unwrap(); - - assert!(msg.contains("allow_shell = true")); - assert!(msg.contains("session only")); - assert!(msg.contains("Work mode")); - assert!(msg.contains("approval gating")); - assert!(msg.contains("next turn")); - assert!(msg.contains("Full Access (Shift+Tab) also enables shell and auto-approves")); - } - - #[test] - fn config_command_allow_shell_save_persists_root_boolean() { - let temp_root = tempfile::tempdir().expect("isolated config dir"); - let _guard = EnvGuard::new(temp_root.path()); - - let config_path = temp_root.path().join("custom-config.toml"); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - let result = config_command(&mut app, Some("allow_shell true --save")); - let msg = result.message.unwrap(); - let saved = fs::read_to_string(&config_path).unwrap(); - - assert!(app.allow_shell); - assert_eq!( - msg, - format!( - "allow_shell = true (saved to {}). Work mode will expose shell on the next turn with approval gating. Full Access (Shift+Tab) also enables shell and auto-approves.", - config_path.display() - ) - ); - assert!(saved.contains("allow_shell = true")); - } - - #[test] - fn config_command_allow_shell_rejects_invalid_boolean() { - let mut app = create_test_app(); - let result = config_command(&mut app, Some("allow_shell maybe")); - assert!(result.is_error); - assert!(!app.allow_shell); - let msg = result.message.unwrap(); - assert!(msg.contains("Failed to parse boolean 'maybe'")); - } - - #[test] - fn config_command_cannot_bypass_project_shell_constraint() { - let temp_root = env::temp_dir().join(format!( - "codewhale-project-shell-control-test-{}", - std::process::id() - )); - fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let root_config = temp_root.join(".deepseek").join("config.toml"); - fs::write(&root_config, "# user root\n").unwrap(); - let workspace = temp_root.join("workspace"); - fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap(); - fs::write( - workspace - .join(codewhale_config::CODEWHALE_APP_DIR) - .join("config.toml"), - "allow_shell = false\n", - ) - .unwrap(); - let mut app = create_test_app(); - app.config_path = Some(root_config.clone()); - app.workspace = workspace; - app.set_agent_shell_access(false); - - let result = config_command(&mut app, Some("allow_shell true --save")); - - assert!(result.is_error, "{:?}", result.message); - assert!(!app.allow_shell); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("project configuration")) - ); - assert!( - !fs::read_to_string(root_config) - .unwrap() - .contains("allow_shell") - ); - } - - #[test] - fn config_command_cannot_bypass_environment_shell_constraint() { - let temp_root = env::temp_dir().join(format!( - "codewhale-env-shell-control-test-{}", - std::process::id() - )); - fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join(".deepseek").join("config.toml"); - fs::write(&config_path, "# root\n").unwrap(); - // Safety: EnvGuard holds the process-wide environment lock and restores - // this variable on drop. - unsafe { env::set_var("DEEPSEEK_ALLOW_SHELL", "false") }; - let config = Config::load(Some(config_path.clone()), None).unwrap(); - let mut app = create_test_app_with_config(&config); - app.config_path = Some(config_path); - app.set_agent_shell_access(false); - - let result = config_command(&mut app, Some("allow_shell true")); - - assert!(result.is_error, "{:?}", result.message); - assert!(!app.allow_shell); - assert!( - result - .message - .as_deref() - .is_some_and(|message| message.contains("DEEPSEEK_ALLOW_SHELL")) - ); - } - - #[test] - fn config_command_cannot_bypass_project_or_environment_approval() { - let temp_root = env::temp_dir().join(format!( - "codewhale-external-approval-control-test-{}", - std::process::id() - )); - fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let root_config = temp_root.join(".deepseek").join("config.toml"); - fs::write(&root_config, "# root\n").unwrap(); - let workspace = temp_root.join("workspace"); - fs::create_dir_all(workspace.join(codewhale_config::CODEWHALE_APP_DIR)).unwrap(); - fs::write( - workspace - .join(codewhale_config::CODEWHALE_APP_DIR) - .join("config.toml"), - "approval_policy = \"never\"\n", - ) - .unwrap(); - let mut app = create_test_app(); - app.config_path = Some(root_config.clone()); - app.workspace = workspace; - app.set_agent_approval_posture(ApprovalMode::Never); - - let project_result = config_command(&mut app, Some("approval_mode full-access")); - assert!(project_result.is_error, "{:?}", project_result.message); - assert_eq!(app.approval_mode, ApprovalMode::Never); - - // Move outside the project and make the environment the controlling - // source for the second half of the regression. - app.workspace = temp_root.join("clean-workspace"); - fs::create_dir_all(&app.workspace).unwrap(); - // Safety: EnvGuard holds the process-wide environment lock and restores - // this variable on drop. - unsafe { env::set_var("DEEPSEEK_APPROVAL_POLICY", "never") }; - let env_result = config_command(&mut app, Some("approval_mode auto")); - assert!(env_result.is_error, "{:?}", env_result.message); - assert_eq!(app.approval_mode, ApprovalMode::Never); - assert!( - env_result - .message - .as_deref() - .is_some_and(|message| message.contains("DEEPSEEK_APPROVAL_POLICY")) - ); - } - - #[test] - fn config_command_shell_choice_survives_plan_round_trip() { - let mut app = create_test_app(); - app.set_agent_approval_posture(ApprovalMode::Bypass); - - let result = config_command(&mut app, Some("allow_shell true")); - - assert!(!result.is_error, "{:?}", result.message); - app.set_mode(AppMode::Plan); - assert!(!app.allow_shell); - app.set_mode(AppMode::Agent); - assert!(app.allow_shell); - assert_eq!(app.approval_mode, ApprovalMode::Bypass); - } - - #[test] - fn config_command_subagents_off_save_persists_and_updates_runtime() { - let temp_root = env::temp_dir().join(format!( - "codewhale-subagents-off-save-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let config_path = temp_root.join("custom-config.toml"); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - let result = config_command(&mut app, Some("subagents off --save")); - let msg = result.message.unwrap(); - let saved = fs::read_to_string(&config_path).unwrap(); - - assert!(!result.is_error); - assert!(msg.contains("subagents.enabled = false")); - assert!(msg.contains("saved to")); - assert!(saved.contains("[subagents]")); - assert!(saved.contains("enabled = false")); - match result.action { - Some(AppAction::UpdateSubagentRuntimeConfig { enabled, .. }) => { - assert!(!enabled); - } - other => panic!("expected subagent runtime update, got {other:?}"), - } - } - - #[test] - fn config_command_subagents_depth_save_clamps_to_ceiling() { - let temp_root = env::temp_dir().join(format!( - "codewhale-subagents-depth-save-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let config_path = temp_root.join("custom-config.toml"); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - let result = config_command(&mut app, Some("subagents max_depth 99 --save")); - let msg = result.message.unwrap(); - let saved = fs::read_to_string(&config_path).unwrap(); - let ceiling = codewhale_config::MAX_SPAWN_DEPTH_CEILING; - - assert!(!result.is_error); - assert!(msg.contains(&format!("subagents.max_depth = {ceiling}"))); - assert!(msg.contains(&format!("clamped from 99 to {ceiling}"))); - assert!(saved.contains(&format!("max_depth = {ceiling}"))); - match result.action { - Some(AppAction::UpdateSubagentRuntimeConfig { - max_spawn_depth, .. - }) => { - assert_eq!(max_spawn_depth, ceiling); - } - other => panic!("expected subagent runtime update, got {other:?}"), - } - } - - #[test] - fn config_command_subagents_status_shows_raw_and_resolved_values() { - let temp_root = env::temp_dir().join(format!( - "codewhale-subagents-status-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let config_path = temp_root.join("custom-config.toml"); - fs::write( - &config_path, - r#" -[subagents] -enabled = true -max_concurrent = 2 -max_depth = 0 -launch_concurrency = 5 -api_timeout_secs = 0 -heartbeat_timeout_secs = 1 -"#, - ) - .unwrap(); - - let mut app = create_test_app(); - app.config_path = Some(config_path); - let result = config_command(&mut app, Some("subagents status")); - let msg = result.message.unwrap(); - - assert!(!result.is_error); - assert!(msg.contains("Sub-agents: disabled (subagents.max_depth=0)")); - assert!(msg.contains("Active provider: deepseek")); - assert!( - msg.contains("subagents.max_concurrent = 2 (resolved global 2; active provider 2)") - ); - assert!( - msg.contains("subagents.launch_concurrency = 5 (resolved global 2; active provider 2)") - ); - assert!( - msg.contains( - "subagents.api_timeout_secs = 0 (resolved global 600; active provider 600)" - ) - ); - assert!(msg.contains( - "subagents.heartbeat_timeout_secs = 1 (resolved global 630; active provider 630)" - )); - assert!(msg.contains("subagents.providers.deepseek = inherits global")); - } - - #[test] - fn config_command_audit_lists_editability_and_current_values() { - let temp_root = env::temp_dir().join(format!( - "codewhale-config-audit-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - // Hermetic: the audit reads Settings::load(); without this guard the - // developer's real saved permission_posture leaks in and the - // "(unset)" assertion below becomes machine-dependent. - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join("custom-config.toml"); - fs::write( - &config_path, - r#" -base_url = "https://api.from-config.local/v1" -instructions = ["~/global.md"] -prompt_suggestion = true - -[subagents] -enabled = false -max_concurrent = 4 - -[search] -provider = "bing" - -[notifications] -method = "osc9" -threshold_secs = 45 -quiet = true -completion_sound = "off" -"#, - ) - .unwrap(); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - app.approval_mode = ApprovalMode::Never; - app.stream_chunk_timeout_secs = 45; - - let result = config_command(&mut app, Some("audit")); - let msg = result.message.unwrap(); - - assert!(!result.is_error); - assert!(msg.contains("Config editability audit")); - assert!(msg.contains(&format!("Config path: {}", config_path.display()))); - assert!(msg.contains("effective_permissions | Never | runtime")); - assert!(msg.contains("permission_posture | (unset) | TUI settings")); - assert!(msg.contains("approval_policy | (unset) | persisted config")); - assert!(msg.contains("stream_chunk_timeout_secs | 45 | runtime+persisted")); - assert!(msg.contains("subagents.enabled | false | runtime+persisted")); - assert!(msg.contains("subagents.max_concurrent | 4 | runtime+persisted")); - assert!(msg.contains("base_url | https://api.from-config.local/v1 | persisted restart")); - assert!(msg.contains("providers..context_window | (unset) | persisted restart")); - assert!(msg.contains("effective_context_window |"), "{msg}"); - assert!(msg.contains("| runtime | /config context_window"), "{msg}"); - assert!(msg.contains("instructions | configured | file-only restart")); - assert!(msg.contains("network | unset | file-only")); - assert!( - msg.contains("search.provider | bing (source: config.toml) | runtime+persisted"), - "{msg}" - ); - assert!( - msg.contains("prompt_suggestion | true | runtime+persisted"), - "{msg}" - ); - assert!( - msg.contains( - "notifications | method=osc9 threshold=45s sound=legacy quiet=true | runtime+persisted" - ), - "{msg}" - ); - - app.mode = AppMode::Plan; - let plan_msg = config_command(&mut app, Some("audit")) - .message - .expect("Plan audit message"); - assert!( - plan_msg.contains("effective_permissions | Read Only | runtime"), - "{plan_msg}" - ); - } - - #[test] - fn config_command_shows_search_prompt_suggestion_and_notifications() { - let temp_root = env::temp_dir().join(format!( - "codewhale-config-discovery-show-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join("custom-config.toml"); - fs::write( - &config_path, - r#" -prompt_suggestion = true - -[search] -provider = "tavily" - -[notifications] -method = "bel" -threshold_secs = 12 -quiet = false -completion_sound = "bell" -"#, - ) - .unwrap(); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - // This fixture starts App with defaults; seed its current Config view - // as the real constructor does before asking a live-session query. - app.notification_settings = Config::load(Some(config_path), None) - .unwrap() - .notifications_config(); - - let search = config_command(&mut app, Some("search.provider")); - assert!(!search.is_error, "{:?}", search.message); - assert_eq!( - search.message.as_deref(), - Some("search.provider = tavily (source: config.toml)") - ); - - let suggestion = config_command(&mut app, Some("prompt_suggestion")); - assert!(!suggestion.is_error, "{:?}", suggestion.message); - assert_eq!( - suggestion.message.as_deref(), - Some("prompt_suggestion = true") - ); - - let notifications = config_command(&mut app, Some("notifications")); - let notifications_msg = notifications.message.expect("notifications status"); - assert!(!notifications.is_error, "{notifications_msg}"); - assert!( - notifications_msg.contains("method = bel"), - "{notifications_msg}" - ); - assert!( - notifications_msg.contains("threshold_secs = 12"), - "{notifications_msg}" - ); - assert!( - notifications_msg.contains("completion_sound = bell"), - "{notifications_msg}" - ); - } - - #[test] - fn config_command_shows_autodetected_tavily_key_source() { - let temp_root = tempfile::tempdir().expect("isolated config dir"); - let _guard = EnvGuard::new(temp_root.path()); - let config_path = temp_root.path().join("custom-config.toml"); - fs::write( - &config_path, - r#" -[search] -api_key = "tvly-autodetected" -"#, - ) - .unwrap(); - - let mut app = create_test_app(); - app.config_path = Some(config_path); - - let search = config_command(&mut app, Some("search.provider")); - assert!(!search.is_error, "{:?}", search.message); - let message = search.message.expect("search provider display"); - assert_eq!(message, "search.provider = tavily (source: tavily key)"); - assert!( - !message.contains("TAVILY_API_KEY"), - "a generic `tvly-` key must not be reported as the env var: {message}" - ); - } - - #[test] - fn config_command_sets_search_prompt_suggestion_and_notifications() { - let temp_root = tempfile::tempdir().expect("isolated config dir"); - let _guard = EnvGuard::new(temp_root.path()); - let config_path = temp_root.path().join("custom-config.toml"); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - - let search = config_command(&mut app, Some("search.provider duckduckgo --save")); - assert!(!search.is_error, "{:?}", search.message); - match search.action { - Some(AppAction::UpdateSearchProvider { provider }) => { - assert_eq!(provider, SearchProvider::DuckDuckGo); - } - other => panic!("expected UpdateSearchProvider, got {other:?}"), - } - - let suggestion = config_command(&mut app, Some("prompt_suggestion true --save")); - assert!(!suggestion.is_error, "{:?}", suggestion.message); - match suggestion.action { - Some(AppAction::UpdatePromptSuggestion { enabled }) => assert!(enabled), - other => panic!("expected UpdatePromptSuggestion, got {other:?}"), - } - - let notifications = config_command(&mut app, Some("notifications method osc9 --save")); - assert!(!notifications.is_error, "{:?}", notifications.message); - match notifications.action { - Some(AppAction::UpdateNotification { - update: NotificationConfigUpdate::Method(method), - }) => assert_eq!(method, NotificationMethod::Osc9), - other => panic!("expected UpdateNotification method, got {other:?}"), - } - - let saved = fs::read_to_string(&config_path).unwrap(); - assert!(saved.contains("provider = \"duckduckgo\""), "{saved}"); - assert!(saved.contains("prompt_suggestion = true"), "{saved}"); - assert!(saved.contains("method = \"osc9\""), "{saved}"); - - let loaded = Config::load(Some(config_path), None).expect("reloaded config"); - assert_eq!(loaded.search_provider(), SearchProvider::DuckDuckGo); - assert!(loaded.prompt_suggestion_enabled()); - assert_eq!( - loaded.notifications_config().method, - NotificationMethod::Osc9 - ); - } - - #[test] - fn session_only_notification_commands_emit_composable_field_deltas() { - let temp_root = tempfile::tempdir().expect("isolated config dir"); - let _guard = EnvGuard::new(temp_root.path()); - let config_path = temp_root.path().join("custom-config.toml"); - fs::write( - &config_path, - "[notifications]\nmethod = \"bel\"\nthreshold_secs = 12\nquiet = false\n", - ) - .expect("persisted notification config"); - - let mut app = create_test_app(); - app.config_path = Some(config_path); - let mut live = NotificationsConfig { - threshold_secs: 12, - ..NotificationsConfig::default() - }; - - for command in ["notifications method osc9", "notifications quiet true"] { - let result = config_command(&mut app, Some(command)); - assert!(!result.is_error, "{:?}", result.message); - let Some(AppAction::UpdateNotification { update }) = result.action else { - panic!("expected notification field delta for {command}"); - }; - live.apply_update(update).unwrap(); - } - - assert_eq!(live.method, NotificationMethod::Osc9); - assert!(live.quiet); - assert_eq!(live.threshold_secs, 12); - } - - #[test] - fn config_command_rejects_invalid_search_and_notification_values() { - let mut app = create_test_app(); - let search = config_command(&mut app, Some("search.provider not-a-backend")); - assert!(search.is_error); - let search_msg = search.message.unwrap(); - assert!( - search_msg.contains("Can't use 'not-a-backend' for search.provider"), - "{search_msg}" - ); - assert!(search_msg.contains("firecrawl"), "{search_msg}"); - - let notifications = config_command(&mut app, Some("notifications method semaphore")); - assert!(notifications.is_error); - let notifications_msg = notifications.message.unwrap(); - assert!( - notifications_msg.contains("Can't use 'semaphore' for notifications.method"), - "{notifications_msg}" - ); - assert!(notifications_msg.contains("osc9"), "{notifications_msg}"); - } - - #[test] - fn config_context_window_query_shows_override_and_effective_source() { - let temp_root = env::temp_dir().join(format!( - "codewhale-context-window-query-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join("custom-config.toml"); - fs::write( - &config_path, - r#" -provider = "moonshot" -[providers.moonshot] -model = "kimi-k3" -context_window = 262144 -"#, - ) - .unwrap(); - let mut app = create_test_app(); - app.config_path = Some(config_path); - app.set_provider_identity_record( - crate::config::Config::default() - .resolve_provider_identity(ProviderKind::Moonshot.as_str()) - .expect("captured fixture provider"), - ); - app.model = "kimi-k3".to_string(); - app.active_route_limits = Some(codewhale_config::route::RouteLimits { - context_tokens: Some(262_144), - ..Default::default() - }); - app.active_context_window_source = crate::route_runtime::ContextWindowSource::Configured; - - let result = config_command(&mut app, Some("context_window")); - let message = result.message.expect("context window message"); - - assert!(!result.is_error, "{message}"); - assert!( - message.contains("262144 (effective 262144 from configured)"), - "{message}" - ); - } - - #[test] - fn config_command_base_url_without_save_requires_save() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let result = config_command(&mut app, Some("base_url https://example.internal.local/v1")); - assert!(result.is_error); - let msg = result.message.unwrap(); - - assert!( - msg.contains("base_url must be saved with --save"), - "got {msg}" - ); - } - - #[test] - fn config_command_base_url_reads_current_value_from_config() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "deepseek-tui-base-url-show-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let config_path = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - fs::write( - &config_path, - "base_url = \"https://api.from-config.local/v1\"\n", - ) - .unwrap(); - - let mut app = create_test_app(); - let result = config_command(&mut app, Some("base_url")); - let msg = result.message.unwrap(); - - assert_eq!(msg, "base_url = https://api.from-config.local/v1"); - } - - #[test] - fn config_command_base_url_reads_current_value_from_app_config_path() { - let temp_root = env::temp_dir().join(format!( - "deepseek-tui-base-url-app-config-path-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - - let config_path = temp_root.join("custom-config.toml"); - fs::write( - &config_path, - "base_url = \"https://api.from-app-path.local/v1\"\n", - ) - .unwrap(); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - let result = config_command(&mut app, Some("base_url")); - let msg = result.message.unwrap(); - - assert_eq!(msg, "base_url = https://api.from-app-path.local/v1"); - } - - #[test] - fn config_command_base_url_save_persists_to_app_config_path() { - let temp_root = env::temp_dir().join(format!( - "deepseek-tui-base-url-save-app-path-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - - let config_path = temp_root.join("custom-config.toml"); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - let result = config_command( - &mut app, - Some("base_url https://example.session.local/v1 --save"), - ); - let msg = result.message.unwrap(); - let saved = fs::read_to_string(&config_path).unwrap(); - - assert_eq!( - msg, - format!( - "base_url = https://example.session.local/v1 for deepseek (saved to {}; restart required)", - config_path.display() - ) - ); - let table: toml::Table = toml::from_str(&saved).unwrap(); - assert_eq!( - table["providers"]["deepseek"]["base_url"].as_str(), - Some("https://example.session.local/v1") - ); - } - - #[test] - fn config_command_stream_chunk_timeout_session_query_uses_live_value() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - - let result = config_command(&mut app, Some("stream_chunk_timeout_secs 90")); - assert!(!result.is_error); - assert_eq!(app.stream_chunk_timeout_secs, 90); - assert!(matches!( - result.action, - Some(AppAction::UpdateStreamChunkTimeout(90)) - )); - - let query = config_command(&mut app, Some("stream_chunk_timeout_secs")); - assert_eq!( - query.message.as_deref(), - Some("stream_chunk_timeout_secs = 90") - ); - } - - #[test] - fn failed_save_leaves_live_config_state_untouched() { - // C01-08: a `--save` that cannot write must not report failure over - // live state that already moved. - let temp = tempfile::tempdir().unwrap(); - let _guard = EnvGuard::new(temp.path()); - let blocker = temp.path().join("not-a-directory"); - fs::write(&blocker, "a file where the config directory should be").unwrap(); - let mut app = create_test_app(); - app.config_path = Some(blocker.join("config.toml")); - let mcp_before = app.mcp_config_path.clone(); - app.mcp_reload_required = false; - let timeout_before = app.stream_chunk_timeout_secs; - - let result = set_config_value(&mut app, "mcp_config_path", "/elsewhere/mcp.json", true); - assert!(result.is_error, "{:?}", result.message); - assert_eq!(app.mcp_config_path, mcp_before); - assert!(!app.mcp_reload_required); - - let next_timeout = if timeout_before == 120 { "121" } else { "120" }; - let result = set_config_value(&mut app, "stream_chunk_timeout_secs", next_timeout, true); - assert!(result.is_error, "{:?}", result.message); - assert!( - result.action.is_none(), - "no engine update for an unsaved value" - ); - assert_eq!(app.stream_chunk_timeout_secs, timeout_before); - } - - #[test] - fn config_command_stream_chunk_timeout_save_overrides_canonical_value() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-stream-timeout-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let config_path = temp_root.join("custom-config.toml"); - fs::write( - &config_path, - "[stream]\nchunk_timeout_secs=45\n[tui]\nstream_chunk_timeout_secs=30\n", - ) - .unwrap(); - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - - let result = config_command(&mut app, Some("stream_chunk_timeout_secs 120 --save")); - let msg = result.message.unwrap(); - let saved = fs::read_to_string(&config_path).unwrap(); - - assert_eq!( - msg, - format!( - "stream_chunk_timeout_secs = 120 (saved to {}; affects subsequent turns in this session)", - config_path.display() - ) - ); - let reopened: Config = toml::from_str(&saved).unwrap(); - assert_eq!( - reopened.stream_chunk_timeout_secs(), - 120, - "saved value must survive reopening when a canonical value already existed" - ); - assert_eq!( - reopened.tui.unwrap().stream_chunk_timeout_secs, - Some(30), - "legacy compatibility input is preserved" - ); - assert_eq!(app.stream_chunk_timeout_secs, 120); - assert!(matches!( - result.action, - Some(AppAction::UpdateStreamChunkTimeout(120)) - )); - } - - /// The bottom-chrome row presets (#5950) apply on the next frame and - /// `--save` writes the `[tui]` key; an unknown preset names the three. - #[test] - fn config_command_row_presets_apply_live_and_persist_to_tui_table() { - use crate::config::ChromeRowPreset; - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-row-presets-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join("custom-config.toml"); - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - assert_eq!(app.posture_bar, ChromeRowPreset::Full); - assert_eq!(app.metrics_line, ChromeRowPreset::Compact); - - let live = config_command(&mut app, Some("posture_bar compact")); - assert!(!live.is_error, "{live:?}"); - assert_eq!(app.posture_bar, ChromeRowPreset::Compact); - assert_eq!( - live.message.as_deref(), - Some("posture_bar = compact (SESSION)") - ); - assert_eq!( - config_command(&mut app, Some("posture_bar")) - .message - .as_deref(), - Some("posture_bar = compact") - ); - - let saved = config_command(&mut app, Some("metrics_line HIDDEN --save")); - assert!(!saved.is_error, "{saved:?}"); - assert_eq!(app.metrics_line, ChromeRowPreset::Hidden); - let body = fs::read_to_string(&config_path).unwrap(); - assert!(body.contains("[tui]"), "{body}"); - assert!(body.contains("metrics_line = \"hidden\""), "{body}"); - assert!( - !body.contains("posture_bar"), - "session-only value must not be saved: {body}" - ); - - let bad = config_command(&mut app, Some("metrics_line tiny")); - assert!(bad.is_error); - assert!( - bad.message - .as_deref() - .is_some_and(|m| m.contains("metrics_line. Try: full, compact, hidden")), - "{bad:?}" - ); - assert_eq!( - app.metrics_line, - ChromeRowPreset::Hidden, - "a bad value changes nothing" - ); - } - - #[test] - fn row_preset_save_failure_preserves_live_state_and_uses_current_locale() { - let temp = tempfile::tempdir().unwrap(); - let _guard = EnvGuard::new(temp.path()); - let path = temp.path().join("config.toml"); - // A directory in place of the file fails on every supported OS. - fs::create_dir(&path).unwrap(); - let mut app = create_test_app(); - app.config_path = Some(path); - app.ui_locale = codewhale_localization::Locale::ZhHans; - let before = (app.posture_bar, app.metrics_line); - for key in ["posture_bar", "metrics_line"] { - let result = config_command(&mut app, Some(&format!("{key} hidden --save"))); - assert!(result.is_error, "{result:?}"); - assert!(result.message.unwrap().contains("未能保存")); - assert_eq!((app.posture_bar, app.metrics_line), before); - let invalid = config_command(&mut app, Some(&format!("{key} tiny"))); - assert!(invalid.is_error); - assert_eq!( - invalid.message, - CommandResult::error( - tr(app.ui_locale, MessageId::ConfigCommandInvalidValue) - .replace("{key}", key) - .replace("{value}", "tiny") - .replace("{choices}", "full, compact, hidden") - ) - .message - ); + fn apply_import( + &mut self, + _plan: &CommandClaudePlan, + _report_path: &std::path::Path, + _wrote_report: bool, + ) -> CommandClaudeApplyOutcome { + panic!("config facet touched") } - // A session-only change needs no writable file and uses the new locale. - let result = config_command(&mut app, Some("posture_bar compact")); - assert!(!result.is_error); - assert_eq!( - result.message.as_deref(), - Some("posture_bar = compact (会话)") - ); - } - - #[test] - fn row_preset_saved_in_active_profile_reloads_without_resetting_other_rows() { - use crate::config::ChromeRowPreset; - let temp = tempfile::tempdir().unwrap(); - let _guard = EnvGuard::new(temp.path()); - let path = temp.path().join("selected.toml"); - for owns_tui in [false, true] { - let mut body = "# Keep this comment\n[tui]\nposture_bar = \"full\"\nmetrics_line = \"hidden\"\n[profiles.\"work.team\"]\nmax_subagents = 4\n".to_string(); - if owns_tui { - body.push_str("[profiles.\"work.team\".tui]\nposture_bar = \"hidden\"\nmetrics_line = \"compact\"\n"); - } - fs::write(&path, body).unwrap(); - let config = Config::load(Some(path.clone()), Some("work.team")).unwrap(); - let mut app = create_test_app_with_config(&config); - app.config_path = Some(path.clone()); - app.config_profile = Some("work.team".to_string()); - let metrics_before = app.metrics_line; - let result = config_command(&mut app, Some("posture_bar compact --save")); - assert!(!result.is_error, "{result:?}"); - assert_eq!(app.posture_bar, ChromeRowPreset::Compact); - let reloaded = Config::load(Some(path.clone()), Some("work.team")).unwrap(); - let restarted = create_test_app_with_config(&reloaded); - assert_eq!(restarted.posture_bar, app.posture_bar); - assert_eq!(restarted.metrics_line, metrics_before); - let saved = fs::read_to_string(&path).unwrap(); - assert!(saved.starts_with("# Keep this comment")); - let document: toml::Value = toml::from_str(&saved).unwrap(); - assert_eq!( - document["profiles"]["work.team"].get("tui").is_some(), - owns_tui - ); - if owns_tui { - assert_eq!(document["tui"]["posture_bar"].as_str(), Some("full")); - } + fn display_path(&self, _path: &std::path::Path) -> String { + panic!("config facet touched") } } - - #[test] - fn config_command_stream_chunk_timeout_rejects_invalid_input() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - - let text = config_command(&mut app, Some("stream_chunk_timeout_secs abc")); - assert!(text.is_error); - assert!( - text.message - .unwrap() - .contains("stream_chunk_timeout_secs must be a whole number") - ); - - let high = config_command(&mut app, Some("stream_chunk_timeout_secs 3601")); - assert!(high.is_error); - assert!( - high.message - .unwrap() - .contains("stream_chunk_timeout_secs must be 0 or 1..=3600") - ); - } - - #[test] - fn config_command_stream_chunk_timeout_zero_reports_effective_default() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - - let result = config_command(&mut app, Some("stream_chunk_timeout_secs 0")); - - assert!(!result.is_error); - assert_eq!( - app.stream_chunk_timeout_secs, - DEFAULT_STREAM_CHUNK_TIMEOUT_SECS - ); - assert_eq!( - result.message.as_deref(), - Some( - "stream_chunk_timeout_secs = 0 (default 900) (session only; affects subsequent turns in this session)" - ) - ); - assert!(matches!( - result.action, - Some(AppAction::UpdateStreamChunkTimeout( - DEFAULT_STREAM_CHUNK_TIMEOUT_SECS - )) - )); - } - - #[test] - fn config_command_provider_url_token_plan_persists_provider_base_url() { - let temp_root = env::temp_dir().join(format!( - "codewhale-provider-url-save-app-path-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - - let config_path = temp_root.join("custom-config.toml"); - - let mut app = create_test_app(); - app.set_provider_identity_record( - crate::config::Config::default() - .resolve_provider_identity(ProviderKind::XiaomiMimo.as_str()) - .expect("captured fixture provider"), - ); - app.config_path = Some(config_path.clone()); - let result = config_command(&mut app, Some("provider_url token-plan --save")); - let msg = result.message.unwrap(); - let saved = fs::read_to_string(&config_path).unwrap(); - - assert_eq!( - msg, - format!( - "provider_url = {} for xiaomi-mimo (saved to {}; restart required)", - DEFAULT_XIAOMI_MIMO_BASE_URL, - config_path.display() - ) - ); - assert!(saved.contains("[providers.xiaomi_mimo]")); - assert!(saved.contains(&format!("base_url = \"{DEFAULT_XIAOMI_MIMO_BASE_URL}\""))); + + /// Presentation that resolves every template except `failing`. + struct OneMissingTemplate(&'static str); + + impl CommandPresentationContext for OneMissingTemplate { + fn translate(&self, key: &str, _r: &[(&str, &str)]) -> Result { + if key == self.0 { + Err(format!("{key}: unknown translation key")) + } else { + Ok(key.to_string()) + } + } } - #[test] - fn config_command_provider_url_without_save_requires_save() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - app.api_provider = ProviderKind::XiaomiMimo; - let result = config_command(&mut app, Some("provider_url token-plan")); - assert!(result.is_error); - let msg = result.message.unwrap(); + /// Presentation that succeeds once per key and fails on any second call, + /// proving prepared rendering never re-translates after an effect. + #[derive(Default)] + struct OncePerKey(std::cell::RefCell>); - assert!( - msg.contains("provider_url must be saved with --save"), - "got {msg}" - ); + impl CommandPresentationContext for OncePerKey { + fn translate(&self, key: &str, r: &[(&str, &str)]) -> Result { + if !self.0.borrow_mut().insert(key.to_string()) { + return Err(format!("{key} translated twice")); + } + // Echo the replacement values so the preflight sentinels survive. + Ok(r.iter().map(|(_, value)| *value).collect()) + } } #[test] - fn theme_command_accepts_grayscale_arg() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-theme-command-test-{}-{}", - std::process::id(), - nanos + fn prepared_rendering_never_translates_again_after_preflight() { + let mut presentation = OncePerKey::default(); + let contexts = CommandContexts::empty().with_presentation(&mut presentation); + let mut parts = contexts.into_parts(); + let prepared = preflight(&mut parts).expect("preflight validates every template once"); + let reply = ConfigReply::message(ConfigMessage::keyed_with( + "config_search_updated", + vec![ + ("value".to_string(), ConfigMessage::literal("duckduckgo")), + ( + "scope".to_string(), + ConfigMessage::keyed("config_scope_saved"), + ), + ], )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); + let result = to_result(reply, &mut parts, &prepared); + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(result.message.as_deref(), Some("duckduckgo")); + } - let mut app = create_test_app(); - let result = theme(&mut app, Some("grayscale")); + /// Presentation that echoes replacement values and counts translations. + #[derive(Default)] + struct EchoWithCount(std::cell::Cell); - assert_eq!(result.message.unwrap(), "theme = grayscale (saved)"); - assert_eq!(app.theme_id, codewhale_palette::ThemeId::Grayscale); - assert_eq!(app.ui_theme.mode, codewhale_palette::PaletteMode::Grayscale); - assert!(app.needs_redraw); + impl CommandPresentationContext for EchoWithCount { + fn translate(&self, _key: &str, r: &[(&str, &str)]) -> Result { + self.0.set(self.0.get() + 1); + Ok(r.iter().map(|(_, value)| *value).collect()) + } } #[test] - fn theme_command_underwater_alias_selects_the_underwater_theme() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-theme-underwater-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let mut app = create_test_app(); - for alias in ["underwater", "Deepsea", "deep-sea", "ombre"] { - let result = theme(&mut app, Some(alias)); - assert!(!result.is_error, "{alias}: {:?}", result.message); - assert_eq!( - result.message.as_deref(), - Some("theme = underwater (saved)"), - "{alias}" - ); - assert_eq!( - app.theme_id, - codewhale_palette::ThemeId::Underwater, - "{alias}" + fn prepared_rendering_rejects_missing_duplicate_and_extra_parameters() { + for (name, params) in [ + ( + "missing scope", + vec![("value".to_string(), ConfigMessage::literal("duckduckgo"))], + ), + ( + "duplicate value", + vec![ + ("value".to_string(), ConfigMessage::literal("a")), + ("value".to_string(), ConfigMessage::literal("b")), + ( + "scope".to_string(), + ConfigMessage::keyed("config_scope_saved"), + ), + ], + ), + ( + "extra parameter", + vec![ + ("value".to_string(), ConfigMessage::literal("duckduckgo")), + ( + "scope".to_string(), + ConfigMessage::keyed("config_scope_saved"), + ), + ("extra".to_string(), ConfigMessage::literal("x")), + ], + ), + ] { + let mut presentation = EchoWithCount::default(); + let contexts = CommandContexts::empty().with_presentation(&mut presentation); + let mut parts = contexts.into_parts(); + let prepared = preflight(&mut parts).expect("preflight"); + let reply = ConfigReply::with_message_and_action( + ConfigMessage::keyed_with("config_search_updated", params), + ConfigAction::OpenConfigView, ); - assert_eq!(app.ui_theme.name, "underwater", "{alias}"); + let result = to_result(reply, &mut parts, &prepared); + assert!(result.is_error, "{name} must fail"); + assert!(result.action.is_none(), "{name} must not keep the action"); assert!( - crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(), - "{alias}: the underwater theme owns the painted field" + result + .message + .as_deref() + .is_some_and(|m| m.contains("invalid translation replacement contract")), + "{name}: {:?}", + result.message ); } } - #[test] - fn underwater_theme_selection_updates_live_state_and_persists_one_field() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-underwater-selection-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); - let _guard = EnvGuard::new(&temp_root); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "theme = \"light\"\nmax_input_history = 77\n", - ) - .expect("seed settings"); - - let mut app = create_test_app(); - let result = set_config_value(&mut app, "theme", "underwater", true); + /// Work-surface facet that rejects every setter. + struct RejectingWorkSurface; - assert!(!result.is_error, "{:?}", result.message); - assert_eq!(app.theme_id, codewhale_palette::ThemeId::Underwater); - let persisted = Settings::load_persisted().expect("persisted selection"); - assert_eq!(persisted.theme, "underwater"); - assert_eq!( - persisted.max_input_history, 77, - "the theme save must not overwrite unrelated settings" - ); + impl codewhale_command_contract::config::CommandWorkSurfaceContext for RejectingWorkSurface { + fn placement(&self) -> codewhale_command_contract::types::CommandWorkSurfacePlacement { + codewhale_command_contract::types::CommandWorkSurfacePlacement::Bottom + } + fn panel(&self) -> codewhale_command_contract::types::CommandRailPanel { + codewhale_command_contract::types::CommandRailPanel::Tasks + } + fn effective_placement( + &self, + ) -> codewhale_command_contract::types::CommandWorkSurfacePlacement { + codewhale_command_contract::types::CommandWorkSurfacePlacement::Bottom + } + fn area_present(&self) -> bool { + true + } + fn panel_title( + &self, + _panel: codewhale_command_contract::types::CommandRailPanel, + ) -> String { + "Tasks".to_string() + } + fn set_placement( + &mut self, + _placement: codewhale_command_contract::types::CommandWorkSurfacePlacement, + _persist: bool, + ) -> ConfigReply { + ConfigReply::error("workbar placement rejected") + } + fn set_panel( + &mut self, + _panel: codewhale_command_contract::types::CommandRailPanel, + _persist: bool, + ) -> ConfigReply { + ConfigReply::error("workbar panel rejected") + } + fn mark_redraw(&mut self) {} } #[test] - fn custom_theme_selection_keeps_the_raw_selector_in_live_app_state() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-custom-theme-selection-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - let _guard = EnvGuard::new(&temp_root); - let themes = temp_root.join(".codewhale").join("themes"); - fs::create_dir_all(&themes).expect("themes dir"); - fs::write( - themes.join("midnight.json"), - r##"{"schema_version":1,"base":"dark","colors":{"accent_primary":"#123456"}}"##, - ) - .expect("theme overlay"); - - let mut app = create_test_app(); - let result = set_config_value(&mut app, "theme", "custom:midnight", false); - - assert!(!result.is_error, "{:?}", result.message); - assert_eq!(app.theme_name, "custom:midnight"); - assert_eq!(app.theme_id, codewhale_palette::ThemeId::Whale); + fn workbar_surfaces_setter_errors_without_save() { + // No presentation facet: the setter error is literal, so `/workbar` + // must not depend on the config templates. + let mut work_surface = RejectingWorkSurface; + let contexts = CommandContexts::empty().with_work_surface(&mut work_surface); + // No `--save`: the setter error must still surface. + let result = workbar(contexts, Some("left")); + assert!(result.is_error, "{:?}", result.message); assert_eq!( - app.ui_theme.accent_primary, - ratatui::style::Color::Rgb(0x12, 0x34, 0x56) + result.message.as_deref(), + Some("Error: workbar placement rejected") ); } #[test] - fn invalid_theme_name_changes_nothing() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-theme-preflight-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); - let _guard = EnvGuard::new(&temp_root); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "theme = \"light\"\n", - ) - .expect("seed settings"); - - let mut app = create_test_app(); - let original_theme = app.theme_id; - let result = set_config_value(&mut app, "theme", "kelp", true); - - assert!(result.is_error); - assert_eq!(app.theme_id, original_theme); - let persisted = Settings::load_persisted().expect("unchanged persisted settings"); - assert_eq!(persisted.theme, "light"); + fn workbar_status_needs_no_presentation() { + let mut work_surface = RejectingWorkSurface; + let contexts = CommandContexts::empty().with_work_surface(&mut work_surface); + let result = workbar(contexts, None); + assert!(!result.is_error, "{:?}", result.message); + assert!(result.message.is_some()); } - #[test] - fn explicit_default_background_override_survives_theme_preview() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-background-override-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); - let _guard = EnvGuard::new(&temp_root); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "theme = \"solarized-light\"\nbackground_color = \"#fdf6e3\"\n", - ) - .expect("seed settings"); - - let mut app = create_test_app(); - let explicit_base3 = ratatui::style::Color::Rgb(0xfd, 0xf6, 0xe3); - assert_eq!(app.background_color_override, Some(explicit_base3)); + /// Presentation whose `config_search_updated` template repeats `{value}`. + struct RepeatedPlaceholder; - let result = set_config_value(&mut app, "theme", "dark", false); - - assert!(!result.is_error, "{:?}", result.message); - assert_eq!(app.theme_id, codewhale_palette::ThemeId::Whale); - assert_eq!(app.background_color_override, Some(explicit_base3)); - assert_eq!(app.ui_theme.surface_bg, explicit_base3); - assert!( - crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_none(), - "only the underwater theme owns a painted field" - ); + impl CommandPresentationContext for RepeatedPlaceholder { + fn translate(&self, key: &str, r: &[(&str, &str)]) -> Result { + if key == "config_search_updated" && r.len() == 2 { + return Ok(format!("{} {} {}", r[0].1, r[0].1, r[1].1)); + } + Ok(r.iter().map(|(_, value)| *value).collect()) + } } #[test] - fn underwater_theme_keeps_its_field_under_a_background_override() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-underwater-override-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); - let _guard = EnvGuard::new(&temp_root); - - let mut app = create_test_app(); - let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26); - let background = set_config_value(&mut app, "background_color", "#1a1b26", false); - assert!(!background.is_error, "{:?}", background.message); - - let preview = set_config_value(&mut app, "theme", "underwater", false); - assert!(!preview.is_error, "{:?}", preview.message); - assert_eq!(app.theme_id, codewhale_palette::ThemeId::Underwater); - assert_eq!(app.background_color_override, Some(custom)); - assert_eq!(app.ui_theme.surface_bg, custom); - assert!( - crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_some(), - "the underwater theme's field survives a background override" + fn prepared_rendering_allows_a_repeated_placeholder() { + let mut presentation = RepeatedPlaceholder; + let contexts = CommandContexts::empty().with_presentation(&mut presentation); + let mut parts = contexts.into_parts(); + let prepared = preflight(&mut parts).expect("preflight"); + let reply = ConfigReply::with_message_and_action( + ConfigMessage::keyed_with( + "config_search_updated", + vec![ + ("value".to_string(), ConfigMessage::literal("duckduckgo")), + ( + "scope".to_string(), + ConfigMessage::keyed("config_scope_saved"), + ), + ], + ), + ConfigAction::OpenConfigView, ); + let result = to_result(reply, &mut parts, &prepared); + assert!(!result.is_error, "{:?}", result.message); + assert_eq!(result.action, Some(ConfigAction::OpenConfigView)); + assert_eq!(result.message.as_deref(), Some("duckduckgo duckduckgo ")); } #[test] - fn session_only_background_override_survives_theme_preview() { - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-session-background-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); - let _guard = EnvGuard::new(&temp_root); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "theme = \"solarized-light\"\n", - ) - .expect("seed settings"); - - let mut app = create_test_app(); - let custom = ratatui::style::Color::Rgb(0x1a, 0x1b, 0x26); - let background = set_config_value(&mut app, "background_color", "#1a1b26", false); - assert!(!background.is_error, "{:?}", background.message); - assert_eq!(app.background_color_override, Some(custom)); - - let preview = set_config_value(&mut app, "theme", "dark", false); - assert!(!preview.is_error, "{:?}", preview.message); - assert_eq!(app.background_color_override, Some(custom)); - assert_eq!(app.ui_theme.surface_bg, custom); - - let solarized_preview = set_config_value(&mut app, "theme", "solarized-light", false); + fn unprepared_key_errors_without_translating_after_an_effect() { + let mut presentation = EchoWithCount::default(); + let contexts = CommandContexts::empty().with_presentation(&mut presentation); + let mut parts = contexts.into_parts(); + // No preflight: an unprepared key must never reach the facet. + let prepared = PreparedTemplates::default(); + let reply = ConfigReply::with_message_and_action( + ConfigMessage::keyed("not_a_prepared_key"), + ConfigAction::OpenConfigView, + ); + let result = to_result(reply, &mut parts, &prepared); + assert!(result.is_error); + assert!(result.action.is_none()); assert!( - !solarized_preview.is_error, + result + .message + .as_deref() + .is_some_and(|m| m.contains("unprepared message key")), "{:?}", - solarized_preview.message + result.message ); - assert_eq!(app.background_color_override, Some(custom)); - assert_eq!(app.ui_theme.surface_bg, custom); - assert!(crate::tui::ocean::OceanRamp::for_theme(&app.ui_theme).is_none()); - - let saved_theme = set_config_value(&mut app, "theme", "dark", true); - assert!(!saved_theme.is_error, "{:?}", saved_theme.message); - assert_eq!(app.background_color_override, Some(custom)); - assert_eq!(app.ui_theme.surface_bg, custom); - let persisted = Settings::load_persisted().expect("persisted settings"); - assert_eq!(persisted.theme, "dark"); assert_eq!( - persisted.background_color, None, - "saving a theme must not persist the session-only background" + presentation.0.get(), + 0, + "an unprepared key must not translate after the effect" ); } #[test] - fn set_theme_save_updates_live_app_and_persists() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-theme-save-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let mut app = create_test_app(); - let result = config_command(&mut app, Some("theme grayscale --save")); - let msg = result.message.unwrap(); - - assert_eq!(msg, "theme = grayscale (saved)"); - assert_eq!(app.ui_theme.mode, codewhale_palette::PaletteMode::Grayscale); - - let settings_path = Settings::path().unwrap(); - let saved = fs::read_to_string(settings_path).unwrap(); - assert!(saved.contains("theme = \"grayscale\"")); + fn preflight_rejects_missing_presentation_before_effects() { + let mut parts = CommandContexts::empty().into_parts(); + let Err(result) = preflight(&mut parts) else { + panic!("a missing presentation capability must fail preflight"); + }; + assert!(result.is_error); + assert!(result.action.is_none()); + assert_eq!( + result.message.as_deref(), + Some("Command capability unavailable: presentation") + ); } #[test] - fn unrelated_save_does_not_persist_no_animations_runtime_overlay() { - let temp_root = env::temp_dir().join(format!( - "codewhale-no-animations-save-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); - let _guard = EnvGuard::new(&temp_root); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "low_motion = false\nfancy_animations = true\ntheme = \"system\"\n", - ) - .expect("seed settings"); - // Safety: test-only environment mutation is serialized by EnvGuard. - unsafe { - env::set_var("NO_ANIMATIONS", "1"); - } - - let mut app = create_test_app(); - assert!(app.low_motion, "runtime overlay should reduce motion"); - assert!( - !app.fancy_animations, - "runtime overlay should disable ocean animations" - ); - - let result = set_config_value(&mut app, "theme", "grayscale", true); - assert!(!result.is_error, "{:?}", result.message); - let saved = Settings::load_persisted().expect("persisted settings"); - assert_eq!(saved.theme, "grayscale"); - assert!( - !saved.low_motion, - "NO_ANIMATIONS must not become a saved preference" - ); - assert!( - saved.fancy_animations, - "NO_ANIMATIONS must not overwrite the saved animation preference" + fn config_fails_before_touching_the_facet_when_presentation_is_missing() { + // `PanicConfig` panics on any call: passing proves no effect ran. + let mut facet = PanicConfig; + let contexts = CommandContexts::empty().with_config(&mut facet); + let result = config(contexts, Some("search duckduckgo --save")); + assert!(result.is_error); + assert!(result.action.is_none()); + assert_eq!( + result.message.as_deref(), + Some("Command capability unavailable: presentation") ); - assert!(app.low_motion); - assert!(!app.fancy_animations); } #[test] - fn preset_save_does_not_persist_runtime_environment_overlays() { - let temp_root = env::temp_dir().join(format!( - "codewhale-preset-env-overlay-test-{}-{}", - std::process::id(), - SystemTime::now() - .duration_since(UNIX_EPOCH) - .expect("clock") - .as_nanos() - )); - fs::create_dir_all(temp_root.join(".deepseek")).expect("settings dir"); - let _guard = EnvGuard::new(&temp_root); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "low_motion = false\nfancy_animations = true\nsynchronized_output = \"auto\"\n", - ) - .expect("seed settings"); - // NO_ANIMATIONS exercises the reported path. Ptyxis supplies an - // unrelated effective-only field, making an accidental - // apply_env_overrides()+save observable even though the calm preset - // intentionally selects reduced motion itself. - unsafe { - env::set_var("NO_ANIMATIONS", "1"); - env::set_var("PTYXIS_VERSION", "50.0"); - } - - let mut app = create_test_app(); - let result = config_command(&mut app, Some("preset calm --save")); - assert!(!result.is_error, "{:?}", result.message); - - let saved = Settings::load_persisted().expect("persisted settings"); - assert!(saved.low_motion, "calm preset should save reduced motion"); + fn config_fails_before_mutating_when_a_required_template_is_missing() { + let mut facet = PanicConfig; + let mut presentation = OneMissingTemplate("config_search_updated"); + let contexts = CommandContexts::empty() + .with_config(&mut facet) + .with_presentation(&mut presentation); + let result = config(contexts, Some("search duckduckgo --save")); + assert!(result.is_error); + assert!(result.action.is_none()); assert!( - !saved.fancy_animations, - "calm preset should save static ocean chrome" - ); - assert_eq!( - saved.synchronized_output, "auto", - "Ptyxis runtime override must not leak into a preset save" + result + .message + .as_deref() + .is_some_and(|m| m.contains("config_search_updated")) ); } #[test] - fn config_approval_mode_valid_values() { - let dir = tempfile::tempdir().expect("isolated config dir"); - let mut app = create_test_app(); - app.config_path = Some(dir.path().join("config.toml")); - // Test auto - let result = config_command(&mut app, Some("approval_mode auto")); - assert!(result.message.is_some()); - assert_eq!(app.approval_mode, ApprovalMode::Auto); - - // Test suggest - let result = config_command(&mut app, Some("approval_mode suggest")); - assert!(result.message.is_some()); - assert_eq!(app.approval_mode, ApprovalMode::Suggest); - - // Test never - let result = config_command(&mut app, Some("approval_mode never")); - assert!(result.message.is_some()); - assert_eq!(app.approval_mode, ApprovalMode::Never); + fn translation_failure_inside_a_nested_parameter_propagates() { + let mut presentation = OneMissingTemplate("config_scope_saved"); + let contexts = CommandContexts::empty().with_presentation(&mut presentation); + let mut parts = contexts.into_parts(); + let prepared = PreparedTemplates::default(); + let reply = ConfigReply::with_message_and_action( + ConfigMessage::keyed_with( + "config_search_updated", + vec![ + ("value".to_string(), ConfigMessage::literal("duckduckgo")), + ( + "scope".to_string(), + ConfigMessage::keyed("config_scope_saved"), + ), + ], + ), + ConfigAction::OpenConfigView, + ); + let result = to_result(reply, &mut parts, &prepared); + assert!(result.is_error, "a nested translation failure must fail"); + assert!(result.action.is_none(), "no action on a failed translation"); + assert!( + result + .message + .as_deref() + .is_some_and(|m| m.contains("config_scope_saved")) + ); } #[test] - fn config_approval_mode_save_persists_top_level_policy() { - let temp_root = env::temp_dir().join(format!( - "codewhale-approval-policy-save-test-{}", - std::process::id() - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join("custom-config.toml"); - - let mut app = create_test_app(); - app.config_path = Some(config_path.clone()); - let result = config_command(&mut app, Some("approval_mode suggest --save")); - let msg = result.message.unwrap(); - let saved = fs::read_to_string(&config_path).unwrap(); - - assert!(!result.is_error); - assert_eq!(app.approval_mode, ApprovalMode::Suggest); + fn subagent_grammar_lives_in_the_command() { + assert_eq!(parse_subagent_rest(""), (SubagentOp::Status, false)); + assert_eq!(parse_subagent_rest("status"), (SubagentOp::Status, false)); assert_eq!( - msg, - format!( - "approval_mode = Ask (saved to {} as approval_policy = \"on-request\")", - config_path.display() + parse_subagent_rest("on"), + ( + SubagentOp::Set { + key: "enabled", + value: "true", + }, + false, ) ); - assert!(saved.contains("approval_policy = \"on-request\"")); - - let loaded = Config::load(Some(config_path.clone()), None).unwrap(); - assert_eq!(loaded.approval_policy.as_deref(), Some("on-request")); - - let mut restarted = create_test_app_with_config(&loaded); - restarted.config_path = Some(config_path.clone()); - assert!(restarted.approval_policy_locked()); - assert!(!restarted.approval_policy_requirements_managed()); - let changed = config_command(&mut restarted, Some("approval_mode auto --save")); - assert!(!changed.is_error, "{:?}", changed.message); assert_eq!( - changed.action, - Some(AppAction::ApprovalPolicyPersisted { - policy: Some("auto".to_string()) - }) + parse_subagent_rest("off --save"), + ( + SubagentOp::Set { + key: "enabled", + value: "false", + }, + true, + ) + ); + assert_eq!( + parse_subagent_rest("max_depth"), + (SubagentOp::Get("max_depth"), false) ); - assert_eq!(restarted.approval_mode, ApprovalMode::Auto); - let reloaded = Config::load(Some(config_path), None).unwrap(); - assert_eq!(reloaded.approval_policy.as_deref(), Some("auto")); - } - - #[test] - fn config_approval_policy_can_return_to_saved_tui_permission_default() { - let temp_root = env::temp_dir().join(format!( - "codewhale-approval-policy-tui-default-test-{}", - std::process::id() - )); - fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join("custom-config.toml"); - fs::write(&config_path, "# keep\napproval_policy = \"auto\"\n").unwrap(); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "permission_posture = \"full-access\"\n", - ) - .unwrap(); - let loaded = Config::load(Some(config_path.clone()), None).unwrap(); - let mut app = create_test_app_with_config(&loaded); - app.config_path = Some(config_path.clone()); - - let result = set_config_value(&mut app, "approval_policy", "use-tui-default", true); - - assert!(!result.is_error, "{:?}", result.message); - assert_eq!(app.approval_mode, ApprovalMode::Bypass); - assert!(!app.approval_policy_locked()); assert_eq!( - result.action, - Some(AppAction::ApprovalPolicyPersisted { policy: None }) + parse_subagent_rest("max_depth 4 -s"), + ( + SubagentOp::Set { + key: "max_depth", + value: "4", + }, + true, + ) ); - let saved = fs::read_to_string(config_path).unwrap(); - assert!(saved.contains("# keep")); - assert!(!saved.contains("approval_policy")); + assert_eq!(parse_subagent_rest("a b c"), (SubagentOp::Usage, false)); } #[test] - fn config_approval_policy_full_access_adopts_tui_posture_and_releases_root_override() { - let temp_root = env::temp_dir().join(format!( - "codewhale-approval-policy-full-access-test-{}", - std::process::id() - )); - fs::create_dir_all(temp_root.join(".deepseek")).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join("custom-config.toml"); - fs::write(&config_path, "# keep\napproval_policy = \"on-request\"\n").unwrap(); - fs::write( - temp_root.join(".deepseek").join("settings.toml"), - "permission_posture = \"ask\"\n", - ) - .unwrap(); - let loaded = Config::load(Some(config_path.clone()), None).unwrap(); - let mut app = create_test_app_with_config(&loaded); - app.config_path = Some(config_path.clone()); - // The production constructor receives the path up front and marks a - // user-owned root policy editable. This focused fixture attaches the - // path after construction, so mirror that resolved ownership here. - app.mark_approval_policy_locked(); - assert!(app.approval_policy_locked()); - - let result = set_config_value(&mut app, "approval_policy", "full-access", true); - - assert!(!result.is_error, "{:?}", result.message); - assert_eq!(app.approval_mode, ApprovalMode::Bypass); - assert!(!app.approval_policy_locked()); + fn search_grammar_lives_in_the_command() { + assert_eq!(parse_search_rest(""), (SearchOp::Status, false)); + assert_eq!(parse_search_rest("status"), (SearchOp::Status, false)); + assert_eq!(parse_search_rest("provider"), (SearchOp::Status, false)); assert_eq!( - result.action, - Some(AppAction::ApprovalPolicyPersisted { policy: None }) + parse_search_rest("duckduckgo"), + (SearchOp::Set("duckduckgo"), false) ); - let saved_config = fs::read_to_string(config_path).unwrap(); - assert!(saved_config.contains("# keep")); - assert!(!saved_config.contains("approval_policy")); - let saved_settings = Settings::load_persisted().expect("saved TUI settings"); assert_eq!( - saved_settings.permission_posture.as_deref(), - Some("full-access") + parse_search_rest("provider tavily --save"), + (SearchOp::Set("tavily"), true) ); + assert_eq!(parse_search_rest("a b"), (SearchOp::Usage, false)); } #[test] - fn config_approval_mode_invalid_value() { - let dir = tempfile::tempdir().expect("isolated config dir"); - let mut app = create_test_app(); - app.config_path = Some(dir.path().join("config.toml")); - let result = config_command(&mut app, Some("approval_mode invalid")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("Invalid approval_mode")); - } - - #[test] - fn config_without_save_flag() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - let result = config_command(&mut app, Some("auto_compact true")); - assert!(result.message.is_some()); - let msg = result.message.unwrap(); - assert!(msg.contains("(session only")); - } - - #[test] - fn config_threshold_enables_and_updates_live_auto_compaction() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - app.auto_compact = false; - app.auto_compact_user_configured = false; - - let result = config_command(&mut app, Some("auto_compact_threshold_percent 65")); - - assert!(!result.is_error, "{:?}", result.message); - assert!(app.auto_compact); - assert!(app.auto_compact_user_configured); - assert_eq!(app.auto_compact_threshold_percent, 65.0); + fn notifications_grammar_lives_in_the_command() { assert_eq!( - app.compact_threshold, - crate::route_budget::compaction_threshold_for_route_at_percent( - app.api_provider, - app.effective_model_for_budget(), - app.active_route_limits, - 65.0, - ) - ); - assert!(matches!( - result.action, - Some(AppAction::UpdateCompaction(_)) - )); - } - - #[test] - fn config_composer_border_updates_live_app() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - app.composer_border = true; - - let result = config_command(&mut app, Some("composer_border false")); - - assert!(result.message.is_some()); - assert!(!app.composer_border); - assert!(app.needs_redraw); - } - - #[test] - fn config_composer_multiline_mode_updates_live_app() { - let _lock = lock_test_env(); - let mut app = create_test_app(); - app.composer_multiline_mode = false; - - let result = config_command(&mut app, Some("composer_multiline_mode true")); - - assert!(!result.is_error, "{:?}", result.message); - assert!(app.composer_multiline_mode); - assert!(app.needs_redraw); - } - - /// `/trust on` and `/trust off` printed nothing (0.10.1 tutorial, lesson - /// 3). Each change now has a line that says what it did and how to undo - /// it, and only `--save` claims anything was saved. - #[test] - fn trust_change_note_says_what_changed() { - let on = trust_change_note(true, false); - assert!(on.starts_with("Trust mode is on for this session"), "{on}"); - assert!(on.contains("/trust off"), "{on}"); - let off = trust_change_note(false, false); - assert!( - off.starts_with("Trust mode is off for this session"), - "{off}" + parse_notifications_rest(""), + (NotificationsOp::Status, false) ); - for note in [&on, &off] { - assert!(!note.contains("Saved"), "{note}"); - } - let saved_on = trust_change_note(true, true); - assert!( - saved_on.starts_with(&on) && saved_on.contains("are trusted in later sessions"), - "{saved_on}" + assert_eq!( + parse_notifications_rest("status"), + (NotificationsOp::Status, false) ); - let saved_off = trust_change_note(false, true); - assert!( - saved_off.starts_with(&off) && saved_off.contains("are no longer trusted"), - "{saved_off}" + assert_eq!( + parse_notifications_rest("method"), + (NotificationsOp::Get("method"), false) ); - } - - #[tokio::test] - async fn trust_only_persists_with_save() { - let tmp = tempfile::tempdir().unwrap(); - let _guard = EnvGuard::new(tmp.path()); - let config_path = tmp.path().join("config.toml"); - let _config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", &config_path); - let workspace = tmp.path().join("workspace"); - let commands = workspace.join(".claude/commands"); - let skills = workspace.join(".claude/skills/review-example"); - fs::create_dir_all(&commands).unwrap(); - fs::create_dir_all(&skills).unwrap(); - fs::write(commands.join("review-example.md"), "Review the example").unwrap(); - fs::write( - skills.join("SKILL.md"), - "---\nname: review-example\ndescription: Review the example\n---\nRead the example.", - ) - .unwrap(); - let mut app = create_test_app(); - app.workspace = workspace.clone(); - app.trust_mode = false; - for trusted in [false, true, false] { - if trusted || app.trust_mode { - let result = trust( - &mut app, - Some(if trusted { "on --save" } else { "off --save" }), - ); - assert_eq!( - result.action, - Some(AppAction::SetWorkspaceTrust { - trusted, - save: true - }) - ); - set_workspace_trust(&mut app, trusted, true).await.unwrap(); - } - let saved = fs::read(&config_path).ok(); - for session_trusted in [true, false] { - let result = trust(&mut app, Some(if session_trusted { "on" } else { "off" })); - assert_eq!( - result.action, - Some(AppAction::SetWorkspaceTrust { - trusted: session_trusted, - save: false - }) - ); - set_workspace_trust(&mut app, session_trusted, false) - .await - .unwrap(); - assert_eq!(app.trust_mode, session_trusted); - assert_eq!( - fs::read(&config_path).ok(), - saved, - "session toggle must not write config" - ); - assert_eq!(crate::config::is_workspace_trusted(&workspace), trusted); - } - app.trust_mode = trusted; - assert!(trust(&mut app, Some("on --typo")).is_error); - assert_eq!(crate::config::is_workspace_trusted(&workspace), trusted); - crate::commands::user_registry::with_registry_for_workspace( - Some(&workspace), - |registry| { - assert_eq!(registry.get("review-example").is_some(), trusted); + assert_eq!( + parse_notifications_rest("method sound --save"), + ( + NotificationsOp::Set { + key: "method", + value: "sound", }, - ); - assert_eq!( - crate::skills::discover_in_workspace(&workspace) - .get("review-example") - .is_some(), - trusted - ); - } - // A failed write must not grant trust in memory. - fs::create_dir_all(tmp.path().join("bad-config")).unwrap(); - let _bad_config = EnvVarGuard::set("CODEWHALE_CONFIG_PATH", tmp.path().join("bad-config")); - assert!(set_workspace_trust(&mut app, true, true).await.is_err()); - assert!(!app.trust_mode); - app.trust_mode = true; - assert!(set_workspace_trust(&mut app, false, true).await.is_err()); - assert!(!app.trust_mode); - } - - #[test] - fn test_trust_status_default_lists_state() { - let mut app = create_test_app(); - let result = trust(&mut app, None); - let msg = result.message.expect("status message"); - assert!(msg.contains("Workspace trust mode")); - } - - #[test] - fn test_trust_add_requires_path() { - let mut app = create_test_app(); - let result = trust(&mut app, Some("add")); - let msg = result.message.expect("error message"); - assert!(msg.starts_with("Error:"), "got {msg:?}"); - } - - #[test] - fn test_logout_clears_api_key_state() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-tui-logout-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - - let config_path = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - fs::write(&config_path, "api_key = \"test-key\"\n").unwrap(); - - let mut app = create_test_app(); - let result = logout(&mut app); - assert!(result.message.is_some()); - assert_eq!(app.onboarding, OnboardingState::Provider); - assert!(app.onboarding_needs_api_key); - assert!(app.onboarding_missing_key_recovery); - assert_eq!(result.action, Some(AppAction::OpenProviderPicker)); - - let updated = fs::read_to_string(config_path).unwrap(); - assert!(!updated.contains("api_key")); - } - - #[test] - fn logout_clears_only_exact_named_custom_provider_key() { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap() - .as_nanos(); - let temp_root = env::temp_dir().join(format!( - "codewhale-custom-logout-test-{}-{}", - std::process::id(), - nanos - )); - fs::create_dir_all(&temp_root).unwrap(); - let _guard = EnvGuard::new(&temp_root); - let config_path = temp_root.join(".deepseek").join("config.toml"); - fs::create_dir_all(config_path.parent().unwrap()).unwrap(); - fs::write( - &config_path, - "[providers.custom-a]\napi_key = \"a-key\"\n\n[providers.custom-b]\napi_key = \"b-key\"\n", - ) - .unwrap(); - let mut app = create_test_app(); - app.set_provider_identity(ProviderKind::Custom, "custom-a"); - - let result = logout(&mut app); - - assert!(result.message.is_some()); - let updated = fs::read_to_string(config_path).unwrap(); - assert!(!updated.contains("a-key"), "{updated}"); - assert!(updated.contains("b-key"), "{updated}"); - } - - #[test] - fn named_custom_provider_url_write_fails_closed() { - let mut app = create_test_app(); - app.set_provider_identity(ProviderKind::Custom, "custom-a"); - - let result = config_command( - &mut app, - Some("provider_url http://127.0.0.1:18181/v1 --save"), + true, + ) ); - let message = result.message.expect("error message"); - - assert!(result.is_error); - assert!(result.action.is_none()); - assert!( - message.contains("[providers.custom-a]") && message.contains("missing"), - "{message}" + assert_eq!( + parse_notifications_rest("threshold_secs 30 -s"), + ( + NotificationsOp::Set { + key: "threshold_secs", + value: "30", + }, + true, + ) ); } } diff --git a/crates/tui/src/commands/groups/config/import_claude.rs b/crates/tui/src/commands/groups/config/import_claude.rs index 88ac71aace..73f9e4bfdf 100644 --- a/crates/tui/src/commands/groups/config/import_claude.rs +++ b/crates/tui/src/commands/groups/config/import_claude.rs @@ -1,305 +1,253 @@ -//! `/import-claude` — explicit, reviewable Claude Code migration (#5557). - -use crate::commands::CommandResult; -use crate::import_claude::{self, McpCandidateLine}; -use crate::tui::app::App; - -pub(super) fn import_claude_command(app: &mut App, arg: Option<&str>) -> CommandResult { - let apply = arg.map(str::trim).is_some_and(|arg| { - arg.eq_ignore_ascii_case("--apply") || arg.eq_ignore_ascii_case("apply") - }); - let home = crate::config::effective_home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - let (sources, claude, settings) = import_claude::read_sources(&home); - - // MCP candidates come from the same discovery the `/mcp import` consent - // flow uses, filtered to the Claude sources: provenance stays single-sourced. - let markets = Vec::new(); - let mcp_candidates = - crate::mcp::external_import::discover_external_sources(&home, &app.workspace, &markets) - .into_iter() - .filter(|candidate| { - matches!( - candidate.source_kind, - crate::mcp::external_import::ExternalMcpSourceKind::ClaudeJson - ) - }) - .map(|candidate| McpCandidateLine { - summary: candidate.summary, - hard_blocked: candidate.hard_blocked, - name: candidate.name, - }) - .collect::>(); - - let plan = import_claude::build_plan(sources, claude, settings, &home, mcp_candidates); - if plan.is_empty() { - return CommandResult { - message: Some( - "No Claude configuration found to import (looked for ~/.claude.json and \ - ~/.claude/settings.json)." - .to_string(), - ), - action: None, - is_error: false, - }; - } - - // The plan is shown before anything is written; the only writes are the - // report and an *unapplied* bundle file. Applying always goes through a - // separate consent path (`/mcp import approve `, `config import`). - let imports_dir = codewhale_config::codewhale_home() - .map(|home| home.join("imports")) - .unwrap_or_else(|_| std::path::PathBuf::from(".codewhale/imports")); - let report_path = imports_dir.join("claude-import-report.md"); - let bundle_path = imports_dir.join("claude-portable-bundle.json"); - let wrote_report = codewhale_config::persistence::atomic_write( - &report_path, - import_claude::report_markdown(&plan).as_bytes(), - ) - .is_ok(); - let wrote_bundle = if plan.env_safe.is_empty() { - false - } else { - codewhale_config::persistence::atomic_write( - &bundle_path, - import_claude::portable_bundle_json(&plan).as_bytes(), - ) - .is_ok() - }; - - if apply { - return apply_plan(&plan, &report_path, wrote_report); +//! Portable `/import-claude` plan rendering (FEAT-028). +//! +//! Pure formatting over the contract-owned [`CommandClaudePlan`]: no `App`, no +//! filesystem. The host builds the plan and performs the writes; the command +//! owns the flow and the messages. + +use codewhale_command_contract::config::CommandClaudePlan; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +/// Claude `permissions.defaultMode` → the Codewhale approval-posture +/// recommendation (report text only; nothing is applied). +pub(crate) fn approval_posture_recommendation(mode: Option<&str>) -> &'static str { + match mode { + Some("acceptEdits") => "auto-review posture (/permissions) matches acceptEdits", + Some("plan") => "plan mode matches Claude's plan default", + Some("bypassPermissions") => { + "Full Access (yolo) matches bypassPermissions — confirm you want it" + } + _ => "the default Ask posture matches Claude's default", } +} - let mut message = import_claude::render_plan(&plan); - message.push_str( - "\n\nNothing above is applied yet. `/import-claude --apply` carries over the \ - standing instructions. Review MCP servers individually with `/mcp import`; hooks and \ - permission rules stay manual because they run code.", - ); - if wrote_report { - message.push_str(&format!( - "\nFull report: {}", - crate::utils::display_path(&report_path) - )); +/// One-line migration guidance for a Claude permission rule. +pub(crate) fn permission_rule_guidance(rule: &str) -> String { + let rule = rule.trim(); + if rule.starts_with("Bash(") { + format!("{rule} → an execpolicy rule for that command family (/permissions)") + } else if rule.starts_with("WebFetch(domain:") || rule.starts_with("WebSearch(") { + format!("{rule} → a fetch/network rule for that host or surface") + } else if rule.starts_with("mcp__") { + format!("{rule} → enable that MCP tool in your mcp.json enabled_tools") + } else if rule.starts_with("Read(") || rule.starts_with("Edit(") || rule.starts_with("Write(") { + format!("{rule} → a path-scoped ask rule (reads stay workspace-bounded by default)") } else { - message.push_str("\n(the report could not be written; the plan above is complete)"); - } - if wrote_bundle { - message.push_str(&format!( - "\nPortable bundle (review, then `codewhale config import {}`): apply it there for the consent/rollback path.", - crate::utils::display_path(&bundle_path) - )); - } - CommandResult { - message: Some(message), - action: None, - is_error: false, + format!("{rule} → review against /permissions before mapping") } } -/// Carry over what can be carried over safely, and say exactly what was done. -/// -/// `--apply` is the consent: the plan is printed first by the bare command, and -/// this path never overwrites an existing file, never imports MCP servers, -/// and never touches hooks or permission rules — those run code, so -/// they stay a human decision. -fn apply_plan( - plan: &import_claude::ClaudeImportPlan, - report_path: &std::path::Path, - wrote_report: bool, -) -> CommandResult { - let mut done: Vec = Vec::new(); - let mut manual: Vec = Vec::new(); +/// The unapplied portable bundle document for the safe env entries. +pub(crate) fn portable_bundle_json(plan: &CommandClaudePlan) -> String { + let global: BTreeMap<&str, &str> = plan + .env_safe + .iter() + .map(|(k, v)| (k.as_str(), v.as_str())) + .collect(); + serde_json::json!({ + "schema_version": 1, + "kind": "codewhale.portable-config", + "metadata": { + "name": "claude-import", + "generator": "codewhale /import-claude", + }, + "global": global, + }) + .to_string() +} - // 1. Standing instructions: a plain file copy, and only when the - // destination does not already exist. Clobbering the operator's own - // instructions would be the one unrecoverable thing here. - if plan.has_claude_md { - match codewhale_config::codewhale_home() { - Ok(home) => { - let destination = home.join("instructions.md"); - let source = crate::config::effective_home_dir() - .unwrap_or_else(|| std::path::PathBuf::from(".")) - .join(".claude") - .join("CLAUDE.md"); - if destination.exists() { - manual.push(format!( - "{} already exists — left untouched; merge from {} by hand if you want both.", - crate::utils::display_path(&destination), - crate::utils::display_path(&source) - )); - } else { - match std::fs::read(&source) { - Ok(bytes) - if codewhale_config::persistence::atomic_write( - &destination, - &bytes, - ) - .is_ok() => - { - done.push(format!( - "Standing instructions copied to {}.", - crate::utils::display_path(&destination) - )); - } - _ => manual.push(format!( - "Could not copy {} — copy it to {} by hand.", - crate::utils::display_path(&source), - crate::utils::display_path(&destination) - )), - } - } - } - Err(_) => manual.push( - "Could not resolve the Codewhale home, so standing instructions were not copied." - .to_string(), - ), +/// Render the reviewable plan shown in the transcript. +pub(crate) fn render_plan( + plan: &CommandClaudePlan, + display: &dyn Fn(&std::path::Path) -> String, +) -> String { + let mut out = String::new(); + out.push_str("Claude import plan (nothing is applied without its own consent):\n"); + for (path, status) in &plan.sources { + let _ = writeln!(out, " · {}", status.describe(&display(path))); + } + if !plan.mcp_candidates.is_empty() { + out.push_str("\nMCP servers (apply each with `/mcp import --approve`):\n"); + for candidate in &plan.mcp_candidates { + let blocked = if candidate.hard_blocked { + " [blocked]" + } else { + "" + }; + let _ = writeln!( + out, + " · {}{blocked} — {}", + candidate.name, candidate.summary + ); } } - - // MCP approval must bind the exact bytes and current managed-config - // revision shown by the shared preview. Blanket migration cannot grant it. - for candidate in &plan.mcp_candidates { - manual.push(if candidate.hard_blocked { - format!("MCP `{}` is disabled at its source and was not imported.", candidate.name) - } else { - format!("MCP `{}` needs a separate review: run /mcp import, then use its displayed approval command. It will be imported OFF.", candidate.name) - }); - } - - // 3. What stays human, always. - if !plan.hook_events.is_empty() { - manual.push(format!( - "{} hook event(s) still need mapping with /hooks — hooks run code, so they are never imported for you.", - plan.hook_events.len() - )); + if !plan.per_project_mcp.is_empty() { + out.push_str("\nProject-scoped MCP servers (listed for provenance only):\n"); + for name in plan.per_project_mcp.iter().take(8) { + let _ = writeln!(out, " · {name}"); + } } - if !plan.permissions_allow.is_empty() - || !plan.permissions_ask.is_empty() - || !plan.permissions_deny.is_empty() - { - manual.push( - "Permission rules are listed in the report; apply the ones you want with /permissions." - .to_string(), - ); + if !plan.env_safe.is_empty() { + out.push_str("\nSafe env settings proposed for the portable bundle:\n"); + for (key, _) in &plan.env_safe { + let _ = writeln!(out, " · {key}"); + } } - - let mut message = String::new(); - if done.is_empty() { - message.push_str("Nothing was applied.\n"); - } else { - message.push_str("Applied:\n"); - for line in &done { - message.push_str(&format!(" · {line}\n")); + if !plan.env_skipped.is_empty() { + out.push_str("\nEnv keys not imported (secret-shaped or unmapped; values never shown):\n"); + for key in plan.env_skipped.iter().take(12) { + let _ = writeln!(out, " · {key}"); } } - if !manual.is_empty() { - message.push_str("\nStill yours to do:\n"); - for line in &manual { - message.push_str(&format!(" · {line}\n")); + let _ = writeln!( + out, + "\nApproval posture: {}.", + approval_posture_recommendation(plan.permissions_default_mode.as_deref()) + ); + for (label, rules) in [ + ("Allow", &plan.permissions_allow), + ("Ask", &plan.permissions_ask), + ("Deny", &plan.permissions_deny), + ] { + if !rules.is_empty() { + let _ = writeln!(out, "\n{label} rules (map manually; not auto-applied):"); + for rule in rules.iter().take(8) { + let _ = writeln!(out, " · {}", permission_rule_guidance(rule)); + } } } - if !done.is_empty() { - message.push_str("\nRun /mcp reload to connect the imported servers after reviewing them."); + if !plan.hook_events.is_empty() { + out.push_str("\nHook events (map manually with /hooks):\n"); + for event in &plan.hook_events { + let _ = writeln!(out, " · {event}"); + } } - if wrote_report { - message.push_str(&format!( - "\nFull report: {}", - crate::utils::display_path(report_path) - )); + if plan.has_claude_md { + out.push_str("\nStanding instructions: ~/.claude/CLAUDE.md found — copy it to ~/.codewhale/instructions.md (or the repo's AGENTS.md) to carry it over.\n"); } - CommandResult { - message: Some(message), - action: None, - is_error: false, + out +} + +/// The written follow-through report (markdown). +pub(crate) fn report_markdown( + plan: &CommandClaudePlan, + display: &dyn Fn(&std::path::Path) -> String, +) -> String { + let mut out = String::from( + "# Claude import report\n\nGenerated by `/import-claude`. Nothing was applied automatically.\n\n", + ); + out.push_str(&render_plan(plan, display)); + if !plan.env_safe.is_empty() { + out.push_str("\n## Apply the portable bundle\n\nReview the generated bundle, then run:\n\n codewhale config import \n\n"); } + out.push_str("\n## Manual follow-ups\n\n- MCP: `/mcp import --approve` per server (consent is recorded).\n- Permissions: map rules in /permissions and execpolicy.\n- Hooks: re-create matching hooks with /hooks.\n- Paths: copy CLAUDE.md if you want those standing instructions.\n"); + out } #[cfg(test)] mod tests { use super::*; - use crate::config::Config; - use crate::tui::app::TuiOptions; - use std::fs; - use tempfile::TempDir; - - /// A home with a Claude `CLAUDE.md`, plus a Codewhale home to import into. - fn sandbox() -> (TempDir, App) { - let temp = TempDir::new().expect("tempdir"); - let home = temp.path().join("home"); - let claude = home.join(".claude"); - fs::create_dir_all(&claude).expect("claude dir"); - fs::write(claude.join("CLAUDE.md"), b"be excellent\n").expect("CLAUDE.md"); - fs::write(claude.join("settings.json"), b"{}").expect("settings"); - fs::write(home.join(".claude.json"), b"{}").expect("claude.json"); + use codewhale_command_contract::config::{CommandMcpCandidate, CommandSourceStatus}; + use std::path::PathBuf; + + fn sample_plan() -> CommandClaudePlan { + CommandClaudePlan { + sources: vec![(PathBuf::from("~/.claude.json"), CommandSourceStatus::Found)], + mcp_candidates: vec![CommandMcpCandidate { + name: "top".into(), + summary: "stdio server (node)".into(), + hard_blocked: false, + }], + per_project_mcp: vec!["inner (project /tmp/p)".into()], + env_safe: vec![("BASH_DEFAULT_TIMEOUT_MS".into(), "120000".into())], + env_skipped: vec!["ANTHROPIC_API_KEY".into()], + permissions_default_mode: Some("acceptEdits".into()), + permissions_allow: vec!["Bash(git status:*)".into(), "[redacted]".into()], + permissions_ask: vec![], + permissions_deny: vec!["Read(./.env)".into()], + hook_events: vec!["PreToolUse (1)".into()], + has_claude_md: true, + claude_projects: vec!["/tmp/proj-a".into()], + } + } - let workspace = temp.path().join("ws"); - fs::create_dir_all(&workspace).expect("workspace"); - let options = TuiOptions { - config_path: Some(temp.path().join("config.toml")), - skills_dir: temp.path().join("skills"), - memory_path: temp.path().join("memory.md"), - notes_path: temp.path().join("notes.txt"), - mcp_config_path: temp.path().join("mcp.json"), - ..crate::tui::test_support::test_tui_options(&workspace) - }; - let app = App::new(options, &Config::default()); - (temp, app) + #[test] + fn render_plan_never_echoes_secret_values_and_maps_rules() { + let plan = sample_plan(); + let rendered = render_plan(&plan, &|path| path.display().to_string()); + assert!(!rendered.contains("sk-"), "{rendered}"); + assert!(rendered.contains("[redacted]"), "{rendered}"); + assert!(rendered.contains("Bash(git status:*) → an execpolicy rule")); + assert!(rendered.contains("acceptEdits")); + assert!(rendered.contains("Standing instructions: ~/.claude/CLAUDE.md found")); } #[test] - fn the_bare_command_applies_nothing_and_says_how_to_apply() { - let _lock = crate::test_support::lock_test_env(); - let (temp, mut app) = sandbox(); - let home = temp.path().join("home"); - let _home = crate::test_support::EnvVarGuard::set("HOME", &home); - let _profile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home); - let cw_home = temp.path().join("cw"); - let _cw = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &cw_home); + fn portable_bundle_is_the_portable_config_document() { + let plan = sample_plan(); + let bundle = portable_bundle_json(&plan); + assert!(bundle.contains("codewhale.portable-config")); + assert!(bundle.contains("BASH_DEFAULT_TIMEOUT_MS")); + assert!(bundle.contains("120000")); + } - let result = import_claude_command(&mut app, None); - let message = result.message.expect("a plan"); - assert!( - message.contains("/import-claude --apply"), - "the plan must say how to apply it: {message}" - ); - assert!( - !cw_home.join("instructions.md").exists(), - "the bare command must not write instructions.md" - ); + #[test] + fn report_markdown_includes_the_plan_and_manual_follow_ups() { + let plan = sample_plan(); + let report = report_markdown(&plan, &|path| path.display().to_string()); + assert!(report.starts_with("# Claude import report")); + assert!(report.contains("## Manual follow-ups")); + assert!(report.contains("## Apply the portable bundle")); } #[test] - fn apply_carries_the_standing_instructions_and_never_clobbers_them() { - let _lock = crate::test_support::lock_test_env(); - let (temp, mut app) = sandbox(); - let home = temp.path().join("home"); - let _home = crate::test_support::EnvVarGuard::set("HOME", &home); - let _profile = crate::test_support::EnvVarGuard::set("USERPROFILE", &home); - let cw_home = temp.path().join("cw"); - let _cw = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &cw_home); + fn posture_recommendations_cover_the_four_claude_modes() { + assert!(approval_posture_recommendation(Some("acceptEdits")).contains("auto-review")); + assert!(approval_posture_recommendation(Some("plan")).contains("plan")); + assert!(approval_posture_recommendation(Some("bypassPermissions")).contains("Full Access")); + assert!(approval_posture_recommendation(None).contains("Ask")); + } - let result = import_claude_command(&mut app, Some("--apply")); - let message = result.message.expect("an outcome"); - let destination = cw_home.join("instructions.md"); - assert!(destination.exists(), "instructions were copied: {message}"); + #[test] + fn source_status_wording_matches_the_baseline() { assert_eq!( - fs::read_to_string(&destination).expect("read"), - "be excellent\n" + CommandSourceStatus::Found.describe("~/.claude.json"), + "found: ~/.claude.json" + ); + assert_eq!( + CommandSourceStatus::NotFound.describe("~/.claude.json"), + "not found: ~/.claude.json" + ); + assert_eq!( + CommandSourceStatus::Oversize(5).describe("~/.claude.json"), + "skipped, too large (5 bytes): ~/.claude.json" ); - - // Running it again must not overwrite the operator's own file. - fs::write(&destination, b"mine now\n").expect("overwrite"); - let again = import_claude_command(&mut app, Some("--apply")); - let message = again.message.expect("an outcome"); assert_eq!( - fs::read_to_string(&destination).expect("read"), - "mine now\n", - "an existing instructions.md is never clobbered: {message}" + CommandSourceStatus::InvalidJson.describe("~/.claude.json"), + "invalid JSON: ~/.claude.json" ); + assert_eq!( + CommandSourceStatus::Unreadable("boom".into()).describe("~/.claude.json"), + "unreadable: ~/.claude.json (boom)" + ); + } + + #[test] + fn render_plan_uses_the_host_display_projection_not_an_absolute_home_path() { + let plan = CommandClaudePlan { + sources: vec![( + PathBuf::from("/home/review-user/.claude.json"), + CommandSourceStatus::Found, + )], + ..Default::default() + }; + let rendered = render_plan(&plan, &|path| { + path.display().to_string().replace("/home/review-user", "~") + }); + assert!(rendered.contains("found: ~/.claude.json"), "{rendered}"); assert!( - message.contains("already exists"), - "and the reason is said out loud: {message}" + !rendered.contains("/home/review-user"), + "the OS account name must not leak: {rendered}" ); } } diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs index 2ab1b96df0..f711a9346f 100644 --- a/crates/tui/src/commands/groups/config/mod.rs +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -1,81 +1,58 @@ -//! Config command area: settings, modes, themes, trust, and status surfaces. +//! Portable config command group metadata and inventory (FEAT-028). +//! +//! Mirrors `groups/session/mod.rs`: this module is part of the movable source +//! closure and imports only `codewhale-command-contract` — no `App`, no +//! `crate::tui`, no TUI `Command`/`CommandGroup` traits. Host registration, +//! concrete logic and action conversion live in +//! `crate::commands::config_group` and `crate::commands::config_main_host`. // This group dir intentionally has a `config.rs` child module with the same // name. The module_inception allow is a permanent structure rationale, not // migration scaffolding; see docs/architecture/command-dispatch.md. #[allow(clippy::module_inception)] pub mod config; -mod import_claude; -pub(in crate::commands) mod policy; +pub mod import_claude; +pub mod policy; -use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; -use crate::tui::app::App; -use codewhale_localization::MessageId; - -pub struct ConfigCommands; - -impl CommandGroup for ConfigCommands { - fn commands(&self) -> &'static [Box] { - cached_command_list!(vec![ - Box::new(FunctionCommand::new(&CONFIG_INFO, run_config)), - Box::new(FunctionCommand::new(&IMPORT_CLAUDE_INFO, run_import_claude)), - crate::commands::config_policy_host::permissions_registration(), - Box::new(FunctionCommand::new(&LOGIN_INFO, run_login)), - Box::new(FunctionCommand::new(&CONNECT_INFO, run_connect)), - Box::new(FunctionCommand::new(&DISCONNECT_INFO, run_disconnect)), - Box::new(FunctionCommand::new(&AUTH_INFO, run_auth)), - Box::new(FunctionCommand::new(&RAIL_INFO, run_rail)), - Box::new(FunctionCommand::new(&PET_INFO, run_pet)), - Box::new(FunctionCommand::new(&SETTINGS_INFO, run_settings)), - crate::commands::config_policy_host::status_registration(), - Box::new(FunctionCommand::new(&STATUSLINE_INFO, run_statusline)), - Box::new(FunctionCommand::new(&MODE_INFO, run_mode)), - Box::new(FunctionCommand::new(&FULLSCREEN_INFO, run_fullscreen)), - Box::new(FunctionCommand::new(&INLINE_INFO, run_inline)), - Box::new(FunctionCommand::new(&THEME_INFO, run_theme)), - Box::new(FunctionCommand::new(&VERBOSE_INFO, run_verbose)), - Box::new(FunctionCommand::new(&TRUST_INFO, run_trust)), - Box::new(FunctionCommand::new(&LOGOUT_INFO, run_logout)), - ]) - } -} +use codewhale_command_contract::handler::{CommandCapabilities, CommandContexts, CommandHandler}; +use codewhale_command_contract::metadata::CommandInfo; +use codewhale_command_contract::outcome::ConfigCommandResult; static CONFIG_INFO: CommandInfo = CommandInfo { name: "config", aliases: &[], usage: "/config [ask-rules|status| [value]]", - description_id: MessageId::CmdConfigDescription, + description_key: "cmd_config_description", }; static IMPORT_CLAUDE_INFO: CommandInfo = CommandInfo { name: "import-claude", aliases: &["import_claude"], usage: "/import-claude [--apply]", - description_id: MessageId::CmdImportClaudeDescription, + description_key: "cmd_import_claude_description", }; static LOGIN_INFO: CommandInfo = CommandInfo { name: "login", aliases: &[], usage: "/login [status|account|key]", - description_id: MessageId::CmdLoginDescription, + description_key: "cmd_login_description", }; static CONNECT_INFO: CommandInfo = CommandInfo { name: "connect", aliases: &[], usage: "/connect []", - description_id: MessageId::CmdConnectDescription, + description_key: "cmd_connect_description", }; static DISCONNECT_INFO: CommandInfo = CommandInfo { name: "disconnect", aliases: &[], usage: "/disconnect ", - description_id: MessageId::CmdDisconnectDescription, + description_key: "cmd_disconnect_description", }; static AUTH_INFO: CommandInfo = CommandInfo { name: "auth", aliases: &[], - usage: "/auth xai-device|chatgpt|chatgpt-revoke|orcarouter|orcarouter-revoke", - description_id: MessageId::CmdAuthDescription, + usage: "/auth xai-device|chatgpt|chatgpt-revoke|claude|claude-revoke|orcarouter|orcarouter-revoke", + description_key: "cmd_auth_description", }; static RAIL_INFO: CommandInfo = CommandInfo { name: "workbar", @@ -83,222 +60,134 @@ static RAIL_INFO: CommandInfo = CommandInfo { // the one workbar. aliases: &["rail", "sidebar"], usage: "/workbar [bottom|top|left|right|off|tasks|agents|context|pinned] [--save]", - description_id: MessageId::CmdSidebarDescription, + description_key: "cmd_sidebar_description", }; static PET_INFO: CommandInfo = CommandInfo { name: "pet", aliases: &[], - usage: "/pet [on|off|inspect|status|appearance|style [smooth|pixel]|language [auto|en|zh-Hans|zh-Hant|ja]|window|source|export|sound [on|off]|avatar [whale|cove|braille|pixels|particles|whale-girl|key]|action [name|live]|view [name|live]]", - description_id: MessageId::CmdPetDescription, + usage: config::PET_USAGE, + description_key: "cmd_pet_description", }; static SETTINGS_INFO: CommandInfo = CommandInfo { name: "settings", aliases: &[], usage: "/settings [text]", - description_id: MessageId::CmdSettingsDescription, + description_key: "cmd_settings_description", }; static STATUSLINE_INFO: CommandInfo = CommandInfo { name: "statusline", aliases: &[], usage: "/statusline", - description_id: MessageId::CmdStatuslineDescription, + description_key: "cmd_statusline_description", }; static MODE_INFO: CommandInfo = CommandInfo { name: "mode", aliases: &["jihua", "zidong"], - usage: "/mode [work|plan|operate|1|2|3]", - description_id: MessageId::CmdModeDescription, + usage: "/mode [act|plan|operate|1|2|3]", + description_key: "cmd_mode_description", }; static FULLSCREEN_INFO: CommandInfo = CommandInfo { name: "fullscreen", aliases: &[], usage: "/fullscreen", - description_id: MessageId::CmdFullscreenDescription, + description_key: "cmd_fullscreen_description", }; static INLINE_INFO: CommandInfo = CommandInfo { name: "inline", aliases: &[], usage: "/inline", - description_id: MessageId::CmdInlineDescription, + description_key: "cmd_inline_description", }; static THEME_INFO: CommandInfo = CommandInfo { name: "theme", aliases: &[], usage: "/theme [name|underwater|custom:|schema|path]", - description_id: MessageId::CmdThemeDescription, + description_key: "cmd_theme_description", }; static VERBOSE_INFO: CommandInfo = CommandInfo { name: "verbose", aliases: &[], usage: "/verbose [on|off]", - description_id: MessageId::CmdVerboseDescription, + description_key: "cmd_verbose_description", }; static TRUST_INFO: CommandInfo = CommandInfo { name: "trust", aliases: &["xinren"], usage: "/trust [on|off [--save]|add |remove |list]", - description_id: MessageId::CmdTrustDescription, + description_key: "cmd_trust_description", }; static LOGOUT_INFO: CommandInfo = CommandInfo { name: "logout", aliases: &[], usage: "/logout", - description_id: MessageId::CmdLogoutDescription, + description_key: "cmd_logout_description", }; -fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { - dispatch(app, name, arg).expect("registered config command should dispatch") -} -fn run_config(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "config", arg) -} -fn run_import_claude(app: &mut App, arg: Option<&str>) -> CommandResult { - import_claude::import_claude_command(app, arg) -} -fn run_login(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "login", arg) -} -fn run_auth(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "auth", arg) -} -fn run_rail(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "workbar", arg) -} -fn run_pet(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "pet", arg) -} -fn run_settings(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "settings", arg) -} -fn run_statusline(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "statusline", arg) -} -fn run_mode(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "mode", arg) -} -fn run_fullscreen(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "fullscreen", arg) -} -fn run_inline(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "inline", arg) -} -fn run_theme(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "theme", arg) -} -fn run_verbose(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "verbose", arg) -} -fn run_trust(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "trust", arg) -} -fn run_logout(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "logout", arg) -} -fn run_connect(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "connect", arg) -} -fn run_disconnect(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "disconnect", arg) -} -pub(in crate::commands) fn dispatch( - app: &mut App, - command: &str, - arg: Option<&str>, -) -> Option { - let result = match command { - "config" => config::config_command(app, arg), - "permissions" | "permission-rules" | "permission_rules" => { - crate::commands::config_policy_host::permissions(app, arg) - } - "login" => config::login(app, arg), - "auth" => match arg.map(str::trim) { - Some("xai-device") | Some("xai_device") => { - CommandResult::action(crate::tui::app::AppAction::StartXaiDeviceLogin) - } - Some("claude") | Some("anthropic") => { - CommandResult::action(crate::tui::app::AppAction::StartClaudeLogin) - } - Some("claude-revoke") => { - CommandResult::action(crate::tui::app::AppAction::StartClaudeRevoke) - } - Some("chatgpt") | Some("openai-codex") | Some("openai_codex") => { - CommandResult::action(crate::tui::app::AppAction::StartChatgptPkceLogin) - } - Some("chatgpt-revoke") | Some("chatgpt_revoke") => { - CommandResult::action(crate::tui::app::AppAction::StartChatgptRevoke) - } - Some("orcarouter") | Some("orca") => { - CommandResult::action(crate::tui::app::AppAction::StartOrcarouterPkceLogin) - } - Some("orcarouter-revoke") | Some("orcarouter_revoke") | Some("orca-revoke") => { - CommandResult::action(crate::tui::app::AppAction::StartOrcarouterRevoke) - } - _ => CommandResult::error( - "Usage: /auth xai-device|chatgpt|chatgpt-revoke|claude|claude-revoke|orcarouter|orcarouter-revoke", - ), - }, - "workbar" | "rail" | "sidebar" => config::sidebar(app, arg), - "pet" => config::pet(app, arg), - "settings" => config::settings_command(app, arg), - "status" => crate::commands::config_policy_host::status(app, arg), - "statusline" => config::status_line(app), - "mode" => config::mode(app, arg), - "fullscreen" => config::screen(app, crate::tui::app::ScreenMode::Fullscreen, arg), - "inline" => config::screen(app, crate::tui::app::ScreenMode::Inline, arg), - "jihua" => config::mode(app, Some("plan")), - "zidong" => config::mode(app, Some("yolo")), - "theme" => config::theme(app, arg), - "verbose" => config::verbose(app, arg), - "trust" | "xinren" => config::trust(app, arg), - "logout" => config::logout(app), - "connect" => config::connect(app, arg), - "disconnect" => config::disconnect(app, arg), - _ => return None, - }; - Some(result) +fn contextual( + capabilities: CommandCapabilities, + handler: fn(CommandContexts<'_>, Option<&str>) -> ConfigCommandResult, +) -> CommandHandler { + CommandHandler::Contextual { + capabilities, + handler, + } } -/// `/workflow settings` and `/config workflow`: the effective `[workflow]` -/// and `[goal]` tables with what each value does, read from the refreshed -/// session table after a config.toml reload (no model turn). The workflow -/// tool reads the same table, so the two surfaces cannot disagree. This -/// surface explains, it does not edit. -pub(in crate::commands) fn workflow_settings(app: &App) -> CommandResult { - let refreshed = crate::tools::workflow::session_workflow_config(&app.workspace); - let cfg = refreshed.as_ref().unwrap_or(&app.workflow_config); - let on = |value: bool| if value { "on" } else { "off" }; - let lines = [ - "[workflow] — config.toml".to_string(), - format!( - "automatic = {} · the agent may start a workflow itself for broad or staged work; off means only /workflow starts one", - on(cfg.automatic) +/// Complete group inventory (17 commands), shared by the host registration and +/// the extraction proof. Order is the registration order. +pub fn portable_handlers() -> [(&'static CommandInfo, CommandHandler); 19] { + // The policy slice (permissions, status) already exposes a portable + // inventory; re-publish its metadata through this group's inventory. + let [permissions, status] = policy::portable_handlers(); + [ + ( + &CONFIG_INFO, + contextual(config::CONFIG_CAPS, config::config), + ), + ( + &IMPORT_CLAUDE_INFO, + contextual(config::CONFIG_ONLY_CAPS, config::import_claude), + ), + permissions, + (&LOGIN_INFO, contextual(config::LOGIN_CAPS, config::login)), + ( + &CONNECT_INFO, + contextual(config::LOGIN_CAPS, config::connect), + ), + ( + &DISCONNECT_INFO, + contextual(config::LOGIN_CAPS, config::disconnect), ), - format!( - "auto_start_read_only = {} · read-only plans start without an approval card", - on(cfg.auto_start_read_only) + (&AUTH_INFO, CommandHandler::Pure(config::auth)), + ( + &RAIL_INFO, + contextual(config::WORK_SURFACE_CAPS, config::workbar), ), - format!( - "require_approval_for_writes = {} · plans that write, use shell/network, or elevate show an approval card first", - on(cfg.require_approval_for_writes) + (&PET_INFO, contextual(config::PET_CAPS, config::pet)), + ( + &SETTINGS_INFO, + contextual(config::CONFIG_PRESENTATION_CAPS, config::settings), ), - format!( - "max_children = {} · max_concurrent = {} · max_depth = {} · hard ceilings for one run", - cfg.max_children, cfg.max_concurrent, cfg.max_depth + status, + (&STATUSLINE_INFO, CommandHandler::Pure(config::statusline)), + (&MODE_INFO, contextual(config::MODE_CAPS, config::mode)), + ( + &FULLSCREEN_INFO, + contextual(config::CONFIG_ONLY_CAPS, config::fullscreen), ), - format!( - "default_token_budget = {} · shared admission cap for a run and its children (0 = none)", - cfg.default_token_budget + ( + &INLINE_INFO, + contextual(config::CONFIG_ONLY_CAPS, config::inline), ), - String::new(), - "[goal] — config.toml".to_string(), - format!( - "max_continuations = {} · automatic continuation passes before a goal pauses; 0 = unlimited (completion, blocked, or you stop it)", - app.goal_max_continuations + ( + &THEME_INFO, + contextual(config::CONFIG_PRESENTATION_CAPS, config::theme), ), - format!( - "enforce_token_budget = {} · true = a goal's token budget is a hard stop; false = advisory telemetry", - on(app.goal_enforce_token_budget) + ( + &VERBOSE_INFO, + contextual(config::CONFIG_ONLY_CAPS, config::verbose), ), - ]; - CommandResult::message(lines.join("\n")) + (&TRUST_INFO, contextual(config::TRUST_CAPS, config::trust)), + (&LOGOUT_INFO, contextual(config::LOGIN_CAPS, config::logout)), + ] } diff --git a/crates/tui/src/commands/groups/config/policy.rs b/crates/tui/src/commands/groups/config/policy.rs index 6c9fa08c95..9a13f599ae 100644 --- a/crates/tui/src/commands/groups/config/policy.rs +++ b/crates/tui/src/commands/groups/config/policy.rs @@ -7,7 +7,7 @@ use codewhale_command_contract::outcome::ConfigPolicyCommandResult as CommandRes #[path = "permissions.rs"] pub mod permissions; #[path = "policy_messages.rs"] -pub(crate) mod policy_messages; +pub mod policy_messages; #[path = "status.rs"] pub mod status; diff --git a/crates/tui/src/commands/groups/config/status.rs b/crates/tui/src/commands/groups/config/status.rs index 97d8f39716..92190f4d58 100644 --- a/crates/tui/src/commands/groups/config/status.rs +++ b/crates/tui/src/commands/groups/config/status.rs @@ -412,7 +412,7 @@ fn safety_summary(view: &ConfigStatusView, locale: &Messages) -> Cow<'static, st /// every narrower posture and is irreversible, so "sandbox disabled" alone /// would promise `sudo`/setuid workflows the process tree cannot perform. /// `None` is a platform without the flag, where the plain label is accurate. -pub(crate) fn safety_disabled_message(no_new_privs_active: Option) -> MessageId { +pub fn safety_disabled_message(no_new_privs_active: Option) -> MessageId { match no_new_privs_active { Some(true) => MessageId::StatusSafetyDisabledSetuidBlocked, Some(false) => MessageId::StatusSafetyDisabledSetuidAllowed, diff --git a/crates/tui/src/commands/groups/core/workflow.rs b/crates/tui/src/commands/groups/core/workflow.rs index 58919d40b6..7b324c4866 100644 --- a/crates/tui/src/commands/groups/core/workflow.rs +++ b/crates/tui/src/commands/groups/core/workflow.rs @@ -263,7 +263,7 @@ fn parse_workflow_control_action(app: &App, arg: Option<&str>) -> Option Some(workflow_status(app, rest)), "cancel" | "stop" | "abort" => Some(workflow_cancel(app, rest)), - "settings" | "config" => Some(super::super::config::workflow_settings(app)), + "settings" | "config" => Some(crate::commands::config_main_host::workflow_settings(app)), "help" | "?" => Some(CommandResult::message(WORKFLOW_USAGE)), // Saved definitions use the same two-turn review/confirm gate as a // conversational Workflow. The draft turn has an empty tool catalog; @@ -452,7 +452,7 @@ pub fn workflows(app: &mut App, arg: Option<&str>) -> CommandResult { match verb { "status" | "runs" | "list" | "inspect" => workflow_status(app, rest), "cancel" | "stop" | "abort" => workflow_cancel(app, rest), - "settings" | "config" => super::super::config::workflow_settings(app), + "settings" | "config" => crate::commands::config_main_host::workflow_settings(app), "help" | "?" => CommandResult::message(WORKFLOWS_USAGE), _ => CommandResult::error( "/workflows observes runs — it never launches one. Use /workflow to run one, or bare /workflow to orchestrate the current work.", diff --git a/crates/tui/src/commands/groups/mod.rs b/crates/tui/src/commands/groups/mod.rs index 18bb14ce2d..1820b1a5a9 100644 --- a/crates/tui/src/commands/groups/mod.rs +++ b/crates/tui/src/commands/groups/mod.rs @@ -33,7 +33,7 @@ pub fn all_command_groups() -> &'static [&'static dyn CommandGroup] { vec![ &core::CoreCommands, &super::session_group::SessionCommands, - &config::ConfigCommands, + &super::config_group::ConfigCommands, &super::debug_group::DebugCommands, &project::ProjectCommands, &skills::SkillsCommands, diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index c98814b31d..28914ab909 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -6,10 +6,15 @@ //! module keeps registry construction, user-command precedence, and the //! fall-through behaviour. +mod config_group; +mod config_host; +mod config_main_host; +mod config_main_import_host; +#[cfg(test)] mod config_policy_host; mod contract; pub mod discovery; -mod groups; +pub(crate) mod groups; // FEAT-025 host services for the session-export slice: the shared recovery // writer and the protected export-destination resolver/writer. Declared at the @@ -59,7 +64,7 @@ mod session_lifecycle_regression_tests; use std::sync::OnceLock; -pub(crate) use groups::config::config::{set_workspace_trust, trust_change_note}; +pub(crate) use config_main_host::{set_workspace_trust, trust_change_note}; pub(crate) use groups::core::open_constitution_manager; /// Stage a rollback of the last exchange for the UI to apply, or `None` when @@ -363,14 +368,10 @@ fn execute_in_context( // command's `CommandInfo` metadata. match command.as_str() { "jihua" => { - return groups::config::dispatch(app, "jihua", arg).unwrap_or_else(|| { - CommandResult::error("The /jihua alias could not be dispatched.") - }); + return config_group::mode_alias(app, "plan"); } "zidong" => { - return groups::config::dispatch(app, "zidong", arg).unwrap_or_else(|| { - CommandResult::error("The /zidong alias could not be dispatched.") - }); + return config_group::mode_alias(app, "yolo"); } _ => {} } @@ -446,12 +447,16 @@ fn execute_in_context( /// Update a configuration value programmatically (used by interactive UI views). pub fn set_config_value(app: &mut App, key: &str, value: &str, persist: bool) -> CommandResult { - groups::config::config::set_config_value(app, key, value, persist) + let locale = app.ui_locale; + config_host::render_config_reply( + config_main_host::set_config_value(app, key, value, persist), + locale, + ) } /// Switch the interaction mode (plan / work / operate). pub fn switch_mode(app: &mut App, mode: AppMode) -> String { - groups::config::config::switch_mode(app, mode) + config_main_host::switch_mode(app, mode) } fn edit_distance(a: &str, b: &str) -> usize { @@ -2210,9 +2215,25 @@ mod tests { "export", // FEAT-026 completes the session structural-copy slice. "structcopy", - // FEAT-027 config policy/status slice; remaining config stays legacy. + // FEAT-027 config policy/status slice. "permissions", "status", + // FEAT-028 completes the config group (all 17 commands portable). + "config", + "import-claude", + "login", + "auth", + "workbar", + "pet", + "settings", + "statusline", + "mode", + "fullscreen", + "inline", + "theme", + "verbose", + "trust", + "logout", // FEAT-029 complete debug group, including receipts and mutation. "tokens", "cost", diff --git a/crates/tui/src/commands/session_export_test_support.rs b/crates/tui/src/commands/session_export_test_support.rs index 926b5685c7..e704321dd2 100644 --- a/crates/tui/src/commands/session_export_test_support.rs +++ b/crates/tui/src/commands/session_export_test_support.rs @@ -121,6 +121,7 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { debug_diagnostics, permissions, config_status, + .. } = parts; assert!(permissions.is_none() && config_status.is_none()); diff --git a/crates/tui/src/commands/session_structcopy_host_tests.rs b/crates/tui/src/commands/session_structcopy_host_tests.rs index 2635162adf..25ff785d61 100644 --- a/crates/tui/src/commands/session_structcopy_host_tests.rs +++ b/crates/tui/src/commands/session_structcopy_host_tests.rs @@ -368,6 +368,7 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross debug_diagnostics, permissions, config_status, + .. } = bundle.contexts(capabilities).into_parts(); assert!(permissions.is_none() && config_status.is_none()); assert!(presentation.is_some()); diff --git a/crates/tui/src/import_claude.rs b/crates/tui/src/import_claude.rs index 66d4159f31..afa1560144 100644 --- a/crates/tui/src/import_claude.rs +++ b/crates/tui/src/import_claude.rs @@ -14,13 +14,14 @@ //! - **Hooks** and **paths** (e.g. `~/.claude/CLAUDE.md`) are listed as manual //! follow-ups with exact target commands. //! -//! The command writes a report and an *unapplied* bundle file. Applying any -//! part always requires its own explicit consent path. - -use std::collections::BTreeMap; -use std::path::{Path, PathBuf}; +//! The plan model and all rendering live in the contract and the portable +//! group; this module only reads the Claude sources and builds the plan. +use codewhale_command_contract::config::{ + CommandClaudePlan, CommandMcpCandidate, CommandSourceStatus, +}; use serde_json::Value; +use std::path::{Path, PathBuf}; /// Maximum size accepted for any single Claude source file. Generous for /// real `~/.claude.json` files with many projects; bounded so a pathological @@ -49,106 +50,29 @@ const SAFE_ENV_KEYS: &[&str] = &[ /// values a migration should not carry. const MAX_ENV_VALUE_LEN: usize = 200; -/// Status of one Claude source file. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) enum SourceStatus { - Found, - NotFound, - Unreadable(String), - Oversize(u64), - InvalidJson, -} - -impl SourceStatus { - fn describe(&self, path: &Path) -> String { - match self { - Self::Found => format!("found: {}", crate::utils::display_path(path)), - Self::NotFound => format!("not found: {}", crate::utils::display_path(path)), - Self::Unreadable(error) => { - format!("unreadable: {} ({error})", crate::utils::display_path(path)) - } - Self::Oversize(bytes) => format!( - "skipped, too large ({} bytes): {}", - bytes, - crate::utils::display_path(path) - ), - Self::InvalidJson => { - format!("invalid JSON: {}", crate::utils::display_path(path)) - } - } - } -} - -/// One parsed MCP candidate (name + one-line summary), already filtered to the -/// Claude sources by the caller. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(crate) struct McpCandidateLine { - pub(crate) name: String, - pub(crate) summary: String, - pub(crate) hard_blocked: bool, -} - -/// The full `/import-claude` plan. Pure data: building it mutates nothing. -#[derive(Debug, Clone, Default)] -pub(crate) struct ClaudeImportPlan { - pub(crate) sources: Vec<(PathBuf, SourceStatus)>, - /// MCP servers from `~/.claude.json` (top level), for `/mcp import`. - pub(crate) mcp_candidates: Vec, - /// Names of MCP servers found under `projects..mcpServers`, listed - /// for provenance only in v1. - pub(crate) per_project_mcp: Vec, - /// Safe env entries proposed for the portable bundle. - pub(crate) env_safe: BTreeMap, - /// Env keys skipped because they are secret-shaped or not allowlisted. - pub(crate) env_skipped: Vec, - /// Claude `permissions.defaultMode`, when present. - pub(crate) permissions_default_mode: Option, - pub(crate) permissions_allow: Vec, - pub(crate) permissions_ask: Vec, - pub(crate) permissions_deny: Vec, - /// Hook event names with counts, e.g. `PreToolUse (2)`. - pub(crate) hook_events: Vec, - /// True when `~/.claude/CLAUDE.md` exists. - pub(crate) has_claude_md: bool, - /// Project workspace paths recorded in `~/.claude.json` (bounded). - pub(crate) claude_projects: Vec, -} - -impl ClaudeImportPlan { - pub(crate) fn is_empty(&self) -> bool { - self.mcp_candidates.is_empty() - && self.per_project_mcp.is_empty() - && self.env_safe.is_empty() - && self.env_skipped.is_empty() - && self.permissions_default_mode.is_none() - && self.permissions_allow.is_empty() - && self.permissions_ask.is_empty() - && self.permissions_deny.is_empty() - && self.hook_events.is_empty() - && !self.has_claude_md - && self.claude_projects.is_empty() - } -} - -fn read_bounded_json(path: &Path) -> Result { +fn read_bounded_json(path: &Path) -> Result { let meta = std::fs::metadata(path).map_err(|e| match e.kind() { - std::io::ErrorKind::NotFound => SourceStatus::NotFound, - _ => SourceStatus::Unreadable(e.to_string()), + std::io::ErrorKind::NotFound => CommandSourceStatus::NotFound, + _ => CommandSourceStatus::Unreadable(e.to_string()), })?; if !meta.is_file() { - return Err(SourceStatus::NotFound); + return Err(CommandSourceStatus::NotFound); } if meta.len() > MAX_SOURCE_BYTES { - return Err(SourceStatus::Oversize(meta.len())); + return Err(CommandSourceStatus::Oversize(meta.len())); } - let raw = std::fs::read(path).map_err(|e| SourceStatus::Unreadable(e.to_string()))?; - serde_json::from_slice::(&raw).map_err(|_| SourceStatus::InvalidJson) + let raw = std::fs::read(path).map_err(|e| CommandSourceStatus::Unreadable(e.to_string()))?; + serde_json::from_slice::(&raw).map_err(|_| CommandSourceStatus::InvalidJson) } /// Read the Claude sources under `home` (usually `~`). pub(crate) fn read_sources( home: &Path, -) -> (Vec<(PathBuf, SourceStatus)>, Option, Option) { +) -> ( + Vec<(PathBuf, CommandSourceStatus)>, + Option, + Option, +) { let claude_json = home.join(".claude.json"); let settings = home.join(".claude").join("settings.json"); let mut sources = Vec::new(); @@ -156,14 +80,14 @@ pub(crate) fn read_sources( let mut settings_value = None; match read_bounded_json(&claude_json) { Ok(value) => { - sources.push((claude_json.clone(), SourceStatus::Found)); + sources.push((claude_json.clone(), CommandSourceStatus::Found)); claude_value = Some(value); } Err(status) => sources.push((claude_json, status)), } match read_bounded_json(&settings) { Ok(value) => { - sources.push((settings.clone(), SourceStatus::Found)); + sources.push((settings.clone(), CommandSourceStatus::Found)); settings_value = Some(value); } Err(status) => sources.push((settings, status)), @@ -186,16 +110,16 @@ fn safe_env_value(value: &Value) -> Option { /// Build the plan from already-read sources plus MCP candidates discovered by /// the existing external-import machinery (so server parsing stays single-sourced). pub(crate) fn build_plan( - sources: Vec<(PathBuf, SourceStatus)>, + sources: Vec<(PathBuf, CommandSourceStatus)>, claude: Option, settings: Option, home: &Path, - mcp_candidates: Vec, -) -> ClaudeImportPlan { - let mut plan = ClaudeImportPlan { + mcp_candidates: Vec, +) -> CommandClaudePlan { + let mut plan = CommandClaudePlan { sources, mcp_candidates, - ..ClaudeImportPlan::default() + ..CommandClaudePlan::default() }; if let Some(claude) = claude.as_ref() && let Some(projects) = claude.get("projects").and_then(Value::as_object) @@ -218,7 +142,7 @@ pub(crate) fn build_plan( if SAFE_ENV_KEYS.contains(&key.to_ascii_lowercase().as_str()) { match safe_env_value(value) { Some(text) => { - plan.env_safe.insert(key.clone(), text); + plan.env_safe.push((key.clone(), text)); } None => plan.env_skipped.push(format!("{key} (value not portable)")), } @@ -254,143 +178,10 @@ pub(crate) fn build_plan( } } plan.has_claude_md = home.join(".claude").join("CLAUDE.md").is_file(); + plan.env_safe.sort(); plan } -/// Claude `permissions.defaultMode` → the Codewhale approval-posture -/// recommendation (report text only; nothing is applied). -pub(crate) fn approval_posture_recommendation(mode: Option<&str>) -> &'static str { - match mode { - Some("acceptEdits") => "auto-review posture (/permissions) matches acceptEdits", - Some("plan") => "plan mode matches Claude's plan default", - Some("bypassPermissions") => { - "Full Access (yolo) matches bypassPermissions — confirm you want it" - } - _ => "the default Ask posture matches Claude's default", - } -} - -/// One-line migration guidance for a Claude permission rule. -pub(crate) fn permission_rule_guidance(rule: &str) -> String { - let rule = rule.trim(); - if rule.starts_with("Bash(") { - format!("{rule} → an execpolicy rule for that command family (/permissions)") - } else if rule.starts_with("WebFetch(domain:") || rule.starts_with("WebSearch(") { - format!("{rule} → a fetch/network rule for that host or surface") - } else if rule.starts_with("mcp__") { - format!("{rule} → enable that MCP tool in your mcp.json enabled_tools") - } else if rule.starts_with("Read(") || rule.starts_with("Edit(") || rule.starts_with("Write(") { - format!("{rule} → a path-scoped ask rule (reads stay workspace-bounded by default)") - } else { - format!("{rule} → review against /permissions before mapping") - } -} - -/// The unapplied portable bundle document for the safe env entries. JSON is a -/// first-class bundle format (`config import` accepts `.json`), so the -/// generated file goes through the same strict parser as any hand-written one. -pub(crate) fn portable_bundle_json(plan: &ClaudeImportPlan) -> String { - let global: BTreeMap<&str, &str> = plan - .env_safe - .iter() - .map(|(k, v)| (k.as_str(), v.as_str())) - .collect(); - serde_json::json!({ - "schema_version": 1, - "kind": "codewhale.portable-config", - "metadata": { - "name": "claude-import", - "generator": "codewhale /import-claude", - }, - "global": global, - }) - .to_string() -} - -/// Render the reviewable plan shown in the transcript. -pub(crate) fn render_plan(plan: &ClaudeImportPlan) -> String { - use std::fmt::Write as _; - - let mut out = String::new(); - out.push_str("Claude import plan (nothing is applied without its own consent):\n"); - for (path, status) in &plan.sources { - let _ = writeln!(out, " · {}", status.describe(path)); - } - if !plan.mcp_candidates.is_empty() { - out.push_str("\nMCP servers (apply each with `/mcp import --approve`):\n"); - for candidate in &plan.mcp_candidates { - let blocked = if candidate.hard_blocked { - " [blocked]" - } else { - "" - }; - let _ = writeln!( - out, - " · {}{blocked} — {}", - candidate.name, candidate.summary - ); - } - } - if !plan.per_project_mcp.is_empty() { - out.push_str("\nProject-scoped MCP servers (listed for provenance only):\n"); - for name in plan.per_project_mcp.iter().take(8) { - let _ = writeln!(out, " · {name}"); - } - } - if !plan.env_safe.is_empty() { - out.push_str("\nSafe env settings proposed for the portable bundle:\n"); - for key in plan.env_safe.keys() { - let _ = writeln!(out, " · {key}"); - } - } - if !plan.env_skipped.is_empty() { - out.push_str("\nEnv keys not imported (secret-shaped or unmapped; values never shown):\n"); - for key in plan.env_skipped.iter().take(12) { - let _ = writeln!(out, " · {key}"); - } - } - let _ = writeln!( - out, - "\nApproval posture: {}.", - approval_posture_recommendation(plan.permissions_default_mode.as_deref()) - ); - for (label, rules) in [ - ("Allow", &plan.permissions_allow), - ("Ask", &plan.permissions_ask), - ("Deny", &plan.permissions_deny), - ] { - if !rules.is_empty() { - let _ = writeln!(out, "\n{label} rules (map manually; not auto-applied):"); - for rule in rules.iter().take(8) { - let _ = writeln!(out, " · {}", permission_rule_guidance(rule)); - } - } - } - if !plan.hook_events.is_empty() { - out.push_str("\nHook events (map manually with /hooks):\n"); - for event in &plan.hook_events { - let _ = writeln!(out, " · {event}"); - } - } - if plan.has_claude_md { - out.push_str("\nStanding instructions: ~/.claude/CLAUDE.md found — copy it to ~/.codewhale/instructions.md (or the repo's AGENTS.md) to carry it over.\n"); - } - out -} - -/// The written follow-through report (markdown). -pub(crate) fn report_markdown(plan: &ClaudeImportPlan) -> String { - let mut out = String::from( - "# Claude import report\n\nGenerated by `/import-claude`. Nothing was applied automatically.\n\n", - ); - out.push_str(&render_plan(plan)); - if !plan.env_safe.is_empty() { - out.push_str("\n## Apply the portable bundle\n\nReview the generated bundle, then run:\n\n codewhale config import \n\n"); - } - out.push_str("\n## Manual follow-ups\n\n- MCP: `/mcp import --approve` per server (consent is recorded).\n- Permissions: map rules in /permissions and execpolicy.\n- Hooks: re-create matching hooks with /hooks.\n- Paths: copy CLAUDE.md if you want those standing instructions.\n"); - out -} - #[cfg(test)] mod tests { use super::*; @@ -403,6 +194,13 @@ mod tests { (dir, home) } + fn env_safe_value(plan: &CommandClaudePlan, key: &str) -> Option { + plan.env_safe + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.clone()) + } + #[test] fn missing_sources_report_not_found_and_plan_is_empty() { let (dir, home) = temp_home("missing"); @@ -412,7 +210,7 @@ mod tests { assert!( plan.sources .iter() - .any(|(_, s)| *s == SourceStatus::NotFound) + .any(|(_, s)| *s == CommandSourceStatus::NotFound) ); assert!(dir.path().exists()); } @@ -448,14 +246,12 @@ mod tests { let (sources, _claude, settings) = read_sources(&home); let plan = build_plan(sources, None, settings, &home, Vec::new()); assert_eq!( - plan.env_safe - .get("BASH_DEFAULT_TIMEOUT_MS") - .map(String::as_str), + env_safe_value(&plan, "BASH_DEFAULT_TIMEOUT_MS").as_deref(), Some("120000") ); - assert!(plan.env_safe.contains_key("USE_BUILTIN_RIPGREP")); - assert!(!plan.env_safe.contains_key("EDITOR")); - assert!(!plan.env_safe.contains_key("ANTHROPIC_API_KEY")); + assert!(env_safe_value(&plan, "USE_BUILTIN_RIPGREP").is_some()); + assert!(env_safe_value(&plan, "EDITOR").is_none()); + assert!(env_safe_value(&plan, "ANTHROPIC_API_KEY").is_none()); assert!( plan.env_skipped .iter() @@ -473,20 +269,6 @@ mod tests { ); assert_eq!(plan.permissions_deny.len(), 1); assert_eq!(plan.hook_events.len(), 2); - - let rendered = render_plan(&plan); - assert!(!rendered.contains("sk-ant-secret-value"), "{rendered}"); - assert!(!rendered.contains("sk-editor-secret-value"), "{rendered}"); - assert!(!rendered.contains("sk-rule-secret-value"), "{rendered}"); - assert!(rendered.contains("[redacted]"), "{rendered}"); - assert!(rendered.contains("Bash(git status:*) → an execpolicy rule")); - assert!(rendered.contains("acceptEdits")); - - let bundle = portable_bundle_json(&plan); - assert!(!bundle.contains("sk-ant-secret-value"), "{bundle}"); - assert!(!bundle.contains("sk-editor-secret-value"), "{bundle}"); - assert!(bundle.contains("codewhale.portable-config")); - assert!(bundle.contains("BASH_DEFAULT_TIMEOUT_MS")); } #[test] @@ -507,7 +289,7 @@ mod tests { .expect("claude.json"); let (sources, claude, _settings) = read_sources(&home); - let candidates = vec![McpCandidateLine { + let candidates = vec![CommandMcpCandidate { name: "top".to_string(), summary: "stdio server (node)".to_string(), hard_blocked: false, @@ -529,15 +311,7 @@ mod tests { assert!( sources .iter() - .any(|(_, s)| matches!(s, SourceStatus::InvalidJson)) + .any(|(_, s)| matches!(s, CommandSourceStatus::InvalidJson)) ); } - - #[test] - fn posture_recommendations_cover_the_four_claude_modes() { - assert!(approval_posture_recommendation(Some("acceptEdits")).contains("auto-review")); - assert!(approval_posture_recommendation(Some("plan")).contains("plan")); - assert!(approval_posture_recommendation(Some("bypassPermissions")).contains("Full Access")); - assert!(approval_posture_recommendation(None).contains("Ask")); - } } diff --git a/crates/tui/src/settings.rs b/crates/tui/src/settings.rs index 05849bdd76..fd14437120 100644 --- a/crates/tui/src/settings.rs +++ b/crates/tui/src/settings.rs @@ -1804,114 +1804,290 @@ impl Settings { } /// Get all settings as a displayable string + /// Test-only whole-report renderer; the command renders from [`Self::command_report`]. + #[cfg(test)] pub fn display(&self, locale: codewhale_localization::Locale) -> String { use codewhale_localization::{MessageId, tr}; + let report = self.command_report(); let mut lines = Vec::new(); lines.push(tr(locale, MessageId::SettingsTitle).to_string()); lines.push("─────────────────────────────".to_string()); - lines.push(format!(" auto_compact: {}", self.auto_compact)); - lines.push(format!( - " auto_compact_pct: {:.0}", - self.auto_compact_threshold_percent - )); - lines.push(format!(" calm_mode: {}", self.calm_mode)); - lines.push(format!(" pet_mode: {}", self.pet_mode)); - lines.push(format!(" tool_collapse: {}", self.tool_collapse_mode)); - lines.push(format!(" low_motion: {}", self.low_motion)); - lines.push(format!(" fancy_animations: {}", self.fancy_animations)); - lines.push(format!(" focus_texture: {}", self.focus_texture)); - lines.push(format!( - " work_surface: {}", - self.work_surface_placement - )); - lines.push(format!( - " work_top_height: {}", - self.work_surface_top_height - )); - lines.push(format!( - " work_side_width: {}", - self.work_surface_side_width - )); - lines.push(format!(" rail_panel: {}", self.rail_panel)); - lines.push(format!(" bracketed_paste: {}", self.bracketed_paste)); - lines.push(format!( - " paste_burst_detect: {}", - self.paste_burst_detection - )); - lines.push(format!(" mention_menu_limit: {}", self.mention_menu_limit)); - lines.push(format!(" mention_walk_depth: {}", self.mention_walk_depth)); - lines.push(format!( - " mention_behavior: {}", - self.mention_menu_behavior - )); - lines.push(format!(" show_thinking: {}", self.show_thinking)); - lines.push(format!( - " thinking_expanded: {}", - self.thinking_default_expanded - )); - lines.push(format!( - " thinking_preview: {}", - self.thinking_preview_lines - )); - lines.push(format!(" thinking_highlight: {}", self.thinking_highlight)); - lines.push(format!( - " help_expand_groups: {}", - self.help_expand_groups - )); - lines.push(format!(" pin_last_prompt: {}", self.pin_last_prompt)); - lines.push(format!(" contextual_tips: {}", self.contextual_tips)); - lines.push(format!(" show_tool_details: {}", self.show_tool_details)); - lines.push(format!(" inline_diffs: {}", self.inline_diffs)); - lines.push(format!(" locale: {}", self.locale)); - lines.push(format!(" theme: {}", self.theme)); - lines.push(format!( - " background_color: {}", - self.background_color.as_deref().unwrap_or("(default)") - )); - lines.push(format!(" composer_density: {}", self.composer_density)); - lines.push(format!(" composer_border: {}", self.composer_border)); - lines.push(format!( - " composer_multiline_mode: {}", - self.composer_multiline_mode - )); - lines.push(format!(" composer_vim_mode: {}", self.composer_vim_mode)); - lines.push(format!(" transcript_spacing: {}", self.transcript_spacing)); - lines.push(format!(" status_indicator: {}", self.status_indicator)); - lines.push(format!( - " synchronized_output: {}", - self.synchronized_output - )); + lines.extend(report.rows.iter().map(|row| { + format!( + " {: codewhale_command_contract::config::CommandSettingsReport { + let mut rows = Vec::new(); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "auto_compact:".to_string(), + value: self.auto_compact.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "auto_compact_pct:".to_string(), + value: format!("{:.0}", self.auto_compact_threshold_percent), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "calm_mode:".to_string(), + value: self.calm_mode.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "pet_mode:".to_string(), + value: self.pet_mode.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "tool_collapse:".to_string(), + value: self.tool_collapse_mode.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "low_motion:".to_string(), + value: self.low_motion.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "fancy_animations:".to_string(), + value: self.fancy_animations.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "focus_texture:".to_string(), + value: self.focus_texture.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "work_surface:".to_string(), + value: self.work_surface_placement.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "work_top_height:".to_string(), + value: self.work_surface_top_height.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "work_side_width:".to_string(), + value: self.work_surface_side_width.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "rail_panel:".to_string(), + value: self.rail_panel.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "bracketed_paste:".to_string(), + value: self.bracketed_paste.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "paste_burst_detect:".to_string(), + value: self.paste_burst_detection.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "mention_menu_limit:".to_string(), + value: self.mention_menu_limit.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "mention_walk_depth:".to_string(), + value: self.mention_walk_depth.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "mention_behavior:".to_string(), + value: self.mention_menu_behavior.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "show_thinking:".to_string(), + value: self.show_thinking.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "thinking_expanded:".to_string(), + value: self.thinking_default_expanded.to_string(), + label_width: 21, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "thinking_preview:".to_string(), + value: self.thinking_preview_lines.to_string(), + label_width: 21, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "thinking_highlight:".to_string(), + value: self.thinking_highlight.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "help_expand_groups:".to_string(), + value: self.help_expand_groups.to_string(), + label_width: 21, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "pin_last_prompt:".to_string(), + value: self.pin_last_prompt.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "contextual_tips:".to_string(), + value: self.contextual_tips.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "show_tool_details:".to_string(), + value: self.show_tool_details.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "inline_diffs:".to_string(), + value: self.inline_diffs.to_string(), + label_width: 19, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "locale:".to_string(), + value: self.locale.to_string(), + label_width: 19, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "theme:".to_string(), + value: self.theme.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "background_color:".to_string(), + value: self + .background_color + .as_deref() + .unwrap_or("(default)") + .to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "composer_density:".to_string(), + value: self.composer_density.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "composer_border:".to_string(), + value: self.composer_border.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "composer_multiline_mode:".to_string(), + value: self.composer_multiline_mode.to_string(), + label_width: 25, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "composer_vim_mode:".to_string(), + value: self.composer_vim_mode.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "transcript_spacing:".to_string(), + value: self.transcript_spacing.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "status_indicator:".to_string(), + value: self.status_indicator.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "synchronized_output:".to_string(), + value: self.synchronized_output.to_string(), + label_width: 21, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "workspace_follow_symlinks:".to_string(), + value: self.workspace_follow_symlinks.to_string(), + label_width: 27, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "default_mode:".to_string(), + value: self.default_mode.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "context_panel:".to_string(), + value: self.context_panel.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "cost_currency:".to_string(), + value: self.cost_currency.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "max_history:".to_string(), + value: self.max_input_history.to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "model defaults:".to_string(), + value: "config.toml (use /config)".to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "reasoning_effort:".to_string(), + value: self + .reasoning_effort .as_deref() .unwrap_or("(config/default)") - )); - lines.push(format!( - " permission_posture: {}", - self.permission_posture + .to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "permission_posture:".to_string(), + value: self + .permission_posture .as_deref() .unwrap_or("(config/default)") - )); - lines.push(format!( - " sandbox_mode: {} # filesystem scope (not approval)", - self.sandbox_mode.as_deref().unwrap_or("(config/default)") - )); - lines.push(String::new()); - lines.push(format!( - "{} {}", - tr(locale, MessageId::SettingsConfigFile), - Self::path().map_or_else(|_| "(unknown)".to_string(), |p| p.display().to_string()) - )); + .to_string(), + label_width: 20, + }); + rows.push(codewhale_command_contract::config::CommandSettingsRow { + label: "sandbox_mode:".to_string(), + value: format!( + "{} # filesystem scope (not approval)", + self.sandbox_mode.as_deref().unwrap_or("(config/default)") + ), + label_width: 20, + }); // Provenance footer: which keys this load actually owns versus the // schema defaults, so `/settings` says what the user set. Session // marks only appear when the instance outlives a `set()` (the CLI @@ -1927,16 +2103,13 @@ impl Settings { Layer::ManagedPolicy | Layer::CliFlag | Layer::ProjectConfig | Layer::Default => {} } } - if !user.is_empty() || !session.is_empty() { - lines.push(String::new()); - if !user.is_empty() { - lines.push(format!(" from settings.toml: {}", user.join(", "))); - } - if !session.is_empty() { - lines.push(format!(" session override: {}", session.join(", "))); - } + codewhale_command_contract::config::CommandSettingsReport { + rows, + config_file: Self::path() + .map_or_else(|_| "(unknown)".to_string(), |p| p.display().to_string()), + from_settings: user.into_iter().map(str::to_string).collect(), + session_overrides: session.into_iter().map(str::to_string).collect(), } - lines.join("\n") } /// Toggle one exact provider/model pin without touching credentials or diff --git a/scripts/check-command-config-policy-proof.py b/scripts/check-command-config-proof.py similarity index 62% rename from scripts/check-command-config-policy-proof.py rename to scripts/check-command-config-proof.py index cd51c714cb..188b3d7f67 100644 --- a/scripts/check-command-config-policy-proof.py +++ b/scripts/check-command-config-proof.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 -"""Require the complete production policy root and a service-free normal graph. +"""Require the complete production config group and a service-free normal graph. The adjacent normal library is the Rust authority: no fake outcomes, selected leaf or cfg(test)-only harness. Follow local module includes (including tests), then audit all-target transitive normal Cargo edges supplied by the runner. -The remaining config group is deliberately outside this partial-slice claim. + +FEAT-028 completes the whole config group, so this proof covers both the +portable main handlers (`config.rs`) and the policy slice (`policy.rs`). """ from pathlib import Path import argparse @@ -12,18 +14,26 @@ import tomllib ROOT = Path(__file__).resolve().parent.parent -GROUP = "crates/tui/src/commands/groups/config/policy.rs" -PROOF = "tests/portable-config-policy/src/lib.rs" -MANIFEST = "tests/portable-config-policy/Cargo.toml" +GROUP_DIR = "crates/tui/src/commands/groups/config" +ROOTS = ("crates/tui/src/commands/groups/config/mod.rs",) +PROOF = "tests/portable-config/src/lib.rs" +MANIFEST = "tests/portable-config/Cargo.toml" REQUIRED = { - GROUP, + ROOTS[0], + "crates/tui/src/commands/groups/config/config.rs", + "crates/tui/src/commands/groups/config/import_claude.rs", "crates/tui/src/commands/groups/config/permissions.rs", + "crates/tui/src/commands/groups/config/policy.rs", "crates/tui/src/commands/groups/config/status.rs", "crates/tui/src/commands/groups/config/policy_messages.rs", "crates/tui/src/commands/groups/config/policy_tests.rs", "crates/command-contract/src/money.rs", } -ALLOWED_WORKSPACE = {"codewhale-portable-config-policy", "codewhale-command-contract", "codewhale-protocol"} +ALLOWED_WORKSPACE = { + "codewhale-portable-config", + "codewhale-command-contract", + "codewhale-protocol", +} FORBIDDEN_SERVICES = { "tokio", "reqwest", "hyper", "rusqlite", "sqlx", "keyring", "dbus", "zbus", "secret-service", "ratatui", "crossterm", "ureq", "mio", "native-tls", "openssl", @@ -43,8 +53,8 @@ def graph_violations(graph): or name in FORBIDDEN_SERVICES] -def source_closure(root): - pending = [root / GROUP] +def source_closure(root, roots): + pending = [root / path for path in roots] seen = set() errors = [] while pending: @@ -52,12 +62,12 @@ def source_closure(root): if path in seen: continue if not path.is_relative_to(root.resolve()) or not path.is_file(): - errors.append(f"missing or out-of-tree policy module: {path}") + errors.append(f"missing or out-of-tree config module: {path}") continue seen.add(path) source = "\n".join(line.split("//")[0] for line in path.read_text().splitlines()) if HOST_IMPORT.search(source): - errors.append(f"host service in policy source: {path.relative_to(root)}") + errors.append(f"host service in config source: {path.relative_to(root)}") if "use codewhale_command_contract::money;" in source: pending.append(root / "crates/command-contract/src/money.rs") for explicit, name in MODULE.findall(source): @@ -72,24 +82,40 @@ def source_closure(root): return {str(path.relative_to(root)) for path in seen}, errors +def directory_violations(root): + """Reject a host import in ANY file of the movable group directory. + + The mounted-closure scan only sees files reachable from the proof roots; the + group root (`mod.rs`) and any future sibling would escape it. This scans the + whole directory so no file can be omitted, which is the FEAT-026 lesson. + """ + errors = [] + for path in sorted((root / GROUP_DIR).glob("*.rs")): + source = "\n".join(line.split("//")[0] for line in path.read_text().splitlines()) + if HOST_IMPORT.search(source): + errors.append(f"host service in movable group file: {path.relative_to(root)}") + return errors + + def violations(root): root = root.resolve() errors = [] wrapper = root / PROOF text = wrapper.read_text() - includes = re.findall(r'#\[path\s*=\s*"([^"]+)"\]\s*pub mod policy;', text) - if (len(includes) != 1 or "#[cfg" in text - or (wrapper.parent / includes[0]).resolve() != (root / GROUP).resolve()): - errors.append("proof must include the actual policy root as a normal library") + includes = re.findall(r'#\[path\s*=\s*"([^"]+)"\]\s*pub mod (\w+);', text) + resolved = {(wrapper.parent / inc).resolve() for inc, _ in includes} + if "#[cfg" in text or resolved != {(root / path).resolve() for path in ROOTS}: + errors.append("proof must include the actual config and policy roots as a normal library") manifest = tomllib.loads((root / MANIFEST).read_text()) if manifest.get("package", {}).get("publish") is not False: errors.append("proof must remain unpublished") if not {"codewhale-command-contract", "codewhale-protocol"} <= set(manifest.get("dependencies", {})): errors.append("shared shapes must be normal dependencies, not test-only substitutes") - closure, source_errors = source_closure(root) + closure, source_errors = source_closure(root, ROOTS) errors.extend(source_errors) + errors.extend(directory_violations(root)) for missing in sorted(REQUIRED - closure): - errors.append(f"incomplete policy source closure: {missing}") + errors.append(f"incomplete config source closure: {missing}") return errors @@ -99,9 +125,9 @@ def main(): args = parser.parse_args() errors = violations(ROOT) + graph_violations(args.graph.read_text()) for error in errors: - print(f"[config-policy-proof] FAIL: {error}") + print(f"[config-proof] FAIL: {error}") if not errors: - print("[config-policy-proof] PASS: actual complete slice and approved normal dependency graph") + print("[config-proof] PASS: actual complete group and approved normal dependency graph") return bool(errors) diff --git a/scripts/check-portable-config-policy.sh b/scripts/check-portable-config-policy.sh deleted file mode 100644 index 3d86dce976..0000000000 --- a/scripts/check-portable-config-policy.sh +++ /dev/null @@ -1,11 +0,0 @@ -#!/bin/sh -# Every Cargo process completes before the next; inspect all-target normal edges. -set -eu -cd "$(dirname "$0")/.." -graph=$(mktemp) -trap 'rm -f "$graph"' EXIT HUP INT TERM -cargo tree --locked -p codewhale-portable-config-policy --edges normal --target all --prefix none > "$graph" -cat "$graph" -python3 -B scripts/check-command-config-policy-proof.py --graph "$graph" -cargo check --locked -p codewhale-portable-config-policy --lib --no-default-features -env CARGO_TERM_COLOR=always CARGO_INCREMENTAL=0 RUSTFLAGS=-Dwarnings RUST_MIN_STACK=16777216 CODEWHALE_EXT_HOST_TESTS=1 sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-portable-config-policy --lib --all-features --locked --profile ci --no-tests=fail diff --git a/scripts/check-portable-config.sh b/scripts/check-portable-config.sh new file mode 100644 index 0000000000..70cbdd817a --- /dev/null +++ b/scripts/check-portable-config.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# Whole config group proof: complete actual production closure, service-free +# normal graph, no-default library check, and executing portable tests. +# Every Cargo process completes before the next; inspect all-target normal edges. +set -eu +cd "$(dirname "$0")/.." +graph=$(mktemp) +trap 'rm -f "$graph"' EXIT HUP INT TERM +cargo tree --locked -p codewhale-portable-config --edges normal --target all --prefix none > "$graph" +cat "$graph" +python3 -B scripts/check-command-config-proof.py --graph "$graph" +cargo check --locked -p codewhale-portable-config --lib --no-default-features +env CARGO_TERM_COLOR=always CARGO_INCREMENTAL=0 RUSTFLAGS=-Dwarnings RUST_MIN_STACK=16777216 CODEWHALE_EXT_HOST_TESTS=1 sh scripts/with-hermetic-test-home.sh cargo nextest run -p codewhale-portable-config --lib --all-features --locked --profile ci --no-tests=fail diff --git a/scripts/command-migration-topology.json b/scripts/command-migration-topology.json index 26501d076c..b1572dbef8 100644 --- a/scripts/command-migration-topology.json +++ b/scripts/command-migration-topology.json @@ -279,7 +279,6 @@ } }, "frontier": [ - "config", "core" ] } diff --git a/scripts/dev-test.sh b/scripts/dev-test.sh index 955a27dc7e..c0d105724d 100755 --- a/scripts/dev-test.sh +++ b/scripts/dev-test.sh @@ -52,7 +52,7 @@ hooks cargo test -p codewhale-hooks --lib --locked 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-policy cargo test -p codewhale-portable-config-policy --lib --locked +portable-config cargo test -p codewhale-portable-config --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 @@ -111,8 +111,8 @@ if [ -e "$area" ] || printf '%s' "$area" | grep -q /; then rel=${area#./} extra= case $rel in - tests/portable-config-policy/*|tests/portable-config-policy) - area=portable-config-policy + tests/portable-config/*|tests/portable-config) + area=portable-config ;; crates/tui/tests/integration/*) area=tui-integration @@ -187,7 +187,7 @@ case $area in lane) pkg=codewhale-lane ;; mcp) pkg=codewhale-mcp ;; paths) pkg=codewhale-paths ;; - portable-config-policy) pkg=codewhale-portable-config-policy ;; + portable-config) pkg=codewhale-portable-config ;; protocol) pkg=codewhale-protocol ;; release) pkg=codewhale-release ;; runtime) pkg=codewhale-runtime ;; diff --git a/scripts/test_check_command_config_policy_proof.py b/scripts/test_check_command_config_policy_proof.py deleted file mode 100644 index a48b951fd0..0000000000 --- a/scripts/test_check_command_config_policy_proof.py +++ /dev/null @@ -1,75 +0,0 @@ -"""Executable negative controls for the complete config-policy extraction proof.""" -import importlib.util -from pathlib import Path -import tempfile -import unittest - -path = Path(__file__).with_name("check-command-config-policy-proof.py") -spec = importlib.util.spec_from_file_location("policy_proof", path) -proof = importlib.util.module_from_spec(spec) -spec.loader.exec_module(proof) - - -class PolicyProofTests(unittest.TestCase): - def fixture(self, root): - for relative in proof.REQUIRED | {proof.PROOF, proof.MANIFEST}: - target = root / relative - target.parent.mkdir(parents=True, exist_ok=True) - target.write_text((proof.ROOT / relative).read_text()) - - def test_actual_complete_slice_is_included(self): - self.assertEqual(proof.violations(proof.ROOT), []) - - def test_selected_leaf_stub_and_test_only_root_are_rejected(self): - for source in ['pub mod policy {}', '#[path = "permissions.rs"] pub mod policy;', - '#[cfg(test)]\n' + (proof.ROOT / proof.PROOF).read_text()]: - 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)) - - def test_omitted_status_or_helper_is_rejected(self): - for name in ["status", "policy_messages", "money", "tests"]: - with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.fixture(root) - group = root / proof.GROUP - import re - if name == 'money': - group.write_text(group.read_text().replace('use codewhale_command_contract::money;', '')) - else: - group.write_text(re.sub(r'#\[path = "[^"]+"\]\s*(?:pub(?:\([^)]*\))?\s+)?mod ' + name + ';', '', group.read_text())) - self.assertTrue(proof.violations(root)) - - def test_new_transitive_helper_cannot_import_host_services(self): - for source in ['use crate::tui::app::App;', 'use codewhale_config::Config;', - 'use crate::commands::CommandResult;', 'use std::fs;', 'use codewhale_secrets::Store;']: - with self.subTest(source=source), tempfile.TemporaryDirectory() as tmp: - root = Path(tmp) - self.fixture(root) - group = root / proof.GROUP - group.write_text(group.read_text() + '\n#[path = "new_helper.rs"] mod helper;\n') - group.with_name('new_helper.rs').write_text(source) - self.assertTrue(proof.violations(root)) - - 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('[dev-dependencies]', '[build-dependencies]').replace('[dependencies]', '[dev-dependencies]')) - self.assertTrue(proof.violations(root)) - - def test_direct_and_transitive_runtime_or_storage_edges_are_rejected(self): - allowed = 'codewhale-portable-config-policy v0.10.1\ncodewhale-command-contract v0.10.1\ncodewhale-protocol v0.10.1\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', 'codewhale-localization', - 'tokio', 'reqwest', 'rusqlite', 'keyring', 'ratatui', 'crossterm']: - with self.subTest(package=package): - self.assertEqual(len(proof.graph_violations(allowed + f'{package} v1.0.0\n')), 1) - - -if __name__ == '__main__': - unittest.main(verbosity=2) diff --git a/scripts/test_check_command_config_proof.py b/scripts/test_check_command_config_proof.py new file mode 100644 index 0000000000..20801d8e1a --- /dev/null +++ b/scripts/test_check_command_config_proof.py @@ -0,0 +1,114 @@ +"""Executable negative controls for the complete config-group extraction proof. + +FEAT-028 proves the WHOLE config group, so the controls must reject a +selected-leaf proof, a cfg(test)-only mount, a missing root or helper, a host +import anywhere in the mounted closure (including `config.rs`), a dev-dependency +swap, and forbidden normal graph edges. +""" +import importlib.util +from pathlib import Path +import re +import tempfile +import unittest + +path = Path(__file__).with_name("check-command-config-proof.py") +spec = importlib.util.spec_from_file_location("config_proof", path) +proof = importlib.util.module_from_spec(spec) +spec.loader.exec_module(proof) + + +class ConfigProofTests(unittest.TestCase): + def fixture(self, root): + for relative in proof.REQUIRED | {proof.PROOF, proof.MANIFEST}: + 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), []) + + def test_selected_leaf_stub_and_test_only_root_are_rejected(self): + for source in [ + 'pub mod config_group {}', + '#[path = "config.rs"] pub mod config_group;', + '#[cfg(test)]\n' + (proof.ROOT / proof.PROOF).read_text(), + ]: + 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)) + + def test_omitted_root_or_helper_is_rejected(self): + group_dir = "crates/tui/src/commands/groups/config" + for name in ["config", "status", "policy_messages", "money", "tests"]: + with self.subTest(name=name), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + if name == "config": + # The group root must include its children. + group_root = root / proof.ROOTS[0] + group_root.write_text( + group_root.read_text().replace("pub mod config;\n", "")) + elif name == "money": + policy = root / group_dir / "policy.rs" + policy.write_text(policy.read_text().replace( + "use codewhale_command_contract::money;", "")) + else: + policy = root / group_dir / "policy.rs" + policy.write_text(re.sub( + r'#\[path = "[^"]+"\]\s*(?:pub(?:\([^)]*\))?\s+)?mod ' + name + r';', + '', policy.read_text())) + self.assertTrue(proof.violations(root)) + + def test_host_import_in_any_mounted_file_is_rejected(self): + for source in [ + 'use crate::tui::app::App;', + 'use codewhale_config::Config;', + 'use crate::commands::CommandResult;', + 'use std::fs;', + 'use codewhale_secrets::Store;', + ]: + with self.subTest(source=source), tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + group_root = root / proof.ROOTS[0] + group_root.write_text(group_root.read_text() + "\n" + source + "\n") + self.assertTrue(proof.violations(root)) + + def test_host_import_in_unmounted_group_file_is_rejected(self): + # A new file in the group directory that is not reachable from the + # mounted root must still be rejected by the whole-directory scan, which + # is the selected-source gap FEAT-026 corrected. + with tempfile.TemporaryDirectory() as tmp: + root = Path(tmp) + self.fixture(root) + stray = root / "crates/tui/src/commands/groups/config/stray_helper.rs" + stray.write_text("use crate::tui::app::App;\n") + self.assertTrue(proof.violations(root)) + + 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("[dev-dependencies]", "[build-dependencies]") + .replace("[dependencies]", "[dev-dependencies]")) + self.assertTrue(proof.violations(root)) + + 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") + self.assertEqual(proof.graph_violations(allowed), []) + for package in ["codewhale-tui", "codewhale-config", "codewhale-core", "codewhale-runtime", + "codewhale-execpolicy", "codewhale-secrets", "codewhale-state", + "codewhale-localization", "tokio", "reqwest", "rusqlite", "keyring", + "ratatui", "crossterm"]: + with self.subTest(package=package): + self.assertEqual( + len(proof.graph_violations(allowed + f"{package} v1.0.0\n")), 1) + + +if __name__ == '__main__': + unittest.main(verbosity=2) diff --git a/scripts/test_check_command_migration_manifest.py b/scripts/test_check_command_migration_manifest.py index 91e90f9b68..517d3b7fe8 100644 --- a/scripts/test_check_command_migration_manifest.py +++ b/scripts/test_check_command_migration_manifest.py @@ -420,10 +420,10 @@ def test_topology_artifact_is_sorted_unique(self) -> None: self.assertEqual(len(frontier), len(set(frontier))) # FEAT-018 removed utility, FEAT-019 removed memory, FEAT-020 removed plugins, # FEAT-021 removed project, FEAT-022 removed skills, FEAT-029 completed debug, - # and FEAT-026 completed session; config/core remain. + # FEAT-026 completed session, and FEAT-028 completed config; core remains. self.assertEqual( set(frontier), - {"config", "core"}, + {"core"}, ) diff --git a/tests/portable-config-policy/src/lib.rs b/tests/portable-config-policy/src/lib.rs deleted file mode 100644 index 89c6870106..0000000000 --- a/tests/portable-config-policy/src/lib.rs +++ /dev/null @@ -1,3 +0,0 @@ -//! Normal-library proof of the complete actual config-policy production slice. -#[path = "../../../crates/tui/src/commands/groups/config/policy.rs"] -pub mod policy; diff --git a/tests/portable-config-policy/Cargo.toml b/tests/portable-config/Cargo.toml similarity index 86% rename from tests/portable-config-policy/Cargo.toml rename to tests/portable-config/Cargo.toml index c84bcdb045..919a68c449 100644 --- a/tests/portable-config-policy/Cargo.toml +++ b/tests/portable-config/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "codewhale-portable-config-policy" +name = "codewhale-portable-config" version.workspace = true edition.workspace = true rust-version.workspace = true @@ -10,6 +10,7 @@ publish = false [dependencies] codewhale-command-contract = { path = "../../crates/command-contract", version = "0.10.1" } codewhale-protocol = { path = "../../crates/protocol", version = "0.10.1" } +serde_json.workspace = true [dev-dependencies] serde_json.workspace = true diff --git a/tests/portable-config/src/lib.rs b/tests/portable-config/src/lib.rs new file mode 100644 index 0000000000..489320c90e --- /dev/null +++ b/tests/portable-config/src/lib.rs @@ -0,0 +1,7 @@ +//! Normal-library proof of the complete actual config command group. +//! +//! Mounts the real group root (`mod.rs`) — not its children separately — so the +//! whole group, including its 17-command portable inventory, compiles as an +//! independent library with no TUI dependency. +#[path = "../../../crates/tui/src/commands/groups/config/mod.rs"] +pub mod config_group; diff --git a/web/lib/release-credits.ts b/web/lib/release-credits.ts index a4fbfc34ec..07baac5565 100644 --- a/web/lib/release-credits.ts +++ b/web/lib/release-credits.ts @@ -34,7 +34,9 @@ export const RELEASE_CONTRIBUTORS: string[] = [ * one. scripts/check-contributor-credit.py requires them here now; the release * cut moves them into RELEASE_CONTRIBUTORS with that release's changelog block. */ -export const UNRELEASED_CONTRIBUTORS: string[] = []; +export const UNRELEASED_CONTRIBUTORS: string[] = [ + "@aboimpinto", +]; /** * Contributors who helped with reports, reproductions, and verification. From 599119aa549db287dc81c265aaf3eb99c629e2c8 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Tue, 13 Oct 2026 20:42:02 +0200 Subject: [PATCH 2/5] fix(commands): preserve reconciled config help and login behavior Refs #5316; CI follow-up for PR #6973. Use the same Work-mode usage string in the portable handler and metadata, restoring help filtering by "mode [work". Restore upstream login guidance pointing plugin-provider sign-in to /connect. Include connect/disconnect in the migrated registry test inventory and align the moved YOLO test with the existing Full Access (Work) receipt; keep all permission/state assertions. Reproduced all five failures from macOS job 115447780813 and Linux job 115447780694 locally before editing: 5 tests run, 0 passed, 5 failed. After the fix: 5 tests run, 5 passed. Related commands/help/settings scope: 1357 tests run, 1357 passed. Portable config proof: 35 tests run, 35 passed; 7 proof negative controls and 63 migration-manifest tests passed. Formatting and git diff --check passed. Hosted confirmation remains pending. PR intentionally remains draft. Signed-off-by: Paulo Aboim Pinto --- crates/tui/src/commands/config_main_host.rs | 9 +++++++-- crates/tui/src/commands/groups/config/config.rs | 5 +++-- crates/tui/src/commands/groups/config/mod.rs | 2 +- crates/tui/src/commands/mod.rs | 4 +++- 4 files changed, 14 insertions(+), 6 deletions(-) diff --git a/crates/tui/src/commands/config_main_host.rs b/crates/tui/src/commands/config_main_host.rs index fdd0f16da0..75e348a2c6 100644 --- a/crates/tui/src/commands/config_main_host.rs +++ b/crates/tui/src/commands/config_main_host.rs @@ -3665,8 +3665,13 @@ mod tests { // user settings on the host machine. let _ = mode_command(&mut app, Some("agent")); let result = mode_command(&mut app, Some("yolo")); - // YOLO is invisible Act+Bypass shorthand — user-facing copy says Act. - assert!(result.message.unwrap().contains("Switched to Act mode")); + // YOLO is invisible Work+Bypass shorthand — user-facing copy names Full Access (Work). + assert!( + result + .message + .unwrap() + .contains("Switched to Full Access (Work)") + ); assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); assert!(app.allow_shell); assert!(app.trust_mode); diff --git a/crates/tui/src/commands/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 7f3fd0b322..6056c8ae70 100644 --- a/crates/tui/src/commands/groups/config/config.rs +++ b/crates/tui/src/commands/groups/config/config.rs @@ -621,7 +621,7 @@ pub fn mode(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandRe } /// `/mode` usage, shared with the registration metadata. -const MODE_USAGE: &str = "/mode [work|plan|operate|1|2|3]"; +pub(super) const MODE_USAGE: &str = "/mode [work|plan|operate|1|2|3]"; /// `/pet` usage, shared with the registration metadata. pub const PET_USAGE: &str = "/pet [on|off|inspect|status|appearance|style [smooth|pixel]|language [auto|en|zh-Hans|zh-Hant|ja]|window|source|export|sound [on|off]|avatar [whale|cove|braille|pixels|particles|whale-girl|key]|action [name|live]|view [name|live]]"; @@ -934,7 +934,8 @@ pub fn login(contexts: CommandContexts<'_>, arg: Option<&str>) -> ConfigCommandR .to_string(), ), other => ConfigCommandResult::error(format!( - "Usage: /login [status|account|key]\nUnknown argument: {other}" + "Usage: /login [status|account|key]\nUnknown argument: {other}\n\ + To sign in to a plugin provider, use `/connect `." )), } } diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs index f711a9346f..cecbc114fe 100644 --- a/crates/tui/src/commands/groups/config/mod.rs +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -83,7 +83,7 @@ static STATUSLINE_INFO: CommandInfo = CommandInfo { static MODE_INFO: CommandInfo = CommandInfo { name: "mode", aliases: &["jihua", "zidong"], - usage: "/mode [act|plan|operate|1|2|3]", + usage: config::MODE_USAGE, description_key: "cmd_mode_description", }; static FULLSCREEN_INFO: CommandInfo = CommandInfo { diff --git a/crates/tui/src/commands/mod.rs b/crates/tui/src/commands/mod.rs index 28914ab909..1b97c1f0a4 100644 --- a/crates/tui/src/commands/mod.rs +++ b/crates/tui/src/commands/mod.rs @@ -2218,10 +2218,12 @@ mod tests { // FEAT-027 config policy/status slice. "permissions", "status", - // FEAT-028 completes the config group (all 17 commands portable). + // FEAT-028 completes the config group (all 19 commands portable). "config", "import-claude", "login", + "connect", + "disconnect", "auth", "workbar", "pet", From a42450d3147378176ebe63e56059db409548e75e Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 20:54:29 -0700 Subject: [PATCH 3/5] fix(commands): route mode aliases through the registered mode handler Registration slot10 is status; slot12 is mode. Preserve the nineteen-command migration, declared mode capabilities and whole-config proof. Strengthen two existing fixtures through the actual /jihua and /zidong dispatcher without adding test functions or comment text. The separate proposed cold-build CI/debug settings patch is retained as an artifact: GitHub rejected its push because the current OAuth App lacks workflow scope. This source-only commit leaves the workflow unchanged; no deadline, gate or assertion is disabled. Exact scoped source review and artifact Rustfmt parsing passed. Full own-branch compilation, before/after alias execution, npm/web and actual native CI are pending; no Windows or release acceptance claim. Draft readiness unchanged. Refs #6973 --- crates/tui/src/commands/config_group.rs | 2 +- crates/tui/src/commands/config_main_host.rs | 48 ++++++++++++-------- crates/tui/src/commands/groups/config/mod.rs | 2 +- tests/portable-config/src/lib.rs | 2 +- 4 files changed, 32 insertions(+), 22 deletions(-) diff --git a/crates/tui/src/commands/config_group.rs b/crates/tui/src/commands/config_group.rs index e87d596be0..43888d4f2e 100644 --- a/crates/tui/src/commands/config_group.rs +++ b/crates/tui/src/commands/config_group.rs @@ -105,7 +105,7 @@ fn registration() -> Box { /// Dispatch a `/jihua` (`plan`) or `/zidong` (`yolo`) mode alias through the /// registered `/mode` handler and its declared capability envelope. pub(super) fn mode_alias(app: &mut App, arg: &str) -> CommandResult { - match Registration::<10>::handler() { + match Registration::<12>::handler() { CommandHandler::Contextual { capabilities, handler, diff --git a/crates/tui/src/commands/config_main_host.rs b/crates/tui/src/commands/config_main_host.rs index 75e348a2c6..22474e4701 100644 --- a/crates/tui/src/commands/config_main_host.rs +++ b/crates/tui/src/commands/config_main_host.rs @@ -3660,25 +3660,31 @@ mod tests { #[test] fn test_mode_yolo_sets_all_flags() { - let mut app = create_test_app(); - // Switch to Agent first to guarantee a clean starting state regardless of - // user settings on the host machine. - let _ = mode_command(&mut app, Some("agent")); - let result = mode_command(&mut app, Some("yolo")); - // YOLO is invisible Work+Bypass shorthand — user-facing copy names Full Access (Work). - assert!( - result - .message - .unwrap() - .contains("Switched to Full Access (Work)") - ); - assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); - assert!(app.allow_shell); - assert!(app.trust_mode); - assert!(app.yolo); - assert_eq!(app.approval_mode, ApprovalMode::Bypass); - // The deprecated YOLO alias remaps to Agent mode (M6 compat shim). - assert_eq!(app.mode, AppMode::Agent); + for use_alias in [false, true] { + let mut app = create_test_app(); + // Switch to Agent first to guarantee a clean starting state regardless of + // user settings on the host machine. + let _ = mode_command(&mut app, Some("agent")); + let result = if use_alias { + crate::commands::execute("/zidong", &mut app) + } else { + mode_command(&mut app, Some("yolo")) + }; + // YOLO is invisible Work+Bypass shorthand — user-facing copy names Full Access (Work). + assert!( + result + .message + .unwrap() + .contains("Switched to Full Access (Work)") + ); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); + assert!(app.allow_shell); + assert!(app.trust_mode); + assert!(app.yolo); + assert_eq!(app.approval_mode, ApprovalMode::Bypass); + // The deprecated YOLO alias remaps to Agent mode (M6 compat shim). + assert_eq!(app.mode, AppMode::Agent); + } } #[test] @@ -3692,6 +3698,10 @@ mod tests { let result = mode_command(&mut app, Some("act")); assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Agent))); assert_eq!(app.mode, AppMode::Agent); + let result = crate::commands::execute("/jihua", &mut app); + assert!(!result.is_error); + assert_eq!(result.action, Some(AppAction::ModeChanged(AppMode::Plan))); + assert_eq!(app.mode, AppMode::Plan); let _ = mode_command(&mut app, Some("plan")); assert_eq!(app.mode, AppMode::Plan); let result = mode_command(&mut app, Some("3")); diff --git a/crates/tui/src/commands/groups/config/mod.rs b/crates/tui/src/commands/groups/config/mod.rs index cecbc114fe..2b441fa4c4 100644 --- a/crates/tui/src/commands/groups/config/mod.rs +++ b/crates/tui/src/commands/groups/config/mod.rs @@ -133,7 +133,7 @@ fn contextual( } } -/// Complete group inventory (17 commands), shared by the host registration and +/// Complete group inventory (19 commands), shared by the host registration and /// the extraction proof. Order is the registration order. pub fn portable_handlers() -> [(&'static CommandInfo, CommandHandler); 19] { // The policy slice (permissions, status) already exposes a portable diff --git a/tests/portable-config/src/lib.rs b/tests/portable-config/src/lib.rs index 489320c90e..ce8661e290 100644 --- a/tests/portable-config/src/lib.rs +++ b/tests/portable-config/src/lib.rs @@ -1,7 +1,7 @@ //! Normal-library proof of the complete actual config command group. //! //! Mounts the real group root (`mod.rs`) — not its children separately — so the -//! whole group, including its 17-command portable inventory, compiles as an +//! whole group, including its 19-command portable inventory, compiles as an //! independent library with no TUI dependency. #[path = "../../../crates/tui/src/commands/groups/config/mod.rs"] pub mod config_group; From 7dba136bb66049222f487d67b47d97d009dbf61b Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Tue, 13 Oct 2026 23:29:44 -0700 Subject: [PATCH 4/5] fix(commands): preserve imported instructions and clear config extraction debt Preserve @aboimpinto's original command-shape work. Remove the obsolete config-host dead-code blanket. Reuse the existing runtime bridge to run import I/O off Tokio workers and the protected no-replace writer so instructions created concurrently remain intact. Strengthen the existing import fixture to execute its first copy on a multithreaded runtime. No new test function or code comment. Scoped frozen canonical ECD+63-path Mac evidence: Summary [1.183s] 129 tests run: 129 passed, 15219 skipped. Summary [0.478s] 35 tests run: 35 passed, 0 skipped. Existing Python harnesses: 34 + 7 + 63 tests; 0 failed. npm: 540 Node + 772 Vitest = 1312 passed; 0 failed; 7 skipped. Web, fmt, diff and actual CI-flags workspace/all-target/all-feature Clippy (9m58s) passed; existing linker warning retained. Only the three repaired source files match those frozen verified bytes. The own-PR tree uses main's older private-directory helper and dependency graph; fresh hosted own-head CI is required. Windows no-clobber/package acceptance has not been run locally. Original a424 native greens and actual fork-advisory ratchet failures remain historical. Refs #6973 --- crates/tui/src/commands/config_host.rs | 3 - .../src/commands/config_main_import_host.rs | 72 +++++++++++++++---- crates/tui/src/commands/contract.rs | 2 +- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/crates/tui/src/commands/config_host.rs b/crates/tui/src/commands/config_host.rs index 3abeea4bfc..649c2612f7 100644 --- a/crates/tui/src/commands/config_host.rs +++ b/crates/tui/src/commands/config_host.rs @@ -6,9 +6,6 @@ //! unified `ConfigAction` to the concrete `AppAction` the event loop applies, //! and back for the host helpers that still return a TUI `CommandResult`. //! -//! The reverse `AppAction` -> `ConfigAction` direction and its `from_*` helpers -//! are consumed by the Phase 5 host adapters; until then they are kept ready. -#![allow(dead_code)] use crate::compaction::CompactionConfig; use crate::config::SearchProvider; diff --git a/crates/tui/src/commands/config_main_import_host.rs b/crates/tui/src/commands/config_main_import_host.rs index 4352bdce78..11a3a3538e 100644 --- a/crates/tui/src/commands/config_main_import_host.rs +++ b/crates/tui/src/commands/config_main_import_host.rs @@ -95,22 +95,63 @@ pub(in crate::commands) fn apply_plan( source: crate::utils::display_path(&source).to_string(), }); } else { - match std::fs::read(&source) { - Ok(bytes) - if codewhale_config::persistence::atomic_write( - &destination, - &bytes, - ) - .is_ok() => + let copy_source = source.clone(); + let copy_home = home.clone(); + #[cfg(test)] + let ticket = crate::test_support::env_scope_ticket(); + let copied = match tokio::runtime::Handle::try_current() { + Ok(handle) + if handle.runtime_flavor() + == tokio::runtime::RuntimeFlavor::MultiThread => { - done.push(CommandImportFollowUp::InstructionsCopied { - destination: crate::utils::display_path(&destination).to_string(), - }); + crate::commands::contract::run_async(async move { + tokio::task::spawn_blocking(move || { + #[cfg(test)] + let _membership = crate::test_support::join_env_scope(ticket); + std::fs::read(©_source).is_ok_and(|bytes| { + codewhale_config::private_directory::PrivateDirectory::open( + ©_home, + ) + .and_then(|directory| { + directory.write_owned_file( + "instructions.md", + &bytes, + false, + ) + }) + .is_ok() + }) + }) + .await + .unwrap_or(false) + }) } - _ => manual.push(CommandImportFollowUp::InstructionsCopyFailed { + Err(_) => std::thread::spawn(move || { + #[cfg(test)] + let _membership = crate::test_support::join_env_scope(ticket); + std::fs::read(©_source).is_ok_and(|bytes| { + codewhale_config::private_directory::PrivateDirectory::open( + ©_home, + ) + .and_then(|directory| { + directory.write_owned_file("instructions.md", &bytes, false) + }) + .is_ok() + }) + }) + .join() + .unwrap_or(false), + Ok(_) => false, + }; + if copied { + done.push(CommandImportFollowUp::InstructionsCopied { + destination: crate::utils::display_path(&destination).to_string(), + }); + } else { + manual.push(CommandImportFollowUp::InstructionsCopyFailed { source: crate::utils::display_path(&source).to_string(), destination: crate::utils::display_path(&destination).to_string(), - }), + }); } } } @@ -234,7 +275,12 @@ mod tests { let cw_home = temp.path().join("cw"); let _cw = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", &cw_home); - let result = import_claude_command(&mut app, Some("--apply")); + let runtime = tokio::runtime::Builder::new_multi_thread() + .worker_threads(2) + .enable_all() + .build() + .expect("import runtime"); + let result = runtime.block_on(async { import_claude_command(&mut app, Some("--apply")) }); let message = result.message.expect("an outcome"); let destination = cw_home.join("instructions.md"); assert!(destination.exists(), "instructions were copied: {message}"); diff --git a/crates/tui/src/commands/contract.rs b/crates/tui/src/commands/contract.rs index 39f69459be..451d41a672 100644 --- a/crates/tui/src/commands/contract.rs +++ b/crates/tui/src/commands/contract.rs @@ -2691,7 +2691,7 @@ pub(crate) struct SkillGroupAdapter<'a> { /// `block_in_place` + `Handle::current().block_on` bridges sync handlers back /// into the async ecosystem. Mirrors `groups/skills/skills.rs::run_async`; /// the legacy copy is removed in Phase 4 when the handlers are ported. -fn run_async(future: F) -> T +pub(super) fn run_async(future: F) -> T where F: std::future::Future, { From b323af876be8cd0ba58fa927d26ff2da1879ad65 Mon Sep 17 00:00:00 2001 From: CodeWhale Bot Date: Wed, 14 Oct 2026 03:12:55 -0700 Subject: [PATCH 5/5] test(commands): restore exhaustive capability and facet guards Refs #6973 The new config/login/work-surface slots were omitted from five existing authority guards. Include both new identity bits, destructure every facet and reject all unrelated slots; the analogous config-policy guard now checks the same five slots. Production capabilities and existing assertions remain unchanged. No new test functions or code comments. Verified in the frozen combined canonical source, with all six repaired file hashes equal to this commit: Summary 2 tests run: 2 passed, 77 skipped (command-contract); Summary 63 tests run: 63 passed, 15356 skipped (TUI including both export helper callers); Summary 1 test run: 1 passed, 15418 skipped (config-policy). All 66 result ordinals and identities audited. Existing macOS __eh_frame linker warning retained. npm: 540 Node plus 772 Vitest passed; 0 failed; 7 skipped. Web, fmt and diff checks pass. No clean own-head binary or new-head Windows run claimed; fresh hosted CI/review remain required. Prior d7 full three-platform pass is retained as historical evidence, not qualification of this new head. Source review matched the six existing test files to immutable d7 preimages. Original contributor history and branch are preserved. --- crates/command-contract/src/tests.rs | 13 ++++++++++++- .../tui/src/commands/contract/config_policy.rs | 7 +++++++ .../commands/debug_diagnostics_surface_tests.rs | 16 ++++++++++++++-- .../src/commands/debug_mutation_host_tests.rs | 16 ++++++++++++++-- .../src/commands/session_export_test_support.rs | 16 ++++++++++++++-- .../commands/session_structcopy_host_tests.rs | 16 ++++++++++++++-- 6 files changed, 75 insertions(+), 9 deletions(-) diff --git a/crates/command-contract/src/tests.rs b/crates/command-contract/src/tests.rs index 0910d32707..e71af81f47 100644 --- a/crates/command-contract/src/tests.rs +++ b/crates/command-contract/src/tests.rs @@ -3554,6 +3554,8 @@ fn structcopy_capability_preserves_all_published_identities() { CommandCapabilities::CONFIG, CommandCapabilities::PET, CommandCapabilities::TRUST, + CommandCapabilities::LOGIN, + CommandCapabilities::WORK_SURFACE, ]; let exact = CommandCapabilities::SESSION_STRUCTCOPY | CommandCapabilities::PRESENTATION; for (index, capability) in capabilities.into_iter().enumerate() { @@ -3613,7 +3615,11 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { debug_diagnostics, permissions, config_status, - .. + config, + pet, + trust, + login, + work_surface, } = CommandContexts::empty() .with_structcopy(&mut fixture) .with_presentation(&mut presentation) @@ -3658,6 +3664,11 @@ fn structcopy_slot_is_optional_and_does_not_grant_other_authority() { debug_diagnostics.is_some(), permissions.is_some(), config_status.is_some(), + config.is_some(), + pet.is_some(), + trust.is_some(), + login.is_some(), + work_surface.is_some(), ] { assert!(!present); } diff --git a/crates/tui/src/commands/contract/config_policy.rs b/crates/tui/src/commands/contract/config_policy.rs index 7e6f57feee..b4bf6e9a0f 100644 --- a/crates/tui/src/commands/contract/config_policy.rs +++ b/crates/tui/src/commands/contract/config_policy.rs @@ -325,6 +325,13 @@ mod tests { assert!( p.debug_diff.is_none() && p.debug_undo.is_none() && p.debug_diagnostics.is_none() ); + assert!( + p.config.is_none() + && p.pet.is_none() + && p.trust.is_none() + && p.login.is_none() + && p.work_surface.is_none() + ); } assert!( bundle diff --git a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs index 011953088c..74c0fa8ae7 100644 --- a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs +++ b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs @@ -253,9 +253,21 @@ fn diagnostics_registrations_expose_exact_facets_and_preview_is_pure() { debug_diagnostics, permissions, config_status, - .. + config, + pet, + trust, + login, + work_surface, } = parts; - assert!(permissions.is_none() && config_status.is_none()); + assert!( + permissions.is_none() + && config_status.is_none() + && config.is_none() + && pet.is_none() + && trust.is_none() + && login.is_none() + && work_surface.is_none() + ); assert!(debug_diagnostics.is_some(), "/{spelling} needs diagnostics"); assert_eq!( presentation.is_some(), diff --git a/crates/tui/src/commands/debug_mutation_host_tests.rs b/crates/tui/src/commands/debug_mutation_host_tests.rs index 9aef6a804d..068f45b79f 100644 --- a/crates/tui/src/commands/debug_mutation_host_tests.rs +++ b/crates/tui/src/commands/debug_mutation_host_tests.rs @@ -1606,9 +1606,21 @@ fn whole_debug_registry_matches_portable_inventory_and_exact_host_authority() { debug_undo, permissions, config_status, - .. + config, + pet, + trust, + login, + work_surface, } = bundle.contexts(capabilities).into_parts(); - assert!(permissions.is_none() && config_status.is_none()); + assert!( + permissions.is_none() + && config_status.is_none() + && config.is_none() + && pet.is_none() + && trust.is_none() + && login.is_none() + && work_surface.is_none() + ); for (name, present, capability) in [ ("session", session.is_some(), Caps::SESSION), ("model", model.is_some(), Caps::MODEL), diff --git a/crates/tui/src/commands/session_export_test_support.rs b/crates/tui/src/commands/session_export_test_support.rs index e704321dd2..2e2128eb11 100644 --- a/crates/tui/src/commands/session_export_test_support.rs +++ b/crates/tui/src/commands/session_export_test_support.rs @@ -121,9 +121,21 @@ pub(crate) fn assert_only_export_facet_exposed(parts: ContextParts<'_>) { debug_diagnostics, permissions, config_status, - .. + config, + pet, + trust, + login, + work_surface, } = parts; - assert!(permissions.is_none() && config_status.is_none()); + assert!( + permissions.is_none() + && config_status.is_none() + && config.is_none() + && pet.is_none() + && trust.is_none() + && login.is_none() + && work_surface.is_none() + ); assert!(export.is_some(), "the export facet must be exposed"); diff --git a/crates/tui/src/commands/session_structcopy_host_tests.rs b/crates/tui/src/commands/session_structcopy_host_tests.rs index 25ff785d61..9cda9d70f4 100644 --- a/crates/tui/src/commands/session_structcopy_host_tests.rs +++ b/crates/tui/src/commands/session_structcopy_host_tests.rs @@ -368,9 +368,21 @@ fn structcopy_host_exposes_exact_authority_and_filters_private_data_before_cross debug_diagnostics, permissions, config_status, - .. + config, + pet, + trust, + login, + work_surface, } = bundle.contexts(capabilities).into_parts(); - assert!(permissions.is_none() && config_status.is_none()); + assert!( + permissions.is_none() + && config_status.is_none() + && config.is_none() + && pet.is_none() + && trust.is_none() + && login.is_none() + && work_surface.is_none() + ); assert!(presentation.is_some()); assert!( session.is_none()