返回 CodeWhale
todo_snapshot.rs
根目录 / crates / tui / src / todo_snapshot.rs
1 //! Bounded renderings of a To-do snapshot.
2 //!
3 //! Codewhale has exactly one To-do list. The model learns what is on it the
4 //! same way it learns anything else: from the tool result its own `todo_write`
5 //! / `work_update` call returned, which is ordinary persisted transcript
6 //! state. **Nothing in this module is appended to a provider request**, and no
7 //! step of a tool loop re-states the list. If the model wants the current
8 //! list, it reads its own last tool result or calls the tool again.
9 //!
10 //! What this module owns is the small set of places that render a snapshot
11 //! *once*, at an explicit seam a person asked for:
12 //!
13 //! 1. the `<codewhale:fork_state>` block a newly forked sub-agent is handed,
14 //! 2. `/relay` handoff instructions,
15 //! 3. the in-transcript agent card (display only).
16 //!
17 //! All three share [`todo_snapshot_body`] byte-for-byte, so no two surfaces
18 //! can disagree about what the list says.
19 //!
20 //! Rules the renderer must keep, because this text reaches a model:
21 //!
22 //! - An empty To-do renders nothing at all. Silence beats an empty list that
23 //! reads as "there is no work".
24 //! - `update_plan` strategy state is conversational reasoning, not a second
25 //! list, and never appears here.
26 //! - Items and characters are both hard-bounded, so a large list cannot eat the
27 //! context window. The in-progress item is preserved preferentially — losing
28 //! the active item is the one omission that would actively mislead.
29 //! - Truncation happens on `char` boundaries and marks the omission, so a
30 //! multi-byte item can neither panic nor silently shrink the list.
31 //! - Item text can never close its wrapper: a closing tag in the `codewhale:`
32 //! namespace is escaped before it reaches the model, and control characters
33 //! are flattened so content cannot forge a new line.
34 //!
35 //! **What this module does not do:** it does not sanitize To-do content against
36 //! prompt injection, and no caller should claim that it does. The guarantees
37 //! above are exactly three — wrapper framing cannot be closed early, control
38 //! characters cannot forge the line format, and the item/character bounds hold.
39 //! The *meaning* of arbitrary item text is not inspected, filtered, or
40 //! neutralized; a To-do item containing instructions still reaches the model as
41 //! item text. Treating that text as untrusted data is the model contract's job
42 //! (the constitution), not the renderer's.
43
44 use crate::tools::todo::{SharedTodoList, TodoItem, TodoListSnapshot, TodoStatus};
45 use crate::work_graph::SharedWorkRuntime;
46
47 /// Maximum number of item lines rendered in the body.
48 pub const MAX_ITEM_LINES: usize = 24;
49 /// Hard character ceiling for the body (counted in `char`s).
50 pub const MAX_BODY_CHARS: usize = 2_000;
51 /// Per-item content ceiling before the omission marker is appended.
52 pub const MAX_ITEM_CONTENT_CHARS: usize = 160;
53
54 /// Marks any text elided by a bound.
55 const OMISSION_MARKER: char = '…';
56
57 /// Escaped form of a closing wrapper tag found inside item content.
58 const ESCAPED_CLOSE_PREFIX: &str = "<\\/codewhale:";
59 const CLOSE_PREFIX: &str = "</codewhale:";
60
61 /// Render the To-do snapshot body, or `None` when there is nothing on the list.
62 ///
63 /// The returned string carries no framing; each seam supplies its own, so the
64 /// body itself stays comparable across surfaces.
65 #[must_use]
66 pub fn todo_snapshot_body(snapshot: &TodoListSnapshot) -> Option<String> {
67 if snapshot.items.is_empty() {
68 return None;
69 }
70
71 let header = format!("To-do ({}% settled)", snapshot.completion_pct);
72 let lines: Vec<String> = snapshot.items.iter().map(item_line).collect();
73 let priority = priority_order(snapshot);
74
75 let mut selected: Vec<usize> = Vec::new();
76 let mut used = header.chars().count();
77 for idx in priority {
78 if selected.len() >= MAX_ITEM_LINES {
79 break;
80 }
81 let cost = 1 + lines[idx].chars().count();
82 if used + cost > MAX_BODY_CHARS {
83 break;
84 }
85 used += cost;
86 selected.push(idx);
87 }
88
89 // The omission line itself costs characters, so it has to fit inside the
90 // same ceiling. Drop lowest-priority selections until it does; the active
91 // item sits at index 0 and is never the one dropped.
92 let mut omitted = lines.len() - selected.len();
93 if omitted > 0 {
94 loop {
95 let cost = 1 + omission_line(omitted).chars().count();
96 if used + cost <= MAX_BODY_CHARS || selected.len() <= 1 {
97 break;
98 }
99 if let Some(dropped) = selected.pop() {
100 used -= 1 + lines[dropped].chars().count();
101 omitted += 1;
102 }
103 }
104 }
105
106 selected.sort_unstable();
107 let mut body = header;
108 for idx in selected {
109 body.push('\n');
110 body.push_str(&lines[idx]);
111 }
112 if omitted > 0 {
113 body.push('\n');
114 body.push_str(&omission_line(omitted));
115 }
116
117 debug_assert!(body.chars().count() <= MAX_BODY_CHARS);
118 Some(body)
119 }
120
121 /// The authoritative source of one agent's To-do state.
122 ///
123 /// There are two stores in play and only one of them is current. When a
124 /// [`WorkRuntime`](crate::work_graph::WorkRuntime) owns this list, a
125 /// `work_update` *stages* the new projection in the graph and the legacy
126 /// `SharedTodoList` view is only refreshed later, asynchronously, by the UI's
127 /// publish step. Reading the legacy view alone therefore shows the model its
128 /// state from before its own last write. So: read the graph projection when the
129 /// runtime owns this exact list (`Arc::ptr_eq` via
130 /// [`WorkRuntime::matches_todos`](crate::work_graph::WorkRuntime::matches_todos)),
131 /// and read the list directly otherwise.
132 ///
133 /// The ownership check is what keeps agents isolated. A child's runtime carries
134 /// its *parent's* `WorkRuntime` handle but its **own** list (#4810), so
135 /// `matches_todos` is false for every child and each child resolves against its
136 /// own store — a child can never read the parent's or a sibling's list here.
137 #[derive(Clone)]
138 pub struct TodoSource {
139 work: Option<SharedWorkRuntime>,
140 todos: SharedTodoList,
141 }
142
143 impl std::fmt::Debug for TodoSource {
144 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145 f.debug_struct("TodoSource")
146 .field("graph_backed", &self.is_graph_backed())
147 .finish()
148 }
149 }
150
151 impl TodoSource {
152 /// Bind a source to an agent's own list plus whatever work runtime its
153 /// tool context carries.
154 #[must_use]
155 pub fn new(work: Option<SharedWorkRuntime>, todos: SharedTodoList) -> Self {
156 Self { work, todos }
157 }
158
159 /// Whether the attached runtime actually owns this list.
160 #[must_use]
161 pub fn is_graph_backed(&self) -> bool {
162 self.work
163 .as_ref()
164 .is_some_and(|work| work.matches_todos(&self.todos))
165 }
166
167 /// Current authoritative snapshot.
168 ///
169 /// Never omits and never fails: a graph read error degrades to the legacy
170 /// view with a warning rather than dropping the list from a fork handoff,
171 /// because a silently missing list reads to the model as "no work".
172 pub async fn snapshot(&self) -> TodoListSnapshot {
173 if let Some(work) = self.work.as_ref().filter(|_| self.is_graph_backed()) {
174 match work.current_todos().await {
175 Ok(snapshot) => return snapshot,
176 Err(err) => tracing::warn!(
177 target: "todo_snapshot",
178 error = %err,
179 "work graph projection unavailable; falling back to the legacy To-do view"
180 ),
181 }
182 }
183 self.todos.lock().await.snapshot()
184 }
185
186 /// Body for the current authoritative snapshot.
187 pub async fn body(&self) -> Option<String> {
188 todo_snapshot_body(&self.snapshot().await)
189 }
190 }
191
192 /// Maximum item rows an in-transcript agent card renders (#4810). Narrower
193 /// than the shared bound: a card is a glance, not the whole list.
194 pub const MAX_CARD_ITEM_LINES: usize = 3;
195 /// Per-item content ceiling on a card row.
196 pub const MAX_CARD_ITEM_CONTENT_CHARS: usize = 72;
197
198 /// Bounded, display-only projection of **one agent's own** To-do snapshot for
199 /// its delegate/agent card.
200 ///
201 /// Same list, same priority order, same sanitizer as [`todo_snapshot_body`] —
202 /// only the framing and the bounds differ. Nothing here derives new work: every
203 /// row corresponds to an item that exists in the snapshot it was built from.
204 #[derive(Debug, Clone, PartialEq, Eq)]
205 pub struct TodoCardProjection {
206 /// Bounded progress, e.g. `To-do 1/4 · 25% settled`.
207 pub header: String,
208 /// Item rows in document order, e.g. `[~] #2 Write the renderer`.
209 pub items: Vec<String>,
210 /// Items that exist in the snapshot but did not fit the card bound.
211 pub omitted: usize,
212 }
213
214 /// Project one agent's To-do snapshot onto its card, or `None` when that agent
215 /// has no work to show.
216 ///
217 /// An empty list returns `None` rather than a placeholder row — the same rule
218 /// [`todo_snapshot_body`] follows. A card that has never received a snapshot
219 /// and a card whose agent reported an empty list both render nothing, because
220 /// neither one has a task to name.
221 #[must_use]
222 pub fn card_todo_projection(snapshot: &TodoListSnapshot) -> Option<TodoCardProjection> {
223 if snapshot.items.is_empty() {
224 return None;
225 }
226
227 let total = snapshot.items.len();
228 let settled = snapshot
229 .items
230 .iter()
231 .filter(|item| item.status.is_settled())
232 .count();
233 let header = format!(
234 "To-do {settled}/{total} · {}% settled",
235 snapshot.completion_pct
236 );
237
238 let mut selected: Vec<usize> = priority_order(snapshot)
239 .into_iter()
240 .take(MAX_CARD_ITEM_LINES)
241 .collect();
242 selected.sort_unstable();
243 let items: Vec<String> = selected
244 .iter()
245 .map(|idx| card_item_line(&snapshot.items[*idx]))
246 .collect();
247
248 Some(TodoCardProjection {
249 omitted: total - items.len(),
250 header,
251 items,
252 })
253 }
254
255 fn card_item_line(item: &TodoItem) -> String {
256 format!(
257 "{} #{} {}",
258 status_marker(item.status),
259 item.id,
260 sanitize_to(&item.content, MAX_CARD_ITEM_CONTENT_CHARS)
261 )
262 }
263
264 /// Row appended when the card bound elided items.
265 #[must_use]
266 pub fn card_omission_line(count: usize) -> String {
267 format!("{OMISSION_MARKER} +{count} more")
268 }
269
270 /// Heading the fork-state block uses for its To-do section.
271 pub const FORK_TODO_SECTION_HEADING: &str = "### To-do";
272
273 /// Render the To-do section of a `<codewhale:fork_state>` block.
274 ///
275 /// This is the one place a To-do snapshot is handed to a model that did not
276 /// produce it, and it happens exactly once — when a sub-agent is forked, as
277 /// part of the context block stored in that child's own history. It is not
278 /// refreshed, re-sent, or appended to later requests.
279 #[must_use]
280 pub fn fork_state_todo_section(body: &str) -> String {
281 format!("{FORK_TODO_SECTION_HEADING}\n\n{body}\n")
282 }
283
284 /// Item indexes in render priority: the active (in-progress) item first, then
285 /// document order. Shared by every bounded projection so no two surfaces can
286 /// disagree about which item matters most.
287 fn priority_order(snapshot: &TodoListSnapshot) -> Vec<usize> {
288 let active = active_index(snapshot);
289 let mut priority: Vec<usize> = Vec::with_capacity(snapshot.items.len());
290 if let Some(active) = active {
291 priority.push(active);
292 }
293 priority.extend((0..snapshot.items.len()).filter(|idx| Some(*idx) != active));
294 priority
295 }
296
297 fn active_index(snapshot: &TodoListSnapshot) -> Option<usize> {
298 snapshot
299 .in_progress_id
300 .and_then(|id| snapshot.items.iter().position(|item| item.id == id))
301 .or_else(|| {
302 snapshot
303 .items
304 .iter()
305 .position(|item| item.status == TodoStatus::InProgress)
306 })
307 }
308
309 fn status_marker(status: TodoStatus) -> &'static str {
310 match status {
311 TodoStatus::Pending => "[ ]",
312 TodoStatus::InProgress => "[~]",
313 TodoStatus::Completed => "[x]",
314 TodoStatus::Cancelled => "[-]",
315 }
316 }
317
318 fn item_line(item: &TodoItem) -> String {
319 // IDs stay visible: `work_update` addresses later transitions by stable
320 // item identity, so a body without IDs is not actionable.
321 format!(
322 "- {} #{} {}",
323 status_marker(item.status),
324 item.id,
325 sanitize(&item.content)
326 )
327 }
328
329 fn omission_line(count: usize) -> String {
330 format!("- {OMISSION_MARKER} +{count} more To-do items omitted")
331 }
332
333 fn sanitize(content: &str) -> String {
334 sanitize_to(content, MAX_ITEM_CONTENT_CHARS)
335 }
336
337 fn sanitize_to(content: &str, max_chars: usize) -> String {
338 let flattened: String = content
339 .chars()
340 .map(|ch| if ch.is_control() { ' ' } else { ch })
341 .collect();
342 let escaped = escape_wrapper(&flattened);
343 truncate_chars(escaped.trim(), max_chars)
344 }
345
346 /// Neutralize any closing tag in the `codewhale:` namespace so item content
347 /// cannot terminate a wrapper early and smuggle instructions past it.
348 fn escape_wrapper(content: &str) -> String {
349 if !content.to_ascii_lowercase().contains(CLOSE_PREFIX) {
350 return content.to_string();
351 }
352
353 let lower = content.to_ascii_lowercase();
354 let mut out = String::with_capacity(content.len() + 8);
355 let mut cursor = 0usize;
356 while let Some(found) = lower[cursor..].find(CLOSE_PREFIX) {
357 let at = cursor + found;
358 out.push_str(&content[cursor..at]);
359 out.push_str(ESCAPED_CLOSE_PREFIX);
360 cursor = at + CLOSE_PREFIX.len();
361 }
362 out.push_str(&content[cursor..]);
363 out
364 }
365
366 /// Truncate on `char` boundaries, marking the omission. Never splits a
367 /// multi-byte scalar.
368 fn truncate_chars(text: &str, max_chars: usize) -> String {
369 if text.chars().count() <= max_chars {
370 return text.to_string();
371 }
372 let keep = max_chars.saturating_sub(1);
373 let mut out: String = text.chars().take(keep).collect();
374 out.push(OMISSION_MARKER);
375 out
376 }
377
378 #[cfg(test)]
379 mod tests;
380
380 lines RUST