use std::collections::{HashMap, VecDeque};
use std::sync::{Arc, Mutex};
use std::time::Instant;
use indexmap::IndexMap;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use crate::event::AgentEvent;
use crate::session::ToolCallEntry;
use super::chat_state::ChatUiState;
use super::picker::ListPicker;
use super::tool_display::CollapsedToolResult;
pub(crate) const TOOL_ACTIVITY_CAP: usize = 8;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub(crate) enum ExpandTarget {
#[default]
None,
Thinking,
Tool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExpandToggle {
Collapse {
start: usize,
expected_len: usize,
eviction_gen: u64,
},
Expand,
Nothing,
}
pub(crate) fn expand_toggle(anchor: Option<(usize, usize, u64)>, has_source: bool) -> ExpandToggle {
match anchor {
Some((start, expected_len, eviction_gen)) => ExpandToggle::Collapse {
start,
expected_len,
eviction_gen,
},
None if has_source => ExpandToggle::Expand,
None => ExpandToggle::Nothing,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ExpandSource {
LiveThinking,
Thinking,
Tool,
None,
}
pub(crate) fn select_expand_source(
live: bool,
target: ExpandTarget,
has_tool: bool,
has_thinking: bool,
) -> ExpandSource {
if live {
return ExpandSource::LiveThinking;
}
match target {
ExpandTarget::Tool if has_tool => ExpandSource::Tool,
ExpandTarget::Thinking if has_thinking => ExpandSource::Thinking,
_ if has_tool => ExpandSource::Tool,
_ if has_thinking => ExpandSource::Thinking,
_ => ExpandSource::None,
}
}
pub(crate) struct UiState {
pub(crate) is_running: bool,
pub(crate) agent_rx: Option<mpsc::Receiver<AgentEvent>>,
pub(crate) agent_abort: Option<JoinHandle<()>>,
pub(crate) agent_interject: Option<mpsc::Sender<()>>,
pub(crate) agent_cancel: Option<mpsc::Sender<()>>,
pub(crate) agent_line_started: bool,
pub(crate) last_user_prompt: String,
pub(crate) tool_calls_this_run: u32,
pub(crate) tool_calls_buf: Vec<ToolCallEntry>,
pub(crate) response_buf: String,
pub(crate) response_start_line: Option<usize>,
pub(crate) reasoning_buf: String,
pub(crate) reasoning_start_line: Option<usize>,
pub(crate) was_reasoning: bool,
pub(crate) last_token_render: Option<Instant>,
pub(crate) last_tool_name: Option<String>,
pub(crate) last_tool_call_id: Option<String>,
pub(crate) tool_chamber_open: bool,
pub(crate) chamber_top_start: Option<usize>,
pub(crate) chamber_top_end: Option<usize>,
pub(crate) last_collapsed: Option<CollapsedToolResult>,
pub(crate) last_thinking: Option<String>,
pub(crate) expand_target: ExpandTarget,
pub(crate) expansion_anchor: Option<(usize, usize, u64)>,
pub(crate) live_thinking_expanded: bool,
pub(crate) show_reasoning: bool,
pub(crate) todo_tools_enabled: bool,
pub(crate) loop_label: Option<String>,
pub(crate) plan_phase: Option<crate::agent::plan::runtime::PlanPhaseHandle>,
pub(crate) active_plan: Option<crate::agent::plan::runtime::ActivePlan>,
pub(crate) chat_ui_states: Vec<ChatUiState>,
pub(crate) subagent_chat_map: HashMap<String, usize>,
pub(crate) chat_idx_to_subagent: HashMap<usize, String>,
pub(crate) subagent_panel_rows: IndexMap<String, (String, String, Vec<String>)>,
pub(crate) tool_activity: VecDeque<String>,
pub(crate) interjection_queue: Arc<Mutex<VecDeque<String>>>,
pub(crate) rewind_picker: ListPicker,
pub(crate) last_esc: Option<Instant>,
pub(crate) input_mode: InputMode,
}
pub(crate) enum InputMode {
Compose,
PlanSwitch {
reply: tokio::sync::oneshot::Sender<crate::agent::tools::plan::PlanSwitchResponse>,
prompt_name: &'static str,
label: &'static str,
},
Question(QuestionState),
DialogConfirm {
reply: std::sync::mpsc::Sender<crate::plugin::DialogReply>,
},
DialogSelect {
reply: std::sync::mpsc::Sender<crate::plugin::DialogReply>,
options: Vec<String>,
},
Permission(PermissionState),
}
pub(crate) struct PermissionState {
pub(crate) req: crate::permission::ask::AskRequest,
pub(crate) pending_chamber_tool: Option<String>,
}
pub(crate) struct QuestionState {
pub(crate) req: crate::agent::tools::question::QuestionRequest,
pub(crate) answers: Vec<Vec<String>>,
pub(crate) qi: usize,
pub(crate) cursor: usize,
pub(crate) selected: Vec<bool>,
pub(crate) custom_text: Option<String>,
pub(crate) anchor: usize,
pub(crate) entry: Option<CustomEntry>,
}
pub(crate) struct CustomEntry {
pub(crate) buf: String,
pub(crate) input_anchor: usize,
}
impl CustomEntry {
pub(crate) fn paste(&mut self, text: &str) {
let normalized = text.replace("\r\n", "\n").replace('\r', "\n");
for c in normalized.chars() {
match c {
'\n' | '\t' => self.buf.push(' '),
c if c.is_control() => {}
c => self.buf.push(c),
}
}
}
}
#[derive(Clone, Copy, PartialEq, Eq)]
pub(crate) enum ModalKind {
Compose,
PlanSwitch,
Question,
DialogConfirm,
DialogSelect,
Permission,
}
impl InputMode {
pub(crate) fn is_modal(&self) -> bool {
!matches!(self, InputMode::Compose)
}
pub(crate) fn kind(&self) -> ModalKind {
match self {
InputMode::Compose => ModalKind::Compose,
InputMode::PlanSwitch { .. } => ModalKind::PlanSwitch,
InputMode::Question(_) => ModalKind::Question,
InputMode::DialogConfirm { .. } => ModalKind::DialogConfirm,
InputMode::DialogSelect { .. } => ModalKind::DialogSelect,
InputMode::Permission(_) => ModalKind::Permission,
}
}
}
impl UiState {
pub(crate) fn new() -> Self {
Self {
is_running: false,
agent_rx: None,
agent_abort: None,
agent_interject: None,
agent_cancel: None,
agent_line_started: false,
last_user_prompt: String::new(),
tool_calls_this_run: 0,
tool_calls_buf: Vec::new(),
response_buf: String::new(),
response_start_line: None,
reasoning_buf: String::new(),
reasoning_start_line: None,
was_reasoning: false,
last_token_render: None,
last_tool_name: None,
last_tool_call_id: None,
tool_chamber_open: false,
chamber_top_start: None,
chamber_top_end: None,
last_collapsed: None,
last_thinking: None,
expand_target: ExpandTarget::None,
expansion_anchor: None,
live_thinking_expanded: false,
show_reasoning: false,
todo_tools_enabled: false,
loop_label: None,
plan_phase: None,
active_plan: None,
chat_ui_states: vec![ChatUiState::empty()],
subagent_chat_map: HashMap::new(),
chat_idx_to_subagent: HashMap::new(),
subagent_panel_rows: IndexMap::new(),
tool_activity: VecDeque::with_capacity(TOOL_ACTIVITY_CAP),
interjection_queue: Arc::new(Mutex::new(VecDeque::new())),
rewind_picker: ListPicker::new(),
last_esc: None,
input_mode: InputMode::Compose,
}
}
pub(crate) fn interjection_len(&self) -> usize {
self.interjection_queue.lock().map(|q| q.len()).unwrap_or(0)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn toggle_expands_when_collapsed_with_a_source() {
assert_eq!(expand_toggle(None, true), ExpandToggle::Expand);
}
#[test]
fn custom_entry_paste_appends_and_flattens_whitespace() {
let mut e = CustomEntry {
buf: "ab".to_string(),
input_anchor: 0,
};
e.paste("cd\r\nef\tgh");
assert_eq!(e.buf, "abcd ef gh");
}
#[test]
fn custom_entry_paste_drops_non_whitespace_control_bytes() {
let mut e = CustomEntry {
buf: String::new(),
input_anchor: 0,
};
e.paste("x\u{1}y\u{7}z");
assert_eq!(e.buf, "xyz");
}
#[test]
fn toggle_is_noop_when_nothing_truncated() {
assert_eq!(expand_toggle(None, false), ExpandToggle::Nothing);
}
#[test]
fn toggle_collapses_when_shown() {
assert_eq!(
expand_toggle(Some((10, 18, 3)), true),
ExpandToggle::Collapse {
start: 10,
expected_len: 18,
eviction_gen: 3,
}
);
}
#[test]
fn live_thinking_always_wins() {
assert_eq!(
select_expand_source(true, ExpandTarget::Tool, true, true),
ExpandSource::LiveThinking
);
}
#[test]
fn source_prefers_target_then_falls_back() {
assert_eq!(
select_expand_source(false, ExpandTarget::Tool, true, true),
ExpandSource::Tool
);
assert_eq!(
select_expand_source(false, ExpandTarget::Thinking, true, true),
ExpandSource::Thinking
);
assert_eq!(
select_expand_source(false, ExpandTarget::Tool, false, true),
ExpandSource::Thinking
);
assert_eq!(
select_expand_source(false, ExpandTarget::None, true, false),
ExpandSource::Tool
);
assert_eq!(
select_expand_source(false, ExpandTarget::None, false, false),
ExpandSource::None
);
}
}