aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-01 15:48:22 +0300
committerGitHub <noreply@github.com>2026-09-01 15:48:22 +0300
commit207be3c771d281baae6e5fa0fb74be9a056e97a2 (patch)
treed7b037ecdf629224a048c841d11659efdc23507d /src/app/actions.rs
parent8a6d697308162874a236c84aac0b0f8c7ac01f83 (diff)
refactor: render the shell in the client (#3487)
* feat: add opt-in client-rendered shell * feat: add client-owned shell mouse controls * feat: complete client-owned shell chrome * feat: add client-owned shell settings and notifications * feat: run client shell custom commands * feat: render popup terminals in client shell * feat: add client-owned scrollback and copy mode * fix: preserve client worktree trust defaults * feat: render startup diagnostics in client shell * feat: render onboarding in client shell * feat: render product announcements in client shell * feat: render release notes in client shell * feat: render mobile switcher in client shell * feat: render kitty graphics in client shell * fix: preserve client shell lifecycle coherence * refactor: route client shell commands through endpoint connections * refactor: make the client-rendered shell the default * refactor: remove monolithic launch mode * refactor: complete client-rendered shell cutover * perf: retain client pane surface updates * perf: scale retained pane surface updates * perf: remove remote pane input latency * test: make client shell checks platform-independent * fix: prevent client timer starvation * test: fix client shell platform gates * test: accept retained surfaces in cross-area checks * test: make client mode assertions platform-neutral * fix: badge only outdated integrations
Diffstat (limited to 'src/app/actions.rs')
-rw-r--r--src/app/actions.rs2165
1 files changed, 94 insertions, 2071 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index 07f7278b..85839322 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -3,7 +3,7 @@
use std::time::Instant;
-use tracing::{info, warn};
+use tracing::warn;
use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
@@ -16,10 +16,8 @@ use crate::workspace::WorkspaceGitStatus;
use super::api_helpers::pane_agent_status;
use super::state::{
- navigator_display_index_of_row, navigator_display_lines, navigator_first_row_at_or_after,
- text_matches_query, AgentNotificationDelivery, AppState, Mode, NavigatorRow,
- NavigatorStateFilter, NavigatorTarget, PaneFocusTarget, PendingAgentNotification, ToastKind,
- ToastNotification, ToastTarget, ViewLayout,
+ AgentNotificationDelivery, AppState, Mode, PaneFocusTarget, PendingAgentNotification,
+ ToastKind, ToastNotification, ToastTarget,
};
fn is_background_completion_transition(prev_state: AgentState, new_state: AgentState) -> bool {
@@ -256,7 +254,7 @@ pub struct PaneStateUpdate {
}
// ---------------------------------------------------------------------------
-// Navigator operations
+// Focus tracking
// ---------------------------------------------------------------------------
impl AppState {
@@ -270,18 +268,6 @@ impl AppState {
})
}
- pub(crate) fn pane_focus_target_indices(
- &self,
- target: &PaneFocusTarget,
- ) -> Option<(usize, usize)> {
- let ws_idx = self
- .workspaces
- .iter()
- .position(|ws| ws.id == target.workspace_id)?;
- let tab_idx = self.workspaces[ws_idx].find_tab_index_for_pane(target.pane_id)?;
- Some((ws_idx, tab_idx))
- }
-
pub(crate) fn record_pane_focus_change(
&mut self,
previous: Option<PaneFocusTarget>,
@@ -307,14 +293,6 @@ impl AppState {
}
}
- fn sync_selection_after_focus_navigation(&mut self) {
- if self.copy_mode.is_some() {
- self.sync_copy_mode_with_focus();
- } else {
- self.clear_selection();
- }
- }
-
pub(crate) fn focus_pane_in_workspace(&mut self, ws_idx: usize, pane_id: PaneId) -> bool {
let Some(ws) = self.workspaces.get(ws_idx) else {
return false;
@@ -331,9 +309,6 @@ impl AppState {
return false;
}
- if self.copy_mode.is_some() {
- self.clear_copy_mode_selection();
- }
self.switch_workspace_tab(ws_idx, tab_idx);
if let Some(tab) = self
.workspaces
@@ -343,649 +318,10 @@ impl AppState {
tab.layout.focus_pane(pane_id);
self.previous_pane_focus = previous;
self.mark_session_dirty();
- self.sync_copy_mode_with_focus();
return true;
}
false
}
-
- #[cfg(test)]
- pub(crate) fn open_navigator(&mut self) {
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- self.open_navigator_from(&terminal_runtimes);
- }
-
- pub(crate) fn open_navigator_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) {
- self.navigator.query.clear();
- self.navigator.search_focused = false;
- self.navigator.state_filter = None;
- self.navigator.scroll = 0;
- self.navigator.expanded_workspaces.clear();
-
- for ws in &self.workspaces {
- self.navigator.expanded_workspaces.insert(ws.id.clone());
- }
-
- self.mode = Mode::Navigator;
- self.navigator.selected = self
- .current_navigator_row_index_from(terminal_runtimes)
- .unwrap_or(0);
- self.ensure_navigator_selection_visible_from(terminal_runtimes);
- }
-
- #[cfg(test)]
- pub(crate) fn navigator_rows(&self) -> Vec<NavigatorRow> {
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- self.navigator_rows_from(&terminal_runtimes)
- }
-
- pub(crate) fn navigator_rows_from(
- &self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) -> Vec<NavigatorRow> {
- let query = self.navigator.query.trim().to_lowercase();
- let query_kind = navigator_query_kind(&query, self.navigator.state_filter);
- let mut rows = Vec::new();
- for (ws_idx, ws) in self.workspaces.iter().enumerate() {
- let workspace_label = ws.display_name_from(&self.terminals, terminal_runtimes);
- let activity = workspace_activity_summary(ws, &self.terminals);
- let workspace_search_text = format!("{workspace_label} {activity}").to_lowercase();
- let workspace_matches = match query_kind {
- NavigatorQueryKind::Empty => true,
- NavigatorQueryKind::State(filter) => {
- let (state, seen) = ws.aggregate_state(&self.terminals);
- navigator_state_filter_matches(filter, state, seen)
- }
- NavigatorQueryKind::Text => navigator_matches(&query, &workspace_search_text),
- };
-
- let child_rows =
- self.navigator_child_rows(ws_idx, query_kind, &query, workspace_matches);
- if !workspace_matches && child_rows.is_empty() {
- continue;
- }
-
- let expanded = !matches!(query_kind, NavigatorQueryKind::Empty)
- || self.navigator.expanded_workspaces.contains(&ws.id);
- let (state, seen) = ws.aggregate_state(&self.terminals);
- let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>();
- rows.push(NavigatorRow {
- target: NavigatorTarget::Workspace { ws_idx },
- depth: 0,
- label: format!("{workspace_label} ({pane_count})"),
- meta: activity,
- status: state,
- seen,
- is_current: self.active == Some(ws_idx),
- is_workspace: true,
- is_tab: false,
- expanded,
- search_text: workspace_search_text,
- matched: workspace_matches,
- });
- if expanded {
- rows.extend(child_rows);
- }
- }
- rows
- }
-
- fn navigator_child_rows(
- &self,
- ws_idx: usize,
- query_kind: NavigatorQueryKind,
- query: &str,
- workspace_matches: bool,
- ) -> Vec<NavigatorRow> {
- let Some(ws) = self.workspaces.get(ws_idx) else {
- return Vec::new();
- };
- let multi_tab = ws.tabs.len() > 1;
- let mut rows = Vec::new();
- for tab_idx in 0..ws.tabs.len() {
- let mut tab_row = self.navigator_tab_row(ws_idx, tab_idx);
- let tab_matches = match query_kind {
- NavigatorQueryKind::Empty => true,
- NavigatorQueryKind::State(filter) => {
- navigator_state_filter_matches(filter, tab_row.status, tab_row.seen)
- }
- NavigatorQueryKind::Text => navigator_matches(
- query,
- if multi_tab {
- &tab_row.search_text
- } else {
- &tab_row.label
- },
- ),
- };
- tab_row.matched = tab_matches;
- let show_tab_row =
- multi_tab || (matches!(query_kind, NavigatorQueryKind::Text) && tab_matches);
- let mut pane_rows = self.navigator_pane_rows_for_tab(ws_idx, tab_idx, show_tab_row);
- let filtered_panes = match query_kind {
- NavigatorQueryKind::Empty => pane_rows,
- NavigatorQueryKind::State(filter) => pane_rows
- .into_iter()
- .filter(|row| navigator_state_filter_matches(filter, row.status, row.seen))
- .collect::<Vec<_>>(),
- // A matching workspace or tab shows its whole subtree; panes
- // keep their own match flag so context rows can be dimmed.
- NavigatorQueryKind::Text if workspace_matches || tab_matches => {
- for row in pane_rows.iter_mut() {
- row.matched = navigator_matches(query, &row.search_text);
- }
- pane_rows
- }
- NavigatorQueryKind::Text => pane_rows
- .into_iter()
- .filter(|row| navigator_matches(query, &row.search_text))
- .collect::<Vec<_>>(),
- };
-
- if show_tab_row && (tab_matches || !filtered_panes.is_empty()) {
- rows.push(tab_row);
- }
- rows.extend(filtered_panes);
- }
- rows
- }
-
- fn navigator_tab_row(&self, ws_idx: usize, tab_idx: usize) -> NavigatorRow {
- let ws = &self.workspaces[ws_idx];
- let tab = &ws.tabs[tab_idx];
- let label = ws
- .tab_display_name(tab_idx)
- .unwrap_or_else(|| (tab_idx + 1).to_string());
- let (status, seen) = tab_aggregate_state(tab, &self.terminals);
- let activity = tab_activity_summary(tab, &self.terminals);
- let pane_count = tab.panes.len();
- let meta = if activity.is_empty() {
- format!("{pane_count} panes")
- } else {
- format!("{pane_count} panes · {activity}")
- };
- let search_text = format!("{label} {meta}").to_lowercase();
- NavigatorRow {
- target: NavigatorTarget::Tab { ws_idx, tab_idx },
- depth: 1,
- label,
- meta,
- status,
- seen,
- is_current: false,
- is_workspace: false,
- is_tab: true,
- expanded: true,
- search_text,
- matched: true,
- }
- }
-
- fn navigator_pane_rows_for_tab(
- &self,
- ws_idx: usize,
- tab_idx: usize,
- show_tab_row: bool,
- ) -> Vec<NavigatorRow> {
- let Some(ws) = self.workspaces.get(ws_idx) else {
- return Vec::new();
- };
- let Some(tab) = ws.tabs.get(tab_idx) else {
- return Vec::new();
- };
- let mut rows = Vec::new();
- for pane_id in tab.layout.pane_ids() {
- let Some(pane) = tab.panes.get(&pane_id) else {
- continue;
- };
- let terminal = self.terminals.get(&pane.attached_terminal_id);
- let pane_number = ws.public_pane_number(pane_id).unwrap_or(0);
- let label = terminal
- .and_then(|terminal| terminal.effective_title())
- .or_else(|| {
- terminal
- .and_then(|terminal| terminal.manual_label.as_deref().map(str::to_string))
- })
- .or_else(|| {
- terminal.and_then(|terminal| terminal.agent_name.as_deref().map(str::to_string))
- })
- .or_else(|| {
- terminal
- .and_then(|terminal| terminal.effective_agent_label().map(str::to_string))
- })
- .or_else(|| {
- launch_label(terminal.and_then(|terminal| terminal.launch_argv.as_ref()))
- })
- .unwrap_or_else(|| format!("pane {pane_number}"));
- let display_agent = terminal.and_then(|terminal| terminal.effective_display_agent());
- let agent_label = display_agent.as_deref().or_else(|| {
- terminal
- .and_then(|terminal| terminal.agent_name.as_deref())
- .or_else(|| terminal.and_then(|terminal| terminal.effective_agent_label()))
- });
- let state = terminal
- .map(|terminal| terminal.state)
- .unwrap_or(AgentState::Unknown);
- let status_label = terminal
- .map(|terminal| terminal.effective_presentation().state_labels)
- .and_then(|labels| labels.get(state_label_text(state, pane.seen)).cloned());
- let status = status_label
- .or_else(|| agent_label.map(|_| state_label_text(state, pane.seen).to_string()));
- let meta = match (agent_label, status.as_deref()) {
- (Some(agent_label), Some(status)) => format!("{agent_label} · {status}"),
- (Some(agent_label), None) => agent_label.to_string(),
- (None, _) => "shell".to_string(),
- };
- let is_current = self.is_active_pane(ws_idx, tab_idx, pane_id);
- let search_text = format!("{label} {meta}").to_lowercase();
- rows.push(NavigatorRow {
- target: NavigatorTarget::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- },
- depth: if show_tab_row { 2 } else { 1 },
- label,
- meta,
- status: state,
- seen: pane.seen,
- is_current,
- is_workspace: false,
- is_tab: false,
- expanded: false,
- search_text,
- matched: true,
- });
- }
- rows
- }
-
- fn current_navigator_row_index_from(
- &self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) -> Option<usize> {
- let rows = self.navigator_rows_from(terminal_runtimes);
- rows.iter()
- .position(|row| matches!(row.target, NavigatorTarget::Pane { .. }) && row.is_current)
- .or_else(|| rows.iter().position(|row| row.is_current))
- }
-
- pub(crate) fn ensure_navigator_selection_visible_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) {
- let body = self.navigator_body_rect();
- let viewport = body.height as usize;
- if viewport == 0 {
- self.navigator.scroll = 0;
- return;
- }
- let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes));
- let max_scroll = lines.len().saturating_sub(viewport);
- let selected_line =
- navigator_display_index_of_row(&lines, self.navigator.selected).unwrap_or(0);
- if selected_line < self.navigator.scroll {
- self.navigator.scroll = selected_line;
- } else if selected_line >= self.navigator.scroll.saturating_add(viewport) {
- self.navigator.scroll = selected_line.saturating_add(1).saturating_sub(viewport);
- }
- self.navigator.scroll = self.navigator.scroll.min(max_scroll);
- }
-
- pub(crate) fn navigator_max_scroll_from(
- &self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- viewport: usize,
- ) -> usize {
- if viewport == 0 {
- return 0;
- }
- navigator_display_lines(&self.navigator_rows_from(terminal_runtimes))
- .len()
- .saturating_sub(viewport)
- }
-
- /// After a mouse-wheel scroll, snap the selection to the first selectable
- /// row at or below the top of the viewport.
- pub(crate) fn align_navigator_selection_to_scroll_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) {
- let lines = navigator_display_lines(&self.navigator_rows_from(terminal_runtimes));
- if let Some(row_idx) = navigator_first_row_at_or_after(&lines, self.navigator.scroll) {
- self.navigator.selected = row_idx;
- }
- self.clamp_navigator_selection_from(terminal_runtimes);
- }
-
- pub(crate) fn move_navigator_selection_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- delta: isize,
- ) {
- let count = self.navigator_rows_from(terminal_runtimes).len();
- if count == 0 {
- self.navigator.selected = 0;
- self.navigator.scroll = 0;
- return;
- }
- let current = self.navigator.selected.min(count - 1) as isize;
- self.navigator.selected = (current + delta).clamp(0, count as isize - 1) as usize;
- self.ensure_navigator_selection_visible_from(terminal_runtimes);
- }
-
- /// Move the selection by a distance measured in display lines (used for
- /// half-page jumps), landing on the nearest selectable row in the move
- /// direction.
- pub(crate) fn move_navigator_selection_by_lines_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- delta_lines: isize,
- ) {
- let rows = self.navigator_rows_from(terminal_runtimes);
- if rows.is_empty() {
- self.navigator.selected = 0;
- self.navigator.scroll = 0;
- return;
- }
- let lines = navigator_display_lines(&rows);
- let current_line =
- navigator_display_index_of_row(&lines, self.navigator.selected.min(rows.len() - 1))
- .unwrap_or(0);
- let target_line =
- (current_line as isize + delta_lines).clamp(0, lines.len() as isize - 1) as usize;
- let row_idx = if delta_lines >= 0 {
- navigator_first_row_at_or_after(&lines, target_line)
- } else {
- lines[..=target_line]
- .iter()
- .rev()
- .find_map(|line| match line {
- super::state::NavigatorDisplayLine::Row(idx) => Some(*idx),
- super::state::NavigatorDisplayLine::Spacer => None,
- })
- };
- if let Some(row_idx) = row_idx {
- self.navigator.selected = row_idx;
- }
- self.ensure_navigator_selection_visible_from(terminal_runtimes);
- }
-
- /// After the query or state filter changes, select the first row that
- /// itself matched the filter, so enter immediately accepts the best match.
- /// State filters prefer pane matches over aggregate workspace/tab matches.
- pub(crate) fn select_first_navigator_match_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) {
- let query = self.navigator.query.trim().to_lowercase();
- let query_kind = navigator_query_kind(&query, self.navigator.state_filter);
- if !matches!(query_kind, NavigatorQueryKind::Empty) {
- let rows = self.navigator_rows_from(terminal_runtimes);
- let idx = if matches!(query_kind, NavigatorQueryKind::State(_)) {
- rows.iter()
- .position(|row| {
- row.matched && matches!(row.target, NavigatorTarget::Pane { .. })
- })
- .or_else(|| rows.iter().position(|row| row.matched))
- } else {
- rows.iter().position(|row| row.matched)
- };
- if let Some(idx) = idx {
- self.navigator.selected = idx;
- }
- }
- self.clamp_navigator_selection_from(terminal_runtimes);
- }
-
- pub(crate) fn clamp_navigator_selection_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) {
- let count = self.navigator_rows_from(terminal_runtimes).len();
- self.navigator.selected = self.navigator.selected.min(count.saturating_sub(1));
- self.ensure_navigator_selection_visible_from(terminal_runtimes);
- }
-
- pub(crate) fn toggle_selected_navigator_workspace_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) {
- let Some(row) = self
- .navigator_rows_from(terminal_runtimes)
- .get(self.navigator.selected)
- .cloned()
- else {
- return;
- };
- let NavigatorTarget::Workspace { ws_idx } = row.target else {
- return;
- };
- let Some(workspace_id) = self.workspaces.get(ws_idx).map(|ws| ws.id.clone()) else {
- return;
- };
- if self.navigator.expanded_workspaces.contains(&workspace_id) {
- self.navigator.expanded_workspaces.remove(&workspace_id);
- } else {
- self.navigator.expanded_workspaces.insert(workspace_id);
- }
- self.clamp_navigator_selection_from(terminal_runtimes);
- }
-
- #[cfg(test)]
- pub(crate) fn accept_navigator_selection(&mut self) -> bool {
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- self.accept_navigator_selection_from(&terminal_runtimes)
- }
-
- pub(crate) fn accept_navigator_selection_from(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ) -> bool {
- let Some(row) = self
- .navigator_rows_from(terminal_runtimes)
- .get(self.navigator.selected)
- .cloned()
- else {
- return false;
- };
- self.focus_navigator_target(row.target)
- }
-
- pub(crate) fn focus_navigator_target(&mut self, target: NavigatorTarget) -> bool {
- match target {
- NavigatorTarget::Workspace { ws_idx } => {
- if ws_idx >= self.workspaces.len() {
- return false;
- }
- self.switch_workspace(ws_idx);
- self.mode = Mode::Terminal;
- true
- }
- NavigatorTarget::Tab { ws_idx, tab_idx } => {
- if ws_idx >= self.workspaces.len() {
- return false;
- }
- let tab_exists = self
- .workspaces
- .get(ws_idx)
- .is_some_and(|ws| tab_idx < ws.tabs.len());
- if !tab_exists {
- return false;
- }
- self.switch_workspace_tab(ws_idx, tab_idx);
- self.mode = Mode::Terminal;
- true
- }
- NavigatorTarget::Pane {
- ws_idx,
- tab_idx,
- pane_id,
- } => {
- if ws_idx >= self.workspaces.len() {
- return false;
- }
- if self
- .workspaces
- .get(ws_idx)
- .and_then(|ws| ws.tabs.get(tab_idx))
- .is_some_and(|tab| tab.panes.contains_key(&pane_id))
- {
- self.focus_pane_in_workspace(ws_idx, pane_id);
- self.mode = Mode::Terminal;
- return true;
- }
- false
- }
- }
- }
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-enum NavigatorQueryKind {
- Empty,
- Text,
- State(NavigatorStateFilter),
-}
-
-fn navigator_query_kind(
- query: &str,
- state_filter: Option<NavigatorStateFilter>,
-) -> NavigatorQueryKind {
- if let Some(filter) = state_filter {
- return NavigatorQueryKind::State(filter);
- }
- if query.is_empty() {
- NavigatorQueryKind::Empty
- } else {
- NavigatorQueryKind::Text
- }
-}
-
-fn navigator_state_filter_matches(
- filter: NavigatorStateFilter,
- state: AgentState,
- seen: bool,
-) -> bool {
- match filter {
- NavigatorStateFilter::Blocked => state == AgentState::Blocked,
- NavigatorStateFilter::Working => state == AgentState::Working,
- NavigatorStateFilter::Idle => state == AgentState::Idle && seen,
- NavigatorStateFilter::Done => state == AgentState::Idle && !seen,
- }
-}
-
-fn navigator_matches(query: &str, text: &str) -> bool {
- text_matches_query(query, text)
-}
-
-fn launch_label(argv: Option<&Vec<String>>) -> Option<String> {
- let argv = argv?;
- let command = argv.first()?;
- std::path::Path::new(command)
- .file_name()
- .and_then(|name| name.to_str())
- .map(str::to_string)
- .or_else(|| Some(command.clone()))
-}
-
-fn state_label_text(state: AgentState, seen: bool) -> &'static str {
- match (state, seen) {
- (AgentState::Blocked, _) => "blocked",
- (AgentState::Working, _) => "working",
- (AgentState::Idle, false) => "done",
- (AgentState::Idle, true) => "idle",
- (AgentState::Unknown, _) => "unknown",
- }
-}
-
-fn tab_aggregate_state(
- tab: &crate::workspace::Tab,
- terminals: &std::collections::HashMap<
- crate::terminal::TerminalId,
- crate::terminal::TerminalState,
- >,
-) -> (AgentState, bool) {
- let mut aggregate = AgentState::Unknown;
- let mut seen = true;
- for pane in tab.panes.values() {
- let Some(terminal) = terminals.get(&pane.attached_terminal_id) else {
- continue;
- };
- if state_priority(terminal.state, pane.seen) > state_priority(aggregate, seen) {
- aggregate = terminal.state;
- seen = pane.seen;
- }
- }
- (aggregate, seen)
-}
-
-fn state_priority(state: AgentState, seen: bool) -> u8 {
- match (state, seen) {
- (AgentState::Blocked, _) => 5,
- (AgentState::Working, _) => 4,
- (AgentState::Idle, false) => 3,
- (AgentState::Idle, true) => 2,
- (AgentState::Unknown, _) => 1,
- }
-}
-
-fn tab_activity_summary(
- tab: &crate::workspace::Tab,
- terminals: &std::collections::HashMap<
- crate::terminal::TerminalId,
- crate::terminal::TerminalState,
- >,
-) -> String {
- activity_summary_for_panes(tab.panes.values(), terminals)
-}
-
-fn workspace_activity_summary(
- ws: &crate::workspace::Workspace,
- terminals: &std::collections::HashMap<
- crate::terminal::TerminalId,
- crate::terminal::TerminalState,
- >,
-) -> String {
- activity_summary_for_panes(ws.tabs.iter().flat_map(|tab| tab.panes.values()), terminals)
-}
-
-fn activity_summary_for_panes<'a>(
- panes: impl Iterator<Item = &'a crate::pane::PaneState>,
- terminals: &std::collections::HashMap<
- crate::terminal::TerminalId,
- crate::terminal::TerminalState,
- >,
-) -> String {
- let mut blocked = 0usize;
- let mut working = 0usize;
- let mut done = 0usize;
- for pane in panes {
- let Some(terminal) = terminals.get(&pane.attached_terminal_id) else {
- continue;
- };
- match (terminal.state, pane.seen) {
- (AgentState::Blocked, _) => blocked += 1,
- (AgentState::Working, _) => working += 1,
- (AgentState::Idle, false) => done += 1,
- _ => {}
- }
- }
-
- let mut parts = Vec::new();
- if blocked > 0 {
- parts.push(format!("{blocked} blocked"));
- }
- if working > 0 {
- parts.push(format!("{working} working"));
- }
- if done > 0 {
- parts.push(format!("{done} done"));
- }
- parts.join(" · ")
}
// ---------------------------------------------------------------------------
@@ -1126,7 +462,6 @@ impl AppState {
let workspace_id = self.workspaces[idx].id.clone();
crate::logging::workspace_focused(&workspace_id);
self.mark_session_dirty();
- self.ensure_workspace_visible(idx);
if let Some(ws) = self.workspaces.get_mut(idx) {
let active_tab = ws.active_tab;
ws.switch_tab(active_tab);
@@ -1134,10 +469,7 @@ impl AppState {
public_tab_id_for_index(ws, active_tab).unwrap_or_else(|| workspace_id.clone());
crate::logging::tab_focused(&workspace_id, &tab_id);
}
- self.tab_scroll_follow_active = true;
- self.refresh_tab_bar_view();
self.record_pane_focus_after_navigation(previous_focus);
- self.sync_selection_after_focus_navigation();
}
}
@@ -1162,99 +494,16 @@ impl AppState {
crate::logging::workspace_focused(&workspace_id);
}
self.mark_session_dirty();
- self.ensure_workspace_visible(ws_idx);
if let Some(ws) = self.workspaces.get_mut(ws_idx) {
ws.switch_tab(tab_idx);
let tab_id =
public_tab_id_for_index(ws, tab_idx).unwrap_or_else(|| workspace_id.clone());
crate::logging::tab_focused(&workspace_id, &tab_id);
}
- self.tab_scroll_follow_active = true;
- self.refresh_tab_bar_view();
self.record_pane_focus_after_navigation(previous_focus);
- self.sync_selection_after_focus_navigation();
true
}
- pub(crate) fn ensure_workspace_visible(&mut self, idx: usize) {
- if idx >= self.workspaces.len() {
- return;
- }
-
- if self.view.layout == ViewLayout::Mobile && self.mode == Mode::Navigate {
- self.ensure_mobile_workspace_visible(idx);
- return;
- }
-
- if self.sidebar_collapsed {
- return;
- }
-
- let entries = crate::ui::workspace_list_entries(self);
- let Some(target_entry_idx) = entries.iter().position(|entry| {
- matches!(
- entry,
- crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } if *ws_idx == idx
- )
- }) else {
- return;
- };
-
- self.workspace_scroll = crate::ui::normalized_workspace_scroll(
- self,
- self.view.sidebar_rect,
- self.workspace_scroll,
- );
- let mut cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
- if cards.iter().any(|card| card.ws_idx == idx) {
- return;
- }
-
- if target_entry_idx < self.workspace_scroll {
- self.workspace_scroll = target_entry_idx;
- return;
- }
-
- while !cards.iter().any(|card| card.ws_idx == idx) {
- let previous_scroll = self.workspace_scroll;
- self.workspace_scroll = self.workspace_scroll.saturating_add(1);
- if self.workspace_scroll == previous_scroll {
- break;
- }
- self.workspace_scroll = crate::ui::normalized_workspace_scroll(
- self,
- self.view.sidebar_rect,
- self.workspace_scroll,
- );
- if self.workspace_scroll == previous_scroll {
- break;
- }
- cards = crate::ui::compute_workspace_card_areas(self, self.view.sidebar_rect);
- if cards.is_empty() {
- break;
- }
- }
- }
-
- fn ensure_mobile_workspace_visible(&mut self, idx: usize) {
- let viewport = crate::ui::mobile_switcher_areas(self).viewport;
- if viewport.height == 0 {
- return;
- }
-
- let row_range = crate::ui::mobile_switcher_workspace_doc_range(self, idx);
- let visible_start = self.mobile_switcher_scroll;
- let visible_end = visible_start.saturating_add(viewport.height as usize);
- if row_range.start < visible_start {
- self.mobile_switcher_scroll = row_range.start;
- } else if row_range.end > visible_end {
- self.mobile_switcher_scroll = row_range.end.saturating_sub(viewport.height as usize);
- }
- self.mobile_switcher_scroll = self
- .mobile_switcher_scroll
- .min(crate::ui::mobile_switcher_max_scroll(self));
- }
-
#[cfg(test)]
pub fn switch_tab(&mut self, idx: usize) {
if let Some(ws_idx) = self.active {
@@ -1267,10 +516,7 @@ impl AppState {
let tab_id = public_tab_id_for_index(ws, idx).unwrap_or_else(|| workspace_id.clone());
crate::logging::tab_focused(&workspace_id, &tab_id);
self.mark_session_dirty();
- self.tab_scroll_follow_active = true;
- self.refresh_tab_bar_view();
self.record_pane_focus_after_navigation(previous_focus);
- self.sync_selection_after_focus_navigation();
}
}
@@ -1296,77 +542,6 @@ impl AppState {
changed
}
- pub(crate) fn visible_workspace_order(&self) -> Vec<usize> {
- // Mobile always shows the worktree tree expanded, so its visible order
- // must ignore collapse state to match what the switcher renders.
- let entries = if self.view.layout == ViewLayout::Mobile {
- crate::ui::workspace_list_entries_expanded(self)
- } else {
- crate::ui::workspace_list_entries(self)
- };
- let order = entries
- .into_iter()
- .map(|entry| match entry {
- crate::ui::WorkspaceListEntry::Workspace { ws_idx, .. } => ws_idx,
- })
- .collect::<Vec<_>>();
- if order.is_empty() {
- (0..self.workspaces.len()).collect()
- } else {
- order
- }
- }
-
- pub(crate) fn workspace_at_visible_position(&self, position: usize) -> Option<usize> {
- self.visible_workspace_order().get(position).copied()
- }
-
- pub(crate) fn move_selected_workspace_by_visible_delta(&mut self, delta: isize) {
- if self.workspaces.is_empty() {
- return;
- }
- let order = self.visible_workspace_order();
- let current_pos = order
- .iter()
- .position(|idx| *idx == self.selected)
- .unwrap_or(0);
- let target_pos = current_pos
- .saturating_add_signed(delta)
- .min(order.len().saturating_sub(1));
- if let Some(ws_idx) = order.get(target_pos).copied() {
- self.selected = ws_idx;
- self.ensure_workspace_visible(ws_idx);
- }
- }
-
- #[cfg(test)]
- pub fn next_workspace(&mut self) {
- if self.workspaces.is_empty() {
- return;
- }
- let current = self.active.unwrap_or(self.selected);
- let order = self.visible_workspace_order();
- let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0);
- let next = order[(current_pos + 1) % order.len()];
- self.switch_workspace(next);
- }
-
- #[cfg(test)]
- pub fn previous_workspace(&mut self) {
- if self.workspaces.is_empty() {
- return;
- }
- let current = self.active.unwrap_or(self.selected);
- let order = self.visible_workspace_order();
- let current_pos = order.iter().position(|idx| *idx == current).unwrap_or(0);
- let prev = if current_pos == 0 {
- order[order.len() - 1]
- } else {
- order[current_pos - 1]
- };
- self.switch_workspace(prev);
- }
-
pub fn move_workspace(&mut self, source_idx: usize, insert_idx: usize) -> bool {
if source_idx >= self.workspaces.len() || insert_idx > self.workspaces.len() {
return false;
@@ -1396,7 +571,6 @@ impl AppState {
self.selected = selected_id
.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
.unwrap_or(0);
- self.ensure_workspace_visible(self.selected);
true
}
@@ -1463,119 +637,9 @@ impl AppState {
self.selected = selected_id
.and_then(|id| self.workspaces.iter().position(|ws| ws.id == id))
.unwrap_or(0);
- self.ensure_workspace_visible(self.selected);
true
}
- pub fn scroll_tabs_left(&mut self) {
- self.tab_scroll_follow_active = false;
- self.tab_scroll = self.tab_scroll.saturating_sub(1);
- self.refresh_tab_bar_view();
- }
-
- pub fn scroll_tabs_right(&mut self) {
- self.tab_scroll_follow_active = false;
- self.tab_scroll = self.tab_scroll.saturating_add(1);
- self.refresh_tab_bar_view();
- }
-
- #[cfg(test)]
- pub fn next_tab(&mut self) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if !ws.tabs.is_empty() {
- let next = (ws.active_tab + 1) % ws.tabs.len();
- self.switch_tab(next);
- }
- }
- }
-
- #[cfg(test)]
- pub fn previous_tab(&mut self) {
- if let Some(ws) = self.active.and_then(|i| self.workspaces.get(i)) {
- if !ws.tabs.is_empty() {
- let prev = if ws.active_tab == 0 {
- ws.tabs.len() - 1
- } else {
- ws.active_tab - 1
- };
- self.switch_tab(prev);
- }
- }
- }
-
- #[cfg(test)]
- pub fn next_agent(&mut self) {
- self.cycle_agent_entry(true);
- }
-
- #[cfg(test)]
- pub fn previous_agent(&mut self) {
- self.cycle_agent_entry(false);
- }
-
- #[cfg(test)]
- pub fn focus_agent_entry(&mut self, idx: usize) -> bool {
- let entries = crate::ui::agent_panel_entries(self);
- let Some(target) = entries.get(idx) else {
- return false;
- };
- let ws_idx = target.ws_idx;
- let pane_id = target.pane_id;
-
- if self.active == Some(ws_idx) && self.workspaces[ws_idx].focused_pane_id() == Some(pane_id)
- {
- self.ensure_agent_panel_entry_visible(idx);
- return true;
- }
-
- if self.focus_pane_in_workspace(ws_idx, pane_id) {
- self.ensure_agent_panel_entry_visible(idx);
- return true;
- }
- false
- }
-
- #[cfg(test)]
- fn cycle_agent_entry(&mut self, forward: bool) {
- let entries = crate::ui::agent_panel_entries(self);
- if entries.is_empty() {
- return;
- }
-
- let focused = self
- .active
- .and_then(|idx| self.workspaces.get(idx))
- .and_then(crate::workspace::Workspace::focused_pane_id);
- let current_idx =
- focused.and_then(|pane_id| entries.iter().position(|entry| entry.pane_id == pane_id));
- let target_idx = match (current_idx, forward) {
- (Some(idx), true) => (idx + 1) % entries.len(),
- (Some(0), false) => entries.len() - 1,
- (Some(idx), false) => idx - 1,
- (None, true) => 0,
- (None, false) => entries.len() - 1,
- };
-
- self.focus_agent_entry(target_idx);
- }
-
- pub(crate) fn ensure_agent_panel_entry_visible(&mut self, idx: usize) {
- if self.sidebar_collapsed {
- return;
- }
-
- let (_, detail_area) = crate::ui::expanded_sidebar_sections(
- self.view.sidebar_rect,
- self.sidebar_section_split,
- );
- self.agent_panel_scroll = crate::ui::agent_panel_scroll_for_target(
- self,
- detail_area,
- self.agent_panel_scroll,
- idx,
- );
- }
-
pub(crate) fn terminal_ids_for_workspace(
&self,
ws_idx: usize,
@@ -1657,7 +721,6 @@ impl AppState {
pane_ids: impl IntoIterator<Item = PaneId>,
) {
let pane_ids = pane_ids.into_iter().collect::<Vec<_>>();
- self.clear_copy_mode_for_removed_panes(pane_ids.iter().copied());
if self
.previous_pane_focus
.as_ref()
@@ -1674,8 +737,6 @@ impl AppState {
if self.workspaces.is_empty() {
return;
}
- self.selection = None;
- self.selection_autoscroll = None;
self.mark_session_dirty();
let close_indices = self.workspace_close_indices(self.selected);
@@ -1700,9 +761,6 @@ impl AppState {
if self.workspaces.is_empty() {
self.active = None;
self.selected = 0;
- self.workspace_scroll = 0;
- self.tab_scroll = 0;
- self.tab_scroll_follow_active = true;
} else {
// Keep focus on the previously focused workspace
if let Some(id) = active_workspace_id {
@@ -1714,39 +772,8 @@ impl AppState {
self.selected = self.workspaces.len() - 1;
}
self.active = Some(self.selected);
- self.workspace_scroll = self
- .workspace_scroll
- .min(self.workspaces.len().saturating_sub(1));
- self.ensure_workspace_visible(self.selected);
- self.tab_scroll_follow_active = true;
- self.refresh_tab_bar_view();
}
}
-
- pub(crate) fn refresh_tab_bar_view(&mut self) {
- let area = self.view.tab_bar_rect;
- let Some(ws) = self.active.and_then(|idx| self.workspaces.get(idx)) else {
- self.tab_scroll = 0;
- self.view.tab_hit_areas.clear();
- self.view.tab_scroll_left_hit_area = ratatui::layout::Rect::default();
- self.view.tab_scroll_right_hit_area = ratatui::layout::Rect::default();
- self.view.new_tab_hit_area = ratatui::layout::Rect::default();
- return;
- };
-
- let layout = crate::ui::compute_tab_bar_view(
- ws,
- crate::ui::tab_bar_content_area(self, area),
- self.tab_scroll,
- self.tab_scroll_follow_active,
- self.mouse_capture,
- );
- self.tab_scroll = layout.scroll;
- self.view.tab_hit_areas = layout.tab_hit_areas;
- self.view.tab_scroll_left_hit_area = layout.scroll_left_hit_area;
- self.view.tab_scroll_right_hit_area = layout.scroll_right_hit_area;
- self.view.new_tab_hit_area = layout.new_tab_hit_area;
- }
}
// ---------------------------------------------------------------------------
@@ -1852,52 +879,6 @@ impl AppState {
}
}
- #[cfg(test)]
- pub fn cycle_pane(&mut self, reverse: bool) {
- let Some(ws_idx) = self.active else {
- return;
- };
- let Some(tab) = self.workspaces.get(ws_idx).and_then(|ws| ws.active_tab()) else {
- return;
- };
- let ids = tab.layout.pane_ids();
- if let Some(pos) = ids.iter().position(|id| *id == tab.layout.focused()) {
- let target = if reverse {
- ids[(pos + ids.len() - 1) % ids.len()]
- } else {
- ids[(pos + 1) % ids.len()]
- };
- self.focus_pane_in_workspace(ws_idx, target);
- }
- }
-
- #[cfg(test)]
- pub fn last_pane(&mut self) {
- let Some(target) = self.previous_pane_focus.clone() else {
- return;
- };
- let Some((ws_idx, tab_idx)) = self.pane_focus_target_indices(&target) else {
- self.previous_pane_focus = None;
- return;
- };
- let current = self.current_pane_focus_target();
- if current.as_ref() == Some(&target) {
- self.previous_pane_focus = None;
- return;
- }
-
- self.switch_workspace_tab(ws_idx, tab_idx);
- if let Some(tab) = self
- .workspaces
- .get_mut(ws_idx)
- .and_then(|ws| ws.tabs.get_mut(tab_idx))
- {
- tab.layout.focus_pane(target.pane_id);
- self.previous_pane_focus = current;
- self.mark_session_dirty();
- }
- }
-
pub(crate) fn apply_pane_zoom(
&mut self,
ws_idx: usize,
@@ -1991,31 +972,8 @@ impl AppState {
self.workspace_close_indices(ws_idx).len() >= 2
}
- pub(crate) fn begin_workspace_close_confirmation(&mut self, ws_idx: usize) -> bool {
- let Some(workspace_id) = self
- .workspaces
- .get(ws_idx)
- .map(|workspace| workspace.id.clone())
- else {
- return false;
- };
- self.selected = ws_idx;
- self.confirm_close_workspace_id = Some(workspace_id);
- self.mode = Mode::ConfirmClose;
- true
- }
-
- pub(crate) fn take_confirmed_workspace_close_index(&mut self) -> Option<usize> {
- let workspace_id = self.confirm_close_workspace_id.take()?;
- self.workspaces
- .iter()
- .position(|workspace| workspace.id == workspace_id)
- }
-
- pub(crate) fn confirm_implicit_worktree_group_close(&mut self, ws_idx: usize) -> bool {
- self.confirm_close
- && self.workspace_close_would_close_worktree_group(ws_idx)
- && self.begin_workspace_close_confirmation(ws_idx)
+ pub(crate) fn confirm_implicit_worktree_group_close(&self, ws_idx: usize) -> bool {
+ self.confirm_close && self.workspace_close_would_close_worktree_group(ws_idx)
}
#[cfg(test)]
@@ -2052,8 +1010,6 @@ impl AppState {
}
}
- self.selection = None;
- self.selection_autoscroll = None;
self.mark_session_dirty();
let terminal_ids = active
.and_then(|i| {
@@ -2099,8 +1055,6 @@ impl AppState {
}
}
- self.selection = None;
- self.selection_autoscroll = None;
self.mark_session_dirty();
let should_close_workspace = self
.active
@@ -2134,173 +1088,70 @@ impl AppState {
self.remove_plugin_pane_records(pane_ids);
self.remove_unattached_terminal_ids(terminal_ids);
crate::logging::tab_closed(&workspace_id, &closing_tab_id);
- self.tab_scroll_follow_active = true;
- self.refresh_tab_bar_view();
}
false
}
}
-// ---------------------------------------------------------------------------
-// Selection
-// ---------------------------------------------------------------------------
-
impl AppState {
- pub fn clear_selection(&mut self) {
- self.selection = None;
- self.selection_autoscroll = None;
- }
-
- pub(crate) fn stop_selection_autoscroll_state(&mut self) {
- self.selection_autoscroll = None;
- }
-
- pub(crate) fn select_word_at_pane_cell(
- &mut self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- pane_id: crate::layout::PaneId,
- viewport_row: u16,
- col: u16,
- ) -> bool {
- // Resolve the active pane cell the double-click landed on.
- let Some(ws_idx) = self
- .active
- .filter(|idx| self.workspaces.get(*idx).is_some())
- else {
- return false;
- };
-
- let Some(info) = self.pane_info_by_id(pane_id) else {
- return false;
- };
- if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width {
- return false;
- }
-
- // Leave mouse input to terminal apps that requested it.
- let Some(rt) = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)
- else {
- return false;
- };
- if rt.mouse_reporting_enabled() {
- return false;
- }
-
- // Read the visible row and identify the clicked token bounds.
- let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
- let row_selection = Selection::range(
- pane_id,
- viewport_row,
- 0,
- info.inner_rect.width.saturating_sub(1),
- metrics,
- );
- let Some(row_text) = rt.extract_selection(&row_selection) else {
- return false;
- };
- let Some((start_col, end_col)) = word_bounds_at_column(&row_text, col) else {
- return false;
- };
-
- let mut selection = Selection::range(pane_id, viewport_row, start_col, end_col, metrics);
- if !selection.finish() {
- return false;
- }
-
- let text = if self.copy_on_select {
- let Some(text) = rt
- .extract_selection(&selection)
- .filter(|text| !text.is_empty())
- else {
- self.clear_selection();
- return false;
- };
- Some(text)
- } else {
- None
- };
-
- self.selection = Some(selection);
- self.selection_autoscroll = None;
- if let Some(text) = text {
- self.request_clipboard_write = Some(text.into_bytes());
- info!("copied double-clicked token to clipboard");
- }
- true
- }
-
- pub(crate) fn url_at_pane_cell(
+ pub(crate) fn url_at_pane_surface_cell(
&self,
terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
+ ws_idx: usize,
pane_id: crate::layout::PaneId,
viewport_row: u16,
col: u16,
) -> Option<String> {
- let ws_idx = self
- .active
- .filter(|idx| self.workspaces.get(*idx).is_some())?;
- let info = self.pane_info_by_id(pane_id)?;
- if viewport_row >= info.inner_rect.height || col >= info.inner_rect.width {
- return None;
- }
-
let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?;
- let screen_col = info.inner_rect.x.saturating_add(col);
- let screen_row = info.inner_rect.y.saturating_add(viewport_row);
- if let Some((_, _, uri)) = rt
- .visible_hyperlinks(info.inner_rect)
- .into_iter()
- .find(|((x, y), _, _)| *x == screen_col && *y == screen_row)
- {
- return Some(uri);
- }
-
- let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
- let visible_selection = Selection::line_range(
+ let (height, width) = rt.current_size();
+ url_at_runtime_cell(
+ rt,
pane_id,
- Selection::absolute_row_for_viewport(0, metrics),
- Selection::absolute_row_for_viewport(info.inner_rect.height.saturating_sub(1), metrics),
- info.inner_rect.width.saturating_sub(1),
- );
- let visible_text = rt.extract_selection(&visible_selection)?;
- let logical_cell =
- logical_cell_for_visible_cell(&visible_text, info.inner_rect.width, viewport_row, col)?;
- let line_start = visible_text[..logical_cell.byte_index]
- .rfind('\n')
- .map_or(0, |idx| idx + 1);
- let line_end = visible_text[logical_cell.byte_index..]
- .find('\n')
- .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx);
- let line = visible_text.get(line_start..line_end)?;
- url_at_column(line, logical_cell.logical_col).map(str::to_owned)
- }
-
- pub fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) {
- let mut sel = match self.selection.take() {
- Some(sel) => sel,
- None => return,
- };
- if !sel.is_finalized() && !sel.finish() {
- return;
- }
-
- let ws_idx = match self.active {
- Some(ws_idx) if self.workspaces.get(ws_idx).is_some() => ws_idx,
- _ => return,
- };
-
- let text = self
- .runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, sel.pane_id)
- .and_then(|rt| rt.extract_selection(&sel));
- if let Some(text) = text {
- if !text.is_empty() {
- self.request_clipboard_write = Some(text.into_bytes());
- info!("copied selection to clipboard");
- }
- }
+ ratatui::layout::Rect::new(0, 0, width, height),
+ viewport_row,
+ col,
+ rt.scroll_metrics(),
+ )
+ }
+}
- self.clear_selection();
+fn url_at_runtime_cell(
+ runtime: &crate::terminal::TerminalRuntime,
+ pane_id: crate::layout::PaneId,
+ area: ratatui::layout::Rect,
+ viewport_row: u16,
+ col: u16,
+ metrics: Option<crate::pane::ScrollMetrics>,
+) -> Option<String> {
+ if viewport_row >= area.height || col >= area.width {
+ return None;
}
+ let screen_col = area.x.saturating_add(col);
+ let screen_row = area.y.saturating_add(viewport_row);
+ if let Some((_, _, uri)) = runtime
+ .visible_hyperlinks(area)
+ .into_iter()
+ .find(|((x, y), _, _)| *x == screen_col && *y == screen_row)
+ {
+ return Some(uri);
+ }
+
+ let visible_selection = Selection::line_range(
+ pane_id,
+ crate::selection::absolute_row_for_viewport(0, metrics),
+ crate::selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics),
+ area.width.saturating_sub(1),
+ );
+ let visible_text = runtime.extract_selection(&visible_selection)?;
+ let logical_cell = logical_cell_for_visible_cell(&visible_text, area.width, viewport_row, col)?;
+ let line_start = visible_text[..logical_cell.byte_index]
+ .rfind('\n')
+ .map_or(0, |idx| idx + 1);
+ let line_end = visible_text[logical_cell.byte_index..]
+ .find('\n')
+ .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx);
+ let line = visible_text.get(line_start..line_end)?;
+ url_at_column(line, logical_cell.logical_col).map(str::to_owned)
}
pub(crate) fn safe_web_url(url: &str) -> Option<&str> {
@@ -2336,7 +1187,7 @@ impl CellSpan {
/// zero-width marks use display columns, then prefers structured spans that
/// users expect to copy whole (URLs and quoted paths), and finally falls back
/// to a separator-delimited token.
-fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
+pub(crate) fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
// Map the row into display cells before doing any word-boundary work.
let cells = text_cells(row);
let clicked_idx = cell_index_at_column(&cells, col)?;
@@ -2921,12 +1772,10 @@ impl AppState {
.collect()
}
}
- // Intercepted before this dispatch — in App::handle_internal_event (monolithic)
- // or via HeadlessServer forwarding to the foreground client (server); never touch
- // AppState. Kept for AppEvent exhaustiveness.
+ // Host-local effects are intercepted by HeadlessServer and forwarded to the
+ // foreground client; they never touch AppState. Kept for AppEvent exhaustiveness.
AppEvent::TerminalBell { .. } => Vec::new(),
AppEvent::ClipboardWrite { .. } => Vec::new(),
- AppEvent::PrefixInputSource { .. } => Vec::new(),
AppEvent::TerminalCwdReported { pane_id, cwd } => {
if !cwd.is_absolute() || !cwd.is_dir() {
return Vec::new();
@@ -3051,33 +1900,6 @@ impl AppState {
.min()
}
- pub(crate) fn reconcile_managed_agents_at(&mut self, now: Instant) -> Vec<(usize, PaneId)> {
- let mut changed_terminals = std::collections::HashSet::new();
- for (terminal_id, terminal) in &mut self.terminals {
- if terminal.reconcile_managed_agent_at(now, false) {
- changed_terminals.insert(terminal_id.clone());
- }
- }
- if changed_terminals.is_empty() {
- return Vec::new();
- }
- self.mark_session_dirty();
- self.workspaces
- .iter()
- .enumerate()
- .flat_map(|(ws_idx, workspace)| {
- let changed_terminals = &changed_terminals;
- workspace.tabs.iter().flat_map(move |tab| {
- tab.panes.iter().filter_map(move |(&pane_id, pane)| {
- changed_terminals
- .contains(&pane.attached_terminal_id)
- .then_some((ws_idx, pane_id))
- })
- })
- })
- .collect()
- }
-
pub(crate) fn publish_pane_process_exit_if_agent(
&mut self,
pane_id: PaneId,
@@ -3274,12 +2096,6 @@ impl AppState {
}
fn apply_agent_notification_delivery(&mut self, delivery: &AgentNotificationDelivery) {
- if self.local_sound_playback {
- if let Some(sound) = delivery.sound {
- crate::sound::play(sound, &self.sound);
- }
- }
-
if matches!(
self.toast_config.delivery,
crate::config::ToastDelivery::Herdr
@@ -3350,15 +2166,6 @@ impl AppState {
return;
};
- if self
- .selection
- .as_ref()
- .is_some_and(|s| s.pane_id == pane_id)
- {
- self.selection = None;
- self.selection_autoscroll = None;
- }
-
let pane_terminal_id = self.terminal_id_for_pane(ws_idx, pane_id);
let workspace_terminal_ids = self.terminal_ids_for_workspace(ws_idx);
self.pane_id_aliases.retain(|_, alias| *alias != pane_id);
@@ -3404,10 +2211,6 @@ impl AppState {
if self.selected >= self.workspaces.len() {
self.selected = self.workspaces.len() - 1;
}
- self.workspace_scroll = self
- .workspace_scroll
- .min(self.workspaces.len().saturating_sub(1));
- self.ensure_workspace_visible(self.selected);
}
} else {
self.remove_unattached_terminal_ids(pane_terminal_id);
@@ -3667,412 +2470,6 @@ mod tests {
}
#[test]
- fn navigator_rows_show_tab_nodes_only_for_multi_tab_workspaces() {
- let mut state = app_with_workspaces(&["single", "multi"]);
- state.workspaces[1].test_add_tab(Some("tests"));
- state.ensure_test_terminals();
-
- state.open_navigator();
- let rows = state.navigator_rows();
-
- assert!(!rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. }
- )));
- assert!(rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Tab {
- ws_idx: 1,
- tab_idx: 0
- }
- )));
- assert!(rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Tab {
- ws_idx: 1,
- tab_idx: 1
- }
- )));
- }
-
- #[test]
- fn navigator_search_matches_named_tabs_in_single_tab_workspaces() {
- let mut state = app_with_workspaces(&["multi", "single"]);
- state.workspaces[0].tabs[0].custom_name = Some("Foo".into());
- state.workspaces[0].test_add_tab(Some("Bar"));
- state.workspaces[1].tabs[0].custom_name = Some("Baz".into());
- state.ensure_test_terminals();
-
- state.open_navigator();
- state.navigator.query = "foo".into();
- assert!(state.navigator_rows().iter().any(|row| {
- row.matched
- && matches!(
- row.target,
- crate::app::state::NavigatorTarget::Tab {
- ws_idx: 0,
- tab_idx: 0
- }
- )
- }));
-
- state.navigator.query = "baz".into();
- state.select_first_navigator_match_from(&crate::terminal::TerminalRuntimeRegistry::new());
- let rows = state.navigator_rows();
- assert!(rows
- .get(state.navigator.selected)
- .is_some_and(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Tab {
- ws_idx: 1,
- tab_idx: 0
- }
- )));
- assert!(!rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Workspace { ws_idx: 0 }
- | crate::app::state::NavigatorTarget::Tab { ws_idx: 0, .. }
- | crate::app::state::NavigatorTarget::Pane { ws_idx: 0, .. }
- )));
-
- assert!(state.accept_navigator_selection());
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].active_tab_index(), 0);
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[tokio::test]
- async fn navigator_rows_match_live_root_runtime_cwd_workspace_label() {
- let unique = format!(
- "herdr-navigator-runtime-cwd-{}-{}",
- std::process::id(),
- std::time::SystemTime::now()
- .duration_since(std::time::UNIX_EPOCH)
- .unwrap()
- .as_nanos()
- );
- let root = std::env::temp_dir().join(unique);
- let stale_cwd = root.join("issue-264-nix-support");
- let live_cwd = root.join("herdr");
- std::fs::create_dir_all(stale_cwd.join(".git")).unwrap();
- std::fs::create_dir_all(live_cwd.join(".git")).unwrap();
-
- let mut state = AppState::test_new();
- let mut workspace = Workspace::test_new("stale-name");
- workspace.custom_name = None;
- workspace.identity_cwd = stale_cwd.clone();
- let pane = workspace.tabs[0].root_pane;
- state.workspaces = vec![workspace];
- state.ensure_test_terminals();
- let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap();
- state.terminals.get_mut(&terminal_id).unwrap().cwd = stale_cwd;
-
- let (events, _) = tokio::sync::mpsc::channel(4);
- let runtime = crate::terminal::TerminalRuntime::spawn(
- pane,
- 24,
- 80,
- live_cwd.clone(),
- 0,
- crate::terminal_theme::TerminalTheme::default(),
- None,
- crate::pane::PaneShellConfig::new("/bin/sh", crate::config::ShellModeConfig::NonLogin),
- &crate::pane::PaneLaunchEnv::default(),
- events,
- std::sync::Arc::new(tokio::sync::Notify::new()),
- std::sync::Arc::new(crate::render_signal::RenderSignal::new()),
- )
- .unwrap();
-
- let deadline = std::time::Instant::now() + std::time::Duration::from_secs(2);
- while runtime.cwd() != Some(live_cwd.clone()) && std::time::Instant::now() < deadline {
- tokio::time::sleep(std::time::Duration::from_millis(10)).await;
- }
-
- let mut runtime_registry = crate::terminal::TerminalRuntimeRegistry::new();
- runtime_registry.insert(terminal_id, runtime);
- state.open_navigator_from(&runtime_registry);
- state.navigator.query = "herdr".into();
- let rows = state.navigator_rows_from(&runtime_registry);
-
- for (_, runtime) in runtime_registry.drain() {
- runtime.shutdown();
- }
- let _ = std::fs::remove_dir_all(root);
-
- // The workspace matched by its live cwd label; its subtree cascades in
- // as context.
- assert_eq!(rows.len(), 2);
- assert_eq!(rows[0].label, "herdr (1)");
- assert!(rows[0].matched);
- assert!(!rows[1].matched);
- }
-
- #[test]
- fn navigator_rows_include_shell_and_agent_panes() {
- let mut state = app_with_workspaces(&["one"]);
- let shell = state.workspaces[0].tabs[0].root_pane;
- let agent = state.workspaces[0].test_split(Direction::Horizontal);
- state.ensure_test_terminals();
-
- let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap();
- let terminal = state.terminals.get_mut(&agent_terminal_id).unwrap();
- terminal.set_detected_state(Some(Agent::Claude), AgentState::Working);
-
- state.open_navigator();
- let rows = state.navigator_rows();
-
- assert!(rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
- )));
- assert!(rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent
- ) && row.meta.contains("claude")));
- }
-
- #[test]
- fn opening_navigator_selects_current_pane_and_expands_attention_workspaces() {
- let mut state = app_with_workspaces(&["one", "two"]);
- let blocked = state.workspaces[1].tabs[0].root_pane;
- let blocked_terminal_id = state.workspaces[1].terminal_id(blocked).cloned().unwrap();
- state
- .terminals
- .get_mut(&blocked_terminal_id)
- .unwrap()
- .set_detected_state(Some(Agent::Codex), AgentState::Blocked);
-
- state.open_navigator();
- let selected = state.navigator_rows()[state.navigator.selected].clone();
-
- assert!(selected.is_current);
- assert!(state
- .navigator
- .expanded_workspaces
- .contains(&state.workspaces[0].id));
- assert!(state
- .navigator
- .expanded_workspaces
- .contains(&state.workspaces[1].id));
- }
-
- #[test]
- fn accepting_navigator_pane_switches_workspace_tab_and_focus() {
- let mut state = app_with_workspaces(&["one", "two"]);
- let target = state.workspaces[1].tabs[0].root_pane;
- state.open_navigator();
- state
- .navigator
- .expanded_workspaces
- .insert(state.workspaces[1].id.clone());
- state.navigator.selected = state
- .navigator_rows()
- .iter()
- .position(|row| {
- matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == target
- )
- })
- .unwrap();
-
- assert!(state.accept_navigator_selection());
-
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].focused_pane_id(), Some(target));
- assert_eq!(state.mode, Mode::Terminal);
- }
-
- #[test]
- fn navigator_idle_search_matches_idle_agents_not_plain_shells() {
- let mut state = app_with_workspaces(&["one"]);
- let shell = state.workspaces[0].tabs[0].root_pane;
- let agent = state.workspaces[0].test_split(Direction::Horizontal);
- state.ensure_test_terminals();
-
- let agent_terminal_id = state.workspaces[0].terminal_id(agent).cloned().unwrap();
- state
- .terminals
- .get_mut(&agent_terminal_id)
- .unwrap()
- .set_detected_state(Some(Agent::Claude), AgentState::Idle);
-
- state.open_navigator();
- state.navigator.query = "idle".into();
- let rows = state.navigator_rows();
-
- assert!(rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == agent
- )));
- assert!(!rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
- )));
- }
-
- #[test]
- fn navigator_search_only_matches_visible_row_text() {
- let mut state = app_with_workspaces(&["one"]);
- state.workspaces[0].identity_cwd = "/tmp/herdr-worktrees/issue-work".into();
-
- state.open_navigator();
- state.navigator.query = "work".into();
-
- assert!(state.navigator_rows().is_empty());
- }
-
- #[test]
- fn navigator_state_filter_is_separate_from_text_search() {
- let mut state = app_with_workspaces(&["one"]);
- let shell = state.workspaces[0].tabs[0].root_pane;
- let working = state.workspaces[0].test_split(Direction::Horizontal);
- state.ensure_test_terminals();
-
- let shell_terminal_id = state.workspaces[0].terminal_id(shell).cloned().unwrap();
- state
- .terminals
- .get_mut(&shell_terminal_id)
- .unwrap()
- .set_manual_label("wheel notes".into());
- let working_terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap();
- state
- .terminals
- .get_mut(&working_terminal_id)
- .unwrap()
- .set_detected_state(Some(Agent::Codex), AgentState::Working);
-
- state.open_navigator();
- state.navigator.state_filter = Some(NavigatorStateFilter::Working);
- let state_rows = state.navigator_rows();
-
- assert!(state_rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
- )));
- assert!(!state_rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
- )));
-
- state.navigator.state_filter = None;
- state.navigator.query = "w".into();
- let text_rows = state.navigator_rows();
-
- assert!(text_rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == shell
- )));
- assert!(
- text_rows.iter().any(|row| matches!(
- row.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
- )),
- "literal one-letter search may still match visible state text"
- );
- }
-
- #[test]
- fn navigator_search_filters_panes_but_keeps_workspace_context() {
- let mut state = app_with_workspaces(&["one"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- let terminal_id = state.workspaces[0].terminal_id(root).cloned().unwrap();
- state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .set_manual_label("weekly review".into());
- state.open_navigator();
- state.navigator.query = "weekly".into();
-
- let rows = state.navigator_rows();
-
- assert!(rows.iter().any(|row| row.is_workspace));
- assert!(rows
- .iter()
- .any(|row| !row.is_workspace && row.label.contains("weekly")));
- }
-
- #[test]
- fn navigator_workspace_match_cascades_full_subtree() {
- let mut state = app_with_workspaces(&["one", "two"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- let extra = state.workspaces[0].test_split(Direction::Horizontal);
- state.ensure_test_terminals();
- for pane in [root, extra] {
- let terminal_id = state.workspaces[0].terminal_id(pane).cloned().unwrap();
- state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .set_manual_label("unrelated".into());
- }
-
- state.open_navigator();
- state.navigator.query = "one".into();
- let rows = state.navigator_rows();
-
- // Both panes cascade in even though only the workspace label matched,
- // and only the workspace carries the matched flag.
- let pane_rows: Vec<_> = rows.iter().filter(|row| !row.is_workspace).collect();
- assert_eq!(pane_rows.len(), 2);
- assert!(pane_rows.iter().all(|row| !row.matched));
- assert!(rows.iter().any(|row| row.is_workspace && row.matched));
- assert!(!rows.iter().any(|row| row.label.starts_with("two")));
- }
-
- #[test]
- fn navigator_search_selects_first_self_match() {
- let mut state = app_with_workspaces(&["one", "two"]);
- let pane = state.workspaces[1].tabs[0].root_pane;
- state.ensure_test_terminals();
- let terminal_id = state.workspaces[1].terminal_id(pane).cloned().unwrap();
- state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .set_manual_label("pi ui build".into());
-
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- state.open_navigator_from(&terminal_runtimes);
- state.navigator.query = "ui".into();
- state.select_first_navigator_match_from(&terminal_runtimes);
-
- let rows = state.navigator_rows_from(&terminal_runtimes);
- let selected = &rows[state.navigator.selected];
- assert!(matches!(
- selected.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == pane
- ));
- }
-
- #[test]
- fn navigator_state_filter_selects_matching_pane_over_workspace() {
- let mut state = app_with_workspaces(&["one"]);
- let working = state.workspaces[0].test_split(Direction::Horizontal);
- state.ensure_test_terminals();
- let terminal_id = state.workspaces[0].terminal_id(working).cloned().unwrap();
- state
- .terminals
- .get_mut(&terminal_id)
- .unwrap()
- .set_detected_state(Some(Agent::Codex), AgentState::Working);
-
- let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
- state.open_navigator_from(&terminal_runtimes);
- state.navigator.state_filter = Some(NavigatorStateFilter::Working);
- state.select_first_navigator_match_from(&terminal_runtimes);
-
- let rows = state.navigator_rows_from(&terminal_runtimes);
- let selected = &rows[state.navigator.selected];
- assert!(matches!(
- selected.target,
- crate::app::state::NavigatorTarget::Pane { pane_id, .. } if pane_id == working
- ));
- }
-
- #[test]
fn apply_workspace_git_statuses_updates_matching_workspace() {
let mut state = app_with_workspaces(&["one", "two"]);
let first_id = state.workspaces[0].id.clone();
@@ -4219,196 +2616,6 @@ mod tests {
assert_eq!(state.workspaces[0].worktree_space().cloned(), membership);
}
- fn mark_agent(state: &mut AppState, ws_idx: usize, tab_idx: usize, pane_id: PaneId) {
- set_agent_state(state, ws_idx, tab_idx, pane_id, AgentState::Idle);
- }
-
- fn set_agent_state(
- state: &mut AppState,
- ws_idx: usize,
- tab_idx: usize,
- pane_id: PaneId,
- agent_state: AgentState,
- ) {
- state.ensure_test_terminals();
- let terminal_id = state.workspaces[ws_idx].tabs[tab_idx]
- .panes
- .get(&pane_id)
- .unwrap()
- .attached_terminal_id
- .clone();
- if let Some(terminal) = state.terminals.get_mut(&terminal_id) {
- terminal.set_detected_state(Some(Agent::Pi), agent_state);
- }
- }
-
- fn transition_agent_state(state: &mut AppState, pane_id: PaneId, agent_state: AgentState) {
- state
- .update_terminal_state(pane_id, |terminal| {
- Some(terminal.set_detected_state_with_screen_signals_at(
- Some(Agent::Pi),
- agent_state,
- matches!(agent_state, AgentState::Blocked),
- false,
- false,
- false,
- std::time::Instant::now(),
- ))
- })
- .expect("agent state transition should update pane state");
- }
-
- #[test]
- fn next_agent_cycles_agent_panel_entries() {
- let mut first = Workspace::test_new("one");
- let first_root = first.tabs[0].root_pane;
- let first_second = first.test_split(Direction::Horizontal);
- first.tabs[0].layout.focus_pane(first_root);
- let second = Workspace::test_new("two");
- let second_root = second.tabs[0].root_pane;
-
- let mut state = AppState::test_new();
- state.workspaces = vec![first, second];
- state.ensure_test_terminals();
- state.active = Some(0);
- state.selected = 0;
- state.mode = Mode::Terminal;
- mark_agent(&mut state, 0, 0, first_root);
- mark_agent(&mut state, 0, 0, first_second);
- mark_agent(&mut state, 1, 0, second_root);
-
- state.next_agent();
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second));
-
- state.next_agent();
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
-
- state.previous_agent();
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_second));
- state.assert_invariants_for_test();
- }
-
- #[test]
- fn focus_agent_entry_uses_agent_panel_order() {
- let mut first = Workspace::test_new("one");
- let first_root = first.tabs[0].root_pane;
- let first_second = first.test_split(Direction::Horizontal);
- first.tabs[0].layout.focus_pane(first_root);
- let second = Workspace::test_new("two");
- let second_root = second.tabs[0].root_pane;
-
- let mut state = AppState::test_new();
- state.workspaces = vec![first, second];
- state.active = Some(0);
- state.selected = 0;
- state.mode = Mode::Terminal;
- mark_agent(&mut state, 0, 0, first_root);
- mark_agent(&mut state, 0, 0, first_second);
- mark_agent(&mut state, 1, 0, second_root);
-
- assert!(state.focus_agent_entry(2));
-
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
- state.assert_invariants_for_test();
- }
-
- #[test]
- fn focus_agent_entry_succeeds_for_already_focused_agent() {
- let mut state = app_with_workspaces(&["one"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- mark_agent(&mut state, 0, 0, root);
-
- assert!(state.focus_agent_entry(0));
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
- state.assert_invariants_for_test();
- }
-
- #[test]
- fn next_agent_cycles_priority_sorted_agent_panel_entries() {
- let mut first = Workspace::test_new("one");
- let first_root = first.tabs[0].root_pane;
- let first_second = first.test_split(Direction::Horizontal);
- first.tabs[0].layout.focus_pane(first_root);
- let second = Workspace::test_new("two");
- let second_root = second.tabs[0].root_pane;
-
- let mut state = AppState::test_new();
- state.workspaces = vec![first, second];
- state.ensure_test_terminals();
- state.active = Some(0);
- state.selected = 0;
- state.mode = Mode::Terminal;
- state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
- set_agent_state(&mut state, 0, 0, first_root, AgentState::Idle);
- set_agent_state(&mut state, 0, 0, first_second, AgentState::Working);
- set_agent_state(&mut state, 1, 0, second_root, AgentState::Blocked);
-
- state.next_agent();
-
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
- state.assert_invariants_for_test();
- }
-
- #[test]
- fn priority_sort_keeps_recently_changed_idle_agent_above_older_idle_agent() {
- let mut workspace = Workspace::test_new("one");
- let first = workspace.tabs[0].root_pane;
- let second = workspace.test_split(Direction::Horizontal);
- workspace.tabs[0].layout.focus_pane(first);
-
- let mut state = AppState::test_new();
- state.workspaces = vec![workspace];
- state.ensure_test_terminals();
- state.active = Some(0);
- state.selected = 0;
- state.mode = Mode::Terminal;
- state.agent_panel_sort = crate::app::state::AgentPanelSort::Priority;
-
- transition_agent_state(&mut state, first, AgentState::Idle);
- transition_agent_state(&mut state, second, AgentState::Working);
- assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second);
-
- transition_agent_state(&mut state, second, AgentState::Idle);
-
- assert_eq!(crate::ui::agent_panel_entries(&state)[0].pane_id, second);
- state.assert_invariants_for_test();
- }
-
- #[test]
- fn previous_agent_keeps_wrapped_target_visible_in_agent_panel() {
- let mut workspace = Workspace::test_new("one");
- let root = workspace.tabs[0].root_pane;
- for idx in 1..8 {
- workspace.test_add_tab(Some(&format!("tab-{idx}")));
- }
-
- let mut state = AppState::test_new();
- state.workspaces = vec![workspace];
- state.ensure_test_terminals();
- state.active = Some(0);
- state.selected = 0;
- state.mode = Mode::Terminal;
- for tab_idx in 0..state.workspaces[0].tabs.len() {
- let pane_id = state.workspaces[0].tabs[tab_idx].root_pane;
- mark_agent(&mut state, 0, tab_idx, pane_id);
- }
- state.workspaces[0].tabs[0].layout.focus_pane(root);
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
-
- state.previous_agent();
-
- let last_idx = state.workspaces[0].tabs.len() - 1;
- assert_eq!(state.workspaces[0].active_tab, last_idx);
- assert!(state.agent_panel_scroll > 0);
- state.assert_invariants_for_test();
- }
-
#[test]
fn switch_workspace_updates_active_and_selected() {
let mut state = app_with_workspaces(&["a", "b", "c"]);
@@ -4418,137 +2625,6 @@ mod tests {
}
#[test]
- fn last_pane_toggles_to_previous_focus_in_active_tab() {
- let mut state = app_with_workspaces(&["test"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- let right = state.workspaces[0].test_split(Direction::Horizontal);
-
- state.focus_pane_in_workspace(0, root);
- state.focus_pane_in_workspace(0, right);
- state.last_pane();
-
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
-
- state.last_pane();
-
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(right));
- }
-
- #[test]
- fn removing_background_pane_preserves_last_pane_history() {
- let mut state = app_with_workspaces(&["test"]);
- let root = state.workspaces[0].tabs[0].root_pane;
- let right = state.workspaces[0].test_split(Direction::Horizontal);
- let background = state.workspaces[0].test_split(Direction::Horizontal);
-
- state.focus_pane_in_workspace(0, root);
- state.focus_pane_in_workspace(0, right);
- state.workspaces[0].remove_pane(background);
- state.last_pane();
-
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
- }
-
- #[test]
- fn last_pane_jumps_across_workspaces_and_tabs() {
- let mut state = app_with_workspaces(&["one", "two"]);
- let first_root = state.workspaces[0].tabs[0].root_pane;
- let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
- let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane;
-
- state.focus_pane_in_workspace(0, first_root);
- state.focus_pane_in_workspace(1, second_tab_root);
- state.last_pane();
-
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].active_tab, 0);
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
-
- state.last_pane();
-
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].active_tab, second_tab);
- assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root));
- }
-
- #[test]
- fn last_pane_tracks_tab_and_workspace_switches() {
- let mut state = app_with_workspaces(&["one", "two"]);
- let first_root = state.workspaces[0].tabs[0].root_pane;
- let first_second_tab = state.workspaces[0].test_add_tab(Some("logs"));
- let first_second_root = state.workspaces[0].tabs[first_second_tab].root_pane;
- let second_root = state.workspaces[1].tabs[0].root_pane;
-
- state.switch_tab(first_second_tab);
- state.last_pane();
-
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].active_tab, 0);
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
-
- state.last_pane();
-
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].active_tab, first_second_tab);
- assert_eq!(
- state.workspaces[0].focused_pane_id(),
- Some(first_second_root)
- );
-
- state.switch_workspace(1);
- state.last_pane();
-
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].active_tab, first_second_tab);
- assert_eq!(
- state.workspaces[0].focused_pane_id(),
- Some(first_second_root)
- );
-
- state.last_pane();
-
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_root));
- }
-
- #[test]
- fn last_pane_tracks_cross_workspace_tab_selection() {
- let mut state = app_with_workspaces(&["one", "two"]);
- let first_root = state.workspaces[0].tabs[0].root_pane;
- let second_first_root = state.workspaces[1].tabs[0].root_pane;
- let second_tab = state.workspaces[1].test_add_tab(Some("logs"));
- let second_tab_root = state.workspaces[1].tabs[second_tab].root_pane;
-
- state.switch_workspace_tab(1, second_tab);
- state.last_pane();
-
- assert_eq!(state.active, Some(0));
- assert_eq!(state.workspaces[0].focused_pane_id(), Some(first_root));
-
- state.last_pane();
-
- assert_eq!(state.active, Some(1));
- assert_eq!(state.workspaces[1].active_tab, second_tab);
- assert_eq!(state.workspaces[1].focused_pane_id(), Some(second_tab_root));
- assert_ne!(second_first_root, second_tab_root);
- }
-
- #[test]
- fn switch_workspace_keeps_selected_visible_in_scrolled_sidebar() {
- let mut state = app_with_workspaces(&["a", "b", "c", "d", "e", "f", "g", "h"]);
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
-
- state.switch_workspace(7);
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 80, 14));
-
- assert!(state
- .view
- .workspace_card_areas
- .iter()
- .any(|card| card.ws_idx == 7));
- }
-
- #[test]
fn switch_workspace_marks_panes_seen() {
let mut state = app_with_workspaces(&["a", "b"]);
// Mark a pane in workspace 1 as unseen
@@ -4738,34 +2814,6 @@ mod tests {
}
#[test]
- fn pane_died_self_closing_earlier_workspace_keeps_focus() {
- let names = (0..20).map(|i| format!("ws{i:02}")).collect::<Vec<_>>();
- let name_refs = names.iter().map(String::as_str).collect::<Vec<_>>();
- let mut state = app_with_workspaces(&name_refs);
- state.selected = 1;
- state.active = Some(1);
- // Constrain the sidebar viewport so the focused workspace starts
- // off-screen: 20 workspaces cannot all fit in 12 rows, so index 1 is
- // hidden at maximum scroll regardless of individual card height.
- state.view.sidebar_rect = ratatui::layout::Rect::new(0, 0, 30, 12);
- state.workspace_scroll =
- crate::ui::normalized_workspace_scroll(&state, state.view.sidebar_rect, usize::MAX / 2);
- let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect);
- assert!(cards.iter().all(|card| card.ws_idx != 1));
-
- let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
- state.handle_pane_died(pane_id);
-
- assert_eq!(state.workspaces.len(), 19);
- assert_eq!(state.workspaces[0].display_name(), "ws01");
- assert_eq!(state.selected, 0);
- assert_eq!(state.active, Some(0));
- let cards = crate::ui::compute_workspace_card_areas(&state, state.view.sidebar_rect);
- assert!(cards.iter().any(|card| card.ws_idx == 0));
- state.assert_invariants_for_test();
- }
-
- #[test]
fn pane_died_last_pane_removes_workspace() {
let mut state = app_with_workspaces(&["a", "b"]);
let pane_id = *state.workspaces[0].panes.keys().next().unwrap();
@@ -4813,55 +2861,6 @@ mod tests {
assert_eq!(state.workspaces.len(), 1);
state.assert_invariants_for_test();
}
-
- #[test]
- fn pane_died_unrelated_pane_preserves_selection() {
- // Two workspaces; user is selecting text in workspace 0.
- // A pane in workspace 1 dies — selection must be preserved.
- let mut state = app_with_workspaces(&["active", "bg"]);
- let active_pane = *state.workspaces[0].panes.keys().next().unwrap();
- let bg_pane = *state.workspaces[1].panes.keys().next().unwrap();
-
- state.selection = Some(crate::selection::Selection::anchor(active_pane, 0, 0, None));
- state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll {
- direction: crate::app::state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 0,
- last_mouse_screen_row: 23,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
-
- state.handle_pane_died(bg_pane);
-
- assert!(state.selection.is_some());
- assert!(state.selection_autoscroll.is_some());
- state.assert_invariants_for_test();
- }
-
- #[test]
- fn pane_died_same_pane_clears_selection() {
- let mut state = app_with_workspaces(&["test"]);
- let first_id = state.workspaces[0].tabs[0].root_pane;
- let second_id = state.workspaces[0].test_split(Direction::Horizontal);
- state.ensure_test_terminals();
-
- state.selection = Some(crate::selection::Selection::anchor(second_id, 0, 0, None));
- state.selection_autoscroll = Some(crate::app::state::SelectionAutoscroll {
- direction: crate::app::state::SelectionAutoscrollDirection::Down,
- last_mouse_screen_col: 0,
- last_mouse_screen_row: 23,
- inner_rect: ratatui::layout::Rect::new(0, 0, 80, 24),
- });
-
- state.handle_pane_died(second_id);
-
- // first_id still alive, workspace stays, but selection was on the dying pane
- assert!(state.selection.is_none());
- assert!(state.selection_autoscroll.is_none());
- assert_eq!(state.workspaces[0].panes.len(), 1);
- assert_eq!(state.workspaces[0].panes.keys().next().unwrap(), &first_id);
- state.assert_invariants_for_test();
- }
-
#[test]
fn state_changed_updates_pane() {
let mut state = app_with_workspaces(&["test"]);
@@ -5832,13 +3831,21 @@ mod tests {
let right = state.workspaces[0].test_split(Direction::Horizontal);
state.workspaces[0].layout.focus_pane(root);
state.workspaces[0].zoomed = true;
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ crate::ui::compute_view_with_runtime_registry(
+ &mut state,
+ &crate::terminal::TerminalRuntimeRegistry::new(),
+ ratatui::layout::Rect::new(0, 0, 100, 20),
+ );
assert_eq!(state.view.pane_infos.len(), 1);
assert_eq!(state.view.pane_infos[0].id, root);
state.navigate_pane(NavDirection::Right);
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ crate::ui::compute_view_with_runtime_registry(
+ &mut state,
+ &crate::terminal::TerminalRuntimeRegistry::new(),
+ ratatui::layout::Rect::new(0, 0, 100, 20),
+ );
assert!(state.workspaces[0].zoomed);
assert_eq!(state.workspaces[0].focused_pane_id(), Some(right));
@@ -5853,7 +3860,11 @@ mod tests {
let root = state.workspaces[0].tabs[0].root_pane;
let right = state.workspaces[0].test_split(Direction::Horizontal);
state.workspaces[0].layout.focus_pane(root);
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ crate::ui::compute_view_with_runtime_registry(
+ &mut state,
+ &crate::terminal::TerminalRuntimeRegistry::new(),
+ ratatui::layout::Rect::new(0, 0, 100, 20),
+ );
let before_root_rect = state
.view
.pane_infos
@@ -5870,7 +3881,11 @@ mod tests {
.rect;
assert!(state.swap_pane(NavDirection::Right));
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ crate::ui::compute_view_with_runtime_registry(
+ &mut state,
+ &crate::terminal::TerminalRuntimeRegistry::new(),
+ ratatui::layout::Rect::new(0, 0, 100, 20),
+ );
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
assert_eq!(
@@ -5902,10 +3917,18 @@ mod tests {
let right = state.workspaces[0].test_split(Direction::Horizontal);
state.workspaces[0].layout.focus_pane(root);
state.workspaces[0].zoomed = true;
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ crate::ui::compute_view_with_runtime_registry(
+ &mut state,
+ &crate::terminal::TerminalRuntimeRegistry::new(),
+ ratatui::layout::Rect::new(0, 0, 100, 20),
+ );
assert!(state.swap_pane(NavDirection::Right));
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ crate::ui::compute_view_with_runtime_registry(
+ &mut state,
+ &crate::terminal::TerminalRuntimeRegistry::new(),
+ ratatui::layout::Rect::new(0, 0, 100, 20),
+ );
assert!(state.workspaces[0].zoomed);
assert_eq!(state.workspaces[0].focused_pane_id(), Some(root));
@@ -5913,7 +3936,11 @@ mod tests {
assert_eq!(state.view.pane_infos[0].id, root);
state.workspaces[0].zoomed = false;
- crate::ui::compute_view(&mut state, ratatui::layout::Rect::new(0, 0, 100, 20));
+ crate::ui::compute_view_with_runtime_registry(
+ &mut state,
+ &crate::terminal::TerminalRuntimeRegistry::new(),
+ ratatui::layout::Rect::new(0, 0, 100, 20),
+ );
let root_rect = state
.view
.pane_infos
@@ -6088,8 +4115,7 @@ mod tests {
let deferred = state.close_pane();
assert!(deferred);
- assert_eq!(state.mode, Mode::ConfirmClose);
- assert_eq!(state.selected, 0);
+ assert_eq!(state.selected, 1);
assert_eq!(state.workspaces.len(), 2);
}
@@ -6102,7 +4128,6 @@ mod tests {
state.close_tab();
- assert_eq!(state.request_remove_linked_worktree, None);
assert_eq!(state.workspaces.len(), 1);
assert_eq!(state.workspaces[0].display_name(), "selected");
}
@@ -6118,8 +4143,7 @@ mod tests {
let deferred = state.close_tab();
assert!(deferred);
- assert_eq!(state.mode, Mode::ConfirmClose);
- assert_eq!(state.selected, 0);
+ assert_eq!(state.selected, 1);
assert_eq!(state.workspaces.len(), 2);
}
@@ -6132,7 +4156,6 @@ mod tests {
state.close_pane();
- assert_eq!(state.request_remove_linked_worktree, None);
assert_eq!(state.workspaces.len(), 1);
assert_eq!(state.workspaces[0].display_name(), "selected");
}