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
376
377
378
379
380
381
382
//! Per-frame lifecycle on `App` — the master `tick()` method
//! (called every event-loop iteration by both `tui.rs` and
//! `headless.rs`), and the private helpers it fans out into
//! (yank-flash expiry, stale-highlight refresh, external-file
//! change detection, autosave-on-idle).
//!
//! Extracted from `app/mod.rs` (file-split refactor — Task #963).
//! Pure non-destructive move; no API change.
use super::*;
impl App {
pub fn tick(&mut self) {
// qa-bug 2026-06-30 — external git operations (user runs
// `git checkout` in a terminal outside mnml) weren't
// picked up by the rail's LOCAL branch list — only by the
// statusline branch chip (whose snapshot self.git.tick
// refreshes on a 3s TTL). Capture the branch snapshot
// before+after the tick; if it changed, refresh git_rail
// so the LOCAL `●` current-branch dot follows.
let before_branch = self.git.snapshot().branch.clone();
self.git.tick();
if self.git.snapshot().branch != before_branch {
let root = self.active_repo_path().to_path_buf();
self.git_rail.refresh(&root);
}
// Per-frame pty maintenance: `pump` drains the reader thread's bytes
// into each (!Send) libghostty terminal — done here (not just on draw)
// so hidden panes keep processing output. `tick_activity` then bumps
// the activity Instant the sessions panel's running/idle chip reads.
let mut pending_spend_toast: Option<(usize, f64)> = None;
for p in self.panes.iter_mut() {
if let crate::pane::Pane::Pty(s) = p {
s.pump();
s.tick_activity();
}
if let crate::pane::Pane::Mount(m) = p {
// Drain pending frames from the mount worker
// thread + detect integration exit. Render reads
// `latest_frame` set here.
m.pump();
}
if let crate::pane::Pane::SpendReport(sr) = p {
// 2026-06-29 claude-agents-power-user SEV-2: pull
// the spend_today worker's snapshot if ready.
// claude-agents 3rd SEV-3: when it arrives, queue a
// totals toast. The inline toast from ai_spend_today
// always sees loading=true (the worker hasn't run
// yet), so the totals-ready toast must fire from here.
if sr.poll_pending() {
pending_spend_toast = Some((
sr.snapshot.claude_sessions + sr.snapshot.codex_sessions,
sr.snapshot.total_cost_usd,
));
}
}
}
if let Some((sessions, cost)) = pending_spend_toast {
self.toast(format!("today: {sessions} sessions · ${cost:.4}"));
}
if let Some(scratch) = self.scratch_term.as_mut() {
scratch.session.pump();
scratch.session.tick_activity();
}
// Agents rail panel — pull the worker's snapshot if ready.
self.drain_agents_panel_refresh();
// Cloud-run trigger result, if a `+ New cloud run` is in flight.
self.drain_cloud_run_trigger();
// CloudAgentRun panes — pull fresh log lines + artifact rows
// from their worker threads into the pane state.
self.drain_cloud_agent_run_panes();
// Auto-refresh cloud-run detail panes whose interval has
// elapsed. No-op when no pane has auto enabled.
self.tick_cloud_agent_run_auto();
// Cloud-run worker messages — toast successes / errors from
// managed-agents submit threads.
self.drain_cloud_run_msgs();
// NewCloudAgentWizard panes — drain PR-list fetcher.
self.drain_new_cloud_agent_wizards();
self.drain_git_results();
self.maybe_announce_update();
self.drain_now_playing();
self.drain_http_jobs();
self.drain_sse_jobs();
self.drain_websocket();
self.drain_http_ai_build();
self.drain_http_chain();
self.drain_ws_send();
self.maybe_auto_refresh_claude_agents();
self.maybe_escalate_claude_kills();
self.drain_http_sync_result();
self.drain_http_sync_check_result();
self.drain_http_bench_result();
self.drain_lookup_fire_result();
// 2026-06-19 — keep started stamps in sync with rx. When
// a drain just cleared its rx, also clear the stamp so the
// cmdline_bar's `⟳ … (Ns)` indicator turns off.
if self.http_bench_rx.is_none() {
self.http_bench_started = None;
self.http_bench_progress = None;
}
if self.http_sync_rx.is_none() {
self.http_sync_started = None;
}
if self.lookup_fire_rx.is_none() {
self.lookup_fire_started = None;
}
self.drain_ai_jobs();
self.drain_ai_session_search();
self.drain_marketplace();
self.drain_launcher_installs();
self.drain_readme_fetches();
self.maybe_refresh_ai_usage();
self.drain_ai_usage();
self.maybe_poll_system_theme();
// 2026-08-17 — pull any completed manifest-declared
// `[[values_sources]]` poll results into their snapshots
// + re-render the `[[statusline_segments]]` chips that
// depend on them. See `src/app/statusline_segments.rs`.
self.drain_statusline_segments();
self.drain_pending_keychain();
self.drain_suggestions();
self.maybe_fire_suggestion();
self.drain_tests_jobs();
self.drain_linter_jobs();
self.drain_dap_events();
self.drain_lsp_events();
self.drain_cdp_events();
self.refresh_live_ai_panes();
self.drain_scm_pr_pending();
self.autosave_idle_buffers();
self.check_external_file_changes();
self.check_format_save_deadline();
self.block_insert_replay_if_done();
self.repeat_insert_replay_if_done();
self.expire_yank_flashes();
self.refresh_stale_highlights();
self.refresh_scroll_semantic_tokens();
self.maybe_fire_mouse_hover();
// mouse-round-10 SEV-2 2026-07-12 — hover on a toast pauses
// its TTL so the user has time to read a long message
// before it fades. The mouse handler sets
// `hover_chip = ToastBox(idx)` when the cursor is over a
// toast rect; here we bump the entry's `created_at` so its
// age effectively stays at whatever it was when the hover
// began. Also refreshes `App.toast`'s legacy timestamp
// when the newest stack entry is under the cursor.
if let Some((crate::HoverChip::ToastBox(hover_idx), _)) = self.hover_chip
&& hover_idx < self.toast_stack.len()
{
if let Some(entry) = self.toast_stack.get_mut(hover_idx) {
entry.created_at = std::time::Instant::now();
}
if hover_idx == 0
&& let Some((_, t)) = self.toast.as_mut()
{
*t = std::time::Instant::now();
}
}
if let Some((_, t)) = &self.toast
&& t.elapsed() >= TOAST_TTL
{
self.toast = None;
}
// Expire stacked toasts individually (entries are independent —
// a rapid burst of toasts ages out one-by-one rather than all
// at once).
while self
.toast_stack
.back()
.is_some_and(|e| e.created_at.elapsed() >= TOAST_TTL)
{
self.toast_stack.pop_back();
}
self.expire_progress_items();
self.tick_pending_undo();
self.tick_claude_agents_prefetch();
// #993 step 2b (2026-08-20) — background auto-update firing.
// Short-circuits when no opt-in is set (global OR per-integration).
self.tick_auto_updates();
}
/// Lines of viewport drift before [`Self::refresh_scroll_semantic_tokens`]
/// re-fires a `semanticTokens/range` request. 20 is a quiet middle
/// ground — small scrolls don't mash the server, but any meaningful
/// jump refreshes promptly.
pub(crate) const VIEWPORT_REFIRE_THRESHOLD: u32 = 20;
/// Clear `Buffer.yank_flash` entries older than ~200ms so the
/// highlight-on-yank overlay fades naturally.
fn expire_yank_flashes(&mut self) {
const YANK_FLASH_TTL: std::time::Duration = std::time::Duration::from_millis(200);
let now = std::time::Instant::now();
for pane in self.panes.iter_mut() {
if let Pane::Editor(b) = pane
&& let Some((_, _, started)) = b.yank_flash
&& now.duration_since(started) >= YANK_FLASH_TTL
{
b.yank_flash = None;
}
}
}
/// Re-run tree-sitter on any editor buffer whose `highlights_dirty` is
/// set AND whose last edit was more than ~120ms ago. Lets rapid
/// typing skip the re-parse hit; the next idle frame catches up.
fn refresh_stale_highlights(&mut self) {
const HIGHLIGHT_IDLE: std::time::Duration = std::time::Duration::from_millis(120);
let now = std::time::Instant::now();
for pane in self.panes.iter_mut() {
if let Pane::Editor(b) = pane
&& b.highlights_dirty
&& b.last_edited
.map(|t| now.duration_since(t) >= HIGHLIGHT_IDLE)
.unwrap_or(true)
{
b.refresh_highlights();
}
}
}
/// Check every open editor buffer's path for an external mtime
/// change vs the last-known `disk_mtime`. Throttled to once every
/// ~2 seconds (stat is cheap but not free, and tick fires
/// continuously). When divergence is detected:
/// - Clean buffer (no unsaved edits) ⇒ silently reload from disk +
/// toast "<file> reloaded".
/// - Dirty buffer ⇒ toast a warning ("<file> changed on disk —
/// :e! to discard / save to overwrite") and leave the buffer
/// alone. The mtime mirror is still updated so the warning fires
/// only once per change.
fn check_external_file_changes(&mut self) {
let now = std::time::Instant::now();
if let Some(last) = self.last_external_check
&& now.duration_since(last) < std::time::Duration::from_secs(2)
{
return;
}
self.last_external_check = Some(now);
// Collect the (idx, path, was_dirty) for buffers whose mtime
// diverges. Done as a separate pass to avoid borrow conflicts.
let mut diverged: Vec<(usize, std::path::PathBuf, bool)> = Vec::new();
for (i, p) in self.panes.iter().enumerate() {
let Pane::Editor(b) = p else { continue };
let Some(path) = &b.path else { continue };
let Some(last_known) = b.disk_mtime else {
continue;
};
let Ok(now_mtime) = std::fs::metadata(path).and_then(|m| m.modified()) else {
continue;
};
if now_mtime > last_known {
diverged.push((i, path.clone(), b.dirty));
}
}
for (idx, path, was_dirty) in diverged {
if was_dirty {
let rel = rel_path(&self.workspace, &path);
self.toast(format!(
"{rel} changed on disk — :e! to discard / save to overwrite"
));
// Update mtime so we don't re-toast next tick.
if let Some(Pane::Editor(b)) = self.panes.get_mut(idx) {
b.disk_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
}
} else {
// Clean ⇒ silently reload. Capture cursor + scroll, re-read,
// restore.
let (cursor, scroll) = if let Some(Pane::Editor(b)) = self.panes.get(idx) {
(b.editor.cursor(), b.scroll)
} else {
(0, 0)
};
if let Ok(text) = std::fs::read_to_string(&path)
&& let Some(Pane::Editor(b)) = self.panes.get_mut(idx)
{
let len = b.editor.text().len();
b.apply_edit_ops(
vec![crate::edit_op::EditOp::ReplaceRange {
start: 0,
end: len,
text: text.clone(),
}],
&mut self.clipboard,
0,
);
let new_len = b.editor.text().len();
b.editor.place_cursor(0, 0);
let _ = new_len; // placeholder if needed later
// Restore cursor + scroll best-effort.
let cur = cursor.min(b.editor.text().len());
let row = b.editor.text()[..cur]
.bytes()
.filter(|&c| c == b'\n')
.count();
let line_count = b.editor.line_count();
b.editor
.place_cursor(row.min(line_count.saturating_sub(1)), 0);
b.scroll = scroll.min(line_count.saturating_sub(1));
b.dirty = false;
b.disk_mtime = std::fs::metadata(&path).and_then(|m| m.modified()).ok();
let rel = rel_path(&self.workspace, &path);
self.toast(format!("{rel} reloaded"));
self.lsp.did_save(&path, &text);
}
}
}
}
/// `[editor] autosave_secs > 0` ⇒ save any dirty editor buffer whose last
/// edit was at least that long ago. No-op when off (the default). LSP gets a
/// `didSave` per saved file so the server stays in sync.
fn autosave_idle_buffers(&mut self) {
let after = self.config.editor.autosave_secs;
if after == 0 {
return;
}
let after = std::time::Duration::from_secs(after);
let saved: Vec<(std::path::PathBuf, String)> = self
.panes
.iter_mut()
.filter_map(|p| match p {
Pane::Editor(b) => {
if b.dirty
&& b.path.is_some()
&& b.last_edited.map(|t| t.elapsed() >= after).unwrap_or(false)
&& b.save_to_disk().is_ok()
{
b.path.clone().map(|p| (p, b.editor.text().to_string()))
} else {
None
}
}
_ => None,
})
.collect();
for (p, t) in saved {
self.lsp.did_save(&p, &t);
}
}
/// #1023 (2026-08-18) — When `[ui] theme_auto_system` is on,
/// poll the OS dark/light preference every 15s and swap
/// themes if the user toggled system-wide dark mode. No-op
/// when the pref is off, so users on split-preference setups
/// aren't overridden. First call schedules the initial poll
/// immediately; steady-state throttles to 1 poll per 15s.
///
/// Swap policy: if system is dark, apply `theme_toggle` (the
/// configured dark counterpart); if light, apply `theme`
/// (the base). Falls back to `theme` when `theme_toggle` is
/// unset. Matches the one-shot `theme.auto_system` command's
/// mapping in `src/command.rs`.
fn maybe_poll_system_theme(&mut self) {
if !self.config.ui.theme_auto_system {
return;
}
const THEME_POLL_MIN: std::time::Duration = std::time::Duration::from_secs(15);
let should_check = self
.last_theme_system_check
.map(|t| t.elapsed() >= THEME_POLL_MIN)
.unwrap_or(true);
if !should_check {
return;
}
self.last_theme_system_check = Some(std::time::Instant::now());
let is_dark = crate::ui::theme::detect_system_dark();
let target = if is_dark {
self.config
.ui
.theme_toggle
.clone()
.unwrap_or_else(|| self.config.ui.theme.clone())
} else {
self.config.ui.theme.clone()
};
if crate::ui::theme::cur().name != target && crate::ui::theme::set(&target).is_some() {
let mode = if is_dark { "dark" } else { "light" };
self.toast(format!("theme: {target} (system {mode})"));
}
}
}