From aa3819c3c6dff7ca5846b71de1577e97ac966d25 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sun, 24 May 2026 09:31:57 -0700 Subject: [PATCH] feat(tui): render headless session conversation history MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Headless sessions (zarvis, claude, codex, antigravity — any harness run with `--mode headless`) emit their conversation as structured Message/Reasoning/ToolUse/ToolResult events with no PTY. The TUI's ItemHistory only ingested PTY bytes and tool blocks, so headless sessions showed their tool blocks (and "no PTY history yet") but never the assistant prose — while the webui rendered the full transcript in chat mode. Add a first-class `Item::Message { kind, text, break_before }` to ItemHistory (approach 2): `feed_message` coalesces the per-delta Message/Reasoning stream into contiguous runs (append-only, so `replay` stays incremental — no full reparse), `synth_message` renders role-styled bytes (assistant plain, user gray `❯`, reasoning dim) through the existing vt100 interleaving path, and the fast/shadow paths now defer to `replay_full` whenever any non-PTY item exists. Wiring is gated on `is_headless` (session mode == "headless"), mirroring the webui's `isHeadlessSession`, in both the live event handler and transcript replay (`apply_transcript_to_local_state` gains an `is_headless` arg, threaded through the hydration request). PTY-backed sessions keep their prose in the PTY stream and are untouched, so there's no double-rendering for interactive sessions. --- crates/cli/src/app.rs | 153 ++++++++++++++++++++++++++- crates/cli/src/pty_render.rs | 198 ++++++++++++++++++++++++++++++++++- crates/cli/src/ui.rs | 2 +- 3 files changed, 344 insertions(+), 9 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 7bd5b294..587b8a95 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -486,6 +486,10 @@ struct SessionHydrationRequest { session_id: String, needs_history: bool, terminal_pane_size: (u16, u16), + /// Whether the session is headless (no PTY). Headless sessions carry + /// their conversation as structured Message/Reasoning events, which + /// replay folds into the items history; PTY-backed sessions don't. + is_headless: bool, } struct PtyInputJob { @@ -646,6 +650,7 @@ async fn load_session_hydration(req: SessionHydrationRequest) -> Result = None; let mut replayed_agent_status: Option = None; + let is_headless = self + .sessions + .iter() + .find(|s| s.id == id) + .map(crate::ui::is_headless) + .unwrap_or(false); match self.client.transcript(id, 0, None).await { Ok(t) => { if t.events @@ -2002,6 +2020,7 @@ impl App { &mut history, &mut replayed_editor_state, &mut replayed_agent_status, + is_headless, ); } Err(e) => { @@ -2311,6 +2330,43 @@ impl App { .or_default(); history.feed_tool_result(tool, *ok, output.clone()); } + // Headless sessions (any harness) emit their + // conversation as structured Message/Reasoning + // events with no PTY, so fold the prose into the + // items history. PTY-backed sessions already carry + // it in the PTY stream, so skip them to avoid + // double-rendering. (Streaming arrives as many + // same-kind deltas; `feed_message` coalesces.) + let msg = match &payload.event { + SessionEvent::Message { role, text } => Some(( + match role { + agentd_protocol::MessageRole::User => { + crate::pty_render::MessageKind::User + } + _ => crate::pty_render::MessageKind::Assistant, + }, + text.clone(), + )), + SessionEvent::Reasoning { text } => { + Some((crate::pty_render::MessageKind::Reasoning, text.clone())) + } + _ => None, + }; + if let Some((kind, text)) = msg { + let headless = self + .sessions + .iter() + .find(|s| s.id == payload.session_id) + .map(crate::ui::is_headless) + .unwrap_or(false); + if headless { + let history = self + .histories + .entry(payload.session_id.clone()) + .or_default(); + history.feed_message(kind, &text); + } + } // Adapter editor state — drives the fixed // bottom input pane. if let SessionEvent::EditorState { @@ -4881,6 +4937,7 @@ pub fn apply_transcript_to_local_state( history: &mut crate::pty_render::ItemHistory, editor_state: &mut Option, agent_status: &mut Option, + is_headless: bool, ) { for ev in events { match &ev.event { @@ -4963,6 +5020,20 @@ pub fn apply_transcript_to_local_state( } } } + // Headless sessions carry their conversation as structured + // Message / Reasoning events (no PTY), so fold the prose into + // history to render it on reconnect. PTY-backed sessions keep + // their prose in the PTY stream — skip to avoid double-render. + SessionEvent::Message { role, text } if is_headless => { + let kind = match role { + agentd_protocol::MessageRole::User => crate::pty_render::MessageKind::User, + _ => crate::pty_render::MessageKind::Assistant, + }; + history.feed_message(kind, text); + } + SessionEvent::Reasoning { text } if is_headless => { + history.feed_message(crate::pty_render::MessageKind::Reasoning, text); + } _ => {} } } @@ -6342,6 +6413,7 @@ mod tests { session_id: "s-big".to_string(), needs_history: true, terminal_pane_size: (80, 24), + is_headless: false, })); tokio::time::timeout(std::time::Duration::from_secs(1), transcript_seen_rx.recv()) @@ -6410,7 +6482,7 @@ mod tests { let mut history = crate::pty_render::ItemHistory::new(); let mut editor: Option = None; let mut status: Option = None; - apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status); + apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status, false); // The render must include the synthesized header for the // block. Before the fix, no `ToolBlock` items existed and @@ -6438,6 +6510,80 @@ mod tests { ); } + /// Headless sessions carry their conversation as structured + /// Message/Reasoning events (no PTY). Replay must fold them into the + /// items history when the session is headless, and ignore them when + /// it's PTY-backed (the prose is already in the PTY stream there). + #[test] + fn transcript_replay_renders_messages_only_when_headless() { + use agentd_protocol::{MessageRole, SessionEvent, TimestampedEvent}; + use chrono::Utc; + fn ev(seq: u64, event: SessionEvent) -> TimestampedEvent { + TimestampedEvent { + seq, + at: Utc::now(), + event, + } + } + let events = vec![ + ev( + 1, + SessionEvent::Message { + role: MessageRole::Assistant, + text: "answer from a headless run".into(), + }, + ), + ev( + 2, + SessionEvent::Reasoning { + text: "some reasoning".into(), + }, + ), + ]; + + let render = |is_headless: bool| { + let mut history = crate::pty_render::ItemHistory::new(); + let mut editor: Option = None; + let mut status: Option = None; + apply_transcript_to_local_state( + &events, + &mut history, + &mut editor, + &mut status, + is_headless, + ); + let out = history.replay(80, 24, 0); + (0..24u16) + .flat_map(|r| { + let mut row = String::new(); + for c in 0..80u16 { + if let Some(cell) = out.screen.cell(r, c) { + row.push_str(&cell.contents()); + } + } + row.push('\n'); + row.chars().collect::>() + }) + .collect::() + }; + + let headless = render(true); + assert!( + headless.contains("answer from a headless run"), + "headless replay must render assistant prose:\n{headless}" + ); + assert!( + headless.contains("some reasoning"), + "headless replay must render reasoning:\n{headless}" + ); + + let interactive = render(false); + assert!( + !interactive.contains("answer from a headless run"), + "PTY-backed replay must NOT re-render Message prose (it's in the PTY):\n{interactive}" + ); + } + #[test] fn transcript_replay_preserves_answer_after_tool_order() { use agentd_protocol::{SessionEvent, TimestampedEvent}; @@ -6475,7 +6621,7 @@ mod tests { let mut history = crate::pty_render::ItemHistory::new(); let mut editor: Option = None; let mut status = None; - apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status); + apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status, false); let screen_rows = 24u16; let screen_cols = 80u16; @@ -6532,7 +6678,7 @@ mod tests { started_at_ms, status: "Working".into(), }); - apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status); + apply_transcript_to_local_state(&events, &mut history, &mut editor, &mut status, false); assert!( status.is_none(), @@ -6669,6 +6815,7 @@ mod tests { &mut history, &mut editor_state, &mut agent_status, + false, ); let state = editor_state diff --git a/crates/cli/src/pty_render.rs b/crates/cli/src/pty_render.rs index fb6bfe2f..3793625d 100644 --- a/crates/cli/src/pty_render.rs +++ b/crates/cli/src/pty_render.rs @@ -29,6 +29,17 @@ use std::collections::{HashMap, VecDeque}; use std::time::Instant; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +/// Kind of a structured chat [`Item::Message`], driving its styling. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MessageKind { + /// A user prompt (gray `❯` prefix). + User, + /// Assistant prose (default style). + Assistant, + /// Model reasoning trace (dim). + Reasoning, +} + /// One element of the rendered session history. #[derive(Debug, Clone)] pub enum Item { @@ -40,6 +51,16 @@ pub enum Item { /// events; PTY bytes between the corresponding OSC `7700` /// markers are discarded. ToolBlock(ToolBlock), + /// A structured chat message from a session that emits no PTY for + /// its conversation (headless harnesses). Rendered as synthesized, + /// role-styled text. Streaming deltas arrive as many consecutive + /// `Message` items of the same kind; only the first of a run sets + /// `break_before`, so a run renders as one continuous block. + Message { + kind: MessageKind, + text: String, + break_before: bool, + }, } #[derive(Debug, Clone)] @@ -261,6 +282,14 @@ enum ItemSig { /// block has output — the synth bytes are stable. running_elapsed: Option, }, + /// Message items are immutable once pushed (streaming appends new + /// items rather than growing one), so the text length + kind fully + /// identify the synthesized bytes. + Msg { + kind: MessageKind, + len: usize, + break_before: bool, + }, } impl ItemSig { @@ -278,6 +307,15 @@ impl ItemSig { Some(b.started_at.elapsed().as_secs()) }, }, + Item::Message { + kind, + text, + break_before, + } => ItemSig::Msg { + kind: *kind, + len: text.len(), + break_before: *break_before, + }, } } } @@ -680,6 +718,32 @@ impl ItemHistory { self.dirty = true; } + /// Append a structured chat message (from a headless session that + /// emits no PTY for its conversation). The adapter streams a + /// `Message`/`Reasoning` event per delta, so consecutive deltas of + /// the same kind are pushed as separate items that render + /// contiguously — only the first of a run gets a leading break. + /// Append-only keeps `replay` incremental (no full reparse). + pub fn feed_message(&mut self, kind: MessageKind, text: &str) { + if text.is_empty() { + return; + } + let continues = + matches!(self.items.last(), Some(Item::Message { kind: k, .. }) if *k == kind); + let break_before = !continues && !self.items.is_empty(); + if !continues { + // A new run starts here — close out any pending PTY bytes so + // the message lands at the right point in the sequence. + self.flush_chunk(); + } + self.items.push(Item::Message { + kind, + text: text.to_string(), + break_before, + }); + self.dirty = true; + } + fn find_block_mut(&mut self, call_id: &str) -> Option<&mut ToolBlock> { self.items.iter_mut().rev().find_map(|it| match it { Item::ToolBlock(b) if b.call_id == call_id => Some(b), @@ -724,7 +788,10 @@ impl ItemHistory { // user actually saw. This is a no-op when dims match. self.set_pty_size(cols, rows); - let has_blocks = self.items.iter().any(|i| matches!(i, Item::ToolBlock(_))); + // Any non-PTY item (a tool block, or a synthesized chat message + // from a headless session) needs the synth-aware interleaving + // path; the fast and shadow-scrollback paths handle raw PTY only. + let needs_synth = self.items.iter().any(|i| !matches!(i, Item::PtyChunk(_))); // Mouse-wheel scrollback for non-tool-block sessions // (shell / claude / codex): divert to the shadow parser. // The shadow sees the same byte stream as main, with @@ -752,14 +819,14 @@ impl ItemHistory { // disappeared". See // `zarvis_tool_block_survives_one_row_of_scroll` // for the regression repro. - if scrollback > 0 && !has_blocks { + if scrollback > 0 && !needs_synth { self.render_shadow(cols, rows, scrollback); return RenderOutput { screen: self.shadow_parser.screen(), blocks: Vec::new(), }; } - if has_blocks { + if needs_synth { self.replay_full(cols, rows, scrollback) } else { self.replay_cached(cols, rows, scrollback) @@ -840,8 +907,8 @@ impl ItemHistory { if let Item::PtyChunk(b) = item { cache.parser.process(b); } - // ToolBlocks can't occur in this path (has_blocks is - // false), but be defensive. + // Non-PtyChunk items (tool blocks / messages) can't occur + // in this path (needs_synth is false), but be defensive. } cache.processed_count = self.items.len(); } @@ -992,6 +1059,20 @@ impl ItemHistory { }), } } + Item::Message { + kind, + text, + break_before, + } => { + let bytes = synth_message(*kind, text, *break_before); + if idx >= start_processing_at { + cache.parser.process(&bytes); + } + ItemLayout { + lines: count_visible_lines(&bytes, cols), + block: None, + } + } }; cache.item_layouts.push(layout); } @@ -1195,6 +1276,47 @@ fn count_visible_lines(bytes: &[u8], cols: u16) -> usize { /// status row with "in background"; `Esc` kill hint only. /// - **Completed** (`output != None` and non-placeholder): header /// + glyph + truncated body + optional expand/collapse footer. +/// Append `text` to `out`, normalizing newlines to CRLF so the vt100 +/// parser advances rows correctly (a bare `\n` would line-feed without a +/// carriage return → staircase). Lone `\r` is dropped. +fn push_text_crlf(out: &mut Vec, text: &str) { + let mut buf = [0u8; 4]; + for ch in text.chars() { + match ch { + '\r' => {} + '\n' => out.extend_from_slice(b"\r\n"), + _ => out.extend_from_slice(ch.encode_utf8(&mut buf).as_bytes()), + } + } +} + +/// Synthesize terminal bytes for a structured chat [`Item::Message`]. +/// vt100 handles column wrapping, so we only normalize newlines and add +/// role styling. `break_before` (set on the first item of a run) starts +/// the message on a fresh line, separating it from prior content. +fn synth_message(kind: MessageKind, text: &str, break_before: bool) -> Vec { + let mut out = Vec::with_capacity(text.len() + 16); + if break_before { + out.extend_from_slice(b"\r\n"); + } + match kind { + MessageKind::Assistant => push_text_crlf(&mut out, text), + MessageKind::User => { + // Gray `❯ ` prompt glyph + gray body — mirrors how consumed + // user input reads elsewhere in the TUI. + out.extend_from_slice("\x1b[90m❯ ".as_bytes()); + push_text_crlf(&mut out, text); + out.extend_from_slice(b"\x1b[0m"); + } + MessageKind::Reasoning => { + out.extend_from_slice(b"\x1b[2m"); + push_text_crlf(&mut out, text); + out.extend_from_slice(b"\x1b[0m"); + } + } + out +} + fn synth_block(block: &ToolBlock, cols: u16) -> SynthOutput { /// Placeholder string the zarvis adapter writes into a tool's /// `output` when it auto-backgrounds. Kept in sync via the @@ -1566,6 +1688,72 @@ mod tests { ); } + #[test] + fn feed_message_coalesces_runs_and_marks_breaks() { + let mut h = ItemHistory::new(); + // Streaming deltas of the same kind → one contiguous run; only the + // first item of a run sets `break_before` (and not the very first + // item in an empty history). + h.feed_message(MessageKind::Assistant, "Hel"); + h.feed_message(MessageKind::Assistant, "lo"); + h.feed_message(MessageKind::Reasoning, "thinking"); + h.feed_message(MessageKind::Assistant, "done"); + let breaks: Vec<(MessageKind, bool)> = h + .items + .iter() + .filter_map(|i| match i { + Item::Message { + kind, break_before, .. + } => Some((*kind, *break_before)), + _ => None, + }) + .collect(); + assert_eq!( + breaks, + vec![ + (MessageKind::Assistant, false), // first item ever + (MessageKind::Assistant, false), // continues the run + (MessageKind::Reasoning, true), // kind change → new run + (MessageKind::Assistant, true), // kind change → new run + ] + ); + // Empty deltas are ignored. + let before = h.items.len(); + h.feed_message(MessageKind::Assistant, ""); + assert_eq!(h.items.len(), before); + } + + #[test] + fn headless_message_history_renders_prose_and_tool_blocks() { + // A headless session emits structured Message/ToolUse/ToolResult + // with no PTY. The TUI must render the assistant prose (streamed as + // deltas) interleaved with tool blocks — none of which is in a PTY. + let mut h = ItemHistory::new(); + h.feed_message(MessageKind::Assistant, "Looking "); + h.feed_message(MessageKind::Assistant, "into it."); + h.feed_task_start("c1".into(), "shell".into(), "ls".into()); + h.feed_tool_result("c1", true, "file.txt".into()); + h.feed_message(MessageKind::Assistant, "Found one file."); + + let text = screen_text(h.replay(60, 16, 0).screen, 16, 60); + assert!(text.contains("Looking into it."), "assistant prose missing:\n{text}"); + assert!(text.contains("→ shell("), "tool block missing:\n{text}"); + assert!(text.contains("file.txt"), "tool output missing:\n{text}"); + assert!(text.contains("Found one file."), "post-tool prose missing:\n{text}"); + } + + #[test] + fn reasoning_and_user_messages_render() { + let mut h = ItemHistory::new(); + h.feed_message(MessageKind::User, "do the thing"); + h.feed_message(MessageKind::Reasoning, "let me think"); + h.feed_message(MessageKind::Assistant, "ok"); + let text = screen_text(h.replay(60, 12, 0).screen, 12, 60); + assert!(text.contains("❯ do the thing"), "user prompt missing:\n{text}"); + assert!(text.contains("let me think"), "reasoning missing:\n{text}"); + assert!(text.contains("ok"), "assistant missing:\n{text}"); + } + #[test] fn tool_header_uses_bright_tool_name_style() { let block = ToolBlock { diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 64b451fa..58f9ca4d 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -4123,7 +4123,7 @@ fn short_id(id: &str) -> &str { &id[..n] } -fn is_headless(s: &agentd_protocol::SessionSummary) -> bool { +pub fn is_headless(s: &agentd_protocol::SessionSummary) -> bool { matches!(s.mode.as_deref(), Some("headless")) }