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..e71af81f47 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,11 @@ fn structcopy_capability_preserves_all_published_identities() { CommandCapabilities::SESSION_STRUCTCOPY, CommandCapabilities::PERMISSIONS, CommandCapabilities::CONFIG_STATUS, + 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() { @@ -3541,6 +3565,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 +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) @@ -3619,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/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..43888d4f2e --- /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::<12>::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..649c2612f7 --- /dev/null +++ b/crates/tui/src/commands/config_host.rs @@ -0,0 +1,510 @@ +//! 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`. +//! + +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..22474e4701 --- /dev/null +++ b/crates/tui/src/commands/config_main_host.rs @@ -0,0 +1,6246 @@ +//! 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() { + 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] + 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 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")); + 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..11a3a3538e --- /dev/null +++ b/crates/tui/src/commands/config_main_import_host.rs @@ -0,0 +1,306 @@ +//! `/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 { + 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 => + { + 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) + }) + } + 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(), + }); + } + } + } + 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 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}"); + 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..451d41a672 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(()) } @@ -2616,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, { @@ -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/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 b0c1a5ed1e..74c0fa8ae7 100644 --- a/crates/tui/src/commands/debug_diagnostics_surface_tests.rs +++ b/crates/tui/src/commands/debug_diagnostics_surface_tests.rs @@ -253,8 +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 dbe2921476..068f45b79f 100644 --- a/crates/tui/src/commands/debug_mutation_host_tests.rs +++ b/crates/tui/src/commands/debug_mutation_host_tests.rs @@ -1606,8 +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/groups/config/config.rs b/crates/tui/src/commands/groups/config/config.rs index 8cdabaee35..6056c8ae70 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. +pub(super) 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,262 @@ 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}\n\ + To sign in to a plugin provider, use `/connect `." + )), + } } -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 + )), + } +} + +/// `/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"); } - CommandResult::message(tr( - app.ui_locale, - if enabled { - MessageId::PetModeOn + 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 +1067,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"] => mode(app, true), - ["off"] => mode(app, false), - ["status"] => CommandResult::message(format!( + ["on"] => { + facet.set_enabled(true, true); + ConfigCommandResult::message(facet.label(CommandPetLabel::ModeOn)) + } + ["off"] => { + facet.set_enabled(false, true); + ConfigCommandResult::message(facet.label(CommandPetLabel::ModeOff)) + } + ["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 +1172,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() + )); } - "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()); + 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.", + ); + } + 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() -} - -fn notifications_summary(config: &Config) -> String { - notifications_summary_value(&config.notifications_config()) + (op, persist) } -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 - ) +/// A parsed `/config search…` tail. +#[derive(Debug, PartialEq, Eq)] +enum SearchOp<'a> { + Status, + Set(&'a str), + Usage, } -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(); } + let op = match tokens.as_slice() { + [] | ["status"] | ["provider"] => SearchOp::Status, + ["provider", value] | [value] => SearchOp::Set(value), + _ => SearchOp::Usage, + }; + (op, persist) +} - 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() - )), - } +/// A parsed `/config notifications…` tail. +#[derive(Debug, PartialEq, Eq)] +enum NotificationsOp<'a> { + Status, + Get(&'a str), + Set { key: &'a str, value: &'a str }, } -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()), - ); +/// 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 { + match rest.split_once(char::is_whitespace) { + Some((key, value)) => NotificationsOp::Set { + key, + value: value.trim(), + }, + None => NotificationsOp::Get(rest), + } }; + (op, persist) +} - 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()), - ); - } - } - } else { - tr(app.ui_locale, MessageId::ConfigScopeSession).into_owned() +#[cfg(test)] +mod tests { + use super::*; + use codewhale_command_contract::config::{ + CommandClaudeApplyOutcome, CommandClaudeArtifacts, CommandClaudePlan, CommandConfigContext, }; + use codewhale_command_contract::facets::CommandPresentationContext; - CommandResult::with_message_and_action( - tr(app.ui_locale, MessageId::ConfigSearchUpdated) - .replace("{value}", provider.as_str()) - .replace("{scope}", &scope), - AppAction::UpdateSearchProvider { provider }, - ) -} - -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"), + #[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()) ); - } - }; - 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() - }; - CommandResult::with_message_and_action( - tr(app.ui_locale, MessageId::ConfigPromptSuggestionUpdated) - .replace("{value}", &enabled.to_string()) - .replace("{scope}", &scope), - AppAction::UpdatePromptSuggestion { enabled }, - ) -} - -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), + 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", - ), - }; - } - "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(); + fn write_artifacts( + &mut self, + _report: &str, + _bundle: Option<&str>, + ) -> CommandClaudeArtifacts { + panic!("config facet touched") } - - 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..2b441fa4c4 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: config::MODE_USAGE, + 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 (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 + // 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..1b97c1f0a4 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,27 @@ 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 19 commands portable). + "config", + "import-claude", + "login", + "connect", + "disconnect", + "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..2e2128eb11 100644 --- a/crates/tui/src/commands/session_export_test_support.rs +++ b/crates/tui/src/commands/session_export_test_support.rs @@ -121,8 +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 2635162adf..9cda9d70f4 100644 --- a/crates/tui/src/commands/session_structcopy_host_tests.rs +++ b/crates/tui/src/commands/session_structcopy_host_tests.rs @@ -368,8 +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() 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..ce8661e290 --- /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 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; 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.