1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
use std::{cell::RefCell, rc::Rc};
pub(crate) mod chapters;
pub(crate) mod model;
pub(crate) mod view;
pub(crate) use model::{Block, BlockKind};
pub(crate) use view::rows;
pub(super) const MAX_BLOCKS: usize = 5000;
pub(super) const MAX_TOTAL_TEXT_BYTES: usize = 4 << 20;
pub(super) type WrapCache = RefCell<Option<(usize, Rc<rows::WrappedLines>)>>;
#[allow(dead_code)]
#[derive(Debug, Default)]
pub(crate) struct Transcript {
pub(super) blocks: Vec<Block>,
pub(super) scroll_up: usize,
/// Rows appended below a scrolled-up reader since they last returned to the
/// tail: pure view state, like `scroll_up` and `Block.group` — raised by
/// the append path, cleared by `scroll_to_bottom`, never persisted, never
/// replayed, and never read by anything that builds the model's context.
pub(super) unseen_rows: usize,
pub(super) cache: WrapCache,
/// Folded finished chapters: pure view state, like `Block.group` — never
/// persisted, never replayed, emptied by `clear()` with the blocks.
pub(super) folded: std::collections::BTreeSet<u32>,
/// Tool events buffered behind the shared grouper: the open run of
/// `ToolRequested`/`ToolCompleted` pairs not yet closed by a boundary
/// event. While the run is open its per-call lines also render live on
/// the tail; the boundary flush folds them into one collapsed block.
/// `None`'s and in-flight requests (`Some` with no completion yet) ride
/// here only — never on a rendered block — so an interrupted stream
/// leaves no half group behind.
pub(super) pending_tools: Vec<model::PendingToolCall>,
/// Where the provider attempt in flight began, taken on every
/// `TurnStarted`. `TurnReset` rolls back to exactly this and no further.
/// Pure lifecycle state: never persisted, never replayed.
pub(super) attempt: Option<model::attempt::AttemptMark>,
}
#[allow(dead_code)]
impl Transcript {
pub(crate) fn new() -> Self {
Self::default()
}
pub(super) fn invalidate_cache(&self) {
*self.cache.borrow_mut() = None;
}
/// Toggles the most recent collapsible tool group between its one-line
/// summary and its full per-call sequence. Returns true when a group was
/// toggled. There is no per-block cursor on the transcript, so this is the
/// smallest honest affordance: the newest group is the one the user just
/// watched stream in. Newest-only is the decided affordance, not an
/// unfinished one: the boundary rule stands and per-block cursor is
/// deliberately not built. Returns false (no-op) when no group exists; nothing
/// is pushed either way.
pub(crate) fn toggle_latest_group(&mut self) -> bool {
let toggled = self
.blocks
.iter_mut()
.rev()
.find(|block| block.is_collapsible())
.map(|block| {
let group = block.group.as_mut().expect("found by the predicate");
group.expanded = !group.expanded;
})
.is_some();
if toggled {
self.invalidate_cache();
}
toggled
}
}
#[cfg(test)]
#[path = "transcript/result_nav_tests.rs"]
mod result_nav_tests;