diff --git a/docs/features/55-paste-file-paths.md b/docs/features/55-paste-file-paths.md index f72b96a..b1acbaa 100644 --- a/docs/features/55-paste-file-paths.md +++ b/docs/features/55-paste-file-paths.md @@ -21,25 +21,45 @@ The web layer cannot close it alone. `DataTransfer` deliberately withholds files ## Key Decisions -1. **Read the paths in Rust, not in the webview.** A new command returns the current pasteboard's file URLs as POSIX paths, read from `NSPasteboard.generalPasteboard`. `commands/session.rs:144-165` already carries the NSPasteboard plumbing and `objc2-app-kit` is already a dependency, so this is an addition to established code rather than new machinery. The frontend never sees a path it could have fabricated from `File.name`. -2. **Branch inside the existing paste handler.** Extend `onPaste` (`RunnerTerminal.tsx:749`): after the image scan fails, if any item is `kind === "file"`, call the new command. On a non-empty result, `preventDefault` and insert; on an empty result, fall through untouched so today's behavior is preserved for any clipboard shape the native read doesn't recognize. -3. **Prefer the native read over `text/uri-list`.** WKWebView sometimes exposes a `text/uri-list` flavor with `file://` URLs, which would avoid an IPC round-trip, but its presence is inconsistent across source applications. One authoritative path (the pasteboard) beats two code paths that disagree. -4. **Quote for the shell, always.** Wrap each path in single quotes with embedded single quotes escaped (`'` → `'\''`). Unquoted paths break on spaces, which are common in real project trees. Quoting unconditionally keeps the rule simple and matches what iTerm2 does. -5. **Insert as text, never submit.** Write the quoted string through the existing raw-stdin injection. Do **not** route through `inject_paste`, which appends Enter — a pasted path is the middle of a sentence the user is still composing. This also means the draft-gate state (spec 54) sees it as ordinary local input, which is correct: the user now has a pending draft. -6. **No cwd-relative rewriting.** Absolute paths always resolve regardless of where the agent has `cd`'d, and the agent can shorten them itself. Relativizing would need the session's live cwd, which Runner only knows at spawn time. +1. **Read the paths in Rust, not in the webview.** A new command returns the current pasteboard's file URLs as POSIX paths. The frontend never sees a path it could have fabricated from `File.name`. + + This is **new machinery**, contrary to an earlier draft of this spec. `commands/session.rs:144-165` is a MIME→OSType lookup table, not NSPasteboard plumbing: the image write shells out to `osascript -e "set the clipboard to (read POSIX file … as «class PNGf»)"` (`:225`), and the repo contains no NSPasteboard binding anywhere. `objc2-app-kit` is a dependency but is declared `default-features = false` with `["std", "NSButton", "NSControl", "NSResponder", "NSView", "NSWindow", "NSWorkspace"]`, and objc2 gates every class behind its own feature — so `NSPasteboard` does not compile today. The work is one Cargo feature plus a first direct binding. Take the binding rather than matching the `osascript` precedent: reads happen on every candidate paste, and a process spawn per paste is not worth the symmetry. + +2. **Decide synchronously, act asynchronously.** `preventDefault()` after an `await` does nothing — the browser has already run the default action by the time an IPC round-trip resolves. So the handler cannot "call the command, then decide", which an earlier draft of this spec asked for. It must commit before it knows the answer, exactly as the image branch does (`RunnerTerminal.tsx:767`: decide from `clipboardData`, *then* go async). + + Gate on **absence of usable text**, not on `kind === "file"`. If `clipboardData.getData("text/plain")` is non-empty, return immediately and let xterm paste it — that is every ordinary paste, unchanged and with no IPC. Otherwise `preventDefault` eagerly and consult the pasteboard; an empty result then swallows a paste that had nothing to insert anyway, which is a no-op. + + This also removes the spec's riskiest assumption. Whether WKWebView exposes an arbitrary non-image file as `kind === "file"` is unverified; if it does not, a `kind`-based trigger never fires and the feature silently does nothing. Text-absence does not depend on that behavior. + +3. **A file reference beats image bytes.** `inferPasteImageMime` falls back to the filename extension (`:100-103`) and WKWebView sets `DataTransferItem.type` from the file's UTI, so copying `shot.png` in Finder currently takes the image branch and attaches it — the path branch would never run. Native terminals paste the path. + + Resolve by clipboard *flavor*, not MIME sniff: if the pasteboard carries `public.file-url`, the user copied a reference and gets a path; otherwise they copied content and get today's attach flow. This preserves [#79](https://github.com/yicheng47/runner/issues/79) exactly — screenshots and copies from Preview or a browser put bytes on the pasteboard with no file-url — and changes only the Finder/GoLand image-*file* case, which is the case the user is asking about. Requires reading the file-url flavor before the image scan. + +4. **Prefer the native read over `text/uri-list`.** WKWebView sometimes exposes a `text/uri-list` flavor with `file://` URLs, which would avoid an IPC round-trip, but its presence is inconsistent across source applications. One authoritative path (the pasteboard) beats two code paths that disagree. + +5. **Quote only when the path needs it.** iTerm2 quotes unconditionally because its panes are shell prompts. Runner's panes are usually *agent* prompts, where `'/Users/jason/foo.go'` is noise and defeats `@`-style file completion. Quote when the path contains whitespace or shell metacharacters, using single quotes with embedded quotes escaped (`'` → `'\''`); otherwise insert it bare. + +6. **Insert as text, never submit.** Write through the existing raw-stdin injection (`api.session.injectStdin`). Do **not** route through `inject_paste`, which appends Enter — a pasted path is the middle of a sentence the user is still composing. This also means the draft-gate state (spec 54) sees it as ordinary local input, which is correct: the user now has a pending draft. + +7. **No cwd-relative rewriting.** Absolute paths always resolve regardless of where the agent has `cd`'d, and the agent can shorten them itself. Relativizing would need the session's live cwd, which Runner only knows at spawn time. ## Implementation Phases ### Phase 1 — native pasteboard read -- Add a command (`commands/session.rs`, beside the image-paste helpers at `:144-165`) returning `Vec` of POSIX paths for the pasteboard's file URLs, empty when the flavor is absent. -- Unit-test the quoting helper: plain path, path with spaces, path with an embedded single quote, multiple paths. +- Add the `NSPasteboard` feature to `objc2-app-kit` in `src-tauri/Cargo.toml` (decision 1). +- Add a command (`commands/session.rs`, beside the image-paste helpers at `:144-165`) returning `Vec` of POSIX paths for the pasteboard's file URLs, empty when the flavor is absent. Non-macOS returns empty. +- Unit-test the quoting helper: plain path (unquoted), path with spaces, path with an embedded single quote, path with shell metacharacters, multiple paths. ### Phase 2 — frontend branch -- Extend `onPaste` (`RunnerTerminal.tsx:749-781`) per decision 2: file-kind items with no image mime → call the command → quote, join with spaces, `preventDefault` + `stopImmediatePropagation`, inject through `api.session.injectStdin`. -- Keep the existing early returns intact so a text paste never reaches the new branch. -- Component test: an image paste still takes the image path; a non-image file paste injects the quoted path; a text paste is untouched. +- Extend `onPaste` (`RunnerTerminal.tsx:749-781`) per decisions 2–3. Order matters: + 1. Non-empty `text/plain` → return immediately, xterm handles it. + 2. Otherwise `preventDefault` + `stopImmediatePropagation`, then call the pasteboard command. + 3. Non-empty result → quote as needed, join with single spaces, inject through `api.session.injectStdin`. + 4. Empty result → fall back to the existing image scan; if that also finds nothing, do nothing. +- Keep the `!sid || disabledRef.current` early return ahead of all of it. +- Component tests: text paste untouched and no IPC; image *bytes* paste still attaches; image *file* paste injects a path; non-image file paste injects a quoted path; empty pasteboard is a silent no-op. ### Phase 3 — validation @@ -48,18 +68,21 @@ The web layer cannot close it alone. `DataTransfer` deliberately withholds files ## Verification -- [ ] Copy a `.go` file in GoLand, ⌘V over a terminal pane — the absolute path appears at the cursor, quoted, not submitted. -- [ ] Copy a file whose path contains a space — the pasted text is correctly quoted and the agent resolves it. +- [ ] Copy a `.go` file in GoLand, ⌘V over a terminal pane — the absolute path appears at the cursor, unquoted, not submitted. +- [ ] Copy a file whose path contains a space — the pasted text is quoted and the agent resolves it. - [ ] Copy two files at once — both paths appear, space-separated. - [ ] Copy a file in Finder — same result as GoLand. -- [ ] Copy an image — the existing `[Image #N]` attach flow is unchanged. -- [ ] Copy ordinary text — pastes exactly as before. +- [ ] Take a screenshot to the clipboard (⌘⇧4), paste — the existing `[Image #N]` attach flow is unchanged (#79). +- [ ] Copy an image *file* in Finder, paste — its **path** appears, not an attachment (decision 3, deliberate change). +- [ ] Copy ordinary text — pastes exactly as before, with no IPC round-trip. +- [ ] Paste with an empty clipboard — nothing happens, no error. - [ ] Paste with the session stopped or the pane disabled — no injection, no error. - [ ] The pasted path leaves the pane in a pending-draft state (spec 54), not a submitted one. ## Relevant Code -- `src/components/RunnerTerminal.tsx:749-783` — `onPaste`, the interception point; `:81-99` — `normalizePasteImageMime` / `inferPasteImageMime`, the mime filter that currently drops non-image files. -- `src-tauri/src/commands/session.rs:144-165` — existing NSPasteboard flavor plumbing for image paste. -- `src/lib/api.ts` — `session.pasteImage` / `session.injectStdin`, the call shapes to mirror. -- `docs/features/54-draft-aware-delivery-gate.md` — the draft model a pasted path feeds into (decision 5). +- `src/components/RunnerTerminal.tsx:749-781` — `onPaste`, the interception point; `:767` — the synchronous commit point the new branch must mirror; `:81-103` — `normalizePasteImageMime` / `inferPasteImageMime`, whose filename fallback creates the collision in decision 3. +- `src-tauri/src/commands/session.rs:143-160` — `paste_image_format`, a MIME→OSType table (**not** NSPasteboard plumbing); `:225` — the `osascript` shell-out that actually writes the pasteboard. +- `src-tauri/Cargo.toml:67` — `objc2-app-kit` with `default-features = false`; `NSPasteboard` must be added to its feature list. +- `src/lib/api.ts:396-397` — `session.injectStdin`; `:412` — `session.pasteImage`, the call shape to mirror. +- `docs/features/54-draft-aware-delivery-gate.md` — the draft model a pasted path feeds into (decision 6). diff --git a/docs/impls/0039-backend-initiated-spawn-width.md b/docs/impls/0039-backend-initiated-spawn-width.md new file mode 100644 index 0000000..046a93b --- /dev/null +++ b/docs/impls/0039-backend-initiated-spawn-width.md @@ -0,0 +1,106 @@ +# Backend-initiated spawn width + +## Status + +Planned. Tracking issue [#367](https://github.com/yicheng47/runner/issues/367). + +## Problem + +Spawns the frontend initiates carry a measured grid; spawns the backend initiates carry nothing and fall to `DEFAULT_PTY_SIZE` (`session/manager/mod.rs:52`, 80×24). Two paths reach that state today, both through MCP: + +- `mission_start_impl` (`commands/mission.rs:488-494`) hardcodes `None` for `initial_size`. Its only caller is the MCP tool (`mcp/tools/mission.rs:436`); the UI's Start Mission modal goes through `mission_start_impl_with_size` (`:867`) with real dims. +- `session_start_direct` via MCP (`mcp/tools/session.rs:47-55`) passes `None, None` for `cols`/`rows`. `session_start_direct_impl` (`commands/session.rs:715-724`) accepts them; the UI supplies them, the MCP tool does not. + +Forking at 80 also seeds `last_pty_cols = Some(80)`. When the pane is first opened and pushes its real width, the cols-gate fires (`session/manager/output.rs:538-546`): + +```rust +let cols_changed = { … state.last_pty_cols != Some(cols) … }; +if cols_changed && runtime_clears_on_resize(session_id, pool) { + self.purge_output_buffer_keep_modes(session_id); +} +``` + +`runtime_clears_on_resize` (`:796`) covers claude-code, codex, qoder, and trae — every agent runtime. So the entire transcript the agent produced before its tab was first visited is discarded, and snapshot replay has nothing to restore. The longer the session ran unobserved, the more is lost. + +The tree already documents this failure, but only as fixed for the *respawn* paths — `commands/mission.rs:1413-1416` and `MissionWorkspace.tsx:629-630` describe exactly this mechanism. Initial spawn via MCP was left behind. + +This is not [#366](https://github.com/yicheng47/runner/issues/366)/[#363](https://github.com/yicheng47/runner/issues/363). Those are about the *estimate* feeding launch auto-resume, where the resulting width is wrong. Here the width ends up correct and the history is gone. Shared mechanism, different cause, different fix site. + +### Why an estimate cannot fix this + +The gate is exact equality on cols. An estimate that lands one column off purges exactly as thoroughly as no estimate at all. There is no partial credit, so a computed approximation buys nothing — it must match the destination pane exactly or it is wasted work. + +That rules out deriving the grid backend-side from window geometry (the issue's option 2). It would mean a second copy of the chrome constants in Rust — the duplication that already bites `runtimeClearsOnResize` (`output.rs:770` / `RunnerTerminal.tsx:168`) — in exchange for a number that is *almost* right, which is worth nothing here. + +The only source of an exactly-correct destination width is a real measurement of a real pane. So: reuse the last one. + +## Key Decisions + +1. **Cache a measured grid; never compute one.** The frontend writes the mission pane grid and the chat pane grid to the backend as it measures them. Backend-initiated spawns read the cache when the caller supplied no size. Rust gains no knowledge of chrome constants, header heights, or panel widths. +2. **Persist it in `_app_state`, not in memory.** Follow the established shape in `db.rs`: private `app_state_get`/`app_state_set` plus a typed `pub fn` pair per key, exactly as `login_shell_env_lkg` (`:218-232`) and `runtime_overrides` (`:234-260`) do. Persisting is what covers the first MCP-started mission after a cold launch — an in-memory cache would be empty precisely when the app hasn't rendered a pane yet. +3. **Two keys, because they are two different boxes.** `missionPaneAreaBox` (`terminalSizing.ts:180`) subtracts the runners rail, the topbar, and the slot-tab strip. `chatPaneAreaBox` (`:210`) subtracts the side panel and is then divided by `paneBoxForSession`. A mission spawn must not read a chat measurement. Store `mission` and `chat` grids separately and have each spawn path read its own. +4. **Write from where the grid is already known.** The measurement is whatever the pane pushes to `session.resize` — same value, same moment. Do not add a separate measuring pass; do not measure on a timer. +5. **No cached grid means 80×24, unchanged.** A fresh install whose first action is an MCP `mission_start` still loses that first session's early scrollback. That is the honest residual: guessing would not avoid the purge (see above), and it self-heals as soon as any pane has rendered once. Do not add a fallback estimate to paper over it. +6. **Do not touch the purge gate.** It is correct given genuinely-80-col bytes: replaying absolute-positioned 80-col frames into a wide grid is the box-drawing shredding impl 0020 / [#306](https://github.com/yicheng47/runner/issues/306) removed. This fix removes the cause, not the safeguard. +7. **Optional explicit dims on the MCP tools are a non-goal.** A caller that knew the geometry could pass it (the issue's option 3), but MCP callers are agents with no view of the window, so the parameter would sit unused while widening the tool surface. + +## Open questions + +- **Staleness.** The cache is not invalidated when the window resizes with no pane mounted, so a spawn can read a grid that no longer matches. The cost of being wrong is exactly today's behavior — one purge — so this is not worth a watcher. Confirm the reasoning holds rather than adding invalidation. +- **Rows.** The gate is cols-only, so a stale rows value costs nothing. Both are stored because the spawn takes a pair; no accuracy claim is made about rows. +- **Multi-window** (impl 0018). Last writer wins across windows. Acceptable while windows are near-identical; note it rather than solve it. +- **Whether the mission grid should be per-project.** Different projects may sit at different rail widths. Probably not worth it — the rail width is global. Confirm. + +## Goals + +- A mission started through MCP and left unopened for several minutes shows its full transcript when its slot tab is first visited. +- A direct chat started through MCP behaves the same. +- Sessions started from the UI are byte-for-byte unaffected. + +## Non-Goals + +- Changing the cols-gate purge, `runtime_clears_on_resize`, or snapshot replay. +- Anything in #363/#366/impl 0038's launch auto-resume estimate. +- Recovering scrollback already purged by shipped versions. +- Adding `cols`/`rows` parameters to the MCP tool schemas. + +## Implementation Phases + +### Phase 1 — persisted pane-grid cache + +- Add `_app_state` keys for the last measured mission grid and chat grid, with typed getters/setters in `db.rs` mirroring `login_shell_env_lkg`. +- Add a command the frontend calls with `{ surface, cols, rows }`. +- Unit-test the round trip, including the absent-key case returning `None`. + +### Phase 2 — write side + +- Have the mission workspace and the chat surface record their measured grid alongside the resize they already push. +- Do not introduce a new measurement path; reuse the value being sent to `session.resize`. + +### Phase 3 — read side + +- `mission_start_impl`: read the cached mission grid and pass it as `initial_size` instead of the hardcoded `None`. +- MCP `session_start_direct`: read the cached chat grid and pass it as `cols`/`rows`. +- Both fall back to today's behavior when the cache is empty. +- Test that an explicit caller-supplied size still wins over the cache. + +### Phase 4 — validation + +- `cargo fmt --check`, `cargo clippy --workspace`, `cargo test --workspace`, `pnpm exec tsc --noEmit`, `pnpm run lint`, `pnpm test`. + +## Verification + +Automated: + +- [ ] Cache round-trips through `_app_state` and survives a pool reopen. +- [ ] Absent key yields `None`; spawn then uses `DEFAULT_PTY_SIZE`. +- [ ] `mission_start_impl` with a cached grid forks at that grid; with an explicit size the explicit value wins. +- [ ] Mission and chat caches do not read each other. +- [ ] A resize to the same cols the session forked at does not purge (the gate's no-op case is what this fix relies on). + +Manual (Jason smoke-tests): + +- [ ] Open a mission workspace once so a grid is cached. Start a mission through MCP, leave the slot tab closed for a few minutes, then open it — full transcript present, no truncation at the top. +- [ ] Same for an MCP-started direct chat. +- [ ] Start a mission from the UI modal — unchanged. +- [ ] Quit, relaunch, immediately start an MCP mission without opening any pane — still forks at the persisted grid. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 41f8d4b..8aa0147 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -64,13 +64,20 @@ libc = "0.2" portable-pty = "0.9" [target.'cfg(target_os = "macos")'.dependencies] -objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSControl", "NSResponder", "NSView", "NSWindow", "NSWorkspace"] } +# `NSPasteboard` + `NSPasteboardItem` back the file-path paste (feature 55). +# A file copy puts no text on the clipboard, only `public.file-url` flavors, +# and `DataTransfer` deliberately withholds filesystem paths — so the path can +# only come from the pasteboard itself. objc2 gates every class behind its own +# feature, hence the explicit adds. +objc2-app-kit = { version = "0.3", default-features = false, features = ["std", "NSButton", "NSControl", "NSPasteboard", "NSPasteboardItem", "NSResponder", "NSView", "NSWindow", "NSWorkspace"] } # System-wake observer (impl 0037). `NSWorkspace`'s notification center is # the only precise sleep/wake signal available to the app — tao never emits # `Event::Resumed` on macOS. `block2` + `NSOperation` are what the # `addObserverForName:object:queue:usingBlock:` binding is gated on; both # crates are already in the lock via tao/wry. -objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSNotification", "NSOperation", "NSString", "block2"] } +# `NSURL` decodes the pasteboard's percent-encoded `file://` strings into +# POSIX paths (feature 55). +objc2-foundation = { version = "0.3", default-features = false, features = ["std", "NSNotification", "NSOperation", "NSString", "NSURL", "block2"] } block2 = "0.6" [dev-dependencies] diff --git a/src-tauri/src/commands/mission.rs b/src-tauri/src/commands/mission.rs index 4133f28..86bdd4b 100644 --- a/src-tauri/src/commands/mission.rs +++ b/src-tauri/src/commands/mission.rs @@ -485,12 +485,26 @@ pub async fn mission_post_human_signal( mission_post_human_signal_impl(&state, input).await } +/// Start a mission with no caller-supplied geometry — the MCP tool's +/// entry point. The UI's Start Mission modal goes through +/// `mission_start_impl_with_size` with the dims it measured. +/// +/// An MCP caller is an agent with no view of the window, so instead of +/// forking at `DEFAULT_PTY_SIZE` (which seeds `last_pty_cols = 80` and +/// makes the first real resize purge the whole transcript) we reuse the +/// last grid a mission pane actually measured. An empty cache still +/// means 80×24: guessing would not avoid the purge, and the cache +/// self-heals as soon as any mission pane has rendered once. See impl +/// 0039. pub(crate) async fn mission_start_impl( state: &AppState, app: &tauri::AppHandle, input: StartMissionInput, ) -> Result { - mission_start_impl_with_size(state, app, input, None).await + let cached = crate::db::pane_grid(&state.db, crate::db::PaneSurface::Mission) + .unwrap_or_default() + .map(|grid| (grid.cols, grid.rows)); + mission_start_impl_with_size(state, app, input, cached).await } async fn mission_start_impl_with_size( diff --git a/src-tauri/src/commands/session.rs b/src-tauri/src/commands/session.rs index d3031f1..71df2c4 100644 --- a/src-tauri/src/commands/session.rs +++ b/src-tauri/src/commands/session.rs @@ -115,6 +115,31 @@ pub async fn session_resize( state.sessions.resize(&session_id, cols, rows, &state.db) } +/// Record the grid a pane just measured, so a backend-initiated spawn +/// can fork at a width some real pane actually had. +/// +/// The frontend calls this with the same dims it pushes to +/// `session_resize`, from the same place — this adds no measuring pass +/// of its own. Rust never derives a grid from window geometry: the +/// purge gate is exact equality on cols, so an estimate that lands one +/// column off purges exactly as hard as no estimate. See impl 0039. +/// +/// Async so the SQLite write stays off the main thread — this fires on +/// every deduped size change, including each column crossed during a +/// window drag. +#[tauri::command] +pub async fn session_record_pane_grid( + state: State<'_, AppState>, + surface: crate::db::PaneSurface, + cols: u16, + rows: u16, +) -> Result<()> { + if cols == 0 || rows == 0 { + return Ok(()); + } + crate::db::set_pane_grid(&state.db, surface, crate::db::PaneGrid { cols, rows }) +} + #[tauri::command] pub async fn session_output_snapshot( state: State<'_, AppState>, @@ -239,6 +264,60 @@ pub async fn session_paste_image(bytes: Vec, mime_type: String) -> Result<() } } +/// POSIX paths of the files currently referenced on the general +/// pasteboard, in pasteboard order. Empty when the clipboard carries no +/// `public.file-url` flavor — which is every ordinary text or +/// image-*bytes* copy. +/// +/// Native terminals paste a copied file's path; the webview can't reach +/// it. `DataTransfer` withholds filesystem paths by design (`File.name` +/// is only the basename, WKWebView exposes no `file.path`), so the path +/// has to come from NSPasteboard directly. This is a direct objc2 +/// binding rather than another `osascript` shell-out like +/// `session_paste_image` above: the read happens on every candidate +/// paste, and a process spawn per paste isn't worth the symmetry. +/// +/// Presence of the file-url flavor is also what decides path-vs-attach +/// for an image *file* (feature 55 decision 3): a Finder-copied +/// `shot.png` carries a file-url and pastes its path, while a screenshot +/// or a browser copy carries bytes only and keeps #79's attach flow. +/// +/// macOS-only; elsewhere this returns empty and the caller falls through +/// to the image scan. +#[tauri::command] +pub fn session_clipboard_file_paths() -> Vec { + #[cfg(not(target_os = "macos"))] + { + Vec::new() + } + + #[cfg(target_os = "macos")] + { + use objc2_app_kit::{NSPasteboard, NSPasteboardTypeFileURL}; + use objc2_foundation::NSURL; + + // SAFETY: AppKit's own flavor constant, read-only. + let file_url_type = unsafe { NSPasteboardTypeFileURL }; + let Some(items) = NSPasteboard::generalPasteboard().pasteboardItems() else { + return Vec::new(); + }; + items + .to_vec() + .into_iter() + .filter_map(|item| { + // Percent-encoded `file://…` per item. NSURL decodes it; + // hand-decoding would be a second, worse parser. + let url_string = item.stringForType(file_url_type)?; + let url = NSURL::URLWithString(&url_string)?; + if !url.isFileURL() { + return None; + } + Some(url.path()?.to_string()) + }) + .collect() + } +} + /// One row per direct-chat *session* in the sidebar SESSION tray. Each /// runner can host multiple parallel chats — see /// docs/impls/archive/0003-direct-chats.md — so the tray is flat (not collapsed per diff --git a/src-tauri/src/db.rs b/src-tauri/src/db.rs index b90a76f..91ab77a 100644 --- a/src-tauri/src/db.rs +++ b/src-tauri/src/db.rs @@ -177,6 +177,8 @@ const MIGRATIONS: &[(i64, &str)] = &[ const SEED_MARKER_KEY: &str = "default_crew_seeded"; const LOGIN_SHELL_ENV_LKG_KEY: &str = "login_shell_env_lkg"; const RUNTIME_OVERRIDES_KEY: &str = "runtime_overrides"; +const PANE_GRID_MISSION_KEY: &str = "pane_grid_mission"; +const PANE_GRID_CHAT_KEY: &str = "pane_grid_chat"; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct LoginShellEnvLkg { @@ -185,6 +187,39 @@ pub struct LoginShellEnvLkg { pub captured_at: String, } +/// Which pane box a cached grid was measured in. Mission and chat panes +/// occupy different boxes (`terminalSizing.ts`'s `missionPaneAreaBox` vs +/// `chatPaneAreaBox`), so a mission spawn must never read a chat +/// measurement or vice versa. See impl 0039 decision 3. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PaneSurface { + Mission, + Chat, +} + +impl PaneSurface { + fn key(self) -> &'static str { + match self { + PaneSurface::Mission => PANE_GRID_MISSION_KEY, + PaneSurface::Chat => PANE_GRID_CHAT_KEY, + } + } +} + +/// A terminal grid the frontend actually measured, not one computed from +/// window geometry. Backend-initiated spawns (MCP `mission_start`, +/// `session_start_direct`) have no pane to measure, and forking at +/// `DEFAULT_PTY_SIZE` seeds `last_pty_cols = 80`; the first real-cols +/// resize then trips the purge gate and discards the transcript. The +/// cols-gate is exact equality, so only a real measurement helps — see +/// impl 0039. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneGrid { + pub cols: u16, + pub rows: u16, +} + fn ensure_app_state_table(conn: &Connection) -> Result<()> { conn.execute_batch( "CREATE TABLE IF NOT EXISTS _app_state ( @@ -230,6 +265,18 @@ pub fn set_login_shell_env_lkg(pool: &DbPool, snapshot: &LoginShellEnvLkg) -> Re ) } +pub fn pane_grid(pool: &DbPool, surface: PaneSurface) -> Result> { + let conn = pool.get()?; + app_state_get(&conn, surface.key())? + .map(|value| serde_json::from_str(&value).map_err(Into::into)) + .transpose() +} + +pub fn set_pane_grid(pool: &DbPool, surface: PaneSurface, grid: PaneGrid) -> Result<()> { + let conn = pool.get()?; + app_state_set(&conn, surface.key(), &serde_json::to_string(&grid)?) +} + pub fn runtime_overrides(pool: &DbPool) -> Result> { let conn = pool.get()?; Ok(app_state_get(&conn, RUNTIME_OVERRIDES_KEY)? @@ -822,6 +869,64 @@ mod tests { assert_eq!(login_shell_env_lkg(&pool).unwrap(), Some(snapshot)); } + #[test] + fn pane_grid_round_trips_per_surface() { + let pool = open_in_memory().unwrap(); + assert_eq!(pane_grid(&pool, PaneSurface::Mission).unwrap(), None); + assert_eq!(pane_grid(&pool, PaneSurface::Chat).unwrap(), None); + + set_pane_grid( + &pool, + PaneSurface::Mission, + PaneGrid { + cols: 214, + rows: 51, + }, + ) + .unwrap(); + set_pane_grid(&pool, PaneSurface::Chat, PaneGrid { cols: 97, rows: 44 }).unwrap(); + + assert_eq!( + pane_grid(&pool, PaneSurface::Mission).unwrap(), + Some(PaneGrid { + cols: 214, + rows: 51 + }) + ); + assert_eq!( + pane_grid(&pool, PaneSurface::Chat).unwrap(), + Some(PaneGrid { cols: 97, rows: 44 }) + ); + } + + #[test] + fn pane_grid_survives_a_pool_reopen() { + use tempfile::tempdir; + let dir = tempdir().unwrap(); + let path = dir.path().join("runner.db"); + { + let pool = open_pool(&path).unwrap(); + set_pane_grid( + &pool, + PaneSurface::Mission, + PaneGrid { + cols: 180, + rows: 48, + }, + ) + .unwrap(); + } + let pool = open_pool(&path).unwrap(); + assert_eq!( + pane_grid(&pool, PaneSurface::Mission).unwrap(), + Some(PaneGrid { + cols: 180, + rows: 48 + }) + ); + assert_eq!(pane_grid(&pool, PaneSurface::Chat).unwrap(), None); + } + #[test] fn runtime_overrides_set_clear_as_one_json_value() { let pool = open_in_memory().unwrap(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index fc8eb1d..0aeb5ab 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -431,9 +431,11 @@ pub fn run() { commands::session::session_kill, commands::session::session_activity_snapshot, commands::session::session_resize, + commands::session::session_record_pane_grid, commands::session::session_output_snapshot, commands::session::session_replay_watermark, commands::session::session_paste_image, + commands::session::session_clipboard_file_paths, commands::session::session_start_direct, commands::session::session_start_runtime, commands::mcp::mcp_integration_status, diff --git a/src-tauri/src/mcp/tools/session.rs b/src-tauri/src/mcp/tools/session.rs index 656cea5..c267306 100644 --- a/src-tauri/src/mcp/tools/session.rs +++ b/src-tauri/src/mcp/tools/session.rs @@ -44,6 +44,12 @@ impl RunnerMcpHandler { Parameters(args): Parameters, ) -> Result { let app_state = self.state.app_state(); + // An MCP caller has no view of the window, so reuse the last + // grid a chat pane measured rather than forking at 80×24 and + // letting the first real resize purge the transcript. Empty + // cache keeps today's behavior. See impl 0039. + let cached = + crate::db::pane_grid(&app_state.db, crate::db::PaneSurface::Chat).unwrap_or_default(); let output = session::session_start_direct_impl( &app_state, &self.state.app_handle, @@ -51,8 +57,8 @@ impl RunnerMcpHandler { args.runtime, args.project_id, args.cwd, - None, - None, + cached.map(|grid| grid.cols), + cached.map(|grid| grid.rows), ) .map_err(command_error)?; Ok(CallToolResult::success(vec![Content::json(&output)?])) diff --git a/src/components/ChatPaneGroup.tsx b/src/components/ChatPaneGroup.tsx index 5a4b306..b9d7e53 100644 --- a/src/components/ChatPaneGroup.tsx +++ b/src/components/ChatPaneGroup.tsx @@ -352,6 +352,7 @@ export function ChatPaneGroup({ ref={terminalRefFor(chat.id)} sessionId={chat.id} runnerRuntime={runtimeFor(chat.id)} + paneSurface="chat" // While the resume/start loader is up the canvas is hidden, so // xterm behaves as inactive (no resize pushes, no focus); when // the flag clears, the activation effect fits + repaints. diff --git a/src/components/RunnerTerminal.tsx b/src/components/RunnerTerminal.tsx index 102bbd5..3c9cd1f 100644 --- a/src/components/RunnerTerminal.tsx +++ b/src/components/RunnerTerminal.tsx @@ -26,7 +26,7 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { WebglAddon } from "@xterm/addon-webgl"; import "@xterm/xterm/css/xterm.css"; -import { api, type PasteImageMimeType } from "../lib/api"; +import { api, type PaneSurface } from "../lib/api"; import { readTerminalCursorStyle, readTerminalFontFamily, @@ -56,6 +56,7 @@ import { import { TERMINAL_SCROLLBAR_WIDTH_PX } from "../lib/terminalSizing"; import { observeBackingScale, stalesTextureAtlas } from "../lib/textureAtlas"; import { eventMatchesShortcut } from "../lib/keymap"; +import { handleTerminalPaste } from "../lib/terminalPaste"; import { runtimeClearsOnResize } from "./ui/runtimes"; interface OutputEvent { @@ -78,32 +79,6 @@ const SIDEBAR_NAVIGATE_EVENT = "runner:navigate-sidebar-page"; const RUNNER_TERMINAL_CYCLE_EVENT = "runner:cycle-terminal"; const OPEN_SETTINGS_EVENT = "runner:open-settings"; -function normalizePasteImageMime(type: string): PasteImageMimeType | null { - switch (type.trim().toLowerCase()) { - case "image/png": - return "image/png"; - case "image/jpeg": - case "image/jpg": - return "image/jpeg"; - default: - return null; - } -} - -function inferPasteImageMime( - itemType: string, - file: File, -): PasteImageMimeType | null { - const fromType = - normalizePasteImageMime(itemType) ?? normalizePasteImageMime(file.type); - if (fromType) return fromType; - - const name = file.name.toLowerCase(); - if (name.endsWith(".png")) return "image/png"; - if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg"; - return null; -} - interface RunnerTerminalProps { sessionId: string; /** Runtime kind of the runner driving this session (e.g. @@ -141,6 +116,12 @@ interface RunnerTerminalProps { * Stopped panes keep this false so their measured size is persisted * for the next resume even though keyboard input stays disabled. */ resizeDisabled?: boolean; + /** Pane box this terminal sits in. Every grid pushed to the backend is + * also cached under this surface so a spawn the backend initiates + * (MCP `mission_start` / `session_start_direct`) can fork at a width + * a real pane had. Mission and chat boxes differ, so they never share + * a cache entry. See docs/impls/0039. */ + paneSurface: PaneSurface; } /** @@ -185,6 +166,7 @@ export const RunnerTerminal = forwardRef< autoFocus, disabled, resizeDisabled, + paneSurface, }, ref, ) { @@ -204,6 +186,9 @@ export const RunnerTerminal = forwardRef< // — declared inside the long-lived mount effect — sees the current // runtime kind without a re-render restarting the whole xterm. const runnerRuntimeRef = useRef(runnerRuntime); + // Mirrors `paneSurface` so the resize funnel — declared inside the + // long-lived mount effect — reads it without re-running the effect. + const paneSurfaceRef = useRef(paneSurface); // Mirrors the `disabled` prop into a ref so the onData/resize // closures don't capture a stale value across the long-lived // terminal effect. @@ -300,6 +285,10 @@ export const RunnerTerminal = forwardRef< runnerRuntimeRef.current = runnerRuntime; }, [runnerRuntime]); + useEffect(() => { + paneSurfaceRef.current = paneSurface; + }, [paneSurface]); + useEffect(() => { onExitRef.current = onExit; }, [onExit]); @@ -355,6 +344,24 @@ export const RunnerTerminal = forwardRef< lastPushedRowsRef.current = lastPushed.rows; }, []); + // Cache every grid that actually reaches the backend, from whichever + // push path sent it. Not folded into `sendBackendResize`: mission slot + // terminals mount under `display:none`, so the mount-time push is + // skipped and their first real grid arrives through + // `refreshActiveTerminal`'s own resize calls — which set `lastPushed*` + // themselves, making the next `pushSize` dedupe away. Routing only the + // one funnel would leave the cache empty for exactly the panes #367 is + // about. Best-effort: a failed write just leaves the previous grid. + const cachePaneGrid = useCallback((cols: number, rows: number) => { + void api.session + .recordPaneGrid(paneSurfaceRef.current, cols, rows) + .catch(() => {}); + }, []); + // Mirrored for `sendBackendResize`, which lives inside the long-lived + // mount effect and must not take a dependency that re-runs it. + const cachePaneGridRef = useRef(cachePaneGrid); + cachePaneGridRef.current = cachePaneGrid; + const refreshActiveTerminal = useCallback( ({ focus = false, @@ -432,6 +439,7 @@ export const RunnerTerminal = forwardRef< ); lastPushedColsRef.current = cols; lastPushedRowsRef.current = rows; + cachePaneGrid(cols, rows); void api.session.resize(sid, cols, rows).catch(() => { rejectSizePush(cols, rows); }); @@ -485,6 +493,7 @@ export const RunnerTerminal = forwardRef< ); lastPushedColsRef.current = cols; lastPushedRowsRef.current = rows; + cachePaneGrid(cols, rows); void api.session.resize(sid, cols, rows).catch(() => { rejectSizePush(cols, rows); }); @@ -517,6 +526,9 @@ export const RunnerTerminal = forwardRef< replayJustDrainedRef.current = false; lastPushedColsRef.current = cols; lastPushedRowsRef.current = rows; + // The settled grid, not the nudge — `nudgedRows` is a one-ioctl + // SIGWINCH trick, never a size any pane is left at. + cachePaneGrid(cols, rows); const nudgedRows = rows > 1 ? rows - 1 : rows + 1; void api.session .resize(sid, cols, nudgedRows) @@ -530,7 +542,7 @@ export const RunnerTerminal = forwardRef< return false; } }, - [ensureWebglRenderer, rejectSizePush, blankGate], + [ensureWebglRenderer, rejectSizePush, blankGate, cachePaneGrid], ); useEffect(() => { @@ -587,6 +599,8 @@ export const RunnerTerminal = forwardRef< void api.session.resize(sid, current.cols, current.rows).catch(() => { rejectSizePush(current.cols, current.rows); }); + // Same value, same moment — no separate measuring pass. + cachePaneGridRef.current(current.cols, current.rows); } function pushBackendResize() { if (resizeDisabledRef.current) return false; @@ -746,38 +760,21 @@ export const RunnerTerminal = forwardRef< // they would in a host terminal, attach the image with their // native `[Image x]` placeholder. Pure-text pastes fall through // to xterm.js's default behavior unchanged. + // + // Copying a *file* takes the same interception point but the other + // branch (feature 55): the clipboard carries no text at all, only + // `public.file-url`, so xterm's default paste inserts nothing. We + // ask NSPasteboard for the paths and inject them instead, which is + // what Terminal.app / iTerm2 / Ghostty do. const onPaste = (e: ClipboardEvent) => { const sid = sessionIdRef.current; if (!sid || disabledRef.current) return; - const items = e.clipboardData?.items; - if (!items) return; - let imageFile: File | null = null; - let imageMimeType: PasteImageMimeType | null = null; - for (let i = 0; i < items.length; i += 1) { - const it = items[i]; - if (it.kind !== "file") continue; - const file = it.getAsFile(); - if (!file) continue; - const mimeType = inferPasteImageMime(it.type, file); - if (!mimeType) continue; - imageFile = file; - imageMimeType = mimeType; - break; - } - if (!imageFile || !imageMimeType) return; - const file = imageFile; - const mimeType = imageMimeType; - e.preventDefault(); - e.stopImmediatePropagation(); - void (async () => { - try { - const buf = await file.arrayBuffer(); - await api.session.pasteImage(new Uint8Array(buf), mimeType); - await api.session.injectStdin(sid, "\x16"); - } catch (err) { - onErrorRef.current?.(String(err)); - } - })(); + void handleTerminalPaste(e, { + clipboardFilePaths: () => api.session.clipboardFilePaths(), + injectStdin: (text) => api.session.injectStdin(sid, text), + pasteImage: (bytes, mimeType) => api.session.pasteImage(bytes, mimeType), + onError: (message) => onErrorRef.current?.(message), + }); }; const textarea = term.textarea; textarea?.addEventListener("paste", onPaste, { capture: true }); diff --git a/src/lib/api.ts b/src/lib/api.ts index cd9f8b5..e1ef7e5 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -79,6 +79,10 @@ export interface DirectSessionEntry { export type PasteImageMimeType = "image/png" | "image/jpeg"; +/** Pane box a measured terminal grid belongs to. Mission panes and chat + * panes subtract different chrome, so their cached grids never mix. */ +export type PaneSurface = "mission" | "chat"; + export interface ProjectRow { id: string; name: string; @@ -398,6 +402,12 @@ export const api = { kill: (sessionId: string) => invoke("session_kill", { sessionId }), resize: (sessionId: string, cols: number, rows: number) => invoke("session_resize", { sessionId, cols, rows }), + /** Record the grid a pane just measured so backend-initiated spawns + * (MCP `mission_start` / `session_start_direct`) can fork at a real + * width instead of 80×24. Mission and chat panes sit in different + * boxes, so they cache separately. See docs/impls/0039. */ + recordPaneGrid: (surface: PaneSurface, cols: number, rows: number) => + invoke("session_record_pane_grid", { surface, cols, rows }), outputSnapshot: (sessionId: string) => invoke("session_output_snapshot", { sessionId }), /** Seq the output ring had reached when the most recent resume @@ -414,6 +424,12 @@ export const api = { bytes: Array.from(bytes), mimeType, }), + /** POSIX paths for the files the clipboard references, in pasteboard + * order; empty for every clipboard shape that carries no + * `public.file-url` flavor (ordinary text, screenshots, browser + * image copies). See docs/features/55-paste-file-paths.md. */ + clipboardFilePaths: () => + invoke("session_clipboard_file_paths"), startDirect: ( runnerId: string, cwd: string | null, diff --git a/src/lib/terminalPaste.test.ts b/src/lib/terminalPaste.test.ts new file mode 100644 index 0000000..f8fd1ff --- /dev/null +++ b/src/lib/terminalPaste.test.ts @@ -0,0 +1,273 @@ +import { describe, expect, it, vi } from "vitest"; + +import type { PasteImageMimeType } from "./api"; +import { + clipboardHasUsableText, + formatPastedPaths, + handleTerminalPaste, + shellQuotePath, +} from "./terminalPaste"; + +function clipboard(text: string | null): DataTransfer { + return { + getData: (type: string) => (type === "text/plain" ? (text ?? "") : ""), + } as unknown as DataTransfer; +} + +describe("clipboardHasUsableText", () => { + it("is true for an ordinary text paste, so xterm keeps handling it", () => { + expect(clipboardHasUsableText(clipboard("/tmp/note.txt"))).toBe(true); + }); + + it("treats whitespace-only text as usable — it is still text to insert", () => { + expect(clipboardHasUsableText(clipboard(" "))).toBe(true); + }); + + it("is false for a file copy, which puts no text on the clipboard", () => { + expect(clipboardHasUsableText(clipboard(""))).toBe(false); + }); + + it("is false when the event carries no clipboardData at all", () => { + expect(clipboardHasUsableText(null)).toBe(false); + }); + + it("is false when reading text/plain throws", () => { + const hostile = { + getData: () => { + throw new Error("no access"); + }, + } as unknown as DataTransfer; + expect(clipboardHasUsableText(hostile)).toBe(false); + }); +}); + +describe("shellQuotePath", () => { + it("leaves an ordinary path bare so @-style completion still works", () => { + expect(shellQuotePath("/Users/jason/go/src/runner/main.go")).toBe( + "/Users/jason/go/src/runner/main.go", + ); + }); + + it("leaves the punctuation paths routinely carry bare", () => { + expect(shellQuotePath("/tmp/a-b_c.d+e=f%g@h:i,j")).toBe( + "/tmp/a-b_c.d+e=f%g@h:i,j", + ); + }); + + it("leaves non-ASCII bare — not a shell metacharacter", () => { + expect(shellQuotePath("/Users/jason/文档/笔记.md")).toBe( + "/Users/jason/文档/笔记.md", + ); + }); + + it("quotes a path with spaces", () => { + expect(shellQuotePath("/Users/jason/My Documents/a.txt")).toBe( + "'/Users/jason/My Documents/a.txt'", + ); + }); + + it("quotes shell metacharacters", () => { + expect(shellQuotePath("/tmp/a$b.txt")).toBe("'/tmp/a$b.txt'"); + expect(shellQuotePath("/tmp/a;b.txt")).toBe("'/tmp/a;b.txt'"); + expect(shellQuotePath("/tmp/a&b.txt")).toBe("'/tmp/a&b.txt'"); + expect(shellQuotePath("/tmp/a*b.txt")).toBe("'/tmp/a*b.txt'"); + expect(shellQuotePath("/tmp/a(b).txt")).toBe("'/tmp/a(b).txt'"); + expect(shellQuotePath("/tmp/a`b`.txt")).toBe("'/tmp/a`b`.txt'"); + expect(shellQuotePath("/tmp/a|b.txt")).toBe("'/tmp/a|b.txt'"); + }); + + it("escapes an embedded single quote by closing, escaping, reopening", () => { + expect(shellQuotePath("/tmp/jason's file.txt")).toBe( + "'/tmp/jason'\\''s file.txt'", + ); + }); + + it("quotes a path whose only oddity is a single quote", () => { + expect(shellQuotePath("/tmp/it's.txt")).toBe("'/tmp/it'\\''s.txt'"); + }); +}); + +describe("formatPastedPaths", () => { + it("joins multiple paths with single spaces", () => { + expect(formatPastedPaths(["/tmp/a.go", "/tmp/b.go"])).toBe( + "/tmp/a.go /tmp/b.go", + ); + }); + + it("quotes only the paths that need it", () => { + expect(formatPastedPaths(["/tmp/a.go", "/tmp/b c.go"])).toBe( + "/tmp/a.go '/tmp/b c.go'", + ); + }); + + it("is empty for an empty pasteboard, so nothing gets injected", () => { + expect(formatPastedPaths([])).toBe(""); + }); +}); + +function imageFile(name: string, type: string, bytes = [1, 2, 3]): File { + const buf = new Uint8Array(bytes).buffer; + return { + name, + type, + arrayBuffer: () => Promise.resolve(buf), + } as unknown as File; +} + +/** A ClipboardEvent stand-in: `text` is what text/plain yields, `files` + * are the `kind: "file"` items WKWebView materializes. */ +function pasteEvent( + text: string, + files: { file: File; type: string }[] = [], +): ClipboardEvent & { preventDefault: ReturnType } { + const items = files.map(({ file, type }) => ({ + kind: "file" as const, + type, + getAsFile: () => file, + })); + return { + clipboardData: { + getData: (t: string) => (t === "text/plain" ? text : ""), + items: Object.assign(items, { length: items.length }), + }, + preventDefault: vi.fn(), + stopImmediatePropagation: vi.fn(), + } as unknown as ClipboardEvent & { + preventDefault: ReturnType; + }; +} + +function effects(paths: string[]) { + return { + clipboardFilePaths: vi.fn(() => Promise.resolve(paths)), + injectStdin: vi.fn(() => Promise.resolve()), + pasteImage: + vi.fn<(bytes: Uint8Array, mimeType: PasteImageMimeType) => Promise>( + () => Promise.resolve(), + ), + onError: vi.fn(), + }; +} + +describe("handleTerminalPaste", () => { + it("leaves an ordinary text paste to xterm — no preventDefault, no IPC", async () => { + const e = pasteEvent("some copied text"); + const fx = effects(["/tmp/should-not-be-read.go"]); + + expect(handleTerminalPaste(e, fx)).toBeNull(); + + expect(e.preventDefault).not.toHaveBeenCalled(); + expect(fx.clipboardFilePaths).not.toHaveBeenCalled(); + expect(fx.injectStdin).not.toHaveBeenCalled(); + }); + + it("commits before the pasteboard round-trip even starts", async () => { + // The load-bearing ordering: preventDefault after an await is a no-op, + // because the browser has already run the default action by then. So + // the handler cannot "call the command, then decide" — it must have + // committed before `clipboardFilePaths` is so much as invoked. + const e = pasteEvent(""); + const fx = effects(["/tmp/a.go"]); + let committedBeforeRoundTrip: boolean | null = null; + fx.clipboardFilePaths.mockImplementation(() => { + committedBeforeRoundTrip = e.preventDefault.mock.calls.length > 0; + return Promise.resolve(["/tmp/a.go"]); + }); + + const pending = handleTerminalPaste(e, fx); + // Committed synchronously, before the returned promise can settle. + expect(pending).not.toBeNull(); + expect(e.preventDefault).toHaveBeenCalledTimes(1); + await pending; + + expect(committedBeforeRoundTrip).toBe(true); + }); + + it("injects the quoted paths through raw stdin, with no trailing Enter", async () => { + const e = pasteEvent(""); + const fx = effects(["/tmp/a.go", "/tmp/b c.go"]); + + await handleTerminalPaste(e, fx); + + expect(fx.injectStdin).toHaveBeenCalledTimes(1); + expect(fx.injectStdin).toHaveBeenCalledWith("/tmp/a.go '/tmp/b c.go'"); + expect(fx.pasteImage).not.toHaveBeenCalled(); + }); + + it("pastes the path of a copied image FILE instead of attaching it", async () => { + // Finder-copied shot.png: WKWebView exposes it as an image file AND + // the pasteboard carries public.file-url. Decision 3 — path wins. + const e = pasteEvent("", [ + { file: imageFile("shot.png", "image/png"), type: "image/png" }, + ]); + const fx = effects(["/Users/jason/Desktop/shot.png"]); + + await handleTerminalPaste(e, fx); + + expect(fx.injectStdin).toHaveBeenCalledWith("/Users/jason/Desktop/shot.png"); + expect(fx.pasteImage).not.toHaveBeenCalled(); + }); + + it("still attaches image BYTES when no file-url is present (#79)", async () => { + // A screenshot or browser copy: bytes on the pasteboard, no file-url. + const e = pasteEvent("", [ + { file: imageFile("image.png", "image/png", [9, 8, 7]), type: "image/png" }, + ]); + const fx = effects([]); + + await handleTerminalPaste(e, fx); + + expect(fx.pasteImage).toHaveBeenCalledTimes(1); + const [bytes, mimeType] = fx.pasteImage.mock.calls[0]; + expect(Array.from(bytes)).toEqual([9, 8, 7]); + expect(mimeType).toBe("image/png"); + // Ctrl-V, so the agent runs its own attach flow. + expect(fx.injectStdin).toHaveBeenCalledWith("\x16"); + }); + + it("attaches a jpeg inferred from the filename when the item type is vague", async () => { + const e = pasteEvent("", [ + { file: imageFile("photo.JPG", ""), type: "application/octet-stream" }, + ]); + const fx = effects([]); + + await handleTerminalPaste(e, fx); + + expect(fx.pasteImage.mock.calls[0][1]).toBe("image/jpeg"); + }); + + it("is a silent no-op when the clipboard holds neither text, paths, nor an image", async () => { + const e = pasteEvent(""); + const fx = effects([]); + + await handleTerminalPaste(e, fx); + + // Swallowed the paste — but it had nothing to insert anyway. + expect(e.preventDefault).toHaveBeenCalledTimes(1); + expect(fx.injectStdin).not.toHaveBeenCalled(); + expect(fx.pasteImage).not.toHaveBeenCalled(); + expect(fx.onError).not.toHaveBeenCalled(); + }); + + it("reports a failed pasteboard read instead of throwing", async () => { + const e = pasteEvent(""); + const fx = effects([]); + fx.clipboardFilePaths.mockRejectedValueOnce(new Error("pasteboard gone")); + + await handleTerminalPaste(e, fx); + + expect(fx.onError).toHaveBeenCalledTimes(1); + expect(String(fx.onError.mock.calls[0][0])).toContain("pasteboard gone"); + }); + + it("reports a failed injection instead of throwing", async () => { + const e = pasteEvent(""); + const fx = effects(["/tmp/a.go"]); + fx.injectStdin.mockRejectedValueOnce(new Error("session not found")); + + await handleTerminalPaste(e, fx); + + expect(fx.onError).toHaveBeenCalledTimes(1); + expect(String(fx.onError.mock.calls[0][0])).toContain("session not found"); + }); +}); diff --git a/src/lib/terminalPaste.ts b/src/lib/terminalPaste.ts new file mode 100644 index 0000000..9d7b911 --- /dev/null +++ b/src/lib/terminalPaste.ts @@ -0,0 +1,170 @@ +// Paste handling for the terminal surface (features 55 and #79). +// +// Copying a file puts no text on the clipboard — only `public.file-url` +// flavors — so xterm's default paste inserts nothing. Runner intercepts +// and inserts the POSIX path instead, which is what every native terminal +// does. Copying image *bytes* takes the other branch and keeps #79's +// attach flow. +// +// The orchestration lives here rather than in the component so it can be +// tested without mounting xterm: the ordering it encodes (commit before +// the round-trip, read the event synchronously, prefer a file reference +// over image bytes) is the whole feature, and every one of those is a +// silent failure if it regresses. + +import type { PasteImageMimeType } from "./api"; + +/** + * Whether the event carries text xterm can paste on its own. + * + * This is the gate, and it has to be answered synchronously: the handler + * commits (`preventDefault`) before it knows whether the pasteboard holds + * any file, because `preventDefault` after an `await` is a no-op — the + * browser has already run the default action by the time an IPC round-trip + * resolves. Every ordinary paste answers true here and costs no IPC. + * + * Deliberately keyed on text *absence*, not on `item.kind === "file"`: + * whether WKWebView exposes an arbitrary non-image file as a `file` item is + * unverified, and a `kind`-based trigger that never fires would make the + * feature silently do nothing. + */ +export function clipboardHasUsableText(data: DataTransfer | null): boolean { + if (!data) return false; + try { + return data.getData("text/plain").length > 0; + } catch { + return false; + } +} + +/** + * Characters that make a bare path unsafe to hand a shell: whitespace and + * the metacharacters. Letters, digits, non-ASCII, and the punctuation a + * POSIX path routinely carries (`_ @ % + = : , . / -`) stay bare. + */ +const NEEDS_QUOTING = /[\s!"#$&'()*;<>?[\\\]^`{|}~]/; + +/** + * Quote a path only when it needs it. iTerm2 quotes unconditionally + * because its panes are shell prompts; Runner's are usually *agent* + * prompts, where `'/Users/jason/foo.go'` is noise and defeats `@`-style + * file completion. + */ +export function shellQuotePath(path: string): string { + if (!NEEDS_QUOTING.test(path)) return path; + // Single-quote, with each embedded `'` closing the quote, escaping + // itself, and reopening: `'` → `'\''`. + return `'${path.split("'").join("'\\''")}'`; +} + +/** Paths as they go into the terminal: quoted as needed, space-separated. */ +export function formatPastedPaths(paths: string[]): string { + return paths.map(shellQuotePath).join(" "); +} + +export function normalizePasteImageMime( + type: string, +): PasteImageMimeType | null { + switch (type.trim().toLowerCase()) { + case "image/png": + return "image/png"; + case "image/jpeg": + case "image/jpg": + return "image/jpeg"; + default: + return null; + } +} + +export function inferPasteImageMime( + itemType: string, + file: File, +): PasteImageMimeType | null { + const fromType = + normalizePasteImageMime(itemType) ?? normalizePasteImageMime(file.type); + if (fromType) return fromType; + + const name = file.name.toLowerCase(); + if (name.endsWith(".png")) return "image/png"; + if (name.endsWith(".jpg") || name.endsWith(".jpeg")) return "image/jpeg"; + return null; +} + +/** First image the event carries as a `File`, or null. Read synchronously: + * `clipboardData` is only valid during dispatch. */ +function findPasteImage( + data: DataTransfer | null, +): { file: File; mimeType: PasteImageMimeType } | null { + const items = data?.items; + if (!items) return null; + for (let i = 0; i < items.length; i += 1) { + const it = items[i]; + if (it.kind !== "file") continue; + const file = it.getAsFile(); + if (!file) continue; + const mimeType = inferPasteImageMime(it.type, file); + if (!mimeType) continue; + return { file, mimeType }; + } + return null; +} + +/** Backend calls the paste handler makes, injected so the orchestration is + * testable without a live session. */ +export interface TerminalPasteEffects { + clipboardFilePaths: () => Promise; + /** Raw stdin — never `inject_paste`, which appends Enter. */ + injectStdin: (text: string) => Promise; + pasteImage: (bytes: Uint8Array, mimeType: PasteImageMimeType) => Promise; + onError: (message: string) => void; +} + +/** + * Decide and run a terminal paste. + * + * Returns null when the event was left alone for xterm to paste — an + * ordinary text paste, which costs no IPC. Otherwise the event has been + * committed (`preventDefault` + `stopImmediatePropagation`) and the + * returned promise resolves when the injection finishes. + * + * The commit is synchronous by necessity: `preventDefault` after an + * `await` does nothing, because the browser has already run the default + * action by the time an IPC round-trip resolves. So this cannot "call the + * command, then decide" — it decides from `clipboardData`, commits, and + * only then goes async. Feature 55 decision 2. + */ +export function handleTerminalPaste( + e: ClipboardEvent, + effects: TerminalPasteEffects, +): Promise | null { + const data = e.clipboardData; + // Ordinary paste — xterm inserts it, no interception and no IPC. + if (clipboardHasUsableText(data)) return null; + + // Read the event before committing: it goes stale once we go async. + const image = findPasteImage(data); + + e.preventDefault(); + e.stopImmediatePropagation(); + return (async () => { + try { + // A file *reference* beats image bytes: a Finder-copied `shot.png` + // carries `public.file-url` and pastes its path, while a screenshot + // or browser copy carries bytes only and keeps #79's attach flow. + // Feature 55 decision 3. + const paths = await effects.clipboardFilePaths(); + if (paths.length > 0) { + await effects.injectStdin(formatPastedPaths(paths)); + return; + } + if (!image) return; + const buf = await image.file.arrayBuffer(); + await effects.pasteImage(new Uint8Array(buf), image.mimeType); + // Ctrl-V: claude-code / codex see it as they would in a host + // terminal and attach with their native `[Image x]` placeholder. + await effects.injectStdin("\x16"); + } catch (err) { + effects.onError(String(err)); + } + })(); +} diff --git a/src/pages/MissionWorkspace.tsx b/src/pages/MissionWorkspace.tsx index c881e1c..85cec14 100644 --- a/src/pages/MissionWorkspace.tsx +++ b/src/pages/MissionWorkspace.tsx @@ -1544,6 +1544,7 @@ function SlotPtyPane({ ref={registerTerminal} sessionId={session.id} runnerRuntime={session.runtime} + paneSurface="mission" onError={onError} active={active && !resuming && !starting} hiddenByDisplayNone={hiddenByDisplayNone}