From 8b3bef58494795cc030a4bfb5f776858fff7d9f1 Mon Sep 17 00:00:00 2001 From: zenobit Date: Sun, 26 Apr 2026 00:26:54 +0200 Subject: [PATCH] feat: Mouse interaction support --- .../src/adapters/daemon/handlers/input.rs | 64 +++++++++ .../src/adapters/daemon/router.rs | 12 ++ .../src/adapters/daemon/router_tests.rs | 16 +++ .../src/adapters/daemon/usecase_container.rs | 12 ++ .../src/adapters/rpc/mod.rs | 75 +++++++++++ .../src/adapters/rpc/mod_tests.rs | 87 ++++++++++++ .../src/adapters/rpc/params.rs | 22 ++++ .../src/adapters/rpc/types.rs | 14 ++ cli/crates/agent-tui-app/src/app/commands.rs | 45 +++++++ cli/crates/agent-tui-app/src/app/handlers.rs | 78 +++++++++++ cli/crates/agent-tui-app/src/app/mod.rs | 14 ++ .../agent-tui-domain/src/domain/types.rs | 124 ++++++++++++++++++ .../src/domain/types_tests.rs | 85 ++++++++++++ .../src/infra/daemon/repository.rs | 20 +++ .../src/infra/daemon/session.rs | 66 ++++++++++ .../agent-tui-usecases/src/usecases/input.rs | 104 +++++++++++++++ .../src/usecases/input_mouse_tests.rs | 124 ++++++++++++++++++ .../src/usecases/input_tests.rs | 88 +++++++++++++ .../agent-tui-usecases/src/usecases/mod.rs | 11 ++ .../src/usecases/ports/session_repository.rs | 4 + .../ports/test_support/mock_session.rs | 26 ++++ .../src/usecases/wait_tests.rs | 16 +++ cli/crates/agent-tui/tests/mouse_e2e.rs | 104 +++++++++++++++ docs/cli/agent-tui.md | 93 +++++++++++++ skills/agent-tui/SKILL.md | 4 +- skills/agent-tui/references/command-atlas.md | 6 + 26 files changed, 1312 insertions(+), 2 deletions(-) create mode 100644 cli/crates/agent-tui-usecases/src/usecases/input_mouse_tests.rs create mode 100644 cli/crates/agent-tui/tests/mouse_e2e.rs diff --git a/cli/crates/agent-tui-adapters/src/adapters/daemon/handlers/input.rs b/cli/crates/agent-tui-adapters/src/adapters/daemon/handlers/input.rs index 0fe7c12..de0ca20 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/daemon/handlers/input.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/daemon/handlers/input.rs @@ -9,10 +9,18 @@ use crate::adapters::parse_keydown_input; use crate::adapters::parse_keystroke_input; use crate::adapters::parse_keyup_input; use crate::adapters::parse_type_input; +use crate::adapters::parse_mouse_click_input; +use crate::adapters::parse_mouse_move_input; +use crate::adapters::parse_mouse_down_input; +use crate::adapters::parse_mouse_up_input; use crate::usecases::KeydownUseCase; use crate::usecases::KeystrokeUseCase; use crate::usecases::KeyupUseCase; use crate::usecases::TypeUseCase; +use crate::usecases::MouseClickUseCase; +use crate::usecases::MouseMoveUseCase; +use crate::usecases::MouseDownUseCase; +use crate::usecases::MouseUpUseCase; pub fn handle_keystroke_uc(usecase: &U, request: RpcRequest) -> RpcResponse { let _span = common::handler_span(&request, "keystroke").entered(); @@ -69,3 +77,59 @@ pub fn handle_keyup_uc(usecase: &U, request: RpcRequest) -> Rpc Err(e) => session_error_response(req_id, e), } } + +pub fn handle_mouse_click_uc(usecase: &U, request: RpcRequest) -> RpcResponse { + let _span = common::handler_span(&request, "mouse_click").entered(); + let req_id = request.id; + let input = match parse_mouse_click_input(&request) { + Ok(i) => i, + Err(resp) => return resp, + }; + + match usecase.execute(input) { + Ok(_) => RpcResponse::action_success(req_id), + Err(e) => session_error_response(req_id, e), + } +} + +pub fn handle_mouse_move_uc(usecase: &U, request: RpcRequest) -> RpcResponse { + let _span = common::handler_span(&request, "mouse_move").entered(); + let req_id = request.id; + let input = match parse_mouse_move_input(&request) { + Ok(i) => i, + Err(resp) => return resp, + }; + + match usecase.execute(input) { + Ok(_) => RpcResponse::action_success(req_id), + Err(e) => session_error_response(req_id, e), + } +} + +pub fn handle_mouse_down_uc(usecase: &U, request: RpcRequest) -> RpcResponse { + let _span = common::handler_span(&request, "mouse_down").entered(); + let req_id = request.id; + let input = match parse_mouse_down_input(&request) { + Ok(i) => i, + Err(resp) => return resp, + }; + + match usecase.execute(input) { + Ok(_) => RpcResponse::action_success(req_id), + Err(e) => session_error_response(req_id, e), + } +} + +pub fn handle_mouse_up_uc(usecase: &U, request: RpcRequest) -> RpcResponse { + let _span = common::handler_span(&request, "mouse_up").entered(); + let req_id = request.id; + let input = match parse_mouse_up_input(&request) { + Ok(i) => i, + Err(resp) => return resp, + }; + + match usecase.execute(input) { + Ok(_) => RpcResponse::action_success(req_id), + Err(e) => session_error_response(req_id, e), + } +} diff --git a/cli/crates/agent-tui-adapters/src/adapters/daemon/router.rs b/cli/crates/agent-tui-adapters/src/adapters/daemon/router.rs index ad8ee9b..5c601e1 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/daemon/router.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/daemon/router.rs @@ -47,6 +47,18 @@ impl<'a, R: SessionRepository + 'static> Router<'a, R> { } "keydown" => handlers::input::handle_keydown_uc(&self.usecases.input.keydown, request), "keyup" => handlers::input::handle_keyup_uc(&self.usecases.input.keyup, request), + "mouse_click" => { + handlers::input::handle_mouse_click_uc(&self.usecases.input.mouse_click, request) + } + "mouse_move" => { + handlers::input::handle_mouse_move_uc(&self.usecases.input.mouse_move, request) + } + "mouse_down" => { + handlers::input::handle_mouse_down_uc(&self.usecases.input.mouse_down, request) + } + "mouse_up" => { + handlers::input::handle_mouse_up_uc(&self.usecases.input.mouse_up, request) + } "type" => handlers::input::handle_type_uc(&self.usecases.input.type_text, request), "wait" => handlers::wait::handle_wait_uc(&self.usecases.wait, request), diff --git a/cli/crates/agent-tui-adapters/src/adapters/daemon/router_tests.rs b/cli/crates/agent-tui-adapters/src/adapters/daemon/router_tests.rs index 3377ea2..36ec5de 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/daemon/router_tests.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/daemon/router_tests.rs @@ -101,6 +101,22 @@ impl SessionOps for TestSession { Ok(()) } + fn mouse_click(&self, _col: u16, _row: u16, _button: &str) -> Result<(), SessionError> { + Ok(()) + } + + fn mouse_move(&self, _col: u16, _row: u16) -> Result<(), SessionError> { + Ok(()) + } + + fn mouse_down(&self, _col: u16, _row: u16, _button: &str) -> Result<(), SessionError> { + Ok(()) + } + + fn mouse_up(&self, _col: u16, _row: u16, _button: &str) -> Result<(), SessionError> { + Ok(()) + } + fn is_running(&self) -> bool { true } diff --git a/cli/crates/agent-tui-adapters/src/adapters/daemon/usecase_container.rs b/cli/crates/agent-tui-adapters/src/adapters/daemon/usecase_container.rs index 691e759..42209bc 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/daemon/usecase_container.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/daemon/usecase_container.rs @@ -10,6 +10,10 @@ use crate::usecases::KeydownUseCaseImpl; use crate::usecases::KeystrokeUseCaseImpl; use crate::usecases::KeyupUseCaseImpl; use crate::usecases::KillUseCaseImpl; +use crate::usecases::MouseClickUseCaseImpl; +use crate::usecases::MouseDownUseCaseImpl; +use crate::usecases::MouseMoveUseCaseImpl; +use crate::usecases::MouseUpUseCaseImpl; use crate::usecases::ResizeUseCaseImpl; use crate::usecases::RestartUseCaseImpl; use crate::usecases::SessionsUseCaseImpl; @@ -51,6 +55,10 @@ pub struct InputUseCases { pub type_text: TypeUseCaseImpl, pub keydown: KeydownUseCaseImpl, pub keyup: KeyupUseCaseImpl, + pub mouse_click: MouseClickUseCaseImpl, + pub mouse_move: MouseMoveUseCaseImpl, + pub mouse_down: MouseDownUseCaseImpl, + pub mouse_up: MouseUpUseCaseImpl, } pub struct DiagnosticsUseCases { @@ -84,6 +92,10 @@ impl UseCaseContainer { type_text: TypeUseCaseImpl::new(Arc::clone(&repository)), keydown: KeydownUseCaseImpl::new(Arc::clone(&repository)), keyup: KeyupUseCaseImpl::new(Arc::clone(&repository)), + mouse_click: MouseClickUseCaseImpl::new(Arc::clone(&repository)), + mouse_move: MouseMoveUseCaseImpl::new(Arc::clone(&repository)), + mouse_down: MouseDownUseCaseImpl::new(Arc::clone(&repository)), + mouse_up: MouseUpUseCaseImpl::new(Arc::clone(&repository)), }, diagnostics: DiagnosticsUseCases { terminal_write: TerminalWriteUseCaseImpl::new(Arc::clone(&repository)), diff --git a/cli/crates/agent-tui-adapters/src/adapters/rpc/mod.rs b/cli/crates/agent-tui-adapters/src/adapters/rpc/mod.rs index 04936d8..0191001 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/rpc/mod.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/rpc/mod.rs @@ -302,6 +302,81 @@ pub fn parse_keyup_input(request: &RpcRequest) -> Result Result { + let col = request.require_u16("col")?; + let row = request.require_u16("row")?; + let button = request.param_str("button").unwrap_or("left"); + let button = crate::domain::MouseButton::parse(button).ok_or_else(|| { + RpcResponse::error(request.id, -32602, "Invalid button. Must be: left, right, or middle") + })?; + + Ok(crate::domain::MouseClickInput { + session_id: parse_session_selector( + request.id, + request.param_str("session").map(String::from), + )?, + col, + row, + button, + }) +} + +#[allow(clippy::result_large_err)] +pub fn parse_mouse_move_input(request: &RpcRequest) -> Result { + let col = request.require_u16("col")?; + let row = request.require_u16("row")?; + + Ok(crate::domain::MouseMoveInput { + session_id: parse_session_selector( + request.id, + request.param_str("session").map(String::from), + )?, + col, + row, + }) +} + +#[allow(clippy::result_large_err)] +pub fn parse_mouse_down_input(request: &RpcRequest) -> Result { + let col = request.require_u16("col")?; + let row = request.require_u16("row")?; + let button = request.param_str("button").unwrap_or("left"); + let button = crate::domain::MouseButton::parse(button).ok_or_else(|| { + RpcResponse::error(request.id, -32602, "Invalid button. Must be: left, right, or middle") + })?; + + Ok(crate::domain::MouseDownInput { + session_id: parse_session_selector( + request.id, + request.param_str("session").map(String::from), + )?, + col, + row, + button, + }) +} + +#[allow(clippy::result_large_err)] +pub fn parse_mouse_up_input(request: &RpcRequest) -> Result { + let col = request.require_u16("col")?; + let row = request.require_u16("row")?; + let button = request.param_str("button").unwrap_or("left"); + let button = crate::domain::MouseButton::parse(button).ok_or_else(|| { + RpcResponse::error(request.id, -32602, "Invalid button. Must be: left, right, or middle") + })?; + + Ok(crate::domain::MouseUpInput { + session_id: parse_session_selector( + request.id, + request.param_str("session").map(String::from), + )?, + col, + row, + button, + }) +} + #[allow(clippy::result_large_err)] pub fn parse_wait_input(request: &RpcRequest) -> Result { let rpc_params: params::WaitParams = deserialize_optional_params(request)?; diff --git a/cli/crates/agent-tui-adapters/src/adapters/rpc/mod_tests.rs b/cli/crates/agent-tui-adapters/src/adapters/rpc/mod_tests.rs index b3ea98c..50c7df5 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/rpc/mod_tests.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/rpc/mod_tests.rs @@ -237,3 +237,90 @@ fn test_parse_resize_input_rejects_blank_explicit_session() { let value = serde_json::to_value(response).expect("response should serialize"); assert_eq!(value["error"]["code"], -32602); } + +#[test] +fn test_parse_mouse_click_input() { + let request = make_request( + 1, + "mouse_click", + Some(json!({"col": 5, "row": 10, "button": "left"})), + ); + let input = parse_mouse_click_input(&request).expect("mouse_click input should parse"); + assert_eq!(input.col, 5); + assert_eq!(input.row, 10); + assert_eq!(input.button, crate::domain::MouseButton::Left); +} + +#[test] +fn test_parse_mouse_click_input_defaults_button() { + let request = make_request(1, "mouse_click", Some(json!({"col": 5, "row": 10}))); + let input = parse_mouse_click_input(&request).expect("mouse_click should default to left button"); + assert_eq!(input.button, crate::domain::MouseButton::Left); +} + +#[test] +fn test_parse_mouse_click_input_right_button() { + let request = make_request( + 1, + "mouse_click", + Some(json!({"col": 5, "row": 10, "button": "right"})), + ); + let input = parse_mouse_click_input(&request).expect("mouse_click should parse right button"); + assert_eq!(input.button, crate::domain::MouseButton::Right); +} + +#[test] +fn test_parse_mouse_click_input_invalid_button() { + let request = make_request( + 1, + "mouse_click", + Some(json!({"col": 5, "row": 10, "button": "invalid"})), + ); + let response = parse_mouse_click_input(&request).expect_err("invalid button should error"); + let value = serde_json::to_value(response).expect("response should serialize"); + assert_eq!(value["error"]["code"], -32602); +} + +#[test] +fn test_parse_mouse_click_input_missing_col() { + let request = make_request(1, "mouse_click", Some(json!({"row": 10}))); + let response = parse_mouse_click_input(&request).expect_err("missing col should error"); + let value = serde_json::to_value(response).expect("response should serialize"); + assert_eq!(value["error"]["code"], -32602); +} + +#[test] +fn test_parse_mouse_move_input() { + let request = make_request( + 1, + "mouse_move", + Some(json!({"col": 5, "row": 10})), + ); + let input = parse_mouse_move_input(&request).expect("mouse_move input should parse"); + assert_eq!(input.col, 5); + assert_eq!(input.row, 10); +} + +#[test] +fn test_parse_mouse_down_input() { + let request = make_request( + 1, + "mouse_down", + Some(json!({"col": 5, "row": 10, "button": "middle"})), + ); + let input = parse_mouse_down_input(&request).expect("mouse_down input should parse"); + assert_eq!(input.col, 5); + assert_eq!(input.row, 10); + assert_eq!(input.button, crate::domain::MouseButton::Middle); +} + +#[test] +fn test_parse_mouse_up_input() { + let request = make_request( + 1, + "mouse_up", + Some(json!({"col": 5, "row": 10, "button": "right"})), + ); + let input = parse_mouse_up_input(&request).expect("mouse_up input should parse"); + assert_eq!(input.button, crate::domain::MouseButton::Right); +} diff --git a/cli/crates/agent-tui-adapters/src/adapters/rpc/params.rs b/cli/crates/agent-tui-adapters/src/adapters/rpc/params.rs index c8a20de..d5fc8bd 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/rpc/params.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/rpc/params.rs @@ -121,6 +121,28 @@ pub struct TypeParams { pub session: Option, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MouseParams { + pub col: u16, + pub row: u16, + #[serde(default = "default_mouse_button")] + pub button: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, +} + +fn default_mouse_button() -> String { + "left".to_string() +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MouseMoveParams { + pub col: u16, + pub row: u16, + #[serde(skip_serializing_if = "Option::is_none")] + pub session: Option, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct WaitParams { #[serde(skip_serializing_if = "Option::is_none")] diff --git a/cli/crates/agent-tui-adapters/src/adapters/rpc/types.rs b/cli/crates/agent-tui-adapters/src/adapters/rpc/types.rs index 0430f2c..24f3979 100644 --- a/cli/crates/agent-tui-adapters/src/adapters/rpc/types.rs +++ b/cli/crates/agent-tui-adapters/src/adapters/rpc/types.rs @@ -62,6 +62,20 @@ impl RpcRequest { self.param_str(key) .ok_or_else(|| RpcResponse::error(self.id, -32602, &format!("Missing '{key}' param"))) } + + #[allow(clippy::result_large_err)] + pub fn require_u64(&self, key: &str) -> Result { + self.param_u64_opt(key) + .ok_or_else(|| RpcResponse::error(self.id, -32602, &format!("Missing '{key}' param"))) + } + + #[allow(clippy::result_large_err)] + pub fn require_u16(&self, key: &str) -> Result { + let value = self.require_u64(key)?; + value.try_into().map_err(|_| { + RpcResponse::error(self.id, -32602, &format!("'{key}' must be a valid u16")) + }) + } } #[derive(Debug, Serialize)] diff --git a/cli/crates/agent-tui-app/src/app/commands.rs b/cli/crates/agent-tui-app/src/app/commands.rs index 96470e7..9b8ec63 100644 --- a/cli/crates/agent-tui-app/src/app/commands.rs +++ b/cli/crates/agent-tui-app/src/app/commands.rs @@ -404,6 +404,19 @@ EXAMPLES: text: String, }, + /// Send mouse events to the terminal + #[command(after_long_help = "\ +EXAMPLES: + agent-tui mouse click 10 20 + agent-tui mouse click 5 5 --button right + agent-tui mouse move 0 0 + agent-tui mouse down 10 10 + agent-tui mouse up 10 10")] + Mouse { + #[command(subcommand)] + command: MouseCommand, + }, + /// Scroll using repeated directional terminal input #[command(long_about = "\ Send repeated directional input to the terminal. @@ -605,6 +618,38 @@ INSTALLATION: }, } +#[derive(Debug, Subcommand)] +pub enum MouseCommand { + /// Send a mouse click (down + up) + Click(MouseArgs), + /// Send a mouse move + Move { + /// Terminal column (0-based) + #[arg(value_name = "COL")] + col: u16, + /// Terminal row (0-based) + #[arg(value_name = "ROW")] + row: u16, + }, + /// Send a mouse button down + Down(MouseArgs), + /// Send a mouse button up + Up(MouseArgs), +} + +#[derive(Debug, Args)] +pub struct MouseArgs { + /// Terminal column (0-based) + #[arg(value_name = "COL")] + pub col: u16, + /// Terminal row (0-based) + #[arg(value_name = "ROW")] + pub row: u16, + /// Mouse button (left, right, middle; default: left) + #[arg(short, long, default_value = "left", value_name = "BUTTON")] + pub button: String, +} + #[derive(Debug, Subcommand)] pub enum SessionsCommand { /// List active sessions diff --git a/cli/crates/agent-tui-app/src/app/handlers.rs b/cli/crates/agent-tui-app/src/app/handlers.rs index 9b37bba..e8d3fa3 100644 --- a/cli/crates/agent-tui-app/src/app/handlers.rs +++ b/cli/crates/agent-tui-app/src/app/handlers.rs @@ -491,6 +491,84 @@ key_handler!(handle_keyup, "keyup", |k: &String| format!( "Key released: {k}" )); +pub(crate) fn handle_mouse_click( + ctx: &mut HandlerContext, + col: u16, + row: u16, + button: String, +) -> HandlerResult { + let params = params::MouseParams { + col, + row, + button: button.clone(), + session: ctx.session.clone(), + }; + let result = call_with_params(ctx.client, "mouse_click", params)?; + ctx.output_success_and_ok( + &result, + &format!("Mouse clicked at {col}x{row} with {button}"), + "Mouse click failed", + ) +} + +pub(crate) fn handle_mouse_move( + ctx: &mut HandlerContext, + col: u16, + row: u16, +) -> HandlerResult { + let params = params::MouseMoveParams { + col, + row, + session: ctx.session.clone(), + }; + let result = call_with_params(ctx.client, "mouse_move", params)?; + ctx.output_success_and_ok( + &result, + &format!("Mouse moved to {col}x{row}"), + "Mouse move failed", + ) +} + +pub(crate) fn handle_mouse_down( + ctx: &mut HandlerContext, + col: u16, + row: u16, + button: String, +) -> HandlerResult { + let params = params::MouseParams { + col, + row, + button: button.clone(), + session: ctx.session.clone(), + }; + let result = call_with_params(ctx.client, "mouse_down", params)?; + ctx.output_success_and_ok( + &result, + &format!("Mouse button down at {col}x{row} with {button}"), + "Mouse down failed", + ) +} + +pub(crate) fn handle_mouse_up( + ctx: &mut HandlerContext, + col: u16, + row: u16, + button: String, +) -> HandlerResult { + let params = params::MouseParams { + col, + row, + button: button.clone(), + session: ctx.session.clone(), + }; + let result = call_with_params(ctx.client, "mouse_up", params)?; + ctx.output_success_and_ok( + &result, + &format!("Mouse button up at {col}x{row} with {button}"), + "Mouse up failed", + ) +} + pub(crate) fn handle_type( ctx: &mut HandlerContext, text: String, diff --git a/cli/crates/agent-tui-app/src/app/mod.rs b/cli/crates/agent-tui-app/src/app/mod.rs index 4ee31fb..50e7115 100644 --- a/cli/crates/agent-tui-app/src/app/mod.rs +++ b/cli/crates/agent-tui-app/src/app/mod.rs @@ -924,6 +924,20 @@ impl Application { } Commands::Type { text } => handlers::handle_type(ctx, text)?, + Commands::Mouse { command } => match command { + crate::app::commands::MouseCommand::Click(args) => { + handlers::handle_mouse_click(ctx, args.col, args.row, args.button)? + } + crate::app::commands::MouseCommand::Move { col, row } => { + handlers::handle_mouse_move(ctx, col, row)? + } + crate::app::commands::MouseCommand::Down(args) => { + handlers::handle_mouse_down(ctx, args.col, args.row, args.button)? + } + crate::app::commands::MouseCommand::Up(args) => { + handlers::handle_mouse_up(ctx, args.col, args.row, args.button)? + } + }, Commands::Scroll { direction, amount } => { handlers::handle_scroll(ctx, direction, amount)? } diff --git a/cli/crates/agent-tui-domain/src/domain/types.rs b/cli/crates/agent-tui-domain/src/domain/types.rs index e15e2d4..5d42b6a 100644 --- a/cli/crates/agent-tui-domain/src/domain/types.rs +++ b/cli/crates/agent-tui-domain/src/domain/types.rs @@ -289,6 +289,130 @@ pub struct ShutdownOutput { pub acknowledged: bool, } +// ============================================================ +// Mouse input types +// ============================================================ + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum MouseButton { + Left, + Right, + Middle, +} + +impl MouseButton { + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "left" => Some(Self::Left), + "right" => Some(Self::Right), + "middle" => Some(Self::Middle), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Left => "left", + Self::Right => "right", + Self::Middle => "middle", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +#[non_exhaustive] +pub enum MouseEventKind { + Down, + Up, + Drag, + Moved, +} + +impl MouseEventKind { + pub fn parse(s: &str) -> Option { + match s.to_lowercase().as_str() { + "down" => Some(Self::Down), + "up" => Some(Self::Up), + "drag" => Some(Self::Drag), + "moved" => Some(Self::Moved), + _ => None, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Self::Down => "down", + Self::Up => "up", + Self::Drag => "drag", + Self::Moved => "moved", + } + } +} + +#[derive(Debug, Clone)] +pub struct MousePosition { + pub col: u16, + pub row: u16, +} + +impl MousePosition { + pub fn new(col: u16, row: u16) -> Self { + Self { col, row } + } +} + +#[derive(Debug, Clone)] +pub struct MouseClickInput { + pub session_id: Option, + pub col: u16, + pub row: u16, + pub button: MouseButton, +} + +#[derive(Debug, Clone)] +pub struct MouseClickOutput { + pub success: bool, +} + +#[derive(Debug, Clone)] +pub struct MouseMoveInput { + pub session_id: Option, + pub col: u16, + pub row: u16, +} + +#[derive(Debug, Clone)] +pub struct MouseMoveOutput { + pub success: bool, +} + +#[derive(Debug, Clone)] +pub struct MouseDownInput { + pub session_id: Option, + pub col: u16, + pub row: u16, + pub button: MouseButton, +} + +#[derive(Debug, Clone)] +pub struct MouseDownOutput { + pub success: bool, +} + +#[derive(Debug, Clone)] +pub struct MouseUpInput { + pub session_id: Option, + pub col: u16, + pub row: u16, + pub button: MouseButton, +} + +#[derive(Debug, Clone)] +pub struct MouseUpOutput { + pub success: bool, +} + #[cfg(test)] #[path = "types_tests.rs"] mod tests; diff --git a/cli/crates/agent-tui-domain/src/domain/types_tests.rs b/cli/crates/agent-tui-domain/src/domain/types_tests.rs index b4605d8..4414c98 100644 --- a/cli/crates/agent-tui-domain/src/domain/types_tests.rs +++ b/cli/crates/agent-tui-domain/src/domain/types_tests.rs @@ -98,3 +98,88 @@ mod assert_condition_type_tests { assert!(AssertConditionType::parse("invalid").is_err()); } } + +mod mouse_button_tests { + use super::*; + + #[test] + fn test_mouse_button_parse_left() { + assert_eq!(MouseButton::parse("left"), Some(MouseButton::Left)); + } + + #[test] + fn test_mouse_button_parse_right() { + assert_eq!(MouseButton::parse("right"), Some(MouseButton::Right)); + } + + #[test] + fn test_mouse_button_parse_middle() { + assert_eq!(MouseButton::parse("middle"), Some(MouseButton::Middle)); + } + + #[test] + fn test_mouse_button_parse_case_insensitive() { + assert_eq!(MouseButton::parse("LEFT"), Some(MouseButton::Left)); + assert_eq!(MouseButton::parse("Right"), Some(MouseButton::Right)); + } + + #[test] + fn test_mouse_button_parse_invalid() { + assert_eq!(MouseButton::parse("invalid"), None); + } + + #[test] + fn test_mouse_button_as_str() { + assert_eq!(MouseButton::Left.as_str(), "left"); + assert_eq!(MouseButton::Right.as_str(), "right"); + assert_eq!(MouseButton::Middle.as_str(), "middle"); + } +} + +mod mouse_event_kind_tests { + use super::*; + + #[test] + fn test_mouse_event_kind_parse_down() { + assert_eq!(MouseEventKind::parse("down"), Some(MouseEventKind::Down)); + } + + #[test] + fn test_mouse_event_kind_parse_up() { + assert_eq!(MouseEventKind::parse("up"), Some(MouseEventKind::Up)); + } + + #[test] + fn test_mouse_event_kind_parse_drag() { + assert_eq!(MouseEventKind::parse("drag"), Some(MouseEventKind::Drag)); + } + + #[test] + fn test_mouse_event_kind_parse_moved() { + assert_eq!(MouseEventKind::parse("moved"), Some(MouseEventKind::Moved)); + } + + #[test] + fn test_mouse_event_kind_parse_invalid() { + assert_eq!(MouseEventKind::parse("invalid"), None); + } + + #[test] + fn test_mouse_event_kind_as_str() { + assert_eq!(MouseEventKind::Down.as_str(), "down"); + assert_eq!(MouseEventKind::Up.as_str(), "up"); + assert_eq!(MouseEventKind::Drag.as_str(), "drag"); + assert_eq!(MouseEventKind::Moved.as_str(), "moved"); + } +} + +mod mouse_position_tests { + use super::*; + + #[test] + fn test_mouse_position_new() { + let pos = MousePosition::new(5, 10); + assert_eq!(pos.col, 5); + assert_eq!(pos.row, 10); + } +} diff --git a/cli/crates/agent-tui-infra/src/infra/daemon/repository.rs b/cli/crates/agent-tui-infra/src/infra/daemon/repository.rs index 8f5768e..2f9ef8f 100644 --- a/cli/crates/agent-tui-infra/src/infra/daemon/repository.rs +++ b/cli/crates/agent-tui-infra/src/infra/daemon/repository.rs @@ -143,6 +143,26 @@ impl SessionOps for SessionHandleImpl { session_guard.keyup(key) } + fn mouse_click(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + let mut session_guard = mutex_lock_or_recover(&self.inner); + session_guard.mouse_click(col, row, button) + } + + fn mouse_move(&self, col: u16, row: u16) -> Result<(), SessionError> { + let mut session_guard = mutex_lock_or_recover(&self.inner); + session_guard.mouse_move(col, row) + } + + fn mouse_down(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + let mut session_guard = mutex_lock_or_recover(&self.inner); + session_guard.mouse_down(col, row, button) + } + + fn mouse_up(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + let mut session_guard = mutex_lock_or_recover(&self.inner); + session_guard.mouse_up(col, row, button) + } + fn is_running(&self) -> bool { let mut session_guard = mutex_lock_or_recover(&self.inner); session_guard.is_running() diff --git a/cli/crates/agent-tui-infra/src/infra/daemon/session.rs b/cli/crates/agent-tui-infra/src/infra/daemon/session.rs index beb9691..644f06e 100644 --- a/cli/crates/agent-tui-infra/src/infra/daemon/session.rs +++ b/cli/crates/agent-tui-infra/src/infra/daemon/session.rs @@ -890,6 +890,72 @@ impl Session { Ok(()) } + /// Mouse click at given position with button. + /// Uses SGR mouse tracking mode (1006h). + pub fn mouse_click(&mut self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + let btn = match button { + "left" => 0u16, + "middle" => 1u16, + "right" => 2u16, + _ => 0u16, + }; + // SGR mode: \x1b[ Result<(), SessionError> { + let px = col + 1; + let py = row + 1; + // 35 is often used for move (32 + button, where 3=release/no button) + self.pty + .write(format!("\x1b[<35;{};{}M", px, py).as_bytes())?; + self.record_command_timeline_entry("mouse_move", format!("{}x{}", col, row)); + Ok(()) + } + + /// Mouse button down at position. + pub fn mouse_down(&mut self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + let btn = match button { + "left" => 0u16, + "middle" => 1u16, + "right" => 2u16, + _ => 0u16, + }; + let px = col + 1; + let py = row + 1; + self.pty + .write(format!("\x1b[<{};{};{}M", btn, px, py).as_bytes())?; + self.record_command_timeline_entry("mouse_down", format!("{}x{} {}", col, row, button)); + Ok(()) + } + + /// Mouse button up at position. + pub fn mouse_up(&mut self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + let btn = match button { + "left" => 0u16, + "middle" => 1u16, + "right" => 2u16, + _ => 0u16, + }; + let px = col + 1; + let py = row + 1; + self.pty + .write(format!("\x1b[<{};{};{}m", btn, px, py).as_bytes())?; + self.record_command_timeline_entry("mouse_up", format!("{}x{} {}", col, row, button)); + Ok(()) + } + pub fn resize(&mut self, size: TerminalSize) -> Result<(), SessionError> { self.pty.resize(size)?; self.terminal.resize(size); diff --git a/cli/crates/agent-tui-usecases/src/usecases/input.rs b/cli/crates/agent-tui-usecases/src/usecases/input.rs index 3c52687..378da7a 100644 --- a/cli/crates/agent-tui-usecases/src/usecases/input.rs +++ b/cli/crates/agent-tui-usecases/src/usecases/input.rs @@ -10,6 +10,14 @@ use crate::domain::KeyupInput; use crate::domain::KeyupOutput; use crate::domain::TypeInput; use crate::domain::TypeOutput; +use crate::domain::MouseClickInput; +use crate::domain::MouseClickOutput; +use crate::domain::MouseMoveInput; +use crate::domain::MouseMoveOutput; +use crate::domain::MouseDownInput; +use crate::domain::MouseDownOutput; +use crate::domain::MouseUpInput; +use crate::domain::MouseUpOutput; use crate::usecases::ports::SessionError; use crate::usecases::ports::SessionRepository; @@ -105,6 +113,102 @@ impl KeyupUseCase for KeyupUseCaseImpl { } } +// ============================================================ +// Mouse use cases +// ============================================================ + +pub trait MouseClickUseCase: Send + Sync { + fn execute(&self, input: MouseClickInput) -> Result; +} + +pub struct MouseClickUseCaseImpl { + repository: Arc, +} + +impl MouseClickUseCaseImpl { + pub fn new(repository: Arc) -> Self { + Self { repository } + } +} + +impl MouseClickUseCase for MouseClickUseCaseImpl { + fn execute(&self, input: MouseClickInput) -> Result { + let session = self.repository.resolve(input.session_id.as_ref())?; + session.mouse_click(input.col, input.row, input.button.as_str())?; + + Ok(MouseClickOutput { success: true }) + } +} + +pub trait MouseMoveUseCase: Send + Sync { + fn execute(&self, input: MouseMoveInput) -> Result; +} + +pub struct MouseMoveUseCaseImpl { + repository: Arc, +} + +impl MouseMoveUseCaseImpl { + pub fn new(repository: Arc) -> Self { + Self { repository } + } +} + +impl MouseMoveUseCase for MouseMoveUseCaseImpl { + fn execute(&self, input: MouseMoveInput) -> Result { + let session = self.repository.resolve(input.session_id.as_ref())?; + session.mouse_move(input.col, input.row)?; + + Ok(MouseMoveOutput { success: true }) + } +} + +pub trait MouseDownUseCase: Send + Sync { + fn execute(&self, input: MouseDownInput) -> Result; +} + +pub struct MouseDownUseCaseImpl { + repository: Arc, +} + +impl MouseDownUseCaseImpl { + pub fn new(repository: Arc) -> Self { + Self { repository } + } +} + +impl MouseDownUseCase for MouseDownUseCaseImpl { + fn execute(&self, input: MouseDownInput) -> Result { + let session = self.repository.resolve(input.session_id.as_ref())?; + session.mouse_down(input.col, input.row, input.button.as_str())?; + + Ok(MouseDownOutput { success: true }) + } +} + +pub trait MouseUpUseCase: Send + Sync { + fn execute(&self, input: MouseUpInput) -> Result; +} + +pub struct MouseUpUseCaseImpl { + repository: Arc, +} + +impl MouseUpUseCaseImpl { + pub fn new(repository: Arc) -> Self { + Self { repository } + } +} + +impl MouseUpUseCase for MouseUpUseCaseImpl { + fn execute(&self, input: MouseUpInput) -> Result { + let session = self.repository.resolve(input.session_id.as_ref())?; + session.mouse_up(input.col, input.row, input.button.as_str())?; + + Ok(MouseUpOutput { success: true }) + } +} + #[cfg(test)] #[path = "input_tests.rs"] mod tests; diff --git a/cli/crates/agent-tui-usecases/src/usecases/input_mouse_tests.rs b/cli/crates/agent-tui-usecases/src/usecases/input_mouse_tests.rs new file mode 100644 index 0000000..d3de1c3 --- /dev/null +++ b/cli/crates/agent-tui-usecases/src/usecases/input_mouse_tests.rs @@ -0,0 +1,124 @@ +use crate::domain::MouseButton; +use crate::domain::MouseClickInput; +use crate::domain::MouseDownInput; +use crate::domain::MouseMoveInput; +use crate::domain::MouseUpInput; +use crate::domain::SessionId; +use crate::usecases::MouseClickUseCase; +use crate::usecases::MouseClickUseCaseImpl; +use crate::usecases::MouseDownUseCase; +use crate::usecases::MouseDownUseCaseImpl; +use crate::usecases::MouseMoveUseCase; +use crate::usecases::MouseMoveUseCaseImpl; +use crate::usecases::MouseUpUseCase; +use crate::usecases::MouseUpUseCaseImpl; +use crate::usecases::ports::test_support::MockSession; +use crate::usecases::ports::test_support::MockSessionRepository; +use std::sync::Arc; + +#[test] +fn test_mouse_click_usecase_calls_session_mouse_click() { + let session = Arc::new(MockSession::new("test-session")); + let session_id = SessionId::try_new("test-session").unwrap(); + let repo = Arc::new( + MockSessionRepository::builder() + .with_session_handle(session.clone()) + .build(), + ); + let usecase = MouseClickUseCaseImpl::new(repo); + + let input = MouseClickInput { + session_id: Some(session_id), + col: 10, + row: 20, + button: MouseButton::Right, + }; + + usecase + .execute(input) + .expect("usecase execution should succeed"); + + let calls = session.mouse_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0], "click 10x20 right"); +} + +#[test] +fn test_mouse_move_usecase_calls_session_mouse_move() { + let session = Arc::new(MockSession::new("test-session")); + let session_id = SessionId::try_new("test-session").unwrap(); + let repo = Arc::new( + MockSessionRepository::builder() + .with_session_handle(session.clone()) + .build(), + ); + let usecase = MouseMoveUseCaseImpl::new(repo); + + let input = MouseMoveInput { + session_id: Some(session_id), + col: 15, + row: 25, + }; + + usecase + .execute(input) + .expect("usecase execution should succeed"); + + let calls = session.mouse_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0], "move 15x25"); +} + +#[test] +fn test_mouse_down_usecase_calls_session_mouse_down() { + let session = Arc::new(MockSession::new("test-session")); + let session_id = SessionId::try_new("test-session").unwrap(); + let repo = Arc::new( + MockSessionRepository::builder() + .with_session_handle(session.clone()) + .build(), + ); + let usecase = MouseDownUseCaseImpl::new(repo); + + let input = MouseDownInput { + session_id: Some(session_id), + col: 5, + row: 5, + button: MouseButton::Left, + }; + + usecase + .execute(input) + .expect("usecase execution should succeed"); + + let calls = session.mouse_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0], "down 5x5 left"); +} + +#[test] +fn test_mouse_up_usecase_calls_session_mouse_up() { + let session = Arc::new(MockSession::new("test-session")); + let session_id = SessionId::try_new("test-session").unwrap(); + let repo = Arc::new( + MockSessionRepository::builder() + .with_session_handle(session.clone()) + .build(), + ); + let usecase = MouseUpUseCaseImpl::new(repo); + + let input = MouseUpInput { + session_id: Some(session_id), + col: 8, + row: 8, + button: MouseButton::Middle, + }; + + usecase + .execute(input) + .expect("usecase execution should succeed"); + + let calls = session.mouse_calls(); + assert_eq!(calls.len(), 1); + assert_eq!(calls[0], "up 8x8 middle"); +} diff --git a/cli/crates/agent-tui-usecases/src/usecases/input_tests.rs b/cli/crates/agent-tui-usecases/src/usecases/input_tests.rs index 6bf7d2f..47cb157 100644 --- a/cli/crates/agent-tui-usecases/src/usecases/input_tests.rs +++ b/cli/crates/agent-tui-usecases/src/usecases/input_tests.rs @@ -1,4 +1,5 @@ use super::*; +use crate::domain::MouseButton; use crate::domain::SessionId; use crate::test_support::MockError; use crate::test_support::MockSessionRepository; @@ -130,3 +131,90 @@ fn test_keyup_usecase_returns_error_when_session_not_found() { let result = usecase.execute(input); assert!(matches!(result, Err(SessionError::NotFound(_)))); } + +#[test] +fn test_mouse_click_usecase_returns_error_when_no_active_session() { + let repo = Arc::new(MockSessionRepository::new()); + let usecase = MouseClickUseCaseImpl::new(repo); + + let input = MouseClickInput { + session_id: None, + col: 5, + row: 10, + button: MouseButton::Left, + }; + + let result = usecase.execute(input); + assert!(matches!(result, Err(SessionError::NoActiveSession))); +} + +#[test] +fn test_mouse_click_usecase_returns_error_when_session_not_found() { + let repo = Arc::new( + MockSessionRepository::builder() + .with_resolve_error(MockError::NotFound("missing".to_string())) + .build(), + ); + let usecase = MouseClickUseCaseImpl::new(repo); + + let input = MouseClickInput { + session_id: Some(SessionId::try_new("missing").expect("valid session id")), + col: 5, + row: 10, + button: MouseButton::Right, + }; + + let result = usecase.execute(input); + assert!(matches!(result, Err(SessionError::NotFound(_)))); +} + +#[test] +fn test_mouse_move_usecase_returns_error_when_no_active_session() { + let repo = Arc::new(MockSessionRepository::new()); + let usecase = MouseMoveUseCaseImpl::new(repo); + + let input = MouseMoveInput { + session_id: None, + col: 5, + row: 10, + }; + + let result = usecase.execute(input); + assert!(matches!(result, Err(SessionError::NoActiveSession))); +} + +#[test] +fn test_mouse_down_usecase_returns_error_when_no_active_session() { + let repo = Arc::new(MockSessionRepository::new()); + let usecase = MouseDownUseCaseImpl::new(repo); + + let input = MouseDownInput { + session_id: None, + col: 5, + row: 10, + button: MouseButton::Middle, + }; + + let result = usecase.execute(input); + assert!(matches!(result, Err(SessionError::NoActiveSession))); +} + +#[test] +fn test_mouse_up_usecase_returns_error_when_session_not_found() { + let repo = Arc::new( + MockSessionRepository::builder() + .with_resolve_error(MockError::NotFound("missing".to_string())) + .build(), + ); + let usecase = MouseUpUseCaseImpl::new(repo); + + let input = MouseUpInput { + session_id: Some(SessionId::try_new("missing").expect("valid session id")), + col: 5, + row: 10, + button: MouseButton::Left, + }; + + let result = usecase.execute(input); + assert!(matches!(result, Err(SessionError::NotFound(_)))); +} diff --git a/cli/crates/agent-tui-usecases/src/usecases/mod.rs b/cli/crates/agent-tui-usecases/src/usecases/mod.rs index 7e44308..acf5143 100644 --- a/cli/crates/agent-tui-usecases/src/usecases/mod.rs +++ b/cli/crates/agent-tui-usecases/src/usecases/mod.rs @@ -19,6 +19,14 @@ pub use input::KeyupUseCase; pub use input::KeyupUseCaseImpl; pub use input::TypeUseCase; pub use input::TypeUseCaseImpl; +pub use input::MouseClickUseCase; +pub use input::MouseClickUseCaseImpl; +pub use input::MouseMoveUseCase; +pub use input::MouseMoveUseCaseImpl; +pub use input::MouseDownUseCase; +pub use input::MouseDownUseCaseImpl; +pub use input::MouseUpUseCase; +pub use input::MouseUpUseCaseImpl; pub use session::AssertUseCase; pub use session::AssertUseCaseImpl; pub use session::AttachUseCase; @@ -43,3 +51,6 @@ pub use spawn_error::SpawnError; pub use wait::WaitUseCase; pub use wait::WaitUseCaseImpl; pub mod ports; + +#[cfg(test)] +mod input_mouse_tests; diff --git a/cli/crates/agent-tui-usecases/src/usecases/ports/session_repository.rs b/cli/crates/agent-tui-usecases/src/usecases/ports/session_repository.rs index 8e36d3c..9ad17ac 100644 --- a/cli/crates/agent-tui-usecases/src/usecases/ports/session_repository.rs +++ b/cli/crates/agent-tui-usecases/src/usecases/ports/session_repository.rs @@ -58,6 +58,10 @@ pub trait SessionOps: Send + Sync { fn type_text(&self, text: &str) -> Result<(), SessionError>; fn keydown(&self, key: &str) -> Result<(), SessionError>; fn keyup(&self, key: &str) -> Result<(), SessionError>; + fn mouse_click(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError>; + fn mouse_move(&self, col: u16, row: u16) -> Result<(), SessionError>; + fn mouse_down(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError>; + fn mouse_up(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError>; fn is_running(&self) -> bool; fn resize(&self, size: TerminalSize) -> Result<(), SessionError>; fn cursor(&self) -> CursorPosition; diff --git a/cli/crates/agent-tui-usecases/src/usecases/ports/test_support/mock_session.rs b/cli/crates/agent-tui-usecases/src/usecases/ports/test_support/mock_session.rs index cec41b3..b1b869d 100644 --- a/cli/crates/agent-tui-usecases/src/usecases/ports/test_support/mock_session.rs +++ b/cli/crates/agent-tui-usecases/src/usecases/ports/test_support/mock_session.rs @@ -36,6 +36,7 @@ pub struct MockSession { update_error: Option, terminal_write_error: Option, written_data: Mutex>>, + mouse_calls: Mutex>, } impl MockSession { @@ -56,6 +57,7 @@ impl MockSession { update_error: None, terminal_write_error: None, written_data: Mutex::new(Vec::new()), + mouse_calls: Mutex::new(Vec::new()), } } @@ -66,6 +68,10 @@ impl MockSession { pub fn written_data(&self) -> Vec> { mutex_lock_or_recover(&self.written_data).clone() } + + pub fn mouse_calls(&self) -> Vec { + mutex_lock_or_recover(&self.mouse_calls).clone() + } } impl SessionOps for MockSession { @@ -147,6 +153,26 @@ impl SessionOps for MockSession { Ok(()) } + fn mouse_click(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + mutex_lock_or_recover(&self.mouse_calls).push(format!("click {}x{} {}", col, row, button)); + Ok(()) + } + + fn mouse_move(&self, col: u16, row: u16) -> Result<(), SessionError> { + mutex_lock_or_recover(&self.mouse_calls).push(format!("move {}x{}", col, row)); + Ok(()) + } + + fn mouse_down(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + mutex_lock_or_recover(&self.mouse_calls).push(format!("down {}x{} {}", col, row, button)); + Ok(()) + } + + fn mouse_up(&self, col: u16, row: u16, button: &str) -> Result<(), SessionError> { + mutex_lock_or_recover(&self.mouse_calls).push(format!("up {}x{} {}", col, row, button)); + Ok(()) + } + fn is_running(&self) -> bool { self.running } diff --git a/cli/crates/agent-tui-usecases/src/usecases/wait_tests.rs b/cli/crates/agent-tui-usecases/src/usecases/wait_tests.rs index 6787722..d1ed524 100644 --- a/cli/crates/agent-tui-usecases/src/usecases/wait_tests.rs +++ b/cli/crates/agent-tui-usecases/src/usecases/wait_tests.rs @@ -238,6 +238,22 @@ impl SessionOps for ControlledSession { Ok(()) } + fn mouse_click(&self, _col: u16, _row: u16, _button: &str) -> Result<(), SessionError> { + Ok(()) + } + + fn mouse_move(&self, _col: u16, _row: u16) -> Result<(), SessionError> { + Ok(()) + } + + fn mouse_down(&self, _col: u16, _row: u16, _button: &str) -> Result<(), SessionError> { + Ok(()) + } + + fn mouse_up(&self, _col: u16, _row: u16, _button: &str) -> Result<(), SessionError> { + Ok(()) + } + fn is_running(&self) -> bool { true } diff --git a/cli/crates/agent-tui/tests/mouse_e2e.rs b/cli/crates/agent-tui/tests/mouse_e2e.rs new file mode 100644 index 0000000..8b24a08 --- /dev/null +++ b/cli/crates/agent-tui/tests/mouse_e2e.rs @@ -0,0 +1,104 @@ +mod common; + +mod e2e_mouse { + use crate::common::RealTestHarness; + use predicates::prelude::*; + use serde_json::Value; + use std::sync::Mutex; + use std::sync::MutexGuard; + use std::sync::OnceLock; + + fn slow_e2e_lock() -> MutexGuard<'static, ()> { + static LOCK: OnceLock> = OnceLock::new(); + LOCK.get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + } + + fn spawn_session(harness: &RealTestHarness, command: &str) -> String { + let output = harness + .cli_command() + .args(["--format", "json", "run", command]) + .output() + .expect("failed to run session"); + assert!( + output.status.success(), + "run command failed: stdout={}, stderr={}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ); + let value: Value = serde_json::from_slice(&output.stdout).expect("run output must be JSON"); + value["session_id"] + .as_str() + .expect("run output must include session_id") + .to_string() + } + + #[test] + #[ignore = "slow e2e"] + fn e2e_mouse_commands_send_rpc_and_succeed() { + let _lock = slow_e2e_lock(); + let harness = RealTestHarness::new(); + let session_id = spawn_session(&harness, "cat"); + + harness + .run(&["--session", &session_id, "mouse", "click", "10", "20"]) + .success() + .stdout(predicate::str::contains("Mouse clicked at 10x20 with left")); + + harness + .run(&[ + "--session", + &session_id, + "mouse", + "click", + "5", + "5", + "--button", + "right", + ]) + .success() + .stdout(predicate::str::contains("Mouse clicked at 5x5 with right")); + + harness + .run(&["--session", &session_id, "mouse", "move", "0", "0"]) + .success() + .stdout(predicate::str::contains("Mouse moved to 0x0")); + + harness + .run(&[ + "--session", + &session_id, + "mouse", + "down", + "10", + "10", + "--button", + "middle", + ]) + .success() + .stdout(predicate::str::contains( + "Mouse button down at 10x10 with middle", + )); + + harness + .run(&[ + "--session", + &session_id, + "mouse", + "up", + "10", + "10", + "--button", + "middle", + ]) + .success() + .stdout(predicate::str::contains( + "Mouse button up at 10x10 with middle", + )); + + harness + .run(&["--session", &session_id, "kill", "--yes"]) + .success(); + } +} diff --git a/docs/cli/agent-tui.md b/docs/cli/agent-tui.md index a1f62fd..cc32689 100644 --- a/docs/cli/agent-tui.md +++ b/docs/cli/agent-tui.md @@ -22,6 +22,7 @@ Commands: restart Restart the current session press Send key press(es) to the terminal (supports modifier hold/release) type Type literal text character by character + mouse Send mouse events to the terminal scroll Scroll using repeated directional terminal input wait Wait for text or screenshot stability kill Kill the current session @@ -111,6 +112,10 @@ EXAMPLES: agent-tui press F10 agent-tui press ArrowDown ArrowDown Enter + # Mouse interactions + agent-tui mouse click 10 20 + agent-tui mouse move 5 5 + # Scroll using directional terminal input agent-tui scroll down agent-tui scroll up 5 @@ -469,6 +474,94 @@ EXAMPLES: printf 'project-name' | agent-tui type - ``` +## `agent-tui mouse` + +```text +Send mouse events to the terminal + +Usage: mouse + +Commands: + click Send a mouse click (down + up) + move Send a mouse move + down Send a mouse button down + up Send a mouse button up + help Print this message or the help of the given subcommand(s) + +Options: + -h, --help Print help + +EXAMPLES: + agent-tui mouse click 10 20 + agent-tui mouse click 5 5 --button right + agent-tui mouse move 0 0 + agent-tui mouse down 10 10 + agent-tui mouse up 10 10 +``` + +## `agent-tui mouse click` + +```text +Send a mouse click (down + up) + +Usage: click [OPTIONS] + +Arguments: + Terminal column (0-based) + Terminal row (0-based) + +Options: + -b, --button