Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
18 changes: 11 additions & 7 deletions src/tui/src/ui/app/agent_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.agent_id())
.map(str::to_string)
}
Expand Down Expand Up @@ -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);
}
}

Expand All @@ -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);
}
}
}
Expand Down
5 changes: 3 additions & 2 deletions src/tui/src/ui/app/commands/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<TaskState> {
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()
}
Expand Down
48 changes: 32 additions & 16 deletions src/tui/src/ui/app/input/mouse.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
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 All @@ -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
Expand All @@ -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;
}
Expand Down
74 changes: 52 additions & 22 deletions src/tui/src/ui/app/input/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<AgentRow> {
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<AgentRow> {
agent_row_model_paged(lanes, SUBTASK_PAGE, |lane| {
self.subtask_pages.get(&lane.key).copied().unwrap_or(0)
})
}
Expand All @@ -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;
};
Expand All @@ -75,7 +85,7 @@ impl App {
SUBTASK_PAGE.min(total)
));
}
self.follow_overflow_row(lane_index);
self.follow_overflow_row(&key);
true
}

Expand All @@ -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
Expand All @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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()
}
}
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
17 changes: 10 additions & 7 deletions src/tui/src/ui/app/keys/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.new_session_agent())
.map(str::to_string)
}
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading