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:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
+2
-1
@@ -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 => {}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user