Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
5594d65
feat(tui): group and sort the Agents sidebar from Appearance
senamakel Aug 7, 2026
2627fa0
Merge upstream/main into sidebar-grouping
senamakel Aug 7, 2026
d848955
fix(rail): preserve grouping headers in cursor resolution
senamakel Aug 7, 2026
1e2adaa
fix(rail): sort tasks before paging
senamakel Aug 7, 2026
fe63231
fix(rail): restore declaration order when grouping by path or harness
senamakel Aug 7, 2026
bba2703
fix(rail): retain pinned and active task rows
senamakel Aug 7, 2026
af0ee14
perf(rail): avoid duplicate task clones
senamakel Aug 7, 2026
a878298
fix(rail): preserve retained task paging
senamakel Aug 7, 2026
6ba749c
refactor(rail): isolate task paging
senamakel Aug 7, 2026
40970fe
Merge remote-tracking branch 'refs/remotes/upstream/main' into pr/230
senamakel Aug 7, 2026
045c178
fix(rail): preserve lane activity in recent sorting
senamakel Aug 7, 2026
5ffa33f
test(rail): colocate paging tests
senamakel Aug 7, 2026
a7df2d6
Merge remote-tracking branch 'upstream/main' into pr/230
senamakel Aug 7, 2026
59b6955
fix(rail): group harnesses case-insensitively
senamakel Aug 7, 2026
d98286e
fix(rail): preserve peer grouping metadata
senamakel Aug 7, 2026
e206239
fix(rail): sort sessions by terminal title
senamakel Aug 7, 2026
41297e6
fix(rail): avoid redundant task row clones
senamakel Aug 7, 2026
23e77cf
Merge remote-tracking branch 'upstream/main' into pr/230
senamakel Aug 7, 2026
d7f6b3b
feat(rail): add grouping headers for sessions by workspace or harness
senamakel Aug 7, 2026
9e1962a
refactor(rail): extract rail organization into a dedicated module
senamakel Aug 7, 2026
cca1e7a
fix(rail): skip empty grouped sections when flattening rows
senamakel Aug 7, 2026
4f5440a
refactor(rail): derive group sessions from lane tasks
senamakel Aug 7, 2026
7e396c6
refactor(tui): move rail row rendering into rows module
senamakel Aug 7, 2026
b3fb84b
test(paging): add test for name sort before visible task selection
senamakel Aug 7, 2026
cfd468c
fix(rail): share pager visibility with session rail
senamakel Aug 7, 2026
9eaadb2
docs(appearance): clarify module purpose in types.rs
senamakel Aug 7, 2026
bb9d307
fix(tui): restore rail build
senamakel Aug 7, 2026
ba6c84d
fix(rail): relocate overflow cursor when its lane is fully revealed
senamakel Aug 7, 2026
6a860d7
docs(tui): document the group row in the sessions rail taxonomy
senamakel Aug 7, 2026
c314b2e
fix(tui): restore sidebar grouping test build
senamakel Aug 7, 2026
7ec197a
fix(rail): keep second declared host in shared tree
senamakel Aug 8, 2026
047cfbe
test(rail): strengthen host tree assertion for local machine
senamakel Aug 8, 2026
3b3b8c3
test(rail): simplify host-kind assertion in tree test
senamakel Aug 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -263,10 +263,33 @@ tokenEnv = "MEDULLA_TOKEN"
# selectionFg = "white" # text drawn on top of the primary selection background
# dimBorder = "darkgray" # panel borders

