| 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 |