aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-04-02 05:10:13 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-04-02 23:24:46 +0300
commitb695adee60e50764f4713c8e586ca02fb844f99a (patch)
tree6fd769682289fc8053e3cf966ad2ca2d5d4898dd /src
parent28fb23cdaf4fce03ed6dbfcb4bb13be324b181ac (diff)
feat: add tabs within workspaces
Diffstat (limited to 'src')
-rw-r--r--src/app/actions.rs411
-rw-r--r--src/app/input.rs258
-rw-r--r--src/app/mod.rs76
-rw-r--r--src/app/state.rs21
-rw-r--r--src/config.rs115
-rw-r--r--src/main.rs10
-rw-r--r--src/persist.rs137
-rw-r--r--src/raw_input.rs5
-rw-r--r--src/ui.rs215
-rw-r--r--src/workspace.rs588
10 files changed, 1291 insertions, 545 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 04ff19ac..279cbabf 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -3,10 +3,9 @@
use tracing::{info, warn};
-use crate::detect::{Agent, AgentState};
+use crate::detect::AgentState;
use crate::events::AppEvent;
use crate::layout::{find_in_direction, NavDirection, PaneId};
-use crate::pane::EffectiveStateChange;
use super::state::{AppState, Mode, ToastKind, ToastNotification};
@@ -44,32 +43,22 @@ fn notification_toast_for_state_change(
}
}
-fn agent_label(agent: Agent) -> &'static str {
+fn agent_label(agent: crate::detect::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",
+ crate::detect::Agent::Pi => "pi",
+ crate::detect::Agent::Claude => "claude",
+ crate::detect::Agent::Codex => "codex",
+ crate::detect::Agent::Gemini => "gemini",
+ crate::detect::Agent::Cursor => "cursor",
+ crate::detect::Agent::Cline => "cline",
+ crate::detect::Agent::OpenCode => "opencode",
+ crate::detect::Agent::GithubCopilot => "copilot",
+ crate::detect::Agent::Kimi => "kimi",
+ crate::detect::Agent::Droid => "droid",
+ crate::detect::Agent::Amp => "amp",
}
}
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub struct PaneStateUpdate {
- pub pane_id: PaneId,
- pub ws_idx: usize,
- pub previous_agent: Option<Agent>,
- pub previous_state: AgentState,
- pub agent: Option<Agent>,
- pub state: AgentState,
-}
-
// ---------------------------------------------------------------------------
// Workspace operations
// ---------------------------------------------------------------------------
@@ -79,8 +68,56 @@ impl AppState {
if idx < self.workspaces.len() {
self.active = Some(idx);
self.selected = idx;
- for pane in self.workspaces[idx].panes.values_mut() {
- pane.seen = true;
+ if let Some(ws) = self.workspaces.get_mut(idx) {
+ ws.switch_tab(ws.active_tab);
+ }
+ }
+ }
+
+ pub fn switch_tab(&mut self, idx: usize) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ ws.switch_tab(idx);
+ }
+ }
+
+ pub fn next_workspace(&mut self) {
+ if !self.workspaces.is_empty() {
+ let current = self.active.unwrap_or(self.selected);
+ let next = (current + 1) % self.workspaces.len();
+ self.switch_workspace(next);
+ }
+ }
+
+ pub fn previous_workspace(&mut self) {
+ if !self.workspaces.is_empty() {
+ let current = self.active.unwrap_or(self.selected);
+ let prev = if current == 0 {
+ self.workspaces.len() - 1
+ } else {
+ current - 1
+ };
+ self.switch_workspace(prev);
+ }
+ }
+
+ pub fn next_tab(&mut self) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if !ws.tabs.is_empty() {
+ let next = (ws.active_tab + 1) % ws.tabs.len();
+ ws.switch_tab(next);
+ }
+ }
+ }
+
+ pub fn previous_tab(&mut self) {
+ if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if !ws.tabs.is_empty() {
+ let prev = if ws.active_tab == 0 {
+ ws.tabs.len() - 1
+ } else {
+ ws.active_tab - 1
+ };
+ ws.switch_tab(prev);
}
}
}
@@ -113,8 +150,12 @@ impl AppState {
let panes = &self.view.pane_infos;
if let Some(focused) = panes.iter().find(|p| p.is_focused) {
if let Some(target) = find_in_direction(focused, direction, panes) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- ws.layout.focus_pane(target);
+ if let Some(tab) = self
+ .active
+ .and_then(|i| self.workspaces.get_mut(i))
+ .and_then(|ws| ws.active_tab_mut())
+ {
+ tab.layout.focus_pane(target);
}
}
}
@@ -127,33 +168,63 @@ impl AppState {
.pane_infos
.iter()
.fold(first.rect, |acc, p| acc.union(p.rect));
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- ws.layout.resize_focused(direction, 0.05, area);
+ if let Some(tab) = self
+ .active
+ .and_then(|i| self.workspaces.get_mut(i))
+ .and_then(|ws| ws.active_tab_mut())
+ {
+ tab.layout.resize_focused(direction, 0.05, area);
}
}
}
pub fn cycle_pane(&mut self, reverse: bool) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
+ if let Some(tab) = self
+ .active
+ .and_then(|i| self.workspaces.get_mut(i))
+ .and_then(|ws| ws.active_tab_mut())
+ {
if reverse {
- ws.layout.focus_prev();
+ tab.layout.focus_prev();
} else {
- ws.layout.focus_next();
+ tab.layout.focus_next();
}
}
}
pub fn toggle_fullscreen(&mut self) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- if ws.layout.pane_count() > 1 {
- ws.zoomed = !ws.zoomed;
+ if let Some(tab) = self
+ .active
+ .and_then(|i| self.workspaces.get_mut(i))
+ .and_then(|ws| ws.active_tab_mut())
+ {
+ if tab.layout.pane_count() > 1 {
+ tab.zoomed = !tab.zoomed;
}
}
}
pub fn close_pane(&mut self) {
+ let should_close_workspace = self
+ .active
+ .and_then(|i| self.workspaces.get_mut(i))
+ .is_some_and(|ws| ws.close_focused());
+ if should_close_workspace {
+ self.close_selected_workspace();
+ }
+ }
+
+ pub fn close_tab(&mut self) {
+ let should_close_workspace = self
+ .active
+ .and_then(|i| self.workspaces.get(i))
+ .is_some_and(|ws| ws.tabs.len() <= 1);
+ if should_close_workspace {
+ self.close_selected_workspace();
+ return;
+ }
if let Some(ws) = self.active.and_then(|i| self.workspaces.get_mut(i)) {
- ws.close_focused();
+ ws.close_active_tab();
}
}
}
@@ -184,7 +255,7 @@ impl AppState {
None => return,
};
- let rt = match ws.runtimes.get(&sel.pane_id) {
+ let rt = match ws.runtime(sel.pane_id) {
Some(r) => r,
None => return,
};
@@ -204,12 +275,9 @@ impl AppState {
// ---------------------------------------------------------------------------
impl AppState {
- pub fn handle_app_event(&mut self, event: AppEvent) -> Vec<PaneStateUpdate> {
+ pub fn handle_app_event(&mut self, event: AppEvent) {
match event {
- AppEvent::PaneDied { pane_id } => {
- self.handle_pane_died(pane_id);
- Vec::new()
- }
+ AppEvent::PaneDied { pane_id } => self.handle_pane_died(pane_id),
AppEvent::UpdateReady { version } => {
self.update_available = Some(version.clone());
self.update_dismissed = true;
@@ -218,115 +286,65 @@ impl AppState {
title: format!("updated to v{version}"),
context: "restart to use it".to_string(),
});
- Vec::new()
}
AppEvent::StateChanged {
pane_id,
agent,
state,
- } => self
- .update_pane_state(pane_id, |pane| pane.set_detected_state(agent, state))
- .into_iter()
- .collect(),
- AppEvent::HookStateReported {
- pane_id,
- source,
- agent,
- state,
- message,
- } => self
- .update_pane_state(pane_id, |pane| {
- pane.set_hook_authority(source, agent, state, message)
- })
- .into_iter()
- .collect(),
- AppEvent::HookAuthorityCleared { pane_id, source } => self
- .update_pane_state(pane_id, |pane| pane.clear_hook_authority(source.as_deref()))
- .into_iter()
- .collect(),
- AppEvent::HookAgentReleased {
- pane_id,
- source,
- agent,
- } => self
- .update_pane_state(pane_id, |pane| pane.release_agent(&source, agent))
- .into_iter()
- .collect(),
- }
- }
-
- fn update_pane_state<F>(&mut self, pane_id: PaneId, update: F) -> Option<PaneStateUpdate>
- where
- F: FnOnce(&mut crate::pane::PaneState) -> Option<EffectiveStateChange>,
- {
- let ws_idx = self
- .workspaces
- .iter()
- .position(|ws| ws.panes.contains_key(&pane_id))?;
- let workspace_name = self.workspaces[ws_idx].display_name();
- let change = {
- let pane = self.workspaces[ws_idx].panes.get_mut(&pane_id)?;
- update(pane)?
- };
- self.apply_pane_state_change(ws_idx, pane_id, &workspace_name, change);
- Some(PaneStateUpdate {
- pane_id,
- ws_idx,
- previous_agent: change.previous_agent,
- previous_state: change.previous_state,
- agent: change.agent,
- state: change.state,
- })
- }
-
- fn apply_pane_state_change(
- &mut self,
- ws_idx: usize,
- pane_id: PaneId,
- workspace_name: &str,
- change: EffectiveStateChange,
- ) {
- let is_active_ws = self.active == Some(ws_idx);
- let Some(pane) = self.workspaces[ws_idx].panes.get_mut(&pane_id) else {
- return;
- };
-
- if change.state == AgentState::Idle
- && change.previous_state != AgentState::Idle
- && !is_active_ws
- {
- pane.seen = false;
- }
-
- if self.sound.allows(change.agent) {
- if let Some(sound) = notification_sound_for_state_change(
- is_active_ws,
- change.previous_state,
- change.state,
- ) {
- crate::sound::play(sound);
- }
- }
-
- if self.toast_config.enabled {
- if let (Some(agent), Some(kind)) = (
- change.agent,
- notification_toast_for_state_change(
- is_active_ws,
- change.previous_state,
- change.state,
- ),
- ) {
- let event_text = match kind {
- ToastKind::NeedsAttention => "needs attention",
- ToastKind::Finished => "finished",
- ToastKind::UpdateInstalled => "updated",
- };
- self.toast = Some(ToastNotification {
- kind,
- title: format!("{} {}", agent_label(agent), event_text),
- context: format!("{} · {}", workspace_name, ws_idx + 1),
- });
+ } => {
+ for (ws_idx, ws) in self.workspaces.iter_mut().enumerate() {
+ let workspace_name = ws.display_name();
+ let Some(tab_idx) = ws.find_tab_index_for_pane(pane_id) else {
+ continue;
+ };
+ if let Some(pane) = ws.tabs[tab_idx].panes.get_mut(&pane_id) {
+ let is_active_ws = self.active == Some(ws_idx) && ws.active_tab == tab_idx;
+ let prev_state = pane.state;
+
+ // Mark unseen when transitioning to Idle in background
+ if state == AgentState::Idle
+ && prev_state != AgentState::Idle
+ && !is_active_ws
+ {
+ pane.seen = false;
+ }
+
+ // Blocked prompts should always make noise; done sounds stay background-only.
+ if self.sound.allows(agent) {
+ if let Some(sound) =
+ notification_sound_for_state_change(is_active_ws, prev_state, state)
+ {
+ crate::sound::play(sound);
+ }
+ }
+
+ if self.toast_config.enabled {
+ if let (Some(agent), Some(kind)) = (
+ agent,
+ notification_toast_for_state_change(
+ is_active_ws,
+ prev_state,
+ state,
+ ),
+ ) {
+ let event_text = match kind {
+ ToastKind::NeedsAttention => "needs attention",
+ ToastKind::Finished => "finished",
+ ToastKind::UpdateInstalled => "updated",
+ };
+ self.toast = Some(ToastNotification {
+ kind,
+ title: format!("{} {}", agent_label(agent), event_text),
+ context: format!("{} · {}", workspace_name, ws_idx + 1),
+ });
+ }
+ }
+
+ pane.detected_agent = agent;
+ pane.state = state;
+ break;
+ }
+ }
}
}
}
@@ -335,16 +353,19 @@ impl AppState {
let ws_idx = self
.workspaces
.iter()
- .position(|ws| ws.panes.contains_key(&pane_id));
+ .position(|ws| ws.find_tab_index_for_pane(pane_id).is_some());
let Some(ws_idx) = ws_idx else {
warn!(pane = pane_id.raw(), "PaneDied for unknown pane");
return;
};
- let ws = &mut self.workspaces[ws_idx];
+ let should_close_workspace = {
+ let ws = &mut self.workspaces[ws_idx];
+ ws.remove_pane(pane_id)
+ };
- if ws.layout.pane_count() <= 1 {
+ if should_close_workspace {
self.workspaces.remove(ws_idx);
if self.workspaces.is_empty() {
self.active = None;
@@ -362,8 +383,6 @@ impl AppState {
self.selected = self.workspaces.len() - 1;
}
}
- } else {
- ws.remove_pane(pane_id);
}
}
}
@@ -541,94 +560,6 @@ mod tests {
}
#[test]
- fn hook_authority_ignores_fallback_state_changes() {
- let mut state = app_with_workspaces(&["test"]);
- let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
-
- state.handle_app_event(AppEvent::StateChanged {
- pane_id,
- agent: Some(Agent::Pi),
- state: AgentState::Idle,
- });
- state.handle_app_event(AppEvent::HookStateReported {
- pane_id,
- source: "herdr:pi".into(),
- agent: Agent::Pi,
- state: AgentState::Working,
- message: None,
- });
- state.handle_app_event(AppEvent::StateChanged {
- pane_id,
- agent: Some(Agent::Pi),
- state: AgentState::Idle,
- });
-
- let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
- assert_eq!(pane.fallback_state, AgentState::Idle);
- assert_eq!(pane.state, AgentState::Working);
- assert!(pane.hook_authority.is_some());
- }
-
- #[test]
- fn hook_authority_clears_when_agent_disappears() {
- let mut state = app_with_workspaces(&["test"]);
- let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
-
- state.handle_app_event(AppEvent::StateChanged {
- pane_id,
- agent: Some(Agent::Pi),
- state: AgentState::Idle,
- });
- state.handle_app_event(AppEvent::HookStateReported {
- pane_id,
- source: "herdr:pi".into(),
- agent: Agent::Pi,
- state: AgentState::Working,
- message: None,
- });
- state.handle_app_event(AppEvent::StateChanged {
- pane_id,
- agent: None,
- state: AgentState::Unknown,
- });
-
- let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
- assert!(pane.hook_authority.is_none());
- assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.state, AgentState::Unknown);
- }
-
- #[test]
- fn hook_agent_release_clears_identity_immediately() {
- let mut state = app_with_workspaces(&["test"]);
- let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
-
- state.handle_app_event(AppEvent::StateChanged {
- pane_id,
- agent: Some(Agent::Pi),
- state: AgentState::Idle,
- });
- state.handle_app_event(AppEvent::HookStateReported {
- pane_id,
- source: "herdr:pi".into(),
- agent: Agent::Pi,
- state: AgentState::Working,
- message: None,
- });
- state.handle_app_event(AppEvent::HookAgentReleased {
- pane_id,
- source: "herdr:pi".into(),
- agent: Agent::Pi,
- });
-
- let pane = state.workspaces[0].panes.get(&pane_id).unwrap();
- assert!(pane.hook_authority.is_none());
- assert_eq!(pane.detected_agent, None);
- assert_eq!(pane.fallback_state, AgentState::Unknown);
- assert_eq!(pane.state, AgentState::Unknown);
- }
-
- #[test]
fn waiting_sound_plays_even_in_active_workspace() {
assert_eq!(
notification_sound_for_state_change(true, AgentState::Working, AgentState::Blocked),
diff --git a/src/app/input.rs b/src/app/input.rs
index e0f012e9..9a4c9aaf 100644
--- a/src/app/input.rs
+++ b/src/app/input.rs
@@ -32,6 +32,35 @@ use super::App;
// Key handling
// ---------------------------------------------------------------------------
+fn terminal_direct_navigation_action(state: &AppState, key: &KeyEvent) -> Option<NavigateAction> {
+ let kb = &state.keybinds;
+ if kb
+ .previous_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousWorkspace);
+ }
+ if kb
+ .next_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextWorkspace);
+ }
+ if kb
+ .previous_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousTab);
+ }
+ if kb
+ .next_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextTab);
+ }
+ None
+}
+
impl App {
pub(super) async fn handle_key(&mut self, key: TerminalKey) {
match self.state.mode {
@@ -42,7 +71,9 @@ impl App {
Mode::Onboarding => self.handle_onboarding_key(key),
Mode::ReleaseNotes => self.handle_release_notes_key(key),
Mode::Navigate => handle_navigate_key(&mut self.state, key),
- Mode::RenameSession => handle_rename_key(&mut self.state, key),
+ Mode::RenameWorkspace | Mode::RenameTab => {
+ 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),
Mode::ContextMenu => handle_context_menu_key(&mut self.state, key),
@@ -223,6 +254,11 @@ impl App {
let key_event = key.as_key_event();
+ if let Some(action) = terminal_direct_navigation_action(&self.state, &key_event) {
+ execute_navigate_action(&mut self.state, action);
+ return;
+ }
+
if self.state.is_prefix(&key_event) {
self.state.mode = Mode::Navigate;
return;
@@ -446,6 +482,13 @@ enum NavigateAction {
NewWorkspace,
RenameWorkspace,
CloseWorkspace,
+ PreviousWorkspace,
+ NextWorkspace,
+ NewTab,
+ RenameTab,
+ PreviousTab,
+ NextTab,
+ CloseTab,
SplitVertical,
SplitHorizontal,
ClosePane,
@@ -465,6 +508,45 @@ fn navigate_action_for_key(state: &AppState, key: &KeyEvent) -> Option<NavigateA
if key_matches(key, kb.close_workspace.0, kb.close_workspace.1) {
return Some(NavigateAction::CloseWorkspace);
}
+ if kb
+ .previous_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousWorkspace);
+ }
+ if kb
+ .next_workspace
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextWorkspace);
+ }
+ if key_matches(key, kb.new_tab.0, kb.new_tab.1) {
+ return Some(NavigateAction::NewTab);
+ }
+ if kb
+ .rename_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::RenameTab);
+ }
+ if kb
+ .previous_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::PreviousTab);
+ }
+ if kb
+ .next_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::NextTab);
+ }
+ if kb
+ .close_tab
+ .is_some_and(|(code, mods)| key_matches(key, code, mods))
+ {
+ return Some(NavigateAction::CloseTab);
+ }
if key_matches(key, kb.split_vertical.0, kb.split_vertical.1) {
return Some(NavigateAction::SplitVertical);
}
@@ -495,7 +577,7 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
NavigateAction::RenameWorkspace => {
if !state.workspaces.is_empty() {
state.name_input = state.workspaces[state.selected].display_name();
- state.mode = Mode::RenameSession;
+ state.mode = Mode::RenameWorkspace;
}
}
NavigateAction::CloseWorkspace => {
@@ -508,6 +590,38 @@ fn execute_navigate_action(state: &mut AppState, action: NavigateAction) {
}
}
}
+ NavigateAction::PreviousWorkspace => {
+ state.previous_workspace();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NextWorkspace => {
+ state.next_workspace();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NewTab => {
+ state.request_new_tab = true;
+ leave_navigate_mode(state);
+ }
+ NavigateAction::RenameTab => {
+ 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;
+ state.mode = Mode::RenameTab;
+ }
+ }
+ }
+ NavigateAction::PreviousTab => {
+ state.previous_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::NextTab => {
+ state.next_tab();
+ leave_navigate_mode(state);
+ }
+ NavigateAction::CloseTab => {
+ state.close_tab();
+ leave_navigate_mode(state);
+ }
NavigateAction::SplitVertical => {
state.split_pane(Direction::Horizontal);
leave_navigate_mode(state);
@@ -567,8 +681,20 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
} else {
state.name_input.trim().to_string()
};
- if !new_name.is_empty() && !state.workspaces.is_empty() {
- state.workspaces[state.selected].set_custom_name(new_name);
+ if !new_name.is_empty() {
+ match state.mode {
+ Mode::RenameWorkspace if !state.workspaces.is_empty() => {
+ state.workspaces[state.selected].set_custom_name(new_name);
+ }
+ Mode::RenameTab => {
+ if let Some(ws) = state.active.and_then(|i| state.workspaces.get_mut(i)) {
+ if let Some(tab) = ws.active_tab_mut() {
+ tab.set_custom_name(new_name);
+ }
+ }
+ }
+ _ => {}
+ }
}
state.name_input.clear();
state.mode = Mode::Navigate;
@@ -577,6 +703,9 @@ fn handle_rename_key(state: &mut AppState, key: KeyEvent) {
state.name_input.clear();
state.mode = Mode::Navigate;
}
+ KeyCode::Char('c') if key.modifiers == crossterm::event::KeyModifiers::CONTROL => {
+ state.name_input.clear();
+ }
KeyCode::Backspace => {
state.name_input.pop();
}
@@ -653,7 +782,7 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx:
(ContextMenuKind::Workspace { ws_idx }, Some("Rename")) => {
state.selected = ws_idx;
state.name_input = state.workspaces[ws_idx].display_name();
- state.mode = Mode::RenameSession;
+ state.mode = Mode::RenameWorkspace;
}
(ContextMenuKind::Workspace { ws_idx }, Some("Close")) => {
state.selected = ws_idx;
@@ -664,6 +793,35 @@ fn apply_context_menu_action(state: &mut AppState, menu: ContextMenuState, idx:
state.mode = Mode::Navigate;
}
}
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("New tab")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.request_new_tab = true;
+ state.mode = Mode::Terminal;
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Rename")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ if let Some(ws) = state.workspaces.get(ws_idx) {
+ if let Some(name) = ws.active_tab_display_name() {
+ state.name_input = name;
+ state.mode = Mode::RenameTab;
+ }
+ }
+ }
+ (ContextMenuKind::Tab { ws_idx, tab_idx }, Some("Close")) => {
+ state.selected = ws_idx;
+ state.active = Some(ws_idx);
+ state.switch_tab(tab_idx);
+ state.close_tab();
+ state.mode = if state.active.is_some() {
+ Mode::Terminal
+ } else {
+ Mode::Navigate
+ };
+ }
(ContextMenuKind::Pane, Some("Split vertical")) => {
state.split_pane(Direction::Horizontal);
state.mode = Mode::Terminal;
@@ -760,6 +918,30 @@ impl AppState {
&& row < button.y + button.height
}
+ fn rename_modal_inner(&self) -> Option<Rect> {
+ self.onboarding_modal_inner(56, 7)
+ }
+
+ fn rename_button_at(&self, col: u16, row: u16) -> Option<&'static str> {
+ let inner = self.rename_modal_inner()?;
+ if inner.height < 4 || inner.width < 28 {
+ return None;
+ }
+ let (save, clear, cancel) = crate::ui::rename_button_rects(inner);
+ if row != save.y {
+ return None;
+ }
+ if col >= save.x && col < save.x + save.width {
+ Some("save")
+ } else if col >= clear.x && col < clear.x + clear.width {
+ Some("clear")
+ } else if col >= cancel.x && col < cancel.x + cancel.width {
+ Some("cancel")
+ } else {
+ None
+ }
+ }
+
fn release_notes_body_rect(&self) -> Option<Rect> {
let inner = self.release_notes_modal_inner()?;
if inner.height < 8 || inner.width < 4 {
@@ -1082,6 +1264,19 @@ impl AppState {
return None;
}
+ if matches!(self.mode, Mode::RenameWorkspace | Mode::RenameTab) {
+ match self.rename_button_at(mouse.column, mouse.row) {
+ Some("save") => handle_rename_key(self, KeyEvent::from(KeyCode::Enter)),
+ Some("clear") => self.name_input.clear(),
+ Some("cancel") => handle_rename_key(self, KeyEvent::from(KeyCode::Esc)),
+ None => {
+ handle_rename_key(self, KeyEvent::from(KeyCode::Esc));
+ }
+ _ => {}
+ }
+ return None;
+ }
+
if self.mode == Mode::ContextMenu {
let item_idx = self.context_menu_item_at(mouse.column, mouse.row);
if let Some(menu) = self.context_menu.take() {
@@ -1139,6 +1334,17 @@ impl AppState {
}
}
+ if let Some(tab_idx) = self.tab_at(mouse.column, mouse.row) {
+ self.switch_tab(tab_idx);
+ self.mode = Mode::Terminal;
+ return None;
+ }
+ if self.on_new_tab_button(mouse.column, mouse.row) {
+ self.request_new_tab = true;
+ self.mode = Mode::Terminal;
+ return None;
+ }
+
if in_sidebar {
if self.sidebar_collapsed {
let idx = (mouse.row - sidebar.y) as usize;
@@ -1300,6 +1506,23 @@ impl AppState {
}
}
+ MouseEventKind::Down(MouseButton::Right)
+ if self.tab_at(mouse.column, mouse.row).is_some() =>
+ {
+ if let (Some(ws_idx), Some(tab_idx)) =
+ (self.active, self.tab_at(mouse.column, mouse.row))
+ {
+ self.switch_tab(tab_idx);
+ self.context_menu = Some(ContextMenuState {
+ kind: ContextMenuKind::Tab { ws_idx, tab_idx },
+ x: mouse.column,
+ y: mouse.row,
+ selected: 0,
+ });
+ self.mode = Mode::ContextMenu;
+ }
+ }
+
MouseEventKind::Down(MouseButton::Right) if !in_sidebar => {
if self.pane_at(mouse.column, mouse.row).is_some() {
self.context_menu = Some(ContextMenuState {
@@ -1337,6 +1560,29 @@ impl AppState {
}
/// Find which workspace index a sidebar row belongs to (two-section layout).
+ fn tab_at(&self, col: u16, row: u16) -> Option<usize> {
+ self.view
+ .tab_hit_areas
+ .iter()
+ .enumerate()
+ .find_map(|(idx, area)| {
+ (row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width)
+ .then_some(idx)
+ })
+ }
+
+ fn on_new_tab_button(&self, col: u16, row: u16) -> bool {
+ let area = self.view.new_tab_hit_area;
+ area.width > 0
+ && row >= area.y
+ && row < area.y + area.height
+ && col >= area.x
+ && col < area.x + area.width
+ }
+
fn workspace_at_row(&self, row: u16) -> Option<usize> {
let sidebar = self.view.sidebar_rect;
let total_h = sidebar.height as usize;
@@ -1744,7 +1990,7 @@ mod tests {
KeyEvent::new(KeyCode::Char('g'), KeyModifiers::empty()),
);
- assert_eq!(state.mode, Mode::RenameSession);
+ assert_eq!(state.mode, Mode::RenameWorkspace);
assert_eq!(state.name_input, "test");
}
diff --git a/src/app/mod.rs b/src/app/mod.rs
index f6eb9af3..b69c324e 100644
--- a/src/app/mod.rs
+++ b/src/app/mod.rs
@@ -186,6 +186,7 @@ impl App {
mode,
should_quit: false,
request_new_workspace: false,
+ request_new_tab: false,
request_complete_onboarding: false,
name_input: String::new(),
onboarding_step: 0,
@@ -198,6 +199,9 @@ impl App {
}),
view: state::ViewState {
sidebar_rect: Rect::default(),
+ tab_bar_rect: Rect::default(),
+ tab_hit_areas: Vec::new(),
+ new_tab_hit_area: Rect::default(),
terminal_area: Rect::default(),
pane_infos: Vec::new(),
split_borders: Vec::new(),
@@ -250,7 +254,7 @@ impl App {
state
.workspaces
.get(idx)
- .map(|ws| (idx, ws.layout.focused()))
+ .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
});
Self {
@@ -319,6 +323,12 @@ impl App {
needs_render = true;
}
+ if self.state.request_new_tab {
+ self.state.request_new_tab = false;
+ self.create_tab();
+ needs_render = true;
+ }
+
let now = Instant::now();
self.sync_animation_timer(now);
@@ -682,7 +692,7 @@ impl App {
self.state
.workspaces
.get(idx)
- .map(|ws| (idx, ws.layout.focused()))
+ .and_then(|ws| ws.focused_pane_id().map(|pane_id| (idx, pane_id)))
});
if current_focus == self.last_focus {
return;
@@ -717,7 +727,7 @@ impl App {
.workspaces
.iter()
.enumerate()
- .find_map(|(ws_idx, ws)| ws.panes.get(&pane_id).map(|pane| (ws_idx, pane)))
+ .find_map(|(ws_idx, ws)| ws.pane_state(pane_id).map(|pane| (ws_idx, pane)))
}
fn public_workspace_id(&self, ws_idx: usize) -> String {
@@ -1451,6 +1461,38 @@ impl App {
}
}
+ fn create_tab(&mut self) {
+ 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("/"));
+ if let Err(e) = self.create_tab_with_options(initial_cwd, true) {
+ error!(err = %e, "failed to create tab");
+ }
+ }
+
+ fn create_tab_with_options(
+ &mut self,
+ initial_cwd: std::path::PathBuf,
+ focus: bool,
+ ) -> std::io::Result<usize> {
+ let Some(ws_idx) = self.state.active else {
+ return self.create_workspace_with_options(initial_cwd, focus);
+ };
+ let (rows, cols) = self.state.estimate_pane_size();
+ let ws = &mut self.state.workspaces[ws_idx];
+ let idx = ws.create_tab(rows, cols, initial_cwd)?;
+ if focus {
+ ws.switch_tab(idx);
+ self.state.mode = Mode::Terminal;
+ }
+ Ok(idx)
+ }
+
fn create_workspace_with_options(
&mut self,
initial_cwd: std::path::PathBuf,
@@ -1493,9 +1535,9 @@ impl App {
));
};
Ok(ws
- .layout
- .pane_ids()
- .into_iter()
+ .tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids().into_iter())
.filter_map(|pane_id| self.pane_info(ws_idx, pane_id))
.collect())
} else {
@@ -1505,9 +1547,9 @@ impl App {
.iter()
.enumerate()
.flat_map(|(ws_idx, ws)| {
- ws.layout
- .pane_ids()
- .into_iter()
+ ws.tabs
+ .iter()
+ .flat_map(|tab| tab.layout.pane_ids().into_iter())
.filter_map(move |pane_id| self.pane_info(ws_idx, pane_id))
})
.collect())
@@ -1520,12 +1562,16 @@ impl App {
pane_id: crate::layout::PaneId,
) -> Option<crate::api::schema::PaneInfo> {
let ws = self.state.workspaces.get(ws_idx)?;
- let pane = ws.panes.get(&pane_id)?;
- let runtime = ws.runtimes.get(&pane_id);
+ let pane = ws.pane_state(pane_id)?;
+ let runtime = ws.runtime(pane_id);
+ let focused = self.state.active == Some(ws_idx)
+ && ws
+ .focused_pane_id()
+ .is_some_and(|focused| focused == pane_id);
Some(crate::api::schema::PaneInfo {
pane_id: self.public_pane_id(ws_idx, pane_id)?,
workspace_id: self.public_workspace_id(ws_idx),
- focused: self.state.active == Some(ws_idx) && ws.layout.focused() == pane_id,
+ focused,
cwd: runtime
.and_then(|rt| rt.cwd())
.map(|cwd| cwd.display().to_string()),
@@ -1541,7 +1587,7 @@ impl App {
pane_id: crate::layout::PaneId,
) -> Option<(&crate::pane::PaneRuntime, String)> {
let ws = self.state.workspaces.get(ws_idx)?;
- let runtime = ws.runtimes.get(&pane_id)?;
+ let runtime = ws.runtime(pane_id)?;
Some((runtime, self.public_workspace_id(ws_idx)))
}
@@ -1554,7 +1600,7 @@ impl App {
&crate::pane::PaneRuntime,
)> {
let ws = self.state.workspaces.get(ws_idx)?;
- let runtime = ws.runtimes.get(&pane_id)?;
+ let runtime = ws.runtime(pane_id)?;
Some((&runtime.sender, runtime))
}
@@ -1566,7 +1612,7 @@ impl App {
number: index + 1,
label: ws.display_name(),
focused: self.state.active == Some(index),
- pane_count: ws.panes.len(),
+ pane_count: ws.public_pane_numbers.len(),
agent_state: pane_agent_state(agg_state),
}
}
diff --git a/src/app/state.rs b/src/app/state.rs
index 49c740ae..5770154d 100644
--- a/src/app/state.rs
+++ b/src/app/state.rs
@@ -324,6 +324,9 @@ impl Palette {
/// Updated before each render, consumed by render and mouse handling.
pub struct ViewState {
pub sidebar_rect: Rect,
+ pub tab_bar_rect: Rect,
+ pub tab_hit_areas: Vec<Rect>,
+ pub new_tab_hit_area: Rect,
pub terminal_area: Rect,
pub pane_infos: Vec<PaneInfo>,
pub split_borders: Vec<SplitBorder>,
@@ -335,7 +338,8 @@ pub enum Mode {
ReleaseNotes,
Navigate,
Terminal,
- RenameSession,
+ RenameWorkspace,
+ RenameTab,
Resize,
ConfirmClose,
ContextMenu,
@@ -414,6 +418,7 @@ pub(crate) struct DragState {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ContextMenuKind {
Workspace { ws_idx: usize },
+ Tab { ws_idx: usize, tab_idx: usize },
Pane,
}
@@ -429,6 +434,7 @@ impl ContextMenuState {
pub fn items(&self) -> &'static [&'static str] {
match self.kind {
ContextMenuKind::Workspace { .. } => &["Rename", "Close"],
+ ContextMenuKind::Tab { .. } => &["New tab", "Rename", "Close"],
ContextMenuKind::Pane => &[
"Split vertical",
"Split horizontal",
@@ -469,6 +475,7 @@ pub struct AppState {
pub mode: Mode,
pub should_quit: bool,
pub request_new_workspace: bool,
+ pub request_new_tab: bool,
pub request_complete_onboarding: bool,
pub name_input: String,
pub onboarding_step: usize,
@@ -559,6 +566,7 @@ impl AppState {
mode: Mode::Navigate,
should_quit: false,
request_new_workspace: false,
+ request_new_tab: false,
request_complete_onboarding: false,
name_input: String::new(),
onboarding_step: 0,
@@ -566,6 +574,9 @@ impl AppState {
release_notes: None,
view: ViewState {
sidebar_rect: Rect::default(),
+ tab_bar_rect: Rect::default(),
+ tab_hit_areas: Vec::new(),
+ new_tab_hit_area: Rect::default(),
terminal_area: Rect::default(),
pane_infos: Vec::new(),
split_borders: Vec::new(),
@@ -597,6 +608,14 @@ impl AppState {
rename_workspace_label: "shift+n".into(),
close_workspace: (KeyCode::Char('d'), KeyModifiers::empty()),
close_workspace_label: "d".into(),
+ previous_workspace: None,
+ next_workspace: None,
+ new_tab: (KeyCode::Char('c'), KeyModifiers::empty()),
+ new_tab_label: "c".into(),
+ rename_tab: None,
+ previous_tab: None,
+ next_tab: None,
+ close_tab: None,
split_vertical: (KeyCode::Char('v'), KeyModifiers::empty()),
split_vertical_label: "v".into(),
split_horizontal: (KeyCode::Char('-'), KeyModifiers::empty()),
diff --git a/src/config.rs b/src/config.rs
index cfd76594..6f5d17ae 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -2,6 +2,8 @@ use std::path::PathBuf;
use crossterm::event::{KeyCode, KeyModifiers};
use serde::Deserialize;
+
+pub const CONFIG_PATH_ENV_VAR: &str = "HERDR_CONFIG_PATH";
use tracing::warn;
use crate::detect::Agent;
@@ -80,6 +82,20 @@ pub struct KeysConfig {
pub rename_workspace: String,
/// Close the selected workspace. Default: "d"
pub close_workspace: String,
+ /// Select the previous workspace. Unset by default.
+ pub previous_workspace: String,
+ /// Select the next workspace. Unset by default.
+ pub next_workspace: String,
+ /// Create a new tab in the active workspace. Default: "c"
+ pub new_tab: String,
+ /// Rename the active tab. Unset by default.
+ pub rename_tab: String,
+ /// Select the previous tab. Unset by default.
+ pub previous_tab: String,
+ /// Select the next tab. Unset by default.
+ pub next_tab: String,
+ /// Close the active tab. Unset by default.
+ pub close_tab: String,
/// Split pane vertically (side by side). Default: "v"
pub split_vertical: String,
/// Split pane horizontally (stacked). Default: "-"
@@ -184,6 +200,13 @@ impl Default for KeysConfig {
new_workspace: "n".into(),
rename_workspace: "shift+n".into(),
close_workspace: "d".into(),
+ previous_workspace: "".into(),
+ next_workspace: "".into(),
+ new_tab: "c".into(),
+ rename_tab: "".into(),
+ previous_tab: "".into(),
+ next_tab: "".into(),
+ close_tab: "".into(),
split_vertical: "v".into(),
split_horizontal: "-".into(),
close_pane: "x".into(),
@@ -343,6 +366,27 @@ impl Config {
}
}
+ fn optional_binding(
+ field: &'static str,
+ configured_label: &str,
+ diagnostics: &mut Vec<String>,
+ ) -> (Option<(KeyCode, KeyModifiers)>, String) {
+ if configured_label.trim().is_empty() {
+ return (None, String::new());
+ }
+ let (value, diag) = parse_key_combo_with_diagnostic(
+ configured_label,
+ field,
+ (KeyCode::Null, KeyModifiers::empty()),
+ );
+ if let Some(diag) = diag {
+ diagnostics.push(diag);
+ (None, String::new())
+ } else {
+ (Some(value), configured_label.to_string())
+ }
+ }
+
let mut bindings = vec![
binding(
"keys.new_workspace",
@@ -366,6 +410,13 @@ impl Config {
&mut diagnostics,
),
binding(
+ "keys.new_tab",
+ &self.keys.new_tab,
+ "c",
+ (KeyCode::Char('c'), KeyModifiers::empty()),
+ &mut diagnostics,
+ ),
+ binding(
"keys.split_vertical",
&self.keys.split_vertical,
"v",
@@ -409,6 +460,27 @@ impl Config {
),
];
+ let optional_bindings = [
+ optional_binding(
+ "keys.previous_workspace",
+ &self.keys.previous_workspace,
+ &mut diagnostics,
+ ),
+ optional_binding(
+ "keys.next_workspace",
+ &self.keys.next_workspace,
+ &mut diagnostics,
+ ),
+ optional_binding("keys.rename_tab", &self.keys.rename_tab, &mut diagnostics),
+ optional_binding(
+ "keys.previous_tab",
+ &self.keys.previous_tab,
+ &mut diagnostics,
+ ),
+ optional_binding("keys.next_tab", &self.keys.next_tab, &mut diagnostics),
+ optional_binding("keys.close_tab", &self.keys.close_tab, &mut diagnostics),
+ ];
+
use std::collections::HashMap;
let mut seen: HashMap<(KeyCode, KeyModifiers), &str> = HashMap::new();
for binding in &mut bindings {
@@ -433,18 +505,26 @@ impl Config {
rename_workspace_label: bindings[1].label.clone(),
close_workspace: bindings[2].value,
close_workspace_label: bindings[2].label.clone(),
- split_vertical: bindings[3].value,
- split_vertical_label: bindings[3].label.clone(),
- split_horizontal: bindings[4].value,
- split_horizontal_label: bindings[4].label.clone(),
- close_pane: bindings[5].value,
- close_pane_label: bindings[5].label.clone(),
- fullscreen: bindings[6].value,
- fullscreen_label: bindings[6].label.clone(),
- resize_mode: bindings[7].value,
- resize_mode_label: bindings[7].label.clone(),
- toggle_sidebar: bindings[8].value,
- toggle_sidebar_label: bindings[8].label.clone(),
+ previous_workspace: optional_bindings[0].0,
+ next_workspace: optional_bindings[1].0,
+ new_tab: bindings[3].value,
+ new_tab_label: bindings[3].label.clone(),
+ rename_tab: optional_bindings[2].0,
+ previous_tab: optional_bindings[3].0,
+ next_tab: optional_bindings[4].0,
+ close_tab: optional_bindings[5].0,
+ split_vertical: bindings[4].value,
+ split_vertical_label: bindings[4].label.clone(),
+ split_horizontal: bindings[5].value,
+ split_horizontal_label: bindings[5].label.clone(),
+ close_pane: bindings[6].value,
+ close_pane_label: bindings[6].label.clone(),
+ fullscreen: bindings[7].value,
+ fullscreen_label: bindings[7].label.clone(),
+ resize_mode: bindings[8].value,
+ resize_mode_label: bindings[8].label.clone(),
+ toggle_sidebar: bindings[9].value,
+ toggle_sidebar_label: bindings[9].label.clone(),
};
(prefix_diag, prefix, diagnostics, keybinds)
@@ -460,6 +540,14 @@ pub struct Keybinds {
pub rename_workspace_label: String,
pub close_workspace: (KeyCode, KeyModifiers),
pub close_workspace_label: String,
+ pub previous_workspace: Option<(KeyCode, KeyModifiers)>,
+ pub next_workspace: Option<(KeyCode, KeyModifiers)>,
+ pub new_tab: (KeyCode, KeyModifiers),
+ pub new_tab_label: String,
+ pub rename_tab: Option<(KeyCode, KeyModifiers)>,
+ pub previous_tab: Option<(KeyCode, KeyModifiers)>,
+ pub next_tab: Option<(KeyCode, KeyModifiers)>,
+ pub close_tab: Option<(KeyCode, KeyModifiers)>,
pub split_vertical: (KeyCode, KeyModifiers),
pub split_vertical_label: String,
pub split_horizontal: (KeyCode, KeyModifiers),
@@ -554,6 +642,9 @@ pub fn save_onboarding_choices(sound_enabled: bool, toast_enabled: bool) -> std:
}
pub fn config_path() -> PathBuf {
+ if let Ok(path) = std::env::var(CONFIG_PATH_ENV_VAR) {
+ return PathBuf::from(path);
+ }
if let Ok(dir) = std::env::var("XDG_CONFIG_HOME") {
PathBuf::from(dir).join("herdr/config.toml")
} else if let Ok(home) = std::env::var("HOME") {
diff --git a/src/main.rs b/src/main.rs
index a23ec6c3..a5a43b39 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -107,6 +107,13 @@ const DEFAULT_CONFIG: &str = r##"# herdr configuration
# new_workspace = "n"
# rename_workspace = "shift+n"
# close_workspace = "d"
+# previous_workspace = "" # optional, unset by default
+# next_workspace = "" # optional, unset by default
+# new_tab = "c"
+# rename_tab = "" # optional, unset by default
+# previous_tab = "" # optional, unset by default
+# next_tab = "" # optional, unset by default
+# close_tab = "" # optional, unset by default
# split_vertical = "v"
# split_horizontal = "-"
# close_pane = "x"
@@ -204,8 +211,9 @@ fn main() -> io::Result<()> {
println!(" --show-changelog Preview the current version's release notes");
println!(" --help, -h Show this help");
println!();
- println!("Config: ~/.config/herdr/config.toml");
+ println!("Config: {}", config::config_path().display());
println!("Logs: ~/.config/herdr/herdr.log");
+ println!("Env: HERDR_CONFIG_PATH overrides config file path");
println!("Home: https://herdr.dev");
return Ok(());
}
diff --git a/src/persist.rs b/src/persist.rs
index 49a36141..3585228e 100644
--- a/src/persist.rs
+++ b/src/persist.rs
@@ -19,7 +19,7 @@ use crate::pane::{PaneRuntime, PaneState};
use crate::workspace::Workspace;
/// Current snapshot format version.
-const SNAPSHOT_VERSION: u32 = 2;
+const SNAPSHOT_VERSION: u32 = 3;
/// Serializable snapshot of the entire herdr session.
#[derive(Serialize, Deserialize)]
@@ -36,13 +36,21 @@ pub struct SessionSnapshot {
pub struct WorkspaceSnapshot {
#[serde(default)]
pub custom_name: Option<String>,
+ pub identity_cwd: PathBuf,
+ pub tabs: Vec<TabSnapshot>,
+ #[serde(default)]
+ pub active_tab: usize,
+}
+
+#[derive(Serialize, Deserialize)]
+pub struct TabSnapshot {
+ #[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>,
}
@@ -87,22 +95,31 @@ pub fn capture(
}
fn capture_workspace(ws: &Workspace) -> WorkspaceSnapshot {
+ WorkspaceSnapshot {
+ custom_name: ws.custom_name.clone(),
+ identity_cwd: ws.identity_cwd.clone(),
+ tabs: ws.tabs.iter().map(capture_tab).collect(),
+ active_tab: ws.active_tab,
+ }
+}
+
+fn capture_tab(tab: &crate::workspace::Tab) -> TabSnapshot {
let mut panes = HashMap::new();
- for id in ws.panes.keys() {
- let cwd = ws
+ for id in tab.panes.keys() {
+ let cwd = tab
.runtimes
.get(id)
.and_then(|rt| rt.cwd())
.unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| "/".into()));
panes.insert(id.raw(), PaneSnapshot { cwd });
}
- WorkspaceSnapshot {
- custom_name: ws.custom_name.clone(),
- layout: capture_node(ws.layout.root()),
+ TabSnapshot {
+ custom_name: tab.custom_name.clone(),
+ layout: capture_node(tab.layout.root()),
panes,
- zoomed: ws.zoomed,
- focused: Some(ws.layout.focused().raw()),
- root_pane: Some(ws.root_pane.raw()),
+ zoomed: tab.zoomed,
+ focused: Some(tab.layout.focused().raw()),
+ root_pane: Some(tab.root_pane.raw()),
}
}
@@ -161,10 +178,53 @@ fn restore_workspace(
render_notify: Arc<Notify>,
render_dirty: Arc<AtomicBool>,
) -> Option<Workspace> {
+ let mut tabs = Vec::new();
+ let mut public_pane_numbers = HashMap::new();
+ let mut next_public_pane_number = 1;
+
+ for (idx, tab_snap) in snap.tabs.iter().enumerate() {
+ let tab = restore_tab(
+ tab_snap,
+ idx + 1,
+ rows,
+ cols,
+ events.clone(),
+ render_notify.clone(),
+ render_dirty.clone(),
+ )?;
+ for pane_id in tab.layout.pane_ids() {
+ public_pane_numbers.insert(pane_id, next_public_pane_number);
+ next_public_pane_number += 1;
+ }
+ tabs.push(tab);
+ }
+
+ if tabs.is_empty() {
+ return None;
+ }
+
+ Some(Workspace {
+ custom_name: snap.custom_name.clone(),
+ identity_cwd: snap.identity_cwd.clone(),
+ cached_git_ahead_behind: None,
+ public_pane_numbers,
+ next_public_pane_number,
+ active_tab: snap.active_tab.min(tabs.len().saturating_sub(1)),
+ tabs,
+ })
+}
+
+fn restore_tab(
+ snap: &TabSnapshot,
+ number: usize,
+ rows: u16,
+ cols: u16,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+) -> Option<crate::workspace::Tab> {
let (node, id_map) = restore_node_remapped(&snap.layout);
let pane_ids = collect_pane_ids(&node);
-
- // Restore focused pane: map saved raw ID to new ID, fall back to first pane
let focus = snap
.focused
.and_then(|old_raw| id_map.get(&old_raw).copied())
@@ -198,7 +258,7 @@ fn restore_workspace(
runtimes.insert(*id, runtime);
}
Err(e) => {
- error!(workspace = ?snap.custom_name, err = %e, "failed to restore pane");
+ error!(tab = ?snap.custom_name, err = %e, "failed to restore pane");
return None;
}
}
@@ -210,20 +270,11 @@ fn restore_workspace(
.or_else(|| pane_ids.first().copied())
.unwrap_or(PaneId::from_raw(0));
- let public_pane_numbers = layout
- .pane_ids()
- .into_iter()
- .enumerate()
- .map(|(index, pane_id)| (pane_id, index + 1))
- .collect();
-
- Some(Workspace {
+ Some(crate::workspace::Tab {
custom_name: snap.custom_name.clone(),
+ number,
root_pane,
layout,
- cached_git_ahead_behind: None,
- public_pane_numbers,
- next_public_pane_number: panes.len() + 1,
panes,
runtimes,
zoomed: snap.zoomed,
@@ -421,16 +472,21 @@ mod tests {
let snap = SessionSnapshot {
workspaces: vec![WorkspaceSnapshot {
custom_name: Some("pi-mono".to_string()),
- layout: LayoutSnapshot::Split {
- direction: DirectionSnapshot::Horizontal,
- ratio: 0.5,
- first: Box::new(LayoutSnapshot::Pane(0)),
- second: Box::new(LayoutSnapshot::Pane(1)),
- },
- panes,
- zoomed: false,
- focused: Some(0),
- root_pane: Some(0),
+ identity_cwd: PathBuf::from("/home/can/Projects/herdr"),
+ tabs: vec![TabSnapshot {
+ custom_name: Some("api".to_string()),
+ layout: LayoutSnapshot::Split {
+ direction: DirectionSnapshot::Horizontal,
+ ratio: 0.5,
+ first: Box::new(LayoutSnapshot::Pane(0)),
+ second: Box::new(LayoutSnapshot::Pane(1)),
+ },
+ panes,
+ zoomed: false,
+ focused: Some(0),
+ root_pane: Some(0),
+ }],
+ active_tab: 0,
}],
active: Some(0),
selected: 0,
@@ -445,9 +501,10 @@ mod tests {
restored.workspaces[0].custom_name.as_deref(),
Some("pi-mono")
);
- assert_eq!(restored.workspaces[0].panes.len(), 2);
+ assert_eq!(restored.workspaces[0].tabs.len(), 1);
+ assert_eq!(restored.workspaces[0].tabs[0].panes.len(), 2);
assert_eq!(
- restored.workspaces[0].panes[&0].cwd,
+ restored.workspaces[0].tabs[0].panes[&0].cwd,
PathBuf::from("/home/can/Projects/herdr")
);
}
@@ -494,9 +551,9 @@ mod tests {
}
#[test]
- fn focused_pane_default_is_none() {
- let json = r#"{"name":"test","layout":{"Pane":0},"panes":{},"zoomed":false}"#;
+ fn active_tab_default_is_zero() {
+ let json = r#"{"custom_name":"test","identity_cwd":"/tmp","tabs":[]}"#;
let ws: WorkspaceSnapshot = serde_json::from_str(json).unwrap();
- assert_eq!(ws.focused, None); // #[serde(default)]
+ assert_eq!(ws.active_tab, 0);
}
}
diff --git a/src/raw_input.rs b/src/raw_input.rs
index 859f4c26..dd61ee5a 100644
--- a/src/raw_input.rs
+++ b/src/raw_input.rs
@@ -53,6 +53,11 @@ fn drain_buffer(buffer: &mut Vec<u8>, tx: &mpsc::Sender<RawInputEvent>) {
let Some((event, consumed)) = extract_one_event(buffer) else {
break;
};
+ tracing::debug!(
+ raw_bytes = ?&buffer[..consumed],
+ event = ?event,
+ "raw input event parsed"
+ );
buffer.drain(..consumed);
let _ = tx.blocking_send(event);
}
diff --git a/src/ui.rs b/src/ui.rs
index 9e2db7c6..cbf8fdd2 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -42,20 +42,35 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
let [sidebar_area, main_area] =
Layout::horizontal([Constraint::Length(sidebar_w), Constraint::Min(1)]).areas(area);
- let terminal_area = main_area;
+ let has_tabs = app.active.and_then(|i| app.workspaces.get(i)).is_some();
+ let (tab_bar_rect, terminal_area) = if has_tabs && main_area.height > 1 {
+ let [tab_bar_rect, terminal_area] =
+ Layout::vertical([Constraint::Length(1), Constraint::Min(1)]).areas(main_area);
+ (tab_bar_rect, terminal_area)
+ } else {
+ (Rect::default(), main_area)
+ };
+
+ let tab_hit_areas = app
+ .active
+ .and_then(|i| app.workspaces.get(i))
+ .map(|ws| compute_tab_hit_areas(ws, tab_bar_rect))
+ .unwrap_or_default();
+ let new_tab_hit_area = compute_new_tab_hit_area(&tab_hit_areas, tab_bar_rect);
- // Compute split borders
let split_borders = app
.active
.and_then(|i| app.workspaces.get(i))
.map(|ws| ws.layout.splits(terminal_area))
.unwrap_or_default();
- // Compute pane layout + reconcile sizes
let pane_infos = compute_pane_infos(app, terminal_area);
app.view = crate::app::ViewState {
sidebar_rect: sidebar_area,
+ tab_bar_rect,
+ tab_hit_areas,
+ new_tab_hit_area,
terminal_area,
pane_infos,
split_borders,
@@ -65,6 +80,7 @@ pub fn compute_view(app: &mut AppState, area: Rect) {
/// Render the UI — reads AppState but does not mutate it.
pub fn render(app: &AppState, frame: &mut Frame) {
let sidebar_area = app.view.sidebar_rect;
+ let tab_bar_area = app.view.tab_bar_rect;
let terminal_area = app.view.terminal_area;
if app.sidebar_collapsed {
@@ -72,6 +88,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
} else {
render_sidebar(app, frame, sidebar_area);
}
+ render_tab_bar(app, frame, tab_bar_area);
render_panes(app, frame, terminal_area);
match app.mode {
@@ -85,7 +102,7 @@ pub fn render(app: &AppState, frame: &mut Frame) {
render_context_menu(app, frame);
}
Mode::Settings => render_settings_overlay(app, frame, frame.area()),
- Mode::RenameSession => {}
+ Mode::RenameWorkspace | Mode::RenameTab => render_rename_overlay(app, frame, frame.area()),
Mode::Terminal => {}
}
@@ -105,6 +122,87 @@ pub fn render(app: &AppState, frame: &mut Frame) {
}
}
+const MIN_TAB_WIDTH: u16 = 8;
+const NEW_TAB_WIDTH: u16 = 3;
+
+fn compute_tab_hit_areas(ws: &crate::workspace::Workspace, area: Rect) -> Vec<Rect> {
+ if area.width == 0 || area.height == 0 {
+ return Vec::new();
+ }
+
+ let mut x = area.x;
+ let mut rects = Vec::new();
+ let right = area.x + area.width;
+ for tab in &ws.tabs {
+ if x >= right.saturating_sub(NEW_TAB_WIDTH) {
+ break;
+ }
+ let desired = (tab.display_name().chars().count() as u16 + 4).max(MIN_TAB_WIDTH);
+ let remaining = right.saturating_sub(NEW_TAB_WIDTH).saturating_sub(x);
+ let width = desired.min(remaining).max(1);
+ rects.push(Rect::new(x, area.y, width, 1));
+ x = x.saturating_add(width + 1);
+ }
+ rects
+}
+
+fn compute_new_tab_hit_area(tab_hit_areas: &[Rect], area: Rect) -> Rect {
+ if area.width == 0 || area.height == 0 {
+ return Rect::default();
+ }
+ let x = tab_hit_areas
+ .last()
+ .map(|rect| rect.x + rect.width + 1)
+ .unwrap_or(area.x)
+ .min(area.x + area.width.saturating_sub(NEW_TAB_WIDTH));
+ Rect::new(x, area.y, NEW_TAB_WIDTH.min(area.width), 1)
+}
+
+fn render_tab_bar(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.width == 0 || area.height == 0 {
+ return;
+ }
+ let Some(ws_idx) = app.active else {
+ return;
+ };
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return;
+ };
+
+ let p = &app.palette;
+
+ frame.render_widget(
+ Paragraph::new(" ".repeat(area.width as usize)).style(Style::default().bg(p.panel_bg)),
+ area,
+ );
+
+ for (idx, tab) in ws.tabs.iter().enumerate() {
+ let Some(rect) = app.view.tab_hit_areas.get(idx).copied() else {
+ break;
+ };
+ let active = idx == ws.active_tab;
+ let style = if active {
+ Style::default()
+ .fg(p.panel_bg)
+ .bg(p.accent)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.overlay1).bg(p.surface0)
+ };
+ let width = rect.width as usize;
+ let name = tab.display_name();
+ let text = format!(" {:width$}", name, width = width.saturating_sub(1));
+ frame.render_widget(Paragraph::new(text).style(style), rect);
+ }
+
+ if app.view.new_tab_hit_area.width > 0 {
+ frame.render_widget(
+ Paragraph::new(" + ").style(Style::default().fg(p.overlay1)),
+ app.view.new_tab_hit_area,
+ );
+ }
+}
+
/// Compute pane layout info and resize pane runtimes to match.
fn compute_pane_infos(app: &AppState, area: Rect) -> Vec<PaneInfo> {
let Some(ws_idx) = app.active else {
@@ -217,7 +315,7 @@ fn render_sidebar_collapsed(app: &AppState, frame: &mut Frame, area: Rect) {
let is_navigating = matches!(
app.mode,
Mode::Navigate
- | Mode::RenameSession
+ | Mode::RenameWorkspace
| Mode::Resize
| Mode::ConfirmClose
| Mode::ContextMenu
@@ -284,7 +382,7 @@ fn render_sidebar(app: &AppState, frame: &mut Frame, area: Rect) {
let is_navigating = matches!(
app.mode,
Mode::Navigate
- | Mode::RenameSession
+ | Mode::RenameWorkspace
| Mode::Resize
| Mode::ConfirmClose
| Mode::ContextMenu
@@ -357,8 +455,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
let (agg_state, agg_seen) = ws.aggregate_state();
// Determine row height for background fill
- let has_second_line =
- ws.branch().is_some() || (app.mode == Mode::RenameSession && i == app.selected);
+ let has_second_line = ws.branch().is_some();
let row_height: u16 = if has_second_line { 2 } else { 1 };
// Background fill: selected gets brighter surface, active gets subtle surface
@@ -419,17 +516,9 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
);
row_y += 1;
- // Line 2: branch or rename input
+ // Line 2: branch
if row_y < list_bottom {
- if app.mode == Mode::RenameSession && i == app.selected {
- let text = format!(" {}\u{2588}", app.name_input);
- frame.render_widget(Clear, Rect::new(area.x, row_y, area.width, 1));
- frame.render_widget(
- Paragraph::new(text).style(Style::default().fg(p.yellow)),
- Rect::new(area.x, row_y, area.width, 1),
- );
- row_y += 1;
- } else if let Some(branch) = ws.branch() {
+ if let Some(branch) = ws.branch() {
let upstream_label = ws.git_ahead_behind().and_then(|(ahead, behind)| {
let mut parts = Vec::new();
if ahead > 0 {
@@ -1314,11 +1403,13 @@ fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let kb = &app.keybinds;
let line1 = Line::from(vec![
Span::styled(format!(" {}", kb.new_workspace_label), key),
- Span::styled(" new ", dim),
+ Span::styled(" new ws ", dim),
Span::styled(kb.rename_workspace_label.as_str(), key),
- Span::styled(" rename ", dim),
+ Span::styled(" rename ws ", dim),
Span::styled(kb.close_workspace_label.as_str(), key),
Span::styled(" close ws ", dim),
+ Span::styled(kb.new_tab_label.as_str(), key),
+ Span::styled(" new tab ", dim),
Span::styled(kb.split_vertical_label.as_str(), key),
Span::styled(" split│ ", dim),
Span::styled(kb.split_horizontal_label.as_str(), key),
@@ -1435,6 +1526,90 @@ fn render_resize_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
}
/// Centered popup confirmation dialog with dimmed background.
+pub(crate) fn rename_button_rects(inner: Rect) -> (Rect, Rect, Rect) {
+ let save_w = 10u16;
+ let clear_w = 11u16;
+ let cancel_w = 12u16;
+ let gap = 2u16;
+ let total_w = save_w + gap + clear_w + gap + cancel_w;
+ let x = inner.x + inner.width.saturating_sub(total_w) / 2;
+ let y = inner.y + 3;
+ (
+ Rect::new(x, y, save_w, 1),
+ Rect::new(x + save_w + gap, y, clear_w, 1),
+ Rect::new(x + save_w + gap + clear_w + gap, y, cancel_w, 1),
+ )
+}
+
+fn render_rename_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
+ dim_background(frame, area);
+
+ let title = match app.mode {
+ Mode::RenameWorkspace => "rename workspace",
+ Mode::RenameTab => "rename tab",
+ _ => return,
+ };
+
+ let Some(inner) = render_modal_shell(frame, area, 56, 7, &app.palette) else {
+ return;
+ };
+ if inner.height < 4 {
+ return;
+ }
+
+ let rows = Layout::vertical([
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Length(1),
+ Constraint::Min(0),
+ ])
+ .areas::<5>(inner);
+
+ render_modal_header(frame, rows[0], title, &app.palette);
+
+ let input_rect = Rect::new(rows[2].x, rows[2].y, rows[2].width, 1);
+ frame.render_widget(Clear, input_rect);
+ frame.render_widget(
+ Paragraph::new(format!(" {}█", app.name_input)).style(
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0),
+ ),
+ input_rect,
+ );
+
+ let (save_rect, clear_rect, cancel_rect) = rename_button_rects(inner);
+
+ frame.render_widget(
+ Paragraph::new(" ↵ save ").style(
+ Style::default()
+ .fg(app.palette.panel_bg)
+ .bg(app.palette.accent)
+ .add_modifier(Modifier::BOLD),
+ ),
+ save_rect,
+ );
+ frame.render_widget(
+ Paragraph::new(" ^c clear ").style(
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ ),
+ clear_rect,
+ );
+ frame.render_widget(
+ Paragraph::new(" esc cancel ").style(
+ Style::default()
+ .fg(app.palette.text)
+ .bg(app.palette.surface0)
+ .add_modifier(Modifier::BOLD),
+ ),
+ cancel_rect,
+ );
+}
+
fn render_confirm_close_overlay(app: &AppState, frame: &mut Frame, area: Rect) {
let ws_name = app
.workspaces
diff --git a/src/workspace.rs b/src/workspace.rs
index 39ee19a0..d10cdfe2 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -1,4 +1,5 @@
use std::collections::HashMap;
+use std::ops::{Deref, DerefMut};
use std::path::{Path, PathBuf};
use std::sync::atomic::AtomicBool;
use std::sync::Arc;
@@ -12,19 +13,12 @@ use crate::events::AppEvent;
use crate::layout::{PaneId, TileLayout};
use crate::pane::{PaneRuntime, PaneState};
-/// A named workspace containing tiled terminal panes.
-pub struct Workspace {
- /// User-provided override. If set, auto-derived identity stops updating.
+pub struct Tab {
pub custom_name: Option<String>,
- /// Identity source for this workspace.
+ pub number: usize,
+ /// Identity source for this tab's pane tree.
pub root_pane: PaneId,
pub layout: TileLayout,
- /// Cached ahead/behind counts for the root repo's current branch upstream.
- pub(crate) cached_git_ahead_behind: Option<(usize, usize)>,
- /// Stable-ish public pane numbers within this workspace.
- /// New panes append at the end; closing a pane compacts higher numbers down.
- pub public_pane_numbers: HashMap<PaneId, usize>,
- pub(crate) next_public_pane_number: usize,
/// Pane state — always present, testable without PTYs.
pub panes: HashMap<PaneId, PaneState>,
/// Pane runtimes — only present in production (empty in tests).
@@ -35,7 +29,7 @@ pub struct Workspace {
pub(crate) render_dirty: Arc<AtomicBool>,
}
-impl Drop for Workspace {
+impl Drop for Tab {
fn drop(&mut self) {
let runtimes = std::mem::take(&mut self.runtimes);
for (pane_id, runtime) in runtimes {
@@ -44,8 +38,9 @@ impl Drop for Workspace {
}
}
-impl Workspace {
+impl Tab {
pub fn new(
+ number: usize,
initial_cwd: PathBuf,
rows: u16,
cols: u16,
@@ -66,19 +61,14 @@ impl Workspace {
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
- let mut public_pane_numbers = HashMap::new();
- public_pane_numbers.insert(root_id, 1);
let mut runtimes = HashMap::new();
runtimes.insert(root_id, runtime);
- info!(root_pane = root_id.raw(), "workspace created");
Ok(Self {
custom_name: None,
+ number,
root_pane: root_id,
layout,
- cached_git_ahead_behind: None,
- public_pane_numbers,
- next_public_pane_number: 2,
panes,
runtimes,
zoomed: false,
@@ -88,7 +78,16 @@ impl Workspace {
})
}
- /// Split the focused pane. Returns the new pane id.
+ pub fn display_name(&self) -> String {
+ self.custom_name
+ .clone()
+ .unwrap_or_else(|| self.number.to_string())
+ }
+
+ pub fn set_custom_name(&mut self, name: String) {
+ self.custom_name = Some(name);
+ }
+
pub fn split_focused(
&mut self,
direction: Direction,
@@ -109,35 +108,22 @@ impl Workspace {
self.render_dirty.clone(),
)?;
self.panes.insert(new_id, PaneState::new());
- self.public_pane_numbers
- .insert(new_id, self.next_public_pane_number);
- self.next_public_pane_number += 1;
self.runtimes.insert(new_id, runtime);
self.zoomed = false;
Ok(new_id)
}
- /// Close the focused pane. Returns the removed pane id, or None if last pane.
- pub fn close_focused(&mut self) -> Option<PaneId> {
+ pub fn close_focused(&mut self) -> Option<(PaneId, Option<PaneRuntime>)> {
let pane_id = self.layout.focused();
- self.close_pane(pane_id)
+ self.detach_pane(pane_id)
}
- /// Close a specific pane and terminate everything running inside it.
- /// Returns None if it's the last pane and the whole workspace should close.
- pub fn close_pane(&mut self, pane_id: PaneId) -> Option<PaneId> {
- let (removed, runtime) = self.detach_pane(pane_id)?;
- if let Some(runtime) = runtime {
- runtime.shutdown(pane_id);
- }
- Some(removed)
+ pub fn close_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ self.detach_pane(pane_id)
}
- /// Remove a specific pane from this workspace without terminating its runtime.
- /// Used when the pane process has already exited and we are only cleaning up state.
- /// 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> {
- self.detach_pane(pane_id).map(|(removed, _)| removed)
+ pub fn remove_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
+ self.detach_pane(pane_id)
}
fn detach_pane(&mut self, pane_id: PaneId) -> Option<(PaneId, Option<PaneRuntime>)> {
@@ -156,14 +142,6 @@ impl Workspace {
self.layout.focus_pane(prev_focus);
}
- if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) {
- for number in self.public_pane_numbers.values_mut() {
- if *number > removed_number {
- *number -= 1;
- }
- }
- self.next_public_pane_number = self.public_pane_numbers.len() + 1;
- }
self.panes.remove(&pane_id);
let runtime = self.runtimes.remove(&pane_id);
self.zoomed = false;
@@ -180,52 +158,18 @@ impl Workspace {
self.layout.pane_ids().into_iter().find(|id| *id != closing)
}
- pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> {
- self.public_pane_numbers.get(&pane_id).copied()
- }
-
- /// Get the runtime for the focused pane.
pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
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())
- }
-
- /// Aggregate workspace signal for sidebar triage.
- /// Returns the most urgent pane's state + seen flag.
- pub fn aggregate_state(&self) -> (AgentState, bool) {
- self.panes
- .values()
- .map(|pane| (pane.state, pane.seen))
- .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
- .unwrap_or((AgentState::Unknown, true))
- }
-
pub fn has_working_pane(&self) -> bool {
self.panes
.values()
.any(|pane| pane.state == AgentState::Working)
}
- /// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom).
#[cfg(test)]
+ #[allow(dead_code)] // retained for focused layout-order assertions in tests
pub fn pane_states(&self) -> Vec<(AgentState, bool)> {
self.layout
.pane_ids()
@@ -239,7 +183,6 @@ impl Workspace {
.collect()
}
- /// Per-pane detail for the agent detail panel, in stable layout order.
pub fn pane_details(&self) -> Vec<PaneDetail> {
self.layout
.pane_ids()
@@ -261,28 +204,336 @@ impl Workspace {
})
.collect()
}
+}
+
+/// A named workspace containing tabs.
+pub struct Workspace {
+ /// User-provided override. If set, auto-derived identity stops updating.
+ pub custom_name: Option<String>,
+ /// Fixed workspace identity source, seeded when the workspace is created.
+ pub identity_cwd: PathBuf,
+ /// Cached ahead/behind counts for the workspace repo's current branch upstream.
+ pub(crate) cached_git_ahead_behind: Option<(usize, usize)>,
+ /// Stable-ish public pane numbers within this workspace.
+ /// New panes append at the end; closing a pane compacts higher numbers down.
+ pub public_pane_numbers: HashMap<PaneId, usize>,
+ pub(crate) next_public_pane_number: usize,
+ pub tabs: Vec<Tab>,
+ pub active_tab: usize,
+}
+
+impl Deref for Workspace {
+ type Target = Tab;
+
+ fn deref(&self) -> &Self::Target {
+ self.active_tab()
+ .expect("workspace must always have at least one active tab")
+ }
+}
+
+impl DerefMut for Workspace {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ self.active_tab_mut()
+ .expect("workspace must always have at least one active tab")
+ }
+}
+
+impl Workspace {
+ pub fn new(
+ initial_cwd: PathBuf,
+ rows: u16,
+ cols: u16,
+ events: mpsc::Sender<AppEvent>,
+ render_notify: Arc<Notify>,
+ render_dirty: Arc<AtomicBool>,
+ ) -> std::io::Result<Self> {
+ let tab = Tab::new(
+ 1,
+ initial_cwd.clone(),
+ rows,
+ cols,
+ events,
+ render_notify,
+ render_dirty,
+ )?;
+ let mut public_pane_numbers = HashMap::new();
+ public_pane_numbers.insert(tab.root_pane, 1);
+ info!(root_pane = tab.root_pane.raw(), "workspace created");
+ Ok(Self {
+ custom_name: None,
+ identity_cwd: initial_cwd,
+ cached_git_ahead_behind: None,
+ public_pane_numbers,
+ next_public_pane_number: 2,
+ tabs: vec![tab],
+ active_tab: 0,
+ })
+ }
+
+ pub fn active_tab(&self) -> Option<&Tab> {
+ self.tabs.get(self.active_tab)
+ }
+
+ pub fn active_tab_mut(&mut self) -> Option<&mut Tab> {
+ self.tabs.get_mut(self.active_tab)
+ }
+
+ pub fn active_tab_display_name(&self) -> Option<String> {
+ self.active_tab().map(Tab::display_name)
+ }
+
+ pub fn switch_tab(&mut self, idx: usize) {
+ if idx < self.tabs.len() {
+ self.active_tab = idx;
+ if let Some(tab) = self.tabs.get_mut(idx) {
+ for pane in tab.panes.values_mut() {
+ pane.seen = true;
+ }
+ }
+ }
+ }
+
+ pub fn create_tab(&mut self, rows: u16, cols: u16, cwd: PathBuf) -> std::io::Result<usize> {
+ let number = self.tabs.len() + 1;
+ let events = self
+ .active_tab()
+ .map(|tab| tab.events.clone())
+ .expect("workspace must always have at least one tab");
+ let render_notify = self
+ .active_tab()
+ .map(|tab| tab.render_notify.clone())
+ .expect("workspace must always have at least one tab");
+ let render_dirty = self
+ .active_tab()
+ .map(|tab| tab.render_dirty.clone())
+ .expect("workspace must always have at least one tab");
+
+ let tab = Tab::new(number, cwd, rows, cols, events, render_notify, render_dirty)?;
+ self.register_new_pane(tab.root_pane);
+ self.tabs.push(tab);
+ self.active_tab = self.tabs.len() - 1;
+ Ok(self.active_tab)
+ }
+
+ pub fn close_tab(&mut self, idx: usize) -> bool {
+ if self.tabs.len() <= 1 || idx >= self.tabs.len() {
+ return false;
+ }
+ let tab = self.tabs.remove(idx);
+ for pane_id in tab.panes.keys() {
+ self.unregister_pane(*pane_id);
+ }
+ self.renumber_tabs();
+ if self.active_tab >= self.tabs.len() {
+ self.active_tab = self.tabs.len() - 1;
+ } else if idx <= self.active_tab && self.active_tab > 0 {
+ self.active_tab -= 1;
+ }
+ true
+ }
+
+ pub fn close_active_tab(&mut self) -> bool {
+ self.close_tab(self.active_tab)
+ }
+
+ pub fn split_focused(
+ &mut self,
+ direction: Direction,
+ rows: u16,
+ cols: u16,
+ cwd: Option<PathBuf>,
+ ) -> std::io::Result<PaneId> {
+ let new_id = self
+ .active_tab_mut()
+ .expect("workspace must always have at least one tab")
+ .split_focused(direction, rows, cols, cwd)?;
+ self.register_new_pane(new_id);
+ Ok(new_id)
+ }
+
+ /// Close the focused pane. Returns true if the workspace should close.
+ pub fn close_focused(&mut self) -> bool {
+ let pane_count = self
+ .active_tab()
+ .map(|tab| tab.layout.pane_count())
+ .unwrap_or(0);
+ let tab_count = self.tabs.len();
+ if pane_count <= 1 {
+ return tab_count <= 1 || self.close_active_tab_and_report();
+ }
+
+ if let Some((removed, runtime)) = self.active_tab_mut().and_then(Tab::close_focused) {
+ self.unregister_pane(removed);
+ if let Some(runtime) = runtime {
+ runtime.shutdown(removed);
+ }
+ }
+ false
+ }
+
+ /// Remove a specific pane from this workspace without terminating its runtime.
+ /// Returns true if the workspace should close.
+ pub fn remove_pane(&mut self, pane_id: PaneId) -> bool {
+ let Some(tab_idx) = self.find_tab_index_for_pane(pane_id) else {
+ return false;
+ };
+ let pane_count = self.tabs[tab_idx].layout.pane_count();
+ let tab_count = self.tabs.len();
+ if pane_count <= 1 {
+ if tab_count <= 1 {
+ return true;
+ }
+ self.tabs.remove(tab_idx);
+ self.unregister_pane(pane_id);
+ self.renumber_tabs();
+ if self.active_tab >= self.tabs.len() {
+ self.active_tab = self.tabs.len() - 1;
+ } else if tab_idx <= self.active_tab && self.active_tab > 0 {
+ self.active_tab -= 1;
+ }
+ return false;
+ }
+
+ if let Some((removed, _)) = self.tabs[tab_idx].remove_pane(pane_id) {
+ self.unregister_pane(removed);
+ }
+ false
+ }
+
+ pub fn public_pane_number(&self, pane_id: PaneId) -> Option<usize> {
+ self.public_pane_numbers.get(&pane_id).copied()
+ }
+
+ 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();
+ }
+
+ derive_label_from_cwd(&self.identity_cwd)
+ }
- /// Get the git branch for this workspace's root cwd.
pub fn branch(&self) -> Option<String> {
- self.root_cwd().and_then(|cwd| git_branch(&cwd))
+ git_branch(&self.identity_cwd)
}
- /// Cached ahead/behind counts for this workspace's current branch upstream.
pub fn git_ahead_behind(&self) -> Option<(usize, usize)> {
self.cached_git_ahead_behind
}
- /// Refresh cached ahead/behind counts from the workspace's current cwd.
pub fn refresh_git_ahead_behind(&mut self) {
- self.cached_git_ahead_behind = self.root_cwd().and_then(|cwd| git_ahead_behind(&cwd));
+ self.cached_git_ahead_behind = git_ahead_behind(&self.identity_cwd);
+ }
+
+ pub fn aggregate_state(&self) -> (AgentState, bool) {
+ self.tabs
+ .iter()
+ .flat_map(|tab| tab.panes.values())
+ .map(|pane| (pane.state, pane.seen))
+ .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
+ .unwrap_or((AgentState::Unknown, true))
+ }
+
+ pub fn has_working_pane(&self) -> bool {
+ self.tabs.iter().any(Tab::has_working_pane)
+ }
+
+ pub fn pane_details(&self) -> Vec<PaneDetail> {
+ self.active_tab().map(Tab::pane_details).unwrap_or_default()
+ }
+
+ pub fn focused_runtime(&self) -> Option<&PaneRuntime> {
+ self.active_tab().and_then(Tab::focused_runtime)
+ }
+
+ pub fn find_tab_index_for_pane(&self, pane_id: PaneId) -> Option<usize> {
+ self.tabs
+ .iter()
+ .position(|tab| tab.panes.contains_key(&pane_id))
+ }
+
+ pub fn pane_state(&self, pane_id: PaneId) -> Option<&PaneState> {
+ self.tabs.iter().find_map(|tab| tab.panes.get(&pane_id))
+ }
+
+ pub fn runtime(&self, pane_id: PaneId) -> Option<&PaneRuntime> {
+ self.tabs.iter().find_map(|tab| tab.runtimes.get(&pane_id))
+ }
+
+ pub fn focused_pane_id(&self) -> Option<PaneId> {
+ self.active_tab().map(|tab| tab.layout.focused())
+ }
+
+ pub fn close_pane(&mut self, pane_id: PaneId) -> bool {
+ let tab_idx = match self.find_tab_index_for_pane(pane_id) {
+ Some(idx) => idx,
+ None => return false,
+ };
+ let pane_count = self.tabs[tab_idx].layout.pane_count();
+ let tab_count = self.tabs.len();
+ if pane_count <= 1 {
+ if tab_count <= 1 {
+ return true;
+ }
+ self.tabs.remove(tab_idx);
+ self.unregister_pane(pane_id);
+ self.renumber_tabs();
+ if self.active_tab >= self.tabs.len() {
+ self.active_tab = self.tabs.len() - 1;
+ } else if tab_idx <= self.active_tab && self.active_tab > 0 {
+ self.active_tab -= 1;
+ }
+ return false;
+ }
+
+ if let Some((removed, runtime)) = self.tabs[tab_idx].close_pane(pane_id) {
+ self.unregister_pane(removed);
+ if let Some(runtime) = runtime {
+ runtime.shutdown(removed);
+ }
+ }
+ false
+ }
+
+ fn register_new_pane(&mut self, pane_id: PaneId) {
+ self.public_pane_numbers
+ .insert(pane_id, self.next_public_pane_number);
+ self.next_public_pane_number += 1;
+ }
+
+ fn unregister_pane(&mut self, pane_id: PaneId) {
+ if let Some(removed_number) = self.public_pane_numbers.remove(&pane_id) {
+ for number in self.public_pane_numbers.values_mut() {
+ if *number > removed_number {
+ *number -= 1;
+ }
+ }
+ self.next_public_pane_number = self.public_pane_numbers.len() + 1;
+ }
+ }
+
+ fn renumber_tabs(&mut self) {
+ for (idx, tab) in self.tabs.iter_mut().enumerate() {
+ tab.number = idx + 1;
+ }
+ }
+
+ fn close_active_tab_and_report(&mut self) -> bool {
+ if self.tabs.len() <= 1 {
+ return true;
+ }
+ self.close_active_tab();
+ false
}
}
/// Detail info for a single pane, used by the agent detail panel.
pub struct PaneDetail {
pub label: String,
- /// The detected agent, if any. Will be used for context extraction.
- #[allow(dead_code)] // used later for triage line extraction
+ #[allow(dead_code)]
pub agent: Option<Agent>,
pub state: AgentState,
pub seen: bool,
@@ -291,7 +542,7 @@ pub struct PaneDetail {
fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
match (state, seen) {
(AgentState::Blocked, _) => 4,
- (AgentState::Idle, false) => 3, // done, waiting for you to look
+ (AgentState::Idle, false) => 3,
(AgentState::Working, _) => 2,
(AgentState::Idle, true) => 1,
(AgentState::Unknown, _) => 0,
@@ -314,7 +565,7 @@ fn agent_name(agent: Agent) -> &'static str {
}
}
-fn derive_label_from_cwd(cwd: &Path) -> String {
+pub 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();
@@ -335,8 +586,6 @@ fn derive_label_from_cwd(cwd: &Path) -> String {
.unwrap_or_else(|| cwd.display().to_string())
}
-/// Read the current git branch name from .git/HEAD.
-/// Returns None if not in a git repo or HEAD is detached.
pub fn git_branch(cwd: &Path) -> Option<String> {
let repo_root = git_repo_root(cwd)?;
let head_path = repo_root.join(".git").join("HEAD");
@@ -364,7 +613,6 @@ fn git_repo_root(start: &Path) -> Option<PathBuf> {
}
}
-/// Read ahead/behind counts relative to the current branch upstream.
fn git_ahead_behind(cwd: &Path) -> Option<(usize, usize)> {
git_repo_root(cwd)?;
@@ -390,45 +638,46 @@ fn parse_git_ahead_behind_output(stdout: &str) -> Option<(usize, usize)> {
Some((ahead, behind))
}
-// ---------------------------------------------------------------------------
-// Test helpers — construct workspaces without PTYs
-// ---------------------------------------------------------------------------
-
#[cfg(test)]
impl Workspace {
- /// Create a test workspace with one pane, no PTY runtime.
pub fn test_new(name: &str) -> Self {
let (events, _) = mpsc::channel(64);
- let (layout, root_id) = TileLayout::new();
let render_notify = Arc::new(Notify::new());
let render_dirty = Arc::new(AtomicBool::new(false));
+ let identity_cwd = std::env::current_dir().unwrap_or_else(|_| "/".into());
+ let (layout, root_id) = TileLayout::new();
let mut panes = HashMap::new();
panes.insert(root_id, PaneState::new());
- let mut public_pane_numbers = HashMap::new();
- public_pane_numbers.insert(root_id, 1);
- Self {
- custom_name: Some(name.to_string()),
+ let tab = Tab {
+ custom_name: None,
+ number: 1,
root_pane: root_id,
layout,
- cached_git_ahead_behind: None,
- public_pane_numbers,
- next_public_pane_number: 2,
panes,
runtimes: HashMap::new(),
zoomed: false,
events,
render_notify,
render_dirty,
+ };
+ let mut public_pane_numbers = HashMap::new();
+ public_pane_numbers.insert(tab.root_pane, 1);
+ Self {
+ custom_name: Some(name.to_string()),
+ identity_cwd,
+ cached_git_ahead_behind: None,
+ public_pane_numbers,
+ next_public_pane_number: 2,
+ tabs: vec![tab],
+ active_tab: 0,
}
}
- /// Add a test pane (splits focused, no PTY runtime).
pub fn test_split(&mut self, direction: Direction) -> PaneId {
- let new_id = self.layout.split_focused(direction);
- self.panes.insert(new_id, PaneState::new());
- self.public_pane_numbers
- .insert(new_id, self.next_public_pane_number);
- self.next_public_pane_number += 1;
+ let tab = self.active_tab_mut().expect("workspace must have tab");
+ let new_id = tab.layout.split_focused(direction);
+ tab.panes.insert(new_id, PaneState::new());
+ self.register_new_pane(new_id);
new_id
}
}
@@ -450,10 +699,14 @@ mod tests {
fn aggregate_state_priority() {
let mut ws = Workspace::test_new("test");
let id2 = ws.test_split(Direction::Horizontal);
-
- 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::Working;
+ let root_id = ws.tabs[0]
+ .panes
+ .keys()
+ .find(|id| **id != id2)
+ .copied()
+ .unwrap();
+ ws.tabs[0].panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
+ ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
let (state, seen) = ws.aggregate_state();
assert_eq!(state, AgentState::Working);
@@ -464,104 +717,19 @@ mod tests {
fn aggregate_state_done_unseen_beats_working() {
let mut ws = Workspace::test_new("test");
let id2 = ws.test_split(Direction::Horizontal);
-
- let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
- let root = ws.panes.get_mut(&root_id).unwrap();
+ let root_id = ws.tabs[0]
+ .panes
+ .keys()
+ .find(|id| **id != id2)
+ .copied()
+ .unwrap();
+ let root = ws.tabs[0].panes.get_mut(&root_id).unwrap();
root.state = AgentState::Idle;
root.seen = false;
- ws.panes.get_mut(&id2).unwrap().state = AgentState::Working;
+ ws.tabs[0].panes.get_mut(&id2).unwrap().state = AgentState::Working;
let (state, seen) = ws.aggregate_state();
assert_eq!(state, AgentState::Idle);
assert!(!seen);
}
-
- #[test]
- fn aggregate_state_blocked_beats_done_unseen() {
- let mut ws = Workspace::test_new("test");
- let id2 = ws.test_split(Direction::Horizontal);
-
- let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
- let root = ws.panes.get_mut(&root_id).unwrap();
- root.state = AgentState::Idle;
- root.seen = false;
- ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
-
- let (state, seen) = ws.aggregate_state();
- assert_eq!(state, AgentState::Blocked);
- assert!(seen);
- }
-
- #[test]
- fn close_focused_removes_pane() {
- let mut ws = Workspace::test_new("test");
- let _id2 = ws.test_split(Direction::Horizontal);
- assert_eq!(ws.panes.len(), 2);
-
- let closed = ws.close_focused();
- assert!(closed.is_some());
- assert_eq!(ws.panes.len(), 1);
- }
-
- #[test]
- fn close_focused_last_pane_returns_none() {
- let mut ws = Workspace::test_new("test");
- assert_eq!(ws.panes.len(), 1);
-
- let closed = ws.close_focused();
- assert!(closed.is_none());
- assert_eq!(ws.panes.len(), 1);
- }
-
- #[test]
- fn pane_states_matches_layout_order() {
- let mut ws = Workspace::test_new("test");
- let id2 = ws.test_split(Direction::Horizontal);
-
- ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
-
- let states = ws.pane_states();
- assert_eq!(states.len(), 2);
- assert_eq!(states[0].0, AgentState::Unknown);
- assert_eq!(states[1].0, AgentState::Blocked);
- }
-
- #[test]
- fn pane_details_stay_in_layout_order() {
- let mut ws = Workspace::test_new("test");
- let id2 = ws.test_split(Direction::Horizontal);
-
- let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
- ws.panes.get_mut(&root_id).unwrap().detected_agent = Some(Agent::Pi);
- ws.panes.get_mut(&root_id).unwrap().state = AgentState::Working;
- ws.panes.get_mut(&id2).unwrap().detected_agent = Some(Agent::Claude);
- ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
-
- let details = ws.pane_details();
- assert_eq!(details.len(), 2);
- assert_eq!(details[0].label, "pi");
- assert_eq!(details[0].state, AgentState::Working);
- assert_eq!(details[1].label, "claude");
- assert_eq!(details[1].state, AgentState::Blocked);
- }
-
- #[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);
- }
-
- #[test]
- fn parse_git_ahead_behind_output_maps_first_field_to_ahead() {
- assert_eq!(parse_git_ahead_behind_output("7\t0\n"), Some((7, 0)));
- }
-
- #[test]
- fn parse_git_ahead_behind_output_maps_second_field_to_behind() {
- assert_eq!(parse_git_ahead_behind_output("0 3\n"), Some((0, 3)));
- }
}