M0: compilable skeleton — Kigi 0.1.0 fork surgery
Hard fork of xai-org/grok-build (Apache-2.0) re-targeted as Kigi, an
unofficial Kimi Code CLI community build.
Rename & identity
- 72 xai-*/xai-grok-* crates -> kigi-* (explicit: xai-grok-pager-bin ->
kigi-bin [binary `kigi`], xai-grok-pager -> kigi-tui; rest mechanical);
ptyctl, ptyctl-cli, third_party/ unchanged; proto package
xai.grok.tools.v1 -> kigi.tools.v1
- Config home ~/.kigi (KIGI_SHARE_DIR override), env prefix GROK_* ->
KIGI_*, `kigi --version` carries the unofficial-community-build notice
- clap identity, help text, startup banner, prompt templates rebranded
(templates re-encrypted)
Deletions (PRD removal list #5/#6/#7/#9/#10)
- voice input (xai-grok-voice) and all TUI wiring
- telemetry: Mixpanel client, external OTel stream, Sentry, OTLP layers,
trace/GCS/S3 upload queues (kigi-file-utils halved), workspace upload
module & dc_log, heap-profile uploader, auth-diagnostics uploader,
session-analytics halves of feedback; local zero-egress observability
preserved in new kigi-log crate (unified log, --debug firehose,
subsystem file logs, opt-in instrumentation)
- announcements (crate, remote-settings fields, TUI surfaces)
- plugin marketplace (crate, sources/browse/CTA/extensions-modal tab);
direct plugin install/uninstall/update via kigi-agent git_install kept
- relay/gateway/assets endpoints and features (agent relay, headless
relay transport, gateway bridge, LeaderEnvUrls); leader IPC socket now
~/.kigi/leader.sock + KIGI_LEADER_SOCKET, no ws-url derivation
- functional types rehomed instead of deleted: PermissionMode ->
kigi-config-types, McpInitStrategy -> kigi-mcp, PrCreationSource ->
session signals, TerminalDiagnostics -> kigi-pager-render, agent_id ->
shell util
Endpoints
- kigi-env rewritten: single production KigiEndpoints {coding_api_base_url
https://api.kimi.com/coding/v1 (KIGI_CODE_BASE_URL), oauth_host
https://auth.kimi.com (KIGI_OAUTH_HOST), update_base_url (GitHub
Releases API), upgrade_page_url}; GrokBuildEnvironment enum deleted
Toolchain & workspace hygiene
- Rust 1.97.0 pinned; edition 2024; full cargo update; git2 hoisted to
workspace at 0.21 (Option->Result API migration), quick-xml 0.41
- Root Cargo.toml hand-maintained (PRD §8.1): version 0.1.0 inherited by
all members, members sorted, unused deps pruned
- cargo-deny advisories gate (deny.toml with documented transitive
exceptions); CI workflow (check/clippy/fmt/deny/test, macOS+Linux)
- cross-crate test seams re-gated behind `test-support` cargo feature;
insta snapshot baselines renamed to the kigi_tui prefix
- clippy --workspace --all-targets: zero warnings; fmt clean
Fixes surfaced by the port
- updater probe/installer divergence (bin/kigi vs bin/grok symlink set)
- idle model-metadata refresh dead under KIGI_CODE_BASE_URL override
(new is_effective_coding_endpoint_url, loopback+override aware)
- macOS symlinked-TMPDIR fixture canonicalization (foreign_sessions,
fast-worktree); RSS measurement tests serialized via serial_test
Docs & legal (Apache §4)
- NOTICE added (upstream attribution + change statement); THIRD-PARTY
notices sustained; kigi-tools ported-code notices extended; README,
CONTRIBUTING, SECURITY, AGENTS.md rewritten
Out of scope for M0 (tracked): Kimi auth/inference (M1), search/fetch,
command parity, config import (M2), Computer Hub excision & final
brand-token sweep (M2), distribution & self-update rewrite (M3).
This commit is contained in:
commit
d6c20fc13f
2612 files changed
+1353757
No files matched your search
@@ -0,0 +1,850 @@
|
||||
//! Local PTY wrapper with OSC 52 clipboard interception and host image paste.
|
||||
//!
|
||||
//! Spawns a command inside a local pseudo-terminal, intercepts the OSC 52
|
||||
//! clipboard escape sequences it emits, and writes their payload to the local
|
||||
//! system clipboard. This is the engine behind `grok wrap` (see
|
||||
//! [`crate::wrap_cmd`]); it makes clipboard "copy" work for programs running
|
||||
//! somewhere that cannot reach the user's clipboard (containers, SSH) even when
|
||||
//! the outer terminal does not handle OSC 52 itself (for example Apple
|
||||
//! Terminal).
|
||||
//!
|
||||
//! Also consumes a private remote request OSC for host clipboard images and
|
||||
//! injects a bracketed-paste response on PTY stdin (see
|
||||
//! [`crate::wrap_clipboard_image`]). Trust model for auto-answering that OSC
|
||||
//! (any PTY emitter can solicit the host pasteboard) is documented there.
|
||||
|
||||
use anyhow::Result;
|
||||
use base64::Engine as _;
|
||||
use std::io::Write;
|
||||
|
||||
/// Maximum size for a buffered escape sequence candidate (1 MiB).
|
||||
///
|
||||
/// This bounds the memory used while accumulating a candidate OSC 52 or DCS
|
||||
/// sequence. Must be large enough to hold the base64-encoded form of
|
||||
/// `MAX_CLIPBOARD_PAYLOAD` (~1.33x expansion) plus the escape envelope.
|
||||
const MAX_ESC_BUFFER: usize = 1024 * 1024;
|
||||
|
||||
/// Maximum decoded clipboard payload size (768 KiB).
|
||||
///
|
||||
/// Aligned with `MAX_ESC_BUFFER`: a 768 KiB payload encodes to ~1 MiB of
|
||||
/// base64, fitting within the buffer limit. Payloads larger than this are
|
||||
/// unrealistic for clipboard content over SSH.
|
||||
const MAX_CLIPBOARD_PAYLOAD: usize = 768 * 1024;
|
||||
|
||||
/// The prefix that identifies an OSC 52 sequence after the `ESC ]`.
|
||||
const OSC52_PREFIX: &[u8] = b"52;";
|
||||
|
||||
/// The tmux DCS passthrough prefix after `ESC P`: `tmux;\x1b\x1b]`.
|
||||
const TMUX_DCS_PREFIX: &[u8] = b"tmux;\x1b\x1b]";
|
||||
|
||||
/// Base64 engine that accepts both padded and unpadded input.
|
||||
///
|
||||
/// OSC 52 emitters in the wild (including some Go-based tools and terminals)
|
||||
/// may omit `=` padding. Using `Indifferent` mode avoids silent decode
|
||||
/// failures from legitimate clipboard sequences.
|
||||
const BASE64_STANDARD_INDIFFERENT: base64::engine::GeneralPurpose =
|
||||
base64::engine::GeneralPurpose::new(
|
||||
&base64::alphabet::STANDARD,
|
||||
base64::engine::GeneralPurposeConfig::new()
|
||||
.with_decode_padding_mode(base64::engine::DecodePaddingMode::Indifferent),
|
||||
);
|
||||
|
||||
/// Run an arbitrary command inside a local PTY with OSC 52 output filtering.
|
||||
///
|
||||
/// This is the engine behind `grok wrap`: it spawns
|
||||
/// `program` (with `args`) attached to a local pseudo-terminal, forwards the
|
||||
/// outer terminal's size changes to it, and filters its output through
|
||||
/// [`Osc52Filter`], which intercepts OSC 52 clipboard sequences and writes
|
||||
/// their payload to the local system clipboard. All other output passes
|
||||
/// through unchanged.
|
||||
///
|
||||
/// Returns the child exit code on success.
|
||||
pub(crate) fn run_wrapped_command(program: &str, args: &[String]) -> Result<i32> {
|
||||
use portable_pty::{CommandBuilder, PtySize, native_pty_system};
|
||||
use std::io::Read;
|
||||
|
||||
// Get current terminal size.
|
||||
let (cols, rows) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
|
||||
// Open PTY pair.
|
||||
let pty_system = native_pty_system();
|
||||
let pair = pty_system.openpty(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
})?;
|
||||
|
||||
// Build the command.
|
||||
let mut cmd = CommandBuilder::new(program);
|
||||
args.iter().for_each(|arg| cmd.arg(arg));
|
||||
|
||||
// Advertise to the wrapped program — and anything it spawns, e.g. a remote
|
||||
// `grok` reached over SSH — that its OSC 52 clipboard writes are being
|
||||
// intercepted here and copied to the real local clipboard. The inner grok
|
||||
// reads this (see `kigi_pager_render::clipboard::osc52_sink_active`) to
|
||||
// *trust* OSC 52 even when it can't detect an OSC-52-capable terminal,
|
||||
// which is the usual SSH case (only `TERM` propagates, so Apple Terminal /
|
||||
// unknown brands look incapable and the inner grok would otherwise report
|
||||
// "Copy failed" despite the copy actually working).
|
||||
//
|
||||
// `CommandBuilder::new` inherits the full parent environment; `env` overlays
|
||||
// these two without clearing it. The canonical `KIGI_OSC52_SINK` is
|
||||
// inherited by local children; the `LC_`-prefixed alias rides the default
|
||||
// OpenSSH env forwarding (`SendEnv LANG LC_*` on the client,
|
||||
// `AcceptEnv LANG LC_*` on the server) so the signal survives the SSH hop.
|
||||
cmd.env("KIGI_OSC52_SINK", "1");
|
||||
cmd.env("LC_KIGI_OSC52_SINK", "1");
|
||||
|
||||
// Spawn child in the PTY slave.
|
||||
let mut child = pair.slave.spawn_command(cmd)?;
|
||||
// Drop the slave so we get EOF when child exits.
|
||||
drop(pair.slave);
|
||||
|
||||
// Obtain reader from the master PTY. All master *writes* (keystrokes +
|
||||
// host-image inject frames) go through one dedicated writer thread via a
|
||||
// channel. Cross-thread use of portable-pty's `Write` impl observed EIO
|
||||
// (errno 5) on macOS even for small inject payloads; confining `write_all`
|
||||
// to a single owner thread avoids that. Handles are intentionally
|
||||
// detached — `grok wrap` is short-lived and exits with the child.
|
||||
let mut pty_reader = pair.master.try_clone_reader()?;
|
||||
|
||||
// NOTE: we intentionally do NOT block SIGWINCH here. The resize handler
|
||||
// (`sigwinch_loop`) installs a real signal handler via `signal-hook`,
|
||||
// which must be free to run when the signal is delivered. Blocking it and
|
||||
// waiting via `sigwait` looks correct but silently fails on macOS (see
|
||||
// `sigwinch_loop`).
|
||||
|
||||
// Switch to raw mode so keystrokes pass through unchanged.
|
||||
crossterm::terminal::enable_raw_mode()?;
|
||||
let _raw_guard = RawModeGuard;
|
||||
|
||||
let (write_tx, write_rx) = std::sync::mpsc::channel::<Vec<u8>>();
|
||||
{
|
||||
let mut writer = pair.master.take_writer()?;
|
||||
std::thread::spawn(move || {
|
||||
while let Ok(bytes) = write_rx.recv() {
|
||||
// Single-thread write_all: no libc chunking. EIO here almost
|
||||
// always means the slave has closed (child exited); stop.
|
||||
if writer
|
||||
.write_all(&bytes)
|
||||
.and_then(|_| writer.flush())
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// Local stdin -> writer thread.
|
||||
let stdin_tx = write_tx.clone();
|
||||
let _stdin_handle = std::thread::spawn(move || {
|
||||
let mut stdin = std::io::stdin().lock();
|
||||
let mut buf = [0u8; 4096];
|
||||
loop {
|
||||
match stdin.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
if stdin_tx.send(buf[..n].to_vec()).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// SIGWINCH handling thread: resize the PTY when the outer terminal changes size.
|
||||
// We move `pair.master` here since we've already cloned the reader and
|
||||
// taken the writer above.
|
||||
//
|
||||
// Unix only: there is no SIGWINCH on Windows. On Windows `pair.master` is
|
||||
// instead kept alive inside `pair` until this function returns (after
|
||||
// `child.wait`), so the ConPTY stays open for the read loop — the OSC 52
|
||||
// clipboard bridge works identically, only live resize is unavailable.
|
||||
#[cfg(unix)]
|
||||
{
|
||||
let master = pair.master;
|
||||
std::thread::spawn(move || {
|
||||
sigwinch_loop(master);
|
||||
});
|
||||
}
|
||||
|
||||
// Output forwarding with OSC 52 filtering: PTY reader -> filter -> stdout.
|
||||
// Host-image requests: spawn a short-lived worker for clipboard I/O (can be
|
||||
// hundreds of ms / osascript) then enqueue the bracketed-paste frame on the
|
||||
// writer thread (+ NL so ICANON slaves deliver without another key). Paste
|
||||
// mashing can spawn multiple workers; fine for a short-lived wrap process.
|
||||
{
|
||||
let mut stdout = std::io::stdout().lock();
|
||||
let mut filter = Osc52Filter::new().with_wrap_image_handler(move || {
|
||||
let tx = write_tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let mut bytes = host_clipboard_image_frame();
|
||||
bytes.push(b'\n');
|
||||
let _ = tx.send(bytes);
|
||||
});
|
||||
});
|
||||
let mut buf = [0u8; 8192];
|
||||
loop {
|
||||
match pty_reader.read(&mut buf) {
|
||||
Ok(0) | Err(_) => break,
|
||||
Ok(n) => {
|
||||
let filtered = filter.feed(&buf[..n]);
|
||||
if !filtered.is_empty() {
|
||||
if stdout.write_all(&filtered).is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = stdout.flush();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for child and extract exit code.
|
||||
let status = child.wait()?;
|
||||
let code = status.exit_code() as i32;
|
||||
|
||||
Ok(code)
|
||||
}
|
||||
|
||||
/// Wait for `SIGWINCH` and resize the PTY master to match the outer terminal.
|
||||
///
|
||||
/// Uses `signal-hook` to install a real signal handler (self-pipe based).
|
||||
///
|
||||
/// A previous implementation blocked SIGWINCH and dequeued it with `nix`'s
|
||||
/// `sigwait`. That pattern is POSIX-correct but **silently fails on macOS**:
|
||||
/// SIGWINCH's default disposition is "ignore", and macOS discards a blocked
|
||||
/// default-ignore signal rather than leaving it pending for `sigwait`. The
|
||||
/// handler never woke, so the inner PTY was never resized and the remote TUI
|
||||
/// kept rendering at the original size — producing overlapping/stale frames
|
||||
/// after the user resized their terminal. Installing an actual handler
|
||||
/// overrides the default-ignore disposition, which is what makes it work.
|
||||
#[cfg(unix)]
|
||||
fn sigwinch_loop(master: Box<dyn portable_pty::MasterPty + Send>) {
|
||||
use portable_pty::PtySize;
|
||||
|
||||
let mut signals = match signal_hook::iterator::Signals::new([signal_hook::consts::SIGWINCH]) {
|
||||
Ok(signals) => signals,
|
||||
Err(e) => {
|
||||
tracing::debug!("failed to install SIGWINCH handler: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
for _ in signals.forever() {
|
||||
if let Ok((cols, rows)) = crossterm::terminal::size() {
|
||||
let _ = master.resize(PtySize {
|
||||
rows,
|
||||
cols,
|
||||
pixel_width: 0,
|
||||
pixel_height: 0,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Guard that restores terminal state when dropped (including on panic).
|
||||
struct RawModeGuard;
|
||||
|
||||
impl Drop for RawModeGuard {
|
||||
fn drop(&mut self) {
|
||||
let _ = crossterm::terminal::disable_raw_mode();
|
||||
}
|
||||
}
|
||||
|
||||
/// State machine states for the OSC 52 streaming parser.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
enum FilterState {
|
||||
/// Normal output passthrough.
|
||||
Normal,
|
||||
/// Saw ESC (0x1b), waiting for next byte to determine sequence type.
|
||||
Esc,
|
||||
/// Inside OSC: saw `ESC ]` -- accumulating until BEL or ST.
|
||||
Osc,
|
||||
/// Inside DCS: saw `ESC P` -- checking for tmux passthrough prefix.
|
||||
Dcs,
|
||||
/// Inside DCS tmux passthrough, accumulating inner OSC 52.
|
||||
DcsTmuxOsc,
|
||||
/// Saw ESC inside an OSC, could be ST terminator (`ESC \`).
|
||||
OscEsc,
|
||||
/// Saw ESC inside a DCS tmux OSC, could be inner ST or DCS ST.
|
||||
DcsTmuxOscEsc,
|
||||
}
|
||||
|
||||
/// Streaming filter that intercepts OSC 52 clipboard sequences from PTY
|
||||
/// output and sends their decoded payload to the local clipboard.
|
||||
///
|
||||
/// All non-OSC-52 bytes pass through unchanged. The parser handles sequences
|
||||
/// split across arbitrary byte boundaries.
|
||||
/// Clipboard sink type: a boxed closure that receives decoded clipboard data.
|
||||
type ClipboardSink = Box<dyn FnMut(&[u8])>;
|
||||
|
||||
type WrapImageRequestHandler = Box<dyn FnMut()>;
|
||||
|
||||
struct Osc52Filter {
|
||||
state: FilterState,
|
||||
buf: Vec<u8>,
|
||||
clipboard_sink: ClipboardSink,
|
||||
wrap_image_handler: Option<WrapImageRequestHandler>,
|
||||
}
|
||||
|
||||
impl Osc52Filter {
|
||||
/// Create a new filter that sends clipboard data to the system clipboard.
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
state: FilterState::Normal,
|
||||
buf: Vec::new(),
|
||||
clipboard_sink: Box::new(set_local_clipboard),
|
||||
wrap_image_handler: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn with_wrap_image_handler(mut self, handler: impl FnMut() + 'static) -> Self {
|
||||
self.wrap_image_handler = Some(Box::new(handler));
|
||||
self
|
||||
}
|
||||
|
||||
/// Create a filter with a custom clipboard sink (for testing).
|
||||
#[cfg(test)]
|
||||
fn with_sink(sink: impl FnMut(&[u8]) + 'static) -> Self {
|
||||
Self {
|
||||
state: FilterState::Normal,
|
||||
buf: Vec::new(),
|
||||
clipboard_sink: Box::new(sink),
|
||||
wrap_image_handler: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Process a chunk of bytes from PTY output.
|
||||
///
|
||||
/// Returns bytes that should be written to stdout. OSC 52 clipboard
|
||||
/// sequences are consumed (not included in the output) and their decoded
|
||||
/// payload is sent to the clipboard sink.
|
||||
fn feed(&mut self, data: &[u8]) -> Vec<u8> {
|
||||
let mut output = Vec::with_capacity(data.len());
|
||||
for &byte in data {
|
||||
match self.state {
|
||||
FilterState::Normal => {
|
||||
if byte == 0x1b {
|
||||
self.state = FilterState::Esc;
|
||||
self.buf.clear();
|
||||
self.buf.push(byte);
|
||||
} else {
|
||||
output.push(byte);
|
||||
}
|
||||
}
|
||||
FilterState::Esc => {
|
||||
self.buf.push(byte);
|
||||
match byte {
|
||||
b']' => self.state = FilterState::Osc,
|
||||
b'P' => self.state = FilterState::Dcs,
|
||||
_ => {
|
||||
// Not an OSC or DCS -- flush buffer and continue.
|
||||
output.extend_from_slice(&self.buf);
|
||||
self.buf.clear();
|
||||
self.state = FilterState::Normal;
|
||||
}
|
||||
}
|
||||
}
|
||||
FilterState::Osc => {
|
||||
self.buf.push(byte);
|
||||
match byte {
|
||||
// BEL terminates the OSC sequence.
|
||||
0x07 => {
|
||||
if !self.try_handle_consumed_osc() {
|
||||
output.extend_from_slice(&self.buf);
|
||||
}
|
||||
self.buf.clear();
|
||||
self.state = FilterState::Normal;
|
||||
}
|
||||
// ESC could be the start of ST (ESC \).
|
||||
0x1b => {
|
||||
self.state = FilterState::OscEsc;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
FilterState::OscEsc => {
|
||||
self.buf.push(byte);
|
||||
if byte == b'\\' {
|
||||
// ST terminator: ESC \.
|
||||
if !self.try_handle_consumed_osc() {
|
||||
output.extend_from_slice(&self.buf);
|
||||
}
|
||||
self.buf.clear();
|
||||
self.state = FilterState::Normal;
|
||||
} else {
|
||||
// Not ST -- continue accumulating in Osc state.
|
||||
// The ESC we saw might be part of the payload in some
|
||||
// broken sequence; just keep buffering.
|
||||
self.state = FilterState::Osc;
|
||||
}
|
||||
}
|
||||
FilterState::Dcs => {
|
||||
self.buf.push(byte);
|
||||
// buf starts with \x1bP so tmux prefix bytes start at offset 2.
|
||||
let prefix_pos = self.buf.len() - 2;
|
||||
if prefix_pos <= TMUX_DCS_PREFIX.len() {
|
||||
if TMUX_DCS_PREFIX[prefix_pos - 1] == byte {
|
||||
if prefix_pos == TMUX_DCS_PREFIX.len() {
|
||||
// Full tmux prefix matched: \x1bPtmux;\x1b\x1b]
|
||||
self.state = FilterState::DcsTmuxOsc;
|
||||
}
|
||||
// else keep matching prefix
|
||||
} else {
|
||||
// Prefix mismatch: not a tmux passthrough, flush.
|
||||
output.extend_from_slice(&self.buf);
|
||||
self.buf.clear();
|
||||
self.state = FilterState::Normal;
|
||||
}
|
||||
} else {
|
||||
// Exceeded prefix length without matching; flush.
|
||||
output.extend_from_slice(&self.buf);
|
||||
self.buf.clear();
|
||||
self.state = FilterState::Normal;
|
||||
}
|
||||
}
|
||||
FilterState::DcsTmuxOsc => {
|
||||
self.buf.push(byte);
|
||||
match byte {
|
||||
// BEL terminates the inner OSC.
|
||||
0x07 => {
|
||||
// Inner OSC is done but we still need DCS ST
|
||||
// (ESC \) to close the tmux wrapper.
|
||||
// Remain in this state to catch the ESC.
|
||||
}
|
||||
0x1b => {
|
||||
self.state = FilterState::DcsTmuxOscEsc;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
FilterState::DcsTmuxOscEsc => {
|
||||
self.buf.push(byte);
|
||||
if byte == b'\\' {
|
||||
// DCS ST: ESC \. The full tmux-wrapped sequence is done.
|
||||
if !self.try_handle_tmux_osc52() {
|
||||
output.extend_from_slice(&self.buf);
|
||||
}
|
||||
self.buf.clear();
|
||||
self.state = FilterState::Normal;
|
||||
} else {
|
||||
// Not ST. Continue accumulating in DcsTmuxOsc.
|
||||
self.state = FilterState::DcsTmuxOsc;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Guard: if the buffer grows beyond the limit, flush and reset.
|
||||
if self.buf.len() > MAX_ESC_BUFFER {
|
||||
output.extend_from_slice(&self.buf);
|
||||
self.buf.clear();
|
||||
self.state = FilterState::Normal;
|
||||
}
|
||||
}
|
||||
output
|
||||
}
|
||||
|
||||
/// Handle OSC 52 clipboard or wrap image request; `true` if consumed.
|
||||
fn try_handle_consumed_osc(&mut self) -> bool {
|
||||
let body = self.buf[2..].to_vec();
|
||||
let body = strip_osc_terminator(&body);
|
||||
if self.try_handle_wrap_image_request(body) {
|
||||
return true;
|
||||
}
|
||||
self.extract_and_set_clipboard(body)
|
||||
}
|
||||
|
||||
fn try_handle_wrap_image_request(&mut self, body: &[u8]) -> bool {
|
||||
if body != crate::wrap_clipboard_image::REQUEST_BODY {
|
||||
return false;
|
||||
}
|
||||
if let Some(handler) = self.wrap_image_handler.as_mut() {
|
||||
handler();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
/// Try to handle the buffered bytes as a tmux-wrapped OSC 52 sequence.
|
||||
///
|
||||
/// Expected buffer format:
|
||||
/// `\x1bPtmux;\x1b\x1b]52;<sel>;<base64>\x07\x1b\\`
|
||||
///
|
||||
/// Returns `true` if the sequence was a valid OSC 52 and was consumed.
|
||||
fn try_handle_tmux_osc52(&mut self) -> bool {
|
||||
// Strip the DCS tmux prefix: \x1bPtmux;\x1b\x1b] (total 9 bytes)
|
||||
// and the DCS ST terminator: \x1b\ (2 bytes at the end).
|
||||
// Copy the body to avoid borrowing self.buf while calling &mut self.
|
||||
let prefix_len = 2 + TMUX_DCS_PREFIX.len(); // \x1bP + tmux;\x1b\x1b]
|
||||
if self.buf.len() < prefix_len + 2 {
|
||||
return false;
|
||||
}
|
||||
let body = self.buf[prefix_len..self.buf.len() - 2].to_vec(); // strip DCS ST
|
||||
let body = strip_osc_terminator(&body); // strip inner BEL if present
|
||||
self.extract_and_set_clipboard(body)
|
||||
}
|
||||
|
||||
/// Parse OSC 52 body (`52;<sel>;<base64>`), decode, and set clipboard.
|
||||
///
|
||||
/// Returns `true` if successfully handled.
|
||||
fn extract_and_set_clipboard(&mut self, body: &[u8]) -> bool {
|
||||
// Must start with "52;"
|
||||
if !body.starts_with(OSC52_PREFIX) {
|
||||
return false;
|
||||
}
|
||||
let after_52 = &body[OSC52_PREFIX.len()..];
|
||||
|
||||
// Find the selection parameter separator (next ';').
|
||||
let payload_start = match after_52.iter().position(|&b| b == b';') {
|
||||
Some(pos) => pos + 1,
|
||||
None => return false,
|
||||
};
|
||||
let b64_payload = &after_52[payload_start..];
|
||||
|
||||
// Decode base64.
|
||||
let decoded = match BASE64_STANDARD_INDIFFERENT.decode(b64_payload) {
|
||||
Ok(data) => data,
|
||||
Err(_) => return false,
|
||||
};
|
||||
|
||||
// Check payload size limit.
|
||||
if decoded.len() > MAX_CLIPBOARD_PAYLOAD {
|
||||
tracing::warn!(
|
||||
"OSC 52 payload too large ({} bytes), ignoring",
|
||||
decoded.len()
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
(self.clipboard_sink)(&decoded);
|
||||
true
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the OSC terminator from the end of a body slice.
|
||||
///
|
||||
/// Removes trailing BEL (`\x07`) or ST (`\x1b\x5c`) if present.
|
||||
fn strip_osc_terminator(body: &[u8]) -> &[u8] {
|
||||
if body.ends_with(&[0x1b, b'\\']) {
|
||||
&body[..body.len() - 2]
|
||||
} else if body.ends_with(&[0x07]) {
|
||||
&body[..body.len() - 1]
|
||||
} else {
|
||||
body
|
||||
}
|
||||
}
|
||||
|
||||
/// Write decoded clipboard payload to the local system clipboard.
|
||||
///
|
||||
/// Delegates to [`kigi_shell::util::clipboard::set_text`] which uses
|
||||
/// `pbcopy` on macOS and `arboard` elsewhere. Failures are logged but do
|
||||
/// not propagate -- clipboard access is best-effort.
|
||||
fn set_local_clipboard(data: &[u8]) {
|
||||
let text = match std::str::from_utf8(data) {
|
||||
Ok(s) => s,
|
||||
Err(e) => {
|
||||
tracing::warn!("OSC 52 payload is not valid UTF-8: {e}");
|
||||
return;
|
||||
}
|
||||
};
|
||||
if let Err(e) = kigi_shell::util::clipboard::set_text(text) {
|
||||
tracing::warn!("clipboard copy failed: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
/// Encode a host clipboard image (or NONE) as a bracketed-paste frame.
|
||||
fn host_clipboard_image_frame() -> Vec<u8> {
|
||||
let image = kigi_pager_render::clipboard::system_clipboard_get_image();
|
||||
crate::wrap_clipboard_image::encode_wrap_image_response(image.as_ref())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
|
||||
/// Helper: run data through the filter with a capturing clipboard sink.
|
||||
/// Returns (stdout_output, captured_clipboard_payloads).
|
||||
fn filter_output(input: &[u8]) -> (Vec<u8>, Vec<Vec<u8>>) {
|
||||
let clips = Rc::new(RefCell::new(Vec::new()));
|
||||
let clips_clone = Rc::clone(&clips);
|
||||
let mut filter = Osc52Filter::with_sink(move |data: &[u8]| {
|
||||
clips_clone.borrow_mut().push(data.to_vec());
|
||||
});
|
||||
let output = filter.feed(input);
|
||||
let captured = clips.borrow().clone();
|
||||
(output, captured)
|
||||
}
|
||||
|
||||
/// Helper: run data through the filter in multiple small chunks.
|
||||
fn filter_output_chunked(input: &[u8], chunk_size: usize) -> (Vec<u8>, Vec<Vec<u8>>) {
|
||||
let clips = Rc::new(RefCell::new(Vec::new()));
|
||||
let clips_clone = Rc::clone(&clips);
|
||||
let mut filter = Osc52Filter::with_sink(move |data: &[u8]| {
|
||||
clips_clone.borrow_mut().push(data.to_vec());
|
||||
});
|
||||
let mut output = Vec::new();
|
||||
for chunk in input.chunks(chunk_size) {
|
||||
output.extend_from_slice(&filter.feed(chunk));
|
||||
}
|
||||
let captured = clips.borrow().clone();
|
||||
(output, captured)
|
||||
}
|
||||
|
||||
/// Encode text as a plain OSC 52 sequence with BEL terminator.
|
||||
fn make_osc52_bel(text: &str) -> Vec<u8> {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||
format!("\x1b]52;c;{b64}\x07").into_bytes()
|
||||
}
|
||||
|
||||
/// Encode text as a plain OSC 52 sequence with ST terminator.
|
||||
fn make_osc52_st(text: &str) -> Vec<u8> {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||
format!("\x1b]52;c;{b64}\x1b\\").into_bytes()
|
||||
}
|
||||
|
||||
/// Encode text as a tmux-wrapped OSC 52 sequence.
|
||||
fn make_osc52_tmux(text: &str) -> Vec<u8> {
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(text.as_bytes());
|
||||
format!("\x1bPtmux;\x1b\x1b]52;c;{b64}\x07\x1b\\").into_bytes()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_normal_text_unchanged() {
|
||||
let input = b"Hello, world!\r\n";
|
||||
let (output, clips) = filter_output(input);
|
||||
assert_eq!(output, input);
|
||||
assert!(clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_ansi_escapes_pass_through() {
|
||||
// SGR color: ESC [ 31 m
|
||||
let input = b"\x1b[31mred text\x1b[0m";
|
||||
let (output, clips) = filter_output(input);
|
||||
assert_eq!(output, input.as_slice());
|
||||
assert!(clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_plain_bel_terminated() {
|
||||
let seq = make_osc52_bel("hello");
|
||||
let (output, clips) = filter_output(&seq);
|
||||
assert!(
|
||||
output.is_empty(),
|
||||
"OSC 52 should be consumed, got: {output:?}"
|
||||
);
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_plain_st_terminated() {
|
||||
let seq = make_osc52_st("hello");
|
||||
let (output, clips) = filter_output(&seq);
|
||||
assert!(
|
||||
output.is_empty(),
|
||||
"OSC 52 should be consumed, got: {output:?}"
|
||||
);
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"hello");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_with_s0_selection() {
|
||||
// Selection parameter "s0" instead of "c".
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(b"clipboard data");
|
||||
let seq = format!("\x1b]52;s0;{b64}\x07").into_bytes();
|
||||
let (output, clips) = filter_output(&seq);
|
||||
assert!(output.is_empty());
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"clipboard data");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_tmux_wrapped() {
|
||||
let seq = make_osc52_tmux("hello from tmux");
|
||||
let (output, clips) = filter_output(&seq);
|
||||
assert!(
|
||||
output.is_empty(),
|
||||
"tmux OSC 52 should be consumed, got: {output:?}"
|
||||
);
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"hello from tmux");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_surrounded_by_text() {
|
||||
let mut input = b"before ".to_vec();
|
||||
input.extend_from_slice(&make_osc52_bel("copied"));
|
||||
input.extend_from_slice(b" after");
|
||||
let (output, clips) = filter_output(&input);
|
||||
assert_eq!(output, b"before after");
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"copied");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_multiple_sequences() {
|
||||
let mut input = make_osc52_bel("first");
|
||||
input.extend_from_slice(b"gap");
|
||||
input.extend_from_slice(&make_osc52_st("second"));
|
||||
let (output, clips) = filter_output(&input);
|
||||
assert_eq!(output, b"gap");
|
||||
assert_eq!(clips.len(), 2);
|
||||
assert_eq!(clips[0], b"first");
|
||||
assert_eq!(clips[1], b"second");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_split_across_chunks() {
|
||||
let seq = make_osc52_bel("split test");
|
||||
// Feed one byte at a time.
|
||||
let (output, clips) = filter_output_chunked(&seq, 1);
|
||||
assert!(output.is_empty(), "should be consumed even byte-by-byte");
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"split test");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_split_at_various_sizes() {
|
||||
let seq = make_osc52_st("chunk test");
|
||||
for chunk_size in 2..=seq.len() {
|
||||
let (output, clips) = filter_output_chunked(&seq, chunk_size);
|
||||
assert!(
|
||||
output.is_empty(),
|
||||
"chunk_size={chunk_size}: should be consumed"
|
||||
);
|
||||
assert_eq!(clips.len(), 1, "chunk_size={chunk_size}: expected 1 clip");
|
||||
assert_eq!(clips[0], b"chunk test");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_tmux_split_across_chunks() {
|
||||
let seq = make_osc52_tmux("tmux split");
|
||||
let (output, clips) = filter_output_chunked(&seq, 3);
|
||||
assert!(output.is_empty());
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"tmux split");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_invalid_base64_passes_through() {
|
||||
// Invalid base64 payload: "!!!" is not valid base64.
|
||||
let seq = b"\x1b]52;c;!!!\x07";
|
||||
let (output, clips) = filter_output(seq);
|
||||
assert_eq!(output, seq.as_slice(), "invalid base64 should pass through");
|
||||
assert!(clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_non_52_osc_passes_through() {
|
||||
// OSC 0 (window title) should pass through.
|
||||
let seq = b"\x1b]0;my title\x07";
|
||||
let (output, clips) = filter_output(seq);
|
||||
assert_eq!(output, seq.as_slice());
|
||||
assert!(clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_non_52_osc_st_passes_through() {
|
||||
// OSC 0 with ST terminator.
|
||||
let seq = b"\x1b]0;my title\x1b\\";
|
||||
let (output, clips) = filter_output(seq);
|
||||
assert_eq!(output, seq.as_slice());
|
||||
assert!(clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_oversized_buffer_flushes() {
|
||||
// Build a sequence that exceeds MAX_ESC_BUFFER.
|
||||
let mut seq = b"\x1b]52;c;".to_vec();
|
||||
// Fill with valid base64 chars until we exceed the limit.
|
||||
seq.resize(MAX_ESC_BUFFER + 100, b'A');
|
||||
seq.push(0x07);
|
||||
|
||||
let (output, clips) = filter_output(&seq);
|
||||
// The oversized sequence should have been flushed through.
|
||||
assert!(
|
||||
!output.is_empty(),
|
||||
"oversized sequence should flush through"
|
||||
);
|
||||
assert!(
|
||||
clips.is_empty(),
|
||||
"oversized sequence should not set clipboard"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_empty_payload() {
|
||||
// Empty base64 payload should still work (copies empty string).
|
||||
let seq = b"\x1b]52;c;\x07";
|
||||
let (output, clips) = filter_output(seq);
|
||||
assert!(output.is_empty());
|
||||
assert_eq!(clips.len(), 1);
|
||||
assert_eq!(clips[0], b"");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_non_tmux_dcs_passes_through() {
|
||||
// A DCS that doesn't start with the tmux prefix should flush.
|
||||
let seq = b"\x1bPother;stuff\x1b\\";
|
||||
let (output, clips) = filter_output(seq);
|
||||
// The flush happens when the prefix mismatch is detected.
|
||||
assert!(!output.is_empty(), "non-tmux DCS should pass through");
|
||||
assert!(clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc52_missing_selection_separator() {
|
||||
// No second ';' after "52;" -- missing selection param separator.
|
||||
let b64 = base64::engine::general_purpose::STANDARD.encode(b"data");
|
||||
let seq = format!("\x1b]52;{b64}\x07").into_bytes();
|
||||
// This has "52;" followed by base64 with no second ';'. The parser
|
||||
// will treat everything after "52;" up to the next ';' as the
|
||||
// selection param. If there's no ';', it returns false.
|
||||
let (output, clips) = filter_output(&seq);
|
||||
assert_eq!(output, seq, "should pass through without second ';'");
|
||||
assert!(clips.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_image_request_consumed_and_handler_runs() {
|
||||
let calls = Rc::new(RefCell::new(0usize));
|
||||
let calls_clone = Rc::clone(&calls);
|
||||
let clips = Rc::new(RefCell::new(Vec::new()));
|
||||
let clips_clone = Rc::clone(&clips);
|
||||
let mut filter = Osc52Filter::with_sink(move |data: &[u8]| {
|
||||
clips_clone.borrow_mut().push(data.to_vec());
|
||||
})
|
||||
.with_wrap_image_handler(move || {
|
||||
*calls_clone.borrow_mut() += 1;
|
||||
});
|
||||
let mut input = b"before".to_vec();
|
||||
input.extend_from_slice(&crate::wrap_clipboard_image::request_osc_bytes());
|
||||
input.extend_from_slice(b"after");
|
||||
let output = filter.feed(&input);
|
||||
assert_eq!(output, b"beforeafter");
|
||||
assert_eq!(*calls.borrow(), 1);
|
||||
assert!(clips.borrow().is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wrap_image_request_split_across_chunks() {
|
||||
let calls = Rc::new(RefCell::new(0usize));
|
||||
let calls_clone = Rc::clone(&calls);
|
||||
let mut filter = Osc52Filter::with_sink(|_| {}).with_wrap_image_handler(move || {
|
||||
*calls_clone.borrow_mut() += 1;
|
||||
});
|
||||
let seq = crate::wrap_clipboard_image::request_osc_bytes();
|
||||
let mut output = Vec::new();
|
||||
for chunk in seq.chunks(3) {
|
||||
output.extend_from_slice(&filter.feed(chunk));
|
||||
}
|
||||
assert!(output.is_empty(), "request OSC must be fully consumed");
|
||||
assert_eq!(*calls.borrow(), 1);
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user