use anyhow::Result;
use atomcode_core::agent::AgentCommand;
use atomcode_core::session::{Session, SessionMeta};
use crossterm::event::{KeyCode, KeyModifiers};
use super::{Modal, ModalAction};
use crate::event_loop::{
build_status, format_tool_detail, perform_session_rename, summarise, Buffer, LoopCtx,
};
use crate::render::{MenuPayload, Renderer, UiLine};
use crate::state::UiState;
pub struct SessionPicker {
pub sessions: Vec<SessionMeta>,
pub query: String,
pub filtered: Vec<usize>,
pub selected: usize,
pub rename_editing: bool,
pub rename_buffer: String,
}
impl SessionPicker {
pub fn open(sessions: Vec<SessionMeta>) -> Self {
let filtered: Vec<usize> = (0..sessions.len()).collect();
Self {
sessions,
query: String::new(),
filtered,
selected: 0,
rename_editing: false,
rename_buffer: String::new(),
}
}
pub fn update_filter(&mut self) {
let q = self.query.to_lowercase();
self.filtered = self
.sessions
.iter()
.enumerate()
.filter(|(_, s)| q.is_empty() || s.name.to_lowercase().contains(&q))
.map(|(i, _)| i)
.collect();
self.selected = 0;
}
pub fn up(&mut self) {
if self.filtered.is_empty() {
self.selected = 0;
return;
}
self.selected = self.selected.saturating_sub(1);
}
pub fn down(&mut self) {
if self.filtered.is_empty() {
self.selected = 0;
return;
}
let max = self.filtered.len().saturating_sub(1);
if self.selected < max {
self.selected += 1;
}
}
pub fn chosen_id(&self) -> Option<atomcode_core::session::SessionId> {
let i = *self.filtered.get(self.selected)?;
self.sessions.get(i).map(|s| s.id.clone())
}
}
impl Modal for SessionPicker {
fn handle_key(
&mut self,
code: KeyCode,
mods: KeyModifiers,
buf: &mut Buffer,
state: &mut UiState,
ctx: &mut LoopCtx,
renderer: &mut dyn Renderer,
) -> Result<ModalAction> {
if self.rename_editing {
match code {
KeyCode::Esc => {
self.rename_editing = false;
self.rename_buffer.clear();
self.draw(buf, state, ctx, renderer);
return Ok(ModalAction::Continue);
}
KeyCode::Enter => {
if let Some(idx) = self.filtered.get(self.selected).copied() {
if let Some(session_meta) = self.sessions.get(idx) {
let id = session_meta.id.clone();
match perform_session_rename(
&ctx.session_manager,
&id,
&self.rename_buffer,
) {
Ok((old_name, new_name)) => {
if let Some(s) = self.sessions.get_mut(idx) {
s.name = new_name.clone();
}
let prev_id = id.clone();
self.update_filter();
self.selected = self
.filtered
.iter()
.position(|&fi| self.sessions[fi].id == prev_id)
.unwrap_or(0);
renderer.render(UiLine::CommandOutput(
crate::i18n::t(crate::i18n::Msg::SessionRenamed {
old: &old_name,
new: &new_name,
}).into_owned(),
));
renderer.flush();
}
Err(err) => {
renderer.render(UiLine::Error(err));
renderer.flush();
}
}
}
}
self.rename_editing = false;
self.rename_buffer.clear();
self.draw(buf, state, ctx, renderer);
return Ok(ModalAction::Continue);
}
KeyCode::Backspace => {
self.rename_buffer.pop();
self.draw(buf, state, ctx, renderer);
return Ok(ModalAction::Continue);
}
KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => {
self.rename_buffer.push(c);
self.draw(buf, state, ctx, renderer);
return Ok(ModalAction::Continue);
}
_ => {
self.draw(buf, state, ctx, renderer);
return Ok(ModalAction::Continue);
}
}
}
match code {
KeyCode::Up => {
self.up();
self.draw(buf, state, ctx, renderer);
Ok(ModalAction::Continue)
}
KeyCode::Down => {
self.down();
self.draw(buf, state, ctx, renderer);
Ok(ModalAction::Continue)
}
KeyCode::Backspace => {
self.query.pop();
self.update_filter();
self.draw(buf, state, ctx, renderer);
Ok(ModalAction::Continue)
}
KeyCode::Char(c) if !mods.contains(KeyModifiers::CONTROL) => {
self.query.push(c);
self.update_filter();
self.draw(buf, state, ctx, renderer);
Ok(ModalAction::Continue)
}
KeyCode::F(2) => {
if let Some(idx) = self.filtered.get(self.selected).copied() {
if let Some(session) = self.sessions.get(idx) {
self.rename_buffer = session.name.clone();
self.rename_editing = true;
self.draw(buf, state, ctx, renderer);
}
} else {
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::SessionNoneSelected).into_owned(),
));
renderer.flush();
}
Ok(ModalAction::Continue)
}
KeyCode::Enter => {
let Some(id) = self.chosen_id() else {
return Ok(ModalAction::Continue);
};
match ctx.session_manager.load(&id) {
Ok(session) => {
ctx.current_session_id = Some(id);
replay_session(renderer, &session, true);
ctx.agent
.cmd_tx
.send(AgentCommand::SetMessages(session.messages.clone()))
.ok();
crate::event_loop::commands::bind_telemetry_to_session(ctx, &session);
ctx.current_session = session;
ctx.bg_manager
.set_foreground_session(ctx.current_session.clone());
state.on_turn_complete();
Ok(ModalAction::Close)
}
Err(e) => {
ctx.current_session_id = None;
state.total_tokens = 0;
state.thinking_idx = 0;
state.on_turn_complete();
let msg = format!("{}", e);
renderer.render(UiLine::Error(
crate::i18n::t(crate::i18n::Msg::SessionLoadFailed { error: &msg }).into_owned(),
));
renderer.flush();
Ok(ModalAction::Close)
}
}
}
KeyCode::Esc => Ok(ModalAction::Close),
_ => Ok(ModalAction::Continue),
}
}
fn draw(&self, buf: &Buffer, state: &UiState, ctx: &LoopCtx, renderer: &mut dyn Renderer) {
let payload = build_menu_payload(self);
renderer.render(UiLine::InputPrompt {
buf: buf.text.clone(),
cursor_byte: buf.cursor,
menu: Some(payload),
status: build_status(state, ctx),
attachments: Vec::new(),
});
renderer.flush();
}
}
fn build_menu_payload(p: &SessionPicker) -> MenuPayload {
if p.filtered.is_empty() {
let label = if p.sessions.is_empty() {
"(no sessions in this project yet)".to_string()
} else if p.query.is_empty() {
"(no sessions match)".to_string()
} else {
format!("(no sessions match \"{}\" — Backspace to clear)", p.query)
};
return MenuPayload {
items: vec![(label, String::new())],
selected: 0,
kind: crate::render::MenuKind::SlashCommand,
};
}
let items: Vec<(String, String)> = p
.filtered
.iter()
.enumerate()
.map(|(filter_idx, &session_idx)| {
let s = &p.sessions[session_idx];
let msgs = crate::i18n::t(crate::i18n::Msg::SessionMsgCount { count: s.message_count });
let desc = format!("{} · {}", msgs, humanize_age(s.updated_at));
if p.rename_editing && filter_idx == p.selected {
(
crate::i18n::t(crate::i18n::Msg::SessionRenameEditing {
buffer: &p.rename_buffer,
}).into_owned(),
desc,
)
} else {
(s.name.clone(), desc)
}
})
.collect();
MenuPayload {
items,
selected: p.selected,
kind: crate::render::MenuKind::SlashCommand,
}
}
fn humanize_age(ts: u64) -> String {
use crate::i18n::{t, Msg};
use std::time::{SystemTime, UNIX_EPOCH};
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(ts);
let d = now.saturating_sub(ts);
if d < 60 {
t(Msg::SessionTimeJustNow).into_owned()
} else if d < 3600 {
t(Msg::SessionTimeMinAgo { n: d / 60 }).into_owned()
} else if d < 86400 {
t(Msg::SessionTimeHourAgo { n: d / 3600 }).into_owned()
} else {
t(Msg::SessionTimeDayAgo { n: d / 86400 }).into_owned()
}
}
fn turn_divider_label(stat: Option<&atomcode_core::session::TurnStat>) -> String {
match stat {
Some(s) if s.errored => crate::i18n::t(crate::i18n::Msg::TurnSummaryError {
turn_count: s.turn_count,
tool_call_count: s.tool_call_count,
duration: &crate::render::fmt_dur(std::time::Duration::from_millis(s.duration_ms)),
total_tokens: s.total_tokens,
})
.into_owned(),
Some(s) => crate::i18n::t(crate::i18n::Msg::TurnSummary {
done: "Done",
turn_count: s.turn_count,
tool_call_count: s.tool_call_count,
duration: &crate::render::fmt_dur(std::time::Duration::from_millis(s.duration_ms)),
total_tokens: s.total_tokens,
})
.into_owned(),
None => String::new(),
}
}
pub(crate) fn replay_session(renderer: &mut dyn Renderer, session: &Session, reset: bool) {
use atomcode_core::conversation::message::{MessageContent, Role};
if reset {
renderer.reset();
}
let resumed = crate::i18n::t(crate::i18n::Msg::SessionResumedLabel { name: &session.name }).into_owned();
renderer.render(UiLine::TurnSeparator {
label: resumed.clone(),
});
let mut seen_user = false;
for (i, m) in session.messages.iter().enumerate() {
if matches!(m.role, Role::User) {
if seen_user {
let stat = session.turn_stats.iter().find(|s| s.after_message == i);
renderer.render(UiLine::TurnSeparator {
label: turn_divider_label(stat),
});
}
seen_user = true;
}
match (&m.role, &m.content) {
(Role::User, MessageContent::Text(s)) => {
renderer.render(UiLine::User(s.clone()));
}
(Role::Assistant, MessageContent::Text(s)) => {
if !s.is_empty() {
renderer.render(UiLine::AssistantText(s.clone()));
renderer.render(UiLine::AssistantLineBreak);
}
}
(
Role::Assistant,
MessageContent::AssistantWithToolCalls {
text, tool_calls, ..
},
) => {
if let Some(t) = text {
if !t.is_empty() {
renderer.render(UiLine::AssistantText(t.clone()));
renderer.render(UiLine::AssistantLineBreak);
}
}
for tc in tool_calls {
renderer.render(UiLine::ToolCall {
name: tc.name.clone(),
detail: format_tool_detail(&tc.name, &tc.arguments),
});
}
}
(Role::Tool, MessageContent::ToolResult(r)) => {
renderer.render(UiLine::ToolResult {
success: r.success,
summary: summarise(&r.output, r.success),
});
}
(Role::Tool, MessageContent::ToolResultRef(r)) => {
renderer.render(UiLine::ToolResult {
success: true,
summary: summarise(&r.summary, true),
});
}
_ => {}
}
}
if let Some(stat) = session
.turn_stats
.iter()
.find(|s| s.after_message == session.messages.len())
{
renderer.render(UiLine::TurnSeparator {
label: turn_divider_label(Some(stat)),
});
}
renderer.render(UiLine::TurnComplete);
renderer.render(UiLine::TurnSeparator {
label: resumed,
});
renderer.flush();
}
#[cfg(test)]
mod tests {
use super::*;
use atomcode_core::session::{SessionId, SessionMeta};
use std::path::PathBuf;
fn meta(name: &str, msgs: usize) -> SessionMeta {
SessionMeta {
id: SessionId::from_string(format!("id-{name}")),
name: name.to_string(),
working_dir: PathBuf::from("/tmp/x"),
created_at: 0,
updated_at: 0,
message_count: msgs,
file_size: 0,
}
}
#[test]
fn turn_divider_label_renders_stats_or_plain_rule() {
use atomcode_core::session::TurnStat;
let s = TurnStat {
after_message: 4,
turn_count: 3,
tool_call_count: 5,
duration_ms: 6800,
total_tokens: 1651,
errored: false,
};
let normal = super::turn_divider_label(Some(&s));
assert!(normal.contains('✓'), "got {normal:?}");
assert!(normal.contains('3') && normal.contains('5') && normal.contains("1651"), "got {normal:?}");
let err = TurnStat { errored: true, ..s.clone() };
assert!(super::turn_divider_label(Some(&err)).contains('✗'));
assert_eq!(super::turn_divider_label(None), "");
}
#[test]
fn open_shows_all_sessions_initially() {
let p = SessionPicker::open(vec![meta("alpha", 3), meta("beta", 5)]);
assert_eq!(p.filtered.len(), 2);
assert_eq!(p.selected, 0);
assert!(p.query.is_empty());
}
#[test]
fn update_filter_matches_by_substring_case_insensitive() {
let mut p = SessionPicker::open(vec![
meta("Fix auth bug", 4),
meta("Refactor renderer", 7),
meta("authentication flow", 2),
]);
p.query = "auth".to_string();
p.update_filter();
assert_eq!(p.filtered.len(), 2);
let names: Vec<&str> = p
.filtered
.iter()
.map(|i| p.sessions[*i].name.as_str())
.collect();
assert!(names.contains(&"Fix auth bug"));
assert!(names.contains(&"authentication flow"));
}
#[test]
fn update_filter_empty_query_shows_all() {
let mut p = SessionPicker::open(vec![meta("x", 1), meta("y", 1)]);
p.query = "zz".to_string();
p.update_filter();
assert_eq!(p.filtered.len(), 0);
p.query.clear();
p.update_filter();
assert_eq!(p.filtered.len(), 2);
}
#[test]
fn update_filter_resets_selection_to_zero() {
let mut p = SessionPicker::open(vec![meta("one", 1), meta("two", 1), meta("three", 1)]);
p.selected = 2;
p.query = "on".to_string();
p.update_filter();
assert_eq!(p.selected, 0, "selection must reset when filter changes");
}
#[test]
fn down_and_up_stay_within_filtered_bounds() {
let mut p = SessionPicker::open(vec![meta("a", 1), meta("b", 1)]);
p.down();
assert_eq!(p.selected, 1);
p.down();
assert_eq!(p.selected, 1, "down at end stays put");
p.up();
assert_eq!(p.selected, 0);
p.up();
assert_eq!(p.selected, 0, "up at top stays put");
}
#[test]
fn chosen_returns_session_at_selected() {
let sessions = vec![meta("first", 1), meta("second", 1)];
let mut p = SessionPicker::open(sessions);
p.down();
let id = p.chosen_id().expect("selection should exist");
assert_eq!(id.as_str(), "id-second");
}
#[test]
fn chosen_returns_none_when_filter_empty() {
let mut p = SessionPicker::open(vec![meta("alpha", 1)]);
p.query = "xyz".to_string();
p.update_filter();
assert!(p.chosen_id().is_none());
}
#[test]
fn build_menu_payload_shows_hint_when_filter_matches_nothing() {
let mut p = SessionPicker::open(vec![meta("alpha", 1), meta("beta", 1)]);
p.query = "zz".to_string();
p.update_filter();
assert_eq!(p.filtered.len(), 0);
let payload = build_menu_payload(&p);
assert_eq!(
payload.items.len(),
1,
"empty filter should produce a single hint row, got: {:?}",
payload.items
);
let (label, _) = &payload.items[0];
assert!(
label.contains("zz"),
"hint should echo the user's query so they know which filter is active: {}",
label
);
}
#[test]
fn build_menu_payload_shows_hint_when_no_sessions_at_all() {
let p = SessionPicker::open(vec![]);
let payload = build_menu_payload(&p);
assert_eq!(payload.items.len(), 1, "must show some empty-state hint");
}
}