use std::collections::HashMap;
use tokio::sync::oneshot;
use crate::app::{AgentViewTarget, TranscriptCache, TuiTranscriptEntry};
use crate::render_cache::RenderCache;
use crate::types::{ChatMessage, InputMode, PasteState};
pub(crate) const FLASH_TICKS: u64 = 4;
pub(crate) const SCROLL_ANIM_TICKS: u64 = 3;
pub(crate) struct FlashState {
pub(crate) pending: HashMap<usize, u64>,
}
impl FlashState {
pub(crate) fn new() -> Self {
Self {
pending: HashMap::new(),
}
}
pub(crate) fn insert(&mut self, start_idx: usize, born_tick: u64) {
self.pending.insert(start_idx, born_tick);
}
pub(crate) fn active_set(&self, now: u64) -> std::collections::HashSet<usize> {
self.pending
.iter()
.filter(|&(_, &born)| now.saturating_sub(born) < FLASH_TICKS)
.map(|(&idx, _)| idx)
.collect()
}
pub(crate) fn prune(&mut self, now: u64) {
self.pending
.retain(|_, &mut born| now.saturating_sub(born) < FLASH_TICKS);
}
}
pub(crate) struct ScrollAnim {
pub(crate) from: usize,
pub(crate) to: usize,
pub(crate) start_tick: u64,
}
impl ScrollAnim {
#[must_use]
pub(crate) fn current_offset(&self, now: u64) -> (usize, bool) {
let elapsed = now.saturating_sub(self.start_tick);
if elapsed >= SCROLL_ANIM_TICKS {
return (self.to, true);
}
#[allow(clippy::cast_precision_loss)]
let t = elapsed as f32 / SCROLL_ANIM_TICKS as f32;
let eased = 1.0_f32 - (1.0 - t).powi(3);
#[allow(
clippy::cast_sign_loss,
clippy::cast_possible_truncation,
clippy::cast_precision_loss
)]
let offset = if self.from <= self.to {
let delta = self.to - self.from;
self.from + (delta as f32 * eased).round() as usize
} else {
let delta = self.from - self.to;
self.from - (delta as f32 * eased).round() as usize
};
(offset, false)
}
}
pub(crate) const MAX_TUI_MESSAGES: usize = 2000;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct SlotId(pub u64);
impl SlotId {
pub const FIRST: Self = SlotId(1);
}
pub(crate) struct SessionSlot {
#[allow(dead_code)]
pub id: SlotId,
#[allow(dead_code)]
pub label: String,
pub messages: Vec<ChatMessage>,
pub scroll_offset: usize,
pub render_cache: RenderCache,
pub input: String,
pub cursor_position: usize,
pub input_mode: InputMode,
pub input_history: Vec<String>,
pub history_index: Option<usize>,
pub draft_input: String,
pub paste_state: Option<PasteState>,
pub view_target: AgentViewTarget,
pub transcript_cache: Option<TranscriptCache>,
pub pending_transcript: Option<oneshot::Receiver<(Vec<TuiTranscriptEntry>, usize)>>,
pub show_splash: bool,
pub plan_view_active: bool,
pub status_label: Option<String>,
pub(crate) flash: FlashState,
pub(crate) scroll_anim: Option<ScrollAnim>,
pub(crate) flashed_groups: std::collections::HashSet<usize>,
pub(crate) prev_show_splash: bool,
}
impl SessionSlot {
pub fn new(id: SlotId, label: impl Into<String>) -> Self {
Self {
id,
label: label.into(),
messages: Vec::new(),
scroll_offset: 0,
render_cache: RenderCache::default(),
input: String::new(),
cursor_position: 0,
input_mode: InputMode::Insert,
input_history: Vec::new(),
history_index: None,
draft_input: String::new(),
paste_state: None,
view_target: AgentViewTarget::Main,
transcript_cache: None,
pending_transcript: None,
show_splash: true,
plan_view_active: false,
status_label: None,
flash: FlashState::new(),
scroll_anim: None,
flashed_groups: std::collections::HashSet::new(),
prev_show_splash: false,
}
}
pub fn trim_messages(&mut self) {
if self.messages.len() > MAX_TUI_MESSAGES {
let excess = self.messages.len() - MAX_TUI_MESSAGES;
self.messages.drain(0..excess);
self.render_cache.shift(excess);
self.scroll_offset = self.scroll_offset.saturating_sub(excess);
self.flash.pending.clear();
self.flashed_groups.clear();
}
}
}
pub(crate) struct SessionRegistry {
slots: indexmap::IndexMap<SlotId, SessionSlot>,
active: SlotId,
#[allow(dead_code)]
next_id: u64,
}
impl SessionRegistry {
pub fn bootstrap() -> Self {
let id = SlotId::FIRST;
let mut slots = indexmap::IndexMap::new();
slots.insert(id, SessionSlot::new(id, "session 1"));
Self {
slots,
active: id,
next_id: 2,
}
}
pub fn active(&self) -> SlotId {
self.active
}
#[allow(dead_code)]
pub fn len(&self) -> usize {
self.slots.len()
}
pub fn current(&self) -> &SessionSlot {
self.slots
.get(&self.active)
.expect("invariant: active slot exists")
}
pub fn current_mut(&mut self) -> &mut SessionSlot {
self.slots
.get_mut(&self.active)
.expect("invariant: active slot exists")
}
#[allow(dead_code)]
pub fn iter(&self) -> impl Iterator<Item = (&SlotId, &SessionSlot)> {
self.slots.iter()
}
pub fn iter_mut(&mut self) -> impl Iterator<Item = &mut SessionSlot> {
self.slots.values_mut()
}
pub fn switch_next(&mut self) {
if self.slots.len() <= 1 {
return;
}
let idx = self
.slots
.get_index_of(&self.active)
.expect("invariant: active slot exists");
let next_idx = (idx + 1) % self.slots.len();
self.active = *self.slots.get_index(next_idx).expect("valid index").0;
}
pub fn switch_prev(&mut self) {
if self.slots.len() <= 1 {
return;
}
let idx = self
.slots
.get_index_of(&self.active)
.expect("invariant: active slot exists");
let prev_idx = if idx == 0 {
self.slots.len() - 1
} else {
idx - 1
};
self.active = *self.slots.get_index(prev_idx).expect("valid index").0;
}
pub fn close(&mut self, id: SlotId) -> bool {
if self.slots.len() <= 1 {
return false;
}
if self.active == id {
self.switch_next();
}
self.slots.shift_remove(&id);
true
}
#[allow(dead_code)]
pub(crate) fn create(&mut self, label: impl Into<String>) -> SlotId {
let id = SlotId(self.next_id);
self.next_id += 1;
self.slots.insert(id, SessionSlot::new(id, label));
id
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::MessageRole;
fn make_registry() -> SessionRegistry {
SessionRegistry::bootstrap()
}
#[test]
fn bootstrap_has_one_slot() {
let reg = make_registry();
assert_eq!(reg.len(), 1);
assert_eq!(reg.active(), SlotId::FIRST);
assert_eq!(reg.current().label, "session 1");
}
#[test]
fn switch_next_noop_on_single_slot() {
let mut reg = make_registry();
let before = reg.active();
reg.switch_next();
assert_eq!(reg.active(), before);
}
#[test]
fn switch_prev_noop_on_single_slot() {
let mut reg = make_registry();
let before = reg.active();
reg.switch_prev();
assert_eq!(reg.active(), before);
}
#[test]
fn switch_next_cyclic() {
let mut reg = make_registry();
let second = reg.create("session 2");
let third = reg.create("session 3");
reg.switch_next();
assert_eq!(reg.active(), second);
reg.switch_next();
assert_eq!(reg.active(), third);
reg.switch_next();
assert_eq!(reg.active(), SlotId::FIRST);
}
#[test]
fn switch_prev_cyclic() {
let mut reg = make_registry();
let second = reg.create("session 2");
let third = reg.create("session 3");
reg.switch_prev();
assert_eq!(reg.active(), third);
reg.switch_prev();
assert_eq!(reg.active(), second);
reg.switch_prev();
assert_eq!(reg.active(), SlotId::FIRST);
}
#[test]
fn close_refuses_last_slot() {
let mut reg = make_registry();
let result = reg.close(SlotId::FIRST);
assert!(!result);
assert_eq!(reg.len(), 1);
}
#[test]
fn create_bumps_next_id() {
let mut reg = make_registry();
let a = reg.create("session 2");
let b = reg.create("session 3");
assert_ne!(a, b);
assert_eq!(a, SlotId(2));
assert_eq!(b, SlotId(3));
}
#[test]
fn trim_messages_respects_cap() {
let mut slot = SessionSlot::new(SlotId::FIRST, "test");
for i in 0..(MAX_TUI_MESSAGES + 10) {
slot.messages
.push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
}
slot.trim_messages();
assert_eq!(slot.messages.len(), MAX_TUI_MESSAGES);
}
#[test]
fn trim_messages_clears_flash_state() {
let mut slot = SessionSlot::new(SlotId::FIRST, "test");
for i in 0..(MAX_TUI_MESSAGES + 5) {
slot.messages
.push(ChatMessage::new(MessageRole::User, format!("msg {i}")));
}
slot.flash.insert(0, 42);
slot.flash.insert(3, 43);
slot.flashed_groups.insert(1);
slot.flashed_groups.insert(2);
slot.trim_messages();
assert!(
slot.flash.pending.is_empty(),
"flash.pending must be cleared after trim"
);
assert!(
slot.flashed_groups.is_empty(),
"flashed_groups must be cleared after trim"
);
}
}