mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-02 02:13:49 +00:00
Addresses three Copilot review comments on #653. `item_label` comes from a model or a workflow author, and it reaches the terminal through the CLI progress display. A label could carry ANSI escapes, newlines, or bidi overrides and rewrite what the operator sees. It could also be whitespace-only, giving a branch a blank identity. Add `text::sanitize_display_label`: strip ANSI sequences, drop control and bidi-reordering characters, trim, and elide past 80 characters. Return an empty string when nothing printable survives so callers fall back to an identity they control. Apply it where the label is created, so events, the store, and the web UI all get a clean value instead of each consumer having to remember. `parallel_branch_display` sanitizes again, because a run recorded before this commit still has raw labels in its event log. `emit_branch_retrying` now sets `stage.retrying`'s `index` from the branch stage's execution ordinal, matching the envelope `stage_id` and the meaning every other emitter gives that field. The branch's position in the fan-out is already on `parallel.branch.started`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
127 lines
4 KiB
Rust
127 lines
4 KiB
Rust
use console::strip_ansi_codes;
|
|
|
|
/// Longest display label kept from runtime data before it is elided.
|
|
const MAX_DISPLAY_LABEL: usize = 80;
|
|
|
|
/// Characters that reorder rendered text without being control characters:
|
|
/// the bidi embedding, override, and isolate marks.
|
|
fn is_bidi_control(ch: char) -> bool {
|
|
matches!(ch, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}' | '\u{200e}' | '\u{200f}')
|
|
}
|
|
|
|
/// Make a string that came from a model or a user safe to print in a terminal.
|
|
///
|
|
/// Strips ANSI escape sequences, then removes control and bidi-reordering
|
|
/// characters, trims surrounding whitespace, and elides anything past
|
|
/// [`MAX_DISPLAY_LABEL`]. Any terminal-facing identifier built from runtime
|
|
/// data should go through this — without it a label can move the cursor,
|
|
/// inject color, or reverse the text around it.
|
|
///
|
|
/// Returns an empty string when nothing printable survives, so callers can
|
|
/// fall back to an identity they control.
|
|
#[must_use]
|
|
pub fn sanitize_display_label(label: &str) -> String {
|
|
let stripped = strip_ansi_codes(label);
|
|
let cleaned = stripped
|
|
.chars()
|
|
.filter(|ch| !ch.is_control() && !is_bidi_control(*ch))
|
|
.collect::<String>();
|
|
let trimmed = cleaned.trim();
|
|
if trimmed.chars().count() > MAX_DISPLAY_LABEL {
|
|
trimmed
|
|
.chars()
|
|
.take(MAX_DISPLAY_LABEL)
|
|
.chain(std::iter::once('…'))
|
|
.collect()
|
|
} else {
|
|
trimmed.to_string()
|
|
}
|
|
}
|
|
|
|
/// Strip markdown heading prefixes and `Plan:` prefix from a goal string.
|
|
///
|
|
/// Takes the first line, removes all leading `#` characters, then strips
|
|
/// a `Plan:` prefix if present. Returns a trimmed `&str` slice.
|
|
pub fn strip_goal_decoration(goal: &str) -> &str {
|
|
let line = goal.lines().next().unwrap_or("");
|
|
let line = line.trim_start_matches('#').trim();
|
|
line.strip_prefix("Plan:").map_or(line, str::trim)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn sanitize_display_label_removes_ansi_and_control_characters() {
|
|
assert_eq!(sanitize_display_label("\u{1b}[31mauth\u{1b}[0m"), "auth");
|
|
assert_eq!(sanitize_display_label("auth\nreviewer"), "authreviewer");
|
|
assert_eq!(sanitize_display_label("auth\r\u{7}"), "auth");
|
|
assert_eq!(sanitize_display_label(" auth "), "auth");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_display_label_removes_bidi_reordering_marks() {
|
|
assert_eq!(sanitize_display_label("auth\u{202e}resu"), "authresu");
|
|
assert_eq!(sanitize_display_label("\u{2066}auth\u{2069}"), "auth");
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_display_label_elides_long_labels() {
|
|
let sanitized = sanitize_display_label(&"a".repeat(200));
|
|
assert_eq!(sanitized.chars().count(), MAX_DISPLAY_LABEL + 1);
|
|
assert!(sanitized.ends_with('…'));
|
|
}
|
|
|
|
#[test]
|
|
fn sanitize_display_label_is_empty_when_nothing_printable_remains() {
|
|
assert_eq!(sanitize_display_label(" "), "");
|
|
assert_eq!(sanitize_display_label("\u{1b}[0m\n\t"), "");
|
|
}
|
|
|
|
#[test]
|
|
fn strips_h1() {
|
|
assert_eq!(strip_goal_decoration("# Title"), "Title");
|
|
}
|
|
|
|
#[test]
|
|
fn strips_h2() {
|
|
assert_eq!(strip_goal_decoration("## Fix bug"), "Fix bug");
|
|
}
|
|
|
|
#[test]
|
|
fn strips_h3() {
|
|
assert_eq!(strip_goal_decoration("### Deep heading"), "Deep heading");
|
|
}
|
|
|
|
#[test]
|
|
fn strips_plan_prefix() {
|
|
assert_eq!(strip_goal_decoration("Plan: do stuff"), "do stuff");
|
|
}
|
|
|
|
#[test]
|
|
fn strips_heading_and_plan_prefix() {
|
|
assert_eq!(strip_goal_decoration("## Plan: migrate DB"), "migrate DB");
|
|
}
|
|
|
|
#[test]
|
|
fn plain_text_unchanged() {
|
|
assert_eq!(
|
|
strip_goal_decoration("Fix the login bug"),
|
|
"Fix the login bug"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn takes_first_line() {
|
|
assert_eq!(
|
|
strip_goal_decoration("## Plan: First\n\nMore details"),
|
|
"First"
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn empty_string() {
|
|
assert_eq!(strip_goal_decoration(""), "");
|
|
}
|
|
}
|