diff --git a/crates/codirigent-ui/src/terminal_header.rs b/crates/codirigent-ui/src/terminal_header.rs index 23f7cb9..3a83f25 100644 --- a/crates/codirigent-ui/src/terminal_header.rs +++ b/crates/codirigent-ui/src/terminal_header.rs @@ -37,8 +37,10 @@ pub struct TerminalHeader { pub ai_summary: Option, /// Git branch name (if in a git repo). pub git_branch: Option, - /// Git dirty file count (if in a git repo). - pub git_dirty_count: Option, + /// Count of pending added/edited files. + pub git_pending_additions: Option, + /// Count of pending deleted files. + pub git_pending_deletions: Option, /// Group name (if session is in a group). pub group_name: Option, } @@ -59,7 +61,8 @@ impl Default for TerminalHeader { needs_attention: false, ai_summary: None, git_branch: None, - git_dirty_count: None, + git_pending_additions: None, + git_pending_deletions: None, group_name: None, } } @@ -124,10 +127,11 @@ impl TerminalHeader { self } - /// Set git branch and dirty count info. - pub fn with_git_info(mut self, branch: String, dirty: usize) -> Self { + /// Set git branch and pending file counts. + pub fn with_git_info(mut self, branch: String, additions: usize, deletions: usize) -> Self { self.git_branch = Some(branch); - self.git_dirty_count = Some(dirty); + self.git_pending_additions = Some(additions); + self.git_pending_deletions = Some(deletions); self } @@ -350,8 +354,10 @@ pub struct TerminalHeaderRenderHints { pub ai_summary: Option, /// Git branch name. pub git_branch: Option, - /// Git dirty file count. - pub git_dirty_count: Option, + /// Count of pending added/edited files. + pub git_pending_additions: Option, + /// Count of pending deleted files. + pub git_pending_deletions: Option, /// Group name (if session is in a group). pub group_name: Option, } @@ -377,7 +383,8 @@ impl TerminalHeader { needs_attention: self.needs_attention, ai_summary: self.ai_summary.clone(), git_branch: self.git_branch.clone(), - git_dirty_count: self.git_dirty_count, + git_pending_additions: self.git_pending_additions, + git_pending_deletions: self.git_pending_deletions, group_name: self.group_name.clone(), } } diff --git a/crates/codirigent-ui/src/workspace/core.rs b/crates/codirigent-ui/src/workspace/core.rs index e00c6ba..f733078 100644 --- a/crates/codirigent-ui/src/workspace/core.rs +++ b/crates/codirigent-ui/src/workspace/core.rs @@ -127,9 +127,11 @@ impl Workspace { /// When switching from split tree to grid, sessions are re-assigned /// in their current order to the new grid. pub fn set_layout(&mut self, profile: LayoutProfile) { - let stacks = self.current_pane_stacks_in_order(); - self.layout_state = WorkspaceLayoutState::Grid(self.rebuild_grid_state(profile, &stacks)); - self.apply_pane_stacks_to_current_layout(stacks); + let stacks = self.layout_transition_stacks(); + let rebuilt_layout = WorkspaceLayoutState::Grid(self.rebuild_grid_state(profile, &stacks)); + let ordered_stacks = Self::stacks_ordered_for_layout_state(&rebuilt_layout, &stacks); + self.layout_state = rebuilt_layout; + self.apply_pane_stacks_to_current_layout(ordered_stacks); } /// Cycle to the next layout profile. @@ -347,6 +349,28 @@ impl Workspace { stacks } + fn layout_transition_stacks(&self) -> Vec { + let mut stacks = self.current_pane_stacks_in_order(); + let session_positions = self + .sessions + .iter() + .enumerate() + .map(|(index, session)| (session.id, index)) + .collect::>(); + + stacks.sort_by_key(|stack| { + stack + .session_ids + .iter() + .filter_map(|session_id| session_positions.get(session_id)) + .copied() + .min() + .unwrap_or(usize::MAX) + }); + + stacks + } + fn apply_pane_stacks_to_current_layout(&mut self, stacks: Vec) { let pane_ids = self.visible_pane_ids(); self.pane_tab_groups.clear(); @@ -372,44 +396,55 @@ impl Workspace { self.cleanup_pane_tab_groups(); } - fn stack_session_order(stack: &PaneStack) -> Vec { - let mut ordered = vec![stack.active_session_id]; - ordered.extend( - stack - .session_ids + fn stacks_ordered_for_layout_state( + layout_state: &WorkspaceLayoutState, + stacks: &[PaneStack], + ) -> Vec { + let visible_session_order = match layout_state { + WorkspaceLayoutState::Grid(state) => state + .assignments() .iter() + .flatten() .copied() - .filter(|session_id| *session_id != stack.active_session_id), - ); - ordered - } + .collect::>(), + WorkspaceLayoutState::SplitTree(state) => state.assigned_sessions(), + }; + + let mut ordered = Vec::with_capacity(stacks.len()); + let mut used_indices = HashSet::new(); - fn layout_session_order(stacks: &[PaneStack], visible_panes: usize) -> Vec { - let mut ordered = Vec::new(); - let visible_len = visible_panes.min(stacks.len()); + for visible_session_id in visible_session_order { + if let Some((index, stack)) = stacks.iter().enumerate().find(|(index, stack)| { + !used_indices.contains(index) && stack.session_ids.contains(&visible_session_id) + }) { + used_indices.insert(index); + ordered.push(stack.clone()); + } + } ordered.extend( stacks .iter() - .take(visible_len) - .map(|stack| stack.active_session_id), + .enumerate() + .filter(|(index, _)| !used_indices.contains(index)) + .map(|(_, stack)| stack.clone()), ); - for stack in stacks.iter().take(visible_len) { - ordered.extend( - stack - .session_ids - .iter() - .copied() - .filter(|session_id| *session_id != stack.active_session_id), - ); - } - for stack in stacks.iter().skip(visible_len) { - ordered.extend(Self::stack_session_order(stack)); - } ordered } + fn stack_session_order(stack: &PaneStack) -> Vec { + let mut ordered = vec![stack.active_session_id]; + ordered.extend( + stack + .session_ids + .iter() + .copied() + .filter(|session_id| *session_id != stack.active_session_id), + ); + ordered + } + fn pane_exists(&self, pane_id: &PaneId) -> bool { match pane_id { PaneId::GridCell { index } => self @@ -565,7 +600,19 @@ impl Workspace { fn rebuild_grid_state(&self, profile: LayoutProfile, stacks: &[PaneStack]) -> LayoutState { let focused = self.layout_state.focused_session(); let mut grid_state = LayoutState::with_profile(profile); - grid_state.set_assignments(Self::layout_session_order(stacks, profile.max_sessions())); + let visible_panes = profile.max_sessions(); + let visible_sessions = stacks + .iter() + .take(visible_panes) + .map(|stack| stack.active_session_id) + .collect::>(); + grid_state.set_assignments(visible_sessions); + + for stack in stacks.iter().skip(visible_panes) { + for session_id in Self::stack_session_order(stack) { + let _ = grid_state.append_hidden_session(session_id); + } + } if let Some(session_id) = focused { if !grid_state.focus_session(session_id) && profile == LayoutProfile::Single { @@ -599,7 +646,11 @@ impl Workspace { let focused = self.layout_state.focused_session(); let mut split_state = SplitLayoutState::new(tree); - for session_id in Self::layout_session_order(stacks, split_state.slot_count()) { + for session_id in stacks + .iter() + .take(split_state.slot_count()) + .map(|stack| stack.active_session_id) + { if !split_state.add_session(session_id) { break; } @@ -658,6 +709,29 @@ impl Workspace { } } + /// Fill any newly available grid cells with hidden sessions in overflow + /// order so deleting a visible session keeps the grid as full as possible. + fn promote_hidden_sessions_into_grid_slots(&mut self) { + let Some(grid_state) = self.layout_state.as_grid_mut() else { + return; + }; + + while let (Some(index), Some(session_id)) = ( + grid_state.first_empty_index(), + grid_state.overflow().first().copied(), + ) { + if !grid_state.assign_session_to_index(session_id, index) { + break; + } + } + + if grid_state.focused_session().is_none() { + if let Some(index) = grid_state.occupied_indices().into_iter().next() { + grid_state.focus_index(index); + } + } + } + // --- Session Management --- /// Get all sessions. @@ -783,6 +857,7 @@ impl Workspace { if let Some(pos) = self.sessions.iter().position(|s| s.id == id) { let removed = self.sessions.remove(pos); self.cleanup_pane_tab_groups(); + self.promote_hidden_sessions_into_grid_slots(); self.promote_hidden_sessions_into_split_slots(); Some(removed) } else { @@ -940,7 +1015,8 @@ impl Workspace { true } else { let replacement_index = s - .focused_index() + .first_empty_index() + .or_else(|| s.focused_index()) .or_else(|| s.occupied_indices().into_iter().next()) .or_else(|| s.first_empty_index()); @@ -948,7 +1024,16 @@ impl Workspace { return false; }; - s.swap_hidden_into_index(id, replacement_index).is_some() + if s.session_at(replacement_index).is_none() { + if s.assign_session_to_index(id, replacement_index) { + s.focus_index(replacement_index); + true + } else { + false + } + } else { + s.swap_hidden_into_index(id, replacement_index).is_some() + } } } WorkspaceLayoutState::SplitTree(s) => { @@ -1077,10 +1162,12 @@ impl Workspace { /// /// Transfers current sessions and focus to the new tree. pub fn set_split_tree(&mut self, tree: LayoutNode) { - let stacks = self.current_pane_stacks_in_order(); - self.layout_state = + let stacks = self.layout_transition_stacks(); + let rebuilt_layout = WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree, &stacks)); - self.apply_pane_stacks_to_current_layout(stacks); + let ordered_stacks = Self::stacks_ordered_for_layout_state(&rebuilt_layout, &stacks); + self.layout_state = rebuilt_layout; + self.apply_pane_stacks_to_current_layout(ordered_stacks); } // --- Split Pane Operations --- @@ -1129,10 +1216,12 @@ impl Workspace { }; if should_switch_to_single { - let stacks = self.current_pane_stacks_in_order(); - self.layout_state = + let stacks = self.layout_transition_stacks(); + let rebuilt_layout = WorkspaceLayoutState::Grid(self.rebuild_grid_state(LayoutProfile::Single, &stacks)); - self.apply_pane_stacks_to_current_layout(stacks); + let ordered_stacks = Self::stacks_ordered_for_layout_state(&rebuilt_layout, &stacks); + self.layout_state = rebuilt_layout; + self.apply_pane_stacks_to_current_layout(ordered_stacks); } result @@ -1164,7 +1253,7 @@ impl Workspace { /// Convert the current grid layout to an equivalent split tree. fn convert_to_split_tree(&mut self) { - let stacks = self.current_pane_stacks_in_order(); + let stacks = self.layout_transition_stacks(); let tree = if let WorkspaceLayoutState::Grid(grid_state) = &self.layout_state { let (rows, cols) = grid_state.profile().dimensions(); Some(LayoutNode::from_grid(rows, cols)) @@ -1173,9 +1262,11 @@ impl Workspace { }; if let Some(tree) = tree { - self.layout_state = + let rebuilt_layout = WorkspaceLayoutState::SplitTree(self.rebuild_split_state(tree, &stacks)); - self.apply_pane_stacks_to_current_layout(stacks); + let ordered_stacks = Self::stacks_ordered_for_layout_state(&rebuilt_layout, &stacks); + self.layout_state = rebuilt_layout; + self.apply_pane_stacks_to_current_layout(ordered_stacks); } } @@ -1463,6 +1554,50 @@ impl Workspace { .collect() } + /// Get pane bounds for drag/drop hit testing, including empty panes. + pub fn pane_drop_target_info(&self) -> Vec { + match &self.layout_state { + WorkspaceLayoutState::Grid(state) => self.grid_pane_drop_target_info(state), + WorkspaceLayoutState::SplitTree(state) => self.split_pane_drop_target_info(state), + } + } + + fn grid_pane_drop_target_info(&self, state: &LayoutState) -> Vec { + let layout = self.grid_layout(); + state + .assignments() + .iter() + .enumerate() + .filter_map(|(index, session_id)| { + let bounds = layout.cell_bounds_for_index(index)?; + Some(PaneDropTargetInfo { + pane_id: PaneId::GridCell { index }, + active_session_id: *session_id, + index, + bounds, + }) + }) + .collect() + } + + fn split_pane_drop_target_info(&self, state: &SplitLayoutState) -> Vec { + let Some(layout) = self.split_layout() else { + return Vec::new(); + }; + + layout + .leaf_bounds() + .into_iter() + .enumerate() + .map(|(index, (slot, bounds))| PaneDropTargetInfo { + pane_id: PaneId::SplitSlot { slot }, + active_session_id: state.session_at_slot(slot), + index, + bounds, + }) + .collect() + } + /// Get all tab sessions for a pane in display order. pub fn pane_tab_session_ids(&self, pane_id: PaneId) -> Vec { self.pane_sessions(pane_id) @@ -1607,8 +1742,9 @@ impl Workspace { self.rebuild_split_state(state.tree().clone(), &stacks), ), }; + let ordered_stacks = Self::stacks_ordered_for_layout_state(&rebuilt_layout, &stacks); self.layout_state = rebuilt_layout; - self.apply_pane_stacks_to_current_layout(stacks); + self.apply_pane_stacks_to_current_layout(ordered_stacks); } /// Restore legacy persisted pane tab groups after sessions have been recreated. @@ -1665,3 +1801,16 @@ pub struct CellInfo { /// Cell bounds. pub bounds: Bounds, } + +/// Pane bounds used for drag/drop hit testing. +#[derive(Debug, Clone)] +pub struct PaneDropTargetInfo { + /// Visible pane identifier. + pub pane_id: PaneId, + /// Active session assigned to the pane, if any. + pub active_session_id: Option, + /// Logical grid/split ordering index. + pub index: usize, + /// Pane bounds in workspace coordinates. + pub bounds: Bounds, +} diff --git a/crates/codirigent-ui/src/workspace/drawer_render.rs b/crates/codirigent-ui/src/workspace/drawer_render.rs index 6053396..9d71827 100644 --- a/crates/codirigent-ui/src/workspace/drawer_render.rs +++ b/crates/codirigent-ui/src/workspace/drawer_render.rs @@ -9,7 +9,7 @@ use super::gpui::WorkspaceView; use super::types::{ - git_colors, DRAWER_HEADER_HEIGHT, HEADER_HEIGHT, MODAL_FIELD_HEIGHT, SESSION_ROW_HEIGHT, + git_colors, DRAWER_HEADER_HEIGHT, HEADER_HEIGHT, SESSION_DRAWER_ROW_HEIGHT, SESSION_ROW_HEIGHT, }; use crate::icons; use crate::theme::CodirigentTheme; @@ -19,6 +19,71 @@ use gpui::{ InteractiveElement, IntoElement, MouseButton, MouseDownEvent, ParentElement, SharedString, StatefulInteractiveElement, Styled, }; +use std::collections::BTreeMap; + +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +enum SessionDrawerGroupKey { + Explicit(String), + Project { + display_name: String, + identity: String, + }, +} + +impl SessionDrawerGroupKey { + fn display_name(&self) -> &str { + match self { + Self::Explicit(name) => name, + Self::Project { display_name, .. } => display_name, + } + } + + fn cache_key(&self) -> String { + match self { + Self::Explicit(name) => format!("explicit:{name}"), + Self::Project { identity, .. } => format!("project:{identity}"), + } + } +} + +type SessionDrawerGroups<'a> = ( + Vec<&'a Session>, + BTreeMap>, +); + +fn session_drawer_groups<'a>(sessions: &'a [Session]) -> SessionDrawerGroups<'a> { + let mut ungrouped: Vec<&Session> = Vec::new(); + let mut groups: BTreeMap> = BTreeMap::new(); + + for session in sessions { + let key = session + .group + .as_ref() + .filter(|group| !group.is_empty()) + .cloned() + .map(SessionDrawerGroupKey::Explicit) + .or_else(|| { + let identity = session + .git_info + .as_ref() + .map(|git_info| git_info.repo_root.to_string_lossy().into_owned()) + .or_else(|| Some(session.working_directory.to_string_lossy().into_owned()))?; + let display_name = + super::gpui::session_project_name(session).unwrap_or_else(|| identity.clone()); + Some(SessionDrawerGroupKey::Project { + display_name, + identity, + }) + }); + + match key { + Some(group_key) => groups.entry(group_key).or_default().push(session), + None => ungrouped.push(session), + } + } + + (ungrouped, groups) +} impl WorkspaceView { pub(super) fn render_drawer(&mut self, cx: &mut Context) -> impl IntoElement { @@ -129,24 +194,20 @@ impl WorkspaceView { let session_count = sessions.len(); // Separate ungrouped and grouped sessions - let mut ungrouped: Vec<&Session> = Vec::new(); - let mut groups: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for session in &sessions { - match &session.group { - Some(group) if !group.is_empty() => { - groups.entry(group.clone()).or_default().push(session); - } - _ => ungrouped.push(session), - } - } + let (ungrouped, groups) = session_drawer_groups(&sessions); - let mut content = div().flex_1().overflow_hidden().flex().flex_col(); + let mut content = div() + .id("sessions-scroll") + .flex_1() + .overflow_y_scroll() + .flex() + .flex_col(); // Render ungrouped sessions first for session in &ungrouped { content = content.child(self.render_session_row( session, + None, focused_id, visible_session_ids.contains(&session.id), &theme, @@ -156,12 +217,13 @@ impl WorkspaceView { // Render grouped sessions with headers let expanded_map = self.cache.drawer_group_expanded.clone(); - for (group_name, group_sessions) in &groups { + for (group_key, group_sessions) in &groups { let color = group_sessions.first().and_then(|s| s.color.clone()); - let expanded = expanded_map.get(group_name).copied().unwrap_or(true); + let cache_key = group_key.cache_key(); + let expanded = expanded_map.get(&cache_key).copied().unwrap_or(true); content = content.child(self.render_session_group_header( - group_name, + group_key, color.as_deref(), group_sessions.len(), expanded, @@ -173,6 +235,7 @@ impl WorkspaceView { for session in group_sessions { content = content.child(self.render_session_row( session, + Some(group_key.display_name()), focused_id, visible_session_ids.contains(&session.id), &theme, @@ -187,7 +250,6 @@ impl WorkspaceView { .flex() .flex_col() .overflow_hidden() - // Scrollable session list .child(content) // Footer .child( @@ -1045,6 +1107,7 @@ impl WorkspaceView { fn render_session_row( &mut self, session: &Session, + group_name: Option<&str>, focused_id: Option, is_visible: bool, theme: &CodirigentTheme, @@ -1060,24 +1123,45 @@ impl WorkspaceView { } else { gpui::Hsla::transparent_black() }; - let hover_bg: gpui::Hsla = theme.active.into(); + let hover_bg: gpui::Hsla = if is_focused { + row_bg + } else { + theme.active.into() + }; let orange: gpui::Hsla = theme.orange.into(); + let primary: gpui::Hsla = theme.primary.into(); let session_id = session.id; let session_name = session.name.clone(); + let project_name = super::gpui::session_project_name(session); + let project_subtitle = project_name + .clone() + .or_else(|| Some(session.working_directory.to_string_lossy().into_owned())) + .filter(|project| group_name != Some(project.as_str())); + let cli_name = self.session_cli_display_name(session_id); let context_pct = session.context_usage; let (shell_label, shell_warning) = self.session_shell_display(session_id, session.shell.as_deref()); - + let show_shell_label = session.shell.is_some() || shell_warning.is_some(); + let branch_badge = session.git_info.as_ref().map(|git_info| { + let mut branch = git_info.branch.clone(); + if branch.chars().count() > 16 { + branch = branch.chars().take(13).collect::() + "..."; + } + branch + }); div() .id(SharedString::from(format!("session-row-{}", session_id.0))) - .h(px(MODAL_FIELD_HEIGHT)) + .h(px(SESSION_DRAWER_ROW_HEIGHT)) .w_full() .px_3() + .py(px(6.0)) .flex() - .items_center() + .items_start() .gap_2() .bg(row_bg) + .border_l_2() + .border_color(if is_focused { primary } else { row_bg }) .cursor_pointer() .hover(move |style| style.bg(hover_bg)) .on_mouse_down( @@ -1097,101 +1181,127 @@ impl WorkspaceView { .bg(status_color) .flex_shrink_0(), ) - // Session name (truncated) .child( div() .flex_1() - .overflow_hidden() - .text_xs() - .text_color(if is_focused { fg } else { muted }) - .child(session_name), - ) - .when(is_hidden, |el| { - el.child( - div() - .text_xs() - .text_color(muted.opacity(0.75)) - .flex_shrink_0() - .child("Hidden"), - ) - }) - .child( - div() - .flex_shrink_0() - .px(px(4.0)) - .py_px() - .rounded_sm() - .bg(if shell_warning.is_some() { - orange.opacity(0.12) - } else { - muted.opacity(0.12) - }) + .min_w_0() + .flex() + .flex_col() + .gap(px(2.0)) .child( div() - .text_xs() - .text_color(if shell_warning.is_some() { - orange - } else { - muted.opacity(0.75) - }) - .child(shell_label), - ), - ) - // Git branch (compact) - between name and context% - .when_some(session.git_info.as_ref(), |el, gi| { - let mut branch = gi.branch.clone(); - if branch.chars().count() > 12 { - branch = branch.chars().take(9).collect::() + "..."; - } - let branch_color = muted.opacity(0.5); - el.child( - div() - .flex_shrink_0() - .flex() - .items_center() - .gap_1() - .child(div().text_xs().text_color(branch_color).child(branch)) - .when(gi.dirty_count > 0, |el| { - el.child( + .w_full() + .flex() + .items_center() + .gap_2() + .child( div() - .text_xs() - .text_color(super::types::DIRTY_INDICATOR_COLOR) - .child(format!("\u{25CF}{}", gi.dirty_count)), + .flex_1() + .min_w_0() + .overflow_hidden() + .text_sm() + .font_weight(FontWeight::MEDIUM) + .text_color(fg) + .text_ellipsis() + .child(session_name), ) - }), - ) - }) - // Context percentage (if available) with threshold-based coloring - .when_some(context_pct, |el, pct| { - let context_color: gpui::Hsla = - crate::terminal_header::ContextLevel::from_percentage(pct) - .color() - .into(); - el.child( - div() - .text_xs() - .text_color(context_color) - .flex_shrink_0() - .child(format!("{}%", (pct * 100.0) as u32)), - ) - }) + .when(is_hidden, |el| { + el.child(Self::render_session_metadata_badge( + "Hidden", + muted.opacity(0.85), + muted.opacity(0.12), + )) + }) + .when_some(context_pct, |el, pct| { + let context_color: gpui::Hsla = + crate::terminal_header::ContextLevel::from_percentage(pct) + .color() + .into(); + el.child( + div() + .text_xs() + .text_color(context_color) + .flex_shrink_0() + .child(format!("{}%", (pct * 100.0) as u32)), + ) + }) + .when(show_shell_label, |el| { + el.child( + div() + .text_xs() + .text_color(if shell_warning.is_some() { + orange + } else { + muted.opacity(0.8) + }) + .flex_shrink_0() + .child(shell_label.clone()), + ) + }) + .when_some(cli_name, |el, cli_name| { + el.child(Self::render_session_metadata_badge( + &cli_name, + primary, + primary.opacity(0.12), + )) + }), + ) + .when(project_subtitle.is_some() || branch_badge.is_some(), |el| { + el.child( + div() + .w_full() + .flex() + .items_center() + .gap_2() + .when_some(project_subtitle.clone(), |row, project_subtitle| { + row.child( + div() + .flex_1() + .min_w_0() + .overflow_hidden() + .text_xs() + .text_color(if is_focused { + muted.opacity(0.95) + } else { + muted.opacity(0.82) + }) + .text_ellipsis() + .child(project_subtitle), + ) + }) + .when_some(branch_badge.clone(), |row, branch| { + row.child( + div() + .max_w(px(96.0)) + .overflow_hidden() + .text_xs() + .text_color(muted.opacity(0.8)) + .text_ellipsis() + .flex_shrink_0() + .child(branch), + ) + }), + ) + }), + ) // Menu button .child( div() .id(SharedString::from(format!("session-menu-{}", session_id.0))) .w(px(24.0)) .h(px(24.0)) + .flex_shrink_0() .rounded_md() .flex() .items_center() .justify_center() - .flex_shrink_0() .cursor_pointer() .hover(|style| style.bg(super::types::CANCEL_BUTTON_HOVER)) .on_mouse_down( MouseButton::Left, - cx.listener(move |this, _, _, cx| { - this.open_session_menu(session_id, cx); + cx.listener(move |this, event: &MouseDownEvent, _window, cx| { + this.open_session_menu(session_id, Some(event.position.y.into()), cx); + cx.stop_propagation(); }), ) .child( @@ -1204,10 +1314,68 @@ impl WorkspaceView { ) } + pub(super) fn session_drawer_row_offset(&self, session_id: SessionId) -> Option { + let sessions: Vec = self.workspace().sessions().to_vec(); + let (ungrouped, groups) = session_drawer_groups(&sessions); + + let mut offset = 0.0; + for session in ungrouped { + if session.id == session_id { + return Some(offset); + } + offset += SESSION_DRAWER_ROW_HEIGHT; + } + + for (group_key, group_sessions) in groups { + offset += SESSION_ROW_HEIGHT; + let cache_key = group_key.cache_key(); + let expanded = self + .cache + .drawer_group_expanded + .get(&cache_key) + .copied() + .unwrap_or(true); + if !expanded { + continue; + } + + for session in group_sessions { + if session.id == session_id { + return Some(offset); + } + offset += SESSION_DRAWER_ROW_HEIGHT; + } + } + + None + } + + fn render_session_metadata_badge( + text: &str, + text_color: gpui::Hsla, + background: gpui::Hsla, + ) -> gpui::Div { + div() + .flex_shrink_0() + .max_w(px(120.0)) + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(background) + .child( + div() + .overflow_hidden() + .text_xs() + .text_ellipsis() + .text_color(text_color) + .child(text.to_owned()), + ) + } + /// Render a session group header in the drawer session list. fn render_session_group_header( &mut self, - group_name: &str, + group_key: &SessionDrawerGroupKey, color: Option<&str>, count: usize, expanded: bool, @@ -1228,15 +1396,12 @@ impl WorkspaceView { icons::chevron_right() }; - let group_name_owned = group_name.to_string(); + let group_name = group_key.display_name(); let group_label = format!("{} ({})", group_name, count); - let toggle_key = group_name_owned.clone(); + let toggle_key = group_key.cache_key(); div() - .id(SharedString::from(format!( - "group-header-{}", - group_name_owned - ))) + .id(SharedString::from(format!("group-header-{}", toggle_key))) .h(px(SESSION_ROW_HEIGHT)) .w_full() .px_3() @@ -1281,3 +1446,120 @@ impl WorkspaceView { )) } } + +#[cfg(test)] +mod tests { + use super::*; + use codirigent_core::{GitRepoInfo, SessionId, SessionStatus}; + use std::path::PathBuf; + + fn test_session(id: u64, working_directory: &str) -> Session { + Session { + id: SessionId(id), + name: format!("Session {id}"), + status: SessionStatus::Idle, + working_directory: PathBuf::from(working_directory), + shell: None, + current_task: None, + context_usage: None, + created_at: chrono::Utc::now(), + group: None, + color: None, + git_info: None, + claude_session_id: None, + codex_session_id: None, + codex_execution_mode: None, + codex_started_at: None, + gemini_session_id: None, + } + } + + #[test] + fn session_drawer_groups_falls_back_to_project_name() { + let sessions = vec![ + test_session(1, "/workspace/dirigent"), + test_session(2, "/workspace/dirigent"), + ]; + + let (ungrouped, groups) = session_drawer_groups(&sessions); + + assert!(ungrouped.is_empty()); + assert_eq!(groups.len(), 1); + assert_eq!( + groups + .get(&SessionDrawerGroupKey::Project { + display_name: "dirigent".to_string(), + identity: "/workspace/dirigent".to_string(), + }) + .map(Vec::len), + Some(2) + ); + } + + #[test] + fn session_drawer_groups_keep_explicit_and_project_groups_distinct() { + let mut explicit = test_session(1, "/workspace/dirigent"); + explicit.group = Some("dirigent".to_string()); + + let mut derived = test_session(2, "/workspace/dirigent/subdir"); + derived.git_info = Some(GitRepoInfo { + repo_root: PathBuf::from("/workspace/dirigent"), + branch: "main".to_string(), + dirty_count: 0, + has_staged: false, + head_sha: None, + unstaged_files: Vec::new(), + staged_files: Vec::new(), + }); + + let sessions = vec![explicit, derived]; + let (_ungrouped, groups) = session_drawer_groups(&sessions); + + assert_eq!(groups.len(), 2); + assert_eq!( + groups + .get(&SessionDrawerGroupKey::Explicit("dirigent".to_string())) + .map(Vec::len), + Some(1) + ); + assert_eq!( + groups + .get(&SessionDrawerGroupKey::Project { + display_name: "dirigent".to_string(), + identity: "/workspace/dirigent".to_string(), + }) + .map(Vec::len), + Some(1) + ); + } + + #[test] + fn session_drawer_groups_keep_duplicate_project_names_distinct_by_path() { + let sessions = vec![ + test_session(1, "/workspace/apps/dirigent"), + test_session(2, "/workspace/tools/dirigent"), + ]; + + let (_ungrouped, groups) = session_drawer_groups(&sessions); + + assert_eq!(groups.len(), 2); + assert_eq!( + groups + .get(&SessionDrawerGroupKey::Project { + display_name: "dirigent".to_string(), + identity: "/workspace/apps/dirigent".to_string(), + }) + .map(Vec::len), + Some(1) + ); + assert_eq!( + groups + .get(&SessionDrawerGroupKey::Project { + display_name: "dirigent".to_string(), + identity: "/workspace/tools/dirigent".to_string(), + }) + .map(Vec::len), + Some(1) + ); + } +} diff --git a/crates/codirigent-ui/src/workspace/gpui.rs b/crates/codirigent-ui/src/workspace/gpui.rs index df4bb6f..5928059 100644 --- a/crates/codirigent-ui/src/workspace/gpui.rs +++ b/crates/codirigent-ui/src/workspace/gpui.rs @@ -30,6 +30,10 @@ mod layout_sync; mod session_metadata; mod ui_events; +pub(super) use session_metadata::{ + cli_type_badge_name, pending_git_file_counts, session_project_name, +}; + // The root still owns `WorkspaceView`, trait impls, and high-level // orchestration. Child modules hold lower-coupling helper clusters. @@ -59,9 +63,9 @@ use codirigent_session::clipboard_service::{ClipboardService, DefaultClipboardSe use codirigent_session::DefaultSessionManager; use gpui::{ div, px, App, AppContext, Bounds, ClickEvent, Context, Entity, EntityInputHandler, FocusHandle, - Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseMoveEvent, - MouseUpEvent, ParentElement, Pixels, Render, StatefulInteractiveElement, Styled, - UTF16Selection, Window, + Focusable, InteractiveElement, IntoElement, KeyDownEvent, MouseButton, MouseDownEvent, + MouseMoveEvent, MouseUpEvent, ParentElement, Pixels, Render, StatefulInteractiveElement, + Styled, UTF16Selection, Window, }; use std::collections::HashMap; use std::path::PathBuf; @@ -305,6 +309,8 @@ impl WorkspaceView { self.clipboard .clipboard_service .set_session_cli_type(session_id, codirigent_core::CliType::CodexCli); + self.sync_session_header(session_id); + cx.notify(); if let Ok(mgr) = self.session_manager.lock() { changed |= mgr @@ -1212,14 +1218,22 @@ impl WorkspaceView { container = container.child( div() + .occlude() .absolute() .inset_0() .bg(super::types::MODAL_BACKDROP) .flex() .items_center() .justify_center() + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + }), + ) .child( div() + .occlude() .bg(panel_bg) .border_1() .border_color(border_color) @@ -1229,6 +1243,12 @@ impl WorkspaceView { .flex_col() .gap_3() .w(px(380.0)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + }), + ) .child( div() .text_base() @@ -1321,6 +1341,7 @@ impl WorkspaceView { pub(super) fn has_blocking_modal(&self) -> bool { self.custom_picker.is_open || self.modals.session_action.is_some() + || self.modals.session_creation.is_some() || self.modals.task_creation.is_some() || self.modals.pending_profile_deletion.is_some() } @@ -1562,6 +1583,7 @@ impl Render for WorkspaceView { || self.cache.render_layout_signature != Some(layout_signature) { self.cache.render_cell_info = self.workspace.cell_info(); + self.cache.render_pane_drop_targets = self.workspace.pane_drop_target_info(); self.cache.render_cell_info_dirty = false; self.cache.render_layout_signature = Some(layout_signature); } diff --git a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs index caedb44..5ecb78e 100644 --- a/crates/codirigent-ui/src/workspace/gpui/derived_state.rs +++ b/crates/codirigent-ui/src/workspace/gpui/derived_state.rs @@ -1,6 +1,6 @@ //! Derived UI state reducers and refresh helpers. -use super::session_metadata::session_project_name; +use super::session_metadata::{pending_git_file_counts, session_project_name}; use super::WorkspaceView; use codirigent_core::SessionId; use std::collections::HashMap; @@ -131,8 +131,14 @@ impl WorkspaceView { let focused_id = self.workspace.focused_session_id(); for session in sessions { let project_name = session_project_name(session); + let cli_name = self.session_cli_display_name(session.id); let git_branch = session.git_info.as_ref().map(|gi| gi.branch.clone()); - let git_dirty_count = session.git_info.as_ref().map(|gi| gi.dirty_count); + let (git_pending_additions, git_pending_deletions) = session + .git_info + .as_ref() + .map(pending_git_file_counts) + .map(|(added, deleted)| (Some(added), Some(deleted))) + .unwrap_or((None, None)); let session_color = session .color .as_deref() @@ -154,11 +160,17 @@ impl WorkspaceView { if header.project_name != project_name { header.project_name = project_name; } + if header.cli_name != cli_name { + header.cli_name = cli_name.clone(); + } if header.git_branch != git_branch { header.git_branch = git_branch; } - if header.git_dirty_count != git_dirty_count { - header.git_dirty_count = git_dirty_count; + if header.git_pending_additions != git_pending_additions { + header.git_pending_additions = git_pending_additions; + } + if header.git_pending_deletions != git_pending_deletions { + header.git_pending_deletions = git_pending_deletions; } if header.session_color != session_color { header.session_color = session_color; @@ -229,8 +241,14 @@ impl WorkspaceView { }; let focused_id = self.workspace.focused_session_id(); let project_name = session_project_name(session); + let cli_name = self.session_cli_display_name(session.id); let git_branch = session.git_info.as_ref().map(|gi| gi.branch.clone()); - let git_dirty_count = session.git_info.as_ref().map(|gi| gi.dirty_count); + let (git_pending_additions, git_pending_deletions) = session + .git_info + .as_ref() + .map(pending_git_file_counts) + .map(|(added, deleted)| (Some(added), Some(deleted))) + .unwrap_or((None, None)); let session_color = session .color .as_deref() @@ -255,12 +273,18 @@ impl WorkspaceView { if header.project_name != project_name { header.project_name = project_name; } + if header.cli_name != cli_name { + header.cli_name = cli_name; + } if header.git_branch != git_branch { header.git_branch = git_branch; } - if header.git_dirty_count != git_dirty_count { - header.git_dirty_count = git_dirty_count; + if header.git_pending_additions != git_pending_additions { + header.git_pending_additions = git_pending_additions; + } + if header.git_pending_deletions != git_pending_deletions { + header.git_pending_deletions = git_pending_deletions; } if header.session_color != session_color { header.session_color = session_color; diff --git a/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs b/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs index 3f99f8e..a57b4e5 100644 --- a/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs +++ b/crates/codirigent-ui/src/workspace/gpui/session_metadata.rs @@ -1,15 +1,51 @@ //! Lightweight session metadata helpers. -use std::collections::HashMap; +use codirigent_core::{CliType, GitChangeKind, GitRepoInfo}; +use std::collections::{HashMap, HashSet}; -pub(super) fn session_project_name(session: &codirigent_core::Session) -> Option { +fn path_display_name(path: &std::path::Path) -> Option { + path.file_name() + .and_then(|name| name.to_str()) + .map(str::to_owned) + .or_else(|| { + let display = path.as_os_str().to_string_lossy(); + (!display.is_empty()).then(|| display.into_owned()) + }) +} + +pub(in crate::workspace) fn session_project_name( + session: &codirigent_core::Session, +) -> Option { session .git_info .as_ref() - .and_then(|git_info| git_info.repo_root.file_name()) - .or_else(|| session.working_directory.file_name()) - .and_then(|name| name.to_str()) - .map(str::to_owned) + .and_then(|git_info| path_display_name(&git_info.repo_root)) + .or_else(|| path_display_name(&session.working_directory)) +} + +pub(in crate::workspace) fn pending_git_file_counts(git_info: &GitRepoInfo) -> (usize, usize) { + let mut added_or_edited = HashSet::new(); + let mut deleted = HashSet::new(); + + for file in git_info + .staged_files + .iter() + .chain(git_info.unstaged_files.iter()) + { + match file.change { + GitChangeKind::Deleted => { + deleted.insert(file.path.clone()); + added_or_edited.remove(&file.path); + } + GitChangeKind::Added | GitChangeKind::Modified | GitChangeKind::Renamed => { + if !deleted.contains(&file.path) { + added_or_edited.insert(file.path.clone()); + } + } + } + } + + (added_or_edited.len(), deleted.len()) } pub(super) fn resolved_task_title( @@ -22,6 +58,15 @@ pub(super) fn resolved_task_title( .unwrap_or_else(|| task_id.0.to_string()) } +pub(in crate::workspace) fn cli_type_badge_name(cli_type: CliType) -> Option<&'static str> { + match cli_type { + CliType::ClaudeCode => Some("Claude Code"), + CliType::GeminiCli => Some("Gemini"), + CliType::CodexCli => Some("Codex"), + CliType::GenericShell => None, + } +} + #[cfg(test)] mod tests { use super::*; @@ -63,6 +108,17 @@ mod tests { ); } + #[test] + fn session_project_name_handles_root_workspaces() { + let session = codirigent_core::Session::new( + codirigent_core::SessionId(1), + "Session 1".to_string(), + std::path::PathBuf::from("/"), + ); + + assert_eq!(session_project_name(&session), Some("/".to_string())); + } + #[test] fn resolved_task_title_prefers_cached_title_and_falls_back_to_id() { let task_id = codirigent_core::TaskId::from("task-123"); @@ -79,4 +135,78 @@ mod tests { ); assert_eq!(resolved_task_title(&task_id, None), "task-123".to_string()); } + + #[test] + fn cli_type_badge_name_hides_generic_shell() { + assert_eq!( + cli_type_badge_name(codirigent_core::CliType::ClaudeCode), + Some("Claude Code") + ); + assert_eq!( + cli_type_badge_name(codirigent_core::CliType::GeminiCli), + Some("Gemini") + ); + assert_eq!( + cli_type_badge_name(codirigent_core::CliType::CodexCli), + Some("Codex") + ); + assert_eq!( + cli_type_badge_name(codirigent_core::CliType::GenericShell), + None + ); + } + + #[test] + fn pending_git_file_counts_dedupes_staged_and_unstaged_paths() { + let git_info = codirigent_core::GitRepoInfo { + repo_root: std::path::PathBuf::from("/workspace/project-root"), + branch: "main".to_string(), + dirty_count: 3, + has_staged: true, + head_sha: Some("deadbeef".to_string()), + unstaged_files: vec![ + codirigent_core::GitChangedFile { + path: "src/lib.rs".to_string(), + change: codirigent_core::GitChangeKind::Modified, + }, + codirigent_core::GitChangedFile { + path: "removed.txt".to_string(), + change: codirigent_core::GitChangeKind::Deleted, + }, + ], + staged_files: vec![ + codirigent_core::GitChangedFile { + path: "src/lib.rs".to_string(), + change: codirigent_core::GitChangeKind::Modified, + }, + codirigent_core::GitChangedFile { + path: "new.rs".to_string(), + change: codirigent_core::GitChangeKind::Added, + }, + ], + }; + + assert_eq!(pending_git_file_counts(&git_info), (2, 1)); + } + + #[test] + fn pending_git_file_counts_treats_deleted_state_as_removal() { + let git_info = codirigent_core::GitRepoInfo { + repo_root: std::path::PathBuf::from("/workspace/project-root"), + branch: "main".to_string(), + dirty_count: 2, + has_staged: true, + head_sha: Some("deadbeef".to_string()), + unstaged_files: vec![codirigent_core::GitChangedFile { + path: "rename-target.rs".to_string(), + change: codirigent_core::GitChangeKind::Renamed, + }], + staged_files: vec![codirigent_core::GitChangedFile { + path: "rename-target.rs".to_string(), + change: codirigent_core::GitChangeKind::Deleted, + }], + }; + + assert_eq!(pending_git_file_counts(&git_info), (0, 1)); + } } diff --git a/crates/codirigent-ui/src/workspace/grid_render.rs b/crates/codirigent-ui/src/workspace/grid_render.rs index 289f792..a154ed5 100644 --- a/crates/codirigent-ui/src/workspace/grid_render.rs +++ b/crates/codirigent-ui/src/workspace/grid_render.rs @@ -119,7 +119,10 @@ impl WorkspaceView { ) } else { // Empty cell - render inline + let pane_id = codirigent_core::PaneId::GridCell { index }; self.render_empty_cell_inline_with_colors( + pane_id, + index, position, panel_bg, border_color, @@ -181,7 +184,7 @@ impl WorkspaceView { } if drag.source_index == drag_logical_index.unwrap_or(usize::MAX) { Some(DragVisual::Source) - } else if drag.target.map(|target| target.index) == drag_logical_index { + } else if drag.target.as_ref().map(|target| target.index) == drag_logical_index { Some(DragVisual::Target) } else { None diff --git a/crates/codirigent-ui/src/workspace/impl_modals.rs b/crates/codirigent-ui/src/workspace/impl_modals.rs index 93090fb..fdf6826 100644 --- a/crates/codirigent-ui/src/workspace/impl_modals.rs +++ b/crates/codirigent-ui/src/workspace/impl_modals.rs @@ -384,14 +384,17 @@ impl WorkspaceView { event: &KeyDownEvent, cx: &mut Context, ) -> bool { - let Some(modal) = self.modals.session_creation.as_mut() else { + let Some(modal) = self.modals.session_creation.as_ref() else { return false; }; + let modal_pending = modal.pending; + let selected_shell_index = modal.selected_shell_index; + let visible_shell_order = self.shell_picker_option_order(&modal.shell_options); let key = event.keystroke.key.to_lowercase(); match key.as_str() { "escape" => { - if modal.pending { + if modal_pending { cx.notify(); return true; } @@ -404,41 +407,60 @@ impl WorkspaceView { return true; } "up" | "left" | "k" => { - if modal.pending { + if modal_pending { return true; } - if !modal.shell_options.is_empty() { - modal.selected_shell_index = modal - .selected_shell_index + if !visible_shell_order.is_empty() { + let current_position = visible_shell_order + .iter() + .position(|&index| index == selected_shell_index) + .unwrap_or(0); + let previous_position = current_position .checked_sub(1) - .unwrap_or(modal.shell_options.len().saturating_sub(1)); + .unwrap_or(visible_shell_order.len().saturating_sub(1)); + if let Some(modal) = self.modals.session_creation.as_mut() { + modal.selected_shell_index = visible_shell_order[previous_position]; + } cx.notify(); } return true; } "down" | "right" | "j" => { - if modal.pending { + if modal_pending { return true; } - if !modal.shell_options.is_empty() { - modal.selected_shell_index = - (modal.selected_shell_index + 1) % modal.shell_options.len(); + if !visible_shell_order.is_empty() { + let current_position = visible_shell_order + .iter() + .position(|&index| index == selected_shell_index) + .unwrap_or(0); + let next_position = (current_position + 1) % visible_shell_order.len(); + if let Some(modal) = self.modals.session_creation.as_mut() { + modal.selected_shell_index = visible_shell_order[next_position]; + } cx.notify(); } return true; } "tab" => { - if modal.pending { + if modal_pending { return true; } - if !modal.shell_options.is_empty() { - let len = modal.shell_options.len(); + if !visible_shell_order.is_empty() { + let current_position = visible_shell_order + .iter() + .position(|&index| index == selected_shell_index) + .unwrap_or(0); + let len = visible_shell_order.len(); let step = if event.keystroke.modifiers.shift { len.saturating_sub(1) } else { 1 }; - modal.selected_shell_index = (modal.selected_shell_index + step) % len; + let next_position = (current_position + step) % len; + if let Some(modal) = self.modals.session_creation.as_mut() { + modal.selected_shell_index = visible_shell_order[next_position]; + } cx.notify(); } return true; diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs index 069fc73..31e10ac 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/git_refresh.rs @@ -60,10 +60,18 @@ impl WorkspaceView { for (id, git_info) in &git_infos { if let Some(header) = this.terminal_headers.get_mut(id) { let branch = git_info.as_ref().map(|info| info.branch.clone()); - let dirty_count = git_info.as_ref().map(|info| info.dirty_count); - if header.git_branch != branch || header.git_dirty_count != dirty_count { + let (pending_additions, pending_deletions) = git_info + .as_ref() + .map(super::super::gpui::pending_git_file_counts) + .map(|(added, deleted)| (Some(added), Some(deleted))) + .unwrap_or((None, None)); + if header.git_branch != branch + || header.git_pending_additions != pending_additions + || header.git_pending_deletions != pending_deletions + { header.git_branch = branch; - header.git_dirty_count = dirty_count; + header.git_pending_additions = pending_additions; + header.git_pending_deletions = pending_deletions; git_changed = true; } } @@ -118,12 +126,20 @@ impl WorkspaceView { } let branch = git_info.as_ref().map(|info| info.branch.clone()); - let dirty_count = git_info.as_ref().map(|info| info.dirty_count); + let (pending_additions, pending_deletions) = git_info + .as_ref() + .map(super::super::gpui::pending_git_file_counts) + .map(|(added, deleted)| (Some(added), Some(deleted))) + .unwrap_or((None, None)); let mut changed = false; if let Some(header) = this.terminal_headers.get_mut(&session_id) { - if header.git_branch != branch || header.git_dirty_count != dirty_count { + if header.git_branch != branch + || header.git_pending_additions != pending_additions + || header.git_pending_deletions != pending_deletions + { header.git_branch = branch.clone(); - header.git_dirty_count = dirty_count; + header.git_pending_additions = pending_additions; + header.git_pending_deletions = pending_deletions; changed = true; } } diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs index 3a22de0..1d4576a 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/hook_signals.rs @@ -317,11 +317,17 @@ impl WorkspaceView { ); let mut id_changed = false; + let mut cli_type_changed = false; let cli_type_name = cli_type.as_deref().unwrap_or(CLI_TYPE_CLAUDE); if let Some(cli_type) = cli_type_from_hook_signal_name(cli_type_name) { + let current_cli_type = self + .clipboard + .clipboard_service + .get_session_cli_type(session_id); self.clipboard .clipboard_service .set_session_cli_type(session_id, cli_type); + cli_type_changed = current_cli_type != cli_type; } let resolved_cli_session_id = resolve_hook_cli_session_id(&signal_file_id, cli_session_id.as_deref(), session_id); @@ -507,7 +513,8 @@ impl WorkspaceView { ); } - if self.sync_session_status(session_id) { + if self.sync_session_status(session_id) || cli_type_changed { + self.sync_session_header(session_id); cx.notify(); } } diff --git a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs index f79000f..2eb5c55 100644 --- a/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs +++ b/crates/codirigent-ui/src/workspace/impl_output_polling/output_runtime.rs @@ -4,7 +4,7 @@ use super::WorkspaceView; use crate::terminal_runtime::TerminalRenderSnapshot; use codirigent_core::{CliType, SessionId, SessionManager, SessionUpdate}; use codirigent_session::clipboard_service::ClipboardService; -use codirigent_session::detect_cli_from_output; +use codirigent_session::{detect_cli_from_output, ShellState}; use gpui::Context; use std::time::Instant; use tracing::{info, trace}; @@ -16,9 +16,16 @@ struct PreparedSessionOutput { has_more: bool, render_snapshot: Option, detected_cli_type: Option, + revert_cli_to_shell: bool, cwd_session: Option, } +fn shell_prompt_event_reverts_cli(events: &[ShellState]) -> bool { + events + .iter() + .any(|event| matches!(event, ShellState::PromptStart)) +} + fn prioritize_and_partition_output_sessions( mut session_ids: Vec, focused_id: Option, @@ -249,10 +256,13 @@ impl WorkspaceView { let render_snapshot = runtime.apply_output(&data); let detected_cli_type = detect_cli_from_output(&data); + let shell_events = codirigent_session::extract_osc133_events(&data); + let revert_cli_to_shell = shell_prompt_event_reverts_cli(&shell_events); + { let mut detector = detector.lock().ok()?; detector.process_output(session_id, &data); - for event in codirigent_session::extract_osc133_events(&data) { + for event in shell_events { // DUAL-PATH: Emitted to channel for phase-2 event routing. // Also applied directly below via set_shell_state() for correctness now. if let Some(tx) = &update_tx { @@ -299,6 +309,7 @@ impl WorkspaceView { has_more: drained.has_more, render_snapshot, detected_cli_type, + revert_cli_to_shell, cwd_session, }) }) @@ -341,6 +352,7 @@ impl WorkspaceView { has_more, render_snapshot, detected_cli_type, + revert_cli_to_shell, cwd_session, } = prepared; trace!( @@ -366,14 +378,33 @@ impl WorkspaceView { self.clipboard .clipboard_service .set_session_cli_type(session_id, cli_type); + any_dirty = true; info!(?session_id, ?cli_type, "Detected CLI type from output"); } } + if revert_cli_to_shell { + let current = self + .clipboard + .clipboard_service + .get_session_cli_type(session_id); + if current != CliType::GenericShell { + self.clipboard + .clipboard_service + .set_session_cli_type(session_id, CliType::GenericShell); + any_dirty = true; + info!( + ?session_id, + "Reverted CLI badge to shell after prompt return" + ); + } + } + if let Some(mgr_session) = cwd_session { if let Some(header) = self.terminal_headers.get_mut(&session_id) { header.git_branch = None; - header.git_dirty_count = None; + header.git_pending_additions = None; + header.git_pending_deletions = None; } if let Some(ws_session) = self.workspace.session_mut(session_id) { @@ -441,4 +472,19 @@ mod tests { assert_eq!(ready, vec![SessionId(3)]); assert_eq!(deferred, vec![SessionId(2), SessionId(1)]); } + + #[test] + fn shell_prompt_events_revert_cli_to_shell() { + assert!(shell_prompt_event_reverts_cli(&[ShellState::PromptStart])); + assert!(!shell_prompt_event_reverts_cli(&[ + ShellState::CommandExecuted, + ShellState::CommandInputStart, + ])); + assert!(!shell_prompt_event_reverts_cli(&[ + ShellState::CommandExecuted + ])); + assert!(!shell_prompt_event_reverts_cli(&[ + ShellState::CommandFinished { exit_code: Some(0) } + ])); + } } diff --git a/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs index aacd70b..48635b3 100644 --- a/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs +++ b/crates/codirigent-ui/src/workspace/impl_pointer_interactions.rs @@ -7,6 +7,25 @@ use crate::workspace::gpui::WorkspaceView; use gpui::{Context, MouseMoveEvent, MouseUpEvent}; +pub(super) fn apply_session_drag_drop( + workspace: &mut super::core::Workspace, + drag: &super::types::DragState, + target: &super::types::DragTarget, +) -> bool { + match target.kind { + super::types::DragTargetKind::PaneBody => { + if target.active_session_id.is_none() { + workspace.group_session_into_pane(drag.source_session_id, target.pane_id.clone()) + } else { + workspace.swap_sessions(drag.source_index, target.index) + } + } + super::types::DragTargetKind::PaneHeader => { + workspace.group_session_into_pane(drag.source_session_id, target.pane_id.clone()) + } + } +} + impl WorkspaceView { pub(super) fn handle_workspace_mouse_move( &mut self, @@ -32,7 +51,7 @@ impl WorkspaceView { return; }; - drag.update_pointer(pos, &self.cache.render_cell_info); + drag.update_pointer(pos, &self.cache.render_pane_drop_targets); cx.notify(); } @@ -95,22 +114,8 @@ impl WorkspaceView { fn finish_session_drag(&mut self, cx: &mut Context) { if let Some(drag) = self.selection.drag.take() { if drag.active { - if let Some(target) = drag.target { - let changed = match target.kind { - super::types::DragTargetKind::PaneBody => self - .workspace - .swap_sessions(drag.source_index, target.index), - super::types::DragTargetKind::PaneHeader => self - .cache - .render_cell_info - .iter() - .find(|info| info.index == target.index) - .cloned() - .is_some_and(|info| { - self.workspace - .group_session_into_pane(drag.source_session_id, info.pane_id) - }), - }; + if let Some(target) = drag.target.clone() { + let changed = apply_session_drag_drop(&mut self.workspace, &drag, &target); if changed { self.mark_layout_cache_dirty(); self.sync_layout_derived_state(); diff --git a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs index ccf5336..654c393 100644 --- a/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs +++ b/crates/codirigent-ui/src/workspace/impl_session_lifecycle.rs @@ -7,16 +7,19 @@ //! - State persistence to disk use super::cli_helpers::is_safe_cli_session_id; -use super::gpui::WorkspaceView; +use super::gpui::{ + cli_type_badge_name, pending_git_file_counts, session_project_name, WorkspaceView, +}; use super::types::{RestoreShellFallback, SESSION_NAME_PREFIX, SESSION_SHELL_AUTO_LABEL}; use crate::terminal::Terminal; use crate::terminal_header::TerminalHeader; use crate::terminal_view::TerminalView; use codirigent_core::{ - CodexExecutionMode, CodirigentEvent, EventBus, GridPosition, LayoutMode, PaneId, + CliType, CodexExecutionMode, CodirigentEvent, EventBus, GridPosition, LayoutMode, PaneId, PaneStackState, PaneTabGroup, ProcessMonitor, Session, SessionId, SessionManager, SessionStatus, SlotId, }; +use codirigent_session::clipboard_service::ClipboardService; use codirigent_session::DefaultSessionManager; use gpui::Context; use serde::Deserialize; @@ -157,6 +160,21 @@ fn restore_resume_commands(plan: &RestoreSessionPlan) -> Vec<&str> { .collect() } +fn restore_plan_cli_type(plan: &RestoreSessionPlan) -> CliType { + if plan.codex_resume.is_some() + || plan.codex_execution_mode.is_some() + || plan.codex_started_at.is_some() + { + CliType::CodexCli + } else if plan.claude_resume.is_some() { + CliType::ClaudeCode + } else if plan.gemini_resume.is_some() { + CliType::GeminiCli + } else { + CliType::GenericShell + } +} + fn bootstrap_session( session_manager: Arc>, request: SessionBootstrapRequest, @@ -217,8 +235,7 @@ fn resolve_restore_shell_choice( fn restore_shell_fallback_message(fallback: &RestoreShellFallback) -> String { format!( "Requested shell '{}' was unavailable, so this session was opened with {}.", - fallback.requested_shell, - WorkspaceView::shell_display_label(fallback.effective_shell.as_deref()) + fallback.requested_shell, fallback.effective_shell_label ) } @@ -904,7 +921,7 @@ mod tests { fn restore_shell_fallback_message_uses_effective_shell_label() { let fallback = RestoreShellFallback { requested_shell: "pwsh".to_string(), - effective_shell: Some("bash".to_string()), + effective_shell_label: "bash".to_string(), }; assert_eq!( @@ -914,17 +931,64 @@ mod tests { } #[test] - fn restore_shell_fallback_message_uses_auto_label_without_effective_shell() { + fn restore_shell_fallback_message_uses_explicit_fallback_label() { let fallback = RestoreShellFallback { requested_shell: "pwsh".to_string(), - effective_shell: None, + effective_shell_label: "zsh".to_string(), }; assert_eq!( restore_shell_fallback_message(&fallback), - "Requested shell 'pwsh' was unavailable, so this session was opened with Auto." + "Requested shell 'pwsh' was unavailable, so this session was opened with zsh." ); } + + #[test] + fn shell_display_label_normalizes_shell_paths() { + assert_eq!(WorkspaceView::shell_display_label(Some("/bin/zsh")), "zsh"); + assert_eq!( + WorkspaceView::shell_display_label(Some(r"C:\Windows\System32\cmd.exe")), + "cmd" + ); + assert_eq!( + WorkspaceView::shell_display_label(Some( + r"C:\Windows\System32\WindowsPowerShell\v1.0\POWERSHELL.EXE" + )), + "POWERSHELL" + ); + assert_eq!(WorkspaceView::shell_display_label(None), "Auto"); + } + + #[test] + fn restore_plan_cli_type_prefers_known_resume_metadata() { + let base = RestoreSessionPlan { + original_session_id: SessionId(1), + session_name: "Session 1".to_string(), + working_dir: PathBuf::from("/tmp"), + shell: None, + group: None, + color: None, + claude_resume: None, + codex_resume: None, + codex_execution_mode: None, + codex_started_at: None, + gemini_resume: None, + }; + + let mut claude = base.clone(); + claude.claude_resume = Some("claude --resume".to_string()); + assert_eq!(restore_plan_cli_type(&claude), CliType::ClaudeCode); + + let mut gemini = base.clone(); + gemini.gemini_resume = Some("gemini resume".to_string()); + assert_eq!(restore_plan_cli_type(&gemini), CliType::GeminiCli); + + let mut codex = base.clone(); + codex.codex_execution_mode = Some(CodexExecutionMode::FullAuto); + assert_eq!(restore_plan_cli_type(&codex), CliType::CodexCli); + + assert_eq!(restore_plan_cli_type(&base), CliType::GenericShell); + } } impl WorkspaceView { @@ -973,13 +1037,101 @@ impl WorkspaceView { ) } + fn shell_name_fragment(shell: &str) -> &str { + let fragment = shell + .trim() + .rsplit(['/', '\\']) + .find(|fragment| !fragment.is_empty()) + .unwrap_or(shell.trim()); + if fragment.len() > 4 && fragment[fragment.len() - 4..].eq_ignore_ascii_case(".exe") { + &fragment[..fragment.len() - 4] + } else { + fragment + } + } + pub(super) fn shell_display_label(shell: Option<&str>) -> String { shell + .map(str::trim) .filter(|value| !value.is_empty()) + .map(Self::shell_name_fragment) .unwrap_or(SESSION_SHELL_AUTO_LABEL) .to_string() } + fn detected_auto_shell_label(&self) -> String { + if let Ok(shell) = std::env::var("CODIRIGENT_SHELL") { + if !shell.trim().is_empty() { + return Self::shell_display_label(Some(&shell)); + } + } + + #[cfg(unix)] + { + std::env::var("SHELL") + .ok() + .filter(|shell| !shell.trim().is_empty()) + .map(|shell| Self::shell_display_label(Some(&shell))) + .unwrap_or_else(|| "bash".to_string()) + } + + #[cfg(windows)] + { + if self + .detected_shell_options() + .iter() + .any(|shell| shell == "pwsh") + { + return "pwsh".to_string(); + } + if self + .detected_shell_options() + .iter() + .any(|shell| shell == "powershell") + { + return "powershell".to_string(); + } + + std::env::var("COMSPEC") + .ok() + .filter(|shell| !shell.trim().is_empty()) + .map(|shell| Self::shell_display_label(Some(&shell))) + .unwrap_or_else(|| "cmd".to_string()) + } + + #[cfg(not(any(unix, windows)))] + { + "sh".to_string() + } + } + + fn effective_shell_label_for_launch_shell(&self, launch_shell: Option<&str>) -> String { + launch_shell + .map(str::trim) + .filter(|shell| !shell.is_empty()) + .map(|shell| Self::shell_display_label(Some(shell))) + .unwrap_or_else(|| self.detected_auto_shell_label()) + } + + fn record_effective_session_shell( + &mut self, + session_id: SessionId, + launch_shell: Option<&str>, + ) { + let shell_label = self.effective_shell_label_for_launch_shell(launch_shell); + self.cache + .effective_shell_labels + .insert(session_id, shell_label); + } + + pub(super) fn session_cli_display_name(&self, session_id: SessionId) -> Option { + let cli_type = self + .clipboard + .clipboard_service + .get_session_cli_type(session_id); + cli_type_badge_name(cli_type).map(str::to_string) + } + pub(super) fn session_shell_warning_message(&self, session_id: SessionId) -> Option { self.cache .restore_shell_fallbacks @@ -992,17 +1144,14 @@ impl WorkspaceView { session_id: SessionId, requested_shell: Option<&str>, ) -> (String, Option) { - if let Some(fallback) = self.cache.restore_shell_fallbacks.get(&session_id) { - ( - Self::shell_display_label(fallback.effective_shell.as_deref()), - Some(restore_shell_fallback_message(fallback)), - ) - } else { - ( - Self::shell_display_label(requested_shell), - self.session_shell_warning_message(session_id), - ) - } + let shell_label = self + .cache + .effective_shell_labels + .get(&session_id) + .cloned() + .unwrap_or_else(|| Self::shell_display_label(requested_shell)); + + (shell_label, self.session_shell_warning_message(session_id)) } fn sync_manager_session_shell( @@ -1022,14 +1171,14 @@ impl WorkspaceView { &mut self, session_id: SessionId, shell_warning: Option, - effective_shell: Option, + effective_shell_label: String, ) { if let Some(requested_shell) = shell_warning { self.cache.restore_shell_fallbacks.insert( session_id, RestoreShellFallback { requested_shell, - effective_shell, + effective_shell_label, }, ); } else { @@ -1059,17 +1208,16 @@ impl WorkspaceView { ) -> TerminalHeader { let mut header = TerminalHeader::new(session_name, SessionStatus::Idle); if let Some(ref git_info) = session.git_info { - header = header.with_git_info(git_info.branch.clone(), git_info.dirty_count); + let (additions, deletions) = pending_git_file_counts(git_info); + header = header.with_git_info(git_info.branch.clone(), additions, deletions); } - let dir_name = session - .git_info - .as_ref() - .and_then(|git_info| git_info.repo_root.file_name()) - .or_else(|| session.working_directory.file_name()) - .and_then(|name| name.to_str()) - .unwrap_or("unknown"); - header = header.with_project_name(dir_name); + if let Some(project_name) = session_project_name(session) { + header = header.with_project_name(project_name); + } + if let Some(cli_name) = self.session_cli_display_name(session.id) { + header = header.with_cli_name(cli_name); + } let (shell_label, shell_warning) = self.session_shell_display(session.id, session.shell.as_deref()); header = header.with_shell(shell_label, shell_warning); @@ -1097,6 +1245,7 @@ impl WorkspaceView { self.terminals.remove(&session_id); self.pty_write_receivers.remove(&session_id); self.terminal_headers.remove(&session_id); + self.cache.effective_shell_labels.remove(&session_id); self.cache.restore_shell_fallbacks.remove(&session_id); self.output_dispatcher.remove_session(session_id); self.polling.output_prepare_in_flight.remove(&session_id); @@ -1188,10 +1337,16 @@ impl WorkspaceView { bootstrapped.session_id, bootstrapped.request.requested_shell.clone(), ); + self.record_effective_session_shell( + bootstrapped.session_id, + bootstrapped.request.launch_shell.as_deref(), + ); self.record_restored_shell_warning( bootstrapped.session_id, bootstrapped.request.shell_warning.clone(), - bootstrapped.request.launch_shell.clone(), + self.effective_shell_label_for_launch_shell( + bootstrapped.request.launch_shell.as_deref(), + ), ); let mut session = bootstrapped.session.clone(); @@ -1237,11 +1392,20 @@ impl WorkspaceView { bootstrapped.session_id, bootstrapped.request.requested_shell.clone(), ); + self.record_effective_session_shell( + bootstrapped.session_id, + bootstrapped.request.launch_shell.as_deref(), + ); self.record_restored_shell_warning( bootstrapped.session_id, bootstrapped.request.shell_warning.clone(), - bootstrapped.request.launch_shell.clone(), + self.effective_shell_label_for_launch_shell( + bootstrapped.request.launch_shell.as_deref(), + ), ); + self.clipboard + .clipboard_service + .set_session_cli_type(bootstrapped.session_id, restore_plan_cli_type(&plan)); if plan.codex_execution_mode.is_some() || plan.codex_started_at.is_some() { let codex_execution_mode = plan.codex_execution_mode; @@ -1737,6 +1901,7 @@ impl WorkspaceView { readers.cached_status.remove(&id); } self.polling.shell_input_buffers.remove(&id); + self.cache.effective_shell_labels.remove(&id); self.cache.restore_shell_fallbacks.remove(&id); // Remove from output dispatcher tracking (ready/in-flight sets) diff --git a/crates/codirigent-ui/src/workspace/impl_settings.rs b/crates/codirigent-ui/src/workspace/impl_settings.rs index e56eb97..be07ce2 100644 --- a/crates/codirigent-ui/src/workspace/impl_settings.rs +++ b/crates/codirigent-ui/src/workspace/impl_settings.rs @@ -1,14 +1,123 @@ //! Settings management for WorkspaceView. use super::gpui::WorkspaceView; +use super::types::{ShellPickerOption, ShellPickerSection, SHELL_PICKER_AUTO_DETECT_LABEL}; use crate::app::OpenSettings; use crate::settings::SettingsPage; use codirigent_core::config_service::ConfigService; use gpui::{Context, Window}; +use std::collections::{HashMap, HashSet}; use std::time::Duration; use tracing::warn; +fn shell_picker_display_label(shell: &str) -> String { + if shell.is_empty() { + SHELL_PICKER_AUTO_DETECT_LABEL.to_string() + } else { + WorkspaceView::shell_display_label(Some(shell)) + } +} + +fn is_common_shell_option(shell: &str) -> bool { + matches!( + WorkspaceView::shell_display_label(Some(shell)) + .to_ascii_lowercase() + .as_str(), + "zsh" | "bash" | "fish" | "sh" | "pwsh" | "powershell" | "cmd" + ) +} + +fn build_shell_picker_options(shell_options: &[String]) -> Vec { + let mut normalized_label_counts = HashMap::new(); + for raw_value in shell_options { + *normalized_label_counts + .entry(shell_picker_display_label(raw_value)) + .or_insert(0usize) += 1; + } + + shell_options + .iter() + .enumerate() + .map(|(source_index, raw_value)| { + let base_label = shell_picker_display_label(raw_value); + let label = if normalized_label_counts + .get(&base_label) + .copied() + .unwrap_or_default() + > 1 + && !raw_value.is_empty() + && raw_value != &base_label + { + format!("{base_label} ({raw_value})") + } else { + base_label + }; + + ShellPickerOption { + source_index, + raw_value: raw_value.clone(), + label, + } + }) + .collect() +} + +fn build_shell_picker_sections(shell_options: &[String]) -> Vec { + let mut primary = Vec::new(); + let mut more = Vec::new(); + + for option in build_shell_picker_options(shell_options) { + if option.raw_value.is_empty() || is_common_shell_option(&option.raw_value) { + primary.push(option); + } else { + more.push(option); + } + } + + let mut sections = Vec::new(); + if !primary.is_empty() { + sections.push(ShellPickerSection { + title: None, + options: primary, + }); + } + if !more.is_empty() { + sections.push(ShellPickerSection { + title: Some("More"), + options: more, + }); + } + sections +} + +fn shell_picker_option_order(shell_options: &[String]) -> Vec { + build_shell_picker_sections(shell_options) + .into_iter() + .flat_map(|section| { + section + .options + .into_iter() + .map(|option| option.source_index) + }) + .collect() +} + impl WorkspaceView { + pub(super) fn shell_picker_sections( + &self, + shell_options: &[String], + ) -> Vec { + build_shell_picker_sections(shell_options) + } + + pub(super) fn shell_picker_display_label(shell: &str) -> String { + shell_picker_display_label(shell) + } + + pub(super) fn shell_picker_option_order(&self, shell_options: &[String]) -> Vec { + shell_picker_option_order(shell_options) + } + pub(super) fn effective_user_settings(&self) -> &codirigent_core::config::UserSettings { self.settings .page @@ -74,6 +183,8 @@ impl WorkspaceView { if !detected_shells.iter().any(|s| s.is_empty()) { detected_shells.insert(0, String::new()); } + let mut seen_shells = HashSet::new(); + detected_shells.retain(|shell| seen_shells.insert(shell.clone())); let mut detected_fonts = self.cache.monospace_fonts.clone().unwrap_or_default(); let current_font = &user_settings.terminal.font_family; @@ -372,3 +483,94 @@ impl WorkspaceView { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn shell_picker_sections_group_common_shells_before_more() { + let sections = build_shell_picker_sections(&[ + String::new(), + "nu".to_string(), + "zsh".to_string(), + "bash".to_string(), + "xonsh".to_string(), + ]); + + assert_eq!(sections.len(), 2); + assert_eq!(sections[0].title, None); + assert_eq!( + sections[0] + .options + .iter() + .map(|option| option.label.as_str()) + .collect::>(), + vec![SHELL_PICKER_AUTO_DETECT_LABEL, "zsh", "bash"] + ); + assert_eq!(sections[1].title, Some("More")); + assert_eq!( + sections[1] + .options + .iter() + .map(|option| option.label.as_str()) + .collect::>(), + vec!["nu", "xonsh"] + ); + } + + #[test] + fn shell_picker_sections_treat_normalized_common_shells_as_primary() { + let sections = build_shell_picker_sections(&[ + r"C:\Windows\System32\WindowsPowerShell\v1.0\POWERSHELL.EXE".to_string(), + "/bin/zsh".to_string(), + ]); + + assert_eq!(sections.len(), 1); + assert_eq!( + sections[0] + .options + .iter() + .map(|option| option.label.as_str()) + .collect::>(), + vec!["POWERSHELL", "zsh"] + ); + } + + #[test] + fn shell_picker_sections_disambiguate_duplicate_normalized_labels() { + let sections = build_shell_picker_sections(&[ + "zsh".to_string(), + "/bin/zsh".to_string(), + r"C:\Windows\System32\cmd.exe".to_string(), + "cmd".to_string(), + ]); + + assert_eq!(sections.len(), 1); + assert_eq!( + sections[0] + .options + .iter() + .map(|option| option.label.as_str()) + .collect::>(), + vec![ + "zsh", + "zsh (/bin/zsh)", + r"cmd (C:\Windows\System32\cmd.exe)", + "cmd", + ] + ); + } + + #[test] + fn shell_picker_option_order_matches_visual_section_order() { + let order = shell_picker_option_order(&[ + String::new(), + "nu".to_string(), + "zsh".to_string(), + "bash".to_string(), + ]); + + assert_eq!(order, vec![0, 2, 3, 1]); + } +} diff --git a/crates/codirigent-ui/src/workspace/impl_ui_operations.rs b/crates/codirigent-ui/src/workspace/impl_ui_operations.rs index 02e57ca..39ad249 100644 --- a/crates/codirigent-ui/src/workspace/impl_ui_operations.rs +++ b/crates/codirigent-ui/src/workspace/impl_ui_operations.rs @@ -100,9 +100,15 @@ impl WorkspaceView { } /// Open the session context menu for a specific session. - pub fn open_session_menu(&mut self, session_id: SessionId, cx: &mut Context) { + pub fn open_session_menu( + &mut self, + session_id: SessionId, + anchor_y: Option, + cx: &mut Context, + ) { info!(?session_id, "Opening session menu"); self.selection.session_menu_open = Some(session_id); + self.selection.session_menu_anchor_y = anchor_y; cx.notify(); } @@ -110,6 +116,7 @@ impl WorkspaceView { pub fn close_session_menu(&mut self, cx: &mut Context) { info!("Closing session menu"); self.selection.session_menu_open = None; + self.selection.session_menu_anchor_y = None; cx.notify(); } diff --git a/crates/codirigent-ui/src/workspace/modal_render.rs b/crates/codirigent-ui/src/workspace/modal_render.rs index cd65a79..3660d05 100644 --- a/crates/codirigent-ui/src/workspace/modal_render.rs +++ b/crates/codirigent-ui/src/workspace/modal_render.rs @@ -163,6 +163,7 @@ impl WorkspaceView { Some( div() .id("custom-layout-modal-overlay") + .occlude() .absolute() .inset_0() .flex() @@ -182,6 +183,7 @@ impl WorkspaceView { .child( div() .id("custom-layout-modal") + .occlude() .w(px(400.0)) .bg(bg) .border_1() @@ -771,12 +773,19 @@ impl WorkspaceView { Some( div() .id("session-action-overlay") + .occlude() .absolute() .inset_0() .flex() .items_center() .justify_center() .bg(gpui::Hsla::black().opacity(0.5)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + }), + ) .on_click(cx.listener(|this, _: &ClickEvent, _window, cx| { this.close_session_action_modal(); cx.notify(); @@ -784,6 +793,7 @@ impl WorkspaceView { .child( div() .id("session-action-modal") + .occlude() .w(px(420.0)) .bg(panel_bg) .border_1() @@ -791,6 +801,12 @@ impl WorkspaceView { .rounded_lg() .flex() .flex_col() + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + }), + ) // Prevent closing when clicking modal content .on_click(cx.listener(|_this, _: &ClickEvent, _window, cx| { cx.stop_propagation(); @@ -941,12 +957,19 @@ impl WorkspaceView { Some( div() .id("session-create-overlay") + .occlude() .absolute() .inset_0() .flex() .items_center() .justify_center() .bg(gpui::Hsla::black().opacity(0.5)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + }), + ) .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { if !modal_pending { this.close_session_creation_modal(); @@ -956,6 +979,7 @@ impl WorkspaceView { .child( div() .id("session-create-modal") + .occlude() .w(px(460.0)) .bg(panel_bg) .border_1() @@ -963,6 +987,12 @@ impl WorkspaceView { .rounded_lg() .flex() .flex_col() + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + }), + ) .on_click(cx.listener(|_this, _: &ClickEvent, _window, cx| { cx.stop_propagation(); })) @@ -1007,109 +1037,142 @@ impl WorkspaceView { .child("Shell"), ) .child({ - let mut list = div().flex().flex_col().gap_2().max_h(px(220.0)); + let shell_sections = + self.shell_picker_sections(&modal.shell_options); + let mut list = div().flex().flex_col().gap_2(); - for (index, option) in modal.shell_options.iter().enumerate() { - let is_selected = index == modal.selected_shell_index; - let option_label = - WorkspaceView::shell_display_label(Some(option)); - let option_hint = if option.is_empty() { - "Use the default shell setting or the platform default." - } else { - "Open the session in this shell." - }; - let option_border = - if is_selected { primary } else { border_color }; - let option_bg = if is_selected { - primary.opacity(0.12) - } else { - gpui::Hsla::transparent_black() - }; + for (section_index, section) in shell_sections.iter().enumerate() { + if section_index > 0 { + list = list.child( + div() + .h(px(1.0)) + .my_1() + .bg(border_color.opacity(0.5)), + ); + } + if let Some(title) = section.title { + list = list.child( + div() + .px_1() + .pt(px(4.0)) + .text_xs() + .font_weight(FontWeight::MEDIUM) + .text_color(muted.opacity(0.8)) + .child(title), + ); + } - list = list.child( - div() - .id(SharedString::from(format!( - "session-shell-option-{}", - index - ))) - .w_full() - .p_3() - .border_1() - .border_color(option_border) - .rounded_md() - .bg(option_bg) - .cursor_pointer() - .hover(|style| style.bg(row_hover)) - .on_click(cx.listener({ - move |this, _: &ClickEvent, _window, cx| { - if modal_pending { - return; - } - if let Some(active) = - this.modals.session_creation.as_mut() - { - active.selected_shell_index = index; - active.error = None; + for option in §ion.options { + let index = option.source_index; + let is_selected = index == modal.selected_shell_index; + let option_hint = if option.raw_value.is_empty() { + "Use the default shell setting or the platform default." + } else { + "Open the session in this shell." + }; + let option_border = + if is_selected { primary } else { border_color }; + let option_bg = if is_selected { + primary.opacity(0.12) + } else { + gpui::Hsla::transparent_black() + }; + + list = list.child( + div() + .id(SharedString::from(format!( + "session-shell-option-{}", + index + ))) + .w_full() + .p_3() + .border_1() + .border_color(option_border) + .rounded_md() + .bg(option_bg) + .cursor_pointer() + .hover(|style| style.bg(row_hover)) + .on_click(cx.listener({ + move |this, _: &ClickEvent, _window, cx| { + if modal_pending { + return; + } + if let Some(active) = + this.modals.session_creation.as_mut() + { + active.selected_shell_index = index; + active.error = None; + } + cx.notify(); } - cx.notify(); - } - })) - .child( - div() - .flex() - .items_start() - .gap_3() - .child( - div() - .mt_px() - .w(px(14.0)) - .h(px(14.0)) - .rounded_full() - .border_1() - .border_color(if is_selected { - primary - } else { - muted - }) - .bg(if is_selected { - primary - } else { - gpui::Hsla::transparent_black() - }), - ) - .child( - div() - .flex_1() - .flex() - .flex_col() - .gap_1() - .child( - div() - .text_sm() - .font_weight( - FontWeight::MEDIUM, - ) - .text_color(fg) - .child(option_label), - ) - .child( - div() - .text_xs() - .text_color( - if option.is_empty() { - warning.opacity(0.9) - } else { - muted - }, - ) - .child(option_hint), - ), - ), - ), - ); + })) + .child( + div() + .flex() + .items_start() + .gap_3() + .child( + div() + .mt_px() + .w(px(14.0)) + .h(px(14.0)) + .rounded_full() + .border_1() + .border_color(if is_selected { + primary + } else { + muted + }) + .bg(if is_selected { + primary + } else { + gpui::Hsla::transparent_black() + }), + ) + .child( + div() + .flex_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .text_sm() + .font_weight( + FontWeight::MEDIUM, + ) + .text_color(fg) + .child( + option.label.clone(), + ), + ) + .child( + div() + .text_xs() + .text_color( + if option.raw_value.is_empty() + { + warning.opacity(0.9) + } else { + muted + }, + ) + .child(option_hint), + ), + ), + ), + ); + } } - list + div() + .id("session-creation-shell-scroll") + .flex() + .flex_col() + .overflow_y_scroll() + .max_h(px(220.0)) + .pr_1() + .child(list) }) .when_some(modal.error.clone(), |this, error| { this.child(div().text_sm().text_color(error_color).child(error)) diff --git a/crates/codirigent-ui/src/workspace/pane_header_render.rs b/crates/codirigent-ui/src/workspace/pane_header_render.rs index 7d72da2..7ab8172 100644 --- a/crates/codirigent-ui/src/workspace/pane_header_render.rs +++ b/crates/codirigent-ui/src/workspace/pane_header_render.rs @@ -83,6 +83,10 @@ impl WorkspaceView { ); } + if let Some(cli_name) = &hints.cli_name { + header = header.child(Self::render_cli_badge(cli_name, border_color, muted, theme)); + } + if let Some(branch) = &hints.git_branch { header = header.child(self.render_git_branch_badge( branch, @@ -227,6 +231,7 @@ impl WorkspaceView { ); if tab_is_active { + let drag_source_pane_id = pane_id.clone(); tab = tab .cursor_grab() .on_mouse_down( @@ -238,6 +243,7 @@ impl WorkspaceView { ); this.selection.drag = Some(super::types::DragState { source_session_id: tab_session_id, + source_pane_id: drag_source_pane_id.clone(), source_index: drag_logical_index.unwrap_or(0), start_position: pos, current_position: pos, @@ -259,7 +265,7 @@ impl WorkspaceView { event.position.x.into(), event.position.y.into(), ); - drag.update_pointer(pos, &this.cache.render_cell_info); + drag.update_pointer(pos, &this.cache.render_pane_drop_targets); cx.notify(); }, )); @@ -277,9 +283,11 @@ impl WorkspaceView { hints: &TerminalHeaderRenderHints, border_color: gpui::Hsla, muted: gpui::Hsla, - orange: gpui::Hsla, + _orange: gpui::Hsla, ) -> gpui::Div { let git_fg = muted.opacity(0.8); + let git_addition: gpui::Hsla = crate::sidebar::Color::from_hex("#22c55e").into(); + let git_deletion: gpui::Hsla = crate::sidebar::Color::from_hex("#ef4444").into(); let git_badge_bg = border_color.opacity(0.25); let branch_label = if branch.chars().count() > 16 { let truncated: String = branch.chars().take(13).collect(); @@ -305,18 +313,53 @@ impl WorkspaceView { ) .child(div().text_xs().text_color(git_fg).child(branch_label)); - if let Some(count) = hints.git_dirty_count.filter(|count| *count > 0) { + if let Some(count) = hints.git_pending_additions.filter(|count| *count > 0) { git_badge = git_badge.child( div() .text_xs() - .text_color(orange) + .text_color(git_addition) .child(format!("+{}", count)), ); } + if let Some(count) = hints.git_pending_deletions.filter(|count| *count > 0) { + git_badge = git_badge.child( + div() + .text_xs() + .text_color(git_deletion) + .child(format!("-{}", count)), + ); + } + git_badge } + fn render_cli_badge( + cli_name: &str, + border_color: gpui::Hsla, + muted: gpui::Hsla, + theme: &CodirigentTheme, + ) -> gpui::Div { + let cli_fg: gpui::Hsla = theme.primary.into(); + + div() + .px(px(4.0)) + .py_px() + .rounded_sm() + .bg(border_color.opacity(0.25)) + .flex() + .flex_shrink_0() + .items_center() + .gap_1() + .child(div().text_xs().text_color(muted.opacity(0.6)).child("CLI")) + .child( + div() + .text_xs() + .text_color(cli_fg) + .child(cli_name.to_owned()), + ) + } + fn render_shell_badge( &mut self, shell_label: &str, diff --git a/crates/codirigent-ui/src/workspace/render.rs b/crates/codirigent-ui/src/workspace/render.rs index cf6499e..016594a 100644 --- a/crates/codirigent-ui/src/workspace/render.rs +++ b/crates/codirigent-ui/src/workspace/render.rs @@ -20,14 +20,11 @@ use crate::icons; use crate::title_bar::TitleBar; use gpui::{ div, prelude::FluentBuilder, px, ClickEvent, Context, FontWeight, InteractiveElement, - IntoElement, ParentElement, SharedString, StatefulInteractiveElement, Styled, Window, - WindowControlArea, + IntoElement, MouseButton, MouseDownEvent, ParentElement, SharedString, + StatefulInteractiveElement, Styled, Window, WindowControlArea, }; use tracing::info; -/// Row height for session entries in the session context menu. -const SESSION_MENU_ROW_HEIGHT: f32 = 36.0; - impl WorkspaceView { /// Render the title bar with window controls (minimize, maximize, close). /// @@ -197,8 +194,11 @@ impl WorkspaceView { } /// Render empty cell inline with pre-computed colors (returns Stateful
). + #[allow(clippy::too_many_arguments)] pub(super) fn render_empty_cell_inline_with_colors( &mut self, + pane_id: codirigent_core::PaneId, + index: usize, position: codirigent_core::GridPosition, panel_bg: gpui::Hsla, border_color: gpui::Hsla, @@ -206,7 +206,20 @@ impl WorkspaceView { cell_height: f32, cx: &mut Context, ) -> gpui::Stateful { - div() + let is_drop_target = self + .selection + .drag + .as_ref() + .and_then(|drag| drag.target.as_ref()) + .is_some_and(|target| target.pane_id == pane_id && target.index == index); + let current_border = if is_drop_target { + let primary: gpui::Hsla = self.workspace().theme().primary.into(); + primary + } else { + border_color + }; + + let empty = div() .id(SharedString::from(format!( "empty-cell-{}-{}", position.row, position.col @@ -214,8 +227,7 @@ impl WorkspaceView { .w_full() .h(px(cell_height)) .bg(panel_bg) - .border_1() - .border_color(border_color) + .border_color(current_border) .rounded_lg() .border_dashed() .flex() @@ -241,7 +253,13 @@ impl WorkspaceView { .text_xs() .text_color(muted) .child(super::types::EMPTY_CELL_MESSAGE), - ) + ); + + if is_drop_target { + empty.border_2() + } else { + empty.border_1() + } } /// Render the session context menu (right-click dropdown). @@ -263,10 +281,16 @@ impl WorkspaceView { let destructive = super::types::DESTRUCTIVE_ITEM_COLOR; let orange: gpui::Hsla = theme.orange.into(); - // Check if this session has a group - let (session_group, session_shell) = { + // Check if this session has a group and collect metadata shown in the dropdown. + let (session_group, session_shell, project_name, cli_name) = { let session = self.workspace().session(session_id)?; - (session.group.clone(), session.shell.clone()) + ( + session.group.clone(), + session.shell.clone(), + super::gpui::session_project_name(session) + .unwrap_or_else(|| "Unknown project".to_string()), + self.session_cli_display_name(session_id), + ) }; let has_group = session_group.is_some(); let (shell_label, shell_warning) = @@ -286,30 +310,30 @@ impl WorkspaceView { groups }; - // Compute vertical position based on session's index in the list - let row_index = self - .workspace() - .sessions() - .iter() - .position(|s| s.id == session_id) - .unwrap_or(0); - let top_offset = crate::title_bar::TitleBar::DEFAULT_HEIGHT - + crate::top_bar::TopBar::HEIGHT - + super::types::DRAWER_HEADER_HEIGHT - + (row_index as f32) * SESSION_MENU_ROW_HEIGHT; + let top_offset = self.selection.session_menu_anchor_y.unwrap_or_else(|| { + crate::title_bar::TitleBar::DEFAULT_HEIGHT + + crate::top_bar::TopBar::HEIGHT + + super::types::DRAWER_HEADER_HEIGHT + + self.session_drawer_row_offset(session_id).unwrap_or(0.0) + }); // Transparent click-away backdrop (no dark overlay) let backdrop = div() .id("session-menu-backdrop") + .occlude() .absolute() .inset_0() - .on_click(cx.listener(|this, _: &ClickEvent, _window, cx| { - this.close_session_menu(cx); - })); + .on_mouse_down( + MouseButton::Left, + cx.listener(|this, _: &MouseDownEvent, _window, cx| { + this.close_session_menu(cx); + cx.stop_propagation(); + }), + ); // Build dropdown menu let mut dropdown = div() - .w(px(180.0)) + .w(px(240.0)) .bg(panel_bg) .border_1() .border_color(border_color) @@ -329,6 +353,24 @@ impl WorkspaceView { .flex() .flex_col() .gap_1() + .child( + div() + .text_xs() + .text_color(muted.opacity(0.6)) + .child("PROJECT"), + ) + .child( + div() + .text_sm() + .text_color(fg) + .overflow_hidden() + .text_ellipsis() + .child(project_name), + ) + .when_some(cli_name, |el, cli_name| { + el.child(div().text_xs().text_color(muted.opacity(0.6)).child("CLI")) + .child(div().text_sm().text_color(fg).child(cli_name)) + }) .child( div() .text_xs() @@ -424,14 +466,22 @@ impl WorkspaceView { Some( div() .id("session-menu-container") + .occlude() .absolute() .inset_0() .child(backdrop) .child( div() + .occlude() .absolute() .left(px(left_offset)) .top(px(top_offset)) + .on_mouse_down( + MouseButton::Left, + cx.listener(|_this, _: &MouseDownEvent, _window, cx| { + cx.stop_propagation(); + }), + ) .child(dropdown), ), ) diff --git a/crates/codirigent-ui/src/workspace/settings_panels.rs b/crates/codirigent-ui/src/workspace/settings_panels.rs index 838fc6b..97aef67 100644 --- a/crates/codirigent-ui/src/workspace/settings_panels.rs +++ b/crates/codirigent-ui/src/workspace/settings_panels.rs @@ -13,8 +13,14 @@ use crate::terminal_view::CursorShape; use super::types::DROPDOWN_TRIGGER_HEIGHT; -/// Displayed in dropdowns when no specific value is configured (auto-selected). -const AUTO_DETECT_LABEL: &str = "(Auto-detect)"; +const SETTINGS_DROPDOWN_MAX_HEIGHT: f32 = 280.0; + +#[derive(Clone)] +enum DropdownEntry { + Option { value: String, label: String }, + Section { label: String }, + Separator, +} impl super::gpui::WorkspaceView { /// Render the full settings overlay (sidebar + content area). @@ -175,9 +181,36 @@ impl super::gpui::WorkspaceView { selected: &str, cx: &mut Context, on_select: impl Fn(&mut Self, String, &mut Window, &mut Context) + 'static, + ) -> impl IntoElement { + let entries = options + .iter() + .map(|option| DropdownEntry::Option { + value: (*option).to_string(), + label: (*option).to_string(), + }) + .collect::>(); + self.render_dropdown_control_with_entries( + dropdown_id, + &entries, + selected, + selected, + cx, + on_select, + ) + } + + fn render_dropdown_control_with_entries( + &self, + dropdown_id: &str, + entries: &[DropdownEntry], + selected_value: &str, + selected_display: &str, + cx: &mut Context, + on_select: impl Fn(&mut Self, String, &mut Window, &mut Context) + 'static, ) -> impl IntoElement { let theme = self.workspace.theme(); let fg: Hsla = theme.foreground.into(); + let muted: Hsla = theme.muted.into(); let panel_bg: Hsla = theme.panel_background.into(); let border: Hsla = theme.border.into(); let hover_bg: Hsla = theme.hover.into(); @@ -191,7 +224,7 @@ impl super::gpui::WorkspaceView { == Some(dropdown_id); let dd_id = dropdown_id.to_string(); - let selected_display = selected.to_string(); + let selected_display = selected_display.to_string(); // Trigger button -- stores click position for anchored overlay let trigger = div() @@ -248,7 +281,69 @@ impl super::gpui::WorkspaceView { .map(|p| p.dropdown_click_pos) .unwrap_or((0.0, 0.0)); - let mut options_list = div() + let mut options_body = div().flex().flex_col(); + + for entry in entries { + match entry { + DropdownEntry::Option { value, label } => { + let opt_value = value.clone(); + let opt_label = label.clone(); + let is_selected = value == selected_value; + let cb = on_select.clone(); + + options_body = options_body.child( + div() + .id(SharedString::from(format!("{}-opt-{}", dd_id, value))) + .px_2() + .py(px(6.0)) + .text_color(if is_selected { accent } else { fg }) + .bg(if is_selected { + Hsla { a: 0.1, ..accent } + } else { + panel_bg + }) + .cursor_pointer() + .hover(|s| s.bg(hover_bg)) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _, window, cx| { + cb(this, opt_value.clone(), window, cx); + if let Some(page) = this.settings.page.as_mut() { + page.open_dropdown = None; + } + cx.notify(); + }), + ) + .child(opt_label), + ); + } + DropdownEntry::Section { label } => { + options_body = options_body.child( + div() + .px_2() + .pt(px(8.0)) + .pb(px(4.0)) + .text_xs() + .text_color(muted.opacity(0.7)) + .child(label.clone()), + ); + } + DropdownEntry::Separator => { + options_body = + options_body.child(div().h(px(1.0)).mx_2().my_1().bg(border)); + } + } + } + + let options_list = div() + .id(SharedString::from(format!("{}-scroll", dd_id))) + .flex() + .flex_col() + .overflow_y_scroll() + .max_h(px(SETTINGS_DROPDOWN_MAX_HEIGHT)) + .child(options_body); + + let options_panel = div() .min_w(px(140.0)) .bg(panel_bg) .border_1() @@ -258,39 +353,8 @@ impl super::gpui::WorkspaceView { .py_1() .flex() .flex_col() - .overflow_hidden(); - - for opt in options { - let opt_str = opt.to_string(); - let is_selected = *opt == selected; - let cb = on_select.clone(); - - options_list = options_list.child( - div() - .id(SharedString::from(format!("{}-opt-{}", dd_id, opt))) - .px_2() - .py(px(6.0)) - .text_color(if is_selected { accent } else { fg }) - .bg(if is_selected { - Hsla { a: 0.1, ..accent } - } else { - panel_bg - }) - .cursor_pointer() - .hover(|s| s.bg(hover_bg)) - .on_mouse_down( - MouseButton::Left, - cx.listener(move |this, _, window, cx| { - cb(this, opt_str.clone(), window, cx); - if let Some(page) = this.settings.page.as_mut() { - page.open_dropdown = None; - } - cx.notify(); - }), - ) - .child(opt.to_string()), - ); - } + .overflow_hidden() + .child(options_list); // Click-away backdrop (closes dropdown when clicking outside) let backdrop = div() @@ -314,7 +378,7 @@ impl super::gpui::WorkspaceView { .anchor(Corner::TopLeft) .position(point(px(click_x), px(click_y + DROPDOWN_TRIGGER_HEIGHT))) .snap_to_window_with_margin(px(8.0)) - .child(div().occlude().child(options_list)), + .child(div().occlude().child(options_panel)), ) .with_priority(1); @@ -354,25 +418,25 @@ impl super::gpui::WorkspaceView { let editor_options: Vec<&str> = page.detected_editors.iter().map(|s| s.as_str()).collect(); - // Build shell options: empty string displays as AUTO_DETECT_LABEL - let shell_display_options: Vec = page - .detected_shells - .iter() - .map(|s| { - if s.is_empty() { - AUTO_DETECT_LABEL.to_string() - } else { - s.clone() - } - }) - .collect(); - let shell_option_refs: Vec<&str> = - shell_display_options.iter().map(|s| s.as_str()).collect(); - let shell_display = if shell.is_empty() { - AUTO_DETECT_LABEL.to_string() - } else { - shell.clone() - }; + let shell_sections = self.shell_picker_sections(&page.detected_shells); + let mut shell_entries = Vec::new(); + for (section_index, section) in shell_sections.iter().enumerate() { + if section_index > 0 { + shell_entries.push(DropdownEntry::Separator); + } + if let Some(title) = section.title { + shell_entries.push(DropdownEntry::Section { + label: title.to_string(), + }); + } + for option in §ion.options { + shell_entries.push(DropdownEntry::Option { + value: option.raw_value.clone(), + label: option.label.clone(), + }); + } + } + let shell_display = Self::shell_picker_display_label(&shell); div() .flex() @@ -401,20 +465,15 @@ impl super::gpui::WorkspaceView { "Default shell", "Shell used for new sessions", theme, - self.render_dropdown_control( + self.render_dropdown_control_with_entries( "dd-shell", - &shell_option_refs, + &shell_entries, + &shell, &shell_display, cx, |this, val, _, _| { if let Some(page) = this.settings.page.as_mut() { - // Map AUTO_DETECT_LABEL back to empty string - let stored = if val == AUTO_DETECT_LABEL { - String::new() - } else { - val - }; - page.user_settings.general.default_shell = stored; + page.user_settings.general.default_shell = val; page.user_save_pending = true; } }, diff --git a/crates/codirigent-ui/src/workspace/split_render.rs b/crates/codirigent-ui/src/workspace/split_render.rs index 5e67010..fdb2f4e 100644 --- a/crates/codirigent-ui/src/workspace/split_render.rs +++ b/crates/codirigent-ui/src/workspace/split_render.rs @@ -273,12 +273,25 @@ impl WorkspaceView { muted: gpui::Hsla, cx: &mut Context, ) -> gpui::Stateful { - div() + let pane_id = codirigent_core::PaneId::SplitSlot { slot }; + let is_drop_target = self + .selection + .drag + .as_ref() + .and_then(|drag| drag.target.as_ref()) + .is_some_and(|target| target.pane_id == pane_id); + let current_border = if is_drop_target { + let primary: gpui::Hsla = self.workspace().theme().primary.into(); + primary + } else { + border_color + }; + + let empty = div() .id(SharedString::from(format!("empty-slot-{}", slot.0))) .size_full() .bg(panel_bg) - .border_1() - .border_color(border_color) + .border_color(current_border) .rounded_lg() .border_dashed() .flex() @@ -303,7 +316,13 @@ impl WorkspaceView { .text_xs() .text_color(muted) .child(super::types::EMPTY_CELL_MESSAGE), - ) + ); + + if is_drop_target { + empty.border_2() + } else { + empty.border_1() + } } } diff --git a/crates/codirigent-ui/src/workspace/task_board_render.rs b/crates/codirigent-ui/src/workspace/task_board_render.rs index 783291a..f5f1f3f 100644 --- a/crates/codirigent-ui/src/workspace/task_board_render.rs +++ b/crates/codirigent-ui/src/workspace/task_board_render.rs @@ -639,15 +639,20 @@ impl WorkspaceView { "menu-{}-{}", id_suffix, session_id.0 ))) + .occlude() .h(px(30.0)) .px_3() .flex() .items_center() .cursor_pointer() .hover(move |style| style.bg(hover_bg.opacity(0.1))) - .on_click(cx.listener(move |this, _: &ClickEvent, _window, cx| { - this.handle_session_menu_action(session_id, action.clone(), cx); - })) + .on_mouse_down( + MouseButton::Left, + cx.listener(move |this, _: &MouseDownEvent, _window, cx| { + this.handle_session_menu_action(session_id, action.clone(), cx); + cx.stop_propagation(); + }), + ) .child(self.aligned_icon_label_row( icon, fg, diff --git a/crates/codirigent-ui/src/workspace/tests.rs b/crates/codirigent-ui/src/workspace/tests.rs index 252b549..6133633 100644 --- a/crates/codirigent-ui/src/workspace/tests.rs +++ b/crates/codirigent-ui/src/workspace/tests.rs @@ -94,6 +94,27 @@ fn test_workspace_remove_session() { assert!(ws.remove_session(SessionId(99)).is_none()); } +#[test] +fn test_workspace_remove_session_promotes_hidden_grid_session() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + let removed = ws.remove_session(SessionId(2)); + assert!(removed.is_some()); + + assert_eq!(ws.visible_sessions().len(), 4); + assert!(ws.is_session_visible(SessionId(5))); + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1), SessionId(5), SessionId(3), SessionId(4)] + ); +} + #[test] fn test_workspace_session_access() { let mut ws = Workspace::new(); @@ -1021,6 +1042,49 @@ fn test_workspace_group_session_into_grid_pane_creates_tabs_without_reflow() { assert!(ws.is_session_visible(SessionId(2))); } +#[test] +fn test_workspace_group_session_into_empty_grid_pane_moves_session() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "S1"))); + assert!(ws.add_session(make_session(2, "S2"))); + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 3 })); + + let cells = ws.cell_info(); + assert_eq!( + cells + .iter() + .map(|cell| (cell.session_id, cell.index)) + .collect::>(), + vec![(SessionId(2), 1), (SessionId(1), 3)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 3 }), + Some(SessionId(1)) + ); +} + +#[test] +fn test_workspace_group_session_into_empty_split_slot_moves_session() { + let mut ws = Workspace::new(); + ws.set_split_tree(LayoutNode::from_grid(1, 2)); + assert!(ws.add_session(make_session(1, "S1"))); + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::SplitSlot { slot: SlotId(1) })); + + assert_eq!( + ws.cell_info() + .into_iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(1)] + ); + assert_eq!( + ws.pane_active_session_id(PaneId::SplitSlot { slot: SlotId(1) }), + Some(SessionId(1)) + ); +} + #[test] fn test_workspace_activate_pane_tab_switches_active_session() { let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); @@ -1082,6 +1146,28 @@ fn test_workspace_remove_active_tab_promotes_next_tab() { ); } +#[test] +fn test_workspace_focus_hidden_grid_session_prefers_empty_cell() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + for i in 1..=5 { + assert!(ws.add_session(make_session(i, &format!("S{}", i)))); + } + + assert!(ws.group_session_into_pane(SessionId(1), PaneId::GridCell { index: 1 })); + assert!(!ws.is_session_visible(SessionId(5))); + assert_eq!(ws.visible_sessions().len(), 3); + + assert!(ws.focus_session(SessionId(5))); + assert_eq!( + ws.cell_info() + .iter() + .map(|cell| cell.session_id) + .collect::>(), + vec![SessionId(5), SessionId(1), SessionId(3), SessionId(4)] + ); + assert!(ws.is_session_visible(SessionId(1))); +} + #[test] fn test_workspace_restore_pane_tab_groups_rehydrates_active_tabs() { let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); @@ -1125,23 +1211,23 @@ fn test_layout_changes_preserve_hidden_pane_stacks_and_active_tabs() { assert_eq!( ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), - vec![SessionId(1), SessionId(2)] + vec![SessionId(3), SessionId(4)] ); assert_eq!( ws.pane_active_session_id(PaneId::GridCell { index: 0 }), - Some(SessionId(2)) + Some(SessionId(4)) ); assert_eq!( ws.pane_stacks(), vec![ - PaneStackState { - session_ids: vec![SessionId(1), SessionId(2)], - active_session_id: SessionId(2), - }, PaneStackState { session_ids: vec![SessionId(3), SessionId(4)], active_session_id: SessionId(4), }, + PaneStackState { + session_ids: vec![SessionId(1), SessionId(2)], + active_session_id: SessionId(2), + }, ] ); @@ -1165,6 +1251,79 @@ fn test_layout_changes_preserve_hidden_pane_stacks_and_active_tabs() { ); } +#[test] +fn test_split_layout_to_grid_preserves_tab_stacks_without_duplicate_visible_sessions() { + let mut ws = Workspace::with_profile(LayoutProfile::Single); + assert!(ws.add_session(make_session(1, "S1"))); + assert!(ws.add_session_to_pane(make_session(2, "S2"), PaneId::GridCell { index: 0 })); + + ws.set_split_tree(codirigent_core::LayoutNode::from_grid(1, 2)); + let slot = ws + .layout_state() + .as_split_tree() + .expect("workspace should be in split mode") + .assignments()[1] + .0; + assert!(ws.add_session_to_slot(make_session(3, "S3"), slot)); + assert!(ws.add_session_to_pane(make_session(4, "S4"), PaneId::SplitSlot { slot })); + + ws.set_layout(LayoutProfile::Grid2x2); + + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(1), SessionId(2)] + ); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(3), SessionId(4)] + ); + + let visible_ids = ws + .cell_info() + .into_iter() + .map(|info| info.session_id) + .collect::>(); + assert_eq!(visible_ids, vec![SessionId(2), SessionId(4)]); +} + +#[test] +fn test_switch_to_single_reorders_pane_stack_to_match_focused_session() { + let mut ws = Workspace::new(); + assert!(ws.add_session(make_session(1, "Session 1"))); + assert!(ws.add_session(make_session(2, "Session 2"))); + assert!(ws.add_session(make_session(3, "Session 3"))); + assert!(ws.group_session_into_pane(SessionId(2), PaneId::GridCell { index: 0 })); + assert!(ws.focus_session(SessionId(3))); + + ws.set_layout(LayoutProfile::Single); + + assert_eq!(ws.layout_state().focused_session(), Some(SessionId(3))); + assert_eq!( + ws.visible_sessions() + .into_iter() + .map(|session| session.id) + .collect::>(), + vec![SessionId(3)] + ); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 0 }), + vec![SessionId(3)] + ); + assert_eq!( + ws.pane_stacks(), + vec![ + PaneStackState { + session_ids: vec![SessionId(3)], + active_session_id: SessionId(3), + }, + PaneStackState { + session_ids: vec![SessionId(1), SessionId(2)], + active_session_id: SessionId(2), + }, + ] + ); +} + #[test] fn test_workspace_restore_pane_stacks_preserves_hidden_stack_order() { let mut ws = Workspace::with_profile(LayoutProfile::Single); @@ -1218,22 +1377,23 @@ fn test_workspace_restore_pane_stacks_preserves_hidden_stack_order() { #[cfg(feature = "gpui-full")] #[test] fn test_drag_state_updates_target_after_leaving_source_header() { - let cells = vec![ - CellInfo { + let panes = vec![ + PaneDropTargetInfo { pane_id: PaneId::GridCell { index: 0 }, - session_id: SessionId(1), + active_session_id: Some(SessionId(1)), index: 0, bounds: Bounds::new(0.0, 0.0, 100.0, 100.0), }, - CellInfo { + PaneDropTargetInfo { pane_id: PaneId::GridCell { index: 1 }, - session_id: SessionId(2), + active_session_id: Some(SessionId(2)), index: 1, bounds: Bounds::new(120.0, 0.0, 100.0, 100.0), }, ]; let mut drag = super::types::DragState { source_session_id: SessionId(1), + source_pane_id: PaneId::GridCell { index: 0 }, source_index: 0, start_position: Point::new(10.0, 10.0), current_position: Point::new(10.0, 10.0), @@ -1241,15 +1401,17 @@ fn test_drag_state_updates_target_after_leaving_source_header() { target: None, }; - drag.update_pointer(Point::new(20.0, 20.0), &cells); + drag.update_pointer(Point::new(20.0, 20.0), &panes); assert!(drag.active); assert_eq!(drag.target, None); - drag.update_pointer(Point::new(140.0, 20.0), &cells); + drag.update_pointer(Point::new(140.0, 20.0), &panes); assert_eq!( drag.target, Some(super::types::DragTarget { + pane_id: PaneId::GridCell { index: 1 }, index: 1, + active_session_id: Some(SessionId(2)), kind: super::types::DragTargetKind::PaneHeader, }) ); @@ -1258,29 +1420,192 @@ fn test_drag_state_updates_target_after_leaving_source_header() { #[cfg(feature = "gpui-full")] #[test] fn test_drag_state_does_not_target_source_or_activate_too_early() { - let cells = vec![CellInfo { + let panes = vec![PaneDropTargetInfo { pane_id: PaneId::GridCell { index: 0 }, - session_id: SessionId(1), + active_session_id: Some(SessionId(1)), index: 0, bounds: Bounds::new(0.0, 0.0, 100.0, 100.0), }]; let mut drag = super::types::DragState { source_session_id: SessionId(1), + source_pane_id: PaneId::GridCell { index: 0 }, source_index: 0, start_position: Point::new(10.0, 10.0), current_position: Point::new(10.0, 10.0), active: false, target: Some(super::types::DragTarget { + pane_id: PaneId::GridCell { index: 0 }, index: 0, + active_session_id: Some(SessionId(1)), kind: super::types::DragTargetKind::PaneHeader, }), }; - drag.update_pointer(Point::new(12.0, 12.0), &cells); + drag.update_pointer(Point::new(12.0, 12.0), &panes); assert!(!drag.active); assert_eq!(drag.target, None); - drag.update_pointer(Point::new(20.0, 20.0), &cells); + drag.update_pointer(Point::new(20.0, 20.0), &panes); assert!(drag.active); assert_eq!(drag.target, None); } + +#[cfg(feature = "gpui-full")] +#[test] +fn test_drag_state_targets_empty_grid_pane_body() { + let panes = vec![ + PaneDropTargetInfo { + pane_id: PaneId::GridCell { index: 0 }, + active_session_id: Some(SessionId(1)), + index: 0, + bounds: Bounds::new(0.0, 0.0, 100.0, 100.0), + }, + PaneDropTargetInfo { + pane_id: PaneId::GridCell { index: 1 }, + active_session_id: None, + index: 1, + bounds: Bounds::new(120.0, 0.0, 100.0, 100.0), + }, + ]; + let mut drag = super::types::DragState { + source_session_id: SessionId(1), + source_pane_id: PaneId::GridCell { index: 0 }, + source_index: 0, + start_position: Point::new(10.0, 10.0), + current_position: Point::new(10.0, 10.0), + active: false, + target: None, + }; + + drag.update_pointer(Point::new(160.0, 60.0), &panes); + + assert_eq!( + drag.target, + Some(super::types::DragTarget { + pane_id: PaneId::GridCell { index: 1 }, + index: 1, + active_session_id: None, + kind: super::types::DragTargetKind::PaneBody, + }) + ); +} + +#[cfg(feature = "gpui-full")] +#[test] +fn test_apply_session_drag_drop_swaps_when_dropped_on_pane_body() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "Session 1"))); + assert!(ws.add_session(make_session(2, "Session 2"))); + + let changed = super::impl_pointer_interactions::apply_session_drag_drop( + &mut ws, + &super::types::DragState { + source_session_id: SessionId(1), + source_pane_id: PaneId::GridCell { index: 0 }, + source_index: 0, + start_position: Point::new(0.0, 0.0), + current_position: Point::new(20.0, 20.0), + active: true, + target: None, + }, + &super::types::DragTarget { + pane_id: PaneId::GridCell { index: 1 }, + index: 1, + active_session_id: Some(SessionId(2)), + kind: super::types::DragTargetKind::PaneBody, + }, + ); + + assert!(changed); + assert_eq!( + ws.visible_sessions() + .into_iter() + .map(|session| session.id) + .collect::>(), + vec![SessionId(2), SessionId(1)] + ); +} + +#[cfg(feature = "gpui-full")] +#[test] +fn test_apply_session_drag_drop_groups_when_dropped_on_header() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "Session 1"))); + assert!(ws.add_session(make_session(2, "Session 2"))); + + let changed = super::impl_pointer_interactions::apply_session_drag_drop( + &mut ws, + &super::types::DragState { + source_session_id: SessionId(1), + source_pane_id: PaneId::GridCell { index: 0 }, + source_index: 0, + start_position: Point::new(0.0, 0.0), + current_position: Point::new(20.0, 20.0), + active: true, + target: None, + }, + &super::types::DragTarget { + pane_id: PaneId::GridCell { index: 1 }, + index: 1, + active_session_id: Some(SessionId(2)), + kind: super::types::DragTargetKind::PaneHeader, + }, + ); + + assert!(changed); + assert_eq!( + ws.pane_tab_session_ids(PaneId::GridCell { index: 1 }), + vec![SessionId(2), SessionId(1)] + ); + assert_eq!( + ws.pane_stacks(), + vec![PaneStackState { + session_ids: vec![SessionId(2), SessionId(1)], + active_session_id: SessionId(1), + }] + ); +} + +#[cfg(feature = "gpui-full")] +#[test] +fn test_apply_session_drag_drop_moves_into_empty_pane_body() { + let mut ws = Workspace::with_profile(LayoutProfile::Grid2x2); + assert!(ws.add_session(make_session(1, "Session 1"))); + assert!(ws.add_session(make_session(2, "Session 2"))); + + let changed = super::impl_pointer_interactions::apply_session_drag_drop( + &mut ws, + &super::types::DragState { + source_session_id: SessionId(1), + source_pane_id: PaneId::GridCell { index: 0 }, + source_index: 0, + start_position: Point::new(0.0, 0.0), + current_position: Point::new(20.0, 20.0), + active: true, + target: None, + }, + &super::types::DragTarget { + pane_id: PaneId::GridCell { index: 2 }, + index: 2, + active_session_id: None, + kind: super::types::DragTargetKind::PaneBody, + }, + ); + + assert!(changed); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 2 }), + Some(SessionId(1)) + ); + assert_eq!( + ws.pane_active_session_id(PaneId::GridCell { index: 0 }), + None + ); + assert_eq!( + ws.visible_sessions() + .into_iter() + .map(|session| session.id) + .collect::>(), + vec![SessionId(2), SessionId(1)] + ); +} diff --git a/crates/codirigent-ui/src/workspace/types.rs b/crates/codirigent-ui/src/workspace/types.rs index 7711b17..b2cdf60 100644 --- a/crates/codirigent-ui/src/workspace/types.rs +++ b/crates/codirigent-ui/src/workspace/types.rs @@ -3,7 +3,7 @@ //! This module contains struct and enum definitions used throughout the workspace //! implementation, including modal states and UI component data. -use super::CellInfo; +use crate::workspace::core::PaneDropTargetInfo; use codirigent_core::{PaneId, SessionId, SessionStatus, SlotId, TaskId}; use codirigent_session::codex_session_reader::CodexSessionReader; use codirigent_session::gemini_session_reader::GeminiSessionReader; @@ -40,9 +40,15 @@ pub(super) const SESSION_NAME_PREFIX: &str = "Session "; /// Label shown when a session uses the default shell resolution path. pub(super) const SESSION_SHELL_AUTO_LABEL: &str = "Auto"; +/// Label shown in shell pickers when the app should resolve the default shell automatically. +pub(super) const SHELL_PICKER_AUTO_DETECT_LABEL: &str = "(Auto-detect)"; + /// Height of session and group rows in the Sessions drawer panel. pub(super) const SESSION_ROW_HEIGHT: f32 = 28.0; +/// Height of rows in the Sessions drawer list. +pub(super) const SESSION_DRAWER_ROW_HEIGHT: f32 = 56.0; + /// Height of input fields and modal rows (larger than session rows). pub(super) const MODAL_FIELD_HEIGHT: f32 = 36.0; @@ -128,14 +134,6 @@ pub(super) const BRANCH_NAME_COLOR: Hsla = Hsla { a: 1.0, }; -/// Amber color used for the dirty-file count indicator in session rows. -pub(super) const DIRTY_INDICATOR_COLOR: Hsla = Hsla { - h: 0.1, - s: 0.8, - l: 0.6, - a: 1.0, -}; - /// Light red used for destructive hover text (close-tab button, etc.). /// /// Lighter than `DESTRUCTIVE_BUTTON_BG` to work as foreground text color. @@ -248,12 +246,25 @@ pub(super) struct SessionCreationModal { pub(super) error: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ShellPickerOption { + pub(super) source_index: usize, + pub(super) raw_value: String, + pub(super) label: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub(super) struct ShellPickerSection { + pub(super) title: Option<&'static str>, + pub(super) options: Vec, +} + #[derive(Debug, Clone)] pub(super) struct RestoreShellFallback { /// Shell originally requested by the saved session. pub(super) requested_shell: String, - /// Shell actually launched for the restored session. `None` means Auto. - pub(super) effective_shell: Option, + /// User-facing label for the shell actually launched for the restored session. + pub(super) effective_shell_label: String, } /// Context menu state for file tree right-click. @@ -304,6 +315,8 @@ pub(super) struct SelectionState { pub selected_session_id: Option, /// Session menu state: which session's menu is open (if any). pub session_menu_open: Option, + /// Vertical anchor position for the session menu overlay, in window pixels. + pub session_menu_anchor_y: Option, /// Whether the user is actively dragging a text selection in a terminal. pub is_selecting: bool, /// Session ID that is currently being selected in (for mouse move events). @@ -329,18 +342,24 @@ pub(super) enum DragTargetKind { } /// Current drop target under the pointer. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq)] pub(super) struct DragTarget { + /// Visible pane identifier under the pointer. + pub pane_id: PaneId, /// Grid or split logical cell index. pub index: usize, + /// Whether the pane currently has an active session. + pub active_session_id: Option, /// Whether the pointer is over the header or body region. pub kind: DragTargetKind, } -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone)] pub(super) struct DragState { /// Session being dragged. pub source_session_id: SessionId, + /// Visible pane the session originated from. + pub source_pane_id: PaneId, /// Grid index (or slot index) of the source cell. pub source_index: usize, /// Mouse position when drag started (screen pixels). @@ -379,7 +398,11 @@ impl DragState { /// This is shared between header-local and workspace-global mouse move /// handlers so reordering keeps working after the cursor leaves the /// source header. - pub(super) fn update_pointer(&mut self, position: crate::layout::Point, cells: &[CellInfo]) { + pub(super) fn update_pointer( + &mut self, + position: crate::layout::Point, + panes: &[PaneDropTargetInfo], + ) { self.current_position = position; if !self.active { @@ -392,19 +415,21 @@ impl DragState { self.active = true; } - self.target = cells + self.target = panes .iter() - .find(|cell| cell.bounds.contains(position)) - .and_then(|cell| { - (cell.index != self.source_index).then(|| { - let header_bottom = cell.bounds.origin.y + HEADER_HEIGHT; + .find(|pane| pane.bounds.contains(position)) + .and_then(|pane| { + (pane.pane_id != self.source_pane_id).then(|| { + let header_bottom = pane.bounds.origin.y + HEADER_HEIGHT; let kind = if position.y <= header_bottom { DragTargetKind::PaneHeader } else { DragTargetKind::PaneBody }; DragTarget { - index: cell.index, + pane_id: pane.pane_id.clone(), + index: pane.index, + active_session_id: pane.active_session_id, kind, } }) @@ -417,6 +442,7 @@ impl SelectionState { Self { selected_session_id: None, session_menu_open: None, + session_menu_anchor_y: None, is_selecting: false, selecting_session_id: None, file_tree_context_menu: None, @@ -582,6 +608,8 @@ pub(super) struct CacheState { pub detected_editors: Option>, /// Cached available shells detected from the system (populated in background on init). pub detected_shells: Option>, + /// Effective shell labels for running sessions, used so "Auto" resolves to the real shell. + pub effective_shell_labels: HashMap, /// Sessions restored with a fallback shell because their requested shell was unavailable. pub restore_shell_fallbacks: HashMap, /// Last PTY-resized dimensions per session, used to skip redundant resize calls. @@ -597,6 +625,8 @@ pub(super) struct CacheState { pub cached_cell_dims: Option, /// Cached cell layout info reused by resize and paint passes. pub render_cell_info: Vec, + /// Cached pane bounds reused by drag/drop hit testing. + pub render_pane_drop_targets: Vec, /// Whether `render_cell_info` must be recomputed before use. pub render_cell_info_dirty: bool, /// Last geometry signature used to build `render_cell_info`. @@ -617,6 +647,7 @@ impl CacheState { monospace_fonts: None, detected_editors: None, detected_shells: None, + effective_shell_labels: HashMap::new(), restore_shell_fallbacks: HashMap::new(), pty_sizes: HashMap::new(), manually_assigned_sessions: HashSet::new(), @@ -624,6 +655,7 @@ impl CacheState { drawer_group_expanded: HashMap::new(), cached_cell_dims: None, render_cell_info: Vec::new(), + render_pane_drop_targets: Vec::new(), render_cell_info_dirty: true, render_layout_signature: None, layout_generation: 0, diff --git a/docs/task-verification-workflow.md b/docs/task-verification-workflow.md new file mode 100644 index 0000000..bca3eb4 --- /dev/null +++ b/docs/task-verification-workflow.md @@ -0,0 +1,146 @@ +# Task Verification Workflow + +Required procedure for any implementation task that is meant to land on a +working branch without regressions. + +--- + +## Goal + +Each task must be completed as a small, reviewable unit: + +1. implement the task +2. run the full verification matrix +3. do a depth code review of the actual result +4. commit locally +5. move to the next task + +Do not batch multiple unfinished tasks into one verification pass or one +commit. + +--- + +## Branch Rules + +- Create a dedicated working branch before starting the task series. +- Keep each task in its own local commit. +- Do not push automatically. +- Wait for human review after the full task series is complete. + +--- + +## Per-Task Procedure + +### 1. Implement + +- Make the code change for exactly one task. +- Keep the scope tight. +- If the task changes UI behavior, include UI/UX quality in the implementation, + not just raw correctness. +- If the task affects platform-specific behavior, check macOS, Linux, and + Windows implications before treating it as done. + +### 2. Run the Required Verification Matrix + +Run these commands after the task implementation is complete: + +```bash +cargo clean +cargo build --all-features +cargo test --all --all-targets --all-features +cargo test -p codirigent-ui --lib --features gpui-full +cargo clippy --all --all-targets --all-features -- -D warnings +cargo fmt --all --check +bash scripts/audit-unwraps.sh +``` + +Notes: + +- `cargo clean` is required before the validation pass for each task. +- `gpui-full` should be run where the task touches GPUI-backed UI behavior. In + practice, use the dedicated `codirigent-ui` command above for UI work. +- `audit-unwraps.sh` is part of the gate. If the repo already has a known + baseline, confirm the touched code did not add to it. +- If a test failure appears unrelated or flaky, rerun it directly and then + rerun the broader suite before deciding it is not caused by the task. + +### 3. Perform a Depth Code Review + +After the verification commands pass, review the resulting diff again with a +fresh code review mindset. + +Review for: + +- behavioral regressions +- missing edge-case handling +- cross-platform issues +- UI/UX quality issues +- incorrect assumptions about focus, event propagation, timing, or state sync +- dead code, stale constants, and unused paths created by the change +- tests that should have been added but were not + +Recommended review commands: + +```bash +git diff -- +git status --short +rg -n "" +``` + +The review pass is not optional. Passing tests is necessary but not sufficient. + +### 4. Commit + +- Commit only after the implementation, verification pass, and review pass are + complete. +- Use a commit message that describes the task outcome, not the debugging path. +- Do not amend older commits unless explicitly requested. +- Do not push. + +--- + +## Multi-Task Series + +When working through several TODO items: + +1. finish task 1 +2. run the full matrix +3. review deeply +4. commit task 1 +5. repeat the same process for task 2 +6. continue until the list is complete + +After the last task: + +- confirm the final branch state is clean +- summarize all local commits +- note any existing baseline issues that were observed but not introduced +- stop and wait for review + +--- + +## Failure Handling + +If any required command fails: + +- do not commit +- fix the issue and rerun the required matrix +- if the failure looks flaky, isolate it, rerun it, then rerun the broader + command that originally failed +- if the failure is outside the task scope and genuinely pre-existing, document + that explicitly before asking for a decision + +--- + +## Completion Standard + +A task is only complete when all of the following are true: + +- the code change is implemented +- the required verification commands pass +- the post-verification review is done +- cross-platform concerns were considered +- UI/UX quality was considered where relevant +- the task is committed locally + +Anything short of that is still in progress.