aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorakbash <akbash@herdr.dev>2026-08-20 00:17:25 +0300
committerGitHub <noreply@github.com>2026-08-20 00:17:25 +0300
commit4d17957c43938ef49aaf395fa8ff954c604e0e35 (patch)
treef36ed8874c9eb304206ceb8857f25d8825bb2ba5
parent4ace235de0ccfa5ebf4619de4e922a62889f7038 (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.md3
-rw-r--r--docs/next/website/src/content/docs/configuration.mdx2
-rw-r--r--src/app/tab_bar_status.rs109
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}"),