diff --git a/crates/maestro-tui/src/action.rs b/crates/maestro-tui/src/action.rs index 97016b2..2181245 100644 --- a/crates/maestro-tui/src/action.rs +++ b/crates/maestro-tui/src/action.rs @@ -48,6 +48,9 @@ pub enum Action { /// resolved; unresolvable defaults open a pre-filled draft instead. FastForwardSpawn, KillHighlighted, + /// Rename the highlighted session (#125): opens a prompt pre-filled with + /// its current name. + RenameHighlighted, /// Open the retained-worktrees view (#85). ShowWorktrees, @@ -92,6 +95,7 @@ impl Action { Action::NewDraftPane => "Create a session".into(), Action::FastForwardSpawn => "Create session with defaults".into(), Action::KillHighlighted => "Kill selected session".into(), + Action::RenameHighlighted => "Rename selected session".into(), Action::ShowWorktrees => "Show retained worktrees".into(), Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ShowHelp => "Show help".into(), @@ -118,6 +122,7 @@ impl Action { "NewDraftPane" => return Ok(Action::NewDraftPane), "FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "KillHighlighted" => return Ok(Action::KillHighlighted), + "RenameHighlighted" => return Ok(Action::RenameHighlighted), "ShowWorktrees" => return Ok(Action::ShowWorktrees), "ToggleSidebar" => return Ok(Action::ToggleSidebar), "ShowHelp" => return Ok(Action::ShowHelp), @@ -223,10 +228,24 @@ mod tests { assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn"); } + #[test] + fn rename_action_round_trips() { + assert_eq!( + Action::parse("RenameHighlighted").unwrap(), + Action::RenameHighlighted + ); + assert_eq!(Action::RenameHighlighted.to_string(), "RenameHighlighted"); + assert_eq!( + Action::RenameHighlighted.help_label(), + "Rename selected session" + ); + } + #[test] fn display_round_trips() { for a in [ Action::EnterInsert, + Action::RenameHighlighted, Action::Quit, Action::NewDraftPane, Action::ScrollPanePageDown, diff --git a/crates/maestro-tui/src/app.rs b/crates/maestro-tui/src/app.rs index fc6011a..34d7a01 100644 --- a/crates/maestro-tui/src/app.rs +++ b/crates/maestro-tui/src/app.rs @@ -40,6 +40,7 @@ use crate::render::{draw, RegionMap, RenderInputs}; use crate::session_task::{SessionInput, SessionTask}; use crate::sidebar::Sidebar; use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem}; +use crate::text_input::TextInput; use crate::worktrees::WorktreesView; use crate::TuiError; @@ -75,6 +76,9 @@ pub struct App { /// Entries of the retained-worktrees view (#85). Daemon data, kept here /// rather than in `AppState` so the overlay kind stays payload-free. worktrees: WorktreesView, + /// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the + /// same reason the worktree entries are. + rename_input: TextInput, region: RegionMap, last_sent_size: HashMap, clicks: ClickTracker, @@ -123,6 +127,12 @@ pub enum Effect { /// Reclaim one retained worktree (#85) — removes its directory; the /// branch is never deleted. ReclaimWorktree(Uuid), + /// Rename a session (#125). An empty name clears the custom one, so the + /// card falls back to the command. + Rename { + id: Uuid, + name: String, + }, } impl App { @@ -148,6 +158,7 @@ impl App { daemon, http, worktrees: WorktreesView::default(), + rename_input: TextInput::default(), draft_tx, draft_rx, region: RegionMap::default(), @@ -284,6 +295,7 @@ impl App { Effect::LoadBranches(tab) => self.spawn_load_branches(tab), Effect::LoadWorktrees => self.spawn_load_worktrees(), Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await, + Effect::Rename { id, name } => self.submit_rename(id, &name).await, Effect::Forward { id, bytes } => { if let Some(handle) = self.emulators.get(id) { let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; @@ -306,6 +318,21 @@ impl App { } _ => (OverlayDecision::Consumed, Vec::new()), }, + Some(OverlayKind::Rename(id)) => match key { + InputKey::Enter => { + let name = self.rename_input.value().to_string(); + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, vec![Effect::Rename { id, name }]) + } + InputKey::Esc => { + self.state.dismiss_overlay(); + (OverlayDecision::Consumed, Vec::new()) + } + other => { + self.rename_input.handle_key(other); + (OverlayDecision::Consumed, Vec::new()) + } + }, Some(OverlayKind::Worktrees) => match key { InputKey::Char { c: 'j', .. } | InputKey::Down => { self.worktrees.move_highlight(1); @@ -430,6 +457,23 @@ impl App { self.state.show_overlay(OverlayKind::KillConfirm(sid)); } } + Action::RenameHighlighted => { + if let Some(sid) = self.pick_sidebar_target() { + // Pre-fill with the custom name only: a session that has + // none starts empty rather than with its command already + // typed in, which Enter would then pin as the name. + self.rename_input = TextInput::with_value( + self.catalog + .snapshot() + .sessions + .iter() + .find(|s| s.id == sid) + .and_then(crate::sidebar::custom_name_for) + .unwrap_or_default(), + ); + self.state.show_overlay(OverlayKind::Rename(sid)); + } + } Action::ShowWorktrees => { self.state.show_overlay(OverlayKind::Worktrees); effects.push(Effect::LoadWorktrees); @@ -928,6 +972,14 @@ impl App { } } + /// Rename a session (#125). The catalog updates from the daemon's + /// `SessionUpdated` broadcast, so nothing is patched locally here. + async fn submit_rename(&mut self, sid: Uuid, name: &str) { + if let Err(e) = self.daemon.rename_session(sid, name).await { + self.state.toast(format!("rename failed: {e}")); + } + } + /// Fetch the retained-worktree store without blocking the loop (#85) — /// same off-loop pattern as the draft loaders. fn spawn_load_worktrees(&self) { @@ -1008,6 +1060,7 @@ impl App { draft, help: &self.help, worktrees: Some(&self.worktrees), + rename_input: &self.rename_input, }; let mut new_region = RegionMap::default(); @@ -2035,6 +2088,117 @@ mod tests { assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid)); } + // -- rename (#125) ------------------------------------------------------ + + fn seed_session(app: &mut App, sid: Uuid, name: Option<&str>) { + use chrono::Utc; + use maestro_protocol::{CatalogEvent, Session, SessionStatus}; + let metadata = match name { + Some(n) => { + HashMap::from([("name".to_string(), serde_json::Value::String(n.to_string()))]) + } + None => Default::default(), + }; + app.catalog.apply_event(CatalogEvent::Snapshot { + sessions: vec![Session { + id: sid, + command: "bash".into(), + cwd: std::path::PathBuf::from("/"), + env: Default::default(), + metadata, + 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 rename_highlighted_opens_a_prompt_prefilled_with_the_current_name() { + let mut app = make_app(); + let sid = Uuid::from_u128(7); + seed_session(&mut app, sid, Some("amber-falcon")); + app.dispatch(Action::RenameHighlighted); + assert!(matches!(app.state.overlay(), Some(OverlayKind::Rename(s)) if *s == sid)); + assert_eq!(app.rename_input.value(), "amber-falcon"); + } + + /// A session with no name of its own opens an empty field: pre-filling + /// with the command would let Enter pin the command as the name. + #[test] + fn rename_prompt_starts_empty_for_an_unnamed_session() { + let mut app = make_app(); + seed_session(&mut app, Uuid::from_u128(7), None); + app.dispatch(Action::RenameHighlighted); + assert_eq!(app.rename_input.value(), ""); + } + + #[test] + fn typing_then_enter_submits_the_edited_name() { + let mut app = make_app(); + let sid = Uuid::from_u128(7); + seed_session(&mut app, sid, Some("amber")); + app.dispatch(Action::RenameHighlighted); + for c in "-fox".chars() { + let (decision, effects) = app.handle_overlay_input(&InputKey::Char { + c, + ctrl: false, + alt: false, + shift: false, + }); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + } + let (decision, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!(decision, OverlayDecision::Consumed); + assert_eq!( + effects, + vec![Effect::Rename { + id: sid, + name: "amber-fox".into() + }] + ); + assert!(app.state.overlay().is_none()); + } + + #[test] + fn escape_cancels_the_rename_without_an_effect() { + let mut app = make_app(); + seed_session(&mut app, Uuid::from_u128(7), Some("amber")); + app.dispatch(Action::RenameHighlighted); + let (decision, effects) = app.handle_overlay_input(&InputKey::Esc); + assert_eq!(decision, OverlayDecision::Consumed); + assert!(effects.is_empty()); + assert!(app.state.overlay().is_none()); + } + + /// Clearing the field and submitting is how the name is dropped, so the + /// card falls back to the command. + #[test] + fn an_emptied_field_submits_a_blank_name() { + let mut app = make_app(); + let sid = Uuid::from_u128(7); + seed_session(&mut app, sid, Some("ab")); + app.dispatch(Action::RenameHighlighted); + app.handle_overlay_input(&InputKey::Backspace); + app.handle_overlay_input(&InputKey::Backspace); + let (_, effects) = app.handle_overlay_input(&InputKey::Enter); + assert_eq!( + effects, + vec![Effect::Rename { + id: sid, + name: String::new() + }] + ); + } + // -- retained worktrees (#85) ------------------------------------------- fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree { diff --git a/crates/maestro-tui/src/config.rs b/crates/maestro-tui/src/config.rs index a751392..408e7a7 100644 --- a/crates/maestro-tui/src/config.rs +++ b/crates/maestro-tui/src/config.rs @@ -53,6 +53,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[ // thing with every default resolved and no form. ("S", "FastForwardSpawn"), ("x", "KillHighlighted"), + ("r", "RenameHighlighted"), ("w", "ShowWorktrees"), // Focus ("ctrl-w h", "FocusSidebar"), diff --git a/crates/maestro-tui/src/overlays.rs b/crates/maestro-tui/src/overlays.rs index 0febb28..a09a89a 100644 --- a/crates/maestro-tui/src/overlays.rs +++ b/crates/maestro-tui/src/overlays.rs @@ -1,4 +1,5 @@ -//! Overlay widgets: kill-confirm prompt, help overlay, transient toast. +//! Overlay widgets: kill-confirm prompt, rename prompt, help overlay, +//! transient toast. //! //! The spawn form is no longer an overlay (issue #63) — it lives in draft //! panes ([`crate::draft::DraftPane`]). @@ -7,10 +8,13 @@ //! `Clear` widget wipes the underlying buffer before drawing. use ratatui::layout::Rect; +use ratatui::style::Style; use ratatui::text::Line; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; use ratatui::Frame; +use crate::text_input::TextInput; + #[derive(Debug, Clone)] pub struct KillConfirm { pub label: String, @@ -39,6 +43,50 @@ impl KillConfirm { } } +/// Rename prompt for the highlighted session (#125). +/// +/// Borrows its text buffer rather than owning it: the buffer lives on `App` +/// alongside the worktrees view's entries, so [`crate::state::OverlayKind`] +/// stays a plain comparable enum. +#[derive(Debug)] +pub struct RenamePrompt<'a> { + /// What the card shows today — the session's name, or the command it + /// falls back to when it has none. + pub current: &'a str, + pub input: &'a TextInput, +} + +impl RenamePrompt<'_> { + /// Draws the prompt and returns where the terminal cursor belongs. + pub fn render(&self, f: &mut Frame, parent: Rect) -> Option<(u16, u16)> { + let area = centered(parent, 64, 7); + f.render_widget(Clear, area); + let block = Block::default().title(" rename ").borders(Borders::ALL); + let inner = block.inner(area); + f.render_widget(block, area); + if inner.height < 4 || inner.width == 0 { + return None; + } + let row = |offset: u16| Rect { + x: inner.x, + y: inner.y + offset, + width: inner.width, + height: 1, + }; + f.render_widget( + Paragraph::new(Line::from(format!("Rename {}", self.current))), + row(0), + ); + let field = row(1); + let col = self.input.render(field, f.buffer_mut(), Style::default()); + f.render_widget( + Paragraph::new(Line::from("[enter] save [esc] cancel")), + row(3), + ); + Some((col, field.y)) + } +} + #[derive(Debug, Clone)] pub struct HelpOverlay { pub lines: Vec, @@ -151,6 +199,22 @@ mod tests { assert!(rendered.contains("refused"), "{rendered}"); } + #[test] + fn rename_prompt_shows_the_current_name_and_the_edit_buffer() { + let input = TextInput::with_value("crimson-otter"); + let prompt = RenamePrompt { + current: "amber-falcon", + input: &input, + }; + let rendered = render_to_string(70, 12, |f| { + prompt.render(f, f.area()); + }); + assert!(rendered.contains("Rename amber-falcon"), "{rendered}"); + assert!(rendered.contains("crimson-otter"), "{rendered}"); + assert!(rendered.contains("[enter] save"), "{rendered}"); + assert!(rendered.contains("[esc] cancel"), "{rendered}"); + } + #[test] fn help_overlay_lists_lines() { let help = HelpOverlay { diff --git a/crates/maestro-tui/src/render.rs b/crates/maestro-tui/src/render.rs index d0ecb3e..5fcb35f 100644 --- a/crates/maestro-tui/src/render.rs +++ b/crates/maestro-tui/src/render.rs @@ -10,7 +10,7 @@ use ratatui::Frame; use crate::input::Region; use crate::keymap::Mode; -use crate::overlays::{HelpOverlay, KillConfirm, Toast}; +use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast}; use crate::palette; use crate::pane::Pane; use crate::sidebar::Sidebar; @@ -84,6 +84,8 @@ pub struct RenderInputs<'a> { pub help: &'a HelpOverlay, /// Retained-worktree entries for the worktrees view (#85). pub worktrees: Option<&'a WorktreesView>, + /// Edit buffer behind the rename prompt (#125). + pub rename_input: &'a crate::text_input::TextInput, } /// Draw one frame. Returns the `RegionMap` the input router will consult for @@ -170,6 +172,23 @@ pub fn draw(f: &mut Frame, inputs: RenderInputs<'_>) -> RegionMap { kc.render(f, area); map.modal = Some(area); } + Some(OverlayKind::Rename(id)) => { + let current = inputs + .sidebar + .sessions + .iter() + .find(|s| &s.id == id) + .map(crate::sidebar::name_for) + .unwrap_or_else(|| id.to_string()); + let prompt = RenamePrompt { + current: ¤t, + input: inputs.rename_input, + }; + if let Some((x, y)) = prompt.render(f, area) { + f.set_cursor_position((x, y)); + } + map.modal = Some(area); + } Some(OverlayKind::Help) => { inputs.help.render(f, area); map.modal = Some(area); @@ -354,6 +373,7 @@ mod tests { state: &state, help: &help, worktrees: None, + rename_input: &crate::text_input::TextInput::default(), }; let backend = ratatui::backend::TestBackend::new(80, 24); @@ -407,6 +427,7 @@ mod tests { state: &state, help: &help, worktrees: None, + rename_input: &crate::text_input::TextInput::default(), }; let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).unwrap(); @@ -446,6 +467,7 @@ mod tests { state: &state, help: &help, worktrees: None, + rename_input: &crate::text_input::TextInput::default(), }; let backend = ratatui::backend::TestBackend::new(80, 24); let mut terminal = ratatui::Terminal::new(backend).unwrap(); diff --git a/crates/maestro-tui/src/sidebar.rs b/crates/maestro-tui/src/sidebar.rs index 9013607..5b37b09 100644 --- a/crates/maestro-tui/src/sidebar.rs +++ b/crates/maestro-tui/src/sidebar.rs @@ -382,10 +382,15 @@ fn pad_to_width(mut spans: Vec>, width: usize, base: Style) -> Vec /// `metadata.name` if present, else the command line. pub(crate) fn name_for(s: &Session) -> String { - if let Some(serde_json::Value::String(name)) = s.metadata.get("name") { - name.clone() - } else { - s.command.clone() + custom_name_for(s).unwrap_or_else(|| s.command.clone()) +} + +/// The session's own name, without the command fallback (#125): `None` for a +/// session that was never given one. +pub(crate) fn custom_name_for(s: &Session) -> Option { + match s.metadata.get("name") { + Some(serde_json::Value::String(name)) => Some(name.clone()), + _ => None, } } diff --git a/crates/maestro-tui/src/state.rs b/crates/maestro-tui/src/state.rs index 229faf8..fe562db 100644 --- a/crates/maestro-tui/src/state.rs +++ b/crates/maestro-tui/src/state.rs @@ -60,6 +60,9 @@ impl Tab { #[derive(Debug, Clone, PartialEq, Eq)] pub enum OverlayKind { KillConfirm(Uuid), + /// Rename prompt for this session (#125). The edit buffer lives on `App`, + /// like the worktrees view's entries, so this stays comparable. + Rename(Uuid), Help, Toast(String), /// The retained-worktrees view (#85); its entries live on `App` because diff --git a/docs/INSTALL.md b/docs/INSTALL.md index ff5e25a..d51e120 100644 --- a/docs/INSTALL.md +++ b/docs/INSTALL.md @@ -142,6 +142,7 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w` "i" = "EnterInsert" "s" = "NewDraftPane" "x" = "KillHighlighted" +"r" = "RenameHighlighted" "?" = "ShowHelp" "ctrl-w h" = "FocusSidebar" "enter" = "AttachHighlighted"