forked from NotAShelf/beer
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:
parent
0c0da3d035
commit
72ec651ff1
5 changed files with 208 additions and 1 deletions
|
|
@ -21,6 +21,9 @@ pub enum Action {
|
||||||
FontReset,
|
FontReset,
|
||||||
Fullscreen,
|
Fullscreen,
|
||||||
NewWindow,
|
NewWindow,
|
||||||
|
JumpPromptUp,
|
||||||
|
JumpPromptDown,
|
||||||
|
PipeCommandOutput,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Action {
|
impl Action {
|
||||||
|
|
@ -39,6 +42,9 @@ impl Action {
|
||||||
"font-reset" => Self::FontReset,
|
"font-reset" => Self::FontReset,
|
||||||
"fullscreen" => Self::Fullscreen,
|
"fullscreen" => Self::Fullscreen,
|
||||||
"new-window" => Self::NewWindow,
|
"new-window" => Self::NewWindow,
|
||||||
|
"jump-prompt-up" => Self::JumpPromptUp,
|
||||||
|
"jump-prompt-down" => Self::JumpPromptDown,
|
||||||
|
"pipe-command-output" => Self::PipeCommandOutput,
|
||||||
_ => return None,
|
_ => return None,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -174,6 +180,8 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[
|
||||||
("Ctrl+0", "font-reset"),
|
("Ctrl+0", "font-reset"),
|
||||||
("F11", "fullscreen"),
|
("F11", "fullscreen"),
|
||||||
("Ctrl+Shift+N", "new-window"),
|
("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.
|
/// 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 scrollback: Scrollback,
|
||||||
pub bell: Bell,
|
pub bell: Bell,
|
||||||
pub mouse: Mouse,
|
pub mouse: Mouse,
|
||||||
|
pub shell_integration: ShellIntegration,
|
||||||
/// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;
|
/// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;
|
||||||
/// a value of `"none"` unbinds.
|
/// a value of `"none"` unbinds.
|
||||||
pub key_bindings: std::collections::HashMap<String, String>,
|
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.
|
/// `[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
|
/// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries
|
||||||
/// keep the built-in default.
|
/// keep the built-in default.
|
||||||
|
|
|
||||||
112
src/grid.rs
112
src/grid.rs
|
|
@ -139,6 +139,17 @@ struct Cursor {
|
||||||
y: usize,
|
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
|
/// 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
|
/// next row (autowrap continuation, as opposed to a hard line break). The flag
|
||||||
/// is what lets resize rejoin and rewrap paragraphs.
|
/// is what lets resize rejoin and rewrap paragraphs.
|
||||||
|
|
@ -146,6 +157,8 @@ struct Cursor {
|
||||||
struct Line {
|
struct Line {
|
||||||
cells: Vec<Cell>,
|
cells: Vec<Cell>,
|
||||||
wrapped: bool,
|
wrapped: bool,
|
||||||
|
/// OSC 133 mark attached to this (logical) line, if any.
|
||||||
|
prompt: Option<PromptKind>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Line {
|
impl Line {
|
||||||
|
|
@ -153,6 +166,7 @@ impl Line {
|
||||||
Self {
|
Self {
|
||||||
cells: vec![Cell::default(); cols],
|
cells: vec![Cell::default(); cols],
|
||||||
wrapped: false,
|
wrapped: false,
|
||||||
|
prompt: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -367,7 +381,9 @@ impl Grid {
|
||||||
// 1. Rejoin soft-wrapped rows into logical lines. Track which logical
|
// 1. Rejoin soft-wrapped rows into logical lines. Track which logical
|
||||||
// line the cursor falls in and its offset within that line.
|
// line the cursor falls in and its offset within that line.
|
||||||
let mut logicals: Vec<Vec<Cell>> = Vec::new();
|
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: Vec<Cell> = Vec::new();
|
||||||
|
let mut acc_mark: Option<PromptKind> = None;
|
||||||
let mut cur_logical = 0usize;
|
let mut cur_logical = 0usize;
|
||||||
let mut cur_off = 0usize;
|
let mut cur_off = 0usize;
|
||||||
for abs in 0..total {
|
for abs in 0..total {
|
||||||
|
|
@ -380,13 +396,19 @@ impl Grid {
|
||||||
} else {
|
} else {
|
||||||
&self.lines[abs - self.scrollback.len()]
|
&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);
|
acc.extend_from_slice(&line.cells);
|
||||||
if !line.wrapped {
|
if !line.wrapped {
|
||||||
logicals.push(std::mem::take(&mut acc));
|
logicals.push(std::mem::take(&mut acc));
|
||||||
|
logical_marks.push(acc_mark.take());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !acc.is_empty() {
|
if !acc.is_empty() {
|
||||||
logicals.push(acc);
|
logicals.push(acc);
|
||||||
|
logical_marks.push(acc_mark);
|
||||||
}
|
}
|
||||||
// Drop trailing all-blank lines (empty screen below the content), but
|
// Drop trailing all-blank lines (empty screen below the content), but
|
||||||
// never above the cursor's line, so the cursor keeps its row.
|
// 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()))
|
.rposition(|l| l.iter().any(|c| *c != Cell::default()))
|
||||||
.unwrap_or(0);
|
.unwrap_or(0);
|
||||||
logicals.truncate(last_content.max(cur_logical) + 1);
|
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
|
// 2. Rewrap each logical line to the new width, recording where the
|
||||||
// cursor lands. Trailing blanks are dropped so a hard line does not
|
// cursor lands. Trailing blanks are dropped so a hard line does not
|
||||||
|
|
@ -418,6 +441,8 @@ impl Grid {
|
||||||
new_lines.push(Line {
|
new_lines.push(Line {
|
||||||
cells,
|
cells,
|
||||||
wrapped: ci + 1 < chunks,
|
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 {
|
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,
|
/// Slide an active selection up by `n` rows after scrollback eviction,
|
||||||
/// dropping it if either endpoint scrolled off the top.
|
/// dropping it if either endpoint scrolled off the top.
|
||||||
fn shift_selection(&mut self, n: usize) {
|
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"));
|
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]
|
#[test]
|
||||||
fn carriage_return_and_line_feed() {
|
fn carriage_return_and_line_feed() {
|
||||||
let mut g = Grid::new(8, 4);
|
let mut g = Grid::new(8, 4);
|
||||||
|
|
|
||||||
35
src/vt.rs
35
src/vt.rs
|
|
@ -4,7 +4,9 @@ use std::io::Write as _;
|
||||||
|
|
||||||
use vte::{Params, Perform};
|
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};
|
use crate::theme::{Rgb, Theme};
|
||||||
|
|
||||||
/// G0/G1 character set designation.
|
/// G0/G1 character set designation.
|
||||||
|
|
@ -667,6 +669,17 @@ impl Perform for Term {
|
||||||
self.cwd = file_uri_path(uri);
|
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).
|
// OSC 4: set/query palette entries (pairs of index;spec).
|
||||||
Some(&n) if n == b"4" => self.osc_palette(params, bell),
|
Some(&n) if n == b"4" => self.osc_palette(params, bell),
|
||||||
// OSC 104: reset palette (all, or the listed indices).
|
// OSC 104: reset palette (all, or the listed indices).
|
||||||
|
|
@ -810,6 +823,17 @@ fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {
|
||||||
Some(out)
|
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
|
/// 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
|
/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns
|
||||||
/// `None` if it is not a usable absolute path.
|
/// `None` if it is not a usable absolute path.
|
||||||
|
|
@ -1114,6 +1138,15 @@ mod tests {
|
||||||
assert_eq!(t.grid().row_text(0), "─│");
|
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]
|
#[test]
|
||||||
fn osc7_tracks_cwd_and_decodes_percent() {
|
fn osc7_tracks_cwd_and_decodes_percent() {
|
||||||
let mut t = Term::new(20, 1);
|
let mut t = Term::new(20, 1);
|
||||||
|
|
|
||||||
|
|
@ -639,6 +639,50 @@ impl App {
|
||||||
Action::FontReset => self.change_font_size(self.config.main.font_size),
|
Action::FontReset => self.change_font_size(self.config.main.font_size),
|
||||||
Action::Fullscreen => self.toggle_fullscreen(),
|
Action::Fullscreen => self.toggle_fullscreen(),
|
||||||
Action::NewWindow => self.spawn_new_window(),
|
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}"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue