mermaid_cli/render/widgets/
session_header.rs1use 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
16pub const SESSION_HEADER_HEIGHT: u16 = 2;
18
19#[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#[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 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#[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
80fn 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 #[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 #[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}