Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions crates/tui/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
198 changes: 198 additions & 0 deletions crates/tui/src/commands/groups/core/acceptance.rs
Original file line number Diff line number Diff line change
@@ -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<TempDir>,
app: Option<Box<App>>,
home_path: Option<std::path::PathBuf>,
last_message: Option<String>,
last_result_is_error: Option<bool>,
}

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::<Vec<_>>()
.join("\n")
}
49 changes: 49 additions & 0 deletions crates/tui/src/commands/groups/core/agent.rs
Original file line number Diff line number Diff line change
@@ -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] <task>",
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] <task>\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),
)
}
24 changes: 23 additions & 1 deletion crates/tui/src/commands/groups/core/anchor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 <text> | /anchor list | /anchor remove <n>";

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 <text>` — add a new anchor
/// - `/anchor list` — list all anchors
Expand Down
26 changes: 26 additions & 0 deletions crates/tui/src/commands/groups/core/clear.rs
Original file line number Diff line number Diff line change
@@ -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)
}
}
26 changes: 26 additions & 0 deletions crates/tui/src/commands/groups/core/exit.rs
Original file line number Diff line number Diff line change
@@ -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()
}
}
21 changes: 21 additions & 0 deletions crates/tui/src/commands/groups/core/feedback.rs
Original file line number Diff line number Diff line change
@@ -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() {
Expand Down
Loading