From ba70c6479c138b8f1558382060e1ebed308c0a25 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 31 May 2026 18:39:49 -0700 Subject: [PATCH 1/3] Add structured TUI chat mode --- crates/cli/src/app.rs | 20 +- crates/cli/src/ui.rs | 182 +++++++++++++----- ...tui-chat-mode-is-structured-events-only.md | 22 +++ 3 files changed, 168 insertions(+), 56 deletions(-) create mode 100644 specs/0016-tui-chat-mode-is-structured-events-only.md diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 8bc15fc9..1340ca96 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -221,8 +221,8 @@ impl MainWindowTree { /// What the right pane is currently showing for the selected session. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ViewMode { - /// Structured transcript renderer (default for headless / non-PTY sessions). - Transcript, + /// Structured-event chat renderer (default for headless / non-PTY sessions). + Chat, /// Live PTY emulator (default for sessions whose adapter has supports_pty). Terminal, } @@ -1401,7 +1401,7 @@ pub async fn run_with_socket(socket: std::path::PathBuf) -> Result<()> { should_quit: false, connected: true, remote_clients: 0, - view: ViewMode::Transcript, + view: ViewMode::Chat, histories: HashMap::new(), block_hits: HashMap::new(), matrix_reveal_hits: Vec::new(), @@ -2133,7 +2133,7 @@ impl App { self.view = if self.selected_session().map(|s| s.has_pty).unwrap_or(false) { ViewMode::Terminal } else { - ViewMode::Transcript + ViewMode::Chat }; } @@ -2440,7 +2440,7 @@ impl App { self.view = ViewMode::Terminal; self.bootstrap_terminal(&id).await; } else { - self.view = ViewMode::Transcript; + self.view = ViewMode::Chat; } if self.selection.session_id() == Some(id.as_str()) { self.start_session_transition(); @@ -2771,7 +2771,7 @@ impl App { self.view = if self.selected_session().map(|s| s.has_pty).unwrap_or(false) { ViewMode::Terminal } else { - ViewMode::Transcript + ViewMode::Chat }; } } @@ -5500,14 +5500,14 @@ impl App { ToggleView => { let has_pty = self.in_pty_session(); self.view = match (self.view, has_pty) { - (ViewMode::Transcript, true) => { + (ViewMode::Chat, true) => { // First time switching → bootstrap from replay snapshot. if let Some(id) = self.selected_id() { self.bootstrap_terminal(&id).await; } ViewMode::Terminal } - _ => ViewMode::Transcript, + _ => ViewMode::Chat, }; } MoveSelectedUp => self.move_selected(true).await, @@ -8148,8 +8148,8 @@ mod tests { "the re-hydration request must actually fetch the PTY snapshot" ); - // In Transcript view a missing history must NOT spin up fetches. - app.view = ViewMode::Transcript; + // In Chat view a missing history must NOT spin up fetches. + app.view = ViewMode::Chat; assert!( !app.selected_needs_hydration(), "transcript view should not re-fetch PTY history" diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 35e3dd38..3f864e17 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -1031,7 +1031,7 @@ fn render_zoomed_view(f: &mut Frame, area: Rect, app: &mut App) { } else { match app.view { ViewMode::Terminal => render_terminal(f, main_area, app), - ViewMode::Transcript => render_transcript(f, main_area, app), + ViewMode::Chat => render_chat(f, main_area, app), } } render_minibuffer(f, minibuffer_area, app); @@ -2393,7 +2393,7 @@ fn render_detail(f: &mut Frame, area: Rect, app: &mut App, window_id: Option render_terminal_for_window(f, inner, app, window_id), - ViewMode::Transcript => render_transcript(f, inner, app), + ViewMode::Chat => render_chat(f, inner, app), } render_main_transition(f, inner, app, window_id); } @@ -4575,17 +4575,17 @@ fn format_elapsed(started_at_ms: i64) -> String { } } -fn render_transcript(f: &mut Frame, area: Rect, app: &App) { - // Windowed render: format only the events visible in the current - // viewport instead of the full transcript. `format_event` is the - // hot allocator here, so this keeps long sessions snappy even - // when `app.transcript` contains thousands of events. - // - // Scroll is event-indexed (not wrapped-line-indexed), so wide - // messages that wrap may push later rows off the bottom of the - // viewport. The user can scroll one event at a time to bring - // them in — same model as the pre-windowing code. - let total = app.transcript.len(); +fn render_chat(f: &mut Frame, area: Rect, app: &App) { + // Structured-event chat mode. This intentionally ignores PTY bytes and + // terminal-derived snapshots; Terminal view owns terminal rendering. C-x t + // and headless sessions both use this path so transcript inspection and + // non-PTY sessions share the same chat presentation. + let chat_events: Vec<&TimestampedEvent> = app + .transcript + .iter() + .filter(|ev| chat_event_kind(&ev.event) != ChatEventKind::Hidden) + .collect(); + let total = chat_events.len(); let height = area.height as usize; let max_scroll = total.saturating_sub(height); let scroll_start = if app.transcript_scroll == u16::MAX { @@ -4594,10 +4594,22 @@ fn render_transcript(f: &mut Frame, area: Rect, app: &App) { (app.transcript_scroll as usize).min(max_scroll) }; let end = (scroll_start + height).min(total); - let lines: Vec = app.transcript[scroll_start..end] - .iter() - .map(|ev| format_event(&app.theme, ev)) - .collect(); + let mut lines = Vec::new(); + let mut previous_kind = ChatEventKind::Hidden; + for ev in &chat_events[scroll_start..end] { + let kind = chat_event_kind(&ev.event); + if !lines.is_empty() && chat_event_needs_gap(previous_kind, kind) { + lines.push(Line::raw("")); + } + lines.push(format_chat_event(&app.theme, ev)); + previous_kind = kind; + } + if lines.is_empty() { + lines.push(Line::from(Span::styled( + "No structured chat events for this session. Use Terminal Mode to view PTY output.", + Style::default().fg(app.theme.dim), + ))); + } let para = Paragraph::new(lines).wrap(Wrap { trim: false }); f.render_widget(para, area); } @@ -4875,7 +4887,7 @@ emacs keymap (default; AGENTD_KEYMAP=vim for vim profile) C-x 0 / C-x 1 delete current window / delete other windows C-x ^ make current window taller C-x } / C-x { make current window wider / narrower - C-x t toggle transcript ↔ terminal view + C-x t toggle chat ↔ terminal view C-x z zoom: fill the screen with the session view C-n / down next session C-p / up prev session @@ -4920,20 +4932,86 @@ child. `C-x` is the escape prefix — start any `C-x …` chord above to run an agentd command without changing focus. "; -fn format_event(theme: &Theme, ev: &TimestampedEvent) -> Line<'static> { +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChatEventKind { + Hidden, + Message(MessageRole), + Reasoning, + Tool, + Metadata, +} + +fn chat_event_kind(ev: &SessionEvent) -> ChatEventKind { + match ev { + SessionEvent::Pty { .. } + | SessionEvent::PtyResize { .. } + | SessionEvent::EditorState { .. } + | SessionEvent::AgentStatus(_) => ChatEventKind::Hidden, + SessionEvent::Message { role, text } if should_render_chat_message(*role, text) => { + ChatEventKind::Message(*role) + } + SessionEvent::Message { .. } => ChatEventKind::Hidden, + SessionEvent::Reasoning { .. } => ChatEventKind::Reasoning, + SessionEvent::ToolUse { .. } + | SessionEvent::ToolResult { .. } + | SessionEvent::ToolApprovalRequest { .. } + | SessionEvent::TaskStart { .. } + | SessionEvent::TaskBackgrounded { .. } + | SessionEvent::TaskEnd { .. } => ChatEventKind::Tool, + SessionEvent::Status { .. } + | SessionEvent::AwaitingInput { .. } + | SessionEvent::Cost { .. } + | SessionEvent::Diff { .. } + | SessionEvent::Error { .. } + | SessionEvent::Reset + | SessionEvent::Done { .. } + | SessionEvent::UiPanel(_) + | SessionEvent::UiDelete { .. } + | SessionEvent::BrowserPreview(_) + | SessionEvent::ContextCompacted { .. } => ChatEventKind::Metadata, + } +} + +fn chat_event_needs_gap(previous: ChatEventKind, current: ChatEventKind) -> bool { + !matches!( + (previous, current), + (ChatEventKind::Tool, ChatEventKind::Tool) + | (ChatEventKind::Metadata, ChatEventKind::Metadata) + | (ChatEventKind::Reasoning, ChatEventKind::Reasoning) + ) +} + +fn should_render_chat_message(role: MessageRole, text: &str) -> bool { + let trimmed = text.trim_start(); + if role == MessageRole::Assistant && trimmed.starts_with("") { + return false; + } + if role == MessageRole::User + && trimmed.starts_with("# AGENTS.md instructions for ") + && trimmed.contains("\n") + { + return false; + } + true +} + +fn format_chat_event(theme: &Theme, ev: &TimestampedEvent) -> Line<'static> { let ts = ev.at.format("%H:%M:%S").to_string(); let mut spans = vec![Span::styled( format!("[{ts}] "), Style::default().fg(theme.dim), )]; - spans.extend(format_event_body(theme, &ev.event)); + spans.extend(format_chat_event_body(theme, &ev.event)); Line::from(spans) } -fn format_event_body(theme: &Theme, ev: &SessionEvent) -> Vec> { +fn format_chat_event_body(theme: &Theme, ev: &SessionEvent) -> Vec> { match ev { - // UI-only geometry hint; never rendered as a transcript line. - SessionEvent::PtyResize { .. } => Vec::new(), + // Hidden events are filtered before formatting. + SessionEvent::Pty { .. } + | SessionEvent::PtyResize { .. } + | SessionEvent::EditorState { .. } + | SessionEvent::AgentStatus(_) => Vec::new(), SessionEvent::Message { role, text } => { let role_label = match role { MessageRole::User => "user", @@ -4989,20 +5067,6 @@ fn format_event_body(theme: &Theme, ev: &SessionEvent) -> Vec> { Style::default().fg(theme.info), )] } - SessionEvent::AgentStatus(status) => { - if status.active { - vec![Span::styled( - format!( - " * {}.. {}", - status.status, - format_elapsed(status.started_at_ms) - ), - Style::default().fg(theme.dim), - )] - } else { - vec![] - } - } SessionEvent::Cost { usd, tokens_in, @@ -5024,10 +5088,6 @@ fn format_event_body(theme: &Theme, ev: &SessionEvent) -> Vec> { format!(" ▢ done (exit {exit_code})"), Style::default().fg(theme.success), )], - SessionEvent::Pty { data } => vec![Span::styled( - format!(" ⌷ pty: {} bytes (switch to terminal view)", data.len()), - Style::default().fg(theme.dim), - )], SessionEvent::ToolApprovalRequest { tool, args_summary, @@ -5065,11 +5125,6 @@ fn format_event_body(theme: &Theme, ev: &SessionEvent) -> Vec> { Style::default().fg(theme.dim), )] } - SessionEvent::EditorState { .. } => { - // Editor state is rendered by the input pane, not the - // chat transcript. - vec![] - } SessionEvent::UiPanel(panel) => vec![Span::styled( format!( " ▣ ui panel: {}", @@ -6121,6 +6176,41 @@ mod tests { .collect() } + #[test] + fn chat_mode_ignores_pty_events() { + let pty = SessionEvent::Pty { + data: "AQID".into(), + }; + let resize = SessionEvent::PtyResize { cols: 80, rows: 24 }; + assert_eq!(chat_event_kind(&pty), ChatEventKind::Hidden); + assert_eq!(chat_event_kind(&resize), ChatEventKind::Hidden); + } + + #[test] + fn chat_mode_filters_codex_bootstrap_messages() { + assert_eq!( + chat_event_kind(&SessionEvent::Message { + role: MessageRole::Assistant, + text: "hide me".into(), + }), + ChatEventKind::Hidden + ); + assert_eq!( + chat_event_kind(&SessionEvent::Message { + role: MessageRole::User, + text: "# AGENTS.md instructions for /tmp/repo\nhide me".into(), + }), + ChatEventKind::Hidden + ); + assert_eq!( + chat_event_kind(&SessionEvent::Message { + role: MessageRole::Assistant, + text: "hello".into(), + }), + ChatEventKind::Message(MessageRole::Assistant) + ); + } + #[test] fn timeline_renders_nested_actions_and_depth() { let mut hits = Vec::new(); diff --git a/specs/0016-tui-chat-mode-is-structured-events-only.md b/specs/0016-tui-chat-mode-is-structured-events-only.md new file mode 100644 index 00000000..015420b2 --- /dev/null +++ b/specs/0016-tui-chat-mode-is-structured-events-only.md @@ -0,0 +1,22 @@ +# 0016-tui-chat-mode-is-structured-events-only + +Status: accepted +Date: 2026-06-01 +Area: tui +Scope: TUI chat-mode rendering and its relationship to terminal rendering. + +## Decision + +The TUI has a Chat Mode that renders only structured session events. It must not render raw PTY bytes, terminal snapshots, or other terminal-derived fallback content. PTY-backed sessions default to Terminal Mode and can toggle to Chat Mode for structured-event inspection; headless and other non-PTY sessions default to Chat Mode. + +## Reason + +Terminal output and structured conversation events have different semantics. Mixing PTY bytes into the chat renderer recreates terminal-history problems such as duplicated repaint snapshots, broken spacing, and inconsistent rendering across resize or restart. A structured-only Chat Mode keeps Terminal Mode responsible for terminal bytes while giving headless sessions and transcript inspection one readable, product-oriented presentation. + +## Consequences + +Adapters that want rich Chat Mode output must emit structured message, reasoning, tool, status, error, approval, or related events. A PTY-backed session that emits only terminal bytes may show an empty Chat Mode with guidance to use Terminal Mode. Future TUI transcript or headless-session changes should reuse the Chat Mode renderer instead of adding another event-log renderer or deriving chat rows from PTY output. + +## Non-Goals + +Chat Mode is not a terminal emulator, scrollback reconstruction mechanism, or ANSI snapshot viewer. It does not replace Terminal Mode for interactive shells or full-screen TUIs. From 092c79ea22d03a01838515b68b0faa56472c627d Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 31 May 2026 18:47:59 -0700 Subject: [PATCH 2/3] Fix TUI chat streaming and scrolling --- crates/cli/src/app.rs | 143 ++++++++++++++++++++++++++++++++++++++---- crates/cli/src/ui.rs | 92 +++++++++++++++++++++------ 2 files changed, 205 insertions(+), 30 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 1340ca96..b7b5194e 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -4,8 +4,8 @@ use crate::keymap::{self, ChordState, KeyAction, Keymap, KeymapResult, Profile}; use crate::ui; use agentd_client::Client; use agentd_protocol::{ - EventNotificationPayload, GroupSummary, HarnessInfo, Notification, Request, SessionEvent, - SessionSummary, StateNotificationPayload, TimestampedEvent, + EventNotificationPayload, GroupSummary, HarnessInfo, MessageRole, Notification, Request, + SessionEvent, SessionSummary, StateNotificationPayload, TimestampedEvent, }; use anyhow::{Context, Result}; use crossterm::event::{ @@ -227,6 +227,84 @@ pub enum ViewMode { Terminal, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChatScrollKind { + Hidden, + AssistantMessage, + Message, + Reasoning, + Tool, + Metadata, +} + +fn chat_scroll_kind(ev: &SessionEvent) -> ChatScrollKind { + match ev { + SessionEvent::Pty { .. } + | SessionEvent::PtyResize { .. } + | SessionEvent::EditorState { .. } + | SessionEvent::AgentStatus(_) => ChatScrollKind::Hidden, + SessionEvent::Message { role, text } + if should_render_chat_message_for_scroll(*role, text) => + { + if *role == MessageRole::Assistant { + ChatScrollKind::AssistantMessage + } else { + ChatScrollKind::Message + } + } + SessionEvent::Message { .. } => ChatScrollKind::Hidden, + SessionEvent::Reasoning { .. } => ChatScrollKind::Reasoning, + SessionEvent::ToolUse { .. } + | SessionEvent::ToolResult { .. } + | SessionEvent::ToolApprovalRequest { .. } + | SessionEvent::TaskStart { .. } + | SessionEvent::TaskBackgrounded { .. } + | SessionEvent::TaskEnd { .. } => ChatScrollKind::Tool, + SessionEvent::Status { .. } + | SessionEvent::AwaitingInput { .. } + | SessionEvent::Cost { .. } + | SessionEvent::Diff { .. } + | SessionEvent::Error { .. } + | SessionEvent::Reset + | SessionEvent::Done { .. } + | SessionEvent::UiPanel(_) + | SessionEvent::UiDelete { .. } + | SessionEvent::BrowserPreview(_) + | SessionEvent::ContextCompacted { .. } => ChatScrollKind::Metadata, + } +} + +fn chat_scroll_needs_gap(previous: ChatScrollKind, current: ChatScrollKind) -> bool { + !matches!( + (previous, current), + (ChatScrollKind::Tool, ChatScrollKind::Tool) + | (ChatScrollKind::Metadata, ChatScrollKind::Metadata) + | (ChatScrollKind::Reasoning, ChatScrollKind::Reasoning) + | ( + ChatScrollKind::AssistantMessage, + ChatScrollKind::AssistantMessage + ) + ) +} + +fn should_render_chat_message_for_scroll(role: MessageRole, text: &str) -> bool { + let trimmed = text.trim_start(); + if role == MessageRole::Assistant && trimmed.starts_with("") { + return false; + } + if role == MessageRole::User + && trimmed.starts_with("# AGENTS.md instructions for ") + && trimmed.contains("\n") + { + return false; + } + true +} + +fn transcript_scroll_pos(value: usize) -> u16 { + value.min((u16::MAX - 1) as usize) as u16 +} + /// Which pane (if any) currently takes the entire screen. Zoom mirrors /// tmux's `prefix z`: a single key collapses the rest of the layout /// onto a single pane and back. @@ -2411,6 +2489,47 @@ impl App { items.iter().position(|it| it.matches(&self.selection)) } + fn chat_scroll_line_count(&self) -> usize { + let mut count = 0usize; + let mut previous = ChatScrollKind::Hidden; + for ev in &self.transcript { + let kind = chat_scroll_kind(&ev.event); + if kind == ChatScrollKind::Hidden { + continue; + } + if kind == ChatScrollKind::AssistantMessage + && previous == ChatScrollKind::AssistantMessage + { + // Streaming assistant chunks render as one aggregated chat row. + continue; + } + if count > 0 && chat_scroll_needs_gap(previous, kind) { + count += 1; + } + count += 1; + previous = kind; + } + count.max(1) + } + + fn chat_scroll_max(&self) -> u16 { + transcript_scroll_pos(self.chat_scroll_line_count().saturating_sub(1)) + } + + fn adjust_chat_scroll(&mut self, delta: i32) { + let max = self.chat_scroll_max(); + let current = if self.transcript_scroll == u16::MAX { + max + } else { + self.transcript_scroll.min(max) + }; + self.transcript_scroll = if delta > 0 { + current.saturating_sub(delta as u16) + } else { + current.saturating_add(delta.unsigned_abs() as u16).min(max) + }; + } + async fn refresh_selected_transcript(&mut self) { let Some(id) = self.selected_id() else { self.transcript.clear(); @@ -4796,6 +4915,8 @@ impl App { let next = adjusted_scrollback(self.scrollback_for_window(scroll_window), delta); self.set_scrollback_for_window(scroll_window, next); self.show_terminal_scrollbar(); + } else if self.view == ViewMode::Chat { + self.adjust_chat_scroll(delta); } } @@ -5542,31 +5663,29 @@ impl App { ScrollUp => { if self.can_scroll_pty_history() { self.adjust_scrollback(1); - } else if self.transcript_scroll != u16::MAX { - self.transcript_scroll = self.transcript_scroll.saturating_sub(1); + } else if self.view == ViewMode::Chat { + self.adjust_chat_scroll(1); } } ScrollDown => { if self.can_scroll_pty_history() { self.adjust_scrollback(-1); - } else if self.transcript_scroll != u16::MAX { - self.transcript_scroll = self.transcript_scroll.saturating_add(1); + } else if self.view == ViewMode::Chat { + self.adjust_chat_scroll(-1); } } ScrollPageUp => { if self.can_scroll_pty_history() { self.adjust_scrollback(10); - } else if self.transcript_scroll == u16::MAX { - self.transcript_scroll = 0; - } else { - self.transcript_scroll = self.transcript_scroll.saturating_sub(10); + } else if self.view == ViewMode::Chat { + self.adjust_chat_scroll(10); } } ScrollPageDown => { if self.can_scroll_pty_history() { self.adjust_scrollback(-10); - } else if self.transcript_scroll != u16::MAX { - self.transcript_scroll = self.transcript_scroll.saturating_add(10); + } else if self.view == ViewMode::Chat { + self.adjust_chat_scroll(-10); } } ScrollTop => { diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 3f864e17..af0c9e99 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -4580,12 +4580,8 @@ fn render_chat(f: &mut Frame, area: Rect, app: &App) { // terminal-derived snapshots; Terminal view owns terminal rendering. C-x t // and headless sessions both use this path so transcript inspection and // non-PTY sessions share the same chat presentation. - let chat_events: Vec<&TimestampedEvent> = app - .transcript - .iter() - .filter(|ev| chat_event_kind(&ev.event) != ChatEventKind::Hidden) - .collect(); - let total = chat_events.len(); + let chat_lines = chat_lines(&app.theme, &app.transcript); + let total = chat_lines.len(); let height = area.height as usize; let max_scroll = total.saturating_sub(height); let scroll_start = if app.transcript_scroll == u16::MAX { @@ -4594,16 +4590,7 @@ fn render_chat(f: &mut Frame, area: Rect, app: &App) { (app.transcript_scroll as usize).min(max_scroll) }; let end = (scroll_start + height).min(total); - let mut lines = Vec::new(); - let mut previous_kind = ChatEventKind::Hidden; - for ev in &chat_events[scroll_start..end] { - let kind = chat_event_kind(&ev.event); - if !lines.is_empty() && chat_event_needs_gap(previous_kind, kind) { - lines.push(Line::raw("")); - } - lines.push(format_chat_event(&app.theme, ev)); - previous_kind = kind; - } + let mut lines = chat_lines[scroll_start..end].to_vec(); if lines.is_empty() { lines.push(Line::from(Span::styled( "No structured chat events for this session. Use Terminal Mode to view PTY output.", @@ -4935,6 +4922,7 @@ an agentd command without changing focus. #[derive(Debug, Clone, Copy, PartialEq, Eq)] enum ChatEventKind { Hidden, + AssistantMessage, Message(MessageRole), Reasoning, Tool, @@ -4948,7 +4936,11 @@ fn chat_event_kind(ev: &SessionEvent) -> ChatEventKind { | SessionEvent::EditorState { .. } | SessionEvent::AgentStatus(_) => ChatEventKind::Hidden, SessionEvent::Message { role, text } if should_render_chat_message(*role, text) => { - ChatEventKind::Message(*role) + if *role == MessageRole::Assistant { + ChatEventKind::AssistantMessage + } else { + ChatEventKind::Message(*role) + } } SessionEvent::Message { .. } => ChatEventKind::Hidden, SessionEvent::Reasoning { .. } => ChatEventKind::Reasoning, @@ -4978,6 +4970,10 @@ fn chat_event_needs_gap(previous: ChatEventKind, current: ChatEventKind) -> bool (ChatEventKind::Tool, ChatEventKind::Tool) | (ChatEventKind::Metadata, ChatEventKind::Metadata) | (ChatEventKind::Reasoning, ChatEventKind::Reasoning) + | ( + ChatEventKind::AssistantMessage, + ChatEventKind::AssistantMessage + ) ) } @@ -4995,6 +4991,40 @@ fn should_render_chat_message(role: MessageRole, text: &str) -> bool { true } +fn chat_lines(theme: &Theme, events: &[TimestampedEvent]) -> Vec> { + let mut lines = Vec::new(); + let mut previous_kind = ChatEventKind::Hidden; + + for ev in events { + let kind = chat_event_kind(&ev.event); + if kind == ChatEventKind::Hidden { + continue; + } + if kind == ChatEventKind::AssistantMessage + && previous_kind == ChatEventKind::AssistantMessage + { + append_assistant_message_chunk(&mut lines, &ev.event); + continue; + } + if !lines.is_empty() && chat_event_needs_gap(previous_kind, kind) { + lines.push(Line::raw("")); + } + lines.push(format_chat_event(theme, ev)); + previous_kind = kind; + } + + lines +} + +fn append_assistant_message_chunk(lines: &mut [Line<'static>], event: &SessionEvent) { + let Some(last) = lines.last_mut() else { + return; + }; + if let SessionEvent::Message { text, .. } = event { + last.spans.push(Span::raw(text.clone())); + } +} + fn format_chat_event(theme: &Theme, ev: &TimestampedEvent) -> Line<'static> { let ts = ev.at.format("%H:%M:%S").to_string(); let mut spans = vec![Span::styled( @@ -6207,10 +6237,36 @@ mod tests { role: MessageRole::Assistant, text: "hello".into(), }), - ChatEventKind::Message(MessageRole::Assistant) + ChatEventKind::AssistantMessage ); } + #[test] + fn chat_mode_aggregates_streaming_assistant_chunks() { + let at = chrono::Utc::now(); + let events = vec![ + TimestampedEvent { + seq: 1, + at, + event: SessionEvent::Message { + role: MessageRole::Assistant, + text: "hel".into(), + }, + }, + TimestampedEvent { + seq: 2, + at, + event: SessionEvent::Message { + role: MessageRole::Assistant, + text: "lo".into(), + }, + }, + ]; + let lines = chat_lines(&Theme::default(), &events); + assert_eq!(lines.len(), 1); + assert!(line_text(&lines[0]).contains("agent: hello")); + } + #[test] fn timeline_renders_nested_actions_and_depth() { let mut hits = Vec::new(); From 7798a8f7772acec3ee1aa701612a0775ff5a1f0b Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 31 May 2026 18:52:08 -0700 Subject: [PATCH 3/3] Aggregate TUI chat reasoning chunks --- crates/cli/src/app.rs | 7 ++++--- crates/cli/src/ui.rs | 41 +++++++++++++++++++++++++++++++++++++---- 2 files changed, 41 insertions(+), 7 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index b7b5194e..42e9dd95 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -2497,10 +2497,11 @@ impl App { if kind == ChatScrollKind::Hidden { continue; } - if kind == ChatScrollKind::AssistantMessage - && previous == ChatScrollKind::AssistantMessage + if (kind == ChatScrollKind::AssistantMessage + && previous == ChatScrollKind::AssistantMessage) + || (kind == ChatScrollKind::Reasoning && previous == ChatScrollKind::Reasoning) { - // Streaming assistant chunks render as one aggregated chat row. + // Streaming assistant/reasoning chunks render as one aggregated chat row. continue; } if count > 0 && chat_scroll_needs_gap(previous, kind) { diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index af0c9e99..235b818d 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -5003,7 +5003,11 @@ fn chat_lines(theme: &Theme, events: &[TimestampedEvent]) -> Vec> if kind == ChatEventKind::AssistantMessage && previous_kind == ChatEventKind::AssistantMessage { - append_assistant_message_chunk(&mut lines, &ev.event); + append_chat_text_chunk(&mut lines, &ev.event); + continue; + } + if kind == ChatEventKind::Reasoning && previous_kind == ChatEventKind::Reasoning { + append_chat_text_chunk(&mut lines, &ev.event); continue; } if !lines.is_empty() && chat_event_needs_gap(previous_kind, kind) { @@ -5016,12 +5020,17 @@ fn chat_lines(theme: &Theme, events: &[TimestampedEvent]) -> Vec> lines } -fn append_assistant_message_chunk(lines: &mut [Line<'static>], event: &SessionEvent) { +fn append_chat_text_chunk(lines: &mut [Line<'static>], event: &SessionEvent) { let Some(last) = lines.last_mut() else { return; }; - if let SessionEvent::Message { text, .. } = event { - last.spans.push(Span::raw(text.clone())); + match event { + SessionEvent::Message { text, .. } => last.spans.push(Span::raw(text.clone())), + SessionEvent::Reasoning { text } => { + let style = last.spans.last().map(|span| span.style).unwrap_or_default(); + last.spans.push(Span::styled(text.clone(), style)); + } + _ => {} } } @@ -6267,6 +6276,30 @@ mod tests { assert!(line_text(&lines[0]).contains("agent: hello")); } + #[test] + fn chat_mode_aggregates_streaming_reasoning_chunks() { + let at = chrono::Utc::now(); + let events = vec![ + TimestampedEvent { + seq: 1, + at, + event: SessionEvent::Reasoning { + text: "thin".into(), + }, + }, + TimestampedEvent { + seq: 2, + at, + event: SessionEvent::Reasoning { + text: "king".into(), + }, + }, + ]; + let lines = chat_lines(&Theme::default(), &events); + assert_eq!(lines.len(), 1); + assert!(line_text(&lines[0]).contains("thinking: thinking")); + } + #[test] fn timeline_renders_nested_actions_and_depth() { let mut hits = Vec::new();