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")) }