From 68e6a3497256696af9547fe847e21f6ec1770b85 Mon Sep 17 00:00:00 2001 From: Edwin Date: Fri, 22 May 2026 13:21:46 -0700 Subject: [PATCH 1/2] feat(tui): matrix-rain horizontal words link to their session (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Horizontal reveal words in the matrix-rain panel are now interactive: hover shows a tooltip naming the session that produced the word, and a click switches the selection to that session. - Tag each `RevealWord` with the `session_id` it was harvested from, threaded through every queue path (`observe_pty_activity`, `observe_event`, `observe_tool_decision`). - The renderer records each horizontal word's screen span (the whole word — interactive even before all letters have pinned in) as a `MatrixRevealHit` on the App each frame, cleared up front so a hidden/too-small panel can't leave stale targets. - Hover (mouse motion is already captured) draws a one-line tooltip on the row above the word: " · <harness> · <state>", or "<word> · session ended" if the session is gone. - Click resolves the hit in `handle_left_click` and selects the source session (focusing the list, matching a list-row click); a click on a word whose session has ended is a no-op with a status message. Tests: reveal words carry their session id (PTY + structured-event paths); clicking a recorded hit switches selection, and a click for a missing session leaves selection unchanged. --- crates/cli/src/app.rs | 121 ++++++++++++++++++++++++++++++++-- crates/cli/src/matrix_rain.rs | 99 +++++++++++++++++++++++----- crates/cli/src/ui.rs | 77 ++++++++++++++++++++-- 3 files changed, 272 insertions(+), 25 deletions(-) diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 6818f33a..c802c355 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -200,6 +200,23 @@ pub struct TextSelectionRange { pub end: ScreenPoint, } +/// A matrix-rain horizontal reveal word's clickable span on screen. +/// `col_start..=col_end` at `row` (absolute terminal coords). +#[derive(Debug, Clone)] +pub struct MatrixRevealHit { + pub col_start: u16, + pub col_end: u16, + pub row: u16, + pub text: String, + pub session_id: String, +} + +impl MatrixRevealHit { + pub fn contains(&self, col: u16, row: u16) -> bool { + row == self.row && col >= self.col_start && col <= self.col_end + } +} + pub struct App { pub client: Arc<Client>, pub sessions: Vec<SessionSummary>, @@ -242,6 +259,11 @@ pub struct App { /// after each `replay`. Mouse clicks in the PTY pane consult /// this to toggle the right block. pub block_hits: HashMap<String, Vec<crate::pty_render::BlockHitRect>>, + /// Screen rects of the matrix-rain horizontal reveal words rendered + /// this frame, each tagged with the session that produced the word. + /// Written by `render_matrix_rain`, consumed by mouse hover (tooltip) + /// and click (switch to the session). Reset every frame. + pub matrix_reveal_hits: Vec<MatrixRevealHit>, /// The orchestrator panel's most recent inner (cols, rows) as /// computed during render. Written by `ui::render`, consumed by /// `run_loop`'s debounce — once the value stays stable for @@ -913,6 +935,7 @@ pub async fn run_with_socket(socket: std::path::PathBuf) -> Result<()> { view: ViewMode::Transcript, histories: HashMap::new(), block_hits: HashMap::new(), + matrix_reveal_hits: Vec::new(), orchestrator_desired_size: None, tasks_popup: None, remote_control_popup: None, @@ -1811,8 +1834,11 @@ impl App { m if m == agentd_protocol::ipc_notif::EVENT => { if let Some(p) = n.params { if let Ok(payload) = serde_json::from_value::<EventNotificationPayload>(p) { - self.matrix_rain - .observe_event(&payload.event, self.matrix_rain_intensity); + self.matrix_rain.observe_event( + &payload.event, + self.matrix_rain_intensity, + &payload.session_id, + ); // Tool-approval prompt: if no minibuffer is in use, // open the approval prompt for the matching session. // Otherwise the user sees the request in the @@ -2590,6 +2616,24 @@ impl App { } return; } + // Matrix-rain horizontal reveal word: jump to the session that + // produced it (issue #140). Checked before the pane hit-tests — + // the rain panel is its own region, so this never shadows a real + // list/view click. + if let Some(hit) = self + .matrix_reveal_hits + .iter() + .find(|h| h.contains(col, row)) + .cloned() + { + if self.sessions.iter().any(|s| s.id == hit.session_id) { + self.focus = PaneFocus::List; + self.select_session(hit.session_id); + } else { + self.set_status(format!("session for \u{201c}{}\u{201d} has ended", hit.text)); + } + return; + } if let Some(mb_area) = self.layout.minibuffer_area { if contains(mb_area, col, row) { // First check the inline-hint zones ("C-x z unzoom" / @@ -3755,8 +3799,11 @@ impl App { self.minibuffer = None; match self.client.tool_decision(&session_id, call_id, d).await { Ok(()) => { - self.matrix_rain - .observe_tool_decision(d, self.matrix_rain_intensity); + self.matrix_rain.observe_tool_decision( + d, + self.matrix_rain_intensity, + &session_id, + ); self.set_status(format!("tool {d}")); } Err(e) => self.set_status(format!("tool_decision failed: {e}")), @@ -4081,8 +4128,11 @@ impl App { { self.set_status(format!("tool_decision failed: {e}")); } else { - self.matrix_rain - .observe_tool_decision("approve", self.matrix_rain_intensity); + self.matrix_rain.observe_tool_decision( + "approve", + self.matrix_rain_intensity, + &session_id, + ); } } } @@ -4806,6 +4856,7 @@ mod tests { view: ViewMode::Terminal, histories: HashMap::new(), block_hits: HashMap::new(), + matrix_reveal_hits: Vec::new(), orchestrator_desired_size: None, terminal_pane_size: (80, 24), zoom: ZoomMode::None, @@ -4913,6 +4964,64 @@ mod tests { server.abort(); } + // issue #140: clicking a matrix-rain horizontal reveal word switches + // the selection to the session that produced it; clicking a word + // whose session has ended is a no-op (just a status message). + #[tokio::test] + async fn matrix_reveal_click_switches_to_source_session() { + use tokio::net::UnixListener; + + let dir = tempfile::tempdir().expect("tempdir"); + let sock = dir.path().join("agentd.sock"); + let listener = UnixListener::bind(&sock).expect("bind mock daemon"); + let server = tokio::spawn(async move { + let _ = listener.accept().await; + futures::future::pending::<()>().await; + }); + let client = Client::connect(&sock).await.expect("client connects"); + + let mut s1 = summary_with_kind(agentd_protocol::SessionKind::User); + s1.id = "s1".into(); + let mut s2 = summary_with_kind(agentd_protocol::SessionKind::User); + s2.id = "s2".into(); + let mut app = test_app(client, vec![s1, s2]); + assert_eq!(app.selection.session_id(), Some("s1")); + + app.matrix_reveal_hits = vec![MatrixRevealHit { + col_start: 5, + col_end: 10, + row: 20, + text: "deploy".into(), + session_id: "s2".into(), + }]; + // Click inside the word span -> switch to s2. + app.handle_left_click(7, 20).await; + assert_eq!( + app.selection.session_id(), + Some("s2"), + "click on a reveal word switches to its session" + ); + // Click outside the span -> no change. + app.handle_left_click(30, 20).await; + assert_eq!(app.selection.session_id(), Some("s2")); + + // Word whose session has ended -> no switch. + app.matrix_reveal_hits = vec![MatrixRevealHit { + col_start: 5, + col_end: 10, + row: 20, + text: "ghost".into(), + session_id: "gone".into(), + }]; + app.handle_left_click(7, 20).await; + assert_eq!( + app.selection.session_id(), + Some("s2"), + "click for a missing session must not switch selection" + ); + server.abort(); + } + // Regression: switching to a session showed "(no PTY history yet)" // even though history existed, until a keystroke recreated the entry // from a live PTY event. Root cause: once the transcript was diff --git a/crates/cli/src/matrix_rain.rs b/crates/cli/src/matrix_rain.rs index 01427f95..392cab91 100644 --- a/crates/cli/src/matrix_rain.rs +++ b/crates/cli/src/matrix_rain.rs @@ -83,6 +83,11 @@ pub struct RevealWord { resolved_col: Option<u16>, /// Absolute starting row, locked alongside `resolved_col`. resolved_row: Option<u16>, + /// Session that produced this word (the PTY chunk / event it was + /// harvested from). Lets the renderer make horizontal words + /// hover/click targets that link back to their session. `None` for + /// words not tied to a specific session. + session_id: Option<String>, } impl RevealWord { @@ -132,6 +137,11 @@ impl RevealWord { pub fn resolved_position(&self) -> Option<(u16, u16)> { Some((self.resolved_col?, self.resolved_row?)) } + + /// Session id this word was harvested from, if any. + pub fn session_id(&self) -> Option<&str> { + self.session_id.as_deref() + } } #[derive(Debug, Default, Clone)] @@ -171,9 +181,9 @@ impl MatrixRain { .filter(move |word| word.progress(now).is_some()) } - pub fn observe_event(&mut self, event: &SessionEvent, intensity: f32) { + pub fn observe_event(&mut self, event: &SessionEvent, intensity: f32, session_id: &str) { if let Some((text, tone, priority)) = word_for_event(event) { - self.queue_random(text, tone, priority, intensity); + self.queue_random(text, tone, priority, intensity, Some(session_id)); } } @@ -222,7 +232,16 @@ impl MatrixRain { }); let (x, y) = random_position(&text, self.queue.len()); let orientation = pick_orientation(&text, intensity); - self.queue_at(text, FlashTone::Work, x, y, 25, orientation, now); + self.queue_at( + text, + FlashTone::Work, + x, + y, + 25, + orientation, + now, + Some(session_id.to_string()), + ); } /// Forget per-session throttle + extracted-word state. Call when @@ -234,18 +253,25 @@ impl MatrixRain { self.pty_word_pool.remove(session_id); } - pub fn observe_tool_decision(&mut self, decision: &str, intensity: f32) { + pub fn observe_tool_decision(&mut self, decision: &str, intensity: f32, session_id: &str) { match decision { "approve" | "automode" => { - self.queue_random("approved", FlashTone::Good, 95, intensity) + self.queue_random("approved", FlashTone::Good, 95, intensity, Some(session_id)) } - "deny" => self.queue_random("denied", FlashTone::Bad, 95, intensity), + "deny" => self.queue_random("denied", FlashTone::Bad, 95, intensity, Some(session_id)), _ => {} } } - fn queue_random(&mut self, text: &'static str, tone: FlashTone, priority: u8, intensity: f32) { - self.queue_random_at(text, tone, priority, intensity, Instant::now()); + fn queue_random( + &mut self, + text: &'static str, + tone: FlashTone, + priority: u8, + intensity: f32, + session_id: Option<&str>, + ) { + self.queue_random_at(text, tone, priority, intensity, Instant::now(), session_id); } fn queue_random_at( @@ -255,10 +281,20 @@ impl MatrixRain { priority: u8, intensity: f32, now: Instant, + session_id: Option<&str>, ) { let (x, y) = random_position(text, self.queue.len()); let orientation = pick_orientation(text, intensity); - self.queue_at(text, tone, x, y, priority, orientation, now); + self.queue_at( + text, + tone, + x, + y, + priority, + orientation, + now, + session_id.map(str::to_string), + ); } pub fn queue( @@ -269,10 +305,12 @@ impl MatrixRain { y: f32, priority: u8, orientation: RevealOrientation, + session_id: Option<String>, ) { - self.queue_at(text, tone, x, y, priority, orientation, Instant::now()); + self.queue_at(text, tone, x, y, priority, orientation, Instant::now(), session_id); } + #[allow(clippy::too_many_arguments)] fn queue_at( &mut self, text: impl Into<String>, @@ -282,6 +320,7 @@ impl MatrixRain { priority: u8, orientation: RevealOrientation, now: Instant, + session_id: Option<String>, ) { self.queue.retain(|word| !word.expired(now)); // Horizontal reveals need *every* column under the word to @@ -307,6 +346,7 @@ impl MatrixRain { pin_state, resolved_col: None, resolved_row: None, + session_id, }); while self.queue.len() > MAX_ACTIVE_REVEALS { if let Some((idx, _)) = self @@ -593,6 +633,7 @@ mod tests { detail: None, }, 1.0, + "s1", ); rain.observe_event( &SessionEvent::ToolApprovalRequest { @@ -602,6 +643,7 @@ mod tests { risk: ToolRisk::Risky, }, 1.0, + "s1", ); rain.observe_event( &SessionEvent::Message { @@ -609,6 +651,7 @@ mod tests { text: "low signal".into(), }, 1.0, + "s1", ); assert_eq!( rain.active_reveal(Instant::now()).map(|f| f.text.as_str()), @@ -616,10 +659,36 @@ mod tests { ); } + #[test] + fn pty_activity_tags_reveal_with_session() { + let mut rain = MatrixRain::default(); + let now = Instant::now(); + rain.observe_pty_activity("sess-xyz", b"deploying service modules", now, 1.0); + let w = rain.active_reveal(now).expect("reveal word"); + assert_eq!(w.session_id(), Some("sess-xyz")); + } + + #[test] + fn observe_event_tags_reveal_with_session() { + let mut rain = MatrixRain::default(); + rain.observe_event( + &SessionEvent::ToolApprovalRequest { + call_id: "c".into(), + tool: "shell".into(), + args_summary: "x".into(), + risk: ToolRisk::Risky, + }, + 1.0, + "sess-abc", + ); + let w = rain.active_reveal(Instant::now()).expect("reveal word"); + assert_eq!(w.session_id(), Some("sess-abc")); + } + #[test] fn queue_sets_target_position() { let mut rain = MatrixRain::default(); - rain.queue("matrix", FlashTone::Work, 0.2, 0.8, 10, RevealOrientation::Horizontal); + rain.queue("matrix", FlashTone::Work, 0.2, 0.8, 10, RevealOrientation::Horizontal, None); let reveal = rain.active_reveal(Instant::now()).expect("reveal word"); assert_eq!(reveal.text, "matrix"); assert_eq!(reveal.x, 0.2); @@ -629,8 +698,8 @@ mod tests { #[test] fn multiple_reveals_can_be_active_together() { let mut rain = MatrixRain::default(); - rain.queue("working", FlashTone::Work, 0.2, 0.4, 10, RevealOrientation::Horizontal); - rain.queue("worked", FlashTone::Good, 0.6, 0.7, 20, RevealOrientation::Horizontal); + rain.queue("working", FlashTone::Work, 0.2, 0.4, 10, RevealOrientation::Horizontal, None); + rain.queue("worked", FlashTone::Good, 0.6, 0.7, 20, RevealOrientation::Horizontal, None); let active: Vec<_> = rain .active_reveals(Instant::now()) @@ -642,8 +711,8 @@ mod tests { #[test] fn active_reveal_reports_highest_priority_word() { let mut rain = MatrixRain::default(); - rain.queue("working", FlashTone::Work, 0.2, 0.4, 10, RevealOrientation::Horizontal); - rain.queue("failed", FlashTone::Bad, 0.6, 0.7, 100, RevealOrientation::Horizontal); + rain.queue("working", FlashTone::Work, 0.2, 0.4, 10, RevealOrientation::Horizontal, None); + rain.queue("failed", FlashTone::Bad, 0.6, 0.7, 100, RevealOrientation::Horizontal, None); assert_eq!( rain.active_reveal(Instant::now()) diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index d95b53e5..a8259fa5 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -1212,6 +1212,10 @@ fn split_list_pane( fn render_matrix_rain(f: &mut Frame, rain_area: Rect, app: &mut App) { app.layout.matrix_rain_area = None; + // Reset hover/click targets every frame, including the early-return + // paths below — otherwise a hidden/too-small panel would leave stale + // hits from a prior frame clickable. + app.matrix_reveal_hits.clear(); if app.matrix_rain_hidden { return; } @@ -1355,13 +1359,65 @@ fn render_matrix_rain(f: &mut Frame, rain_area: Rect, app: &mut App) { .retain(|key, _| current_drop_keys.contains(key)); let theme = app.theme.clone(); + let mut hits: Vec<crate::app::MatrixRevealHit> = Vec::new(); for reveal in app.matrix_rain.active_reveals_mut(now) { if let crate::matrix_rain::RevealOrientation::Horizontal = reveal.orientation { - render_matrix_reveal_horizontal(f, rain_area, &theme, reveal, elapsed, &drop_heads); + if let Some(hit) = + render_matrix_reveal_horizontal(f, rain_area, &theme, reveal, elapsed, &drop_heads) + { + hits.push(hit); + } } // Vertical reveals are rendered inline above as a drop-body // letter overlay — no separate pass / no pin-and-hold. } + app.matrix_reveal_hits = hits; + // 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); +} + +/// 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_reveal_tooltip(f: &mut Frame, rain_area: Rect, app: &App) { + let Some((mx, my)) = app.mouse_pos else { + return; + }; + let Some(hit) = app.matrix_reveal_hits.iter().find(|h| h.contains(mx, my)) else { + return; + }; + let label = match app.sessions.iter().find(|s| s.id == hit.session_id) { + Some(s) => { + let harness = harness_label(s); + // Title if the session has a distinct one, else just the + // harness; append harness too when a title exists so the + // tooltip says both (e.g. "fix auth · zarvis · running"). + let title = s.title.as_deref().filter(|t| !t.is_empty()); + let name = match title { + Some(t) => format!("{t} · {harness}"), + None => harness, + }; + format!(" {name} · {} ", s.state.label()) + } + None => format!(" {} · session ended ", hit.text), + }; + let label: String = label.chars().take(rain_area.width.saturating_sub(1) as usize).collect(); + let w = label.chars().count() as u16; + // Prefer the row above the word; fall back to below at the panel top. + let ty = if hit.row > rain_area.y { + hit.row - 1 + } else { + (hit.row + 1).min(rain_area.y + rain_area.height.saturating_sub(1)) + }; + let max_x = rain_area.x + rain_area.width.saturating_sub(w); + let tx = hit.col_start.min(max_x).max(rain_area.x); + let area = Rect { x: tx, y: ty, width: w, height: 1 }; + let style = Style::default() + .fg(app.theme.highlight_fg) + .bg(app.theme.highlight_bg) + .add_modifier(Modifier::BOLD); + f.render_widget(Clear, area); + f.render_widget(Paragraph::new(Line::from(Span::styled(label, style))), area); } /// First-frame placement for vertical reveals: pick the absolute @@ -1653,14 +1709,14 @@ fn render_matrix_reveal_horizontal( reveal: &mut crate::matrix_rain::RevealWord, elapsed_ms: u64, drop_heads: &[Option<i16>], -) { +) -> Option<crate::app::MatrixRevealHit> { if area.width < 4 || area.height == 0 { - return; + return None; } let chars: Vec<char> = reveal.text.chars().collect(); let text_w = chars.len() as u16; if text_w == 0 || text_w + 2 > area.width { - return; + return None; } // Lock the absolute (col, row) on the first frame so already- // pinned letters don't drift if the area resizes mid-reveal. @@ -1717,6 +1773,19 @@ fn render_matrix_reveal_horizontal( |i| target_x + i as u16, |_| target_y, ); + + // The whole word span is a hover/click target — even while letters + // are still pinning in — so the user can always reach the source + // session. Only words tagged with a session are interactive. + reveal + .session_id() + .map(|sid| crate::app::MatrixRevealHit { + col_start: target_x, + col_end: target_x + text_w - 1, + row: target_y, + text: reveal.text.clone(), + session_id: sid.to_string(), + }) } /// Brightness / hold / fade pipeline for horizontal reveals. Once From c4cb00c86728201b90427e2fb7f4251ebebe4e6d Mon Sep 17 00:00:00 2001 From: Edwin <edwin@zarvis.ai> Date: Fri, 22 May 2026 13:30:02 -0700 Subject: [PATCH 2/2] fix(tui): drop status from matrix-rain word tooltip (#140) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tooltip now reads "<title> · <harness>" (or just "<harness>"); the session state was noisy and the list row already shows it. --- crates/cli/src/ui.rs | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index a8259fa5..5f4d7c71 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -1391,13 +1391,12 @@ fn render_matrix_reveal_tooltip(f: &mut Frame, rain_area: Rect, app: &App) { let harness = harness_label(s); // Title if the session has a distinct one, else just the // harness; append harness too when a title exists so the - // tooltip says both (e.g. "fix auth · zarvis · running"). + // tooltip says both (e.g. "fix auth · zarvis"). let title = s.title.as_deref().filter(|t| !t.is_empty()); - let name = match title { - Some(t) => format!("{t} · {harness}"), - None => harness, - }; - format!(" {name} · {} ", s.state.label()) + match title { + Some(t) => format!(" {t} · {harness} "), + None => format!(" {harness} "), + } } None => format!(" {} · session ended ", hit.text), };