From 543d41c3a24dc2a2ccebb8533f7f9ef6f21f2900 Mon Sep 17 00:00:00 2001 From: Hari <73809867+harivansh-afk@users.noreply.github.com> Date: Wed, 25 Mar 2026 22:00:16 -0400 Subject: [PATCH] runtime contract enforcement (#6) --- CONTRIBUTING.md | 1 + README.md | 11 + docs/runtime-output.md | 178 +++++++++ skills/SKILL.md | 8 + src/cli/mod.rs | 872 ++++++++++++++++++++++++++++++++++------- src/core/types.rs | 16 +- src/daemon/handler.rs | 29 +- 7 files changed, 958 insertions(+), 157 deletions(-) create mode 100644 docs/runtime-output.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3c44332..7a1a2a2 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -21,6 +21,7 @@ pnpm --dir site install - `src/` holds production code and unit tests - `tests/` holds integration tests - `tests/support/` holds shared X11 and daemon helpers for integration coverage +- `docs/runtime-output.md` is the stable-vs-best-effort runtime output contract for agent-facing CLI work Keep integration-only helpers out of `src/`. diff --git a/README.md b/README.md index 387f3e9..6920615 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,7 @@ deskctl doctor - `@wN` refs are short-lived handles assigned by `snapshot` and `list-windows` - `--json` output includes a stable `window_id` for programmatic targeting within the current daemon session - `list-windows` is a cheap read-only operation and does not capture or write a screenshot +- the stable runtime JSON/error contract is documented in [docs/runtime-output.md](docs/runtime-output.md) ## Read and Wait Surface @@ -147,6 +148,16 @@ Successful `get active-window`, `wait window`, and `wait focus` responses return Wait timeout and selector failures are structured in `--json` mode so agents can recover without string parsing. +## Output Policy + +Text mode is compact and follow-up-oriented, but JSON is the parsing contract. + +- use `--json` when an agent needs strict parsing +- rely on `window_id`, selector-related fields, grouped read payloads, and structured error `kind` values for stable automation +- treat monitor naming, incidental whitespace, and default screenshot file names as best-effort + +See [docs/runtime-output.md](docs/runtime-output.md) for the exact stable-vs-best-effort breakdown. + ## Selector Contract Explicit selector modes: diff --git a/docs/runtime-output.md b/docs/runtime-output.md new file mode 100644 index 0000000..7312357 --- /dev/null +++ b/docs/runtime-output.md @@ -0,0 +1,178 @@ +# Runtime Output Contract + +This document defines the current output contract for `deskctl`. + +It is intentionally scoped to the current Linux X11 runtime surface. +It does not promise stability for future Wayland or window-manager-specific features. + +## Goals + +- Keep `deskctl` fully non-interactive +- Make text output actionable for quick terminal and agent loops +- Make `--json` safe for agent consumption without depending on incidental formatting + +## JSON Envelope + +Every runtime command uses the same top-level JSON envelope: + +```json +{ + "success": true, + "data": {}, + "error": null +} +``` + +Stable top-level fields: + +- `success` +- `data` +- `error` + +`success` is always the authoritative success/failure bit. +When `success` is `false`, the CLI exits non-zero in both text mode and `--json` mode. + +## Stable Fields + +These fields are stable for agent consumption in the current Phase 1 runtime contract. + +### Window Identity + +Whenever a runtime response includes a window payload, these fields are stable: + +- `ref_id` +- `window_id` +- `title` +- `app_name` +- `x` +- `y` +- `width` +- `height` +- `focused` +- `minimized` + +`window_id` is the stable public identifier for a live daemon session. +`ref_id` is a short-lived convenience handle for the current window snapshot/ref map. + +### Grouped Reads + +`deskctl get active-window` + +- stable: `data.window` + +`deskctl get monitors` + +- stable: `data.count` +- stable: `data.monitors` +- stable per monitor: + - `name` + - `x` + - `y` + - `width` + - `height` + - `width_mm` + - `height_mm` + - `primary` + - `automatic` + +`deskctl get version` + +- stable: `data.version` +- stable: `data.backend` + +`deskctl get systeminfo` + +- stable: `data.backend` +- stable: `data.display` +- stable: `data.session_type` +- stable: `data.session` +- stable: `data.socket_path` +- stable: `data.screen` +- stable: `data.monitor_count` +- stable: `data.monitors` + +### Waits + +`deskctl wait window` +`deskctl wait focus` + +- stable: `data.wait` +- stable: `data.selector` +- stable: `data.elapsed_ms` +- stable: `data.window` + +### Selector-Driven Action Success + +For selector-driven action commands that resolve a window target, these identifiers are stable when present: + +- `data.ref_id` +- `data.window_id` +- `data.title` +- `data.selector` + +This applies to: + +- `click` +- `dblclick` +- `focus` +- `close` +- `move-window` +- `resize-window` + +The exact human-readable text rendering of those commands is not part of the JSON contract. + +### Artifact-Producing Commands + +`snapshot` +`screenshot` + +- stable: `data.screenshot` + +When the command also returns windows, `data.windows` uses the stable window payload documented above. + +## Stable Structured Error Kinds + +When a runtime command returns structured JSON failure data, these error kinds are stable: + +- `selector_not_found` +- `selector_ambiguous` +- `selector_invalid` +- `timeout` +- `not_found` +- `window_not_focused` as `data.last_observation.kind` or equivalent observation payload + +Stable structured failure fields include: + +- `data.kind` +- `data.selector` when selector-related +- `data.mode` when selector-related +- `data.candidates` for ambiguous selector failures +- `data.message` for invalid selector failures +- `data.wait` +- `data.timeout_ms` +- `data.poll_ms` +- `data.last_observation` + +## Best-Effort Fields + +These values are useful but environment-dependent and should be treated as best-effort: + +- exact monitor naming conventions +- EWMH/window-manager-dependent window ordering details +- cosmetic text formatting in non-JSON mode +- screenshot file names when the caller did not provide an explicit path +- command stderr wording outside the structured `kind` classifications above + +## Text Mode Expectations + +Text mode is intended to stay compact and follow-up-useful. + +The exact whitespace/alignment of text output is not stable. +The following expectations are stable at the behavioral level: + +- important runtime reads print actionable identifiers or geometry +- selector failures print enough detail to recover without `--json` +- artifact-producing commands print the artifact path +- window listings print both `@wN` refs and `window_id` values + +If an agent needs strict parsing, it should use `--json`. diff --git a/skills/SKILL.md b/skills/SKILL.md index 3b1733d..efbd188 100644 --- a/skills/SKILL.md +++ b/skills/SKILL.md @@ -90,6 +90,14 @@ deskctl daemon status # Check daemon status - `--session NAME` : Session name for multiple daemon instances (default: "default") - `--socket PATH` : Custom Unix socket path +## Output Contract + +- Prefer `--json` when an agent needs strict parsing. +- Use `window_id` for stable targeting inside a live daemon session. +- Use `ref_id` / `@wN` for quick short-lived follow-up actions after `snapshot` or `list-windows`. +- Structured JSON failures expose machine-usable `kind` values for selector and wait failures. +- The exact text formatting is intentionally compact but not the parsing contract. See `docs/runtime-output.md` for the stable field policy. + ## Window Refs After `snapshot` or `list-windows`, windows are assigned short refs: diff --git a/src/cli/mod.rs b/src/cli/mod.rs index ccd5b28..bab44c9 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -33,32 +33,38 @@ pub struct GlobalOpts { #[derive(Subcommand)] pub enum Command { /// Take a screenshot and list windows with @wN refs + #[command(after_help = SNAPSHOT_EXAMPLES)] Snapshot { /// Draw bounding boxes and labels on the screenshot #[arg(long)] annotate: bool, }, /// Click a window ref or coordinates + #[command(after_help = CLICK_EXAMPLES)] Click { - /// @w1 or x,y coordinates + /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates selector: String, }, /// Double-click a window ref or coordinates + #[command(after_help = DBLCLICK_EXAMPLES)] Dblclick { - /// @w1 or x,y coordinates + /// Selector (ref=w1, id=win1, title=Firefox, class=firefox, focused) or x,y coordinates selector: String, }, /// Type text into the focused window + #[command(after_help = TYPE_EXAMPLES)] Type { /// Text to type text: String, }, /// Press a key (e.g. enter, tab, escape) + #[command(after_help = PRESS_EXAMPLES)] Press { /// Key name key: String, }, /// Send a hotkey combination (e.g. ctrl c) + #[command(after_help = HOTKEY_EXAMPLES)] Hotkey { /// Key names (e.g. ctrl shift t) keys: Vec, @@ -67,18 +73,21 @@ pub enum Command { #[command(subcommand)] Mouse(MouseCmd), /// Focus a window by ref or name + #[command(after_help = FOCUS_EXAMPLES)] Focus { - /// @w1 or window name substring + /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring selector: String, }, /// Close a window by ref or name + #[command(after_help = CLOSE_EXAMPLES)] Close { - /// @w1 or window name substring + /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring selector: String, }, /// Move a window + #[command(after_help = MOVE_WINDOW_EXAMPLES)] MoveWindow { - /// @w1 or window name substring + /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring selector: String, /// X position x: i32, @@ -86,8 +95,9 @@ pub enum Command { y: i32, }, /// Resize a window + #[command(after_help = RESIZE_WINDOW_EXAMPLES)] ResizeWindow { - /// @w1 or window name substring + /// Selector: ref=w1, id=win1, title=Firefox, class=firefox, focused, or a fuzzy substring selector: String, /// Width w: u32, @@ -95,12 +105,16 @@ pub enum Command { h: u32, }, /// List all windows (same as snapshot but without screenshot) + #[command(after_help = LIST_WINDOWS_EXAMPLES)] ListWindows, /// Get screen resolution + #[command(after_help = GET_SCREEN_SIZE_EXAMPLES)] GetScreenSize, /// Get current mouse position + #[command(after_help = GET_MOUSE_POSITION_EXAMPLES)] GetMousePosition, /// Diagnose X11 runtime, screenshot, and daemon health + #[command(after_help = DOCTOR_EXAMPLES)] Doctor, /// Query runtime state #[command(subcommand)] @@ -109,6 +123,7 @@ pub enum Command { #[command(subcommand)] Wait(WaitCmd), /// Take a screenshot without window tree + #[command(after_help = SCREENSHOT_EXAMPLES)] Screenshot { /// Save path (default: /tmp/deskctl-{timestamp}.png) path: Option, @@ -117,6 +132,7 @@ pub enum Command { annotate: bool, }, /// Launch an application + #[command(after_help = LAUNCH_EXAMPLES)] Launch { /// Command to run command: String, @@ -132,6 +148,7 @@ pub enum Command { #[derive(Subcommand)] pub enum MouseCmd { /// Move the mouse cursor + #[command(after_help = MOUSE_MOVE_EXAMPLES)] Move { /// X coordinate x: i32, @@ -139,6 +156,7 @@ pub enum MouseCmd { y: i32, }, /// Scroll the mouse wheel + #[command(after_help = MOUSE_SCROLL_EXAMPLES)] Scroll { /// Amount (positive = down, negative = up) amount: i32, @@ -147,6 +165,7 @@ pub enum MouseCmd { axis: String, }, /// Drag from one position to another + #[command(after_help = MOUSE_DRAG_EXAMPLES)] Drag { /// Start X x1: i32, @@ -177,13 +196,47 @@ pub enum DaemonAction { const GET_ACTIVE_WINDOW_EXAMPLES: &str = "Examples:\n deskctl get active-window\n deskctl --json get active-window"; +const SNAPSHOT_EXAMPLES: &str = + "Examples:\n deskctl snapshot\n deskctl snapshot --annotate\n deskctl --json snapshot --annotate"; +const LIST_WINDOWS_EXAMPLES: &str = + "Examples:\n deskctl list-windows\n deskctl --json list-windows"; +const CLICK_EXAMPLES: &str = + "Examples:\n deskctl click @w1\n deskctl click 'title=Firefox'\n deskctl click 500,300"; +const DBLCLICK_EXAMPLES: &str = + "Examples:\n deskctl dblclick @w2\n deskctl dblclick 'class=firefox'\n deskctl dblclick 500,300"; +const TYPE_EXAMPLES: &str = + "Examples:\n deskctl type \"hello world\"\n deskctl type \"https://example.com\""; +const PRESS_EXAMPLES: &str = "Examples:\n deskctl press enter\n deskctl press escape"; +const HOTKEY_EXAMPLES: &str = "Examples:\n deskctl hotkey ctrl l\n deskctl hotkey ctrl shift t"; +const FOCUS_EXAMPLES: &str = + "Examples:\n deskctl focus @w1\n deskctl focus 'title=Firefox'\n deskctl focus focused"; +const CLOSE_EXAMPLES: &str = + "Examples:\n deskctl close @w3\n deskctl close 'id=win2'\n deskctl close 'class=firefox'"; +const MOVE_WINDOW_EXAMPLES: &str = + "Examples:\n deskctl move-window @w1 100 200\n deskctl move-window 'title=Firefox' 0 0"; +const RESIZE_WINDOW_EXAMPLES: &str = + "Examples:\n deskctl resize-window @w1 1280 720\n deskctl resize-window 'id=win2' 800 600"; const GET_MONITORS_EXAMPLES: &str = "Examples:\n deskctl get monitors\n deskctl --json get monitors"; const GET_VERSION_EXAMPLES: &str = "Examples:\n deskctl get version\n deskctl --json get version"; const GET_SYSTEMINFO_EXAMPLES: &str = "Examples:\n deskctl get systeminfo\n deskctl --json get systeminfo"; +const GET_SCREEN_SIZE_EXAMPLES: &str = + "Examples:\n deskctl get-screen-size\n deskctl --json get-screen-size"; +const GET_MOUSE_POSITION_EXAMPLES: &str = + "Examples:\n deskctl get-mouse-position\n deskctl --json get-mouse-position"; +const DOCTOR_EXAMPLES: &str = "Examples:\n deskctl doctor\n deskctl --json doctor"; const WAIT_WINDOW_EXAMPLES: &str = "Examples:\n deskctl wait window --selector 'title=Firefox' --timeout 10\n deskctl --json wait window --selector 'class=firefox' --poll-ms 100"; const WAIT_FOCUS_EXAMPLES: &str = "Examples:\n deskctl wait focus --selector 'id=win3' --timeout 5\n deskctl wait focus --selector focused --poll-ms 200"; +const SCREENSHOT_EXAMPLES: &str = + "Examples:\n deskctl screenshot\n deskctl screenshot /tmp/screen.png\n deskctl screenshot --annotate"; +const LAUNCH_EXAMPLES: &str = + "Examples:\n deskctl launch firefox\n deskctl launch code -- --new-window"; +const MOUSE_MOVE_EXAMPLES: &str = + "Examples:\n deskctl mouse move 500 300\n deskctl mouse move 0 0"; +const MOUSE_SCROLL_EXAMPLES: &str = + "Examples:\n deskctl mouse scroll 3\n deskctl mouse scroll -3 --axis vertical"; +const MOUSE_DRAG_EXAMPLES: &str = "Examples:\n deskctl mouse drag 100 100 500 500"; #[derive(Subcommand)] pub enum GetCmd { @@ -338,154 +391,523 @@ fn build_request(cmd: &Command) -> Result { fn print_response(cmd: &Command, response: &Response) -> Result<()> { if !response.success { - if let Some(ref err) = response.error { - eprintln!("Error: {err}"); - } - if let Some(ref data) = response.data { - if let Some(kind) = data.get("kind").and_then(|value| value.as_str()) { - match kind { - "selector_ambiguous" => { - if let Some(candidates) = data.get("candidates").and_then(|v| v.as_array()) - { - eprintln!("Candidates:"); - for candidate in candidates { - print_window_to_stderr(candidate); - } - } - } - "timeout" => { - if let Some(selector) = data.get("selector").and_then(|v| v.as_str()) { - let wait = data.get("wait").and_then(|v| v.as_str()).unwrap_or("wait"); - let timeout_ms = - data.get("timeout_ms").and_then(|v| v.as_u64()).unwrap_or(0); - eprintln!( - "Timed out after {timeout_ms}ms waiting for {wait} selector {selector}" - ); - } - } - _ => {} - } - } + for line in render_error_lines(response) { + eprintln!("{line}"); } std::process::exit(1); } - if let Some(ref data) = response.data { - // For snapshot, print compact text format - if matches!(cmd, Command::Snapshot { .. } | Command::ListWindows) { - if let Some(screenshot) = data.get("screenshot").and_then(|v| v.as_str()) { - println!("Screenshot: {screenshot}"); - } - if let Some(windows) = data.get("windows").and_then(|v| v.as_array()) { - println!("Windows:"); - for w in windows { - let ref_id = w.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?"); - let title = w.get("title").and_then(|v| v.as_str()).unwrap_or(""); - let focused = w.get("focused").and_then(|v| v.as_bool()).unwrap_or(false); - let minimized = w - .get("minimized") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let x = w.get("x").and_then(|v| v.as_i64()).unwrap_or(0); - let y = w.get("y").and_then(|v| v.as_i64()).unwrap_or(0); - let width = w.get("width").and_then(|v| v.as_u64()).unwrap_or(0); - let height = w.get("height").and_then(|v| v.as_u64()).unwrap_or(0); - let state = if focused { - "focused" - } else if minimized { - "hidden" - } else { - "visible" - }; - let display_title = truncate_display(title, 30); - println!( - "@{:<4} {:<30} ({:<7}) {},{} {}x{}", - ref_id, display_title, state, x, y, width, height - ); - } - } - } else if matches!( - cmd, - Command::Get(GetCmd::ActiveWindow) - | Command::Wait(WaitCmd::Window(_)) - | Command::Wait(WaitCmd::Focus(_)) - ) { - if let Some(window) = data.get("window") { - print_window(window); - if let Some(elapsed_ms) = data.get("elapsed_ms").and_then(|v| v.as_u64()) { - println!("Elapsed: {elapsed_ms}ms"); - } - } else { - println!("{}", serde_json::to_string_pretty(data)?); - } - } else if matches!(cmd, Command::Get(GetCmd::Monitors)) { - if let Some(monitors) = data.get("monitors").and_then(|v| v.as_array()) { - for monitor in monitors { - let name = monitor - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("monitor"); - let x = monitor.get("x").and_then(|v| v.as_i64()).unwrap_or(0); - let y = monitor.get("y").and_then(|v| v.as_i64()).unwrap_or(0); - let width = monitor.get("width").and_then(|v| v.as_u64()).unwrap_or(0); - let height = monitor.get("height").and_then(|v| v.as_u64()).unwrap_or(0); - let primary = monitor - .get("primary") - .and_then(|v| v.as_bool()) - .unwrap_or(false); - let primary_suffix = if primary { " primary" } else { "" }; - println!( - "{name:<16} {},{} {}x{}{primary_suffix}", - x, y, width, height - ); - } - } - } else if matches!(cmd, Command::Get(GetCmd::Version)) { - let version = data - .get("version") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - let backend = data - .get("backend") - .and_then(|v| v.as_str()) - .unwrap_or("unknown"); - println!("deskctl {version} ({backend})"); - } else if matches!(cmd, Command::Get(GetCmd::Systeminfo)) { - println!("{}", serde_json::to_string_pretty(data)?); - } else { - // Generic: print JSON data - println!("{}", serde_json::to_string_pretty(data)?); - } + for line in render_success_lines(cmd, response.data.as_ref())? { + println!("{line}"); } Ok(()) } -fn print_window(window: &serde_json::Value) { - print_window_line(window, false); +fn render_success_lines(cmd: &Command, data: Option<&serde_json::Value>) -> Result> { + let Some(data) = data else { + return Ok(vec!["ok".to_string()]); + }; + + let lines = match cmd { + Command::Snapshot { .. } | Command::ListWindows => render_window_listing(data), + Command::Get(GetCmd::ActiveWindow) + | Command::Wait(WaitCmd::Window(_)) + | Command::Wait(WaitCmd::Focus(_)) => render_window_wait_or_read(data), + Command::Get(GetCmd::Monitors) => render_monitor_listing(data), + Command::Get(GetCmd::Version) => vec![render_version_line(data)], + Command::Get(GetCmd::Systeminfo) => render_systeminfo_lines(data), + Command::GetScreenSize => vec![render_screen_size_line(data)], + Command::GetMousePosition => vec![render_mouse_position_line(data)], + Command::Screenshot { annotate, .. } => render_screenshot_lines(data, *annotate), + Command::Click { .. } => vec![render_click_line(data, false)], + Command::Dblclick { .. } => vec![render_click_line(data, true)], + Command::Type { .. } => vec![render_type_line(data)], + Command::Press { .. } => vec![render_press_line(data)], + Command::Hotkey { .. } => vec![render_hotkey_line(data)], + Command::Mouse(sub) => vec![render_mouse_line(sub, data)], + Command::Focus { .. } => vec![render_window_action_line("Focused", data)], + Command::Close { .. } => vec![render_window_action_line("Closed", data)], + Command::MoveWindow { .. } => vec![render_move_window_line(data)], + Command::ResizeWindow { .. } => vec![render_resize_window_line(data)], + Command::Launch { .. } => vec![render_launch_line(data)], + Command::Doctor | Command::Daemon(_) => vec![serde_json::to_string_pretty(data)?], + }; + + Ok(lines) } -fn print_window_to_stderr(window: &serde_json::Value) { - print_window_line(window, true); +fn render_error_lines(response: &Response) -> Vec { + let mut lines = Vec::new(); + if let Some(err) = &response.error { + lines.push(format!("Error: {err}")); + } + + let Some(data) = response.data.as_ref() else { + return lines; + }; + + let Some(kind) = data.get("kind").and_then(|value| value.as_str()) else { + return lines; + }; + + match kind { + "selector_not_found" => { + let selector = data + .get("selector") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let mode = data + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + lines.push(format!("Selector: {selector} (mode: {mode})")); + } + "selector_invalid" => { + let selector = data + .get("selector") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let mode = data + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + lines.push(format!("Selector: {selector} (mode: {mode})")); + if let Some(message) = data.get("message").and_then(|value| value.as_str()) { + lines.push(format!("Reason: {message}")); + } + } + "selector_ambiguous" => { + let selector = data + .get("selector") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let mode = data + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + lines.push(format!("Selector: {selector} (mode: {mode})")); + if let Some(candidates) = data.get("candidates").and_then(|value| value.as_array()) { + lines.push("Candidates:".to_string()); + for candidate in candidates { + lines.push(window_line(candidate)); + } + } + } + "timeout" => { + let selector = data + .get("selector") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let wait = data + .get("wait") + .and_then(|value| value.as_str()) + .unwrap_or("wait"); + let timeout_ms = data + .get("timeout_ms") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + lines.push(format!( + "Timed out after {timeout_ms}ms waiting for {wait} selector {selector}" + )); + if let Some(observation) = data.get("last_observation") { + lines.extend(render_last_observation_lines(observation)); + } + } + "not_found" => { + if data + .get("mode") + .and_then(|value| value.as_str()) + .is_some_and(|mode| mode == "focused") + { + lines.push("No focused window is available.".to_string()); + } + } + _ => {} + } + + lines } -fn print_window_line(window: &serde_json::Value, stderr: bool) { - let ref_id = window.get("ref_id").and_then(|v| v.as_str()).unwrap_or("?"); +fn render_last_observation_lines(observation: &serde_json::Value) -> Vec { + let mut lines = Vec::new(); + let Some(kind) = observation.get("kind").and_then(|value| value.as_str()) else { + return lines; + }; + + match kind { + "window_not_focused" => { + lines.push( + "Last observation: matching window exists but is not focused yet.".to_string(), + ); + if let Some(window) = observation.get("window") { + lines.push(window_line(window)); + } + } + "selector_not_found" => { + let selector = observation + .get("selector") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let mode = observation + .get("mode") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + lines.push(format!( + "Last observation: no window matched selector {selector} (mode: {mode})" + )); + } + _ => { + lines.push(format!( + "Last observation: {}", + serde_json::to_string(observation).unwrap_or_else(|_| kind.to_string()) + )); + } + } + + lines +} + +fn render_window_listing(data: &serde_json::Value) -> Vec { + let mut lines = Vec::new(); + if let Some(screenshot) = data.get("screenshot").and_then(|value| value.as_str()) { + lines.push(format!("Screenshot: {screenshot}")); + } + if let Some(windows) = data.get("windows").and_then(|value| value.as_array()) { + lines.push(format!("Windows: {}", windows.len())); + for window in windows { + lines.push(window_line(window)); + } + } + lines +} + +fn render_window_wait_or_read(data: &serde_json::Value) -> Vec { + let mut lines = Vec::new(); + if let Some(window) = data.get("window") { + lines.push(window_line(window)); + } + if let Some(elapsed_ms) = data.get("elapsed_ms").and_then(|value| value.as_u64()) { + lines.push(format!("Elapsed: {elapsed_ms}ms")); + } + lines +} + +fn render_monitor_listing(data: &serde_json::Value) -> Vec { + let mut lines = Vec::new(); + if let Some(count) = data.get("count").and_then(|value| value.as_u64()) { + lines.push(format!("Monitors: {count}")); + } + if let Some(monitors) = data.get("monitors").and_then(|value| value.as_array()) { + for monitor in monitors { + let name = monitor + .get("name") + .and_then(|value| value.as_str()) + .unwrap_or("monitor"); + let x = monitor + .get("x") + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let y = monitor + .get("y") + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let width = monitor + .get("width") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let height = monitor + .get("height") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let primary = monitor + .get("primary") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let automatic = monitor + .get("automatic") + .and_then(|value| value.as_bool()) + .unwrap_or(false); + let mut flags = Vec::new(); + if primary { + flags.push("primary"); + } + if automatic { + flags.push("automatic"); + } + let suffix = if flags.is_empty() { + String::new() + } else { + format!(" [{}]", flags.join(", ")) + }; + lines.push(format!("{name:<16} {x},{y} {width}x{height}{suffix}")); + } + } + lines +} + +fn render_version_line(data: &serde_json::Value) -> String { + let version = data + .get("version") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + let backend = data + .get("backend") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + format!("deskctl {version} ({backend})") +} + +fn render_systeminfo_lines(data: &serde_json::Value) -> Vec { + let mut lines = Vec::new(); + let backend = data + .get("backend") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + lines.push(format!("Backend: {backend}")); + if let Some(display) = data.get("display").and_then(|value| value.as_str()) { + lines.push(format!("Display: {display}")); + } + if let Some(session_type) = data.get("session_type").and_then(|value| value.as_str()) { + lines.push(format!("Session type: {session_type}")); + } + if let Some(session) = data.get("session").and_then(|value| value.as_str()) { + lines.push(format!("Session: {session}")); + } + if let Some(socket_path) = data.get("socket_path").and_then(|value| value.as_str()) { + lines.push(format!("Socket: {socket_path}")); + } + if let Some(screen) = data.get("screen") { + lines.push(format!("Screen: {}", screen_dimensions(screen))); + } + if let Some(count) = data.get("monitor_count").and_then(|value| value.as_u64()) { + lines.push(format!("Monitor count: {count}")); + } + if let Some(monitors) = data.get("monitors").and_then(|value| value.as_array()) { + for monitor in monitors { + lines.push(format!( + " {}", + render_monitor_listing(&serde_json::json!({"monitors": [monitor]}))[0] + )); + } + } + lines +} + +fn render_screen_size_line(data: &serde_json::Value) -> String { + format!("Screen: {}", screen_dimensions(data)) +} + +fn render_mouse_position_line(data: &serde_json::Value) -> String { + let x = data.get("x").and_then(|value| value.as_i64()).unwrap_or(0); + let y = data.get("y").and_then(|value| value.as_i64()).unwrap_or(0); + format!("Pointer: {x},{y}") +} + +fn render_screenshot_lines(data: &serde_json::Value, annotate: bool) -> Vec { + let mut lines = Vec::new(); + if let Some(screenshot) = data.get("screenshot").and_then(|value| value.as_str()) { + lines.push(format!("Screenshot: {screenshot}")); + } + if annotate { + if let Some(windows) = data.get("windows").and_then(|value| value.as_array()) { + lines.push(format!("Annotated windows: {}", windows.len())); + for window in windows { + lines.push(window_line(window)); + } + } + } + lines +} + +fn render_click_line(data: &serde_json::Value, double: bool) -> String { + let action = if double { "Double-clicked" } else { "Clicked" }; + let key = if double { "double_clicked" } else { "clicked" }; + let x = data + .get(key) + .and_then(|value| value.get("x")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let y = data + .get(key) + .and_then(|value| value.get("y")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + match target_summary(data) { + Some(target) => format!("{action} {x},{y} on {target}"), + None => format!("{action} {x},{y}"), + } +} + +fn render_type_line(data: &serde_json::Value) -> String { + let typed = data + .get("typed") + .and_then(|value| value.as_str()) + .unwrap_or(""); + format!("Typed: {}", quoted_summary(typed, 60)) +} + +fn render_press_line(data: &serde_json::Value) -> String { + let key = data + .get("pressed") + .and_then(|value| value.as_str()) + .unwrap_or("unknown"); + format!("Pressed: {key}") +} + +fn render_hotkey_line(data: &serde_json::Value) -> String { + let keys = data + .get("hotkey") + .and_then(|value| value.as_array()) + .map(|items| { + items + .iter() + .filter_map(|value| value.as_str()) + .collect::>() + .join("+") + }) + .filter(|value| !value.is_empty()) + .unwrap_or_else(|| "unknown".to_string()); + format!("Hotkey: {keys}") +} + +fn render_mouse_line(sub: &MouseCmd, data: &serde_json::Value) -> String { + match sub { + MouseCmd::Move { .. } => { + let x = data + .get("moved") + .and_then(|value| value.get("x")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let y = data + .get("moved") + .and_then(|value| value.get("y")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + format!("Moved pointer to {x},{y}") + } + MouseCmd::Scroll { .. } => { + let amount = data + .get("scrolled") + .and_then(|value| value.get("amount")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let axis = data + .get("scrolled") + .and_then(|value| value.get("axis")) + .and_then(|value| value.as_str()) + .unwrap_or("vertical"); + format!("Scrolled {axis} by {amount}") + } + MouseCmd::Drag { .. } => { + let x1 = data + .get("dragged") + .and_then(|value| value.get("from")) + .and_then(|value| value.get("x")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let y1 = data + .get("dragged") + .and_then(|value| value.get("from")) + .and_then(|value| value.get("y")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let x2 = data + .get("dragged") + .and_then(|value| value.get("to")) + .and_then(|value| value.get("x")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let y2 = data + .get("dragged") + .and_then(|value| value.get("to")) + .and_then(|value| value.get("y")) + .and_then(|value| value.as_i64()) + .unwrap_or(0); + format!("Dragged {x1},{y1} -> {x2},{y2}") + } + } +} + +fn render_window_action_line(action: &str, data: &serde_json::Value) -> String { + match target_summary(data) { + Some(target) => format!("{action} {target}"), + None => action.to_string(), + } +} + +fn render_move_window_line(data: &serde_json::Value) -> String { + let x = data.get("x").and_then(|value| value.as_i64()).unwrap_or(0); + let y = data.get("y").and_then(|value| value.as_i64()).unwrap_or(0); + match target_summary(data) { + Some(target) => format!("Moved {target} to {x},{y}"), + None => format!("Moved window to {x},{y}"), + } +} + +fn render_resize_window_line(data: &serde_json::Value) -> String { + let width = data + .get("width") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let height = data + .get("height") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + match target_summary(data) { + Some(target) => format!("Resized {target} to {width}x{height}"), + None => format!("Resized window to {width}x{height}"), + } +} + +fn render_launch_line(data: &serde_json::Value) -> String { + let command = data + .get("command") + .and_then(|value| value.as_str()) + .unwrap_or("command"); + let pid = data + .get("pid") + .and_then(|value| value.as_u64()) + .map(|value| value.to_string()) + .unwrap_or_else(|| "unknown".to_string()); + format!("Launched {command} (pid {pid})") +} + +fn window_line(window: &serde_json::Value) -> String { + let ref_id = window + .get("ref_id") + .and_then(|value| value.as_str()) + .unwrap_or("?"); let window_id = window .get("window_id") - .and_then(|v| v.as_str()) + .and_then(|value| value.as_str()) .unwrap_or("unknown"); - let title = window.get("title").and_then(|v| v.as_str()).unwrap_or(""); + let title = window + .get("title") + .and_then(|value| value.as_str()) + .unwrap_or(""); let focused = window .get("focused") - .and_then(|v| v.as_bool()) + .and_then(|value| value.as_bool()) .unwrap_or(false); let minimized = window .get("minimized") - .and_then(|v| v.as_bool()) + .and_then(|value| value.as_bool()) .unwrap_or(false); - let x = window.get("x").and_then(|v| v.as_i64()).unwrap_or(0); - let y = window.get("y").and_then(|v| v.as_i64()).unwrap_or(0); - let width = window.get("width").and_then(|v| v.as_u64()).unwrap_or(0); - let height = window.get("height").and_then(|v| v.as_u64()).unwrap_or(0); + let x = window + .get("x") + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let y = window + .get("y") + .and_then(|value| value.as_i64()) + .unwrap_or(0); + let width = window + .get("width") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let height = window + .get("height") + .and_then(|value| value.as_u64()) + .unwrap_or(0); let state = if focused { "focused" } else if minimized { @@ -493,24 +915,50 @@ fn print_window_line(window: &serde_json::Value, stderr: bool) { } else { "visible" }; - let line = format!( - "@{:<4} {:<30} ({:<7}) {},{} {}x{} [{}]", - ref_id, - truncate_display(title, 30), - state, - x, - y, - width, - height, - window_id - ); - if stderr { - eprintln!("{line}"); - } else { - println!("{line}"); + format!( + "@{ref_id:<4} {:<30} ({state:<7}) {x},{y} {width}x{height} [{window_id}]", + truncate_display(title, 30) + ) +} + +fn target_summary(data: &serde_json::Value) -> Option { + let ref_id = data.get("ref_id").and_then(|value| value.as_str()); + let window_id = data.get("window_id").and_then(|value| value.as_str()); + let title = data + .get("title") + .or_else(|| data.get("window")) + .and_then(|value| value.as_str()); + + match (ref_id, window_id, title) { + (Some(ref_id), Some(window_id), Some(title)) => Some(format!( + "@{ref_id} [{window_id}] {}", + quoted_summary(title, 40) + )), + (None, Some(window_id), Some(title)) => { + Some(format!("[{window_id}] {}", quoted_summary(title, 40))) + } + (Some(ref_id), Some(window_id), None) => Some(format!("@{ref_id} [{window_id}]")), + (None, Some(window_id), None) => Some(format!("[{window_id}]")), + _ => None, } } +fn quoted_summary(value: &str, max_chars: usize) -> String { + format!("\"{}\"", truncate_display(value, max_chars)) +} + +fn screen_dimensions(data: &serde_json::Value) -> String { + let width = data + .get("width") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + let height = data + .get("height") + .and_then(|value| value.as_u64()) + .unwrap_or(0); + format!("{width}x{height}") +} + fn truncate_display(value: &str, max_chars: usize) -> String { let char_count = value.chars().count(); if char_count <= max_chars { @@ -520,3 +968,129 @@ fn truncate_display(value: &str, max_chars: usize) -> String { let truncated: String = value.chars().take(max_chars.saturating_sub(3)).collect(); format!("{truncated}...") } + +#[cfg(test)] +mod tests { + use super::{ + render_error_lines, render_screen_size_line, render_success_lines, target_summary, + truncate_display, App, Command, Response, + }; + use clap::CommandFactory; + use serde_json::json; + + #[test] + fn help_examples_include_snapshot_examples() { + let help = App::command() + .find_subcommand_mut("snapshot") + .expect("snapshot subcommand must exist") + .render_long_help() + .to_string(); + assert!(help.contains("deskctl snapshot --annotate")); + } + + #[test] + fn window_listing_text_includes_window_ids() { + let lines = render_success_lines( + &Command::ListWindows, + Some(&json!({ + "windows": [{ + "ref_id": "w1", + "window_id": "win1", + "title": "Firefox", + "app_name": "firefox", + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "focused": true, + "minimized": false + }] + })), + ) + .unwrap(); + + assert_eq!(lines[0], "Windows: 1"); + assert!(lines[1].contains("[win1]")); + assert!(lines[1].contains("@w1")); + } + + #[test] + fn action_text_includes_target_identity() { + let lines = render_success_lines( + &Command::Focus { + selector: "title=Firefox".to_string(), + }, + Some(&json!({ + "action": "focus", + "window": "Firefox", + "title": "Firefox", + "ref_id": "w2", + "window_id": "win7" + })), + ) + .unwrap(); + + assert_eq!(lines, vec!["Focused @w2 [win7] \"Firefox\""]); + } + + #[test] + fn timeout_errors_render_last_observation() { + let lines = render_error_lines(&Response::err_with_data( + "Timed out waiting for focus to match selector: title=Firefox", + json!({ + "kind": "timeout", + "wait": "focus", + "selector": "title=Firefox", + "timeout_ms": 1000, + "last_observation": { + "kind": "window_not_focused", + "window": { + "ref_id": "w1", + "window_id": "win1", + "title": "Firefox", + "app_name": "firefox", + "x": 0, + "y": 0, + "width": 1280, + "height": 720, + "focused": false, + "minimized": false + } + } + }), + )); + + assert!(lines + .iter() + .any(|line| line + .contains("Timed out after 1000ms waiting for focus selector title=Firefox"))); + assert!(lines + .iter() + .any(|line| line.contains("matching window exists but is not focused yet"))); + assert!(lines.iter().any(|line| line.contains("[win1]"))); + } + + #[test] + fn screen_size_text_is_compact() { + assert_eq!( + render_screen_size_line(&json!({"width": 1440, "height": 900})), + "Screen: 1440x900" + ); + } + + #[test] + fn target_summary_prefers_ref_and_window_id() { + let summary = target_summary(&json!({ + "ref_id": "w1", + "window_id": "win1", + "title": "Firefox" + })); + assert_eq!(summary.as_deref(), Some("@w1 [win1] \"Firefox\"")); + } + + #[test] + fn truncate_display_is_char_safe() { + let input = format!("fire{}fox", '\u{00E9}'); + assert_eq!(truncate_display(&input, 7), "fire..."); + } +} diff --git a/src/core/types.rs b/src/core/types.rs index 845a4c0..0dca365 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -88,9 +88,21 @@ impl std::fmt::Display for WindowInfo { #[allow(dead_code)] fn truncate(s: &str, max: usize) -> String { - if s.len() <= max { + if s.chars().count() <= max { s.to_string() } else { - format!("{}...", &s[..max - 3]) + let truncated: String = s.chars().take(max.saturating_sub(3)).collect(); + format!("{truncated}...") + } +} + +#[cfg(test)] +mod tests { + use super::truncate; + + #[test] + fn truncate_is_char_safe() { + let input = format!("fire{}fox", '\u{00E9}'); + assert_eq!(truncate(&input, 7), "fire..."); } } diff --git a/src/daemon/handler.rs b/src/daemon/handler.rs index c0f4f1d..e8cab3a 100644 --- a/src/daemon/handler.rs +++ b/src/daemon/handler.rs @@ -81,9 +81,13 @@ async fn handle_click(request: &Request, state: &Arc>) -> Res ResolveResult::Match(entry) => { let (x, y) = entry.center(); match state.backend.click(x, y) { - Ok(()) => Response::ok( - serde_json::json!({"clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}), - ), + Ok(()) => Response::ok(serde_json::json!({ + "clicked": {"x": x, "y": y}, + "selector": selector, + "ref_id": entry.ref_id, + "window_id": entry.window_id, + "title": entry.title, + })), Err(error) => Response::err(format!("Click failed: {error}")), } } @@ -117,9 +121,13 @@ async fn handle_dblclick(request: &Request, state: &Arc>) -> ResolveResult::Match(entry) => { let (x, y) = entry.center(); match state.backend.dblclick(x, y) { - Ok(()) => Response::ok( - serde_json::json!({"double_clicked": {"x": x, "y": y, "selector": selector, "window_id": entry.window_id}}), - ), + Ok(()) => Response::ok(serde_json::json!({ + "double_clicked": {"x": x, "y": y}, + "selector": selector, + "ref_id": entry.ref_id, + "window_id": entry.window_id, + "title": entry.title, + })), Err(error) => Response::err(format!("Double-click failed: {error}")), } } @@ -267,7 +275,10 @@ async fn handle_window_action( Ok(()) => Response::ok(serde_json::json!({ "action": action, "window": entry.title, + "title": entry.title, + "ref_id": entry.ref_id, "window_id": entry.window_id, + "selector": selector, })), Err(error) => Response::err(format!("{action} failed: {error}")), } @@ -296,7 +307,10 @@ async fn handle_move_window(request: &Request, state: &Arc>) match state.backend.move_window(entry.backend_window_id, x, y) { Ok(()) => Response::ok(serde_json::json!({ "moved": entry.title, + "title": entry.title, + "ref_id": entry.ref_id, "window_id": entry.window_id, + "selector": selector, "x": x, "y": y, })), @@ -338,7 +352,10 @@ async fn handle_resize_window(request: &Request, state: &Arc> { Ok(()) => Response::ok(serde_json::json!({ "resized": entry.title, + "title": entry.title, + "ref_id": entry.ref_id, "window_id": entry.window_id, + "selector": selector, "width": width, "height": height, })),