Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/adapter-zarvis/src/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
109 changes: 109 additions & 0 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<OperatorMonolog>,
/// 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<u16>,
Expand Down Expand Up @@ -777,6 +782,30 @@ pub struct EditorState {
pub completions: Vec<String>,
}

/// 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<String> {
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)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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;
Expand Down
70 changes: 70 additions & 0 deletions crates/cli/src/ui.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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::<Vec<char>>(), 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) {
Expand Down
27 changes: 27 additions & 0 deletions specs/0023-operator-monolog-typewriter.md
Original file line number Diff line number Diff line change
@@ -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.
Loading