diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index 683cab11..a2d6674d 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -517,6 +517,34 @@ fn poll_once(socket: &Path, claimed: &Arc>>) { // Task execution // --------------------------------------------------------------------------- +/// Max characters kept from the command's first line when building the +/// "running" todo summary (see `short_cmd_label`). +const CMD_LABEL_MAX_CHARS: usize = 100; + +/// Build a short, single-line label for `cmd` to use in the "running" +/// todo summary. `cmd` can be an entire multi-line script (heredocs are +/// a common agent pattern) — embedding it whole balloons the todo body +/// in every UI that renders it, so this keeps only the first non-blank +/// line, char-truncated to `CMD_LABEL_MAX_CHARS`, with a trailing `…` +/// whenever either the line itself was cut or more lines follow. The +/// full command is still on disk in the task file for `status`/`view`. +fn short_cmd_label(cmd: &str) -> String { + let mut lines = cmd.lines().map(str::trim).filter(|l| !l.is_empty()); + let first = lines.next().unwrap_or(""); + let more_lines = lines.next().is_some(); + let char_truncated = first.chars().count() > CMD_LABEL_MAX_CHARS; + let label: String = if char_truncated { + first.chars().take(CMD_LABEL_MAX_CHARS).collect() + } else { + first.to_owned() + }; + if char_truncated || more_lines { + format!("{label}…") + } else { + label + } +} + async fn run_task(mut task: TaskFile, socket: &Path) { let id = task.id.clone(); tracing::info!(id = %id, cmd = %task.cmd, "bash_runner: starting task"); @@ -529,7 +557,7 @@ async fn run_task(mut task: TaskFile, socket: &Path) { upsert_bash_todo( socket, &id, - format!("bash task `{id}` running: `{}`", task.cmd), + format!("bash task `{id}` running: `{}`", short_cmd_label(&task.cmd)), ) .await; // Best-effort: tally the normalised command head for the /stats @@ -830,10 +858,43 @@ fn done_summary(id: &str, summary: &str, output: Option<(bool, bool)>) -> String #[cfg(test)] mod tests { - use super::validate_task_name; + use super::{CMD_LABEL_MAX_CHARS, short_cmd_label, validate_task_name}; use crate::test_util::with_harness_dir; use hive_types::Ident; + #[test] + fn short_cmd_label_short_single_line_is_unchanged() { + assert_eq!(short_cmd_label("echo hi"), "echo hi"); + } + + #[test] + fn short_cmd_label_trims_and_skips_leading_blank_lines() { + assert_eq!(short_cmd_label("\n\n echo hi \nmore stuff"), "echo hi…"); + } + + #[test] + fn short_cmd_label_flags_multiline_even_when_first_line_is_short() { + // The whole point: a short first line followed by a long heredoc + // body must still read as truncated, not as the complete command. + let cmd = "cat > script.sh <<'SH'\n#!/usr/bin/env bash\necho hi\nSH\n"; + let label = short_cmd_label(cmd); + assert_eq!(label, "cat > script.sh <<'SH'…"); + } + + #[test] + fn short_cmd_label_char_truncates_a_long_single_line() { + let long = "x".repeat(CMD_LABEL_MAX_CHARS + 50); + let label = short_cmd_label(&long); + assert_eq!(label.chars().count(), CMD_LABEL_MAX_CHARS + 1); // +1 for the `…` + assert!(label.ends_with('…')); + } + + #[test] + fn short_cmd_label_handles_empty_command() { + assert_eq!(short_cmd_label(""), ""); + assert_eq!(short_cmd_label(" \n \n"), ""); + } + #[test] fn accepts_ident_names() { for ok in ["build", "ci-check", "t1", "task-123", "abc"] {