aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs17
-rw-r--r--src/app/input.rs321
-rw-r--r--src/app/mod.rs19
-rw-r--r--src/app/state.rs19
-rw-r--r--src/config.rs127
-rw-r--r--src/main.rs7
-rw-r--r--src/persist.rs31
-rw-r--r--src/ui.rs352
-rw-r--r--src/workspace.rs179
9 files changed, 703 insertions, 369 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 787f6869..7c323da5 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -46,7 +46,7 @@ impl AppState {
if self.workspaces.is_empty() {
return;
}
- let name = self.workspaces[self.selected].name.clone();
+ let name = self.workspaces[self.selected].display_name();
info!(workspace = %name, "workspace closed");
self.workspaces.remove(self.selected);
if self.workspaces.is_empty() {
@@ -236,16 +236,7 @@ impl AppState {
}
}
} else {
- if ws.layout.focused() == pane_id {
- ws.layout.close_focused();
- } else {
- let prev_focus = ws.layout.focused();
- ws.layout.focus_pane(pane_id);
- ws.layout.close_focused();
- ws.layout.focus_pane(prev_focus);
- }
- ws.panes.remove(&pane_id);
- ws.runtimes.remove(&pane_id);
+ ws.remove_pane(pane_id);
}
}
}
@@ -311,7 +302,7 @@ mod tests {
assert_eq!(state.workspaces.len(), 2);
assert_eq!(state.selected, 1);
assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].name, "c");
+ assert_eq!(state.workspaces[1].custom_name.as_deref(), Some("c"));
}
#[test]
@@ -346,7 +337,7 @@ mod tests {
state.handle_pane_died(pane_id);
assert_eq!(state.workspaces.len(), 1);
- assert_eq!(state.workspaces[0].name, "b");
+ assert_eq!(state.workspaces[0].custom_name.as_deref(), Some("b"));
}
#[test]
diff --git a/src/app/input.rs b/src/app/input.rs
index 254b7916..7e7f5877 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -20,7 +20,6 @@ impl App {
match self.state.mode {
Mode::Navigate => handle_navigate_key(&mut self.state, key),
Mode::Terminal => self.handle_terminal_key(key).await,
- Mode::CreateSession => handle_create_key(self, key),
Mode::RenameSession => handle_rename_key(&mut self.state, key),
Mode::Resize => handle_resize_key(&mut self.state, key),
Mode::ConfirmClose => handle_confirm_close_key(&mut self.state, key),
@@ -78,55 +77,28 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
state.update_dismissed = true;
if state.is_prefix(&key) || key.code == KeyCode::Esc {
- if state.active.is_some() {
- state.mode = Mode::Terminal;
- }
+ leave_navigate_mode(state);
return;
}
- // Configurable keybinds (checked before fixed binds)
- let kb = &state.keybinds;
- if key_matches(&key, kb.split_vertical.0, kb.split_vertical.1) {
- state.split_pane(Direction::Horizontal);
- return;
- }
- if key_matches(&key, kb.split_horizontal.0, kb.split_horizontal.1) {
- state.split_pane(Direction::Vertical);
- return;
- }
- if key_matches(&key, kb.close_pane.0, kb.close_pane.1) {
- state.close_pane();
- return;
- }
- if key_matches(&key, kb.fullscreen.0, kb.fullscreen.1) {
- state.toggle_fullscreen();
+ if let Some(action) = navigate_action_for_key(state, &key) {
+ execute_navigate_action(state, action);
return;
}
match key.code {
KeyCode::Char('q') => state.should_quit = true,
- KeyCode::Char('n') => {
- state.mode = Mode::CreateSession;
- state.name_input.clear();
- }
- KeyCode::Char('N') => {
+ KeyCode::Enter => {
if !state.workspaces.is_empty() {
- state.name_input = state.workspaces[state.selected].name.clone();
- state.mode = Mode::RenameSession;
+ state.switch_workspace(state.selected);
+ leave_navigate_mode(state);
}
}
- KeyCode::Char('d') => {
- if !state.workspaces.is_empty() {
- if state.confirm_close {
- state.mode = Mode::ConfirmClose;
- } else {
- state.close_selected_workspace();
- if state.workspaces.is_empty() {
- state.mode = Mode::Navigate;
- } else {
- state.mode = Mode::Terminal;
- }
- }
+ KeyCode::Char(c @ '1'..='9') => {
+ let idx = (c as usize) - ('1' as usize);
+ if idx < state.workspaces.len() {
+ state.switch_workspace(idx);
+ leave_navigate_mode(state);
}
}
KeyCode::Up => {
@@ -139,53 +111,110 @@ fn handle_navigate_key(state: &mut AppState, key: KeyEvent) {
state.selected += 1;
}
}
- KeyCode::Enter => {
- if !state.workspaces.is_empty() {
- state.switch_workspace(state.selected);
- state.mode = Mode::Terminal;
- }
- }
KeyCode::Char('h') | KeyCode::Left => state.navigate_pane(NavDirection::Left),
KeyCode::Char('j') => state.navigate_pane(NavDirection::Down),
KeyCode::Char('k') => state.navigate_pane(NavDirection::Up),
KeyCode::Char('l') | KeyCode::Right => state.navigate_pane(NavDirection::Right),
KeyCode::Tab => state.cycle_pane(false),
KeyCode::BackTab => state.cycle_pane(true),
- KeyCode::Char('r') => state.mode = Mode::Resize,
- KeyCode::Char('b') => state.sidebar_collapsed = !state.sidebar_collapsed,
- KeyCode::Char(c @ '1'..='9') => {
- let idx = (c as usize) - ('1' as usize);
- if idx < state.workspaces.len() {
- state.switch_workspace(idx);
- state.mode = Mode::Terminal;
- }
- }
_ => {}
}
}
-fn handle_create_key(app: &mut App, key: KeyEvent) {
- match key.code {
- KeyCode::Enter => {
- let name = if app.state.name_input.trim().is_empty() {
- format!("workspace-{}", app.state.workspaces.len() + 1)
- } else {
- app.state.name_input.trim().to_string()
- };
- app.state.name_input.clear();
- app.create_workspace(name);
+#[derive(Debug, Clone, Copy)]
+enum NavigateAction {
+ NewWorkspace,
+ RenameWorkspace,
+ CloseWorkspace,
+ SplitVertical,
+ SplitHorizontal,
+ ClosePane,
+ Fullscreen,
+ EnterResizeMode,
+ ToggleSidebar,
+}
+
+fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+ let kb = &state.keybinds;
+ if key_matches(key, kb.new_workspace.0, kb.new_workspace.1) {
+ return Some(NavigateAction::NewWorkspace);
+ }
+ if key_matches(key, kb.rename_workspace.0, kb.rename_workspace.1) {
+ return Some(NavigateAction::RenameWorkspace);
+ }
+ if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
+ return Some(NavigateAction::CloseWorkspace);
+ }
+ if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
+ return Some(NavigateAction::SplitVertical);
+ }
+ if key_matches(key, kb.split_horizontal.0, kb.split_horizontal.1) {
+ return Some(NavigateAction::SplitHorizontal);
+ }
+ if key_matches(key, kb.close_pane.0, kb.close_pane.1) {
+ return Some(NavigateAction::ClosePane);
+ }
+ if key_matches(key, kb.fullscreen.0, kb.fullscreen.1) {
+ return Some(NavigateAction::Fullscreen);
+ }
+ if key_matches(key, kb.resize_mode.0, kb.resize_mode.1) {
+ return Some(NavigateAction::EnterResizeMode);
+ }
+ if key_matches(key, kb.toggle_sidebar.0, kb.toggle_sidebar.1) {
+ return Some(NavigateAction::ToggleSidebar);
+ }
+ None
+}
+
+fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
+ match action {
+ NavigateAction::NewWorkspace => {
+ state.request_new_workspace = true;
+ leave_navigate_mode(state);
}
- KeyCode::Esc => {
- app.state.name_input.clear();
- app.state.mode = Mode::Navigate;
+ NavigateAction::RenameWorkspace => {
+ if !state.workspaces.is_empty() {
+ state.name_input = state.workspaces[state.selected].display_name();
+ state.mode = Mode::RenameSession;
+ }
}
- KeyCode::Backspace => {
- app.state.name_input.pop();
+ NavigateAction::CloseWorkspace => {
+ if !state.workspaces.is_empty() {
+ if state.confirm_close {
+ state.mode = Mode::ConfirmClose;
+ } else {
+ state.close_selected_workspace();
+ leave_navigate_mode(state);
+ }
+ }
}
- KeyCode::Char(c) => {
- app.state.name_input.push(c);
+ NavigateAction::SplitVertical => {
+ state.split_pane(Direction::Horizontal);
+ leave_navigate_mode(state);
}
- _ => {}
+ NavigateAction::SplitHorizontal => {
+ state.split_pane(Direction::Vertical);
+ leave_navigate_mode(state);
+ }
+ NavigateAction::ClosePane => {
+ state.close_pane();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::Fullscreen => {
+ state.toggle_fullscreen();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::EnterResizeMode => state.mode = Mode::Resize,
+ NavigateAction::ToggleSidebar => {
+ state.sidebar_collapsed = !state.sidebar_collapsed;
+ leave_navigate_mode(state);
+ }
+ }
+}
+
+fn leave_navigate_mode(state: &mut AppState) {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
}
}
@@ -198,7 +227,7 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
state.name_input.trim().to_string()
};
if !new_name.is_empty() && !state.workspaces.is_empty() {
- state.workspaces[state.selected].name = new_name;
+ state.workspaces[state.selected].set_custom_name(new_name);
}
state.name_input.clear();
state.mode = Mode::Navigate;
@@ -218,14 +247,19 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
}
fn handle_resize_key(state: &mut AppState, key: KeyEvent) {
- match key.code {
- KeyCode::Esc | KeyCode::Enter | KeyCode::Char('r') => {
- if state.active.is_some() {
- state.mode = Mode::Terminal;
- } else {
- state.mode = Mode::Navigate;
- }
+ if key.code == KeyCode::Esc
+ || key.code == KeyCode::Enter
+ || key_matches(&key, state.keybinds.resize_mode.0, state.keybinds.resize_mode.1)
+ {
+ if state.active.is_some() {
+ state.mode = Mode::Terminal;
+ } else {
+ state.mode = Mode::Navigate;
}
+ return;
+ }
+
+ match key.code {
KeyCode::Char('h') | KeyCode::Left => state.resize_pane(NavDirection::Left),
KeyCode::Char('l') | KeyCode::Right => state.resize_pane(NavDirection::Right),
KeyCode::Char('j') | KeyCode::Down => state.resize_pane(NavDirection::Down),
@@ -275,7 +309,7 @@ fn handle_context_menu_key(state: &mut AppState, key: KeyEvent) {
match CONTEXT_MENU_ITEMS[menu.selected] {
"Rename" => {
state.selected = menu.ws_idx;
- state.name_input = state.workspaces[menu.ws_idx].name.clone();
+ state.name_input = state.workspaces[menu.ws_idx].display_name();
state.mode = Mode::RenameSession;
}
"Close" => {
@@ -320,7 +354,7 @@ impl AppState {
match CONTEXT_MENU_ITEMS[idx] {
"Rename" => {
self.selected = ws_idx;
- self.name_input = self.workspaces[ws_idx].name.clone();
+ self.name_input = self.workspaces[ws_idx].display_name();
self.mode = Mode::RenameSession;
}
"Close" => {
@@ -355,14 +389,21 @@ impl AppState {
if in_sidebar {
let bottom_y = sidebar.y + sidebar.height.saturating_sub(1);
+ let new_row_y = bottom_y.saturating_sub(1);
+
if mouse.row == bottom_y {
self.sidebar_collapsed = !self.sidebar_collapsed;
return;
}
- let inner_y = sidebar.y + 1;
- if mouse.row >= inner_y {
- let idx = (mouse.row - inner_y) as usize;
+ if !self.sidebar_collapsed && mouse.row == new_row_y {
+ self.request_new_workspace = true;
+ return;
+ }
+
+ let inner_y = sidebar.y;
+ if mouse.row >= inner_y && mouse.row < new_row_y {
+ let idx = ((mouse.row - inner_y) / 2) as usize;
if idx < self.workspaces.len() {
self.switch_workspace(idx);
self.mode = Mode::Terminal;
@@ -467,9 +508,11 @@ impl AppState {
}
MouseEventKind::Down(MouseButton::Right) if in_sidebar => {
- let inner_y = sidebar.y + 1;
- if mouse.row >= inner_y {
- let idx = (mouse.row - inner_y) as usize;
+ let inner_y = sidebar.y;
+ let bottom_y = sidebar.y + sidebar.height.saturating_sub(1);
+ let new_row_y = bottom_y.saturating_sub(1);
+ if mouse.row >= inner_y && mouse.row < new_row_y {
+ let idx = ((mouse.row - inner_y) / 2) as usize;
if idx < self.workspaces.len() {
self.selected = idx;
self.context_menu = Some(ContextMenuState {
@@ -552,3 +595,105 @@ impl AppState {
}
}
}
+
+#[cfg(test)]
+mod tests {
+ use super::*;
+ use crossterm::event::KeyModifiers;
+ use crate::workspace::Workspace;
+
+ fn state_with_workspaces(names: &[&str]) -> AppState {
+ let mut state = AppState::test_new();
+ state.workspaces = names.iter().map(|name| Workspace::test_new(name)).collect();
+ if !state.workspaces.is_empty() {
+ state.active = Some(0);
+ state.selected = 0;
+ state.mode = Mode::Navigate;
+ }
+ state
+ }
+
+ #[test]
+ fn custom_rename_key_enters_rename_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.rename_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.rename_workspace_label = "g".into();
+
+ handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()));
+
+ assert_eq!(state.mode, Mode::RenameSession);
+ assert_eq!(state.name_input, "test");
+ }
+
+ #[test]
+ fn custom_new_workspace_key_requests_and_exits_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.new_workspace = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.new_workspace_label = "g".into();
+
+ handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()));
+
+ assert!(state.request_new_workspace);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn custom_sidebar_toggle_key_toggles_and_exits_navigate() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.toggle_sidebar = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.toggle_sidebar_label = "g".into();
+ assert!(!state.sidebar_collapsed);
+
+ handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()));
+
+ assert!(state.sidebar_collapsed);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn custom_resize_key_enters_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.resize_mode_label = "g".into();
+
+ handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()));
+
+ assert_eq!(state.mode, Mode::Resize);
+ }
+
+ #[test]
+ fn movement_action_stays_in_navigate_mode() {
+ let mut state = state_with_workspaces(&["a", "b"]);
+ state.selected = 0;
+
+ handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Down, KeyModifiers::empty()));
+
+ assert_eq!(state.selected, 1);
+ assert_eq!(state.mode, Mode::Navigate);
+ }
+
+ #[test]
+ fn fullscreen_action_exits_navigate_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.workspaces[0].test_split(Direction::Horizontal);
+ state.keybinds.fullscreen = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.fullscreen_label = "g".into();
+
+ handle_navigate_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()));
+
+ assert!(state.workspaces[0].zoomed);
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+
+ #[test]
+ fn custom_resize_key_exits_resize_mode() {
+ let mut state = state_with_workspaces(&["test"]);
+ state.mode = Mode::Resize;
+ state.keybinds.resize_mode = (KeyCode::Char('g'), KeyModifiers::empty());
+ state.keybinds.resize_mode_label = "g".into();
+
+ handle_resize_key(&mut state, KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()));
+
+ assert_eq!(state.mode, Mode::Terminal);
+ }
+}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index b3fab4a9..ad704460 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -66,6 +66,7 @@ impl App {
selected,
mode,
should_quit: false,
+ request_new_workspace: false,
name_input: String::new(),
view: state::ViewState {
sidebar_rect: Rect::default(),
@@ -80,7 +81,6 @@ impl App {
update_dismissed: false,
prefix_code,
prefix_mods,
- prefix_label: config.prefix_label(),
sidebar_width: config.ui.sidebar_width,
sidebar_collapsed: false,
confirm_close: config.ui.confirm_close,
@@ -126,6 +126,11 @@ impl App {
_ => {}
}
}
+
+ if self.state.request_new_workspace {
+ self.state.request_new_workspace = false;
+ self.create_workspace();
+ }
}
// Save session on exit (skip in --no-session mode)
@@ -142,9 +147,17 @@ impl App {
}
/// Create a workspace with a real PTY (needs event_tx).
- fn create_workspace(&mut self, name: String) {
+ fn create_workspace(&mut self) {
let (rows, cols) = self.state.estimate_pane_size();
- match Workspace::new(name, rows, cols, self.event_tx.clone()) {
+ let initial_cwd = self
+ .state
+ .active
+ .and_then(|i| self.state.workspaces.get(i))
+ .and_then(|ws| ws.focused_runtime())
+ .and_then(|rt| rt.cwd())
+ .or_else(|| std::env::current_dir().ok())
+ .unwrap_or_else(|| std::path::PathBuf::from("/"));
+ match Workspace::new(initial_cwd, rows, cols, self.event_tx.clone()) {
Ok(ws) => {
self.state.workspaces.push(ws);
let idx = self.state.workspaces.len() - 1;
diff --git a/src/app/state.rs b/src/app/state.rs
index 7a577bb8..140a1513 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -20,7 +20,6 @@ pub struct ViewState {
pub enum Mode {
Navigate,
Terminal,
- CreateSession,
RenameSession,
Resize,
ConfirmClose,
@@ -52,6 +51,7 @@ pub struct AppState {
pub selected: usize,
pub mode: Mode,
pub should_quit: bool,
+ pub request_new_workspace: bool,
pub name_input: String,
// View geometry (computed before render, consumed by render + mouse)
pub view: ViewState,
@@ -64,7 +64,6 @@ pub struct AppState {
// Config
pub prefix_code: KeyCode,
pub prefix_mods: KeyModifiers,
- pub prefix_label: String,
pub sidebar_width: u16,
pub sidebar_collapsed: bool,
pub confirm_close: bool,
@@ -120,6 +119,7 @@ impl AppState {
selected: 0,
mode: Mode::Navigate,
should_quit: false,
+ request_new_workspace: false,
name_input: String::new(),
view: ViewState {
sidebar_rect: Rect::default(),
@@ -134,7 +134,6 @@ impl AppState {
update_dismissed: false,
prefix_code: KeyCode::Char('b'),
prefix_mods: KeyModifiers::CONTROL,
- prefix_label: "ctrl+b".into(),
sidebar_width: 26,
sidebar_collapsed: false,
confirm_close: true,
@@ -144,10 +143,24 @@ impl AppState {
..SoundConfig::default()
},
keybinds: Keybinds {
+ new_workspace: (KeyCode::Char('n'), KeyModifiers::empty()),
+ new_workspace_label: "n".into(),
+ rename_workspace: (KeyCode::Char('n'), KeyModifiers::SHIFT),
+ rename_workspace_label: "shift+n".into(),
+ close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()),
+ close_workspace_label: "d".into(),
split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()),
+ split_vertical_label: "v".into(),
split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()),
+ split_horizontal_label: "-".into(),
close_pane: (KeyCode::Char('x'), KeyModifiers::empty()),
+ close_pane_label: "x".into(),
fullscreen: (KeyCode::Char('f'), KeyModifiers::empty()),
+ fullscreen_label: "f".into(),
+ resize_mode: (KeyCode::Char('r'), KeyModifiers::empty()),
+ resize_mode_label: "r".into(),
+ toggle_sidebar: (KeyCode::Char('b'), KeyModifiers::empty()),
+ toggle_sidebar_label: "b".into(),
},
}
}
diff --git a/src/config.rs b/src/config.rs
index 0bd280ca..754ede0a 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -18,6 +18,12 @@ pub struct Config {
pub struct KeysConfig {
/// Prefix key to toggle navigate mode (e.g. "ctrl+b", "f12", "esc").
pub prefix: String,
+ /// Create a new workspace. Default: "n"
+ pub new_workspace: String,
+ /// Rename the selected workspace. Default: "shift+n"
+ pub rename_workspace: String,
+ /// Close the selected workspace. Default: "d"
+ pub close_workspace: String,
/// Split pane vertically (side by side). Default: "v"
pub split_vertical: String,
/// Split pane horizontally (stacked). Default: "-"
@@ -26,6 +32,10 @@ pub struct KeysConfig {
pub close_pane: String,
/// Toggle fullscreen for the focused pane. Default: "f"
pub fullscreen: String,
+ /// Enter resize mode. Default: "r"
+ pub resize_mode: String,
+ /// Toggle sidebar collapse. Default: "b"
+ pub toggle_sidebar: String,
}
#[derive(Debug, Deserialize)]
@@ -106,10 +116,15 @@ impl Default for KeysConfig {
fn default() -> Self {
Self {
prefix: "ctrl+b".into(),
+ new_workspace: "n".into(),
+ rename_workspace: "shift+n".into(),
+ close_workspace: "d".into(),
split_vertical: "v".into(),
split_horizontal: "-".into(),
close_pane: "x".into(),
fullscreen: "f".into(),
+ resize_mode: "r".into(),
+ toggle_sidebar: "b".into(),
}
}
}
@@ -175,38 +190,63 @@ impl Config {
)
}
- /// Human-readable label for the prefix key (shown in status bar).
- pub fn prefix_label(&self) -> String {
- if parse_key_combo(&self.keys.prefix).is_some() {
- self.keys.prefix.clone()
- } else {
- "ctrl+b".into()
- }
- }
-
/// Parsed keybinds for navigate mode actions.
pub fn keybinds(&self) -> Keybinds {
Keybinds {
+ new_workspace: parse_key_combo_or_warn(
+ &self.keys.new_workspace,
+ "keys.new_workspace",
+ (KeyCode::Char('n'), KeyModifiers::empty()),
+ ),
+ new_workspace_label: self.keys.new_workspace.clone(),
+ rename_workspace: parse_key_combo_or_warn(
+ &self.keys.rename_workspace,
+ "keys.rename_workspace",
+ (KeyCode::Char('n'), KeyModifiers::SHIFT),
+ ),
+ rename_workspace_label: self.keys.rename_workspace.clone(),
+ close_workspace: parse_key_combo_or_warn(
+ &self.keys.close_workspace,
+ "keys.close_workspace",
+ (KeyCode::Char('d'), KeyModifiers::empty()),
+ ),
+ close_workspace_label: self.keys.close_workspace.clone(),
split_vertical: parse_key_combo_or_warn(
&self.keys.split_vertical,
"keys.split_vertical",
(KeyCode::Char('v'), KeyModifiers::empty()),
),
+ split_vertical_label: self.keys.split_vertical.clone(),
split_horizontal: parse_key_combo_or_warn(
&self.keys.split_horizontal,
"keys.split_horizontal",
(KeyCode::Char('-'), KeyModifiers::empty()),
),
+ split_horizontal_label: self.keys.split_horizontal.clone(),
close_pane: parse_key_combo_or_warn(
&self.keys.close_pane,
"keys.close_pane",
(KeyCode::Char('x'), KeyModifiers::empty()),
),
+ close_pane_label: self.keys.close_pane.clone(),
fullscreen: parse_key_combo_or_warn(
&self.keys.fullscreen,
"keys.fullscreen",
(KeyCode::Char('f'), KeyModifiers::empty()),
),
+ fullscreen_label: self.keys.fullscreen.clone(),
+ resize_mode: parse_key_combo_or_warn(
+ &self.keys.resize_mode,
+ "keys.resize_mode",
+ (KeyCode::Char('r'), KeyModifiers::empty()),
+ ),
+ resize_mode_label: self.keys.resize_mode.clone(),
+ toggle_sidebar: parse_key_combo_or_warn(
+ &self.keys.toggle_sidebar,
+ "keys.toggle_sidebar",
+ (KeyCode::Char('b'), KeyModifiers::empty()),
+ ),
+ toggle_sidebar_label: self.keys.toggle_sidebar.clone(),
}
}
}
@@ -214,10 +254,24 @@ impl Config {
/// Parsed keybinds for navigate mode actions.
#[derive(Debug, Clone)]
pub struct Keybinds {
+ pub new_workspace: (KeyCode, KeyModifiers),
+ pub new_workspace_label: String,
+ pub rename_workspace: (KeyCode, KeyModifiers),
+ pub rename_workspace_label: String,
+ pub close_workspace: (KeyCode, KeyModifiers),
+ pub close_workspace_label: String,
pub split_vertical: (KeyCode, KeyModifiers),
+ pub split_vertical_label: String,
pub split_horizontal: (KeyCode, KeyModifiers),
+ pub split_horizontal_label: String,
pub close_pane: (KeyCode, KeyModifiers),
+ pub close_pane_label: String,
pub fullscreen: (KeyCode, KeyModifiers),
+ pub fullscreen_label: String,
+ pub resize_mode: (KeyCode, KeyModifiers),
+ pub resize_mode_label: String,
+ pub toggle_sidebar: (KeyCode, KeyModifiers),
+ pub toggle_sidebar_label: String,
}
/// Parse a color string into a ratatui Color.
@@ -319,13 +373,22 @@ fn parse_key_combo(s: &str) -> Option<(KeyCode, KeyModifiers)> {
let key_str = key_str?;
- let code = match key_str.to_lowercase().as_str() {
+ let lower = key_str.to_lowercase();
+ let code = match lower.as_str() {
"space" | " " => KeyCode::Char(' '),
"enter" | "return" => KeyCode::Enter,
"esc" | "escape" => KeyCode::Esc,
"tab" => KeyCode::Tab,
"backspace" | "bs" => KeyCode::Backspace,
- s if s.len() == 1 => KeyCode::Char(key_str.chars().next().unwrap()),
+ s if s.len() == 1 => {
+ let ch = key_str.chars().next().unwrap();
+ if ch.is_ascii_uppercase() {
+ modifiers |= KeyModifiers::SHIFT;
+ KeyCode::Char(ch.to_ascii_lowercase())
+ } else {
+ KeyCode::Char(ch)
+ }
+ }
s if s.starts_with('f') => s[1..].parse::<u8>().ok().map(KeyCode::F)?,
_ => return None,
};
@@ -409,6 +472,22 @@ mod tests {
}
#[test]
+ fn uppercase_char_implies_shift() {
+ assert_eq!(
+ parse_key_combo("D"),
+ Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ );
+ }
+
+ #[test]
+ fn explicit_shift_and_uppercase_do_not_double_apply_shift() {
+ assert_eq!(
+ parse_key_combo("shift+D"),
+ Some((KeyCode::Char('d'), KeyModifiers::SHIFT))
+ );
+ }
+
+ #[test]
fn invalid_keybinding_is_rejected() {
assert_eq!(parse_key_combo("ctrl+foo+bar"), None);
assert_eq!(parse_key_combo("ctrl+"), None);
@@ -418,10 +497,15 @@ mod tests {
fn default_keybinds_parse() {
let config = Config::default();
let kb = config.keybinds();
+ assert_eq!(kb.new_workspace.0, KeyCode::Char('n'));
+ assert_eq!(kb.rename_workspace, (KeyCode::Char('n'), KeyModifiers::SHIFT));
+ assert_eq!(kb.close_workspace.0, KeyCode::Char('d'));
assert_eq!(kb.split_vertical.0, KeyCode::Char('v'));
assert_eq!(kb.split_horizontal.0, KeyCode::Char('-'));
assert_eq!(kb.close_pane.0, KeyCode::Char('x'));
assert_eq!(kb.fullscreen.0, KeyCode::Char('f'));
+ assert_eq!(kb.resize_mode.0, KeyCode::Char('r'));
+ assert_eq!(kb.toggle_sidebar.0, KeyCode::Char('b'));
}
#[test]
@@ -429,10 +513,15 @@ mod tests {
let toml = r#"
[keys]
prefix = "ctrl+a"
+new_workspace = "c"
+rename_workspace = "shift+r"
+close_workspace = "ctrl+d"
split_vertical = "s"
split_horizontal = "shift+s"
close_pane = "ctrl+w"
fullscreen = "z"
+resize_mode = "ctrl+r"
+toggle_sidebar = "tab"
"#;
let config: Config = toml::from_str(toml).unwrap();
let (code, mods) = config.prefix_key();
@@ -440,6 +529,9 @@ fullscreen = "z"
assert_eq!(mods, KeyModifiers::CONTROL);
let kb = config.keybinds();
+ assert_eq!(kb.new_workspace, (KeyCode::Char('c'), KeyModifiers::empty()));
+ assert_eq!(kb.rename_workspace, (KeyCode::Char('r'), KeyModifiers::SHIFT));
+ assert_eq!(kb.close_workspace, (KeyCode::Char('d'), KeyModifiers::CONTROL));
assert_eq!(kb.split_vertical.0, KeyCode::Char('s'));
assert_eq!(
kb.split_horizontal,
@@ -447,6 +539,19 @@ fullscreen = "z"
);
assert_eq!(kb.close_pane, (KeyCode::Char('w'), KeyModifiers::CONTROL));
assert_eq!(kb.fullscreen.0, KeyCode::Char('z'));
+ assert_eq!(kb.resize_mode, (KeyCode::Char('r'), KeyModifiers::CONTROL));
+ assert_eq!(kb.toggle_sidebar, (KeyCode::Tab, KeyModifiers::empty()));
+ }
+
+ #[test]
+ fn uppercase_keybind_from_toml_flows_into_shift_combo() {
+ let toml = r#"
+[keys]
+split_horizontal = "D"
+"#;
+ let config: Config = toml::from_str(toml).unwrap();
+ let kb = config.keybinds();
+ assert_eq!(kb.split_horizontal, (KeyCode::Char('d'), KeyModifiers::SHIFT));
}
#[test]
diff --git a/src/main.rs b/src/main.rs
index 8f23287d..93d79b2e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -71,11 +71,16 @@ const DEFAULT_CONFIG: &str = r#"# herdr configuration
# alt+... and punctuation-with-modifiers may depend on your terminal/tmux setup.
# prefix = "ctrl+b"
-# Pane controls (in navigate mode)
+# Navigate-mode actions
+# new_workspace = "n"
+# rename_workspace = "shift+n"
+# close_workspace = "d"
# split_vertical = "v"
# split_horizontal = "-"
# close_pane = "x"
# fullscreen = "f"
+# resize_mode = "r"
+# toggle_sidebar = "b"
[ui]
# Sidebar width (auto-scaled based on workspace names, this sets the default)
diff --git a/src/persist.rs b/src/persist.rs
index 3528dcaf..2660cc8f 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -17,7 +17,7 @@ use crate::pane::{PaneRuntime, PaneState};
use crate::workspace::Workspace;
/// Current snapshot format version.
-const SNAPSHOT_VERSION: u32 = 1;
+const SNAPSHOT_VERSION: u32 = 2;
/// Serializable snapshot of the entire herdr session.
#[derive(Serialize, Deserialize)]
@@ -32,13 +32,17 @@ pub struct SessionSnapshot {
#[derive(Serialize, Deserialize)]
pub struct WorkspaceSnapshot {
- pub name: String,
+ #[serde(default)]
+ pub custom_name: Option<String>,
pub layout: LayoutSnapshot,
pub panes: HashMap<u32, PaneSnapshot>,
pub zoomed: bool,
/// Raw pane ID that was focused when saved.
#[serde(default)]
pub focused: Option<u32>,
+ /// Raw pane ID used as the workspace identity source.
+ #[serde(default)]
+ pub root_pane: Option<u32>,
}
#[derive(Serialize, Deserialize)]
@@ -91,11 +95,12 @@ fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
panes.insert(id.raw(), PaneSnapshot { cwd });
}
WorkspaceSnapshot {
- name: ws.name.clone(),
+ custom_name: ws.custom_name.clone(),
layout: capture_node(ws.layout.root()),
panes,
zoomed: ws.zoomed,
focused: Some(ws.layout.focused().raw()),
+ root_pane: Some(ws.root_pane.raw()),
}
}
@@ -170,14 +175,21 @@ fn restore_workspace(
runtimes.insert(*id, runtime);
}
Err(e) => {
- error!(workspace = %snap.name, err = %e, "failed to restore pane");
+ error!(workspace = ?snap.custom_name, err = %e, "failed to restore pane");
return None;
}
}
}
+ let root_pane = snap
+ .root_pane
+ .and_then(|old_raw| id_map.get(&old_raw).copied())
+ .or_else(|| pane_ids.first().copied())
+ .unwrap_or(PaneId::from_raw(0));
+
Some(Workspace {
- name: snap.name.clone(),
+ custom_name: snap.custom_name.clone(),
+ root_pane,
layout,
panes,
runtimes,
@@ -321,7 +333,7 @@ mod tests {
#[test]
fn round_trip_empty_session() {
let snap = SessionSnapshot {
- version: 1,
+ version: SNAPSHOT_VERSION,
workspaces: vec![],
active: None,
selected: 0,
@@ -373,7 +385,7 @@ mod tests {
let snap = SessionSnapshot {
workspaces: vec![WorkspaceSnapshot {
- name: "pi-mono".to_string(),
+ custom_name: Some("pi-mono".to_string()),
layout: LayoutSnapshot::Split {
direction: DirectionSnapshot::Horizontal,
ratio: 0.5,
@@ -383,17 +395,18 @@ mod tests {
panes,
zoomed: false,
focused: Some(0),
+ root_pane: Some(0),
}],
active: Some(0),
selected: 0,
- version: 1,
+ version: SNAPSHOT_VERSION,
};
let json = serde_json::to_string_pretty(&snap).unwrap();
let restored: SessionSnapshot = serde_json::from_str(&json).unwrap();
assert_eq!(restored.workspaces.len(), 1);
- assert_eq!(restored.workspaces[0].name, "pi-mono");
+ assert_eq!(restored.workspaces[0].custom_name.as_deref(), Some("pi-mono"));
assert_eq!(restored.workspaces[0].panes.len(), 2);
assert_eq!(
restored.workspaces[0].panes[&0].cwd,
diff --git a/src/ui.rs b/src/ui.rs
index 15e5af36..5b089335 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -2,7 +2,7 @@ use ratatui::{
layout::{Constraint, Layout, Rect},
style::{Color, Modifier, Style},
text::{Line, Span},
- widgets::{Block, Borders, Clear, List, ListItem, ListState, Paragraph},
+ widgets::{Block, Borders, Clear, Paragraph},
Frame,
};
use tui_term::widget::PseudoTerminal;
@@ -67,7 +67,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
render_navigate_overlay(app, frame, terminal_area);
render_context_menu(app, frame);
}
- Mode::CreateSession | Mode::RenameSession => {}
+ Mode::RenameSession => {}
Mode::Terminal => {}
}
@@ -129,39 +129,37 @@ fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
pane_infos
}
-/// Auto-scale sidebar width based on longest workspace name.
+/// Auto-scale sidebar width based on workspace identity + agent summary.
fn compute_sidebar_width(app: &AppState) -> u16 {
if app.workspaces.is_empty() {
- return app.sidebar_width; // config default for empty state
+ return app.sidebar_width;
}
- let max_name = app
+ let max_line = app
.workspaces
.iter()
.map(|ws| {
- let pane_count = ws.layout.pane_count();
- let bracket = if pane_count > 1 { 3 + pane_count } else { 0 }; // " [●●]"
- ws.name.len() + bracket
+ let name_len = ws.display_name().len();
+ let pane_count = if ws.layout.pane_count() > 1 {
+ ws.layout.pane_count()
+ } else {
+ 0
+ };
+ let line1 = 4 + name_len + pane_count; // marker + dot + spaces + pane dots
+ let line2 = ws.agent_summary().map(|s| s.len() + 2).unwrap_or(0);
+ line1.max(line2)
})
.max()
- .unwrap_or(8);
- // marker(2) + dot(2) + name + bracket + padding(2) + borders(2)
- let needed = (max_name as u16) + 8;
- needed.clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
+ .unwrap_or(12);
+ ((max_line as u16) + 2).clamp(MIN_SIDEBAR_WIDTH, MAX_SIDEBAR_WIDTH)
}
-/// Collapsed sidebar: borderless strip of state dots with selection highlight.
+/// Collapsed sidebar: pure glance mode.
fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
let is_navigating = matches!(
app.mode,
- Mode::Navigate
- | Mode::CreateSession
- | Mode::RenameSession
- | Mode::Resize
- | Mode::ConfirmClose
- | Mode::ContextMenu
+ Mode::Navigate | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu
);
- // Thin vertical separator line on the right edge
let sep_style = if is_navigating {
Style::default().fg(app.accent)
} else {
@@ -174,93 +172,60 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
buf[(sep_x, y)].set_style(sep_style);
}
- let content_w = area.width.saturating_sub(1); // leave room for separator
+ let content_w = area.width.saturating_sub(1);
+ let bottom_y = area.y + area.height.saturating_sub(1);
for (i, ws) in app.workspaces.iter().enumerate() {
- if i as u16 >= area.height {
+ let y = area.y + i as u16;
+ if y >= bottom_y {
break;
}
let (agg_state, agg_seen) = ws.aggregate_state();
let (icon, icon_style) = state_icon_style(agg_state, agg_seen);
let is_selected = i == app.selected && is_navigating;
-
- let num_label = format!("{}", i + 1);
-
let row_style = if is_selected {
Style::default().bg(app.accent).fg(Color::Black)
} else {
Style::default()
};
- let dim_style = if is_selected {
+ let num_style = if is_selected {
row_style
} else {
Style::default().fg(Color::DarkGray)
};
- let y = area.y + i as u16;
- let row_area = Rect::new(area.x, y, content_w, 1);
-
- // Fill background for selected row
if is_selected {
let buf = frame.buffer_mut();
- for x in row_area.x..row_area.x + row_area.width {
+ for x in area.x..area.x + content_w {
buf[(x, y)].set_style(row_style);
}
}
- let line = Line::from(vec![
- Span::styled(&num_label, dim_style),
- Span::styled(
- " ",
- if is_selected {
- row_style
- } else {
- Style::default()
- },
- ),
- Span::styled(
- icon,
- if is_selected {
- // Keep icon's fg color, just add the highlight bg
- icon_style.bg(app.accent)
- } else {
- icon_style
- },
- ),
- ]);
- frame.render_widget(Paragraph::new(line), row_area);
+ frame.render_widget(
+ Paragraph::new(Line::from(vec![
+ Span::styled(format!("{}", i + 1), num_style),
+ Span::styled(" ", row_style),
+ Span::styled(icon, if is_selected { icon_style.bg(app.accent) } else { icon_style }),
+ ])),
+ Rect::new(area.x, y, content_w, 1),
+ );
}
- // Toggle button at bottom
render_sidebar_toggle(frame, area, true);
}
fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
let is_navigating = matches!(
app.mode,
- Mode::Navigate
- | Mode::CreateSession
- | Mode::RenameSession
- | Mode::Resize
- | Mode::ConfirmClose
- | Mode::ContextMenu
+ Mode::Navigate | Mode::RenameSession | Mode::Resize | Mode::ConfirmClose | Mode::ContextMenu
);
-
- let highlight_style = if is_navigating {
- Style::default()
- .fg(Color::Black)
- .bg(app.accent)
- .add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(Color::Black).bg(Color::DarkGray)
- };
-
- // Thin separator on right edge (consistent with collapsed)
+ let highlight = Style::default().bg(app.accent).fg(Color::Black);
let sep_style = if is_navigating {
Style::default().fg(app.accent)
} else {
Style::default().fg(Color::DarkGray)
};
+
let sep_x = area.x + area.width.saturating_sub(1);
let buf = frame.buffer_mut();
for y in area.y..area.y + area.height {
@@ -268,134 +233,89 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
buf[(sep_x, y)].set_style(sep_style);
}
- // Content area (left of separator)
let content = Rect::new(area.x, area.y, area.width.saturating_sub(1), area.height);
+ let bottom_y = content.y + content.height.saturating_sub(1);
+ let new_row_y = bottom_y.saturating_sub(1);
+ let mut row_y = content.y;
- // Title line
- let title = match app.mode {
- Mode::Terminal => format!(" {} ⏎", app.prefix_label),
- Mode::Navigate | Mode::ContextMenu => " NAVIGATE".to_string(),
- Mode::Resize => " RESIZE".to_string(),
- Mode::CreateSession => " NEW".to_string(),
- Mode::RenameSession => " RENAME".to_string(),
- Mode::ConfirmClose => " CLOSE?".to_string(),
- };
- let title_style = if is_navigating {
- Style::default().fg(app.accent).add_modifier(Modifier::BOLD)
- } else {
- Style::default().fg(Color::DarkGray)
- };
- frame.render_widget(
- Paragraph::new(Span::styled(&title, title_style)),
- Rect::new(content.x, content.y, content.width, 1),
- );
-
- // Workspace list (below title)
- let list_area = Rect::new(
- content.x,
- content.y + 1,
- content.width,
- content.height.saturating_sub(1),
- );
-
- let items: Vec<ListItem> = app
- .workspaces
- .iter()
- .enumerate()
- .map(|(i, ws)| {
- let selected = i == app.selected && is_navigating;
- // When highlighted: text goes black on highlight bg, but icons keep their color
- let text_style = if selected {
- highlight_style
- } else {
- Style::default()
- };
- let dim_style = if selected {
- highlight_style
- } else {
- Style::default().fg(Color::DarkGray)
- };
-
- let marker = if Some(i) == app.active { "▸" } else { " " };
- let (agg_state, agg_seen) = ws.aggregate_state();
- let (rollup_icon, rollup_style) = state_icon_style(agg_state, agg_seen);
- // Icon keeps its fg color, but picks up the highlight bg when selected
- let icon_style = if selected {
- rollup_style.bg(highlight_style.bg.unwrap_or(app.accent))
- } else {
- rollup_style
- };
+ for (i, ws) in app.workspaces.iter().enumerate() {
+ if row_y + 1 >= new_row_y {
+ break;
+ }
+ let selected = i == app.selected && is_navigating;
+ let marker = if Some(i) == app.active { "▸" } else { " " };
+ let (agg_state, agg_seen) = ws.aggregate_state();
+ let (icon, icon_style) = state_icon_style(agg_state, agg_seen);
+ let text_style = if selected { highlight } else { Style::default() };
+ let dim_style = if selected {
+ highlight
+ } else {
+ Style::default().fg(Color::DarkGray)
+ };
+ let mut line1 = vec![Span::styled(marker, text_style)];
- let num_label = format!("{}", i + 1);
- let num_style = if selected {
- highlight_style
- } else {
- Style::default().fg(Color::DarkGray)
- };
+ if ws.layout.pane_count() == 1 {
+ line1.push(Span::styled(
+ icon,
+ if selected { icon_style.bg(app.accent) } else { icon_style },
+ ));
+ line1.push(Span::styled(" ", text_style));
+ line1.push(Span::styled(
+ ws.display_name(),
+ text_style.add_modifier(Modifier::BOLD),
+ ));
+ } else {
+ line1.push(Span::styled(
+ ws.display_name(),
+ text_style.add_modifier(Modifier::BOLD),
+ ));
+ line1.push(Span::styled(" ", dim_style));
+ for (pane_state, pane_seen) in ws.pane_states() {
+ let (pane_icon, pane_style) = state_icon_style(pane_state, pane_seen);
+ line1.push(Span::styled(
+ pane_icon,
+ if selected { pane_style.bg(app.accent) } else { pane_style },
+ ));
+ }
+ }
- let mut spans = vec![
- Span::styled(num_label, num_style),
- Span::styled(format!("{marker}"), text_style),
- Span::styled(rollup_icon, icon_style),
- Span::styled(format!(" {}", ws.name), text_style),
- ];
-
- let pane_states = ws.pane_states();
- let has_any_agent = pane_states.iter().any(|(s, _)| *s != AgentState::Unknown);
-
- if ws.layout.pane_count() > 1 {
- if has_any_agent {
- spans.push(Span::styled(" [", dim_style));
- for (pane_state, pane_seen) in &pane_states {
- let (icon, style) = state_icon_style(*pane_state, *pane_seen);
- let pane_icon_style = if selected {
- style.bg(highlight_style.bg.unwrap_or(app.accent))
- } else {
- style
- };
- spans.push(Span::styled(icon, pane_icon_style));
- }
- spans.push(Span::styled("]", dim_style));
- } else {
- let count = ws.layout.pane_count();
- spans.push(Span::styled(format!(" [{count}]"), dim_style));
+ if selected {
+ let buf = frame.buffer_mut();
+ for y in row_y..=row_y + 1 {
+ for x in content.x..content.x + content.width {
+ buf[(x, y)].set_style(highlight);
}
}
+ }
- ListItem::new(Line::from(spans))
- })
- .collect();
-
- // No highlight_style on List — we handle it per-span above to preserve icon colors
- let list = List::new(items);
- let mut state = ListState::default().with_selected(Some(app.selected));
- frame.render_stateful_widget(list, list_area, &mut state);
+ frame.render_widget(Paragraph::new(Line::from(line1)), Rect::new(content.x, row_y, content.width, 1));
+
+ if app.mode == Mode::RenameSession && i == app.selected {
+ let text = format!(" {}\u{2588}", app.name_input);
+ frame.render_widget(Clear, Rect::new(content.x, row_y + 1, content.width, 1));
+ frame.render_widget(
+ Paragraph::new(text).style(Style::default().fg(Color::Yellow)),
+ Rect::new(content.x, row_y + 1, content.width, 1),
+ );
+ } else if let Some(summary) = ws.agent_summary() {
+ let line2 = Line::from(vec![
+ Span::styled(" ", dim_style),
+ Span::styled(summary, dim_style),
+ ]);
+ frame.render_widget(
+ Paragraph::new(line2),
+ Rect::new(content.x, row_y + 1, content.width, 1),
+ );
+ }
- if app.mode == Mode::CreateSession {
- let input_y = list_area.y + list_area.height.saturating_sub(1);
- let input_area = Rect::new(content.x, input_y, content.width, 1);
- let text = format!(" Name: {}\u{2588}", app.name_input);
- frame.render_widget(
- Paragraph::new(text).style(Style::default().fg(Color::Yellow)),
- input_area,
- );
+ row_y += 2;
}
- if app.mode == Mode::RenameSession {
- let input_y = list_area.y + app.selected as u16;
- let input_area = Rect::new(content.x, input_y, content.width, 1);
- let text = format!(" ✎ {}\u{2588}", app.name_input);
- frame.render_widget(Clear, input_area);
- frame.render_widget(
- Paragraph::new(text).style(Style::default().fg(Color::Yellow)),
- input_area,
- );
- }
-
- // Toggle button at bottom (skip when input field occupies that row)
- if app.mode != Mode::CreateSession {
- render_sidebar_toggle(frame, area, false);
- }
+ frame.render_widget(
+ Paragraph::new(Span::styled("new", Style::default().fg(Color::DarkGray))),
+ Rect::new(content.x, new_row_y, content.width, 1),
+ );
+ render_sidebar_toggle(frame, area, false);
}
fn render_sidebar_toggle(frame: &mut Frame, area: Rect, collapsed: bool) {
@@ -547,7 +467,7 @@ fn render_empty(frame: &mut Frame, area: Rect, accent: Color) {
Line::from(vec![
Span::styled(" Press ", Style::default().fg(Color::DarkGray)),
Span::styled(
- "n",
+ "new",
Style::default().fg(accent).add_modifier(Modifier::BOLD),
),
Span::styled(" to create one", Style::default().fg(Color::DarkGray)),
@@ -569,41 +489,29 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let dim = Style::default().fg(Color::DarkGray);
let label = Style::default().fg(Color::White);
- let is_full = app
- .active
- .and_then(|i| app.workspaces.get(i))
- .is_some_and(|ws| ws.zoomed);
-
- let full_key = if is_full { "unfull" } else { "full" };
-
+ let kb = &app.keybinds;
let line1 = Line::from(vec![
- Span::styled(" n", key),
+ Span::styled(format!(" {}", kb.new_workspace_label), key),
Span::styled(" new ", dim),
- Span::styled("N", key),
+ Span::styled(kb.rename_workspace_label.as_str(), key),
Span::styled(" rename ", dim),
- Span::styled("d", key),
- Span::styled(" close ", dim),
- Span::styled("v", key),
+ Span::styled(kb.close_workspace_label.as_str(), key),
+ Span::styled(" close ws ", dim),
+ Span::styled(kb.split_vertical_label.as_str(), key),
Span::styled(" split│ ", dim),
- Span::styled("-", key),
+ Span::styled(kb.split_horizontal_label.as_str(), key),
Span::styled(" split─ ", dim),
- Span::styled("⇥", key),
- Span::styled(" pane ", dim),
- Span::styled("f", key),
- Span::styled(format!(" {full_key} "), dim),
- Span::styled("r", key),
- Span::styled(" resize ", dim),
- Span::styled("b", key),
- Span::styled(" sidebar ", dim),
- Span::styled("q", key),
- Span::styled(" quit", dim),
+ Span::styled(kb.close_pane_label.as_str(), key),
+ Span::styled(" close pane ", dim),
+ Span::styled(kb.fullscreen_label.as_str(), key),
+ Span::styled(" fullscreen", dim),
]);
let ws_name = app
.active
.and_then(|i| app.workspaces.get(i))
- .map(|ws| ws.name.as_str())
- .unwrap_or("—");
+ .map(|ws| ws.display_name())
+ .unwrap_or_else(|| "—".to_string());
let pane_info = app
.active
@@ -633,9 +541,17 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
Span::styled("esc", key),
Span::styled(" back ", dim),
Span::styled("↑↓", key),
- Span::styled(" select ", dim),
+ Span::styled(" ws ", dim),
+ Span::styled("⇥", key),
+ Span::styled(" pane ", dim),
+ Span::styled(kb.resize_mode_label.as_str(), key),
+ Span::styled(" resize ", dim),
+ Span::styled(kb.toggle_sidebar_label.as_str(), key),
+ Span::styled(" sidebar ", dim),
Span::styled("⏎", key),
- Span::styled(" open", dim),
+ Span::styled(" open ", dim),
+ Span::styled("q", key),
+ Span::styled(" quit", dim),
]);
let overlay_height = 2;
@@ -697,8 +613,8 @@ fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let ws_name = app
.workspaces
.get(app.selected)
- .map(|ws| ws.name.as_str())
- .unwrap_or("?");
+ .map(|ws| ws.display_name())
+ .unwrap_or_else(|| "?".to_string());
let pane_count = app
.workspaces
.get(app.selected)
diff --git a/src/workspace.rs b/src/workspace.rs
index a4cdfcc4..9a584bd3 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -1,17 +1,21 @@
use std::collections::HashMap;
+use std::path::{Path, PathBuf};
use ratatui::layout::Direction;
use tokio::sync::mpsc;
use tracing::info;
-use crate::detect::{self, AgentState};
+use crate::detect::{self, Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{PaneId, TileLayout};
use crate::pane::{PaneRuntime, PaneState};
/// A named workspace containing tiled terminal panes.
pub struct Workspace {
- pub name: String,
+ /// User-provided override. If set, auto-derived identity stops updating.
+ pub custom_name: Option<String>,
+ /// Identity source for this workspace.
+ pub root_pane: PaneId,
pub layout: TileLayout,
/// Pane state — always present, testable without PTYs.
pub panes: HashMap<PaneId, PaneState>,
@@ -23,24 +27,23 @@ pub struct Workspace {
impl Workspace {
pub fn new(
- name: String,
+ initial_cwd: PathBuf,
rows: u16,
cols: u16,
events: mpsc::Sender<AppEvent>,
) -> std::io::Result<Self> {
let (layout, root_id) = TileLayout::new();
-
- let cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
- let runtime = PaneRuntime::spawn(root_id, rows, cols, cwd, events.clone())?;
+ let runtime = PaneRuntime::spawn(root_id, rows, cols, initial_cwd, events.clone())?;
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
let mut runtimes = HashMap::new();
runtimes.insert(root_id, runtime);
- info!(workspace = %name, root_pane = root_id.raw(), "workspace created");
+ info!(root_pane = root_id.raw(), "workspace created");
Ok(Self {
- name,
+ custom_name: None,
+ root_pane: root_id,
layout,
panes,
runtimes,
@@ -55,11 +58,10 @@ impl Workspace {
direction: Direction,
rows: u16,
cols: u16,
- cwd: Option<std::path::PathBuf>,
+ cwd: Option<PathBuf>,
) -> std::io::Result<PaneId> {
let new_id = self.layout.split_focused(direction);
- let actual_cwd =
- cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
+ let actual_cwd = cwd.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
let runtime = PaneRuntime::spawn(new_id, rows, cols, actual_cwd, self.events.clone())?;
self.panes.insert(new_id, PaneState::new());
self.runtimes.insert(new_id, runtime);
@@ -69,18 +71,42 @@ impl Workspace {
/// Close the focused pane. Returns the removed pane id, or None if last pane.
pub fn close_focused(&mut self) -> Option<PaneId> {
+ let pane_id = self.layout.focused();
+ self.remove_pane(pane_id)
+ }
+
+ /// Remove a specific pane from this workspace.
+ /// Returns None if it's the last pane and the whole workspace should close.
+ pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<PaneId> {
if self.layout.pane_count() <= 1 {
return None;
}
- let closed = self.layout.focused();
- if self.layout.close_focused() {
- self.panes.remove(&closed);
- self.runtimes.remove(&closed);
- self.zoomed = false;
- Some(closed)
+
+ let next_root = self.promoted_root_if_needed(pane_id);
+
+ if self.layout.focused() == pane_id {
+ self.layout.close_focused();
} else {
- None
+ let prev_focus = self.layout.focused();
+ self.layout.focus_pane(pane_id);
+ self.layout.close_focused();
+ self.layout.focus_pane(prev_focus);
}
+
+ self.panes.remove(&pane_id);
+ self.runtimes.remove(&pane_id);
+ self.zoomed = false;
+ if let Some(next_root) = next_root {
+ self.root_pane = next_root;
+ }
+ Some(pane_id)
+ }
+
+ fn promoted_root_if_needed(&self, closing: PaneId) -> Option<PaneId> {
+ if self.root_pane != closing {
+ return None;
+ }
+ self.layout.pane_ids().into_iter().find(|id| *id != closing)
}
/// Get the runtime for the focused pane.
@@ -88,6 +114,52 @@ impl Workspace {
self.runtimes.get(&self.layout.focused())
}
+ pub fn set_custom_name(&mut self, name: String) {
+ self.custom_name = Some(name);
+ }
+
+ pub fn display_name(&self) -> String {
+ if let Some(name) = &self.custom_name {
+ return name.clone();
+ }
+
+ self.root_cwd()
+ .as_deref()
+ .map(derive_label_from_cwd)
+ .unwrap_or_else(|| "shell".to_string())
+ }
+
+ pub fn root_cwd(&self) -> Option<PathBuf> {
+ self.runtimes.get(&self.root_pane).and_then(|rt| rt.cwd())
+ }
+
+ pub fn agent_summary(&self) -> Option<String> {
+ let mut names = Vec::new();
+
+ for id in self.layout.pane_ids() {
+ if let Some(agent) = self.panes.get(&id).and_then(|p| p.detected_agent) {
+ let name = agent_name(agent);
+ if !names.iter().any(|n| *n == name) {
+ names.push(name);
+ }
+ }
+ }
+
+ if names.is_empty() {
+ if self.root_cwd().is_some() {
+ Some("shell".to_string())
+ } else {
+ None
+ }
+ } else if names.len() == 1 {
+ Some(names[0].to_string())
+ } else if names.len() == 2 {
+ Some(format!("{} + {}", names[0], names[1]))
+ } else {
+ Some(format!("{} + {} +{}", names[0], names[1], names.len() - 2))
+ }
+ }
+
/// Aggregate state + seen across all panes.
/// Returns the highest-priority state and the worst-case seen flag.
pub fn aggregate_state(&self) -> (AgentState, bool) {
@@ -112,6 +184,60 @@ impl Workspace {
}
}
+fn agent_name(agent: Agent) -> &'static str {
+ match agent {
+ Agent::Pi => "pi",
+ Agent::Claude => "claude",
+ Agent::Codex => "codex",
+ Agent::Gemini => "gemini",
+ Agent::Cursor => "cursor",
+ Agent::Cline => "cline",
+ Agent::OpenCode => "opencode",
+ Agent::GithubCopilot => "copilot",
+ Agent::Kimi => "kimi",
+ Agent::Droid => "droid",
+ Agent::Amp => "amp",
+ }
+}
+
+fn derive_label_from_cwd(cwd: &Path) -> String {
+ if let Some(repo_root) = git_repo_root(cwd) {
+ if let Some(name) = repo_root.file_name().and_then(|n| n.to_str()) {
+ return name.to_string();
+ }
+ }
+
+ if let Ok(home) = std::env::var("HOME") {
+ let home = Path::new(&home);
+ if cwd == home {
+ return "~".to_string();
+ }
+ }
+
+ cwd.file_name()
+ .and_then(|n| n.to_str())
+ .filter(|s| !s.is_empty())
+ .map(|s| s.to_string())
+ .unwrap_or_else(|| cwd.display().to_string())
+}
+
+fn git_repo_root(start: &Path) -> Option<PathBuf> {
+ let mut current = if start.is_dir() {
+ start.to_path_buf()
+ } else {
+ start.parent()?.to_path_buf()
+ };
+
+ loop {
+ if current.join(".git").exists() {
+ return Some(current);
+ }
+ if !current.pop() {
+ return None;
+ }
+ }
+}
+
// ---------------------------------------------------------------------------
// Test helpers — construct workspaces without PTYs
// ---------------------------------------------------------------------------
@@ -125,7 +251,8 @@ impl Workspace {
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
Self {
- name: name.to_string(),
+ custom_name: Some(name.to_string()),
+ root_pane: root_id,
layout,
panes,
runtimes: HashMap::new(),
@@ -160,13 +287,11 @@ mod tests {
let mut ws = Workspace::test_new("test");
let id2 = ws.test_split(Direction::Horizontal);
- // Set one pane to Busy, one to Idle
let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
ws.panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
ws.panes.get_mut(&id2).unwrap().state = AgentState::Busy;
let (state, _) = ws.aggregate_state();
- // Busy should be higher priority than Idle
assert_eq!(state, AgentState::Busy);
}
@@ -207,13 +332,21 @@ mod tests {
let mut ws = Workspace::test_new("test");
let id2 = ws.test_split(Direction::Horizontal);
- // Set second pane to Waiting
ws.panes.get_mut(&id2).unwrap().state = AgentState::Waiting;
let states = ws.pane_states();
assert_eq!(states.len(), 2);
- // One should be Unknown, one Waiting
assert!(states.iter().any(|(s, _)| *s == AgentState::Waiting));
assert!(states.iter().any(|(s, _)| *s == AgentState::Unknown));
}
+
+ #[test]
+ fn closing_root_promotes_another_pane() {
+ let mut ws = Workspace::test_new("test");
+ let root = ws.root_pane;
+ let other = ws.test_split(Direction::Horizontal);
+ ws.layout.focus_pane(root);
+ ws.remove_pane(root);
+ assert_eq!(ws.root_pane, other);
+ }
}