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:
co-authored by
Claude Opus 5
parent
345650172b
commit
bdeec6eff5
@@ -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,
|
||||
|
||||
@@ -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<Uuid, (u16, u16)>,
|
||||
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 {
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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<String>,
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
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<String> {
|
||||
match s.metadata.get("name") {
|
||||
Some(serde_json::Value::String(name)) => Some(name.clone()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user