use unicode_width::UnicodeWidthStr as _;
use super::{ConsoleInfo, DaemonInfo, WelcomeData};
#[cfg(test)]
use super::{LOGO, LOGO_COLS};
#[cfg(test)]
pub(crate) fn render_welcome_panel(data: &WelcomeData) -> String {
let rows = build_rows(data);
let right_w = rows.iter().map(|r| display_len(r)).max().unwrap_or(30);
let inner = 1 + LOGO_COLS + 1 + 1 + 1 + right_w + 1;
let top = format!("\u{256d}{}\u{256e}", "\u{2500}".repeat(inner));
let bot = format!("\u{2570}{}\u{256f}", "\u{2500}".repeat(inner));
let blank_logo = " ".repeat(LOGO_COLS);
let mut out = String::new();
out.push_str(&top);
out.push('\n');
for (i, row) in rows.iter().enumerate() {
let logo_line: &str = LOGO.get(i).copied().unwrap_or(&blank_logo);
let padded = pad_to(row, right_w);
out.push_str(&format!(
"\u{2502} {logo_line} \u{2502} {padded} \u{2502}\n"
));
}
out.push_str(&bot);
out.push('\n');
out
}
pub(crate) fn build_rows(data: &WelcomeData) -> Vec<String> {
let version = env!("CARGO_PKG_VERSION");
let workspace = super::abbreviate_home(&data.workspace);
let mut rows: Vec<String> = Vec::new();
rows.push(format!("trusty-mpm v{version}"));
if data.user.is_empty() {
rows.push(String::new());
} else {
rows.push(format!("Welcome back {}!", data.user));
}
rows.push(String::new());
rows.push(format!("project {}", data.project));
rows.push(format!("workspace {workspace}"));
if data.reconnecting && !data.session_name.is_empty() {
rows.push(format!(
"session \u{21a9} reconnecting ({})",
data.session_name
));
}
if !data.recent_commits.is_empty() {
rows.push(String::new());
rows.push("recent changes".to_string());
for c in &data.recent_commits {
let entry = format!(" {} \u{2022} {} \u{2022} {}", c.sha, c.age, c.subject);
rows.push(truncate_display(entry, 60));
}
}
rows.push(String::new());
rows.push("services".to_string());
rows.push(format_console_row(&data.console));
rows.push(format_daemon_row(&data.daemon));
rows.push(format_service_row("memory", &data.memory_status));
rows.push(format_service_row("search", &data.search_status));
rows.push(format_service_row("review", &data.review_status));
rows.push(String::new());
rows.push("commands".to_string());
rows.push(" tm sessions list tm sessions resume <id>".to_string());
rows.push(" tm connect tm stop".to_string());
rows.push(" tm status detach: ^b d".to_string());
rows
}
fn format_console_row(console: &ConsoleInfo) -> String {
let port = console
.addr
.rsplit_once(':')
.map(|(_, port)| port)
.filter(|port| console.online && !port.is_empty());
match port {
Some(port) => format!(" \u{25cf} console :{port}"),
None => " \u{25cb} console offline".to_string(),
}
}
fn format_daemon_row(daemon: &DaemonInfo) -> String {
if daemon.online {
let count_str = match daemon.session_count {
Some(n) => format!(" ({n})"),
None => String::new(),
};
format!(" \u{25cf} daemon running{count_str}")
} else {
" \u{25cb} daemon offline".to_string()
}
}
fn format_service_row(name: &str, status: &str) -> String {
if status == "(not detected)" {
format!(" \u{25cb} {name:<7} not detected")
} else {
format!(" \u{2713} {name:<7} {status}")
}
}
pub(crate) fn display_len(s: &str) -> usize {
s.width()
}
#[cfg(test)]
pub(crate) fn pad_to(s: &str, width: usize) -> String {
let len = display_len(s);
if len >= width {
s.to_string()
} else {
format!("{}{}", s, " ".repeat(width - len))
}
}
fn truncate_display(s: String, max_cols: usize) -> String {
if display_len(&s) <= max_cols {
return s;
}
let budget = max_cols.saturating_sub(1);
let mut cols_used: usize = 0;
let mut truncated = String::new();
for ch in s.chars() {
let ch_width = unicode_width::UnicodeWidthChar::width(ch).unwrap_or(1);
if cols_used + ch_width > budget {
break;
}
cols_used += ch_width;
truncated.push(ch);
}
format!("{truncated}\u{2026}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::formatters::info_box::CommitLine;
fn online_daemon(port: u16) -> DaemonInfo {
DaemonInfo {
addr: format!("127.0.0.1:{port}"),
online: true,
session_count: None,
}
}
fn offline_daemon() -> DaemonInfo {
DaemonInfo::default()
}
fn base_data() -> WelcomeData {
WelcomeData {
project: "owner/repo".to_string(),
workspace: "/home/user/projects/repo".to_string(),
user: "alice".to_string(),
reconnecting: false,
session_name: String::new(),
daemon: offline_daemon(),
console: ConsoleInfo::default(),
recent_commits: vec![],
memory_status: "(not detected)".to_string(),
search_status: "(not detected)".to_string(),
review_status: "(not detected)".to_string(),
}
}
#[test]
fn welcome_panel_renders_online() {
let data = WelcomeData {
daemon: online_daemon(7880).with_count(2),
..base_data()
};
let out = render_welcome_panel(&data);
assert!(out.contains('\u{25cf}'), "expected ● online marker");
assert!(out.contains("daemon running"), "expected running label");
assert!(out.contains("(2)"), "expected session count");
assert!(out.contains("owner/repo"), "expected project");
assert!(out.contains("alice"), "expected user name");
}
#[test]
fn welcome_panel_never_contains_the_daemon_port() {
for port in [7880u16, 9911] {
let data = WelcomeData {
daemon: online_daemon(port).with_count(2),
..base_data()
};
let panel = render_welcome_panel(&data);
let rows = crate::formatters::info_box::render_info_box_rows(&data).join("\n");
for (label, out) in [("panel", &panel), ("two-panel rows", &rows)] {
assert!(
!out.contains(&port.to_string()),
"{label}: the daemon port must not reach the operator: {out}"
);
assert!(
!out.contains("127.0.0.1"),
"{label}: the daemon host must not reach the operator: {out}"
);
assert!(
out.contains("daemon running"),
"{label}: liveness must still be reported: {out}"
);
assert!(out.contains("(2)"), "{label}: session count kept: {out}");
}
}
let offline = render_welcome_panel(&base_data());
assert!(
!offline.contains("127.0.0.1") && !offline.contains("7880"),
"offline row must name no address: {offline}"
);
}
#[test]
fn welcome_panel_renders_the_console_row_with_its_port() {
let data = WelcomeData {
console: ConsoleInfo {
addr: "127.0.0.1:9312".to_string(),
online: true,
},
..base_data()
};
let panel = render_welcome_panel(&data);
let rows = crate::formatters::info_box::render_info_box_rows(&data);
assert!(
panel.contains("console :9312"),
"the console row must name its own port: {panel}"
);
for (label, out) in [("panel", &panel), ("two-panel rows", &rows.join("\n"))] {
let console_at = out.find("console").expect("console row");
let daemon_at = out.find("daemon").expect("daemon row");
assert!(
console_at < daemon_at,
"{label}: the console row renders first: {out}"
);
}
}
#[test]
fn welcome_panel_console_row_offline_names_no_port() {
for console in [
ConsoleInfo {
addr: "127.0.0.1:9312".to_string(),
online: false,
},
ConsoleInfo {
addr: "localhost".to_string(),
online: true,
},
ConsoleInfo::default(),
] {
let row = format_console_row(&console);
assert_eq!(row, " \u{25cb} console offline", "{console:?}");
}
}
#[test]
fn welcome_panel_renders_offline() {
let out = render_welcome_panel(&base_data());
assert!(out.contains('\u{25cb}'), "expected ○ marker somewhere");
assert!(out.contains("offline"), "expected offline for daemon");
}
#[test]
fn welcome_panel_renders_reconnecting() {
let data = WelcomeData {
reconnecting: true,
session_name: "tmpm-my-proj".to_string(),
..base_data()
};
let out = render_welcome_panel(&data);
assert!(out.contains("reconnecting"), "expected reconnecting text");
assert!(out.contains("tmpm-my-proj"), "expected session name");
}
#[test]
fn welcome_panel_shows_commits() {
let data = WelcomeData {
recent_commits: vec![
CommitLine {
sha: "abc1234".to_string(),
age: "2h".to_string(),
subject: "fix: something important".to_string(),
},
CommitLine {
sha: "def5678".to_string(),
age: "1d".to_string(),
subject: "feat: add new feature".to_string(),
},
],
..base_data()
};
let out = render_welcome_panel(&data);
assert!(out.contains("recent changes"), "expected section header");
assert!(out.contains("abc1234"), "expected first sha");
assert!(out.contains("2h"), "expected first age");
assert!(out.contains("fix: something"), "expected first subject");
assert!(out.contains("def5678"), "expected second sha");
}
#[test]
fn welcome_panel_omits_commits_when_empty() {
let out = render_welcome_panel(&base_data());
assert!(
!out.contains("recent changes"),
"section must not appear when commits is empty"
);
}
#[test]
fn welcome_panel_truncates_subject() {
let long_subject = "a".repeat(100);
let data = WelcomeData {
recent_commits: vec![CommitLine {
sha: "abc1234".to_string(),
age: "1h".to_string(),
subject: long_subject,
}],
..base_data()
};
let out = render_welcome_panel(&data);
for line in out.lines() {
assert!(
line.chars().count() <= 200,
"line too long (>200 cols): {line:?}"
);
}
assert!(
out.contains('\u{2026}'),
"expected ellipsis on truncated subject"
);
}
#[test]
fn welcome_panel_services_up_vs_down() {
let data = WelcomeData {
memory_status: "trusty-memory".to_string(),
search_status: "(not detected)".to_string(),
review_status: "(not detected)".to_string(),
..base_data()
};
let out = render_welcome_panel(&data);
let memory_line = out
.lines()
.find(|l| l.contains("memory"))
.expect("memory line must exist");
assert!(
memory_line.contains('\u{2713}'),
"detected memory must show ✓: {memory_line:?}"
);
let search_line = out
.lines()
.find(|l| l.contains("search"))
.expect("search line must exist");
assert!(
search_line.contains('\u{25cb}'),
"undetected search must show ○: {search_line:?}"
);
}
#[test]
fn welcome_panel_no_checkmark_on_not_detected() {
let out = render_welcome_panel(&base_data());
for line in out.lines() {
if line.contains("not detected") {
assert!(
!line.contains('\u{2713}'),
"✓ must not appear on a not-detected line: {line:?}"
);
}
}
}
#[test]
fn welcome_panel_contains_commands_cheatsheet() {
let out = render_welcome_panel(&base_data());
assert!(
out.contains("tm sessions list"),
"expected sessions list command"
);
assert!(out.contains("tm connect"), "expected connect command");
assert!(out.contains("tm stop"), "expected stop command");
assert!(out.contains("^b d"), "expected detach hint");
}
#[test]
fn welcome_panel_no_user_graceful() {
let data = WelcomeData {
user: String::new(),
..base_data()
};
let out = render_welcome_panel(&data);
assert!(out.contains("trusty-mpm"), "box must still render");
assert!(
!out.contains("Welcome back"),
"must omit greeting when user is empty"
);
}
#[test]
fn display_len_ascii_equals_char_count() {
let s = "hello world";
assert_eq!(display_len(s), s.chars().count());
}
#[test]
fn display_len_cjk_wider_than_char_count() {
let s = "修复";
assert_eq!(s.chars().count(), 2, "codepoint count");
assert_eq!(display_len(s), 4, "display width must be 4 for 2 CJK chars");
}
#[test]
fn truncate_display_short_string_unchanged() {
let s = "hello".to_string();
assert_eq!(truncate_display(s.clone(), 80), s);
}
#[test]
fn truncate_display_long_string_gets_ellipsis() {
let s = "a".repeat(70);
let result = truncate_display(s, 60);
assert!(result.ends_with('\u{2026}'), "must end with ellipsis");
assert!(display_len(&result) <= 60, "must be at most 60 cols");
}
#[test]
fn truncate_display_cjk_respects_column_budget() {
let s = format!("feat: {}", "修".repeat(27));
assert!(display_len(&s) > 20, "sanity: input is wide");
let result = truncate_display(s, 20);
assert!(
result.ends_with('\u{2026}'),
"must end with ellipsis: {result:?}"
);
assert!(
display_len(&result) <= 20,
"truncated result must fit in 20 cols, got {} cols: {result:?}",
display_len(&result)
);
}
#[test]
fn welcome_panel_cjk_commit_box_alignment() {
let data = WelcomeData {
recent_commits: vec![CommitLine {
sha: "abc1234".to_string(),
age: "1h".to_string(),
subject: "feat: 修复登录问题并优化性能".to_string(),
}],
..base_data()
};
let out = render_welcome_panel(&data);
for line in out.lines() {
if line.starts_with('\u{2502}') {
assert!(
line.ends_with('\u{2502}'),
"box border must close with │: {line:?}"
);
}
}
assert!(out.contains("abc1234"), "sha must be present");
}
#[test]
fn welcome_panel_smoke_render() {
let data = WelcomeData {
project: "duetto-research/trusty-tools".to_string(),
workspace: "/Users/masa/Projects/trusty-tools".to_string(),
user: "masa".to_string(),
reconnecting: false,
session_name: String::new(),
daemon: DaemonInfo {
addr: "127.0.0.1:7880".to_string(),
online: true,
session_count: Some(2),
},
console: ConsoleInfo {
addr: "127.0.0.1:7788".to_string(),
online: true,
},
recent_commits: vec![
CommitLine {
sha: "83f30ba".to_string(),
age: "2h".to_string(),
subject: "fix: guided resume restarts a stopped session".to_string(),
},
CommitLine {
sha: "8c7665b".to_string(),
age: "6h".to_string(),
subject: "fix: decouple AppState build from MCP initialize".to_string(),
},
CommitLine {
sha: "db0a115".to_string(),
age: "1d".to_string(),
subject: "feat: tm welcome banner box + rich statusline".to_string(),
},
],
memory_status: "trusty-memory".to_string(),
search_status: "trusty-search".to_string(),
review_status: "(not detected)".to_string(),
};
let out = render_welcome_panel(&data);
println!("\n{out}");
assert!(out.contains("trusty-mpm"), "must contain branding");
assert!(out.contains("Welcome back masa!"), "must contain greeting");
assert!(out.contains("83f30ba"), "must contain first sha");
assert!(out.contains("trusty-memory"), "must show memory detected");
assert!(out.contains("commands"), "must contain commands section");
}
}