From cba566efa030c97143fb2d37677d06fb863c0ba9 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Mon, 10 Aug 2026 03:49:48 -0500 Subject: [PATCH 01/13] herdr: Add session-wide agent selector popup --- herdr-tmux/README.md | 13 +- herdr-tmux/src/app.rs | 287 +++++++++++++++++++++++++++++++++++++++++ herdr-tmux/src/main.rs | 7 +- herdr/README.md | 10 +- herdr/config.toml | 8 ++ 5 files changed, 319 insertions(+), 6 deletions(-) diff --git a/herdr-tmux/README.md b/herdr-tmux/README.md index ddc0f65..aec8c93 100644 --- a/herdr-tmux/README.md +++ b/herdr-tmux/README.md @@ -26,6 +26,7 @@ Run these commands from a Herdr custom key binding or a Herdr-managed pane: herdr-tmux layout even-vertical herdr-tmux layout even-horizontal herdr-tmux picker +herdr-tmux agent-selector ``` `even-vertical` stacks panes top-to-bottom. `even-horizontal` spreads panes @@ -39,6 +40,13 @@ picker supports up to ten panes and cancels on Escape, invalid input, or a no-ops. Before changing focus, the picker verifies that the selected pane still belongs to the originating tab. +Command `herdr-tmux agent-selector` 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 picker'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 the popup pane picker. It binds `prefix a` to the session-wide +agent selector. 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..8ec6280 100644 --- a/herdr-tmux/src/app.rs +++ b/herdr-tmux/src/app.rs @@ -19,6 +19,7 @@ use sha2::{Digest, Sha256}; const TIMEOUT: Duration = Duration::from_secs(5); const PICKER_TIMEOUT: Duration = Duration::from_millis(1_500); const MAX_PICKER_PANES: usize = 10; +const MAX_PICKER_AGENTS: usize = 10; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Direction { @@ -187,6 +188,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 +404,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 +557,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 +608,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 +1045,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 +1213,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..bdf655a 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}; @@ -33,6 +33,8 @@ enum Command { }, /// Select a pane in the active tab by number. Picker, + /// Select a live agent anywhere in the session by number. + AgentSelector, } #[derive(Debug, Subcommand)] @@ -63,6 +65,7 @@ fn main() -> ExitCode { let result = match cli.command { Command::Layout { layout } => arrange(&target, layout.into()), Command::Picker => pick_pane(&target), + Command::AgentSelector => pick_agent(&target), }; match result { Ok(()) => ExitCode::SUCCESS, diff --git a/herdr/README.md b/herdr/README.md index b4b5718..e57eb06 100644 --- a/herdr/README.md +++ b/herdr/README.md @@ -14,7 +14,8 @@ 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` opens the local numeric pane picker, and `prefix+a` opens the +session-wide agent selector. ## Pane layouts and picker @@ -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 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 selector 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..3c3b640 100644 --- a/herdr/config.toml +++ b/herdr/config.toml @@ -156,6 +156,14 @@ width = 60 height = 14 description = "select pane by number" +[[keys.command]] +key = "prefix+a" +type = "popup" +command = "$HOME/.local/bin/herdr-tmux agent-selector" +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] From 374b445d694660bd9efebc1040376ec35d23dc40 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Mon, 10 Aug 2026 03:56:20 -0500 Subject: [PATCH 02/13] herdr: Rename picker commands to focus-pane and focus-agent --- herdr-tmux/README.md | 20 ++++++++++---------- herdr-tmux/src/main.rs | 12 ++++++------ herdr/README.md | 12 ++++++------ herdr/config.toml | 4 ++-- 4 files changed, 24 insertions(+), 24 deletions(-) diff --git a/herdr-tmux/README.md b/herdr-tmux/README.md index aec8c93..460ae60 100644 --- a/herdr-tmux/README.md +++ b/herdr-tmux/README.md @@ -25,25 +25,25 @@ 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 agent-selector +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 agent-selector` lists every live agent in the session. +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 picker's cancellation and +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. @@ -54,9 +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. It binds `prefix a` to the session-wide -agent selector. Both are local, early features and are not packaged as Herdr -plugins. +`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/main.rs b/herdr-tmux/src/main.rs index bdf655a..cea2d11 100644 --- a/herdr-tmux/src/main.rs +++ b/herdr-tmux/src/main.rs @@ -31,10 +31,10 @@ enum Command { #[command(subcommand)] layout: Layout, }, - /// Select a pane in the active tab by number. - Picker, - /// Select a live agent anywhere in the session by number. - AgentSelector, + /// Focus a pane in the active tab by number. + FocusPane, + /// Focus a live agent anywhere in the session by number. + FocusAgent, } #[derive(Debug, Subcommand)] @@ -64,8 +64,8 @@ fn main() -> ExitCode { }; let result = match cli.command { Command::Layout { layout } => arrange(&target, layout.into()), - Command::Picker => pick_pane(&target), - Command::AgentSelector => pick_agent(&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 e57eb06..6409f59 100644 --- a/herdr/README.md +++ b/herdr/README.md @@ -14,10 +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, and `prefix+a` opens the -session-wide agent selector. +`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: @@ -29,10 +29,10 @@ The tmux layout bindings are available in Herdr too: 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. The agent selector supports the same number of rows and cancellation +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: diff --git a/herdr/config.toml b/herdr/config.toml index 3c3b640..291da27 100644 --- a/herdr/config.toml +++ b/herdr/config.toml @@ -151,7 +151,7 @@ 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" @@ -159,7 +159,7 @@ description = "select pane by number" [[keys.command]] key = "prefix+a" type = "popup" -command = "$HOME/.local/bin/herdr-tmux agent-selector" +command = "$HOME/.local/bin/herdr-tmux focus-agent" width = 72 height = 14 description = "select agent by number" From aac54f5ba66ddd8969fc1aa162ff186cf69b4d58 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Mon, 10 Aug 2026 13:20:30 -0500 Subject: [PATCH 03/13] zsh: Force tuicr clipboard exports through OSC 52 --- zsh/aliases.sh | 5 +++++ 1 file changed, 5 insertions(+) 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 "$@" +} From 3349dd4655988e26e547f2e11d5fdf4e720a7770 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Mon, 10 Aug 2026 23:51:30 -0500 Subject: [PATCH 04/13] nvim: Make core settings safe to reload --- nvim/lua/core/vim.lua | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/nvim/lua/core/vim.lua b/nvim/lua/core/vim.lua index 2153511..ee1ed26 100644 --- a/nvim/lua/core/vim.lua +++ b/nvim/lua/core/vim.lua @@ -57,7 +57,11 @@ vim.api.nvim_create_user_command('WY', function(opts) vim.fn.system('iconv -f UTF-8 -t UTF-16LE | 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" }) +end, { + desc = "[W]indows [Y]ank, changing encoding from UTF8 to UTF-16LE on copy", + 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 {} From 270d23d042eebd4d554fc7ff6705e93934d70d5e Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 01:55:30 -0500 Subject: [PATCH 05/13] zsh: Stop regenerating clipboard wrappers --- zsh/clipboard.sh | 39 --------------------------------------- zsh/zshrc | 3 --- 2 files changed, 42 deletions(-) delete mode 100644 zsh/clipboard.sh 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/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 From 7bf79558a27ac4cd71044a4e6c84ff211c3dfeec Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 02:35:38 -0500 Subject: [PATCH 06/13] feat(clipboard): Add persistent WSL clipboard bridge --- clipboard/.gitignore | 1 + clipboard/Cargo.lock | 56 ++++ clipboard/Cargo.toml | 11 + clipboard/src/main.rs | 478 ++++++++++++++++++++++++++++++++++ clipboard/tests/clipboard.rs | 92 +++++++ justfile | 21 ++ zsh/clipboard.bench.test.ts | 85 ++++++ zsh/clipboard.bench.ts | 487 +++++++++++++++++++++++++++++++++++ 8 files changed, 1231 insertions(+) create mode 100644 clipboard/.gitignore create mode 100644 clipboard/Cargo.lock create mode 100644 clipboard/Cargo.toml create mode 100644 clipboard/src/main.rs create mode 100644 clipboard/tests/clipboard.rs create mode 100644 zsh/clipboard.bench.test.ts create mode 100644 zsh/clipboard.bench.ts diff --git a/clipboard/.gitignore b/clipboard/.gitignore new file mode 100644 index 0000000..2f7896d --- /dev/null +++ b/clipboard/.gitignore @@ -0,0 +1 @@ +target/ diff --git a/clipboard/Cargo.lock b/clipboard/Cargo.lock new file mode 100644 index 0000000..767ccd3 --- /dev/null +++ b/clipboard/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/clipboard/Cargo.toml b/clipboard/Cargo.toml new file mode 100644 index 0000000..85435d8 --- /dev/null +++ b/clipboard/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "wsl-clipboard" +version = "0.1.0" +edition = "2024" + +[workspace] + +[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..a72dfc2 --- /dev/null +++ b/clipboard/src/main.rs @@ -0,0 +1,478 @@ +use std::{ + env, + fs::{self, OpenOptions}, + io::{self, BufRead, BufReader, Read, Write}, + os::unix::{ + fs::PermissionsExt, + net::{UnixListener, UnixStream}, + }, + 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; + +const MAX_PAYLOAD_BYTES: u64 = 64 * 1024 * 1024; +const STARTUP_TIMEOUT: Duration = Duration::from_secs(2); +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; + +enum Request { + Copy(Vec), + Paste, + Status, + Stop, +} + +struct Paths { + socket: PathBuf, + startup_lock: PathBuf, + log: PathBuf, +} + +struct PowerShell { + child: Child, + stdin: ChildStdin, + stdout: BufReader, +} + +impl PowerShell { + 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) + } + + 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(|_| ()) + } + + 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()) + } + + 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 command = env::args().nth(1).unwrap_or_else(|| "help".to_owned()); + let paths = socket_paths()?; + match command.as_str() { + "copy" => { + let mut bytes = Vec::new(); + io::stdin().read_to_end(&mut 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}"))), + } +} + +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) +} + +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}", + ))); + } + } + } +} + +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(()) +} + +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(()) +} + +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(), + ), + }; +} + +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"), + }) +} + +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")), + } +} + +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() +} + +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)) +} + +fn build_power_shell_script() -> String { + [ + "$ErrorActionPreference = 'Stop'", + "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)", + "[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 = [Text.Encoding]::UTF8.GetBytes([string]$text)", + "[Console]::Out.WriteLine('OK ' + [Convert]::ToBase64String($bytes))", + "} elseif ($line.StartsWith('COPY ')) {", + "$bytes = [Convert]::FromBase64String($line.Substring(5))", + "$text = [Text.Encoding]::UTF8.GetString($bytes)", + "Set-Clipboard -Value $text", + "[Console]::Out.WriteLine('OK ')", + "} elseif ($line -eq 'QUIT') { break } else {", + "throw 'invalid clipboard command'", + "}", + "} catch {", + "$bytes = [Text.Encoding]::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()) +} + +fn print_usage() { + println!("Usage: wsl-clipboard "); +} + +#[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("Invoke-Expression")); + } +} diff --git a/clipboard/tests/clipboard.rs b/clipboard/tests/clipboard.rs new file mode 100644 index 0000000..8fa47d1 --- /dev/null +++ b/clipboard/tests/clipboard.rs @@ -0,0 +1,92 @@ +use std::{ + io::Write, + process::{Command, Stdio}, + thread, + time::{Duration, Instant}, +}; + +fn binary() -> &'static str { + env!("CARGO_BIN_EXE_wsl-clipboard") +} + +fn has_power_shell() -> bool { + Command::new("powershell.exe") + .arg("-Version") + .output() + .is_ok() +} + +fn run_copy(input: &[u8]) { + let mut child = Command::new(binary()) + .arg("copy") + .stdin(Stdio::piped()) + .spawn() + .unwrap(); + child.stdin.as_mut().unwrap().write_all(input).unwrap(); + let status = child.wait().unwrap(); + assert!(status.success()); +} + +fn run_paste() -> Vec { + let output = Command::new(binary()).arg("paste").output().unwrap(); + assert!(output.status.success()); + assert!(output.stderr.is_empty()); + output.stdout +} + +fn stop_daemon() { + let _ = Command::new(binary()).arg("stop").output(); +} + +fn wait_for_daemon_stop() { + let started_at = Instant::now(); + while started_at.elapsed() < Duration::from_secs(1) { + let status = Command::new(binary()).arg("status").output().unwrap(); + if !status.status.success() { + return; + } + thread::sleep(Duration::from_millis(20)); + } + panic!("clipboard daemon did not stop"); +} + +fn require_power_shell() -> bool { + if !has_power_shell() { + eprintln!( + "skipping Windows clipboard integration test: powershell.exe is unavailable" + ); + return false; + } + true +} + +#[test] +fn copies_and_pastes_existing_text_formats_and_restarts_exactly() { + if !require_power_shell() { + return; + } + + let cases = [ + "one line output", + "line0\nline1\n\n\n", + "line0\nline1", + "sanity check", + "HJK 日本語", + "この職場は、経験よりも腕を優先する考え方だ。\n職場 (しょくば)\n", + ]; + + stop_daemon(); + for expected in cases { + run_copy(expected.as_bytes()); + assert_eq!(run_paste(), expected.as_bytes()); + } + + let expected = "line one\n日本語\n\n".as_bytes(); + run_copy(expected); + assert_eq!(run_paste(), expected); + + stop_daemon(); + wait_for_daemon_stop(); + assert_eq!(run_paste(), expected); + stop_daemon(); +} diff --git a/justfile b/justfile index 0740081..d6b2425 100644 --- a/justfile +++ b/justfile @@ -8,10 +8,31 @@ setup: just -l test: + cargo test --manifest-path clipboard/Cargo.toml 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 experimental persistent WSL clipboard bridge. +clipboard-build: + cargo build --manifest-path clipboard/Cargo.toml + +# Run the experimental clipboard bridge without installing it. +clipboard *ARGS: + cargo run --manifest-path clipboard/Cargo.toml -- {{ARGS}} + +# Benchmark the compiled persistent bridge beside the legacy clipboard commands. +clipboard-rust-bench *ARGS: + #!/usr/bin/env bash + set -Eeuo pipefail + cargo build --manifest-path clipboard/Cargo.toml + WSL_CLIPBOARD_BIN="$PWD/clipboard/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 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..9291522 --- /dev/null +++ b/zsh/clipboard.bench.ts @@ -0,0 +1,487 @@ +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 pbcopy = byLabel.get("pbcopy wrapper") + const clip = byLabel.get("clip.exe direct") + const pbpaste = byLabel.get("pbpaste wrapper") + 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 roundTrip = byLabel.get("pbcopy + pbpaste") + const rustCopy = byLabel.get("wsl-clipboard copy") + const rustPaste = byLabel.get("wsl-clipboard paste") + const rustRoundTrip = byLabel.get("wsl-clipboard copy + paste") + + if ( + pbcopy && + clip && + pbpaste && + powershell && + noProfile && + warm && + persistent && + roundTrip + ) { + const copyWrapper = pbcopy.medianMs - clip.medianMs + const pasteWrapper = pbpaste.medianMs - powershell.medianMs + const roundTripExtra = + roundTrip.medianMs - pbcopy.medianMs - pbpaste.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(` pbcopy shell wrapper: ${formatMs(copyWrapper)} ms`) + console.log(` 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 (rustCopy && rustPaste && rustRoundTrip && pbcopy && pbpaste && roundTrip) { + console.log("\nPersistent bridge median speedups:") + console.log( + ` copy: ${(pbcopy.medianMs / rustCopy.medianMs).toFixed(1)}x`, + ) + console.log( + ` paste: ${(pbpaste.medianMs / rustPaste.medianMs).toFixed(1)}x`, + ) + console.log( + ` round trip: ${(roundTrip.medianMs / rustRoundTrip.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(["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: "pbcopy wrapper", + operation: async () => { + await runCommand(["pbcopy"], payload) + }, + }, + { + label: "PowerShell direct", + operation: async () => { + await runCommand(powershellCommand) + }, + }, + { + label: "PowerShell no-profile", + operation: async () => { + await runCommand( + powershellArgs("(Get-Clipboard).TrimEnd()"), + ) + }, + }, + { + label: "pbpaste wrapper", + operation: async () => { + await runCommand(["pbpaste"]) + }, + }, + { + label: "pbcopy + pbpaste", + operation: async () => { + await runCommand(["pbcopy"], payload) + const pasted = await runCommand(["pbpaste"]) + if (pasted !== payload) { + throw new Error("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 + } +} From 672ad0df0cef1638c6d74149af2cbe039a48c367 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 02:38:18 -0500 Subject: [PATCH 07/13] build(workspace): Add dotfiles Cargo workspace --- .gitignore | 17 +++++++++++++++++ clipboard/Cargo.lock => Cargo.lock | 0 Cargo.toml | 6 ++++++ clipboard/.gitignore | 1 - clipboard/Cargo.toml | 4 +--- justfile | 10 +++++----- 6 files changed, 29 insertions(+), 9 deletions(-) rename clipboard/Cargo.lock => Cargo.lock (100%) create mode 100644 Cargo.toml delete mode 100644 clipboard/.gitignore 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/clipboard/Cargo.lock b/Cargo.lock similarity index 100% rename from clipboard/Cargo.lock rename to Cargo.lock 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/clipboard/.gitignore b/clipboard/.gitignore deleted file mode 100644 index 2f7896d..0000000 --- a/clipboard/.gitignore +++ /dev/null @@ -1 +0,0 @@ -target/ diff --git a/clipboard/Cargo.toml b/clipboard/Cargo.toml index 85435d8..8b35d8b 100644 --- a/clipboard/Cargo.toml +++ b/clipboard/Cargo.toml @@ -1,9 +1,7 @@ [package] name = "wsl-clipboard" version = "0.1.0" -edition = "2024" - -[workspace] +edition.workspace = true [dependencies] base64 = "0.22.1" diff --git a/justfile b/justfile index d6b2425..fca080f 100644 --- a/justfile +++ b/justfile @@ -8,7 +8,7 @@ setup: just -l test: - cargo test --manifest-path clipboard/Cargo.toml + cargo test --workspace bun test alias t := test @@ -19,18 +19,18 @@ clipboard-bench *ARGS: # Build the experimental persistent WSL clipboard bridge. clipboard-build: - cargo build --manifest-path clipboard/Cargo.toml + cargo build --package wsl-clipboard # Run the experimental clipboard bridge without installing it. clipboard *ARGS: - cargo run --manifest-path clipboard/Cargo.toml -- {{ARGS}} + cargo run --package wsl-clipboard -- {{ARGS}} # Benchmark the compiled persistent bridge beside the legacy clipboard commands. clipboard-rust-bench *ARGS: #!/usr/bin/env bash set -Eeuo pipefail - cargo build --manifest-path clipboard/Cargo.toml - WSL_CLIPBOARD_BIN="$PWD/clipboard/target/debug/wsl-clipboard" \ + 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. From 06b02d61c39ed2b5006da63a3e4234065600086d Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 04:06:22 -0500 Subject: [PATCH 08/13] ci: Add Rust CI tests for the workspace --- .github/workflows/rust.yml | 40 ++++++++++++++++++++++++++++++++++++++ .github/workflows/test.yml | 3 +++ 2 files changed, 43 insertions(+) create mode 100644 .github/workflows/rust.yml 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 From fec86210e185caf77f53f9253addd04285333f6e Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 04:06:46 -0500 Subject: [PATCH 09/13] herdr: Save theme and pane prefs --- herdr/config.toml | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/herdr/config.toml b/herdr/config.toml index 291da27..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. @@ -238,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 @@ -281,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 From d251677aab572449e9fc3ed318b4fec3e40266c3 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 04:07:18 -0500 Subject: [PATCH 10/13] herdr: Use longer default timing for agent and pane quick jumps --- herdr-tmux/src/app.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/herdr-tmux/src/app.rs b/herdr-tmux/src/app.rs index 8ec6280..2a91819 100644 --- a/herdr-tmux/src/app.rs +++ b/herdr-tmux/src/app.rs @@ -17,7 +17,8 @@ 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; From 661d8a3c57376ec755bdd9478cb73d5349e810d2 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 04:08:12 -0500 Subject: [PATCH 11/13] codex: Preserve runtime schema directive and raw output setting --- codex/config.test.ts | 17 +++++++++++++++-- codex/config.ts | 20 ++++++++++++++++---- 2 files changed, 31 insertions(+), 6 deletions(-) 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) } From 84b3aa221f792e1d1bd3e290e5a60a60091fa6a5 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 04:29:12 -0500 Subject: [PATCH 12/13] wsl-clipboard: Replace legacy pbcopy and pbpaste shims --- README.md | 12 +- bin/legacy-pbcopy | 3 + bin/legacy-pbpaste | 3 + bin/pbcopy | 4 +- bin/pbpaste | 5 +- bin/wsl-pbcopy | 14 +++ bin/wsl-pbpaste | 14 +++ clipboard/src/main.rs | 82 ++++++++++++- clipboard/tests/clipboard.rs | 228 +++++++++++++++++++++++++++++------ justfile | 22 +++- nvim/lua/core/lsp.lua | 2 +- nvim/lua/core/vim.lua | 12 +- zsh/clipboard.bench.ts | 77 ++++++++---- zsh/clipboard.test.ts | 20 ++- 14 files changed, 418 insertions(+), 80 deletions(-) create mode 100755 bin/legacy-pbcopy create mode 100755 bin/legacy-pbpaste create mode 100755 bin/wsl-pbcopy create mode 100755 bin/wsl-pbpaste diff --git a/README.md b/README.md index 51a35d3..e11fb29 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` and `pbpaste` remain the portable command interface; the explicit + `wsl-pbcopy` and `wsl-pbpaste` names invoke the same bridge +- 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..a96d546 --- /dev/null +++ b/bin/legacy-pbcopy @@ -0,0 +1,3 @@ +#!/bin/sh +# 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..809f82e --- /dev/null +++ b/bin/legacy-pbpaste @@ -0,0 +1,3 @@ +#!/bin/sh +# 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 index 76dca93..4e8ffaa 100755 --- a/bin/pbcopy +++ b/bin/pbcopy @@ -1,2 +1,4 @@ #!/bin/sh -clip.exe +# Backward-compatible macOS-style entry point for the WSL clipboard bridge. +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$script_dir/wsl-pbcopy" "$@" diff --git a/bin/pbpaste b/bin/pbpaste index 58c42bd..0ac0396 100755 --- a/bin/pbpaste +++ b/bin/pbpaste @@ -1,3 +1,4 @@ #!/bin/sh -# powershell.exe "(Get-Clipboard).TrimEnd()" | tr -d "\r" | sed '${/^$/d;}' -powershell.exe "(Get-Clipboard).TrimEnd()" | tr -d "\r" | sed -z 's/\n$//' +# Backward-compatible macOS-style entry point for the WSL clipboard bridge. +script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) +exec "$script_dir/wsl-pbpaste" "$@" diff --git a/bin/wsl-pbcopy b/bin/wsl-pbcopy new file mode 100755 index 0000000..926ed54 --- /dev/null +++ b/bin/wsl-pbcopy @@ -0,0 +1,14 @@ +#!/bin/sh +# Copy UTF-8 stdin through the persistent WSL clipboard bridge. +if [ "$#" -ne 0 ]; then + echo "usage: wsl-pbcopy < stdin" >&2 + exit 2 +fi + +clipboard_bin=${WSL_CLIPBOARD_BIN:-"$HOME/.local/bin/wsl-clipboard"} +if [ ! -x "$clipboard_bin" ]; then + echo "wsl-pbcopy: bridge is not installed; run: just clipboard-install" >&2 + exit 127 +fi + +exec "$clipboard_bin" copy diff --git a/bin/wsl-pbpaste b/bin/wsl-pbpaste new file mode 100755 index 0000000..be5efad --- /dev/null +++ b/bin/wsl-pbpaste @@ -0,0 +1,14 @@ +#!/bin/sh +# Paste UTF-8 text through the persistent WSL clipboard bridge. +if [ "$#" -ne 0 ]; then + echo "usage: wsl-pbpaste" >&2 + exit 2 +fi + +clipboard_bin=${WSL_CLIPBOARD_BIN:-"$HOME/.local/bin/wsl-clipboard"} +if [ ! -x "$clipboard_bin" ]; then + echo "wsl-pbpaste: bridge is not installed; run: just clipboard-install" >&2 + exit 127 +fi + +exec "$clipboard_bin" paste diff --git a/clipboard/src/main.rs b/clipboard/src/main.rs index a72dfc2..03e2fc2 100644 --- a/clipboard/src/main.rs +++ b/clipboard/src/main.rs @@ -19,8 +19,16 @@ use std::{ 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; @@ -28,6 +36,10 @@ 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, @@ -35,12 +47,19 @@ enum Request { 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, @@ -48,6 +67,8 @@ struct PowerShell { } 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") @@ -79,6 +100,8 @@ impl PowerShell { 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())?; @@ -86,6 +109,7 @@ impl PowerShell { 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()?; @@ -116,6 +140,8 @@ impl PowerShell { 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(); @@ -143,6 +169,7 @@ fn run() -> io::Result<()> { "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" => { @@ -168,6 +195,8 @@ fn run() -> io::Result<()> { } } +/// 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, @@ -185,6 +214,9 @@ fn send_client_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() @@ -220,6 +252,8 @@ fn ensure_daemon(paths: &Paths, first_error: io::Error) -> io::Result<()> { } } +/// 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() @@ -235,6 +269,10 @@ fn spawn_daemon(paths: &Paths) -> io::Result<()> { 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() { @@ -269,6 +307,8 @@ fn run_daemon(paths: Paths) -> io::Result<()> { 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>, @@ -297,6 +337,9 @@ fn handle_client( }; } +/// 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")) @@ -311,6 +354,8 @@ fn socket_paths() -> io::Result { }) } +/// 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)?; @@ -355,6 +400,9 @@ fn read_response(reader: &mut R) -> io::Result> { } } +/// 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, @@ -369,6 +417,8 @@ fn write_frame( 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)?; @@ -383,10 +433,16 @@ fn read_frame(reader: &mut R) -> io::Result<(u8, Vec)> { 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'", - "[Console]::OutputEncoding = [Text.UTF8Encoding]::new($false)", + "$utf8 = [Text.UTF8Encoding]::new($false, $true)", + "[Console]::OutputEncoding = $utf8", "[Console]::Out.WriteLine('READY')", "[Console]::Out.Flush()", "while (($line = [Console]::In.ReadLine()) -ne $null) {", @@ -394,18 +450,18 @@ fn build_power_shell_script() -> String { "if ($line -eq 'PASTE') {", "$text = Get-Clipboard -Raw", "if ($null -eq $text) { $text = '' }", - "$bytes = [Text.Encoding]::UTF8.GetBytes([string]$text)", + "$bytes = $utf8.GetBytes([string]$text)", "[Console]::Out.WriteLine('OK ' + [Convert]::ToBase64String($bytes))", "} elseif ($line.StartsWith('COPY ')) {", "$bytes = [Convert]::FromBase64String($line.Substring(5))", - "$text = [Text.Encoding]::UTF8.GetString($bytes)", + "$text = $utf8.GetString($bytes)", "Set-Clipboard -Value $text", "[Console]::Out.WriteLine('OK ')", "} elseif ($line -eq 'QUIT') { break } else {", "throw 'invalid clipboard command'", "}", "} catch {", - "$bytes = [Text.Encoding]::UTF8.GetBytes($_.Exception.Message)", + "$bytes = $utf8.GetBytes($_.Exception.Message)", "[Console]::Out.WriteLine('ERR ' + [Convert]::ToBase64String($bytes))", "}", "[Console]::Out.Flush()", @@ -434,6 +490,18 @@ 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 "); } @@ -473,6 +541,12 @@ mod tests { 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()); + } } diff --git a/clipboard/tests/clipboard.rs b/clipboard/tests/clipboard.rs index 8fa47d1..7f65faf 100644 --- a/clipboard/tests/clipboard.rs +++ b/clipboard/tests/clipboard.rs @@ -1,48 +1,150 @@ use std::{ + env, io::Write, - process::{Command, Stdio}, + 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"); +const WSL_PBCOPY: &str = + concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/wsl-pbcopy"); +const WSL_PBPASTE: &str = + concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/wsl-pbpaste"); + +struct ClipboardCli { + name: &'static str, + copy_executable: &'static str, + copy_args: &'static [&'static str], + paste_executable: &'static str, + paste_args: &'static [&'static str], + bridge_binary: Option<&'static str>, +} + fn binary() -> &'static str { env!("CARGO_BIN_EXE_wsl-clipboard") } -fn has_power_shell() -> bool { - Command::new("powershell.exe") - .arg("-Version") - .output() - .is_ok() +fn legacy_clipboard() -> ClipboardCli { + ClipboardCli { + name: "legacy one-shot clipboard", + copy_executable: LEGACY_PBCOPY, + copy_args: &[], + paste_executable: LEGACY_PBPASTE, + paste_args: &[], + bridge_binary: None, + } +} + +fn rust_clipboard() -> ClipboardCli { + ClipboardCli { + name: "wsl-clipboard", + copy_executable: binary(), + copy_args: &["copy"], + paste_executable: binary(), + paste_args: &["paste"], + bridge_binary: None, + } +} + +fn shim_clipboard() -> ClipboardCli { + ClipboardCli { + name: "wsl-pbcopy/wsl-pbpaste shims", + copy_executable: WSL_PBCOPY, + copy_args: &[], + paste_executable: WSL_PBPASTE, + paste_args: &[], + bridge_binary: Some(binary()), + } +} + +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]>, + bridge_binary: Option<&str>, +) -> 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()); + if let Some(bridge_binary) = bridge_binary { + command.env("WSL_CLIPBOARD_BIN", bridge_binary); + } + 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 run_copy(input: &[u8]) { - let mut child = Command::new(binary()) - .arg("copy") - .stdin(Stdio::piped()) - .spawn() - .unwrap(); - child.stdin.as_mut().unwrap().write_all(input).unwrap(); - let status = child.wait().unwrap(); - assert!(status.success()); +fn copy(cli: &ClipboardCli, input: &[u8]) { + let output = run_command( + cli.copy_executable, + cli.copy_args, + Some(input), + cli.bridge_binary, + ); + 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 run_paste() -> Vec { - let output = Command::new(binary()).arg("paste").output().unwrap(); - assert!(output.status.success()); - assert!(output.stderr.is_empty()); +fn paste(cli: &ClipboardCli) -> Vec { + let output = run_command( + cli.paste_executable, + cli.paste_args, + None, + cli.bridge_binary, + ); + 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, None) +} + fn stop_daemon() { - let _ = Command::new(binary()).arg("stop").output(); + let _ = run_rust_command(&["stop"]); } fn wait_for_daemon_stop() { let started_at = Instant::now(); while started_at.elapsed() < Duration::from_secs(1) { - let status = Command::new(binary()).arg("status").output().unwrap(); - if !status.status.success() { + if !run_rust_command(&["status"]).status.success() { return; } thread::sleep(Duration::from_millis(20)); @@ -50,8 +152,8 @@ fn wait_for_daemon_stop() { panic!("clipboard daemon did not stop"); } -fn require_power_shell() -> bool { - if !has_power_shell() { +fn require_clipboard_prerequisites() -> bool { + if !command_available("powershell.exe") { eprintln!( "skipping Windows clipboard integration test: powershell.exe is unavailable" ); @@ -60,33 +162,91 @@ fn require_power_shell() -> bool { true } +fn assert_clipboard_case(input: &str) { + let expected = input.as_bytes(); + let legacy = legacy_clipboard(); + let rust = rust_clipboard(); + let shims = shim_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" + ); + + copy(&shims, expected); + let shim_output = paste(&shims); + assert_eq!( + shim_output, expected, + "shim output differed from expectation" + ); + assert_eq!( + rust_output, legacy_output, + "Rust output differed from legacy" + ); + assert_eq!( + shim_output, legacy_output, + "shim 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 copies_and_pastes_existing_text_formats_and_restarts_exactly() { - if !require_power_shell() { +fn preserves_legacy_cases_unicode_and_shim_compatibility() { + if !require_clipboard_prerequisites() { return; } let cases = [ - "one line output", + "one line output\n", "line0\nline1\n\n\n", "line0\nline1", "sanity check", "HJK 日本語", "この職場は、経験よりも腕を優先する考え方だ。\n職場 (しょくば)\n", + "’—“” → ← ↔ ✓", + "ΓÇÖ ╬ô├ç├û ΓÇô ΓÇ£ ΓÇ¥", + "e\u{301} café 東京語 𐐷", + "😀 👍 ❤️ 👩‍👩‍👧‍👧 🏳️‍🌈 🇺🇸 🐈", ]; stop_daemon(); - for expected in cases { - run_copy(expected.as_bytes()); - assert_eq!(run_paste(), expected.as_bytes()); + for input in cases { + assert_clipboard_case(input); } - let expected = "line one\n日本語\n\n".as_bytes(); - run_copy(expected); - assert_eq!(run_paste(), expected); + let expected = "line one\n日本語\n\n"; + let shims = shim_clipboard(); + copy(&shims, expected.as_bytes()); + assert_eq!(paste(&shims), expected.as_bytes()); stop_daemon(); wait_for_daemon_stop(); - assert_eq!(run_paste(), expected); + assert_eq!(paste(&shims), 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]), None); + assert!(!output.status.success()); + assert!(String::from_utf8_lossy(&output.stderr).contains("expects UTF-8")); } diff --git a/justfile b/justfile index fca080f..7fa29c3 100644 --- a/justfile +++ b/justfile @@ -17,15 +17,21 @@ alias t := test clipboard-bench *ARGS: bun run zsh/clipboard.bench.ts {{ARGS}} -# Build the experimental persistent WSL clipboard bridge. +# Build the release WSL clipboard bridge without installing it. clipboard-build: - cargo build --package wsl-clipboard + 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" -# Run the experimental clipboard bridge without installing it. +# Run the WSL clipboard bridge from the source workspace. clipboard *ARGS: cargo run --package wsl-clipboard -- {{ARGS}} -# Benchmark the compiled persistent bridge beside the legacy clipboard commands. +# Benchmark the compiled bridge beside the explicitly named legacy commands. clipboard-rust-bench *ARGS: #!/usr/bin/env bash set -Eeuo pipefail @@ -39,6 +45,9 @@ sync: 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 @@ -84,6 +93,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 ee1ed26..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,12 +53,12 @@ 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).") + print("Yanked text copied to the Windows clipboard.") end, { - desc = "[W]indows [Y]ank, changing encoding from UTF8 to UTF-16LE on copy", + desc = "[W]indows [Y]ank through the persistent clipboard bridge", force = true, range = true, }) diff --git a/zsh/clipboard.bench.ts b/zsh/clipboard.bench.ts index 9291522..6b25e54 100644 --- a/zsh/clipboard.bench.ts +++ b/zsh/clipboard.bench.ts @@ -303,39 +303,39 @@ const printResults = (results: TimingSummary[]): void => { ) const byLabel = new Map(results.map((result) => [result.label, result])) - const pbcopy = byLabel.get("pbcopy wrapper") + const legacyCopy = byLabel.get("legacy-pbcopy") const clip = byLabel.get("clip.exe direct") - const pbpaste = byLabel.get("pbpaste wrapper") + const legacyPaste = byLabel.get("legacy-pbpaste") + const bridgeCopy = byLabel.get("pbcopy (persistent)") + const bridgePaste = byLabel.get("pbpaste (persistent)") 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 roundTrip = byLabel.get("pbcopy + pbpaste") - const rustCopy = byLabel.get("wsl-clipboard copy") - const rustPaste = byLabel.get("wsl-clipboard paste") - const rustRoundTrip = byLabel.get("wsl-clipboard copy + paste") + const legacyRoundTrip = byLabel.get("legacy-pbcopy + legacy-pbpaste") + const bridgeRoundTrip = byLabel.get("pbcopy + pbpaste (persistent)") if ( - pbcopy && + legacyCopy && clip && - pbpaste && + legacyPaste && powershell && noProfile && warm && persistent && - roundTrip + legacyRoundTrip ) { - const copyWrapper = pbcopy.medianMs - clip.medianMs - const pasteWrapper = pbpaste.medianMs - powershell.medianMs + const copyWrapper = legacyCopy.medianMs - clip.medianMs + const pasteWrapper = legacyPaste.medianMs - powershell.medianMs const roundTripExtra = - roundTrip.medianMs - pbcopy.medianMs - pbpaste.medianMs + 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(` pbcopy shell wrapper: ${formatMs(copyWrapper)} ms`) - console.log(` pbpaste shell pipeline: ${formatMs(pasteWrapper)} ms`) + 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( @@ -344,16 +344,23 @@ const printResults = (results: TimingSummary[]): void => { console.log(` persistent paste speedup: ${persistentSpeedup.toFixed(1)}x`) } - if (rustCopy && rustPaste && rustRoundTrip && pbcopy && pbpaste && roundTrip) { + if ( + bridgeCopy && + bridgePaste && + bridgeRoundTrip && + legacyCopy && + legacyPaste && + legacyRoundTrip + ) { console.log("\nPersistent bridge median speedups:") console.log( - ` copy: ${(pbcopy.medianMs / rustCopy.medianMs).toFixed(1)}x`, + ` copy: ${(legacyCopy.medianMs / bridgeCopy.medianMs).toFixed(1)}x`, ) console.log( - ` paste: ${(pbpaste.medianMs / rustPaste.medianMs).toFixed(1)}x`, + ` paste: ${(legacyPaste.medianMs / bridgePaste.medianMs).toFixed(1)}x`, ) console.log( - ` round trip: ${(roundTrip.medianMs / rustRoundTrip.medianMs).toFixed(1)}x`, + ` round trip: ${(legacyRoundTrip.medianMs / bridgeRoundTrip.medianMs).toFixed(1)}x`, ) } } @@ -367,7 +374,7 @@ const main = async (): Promise => { ] const useRustBridge = Bun.env.WSL_CLIPBOARD_BIN !== undefined - await runCommand(["pbcopy"], payload) + await runCommand(["legacy-pbcopy"], payload) if (useRustBridge) { await runCommand(wslClipboardArgs("copy"), payload) } @@ -385,9 +392,9 @@ const main = async (): Promise => { }, }, { - label: "pbcopy wrapper", + label: "legacy-pbcopy", operation: async () => { - await runCommand(["pbcopy"], payload) + await runCommand(["legacy-pbcopy"], payload) }, }, { @@ -405,18 +412,40 @@ const main = async (): Promise => { }, }, { - label: "pbpaste wrapper", + 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 (persistent)", + operation: async () => { + await runCommand(["pbcopy"], payload) + }, + }, + { + label: "pbpaste (persistent)", operation: async () => { await runCommand(["pbpaste"]) }, }, { - label: "pbcopy + pbpaste", + label: "pbcopy + pbpaste (persistent)", operation: async () => { await runCommand(["pbcopy"], payload) const pasted = await runCommand(["pbpaste"]) if (pasted !== payload) { - throw new Error("Clipboard round trip returned different text") + throw new Error("Persistent clipboard round trip returned different text") } }, }, diff --git a/zsh/clipboard.test.ts b/zsh/clipboard.test.ts index 9a19f15..d4bcc27 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 shims 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 () => { From 1a08f2efd5bec6b6eabf252688b494a0a8beead6 Mon Sep 17 00:00:00 2001 From: Unique Divine Date: Tue, 11 Aug 2026 04:38:01 -0500 Subject: [PATCH 13/13] refactor(wsl-clipboard): Dispatch clipboard aliases through symlinks --- README.md | 4 +-- bin/legacy-pbcopy | 2 +- bin/legacy-pbpaste | 2 +- bin/pbcopy | 4 --- bin/pbpaste | 4 --- bin/wsl-pbcopy | 14 -------- bin/wsl-pbpaste | 14 -------- clipboard/src/main.rs | 48 +++++++++++++++++++++++-- clipboard/tests/clipboard.rs | 68 ++++++------------------------------ justfile | 3 ++ zsh/clipboard.bench.ts | 12 +++---- zsh/clipboard.test.ts | 2 +- 12 files changed, 70 insertions(+), 107 deletions(-) delete mode 100755 bin/pbcopy delete mode 100755 bin/pbpaste delete mode 100755 bin/wsl-pbcopy delete mode 100755 bin/wsl-pbpaste diff --git a/README.md b/README.md index e11fb29..c862f22 100644 --- a/README.md +++ b/README.md @@ -139,8 +139,8 @@ usage and options. ### WSL Clipboard Integration - A persistent Rust bridge that keeps one PowerShell clipboard process warm -- `pbcopy` and `pbpaste` remain the portable command interface; the explicit - `wsl-pbcopy` and `wsl-pbpaste` names invoke the same bridge +- `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 diff --git a/bin/legacy-pbcopy b/bin/legacy-pbcopy index a96d546..6c4063e 100755 --- a/bin/legacy-pbcopy +++ b/bin/legacy-pbcopy @@ -1,3 +1,3 @@ -#!/bin/sh +#!/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 index 809f82e..8a7b807 100755 --- a/bin/legacy-pbpaste +++ b/bin/legacy-pbpaste @@ -1,3 +1,3 @@ -#!/bin/sh +#!/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 4e8ffaa..0000000 --- a/bin/pbcopy +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -# Backward-compatible macOS-style entry point for the WSL clipboard bridge. -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -exec "$script_dir/wsl-pbcopy" "$@" diff --git a/bin/pbpaste b/bin/pbpaste deleted file mode 100755 index 0ac0396..0000000 --- a/bin/pbpaste +++ /dev/null @@ -1,4 +0,0 @@ -#!/bin/sh -# Backward-compatible macOS-style entry point for the WSL clipboard bridge. -script_dir=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd) -exec "$script_dir/wsl-pbpaste" "$@" diff --git a/bin/wsl-pbcopy b/bin/wsl-pbcopy deleted file mode 100755 index 926ed54..0000000 --- a/bin/wsl-pbcopy +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Copy UTF-8 stdin through the persistent WSL clipboard bridge. -if [ "$#" -ne 0 ]; then - echo "usage: wsl-pbcopy < stdin" >&2 - exit 2 -fi - -clipboard_bin=${WSL_CLIPBOARD_BIN:-"$HOME/.local/bin/wsl-clipboard"} -if [ ! -x "$clipboard_bin" ]; then - echo "wsl-pbcopy: bridge is not installed; run: just clipboard-install" >&2 - exit 127 -fi - -exec "$clipboard_bin" copy diff --git a/bin/wsl-pbpaste b/bin/wsl-pbpaste deleted file mode 100755 index be5efad..0000000 --- a/bin/wsl-pbpaste +++ /dev/null @@ -1,14 +0,0 @@ -#!/bin/sh -# Paste UTF-8 text through the persistent WSL clipboard bridge. -if [ "$#" -ne 0 ]; then - echo "usage: wsl-pbpaste" >&2 - exit 2 -fi - -clipboard_bin=${WSL_CLIPBOARD_BIN:-"$HOME/.local/bin/wsl-clipboard"} -if [ ! -x "$clipboard_bin" ]; then - echo "wsl-pbpaste: bridge is not installed; run: just clipboard-install" >&2 - exit 127 -fi - -exec "$clipboard_bin" paste diff --git a/clipboard/src/main.rs b/clipboard/src/main.rs index 03e2fc2..af0238a 100644 --- a/clipboard/src/main.rs +++ b/clipboard/src/main.rs @@ -6,7 +6,7 @@ use std::{ fs::PermissionsExt, net::{UnixListener, UnixStream}, }, - path::PathBuf, + path::{Path, PathBuf}, process::{Child, ChildStdin, ChildStdout, Command, Stdio}, sync::{ Arc, Mutex, @@ -163,7 +163,9 @@ fn main() { } fn run() -> io::Result<()> { - let command = env::args().nth(1).unwrap_or_else(|| "help".to_owned()); + 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" => { @@ -195,6 +197,22 @@ fn run() -> io::Result<()> { } } +/// 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( @@ -504,6 +522,7 @@ fn validate_utf8_input(bytes: &[u8]) -> io::Result<()> { fn print_usage() { println!("Usage: wsl-clipboard "); + println!("Aliases: pbcopy, pbpaste, wsl-pbcopy, wsl-pbpaste"); } #[cfg(test)] @@ -549,4 +568,29 @@ mod tests { 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 index 7f65faf..8442c8b 100644 --- a/clipboard/tests/clipboard.rs +++ b/clipboard/tests/clipboard.rs @@ -10,18 +10,12 @@ const LEGACY_PBCOPY: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/legacy-pbcopy"); const LEGACY_PBPASTE: &str = concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/legacy-pbpaste"); -const WSL_PBCOPY: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/wsl-pbcopy"); -const WSL_PBPASTE: &str = - concat!(env!("CARGO_MANIFEST_DIR"), "/../bin/wsl-pbpaste"); - struct ClipboardCli { name: &'static str, copy_executable: &'static str, copy_args: &'static [&'static str], paste_executable: &'static str, paste_args: &'static [&'static str], - bridge_binary: Option<&'static str>, } fn binary() -> &'static str { @@ -35,7 +29,6 @@ fn legacy_clipboard() -> ClipboardCli { copy_args: &[], paste_executable: LEGACY_PBPASTE, paste_args: &[], - bridge_binary: None, } } @@ -46,18 +39,6 @@ fn rust_clipboard() -> ClipboardCli { copy_args: &["copy"], paste_executable: binary(), paste_args: &["paste"], - bridge_binary: None, - } -} - -fn shim_clipboard() -> ClipboardCli { - ClipboardCli { - name: "wsl-pbcopy/wsl-pbpaste shims", - copy_executable: WSL_PBCOPY, - copy_args: &[], - paste_executable: WSL_PBPASTE, - paste_args: &[], - bridge_binary: Some(binary()), } } @@ -72,12 +53,7 @@ fn command_available(executable: &str) -> bool { .is_ok_and(|status| status.success()) } -fn run_command( - executable: &str, - args: &[&str], - input: Option<&[u8]>, - bridge_binary: Option<&str>, -) -> Output { +fn run_command(executable: &str, args: &[&str], input: Option<&[u8]>) -> Output { let mut command = Command::new(executable); command .args(args) @@ -88,9 +64,6 @@ fn run_command( }) .stdout(Stdio::piped()) .stderr(Stdio::piped()); - if let Some(bridge_binary) = bridge_binary { - command.env("WSL_CLIPBOARD_BIN", bridge_binary); - } let mut child = command.spawn().unwrap(); if let Some(input) = input { let mut stdin = child.stdin.take().unwrap(); @@ -100,12 +73,7 @@ fn run_command( } fn copy(cli: &ClipboardCli, input: &[u8]) { - let output = run_command( - cli.copy_executable, - cli.copy_args, - Some(input), - cli.bridge_binary, - ); + let output = run_command(cli.copy_executable, cli.copy_args, Some(input)); assert!( output.status.success(), "{} copy failed: {}", @@ -117,12 +85,7 @@ fn copy(cli: &ClipboardCli, input: &[u8]) { } fn paste(cli: &ClipboardCli) -> Vec { - let output = run_command( - cli.paste_executable, - cli.paste_args, - None, - cli.bridge_binary, - ); + let output = run_command(cli.paste_executable, cli.paste_args, None); assert!( output.status.success(), "{} paste failed: {}", @@ -134,7 +97,7 @@ fn paste(cli: &ClipboardCli) -> Vec { } fn run_rust_command(args: &[&str]) -> Output { - run_command(binary(), args, None, None) + run_command(binary(), args, None) } fn stop_daemon() { @@ -166,7 +129,6 @@ fn assert_clipboard_case(input: &str) { let expected = input.as_bytes(); let legacy = legacy_clipboard(); let rust = rust_clipboard(); - let shims = shim_clipboard(); copy(&legacy, expected); let legacy_output = paste(&legacy); @@ -182,27 +144,17 @@ fn assert_clipboard_case(input: &str) { "Rust output differed from expectation" ); - copy(&shims, expected); - let shim_output = paste(&shims); - assert_eq!( - shim_output, expected, - "shim output differed from expectation" - ); assert_eq!( rust_output, legacy_output, "Rust output differed from legacy" ); - assert_eq!( - shim_output, legacy_output, - "shim 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_unicode_and_shim_compatibility() { +fn preserves_legacy_cases_and_unicode() { if !require_clipboard_prerequisites() { return; } @@ -226,13 +178,13 @@ fn preserves_legacy_cases_unicode_and_shim_compatibility() { } let expected = "line one\n日本語\n\n"; - let shims = shim_clipboard(); - copy(&shims, expected.as_bytes()); - assert_eq!(paste(&shims), expected.as_bytes()); + 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(&shims), expected.as_bytes()); + assert_eq!(paste(&rust), expected.as_bytes()); stop_daemon(); } @@ -246,7 +198,7 @@ fn utf16le_to_utf8_preserves_unicode_scalars() { #[test] fn copy_rejects_invalid_utf8_before_starting_daemon() { stop_daemon(); - let output = run_command(binary(), &["copy"], Some(&[0xff]), None); + 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/justfile b/justfile index 7fa29c3..34b3178 100644 --- a/justfile +++ b/justfile @@ -26,6 +26,9 @@ 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: diff --git a/zsh/clipboard.bench.ts b/zsh/clipboard.bench.ts index 6b25e54..48377cb 100644 --- a/zsh/clipboard.bench.ts +++ b/zsh/clipboard.bench.ts @@ -306,14 +306,14 @@ const printResults = (results: TimingSummary[]): void => { const legacyCopy = byLabel.get("legacy-pbcopy") const clip = byLabel.get("clip.exe direct") const legacyPaste = byLabel.get("legacy-pbpaste") - const bridgeCopy = byLabel.get("pbcopy (persistent)") - const bridgePaste = byLabel.get("pbpaste (persistent)") + 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 (persistent)") + const bridgeRoundTrip = byLabel.get("pbcopy + pbpaste (symlink)") if ( legacyCopy && @@ -428,19 +428,19 @@ const main = async (): Promise => { }, }, { - label: "pbcopy (persistent)", + label: "pbcopy (symlink)", operation: async () => { await runCommand(["pbcopy"], payload) }, }, { - label: "pbpaste (persistent)", + label: "pbpaste (symlink)", operation: async () => { await runCommand(["pbpaste"]) }, }, { - label: "pbcopy + pbpaste (persistent)", + label: "pbcopy + pbpaste (symlink)", operation: async () => { await runCommand(["pbcopy"], payload) const pasted = await runCommand(["pbpaste"]) diff --git a/zsh/clipboard.test.ts b/zsh/clipboard.test.ts index d4bcc27..b66a800 100644 --- a/zsh/clipboard.test.ts +++ b/zsh/clipboard.test.ts @@ -23,7 +23,7 @@ clipboardTest("commands present: pbcopy, pbpaste", async () => { expect(out.stderr).toBeEmpty() }) -clipboardTest("explicit WSL clipboard shims are present", async () => { +clipboardTest("explicit WSL clipboard aliases are present", async () => { let out = await bash(`which wsl-pbcopy`) expect(out.stdout).not.toBeEmpty() expect(out.stderr).toBeEmpty()