diff --git a/crates/cli/src/app.rs b/crates/cli/src/app.rs index 97d4be51..21ea192d 100644 --- a/crates/cli/src/app.rs +++ b/crates/cli/src/app.rs @@ -669,6 +669,17 @@ impl Selection { None } } + /// The stable fleet identity represented by this selection. Service + /// names are service ids in the protocol; archived disclosure rows are + /// navigation controls and therefore have no identity of their own. + pub fn fleet_identity(&self) -> Option<(&'static str, &str)> { + match self { + Self::Session(id) => Some(("session", id)), + Self::Group(id) => Some(("project", id)), + Self::Service(name) => Some(("service", name)), + Self::None | Self::ArchivedRow(_) => None, + } + } pub fn archive_section(&self) -> Option<&ArchiveSection> { if let Self::ArchivedRow(section) = self { Some(section) @@ -1491,6 +1502,7 @@ pub enum SessionTitleMenuAction { /// Placed directly below Archive so the close-out actions group together. Merge, Delete, + CopyId, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -1499,18 +1511,21 @@ pub enum ServiceTitleMenuAction { SplitVertical, CloseSplit, Delete, + CopyId, } impl ServiceTitleMenuAction { - pub const ALL: [Self; 4] = [ + pub const ALL: [Self; 5] = [ Self::SplitHorizontal, Self::SplitVertical, Self::CloseSplit, Self::Delete, + Self::CopyId, ]; pub fn label(self) -> &'static str { match self { + Self::CopyId => "copy id", Self::SplitHorizontal => "split horizontal", Self::SplitVertical => "split vertical", Self::CloseSplit => "close split", @@ -1520,7 +1535,7 @@ impl ServiceTitleMenuAction { } impl SessionTitleMenuAction { - pub const ALL: [Self; 9] = [ + pub const ALL: [Self; 10] = [ Self::Rename, Self::Fork, Self::PlaybookTerminalMode, @@ -1530,11 +1545,13 @@ impl SessionTitleMenuAction { Self::Archive, Self::Merge, Self::Delete, + Self::CopyId, ]; pub fn label(self) -> &'static str { match self { Self::Rename => "rename", + Self::CopyId => "copy id", Self::Fork => "fork", Self::PlaybookTerminalMode => "playbook mode", Self::SplitHorizontal => "split horizontal", @@ -1547,6 +1564,17 @@ impl SessionTitleMenuAction { } } +fn fleet_title_menu_width(identity: &str, view_width: u16) -> u16 { + const BASE_WIDTH: u16 = 34; + let label = format!("copy id ({identity})"); + let desired = unicode_width::UnicodeWidthStr::width(label.as_str()) + .saturating_add(2) + .min(u16::MAX as usize) as u16; + BASE_WIDTH + .max(desired) + .min(view_width.saturating_sub(2).max(1)) +} + #[derive(Debug, Clone)] pub struct SessionTitleMenu { pub session_id: String, @@ -12966,6 +12994,28 @@ impl App { && live } + fn copy_selected_id(&mut self) { + self.copy_selected_id_with(copy_to_clipboard); + } + + fn copy_selected_id_with(&mut self, copy: F) + where + F: FnOnce(&str) -> Result, + { + let Some((noun, value)) = self.selection.fleet_identity() else { + self.set_status("copy id: select a session, project, or service".to_string()); + return; + }; + let value = value.to_string(); + match copy(&value) { + Ok(outcome) => self.set_status(format!( + "{noun} id · {}", + outcome.status(value.chars().count()) + )), + Err(error) => self.set_status(format!("copy {noun} id failed: {error}")), + } + } + async fn run_action(&mut self, action: KeyAction) { use KeyAction::*; // Tutorial hook (a) — spec 0077: `run_action` is the one chokepoint @@ -13134,6 +13184,7 @@ impl App { // The "N archived" disclosure row has no name to rename. Selection::ArchivedRow(_) => {} }, + CopySelectedId => self.copy_selected_id(), OpenDeleteConfirm => match self.selection.clone() { Selection::Session(id) => { // The `[d]`/`[a]`/`[m]`/`[N]` choice cluster and its @@ -14210,6 +14261,7 @@ impl App { "send" | "send-input" => self.run_action(KeyAction::OpenSendInput).await, "delete" | "kill" | "rm" => self.run_action(KeyAction::OpenDeleteConfirm).await, "rename" => self.run_action(KeyAction::OpenRename).await, + "copy-id" => self.run_action(KeyAction::CopySelectedId).await, "fork" => self.run_action(KeyAction::OpenFork).await, "playbook" | "edit-playbook" => self.run_action(KeyAction::OpenPlaybook).await, "zoom" | "fullscreen" => self.run_action(KeyAction::ToggleZoom).await, @@ -16667,6 +16719,50 @@ mod tests { ); } + #[tokio::test] + async fn copy_selected_id_uses_exact_typed_fleet_identity() { + let (mut app, _dir, server) = captured_app().await; + for (selection, expected, noun) in [ + ( + Selection::Session("session-full-id".into()), + "session-full-id", + "session", + ), + ( + Selection::Group("project-full-id".into()), + "project-full-id", + "project", + ), + ( + Selection::Service("service-name".into()), + "service-name", + "service", + ), + ] { + app.selection = selection; + let mut copied = None; + app.copy_selected_id_with(|value| { + copied = Some(value.to_string()); + Ok(ClipboardCopyOutcome::Copied) + }); + assert_eq!(copied.as_deref(), Some(expected)); + let expected_status = + format!("{noun} id · copied {} chars", expected.chars().count()); + assert_eq!( + app.status.as_ref().map(|(message, _)| message.as_str()), + Some(expected_status.as_str()) + ); + } + + app.selection = Selection::None; + app.copy_selected_id_with(|_| panic!("no selection must not touch the clipboard")); + assert_eq!( + app.status.as_ref().map(|(message, _)| message.as_str()), + Some("copy id: select a session, project, or service") + ); + server.abort(); + } + #[test] fn operator_monolog_text_filters_noise() { assert_eq!(operator_monolog_text(""), None); @@ -31658,6 +31754,13 @@ mod tests { .session_title_menu .clone() .expect("clicking the actions button opens the session menu"); + term.draw(|f| crate::ui::render(f, &mut app)) + .expect("render open menu"); + let menu_text = rendered_text(term.backend().buffer()); + assert!( + menu_text.contains("copy id (s1)"), + "session action should preview the exact id: {menu_text}" + ); // Click the "split horizontal" row, which sits over the pane content the // child is tracking. With the fix it dispatches; without it, the click @@ -31739,6 +31842,30 @@ mod tests { ); } + #[test] + fn fleet_title_menu_expands_to_show_full_copy_identity() { + let identity = "s18abe9381bf74f85aa7282940c695fc7"; + let label = format!("copy id ({identity})"); + assert_eq!( + fleet_title_menu_width(identity, 120) as usize, + label.len() + 2, + "the bordered menu should fit the complete copy label" + ); + assert_eq!( + fleet_title_menu_width(identity, 20), + 18, + "the menu still clamps to the available pane width" + ); + assert_eq!( + SessionTitleMenuAction::ALL.last(), + Some(&SessionTitleMenuAction::CopyId) + ); + assert_eq!( + ServiceTitleMenuAction::ALL.last(), + Some(&ServiceTitleMenuAction::CopyId) + ); + } + #[tokio::test] async fn clicking_session_name_starts_inline_rename_cursor_at_click() { use crossterm::event::{MouseButton, MouseEvent, MouseEventKind}; @@ -42640,6 +42767,10 @@ mod tests { for action in ServiceTitleMenuAction::ALL { assert!(menu_text.contains(action.label())); } + assert!( + menu_text.contains("copy id (assistant)"), + "service action should preview the exact id: {menu_text}" + ); assert!(app.service_title_menu.is_some()); server.abort(); } diff --git a/crates/cli/src/app/service_title_menu.rs b/crates/cli/src/app/service_title_menu.rs index 1b625787..9d687c4a 100644 --- a/crates/cli/src/app/service_title_menu.rs +++ b/crates/cli/src/app/service_title_menu.rs @@ -2,9 +2,8 @@ use super::*; impl App { pub fn open_service_title_menu(&mut self, name: String, view: ratatui::layout::Rect) { - const MENU_W: u16 = 34; let menu_h = ServiceTitleMenuAction::ALL.len() as u16 + 2; - let width = MENU_W.min(view.width.saturating_sub(2).max(1)); + let width = fleet_title_menu_width(&name, view.width); let x = view .x .saturating_add(view.width) @@ -31,6 +30,10 @@ impl App { } match action { + ServiceTitleMenuAction::CopyId => { + self.run_action(crate::keymap::KeyAction::CopySelectedId) + .await + } ServiceTitleMenuAction::SplitHorizontal => { self.split_active_window(WindowSplitDirection::Right) } diff --git a/crates/cli/src/app/session_title_menu.rs b/crates/cli/src/app/session_title_menu.rs index 5438c9c7..20baf369 100644 --- a/crates/cli/src/app/session_title_menu.rs +++ b/crates/cli/src/app/session_title_menu.rs @@ -2,11 +2,10 @@ use super::*; impl App { pub fn open_session_title_menu(&mut self, session_id: String, view: ratatui::layout::Rect) { - const MENU_W: u16 = 34; // One floating surface at a time. self.fleet_panel = None; let menu_h = SessionTitleMenuAction::ALL.len() as u16 + 2; - let width = MENU_W.min(view.width.saturating_sub(2).max(1)); + let width = fleet_title_menu_width(&session_id, view.width); let x = view .x .saturating_add(view.width) @@ -41,6 +40,10 @@ impl App { SessionTitleMenuAction::Rename => { self.run_action(crate::keymap::KeyAction::OpenRename).await } + SessionTitleMenuAction::CopyId => { + self.run_action(crate::keymap::KeyAction::CopySelectedId) + .await + } SessionTitleMenuAction::Fork => { self.run_action(crate::keymap::KeyAction::OpenFork).await } diff --git a/crates/cli/src/keymap.rs b/crates/cli/src/keymap.rs index 19dffebe..c9cb4a4b 100644 --- a/crates/cli/src/keymap.rs +++ b/crates/cli/src/keymap.rs @@ -19,6 +19,10 @@ pub enum KeyAction { OpenNewSession, OpenDeleteConfirm, OpenRename, + /// Copy the selected fleet item's stable identity: session id, project id, + /// or service name. Intentionally unbound; invoked by `/copy-id` and the + /// pane action menus. + CopySelectedId, /// Open the fork flow for the selected session. When the session has /// past user turns, a turn picker comes first (spec 0163) with "now — /// fork from the present" preselected, so Enter keeps the head-fork @@ -649,6 +653,18 @@ mod tests { } } + #[test] + fn copy_selected_id_has_no_dedicated_keybinding() { + assert!(matches!( + resolve(&default_for(Profile::Emacs), vec![alt('w')]), + KeymapResult::Unhandled + )); + assert!(matches!( + resolve(&default_for(Profile::Vim), vec![ch('y')]), + KeymapResult::Unhandled + )); + } + #[test] fn no_duplicate_chords_in_any_profile() { for profile in [Profile::Emacs, Profile::Vim] { diff --git a/crates/cli/src/ui.rs b/crates/cli/src/ui.rs index 7e71deda..744d6370 100644 --- a/crates/cli/src/ui.rs +++ b/crates/cli/src/ui.rs @@ -7776,7 +7776,7 @@ fn render_session_title_menu(f: &mut Frame, app: &App) { playbook_open, terminal_focus, ); - render_session_title_menu_row(f, area, row, label_text, binding, style); + render_session_title_menu_row(f, area, row, &label_text, binding, style); } } @@ -7818,11 +7818,15 @@ fn render_service_title_menu(f: &mut Frame, app: &App) { } else { Style::default().fg(app.theme.text) }; + let label = match action { + ServiceTitleMenuAction::CopyId => format!("copy id ({})", menu.name), + _ => action.label().to_string(), + }; render_session_title_menu_row( f, area, row, - action.label(), + &label, service_title_menu_action_binding(app, action), style, ); @@ -7834,6 +7838,7 @@ fn service_title_menu_action_binding( action: ServiceTitleMenuAction, ) -> Option<&'static str> { match (action, app.profile) { + (ServiceTitleMenuAction::CopyId, _) => None, (ServiceTitleMenuAction::SplitHorizontal, Profile::Emacs) => Some("C-x 3"), (ServiceTitleMenuAction::SplitHorizontal, Profile::Vim) => Some("C-w v"), (ServiceTitleMenuAction::SplitVertical, Profile::Emacs) => Some("C-x 2"), @@ -7886,19 +7891,22 @@ fn session_title_menu_action_label( session_id: &str, playbook_open: bool, terminal_focus: bool, -) -> (&'static str, Option<&'static str>) { +) -> (String, Option<&'static str>) { let archived = app .sessions .iter() .find(|s| s.id == session_id) .is_some_and(|s| s.archived); let label = match action { - SessionTitleMenuAction::Archive if archived => "unarchive", + SessionTitleMenuAction::CopyId => format!("copy id ({session_id})"), + SessionTitleMenuAction::Archive if archived => "unarchive".to_string(), SessionTitleMenuAction::PlaybookTerminalMode if playbook_open && terminal_focus => { - "playbook mode" + "playbook mode".to_string() + } + SessionTitleMenuAction::PlaybookTerminalMode if playbook_open => { + "terminal mode".to_string() } - SessionTitleMenuAction::PlaybookTerminalMode if playbook_open => "terminal mode", - _ => action.label(), + _ => action.label().to_string(), }; let binding = match (action, app.profile) { (SessionTitleMenuAction::Rename, Profile::Emacs) => Some("C-x r"), @@ -13969,7 +13977,7 @@ emacs keymap (default; CONSTRUCT_KEYMAP=vim for vim profile) global M-x / C-x x command palette (C-x x is Meta-free) - palette commands: new fork send delete rename playbook diff border + palette commands: new fork send delete rename copy-id playbook diff border theme zoom interrupt refresh harnesses configure paste help ? toggle this help @@ -14041,7 +14049,7 @@ vim keymap (CONSTRUCT_KEYMAP=vim; unset for emacs profile) global : command palette - palette commands: new fork send delete rename playbook diff border + palette commands: new fork send delete rename copy-id playbook diff border theme zoom interrupt refresh harnesses configure paste help A cycle approval mode diff --git a/crates/protocol/src/slash.rs b/crates/protocol/src/slash.rs index 01b79dbd..a904298b 100644 --- a/crates/protocol/src/slash.rs +++ b/crates/protocol/src/slash.rs @@ -33,6 +33,7 @@ pub enum CommandId { Agentd, Border, Compact, + CopyId, Help, Loop, Model, @@ -248,6 +249,18 @@ pub const COMMANDS: &[SlashCommand] = &[ help: "Rename the focused session", in_popup: true, }, + SlashCommand { + id: CommandId::CopyId, + name: "/copy-id", + aliases: &[], + args: Args::None, + routing: Routing::Client, + visibility: ModelVisibility::Hidden, + transcript: TranscriptPolicy::Omit, + render: Render::Hidden, + help: "Copy the selected session, project, or service id", + in_popup: true, + }, SlashCommand { id: CommandId::Tasks, name: "/tasks", @@ -568,6 +581,14 @@ mod tests { assert!(popup_names().any(|name| name == "/theme")); } + #[test] + fn copy_id_has_one_spelling() { + let cmd = SlashCommand::resolve("/copy-id").expect("/copy-id command"); + assert_eq!(cmd.id, CommandId::CopyId); + assert!(cmd.aliases.is_empty()); + assert!(SlashCommand::resolve("/copy-identity").is_none()); + } + #[test] fn model_completions_are_offered_after_model_command() { assert!(model_completion_matches("/mod").is_empty()); diff --git a/specs/0197-tui-copy-selected-fleet-identity.md b/specs/0197-tui-copy-selected-fleet-identity.md new file mode 100644 index 00000000..3cb75e4f --- /dev/null +++ b/specs/0197-tui-copy-selected-fleet-identity.md @@ -0,0 +1,31 @@ +# 0197-tui-copy-selected-fleet-identity + +Status: accepted +Date: 2026-08-10 +Area: tui +Scope: Copy the exact stable identity of a selected session, project, or service from the TUI. + +## Decision + +The TUI exposes one copy-ID action for every selectable fleet object. It copies the raw session id for a session, the raw project id for a project, and the stable service name for a service. The action is available as `/copy-id`; pane title menus expose it as the final action where those menus already exist and show the exact identity in the label before it is copied. + +Archived disclosure rows do not invent an identity. Invoking the action without a session, project, or service selected leaves the clipboard unchanged and reports that an identifiable fleet item must be selected. + +Identity copies use the same clipboard transport and delivery-status semantics as other TUI text copies, including the SSH bridge and OSC 52 behavior. + +## Reason + +Fleet identities are frequently needed in CLI commands, logs, issue reports, and cross-session coordination. Display titles and shortened ids are useful for scanning but are unsafe to copy as identifiers, while manually selecting a full id is slow and error-prone. + +## Consequences + +- The copied value is exact and unadorned so it can be pasted directly into commands and APIs. +- Services continue to use their protocol-level stable names as identities; no parallel opaque service id is introduced. +- Copy feedback identifies the fleet object type and distinguishes confirmed clipboard writes from terminal clipboard requests. +- Copy identity has no dedicated keybinding, avoiding conflicts with native editor and terminal keys. +- Future selectable fleet object types must either define their stable copy identity or remain explicitly unsupported by this action. + +## Non-Goals + +- This does not copy display titles, summaries, or compound labels. +- This does not make archived disclosure controls addressable as fleet objects.