mirror of
https://github.com/herdrdev/herdr.git
synced 2026-09-22 00:01:06 +00:00
feat: add optional pane border labels
This commit is contained in:
@@ -98,6 +98,7 @@ new_tab = "c"
|
||||
split_vertical = "v"
|
||||
split_horizontal = "-"
|
||||
close_pane = "x"
|
||||
rename_pane = "" # optional, unset by default
|
||||
fullscreen = "f"
|
||||
resize_mode = "r"
|
||||
toggle_sidebar = "b"
|
||||
@@ -135,6 +136,7 @@ focus_pane_right = "alt+l"
|
||||
| `split_vertical` | `v` | split pane vertically (side by side) |
|
||||
| `split_horizontal` | `-` | split pane horizontally (stacked) |
|
||||
| `close_pane` | `x` | close focused pane |
|
||||
| `rename_pane` | unset | rename the focused pane |
|
||||
| `fullscreen` | `f` | toggle focused pane fullscreen |
|
||||
| `resize_mode` | `r` | enter or leave resize mode |
|
||||
| `toggle_sidebar` | `b` | collapse or expand the sidebar |
|
||||
@@ -251,6 +253,7 @@ for `panel_bg`, you can also use `reset`, `default`, `none`, or `transparent` to
|
||||
[ui]
|
||||
sidebar_width = 26
|
||||
confirm_close = true
|
||||
show_agent_labels_on_pane_borders = false
|
||||
agent_panel_scope = "all"
|
||||
accent = "cyan"
|
||||
```
|
||||
@@ -261,6 +264,7 @@ accent = "cyan"
|
||||
|--------|---------|-------------|
|
||||
| `sidebar_width` | `26` | base sidebar width before auto-scaling |
|
||||
| `confirm_close` | `true` | ask before closing a workspace |
|
||||
| `show_agent_labels_on_pane_borders` | `false` | show detected/reported agent labels in split pane borders when no manual pane name is set |
|
||||
| `agent_panel_scope` | `all` | sidebar agent list scope: `current` or `all` |
|
||||
| `accent` | `cyan` | highlight and border color |
|
||||
|
||||
|
||||
@@ -145,12 +145,15 @@ for backward compatibility, requests also accept the older positional forms like
|
||||
"tab_id": "w64e95948145ed1:1",
|
||||
"focused": true,
|
||||
"cwd": "/home/can/Projects/herdr",
|
||||
"label": "reviewer",
|
||||
"agent": "pi",
|
||||
"agent_status": "working",
|
||||
"revision": 0
|
||||
}
|
||||
```
|
||||
|
||||
`label` is an optional manual pane name set through `pane.rename`.
|
||||
|
||||
`agent` is an optional display label string.
|
||||
|
||||
- when herdr detects a built-in agent, this is that built-in name like `pi` or `claude`
|
||||
@@ -202,6 +205,7 @@ for backward compatibility, requests also accept the older positional forms like
|
||||
| `tab.close` | close a tab | `ok` |
|
||||
| `pane.list` | list panes, optionally filtered by workspace | `pane_list` |
|
||||
| `pane.get` | inspect one pane | `pane_info` |
|
||||
| `pane.rename` | set or clear a manual pane label | `pane_info` |
|
||||
| `pane.read` | read pane output | `pane_read` |
|
||||
| `pane.split` | split a pane and create a sibling pane | `pane_info` |
|
||||
| `pane.send_text` | send literal text without Enter | `ok` |
|
||||
@@ -481,6 +485,21 @@ params:
|
||||
|
||||
returns `pane_info`.
|
||||
|
||||
### `pane.rename`
|
||||
|
||||
params:
|
||||
|
||||
```json
|
||||
{
|
||||
"pane_id": "1-1",
|
||||
"label": "reviewer"
|
||||
}
|
||||
```
|
||||
|
||||
send `label: null` or omit `label` to clear the manual pane label.
|
||||
|
||||
returns `pane_info`.
|
||||
|
||||
### `pane.read`
|
||||
|
||||
params:
|
||||
@@ -946,6 +965,7 @@ pane commands:
|
||||
```text
|
||||
herdr pane list [--workspace <workspace_id>]
|
||||
herdr pane get <pane_id>
|
||||
herdr pane rename <pane_id> <label>|--clear
|
||||
herdr pane read <pane_id> [--source visible|recent|recent-unwrapped] [--lines N] [--format text|ansi] [--ansi]
|
||||
herdr pane split <pane_id> --direction right|down [--cwd PATH] [--focus] [--no-focus]
|
||||
herdr pane close <pane_id>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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")]
|
||||
@@ -160,6 +162,13 @@ pub struct PaneListParams {
|
||||
pub workspace_id: Option<String>,
|
||||
}
|
||||
|
||||
#[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,
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
+49
-4
@@ -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
|
||||
|
||||
+14
-2
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -156,6 +160,30 @@ pub(super) fn update_settings_state(state: &mut AppState, key: KeyEvent) -> Opti
|
||||
state.settings.section = SettingsSection::Sound;
|
||||
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))
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
+53
-1
@@ -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();
|
||||
|
||||
+41
-5
@@ -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,
|
||||
|
||||
+30
-4
@@ -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]"
|
||||
|
||||
+18
-8
@@ -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)>,
|
||||
@@ -379,6 +381,12 @@ impl Config {
|
||||
&self.keys.close_tab,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::Navigate,
|
||||
"keys.rename_pane",
|
||||
&self.keys.rename_pane,
|
||||
&mut diagnostics,
|
||||
),
|
||||
optional_binding(
|
||||
BindingScope::TerminalDirect,
|
||||
"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,
|
||||
|
||||
@@ -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(),
|
||||
@@ -241,6 +247,19 @@ agent_panel_scope = "all"
|
||||
assert_eq!(config.ui.agent_panel_scope, AgentPanelScopeConfig::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#"
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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>,
|
||||
@@ -338,6 +357,26 @@ mod tests {
|
||||
assert_eq!(pane.state, AgentState::Working);
|
||||
}
|
||||
|
||||
#[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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+12
-1
@@ -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()),
|
||||
},
|
||||
);
|
||||
|
||||
@@ -433,6 +438,10 @@ mod tests {
|
||||
restored.workspaces[0].tabs[0].panes[&0].cwd,
|
||||
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 => {}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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"),
|
||||
|
||||
+43
-1
@@ -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();
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user