diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/api/mod.rs | 1 | ||||
| -rw-r--r-- | src/api/schema.rs | 12 | ||||
| -rw-r--r-- | src/app/api.rs | 42 | ||||
| -rw-r--r-- | src/app/config_io.rs | 13 | ||||
| -rw-r--r-- | src/app/creation.rs | 1 | ||||
| -rw-r--r-- | src/app/input/mod.rs | 5 | ||||
| -rw-r--r-- | src/app/input/modal.rs | 53 | ||||
| -rw-r--r-- | src/app/input/mouse.rs | 16 | ||||
| -rw-r--r-- | src/app/input/navigate.rs | 16 | ||||
| -rw-r--r-- | src/app/input/settings.rs | 43 | ||||
| -rw-r--r-- | src/app/mod.rs | 54 | ||||
| -rw-r--r-- | src/app/state.rs | 46 | ||||
| -rw-r--r-- | src/cli.rs | 34 | ||||
| -rw-r--r-- | src/config/keybinds.rs | 26 | ||||
| -rw-r--r-- | src/config/model.rs | 19 | ||||
| -rw-r--r-- | src/main.rs | 4 | ||||
| -rw-r--r-- | src/pane/state.rs | 39 | ||||
| -rw-r--r-- | src/persist/restore.rs | 9 | ||||
| -rw-r--r-- | src/persist/snapshot.rs | 13 | ||||
| -rw-r--r-- | src/ui.rs | 4 | ||||
| -rw-r--r-- | src/ui/dialogs.rs | 1 | ||||
| -rw-r--r-- | src/ui/keybind_help.rs | 1 | ||||
| -rw-r--r-- | src/ui/panes.rs | 44 | ||||
| -rw-r--r-- | src/ui/settings.rs | 11 | ||||
| -rw-r--r-- | src/ui/sidebar.rs | 1 | ||||
| -rw-r--r-- | src/workspace.rs | 6 |
26 files changed, 485 insertions, 29 deletions
diff --git a/src/api/mod.rs b/src/api/mod.rs index 08cf2f50..506bcf7c 100644 --- a/src/api/mod.rs +++ b/src/api/mod.rs @@ -401,6 +401,7 @@ fn api_method_name(method: &Method) -> &'static str { Method::PaneSplit(_) => "pane.split", Method::PaneList(_) => "pane.list", Method::PaneGet(_) => "pane.get", + Method::PaneRename(_) => "pane.rename", Method::PaneSendText(_) => "pane.send_text", Method::PaneSendKeys(_) => "pane.send_keys", Method::PaneSendInput(_) => "pane.send_input", diff --git a/src/api/schema.rs b/src/api/schema.rs index fb7177f7..0b801d89 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -46,6 +46,8 @@ pub enum Method { PaneList(PaneListParams), #[serde(rename = "pane.get")] PaneGet(PaneTarget), + #[serde(rename = "pane.rename")] + PaneRename(PaneRenameParams), #[serde(rename = "pane.send_text")] PaneSendText(PaneSendTextParams), #[serde(rename = "pane.send_keys")] @@ -161,6 +163,13 @@ pub struct PaneListParams { } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct PaneRenameParams { + pub pane_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct PaneSendTextParams { pub pane_id: String, pub text: String, @@ -533,6 +542,8 @@ pub struct PaneInfo { #[serde(default, skip_serializing_if = "Option::is_none")] pub cwd: Option<String>, #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, + #[serde(default, skip_serializing_if = "Option::is_none")] pub agent: Option<String>, pub agent_status: AgentStatus, pub revision: u64, @@ -1012,6 +1023,7 @@ mod tests { tab_id: "w_1:2".into(), focused: false, cwd: Some("/tmp/review".into()), + label: None, agent: None, agent_status: AgentStatus::Unknown, revision: 0, diff --git a/src/app/api.rs b/src/app/api.rs index c675aa6f..46429ffe 100644 --- a/src/app/api.rs +++ b/src/app/api.rs @@ -927,6 +927,48 @@ impl App { result: ResponseResult::PaneInfo { pane }, } } + Method::PaneRename(params) => { + let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.pane_id), + }, + }) + .unwrap(); + }; + let Some(ws) = self.state.workspaces.get_mut(ws_idx) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.pane_id), + }, + }) + .unwrap(); + }; + let Some(pane_state) = ws.pane_state_mut(pane_id) else { + return serde_json::to_string(&ErrorResponse { + id: request.id, + error: ErrorBody { + code: "pane_not_found".into(), + message: format!("pane {} not found", params.pane_id), + }, + }) + .unwrap(); + }; + match params.label.map(|label| label.trim().to_string()) { + Some(label) if !label.is_empty() => pane_state.set_manual_label(label), + _ => pane_state.clear_manual_label(), + } + self.state.mark_session_dirty(); + let pane = self.pane_info(ws_idx, pane_id).unwrap(); + SuccessResponse { + id: request.id, + result: ResponseResult::PaneInfo { pane }, + } + } Method::PaneRead(params) => { let Some((ws_idx, pane_id)) = self.parse_pane_id(¶ms.pane_id) else { return serde_json::to_string(&ErrorResponse { diff --git a/src/app/config_io.rs b/src/app/config_io.rs index a9199561..0bf04831 100644 --- a/src/app/config_io.rs +++ b/src/app/config_io.rs @@ -72,6 +72,19 @@ impl App { } } + pub(super) fn save_agent_border_labels(&mut self, enabled: bool) { + if self.update_config_file("agent border labels", |content| { + crate::config::upsert_section_bool( + content, + "ui", + "show_agent_labels_on_pane_borders", + enabled, + ) + }) { + self.apply_config_from_disk(false); + } + } + pub(super) fn save_agent_panel_scope(&mut self, scope: crate::app::state::AgentPanelScope) { let value = match scope { crate::app::state::AgentPanelScope::CurrentWorkspace => { diff --git a/src/app/creation.rs b/src/app/creation.rs index 4e38084c..51dac1e8 100644 --- a/src/app/creation.rs +++ b/src/app/creation.rs @@ -245,6 +245,7 @@ impl App { cwd: runtime .and_then(|rt| rt.cwd()) .map(|cwd| cwd.display().to_string()), + label: pane.manual_label.clone(), agent: pane.effective_agent_label().map(str::to_string), agent_status: pane_agent_status(pane.state, pane.seen), revision: 0, diff --git a/src/app/input/mod.rs b/src/app/input/mod.rs index aa607c78..575b373d 100644 --- a/src/app/input/mod.rs +++ b/src/app/input/mod.rs @@ -63,7 +63,7 @@ impl App { Mode::Onboarding => self.handle_onboarding_key(key), Mode::ReleaseNotes => self.handle_release_notes_key(key), Mode::Navigate => unreachable!(), - Mode::RenameWorkspace | Mode::RenameTab => { + Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { handle_rename_key(&mut self.state, key) } Mode::Resize => handle_resize_key(&mut self.state, key), @@ -159,6 +159,9 @@ impl App { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), + SettingsAction::SaveAgentBorderLabels(enabled) => { + self.save_agent_border_labels(enabled) + } } } if self.state.agent_panel_scope != previous_agent_panel_scope { diff --git a/src/app/input/modal.rs b/src/app/input/modal.rs index a76576bf..367e7e84 100644 --- a/src/app/input/modal.rs +++ b/src/app/input/modal.rs @@ -164,6 +164,7 @@ pub(crate) fn handle_keybind_help_key(state: &mut AppState, key: KeyEvent) { pub(super) fn open_rename_workspace(state: &mut AppState, ws_idx: usize) { state.selected = ws_idx; + state.rename_pane_target = None; state.name_input = state.workspaces[ws_idx].display_name(); state.name_input_replace_on_type = false; state.mode = Mode::RenameWorkspace; @@ -172,6 +173,7 @@ pub(super) fn open_rename_workspace(state: &mut AppState, ws_idx: usize) { pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool) { state.creating_new_tab = false; state.requested_new_tab_name = None; + state.rename_pane_target = None; if let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) { if let Some(name) = ws.active_tab_display_name() { state.name_input = name; @@ -181,6 +183,21 @@ pub(super) fn open_rename_active_tab(state: &mut AppState, replace_on_type: bool } } +pub(super) fn open_rename_pane(state: &mut AppState, pane_id: crate::layout::PaneId) { + let Some(ws) = state.active.and_then(|i| state.workspaces.get(i)) else { + return; + }; + let Some(pane) = ws.pane_state(pane_id) else { + return; + }; + state.creating_new_tab = false; + state.requested_new_tab_name = None; + state.rename_pane_target = Some(pane_id); + state.name_input = pane.manual_label.clone().unwrap_or_default(); + state.name_input_replace_on_type = pane.manual_label.is_none(); + state.mode = Mode::RenamePane; +} + fn next_new_tab_default_name(state: &AppState) -> String { state .active @@ -192,6 +209,7 @@ fn next_new_tab_default_name(state: &AppState) -> String { pub(super) fn open_new_tab_dialog(state: &mut AppState) { state.creating_new_tab = true; state.requested_new_tab_name = None; + state.rename_pane_target = None; state.name_input = next_new_tab_default_name(state); state.name_input_replace_on_type = true; state.mode = Mode::RenameTab; @@ -295,9 +313,21 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { } } } + Mode::RenamePane => { + if let (Some(ws_idx), Some(pane_id)) = (state.active, state.rename_pane_target) + { + if let Some(ws) = state.workspaces.get_mut(ws_idx) { + if let Some(pane) = ws.pane_state_mut(pane_id) { + pane.set_manual_label(new_name); + state.mark_session_dirty(); + } + } + } + } _ => {} } state.creating_new_tab = false; + state.rename_pane_target = None; state.name_input.clear(); state.name_input_replace_on_type = false; leave_modal(state); @@ -309,6 +339,7 @@ pub(super) fn apply_rename_action(state: &mut AppState, action: ModalAction) { ModalAction::Cancel => { state.creating_new_tab = false; state.requested_new_tab_name = None; + state.rename_pane_target = None; state.name_input.clear(); state.name_input_replace_on_type = false; leave_modal(state); @@ -432,19 +463,33 @@ pub(super) fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuS Mode::Navigate }; } - (ContextMenuKind::Pane, Some("Split vertical")) => { + (ContextMenuKind::Pane { pane_id, .. }, Some("Rename pane")) => { + open_rename_pane(state, pane_id); + } + (ContextMenuKind::Pane { pane_id, .. }, Some("Clear pane name")) => { + if let Some(ws_idx) = state.active { + if let Some(ws) = state.workspaces.get_mut(ws_idx) { + if let Some(pane) = ws.pane_state_mut(pane_id) { + pane.clear_manual_label(); + state.mark_session_dirty(); + } + } + } + state.mode = Mode::Terminal; + } + (ContextMenuKind::Pane { .. }, Some("Split vertical")) => { state.split_pane(Direction::Horizontal); state.mode = Mode::Terminal; } - (ContextMenuKind::Pane, Some("Split horizontal")) => { + (ContextMenuKind::Pane { .. }, Some("Split horizontal")) => { state.split_pane(Direction::Vertical); state.mode = Mode::Terminal; } - (ContextMenuKind::Pane, Some("Fullscreen")) => { + (ContextMenuKind::Pane { .. }, Some("Fullscreen")) => { state.toggle_fullscreen(); state.mode = Mode::Terminal; } - (ContextMenuKind::Pane, Some("Close pane")) => { + (ContextMenuKind::Pane { .. }, Some("Close pane")) => { state.close_pane(); state.mode = if state.active.is_some() { Mode::Terminal diff --git a/src/app/input/mouse.rs b/src/app/input/mouse.rs index 4a074a2e..f7997807 100644 --- a/src/app/input/mouse.rs +++ b/src/app/input/mouse.rs @@ -137,7 +137,10 @@ impl AppState { return None; } - if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) { + if matches!( + self.mode, + Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane + ) { let action = self .rename_modal_inner() .map(crate::ui::rename_button_rects) @@ -678,8 +681,17 @@ impl AppState { MouseEventKind::Down(MouseButton::Right) if !in_sidebar => { if let Some(info) = self.pane_mouse_target(mouse.column, mouse.row).cloned() { self.focus_pane(info.id); + let has_manual_label = self + .active + .and_then(|ws_idx| self.workspaces.get(ws_idx)) + .and_then(|ws| ws.pane_state(info.id)) + .and_then(|pane| pane.manual_label.as_ref()) + .is_some(); self.context_menu = Some(ContextMenuState { - kind: ContextMenuKind::Pane, + kind: ContextMenuKind::Pane { + pane_id: info.id, + has_manual_label, + }, x: mouse.column, y: mouse.row, list: MenuListState::new(0), diff --git a/src/app/input/navigate.rs b/src/app/input/navigate.rs index 3bc8da78..bb88c46b 100644 --- a/src/app/input/navigate.rs +++ b/src/app/input/navigate.rs @@ -368,6 +368,7 @@ pub(crate) enum NavigateAction { PreviousTab, NextTab, CloseTab, + RenamePane, FocusPaneLeft, FocusPaneDown, FocusPaneUp, @@ -432,6 +433,12 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA { return Some(NavigateAction::CloseTab); } + if kb + .rename_pane + .is_some_and(|(code, mods)| key_matches(key, code, mods)) + { + return Some(NavigateAction::RenamePane); + } if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) { return Some(NavigateAction::SplitVertical); } @@ -508,6 +515,15 @@ pub(super) fn execute_navigate_action(state: &mut AppState, action: NavigateActi state.close_tab(); leave_navigate_mode(state); } + NavigateAction::RenamePane => { + if let Some(pane_id) = state + .active + .and_then(|ws_idx| state.workspaces.get(ws_idx)) + .and_then(|ws| ws.focused_pane_id()) + { + super::modal::open_rename_pane(state, pane_id); + } + } NavigateAction::FocusPaneLeft => state.navigate_pane(NavDirection::Left), NavigateAction::FocusPaneDown => state.navigate_pane(NavDirection::Down), NavigateAction::FocusPaneUp => state.navigate_pane(NavDirection::Up), diff --git a/src/app/input/settings.rs b/src/app/input/settings.rs index 67144a28..9e89e515 100644 --- a/src/app/input/settings.rs +++ b/src/app/input/settings.rs @@ -16,6 +16,7 @@ pub(super) enum SettingsAction { SaveTheme(String), SaveSound(bool), SaveToastDelivery(ToastDelivery), + SaveAgentBorderLabels(bool), } impl App { @@ -25,6 +26,9 @@ impl App { SettingsAction::SaveTheme(name) => self.save_theme(&name), SettingsAction::SaveSound(enabled) => self.save_sound(enabled), SettingsAction::SaveToastDelivery(delivery) => self.save_toast_delivery(delivery), + SettingsAction::SaveAgentBorderLabels(enabled) => { + self.save_agent_border_labels(enabled) + } } } } @@ -157,6 +161,30 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti state.settings.list.selected = usize::from(!state.sound_enabled()); } KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { + state.settings.section = SettingsSection::PaneLabels; + state.settings.list.selected = usize::from(!state.agent_border_labels_enabled()); + } + _ => { + if let Some(super::modal::ModalAction::Close) = + super::modal::modal_action_from_key(&key, super::modal::SETTINGS_ACTIONS) + { + cancel_settings(state); + } + } + }, + SettingsSection::PaneLabels => match key.code { + KeyCode::Up | KeyCode::Char('k') | KeyCode::Down | KeyCode::Char('j') => { + state.settings.list.selected = 1 - state.settings.list.selected.min(1); + } + KeyCode::Enter | KeyCode::Char(' ') => { + let enabled = state.settings.list.selected == 0; + return Some(SettingsAction::SaveAgentBorderLabels(enabled)); + } + KeyCode::BackTab | KeyCode::Left | KeyCode::Char('h') => { + state.settings.section = SettingsSection::Toast; + state.settings.list.selected = toast_delivery_index(state.toast_delivery()); + } + KeyCode::Tab | KeyCode::Right | KeyCode::Char('l') => { state.settings.section = SettingsSection::Theme; state.settings.list.selected = current_theme_index(&state.theme_name); } @@ -252,6 +280,14 @@ impl AppState { None } } + SettingsSection::PaneLabels => { + let list_y = area.y + 3; + if row >= list_y && row < list_y + 2 { + Some((row - list_y) as usize) + } else { + None + } + } } } @@ -264,6 +300,9 @@ impl AppState { SettingsSection::Theme => current_theme_index(&self.theme_name), SettingsSection::Sound => usize::from(!self.sound_enabled()), SettingsSection::Toast => toast_delivery_index(self.toast_delivery()), + SettingsSection::PaneLabels => { + usize::from(!self.agent_border_labels_enabled()) + } }); return None; } @@ -282,6 +321,10 @@ impl AppState { let delivery = toast_delivery_for_index(idx); Some(SettingsAction::SaveToastDelivery(delivery)) } + SettingsSection::PaneLabels => { + let enabled = idx == 0; + Some(SettingsAction::SaveAgentBorderLabels(enabled)) + } }; } diff --git a/src/app/mod.rs b/src/app/mod.rs index 60e38f5b..43d15bec 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -292,6 +292,7 @@ impl App { request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, + rename_pane_target: None, request_complete_onboarding: false, name_input: String::new(), name_input_replace_on_type: false, @@ -344,6 +345,7 @@ impl App { sidebar_section_split, agent_panel_scope, confirm_close: config.ui.confirm_close, + show_agent_labels_on_pane_borders: config.ui.show_agent_labels_on_pane_borders, pane_scrollback_limit_bytes: config.advanced.scrollback_limit_bytes, accent: crate::config::parse_color(&config.ui.accent), sound: config.ui.sound.clone(), @@ -649,6 +651,8 @@ impl App { self.state.sidebar_width = config.ui.sidebar_width; } self.state.confirm_close = config.ui.confirm_close; + self.state.show_agent_labels_on_pane_borders = + config.ui.show_agent_labels_on_pane_borders; self.state.agent_panel_scope = agent_panel_scope_from_config(config.ui.agent_panel_scope); self.state.agent_panel_scroll = 0; @@ -809,7 +813,7 @@ impl App { Mode::Navigate => { self.handle_navigate_key(key); } - Mode::RenameWorkspace | Mode::RenameTab => { + Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { input::handle_rename_key(&mut self.state, key_event); } Mode::Resize => { @@ -1482,6 +1486,54 @@ mod tests { assert!(app.state.should_quit); } + #[test] + fn pane_rename_request_sets_and_clears_manual_label() { + let mut app = test_app(); + let workspace = Workspace::test_new("api-pane-rename"); + let pane = workspace.tabs[0].root_pane; + app.state.workspaces = vec![workspace]; + app.state.active = Some(0); + app.state.selected = 0; + + let pane_id = app.pane_info(0, pane).unwrap().pane_id; + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_pane_rename".into(), + method: crate::api::schema::Method::PaneRename(crate::api::schema::PaneRenameParams { + pane_id: pane_id.clone(), + label: Some("reviewer".into()), + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "pane_info"); + assert_eq!(response["result"]["pane"]["label"], "reviewer"); + assert_eq!( + app.state.workspaces[0] + .pane_state(pane) + .unwrap() + .manual_label + .as_deref(), + Some("reviewer") + ); + + let response = app.handle_api_request(crate::api::schema::Request { + id: "req_pane_rename_clear".into(), + method: crate::api::schema::Method::PaneRename(crate::api::schema::PaneRenameParams { + pane_id, + label: None, + }), + }); + let response: serde_json::Value = serde_json::from_str(&response).unwrap(); + + assert_eq!(response["result"]["type"], "pane_info"); + assert!(response["result"]["pane"].get("label").is_none()); + assert!(app.state.workspaces[0] + .pane_state(pane) + .unwrap() + .manual_label + .is_none()); + } + #[tokio::test] async fn pane_split_request_targets_pane_in_background_tab() { let _guard = config_env_lock().lock().unwrap(); diff --git a/src/app/state.rs b/src/app/state.rs index 134d86bd..dd081298 100644 --- a/src/app/state.rs +++ b/src/app/state.rs @@ -383,6 +383,7 @@ pub enum Mode { Terminal, RenameWorkspace, RenameTab, + RenamePane, Resize, ConfirmClose, ContextMenu, @@ -408,16 +409,18 @@ pub enum SettingsSection { Theme, Sound, Toast, + PaneLabels, } impl SettingsSection { - pub const ALL: &[Self] = &[Self::Theme, Self::Sound, Self::Toast]; + pub const ALL: &[Self] = &[Self::Theme, Self::Sound, Self::Toast, Self::PaneLabels]; pub fn label(self) -> &'static str { match self { Self::Theme => "theme", Self::Sound => "sound", Self::Toast => "toasts", + Self::PaneLabels => "pane labels", } } } @@ -554,9 +557,17 @@ pub(crate) struct TabPressState { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContextMenuKind { - Workspace { ws_idx: usize }, - Tab { ws_idx: usize, tab_idx: usize }, - Pane, + Workspace { + ws_idx: usize, + }, + Tab { + ws_idx: usize, + tab_idx: usize, + }, + Pane { + pane_id: PaneId, + has_manual_label: bool, + }, } /// Right-click context menu state. @@ -572,7 +583,22 @@ impl ContextMenuState { match self.kind { ContextMenuKind::Workspace { .. } => &["Rename", "Close"], ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"], - ContextMenuKind::Pane => &[ + ContextMenuKind::Pane { + has_manual_label: true, + .. + } => &[ + "Rename pane", + "Clear pane name", + "Split vertical", + "Split horizontal", + "Fullscreen", + "Close pane", + ], + ContextMenuKind::Pane { + has_manual_label: false, + .. + } => &[ + "Rename pane", "Split vertical", "Split horizontal", "Fullscreen", @@ -645,6 +671,7 @@ pub struct AppState { pub request_clipboard_write: Option<Vec<u8>>, pub creating_new_tab: bool, pub requested_new_tab_name: Option<String>, + pub rename_pane_target: Option<PaneId>, pub request_complete_onboarding: bool, pub name_input: String, pub name_input_replace_on_type: bool, @@ -683,6 +710,7 @@ pub struct AppState { pub sidebar_section_split: f32, pub agent_panel_scope: AgentPanelScope, pub confirm_close: bool, + pub show_agent_labels_on_pane_borders: bool, pub pane_scrollback_limit_bytes: usize, #[allow(dead_code)] // kept for backward compat; palette.accent is the source of truth pub accent: Color, @@ -719,6 +747,10 @@ impl AppState { self.toast_config.delivery } + pub fn agent_border_labels_enabled(&self) -> bool { + self.show_agent_labels_on_pane_borders + } + pub fn is_prefix(&self, key: &crossterm::event::KeyEvent) -> bool { key_matches(key, self.prefix_code, self.prefix_mods) } @@ -797,6 +829,7 @@ impl AppState { request_clipboard_write: None, creating_new_tab: false, requested_new_tab_name: None, + rename_pane_target: None, request_complete_onboarding: false, name_input: String::new(), name_input_replace_on_type: false, @@ -844,6 +877,7 @@ impl AppState { sidebar_section_split: 0.5, agent_panel_scope: AgentPanelScope::AllWorkspaces, confirm_close: true, + show_agent_labels_on_pane_borders: false, pane_scrollback_limit_bytes: crate::config::DEFAULT_SCROLLBACK_LIMIT_BYTES, accent: Color::Cyan, sound: SoundConfig { @@ -877,6 +911,8 @@ impl AppState { next_tab_label: None, close_tab: None, close_tab_label: None, + rename_pane: None, + rename_pane_label: None, focus_pane_left: None, focus_pane_left_label: None, focus_pane_down: None, @@ -7,10 +7,10 @@ use serde::Serialize; use crate::api; use crate::api::schema::{ AgentStatus, EmptyParams, IntegrationTarget, Method, OutputMatch, PaneListParams, - PaneReadParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, PaneSplitParams, - PaneTarget, PaneWaitForOutputParams, PingParams, ReadFormat, ReadSource, Request, - SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, TabTarget, - WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, + PaneReadParams, PaneRenameParams, PaneSendInputParams, PaneSendKeysParams, PaneSendTextParams, + PaneSplitParams, PaneTarget, PaneWaitForOutputParams, PingParams, ReadFormat, ReadSource, + Request, SplitDirection, Subscription, TabCreateParams, TabListParams, TabRenameParams, + TabTarget, WorkspaceCreateParams, WorkspaceRenameParams, WorkspaceTarget, }; pub enum CommandOutcome { @@ -274,6 +274,7 @@ fn run_pane_command(args: &[String]) -> std::io::Result<i32> { "list" => pane_list(&args[1..]), "get" => pane_get(&args[1..]), "read" => pane_read(&args[1..]), + "rename" => pane_rename(&args[1..]), "split" => pane_split(&args[1..]), "close" => pane_close(&args[1..]), "send-text" => pane_send_text(&args[1..]), @@ -788,6 +789,30 @@ fn pane_get(args: &[String]) -> std::io::Result<i32> { })?) } +fn pane_rename(args: &[String]) -> std::io::Result<i32> { + let Some(raw_pane_id) = args.first() else { + eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); + return Ok(2); + }; + if args.len() < 2 { + eprintln!("usage: herdr pane rename <pane_id> <label>|--clear"); + return Ok(2); + } + let label = if args.len() == 2 && args[1] == "--clear" { + None + } else { + Some(args[1..].join(" ")) + }; + + print_response(&send_request(&Request { + id: "cli:pane:rename".into(), + method: Method::PaneRename(PaneRenameParams { + pane_id: normalize_pane_id(raw_pane_id), + label, + }), + })?) +} + fn pane_read(args: &[String]) -> std::io::Result<i32> { let Some(raw_pane_id) = args.first() else { eprintln!("usage: herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); @@ -1497,6 +1522,7 @@ fn print_pane_help() { eprintln!("herdr pane commands:"); eprintln!(" herdr pane list [--workspace <workspace_id>]"); eprintln!(" herdr pane get <pane_id>"); + eprintln!(" herdr pane rename <pane_id> <label>|--clear"); eprintln!(" herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]"); eprintln!( " herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]" diff --git a/src/config/keybinds.rs b/src/config/keybinds.rs index 4a54dd74..4a638ec0 100644 --- a/src/config/keybinds.rs +++ b/src/config/keybinds.rs @@ -81,6 +81,8 @@ pub struct Keybinds { pub next_tab_label: Option<String>, pub close_tab: Option<(KeyCode, KeyModifiers)>, pub close_tab_label: Option<String>, + pub rename_pane: Option<(KeyCode, KeyModifiers)>, + pub rename_pane_label: Option<String>, pub focus_pane_left: Option<(KeyCode, KeyModifiers)>, pub focus_pane_left_label: Option<String>, pub focus_pane_down: Option<(KeyCode, KeyModifiers)>, @@ -380,6 +382,12 @@ impl Config { &mut diagnostics, ), optional_binding( + BindingScope::Navigate, + "keys.rename_pane", + &self.keys.rename_pane, + &mut diagnostics, + ), + optional_binding( BindingScope::TerminalDirect, "keys.focus_pane_left", &self.keys.focus_pane_left, @@ -602,14 +610,16 @@ impl Config { next_tab_label: optional_bindings[6].label.clone(), close_tab: optional_bindings[7].value, close_tab_label: optional_bindings[7].label.clone(), - focus_pane_left: optional_bindings[8].value, - focus_pane_left_label: optional_bindings[8].label.clone(), - focus_pane_down: optional_bindings[9].value, - focus_pane_down_label: optional_bindings[9].label.clone(), - focus_pane_up: optional_bindings[10].value, - focus_pane_up_label: optional_bindings[10].label.clone(), - focus_pane_right: optional_bindings[11].value, - focus_pane_right_label: optional_bindings[11].label.clone(), + rename_pane: optional_bindings[8].value, + rename_pane_label: optional_bindings[8].label.clone(), + focus_pane_left: optional_bindings[9].value, + focus_pane_left_label: optional_bindings[9].label.clone(), + focus_pane_down: optional_bindings[10].value, + focus_pane_down_label: optional_bindings[10].label.clone(), + focus_pane_up: optional_bindings[11].value, + focus_pane_up_label: optional_bindings[11].label.clone(), + focus_pane_right: optional_bindings[12].value, + focus_pane_right_label: optional_bindings[12].label.clone(), split_vertical: bindings[4].value, split_vertical_label: bindings[4].label.clone(), split_horizontal: bindings[5].value, diff --git a/src/config/model.rs b/src/config/model.rs index a11192fb..4b0638be 100644 --- a/src/config/model.rs +++ b/src/config/model.rs @@ -93,6 +93,8 @@ pub struct KeysConfig { pub next_tab: String, /// Close the active tab. Unset by default. pub close_tab: String, + /// Rename the focused pane. Unset by default. + pub rename_pane: String, /// Focus the pane to the left in terminal mode. Unset by default. pub focus_pane_left: String, /// Focus the pane below in terminal mode. Unset by default. @@ -123,6 +125,8 @@ pub struct UiConfig { pub sidebar_width: u16, /// Ask for confirmation before closing a workspace. Default: true. pub confirm_close: bool, + /// Show agent labels in split pane borders when no manual pane label is set. Default: false. + pub show_agent_labels_on_pane_borders: bool, /// Agent sidebar scope. Saved values are "current" or "all". Default: "all". pub agent_panel_scope: AgentPanelScopeConfig, /// Accent color for highlights, borders, and navigation UI. @@ -160,6 +164,7 @@ impl Default for KeysConfig { previous_tab: "".into(), next_tab: "".into(), close_tab: "".into(), + rename_pane: "".into(), focus_pane_left: "".into(), focus_pane_down: "".into(), focus_pane_up: "".into(), @@ -180,6 +185,7 @@ impl Default for UiConfig { Self { sidebar_width: 26, confirm_close: true, + show_agent_labels_on_pane_borders: false, agent_panel_scope: AgentPanelScopeConfig::All, accent: "cyan".into(), toast: ToastConfig::default(), @@ -242,6 +248,19 @@ agent_panel_scope = "all" } #[test] + fn pane_border_agent_labels_default_off_and_parse() { + let default_config = Config::default(); + assert!(!default_config.ui.show_agent_labels_on_pane_borders); + + let toml = r#" +[ui] +show_agent_labels_on_pane_borders = true +"#; + let config: Config = toml::from_str(toml).unwrap(); + assert!(config.ui.show_agent_labels_on_pane_borders); + } + + #[test] fn toast_config_parses() { let toml = r#" [ui.toast] diff --git a/src/main.rs b/src/main.rs index 851ff8f9..4b0c71dd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -92,6 +92,7 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # previous_tab = "" # optional, unset by default # next_tab = "" # optional, unset by default # close_tab = "" # optional, unset by default +# rename_pane = "" # optional, unset by default # focus_pane_left = "" # optional, unset by default # focus_pane_down = "" # optional, unset by default # focus_pane_up = "" # optional, unset by default @@ -118,6 +119,9 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration # Ask for confirmation before closing a workspace # confirm_close = true +# Show detected/reported agent labels in split pane borders when no manual pane name is set. +# show_agent_labels_on_pane_borders = false + # Agent panel scope: "current" or "all". Toggling it in the sidebar saves this setting. # agent_panel_scope = "all" diff --git a/src/pane/state.rs b/src/pane/state.rs index 8b36826b..f9b7a149 100644 --- a/src/pane/state.rs +++ b/src/pane/state.rs @@ -28,6 +28,7 @@ pub struct PaneState { pub detected_agent: Option<Agent>, pub fallback_state: AgentState, pub hook_authority: Option<HookAuthority>, + pub manual_label: Option<String>, hook_report_sequences: HashMap<String, u64>, pub state: AgentState, /// Whether the user has seen this pane since its last state change to Idle. @@ -41,6 +42,7 @@ impl PaneState { detected_agent: None, fallback_state: AgentState::Unknown, hook_authority: None, + manual_label: None, hook_report_sequences: HashMap::new(), state: AgentState::Unknown, seen: true, @@ -184,6 +186,23 @@ impl PaneState { self.detected_agent } + pub fn set_manual_label(&mut self, label: String) { + let label = label.trim().to_string(); + self.manual_label = (!label.is_empty()).then_some(label); + } + + pub fn clear_manual_label(&mut self) { + self.manual_label = None; + } + + pub fn border_label(&self, show_agent_labels: bool) -> Option<&str> { + self.manual_label.as_deref().or_else(|| { + show_agent_labels + .then(|| self.effective_agent_label()) + .flatten() + }) + } + fn recompute_effective_state( &mut self, previous_agent_label: Option<String>, @@ -339,6 +358,26 @@ mod tests { } #[test] + fn border_label_prefers_manual_label_over_agent_label() { + let mut pane = PaneState::new(); + pane.set_detected_state(Some(Agent::Claude), AgentState::Idle); + + assert_eq!(pane.border_label(false), None); + assert_eq!(pane.border_label(true), Some("claude")); + + pane.set_manual_label(" reviewer ".into()); + assert_eq!(pane.border_label(false), Some("reviewer")); + assert_eq!(pane.border_label(true), Some("reviewer")); + + pane.set_manual_label(" ".into()); + assert_eq!(pane.border_label(true), Some("claude")); + + pane.set_manual_label("reviewer".into()); + pane.clear_manual_label(); + assert_eq!(pane.border_label(true), Some("claude")); + } + + #[test] fn hook_authority_survives_unrelated_detected_agent_clear() { let mut pane = PaneState::new(); pane.set_detected_state(Some(Agent::Pi), AgentState::Idle); diff --git a/src/persist/restore.rs b/src/persist/restore.rs index 3eb5a4a3..7d623173 100644 --- a/src/persist/restore.rs +++ b/src/persist/restore.rs @@ -136,6 +136,11 @@ fn restore_tab( } }; + let saved_label = reverse_id_map + .get(id) + .and_then(|old_id| snap.panes.get(old_id)) + .and_then(|p| p.label.clone()); + match PaneRuntime::spawn( *id, rows, @@ -148,7 +153,9 @@ fn restore_tab( render_dirty.clone(), ) { Ok(runtime) => { - panes.insert(*id, PaneState::new()); + let mut pane_state = PaneState::new(); + pane_state.manual_label = saved_label; + panes.insert(*id, pane_state); pane_cwds.insert(*id, cwd.clone()); runtimes.insert(*id, runtime); } diff --git a/src/persist/snapshot.rs b/src/persist/snapshot.rs index 2bcd034e..76ed96f5 100644 --- a/src/persist/snapshot.rs +++ b/src/persist/snapshot.rs @@ -68,6 +68,8 @@ pub struct TabSnapshot { #[derive(Serialize, Deserialize)] pub struct PaneSnapshot { pub cwd: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub label: Option<String>, } /// Serializable BSP tree. @@ -228,7 +230,8 @@ fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot { let cwd = tab .cwd_for_pane(*id) .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into())); - panes.insert(id.raw(), PaneSnapshot { cwd }); + let label = tab.panes.get(id).and_then(|pane| pane.manual_label.clone()); + panes.insert(id.raw(), PaneSnapshot { cwd, label }); } TabSnapshot { custom_name: tab.custom_name.clone(), @@ -381,12 +384,14 @@ mod tests { 0, PaneSnapshot { cwd: PathBuf::from("/home/can/Projects/herdr"), + label: None, }, ); panes.insert( 1, PaneSnapshot { cwd: PathBuf::from("/home/can/Projects/website"), + label: Some("website".into()), }, ); @@ -434,6 +439,10 @@ mod tests { PathBuf::from("/home/can/Projects/herdr") ); assert_eq!( + restored.workspaces[0].tabs[0].panes[&1].label.as_deref(), + Some("website") + ); + assert_eq!( restored.agent_panel_scope, AgentPanelScope::CurrentWorkspace ); @@ -691,6 +700,7 @@ mod tests { 0, PaneSnapshot { cwd: PathBuf::from("/tmp/this-directory-does-not-exist-for-herdr-test"), + label: None, }, ); panes.insert( @@ -699,6 +709,7 @@ mod tests { cwd: std::env::var("HOME") .map(PathBuf::from) .unwrap_or_else(|_| PathBuf::from("/tmp")), + label: None, }, ); @@ -283,7 +283,9 @@ pub fn render(app: &AppState, frame: &mut Frame) { render_context_menu(app, frame); } Mode::Settings => render_settings_overlay(app, frame, frame.area()), - Mode::RenameWorkspace | Mode::RenameTab => render_rename_overlay(app, frame, frame.area()), + Mode::RenameWorkspace | Mode::RenameTab | Mode::RenamePane => { + render_rename_overlay(app, frame, frame.area()) + } Mode::GlobalMenu => render_global_launcher_menu(app, frame), Mode::KeybindHelp => render_keybind_help_overlay(app, frame), Mode::Terminal => {} diff --git a/src/ui/dialogs.rs b/src/ui/dialogs.rs index 78f1d1cb..3c481a97 100644 --- a/src/ui/dialogs.rs +++ b/src/ui/dialogs.rs @@ -42,6 +42,7 @@ pub(super) fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rec Mode::RenameWorkspace => "rename workspace", Mode::RenameTab if app.creating_new_tab => "new tab", Mode::RenameTab => "rename tab", + Mode::RenamePane => "rename pane", _ => return, }; diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs index 2bb05c50..fdc600c4 100644 --- a/src/ui/keybind_help.rs +++ b/src/ui/keybind_help.rs @@ -82,6 +82,7 @@ pub(super) fn keybind_help_groups( (kb.split_vertical_label.clone(), "split vertical"), (kb.split_horizontal_label.clone(), "split horizontal"), (kb.close_pane_label.clone(), "close pane"), + (optional_keybind_label(&kb.rename_pane_label), "rename pane"), (kb.fullscreen_label.clone(), "fullscreen"), (kb.resize_mode_label.clone(), "resize mode"), (kb.toggle_sidebar_label.clone(), "toggle sidebar"), diff --git a/src/ui/panes.rs b/src/ui/panes.rs index 818ee9fd..72a5c30c 100644 --- a/src/ui/panes.rs +++ b/src/ui/panes.rs @@ -18,6 +18,30 @@ pub(crate) fn pane_is_scrolled_back(rt: &PaneRuntime) -> bool { .is_some_and(|metrics| metrics.offset_from_bottom > 0) } +fn truncate_label(text: &str, max_width: usize) -> String { + let len = text.chars().count(); + if len <= max_width { + return text.to_string(); + } + if max_width == 0 { + return String::new(); + } + if max_width == 1 { + return "…".to_string(); + } + let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect(); + format!("{prefix}…") +} + +fn pane_border_title(label: &str, pane_width: u16) -> Option<String> { + let label = label.trim(); + if label.is_empty() || pane_width <= 4 { + return None; + } + let max_label_width = pane_width.saturating_sub(4) as usize; + Some(format!(" {} ", truncate_label(label, max_label_width))) +} + fn stable_terminal_inner_rect(pane_inner: Rect) -> Rect { if pane_inner.width <= 4 { return pane_inner; @@ -174,10 +198,17 @@ pub(super) fn render_panes(app: &AppState, frame: &mut Frame, area: Rect) { ) }; - let block = Block::default() + let mut block = Block::default() .borders(Borders::ALL) .border_style(border_style) .border_set(border_set); + if let Some(title) = ws + .pane_state(info.id) + .and_then(|pane| pane.border_label(app.show_agent_labels_on_pane_borders)) + .and_then(|label| pane_border_title(label, info.rect.width)) + { + block = block.title(Line::from(Span::styled(title, border_style))); + } frame.render_widget(block, info.rect); } @@ -303,6 +334,17 @@ mod tests { use crate::pane::PaneRuntime; use crate::workspace::Workspace; + #[test] + fn pane_border_title_trims_and_truncates() { + assert_eq!( + pane_border_title(" claude ", 20).as_deref(), + Some(" claude ") + ); + assert_eq!(pane_border_title("", 20), None); + assert_eq!(pane_border_title("abcdef", 8).as_deref(), Some(" abc… ")); + assert_eq!(pane_border_title("abcdef", 4), None); + } + #[tokio::test] async fn pane_scrollbar_gutter_is_reserved_before_scrollback_exists() { let mut app = AppState::test_new(); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 5269f380..ef80d85a 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -106,6 +106,17 @@ pub(super) fn render_settings_overlay(app: &AppState, frame: &mut Frame, area: R 2, ); } + SettingsSection::PaneLabels => { + render_settings_toggle( + frame, + content_area, + p, + "agent border labels", + "show detected agent names in split pane borders", + app.agent_border_labels_enabled(), + app.settings.list.selected, + ); + } } if let Some(footer_area) = stack.footer { diff --git a/src/ui/sidebar.rs b/src/ui/sidebar.rs index 83ceebe0..8a104dba 100644 --- a/src/ui/sidebar.rs +++ b/src/ui/sidebar.rs @@ -70,6 +70,7 @@ fn agent_panel_current_workspace_idx(app: &AppState) -> Option<usize> { app.mode, Mode::Navigate | Mode::RenameWorkspace + | Mode::RenamePane | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu diff --git a/src/workspace.rs b/src/workspace.rs index e0513f65..54f94e38 100644 --- a/src/workspace.rs +++ b/src/workspace.rs @@ -408,6 +408,12 @@ impl Workspace { self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id)) } + pub fn pane_state_mut(&mut self, pane_id: PaneId) -> Option<&mut PaneState> { + self.tabs + .iter_mut() + .find_map(|tab| tab.panes.get_mut(&pane_id)) + } + pub fn runtime(&self, pane_id: PaneId) -> Option<&PaneRuntime> { self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id)) } |
