diff --git a/src/tui/src/ui/app/agent_control.rs b/src/tui/src/ui/app/agent_control.rs index 1d4af993..3277b3d5 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.agent_index.min(rows.len().saturating_sub(1))) + 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,10 +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) { - if let Some(index) = self.rail_rows().iter().position( + 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.agent_index = index; + self.set_rail_cursor_in(&rows, &lanes, index); } } @@ -286,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) { - if let Some(index) = 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.agent_index = index; + self.set_rail_cursor_in(&rows, &lanes, index); } } } diff --git a/src/tui/src/ui/app/commands/dispatch.rs b/src/tui/src/ui/app/commands/dispatch.rs index b77b99ee..00c003ba 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.agent_index.min(rows.len().saturating_sub(1))) + 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/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 5d833aa1..0c0cb447 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -236,11 +236,7 @@ impl App { if !rect.contains((x, y).into()) { return None; } - let index = *owners.get((y - rect.y) as usize)?; - self.rail_rows() - .get(index)? - .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. @@ -538,13 +534,21 @@ 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 rows = self.rail_rows(); - if let Some(row) = owners.get(rel).and_then(|idx| rows.get(*idx)) { - if row.selectable() { - let idx = owners[rel]; + 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.agent_index = 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(); @@ -559,14 +563,24 @@ 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(); + } + 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 @@ -578,16 +592,18 @@ 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" // 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 b224b78f..2ed6e53b 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -22,8 +22,18 @@ 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 { - 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) }) } @@ -40,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 Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(self.agent_index) - else { + 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; }; @@ -75,7 +85,7 @@ impl App { SUBTASK_PAGE.min(total) )); } - self.follow_overflow_row(lane_index); + self.follow_overflow_row(&key); true } @@ -95,19 +105,24 @@ 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) { - let rows = self.rail_rows(); + 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.agent_index = - found.unwrap_or_else(|| self.agent_index.min(rows.len().saturating_sub(1))); + self.set_rail_cursor_in( + &rows, + &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 @@ -124,21 +139,26 @@ 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 rows = self.rail_rows(); + let lanes = self.lanes(); + let rows = self.rail_rows_in(&lanes); 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() { 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, &lanes, next); } /// Open a new thread and focus the conversation. @@ -152,7 +172,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 @@ -236,17 +256,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.agent_index.min(rows.len().saturating_sub(1)))?; + 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 @@ -270,11 +299,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.agent_index.min(rows.len().saturating_sub(1)))?; + 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/input/tests.rs b/src/tui/src/ui/app/input/tests.rs index 4227d67f..e626093f 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/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index a29ab4b9..91251cf9 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.agent_index.min(rows.len().saturating_sub(1))) + 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.agent_index.min(rows.len().saturating_sub(1))) + 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.agent_index.min(rows.len().saturating_sub(1))) + 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) } @@ -224,7 +227,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/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs new file mode 100644 index 00000000..35cf1b83 --- /dev/null +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -0,0 +1,119 @@ +//! 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, RailHit}; + +/// 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())), + // 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 + .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(), + }) + }) + }) + .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())), + 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 { + /// 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) + } + + /// 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 lanes = self.lanes(); + let rows = self.rail_rows_in(&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)); + } + + /// 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; + self.agent_anchor = None; + } +} 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 00000000..1b5b027d --- /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 a77a2019..c954510f 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -42,6 +42,9 @@ use crate::worker::pty::SessionRow; 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 @@ -52,7 +55,12 @@ mod merge_tests; pub(in crate::ui::app) mod tests; mod types; -pub use types::{AgentRailRow, HostRailRow, RailRow, SessionRailRow, WorkflowRunRailRow}; +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, +}; /// The label on the rail's "declare an agent" row. /// @@ -137,7 +145,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) @@ -147,7 +164,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/types.rs b/src/tui/src/ui/app/rail/types.rs index 91bebae4..802bf5ed 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -18,6 +18,36 @@ use medulla::ui::hosts::HostAgentRow; use crate::ui::agents::{AgentRow, TaskState}; use crate::worker::pty::{SessionOrigin, SessionRow}; +/// A stable identity for a selectable Agents-rail row. +/// +/// The rail is rebuilt from live state on every frame. Storing an offset would +/// select a different row whenever a row is inserted above it, so the app +/// remembers one of these identities and resolves its current offset instead. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RailAnchor { + /// The action that declares an agent. + NewAgent, + /// A declared or discovered agent, keyed by roster id. + Agent(String), + /// A local session, keyed by PTY id. + Session(String), + /// A dispatched task without a local PTY row, keyed by its lane and task. + 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. + 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. /// /// Emitted **only when there is a second host to tell apart** (progressive diff --git a/src/tui/src/ui/app/render/agents/mod.rs b/src/tui/src/ui/app/render/agents/mod.rs index d826f458..7cb71fbb 100644 --- a/src/tui/src/ui/app/render/agents/mod.rs +++ b/src/tui/src/ui/app/render/agents/mod.rs @@ -84,9 +84,9 @@ 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 active = self.agent_index.min(rows.len().saturating_sub(1)); - self.agent_index = active; + 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 // what that row *is* rather than whoever happens to hold lane 0 — which // is the orchestrator, so the old `unwrap_or(0)` put its thinking under 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 39b5faa1..df4b18ef 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::from_row( + row, + 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_control_tests.rs b/src/tui/src/ui/app/session_control_tests.rs index c2bb75b3..5b528401 100644 --- a/src/tui/src/ui/app/session_control_tests.rs +++ b/src/tui/src/ui/app/session_control_tests.rs @@ -115,6 +115,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`: @@ -124,7 +129,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); @@ -152,12 +157,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(), @@ -166,7 +171,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"); diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs index 3143cfc8..9f397504 100644 --- a/src/tui/src/ui/app/session_focus.rs +++ b/src/tui/src/ui/app/session_focus.rs @@ -95,18 +95,31 @@ 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) + // 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 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 { + return None; + }; + let task = session.task.as_ref()?; + (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}")); 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.agent_index = session.row_index; + 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 @@ -115,7 +128,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 } @@ -131,7 +144,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(); diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index 7de61ce6..5544cf6d 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -67,6 +67,7 @@ impl App { contexts: Vec::new(), context_index: 0, agent_index: 0, + agent_anchor: None, subtask_pages: std::collections::HashMap::new(), watching: None, kill_armed: None, @@ -269,7 +270,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. @@ -643,8 +644,8 @@ 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(); - match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { + 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 // the lane it pages — matching it here would have read that index diff --git a/src/tui/src/ui/app/tests/mod.rs b/src/tui/src/ui/app/tests/mod.rs index d9188589..bedc5302 100644 --- a/src/tui/src/ui/app/tests/mod.rs +++ b/src/tui/src/ui/app/tests/mod.rs @@ -438,7 +438,7 @@ fn select_first_task(app: &mut App) -> Option { // its lane: one row type for everything an agent is running. matches!(r, super::rail::RailRow::Session(session) if session.task.is_some()) })?; - app.agent_index = idx; + app.set_rail_cursor(idx); app.retarget_watch() } @@ -579,7 +579,7 @@ fn selecting_a_lane_rather_than_a_task_watches_nothing() { .iter() .position(|r| matches!(r, super::rail::RailRow::Agent(_))) .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/mod.rs b/src/tui/src/ui/app/types/mod.rs new file mode 100644 index 00000000..94fc1be2 --- /dev/null +++ b/src/tui/src/ui/app/types/mod.rs @@ -0,0 +1,13 @@ +//! The data model for the interactive TUI screen. +//! +//! 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 use model::*; +pub(in crate::ui::app) use rail_hit::{RailHit, RailHitTarget}; diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types/model.rs similarity index 81% rename from src/tui/src/ui/app/types.rs rename to src/tui/src/ui/app/types/model.rs index 49346638..2b441fc2 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types/model.rs @@ -3,11 +3,11 @@ //! small overlay/state types ([`ResumePicker`], [`Prompt`], [`PromptKind`], //! and the central [`App`] struct itself. //! -//! Behaviour lives in the sibling modules ([`super::state`], [`super::input`], -//! [`super::keys`], [`super::commands`], and [`super::render`]), each of which +//! 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. +//! `pub(in crate::ui::app)` so every sibling submodule can reach them. use std::sync::Arc; @@ -19,6 +19,8 @@ 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 — @@ -91,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::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). /// @@ -141,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) } @@ -248,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, @@ -256,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. /// @@ -264,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 @@ -285,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. @@ -509,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. @@ -523,9 +529,9 @@ pub(super) struct ResumePicker { /// 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`]. +/// 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. @@ -548,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. @@ -556,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 @@ -581,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. @@ -593,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. @@ -602,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. @@ -620,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 @@ -632,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. @@ -642,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. /// @@ -674,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. @@ -684,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. @@ -717,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. @@ -825,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 { @@ -864,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. @@ -887,22 +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::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::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(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 @@ -911,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. /// @@ -989,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 @@ -1027,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. /// @@ -1036,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 @@ -1082,25 +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, - /// Where the Agents rail drew, and which rail row each of its visible lines + 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 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(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. @@ -1113,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 @@ -1192,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. // @@ -1214,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 @@ -1247,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 @@ -1263,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 @@ -1273,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, } 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 00000000..63f457b0 --- /dev/null +++ b/src/tui/src/ui/app/types/rail_hit.rs @@ -0,0 +1,111 @@ +//! 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(in crate::ui::app) 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), + /// 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 { + /// The local harness session this target names, if it names one. + pub(in crate::ui::app) 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(in crate::ui::app) struct RailHit { + /// The compact action selected by this drawn line. + pub(in crate::ui::app) target: RailHitTarget, + /// Test-only copy of the row so focused interaction tests can name it. + #[cfg(test)] + 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. + pub(in crate::ui::app) index: usize, +} + +impl RailHit { + /// Capture just the data pointer routing needs from a rendered rail row. + pub(in crate::ui::app) 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(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() { + 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(in crate::ui::app) 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(in crate::ui::app) fn selectable(&self) -> bool { + !matches!(&self.target, RailHitTarget::Inert) + } +}