feat: add tabs within workspaces

This commit is contained in:
Ogulcan Celik 2026-04-02 05:10:13 +03:00
parent 28fb23cdaf
commit b695adee60
11 changed files with 1294 additions and 548 deletions

View File

@ -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(),
}
}
} => {
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;
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,
})
}
// Mark unseen when transitioning to Idle in background
if state == AgentState::Idle
&& prev_state != AgentState::Idle
&& !is_active_ws
{
pane.seen = false;
}
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;
};
// 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 change.state == AgentState::Idle
&& change.previous_state != AgentState::Idle
&& !is_active_ws
{
pane.seen = false;
}
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),
});
}
}
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),
});
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);
}
}
}
@ -540,94 +559,6 @@ mod tests {
assert!(!pane.seen);
}
#[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!(

View File

@ -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");
}

View File

@ -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),
}
}

View File

@ -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()),

View File

@ -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",
@ -365,6 +409,13 @@ impl Config {
(KeyCode::Char('d'), KeyModifiers::empty()),
&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,
@ -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") {

View File

@ -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(());
}

View File

@ -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)]
@ -34,15 +34,23 @@ pub struct SessionSnapshot {
#[derive(Serialize, Deserialize)]
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);
}
}

View File

@ -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);
}

215
src/ui.rs
View File

@ -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

View File

@ -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()
}
}
/// 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))
/// 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)
}
pub fn branch(&self) -> Option<String> {
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)));
}
}

View File

@ -132,7 +132,7 @@ fn workspace_and_pane_management_commands_work() {
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let listed = run_cli(&socket_path, &["workspace", "list"]);
@ -206,7 +206,7 @@ fn pane_run_read_and_wait_commands_work() {
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = send_request(
@ -272,7 +272,7 @@ fn closing_pane_terminates_processes_inside_it() {
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
@ -335,7 +335,7 @@ fn closing_workspace_terminates_processes_inside_it() {
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let created = run_cli(
@ -390,7 +390,7 @@ fn ids_are_compact_and_positional() {
let runtime_dir = base.join("runtime");
let socket_path = runtime_dir.join("herdr.sock");
let mut herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
let herdr = spawn_herdr(&config_home, &runtime_dir, &socket_path);
wait_for_socket(&socket_path, Duration::from_secs(5));
let ws1 = run_cli(
@ -577,7 +577,7 @@ fn wait_agent_state_exits_when_state_matches() {
),
);
let child = pair.slave.spawn_command(cmd).unwrap();
let mut herdr = SpawnedHerdr {
let herdr = SpawnedHerdr {
_master: pair.master,
child,
};