aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-03-30 14:54:21 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-03-30 14:54:21 +0300
commit55684493890b4f67c73ea07b5af5911ec1b4d80e (patch)
tree60d84536c377bc9a124c11a4b02f9e6da854caba /src
parentca93e6e2511d13b7041504e0665fff2d7a402c28 (diff)
refactor sidebar status rollup and preserve pane order
Diffstat (limited to 'src')
-rw-r--r--src/detect.rs40
-rw-r--r--src/input/tests.rs5
-rw-r--r--src/ui.rs24
-rw-r--r--src/workspace.rs124
4 files changed, 76 insertions, 117 deletions
diff --git a/src/detect.rs b/src/detect.rs
index ae7559bd..bc59fe25 100644
--- a/src/detect.rs
+++ b/src/detect.rs
@@ -16,19 +16,6 @@ pub enum AgentState {
Unknown,
}
-impl AgentState {
- /// Priority for rolling up pane states to a workspace indicator.
- /// Higher = more urgent.
- pub fn priority(self) -> u8 {
- match self {
- Self::Unknown => 0,
- Self::Idle => 1,
- Self::Working => 2,
- Self::Blocked => 3,
- }
- }
-}
-
/// Which agent we detected running in a pane.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Agent {
@@ -106,16 +93,6 @@ pub fn detect_state(agent: Option<Agent>, screen_content: &str) -> AgentState {
}
}
-/// Roll up multiple pane states into a single workspace state.
-/// Returns the highest-priority state among all panes.
-pub fn workspace_state(pane_states: &[AgentState]) -> AgentState {
- pane_states
- .iter()
- .max_by_key(|s| s.priority())
- .copied()
- .unwrap_or(AgentState::Unknown)
-}
-
// ---------------------------------------------------------------------------
// Per-agent detectors
// ---------------------------------------------------------------------------
@@ -622,23 +599,6 @@ mod tests {
// ---- Workspace state rollup ----
- #[test]
- fn workspace_state_blocked_wins() {
- let states = [AgentState::Idle, AgentState::Working, AgentState::Blocked];
- assert_eq!(workspace_state(&states), AgentState::Blocked);
- }
-
- #[test]
- fn workspace_state_working_over_idle() {
- let states = [AgentState::Idle, AgentState::Working, AgentState::Unknown];
- assert_eq!(workspace_state(&states), AgentState::Working);
- }
-
- #[test]
- fn workspace_state_empty() {
- assert_eq!(workspace_state(&[]), AgentState::Unknown);
- }
-
// ---- No agent → Unknown ----
#[test]
diff --git a/src/input/tests.rs b/src/input/tests.rs
index d9b5c101..a4f32626 100644
--- a/src/input/tests.rs
+++ b/src/input/tests.rs
@@ -278,7 +278,10 @@ fn kitty_shift_letter_release_does_not_emit_text() {
fn kitty_shifted_symbol_sends_text() {
let key = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::SHIFT)
.with_shifted_codepoint('!' as u32);
- assert_eq!(encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }), b"!");
+ assert_eq!(
+ encode_terminal_key(key, KeyboardProtocol::Kitty { flags: 7 }),
+ b"!"
+ );
}
#[test]
diff --git a/src/ui.rs b/src/ui.rs
index 99a8d38b..21153e5d 100644
--- a/src/ui.rs
+++ b/src/ui.rs
@@ -176,13 +176,8 @@ fn compute_sidebar_width(app: &AppState) -> u16 {
.map(|(i, ws)| {
let name_len = ws.display_name().len();
let number_len = (i + 1).to_string().len();
- let pane_dots = if ws.layout.pane_count() > 1 {
- ws.layout.pane_count()
- } else {
- 1
- };
- // marker + number + space + name + spaces + dots
- let line1 = 3 + number_len + name_len + 2 + pane_dots;
+ // marker + number + space + name + spaces + aggregate dot
+ let line1 = 3 + number_len + name_len + 3;
// branch line: " branch"
let line2 = ws.branch().map(|b| 3 + b.len()).unwrap_or(0);
line1.max(line2)
@@ -381,7 +376,7 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
Style::default().fg(p.overlay0)
};
- // Line 1: marker-space + number + name + state dots
+ // Line 1: marker-space + number + name + aggregate state dot
let marker = if is_active { " " } else { " " }; // accent bar handles active indicator
let mut line1 = vec![
Span::styled(marker, Style::default()),
@@ -390,16 +385,8 @@ fn render_workspace_list(app: &AppState, frame: &mut Frame, area: Rect, is_navig
Span::styled(" ", Style::default()),
];
- // State dots (one per pane, or single aggregate)
- if ws.layout.pane_count() == 1 {
- let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
- line1.push(Span::styled(icon, icon_style));
- } else {
- for (pane_state, pane_seen) in ws.pane_states() {
- let (icon, icon_style) = state_dot(pane_state, pane_seen, p);
- line1.push(Span::styled(icon, icon_style));
- }
- }
+ let (icon, icon_style) = state_dot(agg_state, agg_seen, p);
+ line1.push(Span::styled(icon, icon_style));
frame.render_widget(
Paragraph::new(Line::from(line1)),
@@ -1652,6 +1639,7 @@ fn render_toast_notification(
///
/// | State | Icon | Color |
/// |--------------------|------|--------|
+/// | Blocked | ● | Red |
/// | Working | ● | Yellow |
/// | Done (idle+unseen) | ● | Blue |
/// | Idle (seen) | ○ | Green |
diff --git a/src/workspace.rs b/src/workspace.rs
index 909c4315..68b4e624 100644
--- a/src/workspace.rs
+++ b/src/workspace.rs
@@ -5,7 +5,7 @@ use ratatui::layout::Direction;
use tokio::sync::mpsc;
use tracing::info;
-use crate::detect::{self, Agent, AgentState};
+use crate::detect::{Agent, AgentState};
use crate::events::AppEvent;
use crate::layout::{PaneId, TileLayout};
use crate::pane::{PaneRuntime, PaneState};
@@ -160,44 +160,18 @@ impl Workspace {
self.runtimes.get(&self.root_pane).and_then(|rt| rt.cwd())
}
- /// Agent name summary (e.g. "claude, amp"). Used by collapsed sidebar.
- pub fn agent_summary(&self) -> Option<String> {
- let mut names = Vec::new();
-
- for id in self.layout.pane_ids() {
- if let Some(agent) = self.panes.get(&id).and_then(|p| p.detected_agent) {
- let name = agent_name(agent);
- if !names.iter().any(|n| *n == name) {
- names.push(name);
- }
- }
- }
-
- if names.is_empty() {
- if self.root_cwd().is_some() {
- Some("shell".to_string())
- } else {
- None
- }
- } else if names.len() == 1 {
- Some(names[0].to_string())
- } else if names.len() == 2 {
- Some(format!("{}, {}", names[0], names[1]))
- } else {
- Some(format!("{}, {} +{}", names[0], names[1], names.len() - 2))
- }
- }
-
- /// Aggregate state + seen across all panes.
- /// Returns the highest-priority state and the worst-case seen flag.
+ /// Aggregate workspace signal for sidebar triage.
+ /// Returns the most urgent pane's state + seen flag.
pub fn aggregate_state(&self) -> (AgentState, bool) {
- let states: Vec<AgentState> = self.panes.values().map(|p| p.state).collect();
- let state = detect::workspace_state(&states);
- let seen = self.panes.values().all(|p| p.seen);
- (state, seen)
+ self.panes
+ .values()
+ .map(|pane| (pane.state, pane.seen))
+ .max_by_key(|(state, seen)| pane_attention_priority(*state, *seen))
+ .unwrap_or((AgentState::Unknown, true))
}
/// Per-pane (state, seen) in BSP tree order (left-to-right, top-to-bottom).
+ #[cfg(test)]
pub fn pane_states(&self) -> Vec<(AgentState, bool)> {
self.layout
.pane_ids()
@@ -211,10 +185,9 @@ impl Workspace {
.collect()
}
- /// Per-pane detail for the agent detail panel, ordered by urgency.
+ /// Per-pane detail for the agent detail panel, in stable layout order.
pub fn pane_details(&self) -> Vec<PaneDetail> {
- let mut details: Vec<PaneDetail> = self
- .layout
+ self.layout
.pane_ids()
.iter()
.map(|id| {
@@ -232,10 +205,7 @@ impl Workspace {
seen,
}
})
- .collect();
- // Sort by urgency: blocked > working > idle(unseen) > idle > unknown
- details.sort_by(|a, b| b.urgency().cmp(&a.urgency()));
- details
+ .collect()
}
/// Get the git branch for this workspace's root cwd.
@@ -264,16 +234,13 @@ pub struct PaneDetail {
pub seen: bool,
}
-impl PaneDetail {
- /// Urgency score for sort ordering (higher = more urgent, shown first).
- fn urgency(&self) -> u8 {
- match (self.state, self.seen) {
- (AgentState::Blocked, _) => 4,
- (AgentState::Working, _) => 3,
- (AgentState::Idle, false) => 2, // unseen/done
- (AgentState::Idle, true) => 1,
- (AgentState::Unknown, _) => 0,
- }
+fn pane_attention_priority(state: AgentState, seen: bool) -> u8 {
+ match (state, seen) {
+ (AgentState::Blocked, _) => 4,
+ (AgentState::Idle, false) => 3, // done, waiting for you to look
+ (AgentState::Working, _) => 2,
+ (AgentState::Idle, true) => 1,
+ (AgentState::Unknown, _) => 0,
}
}
@@ -430,22 +397,44 @@ mod tests {
ws.panes.get_mut(&root_id).unwrap().state = AgentState::Idle;
ws.panes.get_mut(&id2).unwrap().state = AgentState::Working;
- let (state, _) = ws.aggregate_state();
+ let (state, seen) = ws.aggregate_state();
assert_eq!(state, AgentState::Working);
+ assert!(seen);
}
#[test]
- fn aggregate_seen_any_unseen_means_unseen() {
+ fn aggregate_state_done_unseen_beats_working() {
let mut ws = Workspace::test_new("test");
let id2 = ws.test_split(Direction::Horizontal);
- ws.panes.get_mut(&id2).unwrap().seen = false;
+ let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
+ let root = ws.panes.get_mut(&root_id).unwrap();
+ root.state = AgentState::Idle;
+ root.seen = false;
+ ws.panes.get_mut(&id2).unwrap().state = AgentState::Working;
- let (_, seen) = ws.aggregate_state();
+ let (state, seen) = ws.aggregate_state();
+ assert_eq!(state, AgentState::Idle);
assert!(!seen);
}
#[test]
+ fn aggregate_state_blocked_beats_done_unseen() {
+ let mut ws = Workspace::test_new("test");
+ let id2 = ws.test_split(Direction::Horizontal);
+
+ let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
+ let root = ws.panes.get_mut(&root_id).unwrap();
+ root.state = AgentState::Idle;
+ root.seen = false;
+ ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
+
+ let (state, seen) = ws.aggregate_state();
+ assert_eq!(state, AgentState::Blocked);
+ assert!(seen);
+ }
+
+ #[test]
fn close_focused_removes_pane() {
let mut ws = Workspace::test_new("test");
let _id2 = ws.test_split(Direction::Horizontal);
@@ -475,8 +464,27 @@ mod tests {
let states = ws.pane_states();
assert_eq!(states.len(), 2);
- assert!(states.iter().any(|(s, _)| *s == AgentState::Blocked));
- assert!(states.iter().any(|(s, _)| *s == AgentState::Unknown));
+ assert_eq!(states[0].0, AgentState::Unknown);
+ assert_eq!(states[1].0, AgentState::Blocked);
+ }
+
+ #[test]
+ fn pane_details_stay_in_layout_order() {
+ let mut ws = Workspace::test_new("test");
+ let id2 = ws.test_split(Direction::Horizontal);
+
+ let root_id = *ws.panes.keys().find(|id| **id != id2).unwrap();
+ ws.panes.get_mut(&root_id).unwrap().detected_agent = Some(Agent::Pi);
+ ws.panes.get_mut(&root_id).unwrap().state = AgentState::Working;
+ ws.panes.get_mut(&id2).unwrap().detected_agent = Some(Agent::Claude);
+ ws.panes.get_mut(&id2).unwrap().state = AgentState::Blocked;
+
+ let details = ws.pane_details();
+ assert_eq!(details.len(), 2);
+ assert_eq!(details[0].label, "pi");
+ assert_eq!(details[0].state, AgentState::Working);
+ assert_eq!(details[1].label, "claude");
+ assert_eq!(details[1].state, AgentState::Blocked);
}
#[test]