diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml new file mode 100644 index 0000000..788bdf5 --- /dev/null +++ b/.github/workflows/rust.yml @@ -0,0 +1,40 @@ +name: Rust + +on: + pull_request: + push: + branches: [main] + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }}-${{ github.head_ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + CARGO_TERM_COLOR: always + +jobs: + clippy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + with: + components: clippy + + - name: Cache Cargo + uses: actions/cache@v5 + with: + path: | + ~/.cargo/bin/ + ~/.cargo/registry/index/ + ~/.cargo/registry/cache/ + ~/.cargo/git/db/ + target/ + key: ${{ runner.os }}-cargo-${{ hashFiles('Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo- + + - name: Run Clippy + run: cargo clippy --workspace --all-targets --locked -- -D warnings diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2e9ec49..841e5e9 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -25,6 +25,9 @@ jobs: # - cache-hit : Whether the executable was read from cache. Ex. "true" # - bun-version : The output from running `bun-version`. Ex. "1.0.0" + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + - name: Install just # casey/just: https://just.systems/man/en/chapter_6.html # taiki-e/install-action: https://github.com/taiki-e/install-action diff --git a/.gitignore b/.gitignore index 41d583e..fcdf8cb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,22 @@ env.sh +## ------------------------------------ +## Ignore Patterns - Rust + +# Generated by Cargo: compiled files and executables. +debug/ +target/ +dist/ + +# Keep Cargo.lock for executable crates such as clipboard/wsl-clipboard. + +# Backup files generated by rustfmt. +**/*.rs.bk + +# Debugging and profiling output from Rust toolchains. +*.pdb +*.profraw + # -------------------------------------------------- # TypeScript # -------------------------------------------------- diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..767ccd3 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,56 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "wsl-clipboard" +version = "0.1.0" +dependencies = [ + "base64", + "fs2", + "libc", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0ec487a --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,6 @@ +[workspace] +members = ["clipboard"] +resolver = "2" + +[workspace.package] +edition = "2024" diff --git a/README.md b/README.md index 51a35d3..c862f22 100644 --- a/README.md +++ b/README.md @@ -138,9 +138,15 @@ other Codex MCP servers remain local. Run `bun run codex/config.ts` for its usage and options. ### WSL Clipboard Integration -- Custom `pbcopy` and `pbpaste` commands that work with Windows clipboard -- Neovim configured to use system clipboard across WSL/Windows boundary -- Automatically removes Windows line endings when pasting +- A persistent Rust bridge that keeps one PowerShell clipboard process warm +- `pbcopy`, `pbpaste`, `wsl-pbcopy`, and `wsl-pbpaste` are symlinks to the one + installed bridge binary; it dispatches by the command name +- Lossless UTF-8 text across the WSL/Windows boundary, including emoji and + non-BMP Unicode; PowerShell performs the internal UTF-16 conversion +- `just sync` installs `~/.local/bin/wsl-clipboard` on WSL; its daemon starts + only on the first copy or paste request +- `legacy-pbcopy` and `legacy-pbpaste` retain the old one-shot commands for + diagnostics and performance comparison ## Requirements diff --git a/bin/legacy-pbcopy b/bin/legacy-pbcopy new file mode 100755 index 0000000..6c4063e --- /dev/null +++ b/bin/legacy-pbcopy @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# One-shot pre-Rust WSL clipboard copy command, retained for benchmarks. +clip.exe diff --git a/bin/legacy-pbpaste b/bin/legacy-pbpaste new file mode 100755 index 0000000..8a7b807 --- /dev/null +++ b/bin/legacy-pbpaste @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# One-shot pre-Rust WSL clipboard paste command, retained for benchmarks. +powershell.exe "(Get-Clipboard).TrimEnd()" | tr -d "\r" | sed -z 's/\n$//' diff --git a/bin/pbcopy b/bin/pbcopy deleted file mode 100755 index 76dca93..0000000 --- a/bin/pbcopy +++ /dev/null @@ -1,2 +0,0 @@ -#!/bin/sh -clip.exe diff --git a/bin/pbpaste b/bin/pbpaste deleted file mode 100755 index 58c42bd..0000000 --- a/bin/pbpaste +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -# powershell.exe "(Get-Clipboard).TrimEnd()" | tr -d "\r" | sed '${/^$/d;}' -powershell.exe "(Get-Clipboard).TrimEnd()" | tr -d "\r" | sed -z 's/\n$//' diff --git a/clipboard/Cargo.toml b/clipboard/Cargo.toml new file mode 100644 index 0000000..8b35d8b --- /dev/null +++ b/clipboard/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "wsl-clipboard" +version = "0.1.0" +edition.workspace = true + +[dependencies] +base64 = "0.22.1" +fs2 = "0.4.3" +libc = "0.2.177" diff --git a/clipboard/src/main.rs b/clipboard/src/main.rs new file mode 100644 index 0000000..af0238a --- /dev/null +++ b/clipboard/src/main.rs @@ -0,0 +1,596 @@ +use std::{ + env, + fs::{self, OpenOptions}, + io::{self, BufRead, BufReader, Read, Write}, + os::unix::{ + fs::PermissionsExt, + net::{UnixListener, UnixStream}, + }, + path::{Path, PathBuf}, + process::{Child, ChildStdin, ChildStdout, Command, Stdio}, + sync::{ + Arc, Mutex, + atomic::{AtomicBool, Ordering}, + }, + thread, + time::{Duration, Instant}, +}; + +use base64::{Engine as _, engine::general_purpose::STANDARD}; +use fs2::FileExt; + +/// Caps a frame before allocating its payload, so a malformed local client +/// cannot make the daemon reserve unbounded memory. +const MAX_PAYLOAD_BYTES: u64 = 64 * 1024 * 1024; + +/// Bounds client startup waits when PowerShell cannot initialize, while still +/// accommodating the one-time Windows process startup cost. +const STARTUP_TIMEOUT: Duration = Duration::from_secs(2); + +// One-byte operation and status codes for the Unix-socket protocol. The +// payload framing below carries all arbitrary text, not these control values. +const REQUEST_COPY: u8 = 1; +const REQUEST_PASTE: u8 = 2; +const REQUEST_STATUS: u8 = 3; +const REQUEST_STOP: u8 = 4; +const RESPONSE_OK: u8 = 0; +const RESPONSE_ERROR: u8 = 1; + +/// A complete, intentionally small request vocabulary for the local bridge. +/// +/// Keeping this protocol declarative prevents socket clients from supplying +/// arbitrary PowerShell source code. +enum Request { + Copy(Vec), + Paste, + Status, + Stop, +} + +/// Runtime-owned paths used to discover, serialize startup of, and diagnose +/// the per-user daemon. These files are never part of the repository state. +struct Paths { + socket: PathBuf, + startup_lock: PathBuf, + log: PathBuf, +} + +/// The one long-lived Windows process that accesses the interactive clipboard. +/// +/// The daemon serializes access to this object because its stdin/stdout are a +/// single request-response stream. Reusing it avoids the expensive PowerShell +/// process startup on every `copy` or `paste` command. +struct PowerShell { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl PowerShell { + /// Starts a constrained PowerShell loop and waits for its explicit ready + /// line before accepting socket requests. + fn start() -> io::Result { + let script = build_power_shell_script(); + let mut child = Command::new("powershell.exe") + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-STA", + "-Command", + &script, + ]) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn()?; + let stdin = child.stdin.take().ok_or_else(missing_pipe)?; + let stdout = child.stdout.take().ok_or_else(missing_pipe)?; + let mut power_shell = Self { + child, + stdin, + stdout: BufReader::new(stdout), + }; + let ready = power_shell.read_line()?; + if ready != "READY" { + return Err(other(format!( + "unexpected PowerShell greeting: {ready}" + ))); + } + Ok(power_shell) + } + + /// Base64-frames arbitrary UTF-8 bytes so newlines never interfere with + /// the line-oriented PowerShell control channel. + fn copy(&mut self, bytes: &[u8]) -> io::Result<()> { + let request = format!("COPY {}\n", STANDARD.encode(bytes)); + self.stdin.write_all(request.as_bytes())?; + self.stdin.flush()?; + self.expect_ok().map(|_| ()) + } + + /// Reads clipboard text through the already-running PowerShell process. + fn paste(&mut self) -> io::Result> { + self.stdin.write_all(b"PASTE\n")?; + self.stdin.flush()?; + self.expect_ok() + } + + fn expect_ok(&mut self) -> io::Result> { + let line = self.read_line()?; + let (kind, encoded) = line + .split_once(' ') + .map_or((line.as_str(), ""), |(kind, encoded)| (kind, encoded)); + let bytes = STANDARD.decode(encoded).map_err(|error| { + other(format!("invalid PowerShell response: {error}")) + })?; + match kind { + "OK" => Ok(bytes), + "ERR" => Err(other(String::from_utf8_lossy(&bytes))), + _ => Err(other(format!("unexpected PowerShell response: {line}"))), + } + } + + fn read_line(&mut self) -> io::Result { + let mut line = String::new(); + let read = self.stdout.read_line(&mut line)?; + if read == 0 { + return Err(other("PowerShell closed its output stream")); + } + Ok(line.trim_end_matches(['\r', '\n']).to_owned()) + } + + /// Lets the child exit cleanly when the daemon stops, rather than leaving + /// a Windows process alive after its Unix-socket owner has gone away. + fn shutdown(&mut self) { + let _ = self.stdin.write_all(b"QUIT\n"); + let _ = self.stdin.flush(); + let _ = self.child.wait(); + } +} + +impl Drop for PowerShell { + fn drop(&mut self) { + self.shutdown(); + } +} + +fn main() { + if let Err(error) = run() { + eprintln!("wsl-clipboard: {error}"); + std::process::exit(1); + } +} + +fn run() -> io::Result<()> { + let mut args = env::args(); + let executable = args.next().unwrap_or_else(|| "wsl-clipboard".to_owned()); + let command = command_from_invocation(Path::new(&executable), args.next()); + let paths = socket_paths()?; + match command.as_str() { + "copy" => { + let mut bytes = Vec::new(); + io::stdin().read_to_end(&mut bytes)?; + validate_utf8_input(&bytes)?; + send_client_request(&paths, Request::Copy(bytes), true).map(|_| ()) + } + "paste" => { + let bytes = send_client_request(&paths, Request::Paste, true)?; + io::stdout().write_all(&bytes) + } + "status" => { + send_client_request(&paths, Request::Status, false)?; + println!("running"); + Ok(()) + } + "stop" => { + send_client_request(&paths, Request::Stop, false)?; + println!("stopped"); + Ok(()) + } + "daemon" => run_daemon(paths), + "help" | "--help" | "-h" => { + print_usage(); + Ok(()) + } + _ => Err(other(format!("unknown command: {command}"))), + } +} + +/// Maps installed command aliases to operations without requiring duplicate +/// binaries. Cargo installs one `wsl-clipboard` executable; the installer then +/// creates same-directory symlinks named like the familiar clipboard tools. +/// The kernel preserves that invoked name in `argv[0]`, so the client can +/// select copy or paste before it contacts the daemon. +fn command_from_invocation( + executable: &Path, + requested: Option, +) -> String { + match executable.file_name().and_then(|name| name.to_str()) { + Some("pbcopy" | "wsl-pbcopy") => "copy".to_owned(), + Some("pbpaste" | "wsl-pbpaste") => "paste".to_owned(), + _ => requested.unwrap_or_else(|| "help".to_owned()), + } +} + +/// Sends one request to the daemon. Copy and paste commands may launch it on +/// demand; status and stop stay side-effect free when it is absent. +fn send_client_request( + paths: &Paths, + request: Request, + auto_start: bool, +) -> io::Result> { + let mut stream = match UnixStream::connect(&paths.socket) { + Ok(stream) => stream, + Err(error) if auto_start => { + ensure_daemon(paths, error)?; + UnixStream::connect(&paths.socket)? + } + Err(error) => return Err(error), + }; + write_request(&mut stream, request)?; + read_response(&mut stream) +} + +/// Starts at most one daemon for a burst of clients that all observe a missing +/// socket. The lock holder rechecks the socket after acquiring the lock, so a +/// previously successful starter wins without spawning a duplicate process. +fn ensure_daemon(paths: &Paths, first_error: io::Error) -> io::Result<()> { + prepare_runtime_dir(paths)?; + let lock = OpenOptions::new() + .create(true) + .read(true) + .write(true) + .truncate(false) + .open(&paths.startup_lock)?; + lock.lock_exclusive()?; + + if UnixStream::connect(&paths.socket).is_ok() { + return Ok(()); + } + if paths.socket.exists() { + fs::remove_file(&paths.socket)?; + } + spawn_daemon(paths)?; + + let started_at = Instant::now(); + loop { + match UnixStream::connect(&paths.socket) { + Ok(_) => return Ok(()), + Err(error) if started_at.elapsed() < STARTUP_TIMEOUT => { + let _ = error; + thread::sleep(Duration::from_millis(20)); + } + Err(error) => { + return Err(other(format!( + "clipboard daemon did not start after {first_error}: {error}", + ))); + } + } + } +} + +/// Detaches the daemon from the short-lived client and records diagnostics in +/// a runtime log instead of corrupting command stdout. +fn spawn_daemon(paths: &Paths) -> io::Result<()> { + let executable = env::current_exe()?; + let log = OpenOptions::new() + .create(true) + .append(true) + .open(&paths.log)?; + Command::new(executable) + .arg("daemon") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::from(log)) + .spawn()?; + Ok(()) +} + +/// Owns the Unix socket and the persistent PowerShell child for one WSL user. +/// +/// Client handlers may run concurrently, but the `PowerShell` mutex preserves +/// a single ordered request-response conversation with the child process. +fn run_daemon(paths: Paths) -> io::Result<()> { + prepare_runtime_dir(&paths)?; + if UnixStream::connect(&paths.socket).is_ok() { + return Err(other("clipboard daemon is already running")); + } + if paths.socket.exists() { + fs::remove_file(&paths.socket)?; + } + let listener = UnixListener::bind(&paths.socket)?; + fs::set_permissions(&paths.socket, fs::Permissions::from_mode(0o600))?; + listener.set_nonblocking(true)?; + + let power_shell = Arc::new(Mutex::new(PowerShell::start()?)); + let running = Arc::new(AtomicBool::new(true)); + while running.load(Ordering::SeqCst) { + match listener.accept() { + Ok((stream, _)) => { + let power_shell = Arc::clone(&power_shell); + let running = Arc::clone(&running); + thread::spawn(move || { + handle_client(stream, power_shell, running) + }); + } + Err(error) if error.kind() == io::ErrorKind::WouldBlock => { + thread::sleep(Duration::from_millis(10)); + } + Err(error) => return Err(error), + } + } + drop(listener); + fs::remove_file(&paths.socket).or_else(ignore_missing_file)?; + Ok(()) +} + +/// Executes one socket request and always returns either a framed result or a +/// framed error, so client command output remains separate from diagnostics. +fn handle_client( + mut stream: UnixStream, + power_shell: Arc>, + running: Arc, +) { + let result = read_request(&mut stream).and_then(|request| match request { + Request::Copy(bytes) => power_shell + .lock() + .map_err(poisoned)? + .copy(&bytes) + .map(|_| Vec::new()), + Request::Paste => power_shell.lock().map_err(poisoned)?.paste(), + Request::Status => Ok(b"running".to_vec()), + Request::Stop => { + running.store(false, Ordering::SeqCst); + Ok(b"stopped".to_vec()) + } + }); + let _ = match result { + Ok(bytes) => write_response(&mut stream, RESPONSE_OK, &bytes), + Err(error) => write_response( + &mut stream, + RESPONSE_ERROR, + error.to_string().as_bytes(), + ), + }; +} + +/// Selects an application-owned runtime directory. An `XDG_RUNTIME_DIR` +/// child is preferred; the UID-specific `/tmp` fallback also avoids sharing a +/// socket namespace across local users when no user runtime directory exists. +fn socket_paths() -> io::Result { + let runtime_dir = env::var_os("XDG_RUNTIME_DIR") + .map(|directory| PathBuf::from(directory).join("wsl-clipboard")) + .unwrap_or_else(|| { + let uid = unsafe { libc::geteuid() }; + PathBuf::from(format!("/tmp/wsl-clipboard-{uid}")) + }); + Ok(Paths { + socket: runtime_dir.join("wsl-clipboard.sock"), + startup_lock: runtime_dir.join("wsl-clipboard.startup.lock"), + log: runtime_dir.join("wsl-clipboard.log"), + }) +} + +/// Creates the application directory with owner-only access before placing a +/// socket, lock, or log inside it. +fn prepare_runtime_dir(paths: &Paths) -> io::Result<()> { + let runtime_dir = paths.socket.parent().expect("socket has parent"); + fs::create_dir_all(runtime_dir)?; + fs::set_permissions(runtime_dir, fs::Permissions::from_mode(0o700)) +} + +fn write_request(writer: &mut W, request: Request) -> io::Result<()> { + let (kind, payload): (u8, &[u8]) = match &request { + Request::Copy(bytes) => (REQUEST_COPY, bytes), + Request::Paste => (REQUEST_PASTE, &[]), + Request::Status => (REQUEST_STATUS, &[]), + Request::Stop => (REQUEST_STOP, &[]), + }; + write_frame(writer, kind, payload) +} + +fn read_request(reader: &mut R) -> io::Result { + let (kind, payload) = read_frame(reader)?; + match kind { + REQUEST_COPY => Ok(Request::Copy(payload)), + REQUEST_PASTE if payload.is_empty() => Ok(Request::Paste), + REQUEST_STATUS if payload.is_empty() => Ok(Request::Status), + REQUEST_STOP if payload.is_empty() => Ok(Request::Stop), + _ => Err(other("invalid clipboard request")), + } +} + +fn write_response( + writer: &mut W, + status: u8, + payload: &[u8], +) -> io::Result<()> { + write_frame(writer, status, payload) +} + +fn read_response(reader: &mut R) -> io::Result> { + let (status, payload) = read_frame(reader)?; + match status { + RESPONSE_OK => Ok(payload), + RESPONSE_ERROR => Err(other(String::from_utf8_lossy(&payload))), + _ => Err(other("invalid clipboard response")), + } +} + +/// Writes the binary wire format: one operation/status byte, an unsigned +/// 64-bit big-endian length, then the exact payload bytes. Length framing keeps +/// embedded newlines and trailing whitespace lossless across the socket. +fn write_frame( + writer: &mut W, + kind: u8, + payload: &[u8], +) -> io::Result<()> { + if payload.len() as u64 > MAX_PAYLOAD_BYTES { + return Err(other("clipboard payload exceeds 64 MiB")); + } + writer.write_all(&[kind])?; + writer.write_all(&(payload.len() as u64).to_be_bytes())?; + writer.write_all(payload)?; + writer.flush() +} + +/// Reads one complete frame and validates its allocation size before creating +/// the payload buffer. +fn read_frame(reader: &mut R) -> io::Result<(u8, Vec)> { + let mut kind = [0_u8; 1]; + reader.read_exact(&mut kind)?; + let mut length = [0_u8; 8]; + reader.read_exact(&mut length)?; + let length = u64::from_be_bytes(length); + if length > MAX_PAYLOAD_BYTES { + return Err(other("clipboard payload exceeds 64 MiB")); + } + let mut payload = vec![0; length as usize]; + reader.read_exact(&mut payload)?; + Ok((kind[0], payload)) +} + +/// Builds the child script rather than accepting a caller-provided command. +/// +/// The script recognizes only `COPY`, `PASTE`, and `QUIT`; base64 carries the +/// actual text and response bytes without invoking PowerShell expression +/// evaluation on client input. +fn build_power_shell_script() -> String { + [ + "$ErrorActionPreference = 'Stop'", + "$utf8 = [Text.UTF8Encoding]::new($false, $true)", + "[Console]::OutputEncoding = $utf8", + "[Console]::Out.WriteLine('READY')", + "[Console]::Out.Flush()", + "while (($line = [Console]::In.ReadLine()) -ne $null) {", + "try {", + "if ($line -eq 'PASTE') {", + "$text = Get-Clipboard -Raw", + "if ($null -eq $text) { $text = '' }", + "$bytes = $utf8.GetBytes([string]$text)", + "[Console]::Out.WriteLine('OK ' + [Convert]::ToBase64String($bytes))", + "} elseif ($line.StartsWith('COPY ')) {", + "$bytes = [Convert]::FromBase64String($line.Substring(5))", + "$text = $utf8.GetString($bytes)", + "Set-Clipboard -Value $text", + "[Console]::Out.WriteLine('OK ')", + "} elseif ($line -eq 'QUIT') { break } else {", + "throw 'invalid clipboard command'", + "}", + "} catch {", + "$bytes = $utf8.GetBytes($_.Exception.Message)", + "[Console]::Out.WriteLine('ERR ' + [Convert]::ToBase64String($bytes))", + "}", + "[Console]::Out.Flush()", + "}", + ] + .join("; ") +} + +fn missing_pipe() -> io::Error { + other("PowerShell pipe was unavailable") +} + +fn poisoned(_: std::sync::PoisonError) -> io::Error { + other("clipboard PowerShell lock was poisoned") +} + +fn ignore_missing_file(error: io::Error) -> io::Result<()> { + if error.kind() == io::ErrorKind::NotFound { + Ok(()) + } else { + Err(error) + } +} + +fn other(message: impl Into) -> io::Error { + io::Error::other(message.into()) +} + +/// Rejects byte streams that PowerShell could only decode by replacing data. +/// +/// The public commands are text tools, like macOS `pbcopy` and `pbpaste`. +/// Windows stores that text as UTF-16 internally, but every Unicode scalar has +/// a lossless UTF-8 representation at this Unix boundary. Failing here keeps +/// invalid byte streams visible instead of silently producing U+FFFD. +fn validate_utf8_input(bytes: &[u8]) -> io::Result<()> { + std::str::from_utf8(bytes).map(|_| ()).map_err(|error| { + other(format!("clipboard copy expects UTF-8 input: {error}")) + }) +} + +fn print_usage() { + println!("Usage: wsl-clipboard "); + println!("Aliases: pbcopy, pbpaste, wsl-pbcopy, wsl-pbpaste"); +} + +#[cfg(test)] +mod tests { + use std::io::Cursor; + + use super::*; + + #[test] + fn copy_request_round_trips_unicode_and_newlines() { + let expected = "line one\n日本語\n\n".as_bytes().to_vec(); + let mut bytes = Vec::new(); + write_request(&mut bytes, Request::Copy(expected.clone())).unwrap(); + let actual = read_request(&mut Cursor::new(bytes)).unwrap(); + assert!(matches!(actual, Request::Copy(bytes) if bytes == expected)); + } + + #[test] + fn response_round_trips_binary_data() { + let expected = vec![0, b'a', b'\n', 255]; + let mut bytes = Vec::new(); + write_response(&mut bytes, RESPONSE_OK, &expected).unwrap(); + assert_eq!(read_response(&mut Cursor::new(bytes)).unwrap(), expected); + } + + #[test] + fn rejects_payload_larger_than_limit() { + let length = (MAX_PAYLOAD_BYTES + 1).to_be_bytes(); + let bytes = [vec![REQUEST_COPY], length.to_vec()].concat(); + assert!(read_request(&mut Cursor::new(bytes)).is_err()); + } + + #[test] + fn power_shell_protocol_has_no_command_evaluation() { + let script = build_power_shell_script(); + assert!(script.contains("Get-Clipboard -Raw")); + assert!(script.contains("Set-Clipboard -Value $text")); + assert!(script.contains("UTF8Encoding]::new($false, $true)")); + assert!(!script.contains("Invoke-Expression")); + } + + #[test] + fn rejects_invalid_utf8_copy_input() { + assert!(validate_utf8_input(&[0xff]).is_err()); + } + + #[test] + fn installed_aliases_select_clipboard_operations() { + assert_eq!( + command_from_invocation( + Path::new("/home/user/.local/bin/pbcopy"), + None + ), + "copy" + ); + assert_eq!( + command_from_invocation( + Path::new("wsl-pbpaste"), + Some("status".to_owned()) + ), + "paste" + ); + assert_eq!( + command_from_invocation( + Path::new("wsl-clipboard"), + Some("status".to_owned()) + ), + "status" + ); + } +} diff --git a/clipboard/tests/clipboard.rs b/clipboard/tests/clipboard.rs new file mode 100644 index 0000000..8442c8b --- /dev/null +++ b/clipboard/tests/clipboard.rs @@ -0,0 +1,204 @@ +use std::{ + env, + io::Write, + process::{Command, Output, Stdio}, + thread, + time::{Duration, Instant}, +}; + +const LEGACY_PBCOPY: &str = + concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/legacy-pbcopy"); +const LEGACY_PBPASTE: &str = + concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/legacy-pbpaste"); +struct ClipboardCli { + name: &'static str, + copy_executable: &'static str, + copy_args: &'static [&'static str], + paste_executable: &'static str, + paste_args: &'static [&'static str], +} + +fn binary() -> &'static str { + env!("CARGO_BIN_EXE_wsl-clipboard") +} + +fn legacy_clipboard() -> ClipboardCli { + ClipboardCli { + name: "legacy one-shot clipboard", + copy_executable: LEGACY_PBCOPY, + copy_args: &[], + paste_executable: LEGACY_PBPASTE, + paste_args: &[], + } +} + +fn rust_clipboard() -> ClipboardCli { + ClipboardCli { + name: "wsl-clipboard", + copy_executable: binary(), + copy_args: &["copy"], + paste_executable: binary(), + paste_args: &["paste"], + } +} + +fn command_available(executable: &str) -> bool { + Command::new(executable) + .arg("-NoProfile") + .arg("-Command") + .arg("exit 0") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok_and(|status| status.success()) +} + +fn run_command(executable: &str, args: &[&str], input: Option<&[u8]>) -> Output { + let mut command = Command::new(executable); + command + .args(args) + .stdin(if input.is_some() { + Stdio::piped() + } else { + Stdio::null() + }) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + let mut child = command.spawn().unwrap(); + if let Some(input) = input { + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(input).unwrap(); + } + child.wait_with_output().unwrap() +} + +fn copy(cli: &ClipboardCli, input: &[u8]) { + let output = run_command(cli.copy_executable, cli.copy_args, Some(input)); + assert!( + output.status.success(), + "{} copy failed: {}", + cli.name, + String::from_utf8_lossy(&output.stderr), + ); + assert!(output.stdout.is_empty(), "{} copy wrote stdout", cli.name); + assert!(output.stderr.is_empty(), "{} copy wrote stderr", cli.name); +} + +fn paste(cli: &ClipboardCli) -> Vec { + let output = run_command(cli.paste_executable, cli.paste_args, None); + assert!( + output.status.success(), + "{} paste failed: {}", + cli.name, + String::from_utf8_lossy(&output.stderr), + ); + assert!(output.stderr.is_empty(), "{} paste wrote stderr", cli.name); + output.stdout +} + +fn run_rust_command(args: &[&str]) -> Output { + run_command(binary(), args, None) +} + +fn stop_daemon() { + let _ = run_rust_command(&["stop"]); +} + +fn wait_for_daemon_stop() { + let started_at = Instant::now(); + while started_at.elapsed() < Duration::from_secs(1) { + if !run_rust_command(&["status"]).status.success() { + return; + } + thread::sleep(Duration::from_millis(20)); + } + panic!("clipboard daemon did not stop"); +} + +fn require_clipboard_prerequisites() -> bool { + if !command_available("powershell.exe") { + eprintln!( + "skipping Windows clipboard integration test: powershell.exe is unavailable" + ); + return false; + } + true +} + +fn assert_clipboard_case(input: &str) { + let expected = input.as_bytes(); + let legacy = legacy_clipboard(); + let rust = rust_clipboard(); + + copy(&legacy, expected); + let legacy_output = paste(&legacy); + assert_eq!( + legacy_output, expected, + "legacy output differed from expectation" + ); + + copy(&rust, expected); + let rust_output = paste(&rust); + assert_eq!( + rust_output, expected, + "Rust output differed from expectation" + ); + + assert_eq!( + rust_output, legacy_output, + "Rust output differed from legacy" + ); +} + +/// The Windows clipboard is global to the interactive desktop, so all +/// clipboard-mutating scenarios live in one test instead of racing in Rust's +/// default parallel test runner. +#[test] +fn preserves_legacy_cases_and_unicode() { + if !require_clipboard_prerequisites() { + return; + } + + let cases = [ + "one line output\n", + "line0\nline1\n\n\n", + "line0\nline1", + "sanity check", + "HJK 日本語", + "この職場は、経験よりも腕を優先する考え方だ。\n職場 (しょくば)\n", + "’—“” → ← ↔ ✓", + "ΓÇÖ ╬ô├ç├û ΓÇô ΓÇ£ ΓÇ¥", + "e\u{301} café 東京語 𐐷", + "😀 👍 ❤️ 👩‍👩‍👧‍👧 🏳️‍🌈 🇺🇸 🐈", + ]; + + stop_daemon(); + for input in cases { + assert_clipboard_case(input); + } + + let expected = "line one\n日本語\n\n"; + let rust = rust_clipboard(); + copy(&rust, expected.as_bytes()); + assert_eq!(paste(&rust), expected.as_bytes()); + + stop_daemon(); + wait_for_daemon_stop(); + assert_eq!(paste(&rust), expected.as_bytes()); + stop_daemon(); +} + +#[test] +fn utf16le_to_utf8_preserves_unicode_scalars() { + let expected = "😀 👩‍👩‍👧‍👧 𐐷 ’—→ 日本語"; + let utf16: Vec = expected.encode_utf16().collect(); + assert_eq!(String::from_utf16(&utf16).unwrap(), expected); +} + +#[test] +fn copy_rejects_invalid_utf8_before_starting_daemon() { + stop_daemon(); + let output = run_command(binary(), &["copy"], Some(&[0xff])); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("expects UTF-8")); +} diff --git a/codex/config.test.ts b/codex/config.test.ts index bde89be..162074a 100644 --- a/codex/config.test.ts +++ b/codex/config.test.ts @@ -48,6 +48,7 @@ describe("codex config", () => { expect(merged).toMatchObject(dotfileConfig) expect(merged.tui).toEqual({ vim_mode_default: true, + raw_output_mode: false, model_availability_nux: { "gpt-5.6-sol": 4 }, }) expect(merged.projects).toEqual({ @@ -99,6 +100,9 @@ describe("codex config", () => { await applyConfig({ runtimePath, mcpSourcePath, quiet: true }), ).toBe(true) const firstText = await Bun.file(runtimePath).text() + expect(firstText).toStartWith( + "#:schema https://developers.openai.com/codex/config-schema.json\n", + ) expect(parse(firstText) as TomlTable).toEqual(dotfileConfig) expect( await applyConfig({ runtimePath, mcpSourcePath, quiet: true }), @@ -134,6 +138,7 @@ describe("codex config", () => { }) expect(config.tui).toEqual({ vim_mode_default: true, + raw_output_mode: false, model_availability_nux: { "gpt-5.6-sol": 4 }, }) } finally { @@ -214,7 +219,7 @@ describe("codex config", () => { } }) - test("does not rewrite equivalent TOML comments or formatting", async () => { + test("adds the schema directive and preserves equivalent TOML comments", async () => { const root = await mkdtemp(join(tmpdir(), "codex-config-comment-test-")) try { @@ -231,14 +236,22 @@ describe("codex config", () => { "", "[tui]", "vim_mode_default = true", + "raw_output_mode = false", ].join("\n")}\n` await mkdir(join(root, ".codex"), { recursive: true }) await writeFile(runtimePath, text) + expect( + await applyConfig({ runtimePath, mcpSourcePath, quiet: true }), + ).toBe(true) + const textWithSchema = + "#:schema https://developers.openai.com/codex/config-schema.json\n" + + text + expect(await Bun.file(runtimePath).text()).toBe(textWithSchema) expect( await applyConfig({ runtimePath, mcpSourcePath, quiet: true }), ).toBe(false) - expect(await Bun.file(runtimePath).text()).toBe(text) + expect(await Bun.file(runtimePath).text()).toBe(textWithSchema) } finally { await rm(root, { recursive: true, force: true }) } diff --git a/codex/config.ts b/codex/config.ts index 914b510..3ffbe14 100644 --- a/codex/config.ts +++ b/codex/config.ts @@ -21,6 +21,9 @@ export const dotfileConfig = { sandbox_mode: "danger-full-access", tui: { vim_mode_default: true, + // Keep rendered output enabled by default. Toggle raw scrollback during a + // session with `/raw` or Alt-R when terminal-native selection is needed. + raw_output_mode: false, }, } satisfies TomlTable @@ -163,7 +166,11 @@ export const mergeRuntimeConfig = ( } } -const serialize = (config: TomlTable): string => `${stringify(config)}\n` +const schemaDirective = + "#:schema https://developers.openai.com/codex/config-schema.json" + +const serialize = (config: TomlTable): string => + `${schemaDirective}\n${stringify(config)}\n` const readRuntimeConfig = async ( path: string, @@ -254,8 +261,13 @@ export const applyConfig = async ({ ) const currentText = serialize(runtimeConfig) const afterText = serialize(nextConfig) + const hasSchemaDirective = beforeText.startsWith(`${schemaDirective}\n`) + const nextText = + currentText === afterText && !hasSchemaDirective + ? `${schemaDirective}\n${beforeText}` + : afterText - if (currentText === afterText) { + if (currentText === afterText && hasSchemaDirective) { if (!quiet) { console.log(`Codex runtime config is already current: ${runtimePath}`) } @@ -265,13 +277,13 @@ export const applyConfig = async ({ if (!quiet) { console.log(`Codex runtime config differs: ${runtimePath}`) - process.stdout.write(unifiedDiff(beforeText, afterText)) + process.stdout.write(unifiedDiff(beforeText, nextText)) } if (!dryRun) { await mkdir(dirname(runtimePath), { recursive: true }) const tmpPath = `${runtimePath}.${process.pid}.tmp` - await writeFile(tmpPath, afterText, { mode: 0o600 }) + await writeFile(tmpPath, nextText, { mode: 0o600 }) await rename(tmpPath, runtimePath) } diff --git a/herdr-tmux/README.md b/herdr-tmux/README.md index ddc0f65..460ae60 100644 --- a/herdr-tmux/README.md +++ b/herdr-tmux/README.md @@ -25,20 +25,28 @@ Run these commands from a Herdr custom key binding or a Herdr-managed pane: ```bash herdr-tmux layout even-vertical herdr-tmux layout even-horizontal -herdr-tmux picker +herdr-tmux focus-pane +herdr-tmux focus-agent ``` `even-vertical` stacks panes top-to-bottom. `even-horizontal` spreads panes left-to-right. Both commands unzoom the active tab, preserve its focused pane, and restore the original layout if a recoverable mutation fails. -Command `herdr-tmux picker` lists panes in the active tab from top to bottom, +Command `herdr-tmux focus-pane` lists panes in the active tab from top to bottom, then left to right. Press one digit without Enter to focus that pane. The -picker supports up to ten panes and cancels on Escape, invalid input, or a +command supports up to ten panes and cancels on Escape, invalid input, or a 1.5-second timeout. A one-pane tab and selecting the active pane are successful -no-ops. Before changing focus, the picker verifies that the selected pane still +no-ops. Before changing focus, the command verifies that the selected pane still belongs to the originating tab. +Command `herdr-tmux focus-agent` lists every live agent in the session. +Each row shows its detected kind, lifecycle status, workspace and tab IDs, and +terminal title. Press `0`–`9` without Enter to focus an agent, even when it is +in another workspace or tab. It shares the pane focus command's cancellation and +timeout behavior, and confirms the selected agent still occupies its pane +before focus. + For development or tests outside a Herdr-managed pane, pass the command-line overrides `--socket-path`, `--tab-id`, and `--pane-id`. @@ -46,8 +54,9 @@ overrides `--socket-path`, `--tab-id`, and `--pane-id`. The source repository's [Herdr configuration](https://github.com/Unique-Divine/dotfiles/tree/main/herdr) binds `prefix =` to `even-vertical`, `prefix _` to `even-horizontal`, and -`prefix q` to the popup pane picker. The picker is a local, early feature and -is not packaged as a Herdr plugin. +`prefix q` to `focus-pane`. It binds `prefix a` to the session-wide +`focus-agent` command. Both are local, early features and are not packaged as +Herdr plugins. ## License diff --git a/herdr-tmux/src/app.rs b/herdr-tmux/src/app.rs index 60bf62d..2a91819 100644 --- a/herdr-tmux/src/app.rs +++ b/herdr-tmux/src/app.rs @@ -17,8 +17,10 @@ use serde_json::{json, Value}; use sha2::{Digest, Sha256}; const TIMEOUT: Duration = Duration::from_secs(5); -const PICKER_TIMEOUT: Duration = Duration::from_millis(1_500); +/// Default time a pane or agent picker waits for a selection before cancelling. +const PICKER_TIMEOUT: Duration = Duration::from_secs(5); const MAX_PICKER_PANES: usize = 10; +const MAX_PICKER_AGENTS: usize = 10; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Direction { @@ -187,6 +189,37 @@ struct PaneInfo { #[serde(default)] cwd: Option, } +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +struct AgentInfo { + terminal_id: String, + #[serde(default)] + name: Option, + #[serde(default)] + agent: Option, + agent_status: String, + workspace_id: String, + tab_id: String, + pane_id: String, + focused: bool, + #[serde(default)] + terminal_title: Option, + #[serde(default)] + terminal_title_stripped: Option, + #[serde(default)] + display_agent: Option, + #[serde(default)] + cwd: Option, + #[serde(default)] + foreground_cwd: Option, +} +#[derive(Debug, Deserialize)] +struct AgentListResponse { + agents: Vec, +} +#[derive(Debug, Deserialize)] +struct AgentGetResponse { + agent: AgentInfo, +} #[derive(Debug, Deserialize)] struct PaneLayoutResponse { layout: PaneLayout, @@ -372,6 +405,24 @@ impl Client { )? .pane) } + fn agent_list(&self) -> Result, HerdrError> { + Ok(self + .request::("agent.list", json!({}))? + .agents) + } + fn agent_get(&self, target: &str) -> Result { + Ok(self + .request::("agent.get", json!({"target": target}))? + .agent) + } + fn agent_focus(&self, target: &str) -> Result { + Ok(self + .request::( + "agent.focus", + json!({"target": target}), + )? + .agent) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -507,6 +558,35 @@ fn pane_description(pane: &PaneInfo) -> String { } } +fn agent_description(agent: &AgentInfo) -> String { + let description = nonempty(&agent.terminal_title_stripped) + .or_else(|| nonempty(&agent.terminal_title)) + .or_else(|| nonempty(&agent.name)) + .or_else(|| nonempty(&agent.display_agent)) + .or_else(|| nonempty(&agent.agent)) + .map(str::to_owned) + .or_else(|| { + nonempty(&agent.foreground_cwd) + .or_else(|| nonempty(&agent.cwd)) + .and_then(|cwd| Path::new(cwd).file_name()) + .map(|name| name.to_string_lossy().into_owned()) + }) + .unwrap_or_else(|| agent.pane_id.clone()); + let mut chars = description.trim().chars().map(|character| { + if character.is_control() { + ' ' + } else { + character + } + }); + let prefix = chars.by_ref().take(36).collect::(); + if chars.next().is_some() { + format!("{prefix}…") + } else { + prefix + } +} + fn write_picker( writer: &mut W, panes: &[(PaneLayoutPane, PaneInfo)], @@ -529,6 +609,85 @@ pub(crate) fn pick_pane(target: &Target) -> Result<(), HerdrError> { pick_pane_with(target, &mut io::stdout().lock(), read_picker_choice) } +fn write_agent_selector( + writer: &mut W, + agents: &[AgentInfo], +) -> Result<(), HerdrError> { + writeln!(writer, "Select agent (0-9, Esc cancels)\n")?; + for (index, agent) in agents.iter().enumerate() { + let marker = if agent.focused { "*" } else { " " }; + let kind = agent.agent.as_deref().unwrap_or("unknown"); + writeln!( + writer, + " {index} {marker} {kind:<8} {:<8} {} / {} {}", + agent.agent_status, + agent.workspace_id, + agent.tab_id, + agent_description(agent), + )?; + } + writer.flush()?; + Ok(()) +} + +pub(crate) fn pick_agent(target: &Target) -> Result<(), HerdrError> { + pick_agent_with(target, &mut io::stdout().lock(), read_picker_choice) +} + +fn pick_agent_with( + target: &Target, + writer: &mut W, + mut choose: C, +) -> Result<(), HerdrError> +where + W: Write, + C: FnMut(usize) -> Result, +{ + let client = Client::new(target.socket_path.clone()); + let agents = client.agent_list()?; + if agents.len() > MAX_PICKER_AGENTS { + return Err(HerdrError::Protocol(format!( + "agent selector supports at most {MAX_PICKER_AGENTS} agents; \ + this session has {}", + agents.len() + ))); + } + if agents.is_empty() { + return Ok(()); + } + + write_agent_selector(writer, &agents)?; + let PickerChoice::Select(index) = choose(agents.len())? else { + return Ok(()); + }; + let selected = &agents[index]; + let current = client.agent_get(&selected.pane_id)?; + if current.pane_id != selected.pane_id + || current.terminal_id != selected.terminal_id + || current.agent.is_none() + { + return Err(HerdrError::Protocol(format!( + "selected agent in pane {} changed before focus", + selected.pane_id + ))); + } + if current.focused { + return Ok(()); + } + + let focused = client.agent_focus(&selected.pane_id)?; + if focused.pane_id != selected.pane_id + || focused.terminal_id != selected.terminal_id + || !focused.focused + { + return Err(HerdrError::Protocol(format!( + "Herdr did not focus agent pane {}", + selected.pane_id + ))); + } + Ok(()) +} + fn pick_pane_with( target: &Target, writer: &mut W, @@ -887,6 +1046,26 @@ mod tests { }}) } + fn selector_agent( + pane_id: &str, + terminal_id: &str, + focused: bool, + title: &str, + ) -> Value { + json!({ + "terminal_id": terminal_id, + "agent": "cursor", + "agent_status": "working", + "workspace_id": "w1", + "tab_id": "w1:t2", + "pane_id": pane_id, + "focused": focused, + "terminal_title_stripped": title, + "foreground_cwd": "/work/foreground", + "cwd": "/work/inherited" + }) + } + #[test] fn even_tree_is_balanced_and_ordered() { let ids = ["p1", "p2", "p3", "p4"].map(String::from); @@ -1035,6 +1214,115 @@ mod tests { ); } + #[test] + fn agent_description_prefers_terminal_title_and_sanitizes_controls() { + let mut agent: AgentInfo = serde_json::from_value(selector_agent( + "w1:p2", + "term-2", + false, + "review\nthis change", + )) + .unwrap(); + assert_eq!(agent_description(&agent), "review this change"); + agent.terminal_title_stripped = None; + agent.terminal_title = None; + agent.name = None; + agent.display_agent = None; + agent.agent = None; + assert_eq!(agent_description(&agent), "foreground"); + } + + #[test] + fn agent_selector_focuses_selected_agent_from_another_tab() { + let first = selector_agent("w1:p1", "term-1", true, "current task"); + let second = selector_agent("w1:p2", "term-2", false, "review task"); + let mut focused_second = second.clone(); + focused_second["focused"] = json!(true); + let results = vec![ + json!({"agents": [first, second.clone()]}), + json!({"agent": second.clone()}), + json!({"agent": focused_second}), + ]; + let (_directory, target, receiver) = scripted_server(results, "w1:p1"); + let mut output = Vec::new(); + pick_agent_with(&target, &mut output, |_| Ok(PickerChoice::Select(1))) + .unwrap(); + + let requests = receiver.iter().collect::>(); + assert_eq!( + requests + .iter() + .map(|request| request["method"].as_str().unwrap()) + .collect::>(), + ["agent.list", "agent.get", "agent.focus"] + ); + assert_eq!( + requests.last().unwrap()["params"], + json!({"target": "w1:p2"}) + ); + let output = String::from_utf8(output).unwrap(); + assert!( + output.contains("0 * cursor working w1 / w1:t2 current task") + ); + assert!(output.contains("1 cursor working w1 / w1:t2 review task")); + } + + #[test] + fn agent_selector_rejects_stale_agent_before_focus() { + let first = selector_agent("w1:p1", "term-1", true, "current task"); + let second = selector_agent("w1:p2", "term-2", false, "review task"); + let stale = + selector_agent("w1:p2", "replacement-term", false, "replacement"); + let results = + vec![json!({"agents": [first, second]}), json!({"agent": stale})]; + let (_directory, target, receiver) = scripted_server(results, "w1:p1"); + assert!(pick_agent_with(&target, &mut Vec::new(), |_| { + Ok(PickerChoice::Select(1)) + }) + .is_err()); + assert!(!receiver + .iter() + .any(|request| request["method"] == "agent.focus")); + } + + #[test] + fn agent_selector_rejects_more_than_ten_agents_before_input() { + let agents = (0..11) + .map(|index| { + selector_agent( + &format!("w1:p{index}"), + &format!("term-{index}"), + index == 0, + "task", + ) + }) + .collect::>(); + let (_directory, target, receiver) = + scripted_server(vec![json!({"agents": agents})], "w1:p0"); + let mut input_called = false; + let error = pick_agent_with(&target, &mut Vec::new(), |_| { + input_called = true; + Ok(PickerChoice::Cancel) + }) + .unwrap_err(); + assert!(error.to_string().contains("at most 10 agents")); + assert!(!input_called); + assert_eq!(receiver.iter().count(), 1); + } + + #[test] + fn agent_selector_cancellation_does_not_focus_an_agent() { + let results = vec![json!({"agents": [selector_agent( + "w1:p1", "term-1", true, "current task" + ), selector_agent("w1:p2", "term-2", false, "review task") ]})]; + let (_directory, target, receiver) = scripted_server(results, "w1:p1"); + pick_agent_with(&target, &mut Vec::new(), |_| Ok(PickerChoice::Cancel)) + .unwrap(); + assert!(!receiver + .iter() + .any(|request| request["method"] == "agent.focus")); + } + #[derive(Default)] struct MockRawMode { enabled: usize, diff --git a/herdr-tmux/src/main.rs b/herdr-tmux/src/main.rs index 6e6d38a..cea2d11 100644 --- a/herdr-tmux/src/main.rs +++ b/herdr-tmux/src/main.rs @@ -4,8 +4,8 @@ use std::path::PathBuf; use std::process::ExitCode; use app::{ - arrange, notify_failure, pick_pane, resolve_target, CliTarget, Direction, - HerdrError, + arrange, notify_failure, pick_agent, pick_pane, resolve_target, CliTarget, + Direction, HerdrError, }; use clap::{Parser, Subcommand}; @@ -31,8 +31,10 @@ enum Command { #[command(subcommand)] layout: Layout, }, - /// Select a pane in the active tab by number. - Picker, + /// Focus a pane in the active tab by number. + FocusPane, + /// Focus a live agent anywhere in the session by number. + FocusAgent, } #[derive(Debug, Subcommand)] @@ -62,7 +64,8 @@ fn main() -> ExitCode { }; let result = match cli.command { Command::Layout { layout } => arrange(&target, layout.into()), - Command::Picker => pick_pane(&target), + Command::FocusPane => pick_pane(&target), + Command::FocusAgent => pick_agent(&target), }; match result { Ok(()) => ExitCode::SUCCESS, diff --git a/herdr/README.md b/herdr/README.md index b4b5718..6409f59 100644 --- a/herdr/README.md +++ b/herdr/README.md @@ -14,9 +14,10 @@ herdr server reload-config ``` The configuration key `keys.detach` uses `prefix+d`, matching tmux. Binding -`prefix+q` opens the local numeric pane picker. +`prefix+q` runs `herdr-tmux focus-pane`, and `prefix+a` runs +`herdr-tmux focus-agent`. -## Pane layouts and picker +## Pane layouts and focus commands The tmux layout bindings are available in Herdr too: @@ -24,11 +25,16 @@ The tmux layout bindings are available in Herdr too: - `prefix _` unzooms the active tab and spreads its panes evenly left-to-right. - `prefix q` opens a popup that lists the active tab's panes in geometric reading order. Press `0`–`9` without Enter to focus a pane. +- `prefix a` opens a popup that lists all live agents across the session. + Each row shows the agent kind and state, workspace and tab IDs, and terminal + title. Press `0`–`9` without Enter to focus an agent. -The layout commands preserve existing pane processes and scrollback. The -picker supports up to ten panes, marks the active pane, cancels after 1.5 +The layout commands preserve existing pane processes and scrollback. The pane +focus command supports up to ten panes, marks the active pane, cancels after 1.5 seconds or on invalid input, and validates the selected pane before focusing -it. Install the `herdr-tmux` command from its sibling source directory: +it. The agent focus command supports the same number of rows and cancellation +behavior, and validates the selected agent before focusing it. Install the +`herdr-tmux` command from its sibling source directory: ```bash cd "$DOTFILES/herdr-tmux" diff --git a/herdr/config.toml b/herdr/config.toml index c7ea19a..dab69f7 100644 --- a/herdr/config.toml +++ b/herdr/config.toml @@ -1,3 +1,4 @@ +onboarding = false # Managed baseline generated by `herdr --default-config` from Herdr 0.8.0. # Keep options commented until intentionally overriding a default; preserve # explicit upstream defaults such as `pane_history = false`. @@ -30,6 +31,8 @@ # red = "#ff6188" # green = "#a6e3a1" +name = "nord" +auto_switch = false [terminal] # Executable used for new interactive panes. # Empty means $SHELL, then /bin/sh. @@ -151,11 +154,19 @@ description = "even horizontal pane layout" [[keys.command]] key = "prefix+q" type = "popup" -command = "$HOME/.local/bin/herdr-tmux picker" +command = "$HOME/.local/bin/herdr-tmux focus-pane" width = 60 height = 14 description = "select pane by number" +[[keys.command]] +key = "prefix+a" +type = "popup" +command = "$HOME/.local/bin/herdr-tmux focus-agent" +width = 72 +height = 14 +description = "select agent by number" + # Legacy indexed shortcut config is still parsed for compatibility. # Prefer switch_tab, switch_workspace, and focus_agent for new configs. # [keys.indexed] @@ -230,8 +241,9 @@ sidebar_start_collapsed = false # Set false to reclaim the scrollbar column and keep it out of terminal-native selections. # pane_scrollbars = true -# Keep split panes visually separated instead of sharing divider borders. -# pane_gaps = true +# When true, Herdr keeps a visual gap between split panes instead of +# sharing a single divider border. +pane_gaps = false # Show detected/reported agent labels in split pane borders when no manual pane name is set. # show_agent_labels_on_pane_borders = false @@ -273,6 +285,7 @@ sidebar_start_collapsed = false # accent = "cyan" # Background notification popup delivery +show_agent_labels_on_pane_borders = true [ui.toast] # off = disable pop-up notifications # herdr = show in-app toasts diff --git a/justfile b/justfile index 0740081..34b3178 100644 --- a/justfile +++ b/justfile @@ -8,16 +8,49 @@ setup: just -l test: + cargo test --workspace bun test alias t := test +# Benchmark WSL clipboard copy, paste, backends, and round-trip latency. +clipboard-bench *ARGS: + bun run zsh/clipboard.bench.ts {{ARGS}} + +# Build the release WSL clipboard bridge without installing it. +clipboard-build: + cargo build --release --package wsl-clipboard + +# Install the release WSL clipboard bridge at ~/.local/bin/wsl-clipboard. +clipboard-install: + #!/usr/bin/env bash + set -Eeuo pipefail + cargo install --path clipboard --locked --root "$HOME/.local" + for command_name in pbcopy pbpaste wsl-pbcopy wsl-pbpaste; do + ln -sfn wsl-clipboard "$HOME/.local/bin/$command_name" + done + +# Run the WSL clipboard bridge from the source workspace. +clipboard *ARGS: + cargo run --package wsl-clipboard -- {{ARGS}} + +# Benchmark the compiled bridge beside the explicitly named legacy commands. +clipboard-rust-bench *ARGS: + #!/usr/bin/env bash + set -Eeuo pipefail + cargo build --package wsl-clipboard + WSL_CLIPBOARD_BIN="$PWD/target/debug/wsl-clipboard" \ + bun run zsh/clipboard.bench.ts {{ARGS}} + # Apply shell bootstrap, portable Codex config, and managed AI skills. sync: #!/usr/bin/env bash set -Eeuo pipefail source zsh/bashlib.sh main_bash_setup + if is_wsl >/dev/null; then + just clipboard-install + fi bun run codex/config.ts --run bun run skillsSync.ts --run @@ -63,6 +96,11 @@ health: failed=1 fi + if is_wsl >/dev/null && [[ ! -x "$HOME/.local/bin/wsl-clipboard" ]]; then + log_error "wsl-clipboard is not installed; run: just clipboard-install" + failed=1 + fi + if [[ -z "${REPO:-}" ]]; then log_error "REPO is not set; run just sync first or source zsh/zshenv" failed=1 diff --git a/nvim/lua/core/lsp.lua b/nvim/lua/core/lsp.lua index 10f4e38..160d4c8 100644 --- a/nvim/lua/core/lsp.lua +++ b/nvim/lua/core/lsp.lua @@ -148,7 +148,7 @@ local mdtoc_flags = '--bullets="-" --maxdepth=3 --no-firsth1' vim.api.nvim_create_user_command('TocCopy', function() -- The "%" means the current file when you run this vim.cmd. This CLI tool -- takes exactly one argument and is configured with flags. - vim.cmd('!bun run ' .. mdtoc_cli .. ' % ' .. mdtoc_flags .. ' | clip.exe') + vim.cmd('!bun run ' .. mdtoc_cli .. ' % ' .. mdtoc_flags .. ' | pbcopy') print('markdown-toc: yanked TOC to clipboard') end, { desc = "Generate markdown TOC and copy to clipboard", diff --git a/nvim/lua/core/vim.lua b/nvim/lua/core/vim.lua index 2153511..fa78978 100644 --- a/nvim/lua/core/vim.lua +++ b/nvim/lua/core/vim.lua @@ -30,14 +30,14 @@ vim.o.mouse = 'a' -- See `:help 'clipboard'` vim.o.clipboard = 'unnamed,unnamedplus' vim.g.clipboard = { - name = "WSL (MacOS-like)", + name = "WSL persistent clipboard", copy = { ["+"] = "pbcopy", ["*"] = "pbcopy", }, paste = { ["+"] = "pbpaste", - ["*"] = "pbcopy", + ["*"] = "pbpaste", }, } @@ -53,11 +53,15 @@ vim.api.nvim_create_user_command('WY', function(opts) -- Get the yanked text from the '+' register. local text = vim.fn.getreg('+') - -- Convert the text from UTF-8 to UTF-16LE and pipe it to pbcopy. - vim.fn.system('iconv -f UTF-8 -t UTF-16LE | pbcopy', text) + -- pbcopy sends UTF-8 text; the bridge handles Windows' UTF-16 clipboard. + vim.fn.system('pbcopy', text) - print("Yanked text copied to Windows clipboard (UTF-16LE).") -end, { range = true, desc = "[W]indows [Y]ank, changing encoding from UTF8 to UTF-16LE on copy" }) + print("Yanked text copied to the Windows clipboard.") +end, { + desc = "[W]indows [Y]ank through the persistent clipboard bridge", + force = true, + range = true, +}) vim.api.nvim_create_user_command("Wfix", function() vim.cmd("write") @@ -68,7 +72,10 @@ vim.api.nvim_create_user_command("Wfix", function() end vim.system({ "winfixtext", file }) print(("Ran: winfixtext %s"):format(file)) -end, { desc = "Write and run `winfixtext` on the current file" }) +end, { + desc = "Write and run `winfixtext` on the current file", + force = true, +}) -- Enable break indent @@ -98,6 +105,14 @@ vim.o.termguicolors = true -- See `:help vim.keymap.set()` vim.keymap.set({ 'n', 'v' }, '', '', { silent = true }) +-- Keep available to terminal applications; double Escape enters +-- terminal-normal mode, where Neovim receives navigation and command keys. +vim.keymap.set('t', '', [[]]) + +-- Re-read the current file when it changed outside Neovim, if it is unmodified. +-- This also lets the LSP observe the refreshed buffer contents. +vim.keymap.set('n', 'r', 'checktime', + { desc = '[R]efresh file from disk' }) -- [[ Highlight on yank ]] -- See `:help vim.highlight.on_yank()` @@ -137,6 +152,9 @@ vim.keymap.set("n", "k", "k", { noremap = true }) local textwidth = 81 vim.opt.colorcolumn = tostring(textwidth + 1) vim.opt.textwidth = textwidth +-- Clear previous FileType handlers before recreating them when this file reloads. +local core_filetype_group = + vim.api.nvim_create_augroup("CoreVimFileTypes", { clear = true }) -- [2024-08-14]: I observed that the textwidth setting was being respected but -- wasn't set to the proper global value in Rust files. -- Using `:set textwidth?` allowed me to inspect the value in different buffers. @@ -145,6 +163,7 @@ vim.opt.textwidth = textwidth -- This "autocmd" is a workaround that overrides the vim.opt in a local scope -- (vim.opt_local), forcing Rust files to have the proper textwidth setting.. vim.api.nvim_create_autocmd("FileType", { + group = core_filetype_group, pattern = "rust", callback = function() vim.opt_local.textwidth = textwidth @@ -177,6 +196,7 @@ vim.api.nvim_create_autocmd("FileType", { -- autocmd FileType netrw silent! nmap -- ]]) vim.api.nvim_create_autocmd("FileType", { + group = core_filetype_group, pattern = "netrw", callback = function(ev) vim.keymap.set("n", "", "", { buffer = ev.buf, silent = true }) @@ -305,6 +325,6 @@ vim.api.nvim_create_user_command("PrintWinbar", function() use_winbar = true, }).str, })) -end, {}) +end, { force = true }) return {} diff --git a/zsh/aliases.sh b/zsh/aliases.sh index 9a7250a..15b61de 100644 --- a/zsh/aliases.sh +++ b/zsh/aliases.sh @@ -43,3 +43,8 @@ git config --global core.editor "nvim" alias ft="focustime" # 2026-03-04 alias npx="bunx" + +# Force tuicr to use OSC 52 instead of WSLg's unreliable clipboard path. +tuicr() { + SSH_TTY="${SSH_TTY:-/dev/tty}" command tuicr "$@" +} diff --git a/zsh/clipboard.bench.test.ts b/zsh/clipboard.bench.test.ts new file mode 100644 index 0000000..334bf6e --- /dev/null +++ b/zsh/clipboard.bench.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, test } from "bun:test" + +import { + buildPersistentPowerShellScript, + decodeClipboardResponse, + parseOptions, + summarizeTimings, +} from "./clipboard.bench" + +describe("parseOptions", () => { + test("uses benchmark defaults", () => { + expect(parseOptions([])).toEqual({ + iterations: 10, + payloadBytes: 128, + warmups: 2, + }) + }) + + test("parses each numeric option", () => { + expect( + parseOptions([ + "--iters", + "20", + "--warmups", + "3", + "--in-bz", + "4096", + ]), + ).toEqual({ + iterations: 20, + payloadBytes: 4096, + warmups: 3, + }) + }) + + test("rejects invalid options", () => { + expect(() => parseOptions(["--iters", "zero"])).toThrow( + "--iters must be a positive integer", + ) + expect(() => parseOptions(["--unknown", "1"])).toThrow( + "Unknown option: --unknown", + ) + }) +}) + +describe("summarizeTimings", () => { + test("calculates stable timing statistics", () => { + expect(summarizeTimings("sample", [5, 1, 4, 2, 3])).toEqual({ + label: "sample", + iterations: 5, + minMs: 1, + medianMs: 3, + meanMs: 3, + p95Ms: 5, + maxMs: 5, + }) + }) + + test("averages the middle values for an even sample count", () => { + expect(summarizeTimings("sample", [4, 1, 3, 2]).medianMs).toBe(2.5) + }) + + test("rejects an empty sample set", () => { + expect(() => summarizeTimings("sample", [])).toThrow( + "Cannot summarize an empty sample set", + ) + }) +}) + +describe("persistent PowerShell protocol", () => { + test("decodes multiline Unicode clipboard text", () => { + const text = "line one\n日本語\n" + const encoded = Buffer.from(text, "utf8").toString("base64") + expect(decodeClipboardResponse(encoded)).toBe(text) + }) + + test("uses a constrained paste protocol with explicit flushing", () => { + const script = buildPersistentPowerShellScript() + expect(script).toContain(`$line -eq 'PASTE'`) + expect(script).toContain("Get-Clipboard -Raw") + expect(script).toContain("ToBase64String") + expect(script).toContain("[Console]::Out.Flush()") + expect(script).not.toContain("Invoke-Expression") + }) +}) diff --git a/zsh/clipboard.bench.ts b/zsh/clipboard.bench.ts new file mode 100644 index 0000000..48377cb --- /dev/null +++ b/zsh/clipboard.bench.ts @@ -0,0 +1,516 @@ +interface BenchmarkOptions { + iterations: number + payloadBytes: number + warmups: number +} + +export interface TimingSummary { + label: string + iterations: number + minMs: number + medianMs: number + meanMs: number + p95Ms: number + maxMs: number +} + +interface BenchmarkCase { + label: string + operation: () => Promise +} + +interface PersistentBenchmark { + firstRequestMs: number + startupMs: number + summary: TimingSummary +} + +const DEFAULT_OPTIONS: BenchmarkOptions = { + iterations: 10, + payloadBytes: 128, + warmups: 2, +} + +const parsePositiveInteger = (value: string, flag: string): number => { + const parsed = Number.parseInt(value, 10) + if (!Number.isInteger(parsed) || parsed <= 0) { + throw new Error(`${flag} must be a positive integer`) + } + return parsed +} + +export const parseOptions = (args: string[]): BenchmarkOptions => { + const options = { ...DEFAULT_OPTIONS } + + for (let index = 0; index < args.length; index += 1) { + const flag = args[index] + const value = args[index + 1] + if (value === undefined) { + throw new Error(`Missing value for ${flag}`) + } + + if (flag === "--iters") { + options.iterations = parsePositiveInteger(value, flag) + } else if (flag === "--warmups") { + options.warmups = parsePositiveInteger(value, flag) + } else if (flag === "--in-bz") { + options.payloadBytes = parsePositiveInteger(value, flag) + } else { + throw new Error(`Unknown option: ${flag}`) + } + index += 1 + } + + return options +} + +const percentile = (sorted: number[], ratio: number): number => { + const index = Math.ceil(sorted.length * ratio) - 1 + return sorted[Math.max(0, index)] +} + +export const summarizeTimings = ( + label: string, + samples: number[], +): TimingSummary => { + if (samples.length === 0) { + throw new Error("Cannot summarize an empty sample set") + } + + const sorted = [...samples].sort((left, right) => left - right) + const total = sorted.reduce((sum, sample) => sum + sample, 0) + const middle = Math.floor(sorted.length / 2) + const median = + sorted.length % 2 === 0 + ? (sorted[middle - 1] + sorted[middle]) / 2 + : sorted[middle] + + return { + label, + iterations: sorted.length, + minMs: sorted[0], + medianMs: median, + meanMs: total / sorted.length, + p95Ms: percentile(sorted, 0.95), + maxMs: sorted[sorted.length - 1], + } +} + +export const decodeClipboardResponse = (encoded: string): string => + Buffer.from(encoded, "base64").toString("utf8") + +export const buildPersistentPowerShellScript = (): string => + [ + `$ErrorActionPreference = 'Stop'`, + `[Console]::Out.WriteLine('READY')`, + `[Console]::Out.Flush()`, + `while (($line = [Console]::In.ReadLine()) -ne $null) {`, + `if ($line -eq 'PASTE') {`, + `$text = Get-Clipboard -Raw`, + `if ($null -eq $text) { $text = '' }`, + `$bytes = [Text.Encoding]::UTF8.GetBytes([string]$text)`, + `[Console]::Out.WriteLine([Convert]::ToBase64String($bytes))`, + `[Console]::Out.Flush()`, + `} elseif ($line -eq 'QUIT') { break }`, + `}`, + ].join("; ") + +class LineReader { + private buffer = "" + private readonly decoder = new TextDecoder() + private readonly reader: ReadableStreamDefaultReader + + constructor(stream: ReadableStream) { + this.reader = stream.getReader() + } + + async readLine(): Promise { + while (!this.buffer.includes("\n")) { + const { done, value } = await this.reader.read() + if (done) { + throw new Error( + "Persistent PowerShell closed before returning a complete line", + ) + } + this.buffer += this.decoder.decode(value, { stream: true }) + } + + const newline = this.buffer.indexOf("\n") + const line = this.buffer.slice(0, newline).replace(/\r$/, "") + this.buffer = this.buffer.slice(newline + 1) + return line + } +} + +const runCommand = async ( + command: string[], + input?: string, +): Promise => { + const process = Bun.spawn(command, { + stdin: input === undefined ? "ignore" : new Blob([input]), + stdout: "pipe", + stderr: "pipe", + }) + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(process.stdout).text(), + new Response(process.stderr).text(), + process.exited, + ]) + + if (exitCode !== 0) { + throw new Error( + `${command[0]} exited with ${exitCode}: ${stderr.trim()}`, + ) + } + return stdout +} + +const powershellArgs = (script: string): string[] => [ + "powershell.exe", + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, +] + +const wslClipboardArgs = (operation: "copy" | "paste" | "stop"): string[] => [ + Bun.env.WSL_CLIPBOARD_BIN ?? "wsl-clipboard", + operation, +] + +const measure = async ( + benchmark: BenchmarkCase, + options: BenchmarkOptions, +): Promise => { + for (let index = 0; index < options.warmups; index += 1) { + await benchmark.operation() + } + + const samples: number[] = [] + for (let index = 0; index < options.iterations; index += 1) { + const startedAt = performance.now() + await benchmark.operation() + samples.push(performance.now() - startedAt) + } + return summarizeTimings(benchmark.label, samples) +} + +const measureWarmPowerShell = async ( + options: BenchmarkOptions, +): Promise => { + const script = [ + `for ($i = 0; $i -lt ${options.warmups}; $i++) {`, + `$null = Get-Clipboard -Raw`, + `}`, + `$samples = @()`, + `for ($i = 0; $i -lt ${options.iterations}; $i++) {`, + `$sw = [Diagnostics.Stopwatch]::StartNew()`, + `$null = Get-Clipboard -Raw`, + `$sw.Stop()`, + `$samples += $sw.Elapsed.TotalMilliseconds`, + `}`, + `[Console]::Out.Write(($samples | ConvertTo-Json -Compress))`, + ].join("; ") + const output = await runCommand(powershellArgs(script)) + const parsed: number | number[] = JSON.parse(output) + const samples = Array.isArray(parsed) ? parsed : [parsed] + return summarizeTimings("PowerShell warm cmdlet", samples) +} + +const measurePersistentPowerShell = async ( + payload: string, + options: BenchmarkOptions, +): Promise => { + const startedAt = performance.now() + const process = Bun.spawn( + powershellArgs(buildPersistentPowerShellScript()), + { + stdin: "pipe", + stdout: "pipe", + stderr: "pipe", + }, + ) + const stdin = process.stdin + if (typeof stdin === "number") { + process.kill() + await process.exited + throw new Error("Persistent PowerShell stdin is not writable") + } + const lines = new LineReader(process.stdout) + + try { + const greeting = await lines.readLine() + if (greeting !== "READY") { + throw new Error(`Unexpected persistent PowerShell greeting: ${greeting}`) + } + const startupMs = performance.now() - startedAt + const request = async (): Promise => { + stdin.write("PASTE\n") + stdin.flush() + const actual = decodeClipboardResponse(await lines.readLine()) + if (actual !== payload) { + throw new Error( + "Persistent PowerShell returned different clipboard text", + ) + } + } + + const firstRequestAt = performance.now() + await request() + const firstRequestMs = performance.now() - firstRequestAt + const summary = await measure( + { + label: "PowerShell persistent", + operation: request, + }, + options, + ) + return { + firstRequestMs, + startupMs, + summary, + } + } finally { + if (process.exitCode === null) { + stdin.write("QUIT\n") + } + stdin.end() + const exitCode = await process.exited + if (exitCode !== 0) { + const stderr = await new Response(process.stderr).text() + throw new Error( + `Persistent PowerShell exited with ${exitCode}: ${stderr.trim()}`, + ) + } + } +} + +const formatMs = (value: number): string => + Math.abs(value) < 10 ? value.toFixed(2) : value.toFixed(1) + +const printResults = (results: TimingSummary[]): void => { + console.table( + results.map((result) => ({ + operation: result.label, + runs: result.iterations, + min_ms: formatMs(result.minMs), + median_ms: formatMs(result.medianMs), + mean_ms: formatMs(result.meanMs), + p95_ms: formatMs(result.p95Ms), + max_ms: formatMs(result.maxMs), + })), + ) + + const byLabel = new Map(results.map((result) => [result.label, result])) + const legacyCopy = byLabel.get("legacy-pbcopy") + const clip = byLabel.get("clip.exe direct") + const legacyPaste = byLabel.get("legacy-pbpaste") + const bridgeCopy = byLabel.get("pbcopy (symlink)") + const bridgePaste = byLabel.get("pbpaste (symlink)") + const powershell = byLabel.get("PowerShell direct") + const noProfile = byLabel.get("PowerShell no-profile") + const warm = byLabel.get("PowerShell warm cmdlet") + const persistent = byLabel.get("PowerShell persistent") + const legacyRoundTrip = byLabel.get("legacy-pbcopy + legacy-pbpaste") + const bridgeRoundTrip = byLabel.get("pbcopy + pbpaste (symlink)") + + if ( + legacyCopy && + clip && + legacyPaste && + powershell && + noProfile && + warm && + persistent && + legacyRoundTrip + ) { + const copyWrapper = legacyCopy.medianMs - clip.medianMs + const pasteWrapper = legacyPaste.medianMs - powershell.medianMs + const roundTripExtra = + legacyRoundTrip.medianMs - legacyCopy.medianMs - legacyPaste.medianMs + const coldStartup = noProfile.medianMs - warm.medianMs + const persistentOverhead = persistent.medianMs - warm.medianMs + const persistentSpeedup = noProfile.medianMs / persistent.medianMs + + console.log("\nMedian deltas (approximate; subprocess timings are noisy):") + console.log(` legacy pbcopy shell wrapper: ${formatMs(copyWrapper)} ms`) + console.log(` legacy pbpaste shell pipeline: ${formatMs(pasteWrapper)} ms`) + console.log(` round-trip coordination: ${formatMs(roundTripExtra)} ms`) + console.log(` PowerShell cold startup: ${formatMs(coldStartup)} ms`) + console.log( + ` persistent protocol overhead: ${formatMs(persistentOverhead)} ms`, + ) + console.log(` persistent paste speedup: ${persistentSpeedup.toFixed(1)}x`) + } + + if ( + bridgeCopy && + bridgePaste && + bridgeRoundTrip && + legacyCopy && + legacyPaste && + legacyRoundTrip + ) { + console.log("\nPersistent bridge median speedups:") + console.log( + ` copy: ${(legacyCopy.medianMs / bridgeCopy.medianMs).toFixed(1)}x`, + ) + console.log( + ` paste: ${(legacyPaste.medianMs / bridgePaste.medianMs).toFixed(1)}x`, + ) + console.log( + ` round trip: ${(legacyRoundTrip.medianMs / bridgeRoundTrip.medianMs).toFixed(1)}x`, + ) + } +} + +const main = async (): Promise => { + const options = parseOptions(Bun.argv.slice(2)) + const payload = "x".repeat(options.payloadBytes) + const powershellCommand = [ + "powershell.exe", + "(Get-Clipboard).TrimEnd()", + ] + const useRustBridge = Bun.env.WSL_CLIPBOARD_BIN !== undefined + + await runCommand(["legacy-pbcopy"], payload) + if (useRustBridge) { + await runCommand(wslClipboardArgs("copy"), payload) + } + const benchmarks: BenchmarkCase[] = [ + { + label: "process baseline", + operation: async () => { + await runCommand(["/bin/true"]) + }, + }, + { + label: "clip.exe direct", + operation: async () => { + await runCommand(["clip.exe"], payload) + }, + }, + { + label: "legacy-pbcopy", + operation: async () => { + await runCommand(["legacy-pbcopy"], payload) + }, + }, + { + label: "PowerShell direct", + operation: async () => { + await runCommand(powershellCommand) + }, + }, + { + label: "PowerShell no-profile", + operation: async () => { + await runCommand( + powershellArgs("(Get-Clipboard).TrimEnd()"), + ) + }, + }, + { + label: "legacy-pbpaste", + operation: async () => { + await runCommand(["legacy-pbpaste"]) + }, + }, + { + label: "legacy-pbcopy + legacy-pbpaste", + operation: async () => { + await runCommand(["legacy-pbcopy"], payload) + const pasted = await runCommand(["legacy-pbpaste"]) + if (pasted !== payload) { + throw new Error("Legacy clipboard round trip returned different text") + } + }, + }, + { + label: "pbcopy (symlink)", + operation: async () => { + await runCommand(["pbcopy"], payload) + }, + }, + { + label: "pbpaste (symlink)", + operation: async () => { + await runCommand(["pbpaste"]) + }, + }, + { + label: "pbcopy + pbpaste (symlink)", + operation: async () => { + await runCommand(["pbcopy"], payload) + const pasted = await runCommand(["pbpaste"]) + if (pasted !== payload) { + throw new Error("Persistent clipboard round trip returned different text") + } + }, + }, + ] + if (useRustBridge) { + benchmarks.push( + { + label: "wsl-clipboard copy", + operation: async () => { + await runCommand(wslClipboardArgs("copy"), payload) + }, + }, + { + label: "wsl-clipboard paste", + operation: async () => { + await runCommand(wslClipboardArgs("paste")) + }, + }, + { + label: "wsl-clipboard copy + paste", + operation: async () => { + await runCommand(wslClipboardArgs("copy"), payload) + const pasted = await runCommand(wslClipboardArgs("paste")) + if (pasted !== payload) { + throw new Error("Persistent clipboard round trip returned different text") + } + }, + }, + ) + } + + console.log( + `Clipboard benchmark: ${options.iterations} runs, ` + + `${options.warmups} warmups, ${options.payloadBytes} bytes`, + ) + try { + const results: TimingSummary[] = [] + for (const benchmark of benchmarks) { + results.push(await measure(benchmark, options)) + } + results.push(await measureWarmPowerShell(options)) + const persistent = await measurePersistentPowerShell(payload, options) + results.push(persistent.summary) + console.log( + `Persistent PowerShell startup: ${formatMs(persistent.startupMs)} ms; ` + + `first request: ${formatMs(persistent.firstRequestMs)} ms`, + ) + printResults(results) + } finally { + if (useRustBridge) { + try { + await runCommand(wslClipboardArgs("stop")) + } catch (error) { + console.error(`Could not stop persistent clipboard bridge: ${error}`) + } + } + } +} + +if (import.meta.main) { + try { + await main() + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + console.error(`Clipboard benchmark failed: ${message}`) + process.exitCode = 1 + } +} diff --git a/zsh/clipboard.sh b/zsh/clipboard.sh deleted file mode 100644 index 8b965e7..0000000 --- a/zsh/clipboard.sh +++ /dev/null @@ -1,39 +0,0 @@ -# Clipboard -# -# See: https://github.com/microsoft/WSL/issues/4933#issuecomment-664471199 -# Default clipboard using powershell that comes with Windows. - -# Create pbcopy as binary -echo '#!/bin/sh' > ./pbcopy -echo 'clip.exe' >> ./pbcopy - -# Create pbpaste as binary -cat << 'EOF' > ./pbpaste -#!/bin/sh -# powershell.exe "(Get-Clipboard).TrimEnd()" | tr -d "\r" | sed '${/^$/d;}' -powershell.exe "(Get-Clipboard).TrimEnd()" | tr -d "\r" | sed -z 's/\n$//' -EOF -# (Get-Clipboard).TrimEnd() : Call `Get-Clipboard` and then remove trailing -# spaces and newlines. However, this command still leaves one newline by default. -# tr -d "\r" : Trim Windows-style `\r` to ensure Unix-style output -# sed '${/^$/d;}' : If the last line is empty, delete it - -chmod +x pbcopy pbpaste -mv pbcopy pbpaste "$DOTFILES/bin/" - -# The following aliases were moved to zshrc to be used as executables instead of -# aliases. Search "clipboad" in zshrc. -# alias pbcopy="clip.exe" -# alias pbpaste="powershell.exe -Command 'Get-Clipboard' | head -n -1" - -# Testing -# alias pbcopy="powershell.exe -Command \"\$input | Set-Clipboard\"" -# alias pbcopy="powershell.exe -NoLogo -NoProfile -Command '[Console]::OpenStandardInput() | Set-Clipboard'" -# alias pbcopy="powershell.exe -Command \"Set-Clipboard -Value \\\$(\\\$input | Out-String)\"" - -# Replacement using Windows Power Shell v7.4 -# https://learn.microsoft.com/en-us/powershell/scripting/install/installing-powershell-on-windows?view=powershell-7.4#install-powershell-using-winget-recommended -# Default command powershell that comes with Windows. -# -# alias pwsh_exe="\"/mnt/c/Program Files/PowerShell/7/pwsh.exe\"" -# alias pbpaste="pwsh_exe -Command 'Get-Clipboard' | head -n -1" diff --git a/zsh/clipboard.test.ts b/zsh/clipboard.test.ts index 9a19f15..b66a800 100644 --- a/zsh/clipboard.test.ts +++ b/zsh/clipboard.test.ts @@ -7,7 +7,9 @@ const hasCommand = async (cmd: string): Promise => { } const hasClipboardBridge = - (await hasCommand("pbcopy")) && (await hasCommand("pbpaste")) + (await hasCommand("powershell.exe")) && + (await hasCommand("pbcopy")) && + (await hasCommand("pbpaste")) const clipboardTest = hasClipboardBridge ? test : test.skip const clipboardDescribe = hasClipboardBridge ? describe : describe.skip @@ -21,6 +23,15 @@ clipboardTest("commands present: pbcopy, pbpaste", async () => { expect(out.stderr).toBeEmpty() }) +clipboardTest("explicit WSL clipboard aliases are present", async () => { + let out = await bash(`which wsl-pbcopy`) + expect(out.stdout).not.toBeEmpty() + expect(out.stderr).toBeEmpty() + out = await bash(`which wsl-pbpaste`) + expect(out.stdout).not.toBeEmpty() + expect(out.stderr).toBeEmpty() +}) + clipboardTest( "pbpaste correctly retrieves a single line without extra newlines", async () => { @@ -56,6 +67,13 @@ clipboardDescribe("echo suite", async () => { 職場 (しょくば) `, }, + { given: "’—“” → ← ↔ ✓", want: "’—“” → ← ↔ ✓" }, + { given: "ΓÇÖ ╬ô├ç├û ΓÇô ΓÇ£ ΓÇ¥", want: "ΓÇÖ ╬ô├ç├û ΓÇô ΓÇ£ ΓÇ¥" }, + { given: "é café 東京語 𐐷", want: "é café 東京語 𐐷" }, + { + given: "😀 👍 ❤️ 👩‍👩‍👧‍👧 🏳️‍🌈 🇺🇸 🐈", + want: "😀 👍 ❤️ 👩‍👩‍👧‍👧 🏳️‍🌈 🇺🇸 🐈", + }, ] for (let { given, want } of cases) { clipboardTest(`input: "${given}", want: "${want}"`, async () => { diff --git a/zsh/zshrc b/zsh/zshrc index cc0a29a..8e4d0e5 100644 --- a/zsh/zshrc +++ b/zsh/zshrc @@ -131,9 +131,6 @@ fi # If you come from bash you might have to change your $PATH. export PATH=$HOME/bin:/usr/local/bin:$DOTFILES/bin:$PATH -# Clipboard -source $DOTFILES/zsh/clipboard.sh - vs_code="/mnt/c/Program Files/Microsoft VS Code" export PATH=$vs_code/bin:$PATH