feat: shell integration (OSC 133)

Terminals now pick up OSC 133 prompt/command/output/finished marks,
which the VT parser ignores: a scanner (app/src/osc.rs, split-safe
across reads, bodies capped at 4 KiB) finds them in the output stream
and each mark records the cursor line. Lines are stable indexes that
account for lines a full scrollback rotates out, so marks stay on their
text; clearing the scrollback drops them.

With marks present: Ctrl/Cmd+Shift+Up/Down and Previous/Next Prompt in
the context menu jump between prompts, Copy Last Command Output copies
what the last finished command printed, and a green/red bar beside each
prompt shows the exit status (hover for code and duration). OSC 7
working-directory reports are parsed too (used by the next change).
The manual shows bash and zsh snippets; fish 4 and common prompt themes
already send the marks.
This commit is contained in:
Andy Dixon
2026-10-12 18:38:03 +01:00
parent 455d8fa6da
commit 58cb531e32
6 changed files with 412 additions and 2 deletions
+2 -1
View File
@@ -846,7 +846,8 @@ impl App {
t.reset();
}
}
MenuAction::Copy | MenuAction::SelectAll => {}
// Handled inside the terminal view.
MenuAction::Copy | MenuAction::SelectAll | MenuAction::PrevPrompt | MenuAction::NextPrompt | MenuAction::CopyOutput => {}
}
}
+1
View File
@@ -8,6 +8,7 @@ mod desktop_view;
mod forwards;
mod github_view;
mod irc_view;
mod osc;
mod profile_editor;
mod quick_connect;
mod raw_view;
+147
View File
@@ -0,0 +1,147 @@
//! Finds the OSC sequences the VT parser ignores but Nexus uses: OSC 133 (shell integration
//! marks: prompt, command, output, finished with exit status) and OSC 7 (the shell's working
//! directory). Sequences may be split across reads, so the scanner keeps state between chunks.
#[derive(Debug, PartialEq)]
pub enum Osc {
/// 133;A — a prompt starts.
Prompt,
/// 133;C — the command's output starts.
Output,
/// 133;D[;status] — the command finished.
Finished(Option<i32>),
/// 7;file://host/path
Cwd { host: String, path: String },
}
/// Longest OSC body collected; longer ones (not ours) are dropped.
const MAX_OSC: usize = 4096;
#[derive(Default)]
enum State {
#[default]
Idle,
Esc,
Body(Vec<u8>),
BodyEsc(Vec<u8>),
}
#[derive(Default)]
pub struct OscScanner {
state: State,
}
impl OscScanner {
/// The sequences that complete in `bytes`, each with the offset just past its terminator.
pub fn scan(&mut self, bytes: &[u8]) -> Vec<(usize, Osc)> {
let mut out = Vec::new();
let mut i = 0;
while i < bytes.len() {
let state = std::mem::take(&mut self.state);
self.state = match state {
State::Idle => match bytes[i..].iter().position(|&b| b == 0x1b) {
Some(p) => {
i += p;
State::Esc
}
None => {
i = bytes.len();
State::Idle
}
},
State::Esc if bytes[i] == b']' => State::Body(Vec::new()),
State::Esc => State::Idle,
State::Body(mut b) => match bytes[i] {
0x07 => {
if let Some(osc) = parse(&b) {
out.push((i + 1, osc));
}
State::Idle
}
0x1b => State::BodyEsc(b),
c if b.len() < MAX_OSC => {
b.push(c);
State::Body(b)
}
_ => State::Idle,
},
State::BodyEsc(b) if bytes[i] == b'\\' => {
if let Some(osc) = parse(&b) {
out.push((i + 1, osc));
}
State::Idle
}
// An ESC that doesn't end the OSC starts a new sequence.
State::BodyEsc(_) => {
// Re-examine this byte as the one after an ESC.
self.state = State::Esc;
continue;
}
};
i += 1;
}
out
}
}
fn parse(body: &[u8]) -> Option<Osc> {
let s = std::str::from_utf8(body).ok()?;
if let Some(rest) = s.strip_prefix("133;") {
let mut parts = rest.split(';');
return match parts.next()? {
"A" => Some(Osc::Prompt),
"C" => Some(Osc::Output),
"D" => Some(Osc::Finished(parts.next().and_then(|c| c.parse().ok()))),
_ => None,
};
}
let url = s.strip_prefix("7;")?.strip_prefix("file://")?;
let (host, path) = url.split_at(url.find('/')?);
Some(Osc::Cwd { host: host.into(), path: percent_decode(path) })
}
fn percent_decode(s: &str) -> String {
let b = s.as_bytes();
let mut out = Vec::with_capacity(b.len());
let mut i = 0;
while i < b.len() {
match (b[i], b.get(i + 1..i + 3).and_then(|h| u8::from_str_radix(std::str::from_utf8(h).ok()?, 16).ok())) {
(b'%', Some(v)) => {
out.push(v);
i += 3;
}
(c, _) => {
out.push(c);
i += 1;
}
}
}
String::from_utf8_lossy(&out).into_owned()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_marks_and_cwd_even_when_split() {
let stream = b"hi\x1b]133;A\x07$ ls\x1b]133;C\x1b\\file\x1b]133;D;2\x07\x1b]7;file://box/home/me/My%20Dir\x07\x1b]0;title\x07\x1b[31mred";
let whole = OscScanner::default().scan(stream);
let want = [Osc::Prompt, Osc::Output, Osc::Finished(Some(2)), Osc::Cwd { host: "box".into(), path: "/home/me/My Dir".into() }];
assert_eq!(whole.iter().map(|(_, o)| o).collect::<Vec<_>>(), want.iter().collect::<Vec<_>>());
assert_eq!(whole[0].0, 10, "offset is just past the terminator");
// Byte by byte gives the same sequences at the same absolute offsets.
let mut s = OscScanner::default();
let mut split = vec![];
for (i, b) in stream.iter().enumerate() {
split.extend(s.scan(std::slice::from_ref(b)).into_iter().map(|(end, o)| (i + end, o)));
}
assert_eq!(split, whole);
// Unterminated and oversized bodies don't leak into later output.
let mut s = OscScanner::default();
let mut long = b"\x1b]133;A".to_vec();
long.extend(std::iter::repeat_n(b'x', MAX_OSC + 10));
long.extend(b"\x07\x1b]133;D\x07");
assert_eq!(s.scan(&long).into_iter().map(|(_, o)| o).collect::<Vec<_>>(), vec![Osc::Finished(None)]);
}
}
+235 -1
View File
@@ -40,6 +40,9 @@ pub enum MenuAction {
Export,
Clear,
Reset,
PrevPrompt,
NextPrompt,
CopyOutput,
}
#[derive(Default)]
@@ -107,8 +110,30 @@ pub struct TermView {
focus_requested: bool,
/// Hold back multi-line pastes that would run unbracketed (set from preferences each frame).
pub confirm_paste: bool,
osc: crate::osc::OscScanner,
/// Shell-integration prompts (OSC 133), oldest first.
prompts: Vec<PromptMark>,
/// Lines rotated out of a full scrollback since start, so marks keep pointing at their text.
rotated: i64,
max_history: usize,
/// The working directory the shell last reported (OSC 7): (host, path).
pub reported_cwd: Option<(String, String)>,
}
/// One command as shell integration reported it. Lines are stable indexes (see `stable`).
#[derive(Clone, Debug)]
struct PromptMark {
prompt: i64,
output: Option<i64>,
end: Option<i64>,
exit: Option<i32>,
started: Option<std::time::Instant>,
duration: Option<std::time::Duration>,
}
/// Marks kept per terminal; older ones are dropped.
const MAX_PROMPTS: usize = 5000;
const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#;
fn rgb(c: [u8; 4]) -> Color32 {
@@ -135,14 +160,122 @@ impl TermView {
find: None,
focus_requested: true,
confirm_paste: false,
osc: Default::default(),
prompts: Vec::new(),
rotated: 0,
max_history: scrollback.min(100_000),
reported_cwd: None,
}
}
pub fn feed(&mut self, bytes: &[u8]) {
self.parser.advance(&mut self.term, bytes);
let mut start = 0;
for (end, osc) in self.osc.scan(bytes) {
self.advance(&bytes[start..end]);
start = end;
self.apply_osc(osc);
}
self.advance(&bytes[start..]);
self.generation += 1;
}
/// Feeds the VT parser, counting lines a full scrollback rotates out (one per line feed at the
/// bottom of the screen) so shell-integration marks stay on their lines.
fn advance(&mut self, bytes: &[u8]) {
if self.prompts.is_empty() || self.term.mode().contains(TermMode::ALT_SCREEN) {
self.parser.advance(&mut self.term, bytes);
return;
}
let h0 = self.term.grid().history_size();
if h0 < self.max_history {
self.parser.advance(&mut self.term, bytes);
} else {
// ponytail: counts LF at the bottom row only; scroll-region and CSI S scrolling while
// the history is full can leave marks a few lines off.
let bottom = self.rows as i32 - 1;
for piece in bytes.split_inclusive(|&b| b == b'\n') {
let at_bottom = self.term.grid().cursor.point.line.0 == bottom;
self.parser.advance(&mut self.term, piece);
if at_bottom && piece.ends_with(b"\n") && self.term.grid().history_size() == self.max_history {
self.rotated += 1;
}
}
}
if self.term.grid().history_size() < h0 {
// The scrollback was cleared: earlier marks point at nothing.
self.prompts.clear();
}
}
/// Stable index of a grid line: unchanged as lines scroll into (and out of) the history.
fn stable(&self, line: Line) -> i64 {
self.term.grid().history_size() as i64 + line.0 as i64 + self.rotated
}
fn line_of(&self, stable: i64) -> Line {
Line((stable - self.rotated - self.term.grid().history_size() as i64) as i32)
}
fn cursor_stable(&self) -> i64 {
self.stable(self.term.grid().cursor.point.line)
}
fn apply_osc(&mut self, osc: crate::osc::Osc) {
use crate::osc::Osc;
match osc {
Osc::Prompt => {
let line = self.cursor_stable();
if self.prompts.last().is_some_and(|p| p.prompt == line) {
return;
}
if self.prompts.len() >= MAX_PROMPTS {
self.prompts.remove(0);
}
self.prompts.push(PromptMark { prompt: line, output: None, end: None, exit: None, started: None, duration: None });
}
Osc::Output => {
let line = self.cursor_stable();
if let Some(p) = self.prompts.last_mut() {
p.output = Some(line);
p.started = Some(std::time::Instant::now());
}
}
Osc::Finished(code) => {
let line = self.cursor_stable();
if let Some(p) = self.prompts.last_mut().filter(|p| p.output.is_some() && p.end.is_none()) {
p.end = Some(line);
p.exit = code;
p.duration = p.started.map(|t| t.elapsed());
}
}
Osc::Cwd { host, path } => self.reported_cwd = Some((host, path)),
}
}
/// Scrolls so the previous (or next) prompt is at the top. False when there is none.
pub fn jump_prompt(&mut self, forward: bool) -> bool {
let top = Line(-(self.term.grid().display_offset() as i32));
let lines: Vec<i32> = self.prompts.iter().map(|p| self.line_of(p.prompt).0).filter(|&l| l >= self.term.grid().topmost_line().0).collect();
let target = if forward { lines.iter().copied().find(|&l| l > top.0) } else { lines.iter().rev().copied().find(|&l| l < top.0) };
let Some(line) = target else { return false };
let want_offset = (-line).clamp(0, self.term.grid().history_size() as i32);
self.term.scroll_display(Scroll::Delta(want_offset - self.term.grid().display_offset() as i32));
true
}
/// The output of the most recent command that finished (shell integration).
pub fn last_output(&self) -> Option<String> {
let p = self.prompts.iter().rev().find(|p| p.output.is_some() && p.end.is_some())?;
let (from, to) = (self.line_of(p.output?).0.max(self.term.grid().topmost_line().0), self.line_of(p.end?).0);
let text: Vec<String> = (from..to).map(|l| self.line_text(Line(l)).0.trim_end().to_string()).collect();
Some(text.join("\n").trim_end_matches('\n').to_string())
}
/// Whether the shell reports prompts (OSC 133), which enables the commands above.
pub fn has_shell_integration(&self) -> bool {
!self.prompts.is_empty()
}
pub fn size(&self) -> (u16, u16) {
(self.cols, self.rows)
}
@@ -306,6 +439,22 @@ impl TermView {
}
self.paint(ui, rect, origin, &font, cw, ch, theme, focused);
// Exit status of each finished command, as a bar beside its prompt (shell integration).
let hover = ui.input(|i| i.pointer.hover_pos());
for (bar, exit, took) in self.status_bars(rect, origin, ch) {
let color = match exit {
Some(0) => Color32::from_rgb(0x4c, 0xaf, 0x50),
Some(_) => Color32::from_rgb(0xe5, 0x39, 0x35),
None => Color32::GRAY,
};
ui.painter_at(rect).rect_filled(bar, 1.0, color);
if hover.is_some_and(|p| bar.expand2(vec2(3.0, 0.0)).contains(p)) {
let exit = exit.map_or("finished".to_string(), |c| format!("exit {c}"));
let took = took.map_or(String::new(), |d| format!(" · {:.1?}", d));
egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("cmd-status", bar.min.y as i32)), egui::PopupAnchor::Pointer)
.show(|ui| ui.label(format!("{exit}{took}")));
}
}
resp.context_menu(|ui| {
let m = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" };
@@ -322,6 +471,19 @@ impl TermView {
(MenuAction::Clear, "Clear", String::new()),
(MenuAction::Reset, "Reset", String::new()),
];
if self.has_shell_integration() {
for (a, label, keys) in [
(MenuAction::PrevPrompt, "Previous Prompt", format!("{m}Shift+↑")),
(MenuAction::NextPrompt, "Next Prompt", format!("{m}Shift+↓")),
(MenuAction::CopyOutput, "Copy Last Command Output", String::new()),
] {
if ui.add(egui::Button::new(label).shortcut_text(keys).min_size(vec2(200.0, 0.0))).clicked() {
out.menu = Some(a);
ui.close();
}
}
ui.separator();
}
for (i, (a, label, keys)) in items.into_iter().enumerate() {
if matches!(i, 3 | 5 | 6 | 8) {
ui.separator();
@@ -342,9 +504,38 @@ impl TermView {
self.select_all();
out.menu = None;
}
match out.menu {
Some(a @ (MenuAction::PrevPrompt | MenuAction::NextPrompt)) => {
self.jump_prompt(a == MenuAction::NextPrompt);
out.menu = None;
}
Some(MenuAction::CopyOutput) => {
if let Some(text) = self.last_output() {
ui.ctx().copy_text(text);
}
out.menu = None;
}
_ => {}
}
out
}
/// Where to draw each visible finished command's status bar, with its exit code and duration.
fn status_bars(&self, rect: Rect, origin: Pos2, ch: f32) -> Vec<(Rect, Option<i32>, Option<std::time::Duration>)> {
let offset = self.term.grid().display_offset() as i32;
self.prompts
.iter()
.filter(|p| p.end.is_some())
.filter_map(|p| {
let vr = self.line_of(p.prompt).0 + offset;
(0..self.rows as i32).contains(&vr).then(|| {
let y = origin.y + vr as f32 * ch;
(Rect::from_min_max(pos2(rect.left(), y + 1.0), pos2(rect.left() + 3.0, y + ch - 1.0)), p.exit, p.duration)
})
})
.collect()
}
fn keyboard(&mut self, ui: &mut Ui, out: &mut TermResponse) {
let events = ui.input(|i| i.events.clone());
let mode = *self.term.mode();
@@ -377,6 +568,11 @@ impl TermView {
typed = true;
}
Event::Key { key, pressed: true, modifiers, .. } => {
// Ctrl/Cmd+Shift+Up/Down jump between prompts (shell integration).
if modifiers.command && modifiers.shift && matches!(key, Key::ArrowUp | Key::ArrowDown) && self.has_shell_integration() {
self.jump_prompt(key == Key::ArrowDown);
continue;
}
// Shift+PageUp/PageDown/Home/End scroll the local scrollback.
if modifiers.shift && !modifiers.ctrl && !mode.contains(TermMode::ALT_SCREEN) {
let page = self.rows as i32 - 1;
@@ -902,6 +1098,44 @@ mod tests {
assert!(out.confirm_paste.is_none() && out.input.starts_with(b"\x1b[200~"), "bracketed paste needs no prompt");
}
#[test]
fn shell_integration_marks_survive_a_full_scrollback() {
// Scrollback of 30 lines, so the history fills and rotates during the test.
let mut t = TermView::new(30, 14.0, CursorStyle::Block);
let prompt = |t: &mut TermView| t.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07");
let run = |t: &mut TermView, cmd: &str, out: &[String], code: i32| {
t.feed(format!("{cmd}\r\n\x1b]133;C\x07").as_bytes());
for l in out {
t.feed(format!("{l}\r\n").as_bytes());
}
t.feed(format!("\x1b]133;D;{code}\x07").as_bytes());
};
prompt(&mut t);
run(&mut t, "seq 3", &["1".into(), "2".into(), "3".into()], 0);
prompt(&mut t);
assert_eq!(t.last_output().as_deref(), Some("1\n2\n3"));
let many: Vec<String> = (0..80).map(|i| format!("line {i}")).collect();
run(&mut t, "big", &many, 3);
prompt(&mut t);
run(&mut t, "tail", &["a".into(), "b".into()], 1);
prompt(&mut t);
assert_eq!(t.last_output().as_deref(), Some("a\nb"), "rotation must not shift the marks");
let last = t.prompts.iter().rev().nth(1).unwrap();
assert_eq!(last.exit, Some(1));
assert_eq!(t.line_text(t.line_of(last.prompt)).0.trim_end(), "$ tail");
// Jumping back lands an earlier prompt (still in the scrollback) at the top of the view.
let more: Vec<String> = (0..20).map(|i| format!("more {i}")).collect();
run(&mut t, "more", &more, 0);
prompt(&mut t);
assert!(t.jump_prompt(false));
let top = Line(-(t.term.grid().display_offset() as i32));
assert!(t.line_text(top).0.starts_with("$ "), "{:?}", t.line_text(top).0);
// A cleared scrollback drops the marks with it.
t.feed(b"\x1b[3J\x1b[2J");
t.feed(b"x\r\n");
assert!(t.prompts.len() <= 1);
}
#[test]
fn feeds_text_and_finds_it() {
let mut t = TermView::new(1000, 14.0, CursorStyle::Block);