返回 CodeWhale
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 pub fn entry_count(&self) -> usize {
81 self.entries.len()
82 }
83
84 /// Whether the active cell has any entries.
85 #[must_use]
86 pub fn is_empty(&self) -> bool {
87 self.entries.is_empty()
88 }
89
90 /// Read-only access to the underlying entries (for rendering).
91 #[must_use]
92 pub fn entries(&self) -> &[HistoryCell] {
93 &self.entries
94 }
95
96 /// Mutable access to a specific entry. Bumps the revision counter so the
97 /// renderer knows the cached lines are stale.
98 pub fn entry_mut(&mut self, index: usize) -> Option<&mut HistoryCell> {
99 if index < self.entries.len() {
100 self.bump_revision();
101 self.entries.get_mut(index)
102 } else {
103 None
104 }
105 }
106
107 /// Current revision counter. Wraps on overflow which is fine for cache
108 /// invalidation; the chance of a wrap-around collision is astronomical
109 /// over a single session and any miss only causes one extra re-render.
110 #[must_use]
111 #[allow(dead_code)] // Used by App::bump_active_cell_revision and future cache wiring.
112 pub fn revision(&self) -> u64 {
113 self.revision
114 }
115
116 /// Increment the revision counter. Call any time an entry is mutated.
117 pub fn bump_revision(&mut self) {
118 self.revision = self.revision.wrapping_add(1);
119 }
120
121 /// Add a tool entry to the active cell.
122 ///
123 /// Returns the entry index (which the caller can record in
124 /// `tool_cells_in_active`). If the cell is an exploring tool start and
125 /// there is already an exploring entry in the active group, the entry is
126 /// appended to that aggregate instead of creating a new entry.
127 ///
128 /// `tool_id` is registered for the new (or updated) entry so future
129 /// completion lookups can find it.
130 pub fn push_tool(&mut self, tool_id: impl Into<String>, cell: HistoryCell) -> usize {
131 let tool_id = tool_id.into();
132 // If this is an exploring start and we already have an exploring
133 // entry, append to that entry rather than creating a new cell.
134 if let HistoryCell::Tool(ToolCell::Exploring(new_cell)) = &cell
135 && let Some(entry_idx) = self.exploring_entry
136 && let Some(HistoryCell::Tool(ToolCell::Exploring(existing))) =
137 self.entries.get_mut(entry_idx)
138 {
139 // The caller hands us a brand-new ExploringCell with one entry.
140 // Move that entry into the existing aggregate.
141 for explore_entry in &new_cell.entries {
142 let _ = existing.insert_entry(explore_entry.clone());
143 }
144 self.tool_to_entry.insert(tool_id, entry_idx);
145 self.bump_revision();
146 return entry_idx;
147 }
148
149 // Otherwise, push a new entry.
150 let entry_idx = self.entries.len();
151 if matches!(cell, HistoryCell::Tool(ToolCell::Exploring(_))) {
152 self.exploring_entry = Some(entry_idx);
153 }
154 self.entries.push(cell);
155 self.tool_to_entry.insert(tool_id, entry_idx);
156 self.bump_revision();
157 entry_idx
158 }
159
160 /// Push an entry with no tool id binding. Approval notices use this path
161 /// so they can remain in the transcript without shifting the virtual
162 /// indices of tools that are still running in `active_cell`.
163 pub fn push_untracked(&mut self, cell: HistoryCell) -> usize {
164 let entry_idx = self.entries.len();
165 self.entries.push(cell);
166 self.bump_revision();
167 entry_idx
168 }
169
170 /// Push a thinking entry as a new active-cell entry. Sibling to
171 /// [`Self::push_tool`] but for `HistoryCell::Thinking` content. Returns the
172 /// entry index. Thinking entries do not participate in `tool_to_entry` or
173 /// the exploring aggregation — each thinking block stands on its own.
174 ///
175 /// P2.3: thinking lives in the active cell so a `Thinking → Tool → Tool`
176 /// sequence renders as one logical "Working…" block until the next
177 /// assistant prose chunk flushes the group into history.
178 pub fn push_thinking(&mut self, cell: HistoryCell) -> usize {
179 debug_assert!(
180 matches!(cell, HistoryCell::Thinking { .. }),
181 "push_thinking expects HistoryCell::Thinking",
182 );
183 let entry_idx = self.entries.len();
184 self.entries.push(cell);
185 self.bump_revision();
186 entry_idx
187 }
188
189 /// Look up the entry index that holds the given tool id.
190 #[must_use]
191 pub fn entry_index_for_tool(&self, tool_id: &str) -> Option<usize> {
192 self.tool_to_entry.get(tool_id).copied()
193 }
194
195 /// Append an [`ExploringEntry`] to the existing exploring aggregate (if
196 /// any), binding the supplied tool id to it. Returns
197 /// `(entry_index, entry_within_exploring)` on success.
198 ///
199 /// Used when a second exploring tool starts during the same active group:
200 /// rather than allocating another ExploringCell entry in the active group
201 /// we extend the one that's already there.
202 pub fn append_to_exploring(
203 &mut self,
204 tool_id: impl Into<String>,
205 explore_entry: ExploringEntry,
206 ) -> Option<(usize, usize)> {
207 let entry_idx = self.exploring_entry?;
208 let HistoryCell::Tool(ToolCell::Exploring(cell)) = self.entries.get_mut(entry_idx)? else {
209 return None;
210 };
211 let inner_idx = cell.insert_entry(explore_entry);
212 self.tool_to_entry.insert(tool_id.into(), entry_idx);
213 self.bump_revision();
214 Some((entry_idx, inner_idx))
215 }
216
217 /// Ensure an [`ExploringCell`] exists in the active group; create it if
218 /// not. Returns its entry index.
219 pub fn ensure_exploring(&mut self) -> usize {
220 if let Some(idx) = self.exploring_entry {
221 return idx;
222 }
223 let idx = self.entries.len();
224 self.entries
225 .push(HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
226 entries: Vec::new(),
227 })));
228 self.exploring_entry = Some(idx);
229 self.bump_revision();
230 idx
231 }
232
233 /// Drain every entry, returning them in insertion order. Resets internal
234 /// state (revision is bumped via `bump_revision`).
235 ///
236 /// Callers use this on `TurnComplete` (or cancellation) to flush the
237 /// active group into `App.history`.
238 pub fn drain(&mut self) -> Vec<HistoryCell> {
239 let entries = std::mem::take(&mut self.entries);
240 self.tool_to_entry.clear();
241 self.exploring_entry = None;
242 self.bump_revision();
243 entries
244 }
245
246 /// Mark every still-running tool entry as `Failed` (used when the turn is
247 /// cancelled mid-flight). Entries that already completed are left alone.
248 ///
249 /// `Failed` is the closest existing variant for "interrupted"; the cell's
250 /// surrounding context (turn-status banner) tells the user it was a
251 /// cancellation rather than a tool error.
252 pub fn mark_in_progress_as_interrupted(&mut self) {
253 for cell in &mut self.entries {
254 mark_running_as_interrupted(cell);
255 }
256 self.bump_revision();
257 }
258 }
259
260 fn mark_running_as_interrupted(cell: &mut HistoryCell) {
261 if let HistoryCell::Thinking {
262 streaming,
263 duration_secs,
264 ..
265 } = cell
266 {
267 // A thinking cell stuck mid-stream should stop spinning when the turn
268 // is cancelled. Leave `duration_secs` as-is if it's already populated;
269 // otherwise the renderer simply omits the duration badge.
270 *streaming = false;
271 let _ = duration_secs;
272 return;
273 }
274 let HistoryCell::Tool(tool_cell) = cell else {
275 return;
276 };
277 match tool_cell {
278 ToolCell::Exec(exec) if exec.status == ToolStatus::Running => {
279 exec.status = ToolStatus::Failed;
280 }
281 ToolCell::Exploring(explore) => {
282 for entry in &mut explore.entries {
283 if entry.status == ToolStatus::Running {
284 entry.status = ToolStatus::Failed;
285 }
286 }
287 }
288 ToolCell::PlanUpdate(plan) if plan.status == ToolStatus::Running => {
289 plan.status = ToolStatus::Failed;
290 }
291 ToolCell::PatchSummary(patch) if patch.status == ToolStatus::Running => {
292 patch.status = ToolStatus::Failed;
293 }
294 ToolCell::Review(review) if review.status == ToolStatus::Running => {
295 review.status = ToolStatus::Failed;
296 }
297 ToolCell::Mcp(mcp) if mcp.status == ToolStatus::Running => {
298 mcp.status = ToolStatus::Failed;
299 }
300 ToolCell::WebSearch(search) if search.status == ToolStatus::Running => {
301 search.status = ToolStatus::Failed;
302 }
303 ToolCell::Generic(generic) if generic.status == ToolStatus::Running => {
304 generic.status = ToolStatus::Failed;
305 }
306 _ => {}
307 }
308 }
309
310 #[cfg(test)]
311 mod tests {
312 use super::*;
313 use crate::tui::history::{
314 ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell,
315 };
316 use std::time::Instant;
317
318 fn exec_cell(command: &str) -> HistoryCell {
319 HistoryCell::Tool(ToolCell::Exec(ExecCell {
320 command: command.to_string(),
321 status: ToolStatus::Running,
322 output: None,
323 live_output: None,
324 shell_task_id: None,
325 owner_agent_id: None,
326 owner_agent_name: None,
327 started_at: Some(Instant::now()),
328 duration_ms: None,
329 stale_elapsed_since_output_ms: None,
330 source: ExecSource::Assistant,
331 interaction: None,
332 output_summary: None,
333 }))
334 }
335
336 fn exploring_cell_with(label: &str) -> HistoryCell {
337 HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
338 entries: vec![ExploringEntry {
339 label: label.to_string(),
340 status: ToolStatus::Running,
341 }],
342 }))
343 }
344
345 fn generic_cell(name: &str) -> HistoryCell {
346 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
347 name: name.to_string(),
348 status: ToolStatus::Running,
349 input_summary: None,
350 output: None,
351 prompts: None,
352 spillover_path: None,
353 output_summary: None,
354 is_diff: false,
355 }))
356 }
357
358 #[test]
359 fn push_tool_records_entry_and_revision_advances() {
360 let mut cell = ActiveCell::new();
361 let r0 = cell.revision();
362 let idx = cell.push_tool("t1", exec_cell("ls"));
363 assert_eq!(idx, 0);
364 assert_eq!(cell.entry_count(), 1);
365 assert!(cell.revision() != r0);
366 assert_eq!(cell.entry_index_for_tool("t1"), Some(0));
367 }
368
369 #[test]
370 fn parallel_exploring_starts_share_one_entry() {
371 let mut cell = ActiveCell::new();
372 let idx_a = cell.push_tool("a", exploring_cell_with("Read foo.rs"));
373 let idx_b = cell.push_tool("b", exploring_cell_with("Read bar.rs"));
374 assert_eq!(
375 idx_a, idx_b,
376 "both exploring starts should land in same entry"
377 );
378 assert_eq!(cell.entry_count(), 1);
379 let HistoryCell::Tool(ToolCell::Exploring(explore)) = &cell.entries()[0] else {
380 panic!("expected exploring cell")
381 };
382 assert_eq!(explore.entries.len(), 2);
383 }
384
385 #[test]
386 fn drain_resets_state_and_returns_in_order() {
387 let mut cell = ActiveCell::new();
388 cell.push_tool("a", exec_cell("ls"));
389 cell.push_tool("b", generic_cell("foo"));
390 let drained = cell.drain();
391 assert_eq!(drained.len(), 2);
392 assert!(cell.is_empty());
393 assert_eq!(cell.entry_index_for_tool("a"), None);
394 }
395
396 #[test]
397 fn interrupt_marks_running_entries_failed() {
398 let mut cell = ActiveCell::new();
399 cell.push_tool("a", exec_cell("ls"));
400 cell.mark_in_progress_as_interrupted();
401 let HistoryCell::Tool(ToolCell::Exec(exec)) = &cell.entries()[0] else {
402 panic!("expected exec")
403 };
404 assert_eq!(exec.status, ToolStatus::Failed);
405 }
406
407 fn thinking_cell(content: &str, streaming: bool) -> HistoryCell {
408 HistoryCell::Thinking {
409 content: content.to_string(),
410 streaming,
411 duration_secs: None,
412 }
413 }
414
415 #[test]
416 fn push_thinking_records_entry_at_tail() {
417 let mut cell = ActiveCell::new();
418 let r0 = cell.revision();
419 let idx = cell.push_thinking(thinking_cell("planning…", true));
420 assert_eq!(idx, 0);
421 assert_eq!(cell.entry_count(), 1);
422 assert!(cell.revision() != r0);
423 }
424
425 #[test]
426 fn thinking_then_tools_group_in_one_active_cell() {
427 // P2.3: a turn that emits Thinking → Tool → Tool keeps everything in
428 // one active cell until the next prose chunk flushes the group.
429 let mut cell = ActiveCell::new();
430 cell.push_thinking(thinking_cell("plan…", true));
431 cell.push_tool("t-1", exec_cell("ls"));
432 cell.push_tool("t-2", exploring_cell_with("Read foo.rs"));
433 assert_eq!(
434 cell.entry_count(),
435 3,
436 "thinking, exec, and exploring entries coexist in one active cell"
437 );
438 assert!(matches!(cell.entries()[0], HistoryCell::Thinking { .. }));
439 assert!(matches!(
440 cell.entries()[1],
441 HistoryCell::Tool(ToolCell::Exec(_))
442 ));
443 assert!(matches!(
444 cell.entries()[2],
445 HistoryCell::Tool(ToolCell::Exploring(_))
446 ));
447 }
448
449 #[test]
450 fn drain_flushes_thinking_alongside_tools_in_order() {
451 let mut cell = ActiveCell::new();
452 cell.push_thinking(thinking_cell("plan…", false));
453 cell.push_tool("t", exec_cell("ls"));
454 let drained = cell.drain();
455 assert_eq!(drained.len(), 2);
456 assert!(matches!(drained[0], HistoryCell::Thinking { .. }));
457 assert!(matches!(drained[1], HistoryCell::Tool(ToolCell::Exec(_))));
458 }
459
460 #[test]
461 fn interrupt_stops_streaming_thinking_spinner() {
462 let mut cell = ActiveCell::new();
463 cell.push_thinking(thinking_cell("plan…", true));
464 cell.mark_in_progress_as_interrupted();
465 let HistoryCell::Thinking { streaming, .. } = &cell.entries()[0] else {
466 panic!("expected thinking cell")
467 };
468 assert!(
469 !*streaming,
470 "interrupted thinking should stop streaming so the spinner exits"
471 );
472 }
473 }
474
474 lines RUST