aboutsummaryrefslogtreecommitdiff
path: root/src/ui
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 02:57:03 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-05-24 02:59:39 +0300
commit80a314a9089b28c1bfdc2cdf5196e9a2eb9f11ba (patch)
treefca593cafa027f4283adcec80c2f8908ef79708e /src/ui
parenta132a7c3b9ade3ad4636e03b1f0930b5fa711766 (diff)
feat: add session navigator
refs #157
Diffstat (limited to 'src/ui')
-rw-r--r--src/ui/keybind_help.rs1
-rw-r--r--src/ui/menus.rs3
-rw-r--r--src/ui/navigator.rs480
3 files changed, 484 insertions, 0 deletions
diff --git a/src/ui/keybind_help.rs b/src/ui/keybind_help.rs
index 6fc778ea..c1f0fa74 100644
--- a/src/ui/keybind_help.rs
+++ b/src/ui/keybind_help.rs
@@ -79,6 +79,7 @@ pub(super) fn keybind_help_groups(
let workspace_tab = vec![
(keybind_label(&kb.workspace_picker), "workspace navigation"),
+ (keybind_label(&kb.goto), "session navigator"),
(keybind_label(&kb.new_workspace), "new workspace"),
(keybind_label(&kb.new_worktree), "new worktree"),
(keybind_label(&kb.open_worktree), "open worktree"),
diff --git a/src/ui/menus.rs b/src/ui/menus.rs
index c83b569f..c37d08d6 100644
--- a/src/ui/menus.rs
+++ b/src/ui/menus.rs
@@ -76,6 +76,7 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R
let resize = prefix_rhs_label(&kb.resize_mode);
let help = prefix_rhs_label(&kb.help);
let settings = prefix_rhs_label(&kb.settings);
+ let goto = prefix_rhs_label(&kb.goto);
let detach = prefix_rhs_label(&kb.detach);
let line = Line::from(vec![
Span::styled(" NAVIGATE ", mode_style),
@@ -86,6 +87,8 @@ pub(super) fn render_navigate_overlay(app: &AppState, frame: &mut Frame, area: R
Span::styled(" ws ", dim),
Span::styled("⇥", key),
Span::styled(" pane ", dim),
+ Span::styled(goto, key),
+ Span::styled(" navigator ", dim),
Span::styled(new_tab, key),
Span::styled(" new tab ", dim),
Span::styled(split_vertical, key),
diff --git a/src/ui/navigator.rs b/src/ui/navigator.rs
new file mode 100644
index 00000000..5cb208ae
--- /dev/null
+++ b/src/ui/navigator.rs
@@ -0,0 +1,480 @@
+use ratatui::{
+ layout::Rect,
+ style::{Modifier, Style},
+ text::{Line, Span},
+ widgets::{Clear, Paragraph},
+ Frame,
+};
+
+use super::{
+ scrollbar::{render_scrollbar, should_show_scrollbar},
+ status::{agent_icon, state_label_color},
+ widgets::{panel_contrast_fg, render_panel_shell},
+};
+use crate::app::state::{AppState, NavigatorRow, NavigatorStateFilter, NavigatorTarget};
+
+pub(super) fn render_navigator_overlay(app: &AppState, frame: &mut Frame) {
+ let popup = app.navigator_popup_rect();
+ let Some(inner) = render_panel_shell(frame, popup, app.palette.accent, app.palette.panel_bg)
+ else {
+ return;
+ };
+
+ let search = app.navigator_search_rect();
+ let body = app.navigator_body_rect();
+ let detail = app.navigator_detail_rect();
+ let footer = app.navigator_footer_rect();
+ render_search(app, frame, search);
+
+ if body.height > 0 {
+ render_separator(frame, Rect::new(inner.x, search.y + 1, inner.width, 1), app);
+ render_rows(app, frame, body);
+ render_navigator_scrollbar(app, frame, body);
+ }
+ render_detail(app, frame, detail);
+ render_footer(app, frame, footer);
+}
+
+fn render_search(app: &AppState, frame: &mut Frame, area: Rect) {
+ let p = &app.palette;
+ let focus_style = if app.navigator.search_focused {
+ Style::default().fg(p.accent).add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.overlay0)
+ };
+ let count = app
+ .workspaces
+ .iter()
+ .flat_map(|workspace| workspace.tabs.iter())
+ .map(|tab| tab.panes.len())
+ .sum::<usize>();
+ let mut spans = vec![Span::styled(" / ", focus_style)];
+ let query = app.navigator.query.trim();
+ match app.navigator.state_filter {
+ Some(NavigatorStateFilter::Blocked) => push_state_chip(
+ &mut spans,
+ crate::detect::AgentState::Blocked,
+ true,
+ app.spinner_tick,
+ "blocked",
+ app,
+ ),
+ Some(NavigatorStateFilter::Working) => push_state_chip(
+ &mut spans,
+ crate::detect::AgentState::Working,
+ true,
+ app.spinner_tick,
+ "working",
+ app,
+ ),
+ Some(NavigatorStateFilter::Idle) => push_state_chip(
+ &mut spans,
+ crate::detect::AgentState::Idle,
+ true,
+ app.spinner_tick,
+ "idle",
+ app,
+ ),
+ Some(NavigatorStateFilter::Done) => push_state_chip(
+ &mut spans,
+ crate::detect::AgentState::Idle,
+ false,
+ app.spinner_tick,
+ "done",
+ app,
+ ),
+ None if query.is_empty() => spans.push(Span::styled(
+ "search panes",
+ Style::default().fg(p.overlay0),
+ )),
+ None => spans.push(Span::styled(query.to_string(), Style::default().fg(p.text))),
+ }
+ spans.push(Span::styled(
+ format!(
+ "{count:>width$} panes",
+ width = area.width.saturating_sub(16) as usize
+ ),
+ Style::default().fg(p.overlay0),
+ ));
+ frame.render_widget(Paragraph::new(Line::from(spans)), area);
+}
+
+fn push_state_chip(
+ spans: &mut Vec<Span<'static>>,
+ state: crate::detect::AgentState,
+ seen: bool,
+ tick: u32,
+ label: &'static str,
+ app: &AppState,
+) {
+ let (icon, icon_style) = agent_icon(state, seen, tick, &app.palette);
+ spans.push(Span::styled(icon, icon_style.add_modifier(Modifier::BOLD)));
+ spans.push(Span::raw(" "));
+ spans.push(Span::styled(
+ label,
+ Style::default()
+ .fg(state_label_color(state, seen, &app.palette))
+ .add_modifier(Modifier::BOLD),
+ ));
+}
+
+fn render_separator(frame: &mut Frame, area: Rect, app: &AppState) {
+ if area.height == 0 || area.width == 0 {
+ return;
+ }
+ let line = "─".repeat(area.width as usize);
+ frame.render_widget(
+ Paragraph::new(line).style(Style::default().fg(app.palette.surface1)),
+ area,
+ );
+}
+
+fn render_rows(app: &AppState, frame: &mut Frame, body: Rect) {
+ let rows = app.navigator_rows();
+ let start = app.navigator.scroll.min(rows.len());
+ let end = rows.len().min(start.saturating_add(body.height as usize));
+ for (visible_idx, row) in rows[start..end].iter().enumerate() {
+ let idx = start + visible_idx;
+ let y = body.y + visible_idx as u16;
+ let rect = Rect::new(body.x, y, body.width, 1);
+ let selected = idx == app.navigator.selected;
+ render_row(app, frame, rect, row, selected);
+ }
+}
+
+fn render_row(app: &AppState, frame: &mut Frame, rect: Rect, row: &NavigatorRow, selected: bool) {
+ let p = &app.palette;
+ frame.render_widget(Clear, rect);
+ let base_style = if selected {
+ Style::default().bg(p.accent).fg(panel_contrast_fg(p))
+ } else {
+ Style::default().bg(p.panel_bg).fg(p.text)
+ };
+ let dim_style = if selected {
+ base_style
+ } else {
+ Style::default().fg(p.overlay0).bg(p.panel_bg)
+ };
+ let text_style = if selected {
+ base_style.add_modifier(Modifier::BOLD)
+ } else if row.is_current {
+ Style::default()
+ .fg(p.text)
+ .bg(p.panel_bg)
+ .add_modifier(Modifier::BOLD)
+ } else {
+ Style::default().fg(p.subtext0).bg(p.panel_bg)
+ };
+ let (status_icon, status_style) = agent_icon(row.status, row.seen, app.spinner_tick, p);
+ let status_style = if selected {
+ base_style.add_modifier(Modifier::BOLD)
+ } else {
+ status_style.bg(p.panel_bg)
+ };
+
+ let prefix = if row.is_workspace {
+ if row.expanded {
+ "▾"
+ } else {
+ "▸"
+ }
+ } else if row.depth > 0 {
+ "├─"
+ } else {
+ " "
+ };
+ let current = if row.is_current { "◆" } else { " " };
+ let marker = if selected { "→" } else { " " };
+ let indent = " ".repeat(row.depth as usize);
+ let left_fixed = format!(" {indent}{prefix} {marker} {current} ");
+ let meta_width = metadata_width(rect.width);
+ let left_budget = rect
+ .width
+ .saturating_sub(meta_width)
+ .saturating_sub(left_fixed.chars().count() as u16)
+ .saturating_sub(3) as usize;
+ let title = truncate_text(&row.label, left_budget);
+
+ let spans = vec![
+ Span::styled(left_fixed, dim_style),
+ Span::styled(status_icon, status_style),
+ Span::raw(" "),
+ Span::styled(title, text_style),
+ ];
+ frame.render_widget(Paragraph::new(Line::from(spans)).style(base_style), rect);
+
+ if meta_width > 0 {
+ let meta_rect = Rect::new(
+ rect.x + rect.width.saturating_sub(meta_width),
+ rect.y,
+ meta_width,
+ 1,
+ );
+ let meta = truncate_text(&row.meta, meta_width.saturating_sub(2) as usize);
+ let meta_style = if selected {
+ base_style
+ } else if row.is_workspace || row.is_tab {
+ Style::default().fg(p.overlay0).bg(p.panel_bg)
+ } else {
+ Style::default()
+ .fg(state_label_color(row.status, row.seen, p))
+ .bg(p.panel_bg)
+ };
+ frame.render_widget(
+ Paragraph::new(format!(" {meta}")).style(meta_style),
+ meta_rect,
+ );
+ }
+}
+
+fn render_navigator_scrollbar(app: &AppState, frame: &mut Frame, body: Rect) {
+ if body.width <= 1 || body.height == 0 {
+ return;
+ }
+ let rows = app.navigator_rows().len();
+ let viewport = body.height as usize;
+ if rows <= viewport {
+ return;
+ }
+ let metrics = crate::pane::ScrollMetrics {
+ viewport_rows: viewport,
+ offset_from_bottom: rows
+ .saturating_sub(viewport)
+ .saturating_sub(app.navigator.scroll),
+ max_offset_from_bottom: rows.saturating_sub(viewport),
+ };
+ if !should_show_scrollbar(metrics) {
+ return;
+ }
+ let track = Rect::new(body.x + body.width - 1, body.y, 1, body.height);
+ render_scrollbar(
+ frame,
+ metrics,
+ track,
+ app.palette.surface_dim,
+ app.palette.overlay0,
+ "▕",
+ );
+}
+
+fn metadata_width(width: u16) -> u16 {
+ if width >= 90 {
+ 28
+ } else if width >= 68 {
+ 20
+ } else if width >= 52 {
+ 14
+ } else {
+ 0
+ }
+}
+
+fn render_detail(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.height == 0 || area.width == 0 {
+ return;
+ }
+ render_separator(frame, area, app);
+ let detail = selected_detail(app);
+ if detail.is_empty() {
+ return;
+ }
+ let text = middle_elide(&detail, area.width.saturating_sub(2) as usize);
+ frame.render_widget(
+ Paragraph::new(format!(" {text}")).style(Style::default().fg(app.palette.overlay0)),
+ area,
+ );
+}
+
+fn selected_detail(app: &AppState) -> String {
+ let rows = app.navigator_rows();
+ let Some(row) = rows.get(app.navigator.selected) else {
+ return String::new();
+ };
+ match row.target {
+ NavigatorTarget::Workspace { ws_idx } => workspace_detail(app, ws_idx),
+ NavigatorTarget::Tab { ws_idx, tab_idx } => tab_detail(app, ws_idx, tab_idx),
+ NavigatorTarget::Pane {
+ ws_idx,
+ tab_idx,
+ pane_id,
+ } => pane_detail(app, ws_idx, tab_idx, pane_id),
+ }
+}
+
+fn workspace_detail(app: &AppState, ws_idx: usize) -> String {
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return String::new();
+ };
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let label = ws.display_name_from(&app.terminals, &terminal_runtimes);
+ let pane_count = ws.tabs.iter().map(|tab| tab.panes.len()).sum::<usize>();
+ let mut parts = vec![label, format!("{pane_count} panes")];
+ if !rowless_workspace_activity(app, ws_idx).is_empty() {
+ parts.push(rowless_workspace_activity(app, ws_idx));
+ }
+ parts.join(" · ")
+}
+
+fn tab_detail(app: &AppState, ws_idx: usize, tab_idx: usize) -> String {
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return String::new();
+ };
+ let Some(tab) = ws.tabs.get(tab_idx) else {
+ return String::new();
+ };
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let mut parts = vec![
+ ws.display_name_from(&app.terminals, &terminal_runtimes),
+ format!("tab: {}", tab.display_name()),
+ format!("{} panes", tab.panes.len()),
+ ];
+ let rows = app.navigator_rows();
+ if let Some(meta) = rows
+ .into_iter()
+ .find(|row| matches!(row.target, NavigatorTarget::Tab { ws_idx: row_ws_idx, tab_idx: row_tab_idx } if row_ws_idx == ws_idx && row_tab_idx == tab_idx))
+ .map(|row| row.meta)
+ .filter(|meta| !meta.is_empty())
+ {
+ parts.push(meta);
+ }
+ parts.join(" · ")
+}
+
+fn pane_detail(
+ app: &AppState,
+ ws_idx: usize,
+ tab_idx: usize,
+ pane_id: crate::layout::PaneId,
+) -> String {
+ let Some(ws) = app.workspaces.get(ws_idx) else {
+ return String::new();
+ };
+ let Some(tab) = ws.tabs.get(tab_idx) else {
+ return String::new();
+ };
+ let terminal_runtimes = crate::terminal::TerminalRuntimeRegistry::new();
+ let mut parts = vec![ws.display_name_from(&app.terminals, &terminal_runtimes)];
+ if ws.tabs.len() > 1 {
+ parts.push(format!("tab: {}", tab.display_name()));
+ }
+ if let Some(pane_number) = ws.public_pane_number(pane_id) {
+ parts.push(format!("pane {pane_number}"));
+ }
+ if let Some(terminal_id) = tab.terminal_id(pane_id) {
+ if let Some(terminal) = app.terminals.get(terminal_id) {
+ if let Some(agent) = terminal
+ .agent_name
+ .as_deref()
+ .or_else(|| terminal.effective_agent_label())
+ {
+ parts.push(agent.to_string());
+ let seen = tab
+ .panes
+ .get(&pane_id)
+ .map(|pane| pane.seen)
+ .unwrap_or(true);
+ parts.push(
+ display_state(row_state(app, ws_idx, tab_idx, pane_id), seen).to_string(),
+ );
+ } else {
+ parts.push("shell".to_string());
+ }
+ if let Some(status) = terminal.effective_custom_status() {
+ parts.push(status.to_string());
+ }
+ }
+ }
+ parts.join(" · ")
+}
+
+fn rowless_workspace_activity(app: &AppState, ws_idx: usize) -> String {
+ app.navigator_rows()
+ .into_iter()
+ .find(|row| matches!(row.target, NavigatorTarget::Workspace { ws_idx: row_ws_idx } if row_ws_idx == ws_idx))
+ .map(|row| row.meta)
+ .unwrap_or_default()
+}
+
+fn row_state(
+ app: &AppState,
+ ws_idx: usize,
+ tab_idx: usize,
+ pane_id: crate::layout::PaneId,
+) -> crate::detect::AgentState {
+ app.workspaces
+ .get(ws_idx)
+ .and_then(|ws| ws.tabs.get(tab_idx))
+ .and_then(|tab| tab.terminal_id(pane_id))
+ .and_then(|terminal_id| app.terminals.get(terminal_id))
+ .map(|terminal| terminal.state)
+ .unwrap_or(crate::detect::AgentState::Unknown)
+}
+
+fn display_state(state: crate::detect::AgentState, seen: bool) -> &'static str {
+ match (state, seen) {
+ (crate::detect::AgentState::Blocked, _) => "blocked",
+ (crate::detect::AgentState::Working, _) => "working",
+ (crate::detect::AgentState::Idle, false) => "done",
+ (crate::detect::AgentState::Idle, true) => "idle",
+ (crate::detect::AgentState::Unknown, _) => "unknown",
+ }
+}
+
+fn middle_elide(text: &str, max_width: usize) -> String {
+ let len = text.chars().count();
+ if len <= max_width {
+ return text.to_string();
+ }
+ if max_width <= 1 {
+ return "…".to_string();
+ }
+ let left = max_width.saturating_sub(1) / 2;
+ let right = max_width.saturating_sub(1).saturating_sub(left);
+ let prefix: String = text.chars().take(left).collect();
+ let suffix: String = text
+ .chars()
+ .rev()
+ .take(right)
+ .collect::<Vec<_>>()
+ .into_iter()
+ .rev()
+ .collect();
+ format!("{prefix}…{suffix}")
+}
+
+fn render_footer(app: &AppState, frame: &mut Frame, area: Rect) {
+ if area.height == 0 {
+ return;
+ }
+ let p = &app.palette;
+ let key = Style::default().fg(p.accent).add_modifier(Modifier::BOLD);
+ let dim = Style::default().fg(p.overlay0);
+ let line = Line::from(vec![
+ Span::styled(" enter", key),
+ Span::styled(" switch ", dim),
+ Span::styled("/", key),
+ Span::styled(" search ", dim),
+ Span::styled("b/w/i/d/a", key),
+ Span::styled(" states ", dim),
+ Span::styled("j/k/↑↓", key),
+ Span::styled(" move ", dim),
+ Span::styled("esc", key),
+ Span::styled(" close", dim),
+ ]);
+ frame.render_widget(Paragraph::new(line), area);
+}
+
+fn truncate_text(text: &str, max_width: usize) -> String {
+ let len = text.chars().count();
+ if len <= max_width {
+ return text.to_string();
+ }
+ if max_width == 0 {
+ return String::new();
+ }
+ if max_width == 1 {
+ return "…".to_string();
+ }
+ let prefix: String = text.chars().take(max_width.saturating_sub(1)).collect();
+ format!("{prefix}…")
+}