diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index f91071d7..7377d553 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,106 @@ 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" + // 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..2f79ae12 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,25 +1703,25 @@ 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 - }); - let prompt_style = Style::default() - .fg(app.theme.matrix_dim) - .add_modifier(Modifier::BOLD); + // 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::Reset); 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 }