1use anyhow::Result;
12use atomcode_core::agent::AgentCommand;
13use atomcode_core::session::{Session, SessionMeta};
14use crossterm::event::{KeyCode, KeyModifiers};
15
16use super::{Modal, ModalAction};
17use crate::event_loop::{
18 build_status, format_tool_detail, perform_session_rename, summarise, Buffer, LoopCtx,
19};
20use crate::render::{MenuPayload, Renderer, UiLine};
21use crate::state::UiState;
22
23pub struct SessionPicker {
24 pub sessions: Vec<SessionMeta>,
26 pub query: String,
28 pub filtered: Vec<usize>,
30 pub selected: usize,
32 pub rename_editing: bool,
34 pub rename_buffer: String,
36}
37
38impl SessionPicker {
39 pub fn open(sessions: Vec<SessionMeta>) -> Self {
40 let filtered: Vec<usize> = (0..sessions.len()).collect();
41 Self {
42 sessions,
43 query: String::new(),
44 filtered,
45 selected: 0,
46 rename_editing: false,
47 rename_buffer: String::new(),
48 }
49 }
50
51 pub fn update_filter(&mut self) {
52 let q = self.query.to_lowercase();
53 self.filtered = self
54 .sessions
55 .iter()
56 .enumerate()
57 .filter(|(_, s)| q.is_empty() || s.name.to_lowercase().contains(&q))
58 .map(|(i, _)| i)
59 .collect();
60 self.selected = 0;
61 }
62
63 pub fn up(&mut self) {
64 if self.filtered.is_empty() {
65 self.selected = 0;
66 return;
67 }
68 self.selected = self.selected.saturating_sub(1);
69 }
70
71 pub fn down(&mut self) {
72 if self.filtered.is_empty() {
73 self.selected = 0;
74 return;
75 }
76 let max = self.filtered.len().saturating_sub(1);
77 if self.selected < max {
78 self.selected += 1;
79 }
80 }
81
82 pub fn chosen_id(&self) -> Option<atomcode_core::session::SessionId> {
83 let i = *self.filtered.get(self.selected)?;
84 self.sessions.get(i).map(|s| s.id.clone())
85 }
86}
87
88impl Modal for SessionPicker {
89 fn handle_key(
90 &mut self,
91 code: KeyCode,
92 mods: KeyModifiers,
93 buf: &mut Buffer,
94 state: &mut UiState,
95 ctx: &mut LoopCtx,
96 renderer: &mut dyn Renderer,
97 ) -> Result<ModalAction> {
98 if self.rename_editing {
100 match code {
101 KeyCode::Esc => {
102 self.rename_editing = false;
104 self.rename_buffer.clear();
105 self.draw(buf, state, ctx, renderer);
106 return Ok(ModalAction::Continue);
107 }
108 KeyCode::Enter => {
109 if let Some(idx) = self.filtered.get(self.selected).copied() {
110 if let Some(session_meta) = self.sessions.get(idx) {
111 let id = session_meta.id.clone();
112 match perform_session_rename(
113 &ctx.session_manager,
114 &id,
115 &self.rename_buffer,
116 ) {
117 Ok((old_name, new_name)) => {
118 if let Some(s) = self.sessions.get_mut(idx) {
120 s.name = new_name.clone();
121 }
122 let prev_id = id.clone();
124 self.update_filter();
125 self.selected = self
127 .filtered
128 .iter()
129 .position(|&fi| self.sessions[fi].id == prev_id)
130 .unwrap_or(0);
131 renderer.render(UiLine::CommandOutput(
133 crate::i18n::t(crate::i18n::Msg::SessionRenamed {
134 old: &old_name,
135 new: &new_name,
136 }).into_owned(),
137 ));
138 renderer.flush();
139 }
140 Err(err) => {
141 renderer.render(UiLine::Error(err));
142 renderer.flush();
143 }
144 }
145 }
146 }
147 self.rename_editing = false;
148 self.rename_buffer.clear();
149 self.draw(buf, state, ctx, renderer);
150 return Ok(ModalAction::Continue);
151 }
152 KeyCode::Backspace => {
153 self.rename_buffer.pop();
154 self.draw(buf, state, ctx, renderer);
155 return Ok(ModalAction::Continue);
156 }
157 KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => {
158 self.rename_buffer.push(c);
159 self.draw(buf, state, ctx, renderer);
160 return Ok(ModalAction::Continue);
161 }
162 _ => {
163 self.draw(buf, state, ctx, renderer);
164 return Ok(ModalAction::Continue);
165 }
166 }
167 }
168
169 match code {
171 KeyCode::Up => {
172 self.up();
173 self.draw(buf, state, ctx, renderer);
174 Ok(ModalAction::Continue)
175 }
176 KeyCode::Down => {
177 self.down();
178 self.draw(buf, state, ctx, renderer);
179 Ok(ModalAction::Continue)
180 }
181 KeyCode::Backspace => {
182 self.query.pop();
183 self.update_filter();
184 self.draw(buf, state, ctx, renderer);
185 Ok(ModalAction::Continue)
186 }
187 KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => {
188 self.query.push(c);
189 self.update_filter();
190 self.draw(buf, state, ctx, renderer);
191 Ok(ModalAction::Continue)
192 }
193 KeyCode::F(2) => {
194 if let Some(idx) = self.filtered.get(self.selected).copied() {
196 if let Some(session) = self.sessions.get(idx) {
197 self.rename_buffer = session.name.clone();
198 self.rename_editing = true;
199 self.draw(buf, state, ctx, renderer);
200 }
201 } else {
202 renderer.render(UiLine::Error(
203 crate::i18n::t(crate::i18n::Msg::SessionNoneSelected).into_owned(),
204 ));
205 renderer.flush();
206 }
207 Ok(ModalAction::Continue)
208 }
209 KeyCode::Enter => {
210 let Some(id) = self.chosen_id() else {
211 return Ok(ModalAction::Continue);
213 };
214 match ctx.session_manager.load(&id) {
215 Ok(session) => {
216 ctx.current_session_id = Some(id);
217 replay_session(renderer, &session, true);
218 ctx.agent
219 .cmd_tx
220 .send(AgentCommand::SetMessages(session.messages.clone()))
221 .ok();
222 if let Ok(uuid) = uuid::Uuid::parse_str(session.id.as_str()) {
228 ctx.telemetry.set_session_id(uuid);
229 }
230 ctx.current_session = session;
231 ctx.bg_manager
232 .set_foreground_session(ctx.current_session.clone());
233 state.on_turn_complete();
234 Ok(ModalAction::Close)
235 }
236 Err(e) => {
237 ctx.current_session_id = None;
238 state.total_tokens = 0;
239 state.thinking_idx = 0;
240 state.on_turn_complete();
241 let msg = format!("{}", e);
242 renderer.render(UiLine::Error(
243 crate::i18n::t(crate::i18n::Msg::SessionLoadFailed { error: &msg }).into_owned(),
244 ));
245 renderer.flush();
246 Ok(ModalAction::Close)
247 }
248 }
249 }
250 KeyCode::Esc => Ok(ModalAction::Close),
251 _ => Ok(ModalAction::Continue),
252 }
253 }
254
255 fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
256 let payload = build_menu_payload(self);
257 renderer.render(UiLine::InputPrompt {
258 buf: buf.text.clone(),
259 cursor_byte: buf.cursor,
260 menu: Some(payload),
261 status: build_status(state, ctx),
262 attachments: Vec::new(),
263 });
264 renderer.flush();
265 }
266}
267
268fn build_menu_payload(p: &SessionPicker) -> MenuPayload {
269 if p.filtered.is_empty() {
273 let label = if p.sessions.is_empty() {
274 "(no sessions in this project yet)".to_string()
275 } else if p.query.is_empty() {
276 "(no sessions match)".to_string()
277 } else {
278 format!("(no sessions match \"{}\" — Backspace to clear)", p.query)
279 };
280 return MenuPayload {
281 items: vec![(label, String::new())],
282 selected: 0,
283 kind: crate::render::MenuKind::SlashCommand,
284 };
285 }
286 let items: Vec<(String, String)> = p
287 .filtered
288 .iter()
289 .enumerate()
290 .map(|(filter_idx, &session_idx)| {
291 let s = &p.sessions[session_idx];
292 let msgs = crate::i18n::t(crate::i18n::Msg::SessionMsgCount { count: s.message_count });
293 let desc = format!("{} · {}", msgs, humanize_age(s.updated_at));
294 if p.rename_editing && filter_idx == p.selected {
296 (
297 crate::i18n::t(crate::i18n::Msg::SessionRenameEditing {
298 buffer: &p.rename_buffer,
299 }).into_owned(),
300 desc,
301 )
302 } else {
303 (s.name.clone(), desc)
304 }
305 })
306 .collect();
307 MenuPayload {
308 items,
309 selected: p.selected,
310 kind: crate::render::MenuKind::SlashCommand,
311 }
312}
313
314fn humanize_age(ts: u64) -> String {
315 use crate::i18n::{t, Msg};
316 use std::time::{SystemTime, UNIX_EPOCH};
317 let now = SystemTime::now()
318 .duration_since(UNIX_EPOCH)
319 .map(|d| d.as_secs())
320 .unwrap_or(ts);
321 let d = now.saturating_sub(ts);
322 if d < 60 {
323 t(Msg::SessionTimeJustNow).into_owned()
324 } else if d < 3600 {
325 t(Msg::SessionTimeMinAgo { n: d / 60 }).into_owned()
326 } else if d < 86400 {
327 t(Msg::SessionTimeHourAgo { n: d / 3600 }).into_owned()
328 } else {
329 t(Msg::SessionTimeDayAgo { n: d / 86400 }).into_owned()
330 }
331}
332
333pub(crate) fn replay_session(renderer: &mut dyn Renderer, session: &Session, reset: bool) {
343 use atomcode_core::conversation::message::{MessageContent, Role};
344 if reset {
345 renderer.reset();
346 }
347 let resumed = crate::i18n::t(crate::i18n::Msg::SessionResumedLabel { name: &session.name }).into_owned();
348 renderer.render(UiLine::TurnSeparator {
349 label: resumed.clone(),
350 });
351 for m in &session.messages {
352 match (&m.role, &m.content) {
353 (Role::User, MessageContent::Text(s)) => {
354 renderer.render(UiLine::User(s.clone()));
355 }
356 (Role::Assistant, MessageContent::Text(s)) => {
357 if !s.is_empty() {
358 renderer.render(UiLine::AssistantText(s.clone()));
359 renderer.render(UiLine::AssistantLineBreak);
360 }
361 }
362 (
363 Role::Assistant,
364 MessageContent::AssistantWithToolCalls {
365 text, tool_calls, ..
366 },
367 ) => {
368 if let Some(t) = text {
369 if !t.is_empty() {
370 renderer.render(UiLine::AssistantText(t.clone()));
371 renderer.render(UiLine::AssistantLineBreak);
372 }
373 }
374 for tc in tool_calls {
375 renderer.render(UiLine::ToolCall {
376 name: tc.name.clone(),
377 detail: format_tool_detail(&tc.name, &tc.arguments),
378 });
379 }
380 }
381 (Role::Tool, MessageContent::ToolResult(r)) => {
382 renderer.render(UiLine::ToolResult {
383 success: r.success,
384 summary: summarise(&r.output, r.success),
385 });
386 }
387 (Role::Tool, MessageContent::ToolResultRef(r)) => {
388 renderer.render(UiLine::ToolResult {
389 success: true,
390 summary: summarise(&r.summary, true),
391 });
392 }
393 _ => {}
394 }
395 }
396 renderer.render(UiLine::TurnComplete);
397 renderer.render(UiLine::TurnSeparator {
398 label: resumed,
399 });
400 renderer.flush();
401}
402
403#[cfg(test)]
404mod tests {
405 use super::*;
406 use atomcode_core::session::{SessionId, SessionMeta};
407 use std::path::PathBuf;
408
409 fn meta(name: &str, msgs: usize) -> SessionMeta {
410 SessionMeta {
411 id: SessionId::from_string(format!("id-{name}")),
412 name: name.to_string(),
413 working_dir: PathBuf::from("/tmp/x"),
414 created_at: 0,
415 updated_at: 0,
416 message_count: msgs,
417 file_size: 0,
418 }
419 }
420
421 #[test]
422 fn open_shows_all_sessions_initially() {
423 let p = SessionPicker::open(vec![meta("alpha", 3), meta("beta", 5)]);
424 assert_eq!(p.filtered.len(), 2);
425 assert_eq!(p.selected, 0);
426 assert!(p.query.is_empty());
427 }
428
429 #[test]
430 fn update_filter_matches_by_substring_case_insensitive() {
431 let mut p = SessionPicker::open(vec![
432 meta("Fix auth bug", 4),
433 meta("Refactor renderer", 7),
434 meta("authentication flow", 2),
435 ]);
436 p.query = "auth".to_string();
437 p.update_filter();
438 assert_eq!(p.filtered.len(), 2);
439 let names: Vec<&str> = p
440 .filtered
441 .iter()
442 .map(|i| p.sessions[*i].name.as_str())
443 .collect();
444 assert!(names.contains(&"Fix auth bug"));
445 assert!(names.contains(&"authentication flow"));
446 }
447
448 #[test]
449 fn update_filter_empty_query_shows_all() {
450 let mut p = SessionPicker::open(vec![meta("x", 1), meta("y", 1)]);
451 p.query = "zz".to_string();
452 p.update_filter();
453 assert_eq!(p.filtered.len(), 0);
454 p.query.clear();
455 p.update_filter();
456 assert_eq!(p.filtered.len(), 2);
457 }
458
459 #[test]
460 fn update_filter_resets_selection_to_zero() {
461 let mut p = SessionPicker::open(vec![meta("one", 1), meta("two", 1), meta("three", 1)]);
462 p.selected = 2;
463 p.query = "on".to_string();
464 p.update_filter();
465 assert_eq!(p.selected, 0, "selection must reset when filter changes");
466 }
467
468 #[test]
469 fn down_and_up_stay_within_filtered_bounds() {
470 let mut p = SessionPicker::open(vec![meta("a", 1), meta("b", 1)]);
471 p.down();
472 assert_eq!(p.selected, 1);
473 p.down();
474 assert_eq!(p.selected, 1, "down at end stays put");
475 p.up();
476 assert_eq!(p.selected, 0);
477 p.up();
478 assert_eq!(p.selected, 0, "up at top stays put");
479 }
480
481 #[test]
482 fn chosen_returns_session_at_selected() {
483 let sessions = vec![meta("first", 1), meta("second", 1)];
484 let mut p = SessionPicker::open(sessions);
485 p.down();
486 let id = p.chosen_id().expect("selection should exist");
487 assert_eq!(id.as_str(), "id-second");
488 }
489
490 #[test]
491 fn chosen_returns_none_when_filter_empty() {
492 let mut p = SessionPicker::open(vec![meta("alpha", 1)]);
493 p.query = "xyz".to_string();
494 p.update_filter();
495 assert!(p.chosen_id().is_none());
496 }
497
498 #[test]
499 fn build_menu_payload_shows_hint_when_filter_matches_nothing() {
500 let mut p = SessionPicker::open(vec![meta("alpha", 1), meta("beta", 1)]);
506 p.query = "zz".to_string();
507 p.update_filter();
508 assert_eq!(p.filtered.len(), 0);
509 let payload = build_menu_payload(&p);
510 assert_eq!(
511 payload.items.len(),
512 1,
513 "empty filter should produce a single hint row, got: {:?}",
514 payload.items
515 );
516 let (label, _) = &payload.items[0];
517 assert!(
518 label.contains("zz"),
519 "hint should echo the user's query so they know which filter is active: {}",
520 label
521 );
522 }
523
524 #[test]
525 fn build_menu_payload_shows_hint_when_no_sessions_at_all() {
526 let p = SessionPicker::open(vec![]);
527 let payload = build_menu_payload(&p);
528 assert_eq!(payload.items.len(), 1, "must show some empty-state hint");
529 }
530}