Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
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 @@ -28,7 +28,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();
match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) {
match rows.get(self.rail_cursor_in(&rows, &self.lanes())) {
Some(super::super::rail::RailRow::Agent(AgentRow::Sub { task, .. })) => {
Some(task.clone())
}
Expand Down
2 changes: 1 addition & 1 deletion src/tui/src/ui/app/harness_control.rs
Original file line number Diff line number Diff line change
Expand Up @@ -122,7 +122,7 @@ impl App {
.iter()
.position(|row| row.session_id() == Some(session_id))
{
self.agent_index = index;
self.set_rail_cursor(index);
}
}

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 @@ -335,7 +335,7 @@ impl App {
let idx = owners[rel];
self.agent_scroll = 0;
self.chat_scroll = 0;
self.agent_index = idx;
self.set_rail_cursor(idx);
// A click is a focus gesture: the arrows should now
// continue from the row that was just picked.
self.focus_agents_rail();
Expand Down
15 changes: 10 additions & 5 deletions src/tui/src/ui/app/input/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,17 +34,22 @@ impl App {
if rows.is_empty() {
return;
}
let clamped = self.agent_index.min(rows.len() - 1);
let lanes = self.lanes();
// From where the cursor *is* — resolved from its anchor — not from the
// offset it last rendered at. A rail that gained a row since the last
// frame would otherwise step from someone else's position.
let clamped = self.rail_cursor_in(&rows, &lanes);
let step: i64 = if up { -1 } else { 1 };
let mut next = clamped as i64 + step;
while next >= 0 && (next as usize) < rows.len() && !rows[next as usize].selectable() {
next += step;
}
self.agent_index = if next < 0 || next as usize >= rows.len() {
let target = if next < 0 || next as usize >= rows.len() {
clamped
} else {
next as usize
};
self.set_rail_cursor_in(&rows, &lanes, target);
}

/// Open a new thread and focus the conversation.
Expand All @@ -58,7 +63,7 @@ impl App {
self.draft = crate::ui::composer::Draft::new();
self.chat_scroll = 0;
self.agent_scroll = 0;
self.agent_index = 0;
self.reset_rail_cursor();
self.tab_index = super::super::types::tab_pos("Agents");
self.refresh_snapshot();
let name = self
Expand Down Expand Up @@ -147,7 +152,7 @@ impl App {
return None;
}
let rows = self.rail_rows();
let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?;
let row = rows.get(self.rail_cursor_in(&rows, &self.lanes()))?;
let RailRow::Agent(AgentRow::Sub {
task, lane_index, ..
}) = row
Expand Down Expand Up @@ -179,7 +184,7 @@ impl App {
/// The selected running task eligible for destructive termination.
pub(in crate::ui::app) fn kill_target(&self) -> Option<(String, String)> {
let rows = self.rail_rows();
let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?;
let row = rows.get(self.rail_cursor_in(&rows, &self.lanes()))?;
Comment thread
senamakel marked this conversation as resolved.
Outdated
let RailRow::Agent(AgentRow::Sub { task, .. }) = row else {
return None;
};
Expand Down
4 changes: 2 additions & 2 deletions src/tui/src/ui/app/keys/agents.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ impl App {
/// Whether the rail cursor sits on the `+ New harness` action row.
pub(in crate::ui::app) fn on_new_harness_row(&self) -> bool {
let rows = self.rail_rows();
rows.get(self.agent_index.min(rows.len().saturating_sub(1)))
rows.get(self.rail_cursor_in(&rows, &self.lanes()))
.is_some_and(|row| row.is_new_harness())
}

Expand Down Expand Up @@ -135,7 +135,7 @@ impl App {
self.set_status("No conversation to type into yet");
return AgentsKey::Handled(None);
};
self.agent_index = index;
self.set_rail_cursor(index);
self.agent_scroll = 0;
self.chat_scroll = 0;
// Leaving a task row drops its screen stream, exactly as
Expand Down
2 changes: 2 additions & 0 deletions src/tui/src/ui/app/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,8 @@ mod overlays;
#[cfg(test)]
mod overlays_tests;
mod rail;
#[cfg(test)]
mod rail_tests;
Comment thread
senamakel marked this conversation as resolved.
Outdated
mod render;
mod routing_options;
mod settings_edit;
Expand Down
142 changes: 141 additions & 1 deletion src/tui/src/ui/app/rail.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
//! is selected, and answers what the detail pane should show.

use super::types::App;
use crate::ui::agents::{AgentRole, AgentRow};
use crate::ui::agents::{AgentLane, AgentRole, AgentRow};
use crate::worker::pty::SessionRow;

/// The label on the rail's "start a harness" row.
Expand Down Expand Up @@ -67,6 +67,92 @@ impl RailRow {
}
}

/// What the rail cursor is *on*, independent of where that row currently sits.
///
/// The rail is rebuilt from scratch every frame out of live state: the fold
/// gains a lane the moment the orchestrator spawns an agent, sublanes reorder as
/// tasks start and finish, and the operator's own harnesses hang below all of
/// it. A cursor stored as a plain row offset therefore points at a *different
/// row* the instant anything above it appears — and for an operator sitting
/// inside an attached harness pane that is not a cosmetic jump: the selection
/// leaves the session, [`App::release_harness`] takes the keyboard back, the
/// composer and work panel reclaim the columns, and the harness is resized and
/// repainted underneath them. It reads exactly like the TUI resetting itself.
///
/// So the cursor is remembered by identity and the offset is re-derived each
/// time the rows are rebuilt. Only rows the cursor can land on have one; the
/// dividers and the `+N more` counter are labels.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RailAnchor {
Comment thread
senamakel marked this conversation as resolved.
Outdated
/// The `+ New harness` action row.
NewHarness,
/// One of the operator's own harnesses, by PTY session id.
Harness(String),
/// A lane header, by [`AgentLane::key`].
Lane(String),
/// A task sublane, by owning lane key and task id.
///
/// Keyed on the lane as well as the task because sublanes are only unique
/// within their lane, and a task row's meaning is "this task, under this
/// agent".
Task {
/// The owning lane's key.
lane: String,
/// The task's id.
task_id: String,
},
}

/// The identity of `row`, when it is one the cursor can hold.
///
/// `lanes` must be the same lane list `rows` was built from: lane rows carry an
/// index into it, and the key behind that index is what survives the list
/// growing.
pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Option<RailAnchor> {
match row {
RailRow::NewHarness => Some(RailAnchor::NewHarness),
RailRow::Harness(session) => Some(RailAnchor::Harness(session.id.clone())),
RailRow::HarnessSeparator => None,
RailRow::Agent(AgentRow::Lane { lane_index }) => lanes
.get(*lane_index)
.map(|lane| RailAnchor::Lane(lane.key.clone())),
RailRow::Agent(AgentRow::Sub {
lane_index, task, ..
}) => lanes.get(*lane_index).map(|lane| RailAnchor::Task {
lane: lane.key.clone(),
task_id: task.task_id.clone(),
}),
// `Separator` and `More` are labels; the cursor steps over them.
RailRow::Agent(_) => None,
}
}

/// Where the anchored row sits in `rows` now, or `fallback` when it is gone.
///
/// A row can genuinely disappear — a harness exits and is forgotten, a task
/// scrolls past the sublane cap — and there is no better answer then than the
/// offset the cursor last held, clamped into range. The caller re-anchors from
/// whatever that lands on, so the fallback is used for one frame at most.
pub(in crate::ui::app) fn resolve_rail_cursor(
rows: &[RailRow],
lanes: &[AgentLane],
anchor: Option<&RailAnchor>,
fallback: usize,
) -> usize {
if rows.is_empty() {
return 0;
}
if let Some(anchor) = anchor {
if let Some(index) = rows
.iter()
.position(|row| rail_anchor(row, lanes).as_ref() == Some(anchor))
{
return index;
}
}
fallback.min(rows.len() - 1)
}

impl App {
/// The rail's rows: the agent lanes.
///
Expand Down Expand Up @@ -112,6 +198,60 @@ impl App {
rows
}

/// The rail offset the cursor is on, re-derived from its anchor.
///
/// Every read of the cursor goes through this rather than through
/// `agent_index` directly, so a rail that grew a row while the operator was
/// looking elsewhere still answers with the row they picked.
pub(in crate::ui::app) fn rail_cursor(&self) -> usize {
self.rail_cursor_in(&self.rail_rows(), &self.lanes())
}

/// [`rail_cursor`](Self::rail_cursor) against rows and lanes the caller
/// already has. Both are derived from the event fold, and rebuilding them
/// per read costs a full re-fold.
pub(in crate::ui::app) fn rail_cursor_in(
&self,
rows: &[RailRow],
lanes: &[AgentLane],
) -> usize {
resolve_rail_cursor(rows, lanes, self.agent_anchor.as_ref(), self.agent_index)
}

/// Put the cursor on `index`, remembering *which row* that is.
///
/// Every write of the cursor goes through this. Setting `agent_index` alone
/// leaves the previous anchor in place, and the next frame would drag the
/// cursor straight back to the old row.
pub(in crate::ui::app) fn set_rail_cursor(&mut self, index: usize) {
let rows = self.rail_rows();
let lanes = self.lanes();
self.set_rail_cursor_in(&rows, &lanes, index);
}

/// [`set_rail_cursor`](Self::set_rail_cursor) against rows and lanes the
/// caller already has.
pub(in crate::ui::app) fn set_rail_cursor_in(
&mut self,
rows: &[RailRow],
lanes: &[AgentLane],
index: usize,
) {
self.agent_index = index.min(rows.len().saturating_sub(1));
self.agent_anchor = rows
.get(self.agent_index)
.and_then(|row| rail_anchor(row, lanes));
}

/// Send the cursor back to the top and forget what it was on.
///
/// For the deliberate resets — opening a new thread — where following the
/// old row would be the wrong behaviour, not the right one.
pub(in crate::ui::app) fn reset_rail_cursor(&mut self) {
self.agent_index = 0;
self.agent_anchor = None;
}

/// How many local harnesses are waiting on the operator right now.
///
/// Counts every live session on this device, not only the rows on the rail:
Expand Down
Loading