From 1b3e09b866bb6a29f118277778528507d99a1a9a Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Fri, 19 Jun 2026 12:11:52 +0200 Subject: [PATCH 1/2] feat(commands): replay FEAT-005 command extraction --- crates/tui/Cargo.toml | 1 + .../src/commands/groups/core/acceptance.rs | 198 ++++ crates/tui/src/commands/groups/core/agent.rs | 49 + crates/tui/src/commands/groups/core/anchor.rs | 24 +- crates/tui/src/commands/groups/core/clear.rs | 26 + crates/tui/src/commands/groups/core/exit.rs | 26 + .../tui/src/commands/groups/core/feedback.rs | 21 + crates/tui/src/commands/groups/core/help.rs | 26 + crates/tui/src/commands/groups/core/hf.rs | 21 + crates/tui/src/commands/groups/core/home.rs | 26 + crates/tui/src/commands/groups/core/hooks.rs | 21 + crates/tui/src/commands/groups/core/links.rs | 26 + crates/tui/src/commands/groups/core/mod.rs | 563 +++-------- crates/tui/src/commands/groups/core/model.rs | 26 + crates/tui/src/commands/groups/core/models.rs | 26 + .../tui/src/commands/groups/core/profile.rs | 26 + .../tui/src/commands/groups/core/provider.rs | 21 + crates/tui/src/commands/groups/core/queue.rs | 20 + crates/tui/src/commands/groups/core/rlm.rs | 67 ++ crates/tui/src/commands/groups/core/stash.rs | 21 + .../tui/src/commands/groups/core/subagents.rs | 26 + crates/tui/src/commands/groups/core/swarm.rs | 99 ++ .../tui/src/commands/groups/core/translate.rs | 26 + crates/tui/src/commands/groups/core/util.rs | 23 + crates/tui/src/commands/groups/core/voice.rs | 56 ++ .../tui/src/commands/groups/core/workspace.rs | 26 + .../src/commands/groups/session/acceptance.rs | 878 ++++++++++++++++++ .../src/commands/groups/session/compact.rs | 26 + .../tui/src/commands/groups/session/export.rs | 26 + .../tui/src/commands/groups/session/fork.rs | 26 + .../tui/src/commands/groups/session/load.rs | 26 + crates/tui/src/commands/groups/session/mod.rs | 348 ++----- crates/tui/src/commands/groups/session/new.rs | 26 + .../tui/src/commands/groups/session/purge.rs | 26 + .../tui/src/commands/groups/session/relay.rs | 192 ++++ .../tui/src/commands/groups/session/rename.rs | 21 + .../tui/src/commands/groups/session/save.rs | 26 + .../src/commands/groups/session/sessions.rs | 26 + crates/tui/src/commands/traits.rs | 9 + .../tests/core_session_command_extraction.rs | 163 ++++ crates/tui/tests/epic_acceptance_harness.rs | 51 + .../features/core_command_surfaces.feature | 42 + .../core_session_command_extraction.feature | 7 + .../features/epic_acceptance_harness.feature | 6 + .../session_command_workflows.feature | 89 ++ 45 files changed, 2756 insertions(+), 749 deletions(-) create mode 100644 crates/tui/src/commands/groups/core/acceptance.rs create mode 100644 crates/tui/src/commands/groups/core/agent.rs create mode 100644 crates/tui/src/commands/groups/core/clear.rs create mode 100644 crates/tui/src/commands/groups/core/exit.rs create mode 100644 crates/tui/src/commands/groups/core/help.rs create mode 100644 crates/tui/src/commands/groups/core/home.rs create mode 100644 crates/tui/src/commands/groups/core/links.rs create mode 100644 crates/tui/src/commands/groups/core/model.rs create mode 100644 crates/tui/src/commands/groups/core/models.rs create mode 100644 crates/tui/src/commands/groups/core/profile.rs create mode 100644 crates/tui/src/commands/groups/core/rlm.rs create mode 100644 crates/tui/src/commands/groups/core/subagents.rs create mode 100644 crates/tui/src/commands/groups/core/swarm.rs create mode 100644 crates/tui/src/commands/groups/core/translate.rs create mode 100644 crates/tui/src/commands/groups/core/util.rs create mode 100644 crates/tui/src/commands/groups/core/workspace.rs create mode 100644 crates/tui/src/commands/groups/session/acceptance.rs create mode 100644 crates/tui/src/commands/groups/session/compact.rs create mode 100644 crates/tui/src/commands/groups/session/export.rs create mode 100644 crates/tui/src/commands/groups/session/fork.rs create mode 100644 crates/tui/src/commands/groups/session/load.rs create mode 100644 crates/tui/src/commands/groups/session/new.rs create mode 100644 crates/tui/src/commands/groups/session/purge.rs create mode 100644 crates/tui/src/commands/groups/session/relay.rs create mode 100644 crates/tui/src/commands/groups/session/save.rs create mode 100644 crates/tui/src/commands/groups/session/sessions.rs create mode 100644 crates/tui/tests/core_session_command_extraction.rs create mode 100644 crates/tui/tests/epic_acceptance_harness.rs create mode 100644 crates/tui/tests/features/core_command_surfaces.feature create mode 100644 crates/tui/tests/features/core_session_command_extraction.feature create mode 100644 crates/tui/tests/features/epic_acceptance_harness.feature create mode 100644 crates/tui/tests/features/session_command_workflows.feature diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index 879504e2a..39375dcb5 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -13,6 +13,7 @@ tui = ["dep:schemaui", "schemaui/tui", "json", "toml"] web = ["dep:schemaui", "schemaui/web", "json", "toml"] json = ["schemaui/json"] toml = ["schemaui/toml"] +long-running-tests = [] [[bin]] name = "codewhale-tui" diff --git a/crates/tui/src/commands/groups/core/acceptance.rs b/crates/tui/src/commands/groups/core/acceptance.rs new file mode 100644 index 000000000..c2dfa2169 --- /dev/null +++ b/crates/tui/src/commands/groups/core/acceptance.rs @@ -0,0 +1,198 @@ +//! Gherkin acceptance coverage for visible core command surfaces. + +use cucumber::{World as _, given, then, when, writer::Stats as _}; +use tempfile::TempDir; + +use crate::commands::{self, CommandResult}; +use crate::config::{ApiProvider, Config}; +use crate::test_support::{EnvVarGuard, lock_test_env}; +use crate::tui::app::{App, TuiOptions}; +use crate::tui::history::HistoryCell; + +const FEATURE_NAME: &str = "Core command visible surfaces"; +const FEATURE_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/features/core_command_surfaces.feature" +); +const INFORMATIONAL_SCENARIO: &str = + "Core informational commands write visible transcript messages"; +const STATE_SCENARIO: &str = "Core state commands report visible changes"; +const CLEAR_SCENARIO: &str = "Clear replaces prior transcript with visible confirmation"; +const PERSISTENT_WORK_SCENARIO: &str = "Persistent work commands report visible dispatch requests"; + +#[derive(Default, cucumber::World)] +struct CoreCommandWorld { + tmpdir: Option, + app: Option>, + home_path: Option, + last_message: Option, + last_result_is_error: Option, +} + +impl std::fmt::Debug for CoreCommandWorld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CoreCommandWorld") + .field("has_tmpdir", &self.tmpdir.is_some()) + .field("has_app", &self.app.is_some()) + .field("home_path", &self.home_path) + .field("last_message", &self.last_message) + .field("last_result_is_error", &self.last_result_is_error) + .finish() + } +} + +#[given("a CodeWhale core command workspace")] +fn core_command_workspace(world: &mut CoreCommandWorld) { + let tmpdir = TempDir::new().expect("core command TempDir"); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.ui_locale = crate::localization::Locale::En; + app.api_provider = ApiProvider::Deepseek; + app.model = "deepseek-v4-pro".to_string(); + app.auto_model = false; + app.model_ids_passthrough = false; + + world.home_path = Some(tmpdir.path().join("home")); + world.app = Some(Box::new(app)); + world.tmpdir = Some(tmpdir); +} + +#[given("a CodeWhale core command workspace with one visible user message")] +fn core_command_workspace_with_one_visible_user_message(world: &mut CoreCommandWorld) { + core_command_workspace(world); + let app = world.app.as_deref_mut().expect("app should exist"); + app.add_message(HistoryCell::User { + content: "Remember the whale migration".to_string(), + }); +} + +#[when(regex = r#"^the user runs the core command "([^"]+)"$"#)] +fn user_runs_core_command(world: &mut CoreCommandWorld, command: String) { + let result = execute_isolated(world, &command); + record_visible_result(world, result); +} + +#[then(regex = r#"^the message window should include "([^"]+)"$"#)] +fn message_window_should_include(world: &mut CoreCommandWorld, expected: String) { + let visible = visible_message_window(world); + + assert!( + visible.contains(&expected), + "message window should include {expected:?}\nvisible transcript:\n{visible}" + ); +} + +#[then(regex = r#"^the message window should not include "([^"]+)"$"#)] +fn message_window_should_not_include(world: &mut CoreCommandWorld, forbidden: String) { + let visible = visible_message_window(world); + + assert!( + !visible.contains(&forbidden), + "message window should not include {forbidden:?}\nvisible transcript:\n{visible}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn core_informational_commands_write_visible_transcript_messages() { + run_scenario(INFORMATIONAL_SCENARIO, 11).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn core_state_commands_report_visible_changes() { + run_scenario(STATE_SCENARIO, 8).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn clear_replaces_prior_transcript_with_visible_confirmation() { + run_scenario(CLEAR_SCENARIO, 4).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn persistent_work_commands_report_visible_dispatch_requests() { + run_scenario(PERSISTENT_WORK_SCENARIO, 7).await; +} + +async fn run_scenario(name: &'static str, expected_steps: usize) { + let writer = CoreCommandWorld::cucumber() + .fail_on_skipped() + .with_default_cli() + .filter_run(FEATURE_PATH, move |feature, _, scenario| { + feature.name == FEATURE_NAME && scenario.name == name + }) + .await; + assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}"); + assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}"); + assert_eq!( + writer.passed_steps(), + expected_steps, + "scenario did not run: {name}" + ); +} + +fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + App::new(options, &Config::default()) +} + +fn execute_isolated(world: &mut CoreCommandWorld, command: &str) -> CommandResult { + let home = world + .home_path + .as_ref() + .expect("test home should exist") + .clone(); + std::fs::create_dir_all(&home).expect("create isolated test home"); + + let _lock = lock_test_env(); + let _home = EnvVarGuard::set("HOME", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")); + + let app = world.app.as_deref_mut().expect("app should exist"); + commands::user_registry::reload(Some(&app.workspace)); + commands::execute(command, app) +} + +fn record_visible_result(world: &mut CoreCommandWorld, result: CommandResult) { + world.last_result_is_error = Some(result.is_error); + world.last_message = result.message.clone(); + + if let Some(message) = result.message { + let app = world.app.as_deref_mut().expect("app should exist"); + app.add_message(HistoryCell::System { content: message }); + } +} + +fn visible_message_window(world: &CoreCommandWorld) -> String { + let app = world.app.as_deref().expect("app should exist"); + app.history + .iter() + .filter_map(|cell| match cell { + HistoryCell::User { content } + | HistoryCell::Assistant { content, .. } + | HistoryCell::System { content } + | HistoryCell::Thinking { content, .. } => Some(content.as_str()), + HistoryCell::Error { message, .. } => Some(message.as_str()), + HistoryCell::ArchivedContext { summary, .. } => Some(summary.as_str()), + HistoryCell::Tool(_) | HistoryCell::SubAgent(_) => None, + }) + .collect::>() + .join("\n") +} diff --git a/crates/tui/src/commands/groups/core/agent.rs b/crates/tui/src/commands/groups/core/agent.rs new file mode 100644 index 000000000..6714de135 --- /dev/null +++ b/crates/tui/src/commands/groups/core/agent.rs @@ -0,0 +1,49 @@ +//! `/agent` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::{App, AppAction}; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "agent", + aliases: &["daili"], + usage: "/agent [N] ", + description_id: MessageId::CmdAgentDescription, +}; + +pub(in crate::commands) struct AgentCmd; + +impl RegisterCommand for AgentCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + agent(app, arg) + } +} + +pub fn agent(_app: &mut App, arg: Option<&str>) -> CommandResult { + let (max_depth, task) = match super::util::parse_depth_prefixed_arg(arg, 1) { + Ok(parsed) => parsed, + Err(message) => return CommandResult::error(message), + }; + let task = match task { + Some(task) if !task.trim().is_empty() => task.trim().to_string(), + _ => { + return CommandResult::error( + "Usage: /agent [N] \n\n\ + Opens a persistent sub-agent session with recursive agent depth N (0-3, default 1).", + ); + } + }; + let message = format!( + "Launch one sub-agent for this task by calling `agent` with name `slash_agent`, `prompt: {task:?}`, and `max_depth: {max_depth}`. Use `handle_read` on the returned transcript_handle if you need more detail. Verify any claimed side effects before reporting success." + ); + CommandResult::with_message_and_action( + format!("Opening persistent sub-agent at depth {max_depth}..."), + AppAction::SendMessage(message), + ) +} diff --git a/crates/tui/src/commands/groups/core/anchor.rs b/crates/tui/src/commands/groups/core/anchor.rs index 7ba66d7a1..f47fe3f7f 100644 --- a/crates/tui/src/commands/groups/core/anchor.rs +++ b/crates/tui/src/commands/groups/core/anchor.rs @@ -5,14 +5,36 @@ //! preserve invariants like "This API's status field is unreliable" or //! ".ssh/ must never be touched". -use crate::tui::app::App; use std::fs; use std::io::Write; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + use super::CommandResult; const USAGE: &str = "/anchor | /anchor list | /anchor remove "; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "anchor", + aliases: &["maodian"], + usage: USAGE, + description_id: MessageId::CmdAnchorDescription, +}; + +pub(in crate::commands) struct AnchorCmd; + +impl RegisterCommand for AnchorCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + anchor(app, arg) + } +} + /// Handle the `/anchor` command with subcommands: /// - `/anchor ` — add a new anchor /// - `/anchor list` — list all anchors diff --git a/crates/tui/src/commands/groups/core/clear.rs b/crates/tui/src/commands/groups/core/clear.rs new file mode 100644 index 000000000..46666df32 --- /dev/null +++ b/crates/tui/src/commands/groups/core/clear.rs @@ -0,0 +1,26 @@ +//! `/clear` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "clear", + aliases: &["qingping"], + usage: "/clear", + description_id: MessageId::CmdClearDescription, +}; + +pub(in crate::commands) struct ClearCmd; + +impl RegisterCommand for ClearCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::core::clear(app) + } +} diff --git a/crates/tui/src/commands/groups/core/exit.rs b/crates/tui/src/commands/groups/core/exit.rs new file mode 100644 index 000000000..30c8491f7 --- /dev/null +++ b/crates/tui/src/commands/groups/core/exit.rs @@ -0,0 +1,26 @@ +//! `/exit` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "exit", + aliases: &["quit", "q", "tuichu"], + usage: "/exit", + description_id: MessageId::CmdExitDescription, +}; + +pub(in crate::commands) struct ExitCmd; + +impl RegisterCommand for ExitCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(_app: &mut App, _arg: Option<&str>) -> CommandResult { + super::core::exit() + } +} diff --git a/crates/tui/src/commands/groups/core/feedback.rs b/crates/tui/src/commands/groups/core/feedback.rs index fc968c73a..c8f27ca25 100644 --- a/crates/tui/src/commands/groups/core/feedback.rs +++ b/crates/tui/src/commands/groups/core/feedback.rs @@ -1,8 +1,29 @@ use super::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; const SECURITY_POLICY_URL: &str = "https://github.com/Hmbown/CodeWhale/security/policy"; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "feedback", + aliases: &[], + usage: "/feedback [bug|feature|security]", + description_id: MessageId::CmdFeedbackDescription, +}; + +pub(in crate::commands) struct FeedbackCmd; + +impl RegisterCommand for FeedbackCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + feedback(app, arg) + } +} + pub fn feedback(_app: &mut App, arg: Option<&str>) -> CommandResult { let raw = arg.map(str::trim).unwrap_or(""); if raw.is_empty() { diff --git a/crates/tui/src/commands/groups/core/help.rs b/crates/tui/src/commands/groups/core/help.rs new file mode 100644 index 000000000..d15589ffe --- /dev/null +++ b/crates/tui/src/commands/groups/core/help.rs @@ -0,0 +1,26 @@ +//! `/help` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "help", + aliases: &["?", "bangzhu", "帮助"], + usage: "/help [command]", + description_id: MessageId::CmdHelpDescription, +}; + +pub(in crate::commands) struct HelpCmd; + +impl RegisterCommand for HelpCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::core::help(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/core/hf.rs b/crates/tui/src/commands/groups/core/hf.rs index 0d2a7230e..9934ca103 100644 --- a/crates/tui/src/commands/groups/core/hf.rs +++ b/crates/tui/src/commands/groups/core/hf.rs @@ -1,10 +1,31 @@ //! `/hf` - Hugging Face MCP and provider concept helpers. +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::mcp::{McpConfig, McpServerConfig}; use crate::tui::app::App; use super::CommandResult; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "hf", + aliases: &["huggingface"], + usage: "/hf [mcp |concepts]", + description_id: MessageId::CmdHfDescription, +}; + +pub(in crate::commands) struct HfCmd; + +impl RegisterCommand for HfCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + hf(app, arg) + } +} + const HF_MCP_SETTINGS_URL: &str = "https://huggingface.co/settings/mcp"; const HF_MCP_DOCS_URL: &str = "https://huggingface.co/docs/hub/hf-mcp-server"; const HF_MCP_SERVER_URL: &str = "https://huggingface.co/mcp"; diff --git a/crates/tui/src/commands/groups/core/home.rs b/crates/tui/src/commands/groups/core/home.rs new file mode 100644 index 000000000..0900c9769 --- /dev/null +++ b/crates/tui/src/commands/groups/core/home.rs @@ -0,0 +1,26 @@ +//! `/home` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "home", + aliases: &["stats", "overview", "zhuye", "shouye"], + usage: "/home", + description_id: MessageId::CmdHomeDescription, +}; + +pub(in crate::commands) struct HomeCmd; + +impl RegisterCommand for HomeCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::core::home_dashboard(app) + } +} diff --git a/crates/tui/src/commands/groups/core/hooks.rs b/crates/tui/src/commands/groups/core/hooks.rs index d01a52ca4..e4beaeadc 100644 --- a/crates/tui/src/commands/groups/core/hooks.rs +++ b/crates/tui/src/commands/groups/core/hooks.rs @@ -6,11 +6,32 @@ //! actually configured in `~/.codewhale/config.toml`'s `[hooks]` //! table — the most-asked question once hooks start firing. +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::hooks::HookEvent; +use crate::localization::MessageId; use crate::tui::app::App; use super::CommandResult; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "hooks", + aliases: &["hook", "gouzi"], + usage: "/hooks [list|events]", + description_id: MessageId::CmdHooksDescription, +}; + +pub(in crate::commands) struct HooksCmd; + +impl RegisterCommand for HooksCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + hooks(app, arg) + } +} + /// Top-level dispatch for `/hooks`. Subcommands: /// /// * `/hooks` — same as `/hooks list`. diff --git a/crates/tui/src/commands/groups/core/links.rs b/crates/tui/src/commands/groups/core/links.rs new file mode 100644 index 000000000..473016a8f --- /dev/null +++ b/crates/tui/src/commands/groups/core/links.rs @@ -0,0 +1,26 @@ +//! `/links` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "links", + aliases: &["dashboard", "api", "lianjie"], + usage: "/links", + description_id: MessageId::CmdLinksDescription, +}; + +pub(in crate::commands) struct LinksCmd; + +impl RegisterCommand for LinksCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::core::deepseek_links(app) + } +} diff --git a/crates/tui/src/commands/groups/core/mod.rs b/crates/tui/src/commands/groups/core/mod.rs index 0151e72ec..5eff7fd84 100644 --- a/crates/tui/src/commands/groups/core/mod.rs +++ b/crates/tui/src/commands/groups/core/mod.rs @@ -1,481 +1,140 @@ //! Core command area: model/provider selection, help, navigation, and the //! persistent RLM / sub-agent entry points. +#[cfg(all(test, feature = "long-running-tests"))] +mod acceptance; +mod agent; mod anchor; +mod clear; // This group dir intentionally has a `core.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)] mod core; +mod exit; mod feedback; +mod help; mod hf; +mod home; mod hooks; +mod links; +mod model; +mod models; +mod profile; mod provider; mod queue; +mod rlm; mod stash; +mod subagents; +mod swarm; +mod translate; +pub mod util; pub mod voice; +mod workspace; pub(in crate::commands) use self::core::reset_conversation_state; use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; -use crate::localization::MessageId; -use crate::tui::app::{App, AppAction}; +use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; pub struct CoreCommands; impl CommandGroup for CoreCommands { fn commands(&self) -> Vec> { vec![ - Box::new(FunctionCommand::new(&ANCHOR_INFO, run_anchor)), - Box::new(FunctionCommand::new(&HELP_INFO, run_help)), - Box::new(FunctionCommand::new(&CLEAR_INFO, run_clear)), - Box::new(FunctionCommand::new(&EXIT_INFO, run_exit)), - Box::new(FunctionCommand::new(&MODEL_INFO, run_model)), - Box::new(FunctionCommand::new(&MODELS_INFO, run_models)), - Box::new(FunctionCommand::new(&PROVIDER_INFO, run_provider)), - Box::new(FunctionCommand::new(&QUEUE_INFO, run_queue)), - Box::new(FunctionCommand::new(&STASH_INFO, run_stash)), - Box::new(FunctionCommand::new(&HOOKS_INFO, run_hooks)), - Box::new(FunctionCommand::new(&SUBAGENTS_INFO, run_subagents)), - Box::new(FunctionCommand::new(&AGENT_INFO, run_agent)), - Box::new(FunctionCommand::new(&SWARM_INFO, run_swarm)), - Box::new(FunctionCommand::new(&LINKS_INFO, run_links)), - Box::new(FunctionCommand::new(&FEEDBACK_INFO, run_feedback)), - Box::new(FunctionCommand::new(&HF_INFO, run_hf)), - Box::new(FunctionCommand::new(&HOME_INFO, run_home)), - Box::new(FunctionCommand::new(&WORKSPACE_INFO, run_workspace)), - Box::new(FunctionCommand::new(&PROFILE_INFO, run_profile)), - Box::new(FunctionCommand::new(&RLM_INFO, run_rlm)), - Box::new(FunctionCommand::new(&TRANSLATE_INFO, run_translate)), - Box::new(FunctionCommand::new(&VOICE_INFO, run_voice)), - Box::new(FunctionCommand::new(&VOICE_SEND_INFO, run_voice_send)), - Box::new(FunctionCommand::new(&VOICE_CONTROL_INFO, run_voice_control)), + Box::new(FunctionCommand::new( + anchor::AnchorCmd::info(), + anchor::AnchorCmd::execute, + )), + Box::new(FunctionCommand::new( + help::HelpCmd::info(), + help::HelpCmd::execute, + )), + Box::new(FunctionCommand::new( + clear::ClearCmd::info(), + clear::ClearCmd::execute, + )), + Box::new(FunctionCommand::new( + exit::ExitCmd::info(), + exit::ExitCmd::execute, + )), + Box::new(FunctionCommand::new( + model::ModelCmd::info(), + model::ModelCmd::execute, + )), + Box::new(FunctionCommand::new( + models::ModelsCmd::info(), + models::ModelsCmd::execute, + )), + Box::new(FunctionCommand::new( + provider::ProviderCmd::info(), + provider::ProviderCmd::execute, + )), + Box::new(FunctionCommand::new( + queue::QueueCmd::info(), + queue::QueueCmd::execute, + )), + Box::new(FunctionCommand::new( + stash::StashCmd::info(), + stash::StashCmd::execute, + )), + Box::new(FunctionCommand::new( + hooks::HooksCmd::info(), + hooks::HooksCmd::execute, + )), + Box::new(FunctionCommand::new( + subagents::SubagentsCmd::info(), + subagents::SubagentsCmd::execute, + )), + Box::new(FunctionCommand::new( + agent::AgentCmd::info(), + agent::AgentCmd::execute, + )), + Box::new(FunctionCommand::new( + swarm::SwarmCmd::info(), + swarm::SwarmCmd::execute, + )), + Box::new(FunctionCommand::new( + links::LinksCmd::info(), + links::LinksCmd::execute, + )), + Box::new(FunctionCommand::new( + feedback::FeedbackCmd::info(), + feedback::FeedbackCmd::execute, + )), + Box::new(FunctionCommand::new(hf::HfCmd::info(), hf::HfCmd::execute)), + Box::new(FunctionCommand::new( + home::HomeCmd::info(), + home::HomeCmd::execute, + )), + Box::new(FunctionCommand::new( + workspace::WorkspaceCmd::info(), + workspace::WorkspaceCmd::execute, + )), + Box::new(FunctionCommand::new( + profile::ProfileCmd::info(), + profile::ProfileCmd::execute, + )), + Box::new(FunctionCommand::new( + rlm::RlmCmd::info(), + rlm::RlmCmd::execute, + )), + Box::new(FunctionCommand::new( + translate::TranslateCmd::info(), + translate::TranslateCmd::execute, + )), + Box::new(FunctionCommand::new( + voice::VoiceCmd::info(), + voice::VoiceCmd::execute, + )), + Box::new(FunctionCommand::new( + voice::VoiceSendCmd::info(), + voice::VoiceSendCmd::execute, + )), + Box::new(FunctionCommand::new( + voice::VoiceControlCmd::info(), + voice::VoiceControlCmd::execute, + )), ] } } - -static ANCHOR_INFO: CommandInfo = CommandInfo { - name: "anchor", - aliases: &["maodian"], - usage: "/anchor | /anchor list | /anchor remove ", - description_id: MessageId::CmdAnchorDescription, -}; -static HELP_INFO: CommandInfo = CommandInfo { - name: "help", - aliases: &["?", "bangzhu", "帮助"], - usage: "/help [command]", - description_id: MessageId::CmdHelpDescription, -}; -static CLEAR_INFO: CommandInfo = CommandInfo { - name: "clear", - aliases: &["qingping"], - usage: "/clear", - description_id: MessageId::CmdClearDescription, -}; -static EXIT_INFO: CommandInfo = CommandInfo { - name: "exit", - aliases: &["quit", "q", "tuichu"], - usage: "/exit", - description_id: MessageId::CmdExitDescription, -}; -static MODEL_INFO: CommandInfo = CommandInfo { - name: "model", - aliases: &["moxing"], - usage: "/model [name]", - description_id: MessageId::CmdModelDescription, -}; -static MODELS_INFO: CommandInfo = CommandInfo { - name: "models", - aliases: &["moxingliebiao"], - usage: "/models", - description_id: MessageId::CmdModelsDescription, -}; -static PROVIDER_INFO: CommandInfo = CommandInfo { - name: "provider", - aliases: &[], - usage: "/provider [name] [model]", - description_id: MessageId::CmdProviderDescription, -}; -static QUEUE_INFO: CommandInfo = CommandInfo { - name: "queue", - aliases: &["queued"], - usage: "/queue [list|send |edit |drop |clear]", - description_id: MessageId::CmdQueueDescription, -}; -static STASH_INFO: CommandInfo = CommandInfo { - name: "stash", - aliases: &["park"], - usage: "/stash [list|pop|clear]", - description_id: MessageId::CmdStashDescription, -}; -static HOOKS_INFO: CommandInfo = CommandInfo { - name: "hooks", - aliases: &["hook", "gouzi"], - usage: "/hooks [list|events]", - description_id: MessageId::CmdHooksDescription, -}; -static SUBAGENTS_INFO: CommandInfo = CommandInfo { - name: "subagents", - aliases: &["agents", "zhinengti"], - usage: "/subagents", - description_id: MessageId::CmdSubagentsDescription, -}; -static AGENT_INFO: CommandInfo = CommandInfo { - name: "agent", - aliases: &["daili"], - usage: "/agent [N] ", - description_id: MessageId::CmdAgentDescription, -}; -static SWARM_INFO: CommandInfo = CommandInfo { - name: "swarm", - aliases: &["fanout", "qun"], - usage: "/swarm [N] ", - description_id: MessageId::CmdSwarmDescription, -}; -static LINKS_INFO: CommandInfo = CommandInfo { - name: "links", - aliases: &["dashboard", "api", "lianjie"], - usage: "/links", - description_id: MessageId::CmdLinksDescription, -}; -static FEEDBACK_INFO: CommandInfo = CommandInfo { - name: "feedback", - aliases: &[], - usage: "/feedback [bug|feature|security]", - description_id: MessageId::CmdFeedbackDescription, -}; -static HF_INFO: CommandInfo = CommandInfo { - name: "hf", - aliases: &["huggingface"], - usage: "/hf [mcp |concepts]", - description_id: MessageId::CmdHfDescription, -}; -static HOME_INFO: CommandInfo = CommandInfo { - name: "home", - aliases: &["stats", "overview", "zhuye", "shouye"], - usage: "/home", - description_id: MessageId::CmdHomeDescription, -}; -static WORKSPACE_INFO: CommandInfo = CommandInfo { - name: "workspace", - aliases: &["cwd"], - usage: "/workspace [path]", - description_id: MessageId::CmdWorkspaceDescription, -}; -static PROFILE_INFO: CommandInfo = CommandInfo { - name: "profile", - aliases: &["dangan"], - usage: "/profile ", - description_id: MessageId::CmdHelpDescription, -}; -static RLM_INFO: CommandInfo = CommandInfo { - name: "rlm", - aliases: &["recursive", "digui"], - usage: "/rlm [N] ", - description_id: MessageId::CmdRlmDescription, -}; -static TRANSLATE_INFO: CommandInfo = CommandInfo { - name: "translate", - aliases: &["translation", "transale"], - usage: "/translate", - description_id: MessageId::CmdTranslateDescription, -}; -static VOICE_INFO: CommandInfo = CommandInfo { - name: "voice", - aliases: &["yuyin", "语音"], - usage: "/voice", - description_id: MessageId::CmdVoiceDescription, -}; -static VOICE_SEND_INFO: CommandInfo = CommandInfo { - name: "voicesend", - aliases: &["voice-send", "yuyinsend", "语音发送"], - usage: "/voicesend", - description_id: MessageId::CmdVoiceSendDescription, -}; -static VOICE_CONTROL_INFO: CommandInfo = CommandInfo { - name: "voicecontrol", - aliases: &["voice-control", "yuyincontrol", "语音控制"], - usage: "/voicecontrol", - description_id: MessageId::CmdVoiceControlDescription, -}; - -fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { - dispatch(app, name, arg).expect("registered core command should dispatch") -} - -fn run_anchor(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "anchor", arg) -} -fn run_help(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "help", arg) -} -fn run_clear(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "clear", arg) -} -fn run_exit(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "exit", arg) -} -fn run_model(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "model", arg) -} -fn run_models(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "models", arg) -} -fn run_provider(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "provider", arg) -} -fn run_queue(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "queue", arg) -} -fn run_stash(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "stash", arg) -} -fn run_hooks(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "hooks", arg) -} -fn run_subagents(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "subagents", arg) -} -fn run_agent(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "agent", arg) -} -fn run_swarm(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "swarm", arg) -} -fn run_links(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "links", arg) -} -fn run_feedback(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "feedback", arg) -} -fn run_hf(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "hf", arg) -} -fn run_home(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "home", arg) -} -fn run_workspace(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "workspace", arg) -} -fn run_profile(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "profile", arg) -} -fn run_rlm(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "rlm", arg) -} -fn run_translate(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "translate", arg) -} -fn run_voice(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "voice", arg) -} -fn run_voice_send(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "voicesend", arg) -} -fn run_voice_control(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "voicecontrol", arg) -} - -pub(in crate::commands) fn dispatch( - app: &mut App, - command: &str, - arg: Option<&str>, -) -> Option { - let result = match command { - "anchor" | "maodian" => anchor::anchor(app, arg), - "help" | "?" | "bangzhu" | "帮助" => core::help(app, arg), - "clear" | "qingping" => core::clear(app), - "exit" | "quit" | "q" | "tuichu" => core::exit(), - "model" | "moxing" => core::model(app, arg), - "models" | "moxingliebiao" => core::models(app), - "provider" => provider::provider(app, arg), - "queue" | "queued" => queue::queue(app, arg), - "stash" | "park" => stash::stash(app, arg), - "hooks" | "hook" | "gouzi" => hooks::hooks(app, arg), - "subagents" | "agents" | "zhinengti" => core::subagents(app), - "agent" | "daili" => agent(app, arg), - "swarm" | "fanout" | "qun" => swarm(app, arg), - "links" | "dashboard" | "api" | "lianjie" => core::deepseek_links(app), - "feedback" => feedback::feedback(app, arg), - "hf" | "huggingface" => hf::hf(app, arg), - "home" | "stats" | "overview" | "zhuye" | "shouye" => core::home_dashboard(app), - "workspace" | "cwd" => core::workspace_switch(app, arg), - "profile" | "dangan" => core::profile_switch(app, arg), - "rlm" | "recursive" | "digui" => rlm(app, arg), - "translate" | "translation" | "transale" => core::translate(app), - "voice" | "yuyin" | "语音" => voice::voice(app), - "voicesend" | "voice-send" | "yuyinsend" | "语音发送" => voice::voice_send(app), - "voicecontrol" | "voice-control" | "yuyincontrol" | "语音控制" => { - voice::voice_control(app) - } - _ => return None, - }; - Some(result) -} - -/// Execute a Recursive Language Model (RLM) turn — Algorithm 1 from -/// Zhang et al. (arXiv:2512.24601). -/// -/// The user's prompt text is passed as the argument. It will be stored -/// in the REPL as the `PROMPT` variable. The root LLM will only see -/// metadata about the REPL state, never the prompt text directly. -pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { - let (max_depth, target) = match parse_depth_prefixed_arg(arg, 1) { - Ok(parsed) => parsed, - Err(message) => return CommandResult::error(message), - }; - let target = match target { - Some(p) if !p.trim().is_empty() => p.trim().to_string(), - _ => { - return CommandResult::error( - "Usage: /rlm [N] \n\n\ - Opens a persistent RLM context with sub_rlm depth N (0-3, default 1)." - .to_string(), - ); - } - }; - - let source_arg = if resolves_to_existing_file(app, &target) { - format!(r#"file_path: "{target}""#) - } else { - format!("content: {target:?}") - }; - let message = format!( - "Open and use a persistent RLM session for this request. Call `rlm_open` with name `slash_rlm` and {source_arg}. Then call `rlm_configure` with `sub_rlm_max_depth: {max_depth}`. Use `rlm_eval` to inspect the context through `peek`, `search`, and `chunk`, and call `finalize(...)` from the REPL when ready. If a `var_handle` is returned, use `handle_read` for bounded slices or projections before answering." - ); - - CommandResult::with_message_and_action( - format!("Opening persistent RLM context at depth {max_depth}..."), - AppAction::SendMessage(message), - ) -} - -/// Open a persistent sub-agent session from a slash command. -pub fn agent(_app: &mut App, arg: Option<&str>) -> CommandResult { - let (max_depth, task) = match parse_depth_prefixed_arg(arg, 1) { - Ok(parsed) => parsed, - Err(message) => return CommandResult::error(message), - }; - let task = match task { - Some(task) if !task.trim().is_empty() => task.trim().to_string(), - _ => { - return CommandResult::error( - "Usage: /agent [N] \n\n\ - Opens a persistent sub-agent session with recursive agent depth N (0-3, default 1).", - ); - } - }; - let message = format!( - "Launch one sub-agent for this task by calling `agent` with name `slash_agent`, `prompt: {task:?}`, and `max_depth: {max_depth}`. Use `handle_read` on the returned transcript_handle if you need more detail. Verify any claimed side effects before reporting success." - ); - CommandResult::with_message_and_action( - format!("Opening persistent sub-agent at depth {max_depth}..."), - AppAction::SendMessage(message), - ) -} - -/// Gate the old prompt-only swarm fanout until it can route through durable -/// WhaleFlow/Fleet workers (#3218). -pub fn swarm(_app: &mut App, arg: Option<&str>) -> CommandResult { - let (_max_depth, task) = match parse_depth_prefixed_arg(arg, 1) { - Ok(parsed) => parsed, - Err(message) => return CommandResult::error(message), - }; - if !matches!(task.map(str::trim), Some(task) if !task.is_empty()) { - return CommandResult::error( - "Usage: /swarm [N] \n\n\ - /swarm is currently gated. Use /goal for a persistent objective \ - or /agent for a single sub-agent while durable Fleet-backed \ - swarm workers are still landing.", - ); - } - CommandResult::error( - "/swarm is gated in v0.8.61: prompt-only agent fanout is disabled until the durable Train-3 worker/goal re-dispatch substrate lands. Use /goal for the persistent objective or /agent [N] for one bounded sub-agent.", - ) -} - -fn parse_depth_prefixed_arg( - arg: Option<&str>, - default_depth: u32, -) -> Result<(u32, Option<&str>), String> { - let Some(raw) = arg.map(str::trim).filter(|raw| !raw.is_empty()) else { - return Ok((default_depth, None)); - }; - let mut parts = raw.splitn(2, char::is_whitespace); - let first = parts.next().unwrap_or_default(); - if first.chars().all(|ch| ch.is_ascii_digit()) { - let depth: u32 = first - .parse() - .map_err(|_| "Depth must be an integer from 0 to 3".to_string())?; - if depth > 3 { - return Err("Depth must be between 0 and 3".to_string()); - } - Ok((depth, parts.next().map(str::trim))) - } else { - Ok((default_depth, Some(raw))) - } -} - -fn resolves_to_existing_file(app: &App, input: &str) -> bool { - let path = std::path::Path::new(input); - let candidate = if path.is_absolute() { - path.to_path_buf() - } else { - app.workspace.join(path) - }; - candidate.is_file() -} - -#[cfg(test)] -mod tests { - use super::*; - - fn create_test_app() -> App { - let options = crate::tui::app::TuiOptions { - model: "deepseek-v4-pro".to_string(), - workspace: std::path::PathBuf::from("/tmp/test-workspace"), - config_path: None, - config_profile: None, - allow_shell: false, - use_alt_screen: true, - use_mouse_capture: false, - use_bracketed_paste: true, - max_subagents: 1, - skills_dir: std::path::PathBuf::from("/tmp/test-skills"), - memory_path: std::path::PathBuf::from("memory.md"), - notes_path: std::path::PathBuf::from("notes.txt"), - mcp_config_path: std::path::PathBuf::from("mcp.json"), - use_memory: false, - start_in_agent_mode: false, - skip_onboarding: true, - initial_input: None, - resume_session_id: None, - yolo: false, - }; - App::new(options, &crate::config::Config::default()) - } - - #[test] - fn swarm_is_gated_until_durable_worker_substrate_lands() { - let mut app = create_test_app(); - let result = swarm(&mut app, Some("inspect five files")); - - assert!(result.is_error); - assert!(result.action.is_none()); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("gated") - ); - assert!( - result - .message - .as_deref() - .unwrap_or_default() - .contains("Train-3") - ); - } -} diff --git a/crates/tui/src/commands/groups/core/model.rs b/crates/tui/src/commands/groups/core/model.rs new file mode 100644 index 000000000..09893ea68 --- /dev/null +++ b/crates/tui/src/commands/groups/core/model.rs @@ -0,0 +1,26 @@ +//! `/model` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "model", + aliases: &["moxing"], + usage: "/model [name]", + description_id: MessageId::CmdModelDescription, +}; + +pub(in crate::commands) struct ModelCmd; + +impl RegisterCommand for ModelCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::core::model(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/core/models.rs b/crates/tui/src/commands/groups/core/models.rs new file mode 100644 index 000000000..0203e7f9f --- /dev/null +++ b/crates/tui/src/commands/groups/core/models.rs @@ -0,0 +1,26 @@ +//! `/models` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "models", + aliases: &["moxingliebiao"], + usage: "/models", + description_id: MessageId::CmdModelsDescription, +}; + +pub(in crate::commands) struct ModelsCmd; + +impl RegisterCommand for ModelsCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::core::models(app) + } +} diff --git a/crates/tui/src/commands/groups/core/profile.rs b/crates/tui/src/commands/groups/core/profile.rs new file mode 100644 index 000000000..d5202650d --- /dev/null +++ b/crates/tui/src/commands/groups/core/profile.rs @@ -0,0 +1,26 @@ +//! `/profile` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "profile", + aliases: &["dangan"], + usage: "/profile ", + description_id: MessageId::CmdHelpDescription, +}; + +pub(in crate::commands) struct ProfileCmd; + +impl RegisterCommand for ProfileCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::core::profile_switch(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/core/provider.rs b/crates/tui/src/commands/groups/core/provider.rs index 2bd96a2ab..a89aca836 100644 --- a/crates/tui/src/commands/groups/core/provider.rs +++ b/crates/tui/src/commands/groups/core/provider.rs @@ -4,14 +4,35 @@ //! `/provider` with no args opens the picker modal (#52). `/provider ` //! keeps the v0.6.6 CLI form for muscle-memory + scripted use. +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::config::{ ApiProvider, normalize_model_name, normalize_model_name_for_provider, provider_passes_model_through, }; +use crate::localization::MessageId; use crate::tui::app::{App, AppAction}; use super::CommandResult; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "provider", + aliases: &[], + usage: "/provider [name] [model]", + description_id: MessageId::CmdProviderDescription, +}; + +pub(in crate::commands) struct ProviderCmd; + +impl RegisterCommand for ProviderCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + provider(app, arg) + } +} + /// Switch or view the current LLM backend. /// /// With no args, opens the picker modal. With ` [model]`, performs diff --git a/crates/tui/src/commands/groups/core/queue.rs b/crates/tui/src/commands/groups/core/queue.rs index 51bf2b7db..5c255acc8 100644 --- a/crates/tui/src/commands/groups/core/queue.rs +++ b/crates/tui/src/commands/groups/core/queue.rs @@ -1,5 +1,6 @@ //! Queue commands: queue list/edit/drop/clear +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::localization::{Locale, MessageId, tr}; use crate::tui::app::App; @@ -7,6 +8,25 @@ use super::CommandResult; const PREVIEW_LIMIT: usize = 120; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "queue", + aliases: &["queued"], + usage: "/queue [list|send |edit |drop |clear]", + description_id: MessageId::CmdQueueDescription, +}; + +pub(in crate::commands) struct QueueCmd; + +impl RegisterCommand for QueueCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + queue(app, arg) + } +} + pub fn queue(app: &mut App, args: Option<&str>) -> CommandResult { let locale = app.ui_locale; let arg = args.unwrap_or("").trim(); diff --git a/crates/tui/src/commands/groups/core/rlm.rs b/crates/tui/src/commands/groups/core/rlm.rs new file mode 100644 index 000000000..a3926b19f --- /dev/null +++ b/crates/tui/src/commands/groups/core/rlm.rs @@ -0,0 +1,67 @@ +//! `/rlm` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::{App, AppAction}; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "rlm", + aliases: &["recursive", "digui"], + usage: "/rlm [N] ", + description_id: MessageId::CmdRlmDescription, +}; + +pub(in crate::commands) struct RlmCmd; + +impl RegisterCommand for RlmCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + rlm(app, arg) + } +} + +pub fn rlm(app: &mut App, arg: Option<&str>) -> CommandResult { + let (max_depth, target) = match super::util::parse_depth_prefixed_arg(arg, 1) { + Ok(parsed) => parsed, + Err(message) => return CommandResult::error(message), + }; + let target = match target { + Some(p) if !p.trim().is_empty() => p.trim().to_string(), + _ => { + return CommandResult::error( + "Usage: /rlm [N] \n\n\ + Opens a persistent RLM context with sub_rlm depth N (0-3, default 1)." + .to_string(), + ); + } + }; + + let source_arg = if resolves_to_existing_file(app, &target) { + format!(r#"file_path: "{target}""#) + } else { + format!("content: {target:?}") + }; + let message = format!( + "Open and use a persistent RLM session for this request. Call `rlm_open` with name `slash_rlm` and {source_arg}. Then call `rlm_configure` with `sub_rlm_max_depth: {max_depth}`. Use `rlm_eval` to inspect the context through `peek`, `search`, and `chunk`, and call `finalize(...)` from the REPL when ready. If a `var_handle` is returned, use `handle_read` for bounded slices or projections before answering." + ); + + CommandResult::with_message_and_action( + format!("Opening persistent RLM context at depth {max_depth}..."), + AppAction::SendMessage(message), + ) +} + +fn resolves_to_existing_file(app: &App, input: &str) -> bool { + let path = std::path::Path::new(input); + let candidate = if path.is_absolute() { + path.to_path_buf() + } else { + app.workspace.join(path) + }; + candidate.is_file() +} diff --git a/crates/tui/src/commands/groups/core/stash.rs b/crates/tui/src/commands/groups/core/stash.rs index 1723e4403..e80d569f2 100644 --- a/crates/tui/src/commands/groups/core/stash.rs +++ b/crates/tui/src/commands/groups/core/stash.rs @@ -5,11 +5,32 @@ //! surface; Ctrl+S in the composer is the corresponding push entry //! point. +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::composer_stash; +use crate::localization::MessageId; use crate::tui::app::App; use super::CommandResult; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "stash", + aliases: &["park"], + usage: "/stash [list|pop|clear]", + description_id: MessageId::CmdStashDescription, +}; + +pub(in crate::commands) struct StashCmd; + +impl RegisterCommand for StashCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + stash(app, arg) + } +} + /// Top-level dispatch for `/stash`. Subcommands: /// /// * `/stash` — same as `/stash list`. diff --git a/crates/tui/src/commands/groups/core/subagents.rs b/crates/tui/src/commands/groups/core/subagents.rs new file mode 100644 index 000000000..e51c282c3 --- /dev/null +++ b/crates/tui/src/commands/groups/core/subagents.rs @@ -0,0 +1,26 @@ +//! `/subagents` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "subagents", + aliases: &["agents", "zhinengti"], + usage: "/subagents", + description_id: MessageId::CmdSubagentsDescription, +}; + +pub(in crate::commands) struct SubagentsCmd; + +impl RegisterCommand for SubagentsCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::core::subagents(app) + } +} diff --git a/crates/tui/src/commands/groups/core/swarm.rs b/crates/tui/src/commands/groups/core/swarm.rs new file mode 100644 index 000000000..52b9cf70e --- /dev/null +++ b/crates/tui/src/commands/groups/core/swarm.rs @@ -0,0 +1,99 @@ +//! `/swarm` command - gated until durable Fleet-backed workers are available. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "swarm", + aliases: &["fanout", "qun"], + usage: "/swarm [N] ", + description_id: MessageId::CmdSwarmDescription, +}; + +pub(in crate::commands) struct SwarmCmd; + +impl RegisterCommand for SwarmCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + swarm(app, arg) + } +} + +/// Gate the old prompt-only swarm fanout until it can route through durable +/// WhaleFlow/Fleet workers (#3218). +pub fn swarm(_app: &mut App, arg: Option<&str>) -> CommandResult { + let (_max_depth, task) = match super::util::parse_depth_prefixed_arg(arg, 1) { + Ok(parsed) => parsed, + Err(message) => return CommandResult::error(message), + }; + if !matches!(task.map(str::trim), Some(task) if !task.is_empty()) { + return CommandResult::error( + "Usage: /swarm [N] \n\n\ + /swarm is currently gated. Use /goal for a persistent objective \ + or /agent for a single sub-agent while durable Fleet-backed \ + swarm workers are still landing.", + ); + } + CommandResult::error( + "/swarm is gated in v0.8.61: prompt-only agent fanout is disabled until the durable Train-3 worker/goal re-dispatch substrate lands. Use /goal for the persistent objective or /agent [N] for one bounded sub-agent.", + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn create_test_app() -> App { + let options = crate::tui::app::TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: std::path::PathBuf::from("/tmp/test-workspace"), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: std::path::PathBuf::from("/tmp/test-skills"), + memory_path: std::path::PathBuf::from("memory.md"), + notes_path: std::path::PathBuf::from("notes.txt"), + mcp_config_path: std::path::PathBuf::from("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + initial_input: None, + resume_session_id: None, + yolo: false, + }; + App::new(options, &crate::config::Config::default()) + } + + #[test] + fn swarm_is_gated_until_durable_worker_substrate_lands() { + let mut app = create_test_app(); + let result = swarm(&mut app, Some("inspect five files")); + + assert!(result.is_error); + assert!(result.action.is_none()); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("gated") + ); + assert!( + result + .message + .as_deref() + .unwrap_or_default() + .contains("Train-3") + ); + } +} diff --git a/crates/tui/src/commands/groups/core/translate.rs b/crates/tui/src/commands/groups/core/translate.rs new file mode 100644 index 000000000..4a626ed92 --- /dev/null +++ b/crates/tui/src/commands/groups/core/translate.rs @@ -0,0 +1,26 @@ +//! `/translate` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "translate", + aliases: &["translation", "transale"], + usage: "/translate", + description_id: MessageId::CmdTranslateDescription, +}; + +pub(in crate::commands) struct TranslateCmd; + +impl RegisterCommand for TranslateCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::core::translate(app) + } +} diff --git a/crates/tui/src/commands/groups/core/util.rs b/crates/tui/src/commands/groups/core/util.rs new file mode 100644 index 000000000..865480621 --- /dev/null +++ b/crates/tui/src/commands/groups/core/util.rs @@ -0,0 +1,23 @@ +//! Shared helpers for core slash commands. + +pub(super) fn parse_depth_prefixed_arg( + arg: Option<&str>, + default_depth: u32, +) -> Result<(u32, Option<&str>), String> { + let Some(raw) = arg.map(str::trim).filter(|raw| !raw.is_empty()) else { + return Ok((default_depth, None)); + }; + let mut parts = raw.splitn(2, char::is_whitespace); + let first = parts.next().unwrap_or_default(); + if first.chars().all(|ch| ch.is_ascii_digit()) { + let depth: u32 = first + .parse() + .map_err(|_| "Depth must be an integer from 0 to 3".to_string())?; + if depth > 3 { + return Err("Depth must be between 0 and 3".to_string()); + } + Ok((depth, parts.next().map(str::trim))) + } else { + Ok((default_depth, Some(raw))) + } +} diff --git a/crates/tui/src/commands/groups/core/voice.rs b/crates/tui/src/commands/groups/core/voice.rs index 5d6e94721..8c0c78d52 100644 --- a/crates/tui/src/commands/groups/core/voice.rs +++ b/crates/tui/src/commands/groups/core/voice.rs @@ -29,6 +29,7 @@ use std::time::Duration; use regex::Regex; use crate::commands::CommandResult; +use crate::commands::traits::{CommandInfo, RegisterCommand}; use crate::config::Config; use crate::localization::{MessageId, tr}; use crate::tui::app::{App, AppAction}; @@ -38,6 +39,61 @@ const ASR_MODEL: &str = "mimo-v2.5-asr"; /// Model used for the AI-assisted voice-control pipeline. const VOICE_CONTROL_MODEL: &str = "mimo-v2.5"; +pub(in crate::commands) const VOICE_INFO: CommandInfo = CommandInfo { + name: "voice", + aliases: &["yuyin", "语音"], + usage: "/voice", + description_id: MessageId::CmdVoiceDescription, +}; + +pub(in crate::commands) const VOICE_SEND_INFO: CommandInfo = CommandInfo { + name: "voicesend", + aliases: &["voice-send", "yuyinsend", "语音发送"], + usage: "/voicesend", + description_id: MessageId::CmdVoiceSendDescription, +}; + +pub(in crate::commands) const VOICE_CONTROL_INFO: CommandInfo = CommandInfo { + name: "voicecontrol", + aliases: &["voice-control", "yuyincontrol", "语音控制"], + usage: "/voicecontrol", + description_id: MessageId::CmdVoiceControlDescription, +}; + +pub(in crate::commands) struct VoiceCmd; +pub(in crate::commands) struct VoiceSendCmd; +pub(in crate::commands) struct VoiceControlCmd; + +impl RegisterCommand for VoiceCmd { + fn info() -> &'static CommandInfo { + &VOICE_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + voice(app) + } +} + +impl RegisterCommand for VoiceSendCmd { + fn info() -> &'static CommandInfo { + &VOICE_SEND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + voice_send(app) + } +} + +impl RegisterCommand for VoiceControlCmd { + fn info() -> &'static CommandInfo { + &VOICE_CONTROL_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + voice_control(app) + } +} + // --- Recorder detection ---------------------------------------------------- /// Platform-specific recorder definitions. diff --git a/crates/tui/src/commands/groups/core/workspace.rs b/crates/tui/src/commands/groups/core/workspace.rs new file mode 100644 index 000000000..169336653 --- /dev/null +++ b/crates/tui/src/commands/groups/core/workspace.rs @@ -0,0 +1,26 @@ +//! `/workspace` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "workspace", + aliases: &["cwd"], + usage: "/workspace [path]", + description_id: MessageId::CmdWorkspaceDescription, +}; + +pub(in crate::commands) struct WorkspaceCmd; + +impl RegisterCommand for WorkspaceCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::core::workspace_switch(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/session/acceptance.rs b/crates/tui/src/commands/groups/session/acceptance.rs new file mode 100644 index 000000000..87155c9d1 --- /dev/null +++ b/crates/tui/src/commands/groups/session/acceptance.rs @@ -0,0 +1,878 @@ +//! Gherkin acceptance coverage for session command workflows. + +use std::path::PathBuf; + +use chrono::{Duration as ChronoDuration, Utc}; +use cucumber::{World as _, given, then, when, writer::Stats as _}; +use tempfile::TempDir; + +use crate::commands::{self, CommandResult}; +use crate::config::Config; +use crate::models::{ContentBlock, Message}; +use crate::session_manager::{SavedSession, SessionManager, create_saved_session_with_id_and_mode}; +use crate::test_support::{EnvVarGuard, lock_test_env}; +use crate::tui::app::{App, AppAction, TuiOptions}; +use crate::tui::history::HistoryCell; +use crate::tui::views::ModalKind; + +const FEATURE_NAME: &str = "Session command workflows"; +const FEATURE_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/features/session_command_workflows.feature" +); +const SAVE_LOAD_SCENARIO: &str = "Save, export, and load preserve the active session"; +const FORK_RESUMABLE_SCENARIO: &str = "Fork keeps the original session resumable"; +const NEW_THEN_FORK_SCENARIO: &str = "New session cannot be forked before messages exist"; +const CLEAR_THEN_FORK_SCENARIO: &str = "Cleared session cannot be forked before messages exist"; +const FORK_THEN_NEW_SCENARIO: &str = "Fork followed by new keeps both saved sessions"; +const FORK_THEN_CLEAR_SCENARIO: &str = "Fork followed by clear keeps both saved sessions"; +const RENAME_SCENARIO: &str = "Rename updates the active saved session title"; +const SESSIONS_LIST_SCENARIO: &str = "Sessions list opens the saved session picker"; +const SESSIONS_PRUNE_SCENARIO: &str = "Sessions prune removes only stale sessions"; +const CONTEXT_MANAGEMENT_SCENARIO: &str = + "Context management commands emit actions without clearing the active session"; +const SINGULAR_SESSION_SCENARIO: &str = "Singular session command is not registered"; + +#[derive(Default, cucumber::World)] +struct SessionCommandWorld { + tmpdir: Option, + app: Option>, + save_path: Option, + export_path: Option, + home_path: Option, + original_session_id: Option, + fork_session_id: Option, + new_session_id: Option, + fresh_session_id: Option, + stale_session_id: Option, + last_message: Option, + last_result_is_error: Option, + last_action: Option, +} + +impl std::fmt::Debug for SessionCommandWorld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("SessionCommandWorld") + .field("has_tmpdir", &self.tmpdir.is_some()) + .field("has_app", &self.app.is_some()) + .field("save_path", &self.save_path) + .field("export_path", &self.export_path) + .field("home_path", &self.home_path) + .field("original_session_id", &self.original_session_id) + .field("fork_session_id", &self.fork_session_id) + .field("new_session_id", &self.new_session_id) + .field("fresh_session_id", &self.fresh_session_id) + .field("stale_session_id", &self.stale_session_id) + .field("last_message", &self.last_message) + .field("last_result_is_error", &self.last_result_is_error) + .finish() + } +} + +#[given("a CodeWhale session workspace with one user message")] +fn workspace_with_one_user_message(world: &mut SessionCommandWorld) { + let tmpdir = TempDir::new().expect("session workflow TempDir"); + let mut app = create_test_app_with_tmpdir(&tmpdir); + app.api_messages.push(Message { + role: "user".to_string(), + content: vec![ContentBlock::Text { + text: "Remember the whale migration".to_string(), + cache_control: None, + }], + }); + app.add_message(HistoryCell::User { + content: "Remember the whale migration".to_string(), + }); + app.session.total_tokens = 321; + app.session.total_conversation_tokens = 321; + + world.save_path = Some(tmpdir.path().join("saved-session.json")); + world.export_path = Some(tmpdir.path().join("transcript.md")); + world.home_path = Some(tmpdir.path().join("home")); + world.app = Some(Box::new(app)); + world.tmpdir = Some(tmpdir); +} + +#[given("a CodeWhale persisted session workspace with one user message")] +fn persisted_workspace_with_one_user_message(world: &mut SessionCommandWorld) { + workspace_with_one_user_message(world); + let original_id = "original-session".to_string(); + let app = world.app.as_deref_mut().expect("app should exist"); + app.current_session_id = Some(original_id.clone()); + world.original_session_id = Some(original_id); + persist_active_session(world); +} + +#[given("a CodeWhale session workspace with stale and fresh saved sessions")] +fn workspace_with_stale_and_fresh_saved_sessions(world: &mut SessionCommandWorld) { + workspace_with_one_user_message(world); + persist_session_with_age(world, "fresh-session", "Fresh session", 1); + persist_session_with_age(world, "stale-session", "Stale session", 30); + world.fresh_session_id = Some("fresh-session".to_string()); + world.stale_session_id = Some("stale-session".to_string()); +} + +#[when("the user saves the active session")] +fn user_saves_active_session(world: &mut SessionCommandWorld) { + let save_path = world + .save_path + .as_ref() + .expect("save path should exist") + .to_string_lossy() + .to_string(); + let result = execute_isolated(world, &format!("/save {save_path}")); + remember_result(world, &result); + + assert!(!result.is_error, "save failed: {:?}", result.message); + assert!( + world.save_path.as_ref().expect("save path").exists(), + "save command should write the session file" + ); +} + +#[when("the user exports the active transcript")] +fn user_exports_active_transcript(world: &mut SessionCommandWorld) { + let export_path = world + .export_path + .as_ref() + .expect("export path should exist") + .to_string_lossy() + .to_string(); + let result = execute_isolated(world, &format!("/export {export_path}")); + remember_result(world, &result); + + assert!(!result.is_error, "export failed: {:?}", result.message); + assert!( + world.export_path.as_ref().expect("export path").exists(), + "export command should write the transcript" + ); +} + +#[when("the user clears the active conversation")] +fn user_clears_active_conversation(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/clear"); + remember_result(world, &result); + + assert!(!result.is_error, "clear failed: {:?}", result.message); + let app = world.app.as_deref().expect("app should exist"); + assert!( + app.api_messages.is_empty(), + "clear command should remove active API messages" + ); + assert_eq!(app.session.total_tokens, 0); +} + +#[when("the user loads the saved session")] +fn user_loads_saved_session(world: &mut SessionCommandWorld) { + let save_path = world + .save_path + .as_ref() + .expect("save path should exist") + .to_string_lossy() + .to_string(); + let result = execute_isolated(world, &format!("/load {save_path}")); + remember_result(world, &result); + + assert!(!result.is_error, "load failed: {:?}", result.message); + world.last_message = result.message; +} + +#[when("the user forks the active session")] +fn user_forks_active_session(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/fork"); + remember_result(world, &result); + + assert!(!result.is_error, "fork failed: {:?}", result.message); + let fork_id = world + .app + .as_deref() + .and_then(|app| app.current_session_id.clone()) + .expect("fork command should switch to a child session"); + let forked = load_saved_session(world, &fork_id); + if world.original_session_id.is_none() { + world.original_session_id = forked.metadata.parent_session_id.clone(); + } + world.fork_session_id = Some(fork_id); +} + +#[when("the user tries to fork the active session")] +fn user_tries_to_fork_active_session(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/fork"); + remember_result(world, &result); +} + +#[when("the user starts a new session")] +fn user_starts_new_session(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/new"); + remember_result(world, &result); + + assert!(!result.is_error, "new session failed: {:?}", result.message); + let new_id = world + .app + .as_deref() + .and_then(|app| app.current_session_id.clone()) + .expect("new command should set an active session id"); + world.new_session_id = Some(new_id); +} + +#[when(regex = r#"^the user renames the active session to "([^"]+)"$"#)] +fn user_renames_active_session(world: &mut SessionCommandWorld, title: String) { + let result = execute_isolated(world, &format!("/rename {title}")); + remember_result(world, &result); + + assert!(!result.is_error, "rename failed: {:?}", result.message); +} + +#[when("the user lists saved sessions")] +fn user_lists_saved_sessions(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/sessions list"); + remember_result(world, &result); + + assert!( + !result.is_error, + "sessions list failed: {:?}", + result.message + ); +} + +#[when(regex = r#"^the user prunes sessions older than (\d+) days$"#)] +fn user_prunes_sessions_older_than(world: &mut SessionCommandWorld, days: String) { + let result = execute_isolated(world, &format!("/sessions prune {days}")); + remember_result(world, &result); + + assert!( + !result.is_error, + "sessions prune failed: {:?}", + result.message + ); +} + +#[when("the user compacts context")] +fn user_compacts_context(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/compact"); + remember_result(world, &result); + + assert!(!result.is_error, "compact failed: {:?}", result.message); +} + +#[when("the user purges context")] +fn user_purges_context(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/purge"); + remember_result(world, &result); + + assert!(!result.is_error, "purge failed: {:?}", result.message); +} + +#[when(regex = r#"^the user prepares a session relay focused on "([^"]+)"$"#)] +fn user_prepares_session_relay_focused_on(world: &mut SessionCommandWorld, focus: String) { + let result = execute_isolated(world, &format!("/relay {focus}")); + remember_result(world, &result); + + assert!(!result.is_error, "relay failed: {:?}", result.message); +} + +#[when("the user runs the singular session command")] +fn user_runs_singular_session_command(world: &mut SessionCommandWorld) { + let result = execute_isolated(world, "/session"); + remember_result(world, &result); +} + +#[then("the active session should contain the saved message")] +fn active_session_contains_saved_message(world: &mut SessionCommandWorld) { + let app = world.app.as_deref().expect("app should exist"); + let message = app + .api_messages + .first() + .expect("loaded session should have one message"); + let content = message + .content + .iter() + .find_map(|block| match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + }) + .expect("loaded message should have text content"); + + assert_eq!(message.role, "user"); + assert_eq!(content, "Remember the whale migration"); +} + +#[then("the saved session file should contain the saved message")] +fn saved_session_file_contains_saved_message(world: &mut SessionCommandWorld) { + let session = read_saved_session_file(world); + + assert_saved_session_contains_message(&session, "Remember the whale migration"); +} + +#[then("the active session id should match the saved session file")] +fn active_session_id_matches_saved_session_file(world: &mut SessionCommandWorld) { + let session = read_saved_session_file(world); + let app = world.app.as_deref().expect("app should exist"); + + assert_eq!( + app.current_session_id.as_deref(), + Some(session.metadata.id.as_str()) + ); +} + +#[then("the exported markdown should contain the active transcript")] +fn exported_markdown_contains_active_transcript(world: &mut SessionCommandWorld) { + let export_path = world + .export_path + .as_ref() + .expect("export path should exist"); + let content = std::fs::read_to_string(export_path) + .unwrap_or_else(|err| panic!("read exported transcript {export_path:?}: {err}")); + + assert!(content.contains("# Chat Export")); + assert!(content.contains("**You:**")); + assert!(content.contains("Remember the whale migration")); +} + +#[then("the restored token count should match the saved session")] +fn restored_token_count_matches_saved_session(world: &mut SessionCommandWorld) { + let app = world.app.as_deref().expect("app should exist"); + + assert_eq!(app.session.total_tokens, 321); + assert_eq!(app.session.total_conversation_tokens, 321); +} + +#[then("CodeWhale should report that the session was loaded")] +fn codewhale_reports_session_loaded(world: &mut SessionCommandWorld) { + let message = world + .last_message + .as_deref() + .expect("load command should produce a message"); + + assert!( + message.contains("Session loaded from"), + "unexpected load message: {message}" + ); +} + +#[then("the forked session should reference the original session")] +fn forked_session_references_original_session(world: &mut SessionCommandWorld) { + let original_id = world + .original_session_id + .as_deref() + .expect("original session id should exist"); + let fork_id = world + .fork_session_id + .as_deref() + .expect("fork session id should exist"); + let forked = load_saved_session(world, fork_id); + + assert_eq!( + forked.metadata.parent_session_id.as_deref(), + Some(original_id) + ); + assert_eq!(forked.metadata.forked_from_message_count, Some(1)); +} + +#[then("the original session should still be loadable")] +fn original_session_still_loadable(world: &mut SessionCommandWorld) { + let original_id = world + .original_session_id + .as_deref() + .expect("original session id should exist"); + let original = load_saved_session(world, original_id); + + assert_saved_session_contains_message(&original, "Remember the whale migration"); +} + +#[then("the active session should be the forked session")] +fn active_session_is_forked_session(world: &mut SessionCommandWorld) { + let fork_id = world + .fork_session_id + .as_deref() + .expect("fork session id should exist"); + let app = world.app.as_deref().expect("app should exist"); + + assert_eq!(app.current_session_id.as_deref(), Some(fork_id)); + assert_app_contains_message(app, "Remember the whale migration"); +} + +#[then("CodeWhale should reject the fork because there are no messages")] +fn codewhale_rejects_empty_fork(world: &mut SessionCommandWorld) { + assert_eq!( + world.last_result_is_error, + Some(true), + "last command should have failed" + ); + let message = world + .last_message + .as_deref() + .expect("fork rejection should include a message"); + + assert!( + message.contains("Nothing to fork"), + "unexpected fork rejection message: {message}" + ); +} + +#[then("the active session should be empty")] +fn active_session_empty(world: &mut SessionCommandWorld) { + let app = world.app.as_deref().expect("app should exist"); + + assert!(app.api_messages.is_empty()); + assert_eq!(app.session.total_tokens, 0); + assert_eq!(app.session.total_conversation_tokens, 0); +} + +#[then("the original and forked sessions should remain loadable")] +fn original_and_forked_sessions_remain_loadable(world: &mut SessionCommandWorld) { + let original_id = world + .original_session_id + .as_deref() + .expect("original session id should exist"); + let fork_id = world + .fork_session_id + .as_deref() + .expect("fork session id should exist"); + let original = load_saved_session(world, original_id); + let forked = load_saved_session(world, fork_id); + + assert_saved_session_contains_message(&original, "Remember the whale migration"); + assert_saved_session_contains_message(&forked, "Remember the whale migration"); + assert_eq!( + forked.metadata.parent_session_id.as_deref(), + Some(original_id) + ); +} + +#[then("the active session should be a new empty session")] +fn active_session_is_new_empty_session(world: &mut SessionCommandWorld) { + let original_id = world + .original_session_id + .as_deref() + .expect("original session id should exist"); + let fork_id = world + .fork_session_id + .as_deref() + .expect("fork session id should exist"); + let new_id = world + .new_session_id + .as_deref() + .expect("new session id should exist"); + let app = world.app.as_deref().expect("app should exist"); + + assert_eq!(app.current_session_id.as_deref(), Some(new_id)); + assert_ne!(new_id, original_id); + assert_ne!(new_id, fork_id); + assert!(app.api_messages.is_empty()); + assert_eq!(app.session.total_tokens, 0); +} + +#[then("the active session should be cleared without an active session id")] +fn active_session_cleared_without_active_session_id(world: &mut SessionCommandWorld) { + let app = world.app.as_deref().expect("app should exist"); + + assert!(app.current_session_id.is_none()); + assert!(app.api_messages.is_empty()); + assert_eq!(app.session.total_tokens, 0); +} + +#[then(regex = r#"^the active saved session title should be "([^"]+)"$"#)] +fn active_saved_session_title_should_be(world: &mut SessionCommandWorld, expected: String) { + let app = world.app.as_deref().expect("app should exist"); + let session_id = app + .current_session_id + .as_deref() + .expect("active session id should exist"); + let saved = load_saved_session(world, session_id); + + assert_eq!(saved.metadata.title, expected); +} + +#[then("the active session should be the original session")] +fn active_session_is_original_session(world: &mut SessionCommandWorld) { + let original_id = world + .original_session_id + .as_deref() + .expect("original session id should exist"); + let app = world.app.as_deref().expect("app should exist"); + + assert_eq!(app.current_session_id.as_deref(), Some(original_id)); + assert_app_contains_message(app, "Remember the whale migration"); +} + +#[then("the session picker should be open")] +fn session_picker_should_be_open(world: &mut SessionCommandWorld) { + let app = world.app.as_deref().expect("app should exist"); + + assert_eq!(app.view_stack.top_kind(), Some(ModalKind::SessionPicker)); +} + +#[then("CodeWhale should report that one session was pruned")] +fn codewhale_reports_one_session_pruned(world: &mut SessionCommandWorld) { + let message = world + .last_message + .as_deref() + .expect("prune command should produce a message"); + + assert!( + message.contains("pruned 1 session"), + "unexpected prune message: {message}" + ); +} + +#[then("the fresh session should still be loadable")] +fn fresh_session_still_loadable(world: &mut SessionCommandWorld) { + let fresh_id = world + .fresh_session_id + .as_deref() + .expect("fresh session id should exist"); + let fresh = load_saved_session(world, fresh_id); + + assert_eq!(fresh.metadata.title, "Fresh session"); +} + +#[then("the stale session should no longer be loadable")] +fn stale_session_no_longer_loadable(world: &mut SessionCommandWorld) { + let stale_id = world + .stale_session_id + .as_deref() + .expect("stale session id should exist"); + + assert!( + try_load_saved_session(world, stale_id).is_err(), + "stale session should have been pruned" + ); +} + +#[then("CodeWhale should trigger context compaction")] +fn codewhale_triggers_context_compaction(world: &mut SessionCommandWorld) { + assert_eq!( + world.last_result_is_error, + Some(false), + "compact command should succeed" + ); + assert!(matches!( + world.last_action.as_ref(), + Some(AppAction::CompactContext) + )); + assert_eq!( + world.last_message.as_deref(), + Some("Context compaction triggered...") + ); +} + +#[then("CodeWhale should trigger context purge")] +fn codewhale_triggers_context_purge(world: &mut SessionCommandWorld) { + assert_eq!( + world.last_result_is_error, + Some(false), + "purge command should succeed" + ); + assert!(matches!( + world.last_action.as_ref(), + Some(AppAction::PurgeContext) + )); + assert_eq!( + world.last_message.as_deref(), + Some("Agent context purge triggered...") + ); +} + +#[then(regex = r#"^CodeWhale should send a session relay instruction focused on "([^"]+)"$"#)] +fn codewhale_sends_session_relay_instruction_focused_on( + world: &mut SessionCommandWorld, + focus: String, +) { + assert_eq!( + world.last_result_is_error, + Some(false), + "relay command should succeed" + ); + let message = match world.last_action.as_ref() { + Some(AppAction::SendMessage(message)) => message, + other => panic!("expected relay SendMessage action, got {other:?}"), + }; + + assert!(message.contains("Write or update `.deepseek/handoff.md`.")); + assert!(message.contains("# Session relay")); + assert!( + message.contains(&format!("- Requested relay focus: {focus}")), + "relay instruction should include requested focus: {message}" + ); + assert_eq!( + world.last_message.as_deref(), + Some("Preparing session relay at .deepseek/handoff.md...") + ); +} + +#[then("CodeWhale should reject the unknown session command")] +fn codewhale_rejects_unknown_session_command(world: &mut SessionCommandWorld) { + assert_eq!( + world.last_result_is_error, + Some(true), + "singular /session should be rejected" + ); + let message = world + .last_message + .as_deref() + .expect("unknown command should include a message"); + + assert!( + message.contains("Unknown command: /session"), + "unexpected unknown command message: {message}" + ); + assert!( + message.contains("/sessions") || message.contains("/save"), + "unknown command should include a session-related suggestion: {message}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn save_export_and_load_session_workflow() { + run_scenario(SAVE_LOAD_SCENARIO, 11).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn fork_keeps_original_session_resumable() { + run_scenario(FORK_RESUMABLE_SCENARIO, 5).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn new_session_cannot_be_forked_before_messages_exist() { + run_scenario(NEW_THEN_FORK_SCENARIO, 5).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn cleared_session_cannot_be_forked_before_messages_exist() { + run_scenario(CLEAR_THEN_FORK_SCENARIO, 5).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn fork_followed_by_new_keeps_both_saved_sessions() { + run_scenario(FORK_THEN_NEW_SCENARIO, 5).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn fork_followed_by_clear_keeps_both_saved_sessions() { + run_scenario(FORK_THEN_CLEAR_SCENARIO, 5).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn rename_updates_active_saved_session_title() { + run_scenario(RENAME_SCENARIO, 4).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sessions_list_opens_saved_session_picker() { + run_scenario(SESSIONS_LIST_SCENARIO, 4).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn sessions_prune_removes_only_stale_sessions() { + run_scenario(SESSIONS_PRUNE_SCENARIO, 5).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn context_management_commands_emit_actions_without_clearing_active_session() { + run_scenario(CONTEXT_MANAGEMENT_SCENARIO, 10).await; +} + +#[tokio::test(flavor = "current_thread")] +async fn singular_session_command_is_not_registered() { + run_scenario(SINGULAR_SESSION_SCENARIO, 4).await; +} + +async fn run_scenario(name: &'static str, expected_steps: usize) { + let writer = SessionCommandWorld::cucumber() + .fail_on_skipped() + .with_default_cli() + .filter_run(FEATURE_PATH, move |feature, _, scenario| { + feature.name == FEATURE_NAME && scenario.name == name + }) + .await; + assert_eq!(writer.failed_steps(), 0, "scenario failed: {name}"); + assert_eq!(writer.skipped_steps(), 0, "scenario skipped steps: {name}"); + assert_eq!( + writer.passed_steps(), + expected_steps, + "scenario did not run: {name}" + ); +} + +fn create_test_app_with_tmpdir(tmpdir: &TempDir) -> App { + let options = TuiOptions { + model: "deepseek-v4-pro".to_string(), + workspace: tmpdir.path().to_path_buf(), + config_path: None, + config_profile: None, + allow_shell: false, + use_alt_screen: true, + use_mouse_capture: false, + use_bracketed_paste: true, + max_subagents: 1, + skills_dir: tmpdir.path().join("skills"), + memory_path: tmpdir.path().join("memory.md"), + notes_path: tmpdir.path().join("notes.txt"), + mcp_config_path: tmpdir.path().join("mcp.json"), + use_memory: false, + start_in_agent_mode: false, + skip_onboarding: true, + yolo: false, + resume_session_id: None, + initial_input: None, + }; + App::new(options, &Config::default()) +} + +fn execute_isolated(world: &mut SessionCommandWorld, command: &str) -> CommandResult { + let home = world + .home_path + .as_ref() + .expect("test home should exist") + .clone(); + std::fs::create_dir_all(&home).expect("create isolated test home"); + + let _lock = lock_test_env(); + let _home = EnvVarGuard::set("HOME", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")); + + let app = world.app.as_deref_mut().expect("app should exist"); + commands::user_registry::reload(Some(&app.workspace)); + commands::execute(command, app) +} + +fn remember_result(world: &mut SessionCommandWorld, result: &CommandResult) { + world.last_result_is_error = Some(result.is_error); + world.last_message = result.message.clone(); + world.last_action = result.action.clone(); +} + +fn persist_active_session(world: &SessionCommandWorld) { + let app = world.app.as_deref().expect("app should exist"); + let session_id = app + .current_session_id + .as_ref() + .expect("active session id should exist") + .clone(); + let session = create_saved_session_with_id_and_mode( + session_id, + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + let home = world + .home_path + .as_ref() + .expect("test home should exist") + .clone(); + std::fs::create_dir_all(&home).expect("create isolated test home"); + + let _lock = lock_test_env(); + let _home = EnvVarGuard::set("HOME", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")); + let manager = SessionManager::default_location().expect("open isolated session manager"); + + manager + .save_session(&session) + .expect("persist active session"); +} + +fn persist_session_with_age(world: &SessionCommandWorld, session_id: &str, title: &str, days: i64) { + let app = world.app.as_deref().expect("app should exist"); + let mut session = create_saved_session_with_id_and_mode( + session_id.to_string(), + &app.api_messages, + &app.model, + &app.workspace, + u64::from(app.session.total_tokens), + app.system_prompt.as_ref(), + Some(app.mode.label()), + ); + let timestamp = Utc::now() - ChronoDuration::days(days); + session.metadata.title = title.to_string(); + session.metadata.created_at = timestamp; + session.metadata.updated_at = timestamp; + + let home = world + .home_path + .as_ref() + .expect("test home should exist") + .clone(); + std::fs::create_dir_all(&home).expect("create isolated test home"); + + let _lock = lock_test_env(); + let _home = EnvVarGuard::set("HOME", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")); + let manager = SessionManager::default_location().expect("open isolated session manager"); + + manager.save_session(&session).expect("persist session"); +} + +fn load_saved_session(world: &SessionCommandWorld, session_id: &str) -> SavedSession { + try_load_saved_session(world, session_id) + .unwrap_or_else(|err| panic!("load session {session_id}: {err}")) +} + +fn try_load_saved_session( + world: &SessionCommandWorld, + session_id: &str, +) -> std::io::Result { + let home = world + .home_path + .as_ref() + .expect("test home should exist") + .clone(); + std::fs::create_dir_all(&home).expect("create isolated test home"); + + let _lock = lock_test_env(); + let _home = EnvVarGuard::set("HOME", &home); + let _codewhale_home = EnvVarGuard::set("CODEWHALE_HOME", home.join(".codewhale")); + let manager = SessionManager::default_location().expect("open isolated session manager"); + + manager.load_session(session_id) +} + +fn read_saved_session_file(world: &SessionCommandWorld) -> SavedSession { + let save_path = world.save_path.as_ref().expect("save path should exist"); + let content = std::fs::read_to_string(save_path) + .unwrap_or_else(|err| panic!("read saved session file {save_path:?}: {err}")); + + serde_json::from_str(&content) + .unwrap_or_else(|err| panic!("parse saved session file {save_path:?}: {err}")) +} + +fn assert_app_contains_message(app: &App, expected: &str) { + let message = app + .api_messages + .first() + .expect("active session should contain one message"); + let content = message + .content + .iter() + .find_map(text_content) + .expect("active message should contain text"); + + assert_eq!(message.role, "user"); + assert_eq!(content, expected); +} + +fn assert_saved_session_contains_message(session: &SavedSession, expected: &str) { + let message = session + .messages + .first() + .expect("saved session should contain one message"); + let content = message + .content + .iter() + .find_map(text_content) + .expect("saved message should contain text"); + + assert_eq!(message.role, "user"); + assert_eq!(content, expected); +} + +fn text_content(block: &ContentBlock) -> Option<&str> { + match block { + ContentBlock::Text { text, .. } => Some(text.as_str()), + _ => None, + } +} diff --git a/crates/tui/src/commands/groups/session/compact.rs b/crates/tui/src/commands/groups/session/compact.rs new file mode 100644 index 000000000..f988e8668 --- /dev/null +++ b/crates/tui/src/commands/groups/session/compact.rs @@ -0,0 +1,26 @@ +//! `/compact` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "compact", + aliases: &["yasuo"], + usage: "/compact", + description_id: MessageId::CmdCompactDescription, +}; + +pub(in crate::commands) struct CompactCmd; + +impl RegisterCommand for CompactCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::session::compact(app) + } +} diff --git a/crates/tui/src/commands/groups/session/export.rs b/crates/tui/src/commands/groups/session/export.rs new file mode 100644 index 000000000..7bf1a5304 --- /dev/null +++ b/crates/tui/src/commands/groups/session/export.rs @@ -0,0 +1,26 @@ +//! `/export` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "export", + aliases: &["daochu"], + usage: "/export [path]", + description_id: MessageId::CmdExportDescription, +}; + +pub(in crate::commands) struct ExportCmd; + +impl RegisterCommand for ExportCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::session::export(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/session/fork.rs b/crates/tui/src/commands/groups/session/fork.rs new file mode 100644 index 000000000..11975ae25 --- /dev/null +++ b/crates/tui/src/commands/groups/session/fork.rs @@ -0,0 +1,26 @@ +//! `/fork` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "fork", + aliases: &["branch"], + usage: "/fork", + description_id: MessageId::CmdForkDescription, +}; + +pub(in crate::commands) struct ForkCmd; + +impl RegisterCommand for ForkCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::session::fork(app) + } +} diff --git a/crates/tui/src/commands/groups/session/load.rs b/crates/tui/src/commands/groups/session/load.rs new file mode 100644 index 000000000..03a6cadbe --- /dev/null +++ b/crates/tui/src/commands/groups/session/load.rs @@ -0,0 +1,26 @@ +//! `/load` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "load", + aliases: &["jiazai"], + usage: "/load [path]", + description_id: MessageId::CmdLoadDescription, +}; + +pub(in crate::commands) struct LoadCmd; + +impl RegisterCommand for LoadCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::session::load(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/session/mod.rs b/crates/tui/src/commands/groups/session/mod.rs index c1bd1ea23..4f6a1ae5e 100644 --- a/crates/tui/src/commands/groups/session/mod.rs +++ b/crates/tui/src/commands/groups/session/mod.rs @@ -1,316 +1,72 @@ //! Session command area: saving, forking, resuming, exporting, and the //! `/relay` session-handoff artifact. +#[cfg(all(test, feature = "long-running-tests"))] +mod acceptance; +mod compact; +mod export; +mod fork; +mod load; +mod new; +mod purge; +mod relay; mod rename; +mod save; +mod sessions; // This group dir intentionally has a `session.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)] mod session; -use std::fmt::Write as _; - use crate::commands::CommandResult; -use crate::commands::traits::{Command, CommandGroup, CommandInfo, FunctionCommand}; -use crate::localization::MessageId; -use crate::tui::app::{App, AppAction}; +use crate::commands::traits::{Command, CommandGroup, FunctionCommand, RegisterCommand}; pub struct SessionCommands; impl CommandGroup for SessionCommands { fn commands(&self) -> Vec> { vec![ - Box::new(FunctionCommand::new(&RENAME_INFO, run_rename)), - Box::new(FunctionCommand::new(&SAVE_INFO, run_save)), - Box::new(FunctionCommand::new(&FORK_INFO, run_fork)), - Box::new(FunctionCommand::new(&NEW_INFO, run_new)), - Box::new(FunctionCommand::new(&SESSIONS_INFO, run_sessions)), - Box::new(FunctionCommand::new(&LOAD_INFO, run_load)), - Box::new(FunctionCommand::new(&COMPACT_INFO, run_compact)), - Box::new(FunctionCommand::new(&PURGE_INFO, run_purge)), - Box::new(FunctionCommand::new(&RELAY_INFO, run_relay)), - Box::new(FunctionCommand::new(&EXPORT_INFO, run_export)), + Box::new(FunctionCommand::new( + rename::RenameCmd::info(), + rename::RenameCmd::execute, + )), + Box::new(FunctionCommand::new( + save::SaveCmd::info(), + save::SaveCmd::execute, + )), + Box::new(FunctionCommand::new( + fork::ForkCmd::info(), + fork::ForkCmd::execute, + )), + Box::new(FunctionCommand::new( + new::NewCmd::info(), + new::NewCmd::execute, + )), + Box::new(FunctionCommand::new( + sessions::SessionsCmd::info(), + sessions::SessionsCmd::execute, + )), + Box::new(FunctionCommand::new( + load::LoadCmd::info(), + load::LoadCmd::execute, + )), + Box::new(FunctionCommand::new( + compact::CompactCmd::info(), + compact::CompactCmd::execute, + )), + Box::new(FunctionCommand::new( + purge::PurgeCmd::info(), + purge::PurgeCmd::execute, + )), + Box::new(FunctionCommand::new( + relay::RelayCmd::info(), + relay::RelayCmd::execute, + )), + Box::new(FunctionCommand::new( + export::ExportCmd::info(), + export::ExportCmd::execute, + )), ] } } - -static RENAME_INFO: CommandInfo = CommandInfo { - name: "rename", - aliases: &["gaiming", "chongmingming"], - usage: "/rename ", - description_id: MessageId::CmdRenameDescription, -}; -static SAVE_INFO: CommandInfo = CommandInfo { - name: "save", - aliases: &[], - usage: "/save [path]", - description_id: MessageId::CmdSaveDescription, -}; -static FORK_INFO: CommandInfo = CommandInfo { - name: "fork", - aliases: &["branch"], - usage: "/fork", - description_id: MessageId::CmdForkDescription, -}; -static NEW_INFO: CommandInfo = CommandInfo { - name: "new", - aliases: &[], - usage: "/new [--force]", - description_id: MessageId::CmdNewDescription, -}; -static SESSIONS_INFO: CommandInfo = CommandInfo { - name: "sessions", - aliases: &["resume"], - usage: "/sessions [show|prune ]", - description_id: MessageId::CmdSessionsDescription, -}; -static LOAD_INFO: CommandInfo = CommandInfo { - name: "load", - aliases: &["jiazai"], - usage: "/load [path]", - description_id: MessageId::CmdLoadDescription, -}; -static COMPACT_INFO: CommandInfo = CommandInfo { - name: "compact", - aliases: &["yasuo"], - usage: "/compact", - description_id: MessageId::CmdCompactDescription, -}; -static PURGE_INFO: CommandInfo = CommandInfo { - name: "purge", - aliases: &["qingchu"], - usage: "/purge", - description_id: MessageId::CmdPurgeDescription, -}; -static RELAY_INFO: CommandInfo = CommandInfo { - name: "relay", - aliases: &["batonpass", "接力"], - usage: "/relay [focus]", - description_id: MessageId::CmdRelayDescription, -}; -static EXPORT_INFO: CommandInfo = CommandInfo { - name: "export", - aliases: &["daochu"], - usage: "/export [path]", - description_id: MessageId::CmdExportDescription, -}; - -fn run_registered(app: &mut App, name: &str, arg: Option<&str>) -> CommandResult { - dispatch(app, name, arg).expect("registered session command should dispatch") -} - -fn run_rename(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "rename", arg) -} -fn run_save(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "save", arg) -} -fn run_fork(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "fork", arg) -} -fn run_new(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "new", arg) -} -fn run_sessions(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "sessions", arg) -} -fn run_load(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "load", arg) -} -fn run_compact(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "compact", arg) -} -fn run_purge(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "purge", arg) -} -fn run_relay(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "relay", arg) -} -fn run_export(app: &mut App, arg: Option<&str>) -> CommandResult { - run_registered(app, "export", arg) -} - -pub(in crate::commands) fn dispatch( - app: &mut App, - command: &str, - arg: Option<&str>, -) -> Option { - let result = match command { - "rename" | "gaiming" | "chongmingming" => rename::rename(app, arg), - "save" => session::save(app, arg), - "fork" | "branch" => session::fork(app), - "new" => session::new_session(app, arg), - "sessions" | "resume" => session::sessions(app, arg), - "relay" | "batonpass" | "接力" => relay(app, arg), - "load" | "jiazai" => session::load(app, arg), - "compact" | "yasuo" => session::compact(app), - "purge" | "qingchu" => session::purge(app), - "export" | "daochu" => session::export(app, arg), - _ => return None, - }; - Some(result) -} - -/// Ask the active model to write a compact relay artifact for the next thread. -/// -/// The visible command is `/relay` (with `/接力` for Chinese users), but the -/// durable file path remains `.deepseek/handoff.md` for compatibility with -/// existing sessions and startup prompt loading. -pub fn relay(app: &mut App, arg: Option<&str>) -> CommandResult { - let focus = arg.map(str::trim).filter(|value| !value.is_empty()); - let message = build_relay_instruction(app, focus); - CommandResult::with_message_and_action( - "Preparing session relay at .deepseek/handoff.md...", - AppAction::SendMessage(message), - ) -} - -fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { - let mut out = String::new(); - let _ = writeln!( - out, - "Create a compact session relay (接力) for a future CodeWhale thread." - ); - let _ = writeln!(out); - let _ = writeln!(out, "Write or update `.deepseek/handoff.md`."); - let _ = writeln!( - out, - "Keep the existing file path for compatibility, but title the artifact `# Session relay`." - ); - let _ = writeln!(out); - let _ = writeln!(out, "Current session snapshot:"); - let _ = writeln!(out, "- Workspace: {}", app.workspace.display()); - let _ = writeln!(out, "- Mode: {}", app.mode.label()); - let _ = writeln!(out, "- Model: {}", app.model_display_label()); - if let Some(focus) = focus { - let _ = writeln!(out, "- Requested relay focus: {focus}"); - } - if let Some(quarry) = app.hunt.quarry.as_deref() { - let _ = writeln!(out, "- Goal objective: {quarry}"); - } - if let Some(budget) = app.hunt.token_budget { - let _ = writeln!(out, "- Goal token budget: {budget}"); - } - if let Ok(todos) = app.todos.try_lock() { - let snapshot = todos.snapshot(); - if !snapshot.items.is_empty() { - let _ = writeln!( - out, - "\nWork checklist (primary progress surface, {}% complete):", - snapshot.completion_pct - ); - for item in snapshot.items { - let _ = writeln!( - out, - "- #{} [{}] {}", - item.id, - item.status.as_str(), - item.content - ); - } - } - } else { - let _ = writeln!( - out, - "\nWork checklist: unavailable because the checklist is busy." - ); - } - - if let Ok(plan) = app.plan_state.try_lock() { - let snapshot = plan.snapshot(); - if !snapshot.is_empty() { - let _ = writeln!(out, "\nOptional strategy metadata from update_plan:"); - write_plan_field(&mut out, "Title", snapshot.title.as_deref()); - write_plan_field(&mut out, "Objective", snapshot.objective.as_deref()); - write_plan_field(&mut out, "Context", snapshot.context_summary.as_deref()); - write_plan_field(&mut out, "Explanation", snapshot.explanation.as_deref()); - write_plan_list(&mut out, "Source", &snapshot.sources_used); - write_plan_list(&mut out, "Critical file", &snapshot.critical_files); - write_plan_list(&mut out, "Constraint", &snapshot.constraints); - write_plan_field( - &mut out, - "Recommended approach", - snapshot.recommended_approach.as_deref(), - ); - write_plan_field( - &mut out, - "Verification plan", - snapshot.verification_plan.as_deref(), - ); - write_plan_field( - &mut out, - "Risks and unknowns", - snapshot.risks_and_unknowns.as_deref(), - ); - write_plan_field( - &mut out, - "Handoff packet", - snapshot.handoff_packet.as_deref(), - ); - for item in snapshot.items { - let _ = writeln!(out, "- [{}] {}", plan_status_label(&item.status), item.step); - } - } - } else { - let _ = writeln!( - out, - "\nStrategy metadata: unavailable because plan state is busy." - ); - } - - let _ = writeln!( - out, - "\nBefore writing, inspect the current transcript context and any live tool evidence you need. Do not invent test results, file changes, blockers, or decisions." - ); - let _ = writeln!( - out, - "\nUse this compact structure:\n\ - # Session relay\n\ - \n\ - ## Goal\n\ - [the user's objective and any explicit constraints]\n\ - \n\ - ## Current work\n\ - [the active Work checklist item, progress, and what is mid-flight]\n\ - \n\ - ## Files and state\n\ - [changed files, important paths, sub-agents/RLM sessions, commands run]\n\ - \n\ - ## Decisions\n\ - [why key choices were made]\n\ - \n\ - ## Verification\n\ - [what passed, what failed, what was not run]\n\ - \n\ - ## Next action\n\ - [one concrete action for the next thread]" - ); - let _ = writeln!( - out, - "\nKeep it under about 900 words unless the session genuinely needs more. After writing, report the path and the single next action." - ); - out -} - -fn write_plan_field(out: &mut String, label: &str, value: Option<&str>) { - if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { - let _ = writeln!(out, "- {label}: {value}"); - } -} - -fn write_plan_list(out: &mut String, label: &str, values: &[String]) { - for value in values { - let value = value.trim(); - if !value.is_empty() { - let _ = writeln!(out, "- {label}: {value}"); - } - } -} - -fn plan_status_label(status: &crate::tools::plan::StepStatus) -> &'static str { - match status { - crate::tools::plan::StepStatus::Pending => "pending", - crate::tools::plan::StepStatus::InProgress => "in_progress", - crate::tools::plan::StepStatus::Completed => "completed", - } -} diff --git a/crates/tui/src/commands/groups/session/new.rs b/crates/tui/src/commands/groups/session/new.rs new file mode 100644 index 000000000..c6f56a90d --- /dev/null +++ b/crates/tui/src/commands/groups/session/new.rs @@ -0,0 +1,26 @@ +//! `/new` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "new", + aliases: &[], + usage: "/new [--force]", + description_id: MessageId::CmdNewDescription, +}; + +pub(in crate::commands) struct NewCmd; + +impl RegisterCommand for NewCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::session::new_session(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/session/purge.rs b/crates/tui/src/commands/groups/session/purge.rs new file mode 100644 index 000000000..e13fc4205 --- /dev/null +++ b/crates/tui/src/commands/groups/session/purge.rs @@ -0,0 +1,26 @@ +//! `/purge` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "purge", + aliases: &["qingchu"], + usage: "/purge", + description_id: MessageId::CmdPurgeDescription, +}; + +pub(in crate::commands) struct PurgeCmd; + +impl RegisterCommand for PurgeCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, _arg: Option<&str>) -> CommandResult { + super::session::purge(app) + } +} diff --git a/crates/tui/src/commands/groups/session/relay.rs b/crates/tui/src/commands/groups/session/relay.rs new file mode 100644 index 000000000..d735de3e0 --- /dev/null +++ b/crates/tui/src/commands/groups/session/relay.rs @@ -0,0 +1,192 @@ +//! `/relay` command. + +use std::fmt::Write as _; + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::{App, AppAction}; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "relay", + aliases: &["batonpass", "接力"], + usage: "/relay [focus]", + description_id: MessageId::CmdRelayDescription, +}; + +pub(in crate::commands) struct RelayCmd; + +impl RegisterCommand for RelayCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + relay(app, arg) + } +} + +/// Ask the active model to write a compact relay artifact for the next thread. +/// +/// The visible command is `/relay` (with `/接力` for Chinese users), but the +/// durable file path remains `.deepseek/handoff.md` for compatibility with +/// existing sessions and startup prompt loading. +pub fn relay(app: &mut App, arg: Option<&str>) -> CommandResult { + let focus = arg.map(str::trim).filter(|value| !value.is_empty()); + let message = build_relay_instruction(app, focus); + CommandResult::with_message_and_action( + "Preparing session relay at .deepseek/handoff.md...", + AppAction::SendMessage(message), + ) +} + +fn build_relay_instruction(app: &App, focus: Option<&str>) -> String { + let mut out = String::new(); + let _ = writeln!( + out, + "Create a compact session relay (接力) for a future CodeWhale thread." + ); + let _ = writeln!(out); + let _ = writeln!(out, "Write or update `.deepseek/handoff.md`."); + let _ = writeln!( + out, + "Keep the existing file path for compatibility, but title the artifact `# Session relay`." + ); + let _ = writeln!(out); + let _ = writeln!(out, "Current session snapshot:"); + let _ = writeln!(out, "- Workspace: {}", app.workspace.display()); + let _ = writeln!(out, "- Mode: {}", app.mode.label()); + let _ = writeln!(out, "- Model: {}", app.model_display_label()); + if let Some(focus) = focus { + let _ = writeln!(out, "- Requested relay focus: {focus}"); + } + if let Some(quarry) = app.hunt.quarry.as_deref() { + let _ = writeln!(out, "- Goal objective: {quarry}"); + } + if let Some(budget) = app.hunt.token_budget { + let _ = writeln!(out, "- Goal token budget: {budget}"); + } + if let Ok(todos) = app.todos.try_lock() { + let snapshot = todos.snapshot(); + if !snapshot.items.is_empty() { + let _ = writeln!( + out, + "\nWork checklist (primary progress surface, {}% complete):", + snapshot.completion_pct + ); + for item in snapshot.items { + let _ = writeln!( + out, + "- #{} [{}] {}", + item.id, + item.status.as_str(), + item.content + ); + } + } + } else { + let _ = writeln!( + out, + "\nWork checklist: unavailable because the checklist is busy." + ); + } + + if let Ok(plan) = app.plan_state.try_lock() { + let snapshot = plan.snapshot(); + if !snapshot.is_empty() { + let _ = writeln!(out, "\nOptional strategy metadata from update_plan:"); + write_plan_field(&mut out, "Title", snapshot.title.as_deref()); + write_plan_field(&mut out, "Objective", snapshot.objective.as_deref()); + write_plan_field(&mut out, "Context", snapshot.context_summary.as_deref()); + write_plan_field(&mut out, "Explanation", snapshot.explanation.as_deref()); + write_plan_list(&mut out, "Source", &snapshot.sources_used); + write_plan_list(&mut out, "Critical file", &snapshot.critical_files); + write_plan_list(&mut out, "Constraint", &snapshot.constraints); + write_plan_field( + &mut out, + "Recommended approach", + snapshot.recommended_approach.as_deref(), + ); + write_plan_field( + &mut out, + "Verification plan", + snapshot.verification_plan.as_deref(), + ); + write_plan_field( + &mut out, + "Risks and unknowns", + snapshot.risks_and_unknowns.as_deref(), + ); + write_plan_field( + &mut out, + "Handoff packet", + snapshot.handoff_packet.as_deref(), + ); + for item in snapshot.items { + let _ = writeln!(out, "- [{}] {}", plan_status_label(&item.status), item.step); + } + } + } else { + let _ = writeln!( + out, + "\nStrategy metadata: unavailable because plan state is busy." + ); + } + + let _ = writeln!( + out, + "\nBefore writing, inspect the current transcript context and any live tool evidence you need. Do not invent test results, file changes, blockers, or decisions." + ); + let _ = writeln!( + out, + "\nUse this compact structure:\n\ + # Session relay\n\ + \n\ + ## Goal\n\ + [the user's objective and any explicit constraints]\n\ + \n\ + ## Current work\n\ + [the active Work checklist item, progress, and what is mid-flight]\n\ + \n\ + ## Files and state\n\ + [changed files, important paths, sub-agents/RLM sessions, commands run]\n\ + \n\ + ## Decisions\n\ + [why key choices were made]\n\ + \n\ + ## Verification\n\ + [what passed, what failed, what was not run]\n\ + \n\ + ## Next action\n\ + [one concrete action for the next thread]" + ); + let _ = writeln!( + out, + "\nKeep it under about 900 words unless the session genuinely needs more. After writing, report the path and the single next action." + ); + out +} + +fn write_plan_field(out: &mut String, label: &str, value: Option<&str>) { + if let Some(value) = value.map(str::trim).filter(|value| !value.is_empty()) { + let _ = writeln!(out, "- {label}: {value}"); + } +} + +fn write_plan_list(out: &mut String, label: &str, values: &[String]) { + for value in values { + let value = value.trim(); + if !value.is_empty() { + let _ = writeln!(out, "- {label}: {value}"); + } + } +} + +fn plan_status_label(status: &crate::tools::plan::StepStatus) -> &'static str { + match status { + crate::tools::plan::StepStatus::Pending => "pending", + crate::tools::plan::StepStatus::InProgress => "in_progress", + crate::tools::plan::StepStatus::Completed => "completed", + } +} diff --git a/crates/tui/src/commands/groups/session/rename.rs b/crates/tui/src/commands/groups/session/rename.rs index e551cf61b..0bd54d83a 100644 --- a/crates/tui/src/commands/groups/session/rename.rs +++ b/crates/tui/src/commands/groups/session/rename.rs @@ -1,5 +1,7 @@ //! `/rename` command — set a custom title for the current session. +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; use crate::session_manager::{SessionManager, update_session}; use crate::tui::app::App; @@ -7,6 +9,25 @@ use super::CommandResult; const MAX_TITLE_LEN: usize = 100; +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "rename", + aliases: &["gaiming", "chongmingming"], + usage: "/rename ", + description_id: MessageId::CmdRenameDescription, +}; + +pub(in crate::commands) struct RenameCmd; + +impl RegisterCommand for RenameCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + rename(app, arg) + } +} + /// Rename the current session to the given title. /// /// Usage: `/rename ` diff --git a/crates/tui/src/commands/groups/session/save.rs b/crates/tui/src/commands/groups/session/save.rs new file mode 100644 index 000000000..fbf589f57 --- /dev/null +++ b/crates/tui/src/commands/groups/session/save.rs @@ -0,0 +1,26 @@ +//! `/save` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "save", + aliases: &[], + usage: "/save [path]", + description_id: MessageId::CmdSaveDescription, +}; + +pub(in crate::commands) struct SaveCmd; + +impl RegisterCommand for SaveCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::session::save(app, arg) + } +} diff --git a/crates/tui/src/commands/groups/session/sessions.rs b/crates/tui/src/commands/groups/session/sessions.rs new file mode 100644 index 000000000..d5f37b934 --- /dev/null +++ b/crates/tui/src/commands/groups/session/sessions.rs @@ -0,0 +1,26 @@ +//! `/sessions` command. + +use crate::commands::traits::{CommandInfo, RegisterCommand}; +use crate::localization::MessageId; +use crate::tui::app::App; + +use super::CommandResult; + +pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { + name: "sessions", + aliases: &["resume"], + usage: "/sessions [show|prune ]", + description_id: MessageId::CmdSessionsDescription, +}; + +pub(in crate::commands) struct SessionsCmd; + +impl RegisterCommand for SessionsCmd { + fn info() -> &'static CommandInfo { + &COMMAND_INFO + } + + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult { + super::session::sessions(app, arg) + } +} diff --git a/crates/tui/src/commands/traits.rs b/crates/tui/src/commands/traits.rs index ec041f29b..fc5fafdf1 100644 --- a/crates/tui/src/commands/traits.rs +++ b/crates/tui/src/commands/traits.rs @@ -53,6 +53,15 @@ pub trait CommandGroup: Send + Sync { pub(crate) type CommandHandler = fn(&mut App, Option<&str>) -> CommandResult; +/// Trait implemented by focused built-in command modules. +/// +/// A command module owns its metadata and exposes a static execution function +/// that the group registry can wire into [`FunctionCommand`]. +pub trait RegisterCommand { + fn info() -> &'static CommandInfo; + fn execute(app: &mut App, arg: Option<&str>) -> CommandResult; +} + pub(crate) struct FunctionCommand { info: &'static CommandInfo, handler: CommandHandler, diff --git a/crates/tui/tests/core_session_command_extraction.rs b/crates/tui/tests/core_session_command_extraction.rs new file mode 100644 index 000000000..a2d8bf9bf --- /dev/null +++ b/crates/tui/tests/core_session_command_extraction.rs @@ -0,0 +1,163 @@ +//! Gherkin binary health and eval harness smoke test for command extraction. +//! +//! This runs the binary through `codewhale-tui eval` and verifies that the +//! executable still loads and reports a successful JSON evaluation after the +//! core/session command modules are extracted. + +use std::path::PathBuf; +use std::process::Command; + +use cucumber::{World as _, given, then, when, writer::Stats as _}; +use serde_json::Value; +use tempfile::TempDir; + +const FEATURE_NAME: &str = "Core and session command extraction"; +const FEATURE_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/features/core_session_command_extraction.feature" +); +const CORE_SCENARIO: &str = "The binary loads and runs the evaluation harness after extraction"; + +#[derive(Debug, Default, cucumber::World)] +struct CoreSessionExtractionWorld { + record_dir: Option, + report: Option, +} + +#[given("a clean CodeWhale evaluation workspace")] +fn clean_codewhale_evaluation_workspace(world: &mut CoreSessionExtractionWorld) { + world.record_dir = Some(TempDir::new().expect("evaluation TempDir")); +} + +#[when("the evaluation harness runs a shell command")] +fn eval_harness_runs_shell_command(world: &mut CoreSessionExtractionWorld) { + let record_dir = world + .record_dir + .as_ref() + .expect("evaluation workspace should exist"); + + let output = Command::new(codewhale_tui_binary()) + .args([ + "eval", + "--json", + "--shell-command", + "echo eval-harness", + "--record", + ]) + .arg(record_dir.path()) + .output() + .expect("codewhale-tui eval should start"); + + assert!( + output.status.success(), + "codewhale-tui eval failed\nstderr:\n{}", + String::from_utf8_lossy(&output.stderr) + ); + + let report: Value = serde_json::from_slice(&output.stdout).unwrap_or_else(|err| { + panic!( + "eval --json should emit valid JSON: {err}\nstdout:\n{}", + String::from_utf8_lossy(&output.stdout) + ) + }); + + world.report = Some(report); +} + +#[then("the harness completes successfully")] +fn harness_completes_successfully(world: &mut CoreSessionExtractionWorld) { + let report = world.report.as_ref().expect("eval report should exist"); + + let success = report + .get("metrics") + .and_then(|metrics| metrics.get("success")) + .and_then(|value| value.as_bool()) + .unwrap_or(false); + assert!( + success, + "eval report 'metrics.success' should be true, got: {report:?}" + ); +} + +#[then("the JSON report contains a step with the expected kind")] +fn json_report_contains_step_with_expected_kind(world: &mut CoreSessionExtractionWorld) { + let report = world.report.as_ref().expect("eval report should exist"); + + let steps = report + .get("steps") + .and_then(|value| value.as_array()) + .expect("eval report should have a 'steps' array"); + + assert!( + !steps.is_empty(), + "eval report should have at least one step" + ); + + let first_step = &steps[0]; + let kind = first_step + .get("kind") + .and_then(|value| value.as_str()) + .expect("step should have a 'kind' field"); + + assert_eq!( + kind, "List", + "first step kind should be 'List', got: {kind}" + ); + + let step_success = first_step + .get("success") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + assert!( + step_success, + "first step 'success' should be true, got: {first_step:?}" + ); + + let output = first_step + .get("output") + .and_then(|value| value.as_str()) + .unwrap_or(""); + assert!( + !output.is_empty(), + "step output should not be empty: {first_step:?}" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn codewhale_eval_runs_after_extraction() { + let writer = CoreSessionExtractionWorld::cucumber() + .fail_on_skipped() + .with_default_cli() + .filter_run(FEATURE_PATH, move |feature, _, scenario| { + feature.name == FEATURE_NAME && scenario.name == CORE_SCENARIO + }) + .await; + assert_eq!(writer.failed_steps(), 0, "scenario failed: {CORE_SCENARIO}"); + assert_eq!( + writer.skipped_steps(), + 0, + "scenario skipped steps: {CORE_SCENARIO}" + ); + assert_eq!( + writer.passed_steps(), + 4, + "scenario did not run: {CORE_SCENARIO}" + ); +} + +fn codewhale_tui_binary() -> PathBuf { + if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale-tui") { + return PathBuf::from(path); + } + if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale-tui") { + return PathBuf::from(path); + } + + let mut path = std::env::current_exe().expect("current test executable path"); + path.pop(); + if path.ends_with("deps") { + path.pop(); + } + path.push(format!("codewhale-tui{}", std::env::consts::EXE_SUFFIX)); + path +} diff --git a/crates/tui/tests/epic_acceptance_harness.rs b/crates/tui/tests/epic_acceptance_harness.rs new file mode 100644 index 000000000..74e6e307a --- /dev/null +++ b/crates/tui/tests/epic_acceptance_harness.rs @@ -0,0 +1,51 @@ +//! EPIC acceptance harness smoke test. +//! +//! Proves that the Gherkin/Cucumber infrastructure is available and functional +//! on the target branch. + +use cucumber::{World as _, given, then, when, writer::Stats as _}; + +const FEATURE_NAME: &str = "EPIC acceptance harness"; +const FEATURE_PATH: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/features/epic_acceptance_harness.feature" +); +const SMOKE_SCENARIO: &str = "Gherkin acceptance tests can run on the target branch"; + +#[derive(Debug, Default, cucumber::World)] +struct EpicAcceptanceWorld; + +#[given("the acceptance harness is available")] +fn acceptance_harness_available(_world: &mut EpicAcceptanceWorld) {} + +#[when("the runner discovers EPIC scenarios")] +fn runner_discovers_epic_scenarios(_world: &mut EpicAcceptanceWorld) {} + +#[then("the runner exits successfully")] +fn runner_exits_successfully(_world: &mut EpicAcceptanceWorld) {} + +#[tokio::test(flavor = "current_thread")] +async fn acceptance_harness_smoke_test() { + let writer = EpicAcceptanceWorld::cucumber() + .fail_on_skipped() + .with_default_cli() + .filter_run(FEATURE_PATH, move |feature, _, scenario| { + feature.name == FEATURE_NAME && scenario.name == SMOKE_SCENARIO + }) + .await; + assert_eq!( + writer.failed_steps(), + 0, + "scenario failed: {SMOKE_SCENARIO}" + ); + assert_eq!( + writer.skipped_steps(), + 0, + "scenario skipped steps: {SMOKE_SCENARIO}" + ); + assert_eq!( + writer.passed_steps(), + 3, + "scenario did not run: {SMOKE_SCENARIO}" + ); +} diff --git a/crates/tui/tests/features/core_command_surfaces.feature b/crates/tui/tests/features/core_command_surfaces.feature new file mode 100644 index 000000000..69c52aaa5 --- /dev/null +++ b/crates/tui/tests/features/core_command_surfaces.feature @@ -0,0 +1,42 @@ +@long-running +# [LONG RUNNING] Opt-in core command acceptance workflows. Run with: +# cargo test -p codewhale-tui --bin codewhale-tui --features long-running-tests commands::groups::core::acceptance -- --test-threads=1 +Feature: Core command visible surfaces + + Scenario: Core informational commands write visible transcript messages + Given a CodeWhale core command workspace + When the user runs the core command "/help links" + Then the message window should include "Usage: /links" + And the message window should include "Aliases: dashboard, api" + When the user runs the core command "/links" + Then the message window should include "https://platform.deepseek.com" + When the user runs the core command "/workspace" + Then the message window should include "Current workspace:" + When the user runs the core command "/home" + Then the message window should include "codewhale Home Dashboard" + And the message window should include "/links" + + Scenario: Core state commands report visible changes + Given a CodeWhale core command workspace + When the user runs the core command "/model auto" + Then the message window should include "Model changed:" + And the message window should include "auto" + When the user runs the core command "/translate" + Then the message window should include "Output translation enabled" + When the user runs the core command "/translate" + Then the message window should include "Output translation disabled" + + Scenario: Clear replaces prior transcript with visible confirmation + Given a CodeWhale core command workspace with one visible user message + When the user runs the core command "/clear" + Then the message window should include "Conversation cleared" + And the message window should not include "Remember the whale migration" + + Scenario: Persistent work commands report visible dispatch requests + Given a CodeWhale core command workspace + When the user runs the core command "/agent 2 summarize logs" + Then the message window should include "Opening persistent sub-agent at depth 2" + When the user runs the core command "/rlm 1 inspect command extraction" + Then the message window should include "Opening persistent RLM context at depth 1" + When the user runs the core command "/swarm 2 audit commands" + Then the message window should include "/swarm is gated" diff --git a/crates/tui/tests/features/core_session_command_extraction.feature b/crates/tui/tests/features/core_session_command_extraction.feature new file mode 100644 index 000000000..a4cfb20a9 --- /dev/null +++ b/crates/tui/tests/features/core_session_command_extraction.feature @@ -0,0 +1,7 @@ +Feature: Core and session command extraction + + Scenario: The binary loads and runs the evaluation harness after extraction + Given a clean CodeWhale evaluation workspace + When the evaluation harness runs a shell command + Then the harness completes successfully + And the JSON report contains a step with the expected kind diff --git a/crates/tui/tests/features/epic_acceptance_harness.feature b/crates/tui/tests/features/epic_acceptance_harness.feature new file mode 100644 index 000000000..af694f79e --- /dev/null +++ b/crates/tui/tests/features/epic_acceptance_harness.feature @@ -0,0 +1,6 @@ +Feature: EPIC acceptance harness + + Scenario: Gherkin acceptance tests can run on the target branch + Given the acceptance harness is available + When the runner discovers EPIC scenarios + Then the runner exits successfully diff --git a/crates/tui/tests/features/session_command_workflows.feature b/crates/tui/tests/features/session_command_workflows.feature new file mode 100644 index 000000000..fccd44f36 --- /dev/null +++ b/crates/tui/tests/features/session_command_workflows.feature @@ -0,0 +1,89 @@ +@long-running +# [LONG RUNNING] Opt-in acceptance workflows. Run with: +# cargo test -p codewhale-tui --bin codewhale-tui --features long-running-tests commands::groups::session::acceptance -- --test-threads=1 +Feature: Session command workflows + + Scenario: Save, export, and load preserve the active session + Given a CodeWhale session workspace with one user message + When the user saves the active session + And the user exports the active transcript + And the user clears the active conversation + And the user loads the saved session + Then the saved session file should contain the saved message + And the active session id should match the saved session file + And the exported markdown should contain the active transcript + And the active session should contain the saved message + And the restored token count should match the saved session + And CodeWhale should report that the session was loaded + + Scenario: Fork keeps the original session resumable + Given a CodeWhale persisted session workspace with one user message + When the user forks the active session + Then the forked session should reference the original session + And the original session should still be loadable + And the active session should be the forked session + + Scenario: New session cannot be forked before messages exist + Given a CodeWhale session workspace with one user message + When the user starts a new session + And the user tries to fork the active session + Then CodeWhale should reject the fork because there are no messages + And the active session should be empty + + Scenario: Cleared session cannot be forked before messages exist + Given a CodeWhale session workspace with one user message + When the user clears the active conversation + And the user tries to fork the active session + Then CodeWhale should reject the fork because there are no messages + And the active session should be empty + + Scenario: Fork followed by new keeps both saved sessions + Given a CodeWhale persisted session workspace with one user message + When the user forks the active session + And the user starts a new session + Then the original and forked sessions should remain loadable + And the active session should be a new empty session + + Scenario: Fork followed by clear keeps both saved sessions + Given a CodeWhale persisted session workspace with one user message + When the user forks the active session + And the user clears the active conversation + Then the original and forked sessions should remain loadable + And the active session should be cleared without an active session id + + Scenario: Rename updates the active saved session title + Given a CodeWhale persisted session workspace with one user message + When the user renames the active session to "Renamed whale path" + Then the active saved session title should be "Renamed whale path" + And the active session should be the original session + + Scenario: Sessions list opens the saved session picker + Given a CodeWhale persisted session workspace with one user message + When the user lists saved sessions + Then the session picker should be open + And the original session should still be loadable + + Scenario: Sessions prune removes only stale sessions + Given a CodeWhale session workspace with stale and fresh saved sessions + When the user prunes sessions older than 7 days + Then CodeWhale should report that one session was pruned + And the fresh session should still be loadable + And the stale session should no longer be loadable + + Scenario: Context management commands emit actions without clearing the active session + Given a CodeWhale session workspace with one user message + When the user compacts context + Then CodeWhale should trigger context compaction + And the active session should contain the saved message + When the user purges context + Then CodeWhale should trigger context purge + And the active session should contain the saved message + When the user prepares a session relay focused on "handoff details" + Then CodeWhale should send a session relay instruction focused on "handoff details" + And the active session should contain the saved message + + Scenario: Singular session command is not registered + Given a CodeWhale session workspace with one user message + When the user runs the singular session command + Then CodeWhale should reject the unknown session command + And the active session should contain the saved message From 432164c067b910fa756e917c733993bbc98a9607 Mon Sep 17 00:00:00 2001 From: Paulo Aboim Pinto Date: Fri, 19 Jun 2026 12:36:28 +0200 Subject: [PATCH 2/2] fix(commands): use profile description metadata --- crates/tui/src/commands/groups/core/profile.rs | 2 +- crates/tui/src/localization.rs | 8 ++++++++ 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/tui/src/commands/groups/core/profile.rs b/crates/tui/src/commands/groups/core/profile.rs index d5202650d..deef2f723 100644 --- a/crates/tui/src/commands/groups/core/profile.rs +++ b/crates/tui/src/commands/groups/core/profile.rs @@ -10,7 +10,7 @@ pub(in crate::commands) const COMMAND_INFO: CommandInfo = CommandInfo { name: "profile", aliases: &["dangan"], usage: "/profile ", - description_id: MessageId::CmdHelpDescription, + description_id: MessageId::CmdProfileDescription, }; pub(in crate::commands) struct ProfileCmd; diff --git a/crates/tui/src/localization.rs b/crates/tui/src/localization.rs index c83664a46..ec788be1c 100644 --- a/crates/tui/src/localization.rs +++ b/crates/tui/src/localization.rs @@ -302,6 +302,7 @@ pub enum MessageId { CmdFeedbackDescription, CmdHfDescription, CmdHelpDescription, + CmdProfileDescription, CmdHomeDescription, CmdHooksDescription, CmdAgentDescription, @@ -742,6 +743,7 @@ pub const ALL_MESSAGE_IDS: &[MessageId] = &[ MessageId::CmdFeedbackDescription, MessageId::CmdHfDescription, MessageId::CmdHelpDescription, + MessageId::CmdProfileDescription, MessageId::CmdHomeDescription, MessageId::CmdHooksDescription, MessageId::CmdAgentDescription, @@ -1380,6 +1382,7 @@ fn english(id: MessageId) -> &'static str { MessageId::CmdFeedbackDescription => "Generate a GitHub feedback URL", MessageId::CmdHfDescription => "Inspect Hugging Face MCP setup and concepts", MessageId::CmdHelpDescription => "Show help information", + MessageId::CmdProfileDescription => "Switch to a named config profile", MessageId::CmdHomeDescription => "Show home dashboard with stats and quick actions", MessageId::CmdHooksDescription => "List configured lifecycle hooks (read-only)", MessageId::CmdAgentDescription => { @@ -1985,6 +1988,7 @@ fn vietnamese(id: MessageId) -> Option<&'static str> { MessageId::CmdFeedbackDescription => "Tạo một URL để gửi phản hồi trên GitHub", MessageId::CmdHfDescription => "Kiểm tra thiết lập và khái niệm Hugging Face MCP", MessageId::CmdHelpDescription => "Hiển thị thông tin trợ giúp", + MessageId::CmdProfileDescription => "Chuyển sang profile cấu hình đã đặt tên", MessageId::CmdHomeDescription => { "Hiển thị bảng điều khiển trang chủ với số liệu thống kê và hành động nhanh" } @@ -2794,6 +2798,7 @@ fn japanese(id: MessageId) -> Option<&'static str> { MessageId::CmdFeedbackDescription => "GitHub フィードバック URL を生成", MessageId::CmdHfDescription => "Hugging Face MCP の設定と概念を確認", MessageId::CmdHelpDescription => "ヘルプを表示", + MessageId::CmdProfileDescription => "名前付き設定プロファイルに切り替え", MessageId::CmdHomeDescription => "統計とクイックアクション付きのホームダッシュボードを表示", MessageId::CmdHooksDescription => { "設定済みのライフサイクルフックを一覧表示(読み取り専用)" @@ -3380,6 +3385,7 @@ fn chinese_simplified(id: MessageId) -> Option<&'static str> { MessageId::CmdFeedbackDescription => "生成 GitHub 反馈链接", MessageId::CmdHfDescription => "检查 Hugging Face MCP 设置和概念", MessageId::CmdHelpDescription => "显示帮助信息", + MessageId::CmdProfileDescription => "切换到命名配置配置文件", MessageId::CmdHomeDescription => "显示主页面板,含统计与快捷操作", MessageId::CmdHooksDescription => "列出已配置的生命周期钩子(只读)", MessageId::CmdAgentDescription => "打开持久子代理会话:/agent [0-3] ", @@ -3916,6 +3922,7 @@ fn portuguese_brazil(id: MessageId) -> Option<&'static str> { MessageId::CmdFeedbackDescription => "Gerar uma URL de feedback no GitHub", MessageId::CmdHfDescription => "Inspecionar configuracao e conceitos do Hugging Face MCP", MessageId::CmdHelpDescription => "Exibir informações de ajuda", + MessageId::CmdProfileDescription => "Alternar para um perfil de configuracao nomeado", MessageId::CmdHomeDescription => "Exibir o painel inicial com estatísticas e ações rápidas", MessageId::CmdHooksDescription => { "Listar hooks de ciclo de vida configurados (somente leitura)" @@ -4538,6 +4545,7 @@ fn spanish_latin_america(id: MessageId) -> Option<&'static str> { MessageId::CmdFeedbackDescription => "Generar una URL de feedback en GitHub", MessageId::CmdHfDescription => "Inspeccionar configuracion y conceptos de Hugging Face MCP", MessageId::CmdHelpDescription => "Mostrar información de ayuda", + MessageId::CmdProfileDescription => "Cambiar a un perfil de configuración con nombre", MessageId::CmdHomeDescription => { "Mostrar el panel inicial con estadísticas y acciones rápidas" }