# Legacy harness-row toggles, kept so an older config keeps working. They are
# read only when [statusLine] below is absent, in which case `false` means that
# field is hidden. Settings › Status line writes [statusLine] and takes over.
# ---------------------------------------------------------------------------
# appearance — how the Agents sidebar arranges itself, plus the legacy
# harness-row toggles. Settings › Appearance edits all of these live.
#
# sidebarGrouping picks what heads the sidebar's sections:
# "host" one section per machine, drawn only once a second host exists
# (the default)
# "path" one per workspace directory, so a checkout's agents read together
# "harness" one per coding-agent CLI
# "none" one flat list, no headers
#
# sidebarSort orders the agents in a section and the sessions under an agent:
# "created" declaration order for agents, oldest-first for sessions (default)
# "recent" most recently active first
# "name" alphabetical
#
# Grouping only moves the headers: every agent keeps its own sessions, so no row
# disappears whichever way these are set.
#
# showHarnessBranch/showHarnessPath are legacy, kept so an older config keeps
# working. They are read only when [statusLine] below is absent, in which case
# `false` means that field is hidden. Settings › Status line writes
# [statusLine] and takes over.
# ---------------------------------------------------------------------------
# [appearance]
# sidebarGrouping = "host"
# sidebarSort = "created"
# showHarnessBranch = true
# showHarnessPath = true

Expand Down
2 changes: 1 addition & 1 deletion gitbooks/developers/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ Config is merged from lowest to highest precedence (highest wins):

