Skip to main content

mermaid_cli/render/widgets/
session_header.rs

1//! The two dim lines an empty transcript opens with: what this session is
2//! (version, model, directory) and how to drive it. They disappear with the
3//! first message, so a running conversation never carries them, and they come
4//! back on `/clear`.
5
6use ratatui::style::Style;
7use ratatui::text::{Line, Span};
8use std::path::{Path, PathBuf};
9use unicode_width::UnicodeWidthStr;
10
11use super::truncate_to_cells;
12use crate::render::theme::Theme;
13use mermaid_domain::{State, TurnState};
14use mermaid_model::models::MessageRole;
15
16/// Rows the header takes when visible.
17pub const SESSION_HEADER_HEIGHT: u16 = 2;
18
19/// Visible while every committed message is a system notice and no turn is
20/// running. Not `messages().is_empty()`: the startup web-capability notice is
21/// a system message that only appears on degraded machines, and keying on it
22/// would make the header (and the pty frames CI compares) environment-
23/// dependent. `/clear` brings the header back; `--resume` with content hides it.
24#[must_use]
25pub fn session_header_visible(state: &State) -> bool {
26    matches!(state.turn, TurnState::Idle)
27        && state
28            .session
29            .messages()
30            .iter()
31            .all(|message| message.role == MessageRole::System)
32}
33
34/// The two header lines, both in the meta colour, each fitted to `width`.
35#[must_use]
36pub fn build_session_header(
37    version: &str,
38    model_id: &str,
39    cwd: &Path,
40    home: Option<&Path>,
41    width: usize,
42    theme: &Theme,
43) -> Vec<Line<'static>> {
44    let meta = Style::new().fg(theme.colors.text_meta.to_color());
45    // The directory is the segment that overflows, so it is the segment that
46    // shortens: a long checkout path keeps its tail after an ellipsis while
47    // the version and model stay whole. Only a width too narrow even for
48    // those clips the line as a whole.
49    let prefix = format!("mermaid v{version} · {model_id} · ");
50    let dir = abbreviate_home(cwd, home).display().to_string();
51    let identity = if prefix.width() < width {
52        format!("{prefix}{}", fit_left(&dir, width - prefix.width()))
53    } else {
54        fit_left(&format!("{prefix}{dir}"), width)
55    };
56    let hints = truncate_to_cells(
57        "/help for commands · shift+tab cycles safety · esc interrupts",
58        width,
59    );
60    vec![
61        Line::from(Span::styled(identity, meta)),
62        Line::from(Span::styled(hints, meta)),
63    ]
64}
65
66/// `~` for the home directory itself, `~/rest` beneath it (joined, so Windows
67/// keeps its separator), the path unchanged otherwise.
68#[must_use]
69pub fn abbreviate_home(cwd: &Path, home: Option<&Path>) -> PathBuf {
70    match home {
71        Some(home) if cwd == home => PathBuf::from("~"),
72        Some(home) => match cwd.strip_prefix(home) {
73            Ok(rest) => PathBuf::from("~").join(rest),
74            Err(_) => cwd.to_path_buf(),
75        },
76        None => cwd.to_path_buf(),
77    }
78}
79
80/// Truncate from the LEFT so a long path keeps its tail, which is the part
81/// that tells one checkout from another.
82fn fit_left(text: &str, width: usize) -> String {
83    if text.width() <= width {
84        return text.to_string();
85    }
86    let keep = width.saturating_sub(1);
87    let mut tail = String::new();
88    let mut used = 0;
89    for ch in text.chars().rev() {
90        let w = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(0);
91        if used + w > keep {
92            break;
93        }
94        used += w;
95        tail.insert(0, ch);
96    }
97    format!("…{tail}")
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn session_header_abbreviates_home() {
106        let home = Path::new("/home/u");
107        assert_eq!(
108            abbreviate_home(Path::new("/home/u/x"), Some(home)),
109            PathBuf::from("~").join("x")
110        );
111        assert_eq!(abbreviate_home(home, Some(home)), PathBuf::from("~"));
112        assert_eq!(
113            abbreviate_home(Path::new("/srv/x"), Some(home)),
114            PathBuf::from("/srv/x")
115        );
116        assert_eq!(
117            abbreviate_home(Path::new("/srv/x"), None),
118            PathBuf::from("/srv/x")
119        );
120    }
121
122    /// A long checkout path shortens from its own left edge; the version and
123    /// model in front of it stay whole. (The Windows CI runner's temp path is
124    /// long enough to overflow 80 columns, and clipping the whole line there
125    /// hid the `mermaid v` the harness keys its redaction on.)
126    #[test]
127    fn a_long_path_keeps_its_tail_and_the_prefix_stays_whole() {
128        let theme = Theme::dark();
129        let deep = PathBuf::from("/".to_string() + &"segment/".repeat(40) + "mermaid-cli");
130        let lines = build_session_header("0.0.0", "ollama/test", &deep, None, 60, &theme);
131        let first: String = lines[0]
132            .spans
133            .iter()
134            .map(|s| s.content.to_string())
135            .collect();
136        assert_eq!(first.width(), 60, "{first}");
137        assert!(first.ends_with("mermaid-cli"), "{first}");
138        assert!(
139            first.starts_with("mermaid v0.0.0 · ollama/test · …"),
140            "{first}"
141        );
142    }
143
144    /// Narrower than the version and model alone: the whole line clips, tail
145    /// first, rather than printing past the edge.
146    #[test]
147    fn a_width_too_narrow_for_the_prefix_clips_the_whole_line() {
148        let theme = Theme::dark();
149        let lines = build_session_header("0.0.0", "ollama/test", Path::new("/x"), None, 12, &theme);
150        let first: String = lines[0]
151            .spans
152            .iter()
153            .map(|s| s.content.to_string())
154            .collect();
155        assert_eq!(first.width(), 12, "{first}");
156        assert!(first.starts_with('…'), "{first}");
157    }
158}