From 06a50aedd165a487266fc62bf10717f42ad55927 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 4 Aug 2026 22:48:39 +0530 Subject: [PATCH 1/6] =?UTF-8?q?feat(hosts):=20render=20the=20Hosts=20tab?= =?UTF-8?q?=20as=20Host=20=E2=86=92=20Agents=20with=20persisted=20roles?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page rendered `Runtime::workers()` flat and called each row a host — its own comment admitted the collapse. That was true only while a machine advertised one worker; a machine now declares one agent per `harness × workspace`, so the list was agents with the host level taken out of it, and a fleet you cannot see the shape of is one you cannot manage. The tab is now the topology the advert is a projection of (spec §2.4): - **The hosts this machine runs come first, always.** They are resolved from config, not from what started, so a host that is declared but idle is still listed — that is the state where the operator most needs to see it. A running primary overrides its identity from the live observation, since `[host].workspace` is usually blank and only it has resolved that. - **Each host carries its agents**, from two sources that are deliberately not merged: declarations for a local host (an agent exists because it is written down), and the roster for a remote one — because the host link does not exchange declared agent lists yet (plan §D1). A remote host says so on its own row and again in its preview rather than passing this hub's roster off as that machine's declarations. - **The cursor walks both levels.** A host row previews the machine — capacity, readiness, budgets, read from whichever entry probed it — and an agent row previews the thing a dispatch targets, with the role toggles. **Roles are assigned per agent and persisted.** `set_roles` moves the roster this process holds, and the roster is rebuilt from the declarations at every launch — so assigning a role that way was a change the operator watched take effect and then lost. Every toggle now writes the declaration through `declare_agent` *first* and moves the live roster second; a failed write makes no live change either, because a UI showing a role the file does not have is worse than one that refused. An agent the roster knows but nothing declared — the migration seed — is declared here from what it reports, since a role assigned to something nobody wrote down has nowhere to persist to. Renaming and removing follow the same rule. **The v1 capability split is enforced in the UI**: the local host offers agent creation (`n`) and role editing; a remote host is operator-read-only, because its agents are declared on that machine. Orchestrator dispatch to a remote agent is untouched — this is only about affordances in this terminal. Shared, so the list and the binder cannot disagree about which address a `[[hosts]]` section will bind: `medulla::config::local_hosts` now owns the device-local host resolution the TUI binary derived privately, and `medulla::ui::hosts` owns the tree itself. Co-Authored-By: Claude --- src/sdk/src/config/local_hosts.rs | 120 ++++++ src/sdk/src/config/local_hosts_tests.rs | 73 ++++ src/sdk/src/config/mod.rs | 4 + src/sdk/src/ui/hosts/mod.rs | 230 +++++++++++ src/sdk/src/ui/hosts/tests.rs | 183 +++++++++ src/sdk/src/ui/hosts/types.rs | 90 ++++ src/sdk/src/ui/mod.rs | 1 + src/tui/src/local_host/mod.rs | 77 +--- src/tui/src/ui/app/commands/dispatch.rs | 17 +- src/tui/src/ui/app/hosts/edit.rs | 181 ++++++++ src/tui/src/ui/app/hosts/mod.rs | 184 +++++++++ src/tui/src/ui/app/hosts/tests.rs | 317 ++++++++++++++ src/tui/src/ui/app/keys/routing/mod.rs | 167 +++++--- src/tui/src/ui/app/mod.rs | 4 +- src/tui/src/ui/app/render/routing/hosts.rs | 387 ------------------ .../src/ui/app/render/routing/hosts/format.rs | 124 ++++++ .../src/ui/app/render/routing/hosts/list.rs | 152 +++++++ .../src/ui/app/render/routing/hosts/mod.rs | 51 +++ .../ui/app/render/routing/hosts/preview.rs | 311 ++++++++++++++ src/tui/tests/feature_app_more/views.rs | 13 +- src/tui/tests/feature_workers.rs | 3 + src/tui/tests/feature_workers/helpers.rs | 20 + src/tui/tests/feature_workers/list.rs | 277 ++++++------- src/tui/tests/feature_workers/roles.rs | 160 ++++++++ 24 files changed, 2480 insertions(+), 666 deletions(-) create mode 100644 src/sdk/src/config/local_hosts.rs create mode 100644 src/sdk/src/config/local_hosts_tests.rs create mode 100644 src/sdk/src/ui/hosts/mod.rs create mode 100644 src/sdk/src/ui/hosts/tests.rs create mode 100644 src/sdk/src/ui/hosts/types.rs create mode 100644 src/tui/src/ui/app/hosts/edit.rs create mode 100644 src/tui/src/ui/app/hosts/mod.rs create mode 100644 src/tui/src/ui/app/hosts/tests.rs delete mode 100644 src/tui/src/ui/app/render/routing/hosts.rs create mode 100644 src/tui/src/ui/app/render/routing/hosts/format.rs create mode 100644 src/tui/src/ui/app/render/routing/hosts/list.rs create mode 100644 src/tui/src/ui/app/render/routing/hosts/mod.rs create mode 100644 src/tui/src/ui/app/render/routing/hosts/preview.rs create mode 100644 src/tui/tests/feature_workers/roles.rs diff --git a/src/sdk/src/config/local_hosts.rs b/src/sdk/src/config/local_hosts.rs new file mode 100644 index 000000000..9173119c8 --- /dev/null +++ b/src/sdk/src/config/local_hosts.rs @@ -0,0 +1,120 @@ +//! Which hosts this machine runs, resolved from config alone. +//! +//! A host is a machine with a bus address; `[host]` declares the primary one and +//! each `[[hosts]]` entry another beside it. Both the process that *starts* them +//! and the UI that *lists* them need the same answer to "what address will this +//! section bind, and what should it be called" — and they must not derive it +//! twice, because a UI that disagrees with the binder would file a running local +//! host under a remote one and quietly show it as read-only. +//! +//! Resolution is deliberately config-only: it holds before anything starts, +//! which is the case the Hosts tab needs (declared agents on a host that is not +//! running are still that host's agents) and the case the roster clean-up needs +//! (recognising remembered local entries when nothing started at all). + +use super::HostSection; + +/// One host this machine declares, as the UI and the starter both see it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LocalHostRef { + /// The bus address it binds — the `hostId` its agents are declared under. + pub id: String, + /// What to call it on screen. + pub name: String, + /// The directory it works in, as configured. Blank means "wherever medulla + /// was launched", which only the running host can resolve. + pub workspace: String, + /// Whether this is the `[host]` section rather than an `[[hosts]]` entry. + pub primary: bool, +} + +/// Every host this machine declares: the primary first, then the extras in +/// declaration order. +/// +/// Addresses come from [`local_host_address`], names from [`local_host_name`]. +pub fn local_hosts(primary: &HostSection, extras: &[HostSection]) -> Vec { + std::iter::once(LocalHostRef { + id: primary.effective_address(), + name: local_host_name(primary, &primary.workspace, true), + workspace: primary.workspace.clone(), + primary: true, + }) + .chain( + extras + .iter() + .enumerate() + .map(|(index, extra)| LocalHostRef { + id: local_host_address(extra, index), + name: local_host_name(extra, &extra.workspace, false), + workspace: extra.workspace.clone(), + primary: false, + }), + ) + .collect() +} + +/// The bus address for an extra host, derived from its name when it declared +/// none of its own. +/// +/// Two hosts cannot share an address — the second `bind` fails — so an operator +/// who adds `[[hosts]]` without thinking about addressing would otherwise get +/// one working host and one startup error. Deriving from the name means the +/// field is optional in the common case and explicit when it matters. +/// +/// The section default counts as unchosen, not as a choice: `[[hosts]]` shares +/// [`HostSection`], so an entry that names no address inherits the primary's. +/// An operator who *typed* the primary's address has made the same mistake, so +/// both are treated the same way. +pub fn local_host_address(config: &HostSection, fallback_index: usize) -> String { + let chosen = config.address.trim(); + let chosen = if chosen == HostSection::default().address { + "" + } else { + chosen + }; + match chosen { + "" => { + let slug = slug_of(&config.name); + if slug.is_empty() { + format!("local-host-{}", fallback_index + 1) + } else { + format!("local-{slug}") + } + } + value => value.to_string(), + } +} + +/// What to call a host that named itself nothing. +/// +/// The primary is "this device" — it is the machine the operator is looking at. +/// An extra is named for the directory it works in, because that is the only +/// thing distinguishing it from the primary. `workspace` is the *resolved* +/// directory where the caller has one; the configured value is the honest +/// fallback before anything has started. +pub fn local_host_name(config: &HostSection, workspace: &str, primary: bool) -> String { + match config.name.trim() { + "" if primary => "this device".to_string(), + "" => std::path::Path::new(workspace) + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| workspace.to_string()), + value => value.to_string(), + } +} + +/// A lowercase, hyphenated form of `name`, safe to use as a bus address. +fn slug_of(name: &str) -> String { + let mut out = String::new(); + let mut hyphen = false; + for ch in name.trim().chars() { + if ch.is_ascii_alphanumeric() { + out.push(ch.to_ascii_lowercase()); + hyphen = false; + } else if !out.is_empty() && !hyphen { + out.push('-'); + hyphen = true; + } + } + out.trim_end_matches('-').to_string() +} diff --git a/src/sdk/src/config/local_hosts_tests.rs b/src/sdk/src/config/local_hosts_tests.rs new file mode 100644 index 000000000..8d7f8cc22 --- /dev/null +++ b/src/sdk/src/config/local_hosts_tests.rs @@ -0,0 +1,73 @@ +//! Unit tests for device-local host resolution. + +use super::{local_host_address, local_host_name, local_hosts, HostSection}; + +/// A `[[hosts]]` entry as `load` produces it: fields default to the primary's, +/// which is exactly why an unchosen address must not count as chosen. +fn extra(name: &str, workspace: &str) -> HostSection { + HostSection { + name: name.into(), + workspace: workspace.into(), + ..HostSection::default() + } +} + +#[test] +fn an_extra_address_comes_from_its_name_then_its_position() { + let named = extra("backend API", "/w"); + let anonymous = extra("", "/w"); + let mut explicit = extra("ignored", "/w"); + explicit.address = "chosen-by-hand".into(); + + assert_eq!(local_host_address(&named, 0), "local-backend-api"); + assert_eq!(local_host_address(&anonymous, 3), "local-host-4"); + assert_eq!(local_host_address(&explicit, 0), "chosen-by-hand"); +} + +#[test] +fn inheriting_the_primary_address_counts_as_unchosen() { + // `[[hosts]]` shares `HostSection`, so an entry that names no address + // deserializes with the primary's default. Treating that as a choice would + // hand two hosts one address and the second would never bind. + let mut inherited = extra("", "/w"); + inherited.address = HostSection::default().address; + assert_eq!(local_host_address(&inherited, 0), "local-host-1"); +} + +#[test] +fn the_primary_leads_and_each_extra_follows_in_order() { + let primary = HostSection { + workspace: "/Users/me/medulla".into(), + ..HostSection::default() + }; + let extras = vec![ + extra("API", "/Users/me/Projects/backend"), + extra("", "/tmp/x"), + ]; + + let hosts = local_hosts(&primary, &extras); + let ids: Vec<&str> = hosts.iter().map(|host| host.id.as_str()).collect(); + assert_eq!(ids, vec!["this-device", "local-api", "local-host-2"]); + assert!(hosts[0].primary); + assert!(!hosts[1].primary); + assert_eq!(hosts[0].name, "this device"); + assert_eq!(hosts[1].name, "API"); + // An unnamed extra is named for the directory that distinguishes it. + assert_eq!(hosts[2].name, "x"); + assert_eq!(hosts[1].workspace, "/Users/me/Projects/backend"); +} + +#[test] +fn an_unnamed_host_falls_back_to_its_directory_then_the_path() { + let unnamed = extra("", ""); + assert_eq!( + local_host_name(&unnamed, "/Users/me/Projects/backend", false), + "backend" + ); + assert_eq!(local_host_name(&unnamed, "", false), ""); + assert_eq!(local_host_name(&unnamed, "/anything", true), "this device"); + assert_eq!( + local_host_name(&extra("API box", ""), "/x", false), + "API box" + ); +} diff --git a/src/sdk/src/config/mod.rs b/src/sdk/src/config/mod.rs index db04db776..3fecbd156 100644 --- a/src/sdk/src/config/mod.rs +++ b/src/sdk/src/config/mod.rs @@ -14,6 +14,7 @@ mod appearance; mod core_socket; mod custom_harnesses; mod load; +mod local_hosts; mod persist; mod types; mod urls; @@ -27,6 +28,8 @@ mod custom_harnesses_tests; #[cfg(test)] mod load_tests; #[cfg(test)] +mod local_hosts_tests; +#[cfg(test)] mod persist_tests; #[cfg(test)] mod types_tests; @@ -44,6 +47,7 @@ pub use custom_harnesses::{ OPENROUTER_ANTHROPIC_URL, OPENROUTER_API_KEY_ENV, OPENROUTER_OPENAI_URL, }; pub use load::{default_link_config, explicit_config_from_env, load_config, CONFIG_PATH_ENV}; +pub use local_hosts::{local_host_address, local_host_name, local_hosts, LocalHostRef}; pub use persist::{ clear_setting, persist_agent_declarations, persist_custom_harnesses, persist_host_workspaces, persist_hub_workers, persist_link_peers, persist_local_hosts, persist_root_setting, diff --git a/src/sdk/src/ui/hosts/mod.rs b/src/sdk/src/ui/hosts/mod.rs new file mode 100644 index 000000000..cd069de3f --- /dev/null +++ b/src/sdk/src/ui/hosts/mod.rs @@ -0,0 +1,230 @@ +//! The Hosts surface: `Host → Agents`, the topology the advert is a projection +//! of (spec §2.4). +//! +//! The page used to render the worker roster flat and call each row a host. That +//! was true only while a machine advertised exactly one worker; now one machine +//! declares one entry per agent, so a flat roster is a list of *agents* with the +//! host level collapsed out of it. This module puts the level back: the hosts +//! this machine runs (always present, running or not), then every remote host +//! the roster reaches, each carrying the agents known to be on it. +//! +//! Two sources, deliberately not merged into one: +//! +//! - **declarations** (`[fleet].agentDeclarations`) are the truth for a local +//! host — an agent exists because it is written down, not because something is +//! running (spec §2.1); +//! - **the roster** is the truth for a remote host, because a remote host does +//! not yet share its declared agent list over the link (plan §D1). Until it +//! does, a remote host shows what the roster knows about it and says so, +//! rather than pretending this machine declared anything over there. + +use crate::config::LocalHostRef; +use crate::runtime::{AgentDeclaration, WorkerInfo}; + +mod types; +pub use types::{HostAgentRow, HostKind, HostRow}; + +#[cfg(test)] +mod tests; + +/// Build the `Host → Agents` tree. +/// +/// `locals` is every host this machine declares (see +/// [`local_hosts`](crate::config::local_hosts)); it leads the result in that +/// order, whether or not anything is running on it — a declared host with +/// nothing in the roster is still where its agents live, and hiding it would +/// make "the local host is always present" false exactly when the operator needs +/// to see why nothing is being dispatched. +/// +/// `workers` is the live roster. Entries whose address matches a local host fill +/// in what is running there; the rest are grouped by address into one remote +/// host each, in first-seen order. +/// +/// A declaration naming a host that `locals` does not contain still gets a host +/// row of its own, after the configured ones: it was declared on this machine, +/// so it is local and editable, and dropping it would hide agents the operator +/// wrote down. +pub fn host_rows( + workers: &[WorkerInfo], + declarations: &[AgentDeclaration], + locals: &[LocalHostRef], +) -> Vec { + let mut rows: Vec = Vec::new(); + let mut claimed: Vec<&str> = Vec::new(); + + for local in locals { + rows.push(local_row( + &local.id, + &local.name, + workers, + declarations, + &mut claimed, + )); + } + // Declared, but on a host id this config does not (or no longer) describes. + for declaration in declarations { + let host_id = declaration.host_id.trim(); + if host_id.is_empty() || rows.iter().any(|row| row.id == host_id) { + continue; + } + rows.push(local_row( + host_id, + host_id, + workers, + declarations, + &mut claimed, + )); + } + // Everything left in the roster is somebody else's machine. + for worker in workers { + if claimed.contains(&worker.id.as_str()) { + continue; + } + let address = worker.address.trim(); + match rows + .iter_mut() + .find(|row| row.kind == HostKind::Remote && row.id == address) + { + Some(row) => { + row.detail_worker.get_or_insert_with(|| worker.id.clone()); + row.agents.push(agent_from_worker(worker, false)); + } + None => rows.push(HostRow { + id: address.to_string(), + label: remote_label(worker), + kind: HostKind::Remote, + agents: vec![agent_from_worker(worker, false)], + detail_worker: Some(worker.id.clone()), + }), + } + } + rows +} + +/// One local host: its declared agents first, then anything the roster has at +/// its address that no declaration claims. +/// +/// The undeclared tail is the migration seed (an install that predates +/// declarations advertises agents nobody wrote down) and it stays editable: +/// assigning it a role is what writes the declaration that makes the role stick. +fn local_row<'a>( + id: &str, + label: &str, + workers: &'a [WorkerInfo], + declarations: &[AgentDeclaration], + claimed: &mut Vec<&'a str>, +) -> HostRow { + let mut agents: Vec = Vec::new(); + for declaration in declarations.iter().filter(|d| d.on_host(id)) { + let worker = workers + .iter() + .find(|worker| worker.id.trim() == declaration.agent_id.trim()); + agents.push(agent_from_declaration(declaration, worker)); + } + let mut detail_worker = None; + for worker in workers.iter().filter(|worker| worker.address.trim() == id) { + if detail_worker.is_none() && has_probe_details(worker) { + detail_worker = Some(worker.id.clone()); + } + if agents.iter().any(|agent| agent.agent_id == worker.id) { + continue; + } + agents.push(agent_from_worker(worker, true)); + } + // Claim by agent id rather than by address: a declaration may name an agent + // whose roster entry is registered under another address, and it must not + // then be listed a second time as a remote host of its own. + for agent in &agents { + if let Some(worker) = workers.iter().find(|worker| worker.id == agent.agent_id) { + claimed.push(worker.id.as_str()); + } + } + if detail_worker.is_none() { + detail_worker = agents + .iter() + .find(|agent| agent.live) + .map(|agent| agent.agent_id.clone()); + } + HostRow { + id: id.to_string(), + label: label.to_string(), + kind: HostKind::Local, + agents, + detail_worker, + } +} + +/// Whether a roster entry carries a capability probe worth previewing. +fn has_probe_details(worker: &WorkerInfo) -> bool { + worker.cpu_cores.is_some() + || worker.memory_total_bytes.is_some() + || worker.memory_available_bytes.is_some() + || worker.ip_address.is_some() + || !worker.readiness.is_empty() + || !worker.budgets.is_empty() +} + +/// An agent row from the declaration that defines it, with whatever the roster +/// knows about it folded in. +/// +/// Roles come from the declaration, not from the live entry: the declaration is +/// what survives a restart, so showing it is what makes the checkbox honest. +fn agent_from_declaration( + declaration: &AgentDeclaration, + worker: Option<&WorkerInfo>, +) -> HostAgentRow { + let workspace = declaration + .workspace + .path() + .map(str::to_string) + .or_else(|| worker.and_then(|worker| worker.workspace.clone())); + HostAgentRow { + agent_id: declaration.agent_id.clone(), + label: declaration + .name + .clone() + .or_else(|| worker.and_then(|worker| worker.label.clone())) + .unwrap_or_else(|| declaration.agent_id.clone()), + harness: Some(declaration.harness.clone()), + workspace, + roles: declaration.roles.clone(), + max_sessions: Some(declaration.max_sessions()), + declared: true, + editable: true, + live: worker.is_some(), + selected: worker.is_some_and(|worker| worker.selected), + } +} + +/// An agent row for a roster entry no declaration on this machine covers. +/// +/// `local` says whether it sits on a host this machine runs, which is the only +/// thing that decides whether its roles can be assigned here. +fn agent_from_worker(worker: &WorkerInfo, local: bool) -> HostAgentRow { + HostAgentRow { + agent_id: worker.id.clone(), + label: worker + .label + .clone() + .or_else(|| worker.handle.clone()) + .unwrap_or_else(|| worker.id.clone()), + harness: worker.harness.clone(), + workspace: worker.workspace.clone(), + roles: worker.roles.clone(), + max_sessions: None, + declared: false, + editable: local, + live: true, + selected: worker.selected, + } +} + +/// What to call a remote host: the operator's label, its handle, else the raw +/// address they typed. +fn remote_label(worker: &WorkerInfo) -> String { + worker + .label + .clone() + .or_else(|| worker.handle.clone()) + .unwrap_or_else(|| worker.address.clone()) +} diff --git a/src/sdk/src/ui/hosts/tests.rs b/src/sdk/src/ui/hosts/tests.rs new file mode 100644 index 000000000..b26cfba88 --- /dev/null +++ b/src/sdk/src/ui/hosts/tests.rs @@ -0,0 +1,183 @@ +//! Unit tests for the `Host → Agents` tree. + +use super::{host_rows, HostKind}; +use crate::config::LocalHostRef; +use crate::runtime::{AgentDeclaration, WorkerInfo}; + +/// A roster entry with nothing probed — the shape an added peer has. +fn worker(id: &str, address: &str) -> WorkerInfo { + WorkerInfo { + id: id.into(), + address: address.into(), + handle: None, + label: None, + harness: Some("codex".into()), + workspace: None, + 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(), + } +} + +/// The device-local host, as `local_hosts` resolves it. +fn this_device() -> LocalHostRef { + LocalHostRef { + id: "this-device".into(), + name: "this device".into(), + workspace: "/w".into(), + primary: true, + } +} + +#[test] +fn the_local_host_is_present_with_no_agents_and_no_roster() { + let rows = host_rows(&[], &[], &[this_device()]); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].id, "this-device"); + assert_eq!(rows[0].kind, HostKind::Local); + assert!(rows[0].agents.is_empty()); + assert!(rows[0].accepts_new_agents()); + assert!(rows[0].detail_worker.is_none()); +} + +#[test] +fn declared_agents_hang_off_their_host_whether_or_not_they_are_running() { + let mut running = + AgentDeclaration::new("medulla-claude", "this-device", "claude", "/w/medulla"); + running.roles = vec!["coder".into()]; + let idle = AgentDeclaration::new("api-codex", "this-device", "codex", "/w/api"); + + let rows = host_rows( + &[worker("medulla-claude", "this-device")], + &[running, idle], + &[this_device()], + ); + + assert_eq!(rows.len(), 1, "one machine, one host row"); + let agents = &rows[0].agents; + assert_eq!(agents.len(), 2); + assert_eq!(agents[0].agent_id, "medulla-claude"); + assert_eq!(agents[0].harness.as_deref(), Some("claude")); + assert_eq!(agents[0].workspace.as_deref(), Some("/w/medulla")); + assert_eq!(agents[0].roles, vec!["coder".to_string()]); + assert_eq!(agents[0].max_sessions, Some(1), "checkout ⇒ serial"); + assert!(agents[0].declared && agents[0].editable && agents[0].live); + // Declared but with nothing in the roster: still an agent, not yet running. + assert!(agents[1].declared && agents[1].editable && !agents[1].live); +} + +#[test] +fn a_roster_entry_no_declaration_covers_is_still_listed_and_still_editable() { + // The migration seed: an install that predates declarations advertises + // agents nobody wrote down. They are this machine's, so a role can be + // assigned — which is what writes the declaration. + let rows = host_rows( + &[worker("this-device", "this-device")], + &[], + &[this_device()], + ); + let agent = &rows[0].agents[0]; + assert_eq!(agent.agent_id, "this-device"); + assert!(!agent.declared, "no declaration covers it"); + assert!(agent.editable, "but it is on this machine"); + assert!(agent.live); + assert_eq!(agent.max_sessions, None, "nothing declared a strategy"); +} + +#[test] +fn remote_peers_group_under_their_address_and_are_read_only() { + let mut labelled = worker("build-box", "7Kx9fQ"); + labelled.label = Some("build box".into()); + let sibling = worker("build-box-codex", "7Kx9fQ"); + + let rows = host_rows( + &[labelled, sibling, worker("other", "9zzz")], + &[], + &[this_device()], + ); + + assert_eq!(rows.len(), 3, "local + two remotes"); + assert_eq!(rows[1].id, "7Kx9fQ"); + assert_eq!(rows[1].label, "build box"); + assert_eq!(rows[1].kind, HostKind::Remote); + assert!(!rows[1].accepts_new_agents()); + assert_eq!(rows[1].agents.len(), 2, "both entries at that address"); + for agent in &rows[1].agents { + assert!(!agent.declared, "a remote's declarations live over there"); + assert!(!agent.editable, "and are not editable from here"); + assert!(agent.live); + } + assert_eq!(rows[2].id, "9zzz"); + assert_eq!( + rows[2].label, "9zzz", + "no label, no handle: the raw address" + ); +} + +#[test] +fn the_preview_reads_capacity_from_whichever_entry_probed_the_machine() { + // Capacity belongs to the machine, so the host row points at the entry that + // reported it rather than at whichever agent happens to be first. + let bare = worker("agent-a", "this-device"); + let mut probed = worker("agent-b", "this-device"); + probed.cpu_cores = Some(8); + + let rows = host_rows(&[bare, probed], &[], &[this_device()]); + assert_eq!(rows[0].detail_worker.as_deref(), Some("agent-b")); +} + +#[test] +fn a_declaration_naming_an_unconfigured_host_still_gets_a_local_row() { + // The `[[hosts]]` entry was removed, or the id was hand-written. The agents + // are still declared on this machine — hiding them would lose them. + let orphan = AgentDeclaration::new("ghost", "local-gone", "claude", "/w/gone"); + let rows = host_rows(&[], &[orphan], &[this_device()]); + + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].id, "local-gone"); + assert_eq!(rows[1].kind, HostKind::Local); + assert_eq!(rows[1].agents[0].agent_id, "ghost"); + assert!(rows[1].agents[0].editable); +} + +#[test] +fn an_agent_named_by_a_declaration_is_never_also_a_remote_host() { + // The roster entry is registered under a different address than the host id + // its declaration names. It is still that host's agent; listing it again as + // a remote host would advertise one agent as two machines. + let declaration = AgentDeclaration::new("roamer", "this-device", "claude", "/w"); + let rows = host_rows( + &[worker("roamer", "somewhere-else")], + &[declaration], + &[this_device()], + ); + + assert_eq!(rows.len(), 1, "no phantom remote host: {rows:?}"); + assert!(rows[0].agents[0].live); +} + +#[test] +fn the_declared_name_and_roles_win_over_the_live_entry() { + // The declaration is what survives a restart, so it is what the row shows — + // a live entry whose roles were set but never written down must not read as + // the persisted answer. + let mut declared = AgentDeclaration::new("a1", "this-device", "claude", "/w"); + declared.name = Some("the good one".into()); + declared.roles = vec!["reviewer".into()]; + let mut live = worker("a1", "this-device"); + live.label = Some("stale label".into()); + live.roles = vec!["coder".into()]; + live.selected = true; + + let rows = host_rows(&[live], &[declared], &[this_device()]); + let agent = &rows[0].agents[0]; + assert_eq!(agent.label, "the good one"); + assert_eq!(agent.roles, vec!["reviewer".to_string()]); + assert!(agent.selected, "selection is live state, and is kept"); +} diff --git a/src/sdk/src/ui/hosts/types.rs b/src/sdk/src/ui/hosts/types.rs new file mode 100644 index 000000000..c30151c6c --- /dev/null +++ b/src/sdk/src/ui/hosts/types.rs @@ -0,0 +1,90 @@ +//! Data types for the Hosts surface: a host and the agents under it. + +/// Where a host is, and therefore what an operator may do to it from here. +/// +/// The v1 capability split (spec §2.4): agents are created and edited on the +/// machine that owns them, so a remote host's agents are read-only here. This is +/// about the *operator's* affordances only — the orchestrator dispatches to a +/// remote agent exactly as it does to a local one. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum HostKind { + /// A host this machine runs: `[host]` or one of the `[[hosts]]` entries. + /// Its agents are declared in this config, so they can be edited here. + Local, + /// Another machine, reached by its tiny.place address. Its agents are + /// declared over there. + Remote, +} + +impl HostKind { + /// Whether an operator may declare or edit agents on this host from here. + pub fn editable(self) -> bool { + matches!(self, HostKind::Local) + } +} + +/// One host in the Hosts tab, with the agents known to be on it. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRow { + /// The `hostId`: the bus address a local host binds, or the tiny.place + /// address a remote one is reached at. + pub id: String, + /// What to call it on screen. + pub label: String, + /// Local or remote — see [`HostKind`]. + pub kind: HostKind, + /// The agents under it, in declaration order for a local host and roster + /// order for a remote one. + pub agents: Vec, + /// The roster entry whose capability probe describes this *machine* + /// (capacity, readiness, budgets). `None` when nothing on this host is in + /// the roster — a host declared here but not running. + /// + /// Capacity is a property of the machine, not of one agent, so the preview + /// reads it from whichever entry reported it rather than repeating it under + /// every agent. + pub detail_worker: Option, +} + +impl HostRow { + /// Whether the operator may declare a new agent on this host from here. + pub fn accepts_new_agents(&self) -> bool { + self.kind.editable() + } +} + +/// One agent under a host. +/// +/// Two sources feed it, and the difference is what the tab must not blur. A +/// **declared** agent is written down in this machine's `[fleet]`: it is an +/// agent whether or not anything is running, and its roles are editable here. An +/// **undeclared** one is only known because the roster has an entry for it — +/// a migration seed on this machine, or a remote peer whose declarations this +/// hub cannot see until the host link learns to exchange them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostAgentRow { + /// The `agentId` a dispatch targets. Also the roster/worker id. + pub agent_id: String, + /// What to call it: its declared name, its roster label, else its id. + pub label: String, + /// The coding-agent CLI it runs, when known. + pub harness: Option, + /// The directory its sessions work in, when known. + pub workspace: Option, + /// Agent-template ids it is offered for. Empty means unspecified — a + /// general agent, not one excluded from every role. + pub roles: Vec, + /// Sessions it may run at once, derived from its declared strategy. `None` + /// for an agent this machine has not declared. + pub max_sessions: Option, + /// Whether this machine's config declares it. + pub declared: bool, + /// Whether its roles can be assigned here. True only on a local host: a + /// remote host's agents are declared on that machine. + pub editable: bool, + /// Whether the live roster carries an entry for it — i.e. whether the + /// orchestrator can dispatch to it right now. + pub live: bool, + /// Whether it is the operator's manually-selected default worker. + pub selected: bool, +} diff --git a/src/sdk/src/ui/mod.rs b/src/sdk/src/ui/mod.rs index 43b85c51c..41a858044 100644 --- a/src/sdk/src/ui/mod.rs +++ b/src/sdk/src/ui/mod.rs @@ -14,6 +14,7 @@ pub mod events; pub mod fleet; pub mod git_review; pub mod harness; +pub mod hosts; pub mod meters; #[cfg(test)] mod meters_tests; diff --git a/src/tui/src/local_host/mod.rs b/src/tui/src/local_host/mod.rs index 3a49a5b86..bae92bbba 100644 --- a/src/tui/src/local_host/mod.rs +++ b/src/tui/src/local_host/mod.rs @@ -69,78 +69,25 @@ pub(crate) fn host_address(config: &HostSection) -> String { /// than from a started host — and it is needed in exactly the case where none /// started, to recognise remembered local roster entries and drop them. pub(crate) fn all_host_addresses(primary: &HostSection, extras: &[HostSection]) -> Vec { - std::iter::once(host_address(primary)) - .chain( - extras - .iter() - .enumerate() - .map(|(index, extra)| extra_host_address(extra, index)), - ) + medulla::config::local_hosts(primary, extras) + .into_iter() + .map(|host| host.id) .collect() } -/// The bus address for an extra host, derived from its name when it declared -/// none of its own. -/// -/// Two hosts cannot share an address — the second `bind` fails — so an operator -/// who adds `[[hosts]]` without thinking about addressing would otherwise get -/// one working host and one startup error. Deriving from the name means the -/// field is optional in the common case and explicit when it matters. +/// The bus address for an extra host — see +/// [`local_host_address`](medulla::config::local_host_address), which the Hosts +/// tab reads too so the list and the binder cannot disagree about which address +/// a section will bind. fn extra_host_address(config: &HostSection, fallback_index: usize) -> String { - // The section default counts as unchosen, not as a choice. `[[hosts]]` - // shares `HostSection`, so an entry that names no address inherits the - // primary's — and two hosts on one address means the second never binds. - // An operator who *typed* the primary's address has made the same mistake, - // so both are treated the same way. - let chosen = config.address.trim(); - let chosen = if chosen == HostSection::default().address { - "" - } else { - chosen - }; - match chosen { - "" => { - let slug = slug_of(&config.name); - if slug.is_empty() { - format!("local-host-{}", fallback_index + 1) - } else { - format!("local-{slug}") - } - } - value => value.to_string(), - } + medulla::config::local_host_address(config, fallback_index) } -/// A lowercase, hyphenated form of `name`, safe to use as a bus address. -fn slug_of(name: &str) -> String { - let mut out = String::new(); - let mut hyphen = false; - for ch in name.trim().chars() { - if ch.is_ascii_alphanumeric() { - out.push(ch.to_ascii_lowercase()); - hyphen = false; - } else if !out.is_empty() && !hyphen { - out.push('-'); - hyphen = true; - } - } - out.trim_end_matches('-').to_string() -} - -/// What to call a host that named itself nothing. -/// -/// The primary is "this device" — it is the machine the operator is looking at. -/// An extra is named for the directory it works in, because that is the only -/// thing distinguishing it from the primary. +/// What to call a host that named itself nothing — see +/// [`local_host_name`](medulla::config::local_host_name). Shared with the Hosts +/// tab, which names the same hosts before any of them has started. pub(crate) fn display_name(config: &HostSection, workspace: &str, primary: bool) -> String { - match config.name.trim() { - "" if primary => "this device".to_string(), - "" => std::path::Path::new(workspace) - .file_name() - .map(|n| n.to_string_lossy().into_owned()) - .unwrap_or_else(|| workspace.to_string()), - value => value.to_string(), - } + medulla::config::local_host_name(config, workspace, primary) } /// Translate the `[host]` section into the SDK's start-up options. diff --git a/src/tui/src/ui/app/commands/dispatch.rs b/src/tui/src/ui/app/commands/dispatch.rs index cfaaef219..11220d90c 100644 --- a/src/tui/src/ui/app/commands/dispatch.rs +++ b/src/tui/src/ui/app/commands/dispatch.rs @@ -4,7 +4,7 @@ use crate::ui::agents::{AgentRow, TaskState}; use crate::ui::clipboard::{copy_for_operator, copy_to_clipboard, current_platform, OSC_52}; use crate::ui::command::{self, CopyScope, SlashCommand}; use crate::ui::composer::Draft; -use medulla::runtime::{WorkerInfo, WorkerOp}; +use medulla::runtime::WorkerOp; use super::super::types::{ tab_pos, App, Cmd, Prompt, PromptKind, SETTINGS_SUBPAGES, SP_APPEARANCE, SP_CONFIG, SP_HELP, @@ -12,15 +12,6 @@ use super::super::types::{ }; impl App { - /// The worker under the Workers-list cursor, if the fleet is non-empty. - pub(in crate::ui::app) fn selected_host(&self) -> Option { - let ws = self.runtime.workers(); - if ws.is_empty() { - return None; - } - ws.get(self.host_index.min(ws.len() - 1)).cloned() - } - /// The task under the Agents-list cursor, when a `Sub` (task) row is selected. /// /// Indexes the rail's rows, which is what `agent_index` counts — the lane @@ -178,7 +169,11 @@ impl App { } PromptKind::HostEditLabel(id) => { let mut patch = serde_json::Map::new(); - patch.insert("label".into(), serde_json::Value::String(text)); + patch.insert("label".into(), serde_json::Value::String(text.clone())); + // The roster label is this run's; the declaration's name is the + // one that comes back after a restart, so an agent this machine + // declared is renamed in both places or the edit half-survives. + self.persist_agent_name(&id, &text); self.set_status("Updating label…"); Some(Cmd::WorkerOp(WorkerOp::Update { id, patch })) } diff --git a/src/tui/src/ui/app/hosts/edit.rs b/src/tui/src/ui/app/hosts/edit.rs new file mode 100644 index 000000000..9b0035201 --- /dev/null +++ b/src/tui/src/ui/app/hosts/edit.rs @@ -0,0 +1,181 @@ +//! Editing an agent from the Hosts page: role assignment, undeclaring, and +//! renaming — each written to `[fleet].agentDeclarations` rather than to the +//! live roster alone. +//! +//! The distinction this file exists for: [`HubHandle::set_roles`] moves the +//! roles on the roster this process is holding, and the roster is rebuilt from +//! the declarations at every launch. Assigning a role that way is a change the +//! operator watches take effect and then loses. So every edit here writes the +//! declaration *first* and mutates the live roster second — and when the write +//! fails, the live change is not made either, because a UI showing a role the +//! file does not have is worse than one that refused. +//! +//! [`HubHandle::set_roles`]: medulla::hub::HubHandle::set_roles + +use medulla::runtime::{AgentDeclaration, WorkerOp}; +use medulla::ui::hosts::{HostAgentRow, HostRow}; + +use super::super::types::{App, Cmd}; + +impl App { + /// Toggle `role` on the agent under the cursor, persisting the result. + /// + /// Returns the roster mutation to apply on top of the write, so the + /// orchestrator starts (or stops) routing that role here without waiting for + /// a restart. `None` when nothing was changed, when the agent is not in the + /// live roster, or when the write failed — in every one of those cases the + /// status line says why. + pub(in crate::ui::app) fn toggle_selected_agent_role(&mut self, role: &str) -> Option { + let host = self.selected_host_row()?; + let agent = self.selected_host_agent()?; + if !agent.editable { + self.set_status(format!( + "{} is declared on {} — assign its roles there", + agent.agent_id, host.label + )); + return None; + } + let mut roles = agent.roles.clone(); + let assigned = match roles.iter().position(|held| held == role) { + Some(at) => { + roles.remove(at); + false + } + None => { + roles.push(role.to_string()); + true + } + }; + let outcome = if assigned { + format!("{} now offered for {role}", agent.agent_id) + } else { + format!("{} no longer offered for {role}", agent.agent_id) + }; + if !self.persist_agent_roles(&host, &agent, roles.clone(), outcome) { + return None; + } + // Only a live agent has a roster entry to move. A declared one that is + // not running has nothing to re-register; its roles ride the declaration + // into the roster the next time it starts. + agent.live.then(|| { + Cmd::WorkerOp(WorkerOp::SetRoles { + id: agent.agent_id.clone(), + roles, + }) + }) + } + + /// Write `roles` onto the agent's declaration, narrating either outcome. + /// + /// Returns whether the caller may go on to move the live roster. An agent + /// the roster knows but no declaration covers — the migration seed — is + /// *declared here*, from what the roster reports, because a role assigned to + /// something nobody wrote down has nowhere to persist to. + fn persist_agent_roles( + &mut self, + host: &HostRow, + agent: &HostAgentRow, + roles: Vec, + outcome: String, + ) -> bool { + let current = self.loaded.config.fleet.agent_declarations.clone(); + let mut declaration = match medulla::config::agent_declaration(¤t, &agent.agent_id) { + Some(held) => held.clone(), + None => { + let Some(harness) = agent.harness.clone().filter(|h| !h.trim().is_empty()) else { + // Nothing to write down faithfully: a declaration invents an + // agent, and inventing one with no harness would advertise a + // placement that cannot run. + self.set_status(format!( + "{} reports no harness, so its roles cannot be saved", + agent.agent_id + )); + return false; + }; + AgentDeclaration::new( + agent.agent_id.clone(), + host.id.clone(), + harness, + agent.workspace.clone().unwrap_or_default(), + ) + } + }; + declaration.roles = roles; + let Some(path) = self.config_path.clone() else { + // Nowhere to write: the change still applies for this run, and the + // status says exactly how long it lasts. + self.set_status(format!("{outcome} (this run only — no config file)")); + return true; + }; + match medulla::config::declare_agent(&path, ¤t, declaration) { + Ok(declarations) => { + self.loaded.config.fleet.agent_declarations = declarations; + self.set_status(outcome); + true + } + Err(error) => { + self.set_status(format!("Roles were not saved: {error}")); + false + } + } + } + + /// Undeclare the agent under the cursor, if this machine declared it. + /// + /// Removing only undeclares: the workspace directory is left alone, because + /// the operator asked the orchestrator to stop placing work there, not to + /// lose their checkout. Returns whether the declaration went, so the caller + /// can decide what to do about the live roster entry. + pub(in crate::ui::app) fn undeclare_selected_agent(&mut self) -> bool { + let Some(agent) = self.selected_host_agent() else { + return false; + }; + if !agent.declared || !agent.editable { + return false; + } + let Some(path) = self.config_path.clone() else { + self.set_status(format!( + "{} stays declared — there is no config file to remove it from", + agent.agent_id + )); + return false; + }; + let current = self.loaded.config.fleet.agent_declarations.clone(); + match medulla::config::undeclare_agent(&path, ¤t, &agent.agent_id) { + Ok(declarations) => { + self.loaded.config.fleet.agent_declarations = declarations; + self.set_status(format!( + "Undeclared {} · its files are untouched", + agent.agent_id + )); + true + } + Err(error) => { + self.set_status(format!("{} was not undeclared: {error}", agent.agent_id)); + false + } + } + } + + /// Persist a renamed agent, when the label just edited belongs to one this + /// machine declares. + /// + /// The roster label is this run's; the declaration's `name` is the one that + /// comes back. A blank name clears it, which returns the agent to being + /// named by the renderer rather than by a label nobody typed. + pub(in crate::ui::app) fn persist_agent_name(&mut self, agent_id: &str, name: &str) { + let current = self.loaded.config.fleet.agent_declarations.clone(); + let Some(declaration) = medulla::config::agent_declaration(¤t, agent_id) else { + return; + }; + let mut declaration = declaration.clone(); + declaration.name = (!name.trim().is_empty()).then(|| name.trim().to_string()); + let Some(path) = self.config_path.clone() else { + return; + }; + match medulla::config::declare_agent(&path, ¤t, declaration) { + Ok(declarations) => self.loaded.config.fleet.agent_declarations = declarations, + Err(error) => self.set_status(format!("The new name was not saved: {error}")), + } + } +} diff --git a/src/tui/src/ui/app/hosts/mod.rs b/src/tui/src/ui/app/hosts/mod.rs new file mode 100644 index 000000000..fa8a22eb2 --- /dev/null +++ b/src/tui/src/ui/app/hosts/mod.rs @@ -0,0 +1,184 @@ +//! The Hosts page's state: the `Host → Agents` tree, the cursor over it, and +//! what the operator may do to the row under it. +//! +//! The tree itself is built in the SDK ([`medulla::ui::hosts`]); everything here +//! is the app-side half — where the local hosts come from, how the flat cursor +//! maps onto a two-level tree, and how an edit reaches disk +//! ([`edit`](self::edit)). +//! +//! The page lists hosts, not workers. That distinction is the whole point: one +//! machine now declares one agent per `harness × workspace`, so the flat roster +//! the page used to render was a list of agents with the host level collapsed +//! out of it — and a fleet you cannot see the shape of is one you cannot manage. + +use medulla::config::LocalHostRef; +use medulla::ui::hosts::{host_rows, HostAgentRow, HostKind, HostRow}; + +use super::types::App; + +mod edit; + +#[cfg(test)] +mod tests; + +/// One line on the Hosts page: a host header or one agent under it. +/// +/// Both are selectable, because both answer different questions — a host row +/// previews the *machine* (capacity, readiness, budgets), an agent row previews +/// the agent and owns its role toggles. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::ui::app) struct HostsRow { + /// Index into the host tree. + pub host: usize, + /// Index into that host's agents, or `None` for the host's own header row. + pub agent: Option, +} + +/// Flatten the tree into the rows the list draws and the cursor walks. +pub(in crate::ui::app) fn flatten(tree: &[HostRow]) -> Vec { + let mut rows = Vec::new(); + for (host, entry) in tree.iter().enumerate() { + rows.push(HostsRow { host, agent: None }); + rows.extend((0..entry.agents.len()).map(|agent| HostsRow { + host, + agent: Some(agent), + })); + } + rows +} + +impl App { + /// The hosts this machine runs, as the tab lists them. + /// + /// Config is the source, so a host that is declared but not running is still + /// present — that is the state where the operator most needs to see it. A + /// *running* primary overrides its own identity from the live observation: + /// `[host].workspace` is usually blank ("wherever medulla was launched") and + /// only the running host has resolved it. + pub(in crate::ui::app) fn local_host_refs(&self) -> Vec { + let mut hosts = + medulla::config::local_hosts(&self.loaded.config.host, &self.loaded.config.hosts); + if let (Some(observation), Some(primary)) = (self.host_obs.as_ref(), hosts.first_mut()) { + primary.id = observation.address().to_string(); + primary.workspace = observation.workspace().to_string(); + primary.name = medulla::config::local_host_name( + &self.loaded.config.host, + observation.workspace(), + true, + ); + } + // A host this device is not serving is not a host. It stays listed only + // while it still holds something — declared agents, or roster entries + // that outlived the switch — because dropping those would hide agents + // the operator wrote down rather than tidying the page. + let sections = std::iter::once(&self.loaded.config.host).chain(&self.loaded.config.hosts); + let hosting = self.host_obs.is_some(); + hosts + .into_iter() + .zip(sections) + .filter(|(host, section)| { + section.enabled + || (host.primary && hosting) + || self.declares_agent_on(&host.id) + || self + .runtime + .workers() + .iter() + .any(|worker| worker.address.trim() == host.id) + }) + .map(|(host, _)| host) + .collect() + } + + /// Whether this machine's config declares any agent on `host_id`. + fn declares_agent_on(&self, host_id: &str) -> bool { + self.loaded + .config + .fleet + .agent_declarations + .iter() + .any(|declaration| declaration.on_host(host_id)) + } + + /// The `Host → Agents` tree the page renders. + pub(in crate::ui::app) fn host_tree(&self) -> Vec { + host_rows( + &self.runtime.workers(), + &self.loaded.config.fleet.agent_declarations, + &self.local_host_refs(), + ) + } + + /// The flattened rows, with the cursor clamped into range. + /// + /// Returned together because every caller needs both and clamping against a + /// stale length is how a cursor ends up pointing at a row that is no longer + /// there. + pub(in crate::ui::app) fn hosts_view(&self) -> (Vec, Vec, usize) { + let tree = self.host_tree(); + let rows = flatten(&tree); + let selected = self.host_index.min(rows.len().saturating_sub(1)); + (tree, rows, selected) + } + + /// How many rows the page lists. Test/inspection seam. + pub fn hosts_row_count(&self) -> usize { + flatten(&self.host_tree()).len() + } + + /// The host under the cursor — the header itself, or the host of the agent + /// row the cursor is on. + pub(in crate::ui::app) fn selected_host_row(&self) -> Option { + let (tree, rows, selected) = self.hosts_view(); + tree.get(rows.get(selected)?.host).cloned() + } + + /// The agent under the cursor, when the cursor is on an agent row. + /// Also a test/inspection seam. + pub fn selected_host_agent(&self) -> Option { + let (tree, rows, selected) = self.hosts_view(); + let row = rows.get(selected)?; + tree.get(row.host)?.agents.get(row.agent?).cloned() + } + + /// Whether the cursor is on a host header rather than an agent. + /// Test/inspection seam. + pub fn hosts_cursor_on_host(&self) -> bool { + let (_, rows, selected) = self.hosts_view(); + rows.get(selected).is_some_and(|row| row.agent.is_none()) + } + + /// The roster entry the cursor's row acts on: the agent itself, or the entry + /// that probed the machine when the cursor is on a host header. + /// + /// This is what `Enter`, `d`, `e` and `r` target — every one of them is a + /// mutation of a *roster* entry, and a host that has none (declared here, + /// nothing running) has nothing for them to act on. + pub(in crate::ui::app) fn selected_host(&self) -> Option { + let (tree, rows, selected) = self.hosts_view(); + let row = rows.get(selected)?; + let host = tree.get(row.host)?; + let id = match row.agent { + Some(agent) => { + let agent = host.agents.get(agent)?; + agent.live.then(|| agent.agent_id.clone())? + } + None => host.detail_worker.clone()?, + }; + self.runtime + .workers() + .into_iter() + .find(|worker| worker.id == id) + } + + /// Whether the row under the cursor may be edited from here. + /// + /// The v1 capability split: a remote host's agents are declared on that + /// machine, so this end is a viewer. Orchestrator dispatch is untouched by + /// it — only what the *operator* can change from this terminal. + /// Test/inspection seam. + pub fn selected_host_is_local(&self) -> bool { + self.selected_host_row() + .is_some_and(|host| host.kind == HostKind::Local) + } +} diff --git a/src/tui/src/ui/app/hosts/tests.rs b/src/tui/src/ui/app/hosts/tests.rs new file mode 100644 index 000000000..762fccdba --- /dev/null +++ b/src/tui/src/ui/app/hosts/tests.rs @@ -0,0 +1,317 @@ +//! Unit tests for the Hosts page's tree, its cursor, and its edits. + +use std::sync::Arc; + +use medulla::config::LoadedConfig; +use medulla::runtime::mock::MockRuntime; +use medulla::runtime::{AgentDeclaration, WorkerInfo}; +use medulla::ui::hosts::HostKind; + +use super::super::types::{App, Cmd}; + +/// A roster entry at `address`, with no capability probe. +fn worker(id: &str, address: &str) -> WorkerInfo { + WorkerInfo { + id: id.into(), + address: address.into(), + handle: None, + label: None, + harness: Some("claude".into()), + workspace: Some("/w/checkout".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(), + } +} + +/// An app over `workers` and `declarations`, writing to a real config file so +/// the persistence path is exercised rather than stubbed. +fn app_with( + workers: Vec, + declarations: Vec, +) -> (App, tempfile::TempDir) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("medulla.tui.json"); + std::fs::write(&path, "{}").unwrap(); + let runtime = MockRuntime::empty(); + runtime.set_workers(workers); + let mut loaded = LoadedConfig::defaults(path.to_string_lossy().into_owned()); + loaded.config.fleet.agent_declarations = declarations; + let mut app = App::new(Arc::new(runtime), loaded); + app.set_config_path(path); + (app, dir) +} + +/// Move the cursor to the row `steps` below the top of the list. +fn cursor_to(app: &mut App, steps: usize) { + app.focus_routing_subpage("Hosts"); + for _ in 0..steps { + let _ = app.on_event(crossterm::event::Event::Key( + crossterm::event::KeyEvent::new( + crossterm::event::KeyCode::Down, + crossterm::event::KeyModifiers::NONE, + ), + )); + } +} + +#[test] +fn the_local_host_leads_the_tree_with_its_agents_under_it() { + let (app, _dir) = app_with( + vec![ + worker("medulla-claude", "this-device"), + worker("peer", "7Kx"), + ], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + + let tree = app.host_tree(); + assert_eq!(tree.len(), 2, "the local host and one remote: {tree:?}"); + assert_eq!(tree[0].id, "this-device"); + assert_eq!(tree[0].kind, HostKind::Local); + assert_eq!(tree[0].agents.len(), 1); + assert_eq!(tree[1].id, "7Kx"); + assert_eq!(tree[1].kind, HostKind::Remote); + // Four rows: two headers, two agents. + assert_eq!(app.hosts_row_count(), 4); +} + +#[test] +fn the_cursor_walks_hosts_and_the_agents_under_them() { + let (mut app, _dir) = app_with(vec![worker("peer", "7Kx")], Vec::new()); + cursor_to(&mut app, 0); + + // Row 0 is the local host header, and it is where a new agent may go. + assert!(app.hosts_cursor_on_host()); + assert!(app.selected_host_is_local()); + assert!(app.selected_host_agent().is_none()); + + // Row 1 is the remote host, row 2 the agent the roster knows on it. + cursor_to(&mut app, 1); + assert!(app.hosts_cursor_on_host()); + assert!(!app.selected_host_is_local()); + cursor_to(&mut app, 1); + assert_eq!( + app.selected_host_agent().map(|agent| agent.agent_id), + Some("peer".to_string()) + ); +} + +#[test] +fn a_role_toggle_is_written_to_the_declaration_and_survives_a_reload() { + let declaration = + AgentDeclaration::new("medulla-claude", "this-device", "claude", "/w/medulla"); + let (mut app, dir) = app_with( + vec![worker("medulla-claude", "this-device")], + vec![declaration], + ); + cursor_to(&mut app, 1); // the local host's only agent + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + let cmd = app.toggle_selected_agent_role(&role); + + // The live roster moves too, so the orchestrator starts routing the role + // here without waiting for a restart. + match cmd { + Some(Cmd::WorkerOp(medulla::runtime::WorkerOp::SetRoles { id, roles })) => { + assert_eq!(id, "medulla-claude"); + assert_eq!(roles, vec![role.clone()]); + } + other => panic!("expected a SetRoles op, got {other:?}"), + } + // And — the point of the whole exercise — the file has it. + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert_eq!(written.len(), 1); + assert_eq!(written[0].roles, vec![role.clone()]); + assert_eq!(written[0].harness, "claude"); + + // Toggling back sends an empty list and clears it on disk, so the checkbox + // is not one-way. + let cmd = app.toggle_selected_agent_role(&role); + match cmd { + Some(Cmd::WorkerOp(medulla::runtime::WorkerOp::SetRoles { roles, .. })) => { + assert!(roles.is_empty(), "removal sends an empty list: {roles:?}") + } + other => panic!("expected a SetRoles op, got {other:?}"), + } + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert!(written[0].roles.is_empty()); +} + +#[test] +fn giving_a_seeded_agent_a_role_declares_it() { + // The migration case: the roster has an agent nobody wrote down. A role must + // still persist, which means the toggle declares it from what the roster + // reports rather than refusing. + let (mut app, dir) = app_with(vec![worker("this-device", "this-device")], Vec::new()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_some()); + + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert_eq!(written.len(), 1, "the seed became a declaration"); + assert_eq!(written[0].agent_id, "this-device"); + assert_eq!(written[0].host_id, "this-device"); + assert_eq!(written[0].harness, "claude"); + assert_eq!(written[0].workspace.path, "/w/checkout"); + assert_eq!(written[0].roles, vec![role]); + // The in-memory config agrees with the file, so the row redraws assigned. + assert_eq!(app.loaded.config.fleet.agent_declarations.len(), 1); +} + +#[test] +fn a_remote_agent_is_read_only() { + let (mut app, dir) = app_with(vec![worker("peer", "7Kx")], Vec::new()); + cursor_to(&mut app, 2); // local header, remote header, remote agent + + let agent = app.selected_host_agent().expect("a remote agent row"); + assert!(!agent.editable); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!( + app.toggle_selected_agent_role(&role).is_none(), + "a remote agent's roles are that machine's to assign" + ); + assert!( + app.status().contains("declared on"), + "and the refusal says why: {}", + app.status() + ); + let written = medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")); + assert!(written.is_empty(), "nothing is declared for a remote host"); +} + +#[test] +fn a_failed_write_changes_nothing_at_all() { + // A UI showing a role the file does not have is worse than one that refused: + // the operator would believe the assignment survived the restart it will not. + let (mut app, dir) = app_with( + vec![worker("medulla-claude", "this-device")], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + // A directory cannot be written as a config file. + app.set_config_path(dir.path().to_path_buf()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_none()); + assert!( + app.status().starts_with("Roles were not saved"), + "status: {}", + app.status() + ); + assert!( + app.loaded.config.fleet.agent_declarations[0] + .roles + .is_empty(), + "the in-memory list must not drift from the file" + ); +} + +#[test] +fn an_agent_with_no_harness_cannot_be_declared_by_a_role_toggle() { + // Declaring an agent with no harness would advertise a placement that cannot + // run, so the toggle says so instead of inventing one. + let mut bare = worker("mystery", "this-device"); + bare.harness = None; + let (mut app, dir) = app_with(vec![bare], Vec::new()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_none()); + assert!(app.status().contains("no harness"), "{}", app.status()); + assert!( + medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")).is_empty() + ); +} + +#[test] +fn undeclaring_an_agent_removes_it_from_the_file_only() { + let (mut app, dir) = app_with( + Vec::new(), + vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )], + ); + cursor_to(&mut app, 1); + + assert!(app.undeclare_selected_agent()); + assert!( + medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")).is_empty() + ); + assert!(app.loaded.config.fleet.agent_declarations.is_empty()); + // An agent the roster owns but this machine never declared is not ours to + // undeclare — that path removes the roster entry instead. + let (mut remote, _dir) = app_with(vec![worker("peer", "7Kx")], Vec::new()); + cursor_to(&mut remote, 2); + assert!(!remote.undeclare_selected_agent()); +} + +#[test] +fn a_host_this_device_does_not_serve_drops_out_unless_it_still_holds_something() { + let (mut app, _dir) = app_with(Vec::new(), Vec::new()); + assert_eq!(app.host_tree().len(), 1, "hosting is on by default"); + + app.loaded.config.host.enabled = false; + assert!( + app.host_tree().is_empty(), + "nothing declared, nothing running, not hosting" + ); + + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )]; + assert_eq!( + app.host_tree().len(), + 1, + "declared agents keep their host listed" + ); +} diff --git a/src/tui/src/ui/app/keys/routing/mod.rs b/src/tui/src/ui/app/keys/routing/mod.rs index c4210a7a9..38bf9f456 100644 --- a/src/tui/src/ui/app/keys/routing/mod.rs +++ b/src/tui/src/ui/app/keys/routing/mod.rs @@ -72,7 +72,11 @@ impl App { } } - /// Browse and mutate the registered host roster. + /// Browse the `Host → Agents` tree and mutate the row under the cursor. + /// + /// The cursor walks hosts *and* the agents under them, because they answer + /// different questions: a host row is the machine (its capacity, and where a + /// new agent would go), an agent row is the thing a dispatch targets. fn hosts_key(&mut self, code: KeyCode) -> RoutingKey { // The preview's role toggles are a second cursor on the same page, so // they claim the arrows while focused. `→` drills in and `←` backs out, @@ -85,32 +89,17 @@ impl App { } match code { KeyCode::Up | KeyCode::Char('k') => { - self.host_index = crate::ui::selection::moved( - self.host_index, - self.runtime.workers().len(), - true, - ); + self.host_index = + crate::ui::selection::moved(self.host_index, self.hosts_row_count(), true); RoutingKey::Handled(None) } KeyCode::Down | KeyCode::Char('j') => { - self.host_index = crate::ui::selection::moved( - self.host_index, - self.runtime.workers().len(), - false, - ); + self.host_index = + crate::ui::selection::moved(self.host_index, self.hosts_row_count(), false); RoutingKey::Handled(None) } KeyCode::Right | KeyCode::Char('l') => { - if self.selected_host().is_none() { - return RoutingKey::Handled(None); - } - if self.agent_templates().is_empty() { - self.set_status("No agent templates are declared — nothing to assign"); - return RoutingKey::Handled(None); - } - self.host_roles_focus = true; - self.host_role_index = 0; - self.set_status("Roles · Space toggles · ← back to the host list"); + self.open_agent_roles(); RoutingKey::Handled(None) } KeyCode::Char('a') => { @@ -118,6 +107,10 @@ impl App { self.open_add_host_prompt(); RoutingKey::Handled(None) } + KeyCode::Char('n') => { + self.new_agent_from_hosts(); + RoutingKey::Handled(None) + } KeyCode::Char('s') | KeyCode::Enter => { let cmd = self.selected_host().map(|worker| { self.set_status(format!( @@ -128,16 +121,7 @@ impl App { }); RoutingKey::Handled(cmd) } - KeyCode::Char('d') | KeyCode::Char('x') => { - let cmd = self.selected_host().map(|worker| { - self.set_status(format!( - "Removing {}", - worker.label.as_deref().unwrap_or(&worker.address) - )); - Cmd::WorkerOp(WorkerOp::Remove { id: worker.id }) - }); - RoutingKey::Handled(cmd) - } + KeyCode::Char('d') | KeyCode::Char('x') => RoutingKey::Handled(self.remove_host_row()), KeyCode::Char('e') => { if let Some(worker) = self.selected_host() { let mut draft = Draft::new(); @@ -164,11 +148,98 @@ impl App { } } - /// Drive the selected host's role toggles. + /// Give the arrows to the selected agent's role toggles, or say why not. /// - /// `None` means the key was not a role-list key and the host roster should - /// see it — so `a`, `r`, `d` and the rest keep working without leaving the - /// preview first. + /// Roles belong to an *agent*, not to a machine — a laptop is not "the + /// reviewer", the agent working in the reviewed checkout is. So the toggles + /// open on an agent row only, and only where the declaration behind them can + /// be written: on a remote host they are that machine's to assign. + fn open_agent_roles(&mut self) { + let Some(agent) = self.selected_host_agent() else { + if self.selected_host_row().is_some() { + self.set_status("Select an agent (↑↓) to assign its roles"); + } + return; + }; + if !agent.editable { + let host = self + .selected_host_row() + .map(|host| host.label) + .unwrap_or_else(|| "that machine".into()); + self.set_status(format!( + "{} is declared on {host} — assign its roles there", + agent.agent_id + )); + return; + } + if self.agent_templates().is_empty() { + self.set_status("No agent templates are declared — nothing to assign"); + return; + } + self.host_roles_focus = true; + self.host_role_index = 0; + self.set_status("Roles · Space toggles · ← back to the list"); + } + + /// Point the operator at where an agent is created — and, on a remote host, + /// explain why it cannot be created from here. + /// + /// Declaring an agent is the Agents tab's flow (it needs the harness picker + /// and a workspace); this page owns the *capability*, which is why the key + /// answers on both kinds of host rather than being silently inert on one. + fn new_agent_from_hosts(&mut self) { + let Some(host) = self.selected_host_row() else { + return; + }; + if !host.accepts_new_agents() { + self.set_status(format!( + "Agents are declared on {} itself — this end is read-only", + host.label + )); + return; + } + match crate::ui::app::TABS.iter().position(|tab| *tab == "Agents") { + Some(index) => { + self.tab_index = index; + self.set_status(format!("New agent on {} · pick a harness", host.label)); + } + None => self.set_status("Declare a new agent from the Agents tab"), + } + } + + /// Remove what the cursor is on: an agent, or a whole remote host. + /// + /// An agent this machine declared is *undeclared* first — dropping only the + /// roster entry would leave the declaration behind to re-create it at the + /// next launch, which reads as a removal that did not take. + fn remove_host_row(&mut self) -> Option { + // Both reads happen before the undeclare: removing a declaration + // reshapes the tree under the cursor, and resolving the roster entry + // afterwards would answer for whichever row slid into its place. + let agent = self.selected_host_agent(); + let worker = self.selected_host(); + let undeclared = self.undeclare_selected_agent(); + match (agent, worker) { + // Declared, not running: the declaration was the whole of it. + (Some(_), None) => None, + (_, Some(worker)) => { + if !undeclared { + self.set_status(format!( + "Removing {}", + worker.label.as_deref().unwrap_or(&worker.address) + )); + } + Some(Cmd::WorkerOp(WorkerOp::Remove { id: worker.id })) + } + (None, None) => None, + } + } + + /// Drive the selected agent's role toggles. + /// + /// `None` means the key was not a role-list key and the list should see it — + /// so `a`, `r`, `d` and the rest keep working without leaving the preview + /// first. fn host_roles_key(&mut self, code: KeyCode) -> Option { let templates = self.agent_templates(); // The catalog can empty out under us (a template file is deleted, a @@ -194,28 +265,12 @@ impl App { Some(RoutingKey::Handled(None)) } KeyCode::Char(' ') | KeyCode::Enter => { - let host = self.selected_host()?; let role = templates.get(self.host_role_index)?.id.clone(); - // Whole-list replacement, so the toggle reads the current set, - // flips one entry, and sends the result. Roles ride the hub's - // descriptor, so this re-registers — the point is that the - // orchestrator starts routing this role here. - let mut roles = host.roles.clone(); - let assigned = if let Some(at) = roles.iter().position(|held| held == &role) { - roles.remove(at); - false - } else { - roles.push(role.clone()); - true - }; - self.set_status(if assigned { - format!("{} now offered for {role}", host.id) - } else { - format!("{} no longer offered for {role}", host.id) - }); - Some(RoutingKey::Handled(Some(Cmd::WorkerOp( - WorkerOp::SetRoles { id: host.id, roles }, - )))) + // Whole-list replacement written to the declaration first: the + // roster is rebuilt from declarations every launch, so a role + // set only on the live entry is one the operator watches take + // effect and then loses. + Some(RoutingKey::Handled(self.toggle_selected_agent_role(&role))) } _ => None, } diff --git a/src/tui/src/ui/app/mod.rs b/src/tui/src/ui/app/mod.rs index b7b19652a..8f1e91652 100644 --- a/src/tui/src/ui/app/mod.rs +++ b/src/tui/src/ui/app/mod.rs @@ -8,7 +8,8 @@ //! [`feedback`] the feedback-board subpage's actions and setters, //! [`settings_edit`] the Config subpage's editable settings, [`account`] the //! logout action, [`templates`] the agent-template store's install/reload -//! actions, and [`render`] the ratatui draw for each view. Public items +//! actions, [`hosts`] the Hosts page's `Host → Agents` tree and its edits, +//! and [`render`] the ratatui draw for each view. Public items //! are re-exported here so callers use `crate::ui::app::*`. mod account; @@ -25,6 +26,7 @@ mod harness_control_tests; mod harness_workspace; #[cfg(test)] mod harness_workspace_tests; +mod hosts; mod input; mod keys; mod overlays; diff --git a/src/tui/src/ui/app/render/routing/hosts.rs b/src/tui/src/ui/app/render/routing/hosts.rs deleted file mode 100644 index 9d7ca0f76..000000000 --- a/src/tui/src/ui/app/render/routing/hosts.rs +++ /dev/null @@ -1,387 +0,0 @@ -//! Registered host list and fleet actions. - -use ratatui::layout::{Constraint, Layout, Rect}; -use ratatui::style::{Color, Modifier, Style}; -use ratatui::text::{Line as TLine, Span, Text}; -use ratatui::widgets::Paragraph; -use ratatui::Frame; - -use medulla::protocol::{BudgetWindow, HarnessBudget, HarnessReadiness}; - -use super::super::super::types::App; - -impl App { - /// Draw registered hosts as a list above a preview of the selected one. - /// - /// The split is what makes roles assignable. Folding every host's capacity, - /// readiness and budgets inline cost two rows apiece — mostly reading - /// "details not captured" — and left nowhere for a toggle list that belongs - /// to *one* host. One row per host up top, everything about the selected one - /// below, and the preview follows the cursor as it moves. - pub(super) fn draw_hosts(&mut self, f: &mut Frame, area: Rect) { - // `Runtime::workers()` keeps the name the serve/hub wire uses; what it - // returns is the host level of the containment chain, which is what this - // page shows and what the rest of the UI now calls it. - let hosts = self.runtime.workers(); - let selected = self.host_index.min(hosts.len().saturating_sub(1)); - self.host_index = selected; - - // Give the preview at most half the page, and never more than the - // selected host actually has to say. A short roster on a tall terminal - // should not push its list into a strip. - let preview_rows = hosts - .get(selected) - .map(|host| self.preview_lines(host).len() as u16 + 2) - .unwrap_or(0) - .min(area.height / 2); - let [list_area, preview_area] = Layout::vertical([ - Constraint::Min(3), - Constraint::Length(if hosts.is_empty() { 0 } else { preview_rows }), - ]) - .areas(area); - - self.draw_host_list(f, list_area, &hosts, selected); - if !hosts.is_empty() { - self.draw_host_preview(f, preview_area, &hosts[selected]); - } - } - - /// The roster itself: one row per host, identity only. - fn draw_host_list( - &mut self, - f: &mut Frame, - area: Rect, - hosts: &[medulla::runtime::WorkerInfo], - selected: usize, - ) { - let block = self.panel(format!("Hosts · {}", hosts.len())); - let inner = block.inner(area); - f.render_widget(block, area); - let mut lines = Vec::new(); - if hosts.is_empty() { - lines.push(TLine::from(Span::styled( - "No hosts registered. Open Add Host to connect a remote machine.", - dim(), - ))); - } else { - // Reserve the footer (and optional hub identity) before choosing the - // host window so the selected row and action hints stay visible. - let footer_rows = 1 + usize::from(self.snapshot.link.is_some()) * 2; - let visible = usize::from(inner.height).saturating_sub(footer_rows).max(1); - let start = crate::ui::selection::viewport_start(selected, hosts.len(), visible); - for (index, host) in hosts.iter().enumerate().skip(start).take(visible) { - let selected_default = if host.selected { "●" } else { " " }; - let handle = host.handle.as_deref().unwrap_or(&host.address); - let label = host - .label - .as_deref() - .map(|value| format!(" · {value}")) - .unwrap_or_default(); - let harness = host - .harness - .as_deref() - .map(|value| format!(" · {}", value.to_uppercase())) - .unwrap_or_default(); - let roles = match host.roles.len() { - 0 => String::new(), - 1 => " · 1 role".to_string(), - count => format!(" · {count} roles"), - }; - let mut style = if host.selected { - Style::default().fg(Color::Green) - } else { - Style::default() - }; - // While the roles toggle has focus the list still marks its row, - // but dimly — two lit cursors on one page read as two selections. - if index == selected { - style = if self.host_roles_focus { - style.add_modifier(Modifier::BOLD) - } else { - self.theme.selection() - }; - } - lines.push(TLine::from(Span::styled( - format!( - "{selected_default} {} · {handle}{label}{harness}{roles}", - host.id - ), - style, - ))); - } - } - if let Some(identity) = &self.snapshot.link { - lines.push(TLine::from("")); - lines.push(TLine::from(vec![ - Span::styled("this hub · ", Style::default().fg(Color::Cyan)), - Span::raw(identity.node_name.clone()), - ])); - } - lines.push(TLine::from(Span::styled( - "↑↓/jk browse · → roles · r refresh · a add · Enter/s select · e edit · d/x remove", - dim(), - ))); - f.render_widget(Paragraph::new(Text::from(lines)), inner); - } - - /// Everything known about the selected host, plus its role toggles. - fn draw_host_preview( - &mut self, - f: &mut Frame, - area: Rect, - host: &medulla::runtime::WorkerInfo, - ) { - let block = self.panel(format!("Host · {}", host.id)); - let inner = block.inner(area); - f.render_widget(block, area); - // Draw to the height the pane actually got, which is what lets the role - // list scroll rather than run off the bottom of a short terminal. - let lines = self.preview_lines_within(host, Some(inner.height as usize)); - f.render_widget(Paragraph::new(Text::from(lines)), inner); - } - - /// The preview at its natural height, used to size the pane. - fn preview_lines(&self, host: &medulla::runtime::WorkerInfo) -> Vec> { - self.preview_lines_within(host, None) - } - - /// Build the preview body, windowing the role list when `budget` rows is - /// less than it needs. Shared with the height calculation so the pane is - /// sized to what it will actually draw rather than a guess. - fn preview_lines_within( - &self, - host: &medulla::runtime::WorkerInfo, - budget: Option, - ) -> Vec> { - let mut lines = Vec::new(); - lines.push(TLine::from(vec![ - Span::styled("workspace ", dim()), - Span::raw( - host.workspace - .clone() - .unwrap_or_else(|| "not reported".into()), - ), - ])); - let capacity = match ( - host.ip_address.as_deref(), - host.cpu_cores, - host.memory_available_bytes, - host.memory_total_bytes, - ) { - (None, None, None, None) => "details not captured · press r to refresh".into(), - (ip, cpu, available, total) => format!( - "IP {} · CPU {} · RAM {} available / {} total", - ip.unwrap_or("unknown"), - cpu.map(|cores| format!("{cores} cores")) - .unwrap_or_else(|| "unknown".into()), - available - .map(format_bytes) - .unwrap_or_else(|| "unknown".into()), - total.map(format_bytes).unwrap_or_else(|| "unknown".into()), - ), - }; - lines.push(TLine::from(vec![ - Span::styled("capacity ", dim()), - Span::raw(capacity), - ])); - if let Some(note) = readiness_summary(&host.readiness) { - lines.push(TLine::from(vec![ - Span::styled("harnesses ", dim()), - Span::raw(note), - ])); - } - if let Some(note) = budget_summary(&host.budgets) { - lines.push(TLine::from(vec![ - Span::styled("budgets ", dim()), - Span::raw(note), - ])); - } - // Whatever rows the detail above did not use are the role list's to fill. - let role_budget = budget.map(|rows| rows.saturating_sub(lines.len())); - lines.extend(self.role_lines(host, role_budget)); - lines - } - - /// The role toggle list. Roles come from the agent-template catalog, so a - /// host can only be offered for a role this hub actually knows how to brief. - /// - /// `budget` caps the rows; the window follows the cursor so a role can never - /// be selected but off-screen. - fn role_lines( - &self, - host: &medulla::runtime::WorkerInfo, - budget: Option, - ) -> Vec> { - let templates = self.agent_templates(); - if templates.is_empty() { - return vec![TLine::from(vec![ - Span::styled("roles ", dim()), - Span::styled("no agent templates are declared".to_string(), dim()), - ])]; - } - // The summary leads, because "none assigned" is the state most hosts are - // in and it must not read as a machine excluded from every role. Trailing - // it under a dozen checkboxes buried exactly the line that says otherwise. - let summary = if host.roles.is_empty() { - "none assigned · offered for any role".to_string() - } else { - host.roles.join(", ") - }; - let mut lines = vec![TLine::from(vec![ - Span::styled("roles ", dim()), - Span::styled( - summary, - if host.roles.is_empty() { - dim() - } else { - Style::default().fg(Color::Green) - }, - ), - ])]; - // One row is already spent on the summary. - let visible = budget - .map(|rows| rows.saturating_sub(1).max(1)) - .unwrap_or(templates.len()) - .min(templates.len()); - let start = - crate::ui::selection::viewport_start(self.host_role_index, templates.len(), visible); - for (index, template) in templates.iter().enumerate().skip(start).take(visible) { - let assigned = host.roles.iter().any(|role| role == &template.id); - let mark = if assigned { "[x]" } else { "[ ]" }; - let cursor = if self.host_roles_focus && index == self.host_role_index { - "▸" - } else { - " " - }; - let mut style = if assigned { - Style::default().fg(Color::Green) - } else { - dim() - }; - if self.host_roles_focus && index == self.host_role_index { - style = self.theme.selection(); - } - lines.push(TLine::from(vec![ - Span::styled(" ", dim()), - Span::styled(format!("{cursor} {mark} {}", template.id), style), - ])); - } - lines - } -} - -/// Format a byte count for a compact host-capacity row. -fn format_bytes(bytes: u64) -> String { - const GIB: f64 = 1024.0 * 1024.0 * 1024.0; - const MIB: f64 = 1024.0 * 1024.0; - if bytes >= GIB as u64 { - format!("{:.1} GiB", bytes as f64 / GIB) - } else { - format!("{:.0} MiB", bytes as f64 / MIB) - } -} - -/// Shared subdued style for host detail rows. -fn dim() -> Style { - Style::default().add_modifier(Modifier::DIM) -} - -/// Strip control characters from probe-supplied text before it reaches a ratatui -/// span. Readiness reasons arrive from a remote host over tiny.place, so a -/// compromised or malicious peer could otherwise smuggle terminal escape/OSC -/// sequences (cursor moves, title rewrites) into the operator's terminal. -fn inline_text(value: &str) -> String { - value.chars().filter(|c| !c.is_control()).collect() -} - -/// A compact per-harness readiness line, e.g. -/// `ready claude · not-ready codex (not authenticated)`. `None` when the host -/// advertised no readiness. Display-only; readiness is heuristic and advisory. -/// The reason is untrusted peer text, so it is sanitized before rendering and a -/// reason that sanitizes to empty is dropped. -fn readiness_summary(items: &[HarnessReadiness]) -> Option { - if items.is_empty() { - return None; - } - let parts: Vec = items - .iter() - .map(|r| { - let provider = r.provider.as_str(); - if r.ready { - format!("ready {provider}") - } else if let Some(reason) = r - .reason - .as_deref() - .map(inline_text) - .filter(|s| !s.is_empty()) - { - format!("not-ready {provider} ({reason})") - } else { - format!("not-ready {provider}") - } - }) - .collect(); - Some(parts.join(" · ")) -} - -/// A compact per-harness budget line carrying headroom, window, and cooldown, -/// e.g. `codex 1.5k left (weekly) · claude cooldown 1893456000`. Entries with no -/// usable signal (a pure estimate: no numbers, no window, no cooldown) are -/// omitted; `None` when nothing is worth showing. -fn budget_summary(items: &[HarnessBudget]) -> Option { - let parts: Vec = items.iter().filter_map(budget_line).collect(); - if parts.is_empty() { - None - } else { - Some(parts.join(" · ")) - } -} - -/// One provider's budget segment, or `None` when it carries no usable signal. -fn budget_line(b: &HarnessBudget) -> Option { - let window = window_label(b.window); - if b.remaining_tokens.is_none() && b.cooldown_until.is_none() && window.is_none() { - return None; // a bare estimate — nothing concrete to show. - } - let mut seg = b.provider.as_str().to_string(); - if let Some(remaining) = b.remaining_tokens { - seg.push_str(&format!(" {} left", fmt_tokens(remaining))); - } - if let Some(window) = window { - seg.push_str(&format!(" ({window})")); - } - if let Some(until) = b.cooldown_until { - seg.push_str(&format!(" · cooldown {until}")); - } - Some(seg) -} - -/// The short label for a metering window, or `None` for `Unknown`. -fn window_label(window: BudgetWindow) -> Option<&'static str> { - match window { - BudgetWindow::Daily => Some("daily"), - BudgetWindow::Weekly => Some("weekly"), - BudgetWindow::FiveHour => Some("5h"), - BudgetWindow::Unknown => None, - } -} - -/// Compact token count that scales into thousands/millions (`980` · `1.5k` · -/// `1.2M`). Negative inputs (never expected) clamp to zero. -fn fmt_tokens(tokens: i64) -> String { - let tokens = tokens.max(0) as u64; - if tokens >= 1_000_000 { - format!("{:.1}M", tokens as f64 / 1_000_000.0) - } else if tokens >= 1_000 { - // Keep one fractional digit so `1_500` reads `1.5k`, not a rounded `2k` - // that would overstate remaining headroom; drop it for whole thousands. - let thousands = tokens as f64 / 1_000.0; - if thousands.fract() == 0.0 { - format!("{}k", thousands as u64) - } else { - format!("{thousands:.1}k") - } - } else { - tokens.to_string() - } -} diff --git a/src/tui/src/ui/app/render/routing/hosts/format.rs b/src/tui/src/ui/app/render/routing/hosts/format.rs new file mode 100644 index 000000000..b8df74ea2 --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/format.rs @@ -0,0 +1,124 @@ +//! Formatting shared by the host tree and its preview: byte counts, token +//! headroom, the subdued style, and the two probe-reported summaries. + +use ratatui::style::{Modifier, Style}; + +use medulla::protocol::{BudgetWindow, HarnessBudget, HarnessReadiness}; + +/// Format a byte count for a compact host-capacity row. +pub(super) fn format_bytes(bytes: u64) -> String { + const GIB: f64 = 1024.0 * 1024.0 * 1024.0; + const MIB: f64 = 1024.0 * 1024.0; + if bytes >= GIB as u64 { + format!("{:.1} GiB", bytes as f64 / GIB) + } else { + format!("{:.0} MiB", bytes as f64 / MIB) + } +} + +/// Shared subdued style for host detail rows. +pub(super) fn dim() -> Style { + Style::default().add_modifier(Modifier::DIM) +} + +/// Strip control characters from text before it reaches a ratatui span. +/// +/// Readiness reasons, labels, ids and workspace paths arrive from a remote host +/// over tiny.place, so a compromised or malicious peer could otherwise smuggle +/// terminal escape/OSC sequences (cursor moves, title rewrites) into the +/// operator's terminal. +pub(super) fn inline_text(value: &str) -> String { + value.chars().filter(|c| !c.is_control()).collect() +} + +/// A compact per-harness readiness line, e.g. +/// `ready claude · not-ready codex (not authenticated)`. `None` when the host +/// advertised no readiness. Display-only; readiness is heuristic and advisory. +/// The reason is untrusted peer text, so it is sanitized before rendering and a +/// reason that sanitizes to empty is dropped. +pub(super) fn readiness_summary(items: &[HarnessReadiness]) -> Option { + if items.is_empty() { + return None; + } + let parts: Vec = items + .iter() + .map(|r| { + let provider = r.provider.as_str(); + if r.ready { + format!("ready {provider}") + } else if let Some(reason) = r + .reason + .as_deref() + .map(inline_text) + .filter(|s| !s.is_empty()) + { + format!("not-ready {provider} ({reason})") + } else { + format!("not-ready {provider}") + } + }) + .collect(); + Some(parts.join(" · ")) +} + +/// A compact per-harness budget line carrying headroom, window, and cooldown, +/// e.g. `codex 1.5k left (weekly) · claude cooldown 1893456000`. Entries with no +/// usable signal (a pure estimate: no numbers, no window, no cooldown) are +/// omitted; `None` when nothing is worth showing. +pub(super) fn budget_summary(items: &[HarnessBudget]) -> Option { + let parts: Vec = items.iter().filter_map(budget_line).collect(); + if parts.is_empty() { + None + } else { + Some(parts.join(" · ")) + } +} + +/// One provider's budget segment, or `None` when it carries no usable signal. +fn budget_line(b: &HarnessBudget) -> Option { + let window = window_label(b.window); + if b.remaining_tokens.is_none() && b.cooldown_until.is_none() && window.is_none() { + return None; // a bare estimate — nothing concrete to show. + } + let mut seg = b.provider.as_str().to_string(); + if let Some(remaining) = b.remaining_tokens { + seg.push_str(&format!(" {} left", fmt_tokens(remaining))); + } + if let Some(window) = window { + seg.push_str(&format!(" ({window})")); + } + if let Some(until) = b.cooldown_until { + seg.push_str(&format!(" · cooldown {until}")); + } + Some(seg) +} + +/// The short label for a metering window, or `None` for `Unknown`. +fn window_label(window: BudgetWindow) -> Option<&'static str> { + match window { + BudgetWindow::Daily => Some("daily"), + BudgetWindow::Weekly => Some("weekly"), + BudgetWindow::FiveHour => Some("5h"), + BudgetWindow::Unknown => None, + } +} + +/// Compact token count that scales into thousands/millions (`980` · `1.5k` · +/// `1.2M`). Negative inputs (never expected) clamp to zero. +fn fmt_tokens(tokens: i64) -> String { + let tokens = tokens.max(0) as u64; + if tokens >= 1_000_000 { + format!("{:.1}M", tokens as f64 / 1_000_000.0) + } else if tokens >= 1_000 { + // Keep one fractional digit so `1_500` reads `1.5k`, not a rounded `2k` + // that would overstate remaining headroom; drop it for whole thousands. + let thousands = tokens as f64 / 1_000.0; + if thousands.fract() == 0.0 { + format!("{}k", thousands as u64) + } else { + format!("{thousands:.1}k") + } + } else { + tokens.to_string() + } +} diff --git a/src/tui/src/ui/app/render/routing/hosts/list.rs b/src/tui/src/ui/app/render/routing/hosts/list.rs new file mode 100644 index 000000000..c134698b4 --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/list.rs @@ -0,0 +1,152 @@ +//! The tree itself: one row per host, then one per agent under it. + +use ratatui::layout::Rect; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line as TLine, Span, Text}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; + +use super::super::super::super::hosts::HostsRow; +use super::super::super::super::types::App; +use super::format::{dim, inline_text}; + +/// The action hints, split so a narrow terminal still shows the first line. +const FOOTER: &str = + "↑↓/jk browse · → roles · n new agent · a add host · r refresh · Enter/s select · e edit · d/x remove"; + +impl App { + /// Draw the host tree, windowed so the cursor stays visible. + pub(super) fn draw_host_list( + &mut self, + f: &mut Frame, + area: Rect, + tree: &[HostRow], + rows: &[HostsRow], + selected: usize, + ) { + let agents: usize = tree.iter().map(|host| host.agents.len()).sum(); + let block = self.panel(format!("Hosts · {} · agents · {agents}", tree.len())); + let inner = block.inner(area); + f.render_widget(block, area); + let mut lines = Vec::new(); + if rows.is_empty() { + lines.push(TLine::from(Span::styled( + "No hosts. This device is not hosting — open Add Host to connect a machine.", + dim(), + ))); + } else { + // Reserve the footer (and optional hub identity) before choosing the + // window, so the selected row and the action hints stay visible. + let footer_rows = 1 + usize::from(self.snapshot.link.is_some()) * 2; + let visible = usize::from(inner.height).saturating_sub(footer_rows).max(1); + let start = crate::ui::selection::viewport_start(selected, rows.len(), visible); + for (index, row) in rows.iter().enumerate().skip(start).take(visible) { + let Some(host) = tree.get(row.host) else { + continue; + }; + let (text, mut style) = match row.agent.and_then(|at| host.agents.get(at)) { + Some(agent) => (agent_line(agent), agent_style(agent)), + None => (host_line(host), host_style(host)), + }; + if index == selected { + // While the roles toggle has focus the list still marks its + // row, but dimly — two lit cursors on one page read as two + // selections. + style = if self.host_roles_focus { + style.add_modifier(Modifier::BOLD) + } else { + self.theme.selection() + }; + } + lines.push(TLine::from(Span::styled(text, style))); + } + } + if let Some(identity) = &self.snapshot.link { + lines.push(TLine::from("")); + lines.push(TLine::from(vec![ + Span::styled("this hub · ", Style::default().fg(Color::Cyan)), + Span::raw(identity.node_name.clone()), + ])); + } + lines.push(TLine::from(Span::styled(FOOTER, dim()))); + f.render_widget(Paragraph::new(Text::from(lines)), inner); + } +} + +/// A host header: what it is called, where it is, and how many agents it holds. +/// +/// A remote host says so on its own row rather than only in the preview: it is +/// the difference between a machine you can declare an agent on and one you can +/// only watch, and that must be legible without moving the cursor. +fn host_line(host: &HostRow) -> String { + let kind = match host.kind { + HostKind::Local => "local".to_string(), + HostKind::Remote => "remote · read-only".to_string(), + }; + let agents = match host.agents.len() { + 0 => "no agents".to_string(), + 1 => "1 agent".to_string(), + count => format!("{count} agents"), + }; + format!( + "▾ {} · {} · {kind} · {agents}", + inline_text(&host.label), + inline_text(&host.id) + ) +} + +/// An agent under its host: the id a dispatch targets, its harness, where it +/// works, and the roles it is offered for. +/// +/// Indented under the header, and marked when it is the manual default (`●`). +/// A declared agent the roster has no entry for is flagged rather than hidden: +/// it is why nothing is being dispatched to it. +fn agent_line(agent: &HostAgentRow) -> String { + let mark = if agent.selected { "●" } else { " " }; + let harness = agent + .harness + .as_deref() + .map(|value| format!(" · {}", inline_text(&value.to_uppercase()))) + .unwrap_or_default(); + let workspace = agent + .workspace + .as_deref() + .map(|value| format!(" · {}", inline_text(value))) + .unwrap_or_default(); + let roles = match agent.roles.len() { + 0 => String::new(), + 1 => " · 1 role".to_string(), + count => format!(" · {count} roles"), + }; + let state = match (agent.live, agent.declared) { + (false, _) => " · declared, not running", + (true, false) => " · undeclared", + (true, true) => "", + }; + format!( + " {mark} {}{harness}{workspace}{roles}{state}", + inline_text(&agent.agent_id) + ) +} + +/// A remote host reads dim: it is context, not something to act on. +fn host_style(host: &HostRow) -> Style { + match host.kind { + HostKind::Local => Style::default().fg(Color::Cyan), + HostKind::Remote => dim().fg(Color::Cyan), + } +} + +/// The default agent is green; one that is declared but not running is dim, +/// because it is not a thing the orchestrator can reach right now. +fn agent_style(agent: &HostAgentRow) -> Style { + if agent.selected { + Style::default().fg(Color::Green) + } else if !agent.live { + dim() + } else { + Style::default() + } +} diff --git a/src/tui/src/ui/app/render/routing/hosts/mod.rs b/src/tui/src/ui/app/render/routing/hosts/mod.rs new file mode 100644 index 000000000..b494677ea --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/mod.rs @@ -0,0 +1,51 @@ +//! The Hosts page: the `Host → Agents` tree above a preview of the row the +//! cursor is on. +//! +//! The page renders the topology the advert is a projection of (spec §2.4): the +//! hosts this machine runs — always present, running or not — then every remote +//! host the roster reaches, each with the agents known to be on it. It used to +//! render the worker roster flat and call each row a host, which was true only +//! while a machine advertised one worker; a machine now declares one agent per +//! `harness × workspace`, so that list was agents with the host level collapsed +//! out of it. +//! +//! The split into list + preview is what makes roles assignable: folding every +//! row's capacity, readiness and budgets inline cost two rows apiece — mostly +//! reading "details not captured" — and left nowhere for a toggle list that +//! belongs to *one* agent. + +use ratatui::layout::{Constraint, Layout, Rect}; +use ratatui::Frame; + +use super::super::super::types::App; + +mod format; +mod list; +mod preview; + +impl App { + /// Draw the host tree with a preview of the selected row beneath it. + pub(super) fn draw_hosts(&mut self, f: &mut Frame, area: Rect) { + let (tree, rows, selected) = self.hosts_view(); + self.host_index = selected; + + // Give the preview at most half the page, and never more than the + // selected row actually has to say. A short tree on a tall terminal + // should not push its list into a strip. + let preview_rows = rows + .get(selected) + .map(|row| self.preview_lines(&tree, *row).len() as u16 + 2) + .unwrap_or(0) + .min(area.height / 2); + let [list_area, preview_area] = Layout::vertical([ + Constraint::Min(3), + Constraint::Length(if rows.is_empty() { 0 } else { preview_rows }), + ]) + .areas(area); + + self.draw_host_list(f, list_area, &tree, &rows, selected); + if let Some(row) = rows.get(selected).copied() { + self.draw_host_preview(f, preview_area, &tree, row); + } + } +} diff --git a/src/tui/src/ui/app/render/routing/hosts/preview.rs b/src/tui/src/ui/app/render/routing/hosts/preview.rs new file mode 100644 index 000000000..1c943d09b --- /dev/null +++ b/src/tui/src/ui/app/render/routing/hosts/preview.rs @@ -0,0 +1,311 @@ +//! The preview under the tree: everything about the row the cursor is on. +//! +//! Two shapes, because the two row kinds answer different questions. A **host** +//! preview is the machine — the capacity, readiness and budgets its capability +//! probe reported, and whether an agent may be declared on it from here. An +//! **agent** preview is the thing a dispatch targets — its harness, workspace, +//! session bound, and the role toggles that are this page's one real edit. + +use ratatui::layout::Rect; +use ratatui::style::{Color, Style}; +use ratatui::text::{Line as TLine, Span, Text}; +use ratatui::widgets::Paragraph; +use ratatui::Frame; + +use medulla::runtime::WorkerInfo; +use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; + +use super::super::super::super::hosts::HostsRow; +use super::super::super::super::types::App; +use super::format::{budget_summary, dim, format_bytes, inline_text, readiness_summary}; + +impl App { + /// Draw the preview pane for the selected row. + pub(super) fn draw_host_preview( + &mut self, + f: &mut Frame, + area: Rect, + tree: &[HostRow], + row: HostsRow, + ) { + let Some(host) = tree.get(row.host) else { + return; + }; + let title = match row.agent.and_then(|at| host.agents.get(at)) { + Some(agent) => format!("Agent · {}", inline_text(&agent.agent_id)), + None => format!("Host · {}", inline_text(&host.label)), + }; + let block = self.panel(title); + let inner = block.inner(area); + f.render_widget(block, area); + // Draw to the height the pane actually got, which is what lets the role + // list scroll rather than run off the bottom of a short terminal. + let lines = self.preview_lines_within(tree, row, Some(inner.height as usize)); + f.render_widget(Paragraph::new(Text::from(lines)), inner); + } + + /// The preview at its natural height, used to size the pane. + pub(super) fn preview_lines(&self, tree: &[HostRow], row: HostsRow) -> Vec> { + self.preview_lines_within(tree, row, None) + } + + /// Build the preview body, windowing the role list when `budget` rows is + /// less than it needs. Shared with the height calculation so the pane is + /// sized to what it will actually draw rather than a guess. + fn preview_lines_within( + &self, + tree: &[HostRow], + row: HostsRow, + budget: Option, + ) -> Vec> { + let Some(host) = tree.get(row.host) else { + return Vec::new(); + }; + match row.agent.and_then(|at| host.agents.get(at)) { + Some(agent) => self.agent_preview(host, agent, budget), + None => self.host_preview(host), + } + } + + /// The machine: where it is, what it has, and what may be done to it here. + fn host_preview(&self, host: &HostRow) -> Vec> { + let mut lines = vec![TLine::from(vec![ + Span::styled("address ", dim()), + Span::raw(inline_text(&host.id)), + ])]; + let probe = host + .detail_worker + .as_deref() + .and_then(|id| self.runtime.workers().into_iter().find(|w| w.id == id)); + lines.push(TLine::from(vec![ + Span::styled("capacity ", dim()), + Span::raw(capacity_line(probe.as_ref())), + ])); + if let Some(note) = probe.as_ref().and_then(|w| readiness_summary(&w.readiness)) { + lines.push(TLine::from(vec![ + Span::styled("harnesses ", dim()), + Span::raw(note), + ])); + } + if let Some(note) = probe.as_ref().and_then(|w| budget_summary(&w.budgets)) { + lines.push(TLine::from(vec![ + Span::styled("budgets ", dim()), + Span::raw(note), + ])); + } + // The capability split, stated where the operator is looking. A remote + // host's agents are declared on that machine — this end can watch them + // and dispatch to them, and that is all (spec §2.4). + let (note, style) = match host.kind { + HostKind::Local if host.agents.is_empty() => ( + "none declared here · n declares one".to_string(), + Style::default().fg(Color::Green), + ), + HostKind::Local => ( + format!("{} declared here · n declares another", host.agents.len()), + Style::default().fg(Color::Green), + ), + HostKind::Remote if host.agents.is_empty() => ( + "none known · they are declared on that machine".to_string(), + dim(), + ), + HostKind::Remote => ( + format!( + "{} known to the roster · declared on that machine", + host.agents.len() + ), + dim(), + ), + }; + lines.push(TLine::from(vec![ + Span::styled("agents ", dim()), + Span::styled(note, style), + ])); + if host.kind == HostKind::Remote { + // Honest about the gap rather than papering over it: the host link + // does not exchange declared agent lists yet, so what is listed is + // whatever this hub happens to have in its own roster. + lines.push(TLine::from(Span::styled( + " this hub lists what its roster reaches, not that machine's declarations", + dim(), + ))); + } + lines + } + + /// The agent: what runs where, how many sessions it may hold, and its roles. + fn agent_preview( + &self, + host: &HostRow, + agent: &HostAgentRow, + budget: Option, + ) -> Vec> { + let mut lines = vec![ + TLine::from(vec![ + Span::styled("host ", dim()), + Span::raw(format!( + "{} · {}", + inline_text(&host.label), + match host.kind { + HostKind::Local => "local", + HostKind::Remote => "remote · read-only", + } + )), + ]), + TLine::from(vec![ + Span::styled("harness ", dim()), + Span::raw( + agent + .harness + .as_deref() + .map(inline_text) + .unwrap_or_else(|| "not reported".into()), + ), + ]), + TLine::from(vec![ + Span::styled("workspace ", dim()), + Span::raw( + agent + .workspace + .as_deref() + .map(inline_text) + .unwrap_or_else(|| "not reported".into()), + ), + ]), + ]; + let sessions = match agent.max_sessions { + Some(1) => "1 at a time · checkout".to_string(), + Some(max) => format!("{max} at a time"), + None => "not declared here".to_string(), + }; + lines.push(TLine::from(vec![ + Span::styled("sessions ", dim()), + Span::raw(sessions), + ])); + let state = match (agent.declared, agent.live) { + (true, true) => "declared · in the roster", + (true, false) => "declared · not running", + (false, true) => "in the roster · not declared here", + (false, false) => "not declared, not running", + }; + lines.push(TLine::from(vec![ + Span::styled("state ", dim()), + Span::raw(state), + ])); + // Whatever rows the detail above did not use are the role list's to fill. + let role_budget = budget.map(|rows| rows.saturating_sub(lines.len())); + lines.extend(self.role_lines(agent, role_budget)); + lines + } + + /// The role toggle list. Roles come from the agent-template catalog, so an + /// agent can only be offered for a role this hub actually knows how to + /// brief. + /// + /// `budget` caps the rows; the window follows the cursor so a role can never + /// be selected but off-screen. A remote agent gets the summary and no + /// checkboxes: its roles are assigned on the machine that declares it. + fn role_lines(&self, agent: &HostAgentRow, budget: Option) -> Vec> { + // The summary leads, because "none assigned" is the state most agents + // are in and it must not read as one excluded from every role. Trailing + // it under a dozen checkboxes buried exactly the line that says + // otherwise. + let summary = if agent.roles.is_empty() { + "none assigned · offered for any role".to_string() + } else { + agent + .roles + .iter() + .map(|role| inline_text(role)) + .collect::>() + .join(", ") + }; + let mut lines = vec![TLine::from(vec![ + Span::styled("roles ", dim()), + Span::styled( + summary, + if agent.roles.is_empty() { + dim() + } else { + Style::default().fg(Color::Green) + }, + ), + ])]; + if !agent.editable { + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled( + "read-only · assign roles on that machine".to_string(), + dim(), + ), + ])); + return lines; + } + let templates = self.agent_templates(); + if templates.is_empty() { + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled("no agent templates are declared".to_string(), dim()), + ])); + return lines; + } + // One row is already spent on the summary. + let visible = budget + .map(|rows| rows.saturating_sub(1).max(1)) + .unwrap_or(templates.len()) + .min(templates.len()); + let start = + crate::ui::selection::viewport_start(self.host_role_index, templates.len(), visible); + for (index, template) in templates.iter().enumerate().skip(start).take(visible) { + let assigned = agent.roles.iter().any(|role| role == &template.id); + let mark = if assigned { "[x]" } else { "[ ]" }; + let cursor = if self.host_roles_focus && index == self.host_role_index { + "▸" + } else { + " " + }; + let mut style = if assigned { + Style::default().fg(Color::Green) + } else { + dim() + }; + if self.host_roles_focus && index == self.host_role_index { + style = self.theme.selection(); + } + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled(format!("{cursor} {mark} {}", template.id), style), + ])); + } + lines + } +} + +/// The machine's resources, or why they are not known yet. +/// +/// A host with nothing in the roster is the ordinary state of a declared host +/// that is not running — it has no probe to read, which is a different answer +/// from a probe that came back empty. +fn capacity_line(probe: Option<&WorkerInfo>) -> String { + let Some(probe) = probe else { + return "nothing in the roster reports for this host".into(); + }; + match ( + probe.ip_address.as_deref(), + probe.cpu_cores, + probe.memory_available_bytes, + probe.memory_total_bytes, + ) { + (None, None, None, None) => "details not captured · press r to refresh".into(), + (ip, cpu, available, total) => format!( + "IP {} · CPU {} · RAM {} available / {} total", + ip.map(inline_text).unwrap_or_else(|| "unknown".into()), + cpu.map(|cores| format!("{cores} cores")) + .unwrap_or_else(|| "unknown".into()), + available + .map(format_bytes) + .unwrap_or_else(|| "unknown".into()), + total.map(format_bytes).unwrap_or_else(|| "unknown".into()), + ), + } +} diff --git a/src/tui/tests/feature_app_more/views.rs b/src/tui/tests/feature_app_more/views.rs index 61e6e9eb4..da484ee71 100644 --- a/src/tui/tests/feature_app_more/views.rs +++ b/src/tui/tests/feature_app_more/views.rs @@ -76,20 +76,23 @@ fn workers_add_prompt_empty_is_cancelled() { } #[test] -fn hosts_select_and_remove_no_op_when_empty() { +fn hosts_select_and_remove_no_op_when_the_roster_is_empty() { let (mut app, _rt) = empty_app(); tab(&mut app, "Hosts"); app.focus_routing_subpage("Hosts"); - // No workers → select/remove produce no command. + // No workers → select/remove have no roster entry to act on. assert!(app.on_event(key(KeyCode::Enter)).is_none()); assert!(app.on_event(key(KeyCode::Char('d'))).is_none()); - // Up/Down clamp harmlessly at 0. + // The tree is still one row — this device — so the cursor clamps at 0. let _ = app.on_event(key(KeyCode::Down)); let _ = app.on_event(key(KeyCode::Up)); assert_eq!(app.host_index(), 0); - // The empty-state hint renders. + assert_eq!(app.hosts_row_count(), 1); + // And the local host says it has declared nothing rather than reading as a + // machine that is not there. let out = render(&mut app, 120, 40); - assert!(out.contains("No hosts registered")); + assert!(out.contains("this device"), "{out}"); + assert!(out.contains("no agents"), "{out}"); } // --- Context navigation & mouse --------------------------------------------- diff --git a/src/tui/tests/feature_workers.rs b/src/tui/tests/feature_workers.rs index 902ae3f3b..6e8a96934 100644 --- a/src/tui/tests/feature_workers.rs +++ b/src/tui/tests/feature_workers.rs @@ -14,6 +14,9 @@ mod helpers; #[path = "feature_workers/list.rs"] mod list; +#[path = "feature_workers/roles.rs"] +mod roles; + #[path = "feature_workers/routing.rs"] mod routing; diff --git a/src/tui/tests/feature_workers/helpers.rs b/src/tui/tests/feature_workers/helpers.rs index e42d35812..b275f9093 100644 --- a/src/tui/tests/feature_workers/helpers.rs +++ b/src/tui/tests/feature_workers/helpers.rs @@ -111,6 +111,26 @@ pub fn worker(id: &str, selected: bool) -> WorkerInfo { } } +/// A roster entry on *this device* — the address the default `[host]` section +/// binds, which is what makes it an agent on the local host rather than a +/// remote peer, and so editable from the Hosts page. +pub fn local_worker(id: &str, selected: bool) -> WorkerInfo { + WorkerInfo { + address: "this-device".into(), + handle: None, + ..worker(id, selected) + } +} + +/// Press `↓` `count` times: the Hosts page's cursor walks host headers *and* +/// the agents under them, so a test that wants a particular row counts rows +/// rather than workers. +pub fn down(app: &mut App, count: usize) { + for _ in 0..count { + let _ = app.on_event(key(KeyCode::Down)); + } +} + /// An `App` over a three-worker roster (`w1` selected). pub fn app_with_workers(stream: Option) -> App { app_with_roster( diff --git a/src/tui/tests/feature_workers/list.rs b/src/tui/tests/feature_workers/list.rs index f09df567c..75f712860 100644 --- a/src/tui/tests/feature_workers/list.rs +++ b/src/tui/tests/feature_workers/list.rs @@ -1,22 +1,81 @@ -//! Hosts subpage coverage: roster rendering (capacity, probe readiness and -//! budget lines, missing-detail fallbacks, pagination), the add/edit/remove -//! selection shortcuts, and the Harnesses credential/runtime view. +//! Hosts subpage coverage: the `Host → Agents` tree (capacity, probe readiness +//! and budget lines, missing-detail fallbacks, pagination), the local/remote +//! capability split, the add/edit/remove selection shortcuts, and the Harnesses +//! credential/runtime view. Role assignment lives in the sibling `roles` module. use crate::helpers::*; #[test] -fn hosts_tab_lists_registered_machines() { +fn hosts_tab_lists_hosts_with_their_agents() { let mut app = app_with_workers(None); tab(&mut app, "Hosts"); app.focus_routing_subpage("Hosts"); let out = render(&mut app, 120, 40); - assert!(out.contains("Hosts · 3"), "host count in title"); - assert!(out.contains("@w1")); - assert!(out.contains("CODEX")); + // Four hosts: this device (always present) and the three peers, carrying + // three agents between them. + assert!(out.contains("Hosts · 4 · agents · 3"), "tree counts: {out}"); + assert!( + out.contains("this device · this-device · local"), + "the local host leads, running or not: {out}" + ); + assert!( + out.contains("w1 label · w1.example:9000 · remote · read-only"), + "a peer is a host, and says it is not editable here: {out}" + ); + assert!(out.contains("CODEX"), "the agent row carries its harness"); + assert!(out.contains("a add host · r refresh")); + + // Capacity belongs to the machine, so it is the *host* row's preview. + down(&mut app, 1); + let out = render(&mut app, 120, 40); assert!(out.contains("IP 10.0.0.1")); assert!(out.contains("CPU 8 cores")); assert!(out.contains("RAM 18.0 GiB available / 32.0 GiB total")); - assert!(out.contains("a add · Enter/s select")); +} + +#[test] +fn the_local_host_offers_agent_creation_and_a_remote_does_not() { + // The v1 capability split, made visible: an agent is declared on the machine + // that owns it. Dispatch to a remote agent is unaffected — this is only + // about what the operator can change from this terminal. + let mut app = app_with_roster(vec![worker("w1", true)], None); + app.focus_routing_subpage("Hosts"); + + let out = render(&mut app, 130, 40); + assert!( + out.contains("none declared here · n declares one"), + "the local host invites a new agent: {out}" + ); + assert!(app.selected_host_is_local()); + + down(&mut app, 1); // the remote host header + let out = render(&mut app, 130, 40); + assert!(!app.selected_host_is_local()); + assert!( + out.contains("declared on that machine"), + "the remote host says where its agents come from: {out}" + ); + assert!( + out.contains("this hub lists what its roster reaches"), + "and is honest that it cannot see that machine's declarations: {out}" + ); + + // `n` refuses on a remote host rather than being silently inert. + assert!(app.on_event(key(KeyCode::Char('n'))).is_none()); + assert!( + app.status().contains("read-only"), + "status: {}", + app.status() + ); + + // On the local host it points at the flow that declares one. + let _ = app.on_event(key(KeyCode::Up)); + assert!(app.on_event(key(KeyCode::Char('n'))).is_none()); + assert!( + app.status().contains("New agent"), + "status: {}", + app.status() + ); } #[test] @@ -62,6 +121,8 @@ fn host_row_shows_probe_readiness_and_budget_lines() { let mut app = app_with_roster(vec![w], None); app.focus_routing_subpage("Hosts"); + // Readiness and budgets describe the machine, so they are the host row's. + down(&mut app, 1); // Wide enough that the folded readiness + budget segments are not clipped. let out = render(&mut app, 200, 40); @@ -112,6 +173,7 @@ fn host_row_sanitizes_probe_text_and_keeps_fractional_budget() { let mut app = app_with_roster(vec![w], None); app.focus_routing_subpage("Hosts"); + down(&mut app, 1); let out = render(&mut app, 200, 40); // The escape/OSC bytes are gone; only the printable tail survives. @@ -132,7 +194,9 @@ fn hosts_r_refreshes_selected_machine_details() { let mut app = app_with_workers(None); tab(&mut app, "Hosts"); app.focus_routing_subpage("Hosts"); - let _ = app.on_event(key(KeyCode::Down)); + // Row 0 is this device, 1–2 are w1's host and agent, 3 is w2's host: a + // refresh reads the probe of the machine the cursor is on either way. + down(&mut app, 3); let cmd = app.on_event(key(KeyCode::Char('r'))); match cmd { Some(Cmd::WorkerOp(op)) => { @@ -163,6 +227,32 @@ fn add_host_page_renders_guidance_and_opens_the_prompt() { assert!(draft.is_empty()); } +#[test] +fn adding_a_remote_host_still_lands_on_the_tree() { + // The wizard is unchanged by the tree: choose Remote, confirm the pairing + // instructions, type the address, and the add op is emitted while the page + // returns to the list the add is about. + let mut app = app_with_roster(Vec::new(), None); + app.focus_routing_subpage("Add Host"); + let _ = app.on_event(key(KeyCode::Down)); // Local leads; Remote is second + let _ = app.on_event(key(KeyCode::Enter)); // settle the kind + let _ = app.on_event(key(KeyCode::Enter)); // open the address prompt + assert!(app.prompt_state().is_some(), "the address prompt opened"); + for ch in "@build-box".chars() { + let _ = app.on_event(key(KeyCode::Char(ch))); + } + + match app.on_event(key(KeyCode::Enter)) { + Some(Cmd::WorkerOp(op)) => { + let debug = format!("{op:?}"); + assert!(debug.contains("Add"), "{debug}"); + assert!(debug.contains("build-box"), "{debug}"); + } + other => panic!("expected an Add op, got {other:?}"), + } + assert_eq!(app.routing_subpage(), "Hosts"); +} + #[test] fn host_list_add_shortcut_opens_the_shared_prompt() { let mut app = app_with_workers(None); @@ -174,12 +264,18 @@ fn host_list_add_shortcut_opens_the_shared_prompt() { } #[test] -fn empty_host_list_explains_the_state_and_roster_actions_are_noops() { +fn an_empty_roster_still_shows_this_device_and_roster_actions_are_noops() { + // "The local host is always present" is what the page is for: with nothing + // registered the operator must still see the machine they are sitting at, + // and that it has declared no agents — not an empty list that reads as a + // fleet with no members. let mut app = app_with_roster(Vec::new(), None); app.focus_routing_subpage("Hosts"); let out = render(&mut app, 120, 40); - assert!(out.contains("No hosts registered")); + assert!(out.contains("this device · this-device · local · no agents")); + assert!(out.contains("Hosts · 1 · agents · 0")); + // Every roster mutation needs an entry to act on, and there is none. for code in [ KeyCode::Enter, KeyCode::Char('d'), @@ -210,10 +306,11 @@ fn host_list_formats_missing_details_and_megabytes() { // Capacity is the preview's job now, so each host's line is read where the // cursor is — which is the point of the split: one host's detail at a time. + down(&mut app, 1); let out = render(&mut app, 120, 40); assert!(out.contains("details not captured")); - let _ = app.on_event(key(KeyCode::Down)); + down(&mut app, 2); // past w1's agent, onto w2's host row let out = render(&mut app, 120, 40); assert!(out.contains("512 MiB available / 768 MiB total")); } @@ -225,9 +322,9 @@ fn host_list_paginates_by_single_line_rows() { .collect(); let mut app = app_with_roster(workers, None); app.focus_routing_subpage("Hosts"); - for _ in 1..12 { - let _ = app.on_event(key(KeyCode::Down)); - } + // This device, then a header and an agent per peer: the last row is the + // twenty-fourth below the top. + down(&mut app, 24); let out = render(&mut app, 120, 24); assert!(out.contains("w12"), "selected worker should remain visible"); @@ -247,10 +344,11 @@ fn the_preview_follows_the_cursor_rather_than_repeating_every_host() { let mut app = app_with_roster(vec![first, second], None); app.focus_routing_subpage("Hosts"); + down(&mut app, 1); let out = render(&mut app, 120, 40); assert!( - out.contains("Host · w1"), - "preview titles the selected host" + out.contains("Host · w1 label"), + "preview titles the selected host: {out}" ); assert!(out.contains("IP 10.0.0.1")); assert!( @@ -258,82 +356,13 @@ fn the_preview_follows_the_cursor_rather_than_repeating_every_host() { "an unselected host's capacity must not be drawn: {out}" ); - let _ = app.on_event(key(KeyCode::Down)); + down(&mut app, 2); let out = render(&mut app, 120, 40); - assert!(out.contains("Host · w2")); + assert!(out.contains("Host · w2 label")); assert!(out.contains("IP 10.0.0.2")); assert!(!out.contains("IP 10.0.0.1")); } -#[test] -fn space_on_a_role_offers_the_selected_host_for_it_and_takes_it_back() { - let mut app = app_with_roster(vec![worker("w1", true)], None); - app.focus_routing_subpage("Hosts"); - - // Roles come from the agent-template catalog, which ships built-in coding - // roles even with nothing declared — so there is always something to toggle. - let out = render(&mut app, 120, 44); - assert!( - out.contains("none assigned · offered for any role"), - "an unassigned host reads as general, not excluded: {out}" - ); - - let _ = app.on_event(key(KeyCode::Right)); - let out = render(&mut app, 120, 44); - assert!(out.contains("[ ]"), "the toggle list is drawn: {out}"); - - let cmd = app.on_event(key(KeyCode::Char(' '))); - let assigned = match cmd { - Some(Cmd::WorkerOp(WorkerOp::SetRoles { id, roles })) => { - assert_eq!(id, "w1"); - assert_eq!(roles.len(), 1, "exactly the toggled role"); - roles - } - other => panic!("expected a SetRoles op, got {other:?}"), - }; - - // And back off again. The op is a whole-list replacement, so removing the - // only role must send an *empty* list — not omit the field, which would - // read as "leave the roles alone" and make the toggle one-way. - let mut held = app_with_roster( - vec![{ - let mut w = worker("w1", true); - w.roles = assigned; - w - }], - None, - ); - held.focus_routing_subpage("Hosts"); - let _ = held.on_event(key(KeyCode::Right)); - match held.on_event(key(KeyCode::Char(' '))) { - Some(Cmd::WorkerOp(WorkerOp::SetRoles { id, roles })) => { - assert_eq!(id, "w1"); - assert!( - roles.is_empty(), - "removal sends an empty list, got {roles:?}" - ); - } - other => panic!("expected a SetRoles op, got {other:?}"), - } -} - -#[test] -fn leaving_the_role_list_hands_the_arrows_back_to_the_host_roster() { - let mut app = app_with_roster(vec![worker("w1", true), worker("w2", false)], None); - app.focus_routing_subpage("Hosts"); - - let _ = app.on_event(key(KeyCode::Right)); - // Down now walks roles, so the selected host must not have changed. - let _ = app.on_event(key(KeyCode::Down)); - let out = render(&mut app, 120, 44); - assert!(out.contains("Host · w1"), "still previewing w1: {out}"); - - let _ = app.on_event(key(KeyCode::Left)); - let _ = app.on_event(key(KeyCode::Down)); - let out = render(&mut app, 120, 44); - assert!(out.contains("Host · w2"), "back on the roster: {out}"); -} - #[test] fn harnesses_page_names_credentials_per_runtime_without_values() { let mut app = app_with_workers(None); @@ -360,20 +389,29 @@ fn harnesses_page_names_credentials_per_runtime_without_values() { } #[test] -fn hosts_up_down_moves_selection() { +fn hosts_up_down_walks_hosts_and_their_agents() { let mut app = app_with_workers(None); tab(&mut app, "Hosts"); app.focus_routing_subpage("Hosts"); + // This device, then a header and an agent for each of the three peers. + assert_eq!(app.hosts_row_count(), 7); assert_eq!(app.host_index(), 0); - let _ = app.on_event(key(KeyCode::Down)); + assert!(app.hosts_cursor_on_host(), "row 0 is this device"); + down(&mut app, 1); assert_eq!(app.host_index(), 1); - let _ = app.on_event(key(KeyCode::Down)); - assert_eq!(app.host_index(), 2); - // Clamp at the last worker. - let _ = app.on_event(key(KeyCode::Down)); + assert!(app.hosts_cursor_on_host(), "row 1 is w1's host header"); + down(&mut app, 1); assert_eq!(app.host_index(), 2); + assert_eq!( + app.selected_host_agent().map(|agent| agent.agent_id), + Some("w1".to_string()), + "row 2 is the agent under it" + ); + // Clamp at the last row. + down(&mut app, 10); + assert_eq!(app.host_index(), 6); let _ = app.on_event(key(KeyCode::Up)); - assert_eq!(app.host_index(), 1); + assert_eq!(app.host_index(), 5); } #[test] @@ -381,7 +419,7 @@ fn hosts_enter_selects_and_d_removes() { let mut app = app_with_workers(None); tab(&mut app, "Hosts"); app.focus_routing_subpage("Hosts"); - let _ = app.on_event(key(KeyCode::Down)); // select w2 + down(&mut app, 2); // w1's agent row let cmd = app.on_event(key(KeyCode::Enter)); match cmd { Some(Cmd::WorkerOp(op)) => assert!(format!("{op:?}").contains("Select")), @@ -399,6 +437,7 @@ fn hosts_s_and_x_are_select_and_remove_aliases() { let mut app = app_with_workers(None); tab(&mut app, "Hosts"); app.focus_routing_subpage("Hosts"); + down(&mut app, 2); // w1's agent row let cmd = app.on_event(key(KeyCode::Char('s'))); assert!(matches!(cmd, Some(Cmd::WorkerOp(_)))); let cmd = app.on_event(key(KeyCode::Char('x'))); @@ -410,6 +449,7 @@ fn hosts_e_opens_edit_label_prompt_prefilled() { let mut app = app_with_workers(None); tab(&mut app, "Hosts"); app.focus_routing_subpage("Hosts"); + down(&mut app, 2); // w1's agent row let _ = app.on_event(key(KeyCode::Char('e'))); let (title, draft) = app.prompt_state().expect("edit prompt open"); assert!(title.starts_with("Edit label")); @@ -428,51 +468,6 @@ fn hosts_e_opens_edit_label_prompt_prefilled() { } } -#[test] -fn an_assigned_role_is_summarised_and_checked() { - let mut w = worker("w1", true); - w.roles = vec!["code-reviewer".into()]; - - let mut app = app_with_roster(vec![w], None); - app.focus_routing_subpage("Hosts"); - let out = render(&mut app, 130, 44); - - // The summary leads the block, so what a host is offered for is readable - // without counting checkboxes. - assert!( - out.contains("roles code-reviewer"), - "assigned roles summarised: {out}" - ); - assert!(out.contains("[x] code-reviewer"), "and checked: {out}"); - assert!(out.contains("[ ] implementer"), "others unchecked: {out}"); - // And the roster row carries the count, so the list still says which hosts - // have been given roles at all. - assert!(out.contains("· 1 role"), "count on the roster row: {out}"); -} - -#[test] -fn a_role_below_the_fold_stays_visible_when_selected() { - // The preview is capped at half the page, so on a short terminal the role - // list must scroll — a role that can be selected but not seen is a toggle - // the operator flips blind. - let mut app = app_with_roster(vec![worker("w1", true)], None); - app.focus_routing_subpage("Hosts"); - let _ = app.on_event(key(KeyCode::Right)); - - let out = render(&mut app, 130, 26); - let last = "repo-orchestrator"; - assert!(!out.contains(last), "the tail starts off-screen: {out}"); - - for _ in 0..12 { - let _ = app.on_event(key(KeyCode::Down)); - } - let out = render(&mut app, 130, 26); - assert!( - out.contains(&format!("▸ [ ] {last}")), - "the window follows the cursor to the last role: {out}" - ); -} - #[test] fn a_local_host_that_could_not_be_saved_can_be_retried() { // The entry used to be pushed into the in-process config before the write, diff --git a/src/tui/tests/feature_workers/roles.rs b/src/tui/tests/feature_workers/roles.rs new file mode 100644 index 000000000..4a203ec9a --- /dev/null +++ b/src/tui/tests/feature_workers/roles.rs @@ -0,0 +1,160 @@ +//! Role assignment on the Hosts page: the toggles belong to an *agent*, they +//! are editable only on a local host, and what they change is the agent's +//! declaration rather than the live roster alone. + +use crate::helpers::*; + +#[test] +fn space_on_a_role_offers_the_selected_agent_for_it_and_takes_it_back() { + // Roles belong to an *agent*: a laptop is not "the reviewer", the agent + // working in the reviewed checkout is. + let mut app = app_with_roster(vec![local_worker("w1", true)], None); + app.focus_routing_subpage("Hosts"); + down(&mut app, 1); + + // Roles come from the agent-template catalog, which ships built-in coding + // roles even with nothing declared — so there is always something to toggle. + let out = render(&mut app, 120, 44); + assert!( + out.contains("none assigned · offered for any role"), + "an unassigned agent reads as general, not excluded: {out}" + ); + + let _ = app.on_event(key(KeyCode::Right)); + let out = render(&mut app, 120, 44); + assert!(out.contains("[ ]"), "the toggle list is drawn: {out}"); + + let cmd = app.on_event(key(KeyCode::Char(' '))); + let assigned = match cmd { + Some(Cmd::WorkerOp(WorkerOp::SetRoles { id, roles })) => { + assert_eq!(id, "w1"); + assert_eq!(roles.len(), 1, "exactly the toggled role"); + roles + } + other => panic!("expected a SetRoles op, got {other:?}"), + }; + // This app has no config file, so the assignment cannot outlive the run — + // and says so rather than implying it was written down. + assert!( + app.status().contains("this run only"), + "status: {}", + app.status() + ); + + // And back off again. The op is a whole-list replacement, so removing the + // only role must send an *empty* list — not omit the field, which would + // read as "leave the roles alone" and make the toggle one-way. + let mut held = app_with_roster( + vec![{ + let mut w = local_worker("w1", true); + w.roles = assigned; + w + }], + None, + ); + held.focus_routing_subpage("Hosts"); + down(&mut held, 1); + let _ = held.on_event(key(KeyCode::Right)); + match held.on_event(key(KeyCode::Char(' '))) { + Some(Cmd::WorkerOp(WorkerOp::SetRoles { id, roles })) => { + assert_eq!(id, "w1"); + assert!( + roles.is_empty(), + "removal sends an empty list, got {roles:?}" + ); + } + other => panic!("expected a SetRoles op, got {other:?}"), + } +} + +#[test] +fn a_remote_agents_roles_cannot_be_assigned_from_here() { + let mut app = app_with_roster(vec![worker("w1", true)], None); + app.focus_routing_subpage("Hosts"); + down(&mut app, 2); // this device, the peer's host row, then its agent + + // The toggles never open, and the preview says who owns the decision. + let _ = app.on_event(key(KeyCode::Right)); + let out = render(&mut app, 130, 44); + assert!( + out.contains("read-only · assign roles on that machine"), + "{out}" + ); + assert!(!out.contains("[ ]"), "no checkboxes to flip: {out}"); + assert!( + app.status().contains("assign its roles there"), + "status: {}", + app.status() + ); + // Space is not a role toggle here — it falls through unhandled rather than + // silently editing the roster. + assert!(app.on_event(key(KeyCode::Char(' '))).is_none()); +} + +#[test] +fn leaving_the_role_list_hands_the_arrows_back_to_the_tree() { + let mut app = app_with_roster( + vec![local_worker("w1", true), local_worker("w2", false)], + None, + ); + app.focus_routing_subpage("Hosts"); + down(&mut app, 1); + + let _ = app.on_event(key(KeyCode::Right)); + // Down now walks roles, so the selected agent must not have changed. + let _ = app.on_event(key(KeyCode::Down)); + let out = render(&mut app, 120, 44); + assert!(out.contains("Agent · w1"), "still previewing w1: {out}"); + + let _ = app.on_event(key(KeyCode::Left)); + let _ = app.on_event(key(KeyCode::Down)); + let out = render(&mut app, 120, 44); + assert!(out.contains("Agent · w2"), "back on the tree: {out}"); +} + +#[test] +fn an_assigned_role_is_summarised_and_checked() { + let mut w = local_worker("w1", true); + w.roles = vec!["code-reviewer".into()]; + + let mut app = app_with_roster(vec![w], None); + app.focus_routing_subpage("Hosts"); + down(&mut app, 1); + let out = render(&mut app, 130, 44); + + // The summary leads the block, so what an agent is offered for is readable + // without counting checkboxes. + assert!( + out.contains("roles code-reviewer"), + "assigned roles summarised: {out}" + ); + assert!(out.contains("[x] code-reviewer"), "and checked: {out}"); + assert!(out.contains("[ ] implementer"), "others unchecked: {out}"); + // And the agent row carries the count, so the tree still says which agents + // have been given roles at all. + assert!(out.contains("· 1 role"), "count on the agent row: {out}"); +} + +#[test] +fn a_role_below_the_fold_stays_visible_when_selected() { + // The preview is capped at half the page, so on a short terminal the role + // list must scroll — a role that can be selected but not seen is a toggle + // the operator flips blind. + let mut app = app_with_roster(vec![local_worker("w1", true)], None); + app.focus_routing_subpage("Hosts"); + down(&mut app, 1); + let _ = app.on_event(key(KeyCode::Right)); + + let out = render(&mut app, 130, 26); + let last = "repo-orchestrator"; + assert!(!out.contains(last), "the tail starts off-screen: {out}"); + + for _ in 0..12 { + let _ = app.on_event(key(KeyCode::Down)); + } + let out = render(&mut app, 130, 26); + assert!( + out.contains(&format!("▸ [ ] {last}")), + "the window follows the cursor to the last role: {out}" + ); +} From 0b6bcecfe322e1359e90d7e886c74a0c9888e49c Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 4 Aug 2026 22:57:22 +0530 Subject: [PATCH 2/6] =?UTF-8?q?feat(ui):=20make=20the=20Agents=20tab=20the?= =?UTF-8?q?=20Host=20=E2=86=92=20Agent=20=E2=86=92=20Session=20tree?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail concatenated two lists — lanes folded from task events, and the operator's own harnesses under a `── your harnesses ──` divider — which is exactly the split the agent/session redefinition removes. A task *is* an agent session; they differ only by origin. And lanes come from traffic, so an agent nobody had dispatched to had no row at all. - A3: `RailRow::{Agent(Lane), Agent(Sub), NewHarness, HarnessSeparator, Harness}` becomes `RailRow::{Host, Agent, Session, NewAgent, Lane}`. Agents come from `[fleet].agentDeclarations`, so a declared agent with zero sessions still renders; a lane nothing declares still gets a row. Sessions are one row type under their agent whatever started them, and the divider is gone. Host rows appear only once a remote host exists. `Lane` is the residue for what is not an agent — the orchestrator's own conversation, the functions divider, a `+N more` counter. - A2: `+ New agent` replaces `+ New harness`. It reuses the harness picker (now carrying a `PickerPurpose`) for harness type × workspace dir, then a name prompt, and persists through `declare_agent`. Starting a session in an undeclared directory offers the same flow inline. - A4: `Ctrl-T` on a row that names an agent opens a session *of that agent* — its declared harness in its declared workspace, named by the operator, `SessionOrigin::User`, with the managed/unmanaged choice kept. - A7: the orchestrator's conversation carries a "sessions started" block; clicking an entry moves focus to that session and the rail follows. `Ctrl-O` returns — scoped to "not already on the orchestrator" so the chord keeps releasing the mouse where drag-select is actually wanted. Session → agent resolves by the id the hub already filed the task under for a dispatch, and by `harness × workspace` for an operator-started PTY. A session in an undeclared directory stays listed rather than hidden. Co-Authored-By: Claude --- src/tui/src/ui/app/agent_control.rs | 285 +++++++++++++ src/tui/src/ui/app/agent_control_tests.rs | 167 ++++++++ src/tui/src/ui/app/commands/dispatch.rs | 21 +- src/tui/src/ui/app/harness_control.rs | 42 +- src/tui/src/ui/app/harness_workspace_tests.rs | 1 + src/tui/src/ui/app/input/mouse.rs | 16 +- src/tui/src/ui/app/input/nav.rs | 26 +- src/tui/src/ui/app/input/tests.rs | 6 +- src/tui/src/ui/app/keys/agents.rs | 12 +- src/tui/src/ui/app/keys/mod.rs | 30 +- src/tui/src/ui/app/mod.rs | 6 + src/tui/src/ui/app/overlays_tests.rs | 1 + src/tui/src/ui/app/rail.rs | 172 -------- src/tui/src/ui/app/rail/mod.rs | 392 ++++++++++++++++++ src/tui/src/ui/app/rail/resolve.rs | 208 ++++++++++ src/tui/src/ui/app/rail/tests.rs | 328 +++++++++++++++ src/tui/src/ui/app/rail/types.rs | 220 ++++++++++ src/tui/src/ui/app/render/agents/mod.rs | 25 +- src/tui/src/ui/app/render/agents/rail/mod.rs | 148 +++++-- .../render/agents/rail/status_line_tests.rs | 11 +- .../src/ui/app/render/agents/transcript.rs | 35 ++ src/tui/src/ui/app/render/settings/help.rs | 12 +- src/tui/src/ui/app/session_focus.rs | 115 +++++ src/tui/src/ui/app/session_focus_tests.rs | 93 +++++ src/tui/src/ui/app/state.rs | 9 +- src/tui/src/ui/app/tests.rs | 15 +- src/tui/src/ui/app/types.rs | 48 ++- src/tui/tests/feature_agents_focus.rs | 6 +- src/tui/tests/feature_harness_control.rs | 10 +- 29 files changed, 2172 insertions(+), 288 deletions(-) create mode 100644 src/tui/src/ui/app/agent_control.rs create mode 100644 src/tui/src/ui/app/agent_control_tests.rs delete mode 100644 src/tui/src/ui/app/rail.rs create mode 100644 src/tui/src/ui/app/rail/mod.rs create mode 100644 src/tui/src/ui/app/rail/resolve.rs create mode 100644 src/tui/src/ui/app/rail/tests.rs create mode 100644 src/tui/src/ui/app/rail/types.rs create mode 100644 src/tui/src/ui/app/session_focus.rs create mode 100644 src/tui/src/ui/app/session_focus_tests.rs diff --git a/src/tui/src/ui/app/agent_control.rs b/src/tui/src/ui/app/agent_control.rs new file mode 100644 index 000000000..e430adb8a --- /dev/null +++ b/src/tui/src/ui/app/agent_control.rs @@ -0,0 +1,285 @@ +//! Declaring agents, and opening sessions under them. +//! +//! The two writes the Agents tab makes. **Declaring** is `harness × workspace` +//! written to `[fleet].agentDeclarations` — it starts nothing, and the agent it +//! produces has a rail row from that moment whether or not anything ever runs in +//! it. **Opening a session** is the opposite: it starts a process and writes +//! nothing, inheriting the harness and the directory from the declaration rather +//! than asking again. +//! +//! Both reuse the picker in [`harness_control`](super::harness_control): picking +//! a CLI and a directory is the same two steps either way, and the intent it +//! carries ([`PickerPurpose`]) is what decides which of these two ends it lands +//! in. +//! +//! Persistence goes through [`declare_agent`](medulla::config::declare_agent), +//! which writes the file and hands back the list *as written*. That list is +//! assigned straight into the loaded config, so a failed write leaves the rail +//! showing exactly what is on disk rather than an agent that will not survive a +//! restart. + +use medulla::config::{declare_agent, declared_agent_ids}; +use medulla::runtime::{suggest_agent_id, AgentDeclaration, WorkspaceRef, WorkspaceStrategy}; + +use crate::ui::harness_pane::HarnessChoice; + +use super::types::{ + tab_pos, App, HarnessPicker, HarnessPickerStep, PickerPurpose, Prompt, PromptKind, +}; + +impl App { + /// Open the create-agent flow: harness type, then workspace, then a name. + /// + /// Refuses on a device that is not hosting rather than opening a picker with + /// nothing in it: an agent is declared *on a host*, and there is none here to + /// declare it on. + pub(in crate::ui::app) fn open_new_agent_picker(&mut self) { + let Some(harnesses) = self.harnesses.clone() else { + self.set_status("This device is not hosting, so it has no agents to declare"); + return; + }; + let choices = harnesses.choices(); + if choices.is_empty() { + self.set_status("No harness CLIs found on this device"); + return; + } + self.harness_picker = Some(HarnessPicker { + purpose: PickerPurpose::DeclareAgent, + choices, + index: 0, + step: HarnessPickerStep::Harness, + cwd: harnesses.workspace.clone(), + workspace_query: String::new(), + workspace_choices: Vec::new(), + workspace_index: 0, + workspace_picked: false, + managed: true, + }); + self.set_status("New agent · pick a harness · Enter workspace · Esc cancel"); + } + + /// Ask what to call the agent about to be declared for this pair. + /// + /// A single-line prompt rather than a third picker step: the answer is + /// optional, and everything the operator needs to decide it — the CLI and the + /// directory — is in the question. + pub(in crate::ui::app) fn prompt_agent_name(&mut self, harness: &str, workspace: &str) { + let folder = std::path::Path::new(workspace) + .file_name() + .map(|name| name.to_string_lossy().into_owned()) + .unwrap_or_else(|| workspace.to_string()); + self.prompt = Some(Prompt { + title: format!("Name this agent — {harness} × {folder} (blank keeps the default)"), + draft: Default::default(), + kind: PromptKind::AgentName { + harness: harness.to_string(), + workspace: workspace.to_string(), + }, + }); + } + + /// Declare `harness × workspace` on this host and adopt the written list. + /// + /// The id is minted rather than typed: it is the dispatch target, and a + /// collision would silently route work to the wrong agent, so + /// [`suggest_agent_id`] is given every id already declared *anywhere* in the + /// fleet — not only this host's. + pub(in crate::ui::app) fn declare_new_agent( + &mut self, + harness: &str, + workspace: &str, + name: &str, + ) { + let Some(path) = self.config_path.clone() else { + self.set_status("No config file to declare an agent in"); + return; + }; + let current = self.agent_declarations().to_vec(); + let taken = declared_agent_ids(¤t); + let agent_id = suggest_agent_id(workspace, harness, &taken); + let name = name.trim(); + let incoming = AgentDeclaration { + agent_id: agent_id.clone(), + host_id: self.local_host_id(), + harness: harness.to_string(), + workspace: WorkspaceRef::checkout(workspace), + name: (!name.is_empty()).then(|| name.to_string()), + roles: Vec::new(), + // The only strategy an operator may choose in v1; a picker that + // offered `worktree` would declare parallel sessions that all land in + // one directory, because nothing provisions a worktree yet. + strategy: WorkspaceStrategy::Checkout, + }; + match declare_agent(&path, ¤t, incoming) { + Ok(declarations) => { + self.loaded.config.fleet.agent_declarations = declarations; + self.select_agent_row(&agent_id); + self.set_status(format!( + "Declared {agent_id} · {harness} in {workspace} · ^T opens a session" + )); + } + // Nothing was applied — `declare_agent` writes before it returns, so + // an error means the file is unchanged and so is the rail. + Err(error) => self.set_status(format!("Could not declare the agent: {error}")), + } + } + + /// Offer to declare an agent for a session started somewhere undeclared. + /// + /// The quick path survives — the session is already running — but it always + /// leaves a declared agent behind if the operator wants one, which is what + /// keeps "declared, never discovered" true without making the fast door + /// slower. Silent when the directory is already declared, and when this + /// device is not hosting. + pub(in crate::ui::app) fn offer_agent_declaration(&mut self, harness: &str, workspace: &str) { + if self.harnesses.is_none() { + return; + } + let declared = self.local_agent_declarations().into_iter().any(|held| { + held.harness.trim().eq_ignore_ascii_case(harness.trim()) + && held.workspace.path.trim().trim_end_matches('/') + == workspace.trim().trim_end_matches('/') + }); + if declared { + return; + } + self.prompt_agent_name(harness, workspace); + } + + /// The agent the rail cursor is on, or the one owning the session it is on. + pub(in crate::ui::app) fn selected_agent_id(&self) -> Option { + let rows = self.rail_rows(); + rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + .and_then(|row| row.agent_id()) + .map(str::to_string) + } + + /// Ask what to call a new session under `agent_id`, then open it. + /// + /// Managed-versus-unmanaged is ownership at birth and is asked first, in the + /// same sentence, because it is the fact that decides whether the + /// orchestrator may dispatch into what the operator is about to open. + pub(in crate::ui::app) fn open_new_session(&mut self, agent_id: &str) { + let Some(declaration) = self.declaration_for(agent_id) else { + self.set_status(format!( + "No agent \"{agent_id}\" is declared on this device" + )); + return; + }; + let Some(workspace) = declaration.workspace.path() else { + self.set_status(format!( + "{agent_id} declares no workspace to open a session in" + )); + return; + }; + self.prompt = Some(Prompt { + title: format!( + "Name this session — {} in {} (blank leaves it unnamed)", + declaration.harness, workspace + ), + draft: Default::default(), + kind: PromptKind::SessionName { + agent_id: agent_id.to_string(), + // Born to the operator: they asked for it and are about to type + // into it, so the orchestrator does not get it until it is handed + // over. The same default the manual picker offers. + managed: false, + }, + }); + } + + /// Open a session for `agent_id` with the name the operator gave it. + /// + /// The harness and the directory come from the declaration, never from the + /// caller: a session that ran a different CLI, or in a different folder, from + /// the agent it is filed under would put the rail's own grouping rule wrong. + pub(in crate::ui::app) fn start_agent_session( + &mut self, + agent_id: &str, + name: &str, + managed: bool, + ) { + let Some(harnesses) = self.harnesses.clone() else { + self.set_status("This device is not hosting, so it has no sessions to open"); + return; + }; + let Some(declaration) = self.declaration_for(agent_id) else { + self.set_status(format!( + "No agent \"{agent_id}\" is declared on this device" + )); + return; + }; + let Some(choice) = harness_choice(&harnesses.choices(), &declaration.harness) else { + self.set_status(format!( + "{} is not installed on this device — {agent_id} cannot open a session", + declaration.harness + )); + return; + }; + let workspace = declaration.workspace.path().unwrap_or_default().to_string(); + let name = name.trim(); + let name = (!name.is_empty()).then(|| name.to_string()); + let skip = self.harness_skip_permissions; + match harnesses.open_unmanaged_named(&choice, &workspace, skip, name.clone()) { + Ok(id) => { + self.tab_index = tab_pos("Agents"); + self.select_session_row(&id); + let label = name.unwrap_or_else(|| choice.display_name().to_string()); + if managed { + self.hand_back_session(&id, None); + } + self.set_status(format!( + "Opened {label} under {agent_id} · {}", + if managed { + "managed, the orchestrator may dispatch into it" + } else { + "yours, the orchestrator will not dispatch into it" + } + )); + } + Err(error) => self.set_status(format!("Could not open a session: {error}")), + } + } + + /// The declaration for `agent_id` on this host, if there is one. + fn declaration_for(&self, agent_id: &str) -> Option { + medulla::config::agent_declaration(self.agent_declarations(), agent_id).cloned() + } + + /// Put the rail cursor on `agent_id`'s row, if it has one. + pub(in crate::ui::app) fn select_agent_row(&mut self, agent_id: &str) { + if let Some(index) = self.rail_rows().iter().position( + |row| matches!(row, super::rail::RailRow::Agent(agent) if agent.agent_id == agent_id), + ) { + self.agent_index = index; + } + } + + /// Put the rail cursor on the row for `session_id`, if it has one. + /// + /// Selecting the new row matters more than it sounds: a session that appears + /// somewhere below the fold, with the pane still showing whatever was + /// selected before, reads as "nothing happened". + pub(in crate::ui::app) fn select_session_row(&mut self, session_id: &str) { + if let Some(index) = self + .rail_rows() + .iter() + .position(|row| row.session_id() == Some(session_id)) + { + self.agent_index = index; + } + } +} + +/// The installed choice implementing `harness`, matched on its stable id. +/// +/// The id is what a declaration records — a native CLI's wire name, or a custom +/// preset's own id — so this resolves both without the caller knowing which it +/// has. +fn harness_choice(choices: &[HarnessChoice], harness: &str) -> Option { + let wanted = harness.trim(); + choices + .iter() + .find(|choice| choice.id().eq_ignore_ascii_case(wanted)) + .cloned() +} diff --git a/src/tui/src/ui/app/agent_control_tests.rs b/src/tui/src/ui/app/agent_control_tests.rs new file mode 100644 index 000000000..7cd3b3a8f --- /dev/null +++ b/src/tui/src/ui/app/agent_control_tests.rs @@ -0,0 +1,167 @@ +//! The two writes the Agents tab makes: declaring an agent, and opening a +//! session under one. + +use medulla::config::load_agent_declarations; +use medulla::protocol::HarnessProvider; +use medulla::runtime::AgentDeclaration; + +use super::rail::tests::{hosting_app, shell_harnesses}; +use super::rail::RailRow; +use super::types::{PickerPurpose, PromptKind}; +use crate::worker::pty::PtyManager; + +/// A hosting app whose config lives in `dir`, so a declaration can be read back. +fn app_with_config(dir: &std::path::Path) -> super::types::App { + let mut app = hosting_app(); + app.set_config_path(dir.join("config.toml")); + app +} + +#[test] +fn declaring_an_agent_writes_it_and_shows_it_on_the_rail() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut app = app_with_config(dir.path()); + + app.declare_new_agent("codex", "/work/api", "API"); + + let written = load_agent_declarations(&dir.path().join("config.toml")); + assert_eq!(written.len(), 1, "one agent is on disk: {written:?}"); + let declaration = &written[0]; + // The id is minted from the folder, not typed: it is the dispatch target. + assert_eq!(declaration.agent_id, "api-codex"); + assert_eq!(declaration.harness, "codex"); + assert_eq!(declaration.workspace.path(), Some("/work/api")); + assert_eq!(declaration.name.as_deref(), Some("API")); + // `checkout` is the only strategy a picker may offer in v1, so one session + // writes at a time. + assert_eq!(declaration.max_sessions(), 1); + + // The written list is adopted in memory too, so the very next frame has the + // row rather than waiting for a restart. + assert!( + app.rail_rows() + .iter() + .any(|row| matches!(row, RailRow::Agent(agent) if agent.agent_id == "api-codex")), + "the declared agent is on the rail" + ); +} + +#[test] +fn a_blank_name_keeps_the_minted_id_and_a_second_agent_does_not_collide() { + let dir = tempfile::tempdir().expect("a temp dir"); + let mut app = app_with_config(dir.path()); + + app.declare_new_agent("codex", "/work/api", " "); + app.declare_new_agent("codex", "/other/api", ""); + + let written = load_agent_declarations(&dir.path().join("config.toml")); + let ids: Vec<&str> = written + .iter() + .map(|declaration| declaration.agent_id.as_str()) + .collect(); + assert_eq!(ids, vec!["api-codex", "api-codex-2"], "ids never collide"); + assert!( + written.iter().all(|declaration| declaration.name.is_none()), + "blank is not a name" + ); +} + +#[test] +fn a_failed_write_leaves_the_rail_showing_what_is_on_disk() { + // No config path at all: nothing can be written, so nothing is adopted — + // the alternative is a rail showing an agent that will not survive a + // restart. + let mut app = hosting_app(); + app.declare_new_agent("codex", "/work/api", "API"); + assert!(app.agent_declarations().is_empty()); + assert!(app.status().contains("No config file"), "{}", app.status()); +} + +#[test] +fn the_new_agent_row_opens_the_picker_in_declare_mode() { + let mut app = hosting_app(); + let index = app + .rail_rows() + .iter() + .position(RailRow::is_new_agent) + .expect("the create action is on the rail"); + app.agent_index = index; + assert!(app.on_new_agent_row()); + + app.open_new_agent_picker(); + let picker = app.harness_picker.as_ref().expect("the picker opened"); + assert_eq!(picker.purpose, PickerPurpose::DeclareAgent); +} + +#[test] +fn a_session_started_somewhere_undeclared_offers_an_agent_for_it() { + let mut app = hosting_app(); + app.offer_agent_declaration("codex", "/work/loose"); + assert!(app.prompt_state().is_some(), "the offer is a name prompt"); + let kind = app.prompt.as_ref().map(|prompt| &prompt.kind); + assert!( + matches!(kind, Some(PromptKind::AgentName { harness, workspace }) + if harness == "codex" && workspace == "/work/loose"), + "the offer carries the pair it would declare" + ); +} + +#[test] +fn a_directory_that_is_already_declared_is_not_offered_again() { + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("api", "", "codex", "/work/api")]; + app.offer_agent_declaration("codex", "/work/api/"); + assert!(app.prompt_state().is_none(), "nothing left to declare"); +} + +#[test] +fn a_new_session_under_an_agent_asks_for_a_name_first() { + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("shell", "", "codex", "/")]; + + app.open_new_session("shell"); + + let kind = app.prompt.as_ref().map(|prompt| &prompt.kind); + assert!( + matches!(kind, Some(PromptKind::SessionName { agent_id, managed }) + if agent_id == "shell" && !managed), + "a session a person spins up is theirs at birth" + ); +} + +#[test] +fn a_named_session_opens_in_the_agents_own_harness_and_workspace() { + let sessions = PtyManager::new(); + let mut app = hosting_app(); + app.set_local_harnesses(shell_harnesses(sessions.clone())); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("shell", "", "codex", "/")]; + + app.start_agent_session("shell", "debug login", false); + + let rows = sessions.rows(); + let row = rows.first().expect("a session opened").clone(); + assert_eq!(row.name.as_deref(), Some("debug login")); + assert_eq!(row.provider, HarnessProvider::Codex, "the agent's harness"); + assert_eq!(row.cwd, "/", "the agent's workspace, not the caller's"); + assert!(row.origin.is_user(), "the operator started it"); + // The cursor moves onto the new row: a session that appears below the fold + // with the pane unchanged reads as "nothing happened". + assert_eq!( + app.rail_rows() + .get(app.agent_index()) + .and_then(RailRow::session_id), + Some(row.id.as_str()) + ); + sessions.shutdown(); +} + +#[test] +fn opening_a_session_for_an_undeclared_agent_says_so() { + let mut app = hosting_app(); + app.open_new_session("nobody"); + assert!(app.status().contains("nobody"), "{}", app.status()); + assert!(app.prompt_state().is_none()); +} diff --git a/src/tui/src/ui/app/commands/dispatch.rs b/src/tui/src/ui/app/commands/dispatch.rs index 11220d90c..9b389b453 100644 --- a/src/tui/src/ui/app/commands/dispatch.rs +++ b/src/tui/src/ui/app/commands/dispatch.rs @@ -1,6 +1,6 @@ //! Runtime, prompt, clipboard, slash-command, and settings command dispatch. -use crate::ui::agents::{AgentRow, TaskState}; +use crate::ui::agents::TaskState; use crate::ui::clipboard::{copy_for_operator, copy_to_clipboard, current_platform, OSC_52}; use crate::ui::command::{self, CopyScope, SlashCommand}; use crate::ui::composer::Draft; @@ -19,12 +19,9 @@ impl App { /// whichever row happened to share the offset. pub(in crate::ui::app) fn selected_agent_task(&self) -> Option { let rows = self.rail_rows(); - match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { - Some(super::super::rail::RailRow::Agent(AgentRow::Sub { task, .. })) => { - Some(task.clone()) - } - _ => None, - } + rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + .and_then(|row| row.task()) + .cloned() } /// Request cancellation of the selected running task, or note why it cannot. @@ -143,6 +140,16 @@ impl App { self.add_workspace(&text); None } + // Blank is an answer here, not a cancellation: the id minted from + // the directory is the name most agents keep. + PromptKind::AgentName { harness, workspace } => { + self.declare_new_agent(&harness, &workspace, &text); + None + } + PromptKind::SessionName { agent_id, managed } => { + self.start_agent_session(&agent_id, &text, managed); + None + } PromptKind::CustomHarnessAdd => { self.save_custom_harness(None, &text); None diff --git a/src/tui/src/ui/app/harness_control.rs b/src/tui/src/ui/app/harness_control.rs index f0ee62a77..70be805a9 100644 --- a/src/tui/src/ui/app/harness_control.rs +++ b/src/tui/src/ui/app/harness_control.rs @@ -25,6 +25,7 @@ use crate::worker::pty::HarnessControl; use super::types::{ tab_pos, App, Cmd, HandbackPolicy, HandbackPrompt, HarnessPicker, HarnessPickerStep, + PickerPurpose, }; impl App { @@ -51,6 +52,7 @@ impl App { return; } self.harness_picker = Some(HarnessPicker { + purpose: PickerPurpose::Spawn, choices, index: 0, step: HarnessPickerStep::Harness, @@ -87,7 +89,7 @@ impl App { match harnesses.open_unmanaged(&choice, &workspace, skip) { Ok(id) => { self.tab_index = tab_pos("Agents"); - self.select_harness_row(&id); + self.select_session_row(&id); let label = if managed { "managed" } else { "unmanaged" }; let mut status = format!( "Started {} · {label}, the orchestrator will{} use it", @@ -106,6 +108,10 @@ impl App { self.hand_back_session(&id, None); } self.set_status(status); + // The quick path always leaves a declared agent behind if the + // operator wants one: a session in a directory nothing declares + // is a real thing running that the rail can only list loose. + self.offer_agent_declaration(choice.id(), &workspace); } // Surfaced, never swallowed: a spawn that fails silently leaves the // operator waiting for a pane that is never coming. @@ -115,17 +121,6 @@ impl App { } } - /// Put the rail cursor on the row for `session_id`, if it has one. - fn select_harness_row(&mut self, session_id: &str) { - if let Some(index) = self - .rail_rows() - .iter() - .position(|row| row.session_id() == Some(session_id)) - { - self.agent_index = index; - } - } - /// Take the selected harness from the orchestrator. pub(crate) fn take_harness_control(&mut self) { let Some((harnesses, session)) = self.selected_harness() else { @@ -562,9 +557,30 @@ impl App { self.refresh_harness_workspace_choices(); } KeyCode::Enter => { - if self.selected_harness_workspace().is_none() { + let Some(workspace) = self.selected_harness_workspace() else { self.set_status("Choose an existing directory"); return; + }; + let purpose = self + .harness_picker + .as_ref() + .map(|picker| picker.purpose.clone()) + .unwrap_or(PickerPurpose::Spawn); + // Declaring is finished by naming, not by choosing an owner: + // nothing starts, so there is nobody to own it yet. + if purpose == PickerPurpose::DeclareAgent { + let harness = self + .harness_picker + .as_ref() + .and_then(|picker| picker.choices.get(picker.index)) + .map(|choice| choice.id().to_string()); + let Some(harness) = harness else { + self.set_status("Choose a harness first"); + return; + }; + self.harness_picker = None; + self.prompt_agent_name(&harness, &workspace); + return; } if let Some(picker) = &mut self.harness_picker { picker.step = HarnessPickerStep::Decision; diff --git a/src/tui/src/ui/app/harness_workspace_tests.rs b/src/tui/src/ui/app/harness_workspace_tests.rs index d5ab77be4..008b71b69 100644 --- a/src/tui/src/ui/app/harness_workspace_tests.rs +++ b/src/tui/src/ui/app/harness_workspace_tests.rs @@ -98,6 +98,7 @@ fn picker_on_workspace_step(workspace: &std::path::Path) -> super::types::App { attribution: true, }); app.harness_picker = Some(HarnessPicker { + purpose: super::types::PickerPurpose::Spawn, choices: Vec::new(), index: 0, step: HarnessPickerStep::Workspace, diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index 6506ce07b..aefadae1f 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -310,6 +310,18 @@ impl App { return None; } if tab == "Agents" { + // §A7: clicking an entry of the orchestrator's "sessions started" + // block opens that session — the rail selection follows, which is + // what makes the pane show its conversation. Checked before the rail + // because the block lives in the pane beside it. + if let Some((rect, tasks)) = self.hit_started_sessions.clone() { + if rect.contains((x, y).into()) { + if let Some(task_id) = tasks.get((y - rect.y) as usize) { + self.focus_session_for_task(task_id); + return self.retarget_watch(); + } + } + } // The rail stacks two hit boxes — threads above lanes — so both are // tried; an `else if` here would leave the strip unclickable. if let Some((rect, window_start)) = self.hit_threads { @@ -347,8 +359,8 @@ impl App { // requiring a second keystroke to confirm what was // already aimed at is the friction it exists to // remove. - if row.is_new_harness() { - self.open_harness_picker(); + if row.is_new_agent() { + self.open_new_agent_picker(); return None; } // So is a lane's `+N more`: the click that lands on diff --git a/src/tui/src/ui/app/input/nav.rs b/src/tui/src/ui/app/input/nav.rs index 74e8f33b1..b38c3f1a5 100644 --- a/src/tui/src/ui/app/input/nav.rs +++ b/src/tui/src/ui/app/input/nav.rs @@ -41,8 +41,11 @@ impl App { /// cursor, so the rows just revealed are the ones on screen. pub(in crate::ui::app) fn page_subtasks(&mut self) -> bool { let rows = self.rail_rows(); - let Some(RailRow::Agent(AgentRow::More { lane_index, hidden })) = - rows.get(self.agent_index) + // The overflow row is a fold row, not an agent row: under the + // `Host → Agent → Session` taxonomy an agent's own row is the declared + // identity, and everything the fold still owns — the orchestrator lane, + // the `── functions ──` divider, this counter — arrives as `Lane`. + let Some(RailRow::Lane(AgentRow::More { lane_index, hidden })) = rows.get(self.agent_index) else { return false; }; @@ -97,12 +100,11 @@ impl App { let found = rows .iter() .position(|row| { - matches!(row, RailRow::Agent(AgentRow::More { lane_index: l, .. }) if *l == lane_index) + matches!(row, RailRow::Lane(AgentRow::More { lane_index: l, .. }) if *l == lane_index) }) .or_else(|| { - rows.iter().position(|row| { - matches!(row, RailRow::Agent(AgentRow::Lane { lane_index: l }) if *l == lane_index) - }) + rows.iter() + .position(|row| matches!(row, RailRow::Agent(agent) if agent.lane_index == Some(lane_index))) }); self.agent_index = found.unwrap_or_else(|| self.agent_index.min(rows.len().saturating_sub(1))); @@ -240,14 +242,12 @@ impl App { } let rows = self.rail_rows(); let row = rows.get(self.agent_index.min(rows.len().saturating_sub(1)))?; - let RailRow::Agent(AgentRow::Sub { - task, lane_index, .. - }) = row - else { + let RailRow::Session(session) = row else { return None; }; + let task = session.task.as_ref()?; let lanes = self.lanes(); - let lane = lanes.get(*lane_index)?; + let lane = lanes.get(session.lane_index?)?; // `Agent` is main's name for a roster agent / delegated task / peer // session — the tiers above it (orchestrator, reasoning, compress) run // no watchable harness. @@ -272,9 +272,7 @@ impl App { 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 RailRow::Agent(AgentRow::Sub { task, .. }) = row else { - return None; - }; + let task = row.task()?; (task.status == TaskStatus::Running) .then(|| self.watch_target()) .flatten() diff --git a/src/tui/src/ui/app/input/tests.rs b/src/tui/src/ui/app/input/tests.rs index c715c1e79..846300746 100644 --- a/src/tui/src/ui/app/input/tests.rs +++ b/src/tui/src/ui/app/input/tests.rs @@ -58,7 +58,7 @@ fn select_overflow_row(app: &mut App) { let index = app .rail_rows() .iter() - .position(|row| matches!(row, RailRow::Agent(AgentRow::More { .. }))) + .position(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))) .expect("a lane with hidden sublanes has an overflow row"); app.agent_index = index; } @@ -67,7 +67,7 @@ fn select_overflow_row(app: &mut App) { fn on_overflow_row(app: &App) -> bool { matches!( app.rail_rows().get(app.agent_index), - Some(RailRow::Agent(AgentRow::More { .. })) + Some(RailRow::Lane(AgentRow::More { .. })) ) } @@ -87,7 +87,7 @@ fn click_overflow_row(app: &mut App) -> Option { let overflow = app .rail_rows() .iter() - .position(|row| matches!(row, RailRow::Agent(AgentRow::More { .. }))) + .position(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))) .expect("a lane with hidden sublanes has an overflow row"); let (rect, owners) = app.hit_agents.clone().expect("the rail was drawn"); let line = owners diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index faeeb5542..c6d4119bc 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -51,11 +51,11 @@ impl App { self.agents_focus == AgentsFocus::Rail || !self.agents_composer_shown() } - /// Whether the rail cursor sits on the `+ New session` action row. - pub(in crate::ui::app) fn on_new_harness_row(&self) -> bool { + /// Whether the rail cursor sits on the `+ New agent` action row. + pub(in crate::ui::app) fn on_new_agent_row(&self) -> bool { let rows = self.rail_rows(); rows.get(self.agent_index.min(rows.len().saturating_sub(1))) - .is_some_and(|row| row.is_new_harness()) + .is_some_and(|row| row.is_new_agent()) } /// Move the keyboard to the rail. Nothing else about the draft changes, so @@ -106,12 +106,12 @@ impl App { AgentsKey::Handled(self.retarget_watch()) } // Enter is "I have found the row I wanted; let me type" — except on - // the rows that are themselves an action: the harness starter, and a + // the rows that are themselves an action: the agent starter, and a // lane's `+N more`, where it pages the hidden sublanes into view. A // visible harness consumes it earlier and takes the keyboard instead. KeyCode::Enter => { - if self.on_new_harness_row() { - self.open_harness_picker(); + if self.on_new_agent_row() { + self.open_new_agent_picker(); } else if !self.page_subtasks() { self.focus_agents_composer(); } diff --git a/src/tui/src/ui/app/keys/mod.rs b/src/tui/src/ui/app/keys/mod.rs index 0cbdc2bef..1edb7c1e4 100644 --- a/src/tui/src/ui/app/keys/mod.rs +++ b/src/tui/src/ui/app/keys/mod.rs @@ -149,6 +149,21 @@ impl App { self.should_quit = true; return None; } + // On the Agents tab, *away from the orchestrator*, this is the + // way back to the conversation after clicking through to a + // session (§A7): the rail cursor returns to the orchestrator and + // the composer takes the keyboard. + // + // Scoped to "not already there" rather than to the tab, because + // the chord's other job — releasing the mouse for native + // drag-select — is wanted most while reading that very + // transcript. So it returns you first and toggles the mouse once + // you have arrived, and `/mouse` reaches the toggle from + // anywhere either way. + KeyCode::Char('o') if tab == "Agents" && !self.on_orchestrator_lane() => { + self.focus_orchestrator(); + return None; + } KeyCode::Char('o') => { self.toggle_mouse(); return None; @@ -174,11 +189,18 @@ impl App { self.new_thread(); return None; } - // Start a harness of your own. `Ctrl-T` for terminal; `Ctrl-N` - // is already a new thread, which is the thing it would - // otherwise be confused with. + // Open a session. `Ctrl-T` for terminal; `Ctrl-N` is already a + // new thread, which is the thing it would otherwise be confused + // with. On a row that names an agent it opens a session *of that + // agent* — its declared harness in its declared workspace, named + // by the operator — because that is the whole point of having + // declared one. Anywhere else it falls back to the free-form + // picker, which declares nothing. KeyCode::Char('t') => { - self.open_harness_picker(); + match self.selected_agent_id().filter(|_| tab == "Agents") { + Some(agent_id) => self.open_new_session(&agent_id), + None => self.open_harness_picker(), + } return None; } // Grab or give: one chord for both directions, because the rail diff --git a/src/tui/src/ui/app/mod.rs b/src/tui/src/ui/app/mod.rs index 8f1e91652..f9e8b674c 100644 --- a/src/tui/src/ui/app/mod.rs +++ b/src/tui/src/ui/app/mod.rs @@ -13,6 +13,9 @@ //! are re-exported here so callers use `crate::ui::app::*`. mod account; +mod agent_control; +#[cfg(test)] +mod agent_control_tests; mod appearance; mod changes; mod commands; @@ -35,6 +38,9 @@ mod overlays_tests; mod rail; mod render; mod routing_options; +mod session_focus; +#[cfg(test)] +mod session_focus_tests; mod settings_edit; mod state; mod status_line; diff --git a/src/tui/src/ui/app/overlays_tests.rs b/src/tui/src/ui/app/overlays_tests.rs index 9f31f0ecc..ec405ad25 100644 --- a/src/tui/src/ui/app/overlays_tests.rs +++ b/src/tui/src/ui/app/overlays_tests.rs @@ -41,6 +41,7 @@ fn raise(app: &mut App, overlay: Overlay) { } Overlay::HarnessPicker => { app.harness_picker = Some(HarnessPicker { + purpose: super::types::PickerPurpose::Spawn, choices: Vec::new(), index: 0, step: HarnessPickerStep::Harness, diff --git a/src/tui/src/ui/app/rail.rs b/src/tui/src/ui/app/rail.rs deleted file mode 100644 index 7c65f7285..000000000 --- a/src/tui/src/ui/app/rail.rs +++ /dev/null @@ -1,172 +0,0 @@ -//! The Agents rail: one cursor over the lanes *and* the declared fleet. -//! -//! The two lists answer adjacent questions — what is running, and what it is -//! running on — and an operator moves between them constantly: an agent stalls, -//! and the next thing you want is the harness it sits on and how much budget -//! that harness has left. Splitting them across tabs meant losing your place in -//! one to look at the other, so they share a rail and a selection here. -//! -//! Rows keep their own models ([`AgentRow`] from the event fold, [`FleetNode`] -//! from the declared capacity); this module only concatenates them, tracks which -//! is selected, and answers what the detail pane should show. - -use super::types::App; -use crate::ui::agents::{AgentRole, AgentRow}; -use crate::worker::pty::SessionRow; - -/// The label on the rail's "start a harness" row. -pub(in crate::ui::app) const NEW_SESSION_LABEL: &str = "+ New session"; - -/// One row of the Agents rail. -#[derive(Debug, Clone)] -pub enum RailRow { - /// A lane, task sublane, or lane-list divider. - /// - /// The lane list's own `── functions ──` separator is an - /// `AgentRow::Separator`; this variant is the group, not the row type. - Agent(AgentRow), - /// The action row that starts a harness of the operator's own. - /// - /// Sits directly under the orchestrator lane because that is where the eye - /// already is — starting a terminal was otherwise a chord (`Ctrl-T`) with - /// nothing on screen to suggest it exists, which is the same as not having - /// it for anyone who has not read the bindings. - NewHarness, - /// The `── your sessions ──` divider above the operator's own sessions. - HarnessSeparator, - /// A harness the operator started, which no lane will ever describe. - /// - /// Lanes are folded from task events, so a session nothing dispatched into - /// produces none — which is exactly the state an unmanaged harness lives in. - /// Without its own group it would be running, costing tokens, and invisible. - Harness(SessionRow), -} - -impl RailRow { - /// Whether the cursor may land on this row. - pub fn selectable(&self) -> bool { - match self { - RailRow::Agent(row) => row.selectable(), - RailRow::NewHarness => true, - RailRow::HarnessSeparator => false, - RailRow::Harness(_) => true, - } - } - - /// The PTY session this row names, when it names one directly. - pub fn session_id(&self) -> Option<&str> { - match self { - RailRow::Harness(row) => Some(row.id.as_str()), - _ => None, - } - } - - /// Whether this row is the "start a harness" action. - pub fn is_new_harness(&self) -> bool { - matches!(self, RailRow::NewHarness) - } -} - -impl App { - /// The rail's rows: the agent lanes. - /// - /// The declared fleet used to hang underneath these, and it was a third - /// rendering of things that already had two homes. Its agents were the very - /// lanes above the divider, so a worker that was both connected and declared - /// appeared twice; its hosts and harnesses are the Routing tab's Harnesses - /// page, which reads the same `fleet_capacity()`; and its templates were - /// already excluded here in favour of Routing's Agent Templates page. What - /// remained was duplication, so the rail now shows what is *running* and - /// nothing else. - /// Operator-started harnesses hang below the lanes under their own divider, - /// because they are the one thing running on this device that the event fold - /// cannot see. The `+ New session` action sits between the two, directly - /// under the orchestrator lane: it is what produces the group below it, and - /// a device that hosts nothing cannot start one, so it is absent there - /// rather than present and refusing. - pub(super) fn rail_rows(&self) -> Vec { - let lanes = self.lanes(); - let can_start = self.harnesses.is_some(); - let mut rows: Vec = Vec::new(); - let mut placed = !can_start; - for row in self.agent_rows() { - let orchestrator = matches!(&row, AgentRow::Lane { lane_index } - if lanes.get(*lane_index).map(|l| l.role) == Some(AgentRole::Orchestrator)); - rows.push(RailRow::Agent(row)); - if orchestrator && !placed { - rows.push(RailRow::NewHarness); - placed = true; - } - } - // No orchestrator lane yet — the very first frame, before any fold has - // run. The action still belongs on screen, and the top is where the - // orchestrator will appear above it. - if !placed { - rows.insert(0, RailRow::NewHarness); - } - let own = self.own_harness_rows(); - if !own.is_empty() { - rows.push(RailRow::HarnessSeparator); - rows.extend(own.into_iter().map(RailRow::Harness)); - } - rows - } - - /// 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: - /// a harness the orchestrator started and then got stuck on a permission - /// prompt is exactly the case an operator needs told about, and it has no - /// row of its own — it is somewhere inside a lane. - /// - /// The attached session is excluded. Its prompt is on screen in front of - /// the person the count is for, so counting it would ask them to go and - /// look at what they are already looking at. - /// Reads the waiting *ids* rather than [`rows`](crate::worker::pty::PtyManager::rows), - /// which clones every session's whole row. This runs on the render thread - /// once per frame for the tab badge, so it takes one lock and copies the - /// handful of ids that are actually waiting — usually none. - pub(in crate::ui) fn harnesses_waiting(&self) -> usize { - let Some(harnesses) = self.harnesses.as_ref() else { - return 0; - }; - Self::count_waiting(&harnesses.sessions.waiting_sessions(), &self.harness_focus) - } - - /// The same count from an already-collected waiting set. - /// - /// The rail collects that set anyway to style its lanes, so the header count - /// is derived from it instead of taking the lock a second time — and, more - /// to the point, the header and the rows beneath it are then answering from - /// one snapshot rather than from two taken a few microseconds apart. - pub(in crate::ui) fn count_waiting( - waiting: &std::collections::HashSet, - focus: &crate::ui::harness_pane::HarnessFocus, - ) -> usize { - waiting - .iter() - .filter(|id| !focus.is_attached_to(id)) - .count() - } - - /// The harnesses this operator started or now holds, oldest first. - /// - /// Exited ones stay listed: the last screen is often the reason it exited, - /// and a row that vanishes on failure is a row that hides the failure. They - /// leave when the operator forgets them. - pub(super) fn own_harness_rows(&self) -> Vec { - let Some(harnesses) = self.harnesses.as_ref() else { - return Vec::new(); - }; - let mut rows: Vec = harnesses - .sessions - .rows() - .into_iter() - .filter(|row| { - row.origin.is_user() || row.control == crate::worker::pty::HarnessControl::User - }) - .collect(); - rows.sort_by_key(|row| row.started_at); - rows - } -} diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs new file mode 100644 index 000000000..375f227ab --- /dev/null +++ b/src/tui/src/ui/app/rail/mod.rs @@ -0,0 +1,392 @@ +//! The Agents rail: one cursor over the whole `Host → Agent → Session` tree. +//! +//! The rail used to concatenate two lists — the lanes the event fold produced, +//! and the harnesses the operator had started under a `── your harnesses ──` +//! divider — and that split is exactly what the agent/session redefinition +//! removes. A task *is* an agent session; a harness is not an entity at all, only +//! the type an agent runs. So the rail now renders one tree: +//! +//! ```text +//! ◆ orchestrator ← the conversation (not an agent) +//! + New agent ← declares one on this machine +//! ▸ this device ← host row, only when a remote host exists +//! ● medulla-claude ← DECLARED agent: present with zero sessions +//! ├ t_41 · running ← a session the orchestrator dispatched +//! └ debug login ← a session the operator started +//! ``` +//! +//! **Agents come from declarations**, not from traffic: a lane is folded from +//! task events, so an agent nothing had been dispatched to produced no row at +//! all — which made the rail a list of what happened rather than of what exists. +//! Declarations are the source; lanes attach to them where they exist, and a lane +//! nothing declares still gets a row of its own so nothing that used to be +//! visible disappears. +//! +//! Row shapes live in [`types`]; the two resolution rules (session → agent, +//! agent → host) in [`resolve`]; this module is the assembly. + +use std::collections::HashSet; + +use medulla::config::agent_declarations_for_host; +use medulla::runtime::AgentDeclaration; + +use super::types::App; +use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; +use crate::worker::pty::SessionRow; + +pub(in crate::ui::app) mod resolve; +#[cfg(test)] +pub(in crate::ui::app) mod tests; +mod types; + +pub use types::{AgentRailRow, HostRailRow, RailRow, SessionRailRow}; + +/// The label on the rail's "declare an agent" row. +/// +/// It says *agent* rather than *harness* because that is what it produces: a +/// declared `harness × workspace` identity that outlives the session it starts. +pub(in crate::ui::app) const NEW_AGENT_LABEL: &str = "+ New agent"; + +/// The most sessions listed under one agent before the rest are counted. +const MAX_SESSIONS_PER_AGENT: usize = 8; + +/// 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 cap already hid, carried so the counts add up. + hidden: usize, +} + +impl App { + /// The agent declarations this machine's config records. + /// + /// Read live rather than cached: [`declare_agent`](medulla::config::declare_agent) + /// writes the file and hands back the list, which is assigned straight into + /// the loaded config, so the next frame's rail is the list as written. + pub(in crate::ui::app) fn agent_declarations(&self) -> &[AgentDeclaration] { + &self.loaded.config.fleet.agent_declarations + } + + /// The host id this machine's agents are declared against. + /// + /// The running host's bus address is the authority — it is what the local + /// roster stamps every entry with. Without a running host there is nothing + /// local to place agents on, and the empty string matches the declarations + /// that name no host. + pub(in crate::ui::app) fn local_host_id(&self) -> String { + self.host_obs + .as_ref() + .map(|host| host.address().to_string()) + .unwrap_or_default() + } + + /// The declarations belonging to this machine, in declaration order. + pub(in crate::ui::app) fn local_agent_declarations(&self) -> Vec { + let host_id = self.local_host_id(); + agent_declarations_for_host(self.agent_declarations(), &host_id) + .into_iter() + .cloned() + .collect() + } + + /// The rail's rows: the conversation, the create action, and the tree. + /// + /// Assembled in three passes so each one answers a single question. First the + /// fold is split — the orchestrator and the function lanes keep their rows, + /// the agent lanes become groups keyed by agent id. Then the declarations + /// this machine holds are folded in, adding every agent with no traffic. Last + /// the live PTY sessions are attached to whichever agent declares the + /// directory they run in, and the whole thing is flattened under host rows — + /// which appear only when there is more than one machine to tell apart. + pub(super) fn rail_rows(&self) -> Vec { + let lanes = self.lanes(); + let (lane_rows, mut groups) = self.split_fold(&lanes); + self.add_declared_agents(&mut groups); + let orphans = self.attach_sessions(&mut groups); + self.flatten(lane_rows, groups, orphans) + } + + /// Split the folded rows into the non-agent ones and the per-agent groups. + fn split_fold(&self, lanes: &[AgentLane]) -> (Vec, Vec) { + let mut lane_rows: Vec = Vec::new(); + let mut groups: Vec = Vec::new(); + for row in self.agent_rows() { + match row { + AgentRow::Lane { lane_index } => { + let Some(lane) = lanes.get(lane_index) else { + continue; + }; + if lane.role != AgentRole::Agent { + lane_rows.push(row); + 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 { + 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; + } + } + AgentRow::Separator => lane_rows.push(row), + } + } + (lane_rows, groups) + } + + /// The group an agent-role lane opens. + /// + /// The lane's `agent_id` is the roster id the hub filed its tasks under, so + /// it is also the key a declaration is looked up by — the two cannot drift, + /// because the roster is a projection of the declarations. + fn group_for_lane(&self, lane: &AgentLane, lane_index: usize) -> AgentGroup { + let agent_id = lane + .agent_id + .clone() + .unwrap_or_else(|| lane.key.trim_start_matches("agent:").to_string()); + let declaration = + medulla::config::agent_declaration(self.agent_declarations(), &agent_id).cloned(); + let host_id = declaration + .as_ref() + .map(|declaration| declaration.host_id.clone()) + .or_else(|| { + lane.descriptor + .as_ref() + .and_then(|descriptor| descriptor.host_id.clone()) + }) + .unwrap_or_default(); + AgentGroup { + row: AgentRailRow { + agent_id, + host_id, + declaration, + lane_index: Some(lane_index), + }, + sessions: Vec::new(), + hidden: 0, + } + } + + /// Add a row for every declared agent that has no lane. + /// + /// This is the whole point of sourcing the rail from declarations: an agent + /// you declared and have not dispatched to is a real, targetable thing, and a + /// rail that only lists traffic cannot show it. + /// + /// Every declaration, not only this machine's: the tab *is* the topology, so + /// an agent declared against another host belongs on it — under that host's + /// row — even before the link handshake starts exchanging rosters. Creating + /// one is still local-only, which the create flow enforces rather than this. + fn add_declared_agents(&self, groups: &mut Vec) { + let seen: HashSet = groups + .iter() + .map(|group| group.row.agent_id.trim().to_string()) + .collect(); + for declaration in self.agent_declarations().iter().cloned() { + if seen.contains(declaration.agent_id.trim()) { + continue; + } + groups.push(AgentGroup { + row: AgentRailRow { + agent_id: declaration.agent_id.clone(), + host_id: declaration.host_id.clone(), + declaration: Some(declaration), + lane_index: None, + }, + sessions: Vec::new(), + hidden: 0, + }); + } + } + + /// Attach the live local sessions to their agents, returning the unclaimed. + /// + /// An unclaimed session runs in a directory nothing declares. It is listed at + /// the end rather than dropped — a harness that is running, costing tokens + /// and invisible is the failure the old `── your harnesses ──` group existed + /// to prevent — and it is what the inline create-agent offer is for. + fn attach_sessions(&self, groups: &mut [AgentGroup]) -> Vec { + let declarations = self.local_agent_declarations(); + let mut orphans = Vec::new(); + for row in self.own_session_rows() { + let agent_id = resolve::agent_for_session(&declarations, &row) + .map(|declaration| declaration.agent_id.clone()); + let index = agent_id.as_ref().and_then(|agent_id| { + groups + .iter() + .position(|group| group.row.agent_id.trim() == agent_id.trim()) + }); + let session = SessionRailRow { + agent_id, + lane_index: index.and_then(|index| groups[index].row.lane_index), + task: None, + local: Some(row), + last: false, + }; + match index { + Some(index) => groups[index].sessions.push(session), + None => orphans.push(session), + } + } + orphans + } + + /// Flatten the tree into rows, wrapping agents in host rows when needed. + fn flatten( + &self, + lane_rows: Vec, + mut groups: Vec, + orphans: Vec, + ) -> Vec { + let mut rows: Vec = lane_rows.into_iter().map(RailRow::Lane).collect(); + // A device that hosts nothing cannot declare an agent on itself, so the + // action is absent there rather than present and refusing. + if self.harnesses.is_some() { + rows.push(RailRow::NewAgent); + } + let local = self.local_host_id(); + let local = local.trim(); + let show_hosts = + resolve::has_remote_host(groups.iter().map(|group| group.row.host_id.as_str()), local); + for host_id in host_order(&groups, local) { + if show_hosts { + let is_local = host_id == local; + rows.push(RailRow::Host(HostRailRow { + label: resolve::host_label(&host_id, is_local), + host_id: host_id.clone(), + local: is_local, + })); + } + for group in groups + .iter_mut() + .filter(|group| placed_on(&group.row.host_id, local) == host_id) + { + push_group(&mut rows, group); + } + } + rows.extend(orphans.into_iter().map(|mut session| { + session.last = true; + RailRow::Session(Box::new(session)) + })); + rows + } + + /// 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: + /// a session the orchestrator started and then got stuck on a permission + /// prompt is exactly the case an operator needs told about, and it may have + /// no row of its own. + /// + /// The attached session is excluded. Its prompt is on screen in front of + /// the person the count is for, so counting it would ask them to go and + /// look at what they are already looking at. + pub(in crate::ui) fn harnesses_waiting(&self) -> usize { + let Some(harnesses) = self.harnesses.as_ref() else { + return 0; + }; + Self::count_waiting(&harnesses.sessions.waiting_sessions(), &self.harness_focus) + } + + /// The same count from an already-collected waiting set. + /// + /// The rail collects that set anyway to style its rows, so the header count + /// is derived from it instead of taking the lock a second time — and, more + /// to the point, the header and the rows beneath it are then answering from + /// one snapshot rather than from two taken a few microseconds apart. + pub(in crate::ui) fn count_waiting( + waiting: &std::collections::HashSet, + focus: &crate::ui::harness_pane::HarnessFocus, + ) -> usize { + waiting + .iter() + .filter(|id| !focus.is_attached_to(id)) + .count() + } + + /// The sessions on this device that no dispatched task already describes. + /// + /// A dispatched session reaches the rail through its task, folded from the + /// event stream; listing it here as well would show one session twice. What + /// is left is the operator's own — started by them, or taken from the + /// orchestrator — which nothing else on this device can see. + /// + /// Exited ones stay listed: the last screen is often the reason it exited, + /// and a row that vanishes on failure is a row that hides the failure. They + /// leave when the operator forgets them. + pub(super) fn own_session_rows(&self) -> Vec { + let Some(harnesses) = self.harnesses.as_ref() else { + return Vec::new(); + }; + let mut rows: Vec = harnesses + .sessions + .rows() + .into_iter() + .filter(|row| { + row.origin.is_user() || row.control == crate::worker::pty::HarnessControl::User + }) + .collect(); + rows.sort_by_key(|row| row.started_at); + rows + } +} + +/// The host an agent is drawn under: its own, or the local one when it names +/// none. An agent nothing places belongs to the machine looking at it. +fn placed_on(host_id: &str, local: &str) -> String { + let host_id = host_id.trim(); + if host_id.is_empty() { + local.to_string() + } else { + host_id.to_string() + } +} + +/// The hosts to draw, local first and the rest in the order their agents appear. +/// +/// Sorting by host id would reorder the rail whenever a peer's key happened to +/// sort differently from the last machine that connected. +fn host_order(groups: &[AgentGroup], local: &str) -> Vec { + let mut order: Vec = Vec::new(); + for group in groups { + let host_id = placed_on(&group.row.host_id, local); + if !order.contains(&host_id) { + order.push(host_id); + } + } + order.sort_by_key(|host_id| host_id.as_str() != local); + order +} + +/// Push one agent row and the sessions under it, capped and tree-marked. +fn push_group(rows: &mut Vec, group: &mut AgentGroup) { + rows.push(RailRow::Agent(group.row.clone())); + let shown = group.sessions.len().min(MAX_SESSIONS_PER_AGENT); + let hidden = group.hidden + (group.sessions.len() - shown); + for (index, session) in group.sessions.iter_mut().take(shown).enumerate() { + session.last = hidden == 0 && index + 1 == shown; + rows.push(RailRow::Session(Box::new(session.clone()))); + } + if hidden > 0 { + rows.push(RailRow::Lane(AgentRow::More { + lane_index: group.row.lane_index.unwrap_or(0), + hidden, + })); + } +} diff --git a/src/tui/src/ui/app/rail/resolve.rs b/src/tui/src/ui/app/rail/resolve.rs new file mode 100644 index 000000000..d1f811616 --- /dev/null +++ b/src/tui/src/ui/app/rail/resolve.rs @@ -0,0 +1,208 @@ +//! Resolving a session to the agent that owns it, and an agent to its host. +//! +//! Two directions, one rule each, kept apart from the assembly in +//! [`super`] so both can be tested without building an [`App`](super::super::types::App). +//! +//! A **dispatched** session already knows its agent: the hub files a task under +//! the roster id the dispatch named ([`lane_id`]), and that id is the lane's +//! `agent_id`. Nothing is re-derived here — re-deriving it by workspace would +//! disagree with the hub on exactly the case the hub was fixed for (a machine +//! advertising several agents at one address). +//! +//! An **operator-started** session knows only where it is running, so it is +//! matched back to the declaration whose `harness × workspace` it is a session +//! of. No match means the directory is undeclared, which is a real state the +//! rail shows rather than hides — and the prompt for inline agent creation. +//! +//! **Follow-up (host level only).** The Hosts tab grew a shared `Host → Agent` +//! projection (`medulla::ui::hosts::host_rows`, with device-local resolution in +//! `medulla::config::local_hosts`) after this branch was cut, and it is not on +//! this tree yet. Once the two are merged, [`has_remote_host`] and +//! [`host_label`] here — and the agent level in [`super::App::rail_rows`] — +//! should source from it, so the two lenses can never disagree about what +//! exists. Session rows stay here: they are the Agents tab's own level. +//! +//! [`lane_id`]: https://docs.rs/medulla + +use medulla::runtime::AgentDeclaration; + +use crate::worker::pty::SessionRow; + +/// The declaration a local PTY session belongs to, if one claims it. +/// +/// Matched on `harness × workspace`, which is what an agent *is*: the CLI that +/// runs the work and the directory it runs in. Paths are compared with trailing +/// separators trimmed, because a declaration typed by hand and a cwd resolved by +/// the spawner disagree about the trailing slash far more often than they +/// disagree about the directory. +/// +/// The first match wins. Two declarations of the same harness in the same +/// directory are the same agent declared twice, so which one claims the session +/// changes nothing an operator can see. +pub fn agent_for_session<'a>( + declarations: &'a [AgentDeclaration], + row: &SessionRow, +) -> Option<&'a AgentDeclaration> { + let cwd = normalize_path(&row.cwd); + let harness = row.provider.as_str(); + declarations.iter().find(|declaration| { + declaration.harness.trim().eq_ignore_ascii_case(harness) + && normalize_path(&declaration.workspace.path) == cwd + }) +} + +/// A path with its trailing separators removed, for comparison only. +/// +/// Never used to *build* a path: an empty result means "the root or nothing", +/// and both compare equal to each other, which is the honest answer for a blank +/// declaration. +fn normalize_path(path: &str) -> &str { + let trimmed = path.trim(); + let stripped = trimmed.trim_end_matches('/'); + if stripped.is_empty() { + trimmed + } else { + stripped + } +} + +/// Whether the rail should draw host headers at all. +/// +/// Progressive disclosure: with only the local host, agents sit at the top level +/// and a permanent `mac-studio ▸` wrapper would add a level of nesting to the +/// surface an operator uses most. A single *unknown* host id does not count as a +/// remote — an agent nothing places is drawn beside the local ones rather than +/// conjuring a second machine out of a missing field. +pub fn has_remote_host<'a>(host_ids: impl IntoIterator, local: &str) -> bool { + let local = local.trim(); + host_ids + .into_iter() + .map(str::trim) + .any(|host_id| !host_id.is_empty() && host_id != local) +} + +/// The display label for a host row. +/// +/// The local machine says so in words; a remote one is named by its address, +/// shortened when the address is a raw key — a 44-character base58 public key is +/// the widest thing the rail would ever hold. +pub fn host_label(host_id: &str, local: bool) -> String { + if local { + return "this device".to_string(); + } + let host_id = host_id.trim(); + if host_id.is_empty() { + "unplaced".to_string() + } else { + crate::ui::util::short_if_address(host_id) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use medulla::protocol::HarnessProvider; + use medulla::runtime::WorkspaceRef; + + use crate::worker::pty::{HarnessControl, PtyState, SessionOrigin}; + + fn session(provider: HarnessProvider, cwd: &str) -> SessionRow { + SessionRow { + id: "w_1".into(), + label: "local".into(), + provider, + state: PtyState::Running, + cwd: cwd.into(), + branch: None, + launch_root: None, + launch_commit: None, + launch_checkout_identity: None, + session_id: None, + thread_name: None, + started_at: 1, + last_output_at: 1, + last_error: None, + busy: false, + control: HarnessControl::User, + origin: SessionOrigin::User, + name: None, + attention: None, + } + } + + #[test] + fn a_session_matches_the_declaration_of_its_harness_and_directory() { + let declarations = vec![ + AgentDeclaration::new("api-claude", "host", "claude", "/work/api"), + AgentDeclaration::new("web-claude", "host", "claude", "/work/web"), + ]; + let matched = agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/work/web"), + ) + .expect("the web checkout is declared"); + assert_eq!(matched.agent_id, "web-claude"); + } + + #[test] + fn a_trailing_separator_is_not_a_different_directory() { + let mut declaration = AgentDeclaration::new("api-claude", "host", "claude", ""); + declaration.workspace = WorkspaceRef::checkout("/work/api/"); + let declarations = vec![declaration]; + assert!(agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/work/api") + ) + .is_some()); + } + + #[test] + fn a_different_harness_in_the_same_directory_is_a_different_agent() { + let declarations = vec![AgentDeclaration::new( + "api-codex", + "host", + "codex", + "/work/api", + )]; + assert!( + agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/work/api") + ) + .is_none(), + "claude in the codex agent's directory is not that agent" + ); + } + + #[test] + fn an_undeclared_directory_resolves_to_no_agent() { + let declarations = vec![AgentDeclaration::new( + "api-claude", + "host", + "claude", + "/work/api", + )]; + assert!(agent_for_session( + &declarations, + &session(HarnessProvider::Claude, "/elsewhere") + ) + .is_none()); + } + + #[test] + fn only_a_second_named_host_counts_as_remote() { + assert!(!has_remote_host(["local", "local", ""], "local")); + assert!(has_remote_host(["local", "studio"], "local")); + assert!( + !has_remote_host(["", ""], "local"), + "an unplaced agent is not a machine of its own" + ); + } + + #[test] + fn the_local_host_says_so_and_a_remote_one_is_named() { + assert_eq!(host_label("anything", true), "this device"); + assert_eq!(host_label("studio", false), "studio"); + assert_eq!(host_label(" ", false), "unplaced"); + } +} diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs new file mode 100644 index 000000000..ba23c80c6 --- /dev/null +++ b/src/tui/src/ui/app/rail/tests.rs @@ -0,0 +1,328 @@ +//! What the Agents rail assembles: declared agents with no traffic, sessions +//! grouped under the agent that owns them, and host rows only once there is a +//! second machine to tell apart. + +use std::collections::HashMap; +use std::sync::Arc; + +use medulla::config::LoadedConfig; +use medulla::protocol::HarnessProvider; +use medulla::runtime::mock::MockRuntime; +use medulla::runtime::{AgentDeclaration, Runtime}; + +use super::{RailRow, NEW_AGENT_LABEL}; +use crate::ui::app::App; +use crate::worker::pty::PtyManager; + +/// An Agents app on the mock runtime, hosting nothing. +pub(in crate::ui::app) fn app() -> App { + let runtime: Arc = Arc::new(MockRuntime::demo()); + let mut loaded = LoadedConfig::defaults("medulla.tui.json".into()); + loaded.config.link = Some(medulla::config::LinkConfig::default()); + App::new(runtime, loaded) +} + +/// The same app with a live (but empty) local host attached, so the rail is +/// allowed to offer `+ New agent` and to list local sessions. +pub(in crate::ui::app) fn hosting_app() -> App { + let mut app = app(); + app.set_local_harnesses(shell_harnesses(PtyManager::new())); + app +} + +/// A [`LocalHarnesses`](crate::ui::harness_pane::LocalHarnesses) whose "codex" +/// is `/bin/sh`, so opening one starts a real pty client and nothing else. +pub(in crate::ui::app) fn shell_harnesses( + sessions: PtyManager, +) -> crate::ui::harness_pane::LocalHarnesses { + let mut env = HashMap::new(); + if let Ok(path) = std::env::var("PATH") { + env.insert("PATH".to_string(), path); + } + env.insert("TERM".to_string(), "xterm-256color".to_string()); + env.insert("TINYPLACE_CODEX_BIN".to_string(), "/bin/sh".to_string()); + crate::ui::harness_pane::LocalHarnesses { + sessions, + runtimes: Arc::new(std::sync::Mutex::new(Vec::new())), + hub_address: "medulla-orchestrator".to_string(), + env, + workspace: "/".to_string(), + providers: vec![HarnessProvider::Codex], + custom_harnesses: Vec::new(), + router: None, + attribution: true, + hooks: medulla::harness_hooks::HooksConfig::default(), + log: None, + } +} + +/// The agent rows, in rail order. +fn agent_ids(app: &App) -> Vec { + app.rail_rows() + .into_iter() + .filter_map(|row| match row { + RailRow::Agent(agent) => Some(agent.agent_id), + _ => None, + }) + .collect() +} + +#[test] +fn a_declared_agent_with_no_sessions_still_has_a_row() { + // The point of sourcing the rail from declarations: an agent nothing has + // been dispatched to folds no lane, so under the old taxonomy it had no row + // at all — a targetable identity you could not see. + let mut app = app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "idle-agent", + "", + "claude", + "/work/idle", + )]; + + let row = app + .rail_rows() + .into_iter() + .find_map(|row| match row { + RailRow::Agent(agent) if agent.agent_id == "idle-agent" => Some(agent), + _ => None, + }) + .expect("the declared agent has a row"); + + assert!(row.lane_index.is_none(), "it has no traffic to fold"); + assert_eq!(row.harness(), Some("claude")); + assert_eq!(row.workspace(), Some("/work/idle")); +} + +#[test] +fn a_declaration_and_its_lane_are_one_row_not_two() { + // Declaring an agent the fold already produced a lane for must not list it + // twice: the id is the join, and the lane keeps its own live row. + let mut app = app(); + let Some(existing) = agent_ids(&app).into_iter().next() else { + return; + }; + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + existing.clone(), + "", + "claude", + "/work", + )]; + + let ids = agent_ids(&app); + assert_eq!( + ids.iter().filter(|id| **id == existing).count(), + 1, + "{ids:?} lists {existing} once" + ); +} + +#[test] +fn every_session_row_sits_under_the_agent_that_owns_it() { + // The rail's whole grouping rule, asserted structurally: walking the rows, + // a session's agent is always whichever agent row it last passed. + let app = app(); + let mut current: Option = None; + let mut checked = 0; + for row in app.rail_rows() { + match row { + RailRow::Agent(agent) => current = Some(agent.agent_id), + RailRow::Session(session) => { + if let Some(agent_id) = &session.agent_id { + assert_eq!( + Some(agent_id), + current.as_ref(), + "a session is filed under the agent above it" + ); + checked += 1; + } + } + _ => {} + } + } + assert!(checked > 0, "the demo fixture dispatches at least one task"); +} + +#[test] +fn a_dispatched_session_and_an_operator_session_are_the_same_row_type() { + // §A0: the `── your harnesses ──` divider and the second row type are gone. + // A task row and a PTY row are both `RailRow::Session`, so nothing on the + // rail separates them into two groups. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("shell", "", "codex", "/")]; + let harnesses = app.local_harnesses().expect("hosting").clone(); + let choice = harnesses + .choices() + .into_iter() + .find(|choice| choice.provider == HarnessProvider::Codex) + .expect("codex is configured"); + let id = harnesses + .open_unmanaged_named(&choice, "/", false, Some("debug login".into())) + .expect("a /bin/sh session starts"); + + let rows = app.rail_rows(); + let session = rows + .iter() + .find_map(|row| match row { + RailRow::Session(session) if session.session_id() == Some(id.as_str()) => Some(session), + _ => None, + }) + .expect("the operator's session has a row"); + assert_eq!(session.agent_id.as_deref(), Some("shell")); + assert_eq!(session.name(), Some("debug login")); + assert!(session.origin().is_user()); + harnesses.sessions.shutdown(); +} + +#[test] +fn a_session_in_an_undeclared_directory_is_still_listed() { + // Nothing declares `/`, so the session belongs to no agent — and a harness + // that is running, costing tokens and invisible is exactly the failure the + // old separate group existed to prevent. + let app = hosting_app(); + let harnesses = app.local_harnesses().expect("hosting").clone(); + let choice = harnesses + .choices() + .into_iter() + .find(|choice| choice.provider == HarnessProvider::Codex) + .expect("codex is configured"); + let id = harnesses + .open_unmanaged(&choice, "/", false) + .expect("a /bin/sh session starts"); + + let listed = app.rail_rows().into_iter().any(|row| match row { + RailRow::Session(session) => { + session.session_id() == Some(id.as_str()) && session.agent_id.is_none() + } + _ => false, + }); + assert!(listed, "an unclaimed session is listed, not hidden"); + harnesses.sessions.shutdown(); +} + +#[test] +fn host_rows_appear_only_once_a_remote_host_exists() { + let mut app = app(); + app.loaded.config.fleet.agent_declarations = + vec![AgentDeclaration::new("local-claude", "", "claude", "/work")]; + assert!( + !app.rail_rows() + .iter() + .any(|row| matches!(row, RailRow::Host(_))), + "one machine needs no host wrapper" + ); + + app.loaded + .config + .fleet + .agent_declarations + .push(AgentDeclaration::new( + "studio-claude", + "studio", + "claude", + "/work", + )); + let hosts: Vec = app + .rail_rows() + .into_iter() + .filter_map(|row| match row { + RailRow::Host(host) => Some(host.host_id), + _ => None, + }) + .collect(); + assert!( + hosts.contains(&"studio".to_string()), + "the remote machine gets a header: {hosts:?}" + ); + assert!( + hosts.len() >= 2, + "so does the local one, once there is a second: {hosts:?}" + ); + assert_eq!(hosts.first().map(String::as_str), Some(""), "local first"); +} + +#[test] +fn the_create_action_is_absent_on_a_device_that_hosts_nothing() { + // A machine with no host cannot declare an agent on itself, so the action is + // missing rather than present and refusing. + assert!( + !app().rail_rows().iter().any(RailRow::is_new_agent), + "no host, no create action" + ); + assert!( + hosting_app().rail_rows().iter().any(RailRow::is_new_agent), + "hosting, so {NEW_AGENT_LABEL} is offered" + ); +} + +#[test] +fn an_agent_is_labelled_by_its_name_and_falls_back_to_its_id() { + let mut declaration = AgentDeclaration::new("api-codex", "", "codex", "/work/api"); + let mut row = super::AgentRailRow { + agent_id: declaration.agent_id.clone(), + host_id: String::new(), + declaration: None, + lane_index: None, + }; + // Undeclared: the id is all there is, and it says nothing about the harness. + assert_eq!(row.label(), "api-codex"); + assert_eq!(row.harness(), None); + assert_eq!(row.workspace(), None); + + row.declaration = Some(declaration.clone()); + assert_eq!(row.label(), "api-codex", "no name means the id"); + + declaration.name = Some(" ".into()); + row.declaration = Some(declaration.clone()); + assert_eq!(row.label(), "api-codex", "a blank name is not a name"); + + declaration.name = Some("API".into()); + row.declaration = Some(declaration); + assert_eq!(row.label(), "API"); +} + +#[test] +fn a_row_answers_for_the_agent_and_the_lane_behind_it() { + let app = app(); + for row in app.rail_rows() { + match &row { + RailRow::Agent(agent) => { + assert_eq!(row.agent_id(), Some(agent.agent_id.as_str())); + assert_eq!(row.lane_index(), agent.lane_index); + assert!(row.task().is_none(), "an agent is not a session"); + } + RailRow::Session(session) => { + assert_eq!(row.agent_id(), session.agent_id.as_deref()); + assert_eq!(row.lane_index(), session.lane_index); + } + // Hosts and the action row are about no agent and no lane. + RailRow::Host(_) | RailRow::NewAgent => { + assert_eq!(row.agent_id(), None); + assert_eq!(row.lane_index(), None); + assert_eq!(row.session_id(), None); + } + RailRow::Lane(lane) => assert_eq!(row.lane_index(), lane.lane_index()), + } + } +} + +#[test] +fn only_the_rows_that_name_something_take_the_cursor() { + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-claude", + "studio", + "claude", + "/work", + )]; + for row in app.rail_rows() { + match row { + RailRow::Host(_) => assert!(!row.selectable(), "a host header is a label"), + RailRow::Agent(_) | RailRow::Session(_) | RailRow::NewAgent => { + assert!(row.selectable()) + } + RailRow::Lane(_) => {} + } + } +} diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs new file mode 100644 index 000000000..d2aa909c8 --- /dev/null +++ b/src/tui/src/ui/app/rail/types.rs @@ -0,0 +1,220 @@ +//! The Agents rail's row taxonomy: `Host → Agent → Session`. +//! +//! One shape for the whole tree. A row is a host, an agent, one of that agent's +//! sessions, or the action that declares a new agent — and the lane rows the +//! event fold still owns for the surfaces that are *not* agents (the +//! orchestrator's own conversation, the `── functions ──` divider, the function +//! lanes beneath it). +//! +//! The taxonomy this replaced carried the split the redefinition removes: an +//! `AgentRow::Sub` rendered a *task* and a `RailRow::Harness` rendered a +//! *session*, in two groups separated by a `── your harnesses ──` divider. A +//! task **is** an agent session — the two differ only in +//! [`SessionOrigin`](crate::worker::pty::SessionOrigin) — so both collapse into +//! [`RailRow::Session`] under the agent that owns them, and the divider is gone. + +use medulla::runtime::AgentDeclaration; + +use crate::ui::agents::{AgentRow, TaskState}; +use crate::worker::pty::{SessionOrigin, SessionRow}; + +/// One host in the tree. +/// +/// Emitted **only when a remote host exists** (progressive disclosure): with just +/// the local machine — the common case — agents sit at the top level and no host +/// row wraps them. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HostRailRow { + /// The host id agents are stamped with; the local bus address for this + /// machine. + pub host_id: String, + /// What the row says. + pub label: String, + /// Whether this is the machine the TUI is running on. + pub local: bool, +} + +/// One agent in the tree — `harness × workspace` on a host. +/// +/// Sourced from the **declaration**, not from the event fold: an agent that has +/// never been dispatched to still has a row, which is the whole point of +/// declaring one. A lane the fold produced for an agent nothing declares (a +/// tiny.place peer, an agent advertised by another machine) still gets a row, so +/// the restructure never hides something that used to be visible. +#[derive(Debug, Clone)] +pub struct AgentRailRow { + /// The `agentId` a dispatch targets — the key sessions are grouped under. + pub agent_id: String, + /// The host this agent runs on. Empty when nothing places it. + pub host_id: String, + /// The declaration this row came from, when the agent is declared here. + pub declaration: Option, + /// The lane the event fold produced for it, when it has traffic. `None` for + /// a declared agent that has not run anything. + pub lane_index: Option, +} + +impl AgentRailRow { + /// The label an undeclared, laneless row would show — the id itself. + /// + /// A declared agent prefers its operator-chosen name, then the folder its + /// workspace sits in, because that is how a person refers to it. + pub fn label(&self) -> String { + let Some(declaration) = &self.declaration else { + return self.agent_id.clone(); + }; + if let Some(name) = declaration.name.as_deref().map(str::trim) { + if !name.is_empty() { + return name.to_string(); + } + } + self.agent_id.clone() + } + + /// The harness type this agent runs, when a declaration says. + pub fn harness(&self) -> Option<&str> { + self.declaration + .as_ref() + .map(|declaration| declaration.harness.as_str()) + } + + /// The directory this agent's sessions work in, when a declaration says. + pub fn workspace(&self) -> Option<&str> { + self.declaration + .as_ref() + .and_then(|declaration| declaration.workspace.path()) + } +} + +/// One session of an agent — **one row type, whatever started it**. +/// +/// An orchestrator dispatch arrives as a [`TaskState`] folded from the event +/// stream; an operator-started session arrives as a live [`SessionRow`] from the +/// local PTY manager. They are the same thing seen through the two surfaces that +/// can see it, so one row carries either (or, for a dispatch this machine is +/// serving, both). +#[derive(Debug, Clone)] +pub struct SessionRailRow { + /// The agent this session belongs to. `None` when nothing declares the + /// directory it runs in — the session is still listed rather than hidden. + pub agent_id: Option, + /// The lane the owning agent folded to, for the transcript behind the row. + pub lane_index: Option, + /// The dispatched task, when the orchestrator started this session. + pub task: Option, + /// The live PTY session, when this device is the one running it. + pub local: Option, + /// Whether this is the last session listed under its agent, for the tree + /// glyph. + pub last: bool, +} + +impl SessionRailRow { + /// The local PTY session id this row names, when it names one. + pub fn session_id(&self) -> Option<&str> { + self.local.as_ref().map(|row| row.id.as_str()) + } + + /// Who started this session. + /// + /// A row backed by a task is an orchestrator dispatch by construction; a + /// local-only row reports what the PTY manager recorded at launch. + pub fn origin(&self) -> SessionOrigin { + match (&self.task, &self.local) { + (Some(_), _) => SessionOrigin::Orchestrator, + (None, Some(row)) => row.origin, + (None, None) => SessionOrigin::Orchestrator, + } + } + + /// The name the operator gave this session, when they gave one. + pub fn name(&self) -> Option<&str> { + self.local + .as_ref() + .and_then(|row| row.name.as_deref()) + .map(str::trim) + .filter(|name| !name.is_empty()) + } +} + +/// One row of the Agents rail. +#[derive(Debug, Clone)] +pub enum RailRow { + /// A host header, emitted only once a remote host exists. + Host(HostRailRow), + /// A declared (or folded) agent. + Agent(AgentRailRow), + /// One session of the agent above it. + /// + /// Boxed because a session row carries both a whole [`TaskState`] and a + /// whole [`SessionRow`], which together are several times the size of every + /// other variant — and a rail is a `Vec` rebuilt each frame, so the + /// widest variant is what every row costs. + Session(Box), + /// The action row that declares a new agent on this machine. + /// + /// Sits directly above the tree it produces, because a machine with no + /// agents declared has nothing else on that half of the rail to suggest the + /// flow exists — which is the same as not having it. + NewAgent, + /// A fold row that is not an agent: the orchestrator's own conversation, the + /// `── functions ──` divider, a function lane, or a `+N more` counter. + /// + /// The one variant the topology does not name, because the orchestrator is + /// not an agent and still needs somewhere to live. Everything in it is + /// either the conversation or a label. + Lane(AgentRow), +} + +impl RailRow { + /// Whether the cursor may land on this row. + pub fn selectable(&self) -> bool { + match self { + RailRow::Host(_) => false, + RailRow::Agent(_) => true, + RailRow::Session(_) => true, + RailRow::NewAgent => true, + RailRow::Lane(row) => row.selectable(), + } + } + + /// The PTY session this row names, when it names one directly. + pub fn session_id(&self) -> Option<&str> { + match self { + RailRow::Session(row) => row.session_id(), + _ => None, + } + } + + /// The task this row renders, when it renders one. + pub fn task(&self) -> Option<&TaskState> { + match self { + RailRow::Session(row) => row.task.as_ref(), + _ => None, + } + } + + /// The lane index behind this row, for the transcript pane. + pub fn lane_index(&self) -> Option { + match self { + RailRow::Agent(row) => row.lane_index, + RailRow::Session(row) => row.lane_index, + RailRow::Lane(row) => row.lane_index(), + RailRow::Host(_) | RailRow::NewAgent => None, + } + } + + /// The agent this row is about, when it is about one. + pub fn agent_id(&self) -> Option<&str> { + match self { + RailRow::Agent(row) => Some(row.agent_id.as_str()), + RailRow::Session(row) => row.agent_id.as_deref(), + _ => None, + } + } + + /// Whether this row is the "declare an agent" action. + pub fn is_new_agent(&self) -> bool { + matches!(self, RailRow::NewAgent) + } +} diff --git a/src/tui/src/ui/app/render/agents/mod.rs b/src/tui/src/ui/app/render/agents/mod.rs index f85a088c8..c5e9ad5bf 100644 --- a/src/tui/src/ui/app/render/agents/mod.rs +++ b/src/tui/src/ui/app/render/agents/mod.rs @@ -79,23 +79,20 @@ impl App { let rows = self.rail_rows(); let active = self.agent_index.min(rows.len().saturating_sub(1)); self.agent_index = active; - let lane_index = match rows.get(active) { - Some(RailRow::Agent(row)) => row.lane_index().unwrap_or(0), - _ => 0, - }; - let task = match rows.get(active) { - Some(RailRow::Agent(AgentRow::Sub { task, .. })) => Some(task.clone()), - _ => None, - }; - // Only a *lane* row can be the orchestrator's. The action row and the - // operator's own harnesses fall back to lane 0 for the transcript - // behind them, and reading the role off that fallback claimed the - // orchestrator's composer for rows that are not it — a text box under a - // row with nothing to say to. Mirrors + let lane_index = rows + .get(active) + .and_then(|row| row.lane_index()) + .unwrap_or(0); + let task = rows.get(active).and_then(|row| row.task()).cloned(); + // Only a *lane* row can be the orchestrator's. Agents, sessions, hosts + // and the action row fall back to lane 0 for the transcript behind them, + // and reading the role off that fallback claimed the orchestrator's + // composer for rows that are not it — a text box under a row with + // nothing to say to. Mirrors // [`App::on_orchestrator_lane`](crate::ui::app), which the keyboard // reads, so focus and layout cannot disagree. let on_orchestrator = match rows.get(active) { - Some(RailRow::Agent(_)) => lanes + Some(RailRow::Lane(AgentRow::Lane { .. })) => lanes .get(lane_index) .map(|l| l.role == AgentRole::Orchestrator) .unwrap_or(true), diff --git a/src/tui/src/ui/app/render/agents/rail/mod.rs b/src/tui/src/ui/app/render/agents/rail/mod.rs index b2a8e9f21..a6e6ec5cd 100644 --- a/src/tui/src/ui/app/render/agents/rail/mod.rs +++ b/src/tui/src/ui/app/render/agents/rail/mod.rs @@ -17,7 +17,7 @@ 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, NEW_AGENT_LABEL}; use super::super::super::types::App; use super::super::color; use super::types::{AgentsPanes, Selection}; @@ -89,19 +89,21 @@ impl App { let waiting_sessions = std::collections::HashSet::new(); let now = medulla::clock::now_millis(); match row { - RailRow::Harness(_) => [false, true] - .into_iter() - .flat_map(|active| { - self.rail_row_lines( - row, - lanes, - active, - RAIL_MAX_CONTENT, - &waiting_sessions, - now, - ) - }) - .collect(), + RailRow::Session(session) if session.local.is_some() && session.task.is_none() => { + [false, true] + .into_iter() + .flat_map(|active| { + self.rail_row_lines( + row, + lanes, + active, + RAIL_MAX_CONTENT, + &waiting_sessions, + now, + ) + }) + .collect() + } _ => self.rail_row_lines(row, lanes, false, RAIL_MAX_CONTENT, &waiting_sessions, now), } } @@ -274,7 +276,34 @@ impl App { now: i64, ) -> Vec> { match row { - RailRow::Harness(session) => self.own_harness_lines(session, active, width, now), + // 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_harness_lines(local, active, width, now)); + lines + } other => wrap_line( &self.rail_row_line(other, lanes, active, waiting_sessions, now), width, @@ -293,28 +322,85 @@ impl App { now: i64, ) -> TLine<'static> { match row { - RailRow::Agent(row) => self.agent_row_line(row, lanes, active, waiting_sessions), - RailRow::NewHarness => self.new_harness_line(active), - RailRow::HarnessSeparator => TLine::from(Span::styled( - "── your sessions ──", - Style::default().add_modifier(Modifier::DIM), + RailRow::Lane(row) => self.agent_row_line(row, lanes, active, waiting_sessions), + RailRow::Host(host) => TLine::from(Span::styled( + format!("▸ {}", host.label), + Style::default() + .fg(color("blue")) + .add_modifier(Modifier::BOLD), )), - // Only reached through `rail_row_lines`, which draws a harness over - // several lines; kept total so measurement can call either. - RailRow::Harness(row) => self - .own_harness_lines(row, active, RAIL_MAX_CONTENT, now) - .into_iter() - .next() - .unwrap_or_default(), + RailRow::Agent(agent) => { + self.declared_agent_line(agent, lanes, active, waiting_sessions) + } + RailRow::NewAgent => self.new_agent_line(active), + RailRow::Session(session) => match (&session.task, &session.local) { + (Some(task), _) => self.agent_row_line( + &crate::ui::agents::AgentRow::Sub { + lane_index: session.lane_index.unwrap_or(0), + task: task.clone(), + last: session.last, + }, + lanes, + 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_harness_lines(local, active, RAIL_MAX_CONTENT, now) + .into_iter() + .next() + .unwrap_or_default(), + (None, None) => TLine::from(""), + }, } } - /// Format the `+ New session` action row. + /// Format an agent row. + /// + /// An agent with a lane keeps the lane's own line — its turn count, context + /// window, and live state are what an operator reads it for. A *declared* + /// agent with no traffic has none of that, so it says what it is instead: + /// its harness and the folder it works in, dimmed, because "declared and + /// idle" should not compete with the rows that are doing something. + fn declared_agent_line( + &self, + agent: &super::super::super::rail::AgentRailRow, + lanes: &[AgentLane], + active: bool, + waiting_sessions: &std::collections::HashSet, + ) -> TLine<'static> { + if let Some(lane_index) = agent.lane_index { + return self.agent_row_line( + &crate::ui::agents::AgentRow::Lane { lane_index }, + lanes, + active, + waiting_sessions, + ); + } + let style = if active { + self.theme.selection() + } else { + Style::default().add_modifier(Modifier::DIM) + }; + let detail = match (agent.harness(), agent.workspace()) { + (Some(harness), Some(workspace)) => format!( + " · {harness} · {}", + crate::ui::util::clip_left(workspace, 24) + ), + (Some(harness), None) => format!(" · {harness}"), + _ => String::new(), + }; + TLine::from(Span::styled(format!("○ {}{detail}", agent.label()), style)) + } + + /// Format the `+ New agent` action row. /// /// 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. - fn new_harness_line(&self, active: bool) -> TLine<'static> { + fn new_agent_line(&self, active: bool) -> TLine<'static> { let style = if active { self.theme.selection() } else { @@ -323,8 +409,8 @@ impl App { .add_modifier(Modifier::BOLD) }; TLine::from(vec![ - Span::styled(format!(" {NEW_SESSION_LABEL} "), style), - Span::styled(" ⏎ / ^T", Style::default().add_modifier(Modifier::DIM)), + Span::styled(format!(" {NEW_AGENT_LABEL} "), style), + Span::styled(" ⏎", Style::default().add_modifier(Modifier::DIM)), ]) } } diff --git a/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs b/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs index cc11fa6c7..2a4c16e6b 100644 --- a/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs +++ b/src/tui/src/ui/app/render/agents/rail/status_line_tests.rs @@ -182,9 +182,14 @@ fn rail_measurement_includes_fields_visible_only_on_the_selected_row() { path_when: FieldVisibility::Active, ..StatusLineConfig::default() }); - let row = crate::ui::app::rail::RailRow::Harness(harness_row( - "/workspace/tinyhumans/products/medulla-public", - )); + let row = + crate::ui::app::rail::RailRow::Session(Box::new(crate::ui::app::rail::SessionRailRow { + agent_id: None, + lane_index: None, + task: None, + local: Some(harness_row("/workspace/tinyhumans/products/medulla-public")), + last: true, + })); let measured = app.rail_row_measurement_lines(&row, &[]); assert!(measured.iter().any(|line| line.width() == 0)); diff --git a/src/tui/src/ui/app/render/agents/transcript.rs b/src/tui/src/ui/app/render/agents/transcript.rs index 9c57b4850..1e3ffef82 100644 --- a/src/tui/src/ui/app/render/agents/transcript.rs +++ b/src/tui/src/ui/app/render/agents/transcript.rs @@ -84,6 +84,41 @@ impl App { let inner = block.inner(area); f.render_widget(block, area); let mut header: Vec = Vec::new(); + // §A7: the orchestrator says what it started, and each entry is a way in. + // Drawn first so its screen rows are the top of the pane and the hit map + // below can be computed from a fixed offset. + self.hit_started_sessions = None; + if on_orchestrator { + let started = self.started_sessions(); + if !started.is_empty() { + header.push(TLine::from(Span::styled( + format!("sessions started · {} · click to open", started.len()), + Style::default().fg(Color::Cyan), + ))); + for session in &started { + header.push(TLine::from(Span::styled( + format!( + " ↳ {} · {} · {}", + session.agent, session.task_id, session.status + ), + Style::default().add_modifier(Modifier::DIM), + ))); + } + self.hit_started_sessions = Some(( + Rect { + // The heading is not a destination; only the entries under + // it are, so the box starts one row down. + y: inner.y.saturating_add(1), + height: (started.len() as u16).min(inner.height.saturating_sub(1)), + ..inner + }, + started + .iter() + .map(|session| session.task_id.clone()) + .collect(), + )); + } + } // What the selected agent is working on, in one line. The Work panel // beside this shows the whole picture, but it needs columns a narrow // terminal does not have — and the single most useful fact, what the diff --git a/src/tui/src/ui/app/render/settings/help.rs b/src/tui/src/ui/app/render/settings/help.rs index b369cf604..be0a7bf9e 100644 --- a/src/tui/src/ui/app/render/settings/help.rs +++ b/src/tui/src/ui/app/render/settings/help.rs @@ -49,7 +49,17 @@ impl App { TLine::from(format!( "{FOCUS_CHORD_LABEL} type into the selected harness (and take it from the orchestrator)" )), - TLine::from("Ctrl-T start a harness of your own · Ctrl-G grab it or give it back"), + TLine::from( + "Agents rail: Enter on + New agent declares one (harness type × workspace dir)", + ), + TLine::from( + "Ctrl-T opens a session of the selected agent · elsewhere it starts a loose harness", + ), + TLine::from("Ctrl-G grabs the selected session or gives it back to the orchestrator"), + TLine::from( + "Click a line of the orchestrator's \"sessions started\" block to open that session", + ), + TLine::from("Ctrl-O returns to the orchestrator (and, once there, releases the mouse)"), TLine::from(format!( "From an empty composer Esc focuses the rail · from a harness {FOCUS_CHORD_LABEL} releases to it" )), diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs new file mode 100644 index 000000000..bc9c5a7b9 --- /dev/null +++ b/src/tui/src/ui/app/session_focus.rs @@ -0,0 +1,115 @@ +//! Click-through between the orchestrator's conversation and the sessions it +//! started. +//! +//! The orchestrator says "I have started three sessions" and then goes quiet +//! while they work, and until now the only way to reach one was to know which +//! agent it landed on and arrow down to it. So its conversation carries a +//! **sessions-started block** naming each one — the agent, the task, and what it +//! is doing — and selecting an entry moves the rail cursor onto that session's +//! row, which is what makes the pane show its conversation. +//! +//! The two directions are deliberately asymmetric. Going *in* is a pointer +//! gesture (the block is in the transcript, where the pointer already is) or the +//! rail's own arrows; coming *back* is `Ctrl-O`, one chord from anywhere on the +//! tab, because an operator several sessions deep should not have to find the +//! orchestrator's row again. +//! +//! Remote sessions are out of scope here: opening one arms a read-only screen +//! mirror, which is its own phase. + +use super::rail::RailRow; +use super::types::App; + +/// One entry of the sessions-started block. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(in crate::ui::app) struct StartedSession { + /// The agent the session runs on — what the operator recognises it by. + pub(in crate::ui::app) agent: String, + /// The task that created it. Empty for a session with no task of its own. + pub(in crate::ui::app) task_id: String, + /// The session's current state, in the task board's vocabulary. + pub(in crate::ui::app) status: &'static str, + /// The rail row it selects. + pub(in crate::ui::app) row_index: usize, +} + +impl App { + /// The sessions the orchestrator started, in rail order. + /// + /// Read off the rail rather than off the event stream, so the block and the + /// tree cannot disagree about what exists — and so an entry always has a row + /// to select. Only orchestrator-originated sessions are listed: a session a + /// person spun up was not "started by the orchestrator", and putting it in + /// this block would claim the orchestrator did something it did not. + pub(in crate::ui::app) fn started_sessions(&self) -> Vec { + self.rail_rows() + .into_iter() + .enumerate() + .filter_map(|(row_index, row)| { + let RailRow::Session(session) = &row else { + return None; + }; + if session.origin().is_user() { + return None; + } + let task = session.task.as_ref()?; + Some(StartedSession { + agent: session.agent_id.clone().unwrap_or_default(), + task_id: task.task_id.clone(), + status: task.status.label(), + row_index, + }) + }) + .collect() + } + + /// Move focus to the session serving `task_id`, if the rail still lists it. + /// + /// Addressed by task rather than by row index because the rail is rebuilt + /// every frame: a session can end between the click landing and this running, + /// and a stale index would put the cursor on whatever took its place. A task + /// that is no longer served is reported instead. + pub(in crate::ui::app) fn focus_session_for_task(&mut self, task_id: &str) -> bool { + let Some(session) = self + .started_sessions() + .into_iter() + .find(|session| session.task_id == task_id) + else { + self.set_status(format!("No session is running {task_id}")); + return false; + }; + // Safe by construction: the index came from the list this call just + // built, so nothing can have moved between resolving it and using it. + self.tab_index = super::types::tab_pos("Agents"); + self.agent_index = session.row_index; + self.agent_scroll = 0; + self.chat_scroll = 0; + // The rail owns the keyboard on a session row: there is no composer under + // one, so leaving focus on the composer would drive a caret that is not + // drawn. + self.focus_agents_rail(); + self.set_status(format!( + "{} · {} · ^O returns to the orchestrator", + session.agent, session.task_id + )); + true + } + + /// Return to the orchestrator's conversation — the `Ctrl-O` half of §A7. + /// + /// Puts the cursor on the orchestrator's own lane row and hands the keyboard + /// to the composer, because that lane *is* the text box: arriving on it with + /// focus still on the rail would mean pressing one more key before typing. + pub(in crate::ui::app) fn focus_orchestrator(&mut self) { + let Some(index) = self.orchestrator_row_index() else { + self.set_status("No conversation to return to yet"); + return; + }; + self.tab_index = super::types::tab_pos("Agents"); + self.agent_index = index; + self.agent_scroll = 0; + self.chat_scroll = 0; + self.focus_agents_composer(); + self.set_status("Back to the orchestrator"); + } +} diff --git a/src/tui/src/ui/app/session_focus_tests.rs b/src/tui/src/ui/app/session_focus_tests.rs new file mode 100644 index 000000000..5b3fabffe --- /dev/null +++ b/src/tui/src/ui/app/session_focus_tests.rs @@ -0,0 +1,93 @@ +//! Click-through between the orchestrator's conversation and the sessions it +//! started, and the chord back. + +use super::rail::tests::app; +use super::rail::RailRow; +use super::types::tab_pos; + +#[test] +fn the_orchestrator_lists_the_sessions_it_started() { + let app = app(); + let started = app.started_sessions(); + assert!( + !started.is_empty(), + "the demo fixture dispatches at least one task" + ); + for session in &started { + assert!(!session.task_id.is_empty(), "each entry names its task"); + assert!( + matches!( + app.rail_rows().get(session.row_index), + Some(RailRow::Session(_)) + ), + "each entry points at a session row" + ); + } +} + +#[test] +fn opening_an_entry_moves_the_rail_selection_onto_that_session() { + let mut app = app(); + let entry = app + .started_sessions() + .into_iter() + .next() + .expect("a started session"); + + assert!(app.focus_session_for_task(&entry.task_id)); + + assert_eq!(app.tab(), "Agents"); + assert_eq!(app.agent_index(), entry.row_index, "the rail follows"); + assert_eq!( + app.rail_rows() + .get(app.agent_index()) + .and_then(RailRow::task) + .map(|task| task.task_id.clone()), + Some(entry.task_id), + "the pane is now that session's conversation" + ); + // No composer is drawn under a session row, so the keyboard must not be left + // on one. + assert!(app.agents_rail_focused()); + assert!(app.status().contains("^O"), "{}", app.status()); +} + +#[test] +fn a_task_no_session_is_serving_is_reported_by_name() { + let mut app = app(); + assert!(!app.focus_session_for_task("no-such-task")); + assert!(app.status().contains("no-such-task"), "{}", app.status()); + assert!(app.on_orchestrator_lane(), "the cursor did not move"); +} + +#[test] +fn a_session_the_operator_started_is_not_claimed_by_the_orchestrator() { + // The block says what the *orchestrator* started. A user-originated session + // has no task, so it is filtered out twice over — by origin and by task — + // and every entry that survives can name a task. + let app = app(); + assert!(app + .started_sessions() + .iter() + .all(|session| !session.task_id.is_empty())); +} + +#[test] +fn the_chord_back_returns_to_the_orchestrator_and_its_composer() { + let mut app = app(); + let entry = app + .started_sessions() + .into_iter() + .next() + .expect("a started session"); + app.focus_session_for_task(&entry.task_id); + + app.focus_orchestrator(); + + assert_eq!(app.tab_index, tab_pos("Agents")); + assert!(app.on_orchestrator_lane(), "the cursor is on the lane"); + assert!( + app.agents_composer_shown(), + "and the keyboard is in the text box" + ); +} diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index 5d8608b78..b23161fd3 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -140,6 +140,7 @@ impl App { hit_agents: None, hit_harness: None, hit_threads: None, + hit_started_sessions: None, hit_context: None, hit_workflow_preview: None, hit_nav: Default::default(), @@ -558,14 +559,14 @@ impl App { let lanes = self.lanes(); let rows = self.rail_rows(); match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { - Some(super::rail::RailRow::Agent(row)) => row + Some(super::rail::RailRow::Lane(row)) => row .lane_index() .and_then(|index| lanes.get(index)) .map(|lane| lane.role == AgentRole::Orchestrator) // An empty lane list means the orchestrator lane is all there is. .unwrap_or(true), - // The action row and the operator's own harnesses are not lanes and - // have no conversation of their own. + // Hosts, agents, sessions and the action row are not lanes and have + // no conversation of their own. Some(_) => false, None => true, } @@ -584,7 +585,7 @@ impl App { pub(in crate::ui::app) fn orchestrator_row_index(&self) -> Option { let lanes = self.lanes(); self.rail_rows().iter().position(|row| match row { - super::rail::RailRow::Agent(AgentRow::Lane { lane_index }) => lanes + super::rail::RailRow::Lane(AgentRow::Lane { lane_index }) => lanes .get(*lane_index) .map(|lane| lane.role == AgentRole::Orchestrator) // Matches the same fallback `on_orchestrator_lane` makes: with diff --git a/src/tui/src/ui/app/tests.rs b/src/tui/src/ui/app/tests.rs index 555dd8b3d..dcfa29926 100644 --- a/src/tui/src/ui/app/tests.rs +++ b/src/tui/src/ui/app/tests.rs @@ -161,6 +161,7 @@ fn enter_answers_the_harness_picker_not_the_harness_behind_it() { // attached instead of advancing to the workspace step. a.harness_pane_session = Some("already-running".to_string()); a.harness_picker = Some(HarnessPicker { + purpose: super::types::PickerPurpose::Spawn, choices: vec![HarnessChoice::native( medulla::protocol::HarnessProvider::Claude, )], @@ -365,10 +366,9 @@ fn select_first_task(app: &mut App) -> Option { app.tab_index = tab("Agents"); let rows = app.rail_rows(); let idx = rows.iter().position(|r| { - matches!( - r, - super::rail::RailRow::Agent(crate::ui::agents::AgentRow::Sub { .. }) - ) + // A dispatched task is a *session* of its agent now, not a sublane of + // its lane: one row type for everything an agent is running. + matches!(r, super::rail::RailRow::Session(session) if session.task.is_some()) })?; app.agent_index = idx; app.retarget_watch() @@ -509,12 +509,7 @@ fn selecting_a_lane_rather_than_a_task_watches_nothing() { let rows = app.rail_rows(); let idx = rows .iter() - .position(|r| { - matches!( - r, - super::rail::RailRow::Agent(crate::ui::agents::AgentRow::Lane { .. }) - ) - }) + .position(|r| matches!(r, super::rail::RailRow::Agent(_))) .expect("the fixture has a lane row"); app.agent_index = idx; assert!(app.retarget_watch().is_none()); diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types.rs index 8babecabe..881d4904a 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types.rs @@ -553,8 +553,25 @@ pub(super) enum Overlay { ResumePicker, } -/// The modal state for the "start a harness" picker overlay. +/// What the harness/workspace picker is being used for. +/// +/// The same two steps — pick a CLI, pick a directory — answer both questions the +/// Agents tab asks, and they differ only in what happens at the end. Declaring an +/// agent writes `harness × workspace` to the config and starts nothing; spawning +/// starts a session and declares nothing. Carrying the intent on the picker keeps +/// one overlay rather than two that would drift apart. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) enum PickerPurpose { + /// Start a session here and now, declaring nothing — the `/harness` path. + Spawn, + /// Declare an agent: `harness × workspace`, named on the step after. + DeclareAgent, +} + +/// The modal state for the harness/workspace picker overlay. pub(super) struct HarnessPicker { + /// What confirming the last step will do. + pub(super) purpose: PickerPurpose, /// Installed providers and registered presets, in offer order. pub(super) choices: Vec, /// The highlighted row. @@ -681,6 +698,28 @@ pub(super) enum PromptKind { HostEditLabel(String), /// Declare another directory this device may work in. WorkspaceAdd, + /// Name the agent about to be declared for this `harness × workspace`. + /// + /// Blank accepts the id [`suggest_agent_id`](medulla::runtime::suggest_agent_id) + /// minted from the directory, which is how a person refers to the agent + /// anyway — the prompt exists for the case where it is not. + AgentName { + /// The CLI the agent runs. + harness: String, + /// The absolute directory its sessions work in. + workspace: String, + }, + /// Name the session about to be opened under an already-declared agent. + /// + /// A session a person spins up is [`SessionOrigin::User`](crate::worker::pty::SessionOrigin) + /// and is the only kind that carries a name; a dispatched one is labelled + /// from its task. Blank leaves it unnamed rather than inventing one. + SessionName { + /// The agent whose harness and workspace the session inherits. + agent_id: String, + /// Whether the orchestrator may dispatch into it — ownership at birth. + managed: bool, + }, /// Add a named OpenRouter-backed coding harness. CustomHarnessAdd, /// Edit the custom harness with the given stable id. @@ -1023,6 +1062,13 @@ pub struct App { pub(super) hit_harness: Option<(Rect, String)>, /// The threads strip's hit box and its first visible row, for click-to-switch. pub(super) hit_threads: Option<(Rect, usize)>, + /// Where the orchestrator's "sessions started" block drew, and the task each + /// of its lines opens (§A7). + /// + /// Tasks rather than row indices: the rail is rebuilt every frame, so an + /// index recorded during the draw can name a different row by the time the + /// click lands. A task id either still has a session or does not. + pub(super) hit_started_sessions: Option<(Rect, Vec)>, pub(super) hit_context: Option, /// The selected workflow step's preview, for pointer-wheel scrolling. pub(super) hit_workflow_preview: Option, diff --git a/src/tui/tests/feature_agents_focus.rs b/src/tui/tests/feature_agents_focus.rs index ea00e1983..4234b9f11 100644 --- a/src/tui/tests/feature_agents_focus.rs +++ b/src/tui/tests/feature_agents_focus.rs @@ -278,7 +278,11 @@ fn clicking_a_row_selects_it_and_takes_focus() { // the fleet and template rows, so every click below the lanes indexed off // the end of the shorter list and silently did nothing. let mut app = agents_app(); - let worker_row = rendered_row(&mut app, 120, 40, "dev-1"); + // Matched on the rail's own spelling of the row. The orchestrator's + // "sessions started" block names the same agent in the pane beside it, and + // it draws higher up, so a bare `dev-1` would find that line instead and + // click whatever the rail happened to have at the same height. + let worker_row = rendered_row(&mut app, 120, 40, "[CODEX] dev-1"); let start = app.agent_index(); click(&mut app, 3, worker_row); diff --git a/src/tui/tests/feature_harness_control.rs b/src/tui/tests/feature_harness_control.rs index 72a7d3215..b1b9d6208 100644 --- a/src/tui/tests/feature_harness_control.rs +++ b/src/tui/tests/feature_harness_control.rs @@ -215,13 +215,19 @@ fn ctrl_t_opens_the_picker_and_enter_starts_an_unmanaged_harness() { #[test] fn an_unmanaged_harness_gets_its_own_rail_row() { // Lanes come from task events, so a session nothing dispatched into folds to - // no lane at all. Without its own group it would be running and invisible. + // no lane at all. Without a row of its own it would be running and invisible. + // + // It no longer gets a *group* of its own: §A0 collapsed the `── your + // harnesses ──` divider, because an operator-started session and a + // dispatched task are one thing seen from two sides. It is a session row on + // the tree like any other. let sessions = PtyManager::new(); let mut app = app_with_harnesses(sessions.clone()); let _ = user_session(&sessions); let out = render(&mut app, 140, 44); - assert!(out.contains("your sessions"), "{out}"); + assert!(!out.contains("your harnesses"), "no second group: {out}"); + assert!(!out.contains("your sessions"), "no second group: {out}"); assert!(out.contains("unmanaged"), "{out}"); sessions.shutdown(); From ecff523224e07e389121424ea6cbef0905cda104 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Tue, 4 Aug 2026 23:29:49 +0530 Subject: [PATCH 3/6] =?UTF-8?q?refactor(ui):=20source=20both=20tabs'=20Hos?= =?UTF-8?q?t=20=E2=86=92=20Agent=20tree=20from=20one=20projection?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Agents rail was written before the Hosts tab grew the shared `Host → Agent` projection, so it built its own host and agent levels out of declarations plus the event fold. Two derivations of one tree is two answers to "what exists", and the rail left a follow-up note saying so. The rail's host and agent levels now come from `medulla::ui::hosts::host_rows` — the same call the Hosts tab renders — with the folded lanes placed onto the agents it produces. A lane the tree does not know (a backend-side roster agent, a peer session) still keeps a row, so nothing that used to be visible disappears. Session rows are unchanged: they are the rail's own level, dispatched ones resolved by the roster id the hub filed them under and operator-started ones by harness × workspace. Progressive disclosure now reads off the projection: host headers appear once it holds more than one host, which is also when a registered remote peer first becomes a machine of its own rather than a bare lane. Two fixes fall out of making the projection load-bearing for both tabs: an agent declared with no host id is claimed by the machine looking at it (it was rendered by the rail and dropped by the Hosts tab), and a peer that reached the registry twice is one agent, not two. Co-Authored-By: Claude --- src/sdk/src/ui/hosts/mod.rs | 42 ++++- src/sdk/src/ui/hosts/tests.rs | 61 +++++++ src/tui/src/ui/app/rail/mod.rs | 237 +++++++++++++++------------ src/tui/src/ui/app/rail/resolve.rs | 65 +------- src/tui/src/ui/app/rail/tests.rs | 106 +++++++++--- src/tui/src/ui/app/rail/types.rs | 60 +++---- src/tui/tests/feature_agent_lanes.rs | 15 +- 7 files changed, 359 insertions(+), 227 deletions(-) diff --git a/src/sdk/src/ui/hosts/mod.rs b/src/sdk/src/ui/hosts/mod.rs index cd069de3f..58a953312 100644 --- a/src/sdk/src/ui/hosts/mod.rs +++ b/src/sdk/src/ui/hosts/mod.rs @@ -52,26 +52,42 @@ pub fn host_rows( let mut rows: Vec = Vec::new(); let mut claimed: Vec<&str> = Vec::new(); - for local in locals { + // The first local host also claims the agents that name no host at all: an + // agent nothing places belongs to the machine looking at it. They are + // declared in *this* config, so the alternative is not "somewhere else" but + // "nowhere" — an agent written down and rendered by neither tab. + for (index, local) in locals.iter().enumerate() { rows.push(local_row( &local.id, &local.name, workers, declarations, + index == 0, &mut claimed, )); } // Declared, but on a host id this config does not (or no longer) describes. + // An unplaced agent lands here only when there is no local host to claim it, + // which is the one arrangement where it would otherwise vanish. for declaration in declarations { let host_id = declaration.host_id.trim(); - if host_id.is_empty() || rows.iter().any(|row| row.id == host_id) { + if rows.iter().any(|row| row.id == host_id) { continue; } + if host_id.is_empty() && !locals.is_empty() { + continue; + } + let label = if host_id.is_empty() { + "this device" + } else { + host_id + }; rows.push(local_row( host_id, - host_id, + label, workers, declarations, + host_id.is_empty(), &mut claimed, )); } @@ -87,7 +103,12 @@ pub fn host_rows( { Some(row) => { row.detail_worker.get_or_insert_with(|| worker.id.clone()); - row.agents.push(agent_from_worker(worker, false)); + // One agent per id: the same peer can reach the registry twice + // (added by hand, then advertised), and an agent listed twice is + // an agent an operator thinks they have two of. + if !row.agents.iter().any(|agent| agent.agent_id == worker.id) { + row.agents.push(agent_from_worker(worker, false)); + } } None => rows.push(HostRow { id: address.to_string(), @@ -107,15 +128,23 @@ pub fn host_rows( /// The undeclared tail is the migration seed (an install that predates /// declarations advertises agents nobody wrote down) and it stays editable: /// assigning it a role is what writes the declaration that makes the role stick. +/// +/// `unplaced` makes this row the home for declarations that name no host — see +/// [`host_rows`]. Exactly one row ever sets it, or an agent would be listed +/// under every local host at once. fn local_row<'a>( id: &str, label: &str, workers: &'a [WorkerInfo], declarations: &[AgentDeclaration], + unplaced: bool, claimed: &mut Vec<&'a str>, ) -> HostRow { let mut agents: Vec = Vec::new(); - for declaration in declarations.iter().filter(|d| d.on_host(id)) { + for declaration in declarations + .iter() + .filter(|d| d.on_host(id) || (unplaced && d.host_id.trim().is_empty())) + { let worker = workers .iter() .find(|worker| worker.id.trim() == declaration.agent_id.trim()); @@ -180,9 +209,12 @@ fn agent_from_declaration( .or_else(|| worker.and_then(|worker| worker.workspace.clone())); HostAgentRow { agent_id: declaration.agent_id.clone(), + // A blank name is not a name: a declaration whose name field holds only + // spaces would otherwise render as a row with nothing on it. label: declaration .name .clone() + .filter(|name| !name.trim().is_empty()) .or_else(|| worker.and_then(|worker| worker.label.clone())) .unwrap_or_else(|| declaration.agent_id.clone()), harness: Some(declaration.harness.clone()), diff --git a/src/sdk/src/ui/hosts/tests.rs b/src/sdk/src/ui/hosts/tests.rs index b26cfba88..1c9b2768e 100644 --- a/src/sdk/src/ui/hosts/tests.rs +++ b/src/sdk/src/ui/hosts/tests.rs @@ -120,6 +120,19 @@ fn remote_peers_group_under_their_address_and_are_read_only() { ); } +#[test] +fn one_peer_registered_twice_is_one_agent() { + // The same peer reached the registry from two directions — added by hand and + // advertised. Listing it twice would read as two machines' worth of capacity. + let rows = host_rows( + &[worker("addr-1", "addr-1"), worker("addr-1", "addr-1")], + &[], + &[this_device()], + ); + assert_eq!(rows.len(), 2); + assert_eq!(rows[1].agents.len(), 1, "{rows:?}"); +} + #[test] fn the_preview_reads_capacity_from_whichever_entry_probed_the_machine() { // Capacity belongs to the machine, so the host row points at the entry that @@ -146,6 +159,54 @@ fn a_declaration_naming_an_unconfigured_host_still_gets_a_local_row() { assert!(rows[1].agents[0].editable); } +#[test] +fn an_agent_that_names_no_host_belongs_to_the_machine_looking_at_it() { + // Both tabs draw this tree, and the Agents rail has always drawn an unplaced + // agent beside the local ones. Dropping it here would mean the two lenses + // disagree about what exists — and on the Hosts tab it would simply vanish. + let unplaced = AgentDeclaration::new("drifter", "", "claude", "/w/drifter"); + let rows = host_rows(&[], &[unplaced], &[this_device()]); + + assert_eq!(rows.len(), 1, "no second machine is conjured: {rows:?}"); + assert_eq!(rows[0].agents[0].agent_id, "drifter"); + assert!(rows[0].agents[0].editable); +} + +#[test] +fn an_unplaced_agent_survives_a_config_with_no_local_host_at_all() { + let unplaced = AgentDeclaration::new("drifter", "", "claude", "/w/drifter"); + let rows = host_rows(&[], &[unplaced], &[]); + + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].label, "this device"); + assert_eq!(rows[0].kind, HostKind::Local); + assert_eq!(rows[0].agents[0].agent_id, "drifter"); +} + +#[test] +fn only_the_first_local_host_claims_the_unplaced() { + // Two local hosts and one agent naming neither: it must appear once, under + // the primary, not under every host on the machine. + let mut second = this_device(); + second.id = "local-api".into(); + second.name = "api".into(); + second.primary = false; + let unplaced = AgentDeclaration::new("drifter", "", "claude", "/w/drifter"); + + let rows = host_rows(&[], &[unplaced], &[this_device(), second]); + assert_eq!(rows.len(), 2); + assert_eq!(rows[0].agents.len(), 1); + assert!(rows[1].agents.is_empty(), "claimed once: {rows:?}"); +} + +#[test] +fn a_blank_declared_name_falls_back_to_the_agent_id() { + let mut declared = AgentDeclaration::new("a1", "this-device", "claude", "/w"); + declared.name = Some(" ".into()); + let rows = host_rows(&[], &[declared], &[this_device()]); + assert_eq!(rows[0].agents[0].label, "a1"); +} + #[test] fn an_agent_named_by_a_declaration_is_never_also_a_remote_host() { // The roster entry is registered under a different address than the host id diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 375f227ab..62ee82f71 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -15,20 +15,25 @@ //! └ debug login ← a session the operator started //! ``` //! -//! **Agents come from declarations**, not from traffic: a lane is folded from -//! task events, so an agent nothing had been dispatched to produced no row at -//! all — which made the rail a list of what happened rather than of what exists. -//! Declarations are the source; lanes attach to them where they exist, and a lane -//! nothing declares still gets a row of its own so nothing that used to be -//! visible disappears. +//! **Agents come from the tree, not from traffic**: a lane is folded from task +//! events, so an agent nothing had been dispatched to produced no row at all — +//! which made the rail a list of what happened rather than of what exists. //! -//! Row shapes live in [`types`]; the two resolution rules (session → agent, -//! agent → host) in [`resolve`]; this module is the assembly. - -use std::collections::HashSet; +//! The host and agent levels are the shared `Host → Agent` projection +//! ([`medulla::ui::hosts::host_rows`]) — literally the same call the Hosts tab +//! renders, so the two lenses cannot disagree about what exists. Lanes attach to +//! the agents it produces; a lane for an agent the projection does not know (a +//! backend-side roster agent, a peer session) still gets a row of its own, so +//! nothing that used to be visible disappears. **Sessions** are the rail's own +//! level and are resolved here: a dispatched one by the roster id the hub filed +//! its task under, an operator-started one by [`resolve::agent_for_session`]. +//! +//! Row shapes live in [`types`]; the session → agent rule in [`resolve`]; this +//! module is the assembly. use medulla::config::agent_declarations_for_host; use medulla::runtime::AgentDeclaration; +use medulla::ui::hosts::{HostAgentRow, HostKind, HostRow}; use super::types::App; use crate::ui::agents::{AgentLane, AgentRole, AgentRow}; @@ -60,6 +65,14 @@ struct AgentGroup { hidden: usize, } +/// 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. /// @@ -96,17 +109,18 @@ impl App { /// /// Assembled in three passes so each one answers a single question. First the /// fold is split — the orchestrator and the function lanes keep their rows, - /// the agent lanes become groups keyed by agent id. Then the declarations - /// this machine holds are folded in, adding every agent with no traffic. Last - /// the live PTY sessions are attached to whichever agent declares the - /// directory they run in, and the whole thing is flattened under host rows — - /// which appear only when there is more than one machine to tell apart. + /// the agent lanes become groups keyed by agent id. Then the shared + /// `Host → Agent` projection places those groups, adding every agent that has + /// no traffic and every host that holds one. Last the live PTY sessions are + /// attached to whichever agent declares the directory they run in, and the + /// whole thing is flattened under host rows — which appear only when there is + /// more than one host to tell apart. pub(super) fn rail_rows(&self) -> Vec { let lanes = self.lanes(); - let (lane_rows, mut groups) = self.split_fold(&lanes); - self.add_declared_agents(&mut groups); - let orphans = self.attach_sessions(&mut groups); - self.flatten(lane_rows, groups, orphans) + let (lane_rows, folded) = self.split_fold(&lanes); + let mut hosts = place_agents(&self.host_tree(), folded); + let orphans = self.attach_sessions(&mut hosts); + self.flatten(lane_rows, hosts, orphans) } /// Split the folded rows into the non-agent ones and the per-agent groups. @@ -153,29 +167,25 @@ impl App { /// The group an agent-role lane opens. /// /// The lane's `agent_id` is the roster id the hub filed its tasks under, so - /// it is also the key a declaration is looked up by — the two cannot drift, - /// because the roster is a projection of the declarations. + /// it is also the key the projection's agent is matched by — the two cannot + /// drift, because the roster is a projection of the declarations. The host id + /// here is only a hint for a lane the projection turns out not to know; a + /// placed agent takes its host from the tree. fn group_for_lane(&self, lane: &AgentLane, lane_index: usize) -> AgentGroup { let agent_id = lane .agent_id .clone() .unwrap_or_else(|| lane.key.trim_start_matches("agent:").to_string()); - let declaration = - medulla::config::agent_declaration(self.agent_declarations(), &agent_id).cloned(); - let host_id = declaration + let host_id = lane + .descriptor .as_ref() - .map(|declaration| declaration.host_id.clone()) - .or_else(|| { - lane.descriptor - .as_ref() - .and_then(|descriptor| descriptor.host_id.clone()) - }) + .and_then(|descriptor| descriptor.host_id.clone()) .unwrap_or_default(); AgentGroup { row: AgentRailRow { agent_id, host_id, - declaration, + agent: None, lane_index: Some(lane_index), }, sessions: Vec::new(), @@ -183,46 +193,18 @@ impl App { } } - /// Add a row for every declared agent that has no lane. - /// - /// This is the whole point of sourcing the rail from declarations: an agent - /// you declared and have not dispatched to is a real, targetable thing, and a - /// rail that only lists traffic cannot show it. - /// - /// Every declaration, not only this machine's: the tab *is* the topology, so - /// an agent declared against another host belongs on it — under that host's - /// row — even before the link handshake starts exchanging rosters. Creating - /// one is still local-only, which the create flow enforces rather than this. - fn add_declared_agents(&self, groups: &mut Vec) { - let seen: HashSet = groups - .iter() - .map(|group| group.row.agent_id.trim().to_string()) - .collect(); - for declaration in self.agent_declarations().iter().cloned() { - if seen.contains(declaration.agent_id.trim()) { - continue; - } - groups.push(AgentGroup { - row: AgentRailRow { - agent_id: declaration.agent_id.clone(), - host_id: declaration.host_id.clone(), - declaration: Some(declaration), - lane_index: None, - }, - sessions: Vec::new(), - hidden: 0, - }); - } - } - /// Attach the live local sessions to their agents, returning the unclaimed. /// /// An unclaimed session runs in a directory nothing declares. It is listed at - /// the end rather than dropped — a harness that is running, costing tokens + /// the end rather than dropped — a session that is running, costing tokens /// and invisible is the failure the old `── your harnesses ──` group existed /// to prevent — and it is what the inline create-agent offer is for. - fn attach_sessions(&self, groups: &mut [AgentGroup]) -> Vec { + fn attach_sessions(&self, hosts: &mut [HostGroup]) -> Vec { let declarations = self.local_agent_declarations(); + let mut groups: Vec<&mut AgentGroup> = hosts + .iter_mut() + .flat_map(|host| host.agents.iter_mut()) + .collect(); let mut orphans = Vec::new(); for row in self.own_session_rows() { let agent_id = resolve::agent_for_session(&declarations, &row) @@ -251,7 +233,7 @@ impl App { fn flatten( &self, lane_rows: Vec, - mut groups: Vec, + hosts: Vec, orphans: Vec, ) -> Vec { let mut rows: Vec = lane_rows.into_iter().map(RailRow::Lane).collect(); @@ -260,23 +242,15 @@ impl App { if self.harnesses.is_some() { rows.push(RailRow::NewAgent); } - let local = self.local_host_id(); - let local = local.trim(); - let show_hosts = - resolve::has_remote_host(groups.iter().map(|group| group.row.host_id.as_str()), local); - for host_id in host_order(&groups, local) { + // 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 { - let is_local = host_id == local; - rows.push(RailRow::Host(HostRailRow { - label: resolve::host_label(&host_id, is_local), - host_id: host_id.clone(), - local: is_local, - })); + rows.push(RailRow::Host(host.row)); } - for group in groups - .iter_mut() - .filter(|group| placed_on(&group.row.host_id, local) == host_id) - { + for group in &mut host.agents { push_group(&mut rows, group); } } @@ -347,31 +321,90 @@ impl App { } } -/// The host an agent is drawn under: its own, or the local one when it names -/// none. An agent nothing places belongs to the machine looking at it. -fn placed_on(host_id: &str, local: &str) -> String { - let host_id = host_id.trim(); - if host_id.is_empty() { - local.to_string() - } else { - host_id.to_string() +/// Place the folded lanes onto the shared `Host → Agent` tree. +/// +/// The tree decides what exists and in what order — it is the same projection +/// the Hosts tab renders, so the two lenses list the same agents under the same +/// hosts. A lane is matched onto its agent by id and contributes only what the +/// projection cannot know: the transcript behind the row, and the tasks folded +/// under it. +/// +/// A lane the tree does not know keeps a row of its own. That is not a leftover +/// case: an agent the backend rosters is not necessarily one this hub declares +/// or advertises, and a rail that dropped it would hide work that is running. +fn place_agents(tree: &[HostRow], folded: Vec) -> Vec { + let mut folded: Vec> = folded.into_iter().map(Some).collect(); + let mut hosts: Vec = tree + .iter() + .map(|host| HostGroup { + row: HostRailRow { + host_id: host.id.clone(), + label: host.label.clone(), + local: host.kind == HostKind::Local, + }, + agents: host + .agents + .iter() + .map(|agent| placed_agent(agent, &host.id, &mut folded)) + .collect(), + }) + .collect(); + for group in folded.into_iter().flatten() { + match unplaced_host(&hosts, &group.row.host_id) { + Some(index) => hosts[index].agents.push(group), + None => hosts.push(HostGroup { + row: HostRailRow { + host_id: group.row.host_id.clone(), + label: "unplaced".to_string(), + local: false, + }, + agents: vec![group], + }), + } } + hosts +} + +/// One agent of the tree, with its folded lane taken if it has one. +fn placed_agent( + agent: &HostAgentRow, + host_id: &str, + folded: &mut [Option], +) -> AgentGroup { + let taken = folded + .iter_mut() + .find(|group| { + group + .as_ref() + .is_some_and(|group| group.row.agent_id.trim() == agent.agent_id.trim()) + }) + .and_then(Option::take); + let mut group = taken.unwrap_or_else(|| AgentGroup { + row: AgentRailRow { + agent_id: agent.agent_id.clone(), + host_id: host_id.to_string(), + agent: None, + lane_index: None, + }, + sessions: Vec::new(), + hidden: 0, + }); + group.row.host_id = host_id.to_string(); + group.row.agent = Some(agent.clone()); + group } -/// The hosts to draw, local first and the rest in the order their agents appear. +/// Where a lane the tree does not know is drawn: the host it names if that host +/// is on the tree, else the machine looking at it. /// -/// Sorting by host id would reorder the rail whenever a peer's key happened to -/// sort differently from the last machine that connected. -fn host_order(groups: &[AgentGroup], local: &str) -> Vec { - let mut order: Vec = Vec::new(); - for group in groups { - let host_id = placed_on(&group.row.host_id, local); - if !order.contains(&host_id) { - order.push(host_id); - } - } - order.sort_by_key(|host_id| host_id.as_str() != local); - order +/// `None` only when there is no host at all to hang it from, which is a device +/// that hosts nothing and has declared nothing. +fn unplaced_host(hosts: &[HostGroup], host_id: &str) -> Option { + let host_id = host_id.trim(); + hosts + .iter() + .position(|host| !host_id.is_empty() && host.row.host_id.trim() == host_id) + .or_else(|| hosts.iter().position(|host| host.row.local)) } /// Push one agent row and the sessions under it, capped and tree-marked. diff --git a/src/tui/src/ui/app/rail/resolve.rs b/src/tui/src/ui/app/rail/resolve.rs index d1f811616..a227d5975 100644 --- a/src/tui/src/ui/app/rail/resolve.rs +++ b/src/tui/src/ui/app/rail/resolve.rs @@ -1,7 +1,9 @@ -//! Resolving a session to the agent that owns it, and an agent to its host. +//! Resolving a session to the agent that owns it. //! -//! Two directions, one rule each, kept apart from the assembly in -//! [`super`] so both can be tested without building an [`App`](super::super::types::App). +//! The rail's own level, and the only one it resolves for itself: hosts and +//! agents come from the shared projection (see [`super`]). Kept apart from the +//! assembly so the rule can be tested without building an +//! [`App`](super::super::types::App). //! //! A **dispatched** session already knows its agent: the hub files a task under //! the roster id the dispatch named ([`lane_id`]), and that id is the lane's @@ -14,14 +16,6 @@ //! of. No match means the directory is undeclared, which is a real state the //! rail shows rather than hides — and the prompt for inline agent creation. //! -//! **Follow-up (host level only).** The Hosts tab grew a shared `Host → Agent` -//! projection (`medulla::ui::hosts::host_rows`, with device-local resolution in -//! `medulla::config::local_hosts`) after this branch was cut, and it is not on -//! this tree yet. Once the two are merged, [`has_remote_host`] and -//! [`host_label`] here — and the agent level in [`super::App::rail_rows`] — -//! should source from it, so the two lenses can never disagree about what -//! exists. Session rows stay here: they are the Agents tab's own level. -//! //! [`lane_id`]: https://docs.rs/medulla use medulla::runtime::AgentDeclaration; @@ -66,38 +60,6 @@ fn normalize_path(path: &str) -> &str { } } -/// Whether the rail should draw host headers at all. -/// -/// Progressive disclosure: with only the local host, agents sit at the top level -/// and a permanent `mac-studio ▸` wrapper would add a level of nesting to the -/// surface an operator uses most. A single *unknown* host id does not count as a -/// remote — an agent nothing places is drawn beside the local ones rather than -/// conjuring a second machine out of a missing field. -pub fn has_remote_host<'a>(host_ids: impl IntoIterator, local: &str) -> bool { - let local = local.trim(); - host_ids - .into_iter() - .map(str::trim) - .any(|host_id| !host_id.is_empty() && host_id != local) -} - -/// The display label for a host row. -/// -/// The local machine says so in words; a remote one is named by its address, -/// shortened when the address is a raw key — a 44-character base58 public key is -/// the widest thing the rail would ever hold. -pub fn host_label(host_id: &str, local: bool) -> String { - if local { - return "this device".to_string(); - } - let host_id = host_id.trim(); - if host_id.is_empty() { - "unplaced".to_string() - } else { - crate::ui::util::short_if_address(host_id) - } -} - #[cfg(test)] mod tests { use super::*; @@ -188,21 +150,4 @@ mod tests { ) .is_none()); } - - #[test] - fn only_a_second_named_host_counts_as_remote() { - assert!(!has_remote_host(["local", "local", ""], "local")); - assert!(has_remote_host(["local", "studio"], "local")); - assert!( - !has_remote_host(["", ""], "local"), - "an unplaced agent is not a machine of its own" - ); - } - - #[test] - fn the_local_host_says_so_and_a_remote_one_is_named() { - assert_eq!(host_label("anything", true), "this device"); - assert_eq!(host_label("studio", false), "studio"); - assert_eq!(host_label(" ", false), "unplaced"); - } } diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index ba23c80c6..20744c4fc 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -202,7 +202,7 @@ fn a_session_in_an_undeclared_directory_is_still_listed() { } #[test] -fn host_rows_appear_only_once_a_remote_host_exists() { +fn host_rows_appear_only_once_a_second_host_exists() { let mut app = app(); app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new("local-claude", "", "claude", "/work")]; @@ -223,23 +223,68 @@ fn host_rows_appear_only_once_a_remote_host_exists() { "claude", "/work", )); - let hosts: Vec = app + let hosts: Vec<(String, bool)> = app .rail_rows() .into_iter() .filter_map(|row| match row { - RailRow::Host(host) => Some(host.host_id), + RailRow::Host(host) => Some((host.host_id, host.local)), _ => None, }) .collect(); assert!( - hosts.contains(&"studio".to_string()), - "the remote machine gets a header: {hosts:?}" + hosts.iter().any(|(host_id, _)| host_id == "studio"), + "the second machine gets a header: {hosts:?}" ); assert!( hosts.len() >= 2, - "so does the local one, once there is a second: {hosts:?}" + "so does this one, once there is a second: {hosts:?}" ); - assert_eq!(hosts.first().map(String::as_str), Some(""), "local first"); + let local = app.local_host_refs(); + assert_eq!( + hosts.first().map(|(host_id, _)| host_id.as_str()), + local.first().map(|host| host.id.as_str()), + "this device leads, in the order the shared tree lists: {hosts:?}" + ); +} + +#[test] +fn the_rail_and_the_hosts_tab_list_the_same_agents_under_the_same_hosts() { + // The unification, asserted directly: both tabs render `host_rows`, so the + // Agents rail cannot claim an agent the Hosts tab does not have, place it on + // another host, or order them differently. The rail may hold *more* — a lane + // for a backend-side agent this hub never advertised — and those are the + // rows the tree does not cover, so they are excluded rather than asserted + // away. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![ + AgentDeclaration::new("api-claude", "", "claude", "/work/api"), + AgentDeclaration::new("web-codex", "", "codex", "/work/web"), + AgentDeclaration::new("studio-claude", "studio", "claude", "/work"), + ]; + + let expected: Vec<(String, String)> = app + .host_tree() + .into_iter() + .flat_map(|host| { + host.agents + .into_iter() + .map(move |agent| (host.id.clone(), agent.agent_id)) + }) + .collect(); + let known: Vec = expected.iter().map(|(_, agent)| agent.clone()).collect(); + let railed: Vec<(String, String)> = app + .rail_rows() + .into_iter() + .filter_map(|row| match row { + RailRow::Agent(agent) if known.contains(&agent.agent_id) => { + Some((agent.host_id, agent.agent_id)) + } + _ => None, + }) + .collect(); + + assert!(!expected.is_empty(), "the fixture declares agents"); + assert_eq!(railed, expected, "one tree, two lenses"); } #[test] @@ -258,28 +303,45 @@ fn the_create_action_is_absent_on_a_device_that_hosts_nothing() { #[test] fn an_agent_is_labelled_by_its_name_and_falls_back_to_its_id() { + // The label, the harness and the workspace all come from the shared tree, so + // an agent reads the same on both tabs. A row the tree does not cover has + // only its id, and says nothing about the harness rather than guessing. + let mut app = app(); let mut declaration = AgentDeclaration::new("api-codex", "", "codex", "/work/api"); - let mut row = super::AgentRailRow { - agent_id: declaration.agent_id.clone(), - host_id: String::new(), - declaration: None, - lane_index: None, + app.loaded.config.fleet.agent_declarations = vec![declaration.clone()]; + + let row = |app: &App| { + app.rail_rows() + .into_iter() + .find_map(|row| match row { + RailRow::Agent(agent) if agent.agent_id == "api-codex" => Some(agent), + _ => None, + }) + .expect("the declared agent has a row") }; - // Undeclared: the id is all there is, and it says nothing about the harness. - assert_eq!(row.label(), "api-codex"); - assert_eq!(row.harness(), None); - assert_eq!(row.workspace(), None); - row.declaration = Some(declaration.clone()); - assert_eq!(row.label(), "api-codex", "no name means the id"); + let declared = row(&app); + assert_eq!(declared.label(), "api-codex", "no name means the id"); + assert_eq!(declared.harness(), Some("codex")); + assert_eq!(declared.workspace(), Some("/work/api")); declaration.name = Some(" ".into()); - row.declaration = Some(declaration.clone()); - assert_eq!(row.label(), "api-codex", "a blank name is not a name"); + app.loaded.config.fleet.agent_declarations = vec![declaration.clone()]; + assert_eq!(row(&app).label(), "api-codex", "a blank name is not a name"); declaration.name = Some("API".into()); - row.declaration = Some(declaration); - assert_eq!(row.label(), "API"); + app.loaded.config.fleet.agent_declarations = vec![declaration]; + assert_eq!(row(&app).label(), "API"); + + let bare = super::AgentRailRow { + agent_id: "api-codex".into(), + host_id: String::new(), + agent: None, + lane_index: None, + }; + assert_eq!(bare.label(), "api-codex"); + assert_eq!(bare.harness(), None); + assert_eq!(bare.workspace(), None); } #[test] diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index d2aa909c8..475d725fe 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -13,16 +13,16 @@ //! [`SessionOrigin`](crate::worker::pty::SessionOrigin) — so both collapse into //! [`RailRow::Session`] under the agent that owns them, and the divider is gone. -use medulla::runtime::AgentDeclaration; +use medulla::ui::hosts::HostAgentRow; use crate::ui::agents::{AgentRow, TaskState}; use crate::worker::pty::{SessionOrigin, SessionRow}; /// One host in the tree. /// -/// Emitted **only when a remote host exists** (progressive disclosure): with just -/// the local machine — the common case — agents sit at the top level and no host -/// row wraps them. +/// Emitted **only when there is a second host to tell apart** (progressive +/// disclosure): with just the local machine — the common case — agents sit at the +/// top level and no host row wraps them. #[derive(Debug, Clone, PartialEq, Eq)] pub struct HostRailRow { /// The host id agents are stamped with; the local bus address for this @@ -30,59 +30,53 @@ pub struct HostRailRow { pub host_id: String, /// What the row says. pub label: String, - /// Whether this is the machine the TUI is running on. + /// Whether this is a machine the operator can act on from here — the local + /// device and any other host declared in this config. pub local: bool, } /// One agent in the tree — `harness × workspace` on a host. /// -/// Sourced from the **declaration**, not from the event fold: an agent that has -/// never been dispatched to still has a row, which is the whole point of -/// declaring one. A lane the fold produced for an agent nothing declares (a -/// tiny.place peer, an agent advertised by another machine) still gets a row, so -/// the restructure never hides something that used to be visible. +/// Sourced from the shared `Host → Agent` projection +/// ([`medulla::ui::hosts::host_rows`]), which is the same tree the Hosts tab +/// renders: an agent exists because it is declared (or because the roster +/// advertises it), never because traffic happened to fold a lane for it. A lane +/// the fold produced for an agent that projection does not know (a backend-side +/// roster agent, a peer session) still gets a row, so the restructure never +/// hides something that used to be visible. #[derive(Debug, Clone)] pub struct AgentRailRow { /// The `agentId` a dispatch targets — the key sessions are grouped under. pub agent_id: String, /// The host this agent runs on. Empty when nothing places it. pub host_id: String, - /// The declaration this row came from, when the agent is declared here. - pub declaration: Option, + /// The projection's row for this agent, when the tree knows it. `None` for a + /// lane-only agent, which is all the fold could tell us about. + pub agent: Option, /// The lane the event fold produced for it, when it has traffic. `None` for /// a declared agent that has not run anything. pub lane_index: Option, } impl AgentRailRow { - /// The label an undeclared, laneless row would show — the id itself. - /// - /// A declared agent prefers its operator-chosen name, then the folder its - /// workspace sits in, because that is how a person refers to it. + /// What to call this agent: whatever the shared projection resolved (its + /// declared name, else its roster label), falling back to the id itself. pub fn label(&self) -> String { - let Some(declaration) = &self.declaration else { - return self.agent_id.clone(); - }; - if let Some(name) = declaration.name.as_deref().map(str::trim) { - if !name.is_empty() { - return name.to_string(); - } - } - self.agent_id.clone() + self.agent + .as_ref() + .map(|agent| agent.label.clone()) + .filter(|label| !label.trim().is_empty()) + .unwrap_or_else(|| self.agent_id.clone()) } - /// The harness type this agent runs, when a declaration says. + /// The harness type this agent runs, when the tree knows one. pub fn harness(&self) -> Option<&str> { - self.declaration - .as_ref() - .map(|declaration| declaration.harness.as_str()) + self.agent.as_ref()?.harness.as_deref() } - /// The directory this agent's sessions work in, when a declaration says. + /// The directory this agent's sessions work in, when the tree knows one. pub fn workspace(&self) -> Option<&str> { - self.declaration - .as_ref() - .and_then(|declaration| declaration.workspace.path()) + self.agent.as_ref()?.workspace.as_deref() } } diff --git a/src/tui/tests/feature_agent_lanes.rs b/src/tui/tests/feature_agent_lanes.rs index 5492e0878..d05c52174 100644 --- a/src/tui/tests/feature_agent_lanes.rs +++ b/src/tui/tests/feature_agent_lanes.rs @@ -168,15 +168,20 @@ fn a_registered_worker_prefers_its_label_and_is_not_listed_twice() { ]); let out = render(&mut app, 140, 40); assert!(out.contains("build box"), "the label names the lane: {out}"); - // Once. This used to be twice — the lane, plus the same peer again as a - // host in the fleet half below the divider — which is exactly the - // duplication the rail no longer carries. Its host and harness are the - // Routing tab's Harnesses page now. + // One agent row. This used to be two — the lane, plus the same peer again as + // a host in the fleet half below the divider — which is exactly the + // duplication the rail no longer carries. assert_eq!( - out.matches("build box").count(), + out.matches("[CLAUDE] build box").count(), 1, "one peer, one lane: {out}" ); + // The machine it runs on is a row of its own, because a second host now + // exists: the same `Host → Agent` tree the Hosts tab draws, one level up. + assert!( + out.contains("▸ build box"), + "the peer's machine heads its agents: {out}" + ); } #[test] From ccbd1b1ad0d6a63637e7fe23d80f947d53db4200 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 5 Aug 2026 03:01:22 +0530 Subject: [PATCH 4/6] fix(ui): a rail row shows its own content, offers its own session MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things an operator hit on the Agents tab. The pane leaked the orchestrator's stream onto rows that are not it. `Selection::lane_index` defaulted to lane 0 — the orchestrator's — for every row with no lane of its own, so selecting `+ New agent`, a host header, or an agent nothing had been dispatched to showed the orchestrator thinking, attributed to a row that had not thought anything. The fallback is gone: `lane_index` is an `Option`, `lane()` answers `None`, and the new `summary` module renders what each of those rows actually is — the agent's identity, harness, workspace, roles and session count; the host's reach and agent count; what each action row will do. `+ New session` was unreachable. `open_new_session` shipped with the tree bound only to `^T`, so only an operator who already knew the chord could find it. `RailRow::NewSession` closes each agent's group, under its sessions, for the agents this machine declares — Enter and a click open the same named, user-owned flow. Spawned sessions were listed in one block for the whole conversation. They are attributed to the user turn that caused them — the event stream already carries both halves, a `User` opening a turn and every `TaskStart` until the next one belonging to it — and drawn under that turn. Sessions this stream cannot account for keep a trailing group rather than vanishing. The click-through is unchanged in kind: still keyed by task id through `focus_session_for_task`, but the hit map is now one slot per drawn row, since the entries are no longer contiguous. Co-Authored-By: Claude --- src/tui/src/ui/app/agent_control_tests.rs | 69 +++++ src/tui/src/ui/app/input/mouse.rs | 15 +- src/tui/src/ui/app/keys/agents.rs | 21 +- src/tui/src/ui/app/rail/mod.rs | 78 +++++- src/tui/src/ui/app/rail/tests.rs | 16 +- src/tui/src/ui/app/rail/types.rs | 26 +- src/tui/src/ui/app/render/agents/mod.rs | 32 ++- src/tui/src/ui/app/render/agents/rail/mod.rs | 22 +- src/tui/src/ui/app/render/agents/started.rs | 161 ++++++++++++ .../src/ui/app/render/agents/started_tests.rs | 200 +++++++++++++++ src/tui/src/ui/app/render/agents/summary.rs | 242 ++++++++++++++++++ .../src/ui/app/render/agents/transcript.rs | 101 +++++--- .../ui/app/render/agents/transcript_tests.rs | 192 +++++++++++++- src/tui/src/ui/app/render/agents/types.rs | 20 +- src/tui/src/ui/app/session_focus.rs | 61 +++-- src/tui/src/ui/app/types.rs | 12 +- 16 files changed, 1167 insertions(+), 101 deletions(-) create mode 100644 src/tui/src/ui/app/render/agents/started.rs create mode 100644 src/tui/src/ui/app/render/agents/started_tests.rs create mode 100644 src/tui/src/ui/app/render/agents/summary.rs diff --git a/src/tui/src/ui/app/agent_control_tests.rs b/src/tui/src/ui/app/agent_control_tests.rs index 7cd3b3a8f..862c40f31 100644 --- a/src/tui/src/ui/app/agent_control_tests.rs +++ b/src/tui/src/ui/app/agent_control_tests.rs @@ -158,6 +158,75 @@ fn a_named_session_opens_in_the_agents_own_harness_and_workspace() { sessions.shutdown(); } +#[test] +fn every_agent_carries_a_new_session_row_that_opens_the_flow() { + // `open_new_session` shipped with the tree and was bound only to `^T`, so an + // operator who did not already know the chord could not reach it. The row is + // how it becomes visible — and selecting it must land in the same flow. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![ + AgentDeclaration::new("api-codex", "", "codex", "/work/api"), + AgentDeclaration::new("web-codex", "", "codex", "/work/web"), + ]; + + let rows = app.rail_rows(); + for agent_id in ["api-codex", "web-codex"] { + let agent = rows + .iter() + .position(|row| matches!(row, RailRow::Agent(agent) if agent.agent_id == agent_id)) + .unwrap_or_else(|| panic!("{agent_id} has a row")); + let action = rows + .iter() + .position(|row| row.new_session_agent() == Some(agent_id)) + .unwrap_or_else(|| panic!("{agent_id} offers a session: {rows:?}")); + assert!(action > agent, "the action sits under its agent"); + // …and under that agent's own sessions: nothing between them belongs to + // anybody else. + assert!( + rows[agent + 1..action] + .iter() + .all(|row| matches!(row, RailRow::Session(_) | RailRow::Lane(_))), + "the action closes the agent's group: {rows:?}" + ); + assert!(rows[action].selectable(), "and the cursor can reach it"); + } + + let index = rows + .iter() + .position(|row| row.new_session_agent() == Some("api-codex")) + .expect("the action row"); + app.agent_index = index; + assert_eq!(app.on_new_session_row().as_deref(), Some("api-codex")); + + app.open_new_session("api-codex"); + let kind = app.prompt.as_ref().map(|prompt| &prompt.kind); + assert!( + matches!(kind, Some(PromptKind::SessionName { agent_id, managed }) + if agent_id == "api-codex" && !managed), + "selecting it opens the named, user-owned flow" + ); +} + +#[test] +fn an_agent_this_machine_does_not_declare_is_not_offered_a_session() { + // A session is started by the host that owns the agent, and the flow reads + // the declaration for the harness and the directory. An action row on a + // remote host's agent would be a button that refuses. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-codex", + "studio", + "codex", + "/work", + )]; + assert!( + app.rail_rows() + .iter() + .all(|row| row.new_session_agent().is_none()), + "nothing local to start it on" + ); +} + #[test] fn opening_a_session_for_an_undeclared_agent_says_so() { let mut app = hosting_app(); diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index aefadae1f..b5caa9866 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -316,8 +316,13 @@ impl App { // because the block lives in the pane beside it. if let Some((rect, tasks)) = self.hit_started_sessions.clone() { if rect.contains((x, y).into()) { - if let Some(task_id) = tasks.get((y - rect.y) as usize) { - self.focus_session_for_task(task_id); + // Only the rows that *are* entries answer; a click on the + // conversation between them falls through to the rail's own + // hit test, exactly as a click above the block used to. + if let Some(task_id) = tasks.get((y - rect.y) as usize).and_then(Option::as_ref) + { + let task_id = task_id.clone(); + self.focus_session_for_task(&task_id); return self.retarget_watch(); } } @@ -363,6 +368,12 @@ impl App { self.open_new_agent_picker(); return None; } + // Same rule for the per-agent action: a click on + // `+ new session` opens the flow it names. + if let Some(agent_id) = row.new_session_agent().map(str::to_string) { + self.open_new_session(&agent_id); + return None; + } // So is a lane's `+N more`: the click that lands on // it is the request to see what it is counting. // diff --git a/src/tui/src/ui/app/keys/agents.rs b/src/tui/src/ui/app/keys/agents.rs index c6d4119bc..064d81188 100644 --- a/src/tui/src/ui/app/keys/agents.rs +++ b/src/tui/src/ui/app/keys/agents.rs @@ -58,6 +58,14 @@ impl App { .is_some_and(|row| row.is_new_agent()) } + /// The agent whose `+ new session` action row the cursor sits on, if it does. + pub(in crate::ui::app) fn on_new_session_row(&self) -> Option { + let rows = self.rail_rows(); + rows.get(self.agent_index.min(rows.len().saturating_sub(1))) + .and_then(|row| row.new_session_agent()) + .map(str::to_string) + } + /// Move the keyboard to the rail. Nothing else about the draft changes, so /// stepping out to look at a lane and back never costs a half-typed message. pub(in crate::ui::app) fn focus_agents_rail(&mut self) { @@ -106,12 +114,19 @@ impl App { AgentsKey::Handled(self.retarget_watch()) } // Enter is "I have found the row I wanted; let me type" — except on - // the rows that are themselves an action: the agent starter, and a - // lane's `+N more`, where it pages the hidden sublanes into view. A - // visible harness consumes it earlier and takes the keyboard instead. + // the rows that are themselves an action: `+ New agent`, an agent's + // `+ new session`, and a lane's `+N more`, where it pages the hidden + // sessions into view. A visible session pane consumes it earlier and + // takes the keyboard instead. KeyCode::Enter => { if self.on_new_agent_row() { self.open_new_agent_picker(); + } else if let Some(agent_id) = self.on_new_session_row() { + // The same flow `Ctrl-T` opens on an agent row: the name + // prompt, then a session in that agent's declared harness + // and directory. Enter is how a row that *is* an action is + // taken, so the two doors lead to one place. + self.open_new_session(&agent_id); } else if !self.page_subtasks() { self.focus_agents_composer(); } diff --git a/src/tui/src/ui/app/rail/mod.rs b/src/tui/src/ui/app/rail/mod.rs index 62ee82f71..3778c4e58 100644 --- a/src/tui/src/ui/app/rail/mod.rs +++ b/src/tui/src/ui/app/rail/mod.rs @@ -52,8 +52,11 @@ pub use types::{AgentRailRow, HostRailRow, RailRow, SessionRailRow}; /// declared `harness × workspace` identity that outlives the session it starts. pub(in crate::ui::app) const NEW_AGENT_LABEL: &str = "+ New agent"; -/// The most sessions listed under one agent before the rest are counted. -const MAX_SESSIONS_PER_AGENT: usize = 8; +/// The label on the action row that opens a session under an agent. +/// +/// Indented and lower-cased beside [`NEW_AGENT_LABEL`] because it is a leaf of +/// one agent's group rather than an action on the machine. +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 { @@ -61,8 +64,17 @@ struct AgentGroup { row: AgentRailRow, /// Its sessions, dispatched and operator-started alike. sessions: Vec, - /// Sessions the fold's own cap already hid, carried so the counts add up. + /// 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. @@ -156,6 +168,7 @@ impl App { AgentRow::More { hidden, .. } => { if let Some(group) = groups.last_mut() { group.hidden += hidden; + group.overflow = true; } } AgentRow::Separator => lane_rows.push(row), @@ -190,6 +203,7 @@ impl App { }, sessions: Vec::new(), hidden: 0, + overflow: false, } } @@ -239,9 +253,21 @@ impl App { let mut rows: Vec = lane_rows.into_iter().map(RailRow::Lane).collect(); // A device that hosts nothing cannot declare an agent on itself, so the // action is absent there rather than present and refusing. - if self.harnesses.is_some() { + let hosting = self.harnesses.is_some(); + if hosting { rows.push(RailRow::NewAgent); } + // Which agents this machine may open a session under: a session is + // started by the host that owns the agent, so only the agents declared + // here get the action. Collected once rather than re-scanned per group. + let declared: Vec = if hosting { + self.local_agent_declarations() + .into_iter() + .map(|declaration| declaration.agent_id) + .collect() + } else { + Vec::new() + }; // 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. @@ -251,7 +277,10 @@ impl App { rows.push(RailRow::Host(host.row)); } for group in &mut host.agents { - push_group(&mut rows, group); + let offers_session = declared + .iter() + .any(|agent_id| agent_id.trim() == group.row.agent_id.trim()); + push_group(&mut rows, group, offers_session); } } rows.extend(orphans.into_iter().map(|mut session| { @@ -388,6 +417,7 @@ fn placed_agent( }, sessions: Vec::new(), hidden: 0, + overflow: false, }); group.row.host_id = host_id.to_string(); group.row.agent = Some(agent.clone()); @@ -407,19 +437,41 @@ fn unplaced_host(hosts: &[HostGroup], host_id: &str) -> Option { .or_else(|| hosts.iter().position(|host| host.row.local)) } -/// Push one agent row and the sessions under it, capped and tree-marked. -fn push_group(rows: &mut Vec, group: &mut AgentGroup) { +/// Push one agent row and the sessions under it, tree-marked. +/// +/// `offers_session` closes the group with the `+ New session` action. It is off +/// for an agent this machine does not declare — a remote host's agent, or a lane +/// the fold produced for an agent declared somewhere else — because the flow it +/// opens reads the declaration for the harness and the directory to start in. +/// +/// 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 group 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_group(rows: &mut Vec, group: &mut AgentGroup, offers_session: bool) { rows.push(RailRow::Agent(group.row.clone())); - let shown = group.sessions.len().min(MAX_SESSIONS_PER_AGENT); - let hidden = group.hidden + (group.sessions.len() - shown); - for (index, session) in group.sessions.iter_mut().take(shown).enumerate() { - session.last = hidden == 0 && index + 1 == shown; + let shown = group.sessions.len(); + for (index, session) in group.sessions.iter_mut().enumerate() { + // The action row below closes the group when it is offered, so the last + // session is only the tree's last leaf when neither it nor the overflow + // row follows. + session.last = !offers_session && !group.overflow && index + 1 == shown; rows.push(RailRow::Session(Box::new(session.clone()))); } - if hidden > 0 { + if group.overflow { rows.push(RailRow::Lane(AgentRow::More { lane_index: group.row.lane_index.unwrap_or(0), - hidden, + hidden: group.hidden, })); } + // Last, under the sessions it adds to: the group reads as a list of what + // this agent is running, and the action that starts one more belongs at the + // end of that list rather than above it. + if offers_session { + rows.push(RailRow::NewSession { + agent_id: group.row.agent_id.clone(), + }); + } } diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 20744c4fc..55fcdf49e 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -358,12 +358,21 @@ fn a_row_answers_for_the_agent_and_the_lane_behind_it() { assert_eq!(row.agent_id(), session.agent_id.as_deref()); assert_eq!(row.lane_index(), session.lane_index); } - // Hosts and the action row are about no agent and no lane. + // Hosts and the create action are about no agent and no lane. RailRow::Host(_) | RailRow::NewAgent => { assert_eq!(row.agent_id(), None); assert_eq!(row.lane_index(), None); assert_eq!(row.session_id(), None); } + // The per-agent action names its agent — that is what `^T` and + // Enter act on — but no lane and no session of its own. + RailRow::NewSession { agent_id } => { + assert_eq!(row.agent_id(), Some(agent_id.as_str())); + assert_eq!(row.new_session_agent(), Some(agent_id.as_str())); + assert_eq!(row.lane_index(), None); + assert_eq!(row.session_id(), None); + assert!(row.task().is_none()); + } RailRow::Lane(lane) => assert_eq!(row.lane_index(), lane.lane_index()), } } @@ -381,7 +390,10 @@ 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::Agent(_) | RailRow::Session(_) | RailRow::NewAgent => { + RailRow::Agent(_) + | RailRow::Session(_) + | RailRow::NewAgent + | RailRow::NewSession { .. } => { assert!(row.selectable()) } RailRow::Lane(_) => {} diff --git a/src/tui/src/ui/app/rail/types.rs b/src/tui/src/ui/app/rail/types.rs index 475d725fe..afa88b0ed 100644 --- a/src/tui/src/ui/app/rail/types.rs +++ b/src/tui/src/ui/app/rail/types.rs @@ -151,6 +151,20 @@ pub enum RailRow { /// agents declared has nothing else on that half of the rail to suggest the /// flow exists — which is the same as not having it. NewAgent, + /// The action row that opens a new session under the agent above it. + /// + /// The last row of an agent's group, under its sessions, because that is + /// where "and one more" belongs: the list you are reading is what the agent + /// is running, and this adds to it. Emitted only for an agent this machine + /// declares — a session is started on the host that owns the agent, so + /// offering the action on a remote one would be a button that refuses. + /// + /// `open_new_session` has existed since the tree landed and was reachable + /// only by `Ctrl-T`, i.e. only by an operator who already knew it was there. + NewSession { + /// The agent whose harness and workspace the session inherits. + agent_id: String, + }, /// A fold row that is not an agent: the orchestrator's own conversation, the /// `── functions ──` divider, a function lane, or a `+N more` counter. /// @@ -168,6 +182,7 @@ impl RailRow { RailRow::Agent(_) => true, RailRow::Session(_) => true, RailRow::NewAgent => true, + RailRow::NewSession { .. } => true, RailRow::Lane(row) => row.selectable(), } } @@ -194,7 +209,7 @@ impl RailRow { RailRow::Agent(row) => row.lane_index, RailRow::Session(row) => row.lane_index, RailRow::Lane(row) => row.lane_index(), - RailRow::Host(_) | RailRow::NewAgent => None, + RailRow::Host(_) | RailRow::NewAgent | RailRow::NewSession { .. } => None, } } @@ -203,6 +218,7 @@ impl RailRow { match self { RailRow::Agent(row) => Some(row.agent_id.as_str()), RailRow::Session(row) => row.agent_id.as_deref(), + RailRow::NewSession { agent_id } => Some(agent_id.as_str()), _ => None, } } @@ -211,4 +227,12 @@ impl RailRow { pub fn is_new_agent(&self) -> bool { matches!(self, RailRow::NewAgent) } + + /// The agent a "start a session" action row would open one under. + pub fn new_session_agent(&self) -> Option<&str> { + match self { + RailRow::NewSession { agent_id } => Some(agent_id.as_str()), + _ => None, + } + } } diff --git a/src/tui/src/ui/app/render/agents/mod.rs b/src/tui/src/ui/app/render/agents/mod.rs index c5e9ad5bf..06a895be1 100644 --- a/src/tui/src/ui/app/render/agents/mod.rs +++ b/src/tui/src/ui/app/render/agents/mod.rs @@ -11,9 +11,11 @@ //! //! Split by responsibility: [`types`] resolves what the cursor is on and where //! the panes landed, [`rail`] draws the threads strip and the lane/fleet list, -//! [`transcript`] the pane beside it, [`work`] the panel showing what the -//! selected agent is working on, and [`composer`] the input under that. This -//! module owns only the layout that decides how much room each one gets. +//! [`transcript`] the pane beside it, [`summary`] what that pane shows for a row +//! with no transcript of its own, [`started`] the sessions each conversation +//! turn spawned, [`work`] the panel showing what the selected agent is working +//! on, and [`composer`] the input under that. This module owns only the layout +//! that decides how much room each one gets. use ratatui::layout::{Constraint, Direction, Layout, Rect}; use ratatui::Frame; @@ -26,10 +28,14 @@ use super::super::types::App; mod composer; mod harness; mod rail; +mod started; +mod summary; mod transcript; mod types; mod work; +#[cfg(test)] +mod started_tests; #[cfg(test)] mod transcript_tests; #[cfg(test)] @@ -79,21 +85,21 @@ impl App { let rows = self.rail_rows(); let active = self.agent_index.min(rows.len().saturating_sub(1)); self.agent_index = active; - let lane_index = rows - .get(active) - .and_then(|row| row.lane_index()) - .unwrap_or(0); + // No fallback: a row with no lane keeps `None`, and the pane renders + // what that row *is* rather than whoever happens to hold lane 0 — which + // is the orchestrator, so the old `unwrap_or(0)` put its thinking under + // `+ New agent`, host headers and idle agents. + let lane_index = rows.get(active).and_then(|row| row.lane_index()); let task = rows.get(active).and_then(|row| row.task()).cloned(); // Only a *lane* row can be the orchestrator's. Agents, sessions, hosts - // and the action row fall back to lane 0 for the transcript behind them, - // and reading the role off that fallback claimed the orchestrator's - // composer for rows that are not it — a text box under a row with - // nothing to say to. Mirrors + // and the action rows are not lanes, and reading the role off the old + // lane-0 fallback claimed the orchestrator's composer for rows that are + // not it — a text box under a row with nothing to say to. Mirrors // [`App::on_orchestrator_lane`](crate::ui::app), which the keyboard // reads, so focus and layout cannot disagree. let on_orchestrator = match rows.get(active) { - Some(RailRow::Lane(AgentRow::Lane { .. })) => lanes - .get(lane_index) + Some(RailRow::Lane(AgentRow::Lane { .. })) => lane_index + .and_then(|index| lanes.get(index)) .map(|l| l.role == AgentRole::Orchestrator) .unwrap_or(true), None => true, diff --git a/src/tui/src/ui/app/render/agents/rail/mod.rs b/src/tui/src/ui/app/render/agents/rail/mod.rs index a6e6ec5cd..437d0dc5b 100644 --- a/src/tui/src/ui/app/render/agents/rail/mod.rs +++ b/src/tui/src/ui/app/render/agents/rail/mod.rs @@ -17,7 +17,7 @@ use ratatui::Frame; use crate::ui::agents::{AgentLane, TaskStatus}; use crate::worker::pty::ATTENTION_GLYPH; -use super::super::super::rail::{RailRow, NEW_AGENT_LABEL}; +use super::super::super::rail::{RailRow, NEW_AGENT_LABEL, NEW_SESSION_LABEL}; use super::super::super::types::App; use super::super::color; use super::types::{AgentsPanes, Selection}; @@ -333,6 +333,7 @@ impl App { self.declared_agent_line(agent, lanes, active, waiting_sessions) } RailRow::NewAgent => self.new_agent_line(active), + RailRow::NewSession { .. } => self.new_session_line(active), RailRow::Session(session) => match (&session.task, &session.local) { (Some(task), _) => self.agent_row_line( &crate::ui::agents::AgentRow::Sub { @@ -413,6 +414,25 @@ impl App { Span::styled(" ⏎", Style::default().add_modifier(Modifier::DIM)), ]) } + + /// Format the `+ new session` action row that closes an agent's group. + /// + /// Drawn as the group's last leaf — the same `└` the last session would have + /// carried — so it reads as part of that agent rather than as a second + /// machine-level button beside `+ New agent`. It carries the `^T` hint the + /// machine-level button used to, because `Ctrl-T` on a row belonging to an + /// agent opens exactly this flow. + fn new_session_line(&self, active: bool) -> TLine<'static> { + let style = if active { + self.theme.selection() + } else { + Style::default().fg(color("cyan")) + }; + 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/agents/started.rs b/src/tui/src/ui/app/render/agents/started.rs new file mode 100644 index 000000000..924f93ea2 --- /dev/null +++ b/src/tui/src/ui/app/render/agents/started.rs @@ -0,0 +1,161 @@ +//! The sessions the orchestrator started, rendered under the query that caused +//! them. +//! +//! §A7 gave the orchestrator's conversation a "sessions started" block so an +//! operator could click through to a dispatched session. It was one aggregate +//! list at the top of the pane: after three queries it said *"sessions started · +//! 7"* and left the reader to work out which query each one came from — which is +//! the fact they were looking for. +//! +//! So the entries are grouped by **turn** and drawn where that turn is: +//! +//! ```text +//! ❯ ship the auth fix and update the docs +//! ⏺ deploying two agents… +//! ▸ t_41 · api-claude · claude × ~/proj/api · running +//! ▸ t_42 · web-codex · codex × ~/proj/web · running +//! +//! ❯ now run the tests +//! ``` +//! +//! **Attribution needs no new bookkeeping.** The conversation is an ordered +//! event stream carrying both halves already: a [`TuiEvent::User`] opens a turn, +//! and every [`TuiEvent::TaskStart`] after it — until the next `User` — is a +//! task that turn caused. The rail's own session rows carry the same task ids, +//! so joining them is a lookup rather than a side-channel. +//! +//! A session whose `task_start` is not in this stream — dispatched in another +//! thread, or folded from a snapshot that predates the visible events — is +//! **not dropped**: it is listed in a trailing group under its own heading, at +//! the end of the transcript where the reader already is. Sessions started +//! before the first query of the thread land ahead of that first query, which is +//! chronologically where they happened. + +use std::collections::HashMap; + +use crate::ui::agents::Line as StyledLine; +use crate::ui::events::{EventEnvelope, TuiEvent}; + +use super::super::super::session_focus::StartedSession; +use super::super::chat_lines; + +/// The turn a task belongs to, keyed by task id. +/// +/// Turn `0` is everything before the first user message — a dispatch the +/// orchestrator made on its own, or one folded in from an earlier state. +fn turn_of_tasks(events: &[EventEnvelope]) -> HashMap { + let mut turns = HashMap::new(); + let mut turn = 0usize; + for env in events { + match &env.event { + TuiEvent::User { .. } => turn += 1, + TuiEvent::TaskStart { task_id, .. } => { + // First writer wins: a task id is announced once, and a retry + // re-announcing it belongs to the turn that first asked. + turns.entry(task_id.clone()).or_insert(turn); + } + _ => {} + } + } + turns +} + +/// The conversation, with each turn's spawned sessions listed under it. +/// +/// Returns the lines and, line for line, the task each one opens — `None` for +/// ordinary transcript lines. The parallel vector is what keeps the entries +/// clickable through the same task-keyed path as the old block: the caller +/// windows both by the same scroll offset, so a click resolves to whatever is +/// drawn at that row rather than to an index recorded before the last scroll. +pub(super) fn chat_lines_with_sessions( + events: &[EventEnvelope], + width: usize, + started: &[StartedSession], +) -> (Vec, Vec>) { + let turns = turn_of_tasks(events); + let mut lines: Vec = Vec::new(); + let mut hits: Vec> = Vec::new(); + let mut push = |line: StyledLine, task: Option| { + lines.push(line); + hits.push(task); + }; + + // The stream split at its user turns: segment `n` runs from the n-th user + // message up to the one after it, and segment 0 is whatever came before the + // first. Splitting is safe because `chat_lines` flushes its pending tool + // calls immediately *before* handling a `User` event, so a boundary there + // produces the same lines the whole stream would. + let mut bounds = vec![0usize]; + bounds.extend( + events + .iter() + .enumerate() + .filter_map(|(index, env)| matches!(env.event, TuiEvent::User { .. }).then_some(index)), + ); + bounds.push(events.len()); + + for (turn, window) in bounds.windows(2).enumerate() { + let (from, to) = (window[0], window[1]); + for line in chat_lines(&events[from..to], width) { + push(line, None); + } + for session in started.iter().filter(|session| { + turns + .get(&session.task_id) + .is_some_and(|attributed| *attributed == turn) + }) { + push(entry_line(session, width), Some(session.task_id.clone())); + } + } + + // Whatever this stream cannot account for. Listed rather than dropped: a + // session that is running, costing tokens and unreachable is worse than one + // filed under a heading that admits it does not know where it came from. + let orphans: Vec<&StartedSession> = started + .iter() + .filter(|session| !turns.contains_key(&session.task_id)) + .collect(); + if !orphans.is_empty() { + push(StyledLine::default(), None); + push( + StyledLine { + text: format!( + "sessions started outside this conversation · {}", + orphans.len() + ), + color: Some("cyan".into()), + dim: true, + }, + None, + ); + for session in orphans { + push(entry_line(session, width), Some(session.task_id.clone())); + } + } + (lines, hits) +} + +/// One session entry: the task, the agent, where it runs, and how it is doing. +fn entry_line(session: &StartedSession, width: usize) -> StyledLine { + let mut parts = vec![session.task_id.clone()]; + if !session.agent.trim().is_empty() { + parts.push(session.agent.clone()); + } + match (&session.harness, &session.workspace) { + (Some(harness), Some(workspace)) => parts.push(format!( + "{harness} × {}", + crate::ui::util::clip_left(workspace, 28) + )), + (Some(harness), None) => parts.push(harness.clone()), + (None, Some(workspace)) => { + parts.push(crate::ui::util::clip_left(workspace, 28).to_string()) + } + (None, None) => {} + } + parts.push(session.status.to_string()); + StyledLine { + text: crate::ui::util::clip(&format!(" ▸ {}", parts.join(" · ")), width.max(20)), + color: Some("cyan".into()), + dim: true, + } +} diff --git a/src/tui/src/ui/app/render/agents/started_tests.rs b/src/tui/src/ui/app/render/agents/started_tests.rs new file mode 100644 index 000000000..3cb9d0820 --- /dev/null +++ b/src/tui/src/ui/app/render/agents/started_tests.rs @@ -0,0 +1,200 @@ +//! Attribution of spawned sessions to the conversation turn that caused them. + +use crate::ui::events::{EventEnvelope, TuiEvent}; + +use super::super::super::session_focus::StartedSession; +use super::started::chat_lines_with_sessions; + +/// One event in the stream, at a monotonic timestamp. +fn env(seq: u64, event: TuiEvent) -> EventEnvelope { + EventEnvelope { + seq, + at: seq as i64, + event, + } +} + +/// A user turn. +fn user(seq: u64, body: &str) -> EventEnvelope { + env( + seq, + TuiEvent::User { + body: body.to_string(), + }, + ) +} + +/// A dispatch announced by the orchestrator. +fn task_start(seq: u64, task_id: &str) -> EventEnvelope { + env( + seq, + TuiEvent::TaskStart { + task_id: task_id.to_string(), + instruction: String::new(), + depth: 1, + agent_id: None, + contract: None, + }, + ) +} + +/// A rail entry for a session serving `task_id`. +fn started(task_id: &str, agent: &str) -> StartedSession { + StartedSession { + agent: agent.to_string(), + harness: Some("claude".into()), + workspace: Some("/work/api".into()), + task_id: task_id.to_string(), + status: "running", + row_index: 0, + } +} + +/// The rendered text of each line, for order assertions. +fn texts(lines: &[crate::ui::agents::Line]) -> Vec { + lines.iter().map(|line| line.text.clone()).collect() +} + +/// Where a substring first appears in the rendered lines. +fn position(lines: &[crate::ui::agents::Line], needle: &str) -> usize { + texts(lines) + .iter() + .position(|text| text.contains(needle)) + .unwrap_or_else(|| panic!("{needle} is missing from {:?}", texts(lines))) +} + +#[test] +fn each_session_renders_under_the_query_that_started_it() { + // Two queries, two dispatches. The old block listed both at the top of the + // pane, which said *that* five sessions exist and never *which query* asked + // for one — the fact an operator reading the conversation is after. + let events = vec![ + user(1, "ship the auth fix"), + task_start(2, "t_auth"), + user(3, "now run the tests"), + task_start(4, "t_tests"), + ]; + let sessions = vec![ + started("t_auth", "api-claude"), + started("t_tests", "api-claude"), + ]; + + let (lines, hits) = chat_lines_with_sessions(&events, 80, &sessions); + + assert!( + position(&lines, "ship the auth fix") < position(&lines, "t_auth"), + "the entry follows its query" + ); + assert!( + position(&lines, "t_auth") < position(&lines, "now run the tests"), + "and precedes the next one: {:?}", + texts(&lines) + ); + assert!( + position(&lines, "now run the tests") < position(&lines, "t_tests"), + "the second turn keeps its own" + ); + // Every entry stays addressable by task, line for line with what was drawn. + assert_eq!(hits.len(), lines.len()); + assert_eq!(hits[position(&lines, "t_auth")].as_deref(), Some("t_auth")); + assert_eq!( + hits[position(&lines, "t_tests")].as_deref(), + Some("t_tests") + ); + assert_eq!( + hits[position(&lines, "ship the auth fix")], + None, + "an ordinary transcript line opens nothing" + ); +} + +#[test] +fn an_entry_names_the_agent_its_harness_and_its_workspace() { + let events = vec![user(1, "go"), task_start(2, "t_1")]; + let (lines, _) = chat_lines_with_sessions(&events, 100, &[started("t_1", "api-claude")]); + let entry = &texts(&lines)[position(&lines, "t_1")]; + assert!(entry.contains("api-claude"), "{entry}"); + assert!(entry.contains("claude × /work/api"), "{entry}"); + assert!(entry.contains("running"), "{entry}"); +} + +#[test] +fn a_session_this_conversation_cannot_account_for_still_appears() { + // Dispatched in another thread, or folded in from a state older than the + // visible events. A session that is running, costing tokens and unreachable + // is the failure the block existed to prevent, so it is filed under a + // heading that admits it rather than dropped. + let events = vec![user(1, "go"), task_start(2, "t_known")]; + let sessions = vec![started("t_known", "api"), started("t_orphan", "web")]; + + let (lines, hits) = chat_lines_with_sessions(&events, 80, &sessions); + + let at = position(&lines, "t_orphan"); + assert_eq!(hits[at].as_deref(), Some("t_orphan"), "and stays clickable"); + assert!( + position(&lines, "sessions started outside this conversation") < at, + "under a heading that says where it came from: {:?}", + texts(&lines) + ); + assert!( + position(&lines, "t_known") < at, + "after the turns that can be accounted for" + ); +} + +#[test] +fn a_session_started_before_the_first_query_leads_the_conversation() { + // Turn 0 is everything before the first user message. Chronologically that + // is where it happened, so that is where it is drawn. + let events = vec![task_start(1, "t_early"), user(2, "go")]; + let (lines, _) = chat_lines_with_sessions(&events, 80, &[started("t_early", "api")]); + assert!( + position(&lines, "t_early") < position(&lines, "go"), + "{:?}", + texts(&lines) + ); +} + +#[test] +fn splitting_the_stream_at_its_turns_does_not_change_the_transcript() { + // The grouping is a *relocation* of the entries, not a re-render of the + // conversation: with no sessions to place, the output has to be exactly + // what the unsplit fold produces, tool calls and all. + let events = vec![ + user(1, "first"), + env( + 2, + TuiEvent::ToolCallStart { + index: 0, + name: "read".into(), + }, + ), + env( + 3, + TuiEvent::ToolCallDelta { + index: 0, + args_delta: "{\"path\":\"a\"}".into(), + }, + ), + env( + 4, + TuiEvent::Assistant { + body: "done".into(), + }, + ), + user(5, "second"), + env( + 6, + TuiEvent::Assistant { + body: "also done".into(), + }, + ), + ]; + + let (grouped, hits) = chat_lines_with_sessions(&events, 80, &[]); + assert_eq!( + texts(&grouped), + texts(&super::super::chat_lines(&events, 80)) + ); + assert!(hits.iter().all(Option::is_none)); +} diff --git a/src/tui/src/ui/app/render/agents/summary.rs b/src/tui/src/ui/app/render/agents/summary.rs new file mode 100644 index 000000000..8b6fac821 --- /dev/null +++ b/src/tui/src/ui/app/render/agents/summary.rs @@ -0,0 +1,242 @@ +//! What the pane shows for a rail row that has no transcript of its own. +//! +//! A host header, an agent nothing has been dispatched to, and the two action +//! rows all name something real, and none of them is a conversation. The pane +//! used to fall back to [`lane_lines`](crate::ui::agents::lane_lines) for them — +//! with a lane index that had itself fallen back to **0**, the orchestrator's — +//! so selecting `+ New agent` showed the orchestrator thinking, attributed to a +//! row that had not thought anything. +//! +//! So each of those rows describes itself instead: the agent says what it is and +//! how to start a session on it, the host says where it is and how many agents +//! it holds, and the action rows say what they will do. Nothing here is +//! lane-shaped, because none of these rows has a lane. + +use crate::ui::agents::Line as StyledLine; + +use super::super::super::rail::{RailRow, NEW_AGENT_LABEL, NEW_SESSION_LABEL}; +use super::super::super::types::App; +use super::types::Selection; + +/// A rendered description of a laneless row: what to title the pane, and what to +/// put in it. +pub(super) struct RowPanel { + /// The pane title — what the row is, not "Transcript". + pub(super) title: String, + /// The body, already wrapped to the pane width. + pub(super) lines: Vec, +} + +/// Describe whatever laneless row the cursor is on. +/// +/// Total by construction: an unknown or missing row degrades to an empty panel +/// rather than borrowing another row's content, which is the failure this module +/// exists to end. +pub(super) fn row_panel(app: &App, selection: &Selection, width: usize) -> RowPanel { + match selection.row() { + Some(RailRow::Agent(agent)) => agent_panel(app, selection, agent, width), + Some(RailRow::Host(host)) => host_panel(selection, host), + Some(RailRow::NewAgent) => new_agent_panel(), + Some(RailRow::NewSession { agent_id }) => new_session_panel(app, agent_id), + _ => RowPanel { + title: "Transcript".into(), + lines: Vec::new(), + }, + } +} + +/// The agent itself: its identity, where it runs, and what it is running. +/// +/// Reached only for an agent with no lane — one the fold has produced no traffic +/// for. An agent *with* a lane keeps its own transcript, which is the thing an +/// operator opened it for. +fn agent_panel( + app: &App, + selection: &Selection, + agent: &super::super::super::rail::AgentRailRow, + width: usize, +) -> RowPanel { + let sessions = sessions_under(selection); + let mut lines = vec![ + field("agent", &agent.label()), + field("id", &agent.agent_id), + field("harness", agent.harness().unwrap_or("not declared")), + field("workspace", agent.workspace().unwrap_or("not declared")), + field("host", &host_label(app, selection, &agent.host_id)), + ]; + let roles = agent + .agent + .as_ref() + .map(|row| row.roles.join(", ")) + .filter(|roles| !roles.trim().is_empty()) + // An agent with no roles is offered for every template, which is the + // useful thing to say — "none" would read as "excluded from all". + .unwrap_or_else(|| "any".to_string()); + lines.push(field("roles", &roles)); + lines.push(field("sessions", &sessions.to_string())); + lines.push(StyledLine::default()); + lines.extend(wrap_note( + if sessions == 0 { + format!( + "No sessions yet. {NEW_SESSION_LABEL} under this agent — or ^T — starts one in {}.", + agent.workspace().unwrap_or("its workspace") + ) + } else { + format!( + "Its sessions are listed under it on the rail; {NEW_SESSION_LABEL} — or ^T — starts another." + ) + }, + width, + )); + RowPanel { + title: format!("agent · {}", agent.label()), + lines, + } +} + +/// The host: what it is called, whether it can be acted on, what it holds. +fn host_panel(selection: &Selection, host: &super::super::super::rail::HostRailRow) -> RowPanel { + let lines = vec![ + field("host", &host.label), + field("id", &host.host_id), + field( + "reach", + if host.local { + "this device — its agents are declared here" + } else { + "remote — its agents are declared on that machine" + }, + ), + field("agents", &agents_under(selection).to_string()), + ]; + RowPanel { + title: format!("host · {}", host.label), + lines, + } +} + +/// The `+ New agent` action, in one breath: what it writes and what it does not. +fn new_agent_panel() -> RowPanel { + RowPanel { + title: NEW_AGENT_LABEL.to_string(), + lines: vec![ + note("Declare an agent on this device: a harness type × a workspace"), + note("directory, with a name you choose."), + StyledLine::default(), + note("Declaring starts nothing. The agent gets a row from that moment"), + note("whether or not anything ever runs in it, and the orchestrator"), + note("can dispatch to it by name."), + StyledLine::default(), + note("⏎ or click opens the picker."), + ], + } +} + +/// The `+ new session` action, named for the agent it will start one on. +fn new_session_panel(app: &App, agent_id: &str) -> RowPanel { + let declaration = medulla::config::agent_declaration(app.agent_declarations(), agent_id); + let mut lines = vec![ + note("Start a session on this agent — its declared harness, in its"), + note("declared directory. You are asked for a name first."), + StyledLine::default(), + ]; + match declaration { + Some(declaration) => { + lines.push(field("agent", agent_id)); + lines.push(field("harness", &declaration.harness)); + lines.push(field( + "workspace", + declaration.workspace.path().unwrap_or("not declared"), + )); + } + // The rail only offers the action for a declared agent, so this is the + // window between a declaration being removed and the next frame. + None => lines.push(field("agent", agent_id)), + } + lines.push(StyledLine::default()); + lines.push(note( + "It is yours at birth: the orchestrator will not dispatch into it", + )); + lines.push(note("until you hand it over with ^G.")); + lines.push(StyledLine::default()); + lines.push(note("⏎ or click starts it.")); + RowPanel { + title: format!("{NEW_SESSION_LABEL} · {agent_id}"), + lines, + } +} + +/// One `label value` row, label dimmed so the values line up as the content. +fn field(label: &str, value: &str) -> StyledLine { + StyledLine { + text: format!("{label:<10} {value}"), + color: None, + dim: false, + } +} + +/// One dimmed prose line. +fn note(text: &str) -> StyledLine { + StyledLine { + text: text.to_string(), + color: None, + dim: true, + } +} + +/// Wrap a sentence into dimmed lines at the pane width. +fn wrap_note(text: String, width: usize) -> Vec { + crate::ui::util::wrap(&text, width.max(20)) + .into_iter() + .map(|row| note(&row)) + .collect() +} + +/// How many session rows hang off the agent under the cursor. +/// +/// Counted off the rail rather than off the roster so the number and the rows +/// beneath it cannot disagree: the walk stops at the next agent, host, or the +/// machine-level action, which is exactly where that agent's group ends. +fn sessions_under(selection: &Selection) -> usize { + selection + .rows + .iter() + .skip(selection.active + 1) + .take_while(|row| { + !matches!( + row, + RailRow::Agent(_) | RailRow::Host(_) | RailRow::NewAgent + ) + }) + .filter(|row| matches!(row, RailRow::Session(_))) + .count() +} + +/// How many agent rows hang off the host under the cursor. +fn agents_under(selection: &Selection) -> usize { + selection + .rows + .iter() + .skip(selection.active + 1) + .take_while(|row| !matches!(row, RailRow::Host(_))) + .filter(|row| matches!(row, RailRow::Agent(_))) + .count() +} + +/// What to call the host an agent is placed on. +/// +/// The rail's own host row when there is one, else this machine when the id is +/// the local host's, else the bare id — which is all a lane-only agent carries. +fn host_label(app: &App, selection: &Selection, host_id: &str) -> String { + let host_id = host_id.trim(); + if let Some(host) = selection.rows.iter().find_map(|row| match row { + RailRow::Host(host) if host.host_id.trim() == host_id && !host_id.is_empty() => Some(host), + _ => None, + }) { + return host.label.clone(); + } + if host_id.is_empty() || host_id == app.local_host_id().trim() { + return "this device".to_string(); + } + host_id.to_string() +} diff --git a/src/tui/src/ui/app/render/agents/transcript.rs b/src/tui/src/ui/app/render/agents/transcript.rs index 1e3ffef82..13509e8b4 100644 --- a/src/tui/src/ui/app/render/agents/transcript.rs +++ b/src/tui/src/ui/app/render/agents/transcript.rs @@ -18,8 +18,9 @@ use crate::ui::meters; use medulla::harness_contract::AgentBudgetMetadata; use super::super::super::types::App; -use super::super::{chat_lines, styled_to_tline}; +use super::super::styled_to_tline; use super::types::Selection; +use super::{started, summary}; impl App { /// Draw the transcript or declaration for whatever the cursor is on. @@ -48,16 +49,35 @@ impl App { let lane = selection.lane(); let pane_width = ((area.width as usize).saturating_sub(4)).max(24); let on_orchestrator = selection.on_orchestrator; + // Resolved for the laneless rows — a host header, an idle agent, either + // action row — which have no transcript and must not be given someone + // else's. `selection.lane()` is `None` for exactly those, so this is the + // one branch that can answer for them. + let panel = (!on_orchestrator && selection.task.is_none() && lane.is_none()) + .then(|| summary::row_panel(self, selection, pane_width)); + // The sessions this conversation started, and the task each of its lines + // opens. Only the orchestrator's own pane carries them. + let mut started_hits: Vec> = Vec::new(); let content_lines: Vec = if let Some(t) = &selection.task { task_lines(t, pane_width) } else if on_orchestrator { // The orchestrator lane is the conversation: show what was said, not // the model calls that said it. The calls stay in Settings › Trace. - chat_lines(&self.snapshot.events, pane_width) + // §A7 rides inside it — each turn is followed by the sessions that + // turn started. + let started = self.started_sessions(); + let (lines, hits) = + started::chat_lines_with_sessions(&self.snapshot.events, pane_width, &started); + started_hits = hits; + lines + } else if let Some(panel) = &panel { + panel.lines.clone() } else { lane_lines(lane, pane_width) }; - let title = if let Some(t) = &selection.task { + let title = if let Some(panel) = &panel { + panel.title.clone() + } else if let Some(t) = &selection.task { format!( "{} › {} · {} turns", lane.map(|l| l.label.as_str()).unwrap_or("task"), @@ -71,8 +91,18 @@ impl App { .get(self.active_thread_idx()) .map(|t| t.name.clone()) .unwrap_or_else(|| "main".into()); + // The session count moved into the title with the entries + // themselves: they are scattered through the conversation now, so + // the pane says how many there are and that they can be opened, + // rather than repeating a heading above every group. + let sessions = started_hits.iter().flatten().count(); + let opener = if sessions > 0 { + format!(" · {sessions} sessions · click ▸ to open") + } else { + String::new() + }; format!( - "orchestrator · {thread} · {} turns", + "orchestrator · {thread} · {} turns{opener}", self.snapshot.messages.len().div_ceil(2) ) } else if let Some(l) = lane { @@ -84,41 +114,11 @@ impl App { let inner = block.inner(area); f.render_widget(block, area); let mut header: Vec = Vec::new(); - // §A7: the orchestrator says what it started, and each entry is a way in. - // Drawn first so its screen rows are the top of the pane and the hit map - // below can be computed from a fixed offset. + // §A7's entries are inside the conversation now (see [`started`]), so + // the hit map is built from the scrolled view below rather than from a + // fixed block at the top. Cleared here for the frames that draw no + // conversation at all. self.hit_started_sessions = None; - if on_orchestrator { - let started = self.started_sessions(); - if !started.is_empty() { - header.push(TLine::from(Span::styled( - format!("sessions started · {} · click to open", started.len()), - Style::default().fg(Color::Cyan), - ))); - for session in &started { - header.push(TLine::from(Span::styled( - format!( - " ↳ {} · {} · {}", - session.agent, session.task_id, session.status - ), - Style::default().add_modifier(Modifier::DIM), - ))); - } - self.hit_started_sessions = Some(( - Rect { - // The heading is not a destination; only the entries under - // it are, so the box starts one row down. - y: inner.y.saturating_add(1), - height: (started.len() as u16).min(inner.height.saturating_sub(1)), - ..inner - }, - started - .iter() - .map(|session| session.task_id.clone()) - .collect(), - )); - } - } // What the selected agent is working on, in one line. The Work panel // beside this shows the whole picture, but it needs columns a narrow // terminal does not have — and the single most useful fact, what the @@ -282,7 +282,30 @@ impl App { self.agent_scroll.min(max_scroll) }; let end = content_lines.len() - eff; - let view = &content_lines[end.saturating_sub(capacity)..end]; + let window = end.saturating_sub(capacity)..end; + let view = &content_lines[window.clone()]; + // The §A7 entries are transcript lines now, so their hit box is the + // *visible* slice of the conversation and the task each drawn row opens. + // Keyed by task rather than by index for the same reason it always was: + // the rail is rebuilt every frame, and a session can end between the + // click landing and this resolving. + if on_orchestrator && !view.is_empty() { + let hits: Vec> = started_hits + .get(window) + .map(<[Option]>::to_vec) + .unwrap_or_default(); + if hits.iter().any(Option::is_some) { + self.hit_started_sessions = Some(( + Rect { + y: inner.y.saturating_add(header.len() as u16), + height: (hits.len() as u16) + .min(inner.height.saturating_sub(header.len() as u16)), + ..inner + }, + hits, + )); + } + } let mut out = header; if view.is_empty() { out.push(TLine::from(Span::styled( diff --git a/src/tui/src/ui/app/render/agents/transcript_tests.rs b/src/tui/src/ui/app/render/agents/transcript_tests.rs index 57ed899e9..2ebdfe39b 100644 --- a/src/tui/src/ui/app/render/agents/transcript_tests.rs +++ b/src/tui/src/ui/app/render/agents/transcript_tests.rs @@ -1,16 +1,18 @@ -//! Rendering regressions for session context in the transcript header. +//! Rendering regressions for session context in the transcript header, and for +//! the rows that have no transcript at all. use std::sync::Arc; use medulla::config::LoadedConfig; use medulla::harness_work::{HarnessSessionInfo, WorkSnapshot}; use medulla::runtime::mock::MockRuntime; -use medulla::runtime::Runtime; -use medulla::ui::agents::{AgentLane, AgentRole}; +use medulla::runtime::{AgentDeclaration, Runtime}; +use medulla::ui::agents::{AgentLane, AgentRole, TurnBlock}; use ratatui::backend::TestBackend; use ratatui::layout::Rect; use ratatui::Terminal; +use crate::ui::app::rail::{AgentRailRow, HostRailRow, RailRow}; use crate::ui::app::App; use super::types::Selection; @@ -48,7 +50,7 @@ fn descriptorless_lanes_still_show_their_pull_request_context() { rows: Vec::new(), active: 0, lanes: vec![lane], - lane_index: 0, + lane_index: Some(0), task: None, on_orchestrator: false, harness: None, @@ -86,3 +88,185 @@ fn descriptorless_lanes_still_show_their_pull_request_context() { assert!(output_without_pr.contains("branch fix-context")); assert!(!output_without_pr.contains("PR 42")); } + +/// A marker only the orchestrator's lane ever renders. +const ORCHESTRATOR_ONLY: &str = "ORCHESTRATORTHINKING"; + +/// Lane 0 — the orchestrator's — with a body no other row may show. +fn orchestrator_lane() -> AgentLane { + AgentLane { + key: "orchestrator".into(), + label: "orchestrator".into(), + role: AgentRole::Orchestrator, + turns: vec![TurnBlock { + at: 1, + header: ORCHESTRATOR_ONLY.into(), + header_color: None, + reasoning: Some(ORCHESTRATOR_ONLY.into()), + content: Some(ORCHESTRATOR_ONLY.into()), + tools: Vec::new(), + }], + last_at: 1, + tasks: Vec::new(), + context_tokens: None, + usage: Default::default(), + harness_label: None, + agent_id: None, + session_id: None, + parent_agent_id: None, + descriptor: None, + active_tasks: 0, + work: None, + } +} + +/// Draw one rail row's pane and return everything it painted. +fn pane_for(row: RailRow) -> String { + let runtime: Arc = Arc::new(MockRuntime::empty()); + let mut app = App::new(runtime, LoadedConfig::defaults("medulla.tui.json".into())); + let selection = Selection { + rows: vec![row], + active: 0, + lanes: vec![orchestrator_lane()], + // What the fix makes representable: a row with no lane of its own. It + // used to be `0`, which is this lane. + lane_index: None, + task: None, + on_orchestrator: false, + harness: None, + }; + let mut terminal = Terminal::new(TestBackend::new(90, 20)).unwrap(); + terminal + .draw(|frame| app.draw_agents_pane(frame, Rect::new(0, 0, 90, 20), &selection)) + .unwrap(); + terminal + .backend() + .buffer() + .content() + .iter() + .map(|cell| cell.symbol()) + .collect() +} + +/// A declared agent with nothing dispatched to it: a row, and no lane. +fn idle_agent() -> AgentRailRow { + AgentRailRow { + agent_id: "api-claude".into(), + host_id: String::new(), + agent: Some(medulla::ui::hosts::HostAgentRow { + agent_id: "api-claude".into(), + label: "API".into(), + harness: Some("claude".into()), + workspace: Some("/work/api".into()), + roles: vec!["reviewer".into()], + max_sessions: Some(1), + declared: true, + editable: true, + live: false, + selected: false, + }), + lane_index: None, + } +} + +#[test] +fn a_row_with_no_lane_never_renders_the_orchestrators_stream() { + // The bug: `selection.lane()` fell back to lane 0 — the orchestrator's — so + // selecting `+ New agent`, a host header, or an agent nothing had been + // dispatched to showed the orchestrator thinking, attributed to a row that + // had not thought anything. + for (what, row) in [ + ("the create action", RailRow::NewAgent), + ( + "a host header", + RailRow::Host(HostRailRow { + host_id: "studio".into(), + label: "studio".into(), + local: false, + }), + ), + ("an idle agent", RailRow::Agent(idle_agent())), + ( + "the per-agent action", + RailRow::NewSession { + agent_id: "api-claude".into(), + }, + ), + ] { + let output = pane_for(row); + assert!( + !output.contains(ORCHESTRATOR_ONLY), + "{what} showed lane 0's stream: {output}" + ); + } +} + +#[test] +fn an_idle_agent_describes_itself_instead() { + let output = pane_for(RailRow::Agent(idle_agent())); + assert!(output.contains("agent · API"), "{output}"); + assert!(output.contains("api-claude"), "the id: {output}"); + assert!(output.contains("claude"), "the harness: {output}"); + assert!(output.contains("/work/api"), "the workspace: {output}"); + assert!(output.contains("reviewer"), "its roles: {output}"); + assert!(output.contains("No sessions yet"), "the count: {output}"); + assert!( + output.contains("new session"), + "and how to start one: {output}" + ); +} + +#[test] +fn a_host_row_and_the_action_rows_say_what_they_are() { + let host = pane_for(RailRow::Host(HostRailRow { + host_id: "studio".into(), + label: "studio".into(), + local: false, + })); + assert!(host.contains("host · studio"), "{host}"); + assert!(host.contains("remote"), "local or remote: {host}"); + + let new_agent = pane_for(RailRow::NewAgent); + assert!(new_agent.contains("New agent"), "{new_agent}"); + assert!(new_agent.contains("Declare an agent"), "{new_agent}"); + + let new_session = pane_for(RailRow::NewSession { + agent_id: "api-claude".into(), + }); + assert!(new_session.contains("new session"), "{new_session}"); + assert!( + new_session.contains("api-claude"), + "named for its agent: {new_session}" + ); +} + +#[test] +fn the_selection_gives_a_laneless_row_no_lane_at_all() { + // The guard itself, one level below the render: the rail's own rows resolve + // to `None` rather than to lane 0, so no caller can inherit a stream. + let mut app = crate::ui::app::rail::tests::hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "idle-agent", + "", + "claude", + "/work/idle", + )]; + let rows = app.rail_rows(); + for (index, row) in rows.iter().enumerate() { + if row.lane_index().is_some() { + continue; + } + app.agent_index = index; + let selection = app.agents_selection(); + assert!( + selection.lane_index.is_none() && selection.lane().is_none(), + "row {index} borrowed a lane it does not have" + ); + if !matches!(row, RailRow::Lane(_)) { + assert!( + !selection.on_orchestrator, + "row {index} is not the conversation" + ); + } + } +} diff --git a/src/tui/src/ui/app/render/agents/types.rs b/src/tui/src/ui/app/render/agents/types.rs index a7546875c..3ad832ec3 100644 --- a/src/tui/src/ui/app/render/agents/types.rs +++ b/src/tui/src/ui/app/render/agents/types.rs @@ -21,7 +21,15 @@ pub(super) struct Selection { /// Every lane the fold produced, in display order. pub(super) lanes: Vec, /// The lane whose transcript the pane shows, when the cursor is on one. - pub(super) lane_index: usize, + /// + /// `None` for a row that has no lane of its own — a host header, an agent + /// nothing has been dispatched to, and either action row. Deliberately an + /// `Option` rather than a defaulted index: this used to fall back to lane + /// **0**, which is the orchestrator's, so selecting `+ New agent` or an idle + /// agent rendered the orchestrator's own thinking as if it belonged to that + /// row. A row with no lane now has no lane, and the caller renders what the + /// row actually is. + pub(super) lane_index: Option, /// The task sublane under the cursor, if the cursor is on one. pub(super) task: Option, /// Whether the pane is showing the operator's own conversation, which @@ -39,8 +47,16 @@ pub(super) struct Selection { impl Selection { /// The lane the pane describes, if any. + /// + /// `None` is a real answer — the row under the cursor has no transcript — + /// and never a stand-in for lane 0. pub(super) fn lane(&self) -> Option<&AgentLane> { - self.lanes.get(self.lane_index) + self.lanes.get(self.lane_index?) + } + + /// The rail row under the cursor, if the rail has one. + pub(super) fn row(&self) -> Option<&RailRow> { + self.rows.get(self.active) } } diff --git a/src/tui/src/ui/app/session_focus.rs b/src/tui/src/ui/app/session_focus.rs index bc9c5a7b9..3143cfc88 100644 --- a/src/tui/src/ui/app/session_focus.rs +++ b/src/tui/src/ui/app/session_focus.rs @@ -25,6 +25,10 @@ use super::types::App; pub(in crate::ui::app) struct StartedSession { /// The agent the session runs on — what the operator recognises it by. pub(in crate::ui::app) agent: String, + /// The harness that agent runs, when the tree knows one. + pub(in crate::ui::app) harness: Option, + /// The directory it works in, when the tree knows one. + pub(in crate::ui::app) workspace: Option, /// The task that created it. Empty for a session with no task of its own. pub(in crate::ui::app) task_id: String, /// The session's current state, in the task board's vocabulary. @@ -42,25 +46,46 @@ impl App { /// person spun up was not "started by the orchestrator", and putting it in /// this block would claim the orchestrator did something it did not. pub(in crate::ui::app) fn started_sessions(&self) -> Vec { - self.rail_rows() - .into_iter() - .enumerate() - .filter_map(|(row_index, row)| { - let RailRow::Session(session) = &row else { - return None; - }; - if session.origin().is_user() { - return None; + // The agent an entry describes is the row it sits under, which the walk + // is already passing: sessions are grouped under their agent, so the + // last agent row seen is this session's own. That is also where the + // harness and the workspace come from — the session row itself carries + // neither, and an entry that names only an id says less than the rail + // row beside it. + let mut agent: Option = None; + let mut started = Vec::new(); + for (row_index, row) in self.rail_rows().into_iter().enumerate() { + match row { + RailRow::Agent(row) => agent = Some(row), + RailRow::Session(session) => { + if session.origin().is_user() { + continue; + } + let Some(task) = session.task.as_ref() else { + continue; + }; + // Only the agent this session is actually filed under: a + // session with no agent sits outside every group, so the row + // above it describes somebody else. + let owner = agent.as_ref().filter(|owner| { + Some(owner.agent_id.as_str()) == session.agent_id.as_deref() + }); + started.push(StartedSession { + agent: owner + .map(|owner| owner.label()) + .or_else(|| session.agent_id.clone()) + .unwrap_or_default(), + harness: owner.and_then(|owner| owner.harness().map(str::to_string)), + workspace: owner.and_then(|owner| owner.workspace().map(str::to_string)), + task_id: task.task_id.clone(), + status: task.status.label(), + row_index, + }); } - let task = session.task.as_ref()?; - Some(StartedSession { - agent: session.agent_id.clone().unwrap_or_default(), - task_id: task.task_id.clone(), - status: task.status.label(), - row_index, - }) - }) - .collect() + _ => {} + } + } + started } /// Move focus to the session serving `task_id`, if the rail still lists it. diff --git a/src/tui/src/ui/app/types.rs b/src/tui/src/ui/app/types.rs index 881d4904a..022bcec57 100644 --- a/src/tui/src/ui/app/types.rs +++ b/src/tui/src/ui/app/types.rs @@ -1062,13 +1062,19 @@ pub struct App { pub(super) hit_harness: Option<(Rect, String)>, /// The threads strip's hit box and its first visible row, for click-to-switch. pub(super) hit_threads: Option<(Rect, usize)>, - /// Where the orchestrator's "sessions started" block drew, and the task each - /// of its lines opens (§A7). + /// Where the orchestrator's conversation drew, and the task each of its + /// visible lines opens (§A7) — `None` for the lines that are transcript + /// rather than a session entry. + /// + /// One slot per drawn row rather than a dense list, because the entries are + /// interleaved with the conversation: each one sits under the turn that + /// started it, so the block is no longer contiguous and an offset from its + /// top no longer identifies an entry. /// /// Tasks rather than row indices: the rail is rebuilt every frame, so an /// index recorded during the draw can name a different row by the time the /// click lands. A task id either still has a session or does not. - pub(super) hit_started_sessions: Option<(Rect, Vec)>, + pub(super) hit_started_sessions: Option<(Rect, Vec>)>, pub(super) hit_context: Option, /// The selected workflow step's preview, for pointer-wheel scrolling. pub(super) hit_workflow_preview: Option, From 2027af834cf6036824d4abdea7b32534d88ca022 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 5 Aug 2026 13:12:31 +0530 Subject: [PATCH 5/6] test(ui): gate the pty-backed rail tests to unix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three tests stand a session up by pointing the codex bin at /bin/sh, which Windows has no equivalent of — CreateProcessW cannot find it, so they fail there while passing everywhere else. This module's sibling pty tests already carry the same guard for the same reason; these were written without it. The row model they exercise is portable; only this way of standing a session up is not. Co-Authored-By: Claude --- src/tui/src/ui/app/agent_control_tests.rs | 4 ++++ src/tui/src/ui/app/rail/tests.rs | 8 ++++++++ 2 files changed, 12 insertions(+) diff --git a/src/tui/src/ui/app/agent_control_tests.rs b/src/tui/src/ui/app/agent_control_tests.rs index 862c40f31..aa5bb76b3 100644 --- a/src/tui/src/ui/app/agent_control_tests.rs +++ b/src/tui/src/ui/app/agent_control_tests.rs @@ -131,6 +131,10 @@ fn a_new_session_under_an_agent_asks_for_a_name_first() { ); } +// 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. +#[cfg(unix)] #[test] fn a_named_session_opens_in_the_agents_own_harness_and_workspace() { let sessions = PtyManager::new(); diff --git a/src/tui/src/ui/app/rail/tests.rs b/src/tui/src/ui/app/rail/tests.rs index 55fcdf49e..9b2e976b3 100644 --- a/src/tui/src/ui/app/rail/tests.rs +++ b/src/tui/src/ui/app/rail/tests.rs @@ -143,6 +143,10 @@ fn every_session_row_sits_under_the_agent_that_owns_it() { assert!(checked > 0, "the demo fixture dispatches at least one task"); } +// 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. +#[cfg(unix)] #[test] fn a_dispatched_session_and_an_operator_session_are_the_same_row_type() { // §A0: the `── your harnesses ──` divider and the second row type are gone. @@ -175,6 +179,10 @@ fn a_dispatched_session_and_an_operator_session_are_the_same_row_type() { harnesses.sessions.shutdown(); } +// 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. +#[cfg(unix)] #[test] fn a_session_in_an_undeclared_directory_is_still_listed() { // Nothing declares `/`, so the session belongs to no agent — and a harness From f5a0ed6abf794100eb31044dafc6900f957d6d45 Mon Sep 17 00:00:00 2001 From: sanil-23 Date: Wed, 5 Aug 2026 13:59:50 +0530 Subject: [PATCH 6/6] fix(ui): scope a session to the host that declares its agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the Host → Agent → Session tree (#182). The two that change what an operator can do: - `^T` acted on any selected agent row, including one declared on another machine, so it started a *local* process for a remote agent — which the rail then listed as an orphan, because it resolves sessions against the local declarations. `declaration_for` now filters by host, and the refusal names the machine to open it on instead. - A session started from a custom preset was compared to declarations by the CLI underneath it (`claude`) rather than by the preset id the declaration records (`deepseek`), so every preset-backed session was filed under no agent. The preset id is carried through the launch and `SessionRow::harness_id` is what the rail matches on. The rest: - `on_orchestrator_lane` matches a lane's own row explicitly. `RailRow:: Lane` also wraps the overflow control and the `── functions ──` divider, and the divider names no lane at all — it fell through to the "no lanes yet ⇒ the orchestrator is all there is" answer and would have claimed the orchestrator's composer. - One rule for the no-config-file path: an edit applies for this run, in the declaration list as well as the roster, and says how long it lasts. Roles updated only the roster (so the row redrew with the old ones), a rename was silent, and undeclaring refused. A seed with no workspace is now refused like one with no harness — an agent is `harness × workspace`, and a declaration missing half of it is one no session can be opened from. - `local_hosts` returns unique addresses; two sections that resolve to one cannot both bind, so listing both drew a host that will not be there. - A remote host's detail row picks a *probed* entry rather than the first. - `mod.rs` keeps the wiring; the host projection moves to `projection.rs`. - Clicking either action row retargets the watch, so a click arriving from a task row stops that stream. - `d` on the Hosts page clears the role focus before reshaping the tree. - The role list never draws past the rows the pane gave it. Co-Authored-By: Claude --- src/sdk/src/config/local_hosts.rs | 25 ++ src/sdk/src/config/local_hosts_tests.rs | 53 ++++ src/sdk/src/ui/hosts/mod.rs | 243 +---------------- src/sdk/src/ui/hosts/projection.rs | 256 ++++++++++++++++++ src/sdk/src/ui/hosts/tests.rs | 30 ++ src/tui/examples/pty_load.rs | 1 + src/tui/src/ui/app/agent_control.rs | 40 ++- src/tui/src/ui/app/agent_control_tests.rs | 62 +++++ src/tui/src/ui/app/changes/baseline_tests.rs | 1 + src/tui/src/ui/app/commands/dispatch.rs | 6 +- src/tui/src/ui/app/hosts/edit.rs | 67 ++++- src/tui/src/ui/app/hosts/tests.rs | 185 +++++++++++++ src/tui/src/ui/app/input/mouse.rs | 11 +- src/tui/src/ui/app/input/tests.rs | 49 ++++ src/tui/src/ui/app/keys/routing/mod.rs | 5 + src/tui/src/ui/app/mod.rs | 2 + src/tui/src/ui/app/rail/resolve.rs | 54 +++- .../src/ui/app/render/agents/rail/tests.rs | 1 + .../ui/app/render/routing/hosts/preview.rs | 61 +++-- .../src/ui/app/render/settings/status_line.rs | 2 + src/tui/src/ui/app/state.rs | 15 +- src/tui/src/ui/app/state_tests.rs | 78 ++++++ src/tui/src/ui/harness_pane/spawn.rs | 5 + src/tui/src/ui/harness_pane/tests/session.rs | 1 + src/tui/src/worker/app/tests/helpers/mod.rs | 1 + src/tui/src/worker/executor/run.rs | 7 +- src/tui/src/worker/executor/types.rs | 6 +- src/tui/src/worker/executor_tests/live.rs | 3 + src/tui/src/worker/executor_tests/sessions.rs | 2 + src/tui/src/worker/pty/handle/state.rs | 1 + src/tui/src/worker/pty/handle/types.rs | 7 + src/tui/src/worker/pty/manager/open.rs | 1 + src/tui/src/worker/pty/tests/mod.rs | 1 + src/tui/src/worker/pty/types.rs | 29 ++ src/tui/tests/e2e_local_harness_pane.rs | 1 + src/tui/tests/e2e_screen_stream.rs | 1 + src/tui/tests/feature_harness_control.rs | 1 + src/tui/tests/feature_harness_handoff.rs | 1 + src/tui/tests/feature_paste/attached.rs | 1 + src/tui/tests/feature_workers/helpers.rs | 4 + src/tui/tests/feature_workers/roles.rs | 51 +++- 41 files changed, 1073 insertions(+), 298 deletions(-) create mode 100644 src/sdk/src/ui/hosts/projection.rs create mode 100644 src/tui/src/ui/app/state_tests.rs diff --git a/src/sdk/src/config/local_hosts.rs b/src/sdk/src/config/local_hosts.rs index 9173119c8..e237a494b 100644 --- a/src/sdk/src/config/local_hosts.rs +++ b/src/sdk/src/config/local_hosts.rs @@ -32,7 +32,21 @@ pub struct LocalHostRef { /// declaration order. /// /// Addresses come from [`local_host_address`], names from [`local_host_name`]. +/// +/// **The resolved ids are unique.** An address is a bus address, so two sections +/// that resolve to one can never both exist: the first binds and the second's +/// `bind` fails, which the host starter already reports as a start-up problem. +/// This is the same fact stated for the readers — a second row for a host that will +/// not be there is not a host, it is the collision drawn twice. The first claim +/// on an address keeps it, which is also the one that binds, so the list and the +/// binder still agree. +/// +/// Two sections collide by two routes, and both are dropped here: an extra that +/// *types* an address another section already resolved to, and two names that +/// slug to the same thing — `"API"` and `"api"` both give `local-api`, because +/// the slug is lowercased. pub fn local_hosts(primary: &HostSection, extras: &[HostSection]) -> Vec { + let mut taken: Vec = Vec::new(); std::iter::once(LocalHostRef { id: primary.effective_address(), name: local_host_name(primary, &primary.workspace, true), @@ -44,12 +58,23 @@ pub fn local_hosts(primary: &HostSection, extras: &[HostSection]) -> Vec = hosts.iter().map(|host| host.id.as_str()).collect(); + assert_eq!(ids, vec!["chosen-by-hand"]); + assert!( + hosts[0].primary, + "the primary keeps the address it declared" + ); +} + +#[test] +fn two_names_that_slug_alike_are_one_host_not_two() { + // The slug is lowercased, so `API` and `api` are the same address. The + // second entry cannot bind, and a row for it would be the collision drawn + // twice rather than a host the operator has. + let primary = HostSection { + workspace: "/w".into(), + ..HostSection::default() + }; + let extras = vec![ + extra("API", "/w/api"), + extra("api", "/w/api-again"), + extra("web", "/w/web"), + ]; + + let hosts = local_hosts(&primary, &extras); + let ids: Vec<&str> = hosts.iter().map(|host| host.id.as_str()).collect(); + assert_eq!(ids, vec!["this-device", "local-api", "local-web"]); + // Dropping the collision must not renumber what follows it: the fallback + // index is the entry's place in `[[hosts]]`, which is what the binder counts. + assert_eq!( + local_hosts( + &primary, + &[extra("API", "/w"), extra("api", "/w"), extra("", "/w/x")] + ) + .last() + .map(|host| host.id.as_str()), + Some("local-host-3") + ); +} + #[test] fn an_unnamed_host_falls_back_to_its_directory_then_the_path() { let unnamed = extra("", ""); diff --git a/src/sdk/src/ui/hosts/mod.rs b/src/sdk/src/ui/hosts/mod.rs index 58a953312..09cd5e141 100644 --- a/src/sdk/src/ui/hosts/mod.rs +++ b/src/sdk/src/ui/hosts/mod.rs @@ -17,246 +17,15 @@ //! not yet share its declared agent list over the link (plan §D1). Until it //! does, a remote host shows what the roster knows about it and says so, //! rather than pretending this machine declared anything over there. +//! +//! The rows are [`types`]; folding the two sources into the tree is +//! [`projection`]. -use crate::config::LocalHostRef; -use crate::runtime::{AgentDeclaration, WorkerInfo}; - +mod projection; mod types; + +pub use projection::host_rows; pub use types::{HostAgentRow, HostKind, HostRow}; #[cfg(test)] mod tests; - -/// Build the `Host → Agents` tree. -/// -/// `locals` is every host this machine declares (see -/// [`local_hosts`](crate::config::local_hosts)); it leads the result in that -/// order, whether or not anything is running on it — a declared host with -/// nothing in the roster is still where its agents live, and hiding it would -/// make "the local host is always present" false exactly when the operator needs -/// to see why nothing is being dispatched. -/// -/// `workers` is the live roster. Entries whose address matches a local host fill -/// in what is running there; the rest are grouped by address into one remote -/// host each, in first-seen order. -/// -/// A declaration naming a host that `locals` does not contain still gets a host -/// row of its own, after the configured ones: it was declared on this machine, -/// so it is local and editable, and dropping it would hide agents the operator -/// wrote down. -pub fn host_rows( - workers: &[WorkerInfo], - declarations: &[AgentDeclaration], - locals: &[LocalHostRef], -) -> Vec { - let mut rows: Vec = Vec::new(); - let mut claimed: Vec<&str> = Vec::new(); - - // The first local host also claims the agents that name no host at all: an - // agent nothing places belongs to the machine looking at it. They are - // declared in *this* config, so the alternative is not "somewhere else" but - // "nowhere" — an agent written down and rendered by neither tab. - for (index, local) in locals.iter().enumerate() { - rows.push(local_row( - &local.id, - &local.name, - workers, - declarations, - index == 0, - &mut claimed, - )); - } - // Declared, but on a host id this config does not (or no longer) describes. - // An unplaced agent lands here only when there is no local host to claim it, - // which is the one arrangement where it would otherwise vanish. - for declaration in declarations { - let host_id = declaration.host_id.trim(); - if rows.iter().any(|row| row.id == host_id) { - continue; - } - if host_id.is_empty() && !locals.is_empty() { - continue; - } - let label = if host_id.is_empty() { - "this device" - } else { - host_id - }; - rows.push(local_row( - host_id, - label, - workers, - declarations, - host_id.is_empty(), - &mut claimed, - )); - } - // Everything left in the roster is somebody else's machine. - for worker in workers { - if claimed.contains(&worker.id.as_str()) { - continue; - } - let address = worker.address.trim(); - match rows - .iter_mut() - .find(|row| row.kind == HostKind::Remote && row.id == address) - { - Some(row) => { - row.detail_worker.get_or_insert_with(|| worker.id.clone()); - // One agent per id: the same peer can reach the registry twice - // (added by hand, then advertised), and an agent listed twice is - // an agent an operator thinks they have two of. - if !row.agents.iter().any(|agent| agent.agent_id == worker.id) { - row.agents.push(agent_from_worker(worker, false)); - } - } - None => rows.push(HostRow { - id: address.to_string(), - label: remote_label(worker), - kind: HostKind::Remote, - agents: vec![agent_from_worker(worker, false)], - detail_worker: Some(worker.id.clone()), - }), - } - } - rows -} - -/// One local host: its declared agents first, then anything the roster has at -/// its address that no declaration claims. -/// -/// The undeclared tail is the migration seed (an install that predates -/// declarations advertises agents nobody wrote down) and it stays editable: -/// assigning it a role is what writes the declaration that makes the role stick. -/// -/// `unplaced` makes this row the home for declarations that name no host — see -/// [`host_rows`]. Exactly one row ever sets it, or an agent would be listed -/// under every local host at once. -fn local_row<'a>( - id: &str, - label: &str, - workers: &'a [WorkerInfo], - declarations: &[AgentDeclaration], - unplaced: bool, - claimed: &mut Vec<&'a str>, -) -> HostRow { - let mut agents: Vec = Vec::new(); - for declaration in declarations - .iter() - .filter(|d| d.on_host(id) || (unplaced && d.host_id.trim().is_empty())) - { - let worker = workers - .iter() - .find(|worker| worker.id.trim() == declaration.agent_id.trim()); - agents.push(agent_from_declaration(declaration, worker)); - } - let mut detail_worker = None; - for worker in workers.iter().filter(|worker| worker.address.trim() == id) { - if detail_worker.is_none() && has_probe_details(worker) { - detail_worker = Some(worker.id.clone()); - } - if agents.iter().any(|agent| agent.agent_id == worker.id) { - continue; - } - agents.push(agent_from_worker(worker, true)); - } - // Claim by agent id rather than by address: a declaration may name an agent - // whose roster entry is registered under another address, and it must not - // then be listed a second time as a remote host of its own. - for agent in &agents { - if let Some(worker) = workers.iter().find(|worker| worker.id == agent.agent_id) { - claimed.push(worker.id.as_str()); - } - } - if detail_worker.is_none() { - detail_worker = agents - .iter() - .find(|agent| agent.live) - .map(|agent| agent.agent_id.clone()); - } - HostRow { - id: id.to_string(), - label: label.to_string(), - kind: HostKind::Local, - agents, - detail_worker, - } -} - -/// Whether a roster entry carries a capability probe worth previewing. -fn has_probe_details(worker: &WorkerInfo) -> bool { - worker.cpu_cores.is_some() - || worker.memory_total_bytes.is_some() - || worker.memory_available_bytes.is_some() - || worker.ip_address.is_some() - || !worker.readiness.is_empty() - || !worker.budgets.is_empty() -} - -/// An agent row from the declaration that defines it, with whatever the roster -/// knows about it folded in. -/// -/// Roles come from the declaration, not from the live entry: the declaration is -/// what survives a restart, so showing it is what makes the checkbox honest. -fn agent_from_declaration( - declaration: &AgentDeclaration, - worker: Option<&WorkerInfo>, -) -> HostAgentRow { - let workspace = declaration - .workspace - .path() - .map(str::to_string) - .or_else(|| worker.and_then(|worker| worker.workspace.clone())); - HostAgentRow { - agent_id: declaration.agent_id.clone(), - // A blank name is not a name: a declaration whose name field holds only - // spaces would otherwise render as a row with nothing on it. - label: declaration - .name - .clone() - .filter(|name| !name.trim().is_empty()) - .or_else(|| worker.and_then(|worker| worker.label.clone())) - .unwrap_or_else(|| declaration.agent_id.clone()), - harness: Some(declaration.harness.clone()), - workspace, - roles: declaration.roles.clone(), - max_sessions: Some(declaration.max_sessions()), - declared: true, - editable: true, - live: worker.is_some(), - selected: worker.is_some_and(|worker| worker.selected), - } -} - -/// An agent row for a roster entry no declaration on this machine covers. -/// -/// `local` says whether it sits on a host this machine runs, which is the only -/// thing that decides whether its roles can be assigned here. -fn agent_from_worker(worker: &WorkerInfo, local: bool) -> HostAgentRow { - HostAgentRow { - agent_id: worker.id.clone(), - label: worker - .label - .clone() - .or_else(|| worker.handle.clone()) - .unwrap_or_else(|| worker.id.clone()), - harness: worker.harness.clone(), - workspace: worker.workspace.clone(), - roles: worker.roles.clone(), - max_sessions: None, - declared: false, - editable: local, - live: true, - selected: worker.selected, - } -} - -/// What to call a remote host: the operator's label, its handle, else the raw -/// address they typed. -fn remote_label(worker: &WorkerInfo) -> String { - worker - .label - .clone() - .or_else(|| worker.handle.clone()) - .unwrap_or_else(|| worker.address.clone()) -} diff --git a/src/sdk/src/ui/hosts/projection.rs b/src/sdk/src/ui/hosts/projection.rs new file mode 100644 index 000000000..544887abe --- /dev/null +++ b/src/sdk/src/ui/hosts/projection.rs @@ -0,0 +1,256 @@ +//! Folding the declarations and the live roster into the `Host → Agents` tree. +//! +//! The one place the two sources meet. Which of them is authoritative depends on +//! the host: a local host's agents come from what this machine wrote down, a +//! remote host's from what the roster reached (see [`super`]) — so the join is +//! stated once, here, rather than being re-derived by each tab that draws it. + +use super::{HostAgentRow, HostKind, HostRow}; +use crate::config::LocalHostRef; +use crate::runtime::{AgentDeclaration, WorkerInfo}; + +/// Build the `Host → Agents` tree. +/// +/// `locals` is every host this machine declares (see +/// [`local_hosts`](crate::config::local_hosts)); it leads the result in that +/// order, whether or not anything is running on it — a declared host with +/// nothing in the roster is still where its agents live, and hiding it would +/// make "the local host is always present" false exactly when the operator needs +/// to see why nothing is being dispatched. +/// +/// `workers` is the live roster. Entries whose address matches a local host fill +/// in what is running there; the rest are grouped by address into one remote +/// host each, in first-seen order. +/// +/// A declaration naming a host that `locals` does not contain still gets a host +/// row of its own, after the configured ones: it was declared on this machine, +/// so it is local and editable, and dropping it would hide agents the operator +/// wrote down. +pub fn host_rows( + workers: &[WorkerInfo], + declarations: &[AgentDeclaration], + locals: &[LocalHostRef], +) -> Vec { + let mut rows: Vec = Vec::new(); + let mut claimed: Vec<&str> = Vec::new(); + + // The first local host also claims the agents that name no host at all: an + // agent nothing places belongs to the machine looking at it. They are + // declared in *this* config, so the alternative is not "somewhere else" but + // "nowhere" — an agent written down and rendered by neither tab. + for (index, local) in locals.iter().enumerate() { + rows.push(local_row( + &local.id, + &local.name, + workers, + declarations, + index == 0, + &mut claimed, + )); + } + // Declared, but on a host id this config does not (or no longer) describes. + // An unplaced agent lands here only when there is no local host to claim it, + // which is the one arrangement where it would otherwise vanish. + for declaration in declarations { + let host_id = declaration.host_id.trim(); + if rows.iter().any(|row| row.id == host_id) { + continue; + } + if host_id.is_empty() && !locals.is_empty() { + continue; + } + let label = if host_id.is_empty() { + "this device" + } else { + host_id + }; + rows.push(local_row( + host_id, + label, + workers, + declarations, + host_id.is_empty(), + &mut claimed, + )); + } + // Everything left in the roster is somebody else's machine. + for worker in workers { + if claimed.contains(&worker.id.as_str()) { + continue; + } + let address = worker.address.trim(); + match rows + .iter_mut() + .find(|row| row.kind == HostKind::Remote && row.id == address) + { + Some(row) => { + // The host preview reads its capacity, readiness and budgets off + // this one entry, so it has to be an entry that has any: keeping + // whichever agent arrived first left the machine reading "not + // reported" while a probed sibling on the same address held the + // answer. A probed pick is never given up for a later one. + let probed = row + .detail_worker + .as_deref() + .and_then(|id| workers.iter().find(|held| held.id == id)) + .is_some_and(has_probe_details); + if row.detail_worker.is_none() || (!probed && has_probe_details(worker)) { + row.detail_worker = Some(worker.id.clone()); + } + // One agent per id: the same peer can reach the registry twice + // (added by hand, then advertised), and an agent listed twice is + // an agent an operator thinks they have two of. + if !row.agents.iter().any(|agent| agent.agent_id == worker.id) { + row.agents.push(agent_from_worker(worker, false)); + } + } + None => rows.push(HostRow { + id: address.to_string(), + label: remote_label(worker), + kind: HostKind::Remote, + agents: vec![agent_from_worker(worker, false)], + detail_worker: Some(worker.id.clone()), + }), + } + } + rows +} + +/// One local host: its declared agents first, then anything the roster has at +/// its address that no declaration claims. +/// +/// The undeclared tail is the migration seed (an install that predates +/// declarations advertises agents nobody wrote down) and it stays editable: +/// assigning it a role is what writes the declaration that makes the role stick. +/// +/// `unplaced` makes this row the home for declarations that name no host — see +/// [`host_rows`]. Exactly one row ever sets it, or an agent would be listed +/// under every local host at once. +fn local_row<'a>( + id: &str, + label: &str, + workers: &'a [WorkerInfo], + declarations: &[AgentDeclaration], + unplaced: bool, + claimed: &mut Vec<&'a str>, +) -> HostRow { + let mut agents: Vec = Vec::new(); + for declaration in declarations + .iter() + .filter(|d| d.on_host(id) || (unplaced && d.host_id.trim().is_empty())) + { + let worker = workers + .iter() + .find(|worker| worker.id.trim() == declaration.agent_id.trim()); + agents.push(agent_from_declaration(declaration, worker)); + } + let mut detail_worker = None; + for worker in workers.iter().filter(|worker| worker.address.trim() == id) { + if detail_worker.is_none() && has_probe_details(worker) { + detail_worker = Some(worker.id.clone()); + } + if agents.iter().any(|agent| agent.agent_id == worker.id) { + continue; + } + agents.push(agent_from_worker(worker, true)); + } + // Claim by agent id rather than by address: a declaration may name an agent + // whose roster entry is registered under another address, and it must not + // then be listed a second time as a remote host of its own. + for agent in &agents { + if let Some(worker) = workers.iter().find(|worker| worker.id == agent.agent_id) { + claimed.push(worker.id.as_str()); + } + } + if detail_worker.is_none() { + detail_worker = agents + .iter() + .find(|agent| agent.live) + .map(|agent| agent.agent_id.clone()); + } + HostRow { + id: id.to_string(), + label: label.to_string(), + kind: HostKind::Local, + agents, + detail_worker, + } +} + +/// Whether a roster entry carries a capability probe worth previewing. +fn has_probe_details(worker: &WorkerInfo) -> bool { + worker.cpu_cores.is_some() + || worker.memory_total_bytes.is_some() + || worker.memory_available_bytes.is_some() + || worker.ip_address.is_some() + || !worker.readiness.is_empty() + || !worker.budgets.is_empty() +} + +/// An agent row from the declaration that defines it, with whatever the roster +/// knows about it folded in. +/// +/// Roles come from the declaration, not from the live entry: the declaration is +/// what survives a restart, so showing it is what makes the checkbox honest. +fn agent_from_declaration( + declaration: &AgentDeclaration, + worker: Option<&WorkerInfo>, +) -> HostAgentRow { + let workspace = declaration + .workspace + .path() + .map(str::to_string) + .or_else(|| worker.and_then(|worker| worker.workspace.clone())); + HostAgentRow { + agent_id: declaration.agent_id.clone(), + // A blank name is not a name: a declaration whose name field holds only + // spaces would otherwise render as a row with nothing on it. + label: declaration + .name + .clone() + .filter(|name| !name.trim().is_empty()) + .or_else(|| worker.and_then(|worker| worker.label.clone())) + .unwrap_or_else(|| declaration.agent_id.clone()), + harness: Some(declaration.harness.clone()), + workspace, + roles: declaration.roles.clone(), + max_sessions: Some(declaration.max_sessions()), + declared: true, + editable: true, + live: worker.is_some(), + selected: worker.is_some_and(|worker| worker.selected), + } +} + +/// An agent row for a roster entry no declaration on this machine covers. +/// +/// `local` says whether it sits on a host this machine runs, which is the only +/// thing that decides whether its roles can be assigned here. +fn agent_from_worker(worker: &WorkerInfo, local: bool) -> HostAgentRow { + HostAgentRow { + agent_id: worker.id.clone(), + label: worker + .label + .clone() + .or_else(|| worker.handle.clone()) + .unwrap_or_else(|| worker.id.clone()), + harness: worker.harness.clone(), + workspace: worker.workspace.clone(), + roles: worker.roles.clone(), + max_sessions: None, + declared: false, + editable: local, + live: true, + selected: worker.selected, + } +} + +/// What to call a remote host: the operator's label, its handle, else the raw +/// address they typed. +fn remote_label(worker: &WorkerInfo) -> String { + worker + .label + .clone() + .or_else(|| worker.handle.clone()) + .unwrap_or_else(|| worker.address.clone()) +} diff --git a/src/sdk/src/ui/hosts/tests.rs b/src/sdk/src/ui/hosts/tests.rs index 1c9b2768e..68de943f3 100644 --- a/src/sdk/src/ui/hosts/tests.rs +++ b/src/sdk/src/ui/hosts/tests.rs @@ -145,6 +145,36 @@ fn the_preview_reads_capacity_from_whichever_entry_probed_the_machine() { assert_eq!(rows[0].detail_worker.as_deref(), Some("agent-b")); } +#[test] +fn a_remote_hosts_preview_reads_capacity_from_its_probed_entry_too() { + // The remote branch used to keep whichever agent opened the row, so a + // machine whose first agent had never been probed read "not reported" while + // a sibling at the same address held the capacity. + let bare = worker("agent-a", "addr-1"); + let mut probed = worker("agent-b", "addr-1"); + probed.cpu_cores = Some(8); + + let rows = host_rows(&[bare, probed.clone()], &[], &[this_device()]); + let remote = rows + .iter() + .find(|row| row.kind == HostKind::Remote) + .expect("the peer is a remote host"); + assert_eq!(remote.agents.len(), 2, "both agents are listed: {remote:?}"); + assert_eq!(remote.detail_worker.as_deref(), Some("agent-b")); + + // And a probed pick is not given up for a later unprobed one. + let rows = host_rows( + &[probed, worker("agent-c", "addr-1")], + &[], + &[this_device()], + ); + let remote = rows + .iter() + .find(|row| row.kind == HostKind::Remote) + .expect("the peer is a remote host"); + assert_eq!(remote.detail_worker.as_deref(), Some("agent-b")); +} + #[test] fn a_declaration_naming_an_unconfigured_host_still_gets_a_local_row() { // The `[[hosts]]` entry was removed, or the id was hand-written. The agents diff --git a/src/tui/examples/pty_load.rs b/src/tui/examples/pty_load.rs index 1128dd7a1..f3cd9a291 100644 --- a/src/tui/examples/pty_load.rs +++ b/src/tui/examples/pty_load.rs @@ -102,6 +102,7 @@ fn flooding(label: &str) -> LaunchSpec { // Codex takes no preset session id, so its interactive argv is empty and // the script below is the whole command. provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/src/ui/app/agent_control.rs b/src/tui/src/ui/app/agent_control.rs index e430adb8a..b4a85cba2 100644 --- a/src/tui/src/ui/app/agent_control.rs +++ b/src/tui/src/ui/app/agent_control.rs @@ -161,9 +161,7 @@ impl App { /// orchestrator may dispatch into what the operator is about to open. pub(in crate::ui::app) fn open_new_session(&mut self, agent_id: &str) { let Some(declaration) = self.declaration_for(agent_id) else { - self.set_status(format!( - "No agent \"{agent_id}\" is declared on this device" - )); + self.refuse_absent_declaration(agent_id); return; }; let Some(workspace) = declaration.workspace.path() else { @@ -204,9 +202,7 @@ impl App { return; }; let Some(declaration) = self.declaration_for(agent_id) else { - self.set_status(format!( - "No agent \"{agent_id}\" is declared on this device" - )); + self.refuse_absent_declaration(agent_id); return; }; let Some(choice) = harness_choice(&harnesses.choices(), &declaration.harness) else { @@ -241,9 +237,37 @@ impl App { } } - /// The declaration for `agent_id` on this host, if there is one. + /// The declaration for `agent_id` **on this host**, if there is one. + /// + /// Scoped to the local host on purpose: a session is a process started on + /// this machine, so opening one from a declaration that names another + /// machine would file a running session under an agent that lives somewhere + /// else — and the rail, which resolves sessions against the *local* + /// declarations, would then list it as an orphan. The rail already limits + /// its `+ new session` row to local agents; `^T` reads whatever row the + /// cursor is on, so the filter has to live here too. fn declaration_for(&self, agent_id: &str) -> Option { - medulla::config::agent_declaration(self.agent_declarations(), agent_id).cloned() + self.local_agent_declarations() + .into_iter() + .find(|declaration| declaration.agent_id.trim() == agent_id.trim()) + } + + /// Say why there is no declaration here to open a session from. + /// + /// Two different answers, because they call for two different next steps: an + /// agent nobody declared anywhere is one to declare, and an agent declared + /// on another machine is one to start a session on *that* machine. Naming + /// the host is the whole of the difference, so the refusal carries it. + fn refuse_absent_declaration(&mut self, agent_id: &str) { + let elsewhere = medulla::config::agent_declaration(self.agent_declarations(), agent_id) + .map(|declaration| declaration.host_id.trim().to_string()) + .filter(|host_id| !host_id.is_empty()); + self.set_status(match elsewhere { + Some(host_id) => { + format!("{agent_id} is declared on {host_id} — open its sessions on that machine") + } + None => format!("No agent \"{agent_id}\" is declared on this device"), + }); } /// Put the rail cursor on `agent_id`'s row, if it has one. diff --git a/src/tui/src/ui/app/agent_control_tests.rs b/src/tui/src/ui/app/agent_control_tests.rs index aa5bb76b3..dc8d7db2e 100644 --- a/src/tui/src/ui/app/agent_control_tests.rs +++ b/src/tui/src/ui/app/agent_control_tests.rs @@ -238,3 +238,65 @@ fn opening_a_session_for_an_undeclared_agent_says_so() { assert!(app.status().contains("nobody"), "{}", app.status()); assert!(app.prompt_state().is_none()); } + +#[test] +fn a_remote_agent_cannot_be_given_a_local_session() { + // `^T` acts on whatever agent row the cursor is on, and a remote host's + // agents have rows. Starting a process here for one of them would file a + // local session under an agent that runs on another machine — where the + // rail, which resolves sessions against the *local* declarations, would + // then show it loose in the orphan list. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-codex", + "studio", + "codex", + "/work", + )]; + // The cursor can reach it: the agent has a row even though the action does + // not, which is exactly how the chord gets there. + let index = app + .rail_rows() + .iter() + .position(|row| matches!(row, RailRow::Agent(agent) if agent.agent_id == "studio-codex")) + .expect("a remote agent still has a rail row"); + app.agent_index = index; + assert_eq!(app.selected_agent_id().as_deref(), Some("studio-codex")); + + app.open_new_session("studio-codex"); + + assert!(app.prompt_state().is_none(), "no session flow opened"); + assert!( + app.status().contains("studio"), + "the refusal names the host that owns it: {}", + app.status() + ); +} + +#[test] +fn starting_a_session_for_a_remote_agent_refuses_before_it_spawns() { + // The same guard on the other door: `start_agent_session` is reached from + // the name prompt, so it must not trust that `open_new_session` vetted the + // agent for it. + // `hosting_app` already carries the local harnesses, so the refusal has to + // come from the declaration and not from having nothing to launch with. + let mut app = hosting_app(); + app.loaded.config.fleet.agent_declarations = vec![AgentDeclaration::new( + "studio-codex", + "studio", + "codex", + "/", + )]; + + app.start_agent_session("studio-codex", "anything", false); + + assert!( + app.status().contains("studio"), + "the refusal names the host: {}", + app.status() + ); + assert!( + app.rail_rows().iter().all(|row| row.session_id().is_none()), + "nothing was started" + ); +} diff --git a/src/tui/src/ui/app/changes/baseline_tests.rs b/src/tui/src/ui/app/changes/baseline_tests.rs index ff6d16f29..374df1503 100644 --- a/src/tui/src/ui/app/changes/baseline_tests.rs +++ b/src/tui/src/ui/app/changes/baseline_tests.rs @@ -273,6 +273,7 @@ fn row( id: id.to_owned(), label: label.to_owned(), provider: medulla::protocol::HarnessProvider::Codex, + preset: None, state: PtyState::Running, cwd: cwd.to_string_lossy().into_owned(), branch: None, diff --git a/src/tui/src/ui/app/commands/dispatch.rs b/src/tui/src/ui/app/commands/dispatch.rs index 9b389b453..1fac47f4d 100644 --- a/src/tui/src/ui/app/commands/dispatch.rs +++ b/src/tui/src/ui/app/commands/dispatch.rs @@ -177,11 +177,15 @@ impl App { PromptKind::HostEditLabel(id) => { let mut patch = serde_json::Map::new(); patch.insert("label".into(), serde_json::Value::String(text.clone())); + // Set first so the persist can overrule it: renaming has two + // outcomes worth more than "Updating label…" — a write that + // failed, and an install with no config file, where the new name + // lasts one run and the operator has to be told so. + self.set_status("Updating label…"); // The roster label is this run's; the declaration's name is the // one that comes back after a restart, so an agent this machine // declared is renamed in both places or the edit half-survives. self.persist_agent_name(&id, &text); - self.set_status("Updating label…"); Some(Cmd::WorkerOp(WorkerOp::Update { id, patch })) } PromptKind::AnswerQuestion { diff --git a/src/tui/src/ui/app/hosts/edit.rs b/src/tui/src/ui/app/hosts/edit.rs index 9b0035201..7d505234c 100644 --- a/src/tui/src/ui/app/hosts/edit.rs +++ b/src/tui/src/ui/app/hosts/edit.rs @@ -10,6 +10,20 @@ //! fails, the live change is not made either, because a UI showing a role the //! file does not have is worse than one that refused. //! +//! **The no-config-file path.** An install with nowhere to write — no +//! `--config`, no discovered file — still has a live roster in front of the +//! operator, so an edit is not refused there. It applies to *this run*, in both +//! places at once (the in-memory declaration list and the roster), and the +//! status says how long it lasts. That is one rule for all three edits here, and +//! it is the same one `save_workspaces` follows for `[host].workspaces`. +//! +//! It is deliberately not the same thing as a write *failure*. A failed write +//! means the file exists and disagrees, so nothing is applied at all — see +//! [`persist_agent_roles`](App::persist_agent_roles). Having no file to +//! disagree with is not a failure, and refusing there would leave the operator +//! unable to touch a roster they can see. What is never allowed is the third +//! option: applying the edit and saying nothing, which reads as "saved". +//! //! [`HubHandle::set_roles`]: medulla::hub::HubHandle::set_roles use medulla::runtime::{AgentDeclaration, WorkerOp}; @@ -92,18 +106,33 @@ impl App { )); return false; }; - AgentDeclaration::new( - agent.agent_id.clone(), - host.id.clone(), - harness, - agent.workspace.clone().unwrap_or_default(), - ) + let Some(workspace) = agent.workspace.clone().filter(|w| !w.trim().is_empty()) + else { + // The same reason as the harness, one step further on: an + // agent is `harness × workspace`, and a declaration with no + // directory is one `open_new_session` then refuses with + // "declares no workspace". Saving the role would leave the + // operator looking at an agent they cannot use. + self.set_status(format!( + "{} reports no workspace, so its roles cannot be saved", + agent.agent_id + )); + return false; + }; + AgentDeclaration::new(agent.agent_id.clone(), host.id.clone(), harness, workspace) } }; declaration.roles = roles; let Some(path) = self.config_path.clone() else { - // Nowhere to write: the change still applies for this run, and the - // status says exactly how long it lasts. + // Nowhere to write, so the edit is this run's. It has to land on the + // declaration list as well as the roster: the Hosts tree reads a + // declared agent's roles from the declaration, so updating only the + // roster would redraw the row with the roles it had before while + // `SetRoles` carried the new ones. + medulla::config::upsert_agent_declaration( + &mut self.loaded.config.fleet.agent_declarations, + declaration, + ); self.set_status(format!("{outcome} (this run only — no config file)")); return true; }; @@ -134,11 +163,18 @@ impl App { return false; } let Some(path) = self.config_path.clone() else { + // Same rule as a role edit: nowhere to write is not a refusal, it is + // an edit that lasts one run — and the status is what keeps the + // agent's return at the next launch from being a surprise. + medulla::config::remove_agent_declaration( + &mut self.loaded.config.fleet.agent_declarations, + &agent.agent_id, + ); self.set_status(format!( - "{} stays declared — there is no config file to remove it from", + "Undeclared {} (this run only — no config file)", agent.agent_id )); - return false; + return true; }; let current = self.loaded.config.fleet.agent_declarations.clone(); match medulla::config::undeclare_agent(&path, ¤t, &agent.agent_id) { @@ -171,6 +207,17 @@ impl App { let mut declaration = declaration.clone(); declaration.name = (!name.trim().is_empty()).then(|| name.trim().to_string()); let Some(path) = self.config_path.clone() else { + // The roster label has already changed by the time this runs, so a + // silent return is the one outcome the module forbids: the operator + // would read the new name off the row and have nothing telling them + // it goes away at the next launch. + medulla::config::upsert_agent_declaration( + &mut self.loaded.config.fleet.agent_declarations, + declaration, + ); + self.set_status(format!( + "Renamed {agent_id} (this run only — no config file)" + )); return; }; match medulla::config::declare_agent(&path, ¤t, declaration) { diff --git a/src/tui/src/ui/app/hosts/tests.rs b/src/tui/src/ui/app/hosts/tests.rs index 762fccdba..919f37992 100644 --- a/src/tui/src/ui/app/hosts/tests.rs +++ b/src/tui/src/ui/app/hosts/tests.rs @@ -181,6 +181,53 @@ fn giving_a_seeded_agent_a_role_declares_it() { assert_eq!(app.loaded.config.fleet.agent_declarations.len(), 1); } +#[test] +fn the_agent_preview_never_draws_past_the_rows_it_was_given() { + // The role list is windowed to whatever the fixed agent details left over. + // A zero budget used to still force one checkbox through, so the block ran + // one row past the bottom of the pane on a short terminal — and the row that + // fell off was the one carrying the role cursor. + let (mut app, _dir) = app_with( + vec![worker("medulla-claude", "this-device")], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + cursor_to(&mut app, 1); + let (tree, rows, selected) = app.hosts_view(); + let row = rows[selected]; + assert!(row.agent.is_some(), "the cursor is on the agent row"); + + // The fixed identity block is the agent, so it is always drawn; what the + // budget governs is the role list hung under it. + let details = app.preview_height_within(&tree, row, 0); + assert!(details > 1, "the identity block is several rows: {details}"); + for budget in 0..details { + assert_eq!( + app.preview_height_within(&tree, row, budget), + details, + "a {budget}-row pane has no room for roles at all" + ); + } + // One row to spare buys the summary — the sentence saying what the agent is + // offered for — rather than one checkbox out of a dozen, which reads as the + // whole list. + assert_eq!( + app.preview_height_within(&tree, row, details + 1), + details + 1 + ); + for budget in details..=details + 12 { + assert!( + app.preview_height_within(&tree, row, budget) <= budget, + "a {budget}-row pane drew {} rows", + app.preview_height_within(&tree, row, budget) + ); + } +} + #[test] fn a_remote_agent_is_read_only() { let (mut app, dir) = app_with(vec![worker("peer", "7Kx")], Vec::new()); @@ -245,6 +292,144 @@ fn a_failed_write_changes_nothing_at_all() { ); } +/// The same app with no config file at all, which is the "this run only" path. +fn app_without_config(workers: Vec, declarations: Vec) -> App { + let runtime = MockRuntime::empty(); + runtime.set_workers(workers); + let mut loaded = LoadedConfig::defaults("medulla.tui.json".into()); + loaded.config.fleet.agent_declarations = declarations; + App::new(Arc::new(runtime), loaded) +} + +#[test] +fn with_no_config_file_an_edit_lasts_the_run_and_says_so() { + // Nowhere to write is not a refusal — the roster is in front of the + // operator and has to stay editable — but it must not read as saved + // either. So the edit lands in *both* places for this run: the roster op + // the caller sends, and the declaration list the tree redraws from. Only + // updating the roster left the row showing the roles it had before. + let mut app = app_without_config( + vec![worker("medulla-claude", "this-device")], + vec![AgentDeclaration::new( + "medulla-claude", + "this-device", + "claude", + "/w/medulla", + )], + ); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_some()); + assert!( + app.status().contains("this run only"), + "the operator is told how long it lasts: {}", + app.status() + ); + assert_eq!( + app.loaded.config.fleet.agent_declarations[0].roles, + vec![role.clone()], + "the declaration the tree reads from moved too" + ); + assert_eq!( + app.selected_host_agent().map(|agent| agent.roles), + Some(vec![role.clone()]), + "so the row redraws assigned rather than reverting" + ); + + // And the toggle is not one-way: the second press sees the role it just + // wrote and takes it back off. + assert!(app.toggle_selected_agent_role(&role).is_some()); + assert!(app.loaded.config.fleet.agent_declarations[0] + .roles + .is_empty()); +} + +#[test] +fn with_no_config_file_a_rename_lasts_the_run_and_says_so() { + // The roster label has already changed by the time the persist runs, so a + // silent return would leave the operator reading a name that disappears at + // the next launch with nothing having said so. + let mut app = app_without_config( + Vec::new(), + vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )], + ); + + app.persist_agent_name("api-codex", "Backend"); + + assert!( + app.status().contains("this run only"), + "status: {}", + app.status() + ); + assert_eq!( + app.loaded.config.fleet.agent_declarations[0] + .name + .as_deref(), + Some("Backend"), + "the name is the run's, in the same place the saved one would be" + ); +} + +#[test] +fn with_no_config_file_undeclaring_lasts_the_run_and_says_so() { + // One rule for the whole path: removing is an edit like the others, so it + // applies for this run rather than being refused — and the status is what + // stops the agent's return at the next launch being a surprise. + let mut app = app_without_config( + Vec::new(), + vec![AgentDeclaration::new( + "api-codex", + "this-device", + "codex", + "/w/api", + )], + ); + cursor_to(&mut app, 1); + + assert!(app.undeclare_selected_agent()); + assert!(app.loaded.config.fleet.agent_declarations.is_empty()); + assert!( + app.status().contains("this run only"), + "status: {}", + app.status() + ); +} + +#[test] +fn an_agent_with_no_workspace_cannot_be_declared_by_a_role_toggle() { + // An agent is `harness × workspace`. Seeding one with no directory writes a + // declaration no session can be opened from — `open_new_session` refuses it + // with "declares no workspace" — so the role toggle says so up front rather + // than saving a row the operator then cannot use. + let mut bare = worker("mystery", "this-device"); + bare.workspace = None; + let (mut app, dir) = app_with(vec![bare], Vec::new()); + cursor_to(&mut app, 1); + + let role = app + .agent_templates() + .first() + .expect("built-in roles") + .id + .clone(); + assert!(app.toggle_selected_agent_role(&role).is_none()); + assert!(app.status().contains("no workspace"), "{}", app.status()); + assert!( + medulla::config::load_agent_declarations(&dir.path().join("medulla.tui.json")).is_empty() + ); +} + #[test] fn an_agent_with_no_harness_cannot_be_declared_by_a_role_toggle() { // Declaring an agent with no harness would advertise a placement that cannot diff --git a/src/tui/src/ui/app/input/mouse.rs b/src/tui/src/ui/app/input/mouse.rs index b5caa9866..cd362aeb2 100644 --- a/src/tui/src/ui/app/input/mouse.rs +++ b/src/tui/src/ui/app/input/mouse.rs @@ -364,15 +364,22 @@ impl App { // requiring a second keystroke to confirm what was // already aimed at is the friction it exists to // remove. + // + // Both action branches return the retarget rather + // than nothing, for the same reason the overflow and + // harness branches below do: an action row watches + // no task, so a click arriving from one that did has + // to stop that stream — and neither open method + // clears `watching` on its own. if row.is_new_agent() { self.open_new_agent_picker(); - return None; + return self.retarget_watch(); } // Same rule for the per-agent action: a click on // `+ new session` opens the flow it names. if let Some(agent_id) = row.new_session_agent().map(str::to_string) { self.open_new_session(&agent_id); - return None; + return self.retarget_watch(); } // So is a lane's `+N more`: the click that lands on // it is the request to see what it is counting. diff --git a/src/tui/src/ui/app/input/tests.rs b/src/tui/src/ui/app/input/tests.rs index 846300746..27b9cbe8a 100644 --- a/src/tui/src/ui/app/input/tests.rs +++ b/src/tui/src/ui/app/input/tests.rs @@ -186,6 +186,55 @@ fn clicking_the_overflow_row_stops_a_task_stream_it_left_behind() { assert_eq!(app.watching, None); } +/// Click the first row `want` accepts, resolved through the rendered hit map. +fn click_row(app: &mut App, want: impl Fn(&RailRow) -> bool) -> Option { + draw(app); + let index = app + .rail_rows() + .iter() + .position(want) + .expect("the rail has the row under test"); + let (rect, owners) = app.hit_agents.clone().expect("the rail was drawn"); + let line = owners + .iter() + .position(|owner| *owner == index) + .expect("the row is on screen"); + app.handle_click(rect.x, rect.y + line as u16) +} + +#[test] +fn clicking_an_action_row_stops_a_task_stream_it_left_behind() { + // `+ New agent` and `+ new session` are controls, not conversations: neither + // watches a task, and neither open method clears `watching`. A click that + // arrives from a task row therefore has to release that stream on its way, + // or a worker keeps sampling and sending a screen nobody is looking at. + for want in [ + &(|row: &RailRow| row.is_new_agent()) as &dyn Fn(&RailRow) -> bool, + &|row: &RailRow| row.new_session_agent().is_some(), + ] { + let mut app = app_with_tasks("dev", 3); + app.set_local_harnesses(super::super::rail::tests::shell_harnesses( + crate::worker::pty::PtyManager::new(), + )); + app.loaded.config.fleet.agent_declarations = vec![medulla::runtime::AgentDeclaration::new( + "dev", + "", + "codex", + "/work/dev", + )]; + let watched = ("worker-1".to_string(), "dev-t1".to_string()); + app.watching = Some(watched.clone()); + + let cmd = click_row(&mut app, want); + + assert!( + matches!(&cmd, Some(Cmd::WatchTask { stop, start: None }) if stop.as_ref() == Some(&watched)), + "expected the click to stop the previous watch, got {cmd:?}" + ); + assert_eq!(app.watching, None); + } +} + #[test] fn an_expansion_follows_its_lane_when_the_rows_move() { let mut app = app_with_tasks("dev", 25); diff --git a/src/tui/src/ui/app/keys/routing/mod.rs b/src/tui/src/ui/app/keys/routing/mod.rs index 38bf9f456..c6fa59fe2 100644 --- a/src/tui/src/ui/app/keys/routing/mod.rs +++ b/src/tui/src/ui/app/keys/routing/mod.rs @@ -218,6 +218,11 @@ impl App { // afterwards would answer for whichever row slid into its place. let agent = self.selected_host_agent(); let worker = self.selected_host(); + // The removal key is reachable while the role toggles hold the arrows — + // `host_roles_key` passes `d`/`x` through. Leaving the focus on would + // point the next arrow at the roles of whichever row slid up into the + // cursor, which is not the agent whose toggles were open. + self.host_roles_focus = false; let undeclared = self.undeclare_selected_agent(); match (agent, worker) { // Declared, not running: the declaration was the whole of it. diff --git a/src/tui/src/ui/app/mod.rs b/src/tui/src/ui/app/mod.rs index f9e8b674c..00dc9c71f 100644 --- a/src/tui/src/ui/app/mod.rs +++ b/src/tui/src/ui/app/mod.rs @@ -43,6 +43,8 @@ mod session_focus; mod session_focus_tests; mod settings_edit; mod state; +#[cfg(test)] +mod state_tests; mod status_line; mod templates; mod types; diff --git a/src/tui/src/ui/app/rail/resolve.rs b/src/tui/src/ui/app/rail/resolve.rs index a227d5975..46fc816a6 100644 --- a/src/tui/src/ui/app/rail/resolve.rs +++ b/src/tui/src/ui/app/rail/resolve.rs @@ -30,6 +30,13 @@ use crate::worker::pty::SessionRow; /// the spawner disagree about the trailing slash far more often than they /// disagree about the directory. /// +/// The harness compared is the session's *id* +/// ([`harness_id`](SessionRow::harness_id)) — a custom preset's own id, else the +/// CLI's wire name — because that is the vocabulary a declaration is written in. +/// A preset is a different agent running the same CLI, so comparing the CLI +/// underneath it matched a `deepseek` declaration against `claude` and left +/// every preset-backed session in the orphan list. +/// /// The first match wins. Two declarations of the same harness in the same /// directory are the same agent declared twice, so which one claims the session /// changes nothing an operator can see. @@ -38,7 +45,7 @@ pub fn agent_for_session<'a>( row: &SessionRow, ) -> Option<&'a AgentDeclaration> { let cwd = normalize_path(&row.cwd); - let harness = row.provider.as_str(); + let harness = row.harness_id(); declarations.iter().find(|declaration| { declaration.harness.trim().eq_ignore_ascii_case(harness) && normalize_path(&declaration.workspace.path) == cwd @@ -73,6 +80,7 @@ mod tests { id: "w_1".into(), label: "local".into(), provider, + preset: None, state: PtyState::Running, cwd: cwd.into(), branch: None, @@ -136,6 +144,50 @@ mod tests { ); } + #[test] + fn a_preset_backed_session_belongs_to_the_agent_declared_for_that_preset() { + // A custom preset is its own agent — its own model, endpoint and + // environment — so a declaration records the *preset's* id. Matching on + // the CLI underneath it compared `claude` against `deepseek`, and every + // session an operator started from a preset was listed as belonging to + // no agent at all. + let declarations = vec![AgentDeclaration::new( + "api-deepseek", + "host", + "deepseek", + "/work/api", + )]; + let mut row = session(HarnessProvider::Claude, "/work/api"); + row.preset = Some("deepseek".into()); + + let matched = agent_for_session(&declarations, &row).expect("its own agent claims it"); + assert_eq!(matched.agent_id, "api-deepseek"); + assert_eq!(row.harness_id(), "deepseek"); + } + + #[test] + fn a_preset_is_not_the_base_cli_declared_in_the_same_directory() { + // The other direction of the same rule: an agent declared as plain + // `claude` is not the one a `deepseek` preset session belongs to, even + // though both run the claude binary in that folder. + let declarations = vec![AgentDeclaration::new( + "api-claude", + "host", + "claude", + "/work/api", + )]; + let mut row = session(HarnessProvider::Claude, "/work/api"); + row.preset = Some("deepseek".into()); + assert!(agent_for_session(&declarations, &row).is_none()); + + // And a native session still resolves by the provider's wire name: a + // blank preset is no preset. + let mut native = session(HarnessProvider::Claude, "/work/api"); + native.preset = Some(" ".into()); + assert_eq!(native.harness_id(), "claude"); + assert!(agent_for_session(&declarations, &native).is_some()); + } + #[test] fn an_undeclared_directory_resolves_to_no_agent() { let declarations = vec![AgentDeclaration::new( diff --git a/src/tui/src/ui/app/render/agents/rail/tests.rs b/src/tui/src/ui/app/render/agents/rail/tests.rs index 63648348d..3c1a9ed5c 100644 --- a/src/tui/src/ui/app/render/agents/rail/tests.rs +++ b/src/tui/src/ui/app/render/agents/rail/tests.rs @@ -155,6 +155,7 @@ pub(super) fn harness_row(cwd: &str) -> SessionRow { id: "w_1".into(), label: "local".into(), provider: medulla::protocol::HarnessProvider::Codex, + preset: None, state: PtyState::Running, cwd: cwd.into(), branch: Some("main".into()), diff --git a/src/tui/src/ui/app/render/routing/hosts/preview.rs b/src/tui/src/ui/app/render/routing/hosts/preview.rs index 1c943d09b..d2e7766e3 100644 --- a/src/tui/src/ui/app/render/routing/hosts/preview.rs +++ b/src/tui/src/ui/app/render/routing/hosts/preview.rs @@ -49,6 +49,18 @@ impl App { self.preview_lines_within(tree, row, None) } + /// How many rows the preview would draw in `budget` of them. Test seam for + /// the one property the budget exists to hold: it is never exceeded. + #[cfg(test)] + pub(in crate::ui::app) fn preview_height_within( + &self, + tree: &[HostRow], + row: HostsRow, + budget: usize, + ) -> usize { + self.preview_lines_within(tree, row, Some(budget)).len() + } + /// Build the preview body, windowing the role list when `budget` rows is /// less than it needs. Shared with the height calculation so the pane is /// sized to what it will actually draw rather than a guess. @@ -205,7 +217,18 @@ impl App { /// `budget` caps the rows; the window follows the cursor so a role can never /// be selected but off-screen. A remote agent gets the summary and no /// checkboxes: its roles are assigned on the machine that declares it. + /// + /// The cap is a hard one — the result never exceeds `budget`. A zero budget + /// returns nothing at all and a budget of one returns only the summary, + /// because the alternative is drawing past the bottom of the pane, which on + /// a short terminal clipped the role cursor: the row the operator was about + /// to toggle was the one that fell off. fn role_lines(&self, agent: &HostAgentRow, budget: Option) -> Vec> { + if budget == Some(0) { + return Vec::new(); + } + // What is left once the summary below has taken its row. + let remaining = budget.map(|rows| rows.saturating_sub(1)); // The summary leads, because "none assigned" is the state most agents // are in and it must not read as one excluded from every role. Trailing // it under a dozen checkboxes buried exactly the line that says @@ -232,28 +255,34 @@ impl App { ), ])]; if !agent.editable { - lines.push(TLine::from(vec![ - Span::styled(" ", dim()), - Span::styled( - "read-only · assign roles on that machine".to_string(), - dim(), - ), - ])); + if remaining != Some(0) { + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled( + "read-only · assign roles on that machine".to_string(), + dim(), + ), + ])); + } return lines; } let templates = self.agent_templates(); if templates.is_empty() { - lines.push(TLine::from(vec![ - Span::styled(" ", dim()), - Span::styled("no agent templates are declared".to_string(), dim()), - ])); + if remaining != Some(0) { + lines.push(TLine::from(vec![ + Span::styled(" ", dim()), + Span::styled("no agent templates are declared".to_string(), dim()), + ])); + } + return lines; + } + let visible = remaining.unwrap_or(templates.len()).min(templates.len()); + if visible == 0 { + // One row left, and the summary has it. Better the sentence that + // says what the agent is offered for than one checkbox out of a + // dozen, which reads as the whole list. return lines; } - // One row is already spent on the summary. - let visible = budget - .map(|rows| rows.saturating_sub(1).max(1)) - .unwrap_or(templates.len()) - .min(templates.len()); let start = crate::ui::selection::viewport_start(self.host_role_index, templates.len(), visible); for (index, template) in templates.iter().enumerate().skip(start).take(visible) { diff --git a/src/tui/src/ui/app/render/settings/status_line.rs b/src/tui/src/ui/app/render/settings/status_line.rs index 42f82dae3..ef937c6c2 100644 --- a/src/tui/src/ui/app/render/settings/status_line.rs +++ b/src/tui/src/ui/app/render/settings/status_line.rs @@ -156,6 +156,7 @@ fn sample_selected() -> SessionRow { id: "preview".into(), label: "preview".into(), provider: HarnessProvider::Claude, + preset: None, state: PtyState::Running, cwd: "/home/you/work/tinyhumans/medulla-public".into(), branch: Some("feat/status-line".into()), @@ -180,6 +181,7 @@ fn sample_selected() -> SessionRow { fn sample_orchestrator() -> SessionRow { SessionRow { provider: HarnessProvider::Codex, + preset: None, state: PtyState::Exited { code: Some(0) }, cwd: "/tmp/scratch".into(), branch: None, diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index b23161fd3..d982cc66d 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -559,14 +559,19 @@ impl App { let lanes = self.lanes(); let rows = self.rail_rows(); match rows.get(self.agent_index.min(rows.len().saturating_sub(1))) { - Some(super::rail::RailRow::Lane(row)) => row - .lane_index() - .and_then(|index| lanes.get(index)) + // Only a lane's *own* row is a conversation. `AgentRow` also wraps + // the `+N more` overflow control, which carries the lane index of + // the lane it pages — matching it here would have read that index + // out of a row that is a button, and an overflow row on a rail with + // no folded lanes yet would fall through to the `true` below and + // hand the orchestrator's composer to it. + Some(super::rail::RailRow::Lane(AgentRow::Lane { lane_index })) => lanes + .get(*lane_index) .map(|lane| lane.role == AgentRole::Orchestrator) // An empty lane list means the orchestrator lane is all there is. .unwrap_or(true), - // Hosts, agents, sessions and the action row are not lanes and have - // no conversation of their own. + // Hosts, agents, sessions, the overflow control and the action row + // are not lanes and have no conversation of their own. Some(_) => false, None => true, } diff --git a/src/tui/src/ui/app/state_tests.rs b/src/tui/src/ui/app/state_tests.rs new file mode 100644 index 000000000..799cc731a --- /dev/null +++ b/src/tui/src/ui/app/state_tests.rs @@ -0,0 +1,78 @@ +//! Tests for the derived state the Agents surface reads — currently which rail +//! row counts as the operator's own conversation. + +use std::sync::Arc; + +use medulla::config::LoadedConfig; +use medulla::runtime::mock::MockRuntime; +use medulla::runtime::Runtime; +use medulla::ui::agents::AgentRow; +use medulla::ui::events::{EventEnvelope, TuiEvent}; + +use super::rail::RailRow; +use super::types::App; + +/// An app on the demo runtime with a busy agent, so the rail carries every row +/// kind at once: the orchestrator's lane, an agent, its sessions, the `+N more` +/// overflow control and the `+ new session` action. +fn app_with_a_busy_agent() -> App { + let runtime: Arc = Arc::new(MockRuntime::demo()); + let mut app = App::new(runtime, LoadedConfig::defaults("medulla.tui.json".into())); + let base = app.snapshot.events.len() as u64; + for i in 0..25 { + let seq = base + i; + app.snapshot.events.push(EventEnvelope { + seq, + at: seq as i64 * 1000, + event: TuiEvent::TaskStart { + task_id: format!("dev-t{i}"), + instruction: "x".into(), + depth: 2, + agent_id: Some("dev".into()), + contract: None, + }, + }); + } + app +} + +#[test] +fn only_a_lanes_own_row_is_the_orchestrators_conversation() { + // The composer's visibility hangs off this answer, so every row that is not + // a lane must say no. The dangerous shape is a row that *wraps* a lane + // without being one: `RailRow::Lane` also carries `AgentRow::More`, the + // overflow control, and the `── functions ──` divider, which names no lane + // at all and so used to fall through to the "no lanes yet ⇒ the + // orchestrator is all there is" fallback. + let mut app = app_with_a_busy_agent(); + let rows = app.rail_rows(); + assert!( + rows.iter() + .any(|row| matches!(row, RailRow::Lane(AgentRow::More { .. }))), + "25 tasks overflow one page: {rows:?}" + ); + + for (index, row) in rows.iter().enumerate() { + if matches!(row, RailRow::Lane(AgentRow::Lane { .. })) { + continue; + } + app.agent_index = index; + assert!( + !app.on_orchestrator_lane(), + "row {index} ({row:?}) is not a lane and must not read as the orchestrator" + ); + } +} + +#[test] +fn the_orchestrators_own_lane_row_still_is() { + // The other half of the same match: narrowing it must not cost the row it + // exists to recognise. + let mut app = app_with_a_busy_agent(); + let index = app + .orchestrator_row_index() + .expect("the demo runtime folds an orchestrator lane"); + + app.agent_index = index; + assert!(app.on_orchestrator_lane()); +} diff --git a/src/tui/src/ui/harness_pane/spawn.rs b/src/tui/src/ui/harness_pane/spawn.rs index 8605dd0b5..beae80c72 100644 --- a/src/tui/src/ui/harness_pane/spawn.rs +++ b/src/tui/src/ui/harness_pane/spawn.rs @@ -86,6 +86,11 @@ impl LocalHarnesses { self.sessions.open(LaunchSpec { provider, + // The preset is the agent, not the CLI under it: a declaration for a + // preset records the preset's id, so a session that recorded only + // `claude` could never be matched back to the agent that declared + // it and was listed as belonging to none. + preset: choice.preset.as_ref().map(|preset| preset.id.clone()), bin, cwd, env, diff --git a/src/tui/src/ui/harness_pane/tests/session.rs b/src/tui/src/ui/harness_pane/tests/session.rs index c218787c1..f73001f27 100644 --- a/src/tui/src/ui/harness_pane/tests/session.rs +++ b/src/tui/src/ui/harness_pane/tests/session.rs @@ -32,6 +32,7 @@ fn sh(script: &str) -> LaunchSpec { env.insert("TERM".to_string(), "xterm-256color".to_string()); LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/src/worker/app/tests/helpers/mod.rs b/src/tui/src/worker/app/tests/helpers/mod.rs index a31c3831a..364b10917 100644 --- a/src/tui/src/worker/app/tests/helpers/mod.rs +++ b/src/tui/src/worker/app/tests/helpers/mod.rs @@ -49,6 +49,7 @@ pub(super) fn sh(script: &str, label: &str) -> LaunchSpec { // Codex takes no preset session id, so its interactive argv is empty // and `/bin/sh` receives only the script. provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/src/worker/executor/run.rs b/src/tui/src/worker/executor/run.rs index 3d508976b..e96083097 100644 --- a/src/tui/src/worker/executor/run.rs +++ b/src/tui/src/worker/executor/run.rs @@ -149,7 +149,7 @@ impl PtySessionExecutor { // borrow back; only the owned [`LaunchSpec`] crosses the await. let opened = match self.session_for(&options, class)? { SessionPlan::Reuse(opened) => opened, - SessionPlan::Launch(spec) => self.launch(spec).await?, + SessionPlan::Launch(spec) => self.launch(*spec).await?, }; if let Some(pinned) = &opened.harness_session_id { // A reused session's transcript already exists, so the fresh-session @@ -389,8 +389,9 @@ impl PtySessionExecutor { &mut extra_args, self.log.as_ref(), ); - Ok(SessionPlan::Launch(LaunchSpec { + Ok(SessionPlan::Launch(Box::new(LaunchSpec { provider: options.provider, + preset: None, bin, cwd: options.cwd.clone(), env, @@ -410,7 +411,7 @@ impl PtySessionExecutor { origin: SessionOrigin::Orchestrator, name: None, mcp_grant_session, - })) + }))) } /// Start a fresh harness on the blocking pool. diff --git a/src/tui/src/worker/executor/types.rs b/src/tui/src/worker/executor/types.rs index 107302510..508f02e37 100644 --- a/src/tui/src/worker/executor/types.rs +++ b/src/tui/src/worker/executor/types.rs @@ -34,7 +34,11 @@ pub(super) enum SessionPlan { /// An idle session for this conversation, already claimed. Reuse(OpenedSession), /// Nothing reusable: start a harness with this spec. - Launch(LaunchSpec), + /// + /// Boxed because a `LaunchSpec` is much the larger of the two — it carries + /// the child's whole environment — and every `Reuse` would otherwise pay + /// for a launch it is not doing. + Launch(Box), } /// Runs delegated tasks inside live harness sessions. diff --git a/src/tui/src/worker/executor_tests/live.rs b/src/tui/src/worker/executor_tests/live.rs index f660402c7..1527f482b 100644 --- a/src/tui/src/worker/executor_tests/live.rs +++ b/src/tui/src/worker/executor_tests/live.rs @@ -213,6 +213,7 @@ async fn experiment_codex_dialog_dismissal() { let id = sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin, cwd, env, @@ -297,6 +298,7 @@ async fn experiment_codex_startup_dialog_dismissal() { let id = sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin, cwd, env, @@ -372,6 +374,7 @@ async fn diagnose_codex_paste_rendering() { let id = sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin, cwd, env, diff --git a/src/tui/src/worker/executor_tests/sessions.rs b/src/tui/src/worker/executor_tests/sessions.rs index cfe1c0e82..c9e539dc5 100644 --- a/src/tui/src/worker/executor_tests/sessions.rs +++ b/src/tui/src/worker/executor_tests/sessions.rs @@ -374,6 +374,7 @@ async fn a_dispatch_into_a_workspace_the_operator_holds_is_refused() { let held = sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: cwd.clone(), env: HashMap::new(), @@ -436,6 +437,7 @@ async fn a_dispatch_runs_again_once_the_harness_is_handed_back() { let held = sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: cwd.clone(), env: env.clone(), diff --git a/src/tui/src/worker/pty/handle/state.rs b/src/tui/src/worker/pty/handle/state.rs index f70eda5ad..0332270e3 100644 --- a/src/tui/src/worker/pty/handle/state.rs +++ b/src/tui/src/worker/pty/handle/state.rs @@ -129,6 +129,7 @@ impl SessionHandle { id: self.meta.id.clone(), label: cold.label.clone(), provider: self.meta.provider, + preset: self.meta.preset.clone(), state: self.state(), cwd: self.meta.cwd.clone(), branch: self.meta.branch.clone(), diff --git a/src/tui/src/worker/pty/handle/types.rs b/src/tui/src/worker/pty/handle/types.rs index f0eafd643..6de91919f 100644 --- a/src/tui/src/worker/pty/handle/types.rs +++ b/src/tui/src/worker/pty/handle/types.rs @@ -140,6 +140,13 @@ pub(crate) struct SessionMeta { pub(crate) id: String, /// Which harness is running. pub(crate) provider: HarnessProvider, + /// The custom preset it was launched from, when it was one — see + /// [`LaunchSpec::preset`](super::super::types::LaunchSpec::preset). + /// + /// Immutable like the rest of the meta: which preset ran is a fact about + /// this session's birth, and it is half of the harness id the rail matches + /// a session to its agent by. + pub(crate) preset: Option, /// The working directory the child runs in. pub(crate) cwd: String, /// Git branch resolved from the working directory when the session opened. diff --git a/src/tui/src/worker/pty/manager/open.rs b/src/tui/src/worker/pty/manager/open.rs index 77268ae73..f3eae8590 100644 --- a/src/tui/src/worker/pty/manager/open.rs +++ b/src/tui/src/worker/pty/manager/open.rs @@ -192,6 +192,7 @@ impl PtyManager { SessionMeta { id: id.clone(), provider: spec.provider, + preset: spec.preset, cwd: spec.cwd, branch, gh_repo_is_set: spec.env.contains_key("GH_REPO"), diff --git a/src/tui/src/worker/pty/tests/mod.rs b/src/tui/src/worker/pty/tests/mod.rs index 39df08a1f..65c026338 100644 --- a/src/tui/src/worker/pty/tests/mod.rs +++ b/src/tui/src/worker/pty/tests/mod.rs @@ -49,6 +49,7 @@ fn sh(script: &str) -> LaunchSpec { // `/bin/sh` would reject as an unknown option. Codex takes no preset id, // so its interactive argv is empty and the script is the whole command. provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/src/worker/pty/types.rs b/src/tui/src/worker/pty/types.rs index a21333512..db4f7d8f9 100644 --- a/src/tui/src/worker/pty/types.rs +++ b/src/tui/src/worker/pty/types.rs @@ -115,6 +115,17 @@ impl HarnessControl { pub struct LaunchSpec { /// Which coding-agent CLI to run. pub provider: HarnessProvider, + /// The custom preset this session was launched from, when it was one. + /// + /// A preset is a *different agent* running the same CLI — its own model, + /// endpoint and environment — so its id, not the base CLI's wire name, is + /// what a declaration records for it. Carrying it here is what lets the rail + /// file the session under the agent that declared it; without it a + /// preset-backed session was compared as `claude` against a declaration + /// saying `deepseek` and listed as belonging to no agent at all. + /// + /// `None` for a native CLI entry, whose id *is* the provider's wire name. + pub preset: Option, /// The resolved binary name or path. pub bin: String, /// Working directory for the child. @@ -181,6 +192,11 @@ pub struct SessionRow { pub label: String, /// Which harness is running. pub provider: HarnessProvider, + /// The custom preset it was launched from — see [`LaunchSpec::preset`]. + /// + /// Together with `provider` this gives the session's *harness id*, which is + /// the vocabulary a declaration is written in: [`harness_id`](Self::harness_id). + pub preset: Option, /// Where the child is in its life. pub state: PtyState, /// The working directory the child runs in. @@ -248,4 +264,17 @@ impl SessionRow { pub fn idle_ms(&self, now: i64) -> i64 { now.saturating_sub(self.last_output_at).max(0) } + + /// The stable harness id this session runs as — a preset's own id, else the + /// provider's wire name. + /// + /// The same id a picker choice reports and a declaration records, which is + /// what makes matching a session to its agent a comparison of like with + /// like rather than of a preset against the CLI underneath it. + pub fn harness_id(&self) -> &str { + self.preset + .as_deref() + .filter(|preset| !preset.trim().is_empty()) + .unwrap_or_else(|| self.provider.as_str()) + } } diff --git a/src/tui/tests/e2e_local_harness_pane.rs b/src/tui/tests/e2e_local_harness_pane.rs index 045a11de2..06142c9d8 100644 --- a/src/tui/tests/e2e_local_harness_pane.rs +++ b/src/tui/tests/e2e_local_harness_pane.rs @@ -47,6 +47,7 @@ fn sh(script: &str, label: &str) -> LaunchSpec { env.insert("TERM".to_string(), "xterm-256color".to_string()); LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/tests/e2e_screen_stream.rs b/src/tui/tests/e2e_screen_stream.rs index d2360d359..f8dee22fa 100644 --- a/src/tui/tests/e2e_screen_stream.rs +++ b/src/tui/tests/e2e_screen_stream.rs @@ -49,6 +49,7 @@ fn sh(script: &str, label: &str) -> LaunchSpec { env.insert("TERM".to_string(), "xterm-256color".to_string()); LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/tests/feature_harness_control.rs b/src/tui/tests/feature_harness_control.rs index b1b9d6208..8d3302405 100644 --- a/src/tui/tests/feature_harness_control.rs +++ b/src/tui/tests/feature_harness_control.rs @@ -155,6 +155,7 @@ fn user_session(sessions: &PtyManager) -> String { sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/tests/feature_harness_handoff.rs b/src/tui/tests/feature_harness_handoff.rs index d34d1fd2c..cae5fbac9 100644 --- a/src/tui/tests/feature_harness_handoff.rs +++ b/src/tui/tests/feature_harness_handoff.rs @@ -129,6 +129,7 @@ fn user_session_in(sessions: &PtyManager, cwd: &str) -> String { let id = sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: cwd.to_string(), env, diff --git a/src/tui/tests/feature_paste/attached.rs b/src/tui/tests/feature_paste/attached.rs index d7089cdd8..32d6987b7 100644 --- a/src/tui/tests/feature_paste/attached.rs +++ b/src/tui/tests/feature_paste/attached.rs @@ -34,6 +34,7 @@ fn shell_session(sessions: &PtyManager, script: &str) -> String { sessions .open(LaunchSpec { provider: HarnessProvider::Codex, + preset: None, bin: "/bin/sh".to_string(), cwd: "/".to_string(), env, diff --git a/src/tui/tests/feature_workers/helpers.rs b/src/tui/tests/feature_workers/helpers.rs index b275f9093..f338bfd95 100644 --- a/src/tui/tests/feature_workers/helpers.rs +++ b/src/tui/tests/feature_workers/helpers.rs @@ -117,6 +117,10 @@ pub fn worker(id: &str, selected: bool) -> WorkerInfo { pub fn local_worker(id: &str, selected: bool) -> WorkerInfo { WorkerInfo { address: "this-device".into(), + // A local roster entry is an agent working *somewhere*: the directory is + // half of what an agent is, and the seed path that declares one from the + // roster has nothing to write down without it. + workspace: Some(format!("/w/{id}")), handle: None, ..worker(id, selected) } diff --git a/src/tui/tests/feature_workers/roles.rs b/src/tui/tests/feature_workers/roles.rs index 4a203ec9a..1fcf6c4f8 100644 --- a/src/tui/tests/feature_workers/roles.rs +++ b/src/tui/tests/feature_workers/roles.rs @@ -41,21 +41,20 @@ fn space_on_a_role_offers_the_selected_agent_for_it_and_takes_it_back() { app.status() ); - // And back off again. The op is a whole-list replacement, so removing the - // only role must send an *empty* list — not omit the field, which would - // read as "leave the roles alone" and make the toggle one-way. - let mut held = app_with_roster( - vec![{ - let mut w = local_worker("w1", true); - w.roles = assigned; - w - }], - None, + // The row redraws assigned rather than reverting: the assignment landed on + // the declaration the tree reads roles from, not on the roster alone. + let out = render(&mut app, 120, 44); + assert!( + out.contains(&format!("[x] {}", assigned[0])), + "the checkbox the operator just ticked stays ticked: {out}" ); - held.focus_routing_subpage("Hosts"); - down(&mut held, 1); - let _ = held.on_event(key(KeyCode::Right)); - match held.on_event(key(KeyCode::Char(' '))) { + + // And back off again, on the *same* app — which is only a real round trip + // because the first press is still there to be undone. The op is a + // whole-list replacement, so removing the only role must send an *empty* + // list — not omit the field, which would read as "leave the roles alone" + // and make the toggle one-way. + match app.on_event(key(KeyCode::Char(' '))) { Some(Cmd::WorkerOp(WorkerOp::SetRoles { id, roles })) => { assert_eq!(id, "w1"); assert!( @@ -112,6 +111,30 @@ fn leaving_the_role_list_hands_the_arrows_back_to_the_tree() { assert!(out.contains("Agent · w2"), "back on the tree: {out}"); } +#[test] +fn removing_a_row_takes_the_arrows_back_off_the_role_list() { + // `d` is reachable while the toggles hold the arrows — `host_roles_key` + // passes it through — and it reshapes the tree under the cursor. Leaving the + // focus on would point the next arrow at the roles of whichever row slid + // into the cursor's place, which is not the agent whose toggles were open. + let mut app = app_with_roster( + vec![local_worker("w1", true), local_worker("w2", false)], + None, + ); + app.focus_routing_subpage("Hosts"); + down(&mut app, 1); + let _ = app.on_event(key(KeyCode::Right)); + let out = render(&mut app, 130, 44); + assert!(out.contains("[ ]"), "the toggles have the arrows: {out}"); + + let _ = app.on_event(key(KeyCode::Char('d'))); + + // Down now walks the tree again rather than the role list. + let _ = app.on_event(key(KeyCode::Down)); + let out = render(&mut app, 130, 44); + assert!(out.contains("Agent · w2"), "the cursor moved on: {out}"); +} + #[test] fn an_assigned_role_is_summarised_and_checked() { let mut w = local_worker("w1", true);