diff --git a/config.example.toml b/config.example.toml index 7e3a30634..ec2e3a359 100644 --- a/config.example.toml +++ b/config.example.toml @@ -263,10 +263,33 @@ tokenEnv = "MEDULLA_TOKEN" # selectionFg = "white" # text drawn on top of the primary selection background # dimBorder = "darkgray" # panel borders -# Legacy harness-row toggles, kept so an older config keeps working. They are -# read only when [statusLine] below is absent, in which case `false` means that -# field is hidden. Settings › Status line writes [statusLine] and takes over. +# --------------------------------------------------------------------------- +# appearance — how the Agents sidebar arranges itself, plus the legacy +# harness-row toggles. Settings › Appearance edits all of these live. +# +# sidebarGrouping picks what heads the sidebar's sections: +# "host" one section per machine, drawn only once a second host exists +# (the default) +# "path" one per workspace directory, so a checkout's agents read together +# "harness" one per coding-agent CLI +# "none" one flat list, no headers +# +# sidebarSort orders the agents in a section and the sessions under an agent: +# "created" declaration order for agents, oldest-first for sessions (default) +# "recent" most recently active first +# "name" alphabetical +# +# Grouping only moves the headers: every agent keeps its own sessions, so no row +# disappears whichever way these are set. +# +# showHarnessBranch/showHarnessPath are legacy, kept so an older config keeps +# working. They are read only when [statusLine] below is absent, in which case +# `false` means that field is hidden. Settings › Status line writes +# [statusLine] and takes over. +# --------------------------------------------------------------------------- # [appearance] +# sidebarGrouping = "host" +# sidebarSort = "created" # showHarnessBranch = true # showHarnessPath = true diff --git a/gitbooks/developers/configuration.md b/gitbooks/developers/configuration.md index 60240a566..bd1c9f1f7 100644 --- a/gitbooks/developers/configuration.md +++ b/gitbooks/developers/configuration.md @@ -58,7 +58,7 @@ Config is merged from lowest to highest precedence (highest wins): Files are merged field-by-field (a recursive table merge), so a project-local file can override just `backend.baseUrl` without discarding the rest of a global file. [TOML](https://toml.io/) is the primary format; `--config ` still accepts either `.toml` or `.json` (parser chosen by extension) and bypasses file discovery, but env vars and CLI flags still override it. The Config tab shows the merged effective config and lists the source files that contributed. -Every section is optional; with no file anywhere, all defaults apply. Sections: `backend`, `host` (whether this device also runs the work it orchestrates, and the workspace and roots it advertises), `link` (host-link identity, forwarder, and peer roster for the daemon and Overview panel), `hub` (the persisted worker roster and selected default worker, so a fleet survives a restart), `stateDir` (default `/state`; `MEDULLA_STATE_DIR` overrides), `opencode` (worker display, model, agent, workspace, concurrency), `workflow` (the daemon's workspace allowlist, and the workspace roots whose `MEDULLA.md` rides every backend session mint), `fleet` (the declared `Host → Harness → Workspace → Agent` capacity chain and the agent-template catalog), `router` (a custom OpenAI-compatible router the daemon spawns harnesses against; absent leaves every harness unrouted), `budget` (operator-declared per-provider budgets; absent leaves every harness advertising an estimate), `onboarding` (welcome-flow completion state), `update` (`check = true`/`false` for the background release check; `MEDULLA_NO_UPDATE_CHECK` env kill-switch), `theme` (TUI colors — `primary`/`accent`/`selectionFg`/`dimBorder` as [ratatui](https://ratatui.rs/) color names or `#rrggbb`; the Settings › Appearance subpage edits and persists these), `statusLine` (how a harness row on the Agents rail is laid out — each of `state`/`harness`/`control`/`thread`/`branch`/`path` takes a `line1`/`line2`/`line3`/`hidden` placement, a `*When` visibility of `always`/`active`/`alert`, and where it applies a `*Style` spelling; the Settings › Status line subpage edits these with a live preview, showing each field's description and the full set of values a row can take, and the older `appearance.showHarnessBranch`/`showHarnessPath` booleans are read only when this section is absent), and `medulla.contextWindowTokens` (Context tab usage hint; the orchestration limits section also carries pass/step/depth/task/token bounds). Inference and tracing are server-side concerns — the TUI has no config for them; unknown sections are ignored. +Every section is optional; with no file anywhere, all defaults apply. Sections: `backend`, `host` (whether this device also runs the work it orchestrates, and the workspace and roots it advertises), `link` (host-link identity, forwarder, and peer roster for the daemon and Overview panel), `hub` (the persisted worker roster and selected default worker, so a fleet survives a restart), `stateDir` (default `/state`; `MEDULLA_STATE_DIR` overrides), `opencode` (worker display, model, agent, workspace, concurrency), `workflow` (the daemon's workspace allowlist, and the workspace roots whose `MEDULLA.md` rides every backend session mint), `fleet` (the declared `Host → Harness → Workspace → Agent` capacity chain and the agent-template catalog), `router` (a custom OpenAI-compatible router the daemon spawns harnesses against; absent leaves every harness unrouted), `budget` (operator-declared per-provider budgets; absent leaves every harness advertising an estimate), `onboarding` (welcome-flow completion state), `update` (`check = true`/`false` for the background release check; `MEDULLA_NO_UPDATE_CHECK` env kill-switch), `theme` (TUI colors — `primary`/`accent`/`selectionFg`/`dimBorder` as [ratatui](https://ratatui.rs/) color names or `#rrggbb`; the Settings › Appearance subpage edits and persists these), `statusLine` (how a harness row on the Agents rail is laid out — each of `state`/`harness`/`control`/`thread`/`branch`/`path` takes a `line1`/`line2`/`line3`/`hidden` placement, a `*When` visibility of `always`/`active`/`alert`, and where it applies a `*Style` spelling; the Settings › Status line subpage edits these with a live preview, showing each field's description and the full set of values a row can take, and the older `appearance.showHarnessBranch`/`showHarnessPath` booleans are read only when this section is absent), `appearance` (Agents-sidebar layout — `sidebarGrouping` of `host`/`path`/`harness`/`none` picks what the sidebar's section headers are, `sidebarSort` of `created`/`recent`/`name` orders the agents in a section and the sessions under an agent, alongside the resource-indicator keys; the Settings › Appearance subpage edits these live), and `medulla.contextWindowTokens` (Context tab usage hint; the orchestration limits section also carries pass/step/depth/task/token bounds). Inference and tracing are server-side concerns — the TUI has no config for them; unknown sections are ignored. There is no `memory` section: the persona-memory layer is out of this build, and its config schema went with it. diff --git a/src/sdk/src/config/appearance/mod.rs b/src/sdk/src/config/appearance/mod.rs index e4a8f6df8..54925b7da 100644 --- a/src/sdk/src/config/appearance/mod.rs +++ b/src/sdk/src/config/appearance/mod.rs @@ -1,5 +1,6 @@ -//! Configuration for local-process resource indicators in the TUI chrome. +//! Configuration for the TUI's chrome: resource indicators, and how the Agents +//! sidebar groups and orders its rows. mod types; -pub use types::{AppearanceConfig, ResourceDisplay}; +pub use types::{AppearanceConfig, ResourceDisplay, SidebarGrouping, SidebarSort}; diff --git a/src/sdk/src/config/appearance/types.rs b/src/sdk/src/config/appearance/types.rs index c0e8b6c39..a336d0b0a 100644 --- a/src/sdk/src/config/appearance/types.rs +++ b/src/sdk/src/config/appearance/types.rs @@ -1,4 +1,4 @@ -//! Data types for selecting process and whole-device resource display formats. +//! Appearance configuration types for resource displays and Agents-sidebar layout. use serde::{Deserialize, Serialize}; @@ -17,6 +17,75 @@ pub enum ResourceDisplay { Bar, } +/// How the Agents sidebar sections its agent rows. +/// +/// The sidebar is a `Host → Agent → Session` tree; this chooses what the top +/// level is. Only the *sectioning* changes — every agent keeps its own sessions +/// under it — so no row disappears whichever way it is set. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SidebarGrouping { + /// Section by host, and only once a second host exists. The default: with + /// one machine the header would say nothing the operator does not know. + #[default] + Host, + /// Section by the directory an agent works in, so one checkout's agents read + /// together however many hosts or harnesses they span. + Path, + /// Section by the harness an agent runs (`claude`, `codex`, …). + Harness, + /// No section headers at all — one flat list of agents. + None, +} + +impl SidebarGrouping { + /// The label shown in Settings → Appearance. + pub const fn label(self) -> &'static str { + match self { + Self::Host => "host", + Self::Path => "path", + Self::Harness => "harness", + Self::None => "none", + } + } + + /// Every value, in the order the settings row cycles through them. + pub const ALL: [Self; 4] = [Self::Host, Self::Path, Self::Harness, Self::None]; +} + +/// How the Agents sidebar orders the rows inside one section. +/// +/// Applied at both levels the operator reads: the agents in a section, and the +/// sessions under an agent. +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub enum SidebarSort { + /// Declaration order for agents, start time for sessions — oldest first. + /// The default, because it is the only order that does not move under you + /// while you read it. + #[default] + Created, + /// Most recently active first: the session that last produced output, and + /// the agent whose most recent session did. + Recent, + /// Alphabetical by label. + Name, +} + +impl SidebarSort { + /// The label shown in Settings → Appearance. + pub const fn label(self) -> &'static str { + match self { + Self::Created => "created", + Self::Recent => "recent", + Self::Name => "name", + } + } + + /// Every value, in the order the settings row cycles through them. + pub const ALL: [Self; 3] = [Self::Created, Self::Recent, Self::Name]; +} + /// TUI display preferences retained under the `[appearance]` section. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, rename_all = "camelCase")] @@ -39,6 +108,10 @@ pub struct AppearanceConfig { pub device_ram: ResourceDisplay, /// How to show whole-device disk-capacity pressure in the Agents sidebar. pub device_disk: ResourceDisplay, + /// What the Agents sidebar sections its agents by. + pub sidebar_grouping: SidebarGrouping, + /// How the Agents sidebar orders agents and the sessions under them. + pub sidebar_sort: SidebarSort, } impl AppearanceConfig { @@ -54,6 +127,8 @@ impl AppearanceConfig { device_cpu: ResourceDisplay::Off, device_ram: ResourceDisplay::Off, device_disk: ResourceDisplay::Off, + sidebar_grouping: SidebarGrouping::Host, + sidebar_sort: SidebarSort::Created, } } } diff --git a/src/sdk/src/config/mod.rs b/src/sdk/src/config/mod.rs index 134baab2e..d88675d3e 100644 --- a/src/sdk/src/config/mod.rs +++ b/src/sdk/src/config/mod.rs @@ -40,7 +40,7 @@ pub use agent_declarations::{ agent_declaration, agent_declarations_for_host, declare_agent, declared_agent_ids, load_agent_declarations, remove_agent_declaration, undeclare_agent, upsert_agent_declaration, }; -pub use appearance::{AppearanceConfig, ResourceDisplay}; +pub use appearance::{AppearanceConfig, ResourceDisplay, SidebarGrouping, SidebarSort}; pub use core_socket::{validate_core_socket, CoreSocketError, CoreSocketSource}; pub use custom_harnesses::{ load_custom_harnesses, load_layered_custom_harnesses, CustomHarnessConfig, diff --git a/src/sdk/src/config/types_tests.rs b/src/sdk/src/config/types_tests.rs index 1d2e405ed..123037412 100644 --- a/src/sdk/src/config/types_tests.rs +++ b/src/sdk/src/config/types_tests.rs @@ -59,6 +59,30 @@ fn appearance_session_titles_can_be_hidden() { assert!(!cfg.appearance.show_session_titles); } +#[test] +fn sidebar_layout_defaults_to_the_tree_in_declaration_order() { + let cfg: TuiConfig = serde_json::from_str("{}").unwrap(); + + assert_eq!( + cfg.appearance.sidebar_grouping, + super::SidebarGrouping::Host + ); + assert_eq!(cfg.appearance.sidebar_sort, super::SidebarSort::Created); +} + +#[test] +fn sidebar_layout_parses_the_operators_choice() { + let cfg: TuiConfig = + serde_json::from_str(r#"{"appearance":{"sidebarGrouping":"path","sidebarSort":"recent"}}"#) + .unwrap(); + + assert_eq!( + cfg.appearance.sidebar_grouping, + super::SidebarGrouping::Path + ); + assert_eq!(cfg.appearance.sidebar_sort, super::SidebarSort::Recent); +} + #[test] fn appearance_harness_details_parse_independently() { let cfg: TuiConfig = serde_json::from_str( diff --git a/src/tui/src/ui/app/appearance.rs b/src/tui/src/ui/app/appearance.rs index dac83186f..030724496 100644 --- a/src/tui/src/ui/app/appearance.rs +++ b/src/tui/src/ui/app/appearance.rs @@ -1,7 +1,8 @@ -//! Appearance-setting mutations and persistence. +//! Appearance-setting mutations and persistence, including the Agents-sidebar +//! grouping and ordering controls. //! -//! Colour roles, local-process indicators, and whole-device indicators live -//! here. The two +//! Colour roles, local-process indicators, whole-device indicators, and the +//! sidebar's grouping and sort preferences live here. The two //! harness-row toggles this page used to carry — branch and shortened path — //! became placements on the Status line page, which is where the rest of the row //! is configured and the only place the effect can be previewed. Leaving them @@ -16,8 +17,15 @@ use crate::ui::theme::{color_to_string, THEME_ROLES}; use super::types::App; -/// Non-theme rows: session titles, then process and device resource indicators. -pub(super) const APPEARANCE_OPTION_ROWS: usize = 7; +/// Non-theme rows: session titles, the process and device resource indicators, +/// and the two Agents-sidebar layout controls. +pub(super) const APPEARANCE_OPTION_ROWS: usize = 9; + +/// The option offset of the sidebar grouping row, past the resource indicators. +pub(super) const SIDEBAR_GROUPING_OPTION: usize = 7; + +/// The option offset of the sidebar sort row. +pub(super) const SIDEBAR_SORT_OPTION: usize = 8; /// Number of behavior controls shown after the editable theme colors. pub(super) const ATTENTION_ROWS: usize = 1; @@ -26,6 +34,20 @@ pub(super) const ATTENTION_ROWS: usize = 1; pub(super) const APPEARANCE_ROWS: usize = THEME_ROLES.len() + ATTENTION_ROWS + APPEARANCE_OPTION_ROWS; +/// The next value after `current` in `choices`, wrapping in either direction. +/// +/// A value that is not in `choices` — a config hand-edited to something the +/// build does not know — starts the cycle from the first entry rather than +/// refusing to move, so the row is never stuck. +fn cycled(choices: &[T], current: T, forward: bool) -> T { + let at = choices.iter().position(|choice| *choice == current); + match at { + Some(at) if forward => choices[(at + 1) % choices.len()], + Some(at) => choices[(at + choices.len() - 1) % choices.len()], + None => choices[0], + } +} + impl App { /// Cycle the selected colour role and persist the theme. pub(super) fn cycle_appearance_row(&mut self, forward: bool) { @@ -43,11 +65,14 @@ impl App { self.persist_theme_value_now("Attention blink", value.into()); } else { let option = index - THEME_ROLES.len() - ATTENTION_ROWS; - if option == 3 { - self.toggle_session_titles(); - } else { - let resource = if option < 3 { option } else { option - 1 }; - self.cycle_resource_display(resource, forward); + match option { + 3 => self.toggle_session_titles(), + SIDEBAR_GROUPING_OPTION => self.cycle_sidebar_grouping(forward), + SIDEBAR_SORT_OPTION => self.cycle_sidebar_sort(forward), + _ => { + let resource = if option < 3 { option } else { option - 1 }; + self.cycle_resource_display(resource, forward); + } } } } @@ -60,6 +85,26 @@ impl App { self.persist_appearance_now("Session titles", if shown { "on" } else { "off" }.into()); } + /// Cycle and persist what the Agents sidebar sections its agents by. + /// + /// Takes effect on the next frame without any rebuild: the rail is assembled + /// from the loaded config every time it is drawn, so the operator sees the + /// arrangement they picked while the settings row is still under the cursor. + fn cycle_sidebar_grouping(&mut self, forward: bool) { + let current = self.loaded.config.appearance.sidebar_grouping; + let next = cycled(&medulla::config::SidebarGrouping::ALL, current, forward); + self.loaded.config.appearance.sidebar_grouping = next; + self.persist_appearance_now("Sidebar grouping", next.label().into()); + } + + /// Cycle and persist how the Agents sidebar orders agents and sessions. + fn cycle_sidebar_sort(&mut self, forward: bool) { + let current = self.loaded.config.appearance.sidebar_sort; + let next = cycled(&medulla::config::SidebarSort::ALL, current, forward); + self.loaded.config.appearance.sidebar_sort = next; + self.persist_appearance_now("Sidebar sort", next.label().into()); + } + /// Cycle and persist one resource indicator, process-scoped or device-wide. /// /// `index` is the row's offset past the theme roles, in the order the @@ -153,6 +198,16 @@ impl App { "showHarnessPath".into(), toml::Value::Boolean(self.loaded.config.appearance.show_harness_path), ); + section.insert( + "sidebarGrouping".into(), + toml::Value::String( + format!("{:?}", appearance.sidebar_grouping).to_lowercase(), + ), + ); + section.insert( + "sidebarSort".into(), + toml::Value::String(format!("{:?}", appearance.sidebar_sort).to_lowercase()), + ); match medulla::config::persist_section(path, "appearance", section) { Ok(()) => self.set_status(format!("Appearance · {name} → {value} (saved)")), Err(error) => self.set_status(format!("Appearance save failed: {error}")), diff --git a/src/tui/src/ui/app/input/mod.rs b/src/tui/src/ui/app/input/mod.rs index 38e645f32..3971eff3c 100644 --- a/src/tui/src/ui/app/input/mod.rs +++ b/src/tui/src/ui/app/input/mod.rs @@ -17,6 +17,8 @@ mod paste; #[cfg(test)] mod tests; +pub(in crate::ui::app) use nav::SUBTASK_PAGE; + impl App { /// Route a terminal event to the key, mouse, or paste handler, producing any /// command the event loop must run. diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index d9a3045b6..ef1746829 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -8,7 +8,9 @@ use super::super::rail::RailRow; use super::super::types::{App, Cmd}; -use crate::ui::agents::{agent_row_model_paged, AgentRole, AgentRow, TaskStatus}; +#[cfg(test)] +use crate::ui::agents::{agent_row_model_paged, AgentRow}; +use crate::ui::agents::{AgentRole, TaskStatus}; /// How many of a lane's task sublanes one page reveals. /// @@ -24,15 +26,7 @@ impl App { #[cfg(test)] pub(in crate::ui::app) fn agent_rows(&self) -> Vec { 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| { + agent_row_model_paged(&lanes, SUBTASK_PAGE, |lane| { self.subtask_pages.get(&lane.key).copied().unwrap_or(0) }) } @@ -85,7 +79,7 @@ impl App { } /// How many sublanes a lane reveals at its current expansion. - fn revealed_subtasks(&self, key: &str) -> usize { + pub(in crate::ui::app) fn revealed_subtasks(&self, key: &str) -> usize { SUBTASK_PAGE.saturating_mul( self.subtask_pages .get(key) diff --git a/src/tui/src/ui/app/rail/cursor.rs b/src/tui/src/ui/app/rail/cursor.rs index 9c4e02e49..08dadb7ac 100644 --- a/src/tui/src/ui/app/rail/cursor.rs +++ b/src/tui/src/ui/app/rail/cursor.rs @@ -35,7 +35,7 @@ pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Opt RailRow::Overflow { lane_index, .. } => lanes .get(*lane_index) .map(|lane| RailAnchor::Overflow(lane.key.clone())), - RailRow::Host(_) => None, + RailRow::Host(_) | RailRow::Group(_) => None, } } @@ -54,6 +54,18 @@ pub(in crate::ui::app) fn resolve_rail_cursor( rows.iter() .position(|row| rail_anchor(row, lanes).as_ref() == Some(anchor)) }) + // Retaining a task can consume the final hidden row. In that case the + // overflow anchor cannot be retained, but resolving by its old offset + // would select the newly revealed task. Relocate to the lane's first + // remaining row instead, which is stable as sessions appear beneath it. + .or_else(|| match anchor { + Some(RailAnchor::Overflow(lane)) => rows.iter().position(|row| { + row.lane_index() + .and_then(|index| lanes.get(index)) + .is_some_and(|candidate| candidate.key == *lane) + }), + _ => None, + }) .unwrap_or_else(|| fallback.min(rows.len() - 1)) } diff --git a/src/tui/src/ui/app/rail/cursor_tests.rs b/src/tui/src/ui/app/rail/cursor_tests.rs index 09d7ccf65..aac1d065e 100644 --- a/src/tui/src/ui/app/rail/cursor_tests.rs +++ b/src/tui/src/ui/app/rail/cursor_tests.rs @@ -82,6 +82,39 @@ fn an_overflow_anchor_uses_its_lanes_stable_key() { ); } +#[test] +fn a_removed_overflow_anchor_relocates_to_its_lanes_first_session() { + let lanes = vec![lane("builder")]; + let task = |id: &str| { + RailRow::Session(Box::new(SessionRailRow { + agent_id: Some("builder".to_string()), + lane_index: Some(0), + task: Some(TaskState { + task_id: id.to_string(), + status: TaskStatus::Running, + turns: 0, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }), + local: None, + last: false, + })) + }; + + assert_eq!( + resolve_rail_cursor( + &[RailRow::NewSession, task("first"), task("retained")], + &lanes, + Some(&RailAnchor::Overflow("builder".to_string())), + 2, + ), + 1, + ); +} + #[test] fn a_task_anchor_survives_local_pty_enrichment() { let lanes = vec![lane("builder")]; diff --git a/src/tui/src/ui/app/rail/merge_tests.rs b/src/tui/src/ui/app/rail/merge_tests.rs index da66ba1e9..a5f272fb0 100644 --- a/src/tui/src/ui/app/rail/merge_tests.rs +++ b/src/tui/src/ui/app/rail/merge_tests.rs @@ -39,6 +39,10 @@ fn group(sessions: Vec) -> AgentGroup { lane_index: Some(0), }, sessions, + last_at: 0, + lane_label: None, + harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, } diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index f02e09d7b..905753de6 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -1,14 +1,20 @@ -//! The Sessions rail: one cursor over the `Host → Session` tree. +//! The Sessions rail: one cursor over configured `Host → Group → Session` +//! sections. //! //! ```text //! + New session ← the one action, when this device hosts //! ▸ this device ← host row, only when a remote host exists -//! ├ t_41 · running ← a session the orchestrator dispatched -//! ├ debug login ← a session the operator started +//! ├ /workspace ← optional path or harness group heading +//! │ ├ t_41 · running ← a session the orchestrator dispatched +//! │ └ debug login ← a session the operator started //! │ └ wf run · deploy ← a workflow run that session started //! └ +3 more ← the fold's paging control //! ``` //! +//! Appearance preferences organize the sections by host, path, harness, or no +//! heading, and order their agents and sessions by creation time, recent +//! activity, or name. +//! //! The rail lists **what is running**. It used to render the whole //! `Host → Agent → Session` tree, with a row per declared agent, because //! declaring one was a thing an operator did from here. It is not any more — @@ -35,7 +41,7 @@ use medulla::runtime::AgentDeclaration; use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; use super::types::App; -use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; +use crate::ui::agents::{ordered_tasks, AgentLane, AgentRole}; use crate::worker::pty::SessionRow; mod cleanup; @@ -44,6 +50,8 @@ mod cleanup_tests; mod cursor; #[cfg(test)] mod cursor_tests; +mod organize; +mod paging; 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 @@ -57,8 +65,10 @@ mod types; pub(in crate::ui::app) use cursor::rail_anchor; #[cfg(test)] pub(in crate::ui::app) use cursor::resolve_rail_cursor; +use types::{AgentGroup, HostGroup}; pub use types::{ - AgentRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, WorkflowRunRailRow, + AgentRailRow, GroupRailRow, HostRailRow, RailAnchor, RailRow, SessionRailRow, + WorkflowRunRailRow, }; /// The label on the rail's "open a session" row. @@ -67,33 +77,6 @@ pub use types::{ /// as one: it acts on the machine, not on a row beneath it. pub(in crate::ui::app) const NEW_SESSION_LABEL: &str = "+ New session"; -/// One agent and the sessions hanging off it, before the tree is flattened. -struct AgentGroup { - /// The agent row itself. - row: AgentRailRow, - /// Its sessions, dispatched and operator-started alike. - sessions: Vec, - /// Sessions the fold's own page already hid, carried so the counts add up. - hidden: usize, - /// Whether the fold drew an overflow row under this agent's lane. - /// - /// The rail does **not** re-cap what the fold already paged (#171): the fold - /// reveals `SUBTASK_PAGE` sessions per page and decides when the `+N more` - /// row exists, including the fully-revealed case where it is instead the - /// `show less` control and `hidden` is zero. A second cap here would clip - /// below the page the operator just asked for, so this only records that the - /// row is owed. - overflow: bool, -} - -/// One host and the agents placed on it, before the tree is flattened. -struct HostGroup { - /// The host row, drawn only once there is more than one of them. - row: HostRailRow, - /// Its agents, in the order the shared projection lists them. - agents: Vec, -} - impl App { /// The agent declarations this machine's config records. /// @@ -149,8 +132,16 @@ impl App { pub(super) fn rail_rows_in(&self, lanes: &[AgentLane]) -> Vec { let folded = self.split_fold(lanes); let mut hosts = place_agents(&self.host_tree(), folded); - let orphans = self.attach_sessions(&mut hosts); - self.flatten(hosts, orphans) + let mut orphans = self.attach_sessions(&mut hosts); + let appearance = &self.loaded.config.appearance; + let sections = organize::organize( + hosts, + self.agent_declarations(), + appearance.sidebar_grouping, + appearance.sidebar_sort, + ); + organize::sort_sessions(&mut orphans, appearance.sidebar_sort); + self.flatten(sections, orphans, lanes) } /// Fold the lane rows into per-agent groups. @@ -160,42 +151,29 @@ impl App { /// carried through as rows of their own; the rail lists sessions now, and /// none of those is one. fn split_fold(&self, lanes: &[AgentLane]) -> Vec { - let mut groups: Vec = Vec::new(); - for row in self.agent_rows_in(lanes) { - match row { - AgentRow::Lane { lane_index } => { - let Some(lane) = lanes.get(lane_index) else { - continue; - }; - if lane.role != AgentRole::Agent { - continue; - } - groups.push(self.group_for_lane(lane, lane_index)); - } - AgentRow::Sub { - lane_index, task, .. - } => { - let Some(group) = groups.last_mut() else { - continue; - }; - group.sessions.push(SessionRailRow { + lanes + .iter() + .enumerate() + .filter(|(_, lane)| lane.role == AgentRole::Agent) + .map(|(lane_index, lane)| { + let mut group = self.group_for_lane(lane, lane_index); + group.sessions = ordered_tasks(&lane.tasks) + .into_iter() + .map(|task| SessionRailRow { agent_id: Some(group.row.agent_id.clone()), lane_index: Some(lane_index), task: Some(task), local: None, last: false, - }); - } - AgentRow::More { hidden, .. } => { - if let Some(group) = groups.last_mut() { - group.hidden += hidden; - group.overflow = true; - } - } - AgentRow::Separator => continue, - } - } - groups + }) + .collect(); + group.visible_tasks = self.revealed_subtasks(&lane.key).min(group.sessions.len()); + group.hidden = group.sessions.len().saturating_sub(group.visible_tasks); + group.overflow = + group.hidden > 0 || group.visible_tasks > crate::ui::app::input::SUBTASK_PAGE; + group + }) + .collect() } /// The group an agent-role lane opens. @@ -223,6 +201,10 @@ impl App { lane_index: Some(lane_index), }, sessions: Vec::new(), + last_at: lane.last_at, + lane_label: Some(lane.label.clone()), + harness_label: lane.harness_label.clone(), + visible_tasks: 0, hidden: 0, overflow: false, } @@ -302,7 +284,12 @@ impl App { /// The agent groups survive the flattening without being rendered: they are /// what decides a session's order and its lane, and the sessions of one /// agent still come out contiguous. What they no longer get is a row. - fn flatten(&self, hosts: Vec, orphans: Vec) -> Vec { + fn flatten( + &self, + sections: Vec, + orphans: Vec, + lanes: &[AgentLane], + ) -> Vec { let mut rows: Vec = Vec::new(); // A device that hosts nothing has nowhere to start a session, so the // action is absent there rather than present and refusing. @@ -312,13 +299,25 @@ impl App { // Progressive disclosure: one host is the common case, and a permanent // `mac-studio ▸` wrapper would add a level of nesting to the surface an // operator uses most. - let show_hosts = hosts.len() > 1; - for mut host in hosts { - if show_hosts { - rows.push(RailRow::Host(host.row)); + for mut section in sections.into_iter().filter(|section| { + section + .agents + .iter() + .any(|group| !group.sessions.is_empty() || group.overflow) + }) { + match section.header { + organize::SectionHeader::Host(host) => rows.push(RailRow::Host(host)), + organize::SectionHeader::Group(group) => rows.push(RailRow::Group(group)), + organize::SectionHeader::None => {} } - for group in &mut host.agents { - push_sessions(&mut rows, group, &self.harness_runs); + for group in &mut section.agents { + paging::push_group( + &mut rows, + group, + &self.harness_runs, + lanes, + self.agent_anchor.as_ref(), + ); } } for mut session in orphans { @@ -492,6 +491,10 @@ fn placed_agent( lane_index: None, }, sessions: Vec::new(), + last_at: 0, + lane_label: None, + harness_label: None, + visible_tasks: 0, hidden: 0, overflow: false, }); @@ -574,33 +577,3 @@ fn run_rows_under( }) .collect() } - -/// Push one agent's sessions, tree-marked. The agent itself gets no row. -/// -/// Paging is the fold's, not the rail's (#171): `agent_rows` reveals a page of -/// task sublanes at a time and marks the rest with an overflow row, so a second -/// cap here would clip the page the operator just asked to see. The overflow row -/// is re-emitted under the sessions and stays selectable, which is what makes -/// `Enter` on it page the lane open — and, once the lane is fully revealed, fold -/// it back. -fn push_sessions( - rows: &mut Vec, - group: &mut AgentGroup, - runs: &medulla::control_socket::HarnessRunRegistry, -) { - let shown = group.sessions.len(); - for (index, session) in group.sessions.iter_mut().enumerate() { - // Only the tree's last leaf when the overflow row does not follow it. - session.last = !group.overflow && index + 1 == shown; - let session = Box::new(session.clone()); - let run_rows = run_rows_under(&session, runs); - rows.push(RailRow::Session(session)); - rows.extend(run_rows); - } - if group.overflow { - rows.push(RailRow::Overflow { - lane_index: group.row.lane_index.unwrap_or(0), - hidden: group.hidden, - }); - } -} diff --git a/src/tui/src/ui/app/rail/organize/mod.rs b/src/tui/src/ui/app/rail/organize/mod.rs new file mode 100644 index 000000000..134b79ef0 --- /dev/null +++ b/src/tui/src/ui/app/rail/organize/mod.rs @@ -0,0 +1,294 @@ +//! How the rail's tree is sectioned and ordered before it is flattened. +//! +//! The assembly in [`super`] decides *what exists*: which agents, on which +//! hosts, with which sessions under them. This module decides how that is +//! presented — the two questions an operator with more than a handful of agents +//! actually asks, and the two the Appearance page now exposes: +//! +//! * **Grouping** picks the top level. By host (the default, and drawn only once +//! a second host exists), by the directory an agent works in, by the harness it +//! runs, or not at all. Only the *headers* change: every agent keeps its own +//! sessions, so nothing that had a row loses one whichever way it is set. +//! * **Sorting** orders the rows inside a section, at both levels the eye reads: +//! the agents in a section, and the sessions under an agent. +//! +//! Kept apart from the assembly because they are genuinely separate concerns — +//! the placement rules answer to the roster and the PTY manager, these answer to +//! one config value each — and because the assembly file was already at this +//! repository's size ceiling. + +use medulla::config::{SidebarGrouping, SidebarSort}; +use medulla::runtime::AgentDeclaration; + +use super::{AgentGroup, GroupRailRow, HostGroup, SessionRailRow}; + +#[cfg(test)] +mod tests; +mod types; + +pub(super) use types::{Section, SectionHeader}; + +/// Section and order the placed tree according to the operator's preferences. +/// +/// Host grouping keeps the tree as placed; every other grouping flattens the +/// hosts away first, because an agent's directory or harness is a fact about the +/// agent rather than about the machine, and keeping both levels would mean +/// reading the same checkout twice under two hosts. +pub(super) fn organize( + hosts: Vec, + declarations: &[AgentDeclaration], + grouping: SidebarGrouping, + sort: SidebarSort, +) -> Vec
{ + let mut sections = match grouping { + SidebarGrouping::Host => by_host(hosts), + SidebarGrouping::Path => by_key( + flatten_agents(hosts, declarations, sort), + agent_path, + str::eq, + ), + SidebarGrouping::Harness => by_key( + flatten_agents(hosts, declarations, sort), + agent_harness, + str::eq_ignore_ascii_case, + ), + SidebarGrouping::None => vec![Section { + header: SectionHeader::None, + agents: flatten_agents(hosts, declarations, sort), + }], + }; + for section in &mut sections { + sort_agents(&mut section.agents, sort); + for agent in &mut section.agents { + sort_sessions(&mut agent.sessions, sort); + } + } + order_sections(&mut sections, sort); + sections +} + +/// Keep the placed tree, dropping the header when there is only one host. +/// +/// Progressive disclosure, unchanged from before grouping was configurable: with +/// just the local machine a permanent `mac-studio ▸` wrapper would add a level of +/// nesting to the surface an operator uses most. +fn by_host(hosts: Vec) -> Vec
{ + let show = hosts.len() > 1; + hosts + .into_iter() + .map(|host| Section { + header: if show { + SectionHeader::Host(host.row) + } else { + SectionHeader::None + }, + agents: host.agents, + }) + .collect() +} + +/// Section every agent by one derived key, preserving first-seen order. +fn by_key( + agents: Vec, + key: fn(&AgentGroup) -> String, + keys_match: fn(&str, &str) -> bool, +) -> Vec
{ + let mut sections: Vec
= Vec::new(); + for agent in agents { + let label = key(&agent); + match sections.iter_mut().find(|section| match §ion.header { + SectionHeader::Group(group) => keys_match(&group.label, &label), + _ => false, + }) { + Some(section) => section.agents.push(agent), + None => sections.push(Section { + header: SectionHeader::Group(GroupRailRow { + label: label.clone(), + }), + agents: vec![agent], + }), + } + } + sections +} + +/// Flatten host sections, restoring declaration order before any non-host +/// grouping can interleave agents from separate hosts. +fn flatten_agents( + hosts: Vec, + declarations: &[AgentDeclaration], + sort: SidebarSort, +) -> Vec { + let mut agents: Vec<_> = hosts.into_iter().flat_map(|host| host.agents).collect(); + if sort == SidebarSort::Created { + agents.sort_by_key(|agent| { + declarations + .iter() + .position(|declaration| declaration.agent_id.trim() == agent.row.agent_id.trim()) + .unwrap_or(usize::MAX) + }); + } + agents +} + +/// The directory an agent works in, as its section label. +/// +/// An agent the projection knows nothing about — a lane the backend rosters, +/// a peer session — has no directory, and is collected under one heading rather +/// than given a section of its own per agent. +fn agent_path(agent: &AgentGroup) -> String { + agent + .row + .workspace() + .map(normalize_path_label) + .filter(|workspace| !workspace.is_empty()) + .unwrap_or_else(|| "no path".to_string()) +} + +/// Normalize a workspace directory for grouping without changing the root. +/// +/// Declarations and spawned working directories commonly differ only by a +/// trailing separator. Grouping uses the same comparison rule as session +/// ownership resolution so that representation detail does not create a second +/// section header. +fn normalize_path_label(path: &str) -> String { + let trimmed = path.trim(); + let stripped = trimmed.trim_end_matches('/'); + if stripped.is_empty() { + trimmed.to_string() + } else { + stripped.to_string() + } +} + +/// The harness an agent runs, as its section label. +fn agent_harness(agent: &AgentGroup) -> String { + agent + .row + .harness() + .or(agent.harness_label.as_deref()) + .map(str::trim) + .filter(|harness| !harness.is_empty()) + .map_or_else(|| "no harness".to_string(), str::to_string) +} + +/// Order the sections themselves. +/// +/// Alphabetical for the orders that are about identity, most-recently-active +/// first for the one that is about activity. Host sections are left in the +/// projection's order whatever the sort: that order is the tree's, shared with +/// the Hosts tab, and the two lenses are meant to list hosts the same way. +fn order_sections(sections: &mut [Section], sort: SidebarSort) { + if sections + .iter() + .any(|section| !matches!(section.header, SectionHeader::Group(_))) + { + return; + } + match sort { + SidebarSort::Recent => sections.sort_by_key(|section| { + std::cmp::Reverse( + section + .agents + .iter() + .map(agent_activity) + .max() + .unwrap_or(i64::MIN), + ) + }), + SidebarSort::Created | SidebarSort::Name => { + sections.sort_by_key(|section| match §ion.header { + SectionHeader::Group(group) => group.label.to_lowercase(), + _ => String::new(), + }) + } + } +} + +/// Order the agents inside one section. +/// +/// `Created` leaves the projection's order alone — declaration order, which is +/// what the operator wrote — because an agent has no creation timestamp to sort +/// by and re-ordering it would only shuffle the list without answering anything. +fn sort_agents(agents: &mut [AgentGroup], sort: SidebarSort) { + match sort { + SidebarSort::Created => {} + SidebarSort::Recent => { + agents.sort_by_key(|agent| std::cmp::Reverse(agent_activity(agent))); + } + SidebarSort::Name => agents.sort_by_key(|agent| agent.label().to_lowercase()), + } +} + +/// Order the sessions under one agent. +/// +/// A dispatched session with no live pty carries no start time, so `Created` +/// sorts it to the front and the stable sort leaves those in the order the fold +/// produced them — which is the order the rail listed them in before grouping +/// existed, so the default arrangement is unchanged. +pub(super) fn sort_sessions(sessions: &mut [SessionRailRow], sort: SidebarSort) { + match sort { + SidebarSort::Created => sessions.sort_by_key(session_started), + SidebarSort::Recent => { + sessions.sort_by_key(|session| std::cmp::Reverse(session_activity(session))); + } + SidebarSort::Name => sessions.sort_by_key(|session| session_label(session).to_lowercase()), + } +} + +/// When a local-only session started, or [`i64::MIN`] for dispatches. +/// +/// A task may be enriched with a local PTY after `ordered_tasks` established +/// its running-first fold order. Treating that row as locally started would +/// reorder it behind task-only rows, so task-backed rows deliberately retain +/// their stable fold position. +fn session_started(session: &SessionRailRow) -> i64 { + if session.task.is_some() { + i64::MIN + } else { + session + .local + .as_ref() + .map_or(i64::MIN, |local| local.started_at) + } +} + +/// The most recent thing known about a session: its last output byte, or the +/// last event its task folded. +fn session_activity(session: &SessionRailRow) -> i64 { + let local = session.local.as_ref().map(|local| local.last_output_at); + let task = session.task.as_ref().map(|task| task.last_at); + local.into_iter().chain(task).max().unwrap_or(i64::MIN) +} + +/// What a session sorts as by name: what the operator called it, else its task, +/// then its terminal title, else the pty's own label. +fn session_label(session: &SessionRailRow) -> String { + if let Some(name) = session.name() { + return name.to_string(); + } + if let Some(task) = &session.task { + return task.task_id.clone(); + } + session + .local + .as_ref() + .map(|local| { + local + .thread_name + .as_deref() + .map(str::trim) + .filter(|name| !name.is_empty()) + .unwrap_or(&local.label) + .to_string() + }) + .unwrap_or_default() +} + +/// The most recent activity under an agent, for the activity orders. +fn agent_activity(agent: &AgentGroup) -> i64 { + std::iter::once(agent.last_at) + .chain(agent.sessions.iter().map(session_activity)) + .max() + .unwrap_or(i64::MIN) +} diff --git a/src/tui/src/ui/app/rail/organize/tests.rs b/src/tui/src/ui/app/rail/organize/tests.rs new file mode 100644 index 000000000..c26cc483e --- /dev/null +++ b/src/tui/src/ui/app/rail/organize/tests.rs @@ -0,0 +1,531 @@ +//! How the sidebar's grouping and sorting preferences change what the rail +//! lists — and, as much to the point, what they must not change: every agent +//! keeps a row and every session stays under its own agent whichever way the +//! two settings are turned. + +use medulla::config::{SidebarGrouping, SidebarSort}; +use medulla::runtime::AgentDeclaration; +use medulla::ui::hosts::HostAgentRow; + +use super::super::tests::{app, stub_session}; +use super::super::{ + AgentGroup, AgentRailRow, GroupRailRow, HostGroup, HostRailRow, SessionRailRow, +}; +use super::{flatten_agents, order_sections, sort_agents, sort_sessions, Section, SectionHeader}; +use crate::ui::app::App; + +/// Three agents across two checkouts and two harnesses, in declaration order. +fn app_with_agents() -> App { + let mut app = app(); + app.loaded.config.fleet.agent_declarations = vec![ + AgentDeclaration::new("zed", "", "claude", "/work/beta"), + AgentDeclaration::new("acorn", "", "codex", "/work/alpha"), + AgentDeclaration::new("mint", "", "claude", "/work/alpha"), + ]; + app +} + +/// The configured sections for the declared agents, without unrelated mock lanes. +fn sections(app: &App) -> Vec<(Option, Vec)> { + let agents = app + .loaded + .config + .fleet + .agent_declarations + .iter() + .map(|declaration| AgentGroup { + row: AgentRailRow { + agent_id: declaration.agent_id.clone(), + host_id: String::new(), + // `organize` receives the already placed tree in production, + // whose rows carry their declaration-derived workspace and + // harness. Keep this fixture faithful so path and harness + // grouping exercise the values the implementation reads. + agent: Some(HostAgentRow { + agent_id: declaration.agent_id.clone(), + label: declaration.agent_id.clone(), + harness: Some(declaration.harness.clone()), + workspace: Some(declaration.workspace.path.clone()), + roles: Vec::new(), + max_sessions: None, + declared: true, + editable: true, + live: false, + selected: false, + }), + lane_index: None, + }, + sessions: Vec::new(), + last_at: 0, + lane_label: None, + harness_label: None, + visible_tasks: 0, + hidden: 0, + overflow: false, + }) + .collect(); + super::organize( + vec![HostGroup { + row: HostRailRow { + host_id: String::new(), + label: "local".into(), + local: true, + }, + agents, + }], + &app.loaded.config.fleet.agent_declarations, + app.loaded.config.appearance.sidebar_grouping, + app.loaded.config.appearance.sidebar_sort, + ) + .into_iter() + .map(|section| { + let header = match section.header { + SectionHeader::Host(host) => Some(host.label), + SectionHeader::Group(group) => Some(group.label), + SectionHeader::None => None, + }; + let agents = section + .agents + .into_iter() + .map(|agent| agent.row.agent_id) + .collect(); + (header, agents) + }) + .collect() +} + +#[test] +fn the_default_leaves_one_machines_tree_unsectioned() { + // The setting exists to be changed, not to change what an operator who has + // never opened it sees: with one host and the default grouping the rail is + // the flat list it was before grouping was configurable, in declaration + // order. + let app = app_with_agents(); + assert_eq!( + app.loaded.config.appearance.sidebar_grouping, + SidebarGrouping::Host + ); + assert_eq!( + sections(&app), + vec![(None, vec!["zed".into(), "acorn".into(), "mint".into()])] + ); +} + +#[test] +fn grouping_by_path_heads_each_checkout_once() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Path; + + assert_eq!( + sections(&app), + vec![ + ( + Some("/work/alpha".into()), + vec!["acorn".into(), "mint".into()] + ), + (Some("/work/beta".into()), vec!["zed".into()]), + ], + "one header per directory, alphabetical, with its agents under it" + ); +} + +#[test] +fn grouping_by_path_ignores_a_trailing_separator() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Path; + app.loaded.config.fleet.agent_declarations[2].workspace.path = "/work/alpha/".into(); + + assert_eq!( + sections(&app), + vec![ + ( + Some("/work/alpha".into()), + vec!["acorn".into(), "mint".into()] + ), + (Some("/work/beta".into()), vec!["zed".into()]), + ] + ); +} + +#[test] +fn grouping_by_harness_sections_by_the_cli_each_agent_runs() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Harness; + + let sections = sections(&app); + let claude = sections + .iter() + .find(|(label, _)| label.as_deref() == Some("claude")) + .expect("the claude agents are sectioned together"); + assert_eq!(claude.1, vec!["zed".to_string(), "mint".to_string()]); + assert!(sections + .iter() + .any(|(label, agents)| label.as_deref() == Some("codex") && agents == &["acorn"])); +} + +#[test] +fn grouping_by_harness_ignores_ascii_case() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::Harness; + app.loaded.config.fleet.agent_declarations[2].harness = "Claude".into(); + + assert_eq!( + sections(&app), + vec![ + (Some("claude".into()), vec!["zed".into(), "mint".into()]), + (Some("codex".into()), vec!["acorn".into()]), + ], + "the first configured spelling remains the label for one shared harness section" + ); +} + +#[test] +fn grouping_by_none_lists_every_agent_without_a_header() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_grouping = SidebarGrouping::None; + + assert_eq!( + sections(&app), + vec![(None, vec!["zed".into(), "acorn".into(), "mint".into()])] + ); +} + +#[test] +fn no_grouping_loses_an_agent() { + // The one property that has to hold across all four: sectioning is a + // presentation of the same tree, so the set of agents on the rail is the + // same set however it is grouped. + let mut app = app_with_agents(); + let mut seen: Vec> = Vec::new(); + for grouping in SidebarGrouping::ALL { + app.loaded.config.appearance.sidebar_grouping = grouping; + let mut agents: Vec = sections(&app) + .into_iter() + .flat_map(|(_, agents)| agents) + .collect(); + agents.sort(); + seen.push(agents); + } + assert!( + seen.windows(2).all(|pair| pair[0] == pair[1]), + "every grouping lists the same agents: {seen:?}" + ); +} + +#[test] +fn sorting_by_name_is_alphabetical_within_a_section() { + let mut app = app_with_agents(); + app.loaded.config.appearance.sidebar_sort = SidebarSort::Name; + + assert_eq!( + sections(&app), + vec![(None, vec!["acorn".into(), "mint".into(), "zed".into()])] + ); +} + +/// A session row for a live pty with the given start and last-output times. +fn session(id: &str, started_at: i64, last_output_at: i64) -> SessionRailRow { + let mut local = stub_session(id); + local.started_at = started_at; + local.last_output_at = last_output_at; + local.name = Some(id.to_string()); + SessionRailRow { + agent_id: Some("agent".into()), + lane_index: None, + task: None, + local: Some(local), + last: false, + } +} + +/// The ids of `sessions` after sorting, for the session-order assertions. +fn sorted(mut sessions: Vec, sort: SidebarSort) -> Vec { + sort_sessions(&mut sessions, sort); + sessions + .into_iter() + .filter_map(|session| session.session_id().map(str::to_string)) + .collect() +} + +#[test] +fn created_sorts_sessions_oldest_first() { + let rows = vec![ + session("middle", 200, 500), + session("oldest", 100, 900), + session("newest", 300, 100), + ]; + assert_eq!( + sorted(rows, SidebarSort::Created), + vec!["oldest", "middle", "newest"], + "the default is the order a session list grows in" + ); +} + +#[test] +fn created_keeps_task_rows_in_fold_order_after_pty_enrichment() { + let mut served = active_agent("served", 0) + .sessions + .pop() + .expect("one task session"); + let mut local = stub_session("pty"); + local.started_at = 100; + served.local = Some(local); + let task_only = active_agent("task-only", 0) + .sessions + .pop() + .expect("one task session"); + + let mut sessions = vec![served, task_only]; + sort_sessions(&mut sessions, SidebarSort::Created); + + assert_eq!( + sessions + .iter() + .filter_map(|session| session.task.as_ref().map(|task| task.task_id.as_str())) + .collect::>(), + vec!["task-served", "task-task-only"], + "a live PTY must not move a task-backed row out of the fold's order" + ); +} + +#[test] +fn recent_sorts_by_the_last_thing_a_session_did() { + let rows = vec![ + session("quiet", 300, 100), + session("loud", 100, 900), + session("middling", 200, 500), + ]; + assert_eq!( + sorted(rows, SidebarSort::Recent), + vec!["loud", "middling", "quiet"], + "most recent output first, whatever order they started in" + ); +} + +#[test] +fn name_sorts_sessions_by_what_the_operator_called_them() { + let rows = vec![ + session("zulu", 100, 100), + session("alpha", 200, 200), + session("mike", 300, 300), + ]; + assert_eq!( + sorted(rows, SidebarSort::Name), + vec!["alpha", "mike", "zulu"] + ); +} + +#[test] +fn name_sorts_local_sessions_by_their_visible_thread_titles() { + let mut zulu = session("zulu", 100, 100); + let mut alpha = session("alpha", 200, 200); + for session in [&mut zulu, &mut alpha] { + let local = session.local.as_mut().expect("live pty session"); + local.name = None; + } + zulu.local.as_mut().expect("live pty session").thread_name = Some("Zulu thread".into()); + alpha.local.as_mut().expect("live pty session").thread_name = Some("Alpha thread".into()); + + assert_eq!( + sorted(vec![zulu, alpha], SidebarSort::Name), + vec!["alpha", "zulu"], + "local sessions without launch names sort by the titles visible in the rail" + ); +} + +/// An agent group with one task whose activity timestamp controls recent order. +fn active_agent(label: &str, last_at: i64) -> AgentGroup { + AgentGroup { + row: AgentRailRow { + agent_id: label.into(), + host_id: String::new(), + agent: None, + lane_index: None, + }, + sessions: vec![SessionRailRow { + agent_id: Some(label.into()), + lane_index: None, + task: Some(medulla::ui::agents::TaskState { + task_id: format!("task-{label}"), + status: medulla::ui::agents::TaskStatus::Running, + turns: 0, + last_at, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }), + local: None, + last: false, + }], + last_at, + lane_label: None, + harness_label: None, + visible_tasks: 0, + hidden: 0, + overflow: false, + } +} + +/// An agent group whose activity is reported only at the lane level. +fn peer_agent(label: &str, last_at: i64) -> AgentGroup { + AgentGroup { + row: AgentRailRow { + agent_id: label.into(), + host_id: String::new(), + agent: None, + lane_index: None, + }, + sessions: Vec::new(), + last_at, + lane_label: Some(label.into()), + harness_label: None, + visible_tasks: 0, + hidden: 0, + overflow: false, + } +} + +#[test] +fn recent_sorts_agents_by_their_most_recent_session() { + let mut agents = vec![active_agent("quiet", 100), active_agent("loud", 900)]; + + sort_agents(&mut agents, SidebarSort::Recent); + + assert_eq!( + agents + .iter() + .map(|agent| agent.row.agent_id.as_str()) + .collect::>(), + vec!["loud", "quiet"] + ); +} + +#[test] +fn recent_sorts_agents_by_peer_lane_activity() { + let mut agents = vec![active_agent("task", 500), peer_agent("peer", 900)]; + + sort_agents(&mut agents, SidebarSort::Recent); + + assert_eq!( + agents + .iter() + .map(|agent| agent.row.agent_id.as_str()) + .collect::>(), + vec!["peer", "task"] + ); +} + +#[test] +fn name_sorts_lane_only_agents_by_their_displayed_labels() { + let mut zulu = peer_agent("opaque-a", 0); + zulu.lane_label = Some("Zulu".into()); + let mut alpha = peer_agent("opaque-z", 0); + alpha.lane_label = Some("Alpha".into()); + + let mut agents = vec![zulu, alpha]; + sort_agents(&mut agents, SidebarSort::Name); + + assert_eq!(agents[0].row.agent_id, "opaque-z"); +} + +#[test] +fn harness_grouping_uses_a_lane_only_agents_reported_harness() { + let mut peer = peer_agent("peer", 0); + peer.harness_label = Some("CODEX".into()); + + let sections = super::by_key(vec![peer], super::agent_harness, str::eq_ignore_ascii_case); + + assert!(matches!( + §ions[0].header, + SectionHeader::Group(group) if group.label == "CODEX" + )); +} + +#[test] +fn recent_sorts_sections_by_peer_lane_activity() { + let mut sections = vec![ + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/task".into(), + }), + agents: vec![active_agent("task", 500)], + }, + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/peer".into(), + }), + agents: vec![peer_agent("peer", 900)], + }, + ]; + + order_sections(&mut sections, SidebarSort::Recent); + + assert!(matches!( + §ions[0].header, + SectionHeader::Group(group) if group.label == "/work/peer" + )); +} + +#[test] +fn recent_sorts_path_sections_by_their_most_recent_agent() { + let mut sections = vec![ + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/older".into(), + }), + agents: vec![active_agent("older", 100)], + }, + Section { + header: SectionHeader::Group(GroupRailRow { + label: "/work/newer".into(), + }), + agents: vec![active_agent("newer", 900)], + }, + ]; + + order_sections(&mut sections, SidebarSort::Recent); + + assert!(matches!( + §ions[0].header, + SectionHeader::Group(group) if group.label == "/work/newer" + )); +} + +#[test] +fn created_keeps_declaration_order_after_flattening_hosts() { + let declarations = vec![ + AgentDeclaration::new("first", "host-one", "codex", "/one"), + AgentDeclaration::new("second", "host-two", "codex", "/two"), + AgentDeclaration::new("third", "host-one", "codex", "/one"), + ]; + let hosts = vec![ + HostGroup { + row: HostRailRow { + host_id: "host-one".into(), + label: "one".into(), + local: true, + }, + agents: vec![active_agent("first", 0), active_agent("third", 0)], + }, + HostGroup { + row: HostRailRow { + host_id: "host-two".into(), + label: "two".into(), + local: false, + }, + agents: vec![active_agent("second", 0)], + }, + ]; + + let agents = flatten_agents(hosts, &declarations, SidebarSort::Created); + + assert_eq!( + agents + .iter() + .map(|agent| agent.row.agent_id.as_str()) + .collect::>(), + vec!["first", "second", "third"], + "grouping must not turn A(host one), B(host two), C(host one) into A, C, B" + ); +} diff --git a/src/tui/src/ui/app/rail/organize/types.rs b/src/tui/src/ui/app/rail/organize/types.rs new file mode 100644 index 000000000..fb01267ec --- /dev/null +++ b/src/tui/src/ui/app/rail/organize/types.rs @@ -0,0 +1,21 @@ +//! Data types that describe the rail's organized section tree. + +use super::super::{AgentGroup, GroupRailRow, HostRailRow}; + +/// What heads one section of the rail. +pub(in crate::ui::app::rail) enum SectionHeader { + /// A host row — emitted only when a second host exists to tell it from. + Host(HostRailRow), + /// A grouping header: a workspace directory, or a harness name. + Group(GroupRailRow), + /// No header at all: the agents sit at the top level. + None, +} + +/// One section of the rail: a header and the agents under it. +pub(in crate::ui::app::rail) struct Section { + /// What heads it, if anything. + pub header: SectionHeader, + /// The agents in it, already ordered. + pub agents: Vec, +} diff --git a/src/tui/src/ui/app/rail/paging/mod.rs b/src/tui/src/ui/app/rail/paging/mod.rs new file mode 100644 index 000000000..4751befaf --- /dev/null +++ b/src/tui/src/ui/app/rail/paging/mod.rs @@ -0,0 +1,75 @@ +//! Paging and rendering of sessions nested below one agent rail row. +//! +//! This keeps selection and retention rules together: a page can retain the +//! cursor's task or one with a live workflow run, and its overflow count must +//! describe the tasks actually omitted after those retention rules apply. + +use medulla::control_socket::HarnessRunRegistry; + +use super::{run_rows_under, AgentGroup, RailAnchor, RailRow}; +use crate::ui::agents::AgentLane; + +/// Push one agent's selected session rows and its optional paging control. +pub(super) fn push_group( + rows: &mut Vec, + group: &mut AgentGroup, + runs: &HarnessRunRegistry, + lanes: &[AgentLane], + anchor: Option<&RailAnchor>, +) { + let task_limit = group.visible_tasks; + let mut visible_tasks = 0; + let mut hidden_tasks = 0; + let pinned_task = group + .row + .lane_index + .and_then(|index| lanes.get(index)) + .and_then(|lane| match anchor { + Some(RailAnchor::Task { + lane: anchored_lane, + task_id, + }) if anchored_lane == &lane.key => Some(task_id.as_str()), + _ => None, + }); + let mut shown_sessions: Vec<_> = group + .sessions + .iter_mut() + .filter(|session| { + if session.task.is_none() { + return true; + } + visible_tasks += 1; + let shown = visible_tasks <= task_limit + || session + .task + .as_ref() + .is_some_and(|task| Some(task.task_id.as_str()) == pinned_task) + || !run_rows_under(session, runs).is_empty(); + if !shown { + hidden_tasks += 1; + } + shown + }) + .collect(); + // A pinned task or one with an active workflow run may extend the fold's + // nominal page. Recount what was actually omitted so the overflow action + // neither overstates the remainder nor survives after showing every task. + let show_overflow = group.overflow && (group.hidden == 0 || hidden_tasks > 0); + let shown = shown_sessions.len(); + for (index, session) in shown_sessions.iter_mut().enumerate() { + session.last = !show_overflow && index + 1 == shown; + let session = Box::new(session.clone()); + let run_rows = run_rows_under(&session, runs); + rows.push(RailRow::Session(session)); + rows.extend(run_rows); + } + if show_overflow { + rows.push(RailRow::Overflow { + lane_index: group.row.lane_index.unwrap_or(0), + hidden: hidden_tasks, + }); + } +} + +#[cfg(test)] +mod tests; diff --git a/src/tui/src/ui/app/rail/paging/tests.rs b/src/tui/src/ui/app/rail/paging/tests.rs new file mode 100644 index 000000000..dd6279a27 --- /dev/null +++ b/src/tui/src/ui/app/rail/paging/tests.rs @@ -0,0 +1,255 @@ +//! Tests for session paging, retention, and overflow rendering. + +use super::super::organize::sort_sessions; +use super::super::tests::app; +use super::super::{AgentGroup, AgentRailRow, RailAnchor, RailRow, SessionRailRow}; +use super::push_group; +use medulla::config::SidebarSort; +use medulla::control_socket::{HarnessRunStatus, RunReport}; +use medulla::ui::agents::{AgentLane, AgentRole, TaskState, TaskStatus}; + +fn task_row(task_id: &str) -> SessionRailRow { + SessionRailRow { + agent_id: Some("shell".to_string()), + lane_index: Some(0), + task: Some(TaskState { + task_id: task_id.to_string(), + status: TaskStatus::Running, + turns: 0, + last_at: 0, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }), + local: None, + last: false, + } +} + +fn group(sessions: Vec) -> AgentGroup { + AgentGroup { + row: AgentRailRow { + agent_id: "shell".to_string(), + host_id: String::new(), + agent: None, + lane_index: Some(0), + }, + sessions, + last_at: 0, + lane_label: None, + harness_label: None, + visible_tasks: 0, + hidden: 0, + overflow: false, + } +} + +fn shell_lane() -> AgentLane { + AgentLane { + key: "agent:shell".into(), + label: "shell".into(), + role: AgentRole::Agent, + turns: Vec::new(), + last_at: 0, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: Some("shell".into()), + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + +#[test] +fn split_fold_derives_the_first_page_counts_from_the_lane_tasks() { + let mut lane = shell_lane(); + lane.tasks = (0..11) + .map(|index| TaskState { + task_id: format!("task-{index}"), + status: TaskStatus::Done, + turns: 0, + last_at: index, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }) + .collect(); + + let groups = app().split_fold(&[lane]); + let group = groups.first().expect("the agent lane becomes one group"); + + assert_eq!(group.visible_tasks, 10); + assert_eq!(group.hidden, 1); + assert!(group.overflow); +} + +#[test] +fn paging_starts_with_the_fold_running_first_task_order() { + let mut lane = shell_lane(); + lane.tasks = vec![ + TaskState { + task_id: "completed-recently".into(), + status: TaskStatus::Done, + turns: 0, + last_at: 900, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }, + TaskState { + task_id: "running-older".into(), + status: TaskStatus::Running, + turns: 0, + last_at: 100, + turn_blocks: Vec::new(), + attention: None, + question_id: None, + work: None, + }, + ]; + + let group = app() + .split_fold(&[lane]) + .pop() + .expect("the agent lane becomes one group"); + + assert_eq!( + group + .sessions + .iter() + .filter_map(|session| session.task.as_ref().map(|task| task.task_id.as_str())) + .collect::>(), + vec!["running-older", "completed-recently"], + "the first task page must match the fold's running-first order" + ); +} + +#[test] +fn paging_applies_name_sort_before_selecting_the_visible_tasks() { + let lanes = vec![shell_lane()]; + let mut owner = group(vec![task_row("zulu"), task_row("alpha")]); + owner.visible_tasks = 1; + owner.hidden = 1; + owner.overflow = true; + sort_sessions(&mut owner.sessions, SidebarSort::Name); + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + None, + ); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::Session(session) + if session.task.as_ref().is_some_and(|task| task.task_id == "alpha") + ))); + assert!(!rows.iter().any(|row| matches!( + row, + RailRow::Session(session) + if session.task.as_ref().is_some_and(|task| task.task_id == "zulu") + ))); +} + +#[test] +fn paging_keeps_the_anchored_task_after_recent_sorting() { + let lanes = vec![shell_lane()]; + let mut owner = group(vec![ + task_row("newest"), + task_row("middle"), + task_row("selected"), + ]); + owner.sessions[0].task.as_mut().expect("task row").last_at = 900; + owner.sessions[1].task.as_mut().expect("task row").last_at = 500; + owner.sessions[2].task.as_mut().expect("task row").last_at = 100; + sort_sessions(&mut owner.sessions, SidebarSort::Recent); + owner.visible_tasks = 1; + owner.overflow = true; + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + Some(&RailAnchor::Task { + lane: "agent:shell".into(), + task_id: "selected".into(), + }), + ); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::Session(session) + if session.task.as_ref().is_some_and(|task| task.task_id == "selected") + ))); +} + +#[test] +fn paging_keeps_a_task_backed_session_with_an_active_workflow_run() { + let lanes = vec![shell_lane()]; + let mut active = task_row("active-workflow"); + let mut local = super::super::tests::stub_session("pty-1"); + local.mcp_grant_session = Some("grant-1".into()); + active.local = Some(local); + let mut owner = group(vec![task_row("newest"), active]); + owner.visible_tasks = 1; + owner.overflow = true; + let runs = medulla::control_socket::HarnessRunRegistry::new(); + runs.report( + "grant-1", + RunReport { + run_id: "run-1".into(), + workflow_id: "workflow".into(), + status: HarnessRunStatus::Running, + detail: None, + node: None, + }, + ); + let mut rows = Vec::new(); + + push_group(&mut rows, &mut owner, &runs, &lanes, None); + + assert!(rows.iter().any(|row| matches!( + row, + RailRow::WorkflowRun(run) if run.run.run_id == "run-1" + ))); +} + +#[test] +fn paging_hides_the_overflow_action_when_retention_shows_every_task() { + let lanes = vec![shell_lane()]; + let mut owner = group(vec![task_row("first"), task_row("pinned")]); + owner.visible_tasks = 1; + owner.hidden = 1; + owner.overflow = true; + let mut rows = Vec::new(); + + push_group( + &mut rows, + &mut owner, + &medulla::control_socket::HarnessRunRegistry::new(), + &lanes, + Some(&RailAnchor::Task { + lane: "agent:shell".into(), + task_id: "pinned".into(), + }), + ); + + assert!( + !rows + .iter() + .any(|row| matches!(row, RailRow::Overflow { .. })), + "the overflow action is absent when retaining a task reveals every task" + ); +} diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index fc43cdb9c..e09e2fd79 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -8,9 +8,9 @@ use std::sync::Arc; use medulla::config::LoadedConfig; use medulla::protocol::HarnessProvider; use medulla::runtime::mock::MockRuntime; -use medulla::runtime::{AgentDeclaration, Runtime}; +use medulla::runtime::{AgentDeclaration, Runtime, WorkerInfo}; -use super::{RailRow, NEW_SESSION_LABEL}; +use super::{AgentGroup, AgentRailRow, GroupRailRow, RailRow, NEW_SESSION_LABEL}; use crate::ui::app::App; use crate::worker::pty::PtyManager; @@ -116,6 +116,39 @@ fn the_sessions_of_one_agent_stay_contiguous() { ); } +#[test] +fn empty_grouped_sections_do_not_emit_a_header() { + let rows = app().flatten( + vec![super::organize::Section { + header: super::organize::SectionHeader::Group(GroupRailRow { + label: "/quiet".to_string(), + }), + agents: vec![AgentGroup { + row: AgentRailRow { + agent_id: "quiet".to_string(), + host_id: String::new(), + agent: None, + lane_index: None, + }, + sessions: Vec::new(), + last_at: 0, + lane_label: None, + harness_label: None, + visible_tasks: 0, + hidden: 0, + overflow: false, + }], + }], + Vec::new(), + &[], + ); + + assert!( + !rows.iter().any(|row| matches!(row, RailRow::Group(_))), + "a group without a rendered session must not leave an empty heading" + ); +} + // Unix-only: starts a real child on a real pseudo-terminal via `/bin/sh`, // which Windows has no equivalent of. The row model under test is // portable; only this way of standing a session up is not. @@ -233,8 +266,28 @@ fn a_session_in_an_undeclared_directory_is_still_listed() { } #[test] -fn host_rows_appear_only_once_a_second_host_exists() { - let mut app = app(); +fn the_host_tree_keeps_a_second_declared_host() { + let runtime = MockRuntime::empty(); + runtime.set_workers(vec![WorkerInfo { + id: "studio-claude".into(), + address: "studio".into(), + handle: None, + label: None, + harness: Some("claude".into()), + workspace: Some("/work".into()), + peer_id: None, + cpu_cores: None, + memory_total_bytes: None, + memory_available_bytes: None, + ip_address: None, + selected: false, + roles: Vec::new(), + budgets: Vec::new(), + readiness: Vec::new(), + }]); + let mut loaded = LoadedConfig::defaults("medulla.tui.json".into()); + loaded.config.link = Some(medulla::config::LinkConfig::default()); + let mut app = App::new(Arc::new(runtime), loaded); app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new("local-claude", "", "claude", "/work")]; assert!( @@ -255,20 +308,21 @@ fn host_rows_appear_only_once_a_second_host_exists() { "/work", )); let hosts: Vec<(String, bool)> = app - .rail_rows() + .host_tree() .into_iter() - .filter_map(|row| match row { - RailRow::Host(host) => Some((host.host_id, host.local)), - _ => None, - }) + .map(|host| (host.id, host.kind == medulla::ui::hosts::HostKind::Local)) .collect(); assert!( hosts.iter().any(|(host_id, _)| host_id == "studio"), - "the second machine gets a header: {hosts:?}" + "the second declared machine stays in the shared tree: {hosts:?}" + ); + assert!( + hosts.iter().any(|(_, is_local)| *is_local), + "the local machine remains in the shared tree: {hosts:?}" ); assert!( hosts.len() >= 2, - "so does this one, once there is a second: {hosts:?}" + "the local and second declared machines both remain: {hosts:?}" ); let local = app.local_host_refs(); assert_eq!( @@ -310,7 +364,7 @@ fn a_row_answers_for_the_lane_behind_it() { } // The host header and the action row are about no lane and no // session. - RailRow::Host(_) | RailRow::NewSession => { + RailRow::Host(_) | RailRow::Group(_) | RailRow::NewSession => { assert_eq!(row.lane_index(), None); assert_eq!(row.session_id(), None); assert!(row.task().is_none()); @@ -341,7 +395,9 @@ fn only_the_rows_that_name_something_take_the_cursor() { )]; for row in app.rail_rows() { match row { - RailRow::Host(_) => assert!(!row.selectable(), "a host header is a label"), + RailRow::Host(_) | RailRow::Group(_) => { + assert!(!row.selectable(), "a section header is a label") + } RailRow::Session(_) | RailRow::NewSession | RailRow::WorkflowRun(_) diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index fbea259c7..7b9bbd97b 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -1,9 +1,9 @@ -//! The Sessions rail's row taxonomy: `Host → Session`. +//! The Sessions rail's row taxonomy: `Host → Group → Session`. //! -//! One shape for the whole tree. A row is a host, one session running on it, a -//! workflow run that session started, or one of the two controls — the action -//! that opens a session, and the paging control for a lane whose sessions the -//! fold hid. +//! One shape for the whole tree. A row is a host, a grouping heading, one +//! session running on it, a workflow run that session started, or one of the +//! two controls — the action that opens a session, and the paging control for a +//! lane whose sessions the fold hid. //! //! Two levels have been removed since. `AgentRow::Sub` rendered a *task* and //! `RailRow::Harness` a *session*, in two groups separated by a @@ -62,6 +62,13 @@ pub struct HostRailRow { pub local: bool, } +/// A section heading when sessions are grouped by workspace or harness. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GroupRailRow { + /// The workspace or harness shared by the sessions below this heading. + pub label: String, +} + /// One agent — `harness × workspace` on a host — resolved but never rendered. /// /// The rail groups sessions by agent and takes each session's lane from the @@ -90,6 +97,71 @@ pub struct AgentRailRow { pub lane_index: Option, } +/// One agent and its sessions before grouping and sorting flatten the rail. +#[derive(Debug, Clone)] +pub(super) struct AgentGroup { + /// The resolved agent identity. + pub(super) row: AgentRailRow, + /// Sessions belonging to the agent. + pub(super) sessions: Vec, + /// Latest lane activity, including peer-lane activity without a task row. + pub(super) last_at: i64, + /// Display label supplied by a lane-only peer agent. + pub(super) lane_label: Option, + /// Harness label supplied by a lane-only peer agent. + pub(super) harness_label: Option, + /// Number of task-backed sessions currently revealed by the lane pager. + pub(super) visible_tasks: usize, + /// Sessions hidden by the fold's paging boundary. + pub(super) hidden: usize, + /// Whether the fold supplied an overflow control for this group. + pub(super) overflow: bool, +} + +impl AgentGroup { + /// The ordering label, preferring a peer lane's display label when needed. + pub(super) fn label(&self) -> String { + if self.row.agent.is_some() { + self.row.label() + } else { + self.lane_label + .clone() + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| self.row.label()) + } + } +} + +/// One host and its resolved agents before the rail is flattened. +#[derive(Debug, Clone)] +pub(super) struct HostGroup { + /// The host header. + pub(super) row: HostRailRow, + /// Agents that belong to the host. + pub(super) agents: Vec, +} + +impl AgentRailRow { + /// The resolved display label, falling back to the stable agent id. + pub fn label(&self) -> String { + self.agent + .as_ref() + .map(|agent| agent.label.clone()) + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| self.agent_id.clone()) + } + + /// The declared harness, when the shared host projection knows it. + pub fn harness(&self) -> Option<&str> { + self.agent.as_ref()?.harness.as_deref() + } + + /// The declared workspace, when the shared host projection knows it. + pub fn workspace(&self) -> Option<&str> { + self.agent.as_ref()?.workspace.as_deref() + } +} + /// One session of an agent — **one row type, whatever started it**. /// /// An orchestrator dispatch arrives as a [`TaskState`] folded from the event @@ -163,6 +235,8 @@ pub struct WorkflowRunRailRow { pub enum RailRow { /// A host header, emitted only once a remote host exists. Host(HostRailRow), + /// A workspace or harness grouping header. + Group(GroupRailRow), /// One session running on the host above it. /// /// Boxed because a session row carries both a whole [`TaskState`] and a @@ -201,7 +275,7 @@ impl RailRow { /// Whether the cursor may land on this row. pub fn selectable(&self) -> bool { match self { - RailRow::Host(_) => false, + RailRow::Host(_) | RailRow::Group(_) => false, RailRow::Session(_) => true, RailRow::NewSession => true, // Selectable so `Enter` can open the workflow it belongs to; the @@ -253,7 +327,10 @@ impl RailRow { match self { RailRow::Session(row) => row.lane_index, RailRow::Overflow { lane_index, .. } => Some(*lane_index), - RailRow::Host(_) | RailRow::NewSession | RailRow::WorkflowRun(_) => None, + RailRow::Host(_) + | RailRow::Group(_) + | RailRow::NewSession + | RailRow::WorkflowRun(_) => None, } } diff --git a/src/tui/src/ui/app/render/sessions/rail/mod.rs b/src/tui/src/ui/app/render/sessions/rail/mod.rs index 42a287e1e..76e2cf343 100644 --- a/src/tui/src/ui/app/render/sessions/rail/mod.rs +++ b/src/tui/src/ui/app/render/sessions/rail/mod.rs @@ -8,17 +8,16 @@ //! says rather than how it is laid out across columns. use ratatui::layout::Rect; -use ratatui::style::{Modifier, Style}; -use ratatui::text::{Line as TLine, Span, Text}; +use ratatui::style::Style; +use ratatui::text::{Line as TLine, Text}; use ratatui::widgets::Paragraph; use ratatui::Frame; use crate::ui::agents::{AgentLane, TaskStatus}; use crate::worker::pty::ATTENTION_GLYPH; -use super::super::super::rail::{RailRow, NEW_SESSION_LABEL}; +use super::super::super::rail::RailRow; use super::super::super::types::{App, RailHit}; -use super::super::color; use super::types::{Selection, SessionsPanes}; mod device_footer; @@ -35,7 +34,6 @@ mod wrap; use types::DeviceFooter; pub(super) use workflow_run::workflow_run_elapsed; -use wrap::wrap_line; /// The most content columns the Sessions rail ever takes. /// @@ -197,115 +195,6 @@ impl App { device_footer.append_to(&mut view, Style::default().fg(self.theme.accent)); f.render_widget(Paragraph::new(Text::from(view)), inner); } - - /// One row per open thread, with its running/attention badges. Built apart - /// Render one rail row as the lines it occupies, wrapped to `width`. - /// - /// A row is not always one line: an operator's own session carries a name - /// above its status line, and either can wrap. Continuations are indented so - /// a row that took two lines still reads as one row. - pub(super) fn rail_row_lines( - &self, - row: &RailRow, - lanes: &[AgentLane], - active: bool, - width: usize, - waiting_sessions: &std::collections::HashSet, - now: i64, - ) -> Vec> { - match row { - // A session this device runs and no task describes is the operator's - // own: it gets the multi-line status-line treatment, because its - // working directory is the only thing telling two of them apart. - // - // A name goes above that rather than into it. The status line is - // configurable and describes the *harness*; the name is what the - // person who opened the session called it, and it is the first thing - // they look for. - RailRow::Session(session) if session.task.is_none() => { - let Some(local) = &session.local else { - return Vec::new(); - }; - let mut lines = Vec::new(); - if let Some(name) = session.name() { - let style = if active { - self.theme.selection() - } else { - Style::default().fg(color("cyan")) - }; - lines.extend(wrap_line( - &TLine::from(Span::styled(format!(" {name}"), style)), - width, - CONT_INDENT, - )); - } - lines.extend(self.own_session_lines(local, active, width, now)); - lines - } - other => wrap_line( - &self.rail_row_line(other, lanes, active, waiting_sessions, now), - width, - CONT_INDENT, - ), - } - } - - /// Format one single-line rail row. - pub(super) fn rail_row_line( - &self, - row: &RailRow, - lanes: &[AgentLane], - active: bool, - waiting_sessions: &std::collections::HashSet, - now: i64, - ) -> TLine<'static> { - let _ = lanes; - match row { - RailRow::Host(host) => TLine::from(Span::styled( - format!("▸ {}", host.label), - Style::default() - .fg(color("blue")) - .add_modifier(Modifier::BOLD), - )), - RailRow::NewSession => self.new_session_line(active), - RailRow::Overflow { hidden, .. } => self.overflow_line(*hidden, active), - RailRow::WorkflowRun(run) => self.workflow_run_line(run, active, now), - RailRow::Session(session) => match (&session.task, &session.local) { - (Some(task), _) => { - self.task_session_line(task, session.last, active, waiting_sessions) - } - // Only reached through `rail_row_lines`, which draws a local - // session over several lines; kept total so measurement can call - // either. - (None, Some(local)) => self - .own_session_lines(local, active, RAIL_MAX_CONTENT, now) - .into_iter() - .next() - .unwrap_or_default(), - (None, None) => TLine::from(""), - }, - } - } - - /// Format the `+ New session` action row at the top of the rail. - /// - /// Drawn as a button rather than as another list entry — bold and coloured, - /// with its chord beside it — because it is the one row on the rail that - /// *does* something rather than selecting something. It is no longer a leaf - /// of an agent's group, so it takes the top level and loses the `└`. - fn new_session_line(&self, active: bool) -> TLine<'static> { - let style = if active { - self.theme.selection() - } else { - Style::default() - .fg(color("cyan")) - .add_modifier(Modifier::BOLD) - }; - TLine::from(vec![ - Span::styled(format!(" {NEW_SESSION_LABEL} "), style), - Span::styled(" ⏎ / ^T", Style::default().add_modifier(Modifier::DIM)), - ]) - } } /// Center the selected row while keeping its final line visible when the full diff --git a/src/tui/src/ui/app/render/sessions/rail/rows.rs b/src/tui/src/ui/app/render/sessions/rail/rows.rs index 272a4e56d..90864e8d6 100644 --- a/src/tui/src/ui/app/render/sessions/rail/rows.rs +++ b/src/tui/src/ui/app/render/sessions/rail/rows.rs @@ -6,9 +6,11 @@ use ratatui::style::{Modifier, Style}; use ratatui::text::{Line as TLine, Span}; use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; +use crate::ui::agents::AgentLane; use crate::ui::util::slug; use crate::worker::pty::{HarnessAttention, SessionControl, SessionRow, ATTENTION_GLYPH}; +use super::super::super::super::rail::{RailRow, NEW_SESSION_LABEL}; use super::super::super::super::types::App; use super::super::super::color; use super::harness_line; @@ -25,6 +27,102 @@ const SESSION_TITLE_MAX_CELLS: usize = 48; /// What a row says when its harness is blocked on the operator. const NEEDS_INPUT_LABEL: &str = "needs input"; +impl App { + /// Render a rail row as the lines it occupies, wrapped to `width`. + pub(super) fn rail_row_lines( + &self, + row: &RailRow, + lanes: &[AgentLane], + active: bool, + width: usize, + waiting_sessions: &HashSet, + now: i64, + ) -> Vec> { + match row { + RailRow::Session(session) if session.task.is_none() => { + let Some(local) = &session.local else { + return Vec::new(); + }; + let mut lines = Vec::new(); + if let Some(name) = session.name() { + let style = if active { + self.theme.selection() + } else { + Style::default().fg(color("cyan")) + }; + lines.extend(wrap_line( + &TLine::from(Span::styled(format!(" {name}"), style)), + width, + CONT_INDENT, + )); + } + lines.extend(self.own_session_lines(local, active, width, now)); + lines + } + other => wrap_line( + &self.rail_row_line(other, lanes, active, waiting_sessions, now), + width, + CONT_INDENT, + ), + } + } + + /// Format one single-line rail row. + pub(super) fn rail_row_line( + &self, + row: &RailRow, + lanes: &[AgentLane], + active: bool, + waiting_sessions: &HashSet, + now: i64, + ) -> TLine<'static> { + let _ = lanes; + match row { + RailRow::Host(host) => TLine::from(Span::styled( + format!("▸ {}", host.label), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), + )), + RailRow::Group(group) => TLine::from(Span::styled( + format!("▸ {}", group.label), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), + )), + RailRow::NewSession => self.new_session_line(active), + RailRow::Overflow { hidden, .. } => self.overflow_line(*hidden, active), + RailRow::WorkflowRun(run) => self.workflow_run_line(run, active, now), + RailRow::Session(session) => match (&session.task, &session.local) { + (Some(task), _) => { + self.task_session_line(task, session.last, active, waiting_sessions) + } + (None, Some(local)) => self + .own_session_lines(local, active, super::RAIL_MAX_CONTENT, now) + .into_iter() + .next() + .unwrap_or_default(), + (None, None) => TLine::from(""), + }, + } + } + + /// Format the `+ New session` action row at the top of the rail. + fn new_session_line(&self, active: bool) -> TLine<'static> { + let style = if active { + self.theme.selection() + } else { + Style::default() + .fg(color("cyan")) + .add_modifier(Modifier::BOLD) + }; + TLine::from(vec![ + Span::styled(format!(" {NEW_SESSION_LABEL} "), style), + Span::styled(" ⏎ / ^T", Style::default().add_modifier(Modifier::DIM)), + ]) + } +} + impl App { /// Format one operator-started harness using the configured status-line layout. /// diff --git a/src/tui/src/ui/app/render/settings/appearance_usage.rs b/src/tui/src/ui/app/render/settings/appearance_usage.rs index 3f5d66abb..e78305c12 100644 --- a/src/tui/src/ui/app/render/settings/appearance_usage.rs +++ b/src/tui/src/ui/app/render/settings/appearance_usage.rs @@ -11,7 +11,7 @@ use crate::ui::stream; use crate::ui::theme::{color_to_string, THEME_ROLES}; use crate::ui::util::clip; -use super::super::super::appearance::{APPEARANCE_ROWS, ATTENTION_ROWS}; +use super::super::super::appearance::{APPEARANCE_ROWS, ATTENTION_ROWS, SIDEBAR_GROUPING_OPTION}; use super::super::super::types::App; impl App { @@ -173,6 +173,49 @@ impl App { ))); } lines.push(TLine::from("")); + lines.push(TLine::from(Span::styled("Agents sidebar", heading))); + lines.push(TLine::from(Span::styled( + " How the sidebar sections its agents, and what order rows come in.", + description, + ))); + lines.push(TLine::from("")); + for (offset, (label, value, hint)) in [ + ( + "Group by", + self.loaded.config.appearance.sidebar_grouping.label(), + "host · path · harness · none", + ), + ( + "Sort by", + self.loaded.config.appearance.sidebar_sort.label(), + "created · recent · name", + ), + ] + .into_iter() + .enumerate() + { + let index = THEME_ROLES.len() + ATTENTION_ROWS + SIDEBAR_GROUPING_OPTION + offset; + if index == sel { + selected_line_index = lines.len(); + } + let style = if index == sel { + self.theme.selection() + } else { + Style::default() + }; + let marker = if index == sel { " ▸ " } else { " " }; + lines.push(TLine::from(vec![ + Span::styled(format!("{marker}{label:<20} {value:<10}"), style), + // The other rows on this page cycle between two or three states + // an operator can guess at; these cycle between four named ones, + // and a row reading only "path" gives no clue what else there is. + Span::styled( + hint.to_string(), + Style::default().add_modifier(Modifier::DIM), + ), + ])); + } + lines.push(TLine::from("")); lines.push(TLine::from(Span::styled( "j/k select · ←/→ or Enter change · applies live", Style::default().add_modifier(Modifier::DIM), diff --git a/src/tui/tests/feature_settings.rs b/src/tui/tests/feature_settings.rs index 91f4cb0c2..29bd8d1d4 100644 --- a/src/tui/tests/feature_settings.rs +++ b/src/tui/tests/feature_settings.rs @@ -244,6 +244,58 @@ fn appearance_cycles_and_persists_process_indicators() { assert!(saved.contains("diskIo = \"off\""), "{saved}"); } +#[test] +fn appearance_cycles_and_persists_the_sidebar_layout() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("config.toml"); + let mut app = settings_app(); + app.set_config_path(path.clone()); + let _ = key(&mut app, KeyCode::Char('2')); + // Five color rows, the blink toggle, and the seven option rows before it. + for _ in 0..13 { + let _ = key(&mut app, KeyCode::Char('j')); + } + let _ = key(&mut app, KeyCode::Right); + + let out = text_of(&draw(&mut app, 180, 60)); + assert!(out.contains("Group by path"), "{out}"); + assert!( + app.status().contains("Sidebar grouping \u{2192} path"), + "status names the setting and its new value: {}", + app.status() + ); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains("sidebarGrouping = \"path\""), "{saved}"); + + // The sort row is the next one down, and cycles independently. + let _ = key(&mut app, KeyCode::Char('j')); + let _ = key(&mut app, KeyCode::Right); + + let out = text_of(&draw(&mut app, 180, 60)); + assert!(out.contains("Sort by recent"), "{out}"); + let saved = std::fs::read_to_string(&path).unwrap(); + assert!(saved.contains("sidebarSort = \"recent\""), "{saved}"); + assert!( + saved.contains("sidebarGrouping = \"path\""), + "the grouping survives the next write: {saved}" + ); +} + +#[test] +fn appearance_sidebar_grouping_wraps_backwards() { + // Left from the default is the last value, not a stuck row: the cycle is + // how every other control on this page behaves. + let mut app = settings_app(); + let _ = key(&mut app, KeyCode::Char('2')); + for _ in 0..13 { + let _ = key(&mut app, KeyCode::Char('j')); + } + let _ = key(&mut app, KeyCode::Left); + + let out = text_of(&draw(&mut app, 180, 60)); + assert!(out.contains("Group by none"), "{out}"); +} + #[test] fn appearance_persists_process_indicators_to_json() { let dir = tempfile::tempdir().unwrap();