diff options
| author | akbash <akbash@herdr.dev> | 2026-08-20 00:17:25 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-08-20 00:17:25 +0300 |
| commit | 4d17957c43938ef49aaf395fa8ff954c604e0e35 (patch) | |
| tree | f36ed8874c9eb304206ceb8857f25d8825bb2ba5 | |
| parent | 4ace235de0ccfa5ebf4619de4e922a62889f7038 (diff) | |
fix: strip ansi sequences from tab bar status (#3003)
* fix: strip ansi sequences from tab bar status
refs #3001
* docs: clarify tab bar escape sequence scope
refs #3001
---------
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
| -rw-r--r-- | docs/next/CHANGELOG.md | 3 | ||||
| -rw-r--r-- | docs/next/website/src/content/docs/configuration.mdx | 2 | ||||
| -rw-r--r-- | src/app/tab_bar_status.rs | 109 |
3 files changed, 112 insertions, 2 deletions
diff --git a/docs/next/CHANGELOG.md b/docs/next/CHANGELOG.md index 05f36808..6b155638 100644 --- a/docs/next/CHANGELOG.md +++ b/docs/next/CHANGELOG.md @@ -2,6 +2,9 @@ ## Unreleased +### Fixed +- Tab bar status commands now remove ESC-prefixed terminal control sequences instead of displaying their sequence bodies as text. (#3001) + ## [0.8.2] - 2026-08-19 ### Added diff --git a/docs/next/website/src/content/docs/configuration.mdx b/docs/next/website/src/content/docs/configuration.mdx index 4138c9f5..5d4a7ab1 100644 --- a/docs/next/website/src/content/docs/configuration.mdx +++ b/docs/next/website/src/content/docs/configuration.mdx @@ -299,7 +299,7 @@ tab_bar_right_separator = " · " The status area is empty by default. Add `zoom` to show a fixed `ZOOM` pill while the active tab is zoomed; the existing per-tab `Z` markers remain independent. `hostname`, `datetime`, and `command` resolve on the Herdr server, so `herdr --remote` shows the remote machine's values. Datetime entries use `strftime` formatting; directives that require a UTC offset or Unix timestamp, such as `%z` and `%s`, are rejected because the value is server-local wall-clock time. -Command entries run immediately and then at `interval_seconds` without blocking rendering or overlapping a previous run. The interval can be 1–31,536,000 seconds and the timeout can be 1–3,600 seconds. Herdr uses the last line of successful output, clears it after failure, empty output, or `timeout_seconds`, and provides the same active workspace, tab, pane, socket, binary, and working-directory context as custom command keybindings. Commands are supported on Linux, macOS, and Windows, using `/bin/sh -lc` on Linux and macOS and `cmd.exe /d /c` on Windows. +Command entries run immediately and then at `interval_seconds` without blocking rendering or overlapping a previous run. The interval can be 1–31,536,000 seconds and the timeout can be 1–3,600 seconds. Herdr uses the last line of successful output, removes ESC-prefixed terminal control sequences instead of interpreting styles, clears the entry after failure, empty output, or `timeout_seconds`, and provides the same active workspace, tab, pane, socket, binary, and working-directory context as custom command keybindings. Commands are supported on Linux, macOS, and Windows, using `/bin/sh -lc` on Linux and macOS and `cmd.exe /d /c` on Windows. Separators appear only between visible entries. Set `tab_bar_right_separator = ""` for direct concatenation. On a narrow tab row, the complete status area yields to the tabs and their controls. diff --git a/src/app/tab_bar_status.rs b/src/app/tab_bar_status.rs index 4a617c1d..eca5a314 100644 --- a/src/app/tab_bar_status.rs +++ b/src/app/tab_bar_status.rs @@ -277,9 +277,75 @@ fn is_unicode_format_control(character: char) -> bool { fn command_output_text(output: &[u8]) -> Option<String> { let output = String::from_utf8_lossy(output); + let output = strip_terminal_control_sequences(output.as_bytes()); + let output = String::from_utf8_lossy(&output); output.lines().next_back().and_then(sanitize_status_text) } +#[derive(Clone, Copy)] +enum ControlSequenceState { + Text, + Escape, + EscapeIntermediate, + Csi, + Osc, + StString, +} + +fn strip_terminal_control_sequences(value: &[u8]) -> Vec<u8> { + use ControlSequenceState::*; + + let mut output = Vec::with_capacity(value.len()); + let mut state = Text; + for &byte in value { + state = match (state, byte) { + (Text, b'\x1b') => Escape, + (Text, _) => { + output.push(byte); + Text + } + (Escape, b'[') => Csi, + (Escape, b']') => Osc, + (Escape, b'P' | b'X' | b'^' | b'_') => StString, + (Escape, 0x20..=0x2f) => EscapeIntermediate, + (Escape, 0x30..=0x7e) => Text, + (Escape, b'\x1b') => Escape, + (Escape, b'\x18' | b'\x1a') => Text, + (Escape, byte) if byte.is_ascii_control() => Escape, + (Escape, _) => { + output.push(byte); + Text + } + (EscapeIntermediate, 0x20..=0x2f) => EscapeIntermediate, + (EscapeIntermediate, 0x30..=0x7e) => Text, + (EscapeIntermediate, b'\x1b') => Escape, + (EscapeIntermediate, b'\x18' | b'\x1a') => Text, + (EscapeIntermediate, byte) if byte.is_ascii_control() => EscapeIntermediate, + (EscapeIntermediate, _) => { + output.push(byte); + Text + } + (Csi, 0x20..=0x3f) => Csi, + (Csi, 0x40..=0x7e) => Text, + (Csi, b'\x1b') => Escape, + (Csi, b'\x18' | b'\x1a') => Text, + (Csi, byte) if byte.is_ascii_control() => Csi, + (Csi, _) => { + output.push(byte); + Text + } + (Osc, b'\x07') => Text, + (Osc, b'\x1b') => Escape, + (Osc, b'\x18' | b'\x1a') => Text, + (Osc, _) => Osc, + (StString, b'\x1b') => Escape, + (StString, b'\x18' | b'\x1a') => Text, + (StString, _) => StString, + }; + } + output +} + async fn read_last_output_line( mut stdout: tokio::process::ChildStdout, ) -> std::io::Result<Vec<u8>> { @@ -699,12 +765,53 @@ mod tests { fn command_output_uses_sanitized_last_line() { assert_eq!( command_output_text(b"old\n win\x1b[31mter\r\n"), - Some("win[31mter".into()) + Some("winter".into()) ); assert_eq!(command_output_text(b"\r\n"), None); } #[test] + fn command_output_strips_ansi_style_sequences() { + assert_eq!( + command_output_text(b"\x1b[32mHELLO\x1b[0m"), + Some("HELLO".into()) + ); + } + + #[test] + fn command_output_strips_terminal_control_sequence_families() { + assert_eq!( + command_output_text(b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\"), + Some("link".into()) + ); + assert_eq!( + command_output_text(b"\x1bPignored\x1b\\visible\x1b7"), + Some("visible".into()) + ); + assert_eq!(command_output_text(b"\x1b[31m\x1b[0m"), None); + assert_eq!( + command_output_text(b"\x1b\x07[32mHELLO\x1b[0m"), + Some("HELLO".into()) + ); + assert_eq!( + command_output_text(b"\x1bPignored\x18VISIBLE"), + Some("VISIBLE".into()) + ); + assert_eq!( + command_output_text(b"\x1bPignored\x1b7VISIBLE"), + Some("VISIBLE".into()) + ); + assert_eq!( + command_output_text(b"\x1b]ignored\x1aVISIBLE"), + Some("VISIBLE".into()) + ); + assert_eq!(command_output_text(b"\xc2\x1b[31m\xa2"), Some("��".into())); + + let styled = format!("\x1b[38;2;1;2;3m{}\x1b[0m", "x".repeat(80)); + assert_eq!(command_output_text(styled.as_bytes()), Some("x".repeat(80))); + } + + #[test] fn status_text_strips_bidi_and_zero_width_format_controls() { assert_eq!( sanitize_status_text("safe\u{202e}evil\u{200b}"), |
