diff --git a/src/bindings.rs b/src/bindings.rs index 8e23f05..1d8db98 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -21,6 +21,9 @@ pub enum Action { FontReset, Fullscreen, NewWindow, + JumpPromptUp, + JumpPromptDown, + PipeCommandOutput, } impl Action { @@ -39,6 +42,9 @@ impl Action { "font-reset" => Self::FontReset, "fullscreen" => Self::Fullscreen, "new-window" => Self::NewWindow, + "jump-prompt-up" => Self::JumpPromptUp, + "jump-prompt-down" => Self::JumpPromptDown, + "pipe-command-output" => Self::PipeCommandOutput, _ => return None, }) } @@ -174,6 +180,8 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[ ("Ctrl+0", "font-reset"), ("F11", "fullscreen"), ("Ctrl+Shift+N", "new-window"), + ("Ctrl+Shift+Up", "jump-prompt-up"), + ("Ctrl+Shift+Down", "jump-prompt-down"), ]; /// Map a key token to a keysym: a single character, or a named special key. diff --git a/src/config.rs b/src/config.rs index a56e260..83e4204 100644 --- a/src/config.rs +++ b/src/config.rs @@ -17,6 +17,7 @@ pub struct Config { pub scrollback: Scrollback, pub bell: Bell, pub mouse: Mouse, + pub shell_integration: ShellIntegration, /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults; /// a value of `"none"` unbinds. pub key_bindings: std::collections::HashMap, @@ -63,6 +64,15 @@ impl Default for Mouse { } } +/// `[shell-integration]`: behaviour driven by OSC 7 / OSC 133 marks. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct ShellIntegration { + /// Command the `pipe-command-output` binding feeds the last command's + /// output to on stdin (argv form, e.g. `["less"]`). Empty disables it. + pub pipe_command: Vec, +} + /// `[colors]`: foreground/background, the 16 base palette entries, and accents. /// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries /// keep the built-in default. diff --git a/src/grid.rs b/src/grid.rs index 74ce114..3f4f8b0 100644 --- a/src/grid.rs +++ b/src/grid.rs @@ -139,6 +139,17 @@ struct Cursor { y: usize, } +/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt, +/// the start of typed command input, the start of command output, or the line +/// where the command finished. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub enum PromptKind { + PromptStart, + CmdStart, + OutputStart, + CmdEnd, +} + /// One screen/scrollback row: its cells plus whether it soft-wrapped into the /// next row (autowrap continuation, as opposed to a hard line break). The flag /// is what lets resize rejoin and rewrap paragraphs. @@ -146,6 +157,8 @@ struct Cursor { struct Line { cells: Vec, wrapped: bool, + /// OSC 133 mark attached to this (logical) line, if any. + prompt: Option, } impl Line { @@ -153,6 +166,7 @@ impl Line { Self { cells: vec![Cell::default(); cols], wrapped: false, + prompt: None, } } } @@ -367,7 +381,9 @@ impl Grid { // 1. Rejoin soft-wrapped rows into logical lines. Track which logical // line the cursor falls in and its offset within that line. let mut logicals: Vec> = Vec::new(); + let mut logical_marks: Vec> = Vec::new(); let mut acc: Vec = Vec::new(); + let mut acc_mark: Option = None; let mut cur_logical = 0usize; let mut cur_off = 0usize; for abs in 0..total { @@ -380,13 +396,19 @@ impl Grid { } else { &self.lines[abs - self.scrollback.len()] }; + // The mark on the first physical row of a logical line carries over. + if acc.is_empty() { + acc_mark = line.prompt; + } acc.extend_from_slice(&line.cells); if !line.wrapped { logicals.push(std::mem::take(&mut acc)); + logical_marks.push(acc_mark.take()); } } if !acc.is_empty() { logicals.push(acc); + logical_marks.push(acc_mark); } // Drop trailing all-blank lines (empty screen below the content), but // never above the cursor's line, so the cursor keeps its row. @@ -395,6 +417,7 @@ impl Grid { .rposition(|l| l.iter().any(|c| *c != Cell::default())) .unwrap_or(0); logicals.truncate(last_content.max(cur_logical) + 1); + logical_marks.truncate(last_content.max(cur_logical) + 1); // 2. Rewrap each logical line to the new width, recording where the // cursor lands. Trailing blanks are dropped so a hard line does not @@ -418,6 +441,8 @@ impl Grid { new_lines.push(Line { cells, wrapped: ci + 1 < chunks, + // The mark belongs to the first physical row of the line. + prompt: if ci == 0 { logical_marks[li] } else { None }, }); } if li == cur_logical { @@ -999,6 +1024,81 @@ impl Grid { } } + /// Total rows across scrollback and the live screen. + fn total_lines(&self) -> usize { + self.scrollback.len() + self.lines.len() + } + + /// The OSC 133 mark on an absolute row, if any. + fn abs_prompt(&self, row: usize) -> Option { + if row < self.scrollback.len() { + self.scrollback[row].prompt + } else { + self.lines + .get(row - self.scrollback.len()) + .and_then(|l| l.prompt) + } + } + + // --- shell integration (OSC 133) --- + + /// Attach an OSC 133 prompt mark to the live line under the cursor. + pub fn set_prompt_mark(&mut self, kind: PromptKind) { + let y = self.cursor.y; + if let Some(line) = self.lines.get_mut(y) { + line.prompt = Some(kind); + } + } + + /// Scroll the viewport to the previous (`up`) or next prompt, placing that + /// prompt line at the top of the window. No-op on the alternate screen or + /// when there is no prompt in that direction. + pub fn jump_prompt(&mut self, up: bool) { + if self.alt_saved.is_some() { + return; + } + let top = self.scrollback.len().saturating_sub(self.view_offset); + let total = self.total_lines(); + let is_prompt = |k: Option| k == Some(PromptKind::PromptStart); + let target = if up { + (0..top).rev().find(|&r| is_prompt(self.abs_prompt(r))) + } else { + ((top + 1)..total).find(|&r| is_prompt(self.abs_prompt(r))) + }; + if let Some(t) = target { + let offset = self.scrollback.len() as isize - t as isize; + self.view_offset = offset.clamp(0, self.scrollback.len() as isize) as usize; + } + } + + /// Text of the most recent command's output: the rows from the last + /// output-start (OSC 133 C) up to the command-end (D) or next prompt. + pub fn last_command_output(&self) -> Option { + let total = self.total_lines(); + let start = (0..total) + .rev() + .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?; + let mut lines: Vec = Vec::new(); + for r in start..total { + if r > start + && matches!( + self.abs_prompt(r), + Some(PromptKind::CmdEnd | PromptKind::PromptStart) + ) + { + break; + } + lines.push(self.row_slice_text(r, 0, usize::MAX).trim_end().to_string()); + } + // Drop trailing blank rows (e.g. the empty live screen below the output). + while lines.last().is_some_and(|l| l.is_empty()) { + lines.pop(); + } + let mut out = lines.join("\n"); + out.push('\n'); + Some(out) + } + /// Slide an active selection up by `n` rows after scrollback eviction, /// dropping it if either endpoint scrolled off the top. fn shift_selection(&mut self, n: usize) { @@ -1408,6 +1508,18 @@ mod tests { assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x")); } + #[test] + fn prompt_mark_survives_reflow() { + let mut g = Grid::new(10, 4); + g.set_prompt_mark(PromptKind::OutputStart); // marks line 0 + for c in "hello".chars() { + g.print(c); + } + g.resize(6, 4); // rewrap to a narrower width + // The mark followed its logical line, so the output is still found. + assert_eq!(g.last_command_output().as_deref(), Some("hello\n")); + } + #[test] fn carriage_return_and_line_feed() { let mut g = Grid::new(8, 4); diff --git a/src/vt.rs b/src/vt.rs index 4104e05..d2e62ae 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -4,7 +4,9 @@ use std::io::Write as _; use vte::{Params, Perform}; -use crate::grid::{Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, Underline}; +use crate::grid::{ + Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline, +}; use crate::theme::{Rgb, Theme}; /// G0/G1 character set designation. @@ -667,6 +669,17 @@ impl Perform for Term { self.cwd = file_uri_path(uri); } } + // OSC 133: shell-integration prompt marks (A/B/C/D, with optional + // `;key=value` attributes we ignore). + Some(&n) if n == b"133" => { + if let Some(kind) = params + .get(1) + .and_then(|p| p.first()) + .and_then(|&b| prompt_kind(b)) + { + self.grid.set_prompt_mark(kind); + } + } // OSC 4: set/query palette entries (pairs of index;spec). Some(&n) if n == b"4" => self.osc_palette(params, bell), // OSC 104: reset palette (all, or the listed indices). @@ -810,6 +823,17 @@ fn base64_decode(data: &[u8]) -> Option> { Some(out) } +/// Map an OSC 133 mark letter to a [`PromptKind`]. +fn prompt_kind(b: u8) -> Option { + match b { + b'A' => Some(PromptKind::PromptStart), + b'B' => Some(PromptKind::CmdStart), + b'C' => Some(PromptKind::OutputStart), + b'D' => Some(PromptKind::CmdEnd), + _ => None, + } +} + /// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding /// `%XX` escapes. The host part is ignored (we only spawn locally). Returns /// `None` if it is not a usable absolute path. @@ -1114,6 +1138,15 @@ mod tests { assert_eq!(t.grid().row_text(0), "─│"); } + #[test] + fn osc133_marks_capture_last_command_output() { + let mut t = Term::new(12, 6); + feed(&mut t, b"\x1b]133;A\x07$ echo hi\r\n"); // prompt + typed command + feed(&mut t, b"\x1b]133;C\x07hi\r\n"); // output start, then output + feed(&mut t, b"\x1b]133;D\x07"); // command finished + assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n")); + } + #[test] fn osc7_tracks_cwd_and_decodes_percent() { let mut t = Term::new(20, 1); diff --git a/src/wayland.rs b/src/wayland.rs index 03f9066..e0c267c 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -639,6 +639,50 @@ impl App { Action::FontReset => self.change_font_size(self.config.main.font_size), Action::Fullscreen => self.toggle_fullscreen(), Action::NewWindow => self.spawn_new_window(), + Action::JumpPromptUp => self.jump_prompt(true), + Action::JumpPromptDown => self.jump_prompt(false), + Action::PipeCommandOutput => self.pipe_command_output(), + } + } + + /// Scroll the viewport to the previous/next shell prompt (OSC 133). + fn jump_prompt(&mut self, up: bool) { + if let Some(session) = self.session.as_mut() { + session.term.grid_mut().jump_prompt(up); + self.needs_draw = true; + } + } + + /// Feed the last command's output (between OSC 133 C and D) to the configured + /// command on stdin. + fn pipe_command_output(&mut self) { + let argv = &self.config.shell_integration.pipe_command; + let Some((program, args)) = argv.split_first() else { + tracing::warn!("pipe-command-output: no [shell-integration] pipe-command configured"); + return; + }; + let Some(text) = self + .session + .as_ref() + .and_then(|s| s.term.grid().last_command_output()) + else { + return; + }; + let mut cmd = std::process::Command::new(program); + cmd.args(args) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) { + cmd.current_dir(cwd); + } + match cmd.spawn() { + Ok(mut child) => { + if let Some(mut stdin) = child.stdin.take() { + let _ = stdin.write_all(text.as_bytes()); + } + } + Err(err) => tracing::warn!("pipe-command-output: spawn failed: {err}"), } }