返回 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 #[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. Approval notices use this path
162 /// so they can remain in the transcript without shifting the virtual
163 /// indices of tools that are still running in `active_cell`.
164 pub fn push_untracked(&mut self, cell: HistoryCell) -> usize {
165 let entry_idx = self.entries.len();
166 self.entries.push(cell);
167 self.bump_revision();
168 entry_idx
169 }
170
171 /// Push a thinking entry as a new active-cell entry. Sibling to
172 /// [`Self::push_tool`] but for `HistoryCell::Thinking` content. Returns the
173 /// entry index. Thinking entries do not participate in `tool_to_entry` or
174 /// the exploring aggregation — each thinking block stands on its own.
175 ///
176 /// P2.3: thinking lives in the active cell so a `Thinking → Tool → Tool`
177 /// sequence renders as one logical "Working…" block until the next
178 /// assistant prose chunk flushes the group into history.
179 pub fn push_thinking(&mut self, cell: HistoryCell) -> usize {
180 debug_assert!(
181 matches!(cell, HistoryCell::Thinking { .. }),
182 "push_thinking expects HistoryCell::Thinking",
183 );
184 let entry_idx = self.entries.len();
185 self.entries.push(cell);
186 self.bump_revision();
187 entry_idx
188 }
189
190 /// Look up the entry index that holds the given tool id.
191 #[must_use]
192 #[allow(dead_code)] // Reserved for the Codex-style "exec end target" lookup.
193 pub fn entry_index_for_tool(&self, tool_id: &str) -> Option<usize> {
194 self.tool_to_entry.get(tool_id).copied()
195 }
196
197 /// Append an [`ExploringEntry`] to the existing exploring aggregate (if
198 /// any), binding the supplied tool id to it. Returns
199 /// `(entry_index, entry_within_exploring)` on success.
200 ///
201 /// Used when a second exploring tool starts during the same active group:
202 /// rather than allocating another ExploringCell entry in the active group
203 /// we extend the one that's already there.
204 pub fn append_to_exploring(
205 &mut self,
206 tool_id: impl Into<String>,
207 explore_entry: ExploringEntry,
208 ) -> Option<(usize, usize)> {
209 let entry_idx = self.exploring_entry?;
210 let HistoryCell::Tool(ToolCell::Exploring(cell)) = self.entries.get_mut(entry_idx)? else {
211 return None;
212 };
213 let inner_idx = cell.insert_entry(explore_entry);
214 self.tool_to_entry.insert(tool_id.into(), entry_idx);
215 self.bump_revision();
216 Some((entry_idx, inner_idx))
217 }
218
219 /// Ensure an [`ExploringCell`] exists in the active group; create it if
220 /// not. Returns its entry index.
221 pub fn ensure_exploring(&mut self) -> usize {
222 if let Some(idx) = self.exploring_entry {
223 return idx;
224 }
225 let idx = self.entries.len();
226 self.entries
227 .push(HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
228 entries: Vec::new(),
229 })));
230 self.exploring_entry = Some(idx);
231 self.bump_revision();
232 idx
233 }
234
235 /// Remove the tool-id binding for an entry without removing the entry
236 /// itself (the entry remains in the active group, presumably with its
237 /// status updated).
238 #[allow(dead_code)] // Reserved for cancellation paths that prune ids without flushing.
239 pub fn forget_tool(&mut self, tool_id: &str) -> Option<usize> {
240 self.tool_to_entry.remove(tool_id)
241 }
242
243 /// Drain every entry, returning them in insertion order. Resets internal
244 /// state (revision is bumped via `bump_revision`).
245 ///
246 /// Callers use this on `TurnComplete` (or cancellation) to flush the
247 /// active group into `App.history`.
248 pub fn drain(&mut self) -> Vec<HistoryCell> {
249 let entries = std::mem::take(&mut self.entries);
250 self.tool_to_entry.clear();
251 self.exploring_entry = None;
252 self.bump_revision();
253 entries
254 }
255
256 /// Mark every still-running tool entry as `Failed` (used when the turn is
257 /// cancelled mid-flight). Entries that already completed are left alone.
258 ///
259 /// `Failed` is the closest existing variant for "interrupted"; the cell's
260 /// surrounding context (turn-status banner) tells the user it was a
261 /// cancellation rather than a tool error.
262 pub fn mark_in_progress_as_interrupted(&mut self) {
263 for cell in &mut self.entries {
264 mark_running_as_interrupted(cell);
265 }
266 self.bump_revision();
267 }
268 }
269
270 fn mark_running_as_interrupted(cell: &mut HistoryCell) {
271 if let HistoryCell::Thinking {
272 streaming,
273 duration_secs,
274 ..
275 } = cell
276 {
277 // A thinking cell stuck mid-stream should stop spinning when the turn
278 // is cancelled. Leave `duration_secs` as-is if it's already populated;
279 // otherwise the renderer simply omits the duration badge.
280 *streaming = false;
281 let _ = duration_secs;
282 return;
283 }
284 let HistoryCell::Tool(tool_cell) = cell else {
285 return;
286 };
287 match tool_cell {
288 ToolCell::Exec(exec) if exec.status == ToolStatus::Running => {
289 exec.status = ToolStatus::Failed;
290 }
291 ToolCell::Exploring(explore) => {
292 for entry in &mut explore.entries {
293 if entry.status == ToolStatus::Running {
294 entry.status = ToolStatus::Failed;
295 }
296 }
297 }
298 ToolCell::PlanUpdate(plan) if plan.status == ToolStatus::Running => {
299 plan.status = ToolStatus::Failed;
300 }
301 ToolCell::PatchSummary(patch) if patch.status == ToolStatus::Running => {
302 patch.status = ToolStatus::Failed;
303 }
304 ToolCell::Review(review) if review.status == ToolStatus::Running => {
305 review.status = ToolStatus::Failed;
306 }
307 ToolCell::Mcp(mcp) if mcp.status == ToolStatus::Running => {
308 mcp.status = ToolStatus::Failed;
309 }
310 ToolCell::WebSearch(search) if search.status == ToolStatus::Running => {
311 search.status = ToolStatus::Failed;
312 }
313 ToolCell::Generic(generic) if generic.status == ToolStatus::Running => {
314 generic.status = ToolStatus::Failed;
315 }
316 _ => {}
317 }
318 }
319
320 #[cfg(test)]
321 mod tests {
322 use super::*;
323 use crate::tui::history::{
324 ExecCell, ExecSource, ExploringCell, ExploringEntry, GenericToolCell,
325 };
326 use std::time::Instant;
327
328 fn exec_cell(command: &str) -> HistoryCell {
329 HistoryCell::Tool(ToolCell::Exec(ExecCell {
330 command: command.to_string(),
331 status: ToolStatus::Running,
332 output: None,
333 live_output: None,
334 shell_task_id: None,
335 owner_agent_id: None,
336 owner_agent_name: None,
337 started_at: Some(Instant::now()),
338 duration_ms: None,
339 stale_elapsed_since_output_ms: None,
340 source: ExecSource::Assistant,
341 interaction: None,
342 output_summary: None,
343 }))
344 }
345
346 fn exploring_cell_with(label: &str) -> HistoryCell {
347 HistoryCell::Tool(ToolCell::Exploring(ExploringCell {
348 entries: vec![ExploringEntry {
349 label: label.to_string(),
350 status: ToolStatus::Running,
351 }],
352 }))
353 }
354
355 fn generic_cell(name: &str) -> HistoryCell {
356 HistoryCell::Tool(ToolCell::Generic(GenericToolCell {
357 name: name.to_string(),
358 status: ToolStatus::Running,
359 input_summary: None,
360 output: None,
361 prompts: None,
362 spillover_path: None,
363 output_summary: None,
364 is_diff: false,
365 }))
366 }
367
368 #[test]
369 fn push_tool_records_entry_and_revision_advances() {
370 let mut cell = ActiveCell::new();
371 let r0 = cell.revision();
372 let idx = cell.push_tool("t1", exec_cell("ls"));
373 assert_eq!(idx, 0);
374 assert_eq!(cell.entry_count(), 1);
375 assert!(cell.revision() != r0);
376 assert_eq!(cell.entry_index_for_tool("t1"), Some(0));
377 }
378
379 #[test]
380 fn parallel_exploring_starts_share_one_entry() {
381 let mut cell = ActiveCell::new();
382 let idx_a = cell.push_tool("a", exploring_cell_with("Read foo.rs"));
383 let idx_b = cell.push_tool("b", exploring_cell_with("Read bar.rs"));
384 assert_eq!(
385 idx_a, idx_b,
386 "both exploring starts should land in same entry"
387 );
388 assert_eq!(cell.entry_count(), 1);
389 let HistoryCell::Tool(ToolCell::Exploring(explore)) = &cell.entries()[0] else {
390 panic!("expected exploring cell")
391 };
392 assert_eq!(explore.entries.len(), 2);
393 }
394
395 #[test]
396 fn drain_resets_state_and_returns_in_order() {
397 let mut cell = ActiveCell::new();
398 cell.push_tool("a", exec_cell("ls"));
399 cell.push_tool("b", generic_cell("foo"));
400 let drained = cell.drain();
401 assert_eq!(drained.len(), 2);
402 assert!(cell.is_empty());
403 assert_eq!(cell.entry_index_for_tool("a"), None);
404 }
405
406 #[test]
407 fn interrupt_marks_running_entries_failed() {
408 let mut cell = ActiveCell::new();
409 cell.push_tool("a", exec_cell("ls"));
410 cell.mark_in_progress_as_interrupted();
411 let HistoryCell::Tool(ToolCell::Exec(exec)) = &cell.entries()[0] else {
412 panic!("expected exec")
413 };
414 assert_eq!(exec.status, ToolStatus::Failed);
415 }
416
417 fn thinking_cell(content: &str, streaming: bool) -> HistoryCell {
418 HistoryCell::Thinking {
419 content: content.to_string(),
420 streaming,
421 duration_secs: None,
422 }
423 }
424
425 #[test]
426 fn push_thinking_records_entry_at_tail() {
427 let mut cell = ActiveCell::new();
428 let r0 = cell.revision();
429 let idx = cell.push_thinking(thinking_cell("planning…", true));
430 assert_eq!(idx, 0);
431 assert_eq!(cell.entry_count(), 1);
432 assert!(cell.revision() != r0);
433 }
434
435 #[test]
436 fn thinking_then_tools_group_in_one_active_cell() {
437 // P2.3: a turn that emits Thinking → Tool → Tool keeps everything in
438 // one active cell until the next prose chunk flushes the group.
439 let mut cell = ActiveCell::new();
440 cell.push_thinking(thinking_cell("plan…", true));
441 cell.push_tool("t-1", exec_cell("ls"));
442 cell.push_tool("t-2", exploring_cell_with("Read foo.rs"));
443 assert_eq!(
444 cell.entry_count(),
445 3,
446 "thinking, exec, and exploring entries coexist in one active cell"
447 );
448 assert!(matches!(cell.entries()[0], HistoryCell::Thinking { .. }));
449 assert!(matches!(
450 cell.entries()[1],
451 HistoryCell::Tool(ToolCell::Exec(_))
452 ));
453 assert!(matches!(
454 cell.entries()[2],
455 HistoryCell::Tool(ToolCell::Exploring(_))
456 ));
457 }
458
459 #[test]
460 fn drain_flushes_thinking_alongside_tools_in_order() {
461 let mut cell = ActiveCell::new();
462 cell.push_thinking(thinking_cell("plan…", false));
463 cell.push_tool("t", exec_cell("ls"));
464 let drained = cell.drain();
465 assert_eq!(drained.len(), 2);
466 assert!(matches!(drained[0], HistoryCell::Thinking { .. }));
467 assert!(matches!(drained[1], HistoryCell::Tool(ToolCell::Exec(_))));
468 }
469
470 #[test]
471 fn interrupt_stops_streaming_thinking_spinner() {
472 let mut cell = ActiveCell::new();
473 cell.push_thinking(thinking_cell("plan…", true));
474 cell.mark_in_progress_as_interrupted();
475 let HistoryCell::Thinking { streaming, .. } = &cell.entries()[0] else {
476 panic!("expected thinking cell")
477 };
478 assert!(
479 !*streaming,
480 "interrupted thinking should stop streaming so the spinner exits"
481 );
482 }
483 }
484
484 lines RUST