From f3f2fc28661b4eb1f88bdfbc991407a9302ffa27 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 10:05:14 +0300 Subject: [PATCH 01/14] fix(tui): anchor the Agents rail cursor to its row, not its offset Co-authored-by: Medulla --- src/tui/src/ui/app/commands/dispatch.rs | 2 +- src/tui/src/ui/app/harness_control.rs | 2 +- src/tui/src/ui/app/input/mouse.rs | 2 +- src/tui/src/ui/app/input/nav.rs | 15 +- src/tui/src/ui/app/keys/agents.rs | 4 +- src/tui/src/ui/app/mod.rs | 2 + src/tui/src/ui/app/rail.rs | 142 ++++++++- src/tui/src/ui/app/rail_tests.rs | 392 ++++++++++++++++++++++++ src/tui/src/ui/app/render/agents/mod.rs | 10 +- src/tui/src/ui/app/state.rs | 9 +- src/tui/src/ui/app/tests.rs | 4 +- src/tui/src/ui/app/types.rs | 15 + 12 files changed, 582 insertions(+), 17 deletions(-) create mode 100644 src/tui/src/ui/app/rail_tests.rs diff --git a/src/tui/src/ui/app/commands/dispatch.rs b/src/tui/src/ui/app/commands/dispatch.rs index cfaaef219..b6d60d72e 100644 --- a/src/tui/src/ui/app/commands/dispatch.rs +++ b/src/tui/src/ui/app/commands/dispatch.rs @@ -28,7 +28,7 @@ impl App { /// whichever row happened to share the offset. pub(in crate::ui::app) fn selected_agent_task(&self) -> Option { let rows = self.rail_rows(); - match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { + match rows.get(self.rail_cursor_in(&rows, &self.lanes())) { Some(super::super::rail::RailRow::Agent(AgentRow::Sub { task, .. })) => { Some(task.clone()) } diff --git a/src/tui/src/ui/app/harness_control.rs b/src/tui/src/ui/app/harness_control.rs index f0ee62a77..51f91b7e2 100644 --- a/src/tui/src/ui/app/harness_control.rs +++ b/src/tui/src/ui/app/harness_control.rs @@ -122,7 +122,7 @@ impl App { .iter() .position(|row| row.session_id() == Some(session_id)) { - self.agent_index = index; + self.set_rail_cursor(index); } } diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 28e9efcd3..96f32aeb2 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -335,7 +335,7 @@ impl App { let idx = owners[rel]; self.agent_scroll = 0; self.chat_scroll = 0; - self.agent_index = idx; + self.set_rail_cursor(idx); // A click is a focus gesture: the arrows should now // continue from the row that was just picked. self.focus_agents_rail(); diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 1d281dc05..8d24f170c 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -34,17 +34,22 @@ impl App { if rows.is_empty() { return; } - let clamped = self.agent_index.min(rows.len() - 1); + let lanes = self.lanes(); + // From where the cursor *is* — resolved from its anchor — not from the + // offset it last rendered at. A rail that gained a row since the last + // frame would otherwise step from someone else's position. + let clamped = self.rail_cursor_in(&rows, &lanes); let step: i64 = if up { -1 } else { 1 }; let mut next = clamped as i64 + step; while next >= 0 && (next as usize) < rows.len() && !rows[next as usize].selectable() { next += step; } - self.agent_index = if next < 0 || next as usize >= rows.len() { + let target = if next < 0 || next as usize >= rows.len() { clamped } else { next as usize }; + self.set_rail_cursor_in(&rows, &lanes, target); } /// Open a new thread and focus the conversation. @@ -58,7 +63,7 @@ impl App { self.draft = crate::ui::composer::Draft::new(); self.chat_scroll = 0; self.agent_scroll = 0; - self.agent_index = 0; + self.reset_rail_cursor(); self.tab_index = super::super::types::tab_pos("Agents"); self.refresh_snapshot(); let name = self @@ -147,7 +152,7 @@ impl App { return None; } let rows = self.rail_rows(); - let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?; + let row = rows.get(self.rail_cursor_in(&rows, &self.lanes()))?; let RailRow::Agent(AgentRow::Sub { task, lane_index, .. }) = row @@ -179,7 +184,7 @@ impl App { /// The selected running task eligible for destructive termination. pub(in crate::ui::app) fn kill_target(&self) -> Option<(String, String)> { let rows = self.rail_rows(); - let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?; + let row = rows.get(self.rail_cursor_in(&rows, &self.lanes()))?; let RailRow::Agent(AgentRow::Sub { task, .. }) = row else { return None; }; diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index af3b43831..e5f37d536 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -54,7 +54,7 @@ impl App { /// Whether the rail cursor sits on the `+ New harness` action row. pub(in crate::ui::app) fn on_new_harness_row(&self) -> bool { let rows = self.rail_rows(); - rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + rows.get(self.rail_cursor_in(&rows, &self.lanes())) .is_some_and(|row| row.is_new_harness()) } @@ -135,7 +135,7 @@ impl App { self.set_status("No conversation to type into yet"); return AgentsKey::Handled(None); }; - self.agent_index = index; + self.set_rail_cursor(index); self.agent_scroll = 0; self.chat_scroll = 0; // Leaving a task row drops its screen stream, exactly as diff --git a/src/tui/src/ui/app/mod.rs b/src/tui/src/ui/app/mod.rs index b7b19652a..db041f33b 100644 --- a/src/tui/src/ui/app/mod.rs +++ b/src/tui/src/ui/app/mod.rs @@ -31,6 +31,8 @@ mod overlays; #[cfg(test)] mod overlays_tests; mod rail; +#[cfg(test)] +mod rail_tests; mod render; mod routing_options; mod settings_edit; diff --git a/src/tui/src/ui/app/rail.rs b/src/tui/src/ui/app/rail.rs index ae8241ce8..86a16107a 100644 --- a/src/tui/src/ui/app/rail.rs +++ b/src/tui/src/ui/app/rail.rs @@ -11,7 +11,7 @@ //! is selected, and answers what the detail pane should show. use super::types::App; -use crate::ui::agents::{AgentRole, AgentRow}; +use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; use crate::worker::pty::SessionRow; /// The label on the rail's "start a harness" row. @@ -67,6 +67,92 @@ impl RailRow { } } +/// What the rail cursor is *on*, independent of where that row currently sits. +/// +/// The rail is rebuilt from scratch every frame out of live state: the fold +/// gains a lane the moment the orchestrator spawns an agent, sublanes reorder as +/// tasks start and finish, and the operator's own harnesses hang below all of +/// it. A cursor stored as a plain row offset therefore points at a *different +/// row* the instant anything above it appears — and for an operator sitting +/// inside an attached harness pane that is not a cosmetic jump: the selection +/// leaves the session, [`App::release_harness`] takes the keyboard back, the +/// composer and work panel reclaim the columns, and the harness is resized and +/// repainted underneath them. It reads exactly like the TUI resetting itself. +/// +/// So the cursor is remembered by identity and the offset is re-derived each +/// time the rows are rebuilt. Only rows the cursor can land on have one; the +/// dividers and the `+N more` counter are labels. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RailAnchor { + /// The `+ New harness` action row. + NewHarness, + /// One of the operator's own harnesses, by PTY session id. + Harness(String), + /// A lane header, by [`AgentLane::key`]. + Lane(String), + /// A task sublane, by owning lane key and task id. + /// + /// Keyed on the lane as well as the task because sublanes are only unique + /// within their lane, and a task row's meaning is "this task, under this + /// agent". + Task { + /// The owning lane's key. + lane: String, + /// The task's id. + task_id: String, + }, +} + +/// The identity of `row`, when it is one the cursor can hold. +/// +/// `lanes` must be the same lane list `rows` was built from: lane rows carry an +/// index into it, and the key behind that index is what survives the list +/// growing. +pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Option { + match row { + RailRow::NewHarness => Some(RailAnchor::NewHarness), + RailRow::Harness(session) => Some(RailAnchor::Harness(session.id.clone())), + RailRow::HarnessSeparator => None, + RailRow::Agent(AgentRow::Lane { lane_index }) => lanes + .get(*lane_index) + .map(|lane| RailAnchor::Lane(lane.key.clone())), + RailRow::Agent(AgentRow::Sub { + lane_index, task, .. + }) => lanes.get(*lane_index).map(|lane| RailAnchor::Task { + lane: lane.key.clone(), + task_id: task.task_id.clone(), + }), + // `Separator` and `More` are labels; the cursor steps over them. + RailRow::Agent(_) => None, + } +} + +/// Where the anchored row sits in `rows` now, or `fallback` when it is gone. +/// +/// A row can genuinely disappear — a harness exits and is forgotten, a task +/// scrolls past the sublane cap — and there is no better answer then than the +/// offset the cursor last held, clamped into range. The caller re-anchors from +/// whatever that lands on, so the fallback is used for one frame at most. +pub(in crate::ui::app) fn resolve_rail_cursor( + rows: &[RailRow], + lanes: &[AgentLane], + anchor: Option<&RailAnchor>, + fallback: usize, +) -> usize { + if rows.is_empty() { + return 0; + } + if let Some(anchor) = anchor { + if let Some(index) = rows + .iter() + .position(|row| rail_anchor(row, lanes).as_ref() == Some(anchor)) + { + return index; + } + } + fallback.min(rows.len() - 1) +} + impl App { /// The rail's rows: the agent lanes. /// @@ -112,6 +198,60 @@ impl App { rows } + /// The rail offset the cursor is on, re-derived from its anchor. + /// + /// Every read of the cursor goes through this rather than through + /// `agent_index` directly, so a rail that grew a row while the operator was + /// looking elsewhere still answers with the row they picked. + pub(in crate::ui::app) fn rail_cursor(&self) -> usize { + self.rail_cursor_in(&self.rail_rows(), &self.lanes()) + } + + /// [`rail_cursor`](Self::rail_cursor) against rows and lanes the caller + /// already has. Both are derived from the event fold, and rebuilding them + /// per read costs a full re-fold. + pub(in crate::ui::app) fn rail_cursor_in( + &self, + rows: &[RailRow], + lanes: &[AgentLane], + ) -> usize { + resolve_rail_cursor(rows, lanes, self.agent_anchor.as_ref(), self.agent_index) + } + + /// Put the cursor on `index`, remembering *which row* that is. + /// + /// Every write of the cursor goes through this. Setting `agent_index` alone + /// leaves the previous anchor in place, and the next frame would drag the + /// cursor straight back to the old row. + pub(in crate::ui::app) fn set_rail_cursor(&mut self, index: usize) { + let rows = self.rail_rows(); + let lanes = self.lanes(); + self.set_rail_cursor_in(&rows, &lanes, index); + } + + /// [`set_rail_cursor`](Self::set_rail_cursor) against rows and lanes the + /// caller already has. + pub(in crate::ui::app) fn set_rail_cursor_in( + &mut self, + rows: &[RailRow], + lanes: &[AgentLane], + index: usize, + ) { + self.agent_index = index.min(rows.len().saturating_sub(1)); + self.agent_anchor = rows + .get(self.agent_index) + .and_then(|row| rail_anchor(row, lanes)); + } + + /// Send the cursor back to the top and forget what it was on. + /// + /// For the deliberate resets — opening a new thread — where following the + /// old row would be the wrong behaviour, not the right one. + pub(in crate::ui::app) fn reset_rail_cursor(&mut self) { + self.agent_index = 0; + self.agent_anchor = None; + } + /// How many local harnesses are waiting on the operator right now. /// /// Counts every live session on this device, not only the rows on the rail: diff --git a/src/tui/src/ui/app/rail_tests.rs b/src/tui/src/ui/app/rail_tests.rs new file mode 100644 index 000000000..199ab6765 --- /dev/null +++ b/src/tui/src/ui/app/rail_tests.rs @@ -0,0 +1,392 @@ +//! Tests for the Agents rail's cursor identity: that the row the operator +//! picked stays picked while the rail grows, reorders, and loses rows +//! underneath them. +//! +//! The rail is rebuilt from live state every frame, so these pin the one +//! property that makes a positional cursor safe — that it is not actually +//! positional. The case that motivated them: the orchestrator spawns an agent, +//! its lane lands above the operator's own harness rows, and every row below +//! shifts by one. With an offset-only cursor the selection left the harness the +//! operator was attached to, which released the keyboard, restored the composer +//! and work panel, and resized the pane out from under them. + +use medulla::protocol::HarnessProvider; +use medulla::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; + +use super::rail::{rail_anchor, resolve_rail_cursor, RailAnchor, RailRow}; +use crate::worker::pty::{HarnessControl, PtyState, SessionRow}; + +/// A lane with `key`, carrying `tasks`. +fn lane(key: &str, tasks: Vec) -> AgentLane { + AgentLane { + key: key.into(), + label: key.into(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks, + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: None, + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + +fn task(id: &str) -> TaskState { + TaskState { + task_id: id.into(), + status: TaskStatus::Running, + turns: 1, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + } +} + +/// An operator-started harness row with local id `id`. +fn harness(id: &str) -> SessionRow { + SessionRow { + id: id.into(), + label: "local".into(), + provider: HarnessProvider::Codex, + state: PtyState::Running, + cwd: "/workspace".into(), + branch: None, + launch_root: None, + launch_commit: None, + launch_checkout_identity: None, + session_id: None, + thread_name: None, + started_at: 1, + last_output_at: 1, + last_error: None, + busy: false, + control: HarnessControl::User, + user_spawned: true, + attention: None, + } +} + +/// The rail as it stands before the orchestrator spawns anything: the +/// orchestrator lane, the action row, then the operator's own harness. +fn before() -> (Vec, Vec) { + let lanes = vec![lane("orchestrator", Vec::new())]; + let rows = vec![ + RailRow::Agent(AgentRow::Lane { lane_index: 0 }), + RailRow::NewHarness, + RailRow::HarnessSeparator, + RailRow::Harness(harness("w_1")), + ]; + (lanes, rows) +} + +/// The same rail one spawn later: a new agent lane and its task sublane sit +/// between the action row and the operator's harnesses. +fn after() -> (Vec, Vec) { + let lanes = vec![ + lane("orchestrator", Vec::new()), + lane("agent:builder", vec![task("task-1")]), + ]; + let rows = vec![ + RailRow::Agent(AgentRow::Lane { lane_index: 0 }), + RailRow::NewHarness, + RailRow::Agent(AgentRow::Lane { lane_index: 1 }), + RailRow::Agent(AgentRow::Sub { + lane_index: 1, + task: task("task-1"), + last: true, + }), + RailRow::HarnessSeparator, + RailRow::Harness(harness("w_1")), + ]; + (lanes, rows) +} + +#[test] +fn a_spawned_agent_does_not_move_the_cursor_off_the_operators_harness() { + let (lanes, rows) = before(); + let anchor = rail_anchor(&rows[3], &lanes).expect("a harness row is selectable"); + assert_eq!(anchor, RailAnchor::Harness("w_1".into())); + + let (lanes, rows) = after(); + let cursor = resolve_rail_cursor(&rows, &lanes, Some(&anchor), 3); + + assert_eq!( + cursor, 5, + "the cursor must follow the harness row, not the offset it used to sit at" + ); + assert!( + matches!(&rows[cursor], RailRow::Harness(row) if row.id == "w_1"), + "the resolved row must be the same harness" + ); +} + +#[test] +fn an_anchored_lane_survives_lanes_appearing_above_it() { + // The bug is not specific to harnesses: any row below an insertion point + // moves. Here the operator is on a lane and a second lane is inserted + // before it. + let lanes = vec![lane("agent:builder", Vec::new())]; + let rows = [RailRow::Agent(AgentRow::Lane { lane_index: 0 })]; + let anchor = rail_anchor(&rows[0], &lanes).expect("a lane row is selectable"); + + let lanes = vec![ + lane("agent:scout", Vec::new()), + lane("agent:builder", Vec::new()), + ]; + let rows = vec![ + RailRow::Agent(AgentRow::Lane { lane_index: 0 }), + RailRow::Agent(AgentRow::Lane { lane_index: 1 }), + ]; + + assert_eq!(resolve_rail_cursor(&rows, &lanes, Some(&anchor), 0), 1); +} + +#[test] +fn a_task_sublane_is_anchored_to_its_task_not_its_position() { + // Sublanes are ordered running-first then most-recent, so they reorder on + // their own without anything being spawned at all. + let lanes = vec![lane("agent:builder", vec![task("task-a"), task("task-b")])]; + let rows = [ + RailRow::Agent(AgentRow::Lane { lane_index: 0 }), + RailRow::Agent(AgentRow::Sub { + lane_index: 0, + task: task("task-a"), + last: false, + }), + RailRow::Agent(AgentRow::Sub { + lane_index: 0, + task: task("task-b"), + last: true, + }), + ]; + let anchor = rail_anchor(&rows[2], &lanes).expect("a sublane is selectable"); + assert_eq!( + anchor, + RailAnchor::Task { + lane: "agent:builder".into(), + task_id: "task-b".into(), + } + ); + + // `task-b` overtakes `task-a`. + let reordered = [ + RailRow::Agent(AgentRow::Lane { lane_index: 0 }), + RailRow::Agent(AgentRow::Sub { + lane_index: 0, + task: task("task-b"), + last: false, + }), + RailRow::Agent(AgentRow::Sub { + lane_index: 0, + task: task("task-a"), + last: true, + }), + ]; + + assert_eq!(resolve_rail_cursor(&reordered, &lanes, Some(&anchor), 2), 1); +} + +#[test] +fn a_row_that_is_gone_falls_back_to_the_last_offset() { + // A harness that exits and is forgotten takes its anchor with it. There is + // no better answer then than where the cursor was, clamped into range — + // and the caller re-anchors from whatever that lands on. + let anchor = RailAnchor::Harness("w_gone".into()); + let (lanes, rows) = after(); + + assert_eq!(resolve_rail_cursor(&rows, &lanes, Some(&anchor), 3), 3); + assert_eq!( + resolve_rail_cursor(&rows, &lanes, Some(&anchor), 99), + rows.len() - 1, + "an out-of-range fallback must clamp rather than index past the end" + ); +} + +#[test] +fn dividers_are_not_anchorable() { + let (lanes, rows) = before(); + + assert_eq!(rail_anchor(&rows[2], &lanes), None); + assert_eq!(rail_anchor(&rows[1], &lanes), Some(RailAnchor::NewHarness)); +} + +#[test] +fn an_empty_rail_resolves_to_zero() { + let anchor = RailAnchor::NewHarness; + + assert_eq!(resolve_rail_cursor(&[], &[], Some(&anchor), 7), 0); +} + +/// The whole failure, end to end, against a real harness on a real pty. +/// +/// Unix-only: it needs a genuine pty client to occupy a harness row, and +/// `/bin/sh` is the portable stand-in the pty layer's own tests use. +#[cfg(unix)] +mod attached { + use std::collections::HashMap; + use std::sync::{Arc, Mutex}; + + use medulla::config::LoadedConfig; + use medulla::protocol::HarnessProvider; + use medulla::runtime::mock::MockRuntime; + use medulla::runtime::Runtime; + use medulla::ui::events::{EventEnvelope, TuiEvent}; + use ratatui::backend::TestBackend; + use ratatui::Terminal; + + use crate::ui::app::rail::RailRow; + use crate::ui::app::App; + use crate::ui::harness_pane::{HarnessFocus, LocalHarnesses}; + use crate::worker::pty::{HarnessControl, LaunchSpec, PtyManager}; + + /// A harness that just sits there: a real child on a real pty, reading. + fn spec() -> LaunchSpec { + let mut env = HashMap::new(); + if let Ok(path) = std::env::var("PATH") { + env.insert("PATH".to_string(), path); + } + env.insert("TERM".to_string(), "xterm-256color".to_string()); + LaunchSpec { + provider: HarnessProvider::Codex, + bin: "/bin/sh".to_string(), + cwd: "/".to_string(), + env, + extra_args: vec!["-c".to_string(), "read line".to_string()], + skip_permissions: false, + label: "test".to_string(), + session_id: None, + model: None, + // The operator's own: what puts it in the rail's harness group. + control: HarnessControl::User, + user_spawned: true, + } + } + + /// [`LocalHarnesses`] over `sessions`, with an inert runtime — nothing here + /// dispatches a task, so task resolution never runs. + fn harnesses(sessions: PtyManager) -> LocalHarnesses { + let config = medulla::daemon::DaemonConfig { + providers: vec![HarnessProvider::Codex], + default_provider: HarnessProvider::Codex, + workspace: "/".to_string(), + accessible_dirs: Vec::new(), + env: HashMap::new(), + task_timeout_ms: 1_000, + capability_timeout_ms: None, + concurrency: 1, + status_throttle_ms: 1_000, + max_pending: 1, + model: None, + agent: None, + extra_args: Vec::new(), + skip_permissions: false, + router: None, + custom_harnesses: Vec::new(), + budget: None, + attribution: true, + }; + let run_task: medulla::daemon::providers::RunTaskFn = + Arc::new(|_| Box::pin(async { Err("not used in these tests".to_string()) })); + let send: medulla::daemon::SendFn = Arc::new(|_, _| { + Box::pin(async {}) as std::pin::Pin + Send>> + }); + LocalHarnesses { + sessions, + runtimes: Arc::new(Mutex::new(vec![medulla::daemon::DaemonRuntime::new( + config, run_task, send, + )])), + hub_address: "medulla-orchestrator".to_string(), + env: HashMap::new(), + workspace: "/".to_string(), + providers: vec![HarnessProvider::Codex], + custom_harnesses: Vec::new(), + router: None, + attribution: true, + } + } + + fn draw(app: &mut App) { + let mut terminal = Terminal::new(TestBackend::new(120, 40)).expect("terminal"); + terminal.draw(|f| app.draw(f)).expect("draw"); + } + + #[test] + fn spawning_an_agent_does_not_evict_the_operator_from_the_harness_they_are_in() { + let sessions = PtyManager::new(); + let id = sessions.open(spec()).expect("a pty"); + let mut app = App::new( + Arc::new(MockRuntime::demo()) as Arc, + LoadedConfig::defaults("medulla.tui.json".into()), + ); + app.harnesses = Some(harnesses(sessions)); + app.tab_index = crate::ui::app::TABS + .iter() + .position(|t| *t == "Agents") + .expect("the Agents tab"); + + // The operator arrows onto their harness and takes the keyboard. + let rows = app.rail_rows(); + let index = rows + .iter() + .position(|row| row.session_id() == Some(id.as_str())) + .expect("the harness has a rail row"); + app.set_rail_cursor(index); + app.harness_focus = HarnessFocus::Attached(id.clone()); + draw(&mut app); + assert_eq!( + app.attached_harness(), + Some(id.as_str()), + "precondition: the operator is typing into the harness" + ); + + // The orchestrator spawns an agent. Its lane — and its task sublane — + // land above the harness group, moving every row below them down. + app.snapshot.events.push(EventEnvelope { + seq: 9_000, + at: 9_000, + event: TuiEvent::TaskStart { + task_id: "task-spawned".into(), + instruction: "Audit the rail".into(), + depth: 2, + agent_id: Some("dev-2".into()), + contract: None, + }, + }); + let moved = app.rail_rows(); + let now_at = moved + .iter() + .position(|row| row.session_id() == Some(id.as_str())) + .expect("the harness still has a rail row"); + assert!( + now_at > index, + "precondition: the spawn must have pushed the harness row down" + ); + + draw(&mut app); + + assert_eq!( + app.attached_harness(), + Some(id.as_str()), + "an agent starting elsewhere must not take the keyboard out of the harness" + ); + let rows = app.rail_rows(); + assert!( + matches!(rows.get(app.agent_index()), Some(RailRow::Harness(row)) if row.id == id), + "the cursor must still be on the harness, not on the row that slid into its offset" + ); + + app.harnesses + .as_ref() + .expect("harnesses") + .sessions + .shutdown(); + } +} diff --git a/src/tui/src/ui/app/render/agents/mod.rs b/src/tui/src/ui/app/render/agents/mod.rs index f85a088c8..595beef9d 100644 --- a/src/tui/src/ui/app/render/agents/mod.rs +++ b/src/tui/src/ui/app/render/agents/mod.rs @@ -77,8 +77,14 @@ impl App { fn agents_selection(&mut self) -> Selection { let lanes = self.lanes(); let rows = self.rail_rows(); - let active = self.agent_index.min(rows.len().saturating_sub(1)); - self.agent_index = active; + // Re-derived from the anchor, not carried over as an offset: the rail is + // rebuilt from live state every frame, and an agent the orchestrator + // just spawned inserts a lane above the operator's own harness rows. + // Keeping the offset would hand the cursor to whatever row slid into it + // — releasing the attached harness, restoring the composer, and resizing + // the pane, all while the operator was typing into it. + let active = self.rail_cursor_in(&rows, &lanes); + self.set_rail_cursor_in(&rows, &lanes, active); let lane_index = match rows.get(active) { Some(RailRow::Agent(row)) => row.lane_index().unwrap_or(0), _ => 0, diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index ebafeb5e9..083c074aa 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -65,6 +65,7 @@ impl App { contexts: Vec::new(), context_index: 0, agent_index: 0, + agent_anchor: None, watching: None, kill_armed: None, agents_focus: super::types::AgentsFocus::default(), @@ -237,8 +238,12 @@ impl App { } /// Where the Agents rail cursor is. Test/inspection seam. + /// + /// Resolved through the cursor's anchor rather than read off the stored + /// offset, so this answers with the row the operator is on even when the + /// rail has gained rows since it was last drawn. pub fn agent_index(&self) -> usize { - self.agent_index + self.rail_cursor() } /// The current composer draft text. Test/inspection seam. @@ -555,7 +560,7 @@ impl App { pub fn on_orchestrator_lane(&self) -> bool { let lanes = self.lanes(); let rows = self.rail_rows(); - match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { + match rows.get(self.rail_cursor_in(&rows, &lanes)) { Some(super::rail::RailRow::Agent(row)) => row .lane_index() .and_then(|index| lanes.get(index)) diff --git a/src/tui/src/ui/app/tests.rs b/src/tui/src/ui/app/tests.rs index 37807c6dd..d4d3fdc77 100644 --- a/src/tui/src/ui/app/tests.rs +++ b/src/tui/src/ui/app/tests.rs @@ -340,7 +340,7 @@ fn select_first_task(app: &mut App) -> Option { super::rail::RailRow::Agent(crate::ui::agents::AgentRow::Sub { .. }) ) })?; - app.agent_index = idx; + app.set_rail_cursor(idx); app.retarget_watch() } @@ -486,7 +486,7 @@ fn selecting_a_lane_rather_than_a_task_watches_nothing() { ) }) .expect("the fixture has a lane row"); - app.agent_index = idx; + app.set_rail_cursor(idx); assert!(app.retarget_watch().is_none()); assert!(app.watching.is_none()); } diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types.rs index ae3469e31..5e386eec1 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types.rs @@ -819,7 +819,22 @@ pub struct App { pub(super) update_notice: Option, pub(super) contexts: Vec, pub(super) context_index: usize, + /// The Agents-rail cursor as a row offset. + /// + /// Derived state: [`super::rail::RailAnchor`] below is what the cursor + /// actually means, and this is where it last resolved to. Kept as the + /// fallback for the frame after an anchored row disappears, and as the + /// offset the renderer highlights. Write it through + /// [`App::set_rail_cursor`](crate::ui::app::App::set_rail_cursor), never + /// directly. pub(super) agent_index: usize, + /// Which rail row the cursor is on, by identity. + /// + /// The rail is rebuilt every frame from live state, so a lane appearing + /// above the cursor moves every row below it. Holding the identity means the + /// selection follows the row instead of the offset — which is what keeps an + /// attached harness attached when the orchestrator spawns an agent. + pub(super) agent_anchor: Option, /// The `(worker address, task id)` whose screen is currently subscribed. /// /// Held so a selection change can stop the old stream as well as start the From 0b62c968515ca5d4fdc4bc3cd39f94713bfb4995 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 13:04:55 +0300 Subject: [PATCH 02/14] fix(tui): keep Agents rail cursor anchored --- src/tui/src/ui/app/agent_control.rs | 11 +- src/tui/src/ui/app/input/nav.rs | 12 +- src/tui/src/ui/app/keys/agents.rs | 2 +- src/tui/src/ui/app/mod.rs | 2 - src/tui/src/ui/app/rail/cursor_tests.rs | 31 ++ src/tui/src/ui/app/rail/mod.rs | 2 + src/tui/src/ui/app/rail_tests.rs | 392 ------------------------ src/tui/src/ui/app/session_focus.rs | 4 +- 8 files changed, 50 insertions(+), 406 deletions(-) create mode 100644 src/tui/src/ui/app/rail/cursor_tests.rs delete mode 100644 src/tui/src/ui/app/rail_tests.rs diff --git a/src/tui/src/ui/app/agent_control.rs b/src/tui/src/ui/app/agent_control.rs index 1d4af9938..6c54a1bd2 100644 --- a/src/tui/src/ui/app/agent_control.rs +++ b/src/tui/src/ui/app/agent_control.rs @@ -273,10 +273,11 @@ impl App { /// Put the rail cursor on `agent_id`'s row, if it has one. pub(in crate::ui::app) fn select_agent_row(&mut self, agent_id: &str) { - if let Some(index) = self.rail_rows().iter().position( + let rows = self.rail_rows(); + if let Some(index) = rows.iter().position( |row| matches!(row, super::rail::RailRow::Agent(agent) if agent.agent_id == agent_id), ) { - self.agent_index = index; + self.set_rail_cursor_in(&rows, &self.lanes(), index); } } @@ -286,12 +287,12 @@ impl App { /// somewhere below the fold, with the pane still showing whatever was /// selected before, reads as "nothing happened". pub(in crate::ui::app) fn select_session_row(&mut self, session_id: &str) { - if let Some(index) = self - .rail_rows() + let rows = self.rail_rows(); + if let Some(index) = rows .iter() .position(|row| row.session_id() == Some(session_id)) { - self.agent_index = index; + self.set_rail_cursor_in(&rows, &self.lanes(), index); } } } diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index feae4f672..763fb3aac 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -106,8 +106,11 @@ impl App { rows.iter() .position(|row| matches!(row, RailRow::Agent(agent) if agent.lane_index == Some(lane_index))) }); - self.agent_index = - found.unwrap_or_else(|| self.agent_index.min(rows.len().saturating_sub(1))); + self.set_rail_cursor_in( + &rows, + &self.lanes(), + found.unwrap_or_else(|| self.agent_index.min(rows.len().saturating_sub(1))), + ); } /// The number of body rows a list pane can show for the current terminal @@ -134,11 +137,12 @@ impl App { while next >= 0 && (next as usize) < rows.len() && !rows[next as usize].selectable() { next += step; } - self.agent_index = if next < 0 || next as usize >= rows.len() { + let next = if next < 0 || next as usize >= rows.len() { clamped } else { next as usize }; + self.set_rail_cursor_in(&rows, &self.lanes(), next); } /// Open a new thread and focus the conversation. @@ -152,7 +156,7 @@ impl App { self.draft = crate::ui::composer::Draft::new(); self.chat_scroll = 0; self.agent_scroll = 0; - self.agent_index = 0; + self.reset_rail_cursor(); self.tab_index = super::super::types::tab_pos("Agents"); self.refresh_snapshot(); let name = self diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index 662ebc61e..485718da5 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -171,7 +171,7 @@ impl App { self.set_status("No conversation to type into yet"); return AgentsKey::Handled(None); }; - self.agent_index = index; + self.set_rail_cursor(index); self.agent_scroll = 0; self.chat_scroll = 0; // Leaving a task row drops its screen stream, exactly as diff --git a/src/tui/src/ui/app/mod.rs b/src/tui/src/ui/app/mod.rs index 4a664f0ec..45c430410 100644 --- a/src/tui/src/ui/app/mod.rs +++ b/src/tui/src/ui/app/mod.rs @@ -34,8 +34,6 @@ mod overlays; #[cfg(test)] mod overlays_tests; mod rail; -#[cfg(test)] -mod rail_tests; mod render; mod routing_options; mod session_control; diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs new file mode 100644 index 000000000..7442fa2fb --- /dev/null +++ b/src/tui/src/ui/app/rail/cursor_tests.rs @@ -0,0 +1,31 @@ +//! Cursor identity tests for the Agents rail. +//! +//! The live rail is rebuilt on every frame, so these cover the stable-anchor +//! resolver independently of the `App` rendering loop. + +use super::{resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow}; + +fn agent(id: &str) -> RailRow { + RailRow::Agent(AgentRailRow { + agent_id: id.to_string(), + host_id: String::new(), + agent: None, + lane_index: None, + }) +} + +#[test] +fn an_anchored_agent_follows_rows_inserted_ahead_of_it() { + let anchor = RailAnchor::Agent("builder".to_string()); + let rows = vec![RailRow::NewAgent, agent("scout"), agent("builder")]; + + assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 0), 2); +} + +#[test] +fn a_missing_anchor_uses_the_clamped_previous_offset() { + let rows = vec![RailRow::NewAgent, agent("builder")]; + let anchor = RailAnchor::Agent("removed".to_string()); + + assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 99), 1); +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index a0b26633a..34c565287 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -44,6 +44,8 @@ pub(in crate::ui::app) mod resolve; // the served-dispatch merge are separate responsibilities, and one file for // both had already grown past this repository's line ceiling. #[cfg(test)] +mod cursor_tests; +#[cfg(test)] mod merge_tests; #[cfg(test)] pub(in crate::ui::app) mod tests; diff --git a/src/tui/src/ui/app/rail_tests.rs b/src/tui/src/ui/app/rail_tests.rs deleted file mode 100644 index 199ab6765..000000000 --- a/src/tui/src/ui/app/rail_tests.rs +++ /dev/null @@ -1,392 +0,0 @@ -//! Tests for the Agents rail's cursor identity: that the row the operator -//! picked stays picked while the rail grows, reorders, and loses rows -//! underneath them. -//! -//! The rail is rebuilt from live state every frame, so these pin the one -//! property that makes a positional cursor safe — that it is not actually -//! positional. The case that motivated them: the orchestrator spawns an agent, -//! its lane lands above the operator's own harness rows, and every row below -//! shifts by one. With an offset-only cursor the selection left the harness the -//! operator was attached to, which released the keyboard, restored the composer -//! and work panel, and resized the pane out from under them. - -use medulla::protocol::HarnessProvider; -use medulla::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; - -use super::rail::{rail_anchor, resolve_rail_cursor, RailAnchor, RailRow}; -use crate::worker::pty::{HarnessControl, PtyState, SessionRow}; - -/// A lane with `key`, carrying `tasks`. -fn lane(key: &str, tasks: Vec) -> AgentLane { - AgentLane { - key: key.into(), - label: key.into(), - role: AgentRole::Agent, - turns: Vec::new(), - last_at: 0, - tasks, - context_tokens: None, - usage: Default::default(), - harness_label: None, - agent_id: None, - session_id: None, - parent_agent_id: None, - descriptor: None, - active_tasks: 0, - work: None, - } -} - -fn task(id: &str) -> TaskState { - TaskState { - task_id: id.into(), - status: TaskStatus::Running, - turns: 1, - last_at: 0, - turn_blocks: Vec::new(), - attention: None, - question_id: None, - work: None, - } -} - -/// An operator-started harness row with local id `id`. -fn harness(id: &str) -> SessionRow { - SessionRow { - id: id.into(), - label: "local".into(), - provider: HarnessProvider::Codex, - state: PtyState::Running, - cwd: "/workspace".into(), - branch: None, - launch_root: None, - launch_commit: None, - launch_checkout_identity: None, - session_id: None, - thread_name: None, - started_at: 1, - last_output_at: 1, - last_error: None, - busy: false, - control: HarnessControl::User, - user_spawned: true, - attention: None, - } -} - -/// The rail as it stands before the orchestrator spawns anything: the -/// orchestrator lane, the action row, then the operator's own harness. -fn before() -> (Vec, Vec) { - let lanes = vec![lane("orchestrator", Vec::new())]; - let rows = vec![ - RailRow::Agent(AgentRow::Lane { lane_index: 0 }), - RailRow::NewHarness, - RailRow::HarnessSeparator, - RailRow::Harness(harness("w_1")), - ]; - (lanes, rows) -} - -/// The same rail one spawn later: a new agent lane and its task sublane sit -/// between the action row and the operator's harnesses. -fn after() -> (Vec, Vec) { - let lanes = vec![ - lane("orchestrator", Vec::new()), - lane("agent:builder", vec![task("task-1")]), - ]; - let rows = vec![ - RailRow::Agent(AgentRow::Lane { lane_index: 0 }), - RailRow::NewHarness, - RailRow::Agent(AgentRow::Lane { lane_index: 1 }), - RailRow::Agent(AgentRow::Sub { - lane_index: 1, - task: task("task-1"), - last: true, - }), - RailRow::HarnessSeparator, - RailRow::Harness(harness("w_1")), - ]; - (lanes, rows) -} - -#[test] -fn a_spawned_agent_does_not_move_the_cursor_off_the_operators_harness() { - let (lanes, rows) = before(); - let anchor = rail_anchor(&rows[3], &lanes).expect("a harness row is selectable"); - assert_eq!(anchor, RailAnchor::Harness("w_1".into())); - - let (lanes, rows) = after(); - let cursor = resolve_rail_cursor(&rows, &lanes, Some(&anchor), 3); - - assert_eq!( - cursor, 5, - "the cursor must follow the harness row, not the offset it used to sit at" - ); - assert!( - matches!(&rows[cursor], RailRow::Harness(row) if row.id == "w_1"), - "the resolved row must be the same harness" - ); -} - -#[test] -fn an_anchored_lane_survives_lanes_appearing_above_it() { - // The bug is not specific to harnesses: any row below an insertion point - // moves. Here the operator is on a lane and a second lane is inserted - // before it. - let lanes = vec![lane("agent:builder", Vec::new())]; - let rows = [RailRow::Agent(AgentRow::Lane { lane_index: 0 })]; - let anchor = rail_anchor(&rows[0], &lanes).expect("a lane row is selectable"); - - let lanes = vec![ - lane("agent:scout", Vec::new()), - lane("agent:builder", Vec::new()), - ]; - let rows = vec![ - RailRow::Agent(AgentRow::Lane { lane_index: 0 }), - RailRow::Agent(AgentRow::Lane { lane_index: 1 }), - ]; - - assert_eq!(resolve_rail_cursor(&rows, &lanes, Some(&anchor), 0), 1); -} - -#[test] -fn a_task_sublane_is_anchored_to_its_task_not_its_position() { - // Sublanes are ordered running-first then most-recent, so they reorder on - // their own without anything being spawned at all. - let lanes = vec![lane("agent:builder", vec![task("task-a"), task("task-b")])]; - let rows = [ - RailRow::Agent(AgentRow::Lane { lane_index: 0 }), - RailRow::Agent(AgentRow::Sub { - lane_index: 0, - task: task("task-a"), - last: false, - }), - RailRow::Agent(AgentRow::Sub { - lane_index: 0, - task: task("task-b"), - last: true, - }), - ]; - let anchor = rail_anchor(&rows[2], &lanes).expect("a sublane is selectable"); - assert_eq!( - anchor, - RailAnchor::Task { - lane: "agent:builder".into(), - task_id: "task-b".into(), - } - ); - - // `task-b` overtakes `task-a`. - let reordered = [ - RailRow::Agent(AgentRow::Lane { lane_index: 0 }), - RailRow::Agent(AgentRow::Sub { - lane_index: 0, - task: task("task-b"), - last: false, - }), - RailRow::Agent(AgentRow::Sub { - lane_index: 0, - task: task("task-a"), - last: true, - }), - ]; - - assert_eq!(resolve_rail_cursor(&reordered, &lanes, Some(&anchor), 2), 1); -} - -#[test] -fn a_row_that_is_gone_falls_back_to_the_last_offset() { - // A harness that exits and is forgotten takes its anchor with it. There is - // no better answer then than where the cursor was, clamped into range — - // and the caller re-anchors from whatever that lands on. - let anchor = RailAnchor::Harness("w_gone".into()); - let (lanes, rows) = after(); - - assert_eq!(resolve_rail_cursor(&rows, &lanes, Some(&anchor), 3), 3); - assert_eq!( - resolve_rail_cursor(&rows, &lanes, Some(&anchor), 99), - rows.len() - 1, - "an out-of-range fallback must clamp rather than index past the end" - ); -} - -#[test] -fn dividers_are_not_anchorable() { - let (lanes, rows) = before(); - - assert_eq!(rail_anchor(&rows[2], &lanes), None); - assert_eq!(rail_anchor(&rows[1], &lanes), Some(RailAnchor::NewHarness)); -} - -#[test] -fn an_empty_rail_resolves_to_zero() { - let anchor = RailAnchor::NewHarness; - - assert_eq!(resolve_rail_cursor(&[], &[], Some(&anchor), 7), 0); -} - -/// The whole failure, end to end, against a real harness on a real pty. -/// -/// Unix-only: it needs a genuine pty client to occupy a harness row, and -/// `/bin/sh` is the portable stand-in the pty layer's own tests use. -#[cfg(unix)] -mod attached { - use std::collections::HashMap; - use std::sync::{Arc, Mutex}; - - use medulla::config::LoadedConfig; - use medulla::protocol::HarnessProvider; - use medulla::runtime::mock::MockRuntime; - use medulla::runtime::Runtime; - use medulla::ui::events::{EventEnvelope, TuiEvent}; - use ratatui::backend::TestBackend; - use ratatui::Terminal; - - use crate::ui::app::rail::RailRow; - use crate::ui::app::App; - use crate::ui::harness_pane::{HarnessFocus, LocalHarnesses}; - use crate::worker::pty::{HarnessControl, LaunchSpec, PtyManager}; - - /// A harness that just sits there: a real child on a real pty, reading. - fn spec() -> LaunchSpec { - let mut env = HashMap::new(); - if let Ok(path) = std::env::var("PATH") { - env.insert("PATH".to_string(), path); - } - env.insert("TERM".to_string(), "xterm-256color".to_string()); - LaunchSpec { - provider: HarnessProvider::Codex, - bin: "/bin/sh".to_string(), - cwd: "/".to_string(), - env, - extra_args: vec!["-c".to_string(), "read line".to_string()], - skip_permissions: false, - label: "test".to_string(), - session_id: None, - model: None, - // The operator's own: what puts it in the rail's harness group. - control: HarnessControl::User, - user_spawned: true, - } - } - - /// [`LocalHarnesses`] over `sessions`, with an inert runtime — nothing here - /// dispatches a task, so task resolution never runs. - fn harnesses(sessions: PtyManager) -> LocalHarnesses { - let config = medulla::daemon::DaemonConfig { - providers: vec![HarnessProvider::Codex], - default_provider: HarnessProvider::Codex, - workspace: "/".to_string(), - accessible_dirs: Vec::new(), - env: HashMap::new(), - task_timeout_ms: 1_000, - capability_timeout_ms: None, - concurrency: 1, - status_throttle_ms: 1_000, - max_pending: 1, - model: None, - agent: None, - extra_args: Vec::new(), - skip_permissions: false, - router: None, - custom_harnesses: Vec::new(), - budget: None, - attribution: true, - }; - let run_task: medulla::daemon::providers::RunTaskFn = - Arc::new(|_| Box::pin(async { Err("not used in these tests".to_string()) })); - let send: medulla::daemon::SendFn = Arc::new(|_, _| { - Box::pin(async {}) as std::pin::Pin + Send>> - }); - LocalHarnesses { - sessions, - runtimes: Arc::new(Mutex::new(vec![medulla::daemon::DaemonRuntime::new( - config, run_task, send, - )])), - hub_address: "medulla-orchestrator".to_string(), - env: HashMap::new(), - workspace: "/".to_string(), - providers: vec![HarnessProvider::Codex], - custom_harnesses: Vec::new(), - router: None, - attribution: true, - } - } - - fn draw(app: &mut App) { - let mut terminal = Terminal::new(TestBackend::new(120, 40)).expect("terminal"); - terminal.draw(|f| app.draw(f)).expect("draw"); - } - - #[test] - fn spawning_an_agent_does_not_evict_the_operator_from_the_harness_they_are_in() { - let sessions = PtyManager::new(); - let id = sessions.open(spec()).expect("a pty"); - let mut app = App::new( - Arc::new(MockRuntime::demo()) as Arc, - LoadedConfig::defaults("medulla.tui.json".into()), - ); - app.harnesses = Some(harnesses(sessions)); - app.tab_index = crate::ui::app::TABS - .iter() - .position(|t| *t == "Agents") - .expect("the Agents tab"); - - // The operator arrows onto their harness and takes the keyboard. - let rows = app.rail_rows(); - let index = rows - .iter() - .position(|row| row.session_id() == Some(id.as_str())) - .expect("the harness has a rail row"); - app.set_rail_cursor(index); - app.harness_focus = HarnessFocus::Attached(id.clone()); - draw(&mut app); - assert_eq!( - app.attached_harness(), - Some(id.as_str()), - "precondition: the operator is typing into the harness" - ); - - // The orchestrator spawns an agent. Its lane — and its task sublane — - // land above the harness group, moving every row below them down. - app.snapshot.events.push(EventEnvelope { - seq: 9_000, - at: 9_000, - event: TuiEvent::TaskStart { - task_id: "task-spawned".into(), - instruction: "Audit the rail".into(), - depth: 2, - agent_id: Some("dev-2".into()), - contract: None, - }, - }); - let moved = app.rail_rows(); - let now_at = moved - .iter() - .position(|row| row.session_id() == Some(id.as_str())) - .expect("the harness still has a rail row"); - assert!( - now_at > index, - "precondition: the spawn must have pushed the harness row down" - ); - - draw(&mut app); - - assert_eq!( - app.attached_harness(), - Some(id.as_str()), - "an agent starting elsewhere must not take the keyboard out of the harness" - ); - let rows = app.rail_rows(); - assert!( - matches!(rows.get(app.agent_index()), Some(RailRow::Harness(row)) if row.id == id), - "the cursor must still be on the harness, not on the row that slid into its offset" - ); - - app.harnesses - .as_ref() - .expect("harnesses") - .sessions - .shutdown(); - } -} diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs index 3143cfc88..c01a9a044 100644 --- a/src/tui/src/ui/app/session_focus.rs +++ b/src/tui/src/ui/app/session_focus.rs @@ -106,7 +106,7 @@ impl App { // Safe by construction: the index came from the list this call just // built, so nothing can have moved between resolving it and using it. self.tab_index = super::types::tab_pos("Agents"); - self.agent_index = session.row_index; + self.set_rail_cursor(session.row_index); self.agent_scroll = 0; self.chat_scroll = 0; // The rail owns the keyboard on a session row: there is no composer under @@ -131,7 +131,7 @@ impl App { return; }; self.tab_index = super::types::tab_pos("Agents"); - self.agent_index = index; + self.set_rail_cursor(index); self.agent_scroll = 0; self.chat_scroll = 0; self.focus_agents_composer(); From e830d9dabe5018dcf7de6ce0f11ac3f3d9a21fea Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 13:26:55 +0300 Subject: [PATCH 03/14] fix(tui): anchor overflow rail controls --- src/tui/src/ui/app/rail/cursor_tests.rs | 39 ++++++++++++++++++++- src/tui/src/ui/app/rail/mod.rs | 3 ++ src/tui/src/ui/app/rail/types.rs | 2 ++ src/tui/src/ui/app/session_control_tests.rs | 13 ++++--- 4 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs index 7442fa2fb..5c287d4c0 100644 --- a/src/tui/src/ui/app/rail/cursor_tests.rs +++ b/src/tui/src/ui/app/rail/cursor_tests.rs @@ -3,7 +3,9 @@ //! The live rail is rebuilt on every frame, so these cover the stable-anchor //! resolver independently of the `App` rendering loop. -use super::{resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow}; +use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; + +use super::{rail_anchor, resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow}; fn agent(id: &str) -> RailRow { RailRow::Agent(AgentRailRow { @@ -14,6 +16,26 @@ fn agent(id: &str) -> RailRow { }) } +fn lane(key: &str) -> AgentLane { + AgentLane { + key: key.to_string(), + label: String::new(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: None, + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + #[test] fn an_anchored_agent_follows_rows_inserted_ahead_of_it() { let anchor = RailAnchor::Agent("builder".to_string()); @@ -29,3 +51,18 @@ fn a_missing_anchor_uses_the_clamped_previous_offset() { assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 99), 1); } + +#[test] +fn an_overflow_anchor_uses_its_lanes_stable_key() { + let lanes = vec![lane("builder")]; + let overflow = RailRow::Lane(AgentRow::More { + lane_index: 0, + hidden: 3, + }); + + let anchor = rail_anchor(&overflow, &lanes); + assert_eq!(anchor, Some(RailAnchor::Overflow("builder".to_string()))); + + let rows = vec![RailRow::NewAgent, agent("new"), overflow]; + assert_eq!(resolve_rail_cursor(&rows, &lanes, anchor.as_ref(), 0), 2); +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 34c565287..44b102efa 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -97,6 +97,9 @@ pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Opt lane: lane.key.clone(), task_id: task.task_id.clone(), }), + RailRow::Lane(AgentRow::More { lane_index, .. }) => lanes + .get(*lane_index) + .map(|lane| RailAnchor::Overflow(lane.key.clone())), RailRow::Host(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None, } } diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index f2245b7a6..00738e1b5 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -39,6 +39,8 @@ pub enum RailAnchor { WorkflowRun(String), /// A non-agent lane header, keyed by the fold's stable lane key. Lane(String), + /// The paging control for an agent lane, keyed by that lane's stable key. + Overflow(String), } /// One host in the tree. diff --git a/src/tui/src/ui/app/session_control_tests.rs b/src/tui/src/ui/app/session_control_tests.rs index a02f15e7a..620fac41d 100644 --- a/src/tui/src/ui/app/session_control_tests.rs +++ b/src/tui/src/ui/app/session_control_tests.rs @@ -111,6 +111,11 @@ fn row_index(app: &App, wanted: impl Fn(&RailRow) -> bool) -> usize { .expect("the demo fixture has such a row") } +/// Select a rail row through the cursor API so its stable anchor follows it. +fn select_row(app: &mut App, wanted: impl Fn(&RailRow) -> bool) { + app.set_rail_cursor(row_index(app, wanted)); +} + #[test] fn selecting_a_session_this_device_does_not_host_arms_the_remote_refusal() { // The other half of `taking_a_session_on_another_host_is_refused_by_name`: @@ -120,7 +125,7 @@ fn selecting_a_session_this_device_does_not_host_arms_the_remote_refusal() { // cursor landing on one is the whole of what arms the refusal. let mut app = app(); app.tab_index = tab_pos("Agents"); - app.agent_index = row_index(&app, |row| matches!(row, RailRow::Session(_))); + select_row(&mut app, |row| matches!(row, RailRow::Session(_))); draw_once(&mut app); @@ -148,12 +153,12 @@ fn moving_off_the_row_or_off_the_tab_disarms_it_again() { // somebody else's machine. let mut app = app(); app.tab_index = tab_pos("Agents"); - app.agent_index = row_index(&app, |row| matches!(row, RailRow::Session(_))); + select_row(&mut app, |row| matches!(row, RailRow::Session(_))); draw_once(&mut app); assert!(app.pane_remote_session.is_some(), "armed to begin with"); // Off the row: the conversation is not a session at all. - app.agent_index = row_index(&app, |row| matches!(row, RailRow::Lane(_))); + select_row(&mut app, |row| matches!(row, RailRow::Lane(_))); draw_once(&mut app); assert!( app.pane_remote_session.is_none(), @@ -162,7 +167,7 @@ fn moving_off_the_row_or_off_the_tab_disarms_it_again() { ); // Off the tab: nothing on Settings draws the rail, so nothing re-arms it. - app.agent_index = row_index(&app, |row| matches!(row, RailRow::Session(_))); + select_row(&mut app, |row| matches!(row, RailRow::Session(_))); draw_once(&mut app); assert!(app.pane_remote_session.is_some(), "armed again"); app.tab_index = tab_pos("Settings"); From fa8894ea3ee875450216d99df6a2dc3ff14a63f5 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 13:49:19 +0300 Subject: [PATCH 04/14] fix(tui): stabilize rail cursor navigation --- src/tui/src/ui/app/input/mouse.rs | 3 ++- src/tui/src/ui/app/input/nav.rs | 8 ++++++-- src/tui/src/ui/app/session_focus.rs | 21 +++++++++++++-------- 3 files changed, 21 insertions(+), 11 deletions(-) diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 82c6c9b5d..386f12ba2 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -535,13 +535,14 @@ impl App { // covers the unselectable rows too — the `── functions ──` // separator — because `agent_index` indexes all of them. let rel = (y - rect.y) as usize; + let lanes = self.lanes(); let rows = self.rail_rows(); if let Some(row) = owners.get(rel).and_then(|idx| rows.get(*idx)) { if row.selectable() { let idx = owners[rel]; self.agent_scroll = 0; self.chat_scroll = 0; - self.set_rail_cursor(idx); + self.set_rail_cursor_in(&rows, &lanes, idx); // A click is a focus gesture: the arrows should now // continue from the row that was just picked. self.focus_agents_rail(); diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 763fb3aac..783998734 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -127,11 +127,15 @@ impl App { /// where it was. The `+N more` row *is* a destination — it is the control /// that pages its lane open. pub(in crate::ui::app) fn move_agent_index(&mut self, up: bool) { + let lanes = self.lanes(); let rows = self.rail_rows(); if rows.is_empty() { return; } - let clamped = self.agent_index.min(rows.len() - 1); + // `agent_index` is only the last rendered offset. Resolve the anchor + // first: a local session may have appeared above it since that frame, + // and stepping from the old offset would select the wrong neighbour. + let clamped = self.rail_cursor_in(&rows, &lanes); let step: i64 = if up { -1 } else { 1 }; let mut next = clamped as i64 + step; while next >= 0 && (next as usize) < rows.len() && !rows[next as usize].selectable() { @@ -142,7 +146,7 @@ impl App { } else { next as usize }; - self.set_rail_cursor_in(&rows, &self.lanes(), next); + self.set_rail_cursor_in(&rows, &lanes, next); } /// Open a new thread and focus the conversation. diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs index c01a9a044..a4fdc364d 100644 --- a/src/tui/src/ui/app/session_focus.rs +++ b/src/tui/src/ui/app/session_focus.rs @@ -95,18 +95,23 @@ impl App { /// and a stale index would put the cursor on whatever took its place. A task /// that is no longer served is reported instead. pub(in crate::ui::app) fn focus_session_for_task(&mut self, task_id: &str) -> bool { - let Some(session) = self - .started_sessions() - .into_iter() - .find(|session| session.task_id == task_id) - else { + // Keep the rows that yielded the offset through the cursor write. The + // local PTY registry can change while this event is handled; rebuilding + // here would let an insertion above the target retarget the cursor. + let rows = self.rail_rows(); + let Some(row_index) = rows.iter().position(|row| { + matches!(row, RailRow::Session(session) if session + .task + .as_ref() + .is_some_and(|task| task.task_id == task_id) + && !session.origin().is_user()) + }) else { self.set_status(format!("No session is running {task_id}")); return false; }; - // Safe by construction: the index came from the list this call just - // built, so nothing can have moved between resolving it and using it. self.tab_index = super::types::tab_pos("Agents"); - self.set_rail_cursor(session.row_index); + let lanes = self.lanes(); + self.set_rail_cursor_in(&rows, &lanes, row_index); self.agent_scroll = 0; self.chat_scroll = 0; // The rail owns the keyboard on a session row: there is no composer under From daff7b1017e9828261e2778d80e431d9a3edd92f Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 14:09:06 +0300 Subject: [PATCH 05/14] fix(tui): isolate rail cursor behavior --- src/tui/src/ui/app/rail/cursor.rs | 105 ++++++++++++++++++++++++++++ src/tui/src/ui/app/rail/mod.rs | 97 +------------------------ src/tui/src/ui/app/session_focus.rs | 19 ++--- 3 files changed, 118 insertions(+), 103 deletions(-) create mode 100644 src/tui/src/ui/app/rail/cursor.rs diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs new file mode 100644 index 000000000..1ce5c5531 --- /dev/null +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -0,0 +1,105 @@ +//! Stable identity and movement for the Agents rail cursor. +//! +//! The rail is rebuilt every frame, so its cursor records the selected row's +//! durable identity and resolves that identity against the current rows. + +use super::{RailAnchor, RailRow}; +use crate::ui::agents::{AgentLane, AgentRow}; +use crate::ui::app::types::App; + +/// The identity of a selectable `row`, if it has one. +pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Option { + match row { + RailRow::NewAgent => Some(RailAnchor::NewAgent), + RailRow::Agent(agent) => Some(RailAnchor::Agent(agent.agent_id.clone())), + RailRow::Session(session) => session + .local + .as_ref() + .map(|local| RailAnchor::Session(local.id.clone())) + .or_else(|| { + session.task.as_ref().and_then(|task| { + session.lane_index.and_then(|index| { + lanes.get(index).map(|lane| RailAnchor::Task { + lane: lane.key.clone(), + task_id: task.task_id.clone(), + }) + }) + }) + }), + RailRow::NewSession { agent_id } => Some(RailAnchor::NewSession(agent_id.clone())), + RailRow::WorkflowRun(row) => Some(RailAnchor::WorkflowRun(row.run.run_id.clone())), + RailRow::Lane(AgentRow::Lane { lane_index }) => lanes + .get(*lane_index) + .map(|lane| RailAnchor::Lane(lane.key.clone())), + RailRow::Lane(AgentRow::Sub { + lane_index, task, .. + }) => lanes.get(*lane_index).map(|lane| RailAnchor::Task { + lane: lane.key.clone(), + task_id: task.task_id.clone(), + }), + RailRow::Lane(AgentRow::More { lane_index, .. }) => lanes + .get(*lane_index) + .map(|lane| RailAnchor::Overflow(lane.key.clone())), + RailRow::Host(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None, + } +} + +/// Resolves an anchored cursor to its present offset, using `fallback` when gone. +pub(in crate::ui::app) fn resolve_rail_cursor( + rows: &[RailRow], + lanes: &[AgentLane], + anchor: Option<&RailAnchor>, + fallback: usize, +) -> usize { + if rows.is_empty() { + return 0; + } + anchor + .and_then(|anchor| { + rows.iter() + .position(|row| rail_anchor(row, lanes).as_ref() == Some(anchor)) + }) + .unwrap_or_else(|| fallback.min(rows.len() - 1)) +} + +impl App { + /// The rail offset the cursor is on, re-derived from its stable anchor. + pub(in crate::ui::app) fn rail_cursor(&self) -> usize { + self.rail_cursor_in(&self.rail_rows(), &self.lanes()) + } + + /// Resolves the rail cursor against rows and lanes already collected by a caller. + pub(in crate::ui::app) fn rail_cursor_in( + &self, + rows: &[RailRow], + lanes: &[AgentLane], + ) -> usize { + resolve_rail_cursor(rows, lanes, self.agent_anchor.as_ref(), self.agent_index) + } + + /// Moves the cursor to `index` and remembers the selected row by identity. + pub(in crate::ui::app) fn set_rail_cursor(&mut self, index: usize) { + let rows = self.rail_rows(); + let lanes = self.lanes(); + self.set_rail_cursor_in(&rows, &lanes, index); + } + + /// Moves the cursor using rows and lanes already collected by a caller. + pub(in crate::ui::app) fn set_rail_cursor_in( + &mut self, + rows: &[RailRow], + lanes: &[AgentLane], + index: usize, + ) { + self.agent_index = index.min(rows.len().saturating_sub(1)); + self.agent_anchor = rows + .get(self.agent_index) + .and_then(|row| rail_anchor(row, lanes)); + } + + /// Returns the rail cursor to its initial position without retaining its anchor. + pub(in crate::ui::app) fn reset_rail_cursor(&mut self) { + self.agent_index = 0; + self.agent_anchor = None; + } +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 44b102efa..0cffee15e 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -39,6 +39,7 @@ use super::types::App; use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; use crate::worker::pty::SessionRow; +mod cursor; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for @@ -51,6 +52,7 @@ mod merge_tests; pub(in crate::ui::app) mod tests; mod types; +pub(in crate::ui::app) use cursor::{rail_anchor, resolve_rail_cursor}; pub use types::{ AgentRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, WorkflowRunRailRow, }; @@ -67,61 +69,6 @@ pub(in crate::ui::app) const NEW_AGENT_LABEL: &str = "+ New agent"; /// one agent's group rather than an action on the machine. pub(in crate::ui::app) const NEW_SESSION_LABEL: &str = "+ new session"; -/// The identity of a selectable `row`, if it has one. -pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Option { - match row { - RailRow::NewAgent => Some(RailAnchor::NewAgent), - RailRow::Agent(agent) => Some(RailAnchor::Agent(agent.agent_id.clone())), - RailRow::Session(session) => session - .local - .as_ref() - .map(|local| RailAnchor::Session(local.id.clone())) - .or_else(|| { - session.task.as_ref().and_then(|task| { - session.lane_index.and_then(|index| { - lanes.get(index).map(|lane| RailAnchor::Task { - lane: lane.key.clone(), - task_id: task.task_id.clone(), - }) - }) - }) - }), - RailRow::NewSession { agent_id } => Some(RailAnchor::NewSession(agent_id.clone())), - RailRow::WorkflowRun(row) => Some(RailAnchor::WorkflowRun(row.run.run_id.clone())), - RailRow::Lane(AgentRow::Lane { lane_index }) => lanes - .get(*lane_index) - .map(|lane| RailAnchor::Lane(lane.key.clone())), - RailRow::Lane(AgentRow::Sub { - lane_index, task, .. - }) => lanes.get(*lane_index).map(|lane| RailAnchor::Task { - lane: lane.key.clone(), - task_id: task.task_id.clone(), - }), - RailRow::Lane(AgentRow::More { lane_index, .. }) => lanes - .get(*lane_index) - .map(|lane| RailAnchor::Overflow(lane.key.clone())), - RailRow::Host(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None, - } -} - -/// Resolves an anchored cursor to its present offset, using `fallback` when gone. -pub(in crate::ui::app) fn resolve_rail_cursor( - rows: &[RailRow], - lanes: &[AgentLane], - anchor: Option<&RailAnchor>, - fallback: usize, -) -> usize { - if rows.is_empty() { - return 0; - } - anchor - .and_then(|anchor| { - rows.iter() - .position(|row| rail_anchor(row, lanes).as_ref() == Some(anchor)) - }) - .unwrap_or_else(|| fallback.min(rows.len() - 1)) -} - /// One agent and the sessions hanging off it, before the tree is flattened. struct AgentGroup { /// The agent row itself. @@ -150,46 +97,6 @@ struct HostGroup { } impl App { - /// The rail offset the cursor is on, re-derived from its stable anchor. - pub(in crate::ui::app) fn rail_cursor(&self) -> usize { - self.rail_cursor_in(&self.rail_rows(), &self.lanes()) - } - - /// Resolves the rail cursor against rows and lanes already collected by a caller. - pub(in crate::ui::app) fn rail_cursor_in( - &self, - rows: &[RailRow], - lanes: &[AgentLane], - ) -> usize { - resolve_rail_cursor(rows, lanes, self.agent_anchor.as_ref(), self.agent_index) - } - - /// Moves the cursor to `index` and remembers the selected row by identity. - pub(in crate::ui::app) fn set_rail_cursor(&mut self, index: usize) { - let rows = self.rail_rows(); - let lanes = self.lanes(); - self.set_rail_cursor_in(&rows, &lanes, index); - } - - /// Moves the cursor using rows and lanes already collected by a caller. - pub(in crate::ui::app) fn set_rail_cursor_in( - &mut self, - rows: &[RailRow], - lanes: &[AgentLane], - index: usize, - ) { - self.agent_index = index.min(rows.len().saturating_sub(1)); - self.agent_anchor = rows - .get(self.agent_index) - .and_then(|row| rail_anchor(row, lanes)); - } - - /// Returns the rail cursor to its initial position without retaining its anchor. - pub(in crate::ui::app) fn reset_rail_cursor(&mut self) { - self.agent_index = 0; - self.agent_anchor = None; - } - /// The agent declarations this machine's config records. /// /// Read live rather than cached: [`declare_agent`](medulla::config::declare_agent) diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs index a4fdc364d..6a970ad35 100644 --- a/src/tui/src/ui/app/session_focus.rs +++ b/src/tui/src/ui/app/session_focus.rs @@ -99,13 +99,16 @@ impl App { // local PTY registry can change while this event is handled; rebuilding // here would let an insertion above the target retarget the cursor. let rows = self.rail_rows(); - let Some(row_index) = rows.iter().position(|row| { - matches!(row, RailRow::Session(session) if session - .task - .as_ref() - .is_some_and(|task| task.task_id == task_id) - && !session.origin().is_user()) - }) else { + let Some((row_index, agent, session_task_id)) = + rows.iter().enumerate().find_map(|(index, row)| { + let RailRow::Session(session) = row else { + return None; + }; + let task = session.task.as_ref()?; + (task.task_id == task_id && !session.origin().is_user()) + .then(|| (index, session.agent.clone(), task.task_id.clone())) + }) + else { self.set_status(format!("No session is running {task_id}")); return false; }; @@ -120,7 +123,7 @@ impl App { self.focus_agents_rail(); self.set_status(format!( "{} · {} · ^O returns to the orchestrator", - session.agent, session.task_id + agent, session_task_id )); true } From f9ce2022625744bf40459e0a38cc7476a4b553ac Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 14:43:36 +0300 Subject: [PATCH 06/14] fix(tui): stabilize rendered rail interactions --- src/tui/src/ui/app/agent_control.rs | 2 +- src/tui/src/ui/app/input/mouse.rs | 14 ++++++------- src/tui/src/ui/app/input/nav.rs | 4 ++-- src/tui/src/ui/app/input/tests.rs | 14 ++----------- src/tui/src/ui/app/rail/cursor.rs | 13 ++++++------ src/tui/src/ui/app/rail/mod.rs | 4 +++- src/tui/src/ui/app/render/agents/rail/mod.rs | 12 +++++++---- src/tui/src/ui/app/session_focus.rs | 9 ++++++-- src/tui/src/ui/app/types.rs | 22 +++++++++++++++++--- 9 files changed, 55 insertions(+), 39 deletions(-) diff --git a/src/tui/src/ui/app/agent_control.rs b/src/tui/src/ui/app/agent_control.rs index 6c54a1bd2..4b2cd69ae 100644 --- a/src/tui/src/ui/app/agent_control.rs +++ b/src/tui/src/ui/app/agent_control.rs @@ -150,7 +150,7 @@ impl App { /// The agent the rail cursor is on, or the one owning the session it is on. pub(in crate::ui::app) fn selected_agent_id(&self) -> Option { let rows = self.rail_rows(); - rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + rows.get(self.rail_cursor_in(&rows, &self.lanes())) .and_then(|row| row.agent_id()) .map(str::to_string) } diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 386f12ba2..55f44c2da 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -233,9 +233,9 @@ impl App { if !rect.contains((x, y).into()) { return None; } - let index = *owners.get((y - rect.y) as usize)?; - self.rail_rows() - .get(index)? + owners + .get((y - rect.y) as usize)? + .row .session_id() .map(str::to_string) } @@ -535,14 +535,12 @@ impl App { // covers the unselectable rows too — the `── functions ──` // separator — because `agent_index` indexes all of them. let rel = (y - rect.y) as usize; - let lanes = self.lanes(); - let rows = self.rail_rows(); - if let Some(row) = owners.get(rel).and_then(|idx| rows.get(*idx)) { + if let Some(hit) = owners.get(rel) { + let row = &hit.row; if row.selectable() { - let idx = owners[rel]; self.agent_scroll = 0; self.chat_scroll = 0; - self.set_rail_cursor_in(&rows, &lanes, idx); + self.set_rendered_rail_cursor(hit); // A click is a focus gesture: the arrows should now // continue from the row that was just picked. self.focus_agents_rail(); diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 783998734..163695e45 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -45,8 +45,8 @@ impl App { // `Host → Agent → Session` taxonomy an agent's own row is the declared // identity, and everything the fold still owns — the orchestrator lane, // the `── functions ──` divider, this counter — arrives as `Lane`. - let Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(self.agent_index) - else { + let cursor = self.rail_cursor_in(&rows, &self.lanes()); + let Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(cursor) else { return false; }; let (lane_index, hidden) = (*lane_index, *hidden); diff --git a/src/tui/src/ui/app/input/tests.rs b/src/tui/src/ui/app/input/tests.rs index 4227d67fc..e626093f6 100644 --- a/src/tui/src/ui/app/input/tests.rs +++ b/src/tui/src/ui/app/input/tests.rs @@ -84,15 +84,10 @@ fn draw(app: &mut App) { /// row offset, so the test exercises the same lookup a real click does. fn click_overflow_row(app: &mut App) -> Option { draw(app); - let overflow = app - .rail_rows() - .iter() - .position(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))) - .expect("a lane with hidden sublanes has an overflow row"); let (rect, owners) = app.hit_agents.clone().expect("the rail was drawn"); let line = owners .iter() - .position(|owner| *owner == overflow) + .position(|hit| matches!(hit.row, RailRow::Lane(AgentRow::More { .. }))) .expect("the overflow row is on screen"); app.handle_click(rect.x, rect.y + line as u16) } @@ -189,15 +184,10 @@ fn clicking_the_overflow_row_stops_a_task_stream_it_left_behind() { /// Click the first row `want` accepts, resolved through the rendered hit map. fn click_row(app: &mut App, want: impl Fn(&RailRow) -> bool) -> Option { draw(app); - let index = app - .rail_rows() - .iter() - .position(want) - .expect("the rail has the row under test"); let (rect, owners) = app.hit_agents.clone().expect("the rail was drawn"); let line = owners .iter() - .position(|owner| *owner == index) + .position(|hit| want(&hit.row)) .expect("the row is on screen"); app.handle_click(rect.x, rect.y + line as u16) } diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs index 1ce5c5531..f2e01bc71 100644 --- a/src/tui/src/ui/app/rail/cursor.rs +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -5,7 +5,7 @@ use super::{RailAnchor, RailRow}; use crate::ui::agents::{AgentLane, AgentRow}; -use crate::ui::app::types::App; +use crate::ui::app::types::{App, RailHit}; /// The identity of a selectable `row`, if it has one. pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Option { @@ -63,11 +63,6 @@ pub(in crate::ui::app) fn resolve_rail_cursor( } impl App { - /// The rail offset the cursor is on, re-derived from its stable anchor. - pub(in crate::ui::app) fn rail_cursor(&self) -> usize { - self.rail_cursor_in(&self.rail_rows(), &self.lanes()) - } - /// Resolves the rail cursor against rows and lanes already collected by a caller. pub(in crate::ui::app) fn rail_cursor_in( &self, @@ -97,6 +92,12 @@ impl App { .and_then(|row| rail_anchor(row, lanes)); } + /// Restores the cursor state captured with a rendered pointer target. + pub(in crate::ui::app) fn set_rendered_rail_cursor(&mut self, hit: &RailHit) { + self.agent_index = hit.index; + self.agent_anchor = hit.anchor.clone(); + } + /// Returns the rail cursor to its initial position without retaining its anchor. pub(in crate::ui::app) fn reset_rail_cursor(&mut self) { self.agent_index = 0; diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 0cffee15e..f78ce3b5f 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -52,7 +52,9 @@ mod merge_tests; pub(in crate::ui::app) mod tests; mod types; -pub(in crate::ui::app) use cursor::{rail_anchor, resolve_rail_cursor}; +pub(in crate::ui::app) use cursor::rail_anchor; +#[cfg(test)] +pub(in crate::ui::app) use cursor::resolve_rail_cursor; pub use types::{ AgentRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, WorkflowRunRailRow, }; diff --git a/src/tui/src/ui/app/render/agents/rail/mod.rs b/src/tui/src/ui/app/render/agents/rail/mod.rs index 39b5faa17..ada45dc58 100644 --- a/src/tui/src/ui/app/render/agents/rail/mod.rs +++ b/src/tui/src/ui/app/render/agents/rail/mod.rs @@ -18,7 +18,7 @@ use crate::ui::agents::{AgentLane, TaskStatus}; use crate::worker::pty::ATTENTION_GLYPH; use super::super::super::rail::{RailRow, NEW_AGENT_LABEL, NEW_SESSION_LABEL}; -use super::super::super::types::App; +use super::super::super::types::{App, RailHit}; use super::super::color; use super::types::{AgentsPanes, Selection}; @@ -160,7 +160,7 @@ impl App { // the same elapsed time throughout the frame, not a new one for each row. let now = medulla::clock::now_millis(); let mut lines: Vec = Vec::new(); - let mut owners: Vec = Vec::new(); + let mut owners: Vec = Vec::new(); let mut active_line = 0; let mut active_line_end = 0; for (index, row) in selection.rows.iter().enumerate() { @@ -176,7 +176,11 @@ impl App { now, ) { lines.push(line); - owners.push(index); + owners.push(RailHit { + row: row.clone(), + anchor: super::super::super::rail::rail_anchor(row, &selection.lanes), + index, + }); } if index == selection.active { active_line_end = lines.len(); @@ -196,7 +200,7 @@ impl App { }; self.hit_agents = Some(( nav_area, - owners.iter().skip(start).take(capacity).copied().collect(), + owners.into_iter().skip(start).take(capacity).collect(), )); let mut view: Vec = lines.into_iter().skip(start).take(capacity).collect(); device_footer.append_to(&mut view, Style::default().fg(self.theme.accent)); diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs index 6a970ad35..b83e9c22c 100644 --- a/src/tui/src/ui/app/session_focus.rs +++ b/src/tui/src/ui/app/session_focus.rs @@ -105,8 +105,13 @@ impl App { return None; }; let task = session.task.as_ref()?; - (task.task_id == task_id && !session.origin().is_user()) - .then(|| (index, session.agent.clone(), task.task_id.clone())) + (task.task_id == task_id && !session.origin().is_user()).then(|| { + ( + index, + session.agent_id.clone().unwrap_or_default(), + task.task_id.clone(), + ) + }) }) else { self.set_status(format!("No session is running {task_id}")); diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types.rs index b4efe7318..c5afd54e9 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types.rs @@ -1071,10 +1071,11 @@ pub struct App { pub(super) area: Rect, pub(super) hit_tabs: Vec<(u16, u16)>, pub(super) hit_tabs_row: u16, - /// Where the Agents rail drew, and which rail row each of its visible lines + /// Where the Agents rail drew, and the rendered row each visible line /// belongs to. A row may wrap onto several lines, so a click resolves - /// through this map rather than by adding an offset to a first-row index. - pub(super) hit_agents: Option<(Rect, Vec)>, + /// through this snapshot rather than by adding an offset to a freshly + /// rebuilt row list that may have changed since the frame was drawn. + pub(super) hit_agents: Option<(Rect, Vec)>, // Where the embedded session screen landed, and whose it is. Recorded so a // wheel event can be routed to the terminal under the pointer and given // coordinates relative to *its* origin rather than the screen's. @@ -1245,3 +1246,18 @@ pub struct App { /// flag, from `[harness].skipPermissions`. pub(super) harness_skip_permissions: bool, } + +/// One rendered Agents-rail line and the stable cursor state it represented. +/// +/// Pointer input happens after drawing, when live lanes may have changed. The +/// hit map therefore retains the row and its anchor from that frame instead of +/// treating a rendered offset as an offset into a new rail projection. +#[derive(Clone)] +pub(super) struct RailHit { + /// The rendered row, used to decide which click action to take. + pub(super) row: super::rail::RailRow, + /// The durable cursor identity resolved while the row was rendered. + pub(super) anchor: Option, + /// The row's rendered offset, retained only as a fallback if it has no anchor. + pub(super) index: usize, +} From 1a9ee3f6715842435fef7fe9629676878a7420be Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 15:12:08 +0300 Subject: [PATCH 07/14] fix(tui): stabilize rail cursor hit targets --- src/tui/src/ui/app/input/mouse.rs | 23 +++--- src/tui/src/ui/app/rail/cursor.rs | 9 ++- src/tui/src/ui/app/rail/mod.rs | 11 ++- src/tui/src/ui/app/render/agents/mod.rs | 2 +- src/tui/src/ui/app/render/agents/rail/mod.rs | 8 +-- src/tui/src/ui/app/state.rs | 4 +- src/tui/src/ui/app/types.rs | 73 +++++++++++++++++++- 7 files changed, 108 insertions(+), 22 deletions(-) diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 55f44c2da..259f13f98 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -233,11 +233,7 @@ impl App { if !rect.contains((x, y).into()) { return None; } - owners - .get((y - rect.y) as usize)? - .row - .session_id() - .map(str::to_string) + owners.get((y - rect.y) as usize)?.target.session_id() } /// Deliver a drag or release to the harness that took the matching press. @@ -536,8 +532,7 @@ impl App { // separator — because `agent_index` indexes all of them. let rel = (y - rect.y) as usize; if let Some(hit) = owners.get(rel) { - let row = &hit.row; - if row.selectable() { + if hit.selectable() { self.agent_scroll = 0; self.chat_scroll = 0; self.set_rendered_rail_cursor(hit); @@ -555,14 +550,16 @@ impl App { // no task, so a click arriving from one that did has // to stop that stream — and neither open method // clears `watching` on its own. - if row.is_new_agent() { + if matches!(&hit.target, super::super::types::RailHitTarget::NewAgent) { self.open_new_agent_picker(); return self.retarget_watch(); } // Same rule for the per-agent action: a click on // `+ new session` opens the flow it names. - if let Some(agent_id) = row.new_session_agent().map(str::to_string) { - self.open_new_session(&agent_id); + if let super::super::types::RailHitTarget::NewSession(agent_id) = + &hit.target + { + self.open_new_session(agent_id); return self.retarget_watch(); } // So is a lane's `+N more`: the click that lands on @@ -574,10 +571,12 @@ impl App { // from one has to stop that stream. The keyboard // path is already covered — the arrow that reaches // this row retargets on the way. - if self.page_subtasks() { + if matches!(&hit.target, super::super::types::RailHitTarget::Overflow) + && self.page_subtasks() + { return self.retarget_watch(); } - if let Some(session) = row.session_id() { + if let Some(session) = hit.target.session_id() { // Clicking the row of the harness the keyboard // is already in is not a handover request. It // used to raise "you still have this harness" diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs index f2e01bc71..05d514d33 100644 --- a/src/tui/src/ui/app/rail/cursor.rs +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -72,10 +72,17 @@ impl App { resolve_rail_cursor(rows, lanes, self.agent_anchor.as_ref(), self.agent_index) } + /// Resolves the stored cursor against one fresh rail and lane snapshot. + pub(in crate::ui::app) fn rail_cursor(&self) -> usize { + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + self.rail_cursor_in(&rows, &lanes) + } + /// Moves the cursor to `index` and remembers the selected row by identity. pub(in crate::ui::app) fn set_rail_cursor(&mut self, index: usize) { - let rows = self.rail_rows(); let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); self.set_rail_cursor_in(&rows, &lanes, index); } diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index f78ce3b5f..83577fb3b 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -142,7 +142,16 @@ impl App { /// more than one host to tell apart. pub(super) fn rail_rows(&self) -> Vec { let lanes = self.lanes(); - let (lane_rows, folded) = self.split_fold(&lanes); + self.rail_rows_in(&lanes) + } + + /// Assemble rail rows from one already-captured lane snapshot. + /// + /// Callers that also resolve a cursor anchor must use this with that same + /// snapshot: lane indexes in fold rows are meaningful only to the lanes + /// that produced them. + pub(super) fn rail_rows_in(&self, lanes: &[AgentLane]) -> Vec { + let (lane_rows, folded) = self.split_fold(lanes); let mut hosts = place_agents(&self.host_tree(), folded); let orphans = self.attach_sessions(&mut hosts); self.flatten(lane_rows, hosts, orphans) diff --git a/src/tui/src/ui/app/render/agents/mod.rs b/src/tui/src/ui/app/render/agents/mod.rs index fc78b0578..7cb71fbb0 100644 --- a/src/tui/src/ui/app/render/agents/mod.rs +++ b/src/tui/src/ui/app/render/agents/mod.rs @@ -84,7 +84,7 @@ impl App { /// Resolve what the rail cursor is on, clamping it to the rows that exist. fn agents_selection(&mut self) -> Selection { let lanes = self.lanes(); - let rows = self.rail_rows(); + let rows = self.rail_rows_in(&lanes); let active = self.rail_cursor_in(&rows, &lanes); self.set_rail_cursor_in(&rows, &lanes, active); // No fallback: a row with no lane keeps `None`, and the pane renders diff --git a/src/tui/src/ui/app/render/agents/rail/mod.rs b/src/tui/src/ui/app/render/agents/rail/mod.rs index ada45dc58..df4b18ef8 100644 --- a/src/tui/src/ui/app/render/agents/rail/mod.rs +++ b/src/tui/src/ui/app/render/agents/rail/mod.rs @@ -176,11 +176,11 @@ impl App { now, ) { lines.push(line); - owners.push(RailHit { - row: row.clone(), - anchor: super::super::super::rail::rail_anchor(row, &selection.lanes), + owners.push(RailHit::from_row( + row, + super::super::super::rail::rail_anchor(row, &selection.lanes), index, - }); + )); } if index == selection.active { active_line_end = lines.len(); diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index 4a09abcf6..c8e10adfc 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -267,7 +267,7 @@ impl App { /// Where the Agents rail cursor is. Test/inspection seam. pub fn agent_index(&self) -> usize { - self.agent_index + self.rail_cursor() } /// The current composer draft text. Test/inspection seam. @@ -629,7 +629,7 @@ impl App { /// never drawn, and every keystroke went into it. pub fn on_orchestrator_lane(&self) -> bool { let lanes = self.lanes(); - let rows = self.rail_rows(); + let rows = self.rail_rows_in(&lanes); match rows.get(self.rail_cursor_in(&rows, &lanes)) { // Only a lane's *own* row is a conversation. `AgentRow` also wraps // the `+N more` overflow control, which carries the lane index of diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types.rs index c5afd54e9..2f7849383 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types.rs @@ -1247,6 +1247,37 @@ pub struct App { pub(super) harness_skip_permissions: bool, } +/// The compact pointer action a rendered rail line represents. +/// +/// This deliberately excludes the rendered [`RailRow`](super::rail::RailRow): +/// sessions can retain their transcript, and a per-line hit map must not clone +/// that transcript for every wrapped or off-screen row. +#[derive(Clone)] +pub(super) enum RailHitTarget { + /// A non-selectable label or host row. + Inert, + /// A selectable row with no direct pointer action. + Selectable, + /// The action that opens the new-agent picker. + NewAgent, + /// The action that starts a session for this agent. + NewSession(String), + /// The action that pages an agent lane's tasks. + Overflow, + /// A row attached to this local harness session. + Session(String), +} + +impl RailHitTarget { + /// The local harness session this target names, if it names one. + pub(super) fn session_id(&self) -> Option { + match self { + Self::Session(session) => Some(session.clone()), + _ => None, + } + } +} + /// One rendered Agents-rail line and the stable cursor state it represented. /// /// Pointer input happens after drawing, when live lanes may have changed. The @@ -1254,10 +1285,50 @@ pub struct App { /// treating a rendered offset as an offset into a new rail projection. #[derive(Clone)] pub(super) struct RailHit { - /// The rendered row, used to decide which click action to take. + /// The compact action selected by this drawn line. + pub(super) target: RailHitTarget, + /// Test-only copy of the row so focused interaction tests can name it. + #[cfg(test)] pub(super) row: super::rail::RailRow, /// The durable cursor identity resolved while the row was rendered. pub(super) anchor: Option, /// The row's rendered offset, retained only as a fallback if it has no anchor. pub(super) index: usize, } + +impl RailHit { + /// Capture just the data pointer routing needs from a rendered rail row. + pub(super) fn from_row( + row: &super::rail::RailRow, + anchor: Option, + index: usize, + ) -> Self { + use super::rail::RailRow; + + let target = if row.is_new_agent() { + RailHitTarget::NewAgent + } else if let Some(agent_id) = row.new_session_agent() { + RailHitTarget::NewSession(agent_id.to_string()) + } else if matches!(row, RailRow::Lane(crate::ui::agents::AgentRow::More { .. })) { + RailHitTarget::Overflow + } else if let Some(session) = row.session_id() { + RailHitTarget::Session(session.to_string()) + } else if row.selectable() { + RailHitTarget::Selectable + } else { + RailHitTarget::Inert + }; + Self { + target, + #[cfg(test)] + row: row.clone(), + anchor, + index, + } + } + + /// Whether the cursor may land on this target. + pub(super) fn selectable(&self) -> bool { + !matches!(&self.target, RailHitTarget::Inert) + } +} From 4afae70a7b0633f1da487efb824d76d9e13a5e69 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 17:30:37 +0300 Subject: [PATCH 08/14] fix(tui): reject stale rail pointer targets --- src/tui/src/ui/app/input/mouse.rs | 10 ++ src/tui/src/ui/app/rail/cursor.rs | 24 +++-- src/tui/src/ui/app/rail/cursor_tests.rs | 40 +++++++- src/tui/src/ui/app/rail/mod.rs | 2 +- src/tui/src/ui/app/{types.rs => types/mod.rs} | 90 +---------------- src/tui/src/ui/app/types/rail_hit.rs | 99 +++++++++++++++++++ 6 files changed, 167 insertions(+), 98 deletions(-) rename src/tui/src/ui/app/{types.rs => types/mod.rs} (95%) create mode 100644 src/tui/src/ui/app/types/rail_hit.rs diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 583db8732..b0d027d7d 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -536,6 +536,16 @@ impl App { let rel = (y - rect.y) as usize; if let Some(hit) = owners.get(rel) { if hit.selectable() { + // The hit map describes the frame the operator saw, + // but actions below read the current rail. A row + // that vanished must not use its old numeric offset + // as a substitute target: that can page or watch a + // different row that happened to move into place. + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + if !hit.exists_in(&rows, &lanes) { + return None; + } self.agent_scroll = 0; self.chat_scroll = 0; self.set_rendered_rail_cursor(hit); diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs index 05d514d33..35cf1b834 100644 --- a/src/tui/src/ui/app/rail/cursor.rs +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -12,19 +12,25 @@ pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Opt match row { RailRow::NewAgent => Some(RailAnchor::NewAgent), RailRow::Agent(agent) => Some(RailAnchor::Agent(agent.agent_id.clone())), + // A dispatched row remains identified by its task after the local PTY + // is discovered. PTY enrichment must not move the cursor off that task + // while the live rail is being rebuilt asynchronously. RailRow::Session(session) => session - .local + .task .as_ref() - .map(|local| RailAnchor::Session(local.id.clone())) - .or_else(|| { - session.task.as_ref().and_then(|task| { - session.lane_index.and_then(|index| { - lanes.get(index).map(|lane| RailAnchor::Task { - lane: lane.key.clone(), - task_id: task.task_id.clone(), - }) + .and_then(|task| { + session.lane_index.and_then(|index| { + lanes.get(index).map(|lane| RailAnchor::Task { + lane: lane.key.clone(), + task_id: task.task_id.clone(), }) }) + }) + .or_else(|| { + session + .local + .as_ref() + .map(|local| RailAnchor::Session(local.id.clone())) }), RailRow::NewSession { agent_id } => Some(RailAnchor::NewSession(agent_id.clone())), RailRow::WorkflowRun(row) => Some(RailAnchor::WorkflowRun(row.run.run_id.clone())), diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs index 5c287d4c0..d8b5407c2 100644 --- a/src/tui/src/ui/app/rail/cursor_tests.rs +++ b/src/tui/src/ui/app/rail/cursor_tests.rs @@ -3,9 +3,9 @@ //! The live rail is rebuilt on every frame, so these cover the stable-anchor //! resolver independently of the `App` rendering loop. -use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; +use crate::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; -use super::{rail_anchor, resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow}; +use super::{rail_anchor, resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow, SessionRailRow}; fn agent(id: &str) -> RailRow { RailRow::Agent(AgentRailRow { @@ -66,3 +66,39 @@ fn an_overflow_anchor_uses_its_lanes_stable_key() { let rows = vec![RailRow::NewAgent, agent("new"), overflow]; assert_eq!(resolve_rail_cursor(&rows, &lanes, anchor.as_ref(), 0), 2); } + +#[test] +fn a_task_anchor_survives_local_pty_enrichment() { + let lanes = vec![lane("builder")]; + let task = TaskState { + task_id: "t-1".to_string(), + status: TaskStatus::Running, + turns: 0, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }; + let before = RailRow::Session(Box::new(SessionRailRow { + agent_id: Some("builder".to_string()), + lane_index: Some(0), + task: Some(task.clone()), + local: None, + last: true, + })); + let anchor = rail_anchor(&before, &lanes); + let after = RailRow::Session(Box::new(SessionRailRow { + local: Some(super::tests::stub_session("w_1")), + ..match before { + RailRow::Session(session) => *session, + _ => unreachable!("the fixture is a session"), + } + })); + + assert_eq!(rail_anchor(&after, &lanes), anchor); + assert_eq!( + resolve_rail_cursor(&[RailRow::NewAgent, after], &lanes, anchor.as_ref(), 0), + 1 + ); +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 7dcfb19f1..c7146ef79 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -39,10 +39,10 @@ use super::types::App; use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; use crate::worker::pty::SessionRow; -mod cursor; mod cleanup; #[cfg(test)] mod cleanup_tests; +mod cursor; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types/mod.rs similarity index 95% rename from src/tui/src/ui/app/types.rs rename to src/tui/src/ui/app/types/mod.rs index d0c484b9f..e6e921771 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types/mod.rs @@ -19,6 +19,10 @@ use medulla::client::{FeedbackComment, FeedbackItem, FeedbackQuery, FeedbackType use medulla::config::LoadedConfig; use medulla::runtime::{ContextItem, Runtime, RuntimeSnapshot, WorkerOp}; +mod rail_hit; + +pub(super) use rail_hit::{RailHit, RailHitTarget}; + /// The ordered top-level tab names. The tab index selects into this array. /// /// Trace and Context used to live here. They are secondary surfaces — @@ -1281,89 +1285,3 @@ pub struct App { /// flag, from `[harness].skipPermissions`. pub(super) harness_skip_permissions: bool, } - -/// The compact pointer action a rendered rail line represents. -/// -/// This deliberately excludes the rendered [`RailRow`](super::rail::RailRow): -/// sessions can retain their transcript, and a per-line hit map must not clone -/// that transcript for every wrapped or off-screen row. -#[derive(Clone)] -pub(super) enum RailHitTarget { - /// A non-selectable label or host row. - Inert, - /// A selectable row with no direct pointer action. - Selectable, - /// The action that opens the new-agent picker. - NewAgent, - /// The action that starts a session for this agent. - NewSession(String), - /// The action that pages an agent lane's tasks. - Overflow, - /// A row attached to this local harness session. - Session(String), -} - -impl RailHitTarget { - /// The local harness session this target names, if it names one. - pub(super) fn session_id(&self) -> Option { - match self { - Self::Session(session) => Some(session.clone()), - _ => None, - } - } -} - -/// One rendered Agents-rail line and the stable cursor state it represented. -/// -/// Pointer input happens after drawing, when live lanes may have changed. The -/// hit map therefore retains the row and its anchor from that frame instead of -/// treating a rendered offset as an offset into a new rail projection. -#[derive(Clone)] -pub(super) struct RailHit { - /// The compact action selected by this drawn line. - pub(super) target: RailHitTarget, - /// Test-only copy of the row so focused interaction tests can name it. - #[cfg(test)] - pub(super) row: super::rail::RailRow, - /// The durable cursor identity resolved while the row was rendered. - pub(super) anchor: Option, - /// The row's rendered offset, retained only as a fallback if it has no anchor. - pub(super) index: usize, -} - -impl RailHit { - /// Capture just the data pointer routing needs from a rendered rail row. - pub(super) fn from_row( - row: &super::rail::RailRow, - anchor: Option, - index: usize, - ) -> Self { - use super::rail::RailRow; - - let target = if row.is_new_agent() { - RailHitTarget::NewAgent - } else if let Some(agent_id) = row.new_session_agent() { - RailHitTarget::NewSession(agent_id.to_string()) - } else if matches!(row, RailRow::Lane(crate::ui::agents::AgentRow::More { .. })) { - RailHitTarget::Overflow - } else if let Some(session) = row.session_id() { - RailHitTarget::Session(session.to_string()) - } else if row.selectable() { - RailHitTarget::Selectable - } else { - RailHitTarget::Inert - }; - Self { - target, - #[cfg(test)] - row: row.clone(), - anchor, - index, - } - } - - /// Whether the cursor may land on this target. - pub(super) fn selectable(&self) -> bool { - !matches!(&self.target, RailHitTarget::Inert) - } -} diff --git a/src/tui/src/ui/app/types/rail_hit.rs b/src/tui/src/ui/app/types/rail_hit.rs new file mode 100644 index 000000000..f18a3bd88 --- /dev/null +++ b/src/tui/src/ui/app/types/rail_hit.rs @@ -0,0 +1,99 @@ +//! Compact, identity-bearing pointer targets for the rendered Agents rail. + +/// The compact pointer action a rendered rail line represents. +/// +/// This deliberately excludes the rendered [`RailRow`](super::super::rail::RailRow): +/// sessions can retain their transcript, and a per-line hit map must not clone +/// that transcript for every wrapped or off-screen row. +#[derive(Clone)] +pub(super) enum RailHitTarget { + /// A non-selectable label or host row. + Inert, + /// A selectable row with no direct pointer action. + Selectable, + /// The action that opens the new-agent picker. + NewAgent, + /// The action that starts a session for this agent. + NewSession(String), + /// The action that pages an agent lane's tasks. + Overflow, + /// A row attached to this local harness session. + Session(String), +} + +impl RailHitTarget { + /// The local harness session this target names, if it names one. + pub(super) fn session_id(&self) -> Option { + match self { + Self::Session(session) => Some(session.clone()), + _ => None, + } + } +} + +/// One rendered Agents-rail line and the stable cursor state it represented. +/// +/// Pointer input happens after drawing, when live lanes may have changed. The +/// hit map therefore retains the row and its anchor from that frame instead of +/// treating a rendered offset as an offset into a new rail projection. +#[derive(Clone)] +pub(super) struct RailHit { + /// The compact action selected by this drawn line. + pub(super) target: RailHitTarget, + /// Test-only copy of the row so focused interaction tests can name it. + #[cfg(test)] + pub(super) row: super::super::rail::RailRow, + /// The durable cursor identity resolved while the row was rendered. + pub(super) anchor: Option, + /// The row's rendered offset, retained only as a fallback if it has no anchor. + pub(super) index: usize, +} + +impl RailHit { + /// Capture just the data pointer routing needs from a rendered rail row. + pub(super) fn from_row( + row: &super::super::rail::RailRow, + anchor: Option, + index: usize, + ) -> Self { + use super::super::rail::RailRow; + + let target = if row.is_new_agent() { + RailHitTarget::NewAgent + } else if let Some(agent_id) = row.new_session_agent() { + RailHitTarget::NewSession(agent_id.to_string()) + } else if matches!(row, RailRow::Lane(crate::ui::agents::AgentRow::More { .. })) { + RailHitTarget::Overflow + } else if let Some(session) = row.session_id() { + RailHitTarget::Session(session.to_string()) + } else if row.selectable() { + RailHitTarget::Selectable + } else { + RailHitTarget::Inert + }; + Self { + target, + #[cfg(test)] + row: row.clone(), + anchor, + index, + } + } + + /// Whether the current rail projection still contains this rendered row. + pub(super) fn exists_in( + &self, + rows: &[super::super::rail::RailRow], + lanes: &[crate::ui::agents::AgentLane], + ) -> bool { + self.anchor.as_ref().is_some_and(|anchor| { + rows.iter() + .any(|row| super::super::rail::rail_anchor(row, lanes).as_ref() == Some(anchor)) + }) + } + + /// Whether the cursor may land on this target. + pub(super) fn selectable(&self) -> bool { + !matches!(&self.target, RailHitTarget::Inert) + } +} From 5bc66ce831492122e88d9a4d161e14134d7656c3 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 18:13:17 +0300 Subject: [PATCH 09/14] fix(tui): preserve rail snapshot identity --- src/tui/src/ui/app/commands/dispatch.rs | 5 +- src/tui/src/ui/app/input/nav.rs | 11 +- src/tui/src/ui/app/rail/cursor_tests.rs | 104 -- src/tui/src/ui/app/rail/mod.rs | 4 +- src/tui/src/ui/app/rail/tests.rs | 102 ++ src/tui/src/ui/app/types/mod.rs | 1292 +---------------------- src/tui/src/ui/app/types/model.rs | 1285 ++++++++++++++++++++++ src/tui/src/ui/app/types/rail_hit.rs | 18 +- 8 files changed, 1419 insertions(+), 1402 deletions(-) delete mode 100644 src/tui/src/ui/app/rail/cursor_tests.rs create mode 100644 src/tui/src/ui/app/types/model.rs diff --git a/src/tui/src/ui/app/commands/dispatch.rs b/src/tui/src/ui/app/commands/dispatch.rs index 56fdb7dd2..00c003bab 100644 --- a/src/tui/src/ui/app/commands/dispatch.rs +++ b/src/tui/src/ui/app/commands/dispatch.rs @@ -18,8 +18,9 @@ impl App { /// list alone is shorter than the rail and reading it here would answer for /// whichever row happened to share the offset. pub(in crate::ui::app) fn selected_agent_task(&self) -> Option { - let rows = self.rail_rows(); - rows.get(self.rail_cursor_in(&rows, &self.lanes())) + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + rows.get(self.rail_cursor_in(&rows, &lanes)) .and_then(|row| row.task()) .cloned() } diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 163695e45..2f56ffbd4 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -23,7 +23,16 @@ impl App { /// The current Agents-list rows, each lane paged to whatever the operator /// has expanded it to. pub(in crate::ui::app) fn agent_rows(&self) -> Vec { - agent_row_model_paged(&self.lanes(), SUBTASK_PAGE, |lane| { + let lanes = self.lanes(); + self.agent_rows_in(&lanes) + } + + /// Build paged fold rows from one already-captured lane snapshot. + pub(in crate::ui::app) fn agent_rows_in( + &self, + lanes: &[crate::ui::agents::AgentLane], + ) -> Vec { + agent_row_model_paged(lanes, SUBTASK_PAGE, |lane| { self.subtask_pages.get(&lane.key).copied().unwrap_or(0) }) } diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs deleted file mode 100644 index d8b5407c2..000000000 --- a/src/tui/src/ui/app/rail/cursor_tests.rs +++ /dev/null @@ -1,104 +0,0 @@ -//! Cursor identity tests for the Agents rail. -//! -//! The live rail is rebuilt on every frame, so these cover the stable-anchor -//! resolver independently of the `App` rendering loop. - -use crate::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; - -use super::{rail_anchor, resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow, SessionRailRow}; - -fn agent(id: &str) -> RailRow { - RailRow::Agent(AgentRailRow { - agent_id: id.to_string(), - host_id: String::new(), - agent: None, - lane_index: None, - }) -} - -fn lane(key: &str) -> AgentLane { - AgentLane { - key: key.to_string(), - label: String::new(), - role: AgentRole::Agent, - turns: Vec::new(), - last_at: 0, - tasks: Vec::new(), - context_tokens: None, - usage: Default::default(), - harness_label: None, - agent_id: None, - session_id: None, - parent_agent_id: None, - descriptor: None, - active_tasks: 0, - work: None, - } -} - -#[test] -fn an_anchored_agent_follows_rows_inserted_ahead_of_it() { - let anchor = RailAnchor::Agent("builder".to_string()); - let rows = vec![RailRow::NewAgent, agent("scout"), agent("builder")]; - - assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 0), 2); -} - -#[test] -fn a_missing_anchor_uses_the_clamped_previous_offset() { - let rows = vec![RailRow::NewAgent, agent("builder")]; - let anchor = RailAnchor::Agent("removed".to_string()); - - assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 99), 1); -} - -#[test] -fn an_overflow_anchor_uses_its_lanes_stable_key() { - let lanes = vec![lane("builder")]; - let overflow = RailRow::Lane(AgentRow::More { - lane_index: 0, - hidden: 3, - }); - - let anchor = rail_anchor(&overflow, &lanes); - assert_eq!(anchor, Some(RailAnchor::Overflow("builder".to_string()))); - - let rows = vec![RailRow::NewAgent, agent("new"), overflow]; - assert_eq!(resolve_rail_cursor(&rows, &lanes, anchor.as_ref(), 0), 2); -} - -#[test] -fn a_task_anchor_survives_local_pty_enrichment() { - let lanes = vec![lane("builder")]; - let task = TaskState { - task_id: "t-1".to_string(), - status: TaskStatus::Running, - turns: 0, - last_at: 0, - turn_blocks: Vec::new(), - attention: None, - question_id: None, - work: None, - }; - let before = RailRow::Session(Box::new(SessionRailRow { - agent_id: Some("builder".to_string()), - lane_index: Some(0), - task: Some(task.clone()), - local: None, - last: true, - })); - let anchor = rail_anchor(&before, &lanes); - let after = RailRow::Session(Box::new(SessionRailRow { - local: Some(super::tests::stub_session("w_1")), - ..match before { - RailRow::Session(session) => *session, - _ => unreachable!("the fixture is a session"), - } - })); - - assert_eq!(rail_anchor(&after, &lanes), anchor); - assert_eq!( - resolve_rail_cursor(&[RailRow::NewAgent, after], &lanes, anchor.as_ref(), 0), - 1 - ); -} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index c7146ef79..7db052f18 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -48,8 +48,6 @@ pub(in crate::ui::app) mod resolve; // the served-dispatch merge are separate responsibilities, and one file for // both had already grown past this repository's line ceiling. #[cfg(test)] -mod cursor_tests; -#[cfg(test)] mod merge_tests; #[cfg(test)] pub(in crate::ui::app) mod tests; @@ -164,7 +162,7 @@ impl App { fn split_fold(&self, lanes: &[AgentLane]) -> (Vec, Vec) { let mut lane_rows: Vec = Vec::new(); let mut groups: Vec = Vec::new(); - for row in self.agent_rows() { + for row in self.agent_rows_in(lanes) { match row { AgentRow::Lane { lane_index } => { let Some(lane) = lanes.get(lane_index) else { diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 0c9ebad19..3f58a924f 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -22,6 +22,108 @@ pub(in crate::ui::app) fn app() -> App { App::new(runtime, loaded) } +// Cursor identity is kept here with the rail's other unit coverage: the rail +// directory owns both the rows and the cursor that selects them. +fn cursor_agent(id: &str) -> RailRow { + RailRow::Agent(AgentRailRow { + agent_id: id.to_string(), + host_id: String::new(), + agent: None, + lane_index: None, + }) +} + +fn cursor_lane(key: &str) -> crate::ui::agents::AgentLane { + crate::ui::agents::AgentLane { + key: key.to_string(), + label: String::new(), + role: crate::ui::agents::AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: None, + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + +#[test] +fn an_anchored_agent_follows_rows_inserted_ahead_of_it() { + let anchor = RailAnchor::Agent("builder".to_string()); + let rows = vec![ + RailRow::NewAgent, + cursor_agent("scout"), + cursor_agent("builder"), + ]; + + assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 0), 2); +} + +#[test] +fn a_missing_anchor_uses_the_clamped_previous_offset() { + let rows = vec![RailRow::NewAgent, cursor_agent("builder")]; + let anchor = RailAnchor::Agent("removed".to_string()); + + assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 99), 1); +} + +#[test] +fn an_overflow_anchor_uses_its_lanes_stable_key() { + let lanes = vec![cursor_lane("builder")]; + let overflow = RailRow::Lane(AgentRow::More { + lane_index: 0, + hidden: 3, + }); + + let anchor = rail_anchor(&overflow, &lanes); + assert_eq!(anchor, Some(RailAnchor::Overflow("builder".to_string()))); + + let rows = vec![RailRow::NewAgent, cursor_agent("new"), overflow]; + assert_eq!(resolve_rail_cursor(&rows, &lanes, anchor.as_ref(), 0), 2); +} + +#[test] +fn a_task_anchor_survives_local_pty_enrichment() { + let lanes = vec![cursor_lane("builder")]; + let task = crate::ui::agents::TaskState { + task_id: "t-1".to_string(), + status: crate::ui::agents::TaskStatus::Running, + turns: 0, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }; + let before = RailRow::Session(Box::new(SessionRailRow { + agent_id: Some("builder".to_string()), + lane_index: Some(0), + task: Some(task), + local: None, + last: true, + })); + let anchor = rail_anchor(&before, &lanes); + let after = RailRow::Session(Box::new(SessionRailRow { + local: Some(stub_session("w_1")), + ..match before { + RailRow::Session(session) => *session, + _ => unreachable!("the fixture is a session"), + } + })); + + assert_eq!(rail_anchor(&after, &lanes), anchor); + assert_eq!( + resolve_rail_cursor(&[RailRow::NewAgent, after], &lanes, anchor.as_ref(), 0), + 1 + ); +} + /// The same app with a live (but empty) local host attached, so the rail is /// allowed to offer `+ New agent` and to list local sessions. pub(in crate::ui::app) fn hosting_app() -> App { diff --git a/src/tui/src/ui/app/types/mod.rs b/src/tui/src/ui/app/types/mod.rs index e6e921771..94fc1be28 100644 --- a/src/tui/src/ui/app/types/mod.rs +++ b/src/tui/src/ui/app/types/mod.rs @@ -1,1287 +1,13 @@ -//! The data model for the interactive TUI screen: the tab list, multi-pane -//! navigation constants, the [`Cmd`] the event loop runs on the app's behalf, the -//! small overlay/state types ([`ResumePicker`], [`Prompt`], [`PromptKind`], -//! and the central [`App`] struct itself. +//! The data model for the interactive TUI screen. //! -//! Behaviour lives in the sibling modules ([`super::state`], [`super::input`], -//! [`super::keys`], [`super::commands`], and [`super::render`]), each of which -//! adds its own `impl App` block. Because those blocks share `App`'s private -//! fields, the fields (and the private helper types/consts here) are -//! `pub(super)` so every sibling submodule can reach them. - -use std::sync::Arc; - -use ratatui::layout::Rect; - -use crate::ui::composer::{Draft, TextPrompt}; -use crate::ui::theme::Theme; -use medulla::client::{FeedbackComment, FeedbackItem, FeedbackQuery, FeedbackType}; -use medulla::config::LoadedConfig; -use medulla::runtime::{ContextItem, Runtime, RuntimeSnapshot, WorkerOp}; +//! This module is deliberately wiring only. Screen state and its supporting +//! model types live in [`model`], while the compact rendered rail hit map lives +//! in [`rail_hit`]. Keeping those responsibilities separate lets the app's +//! sibling input, rendering, and command modules share the model without +//! turning this directory module into another monolithic source file. +mod model; mod rail_hit; -pub(super) use rail_hit::{RailHit, RailHitTarget}; - -/// The ordered top-level tab names. The tab index selects into this array. -/// -/// Trace and Context used to live here. They are secondary surfaces — -/// two of them diagnostic — so they now sit under Settings, keeping the tab bar -/// to the views a session is actually driven from. -/// -/// Chat used to live here too, and is now the Agents tab: talking to the -/// orchestrator *is* selecting its lane and typing. Splitting them meant reading -/// what an operation was doing on one tab and steering it on another, with two -/// scroll positions and no way to answer an agent's question from where the -/// question was visible. -/// -/// Workflows used to be a Routing subpage. It is a tab because it is not a -/// management surface: Routing is where an operator declares what capacity -/// exists, and a workflow is *work* — a plan they read, edit, and run, with a -/// graph to navigate and a copilot to edit it by. Three panes' worth of surface -/// does not fit in a subpage of something else. -/// `Tasks` and `Memory` are commented out rather than deleted: the code behind -/// both still builds and their render paths are intact, so restoring either is -/// putting one line back. Memory is out of the build entirely (its tab said -/// "coming soon"); Tasks duplicates what the Agents tab already shows per lane. -#[cfg(feature = "workflows")] -pub const TABS: [&str; 7] = [ - "Overview", - "Agents", - "Workflows", - "Changes", - "Hosts", - "Feedback", - "Settings", -]; - -/// Without the workflow engine. A slim build must not offer a tab that cannot -/// draw anything. -#[cfg(not(feature = "workflows"))] -pub const TABS: [&str; 6] = [ - "Overview", "Agents", "Changes", "Hosts", "Feedback", "Settings", -]; - -/// The Routing tab's left-nav pages. -/// -/// Ordered by the containment chain. `Hosts` is the machine level the operator -/// registers and steers by hand; `Harness Types` is the runtime level, which is -/// where credentials live — a subscription or an API key is a property of the -/// CLI runtime that spends it, not of the machine it happens to sit on; -/// `Workspaces` is the folder level, which is what the orchestrator actually -/// reasons about — a machine is capacity, a directory is *work*; `Agent -/// Templates` is the catalog of what may be provisioned onto any of it. `Add -/// Host` and `Strategies` are the two actions that belong to no level. -/// -/// There is no `Fleet` page: the whole declared tree lives in the Agents rail, -/// beside the lanes running on it. These pages are the *management* surfaces — -/// what you register, authenticate, and choose — not the picture. Workflows is -/// not here either: it is a tab of its own (see [`TABS`]). -/// Ordered by the containment chain, as before: the machine, what runs on it, -/// what may be stood up there, how to add another, and how work is routed -/// between them. -/// -/// Only Workspaces is commented out. An entry there was advisory routing -/// context; declaring an agent is what actually puts work in a directory, and -/// that is done from the host tree. Its draw arm, keys and `[host].workspaces` -/// persistence all still build, so restoring it is putting its name back here -/// and renumbering. -pub const ROUTING_SUBPAGES: [&str; 6] = [ - "Hosts", - "Harness Types", - "Hooks", - "Agent Templates", - "Add Host", - "Strategies", -]; - -pub(super) const RP_HOSTS: usize = 0; -pub(super) const RP_HARNESSES: usize = 1; -// Beside Harness Types on purpose: a hook is a property of every harness -// Medulla launches, and this page is the one place they are declared for all of -// them. -pub(super) const RP_HOOKS: usize = 2; -pub(super) const RP_TEMPLATES: usize = 3; -pub(super) const RP_ADD_HOST: usize = 4; -pub(super) const RP_STRATEGIES: usize = 5; -// Past the end of `ROUTING_SUBPAGES`, so the nav clamp cannot reach it and its -// arm is unreachable — the page is off without its code rotting. -pub(super) const RP_WORKSPACES: usize = 6; - -/// The TokenMaxxxing tab's sidebar pages. -pub(super) const TOKENMAXXING_SUBPAGES: [&str; 3] = ["Overview", "Bounties", "Leaderboard"]; - -pub(super) const TM_OVERVIEW: usize = 0; -pub(super) const TM_BOUNTIES: usize = 1; -pub(super) const TM_LEADERBOARD: usize = 2; - -pub(super) use super::routing_options::{ROUTING_STRATEGIES, SUBSCRIPTION_STRATEGIES}; - -/// The Settings tab's left-nav subpages, in order (number keys 1-9 jump to them). -/// -/// This is the flat, selectable list [`App::settings_index`] indexes into. -/// [`SETTINGS_GROUPS`] overlays the display-only headings. -pub const SETTINGS_SUBPAGES: [&str; 9] = [ - "Usage", - "Appearance", - "Status line", - "Config", - "Feedback", - "Trace", - "Context", - "Account", - "Help", -]; - -/// The left-nav group headings, as `(heading, first subpage index)`. -/// -/// Headings are rendered dim and are not selectable — they exist to separate the -/// everyday settings from the diagnostic ones. Each group runs until the next -/// group's start index. -pub const SETTINGS_GROUPS: [(&str, usize); 3] = [ - ("GENERAL", SP_USAGE), - ("DEBUG", SP_TRACE), - ("ABOUT", SP_ACCOUNT), -]; - -// Settings subpage indices. -pub(super) const SP_USAGE: usize = 0; -pub(super) const SP_APPEARANCE: usize = 1; -pub(super) const SP_STATUS_LINE: usize = 2; -pub(super) const SP_CONFIG: usize = 3; -pub(super) const SP_FEEDBACK: usize = 4; -pub(super) const SP_TRACE: usize = 5; -pub(super) const SP_CONTEXT: usize = 6; -pub(super) const SP_ACCOUNT: usize = 7; -pub(super) const SP_HELP: usize = 8; - -/// The index of a tab by name, or 0 if unknown. Keeps tab jumps robust as the tab -/// list grows. -pub(super) fn tab_pos(name: &str) -> usize { - TABS.iter().position(|t| *t == name).unwrap_or(0) -} - -/// Which half of the Agents tab the keyboard is driving. -/// -/// The tab merges a list (the rail) with a text input (the composer), and a -/// terminal has one keyboard for both. Typing has to work the instant the tab -/// opens — that is the point of folding chat in here — so the composer holds -/// focus by default and the bare arrows belong to the caret. -/// -/// That left the rail reachable only by `Alt`+`↑`/`↓`, which most macOS -/// terminals do not send at all unless the user has rebound the Option key. -/// Focus is therefore explicit and movable, matching the menu/content model -/// Settings and Routing already use: `Esc` steps out to the rail, `Enter` (or -/// simply typing) steps back in. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum AgentsFocus { - /// The composer has the keyboard: arrows move the caret, Enter submits. - #[default] - Composer, - /// The rail has the keyboard: arrows walk the rows, Enter returns below. - Rail, -} - -/// What the pane beside the rail is showing for the selected harness. -/// -/// The harness screen is not the only thing worth looking at for a session, and -/// the alternatives are all *about* that session rather than beside it: what it -/// has changed, and — as more of them land — what it is running. So they take -/// the pane's real estate rather than opening somewhere else, the way a tab -/// switch replaces a page: one thing on screen, one key to swap it, and the -/// rail cursor never moves. -/// -/// Scoped to the selected session and reset when the cursor moves off it -/// ([`App::resolve_selected_session`](crate::ui::app)): a view opened to answer -/// a question about one harness must not stay open over the next one, where it -/// would be showing another session's diff under this session's row. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum PaneView { - /// The harness's own terminal — what it is painting right now. - #[default] - Harness, - /// What the harness has changed since it launched. - Diff, -} - -/// Which pane of the Workflows tab has the keyboard. -/// -/// Focus is split by *mode*, the way Settings and Routing split theirs: the -/// sidebar picks what is being looked at and hands over with `Enter`, the canvas -/// walks the graph, and the copilot is a composer that takes every printable -/// key. `Esc` steps back out one level at a time. -/// -/// `Tab` is not part of this. It belongs to the top-level view ring, and a tab -/// that cycled its own panes with it would be a tab inside a tab — `c` reaches -/// the copilot instead. -#[cfg(feature = "workflows")] -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum WorkflowFocus { - /// The catalogue sidebar: arrows walk workflows and their runs. - #[default] - Sidebar, - /// The graph canvas: arrows walk nodes along their edges. - Canvas, - /// The copilot composer: printable keys type, Enter sends. - Copilot, -} - -/// What the Workflows content pane is showing. -/// -/// One view at a time, beside the catalogue sidebar — the same two-pane shape -/// as Routing and Settings. Derived from [`WorkflowFocus`] and the inspector -/// toggle by [`App::workflow_view`] rather than stored, so it cannot drift from -/// the state that decides it. -#[cfg(feature = "workflows")] -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum WorkflowView { - /// The laid-out graph, with any selected run overlaid on it. - Graph, - /// The selected node's declaration, and how a run left it. - Inspector, - /// The conversation that edits the graph. - Copilot, -} - -/// Everything the Workflows tab holds that is not the catalogue itself. -/// -/// Grouped into one struct rather than a dozen `workflow_*` fields on [`App`]: -/// the tab has three panes with their own cursors, and a flat namespace made it -/// impossible to see which cursor belonged to which pane. -#[cfg(feature = "workflows")] -#[derive(Debug, Default)] -pub struct WorkflowsState { - /// Which pane has the keyboard. - pub(super) focus: WorkflowFocus, - /// Whether the rail cursor is on the "New workflow" row. - /// - /// Its own flag rather than a sentinel value of the catalogue index, - /// because the New row is not a workflow: it has no graph to draw, no runs - /// to list, and nothing to run. Everything that reads the selection has to - /// answer "or is it the new one?" and a magic index would let that question - /// go unasked. - pub(super) creating: bool, - /// The selected workflow's run, when the rail cursor is on one of the run - /// rows nested under it rather than on the workflow itself. - /// - /// A tree cursor rather than an index into a flattened row list: the rows - /// under a workflow only exist while it is selected, so a flat index would - /// have to be reinterpreted every time the cursor crossed a workflow - /// boundary — and gets it wrong the moment a run appears mid-scroll. - pub(super) run_index: Option, - /// The selected workflow's graph, as last read from the store. Cached - /// because a render pass must not touch the disk, and re-laying it out every - /// frame would move boxes under the cursor. - pub(super) graph: Option>, - /// The selected workflow's own choice of harness and model, cached with the - /// graph and for the same reason. Not part of the graph, so a preview - /// reading only [`graph`](Self::graph) would report the host's harness for a - /// workflow that pinned its own. - pub(super) defaults: medulla::workflows::WorkflowDefaults, - /// The laid-out form of [`graph`](Self::graph). - pub(super) layout: medulla::ui::workflows::GraphLayout, - /// Selected node in the canvas, in the layout's reading order. - pub(super) node_index: usize, - /// Vertical scroll of the canvas, in rows. - /// - /// The only scroll the canvas has: the graph folds onto a new band whenever - /// a layer would run past the right edge, so it is never wider than the - /// pane and there is nothing to scroll horizontally. Counted in rows rather - /// than lanes because a fold puts a band boundary between two lanes, and a - /// scroll measured in lanes cannot address the gap. - pub(super) canvas_row: usize, - /// Rows inside the graph panel during its most recent render. - /// - /// Navigation uses this measured viewport rather than the full terminal - /// height, because the selected-node preview shares the content column. - pub(super) graph_rows: usize, - /// Top line of the rich selected-step preview. - pub(super) preview_scroll: usize, - /// Whether the inspector below the canvas is expanded over it. - pub(super) inspector_open: bool, - /// The run being overlaid on the graph, when a run row is selected. - pub(super) overlay: Option, - /// One copilot thread per workflow, so switching in the rail does not show - /// the previous workflow's conversation or lose this one's. - pub(super) copilots: std::collections::HashMap, - /// The copilot composer's draft. - pub(super) draft: Draft, - /// Scroll offset in the copilot transcript, in lines from the bottom. - pub(super) copilot_scroll: usize, -} - -/// An async action the event loop must run on the app's behalf. -#[derive(Debug)] -pub enum Cmd { - /// Exit the application. - Quit, - /// Submit a composer line as a new conversational turn. - Submit(String), - /// Resume a previously saved chat by session id. - Resume(String), - /// Fetch the list of resumable chats for the resume picker. - ListChats, - /// Re-inspect the runtime's context chunks for the Context tab. - InspectContext, - /// Clear the session this host is signed in with. - Logout, - /// Apply a worker fleet mutation. - WorkerOp(WorkerOp), - /// Apply several fleet mutations as one operator action. - /// - /// Removing a *host* is the case this exists for: a host is a group of - /// roster entries sharing an address, and the registry has no host-level - /// op — so taking one out means taking each of its agents out. Carrying - /// them together keeps that one keypress one status line, rather than N - /// racing "Worker registry updated" messages for what the operator did - /// once. They are applied in order, and a failure reports the op it - /// stopped on instead of being swallowed by the next success. - WorkerOps(Vec), - /// Retarget the live screen subscription: stop watching one task, start - /// watching another. Both halves ride one command so the change is atomic - /// from the loop's point of view — a stop that landed without its start - /// would leave the pane blank with nothing on the way. - WatchTask { - /// The `(worker address, task id)` to stop streaming, if any. - stop: Option<(String, String)>, - /// The `(worker address, task id)` to start streaming, if any. - start: Option<(String, String)>, - }, - /// Kill the session serving a watched task after UI confirmation. - KillTask { - /// The worker address that owns the session. - worker: String, - /// The dispatched task whose session should be killed. - task_id: String, - }, - /// Push a handoff brief for a session the operator just gave back. - /// - /// Off the render thread because it does two things that must not block a - /// frame: shells out to `git` for the branch, and awaits a socket emit. - /// Arrives with `branch`/`project` unset — the dispatcher fills them. - HandOffSession(Box), - /// Tell the orchestrator the operator has taken the session in a workspace. - HoldSession { - /// The workspace being taken. - workspace: String, - /// Why, when the operator said. - reason: Option, - }, - /// Fetch account-level usage from the backend for the Usage tab. - LoadUsage, - /// Load a page of the feedback board for the Feedback surface. - LoadFeedback(FeedbackQuery), - /// Load one board item's comments for the detail pane. - LoadFeedbackDetail(String), - /// Cast, change, or retract a vote on a board item. - VoteFeedback { - /// The item being voted on. - id: String, - /// `1` upvote, `-1` downvote, `0` retract. - value: i8, - }, - /// Post a comment on a board item. - CommentFeedback { - /// The item being commented on. - id: String, - /// The comment text. - body: String, - }, - /// Submit new feedback to the board. - SubmitFeedback { - /// Feature request or bug report. - kind: FeedbackType, - /// The submission's title. - title: String, - /// The submission's body. - body: String, - }, - /// Re-read the declared fleet (roster + capacity) from the runtime. - RefreshFleet, - /// Run an installed workflow on this machine. - /// - /// Off-thread like every other filesystem/process command: a workflow run - /// dispatches real agent sessions and takes minutes, so doing it on the - /// render thread would freeze the app for the whole run. - #[cfg(feature = "workflows")] - RunWorkflow { - /// The workflow to run. - id: String, - /// Values for the workflow's declared inputs, collected from the - /// operator before this command was emitted. Empty when the workflow - /// declares none. - inputs: serde_json::Map, - }, - /// Ask the copilot to change or explain a workflow. - /// - /// Off-thread for the same reason a run is: the turn starts a real agent - /// session, and the pane it reports into has to keep repainting while it - /// does. - #[cfg(feature = "workflows")] - CopilotTurn { - /// The workflow the turn is scoped to. - workflow: String, - /// The operator's instruction, verbatim. - instruction: String, - }, - /// Ask the copilot to build a workflow that does not exist yet. - /// - /// Separate from [`Cmd::CopilotTurn`] because it has no workflow to name: - /// the agent is told to call `workflow_create`, and which workflow appeared - /// is worked out from the store afterwards. - #[cfg(feature = "workflows")] - CreateWorkflow { - /// Which copilot thread the turn's progress and result belong to. - /// - /// Carried rather than assumed: the thread for a workflow that does not - /// exist is keyed by a sentinel the app owns, and an event loop that - /// had to know that sentinel would be a second place it is spelled. - thread: String, - /// The operator's description of what they want, verbatim. - instruction: String, - }, - /// Simulate a workflow without dispatching anything, and report the result. - #[cfg(feature = "workflows")] - DryRunWorkflow { - /// The workflow to simulate. - id: String, - /// Values for the workflow's declared inputs — a simulation resolves - /// `=inputs.` bindings like a real run, so it needs them too. - inputs: serde_json::Map, - }, - /// Take back a workflow's most recent edit. - /// - /// Off-thread with the rest: it reads the history directory and writes a - /// definition, and the store's methods are synchronous by contract. - #[cfg(feature = "workflows")] - UndoWorkflow { - /// The workflow to restore. - id: String, - }, - /// Stop the copilot turn running on a thread. - #[cfg(feature = "workflows")] - AbortCopilot { - /// Which copilot thread to stop. - thread: String, - }, - /// Ask the copilot to diagnose a failed run and fix its cause. - /// - /// Separate from [`Cmd::CopilotTurn`] because it carries the failure: the - /// run, its error, and the nodes implicated. All three are on screen when - /// the operator presses the key, and a turn that had to rediscover them - /// would start a step behind. - #[cfg(feature = "workflows")] - RepairWorkflow { - /// The workflow the run belongs to. - workflow: String, - /// The operator's words, if they typed any. - instruction: String, - /// The run to diagnose. - run_id: String, - }, - /// Review a workflow against its own history. - /// - /// Unlike [`Cmd::RepairWorkflow`], this turn may not edit: it records what - /// it learns and proposes changes for the operator to accept. The two are - /// separate commands rather than one with a flag because they are different - /// asks — repair is "fix this now", review is "what should change". - #[cfg(feature = "workflows")] - EvolveWorkflow { - /// The workflow to review. - workflow: String, - /// The failed run to lead with, when the review was triggered by one. - run_id: Option, - }, - /// Apply a proposed change to the saved graph. - #[cfg(feature = "workflows")] - AcceptProposal { - /// The workflow being changed, so the pane can be refreshed. - workflow: String, - /// The proposal to apply. - proposal_id: String, - }, - /// Turn a proposed change down. - #[cfg(feature = "workflows")] - RejectProposal { - /// The workflow the proposal was for. - workflow: String, - /// The proposal to decline. - proposal_id: String, - /// Why, recorded as a note so a later review does not propose it again. - reason: String, - }, -} - -/// The modal state for the "resume a chat" picker overlay. -pub(super) struct ResumePicker { - /// The resumable chats to choose from. - pub(super) chats: Vec, - /// The highlighted row. - pub(super) index: usize, -} - -/// An overlay the app can draw over the content pane. -/// -/// Ordered as they stack, back to front: the two that float over the content, -/// then the session picker, then the question asked about a session being -/// released, and finally the two that claim a row of their own below it. -/// -/// Produced by [`App::visible_overlays`], which is the single source of truth -/// for what is in front of the content — see [`super::overlays`]. -#[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) enum Overlay { - /// The prepared-decision board. - Decisions, - /// The agent-template detail popup. - TemplatePopup, - /// The "start a session" picker. - AgentPicker, - /// The question asked when the operator lets go of a session. - HandbackPrompt, - /// The shared single-line prompt (Workers add/edit, Agents answer). - InlinePrompt, - /// The saved-chat resume picker. - ResumePicker, -} - -/// What the harness-type/workspace picker is being used for. -/// -/// The same two steps — pick a CLI, pick a directory — answer both questions the -/// Agents tab asks, and they differ only in what happens at the end. Declaring an -/// agent writes `harness × workspace` to the config and starts nothing; spawning -/// starts a session and declares nothing. Carrying the intent on the picker keeps -/// one overlay rather than two that would drift apart. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum PickerPurpose { - /// Start a session here and now, declaring nothing — the `/session` path. - Spawn, - /// Declare an agent: `harness × workspace`, named on the step after. - DeclareAgent, -} - -/// The modal state for the harness-type/workspace picker overlay. -pub(super) struct AgentPicker { - /// What confirming the last step will do. - pub(super) purpose: PickerPurpose, - /// Installed providers and registered presets, in offer order. - pub(super) choices: Vec, - /// The highlighted row. - pub(super) index: usize, - /// Which half of the two-step picker owns the keyboard. - pub(super) step: AgentPickerStep, - /// Default directory used to seed the editable workspace query. - pub(super) cwd: String, - /// Inline fuzzy-completion text on the workspace step. - pub(super) workspace_query: String, - /// Cached workspace rows, refreshed only when the query changes. - pub(super) workspace_choices: Vec, - /// Highlighted workspace completion. - pub(super) workspace_index: usize, - /// Whether the operator has deliberately picked one of the completions. - /// - /// Distinct from `workspace_index != 0`, which cannot express it: a query - /// that offers a single completion leaves the cursor on row zero however - /// deliberately it was moved there. Set by the arrows, cleared whenever the - /// query changes, and read by - /// [`selected_picker_workspace`](App::selected_picker_workspace) to decide - /// whether an entered directory outranks the completions listed under it. - pub(super) workspace_picked: bool, -} - -/// Active stage of the manual session launcher. -/// -/// There is deliberately no "managed or unmanaged?" stage. A session the -/// operator starts by hand is theirs — that is what starting it by hand *means* -/// — and the orchestrator spawns its own sessions managed without asking -/// anybody. So the question only ever had one sensible answer, and asking it -/// bought a keystroke, an extra screen, and a freshly started session the -/// operator then had to take back from the orchestrator before typing into it. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AgentPickerStep { - /// Choose an installed CLI or registered preset. - Harness, - /// Choose or complete the working directory. - Workspace, -} - -/// One cached workspace completion and why it was suggested. -#[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct WorkspaceChoice { - /// Absolute directory path. - pub(super) path: String, - /// Short operator-facing provenance such as `recent` or `folder`. - pub(super) source: &'static str, -} - -/// A pointer gesture a harness owns until the button comes back up. -/// -/// Terminals grab the pointer on press: every drag and the release belong to -/// whoever took the press, regardless of where the pointer has moved to since. -/// The embedded pane has to do the same, because the alternatives are both -/// visible failures — a release that lands outside the pane, or one swallowed -/// by the hand-back question the click itself opened, leaves the child holding -/// a button nobody is pressing. Claude Code and Codex then read every later -/// motion as a drag and anchor their popups to a press the operator has long -/// since let go of. -#[derive(Clone)] -pub(super) struct PointerGrab { - /// The session that received the press. - pub(super) session: String, - /// The button that went down, so a second button's events are not stolen. - pub(super) button: crate::ui::harness_pane::mouse::Button, - /// Where that session's pane was when the press landed. - /// - /// Carried rather than re-read from `hit_session` because the grab has to - /// outlive the pane: the click that opened a modal, detached the harness, - /// or scrolled the rail can move or remove the rect before the release - /// arrives, and the release still has to be encoded against the geometry - /// the child believes it has. - pub(super) rect: Rect, -} - -/// The "you still hold this session" confirmation shown on release. -/// -/// Modelled on an unsaved-changes prompt, and for the same reason: an operator -/// who took a session over and walked away has left the orchestrator locked out -/// of it, and the moment they release the keyboard is the only moment they are -/// certainly thinking about it. Silently handing it back would be worse — it -/// would resume dispatch into a session mid-thought. -pub(super) struct HandbackPrompt { - /// The session the question is about. - /// - /// Every answer acts on this, never on whatever the rail last resolved: the - /// question can outlive the frame that raised it, and a `y` that moved - /// control of a *different* session is the worst outcome this whole flow - /// has. - pub(super) session: String, - /// Whether attaching is what took control, as opposed to an explicit - /// `/takecontrol`. An explicit take is a decision, so the prompt says so - /// rather than implying the operator got here by accident. - pub(super) took_control: bool, - /// What the operator wants continued, typed into the prompt. - /// - /// This is the moment they actually have the context — they are leaving the - /// session *now* — so it is the one place worth asking. `/handoff ` - /// exists for the operator who already knows; this is for the one who is - /// only reminded by being asked. - pub(super) note: crate::ui::composer::Draft, - /// Whether keystrokes are going into the note rather than answering. - /// - /// Modal because `y`/`n` have to keep meaning yes and no: an operator who - /// starts typing a note that begins with "no, ..." must not have the first - /// letter answer the question for them. - pub(super) editing_note: bool, - /// Which direction the question is about: `true` asks whether to take the - /// session from the orchestrator, `false` whether to hand it back. - /// - /// One prompt for both because they are the same decision seen from either - /// side, and the answer is the same keystroke — but the sentence has to say - /// which way control is about to move, or the operator confirms the - /// opposite of what they meant. - pub(super) is_takeover: bool, -} - -/// How the operator came to hold a session the orchestrator had. -/// -/// Only the wording of the release question turns on this — both origins ask, -/// because both locked dispatch out of a workspace. What does *not* appear here -/// is "started it myself": that session was never taken from anyone, so it is -/// absent from [`App::sessions_taken`] rather than being a third variant. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum TakeOrigin { - /// Focusing in took it, which the operator may not have realised. - Focus, - /// `/takecontrol`, `Ctrl-G`, or answering the takeover question — a decision. - Explicit, -} - -/// What to do when the operator releases a session they took. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] -pub enum HandbackPolicy { - /// Ask, every time. - #[default] - Ask, - /// Always hand back without asking. - Always, - /// Never hand back; releasing the keyboard keeps control. - Never, -} - -impl HandbackPolicy { - /// Parse the `[harness].handback` config value, falling back to - /// [`Ask`](Self::Ask) for anything unrecognized — a typo in a config file - /// should not silently change who controls a session. - pub fn from_config(value: &str) -> Self { - match value.trim().to_ascii_lowercase().as_str() { - "always" => HandbackPolicy::Always, - "never" => HandbackPolicy::Never, - _ => HandbackPolicy::Ask, - } - } -} - -/// The action a small inline prompt (Hosts add/edit, Agents answer) submits. -pub(super) enum PromptKind { - /// Select an arbitrary Git revision as the Changes comparison baseline. - ChangesBaseline, - /// Attach a session-local review comment to a file, hunk, or patch line. - ChangesComment { - /// Repository-relative path being reviewed. - path: std::path::PathBuf, - /// Position within that file's patch the note is bound to. - anchor: medulla::ui::git_review::CommentAnchor, - }, - /// Add a worker from an address/@handle line. - HostAdd, - /// Edit the label of the worker with the given id. - HostEditLabel(String), - /// Declare another directory this device may work in. - WorkspaceAdd, - /// Name the agent about to be declared for this `harness × workspace`. - /// - /// Blank accepts the id [`suggest_agent_id`](medulla::runtime::suggest_agent_id) - /// minted from the directory, which is how a person refers to the agent - /// anyway — the prompt exists for the case where it is not. - AgentName { - /// The CLI the agent runs. - harness: String, - /// The absolute directory its sessions work in. - workspace: String, - }, - /// Name the session about to be opened under an already-declared agent. - /// - /// A session a person spins up is [`SessionOrigin::User`](crate::worker::pty::SessionOrigin) - /// and is the only kind that carries a name; a dispatched one is labelled - /// from its task. Blank leaves it unnamed rather than inventing one. - SessionName { - /// The agent whose harness type and workspace the session inherits. - agent_id: String, - /// Whether the orchestrator may dispatch into it — ownership at birth. - managed: bool, - }, - /// Add a named OpenRouter-backed coding harness. - CustomHarnessAdd, - /// Edit the custom harness with the given stable id. - CustomHarnessEdit(String), - /// Declare a lifecycle hook for every harness Medulla launches. - HookAdd, - /// Edit the hook at the given row of the Hooks page. - HookEdit(usize), - /// Reject a workflow proposal with the operator's explanation. - RejectProposal { - /// The workflow the proposal belongs to. - workflow: String, - /// The proposal awaiting the decision. - proposal_id: String, - }, - /// Answer a pending sub-agent question. - AnswerQuestion { - /// The cycle the question belongs to. - cycle_id: String, - /// The pending question's id. - question_id: String, - }, - /// Answer a prepared decision and dismiss it locally once routed. - DecisionAnswer { - /// Stable decision id. - decision_id: String, - /// Cycle that owns the question. - cycle_id: String, - /// Harness question id. - question_id: String, - }, - /// Comment on the given feedback board item. - FeedbackComment { - /// The item being commented on. - id: String, - }, - /// Step one of submitting feedback: the title. Submitting advances to - /// [`PromptKind::FeedbackBody`] rather than sending anything. - FeedbackTitle { - /// Feature request or bug report, chosen by which key opened the prompt. - kind: FeedbackType, - }, - /// Step two of submitting feedback: the body. Submitting sends it. - FeedbackBody { - /// Feature request or bug report. - kind: FeedbackType, - /// The title captured in step one. - title: String, - }, - /// One field of a workflow's declared inputs, collected before the run - /// starts. Submitting either opens the prompt for the next field or, when - /// this was the last, dispatches the run. - /// - /// The whole set is carried on the prompt rather than parked in `App` - /// state, so cancelling with `Esc` abandons the collected values with it — - /// a half-filled set cannot leak into the next run. - WorkflowInput { - /// The workflow the values are being collected for. - workflow_id: String, - /// Whether to dispatch a dry run rather than a real one. - dry_run: bool, - /// The fields still to ask about; the head is the one on screen. - remaining: Vec, - /// What has been collected so far, keyed by input name. - collected: serde_json::Map, - }, -} - -/// The Feedback surface's state: the loaded page, the selected row, that row's -/// comments, and the active query. -pub(super) struct FeedbackState { - /// The current page of board items. - pub(super) items: Vec, - /// Total items matching the query across all pages. - pub(super) total: i64, - /// The highlighted row. - pub(super) index: usize, - /// Comments for [`FeedbackState::detail_id`], loaded lazily on selection. - pub(super) comments: Vec, - /// Which item [`FeedbackState::comments`] belongs to. - pub(super) detail_id: Option, - /// Scroll offset within the detail pane. - pub(super) detail_scroll: usize, - /// The active filter/sort/pagination. - pub(super) query: FeedbackQuery, - /// Whether the runtime serves a board at all. `false` renders a sign-in - /// hint instead of an empty list. - pub(super) supported: bool, - /// Whether a board load is in flight (drives the header's "loading…"). - pub(super) loading: bool, -} - -impl Default for FeedbackState { - fn default() -> Self { - Self { - items: Vec::new(), - total: 0, - index: 0, - comments: Vec::new(), - detail_id: None, - detail_scroll: 0, - query: FeedbackQuery::default(), - supported: true, - loading: false, - } - } -} - -/// A single-line inline input overlay shared with daemon controls. -pub(super) type Prompt = TextPrompt; - -/// Cached credential-presence flags displayed by Routing's Manage Keys pane. -#[derive(Default)] -pub(super) struct CredentialStatus { - pub(super) claude_subscription: bool, - pub(super) codex_subscription: bool, - pub(super) anthropic_api_key: bool, - pub(super) openai_api_key: bool, - pub(super) openrouter_api_key: bool, -} - -/// The interactive TUI screen: all tab state, input focus, and render geometry. -pub struct App { - /// The runtime this screen drives. - pub runtime: Arc, - /// The loaded configuration (for the Config/Overview surfaces). - pub loaded: LoadedConfig, - /// The most recent runtime snapshot, refreshed each loop tick. - pub snapshot: RuntimeSnapshot, - /// The active top-level tab index (into [`TABS`]). - pub tab_index: usize, - /// Git changes from the selected session or operator-chosen commit. - pub(super) changes: super::changes::GitChangesState, - pub(super) draft: Draft, - pub(super) history: Vec, - pub(super) history_index: i64, - pub(super) selected: usize, - /// The Overview tab's animated workflow graph. Held on the app because its - /// simulation has to survive between frames; it is advanced by the draw - /// path, which is the only thing that looks at it. - pub(super) graph: super::render::graph::Graph, - pub(super) status: String, - /// A persistent "update vX.Y.Z available" banner, set by the background - /// update checker; shown in the header until the app exits. - pub(super) update_notice: Option, - pub(super) contexts: Vec, - pub(super) context_index: usize, - pub(super) agent_index: usize, - /// Which selectable rail row remains selected while the live rail is rebuilt. - pub(super) agent_anchor: Option, - /// Extra pages of sublanes revealed under an agent lane, keyed by lane key. - /// - /// Keyed by [`AgentLane::key`](crate::ui::agents::AgentLane::key) rather than - /// by the lane's rail position, because lanes are re-folded from events every - /// tick and a lane that appears or ends shifts every index below it — an - /// expansion tied to a position would silently jump to a different agent. - /// Absent means the lane shows its first page, which is the default every - /// lane starts at. - pub(super) subtask_pages: std::collections::HashMap, - /// The `(worker address, task id)` whose screen is currently subscribed. - /// - /// Held so a selection change can stop the old stream as well as start the - /// new one: a subscription nobody is looking at costs the worker a sample, - /// a ratchet advance and a send on every tick. - pub(super) watching: Option<(String, String)>, - /// The watched `(worker, task)` awaiting destructive-action confirmation. - pub(super) kill_armed: Option<(String, String)>, - /// Which half of the Agents tab the keyboard is driving. - pub(super) agents_focus: AgentsFocus, - pub(super) agent_scroll: usize, - pub(super) chat_scroll: usize, - /// Selected row in the command peek, while it is open. - pub(super) command_index: usize, - /// Selected row on the Routing Hosts page. - pub(super) host_index: usize, - /// Whether ↑↓ on the Hosts page drives the role toggles in the preview - /// rather than the host list above it. Tab moves between the two. - pub(super) host_roles_focus: bool, - /// Selected role in the preview's toggle list, while it has focus. - pub(super) host_role_index: usize, - /// Selected row on the Routing Workspaces page. - pub(super) workspace_index: usize, - /// Selected row on the Routing Agent Templates page. - pub(super) template_index: usize, - /// OpenRouter-backed harness presets loaded from the active config. - pub(super) custom_harnesses: Vec, - /// Selected row on the Routing Harness Types page. - pub(super) custom_harness_index: usize, - /// Selected row on the Routing Hooks page. - pub(super) hook_index: usize, - /// Lifecycle reports arriving from the harnesses this Medulla launched. - /// - /// Written by the control socket's `hook.report` handler and read here; an - /// app with no control plane bound simply renders an empty log. - pub(super) hook_log: medulla::harness_hooks::HookEventLog, - /// Scroll offset inside the open agent-template popup. - pub(super) template_scroll: usize, - /// Whether the agent-template popup is open over the catalog. - pub(super) template_modal: bool, - /// Selected row on the Routing Workflows page. - #[cfg(feature = "workflows")] - pub(super) workflow_index: usize, - /// The installed workflows, as last read from disk. - /// - /// Cached rather than re-read every frame: the store is files, and a render - /// pass should not do I/O. `r` re-reads it, as it does for templates. - #[cfg(feature = "workflows")] - pub(super) workflows: Vec, - /// The selected workflow's runs, read when the selection changes rather - /// than on every frame. - #[cfg(feature = "workflows")] - pub(super) workflow_runs: Vec, - /// Why the run history could not be read, if it could not. - #[cfg(feature = "workflows")] - pub(super) workflow_runs_error: Option, - /// What the selected workflow has learned, newest first. - /// - /// Cached beside the runs and refreshed with them, for the same reason: a - /// render pass must not touch the disk. - #[cfg(feature = "workflows")] - pub(super) workflow_notes: Vec, - /// Changes proposed for the selected workflow, newest first. - #[cfg(feature = "workflows")] - pub(super) workflow_proposals: Vec, - /// The Workflows tab's panes, cursors, and copilot threads. - #[cfg(feature = "workflows")] - pub(super) wf: WorkflowsState, - /// A workflow store attached directly, overriding the layered one this - /// client would otherwise resolve. - /// - /// The layered store always reads the current directory's - /// `.medulla/workflows` as repository defaults, then overlays the - /// user-global workflow directory. That is useful in a real session and - /// wrong under test, where it makes the catalogue depend on the developer's - /// checkout. `None` resolves the layered store, as a real session does. - #[cfg(feature = "workflows")] - pub(super) workflow_store_override: Option>, - /// The active Routing subpage (index into [`ROUTING_SUBPAGES`]). - pub(super) routing_index: usize, - /// Whether keyboard focus is inside the Routing content pane. - pub(super) routing_focused: bool, - /// Selected row on the Routing strategy page. - pub(super) routing_strategy_index: usize, - /// Selected subscription rule on the Routing strategy page. - pub(super) subscription_strategy_index: usize, - /// Whether the subscription group, rather than the host group, has focus. - pub(super) subscription_strategy_focused: bool, - /// Credential presence captured on startup and refreshed when its pane opens. - pub(super) credential_status: CredentialStatus, - /// The active TokenMaxxxing sidebar page. - pub(super) tokenmaxxing_index: usize, - /// Whether keyboard focus is inside the TokenMaxxxing content pane. - pub(super) tokenmaxxing_focused: bool, - /// Feedback-board state (lazily loaded on entry / refresh). - pub(super) feedback: FeedbackState, - /// Feedback-board tab state (lazily loaded on tab entry / refresh). - /// Whether the prepared-decision modal is visible. - pub(super) decision_open: bool, - /// Highlighted decision row. - pub(super) decision_index: usize, - /// Session-local ids intentionally hidden by the operator. - pub(super) dismissed_decisions: std::collections::BTreeSet, - pub(super) prompt: Option, - /// The animation frame counter: one per event-loop tick (~90ms). - /// - /// Drives the spinner and the workflow canvas's flowing wires. Held on the - /// app rather than read from a clock so a test that draws frames explicitly - /// sees the same animation the terminal does. - pub frame: usize, - /// Whether the app currently captures the mouse. - pub mouse_capture: bool, - /// Account-level usage payload (`/teams/me/usage` data), when fetched. - pub account_usage: Option, - /// The active Settings subpage (index into [`SETTINGS_SUBPAGES`]). - pub(super) settings_index: usize, - /// Whether keyboard focus is inside the Settings content pane rather than on - /// the left-hand subpage nav. - /// - /// Subpages whose content is a list of *actions* (Feedback especially) bind - /// enough single letters that they swallow the keys you would otherwise use - /// to get around, and `↑↓` moving the nav meant arrow keys jumped you off - /// the page entirely. Entering the pane hands `↑↓` to the content and makes - /// the letter bindings deliberate rather than ambient. - pub(super) settings_focused: bool, - /// The selected theme role on the Appearance subpage. - pub(super) appearance_index: usize, - /// Throttled sampler backing the optional local-process status indicators. - pub(super) resource_monitor: crate::ui::resources::ResourceMonitor, - /// Throttled sampler backing the optional whole-device sidebar indicators. - pub(super) device_monitor: crate::ui::resources::DeviceMonitor, - /// The selected field row on the Status line subpage. - pub(super) status_line_index: usize, - /// Whether the next persisted status-line edit must write the complete - /// legacy-derived section rather than one field. - pub(super) status_line_promotion_pending: bool, - /// The selected editable row on the Config subpage. - pub(super) config_index: usize, - /// Whether the Account subpage's logout is armed. Logging out clears stored - /// credentials, so the first Enter arms and the second confirms; any other - /// navigation disarms it. - pub(super) logout_armed: bool, - /// Whether the app is quitting in order to re-authenticate rather than to - /// exit. Set by a successful logout so the caller tears the session down and - /// returns to the login screen instead of returning to the shell. - pub(super) relogin_requested: bool, - /// Who the embedded core is signed in as, for the Account subpage. - pub(super) account: Option, - /// The Medulla home directory, used to locate the credential store the - /// Account subpage clears. Injectable so feature tests never touch the real - /// home; `None` disables logout. - pub(super) medulla_home: Option, - /// The resolved color theme; selection highlighting + chrome draw from it. - pub(super) theme: Theme, - /// Where appearance changes are persisted (the user-global `config.toml`). - /// Injectable so feature tests never touch the real home. `None` disables - /// persistence (changes still apply live). - pub(super) config_path: Option, - /// Where hook edits are persisted — deliberately not always [`Self::config_path`]. - /// - /// `config_path` may resolve to a project-local file - /// (`.medulla/config.toml`/`medulla.toml`), which is exactly the layer - /// `medulla::config::load_config` strips `[[hooks]]` from on every load that - /// is not an explicit `--config` (project configuration must not authorize - /// shell commands in the operator's environment). Saving a hook there would - /// show "Hook saved" and apply for the rest of this session while writing - /// to a file the next launch ignores. Defaulted to [`Self::config_path`] by - /// [`Self::set_config_path`] and overridden by - /// [`Self::set_hooks_config_path`] whenever the caller knows the two must - /// differ — see `app_loop::run_tui` in the `medulla-tui` crate. - pub(super) hooks_config_path: Option, - pub(super) resume_picker: Option, - /// Whether the event loop should exit after this tick. - pub should_quit: bool, - - // Render geometry, recorded each draw for click hit-testing. - pub(super) area: Rect, - pub(super) hit_tabs: Vec<(u16, u16)>, - pub(super) hit_tabs_row: u16, - /// Where the Agents rail drew, and the rendered row each visible line - /// belongs to. A row may wrap onto several lines, so a click resolves - /// through this snapshot rather than by adding an offset to a freshly - /// rebuilt row list that may have changed since the frame was drawn. - pub(super) hit_agents: Option<(Rect, Vec)>, - // Where the embedded session screen landed, and whose it is. Recorded so a - // wheel event can be routed to the terminal under the pointer and given - // coordinates relative to *its* origin rather than the screen's. - pub(super) hit_session: Option<(Rect, String)>, - /// The threads strip's hit box and its first visible row, for click-to-switch. - pub(super) hit_threads: Option<(Rect, usize)>, - /// Where the orchestrator's conversation drew, and the task each of its - /// visible lines opens (§A7) — `None` for the lines that are transcript - /// rather than a session entry. - /// - /// One slot per drawn row rather than a dense list, because the entries are - /// interleaved with the conversation: each one sits under the turn that - /// started it, so the block is no longer contiguous and an offset from its - /// top no longer identifies an entry. - /// - /// Tasks rather than row indices: the rail is rebuilt every frame, so an - /// index recorded during the draw can name a different row by the time the - /// click lands. A task id either still has a session or does not. - pub(super) hit_started_sessions: Option<(Rect, Vec>)>, - pub(super) hit_context: Option, - /// The selected workflow step's preview, for pointer-wheel scrolling. - pub(super) hit_workflow_preview: Option, - /// Where the active tab's subpage nav drew its page rows. Only one nav is on - /// screen at a time, so one field serves Routing and Settings. - pub(super) hit_nav: crate::ui::multi_pane::NavHits, - /// Every pane drawn this frame, in draw order. A pointer selection is - /// clamped to whichever of these it started in, so a drag reads one pane's - /// text instead of splicing its neighbour's columns into every row. - pub(super) panes: Vec, - /// A drag in progress: where the button went down. Kept apart from - /// [`Self::selection`] so a click that never moves leaves no selection. - pub(super) drag_anchor: Option<(u16, u16)>, - /// The block of cells the pointer has swept, normalized to - /// `(left, top, right, bottom)` inclusive. - pub(super) selection: Option<(u16, u16, u16, u16)>, - /// Set when the button is released over a live selection: the next draw - /// copies what the selection covers, since only then is the buffer readable. - pub(super) copy_selection: bool, - pub(super) last_events_len: usize, - - // Test-only clipboard capture: when set, `copy_chat` records the copied text - // here and skips the platform writers (no `pbcopy`/OSC subprocess in tests). - pub(super) copy_capture: Option>>>, - - // Optional observational overlay from the background host-link service: - // this endpoint's own identity, its peer roster, and peer presence. Merged - // into the snapshot on every refresh so the Overview panel and Agents lanes - // light up without the runtime having to know about the link. - pub(super) link_obs: Option>>, - // A read-only view of the task host running on this device, when one is. - // Read live at render rather than merged into the snapshot: its counters - // move on the host's own schedule, and the snapshot is the *runtime's* - // picture of the world — the host is a peer to it, not part of it. - pub(super) host_obs: Option, - // The live sessions this device is running. `None` when this machine - // does not host, in which case the Agents tab has no local screen to show - // and falls back to a remote worker's streamed one, or to the transcript. - pub(super) local_sessions: Option, - // Workflow runs the harnesses on this device started over MCP, keyed by the - // grant session the launcher recorded on each PTY row. Read at render so a - // run reported a moment ago is on screen at the next frame, and empty on a - // build or a host with no control plane bound. - pub(super) harness_runs: medulla::control_socket::HarnessRunRegistry, - // Live per-node harness output for runs this TUI started, keyed by run id. - // Only ever a handful: a settled run keeps its frames until the next run of - // the same workflow replaces it. - #[cfg(feature = "workflows")] - pub(super) live_runs: std::collections::HashMap, - // Which of the TUI and the selected session owns the keyboard. Reset to - // `Chrome` whenever the attached session stops being the selected one, so - // the operator's keys can never land in a session they are not looking at. - pub(super) harness_focus: crate::ui::harness_pane::HarnessFocus, - // The session the Agents pane resolved on the last draw, and the - // only one the attach chord can act on. Recorded during render because that - // is where the rail cursor is turned into a selection; cleared at the top of - // every draw so it can never name a pane that is no longer on screen. - pub(super) pane_session: Option, - // What the pane is showing for that session: its terminal, or one of the - // views that replace it. Not cleared per frame — it is the operator's - // choice, not a record of what the last draw resolved — so it is reset by - // the selection moving instead. - pub(super) pane_view: PaneView, - // The session `pane_view` was chosen for. `pane_session` cannot answer this: - // it is cleared at the top of every draw, so comparing against it would - // reset the view on every frame. - pub(super) pane_view_session: Option, - // The session whose close is awaiting confirmation, if one is. Killing a - // harness ends whatever it was in the middle of, so `k` asks first — the - // same one-keypress contract `kill_armed` has for a dispatched task. - pub(super) harness_close_armed: Option, - // The session that took the press of the button currently held down, if any. - // A terminal grabs the pointer for the whole gesture: whoever received the - // press receives the drags and the release too, wherever the pointer has - // wandered to since. Without the grab a release outside the pane — or one - // swallowed by a modal the click itself opened — never reaches the child, - // which goes on believing the button is still down and misplaces everything - // it draws in response to the pointer afterwards. - pub(super) pointer_grab: Option, - // Where the hand-back question drew each of its answers, and the key each - // one stands for. Recorded during the draw so a click can be answered by - // replaying the keystroke rather than by a second copy of the routing: the - // two would drift, and the direction they would drift in is a pointer that - // hands a harness back when the operator meant to keep it. - pub(super) hit_handback: Vec<(Rect, crossterm::event::KeyCode)>, - // The "start a session" picker's outer box, and where each offered row was - // drawn with the index it stands for in that step's list. Recorded during - // the draw for the same reason the hand-back answers are: the harness step - // windows a long list, so screen position and list index are not the same - // number, and only the draw knows which window it used. - pub(super) hit_agent_picker: Option<(Rect, Vec<(Rect, usize)>)>, - // The agent behind a selected session row that this device does NOT run, - // recorded alongside `pane_session` on the same draw. - // - // Its only purpose is to tell "the cursor is not on a session" apart from - // "the cursor is on somebody else's session", which `pane_session` cannot: - // both leave it `None`. Taking control resolves through the local workspace - // path, so a remote session can be watched but not taken (§E7), and an - // operator who presses the take chord on one deserves that answer rather - // than "no session on this row". - pub(super) pane_remote_session: Option, - // The session selected on the Agents rail, retained while another tab is - // visible. Unlike `pane_session`, this is navigation state rather than a - // keyboard-routing capability: Changes uses it to keep following - // the repository the operator selected after an intervening tab draw. - pub(super) rail_session: Option, - /// The "start a session" picker, while it is open. - pub(super) agent_picker: Option, - /// The "you still hold this session" confirmation, while it is open. - pub(super) handback_prompt: Option, - /// How far the Help page is scrolled, in lines. - pub(super) help_scroll: u16, - /// What releasing a held session does, from `[harness].handback`. - pub(super) handback_policy: HandbackPolicy, - /// The sessions taken *from the orchestrator*, and how each was taken. - /// - /// Membership is the whole question the release prompt exists to ask. A - /// session the operator started themselves was never the orchestrator's, so - /// letting go of the keyboard owes it nothing, and asking about it every - /// time is how a confirmation becomes furniture. One taken out from under - /// dispatch is different: walking away from it leaves the orchestrator - /// locked out of a workspace, silently and indefinitely. - /// - /// The origin distinguishes "you picked this up by focusing in" from "you - /// asked for it with /takecontrol", which the release prompt words - /// differently: the second was a decision, and re-asking about it as though - /// it were an accident is how a confirmation becomes noise. - /// - /// Keyed by session rather than kept as one flag because several sessions - /// can be held at once — take A, keep it on release, then attach to B. A - /// single flag answered for whichever was touched last: it worded B's - /// question with A's takeover, and kept asking about sessions nobody had - /// taken from anyone. - pub(super) sessions_taken: std::collections::HashMap, - /// Sessions the operator has at some point given to the orchestrator. - /// - /// Separate from [`sessions_taken`](Self::sessions_taken), which says who - /// holds a session *now*; this remembers that dispatch once had a claim on - /// it, and it is never cleared. - /// - /// Needed because [`SessionOrigin`](crate::worker::pty::SessionOrigin) alone - /// under-counts. A session the operator started carries origin `User` - /// forever, but handing it back makes it genuinely dispatchable — - /// `SessionHandle::serves_label` lets a handed-back operator session be - /// adopted for a task. If that turn then fails, the executor hands it - /// straight back to the operator without going through - /// [`take_session`](App::take_session), leaving a session with origin - /// `User`, no entry in `sessions_taken`, and dispatch locked out of it. - /// Releasing that in silence is the bug this set closes. - pub(super) orchestrator_claimed: std::collections::HashSet, - /// Commands raised by synchronous input handlers, drained by the event loop. - /// - /// The key and mouse handlers that move session control cannot return a - /// [`Cmd`] — `handle_handback_key` returns `()`, `handle_harness_key` - /// returns `bool`, and the mouse path returns nothing — and threading an - /// `Option` back through all three would be a wide, test-breaking - /// change to say one thing. So they push here instead, and the loop drains - /// it right after the event that produced it. Commands run in submission - /// order. - pub(super) pending_cmds: std::collections::VecDeque, - /// Whether operator-started sessions launch with the permission-bypass - /// flag, from `[harness].skipPermissions`. - pub(super) harness_skip_permissions: bool, -} +pub use model::*; +pub(in crate::ui::app) use rail_hit::{RailHit, RailHitTarget}; diff --git a/src/tui/src/ui/app/types/model.rs b/src/tui/src/ui/app/types/model.rs new file mode 100644 index 000000000..093f2007e --- /dev/null +++ b/src/tui/src/ui/app/types/model.rs @@ -0,0 +1,1285 @@ +//! The data model for the interactive TUI screen: the tab list, multi-pane +//! navigation constants, the [`Cmd`] the event loop runs on the app's behalf, the +//! small overlay/state types ([`ResumePicker`], [`Prompt`], [`PromptKind`], +//! and the central [`App`] struct itself. +//! +//! Behaviour lives in the sibling modules ([`super::super::state`], [`super::super::input`], +//! [`super::super::keys`], [`super::super::commands`], and [`super::super::render`]), each of which +//! adds its own `impl App` block. Because those blocks share `App`'s private +//! fields, the fields (and the private helper types/consts here) are +//! `pub(super)` so every sibling submodule can reach them. + +use std::sync::Arc; + +use ratatui::layout::Rect; + +use crate::ui::composer::{Draft, TextPrompt}; +use crate::ui::theme::Theme; +use medulla::client::{FeedbackComment, FeedbackItem, FeedbackQuery, FeedbackType}; +use medulla::config::LoadedConfig; +use medulla::runtime::{ContextItem, Runtime, RuntimeSnapshot, WorkerOp}; + +use super::rail_hit::RailHit; + +/// The ordered top-level tab names. The tab index selects into this array. +/// +/// Trace and Context used to live here. They are secondary surfaces — +/// two of them diagnostic — so they now sit under Settings, keeping the tab bar +/// to the views a session is actually driven from. +/// +/// Chat used to live here too, and is now the Agents tab: talking to the +/// orchestrator *is* selecting its lane and typing. Splitting them meant reading +/// what an operation was doing on one tab and steering it on another, with two +/// scroll positions and no way to answer an agent's question from where the +/// question was visible. +/// +/// Workflows used to be a Routing subpage. It is a tab because it is not a +/// management surface: Routing is where an operator declares what capacity +/// exists, and a workflow is *work* — a plan they read, edit, and run, with a +/// graph to navigate and a copilot to edit it by. Three panes' worth of surface +/// does not fit in a subpage of something else. +/// `Tasks` and `Memory` are commented out rather than deleted: the code behind +/// both still builds and their render paths are intact, so restoring either is +/// putting one line back. Memory is out of the build entirely (its tab said +/// "coming soon"); Tasks duplicates what the Agents tab already shows per lane. +#[cfg(feature = "workflows")] +pub const TABS: [&str; 7] = [ + "Overview", + "Agents", + "Workflows", + "Changes", + "Hosts", + "Feedback", + "Settings", +]; + +/// Without the workflow engine. A slim build must not offer a tab that cannot +/// draw anything. +#[cfg(not(feature = "workflows"))] +pub const TABS: [&str; 6] = [ + "Overview", "Agents", "Changes", "Hosts", "Feedback", "Settings", +]; + +/// The Routing tab's left-nav pages. +/// +/// Ordered by the containment chain. `Hosts` is the machine level the operator +/// registers and steers by hand; `Harness Types` is the runtime level, which is +/// where credentials live — a subscription or an API key is a property of the +/// CLI runtime that spends it, not of the machine it happens to sit on; +/// `Workspaces` is the folder level, which is what the orchestrator actually +/// reasons about — a machine is capacity, a directory is *work*; `Agent +/// Templates` is the catalog of what may be provisioned onto any of it. `Add +/// Host` and `Strategies` are the two actions that belong to no level. +/// +/// There is no `Fleet` page: the whole declared tree lives in the Agents rail, +/// beside the lanes running on it. These pages are the *management* surfaces — +/// what you register, authenticate, and choose — not the picture. Workflows is +/// not here either: it is a tab of its own (see [`TABS`]). +/// Ordered by the containment chain, as before: the machine, what runs on it, +/// what may be stood up there, how to add another, and how work is routed +/// between them. +/// +/// Only Workspaces is commented out. An entry there was advisory routing +/// context; declaring an agent is what actually puts work in a directory, and +/// that is done from the host tree. Its draw arm, keys and `[host].workspaces` +/// persistence all still build, so restoring it is putting its name back here +/// and renumbering. +pub const ROUTING_SUBPAGES: [&str; 6] = [ + "Hosts", + "Harness Types", + "Hooks", + "Agent Templates", + "Add Host", + "Strategies", +]; + +pub(super) const RP_HOSTS: usize = 0; +pub(super) const RP_HARNESSES: usize = 1; +// Beside Harness Types on purpose: a hook is a property of every harness +// Medulla launches, and this page is the one place they are declared for all of +// them. +pub(super) const RP_HOOKS: usize = 2; +pub(super) const RP_TEMPLATES: usize = 3; +pub(super) const RP_ADD_HOST: usize = 4; +pub(super) const RP_STRATEGIES: usize = 5; +// Past the end of `ROUTING_SUBPAGES`, so the nav clamp cannot reach it and its +// arm is unreachable — the page is off without its code rotting. +pub(super) const RP_WORKSPACES: usize = 6; + +/// The TokenMaxxxing tab's sidebar pages. +pub(super) const TOKENMAXXING_SUBPAGES: [&str; 3] = ["Overview", "Bounties", "Leaderboard"]; + +pub(super) const TM_OVERVIEW: usize = 0; +pub(super) const TM_BOUNTIES: usize = 1; +pub(super) const TM_LEADERBOARD: usize = 2; + +pub(super) use super::super::routing_options::{ROUTING_STRATEGIES, SUBSCRIPTION_STRATEGIES}; + +/// The Settings tab's left-nav subpages, in order (number keys 1-9 jump to them). +/// +/// This is the flat, selectable list [`App::settings_index`] indexes into. +/// [`SETTINGS_GROUPS`] overlays the display-only headings. +pub const SETTINGS_SUBPAGES: [&str; 9] = [ + "Usage", + "Appearance", + "Status line", + "Config", + "Feedback", + "Trace", + "Context", + "Account", + "Help", +]; + +/// The left-nav group headings, as `(heading, first subpage index)`. +/// +/// Headings are rendered dim and are not selectable — they exist to separate the +/// everyday settings from the diagnostic ones. Each group runs until the next +/// group's start index. +pub const SETTINGS_GROUPS: [(&str, usize); 3] = [ + ("GENERAL", SP_USAGE), + ("DEBUG", SP_TRACE), + ("ABOUT", SP_ACCOUNT), +]; + +// Settings subpage indices. +pub(super) const SP_USAGE: usize = 0; +pub(super) const SP_APPEARANCE: usize = 1; +pub(super) const SP_STATUS_LINE: usize = 2; +pub(super) const SP_CONFIG: usize = 3; +pub(super) const SP_FEEDBACK: usize = 4; +pub(super) const SP_TRACE: usize = 5; +pub(super) const SP_CONTEXT: usize = 6; +pub(super) const SP_ACCOUNT: usize = 7; +pub(super) const SP_HELP: usize = 8; + +/// The index of a tab by name, or 0 if unknown. Keeps tab jumps robust as the tab +/// list grows. +pub(super) fn tab_pos(name: &str) -> usize { + TABS.iter().position(|t| *t == name).unwrap_or(0) +} + +/// Which half of the Agents tab the keyboard is driving. +/// +/// The tab merges a list (the rail) with a text input (the composer), and a +/// terminal has one keyboard for both. Typing has to work the instant the tab +/// opens — that is the point of folding chat in here — so the composer holds +/// focus by default and the bare arrows belong to the caret. +/// +/// That left the rail reachable only by `Alt`+`↑`/`↓`, which most macOS +/// terminals do not send at all unless the user has rebound the Option key. +/// Focus is therefore explicit and movable, matching the menu/content model +/// Settings and Routing already use: `Esc` steps out to the rail, `Enter` (or +/// simply typing) steps back in. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum AgentsFocus { + /// The composer has the keyboard: arrows move the caret, Enter submits. + #[default] + Composer, + /// The rail has the keyboard: arrows walk the rows, Enter returns below. + Rail, +} + +/// What the pane beside the rail is showing for the selected harness. +/// +/// The harness screen is not the only thing worth looking at for a session, and +/// the alternatives are all *about* that session rather than beside it: what it +/// has changed, and — as more of them land — what it is running. So they take +/// the pane's real estate rather than opening somewhere else, the way a tab +/// switch replaces a page: one thing on screen, one key to swap it, and the +/// rail cursor never moves. +/// +/// Scoped to the selected session and reset when the cursor moves off it +/// ([`App::resolve_selected_session`](crate::ui::app)): a view opened to answer +/// a question about one harness must not stay open over the next one, where it +/// would be showing another session's diff under this session's row. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum PaneView { + /// The harness's own terminal — what it is painting right now. + #[default] + Harness, + /// What the harness has changed since it launched. + Diff, +} + +/// Which pane of the Workflows tab has the keyboard. +/// +/// Focus is split by *mode*, the way Settings and Routing split theirs: the +/// sidebar picks what is being looked at and hands over with `Enter`, the canvas +/// walks the graph, and the copilot is a composer that takes every printable +/// key. `Esc` steps back out one level at a time. +/// +/// `Tab` is not part of this. It belongs to the top-level view ring, and a tab +/// that cycled its own panes with it would be a tab inside a tab — `c` reaches +/// the copilot instead. +#[cfg(feature = "workflows")] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum WorkflowFocus { + /// The catalogue sidebar: arrows walk workflows and their runs. + #[default] + Sidebar, + /// The graph canvas: arrows walk nodes along their edges. + Canvas, + /// The copilot composer: printable keys type, Enter sends. + Copilot, +} + +/// What the Workflows content pane is showing. +/// +/// One view at a time, beside the catalogue sidebar — the same two-pane shape +/// as Routing and Settings. Derived from [`WorkflowFocus`] and the inspector +/// toggle by [`App::workflow_view`] rather than stored, so it cannot drift from +/// the state that decides it. +#[cfg(feature = "workflows")] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum WorkflowView { + /// The laid-out graph, with any selected run overlaid on it. + Graph, + /// The selected node's declaration, and how a run left it. + Inspector, + /// The conversation that edits the graph. + Copilot, +} + +/// Everything the Workflows tab holds that is not the catalogue itself. +/// +/// Grouped into one struct rather than a dozen `workflow_*` fields on [`App`]: +/// the tab has three panes with their own cursors, and a flat namespace made it +/// impossible to see which cursor belonged to which pane. +#[cfg(feature = "workflows")] +#[derive(Debug, Default)] +pub struct WorkflowsState { + /// Which pane has the keyboard. + pub(super) focus: WorkflowFocus, + /// Whether the rail cursor is on the "New workflow" row. + /// + /// Its own flag rather than a sentinel value of the catalogue index, + /// because the New row is not a workflow: it has no graph to draw, no runs + /// to list, and nothing to run. Everything that reads the selection has to + /// answer "or is it the new one?" and a magic index would let that question + /// go unasked. + pub(super) creating: bool, + /// The selected workflow's run, when the rail cursor is on one of the run + /// rows nested under it rather than on the workflow itself. + /// + /// A tree cursor rather than an index into a flattened row list: the rows + /// under a workflow only exist while it is selected, so a flat index would + /// have to be reinterpreted every time the cursor crossed a workflow + /// boundary — and gets it wrong the moment a run appears mid-scroll. + pub(super) run_index: Option, + /// The selected workflow's graph, as last read from the store. Cached + /// because a render pass must not touch the disk, and re-laying it out every + /// frame would move boxes under the cursor. + pub(super) graph: Option>, + /// The selected workflow's own choice of harness and model, cached with the + /// graph and for the same reason. Not part of the graph, so a preview + /// reading only [`graph`](Self::graph) would report the host's harness for a + /// workflow that pinned its own. + pub(super) defaults: medulla::workflows::WorkflowDefaults, + /// The laid-out form of [`graph`](Self::graph). + pub(super) layout: medulla::ui::workflows::GraphLayout, + /// Selected node in the canvas, in the layout's reading order. + pub(super) node_index: usize, + /// Vertical scroll of the canvas, in rows. + /// + /// The only scroll the canvas has: the graph folds onto a new band whenever + /// a layer would run past the right edge, so it is never wider than the + /// pane and there is nothing to scroll horizontally. Counted in rows rather + /// than lanes because a fold puts a band boundary between two lanes, and a + /// scroll measured in lanes cannot address the gap. + pub(super) canvas_row: usize, + /// Rows inside the graph panel during its most recent render. + /// + /// Navigation uses this measured viewport rather than the full terminal + /// height, because the selected-node preview shares the content column. + pub(super) graph_rows: usize, + /// Top line of the rich selected-step preview. + pub(super) preview_scroll: usize, + /// Whether the inspector below the canvas is expanded over it. + pub(super) inspector_open: bool, + /// The run being overlaid on the graph, when a run row is selected. + pub(super) overlay: Option, + /// One copilot thread per workflow, so switching in the rail does not show + /// the previous workflow's conversation or lose this one's. + pub(super) copilots: std::collections::HashMap, + /// The copilot composer's draft. + pub(super) draft: Draft, + /// Scroll offset in the copilot transcript, in lines from the bottom. + pub(super) copilot_scroll: usize, +} + +/// An async action the event loop must run on the app's behalf. +#[derive(Debug)] +pub enum Cmd { + /// Exit the application. + Quit, + /// Submit a composer line as a new conversational turn. + Submit(String), + /// Resume a previously saved chat by session id. + Resume(String), + /// Fetch the list of resumable chats for the resume picker. + ListChats, + /// Re-inspect the runtime's context chunks for the Context tab. + InspectContext, + /// Clear the session this host is signed in with. + Logout, + /// Apply a worker fleet mutation. + WorkerOp(WorkerOp), + /// Apply several fleet mutations as one operator action. + /// + /// Removing a *host* is the case this exists for: a host is a group of + /// roster entries sharing an address, and the registry has no host-level + /// op — so taking one out means taking each of its agents out. Carrying + /// them together keeps that one keypress one status line, rather than N + /// racing "Worker registry updated" messages for what the operator did + /// once. They are applied in order, and a failure reports the op it + /// stopped on instead of being swallowed by the next success. + WorkerOps(Vec), + /// Retarget the live screen subscription: stop watching one task, start + /// watching another. Both halves ride one command so the change is atomic + /// from the loop's point of view — a stop that landed without its start + /// would leave the pane blank with nothing on the way. + WatchTask { + /// The `(worker address, task id)` to stop streaming, if any. + stop: Option<(String, String)>, + /// The `(worker address, task id)` to start streaming, if any. + start: Option<(String, String)>, + }, + /// Kill the session serving a watched task after UI confirmation. + KillTask { + /// The worker address that owns the session. + worker: String, + /// The dispatched task whose session should be killed. + task_id: String, + }, + /// Push a handoff brief for a session the operator just gave back. + /// + /// Off the render thread because it does two things that must not block a + /// frame: shells out to `git` for the branch, and awaits a socket emit. + /// Arrives with `branch`/`project` unset — the dispatcher fills them. + HandOffSession(Box), + /// Tell the orchestrator the operator has taken the session in a workspace. + HoldSession { + /// The workspace being taken. + workspace: String, + /// Why, when the operator said. + reason: Option, + }, + /// Fetch account-level usage from the backend for the Usage tab. + LoadUsage, + /// Load a page of the feedback board for the Feedback surface. + LoadFeedback(FeedbackQuery), + /// Load one board item's comments for the detail pane. + LoadFeedbackDetail(String), + /// Cast, change, or retract a vote on a board item. + VoteFeedback { + /// The item being voted on. + id: String, + /// `1` upvote, `-1` downvote, `0` retract. + value: i8, + }, + /// Post a comment on a board item. + CommentFeedback { + /// The item being commented on. + id: String, + /// The comment text. + body: String, + }, + /// Submit new feedback to the board. + SubmitFeedback { + /// Feature request or bug report. + kind: FeedbackType, + /// The submission's title. + title: String, + /// The submission's body. + body: String, + }, + /// Re-read the declared fleet (roster + capacity) from the runtime. + RefreshFleet, + /// Run an installed workflow on this machine. + /// + /// Off-thread like every other filesystem/process command: a workflow run + /// dispatches real agent sessions and takes minutes, so doing it on the + /// render thread would freeze the app for the whole run. + #[cfg(feature = "workflows")] + RunWorkflow { + /// The workflow to run. + id: String, + /// Values for the workflow's declared inputs, collected from the + /// operator before this command was emitted. Empty when the workflow + /// declares none. + inputs: serde_json::Map, + }, + /// Ask the copilot to change or explain a workflow. + /// + /// Off-thread for the same reason a run is: the turn starts a real agent + /// session, and the pane it reports into has to keep repainting while it + /// does. + #[cfg(feature = "workflows")] + CopilotTurn { + /// The workflow the turn is scoped to. + workflow: String, + /// The operator's instruction, verbatim. + instruction: String, + }, + /// Ask the copilot to build a workflow that does not exist yet. + /// + /// Separate from [`Cmd::CopilotTurn`] because it has no workflow to name: + /// the agent is told to call `workflow_create`, and which workflow appeared + /// is worked out from the store afterwards. + #[cfg(feature = "workflows")] + CreateWorkflow { + /// Which copilot thread the turn's progress and result belong to. + /// + /// Carried rather than assumed: the thread for a workflow that does not + /// exist is keyed by a sentinel the app owns, and an event loop that + /// had to know that sentinel would be a second place it is spelled. + thread: String, + /// The operator's description of what they want, verbatim. + instruction: String, + }, + /// Simulate a workflow without dispatching anything, and report the result. + #[cfg(feature = "workflows")] + DryRunWorkflow { + /// The workflow to simulate. + id: String, + /// Values for the workflow's declared inputs — a simulation resolves + /// `=inputs.` bindings like a real run, so it needs them too. + inputs: serde_json::Map, + }, + /// Take back a workflow's most recent edit. + /// + /// Off-thread with the rest: it reads the history directory and writes a + /// definition, and the store's methods are synchronous by contract. + #[cfg(feature = "workflows")] + UndoWorkflow { + /// The workflow to restore. + id: String, + }, + /// Stop the copilot turn running on a thread. + #[cfg(feature = "workflows")] + AbortCopilot { + /// Which copilot thread to stop. + thread: String, + }, + /// Ask the copilot to diagnose a failed run and fix its cause. + /// + /// Separate from [`Cmd::CopilotTurn`] because it carries the failure: the + /// run, its error, and the nodes implicated. All three are on screen when + /// the operator presses the key, and a turn that had to rediscover them + /// would start a step behind. + #[cfg(feature = "workflows")] + RepairWorkflow { + /// The workflow the run belongs to. + workflow: String, + /// The operator's words, if they typed any. + instruction: String, + /// The run to diagnose. + run_id: String, + }, + /// Review a workflow against its own history. + /// + /// Unlike [`Cmd::RepairWorkflow`], this turn may not edit: it records what + /// it learns and proposes changes for the operator to accept. The two are + /// separate commands rather than one with a flag because they are different + /// asks — repair is "fix this now", review is "what should change". + #[cfg(feature = "workflows")] + EvolveWorkflow { + /// The workflow to review. + workflow: String, + /// The failed run to lead with, when the review was triggered by one. + run_id: Option, + }, + /// Apply a proposed change to the saved graph. + #[cfg(feature = "workflows")] + AcceptProposal { + /// The workflow being changed, so the pane can be refreshed. + workflow: String, + /// The proposal to apply. + proposal_id: String, + }, + /// Turn a proposed change down. + #[cfg(feature = "workflows")] + RejectProposal { + /// The workflow the proposal was for. + workflow: String, + /// The proposal to decline. + proposal_id: String, + /// Why, recorded as a note so a later review does not propose it again. + reason: String, + }, +} + +/// The modal state for the "resume a chat" picker overlay. +pub(super) struct ResumePicker { + /// The resumable chats to choose from. + pub(super) chats: Vec, + /// The highlighted row. + pub(super) index: usize, +} + +/// An overlay the app can draw over the content pane. +/// +/// Ordered as they stack, back to front: the two that float over the content, +/// then the session picker, then the question asked about a session being +/// released, and finally the two that claim a row of their own below it. +/// +/// Produced by [`App::visible_overlays`], which is the single source of truth +/// for what is in front of the content — see [`super::super::overlays`]. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(super) enum Overlay { + /// The prepared-decision board. + Decisions, + /// The agent-template detail popup. + TemplatePopup, + /// The "start a session" picker. + AgentPicker, + /// The question asked when the operator lets go of a session. + HandbackPrompt, + /// The shared single-line prompt (Workers add/edit, Agents answer). + InlinePrompt, + /// The saved-chat resume picker. + ResumePicker, +} + +/// What the harness-type/workspace picker is being used for. +/// +/// The same two steps — pick a CLI, pick a directory — answer both questions the +/// Agents tab asks, and they differ only in what happens at the end. Declaring an +/// agent writes `harness × workspace` to the config and starts nothing; spawning +/// starts a session and declares nothing. Carrying the intent on the picker keeps +/// one overlay rather than two that would drift apart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum PickerPurpose { + /// Start a session here and now, declaring nothing — the `/session` path. + Spawn, + /// Declare an agent: `harness × workspace`, named on the step after. + DeclareAgent, +} + +/// The modal state for the harness-type/workspace picker overlay. +pub(super) struct AgentPicker { + /// What confirming the last step will do. + pub(super) purpose: PickerPurpose, + /// Installed providers and registered presets, in offer order. + pub(super) choices: Vec, + /// The highlighted row. + pub(super) index: usize, + /// Which half of the two-step picker owns the keyboard. + pub(super) step: AgentPickerStep, + /// Default directory used to seed the editable workspace query. + pub(super) cwd: String, + /// Inline fuzzy-completion text on the workspace step. + pub(super) workspace_query: String, + /// Cached workspace rows, refreshed only when the query changes. + pub(super) workspace_choices: Vec, + /// Highlighted workspace completion. + pub(super) workspace_index: usize, + /// Whether the operator has deliberately picked one of the completions. + /// + /// Distinct from `workspace_index != 0`, which cannot express it: a query + /// that offers a single completion leaves the cursor on row zero however + /// deliberately it was moved there. Set by the arrows, cleared whenever the + /// query changes, and read by + /// [`selected_picker_workspace`](App::selected_picker_workspace) to decide + /// whether an entered directory outranks the completions listed under it. + pub(super) workspace_picked: bool, +} + +/// Active stage of the manual session launcher. +/// +/// There is deliberately no "managed or unmanaged?" stage. A session the +/// operator starts by hand is theirs — that is what starting it by hand *means* +/// — and the orchestrator spawns its own sessions managed without asking +/// anybody. So the question only ever had one sensible answer, and asking it +/// bought a keystroke, an extra screen, and a freshly started session the +/// operator then had to take back from the orchestrator before typing into it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum AgentPickerStep { + /// Choose an installed CLI or registered preset. + Harness, + /// Choose or complete the working directory. + Workspace, +} + +/// One cached workspace completion and why it was suggested. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct WorkspaceChoice { + /// Absolute directory path. + pub(super) path: String, + /// Short operator-facing provenance such as `recent` or `folder`. + pub(super) source: &'static str, +} + +/// A pointer gesture a harness owns until the button comes back up. +/// +/// Terminals grab the pointer on press: every drag and the release belong to +/// whoever took the press, regardless of where the pointer has moved to since. +/// The embedded pane has to do the same, because the alternatives are both +/// visible failures — a release that lands outside the pane, or one swallowed +/// by the hand-back question the click itself opened, leaves the child holding +/// a button nobody is pressing. Claude Code and Codex then read every later +/// motion as a drag and anchor their popups to a press the operator has long +/// since let go of. +#[derive(Clone)] +pub(super) struct PointerGrab { + /// The session that received the press. + pub(super) session: String, + /// The button that went down, so a second button's events are not stolen. + pub(super) button: crate::ui::harness_pane::mouse::Button, + /// Where that session's pane was when the press landed. + /// + /// Carried rather than re-read from `hit_session` because the grab has to + /// outlive the pane: the click that opened a modal, detached the harness, + /// or scrolled the rail can move or remove the rect before the release + /// arrives, and the release still has to be encoded against the geometry + /// the child believes it has. + pub(super) rect: Rect, +} + +/// The "you still hold this session" confirmation shown on release. +/// +/// Modelled on an unsaved-changes prompt, and for the same reason: an operator +/// who took a session over and walked away has left the orchestrator locked out +/// of it, and the moment they release the keyboard is the only moment they are +/// certainly thinking about it. Silently handing it back would be worse — it +/// would resume dispatch into a session mid-thought. +pub(super) struct HandbackPrompt { + /// The session the question is about. + /// + /// Every answer acts on this, never on whatever the rail last resolved: the + /// question can outlive the frame that raised it, and a `y` that moved + /// control of a *different* session is the worst outcome this whole flow + /// has. + pub(super) session: String, + /// Whether attaching is what took control, as opposed to an explicit + /// `/takecontrol`. An explicit take is a decision, so the prompt says so + /// rather than implying the operator got here by accident. + pub(super) took_control: bool, + /// What the operator wants continued, typed into the prompt. + /// + /// This is the moment they actually have the context — they are leaving the + /// session *now* — so it is the one place worth asking. `/handoff ` + /// exists for the operator who already knows; this is for the one who is + /// only reminded by being asked. + pub(super) note: crate::ui::composer::Draft, + /// Whether keystrokes are going into the note rather than answering. + /// + /// Modal because `y`/`n` have to keep meaning yes and no: an operator who + /// starts typing a note that begins with "no, ..." must not have the first + /// letter answer the question for them. + pub(super) editing_note: bool, + /// Which direction the question is about: `true` asks whether to take the + /// session from the orchestrator, `false` whether to hand it back. + /// + /// One prompt for both because they are the same decision seen from either + /// side, and the answer is the same keystroke — but the sentence has to say + /// which way control is about to move, or the operator confirms the + /// opposite of what they meant. + pub(super) is_takeover: bool, +} + +/// How the operator came to hold a session the orchestrator had. +/// +/// Only the wording of the release question turns on this — both origins ask, +/// because both locked dispatch out of a workspace. What does *not* appear here +/// is "started it myself": that session was never taken from anyone, so it is +/// absent from [`App::sessions_taken`] rather than being a third variant. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum TakeOrigin { + /// Focusing in took it, which the operator may not have realised. + Focus, + /// `/takecontrol`, `Ctrl-G`, or answering the takeover question — a decision. + Explicit, +} + +/// What to do when the operator releases a session they took. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum HandbackPolicy { + /// Ask, every time. + #[default] + Ask, + /// Always hand back without asking. + Always, + /// Never hand back; releasing the keyboard keeps control. + Never, +} + +impl HandbackPolicy { + /// Parse the `[harness].handback` config value, falling back to + /// [`Ask`](Self::Ask) for anything unrecognized — a typo in a config file + /// should not silently change who controls a session. + pub fn from_config(value: &str) -> Self { + match value.trim().to_ascii_lowercase().as_str() { + "always" => HandbackPolicy::Always, + "never" => HandbackPolicy::Never, + _ => HandbackPolicy::Ask, + } + } +} + +/// The action a small inline prompt (Hosts add/edit, Agents answer) submits. +pub(super) enum PromptKind { + /// Select an arbitrary Git revision as the Changes comparison baseline. + ChangesBaseline, + /// Attach a session-local review comment to a file, hunk, or patch line. + ChangesComment { + /// Repository-relative path being reviewed. + path: std::path::PathBuf, + /// Position within that file's patch the note is bound to. + anchor: medulla::ui::git_review::CommentAnchor, + }, + /// Add a worker from an address/@handle line. + HostAdd, + /// Edit the label of the worker with the given id. + HostEditLabel(String), + /// Declare another directory this device may work in. + WorkspaceAdd, + /// Name the agent about to be declared for this `harness × workspace`. + /// + /// Blank accepts the id [`suggest_agent_id`](medulla::runtime::suggest_agent_id) + /// minted from the directory, which is how a person refers to the agent + /// anyway — the prompt exists for the case where it is not. + AgentName { + /// The CLI the agent runs. + harness: String, + /// The absolute directory its sessions work in. + workspace: String, + }, + /// Name the session about to be opened under an already-declared agent. + /// + /// A session a person spins up is [`SessionOrigin::User`](crate::worker::pty::SessionOrigin) + /// and is the only kind that carries a name; a dispatched one is labelled + /// from its task. Blank leaves it unnamed rather than inventing one. + SessionName { + /// The agent whose harness type and workspace the session inherits. + agent_id: String, + /// Whether the orchestrator may dispatch into it — ownership at birth. + managed: bool, + }, + /// Add a named OpenRouter-backed coding harness. + CustomHarnessAdd, + /// Edit the custom harness with the given stable id. + CustomHarnessEdit(String), + /// Declare a lifecycle hook for every harness Medulla launches. + HookAdd, + /// Edit the hook at the given row of the Hooks page. + HookEdit(usize), + /// Reject a workflow proposal with the operator's explanation. + RejectProposal { + /// The workflow the proposal belongs to. + workflow: String, + /// The proposal awaiting the decision. + proposal_id: String, + }, + /// Answer a pending sub-agent question. + AnswerQuestion { + /// The cycle the question belongs to. + cycle_id: String, + /// The pending question's id. + question_id: String, + }, + /// Answer a prepared decision and dismiss it locally once routed. + DecisionAnswer { + /// Stable decision id. + decision_id: String, + /// Cycle that owns the question. + cycle_id: String, + /// Harness question id. + question_id: String, + }, + /// Comment on the given feedback board item. + FeedbackComment { + /// The item being commented on. + id: String, + }, + /// Step one of submitting feedback: the title. Submitting advances to + /// [`PromptKind::FeedbackBody`] rather than sending anything. + FeedbackTitle { + /// Feature request or bug report, chosen by which key opened the prompt. + kind: FeedbackType, + }, + /// Step two of submitting feedback: the body. Submitting sends it. + FeedbackBody { + /// Feature request or bug report. + kind: FeedbackType, + /// The title captured in step one. + title: String, + }, + /// One field of a workflow's declared inputs, collected before the run + /// starts. Submitting either opens the prompt for the next field or, when + /// this was the last, dispatches the run. + /// + /// The whole set is carried on the prompt rather than parked in `App` + /// state, so cancelling with `Esc` abandons the collected values with it — + /// a half-filled set cannot leak into the next run. + WorkflowInput { + /// The workflow the values are being collected for. + workflow_id: String, + /// Whether to dispatch a dry run rather than a real one. + dry_run: bool, + /// The fields still to ask about; the head is the one on screen. + remaining: Vec, + /// What has been collected so far, keyed by input name. + collected: serde_json::Map, + }, +} + +/// The Feedback surface's state: the loaded page, the selected row, that row's +/// comments, and the active query. +pub(super) struct FeedbackState { + /// The current page of board items. + pub(super) items: Vec, + /// Total items matching the query across all pages. + pub(super) total: i64, + /// The highlighted row. + pub(super) index: usize, + /// Comments for [`FeedbackState::detail_id`], loaded lazily on selection. + pub(super) comments: Vec, + /// Which item [`FeedbackState::comments`] belongs to. + pub(super) detail_id: Option, + /// Scroll offset within the detail pane. + pub(super) detail_scroll: usize, + /// The active filter/sort/pagination. + pub(super) query: FeedbackQuery, + /// Whether the runtime serves a board at all. `false` renders a sign-in + /// hint instead of an empty list. + pub(super) supported: bool, + /// Whether a board load is in flight (drives the header's "loading…"). + pub(super) loading: bool, +} + +impl Default for FeedbackState { + fn default() -> Self { + Self { + items: Vec::new(), + total: 0, + index: 0, + comments: Vec::new(), + detail_id: None, + detail_scroll: 0, + query: FeedbackQuery::default(), + supported: true, + loading: false, + } + } +} + +/// A single-line inline input overlay shared with daemon controls. +pub(super) type Prompt = TextPrompt; + +/// Cached credential-presence flags displayed by Routing's Manage Keys pane. +#[derive(Default)] +pub(super) struct CredentialStatus { + pub(super) claude_subscription: bool, + pub(super) codex_subscription: bool, + pub(super) anthropic_api_key: bool, + pub(super) openai_api_key: bool, + pub(super) openrouter_api_key: bool, +} + +/// The interactive TUI screen: all tab state, input focus, and render geometry. +pub struct App { + /// The runtime this screen drives. + pub runtime: Arc, + /// The loaded configuration (for the Config/Overview surfaces). + pub loaded: LoadedConfig, + /// The most recent runtime snapshot, refreshed each loop tick. + pub snapshot: RuntimeSnapshot, + /// The active top-level tab index (into [`TABS`]). + pub tab_index: usize, + /// Git changes from the selected session or operator-chosen commit. + pub(super) changes: super::super::changes::GitChangesState, + pub(super) draft: Draft, + pub(super) history: Vec, + pub(super) history_index: i64, + pub(super) selected: usize, + /// The Overview tab's animated workflow graph. Held on the app because its + /// simulation has to survive between frames; it is advanced by the draw + /// path, which is the only thing that looks at it. + pub(super) graph: super::super::render::graph::Graph, + pub(super) status: String, + /// A persistent "update vX.Y.Z available" banner, set by the background + /// update checker; shown in the header until the app exits. + pub(super) update_notice: Option, + pub(super) contexts: Vec, + pub(super) context_index: usize, + pub(super) agent_index: usize, + /// Which selectable rail row remains selected while the live rail is rebuilt. + pub(super) agent_anchor: Option, + /// Extra pages of sublanes revealed under an agent lane, keyed by lane key. + /// + /// Keyed by [`AgentLane::key`](crate::ui::agents::AgentLane::key) rather than + /// by the lane's rail position, because lanes are re-folded from events every + /// tick and a lane that appears or ends shifts every index below it — an + /// expansion tied to a position would silently jump to a different agent. + /// Absent means the lane shows its first page, which is the default every + /// lane starts at. + pub(super) subtask_pages: std::collections::HashMap, + /// The `(worker address, task id)` whose screen is currently subscribed. + /// + /// Held so a selection change can stop the old stream as well as start the + /// new one: a subscription nobody is looking at costs the worker a sample, + /// a ratchet advance and a send on every tick. + pub(super) watching: Option<(String, String)>, + /// The watched `(worker, task)` awaiting destructive-action confirmation. + pub(super) kill_armed: Option<(String, String)>, + /// Which half of the Agents tab the keyboard is driving. + pub(super) agents_focus: AgentsFocus, + pub(super) agent_scroll: usize, + pub(super) chat_scroll: usize, + /// Selected row in the command peek, while it is open. + pub(super) command_index: usize, + /// Selected row on the Routing Hosts page. + pub(super) host_index: usize, + /// Whether ↑↓ on the Hosts page drives the role toggles in the preview + /// rather than the host list above it. Tab moves between the two. + pub(super) host_roles_focus: bool, + /// Selected role in the preview's toggle list, while it has focus. + pub(super) host_role_index: usize, + /// Selected row on the Routing Workspaces page. + pub(super) workspace_index: usize, + /// Selected row on the Routing Agent Templates page. + pub(super) template_index: usize, + /// OpenRouter-backed harness presets loaded from the active config. + pub(super) custom_harnesses: Vec, + /// Selected row on the Routing Harness Types page. + pub(super) custom_harness_index: usize, + /// Selected row on the Routing Hooks page. + pub(super) hook_index: usize, + /// Lifecycle reports arriving from the harnesses this Medulla launched. + /// + /// Written by the control socket's `hook.report` handler and read here; an + /// app with no control plane bound simply renders an empty log. + pub(super) hook_log: medulla::harness_hooks::HookEventLog, + /// Scroll offset inside the open agent-template popup. + pub(super) template_scroll: usize, + /// Whether the agent-template popup is open over the catalog. + pub(super) template_modal: bool, + /// Selected row on the Routing Workflows page. + #[cfg(feature = "workflows")] + pub(super) workflow_index: usize, + /// The installed workflows, as last read from disk. + /// + /// Cached rather than re-read every frame: the store is files, and a render + /// pass should not do I/O. `r` re-reads it, as it does for templates. + #[cfg(feature = "workflows")] + pub(super) workflows: Vec, + /// The selected workflow's runs, read when the selection changes rather + /// than on every frame. + #[cfg(feature = "workflows")] + pub(super) workflow_runs: Vec, + /// Why the run history could not be read, if it could not. + #[cfg(feature = "workflows")] + pub(super) workflow_runs_error: Option, + /// What the selected workflow has learned, newest first. + /// + /// Cached beside the runs and refreshed with them, for the same reason: a + /// render pass must not touch the disk. + #[cfg(feature = "workflows")] + pub(super) workflow_notes: Vec, + /// Changes proposed for the selected workflow, newest first. + #[cfg(feature = "workflows")] + pub(super) workflow_proposals: Vec, + /// The Workflows tab's panes, cursors, and copilot threads. + #[cfg(feature = "workflows")] + pub(super) wf: WorkflowsState, + /// A workflow store attached directly, overriding the layered one this + /// client would otherwise resolve. + /// + /// The layered store always reads the current directory's + /// `.medulla/workflows` as repository defaults, then overlays the + /// user-global workflow directory. That is useful in a real session and + /// wrong under test, where it makes the catalogue depend on the developer's + /// checkout. `None` resolves the layered store, as a real session does. + #[cfg(feature = "workflows")] + pub(super) workflow_store_override: Option>, + /// The active Routing subpage (index into [`ROUTING_SUBPAGES`]). + pub(super) routing_index: usize, + /// Whether keyboard focus is inside the Routing content pane. + pub(super) routing_focused: bool, + /// Selected row on the Routing strategy page. + pub(super) routing_strategy_index: usize, + /// Selected subscription rule on the Routing strategy page. + pub(super) subscription_strategy_index: usize, + /// Whether the subscription group, rather than the host group, has focus. + pub(super) subscription_strategy_focused: bool, + /// Credential presence captured on startup and refreshed when its pane opens. + pub(super) credential_status: CredentialStatus, + /// The active TokenMaxxxing sidebar page. + pub(super) tokenmaxxing_index: usize, + /// Whether keyboard focus is inside the TokenMaxxxing content pane. + pub(super) tokenmaxxing_focused: bool, + /// Feedback-board state (lazily loaded on entry / refresh). + pub(super) feedback: FeedbackState, + /// Feedback-board tab state (lazily loaded on tab entry / refresh). + /// Whether the prepared-decision modal is visible. + pub(super) decision_open: bool, + /// Highlighted decision row. + pub(super) decision_index: usize, + /// Session-local ids intentionally hidden by the operator. + pub(super) dismissed_decisions: std::collections::BTreeSet, + pub(super) prompt: Option, + /// The animation frame counter: one per event-loop tick (~90ms). + /// + /// Drives the spinner and the workflow canvas's flowing wires. Held on the + /// app rather than read from a clock so a test that draws frames explicitly + /// sees the same animation the terminal does. + pub frame: usize, + /// Whether the app currently captures the mouse. + pub mouse_capture: bool, + /// Account-level usage payload (`/teams/me/usage` data), when fetched. + pub account_usage: Option, + /// The active Settings subpage (index into [`SETTINGS_SUBPAGES`]). + pub(super) settings_index: usize, + /// Whether keyboard focus is inside the Settings content pane rather than on + /// the left-hand subpage nav. + /// + /// Subpages whose content is a list of *actions* (Feedback especially) bind + /// enough single letters that they swallow the keys you would otherwise use + /// to get around, and `↑↓` moving the nav meant arrow keys jumped you off + /// the page entirely. Entering the pane hands `↑↓` to the content and makes + /// the letter bindings deliberate rather than ambient. + pub(super) settings_focused: bool, + /// The selected theme role on the Appearance subpage. + pub(super) appearance_index: usize, + /// Throttled sampler backing the optional local-process status indicators. + pub(super) resource_monitor: crate::ui::resources::ResourceMonitor, + /// Throttled sampler backing the optional whole-device sidebar indicators. + pub(super) device_monitor: crate::ui::resources::DeviceMonitor, + /// The selected field row on the Status line subpage. + pub(super) status_line_index: usize, + /// Whether the next persisted status-line edit must write the complete + /// legacy-derived section rather than one field. + pub(super) status_line_promotion_pending: bool, + /// The selected editable row on the Config subpage. + pub(super) config_index: usize, + /// Whether the Account subpage's logout is armed. Logging out clears stored + /// credentials, so the first Enter arms and the second confirms; any other + /// navigation disarms it. + pub(super) logout_armed: bool, + /// Whether the app is quitting in order to re-authenticate rather than to + /// exit. Set by a successful logout so the caller tears the session down and + /// returns to the login screen instead of returning to the shell. + pub(super) relogin_requested: bool, + /// Who the embedded core is signed in as, for the Account subpage. + pub(super) account: Option, + /// The Medulla home directory, used to locate the credential store the + /// Account subpage clears. Injectable so feature tests never touch the real + /// home; `None` disables logout. + pub(super) medulla_home: Option, + /// The resolved color theme; selection highlighting + chrome draw from it. + pub(super) theme: Theme, + /// Where appearance changes are persisted (the user-global `config.toml`). + /// Injectable so feature tests never touch the real home. `None` disables + /// persistence (changes still apply live). + pub(super) config_path: Option, + /// Where hook edits are persisted — deliberately not always [`Self::config_path`]. + /// + /// `config_path` may resolve to a project-local file + /// (`.medulla/config.toml`/`medulla.toml`), which is exactly the layer + /// `medulla::config::load_config` strips `[[hooks]]` from on every load that + /// is not an explicit `--config` (project configuration must not authorize + /// shell commands in the operator's environment). Saving a hook there would + /// show "Hook saved" and apply for the rest of this session while writing + /// to a file the next launch ignores. Defaulted to [`Self::config_path`] by + /// [`Self::set_config_path`] and overridden by + /// [`Self::set_hooks_config_path`] whenever the caller knows the two must + /// differ — see `app_loop::run_tui` in the `medulla-tui` crate. + pub(super) hooks_config_path: Option, + pub(super) resume_picker: Option, + /// Whether the event loop should exit after this tick. + pub should_quit: bool, + + // Render geometry, recorded each draw for click hit-testing. + pub(super) area: Rect, + pub(super) hit_tabs: Vec<(u16, u16)>, + pub(super) hit_tabs_row: u16, + /// Where the Agents rail drew, and the rendered row each visible line + /// belongs to. A row may wrap onto several lines, so a click resolves + /// through this snapshot rather than by adding an offset to a freshly + /// rebuilt row list that may have changed since the frame was drawn. + pub(super) hit_agents: Option<(Rect, Vec)>, + // Where the embedded session screen landed, and whose it is. Recorded so a + // wheel event can be routed to the terminal under the pointer and given + // coordinates relative to *its* origin rather than the screen's. + pub(super) hit_session: Option<(Rect, String)>, + /// The threads strip's hit box and its first visible row, for click-to-switch. + pub(super) hit_threads: Option<(Rect, usize)>, + /// Where the orchestrator's conversation drew, and the task each of its + /// visible lines opens (§A7) — `None` for the lines that are transcript + /// rather than a session entry. + /// + /// One slot per drawn row rather than a dense list, because the entries are + /// interleaved with the conversation: each one sits under the turn that + /// started it, so the block is no longer contiguous and an offset from its + /// top no longer identifies an entry. + /// + /// Tasks rather than row indices: the rail is rebuilt every frame, so an + /// index recorded during the draw can name a different row by the time the + /// click lands. A task id either still has a session or does not. + pub(super) hit_started_sessions: Option<(Rect, Vec>)>, + pub(super) hit_context: Option, + /// The selected workflow step's preview, for pointer-wheel scrolling. + pub(super) hit_workflow_preview: Option, + /// Where the active tab's subpage nav drew its page rows. Only one nav is on + /// screen at a time, so one field serves Routing and Settings. + pub(super) hit_nav: crate::ui::multi_pane::NavHits, + /// Every pane drawn this frame, in draw order. A pointer selection is + /// clamped to whichever of these it started in, so a drag reads one pane's + /// text instead of splicing its neighbour's columns into every row. + pub(super) panes: Vec, + /// A drag in progress: where the button went down. Kept apart from + /// [`Self::selection`] so a click that never moves leaves no selection. + pub(super) drag_anchor: Option<(u16, u16)>, + /// The block of cells the pointer has swept, normalized to + /// `(left, top, right, bottom)` inclusive. + pub(super) selection: Option<(u16, u16, u16, u16)>, + /// Set when the button is released over a live selection: the next draw + /// copies what the selection covers, since only then is the buffer readable. + pub(super) copy_selection: bool, + pub(super) last_events_len: usize, + + // Test-only clipboard capture: when set, `copy_chat` records the copied text + // here and skips the platform writers (no `pbcopy`/OSC subprocess in tests). + pub(super) copy_capture: Option>>>, + + // Optional observational overlay from the background host-link service: + // this endpoint's own identity, its peer roster, and peer presence. Merged + // into the snapshot on every refresh so the Overview panel and Agents lanes + // light up without the runtime having to know about the link. + pub(super) link_obs: Option>>, + // A read-only view of the task host running on this device, when one is. + // Read live at render rather than merged into the snapshot: its counters + // move on the host's own schedule, and the snapshot is the *runtime's* + // picture of the world — the host is a peer to it, not part of it. + pub(super) host_obs: Option, + // The live sessions this device is running. `None` when this machine + // does not host, in which case the Agents tab has no local screen to show + // and falls back to a remote worker's streamed one, or to the transcript. + pub(super) local_sessions: Option, + // Workflow runs the harnesses on this device started over MCP, keyed by the + // grant session the launcher recorded on each PTY row. Read at render so a + // run reported a moment ago is on screen at the next frame, and empty on a + // build or a host with no control plane bound. + pub(super) harness_runs: medulla::control_socket::HarnessRunRegistry, + // Live per-node harness output for runs this TUI started, keyed by run id. + // Only ever a handful: a settled run keeps its frames until the next run of + // the same workflow replaces it. + #[cfg(feature = "workflows")] + pub(super) live_runs: std::collections::HashMap, + // Which of the TUI and the selected session owns the keyboard. Reset to + // `Chrome` whenever the attached session stops being the selected one, so + // the operator's keys can never land in a session they are not looking at. + pub(super) harness_focus: crate::ui::harness_pane::HarnessFocus, + // The session the Agents pane resolved on the last draw, and the + // only one the attach chord can act on. Recorded during render because that + // is where the rail cursor is turned into a selection; cleared at the top of + // every draw so it can never name a pane that is no longer on screen. + pub(super) pane_session: Option, + // What the pane is showing for that session: its terminal, or one of the + // views that replace it. Not cleared per frame — it is the operator's + // choice, not a record of what the last draw resolved — so it is reset by + // the selection moving instead. + pub(super) pane_view: PaneView, + // The session `pane_view` was chosen for. `pane_session` cannot answer this: + // it is cleared at the top of every draw, so comparing against it would + // reset the view on every frame. + pub(super) pane_view_session: Option, + // The session whose close is awaiting confirmation, if one is. Killing a + // harness ends whatever it was in the middle of, so `k` asks first — the + // same one-keypress contract `kill_armed` has for a dispatched task. + pub(super) harness_close_armed: Option, + // The session that took the press of the button currently held down, if any. + // A terminal grabs the pointer for the whole gesture: whoever received the + // press receives the drags and the release too, wherever the pointer has + // wandered to since. Without the grab a release outside the pane — or one + // swallowed by a modal the click itself opened — never reaches the child, + // which goes on believing the button is still down and misplaces everything + // it draws in response to the pointer afterwards. + pub(super) pointer_grab: Option, + // Where the hand-back question drew each of its answers, and the key each + // one stands for. Recorded during the draw so a click can be answered by + // replaying the keystroke rather than by a second copy of the routing: the + // two would drift, and the direction they would drift in is a pointer that + // hands a harness back when the operator meant to keep it. + pub(super) hit_handback: Vec<(Rect, crossterm::event::KeyCode)>, + // The "start a session" picker's outer box, and where each offered row was + // drawn with the index it stands for in that step's list. Recorded during + // the draw for the same reason the hand-back answers are: the harness step + // windows a long list, so screen position and list index are not the same + // number, and only the draw knows which window it used. + pub(super) hit_agent_picker: Option<(Rect, Vec<(Rect, usize)>)>, + // The agent behind a selected session row that this device does NOT run, + // recorded alongside `pane_session` on the same draw. + // + // Its only purpose is to tell "the cursor is not on a session" apart from + // "the cursor is on somebody else's session", which `pane_session` cannot: + // both leave it `None`. Taking control resolves through the local workspace + // path, so a remote session can be watched but not taken (§E7), and an + // operator who presses the take chord on one deserves that answer rather + // than "no session on this row". + pub(super) pane_remote_session: Option, + // The session selected on the Agents rail, retained while another tab is + // visible. Unlike `pane_session`, this is navigation state rather than a + // keyboard-routing capability: Changes uses it to keep following + // the repository the operator selected after an intervening tab draw. + pub(super) rail_session: Option, + /// The "start a session" picker, while it is open. + pub(super) agent_picker: Option, + /// The "you still hold this session" confirmation, while it is open. + pub(super) handback_prompt: Option, + /// How far the Help page is scrolled, in lines. + pub(super) help_scroll: u16, + /// What releasing a held session does, from `[harness].handback`. + pub(super) handback_policy: HandbackPolicy, + /// The sessions taken *from the orchestrator*, and how each was taken. + /// + /// Membership is the whole question the release prompt exists to ask. A + /// session the operator started themselves was never the orchestrator's, so + /// letting go of the keyboard owes it nothing, and asking about it every + /// time is how a confirmation becomes furniture. One taken out from under + /// dispatch is different: walking away from it leaves the orchestrator + /// locked out of a workspace, silently and indefinitely. + /// + /// The origin distinguishes "you picked this up by focusing in" from "you + /// asked for it with /takecontrol", which the release prompt words + /// differently: the second was a decision, and re-asking about it as though + /// it were an accident is how a confirmation becomes noise. + /// + /// Keyed by session rather than kept as one flag because several sessions + /// can be held at once — take A, keep it on release, then attach to B. A + /// single flag answered for whichever was touched last: it worded B's + /// question with A's takeover, and kept asking about sessions nobody had + /// taken from anyone. + pub(super) sessions_taken: std::collections::HashMap, + /// Sessions the operator has at some point given to the orchestrator. + /// + /// Separate from [`sessions_taken`](Self::sessions_taken), which says who + /// holds a session *now*; this remembers that dispatch once had a claim on + /// it, and it is never cleared. + /// + /// Needed because [`SessionOrigin`](crate::worker::pty::SessionOrigin) alone + /// under-counts. A session the operator started carries origin `User` + /// forever, but handing it back makes it genuinely dispatchable — + /// `SessionHandle::serves_label` lets a handed-back operator session be + /// adopted for a task. If that turn then fails, the executor hands it + /// straight back to the operator without going through + /// [`take_session`](App::take_session), leaving a session with origin + /// `User`, no entry in `sessions_taken`, and dispatch locked out of it. + /// Releasing that in silence is the bug this set closes. + pub(super) orchestrator_claimed: std::collections::HashSet, + /// Commands raised by synchronous input handlers, drained by the event loop. + /// + /// The key and mouse handlers that move session control cannot return a + /// [`Cmd`] — `handle_handback_key` returns `()`, `handle_harness_key` + /// returns `bool`, and the mouse path returns nothing — and threading an + /// `Option` back through all three would be a wide, test-breaking + /// change to say one thing. So they push here instead, and the loop drains + /// it right after the event that produced it. Commands run in submission + /// order. + pub(super) pending_cmds: std::collections::VecDeque, + /// Whether operator-started sessions launch with the permission-bypass + /// flag, from `[harness].skipPermissions`. + pub(super) harness_skip_permissions: bool, +} diff --git a/src/tui/src/ui/app/types/rail_hit.rs b/src/tui/src/ui/app/types/rail_hit.rs index f18a3bd88..3bd02c058 100644 --- a/src/tui/src/ui/app/types/rail_hit.rs +++ b/src/tui/src/ui/app/types/rail_hit.rs @@ -6,7 +6,7 @@ /// sessions can retain their transcript, and a per-line hit map must not clone /// that transcript for every wrapped or off-screen row. #[derive(Clone)] -pub(super) enum RailHitTarget { +pub(in crate::ui::app) enum RailHitTarget { /// A non-selectable label or host row. Inert, /// A selectable row with no direct pointer action. @@ -23,7 +23,7 @@ pub(super) enum RailHitTarget { impl RailHitTarget { /// The local harness session this target names, if it names one. - pub(super) fn session_id(&self) -> Option { + pub(in crate::ui::app) fn session_id(&self) -> Option { match self { Self::Session(session) => Some(session.clone()), _ => None, @@ -37,21 +37,21 @@ impl RailHitTarget { /// hit map therefore retains the row and its anchor from that frame instead of /// treating a rendered offset as an offset into a new rail projection. #[derive(Clone)] -pub(super) struct RailHit { +pub(in crate::ui::app) struct RailHit { /// The compact action selected by this drawn line. - pub(super) target: RailHitTarget, + pub(in crate::ui::app) target: RailHitTarget, /// Test-only copy of the row so focused interaction tests can name it. #[cfg(test)] pub(super) row: super::super::rail::RailRow, /// The durable cursor identity resolved while the row was rendered. - pub(super) anchor: Option, + pub(in crate::ui::app) anchor: Option, /// The row's rendered offset, retained only as a fallback if it has no anchor. - pub(super) index: usize, + pub(in crate::ui::app) index: usize, } impl RailHit { /// Capture just the data pointer routing needs from a rendered rail row. - pub(super) fn from_row( + pub(in crate::ui::app) fn from_row( row: &super::super::rail::RailRow, anchor: Option, index: usize, @@ -81,7 +81,7 @@ impl RailHit { } /// Whether the current rail projection still contains this rendered row. - pub(super) fn exists_in( + pub(in crate::ui::app) fn exists_in( &self, rows: &[super::super::rail::RailRow], lanes: &[crate::ui::agents::AgentLane], @@ -93,7 +93,7 @@ impl RailHit { } /// Whether the cursor may land on this target. - pub(super) fn selectable(&self) -> bool { + pub(in crate::ui::app) fn selectable(&self) -> bool { !matches!(&self.target, RailHitTarget::Inert) } } From 08515963cac4d624fdb5295cf96bc14f7f84d262 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 18:17:48 +0300 Subject: [PATCH 10/14] fix(tui): expose app model to sibling modules --- src/tui/src/ui/app/types/model.rs | 391 +++++++++++++++--------------- 1 file changed, 199 insertions(+), 192 deletions(-) diff --git a/src/tui/src/ui/app/types/model.rs b/src/tui/src/ui/app/types/model.rs index 093f2007e..2b441fc2c 100644 --- a/src/tui/src/ui/app/types/model.rs +++ b/src/tui/src/ui/app/types/model.rs @@ -7,7 +7,7 @@ //! [`super::super::keys`], [`super::super::commands`], and [`super::super::render`]), each of which //! adds its own `impl App` block. Because those blocks share `App`'s private //! fields, the fields (and the private helper types/consts here) are -//! `pub(super)` so every sibling submodule can reach them. +//! `pub(in crate::ui::app)` so every sibling submodule can reach them. use std::sync::Arc; @@ -93,27 +93,30 @@ pub const ROUTING_SUBPAGES: [&str; 6] = [ "Strategies", ]; -pub(super) const RP_HOSTS: usize = 0; -pub(super) const RP_HARNESSES: usize = 1; +pub(in crate::ui::app) const RP_HOSTS: usize = 0; +pub(in crate::ui::app) const RP_HARNESSES: usize = 1; // Beside Harness Types on purpose: a hook is a property of every harness // Medulla launches, and this page is the one place they are declared for all of // them. -pub(super) const RP_HOOKS: usize = 2; -pub(super) const RP_TEMPLATES: usize = 3; -pub(super) const RP_ADD_HOST: usize = 4; -pub(super) const RP_STRATEGIES: usize = 5; +pub(in crate::ui::app) const RP_HOOKS: usize = 2; +pub(in crate::ui::app) const RP_TEMPLATES: usize = 3; +pub(in crate::ui::app) const RP_ADD_HOST: usize = 4; +pub(in crate::ui::app) const RP_STRATEGIES: usize = 5; // Past the end of `ROUTING_SUBPAGES`, so the nav clamp cannot reach it and its // arm is unreachable — the page is off without its code rotting. -pub(super) const RP_WORKSPACES: usize = 6; +pub(in crate::ui::app) const RP_WORKSPACES: usize = 6; /// The TokenMaxxxing tab's sidebar pages. -pub(super) const TOKENMAXXING_SUBPAGES: [&str; 3] = ["Overview", "Bounties", "Leaderboard"]; +pub(in crate::ui::app) const TOKENMAXXING_SUBPAGES: [&str; 3] = + ["Overview", "Bounties", "Leaderboard"]; -pub(super) const TM_OVERVIEW: usize = 0; -pub(super) const TM_BOUNTIES: usize = 1; -pub(super) const TM_LEADERBOARD: usize = 2; +pub(in crate::ui::app) const TM_OVERVIEW: usize = 0; +pub(in crate::ui::app) const TM_BOUNTIES: usize = 1; +pub(in crate::ui::app) const TM_LEADERBOARD: usize = 2; -pub(super) use super::super::routing_options::{ROUTING_STRATEGIES, SUBSCRIPTION_STRATEGIES}; +pub(in crate::ui::app) use super::super::routing_options::{ + ROUTING_STRATEGIES, SUBSCRIPTION_STRATEGIES, +}; /// The Settings tab's left-nav subpages, in order (number keys 1-9 jump to them). /// @@ -143,19 +146,19 @@ pub const SETTINGS_GROUPS: [(&str, usize); 3] = [ ]; // Settings subpage indices. -pub(super) const SP_USAGE: usize = 0; -pub(super) const SP_APPEARANCE: usize = 1; -pub(super) const SP_STATUS_LINE: usize = 2; -pub(super) const SP_CONFIG: usize = 3; -pub(super) const SP_FEEDBACK: usize = 4; -pub(super) const SP_TRACE: usize = 5; -pub(super) const SP_CONTEXT: usize = 6; -pub(super) const SP_ACCOUNT: usize = 7; -pub(super) const SP_HELP: usize = 8; +pub(in crate::ui::app) const SP_USAGE: usize = 0; +pub(in crate::ui::app) const SP_APPEARANCE: usize = 1; +pub(in crate::ui::app) const SP_STATUS_LINE: usize = 2; +pub(in crate::ui::app) const SP_CONFIG: usize = 3; +pub(in crate::ui::app) const SP_FEEDBACK: usize = 4; +pub(in crate::ui::app) const SP_TRACE: usize = 5; +pub(in crate::ui::app) const SP_CONTEXT: usize = 6; +pub(in crate::ui::app) const SP_ACCOUNT: usize = 7; +pub(in crate::ui::app) const SP_HELP: usize = 8; /// The index of a tab by name, or 0 if unknown. Keeps tab jumps robust as the tab /// list grows. -pub(super) fn tab_pos(name: &str) -> usize { +pub(in crate::ui::app) fn tab_pos(name: &str) -> usize { TABS.iter().position(|t| *t == name).unwrap_or(0) } @@ -250,7 +253,7 @@ pub enum WorkflowView { #[derive(Debug, Default)] pub struct WorkflowsState { /// Which pane has the keyboard. - pub(super) focus: WorkflowFocus, + pub(in crate::ui::app) focus: WorkflowFocus, /// Whether the rail cursor is on the "New workflow" row. /// /// Its own flag rather than a sentinel value of the catalogue index, @@ -258,7 +261,7 @@ pub struct WorkflowsState { /// to list, and nothing to run. Everything that reads the selection has to /// answer "or is it the new one?" and a magic index would let that question /// go unasked. - pub(super) creating: bool, + pub(in crate::ui::app) creating: bool, /// The selected workflow's run, when the rail cursor is on one of the run /// rows nested under it rather than on the workflow itself. /// @@ -266,20 +269,20 @@ pub struct WorkflowsState { /// under a workflow only exist while it is selected, so a flat index would /// have to be reinterpreted every time the cursor crossed a workflow /// boundary — and gets it wrong the moment a run appears mid-scroll. - pub(super) run_index: Option, + pub(in crate::ui::app) run_index: Option, /// The selected workflow's graph, as last read from the store. Cached /// because a render pass must not touch the disk, and re-laying it out every /// frame would move boxes under the cursor. - pub(super) graph: Option>, + pub(in crate::ui::app) graph: Option>, /// The selected workflow's own choice of harness and model, cached with the /// graph and for the same reason. Not part of the graph, so a preview /// reading only [`graph`](Self::graph) would report the host's harness for a /// workflow that pinned its own. - pub(super) defaults: medulla::workflows::WorkflowDefaults, + pub(in crate::ui::app) defaults: medulla::workflows::WorkflowDefaults, /// The laid-out form of [`graph`](Self::graph). - pub(super) layout: medulla::ui::workflows::GraphLayout, + pub(in crate::ui::app) layout: medulla::ui::workflows::GraphLayout, /// Selected node in the canvas, in the layout's reading order. - pub(super) node_index: usize, + pub(in crate::ui::app) node_index: usize, /// Vertical scroll of the canvas, in rows. /// /// The only scroll the canvas has: the graph folds onto a new band whenever @@ -287,25 +290,26 @@ pub struct WorkflowsState { /// pane and there is nothing to scroll horizontally. Counted in rows rather /// than lanes because a fold puts a band boundary between two lanes, and a /// scroll measured in lanes cannot address the gap. - pub(super) canvas_row: usize, + pub(in crate::ui::app) canvas_row: usize, /// Rows inside the graph panel during its most recent render. /// /// Navigation uses this measured viewport rather than the full terminal /// height, because the selected-node preview shares the content column. - pub(super) graph_rows: usize, + pub(in crate::ui::app) graph_rows: usize, /// Top line of the rich selected-step preview. - pub(super) preview_scroll: usize, + pub(in crate::ui::app) preview_scroll: usize, /// Whether the inspector below the canvas is expanded over it. - pub(super) inspector_open: bool, + pub(in crate::ui::app) inspector_open: bool, /// The run being overlaid on the graph, when a run row is selected. - pub(super) overlay: Option, + pub(in crate::ui::app) overlay: Option, /// One copilot thread per workflow, so switching in the rail does not show /// the previous workflow's conversation or lose this one's. - pub(super) copilots: std::collections::HashMap, + pub(in crate::ui::app) copilots: + std::collections::HashMap, /// The copilot composer's draft. - pub(super) draft: Draft, + pub(in crate::ui::app) draft: Draft, /// Scroll offset in the copilot transcript, in lines from the bottom. - pub(super) copilot_scroll: usize, + pub(in crate::ui::app) copilot_scroll: usize, } /// An async action the event loop must run on the app's behalf. @@ -511,11 +515,11 @@ pub enum Cmd { } /// The modal state for the "resume a chat" picker overlay. -pub(super) struct ResumePicker { +pub(in crate::ui::app) struct ResumePicker { /// The resumable chats to choose from. - pub(super) chats: Vec, + pub(in crate::ui::app) chats: Vec, /// The highlighted row. - pub(super) index: usize, + pub(in crate::ui::app) index: usize, } /// An overlay the app can draw over the content pane. @@ -527,7 +531,7 @@ pub(super) struct ResumePicker { /// Produced by [`App::visible_overlays`], which is the single source of truth /// for what is in front of the content — see [`super::super::overlays`]. #[derive(Clone, Copy, PartialEq, Eq, Debug)] -pub(super) enum Overlay { +pub(in crate::ui::app) enum Overlay { /// The prepared-decision board. Decisions, /// The agent-template detail popup. @@ -550,7 +554,7 @@ pub(super) enum Overlay { /// starts a session and declares nothing. Carrying the intent on the picker keeps /// one overlay rather than two that would drift apart. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) enum PickerPurpose { +pub(in crate::ui::app) enum PickerPurpose { /// Start a session here and now, declaring nothing — the `/session` path. Spawn, /// Declare an agent: `harness × workspace`, named on the step after. @@ -558,23 +562,23 @@ pub(super) enum PickerPurpose { } /// The modal state for the harness-type/workspace picker overlay. -pub(super) struct AgentPicker { +pub(in crate::ui::app) struct AgentPicker { /// What confirming the last step will do. - pub(super) purpose: PickerPurpose, + pub(in crate::ui::app) purpose: PickerPurpose, /// Installed providers and registered presets, in offer order. - pub(super) choices: Vec, + pub(in crate::ui::app) choices: Vec, /// The highlighted row. - pub(super) index: usize, + pub(in crate::ui::app) index: usize, /// Which half of the two-step picker owns the keyboard. - pub(super) step: AgentPickerStep, + pub(in crate::ui::app) step: AgentPickerStep, /// Default directory used to seed the editable workspace query. - pub(super) cwd: String, + pub(in crate::ui::app) cwd: String, /// Inline fuzzy-completion text on the workspace step. - pub(super) workspace_query: String, + pub(in crate::ui::app) workspace_query: String, /// Cached workspace rows, refreshed only when the query changes. - pub(super) workspace_choices: Vec, + pub(in crate::ui::app) workspace_choices: Vec, /// Highlighted workspace completion. - pub(super) workspace_index: usize, + pub(in crate::ui::app) workspace_index: usize, /// Whether the operator has deliberately picked one of the completions. /// /// Distinct from `workspace_index != 0`, which cannot express it: a query @@ -583,7 +587,7 @@ pub(super) struct AgentPicker { /// query changes, and read by /// [`selected_picker_workspace`](App::selected_picker_workspace) to decide /// whether an entered directory outranks the completions listed under it. - pub(super) workspace_picked: bool, + pub(in crate::ui::app) workspace_picked: bool, } /// Active stage of the manual session launcher. @@ -595,7 +599,7 @@ pub(super) struct AgentPicker { /// bought a keystroke, an extra screen, and a freshly started session the /// operator then had to take back from the orchestrator before typing into it. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum AgentPickerStep { +pub(in crate::ui::app) enum AgentPickerStep { /// Choose an installed CLI or registered preset. Harness, /// Choose or complete the working directory. @@ -604,11 +608,11 @@ pub(super) enum AgentPickerStep { /// One cached workspace completion and why it was suggested. #[derive(Debug, Clone, PartialEq, Eq)] -pub(super) struct WorkspaceChoice { +pub(in crate::ui::app) struct WorkspaceChoice { /// Absolute directory path. - pub(super) path: String, + pub(in crate::ui::app) path: String, /// Short operator-facing provenance such as `recent` or `folder`. - pub(super) source: &'static str, + pub(in crate::ui::app) source: &'static str, } /// A pointer gesture a harness owns until the button comes back up. @@ -622,11 +626,11 @@ pub(super) struct WorkspaceChoice { /// motion as a drag and anchor their popups to a press the operator has long /// since let go of. #[derive(Clone)] -pub(super) struct PointerGrab { +pub(in crate::ui::app) struct PointerGrab { /// The session that received the press. - pub(super) session: String, + pub(in crate::ui::app) session: String, /// The button that went down, so a second button's events are not stolen. - pub(super) button: crate::ui::harness_pane::mouse::Button, + pub(in crate::ui::app) button: crate::ui::harness_pane::mouse::Button, /// Where that session's pane was when the press landed. /// /// Carried rather than re-read from `hit_session` because the grab has to @@ -634,7 +638,7 @@ pub(super) struct PointerGrab { /// or scrolled the rail can move or remove the rect before the release /// arrives, and the release still has to be encoded against the geometry /// the child believes it has. - pub(super) rect: Rect, + pub(in crate::ui::app) rect: Rect, } /// The "you still hold this session" confirmation shown on release. @@ -644,31 +648,31 @@ pub(super) struct PointerGrab { /// of it, and the moment they release the keyboard is the only moment they are /// certainly thinking about it. Silently handing it back would be worse — it /// would resume dispatch into a session mid-thought. -pub(super) struct HandbackPrompt { +pub(in crate::ui::app) struct HandbackPrompt { /// The session the question is about. /// /// Every answer acts on this, never on whatever the rail last resolved: the /// question can outlive the frame that raised it, and a `y` that moved /// control of a *different* session is the worst outcome this whole flow /// has. - pub(super) session: String, + pub(in crate::ui::app) session: String, /// Whether attaching is what took control, as opposed to an explicit /// `/takecontrol`. An explicit take is a decision, so the prompt says so /// rather than implying the operator got here by accident. - pub(super) took_control: bool, + pub(in crate::ui::app) took_control: bool, /// What the operator wants continued, typed into the prompt. /// /// This is the moment they actually have the context — they are leaving the /// session *now* — so it is the one place worth asking. `/handoff ` /// exists for the operator who already knows; this is for the one who is /// only reminded by being asked. - pub(super) note: crate::ui::composer::Draft, + pub(in crate::ui::app) note: crate::ui::composer::Draft, /// Whether keystrokes are going into the note rather than answering. /// /// Modal because `y`/`n` have to keep meaning yes and no: an operator who /// starts typing a note that begins with "no, ..." must not have the first /// letter answer the question for them. - pub(super) editing_note: bool, + pub(in crate::ui::app) editing_note: bool, /// Which direction the question is about: `true` asks whether to take the /// session from the orchestrator, `false` whether to hand it back. /// @@ -676,7 +680,7 @@ pub(super) struct HandbackPrompt { /// side, and the answer is the same keystroke — but the sentence has to say /// which way control is about to move, or the operator confirms the /// opposite of what they meant. - pub(super) is_takeover: bool, + pub(in crate::ui::app) is_takeover: bool, } /// How the operator came to hold a session the orchestrator had. @@ -686,7 +690,7 @@ pub(super) struct HandbackPrompt { /// is "started it myself": that session was never taken from anyone, so it is /// absent from [`App::sessions_taken`] rather than being a third variant. #[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub(super) enum TakeOrigin { +pub(in crate::ui::app) enum TakeOrigin { /// Focusing in took it, which the operator may not have realised. Focus, /// `/takecontrol`, `Ctrl-G`, or answering the takeover question — a decision. @@ -719,7 +723,7 @@ impl HandbackPolicy { } /// The action a small inline prompt (Hosts add/edit, Agents answer) submits. -pub(super) enum PromptKind { +pub(in crate::ui::app) enum PromptKind { /// Select an arbitrary Git revision as the Changes comparison baseline. ChangesBaseline, /// Attach a session-local review comment to a file, hunk, or patch line. @@ -827,26 +831,26 @@ pub(super) enum PromptKind { /// The Feedback surface's state: the loaded page, the selected row, that row's /// comments, and the active query. -pub(super) struct FeedbackState { +pub(in crate::ui::app) struct FeedbackState { /// The current page of board items. - pub(super) items: Vec, + pub(in crate::ui::app) items: Vec, /// Total items matching the query across all pages. - pub(super) total: i64, + pub(in crate::ui::app) total: i64, /// The highlighted row. - pub(super) index: usize, + pub(in crate::ui::app) index: usize, /// Comments for [`FeedbackState::detail_id`], loaded lazily on selection. - pub(super) comments: Vec, + pub(in crate::ui::app) comments: Vec, /// Which item [`FeedbackState::comments`] belongs to. - pub(super) detail_id: Option, + pub(in crate::ui::app) detail_id: Option, /// Scroll offset within the detail pane. - pub(super) detail_scroll: usize, + pub(in crate::ui::app) detail_scroll: usize, /// The active filter/sort/pagination. - pub(super) query: FeedbackQuery, + pub(in crate::ui::app) query: FeedbackQuery, /// Whether the runtime serves a board at all. `false` renders a sign-in /// hint instead of an empty list. - pub(super) supported: bool, + pub(in crate::ui::app) supported: bool, /// Whether a board load is in flight (drives the header's "loading…"). - pub(super) loading: bool, + pub(in crate::ui::app) loading: bool, } impl Default for FeedbackState { @@ -866,16 +870,16 @@ impl Default for FeedbackState { } /// A single-line inline input overlay shared with daemon controls. -pub(super) type Prompt = TextPrompt; +pub(in crate::ui::app) type Prompt = TextPrompt; /// Cached credential-presence flags displayed by Routing's Manage Keys pane. #[derive(Default)] -pub(super) struct CredentialStatus { - pub(super) claude_subscription: bool, - pub(super) codex_subscription: bool, - pub(super) anthropic_api_key: bool, - pub(super) openai_api_key: bool, - pub(super) openrouter_api_key: bool, +pub(in crate::ui::app) struct CredentialStatus { + pub(in crate::ui::app) claude_subscription: bool, + pub(in crate::ui::app) codex_subscription: bool, + pub(in crate::ui::app) anthropic_api_key: bool, + pub(in crate::ui::app) openai_api_key: bool, + pub(in crate::ui::app) openrouter_api_key: bool, } /// The interactive TUI screen: all tab state, input focus, and render geometry. @@ -889,24 +893,24 @@ pub struct App { /// The active top-level tab index (into [`TABS`]). pub tab_index: usize, /// Git changes from the selected session or operator-chosen commit. - pub(super) changes: super::super::changes::GitChangesState, - pub(super) draft: Draft, - pub(super) history: Vec, - pub(super) history_index: i64, - pub(super) selected: usize, + pub(in crate::ui::app) changes: super::super::changes::GitChangesState, + pub(in crate::ui::app) draft: Draft, + pub(in crate::ui::app) history: Vec, + pub(in crate::ui::app) history_index: i64, + pub(in crate::ui::app) selected: usize, /// The Overview tab's animated workflow graph. Held on the app because its /// simulation has to survive between frames; it is advanced by the draw /// path, which is the only thing that looks at it. - pub(super) graph: super::super::render::graph::Graph, - pub(super) status: String, + pub(in crate::ui::app) graph: super::super::render::graph::Graph, + pub(in crate::ui::app) status: String, /// A persistent "update vX.Y.Z available" banner, set by the background /// update checker; shown in the header until the app exits. - pub(super) update_notice: Option, - pub(super) contexts: Vec, - pub(super) context_index: usize, - pub(super) agent_index: usize, + pub(in crate::ui::app) update_notice: Option, + pub(in crate::ui::app) contexts: Vec, + pub(in crate::ui::app) context_index: usize, + pub(in crate::ui::app) agent_index: usize, /// Which selectable rail row remains selected while the live rail is rebuilt. - pub(super) agent_anchor: Option, + pub(in crate::ui::app) agent_anchor: Option, /// Extra pages of sublanes revealed under an agent lane, keyed by lane key. /// /// Keyed by [`AgentLane::key`](crate::ui::agents::AgentLane::key) rather than @@ -915,75 +919,75 @@ pub struct App { /// expansion tied to a position would silently jump to a different agent. /// Absent means the lane shows its first page, which is the default every /// lane starts at. - pub(super) subtask_pages: std::collections::HashMap, + pub(in crate::ui::app) subtask_pages: std::collections::HashMap, /// The `(worker address, task id)` whose screen is currently subscribed. /// /// Held so a selection change can stop the old stream as well as start the /// new one: a subscription nobody is looking at costs the worker a sample, /// a ratchet advance and a send on every tick. - pub(super) watching: Option<(String, String)>, + pub(in crate::ui::app) watching: Option<(String, String)>, /// The watched `(worker, task)` awaiting destructive-action confirmation. - pub(super) kill_armed: Option<(String, String)>, + pub(in crate::ui::app) kill_armed: Option<(String, String)>, /// Which half of the Agents tab the keyboard is driving. - pub(super) agents_focus: AgentsFocus, - pub(super) agent_scroll: usize, - pub(super) chat_scroll: usize, + pub(in crate::ui::app) agents_focus: AgentsFocus, + pub(in crate::ui::app) agent_scroll: usize, + pub(in crate::ui::app) chat_scroll: usize, /// Selected row in the command peek, while it is open. - pub(super) command_index: usize, + pub(in crate::ui::app) command_index: usize, /// Selected row on the Routing Hosts page. - pub(super) host_index: usize, + pub(in crate::ui::app) host_index: usize, /// Whether ↑↓ on the Hosts page drives the role toggles in the preview /// rather than the host list above it. Tab moves between the two. - pub(super) host_roles_focus: bool, + pub(in crate::ui::app) host_roles_focus: bool, /// Selected role in the preview's toggle list, while it has focus. - pub(super) host_role_index: usize, + pub(in crate::ui::app) host_role_index: usize, /// Selected row on the Routing Workspaces page. - pub(super) workspace_index: usize, + pub(in crate::ui::app) workspace_index: usize, /// Selected row on the Routing Agent Templates page. - pub(super) template_index: usize, + pub(in crate::ui::app) template_index: usize, /// OpenRouter-backed harness presets loaded from the active config. - pub(super) custom_harnesses: Vec, + pub(in crate::ui::app) custom_harnesses: Vec, /// Selected row on the Routing Harness Types page. - pub(super) custom_harness_index: usize, + pub(in crate::ui::app) custom_harness_index: usize, /// Selected row on the Routing Hooks page. - pub(super) hook_index: usize, + pub(in crate::ui::app) hook_index: usize, /// Lifecycle reports arriving from the harnesses this Medulla launched. /// /// Written by the control socket's `hook.report` handler and read here; an /// app with no control plane bound simply renders an empty log. - pub(super) hook_log: medulla::harness_hooks::HookEventLog, + pub(in crate::ui::app) hook_log: medulla::harness_hooks::HookEventLog, /// Scroll offset inside the open agent-template popup. - pub(super) template_scroll: usize, + pub(in crate::ui::app) template_scroll: usize, /// Whether the agent-template popup is open over the catalog. - pub(super) template_modal: bool, + pub(in crate::ui::app) template_modal: bool, /// Selected row on the Routing Workflows page. #[cfg(feature = "workflows")] - pub(super) workflow_index: usize, + pub(in crate::ui::app) workflow_index: usize, /// The installed workflows, as last read from disk. /// /// Cached rather than re-read every frame: the store is files, and a render /// pass should not do I/O. `r` re-reads it, as it does for templates. #[cfg(feature = "workflows")] - pub(super) workflows: Vec, + pub(in crate::ui::app) workflows: Vec, /// The selected workflow's runs, read when the selection changes rather /// than on every frame. #[cfg(feature = "workflows")] - pub(super) workflow_runs: Vec, + pub(in crate::ui::app) workflow_runs: Vec, /// Why the run history could not be read, if it could not. #[cfg(feature = "workflows")] - pub(super) workflow_runs_error: Option, + pub(in crate::ui::app) workflow_runs_error: Option, /// What the selected workflow has learned, newest first. /// /// Cached beside the runs and refreshed with them, for the same reason: a /// render pass must not touch the disk. #[cfg(feature = "workflows")] - pub(super) workflow_notes: Vec, + pub(in crate::ui::app) workflow_notes: Vec, /// Changes proposed for the selected workflow, newest first. #[cfg(feature = "workflows")] - pub(super) workflow_proposals: Vec, + pub(in crate::ui::app) workflow_proposals: Vec, /// The Workflows tab's panes, cursors, and copilot threads. #[cfg(feature = "workflows")] - pub(super) wf: WorkflowsState, + pub(in crate::ui::app) wf: WorkflowsState, /// A workflow store attached directly, overriding the layered one this /// client would otherwise resolve. /// @@ -993,33 +997,34 @@ pub struct App { /// wrong under test, where it makes the catalogue depend on the developer's /// checkout. `None` resolves the layered store, as a real session does. #[cfg(feature = "workflows")] - pub(super) workflow_store_override: Option>, + pub(in crate::ui::app) workflow_store_override: + Option>, /// The active Routing subpage (index into [`ROUTING_SUBPAGES`]). - pub(super) routing_index: usize, + pub(in crate::ui::app) routing_index: usize, /// Whether keyboard focus is inside the Routing content pane. - pub(super) routing_focused: bool, + pub(in crate::ui::app) routing_focused: bool, /// Selected row on the Routing strategy page. - pub(super) routing_strategy_index: usize, + pub(in crate::ui::app) routing_strategy_index: usize, /// Selected subscription rule on the Routing strategy page. - pub(super) subscription_strategy_index: usize, + pub(in crate::ui::app) subscription_strategy_index: usize, /// Whether the subscription group, rather than the host group, has focus. - pub(super) subscription_strategy_focused: bool, + pub(in crate::ui::app) subscription_strategy_focused: bool, /// Credential presence captured on startup and refreshed when its pane opens. - pub(super) credential_status: CredentialStatus, + pub(in crate::ui::app) credential_status: CredentialStatus, /// The active TokenMaxxxing sidebar page. - pub(super) tokenmaxxing_index: usize, + pub(in crate::ui::app) tokenmaxxing_index: usize, /// Whether keyboard focus is inside the TokenMaxxxing content pane. - pub(super) tokenmaxxing_focused: bool, + pub(in crate::ui::app) tokenmaxxing_focused: bool, /// Feedback-board state (lazily loaded on entry / refresh). - pub(super) feedback: FeedbackState, + pub(in crate::ui::app) feedback: FeedbackState, /// Feedback-board tab state (lazily loaded on tab entry / refresh). /// Whether the prepared-decision modal is visible. - pub(super) decision_open: bool, + pub(in crate::ui::app) decision_open: bool, /// Highlighted decision row. - pub(super) decision_index: usize, + pub(in crate::ui::app) decision_index: usize, /// Session-local ids intentionally hidden by the operator. - pub(super) dismissed_decisions: std::collections::BTreeSet, - pub(super) prompt: Option, + pub(in crate::ui::app) dismissed_decisions: std::collections::BTreeSet, + pub(in crate::ui::app) prompt: Option, /// The animation frame counter: one per event-loop tick (~90ms). /// /// Drives the spinner and the workflow canvas's flowing wires. Held on the @@ -1031,7 +1036,7 @@ pub struct App { /// Account-level usage payload (`/teams/me/usage` data), when fetched. pub account_usage: Option, /// The active Settings subpage (index into [`SETTINGS_SUBPAGES`]). - pub(super) settings_index: usize, + pub(in crate::ui::app) settings_index: usize, /// Whether keyboard focus is inside the Settings content pane rather than on /// the left-hand subpage nav. /// @@ -1040,40 +1045,40 @@ pub struct App { /// to get around, and `↑↓` moving the nav meant arrow keys jumped you off /// the page entirely. Entering the pane hands `↑↓` to the content and makes /// the letter bindings deliberate rather than ambient. - pub(super) settings_focused: bool, + pub(in crate::ui::app) settings_focused: bool, /// The selected theme role on the Appearance subpage. - pub(super) appearance_index: usize, + pub(in crate::ui::app) appearance_index: usize, /// Throttled sampler backing the optional local-process status indicators. - pub(super) resource_monitor: crate::ui::resources::ResourceMonitor, + pub(in crate::ui::app) resource_monitor: crate::ui::resources::ResourceMonitor, /// Throttled sampler backing the optional whole-device sidebar indicators. - pub(super) device_monitor: crate::ui::resources::DeviceMonitor, + pub(in crate::ui::app) device_monitor: crate::ui::resources::DeviceMonitor, /// The selected field row on the Status line subpage. - pub(super) status_line_index: usize, + pub(in crate::ui::app) status_line_index: usize, /// Whether the next persisted status-line edit must write the complete /// legacy-derived section rather than one field. - pub(super) status_line_promotion_pending: bool, + pub(in crate::ui::app) status_line_promotion_pending: bool, /// The selected editable row on the Config subpage. - pub(super) config_index: usize, + pub(in crate::ui::app) config_index: usize, /// Whether the Account subpage's logout is armed. Logging out clears stored /// credentials, so the first Enter arms and the second confirms; any other /// navigation disarms it. - pub(super) logout_armed: bool, + pub(in crate::ui::app) logout_armed: bool, /// Whether the app is quitting in order to re-authenticate rather than to /// exit. Set by a successful logout so the caller tears the session down and /// returns to the login screen instead of returning to the shell. - pub(super) relogin_requested: bool, + pub(in crate::ui::app) relogin_requested: bool, /// Who the embedded core is signed in as, for the Account subpage. - pub(super) account: Option, + pub(in crate::ui::app) account: Option, /// The Medulla home directory, used to locate the credential store the /// Account subpage clears. Injectable so feature tests never touch the real /// home; `None` disables logout. - pub(super) medulla_home: Option, + pub(in crate::ui::app) medulla_home: Option, /// The resolved color theme; selection highlighting + chrome draw from it. - pub(super) theme: Theme, + pub(in crate::ui::app) theme: Theme, /// Where appearance changes are persisted (the user-global `config.toml`). /// Injectable so feature tests never touch the real home. `None` disables /// persistence (changes still apply live). - pub(super) config_path: Option, + pub(in crate::ui::app) config_path: Option, /// Where hook edits are persisted — deliberately not always [`Self::config_path`]. /// /// `config_path` may resolve to a project-local file @@ -1086,26 +1091,26 @@ pub struct App { /// [`Self::set_config_path`] and overridden by /// [`Self::set_hooks_config_path`] whenever the caller knows the two must /// differ — see `app_loop::run_tui` in the `medulla-tui` crate. - pub(super) hooks_config_path: Option, - pub(super) resume_picker: Option, + pub(in crate::ui::app) hooks_config_path: Option, + pub(in crate::ui::app) resume_picker: Option, /// Whether the event loop should exit after this tick. pub should_quit: bool, // Render geometry, recorded each draw for click hit-testing. - pub(super) area: Rect, - pub(super) hit_tabs: Vec<(u16, u16)>, - pub(super) hit_tabs_row: u16, + pub(in crate::ui::app) area: Rect, + pub(in crate::ui::app) hit_tabs: Vec<(u16, u16)>, + pub(in crate::ui::app) hit_tabs_row: u16, /// Where the Agents rail drew, and the rendered row each visible line /// belongs to. A row may wrap onto several lines, so a click resolves /// through this snapshot rather than by adding an offset to a freshly /// rebuilt row list that may have changed since the frame was drawn. - pub(super) hit_agents: Option<(Rect, Vec)>, + pub(in crate::ui::app) hit_agents: Option<(Rect, Vec)>, // Where the embedded session screen landed, and whose it is. Recorded so a // wheel event can be routed to the terminal under the pointer and given // coordinates relative to *its* origin rather than the screen's. - pub(super) hit_session: Option<(Rect, String)>, + pub(in crate::ui::app) hit_session: Option<(Rect, String)>, /// The threads strip's hit box and its first visible row, for click-to-switch. - pub(super) hit_threads: Option<(Rect, usize)>, + pub(in crate::ui::app) hit_threads: Option<(Rect, usize)>, /// Where the orchestrator's conversation drew, and the task each of its /// visible lines opens (§A7) — `None` for the lines that are transcript /// rather than a session entry. @@ -1118,78 +1123,80 @@ pub struct App { /// Tasks rather than row indices: the rail is rebuilt every frame, so an /// index recorded during the draw can name a different row by the time the /// click lands. A task id either still has a session or does not. - pub(super) hit_started_sessions: Option<(Rect, Vec>)>, - pub(super) hit_context: Option, + pub(in crate::ui::app) hit_started_sessions: Option<(Rect, Vec>)>, + pub(in crate::ui::app) hit_context: Option, /// The selected workflow step's preview, for pointer-wheel scrolling. - pub(super) hit_workflow_preview: Option, + pub(in crate::ui::app) hit_workflow_preview: Option, /// Where the active tab's subpage nav drew its page rows. Only one nav is on /// screen at a time, so one field serves Routing and Settings. - pub(super) hit_nav: crate::ui::multi_pane::NavHits, + pub(in crate::ui::app) hit_nav: crate::ui::multi_pane::NavHits, /// Every pane drawn this frame, in draw order. A pointer selection is /// clamped to whichever of these it started in, so a drag reads one pane's /// text instead of splicing its neighbour's columns into every row. - pub(super) panes: Vec, + pub(in crate::ui::app) panes: Vec, /// A drag in progress: where the button went down. Kept apart from /// [`Self::selection`] so a click that never moves leaves no selection. - pub(super) drag_anchor: Option<(u16, u16)>, + pub(in crate::ui::app) drag_anchor: Option<(u16, u16)>, /// The block of cells the pointer has swept, normalized to /// `(left, top, right, bottom)` inclusive. - pub(super) selection: Option<(u16, u16, u16, u16)>, + pub(in crate::ui::app) selection: Option<(u16, u16, u16, u16)>, /// Set when the button is released over a live selection: the next draw /// copies what the selection covers, since only then is the buffer readable. - pub(super) copy_selection: bool, - pub(super) last_events_len: usize, + pub(in crate::ui::app) copy_selection: bool, + pub(in crate::ui::app) last_events_len: usize, // Test-only clipboard capture: when set, `copy_chat` records the copied text // here and skips the platform writers (no `pbcopy`/OSC subprocess in tests). - pub(super) copy_capture: Option>>>, + pub(in crate::ui::app) copy_capture: Option>>>, // Optional observational overlay from the background host-link service: // this endpoint's own identity, its peer roster, and peer presence. Merged // into the snapshot on every refresh so the Overview panel and Agents lanes // light up without the runtime having to know about the link. - pub(super) link_obs: Option>>, + pub(in crate::ui::app) link_obs: + Option>>, // A read-only view of the task host running on this device, when one is. // Read live at render rather than merged into the snapshot: its counters // move on the host's own schedule, and the snapshot is the *runtime's* // picture of the world — the host is a peer to it, not part of it. - pub(super) host_obs: Option, + pub(in crate::ui::app) host_obs: Option, // The live sessions this device is running. `None` when this machine // does not host, in which case the Agents tab has no local screen to show // and falls back to a remote worker's streamed one, or to the transcript. - pub(super) local_sessions: Option, + pub(in crate::ui::app) local_sessions: Option, // Workflow runs the harnesses on this device started over MCP, keyed by the // grant session the launcher recorded on each PTY row. Read at render so a // run reported a moment ago is on screen at the next frame, and empty on a // build or a host with no control plane bound. - pub(super) harness_runs: medulla::control_socket::HarnessRunRegistry, + pub(in crate::ui::app) harness_runs: medulla::control_socket::HarnessRunRegistry, // Live per-node harness output for runs this TUI started, keyed by run id. // Only ever a handful: a settled run keeps its frames until the next run of // the same workflow replaces it. #[cfg(feature = "workflows")] - pub(super) live_runs: std::collections::HashMap, + pub(in crate::ui::app) live_runs: + std::collections::HashMap, // Which of the TUI and the selected session owns the keyboard. Reset to // `Chrome` whenever the attached session stops being the selected one, so // the operator's keys can never land in a session they are not looking at. - pub(super) harness_focus: crate::ui::harness_pane::HarnessFocus, + pub(in crate::ui::app) harness_focus: crate::ui::harness_pane::HarnessFocus, // The session the Agents pane resolved on the last draw, and the // only one the attach chord can act on. Recorded during render because that // is where the rail cursor is turned into a selection; cleared at the top of // every draw so it can never name a pane that is no longer on screen. - pub(super) pane_session: Option, + pub(in crate::ui::app) pane_session: Option, // What the pane is showing for that session: its terminal, or one of the // views that replace it. Not cleared per frame — it is the operator's // choice, not a record of what the last draw resolved — so it is reset by // the selection moving instead. - pub(super) pane_view: PaneView, + pub(in crate::ui::app) pane_view: PaneView, // The session `pane_view` was chosen for. `pane_session` cannot answer this: // it is cleared at the top of every draw, so comparing against it would // reset the view on every frame. - pub(super) pane_view_session: Option, + pub(in crate::ui::app) pane_view_session: Option, // The session whose close is awaiting confirmation, if one is. Killing a // harness ends whatever it was in the middle of, so `k` asks first — the // same one-keypress contract `kill_armed` has for a dispatched task. - pub(super) harness_close_armed: Option, + pub(in crate::ui::app) harness_close_armed: Option, // The session that took the press of the button currently held down, if any. // A terminal grabs the pointer for the whole gesture: whoever received the // press receives the drags and the release too, wherever the pointer has @@ -1197,19 +1204,19 @@ pub struct App { // swallowed by a modal the click itself opened — never reaches the child, // which goes on believing the button is still down and misplaces everything // it draws in response to the pointer afterwards. - pub(super) pointer_grab: Option, + pub(in crate::ui::app) pointer_grab: Option, // Where the hand-back question drew each of its answers, and the key each // one stands for. Recorded during the draw so a click can be answered by // replaying the keystroke rather than by a second copy of the routing: the // two would drift, and the direction they would drift in is a pointer that // hands a harness back when the operator meant to keep it. - pub(super) hit_handback: Vec<(Rect, crossterm::event::KeyCode)>, + pub(in crate::ui::app) hit_handback: Vec<(Rect, crossterm::event::KeyCode)>, // The "start a session" picker's outer box, and where each offered row was // drawn with the index it stands for in that step's list. Recorded during // the draw for the same reason the hand-back answers are: the harness step // windows a long list, so screen position and list index are not the same // number, and only the draw knows which window it used. - pub(super) hit_agent_picker: Option<(Rect, Vec<(Rect, usize)>)>, + pub(in crate::ui::app) hit_agent_picker: Option<(Rect, Vec<(Rect, usize)>)>, // The agent behind a selected session row that this device does NOT run, // recorded alongside `pane_session` on the same draw. // @@ -1219,20 +1226,20 @@ pub struct App { // path, so a remote session can be watched but not taken (§E7), and an // operator who presses the take chord on one deserves that answer rather // than "no session on this row". - pub(super) pane_remote_session: Option, + pub(in crate::ui::app) pane_remote_session: Option, // The session selected on the Agents rail, retained while another tab is // visible. Unlike `pane_session`, this is navigation state rather than a // keyboard-routing capability: Changes uses it to keep following // the repository the operator selected after an intervening tab draw. - pub(super) rail_session: Option, + pub(in crate::ui::app) rail_session: Option, /// The "start a session" picker, while it is open. - pub(super) agent_picker: Option, + pub(in crate::ui::app) agent_picker: Option, /// The "you still hold this session" confirmation, while it is open. - pub(super) handback_prompt: Option, + pub(in crate::ui::app) handback_prompt: Option, /// How far the Help page is scrolled, in lines. - pub(super) help_scroll: u16, + pub(in crate::ui::app) help_scroll: u16, /// What releasing a held session does, from `[harness].handback`. - pub(super) handback_policy: HandbackPolicy, + pub(in crate::ui::app) handback_policy: HandbackPolicy, /// The sessions taken *from the orchestrator*, and how each was taken. /// /// Membership is the whole question the release prompt exists to ask. A @@ -1252,7 +1259,7 @@ pub struct App { /// single flag answered for whichever was touched last: it worded B's /// question with A's takeover, and kept asking about sessions nobody had /// taken from anyone. - pub(super) sessions_taken: std::collections::HashMap, + pub(in crate::ui::app) sessions_taken: std::collections::HashMap, /// Sessions the operator has at some point given to the orchestrator. /// /// Separate from [`sessions_taken`](Self::sessions_taken), which says who @@ -1268,7 +1275,7 @@ pub struct App { /// [`take_session`](App::take_session), leaving a session with origin /// `User`, no entry in `sessions_taken`, and dispatch locked out of it. /// Releasing that in silence is the bug this set closes. - pub(super) orchestrator_claimed: std::collections::HashSet, + pub(in crate::ui::app) orchestrator_claimed: std::collections::HashSet, /// Commands raised by synchronous input handlers, drained by the event loop. /// /// The key and mouse handlers that move session control cannot return a @@ -1278,8 +1285,8 @@ pub struct App { /// change to say one thing. So they push here instead, and the loop drains /// it right after the event that produced it. Commands run in submission /// order. - pub(super) pending_cmds: std::collections::VecDeque, + pub(in crate::ui::app) pending_cmds: std::collections::VecDeque, /// Whether operator-started sessions launch with the permission-bypass /// flag, from `[harness].skipPermissions`. - pub(super) harness_skip_permissions: bool, + pub(in crate::ui::app) harness_skip_permissions: bool, } From 30499350a026221124a93b2d198eb33e115d2611 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 18:27:41 +0300 Subject: [PATCH 11/14] fix(tui): preserve rail target snapshots --- src/tui/src/ui/app/input/mouse.rs | 2 +- src/tui/src/ui/app/input/nav.rs | 25 ++++++++++++++++++------- src/tui/src/ui/app/rail/types.rs | 7 ++++++- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index b0d027d7d..5837cb6f3 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -595,7 +595,7 @@ impl App { // used to raise "you still have this harness" // over the pane the operator was mid-sentence // in, whose only useful answer was Esc. - if self.harness_focus.is_attached_to(session) { + if self.harness_focus.is_attached_to(&session) { self.pane_session = Some(session.to_string()); return None; } diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 2f56ffbd4..d8636c18b 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -22,6 +22,7 @@ pub(in crate::ui::app) const SUBTASK_PAGE: usize = 10; impl App { /// The current Agents-list rows, each lane paged to whatever the operator /// has expanded it to. + #[cfg(test)] pub(in crate::ui::app) fn agent_rows(&self) -> Vec { let lanes = self.lanes(); self.agent_rows_in(&lanes) @@ -137,7 +138,7 @@ impl App { /// that pages its lane open. pub(in crate::ui::app) fn move_agent_index(&mut self, up: bool) { let lanes = self.lanes(); - let rows = self.rail_rows(); + let rows = self.rail_rows_in(&lanes); if rows.is_empty() { return; } @@ -253,17 +254,26 @@ impl App { /// The `(worker address, task id)` the current selection asks to watch. pub(in crate::ui::app) fn watch_target(&self) -> Option<(String, String)> { + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + self.watch_target_in(&rows, &lanes) + } + + /// Resolve a watch target from one coherent rail and lane snapshot. + fn watch_target_in( + &self, + rows: &[RailRow], + lanes: &[crate::ui::agents::AgentLane], + ) -> Option<(String, String)> { // Only on the Agents tab: leaving it releases the subscription. if self.tab() != "Agents" { return None; } - let rows = self.rail_rows(); - let row = rows.get(self.rail_cursor_in(&rows, &self.lanes()))?; + let row = rows.get(self.rail_cursor_in(rows, lanes))?; let RailRow::Session(session) = row else { return None; }; let task = session.task.as_ref()?; - let lanes = self.lanes(); let lane = lanes.get(session.lane_index?)?; // `Agent` is main's name for a roster agent / delegated task / peer // session — the tiers above it (orchestrator, reasoning, compress) run @@ -287,11 +297,12 @@ impl App { /// The selected running task eligible for destructive termination. pub(in crate::ui::app) fn kill_target(&self) -> Option<(String, String)> { - let rows = self.rail_rows(); - let row = rows.get(self.rail_cursor_in(&rows, &self.lanes()))?; + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + let row = rows.get(self.rail_cursor_in(&rows, &lanes))?; let task = row.task()?; (task.status == TaskStatus::Running) - .then(|| self.watch_target()) + .then(|| self.watch_target_in(&rows, &lanes)) .flatten() } } diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index 00738e1b5..802bf5ed3 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -32,7 +32,12 @@ pub enum RailAnchor { /// A local session, keyed by PTY id. Session(String), /// A dispatched task without a local PTY row, keyed by its lane and task. - Task { lane: String, task_id: String }, + Task { + /// Stable key of the lane that owns this task. + lane: String, + /// Backend task id within [`Self::Task::lane`]. + task_id: String, + }, /// An action that opens another session for an agent. NewSession(String), /// A workflow run, keyed by its run id. From 53baa73b2458e3e35d9e09d9acfd7205f681b2bb Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 18:32:11 +0300 Subject: [PATCH 12/14] fix(tui): restore rail test visibility --- src/tui/src/ui/app/input/nav.rs | 11 ++++++----- src/tui/src/ui/app/keys/agents.rs | 4 ++-- src/tui/src/ui/app/rail/tests.rs | 6 +++++- src/tui/src/ui/app/types/rail_hit.rs | 2 +- 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index d8636c18b..4cc49a302 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -50,17 +50,17 @@ impl App { /// slides down as sublanes appear above it, and the viewport follows the /// cursor, so the rows just revealed are the ones on screen. pub(in crate::ui::app) fn page_subtasks(&mut self) -> bool { - let rows = self.rail_rows(); + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); // The overflow row is a fold row, not an agent row: under the // `Host → Agent → Session` taxonomy an agent's own row is the declared // identity, and everything the fold still owns — the orchestrator lane, // the `── functions ──` divider, this counter — arrives as `Lane`. - let cursor = self.rail_cursor_in(&rows, &self.lanes()); + let cursor = self.rail_cursor_in(&rows, &lanes); let Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(cursor) else { return false; }; let (lane_index, hidden) = (*lane_index, *hidden); - let lanes = self.lanes(); let Some(lane) = lanes.get(lane_index) else { return false; }; @@ -106,7 +106,8 @@ impl App { /// no longer has an overflow row cannot strand the cursor past the end of /// the rail. fn follow_overflow_row(&mut self, lane_index: usize) { - let rows = self.rail_rows(); + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); let found = rows .iter() .position(|row| { @@ -118,7 +119,7 @@ impl App { }); self.set_rail_cursor_in( &rows, - &self.lanes(), + &lanes, found.unwrap_or_else(|| self.agent_index.min(rows.len().saturating_sub(1))), ); } diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index 505576b1b..da50dd01a 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -22,7 +22,7 @@ use super::super::types::{AgentsFocus, App, Cmd, PaneView}; use crate::ui::composer::insert_at; /// What rail-focus handling did with one key press. -pub(super) enum AgentsKey { +pub(in crate::ui::app) enum AgentsKey { /// The key belonged to the rail; any follow-up command is carried along. Handled(Option), /// The rail does not claim this key — global and composer handling apply. @@ -115,7 +115,7 @@ impl App { /// Returns [`AgentsKey::Unhandled`] for anything the rail has no opinion on /// — tab switching, transcript paging, the `Alt` steering chords — so those /// keep working identically from either side of the tab. - pub(super) fn on_agents_rail_key(&mut self, k: KeyEvent) -> AgentsKey { + pub(in crate::ui::app) fn on_agents_rail_key(&mut self, k: KeyEvent) -> AgentsKey { if !self.agents_rail_focused() { return AgentsKey::Unhandled; } diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 3f58a924f..5d7959e08 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -10,7 +10,11 @@ use medulla::protocol::HarnessProvider; use medulla::runtime::mock::MockRuntime; use medulla::runtime::{AgentDeclaration, Runtime}; -use super::{RailRow, NEW_AGENT_LABEL}; +use super::{ + rail_anchor, resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow, SessionRailRow, + NEW_AGENT_LABEL, +}; +use crate::ui::agents::AgentRow; use crate::ui::app::App; use crate::worker::pty::PtyManager; diff --git a/src/tui/src/ui/app/types/rail_hit.rs b/src/tui/src/ui/app/types/rail_hit.rs index 3bd02c058..24848b26a 100644 --- a/src/tui/src/ui/app/types/rail_hit.rs +++ b/src/tui/src/ui/app/types/rail_hit.rs @@ -42,7 +42,7 @@ pub(in crate::ui::app) struct RailHit { pub(in crate::ui::app) target: RailHitTarget, /// Test-only copy of the row so focused interaction tests can name it. #[cfg(test)] - pub(super) row: super::super::rail::RailRow, + pub(in crate::ui::app) row: super::super::rail::RailRow, /// The durable cursor identity resolved while the row was rendered. pub(in crate::ui::app) anchor: Option, /// The row's rendered offset, retained only as a fallback if it has no anchor. From cde3c592bda123bb11916e54a8997ae39e5dc046 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 18:35:02 +0300 Subject: [PATCH 13/14] fix(tui): keep rail cursors on lane snapshots --- src/tui/src/ui/app/agent_control.rs | 15 +++++++++------ src/tui/src/ui/app/input/nav.rs | 9 +++++---- src/tui/src/ui/app/keys/agents.rs | 15 +++++++++------ src/tui/src/ui/app/session_focus.rs | 4 ++-- 4 files changed, 25 insertions(+), 18 deletions(-) diff --git a/src/tui/src/ui/app/agent_control.rs b/src/tui/src/ui/app/agent_control.rs index 4b2cd69ae..3277b3d5b 100644 --- a/src/tui/src/ui/app/agent_control.rs +++ b/src/tui/src/ui/app/agent_control.rs @@ -149,8 +149,9 @@ impl App { /// The agent the rail cursor is on, or the one owning the session it is on. pub(in crate::ui::app) fn selected_agent_id(&self) -> Option { - let rows = self.rail_rows(); - rows.get(self.rail_cursor_in(&rows, &self.lanes())) + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + rows.get(self.rail_cursor_in(&rows, &lanes)) .and_then(|row| row.agent_id()) .map(str::to_string) } @@ -273,11 +274,12 @@ impl App { /// Put the rail cursor on `agent_id`'s row, if it has one. pub(in crate::ui::app) fn select_agent_row(&mut self, agent_id: &str) { - let rows = self.rail_rows(); + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); if let Some(index) = rows.iter().position( |row| matches!(row, super::rail::RailRow::Agent(agent) if agent.agent_id == agent_id), ) { - self.set_rail_cursor_in(&rows, &self.lanes(), index); + self.set_rail_cursor_in(&rows, &lanes, index); } } @@ -287,12 +289,13 @@ impl App { /// somewhere below the fold, with the pane still showing whatever was /// selected before, reads as "nothing happened". pub(in crate::ui::app) fn select_session_row(&mut self, session_id: &str) { - let rows = self.rail_rows(); + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); if let Some(index) = rows .iter() .position(|row| row.session_id() == Some(session_id)) { - self.set_rail_cursor_in(&rows, &self.lanes(), index); + self.set_rail_cursor_in(&rows, &lanes, index); } } } diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 4cc49a302..2ed6e53b6 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -85,7 +85,7 @@ impl App { SUBTASK_PAGE.min(total) )); } - self.follow_overflow_row(lane_index); + self.follow_overflow_row(&key); true } @@ -105,17 +105,18 @@ impl App { /// Falls back to the lane's own header, and then to clamping, so a lane that /// no longer has an overflow row cannot strand the cursor past the end of /// the rail. - fn follow_overflow_row(&mut self, lane_index: usize) { + fn follow_overflow_row(&mut self, lane_key: &str) { let lanes = self.lanes(); let rows = self.rail_rows_in(&lanes); + let lane_index = lanes.iter().position(|lane| lane.key == lane_key); let found = rows .iter() .position(|row| { - matches!(row, RailRow::Lane(AgentRow::More { lane_index: l, .. }) if *l == lane_index) + matches!(row, RailRow::Lane(AgentRow::More { lane_index: l, .. }) if Some(*l) == lane_index) }) .or_else(|| { rows.iter() - .position(|row| matches!(row, RailRow::Agent(agent) if agent.lane_index == Some(lane_index))) + .position(|row| matches!(row, RailRow::Agent(agent) if agent.lane_index == lane_index)) }); self.set_rail_cursor_in( &rows, diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index da50dd01a..bb43b688f 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -53,23 +53,26 @@ impl App { /// Whether the rail cursor sits on the `+ New agent` action row. pub(in crate::ui::app) fn on_new_agent_row(&self) -> bool { - let rows = self.rail_rows(); - rows.get(self.rail_cursor_in(&rows, &self.lanes())) + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + rows.get(self.rail_cursor_in(&rows, &lanes)) .is_some_and(|row| row.is_new_agent()) } /// The workflow run the rail cursor sits on, when it sits on one. pub(in crate::ui::app) fn on_workflow_run_row(&self) -> Option<(String, String)> { - let rows = self.rail_rows(); - rows.get(self.rail_cursor_in(&rows, &self.lanes())) + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + rows.get(self.rail_cursor_in(&rows, &lanes)) .and_then(|row| row.workflow_run()) .map(|row| (row.run.workflow_id.clone(), row.run.run_id.clone())) } /// The agent whose `+ new session` action row the cursor sits on, if it does. pub(in crate::ui::app) fn on_new_session_row(&self) -> Option { - let rows = self.rail_rows(); - rows.get(self.rail_cursor_in(&rows, &self.lanes())) + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); + rows.get(self.rail_cursor_in(&rows, &lanes)) .and_then(|row| row.new_session_agent()) .map(str::to_string) } diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs index b83e9c22c..9f3975043 100644 --- a/src/tui/src/ui/app/session_focus.rs +++ b/src/tui/src/ui/app/session_focus.rs @@ -98,7 +98,8 @@ impl App { // Keep the rows that yielded the offset through the cursor write. The // local PTY registry can change while this event is handled; rebuilding // here would let an insertion above the target retarget the cursor. - let rows = self.rail_rows(); + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); let Some((row_index, agent, session_task_id)) = rows.iter().enumerate().find_map(|(index, row)| { let RailRow::Session(session) = row else { @@ -118,7 +119,6 @@ impl App { return false; }; self.tab_index = super::types::tab_pos("Agents"); - let lanes = self.lanes(); self.set_rail_cursor_in(&rows, &lanes, row_index); self.agent_scroll = 0; self.chat_scroll = 0; From 5a295b3f2861321cc6e470745f9c863f123aadd9 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Fri, 7 Aug 2026 18:37:56 +0300 Subject: [PATCH 14/14] fix(tui): route workflow rail clicks --- src/tui/src/ui/app/input/mouse.rs | 8 ++ src/tui/src/ui/app/rail/cursor_tests.rs | 116 ++++++++++++++++++++++++ src/tui/src/ui/app/rail/mod.rs | 2 + src/tui/src/ui/app/rail/tests.rs | 108 +--------------------- src/tui/src/ui/app/types/rail_hit.rs | 12 +++ 5 files changed, 139 insertions(+), 107 deletions(-) create mode 100644 src/tui/src/ui/app/rail/cursor_tests.rs diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 5837cb6f3..0c0cb4473 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -575,6 +575,14 @@ impl App { self.open_new_session(agent_id); return self.retarget_watch(); } + if let super::super::types::RailHitTarget::WorkflowRun { + workflow_id, + run_id, + } = &hit.target + { + self.open_workflow_run(workflow_id, run_id); + return self.retarget_watch(); + } // So is a lane's `+N more`: the click that lands on // it is the request to see what it is counting. // diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs new file mode 100644 index 000000000..1b5b027d7 --- /dev/null +++ b/src/tui/src/ui/app/rail/cursor_tests.rs @@ -0,0 +1,116 @@ +//! Stable cursor identity tests for the Agents rail. + +use super::{rail_anchor, resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow, SessionRailRow}; +use crate::ui::agents::{AgentLane, AgentRole, AgentRow, TaskState, TaskStatus}; + +fn agent(id: &str) -> RailRow { + RailRow::Agent(AgentRailRow { + agent_id: id.to_string(), + host_id: String::new(), + agent: None, + lane_index: None, + }) +} + +fn lane(key: &str) -> AgentLane { + AgentLane { + key: key.to_string(), + label: String::new(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: None, + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + +#[test] +fn an_anchored_agent_follows_rows_inserted_ahead_of_it() { + let anchor = RailAnchor::Agent("builder".to_string()); + assert_eq!( + resolve_rail_cursor( + &[RailRow::NewAgent, agent("scout"), agent("builder")], + &[], + Some(&anchor), + 0 + ), + 2 + ); +} + +#[test] +fn a_missing_anchor_uses_the_clamped_previous_offset() { + let rows = vec![RailRow::NewAgent, agent("builder")]; + assert_eq!( + resolve_rail_cursor( + &rows, + &[], + Some(&RailAnchor::Agent("removed".to_string())), + 99 + ), + 1 + ); +} + +#[test] +fn an_overflow_anchor_uses_its_lanes_stable_key() { + let lanes = vec![lane("builder")]; + let overflow = RailRow::Lane(AgentRow::More { + lane_index: 0, + hidden: 3, + }); + let anchor = rail_anchor(&overflow, &lanes); + assert_eq!(anchor, Some(RailAnchor::Overflow("builder".to_string()))); + assert_eq!( + resolve_rail_cursor( + &[RailRow::NewAgent, agent("new"), overflow], + &lanes, + anchor.as_ref(), + 0 + ), + 2 + ); +} + +#[test] +fn a_task_anchor_survives_local_pty_enrichment() { + let lanes = vec![lane("builder")]; + let task = TaskState { + task_id: "t-1".to_string(), + status: TaskStatus::Running, + turns: 0, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }; + let before = RailRow::Session(Box::new(SessionRailRow { + agent_id: Some("builder".to_string()), + lane_index: Some(0), + task: Some(task), + local: None, + last: true, + })); + let anchor = rail_anchor(&before, &lanes); + let after = RailRow::Session(Box::new(SessionRailRow { + local: Some(super::tests::stub_session("w_1")), + ..match before { + RailRow::Session(session) => *session, + _ => unreachable!(), + } + })); + assert_eq!(rail_anchor(&after, &lanes), anchor); + assert_eq!( + resolve_rail_cursor(&[RailRow::NewAgent, after], &lanes, anchor.as_ref(), 0), + 1 + ); +} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 7db052f18..c954510f7 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -43,6 +43,8 @@ mod cleanup; #[cfg(test)] mod cleanup_tests; mod cursor; +#[cfg(test)] +mod cursor_tests; pub(in crate::ui::app) mod resolve; // Kept apart from `tests` rather than nested inside it: the assembly rules and // the served-dispatch merge are separate responsibilities, and one file for diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 5d7959e08..0c9ebad19 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -10,11 +10,7 @@ use medulla::protocol::HarnessProvider; use medulla::runtime::mock::MockRuntime; use medulla::runtime::{AgentDeclaration, Runtime}; -use super::{ - rail_anchor, resolve_rail_cursor, AgentRailRow, RailAnchor, RailRow, SessionRailRow, - NEW_AGENT_LABEL, -}; -use crate::ui::agents::AgentRow; +use super::{RailRow, NEW_AGENT_LABEL}; use crate::ui::app::App; use crate::worker::pty::PtyManager; @@ -26,108 +22,6 @@ pub(in crate::ui::app) fn app() -> App { App::new(runtime, loaded) } -// Cursor identity is kept here with the rail's other unit coverage: the rail -// directory owns both the rows and the cursor that selects them. -fn cursor_agent(id: &str) -> RailRow { - RailRow::Agent(AgentRailRow { - agent_id: id.to_string(), - host_id: String::new(), - agent: None, - lane_index: None, - }) -} - -fn cursor_lane(key: &str) -> crate::ui::agents::AgentLane { - crate::ui::agents::AgentLane { - key: key.to_string(), - label: String::new(), - role: crate::ui::agents::AgentRole::Agent, - turns: Vec::new(), - last_at: 0, - tasks: Vec::new(), - context_tokens: None, - usage: Default::default(), - harness_label: None, - agent_id: None, - session_id: None, - parent_agent_id: None, - descriptor: None, - active_tasks: 0, - work: None, - } -} - -#[test] -fn an_anchored_agent_follows_rows_inserted_ahead_of_it() { - let anchor = RailAnchor::Agent("builder".to_string()); - let rows = vec![ - RailRow::NewAgent, - cursor_agent("scout"), - cursor_agent("builder"), - ]; - - assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 0), 2); -} - -#[test] -fn a_missing_anchor_uses_the_clamped_previous_offset() { - let rows = vec![RailRow::NewAgent, cursor_agent("builder")]; - let anchor = RailAnchor::Agent("removed".to_string()); - - assert_eq!(resolve_rail_cursor(&rows, &[], Some(&anchor), 99), 1); -} - -#[test] -fn an_overflow_anchor_uses_its_lanes_stable_key() { - let lanes = vec![cursor_lane("builder")]; - let overflow = RailRow::Lane(AgentRow::More { - lane_index: 0, - hidden: 3, - }); - - let anchor = rail_anchor(&overflow, &lanes); - assert_eq!(anchor, Some(RailAnchor::Overflow("builder".to_string()))); - - let rows = vec![RailRow::NewAgent, cursor_agent("new"), overflow]; - assert_eq!(resolve_rail_cursor(&rows, &lanes, anchor.as_ref(), 0), 2); -} - -#[test] -fn a_task_anchor_survives_local_pty_enrichment() { - let lanes = vec![cursor_lane("builder")]; - let task = crate::ui::agents::TaskState { - task_id: "t-1".to_string(), - status: crate::ui::agents::TaskStatus::Running, - turns: 0, - last_at: 0, - turn_blocks: Vec::new(), - attention: None, - question_id: None, - work: None, - }; - let before = RailRow::Session(Box::new(SessionRailRow { - agent_id: Some("builder".to_string()), - lane_index: Some(0), - task: Some(task), - local: None, - last: true, - })); - let anchor = rail_anchor(&before, &lanes); - let after = RailRow::Session(Box::new(SessionRailRow { - local: Some(stub_session("w_1")), - ..match before { - RailRow::Session(session) => *session, - _ => unreachable!("the fixture is a session"), - } - })); - - assert_eq!(rail_anchor(&after, &lanes), anchor); - assert_eq!( - resolve_rail_cursor(&[RailRow::NewAgent, after], &lanes, anchor.as_ref(), 0), - 1 - ); -} - /// The same app with a live (but empty) local host attached, so the rail is /// allowed to offer `+ New agent` and to list local sessions. pub(in crate::ui::app) fn hosting_app() -> App { diff --git a/src/tui/src/ui/app/types/rail_hit.rs b/src/tui/src/ui/app/types/rail_hit.rs index 24848b26a..63f457b0d 100644 --- a/src/tui/src/ui/app/types/rail_hit.rs +++ b/src/tui/src/ui/app/types/rail_hit.rs @@ -19,6 +19,13 @@ pub(in crate::ui::app) enum RailHitTarget { Overflow, /// A row attached to this local harness session. Session(String), + /// A workflow run whose graph view should open. + WorkflowRun { + /// Stable workflow identifier. + workflow_id: String, + /// Stable run identifier within the workflow. + run_id: String, + }, } impl RailHitTarget { @@ -64,6 +71,11 @@ impl RailHit { RailHitTarget::NewSession(agent_id.to_string()) } else if matches!(row, RailRow::Lane(crate::ui::agents::AgentRow::More { .. })) { RailHitTarget::Overflow + } else if let Some(run) = row.workflow_run() { + RailHitTarget::WorkflowRun { + workflow_id: run.run.workflow_id.clone(), + run_id: run.run.run_id.clone(), + } } else if let Some(session) = row.session_id() { RailHitTarget::Session(session.to_string()) } else if row.selectable() {