Merge branch 'linear/nap-280-tui-widgets'
ci / rust (push) Failing after 4m14s

This commit is contained in:
2026-05-28 22:24:22 +00:00
11 changed files with 1401 additions and 0 deletions
+1
View File
@@ -13,6 +13,7 @@ crossterm.workspace = true
futures-util.workspace = true
maestro-config.workspace = true
maestro-protocol.workspace = true
ratatui.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["sync", "time", "macros", "rt"] }
+7
View File
@@ -15,7 +15,14 @@ pub mod config;
pub mod emulators;
pub mod input;
pub mod keymap;
pub mod overlays;
pub mod pane;
pub mod render;
pub mod session_task;
pub mod sidebar;
pub mod state;
pub mod tabs;
pub mod terminal;
/// Inputs for [`run`]. Built by the umbrella binary from the parsed
/// `TuiArgs` plus any global flags.
+281
View File
@@ -0,0 +1,281 @@
//! Overlay widgets: spawn modal, kill-confirm prompt, help overlay,
//! transient toast.
//!
//! All overlays are centered popups drawn over the rest of the UI; the
//! `Clear` widget wipes the underlying buffer before drawing.
use maestro_protocol::DaemonInfo;
use ratatui::layout::{Constraint, Layout, Rect};
use ratatui::style::{Modifier, Style};
use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame;
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
pub enum SpawnField {
#[default]
Daemon,
Cwd,
Command,
Label,
Submit,
}
#[derive(Debug, Default, Clone)]
pub struct SpawnModal {
pub daemons: Vec<DaemonInfo>,
pub daemon_idx: usize,
pub cwd: String,
pub command: String,
pub label: String,
pub focused_field: SpawnField,
}
impl SpawnModal {
pub fn next_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Cwd,
SpawnField::Cwd => SpawnField::Command,
SpawnField::Command => SpawnField::Label,
SpawnField::Label => SpawnField::Submit,
SpawnField::Submit => SpawnField::Daemon,
};
}
pub fn prev_field(&mut self) {
self.focused_field = match self.focused_field {
SpawnField::Daemon => SpawnField::Submit,
SpawnField::Cwd => SpawnField::Daemon,
SpawnField::Command => SpawnField::Cwd,
SpawnField::Label => SpawnField::Command,
SpawnField::Submit => SpawnField::Label,
};
}
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 60, 14);
f.render_widget(Clear, area);
let block = Block::default().title(" spawn ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let rows = Layout::vertical([
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(2),
Constraint::Length(1),
])
.split(inner);
let highlight = |field: SpawnField| {
if self.focused_field == field {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
}
};
let daemon_label = self
.daemons
.get(self.daemon_idx)
.map(|d| d.hostname.as_str())
.unwrap_or("(none)");
let field_row = |label: &str, value: &str, style: Style| {
Paragraph::new(Line::from(vec![
ratatui::text::Span::raw(format!("{label:8} ")),
ratatui::text::Span::styled(value.to_string(), style),
]))
};
f.render_widget(
field_row("daemon:", daemon_label, highlight(SpawnField::Daemon)),
rows[0],
);
f.render_widget(
field_row("cwd:", &self.cwd, highlight(SpawnField::Cwd)),
rows[1],
);
f.render_widget(
field_row("command:", &self.command, highlight(SpawnField::Command)),
rows[2],
);
f.render_widget(
field_row("label:", &self.label, highlight(SpawnField::Label)),
rows[3],
);
f.render_widget(
Paragraph::new(Line::from(vec![ratatui::text::Span::styled(
"[ Spawn ]",
highlight(SpawnField::Submit),
)])),
rows[4],
);
}
}
#[derive(Debug, Clone)]
pub struct KillConfirm {
pub label: String,
}
impl KillConfirm {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 50, 5);
f.render_widget(Clear, area);
let block = Block::default().title(" kill ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(
Paragraph::new(vec![
Line::from(format!("Kill session {}?", self.label)),
Line::from(""),
Line::from("[y] yes [n] no"),
]),
inner,
);
}
}
#[derive(Debug, Clone)]
pub struct HelpOverlay {
pub lines: Vec<String>,
}
impl HelpOverlay {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let area = centered(parent, 70, parent.height.saturating_sub(4));
f.render_widget(Clear, area);
let block = Block::default().title(" help ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let items: Vec<ListItem> = self
.lines
.iter()
.map(|s| ListItem::new(s.as_str()))
.collect();
f.render_widget(List::new(items), inner);
}
}
#[derive(Debug, Clone)]
pub struct Toast {
pub text: String,
}
impl Toast {
pub fn render(&self, f: &mut Frame, parent: Rect) {
let h = 3u16;
let want = self.text.len().saturating_add(4) as u16;
let w = parent.width.min(want);
if h >= parent.height {
return;
}
let area = Rect {
x: parent.x + (parent.width.saturating_sub(w)) / 2,
y: parent.y + parent.height - h - 1,
width: w,
height: h,
};
f.render_widget(Clear, area);
let block = Block::default().borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
f.render_widget(Paragraph::new(self.text.as_str()), inner);
}
}
fn centered(parent: Rect, w: u16, h: u16) -> Rect {
let w = w.min(parent.width);
let h = h.min(parent.height);
Rect {
x: parent.x + parent.width.saturating_sub(w) / 2,
y: parent.y + parent.height.saturating_sub(h) / 2,
width: w,
height: h,
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_to_string<F>(w: u16, h: u16, draw: F) -> String
where
F: FnOnce(&mut Frame),
{
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(draw).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 spawn_modal_renders_field_labels() {
let modal = SpawnModal {
daemons: vec![DaemonInfo {
daemon_id: "k".into(),
hostname: "konishi".into(),
version: "0".into(),
daemon_url: "ws://x".into(),
}],
daemon_idx: 0,
cwd: "/x".into(),
command: "bash".into(),
label: String::new(),
focused_field: SpawnField::Cwd,
};
let rendered = render_to_string(80, 20, |f| modal.render(f, f.area()));
for s in [
"daemon", "cwd", "command", "label", "Spawn", "konishi", "/x", "bash",
] {
assert!(rendered.contains(s), "missing {s:?}:\n{rendered}");
}
}
#[test]
fn spawn_modal_field_rotation() {
let mut modal = SpawnModal::default();
assert_eq!(modal.focused_field, SpawnField::Daemon);
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Cwd);
modal.next_field();
modal.next_field();
modal.next_field();
modal.next_field();
assert_eq!(modal.focused_field, SpawnField::Daemon);
modal.prev_field();
assert_eq!(modal.focused_field, SpawnField::Submit);
}
#[test]
fn kill_confirm_renders_prompt() {
let modal = KillConfirm {
label: "session-7".into(),
};
let rendered = render_to_string(60, 10, |f| modal.render(f, f.area()));
assert!(rendered.contains("Kill"));
assert!(rendered.contains("session-7"));
assert!(rendered.contains("yes"));
assert!(rendered.contains("no"));
}
#[test]
fn help_overlay_lists_lines() {
let help = HelpOverlay {
lines: vec![
"i EnterInsert".into(),
"s OpenSpawnModal".into(),
],
};
let rendered = render_to_string(80, 12, |f| help.render(f, f.area()));
assert!(rendered.contains("EnterInsert"));
assert!(rendered.contains("OpenSpawnModal"));
}
}
+151
View File
@@ -0,0 +1,151 @@
//! Attach-pane widget — draws a `vt100::Screen` into a ratatui buffer
//! cell-by-cell, translating SGR (fg / bg / attrs) into `ratatui::Style`.
//!
//! The pane's `screen` is the same `Arc<Mutex<vt100::Parser>>` the
//! `SessionTask` writes into, so the renderer is always looking at the most
//! recent cell grid.
use std::sync::{Arc, Mutex};
use ratatui::layout::Rect;
use ratatui::style::{Color, Modifier, Style};
use ratatui::Frame;
use crate::session_task::SessionState;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PaneStatus {
Connecting,
Live,
Reconnecting,
Ended,
}
impl From<SessionState> for PaneStatus {
fn from(s: SessionState) -> Self {
match s {
SessionState::Connecting => PaneStatus::Connecting,
SessionState::Live => PaneStatus::Live,
SessionState::Reconnecting => PaneStatus::Reconnecting,
SessionState::Ended => PaneStatus::Ended,
}
}
}
#[derive(Clone)]
pub struct Pane {
pub screen: Arc<Mutex<vt100::Parser>>,
pub status: PaneStatus,
pub focused: bool,
}
impl std::fmt::Debug for Pane {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Pane")
.field("status", &self.status)
.field("focused", &self.focused)
.finish_non_exhaustive()
}
}
impl Pane {
pub fn render(&self, f: &mut Frame, area: Rect) {
let parser = self.screen.lock().expect("vt100 parser mutex");
let screen = parser.screen();
let buf = f.buffer_mut();
let (screen_rows, screen_cols) = screen.size();
let rows = area.height.min(screen_rows);
let cols = area.width.min(screen_cols);
for r in 0..rows {
for c in 0..cols {
if let Some(cell) = screen.cell(r, c) {
let s = cell.contents();
let s = if s.is_empty() { " " } else { &s };
buf[(area.x + c, area.y + r)]
.set_symbol(s)
.set_style(translate_cell_style(cell));
}
}
}
}
}
fn translate_cell_style(cell: &vt100::Cell) -> Style {
let mut s = Style::default();
if let Some(c) = translate_color(cell.fgcolor()) {
s = s.fg(c);
}
if let Some(c) = translate_color(cell.bgcolor()) {
s = s.bg(c);
}
if cell.bold() {
s = s.add_modifier(Modifier::BOLD);
}
if cell.italic() {
s = s.add_modifier(Modifier::ITALIC);
}
if cell.underline() {
s = s.add_modifier(Modifier::UNDERLINED);
}
if cell.inverse() {
s = s.add_modifier(Modifier::REVERSED);
}
s
}
fn translate_color(c: vt100::Color) -> Option<Color> {
match c {
vt100::Color::Default => None,
vt100::Color::Idx(i) => Some(Color::Indexed(i)),
vt100::Color::Rgb(r, g, b) => Some(Color::Rgb(r, g, b)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
fn render_with_bytes(rows: u16, cols: u16, bytes: &[u8]) -> String {
let parser = Arc::new(Mutex::new(vt100::Parser::new(rows, cols, 0)));
parser.lock().unwrap().process(bytes);
let pane = Pane {
screen: parser,
status: PaneStatus::Live,
focused: true,
};
let backend = TestBackend::new(cols, rows);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| pane.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 pane_renders_vt100_chars_into_buffer() {
let rendered = render_with_bytes(3, 20, b"hello world");
assert!(rendered.contains("hello world"), "got:\n{rendered}");
}
#[test]
fn pane_status_round_trips_from_session_state() {
assert_eq!(
PaneStatus::from(SessionState::Connecting),
PaneStatus::Connecting
);
assert_eq!(PaneStatus::from(SessionState::Live), PaneStatus::Live);
assert_eq!(
PaneStatus::from(SessionState::Reconnecting),
PaneStatus::Reconnecting
);
assert_eq!(PaneStatus::from(SessionState::Ended), PaneStatus::Ended);
}
}
+203
View File
@@ -0,0 +1,203 @@
//! Top-level draw orchestration. Composes sidebar / tab bar / 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::widgets::{Block, Borders};
use ratatui::Frame;
use crate::input::Region;
use crate::overlays::{HelpOverlay, KillConfirm, SpawnModal, Toast};
use crate::pane::Pane;
use crate::sidebar::Sidebar;
use crate::state::{AppState, OverlayKind};
use crate::tabs::TabBar;
/// Tagged regions for mouse hit-testing. The input router resolves a mouse
/// coordinate to one of these via [`RegionMap::region_at`].
#[derive(Debug, Default, Clone)]
pub struct RegionMap {
pub sidebar: Option<Rect>,
pub tab_bar: Option<Rect>,
pub pane: Option<Rect>,
pub modal: Option<Rect>,
}
impl RegionMap {
pub fn region_at(&self, col: u16, row: u16) -> Region {
if let Some(r) = self.modal {
if inside(r, col, row) {
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;
}
}
if let Some(r) = self.pane {
if inside(r, col, row) {
return Region::Pane;
}
}
Region::Unknown
}
}
fn inside(r: Rect, col: u16, row: u16) -> bool {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
}
/// Bundle of references the renderer reads from. Built by `App::redraw` each
/// frame.
#[derive(Debug)]
pub struct RenderInputs<'a> {
pub sidebar: &'a Sidebar,
pub tab_bar: &'a TabBar,
pub pane: Option<&'a Pane>,
pub state: &'a AppState,
pub spawn: &'a SpawnModal,
pub help: &'a HelpOverlay,
}
/// Draw one frame. Returns the `RegionMap` the input router will consult for
/// mouse events the next time it canonicalises an event.
pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap {
let mut map = RegionMap::default();
let area = f.area();
let cols = if inputs.state.sidebar_visible {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(28), Constraint::Min(0)])
.split(area)
} else {
Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Length(0), Constraint::Min(0)])
.split(area)
};
let sidebar_area = cols[0];
let right = cols[1];
if inputs.state.sidebar_visible {
inputs.sidebar.render(f, sidebar_area);
map.sidebar = Some(sidebar_area);
}
let rows = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(3), Constraint::Min(0)])
.split(right);
let tab_area = rows[0];
let pane_area = rows[1];
inputs.tab_bar.render(f, tab_area);
map.tab_bar = Some(tab_area);
let pane_block = Block::default().borders(Borders::ALL);
let pane_inner = pane_block.inner(pane_area);
f.render_widget(pane_block, pane_area);
if let Some(p) = inputs.pane {
p.render(f, pane_inner);
}
map.pane = Some(pane_inner);
match &inputs.state.overlay {
Some(OverlayKind::Spawn) => {
inputs.spawn.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::KillConfirm(id)) => {
let kc = KillConfirm {
label: id.to_string(),
};
kc.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Help) => {
inputs.help.render(f, area);
map.modal = Some(area);
}
Some(OverlayKind::Toast(text)) => {
let t = Toast { text: text.clone() };
t.render(f, area);
}
None => {}
}
map
}
#[cfg(test)]
mod tests {
use super::*;
use crate::input::Region;
#[test]
fn region_map_resolves_known_areas() {
let map = RegionMap {
sidebar: Some(Rect {
x: 0,
y: 0,
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
}),
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);
}
#[test]
fn modal_eclipses_other_regions() {
let map = RegionMap {
sidebar: Some(Rect {
x: 0,
y: 0,
width: 10,
height: 20,
}),
tab_bar: Some(Rect {
x: 10,
y: 0,
width: 30,
height: 3,
}),
pane: Some(Rect {
x: 10,
y: 3,
width: 30,
height: 17,
}),
modal: Some(Rect {
x: 5,
y: 5,
width: 25,
height: 10,
}),
};
// Even though (15, 10) is inside `pane`, the modal claims it.
assert_eq!(map.region_at(15, 10), Region::Modal);
assert_eq!(map.region_at(3, 12), Region::SidebarRow); // outside modal
}
}
+200
View File
@@ -0,0 +1,200 @@
//! Sidebar widget — daemon→session tree, navigation-only.
//!
//! Status glyphs: ● attached / ○ live / ✗ dead.
use std::collections::HashSet;
use maestro_protocol::{DaemonInfo, Session, SessionStatus};
use ratatui::layout::Rect;
use ratatui::style::{Modifier, Style};
use ratatui::text::{Line, Span};
use ratatui::widgets::{Block, Borders, Paragraph};
use ratatui::Frame;
use uuid::Uuid;
/// Renderable sidebar state.
#[derive(Debug, Clone)]
pub struct Sidebar {
pub daemons: Vec<DaemonInfo>,
pub sessions: Vec<Session>,
pub highlight: Option<Uuid>,
pub attached: HashSet<Uuid>,
}
impl Sidebar {
pub fn render(&self, f: &mut Frame, area: Rect) {
let block = Block::default().title(" daemons ").borders(Borders::ALL);
let inner = block.inner(area);
f.render_widget(block, area);
let mut lines: Vec<Line> = Vec::new();
for d in &self.daemons {
lines.push(Line::from(format!("● {}", d.hostname)));
for s in self.sessions.iter().filter(|s| s.daemon_id == d.daemon_id) {
let glyph = match (s.status, self.attached.contains(&s.id)) {
(SessionStatus::Dead, _) => "✗",
(SessionStatus::Running, true) => "●",
(SessionStatus::Running, false) => "○",
};
let label = label_for(s);
let style = if self.highlight == Some(s.id) {
Style::default().add_modifier(Modifier::REVERSED)
} else {
Style::default()
};
lines.push(Line::from(vec![
Span::raw(" "),
Span::styled(format!("{glyph} {label}"), style),
]));
}
}
f.render_widget(Paragraph::new(lines), inner);
}
}
/// `metadata.label` if present, else the command line.
fn label_for(s: &Session) -> String {
if let Some(serde_json::Value::String(label)) = s.metadata.get("label") {
label.clone()
} else {
s.command.clone()
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Utc;
use ratatui::backend::TestBackend;
use ratatui::Terminal;
use std::collections::HashMap;
fn daemon(id: &str) -> DaemonInfo {
DaemonInfo {
daemon_id: id.into(),
hostname: id.into(),
version: "test".into(),
daemon_url: format!("ws://{id}:7070"),
}
}
fn session_with_label(id: u128, daemon_id: &str, label: &str) -> Session {
let mut metadata = HashMap::new();
metadata.insert("label".into(), serde_json::Value::String(label.into()));
Session {
id: Uuid::from_u128(id),
daemon_id: daemon_id.into(),
command: "bash".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
metadata,
status: SessionStatus::Running,
created_at: Utc::now(),
}
}
fn render_to_string(sidebar: &Sidebar, w: u16, h: u16) -> String {
let backend = TestBackend::new(w, h);
let mut terminal = Terminal::new(backend).unwrap();
terminal.draw(|f| sidebar.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_hostname_and_session_labels() {
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![
session_with_label(1, "konishi", "claude"),
session_with_label(2, "konishi", "bash"),
],
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 12);
assert!(
rendered.contains("konishi"),
"missing daemon name:\n{rendered}"
);
assert!(
rendered.contains("claude"),
"missing session label:\n{rendered}"
);
assert!(
rendered.contains("bash"),
"missing session label:\n{rendered}"
);
}
#[test]
fn attached_session_uses_filled_glyph() {
let sid = Uuid::from_u128(1);
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![session_with_label(1, "konishi", "claude")],
highlight: None,
attached: [sid].into_iter().collect(),
};
let rendered = render_to_string(&sb, 28, 6);
let line = rendered
.lines()
.find(|l| l.contains("claude"))
.expect("claude row");
assert!(line.contains('●'), "expected filled glyph in: {line:?}");
assert!(
!line.contains('○'),
"should not contain hollow glyph: {line:?}"
);
}
#[test]
fn dead_session_uses_cross_glyph() {
let mut sess = session_with_label(1, "konishi", "claude");
sess.status = SessionStatus::Dead;
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![sess],
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 6);
let line = rendered
.lines()
.find(|l| l.contains("claude"))
.expect("claude row");
assert!(line.contains('✗'), "expected dead glyph in: {line:?}");
}
#[test]
fn falls_back_to_command_when_label_metadata_is_absent() {
let s = Session {
id: Uuid::from_u128(1),
daemon_id: "konishi".into(),
command: "vim".into(),
cwd: std::path::PathBuf::from("/tmp"),
env: Default::default(),
metadata: HashMap::new(),
status: SessionStatus::Running,
created_at: Utc::now(),
};
let sb = Sidebar {
daemons: vec![daemon("konishi")],
sessions: vec![s],
highlight: None,
attached: Default::default(),
};
let rendered = render_to_string(&sb, 28, 6);
assert!(
rendered.contains("vim"),
"missing fallback command label:\n{rendered}"
);
}
}
+227
View File
@@ -0,0 +1,227 @@
//! `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), an active tab index, focus split between sidebar and pane, the
//! current input mode, and an optional overlay (spawn modal, kill confirm,
//! help, transient toast).
//!
//! v1.5 will replace `Tab::pane: Option<SessionId>` with a split tree.
use uuid::Uuid;
use crate::keymap::Mode;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Focus {
Sidebar,
Pane,
}
#[derive(Debug, Clone)]
pub struct Tab {
pub name: String,
pub pane: Option<Uuid>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind {
Spawn,
KillConfirm(Uuid),
Help,
Toast(String),
}
#[derive(Debug)]
pub struct AppState {
pub tabs: Vec<Tab>,
pub active_tab_idx: usize,
pub focus: Focus,
pub mode: Mode,
pub sidebar_visible: bool,
pub sidebar_highlight: Option<Uuid>,
pub overlay: Option<OverlayKind>,
}
impl Default for AppState {
fn default() -> Self {
Self::new()
}
}
impl AppState {
pub fn new() -> Self {
Self {
tabs: vec![Tab {
name: "1".into(),
pane: None,
}],
active_tab_idx: 0,
focus: Focus::Sidebar,
mode: Mode::Normal,
sidebar_visible: true,
sidebar_highlight: None,
overlay: None,
}
}
pub fn active_tab(&self) -> &Tab {
&self.tabs[self.active_tab_idx]
}
pub fn active_tab_mut(&mut self) -> &mut Tab {
&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.
pub fn close_tab(&mut self) {
if self.tabs.len() <= 1 {
return;
}
self.tabs.remove(self.active_tab_idx);
if self.active_tab_idx >= self.tabs.len() {
self.active_tab_idx = self.tabs.len() - 1;
}
}
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 and move focus into the pane.
pub fn attach_to_focused(&mut self, session_id: Uuid) {
self.active_tab_mut().pane = Some(session_id);
self.focus = Focus::Pane;
}
/// Toggle sidebar visibility.
pub fn toggle_sidebar(&mut self) {
self.sidebar_visible = !self.sidebar_visible;
if !self.sidebar_visible {
self.focus = Focus::Pane;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn new_app_starts_with_one_empty_tab_focused_on_sidebar() {
let state = AppState::new();
assert_eq!(state.tabs.len(), 1);
assert_eq!(state.tabs[0].pane, None);
assert_eq!(state.focus, Focus::Sidebar);
assert_eq!(state.mode, Mode::Normal);
assert!(state.sidebar_visible);
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 attach_binds_to_active_tab_and_focuses_pane() {
let mut state = AppState::new();
let sid = Uuid::from_u128(7);
state.attach_to_focused(sid);
assert_eq!(state.tabs[0].pane, Some(sid));
assert_eq!(state.focus, Focus::Pane);
}
#[test]
fn toggle_sidebar_moves_focus_to_pane_when_hiding() {
let mut state = AppState::new();
state.toggle_sidebar();
assert!(!state.sidebar_visible);
assert_eq!(state.focus, Focus::Pane);
state.toggle_sidebar();
assert!(state.sidebar_visible);
// Doesn't force focus back; sidebar shown but focus stays where it was.
assert_eq!(state.focus, Focus::Pane);
}
}
+61
View File
@@ -0,0 +1,61 @@
//! 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"));
}
}
+73
View File
@@ -0,0 +1,73 @@
//! `TerminalGuard` — RAII setup/teardown of raw mode, the alternate screen
//! buffer, and mouse capture. Restores everything on drop or panic.
//!
//! Pattern matches `crates/maestro-cli/src/attach.rs::RawTty`.
use std::io;
use crossterm::event::{DisableMouseCapture, EnableMouseCapture};
use crossterm::execute;
use crossterm::terminal::{
disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen,
};
#[derive(Debug)]
pub struct TerminalGuard;
#[derive(Debug, thiserror::Error)]
pub enum TerminalError {
#[error("io: {0}")]
Io(#[from] io::Error),
}
impl TerminalGuard {
/// Enter raw mode, switch to alt screen, enable mouse capture. Returns a
/// guard whose `Drop` impl reverses each step.
pub fn enter() -> Result<Self, TerminalError> {
enable_raw_mode()?;
let mut out = io::stdout();
execute!(out, EnterAlternateScreen, EnableMouseCapture)?;
Ok(TerminalGuard)
}
/// Restore terminal state. Idempotent so the panic hook + `Drop` can both
/// call it without producing weird errors.
fn restore() {
let mut out = io::stdout();
let _ = execute!(out, DisableMouseCapture, LeaveAlternateScreen);
let _ = disable_raw_mode();
}
}
impl Drop for TerminalGuard {
fn drop(&mut self) {
Self::restore();
}
}
static HOOK_INSTALLED: std::sync::Once = std::sync::Once::new();
/// Install a panic hook that restores the terminal before re-panicking, so
/// a backtrace lands in cooked mode instead of glitching against the alt
/// screen. Idempotent — safe to call from `run()` every time.
pub fn install_panic_hook() {
HOOK_INSTALLED.call_once(|| {
let prev = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
TerminalGuard::restore();
prev(info);
}));
});
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn install_panic_hook_is_idempotent() {
install_panic_hook();
install_panic_hook();
install_panic_hook();
}
}