diff --git a/crates/adapter-zarvis/src/agent.rs b/crates/adapter-zarvis/src/agent.rs index 21a0aaab..a4277e12 100644 --- a/crates/adapter-zarvis/src/agent.rs +++ b/crates/adapter-zarvis/src/agent.rs @@ -227,6 +227,8 @@ For `OBSERVATION: ambient operator loop tick`, act as an ambient companion. You Dynamic session UI: when a session/task benefits from compact status/actions, call `agentd_context` to discover `session_widgets.dir`, `session_widgets.action_link_scheme`, and supported `widget_markdown_extensions`, then create/update concise `.md` widget files there with normal file tools. Widget creation, updates, and cleanup are mostly automated system behavior: use best judgment and ask first only when normal safety/tool policy absolutely requires approval or the widget would make a significant product/user-facing decision. Use checklists, supported widget_markdown_extensions from `agentd_context`, and action links such as `[Open checks](agentd:action/open-checks)` or `[Open checks](agentd:action/open-checks?key=o)` when a keyboard shortcut is desired. Treat `OBSERVATION: ui.action ...` as user intent; actions still go through normal tools and approvals. +SURFACING: a short text reply is shown to the user as a brief typewriter "monolog" over your matrix animation that then fades — so a one-line heads-up reaches them even with the minibuffer panel closed. Use it for a quick, transient note ("X is waiting at a trust prompt — press Enter"). When the user should be able to act on something or keep it around, create/update a compact Operator widget instead (widgets persist; monologs fade). Reply exactly `noted` when nothing needs surfacing. + Be concise. The minibuffer panel is small; aim for one to three short lines per turn, longer only when the user explicitly asks for detail. Risky tool calls (delete / kill / send) still gate through approval unless the session is in unsafe-auto."#; /// Pick the right system prompt for this session's kind. The daemon diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 900e0a1c..3a598897 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -554,6 +554,11 @@ pub struct App { /// history does not leave the main session view scrolled when the panel /// closes. pub orchestrator_scrollback: usize, + /// Active operator monolog typewritten over the matrix rain (`None` = rain). + pub operator_monolog: Option, + /// Accumulates the orchestrator's streaming assistant text across the + /// current turn; consolidated into `operator_monolog` at turn end. + pub operator_utterance: String, /// User-preferred height for the daemon-owned orchestrator panel rendered /// in the minibuffer. Clamped by terminal height at render time. pub orchestrator_panel_h: Option, @@ -777,6 +782,30 @@ pub struct EditorState { pub completions: Vec, } +/// The operator's latest finalized utterance, typewritten over the matrix +/// rain as an ambient "monolog" then auto-cleared. Lets the user see what the +/// operator said without opening the (collapsed) minibuffer panel. +#[derive(Debug, Clone)] +pub struct OperatorMonolog { + pub text: String, + pub started_at: Instant, +} + +/// Consolidate the orchestrator's streaming assistant text into a user-facing +/// monolog line, or `None` if it's empty or the internal `noted` no-op token +/// (which the operator replies when nothing needs surfacing). +pub fn operator_monolog_text(raw: &str) -> Option { + let t = raw.trim(); + if t.is_empty() { + return None; + } + let lower = t.to_ascii_lowercase(); + if lower == "noted" || lower == "noted." { + return None; + } + Some(t.to_string()) +} + /// MRU cache of resized preview images, keyed by `(source Arc ptr, /// out_w, out_h)`. Lets the overlay and the matrix-rain wallpaper blit /// the same image every frame without re-running the (very expensive) @@ -1585,6 +1614,8 @@ pub async fn run_with_socket(socket: std::path::PathBuf) -> Result<()> { skip_redraw_after_event: false, hydrating_sessions: HashSet::new(), orchestrator_scrollback: 0, + operator_monolog: None, + operator_utterance: String::new(), orchestrator_panel_h: persisted.orchestrator_panel_h, resizing_orchestrator_panel: None, dragging_terminal_scrollbar: None, @@ -3446,6 +3477,15 @@ impl App { .or_default(); history.feed_message(kind, &text); } + // Accumulate the orchestrator's streaming assistant + // text; finalized into a typewriter monolog at turn + // end (the AgentStatus active=false handler below). + if matches!(kind, crate::pty_render::MessageKind::Assistant) + && self.orchestrator_id.as_deref() + == Some(payload.session_id.as_str()) + { + self.operator_utterance.push_str(&text); + } } // Adapter editor state — drives the fixed // bottom input pane. @@ -3522,11 +3562,30 @@ impl App { _ => {} } if let SessionEvent::AgentStatus(status) = &payload.event { + 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(); + } self.agent_statuses .insert(payload.session_id.clone(), status.clone()); } else { self.agent_statuses.remove(&payload.session_id); + // Turn end — consolidate the accumulated text into + // a single typewriter monolog over the matrix rain. + if is_orchestrator { + if let Some(text) = + operator_monolog_text(&self.operator_utterance) + { + self.operator_monolog = Some(OperatorMonolog { + text, + started_at: Instant::now(), + }); + } + self.operator_utterance.clear(); + } if let Some(bytes) = agent_status_history_line(status) { let history = self .histories @@ -7497,6 +7556,19 @@ mod tests { use super::*; use ratatui::layout::Rect; + #[test] + fn operator_monolog_text_filters_noise() { + assert_eq!(operator_monolog_text(""), None); + assert_eq!(operator_monolog_text(" "), None); + assert_eq!(operator_monolog_text("noted"), None); + assert_eq!(operator_monolog_text(" Noted. "), None); + assert_eq!( + operator_monolog_text(" 'run using zarvis' is waiting at the trust prompt. ") + .as_deref(), + Some("'run using zarvis' is waiting at the trust prompt.") + ); + } + /// Regression guard for the input-priority optimization (#2). /// /// Under heavy background PTY output `notifications.recv()` is @@ -7668,6 +7740,8 @@ mod tests { skip_redraw_after_event: false, hydrating_sessions: HashSet::new(), orchestrator_scrollback: 0, + operator_monolog: None, + operator_utterance: String::new(), orchestrator_panel_h: None, resizing_orchestrator_panel: None, dragging_terminal_scrollbar: None, @@ -8437,6 +8511,41 @@ mod tests { ); } + #[tokio::test] + async fn operator_monolog_typewriter_renders_then_expires() { + 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); + + // ~500ms in: several characters revealed, still showing. + app.operator_monolog = Some(OperatorMonolog { + text: "session waiting at trust prompt".into(), + started_at: Instant::now() - std::time::Duration::from_millis(500), + }); + let mut showing = false; + terminal + .draw(|f| showing = crate::ui::render_operator_monolog(f, area, &mut app, Instant::now())) + .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}"); + + // Far past type+hold+fade: clears itself and yields the rain. + app.operator_monolog = Some(OperatorMonolog { + text: "session waiting at trust prompt".into(), + started_at: Instant::now() - std::time::Duration::from_secs(30), + }); + terminal + .draw(|f| showing = crate::ui::render_operator_monolog(f, area, &mut app, Instant::now())) + .expect("draw"); + assert!(!showing, "monolog should have expired"); + assert!(app.operator_monolog.is_none(), "expired monolog not cleared"); + + 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 f68f2493..b6c11fb8 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -1470,6 +1470,15 @@ 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, @@ -1651,6 +1660,67 @@ fn render_matrix_rain(f: &mut Frame, rain_area: Rect, app: &mut App) { render_matrix_widget_viewport(f, rain_area, app, now); } +/// Reveal speed and post-typing dwell for the operator monolog. +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. +pub(crate) fn render_operator_monolog( + f: &mut Frame, + area: Rect, + app: &mut App, + now: Instant, +) -> bool { + // Snapshot what we need so the borrow ends before we may clear the state. + let (chars, started_at) = match app.operator_monolog.as_ref() { + Some(m) => (m.text.chars().collect::>(), m.started_at), + None => return false, + }; + let n = chars.len() as u64; + let elapsed = now.saturating_duration_since(started_at).as_millis() as u64; + let type_ms = n.saturating_mul(MONOLOG_MS_PER_CHAR); + if elapsed >= type_ms + MONOLOG_HOLD_MS + MONOLOG_FADE_MS { + app.operator_monolog = None; + return false; + } + if area.width < 12 || area.height < 3 { + return true; // showing — suppress the rain even if too small to draw + } + + // 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; + if typing && (elapsed / 450) % 2 == 0 { + 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); + 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); + true +} + /// If the mouse is hovering a matrix-rain horizontal reveal word, draw a /// one-line tooltip on an adjacent row naming the source session. fn render_matrix_widget_viewport(f: &mut Frame, rain_area: Rect, app: &mut App, now: Instant) { diff --git a/specs/0023-operator-monolog-typewriter.md b/specs/0023-operator-monolog-typewriter.md new file mode 100644 index 00000000..3530a55d --- /dev/null +++ b/specs/0023-operator-monolog-typewriter.md @@ -0,0 +1,27 @@ +# 0023-operator-monolog-typewriter + +Status: accepted +Date: 2026-06-06 +Area: tui +Scope: How the operator (orchestrator) surfaces its spoken messages to the user. + +## Decision + +The operator surfaces in two ways, and it chooses by *which event it emits*: + +- **Findings the user should act on or keep → an Operator widget** (`UiPanel`), the existing mechanism rendered in the matrix area. Persistent. +- **Monolog / "what I just did" narration → a typewriter line over the matrix rain.** When the orchestrator finishes a turn with substantive text, the matrix-rain body briefly becomes a monochrome terminal: the line types out, holds, fades, and the rain resumes. Ephemeral; nothing to open. + +The TUI consolidates the orchestrator's streaming assistant `Message` deltas across a turn into one finalized string at turn end (`AgentStatus active=false`), filters the internal `noted`/empty no-op token, and plays it once as the monolog. No new protocol event — `Message` (stream) vs `UiPanel` (widget) already distinguishes the two. + +## Reason + +The operator's text replies landed only in the daemon-owned orchestrator panel, which is collapsed by default (`orchestrator_panel_h: None`) — so a genuinely useful line ("'run using zarvis' is waiting at the folder trust prompt — press Enter") was invisible unless the user opened the panel. The matrix area is the operator's always-visible visual home, so surfacing its monolog there (without stealing focus or a panel) closes the gap. A typewriter over the rain is ambient: visible but not modal, and self-dismissing. + +## Consequences + +A short operator reply now reaches the user with the panel closed; the panel remains the full-detail/scrollback view. Only substantive replies show (`noted`/empty are dropped). One monolog at a time — a newer utterance replaces an older one. The monolog rides the existing matrix animation tick, so it needs no extra redraw machinery; it renders only while the matrix rain is visible. Widgets still render on top of a monolog, so an open widget isn't hidden. The orchestrator system prompt was updated to tell the operator its short text shows as a fading monolog and to use widgets for anything persistent/actionable. + +## Non-Goals + +Not a transient toast, OS notification, or auto-opening panel (considered, rejected as either missable or focus-stealing); not a new protocol/`SessionEvent` variant; does not change the orchestrator panel, the operator title/status, or the widget rendering path; does not surface non-orchestrator sessions' messages this way.