From 3419f76b199a4a915bb2de2b2ab2509163b9af30 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 6 Jun 2026 13:18:58 -0700 Subject: [PATCH 1/3] tui: fix operator monolog showing only the last delta; drop the label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two issues from dogfooding the typewriter monolog (#379): 1. It displayed only the tail of a message -- e.g. "ed" for "noted" (which should be filtered entirely). Cause: `AgentStatus active=true` fires on EVERY delta (a per-token "Working" heartbeat), and the TUI cleared the utterance accumulator on every active=true. So a message streamed as "not"+"ed" lost "not" and finalized as "ed". This truncated ALL multi-delta monologs to their last fragment, not just "noted". Fix: don't clear on active=true. The accumulator is already cleared at turn end (active=false, which fires once per turn), so each turn starts clean. Now the full text accumulates -> "noted" is filtered, findings show in full. 2. Dropped the "operator ▸" label -- the matrix panel title already says "operator", so it was redundant. Regression test feeds the exact heartbeat/delta sequence (working, "not", working, "ed", worked) and asserts "noted" is filtered (not "ed"), plus a finding across deltas yields the full "session blocked". Render test updated for the removed label. --- crates/cli/src/app.rs | 86 ++++++++++++++++++++++++++++++++++++++++--- crates/cli/src/ui.rs | 12 ++---- 2 files changed, 85 insertions(+), 13 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index f91071d7..484d655b 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -3575,10 +3575,13 @@ impl App { let is_orchestrator = self.orchestrator_id.as_deref() == Some(payload.session_id.as_str()); if status.active { - // New orchestrator turn — start a fresh utterance. - if is_orchestrator { - self.operator_utterance.clear(); - } + // NOTE: `active=true` fires on *every* delta (a + // per-token "Working" heartbeat), not just at + // turn start — so we must NOT clear the utterance + // here, or only the final delta would survive + // (e.g. "noted" → "ed"). The accumulator is + // cleared at turn end (finalize, below), so each + // turn already starts clean. self.agent_statuses .insert(payload.session_id.clone(), status.clone()); } else { @@ -8539,8 +8542,9 @@ mod tests { .expect("draw"); assert!(showing, "monolog should be showing mid-cycle"); let screen = rendered_text(terminal.backend().buffer()); - assert!(screen.contains("operator"), "missing prompt:\n{screen}"); assert!(screen.contains("session"), "missing typed text:\n{screen}"); + // No "operator ▸" label — the matrix panel title already says "operator". + assert!(!screen.contains("▸"), "label should be gone:\n{screen}"); // Far past type+hold+fade: clears itself and yields the rain. app.operator_monolog = Some(OperatorMonolog { @@ -8556,6 +8560,78 @@ mod tests { server.abort(); } + #[tokio::test] + async fn operator_monolog_accumulates_across_delta_heartbeats() { + // `AgentStatus active=true` fires on every delta (a per-token "Working" + // heartbeat), so the utterance must accumulate across them, not reset — + // otherwise only the final delta survives ("noted" → "ed"). + let (mut app, _dir, server) = empty_app().await; + app.orchestrator_id = Some("op".into()); + + async fn feed(app: &mut App, event: SessionEvent, seq: u64) { + let n = Notification { + jsonrpc: "2.0".into(), + method: agentd_protocol::ipc_notif::EVENT.into(), + params: Some( + serde_json::to_value(EventNotificationPayload { + session_id: "op".into(), + at: chrono::Utc::now(), + event, + seq, + }) + .unwrap(), + ), + }; + app.on_notification(n).await; + } + fn working() -> SessionEvent { + SessionEvent::AgentStatus(agentd_protocol::AgentStatus { + active: true, + started_at_ms: 1, + status: "Working".into(), + }) + } + fn worked() -> SessionEvent { + SessionEvent::AgentStatus(agentd_protocol::AgentStatus { + active: false, + started_at_ms: 1, + status: "Worked".into(), + }) + } + fn say(t: &str) -> SessionEvent { + SessionEvent::Message { + role: MessageRole::Assistant, + text: t.into(), + } + } + + // "noted" as two deltas, each preceded by a heartbeat → full "noted" + // accumulates → filtered → no monolog (NOT the bare tail "ed"). + feed(&mut app, working(), 1).await; + feed(&mut app, say("not"), 2).await; + feed(&mut app, working(), 3).await; + feed(&mut app, say("ed"), 4).await; + feed(&mut app, worked(), 5).await; + assert!( + app.operator_monolog.is_none(), + "noted must be filtered, got {:?}", + app.operator_monolog.as_ref().map(|m| &m.text) + ); + + // A real finding across deltas → monolog gets the FULL text, not a tail. + feed(&mut app, working(), 6).await; + feed(&mut app, say("session "), 7).await; + feed(&mut app, working(), 8).await; + feed(&mut app, say("blocked"), 9).await; + feed(&mut app, worked(), 10).await; + assert_eq!( + app.operator_monolog.as_ref().map(|m| m.text.as_str()), + Some("session blocked") + ); + + server.abort(); + } + #[tokio::test] async fn empty_tui_renders_welcome_and_modeline_hint() { let (mut app, _dir, server) = empty_app().await; diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index b6c11fb8..555a016e 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -1704,20 +1704,16 @@ pub(crate) fn render_operator_monolog( } else { app.theme.matrix_glow }); - let prompt_style = Style::default() - .fg(app.theme.matrix_dim) - .add_modifier(Modifier::BOLD); let inner = Rect { x: area.x + 2, y: area.y + 1, width: area.width.saturating_sub(4), height: area.height.saturating_sub(2), }; - let lines = vec![ - Line::from(Span::styled("operator ▸", prompt_style)), - Line::from(Span::styled(body, text_style)), - ]; - f.render_widget(Paragraph::new(lines).wrap(Wrap { trim: false }), inner); + f.render_widget( + Paragraph::new(Span::styled(body, text_style)).wrap(Wrap { trim: false }), + inner, + ); true } From 75f6b3eb2c750e38acab9977d0ee8bacdd4f62e0 Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 6 Jun 2026 13:25:09 -0700 Subject: [PATCH 2/3] tui: overlay the operator monolog on the rain; skip it when the panel is open Three refinements from dogfooding: 1. The typewriter took over the rain body (Clear + skip the rain render), so the rain froze and restarted from empty when the text cleared. Make it an OVERLAY instead: the rain renders every frame (state keeps advancing) and the text draws on top, so it never restarts. Moved the call after the rain pass. 2. Skip the overlay while the orchestrator panel (minibuffer) is open -- the operator's text is already visible right below, so overlaying it on the rain just duplicates it. 3. Widgets are unaffected: render_matrix_widget_viewport runs in its own pass after the monolog, independent of the panel/monolog, so the operator can show widgets whether the panel is open or collapsed. Tests: skip-while-panel-open (returns false, draws nothing); existing render + accumulation tests still pass. 4 monolog tests green. --- crates/cli/src/app.rs | 28 ++++++++++++++++++++++++++ crates/cli/src/ui.rs | 47 +++++++++++++++++++++++++------------------ 2 files changed, 55 insertions(+), 20 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 484d655b..7377d553 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -8560,6 +8560,34 @@ mod tests { server.abort(); } + #[tokio::test] + async fn operator_monolog_skipped_while_orchestrator_panel_open() { + let (mut app, _dir, server) = empty_app().await; + let backend = ratatui::backend::TestBackend::new(60, 12); + let mut terminal = ratatui::Terminal::new(backend).expect("terminal"); + let area = Rect::new(0, 0, 60, 12); + app.operator_monolog = Some(OperatorMonolog { + text: "session waiting at trust prompt".into(), + started_at: Instant::now() - std::time::Duration::from_millis(500), + }); + // Orchestrator panel open → the text is visible below, so don't overlay. + app.minibuffer = Some(Minibuffer { + prompt: String::new(), + input: String::new(), + cursor: 0, + intent: MinibufferIntent::Orchestrator, + error: None, + }); + let mut drew = true; + terminal + .draw(|f| drew = crate::ui::render_operator_monolog(f, area, &mut app, Instant::now())) + .expect("draw"); + assert!(!drew, "monolog should be skipped while the panel is open"); + let screen = rendered_text(terminal.backend().buffer()); + assert!(!screen.contains("session"), "should not draw over rain:\n{screen}"); + server.abort(); + } + #[tokio::test] async fn operator_monolog_accumulates_across_delta_heartbeats() { // `AgentStatus active=true` fires on every delta (a per-token "Working" diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 555a016e..54c8c065 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -1470,15 +1470,6 @@ fn render_matrix_rain(f: &mut Frame, rain_area: Rect, app: &mut App) { return; } - // Operator monolog: when the orchestrator has just said something, take - // over the rain body with a monochrome typewriter line. Once it finishes - // the type→hold→fade cycle it clears itself and the rain resumes; widgets - // still render on top so an open widget isn't hidden. - if render_operator_monolog(f, rain_area, app, now) { - render_matrix_widget_viewport(f, rain_area, app, now); - return; - } - // Wallpaper: paint the most recent browser preview from ANY session // (cross-session — the matrix rain is a fleet visualization, so the // backdrop reflects the whole fleet, not just the focused session, @@ -1657,6 +1648,11 @@ fn render_matrix_rain(f: &mut Frame, rain_area: Rect, app: &mut App) { // Hover tooltip: if the cursor is over a horizontal word, name the // session it came from. Drawn last so it sits on top of the rain. render_matrix_reveal_tooltip(f, rain_area, app); + // Operator monolog: overlaid on the still-running rain (not a takeover), so + // the rain keeps animating underneath and doesn't restart when the text + // clears. Skipped while the orchestrator panel is open (the text is already + // visible right below). Widgets render after, on top. + render_operator_monolog(f, rain_area, app, now); render_matrix_widget_viewport(f, rain_area, app, now); } @@ -1665,9 +1661,13 @@ const MONOLOG_MS_PER_CHAR: u64 = 28; const MONOLOG_HOLD_MS: u64 = 4200; const MONOLOG_FADE_MS: u64 = 800; -/// Typewriter-render the operator's latest monolog over the matrix-rain body, -/// then clear it (returning `false`) once the type → hold → fade cycle -/// completes, so the rain resumes. Returns `true` while it's showing. +/// Overlay the operator's latest monolog on top of the (still-running) matrix +/// rain as a typewriter line, clearing it once the type → hold → fade cycle +/// completes. Returns `true` if it drew this frame. Crucially this is an +/// *overlay*, not a takeover: the rain is rendered every frame regardless, so +/// it keeps animating underneath and never restarts when the text clears. +/// Skipped while the orchestrator panel is open — the operator's text is +/// already visible right below in the panel, so the overlay would duplicate it. pub(crate) fn render_operator_monolog( f: &mut Frame, area: Rect, @@ -1686,12 +1686,16 @@ pub(crate) fn render_operator_monolog( app.operator_monolog = None; return false; } + if matches!( + app.minibuffer.as_ref().map(|m| &m.intent), + Some(MinibufferIntent::Orchestrator) + ) { + return false; // duplicate of the open panel below + } if area.width < 12 || area.height < 3 { - return true; // showing — suppress the rain even if too small to draw + return false; } - // Monochrome terminal takeover: clear the rain body, then the prompt + text. - f.render_widget(Clear, area); let shown = ((elapsed / MONOLOG_MS_PER_CHAR) as usize).min(chars.len()); let mut body: String = chars[..shown].iter().collect(); let typing = (shown as u64) < n; @@ -1699,11 +1703,14 @@ pub(crate) fn render_operator_monolog( body.push('▌'); // blinking cursor while typing } let fading = elapsed >= type_ms + MONOLOG_HOLD_MS; - let text_style = Style::default().fg(if fading { - app.theme.matrix_dim - } else { - app.theme.matrix_glow - }); + // Solid bg so the typed line reads cleanly as an overlay over the rain. + let text_style = Style::default() + .fg(if fading { + app.theme.matrix_dim + } else { + app.theme.matrix_glow + }) + .bg(Color::Black); let inner = Rect { x: area.x + 2, y: area.y + 1, From 46022a55ae016d56ca998d396e6bfae0cd2979fe Mon Sep 17 00:00:00 2001 From: Edwin Date: Sat, 6 Jun 2026 13:37:53 -0700 Subject: [PATCH 3/3] tui: match operator monolog bg to the rain backdrop (subtle overlay) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The overlay text used a solid Color::Black background, which read as a highlight band over the rain. Use Color::Reset (terminal default — the rain's own backdrop) so the typed line blends in as a subtle overlay. --- crates/cli/src/ui.rs | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 54c8c065..2f79ae12 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -1703,14 +1703,15 @@ pub(crate) fn render_operator_monolog( body.push('▌'); // blinking cursor while typing } let fading = elapsed >= type_ms + MONOLOG_HOLD_MS; - // Solid bg so the typed line reads cleanly as an overlay over the rain. + // Match the rain's backdrop (terminal default) rather than a solid band, so + // the typed line reads as a subtle overlay instead of a highlight. let text_style = Style::default() .fg(if fading { app.theme.matrix_dim } else { app.theme.matrix_glow }) - .bg(Color::Black); + .bg(Color::Reset); let inner = Rect { x: area.x + 2, y: area.y + 1,