use crate::tui::history::{ExploringCell, ExploringEntry, HistoryCell, ToolCell, ToolStatus};
#[derive(Debug, Clone, Default)]
pub struct ActiveCell {
entries: Vec<HistoryCell>,
tool_to_entry: std::collections::HashMap<String, usize>,
exploring_entry: Option<usize>,
revision: u64,
}
impl ActiveCell {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
#[allow(dead_code)] pub fn entry_count(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
#[must_use]
pub fn entries(&self) -> &[HistoryCell] {
&self.entries
}
pub fn entry_mut(&mut self, index: usize) -> Option<&mut HistoryCell> {
if index < self.entries.len() {
self.bump_revision();
self.entries.get_mut(index)
} else {
None
}
}
#[must_use]
#[allow(dead_code)] pub fn revision(&self) -> u64 {
self.revision
}
pub fn bump_revision(&mut self) {
self.revision = self.revision.wrapping_add(1);
}
pub fn push_tool(&mut self, tool_id: impl Into<String>, cell: HistoryCell) -> usize {
let tool_id = tool_id.into();
if let HistoryCell::Tool(ToolCell::Exploring(new_cell)) = &cell
&& let Some(entry_idx) = self.exploring_entry
&& let Some(HistoryCell::Tool(ToolCell::Exploring(existing))) =
self.entries.get_mut(entry_idx)
{
for explore_entry in &new_cell.entries {
let _ = existing.insert_entry(explore_entry.clone());
}
self.tool_to_entry.insert(tool_id, entry_idx);
self.bump_revision();
return entry_idx;
}
let entry_idx = self.entries.len();
if matches!(cell, HistoryCell::Tool(ToolCell::Exploring(_))) {
self.exploring_entry = Some(entry_idx);
}
self.entries.push(cell);
self.tool_to_entry.insert(tool_id, entry_idx);
self.bump_revision();
entry_idx
}
#[allow(dead_code)]
pub fn push_untracked(&mut self, cell: HistoryCell) -> usize {
let entry_idx = self.entries.len();
self.entries.push(cell);
self.bump_revision();
entry_idx
}
pub fn push_thinking(&mut self, cell: HistoryCell) -> usize {
debug_assert!(
matches!(cell, HistoryCell::Thinking { .. }),
"push_thinking expects HistoryCell::Thinking",
);
let entry_idx = self.entries.len();
self.entries.push(cell);
self.bump_revision();
entry_idx
}
#[must_use]
#[allow(dead_code)] pub fn entry_index_for_tool(&self, tool_id: &str) -> Option<usize> {
self.tool_to_entry.get(tool_id).copied()
}
pub fn append_to_exploring(
&mut self,
tool_id: impl Into<String>,
explore_entry: ExploringEntry,
) -> Option<(usize, usize)> {
let entry_idx = self.exploring_entry?;
let HistoryCell::Tool(ToolCell::Exploring(cell)) = self.entries.get_mut(entry_idx)? else {
return None;
};
let inner_idx = cell.insert_entry(explore_entry);
self.tool_to_entry.insert(tool_id.into(), entry_idx);
self.bump_revision();
Some((entry_idx, inner_idx))
}
pub fn ensure_exploring(&mut self) -> usize {
if let Some(idx) = self.exploring_entry {
return idx;
}
let idx = self.entries.len();
self.entries
.push(HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
entries: Vec::new(),
})));
self.exploring_entry = Some(idx);
self.bump_revision();
idx
}
#[allow(dead_code)] pub fn forget_tool(&mut self, tool_id: &str) -> Option<usize> {
self.tool_to_entry.remove(tool_id)
}
pub fn drain(&mut self) -> Vec<HistoryCell> {
let entries = std::mem::take(&mut self.entries);
self.tool_to_entry.clear();
self.exploring_entry = None;
self.bump_revision();
entries
}
pub fn mark_in_progress_as_interrupted(&mut self) {
for cell in &mut self.entries {
mark_running_as_interrupted(cell);
}
self.bump_revision();
}
}
fn mark_running_as_interrupted(cell: &mut HistoryCell) {
if let HistoryCell::Thinking {
streaming,
duration_secs,
..
} = cell
{
*streaming = false;
let _ = duration_secs;
return;
}
let HistoryCell::Tool(tool_cell) = cell else {
return;
};
match tool_cell {
ToolCell::Exec(exec) if exec.status == ToolStatus::Running => {
exec.status = ToolStatus::Failed;
}
ToolCell::Exploring(explore) => {
for entry in &mut explore.entries {
if entry.status == ToolStatus::Running {
entry.status = ToolStatus::Failed;
}
}
}
ToolCell::PlanUpdate(plan) if plan.status == ToolStatus::Running => {
plan.status = ToolStatus::Failed;
}
ToolCell::PatchSummary(patch) if patch.status == ToolStatus::Running => {
patch.status = ToolStatus::Failed;
}
ToolCell::Review(review) if review.status == ToolStatus::Running => {
review.status = ToolStatus::Failed;
}
ToolCell::Mcp(mcp) if mcp.status == ToolStatus::Running => {
mcp.status = ToolStatus::Failed;
}
ToolCell::WebSearch(search) if search.status == ToolStatus::Running => {
search.status = ToolStatus::Failed;
}
ToolCell::Generic(generic) if generic.status == ToolStatus::Running => {
generic.status = ToolStatus::Failed;
}
_ => {}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::tui::history::{
ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell,
};
use std::time::Instant;
fn exec_cell(command: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Exec(ExecCell {
command: command.to_string(),
status: ToolStatus::Running,
output: None,
started_at: Some(Instant::now()),
duration_ms: None,
source: ExecSource::Assistant,
interaction: None,
}))
}
fn exploring_cell_with(label: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
entries: vec![ExploringEntry {
label: label.to_string(),
status: ToolStatus::Running,
}],
}))
}
fn generic_cell(name: &str) -> HistoryCell {
HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
name: name.to_string(),
status: ToolStatus::Running,
input_summary: None,
output: None,
prompts: None,
spillover_path: None,
}))
}
#[test]
fn push_tool_records_entry_and_revision_advances() {
let mut cell = ActiveCell::new();
let r0 = cell.revision();
let idx = cell.push_tool("t1", exec_cell("ls"));
assert_eq!(idx, 0);
assert_eq!(cell.entry_count(), 1);
assert!(cell.revision() != r0);
assert_eq!(cell.entry_index_for_tool("t1"), Some(0));
}
#[test]
fn parallel_exploring_starts_share_one_entry() {
let mut cell = ActiveCell::new();
let idx_a = cell.push_tool("a", exploring_cell_with("Read foo.rs"));
let idx_b = cell.push_tool("b", exploring_cell_with("Read bar.rs"));
assert_eq!(
idx_a, idx_b,
"both exploring starts should land in same entry"
);
assert_eq!(cell.entry_count(), 1);
let HistoryCell::Tool(ToolCell::Exploring(explore)) = &cell.entries()[0] else {
panic!("expected exploring cell")
};
assert_eq!(explore.entries.len(), 2);
}
#[test]
fn drain_resets_state_and_returns_in_order() {
let mut cell = ActiveCell::new();
cell.push_tool("a", exec_cell("ls"));
cell.push_tool("b", generic_cell("foo"));
let drained = cell.drain();
assert_eq!(drained.len(), 2);
assert!(cell.is_empty());
assert_eq!(cell.entry_index_for_tool("a"), None);
}
#[test]
fn interrupt_marks_running_entries_failed() {
let mut cell = ActiveCell::new();
cell.push_tool("a", exec_cell("ls"));
cell.mark_in_progress_as_interrupted();
let HistoryCell::Tool(ToolCell::Exec(exec)) = &cell.entries()[0] else {
panic!("expected exec")
};
assert_eq!(exec.status, ToolStatus::Failed);
}
fn thinking_cell(content: &str, streaming: bool) -> HistoryCell {
HistoryCell::Thinking {
content: content.to_string(),
streaming,
duration_secs: None,
}
}
#[test]
fn push_thinking_records_entry_at_tail() {
let mut cell = ActiveCell::new();
let r0 = cell.revision();
let idx = cell.push_thinking(thinking_cell("planning…", true));
assert_eq!(idx, 0);
assert_eq!(cell.entry_count(), 1);
assert!(cell.revision() != r0);
}
#[test]
fn thinking_then_tools_group_in_one_active_cell() {
let mut cell = ActiveCell::new();
cell.push_thinking(thinking_cell("plan…", true));
cell.push_tool("t-1", exec_cell("ls"));
cell.push_tool("t-2", exploring_cell_with("Read foo.rs"));
assert_eq!(
cell.entry_count(),
3,
"thinking, exec, and exploring entries coexist in one active cell"
);
assert!(matches!(cell.entries()[0], HistoryCell::Thinking { .. }));
assert!(matches!(
cell.entries()[1],
HistoryCell::Tool(ToolCell::Exec(_))
));
assert!(matches!(
cell.entries()[2],
HistoryCell::Tool(ToolCell::Exploring(_))
));
}
#[test]
fn drain_flushes_thinking_alongside_tools_in_order() {
let mut cell = ActiveCell::new();
cell.push_thinking(thinking_cell("plan…", false));
cell.push_tool("t", exec_cell("ls"));
let drained = cell.drain();
assert_eq!(drained.len(), 2);
assert!(matches!(drained[0], HistoryCell::Thinking { .. }));
assert!(matches!(drained[1], HistoryCell::Tool(ToolCell::Exec(_))));
}
#[test]
fn interrupt_stops_streaming_thinking_spinner() {
let mut cell = ActiveCell::new();
cell.push_thinking(thinking_cell("plan…", true));
cell.mark_in_progress_as_interrupted();
let HistoryCell::Thinking { streaming, .. } = &cell.entries()[0] else {
panic!("expected thinking cell")
};
assert!(
!*streaming,
"interrupted thinking should stop streaming so the spinner exits"
);
}
}