Skip to main content

koda_cli/
startup.rs

1//! Startup banner and initial messages.
2//!
3//! All builder functions return `Vec<Line<'static>>` which get pushed
4//! into the scroll buffer during `TuiContext::new()`.
5
6use crate::tui_output::{DIM, WARM_ACCENT, WARM_INFO, WARM_MUTED, WARM_TITLE};
7use koda_core::config::KodaConfig;
8use ratatui::{
9    style::{Color, Style},
10    text::{Line, Span},
11};
12
13// ── Banner ───────────────────────────────────────────
14
15pub fn build_banner_lines(
16    model: &str,
17    provider: &str,
18    cwd: &str,
19    _recent_activity: &[String],
20) -> Vec<Line<'static>> {
21    let ver = env!("CARGO_PKG_VERSION");
22
23    const BEAR: [&str; 3] = [
24        "\u{259e}\u{2580}\u{259a}\u{2584}\u{2584}\u{259e}\u{2580}\u{259a}",
25        "\u{258c}\u{00b7}\u{2590}\u{2580}\u{258c}\u{00b7}\u{2590} ",
26        "\u{2580}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2584}\u{2580}",
27    ];
28
29    vec![
30        Line::default(),
31        Line::from(vec![
32            Span::styled(format!(" {}", BEAR[0]), WARM_ACCENT),
33            Span::raw("  "),
34            Span::styled(format!("Koda v{ver}"), WARM_TITLE),
35        ]),
36        Line::from(vec![
37            Span::styled(format!(" {}", BEAR[1]), WARM_ACCENT),
38            Span::raw("  "),
39            Span::styled(model.to_string(), WARM_INFO),
40            Span::styled(" \u{00b7} ", WARM_MUTED),
41            Span::styled(provider.to_string(), WARM_MUTED),
42        ]),
43        Line::from(vec![
44            Span::styled(format!(" {}", BEAR[2]), WARM_ACCENT),
45            Span::raw("  "),
46            Span::styled(cwd.to_string(), DIM),
47        ]),
48        Line::from(vec![
49            Span::styled("  /", WARM_ACCENT),
50            Span::styled("commands", DIM),
51            Span::styled("  @", WARM_ACCENT),
52            Span::styled("file", DIM),
53            Span::styled("  Shift+Tab ", WARM_ACCENT),
54            Span::styled("mode", DIM),
55            Span::styled("  Ctrl+C ", WARM_ACCENT),
56            Span::styled("cancel", DIM),
57            Span::styled("  PgUp/PgDn ", WARM_ACCENT),
58            Span::styled("scroll", DIM),
59            Span::styled("  Ctrl+Y ", WARM_ACCENT),
60            Span::styled("copy code", DIM),
61            Span::styled("  Ctrl+U ", WARM_ACCENT),
62            Span::styled("copy response", DIM),
63            Span::styled("  Ctrl+D ", WARM_ACCENT),
64            Span::styled("quit", DIM),
65        ]),
66        Line::default(),
67    ]
68}
69
70/// Collect all startup lines (banner + warnings + notices).
71pub fn collect_startup_lines(
72    config: &KodaConfig,
73    recent_activity: &[String],
74) -> Vec<Line<'static>> {
75    let cwd = pretty_cwd();
76    let mut lines = build_banner_lines(
77        &config.model,
78        &config.provider_type.to_string(),
79        &cwd,
80        recent_activity,
81    );
82
83    // Model warnings
84    if config.model == "(no model loaded)" {
85        lines.push(Line::from(vec![
86            Span::styled("  \u{26a0} ", Style::new().fg(Color::Yellow)),
87            Span::styled(
88                format!("No model loaded in {}.", config.provider_type),
89                Style::new().fg(Color::Yellow),
90            ),
91        ]));
92        lines.push(Line::styled(
93            "  Load a model, then use /model to select it.",
94            DIM,
95        ));
96    } else if config.model == "(connection failed)" {
97        lines.push(Line::from(vec![
98            Span::styled("  \u{2717} ", Style::new().fg(Color::Red)),
99            Span::styled(
100                format!(
101                    "Could not connect to {} at {}",
102                    config.provider_type, config.base_url
103                ),
104                Style::new().fg(Color::Red),
105            ),
106        ]));
107    }
108
109    lines
110}
111
112/// Build update-available notice lines.
113pub fn update_notice_lines(current: &str, latest: &str) -> Vec<Line<'static>> {
114    let crate_name = koda_core::version::crate_name();
115    vec![
116        Line::from(vec![
117            Span::styled("  \u{2728} Update available: ", DIM),
118            Span::styled(current.to_string(), WARM_ACCENT),
119            Span::styled(" \u{2192} ", DIM),
120            Span::styled(latest.to_string(), Style::new().fg(Color::Green)),
121            Span::styled(format!("  (cargo install {crate_name})"), DIM),
122        ]),
123        Line::default(),
124    ]
125}
126
127/// Build purge nudge lines.
128pub fn purge_nudge_lines(size_str: &str) -> Vec<Line<'static>> {
129    vec![Line::from(vec![
130        Span::styled("  \u{1f4a1} ", Style::default()),
131        Span::styled(
132            format!("{size_str} of archived history \u{2014} run /purge to clean up"),
133            DIM,
134        ),
135    ])]
136}
137
138/// Build home-directory footgun warning lines.
139///
140/// Returns empty vec when project root is NOT the home directory.
141pub fn home_dir_warning_lines(project_root: &std::path::Path) -> Vec<Line<'static>> {
142    let home = match std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE")) {
143        Ok(h) => h,
144        Err(_) => return vec![],
145    };
146    let home_path = match std::fs::canonicalize(&home) {
147        Ok(p) => p,
148        Err(_) => return vec![],
149    };
150    if project_root != home_path {
151        return vec![];
152    }
153    vec![
154        Line::from(vec![
155            Span::styled("  \u{26a0}\u{fe0f}  ", Style::new().fg(Color::Yellow)),
156            Span::styled(
157                format!(
158                    "Project root is your home directory ({}).",
159                    project_root.display()
160                ),
161                Style::new().fg(Color::Yellow),
162            ),
163        ]),
164        Line::styled(
165            "     koda can modify any file in this tree. Consider running from a project subdirectory.",
166            Style::new().fg(Color::Yellow),
167        ),
168        Line::default(),
169    ]
170}
171
172/// Print session resume hint (after raw mode ends, to stdout).
173///
174/// NOTE: Session IDs are non-sensitive local identifiers (UUIDs stored in
175/// a local SQLite database). Printing them to stdout is intentional UX —
176/// the user needs the ID to resume their session. This is not a credential
177/// leak. (Addresses CodeQL alert #7 / `rust/cleartext-logging`.)
178pub fn print_resume_hint(session_id: &str) {
179    println!("\nResume this session with:\n  koda --resume {session_id}");
180}
181
182/// Nudge threshold: 500MB of compacted data.
183pub const PURGE_NUDGE_BYTES: i64 = 500 * 1024 * 1024;
184
185/// Append purge nudge lines if compacted data exceeds threshold.
186pub async fn purge_nudge(db: &koda_core::db::Database, lines: &mut Vec<Line<'static>>) {
187    use koda_core::persistence::Persistence;
188    match db.compacted_stats().await {
189        Ok(stats) if stats.size_bytes >= PURGE_NUDGE_BYTES => {
190            let size = crate::tui_wizards::format_bytes(stats.size_bytes);
191            lines.extend(purge_nudge_lines(&size));
192        }
193        _ => {}
194    }
195}
196
197// ── Helpers ───────────────────────────────────────────
198
199fn pretty_cwd() -> String {
200    let cwd = std::env::current_dir().unwrap_or_default();
201    if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"))
202        && let Ok(rest) = cwd.strip_prefix(&home)
203    {
204        return format!("~/{}", rest.display())
205            .trim_end_matches('/')
206            .to_string();
207    }
208    cwd.display().to_string()
209}
210
211#[cfg(test)]
212pub(crate) fn lines_to_text(lines: &[Line]) -> String {
213    lines
214        .iter()
215        .map(|l| {
216            l.spans
217                .iter()
218                .map(|s| s.content.as_ref())
219                .collect::<String>()
220        })
221        .collect::<Vec<_>>()
222        .join("\n")
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228
229    #[test]
230    fn home_dir_warning_when_at_home() {
231        // Use a path that definitely is NOT the home dir so we get empty.
232        let lines = home_dir_warning_lines(std::path::Path::new("/tmp/definitely-not-home"));
233        assert!(
234            lines.is_empty(),
235            "Should produce no warning for non-home dir"
236        );
237    }
238
239    #[test]
240    fn home_dir_warning_contains_text() {
241        // When project_root == home, we should get warning lines.
242        if let Ok(home) = std::env::var("HOME").or_else(|_| std::env::var("USERPROFILE"))
243            && let Ok(home_path) = std::fs::canonicalize(&home)
244        {
245            let lines = home_dir_warning_lines(&home_path);
246            let text = lines_to_text(&lines);
247            assert!(
248                text.contains("home directory"),
249                "Warning should mention home directory"
250            );
251            assert!(
252                text.contains("subdirectory"),
253                "Warning should suggest subdirectory"
254            );
255        }
256    }
257
258    #[test]
259    fn banner_contains_model_name() {
260        let lines = build_banner_lines("gpt-4o", "openai", "~/projects/koda", &[]);
261        let text = lines_to_text(&lines);
262        assert!(text.contains("gpt-4o"));
263    }
264
265    #[test]
266    fn banner_contains_provider() {
267        let lines = build_banner_lines("claude-sonnet", "anthropic", "~/repo", &[]);
268        let text = lines_to_text(&lines);
269        assert!(text.contains("anthropic"));
270    }
271
272    #[test]
273    fn banner_contains_cwd() {
274        let lines = build_banner_lines("m", "p", "/tmp/test", &[]);
275        let text = lines_to_text(&lines);
276        assert!(text.contains("/tmp/test"));
277    }
278
279    #[test]
280    fn banner_contains_version() {
281        let lines = build_banner_lines("m", "p", "~", &[]);
282        let text = lines_to_text(&lines);
283        let ver = env!("CARGO_PKG_VERSION");
284        assert!(text.contains(ver));
285    }
286
287    #[test]
288    fn banner_is_compact() {
289        let lines = build_banner_lines("gpt-4o", "openai", "~/repo", &[]);
290        // 3 bear lines + blank top + tips + blank bottom = 6
291        assert_eq!(lines.len(), 6);
292    }
293}