Skip to content

Commit 8dddcd3

Browse files
BunsDevclaude
andcommitted
feat(tui): welcome screen shows model, provider, daemon, familiar, goal
Closes the "first-screen experience" gap from the TUI audit. A user landing on the welcome screen previously saw only the mascot card on the left and "Tips for getting started" + "Recent activity" (always empty) on the right. There was no way to tell at a glance: * which model the session would use * which provider is active * whether the Coven daemon is reachable * which familiar is active and how to switch it * whether an active /goal is running This commit adds a "Status" block in the right column of the welcome box that surfaces all five. Daemon online/offline lights up in the accent colour when reachable; otherwise it falls back to dark grey ("Daemon: offline"). Familiar line includes the discoverability hint "(F2 to switch)" so users find the keyboard shortcut the audit flagged as undiscoverable. The Goal row is omitted when no goal is active so the layout stays compact. Five new pure helpers cover the formatting: welcome_model_label — prefers config override, falls back to effective_model() default welcome_provider_label — provider id or "anthropic" default welcome_daemon_label — cheap socket-existence check via DaemonClient::new() + is_online() (no RPC) welcome_familiar_label — "Familiar: <id>" with kitty default The small-terminal fallback (when area.height < 9 or width < 30) is also upgraded: instead of showing only "Coven Code v0.0.13" it now shows "Coven Code vX.Y · <model> · Daemon: online · Familiar: <id>" on a single line, so even a 24×9 tmux pane carries the key signals. Five new unit tests cover the formatters, including the daemon-label test that accepts either online/offline depending on the test machine (we never want to require live infra in CI). cargo test --workspace: 1530 passing, 0 failing (+5 new). All four verification gates pass clean: fmt, check, clippy -D warnings, test. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 11f381f commit 8dddcd3

1 file changed

Lines changed: 152 additions & 4 deletions

File tree

src-rust/crates/tui/src/render.rs

