From 4ab56c9e17900f37343ec6baf4a01918aa2be8fd Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 00:49:53 +0300 Subject: [PATCH 1/3] feat(ui): remove top-level Changes tab, keep session-level d diff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Git diff is a property of one session — what it changed since it launched — not a view over the whole fleet, so it no longer has a top-level tab. It lives on the Sessions tab as the `d` pane (`PaneView::Diff`), drawn over the harness terminal for the row under the cursor; `d`/`Esc` swaps the terminal back. Removed: - `"Changes"` from both TABS arrays (workflows 8->7, slim 7->6) - the Changes render dispatch arm, hint line, and "Diff" compact label - the Changes key-dispatch arm and its `refresh_changes` on tab-enter - the capital `D` shortcut (it jumped to the now-gone Changes tab) - `open_selected_harness_changes` and the dead `draw_changes` wrapper - the `shift_d_on_a_selected_harness_opens_its_changes_tab` test Untouched: the shared `GitChangesState`, `on_changes_key` bindings (file/hunk nav, `b` baseline, `c`/`C`/`e` comments, `r` refresh), and the SDK-side `git_review` model — both access paths already shared them, so the session `d` pane keeps the full review surface. The oversized-diff wrapping tests now render via `draw_harness_diff` (the remaining path) instead of the removed tab arm. Validation: cargo build, cargo clippy --all-targets -- -D warnings, cargo fmt --check, cargo test (all green, 0 failures). Co-authored-by: Medulla --- src/tui/src/ui/app/changes/mod.rs | 49 ++++++++-------------- src/tui/src/ui/app/keys/mod.rs | 3 -- src/tui/src/ui/app/keys/sessions.rs | 5 --- src/tui/src/ui/app/render/changes.rs | 5 --- src/tui/src/ui/app/render/mod.rs | 4 -- src/tui/src/ui/app/render/settings/help.rs | 3 +- src/tui/src/ui/app/render/tests.rs | 29 ++++++++----- src/tui/src/ui/app/state.rs | 23 ++++------ src/tui/src/ui/app/tests/harness_pane.rs | 16 +------ src/tui/src/ui/app/types/model.rs | 13 ++++-- 10 files changed, 56 insertions(+), 94 deletions(-) diff --git a/src/tui/src/ui/app/changes/mod.rs b/src/tui/src/ui/app/changes/mod.rs index 1c0650ff3..73b7349c7 100644 --- a/src/tui/src/ui/app/changes/mod.rs +++ b/src/tui/src/ui/app/changes/mod.rs @@ -1,4 +1,8 @@ -//! Session-scoped Git inspection and review comments for the Changes tab. +//! Session-scoped Git inspection and review comments for the harness diff pane. +//! +//! A Git diff is a property of one session — what it changed since it launched +//! — so it is shown in the Sessions tab's harness pane (`d` on a row swaps the +//! terminal for the diff) rather than as a top-level tab over the whole fleet. //! //! Git subprocesses live in [`repository`], the cursor and cache in [`types`], //! and the reusable review model in [`medulla::ui::git_review`]. This module is @@ -23,44 +27,25 @@ use std::path::Path; use medulla::ui::git_review::CommentAnchor; pub(crate) use types::GitChangesState; -use super::types::{App, Cmd, PaneView, PromptKind, TABS}; +use super::types::{App, PaneView, PromptKind}; use crate::ui::composer::{Draft, TextPrompt}; use baseline::select_harness_baseline; impl App { - /// Open the Changes tab for the harness currently shown in the Sessions pane. - /// - /// The draw path records the exact session resolved from the selected rail - /// row. Keeping that id before changing tabs lets the forced refresh pick - /// the matching launch snapshot instead of falling back to another, newer - /// harness in a different checkout. Unlike ordinary tab entry, this also - /// replaces an operator-selected commit or manual baseline: `d` means the - /// immutable launch diff for the harness under the cursor. - pub(super) fn open_selected_harness_changes(&mut self) -> Option { - let session = self.pane_session.clone()?; - self.rail_session = Some(session); - self.tab_index = TABS - .iter() - .position(|tab| *tab == "Changes") - .expect("Changes is a top-level tab"); - self.selected = 0; - self.refresh_changes_from_selected_harness(); - None - } - /// Swap the harness pane between its terminal and its diff. /// - /// The diff is drawn *in the pane* rather than on the Changes tab, because - /// the operator asked a question about the row they are sitting on: sending - /// them to another tab makes the rail cursor, the pane title and the tab bar - /// all move at once to answer "what has this one changed". Same state and - /// same bindings as the tab — only the real estate differs — so `d` again - /// puts the terminal back. + /// The diff is drawn *in the pane* rather than on its own tab, because the + /// operator asked a question about the row they are sitting on: a Git diff + /// is a property of one session — what it changed since it launched — not a + /// view over the whole fleet, so `d` answers "what has this one changed" + /// without moving the rail cursor, the pane title or the tab bar. Same state + /// and same bindings as a full diff would have — only the real estate differs + /// — so `d` again puts the terminal back. /// - /// Each opening re-points Git at this session's launch snapshot, for the - /// reason [`open_selected_harness_changes`](Self::open_selected_harness_changes) - /// does: the collected diff is shared with the Changes tab, so it may be - /// describing a different harness entirely. + /// Each opening re-points Git at this session's launch snapshot: the + /// collected diff is shared state that may have been left describing a + /// different harness entirely, so it is re-rooted on the row under the cursor + /// before anything is drawn. pub(super) fn toggle_harness_diff_pane(&mut self) { if self.pane_view == PaneView::Diff { self.pane_view = PaneView::Harness; diff --git a/src/tui/src/ui/app/keys/mod.rs b/src/tui/src/ui/app/keys/mod.rs index 943d8e504..34982c1a5 100644 --- a/src/tui/src/ui/app/keys/mod.rs +++ b/src/tui/src/ui/app/keys/mod.rs @@ -254,9 +254,6 @@ impl App { return cmd; } } - if tab == "Changes" && self.on_changes_key(k.code) { - return None; - } // Workflows owns three panes, one of which is a composer, so it gets // first refusal on every key that is not a global chord — exactly as // Settings and Routing do for their subpages. diff --git a/src/tui/src/ui/app/keys/sessions.rs b/src/tui/src/ui/app/keys/sessions.rs index 485840f06..cf0ca772e 100644 --- a/src/tui/src/ui/app/keys/sessions.rs +++ b/src/tui/src/ui/app/keys/sessions.rs @@ -132,11 +132,6 @@ impl App { self.toggle_harness_diff_pane(); SessionsKey::Handled(None) } - // `D` is the same diff on the Changes tab: the pane is half a - // screen, and a review with comments on it wants the whole one. - KeyCode::Char('D') if !ctrl && !alt && self.pane_session.is_some() => { - SessionsKey::Handled(self.open_selected_harness_changes()) - } // `k` closes the harness the pane is showing — the other half of the // two things an operator wants from a session they are looking at // but not typing into. It asks first; see `close_pane_session_prompt`. diff --git a/src/tui/src/ui/app/render/changes.rs b/src/tui/src/ui/app/render/changes.rs index 5fb10407e..73bafc0ca 100644 --- a/src/tui/src/ui/app/render/changes.rs +++ b/src/tui/src/ui/app/render/changes.rs @@ -15,11 +15,6 @@ use medulla::ui::git_review::CommentAnchor; use super::super::types::App; impl App { - /// Draw the session-start summary/file rail and selected unified patch. - pub(super) fn draw_changes(&mut self, frame: &mut Frame, area: Rect) { - self.draw_changes_into(frame, area, " Git changes · b baseline "); - } - /// The same two panes, drawn over the Sessions harness pane. /// /// Same state, same bindings, different real estate: `d` on a harness row diff --git a/src/tui/src/ui/app/render/mod.rs b/src/tui/src/ui/app/render/mod.rs index f3b185670..380eb53a4 100644 --- a/src/tui/src/ui/app/render/mod.rs +++ b/src/tui/src/ui/app/render/mod.rs @@ -278,8 +278,6 @@ impl App { // Its own line, and a short one: the placeholder binds nothing, and // the default hint below advertises the session steering chords. "Tab views · Subconscious coming soon" - } else if self.tab() == "Changes" { - "Tab views · ↑↓ files · j/k line · [ ] hunk · b baseline · c comment · C file · e edit · r refresh" } else if workflows { "Tab views · ⏎ open · Esc back · ←→ follow edges · ↑↓ lanes · i inspect · c copilot · x run · d dry-run · r refresh" } else if self.tab() == "Sessions" && self.pane_view == PaneView::Diff { @@ -310,7 +308,6 @@ impl App { match self.tab() { "Overview" => self.draw_overview(f, area), "Sessions" => self.draw_sessions_tab(f, area), - "Changes" => self.draw_changes(f, area), #[cfg(feature = "workflows")] "Workflows" => self.draw_workflows_tab(f, area), // Not feature-gated: the tab exists in the slim build too, and a @@ -355,7 +352,6 @@ fn compact_tab_label(name: &str, compact: bool) -> &str { "Sessions" => "Sess", "Workflows" => "Flows", "Subconscious" => "Sub", - "Changes" => "Diff", "Feedback" => "Feed", "Settings" => "Set", _ => name, diff --git a/src/tui/src/ui/app/render/settings/help.rs b/src/tui/src/ui/app/render/settings/help.rs index c189db6b6..ffae41727 100644 --- a/src/tui/src/ui/app/render/settings/help.rs +++ b/src/tui/src/ui/app/render/settings/help.rs @@ -53,11 +53,12 @@ impl App { TLine::from("Account: Enter twice to log out · Usage: r refresh"), TLine::from(" "), TLine::from(Span::styled("Changes", bold)), - TLine::from("Tab / Shift-Tab to the Changes view to inspect the Git diff since session start"), + TLine::from("On a session row, press d to inspect the Git diff since that session launched"), TLine::from("↑↓ select files · j/k move by line · [/] jump hunks · PageUp/PageDown move faster"), TLine::from( "c comments on a line or hunk · e edits it · C comments on or edits the file · r refreshes", ), + TLine::from("d or Esc puts the harness terminal back"), TLine::from(" "), TLine::from(Span::styled("Mouse", bold)), TLine::from("Click a tab to switch views · click a rail row to select it · wheel scrolls"), diff --git a/src/tui/src/ui/app/render/tests.rs b/src/tui/src/ui/app/render/tests.rs index 1835f4f48..9ee15279b 100644 --- a/src/tui/src/ui/app/render/tests.rs +++ b/src/tui/src/ui/app/render/tests.rs @@ -23,11 +23,9 @@ fn compact_tab_labels_shorten_the_current_wide_destinations() { assert_eq!(super::compact_tab_label("Sessions", true), "Sess"); assert_eq!(super::compact_tab_label("Workflows", true), "Flows"); assert_eq!(super::compact_tab_label("Subconscious", true), "Sub"); - assert_eq!(super::compact_tab_label("Changes", true), "Diff"); assert_eq!(super::compact_tab_label("Feedback", true), "Feed"); assert_eq!(super::compact_tab_label("Settings", true), "Set"); assert_eq!(super::compact_tab_label("Hosts", true), "Hosts"); - assert_eq!(super::compact_tab_label("Changes", false), "Changes"); } #[test] @@ -130,16 +128,19 @@ fn a_stale_harness_diff_does_not_advertise_agents_shortcuts_on_another_tab() { assert!(!output.contains("d/Esc harness"), "{output}"); } -/// Put the Changes tab in front of a patch whose selected line is far wider -/// than the diff pane, so one rendered row wraps past the whole viewport. +/// Put the harness diff pane in front of a patch whose selected line is far +/// wider than the diff pane, so one rendered row wraps past the whole viewport. +/// +/// The diff is reached only as a session pane (`d` on a row) now that the +/// top-level Changes tab is gone, so this renders through `draw_harness_diff` +/// — the same path the Sessions tab takes — rather than a removed tab arm. fn app_on_an_oversized_diff_line() -> App { use crate::ui::app::changes::types::ChangedFile; + use crate::ui::app::types::{tab_pos, PaneView}; let mut app = app(); - app.tab_index = crate::ui::app::types::TABS - .iter() - .position(|t| *t == "Changes") - .expect("Changes tab"); + app.tab_index = tab_pos("Sessions"); + app.pane_view = PaneView::Diff; app.changes.root = Some(std::path::PathBuf::from("/repo")); app.changes.baseline = Some("baseline".to_owned()); app.changes.files = vec![ChangedFile { @@ -162,7 +163,9 @@ fn a_wrapped_diff_bounds_the_scroll_by_the_rows_it_actually_occupies() { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).expect("terminal"); - terminal.draw(|f| app.draw(f)).expect("draw"); + terminal + .draw(|f| app.draw_harness_diff(f, f.area())) + .expect("draw"); // The oversized line wraps to many rows, so the bound has to exceed the // three logical patch lines rather than counting them one row each. @@ -179,9 +182,13 @@ fn a_cursor_on_an_oversized_line_holds_a_stable_scroll_offset() { let mut terminal = ratatui::Terminal::new(ratatui::backend::TestBackend::new(120, 40)).expect("terminal"); - terminal.draw(|f| app.draw(f)).expect("draw"); + terminal + .draw(|f| app.draw_harness_diff(f, f.area())) + .expect("draw"); let first = app.changes.scroll; - terminal.draw(|f| app.draw(f)).expect("draw again"); + terminal + .draw(|f| app.draw_harness_diff(f, f.area())) + .expect("draw again"); // Showing the top of the row keeps consecutive frames identical instead of // oscillating between the row's top and bottom edges. diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index ae2bcf5dd..f8f5639ad 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -529,21 +529,16 @@ impl App { /// active subpage. pub(super) fn tab_enter_cmd(&mut self) -> Option { // Arriving at a tab should put the keyboard on the thing the tab is - // *about*. Both of these used to land it somewhere else — Hosts on its - // two-item menu — so the first arrow press did - // nothing visible and the list had to be clicked before it would move. - match self.tab() { - "Changes" => { - self.refresh_changes(); - } - // The list is the page; the menu is two rows and reachable with `1` - // and `2`, or with Esc. - "Hosts" => self.routing_focused = true, - // Safe because the rail forwards typing: a printable key moves focus - // to the composer and lands the character there, so nothing is lost - // by not starting in it. - _ => {} + // *about*. Hosts used to land on its two-item menu, so the first arrow + // press did nothing visible and the list had to be clicked before it + // would move — the list is the page, and the menu is two rows reachable + // with `1` and `2` or Esc. + if self.tab() == "Hosts" { + self.routing_focused = true; } + // No other tab needs a nudge: the rail forwards typing, so a printable + // key moves focus to the composer and lands the character there, and + // nothing is lost by not starting in it. match self.tab() { "Feedback" => Some(Cmd::LoadFeedback(self.feedback.query.clone())), // The workflow store is files on this machine, so entering the tab diff --git a/src/tui/src/ui/app/tests/harness_pane.rs b/src/tui/src/ui/app/tests/harness_pane.rs index b83465cb8..0d33f2593 100644 --- a/src/tui/src/ui/app/tests/harness_pane.rs +++ b/src/tui/src/ui/app/tests/harness_pane.rs @@ -5,7 +5,7 @@ use medulla::config::LoadedConfig; use medulla::runtime::mock::MockRuntime; use std::sync::Arc; -use super::super::types::{tab_pos, App, PaneView}; +use super::super::types::{tab_pos, App}; /// Build the standard app fixture with the harness pane available. fn app() -> App { @@ -20,20 +20,6 @@ fn tab(name: &str) -> usize { tab_pos(name) } -#[test] -fn shift_d_on_a_selected_harness_opens_its_changes_tab() { - let mut app = app(); - app.tab_index = tab("Sessions"); - app.pane_session = Some("selected-harness".to_owned()); - - let cmd = app.on_key(KeyEvent::new(KeyCode::Char('D'), KeyModifiers::SHIFT)); - - assert!(cmd.is_none()); - assert_eq!(app.tab(), "Changes"); - assert_eq!(app.pane_view, PaneView::Harness); - assert_eq!(app.rail_session.as_deref(), Some("selected-harness")); -} - #[test] fn k_on_a_selected_harness_asks_before_closing_it() { let mut app = app(); diff --git a/src/tui/src/ui/app/types/model.rs b/src/tui/src/ui/app/types/model.rs index f73148670..097498744 100644 --- a/src/tui/src/ui/app/types/model.rs +++ b/src/tui/src/ui/app/types/model.rs @@ -52,13 +52,19 @@ use super::rail_hit::RailHit; /// both still builds and their render paths are intact, so restoring either is /// putting one line back. Memory is out of the build entirely (its tab said /// "coming soon"); Tasks duplicates what the Sessions tab already shows per lane. +/// +/// `Changes` is gone rather than commented out: a Git diff is a property of one +/// session — what it changed since it launched — not a view over the whole fleet. +/// It lives on the Sessions tab as the `d` pane (`PaneView::Diff`), drawn over +/// the harness terminal for the row under the cursor. The shared diff state and +/// bindings stay in `app::changes`; only the top-level tab and its `D` shortcut +/// were removed. #[cfg(feature = "workflows")] -pub const TABS: [&str; 8] = [ +pub const TABS: [&str; 7] = [ "Overview", "Sessions", "Workflows", "Subconscious", - "Changes", "Hosts", "Feedback", "Settings", @@ -67,11 +73,10 @@ pub const TABS: [&str; 8] = [ /// Without the workflow engine. A slim build must not offer a tab that cannot /// draw anything. #[cfg(not(feature = "workflows"))] -pub const TABS: [&str; 7] = [ +pub const TABS: [&str; 6] = [ "Overview", "Sessions", "Subconscious", - "Changes", "Hosts", "Feedback", "Settings", From 9db329a9c98e875a446a321e4b6d957c2406d2de Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 08:21:53 +0300 Subject: [PATCH 2/3] fix(ui): refresh the harness diff pane on re-entering Sessions Co-authored-by: Medulla --- src/tui/src/ui/app/state.rs | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/tui/src/ui/app/state.rs b/src/tui/src/ui/app/state.rs index f8f5639ad..dd6025ba9 100644 --- a/src/tui/src/ui/app/state.rs +++ b/src/tui/src/ui/app/state.rs @@ -14,8 +14,8 @@ use medulla::config::LoadedConfig; use medulla::runtime::{ContextItem, Runtime}; use super::types::{ - App, Cmd, HandbackPolicy, ResumePicker, ROUTING_SUBPAGES, SETTINGS_SUBPAGES, SP_CONTEXT, - SP_FEEDBACK, SP_USAGE, TABS, + App, Cmd, HandbackPolicy, PaneView, ResumePicker, ROUTING_SUBPAGES, SETTINGS_SUBPAGES, + SP_CONTEXT, SP_FEEDBACK, SP_USAGE, TABS, }; impl App { @@ -541,6 +541,16 @@ impl App { // nothing is lost by not starting in it. match self.tab() { "Feedback" => Some(Cmd::LoadFeedback(self.feedback.query.clone())), + // The diff pane is the Changes surface now, so entering the tab + // re-loads its git data the way the removed Changes tab did on + // entry: a repo that changed while the operator was on another tab + // would otherwise stay stale beneath the open diff. + "Sessions" => { + if self.pane_view == PaneView::Diff { + self.refresh_changes(); + } + None + } // The workflow store is files on this machine, so entering the tab // reads them rather than asking the runtime for anything — which is // why this arm returns no command and does the work here. From c6b5e5787ad73c5774ab9210dd1699ca19a6a806 Mon Sep 17 00:00:00 2001 From: Steven Enamakel Date: Mon, 10 Aug 2026 08:21:53 +0300 Subject: [PATCH 3/3] chore(ui): group test imports and document the slim-build TABS Co-authored-by: Medulla --- src/tui/src/ui/app/render/tests.rs | 7 ++----- src/tui/src/ui/app/types/model.rs | 5 +++++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/src/tui/src/ui/app/render/tests.rs b/src/tui/src/ui/app/render/tests.rs index 9ee15279b..70b596c80 100644 --- a/src/tui/src/ui/app/render/tests.rs +++ b/src/tui/src/ui/app/render/tests.rs @@ -11,6 +11,8 @@ use medulla::config::LoadedConfig; use medulla::runtime::mock::MockRuntime; use medulla::runtime::Runtime; +use crate::ui::app::changes::types::ChangedFile; +use crate::ui::app::types::{tab_pos, PaneView}; use crate::ui::app::App; fn app() -> App { @@ -108,8 +110,6 @@ fn leaving_the_agents_tab_takes_the_keyboard_back_from_an_attached_harness() { #[test] fn a_stale_harness_diff_does_not_advertise_agents_shortcuts_on_another_tab() { - use crate::ui::app::types::{tab_pos, PaneView}; - let mut app = app(); app.tab_index = tab_pos("Overview"); app.pane_view = PaneView::Diff; @@ -135,9 +135,6 @@ fn a_stale_harness_diff_does_not_advertise_agents_shortcuts_on_another_tab() { /// top-level Changes tab is gone, so this renders through `draw_harness_diff` /// — the same path the Sessions tab takes — rather than a removed tab arm. fn app_on_an_oversized_diff_line() -> App { - use crate::ui::app::changes::types::ChangedFile; - use crate::ui::app::types::{tab_pos, PaneView}; - let mut app = app(); app.tab_index = tab_pos("Sessions"); app.pane_view = PaneView::Diff; diff --git a/src/tui/src/ui/app/types/model.rs b/src/tui/src/ui/app/types/model.rs index 097498744..88283aeda 100644 --- a/src/tui/src/ui/app/types/model.rs +++ b/src/tui/src/ui/app/types/model.rs @@ -72,6 +72,11 @@ pub const TABS: [&str; 7] = [ /// Without the workflow engine. A slim build must not offer a tab that cannot /// draw anything. +/// +/// This is the workflow-enabled list minus `Workflows`: every remaining tab — +/// the fleet Overview, the Sessions rail with its `d` diff pane, Subconscious +/// (still drawing its placeholder), Hosts, Feedback, and Settings — renders +/// without the workflow engine. #[cfg(not(feature = "workflows"))] pub const TABS: [&str; 6] = [ "Overview",