use crossterm::event::{KeyCode, KeyEvent, KeyModifiers, MouseEvent};
use ratatui::{buffer::Buffer, layout::Rect};
use crate::tui::app::App;
use crate::tui::pager::PagerView;
use crate::tui::views::{ModalKind, ModalView, ViewAction, ViewEvent};
pub(crate) struct AgentTranscriptView {
pager: PagerView,
agent_id: String,
}
impl AgentTranscriptView {
fn new(title: String, body: &str, agent_id: impl Into<String>, width: u16) -> Self {
let pager = PagerView::from_text(title, body, width.saturating_sub(2))
.with_copy_text(body.to_string());
Self {
pager,
agent_id: agent_id.into(),
}
}
#[cfg(test)]
pub(crate) fn body_text(&self) -> String {
self.pager.body_text()
}
#[cfg(test)]
pub(crate) fn title(&self) -> &str {
self.pager.title()
}
}
impl ModalView for AgentTranscriptView {
fn kind(&self) -> ModalKind {
ModalKind::Pager
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
if !self.pager.in_search_mode() {
if matches!(key.code, KeyCode::Char('v' | 'V'))
&& key.modifiers.contains(KeyModifiers::ALT)
{
return ViewAction::Emit(ViewEvent::OpenAgentDetails {
agent_id: self.agent_id.clone(),
});
}
if matches!(key.code, KeyCode::Esc | KeyCode::Left)
|| (key.code == KeyCode::Char('q') && key.modifiers.is_empty())
{
return ViewAction::EmitAndClose(ViewEvent::AgentTranscriptClosed {
agent_id: self.agent_id.clone(),
});
}
}
self.pager.handle_key(key)
}
fn handle_paste(&mut self, text: &str) -> bool {
self.pager.handle_paste(text)
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
self.pager.handle_mouse(mouse)
}
fn render(&self, area: Rect, buf: &mut Buffer) {
self.pager.render(area, buf);
}
fn as_any_mut(&mut self) -> &mut dyn std::any::Any {
self
}
}
pub(crate) fn open_agent_transcript(app: &mut App, agent_id: &str) {
let transcript = crate::tui::mouse_ui::resolve_agent_transcript_text(app, agent_id);
let display_name = crate::tui::agent_details::safe_agent_display_name(app, agent_id);
let chord = crate::tui::shell_key_routing::tool_details_chord();
let hint = format!("Agent details: {chord}:details");
let body = match transcript {
Some(text) => format!("{hint}\n\n{text}"),
None => format!(
"{hint}\n\nNo transcript captured yet.\n\n\
A worker's chat appears here once it exchanges its first \
messages; finished workers keep a durable copy under the \
workspace. If this agent just started, reopen after it makes \
progress."
),
};
let width = app
.viewport
.last_transcript_area
.map(|area| area.width)
.unwrap_or(80);
app.view_stack.push(AgentTranscriptView::new(
format!("Agent transcript — {display_name}"),
&body,
agent_id,
width,
));
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
use serde_json::json;
use tempfile::tempdir;
use crate::config::Config;
use crate::tui::app::TuiOptions;
fn test_app(workspace: PathBuf) -> App {
App::new(
TuiOptions {
model: "test-model".to_string(),
use_mouse_capture: true,
max_subagents: 4,
..crate::test_support::test_tui_options(workspace)
},
&Config::default(),
)
}
fn key(code: KeyCode, mods: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, mods)
}
fn open_with_resident_transcript(app: &mut App, agent_id: &str) {
{
let mut store = app
.runtime_services
.handle_store
.try_lock()
.expect("handle store");
let _ = store.insert_json(
format!("agent:{agent_id}"),
"full_transcript",
json!({
"message_count": 1,
"messages": [{
"role": "assistant",
"content": [{
"type": "text",
"text": "exact evidence",
"cache_control": null
}]
}]
}),
);
}
open_agent_transcript(app, agent_id);
}
#[test]
fn transcript_is_the_destination_with_and_without_evidence() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
let agent_id = "agent_destination";
open_agent_transcript(&mut app, agent_id);
let mut view = app.view_stack.pop().expect("transcript surface");
let view = view
.as_any_mut()
.downcast_mut::<AgentTranscriptView>()
.expect("agent transcript view");
assert!(view.title().starts_with("Agent transcript — "));
assert!(view.body_text().contains("No transcript captured yet"));
let details_hint = format!(
"Agent details: {}:details",
crate::tui::shell_key_routing::tool_details_chord()
);
assert!(view.body_text().contains(&details_hint));
open_with_resident_transcript(&mut app, agent_id);
let mut view = app.view_stack.pop().expect("transcript surface");
let view = view
.as_any_mut()
.downcast_mut::<AgentTranscriptView>()
.expect("agent transcript view");
assert!(view.body_text().contains("exact evidence"));
assert!(view.body_text().contains(&details_hint));
}
#[test]
fn alt_v_requests_details_and_close_keys_emit_receipt() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
let agent_id = "agent_secondary";
open_with_resident_transcript(&mut app, agent_id);
let events = app
.view_stack
.handle_key(key(KeyCode::Char('v'), KeyModifiers::ALT));
assert!(matches!(
events.as_slice(),
[ViewEvent::OpenAgentDetails { agent_id: id }] if id == agent_id
));
for code in [KeyCode::Esc, KeyCode::Left, KeyCode::Char('q')] {
open_with_resident_transcript(&mut app, agent_id);
let events = app.view_stack.handle_key(key(code, KeyModifiers::NONE));
assert!(
matches!(
events.as_slice(),
[ViewEvent::AgentTranscriptClosed { agent_id: id }] if id == agent_id
),
"{code:?} must close the transcript with a receipt: {events:?}"
);
}
}
#[test]
fn search_mode_keeps_q_and_chord_keys_as_query_input() {
let tmp = tempdir().expect("tempdir");
let mut app = test_app(tmp.path().to_path_buf());
let agent_id = "agent_search";
open_with_resident_transcript(&mut app, agent_id);
assert!(
app.view_stack
.handle_key(key(KeyCode::Char('/'), KeyModifiers::NONE))
.is_empty()
);
assert!(
app.view_stack
.handle_key(key(KeyCode::Char('q'), KeyModifiers::NONE))
.is_empty()
);
assert!(!app.view_stack.is_empty(), "search 'q' must not close");
assert!(
app.view_stack
.handle_key(key(KeyCode::Esc, KeyModifiers::NONE))
.is_empty()
);
assert!(!app.view_stack.is_empty(), "search Esc must not close");
}
}