返回 CodeWhale
views.rs
根目录 / crates / tui / src / tui / work_surface / views.rs
1 //! Row builders for the dock views that are not work rows: files, notepad,
2 //! context, git, and price. Each returns ordinary [`WorkRow`]s so the one
3 //! row/hitbox pipeline in `render/` paints, selects, and clicks them exactly
4 //! like a to-do or a sub-agent — a view is a subset of one row grammar, not
5 //! a second widget system.
6
7 use crate::agent_roster::format_tokens;
8 use crate::tui::app::{App, SidebarRowAction};
9
10 use super::model::{RailPanel, WorkRow, WorkRowId, WorkTone};
11
12 /// Views whose tab always shows once the dock is up: the fact views have
13 /// something to say in every session, so they are always one click away.
14 pub(super) const fn view_always_has_content(panel: RailPanel) -> bool {
15 matches!(
16 panel,
17 RailPanel::Context | RailPanel::Git | RailPanel::Price
18 )
19 }
20
21 /// Files this session edited or read, even before TASKS has been projected.
22 pub(super) fn files_touched_count(app: &mut App) -> usize {
23 app.work_surface.file_activity = super::model::settled_file_activity(app);
24 let activity = &app.work_surface.file_activity;
25 edited_files(activity).len() + activity.read.len()
26 }
27
28 pub(super) fn notepad_has_text(app: &App) -> bool {
29 !app.workspace_notes.is_empty()
30 }
31
32 /// Each edited file once, newest receipt last. Direct writes without a
33 /// mutation receipt have no diff to inspect.
34 fn edited_files(
35 activity: &super::model::SettledFileActivity,
36 ) -> Vec<(String, Option<&crate::tui::history::FileMutationReceipt>)> {
37 let mut files = Vec::new();
38 for path in &activity.write {
39 if !files.iter().any(|(existing, _)| existing == path) {
40 files.push((path.clone(), None));
41 }
42 }
43 for receipt in &activity.mutations {
44 for file in &receipt.files {
45 files.retain(|(path, _)| path != &file.path);
46 files.push((file.path.clone(), Some(receipt)));
47 }
48 }
49 files
50 }
51
52 /// The FILES view: what this session changed (with each change's size and
53 /// its evidence one Enter away), then what it read (#6565).
54 pub(super) fn files_rows(app: &mut App) -> Vec<WorkRow> {
55 app.work_surface.file_activity = super::model::settled_file_activity(app);
56 let activity = app.work_surface.file_activity.clone();
57 let mut out = Vec::new();
58 let edited = edited_files(&activity);
59 if !edited.is_empty() {
60 out.push(heading("files:edited", format!("Edited {}", edited.len())));
61 for (path, receipt) in &edited {
62 let Some(receipt) = receipt else {
63 out.push(WorkRow {
64 mark: "✎",
65 tone: WorkTone::Success,
66 ..note_row(&format!("files:edit:{path}"), path)
67 });
68 continue;
69 };
70 let across = if receipt.files.len() > 1 {
71 format!(" across {} files", receipt.files.len())
72 } else {
73 String::new()
74 };
75 out.push(WorkRow {
76 id: WorkRowId(format!("files:edit:{path}")),
77 mark: "✎",
78 label: path.clone(),
79 detail: format!("+{} −{}{across}", receipt.added, receipt.deleted),
80 tone: WorkTone::Success,
81 selectable: true,
82 primary_action: Some(SidebarRowAction::InspectWork {
83 title: format!("File · {path}"),
84 body: super::model::settled_mutation_body(
85 std::slice::from_ref(*receipt),
86 activity.inline_diff_mode,
87 ),
88 stop_action: None,
89 }),
90 agent: None,
91 });
92 }
93 }
94 if !activity.read.is_empty() {
95 out.push(heading(
96 "files:read",
97 format!("Read {}", activity.read.len()),
98 ));
99 for path in &activity.read {
100 out.push(WorkRow {
101 id: WorkRowId(format!("files:read:{path}")),
102 mark: "·",
103 label: path.clone(),
104 detail: String::new(),
105 tone: WorkTone::Muted,
106 selectable: false,
107 primary_action: None,
108 agent: None,
109 });
110 }
111 }
112 app.work_surface.latest_rows = out.clone();
113 out
114 }
115
116 /// The NOTES view: the workspace notes `/note` keeps, one row each; Enter
117 /// shows the whole note.
118 pub(super) fn notepad_rows(app: &mut App) -> Vec<WorkRow> {
119 let out = app
120 .workspace_notes
121 .iter()
122 .enumerate()
123 .map(|(index, note)| {
124 let number = index + 1;
125 let first_line = note.lines().map(str::trim).find(|line| !line.is_empty());
126 WorkRow {
127 id: WorkRowId(format!("notes:{number}")),
128 mark: "▪",
129 label: crate::agent_roster::one_line(first_line.unwrap_or_default()),
130 detail: format!("/note show {number}"),
131 tone: WorkTone::Live,
132 selectable: true,
133 primary_action: Some(SidebarRowAction::Command(format!("/note show {number}"))),
134 agent: None,
135 }
136 })
137 .collect::<Vec<_>>();
138 app.work_surface.latest_rows = out.clone();
139 out
140 }
141
142 fn heading(id: &str, label: String) -> WorkRow {
143 WorkRow {
144 id: WorkRowId(id.to_string()),
145 mark: "▾",
146 label,
147 detail: String::new(),
148 tone: WorkTone::Heading,
149 selectable: false,
150 primary_action: None,
151 agent: None,
152 }
153 }
154
155 /// The context view: the budget, not a fact list. Used/limit and the
156 /// compaction threshold from the same snapshot the footer meter reads, a
157 /// gauge, then the breakdown the accounting can already give per frame —
158 /// system prompt, tool schemas, conversation, tool output, files read — and
159 /// the one action that exists as a command, `/compact`.
160 pub(super) fn context_rows(app: &mut App) -> Vec<WorkRow> {
161 let Some((used, max, percent)) = crate::tui::ui::context_usage_snapshot(app) else {
162 return Vec::new();
163 };
164 let used = u64::try_from(used).unwrap_or(0);
165 let threshold = app.auto_compact_threshold_percent.round().clamp(0.0, 100.0) as u8;
166 let fact = |id: &str, label: String, detail: &str| WorkRow {
167 id: WorkRowId(format!("context:{id}")),
168 mark: "·",
169 label,
170 detail: detail.to_string(),
171 tone: WorkTone::Muted,
172 selectable: false,
173 primary_action: None,
174 agent: None,
175 };
176 let mut out = vec![WorkRow {
177 id: WorkRowId("context:budget".to_string()),
178 mark: "◔",
179 label: format!(
180 "{} of {} · {}% · compacts at {threshold}%",
181 format_tokens(used),
182 format_tokens(u64::from(max)),
183 percent.round() as u8
184 ),
185 detail: "/context for the full source map".to_string(),
186 tone: if percent >= f64::from(threshold) {
187 WorkTone::Attention
188 } else {
189 WorkTone::Live
190 },
191 selectable: true,
192 primary_action: Some(SidebarRowAction::Command("/context".to_string())),
193 agent: None,
194 }];
195 out.push(fact("gauge", gauge(percent, 24), ""));
196
197 // Breakdown from what is already counted per frame: the system prompt
198 // estimate the footer meter uses and the per-message token cache. No
199 // text is re-scanned here; a message is tool output when every block in
200 // it is a tool result.
201 let system_tokens =
202 crate::compaction::estimate_input_tokens_conservative(&[], app.system_prompt.as_ref());
203 let tools = app
204 .session
205 .last_tool_catalog
206 .as_ref()
207 .map(|catalog| catalog.len())
208 .unwrap_or(0);
209 let (conversation, tool_output, messages) = message_split(app);
210 out.push(fact(
211 "system",
212 format!(
213 "system + tools · {} · {tools} tools",
214 format_tokens(system_tokens as u64)
215 ),
216 "",
217 ));
218 out.push(fact(
219 "conversation",
220 format!(
221 "conversation · {messages} · {}",
222 format_tokens(conversation)
223 ),
224 "",
225 ));
226 out.push(fact(
227 "tool-output",
228 format!("tool output · {}", format_tokens(tool_output)),
229 "",
230 ));
231 let read = super::model::settled_file_activity(app).read;
232 if !read.is_empty() {
233 out.push(fact(
234 "files",
235 format!("files read · {}", read.len()),
236 &read.join(", "),
237 ));
238 }
239 out.push(WorkRow {
240 id: WorkRowId("context:compact".to_string()),
241 mark: "▸",
242 label: "compact now".to_string(),
243 detail: "/compact".to_string(),
244 tone: WorkTone::Live,
245 selectable: true,
246 primary_action: Some(SidebarRowAction::Command("/compact".to_string())),
247 agent: None,
248 });
249 app.work_surface.latest_rows = out.clone();
250 out
251 }
252
253 /// `████████░░░░░░░░` — `width` cells, filled to `percent`.
254 fn gauge(percent: f64, width: usize) -> String {
255 let filled = ((percent / 100.0) * width as f64)
256 .round()
257 .clamp(0.0, width as f64) as usize;
258 let mut bar = String::with_capacity(width * 3);
259 for _ in 0..filled {
260 bar.push('█');
261 }
262 for _ in filled..width {
263 bar.push('░');
264 }
265 bar
266 }
267
268 /// `(conversation tokens, tool-output tokens, message count)` from the
269 /// per-message estimate cache the meter already maintains. Messages the
270 /// cache has not seen yet count as zero rather than being re-estimated on
271 /// the render path.
272 fn message_split(app: &App) -> (u64, u64, usize) {
273 let cache = app.context_token_cache.borrow();
274 let mut conversation = 0u64;
275 let mut tool_output = 0u64;
276 for (index, message) in app.api_messages.iter().enumerate() {
277 let tokens = cache
278 .message_tokens
279 .get(index)
280 .map(|tokens| (*tokens as u64).saturating_mul(3).div_ceil(2))
281 .unwrap_or(0);
282 let all_tool_results = !message.content.is_empty()
283 && message.content.iter().all(|block| {
284 matches!(
285 block,
286 codewhale_models::ContentBlock::ToolResult { .. }
287 | codewhale_models::ContentBlock::ToolSearchToolResult { .. }
288 | codewhale_models::ContentBlock::CodeExecutionToolResult { .. }
289 )
290 });
291 if all_tool_results {
292 tool_output = tool_output.saturating_add(tokens);
293 } else {
294 conversation = conversation.saturating_add(tokens);
295 }
296 }
297 (conversation, tool_output, app.api_messages.len())
298 }
299
300 /// The GIT view, from the cached repository probe (never git on the render
301 /// path): the branch and where it stands against its upstream, the changes
302 /// with their paths one Enter away, linked worktrees, and the last commits.
303 /// Before the first probe it says so; "not a git repository" only when the
304 /// probe found none, and a git failure is named (#6565).
305 pub(super) fn git_rows(app: &mut App) -> Vec<WorkRow> {
306 let out = git_rows_for(&crate::tui::git_status::cached_status(), &app.workspace);
307 app.work_surface.latest_rows = out.clone();
308 out
309 }
310
311 fn git_rows_for(
312 snap: &crate::tui::git_status::GitStatusSnapshot,
313 workspace: &std::path::Path,
314 ) -> Vec<WorkRow> {
315 let probed_here = snap.probed_workspace.as_deref() == Some(workspace);
316 if !probed_here || snap.fetched_at.is_none() {
317 vec![note_row("git:state", "reading git status…")]
318 } else if let Some(error) = snap.error.as_deref() {
319 vec![note_row("git:state", error)]
320 } else {
321 git_state_rows(snap)
322 }
323 }
324
325 fn note_row(id: &str, text: &str) -> WorkRow {
326 WorkRow {
327 id: WorkRowId(id.to_string()),
328 mark: "·",
329 label: text.to_string(),
330 detail: String::new(),
331 tone: WorkTone::Muted,
332 selectable: false,
333 primary_action: None,
334 agent: None,
335 }
336 }
337
338 fn git_state_rows(snap: &crate::tui::git_status::GitStatusSnapshot) -> Vec<WorkRow> {
339 let mut out = Vec::new();
340 let branch = match (snap.branch.as_deref(), snap.detached) {
341 (Some(id), true) => format!("detached at {id}"),
342 (Some(branch), false) => branch.to_string(),
343 (None, _) => "no commits yet".to_string(),
344 };
345 let upstream = if snap.detached {
346 String::new()
347 } else if !snap.has_upstream {
348 " · no upstream".to_string()
349 } else if snap.ahead == 0 && snap.behind == 0 {
350 " · up to date".to_string()
351 } else {
352 format!(" · ↑{} ↓{}", snap.ahead, snap.behind)
353 };
354 let location = snap
355 .remote_slug
356 .clone()
357 .or_else(|| snap.repository_name.clone())
358 .unwrap_or_default();
359 out.push(WorkRow {
360 id: WorkRowId("git:branch".to_string()),
361 mark: "⎇",
362 label: format!("{branch}{upstream}"),
363 detail: location,
364 tone: WorkTone::Live,
365 selectable: true,
366 primary_action: Some(SidebarRowAction::Command("/diff".to_string())),
367 agent: None,
368 });
369 let changes = snap.changes.summary();
370 let body = if snap.changed_paths.is_empty() {
371 "Working tree clean.".to_string()
372 } else {
373 let listed = snap
374 .changed_paths
375 .iter()
376 .map(|path| format!("{} {}", path.code, path.path))
377 .collect::<Vec<_>>()
378 .join("\n");
379 let tail = if snap.changed_path_count > snap.changed_paths.len() {
380 "\n… and more; /diff for everything".to_string()
381 } else {
382 String::new()
383 };
384 format!("{listed}{tail}")
385 };
386 out.push(WorkRow {
387 id: WorkRowId("git:changes".to_string()),
388 mark: if snap.changes.conflicts > 0 {
389 "!"
390 } else {
391 "±"
392 },
393 label: changes,
394 detail: String::new(),
395 tone: if snap.changes.conflicts > 0 {
396 WorkTone::Attention
397 } else if snap.dirty {
398 WorkTone::Live
399 } else {
400 WorkTone::Muted
401 },
402 selectable: true,
403 primary_action: Some(SidebarRowAction::InspectWork {
404 title: "Git · changes".to_string(),
405 body,
406 stop_action: None,
407 }),
408 agent: None,
409 });
410 let linked: Vec<_> = snap
411 .worktrees
412 .iter()
413 .filter(|worktree| !worktree.bare && Some(&worktree.path) != snap.root.as_ref())
414 .collect();
415 if !linked.is_empty() {
416 out.push(heading(
417 "git:worktrees",
418 format!("Worktrees {}", linked.len()),
419 ));
420 for worktree in linked {
421 out.push(WorkRow {
422 id: WorkRowId(format!("git:worktree:{}", worktree.path.display())),
423 mark: "·",
424 label: worktree
425 .branch
426 .clone()
427 .unwrap_or_else(|| "detached".to_string()),
428 detail: format!(
429 "{}{}",
430 worktree.path.display(),
431 if worktree.locked { " · locked" } else { "" }
432 ),
433 tone: WorkTone::Muted,
434 selectable: false,
435 primary_action: None,
436 agent: None,
437 });
438 }
439 }
440 if !snap.recent_commits.is_empty() {
441 out.push(heading("git:commits", "Recent commits".to_string()));
442 for commit in &snap.recent_commits {
443 out.push(WorkRow {
444 id: WorkRowId(format!("git:commit:{}", commit.hash)),
445 mark: "·",
446 label: format!("{} {}", commit.hash, commit.subject),
447 detail: commit.when.clone(),
448 tone: WorkTone::Muted,
449 selectable: false,
450 primary_action: None,
451 agent: None,
452 });
453 }
454 }
455 out
456 }
457
458 /// The price view. One number everywhere: the session total is the same
459 /// `displayed_session_cost_for_currency` the footer chip prints, through the
460 /// same `App::format_cost_amount`; per-agent rows come from the roster's
461 /// usage receipts (`cost_microusd`, absent = no receipt, never `0`).
462 pub(super) fn price_rows(app: &mut App) -> Vec<WorkRow> {
463 let mut out = Vec::new();
464 let session = app.session_cost_label();
465 out.push(WorkRow {
466 id: WorkRowId("price:session".to_string()),
467 mark: "$",
468 label: format!("session · {session}"),
469 detail: "/cost for the full ledger".to_string(),
470 tone: WorkTone::Live,
471 selectable: true,
472 primary_action: Some(SidebarRowAction::Command("/cost".to_string())),
473 agent: None,
474 });
475 let roster = app.current_agent_roster().to_vec();
476 let priced: Vec<_> = roster
477 .iter()
478 .filter(|row| row.cost_microusd.is_some())
479 .collect();
480 if !priced.is_empty() {
481 let total = priced
482 .iter()
483 .filter_map(|row| row.cost_microusd)
484 .fold(0u64, u64::saturating_add);
485 out.push(WorkRow {
486 id: WorkRowId("price:agents".to_string()),
487 mark: "·",
488 label: format!(
489 "agents · {}",
490 app.format_cost_amount(total as f64 / 1_000_000.0)
491 ),
492 detail: format!("{} of {} agents priced", priced.len(), roster.len()),
493 tone: WorkTone::Muted,
494 selectable: false,
495 primary_action: None,
496 agent: None,
497 });
498 for row in priced {
499 let cost = row.cost_microusd.unwrap_or(0) as f64 / 1_000_000.0;
500 out.push(WorkRow {
501 id: WorkRowId(format!("price:agent:{}", row.worker_id)),
502 mark: row.state.glyph(),
503 label: format!(" {} · {}", row.display_name, app.format_cost_amount(cost)),
504 detail: row.model.clone(),
505 tone: WorkTone::Muted,
506 selectable: true,
507 primary_action: Some(SidebarRowAction::OpenAgentDetail {
508 agent_id: row.worker_id.clone(),
509 }),
510 agent: None,
511 });
512 }
513 }
514 let rates = crate::tui::session_metrics::cache_rates(app);
515 if let Some(rates) = rates.labelled("parent", "agents", "combined") {
516 out.push(WorkRow {
517 id: WorkRowId("price:cache".to_string()),
518 mark: "·",
519 label: format!("cache hit · {rates}"),
520 detail: "/cache for per-turn cache telemetry".to_string(),
521 tone: WorkTone::Muted,
522 selectable: true,
523 primary_action: Some(SidebarRowAction::Command("/cache".to_string())),
524 agent: None,
525 });
526 }
527 if let Some(rate) = crate::pricing::model_rate_label(
528 app.api_provider,
529 &app.model,
530 app.cost_display_currency(app.cost_currency),
531 ) {
532 out.push(WorkRow {
533 id: WorkRowId("price:rate".to_string()),
534 mark: "·",
535 label: format!("{} · {rate}", app.model),
536 detail: "per million tokens, in / out".to_string(),
537 tone: WorkTone::Muted,
538 selectable: false,
539 primary_action: None,
540 agent: None,
541 });
542 }
543 app.work_surface.latest_rows = out.clone();
544 out
545 }
546
547 #[cfg(test)]
548 mod tests {
549 use super::*;
550 use crate::tui::git_status::{
551 ChangeCounts, ChangedPath, GitStatusSnapshot, RecentCommit, WorktreeEntry,
552 };
553 use std::path::{Path, PathBuf};
554 use std::time::Instant;
555
556 fn app() -> App {
557 crate::tui::app::App::new(
558 crate::test_support::test_tui_options(PathBuf::from("/repo")),
559 &crate::config::Config::default(),
560 )
561 }
562
563 #[test]
564 fn the_git_view_names_its_state_honestly() {
565 let workspace = Path::new("/repo");
566 // Before the first probe for this workspace.
567 let rows = git_rows_for(&GitStatusSnapshot::default(), workspace);
568 assert_eq!(rows[0].label, "reading git status…");
569 let other = GitStatusSnapshot {
570 probed_workspace: Some(PathBuf::from("/elsewhere")),
571 fetched_at: Some(Instant::now()),
572 ..GitStatusSnapshot::default()
573 };
574 assert_eq!(
575 git_rows_for(&other, workspace)[0].label,
576 "reading git status…"
577 );
578 // Only a probe that found no repository says so.
579 let outside = GitStatusSnapshot {
580 probed_workspace: Some(workspace.to_path_buf()),
581 fetched_at: Some(Instant::now()),
582 error: Some("not a git repository".to_string()),
583 ..GitStatusSnapshot::default()
584 };
585 assert_eq!(
586 git_rows_for(&outside, workspace)[0].label,
587 "not a git repository"
588 );
589 let broken = GitStatusSnapshot {
590 error: Some("git unavailable: No such file".to_string()),
591 ..outside
592 };
593 assert_eq!(
594 git_rows_for(&broken, workspace)[0].label,
595 "git unavailable: No such file"
596 );
597 }
598
599 #[test]
600 fn the_git_view_shows_branch_changes_worktrees_and_commits() {
601 let workspace = Path::new("/repo");
602 let snap = GitStatusSnapshot {
603 probed_workspace: Some(workspace.to_path_buf()),
604 fetched_at: Some(Instant::now()),
605 root: Some(PathBuf::from("/repo")),
606 repository_name: Some("repo".to_string()),
607 remote_slug: Some("owner/repo".to_string()),
608 branch: Some("main".to_string()),
609 has_upstream: true,
610 ahead: 2,
611 behind: 1,
612 dirty: true,
613 changes: ChangeCounts {
614 staged: 1,
615 modified: 1,
616 untracked: 0,
617 conflicts: 0,
618 },
619 changed_path_count: 2,
620 changed_paths: vec![
621 ChangedPath {
622 code: "M ".to_string(),
623 path: "src/lib.rs".to_string(),
624 },
625 ChangedPath {
626 code: " M".to_string(),
627 path: "README.md".to_string(),
628 },
629 ],
630 worktrees: vec![
631 WorktreeEntry {
632 path: PathBuf::from("/repo"),
633 branch: Some("main".to_string()),
634 bare: false,
635 locked: false,
636 },
637 WorktreeEntry {
638 path: PathBuf::from("/repo/.cw-worktrees/agent-a"),
639 branch: Some("agent-a".to_string()),
640 bare: false,
641 locked: true,
642 },
643 ],
644 recent_commits: vec![RecentCommit {
645 hash: "abc1234".to_string(),
646 subject: "fix: a thing".to_string(),
647 when: "3 hours ago".to_string(),
648 }],
649 ..GitStatusSnapshot::default()
650 };
651 let rows = git_rows_for(&snap, workspace);
652 let labels = rows
653 .iter()
654 .map(|row| row.label.as_str())
655 .collect::<Vec<_>>();
656 assert_eq!(
657 labels,
658 [
659 "main · ↑2 ↓1",
660 "1 staged, 1 modified",
661 "Worktrees 1",
662 "agent-a",
663 "Recent commits",
664 "abc1234 fix: a thing",
665 ]
666 );
667 assert_eq!(rows[0].detail, "owner/repo");
668 let Some(SidebarRowAction::InspectWork { body, .. }) = rows[1].primary_action.as_ref()
669 else {
670 panic!("changes row opens its paths");
671 };
672 assert_eq!(body, "M src/lib.rs\n M README.md");
673 assert!(
674 rows[3].detail.ends_with("agent-a · locked"),
675 "{}",
676 rows[3].detail
677 );
678
679 let no_upstream = GitStatusSnapshot {
680 has_upstream: false,
681 ..snap.clone()
682 };
683 assert_eq!(
684 git_rows_for(&no_upstream, workspace)[0].label,
685 "main · no upstream"
686 );
687 let detached = GitStatusSnapshot {
688 branch: Some("abc1234".to_string()),
689 detached: true,
690 ..snap
691 };
692 assert_eq!(
693 git_rows_for(&detached, workspace)[0].label,
694 "detached at abc1234"
695 );
696 }
697
698 #[test]
699 fn git_more_means_unlisted_paths() {
700 use crate::tui::git_status::{MAX_CHANGED_PATHS, parse_porcelain_v2};
701 for count in [1, MAX_CHANGED_PATHS, MAX_CHANGED_PATHS + 1] {
702 let mut raw = "# branch.head main\0".to_string();
703 for index in 0..count {
704 raw.push_str(&format!(
705 "1 MM N... 100644 100644 100644 aaa bbb file-{index}\0"
706 ));
707 }
708 let status = parse_porcelain_v2(&raw).unwrap();
709 let rows = git_state_rows(&GitStatusSnapshot {
710 changes: status.changes,
711 changed_paths: status.changed_paths,
712 changed_path_count: status.changed_path_count,
713 ..GitStatusSnapshot::default()
714 });
715 let Some(SidebarRowAction::InspectWork { body, .. }) = &rows[1].primary_action else {
716 panic!("changes opens path details");
717 };
718 assert_eq!(
719 body.contains("and more"),
720 count > MAX_CHANGED_PATHS,
721 "{count}"
722 );
723 }
724 }
725
726 fn record_file(app: &mut App, id: &str, action: &str, path: &str, diff: Option<String>) {
727 use crate::tools::spec::ToolResult;
728 use crate::tui::tool_routing::{handle_tool_call_complete, handle_tool_call_started};
729 let input = serde_json::json!({"action": action, "path": path, "content": "new"});
730 handle_tool_call_started(app, id, "File", &input);
731 let mut result = ToolResult::success("ok");
732 if let Some(diff) = diff {
733 result = result.with_metadata(serde_json::json!({"mutation": {
734 "diff": diff, "files": [{"path": path, "outcome": "updated"}],
735 }}));
736 }
737 handle_tool_call_complete(app, id, "File", &Ok(result));
738 app.flush_active_cell();
739 }
740
741 #[test]
742 fn files_and_badge_follow_current_history_without_opening_tasks() {
743 for badge_first in [false, true] {
744 let mut app = app();
745 app.current_session_id = Some("session-a".into());
746 record_file(&mut app, "first", "write", "first.rs", None);
747 if badge_first {
748 assert_eq!(files_touched_count(&mut app), 1);
749 } else {
750 assert!(
751 files_rows(&mut app)
752 .iter()
753 .any(|row| row.label == "first.rs")
754 );
755 }
756 record_file(&mut app, "second", "read", "second.rs", None);
757 if badge_first {
758 assert_eq!(files_touched_count(&mut app), 2);
759 } else {
760 assert!(
761 files_rows(&mut app)
762 .iter()
763 .any(|row| row.label == "second.rs")
764 );
765 }
766 // Keep the old projection, as a session switch can, but replace
767 // the transcript that owns the next session's activity.
768 app.history.clear();
769 app.current_session_id = Some("session-b".into());
770 if badge_first {
771 assert_eq!(files_touched_count(&mut app), 0);
772 assert!(files_rows(&mut app).is_empty());
773 } else {
774 assert!(files_rows(&mut app).is_empty());
775 assert_eq!(files_touched_count(&mut app), 0);
776 }
777 }
778 }
779
780 #[test]
781 fn failed_git_status_reports_the_error_in_the_view_and_composer() {
782 use crate::tui::git_status::{context_line, probe_status};
783 let dir = tempfile::tempdir().unwrap();
784 let workspace = dir.path();
785 let init = std::process::Command::new("git")
786 .args(["init", "--initial-branch=main"])
787 .current_dir(workspace)
788 .output()
789 .unwrap();
790 assert!(init.status.success());
791 let clean = probe_status(workspace);
792 assert!(clean.error.is_none(), "{clean:?}");
793 assert!(context_line(&clean).unwrap().contains("clean"));
794 std::fs::write(workspace.join(".git/index"), b"broken index").unwrap();
795 let failed = probe_status(workspace);
796 assert!(failed.root.is_some(), "root discovery still succeeds");
797 let error = failed
798 .error
799 .as_deref()
800 .expect("status failure must be recorded");
801 assert!(error.contains("git status failed"), "{error}");
802 assert!(error.contains("index"), "{error}");
803 assert_eq!(context_line(&failed).as_deref(), Some(error));
804 let rows = git_rows_for(&failed, workspace);
805 assert_eq!(rows.len(), 1);
806 assert_eq!(rows[0].label, error);
807 assert!(!rows[0].label.contains("clean"));
808 }
809
810 #[test]
811 fn files_include_successful_writes_without_receipts() {
812 use crate::tools::spec::ToolResult;
813 use crate::tui::tool_routing::{handle_tool_call_complete, handle_tool_call_started};
814 let mut app = app();
815 for (id, action, path) in [("write", "write", "new.rs"), ("edit", "edit", "edited.rs")] {
816 let input = serde_json::json!({"action": action, "path": path, "content": "new"});
817 handle_tool_call_started(&mut app, id, "File", &input);
818 handle_tool_call_complete(&mut app, id, "File", &Ok(ToolResult::success("ok")));
819 app.flush_active_cell();
820 }
821 assert_eq!(files_touched_count(&mut app), 2);
822 let rows = files_rows(&mut app);
823 for path in ["new.rs", "edited.rs"] {
824 let row = rows
825 .iter()
826 .find(|row| row.label == path)
827 .expect("write row");
828 assert!(!row.selectable);
829 assert!(row.primary_action.is_none());
830 assert!(row.detail.is_empty());
831 }
832 // A receipt for an already-listed write adds its diff, not a second row.
833 record_file(
834 &mut app,
835 "receipt",
836 "edit",
837 "edited.rs",
838 Some("--- a/edited.rs\n+++ b/edited.rs\n@@ -0,0 +1 @@\n+new\n".into()),
839 );
840 let rows = files_rows(&mut app);
841 assert_eq!(files_touched_count(&mut app), 2);
842 assert_eq!(
843 rows.iter().filter(|row| row.label == "edited.rs").count(),
844 1
845 );
846 assert!(
847 rows.iter()
848 .find(|row| row.label == "edited.rs")
849 .unwrap()
850 .selectable
851 );
852 }
853
854 #[test]
855 fn the_files_view_lists_edits_with_their_size_then_reads() {
856 let mut app = app();
857 assert!(files_rows(&mut app).is_empty());
858 assert_eq!(files_touched_count(&mut app), 0);
859 let diff = format!(
860 "--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -1,3 +1,12 @@\n{}{}",
861 "-old\n".repeat(3),
862 "+new\n".repeat(12),
863 );
864 record_file(&mut app, "edit", "edit", "src/lib.rs", Some(diff));
865 record_file(&mut app, "read", "read", "Cargo.toml", None);
866 let rows = files_rows(&mut app);
867 let labels = rows
868 .iter()
869 .map(|row| row.label.as_str())
870 .collect::<Vec<_>>();
871 assert_eq!(labels, ["Edited 1", "src/lib.rs", "Read 1", "Cargo.toml"]);
872 assert_eq!(rows[1].detail, "+12 −3");
873 assert!(rows[1].selectable);
874 assert_eq!(files_touched_count(&mut app), 2);
875 }
876
877 #[test]
878 fn the_notes_view_lists_each_note_and_opens_it() {
879 let mut app = app();
880 assert!(!notepad_has_text(&app));
881 assert!(notepad_rows(&mut app).is_empty());
882 app.workspace_notes = vec![
883 "Ship the docs fix\nwith the link audit".to_string(),
884 "Ask about the flaky test".to_string(),
885 ];
886 assert!(notepad_has_text(&app));
887 let rows = notepad_rows(&mut app);
888 assert_eq!(rows[0].label, "Ship the docs fix");
889 assert!(matches!(
890 rows[1].primary_action.as_ref(),
891 Some(SidebarRowAction::Command(command)) if command == "/note show 2"
892 ));
893 }
894 }
895
895 lines RUST