Files are merged field-by-field (a recursive table merge), so a project-local file can override just `backend.baseUrl` without discarding the rest of a global file. [TOML](https://toml.io/) is the primary format; `--config <path>` still accepts either `.toml` or `.json` (parser chosen by extension) and bypasses file discovery, but env vars and CLI flags still override it. The Config tab shows the merged effective config and lists the source files that contributed.

Every section is optional; with no file anywhere, all defaults apply. Sections: `backend`, `host` (whether this device also runs the work it orchestrates, and the workspace and roots it advertises), `link` (host-link identity, forwarder, and peer roster for the daemon and Overview panel), `hub` (the persisted worker roster and selected default worker, so a fleet survives a restart), `stateDir` (default `<home>/state`; `MEDULLA_STATE_DIR` overrides), `opencode` (worker display, model, agent, workspace, concurrency), `workflow` (the daemon's workspace allowlist, and the workspace roots whose `MEDULLA.md` rides every backend session mint), `fleet` (the declared `Host → Harness → Workspace → Agent` capacity chain and the agent-template catalog), `router` (a custom OpenAI-compatible router the daemon spawns harnesses against; absent leaves every harness unrouted), `budget` (operator-declared per-provider budgets; absent leaves every harness advertising an estimate), `onboarding` (welcome-flow completion state), `update` (`check = true`/`false` for the background release check; `MEDULLA_NO_UPDATE_CHECK` env kill-switch), `theme` (TUI colors — `primary`/`accent`/`selectionFg`/`dimBorder` as [ratatui](https://ratatui.rs/) color names or `#rrggbb`; the Settings › Appearance subpage edits and persists these), `statusLine` (how a harness row on the Agents rail is laid out — each of `state`/`harness`/`control`/`branch`/`path` takes a `line1`/`line2`/`line3`/`hidden` placement, a `*When` visibility of `always`/`active`/`alert`, and where it applies a `*Style` spelling; the Settings › Status line subpage edits these with a live preview, and the older `appearance.showHarnessBranch`/`showHarnessPath` booleans are read only when this section is absent), and `medulla.contextWindowTokens` (Context tab usage hint; the orchestration limits section also carries pass/step/depth/task/token bounds). Inference and tracing are server-side concerns — the TUI has no config for them; unknown sections are ignored.
Every section is optional; with no file anywhere, all defaults apply. Sections: `backend`, `host` (whether this device also runs the work it orchestrates, and the workspace and roots it advertises), `link` (host-link identity, forwarder, and peer roster for the daemon and Overview panel), `hub` (the persisted worker roster and selected default worker, so a fleet survives a restart), `stateDir` (default `<home>/state`; `MEDULLA_STATE_DIR` overrides), `opencode` (worker display, model, agent, workspace, concurrency), `workflow` (the daemon's workspace allowlist, and the workspace roots whose `MEDULLA.md` rides every backend session mint), `fleet` (the declared `Host → Harness → Workspace → Agent` capacity chain and the agent-template catalog), `router` (a custom OpenAI-compatible router the daemon spawns harnesses against; absent leaves every harness unrouted), `budget` (operator-declared per-provider budgets; absent leaves every harness advertising an estimate), `onboarding` (welcome-flow completion state), `update` (`check = true`/`false` for the background release check; `MEDULLA_NO_UPDATE_CHECK` env kill-switch), `theme` (TUI colors — `primary`/`accent`/`selectionFg`/`dimBorder` as [ratatui](https://ratatui.rs/) color names or `#rrggbb`; the Settings › Appearance subpage edits and persists these), `statusLine` (how a harness row on the Agents rail is laid out — each of `state`/`harness`/`control`/`branch`/`path` takes a `line1`/`line2`/`line3`/`hidden` placement, a `*When` visibility of `always`/`active`/`alert`, and where it applies a `*Style` spelling; the Settings › Status line subpage edits these with a live preview, and the older `appearance.showHarnessBranch`/`showHarnessPath` booleans are read only when this section is absent), `appearance` (Agents-sidebar layout — `sidebarGrouping` of `host`/`path`/`harness`/`none` picks what the sidebar's section headers are, `sidebarSort` of `created`/`recent`/`name` orders the agents in a section and the sessions under an agent, alongside the resource-indicator keys; the Settings › Appearance subpage edits these live), and `medulla.contextWindowTokens` (Context tab usage hint; the orchestration limits section also carries pass/step/depth/task/token bounds). Inference and tracing are server-side concerns — the TUI has no config for them; unknown sections are ignored.

There is no `memory` section: the persona-memory layer is out of this build, and its config schema went with it.

Expand Down
5 changes: 3 additions & 2 deletions src/sdk/src/config/appearance/mod.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
//! Configuration for local-process resource indicators in the TUI chrome.
//! Configuration for the TUI's chrome: resource indicators, and how the Agents
//! sidebar groups and orders its rows.

mod types;

pub use types::{AppearanceConfig, ResourceDisplay};
pub use types::{AppearanceConfig, ResourceDisplay, SidebarGrouping, SidebarSort};
75 changes: 75 additions & 0 deletions src/sdk/src/config/appearance/types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,75 @@ pub enum ResourceDisplay {
Bar,
}

/// How the Agents sidebar sections its agent rows.
Comment thread
senamakel marked this conversation as resolved.
///
/// The sidebar is a `Host → Agent → Session` tree; this chooses what the top
/// level is. Only the *sectioning* changes — every agent keeps its own sessions
/// under it — so no row disappears whichever way it is set.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SidebarGrouping {
/// Section by host, and only once a second host exists. The default: with
/// one machine the header would say nothing the operator does not know.
#[default]
Host,
/// Section by the directory an agent works in, so one checkout's agents read
/// together however many hosts or harnesses they span.
Path,
/// Section by the harness an agent runs (`claude`, `codex`, …).
Harness,
/// No section headers at all — one flat list of agents.
None,
}

impl SidebarGrouping {
/// The label shown in Settings → Appearance.
pub const fn label(self) -> &'static str {
match self {
Self::Host => "host",
Self::Path => "path",
Self::Harness => "harness",
Self::None => "none",
}
}

/// Every value, in the order the settings row cycles through them.
pub const ALL: [Self; 4] = [Self::Host, Self::Path, Self::Harness, Self::None];
}

/// How the Agents sidebar orders the rows inside one section.
///
/// Applied at both levels the operator reads: the agents in a section, and the
/// sessions under an agent.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub enum SidebarSort {
/// Declaration order for agents, start time for sessions — oldest first.
/// The default, because it is the only order that does not move under you
/// while you read it.
#[default]
Created,
/// Most recently active first: the session that last produced output, and
/// the agent whose most recent session did.
Recent,
/// Alphabetical by label.
Name,
}

impl SidebarSort {
/// The label shown in Settings → Appearance.
pub const fn label(self) -> &'static str {
match self {
Self::Created => "created",
Self::Recent => "recent",
Self::Name => "name",
}
}

/// Every value, in the order the settings row cycles through them.
pub const ALL: [Self; 3] = [Self::Created, Self::Recent, Self::Name];
}

