From 58cb531e32b81775cf17f05f916e6be834aa56c8 Mon Sep 17 00:00:00 2001 From: Andy Dixon Date: Mon, 12 Oct 2026 18:37:53 +0100 Subject: [PATCH] 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. --- docs/overview.md | 1 + docs/user-manual.md | 26 +++++ rust/app/src/app.rs | 3 +- rust/app/src/main.rs | 1 + rust/app/src/osc.rs | 147 ++++++++++++++++++++++++ rust/app/src/terminal.rs | 236 ++++++++++++++++++++++++++++++++++++++- 6 files changed, 412 insertions(+), 2 deletions(-) create mode 100644 rust/app/src/osc.rs diff --git a/docs/overview.md b/docs/overview.md index 6e903f4..e9914b3 100644 --- a/docs/overview.md +++ b/docs/overview.md @@ -73,6 +73,7 @@ Use Nexus Terminal when you need to: - Shift+PageUp/PageDown/Home/End and the mouse wheel scroll the scrollback; on the alternate screen the wheel sends arrow keys, so `tmux`, `screen` and `byobu` scroll naturally. - Context menu with Copy, Paste, Select All, Find, Send Snippet, Port Forwards, Split Right, Split Down, Export Output, Clear and Reset. - Export the whole scrollback as plain text. +- Shell integration (OSC 133): jump between prompts, copy the last command's output, and see each command's exit status and duration beside its prompt. - Status-bar latency for the active SSH session. ### SSH capabilities diff --git a/docs/user-manual.md b/docs/user-manual.md index 4bdbfe9..a9b8b8c 100644 --- a/docs/user-manual.md +++ b/docs/user-manual.md @@ -218,6 +218,32 @@ A new pane offers **Local Machine** and your terminal profiles (SSH, Telnet and ## 6. Terminal actions and search +### Shell integration + +When the shell marks its prompts and commands (the OSC 133 sequences), Nexus Terminal knows where each command starts and ends: + +- **Ctrl+Shift+Up / Ctrl+Shift+Down** (**Cmd+Shift** on macOS), or **Previous Prompt** / **Next Prompt** in the right-click menu, jump between prompts in the scrollback. +- **Copy Last Command Output** in the right-click menu copies what the last finished command printed. +- A thin bar beside each finished command's prompt shows how it ended: green for success, red for a non-zero exit. Hover it for the exit code and how long the command took. + +fish 4 and several prompt themes (Starship, Powerlevel10k with its shell integration option) send the marks already. For bash, add this to `~/.bashrc` on the machine the shell runs on: + +```bash +PS0='\[\e]133;C\a\]' +PROMPT_COMMAND='printf "\033]133;D;%s\007" "$?"; '"${PROMPT_COMMAND}" +PS1='\[\e]133;A\a\]'"$PS1"'\[\e]133;B\a\]' +``` + +For zsh, add to `~/.zshrc`: + +```zsh +precmd() { print -Pn "\e]133;D;$?\a\e]133;A\a" } +preexec() { print -n "\e]133;C\a" } +PS1="$PS1%{\e]133;B\a%}" +``` + +Shells that also report their working directory (OSC 7; fish, and zsh/bash with the usual `printf '\e]7;file://%s%s\a' "$HOSTNAME" "$PWD"` hook) let **SFTP Browser** open in that directory. + ### Keyboard and mouse - **Copy**: select text and press **Cmd+C** / **Ctrl+C**. With nothing selected, **Ctrl+C** sends an interrupt as usual. diff --git a/rust/app/src/app.rs b/rust/app/src/app.rs index 09f9c4a..dc6b315 100644 --- a/rust/app/src/app.rs +++ b/rust/app/src/app.rs @@ -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 => {} } } diff --git a/rust/app/src/main.rs b/rust/app/src/main.rs index 4733e6a..4f4e88c 100644 --- a/rust/app/src/main.rs +++ b/rust/app/src/main.rs @@ -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; diff --git a/rust/app/src/osc.rs b/rust/app/src/osc.rs new file mode 100644 index 0000000..e58e1ae --- /dev/null +++ b/rust/app/src/osc.rs @@ -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), + /// 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), + BodyEsc(Vec), +} + +#[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 { + 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::>(), want.iter().collect::>()); + 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![Osc::Finished(None)]); + } +} diff --git a/rust/app/src/terminal.rs b/rust/app/src/terminal.rs index 303e10f..007f932 100644 --- a/rust/app/src/terminal.rs +++ b/rust/app/src/terminal.rs @@ -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, + /// 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, + end: Option, + exit: Option, + started: Option, + duration: Option, +} + +/// 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 = 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 { + 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 = (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, Option)> { + 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 = (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 = (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);