aboutsummaryrefslogtreecommitdiff
path: root/src/app
diff options
context:
space:
mode:
Diffstat (limited to 'src/app')
-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
4 files changed, 504 insertions, 262 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()),