1use 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
13pub 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
70pub 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 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
112pub 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
127pub 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
138pub 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
172pub fn print_resume_hint(session_id: &str) {
179 println!("\nResume this session with:\n koda --resume {session_id}");
180}
181
182pub const PURGE_NUDGE_BYTES: i64 = 500 * 1024 * 1024;
184
185pub 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
197fn 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 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 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 assert_eq!(lines.len(), 6);
292 }
293}