feat(maestro): rename the highlighted session from the TUI sidebar

`r` opens a prompt pre-filled with the session's own name — empty when it
has none, so Enter cannot pin the command as the name. Enter saves, Escape
cancels, and an emptied field clears the name.

Refs #125

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-30 11:29:05 +01:00
co-authored by Claude Opus 5
parent 345650172b
commit bdeec6eff5
8 changed files with 285 additions and 6 deletions
+19
View File
@@ -48,6 +48,9 @@ pub enum Action {
/// resolved; unresolvable defaults open a pre-filled draft instead. /// resolved; unresolvable defaults open a pre-filled draft instead.
FastForwardSpawn, FastForwardSpawn,
KillHighlighted, KillHighlighted,
/// Rename the highlighted session (#125): opens a prompt pre-filled with
/// its current name.
RenameHighlighted,
/// Open the retained-worktrees view (#85). /// Open the retained-worktrees view (#85).
ShowWorktrees, ShowWorktrees,
@@ -92,6 +95,7 @@ impl Action {
Action::NewDraftPane => "Create a session".into(), Action::NewDraftPane => "Create a session".into(),
Action::FastForwardSpawn => "Create session with defaults".into(), Action::FastForwardSpawn => "Create session with defaults".into(),
Action::KillHighlighted => "Kill selected session".into(), Action::KillHighlighted => "Kill selected session".into(),
Action::RenameHighlighted => "Rename selected session".into(),
Action::ShowWorktrees => "Show retained worktrees".into(), Action::ShowWorktrees => "Show retained worktrees".into(),
Action::ToggleSidebar => "Show or hide sidebar".into(), Action::ToggleSidebar => "Show or hide sidebar".into(),
Action::ShowHelp => "Show help".into(), Action::ShowHelp => "Show help".into(),
@@ -118,6 +122,7 @@ impl Action {
"NewDraftPane" => return Ok(Action::NewDraftPane), "NewDraftPane" => return Ok(Action::NewDraftPane),
"FastForwardSpawn" => return Ok(Action::FastForwardSpawn), "FastForwardSpawn" => return Ok(Action::FastForwardSpawn),
"KillHighlighted" => return Ok(Action::KillHighlighted), "KillHighlighted" => return Ok(Action::KillHighlighted),
"RenameHighlighted" => return Ok(Action::RenameHighlighted),
"ShowWorktrees" => return Ok(Action::ShowWorktrees), "ShowWorktrees" => return Ok(Action::ShowWorktrees),
"ToggleSidebar" => return Ok(Action::ToggleSidebar), "ToggleSidebar" => return Ok(Action::ToggleSidebar),
"ShowHelp" => return Ok(Action::ShowHelp), "ShowHelp" => return Ok(Action::ShowHelp),
@@ -223,10 +228,24 @@ mod tests {
assert_eq!(Action::FastForwardSpawn.to_string(), "FastForwardSpawn"); 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] #[test]
fn display_round_trips() { fn display_round_trips() {
for a in [ for a in [
Action::EnterInsert, Action::EnterInsert,
Action::RenameHighlighted,
Action::Quit, Action::Quit,
Action::NewDraftPane, Action::NewDraftPane,
Action::ScrollPanePageDown, Action::ScrollPanePageDown,
+164
View File
@@ -40,6 +40,7 @@ use crate::render::{draw, RegionMap, RenderInputs};
use crate::session_task::{SessionInput, SessionTask}; use crate::session_task::{SessionInput, SessionTask};
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem}; use crate::state::{AppState, Focus, OverlayKind, PaneRef, SidebarItem};
use crate::text_input::TextInput;
use crate::worktrees::WorktreesView; use crate::worktrees::WorktreesView;
use crate::TuiError; use crate::TuiError;
@@ -75,6 +76,9 @@ pub struct App {
/// Entries of the retained-worktrees view (#85). Daemon data, kept here /// Entries of the retained-worktrees view (#85). Daemon data, kept here
/// rather than in `AppState` so the overlay kind stays payload-free. /// rather than in `AppState` so the overlay kind stays payload-free.
worktrees: WorktreesView, worktrees: WorktreesView,
/// Edit buffer behind [`OverlayKind::Rename`] (#125), kept here for the
/// same reason the worktree entries are.
rename_input: TextInput,
region: RegionMap, region: RegionMap,
last_sent_size: HashMap<Uuid, (u16, u16)>, last_sent_size: HashMap<Uuid, (u16, u16)>,
clicks: ClickTracker, clicks: ClickTracker,
@@ -123,6 +127,12 @@ pub enum Effect {
/// Reclaim one retained worktree (#85) — removes its directory; the /// Reclaim one retained worktree (#85) — removes its directory; the
/// branch is never deleted. /// branch is never deleted.
ReclaimWorktree(Uuid), 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 { impl App {
@@ -148,6 +158,7 @@ impl App {
daemon, daemon,
http, http,
worktrees: WorktreesView::default(), worktrees: WorktreesView::default(),
rename_input: TextInput::default(),
draft_tx, draft_tx,
draft_rx, draft_rx,
region: RegionMap::default(), region: RegionMap::default(),
@@ -284,6 +295,7 @@ impl App {
Effect::LoadBranches(tab) => self.spawn_load_branches(tab), Effect::LoadBranches(tab) => self.spawn_load_branches(tab),
Effect::LoadWorktrees => self.spawn_load_worktrees(), Effect::LoadWorktrees => self.spawn_load_worktrees(),
Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await, Effect::ReclaimWorktree(id) => self.submit_reclaim(id).await,
Effect::Rename { id, name } => self.submit_rename(id, &name).await,
Effect::Forward { id, bytes } => { Effect::Forward { id, bytes } => {
if let Some(handle) = self.emulators.get(id) { if let Some(handle) = self.emulators.get(id) {
let _ = handle.tx.send(SessionInput::Bytes(bytes)).await; let _ = handle.tx.send(SessionInput::Bytes(bytes)).await;
@@ -306,6 +318,21 @@ impl App {
} }
_ => (OverlayDecision::Consumed, Vec::new()), _ => (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 { Some(OverlayKind::Worktrees) => match key {
InputKey::Char { c: 'j', .. } | InputKey::Down => { InputKey::Char { c: 'j', .. } | InputKey::Down => {
self.worktrees.move_highlight(1); self.worktrees.move_highlight(1);
@@ -430,6 +457,23 @@ impl App {
self.state.show_overlay(OverlayKind::KillConfirm(sid)); 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 => { Action::ShowWorktrees => {
self.state.show_overlay(OverlayKind::Worktrees); self.state.show_overlay(OverlayKind::Worktrees);
effects.push(Effect::LoadWorktrees); 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) — /// Fetch the retained-worktree store without blocking the loop (#85) —
/// same off-loop pattern as the draft loaders. /// same off-loop pattern as the draft loaders.
fn spawn_load_worktrees(&self) { fn spawn_load_worktrees(&self) {
@@ -1008,6 +1060,7 @@ impl App {
draft, draft,
help: &self.help, help: &self.help,
worktrees: Some(&self.worktrees), worktrees: Some(&self.worktrees),
rename_input: &self.rename_input,
}; };
let mut new_region = RegionMap::default(); let mut new_region = RegionMap::default();
@@ -2035,6 +2088,117 @@ mod tests {
assert!(matches!(app.state.overlay(), Some(OverlayKind::KillConfirm(s)) if *s == sid)); 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) ------------------------------------------- // -- retained worktrees (#85) -------------------------------------------
fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree { fn sample_worktree(id: u128) -> maestro_protocol::RetainedWorktree {
+1
View File
@@ -53,6 +53,7 @@ const DEFAULT_NORMAL: &[(&str, &str)] = &[
// thing with every default resolved and no form. // thing with every default resolved and no form.
("S", "FastForwardSpawn"), ("S", "FastForwardSpawn"),
("x", "KillHighlighted"), ("x", "KillHighlighted"),
("r", "RenameHighlighted"),
("w", "ShowWorktrees"), ("w", "ShowWorktrees"),
// Focus // Focus
("ctrl-w h", "FocusSidebar"), ("ctrl-w h", "FocusSidebar"),
+65 -1
View File
@@ -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 //! The spawn form is no longer an overlay (issue #63) — it lives in draft
//! panes ([`crate::draft::DraftPane`]). //! panes ([`crate::draft::DraftPane`]).
@@ -7,10 +8,13 @@
//! `Clear` widget wipes the underlying buffer before drawing. //! `Clear` widget wipes the underlying buffer before drawing.
use ratatui::layout::Rect; use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::text::Line; use ratatui::text::Line;
use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph}; use ratatui::widgets::{Block, Borders, Clear, List, ListItem, Paragraph};
use ratatui::Frame; use ratatui::Frame;
use crate::text_input::TextInput;
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct KillConfirm { pub struct KillConfirm {
pub label: String, 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)] #[derive(Debug, Clone)]
pub struct HelpOverlay { pub struct HelpOverlay {
pub lines: Vec<String>, pub lines: Vec<String>,
@@ -151,6 +199,22 @@ mod tests {
assert!(rendered.contains("refused"), "{rendered}"); 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] #[test]
fn help_overlay_lists_lines() { fn help_overlay_lists_lines() {
let help = HelpOverlay { let help = HelpOverlay {
+23 -1
View File
@@ -10,7 +10,7 @@ use ratatui::Frame;
use crate::input::Region; use crate::input::Region;
use crate::keymap::Mode; use crate::keymap::Mode;
use crate::overlays::{HelpOverlay, KillConfirm, Toast}; use crate::overlays::{HelpOverlay, KillConfirm, RenamePrompt, Toast};
use crate::palette; use crate::palette;
use crate::pane::Pane; use crate::pane::Pane;
use crate::sidebar::Sidebar; use crate::sidebar::Sidebar;
@@ -84,6 +84,8 @@ pub struct RenderInputs<'a> {
pub help: &'a HelpOverlay, pub help: &'a HelpOverlay,
/// Retained-worktree entries for the worktrees view (#85). /// Retained-worktree entries for the worktrees view (#85).
pub worktrees: Option<&'a WorktreesView>, 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 /// 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); kc.render(f, area);
map.modal = Some(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: &current,
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) => { Some(OverlayKind::Help) => {
inputs.help.render(f, area); inputs.help.render(f, area);
map.modal = Some(area); map.modal = Some(area);
@@ -354,6 +373,7 @@ mod tests {
state: &state, state: &state,
help: &help, help: &help,
worktrees: None, worktrees: None,
rename_input: &crate::text_input::TextInput::default(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
@@ -407,6 +427,7 @@ mod tests {
state: &state, state: &state,
help: &help, help: &help,
worktrees: None, worktrees: None,
rename_input: &crate::text_input::TextInput::default(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap(); let mut terminal = ratatui::Terminal::new(backend).unwrap();
@@ -446,6 +467,7 @@ mod tests {
state: &state, state: &state,
help: &help, help: &help,
worktrees: None, worktrees: None,
rename_input: &crate::text_input::TextInput::default(),
}; };
let backend = ratatui::backend::TestBackend::new(80, 24); let backend = ratatui::backend::TestBackend::new(80, 24);
let mut terminal = ratatui::Terminal::new(backend).unwrap(); let mut terminal = ratatui::Terminal::new(backend).unwrap();
+9 -4
View File
@@ -382,10 +382,15 @@ fn pad_to_width(mut spans: Vec<Span<'static>>, width: usize, base: Style) -> Vec
/// `metadata.name` if present, else the command line. /// `metadata.name` if present, else the command line.
pub(crate) 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") { custom_name_for(s).unwrap_or_else(|| s.command.clone())
name.clone() }
} 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<String> {
match s.metadata.get("name") {
Some(serde_json::Value::String(name)) => Some(name.clone()),
_ => None,
} }
} }
+3
View File
@@ -60,6 +60,9 @@ impl Tab {
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum OverlayKind { pub enum OverlayKind {
KillConfirm(Uuid), 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, Help,
Toast(String), Toast(String),
/// The retained-worktrees view (#85); its entries live on `App` because /// The retained-worktrees view (#85); its entries live on `App` because
+1
View File
@@ -142,6 +142,7 @@ chord_timeout_ms = 1000 # how long to hold a partial chord like `ctrl-w`
"i" = "EnterInsert" "i" = "EnterInsert"
"s" = "NewDraftPane" "s" = "NewDraftPane"
"x" = "KillHighlighted" "x" = "KillHighlighted"
"r" = "RenameHighlighted"
"?" = "ShowHelp" "?" = "ShowHelp"
"ctrl-w h" = "FocusSidebar" "ctrl-w h" = "FocusSidebar"
"enter" = "AttachHighlighted" "enter" = "AttachHighlighted"