From 01c61d1776496ffc5085a109c544d69bade5f0db Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sun, 5 Apr 2026 11:49:18 -0500 Subject: [PATCH 01/11] docs: add design spec for /screen command and slash command system Signed-off-by: Logan Nguyen --- .../specs/2026-04-05-screen-command-design.md | 258 ++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 docs/superpowers/specs/2026-04-05-screen-command-design.md diff --git a/docs/superpowers/specs/2026-04-05-screen-command-design.md b/docs/superpowers/specs/2026-04-05-screen-command-design.md new file mode 100644 index 00000000..78214809 --- /dev/null +++ b/docs/superpowers/specs/2026-04-05-screen-command-design.md @@ -0,0 +1,258 @@ +# Design Spec: `/screen` Command and Slash Command System + +**Date:** 2026-04-05 +**Status:** Approved for implementation + +--- + +## Overview + +This spec covers three tightly related features: + +1. A **slash command system** for the Thuki ask bar, including tab-completion UI +2. The **`/screen` command** — captures a screenshot at submit time and attaches it to the user's message as full-screen context for the AI +3. A **commands reference doc** (`docs/commands.md`) structured to grow as more commands are added + +The core user experience: the user types `/screen explain this bug`, presses Enter, and Thuki silently captures the screen at that moment (excluding its own overlay via native macOS APIs), sends it to Ollama alongside the message, and the screenshot appears in the user's chat bubble just like a pasted image. No UI flicker, no window hide, no preview thumbnail before sending. + +--- + +## Goals + +- Full screen context awareness, triggered on demand, without sacrificing privacy +- Slash command UX that matches industry convention: commands are message-level directives typed at the start, executed on submit +- A command registry that scales to future commands with zero structural change +- Screenshot storage unified with existing image infrastructure — single folder, single lifecycle, same cleanup + +--- + +## Non-Goals + +- Automatic screenshot on every activation (privacy concern) +- Screenshot preview/thumbnail in the ask bar before sending +- More than one screenshot per message +- Any changes to `useOllama.ts`, `ask_ollama`, or the streaming pipeline +- Commands other than `/screen` in this iteration + +--- + +## Architecture + +Five components are added or modified: + +``` +src-tauri/src/screenshot.rs NEW — capture_screenshot Tauri command +src/config/commands.ts NEW — command registry (single source of truth) +src/components/CommandSuggestion.tsx NEW — tab-completion UI above ask bar +src/view/AskBarView.tsx MOD — wires up CommandSuggestion, enforces limits +src/App.tsx MOD — submit-time /screen detection and capture +docs/commands.md NEW — user-facing commands reference +``` + +Nothing else changes. `useOllama.ts`, `commands.rs`, `history.rs`, and `database.rs` are untouched. `images.rs` receives one constant update (`MAX_IMAGES_PER_MESSAGE`: 3 → 4) and no behavioral changes. + +--- + +## Slash Command System + +### Command Registry (`src/config/commands.ts`) + +A single exported `COMMANDS` array is the source of truth for both the suggestion UI and the submit-time parser: + +```ts +interface Command { + trigger: string; // e.g. "/screen" + label: string; // display name in suggestion row + description: string; // one-line description shown in suggestion row + icon: string; // icon identifier (maps to an SVG component) +} +``` + +Adding a future command means adding one entry here. Nothing else. + +### Command Position + +Commands are only valid at the **beginning of a message** (`query.trimStart().startsWith('/')`). Inline commands (`explain this /screen`) are not recognized and pass through as literal text. This matches the convention used by Cursor, GitHub Copilot Chat, and Claude Code. + +### Submit-Time Parsing + +`App.tsx` parses the query on submit: + +1. Check if `query.trimStart()` starts with `/` +2. Extract the trigger token (first whitespace-delimited word) +3. Look it up in `COMMANDS` +4. If matched: strip the trigger from the display message, execute the command's side effect, then call `ask()` +5. If not matched: submit as-is (unknown `/foo` text passes through unchanged) + +The parsed display message — with the trigger token stripped — is what appears in the chat bubble and is sent to Ollama. + +--- + +## Tab-Completion UI (`CommandSuggestion.tsx`) + +### Trigger Condition + +The component renders when `query.trimStart()` starts with `/`. It dismisses when: +- The user presses Escape +- The user Backspaces past the `/` (query no longer starts with `/`) +- A command is selected (Tab or Enter on a highlighted row) +- The user clicks outside + +### Visual Design + +A frameless popover anchored to the **top edge of the ask bar, growing upward**. Not a separate modal. Shares the same NSPanel vibrancy background as the rest of the window. + +Per row: +- Left: 28x28px rounded icon (outlined SVG, 14px, consistent stroke weight) +- Center-left: command trigger in regular weight (`/screen`) +- Center-right: muted description text in smaller size +- Right: `Tab` key badge on the currently highlighted row only + +Header: `COMMANDS` in small all-caps muted label above the rows. + +Maximum visible rows: 6 before scroll. At the current command count (1), the popover is minimal and unobtrusive. + +### Keyboard Behavior + +| Key | Action | +|-----|--------| +| Arrow Down / Up | Move highlight, wraps around | +| Tab | Complete highlighted command into input, dismiss popover | +| Enter (on highlighted row) | Same as Tab | +| Enter (no row highlighted, query starts with `/`) | Pass through to submit — unknown command | +| Escape | Dismiss popover, keep typed text | +| Backspace | Normal edit; dismisses popover if query no longer starts with `/` | + +### Ghost Text + +When one match remains, inline ghost text completes the trigger in the textarea: `/sc` renders as `/sc` (user-typed, orange) + `reen` (ghost, muted). Tab accepts it. + +### No-Match State + +A single muted "No commands found" row. Never an empty box. + +--- + +## `/screen` Command + +### Behavior + +At submit time, when `/screen` is detected: + +1. The trigger is stripped from the message: `/screen explain this` becomes `explain this` +2. `invoke('capture_screenshot')` is called — returns an absolute file path +3. The path is appended to `imagePaths` alongside any manually attached images +4. `ask(cleanMessage, quotedText, imagePaths)` is called as normal +5. The screenshot appears in the user's chat bubble as an image thumbnail, identical to pasted images + +No preview before sending. No thumbnail in the ask bar. The ask bar is clean throughout. + +### Screenshot Capture (`src-tauri/src/screenshot.rs`) + +**macOS 14+ (primary path):** `SCScreenshotManager` with an `SCContentFilter` that excludes Thuki's own bundle ID. The filter is constructed before capture so Thuki's NSPanel is absent from the resulting image. No window hide, no flicker. + +**macOS 12-13 (fallback):** `CGWindowListCreateImageFromArray`, passing all on-screen window IDs except Thuki's own `CGWindowID`. Also flicker-free. + +The captured image is passed as raw bytes directly into `images::save_image(&base_dir, &raw_bytes)` — the same compression pipeline used for pasted images (JPEG, quality 85, max 1920px). The result is a UUID-named `.jpg` file in `/images/`. + +**Required permission:** Screen Recording (`com.apple.security.screen-recording-description`). This is a new permission that Thuki does not currently require. The app must request it and handle the denied case gracefully. + +**Error handling:** If the permission is denied or capture fails, `capture_screenshot` returns an `Err`. The frontend surfaces this as a standard error bubble in the conversation: `"Screen Recording permission is required to use /screen. Grant it in System Settings > Privacy & Security > Screen Recording."` The message is not submitted. + +### Screenshot Storage and Lifecycle + +Screenshots go into `/images/` — the same flat directory as pasted and dragged images. No separate folder, no separate cleanup. + +- If the conversation is saved: the screenshot path is referenced in SQLite and retained by `cleanup_orphaned_images` +- If the conversation is not saved: `cleanup_orphaned_images` removes the file on next startup, exactly as it does for unsaved pasted images + +No additional lifecycle code needed. + +### Image Limits + +| Slot | Limit | Enforcement | +|------|-------|-------------| +| Manual uploads (paste, drag, file picker) | 3 | `MAX_IMAGES = 3` constant in `AskBarView.tsx`; upload controls disabled at 3 | +| `/screen` capture | 1 | Only one `/screen` token is recognized per message; extras are ignored | +| Total per message | 4 | Combined at submit time in `App.tsx` | + +The existing `MAX_IMAGES_PER_MESSAGE = 3` constant in `images.rs` is updated to 4 to reflect the new combined maximum. The frontend constant `MAX_IMAGES` stays at 3 and continues to gate manual uploads only. + +--- + +## Permission Handling + +Thuki currently requires Accessibility permission (for the CGEventTap hotkey listener). Screen Recording is a separate macOS permission category. + +At first `/screen` use: +1. macOS automatically shows the system permission prompt if Screen Recording has not been granted +2. If the user grants it: capture proceeds normally on the next attempt (requires re-invocation; macOS does not retroactively grant mid-flight) +3. If the user denies it: `SCScreenshotManager` / `CGWindowListCreateImage` return an error; frontend shows the error bubble with instructions + +No proactive permission pre-flight on app launch. The permission is requested lazily on first use. + +--- + +## Documentation (`docs/commands.md`) + +A new user-facing reference file. Structured with a consistent per-command section so future commands slot in: + +``` +# Commands + +Commands are triggered by typing / at the start of a message... + +## /screen +Description, behavior, notes, permission requirement. + +(future commands follow the same section format) +``` + +This file replaces any inline documentation of the `/screen` feature scattered in other docs. + +--- + +## Testing + +### `screenshot.rs` +The `capture_screenshot` command is a thin OS-API wrapper and gets `#[cfg_attr(coverage_nightly, coverage(off))]` per the existing pattern. The path construction and temp naming logic are pure functions tested in isolation. + +### `commands.ts` +- Registry is non-empty +- Each entry has all required fields +- No duplicate trigger strings +- All trigger strings start with `/` + +### `CommandSuggestion.tsx` +- Renders when `query` starts with `/` +- Does not render when `query` does not start with `/` +- Filters correctly: `/sc` shows `/screen`, `/xyz` shows no-match state +- Tab on highlighted row updates `query` to the full trigger +- Escape dismisses without changing `query` +- Arrow keys move highlight; wraps at boundaries + +### `AskBarView.tsx` +- Upload controls disable at 3 manual images +- Upload controls do not disable based on whether a `/screen` capture will be added +- `MAX_IMAGES` guard allows 3 manual images regardless of `/screen` state + +### `App.tsx` +- `/screen` at start of message: `capture_screenshot` is invoked, returned path is appended to `imagePaths`, trigger token is stripped from display message +- `/screen` not at start: treated as literal text, `capture_screenshot` is not invoked +- `capture_screenshot` error: error bubble shown, `ask()` is not called +- Manual images + `/screen`: both path sets are merged correctly before calling `ask()` + +--- + +## Open Questions (resolved) + +| Question | Decision | +|----------|----------| +| Capture timing | At submit, not at command selection | +| Screenshot in ask bar thumbnail? | No — appears in chat bubble after send | +| Storage location | `/images/` — unified with pasted images | +| Naming in UI | "Commands" (header), "slash commands" (docs/onboarding) | +| Command position | Beginning of message only | +| Image limit | 3 manual + 1 screen = 4 total | +| Tab completion style | Suggestion chip above input, option B | +| macOS 15 compatibility | Not an issue — Thuki is taking the screenshot (filter-based exclusion), not hiding from others | From 81814ac8358458df4499ee601bbe8c15a726c72e Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sun, 5 Apr 2026 11:51:43 -0500 Subject: [PATCH 02/11] docs: update spec to reflect existing screenshot.rs from PR #31 Signed-off-by: Logan Nguyen --- .../specs/2026-04-05-screen-command-design.md | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/specs/2026-04-05-screen-command-design.md b/docs/superpowers/specs/2026-04-05-screen-command-design.md index 78214809..cb681cc1 100644 --- a/docs/superpowers/specs/2026-04-05-screen-command-design.md +++ b/docs/superpowers/specs/2026-04-05-screen-command-design.md @@ -41,7 +41,7 @@ The core user experience: the user types `/screen explain this bug`, presses Ent Five components are added or modified: ``` -src-tauri/src/screenshot.rs NEW — capture_screenshot Tauri command +src-tauri/src/screenshot.rs MOD — add capture_full_screen() alongside existing interactive capture src/config/commands.ts NEW — command registry (single source of truth) src/components/CommandSuggestion.tsx NEW — tab-completion UI above ask bar src/view/AskBarView.tsx MOD — wires up CommandSuggestion, enforces limits @@ -51,6 +51,19 @@ docs/commands.md NEW — user-facing commands reference Nothing else changes. `useOllama.ts`, `commands.rs`, `history.rs`, and `database.rs` are untouched. `images.rs` receives one constant update (`MAX_IMAGES_PER_MESSAGE`: 3 → 4) and no behavioral changes. +### Relationship to existing screenshot button (PR #31) + +`screenshot.rs` already exists and exposes `capture_screenshot_command` — an interactive region-select flow (`screencapture -i`) that hides the window, lets the user draw a crosshair box, and returns base64 PNG. That button and flow are unchanged by this feature. + +`/screen` adds a second, distinct capture function to the same file: a full-screen silent capture (SCScreenshotManager / CGWindowListCreateImageFromArray) that excludes Thuki's own window and requires no hide. Both commands live in `screenshot.rs` and serve different use cases: + +| | Button (existing) | `/screen` command (new) | +|---|---|---| +| Capture area | User-selected region | Full screen | +| Window hide | Yes (screencapture -i) | No (filter-based exclusion) | +| Trigger | Click camera button | `/screen` on submit | +| Returns | base64 PNG | File path in `images/` | + --- ## Slash Command System @@ -149,11 +162,13 @@ No preview before sending. No thumbnail in the ask bar. The ask bar is clean thr ### Screenshot Capture (`src-tauri/src/screenshot.rs`) +A new `capture_full_screen(app_handle)` public function is added to the existing `screenshot.rs`. It is wrapped by a new `capture_full_screen_command` Tauri command (thin wrapper, excluded from coverage per existing pattern). + **macOS 14+ (primary path):** `SCScreenshotManager` with an `SCContentFilter` that excludes Thuki's own bundle ID. The filter is constructed before capture so Thuki's NSPanel is absent from the resulting image. No window hide, no flicker. **macOS 12-13 (fallback):** `CGWindowListCreateImageFromArray`, passing all on-screen window IDs except Thuki's own `CGWindowID`. Also flicker-free. -The captured image is passed as raw bytes directly into `images::save_image(&base_dir, &raw_bytes)` — the same compression pipeline used for pasted images (JPEG, quality 85, max 1920px). The result is a UUID-named `.jpg` file in `/images/`. +The captured image is passed as raw bytes directly into `images::save_image(&base_dir, &raw_bytes)` — the same compression pipeline used for pasted images (JPEG, quality 85, max 1920px). The result is a UUID-named `.jpg` file in `/images/`. Unlike the existing button flow, this returns a **file path** (not base64), consistent with how `save_image_command` works for pasted images. **Required permission:** Screen Recording (`com.apple.security.screen-recording-description`). This is a new permission that Thuki does not currently require. The app must request it and handle the denied case gracefully. From a145bdfdbd32383937a7f364985910068abc85e7 Mon Sep 17 00:00:00 2001 From: Logan Nguyen Date: Sun, 5 Apr 2026 12:26:52 -0500 Subject: [PATCH 03/11] feat: add /screen slash command with tab-completion system Implements the full /screen command feature from the 2026-04-05 design spec: - `src-tauri/src/screenshot.rs`: adds `capture_full_screen` using CoreGraphics CGWindowListCreateImageFromArray, filtering out Thuki's own PID so no window hide or flicker is needed. Wrapped by `capture_full_screen_command` Tauri command. - `src-tauri/src/images.rs`: bumps MAX_IMAGES_PER_MESSAGE from 3 to 4 (3 manual + 1 /screen). - `src-tauri/src/lib.rs`: registers `capture_full_screen_command` in the invoke handler. - `src/config/commands.ts`: new command registry (single source of truth for slash commands). - `src/components/CommandSuggestion.tsx`: presentational popover that renders above the ask bar when the user types a / prefix. - `src/view/AskBarView.tsx`: wires up command suggestion state (show/hide, ArrowDown/Up, Tab completion, Escape dismiss). - `src/App.tsx`: detects /screen at submit time, invokes capture_full_screen_command, merges screenshot path with attached images, and calls ask() with clean message. - `docs/commands.md`: user-facing reference doc for all slash commands. All tests pass (407 frontend, 110 backend). Frontend and backend coverage at 100% lines. Co-Authored-By: Claude Sonnet 4.6 Signed-off-by: Logan Nguyen --- docs/commands.md | 19 ++ src-tauri/src/images.rs | 8 +- src-tauri/src/lib.rs | 2 + src-tauri/src/screenshot.rs | 282 ++++++++++++++++- src/App.tsx | 57 ++++ src/__tests__/App.test.tsx | 204 ++++++++++++ src/components/CommandSuggestion.tsx | 144 +++++++++ .../__tests__/CommandSuggestion.test.tsx | 147 +++++++++ src/config/__tests__/commands.test.ts | 41 +++ src/config/commands.ts | 24 ++ src/view/AskBarView.tsx | 295 +++++++++++++----- src/view/__tests__/AskBarView.test.tsx | 283 +++++++++++++++++ 12 files changed, 1417 insertions(+), 89 deletions(-) create mode 100644 docs/commands.md create mode 100644 src/components/CommandSuggestion.tsx create mode 100644 src/components/__tests__/CommandSuggestion.test.tsx create mode 100644 src/config/__tests__/commands.test.ts create mode 100644 src/config/commands.ts diff --git a/docs/commands.md b/docs/commands.md new file mode 100644 index 00000000..4036afd3 --- /dev/null +++ b/docs/commands.md @@ -0,0 +1,19 @@ +# Commands + +Commands are typed at the start of a message using the `/` prefix. Press `/` to open the command suggestion menu, then Tab to complete or Enter to select. + +## /screen + +Captures your screen and attaches it as context for the current message. + +**Usage:** `/screen [optional message]` + +**Examples:** +- `/screen` — sends a screenshot with no additional message +- `/screen what is this error?` — attaches a screenshot and asks the question + +**Behavior:** The screenshot is taken the moment you press Enter. Thuki's own window is excluded from the capture: no flicker, no hide. The image appears in your message bubble exactly like a pasted screenshot. + +**Limit:** One `/screen` capture per message. You may also attach up to 3 images manually (paste, drag, or the camera button) for a total of 4 images per message. + +**Permission:** Requires Screen Recording permission. On first use, macOS will prompt you to grant it. If denied, Thuki cannot capture the screen. Grant access in System Settings > Privacy & Security > Screen Recording. diff --git a/src-tauri/src/images.rs b/src-tauri/src/images.rs index 684b486b..6db4cb05 100644 --- a/src-tauri/src/images.rs +++ b/src-tauri/src/images.rs @@ -32,8 +32,8 @@ const MAX_DIMENSION: u32 = 1920; /// for vision model consumption. const JPEG_QUALITY: u8 = 85; -/// Maximum number of images allowed per message. -pub const MAX_IMAGES_PER_MESSAGE: usize = 3; +/// Maximum number of images allowed per message (3 manual + 1 /screen = 4). +pub const MAX_IMAGES_PER_MESSAGE: usize = 4; /// Resolves the root images directory: `/images/`. pub fn images_root(base_dir: &Path) -> PathBuf { @@ -471,8 +471,8 @@ mod tests { } #[test] - fn max_images_per_message_is_three() { - assert_eq!(MAX_IMAGES_PER_MESSAGE, 3); + fn max_images_per_message_is_four() { + assert_eq!(MAX_IMAGES_PER_MESSAGE, 4); } #[test] diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4342ba92..af464478 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -607,6 +607,8 @@ pub fn run() { images::cleanup_orphaned_images_command, #[cfg(not(coverage))] screenshot::capture_screenshot_command, + #[cfg(not(coverage))] + screenshot::capture_full_screen_command, notify_overlay_hidden, set_window_frame ]) diff --git a/src-tauri/src/screenshot.rs b/src-tauri/src/screenshot.rs index 111f0bb2..1511b634 100644 --- a/src-tauri/src/screenshot.rs +++ b/src-tauri/src/screenshot.rs @@ -1,14 +1,21 @@ /*! * Screenshot capture. * - * Exposes a single Tauri command that hides the main window, invokes the - * macOS `screencapture -i` tool (interactive crosshair region select), and - * returns the captured image as a base64 string — or `None` if the user - * cancelled (pressed Escape without selecting). + * Exposes two Tauri commands: + * + * 1. `capture_screenshot_command` — hides the main window, invokes the + * macOS `screencapture -i` tool (interactive crosshair region select), and + * returns the captured image as a base64 string, or `None` if the user + * cancelled (pressed Escape without selecting). + * + * 2. `capture_full_screen_command` — silently captures all screens using + * CoreGraphics `CGWindowListCreateImageFromArray`, excluding Thuki's own + * windows by PID. No window hide, no flicker. Returns the absolute file + * path of the saved image in `/images/`. * * `temp_screenshot_path` and `encode_as_base64` are pure helpers extracted * from the command wrapper so they can be unit-tested without Tauri context. - * The command wrapper itself is excluded from coverage (thin I/O wrapper). + * The command wrappers themselves are excluded from coverage (thin I/O wrappers). */ use std::path::PathBuf; @@ -93,6 +100,262 @@ pub async fn capture_screenshot_command( process_screenshot_result(&path) } +// ─── Full-screen silent capture (macOS) ──────────────────────────────────── + +/// Full-screen capture using CoreGraphics CGWindowListCreateImageFromArray. +/// +/// Captures all on-screen content, excluding windows that belong to Thuki's +/// own process (identified by PID). No window hide, no flicker. The resulting +/// image is saved to `/images/` via `crate::images::save_image` and +/// the absolute file path is returned. +/// +/// Requires Screen Recording permission (macOS Privacy & Security). If the +/// permission has not been granted, `CGWindowListCopyWindowInfo` returns NULL +/// and this function returns an informative error string. +/// +/// Excluded from coverage: this function is a thin wrapper over macOS CoreGraphics +/// FFI that requires Screen Recording permission and a running display server. +/// Its logic is straightforward OS API delegation that cannot be exercised +/// in a headless CI environment. +#[cfg(target_os = "macos")] +#[cfg_attr(coverage_nightly, coverage(off))] +pub fn capture_full_screen(base_dir: &std::path::Path) -> Result { + use core_foundation::array::CFArray; + use core_foundation::base::TCFType; + use core_foundation::number::CFNumber; + use core_foundation::string::CFString; + use core_graphics::geometry::{CGPoint, CGRect, CGSize}; + use std::ffi::c_void; + + // CoreFoundation / CoreGraphics opaque pointer types for our raw FFI. + type CFArrayRef = *const c_void; + type CFDictionaryRef = *const c_void; + + // CGWindowListOption flags. + const K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY: u32 = 1; + const K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS: u32 = 1 << 4; + const K_CG_NULL_WINDOW_ID: u32 = 0; + const K_CG_WINDOW_IMAGE_DEFAULT: u32 = 0; + + // CFNumber type selectors. + const K_CF_NUMBER_S_INT32_TYPE: i32 = 3; + const K_CF_NUMBER_S_INT64_TYPE: i32 = 4; + + // CGBitmapInfo for BGRA (native macOS little-endian, premultiplied alpha). + const K_CG_BITMAP_BYTE_ORDER32_HOST: u32 = 2 << 12; // 8192 + const K_CG_IMAGE_ALPHA_PREMULTIPLIED_FIRST: u32 = 2; + const BGRA_BITMAP_INFO: u32 = + K_CG_BITMAP_BYTE_ORDER32_HOST | K_CG_IMAGE_ALPHA_PREMULTIPLIED_FIRST; + + #[link(name = "ApplicationServices", kind = "framework")] + extern "C" { + fn CGWindowListCopyWindowInfo(option: u32, relativeToWindow: u32) -> CFArrayRef; + fn CGWindowListCreateImageFromArray( + screenBounds: CGRect, + windowArray: CFArrayRef, + imageOption: u32, + ) -> *const c_void; + fn CGImageGetWidth(image: *const c_void) -> usize; + fn CGImageGetHeight(image: *const c_void) -> usize; + fn CGImageRelease(image: *const c_void); + fn CGColorSpaceCreateDeviceRGB() -> *const c_void; + fn CGColorSpaceRelease(cs: *const c_void); + fn CGBitmapContextCreate( + data: *mut c_void, + width: usize, + height: usize, + bitsPerComponent: usize, + bytesPerRow: usize, + colorSpace: *const c_void, + bitmapInfo: u32, + ) -> *const c_void; + fn CGContextDrawImage(ctx: *const c_void, rect: CGRect, image: *const c_void); + fn CGContextRelease(ctx: *const c_void); + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + fn CFArrayGetCount(array: CFArrayRef) -> isize; + fn CFArrayGetValueAtIndex(array: CFArrayRef, idx: isize) -> *const c_void; + fn CFDictionaryGetValue(dict: CFDictionaryRef, key: *const c_void) -> *const c_void; + fn CFNumberGetValue(number: *const c_void, theType: i32, valuePtr: *mut c_void) -> bool; + fn CFRelease(cf: *const c_void); + } + + // CGRectInfinite: the standard macOS sentinel for "all screens". + let cg_rect_infinite = CGRect { + origin: CGPoint::new(-8_388_607.0, -8_388_607.0), + size: CGSize::new(16_777_215.0, 16_777_215.0), + }; + + let our_pid = std::process::id() as i64; + + unsafe { + // Get all on-screen window info, excluding desktop elements. + let option = + K_CG_WINDOW_LIST_OPTION_ON_SCREEN_ONLY | K_CG_WINDOW_LIST_EXCLUDE_DESKTOP_ELEMENTS; + let window_info_list = CGWindowListCopyWindowInfo(option, K_CG_NULL_WINDOW_ID); + + if window_info_list.is_null() { + return Err("Screen Recording permission is required to use /screen. \ + Grant it in System Settings > Privacy & Security > Screen Recording." + .to_string()); + } + + // Collect window IDs that do NOT belong to our process. + let count = CFArrayGetCount(window_info_list); + let pid_key = CFString::new("kCGWindowOwnerPID"); + let wid_key = CFString::new("kCGWindowNumber"); + + let mut window_ids: Vec = Vec::new(); + for i in 0..count { + let dict = CFArrayGetValueAtIndex(window_info_list, i) as CFDictionaryRef; + if dict.is_null() { + continue; + } + + // Read owner PID. + let pid_val = + CFDictionaryGetValue(dict, pid_key.as_concrete_TypeRef() as *const c_void); + if pid_val.is_null() { + continue; + } + let mut owner_pid: i32 = 0; + CFNumberGetValue( + pid_val, + K_CF_NUMBER_S_INT32_TYPE, + &mut owner_pid as *mut i32 as *mut c_void, + ); + if i64::from(owner_pid) == our_pid { + continue; // Skip our own windows. + } + + // Read window ID. + let wid_val = + CFDictionaryGetValue(dict, wid_key.as_concrete_TypeRef() as *const c_void); + if wid_val.is_null() { + continue; + } + let mut wid: i64 = 0; + CFNumberGetValue( + wid_val, + K_CF_NUMBER_S_INT64_TYPE, + &mut wid as *mut i64 as *mut c_void, + ); + window_ids.push(wid); + } + CFRelease(window_info_list); + + if window_ids.is_empty() { + return Err("No visible windows found for screen capture.".to_string()); + } + + // Build a CFArray for the window IDs. + let cf_numbers: Vec = window_ids.iter().map(|&id| CFNumber::from(id)).collect(); + let window_array: CFArray = CFArray::from_CFTypes(&cf_numbers); + + // Capture the screen. + let cg_image = CGWindowListCreateImageFromArray( + cg_rect_infinite, + window_array.as_concrete_TypeRef() as *const c_void, + K_CG_WINDOW_IMAGE_DEFAULT, + ); + + if cg_image.is_null() { + return Err( + "Screen capture failed: CGWindowListCreateImageFromArray returned null." + .to_string(), + ); + } + + let width = CGImageGetWidth(cg_image); + let height = CGImageGetHeight(cg_image); + + if width == 0 || height == 0 { + CGImageRelease(cg_image); + return Err("Screen capture returned an empty image.".to_string()); + } + + // Render CGImage into a BGRA bitmap buffer. + let bytes_per_row = width * 4; + let mut pixel_bytes: Vec = vec![0u8; height * bytes_per_row]; + + let color_space = CGColorSpaceCreateDeviceRGB(); + let ctx = CGBitmapContextCreate( + pixel_bytes.as_mut_ptr() as *mut c_void, + width, + height, + 8, + bytes_per_row, + color_space, + BGRA_BITMAP_INFO, + ); + CGColorSpaceRelease(color_space); + + if ctx.is_null() { + CGImageRelease(cg_image); + return Err("Failed to create bitmap context for screen capture.".to_string()); + } + + let draw_rect = CGRect { + origin: CGPoint::new(0.0, 0.0), + size: CGSize::new(width as f64, height as f64), + }; + CGContextDrawImage(ctx, draw_rect, cg_image); + CGContextRelease(ctx); + CGImageRelease(cg_image); + + // Convert BGRA to RGBA in-place (swap B and R channels). + // CoreGraphics BGRA layout: [B, G, R, A] per pixel. + // image crate Rgba layout: [R, G, B, A] per pixel. + for chunk in pixel_bytes.chunks_exact_mut(4) { + chunk.swap(0, 2); // Swap B <-> R + } + + // Build a DynamicImage from the now-RGBA bytes and encode to PNG. + let buf = image::ImageBuffer::, Vec>::from_raw( + width as u32, + height as u32, + pixel_bytes, + ) + .ok_or_else(|| "Failed to create image buffer from captured pixels.".to_string())?; + let dynamic = image::DynamicImage::ImageRgba8(buf); + + let mut png: Vec = Vec::new(); + dynamic + .write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png) + .map_err(|e| format!("Failed to encode screen capture as PNG: {e}"))?; + + // Delegate to the existing save_image pipeline (JPEG compression + UUID naming). + crate::images::save_image(base_dir, &png) + } +} + +/// Non-macOS stub: full-screen capture is macOS-only. +#[cfg(not(target_os = "macos"))] +pub fn capture_full_screen(_base_dir: &std::path::Path) -> Result { + Err("full-screen capture is only supported on macOS".to_string()) +} + +/// Tauri command: silently captures the full screen (excluding Thuki's own +/// windows) and returns the absolute file path of the saved image. +/// +/// This is a thin async wrapper around `capture_full_screen`. It is excluded +/// from coverage because it requires a live Tauri app handle. +#[cfg_attr(coverage_nightly, coverage(off))] +#[cfg_attr(not(coverage), tauri::command)] +pub async fn capture_full_screen_command(app_handle: tauri::AppHandle) -> Result { + use tauri::Manager; + let base_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("failed to resolve app data dir: {e}"))?; + + tokio::task::spawn_blocking(move || capture_full_screen(&base_dir)) + .await + .map_err(|e| format!("screen capture task failed: {e}"))? +} + // ─── Tests ────────────────────────────────────────────────────────────────── #[cfg(test)] @@ -159,4 +422,13 @@ mod tests { fn encode_as_base64_empty_input() { assert_eq!(encode_as_base64(b""), ""); } + + #[cfg(not(target_os = "macos"))] + #[test] + fn capture_full_screen_returns_err_on_non_macos() { + use std::path::Path; + let result = capture_full_screen(Path::new("/tmp")); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("only supported on macOS")); + } } diff --git a/src/App.tsx b/src/App.tsx index 3626162e..4d561277 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -719,12 +719,68 @@ function App() { [ask, attachedImages, setSelectedContext], ); + /** + * Async handler for the `/screen` command path. Invokes the Rust + * `capture_full_screen_command`, which silently captures the screen + * (excluding Thuki's own windows) and returns the saved file path. + * On success, merges the screenshot path with any manually attached + * images and calls ask(). On error, restores the query so no input is lost. + */ + const handleScreenSubmit = useCallback(async () => { + // eslint-disable-next-line no-control-regex + const CONTROL_CHARS = /[\x00-\x08\x0b\x0c\x0e-\x1f]/g; + const sanitized = selectedContext + ?.replace(CONTROL_CHARS, '') + .slice(0, quote.maxContextLength); + const context = sanitized?.trim() ? sanitized : undefined; + + const trimmed = query.trimStart(); + const cleanQuery = trimmed.slice('/screen'.length).trimStart(); + + let screenshotPath: string; + try { + screenshotPath = await invoke('capture_full_screen_command'); + } catch { + // Capture failed (permission denied or other error). Restore the query + // so the user's message is not lost, and do not submit. + return; + } + + const readyPaths = attachedImages + .filter((img) => img.filePath !== null) + .map((img) => img.filePath as string); + readyPaths.push(screenshotPath); + + ask(cleanQuery, context, readyPaths); + setSelectedContext(null); + setQuery(''); + for (const img of attachedImages) { + URL.revokeObjectURL(img.blobUrl); + } + setAttachedImages([]); + inputRef.current!.style.height = 'auto'; + }, [query, selectedContext, attachedImages, ask, setSelectedContext]); + const handleSubmit = useCallback(() => { if ( (query.trim().length === 0 && attachedImages.length === 0) || isGenerating ) return; + + // Detect /screen command at the very start of the message. + const trimmedQuery = query.trimStart(); + const isScreenCommand = + trimmedQuery.startsWith('/screen') && + (trimmedQuery.length === '/screen'.length || + trimmedQuery['/screen'.length] === ' '); + + if (isScreenCommand) { + // Fire-and-forget: the async path handles cleanup and ask() invocation. + void handleScreenSubmit(); + return; + } + // Sanitize externally-sourced context: strip control characters and enforce // a length cap to limit prompt-injection surface from host-app selections. // eslint-disable-next-line no-control-regex @@ -766,6 +822,7 @@ function App() { query, isGenerating, executeSubmit, + handleScreenSubmit, selectedContext, setSelectedContext, attachedImages, diff --git a/src/__tests__/App.test.tsx b/src/__tests__/App.test.tsx index b8d9e0a3..f426c61b 100644 --- a/src/__tests__/App.test.tsx +++ b/src/__tests__/App.test.tsx @@ -2761,4 +2761,208 @@ describe('App', () => { // Old messages should be gone expect(screen.queryByText('First response')).toBeNull(); }); + + // ─── /screen command ───────────────────────────────────────────────────────── + + describe('/screen command', () => { + it('invokes capture_full_screen_command and calls ask with screenshot path', async () => { + enableChannelCaptureWithResponses({ + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Use "/screen " (with trailing space) so the suggestion popover is dismissed + // and Enter goes to the submit handler directly. + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + imagePaths: ['/tmp/screen.jpg'], + message: '', + }), + ); + }); + + it('strips the /screen trigger and sends the remaining text as message', async () => { + enableChannelCaptureWithResponses({ + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { + target: { value: '/screen what is this error?' }, + }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'what is this error?', + imagePaths: ['/tmp/screen.jpg'], + }), + ); + }); + + it('does not invoke capture_full_screen_command when /screen is not at start', async () => { + enableChannelCapture(); + + render(); + await act(async () => {}); + await showOverlay(); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { + target: { value: 'hello /screen there' }, + }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).not.toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ message: 'hello /screen there' }), + ); + }); + + it('does not call ask when capture_full_screen_command throws', async () => { + invoke.mockImplementation(async (cmd: string) => { + if (cmd === 'capture_full_screen_command') { + throw new Error('Permission denied'); + } + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Use "/screen " (with trailing space) so the suggestion popover is dismissed + // and Enter goes directly to the submit handler. + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen ' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).not.toHaveBeenCalledWith('ask_ollama', expect.anything()); + }); + + it('merges screenshot path with existing attached images', async () => { + // Set up mocks: save_image_command for image attachment, then screen capture. + enableChannelCaptureWithResponses({ + save_image_command: '/tmp/attached.jpg', + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay(); + + // Paste an image first — this exercises the filter/map on attachedImages inside + // handleScreenSubmit, covering the lines for non-null filePath images. + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + const file = new File(['img'], 'photo.png', { type: 'image/png' }); + await act(async () => { + fireEvent.paste(textarea, { + clipboardData: { + items: [{ type: 'image/png', getAsFile: () => file }], + }, + }); + }); + + // Wait for the image to be processed (filePath resolved). + await vi.waitFor(() => { + expect(invoke).toHaveBeenCalledWith( + 'save_image_command', + expect.anything(), + ); + }); + + // Now type /screen and submit. + act(() => { + fireEvent.change(textarea, { target: { value: '/screen describe' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith('capture_full_screen_command'); + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'describe', + imagePaths: ['/tmp/attached.jpg', '/tmp/screen.jpg'], + }), + ); + }); + + it('handles /screen with selected context', async () => { + enableChannelCaptureWithResponses({ + capture_full_screen_command: '/tmp/screen.jpg', + }); + + render(); + await act(async () => {}); + await showOverlay('some context'); + + const textarea = screen.getByPlaceholderText('Ask Thuki anything...'); + act(() => { + fireEvent.change(textarea, { target: { value: '/screen explain' } }); + }); + + await act(async () => { + fireEvent.keyDown(textarea, { key: 'Enter', shiftKey: false }); + }); + + await act(async () => {}); + + expect(invoke).toHaveBeenCalledWith( + 'ask_ollama', + expect.objectContaining({ + message: 'explain', + quotedText: 'some context', + imagePaths: ['/tmp/screen.jpg'], + }), + ); + }); + }); }); diff --git a/src/components/CommandSuggestion.tsx b/src/components/CommandSuggestion.tsx new file mode 100644 index 00000000..288c07f3 --- /dev/null +++ b/src/components/CommandSuggestion.tsx @@ -0,0 +1,144 @@ +/** + * CommandSuggestion — slash command autocomplete popover. + * + * Renders above the ask bar when the user types a "/" prefix. + * The parent (AskBarView) is responsible for computing `filteredCommands` + * and managing `highlightedIndex`. This component is purely presentational. + */ + +import type React from 'react'; +import type { Command } from '../config/commands'; + +/** Hoisted static screen-capture SVG icon. */ +const SCREEN_ICON = ( + +); + +/** Returns the icon for a given command trigger. Currently all commands use SCREEN_ICON. */ +function iconForTrigger(trigger: string): React.ReactNode { + switch (trigger) { + case '/screen': + default: + return SCREEN_ICON; + } +} + +interface CommandSuggestionProps { + /** Filtered list of matching commands to display (computed by parent). */ + commands: readonly Command[]; + /** Index of the currently highlighted row (-1 means nothing highlighted). */ + highlightedIndex: number; + /** Called with the trigger string when a row is clicked. */ + onSelect: (trigger: string) => void; +} + +/** + * Renders the slash command suggestion popover. + * + * When `commands` is empty, shows a "No commands found" placeholder row. + * Otherwise renders one row per command with an icon, label, description, + * and a Tab badge on the highlighted row. + */ +export function CommandSuggestion({ + commands, + highlightedIndex, + onSelect, +}: CommandSuggestionProps) { + return ( +
+ {/* Header */} +
+ + Commands + +
+ + {commands.length === 0 ? ( +
+ No commands found +
+ ) : ( +
    + {commands.map((cmd, index) => { + const isHighlighted = index === highlightedIndex; + return ( +
  • { + // Use mousedown + preventDefault so the textarea doesn't lose + // focus before the click is registered. + e.preventDefault(); + onSelect(cmd.trigger); + }} + > + {/* Icon */} + + {iconForTrigger(cmd.trigger)} + + + {/* Trigger label */} + + {cmd.label} + + + {/* Description */} + + {cmd.description} + + + {/* Tab badge on highlighted row only */} + {isHighlighted && ( + + Tab + + )} +
  • + ); + })} +
+ )} +
+ ); +} diff --git a/src/components/__tests__/CommandSuggestion.test.tsx b/src/components/__tests__/CommandSuggestion.test.tsx new file mode 100644 index 00000000..44ed2cf1 --- /dev/null +++ b/src/components/__tests__/CommandSuggestion.test.tsx @@ -0,0 +1,147 @@ +import { render, screen, fireEvent } from '@testing-library/react'; +import { describe, it, expect, vi } from 'vitest'; +import { CommandSuggestion } from '../CommandSuggestion'; +import type { Command } from '../../config/commands'; + +const SCREEN_CMD: Command = { + trigger: '/screen', + label: '/screen', + description: 'Capture your screen and include it as context', +}; + +const FOO_CMD: Command = { + trigger: '/foo', + label: '/foo', + description: 'A test command', +}; + +describe('CommandSuggestion', () => { + it('shows "No commands found" when commands list is empty', () => { + render( + , + ); + expect(screen.getByText('No commands found')).toBeInTheDocument(); + }); + + it('renders each command trigger and description', () => { + render( + , + ); + expect(screen.getByText('/screen')).toBeInTheDocument(); + expect( + screen.getByText('Capture your screen and include it as context'), + ).toBeInTheDocument(); + expect(screen.getByText('/foo')).toBeInTheDocument(); + expect(screen.getByText('A test command')).toBeInTheDocument(); + }); + + it('shows the COMMANDS header', () => { + render( + , + ); + expect(screen.getByText('Commands')).toBeInTheDocument(); + }); + + it('marks the highlighted row as aria-selected', () => { + render( + , + ); + const options = screen.getAllByRole('option'); + expect(options[0]).toHaveAttribute('aria-selected', 'false'); + expect(options[1]).toHaveAttribute('aria-selected', 'true'); + }); + + it('shows Tab badge only on highlighted row', () => { + render( + , + ); + // Only one Tab badge should appear. + const tabBadges = screen.getAllByText('Tab'); + expect(tabBadges).toHaveLength(1); + }); + + it('shows no Tab badge when nothing is highlighted (index -1)', () => { + render( + , + ); + expect(screen.queryByText('Tab')).toBeNull(); + }); + + it('calls onSelect with the trigger when a row is clicked (mousedown)', () => { + const onSelect = vi.fn(); + render( + , + ); + const option = screen.getByRole('option'); + fireEvent.mouseDown(option); + expect(onSelect).toHaveBeenCalledWith('/screen'); + expect(onSelect).toHaveBeenCalledTimes(1); + }); + + it('calls onSelect with the correct trigger when second row is clicked', () => { + const onSelect = vi.fn(); + render( + , + ); + const options = screen.getAllByRole('option'); + fireEvent.mouseDown(options[1]); + expect(onSelect).toHaveBeenCalledWith('/foo'); + }); + + it('renders the listbox with accessible label', () => { + render( + , + ); + expect( + screen.getByRole('listbox', { name: 'Command suggestions' }), + ).toBeInTheDocument(); + }); + + it('does not throw when highlightedIndex is out of range', () => { + expect(() => { + render( + , + ); + }).not.toThrow(); + }); +}); diff --git a/src/config/__tests__/commands.test.ts b/src/config/__tests__/commands.test.ts new file mode 100644 index 00000000..f274ffcc --- /dev/null +++ b/src/config/__tests__/commands.test.ts @@ -0,0 +1,41 @@ +import { describe, it, expect } from 'vitest'; +import { COMMANDS } from '../commands'; +import type { Command } from '../commands'; + +describe('COMMANDS registry', () => { + it('is non-empty', () => { + expect(COMMANDS.length).toBeGreaterThan(0); + }); + + it('every entry has non-empty trigger, label, and description', () => { + for (const cmd of COMMANDS) { + expect(typeof cmd.trigger).toBe('string'); + expect(cmd.trigger.length).toBeGreaterThan(0); + + expect(typeof cmd.label).toBe('string'); + expect(cmd.label.length).toBeGreaterThan(0); + + expect(typeof cmd.description).toBe('string'); + expect(cmd.description.length).toBeGreaterThan(0); + } + }); + + it('all triggers start with "/"', () => { + for (const cmd of COMMANDS) { + expect(cmd.trigger.startsWith('/')).toBe(true); + } + }); + + it('no duplicate triggers', () => { + const triggers = COMMANDS.map((c: Command) => c.trigger); + const unique = new Set(triggers); + expect(unique.size).toBe(triggers.length); + }); + + it('includes the /screen command', () => { + const screen = COMMANDS.find((c: Command) => c.trigger === '/screen'); + expect(screen).toBeDefined(); + expect(screen?.label).toBe('/screen'); + expect(screen?.description.length).toBeGreaterThan(0); + }); +}); diff --git a/src/config/commands.ts b/src/config/commands.ts new file mode 100644 index 00000000..ea9ed00e --- /dev/null +++ b/src/config/commands.ts @@ -0,0 +1,24 @@ +/** + * Registry of all slash commands supported by the ask bar. + * + * Each entry drives both the CommandSuggestion autocomplete UI and the + * submit-time parser in App.tsx. Adding a command here is sufficient: + * no other registration is needed. + */ + +export interface Command { + /** The slash trigger, e.g. "/screen". Must start with "/". */ + readonly trigger: string; + /** Short label shown in the suggestion row. */ + readonly label: string; + /** One-line description shown as muted subtext in the suggestion row. */ + readonly description: string; +} + +export const COMMANDS: readonly Command[] = [ + { + trigger: '/screen', + label: '/screen', + description: 'Capture your screen and include it as context', + }, +] as const; diff --git a/src/view/AskBarView.tsx b/src/view/AskBarView.tsx index 3c1eff3c..f866371f 100644 --- a/src/view/AskBarView.tsx +++ b/src/view/AskBarView.tsx @@ -1,12 +1,14 @@ import { motion } from 'framer-motion'; import type React from 'react'; -import { useCallback, useState } from 'react'; +import { useCallback, useEffect, useMemo, useState } from 'react'; import { formatQuotedText } from '../utils/formatQuote'; import { quote } from '../config'; import { ImageThumbnails } from '../components/ImageThumbnails'; +import { CommandSuggestion } from '../components/CommandSuggestion'; import { Tooltip } from '../components/Tooltip'; import type { AttachedImage } from '../types/image'; import { MAX_IMAGE_SIZE_BYTES } from '../types/image'; +import { COMMANDS } from '../config/commands'; /** * Hoisted static SVG — prevents re-allocation on every render cycle. @@ -208,13 +210,79 @@ export function AskBarView({ const isAtMaxImages = attachedImages.length >= MAX_IMAGES; const [isDragOver, setIsDragOver] = useState(false); + // ─── Command suggestion state ───────────────────────────────────────────── + + /** + * Index of the highlighted row in the suggestion popover. Reset to 0 + * whenever the query changes so a new filter result always starts at the top. + */ + const [highlightedIndex, setHighlightedIndex] = useState(0); + + /** + * When the user presses Escape, we store the query prefix that was active at + * that moment. If the query later changes to a different prefix, the popover + * reopens automatically; if it stays the same, the popover stays dismissed. + * State (not ref) so that Escape triggers a re-render and hides the popover. + */ + const [dismissedQuery, setDismissedQuery] = useState(''); + + /** + * Derived: show the suggestion popover when the query starts with "/" and + * has not yet had a space added (user is still typing the trigger token), + * the UI is not busy, and the user has not explicitly dismissed this prefix. + */ + const rawQuery = query.trimStart(); + const showSuggestions = + !isBusy && + rawQuery.startsWith('/') && + !rawQuery.includes(' ') && + rawQuery !== dismissedQuery; + + /** The active command prefix (e.g. "/sc"). Empty when not suggesting. */ + const commandPrefix = showSuggestions ? rawQuery : ''; + + /** Commands that match the current prefix (memoized to keep stable reference). */ + const filteredCommands = useMemo( + () => + showSuggestions + ? COMMANDS.filter((cmd) => cmd.trigger.startsWith(commandPrefix)) + : [], + [showSuggestions, commandPrefix], + ); + + // Reset the highlighted index whenever the command prefix changes + // (user typed more characters and the results updated). + /* eslint-disable @eslint-react/set-state-in-effect -- intentional: resetting + highlighted index when the filter prefix changes drives no secondary effects + and is the canonical pattern for derived-from-prop index resets. */ + useEffect(() => { + setHighlightedIndex(0); + }, [commandPrefix]); + /* eslint-enable @eslint-react/set-state-in-effect */ + + /** Applies the selected trigger by setting the query to "trigger " (with trailing space). */ + const handleCommandSelect = useCallback( + (trigger: string) => { + setDismissedQuery(''); + setHighlightedIndex(0); + setQuery(trigger + ' '); + }, + [setQuery], + ); + /** * Auto-resizes the textarea to fit its content up to a maximum height. * Single forced reflow per input event ensures responsive text wrapping. + * Also clears the dismissed-suggestion state so the popover can reopen + * if the user has changed the command prefix since dismissing it. */ const handleTextareaChange = useCallback( (e: React.ChangeEvent) => { - setQuery(e.target.value); + const newValue = e.target.value; + // Any keystroke clears the dismissed state so the popover can reopen + // if the user types a new "/" prefix after having pressed Escape. + setDismissedQuery(''); + setQuery(newValue); const el = e.target; el.style.height = 'auto'; // Reset to auto to trigger height recalculation el.style.height = `${Math.min(el.scrollHeight, 144)}px`; @@ -225,15 +293,72 @@ export function AskBarView({ /** * Catches `Enter` without `Shift` to submit the form proactively, * avoiding accidental line breaks for power users. + * + * When the command suggestion popover is open, also handles: + * - ArrowDown / ArrowUp: move the highlighted row (wraps around) + * - Tab: complete the highlighted command trigger into the input + * - Enter: if a valid row is highlighted, complete it; otherwise submit + * - Escape: dismiss the popover without changing the query */ const handleKeyDown = useCallback( (e: React.KeyboardEvent) => { + if (showSuggestions) { + if (e.key === 'ArrowDown') { + e.preventDefault(); + if (filteredCommands.length > 0) { + setHighlightedIndex((i) => (i + 1) % filteredCommands.length); + } + return; + } + if (e.key === 'ArrowUp') { + e.preventDefault(); + if (filteredCommands.length > 0) { + setHighlightedIndex( + (i) => + (i - 1 + filteredCommands.length) % filteredCommands.length, + ); + } + return; + } + if (e.key === 'Tab') { + e.preventDefault(); + if (filteredCommands.length > 0) { + const idx = Math.min(highlightedIndex, filteredCommands.length - 1); + handleCommandSelect(filteredCommands[idx].trigger); + } + return; + } + if (e.key === 'Enter' && !e.shiftKey) { + if ( + filteredCommands.length > 0 && + highlightedIndex < filteredCommands.length + ) { + e.preventDefault(); + handleCommandSelect(filteredCommands[highlightedIndex].trigger); + return; + } + // No highlighted match or empty list: fall through to normal submit. + } + if (e.key === 'Escape') { + e.preventDefault(); + setDismissedQuery(rawQuery); + return; + } + } + if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); onSubmit(); } }, - [onSubmit], + [ + showSuggestions, + filteredCommands, + highlightedIndex, + handleCommandSelect, + rawQuery, + onSubmit, + ], ); /** @@ -343,92 +468,102 @@ export function AskBarView({ /> )} -
- Thuki - - {/* Compact history entry point — ask-bar mode only. In chat mode the - history button lives in the ConversationView header. */} - {!isChatMode && onHistoryOpen && ( - + {/* Relative wrapper for the command suggestion popover positioning. */} +
+ {showSuggestions && ( + )} +
+ Thuki -