Lines changed: 152 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1573,13 +1573,58 @@ fn render_message_items(app: &App, width: u16) -> Vec<RenderedLineItem> {
15731573
// ── Welcome / startup screen ─────────────────────────────────────────────────
15741574

15751575
/// Render the two-column orange round-bordered welcome box (matches TS LogoV2).
1576+
/// Short, user-facing label for the active model. Falls back to the
1577+
/// configured default when no override is set.
1578+
fn welcome_model_label(app: &App) -> String {
1579+
app.config
1580+
.model
1581+
.as_deref()
1582+
.filter(|m| !m.is_empty())
1583+
.map(|m| m.to_string())
1584+
.unwrap_or_else(|| app.config.effective_model().to_string())
1585+
}
1586+
1587+
/// Short label for the active provider id (or "default" when no override).
1588+
fn welcome_provider_label(app: &App) -> String {
1589+
app.config
1590+
.provider
1591+
.as_deref()
1592+
.filter(|p| !p.is_empty())
1593+
.map(|p| p.to_string())
1594+
.unwrap_or_else(|| "anthropic".to_string())
1595+
}
1596+
1597+
/// One-glance daemon status: "Daemon: online" / "Daemon: offline". Cheap —
1598+
/// just a socket-existence check, no RPC.
1599+
fn welcome_daemon_label() -> String {
1600+
if claurst_core::coven_shared::DaemonClient::new()
1601+
.map(|c| c.is_online())
1602+
.unwrap_or(false)
1603+
{
1604+
"Daemon: online".to_string()
1605+
} else {
1606+
"Daemon: offline".to_string()
1607+
}
1608+
}
1609+
1610+
/// Familiar display name with the F2 switcher hint appended.
1611+
fn welcome_familiar_label(app: &App) -> String {
1612+
let id = app.config.familiar.as_deref().unwrap_or("kitty");
1613+
format!("Familiar: {id}")
1614+
}
1615+
15761616
fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) {
15771617
// --- Box dimensions ---
15781618
// The box should be at most the full area width, and a fixed height.
15791619
let box_width = area.width;
15801620
let box_height: u16 = WELCOME_BOX_HEIGHT;
15811621
if area.height < box_height || box_width < 30 {
1582-
// Too small: fall back to a single line
1622+
// Too small: collapse to a single-line status that still surfaces
1623+
// the model, daemon, and familiar so a user on a 24×9 terminal
1624+
// doesn't see a content-less "Coven Code v0.0.13" header.
1625+
let model = welcome_model_label(app);
1626+
let daemon = welcome_daemon_label();
1627+
let familiar = welcome_familiar_label(app);
15831628
let line = Line::from(vec![
15841629
Span::styled(
15851630
"Coven Code ",
@@ -1591,6 +1636,12 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) {
15911636
format!("v{}", APP_VERSION),
15921637
Style::default().fg(Color::DarkGray),
15931638
),
1639+
Span::styled(format!(" · {model}"), Style::default().fg(Color::DarkGray)),
1640+
Span::styled(format!(" · {daemon}"), Style::default().fg(Color::DarkGray)),
1641+
Span::styled(
1642+
format!(" · {familiar}"),
1643+
Style::default().fg(Color::DarkGray),
1644+
),
15941645
]);
15951646
frame.render_widget(Paragraph::new(vec![line]), area);
15961647
return;
@@ -1710,13 +1761,47 @@ fn render_welcome_box(frame: &mut Frame, app: &App, area: Rect) {
17101761
}
17111762
right_lines.push(Line::from(""));
17121763
right_lines.push(Line::from(Span::styled(
1713-
"Recent activity",
1764+
"Status",
17141765
Style::default().fg(accent).add_modifier(Modifier::BOLD),
17151766
)));
1767+
let model = welcome_model_label(app);
1768+
let provider = welcome_provider_label(app);
1769+
let daemon = welcome_daemon_label();
1770+
let familiar_id = app.config.familiar.as_deref().unwrap_or("kitty");
1771+
right_lines.push(Line::from(Span::styled(
1772+
format!("Model: {model}"),
1773+
Style::default().fg(Color::Gray),
1774+
)));
17161775
right_lines.push(Line::from(Span::styled(
1717-
"No recent activity",
1718-
Style::default().fg(Color::DarkGray),
1776+
format!("Provider: {provider}"),
1777+
Style::default().fg(Color::Gray),
17191778
)));
1779+
let daemon_style = if daemon.contains("online") {
1780+
Style::default().fg(accent)
1781+
} else {
1782+
Style::default().fg(Color::DarkGray)
1783+
};
1784+
right_lines.push(Line::from(Span::styled(daemon, daemon_style)));
1785+
right_lines.push(Line::from(vec![
1786+
Span::styled(
1787+
format!("Familiar: {familiar_id} "),
1788+
Style::default().fg(Color::Gray),
1789+
),
1790+
Span::styled("(F2 to switch)", Style::default().fg(Color::DarkGray)),
1791+
]));
1792+
if let Some(goal) = app.active_goal_badge.as_deref().filter(|s| !s.is_empty()) {
1793+
let truncated = if goal.chars().count() > right_w_usize.saturating_sub(8) {
1794+
let take = right_w_usize.saturating_sub(9).max(8);
1795+
let s: String = goal.chars().take(take).collect();
1796+
format!("{s}…")
1797+
} else {
1798+
goal.to_string()
1799+
};
1800+
right_lines.push(Line::from(Span::styled(
1801+
format!("Goal: {truncated}"),
1802+
Style::default().fg(accent).add_modifier(Modifier::BOLD),
1803+
)));
1804+
}
17201805

17211806
frame.render_widget(
17221807
Paragraph::new(right_lines).wrap(Wrap { trim: false }),
@@ -3300,3 +3385,66 @@ fn render_familiar_switcher(frame: &mut Frame, app: &App, area: Rect) {
33003385
state.select(Some(app.familiar_switcher_idx));
33013386
frame.render_stateful_widget(list, popup_area, &mut state);
33023387
}
3388+
3389+
#[cfg(test)]
3390+
mod welcome_tests {
3391+
use super::*;
3392+
3393+
fn make_test_app_with_model_and_familiar(
3394+
model: Option<&str>,
3395+
provider: Option<&str>,
3396+
familiar: Option<&str>,
3397+
goal: Option<&str>,
3398+
) -> App {
3399+
let config = claurst_core::config::Config {
3400+
model: model.map(str::to_string),
3401+
provider: provider.map(str::to_string),
3402+
familiar: familiar.map(str::to_string),
3403+
..claurst_core::config::Config::default()
3404+
};
3405+
let mut app = App::new(config, claurst_core::cost::CostTracker::new());
3406+
app.active_goal_badge = goal.map(str::to_string);
3407+
app
3408+
}
3409+
3410+
#[test]
3411+
fn welcome_model_label_prefers_config_override() {
3412+
let app = make_test_app_with_model_and_familiar(
3413+
Some("claude-haiku-4-5-20251001"),
3414+
None,
3415+
None,
3416+
None,
3417+
);
3418+
assert_eq!(welcome_model_label(&app), "claude-haiku-4-5-20251001");
3419+
}
3420+
3421+
#[test]
3422+
fn welcome_provider_label_falls_back_to_anthropic() {
3423+
let app = make_test_app_with_model_and_familiar(None, None, None, None);
3424+
assert_eq!(welcome_provider_label(&app), "anthropic");
3425+
}
3426+
3427+
#[test]
3428+
fn welcome_familiar_label_uses_kitty_by_default() {
3429+
let app = make_test_app_with_model_and_familiar(None, None, None, None);
3430+
assert_eq!(welcome_familiar_label(&app), "Familiar: kitty");
3431+
}
3432+
3433+
#[test]
3434+
fn welcome_familiar_label_reflects_config_override() {
3435+
let app = make_test_app_with_model_and_familiar(None, None, Some("sage"), None);
3436+
assert_eq!(welcome_familiar_label(&app), "Familiar: sage");
3437+
}
3438+
3439+
#[test]
3440+
fn welcome_daemon_label_is_one_of_two_strings() {
3441+
// Either string is acceptable — the test machine may or may not have
3442+
// the daemon socket. The label MUST be a stable, non-empty
3443+
// human-readable hint, not a raw IO error.
3444+
let label = welcome_daemon_label();
3445+
assert!(
3446+
label == "Daemon: online" || label == "Daemon: offline",
3447+
"unexpected daemon label: {label}"
3448+
);
3449+
}
3450+
}

0 commit comments

Comments
 (0)