clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 72ec651ff1dc
tarballunverified5 files changed+208-1
@@ -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.@@ -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.@@ -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() {@@ -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");@@ -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}"), } }