返回 CodeWhale
agent_details.rs
根目录 / crates / tui / src / tui / agent_details.rs
1 //! Safe, bounded Agent Details projection (#2889).
2 //!
3 //! The default route intentionally does not expose the child transcript. Exact
4 //! evidence remains behind an explicit artifact-first action.
5
6 use std::path::{Component, Path};
7
8 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
9 use ratatui::{buffer::Buffer, layout::Rect};
10
11 use crate::tools::subagent::{SubAgentResult, SubAgentStatus, localized_whale_display_names};
12 use crate::tui::app::{
13 AgentCurrentActivityStatus, AgentProgressMeta, App, bound_agent_activity_text,
14 };
15 use crate::tui::pager::PagerView;
16 use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
17
18 pub(crate) struct AgentDetailsProjection {
19 pub(crate) title: String,
20 pub(crate) body: String,
21 pub(crate) transcript_available: bool,
22 }
23
24 /// Pager-backed details view with a distinct close receipt and an explicit
25 /// exact-transcript action.
26 pub(crate) struct AgentDetailsView {
27 pager: PagerView,
28 agent_id: String,
29 transcript_available: bool,
30 }
31
32 impl AgentDetailsView {
33 fn new(projection: AgentDetailsProjection, agent_id: impl Into<String>, width: u16) -> Self {
34 let agent_id = agent_id.into();
35 let pager =
36 PagerView::from_text(projection.title, &projection.body, width.saturating_sub(2))
37 .with_copy_text(projection.body);
38 Self {
39 pager,
40 agent_id,
41 transcript_available: projection.transcript_available,
42 }
43 }
44
45 #[cfg(test)]
46 fn body_text(&self) -> String {
47 self.pager.body_text()
48 }
49
50 #[cfg(test)]
51 fn title(&self) -> &str {
52 self.pager.title()
53 }
54 }
55
56 impl ModalView for AgentDetailsView {
57 fn kind(&self) -> ModalKind {
58 ModalKind::Pager
59 }
60
61 fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
62 if matches!(key.code, KeyCode::Char('v' | 'V'))
63 && key.modifiers.contains(KeyModifiers::ALT)
64 && self.transcript_available
65 {
66 return ViewAction::Emit(ViewEvent::OpenAgentTranscript {
67 agent_id: self.agent_id.clone(),
68 });
69 }
70 if matches!(key.code, KeyCode::Esc | KeyCode::Left)
71 || (key.code == KeyCode::Char('q') && key.modifiers.is_empty())
72 {
73 return ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed {
74 agent_id: self.agent_id.clone(),
75 });
76 }
77 self.pager.handle_key(key)
78 }
79
80 fn handle_paste(&mut self, text: &str) -> bool {
81 self.pager.handle_paste(text)
82 }
83
84 fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
85 self.pager.handle_mouse(mouse)
86 }
87
88 fn render(&self, area: Rect, buf: &mut Buffer) {
89 self.pager.render(area, buf);
90 }
91
92 fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
93 self
94 }
95 }
96
97 pub(crate) fn open_agent_details(app: &mut App, agent_id: &str) -> bool {
98 let Some(projection) = project_agent_details(app, agent_id) else {
99 return false;
100 };
101 let width = app
102 .viewport
103 .last_transcript_area
104 .map(|area| area.width)
105 .unwrap_or(80);
106 app.view_stack
107 .push(AgentDetailsView::new(projection, agent_id, width));
108 true
109 }
110
111 pub(crate) fn safe_agent_display_name(app: &App, agent_id: &str) -> String {
112 let generated = localized_whale_display_names(
113 app.subagent_cache
114 .iter()
115 .map(|agent| (agent.agent_id.as_str(), agent.nickname.as_deref())),
116 app.ui_locale.tag(),
117 );
118 generated
119 .get(agent_id)
120 .cloned()
121 .or_else(|| app.agent_label_map.get(agent_id).cloned())
122 .and_then(|name| safe_child_value(app, &name))
123 .unwrap_or_else(|| "Agent".to_string())
124 }
125
126 pub(crate) fn project_agent_details(app: &App, agent_id: &str) -> Option<AgentDetailsProjection> {
127 // The Agents sidebar is the primary worker surface. Consume its exact row
128 // projection so status precedence, model, elapsed time, and steps cannot
129 // diverge between the row and the popup opened from it.
130 let surface_row = crate::tui::sidebar::sidebar_agent_rows(app)
131 .into_iter()
132 .find(|row| row.id == agent_id)?;
133 let agent = app
134 .subagent_cache
135 .iter()
136 .find(|agent| agent.agent_id == agent_id);
137 let meta = app.agent_progress_meta.get(agent_id);
138
139 let display_name = safe_child_value(app, &surface_row.name).unwrap_or_else(|| "Agent".into());
140 let mut lines = Vec::new();
141
142 if let Some(agent) = agent {
143 push_safe_line(app, &mut lines, "Assignment", &agent.assignment.objective);
144
145 if let Some(role) = agent.assignment.role.as_deref() {
146 push_safe_line(app, &mut lines, "Role", role);
147 }
148 push_safe_line(app, &mut lines, "Profile", agent.agent_type.as_str());
149 lines.push(format!("Parent: {}", safe_parent_name(app, agent)));
150 } else {
151 lines.push(format!("Parent: {}", safe_parent_from_meta(app, meta)));
152 }
153
154 let mut state = vec![surface_row.status.clone()];
155 if let Some(duration_ms) = surface_row.duration_ms {
156 state.push(format!(
157 "elapsed {}",
158 crate::elapsed::format_elapsed_ms(duration_ms)
159 ));
160 }
161 state.push(format!(
162 "{} {}",
163 surface_row.steps_taken,
164 if surface_row.steps_taken == 1 {
165 "step"
166 } else {
167 "steps"
168 }
169 ));
170 lines.push(format!("State: {}", state.join(" · ")));
171
172 if let Some(meta) = meta
173 && let Some(provider) = meta.resolved_provider.as_deref()
174 {
175 push_safe_line(app, &mut lines, "Provider", provider);
176 }
177 if let Some(model) = surface_row.model.as_deref() {
178 push_safe_line(app, &mut lines, "Model", model);
179 }
180
181 if let Some(agent) = agent {
182 if let Some(workspace) = agent.workspace.as_deref()
183 && let Some(workspace) = safe_workspace(app, workspace)
184 {
185 lines.push(format!("Workspace: {workspace}"));
186 }
187 if let Some(branch) = agent.git_branch.as_deref() {
188 push_safe_line(app, &mut lines, "Branch", branch);
189 }
190 }
191
192 if let Some(activity) = meta.and_then(|meta| meta.current_activity.as_ref())
193 && !matches!(
194 activity.status,
195 AgentCurrentActivityStatus::Done
196 | AgentCurrentActivityStatus::Failed
197 | AgentCurrentActivityStatus::Canceled
198 | AgentCurrentActivityStatus::Interrupted
199 | AgentCurrentActivityStatus::Waiting
200 )
201 {
202 let mut current = vec![activity_status_label(activity.status).to_string()];
203 if let Some(tool) = activity
204 .current_tool
205 .as_deref()
206 .and_then(|tool| safe_child_value(app, tool))
207 {
208 current.push(tool);
209 }
210 if let Some(step) = activity.step {
211 current.push(format!("step {step}"));
212 }
213 if let Some(detail) = activity
214 .detail
215 .as_deref()
216 .and_then(|detail| safe_child_value(app, detail))
217 && !detail.starts_with("started ")
218 && !current.iter().any(|part| part == &detail)
219 {
220 current.push(detail);
221 }
222 lines.push(format!("Current: {}", current.join(" · ")));
223 }
224
225 if let Some(meta) = meta {
226 for action in meta.recent_actions.iter().rev().take(3).rev() {
227 if let Some(tool) = safe_child_value(app, &action.tool) {
228 lines.push(format!(
229 "Recent: {} {tool} · step {}",
230 if action.ok { "✓" } else { "!" },
231 action.step
232 ));
233 }
234 }
235 }
236
237 if let Some(question) = pending_question(agent, meta)
238 && let Some(question) = safe_child_value(app, question)
239 {
240 lines.push(format!("Pending question: {question}"));
241 }
242 if let Some(blocker) = blocker(agent, meta)
243 && let Some(blocker) = safe_child_value(app, blocker)
244 {
245 lines.push(format!("Blocker: {blocker}"));
246 }
247 if let Some(summary) = terminal_summary(agent, meta)
248 && let Some(summary) = safe_child_value(app, summary)
249 {
250 lines.push(format!("Summary: {summary}"));
251 }
252
253 let transcript_available =
254 crate::tui::mouse_ui::agent_transcript_evidence_available(app, agent_id);
255 if transcript_available {
256 // Platform glyph via display_chord (⌥V on macOS, Alt+V elsewhere) —
257 // never the dual "Alt/⌥V" spelling, and cap:verb not a sentence.
258 let chord = crate::tui::shell_key_routing::tool_details_chord();
259 lines.push(format!("Exact evidence: available · {chord}:transcript"));
260 } else {
261 lines.push("Exact evidence: unavailable".to_string());
262 }
263
264 Some(AgentDetailsProjection {
265 title: format!("Agent Details — {display_name}"),
266 body: lines.join("\n"),
267 transcript_available,
268 })
269 }
270
271 fn push_safe_line(app: &App, lines: &mut Vec<String>, label: &str, value: &str) {
272 if let Some(value) = safe_child_value(app, value) {
273 lines.push(format!("{label}: {value}"));
274 }
275 }
276
277 fn safe_child_value(app: &App, value: &str) -> Option<String> {
278 let bounded = bound_agent_activity_text(value);
279 let scrubbed = scrub_raw_agent_ids(app, &bounded);
280 let trimmed = scrubbed.trim();
281 if trimmed.is_empty()
282 || matches!(
283 trimmed.to_ascii_lowercase().as_str(),
284 "none" | "(none)" | "n/a" | "unknown" | "not set" | "not available" | "-"
285 )
286 {
287 None
288 } else {
289 Some(trimmed.to_string())
290 }
291 }
292
293 fn scrub_raw_agent_ids(app: &App, value: &str) -> String {
294 let mut scrubbed = value.to_string();
295 let mut ids: Vec<&str> = app
296 .subagent_cache
297 .iter()
298 .map(|agent| agent.agent_id.as_str())
299 .chain(
300 app.subagent_cache
301 .iter()
302 .filter_map(|agent| agent.parent_run_id.as_deref()),
303 )
304 .chain(app.agent_progress_meta.keys().map(String::as_str))
305 .chain(
306 app.agent_progress_meta
307 .values()
308 .filter_map(|meta| meta.parent_run_id.as_deref()),
309 )
310 .chain(app.agent_progress.keys().map(String::as_str))
311 .chain(app.agent_label_map.keys().map(String::as_str))
312 .collect();
313 ids.sort_unstable_by_key(|id| std::cmp::Reverse(id.len()));
314 ids.dedup();
315 for id in ids {
316 if !id.is_empty() {
317 scrubbed = scrubbed.replace(id, "agent");
318 }
319 }
320
321 let mut output = String::with_capacity(scrubbed.len());
322 let mut token = String::new();
323 let flush = |token: &mut String, output: &mut String| {
324 if token.starts_with("agent_")
325 || token.starts_with("agent-")
326 || token.starts_with("worker:agent_")
327 || token.starts_with("worker:agent-")
328 {
329 output.push_str("agent");
330 } else {
331 output.push_str(token);
332 }
333 token.clear();
334 };
335 for ch in scrubbed.chars() {
336 if ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | ':') {
337 token.push(ch);
338 } else {
339 flush(&mut token, &mut output);
340 output.push(ch);
341 }
342 }
343 flush(&mut token, &mut output);
344 output
345 }
346
347 fn safe_parent_name(app: &App, agent: &SubAgentResult) -> String {
348 match agent.parent_run_id.as_deref() {
349 Some(parent_id)
350 if app
351 .subagent_cache
352 .iter()
353 .any(|candidate| candidate.agent_id == parent_id) =>
354 {
355 safe_agent_display_name(app, parent_id)
356 }
357 Some(_) if agent.spawn_depth > 1 => "parent agent".to_string(),
358 _ => "primary session".to_string(),
359 }
360 }
361
362 fn safe_parent_from_meta(app: &App, meta: Option<&AgentProgressMeta>) -> String {
363 match meta.and_then(|meta| meta.parent_run_id.as_deref()) {
364 Some(parent_id)
365 if app
366 .subagent_cache
367 .iter()
368 .any(|candidate| candidate.agent_id == parent_id) =>
369 {
370 safe_agent_display_name(app, parent_id)
371 }
372 Some(_) => "parent agent".to_string(),
373 None => "primary session".to_string(),
374 }
375 }
376
377 fn activity_status_label(status: AgentCurrentActivityStatus) -> &'static str {
378 match status {
379 AgentCurrentActivityStatus::Queued => "queued",
380 AgentCurrentActivityStatus::Starting => "starting",
381 AgentCurrentActivityStatus::Running => "running",
382 AgentCurrentActivityStatus::ModelWait => "waiting for model",
383 AgentCurrentActivityStatus::RunningTool => "running tool",
384 AgentCurrentActivityStatus::Waiting => "waiting for input",
385 AgentCurrentActivityStatus::Done => "completed",
386 AgentCurrentActivityStatus::Failed => "failed",
387 AgentCurrentActivityStatus::Canceled => "canceled",
388 AgentCurrentActivityStatus::Interrupted => "interrupted",
389 }
390 }
391
392 fn pending_question<'a>(
393 agent: Option<&'a SubAgentResult>,
394 meta: Option<&'a AgentProgressMeta>,
395 ) -> Option<&'a str> {
396 agent
397 .and_then(|agent| agent.needs_input.as_ref())
398 .map(|needs_input| needs_input.question.as_str())
399 .or_else(|| {
400 meta.and_then(|meta| meta.current_activity.as_ref())
401 .filter(|activity| activity.status == AgentCurrentActivityStatus::Waiting)
402 .and_then(|activity| activity.detail.as_deref())
403 })
404 .or_else(|| match agent.map(|agent| &agent.status) {
405 Some(SubAgentStatus::Interrupted(reason)) => Some(reason.as_str()),
406 _ => None,
407 })
408 }
409
410 fn blocker<'a>(
411 agent: Option<&'a SubAgentResult>,
412 meta: Option<&'a AgentProgressMeta>,
413 ) -> Option<&'a str> {
414 match agent.map(|agent| &agent.status) {
415 Some(SubAgentStatus::Failed(error) | SubAgentStatus::Interrupted(error)) => {
416 Some(error.as_str())
417 }
418 Some(SubAgentStatus::BudgetExhausted) => Some("worker budget exhausted"),
419 _ => meta
420 .and_then(|meta| meta.current_activity.as_ref())
421 .filter(|activity| activity.status == AgentCurrentActivityStatus::Failed)
422 .and_then(|activity| activity.detail.as_deref()),
423 }
424 }
425
426 fn terminal_summary<'a>(
427 agent: Option<&'a SubAgentResult>,
428 meta: Option<&'a AgentProgressMeta>,
429 ) -> Option<&'a str> {
430 let agent = agent?;
431 if !matches!(
432 agent.status,
433 SubAgentStatus::Completed | SubAgentStatus::Cancelled
434 ) {
435 return None;
436 }
437 agent.result.as_deref().or_else(|| {
438 meta.and_then(|meta| meta.current_activity.as_ref())
439 .and_then(|activity| activity.detail.as_deref())
440 })
441 }
442
443 fn safe_workspace(app: &App, workspace: &Path) -> Option<String> {
444 if workspace.as_os_str().is_empty() {
445 return None;
446 }
447 if workspace == app.workspace {
448 return Some(".".to_string());
449 }
450 if let Ok(relative) = workspace.strip_prefix(&app.workspace) {
451 let parts: Vec<String> = relative
452 .components()
453 .filter_map(|component| match component {
454 Component::Normal(part) => part.to_str().map(ToString::to_string),
455 _ => None,
456 })
457 .collect();
458 if !parts.is_empty() {
459 return safe_child_value(app, &parts.join("/"));
460 }
461 }
462 workspace
463 .file_name()
464 .and_then(|name| name.to_str())
465 .and_then(|name| safe_child_value(app, name))
466 }
467
468 #[cfg(test)]
469 mod tests {
470 use super::*;
471 use std::path::PathBuf;
472
473 use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
474 use ratatui::{buffer::Buffer, layout::Rect};
475 use serde_json::json;
476 use tempfile::tempdir;
477
478 use crate::config::Config;
479 use crate::tools::subagent::{
480 AgentWorkerStatus, FleetRole, SubAgentAssignment, SubAgentNeedsInput,
481 };
482 use crate::tui::app::{
483 AgentCurrentActivity, AgentRecentAction, MAX_AGENT_RECENT_ACTIONS, TuiOptions,
484 };
485
486 fn test_app(workspace: PathBuf) -> App {
487 App::new(
488 TuiOptions {
489 model: "test-model".to_string(),
490 use_mouse_capture: true,
491 max_subagents: 4,
492 ..crate::test_support::test_tui_options(workspace)
493 },
494 &Config::default(),
495 )
496 }
497
498 fn agent(agent_id: &str, status: SubAgentStatus) -> SubAgentResult {
499 SubAgentResult {
500 name: agent_id.to_string(),
501 agent_id: agent_id.to_string(),
502 context_mode: "isolated".to_string(),
503 fork_context: false,
504 workspace: None,
505 git_branch: None,
506 agent_type: FleetRole::Builder,
507 assignment: SubAgentAssignment {
508 objective: "Implement the bounded details route".to_string(),
509 role: Some("worker".to_string()),
510 },
511 model: "deepseek-v4-pro".to_string(),
512 nickname: Some("Blue Whale".to_string()),
513 status,
514 worker_status: None,
515 runtime_permissions: None,
516 parent_run_id: None,
517 spawn_depth: 1,
518 result: None,
519 steps_taken: 2,
520 checkpoint: None,
521 needs_input: None,
522 duration_ms: 2_500,
523 from_prior_session: false,
524 }
525 }
526
527 fn body_for(
528 status: SubAgentStatus,
529 worker_status: AgentWorkerStatus,
530 detail: Option<&str>,
531 ) -> String {
532 let tmp = tempdir().expect("tempdir");
533 let mut app = test_app(tmp.path().to_path_buf());
534 let agent_id = "agent_matrix_subject";
535 let mut child = agent(agent_id, status);
536 child.worker_status = Some(worker_status);
537 match worker_status {
538 AgentWorkerStatus::WaitingForUser => {
539 child.needs_input = Some(SubAgentNeedsInput {
540 question: detail.unwrap_or("Which path should I use?").to_string(),
541 });
542 }
543 AgentWorkerStatus::Completed => child.result = detail.map(str::to_string),
544 _ => {}
545 }
546 app.subagent_cache.push(child);
547 app.agent_progress_meta.insert(
548 agent_id.to_string(),
549 AgentProgressMeta {
550 current_activity: Some(AgentCurrentActivity::bounded(
551 worker_status.into(),
552 detail.map(str::to_string),
553 (worker_status == AgentWorkerStatus::RunningTool)
554 .then(|| "read_file".to_string()),
555 Some(2),
556 )),
557 ..AgentProgressMeta::default()
558 },
559 );
560 project_agent_details(&app, agent_id)
561 .expect("projection")
562 .body
563 }
564
565 #[test]
566 fn provider_free_status_matrix_is_typed_and_bounded() {
567 let running = body_for(
568 SubAgentStatus::Running,
569 AgentWorkerStatus::RunningTool,
570 None,
571 );
572 assert!(running.contains("State: tool · elapsed 2s · 2 steps"));
573 assert!(running.contains("Current: running tool · read_file · step 2"));
574 assert!(!running.contains("Provider:"));
575
576 let waiting = body_for(
577 SubAgentStatus::Running,
578 AgentWorkerStatus::WaitingForUser,
579 Some("Which path should I use?"),
580 );
581 assert!(waiting.contains("State: waiting"));
582 assert!(waiting.contains("Pending question: Which path should I use?"));
583
584 let failed = body_for(
585 SubAgentStatus::Failed("verification failed".to_string()),
586 AgentWorkerStatus::Failed,
587 Some("verification failed"),
588 );
589 assert!(failed.contains("State: failed"));
590 assert!(failed.contains("Blocker: verification failed"));
591
592 let completed = body_for(
593 SubAgentStatus::Completed,
594 AgentWorkerStatus::Completed,
595 Some("all checks passed"),
596 );
597 assert!(completed.contains("State: done"));
598 assert!(completed.contains("Summary: all checks passed"));
599 }
600
601 #[test]
602 fn details_projection_matches_primary_agents_row() {
603 let tmp = tempdir().expect("tempdir");
604 let mut app = test_app(tmp.path().to_path_buf());
605 let agent_id = "agent_row_agreement";
606 let mut child = agent(
607 agent_id,
608 SubAgentStatus::Failed("stale failure".to_string()),
609 );
610 child.model = "kimi-k3".to_string();
611 child.steps_taken = 7;
612 child.duration_ms = 61_000;
613 app.subagent_cache.push(child);
614 app.agent_progress_meta.insert(
615 agent_id.to_string(),
616 AgentProgressMeta {
617 current_activity: Some(AgentCurrentActivity::bounded(
618 AgentCurrentActivityStatus::RunningTool,
619 Some("checking tests".to_string()),
620 Some("cargo test".to_string()),
621 Some(7),
622 )),
623 resolved_model: Some("stale-meta-model".to_string()),
624 ..AgentProgressMeta::default()
625 },
626 );
627
628 let row = crate::tui::sidebar::sidebar_agent_rows(&app)
629 .into_iter()
630 .find(|row| row.id == agent_id)
631 .expect("primary agents row");
632 let details = project_agent_details(&app, agent_id).expect("details projection");
633
634 assert_eq!(row.status, "tool");
635 assert_eq!(row.steps_taken, 7);
636 assert_eq!(row.model.as_deref(), Some("kimi-k3"));
637 assert_eq!(row.duration_ms, Some(61_000));
638 assert!(details.body.contains(&format!(
639 "State: {} · elapsed {} · {} steps",
640 row.status,
641 crate::elapsed::format_elapsed_ms(row.duration_ms.expect("duration")),
642 row.steps_taken
643 )));
644 assert!(details.body.contains("Model: kimi-k3"));
645 assert!(!details.body.contains("stale-meta-model"));
646 }
647
648 #[test]
649 fn projection_redacts_child_strings_and_never_exposes_raw_ids_or_none() {
650 let tmp = tempdir().expect("tempdir");
651 let mut app = test_app(tmp.path().to_path_buf());
652 let agent_id = "agent_secret_child";
653 let parent_id = "agent_raw_parent";
654 let mut parent = agent(parent_id, SubAgentStatus::Running);
655 parent.nickname = Some("Parent Whale".to_string());
656 let mut child = agent(agent_id, SubAgentStatus::Running);
657 child.parent_run_id = Some(parent_id.to_string());
658 child.spawn_depth = 2;
659 child.assignment.objective = format!(
660 "\u{1b}[31minspect {agent_id}\u{1b}[0m with api_key=sk-agent-details-secret-1234567890"
661 );
662 child.git_branch = Some(format!("work/{parent_id}"));
663 child.model.clear();
664 child.nickname = Some(format!("\u{1b}[35m{agent_id}\u{1b}[0m"));
665 app.subagent_cache.extend([parent, child]);
666
667 let projection = project_agent_details(&app, agent_id).expect("projection");
668 let all = format!("{}\n{}", projection.title, projection.body);
669 assert!(!all.contains(agent_id), "{all}");
670 assert!(!all.contains(parent_id), "{all}");
671 assert!(!all.contains("sk-agent-details-secret"), "{all}");
672 assert!(!all.contains('\u{1b}'), "{all:?}");
673 assert!(!all.contains("None"), "{all}");
674 assert!(all.contains("[redacted]"), "{all}");
675 }
676
677 #[test]
678 fn external_workspace_is_basename_safe_and_branch_is_bounded() {
679 let mut app = test_app(PathBuf::from("/repo/main"));
680 let agent_id = "agent_external_workspace";
681 let mut child = agent(agent_id, SubAgentStatus::Running);
682 child.workspace = Some(PathBuf::from("/private/customer/secret/repo-child"));
683 child.git_branch = Some("codex/details".to_string());
684 app.subagent_cache.push(child);
685
686 let body = project_agent_details(&app, agent_id)
687 .expect("projection")
688 .body;
689 assert!(body.contains("Workspace: repo-child"), "{body}");
690 assert!(!body.contains("/private/customer/secret"), "{body}");
691 assert!(body.contains("Branch: codex/details"), "{body}");
692 }
693
694 #[test]
695 fn recent_actions_are_bounded_and_render_only_structured_outcomes() {
696 let tmp = tempdir().expect("tempdir");
697 let mut app = test_app(tmp.path().to_path_buf());
698 let agent_id = "agent_recent_actions";
699 app.subagent_cache
700 .push(agent(agent_id, SubAgentStatus::Running));
701 let mut meta = AgentProgressMeta::default();
702 for step in 1..=MAX_AGENT_RECENT_ACTIONS as u32 {
703 meta.recent_actions.push_back(AgentRecentAction::bounded(
704 if step == 2 {
705 "apply_patch"
706 } else {
707 "read_file"
708 },
709 step,
710 step != 2,
711 ));
712 }
713 app.agent_progress_meta.insert(agent_id.to_string(), meta);
714
715 let body = project_agent_details(&app, agent_id)
716 .expect("projection")
717 .body;
718 assert_eq!(body.matches("Recent:").count(), 3, "{body}");
719 assert!(body.contains("Recent: ! apply_patch · step 2"), "{body}");
720 }
721
722 #[test]
723 fn alt_v_is_truthful_for_present_and_absent_evidence() {
724 let tmp = tempdir().expect("tempdir");
725 let agent_id = "agent_evidence";
726 let mut app = test_app(tmp.path().to_path_buf());
727 app.subagent_cache
728 .push(agent(agent_id, SubAgentStatus::Running));
729 let absent = project_agent_details(&app, agent_id).expect("projection");
730 assert!(!absent.transcript_available);
731 let details_chord = crate::tui::shell_key_routing::tool_details_chord();
732 let transcript_hint = format!("{details_chord}:transcript");
733 assert!(!absent.body.contains(&transcript_hint));
734 let mut absent_view = AgentDetailsView::new(absent, agent_id, 80);
735 assert!(matches!(
736 absent_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)),
737 ViewAction::None
738 ));
739
740 {
741 let mut store = app
742 .runtime_services
743 .handle_store
744 .try_lock()
745 .expect("handle store");
746 let _ = store.insert_json(
747 format!("agent:{agent_id}"),
748 "full_transcript",
749 json!({
750 "message_count": 1,
751 "messages": [{
752 "role": "assistant",
753 "content": [{
754 "type": "text",
755 "text": "exact evidence",
756 "cache_control": null
757 }]
758 }]
759 }),
760 );
761 }
762 let present = project_agent_details(&app, agent_id).expect("projection");
763 assert!(present.transcript_available);
764 assert!(
765 present.body.contains(&transcript_hint),
766 "expected {transcript_hint:?} in {}",
767 present.body
768 );
769 assert!(
770 !present.body.contains("Alt/⌥V"),
771 "dual Alt/⌥ spelling must not appear: {}",
772 present.body
773 );
774 let mut present_view = AgentDetailsView::new(present, agent_id, 80);
775 assert!(matches!(
776 present_view.handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT)),
777 ViewAction::Emit(ViewEvent::OpenAgentTranscript { agent_id: ref id }) if id == agent_id
778 ));
779
780 assert!(open_agent_details(&mut app, agent_id));
781 let events = app
782 .view_stack
783 .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT));
784 assert!(matches!(
785 events.as_slice(),
786 [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id
787 ));
788 assert!(crate::tui::mouse_ui::open_agent_chat_pager(
789 &mut app, agent_id
790 ));
791 assert!(
792 app.view_stack
793 .handle_key(KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE))
794 .is_empty(),
795 "transcript Esc closes only the top pager"
796 );
797 assert!(matches!(
798 app.view_stack
799 .handle_key(KeyEvent::new(KeyCode::Char('v'), KeyModifiers::ALT))
800 .as_slice(),
801 [ViewEvent::OpenAgentTranscript { agent_id: id }] if id == agent_id
802 ));
803 }
804
805 #[test]
806 fn close_keys_emit_receipt_and_80x24_render_stays_safe() {
807 let tmp = tempdir().expect("tempdir");
808 let agent_id = "agent_render_80x24";
809 let mut app = test_app(tmp.path().to_path_buf());
810 app.subagent_cache
811 .push(agent(agent_id, SubAgentStatus::Running));
812 let projection = project_agent_details(&app, agent_id).expect("projection");
813 let mut view = AgentDetailsView::new(projection, agent_id, 80);
814 assert!(view.title().starts_with("Agent Details — "));
815 assert!(view.body_text().contains("Exact evidence: unavailable"));
816
817 let area = Rect::new(0, 0, 80, 24);
818 let mut buffer = Buffer::empty(area);
819 view.render(area, &mut buffer);
820 let rendered = (0..area.height)
821 .flat_map(|y| (0..area.width).map(move |x| (x, y)))
822 .map(|point| buffer[point].symbol())
823 .collect::<String>();
824 assert!(rendered.contains("Agent Details"), "{rendered}");
825 assert!(rendered.contains("Assignment"), "{rendered}");
826 assert!(!rendered.contains(agent_id), "{rendered}");
827
828 for code in [KeyCode::Esc, KeyCode::Left] {
829 assert!(matches!(
830 view.handle_key(KeyEvent::new(code, KeyModifiers::NONE)),
831 ViewAction::EmitAndClose(ViewEvent::AgentDetailsClosed { agent_id: ref id })
832 if id == agent_id
833 ));
834 }
835 }
836 }
837
837 lines RUST