/// TUI display preferences retained under the `[appearance]` section.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, rename_all = "camelCase")]
Expand All @@ -39,6 +108,10 @@ pub struct AppearanceConfig {
pub device_ram: ResourceDisplay,
/// How to show whole-device disk-capacity pressure in the Agents sidebar.
pub device_disk: ResourceDisplay,
/// What the Agents sidebar sections its agents by.
pub sidebar_grouping: SidebarGrouping,
/// How the Agents sidebar orders agents and the sessions under them.
pub sidebar_sort: SidebarSort,
}

impl AppearanceConfig {
Expand All @@ -54,6 +127,8 @@ impl AppearanceConfig {
device_cpu: ResourceDisplay::Off,
device_ram: ResourceDisplay::Off,
device_disk: ResourceDisplay::Off,
sidebar_grouping: SidebarGrouping::Host,
sidebar_sort: SidebarSort::Created,
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/sdk/src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ pub use agent_declarations::{
agent_declaration, agent_declarations_for_host, declare_agent, declared_agent_ids,
load_agent_declarations, remove_agent_declaration, undeclare_agent, upsert_agent_declaration,
};
pub use appearance::{AppearanceConfig, ResourceDisplay};
pub use appearance::{AppearanceConfig, ResourceDisplay, SidebarGrouping, SidebarSort};
pub use core_socket::{validate_core_socket, CoreSocketError, CoreSocketSource};
pub use custom_harnesses::{
load_custom_harnesses, load_layered_custom_harnesses, CustomHarnessConfig,
Expand Down
24 changes: 24 additions & 0 deletions src/sdk/src/config/types_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,30 @@ fn appearance_session_titles_can_be_hidden() {
assert!(!cfg.appearance.show_session_titles);
}

#[test]
fn sidebar_layout_defaults_to_the_tree_in_declaration_order() {
let cfg: TuiConfig = serde_json::from_str("{}").unwrap();

assert_eq!(
cfg.appearance.sidebar_grouping,
super::SidebarGrouping::Host
);
assert_eq!(cfg.appearance.sidebar_sort, super::SidebarSort::Created);
}

#[test]
fn sidebar_layout_parses_the_operators_choice() {
let cfg: TuiConfig =
serde_json::from_str(r#"{"appearance":{"sidebarGrouping":"path","sidebarSort":"recent"}}"#)
.unwrap();

assert_eq!(
cfg.appearance.sidebar_grouping,
super::SidebarGrouping::Path
);
assert_eq!(cfg.appearance.sidebar_sort, super::SidebarSort::Recent);
}

#[test]
fn appearance_harness_details_parse_independently() {
let cfg: TuiConfig = serde_json::from_str(
Expand Down
68 changes: 61 additions & 7 deletions src/tui/src/ui/app/appearance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,15 @@ use crate::ui::theme::{color_to_string, THEME_ROLES};

use super::types::App;

/// Non-theme rows: session titles, then process and device resource indicators.
pub(super) const APPEARANCE_OPTION_ROWS: usize = 7;
/// Non-theme rows: session titles, the process and device resource indicators,
/// and the two Agents-sidebar layout controls.
pub(super) const APPEARANCE_OPTION_ROWS: usize = 9;

/// The option offset of the sidebar grouping row, past the resource indicators.
pub(super) const SIDEBAR_GROUPING_OPTION: usize = 7;

/// The option offset of the sidebar sort row.
pub(super) const SIDEBAR_SORT_OPTION: usize = 8;

/// Number of behavior controls shown after the editable theme colors.
pub(super) const ATTENTION_ROWS: usize = 1;
Expand All @@ -26,6 +33,20 @@ pub(super) const ATTENTION_ROWS: usize = 1;
pub(super) const APPEARANCE_ROWS: usize =
THEME_ROLES.len() + ATTENTION_ROWS + APPEARANCE_OPTION_ROWS;

/// The next value after `current` in `choices`, wrapping in either direction.
///
/// A value that is not in `choices` — a config hand-edited to something the
/// build does not know — starts the cycle from the first entry rather than
/// refusing to move, so the row is never stuck.
fn cycled<T: Copy + PartialEq>(choices: &[T], current: T, forward: bool) -> T {
let at = choices.iter().position(|choice| *choice == current);
match at {
Some(at) if forward => choices[(at + 1) % choices.len()],
Some(at) => choices[(at + choices.len() - 1) % choices.len()],
None => choices[0],
}
}

impl App {
/// Cycle the selected colour role and persist the theme.
pub(super) fn cycle_appearance_row(&mut self, forward: bool) {
Expand All @@ -43,11 +64,14 @@ impl App {
self.persist_theme_value_now("Attention blink", value.into());
} else {
let option = index - THEME_ROLES.len() - ATTENTION_ROWS;
if option == 3 {
self.toggle_session_titles();
} else {
let resource = if option < 3 { option } else { option - 1 };
self.cycle_resource_display(resource, forward);
match option {
3 => self.toggle_session_titles(),
SIDEBAR_GROUPING_OPTION => self.cycle_sidebar_grouping(forward),
SIDEBAR_SORT_OPTION => self.cycle_sidebar_sort(forward),
_ => {
let resource = if option < 3 { option } else { option - 1 };
self.cycle_resource_display(resource, forward);
}
}
}
}
Expand All @@ -60,6 +84,26 @@ impl App {
self.persist_appearance_now("Session titles", if shown { "on" } else { "off" }.into());
}

/// Cycle and persist what the Agents sidebar sections its agents by.
///
/// Takes effect on the next frame without any rebuild: the rail is assembled
/// from the loaded config every time it is drawn, so the operator sees the
/// arrangement they picked while the settings row is still under the cursor.
fn cycle_sidebar_grouping(&mut self, forward: bool) {
Comment thread
senamakel marked this conversation as resolved.
let current = self.loaded.config.appearance.sidebar_grouping;
let next = cycled(&medulla::config::SidebarGrouping::ALL, current, forward);
self.loaded.config.appearance.sidebar_grouping = next;
self.persist_appearance_now("Sidebar grouping", next.label().into());
}

/// Cycle and persist how the Agents sidebar orders agents and sessions.
fn cycle_sidebar_sort(&mut self, forward: bool) {
let current = self.loaded.config.appearance.sidebar_sort;
let next = cycled(&medulla::config::SidebarSort::ALL, current, forward);
self.loaded.config.appearance.sidebar_sort = next;
self.persist_appearance_now("Sidebar sort", next.label().into());
}

/// Cycle and persist one resource indicator, process-scoped or device-wide.
///
/// `index` is the row's offset past the theme roles, in the order the
Expand Down Expand Up @@ -153,6 +197,16 @@ impl App {
"showHarnessPath".into(),
toml::Value::Boolean(self.loaded.config.appearance.show_harness_path),
);
section.insert(
"sidebarGrouping".into(),
toml::Value::String(
format!("{:?}", appearance.sidebar_grouping).to_lowercase(),
),
);
section.insert(
"sidebarSort".into(),
toml::Value::String(format!("{:?}", appearance.sidebar_sort).to_lowercase()),
);
match medulla::config::persist_section(path, "appearance", section) {
Ok(()) => self.set_status(format!("Appearance · {name} → {value} (saved)")),
Err(error) => self.set_status(format!("Appearance save failed: {error}")),
Expand Down
2 changes: 1 addition & 1 deletion src/tui/src/ui/app/rail/cursor.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub(in crate::ui::app) fn rail_anchor(row: &RailRow, lanes: &[AgentLane]) -> Opt
RailRow::Lane(AgentRow::More { lane_index, .. }) => lanes
.get(*lane_index)
.map(|lane| RailAnchor::Overflow(lane.key.clone())),
RailRow::Host(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None,
RailRow::Host(_) | RailRow::Group(_) | RailRow::AgentsHeader | RailRow::Lane(_) => None,
}
}

Expand Down
Loading
Loading