Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions src/tui/src/ui/app/agent_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/tui/src/ui/app/commands/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ impl App {
/// whichever row happened to share the offset.
pub(in crate::ui::app) fn selected_agent_task(&self) -> Option<TaskState> {
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()))
Comment thread
senamakel marked this conversation as resolved.
Outdated
.and_then(|row| row.task())
.cloned()
}
Expand Down
2 changes: 1 addition & 1 deletion src/tui/src/ui/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -541,7 +541,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();
Expand Down
16 changes: 10 additions & 6 deletions src/tui/src/ui/app/input/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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);
Comment thread
senamakel marked this conversation as resolved.
Outdated
}

/// Open a new thread and focus the conversation.
Expand All @@ -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
Expand Down Expand Up @@ -241,7 +245,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::Session(session) = row else {
return None;
};
Expand Down Expand Up @@ -271,7 +275,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()))?;
Comment thread
senamakel marked this conversation as resolved.
Outdated
let task = row.task()?;
(task.status == TaskStatus::Running)
.then(|| self.watch_target())
Expand Down
8 changes: 4 additions & 4 deletions src/tui/src/ui/app/keys/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,22 +54,22 @@ 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)))
rows.get(self.rail_cursor_in(&rows, &self.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)))
rows.get(self.rail_cursor_in(&rows, &self.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<String> {
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()))
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
.and_then(|row| row.new_session_agent())
.map(str::to_string)
}
Expand Down Expand Up @@ -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
Expand Down
68 changes: 68 additions & 0 deletions src/tui/src/ui/app/rail/cursor_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
//! 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};

use super::{rail_anchor, 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,
})
}

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);
}
101 changes: 100 additions & 1 deletion src/tui/src/ui/app/rail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,16 @@ 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;
Comment thread
senamakel marked this conversation as resolved.
Outdated
#[cfg(test)]
mod merge_tests;
#[cfg(test)]
pub(in crate::ui::app) mod tests;
mod types;

pub use types::{AgentRailRow, HostRailRow, RailRow, SessionRailRow, WorkflowRunRailRow};
pub use types::{
AgentRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, WorkflowRunRailRow,
};

/// The label on the rail's "declare an agent" row.
///
Expand All @@ -63,6 +67,61 @@ 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<RailAnchor> {
Comment thread
senamakel marked this conversation as resolved.
Outdated
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,
Comment thread
senamakel marked this conversation as resolved.
Outdated
}
}

/// 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.
Expand Down Expand Up @@ -91,6 +150,46 @@ 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)
Comment thread
senamakel marked this conversation as resolved.
Outdated
}

/// 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);
Comment thread
senamakel marked this conversation as resolved.
Outdated
}

/// 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)
Expand Down
25 changes: 25 additions & 0 deletions src/tui/src/ui/app/rail/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,31 @@ 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 { lane: String, task_id: String },
Comment thread
senamakel marked this conversation as resolved.
Outdated
/// 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
Expand Down
4 changes: 2 additions & 2 deletions src/tui/src/ui/app/render/agents/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -85,8 +85,8 @@ 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;
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
Expand Down
Loading
Loading