vt: add OSC 133 prompt marks with jump and pipe-output actions

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I0afe252fefa3eb82559a35d03ba449376a6a6964
This commit is contained in:
raf 2026-06-25 13:41:34 +03:00
commit 72ec651ff1
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
5 changed files with 208 additions and 1 deletions

View file

@ -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.

View file

@ -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.

View file

@ -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) {
@ -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);

View file

@ -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.
@ -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);

View file

@ -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}"),
}
}