1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
//! Application state and business logic for the dashboard TUI.
mod agents;
mod appearance;
mod background;
mod events;
mod preview;
mod types;
mod worktrees;
pub use types::*;
use anyhow::Result;
use ratatui::widgets::TableState;
use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::atomic::AtomicBool;
use std::sync::{Arc, mpsc};
use std::time::{Duration, SystemTime, UNIX_EPOCH};
use crate::config::Config;
use crate::git::{self, GitStatus};
use crate::github::PrSummary;
use crate::multiplexer::{AgentPane, Multiplexer};
use crate::state::StateStore;
use crate::workflow::types::WorktreeInfo;
use super::ui::theme::ThemePalette;
const PR_FETCH_INTERVAL: Duration = Duration::from_secs(30);
use super::scope::ScopeMode;
use super::settings::{load_hide_stale, load_last_pane_id, load_preview_size};
use super::sort::{SortMode, WorktreeSortMode};
/// App state for the TUI
pub struct App {
/// The multiplexer backend
pub mux: Arc<dyn Multiplexer>,
pub agents: Vec<AgentPane>,
/// Full agent list before name/stale filtering (populated by refresh())
all_agents: Vec<AgentPane>,
pub table_state: TableState,
/// Track the selected item by pane_id to preserve selection across reorders
selected_pane_id: Option<String>,
/// The directory from which the dashboard was launched (used to indicate the active worktree).
pub current_worktree: Option<PathBuf>,
pub stale_threshold_secs: u64,
pub config: Config,
pub should_quit: bool,
pub should_jump: bool,
pub sort_mode: SortMode,
/// Current view mode (Dashboard or Diff modal)
pub view_mode: ViewMode,
/// Cached preview of the currently selected agent's terminal output
pub preview: Option<String>,
/// Track which pane_id the preview was captured from (to detect selection changes)
preview_pane_id: Option<String>,
/// Input mode: keystrokes are sent directly to the selected agent's pane
pub input_mode: bool,
/// Manual scroll offset for the preview (None = auto-scroll to bottom)
pub preview_scroll: Option<u16>,
/// Number of lines in the current preview content
pub preview_line_count: u16,
/// Height of the preview area (updated during rendering)
pub preview_height: u16,
/// Git status for each worktree path
pub git_statuses: HashMap<PathBuf, GitStatus>,
/// Last time git status was fetched (to throttle background fetches)
last_git_fetch: std::time::Instant,
/// Flag to track if a git fetch is in progress (prevents thread pile-up)
pub is_git_fetching: Arc<AtomicBool>,
/// PR info indexed by repo root, then branch name
pr_statuses: HashMap<PathBuf, HashMap<String, PrSummary>>,
/// Last PR fetch time
last_pr_fetch: std::time::Instant,
/// Flag to prevent concurrent PR fetches
is_pr_fetching: Arc<AtomicBool>,
/// Unified event sender (cloned by all background threads)
pub event_tx: mpsc::Sender<AppEvent>,
/// Cache of repo roots for agent paths
repo_roots: HashMap<PathBuf, PathBuf>,
/// Frame counter for spinner animation (increments each tick)
pub spinner_frame: u8,
/// Whether to hide stale agents from the list
pub hide_stale: bool,
/// Whether to show the help overlay
pub show_help: bool,
/// Preview pane size as percentage (1-90). Higher = larger preview.
pub preview_size: u8,
/// Last jumped-to pane_id for quick toggle (cached from settings)
last_pane_id: Option<String>,
/// Color palette based on the configured theme
pub palette: ThemePalette,
/// Current color scheme
pub scheme: crate::config::ThemeScheme,
/// Current theme mode (dark/light)
pub theme_mode: crate::config::ThemeMode,
/// Path to the project config file (for persisting theme changes)
config_path: Option<PathBuf>,
/// Dashboard scope filter mode (All or Session)
pub scope_mode: ScopeMode,
/// Session name at launch time (for session scope filtering)
launch_session: Option<String>,
/// Whether the filter input is active (accepting keystrokes)
pub filter_active: bool,
/// Text filter for filtering agents by name. Empty string means no filter.
pub filter_text: String,
/// Pane ID awaiting kill confirmation (set when pressing x on a working agent)
pub pending_kill_pane_id: Option<String>,
/// Which tab is active (Agents or Worktrees)
pub active_tab: DashboardTab,
/// Full worktree list from background fetch (baseline for filtering/sorting)
all_worktrees: Vec<WorktreeInfo>,
/// Filtered and sorted worktree list for display
pub worktrees: Vec<WorktreeInfo>,
/// Table state for the worktree view
pub worktree_table_state: TableState,
/// Track selected worktree by path for stable selection
selected_worktree_path: Option<PathBuf>,
/// Filter text for worktree view (separate from agent filter)
pub worktree_filter_text: String,
/// Whether worktree filter input is active
pub worktree_filter_active: bool,
/// Current sort mode for the worktree list
pub worktree_sort_mode: WorktreeSortMode,
/// Pending worktree removal (shown in confirmation modal)
pub pending_remove: Option<RemovePlan>,
/// Pending bulk sweep state (shown in sweep modal)
pub pending_sweep: Option<SweepState>,
/// Pending project picker state (shown in project picker modal)
pub pending_project_picker: Option<ProjectPicker>,
/// Pending base branch picker state (shown in base picker modal)
pub pending_base_picker: Option<BaseBranchPicker>,
/// Pending add-worktree modal state
pub pending_add_worktree: Option<AddWorktreeState>,
/// Override which repo's worktrees are shown (name, git root path)
pub worktree_project_override: Option<(String, PathBuf)>,
/// Flag to prevent concurrent worktree fetches
is_worktree_fetching: Arc<AtomicBool>,
/// Last time worktree list was fetched
last_worktree_fetch: std::time::Instant,
/// Cached git log preview for selected worktree
pub worktree_preview: Option<String>,
/// Path of the worktree whose preview is cached
worktree_preview_path: Option<PathBuf>,
/// Temporary status message shown in the footer (auto-clears after timeout)
pub status_message: Option<(String, std::time::Instant)>,
/// Whether to show the "New: workmux sidebar" tip in the tab header
pub show_sidebar_tip: bool,
/// Pane IDs of agents detected as interrupted by the sidebar daemon.
pub interrupted_pane_ids: std::collections::HashSet<String>,
/// Pending command palette state (shown in command palette modal)
pub pending_command_palette: Option<CommandPaletteState>,
}
impl App {
pub fn new(
mux: Arc<dyn Multiplexer>,
cli_session_filter: bool,
event_tx: mpsc::Sender<AppEvent>,
) -> Result<Self> {
let config = Config::load(None)?;
// Get the active pane's directory to indicate the active worktree.
// Try multiplexer first (handles popup case), fall back to current_dir.
let current_worktree = mux
.get_client_active_pane_path()
.or_else(|_| std::env::current_dir())
.ok();
// Preview size: CLI override > tmux saved > config default
// Clamp to 10-90 to handle manually corrupted tmux variables
let preview_size = load_preview_size()
.unwrap_or_else(|| config.dashboard.preview_size())
.clamp(10, 90);
// Determine theme mode: config override or auto-detect from terminal
let theme_mode = config
.theme
.mode
.unwrap_or_else(|| match terminal_light::luma() {
Ok(luma) if luma > 0.6 => crate::config::ThemeMode::Light,
_ => crate::config::ThemeMode::Dark,
});
let scheme = config.theme.scheme;
let palette = ThemePalette::from_config(&config.theme, theme_mode);
let config_path = crate::config::global_config_path();
let sort_mode = SortMode::load();
let scope_mode = if cli_session_filter {
ScopeMode::Session
} else {
ScopeMode::load()
};
let launch_session = mux.current_session();
let git_statuses = git::load_status_cache();
let pr_statuses = crate::github::load_pr_cache();
let hide_stale = load_hide_stale();
let last_pane_id = load_last_pane_id();
let mut app = Self {
mux,
agents: Vec::new(),
all_agents: Vec::new(),
table_state: TableState::default(),
selected_pane_id: None,
current_worktree,
stale_threshold_secs: 60 * 60, // 60 minutes
config,
should_quit: false,
should_jump: false,
sort_mode,
view_mode: ViewMode::default(),
preview: None,
preview_pane_id: None,
input_mode: false,
preview_scroll: None,
preview_line_count: 0,
preview_height: 0,
git_statuses,
// Set to past to trigger immediate fetch on first refresh
last_git_fetch: std::time::Instant::now() - Duration::from_secs(60),
is_git_fetching: Arc::new(AtomicBool::new(false)),
pr_statuses,
// Set to past to trigger immediate fetch on first refresh
last_pr_fetch: std::time::Instant::now() - PR_FETCH_INTERVAL,
is_pr_fetching: Arc::new(AtomicBool::new(false)),
event_tx,
repo_roots: HashMap::new(),
spinner_frame: 0,
hide_stale,
show_help: false,
preview_size,
last_pane_id,
palette,
scheme,
theme_mode,
config_path,
scope_mode,
launch_session,
filter_active: false,
filter_text: String::new(),
pending_kill_pane_id: None,
active_tab: DashboardTab::Agents,
all_worktrees: Vec::new(),
worktrees: Vec::new(),
worktree_table_state: TableState::default(),
selected_worktree_path: None,
worktree_filter_text: String::new(),
worktree_filter_active: false,
worktree_sort_mode: WorktreeSortMode::load(),
pending_remove: None,
pending_sweep: None,
pending_project_picker: None,
pending_base_picker: None,
pending_add_worktree: None,
worktree_project_override: None,
is_worktree_fetching: Arc::new(AtomicBool::new(false)),
// Set to past so first switch triggers immediate fetch
last_worktree_fetch: std::time::Instant::now() - Duration::from_secs(60),
worktree_preview: None,
worktree_preview_path: None,
status_message: None,
show_sidebar_tip: crate::tips::should_show_sidebar_tip(),
interrupted_pane_ids: std::collections::HashSet::new(),
pending_command_palette: None,
};
app.refresh();
// Select first item if available
if !app.agents.is_empty() {
app.table_state.select(Some(0));
app.selected_pane_id = app.agents.first().map(|a| a.pane_id.clone());
}
// Initial preview fetch
app.update_preview();
// Fetch worktree list early so PR fetching can include worktree repo roots
app.spawn_worktree_fetch();
Ok(app)
}
pub fn refresh(&mut self) {
// Load agents from StateStore with reconciliation against live pane state
self.all_agents = StateStore::new()
.and_then(|store| store.load_reconciled_agents(self.mux.as_ref()))
.unwrap_or_default();
// Load interrupted pane IDs from daemon runtime state
if let Ok(store) = StateStore::new() {
let backend = self.mux.name();
let instance = self.mux.instance_id();
let runtime = store.read_runtime(backend, &instance);
let now = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Ignore stale runtime state (daemon not running for >15s)
if now.saturating_sub(runtime.updated_ts) <= 15 {
self.interrupted_pane_ids = runtime.interrupted_pane_ids;
} else {
self.interrupted_pane_ids.clear();
}
}
// Cache repo roots for ALL agents before filtering (project picker needs all projects)
let paths_to_resolve: Vec<PathBuf> = self
.all_agents
.iter()
.filter(|a| !self.repo_roots.contains_key(&a.path))
.map(|a| a.path.clone())
.collect();
if !paths_to_resolve.is_empty() {
// Resolve repo roots in parallel using threads
let results: Vec<_> = paths_to_resolve
.into_iter()
.map(|path| {
std::thread::spawn(move || {
let root = git::get_repo_root_for(&path).ok();
(path, root)
})
})
.collect::<Vec<_>>()
.into_iter()
.filter_map(|handle| handle.join().ok())
.collect();
for (path, root) in results {
if let Some(r) = root {
self.repo_roots.insert(path, r);
}
}
}
// Apply session scope filter after caching repo roots
if self.scope_mode == ScopeMode::Session
&& let Some(ref session) = self.launch_session
{
self.all_agents.retain(|a| a.session == *session);
}
// Trigger background git status fetch every 5 seconds
if self.last_git_fetch.elapsed() >= Duration::from_secs(5) {
self.last_git_fetch = std::time::Instant::now();
self.spawn_git_status_fetch();
}
// Trigger PR fetch every 30 seconds (only update timer if fetch actually started)
if self.last_pr_fetch.elapsed() >= PR_FETCH_INTERVAL && self.spawn_pr_status_fetch() {
self.last_pr_fetch = std::time::Instant::now();
}
// Trigger background worktree fetch every 5 seconds
if self.active_tab == DashboardTab::Worktrees
&& self.last_worktree_fetch.elapsed() >= Duration::from_secs(5)
{
self.last_worktree_fetch = std::time::Instant::now();
self.spawn_worktree_fetch();
}
// Clear expired status messages
if let Some((_, created)) = &self.status_message
&& created.elapsed() >= Duration::from_millis(1500)
{
self.status_message = None;
}
// Apply name filter, stale filter, sort, and restore selection
self.apply_filters();
}
}