//! Session display-title helpers shared by the dashboard and other surfaces. //! //! Title derivation order ([`entry_title`]): //! 1. `AgentView::display_name` if set (post-rename), //! 2. else `AgentView::generated_session_title` (LLM title or from disk on resume), //! 3. else the trimmed first ~60 chars of the first user-prompt block in scrollback, //! 4. else `"session abc12345"` (or `"loading..."` when no session id //! is established yet). use std::borrow::Cow; use std::time::Duration; use crate::app::agent_view::AgentView; use crate::scrollback::block::RenderBlock; /// Maximum characters of a derived first-prompt title. const MAX_TITLE_CHARS: usize = 60; /// Derive the display title for an agent (rename > generated title > first-prompt > id). /// /// Centralised so every surface that shows a session name agrees on the same /// precedence. Trimming and truncation happen in this single place to avoid drift. pub fn entry_title(agent: &AgentView) -> String { if let Some(name) = agent.display_name.as_deref() { let trimmed = name.trim(); if !trimmed.is_empty() { return truncate_title(&sanitize_display_text(trimmed)); } } if let Some(title) = agent.generated_session_title.as_deref() { let trimmed = title.trim(); if !trimmed.is_empty() { let clean = kigi_tools::implementations::skills::skill::extract_skill_display_text(trimmed); let text = clean.as_deref().unwrap_or(trimmed); return truncate_title(&sanitize_display_text(text)); } } if let Some(text) = first_user_prompt_text(agent) { let trimmed = text.trim(); if !trimmed.is_empty() { let clean = kigi_tools::implementations::skills::skill::extract_skill_display_text(trimmed); let display = clean.as_deref().unwrap_or(trimmed); return truncate_title(&sanitize_display_text(display)); } } match agent.session.session_id.as_ref() { Some(sid) => { let short: String = sid.0.chars().take(8).collect(); format!("session {short}") } None => "loading...".to_string(), } } /// Take the first scrollback `UserPrompt` block's text, if any. /// /// Skips indices whose `entry()` returns `None` (defensive: the indexed /// range matches `scrollback.len()` so this should not happen in /// practice) instead of bailing out with `?`, which would conflate /// "no UserPrompt anywhere" with "hit an unexpected gap mid-scan". fn first_user_prompt_text(agent: &AgentView) -> Option { for i in 0..agent.scrollback.len() { if let Some(entry) = agent.scrollback.entry(i) && let RenderBlock::UserPrompt(block) = &entry.block { return Some(block.text.clone()); } } None } /// Take the first `MAX_TITLE_CHARS` chars and append an ellipsis when /// truncated. Char-based (not byte-based) so multi-byte codepoints /// don't get split. fn truncate_title(text: &str) -> String { if text.chars().count() <= MAX_TITLE_CHARS { return text.to_string(); } let head: String = text.chars().take(MAX_TITLE_CHARS).collect(); format!("{head}...") } /// Strip ASCII control characters (`0x00-0x1f` and `0x7f`) that could /// inject terminal escape sequences (CSI, OSC, BEL, etc.) into the /// rendered output. Replaces stripped chars with `U+FFFD` so the caller /// can still see something was there. /// /// Returns `Cow::Borrowed(s)` when no sanitization is needed, so the /// common per-render call on a clean cached display_name does not /// allocate. pub(crate) fn sanitize_display_text(s: &str) -> Cow<'_, str> { if s.chars().any(|c| c.is_ascii_control()) { Cow::Owned( s.chars() .map(|c| if c.is_ascii_control() { '\u{FFFD}' } else { c }) .collect(), ) } else { Cow::Borrowed(s) } } /// Format an elapsed duration as a compact relative label (`now`, `30s ago`, /// `5m ago`, `2h ago`, `3d ago`). Shared by the dashboard and project picker. pub(crate) fn format_relative_time(elapsed: Duration) -> String { let secs = elapsed.as_secs(); if secs < 1 { return "now".to_string(); } if secs < 60 { return format!("{secs}s ago"); } let mins = secs / 60; if mins < 60 { return format!("{mins}m ago"); } let hours = mins / 60; if hours < 24 { return format!("{hours}h ago"); } let days = hours / 24; format!("{days}d ago") } #[cfg(test)] mod tests { use super::*; // ── sanitize_display_text ─────────────────────────────────────── #[test] fn sanitize_passes_through_clean_ascii_unchanged_no_alloc() { let s = "session foo bar"; let out = sanitize_display_text(s); assert_eq!(out.as_ref(), s); assert!(matches!(out, Cow::Borrowed(_))); } #[test] fn sanitize_passes_through_unicode_widechars() { for s in ["セッション one", "session 🦀 two", "naïve"] { let out = sanitize_display_text(s); assert_eq!(out.as_ref(), s); assert!(matches!(out, Cow::Borrowed(_)), "input={s:?}"); } } #[test] fn sanitize_strips_osc_escape_sequence() { // Attack: OSC title-set + clear screen. let attack = "\x1b]0;PWNED\x07\x1b[2J safe text"; let out = sanitize_display_text(attack); assert!(matches!(out, Cow::Owned(_))); for c in out.chars() { assert!(!c.is_ascii_control(), "leaked control char: {:?}", c); } // Replacement chars should be present where escapes were. assert!(out.contains('\u{FFFD}')); assert!(out.ends_with(" safe text")); } #[test] fn sanitize_strips_csi_sequence() { let csi = "\x1b[31mred\x1b[0m"; let out = sanitize_display_text(csi); for c in out.chars() { assert!(!c.is_ascii_control()); } } #[test] fn sanitize_strips_bel_and_del() { let s = "ring\x07the\x7fbell"; let out = sanitize_display_text(s); assert_eq!(out.as_ref(), "ring\u{FFFD}the\u{FFFD}bell"); } #[test] fn sanitize_strips_tab_newline_carriage_return() { // Tabs and newlines are also ASCII controls -- a single-line // rename input should never contain them, so strip all. let s = "a\tb\nc\rd"; let out = sanitize_display_text(s); assert_eq!(out.as_ref(), "a\u{FFFD}b\u{FFFD}c\u{FFFD}d"); } #[test] fn sanitize_empty_returns_empty_borrowed() { let out = sanitize_display_text(""); assert_eq!(out.as_ref(), ""); assert!(matches!(out, Cow::Borrowed(_))); } // ── truncate_title ────────────────────────────────────────────── #[test] fn truncate_title_keeps_short_strings() { assert_eq!(truncate_title("hello"), "hello"); } #[test] fn truncate_title_appends_ellipsis_when_too_long() { let long = "x".repeat(MAX_TITLE_CHARS + 5); let out = truncate_title(&long); assert!(out.ends_with("...")); assert_eq!(out.chars().count(), MAX_TITLE_CHARS + 3); } #[test] fn truncate_title_handles_multibyte_codepoints_safely() { // Each "é" (U+00E9) is one char (two bytes); ensure char-based // truncation does not split a multibyte codepoint mid-byte. // This does NOT exercise grapheme-cluster handling -- a // decomposed sequence (e + U+0301) would split at the // codepoint boundary today; that's a separate concern. let s: String = std::iter::repeat_n('é', MAX_TITLE_CHARS + 2).collect(); let out = truncate_title(&s); assert!(out.ends_with("...")); assert_eq!(out.chars().count(), MAX_TITLE_CHARS + 3); } // ── format_relative_time ──────────────────────────────────────── #[test] fn format_relative_time_sub_second_is_now() { assert_eq!(format_relative_time(Duration::from_millis(0)), "now"); assert_eq!(format_relative_time(Duration::from_millis(500)), "now"); assert_eq!(format_relative_time(Duration::from_millis(999)), "now"); } #[test] fn format_relative_time_seconds() { assert_eq!(format_relative_time(Duration::from_secs(1)), "1s ago"); assert_eq!(format_relative_time(Duration::from_secs(30)), "30s ago"); assert_eq!(format_relative_time(Duration::from_secs(59)), "59s ago"); } #[test] fn format_relative_time_minutes() { assert_eq!(format_relative_time(Duration::from_secs(60)), "1m ago"); assert_eq!(format_relative_time(Duration::from_secs(120)), "2m ago"); assert_eq!( format_relative_time(Duration::from_secs(59 * 60)), "59m ago" ); } #[test] fn format_relative_time_hours() { assert_eq!(format_relative_time(Duration::from_secs(60 * 60)), "1h ago"); assert_eq!( format_relative_time(Duration::from_secs(2 * 60 * 60)), "2h ago" ); assert_eq!( format_relative_time(Duration::from_secs(23 * 60 * 60)), "23h ago" ); } #[test] fn format_relative_time_days() { assert_eq!( format_relative_time(Duration::from_secs(24 * 60 * 60)), "1d ago" ); assert_eq!( format_relative_time(Duration::from_secs(3 * 24 * 60 * 60)), "3d ago" ); } }