feat(tui): add sub-agents panel to transcript screen

Splits chat area with a 30-char right panel listing sub-agent runs.
Yellow border highlights the focused pane. Tab cycles focus; j/k
navigates agents when panel focused; Enter drills into sub-agent
transcript. Shows "No sub-agents" when list is empty.

Closes claudbg-9c8r

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
main
Elijah Voigt 2 months ago
parent 0581431dd7
commit f80185e442

@ -1,11 +1,11 @@
---
# claudbg-9c8r
title: 'TUI: sub-agents panel in transcript view'
status: todo
status: in-progress
type: feature
priority: normal
created_at: 2026-03-30T04:47:15Z
updated_at: 2026-03-30T04:49:03Z
updated_at: 2026-03-30T16:59:55Z
parent: claudbg-i6l2
blocked_by:
- claudbg-rudq

@ -6,7 +6,7 @@
use ratatui::Frame;
use ratatui::crossterm::event::{Event, KeyCode, KeyEventKind};
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span, Text};
use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
@ -248,10 +248,16 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
.wrap(Wrap { trim: false });
f.render_widget(header_paragraph, chunks[0]);
// ── Chat log + sub-agents panel (horizontal split) ──────────────────────
let body_chunks = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Min(0), Constraint::Length(30)])
.split(chunks[1]);
// ── Chat log ─────────────────────────────────────────────────────────────
let chat_lines = build_chat_lines(&state.transcript_entries);
let border_style = if state.focus == Focus::ChatLog {
let chat_border_style = if state.focus == Focus::ChatLog {
Style::default().fg(Color::Yellow)
} else {
Style::default()
@ -260,13 +266,60 @@ pub fn render_transcript(f: &mut Frame, area: Rect, state: &AppState) {
let chat_block = Block::default()
.title(" Transcript [↑/↓ scroll · ←/→ h-scroll · Tab focus · Esc back · ? help] ")
.borders(Borders::ALL)
.border_style(border_style);
.border_style(chat_border_style);
let chat_paragraph = Paragraph::new(Text::from(chat_lines))
.block(chat_block)
.scroll((state.transcript_scroll as u16, state.transcript_h_scroll as u16));
f.render_widget(chat_paragraph, chunks[1]);
f.render_widget(chat_paragraph, body_chunks[0]);
// ── Sub-agents panel ─────────────────────────────────────────────────────
let subagents_border_style = if state.focus == Focus::SubagentsPanel {
Style::default().fg(Color::Yellow)
} else {
Style::default()
};
let subagents_block = Block::default()
.title(" Sub-agents ")
.borders(Borders::ALL)
.border_style(subagents_border_style);
let subagent_lines: Vec<Line<'static>> = if state.subagents.is_empty() {
vec![Line::from(" No sub-agents")]
} else {
state
.subagents
.iter()
.enumerate()
.map(|(i, agent)| {
let short_id = if agent.agent_id.len() >= 8 {
agent.agent_id[..8].to_string()
} else {
agent.agent_id.clone()
};
let agent_type = agent
.agent_type
.as_deref()
.unwrap_or("agent")
.to_string();
if i == state.subagent_selected && state.focus == Focus::SubagentsPanel {
Line::from(Span::styled(
format!("> {short_id} {agent_type}"),
Style::default().fg(Color::Yellow),
))
} else {
Line::from(format!(" {short_id} {agent_type}"))
}
})
.collect()
};
let subagents_paragraph = Paragraph::new(Text::from(subagent_lines))
.block(subagents_block);
f.render_widget(subagents_paragraph, body_chunks[1]);
}
// ---------------------------------------------------------------------------
@ -285,24 +338,6 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
}
match key.code {
// Vertical scroll.
KeyCode::Up | KeyCode::Char('k') => {
state.transcript_scroll = state.transcript_scroll.saturating_sub(1);
true
}
KeyCode::Down | KeyCode::Char('j') => {
state.transcript_scroll = state.transcript_scroll.saturating_add(1);
true
}
// Horizontal scroll.
KeyCode::Left | KeyCode::Char('h') => {
state.transcript_h_scroll = state.transcript_h_scroll.saturating_sub(1);
true
}
KeyCode::Right | KeyCode::Char('l') => {
state.transcript_h_scroll = state.transcript_h_scroll.saturating_add(1);
true
}
// Toggle focus between ChatLog and SubagentsPanel.
KeyCode::Tab => {
state.focus = match state.focus {
@ -326,6 +361,56 @@ pub fn handle_transcript_event(event: Event, state: &mut AppState) -> bool {
state.show_help = true;
true
}
// Directional keys are routed based on the focused panel.
KeyCode::Up | KeyCode::Char('k') => match state.focus {
Focus::SubagentsPanel => {
state.subagent_selected = state.subagent_selected.saturating_sub(1);
true
}
Focus::ChatLog => {
state.transcript_scroll = state.transcript_scroll.saturating_sub(1);
true
}
},
KeyCode::Down | KeyCode::Char('j') => match state.focus {
Focus::SubagentsPanel => {
if !state.subagents.is_empty() {
let max = state.subagents.len() - 1;
state.subagent_selected =
(state.subagent_selected + 1).min(max);
}
true
}
Focus::ChatLog => {
state.transcript_scroll = state.transcript_scroll.saturating_add(1);
true
}
},
// Horizontal scroll — only meaningful in ChatLog.
KeyCode::Left | KeyCode::Char('h') => {
state.transcript_h_scroll = state.transcript_h_scroll.saturating_sub(1);
true
}
KeyCode::Right | KeyCode::Char('l') => {
state.transcript_h_scroll = state.transcript_h_scroll.saturating_add(1);
true
}
// Enter on a selected sub-agent — navigate into its transcript.
KeyCode::Enter => {
if state.focus == Focus::SubagentsPanel && !state.subagents.is_empty() {
let agent = state.subagents[state.subagent_selected].clone();
let parent_session_id = agent.session_id.clone();
let agent_id = agent.agent_id.clone();
state.enter_subagent_transcript(parent_session_id, agent_id);
// Load the transcript for the selected agent.
let agent_id_for_load = agent.agent_id.clone();
let parent_for_load = agent.session_id.clone();
load_transcript_for_agent(&parent_for_load, &agent_id_for_load, state);
true
} else {
false
}
}
_ => false,
}
}

Loading…
Cancel
Save