Compare commits
22
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2ab07c2343 | ||
|
|
342e732ef0 | ||
|
|
3205270b51 | ||
|
|
d8e31f2760 | ||
|
|
fdf9ea1b31 | ||
|
|
22cbfbc9bc | ||
|
|
8157793282 | ||
|
|
839dd27925 | ||
|
|
a1816a0838 | ||
|
|
e01b0616b1 | ||
|
|
74d8d3c37a | ||
|
|
cc9b027bd1 | ||
|
|
10a25e5b83 | ||
|
|
4c3f65027d | ||
|
|
76eb3d1386 | ||
|
|
877f409194 | ||
|
|
39d4014dcb | ||
|
|
f02510465c | ||
|
|
8e251bf8be | ||
|
|
bc95d16a98 | ||
|
|
f0108c5645 | ||
|
|
c534cee7cc |
@@ -22,7 +22,8 @@ use std::collections::BTreeMap;
|
|||||||
use std::net::SocketAddr;
|
use std::net::SocketAddr;
|
||||||
use std::path::{Path, PathBuf};
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
use serde::Deserialize;
|
use serde::de::Error as _;
|
||||||
|
use serde::{Deserialize, Deserializer};
|
||||||
|
|
||||||
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
/// Directory name under `$XDG_CONFIG_HOME` (default `~/.config`).
|
||||||
pub const DIR_NAME: &str = "maestro";
|
pub const DIR_NAME: &str = "maestro";
|
||||||
@@ -146,11 +147,14 @@ pub struct WebSection {
|
|||||||
|
|
||||||
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
|
||||||
///
|
///
|
||||||
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
|
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
|
||||||
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
|
/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert`
|
||||||
/// applied **over** the defaults, so a partial override is enough.
|
/// are applied **over** the defaults, so a partial override is enough.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
|
||||||
pub struct TuiSection {
|
pub struct TuiSection {
|
||||||
|
/// Named palette preset. Defaults to the dark preset.
|
||||||
|
#[serde(default, deserialize_with = "deserialize_theme")]
|
||||||
|
pub theme: Option<TuiTheme>,
|
||||||
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
/// Canonical key form, e.g. `"ctrl-a"`. Default `ctrl-a`.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub leader: Option<String>,
|
pub leader: Option<String>,
|
||||||
@@ -167,6 +171,33 @@ pub struct TuiSection {
|
|||||||
pub keymap_insert: BTreeMap<String, String>,
|
pub keymap_insert: BTreeMap<String, String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Palette presets supported by the TUI's `[tui].theme` setting.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum TuiTheme {
|
||||||
|
/// The terminal-native dark palette and the default.
|
||||||
|
Dark,
|
||||||
|
/// A high-contrast light palette.
|
||||||
|
Light,
|
||||||
|
/// Named ANSI colours, allowing the terminal to provide the colour set.
|
||||||
|
Ansi,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn deserialize_theme<'de, D>(deserializer: D) -> Result<Option<TuiTheme>, D::Error>
|
||||||
|
where
|
||||||
|
D: Deserializer<'de>,
|
||||||
|
{
|
||||||
|
let raw = Option::<String>::deserialize(deserializer)?;
|
||||||
|
raw.map(|name| match name.as_str() {
|
||||||
|
"dark" => Ok(TuiTheme::Dark),
|
||||||
|
"light" => Ok(TuiTheme::Light),
|
||||||
|
"ansi" => Ok(TuiTheme::Ansi),
|
||||||
|
_ => Err(D::Error::custom(format!(
|
||||||
|
"invalid [tui].theme {name:?}; expected \"dark\", \"light\", or \"ansi\""
|
||||||
|
))),
|
||||||
|
})
|
||||||
|
.transpose()
|
||||||
|
}
|
||||||
|
|
||||||
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
|
/// `[spawn]` — command presets for the spawn form (#22, part of #21).
|
||||||
///
|
///
|
||||||
/// Defined in shared config so every binary sees the same list: the web reads
|
/// Defined in shared config so every binary sees the same list: the web reads
|
||||||
@@ -596,6 +627,30 @@ bind_addr = "127.0.0.1:9090"
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
|
||||||
|
assert_eq!(TuiSection::default().theme, None);
|
||||||
|
let cfg = file_with("[tui]\ntheme = \"light\"\n");
|
||||||
|
assert_eq!(cfg.tui.theme, Some(TuiTheme::Light));
|
||||||
|
let cfg = file_with("[tui]\ntheme = \"ansi\"\n");
|
||||||
|
assert_eq!(cfg.tui.theme, Some(TuiTheme::Ansi));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tui_theme_rejects_unknown_names_with_the_field_name() {
|
||||||
|
let err =
|
||||||
|
toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"solarized\"\n").unwrap_err();
|
||||||
|
let message = err.to_string();
|
||||||
|
assert!(
|
||||||
|
message.contains("[tui].theme"),
|
||||||
|
"diagnostic should name the field: {message}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
message.contains("solarized"),
|
||||||
|
"diagnostic should name the invalid value: {message}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
fn file_with(body: &str) -> MaestroConfigFile {
|
fn file_with(body: &str) -> MaestroConfigFile {
|
||||||
toml::from_str(body).expect("parse")
|
toml::from_str(body).expect("parse")
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ pub enum Action {
|
|||||||
// Sidebar highlight movement
|
// Sidebar highlight movement
|
||||||
SidebarNext,
|
SidebarNext,
|
||||||
SidebarPrev,
|
SidebarPrev,
|
||||||
|
ToggleSidebarGroup,
|
||||||
|
|
||||||
// Catalog actions on the highlighted sidebar row
|
// Catalog actions on the highlighted sidebar row
|
||||||
AttachHighlighted,
|
AttachHighlighted,
|
||||||
@@ -81,6 +82,7 @@ impl Action {
|
|||||||
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
"FocusSidebar" => return Ok(Action::FocusSidebar),
|
||||||
"SidebarNext" => return Ok(Action::SidebarNext),
|
"SidebarNext" => return Ok(Action::SidebarNext),
|
||||||
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
"SidebarPrev" => return Ok(Action::SidebarPrev),
|
||||||
|
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
|
||||||
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
|
||||||
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
|
||||||
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
"NewDraftPane" => return Ok(Action::NewDraftPane),
|
||||||
|
|||||||
@@ -30,14 +30,16 @@ use crate::catalog::CatalogStore;
|
|||||||
use crate::config::TuiConfig;
|
use crate::config::TuiConfig;
|
||||||
use crate::draft::DraftInputResult;
|
use crate::draft::DraftInputResult;
|
||||||
use crate::emulators::SessionEmulators;
|
use crate::emulators::SessionEmulators;
|
||||||
use crate::input::{canonicalise, ChordResult, ChordState, ClickTracker, InputKey};
|
use crate::input::{
|
||||||
|
canonicalise, ChordResult, ChordState, ClickTracker, InputKey, MouseKind, Region,
|
||||||
|
};
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::HelpOverlay;
|
use crate::overlays::HelpOverlay;
|
||||||
use crate::pane::{Pane, PaneStatus};
|
use crate::pane::{Pane, PaneStatus};
|
||||||
use crate::render::{draw, RegionMap, RenderInputs};
|
use crate::render::{draw, RegionMap, RenderInputs};
|
||||||
use crate::session_task::{SessionInput, SessionTask};
|
use crate::session_task::{SessionInput, SessionTask};
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
|
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
use crate::worktrees::WorktreesView;
|
use crate::worktrees::WorktreesView;
|
||||||
use crate::TuiError;
|
use crate::TuiError;
|
||||||
@@ -52,6 +54,7 @@ enum OverlayDecision {
|
|||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
cfg: TuiConfig,
|
cfg: TuiConfig,
|
||||||
|
home_dir: String,
|
||||||
catalog: CatalogStore,
|
catalog: CatalogStore,
|
||||||
emulators: SessionEmulators,
|
emulators: SessionEmulators,
|
||||||
state: AppState,
|
state: AppState,
|
||||||
@@ -137,6 +140,7 @@ impl App {
|
|||||||
.expect("build reqwest client");
|
.expect("build reqwest client");
|
||||||
Self {
|
Self {
|
||||||
cfg,
|
cfg,
|
||||||
|
home_dir: std::env::var("HOME").unwrap_or_default(),
|
||||||
catalog,
|
catalog,
|
||||||
emulators: SessionEmulators::default(),
|
emulators: SessionEmulators::default(),
|
||||||
state: AppState::new(),
|
state: AppState::new(),
|
||||||
@@ -158,6 +162,9 @@ impl App {
|
|||||||
mut self,
|
mut self,
|
||||||
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
|
||||||
) -> Result<(), TuiError> {
|
) -> Result<(), TuiError> {
|
||||||
|
if let Ok(info) = self.daemon.daemon_info().await {
|
||||||
|
self.home_dir = info.home_dir;
|
||||||
|
}
|
||||||
let mut events = EventStream::new();
|
let mut events = EventStream::new();
|
||||||
loop {
|
loop {
|
||||||
self.redraw(terminal)?;
|
self.redraw(terminal)?;
|
||||||
@@ -197,6 +204,10 @@ impl App {
|
|||||||
return Ok(false);
|
return Ok(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if self.handle_sidebar_mouse(&key) {
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
|
||||||
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
let known = self.cfg.keymap.known_bindings(self.state.mode);
|
||||||
match self.chord.feed(&key, &known) {
|
match self.chord.feed(&key, &known) {
|
||||||
ChordResult::Pending => Ok(false),
|
ChordResult::Pending => Ok(false),
|
||||||
@@ -224,6 +235,34 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clicks select rendered sidebar items directly. Double-clicks continue
|
||||||
|
/// through the keymap so the configured attach binding remains intact.
|
||||||
|
fn handle_sidebar_mouse(&mut self, key: &InputKey) -> bool {
|
||||||
|
let InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
row,
|
||||||
|
..
|
||||||
|
} = key
|
||||||
|
else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
let Some(item) = self.region.sidebar_item_at(*row) else {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
self.state.focus = Focus::Sidebar;
|
||||||
|
match item {
|
||||||
|
SidebarItem::Group(key) => {
|
||||||
|
self.state.sidebar_highlight = Some(SidebarItem::Group(key.clone()));
|
||||||
|
self.toggle_sidebar_group(key);
|
||||||
|
}
|
||||||
|
SidebarItem::Session(id) => {
|
||||||
|
self.state.sidebar_highlight = Some(SidebarItem::Session(id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
/// Carry out what `dispatch` decided. The only place in `App` that talks to
|
||||||
/// the daemon.
|
/// the daemon.
|
||||||
async fn perform(&mut self, effects: Vec<Effect>) {
|
async fn perform(&mut self, effects: Vec<Effect>) {
|
||||||
@@ -330,15 +369,22 @@ impl App {
|
|||||||
self.state.focus = Focus::Pane;
|
self.state.focus = Focus::Pane;
|
||||||
}
|
}
|
||||||
Action::SidebarNext => {
|
Action::SidebarNext => {
|
||||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
let order = visible_sidebar_order(
|
||||||
|
&self.catalog.snapshot(),
|
||||||
|
&self.state.collapsed_sidebar_groups,
|
||||||
|
);
|
||||||
self.state.move_sidebar_highlight(&order, 1);
|
self.state.move_sidebar_highlight(&order, 1);
|
||||||
self.state.focus = Focus::Sidebar;
|
self.state.focus = Focus::Sidebar;
|
||||||
}
|
}
|
||||||
Action::SidebarPrev => {
|
Action::SidebarPrev => {
|
||||||
let order = visible_sidebar_order(&self.catalog.snapshot());
|
let order = visible_sidebar_order(
|
||||||
|
&self.catalog.snapshot(),
|
||||||
|
&self.state.collapsed_sidebar_groups,
|
||||||
|
);
|
||||||
self.state.move_sidebar_highlight(&order, -1);
|
self.state.move_sidebar_highlight(&order, -1);
|
||||||
self.state.focus = Focus::Sidebar;
|
self.state.focus = Focus::Sidebar;
|
||||||
}
|
}
|
||||||
|
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
|
||||||
Action::NewTab => self.state.new_tab(),
|
Action::NewTab => self.state.new_tab(),
|
||||||
Action::CloseTab => {
|
Action::CloseTab => {
|
||||||
if let Some(sid) = self.state.close_tab() {
|
if let Some(sid) = self.state.close_tab() {
|
||||||
@@ -403,13 +449,50 @@ impl App {
|
|||||||
/// Return the session under the sidebar highlight, falling back to the
|
/// Return the session under the sidebar highlight, falling back to the
|
||||||
/// first listed session so the user can attach without arrowing first.
|
/// first listed session so the user can attach without arrowing first.
|
||||||
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
fn pick_sidebar_target(&self) -> Option<Uuid> {
|
||||||
if let Some(sid) = self.state.sidebar_highlight {
|
if let Some(SidebarItem::Session(sid)) = self.state.sidebar_highlight {
|
||||||
return Some(sid);
|
return Some(sid);
|
||||||
}
|
}
|
||||||
|
if self.state.sidebar_highlight.is_some() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
let snap = self.catalog.snapshot();
|
let snap = self.catalog.snapshot();
|
||||||
snap.sessions.first().map(|s| s.id)
|
snap.sessions.first().map(|s| s.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn toggle_highlighted_sidebar_group(&mut self) {
|
||||||
|
let key = match self.state.sidebar_highlight.clone() {
|
||||||
|
Some(SidebarItem::Group(key)) => Some(key),
|
||||||
|
Some(SidebarItem::Session(id)) => self
|
||||||
|
.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.and_then(|s| maestro_protocol::display::session_tree_path(&s)),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
if let Some(key) = key {
|
||||||
|
self.toggle_sidebar_group(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle_sidebar_group(&mut self, key: PathBuf) {
|
||||||
|
let contains_highlight = match self.state.sidebar_highlight {
|
||||||
|
Some(SidebarItem::Session(id)) => {
|
||||||
|
self.catalog
|
||||||
|
.snapshot()
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.find(|s| s.id == id)
|
||||||
|
.and_then(maestro_protocol::display::session_tree_path)
|
||||||
|
.as_ref()
|
||||||
|
== Some(&key)
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
};
|
||||||
|
self.state.toggle_sidebar_group(key, contains_highlight);
|
||||||
|
}
|
||||||
|
|
||||||
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
/// Encode a keystroke for the focused pane. `None` when there is nothing
|
||||||
/// attached to send it to.
|
/// attached to send it to.
|
||||||
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
|
||||||
@@ -811,7 +894,7 @@ impl App {
|
|||||||
.collect();
|
.collect();
|
||||||
let sidebar = Sidebar {
|
let sidebar = Sidebar {
|
||||||
sessions: snap.sessions.clone(),
|
sessions: snap.sessions.clone(),
|
||||||
highlight: self.state.sidebar_highlight,
|
highlight: self.state.sidebar_highlight.clone(),
|
||||||
attached,
|
attached,
|
||||||
};
|
};
|
||||||
let tab_bar = TabBar {
|
let tab_bar = TabBar {
|
||||||
@@ -833,6 +916,7 @@ impl App {
|
|||||||
|
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
|
home_dir: &self.home_dir,
|
||||||
tab_bar: &tab_bar,
|
tab_bar: &tab_bar,
|
||||||
pane: pane.as_ref(),
|
pane: pane.as_ref(),
|
||||||
state: &self.state,
|
state: &self.state,
|
||||||
@@ -881,8 +965,16 @@ impl App {
|
|||||||
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
|
||||||
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
|
||||||
/// visual sync with what the user sees.
|
/// visual sync with what the user sees.
|
||||||
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
|
fn visible_sidebar_order(
|
||||||
snap.sessions.iter().map(|s| s.id).collect()
|
snap: &crate::catalog::CatalogSnapshot,
|
||||||
|
collapsed: &std::collections::HashSet<PathBuf>,
|
||||||
|
) -> Vec<SidebarItem> {
|
||||||
|
Sidebar {
|
||||||
|
sessions: snap.sessions.clone(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
}
|
||||||
|
.rendered_items_with_collapsed(collapsed)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
/// Wire encoding for a keystroke headed to a session's PTY. `None` for keys
|
||||||
@@ -1134,9 +1226,32 @@ mod tests {
|
|||||||
}],
|
}],
|
||||||
});
|
});
|
||||||
app.dispatch(Action::SidebarNext);
|
app.dispatch(Action::SidebarNext);
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn test_session(sid: Uuid, cwd: &str) -> maestro_protocol::Session {
|
||||||
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::{Session, SessionStatus};
|
||||||
|
Session {
|
||||||
|
id: sid,
|
||||||
|
command: "bash".into(),
|
||||||
|
cwd: cwd.into(),
|
||||||
|
env: Default::default(),
|
||||||
|
metadata: Default::default(),
|
||||||
|
worktree: None,
|
||||||
|
status: SessionStatus::Running,
|
||||||
|
created_at: Utc::now(),
|
||||||
|
foreground: None,
|
||||||
|
activity: Default::default(),
|
||||||
|
activity_detail: None,
|
||||||
|
last_activity_at: Utc::now(),
|
||||||
|
activity_since: Utc::now(),
|
||||||
|
current_cwd: None,
|
||||||
|
blocked_work: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_highlighted_asks_for_an_attach() {
|
fn attach_highlighted_asks_for_an_attach() {
|
||||||
let sid = Uuid::from_u128(11);
|
let sid = Uuid::from_u128(11);
|
||||||
@@ -1148,6 +1263,94 @@ mod tests {
|
|||||||
assert_eq!(app.state.active_tab().session(), Some(sid));
|
assert_eq!(app.state.active_tab().session(), Some(sid));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn group_highlight_is_not_a_session_action_target() {
|
||||||
|
use maestro_protocol::CatalogEvent;
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(14);
|
||||||
|
let mut app = make_app();
|
||||||
|
app.catalog.apply_event(CatalogEvent::Snapshot {
|
||||||
|
sessions: vec![test_session(sid, "/repo")],
|
||||||
|
});
|
||||||
|
app.dispatch(Action::SidebarNext);
|
||||||
|
assert_eq!(
|
||||||
|
app.state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/repo")))
|
||||||
|
);
|
||||||
|
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
|
||||||
|
assert!(app.dispatch(Action::KillHighlighted).is_empty());
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsing_the_group_containing_a_card_highlight_keeps_a_visible_group_highlight() {
|
||||||
|
let sid = Uuid::from_u128(15);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
|
||||||
|
assert!(app.dispatch(Action::ToggleSidebarGroup).is_empty());
|
||||||
|
assert_eq!(
|
||||||
|
app.state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||||
|
);
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/")));
|
||||||
|
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clicking_a_group_toggles_it_and_selects_the_group() {
|
||||||
|
let sid = Uuid::from_u128(16);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
|
||||||
|
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
|
||||||
|
let click = InputKey::Mouse {
|
||||||
|
kind: MouseKind::Click,
|
||||||
|
region: Region::SidebarRow,
|
||||||
|
col: 3,
|
||||||
|
row: 2,
|
||||||
|
};
|
||||||
|
assert!(app.handle_sidebar_mouse(&click));
|
||||||
|
assert_eq!(
|
||||||
|
app.state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/")))
|
||||||
|
);
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/")));
|
||||||
|
assert_eq!(app.state.focus, Focus::Sidebar);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn sidebar_click_dismisses_a_toast_before_handling_the_click() {
|
||||||
|
use crossterm::event::{MouseButton, MouseEvent, MouseEventKind};
|
||||||
|
|
||||||
|
let sid = Uuid::from_u128(17);
|
||||||
|
let mut app = app_with_highlighted_session(sid);
|
||||||
|
app.state.toast("notice");
|
||||||
|
app.region.sidebar = Some(ratatui::layout::Rect::new(0, 0, 28, 10));
|
||||||
|
app.region.sidebar_items = vec![SidebarItem::Group(PathBuf::from("/"))];
|
||||||
|
|
||||||
|
let quit = app
|
||||||
|
.handle_event(CtEvent::Mouse(MouseEvent {
|
||||||
|
kind: MouseEventKind::Down(MouseButton::Left),
|
||||||
|
column: 3,
|
||||||
|
row: 2,
|
||||||
|
modifiers: crossterm::event::KeyModifiers::NONE,
|
||||||
|
}))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(!quit);
|
||||||
|
assert!(app.state.overlay().is_none());
|
||||||
|
assert!(app
|
||||||
|
.state
|
||||||
|
.collapsed_sidebar_groups
|
||||||
|
.contains(&PathBuf::from("/")));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn confirming_a_kill_asks_for_the_kill() {
|
fn confirming_a_kill_asks_for_the_kill() {
|
||||||
let sid = Uuid::from_u128(12);
|
let sid = Uuid::from_u128(12);
|
||||||
|
|||||||
@@ -8,13 +8,15 @@
|
|||||||
|
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use maestro_config::TuiSection;
|
use maestro_config::{TuiSection, TuiTheme};
|
||||||
|
|
||||||
use crate::input::InputKey;
|
use crate::input::InputKey;
|
||||||
use crate::keymap::{Keymap, KeymapError, Mode};
|
use crate::keymap::{Keymap, KeymapError, Mode};
|
||||||
|
use crate::palette::Palette;
|
||||||
|
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct TuiConfig {
|
pub struct TuiConfig {
|
||||||
|
pub palette: Palette,
|
||||||
pub leader: InputKey,
|
pub leader: InputKey,
|
||||||
pub chord_timeout: Duration,
|
pub chord_timeout: Duration,
|
||||||
pub keymap: Keymap,
|
pub keymap: Keymap,
|
||||||
@@ -43,6 +45,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
|
|||||||
("k", "SidebarPrev"),
|
("k", "SidebarPrev"),
|
||||||
("down", "SidebarNext"),
|
("down", "SidebarNext"),
|
||||||
("up", "SidebarPrev"),
|
("up", "SidebarPrev"),
|
||||||
|
("c", "ToggleSidebarGroup"),
|
||||||
// Catalog actions
|
// Catalog actions
|
||||||
("enter", "AttachHighlighted"),
|
("enter", "AttachHighlighted"),
|
||||||
("s", "NewDraftPane"),
|
("s", "NewDraftPane"),
|
||||||
@@ -76,6 +79,11 @@ impl TuiConfig {
|
|||||||
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
|
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
|
||||||
/// applied first, then user overrides on top.
|
/// applied first, then user overrides on top.
|
||||||
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
|
pub fn load(section: &TuiSection) -> Result<Self, TuiConfigError> {
|
||||||
|
let palette = match section.theme.unwrap_or(TuiTheme::Dark) {
|
||||||
|
TuiTheme::Dark => Palette::DARK,
|
||||||
|
TuiTheme::Light => Palette::LIGHT,
|
||||||
|
TuiTheme::Ansi => Palette::ANSI,
|
||||||
|
};
|
||||||
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
|
||||||
let leader = InputKey::parse(leader_str)
|
let leader = InputKey::parse(leader_str)
|
||||||
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
|
||||||
@@ -131,6 +139,7 @@ impl TuiConfig {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Ok(TuiConfig {
|
Ok(TuiConfig {
|
||||||
|
palette,
|
||||||
leader,
|
leader,
|
||||||
chord_timeout,
|
chord_timeout,
|
||||||
keymap,
|
keymap,
|
||||||
@@ -143,6 +152,28 @@ impl TuiConfig {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::action::Action;
|
use crate::action::Action;
|
||||||
|
use maestro_config::{TuiSection, TuiTheme};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn absent_theme_selects_the_dark_default() {
|
||||||
|
let cfg = TuiConfig::load(&TuiSection::default()).unwrap();
|
||||||
|
assert_eq!(cfg.palette, Palette::DARK);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn named_themes_select_the_matching_complete_palette() {
|
||||||
|
for (theme, expected) in [
|
||||||
|
(TuiTheme::Dark, Palette::DARK),
|
||||||
|
(TuiTheme::Light, Palette::LIGHT),
|
||||||
|
(TuiTheme::Ansi, Palette::ANSI),
|
||||||
|
] {
|
||||||
|
let section = TuiSection {
|
||||||
|
theme: Some(theme),
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(TuiConfig::load(§ion).unwrap().palette, expected);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn defaults_cover_core_actions() {
|
fn defaults_cover_core_actions() {
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ use chrono::{DateTime, Utc};
|
|||||||
use maestro_config::{builtin_presets, custom_preset};
|
use maestro_config::{builtin_presets, custom_preset};
|
||||||
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, WorktreeSpec};
|
||||||
use ratatui::layout::{Constraint, Layout, Rect};
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -1510,7 +1510,7 @@ impl DraftPane {
|
|||||||
f.render_widget(
|
f.render_widget(
|
||||||
Paragraph::new(Line::from(Span::styled(
|
Paragraph::new(Line::from(Span::styled(
|
||||||
format!("error: {error}"),
|
format!("error: {error}"),
|
||||||
Style::default().fg(Color::Red),
|
crate::palette::active().error_style(),
|
||||||
))),
|
))),
|
||||||
row,
|
row,
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ pub mod fast_forward;
|
|||||||
pub mod input;
|
pub mod input;
|
||||||
pub mod keymap;
|
pub mod keymap;
|
||||||
pub mod overlays;
|
pub mod overlays;
|
||||||
|
pub mod palette;
|
||||||
pub mod pane;
|
pub mod pane;
|
||||||
pub mod projects;
|
pub mod projects;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
@@ -89,6 +90,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
|
|||||||
Err(_) => maestro_config::MaestroConfigFile::default(),
|
Err(_) => maestro_config::MaestroConfigFile::default(),
|
||||||
};
|
};
|
||||||
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
let tui_cfg = TuiConfig::load(&cfg_file.tui).map_err(|e| TuiError::Config(e.to_string()))?;
|
||||||
|
palette::set_active(tui_cfg.palette);
|
||||||
|
|
||||||
let catalog = CatalogStore::default();
|
let catalog = CatalogStore::default();
|
||||||
let ws_url = daemon
|
let ws_url = daemon
|
||||||
|
|||||||
@@ -0,0 +1,501 @@
|
|||||||
|
//! Named colour roles for the TUI.
|
||||||
|
//!
|
||||||
|
//! Every colour the TUI paints on its own chrome comes from here, named for
|
||||||
|
//! what it means rather than what it looks like: the card surface, the
|
||||||
|
//! selection surface, the panel border in its focused and unfocused forms,
|
||||||
|
//! the text weights (`fg` / `muted` / `dim`), and one role per
|
||||||
|
//! [`ActivityState`]. Call sites ask the palette for a role; they never spell
|
||||||
|
//! a colour out.
|
||||||
|
//!
|
||||||
|
//! Values are [`Color::Rgb`], so contrast is a property of the palette rather
|
||||||
|
//! than of the user's terminal theme — and can therefore be asserted (see
|
||||||
|
//! [`contrast_ratio`] and this module's tests).
|
||||||
|
//!
|
||||||
|
//! Each surface carries its own activity colours. A state colour picked for
|
||||||
|
//! the dark card background is not legible on the selection background, so
|
||||||
|
//! the selected card uses the selection surface's own state colours; the dot
|
||||||
|
//! keeps carrying the state either way (docs/SIDEBAR.md).
|
||||||
|
//!
|
||||||
|
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
|
||||||
|
//! [`Palette::ANSI`] uses terminal-provided ANSI colours.
|
||||||
|
//!
|
||||||
|
//! Out of scope: PTY cell colours. Those belong to the program running in the
|
||||||
|
//! pane and pass through `pane::translate_color` verbatim.
|
||||||
|
|
||||||
|
use maestro_protocol::ActivityState;
|
||||||
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
|
|
||||||
|
/// A background and the foreground roles legible on it.
|
||||||
|
///
|
||||||
|
/// The sidebar has two: the card surface every card sits on, and the
|
||||||
|
/// selection surface the highlighted card swaps in.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Surface {
|
||||||
|
/// The surface itself.
|
||||||
|
pub bg: Color,
|
||||||
|
/// Primary text on this surface.
|
||||||
|
pub fg: Color,
|
||||||
|
/// Secondary text — the blocked-work, exited, detail and command
|
||||||
|
/// fragments of a card's meta line.
|
||||||
|
pub muted: Color,
|
||||||
|
/// The quietest legible text on this surface, for decoration that must
|
||||||
|
/// not compete with content.
|
||||||
|
pub dim: Color,
|
||||||
|
/// `ActivityState::Active`.
|
||||||
|
pub active: Color,
|
||||||
|
/// `ActivityState::Idle`.
|
||||||
|
pub idle: Color,
|
||||||
|
/// `ActivityState::AwaitingInput`.
|
||||||
|
pub awaiting: Color,
|
||||||
|
/// `ActivityState::Error`.
|
||||||
|
pub error: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Surface {
|
||||||
|
/// The bare surface: a background with no foreground override.
|
||||||
|
pub fn base(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Primary text on this surface.
|
||||||
|
pub fn text_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg).fg(self.fg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Secondary text on this surface.
|
||||||
|
pub fn muted_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg).fg(self.muted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The quietest text on this surface.
|
||||||
|
pub fn dim_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.bg).fg(self.dim)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The colour carrying `state` on this surface.
|
||||||
|
pub fn activity(&self, state: ActivityState) -> Color {
|
||||||
|
match state {
|
||||||
|
ActivityState::Active => self.active,
|
||||||
|
ActivityState::Idle => self.idle,
|
||||||
|
ActivityState::AwaitingInput => self.awaiting,
|
||||||
|
ActivityState::Error => self.error,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every foreground role on this surface, paired with its role name.
|
||||||
|
/// Used by the contrast tests, and by anything that needs to reason about
|
||||||
|
/// the surface as a whole.
|
||||||
|
pub fn roles(&self) -> [(&'static str, Color); 7] {
|
||||||
|
[
|
||||||
|
("fg", self.fg),
|
||||||
|
("muted", self.muted),
|
||||||
|
("dim", self.dim),
|
||||||
|
("active", self.active),
|
||||||
|
("idle", self.idle),
|
||||||
|
("awaiting", self.awaiting),
|
||||||
|
("error", self.error),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The full set of colour roles the TUI paints with.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Palette {
|
||||||
|
/// The surface an unselected card sits on.
|
||||||
|
pub card: Surface,
|
||||||
|
/// The surface the highlighted card swaps in.
|
||||||
|
pub selection: Surface,
|
||||||
|
/// Background for TUI-owned chrome outside cards. Fixed-RGB chrome must
|
||||||
|
/// paint this background too; otherwise its contrast depends on the
|
||||||
|
/// operator's terminal theme.
|
||||||
|
pub chrome_bg: Color,
|
||||||
|
/// Panel border while that panel holds focus.
|
||||||
|
pub border_focused: Color,
|
||||||
|
/// Panel border while it does not.
|
||||||
|
pub border_unfocused: Color,
|
||||||
|
/// Focus affordances that are not the border itself — markers, titles and
|
||||||
|
/// the like on the panel that holds focus.
|
||||||
|
pub focus_accent: Color,
|
||||||
|
/// Sidebar group headings.
|
||||||
|
pub group_row: Color,
|
||||||
|
/// Status-bar mode indicator, normal mode.
|
||||||
|
pub mode_normal_bg: Color,
|
||||||
|
pub mode_normal_fg: Color,
|
||||||
|
/// Status-bar mode indicator, insert mode.
|
||||||
|
pub mode_insert_bg: Color,
|
||||||
|
pub mode_insert_fg: Color,
|
||||||
|
/// Error text outside a card — form errors, failed actions.
|
||||||
|
pub error_text: Color,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Palette {
|
||||||
|
/// The dark terminal-native palette and the default preset.
|
||||||
|
pub const DARK: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
fg: Color::Rgb(0xe5, 0xe7, 0xeb),
|
||||||
|
muted: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||||
|
dim: Color::Rgb(0x85, 0x8f, 0x9e),
|
||||||
|
active: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
idle: Color::Rgb(0xae, 0xb7, 0xc4),
|
||||||
|
awaiting: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||||
|
error: Color::Rgb(0xff, 0x82, 0x7a),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0x3d, 0x51, 0x44),
|
||||||
|
fg: Color::Rgb(0xf3, 0xf7, 0xf4),
|
||||||
|
muted: Color::Rgb(0xd0, 0xd9, 0xd3),
|
||||||
|
dim: Color::Rgb(0xa1, 0xb0, 0xa6),
|
||||||
|
active: Color::Rgb(0x9a, 0xf0, 0xa6),
|
||||||
|
idle: Color::Rgb(0xc4, 0xcf, 0xc7),
|
||||||
|
awaiting: Color::Rgb(0xf6, 0xd9, 0x78),
|
||||||
|
error: Color::Rgb(0xff, 0xa8, 0xa3),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
border_focused: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
border_unfocused: Color::Rgb(0x68, 0x73, 0x84),
|
||||||
|
focus_accent: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
group_row: Color::Rgb(0xb6, 0xc1, 0xd3),
|
||||||
|
mode_normal_bg: Color::Rgb(0x72, 0xd6, 0x87),
|
||||||
|
mode_normal_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
mode_insert_bg: Color::Rgb(0xf2, 0xc9, 0x5c),
|
||||||
|
mode_insert_fg: Color::Rgb(0x1f, 0x23, 0x2b),
|
||||||
|
error_text: Color::Rgb(0xff, 0x82, 0x7a),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A high-contrast light peer to [`Self::DARK`].
|
||||||
|
pub const LIGHT: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
fg: Color::Rgb(0x27, 0x31, 0x3d),
|
||||||
|
muted: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||||
|
dim: Color::Rgb(0x68, 0x78, 0x8a),
|
||||||
|
active: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
idle: Color::Rgb(0x4c, 0x5b, 0x6b),
|
||||||
|
awaiting: Color::Rgb(0x8f, 0x63, 0x00),
|
||||||
|
error: Color::Rgb(0xb4, 0x23, 0x18),
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Rgb(0xb5, 0xcf, 0xba),
|
||||||
|
fg: Color::Rgb(0x1d, 0x2a, 0x24),
|
||||||
|
muted: Color::Rgb(0x3f, 0x55, 0x45),
|
||||||
|
dim: Color::Rgb(0x52, 0x68, 0x5a),
|
||||||
|
active: Color::Rgb(0x15, 0x5b, 0x2e),
|
||||||
|
idle: Color::Rgb(0x3f, 0x55, 0x45),
|
||||||
|
awaiting: Color::Rgb(0x69, 0x46, 0x00),
|
||||||
|
error: Color::Rgb(0xa5, 0x1d, 0x14),
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
border_focused: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
border_unfocused: Color::Rgb(0x65, 0x73, 0x6b),
|
||||||
|
focus_accent: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
group_row: Color::Rgb(0x3f, 0x55, 0x45),
|
||||||
|
mode_normal_bg: Color::Rgb(0x21, 0x7a, 0x3c),
|
||||||
|
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
|
||||||
|
mode_insert_bg: Color::Rgb(0xb6, 0x81, 0x08),
|
||||||
|
mode_insert_fg: Color::Rgb(0x0b, 0x12, 0x0e),
|
||||||
|
error_text: Color::Rgb(0xb4, 0x23, 0x18),
|
||||||
|
};
|
||||||
|
|
||||||
|
/// A palette of named ANSI colours for terminals without truecolour.
|
||||||
|
pub const ANSI: Palette = Palette {
|
||||||
|
card: Surface {
|
||||||
|
bg: Color::Black,
|
||||||
|
fg: Color::White,
|
||||||
|
muted: Color::Gray,
|
||||||
|
dim: Color::DarkGray,
|
||||||
|
active: Color::Green,
|
||||||
|
idle: Color::Gray,
|
||||||
|
awaiting: Color::Yellow,
|
||||||
|
error: Color::Red,
|
||||||
|
},
|
||||||
|
selection: Surface {
|
||||||
|
bg: Color::Blue,
|
||||||
|
fg: Color::White,
|
||||||
|
muted: Color::White,
|
||||||
|
dim: Color::LightBlue,
|
||||||
|
active: Color::LightGreen,
|
||||||
|
idle: Color::White,
|
||||||
|
awaiting: Color::LightYellow,
|
||||||
|
error: Color::LightRed,
|
||||||
|
},
|
||||||
|
chrome_bg: Color::Black,
|
||||||
|
border_focused: Color::LightGreen,
|
||||||
|
border_unfocused: Color::DarkGray,
|
||||||
|
focus_accent: Color::LightGreen,
|
||||||
|
group_row: Color::LightCyan,
|
||||||
|
mode_normal_bg: Color::Green,
|
||||||
|
mode_normal_fg: Color::Black,
|
||||||
|
mode_insert_bg: Color::Yellow,
|
||||||
|
mode_insert_fg: Color::Black,
|
||||||
|
error_text: Color::LightRed,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// The default preset, retained as a named compatibility constant.
|
||||||
|
pub const DEFAULT: Palette = Self::DARK;
|
||||||
|
|
||||||
|
/// All preset names and their complete palettes.
|
||||||
|
pub fn presets() -> [(&'static str, Palette); 3] {
|
||||||
|
[
|
||||||
|
("dark", Self::DARK),
|
||||||
|
("light", Self::LIGHT),
|
||||||
|
("ansi", Self::ANSI),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a config name to a palette.
|
||||||
|
pub fn named(name: &str) -> Option<Palette> {
|
||||||
|
match name {
|
||||||
|
"dark" => Some(Self::DARK),
|
||||||
|
"light" => Some(Self::LIGHT),
|
||||||
|
"ansi" => Some(Self::ANSI),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every role, including the backgrounds it is painted against.
|
||||||
|
pub fn roles(&self) -> [(&'static str, Color); 26] {
|
||||||
|
[
|
||||||
|
("card.bg", self.card.bg),
|
||||||
|
("card.fg", self.card.fg),
|
||||||
|
("card.muted", self.card.muted),
|
||||||
|
("card.dim", self.card.dim),
|
||||||
|
("card.active", self.card.active),
|
||||||
|
("card.idle", self.card.idle),
|
||||||
|
("card.awaiting", self.card.awaiting),
|
||||||
|
("card.error", self.card.error),
|
||||||
|
("selection.bg", self.selection.bg),
|
||||||
|
("selection.fg", self.selection.fg),
|
||||||
|
("selection.muted", self.selection.muted),
|
||||||
|
("selection.dim", self.selection.dim),
|
||||||
|
("selection.active", self.selection.active),
|
||||||
|
("selection.idle", self.selection.idle),
|
||||||
|
("selection.awaiting", self.selection.awaiting),
|
||||||
|
("selection.error", self.selection.error),
|
||||||
|
("chrome_bg", self.chrome_bg),
|
||||||
|
("border_focused", self.border_focused),
|
||||||
|
("border_unfocused", self.border_unfocused),
|
||||||
|
("focus_accent", self.focus_accent),
|
||||||
|
("group_row", self.group_row),
|
||||||
|
("mode_normal_bg", self.mode_normal_bg),
|
||||||
|
("mode_normal_fg", self.mode_normal_fg),
|
||||||
|
("mode_insert_bg", self.mode_insert_bg),
|
||||||
|
("mode_insert_fg", self.mode_insert_fg),
|
||||||
|
("error_text", self.error_text),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The surface a card sits on: the selection surface when it is the
|
||||||
|
/// highlighted card, the card surface otherwise.
|
||||||
|
pub fn surface(&self, selected: bool) -> &Surface {
|
||||||
|
if selected {
|
||||||
|
&self.selection
|
||||||
|
} else {
|
||||||
|
&self.card
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Border style for a panel, by whether it holds focus.
|
||||||
|
pub fn border_style(&self, focused: bool) -> Style {
|
||||||
|
if focused {
|
||||||
|
Style::default().bg(self.chrome_bg).fg(self.border_focused)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.bg(self.chrome_bg)
|
||||||
|
.fg(self.border_unfocused)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for a sidebar group heading.
|
||||||
|
pub fn group_row_style(&self) -> Style {
|
||||||
|
Style::default()
|
||||||
|
.bg(self.chrome_bg)
|
||||||
|
.fg(self.group_row)
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for the status bar's mode indicator.
|
||||||
|
pub fn mode_style(&self, insert: bool) -> Style {
|
||||||
|
if insert {
|
||||||
|
Style::default()
|
||||||
|
.fg(self.mode_insert_fg)
|
||||||
|
.bg(self.mode_insert_bg)
|
||||||
|
} else {
|
||||||
|
Style::default()
|
||||||
|
.fg(self.mode_normal_fg)
|
||||||
|
.bg(self.mode_normal_bg)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Style for error text outside a card.
|
||||||
|
pub fn error_style(&self) -> Style {
|
||||||
|
Style::default().bg(self.chrome_bg).fg(self.error_text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Palette {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::DEFAULT
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn active_slot() -> &'static std::sync::RwLock<Palette> {
|
||||||
|
static ACTIVE: std::sync::OnceLock<std::sync::RwLock<Palette>> = std::sync::OnceLock::new();
|
||||||
|
ACTIVE.get_or_init(|| std::sync::RwLock::new(Palette::DEFAULT))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Set the palette used by the running TUI.
|
||||||
|
pub(crate) fn set_active(palette: Palette) {
|
||||||
|
*active_slot().write().expect("active palette lock poisoned") = palette;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The palette in force. Config-driven preset selection lands here at TUI
|
||||||
|
/// startup, before the first frame is drawn.
|
||||||
|
pub fn active() -> Palette {
|
||||||
|
*active_slot().read().expect("active palette lock poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
|
||||||
|
/// luminance — they are whatever the terminal theme says — so they return
|
||||||
|
/// `None` and cannot be contrast-checked.
|
||||||
|
pub fn relative_luminance(color: Color) -> Option<f64> {
|
||||||
|
let Color::Rgb(r, g, b) = color else {
|
||||||
|
return None;
|
||||||
|
};
|
||||||
|
fn channel(v: u8) -> f64 {
|
||||||
|
let v = v as f64 / 255.0;
|
||||||
|
if v <= 0.03928 {
|
||||||
|
v / 12.92
|
||||||
|
} else {
|
||||||
|
((v + 0.055) / 1.055).powf(2.4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some(0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// WCAG contrast ratio between two RGB roles, 1.0 (identical) to 21.0
|
||||||
|
/// (black on white). `None` if either colour is not RGB.
|
||||||
|
pub fn contrast_ratio(a: Color, b: Color) -> Option<f64> {
|
||||||
|
let (la, lb) = (relative_luminance(a)?, relative_luminance(b)?);
|
||||||
|
let (hi, lo) = if la >= lb { (la, lb) } else { (lb, la) };
|
||||||
|
Some((hi + 0.05) / (lo + 0.05))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const STATES: [ActivityState; 4] = [
|
||||||
|
ActivityState::Active,
|
||||||
|
ActivityState::Idle,
|
||||||
|
ActivityState::AwaitingInput,
|
||||||
|
ActivityState::Error,
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Text roles clear WCAG AA (4.5:1) against their own surface; `dim` is
|
||||||
|
/// decoration and only has to clear the 3:1 non-text threshold.
|
||||||
|
#[test]
|
||||||
|
fn every_role_is_legible_on_its_own_surface() {
|
||||||
|
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||||
|
for (surface_name, surface) in [("card", &p.card), ("selection", &p.selection)] {
|
||||||
|
for (role, color) in surface.roles() {
|
||||||
|
let ratio = contrast_ratio(surface.bg, color)
|
||||||
|
.unwrap_or_else(|| panic!("{preset}.{surface_name}.{role} must be RGB"));
|
||||||
|
let floor = if role == "dim" { 3.0 } else { 4.5 };
|
||||||
|
assert!(
|
||||||
|
ratio >= floor,
|
||||||
|
"{preset}.{surface_name}.{role} contrast {ratio:.2} is below {floor}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_preset_supplies_every_role() {
|
||||||
|
for (name, p) in Palette::presets() {
|
||||||
|
assert_eq!(p.roles().len(), 26, "{name} role set is incomplete");
|
||||||
|
}
|
||||||
|
assert_eq!(Palette::named("dark"), Some(Palette::DARK));
|
||||||
|
assert_eq!(Palette::named("light"), Some(Palette::LIGHT));
|
||||||
|
assert_eq!(Palette::named("ansi"), Some(Palette::ANSI));
|
||||||
|
assert_eq!(Palette::named("unknown"), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The selection background exists to be seen as a different surface, not
|
||||||
|
/// merely a slightly different shade of the card.
|
||||||
|
#[test]
|
||||||
|
fn selection_background_is_distinguishable_from_the_card_background() {
|
||||||
|
for (name, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||||
|
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= 1.6,
|
||||||
|
"{name} card and selection backgrounds differ by only {ratio:.2}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bug this palette fixes: a state colour chosen for the dark card
|
||||||
|
/// background is not legible on the selection background, so the
|
||||||
|
/// selection surface carries its own — distinct — state colours.
|
||||||
|
#[test]
|
||||||
|
fn selection_state_colours_are_not_the_card_state_colours() {
|
||||||
|
for (name, p) in Palette::presets() {
|
||||||
|
for state in STATES {
|
||||||
|
assert_ne!(
|
||||||
|
p.card.activity(state),
|
||||||
|
p.selection.activity(state),
|
||||||
|
"{name} {state:?} must have a selection-specific colour"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Other panels' roles are RGB too, so a preset swap moves all of them.
|
||||||
|
/// Foregrounds are checked against the chrome background they are
|
||||||
|
/// actually painted on; they must never rely on the terminal default.
|
||||||
|
#[test]
|
||||||
|
fn chrome_roles_are_rgb_values() {
|
||||||
|
for (preset, p) in [("dark", Palette::DARK), ("light", Palette::LIGHT)] {
|
||||||
|
for (name, color) in p.roles().into_iter().skip(16) {
|
||||||
|
assert!(
|
||||||
|
relative_luminance(color).is_some(),
|
||||||
|
"{preset}.{name} must be an RGB value"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// The mode indicator paints fg on bg; both pairs must stay readable.
|
||||||
|
for (name, fg, bg) in [
|
||||||
|
("normal", p.mode_normal_fg, p.mode_normal_bg),
|
||||||
|
("insert", p.mode_insert_fg, p.mode_insert_bg),
|
||||||
|
] {
|
||||||
|
let ratio = contrast_ratio(fg, bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= 4.5,
|
||||||
|
"{preset} mode {name} contrast {ratio:.2} is below 4.5"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
for (name, fg, floor) in [
|
||||||
|
("border_focused", p.border_focused, 3.0),
|
||||||
|
("border_unfocused", p.border_unfocused, 3.0),
|
||||||
|
("focus_accent", p.focus_accent, 3.0),
|
||||||
|
("group_row", p.group_row, 4.5),
|
||||||
|
("error_text", p.error_text, 4.5),
|
||||||
|
] {
|
||||||
|
let ratio = contrast_ratio(fg, p.chrome_bg).unwrap();
|
||||||
|
assert!(
|
||||||
|
ratio >= floor,
|
||||||
|
"{preset}.{name} contrast {ratio:.2} is below {floor} on chrome_bg"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn contrast_ratio_matches_known_values() {
|
||||||
|
let black = Color::Rgb(0, 0, 0);
|
||||||
|
let white = Color::Rgb(255, 255, 255);
|
||||||
|
assert!((contrast_ratio(black, white).unwrap() - 21.0).abs() < 0.01);
|
||||||
|
assert!((contrast_ratio(white, white).unwrap() - 1.0).abs() < 0.01);
|
||||||
|
assert_eq!(contrast_ratio(Color::Cyan, white), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -106,6 +106,9 @@ fn translate_cell_style(cell: &vt100::Cell) -> Style {
|
|||||||
s
|
s
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// PTY cell colours pass through verbatim: they belong to the program
|
||||||
|
/// running in the pane, not to the TUI's own chrome, so they are deliberately
|
||||||
|
/// not palette roles (see `crate::palette`).
|
||||||
fn translate_color(c: vt100::Color) -> Option<Color> {
|
fn translate_color(c: vt100::Color) -> Option<Color> {
|
||||||
match c {
|
match c {
|
||||||
vt100::Color::Default => None,
|
vt100::Color::Default => None,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! hit-test mouse clicks.
|
//! hit-test mouse clicks.
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
use ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||||
use ratatui::style::{Color, Style};
|
use ratatui::style::Style;
|
||||||
use ratatui::text::Span;
|
use ratatui::text::Span;
|
||||||
use ratatui::widgets::{Block, Borders, Paragraph};
|
use ratatui::widgets::{Block, Borders, Paragraph};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
@@ -11,9 +11,10 @@ use ratatui::Frame;
|
|||||||
use crate::input::Region;
|
use crate::input::Region;
|
||||||
use crate::keymap::Mode;
|
use crate::keymap::Mode;
|
||||||
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
use crate::overlays::{HelpOverlay, KillConfirm, Toast};
|
||||||
|
use crate::palette;
|
||||||
use crate::pane::Pane;
|
use crate::pane::Pane;
|
||||||
use crate::sidebar::Sidebar;
|
use crate::sidebar::Sidebar;
|
||||||
use crate::state::{AppState, Focus, OverlayKind};
|
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
|
||||||
use crate::tabs::TabBar;
|
use crate::tabs::TabBar;
|
||||||
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
||||||
|
|
||||||
@@ -22,6 +23,7 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView};
|
|||||||
#[derive(Debug, Default, Clone)]
|
#[derive(Debug, Default, Clone)]
|
||||||
pub struct RegionMap {
|
pub struct RegionMap {
|
||||||
pub sidebar: Option<Rect>,
|
pub sidebar: Option<Rect>,
|
||||||
|
pub sidebar_items: Vec<SidebarItem>,
|
||||||
pub tab_bar: Option<Rect>,
|
pub tab_bar: Option<Rect>,
|
||||||
pub pane: Option<Rect>,
|
pub pane: Option<Rect>,
|
||||||
pub modal: Option<Rect>,
|
pub modal: Option<Rect>,
|
||||||
@@ -51,6 +53,24 @@ impl RegionMap {
|
|||||||
}
|
}
|
||||||
Region::Unknown
|
Region::Unknown
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a mouse row against the rendered sidebar sequence. The title
|
||||||
|
/// occupies one inner row; groups occupy one row and visible cards two.
|
||||||
|
pub fn sidebar_item_at(&self, row: u16) -> Option<SidebarItem> {
|
||||||
|
let area = self.sidebar?;
|
||||||
|
let mut cursor = area.y.saturating_add(2);
|
||||||
|
for item in &self.sidebar_items {
|
||||||
|
let height = match item {
|
||||||
|
SidebarItem::Group(_) => 1,
|
||||||
|
SidebarItem::Session(_) => 2,
|
||||||
|
};
|
||||||
|
if row >= cursor && row < cursor.saturating_add(height) {
|
||||||
|
return Some(item.clone());
|
||||||
|
}
|
||||||
|
cursor = cursor.saturating_add(height);
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn inside(r: Rect, col: u16, row: u16) -> bool {
|
fn inside(r: Rect, col: u16, row: u16) -> bool {
|
||||||
@@ -62,6 +82,7 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
|
|||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct RenderInputs<'a> {
|
pub struct RenderInputs<'a> {
|
||||||
pub sidebar: &'a Sidebar,
|
pub sidebar: &'a Sidebar,
|
||||||
|
pub home_dir: &'a str,
|
||||||
pub tab_bar: &'a TabBar,
|
pub tab_bar: &'a TabBar,
|
||||||
pub pane: Option<&'a Pane>,
|
pub pane: Option<&'a Pane>,
|
||||||
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
/// Inline spawn form when the active tab's pane is a draft (issue #63).
|
||||||
@@ -101,44 +122,46 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
let right = cols[1];
|
let right = cols[1];
|
||||||
|
|
||||||
if inputs.state.sidebar_visible {
|
if inputs.state.sidebar_visible {
|
||||||
inputs
|
inputs.sidebar.render_with_focus_and_collapsed_home(
|
||||||
.sidebar
|
f,
|
||||||
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
|
sidebar_area,
|
||||||
|
inputs.state.focus == Focus::Sidebar,
|
||||||
|
&inputs.state.collapsed_sidebar_groups,
|
||||||
|
inputs.home_dir,
|
||||||
|
);
|
||||||
map.sidebar = Some(sidebar_area);
|
map.sidebar = Some(sidebar_area);
|
||||||
|
map.sidebar_items = inputs
|
||||||
|
.sidebar
|
||||||
|
.rendered_items_with_collapsed(&inputs.state.collapsed_sidebar_groups);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1-row tab bar + 1-row horizontal rule (doubles as the pane's focus
|
// The pane always owns its full border, so focus changes only recolour
|
||||||
// indicator) + flex pane area below. Saves the 4 rows the old
|
// chrome and never change the rectangles handed to the input router.
|
||||||
// `Length(3) + Borders::ALL` layout was eating before any content showed.
|
|
||||||
let rows = Layout::default()
|
let rows = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([
|
.constraints([Constraint::Length(1), Constraint::Min(0)])
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Length(1),
|
|
||||||
Constraint::Min(0),
|
|
||||||
])
|
|
||||||
.split(right);
|
.split(right);
|
||||||
let tab_area = rows[0];
|
let tab_area = rows[0];
|
||||||
let separator_area = rows[1];
|
let pane_area = rows[1];
|
||||||
let pane_area = rows[2];
|
|
||||||
|
|
||||||
inputs.tab_bar.render(f, tab_area);
|
inputs.tab_bar.render(f, tab_area);
|
||||||
map.tab_bar = Some(tab_area);
|
map.tab_bar = Some(tab_area);
|
||||||
|
|
||||||
let pane_focused = inputs.state.focus == Focus::Pane;
|
let pane_focused = inputs.state.focus == Focus::Pane;
|
||||||
f.render_widget(
|
let pane_block = Block::default()
|
||||||
Block::default()
|
.borders(Borders::ALL)
|
||||||
.borders(Borders::TOP)
|
.border_style(focus_style(pane_focused));
|
||||||
.border_style(focus_style(pane_focused)),
|
let pane_inner = pane_block.inner(pane_area);
|
||||||
separator_area,
|
f.render_widget(pane_block, pane_area);
|
||||||
);
|
|
||||||
if let Some(p) = inputs.pane {
|
if let Some(p) = inputs.pane {
|
||||||
p.render(f, pane_area);
|
p.render(f, pane_inner);
|
||||||
}
|
}
|
||||||
if let Some(d) = inputs.draft {
|
if let Some(d) = inputs.draft {
|
||||||
d.render(f, pane_area, pane_focused);
|
d.render(f, pane_inner, pane_focused);
|
||||||
}
|
}
|
||||||
map.pane = Some(pane_area);
|
// `RegionMap::pane` is also the PTY resize source. Keep it aligned with
|
||||||
|
// the cells the emulator actually owns, excluding the persistent border.
|
||||||
|
map.pane = Some(pane_inner);
|
||||||
|
|
||||||
// Status bar — always visible, never eclipsed by overlays so the user can
|
// Status bar — always visible, never eclipsed by overlays so the user can
|
||||||
// always see the current mode + key hints.
|
// always see the current mode + key hints.
|
||||||
@@ -193,14 +216,10 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
|
|||||||
map
|
map
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Border style for a panel based on whether it currently has focus.
|
/// Border style for a panel based on whether it currently has focus. Both
|
||||||
/// Focused = cyan; unfocused = the terminal default.
|
/// forms are palette roles (`border_focused` / `border_unfocused`).
|
||||||
fn focus_style(focused: bool) -> Style {
|
fn focus_style(focused: bool) -> Style {
|
||||||
if focused {
|
palette::active().border_style(focused)
|
||||||
Style::default().fg(Color::Cyan)
|
|
||||||
} else {
|
|
||||||
Style::default()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
/// Render the always-visible status bar at the bottom of the frame. Surfaces
|
||||||
@@ -219,10 +238,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
|
|||||||
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
|
||||||
Mode::Insert => "^a: back to normal",
|
Mode::Insert => "^a: back to normal",
|
||||||
};
|
};
|
||||||
let mode_style = match state.mode {
|
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
|
||||||
Mode::Normal => Style::default().fg(Color::Black).bg(Color::Cyan),
|
|
||||||
Mode::Insert => Style::default().fg(Color::Black).bg(Color::Yellow),
|
|
||||||
};
|
|
||||||
let line = ratatui::text::Line::from(vec![
|
let line = ratatui::text::Line::from(vec![
|
||||||
Span::styled(format!(" {mode_label} "), mode_style),
|
Span::styled(format!(" {mode_label} "), mode_style),
|
||||||
Span::raw(format!(" {focus_label} • {hint}")),
|
Span::raw(format!(" {focus_label} • {hint}")),
|
||||||
@@ -244,6 +260,7 @@ mod tests {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 20,
|
height: 20,
|
||||||
}),
|
}),
|
||||||
|
sidebar_items: Vec::new(),
|
||||||
tab_bar: Some(Rect {
|
tab_bar: Some(Rect {
|
||||||
x: 10,
|
x: 10,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -273,6 +290,7 @@ mod tests {
|
|||||||
width: 10,
|
width: 10,
|
||||||
height: 20,
|
height: 20,
|
||||||
}),
|
}),
|
||||||
|
sidebar_items: Vec::new(),
|
||||||
tab_bar: Some(Rect {
|
tab_bar: Some(Rect {
|
||||||
x: 10,
|
x: 10,
|
||||||
y: 0,
|
y: 0,
|
||||||
@@ -297,6 +315,40 @@ mod tests {
|
|||||||
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sidebar_mouse_rows_follow_group_and_two_line_card_heights() {
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
let group_a = SidebarItem::Group(PathBuf::from("/a"));
|
||||||
|
let card_a = SidebarItem::Session(Uuid::from_u128(1));
|
||||||
|
let group_b = SidebarItem::Group(PathBuf::from("/b"));
|
||||||
|
let card_b = SidebarItem::Session(Uuid::from_u128(2));
|
||||||
|
let map = RegionMap {
|
||||||
|
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||||
|
sidebar_items: vec![group_a.clone(), card_a.clone(), group_b.clone(), card_b],
|
||||||
|
..RegionMap::default()
|
||||||
|
};
|
||||||
|
assert_eq!(map.sidebar_item_at(2), Some(group_a));
|
||||||
|
assert_eq!(map.sidebar_item_at(3), Some(card_a.clone()));
|
||||||
|
assert_eq!(map.sidebar_item_at(4), Some(card_a));
|
||||||
|
assert_eq!(map.sidebar_item_at(5), Some(group_b));
|
||||||
|
|
||||||
|
let collapsed = RegionMap {
|
||||||
|
sidebar: Some(Rect::new(0, 0, 28, 10)),
|
||||||
|
sidebar_items: vec![
|
||||||
|
SidebarItem::Group(PathBuf::from("/a")),
|
||||||
|
SidebarItem::Group(PathBuf::from("/b")),
|
||||||
|
SidebarItem::Session(Uuid::from_u128(2)),
|
||||||
|
],
|
||||||
|
..RegionMap::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
collapsed.sidebar_item_at(3),
|
||||||
|
Some(SidebarItem::Group(PathBuf::from("/b")))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
/// Issue #63: an empty pane is a draft — the frame must show the spawn
|
||||||
/// form where the terminal would otherwise be.
|
/// form where the terminal would otherwise be.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -323,6 +375,7 @@ mod tests {
|
|||||||
let help = HelpOverlay { lines: Vec::new() };
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
let inputs = RenderInputs {
|
let inputs = RenderInputs {
|
||||||
sidebar: &sidebar,
|
sidebar: &sidebar,
|
||||||
|
home_dir: "",
|
||||||
tab_bar: &tab_bar,
|
tab_bar: &tab_bar,
|
||||||
pane: None,
|
pane: None,
|
||||||
draft: Some(draft),
|
draft: Some(draft),
|
||||||
@@ -357,4 +410,99 @@ mod tests {
|
|||||||
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
assert!(out.contains(s), "missing {s:?}:\n{out}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_rectangles_are_stable_when_focus_moves() {
|
||||||
|
use crate::overlays::HelpOverlay;
|
||||||
|
use crate::sidebar::Sidebar;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
};
|
||||||
|
let tab_bar = TabBar {
|
||||||
|
tab_names: vec!["1".into()],
|
||||||
|
active_idx: 0,
|
||||||
|
};
|
||||||
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
|
let draw_map = |focus| {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = focus;
|
||||||
|
let inputs = RenderInputs {
|
||||||
|
sidebar: &sidebar,
|
||||||
|
home_dir: "",
|
||||||
|
tab_bar: &tab_bar,
|
||||||
|
pane: None,
|
||||||
|
draft: None,
|
||||||
|
state: &state,
|
||||||
|
help: &help,
|
||||||
|
worktrees: None,
|
||||||
|
};
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
let mut map = None;
|
||||||
|
terminal.draw(|f| map = Some(draw(f, inputs))).unwrap();
|
||||||
|
map.unwrap()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidebar_focus = draw_map(Focus::Sidebar);
|
||||||
|
let pane_focus = draw_map(Focus::Pane);
|
||||||
|
assert_eq!(sidebar_focus.sidebar, pane_focus.sidebar);
|
||||||
|
assert_eq!(sidebar_focus.tab_bar, pane_focus.tab_bar);
|
||||||
|
assert_eq!(sidebar_focus.pane, pane_focus.pane);
|
||||||
|
assert_eq!(sidebar_focus.pane, Some(Rect::new(29, 2, 50, 20)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn panel_border_colours_follow_focus_without_restyling_the_pane() {
|
||||||
|
use crate::overlays::HelpOverlay;
|
||||||
|
use crate::sidebar::Sidebar;
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: HashSet::new(),
|
||||||
|
};
|
||||||
|
let tab_bar = TabBar {
|
||||||
|
tab_names: vec!["1".into()],
|
||||||
|
active_idx: 0,
|
||||||
|
};
|
||||||
|
let help = HelpOverlay { lines: Vec::new() };
|
||||||
|
let render = |focus| {
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.focus = focus;
|
||||||
|
let inputs = RenderInputs {
|
||||||
|
sidebar: &sidebar,
|
||||||
|
home_dir: "",
|
||||||
|
tab_bar: &tab_bar,
|
||||||
|
pane: None,
|
||||||
|
draft: None,
|
||||||
|
state: &state,
|
||||||
|
help: &help,
|
||||||
|
worktrees: None,
|
||||||
|
};
|
||||||
|
let backend = ratatui::backend::TestBackend::new(80, 24);
|
||||||
|
let mut terminal = ratatui::Terminal::new(backend).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| {
|
||||||
|
draw(f, inputs);
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
terminal.backend().buffer().clone()
|
||||||
|
};
|
||||||
|
|
||||||
|
let sidebar_focused = render(Focus::Sidebar);
|
||||||
|
let pane_focused = render(Focus::Pane);
|
||||||
|
let p = palette::active();
|
||||||
|
assert_eq!(sidebar_focused[(0, 0)].style().fg, Some(p.border_focused));
|
||||||
|
assert_eq!(pane_focused[(0, 0)].style().fg, Some(p.border_unfocused));
|
||||||
|
assert_eq!(
|
||||||
|
sidebar_focused[(28, 1)].style().fg,
|
||||||
|
Some(p.border_unfocused)
|
||||||
|
);
|
||||||
|
assert_eq!(pane_focused[(28, 1)].style().fg, Some(p.border_focused));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+542
-114
@@ -2,12 +2,13 @@
|
|||||||
//!
|
//!
|
||||||
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
|
//! Line 1: status glyph (● attached / ○ live / ✗ dead) + name.
|
||||||
//! Line 2: activity dot + duration in the state's colour, plus the
|
//! Line 2: activity dot + duration in the state's colour, plus the
|
||||||
//! blocked-work and exited fragments when they apply. Absent entirely when
|
//! adaptive detail, blocked-work, exited, and command fragments.
|
||||||
//! it has nothing to say.
|
|
||||||
|
|
||||||
use std::collections::HashSet;
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use chrono::Utc;
|
use chrono::Utc;
|
||||||
|
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
|
||||||
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
use maestro_protocol::{ActivityState, Session, SessionStatus};
|
||||||
use ratatui::layout::Rect;
|
use ratatui::layout::Rect;
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
@@ -16,8 +17,11 @@ use ratatui::widgets::{Block, Borders, Paragraph};
|
|||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
/// Time-in-state alone earns a meta line only once the session has sat in
|
use crate::palette;
|
||||||
/// its current activity state this long (docs/SIDEBAR.md).
|
use crate::state::SidebarItem;
|
||||||
|
|
||||||
|
/// Time-in-state earns a duration fragment once the session has sat in its
|
||||||
|
/// current activity state this long (docs/SIDEBAR.md).
|
||||||
const META_LINE_AFTER_SECS: i64 = 30;
|
const META_LINE_AFTER_SECS: i64 = 30;
|
||||||
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
|
/// A payload predating #102 defaults `activity_since` to the Unix epoch. Per
|
||||||
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
|
/// docs/SIDEBAR.md such a session contributes no duration — the dot still
|
||||||
@@ -30,43 +34,63 @@ const META_INDENT: &str = " ";
|
|||||||
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
|
||||||
const ACTIONS: &str = "⧉ ✕";
|
const ACTIONS: &str = "⧉ ✕";
|
||||||
|
|
||||||
/// Every card sits on this background so cards read as cards against the
|
/// Cards use the selection surface only while the sidebar owns focus. An
|
||||||
/// panel; the highlighted card swaps it for the focus background instead
|
/// unfocused highlight remains an edge-marked cursor without claiming the
|
||||||
/// (`Modifier::REVERSED` made the card unreadable against a background).
|
/// keyboard via a filled selection background.
|
||||||
const CARD_BG: Color = Color::Black;
|
fn card_base(highlighted: bool, focused: bool) -> Style {
|
||||||
fn card_base(highlighted: bool) -> Style {
|
palette::active()
|
||||||
if highlighted {
|
.surface(highlighted && focused)
|
||||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
.text_style()
|
||||||
} else {
|
|
||||||
Style::default().bg(CARD_BG)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Renderable sidebar state.
|
/// Renderable sidebar state.
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Sidebar {
|
pub struct Sidebar {
|
||||||
pub sessions: Vec<Session>,
|
pub sessions: Vec<Session>,
|
||||||
pub highlight: Option<Uuid>,
|
pub highlight: Option<SidebarItem>,
|
||||||
pub attached: HashSet<Uuid>,
|
pub attached: HashSet<Uuid>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct SidebarGroup {
|
||||||
|
pub key: PathBuf,
|
||||||
|
pub sessions: Vec<Session>,
|
||||||
|
pub collapsed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Sidebar {
|
impl Sidebar {
|
||||||
pub fn render(&self, f: &mut Frame, area: Rect) {
|
pub fn render(&self, f: &mut Frame, area: Rect) {
|
||||||
self.render_with_focus(f, area, false);
|
self.render_with_focus(f, area, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Same as [`Sidebar::render`] but tints the right-edge separator in the
|
/// Render the panel with a persistent border. Focus changes its colour
|
||||||
/// focus colour when `focused`. Box borders eat too many rows/cols for
|
/// and whether the highlighted card claims the selection surface, never
|
||||||
/// the v1 layout — a single vertical rule keeps the panel boundary
|
/// its geometry.
|
||||||
/// without stealing space.
|
|
||||||
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
|
||||||
let border_style = if focused {
|
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
|
||||||
Style::default().fg(Color::Cyan)
|
}
|
||||||
} else {
|
|
||||||
Style::default()
|
pub fn render_with_focus_and_collapsed(
|
||||||
};
|
&self,
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
focused: bool,
|
||||||
|
collapsed: &HashSet<PathBuf>,
|
||||||
|
) {
|
||||||
|
self.render_with_focus_and_collapsed_home(f, area, focused, collapsed, &home_dir());
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_with_focus_and_collapsed_home(
|
||||||
|
&self,
|
||||||
|
f: &mut Frame,
|
||||||
|
area: Rect,
|
||||||
|
focused: bool,
|
||||||
|
collapsed: &HashSet<PathBuf>,
|
||||||
|
home: &str,
|
||||||
|
) {
|
||||||
|
let border_style = palette::active().border_style(focused);
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::RIGHT)
|
.borders(Borders::ALL)
|
||||||
.border_style(border_style);
|
.border_style(border_style);
|
||||||
let inner = block.inner(area);
|
let inner = block.inner(area);
|
||||||
f.render_widget(block, area);
|
f.render_widget(block, area);
|
||||||
@@ -76,25 +100,117 @@ impl Sidebar {
|
|||||||
"sessions",
|
"sessions",
|
||||||
Style::default().add_modifier(Modifier::BOLD),
|
Style::default().add_modifier(Modifier::BOLD),
|
||||||
))];
|
))];
|
||||||
for s in &self.sessions {
|
for group in self.groups(collapsed) {
|
||||||
let highlighted = self.highlight == Some(s.id);
|
lines.push(Line::from(self.group_row(&group, width, focused, home)));
|
||||||
let base = card_base(highlighted);
|
if !group.collapsed {
|
||||||
lines.push(Line::from(self.name_row(s, width)));
|
for s in &group.sessions {
|
||||||
if let Some(meta) = self.meta_row(s, width) {
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
lines.push(Line::from(pad_to_width(meta, width, base)));
|
let base = card_base(highlighted, focused);
|
||||||
|
lines.push(Line::from(self.name_row(s, width, focused)));
|
||||||
|
let meta = self.meta_row(s, width, focused);
|
||||||
|
lines.push(Line::from(pad_to_width(meta, width, base)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
f.render_widget(Paragraph::new(lines), inner);
|
f.render_widget(Paragraph::new(lines), inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
|
/// Items in exactly the order and height they occupy on screen.
|
||||||
|
pub fn rendered_items(&self) -> Vec<SidebarItem> {
|
||||||
|
self.rendered_items_with_collapsed(&HashSet::new())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn rendered_items_with_collapsed(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarItem> {
|
||||||
|
self.groups(collapsed)
|
||||||
|
.into_iter()
|
||||||
|
.flat_map(|group| {
|
||||||
|
let mut items = vec![SidebarItem::Group(group.key)];
|
||||||
|
if !group.collapsed {
|
||||||
|
items.extend(
|
||||||
|
group
|
||||||
|
.sessions
|
||||||
|
.into_iter()
|
||||||
|
.map(|s| SidebarItem::Session(s.id)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
items
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn groups(&self, collapsed: &HashSet<PathBuf>) -> Vec<SidebarGroup> {
|
||||||
|
let mut grouped: BTreeMap<PathBuf, Vec<Session>> = BTreeMap::new();
|
||||||
|
for session in &self.sessions {
|
||||||
|
if let Some(key) = session_tree_path(session) {
|
||||||
|
grouped.entry(key).or_default().push(session.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
grouped
|
||||||
|
.into_iter()
|
||||||
|
.map(|(key, mut sessions)| {
|
||||||
|
sessions
|
||||||
|
.sort_by(|a, b| name_for(a).cmp(&name_for(b)).then_with(|| a.id.cmp(&b.id)));
|
||||||
|
SidebarGroup {
|
||||||
|
collapsed: collapsed.contains(&key),
|
||||||
|
key,
|
||||||
|
sessions,
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn group_row(
|
||||||
|
&self,
|
||||||
|
group: &SidebarGroup,
|
||||||
|
width: usize,
|
||||||
|
focused: bool,
|
||||||
|
home: &str,
|
||||||
|
) -> Vec<Span<'static>> {
|
||||||
|
let highlighted = self.highlight == Some(SidebarItem::Group(group.key.clone()));
|
||||||
|
let palette = palette::active();
|
||||||
|
let surface = palette.surface(highlighted && focused);
|
||||||
|
let base = Style::default().bg(surface.bg).fg(if highlighted {
|
||||||
|
surface.fg
|
||||||
|
} else {
|
||||||
|
palette.group_row
|
||||||
|
});
|
||||||
|
let edge = highlighted && !focused;
|
||||||
|
let prefix = if edge { "▏ " } else { " " };
|
||||||
|
let chevron = if group.collapsed { "▸ " } else { "▾ " };
|
||||||
|
let path = abbreviate_dir(&group.key, home);
|
||||||
|
let name = abbreviate_folder_name(&path);
|
||||||
|
let count = format!(" ({})", group.sessions.len());
|
||||||
|
let prefix = Span::styled(
|
||||||
|
prefix,
|
||||||
|
if edge {
|
||||||
|
base.fg(palette.focus_accent)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
},
|
||||||
|
);
|
||||||
|
let chevron = Span::styled(chevron, base);
|
||||||
|
let count = Span::styled(count, surface.muted_style());
|
||||||
|
let label_budget = width.saturating_sub(prefix.width() + chevron.width() + count.width());
|
||||||
|
let mut label = Vec::new();
|
||||||
|
if let Some(stem) = name.stem {
|
||||||
|
label.push(Span::styled(format!("{stem}/"), surface.dim_style()));
|
||||||
|
}
|
||||||
|
label.push(Span::styled(name.last, base));
|
||||||
|
|
||||||
|
let mut spans = vec![prefix, chevron];
|
||||||
|
spans.extend(fit_to_width(label, label_budget));
|
||||||
|
spans.push(count);
|
||||||
|
pad_to_width(spans, width, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn name_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||||
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
let glyph = match (s.status, self.attached.contains(&s.id)) {
|
||||||
(SessionStatus::Dead, _) => "✗",
|
(SessionStatus::Dead, _) => "✗",
|
||||||
(SessionStatus::Running, true) => "●",
|
(SessionStatus::Running, true) => "●",
|
||||||
(SessionStatus::Running, false) => "○",
|
(SessionStatus::Running, false) => "○",
|
||||||
};
|
};
|
||||||
let highlighted = self.highlight == Some(s.id);
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
let base = card_base(highlighted);
|
let base = card_base(highlighted, focused);
|
||||||
// Line-1 actions are keyboard affordances shown on the highlighted
|
// Line-1 actions are keyboard affordances shown on the highlighted
|
||||||
// row only; they reserve their width so the name truncates with `…`
|
// row only; they reserve their width so the name truncates with `…`
|
||||||
// before it can ever reach them (docs/SIDEBAR.md).
|
// before it can ever reach them (docs/SIDEBAR.md).
|
||||||
@@ -110,8 +226,16 @@ impl Sidebar {
|
|||||||
let sep = if highlighted { 1 } else { 0 };
|
let sep = if highlighted { 1 } else { 0 };
|
||||||
let name_budget = usable.saturating_sub(prefix + sep);
|
let name_budget = usable.saturating_sub(prefix + sep);
|
||||||
|
|
||||||
|
let edge = highlighted && !focused;
|
||||||
let mut spans: Vec<Span<'static>> = vec![
|
let mut spans: Vec<Span<'static>> = vec![
|
||||||
Span::styled(INDENT, base),
|
Span::styled(
|
||||||
|
if edge { "▏ " } else { INDENT },
|
||||||
|
if edge {
|
||||||
|
base.fg(palette::active().focus_accent)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
},
|
||||||
|
),
|
||||||
Span::styled(format!("{glyph} "), base),
|
Span::styled(format!("{glyph} "), base),
|
||||||
];
|
];
|
||||||
spans.extend(
|
spans.extend(
|
||||||
@@ -121,10 +245,7 @@ impl Sidebar {
|
|||||||
);
|
);
|
||||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||||
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
|
||||||
spans.push(Span::styled(
|
spans.push(Span::styled(" ".repeat(fill), base));
|
||||||
" ".repeat(fill),
|
|
||||||
Style::default().bg(base.bg.unwrap_or(Color::Reset)),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
if highlighted {
|
if highlighted {
|
||||||
spans.push(Span::styled(ACTIONS, base));
|
spans.push(Span::styled(ACTIONS, base));
|
||||||
@@ -132,8 +253,8 @@ impl Sidebar {
|
|||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The adaptive second line: `<dot> <duration> [<blocked work> | exited]`.
|
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
|
||||||
fn meta_row(&self, s: &Session, width: usize) -> Option<Vec<Span<'static>>> {
|
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
|
||||||
let now = Utc::now();
|
let now = Utc::now();
|
||||||
let held = held_for(s, now);
|
let held = held_for(s, now);
|
||||||
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
|
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
|
||||||
@@ -141,28 +262,29 @@ impl Sidebar {
|
|||||||
// on line 1; the kill confirm names the specifics.
|
// on line 1; the kill confirm names the specifics.
|
||||||
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
let blocked = s.blocked_work.filter(|b| !b.is_clear());
|
||||||
let dead = s.status == SessionStatus::Dead;
|
let dead = s.status == SessionStatus::Dead;
|
||||||
let errored = s.activity == ActivityState::Error;
|
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
|
||||||
if (held.is_none() || held_secs < META_LINE_AFTER_SECS)
|
let base = card_base(highlighted, focused);
|
||||||
&& blocked.is_none()
|
let dot_style =
|
||||||
&& !dead
|
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
|
||||||
&& !errored
|
let muted = palette::active()
|
||||||
{
|
.surface(highlighted && focused)
|
||||||
return None;
|
.muted_style();
|
||||||
}
|
let edge = highlighted && !focused;
|
||||||
let base = card_base(self.highlight == Some(s.id));
|
|
||||||
let dot_style = base.patch(Style::default().fg(activity_color(s.activity)));
|
|
||||||
let muted = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
|
||||||
let mut spans = vec![
|
let mut spans = vec![
|
||||||
Span::styled(META_INDENT, base),
|
Span::styled(
|
||||||
|
if edge { " ▏ " } else { META_INDENT },
|
||||||
|
if edge {
|
||||||
|
base.fg(palette::active().focus_accent)
|
||||||
|
} else {
|
||||||
|
base
|
||||||
|
},
|
||||||
|
),
|
||||||
Span::styled("●", dot_style),
|
Span::styled("●", dot_style),
|
||||||
];
|
];
|
||||||
if let Some(d) = held {
|
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
|
||||||
spans.push(Span::styled(" ", base));
|
spans.push(Span::styled(" ", base));
|
||||||
spans.push(Span::styled(format_state_duration(d), dot_style));
|
spans.push(Span::styled(format_state_duration(d), dot_style));
|
||||||
}
|
}
|
||||||
// The trailing fragments are measured up front so a long detail
|
|
||||||
// truncates ahead of them instead of pushing them off the line
|
|
||||||
// (docs/SIDEBAR.md).
|
|
||||||
let mut tail: Vec<Span<'static>> = Vec::new();
|
let mut tail: Vec<Span<'static>> = Vec::new();
|
||||||
if let Some(b) = blocked {
|
if let Some(b) = blocked {
|
||||||
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
|
tail.push(Span::styled(format!(" ⚠ {b}"), muted));
|
||||||
@@ -170,29 +292,40 @@ impl Sidebar {
|
|||||||
if dead {
|
if dead {
|
||||||
tail.push(Span::styled(" exited", muted));
|
tail.push(Span::styled(" exited", muted));
|
||||||
}
|
}
|
||||||
|
let command = Span::styled(format!(" {}", s.command), muted);
|
||||||
|
|
||||||
|
// Reserve the triggered status fragments before detail and command.
|
||||||
|
// If the row is exceptionally narrow, keep one column for the
|
||||||
|
// unconditional command and truncate the status tail first. Detail
|
||||||
|
// then receives only space left after the full command reservation;
|
||||||
|
// when the full command cannot fit, it is the final truncation target.
|
||||||
|
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||||
|
let available = width.saturating_sub(head_w);
|
||||||
|
let command_floor = usize::from(available > 0);
|
||||||
|
let tail = fit_to_width(tail, available.saturating_sub(command_floor));
|
||||||
|
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
||||||
|
let command_w = command.width().min(available.saturating_sub(tail_w));
|
||||||
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
if let Some(detail) = s.activity_detail.as_deref().filter(|d| !d.is_empty()) {
|
||||||
let head_w: usize = spans.iter().map(|sp| sp.width()).sum();
|
let budget = available.saturating_sub(tail_w + command_w);
|
||||||
let tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
|
|
||||||
let budget = width.saturating_sub(head_w + tail_w);
|
|
||||||
spans.extend(fit_to_width(
|
spans.extend(fit_to_width(
|
||||||
vec![Span::styled(format!(" {detail}"), muted)],
|
vec![Span::styled(format!(" {detail}"), muted)],
|
||||||
budget,
|
budget,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
spans.extend(tail);
|
spans.extend(tail);
|
||||||
Some(fit_to_width(spans, width))
|
spans.extend(fit_to_width(vec![command], command_w));
|
||||||
|
spans
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// ratatui colour for an [`ActivityState`], per docs/SIDEBAR.md. The state
|
/// Palette role carrying an [`ActivityState`], per docs/SIDEBAR.md. The
|
||||||
/// word is never spelled out — this colour carries it.
|
/// state word is never spelled out — this colour carries it. The highlighted
|
||||||
fn activity_color(state: ActivityState) -> Color {
|
/// card takes its state colours from the selection surface: a colour picked
|
||||||
match state {
|
/// for the dark card background is not legible on the selection background.
|
||||||
ActivityState::Active => Color::Green,
|
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
|
||||||
ActivityState::Idle => Color::DarkGray,
|
palette::active()
|
||||||
ActivityState::AwaitingInput => Color::Yellow,
|
.surface(highlighted && focused)
|
||||||
ActivityState::Error => Color::Red,
|
.activity(state)
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
|
||||||
@@ -246,8 +379,7 @@ fn fit_to_width(spans: Vec<Span<'static>>, max: usize) -> Vec<Span<'static>> {
|
|||||||
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
|
fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec<Span<'static>> {
|
||||||
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
let used: usize = spans.iter().map(|sp| sp.width()).sum();
|
||||||
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
|
if let Some(fill) = width.checked_sub(used).filter(|&n| n > 0) {
|
||||||
let pad_style = Style::default().bg(base.bg.unwrap_or(Color::Reset));
|
spans.push(Span::styled(" ".repeat(fill), base));
|
||||||
spans.push(Span::styled(" ".repeat(fill), pad_style));
|
|
||||||
}
|
}
|
||||||
spans
|
spans
|
||||||
}
|
}
|
||||||
@@ -261,6 +393,10 @@ fn name_for(s: &Session) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn home_dir() -> String {
|
||||||
|
std::env::var("HOME").unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -294,9 +430,24 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
|
fn draw(sidebar: &Sidebar, w: u16, h: u16) -> Buffer {
|
||||||
|
draw_with_focus(sidebar, w, h, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_with_focus(sidebar: &Sidebar, w: u16, h: u16, focused: bool) -> Buffer {
|
||||||
let backend = TestBackend::new(w, h);
|
let backend = TestBackend::new(w, h);
|
||||||
let mut terminal = Terminal::new(backend).unwrap();
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
terminal.draw(|f| sidebar.render(f, f.area())).unwrap();
|
terminal
|
||||||
|
.draw(|f| sidebar.render_with_focus(f, f.area(), focused))
|
||||||
|
.unwrap();
|
||||||
|
terminal.backend().buffer().clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_collapsed(sidebar: &Sidebar, w: u16, h: u16, collapsed: &HashSet<PathBuf>) -> Buffer {
|
||||||
|
let backend = TestBackend::new(w, h);
|
||||||
|
let mut terminal = Terminal::new(backend).unwrap();
|
||||||
|
terminal
|
||||||
|
.draw(|f| sidebar.render_with_focus_and_collapsed(f, f.area(), true, collapsed))
|
||||||
|
.unwrap();
|
||||||
terminal.backend().buffer().clone()
|
terminal.backend().buffer().clone()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,7 +473,10 @@ mod tests {
|
|||||||
|
|
||||||
/// Row text without the trailing space padding and right border.
|
/// Row text without the trailing space padding and right border.
|
||||||
fn content(row: &str) -> &str {
|
fn content(row: &str) -> &str {
|
||||||
row.trim_end().trim_end_matches('│').trim_end()
|
row.trim()
|
||||||
|
.trim_start_matches('│')
|
||||||
|
.trim_end_matches('│')
|
||||||
|
.trim()
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -343,6 +497,102 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn groups_worktrees_under_their_base_repository() {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.cwd = "/home/u/repos/worktrees/feature".into();
|
||||||
|
s.worktree = Some(maestro_protocol::SessionWorktree {
|
||||||
|
path: s.cwd.clone(),
|
||||||
|
base_repo: "/home/u/repos/maestro".into(),
|
||||||
|
});
|
||||||
|
let groups = (Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
})
|
||||||
|
.groups(&HashSet::new());
|
||||||
|
assert_eq!(groups[0].key, PathBuf::from("/home/u/repos/maestro"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn does_not_render_groups_without_sessions() {
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: Vec::new(),
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
assert!(sidebar.groups(&HashSet::new()).is_empty());
|
||||||
|
assert!(sidebar.rendered_items().is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsing_a_group_hides_cards_but_keeps_the_count_and_item() {
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: vec![session_named(1, "claude"), session_named(2, "bash")],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let key = PathBuf::from("/tmp");
|
||||||
|
let mut collapsed = HashSet::new();
|
||||||
|
collapsed.insert(key.clone());
|
||||||
|
let groups = sidebar.groups(&collapsed);
|
||||||
|
assert_eq!(groups[0].sessions.len(), 2);
|
||||||
|
assert!(groups[0].collapsed);
|
||||||
|
assert_eq!(
|
||||||
|
sidebar.rendered_items_with_collapsed(&collapsed),
|
||||||
|
vec![SidebarItem::Group(key)]
|
||||||
|
);
|
||||||
|
let rendered = render_to_string(&draw_collapsed(&sidebar, 40, 6, &collapsed));
|
||||||
|
assert!(rendered.contains("(2)"));
|
||||||
|
assert!(!rendered.contains("claude"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_group_labels_yield_to_the_always_visible_count() {
|
||||||
|
let mut session = session_named(1, "claude");
|
||||||
|
session.cwd = "/very/long/project/path/extraordinarily-long-project-name".into();
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: vec![session],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let key = PathBuf::from("/very/long/project/path/extraordinarily-long-project-name");
|
||||||
|
let collapsed = HashSet::from([key]);
|
||||||
|
let rendered = render_to_string(&draw_collapsed(&sidebar, 28, 6, &collapsed));
|
||||||
|
|
||||||
|
assert!(rendered.contains('…'));
|
||||||
|
assert!(
|
||||||
|
rendered.contains("(1)"),
|
||||||
|
"group count was truncated: {rendered:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shared_display_helpers_abbreviate_group_labels() {
|
||||||
|
let path = maestro_protocol::display::abbreviate_dir(
|
||||||
|
std::path::Path::new("/home/u/git.naps.pt/naps62/maestro"),
|
||||||
|
"/home/u",
|
||||||
|
);
|
||||||
|
let name = maestro_protocol::display::abbreviate_folder_name(&path);
|
||||||
|
assert_eq!(path, "~/git.naps.pt/naps62/maestro");
|
||||||
|
assert_eq!(name.stem.as_deref(), Some("~/g/n"));
|
||||||
|
assert_eq!(name.last, "maestro");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rendered_cards_are_always_two_lines_after_one_group_row() {
|
||||||
|
let sidebar = Sidebar {
|
||||||
|
sessions: vec![session_named(1, "claude")],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sidebar, 40, 8));
|
||||||
|
assert!(all[2].contains("/tmp"));
|
||||||
|
assert!(all[3].contains("claude"));
|
||||||
|
assert!(all[4].contains("bash"));
|
||||||
|
assert!(!all[5].contains("bash"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attached_session_uses_filled_glyph() {
|
fn attached_session_uses_filled_glyph() {
|
||||||
let sid = Uuid::from_u128(1);
|
let sid = Uuid::from_u128(1);
|
||||||
@@ -418,10 +668,10 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A freshly-active session stays one line — never an empty placeholder
|
/// A fresh session still gets its unconditional second line, but no
|
||||||
/// row (docs/SIDEBAR.md).
|
/// duration until it has been held in state for 30 seconds.
|
||||||
#[test]
|
#[test]
|
||||||
fn freshly_active_session_stays_one_line() {
|
fn fresh_session_renders_two_lines_without_duration() {
|
||||||
let mut s = session_named(1, "claude");
|
let mut s = session_named(1, "claude");
|
||||||
s.activity_since = Utc::now() - Duration::seconds(10);
|
s.activity_since = Utc::now() - Duration::seconds(10);
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
@@ -430,12 +680,22 @@ mod tests {
|
|||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
assert_eq!(content(&all[0]), "sessions");
|
assert_eq!(content(&all[1]), "sessions");
|
||||||
assert!(row_containing(&all, "claude").contains('○'));
|
assert!(row_containing(&all, "claude").contains('○'));
|
||||||
assert!(
|
assert!(
|
||||||
content(&all[2]).is_empty(),
|
all[4].contains('●'),
|
||||||
"line 2 must be absent, got {:?}",
|
"line 2 must carry the dot: {:?}",
|
||||||
all[2]
|
all[3]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all[4].contains("bash"),
|
||||||
|
"line 2 must carry the command: {:?}",
|
||||||
|
all[3]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!all[4].contains("10s"),
|
||||||
|
"fresh cards have no duration: {:?}",
|
||||||
|
all[3]
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -474,12 +734,13 @@ mod tests {
|
|||||||
/// is never spelled out (docs/SIDEBAR.md colour table).
|
/// is never spelled out (docs/SIDEBAR.md colour table).
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
fn meta_line_dot_uses_the_contract_colour_per_state() {
|
||||||
for (state, expected) in [
|
for state in [
|
||||||
(ActivityState::Active, Color::Green),
|
ActivityState::Active,
|
||||||
(ActivityState::Idle, Color::DarkGray),
|
ActivityState::Idle,
|
||||||
(ActivityState::AwaitingInput, Color::Yellow),
|
ActivityState::AwaitingInput,
|
||||||
(ActivityState::Error, Color::Red),
|
ActivityState::Error,
|
||||||
] {
|
] {
|
||||||
|
let expected = palette::active().card.activity(state);
|
||||||
let mut s = session_named(1, "claude");
|
let mut s = session_named(1, "claude");
|
||||||
s.activity = state;
|
s.activity = state;
|
||||||
s.activity_since = Utc::now() - Duration::seconds(120);
|
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||||
@@ -495,6 +756,54 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The selected card keeps carrying its state on the dot, but in the
|
||||||
|
/// selection surface's own colours. A state colour picked for the dark
|
||||||
|
/// card background painted onto the selection background is the contrast
|
||||||
|
/// bug this palette fixes — this test fails if it comes back.
|
||||||
|
#[test]
|
||||||
|
fn selected_card_never_paints_a_card_state_colour_on_the_selection_background() {
|
||||||
|
let p = palette::active();
|
||||||
|
for state in [
|
||||||
|
ActivityState::Active,
|
||||||
|
ActivityState::Idle,
|
||||||
|
ActivityState::AwaitingInput,
|
||||||
|
ActivityState::Error,
|
||||||
|
] {
|
||||||
|
let mut s = session_named(1, "claude");
|
||||||
|
s.activity = state;
|
||||||
|
s.activity_since = Utc::now() - Duration::seconds(120);
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let buf = draw(&sb, 28, 8);
|
||||||
|
let on_selection = |fg: Color| {
|
||||||
|
(0..buf.area.height).any(|y| {
|
||||||
|
(0..buf.area.width).any(|x| {
|
||||||
|
let st = buf[(x, y)].style();
|
||||||
|
st.bg == Some(p.selection.bg) && st.fg == Some(fg)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
};
|
||||||
|
assert!(
|
||||||
|
!on_selection(p.card.activity(state)),
|
||||||
|
"{state:?}: card surface colour {:?} painted onto the selection background",
|
||||||
|
p.card.activity(state)
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
on_selection(p.selection.activity(state)),
|
||||||
|
"{state:?}: the dot must still carry the state on the selected card"
|
||||||
|
);
|
||||||
|
let ratio = palette::contrast_ratio(p.selection.bg, p.selection.activity(state))
|
||||||
|
.expect("palette roles are RGB");
|
||||||
|
assert!(
|
||||||
|
ratio >= 4.5,
|
||||||
|
"{state:?} on the selection background is only {ratio:.2}:1"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
|
/// Rule 3 of docs/SIDEBAR.md: a dead session always gets a meta line,
|
||||||
/// even freshly dead, with the muted `exited` fragment.
|
/// even freshly dead, with the muted `exited` fragment.
|
||||||
#[test]
|
#[test]
|
||||||
@@ -516,11 +825,12 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The sidebar is narrow: the meta line truncates with `…` and never
|
/// The sidebar is narrow: the command truncates with `…` and never wraps
|
||||||
/// wraps onto a third row (docs/SIDEBAR.md).
|
/// onto a third row (docs/SIDEBAR.md).
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_line_truncates_instead_of_wrapping() {
|
fn meta_line_command_truncates_instead_of_wrapping() {
|
||||||
let mut s = session_named(1, "claude");
|
let mut s = session_named(1, "claude");
|
||||||
|
s.command = "a command that cannot fit".into();
|
||||||
s.activity_since = Utc::now() - Duration::seconds(5);
|
s.activity_since = Utc::now() - Duration::seconds(5);
|
||||||
s.blocked_work = Some(InTheWay {
|
s.blocked_work = Some(InTheWay {
|
||||||
uncommitted_files: 120,
|
uncommitted_files: 120,
|
||||||
@@ -536,7 +846,7 @@ mod tests {
|
|||||||
let meta = content(&all[idx + 1]);
|
let meta = content(&all[idx + 1]);
|
||||||
assert!(
|
assert!(
|
||||||
meta.trim_end().ends_with('…'),
|
meta.trim_end().ends_with('…'),
|
||||||
"truncated meta line must end with …: {meta:?}"
|
"truncated command must end with …: {meta:?}"
|
||||||
);
|
);
|
||||||
assert!(
|
assert!(
|
||||||
content(&all[idx + 2]).trim().is_empty(),
|
content(&all[idx + 2]).trim().is_empty(),
|
||||||
@@ -613,7 +923,7 @@ mod tests {
|
|||||||
highlight: None,
|
highlight: None,
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 40, 8));
|
let all = rows(&draw(&sb, 42, 8));
|
||||||
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
let idx = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
let meta = content(&all[idx + 1]);
|
let meta = content(&all[idx + 1]);
|
||||||
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
|
||||||
@@ -651,10 +961,6 @@ mod tests {
|
|||||||
rendered.contains("exited"),
|
rendered.contains("exited"),
|
||||||
"dead session must still show line 2:\n{rendered}"
|
"dead session must still show line 2:\n{rendered}"
|
||||||
);
|
);
|
||||||
assert!(
|
|
||||||
!rendered.contains('h') || !rendered.contains("● "),
|
|
||||||
"no duration may follow the dot for an epoch stamp:\n{rendered}"
|
|
||||||
);
|
|
||||||
for line in rendered.lines().filter(|l| l.contains('●')) {
|
for line in rendered.lines().filter(|l| l.contains('●')) {
|
||||||
assert!(
|
assert!(
|
||||||
!line
|
!line
|
||||||
@@ -699,6 +1005,90 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn command_renders_on_line_two_even_when_name_is_metadata() {
|
||||||
|
let s = session_named(1, "friendly name");
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 40, 6));
|
||||||
|
assert!(
|
||||||
|
all[3].contains("friendly name"),
|
||||||
|
"name must stay on line 1: {:?}",
|
||||||
|
all[2]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all[4].contains("bash"),
|
||||||
|
"command must be on line 2: {:?}",
|
||||||
|
all[3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn narrow_meta_line_keeps_dot_and_truncates_command() {
|
||||||
|
let s = session_named(1, "name");
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 10, 6));
|
||||||
|
assert!(
|
||||||
|
all[4].contains('●'),
|
||||||
|
"dot must survive narrow cards: {:?}",
|
||||||
|
all[3]
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
all[4].contains('…'),
|
||||||
|
"command should truncate: {:?}",
|
||||||
|
all[3]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn blocked_warning_survives_before_a_truncated_command() {
|
||||||
|
let mut s = session_named(1, "name");
|
||||||
|
s.command = "a command that cannot fit".into();
|
||||||
|
s.blocked_work = Some(InTheWay {
|
||||||
|
uncommitted_files: 1,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 29, 6));
|
||||||
|
assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
|
||||||
|
let meta = content(&all[4]);
|
||||||
|
assert!(meta.ends_with('…'), "command must truncate: {meta:?}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn long_detail_yields_to_status_and_command() {
|
||||||
|
let mut s = session_named(1, "name");
|
||||||
|
s.activity_detail = Some("a very long agent-reported detail".into());
|
||||||
|
s.blocked_work = Some(InTheWay {
|
||||||
|
uncommitted_files: 1,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![s],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let all = rows(&draw(&sb, 40, 6));
|
||||||
|
assert!(all[4].contains("⚠ 1 uncommitted file"), "{:?}", all[4]);
|
||||||
|
assert!(
|
||||||
|
all[4].contains("bash"),
|
||||||
|
"command must survive: {:?}",
|
||||||
|
all[3]
|
||||||
|
);
|
||||||
|
assert!(all[4].contains('…'), "detail must truncate: {:?}", all[4]);
|
||||||
|
}
|
||||||
|
|
||||||
/// Reversed video makes a card unreadable against a background; cards
|
/// Reversed video makes a card unreadable against a background; cards
|
||||||
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
|
||||||
#[test]
|
#[test]
|
||||||
@@ -707,7 +1097,7 @@ mod tests {
|
|||||||
highlighted.activity_since = Utc::now() - Duration::seconds(120);
|
highlighted.activity_since = Utc::now() - Duration::seconds(120);
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![highlighted],
|
sessions: vec![highlighted],
|
||||||
highlight: Some(Uuid::from_u128(1)),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
@@ -732,36 +1122,74 @@ mod tests {
|
|||||||
let two = session_named(2, "bash");
|
let two = session_named(2, "bash");
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![one, two],
|
sessions: vec![one, two],
|
||||||
highlight: Some(Uuid::from_u128(1)),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
let all = rows(&buf);
|
let all = rows(&buf);
|
||||||
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
|
let claude_y = all.iter().position(|l| l.contains("claude")).unwrap();
|
||||||
let bash_y = all.iter().position(|l| l.contains("bash")).unwrap();
|
let bash_y = all.iter().position(|l| l.contains("○ bash")).unwrap();
|
||||||
|
|
||||||
|
let p = palette::active();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf[(0, claude_y as u16)].style().bg,
|
buf[(1, claude_y as u16)].style().bg,
|
||||||
Some(Color::Cyan),
|
Some(p.selection.bg),
|
||||||
"focused card must sit on the focus background"
|
"focused card must sit on the selection surface"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf[(0, claude_y as u16)].style().fg,
|
buf[(1, claude_y as u16)].style().fg,
|
||||||
Some(Color::Black),
|
Some(p.selection.fg),
|
||||||
"focused card text must stay readable on cyan"
|
"focused card text must use the selection foreground role"
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf[(0, bash_y as u16)].style().bg,
|
buf[(1, bash_y as u16)].style().bg,
|
||||||
Some(Color::Black),
|
Some(p.card.bg),
|
||||||
"unfocused cards must still have a card background"
|
"unfocused cards must still sit on the card surface"
|
||||||
);
|
);
|
||||||
// The panel itself (header row) stays on the terminal default.
|
// The panel itself (header row) stays on the terminal default.
|
||||||
assert!(
|
assert!(
|
||||||
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
|
matches!(buf[(1, 1)].style().bg, None | Some(Color::Reset)),
|
||||||
"panel must keep the terminal default background"
|
"panel must keep the terminal default background"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn highlighted_card_is_hollow_when_sidebar_is_unfocused() {
|
||||||
|
let sb = Sidebar {
|
||||||
|
sessions: vec![session_named(1, "claude")],
|
||||||
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
|
attached: Default::default(),
|
||||||
|
};
|
||||||
|
let buf = draw_with_focus(&sb, 28, 8, false);
|
||||||
|
let y = (0..buf.area.height)
|
||||||
|
.find(|&y| (0..buf.area.width).any(|x| buf[(x, y)].symbol() == "c"))
|
||||||
|
.expect("highlighted session row");
|
||||||
|
assert_eq!(
|
||||||
|
buf[(1, y)].style().bg,
|
||||||
|
Some(palette::active().card.bg),
|
||||||
|
"unfocused highlight must not paint the selection surface"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
(0..buf.area.width).any(|x| buf[(x, y)].symbol() == "▏"),
|
||||||
|
"unfocused highlight must retain a visible edge marker"
|
||||||
|
);
|
||||||
|
let ordinary = draw_with_focus(
|
||||||
|
&Sidebar {
|
||||||
|
sessions: vec![session_named(1, "claude")],
|
||||||
|
highlight: None,
|
||||||
|
attached: Default::default(),
|
||||||
|
},
|
||||||
|
28,
|
||||||
|
8,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!(0..ordinary.area.height)
|
||||||
|
.any(|row| (0..ordinary.area.width).any(|x| ordinary[(x, row)].symbol() == "▏")),
|
||||||
|
"ordinary unhighlighted cards must not carry the cursor marker"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/// The card background paints the full card width, not just the glyphs.
|
/// The card background paints the full card width, not just the glyphs.
|
||||||
#[test]
|
#[test]
|
||||||
fn card_background_covers_the_full_card_width() {
|
fn card_background_covers_the_full_card_width() {
|
||||||
@@ -773,10 +1201,10 @@ mod tests {
|
|||||||
};
|
};
|
||||||
let buf = draw(&sb, 28, 8);
|
let buf = draw(&sb, 28, 8);
|
||||||
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
let y = buf.content.iter().position(|c| c.symbol() == "c").unwrap() as u16 / buf.area.width;
|
||||||
for x in 0..27 {
|
for x in 1..27 {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
buf[(x, y)].style().bg,
|
buf[(x, y)].style().bg,
|
||||||
Some(Color::Black),
|
Some(palette::active().card.bg),
|
||||||
"gap in card background at col {x}"
|
"gap in card background at col {x}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -787,7 +1215,7 @@ mod tests {
|
|||||||
fn highlighted_row_anchors_the_actions_top_right() {
|
fn highlighted_row_anchors_the_actions_top_right() {
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![session_named(1, "claude")],
|
sessions: vec![session_named(1, "claude")],
|
||||||
highlight: Some(Uuid::from_u128(1)),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
@@ -804,7 +1232,7 @@ mod tests {
|
|||||||
let two = session_named(2, "bash");
|
let two = session_named(2, "bash");
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![one, two],
|
sessions: vec![one, two],
|
||||||
highlight: Some(Uuid::from_u128(1)),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
@@ -825,7 +1253,7 @@ mod tests {
|
|||||||
}
|
}
|
||||||
let sb = Sidebar {
|
let sb = Sidebar {
|
||||||
sessions: vec![s],
|
sessions: vec![s],
|
||||||
highlight: Some(Uuid::from_u128(1)),
|
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
|
||||||
attached: Default::default(),
|
attached: Default::default(),
|
||||||
};
|
};
|
||||||
let all = rows(&draw(&sb, 28, 8));
|
let all = rows(&draw(&sb, 28, 8));
|
||||||
|
|||||||
@@ -8,6 +8,9 @@
|
|||||||
//!
|
//!
|
||||||
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
//! v1.5 will replace `Tab::pane`'s single slot with a split tree.
|
||||||
|
|
||||||
|
use std::collections::HashSet;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::draft::DraftPane;
|
use crate::draft::DraftPane;
|
||||||
@@ -19,6 +22,16 @@ pub enum Focus {
|
|||||||
Pane,
|
Pane,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One selectable item in the rendered sidebar sequence.
|
||||||
|
///
|
||||||
|
/// Group paths, unlike a session's position in the catalog, survive catalog
|
||||||
|
/// redraws and make a highlighted collapsed group unambiguous.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||||
|
pub enum SidebarItem {
|
||||||
|
Group(PathBuf),
|
||||||
|
Session(Uuid),
|
||||||
|
}
|
||||||
|
|
||||||
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
/// What a tab's pane slot holds: a live session, or an inline draft form.
|
||||||
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
|
||||||
// that boxing every access costs more than the copy does.
|
// that boxing every access costs more than the copy does.
|
||||||
@@ -70,7 +83,10 @@ pub struct AppState {
|
|||||||
pub focus: Focus,
|
pub focus: Focus,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
pub sidebar_visible: bool,
|
pub sidebar_visible: bool,
|
||||||
pub sidebar_highlight: Option<Uuid>,
|
pub sidebar_highlight: Option<SidebarItem>,
|
||||||
|
/// Group paths whose cards are hidden. This is UI state rather than
|
||||||
|
/// catalog state, so it survives every catalog redraw.
|
||||||
|
pub collapsed_sidebar_groups: HashSet<PathBuf>,
|
||||||
/// Private: every transition goes through [`AppState::show_overlay`],
|
/// Private: every transition goes through [`AppState::show_overlay`],
|
||||||
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
|
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
|
||||||
/// from four call sites is what left a toast on screen with no way to
|
/// from four call sites is what left a toast on screen with no way to
|
||||||
@@ -96,6 +112,7 @@ impl AppState {
|
|||||||
mode: Mode::Normal,
|
mode: Mode::Normal,
|
||||||
sidebar_visible: true,
|
sidebar_visible: true,
|
||||||
sidebar_highlight: None,
|
sidebar_highlight: None,
|
||||||
|
collapsed_sidebar_groups: HashSet::new(),
|
||||||
overlay: None,
|
overlay: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -213,19 +230,32 @@ impl AppState {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Move the sidebar highlight by `delta` (positive = down) within the
|
/// Move the sidebar highlight by `delta` (positive = down) within the
|
||||||
/// given ordered list of selectable session ids. Wraps. No-op on empty.
|
/// rendered item sequence. Wraps. No-op on empty.
|
||||||
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
|
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
|
||||||
if order.is_empty() {
|
if order.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
let len = order.len() as i32;
|
let len = order.len() as i32;
|
||||||
let cur = self
|
let cur = self
|
||||||
.sidebar_highlight
|
.sidebar_highlight
|
||||||
.and_then(|id| order.iter().position(|x| *x == id))
|
.as_ref()
|
||||||
|
.and_then(|id| order.iter().position(|x| x == id))
|
||||||
.map(|i| i as i32)
|
.map(|i| i as i32)
|
||||||
.unwrap_or(-1);
|
.unwrap_or(-1);
|
||||||
let next = (cur + delta).rem_euclid(len) as usize;
|
let next = (cur + delta).rem_euclid(len) as usize;
|
||||||
self.sidebar_highlight = Some(order[next]);
|
self.sidebar_highlight = Some(order[next].clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle a group. When collapsing a group containing the current card,
|
||||||
|
/// leave the highlight on the group row that remains visible.
|
||||||
|
pub fn toggle_sidebar_group(&mut self, key: PathBuf, contains_highlight: bool) {
|
||||||
|
if self.collapsed_sidebar_groups.remove(&key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.collapsed_sidebar_groups.insert(key.clone());
|
||||||
|
if contains_highlight {
|
||||||
|
self.sidebar_highlight = Some(SidebarItem::Group(key));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Toggle sidebar visibility.
|
/// Toggle sidebar visibility.
|
||||||
@@ -318,22 +348,30 @@ mod tests {
|
|||||||
let a = Uuid::from_u128(1);
|
let a = Uuid::from_u128(1);
|
||||||
let b = Uuid::from_u128(2);
|
let b = Uuid::from_u128(2);
|
||||||
let c = Uuid::from_u128(3);
|
let c = Uuid::from_u128(3);
|
||||||
let order = vec![a, b, c];
|
let order = vec![
|
||||||
|
SidebarItem::Session(a),
|
||||||
|
SidebarItem::Group("repo".into()),
|
||||||
|
SidebarItem::Session(b),
|
||||||
|
SidebarItem::Session(c),
|
||||||
|
];
|
||||||
|
|
||||||
// No prior highlight + delta +1 lands on first item.
|
// No prior highlight + delta +1 lands on first item.
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(a));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(a)));
|
||||||
|
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(b));
|
assert_eq!(
|
||||||
|
state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group("repo".into()))
|
||||||
|
);
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(c));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||||
// Wraps forward.
|
// Wraps forward.
|
||||||
state.move_sidebar_highlight(&order, 1);
|
state.move_sidebar_highlight(&order, 1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(a));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
|
||||||
// Wraps backward.
|
// Wraps backward.
|
||||||
state.move_sidebar_highlight(&order, -1);
|
state.move_sidebar_highlight(&order, -1);
|
||||||
assert_eq!(state.sidebar_highlight, Some(c));
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -343,6 +381,22 @@ mod tests {
|
|||||||
assert_eq!(state.sidebar_highlight, None);
|
assert_eq!(state.sidebar_highlight, None);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn collapsing_highlighted_group_moves_a_card_highlight_to_the_group() {
|
||||||
|
let key = PathBuf::from("/repo");
|
||||||
|
let mut state = AppState::new();
|
||||||
|
state.sidebar_highlight = Some(SidebarItem::Session(Uuid::from_u128(9)));
|
||||||
|
state.toggle_sidebar_group(key.clone(), true);
|
||||||
|
assert_eq!(
|
||||||
|
state.sidebar_highlight,
|
||||||
|
Some(SidebarItem::Group(key.clone()))
|
||||||
|
);
|
||||||
|
assert!(state.collapsed_sidebar_groups.contains(&key));
|
||||||
|
state.toggle_sidebar_group(key.clone(), false);
|
||||||
|
assert!(!state.collapsed_sidebar_groups.contains(&key));
|
||||||
|
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Group(key)));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn attach_binds_focuses_pane_and_enters_insert_mode() {
|
fn attach_binds_focuses_pane_and_enters_insert_mode() {
|
||||||
let mut state = AppState::new();
|
let mut state = AppState::new();
|
||||||
|
|||||||
+37
-12
@@ -19,8 +19,7 @@ use tracing_subscriber::EnvFilter;
|
|||||||
#[command(
|
#[command(
|
||||||
name = "maestro",
|
name = "maestro",
|
||||||
version,
|
version,
|
||||||
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator",
|
about = "Unified CLI / daemon / TUI for the Maestro terminal-session orchestrator"
|
||||||
arg_required_else_help = true
|
|
||||||
)]
|
)]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Override the daemon HTTP base clients talk to (e.g.
|
/// Override the daemon HTTP base clients talk to (e.g.
|
||||||
@@ -37,8 +36,11 @@ struct Cli {
|
|||||||
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
#[arg(short, long, global = true, action = clap::ArgAction::Count)]
|
||||||
verbose: u8,
|
verbose: u8,
|
||||||
|
|
||||||
|
/// A missing subcommand defaults to the TUI (#119): a bare `maestro` opens
|
||||||
|
/// the terminal UI. Builds without the `tui` feature have no UI surface,
|
||||||
|
/// so they print help and exit 0 instead.
|
||||||
#[command(subcommand)]
|
#[command(subcommand)]
|
||||||
command: Command,
|
command: Option<Command>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Subcommand)]
|
#[derive(Debug, Subcommand)]
|
||||||
@@ -89,7 +91,22 @@ fn main() -> ExitCode {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
async fn dispatch(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
match cli.command {
|
let command = match cli.command {
|
||||||
|
Some(command) => command,
|
||||||
|
None => {
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
|
{
|
||||||
|
Command::Tui(maestro_tui::cli::TuiArgs { daemon_url: None })
|
||||||
|
}
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
{
|
||||||
|
use clap::CommandFactory;
|
||||||
|
let _ = <Cli as CommandFactory>::command().print_help();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match command {
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
Command::List(args) => {
|
Command::List(args) => {
|
||||||
maestro_cli::dispatch(
|
maestro_cli::dispatch(
|
||||||
@@ -166,19 +183,19 @@ fn init_tracing(cli: &Cli) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
fn is_cli_subcommand(command: &Command) -> bool {
|
fn is_cli_subcommand(command: &Option<Command>) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
command,
|
command,
|
||||||
Command::List(_)
|
Some(Command::List(_))
|
||||||
| Command::Spawn(_)
|
| Some(Command::Spawn(_))
|
||||||
| Command::Attach(_)
|
| Some(Command::Attach(_))
|
||||||
| Command::Kill(_)
|
| Some(Command::Kill(_))
|
||||||
| Command::Hooks(_)
|
| Some(Command::Hooks(_))
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(not(feature = "cli"))]
|
#[cfg(not(feature = "cli"))]
|
||||||
fn is_cli_subcommand(_command: &Command) -> bool {
|
fn is_cli_subcommand(_command: &Option<Command>) -> bool {
|
||||||
false
|
false
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,12 +233,20 @@ mod tests {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Parse argv the way `main` does, returning just the dispatched subcommand.
|
/// Parse argv the way `main` does, returning just the dispatched subcommand.
|
||||||
fn command_of(argv: &[&str]) -> Command {
|
fn command_of(argv: &[&str]) -> Option<Command> {
|
||||||
Cli::try_parse_from(argv)
|
Cli::try_parse_from(argv)
|
||||||
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
|
.unwrap_or_else(|e| panic!("parse {argv:?}: {e}"))
|
||||||
.command
|
.command
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_invocation_parses_to_no_subcommand() {
|
||||||
|
// The TUI-vs-help default is applied in `dispatch`; parsing must not
|
||||||
|
// reject an empty argv.
|
||||||
|
assert!(command_of(&["maestro"]).is_none());
|
||||||
|
assert!(command_of(&["maestro", "--verbose"]).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(feature = "cli")]
|
#[cfg(feature = "cli")]
|
||||||
#[test]
|
#[test]
|
||||||
fn cli_subcommands_are_classified_as_cli() {
|
fn cli_subcommands_are_classified_as_cli() {
|
||||||
|
|||||||
@@ -54,11 +54,35 @@ fn version_flag_succeeds() {
|
|||||||
assert!(text.contains("maestro"), "--version output: {text}");
|
assert!(text.contains("maestro"), "--version output: {text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A bare `maestro` must never fall back to help on a build that has a TUI:
|
||||||
|
/// the missing subcommand defaults to `tui` (#119). Point at a port with
|
||||||
|
/// nothing listening so the TUI's health probe fails fast instead of blocking
|
||||||
|
/// on a terminal.
|
||||||
|
#[cfg(feature = "tui")]
|
||||||
#[test]
|
#[test]
|
||||||
fn no_args_prints_help_and_fails() {
|
fn bare_invocation_dispatches_to_the_tui() {
|
||||||
// `arg_required_else_help = true` => exit non-zero but show usage.
|
let (ok, text) = run(&["--daemon-url", "http://127.0.0.1:9"]);
|
||||||
|
assert!(
|
||||||
|
!ok,
|
||||||
|
"TUI against a dead daemon should exit non-zero:\n{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
text.contains("daemon unreachable"),
|
||||||
|
"bare invocation should have run the TUI's daemon probe:\n{text}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!text.contains("Usage"),
|
||||||
|
"bare invocation must not print help when a TUI exists:\n{text}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Builds without the `tui` feature keep the historical behaviour: no args =>
|
||||||
|
/// help, exit 0 (#119).
|
||||||
|
#[cfg(not(feature = "tui"))]
|
||||||
|
#[test]
|
||||||
|
fn bare_invocation_prints_help_and_exits_zero() {
|
||||||
let (ok, text) = run(&[]);
|
let (ok, text) = run(&[]);
|
||||||
assert!(!ok, "bare invocation should fail:\n{text}");
|
assert!(ok, "bare invocation should exit 0:\n{text}");
|
||||||
assert!(text.contains("Usage"), "expected usage text:\n{text}");
|
assert!(text.contains("Usage"), "expected usage text:\n{text}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -119,6 +119,9 @@ The wizard always installs the default-feature build.
|
|||||||
|
|
||||||
`maestro tui` is the keyboard-and-mouse driven terminal UI client. It ships
|
`maestro tui` is the keyboard-and-mouse driven terminal UI client. It ships
|
||||||
in the default feature set, so a vanilla `cargo install maestro` builds it.
|
in the default feature set, so a vanilla `cargo install maestro` builds it.
|
||||||
|
A bare `maestro` opens the TUI too — the missing subcommand defaults to `tui`
|
||||||
|
(#119), so `maestro --daemon-url http://host:7070` works as well. Builds
|
||||||
|
without the `tui` feature have no UI to open and print help instead (exit 0).
|
||||||
Drop the `tui` feature to skip it on hosts that don't need the UI surface
|
Drop the `tui` feature to skip it on hosts that don't need the UI surface
|
||||||
(`--no-default-features --features cli,daemon`).
|
(`--no-default-features --features cli,daemon`).
|
||||||
|
|
||||||
@@ -131,6 +134,7 @@ cover every v1 action — only override what you want to change:
|
|||||||
|
|
||||||
```toml
|
```toml
|
||||||
[tui]
|
[tui]
|
||||||
|
theme = "dark" # dark (default), light, or ansi
|
||||||
leader = "ctrl-a" # one byte that escapes Insert mode
|
leader = "ctrl-a" # one byte that escapes Insert mode
|
||||||
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
||||||
|
|
||||||
@@ -149,6 +153,11 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `g`
|
|||||||
# want extra Insert-mode bindings)
|
# want extra Insert-mode bindings)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`theme` selects the complete TUI palette. `dark` is the default and follows
|
||||||
|
Maestro's terminal-native dark product palette; `light` is its light peer;
|
||||||
|
`ansi` uses the terminal's named ANSI colours for terminals without truecolour.
|
||||||
|
An unknown name is rejected as an `[tui].theme` configuration error.
|
||||||
|
|
||||||
The full action vocabulary is the `Action` enum in
|
The full action vocabulary is the `Action` enum in
|
||||||
`crates/maestro-tui/src/action.rs`.
|
`crates/maestro-tui/src/action.rs`.
|
||||||
|
|
||||||
|
|||||||
+20
-11
@@ -69,21 +69,30 @@ height.
|
|||||||
|
|
||||||
The state word is never spelled out — the colour carries it.
|
The state word is never spelled out — the colour carries it.
|
||||||
|
|
||||||
| Activity state | Web token | ratatui colour |
|
| Activity state | Web token | TUI palette role |
|
||||||
| ---------------- | --------- | ----------------- |
|
| ---------------- | --------- | ---------------- |
|
||||||
| `Active` | `--live` | `Color::Green` |
|
| `Active` | `--live` | `active` |
|
||||||
| `Idle` | `--muted` | `Color::DarkGray` |
|
| `Idle` | `--muted` | `idle` |
|
||||||
| `AwaitingInput` | `--warn` | `Color::Yellow` |
|
| `AwaitingInput` | `--warn` | `awaiting` |
|
||||||
| `Error` | `--danger`| `Color::Red` |
|
| `Error` | `--danger`| `error` |
|
||||||
|
|
||||||
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
|
Web tokens resolve per theme automatically (`styles.css` defines dark, light,
|
||||||
and `prefers-color-scheme` variants for each). The TUI uses the plain ratatui
|
and `prefers-color-scheme` variants for each). The TUI names roles, not
|
||||||
named colours above; do not introduce RGB values or a new palette module for
|
colours: a role's value comes from the active palette
|
||||||
this.
|
(`maestro-tui/src/palette.rs`), never from a colour spelled out at the use
|
||||||
|
site.
|
||||||
|
|
||||||
|
A palette holds one role set per **surface** — the card surface every card
|
||||||
|
sits on, and the selection surface the highlighted card swaps in — so each
|
||||||
|
state role resolves to the value picked for the surface it is painted on. The
|
||||||
|
highlighted card therefore takes its state colours from the selection
|
||||||
|
surface; a colour chosen for the card background is never painted onto the
|
||||||
|
selection background. Contrast is a property of the palette (values are RGB)
|
||||||
|
and is asserted in the palette's tests, not left to the terminal theme.
|
||||||
|
|
||||||
The duration text inherits its state's colour; the blocked-work, exited and
|
The duration text inherits its state's colour; the blocked-work, exited and
|
||||||
command fragments use the muted colour (`--faint` / default style), since the
|
command fragments use the surface's `muted` role, since the ⚠ glyph already
|
||||||
⚠ glyph already signals severity.
|
signals severity.
|
||||||
|
|
||||||
## Duration format
|
## Duration format
|
||||||
|
|
||||||
|
|||||||
@@ -4,9 +4,7 @@ import {
|
|||||||
activityColorSlug,
|
activityColorSlug,
|
||||||
activityDurationLabel,
|
activityDurationLabel,
|
||||||
activityHeldMs,
|
activityHeldMs,
|
||||||
showsMetaLine,
|
|
||||||
} from "./activity.js";
|
} from "./activity.js";
|
||||||
import type { Session } from "./types.js";
|
|
||||||
|
|
||||||
describe("activityHeldMs", () => {
|
describe("activityHeldMs", () => {
|
||||||
it("measures time spent in the current state", () => {
|
it("measures time spent in the current state", () => {
|
||||||
@@ -60,42 +58,3 @@ describe("activityColorSlug", () => {
|
|||||||
expect(activityColorSlug("Teleported")).toBe("idle");
|
expect(activityColorSlug("Teleported")).toBe("idle");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
function session(overrides: Partial<Session>): Session {
|
|
||||||
return {
|
|
||||||
id: "11111111-2222-3333-4444-555555555555",
|
|
||||||
cwd: "/home/naps/tea/maestro",
|
|
||||||
command: "claude",
|
|
||||||
env: {},
|
|
||||||
metadata: {},
|
|
||||||
status: "Running",
|
|
||||||
created_at: "2026-08-24T10:00:00Z",
|
|
||||||
activity: "Active",
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("showsMetaLine", () => {
|
|
||||||
const fresh = new Date(Date.now() - 10_000).toISOString();
|
|
||||||
|
|
||||||
it("stays hidden for a freshly-active session", () => {
|
|
||||||
expect(showsMetaLine(session({ activity_since: fresh }))).toBe(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appears once the state has been held past 30 seconds", () => {
|
|
||||||
const held = new Date(Date.now() - 31_000).toISOString();
|
|
||||||
expect(showsMetaLine(session({ activity_since: held }))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("appears for blocked work, exited sessions and reported errors", () => {
|
|
||||||
expect(
|
|
||||||
showsMetaLine(session({ activity_since: fresh, blocked_work: { uncommitted_files: 1 } })),
|
|
||||||
).toBe(true);
|
|
||||||
expect(showsMetaLine(session({ activity_since: fresh, status: "Dead" }))).toBe(true);
|
|
||||||
expect(showsMetaLine(session({ activity_since: fresh, activity: "Error" }))).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it("needs no activity timestamp when another trigger holds", () => {
|
|
||||||
expect(showsMetaLine(session({ activity_since: undefined, status: "Dead" }))).toBe(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
+3
-15
@@ -2,11 +2,10 @@
|
|||||||
// the shared duration format. Both clients compute it identically; the TUI
|
// the shared duration format. Both clients compute it identically; the TUI
|
||||||
// mirrors this in crates/maestro-tui.
|
// mirrors this in crates/maestro-tui.
|
||||||
|
|
||||||
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
|
import type { ActivityState, IsoTimestamp } from "./types.js";
|
||||||
import { sessionBlockers } from "./types.js";
|
|
||||||
|
|
||||||
/** A state held shorter than this keeps the card to one line (contract rule 1). */
|
/** A state held shorter than this has no duration fragment. */
|
||||||
const META_LINE_MIN_HOLD_MS = 30_000;
|
export const META_LINE_MIN_HOLD_MS = 30_000;
|
||||||
|
|
||||||
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
|
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
|
||||||
// implausibly old is treated as "never observed" rather than a huge duration.
|
// implausibly old is treated as "never observed" rather than a huge duration.
|
||||||
@@ -43,14 +42,3 @@ export function activityColorSlug(state: ActivityState | string): string {
|
|||||||
return "idle";
|
return "idle";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Line 2 renders only when at least one contract trigger holds. */
|
|
||||||
export function showsMetaLine(s: Session, now: number = Date.now()): boolean {
|
|
||||||
const held = activityHeldMs(s.activity_since, now);
|
|
||||||
return (
|
|
||||||
(held !== null && held >= META_LINE_MIN_HOLD_MS) ||
|
|
||||||
sessionBlockers(s) !== null ||
|
|
||||||
s.status === "Dead" ||
|
|
||||||
s.activity === "Error"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
+16
-2
@@ -150,14 +150,18 @@ describe("session cards (#107)", () => {
|
|||||||
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
expect(line1?.querySelector(".leaf-actions")).toBe(line1?.lastElementChild);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("stays a single line while freshly active", () => {
|
it("always renders line 2 for a freshly spawned session", () => {
|
||||||
const s = session({
|
const s = session({
|
||||||
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
id: "aaaaaaaa-0000-0000-0000-000000000011",
|
||||||
activity: "Active",
|
activity: "Active",
|
||||||
activity_since: ago(5_000),
|
activity_since: ago(5_000),
|
||||||
});
|
});
|
||||||
const el = renderSidebar(storeWith([s])).el;
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
expect(rowFor(el, s.id)?.querySelector(".card-line2")).toBeNull();
|
const line2 = rowFor(el, s.id)?.querySelector<HTMLElement>(".card-line2");
|
||||||
|
expect(line2).not.toBeNull();
|
||||||
|
expect(line2?.querySelector(".activity-dot")).not.toBeNull();
|
||||||
|
expect(line2?.querySelector(".activity-duration")).toBeNull();
|
||||||
|
expect(line2?.querySelector(".leaf-cmd")?.textContent).toBe("claude");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows dot + floored duration once the state has been held past 30s", () => {
|
it("shows dot + floored duration once the state has been held past 30s", () => {
|
||||||
@@ -173,6 +177,16 @@ describe("session cards (#107)", () => {
|
|||||||
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
expect(line2?.querySelector(".activity-duration")?.textContent).toBe("4m");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("renders the command when the session name equals it", () => {
|
||||||
|
const s = session({
|
||||||
|
id: "aaaaaaaa-0000-0000-0000-00000000001b",
|
||||||
|
command: "claude",
|
||||||
|
metadata: { name: "claude" },
|
||||||
|
});
|
||||||
|
const el = renderSidebar(storeWith([s])).el;
|
||||||
|
expect(rowFor(el, s.id)?.querySelector(".card-line2 .leaf-cmd")?.textContent).toBe("claude");
|
||||||
|
});
|
||||||
|
|
||||||
it("maps each activity state to its contract slug", () => {
|
it("maps each activity state to its contract slug", () => {
|
||||||
const mk = (id: string, activity: string) =>
|
const mk = (id: string, activity: string) =>
|
||||||
session({
|
session({
|
||||||
|
|||||||
+11
-14
@@ -4,7 +4,12 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
|
|||||||
import { CatalogStore } from "./events.js";
|
import { CatalogStore } from "./events.js";
|
||||||
import type { Session } from "./types.js";
|
import type { Session } from "./types.js";
|
||||||
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
import { abbreviateFolderName, formatInTheWay, sessionBlockers, sessionName } from "./types.js";
|
||||||
import { activityColorSlug, activityDurationLabel, activityHeldMs, showsMetaLine } from "./activity.js";
|
import {
|
||||||
|
activityColorSlug,
|
||||||
|
activityDurationLabel,
|
||||||
|
activityHeldMs,
|
||||||
|
META_LINE_MIN_HOLD_MS,
|
||||||
|
} from "./activity.js";
|
||||||
import type { TreeNode } from "./tree.js";
|
import type { TreeNode } from "./tree.js";
|
||||||
import { buildTree } from "./tree.js";
|
import { buildTree } from "./tree.js";
|
||||||
import { createThemeToggle } from "./theme.js";
|
import { createThemeToggle } from "./theme.js";
|
||||||
@@ -28,16 +33,13 @@ const STATE_WORDS: Record<string, string> = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function metaLine(s: Session, now: number): string {
|
function metaLine(s: Session, now: number): string {
|
||||||
const name = sessionName(s);
|
|
||||||
const dead = s.status === "Dead";
|
const dead = s.status === "Dead";
|
||||||
const blockers = sessionBlockers(s);
|
const blockers = sessionBlockers(s);
|
||||||
const slug = activityColorSlug(s.activity ?? "Idle");
|
const slug = activityColorSlug(s.activity ?? "Idle");
|
||||||
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
|
||||||
const held = activityHeldMs(s.activity_since, now);
|
const held = activityHeldMs(s.activity_since, now);
|
||||||
const duration = held !== null ? activityDurationLabel(held) : null;
|
const duration =
|
||||||
// The directory is the enclosing folder, so only surface the command — and
|
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
|
||||||
// only when it isn't already what the name shows.
|
|
||||||
const showCmd = s.command !== name;
|
|
||||||
|
|
||||||
const fragments = [
|
const fragments = [
|
||||||
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
`<span class="activity-dot" role="img" title="${escapeHtml(stateWord)}" aria-label="${escapeHtml(stateWord)}"></span>`,
|
||||||
@@ -49,9 +51,7 @@ function metaLine(s: Session, now: number): string {
|
|||||||
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
? `<span class="card-flag leaf-blocked" title="Kill will be refused unless forced">⚠ ${escapeHtml(formatInTheWay(blockers))}</span>`
|
||||||
: "",
|
: "",
|
||||||
dead ? `<span class="card-flag">exited</span>` : "",
|
dead ? `<span class="card-flag">exited</span>` : "",
|
||||||
showCmd
|
`<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`,
|
||||||
? `<span class="card-flag leaf-cmd" title="${escapeHtml(s.command)}">${escapeHtml(s.command)}</span>`
|
|
||||||
: "",
|
|
||||||
];
|
];
|
||||||
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
return `<span class="card-line2" data-activity="${slug}">${fragments.join("")}</span>`;
|
||||||
}
|
}
|
||||||
@@ -106,10 +106,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
"aria-label",
|
"aria-label",
|
||||||
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
|
||||||
);
|
);
|
||||||
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
|
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
|
||||||
// the actions reserve their width, so the name truncates before it can ever
|
|
||||||
// reach them. Line 2 renders only when a contract trigger holds.
|
|
||||||
const showMeta = showsMetaLine(s, now);
|
|
||||||
row.innerHTML = `
|
row.innerHTML = `
|
||||||
<span class="card-line1">
|
<span class="card-line1">
|
||||||
<span class="leaf-name">${escapeHtml(name)}</span>
|
<span class="leaf-name">${escapeHtml(name)}</span>
|
||||||
@@ -118,7 +115,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
|
|||||||
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
<button class="leaf-act leaf-kill" type="button" title="Kill session" aria-label="Kill ${escapeHtml(name)}">✕</button>
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
${showMeta ? metaLine(s, now) : ""}
|
${metaLine(s, now)}
|
||||||
`;
|
`;
|
||||||
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
|
||||||
similarBtn.addEventListener("click", (ev) => {
|
similarBtn.addEventListener("click", (ev) => {
|
||||||
|
|||||||
+12
-4
@@ -736,7 +736,6 @@ textarea {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: var(--sp-2);
|
gap: var(--sp-2);
|
||||||
min-height: 30px;
|
|
||||||
margin: 1px var(--sp-2);
|
margin: 1px var(--sp-2);
|
||||||
padding: 4px var(--sp-3);
|
padding: 4px var(--sp-3);
|
||||||
border-radius: var(--r-1);
|
border-radius: var(--r-1);
|
||||||
@@ -787,12 +786,13 @@ textarea {
|
|||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
|
/* #120: every session card has two lines, so its row height is stable. */
|
||||||
always, line 2 (meta) only when it has something to say. */
|
|
||||||
.tree-leaf.session-card {
|
.tree-leaf.session-card {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
gap: 1px;
|
gap: 1px;
|
||||||
|
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
|
||||||
|
height: 49px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-line1 {
|
.card-line1 {
|
||||||
@@ -855,7 +855,8 @@ textarea {
|
|||||||
ellipsis so it never pushes the trailing fragments off the line. */
|
ellipsis so it never pushes the trailing fragments off the line. */
|
||||||
.activity-detail {
|
.activity-detail {
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
flex: 0 1 auto;
|
/* Consume only the space left after the trailing status + command. */
|
||||||
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -866,12 +867,19 @@ textarea {
|
|||||||
signals severity and "exited" dims with the row. */
|
signals severity and "exited" dims with the row. */
|
||||||
.card-flag {
|
.card-flag {
|
||||||
color: var(--faint);
|
color: var(--faint);
|
||||||
|
flex: 0 0 auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Status fragments stay intact. The unconditional command is the final
|
||||||
|
truncation target when even the complete trailing set cannot fit. */
|
||||||
|
.leaf-cmd {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
}
|
||||||
|
|
||||||
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
/* Actions live in line 1's flow at its right edge — never overlaid on the
|
||||||
name (#107). They reserve their width even while hover-faded. */
|
name (#107). They reserve their width even while hover-faded. */
|
||||||
.leaf-actions {
|
.leaf-actions {
|
||||||
|
|||||||
Reference in New Issue
Block a user