Compare commits

..
Author SHA1 Message Date
Miguel Palhas 999dbe3ea3 Merge remote-tracking branch 'origin/main' into themes
ci / web (push) Successful in 1m27s
ci / rust (push) Successful in 5m4s
ci / flake (push) Successful in 6m57s
2026-08-25 18:46:35 +01:00
Miguel Palhas c9d089534b enable session scrolling across clients
ci / web (push) Successful in 59s
ci / rust (push) Successful in 6m8s
ci / flake (push) Successful in 9m52s
2026-08-25 18:46:26 +01:00
Miguel PalhasandClaude Fable 5 573a2b4a07 feat(maestro): add nine editor-theme TUI palettes
Solarized (dark/light), Catppuccin Mocha, GitHub (dark/light),
Tokyo Night, Gruvbox, Nord, Dracula. Colours auto-tuned from the
canonical seeds to clear the palette contrast floors; tests now
assert every RGB preset, not just dark/light.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-25 18:44:12 +01:00
Miguel Palhas 9ef3547a00 refine TUI navigation and chrome
ci / web (push) Successful in 30s
ci / rust (push) Successful in 4m41s
ci / flake (push) Successful in 7m13s
2026-08-25 15:10:47 +01:00
Miguel Palhas 2ab07c2343 fix(maestro): harden grouped sidebar interactions
ci / web (push) Successful in 1m29s
ci / rust (push) Successful in 5m25s
ci / flake (push) Successful in 7m56s
2026-08-25 13:59:38 +01:00
Miguel Palhas 342e732ef0 merge: TUI shell/124 — collapsible project groups 2026-08-25 13:54:09 +01:00
Miguel Palhas 3205270b51 feat(maestro): group TUI sidebar by project 2026-08-25 13:53:51 +01:00
Miguel Palhas d8e31f2760 fix(maestro): resize PTY to bordered pane content 2026-08-25 13:37:30 +01:00
Miguel Palhas fdf9ea1b31 merge: TUI shell/123 — unmistakable panel focus 2026-08-25 13:31:24 +01:00
Miguel Palhas 22cbfbc9bc merge: TUI shell/122 — configurable theme presets 2026-08-25 13:31:24 +01:00
Miguel Palhas 8157793282 feat(maestro): add TUI theme presets 2026-08-25 13:30:59 +01:00
Miguel Palhas 839dd27925 feat(maestro): make TUI panel focus unmistakable 2026-08-25 13:26:54 +01:00
Miguel Palhas a1816a0838 fix(maestro): preserve fixed card trailing fragments 2026-08-25 13:18:24 +01:00
Miguel Palhas e01b0616b1 merge: TUI shell/121 — TUI cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas 74d8d3c37a merge: TUI shell/120 — web cards always render line two 2026-08-25 13:11:47 +01:00
Miguel Palhas 10a25e5b83 feat(maestro): keep web session cards two lines 2026-08-25 13:06:23 +01:00
24 changed files with 2033 additions and 742 deletions
+100 -4
View File
@@ -22,7 +22,8 @@ use std::collections::BTreeMap;
use std::net::SocketAddr;
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`).
pub const DIR_NAME: &str = "maestro";
@@ -146,11 +147,14 @@ pub struct WebSection {
/// `[tui]` — `maestro tui` settings (NAP-275 epic, phase 2 NAP-277).
///
/// Leader key, chord timeout, and per-mode keymap overrides. The TUI ships
/// with a default keymap; entries in `keymap_normal` / `keymap_insert` are
/// applied **over** the defaults, so a partial override is enough.
/// Theme, leader key, chord timeout, and per-mode keymap overrides. The TUI
/// ships with a default keymap; entries in `keymap_normal` / `keymap_insert`
/// are applied **over** the defaults, so a partial override is enough.
#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
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`.
#[serde(default)]
pub leader: Option<String>,
@@ -167,6 +171,75 @@ pub struct TuiSection {
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,
/// Solarized Dark.
SolarizedDark,
/// Solarized Light.
SolarizedLight,
/// Catppuccin Mocha.
Catppuccin,
/// GitHub Dark.
GithubDark,
/// GitHub Light.
GithubLight,
/// Tokyo Night.
TokyoNight,
/// Gruvbox Dark.
Gruvbox,
/// Nord.
Nord,
/// Dracula.
Dracula,
}
/// Every `[tui].theme` name and its variant, in documentation order.
pub const TUI_THEMES: &[(&str, TuiTheme)] = &[
("dark", TuiTheme::Dark),
("light", TuiTheme::Light),
("ansi", TuiTheme::Ansi),
("solarized-dark", TuiTheme::SolarizedDark),
("solarized-light", TuiTheme::SolarizedLight),
("catppuccin", TuiTheme::Catppuccin),
("github-dark", TuiTheme::GithubDark),
("github-light", TuiTheme::GithubLight),
("tokyo-night", TuiTheme::TokyoNight),
("gruvbox", TuiTheme::Gruvbox),
("nord", TuiTheme::Nord),
("dracula", TuiTheme::Dracula),
];
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| {
TUI_THEMES
.iter()
.find(|(n, _)| *n == name)
.map(|(_, theme)| *theme)
.ok_or_else(|| {
let known = TUI_THEMES
.iter()
.map(|(n, _)| format!("{n:?}"))
.collect::<Vec<_>>()
.join(", ");
D::Error::custom(format!(
"invalid [tui].theme {name:?}; expected one of {known}"
))
})
})
.transpose()
}
/// `[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
@@ -596,6 +669,29 @@ bind_addr = "127.0.0.1:9090"
);
}
#[test]
fn tui_theme_parses_named_presets_and_defaults_when_absent() {
assert_eq!(TuiSection::default().theme, None);
for (name, theme) in TUI_THEMES {
let cfg = file_with(&format!("[tui]\ntheme = \"{name}\"\n"));
assert_eq!(cfg.tui.theme, Some(*theme));
}
}
#[test]
fn tui_theme_rejects_unknown_names_with_the_field_name() {
let err = toml::from_str::<MaestroConfigFile>("[tui]\ntheme = \"glorb\"\n").unwrap_err();
let message = err.to_string();
assert!(
message.contains("[tui].theme"),
"diagnostic should name the field: {message}"
);
assert!(
message.contains("glorb"),
"diagnostic should name the invalid value: {message}"
);
}
fn file_with(body: &str) -> MaestroConfigFile {
toml::from_str(body).expect("parse")
}
+51 -42
View File
@@ -31,9 +31,14 @@ pub enum Action {
FocusSidebar,
FocusPane(Direction),
// Session scrollback
ScrollPanePageUp,
ScrollPanePageDown,
// Sidebar highlight movement
SidebarNext,
SidebarPrev,
ToggleSidebarGroup,
// Catalog actions on the highlighted sidebar row
AttachHighlighted,
@@ -46,13 +51,6 @@ pub enum Action {
/// Open the retained-worktrees view (#85).
ShowWorktrees,
// Tabs
NewTab,
CloseTab,
NextTab,
PrevTab,
GotoTab(u8),
// Chrome
ToggleSidebar,
ShowHelp,
@@ -70,8 +68,39 @@ pub enum ActionParseError {
}
impl Action {
/// Parse a canonical action string like `"EnterInsert"`, `"FocusPane(Left)"`,
/// or `"GotoTab(3)"`.
/// Plain-language label for the in-app help menu. Configuration keeps the
/// stable enum spelling; operators should not have to decode it.
pub fn help_label(&self) -> String {
match self {
Action::Noop => "Do nothing".into(),
Action::EnterInsert => "Send keys to session".into(),
Action::LeaveInsert => "Return to sidebar".into(),
Action::FocusSidebar => "Focus sidebar".into(),
Action::FocusPane(direction) => {
format!(
"Focus session pane ({})",
format!("{direction:?}").to_lowercase()
)
}
Action::ScrollPanePageUp => "Scroll session up one page".into(),
Action::ScrollPanePageDown => "Scroll session down one page".into(),
Action::SidebarNext => "Select next sidebar item".into(),
Action::SidebarPrev => "Select previous sidebar item".into(),
Action::ToggleSidebarGroup => "Expand or collapse directory".into(),
Action::AttachHighlighted => "Open selected item".into(),
Action::ToggleAttachHighlighted => "Open selected item".into(),
Action::NewDraftPane => "Create a session".into(),
Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(),
Action::ShowWorktrees => "Show retained worktrees".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(),
Action::Quit => "Quit Maestro".into(),
}
}
/// Parse a canonical action string like `"EnterInsert"` or
/// `"FocusPane(Left)"`.
pub fn parse(s: &str) -> Result<Self, ActionParseError> {
let s = s.trim();
match s {
@@ -79,18 +108,17 @@ impl Action {
"EnterInsert" => return Ok(Action::EnterInsert),
"LeaveInsert" => return Ok(Action::LeaveInsert),
"FocusSidebar" => return Ok(Action::FocusSidebar),
"ScrollPanePageUp" => return Ok(Action::ScrollPanePageUp),
"ScrollPanePageDown" => return Ok(Action::ScrollPanePageDown),
"SidebarNext" => return Ok(Action::SidebarNext),
"SidebarPrev" => return Ok(Action::SidebarPrev),
"ToggleSidebarGroup" => return Ok(Action::ToggleSidebarGroup),
"AttachHighlighted" => return Ok(Action::AttachHighlighted),
"ToggleAttachHighlighted" => return Ok(Action::ToggleAttachHighlighted),
"NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees),
"NewTab" => return Ok(Action::NewTab),
"CloseTab" => return Ok(Action::CloseTab),
"NextTab" => return Ok(Action::NextTab),
"PrevTab" => return Ok(Action::PrevTab),
"ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp),
"Quit" => return Ok(Action::Quit),
@@ -111,13 +139,6 @@ impl Action {
})?;
Ok(Action::FocusPane(dir))
}
"GotoTab" => {
let n: u8 = arg.parse().map_err(|_| ActionParseError::BadArg {
action: name.to_string(),
detail: format!("expected u8 0..=255, got {arg:?}"),
})?;
Ok(Action::GotoTab(n))
}
_ => Err(ActionParseError::Unknown(name.to_string())),
}
}
@@ -137,7 +158,6 @@ impl fmt::Display for Action {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Action::FocusPane(d) => write!(f, "FocusPane({d:?})"),
Action::GotoTab(n) => write!(f, "GotoTab({n})"),
other => write!(f, "{other:?}"),
}
}
@@ -154,6 +174,13 @@ mod tests {
assert_eq!(Action::parse(" Noop ").unwrap(), Action::Noop);
}
#[test]
fn help_labels_are_plain_language() {
assert_eq!(Action::EnterInsert.help_label(), "Send keys to session");
assert_eq!(Action::AttachHighlighted.help_label(), "Open selected item");
assert!(!Action::NewDraftPane.help_label().contains("NewDraftPane"));
}
#[test]
fn parses_focus_pane_directions() {
assert_eq!(
@@ -166,12 +193,6 @@ mod tests {
);
}
#[test]
fn parses_goto_tab() {
assert_eq!(Action::parse("GotoTab(3)").unwrap(), Action::GotoTab(3));
assert_eq!(Action::parse("GotoTab(0)").unwrap(), Action::GotoTab(0));
}
#[test]
fn rejects_unknown_action() {
let err = Action::parse("Frobnicate").unwrap_err();
@@ -186,22 +207,10 @@ mod tests {
);
}
#[test]
fn rejects_non_numeric_tab_arg() {
let err = Action::parse("GotoTab(abc)").unwrap_err();
assert!(matches!(err, ActionParseError::BadArg { ref action, .. } if action == "GotoTab"));
}
#[test]
fn rejects_overflow_tab_arg() {
let err = Action::parse("GotoTab(9999)").unwrap_err();
assert!(matches!(err, ActionParseError::BadArg { .. }));
}
#[test]
fn rejects_unclosed_paren() {
let err = Action::parse("GotoTab(3").unwrap_err();
assert_eq!(err, ActionParseError::Malformed("GotoTab(3".into()));
let err = Action::parse("FocusPane(Left").unwrap_err();
assert_eq!(err, ActionParseError::Malformed("FocusPane(Left".into()));
}
/// The fast-forward pair sits next to the draft-pane key (#67).
@@ -220,8 +229,8 @@ mod tests {
Action::EnterInsert,
Action::Quit,
Action::NewDraftPane,
Action::ScrollPanePageDown,
Action::FocusPane(Direction::Right),
Action::GotoTab(7),
] {
let s = a.to_string();
assert_eq!(Action::parse(&s).unwrap(), a, "round-trip for {a:?}");
+427 -49
View File
@@ -30,15 +30,16 @@ use crate::catalog::CatalogStore;
use crate::config::TuiConfig;
use crate::draft::DraftInputResult;
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::overlays::HelpOverlay;
use crate::pane::{Pane, PaneStatus};
use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef};
use crate::tabs::TabBar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
use crate::worktrees::WorktreesView;
use crate::TuiError;
@@ -52,6 +53,7 @@ enum OverlayDecision {
pub struct App {
cfg: TuiConfig,
home_dir: String,
catalog: CatalogStore,
emulators: SessionEmulators,
state: AppState,
@@ -137,6 +139,7 @@ impl App {
.expect("build reqwest client");
Self {
cfg,
home_dir: std::env::var("HOME").unwrap_or_default(),
catalog,
emulators: SessionEmulators::default(),
state: AppState::new(),
@@ -158,6 +161,9 @@ impl App {
mut self,
terminal: &mut Terminal<CrosstermBackend<Stdout>>,
) -> Result<(), TuiError> {
if let Ok(info) = self.daemon.daemon_info().await {
self.home_dir = info.home_dir;
}
let mut events = EventStream::new();
loop {
self.redraw(terminal)?;
@@ -197,6 +203,15 @@ impl App {
return Ok(false);
}
if self.handle_sidebar_mouse(&key) {
return Ok(false);
}
if let Some(effect) = self.handle_pane_scroll(&key) {
self.perform(effect.into_iter().collect()).await;
return Ok(false);
}
let known = self.cfg.keymap.known_bindings(self.state.mode);
match self.chord.feed(&key, &known) {
ChordResult::Pending => Ok(false),
@@ -224,6 +239,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
/// the daemon.
async fn perform(&mut self, effects: Vec<Effect>) {
@@ -322,32 +365,37 @@ impl App {
match act {
Action::Noop | Action::Quit => {}
Action::EnterInsert => self.state.mode = Mode::Insert,
Action::LeaveInsert => self.state.mode = Mode::Normal,
Action::LeaveInsert => {
self.state.mode = Mode::Normal;
self.state.focus = Focus::Sidebar;
}
Action::FocusSidebar => self.state.focus = Focus::Sidebar,
Action::FocusPane(_) => {
// v1 has one pane per tab; geometric focus motion lands
// either in the sidebar or back in the pane.
self.state.focus = Focus::Pane;
}
Action::ScrollPanePageUp => self.scroll_active_pane(self.pane_page_rows() as isize),
Action::ScrollPanePageDown => {
self.scroll_active_pane(-(self.pane_page_rows() as isize))
}
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.focus = Focus::Sidebar;
}
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.focus = Focus::Sidebar;
}
Action::NewTab => self.state.new_tab(),
Action::CloseTab => {
if let Some(sid) = self.state.close_tab() {
self.release_session(sid);
}
}
Action::NextTab => self.state.next_tab(),
Action::PrevTab => self.state.prev_tab(),
Action::GotoTab(n) => self.state.goto_tab(n),
Action::ToggleSidebarGroup => self.toggle_highlighted_sidebar_group(),
Action::ToggleSidebar => self.state.toggle_sidebar(),
Action::ShowHelp => {
self.help = HelpOverlay {
@@ -356,13 +404,24 @@ impl App {
self.state.show_overlay(OverlayKind::Help);
}
Action::NewDraftPane => {
if let Some(sid) = self.state.active_session() {
self.release_session(sid);
}
self.state.open_draft_pane();
effects.push(Effect::LoadDraft(self.state.active_tab_idx));
}
Action::FastForwardSpawn => effects.push(Effect::FastForward),
Action::AttachHighlighted | Action::ToggleAttachHighlighted => {
if let Some(sid) = self.pick_sidebar_target() {
effects.push(Effect::Attach(sid));
if let Some(SidebarItem::Group(key)) = self.state.sidebar_highlight.clone() {
self.toggle_sidebar_group(key);
} else if let Some(sid) = self.pick_sidebar_target() {
self.state.sidebar_highlight = Some(SidebarItem::Session(sid));
if self.state.active_session() != Some(sid) {
if let Some(previous) = self.state.active_session() {
self.release_session(previous);
}
effects.push(Effect::Attach(sid));
}
self.state.attach_to_focused(sid);
}
}
@@ -403,22 +462,127 @@ impl App {
/// Return the session under the sidebar highlight, falling back to the
/// first listed session so the user can attach without arrowing first.
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);
}
if self.state.sidebar_highlight.is_some() {
return None;
}
let snap = self.catalog.snapshot();
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
/// attached to send it to.
fn forward_to_session(&mut self, key: &InputKey) -> Option<Effect> {
let sid = self.state.active_session()?;
self.emulators.get(sid)?;
let handle = self.emulators.get(sid)?;
let bytes = encode_key(key)?;
handle
.screen
.lock()
.expect("vt100 parser mutex")
.set_scrollback(0);
Some(Effect::Forward { id: sid, bytes })
}
fn pane_page_rows(&self) -> usize {
self.region
.pane
.map_or(1, |area| usize::from(area.height.saturating_sub(1).max(1)))
}
/// Positive rows move into older history; negative rows return toward the
/// live bottom. vt100 clamps both ends to the retained buffer.
fn scroll_active_pane(&mut self, rows: isize) {
let Some(sid) = self.state.active_session() else {
return;
};
let Some(handle) = self.emulators.get(sid) else {
return;
};
let mut parser = handle.screen.lock().expect("vt100 parser mutex");
let current = parser.screen().scrollback();
let next = if rows >= 0 {
current.saturating_add(rows as usize)
} else {
current.saturating_sub(rows.unsigned_abs())
};
parser.set_scrollback(next);
}
/// Wheel input belongs to the terminal surface in every mode. Normal
/// shells move through Maestro's retained history; full-screen programs
/// that enabled terminal mouse reporting receive a real wheel event and
/// scroll their own alternate-screen content.
fn handle_pane_scroll(&mut self, key: &InputKey) -> Option<Option<Effect>> {
let InputKey::Mouse {
kind,
region: Region::Pane,
col,
row,
} = key
else {
return None;
};
let lines = match kind {
MouseKind::ScrollUp => 3,
MouseKind::ScrollDown => -3,
_ => return None,
};
let sid = self.state.active_session()?;
let handle = self.emulators.get(sid)?;
let pane = self.region.pane?;
let parser = handle.screen.lock().expect("vt100 parser mutex");
if parser.screen().mouse_protocol_mode() != vt100::MouseProtocolMode::None {
let bytes = encode_mouse_wheel(
*kind,
col.saturating_sub(pane.x).saturating_add(1),
row.saturating_sub(pane.y).saturating_add(1),
parser.screen().mouse_protocol_encoding(),
);
drop(parser);
return Some(Some(Effect::Forward { id: sid, bytes }));
}
drop(parser);
self.scroll_active_pane(lines);
Some(None)
}
/// Feed one keystroke into the active tab's draft form. Returns the spawn
/// effect when the user accepted a valid form; an invalid one names the
/// missing field inside the form (#64) instead of posting.
@@ -704,11 +868,6 @@ impl App {
let resolved =
crate::fast_forward::resolve(&discovered, &presets, names.first().cloned(), Utc::now());
if let Some(req) = resolved.build_request() {
// Never eat a pane already showing something: the spawn takes a
// fresh tab, same as opening a draft over a session would.
if self.state.active_tab().pane.is_some() {
self.state.new_tab();
}
self.submit_spawn(req).await;
return;
}
@@ -734,6 +893,9 @@ impl App {
// Fill the pane in place with the new session (web parity,
// #57) and attach to it right away.
let sid = session.id;
if let Some(previous) = self.state.active_session() {
self.release_session(previous);
}
self.state.complete_spawn(sid);
self.attach(sid);
}
@@ -811,13 +973,9 @@ impl App {
.collect();
let sidebar = Sidebar {
sessions: snap.sessions.clone(),
highlight: self.state.sidebar_highlight,
highlight: self.state.sidebar_highlight.clone(),
attached,
};
let tab_bar = TabBar {
tab_names: self.state.tabs.iter().map(|t| t.name.clone()).collect(),
active_idx: self.state.active_tab_idx,
};
let active_sid = self.state.active_session();
let pane = active_sid.and_then(|sid| {
self.emulators.get(sid).map(|h| Pane {
@@ -830,10 +988,21 @@ impl App {
Some(PaneRef::Draft(d)) => Some(d),
_ => None,
};
let pane_title = match self.state.active_tab().pane.as_ref() {
Some(PaneRef::Session(sid)) => snap
.sessions
.iter()
.find(|session| session.id == *sid)
.map(crate::sidebar::name_for)
.unwrap_or_else(|| "session".into()),
Some(PaneRef::Draft(_)) => "new session".into(),
None => "session".into(),
};
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
home_dir: &self.home_dir,
pane_title: &pane_title,
pane: pane.as_ref(),
state: &self.state,
draft,
@@ -881,8 +1050,16 @@ impl App {
/// Order the sidebar walks for `j`/`k`: the sessions in the same order
/// [`crate::sidebar::Sidebar::render`] emits them. Keeps highlight movement in
/// visual sync with what the user sees.
fn visible_sidebar_order(snap: &crate::catalog::CatalogSnapshot) -> Vec<Uuid> {
snap.sessions.iter().map(|s| s.id).collect()
fn visible_sidebar_order(
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
@@ -916,6 +1093,8 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
InputKey::Left => Some(b"\x1b[D".to_vec()),
InputKey::Home => Some(b"\x1b[H".to_vec()),
InputKey::End => Some(b"\x1b[F".to_vec()),
InputKey::PageUp => Some(b"\x1b[5~".to_vec()),
InputKey::PageDown => Some(b"\x1b[6~".to_vec()),
InputKey::Delete => Some(b"\x1b[3~".to_vec()),
InputKey::Paste(text) => {
// Wrap in the DECSET 2004 markers. Raw bytes look like typed keys
@@ -931,6 +1110,39 @@ fn encode_key(key: &InputKey) -> Option<Vec<u8>> {
}
}
fn encode_mouse_wheel(
kind: MouseKind,
col: u16,
row: u16,
encoding: vt100::MouseProtocolEncoding,
) -> Vec<u8> {
let button = match kind {
MouseKind::ScrollUp => 64,
MouseKind::ScrollDown => 65,
_ => return Vec::new(),
};
match encoding {
vt100::MouseProtocolEncoding::Sgr => format!("\x1b[<{button};{col};{row}M").into_bytes(),
vt100::MouseProtocolEncoding::Default => vec![
0x1b,
b'[',
b'M',
(button + 32) as u8,
col.min(223).saturating_add(32) as u8,
row.min(223).saturating_add(32) as u8,
],
vt100::MouseProtocolEncoding::Utf8 => {
let mut out = b"\x1b[M".to_vec();
for value in [button + 32, u32::from(col) + 32, u32::from(row) + 32] {
let ch = char::from_u32(value).expect("mouse coordinate is a valid codepoint");
let mut bytes = [0; 4];
out.extend_from_slice(ch.encode_utf8(&mut bytes).as_bytes());
}
out
}
}
}
/// Build the lines shown by the help overlay from the live keymap, so the
/// user always sees the bindings that are actually active (defaults + their
/// own `[tui.keymap.*]` overrides).
@@ -938,14 +1150,14 @@ fn render_help_lines(cfg: &TuiConfig) -> Vec<String> {
fn section(mode: Mode, label: &str, cfg: &TuiConfig) -> Vec<String> {
let mut out = vec![format!("{label} mode"), String::new()];
for (input, action) in cfg.keymap.entries(mode) {
out.push(format!(" {input:<24} {action}"));
out.push(format!(" {input:<24} {}", action.help_label()));
}
out.push(String::new());
out
}
let leader = cfg.leader.canonical();
let mut lines = vec![
format!("Leader: {leader} (Insert → Normal)"),
format!("Leader: {leader} (return to sidebar)"),
String::new(),
];
lines.extend(section(Mode::Normal, "Normal", cfg));
@@ -1057,19 +1269,11 @@ mod tests {
let mut app = make_app();
assert_eq!(app.state.mode, Mode::Normal);
app.dispatch(Action::EnterInsert);
app.state.focus = Focus::Pane;
assert_eq!(app.state.mode, Mode::Insert);
app.dispatch(Action::LeaveInsert);
assert_eq!(app.state.mode, Mode::Normal);
}
#[test]
fn new_tab_action_extends_tabs() {
let mut app = make_app();
app.dispatch(Action::NewTab);
app.dispatch(Action::NewTab);
assert_eq!(app.state.tabs.len(), 3);
app.dispatch(Action::CloseTab);
assert_eq!(app.state.tabs.len(), 2);
assert_eq!(app.state.focus, Focus::Sidebar);
}
#[test]
@@ -1079,6 +1283,18 @@ mod tests {
assert!(matches!(app.state.overlay(), Some(OverlayKind::Help)));
}
#[test]
fn help_uses_plain_language_instead_of_action_names() {
let app = make_app();
let help = render_help_lines(&app.cfg).join("\n");
assert!(help.contains("Send keys to session"));
assert!(help.contains("Create a session"));
assert!(help.contains("Expand or collapse directory"));
assert!(!help.contains("EnterInsert"));
assert!(!help.contains("NewDraftPane"));
assert!(!help.contains("ToggleSidebarGroup"));
}
#[test]
fn new_draft_pane_opens_a_focused_draft_in_the_empty_tab() {
let mut app = make_app();
@@ -1092,21 +1308,19 @@ mod tests {
assert!(app.state.overlay().is_none(), "no overlay involved");
}
/// A tab already showing a session gets a new tab for the draft.
#[test]
fn new_draft_pane_over_a_session_opens_a_new_tab() {
fn new_draft_pane_over_a_session_reuses_the_pane() {
let sid = Uuid::from_u128(10);
let mut app = app_with_highlighted_session(sid);
app.dispatch(Action::AttachHighlighted);
app.dispatch(Action::NewDraftPane);
assert_eq!(app.state.tabs.len(), 2);
assert_eq!(app.state.active_tab_idx, 1);
assert_eq!(app.state.tabs.len(), 1);
assert_eq!(app.state.active_tab_idx, 0);
assert!(matches!(
app.state.active_tab().pane,
Some(PaneRef::Draft(_))
));
assert_eq!(app.state.tabs[0].session(), Some(sid));
}
/// Seed the catalog with one session and highlight it.
@@ -1134,9 +1348,32 @@ mod tests {
}],
});
app.dispatch(Action::SidebarNext);
app.dispatch(Action::SidebarNext);
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]
fn attach_highlighted_asks_for_an_attach() {
let sid = Uuid::from_u128(11);
@@ -1146,6 +1383,104 @@ mod tests {
vec![Effect::Attach(sid)]
);
assert_eq!(app.state.active_tab().session(), Some(sid));
assert_eq!(app.state.sidebar_highlight, Some(SidebarItem::Session(sid)));
}
#[test]
fn enter_on_a_group_toggles_it_without_targeting_a_session() {
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
.state
.collapsed_sidebar_groups
.contains(&PathBuf::from("/repo")));
assert!(app.dispatch(Action::AttachHighlighted).is_empty());
assert!(!app
.state
.collapsed_sidebar_groups
.contains(&PathBuf::from("/repo")));
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: 1,
};
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: 1,
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]
@@ -1602,6 +1937,49 @@ mod tests {
assert!(app.forward_to_session(&InputKey::Enter).is_none());
}
#[test]
fn pane_scrollback_moves_and_user_input_returns_to_live_output() {
use crate::session_task::{SessionHandle, SessionState};
use std::sync::atomic::AtomicU8;
use std::sync::{Arc, Mutex};
use tokio::sync::mpsc;
let sid = Uuid::from_u128(91);
let mut parser = vt100::Parser::new(2, 20, 20);
parser.process(b"one\r\ntwo\r\nthree\r\nfour\r\nfive");
let screen = Arc::new(Mutex::new(parser));
let handle = SessionHandle {
tx: mpsc::channel(1).0,
screen: Arc::clone(&screen),
state: Arc::new(AtomicU8::new(SessionState::Live as u8)),
};
let mut app = make_app();
app.state.active_tab_mut().pane = Some(PaneRef::Session(sid));
app.emulators.insert(sid, handle);
app.scroll_active_pane(3);
assert!(screen.lock().unwrap().screen().scrollback() > 0);
assert!(app.forward_to_session(&InputKey::Enter).is_some());
assert_eq!(screen.lock().unwrap().screen().scrollback(), 0);
}
#[test]
fn mouse_wheel_encoding_supports_sgr_terminals() {
assert_eq!(
encode_mouse_wheel(MouseKind::ScrollUp, 7, 4, vt100::MouseProtocolEncoding::Sgr,),
b"\x1b[<64;7;4M"
);
assert_eq!(
encode_mouse_wheel(
MouseKind::ScrollDown,
7,
4,
vt100::MouseProtocolEncoding::Sgr,
),
b"\x1b[<65;7;4M"
);
}
/// Regression review fix: `Paste(_)` used to be silently dropped, so a
/// paste into an attached session vanished once bracketed paste was
/// enabled. It also has to keep its DECSET 2004 markers — without them a
+54 -11
View File
@@ -8,13 +8,15 @@
use std::time::Duration;
use maestro_config::TuiSection;
use maestro_config::{TuiSection, TuiTheme};
use crate::input::InputKey;
use crate::keymap::{Keymap, KeymapError, Mode};
use crate::palette::Palette;
#[derive(Debug)]
pub struct TuiConfig {
pub palette: Palette,
pub leader: InputKey,
pub chord_timeout: Duration,
pub keymap: Keymap,
@@ -38,11 +40,12 @@ pub enum TuiConfigError {
const DEFAULT_NORMAL: &[(&str, &str)] = &[
// Mode
("i", "EnterInsert"),
// Sidebar nav
// Sidebar navigation
("j", "SidebarNext"),
("k", "SidebarPrev"),
("down", "SidebarNext"),
("up", "SidebarPrev"),
("c", "ToggleSidebarGroup"),
// Catalog actions
("enter", "AttachHighlighted"),
("s", "NewDraftPane"),
@@ -51,19 +54,17 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
("S", "FastForwardSpawn"),
("x", "KillHighlighted"),
("w", "ShowWorktrees"),
// Tabs
("ctrl-t", "NewTab"),
("g t", "NextTab"),
("g T", "PrevTab"),
// Focus
("ctrl-w h", "FocusSidebar"),
// Session scrollback
("pageup", "ScrollPanePageUp"),
("pagedown", "ScrollPanePageDown"),
// Chrome
("ctrl-w b", "ToggleSidebar"),
("?", "ShowHelp"),
("ctrl-w q", "Quit"),
// Mouse
("mouse:dblclick(sidebar-row)", "AttachHighlighted"),
("mouse:click(tab)", "NextTab"),
];
/// Default Insert-mode bindings. Empty by default — the only Insert binding
@@ -76,6 +77,20 @@ impl TuiConfig {
/// field of the loaded `MaestroConfigFile`). The shipped defaults are
/// applied first, then user overrides on top.
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,
TuiTheme::SolarizedDark => Palette::SOLARIZED_DARK,
TuiTheme::SolarizedLight => Palette::SOLARIZED_LIGHT,
TuiTheme::Catppuccin => Palette::CATPPUCCIN,
TuiTheme::GithubDark => Palette::GITHUB_DARK,
TuiTheme::GithubLight => Palette::GITHUB_LIGHT,
TuiTheme::TokyoNight => Palette::TOKYO_NIGHT,
TuiTheme::Gruvbox => Palette::GRUVBOX,
TuiTheme::Nord => Palette::NORD,
TuiTheme::Dracula => Palette::DRACULA,
};
let leader_str = section.leader.as_deref().unwrap_or("ctrl-a");
let leader = InputKey::parse(leader_str)
.map_err(|_| TuiConfigError::BadLeader(leader_str.to_string()))?;
@@ -131,6 +146,7 @@ impl TuiConfig {
}
Ok(TuiConfig {
palette,
leader,
chord_timeout,
keymap,
@@ -143,6 +159,37 @@ impl TuiConfig {
mod tests {
use super::*;
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),
(TuiTheme::SolarizedDark, Palette::SOLARIZED_DARK),
(TuiTheme::SolarizedLight, Palette::SOLARIZED_LIGHT),
(TuiTheme::Catppuccin, Palette::CATPPUCCIN),
(TuiTheme::GithubDark, Palette::GITHUB_DARK),
(TuiTheme::GithubLight, Palette::GITHUB_LIGHT),
(TuiTheme::TokyoNight, Palette::TOKYO_NIGHT),
(TuiTheme::Gruvbox, Palette::GRUVBOX),
(TuiTheme::Nord, Palette::NORD),
(TuiTheme::Dracula, Palette::DRACULA),
] {
let section = TuiSection {
theme: Some(theme),
..Default::default()
};
assert_eq!(TuiConfig::load(&section).unwrap().palette, expected);
}
}
#[test]
fn defaults_cover_core_actions() {
@@ -155,10 +202,6 @@ mod tests {
cfg.keymap.resolve(Mode::Normal, "enter"),
Some(Action::AttachHighlighted),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "g t"),
Some(Action::NextTab),
);
assert_eq!(
cfg.keymap.resolve(Mode::Normal, "s"),
Some(Action::NewDraftPane),
+4 -9
View File
@@ -43,7 +43,7 @@ use maestro_protocol::{BranchInfo, CreateSessionRequest, Project, SpawnPreset, W
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::input::InputKey;
@@ -1078,9 +1078,7 @@ impl DraftPane {
/// cursor placement the same way a session pane hides its cursor when
/// unfocused.
pub fn render(&self, f: &mut Frame, area: Rect, pane_focused: bool) {
let block = Block::default().title(" draft ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let inner = area;
let show_custom = self.is_custom();
let show_raw = self.cwd_raw_visible;
@@ -2117,7 +2115,7 @@ mod tests {
}
/// The terminal cursor must sit right after the typed text in the focused
/// field (gutter is 5 chars, block border takes column 0).
/// field (gutter is 5 chars and the pane has no border).
#[test]
fn render_places_the_cursor_in_the_focused_text_field() {
let mut draft = DraftPane {
@@ -2129,10 +2127,7 @@ mod tests {
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| draft.render(f, f.area(), true)).unwrap();
let pos = terminal.get_cursor_position().unwrap();
assert_eq!(
pos,
ratatui::layout::Position::new(1 + GUTTER as u16 + 2, 2)
);
assert_eq!(pos, ratatui::layout::Position::new(GUTTER as u16 + 2, 1));
}
#[test]
+10 -3
View File
@@ -36,6 +36,8 @@ pub enum InputKey {
Right,
Home,
End,
PageUp,
PageDown,
Delete,
/// Bracketed paste: crossterm delivers the whole payload as one event, so
/// text fields can insert it as a single operation.
@@ -63,7 +65,6 @@ pub enum MouseKind {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Region {
SidebarRow,
Tab,
Pane,
Modal,
Unknown,
@@ -136,6 +137,8 @@ impl InputKey {
InputKey::Right => "right".into(),
InputKey::Home => "home".into(),
InputKey::End => "end".into(),
InputKey::PageUp => "pageup".into(),
InputKey::PageDown => "pagedown".into(),
InputKey::Delete => "delete".into(),
// Pastes are not bindable; the canonical form exists only to
// satisfy exhaustiveness and never round-trips through `parse`.
@@ -150,7 +153,6 @@ impl InputKey {
};
let r = match region {
Region::SidebarRow => "sidebar-row",
Region::Tab => "tab",
Region::Pane => "pane",
Region::Modal => "modal",
Region::Unknown => "unknown",
@@ -182,7 +184,6 @@ impl InputKey {
};
let region = match r {
"sidebar-row" => Region::SidebarRow,
"tab" => Region::Tab,
"pane" => Region::Pane,
"modal" => Region::Modal,
"unknown" => Region::Unknown,
@@ -207,6 +208,8 @@ impl InputKey {
"right" => return Ok(InputKey::Right),
"home" => return Ok(InputKey::Home),
"end" => return Ok(InputKey::End),
"pageup" => return Ok(InputKey::PageUp),
"pagedown" => return Ok(InputKey::PageDown),
"delete" => return Ok(InputKey::Delete),
_ => {}
}
@@ -281,6 +284,8 @@ where
KeyCode::Right => InputKey::Right,
KeyCode::Home => InputKey::Home,
KeyCode::End => InputKey::End,
KeyCode::PageUp => InputKey::PageUp,
KeyCode::PageDown => InputKey::PageDown,
KeyCode::Delete => InputKey::Delete,
_ => return None,
})
@@ -617,6 +622,8 @@ mod tests {
"right",
"home",
"end",
"pageup",
"pagedown",
"delete",
] {
let k = InputKey::parse(s).unwrap();
+4 -4
View File
@@ -107,8 +107,8 @@ mod tests {
#[test]
fn resolves_chord() {
let k = build(&[(Mode::Normal, "g t", "NextTab")]);
assert_eq!(k.resolve(Mode::Normal, "g t"), Some(Action::NextTab));
let k = build(&[(Mode::Normal, "g h", "FocusSidebar")]);
assert_eq!(k.resolve(Mode::Normal, "g h"), Some(Action::FocusSidebar));
}
#[test]
@@ -123,11 +123,11 @@ mod tests {
#[test]
fn known_bindings_sees_everything() {
let k = build(&[
(Mode::Normal, "g t", "NextTab"),
(Mode::Normal, "g h", "FocusSidebar"),
(Mode::Normal, "i", "EnterInsert"),
]);
let kb = k.known_bindings(Mode::Normal);
assert!(kb.contains("g t"));
assert!(kb.contains("g h"));
assert!(kb.contains("i"));
assert_eq!(kb.len(), 2);
}
+1 -1
View File
@@ -20,7 +20,6 @@ pub mod render;
pub mod session_task;
pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
pub mod text_input;
pub mod worktrees;
@@ -90,6 +89,7 @@ pub async fn run(cfg: RunConfig) -> Result<(), TuiError> {
Err(_) => maestro_config::MaestroConfigFile::default(),
};
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 ws_url = daemon
+4 -4
View File
@@ -155,12 +155,12 @@ mod tests {
fn help_overlay_lists_lines() {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s NewDraftPane".into(),
"i Send keys to session".into(),
"s Create a session".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("NewDraftPane"));
assert!(rendered.contains("Send keys to session"));
assert!(rendered.contains("Create a session"));
}
}
+575 -86
View File
@@ -16,9 +16,12 @@
//! the selected card uses the selection surface's own state colours; the dot
//! keeps carrying the state either way (docs/SIDEBAR.md).
//!
//! One palette ships today, reached through [`active`]. Selecting between
//! presets from config is a separate issue; [`active`] is the seam it
//! replaces.
//! [`Palette::DARK`] is the default. [`Palette::LIGHT`] is its light peer and
//! [`Palette::ANSI`] uses terminal-provided ANSI colours. The remaining
//! presets ([`Palette::presets`]) adapt well-known editor themes — Solarized,
//! Catppuccin, GitHub, Tokyo Night, Gruvbox, Nord, Dracula — with individual
//! colours lifted or darkened where the originals fall short of the contrast
//! floors asserted below.
//!
//! Out of scope: PTY cell colours. Those belong to the program running in the
//! pane and pass through `pane::translate_color` verbatim.
@@ -110,6 +113,8 @@ pub struct Palette {
/// paint this background too; otherwise its contrast depends on the
/// operator's terminal theme.
pub chrome_bg: Color,
/// Subtle full-panel background while the sidebar owns focus.
pub sidebar_focus_bg: Color,
/// Panel border while that panel holds focus.
pub border_focused: Color,
/// Panel border while it does not.
@@ -130,40 +135,489 @@ pub struct Palette {
}
impl Palette {
/// The one palette that ships today.
pub const DEFAULT: Palette = Palette {
/// The dark terminal-native palette and the default preset.
pub const DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x14, 0x16, 0x1b),
fg: Color::Rgb(0xd6, 0xdc, 0xe5),
muted: Color::Rgb(0x9a, 0xa3, 0xb2),
dim: Color::Rgb(0x6b, 0x72, 0x80),
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),
sidebar_focus_bg: Color::Rgb(0x28, 0x30, 0x2a),
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),
sidebar_focus_bg: Color::Rgb(0xee, 0xf5, 0xef),
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,
sidebar_focus_bg: Color::DarkGray,
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,
};
/// Solarized Dark — Ethan Schoonover's low-contrast classic,
/// accents lifted where the originals fall short of WCAG AA.
pub const SOLARIZED_DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x00, 0x2b, 0x36),
fg: Color::Rgb(0x93, 0xa1, 0xa1),
muted: Color::Rgb(0x83, 0x94, 0x96),
dim: Color::Rgb(0x65, 0x7b, 0x83),
active: Color::Rgb(0x85, 0x99, 0x00),
idle: Color::Rgb(0x83, 0x94, 0x96),
awaiting: Color::Rgb(0xb5, 0x89, 0x00),
error: Color::Rgb(0xe5, 0x68, 0x65),
},
selection: Surface {
bg: Color::Rgb(0x10, 0x53, 0x62),
fg: Color::Rgb(0xb6, 0xc0, 0xc0),
muted: Color::Rgb(0xb5, 0xc0, 0xc1),
dim: Color::Rgb(0x8b, 0x9f, 0xa6),
active: Color::Rgb(0xaf, 0xca, 0x00),
idle: Color::Rgb(0xb5, 0xc0, 0xc1),
awaiting: Color::Rgb(0xef, 0xb5, 0x00),
error: Color::Rgb(0xf1, 0xad, 0xab),
},
chrome_bg: Color::Rgb(0x00, 0x2b, 0x36),
sidebar_focus_bg: Color::Rgb(0x00, 0x1a, 0x21),
border_focused: Color::Rgb(0x2a, 0xa1, 0x98),
border_unfocused: Color::Rgb(0x5e, 0x76, 0x7e),
focus_accent: Color::Rgb(0x2a, 0xa1, 0x98),
group_row: Color::Rgb(0x93, 0xa1, 0xa1),
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
mode_insert_bg: Color::Rgb(0xb5, 0x89, 0x00),
mode_insert_fg: Color::Rgb(0x00, 0x2b, 0x36),
error_text: Color::Rgb(0xe5, 0x68, 0x65),
};
/// Solarized Light — the warm cream-paper peer of Solarized Dark.
pub const SOLARIZED_LIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0xfd, 0xf6, 0xe3),
fg: Color::Rgb(0x58, 0x6e, 0x75),
muted: Color::Rgb(0x5e, 0x73, 0x7a),
dim: Color::Rgb(0x7e, 0x8f, 0x92),
active: Color::Rgb(0x67, 0x76, 0x00),
idle: Color::Rgb(0x5e, 0x73, 0x7a),
awaiting: Color::Rgb(0x8d, 0x6a, 0x00),
error: Color::Rgb(0xd7, 0x27, 0x24),
},
selection: Surface {
bg: Color::Rgb(0xcb, 0xbd, 0x93),
fg: Color::Rgb(0x3f, 0x4e, 0x53),
muted: Color::Rgb(0x40, 0x4e, 0x53),
dim: Color::Rgb(0x59, 0x67, 0x69),
active: Color::Rgb(0x46, 0x50, 0x00),
idle: Color::Rgb(0x40, 0x4e, 0x53),
awaiting: Color::Rgb(0x60, 0x48, 0x00),
error: Color::Rgb(0x94, 0x1b, 0x19),
},
chrome_bg: Color::Rgb(0xfd, 0xf6, 0xe3),
sidebar_focus_bg: Color::Rgb(0xf7, 0xf4, 0xed),
border_focused: Color::Rgb(0x29, 0x9b, 0x93),
border_unfocused: Color::Rgb(0x7f, 0x8f, 0x8f),
focus_accent: Color::Rgb(0x29, 0x9b, 0x93),
group_row: Color::Rgb(0x58, 0x6e, 0x75),
mode_normal_bg: Color::Rgb(0x2a, 0xa1, 0x98),
mode_normal_fg: Color::Rgb(0x00, 0x2b, 0x36),
mode_insert_bg: Color::Rgb(0x8d, 0x6a, 0x00),
mode_insert_fg: Color::Rgb(0xfd, 0xf6, 0xe3),
error_text: Color::Rgb(0xd7, 0x27, 0x24),
};
/// Catppuccin Mocha — soothing pastels on a soft dark base.
pub const CATPPUCCIN: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1e, 0x1e, 0x2e),
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
muted: Color::Rgb(0xa6, 0xad, 0xc8),
dim: Color::Rgb(0x7f, 0x84, 0x9c),
active: Color::Rgb(0xa6, 0xe3, 0xa1),
idle: Color::Rgb(0xa6, 0xad, 0xc8),
awaiting: Color::Rgb(0xf9, 0xe2, 0xaf),
error: Color::Rgb(0xf3, 0x8b, 0xa8),
},
selection: Surface {
bg: Color::Rgb(0x45, 0x47, 0x5a),
fg: Color::Rgb(0xcd, 0xd6, 0xf4),
muted: Color::Rgb(0xb2, 0xb8, 0xcf),
dim: Color::Rgb(0x92, 0x97, 0xab),
active: Color::Rgb(0xb5, 0xe8, 0xb1),
idle: Color::Rgb(0xb2, 0xb8, 0xcf),
awaiting: Color::Rgb(0xfa, 0xe9, 0xc2),
error: Color::Rgb(0xf5, 0xa1, 0xb8),
},
chrome_bg: Color::Rgb(0x1e, 0x1e, 0x2e),
sidebar_focus_bg: Color::Rgb(0x2a, 0x2a, 0x3c),
border_focused: Color::Rgb(0xcb, 0xa6, 0xf7),
border_unfocused: Color::Rgb(0x6c, 0x70, 0x86),
focus_accent: Color::Rgb(0xcb, 0xa6, 0xf7),
group_row: Color::Rgb(0xb4, 0xbe, 0xfe),
mode_normal_bg: Color::Rgb(0xcb, 0xa6, 0xf7),
mode_normal_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
mode_insert_bg: Color::Rgb(0xf9, 0xe2, 0xaf),
mode_insert_fg: Color::Rgb(0x1e, 0x1e, 0x2e),
error_text: Color::Rgb(0xf3, 0x8b, 0xa8),
};
/// GitHub Dark — the github.com dark default.
pub const GITHUB_DARK: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x0d, 0x11, 0x17),
fg: Color::Rgb(0xe6, 0xed, 0xf3),
muted: Color::Rgb(0x91, 0x98, 0xa1),
dim: Color::Rgb(0x6e, 0x76, 0x81),
active: Color::Rgb(0x3f, 0xb9, 0x50),
idle: Color::Rgb(0x8b, 0x94, 0x9e),
idle: Color::Rgb(0x91, 0x98, 0xa1),
awaiting: Color::Rgb(0xd2, 0x99, 0x22),
error: Color::Rgb(0xf8, 0x51, 0x49),
},
selection: Surface {
bg: Color::Rgb(0x2b, 0x3f, 0x6e),
fg: Color::Rgb(0xf0, 0xf4, 0xfa),
muted: Color::Rgb(0xc0, 0xc9, 0xda),
dim: Color::Rgb(0x93, 0xa0, 0xbc),
active: Color::Rgb(0x7e, 0xe7, 0x87),
idle: Color::Rgb(0xb6, 0xc0, 0xd4),
awaiting: Color::Rgb(0xf2, 0xcc, 0x60),
error: Color::Rgb(0xff, 0x94, 0x92),
bg: Color::Rgb(0x1f, 0x3a, 0x5f),
fg: Color::Rgb(0xe6, 0xed, 0xf3),
muted: Color::Rgb(0x9f, 0xa5, 0xad),
dim: Color::Rgb(0x7e, 0x86, 0x91),
active: Color::Rgb(0x40, 0xbc, 0x51),
idle: Color::Rgb(0x9f, 0xa5, 0xad),
awaiting: Color::Rgb(0xd4, 0x9b, 0x22),
error: Color::Rgb(0xfa, 0x81, 0x7b),
},
chrome_bg: Color::Rgb(0x14, 0x16, 0x1b),
chrome_bg: Color::Rgb(0x0d, 0x11, 0x17),
sidebar_focus_bg: Color::Rgb(0x16, 0x1b, 0x26),
border_focused: Color::Rgb(0x58, 0xa6, 0xff),
border_unfocused: Color::Rgb(0x6b, 0x72, 0x80),
border_unfocused: Color::Rgb(0x58, 0x64, 0x70),
focus_accent: Color::Rgb(0x58, 0xa6, 0xff),
group_row: Color::Rgb(0x8f, 0xa3, 0xc8),
group_row: Color::Rgb(0x91, 0x98, 0xa1),
mode_normal_bg: Color::Rgb(0x58, 0xa6, 0xff),
mode_normal_fg: Color::Rgb(0x0b, 0x0d, 0x11),
mode_normal_fg: Color::Rgb(0x0d, 0x11, 0x17),
mode_insert_bg: Color::Rgb(0xd2, 0x99, 0x22),
mode_insert_fg: Color::Rgb(0x0b, 0x0d, 0x11),
mode_insert_fg: Color::Rgb(0x0d, 0x11, 0x17),
error_text: Color::Rgb(0xf8, 0x51, 0x49),
};
/// GitHub Light — the github.com light default.
pub const GITHUB_LIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0xff, 0xff, 0xff),
fg: Color::Rgb(0x1f, 0x23, 0x28),
muted: Color::Rgb(0x59, 0x63, 0x6e),
dim: Color::Rgb(0x81, 0x8b, 0x98),
active: Color::Rgb(0x1a, 0x7f, 0x37),
idle: Color::Rgb(0x59, 0x63, 0x6e),
awaiting: Color::Rgb(0x9a, 0x67, 0x00),
error: Color::Rgb(0xd1, 0x24, 0x2f),
},
selection: Surface {
bg: Color::Rgb(0xa5, 0xcd, 0xf0),
fg: Color::Rgb(0x1f, 0x23, 0x28),
muted: Color::Rgb(0x4b, 0x53, 0x5d),
dim: Color::Rgb(0x64, 0x6d, 0x7a),
active: Color::Rgb(0x14, 0x5f, 0x29),
idle: Color::Rgb(0x4b, 0x53, 0x5d),
awaiting: Color::Rgb(0x71, 0x4b, 0x00),
error: Color::Rgb(0xa1, 0x1c, 0x24),
},
chrome_bg: Color::Rgb(0xff, 0xff, 0xff),
sidebar_focus_bg: Color::Rgb(0xf0, 0xf6, 0xfc),
border_focused: Color::Rgb(0x09, 0x69, 0xda),
border_unfocused: Color::Rgb(0x89, 0x93, 0x9d),
focus_accent: Color::Rgb(0x09, 0x69, 0xda),
group_row: Color::Rgb(0x59, 0x63, 0x6e),
mode_normal_bg: Color::Rgb(0x09, 0x69, 0xda),
mode_normal_fg: Color::Rgb(0xff, 0xff, 0xff),
mode_insert_bg: Color::Rgb(0x9a, 0x67, 0x00),
mode_insert_fg: Color::Rgb(0xff, 0xff, 0xff),
error_text: Color::Rgb(0xd1, 0x24, 0x2f),
};
/// Tokyo Night — neon-tinted blues of the VS Code / vim favourite.
pub const TOKYO_NIGHT: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x1a, 0x1b, 0x26),
fg: Color::Rgb(0xc0, 0xca, 0xf5),
muted: Color::Rgb(0xa9, 0xb1, 0xd6),
dim: Color::Rgb(0x5d, 0x67, 0x95),
active: Color::Rgb(0x9e, 0xce, 0x6a),
idle: Color::Rgb(0xa9, 0xb1, 0xd6),
awaiting: Color::Rgb(0xe0, 0xaf, 0x68),
error: Color::Rgb(0xf7, 0x76, 0x8e),
},
selection: Surface {
bg: Color::Rgb(0x33, 0x46, 0x7c),
fg: Color::Rgb(0xc0, 0xca, 0xf5),
muted: Color::Rgb(0xb0, 0xb8, 0xd9),
dim: Color::Rgb(0x8e, 0x96, 0xb8),
active: Color::Rgb(0xa8, 0xd3, 0x79),
idle: Color::Rgb(0xb0, 0xb8, 0xd9),
awaiting: Color::Rgb(0xe1, 0xb1, 0x6b),
error: Color::Rgb(0xf9, 0xa0, 0xb0),
},
chrome_bg: Color::Rgb(0x1a, 0x1b, 0x26),
sidebar_focus_bg: Color::Rgb(0x24, 0x28, 0x3b),
border_focused: Color::Rgb(0x7a, 0xa2, 0xf7),
border_unfocused: Color::Rgb(0x5d, 0x67, 0x95),
focus_accent: Color::Rgb(0x7a, 0xa2, 0xf7),
group_row: Color::Rgb(0xa9, 0xb1, 0xd6),
mode_normal_bg: Color::Rgb(0x7a, 0xa2, 0xf7),
mode_normal_fg: Color::Rgb(0x1a, 0x1b, 0x26),
mode_insert_bg: Color::Rgb(0xe0, 0xaf, 0x68),
mode_insert_fg: Color::Rgb(0x1a, 0x1b, 0x26),
error_text: Color::Rgb(0xf7, 0x76, 0x8e),
};
/// Gruvbox Dark — retro warm earth tones.
pub const GRUVBOX: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x28, 0x28, 0x28),
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
muted: Color::Rgb(0xbd, 0xae, 0x93),
dim: Color::Rgb(0x92, 0x83, 0x74),
active: Color::Rgb(0xb8, 0xbb, 0x26),
idle: Color::Rgb(0xbd, 0xae, 0x93),
awaiting: Color::Rgb(0xfa, 0xbd, 0x2f),
error: Color::Rgb(0xfb, 0x58, 0x44),
},
selection: Surface {
bg: Color::Rgb(0x50, 0x49, 0x45),
fg: Color::Rgb(0xeb, 0xdb, 0xb2),
muted: Color::Rgb(0xc7, 0xbb, 0xa4),
dim: Color::Rgb(0xa4, 0x98, 0x8b),
active: Color::Rgb(0xbf, 0xc3, 0x28),
idle: Color::Rgb(0xc7, 0xbb, 0xa4),
awaiting: Color::Rgb(0xfa, 0xc3, 0x43),
error: Color::Rgb(0xfd, 0xa5, 0x9a),
},
chrome_bg: Color::Rgb(0x28, 0x28, 0x28),
sidebar_focus_bg: Color::Rgb(0x32, 0x30, 0x2f),
border_focused: Color::Rgb(0xfe, 0x80, 0x19),
border_unfocused: Color::Rgb(0x7e, 0x71, 0x67),
focus_accent: Color::Rgb(0xfe, 0x80, 0x19),
group_row: Color::Rgb(0xd5, 0xc4, 0xa1),
mode_normal_bg: Color::Rgb(0xfe, 0x80, 0x19),
mode_normal_fg: Color::Rgb(0x28, 0x28, 0x28),
mode_insert_bg: Color::Rgb(0xfa, 0xbd, 0x2f),
mode_insert_fg: Color::Rgb(0x28, 0x28, 0x28),
error_text: Color::Rgb(0xfb, 0x58, 0x44),
};
/// Nord — arctic, bluish and calm; the red lifted to stay legible.
pub const NORD: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x2e, 0x34, 0x40),
fg: Color::Rgb(0xec, 0xef, 0xf4),
muted: Color::Rgb(0xd8, 0xde, 0xe9),
dim: Color::Rgb(0x9a, 0xa5, 0xb5),
active: Color::Rgb(0xa3, 0xbe, 0x8c),
idle: Color::Rgb(0xd8, 0xde, 0xe9),
awaiting: Color::Rgb(0xeb, 0xcb, 0x8b),
error: Color::Rgb(0xd0, 0x8b, 0x91),
},
selection: Surface {
bg: Color::Rgb(0x4c, 0x56, 0x6a),
fg: Color::Rgb(0xec, 0xef, 0xf4),
muted: Color::Rgb(0xd8, 0xde, 0xe9),
dim: Color::Rgb(0x9f, 0xaa, 0xb9),
active: Color::Rgb(0xc0, 0xd3, 0xb0),
idle: Color::Rgb(0xe5, 0xe9, 0xf0),
awaiting: Color::Rgb(0xee, 0xd3, 0x9c),
error: Color::Rgb(0xe7, 0xc5, 0xc8),
},
chrome_bg: Color::Rgb(0x2e, 0x34, 0x40),
sidebar_focus_bg: Color::Rgb(0x35, 0x3b, 0x49),
border_focused: Color::Rgb(0x88, 0xc0, 0xd0),
border_unfocused: Color::Rgb(0x72, 0x80, 0x9a),
focus_accent: Color::Rgb(0x88, 0xc0, 0xd0),
group_row: Color::Rgb(0xd8, 0xde, 0xe9),
mode_normal_bg: Color::Rgb(0x88, 0xc0, 0xd0),
mode_normal_fg: Color::Rgb(0x2e, 0x34, 0x40),
mode_insert_bg: Color::Rgb(0xeb, 0xcb, 0x8b),
mode_insert_fg: Color::Rgb(0x2e, 0x34, 0x40),
error_text: Color::Rgb(0xd0, 0x8b, 0x91),
};
/// Dracula — high-saturation accents on a dark violet-grey base.
pub const DRACULA: Palette = Palette {
card: Surface {
bg: Color::Rgb(0x28, 0x2a, 0x36),
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
dim: Color::Rgb(0x65, 0x74, 0xa6),
active: Color::Rgb(0x50, 0xfa, 0x7b),
idle: Color::Rgb(0xbf, 0xc3, 0xd9),
awaiting: Color::Rgb(0xf1, 0xfa, 0x8c),
error: Color::Rgb(0xff, 0x59, 0x59),
},
selection: Surface {
bg: Color::Rgb(0x4a, 0x4e, 0x62),
fg: Color::Rgb(0xf8, 0xf8, 0xf2),
muted: Color::Rgb(0xbf, 0xc3, 0xd9),
dim: Color::Rgb(0x94, 0x9f, 0xc1),
active: Color::Rgb(0x64, 0xfb, 0x8a),
idle: Color::Rgb(0xcc, 0xcf, 0xe1),
awaiting: Color::Rgb(0xf3, 0xfb, 0xa0),
error: Color::Rgb(0xff, 0xad, 0xad),
},
chrome_bg: Color::Rgb(0x28, 0x2a, 0x36),
sidebar_focus_bg: Color::Rgb(0x31, 0x33, 0x42),
border_focused: Color::Rgb(0xbd, 0x93, 0xf9),
border_unfocused: Color::Rgb(0x65, 0x74, 0xa6),
focus_accent: Color::Rgb(0xbd, 0x93, 0xf9),
group_row: Color::Rgb(0xbf, 0xc3, 0xd9),
mode_normal_bg: Color::Rgb(0xbd, 0x93, 0xf9),
mode_normal_fg: Color::Rgb(0x28, 0x2a, 0x36),
mode_insert_bg: Color::Rgb(0xf1, 0xfa, 0x8c),
mode_insert_fg: Color::Rgb(0x28, 0x2a, 0x36),
error_text: Color::Rgb(0xff, 0x59, 0x59),
};
/// 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); 12] {
[
("dark", Self::DARK),
("light", Self::LIGHT),
("ansi", Self::ANSI),
("solarized-dark", Self::SOLARIZED_DARK),
("solarized-light", Self::SOLARIZED_LIGHT),
("catppuccin", Self::CATPPUCCIN),
("github-dark", Self::GITHUB_DARK),
("github-light", Self::GITHUB_LIGHT),
("tokyo-night", Self::TOKYO_NIGHT),
("gruvbox", Self::GRUVBOX),
("nord", Self::NORD),
("dracula", Self::DRACULA),
]
}
/// Resolve a config name to a palette.
pub fn named(name: &str) -> Option<Palette> {
Self::presets()
.into_iter()
.find(|(n, _)| *n == name)
.map(|(_, p)| p)
}
/// Every role, including the backgrounds it is painted against.
pub fn roles(&self) -> [(&'static str, Color); 27] {
[
("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),
("sidebar_focus_bg", self.sidebar_focus_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 {
@@ -218,9 +672,20 @@ impl Default for Palette {
}
}
/// The palette in force. Config-driven preset selection lands here.
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 {
Palette::DEFAULT
*active_slot().read().expect("active palette lock poisoned")
}
/// WCAG relative luminance of an RGB role. Non-RGB colours have no fixed
@@ -260,34 +725,53 @@ mod tests {
ActivityState::Error,
];
/// Every preset except `ansi`, whose colours the terminal owns and which
/// therefore has no fixed luminance to assert against.
fn rgb_presets() -> impl Iterator<Item = (&'static str, Palette)> {
Palette::presets()
.into_iter()
.filter(|(_, p)| relative_luminance(p.card.bg).is_some())
}
/// 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() {
let p = active();
for (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!("{name}.{role} must be an RGB value"));
let floor = if role == "dim" { 3.0 } else { 4.5 };
assert!(
ratio >= floor,
"{name}.{role} contrast {ratio:.2} is below {floor}"
);
for (preset, p) in rgb_presets() {
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(), 27, "{name} role set is incomplete");
assert_eq!(Palette::named(name), Some(p));
}
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() {
let p = active();
let ratio = contrast_ratio(p.card.bg, p.selection.bg).unwrap();
assert!(
ratio >= 1.6,
"card and selection backgrounds differ by only {ratio:.2}"
);
for (name, p) in rgb_presets() {
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
@@ -295,13 +779,14 @@ mod tests {
/// selection surface carries its own — distinct — state colours.
#[test]
fn selection_state_colours_are_not_the_card_state_colours() {
let p = active();
for state in STATES {
assert_ne!(
p.card.activity(state),
p.selection.activity(state),
"{state:?} must have a selection-specific colour"
);
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"
);
}
}
}
@@ -310,44 +795,48 @@ mod tests {
/// actually painted on; they must never rely on the terminal default.
#[test]
fn chrome_roles_are_rgb_values() {
let p = active();
for (name, color) in [
("chrome_bg", p.chrome_bg),
("border_focused", p.border_focused),
("border_unfocused", p.border_unfocused),
("focus_accent", p.focus_accent),
("group_row", p.group_row),
("mode_normal_bg", p.mode_normal_bg),
("mode_normal_fg", p.mode_normal_fg),
("mode_insert_bg", p.mode_insert_bg),
("mode_insert_fg", p.mode_insert_fg),
("error_text", p.error_text),
] {
assert!(
relative_luminance(color).is_some(),
"{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, "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,
"{name} contrast {ratio:.2} is below {floor} on chrome_bg"
);
for (preset, p) in rgb_presets() {
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"
);
}
for (name, fg) in [
("card.fg", p.card.fg),
("card.muted", p.card.muted),
("group_row", p.group_row),
] {
let ratio = contrast_ratio(fg, p.sidebar_focus_bg).unwrap();
assert!(
ratio >= 4.5,
"{preset}.{name} contrast {ratio:.2} is below 4.5 on sidebar_focus_bg"
);
}
}
}
+1 -1
View File
@@ -74,7 +74,7 @@ impl Pane {
// Place the terminal cursor at vt100's cursor position when we own
// focus and the cursor is on-screen. Terminals blink it on their own.
if self.focused && !screen.hide_cursor() {
if self.focused && screen.scrollback() == 0 && !screen.hide_cursor() {
let (cr, cc) = screen.cursor_position();
if cr < rows && cc < cols {
f.set_cursor_position((area.x + cc, area.y + cr));
+180 -64
View File
@@ -1,11 +1,11 @@
//! Top-level draw orchestration. Composes sidebar / tab bar / pane / overlays
//! Top-level draw orchestration. Composes sidebar / pane / overlays
//! into one frame, and returns a [`RegionMap`] that the input router uses to
//! hit-test mouse clicks.
use ratatui::layout::{Constraint, Direction, Layout, Rect};
use ratatui::style::Style;
use ratatui::style::Modifier;
use ratatui::text::Span;
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::Paragraph;
use ratatui::Frame;
use crate::input::Region;
@@ -14,8 +14,7 @@ use crate::overlays::{HelpOverlay, KillConfirm, Toast};
use crate::palette;
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind};
use crate::tabs::TabBar;
use crate::state::{AppState, Focus, OverlayKind, SidebarItem};
use crate::worktrees::{ReclaimConfirm, WorktreesView};
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
@@ -23,7 +22,7 @@ use crate::worktrees::{ReclaimConfirm, WorktreesView};
#[derive(Debug, Default, Clone)]
pub struct RegionMap {
pub sidebar: Option<Rect>,
pub tab_bar: Option<Rect>,
pub sidebar_items: Vec<SidebarItem>,
pub pane: Option<Rect>,
pub modal: Option<Rect>,
}
@@ -35,11 +34,6 @@ impl RegionMap {
return Region::Modal;
}
}
if let Some(r) = self.tab_bar {
if inside(r, col, row) {
return Region::Tab;
}
}
if let Some(r) = self.sidebar {
if inside(r, col, row) {
return Region::SidebarRow;
@@ -52,6 +46,24 @@ impl RegionMap {
}
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(1);
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 {
@@ -63,9 +75,10 @@ fn inside(r: Rect, col: u16, row: u16) -> bool {
#[derive(Debug)]
pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub home_dir: &'a str,
pub pane_title: &'a str,
pub pane: Option<&'a Pane>,
/// Inline spawn form when the active tab's pane is a draft (issue #63).
/// Inline spawn form when the pane is a draft (issue #63).
pub draft: Option<&'a crate::draft::DraftPane>,
pub state: &'a AppState,
pub help: &'a HelpOverlay,
@@ -102,43 +115,36 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
let right = cols[1];
if inputs.state.sidebar_visible {
inputs
.sidebar
.render_with_focus(f, sidebar_area, inputs.state.focus == Focus::Sidebar);
inputs.sidebar.render_with_focus_and_collapsed_home(
f,
sidebar_area,
inputs.state.focus == Focus::Sidebar,
&inputs.state.collapsed_sidebar_groups,
inputs.home_dir,
);
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
// indicator) + flex pane area below. Saves the 4 rows the old
// `Length(3) + Borders::ALL` layout was eating before any content showed.
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([
Constraint::Length(1),
Constraint::Length(1),
Constraint::Min(0),
])
.constraints([Constraint::Length(1), Constraint::Min(0)])
.split(right);
let tab_area = rows[0];
let separator_area = rows[1];
let pane_area = rows[2];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let title_area = rows[0];
let pane_area = rows[1];
let pane_focused = inputs.state.focus == Focus::Pane;
f.render_widget(
Block::default()
.borders(Borders::TOP)
.border_style(focus_style(pane_focused)),
separator_area,
);
render_title_bar(f, title_area, inputs.pane_title, pane_focused);
if let Some(p) = inputs.pane {
p.render(f, pane_area);
}
if let Some(d) = inputs.draft {
d.render(f, pane_area, pane_focused);
}
// `RegionMap::pane` is also the PTY resize source. The title bar is
// chrome; every remaining cell belongs to the emulator.
map.pane = Some(pane_area);
// Status bar — always visible, never eclipsed by overlays so the user can
@@ -194,10 +200,15 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
map
}
/// Border style for a panel based on whether it currently has focus. Both
/// forms are palette roles (`border_focused` / `border_unfocused`).
fn focus_style(focused: bool) -> Style {
palette::active().border_style(focused)
fn render_title_bar(f: &mut Frame, area: Rect, title: &str, focused: bool) {
let palette = palette::active();
let style = if focused {
palette.mode_style(false)
} else {
palette.card.text_style()
}
.add_modifier(Modifier::BOLD);
f.render_widget(Paragraph::new(format!(" {title}")).style(style), area);
}
/// Render the always-visible status bar at the bottom of the frame. Surfaces
@@ -214,7 +225,7 @@ fn render_status_bar(f: &mut Frame, area: Rect, state: &AppState) {
};
let hint = match state.mode {
Mode::Normal => "j/k: nav • enter: attach • s: new draft • ?: help • ^Wq: quit",
Mode::Insert => "^a: back to normal",
Mode::Insert => "^a: return to sidebar",
};
let mode_style = palette::active().mode_style(state.mode == Mode::Insert);
let line = ratatui::text::Line::from(vec![
@@ -238,22 +249,16 @@ mod tests {
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
height: 20,
}),
modal: None,
};
assert_eq!(map.region_at(5, 5), Region::SidebarRow);
assert_eq!(map.region_at(15, 1), Region::Tab);
assert_eq!(map.region_at(15, 5), Region::Pane);
assert_eq!(map.region_at(50, 50), Region::Unknown);
}
@@ -267,17 +272,12 @@ mod tests {
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
sidebar_items: Vec::new(),
pane: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
height: 20,
}),
modal: Some(Rect {
x: 5,
@@ -291,6 +291,40 @@ mod tests {
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(1), Some(group_a));
assert_eq!(map.sidebar_item_at(2), Some(card_a.clone()));
assert_eq!(map.sidebar_item_at(3), Some(card_a));
assert_eq!(map.sidebar_item_at(4), 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(2),
Some(SidebarItem::Group(PathBuf::from("/b")))
);
}
/// Issue #63: an empty pane is a draft — the frame must show the spawn
/// form where the terminal would otherwise be.
#[test]
@@ -310,14 +344,11 @@ mod tests {
highlight: None,
attached: HashSet::new(),
};
let tab_bar = TabBar {
tab_names: vec!["1".into()],
active_idx: 0,
};
let help = HelpOverlay { lines: Vec::new() };
let inputs = RenderInputs {
sidebar: &sidebar,
tab_bar: &tab_bar,
home_dir: "",
pane_title: "new session",
pane: None,
draft: Some(draft),
state: &state,
@@ -341,7 +372,7 @@ mod tests {
out.push('\n');
}
for s in [
"draft",
"new session",
"cmd:",
"dir:",
"name:",
@@ -351,4 +382,89 @@ mod tests {
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 help = HelpOverlay { lines: Vec::new() };
let draw_map = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
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.pane, pane_focus.pane);
assert_eq!(sidebar_focus.pane, Some(Rect::new(28, 1, 52, 22)));
}
#[test]
fn panel_focus_uses_backgrounds_without_borders() {
use crate::overlays::HelpOverlay;
use crate::sidebar::Sidebar;
use std::collections::HashSet;
let sidebar = Sidebar {
sessions: Vec::new(),
highlight: None,
attached: HashSet::new(),
};
let help = HelpOverlay { lines: Vec::new() };
let render = |focus| {
let mut state = AppState::new();
state.focus = focus;
let inputs = RenderInputs {
sidebar: &sidebar,
home_dir: "",
pane_title: "session",
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().bg, Some(p.sidebar_focus_bg));
assert_eq!(pane_focused[(0, 0)].style().bg, Some(p.chrome_bg));
assert_eq!(sidebar_focused[(28, 0)].style().bg, Some(p.card.bg));
assert_eq!(pane_focused[(28, 0)].style().bg, Some(p.mode_normal_bg));
let title: String = (28..80).map(|x| pane_focused[(x, 0)].symbol()).collect();
assert!(title.contains("session"), "missing title: {title:?}");
}
}
+5 -1
View File
@@ -23,6 +23,10 @@ use maestro_protocol::ClientControl;
use tokio::sync::mpsc;
use tokio_tungstenite::tungstenite::Message;
/// Match the browser terminal's retained history without letting long-running
/// sessions grow the client without bound.
pub const SCROLLBACK_ROWS: usize = 5_000;
/// Input the dispatcher (or the resize tracker) pushes to a session task.
#[derive(Debug, Clone)]
pub enum SessionInput {
@@ -103,7 +107,7 @@ impl SessionTask {
/// up with `SessionInput::Resize` on every frame whose allocation changes.
pub fn spawn(ws_url: String, rows: u16, cols: u16) -> SessionHandle {
let (tx, mut rx) = mpsc::channel::<SessionInput>(64);
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, SCROLLBACK_ROWS)));
let state = Arc::new(AtomicU8::new(SessionState::Connecting as u8));
let parser_bg = parser.clone();
+462 -128
View File
@@ -4,18 +4,21 @@
//! Line 2: activity dot + duration in the state's colour, plus the
//! adaptive detail, blocked-work, exited, and command fragments.
use std::collections::HashSet;
use std::collections::{BTreeMap, HashSet};
use std::path::PathBuf;
use chrono::Utc;
use maestro_protocol::display::{abbreviate_dir, abbreviate_folder_name, session_tree_path};
use maestro_protocol::{ActivityState, Session, SessionStatus};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::widgets::{Block, Paragraph};
use ratatui::Frame;
use uuid::Uuid;
use crate::palette;
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).
@@ -28,82 +31,213 @@ const META_LINE_AFTER_SECS: i64 = 30;
const PLAUSIBLE_SINCE_YEAR: i32 = 2010;
const INDENT: &str = " ";
const META_INDENT: &str = " ";
/// Spawn-similar and kill, shown on the highlighted card's top-right corner.
const ACTIONS: &str = "⧉ ✕";
/// Every card sits on the palette's card surface so cards read as cards
/// against the panel; the highlighted card swaps in the selection surface
/// instead (`Modifier::REVERSED` made the card unreadable against a
/// background).
fn card_base(highlighted: bool) -> Style {
palette::active().surface(highlighted).text_style()
/// Cards use the selection surface only while the sidebar owns focus. An
/// unfocused highlight remains an edge-marked cursor without claiming the
/// keyboard via a filled selection background.
fn card_base(highlighted: bool, focused: bool) -> Style {
let palette = palette::active();
let mut style = palette.surface(highlighted && focused).text_style();
if focused && !highlighted {
style = style.bg(palette.sidebar_focus_bg);
}
style
}
/// Renderable sidebar state.
#[derive(Debug, Clone)]
pub struct Sidebar {
pub sessions: Vec<Session>,
pub highlight: Option<Uuid>,
pub highlight: Option<SidebarItem>,
pub attached: HashSet<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SidebarGroup {
pub key: PathBuf,
pub sessions: Vec<Session>,
pub collapsed: bool,
}
impl Sidebar {
pub fn render(&self, f: &mut Frame, area: Rect) {
self.render_with_focus(f, area, false);
}
/// Same as [`Sidebar::render`] but tints the right-edge separator in the
/// focus colour when `focused`. Box borders eat too many rows/cols for
/// the v1 layout — a single vertical rule keeps the panel boundary
/// without stealing space.
/// Render the panel without a border. Focus is the panel background, so
/// it remains obvious without spending rows or columns on chrome.
pub fn render_with_focus(&self, f: &mut Frame, area: Rect, focused: bool) {
let border_style = palette::active().border_style(focused);
let block = Block::default()
.borders(Borders::RIGHT)
.border_style(border_style);
let inner = block.inner(area);
f.render_widget(block, area);
let width = inner.width as usize;
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
"sessions",
Style::default().add_modifier(Modifier::BOLD),
))];
for s in &self.sessions {
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
lines.push(Line::from(self.name_row(s, width)));
let meta = self.meta_row(s, width);
lines.push(Line::from(pad_to_width(meta, width, base)));
}
f.render_widget(Paragraph::new(lines), inner);
self.render_with_focus_and_collapsed(f, area, focused, &HashSet::new());
}
fn name_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
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 palette = palette::active();
let panel_bg = if focused {
palette.sidebar_focus_bg
} else {
palette.chrome_bg
};
f.render_widget(Block::default().style(Style::default().bg(panel_bg)), area);
let width = area.width as usize;
let mut lines: Vec<Line> = vec![Line::from(Span::styled(
"sessions",
Style::default()
.bg(panel_bg)
.fg(palette.card.fg)
.add_modifier(Modifier::BOLD),
))];
for group in self.groups(collapsed) {
lines.push(Line::from(self.group_row(&group, width, focused, home)));
if !group.collapsed {
for s in &group.sessions {
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
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), area);
}
/// 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 row_bg = if highlighted {
surface.bg
} else if focused {
palette.sidebar_focus_bg
} else {
surface.bg
};
let base = Style::default().bg(row_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().bg(row_bg));
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().bg(row_bg),
));
}
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)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
// Line-1 actions are keyboard affordances shown on the highlighted
// row only; they reserve their width so the name truncates with `…`
// before it can ever reach them (docs/SIDEBAR.md).
let actions_width = if highlighted {
ACTIONS.chars().count()
} else {
0
};
let usable = width.saturating_sub(actions_width);
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let prefix = INDENT.chars().count() + 2;
// Keep one column of separation between a truncated name and the
// actions so the ellipsis never touches them.
let sep = if highlighted { 1 } else { 0 };
let name_budget = usable.saturating_sub(prefix + sep);
let name_budget = width.saturating_sub(prefix);
let edge = highlighted && !focused;
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),
];
spans.extend(
@@ -111,18 +245,11 @@ impl Sidebar {
.into_iter()
.map(|sp| Span::styled(sp.content, base.patch(sp.style))),
);
let used: usize = spans.iter().map(|sp| sp.width()).sum();
if let Some(fill) = usable.checked_sub(used).filter(|&n| n > 0) {
spans.push(Span::styled(" ".repeat(fill), base));
}
if highlighted {
spans.push(Span::styled(ACTIONS, base));
}
spans
pad_to_width(spans, width, base)
}
/// The adaptive second line: `<dot> [<duration>] [<detail>] [<blocked work> | exited] <command>`.
fn meta_row(&self, s: &Session, width: usize) -> Vec<Span<'static>> {
fn meta_row(&self, s: &Session, width: usize, focused: bool) -> Vec<Span<'static>> {
let now = Utc::now();
let held = held_for(s, now);
let held_secs = held.map(|d| d.num_seconds()).unwrap_or(0);
@@ -130,12 +257,24 @@ impl Sidebar {
// on line 1; the kill confirm names the specifics.
let blocked = s.blocked_work.filter(|b| !b.is_clear());
let dead = s.status == SessionStatus::Dead;
let highlighted = self.highlight == Some(s.id);
let base = card_base(highlighted);
let dot_style = base.patch(Style::default().fg(activity_color(s.activity, highlighted)));
let muted = palette::active().surface(highlighted).muted_style();
let highlighted = self.highlight == Some(SidebarItem::Session(s.id));
let base = card_base(highlighted, focused);
let dot_style =
base.patch(Style::default().fg(activity_color(s.activity, highlighted, focused)));
let muted = palette::active()
.surface(highlighted && focused)
.muted_style()
.bg(base.bg.unwrap_or(palette::active().chrome_bg));
let edge = highlighted && !focused;
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),
];
if let Some(d) = held.filter(|_| held_secs >= META_LINE_AFTER_SECS) {
@@ -151,26 +290,27 @@ impl Sidebar {
}
let command = Span::styled(format!(" {}", s.command), muted);
// Reserve the trailing fragments so detail truncates before them;
// the command is itself the final truncation target on narrow cards.
// 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 tail_w: usize = tail.iter().map(|sp| sp.width()).sum();
let command_w = command.width();
let available = width.saturating_sub(head_w);
if tail_w + command_w > available {
spans.extend(fit_to_width(vec![command], available));
return spans;
}
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()) {
let budget = width.saturating_sub(head_w + tail_w);
let budget = available.saturating_sub(tail_w + command_w);
spans.extend(fit_to_width(
vec![Span::styled(format!(" {detail}"), muted)],
budget,
));
}
spans.extend(tail);
spans.push(command);
fit_to_width(spans, width)
spans.extend(fit_to_width(vec![command], command_w));
spans
}
}
@@ -178,8 +318,10 @@ impl Sidebar {
/// state word is never spelled out — this colour carries it. The highlighted
/// card takes its state colours from the selection surface: a colour picked
/// for the dark card background is not legible on the selection background.
fn activity_color(state: ActivityState, highlighted: bool) -> Color {
palette::active().surface(highlighted).activity(state)
fn activity_color(state: ActivityState, highlighted: bool, focused: bool) -> Color {
palette::active()
.surface(highlighted && focused)
.activity(state)
}
/// docs/SIDEBAR.md duration format — floored whole seconds / minutes / hours,
@@ -239,7 +381,7 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
}
/// `metadata.name` if present, else the command line.
fn name_for(s: &Session) -> String {
pub(crate) fn name_for(s: &Session) -> String {
if let Some(serde_json::Value::String(name)) = s.metadata.get("name") {
name.clone()
} else {
@@ -247,6 +389,10 @@ fn name_for(s: &Session) -> String {
}
}
fn home_dir() -> String {
std::env::var("HOME").unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
@@ -280,9 +426,24 @@ mod tests {
}
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 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()
}
@@ -308,7 +469,10 @@ mod tests {
/// Row text without the trailing space padding and right border.
fn content(row: &str) -> &str {
row.trim_end().trim_end_matches('│').trim_end()
row.trim()
.trim_start_matches('│')
.trim_end_matches('│')
.trim()
}
#[test]
@@ -329,6 +493,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[1].contains("/tmp"));
assert!(all[2].contains("claude"));
assert!(all[3].contains("bash"));
assert!(!all[4].contains("bash"));
}
#[test]
fn attached_session_uses_filled_glyph() {
let sid = Uuid::from_u128(1);
@@ -419,19 +679,19 @@ mod tests {
assert_eq!(content(&all[0]), "sessions");
assert!(row_containing(&all, "claude").contains('○'));
assert!(
all[2].contains('●'),
all[3].contains('●'),
"line 2 must carry the dot: {:?}",
all[2]
all[3]
);
assert!(
all[2].contains("bash"),
all[3].contains("bash"),
"line 2 must carry the command: {:?}",
all[2]
all[3]
);
assert!(
!all[2].contains("10s"),
!all[3].contains("10s"),
"fresh cards have no duration: {:?}",
all[2]
all[3]
);
}
@@ -510,7 +770,7 @@ mod tests {
s.activity_since = Utc::now() - Duration::seconds(120);
let sb = Sidebar {
sessions: vec![s],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
@@ -659,7 +919,7 @@ mod tests {
highlight: None,
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 meta = content(&all[idx + 1]);
assert!(meta.contains('…'), "truncated detail ends with …: {meta:?}");
@@ -751,14 +1011,14 @@ mod tests {
};
let all = rows(&draw(&sb, 40, 6));
assert!(
all[1].contains("friendly name"),
all[2].contains("friendly name"),
"name must stay on line 1: {:?}",
all[1]
all[2]
);
assert!(
all[2].contains("bash"),
all[3].contains("bash"),
"command must be on line 2: {:?}",
all[2]
all[3]
);
}
@@ -770,19 +1030,61 @@ mod tests {
highlight: None,
attached: Default::default(),
};
let all = rows(&draw(&sb, 10, 6));
let all = rows(&draw(&sb, 9, 6));
assert!(
all[2].contains('●'),
all[3].contains('●'),
"dot must survive narrow cards: {:?}",
all[2]
all[3]
);
assert!(
all[2].contains('…'),
all[3].contains('…'),
"command should truncate: {:?}",
all[2]
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[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
let meta = content(&all[3]);
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[3].contains("⚠ 1 uncommitted file"), "{:?}", all[3]);
assert!(
all[3].contains("bash"),
"command must survive: {:?}",
all[3]
);
assert!(all[3].contains('…'), "detail must truncate: {:?}", all[3]);
}
/// Reversed video makes a card unreadable against a background; cards
/// use explicit backgrounds instead (docs/SIDEBAR.md, #108).
#[test]
@@ -791,7 +1093,7 @@ mod tests {
highlighted.activity_since = Utc::now() - Duration::seconds(120);
let sb = Sidebar {
sessions: vec![highlighted],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
@@ -808,15 +1110,15 @@ mod tests {
}
}
/// The highlighted card gets the focus background; every other card gets
/// a card background distinct from the panel's default.
/// The highlighted card gets the selection background; the rest of the
/// focused sidebar shares its subtle panel background.
#[test]
fn cards_use_explicit_backgrounds_with_a_distinct_focus_background() {
let one = session_named(1, "claude");
let two = session_named(2, "bash");
let sb = Sidebar {
sessions: vec![one, two],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let buf = draw(&sb, 28, 8);
@@ -826,24 +1128,61 @@ mod tests {
let p = palette::active();
assert_eq!(
buf[(0, claude_y as u16)].style().bg,
buf[(1, claude_y as u16)].style().bg,
Some(p.selection.bg),
"focused card must sit on the selection surface"
);
assert_eq!(
buf[(0, claude_y as u16)].style().fg,
buf[(1, claude_y as u16)].style().fg,
Some(p.selection.fg),
"focused card text must use the selection foreground role"
);
assert_eq!(
buf[(0, bash_y as u16)].style().bg,
Some(p.card.bg),
"unfocused cards must still sit on the card surface"
buf[(1, bash_y as u16)].style().bg,
Some(p.sidebar_focus_bg),
"ordinary cards must share the focused panel surface"
);
assert_eq!(
buf[(1, 0)].style().bg,
Some(p.sidebar_focus_bg),
"the focus background must cover the whole sidebar"
);
}
#[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"
);
// The panel itself (header row) stays on the terminal default.
assert!(
matches!(buf[(0, 0)].style().bg, None | Some(Color::Reset)),
"panel must keep the terminal default background"
(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"
);
}
@@ -858,28 +1197,27 @@ mod tests {
};
let buf = draw(&sb, 28, 8);
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!(
buf[(x, y)].style().bg,
Some(palette::active().card.bg),
Some(palette::active().sidebar_focus_bg),
"gap in card background at col {x}"
);
}
}
/// Line 1's actions live on the highlighted row's top-right corner.
#[test]
fn highlighted_row_anchors_the_actions_top_right() {
fn highlighted_rows_do_not_show_action_glyphs() {
let sb = Sidebar {
sessions: vec![session_named(1, "claude")],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let line = content(row_containing(&all, "claude"));
assert!(
line.ends_with("⧉ ✕"),
"actions must sit at the right edge: {line:?}"
!line.contains('⧉') && !line.contains('✕'),
"keyboard-only actions should not consume card space: {line:?}"
);
}
@@ -889,7 +1227,7 @@ mod tests {
let two = session_named(2, "bash");
let sb = Sidebar {
sessions: vec![one, two],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
@@ -900,27 +1238,23 @@ mod tests {
);
}
/// The name truncates with `…` before it can ever reach the reserved
/// action width (docs/SIDEBAR.md — overlap is a defect).
#[test]
fn name_truncates_before_reaching_the_actions() {
fn long_name_uses_the_full_card_width() {
let mut s = session_named(1, "claude");
if let Some(serde_json::Value::String(name)) = s.metadata.get_mut("name") {
*name = "an-unreasonably-long-session-name".into();
}
let sb = Sidebar {
sessions: vec![s],
highlight: Some(Uuid::from_u128(1)),
highlight: Some(SidebarItem::Session(Uuid::from_u128(1))),
attached: Default::default(),
};
let all = rows(&draw(&sb, 28, 8));
let line = row_containing(&all, "an-unreasonably");
let name_end = line.find('…').expect("name must truncate with …");
let actions_start = line.find('⧉').expect("actions must still render");
assert!(name_end < actions_start, "name overlaps actions: {line:?}");
assert!(line.contains('…'), "name must truncate with …: {line:?}");
assert!(
line.split_at(actions_start).0.ends_with(' '),
"no gap before the actions: {line:?}"
!line.contains('⧉') && !line.contains('✕'),
"actions leaked in: {line:?}"
);
}
}
+74 -182
View File
@@ -1,13 +1,15 @@
//! `AppState` — the top-level mutable state of the running TUI.
//!
//! v1 keeps it small on purpose: a list of tabs (each bound to at most one
//! session or a draft form), an active tab index, focus split between sidebar
//! and pane, the current input mode, and an optional overlay (kill confirm,
//! v1 keeps it small on purpose: one pane containing a session or draft form,
//! focus split between sidebar and pane, the current input mode, and an optional overlay (kill confirm,
//! help, transient toast). The spawn form is no longer an overlay — an empty
//! pane is a draft pane (issue #63).
//!
//! 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 crate::draft::DraftPane;
@@ -19,8 +21,18 @@ pub enum Focus {
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.
// The draft variant dwarfs `Session(Uuid)`; tabs are moved rarely enough
// The draft variant dwarfs `Session(Uuid)`; the pane is moved rarely enough
// that boxing every access costs more than the copy does.
#[allow(clippy::large_enum_variant)]
#[derive(Debug, Clone, PartialEq)]
@@ -70,7 +82,10 @@ pub struct AppState {
pub focus: Focus,
pub mode: Mode,
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`],
/// [`AppState::dismiss_overlay`] or [`AppState::toast`]. Poking the field
/// from four call sites is what left a toast on screen with no way to
@@ -96,6 +111,7 @@ impl AppState {
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
collapsed_sidebar_groups: HashSet::new(),
overlay: None,
}
}
@@ -126,57 +142,6 @@ impl AppState {
&mut self.tabs[self.active_tab_idx]
}
/// Append a new tab and switch to it. Sidebar regains focus so the user
/// can pick something to attach.
pub fn new_tab(&mut self) {
let name = format!("{}", self.tabs.len() + 1);
self.tabs.push(Tab { name, pane: None });
self.active_tab_idx = self.tabs.len() - 1;
self.focus = Focus::Sidebar;
}
/// Drop the active tab. No-op if it's the only one — there must always
/// be at least one tab.
///
/// Returns the session that tab was showing, if any, so the caller can
/// release its emulator. Without that the tab's WebSocket task and vt100
/// parser outlive the tab forever. A draft pane holds no session and
/// releases nothing.
pub fn close_tab(&mut self) -> Option<Uuid> {
if self.tabs.len() <= 1 {
return None;
}
let closed = self.tabs.remove(self.active_tab_idx);
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
let sid = closed.session()?;
// Only release it if no other tab still shows the same session.
let still_shown = self.tabs.iter().any(|t| t.session() == Some(sid));
(!still_shown).then_some(sid)
}
pub fn next_tab(&mut self) {
if self.tabs.is_empty() {
return;
}
self.active_tab_idx = (self.active_tab_idx + 1) % self.tabs.len();
}
pub fn prev_tab(&mut self) {
if self.tabs.is_empty() {
return;
}
self.active_tab_idx = (self.active_tab_idx + self.tabs.len() - 1) % self.tabs.len();
}
pub fn goto_tab(&mut self, n: u8) {
let idx = (n as usize)
.saturating_sub(1)
.min(self.tabs.len().saturating_sub(1));
self.active_tab_idx = idx;
}
/// Bind a session to the active tab, move focus into the pane, and
/// drop straight into Insert mode so keystrokes go to the session
/// without an extra `i` press. The leader returns to Normal.
@@ -186,14 +151,10 @@ impl AppState {
self.mode = Mode::Insert;
}
/// Open a draft form in a pane (issue #63): reuse the empty active pane,
/// otherwise open a fresh tab for it — v1 has no split tree, so "split"
/// means a new tab. Focus lands in the pane, Insert mode on, so typing
/// starts immediately, same flow as [`Self::attach_to_focused`].
/// Replace the pane with a draft form. Focus lands in the pane, Insert
/// mode on, so typing starts immediately, same flow as
/// [`Self::attach_to_focused`].
pub fn open_draft_pane(&mut self) {
if self.active_tab().pane.is_some() {
self.new_tab();
}
self.active_tab_mut().pane = Some(PaneRef::Draft(DraftPane::default()));
self.focus = Focus::Pane;
self.mode = Mode::Insert;
@@ -213,19 +174,32 @@ impl AppState {
}
/// Move the sidebar highlight by `delta` (positive = down) within the
/// given ordered list of selectable session ids. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[Uuid], delta: i32) {
/// rendered item sequence. Wraps. No-op on empty.
pub fn move_sidebar_highlight(&mut self, order: &[SidebarItem], delta: i32) {
if order.is_empty() {
return;
}
let len = order.len() as i32;
let cur = self
.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)
.unwrap_or(-1);
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.
@@ -252,88 +226,36 @@ mod tests {
assert!(state.overlay.is_none());
}
#[test]
fn new_tab_extends_and_focuses() {
let mut state = AppState::new();
state.new_tab();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert_eq!(state.focus, Focus::Sidebar);
}
#[test]
fn close_tab_keeps_at_least_one() {
let mut state = AppState::new();
state.close_tab();
assert_eq!(state.tabs.len(), 1);
state.new_tab();
state.close_tab();
assert_eq!(state.tabs.len(), 1);
}
#[test]
fn close_tab_advances_when_last_is_closed() {
let mut state = AppState::new();
state.new_tab();
state.new_tab();
assert_eq!(state.active_tab_idx, 2);
state.close_tab();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
}
#[test]
fn next_prev_tab_wraps() {
let mut state = AppState::new();
state.new_tab();
state.new_tab();
state.active_tab_idx = 0;
state.next_tab();
assert_eq!(state.active_tab_idx, 1);
state.next_tab();
assert_eq!(state.active_tab_idx, 2);
state.next_tab();
assert_eq!(state.active_tab_idx, 0);
state.prev_tab();
assert_eq!(state.active_tab_idx, 2);
}
#[test]
fn goto_tab_uses_one_based_clamp() {
let mut state = AppState::new();
state.new_tab();
state.new_tab();
state.goto_tab(1);
assert_eq!(state.active_tab_idx, 0);
state.goto_tab(2);
assert_eq!(state.active_tab_idx, 1);
state.goto_tab(99);
// Clamped to last available tab.
assert_eq!(state.active_tab_idx, state.tabs.len() - 1);
}
#[test]
fn move_sidebar_highlight_walks_order_and_wraps() {
let mut state = AppState::new();
let a = Uuid::from_u128(1);
let b = Uuid::from_u128(2);
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.
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);
assert_eq!(state.sidebar_highlight, Some(b));
assert_eq!(
state.sidebar_highlight,
Some(SidebarItem::Group("repo".into()))
);
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(c));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
// Wraps forward.
state.move_sidebar_highlight(&order, 1);
assert_eq!(state.sidebar_highlight, Some(a));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(c)));
// Wraps backward.
state.move_sidebar_highlight(&order, -1);
assert_eq!(state.sidebar_highlight, Some(c));
assert_eq!(state.sidebar_highlight, Some(SidebarItem::Session(b)));
}
#[test]
@@ -343,6 +265,22 @@ mod tests {
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]
fn attach_binds_focuses_pane_and_enters_insert_mode() {
let mut state = AppState::new();
@@ -367,38 +305,6 @@ mod tests {
assert_eq!(state.focus, Focus::Pane);
}
/// The emulator leak: a closed tab's session must come back so the caller
/// can abort its WebSocket task.
#[test]
fn close_tab_reports_the_session_it_dropped() {
let mut state = AppState::new();
let sid = Uuid::from_u128(3);
state.new_tab();
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), Some(sid));
}
/// ...but not while another tab still shows it.
#[test]
fn close_tab_keeps_a_session_another_tab_still_shows() {
let mut state = AppState::new();
let sid = Uuid::from_u128(4);
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
state.new_tab();
state.active_tab_mut().pane = Some(PaneRef::Session(sid));
assert_eq!(state.close_tab(), None);
}
#[test]
fn close_tab_is_a_no_op_on_the_last_tab() {
let mut state = AppState::new();
state.active_tab_mut().pane = Some(PaneRef::Session(Uuid::from_u128(5)));
assert_eq!(state.close_tab(), None);
assert_eq!(state.tabs.len(), 1);
}
#[test]
fn dismiss_overlay_reports_whether_one_was_showing() {
let mut state = AppState::new();
@@ -420,18 +326,14 @@ mod tests {
assert_eq!(state.tabs.len(), 1);
}
/// A tab already showing a session gets a fresh tab for the draft; v1
/// has no split tree, so a new tab is the "split".
#[test]
fn open_draft_over_a_session_opens_a_new_tab() {
fn open_draft_over_a_session_reuses_the_pane() {
let mut state = AppState::new();
state.attach_to_focused(Uuid::from_u128(9));
state.open_draft_pane();
assert_eq!(state.tabs.len(), 2);
assert_eq!(state.active_tab_idx, 1);
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.active_tab_idx, 0);
assert!(matches!(state.active_tab().pane, Some(PaneRef::Draft(_))));
// The session tab is untouched.
assert_eq!(state.tabs[0].session(), Some(Uuid::from_u128(9)));
}
/// Web parity (#57): submitting fills the pane in place.
@@ -445,14 +347,4 @@ mod tests {
assert_eq!(state.focus, Focus::Pane);
assert_eq!(state.mode, Mode::Insert);
}
/// A closed draft tab holds no session, so nothing is released.
#[test]
fn close_tab_reports_nothing_for_a_draft_pane() {
let mut state = AppState::new();
state.open_draft_pane();
state.new_tab();
state.active_tab_idx = 0;
assert_eq!(state.close_tab(), None);
}
}
-61
View File
@@ -1,61 +0,0 @@
//! Tab-bar widget — top row of the main area.
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::Tabs;
use ratatui::Frame;
#[derive(Debug, Clone)]
pub struct TabBar {
pub tab_names: Vec<String>,
pub active_idx: usize,
}
impl TabBar {
pub fn render(&self, f: &mut Frame, area: Rect) {
let titles: Vec<Line> = self
.tab_names
.iter()
.map(|n| Line::from(n.as_str()))
.collect();
let tabs = Tabs::new(titles)
.select(self.active_idx)
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
f.render_widget(tabs, area);
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string(bar: &TabBar, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| bar.render(f, f.area())).unwrap();
let buf = terminal.backend().buffer().clone();
let mut out = String::new();
for y in 0..buf.area.height {
for x in 0..buf.area.width {
out.push_str(buf[(x, y)].symbol());
}
out.push('\n');
}
out
}
#[test]
fn renders_three_tab_names() {
let bar = TabBar {
tab_names: vec!["1".into(), "2".into(), "3".into()],
active_idx: 1,
};
let rendered = render_to_string(&bar, 40, 1);
assert!(rendered.contains("1"));
assert!(rendered.contains("2"));
assert!(rendered.contains("3"));
}
}
+26 -4
View File
@@ -134,24 +134,46 @@ cover every v1 action — only override what you want to change:
```toml
[tui]
theme = "dark" # see the theme list below
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 `ctrl-w`
[tui.keymap.normal]
"i" = "EnterInsert"
"s" = "OpenSpawnModal"
"s" = "NewDraftPane"
"x" = "KillHighlighted"
"?" = "ShowHelp"
"ctrl-t" = "NewTab"
"g t" = "NextTab"
"ctrl-w h" = "FocusSidebar"
"enter" = "AttachHighlighted"
"pageup" = "ScrollPanePageUp"
"pagedown" = "ScrollPanePageDown"
[tui.keymap.insert]
# (leader is added automatically as LeaveInsert; only override here when you
# 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.
The remaining presets adapt well-known editor themes, with individual colours
lifted or darkened where the originals fall short of the WCAG AA contrast
floors the palette tests assert:
| name | based on |
|-------------------|------------------|
| `solarized-dark` | Solarized Dark |
| `solarized-light` | Solarized Light |
| `catppuccin` | Catppuccin Mocha |
| `github-dark` | GitHub Dark |
| `github-light` | GitHub Light |
| `tokyo-night` | Tokyo Night |
| `gruvbox` | Gruvbox Dark |
| `nord` | Nord |
| `dracula` | Dracula |
An unknown name is rejected as an `[tui].theme` configuration error.
The full action vocabulary is the `Action` enum in
`crates/maestro-tui/src/action.rs`.
+6 -8
View File
@@ -7,17 +7,15 @@ written here; if it needs to change, change it here first.
A card is always two lines: **line 1** and **line 2**.
## Line 1 — name + actions
## Line 1 — name
- Left: the session name — `metadata.name` if present, else the command line
(same fallback both clients already use).
- Right: two actions anchored to the top-right corner: **spawn similar**
(`⧉`) and **kill** (`✕`). In the TUI these are keyboard affordances shown on
the highlighted row; in the web they are buttons. The glyphs are shared so
muscle memory transfers.
- Actions reserve their fixed width; the name truncates with `…` before it can
ever reach them. **Overlap between the name and the actions is a defect** —
that overlap is what started this milestone.
- In the web client, **spawn similar** (`⧉`) and **kill** (`✕`) remain buttons
anchored on the right. The TUI omits those glyphs because its actions are
keyboard-driven and the icons do not identify their keys.
- The name truncates with `…` at the available width. In the web client it
must stop before the action buttons; in the TUI it may use the full row.
Line 1 carries no badges. The `⚠ blocked work` badge (#83) and the `exited`
badge move off this row into line 2.
-41
View File
@@ -4,9 +4,7 @@ import {
activityColorSlug,
activityDurationLabel,
activityHeldMs,
showsMetaLine,
} from "./activity.js";
import type { Session } from "./types.js";
describe("activityHeldMs", () => {
it("measures time spent in the current state", () => {
@@ -60,42 +58,3 @@ describe("activityColorSlug", () => {
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
View File
@@ -2,11 +2,10 @@
// the shared duration format. Both clients compute it identically; the TUI
// mirrors this in crates/maestro-tui.
import type { ActivityState, IsoTimestamp, Session } from "./types.js";
import { sessionBlockers } from "./types.js";
import type { ActivityState, IsoTimestamp } from "./types.js";
/** A state held shorter than this keeps the card to one line (contract rule 1). */
const META_LINE_MIN_HOLD_MS = 30_000;
/** A state held shorter than this has no duration fragment. */
export const META_LINE_MIN_HOLD_MS = 30_000;
// Payloads predating #102 default `activity_since` to the Unix epoch; anything
// 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";
}
}
/** 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"
);
}
+2 -1
View File
@@ -123,7 +123,8 @@ export function createAttachConnection(
// wheel events so xterm encodes them as mouse-wheel reports the app reads,
// scrolling *its* content (the only scroll that exists on the alt screen).
// • alt screen with no mouse tracking → nothing to do; the app owns input.
// `touch-action: pan-y` (CSS) tells the browser we handle the Y gesture.
// `touch-action: none` (CSS) keeps the browser from cancelling the custom
// gesture stream; only the terminal surface opts out of native page panning.
{
const vp = el.querySelector(".xterm-viewport") as HTMLElement | null;
const screen = el.querySelector(".xterm-screen") as HTMLElement | null;
+16 -2
View File
@@ -150,14 +150,18 @@ describe("session cards (#107)", () => {
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({
id: "aaaaaaaa-0000-0000-0000-000000000011",
activity: "Active",
activity_since: ago(5_000),
});
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", () => {
@@ -173,6 +177,16 @@ describe("session cards (#107)", () => {
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", () => {
const mk = (id: string, activity: string) =>
session({
+11 -14
View File
@@ -4,7 +4,12 @@ import { HUD_TOGGLE_EVENT, isHudEnabled, toggleHud } from "./debug.js";
import { CatalogStore } from "./events.js";
import type { Session } 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 { buildTree } from "./tree.js";
import { createThemeToggle } from "./theme.js";
@@ -28,16 +33,13 @@ const STATE_WORDS: Record<string, string> = {
};
function metaLine(s: Session, now: number): string {
const name = sessionName(s);
const dead = s.status === "Dead";
const blockers = sessionBlockers(s);
const slug = activityColorSlug(s.activity ?? "Idle");
const stateWord = STATE_WORDS[slug] ?? STATE_WORDS.idle;
const held = activityHeldMs(s.activity_since, now);
const duration = held !== null ? activityDurationLabel(held) : null;
// The directory is the enclosing folder, so only surface the command — and
// only when it isn't already what the name shows.
const showCmd = s.command !== name;
const duration =
held !== null && held >= META_LINE_MIN_HOLD_MS ? activityDurationLabel(held) : null;
const fragments = [
`<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>`
: "",
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>`;
}
@@ -106,10 +106,7 @@ function renderNode(node: TreeNode, depth: number): HTMLElement {
"aria-label",
`${name}, ${running ? "running" : "exited"}, ${stateWord}`,
);
// docs/SIDEBAR.md line 1: name + top-right actions in the same flow line —
// 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);
// docs/SIDEBAR.md: line 1 reserves action width, and line 2 is unconditional.
row.innerHTML = `
<span class="card-line1">
<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>
</span>
</span>
${showMeta ? metaLine(s, now) : ""}
${metaLine(s, now)}
`;
const similarBtn = row.querySelector(".leaf-clone") as HTMLButtonElement;
similarBtn.addEventListener("click", (ev) => {
+17 -7
View File
@@ -736,7 +736,6 @@ textarea {
display: flex;
align-items: center;
gap: var(--sp-2);
min-height: 30px;
margin: 1px var(--sp-2);
padding: 4px var(--sp-3);
border-radius: var(--r-1);
@@ -787,12 +786,13 @@ textarea {
font-weight: 400;
}
/* #107: session cards per docs/SIDEBAR.md — line 1 (name + top-right actions)
always, line 2 (meta) only when it has something to say. */
/* #120: every session card has two lines, so its row height is stable. */
.tree-leaf.session-card {
flex-direction: column;
align-items: stretch;
gap: 1px;
/* 24px action row + 16px meta row + 1px gap + 8px vertical padding. */
height: 49px;
}
.card-line1 {
@@ -855,7 +855,8 @@ textarea {
ellipsis so it never pushes the trailing fragments off the line. */
.activity-detail {
color: var(--faint);
flex: 0 1 auto;
/* Consume only the space left after the trailing status + command. */
flex: 1 1 0;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
@@ -866,12 +867,19 @@ textarea {
signals severity and "exited" dims with the row. */
.card-flag {
color: var(--faint);
flex: 0 0 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
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
name (#107). They reserve their width even while hover-faded. */
.leaf-actions {
@@ -1134,9 +1142,11 @@ textarea {
.attach-body .xterm .xterm-viewport {
overflow-y: auto;
scrollbar-width: none;
/* We drive scrollback from touchmove (see attach.ts); claim vertical pans so
the browser doesn't fight the handler or trigger gestures mid-scroll. */
touch-action: pan-y;
/* Maestro drives scrollback from touchmove (see attach.ts). `pan-y` hands
the gesture to the browser, which may cancel the event stream before the
terminal sees it; `none` keeps a drag inside the terminal under our
control while the rest of the page retains native scrolling. */
touch-action: none;
}
.attach-body .xterm .xterm-viewport::-webkit-scrollbar {