brewery
notashelf /
72ec651ff1dc4daa6a0146a6dd9c1b47c4d5c464

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 72ec651ff1dc

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified5 files changed+208-1
vt: add OSC 133 prompt marks with jump and pipe-output actions

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0afe252fefa3eb82559a35d03ba449376a6a6964
diff --git a/src/bindings.rs b/src/bindings.rsindex 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.rsindex 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<String, String>,@@ -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<String>,+}+ /// `[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.rsindex 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<Cell>,     wrapped: bool,+    /// OSC 133 mark attached to this (logical) line, if any.+    prompt: Option<PromptKind>, }  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<Cell>> = Vec::new();+        let mut logical_marks: Vec<Option<PromptKind>> = Vec::new();         let mut acc: Vec<Cell> = Vec::new();+        let mut acc_mark: Option<PromptKind> = 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<PromptKind> {+        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<PromptKind>| 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<String> {+        let total = self.total_lines();+        let start = (0..total)+            .rev()+            .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?;+        let mut lines: Vec<String> = 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) {@@ -1409,6 +1509,18 @@ mod tests {     }      #[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);         for c in "hi".chars() {diff --git a/src/vt.rs b/src/vt.rsindex 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<Vec<u8>> {     Some(out) } +/// Map an OSC 133 mark letter to a [`PromptKind`].+fn prompt_kind(b: u8) -> Option<PromptKind> {+    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.@@ -1115,6 +1139,15 @@ mod tests {     }      #[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);         feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");diff --git a/src/wayland.rs b/src/wayland.rsindex 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}"),         }     }