Skip to content
Merged
Show file tree
Hide file tree
Changes from 9 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
13 changes: 7 additions & 6 deletions src/tui/src/ui/app/agent_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ impl App {
/// The agent the rail cursor is on, or the one owning the session it is on.
pub(in crate::ui::app) fn selected_agent_id(&self) -> Option<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.agent_id())
.map(str::to_string)
}
Expand Down 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
13 changes: 6 additions & 7 deletions src/tui/src/ui/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,9 +233,9 @@ impl App {
if !rect.contains((x, y).into()) {
return None;
}
let index = *owners.get((y - rect.y) as usize)?;
self.rail_rows()
.get(index)?
owners
.get((y - rect.y) as usize)?
.row
.session_id()
.map(str::to_string)
}
Expand Down Expand Up @@ -535,13 +535,12 @@ impl App {
// covers the unselectable rows too — the `── functions ──`
// separator — because `agent_index` indexes all of them.
let rel = (y - rect.y) as usize;
let rows = self.rail_rows();
if let Some(row) = owners.get(rel).and_then(|idx| rows.get(*idx)) {
if let Some(hit) = owners.get(rel) {
let row = &hit.row;
if row.selectable() {
let idx = owners[rel];
self.agent_scroll = 0;
self.chat_scroll = 0;
self.agent_index = idx;
self.set_rendered_rail_cursor(hit);
Comment thread
senamakel marked this conversation as resolved.
// A click is a focus gesture: the arrows should now
// continue from the row that was just picked.
self.focus_agents_rail();
Expand Down
26 changes: 17 additions & 9 deletions src/tui/src/ui/app/input/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,8 @@ impl App {
// `Host → Agent → Session` taxonomy an agent's own row is the declared
// identity, and everything the fold still owns — the orchestrator lane,
// the `── functions ──` divider, this counter — arrives as `Lane`.
let Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(self.agent_index)
else {
let cursor = self.rail_cursor_in(&rows, &self.lanes());
let Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(cursor) else {
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
return false;
};
let (lane_index, hidden) = (*lane_index, *hidden);
Expand Down 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 @@ -124,21 +127,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 lanes = self.lanes();
let rows = self.rail_rows();
Comment thread
senamakel marked this conversation as resolved.
Outdated
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.
Expand All @@ -152,7 +160,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 +249,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 +279,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
14 changes: 2 additions & 12 deletions src/tui/src/ui/app/input/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Cmd> {
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)
}
Expand Down Expand Up @@ -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<Cmd> {
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)
}
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
106 changes: 106 additions & 0 deletions src/tui/src/ui/app/rail/cursor.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
//! 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<RailAnchor> {
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()))
Comment thread
senamakel marked this conversation as resolved.
Outdated
.or_else(|| {
session.task.as_ref().and_then(|task| {
session.lane_index.and_then(|index| {
lanes.get(index).map(|lane| RailAnchor::Task {
lane: lane.key.clone(),
task_id: task.task_id.clone(),
})
})
})
}),
RailRow::NewSession { agent_id } => Some(RailAnchor::NewSession(agent_id.clone())),
RailRow::WorkflowRun(row) => Some(RailAnchor::WorkflowRun(row.run.run_id.clone())),
RailRow::Lane(AgentRow::Lane { lane_index }) => lanes
.get(*lane_index)
.map(|lane| RailAnchor::Lane(lane.key.clone())),
RailRow::Lane(AgentRow::Sub {
lane_index, task, ..
}) => lanes.get(*lane_index).map(|lane| RailAnchor::Task {
lane: lane.key.clone(),
task_id: task.task_id.clone(),
}),
RailRow::Lane(AgentRow::More { lane_index, .. }) => lanes
.get(*lane_index)
.map(|lane| RailAnchor::Overflow(lane.key.clone())),
RailRow::Host(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None,
}
}

/// Resolves an anchored cursor to its present offset, using `fallback` when gone.
pub(in crate::ui::app) fn resolve_rail_cursor(
rows: &[RailRow],
lanes: &[AgentLane],
anchor: Option<&RailAnchor>,
fallback: usize,
) -> usize {
if rows.is_empty() {
return 0;
}
anchor
.and_then(|anchor| {
rows.iter()
.position(|row| rail_anchor(row, lanes).as_ref() == Some(anchor))
})
.unwrap_or_else(|| fallback.min(rows.len() - 1))
}

impl App {
/// 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.
Comment thread
senamakel marked this conversation as resolved.
}

/// 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));
}

/// 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;
}
}
Loading
Loading