返回 DeepSeek-TUI-2026
active_cell.rs
根目录 / crates / tui / src / tui / active_cell.rs
1 //! Active in-flight tool/exec cell — single mutable group that buffers parallel
2 //! tool work for the current turn.
3 //!
4 //! ## Why
5 //!
6 //! When the model issues parallel tool calls in a single assistant turn (e.g.
7 //! two `read_file` and one `grep_files` running concurrently), naively
8 //! appending each tool start as its own history cell makes the transcript
9 //! "bounce" as completions arrive out of order. Codex's pattern is to keep all
10 //! in-flight tool work in ONE active cell that mutates in place; once the turn
11 //! resolves the active cell finalizes into the transcript.
12 //!
13 //! ## Contract
14 //!
15 //! - At most one [`ActiveCell`] per turn. It holds zero or more
16 //! [`HistoryCell`]s that are still being mutated (status `Running`, output
17 //! pending, etc.).
18 //! - The owning [`crate::tui::app::App`] renders the active cell's contents
19 //! AFTER `App.history` so they appear at the live tail.
20 //! - Cell indices used by helpers like `tool_cells` / `tool_details_by_cell`
21 //! address the virtual sequence `App.history ++ active_cell.entries`. Each
22 //! entry's index is `App.history.len() + entry_offset`.
23 //! - When a tool completes whose `tool_id` does not match any active entry
24 //! (orphan), the caller pushes a finalized standalone cell into `App.history`
25 //! instead of mutating the active group. This keeps `active_cell` a stable
26 //! reflection of what was actually started, and avoids merging unrelated
27 //! tool work.
28 //! - On `TurnComplete` (or cancellation) the active cell is "flushed":
29 //! in-progress entries are marked with the supplied terminal status, then
30 //! every entry is appended to `App.history`. Companion maps
31 //! (`tool_cells`, `tool_details_by_cell`) are rewritten to point at the new
32 //! `App.history` indices.
33 //!
34 //! ## Revision counter
35 //!
36 //! Cells inside the active group mutate without changing pointer identity, so
37 //! the transcript cache cannot rely on enum-equality for invalidation. We
38 //! expose `revision()` and `bump_revision()`; the renderer combines this with
39 //! `App.history_version` when computing per-cell revisions for the cache.
40
41 use crate::tui::history::{ExploringCell, ExploringEntry, HistoryCell, ToolCell, ToolStatus};
42
43 /// In-flight active cell: a sequence of mutable [`HistoryCell`] entries.
44 ///
45 /// Conceptually a single "live tail" cell in the Codex sense: it appears as
46 /// one logical block at the end of the transcript, but internally it is
47 /// composed of one or more entries (each rendered as its own
48 /// [`HistoryCell`]). The reason we keep them as separate entries — rather
49 /// than fusing into a single conceptual block — is that they may have
50 /// different shapes (an `ExecCell`, an `ExploringCell` aggregate, an MCP
51 /// tool result, …) and the existing renderers already know how to draw each
52 /// shape correctly. Coalescing into a single render path would duplicate
53 /// logic we already have.
54 #[derive(Debug, Clone, Default)]
55 pub struct ActiveCell {
56 entries: Vec<HistoryCell>,
57 /// Tool ids currently associated with this active cell. The map values are
58 /// indices into [`Self::entries`]. Multiple tool ids can map to the same
59 /// entry (the existing `ExploringCell` aggregates several reads into a
60 /// single entry).
61 tool_to_entry: std::collections::HashMap<String, usize>,
62 /// Index of the current `ExploringCell` entry (when present), so additional
63 /// exploring tool starts append to it instead of creating new cells.
64 exploring_entry: Option<usize>,
65 /// Bumped on every mutation. Used by the transcript cache to know that
66 /// the active cell needs re-rendering even though its position in the
67 /// virtual cell list is unchanged.
68 revision: u64,
69 }
70
71 impl ActiveCell {
72 /// Create an empty active cell.
73 #[must_use]
74 pub fn new() -> Self {
75 Self::default()
76 }
77
78 /// Number of entries (each rendered as its own [`HistoryCell`]).
79 #[must_use]
80 #[allow(dead_code)] // Public surface used by tests and future renderers.
81 pub fn entry_count(&self) -> usize {
82 self.entries.len()
83 }
84
85 /// Whether the active cell has any entries.
86 #[must_use]
87 pub fn is_empty(&self) -> bool {
88 self.entries.is_empty()
89 }
90
91 /// Read-only access to the underlying entries (for rendering).
92 #[must_use]
93 pub fn entries(&self) -> &[HistoryCell] {
94 &self.entries
95 }
96
97 /// Mutable access to a specific entry. Bumps the revision counter so the
98 /// renderer knows the cached lines are stale.
99 pub fn entry_mut(&mut self, index: usize) -> Option<&mut HistoryCell> {
100 if index < self.entries.len() {
101 self.bump_revision();
102 self.entries.get_mut(index)
103 } else {
104 None
105 }
106 }
107
108 /// Current revision counter. Wraps on overflow which is fine for cache
109 /// invalidation; the chance of a wrap-around collision is astronomical
110 /// over a single session and any miss only causes one extra re-render.
111 #[must_use]
112 #[allow(dead_code)] // Used by App::bump_active_cell_revision and future cache wiring.
113 pub fn revision(&self) -> u64 {
114 self.revision
115 }
116
117 /// Increment the revision counter. Call any time an entry is mutated.
118 pub fn bump_revision(&mut self) {
119 self.revision = self.revision.wrapping_add(1);
120 }
121
122 /// Add a tool entry to the active cell.
123 ///
124 /// Returns the entry index (which the caller can record in
125 /// `tool_cells_in_active`). If the cell is an exploring tool start and
126 /// there is already an exploring entry in the active group, the entry is
127 /// appended to that aggregate instead of creating a new entry.
128 ///
129 /// `tool_id` is registered for the new (or updated) entry so future
130 /// completion lookups can find it.
131 pub fn push_tool(&mut self, tool_id: impl Into<String>, cell: HistoryCell) -> usize {
132 let tool_id = tool_id.into();
133 // If this is an exploring start and we already have an exploring
134 // entry, append to that entry rather than creating a new cell.
135 if let HistoryCell::Tool(ToolCell::Exploring(new_cell)) = &cell
136 && let Some(entry_idx) = self.exploring_entry
137 && let Some(HistoryCell::Tool(ToolCell::Exploring(existing))) =
138 self.entries.get_mut(entry_idx)
139 {
140 // The caller hands us a brand-new ExploringCell with one entry.
141 // Move that entry into the existing aggregate.
142 for explore_entry in &new_cell.entries {
143 let _ = existing.insert_entry(explore_entry.clone());
144 }
145 self.tool_to_entry.insert(tool_id, entry_idx);
146 self.bump_revision();
147 return entry_idx;
148 }
149
150 // Otherwise, push a new entry.
151 let entry_idx = self.entries.len();
152 if matches!(cell, HistoryCell::Tool(ToolCell::Exploring(_))) {
153 self.exploring_entry = Some(entry_idx);
154 }
155 self.entries.push(cell);
156 self.tool_to_entry.insert(tool_id, entry_idx);
157 self.bump_revision();
158 entry_idx
159 }
160
161 /// Push an entry with no tool id binding (used for non-tool grouping if
162 /// ever needed). Currently unused; kept for symmetry with Codex which
163 /// allows e.g. session-header cells to live in `active_cell`.
164 #[allow(dead_code)]
165 pub fn push_untracked(&mut self, cell: HistoryCell) -> usize {
166 let entry_idx = self.entries.len();
167 self.entries.push(cell);
168 self.bump_revision();
169 entry_idx
170 }
171
172 /// Push a thinking entry as a new active-cell entry. Sibling to
173 /// [`Self::push_tool`] but for `HistoryCell::Thinking` content. Returns the
174 /// entry index. Thinking entries do not participate in `tool_to_entry` or
175 /// the exploring aggregation — each thinking block stands on its own.
176 ///
177 /// P2.3: thinking lives in the active cell so a `Thinking → Tool → Tool`
178 /// sequence renders as one logical "Working…" block until the next
179 /// assistant prose chunk flushes the group into history.
180 pub fn push_thinking(&mut self, cell: HistoryCell) -> usize {
181 debug_assert!(
182 matches!(cell, HistoryCell::Thinking { .. }),
183 "push_thinking expects HistoryCell::Thinking",
184 );
185 let entry_idx = self.entries.len();
186 self.entries.push(cell);
187 self.bump_revision();
188 entry_idx
189 }
190
191 /// Look up the entry index that holds the given tool id.
192 #[must_use]
193 #[allow(dead_code)] // Reserved for the Codex-style "exec end target" lookup.
194 pub fn entry_index_for_tool(&self, tool_id: &str) -> Option<usize> {
195 self.tool_to_entry.get(tool_id).copied()
196 }
197
198 /// Append an [`ExploringEntry`] to the existing exploring aggregate (if
199 /// any), binding the supplied tool id to it. Returns
200 /// `(entry_index, entry_within_exploring)` on success.
201 ///
202 /// Used when a second exploring tool starts during the same active group:
203 /// rather than allocating another ExploringCell entry in the active group
204 /// we extend the one that's already there.
205 pub fn append_to_exploring(
206 &mut self,
207 tool_id: impl Into<String>,
208 explore_entry: ExploringEntry,
209 ) -> Option<(usize, usize)> {
210 let entry_idx = self.exploring_entry?;
211 let HistoryCell::Tool(ToolCell::Exploring(cell)) = self.entries.get_mut(entry_idx)? else {
212 return None;
213 };
214 let inner_idx = cell.insert_entry(explore_entry);
215 self.tool_to_entry.insert(tool_id.into(), entry_idx);
216 self.bump_revision();
217 Some((entry_idx, inner_idx))
218 }
219
220 /// Ensure an [`ExploringCell`] exists in the active group; create it if
221 /// not. Returns its entry index.
222 pub fn ensure_exploring(&mut self) -> usize {
223 if let Some(idx) = self.exploring_entry {
224 return idx;
225 }
226 let idx = self.entries.len();
227 self.entries
228 .push(HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
229 entries: Vec::new(),
230 })));
231 self.exploring_entry = Some(idx);
232 self.bump_revision();
233 idx
234 }
235
236 /// Remove the tool-id binding for an entry without removing the entry
237 /// itself (the entry remains in the active group, presumably with its
238 /// status updated).
239 #[allow(dead_code)] // Reserved for cancellation paths that prune ids without flushing.
240 pub fn forget_tool(&mut self, tool_id: &str) -> Option<usize> {
241 self.tool_to_entry.remove(tool_id)
242 }
243
244 /// Drain every entry, returning them in insertion order. Resets internal
245 /// state (revision is bumped via `bump_revision`).
246 ///
247 /// Callers use this on `TurnComplete` (or cancellation) to flush the
248 /// active group into `App.history`.
249 pub fn drain(&mut self) -> Vec<HistoryCell> {
250 let entries = std::mem::take(&mut self.entries);
251 self.tool_to_entry.clear();
252 self.exploring_entry = None;
253 self.bump_revision();
254 entries
255 }
256
257 /// Mark every still-running tool entry as `Failed` (used when the turn is
258 /// cancelled mid-flight). Entries that already completed are left alone.
259 ///
260 /// `Failed` is the closest existing variant for "interrupted"; the cell's
261 /// surrounding context (turn-status banner) tells the user it was a
262 /// cancellation rather than a tool error.
263 pub fn mark_in_progress_as_interrupted(&mut self) {
264 for cell in &mut self.entries {
265 mark_running_as_interrupted(cell);
266 }
267 self.bump_revision();
268 }
269 }
270
271 fn mark_running_as_interrupted(cell: &mut HistoryCell) {
272 if let HistoryCell::Thinking {
273 streaming,
274 duration_secs,
275 ..
276 } = cell
277 {
278 // A thinking cell stuck mid-stream should stop spinning when the turn
279 // is cancelled. Leave `duration_secs` as-is if it's already populated;
280 // otherwise the renderer simply omits the duration badge.
281 *streaming = false;
282 let _ = duration_secs;
283 return;
284 }
285 let HistoryCell::Tool(tool_cell) = cell else {
286 return;
287 };
288 match tool_cell {
289 ToolCell::Exec(exec) if exec.status == ToolStatus::Running => {
290 exec.status = ToolStatus::Failed;
291 }
292 ToolCell::Exploring(explore) => {
293 for entry in &mut explore.entries {
294 if entry.status == ToolStatus::Running {
295 entry.status = ToolStatus::Failed;
296 }
297 }
298 }
299 ToolCell::PlanUpdate(plan) if plan.status == ToolStatus::Running => {
300 plan.status = ToolStatus::Failed;
301 }
302 ToolCell::PatchSummary(patch) if patch.status == ToolStatus::Running => {
303 patch.status = ToolStatus::Failed;
304 }
305 ToolCell::Review(review) if review.status == ToolStatus::Running => {
306 review.status = ToolStatus::Failed;
307 }
308 ToolCell::Mcp(mcp) if mcp.status == ToolStatus::Running => {
309 mcp.status = ToolStatus::Failed;
310 }
311 ToolCell::WebSearch(search) if search.status == ToolStatus::Running => {
312 search.status = ToolStatus::Failed;
313 }
314 ToolCell::Generic(generic) if generic.status == ToolStatus::Running => {
315 generic.status = ToolStatus::Failed;
316 }
317 _ => {}
318 }
319 }
320
321 #[cfg(test)]
322 mod tests {
323 use super::*;
324 use crate::tui::history::{
325 ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell,
326 };
327 use std::time::Instant;
328
329 fn exec_cell(command: &str) -> HistoryCell {
330 HistoryCell::Tool(ToolCell::Exec(ExecCell {
331 command: command.to_string(),
332 status: ToolStatus::Running,
333 output: None,
334 started_at: Some(Instant::now()),
335 duration_ms: None,
336 source: ExecSource::Assistant,
337 interaction: None,
338 }))
339 }
340
341 fn exploring_cell_with(label: &str) -> HistoryCell {
342 HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
343 entries: vec![ExploringEntry {
344 label: label.to_string(),
345 status: ToolStatus::Running,
346 }],
347 }))
348 }
349
350 fn generic_cell(name: &str) -> HistoryCell {
351 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
352 name: name.to_string(),
353 status: ToolStatus::Running,
354 input_summary: None,
355 output: None,
356 prompts: None,
357 spillover_path: None,
358 }))
359 }
360
361 #[test]
362 fn push_tool_records_entry_and_revision_advances() {
363 let mut cell = ActiveCell::new();
364 let r0 = cell.revision();
365 let idx = cell.push_tool("t1", exec_cell("ls"));
366 assert_eq!(idx, 0);
367 assert_eq!(cell.entry_count(), 1);
368 assert!(cell.revision() != r0);
369 assert_eq!(cell.entry_index_for_tool("t1"), Some(0));
370 }
371
372 #[test]
373 fn parallel_exploring_starts_share_one_entry() {
374 let mut cell = ActiveCell::new();
375 let idx_a = cell.push_tool("a", exploring_cell_with("Read foo.rs"));
376 let idx_b = cell.push_tool("b", exploring_cell_with("Read bar.rs"));
377 assert_eq!(
378 idx_a, idx_b,
379 "both exploring starts should land in same entry"
380 );
381 assert_eq!(cell.entry_count(), 1);
382 let HistoryCell::Tool(ToolCell::Exploring(explore)) = &cell.entries()[0] else {
383 panic!("expected exploring cell")
384 };
385 assert_eq!(explore.entries.len(), 2);
386 }
387
388 #[test]
389 fn drain_resets_state_and_returns_in_order() {
390 let mut cell = ActiveCell::new();
391 cell.push_tool("a", exec_cell("ls"));
392 cell.push_tool("b", generic_cell("foo"));
393 let drained = cell.drain();
394 assert_eq!(drained.len(), 2);
395 assert!(cell.is_empty());
396 assert_eq!(cell.entry_index_for_tool("a"), None);
397 }
398
399 #[test]
400 fn interrupt_marks_running_entries_failed() {
401 let mut cell = ActiveCell::new();
402 cell.push_tool("a", exec_cell("ls"));
403 cell.mark_in_progress_as_interrupted();
404 let HistoryCell::Tool(ToolCell::Exec(exec)) = &cell.entries()[0] else {
405 panic!("expected exec")
406 };
407 assert_eq!(exec.status, ToolStatus::Failed);
408 }
409
410 fn thinking_cell(content: &str, streaming: bool) -> HistoryCell {
411 HistoryCell::Thinking {
412 content: content.to_string(),
413 streaming,
414 duration_secs: None,
415 }
416 }
417
418 #[test]
419 fn push_thinking_records_entry_at_tail() {
420 let mut cell = ActiveCell::new();
421 let r0 = cell.revision();
422 let idx = cell.push_thinking(thinking_cell("planning…", true));
423 assert_eq!(idx, 0);
424 assert_eq!(cell.entry_count(), 1);
425 assert!(cell.revision() != r0);
426 }
427
428 #[test]
429 fn thinking_then_tools_group_in_one_active_cell() {
430 // P2.3: a turn that emits Thinking → Tool → Tool keeps everything in
431 // one active cell until the next prose chunk flushes the group.
432 let mut cell = ActiveCell::new();
433 cell.push_thinking(thinking_cell("plan…", true));
434 cell.push_tool("t-1", exec_cell("ls"));
435 cell.push_tool("t-2", exploring_cell_with("Read foo.rs"));
436 assert_eq!(
437 cell.entry_count(),
438 3,
439 "thinking, exec, and exploring entries coexist in one active cell"
440 );
441 assert!(matches!(cell.entries()[0], HistoryCell::Thinking { .. }));
442 assert!(matches!(
443 cell.entries()[1],
444 HistoryCell::Tool(ToolCell::Exec(_))
445 ));
446 assert!(matches!(
447 cell.entries()[2],
448 HistoryCell::Tool(ToolCell::Exploring(_))
449 ));
450 }
451
452 #[test]
453 fn drain_flushes_thinking_alongside_tools_in_order() {
454 let mut cell = ActiveCell::new();
455 cell.push_thinking(thinking_cell("plan…", false));
456 cell.push_tool("t", exec_cell("ls"));
457 let drained = cell.drain();
458 assert_eq!(drained.len(), 2);
459 assert!(matches!(drained[0], HistoryCell::Thinking { .. }));
460 assert!(matches!(drained[1], HistoryCell::Tool(ToolCell::Exec(_))));
461 }
462
463 #[test]
464 fn interrupt_stops_streaming_thinking_spinner() {
465 let mut cell = ActiveCell::new();
466 cell.push_thinking(thinking_cell("plan…", true));
467 cell.mark_in_progress_as_interrupted();
468 let HistoryCell::Thinking { streaming, .. } = &cell.entries()[0] else {
469 panic!("expected thinking cell")
470 };
471 assert!(
472 !*streaming,
473 "interrupted thinking should stop streaming so the spinner exits"
474 );
475 }
476 }
477
477 lines RUST