fleetcom 0.9.0

A fleet-view supervisor for arbitrary shell commands.
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
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
//! Display-only summary adapters for the Anchor tier of the dashboard preview.
//! Each adapter extracts status text from an agent CLI's bottom chrome.
//!
//! # Display-only contract
//!
//! Adapter output is rendered in the dashboard and never enters a shell
//! command. It is therefore outside the session-ID validation boundary in
//! [`is_uuid`](super::is_uuid).
//!
//! # Anchor discipline
//!
//! Status-shaped text can also appear in scrollback or conversation content.
//! To avoid treating it as live status, every matcher:
//!
//! 1. locates the chrome region structurally (claude's separator-pair input
//!    box, codex's status bar and composer, grok's bordered input box) and
//!    limits status candidates relative to it;
//! 2. returns `None` when the expected structure is absent or inconsistent;
//! 3. matches row prefixes so status rows truncated with an ellipsis at narrow
//!    widths remain recognizable. A wrapped row fails the structural check.
//!
//! Normalization removes spinner glyphs, elapsed counters, throughput data,
//! and key hints while preserving the CLI's status text. The only synthesized
//! status is `awaiting approval`, for approval menus: claude's dialog and
//! codex's modal. Corpus fixtures in `tests/corpus` pin the supported screen
//! structures.

use std::path::Path;

use crate::preview::{ScreenFacts, SummaryAdapter};

/// Select an adapter by the basename of the command's first
/// whitespace-separated word. Arguments are accepted; environment prefixes
/// and compound shell commands do not select an adapter. Selection is
/// independent of session-capture instrumentation.
pub fn select(command: &str) -> Option<&'static dyn SummaryAdapter> {
    let first = command.split_whitespace().next()?;
    match Path::new(first).file_name()?.to_str()? {
        "claude" => Some(&ClaudeSummary),
        "codex" => Some(&CodexSummary),
        "grok" => Some(&GrokSummary),
        _ => None,
    }
}

/// Whether `row` is a full-width horizontal rule: nothing but `─`, long
/// enough that box borders and inline list rules never qualify. claude's
/// input box is fenced by two such rows.
fn is_rule_row(row: &str) -> bool {
    let mut n = 0usize;
    for c in row.trim().chars() {
        if c != '' {
            return false;
        }
        n += 1;
    }
    n >= 40
}

/// The status phrase of a spinner row: a frame char accepted by `is_frame`,
/// a space, then text through the first `…` inclusive. The phrase must open
/// alphanumeric; past that it is task-derived and unconstrained. Trailing
/// text is left for the caller to interpret.
fn spinner_text(row: &str, is_frame: impl Fn(char) -> bool) -> Option<String> {
    let mut chars = row.chars();
    if !is_frame(chars.next()?) || chars.next()? != ' ' {
        return None;
    }
    let rest = chars.as_str();
    let text = &rest[..rest.find('')? + ''.len_utf8()];
    text.chars()
        .next()?
        .is_alphanumeric()
        .then(|| text.to_string())
}

/// Keep nonempty ` · `-separated segments not matched by `drop`, preserving
/// their order and separator prefixes.
fn slow_segments(tail: &str, drop: impl Fn(&str) -> bool) -> String {
    let mut out = String::new();
    for seg in tail.split(" · ") {
        let seg = seg.trim();
        if seg.is_empty() || drop(seg) {
            continue;
        }
        out.push_str(" · ");
        out.push_str(seg);
    }
    out
}

// ---------------------------------------------------------------- claude --

/// Accepted claude spinner frames. A frame matches only when followed by a
/// space and an `…`-terminated status phrase.
const CLAUDE_SPINNER: &[char] = &['·', '', '', '', '', ''];

/// Maximum nonblank rows inspected above the input box. Blank rows do not
/// consume the limit; indented hint and task-list rows do.
const CLAUDE_STATUS_WINDOW: usize = 16;

/// claude (alt screen). Working state: a column-0 spinner row above the
/// input box's top separator, within [`CLAUDE_STATUS_WINDOW`] nonblank rows
/// of it.
/// Approval state: the dialog replaces the input box entirely; the menu
/// match fires only when that box is gone.
pub struct ClaudeSummary;

impl SummaryAdapter for ClaudeSummary {
    fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> {
        let rows = screen.live_rows();
        match claude_box_top(&rows) {
            Some(top) => claude_spinner_status(&rows, top),
            // Consider approval menus only when the normal input box is absent.
            None => claude_approval(&rows),
        }
    }

    fn model_label(&self, screen: &dyn ScreenFacts) -> Option<String> {
        claude_welcome_label(&screen.live_rows())
    }

    /// Canonicalize a leading claude spinner or braille frame to `✻` so title
    /// animation does not change the rendered text. Other titles pass through
    /// unchanged.
    fn normalize_title(&self, title: &str) -> Option<String> {
        let mut chars = title.chars();
        let frame = chars.next()?;
        let framed = CLAUDE_SPINNER.contains(&frame) || ('\u{2800}'..='\u{28FF}').contains(&frame);
        (framed && chars.next()? == ' ').then(|| format!("{}", chars.as_str()))
    }
}

/// Index of the input box's top separator. The bottom-most full-width rule
/// is the box's bottom edge (only statusline rows render below it); a
/// second rule within six rows is its top edge, and a `❯`-headed row between
/// them is the input line. Body text above and statusline rows below never
/// enter the scan.
fn claude_box_top(rows: &[String]) -> Option<usize> {
    let bottom = rows.iter().rposition(|r| is_rule_row(r))?;
    let top = (bottom.saturating_sub(6)..bottom)
        .rev()
        .find(|&i| is_rule_row(&rows[i]))?;
    rows[top + 1..bottom]
        .iter()
        .any(|r| r.starts_with(''))
        .then_some(top)
}

/// Scan upward from the input box for a spinner or waiting row. Blank rows do
/// not consume the window; indented rows do. The first other column-0 row,
/// including body prose or a wrapped status tail, invalidates the structure.
fn claude_spinner_status(rows: &[String], top: usize) -> Option<(String, &'static str)> {
    let mut content = 0usize;
    for i in (0..top).rev() {
        let row = &rows[i];
        if row.is_empty() {
            continue;
        }
        content += 1;
        if content > CLAUDE_STATUS_WINDOW {
            return None;
        }
        if row.starts_with(' ') {
            continue;
        }
        if let Some(verb) = spinner_text(row, |c| CLAUDE_SPINNER.contains(&c)) {
            // The spinner row's parenthetical contributes its slow
            // semantic tail to whichever text wins the head.
            let tail = claude_semantic_tail(row);
            // The spinner confirms the working state; only then prefer the
            // concrete-action row over the rotating verb.
            if let Some(action) = claude_action_row(rows, i) {
                return Some((format!("{action}{tail}"), "claude:action-row"));
            }
            return Some((format!("{verb}{tail}"), "claude:spinner"));
        }
        // Action-row lookup applies only to ellipsis-terminated spinner rows.
        if let Some(waiting) = claude_waiting_text(row) {
            return Some((waiting, "claude:waiting"));
        }
        // Foreign column-0 row: abort (see above).
        return None;
    }
    None
}

/// Match a spinner-framed `Waiting for {digits} {subject} to finish` row and
/// return its text verbatim. The subject must contain one to three words.
fn claude_waiting_text(row: &str) -> Option<String> {
    let mut chars = row.chars();
    if !CLAUDE_SPINNER.contains(&chars.next()?) || chars.next()? != ' ' {
        return None;
    }
    let text = chars.as_str();
    let rest = text.strip_prefix("Waiting for ")?;
    let digits = rest.chars().take_while(char::is_ascii_digit).count();
    if digits == 0 {
        return None;
    }
    let middle = rest[digits..]
        .strip_prefix(' ')?
        .strip_suffix(" to finish")?;
    (1..=3)
        .contains(&middle.split_whitespace().count())
        .then(|| text.to_string())
}

/// The spinner parenthetical's slow semantic tail:
/// `(1m 8s · ↓ 2.1k tokens · thinking with high effort)` keeps
/// ` · thinking with high effort`. Recognized ticker segments drop;
/// everything else is kept in order as ` · {seg}`. No parenthetical yields
/// an empty tail; an unclosed one is parsed to the cut.
fn claude_semantic_tail(row: &str) -> String {
    let Some(open) = row.find("… (") else {
        return String::new();
    };
    let inner = &row[open + "… (".len()..];
    let inner = inner.strip_suffix(')').unwrap_or(inner);
    slow_segments(inner, claude_ticker_segment)
}

/// Whether one parenthetical segment is recognized ticker churn: elapsed
/// time (each whitespace token is digits, optional dot, then `s`/`m`/`h`:
/// `6s`, `1m 8s`, `2h 3m`), token/throughput counters (`↓`/`↑`-headed or
/// `tokens`-suffixed), or the `esc to interrupt` affordance.
fn claude_ticker_segment(seg: &str) -> bool {
    if seg == "esc to interrupt"
        || seg == "tokens"
        || seg.starts_with('')
        || seg.starts_with('')
        || seg.ends_with(" tokens")
    {
        return true;
    }
    !seg.is_empty()
        && seg.split_whitespace().all(|tok| {
            let Some((num, unit)) = tok.split_at_checked(tok.len() - 1) else {
                return false;
            };
            matches!(unit, "s" | "m" | "h")
                && num.starts_with(|c: char| c.is_ascii_digit())
                && num.chars().all(|c| c.is_ascii_digit() || c == '.')
        })
}

/// The concrete-action row above a confirmed spinner: skip the blank gap,
/// probe exactly one row. `⏺ Running 1 shell command…` names real work while
/// the spinner phrase rotates per request, so it wins when both are
/// present. The probe requires the `⏺` head and a single trailing `…`
/// (`⏺ ok`-style reply rows fail it); anything else keeps the spinner
/// phrase; scanning further up could match conversation content.
fn claude_action_row(rows: &[String], spinner: usize) -> Option<String> {
    let row = rows[..spinner].iter().rev().find(|r| !r.is_empty())?;
    let text = row.strip_prefix("")?.trim();
    let tail = text.len().checked_sub(''.len_utf8())?;
    (text.find('') == Some(tail)).then(|| text.to_string())
}

/// Match an approval selector only when the input box is absent: `❯ 1. …`
/// with a `2. …` option below, within the last nine painted rows. Returns the
/// synthesized label `awaiting approval`.
fn claude_approval(rows: &[String]) -> Option<(String, &'static str)> {
    let last = rows.iter().rposition(|r| !r.is_empty())?;
    let i = (last.saturating_sub(8)..=last).find(|&i| rows[i].trim_start().starts_with("❯ 1. "))?;
    let next = rows[i + 1..].iter().find(|r| !r.is_empty())?;
    next.trim_start()
        .starts_with("2. ")
        .then(|| ("awaiting approval".to_string(), "claude:approval-menu"))
}

/// `Fable 5 with high effort` from the welcome box → `Fable 5 (high)`. The
/// welcome box is the stable source; user-configurable statusline rows are not
/// parsed. When the box scrolls away, the label is unavailable.
fn claude_welcome_label(rows: &[String]) -> Option<String> {
    let start = rows
        .iter()
        .take(4)
        .position(|r| r.trim_start().starts_with("╭─── Claude Code"))?;
    for row in &rows[start + 1..] {
        if row.trim_start().starts_with('') {
            break;
        }
        // First cell of the box row: the welcome pane, left of the divider.
        let Some(cell) = row.split('').nth(1) else {
            continue;
        };
        let head = cell.trim().split(" · ").next().unwrap_or("");
        if let Some(model_effort) = head.strip_suffix(" effort")
            && let Some((model, effort)) = model_effort.rsplit_once(" with ")
            && !model.is_empty()
            && !effort.is_empty()
        {
            return Some(format!("{model} ({effort})"));
        }
    }
    None
}

// ----------------------------------------------------------------- codex --

/// codex (inline UI, primary screen). The pin is its composer: the
/// bottom-most column-0 `›` row that is not a modal selector; status rows
/// sit above it, and scrollback beyond the first foreign row is out of
/// bounds. The approval modal removes the composer and is checked first.
/// A token bar or indented hint rows may appear below the composer.
pub struct CodexSummary;

impl SummaryAdapter for CodexSummary {
    fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> {
        let rows = screen.live_rows();
        if let Some(hit) = codex_approval(&rows) {
            return Some(hit);
        }
        let composer = codex_composer(&rows)?;
        codex_status(&rows, composer)
    }

    fn model_label(&self, screen: &dyn ScreenFacts) -> Option<String> {
        let rows = screen.live_rows();
        let token = codex_token_line(&rows)?;
        // `codex_token_line` guarantees a non-empty first segment.
        Some(rows[token].trim().split(" · ").next()?.to_string())
    }
}

/// `› 1. Yes, proceed (y)`: the modal's selected option row (column-0 `›`,
/// one digit, `. `).
fn codex_menu_head(row: &str) -> bool {
    row.strip_prefix("")
        .and_then(|r| r.strip_prefix(|c: char| c.is_ascii_digit()))
        .is_some_and(|r| r.starts_with(". "))
}

/// An unselected modal option: indented, `{digit}. `-headed.
fn codex_numbered_option(row: &str) -> bool {
    let t = row.trim_start();
    let digits = t.chars().take_while(char::is_ascii_digit).count();
    t.len() > digits && digits >= 1 && t[digits..].starts_with(". ")
}

/// codex's approval modal: a selector row with an indented numbered sibling
/// below it, pinned to the last nine painted rows. The modal removes the
/// composer and token bar; that absence is the disambiguator (a menu quoted
/// in the conversation always has the live composer below it, so any
/// non-selector `›` row under the selector suppresses the match).
fn codex_approval(rows: &[String]) -> Option<(String, &'static str)> {
    let last = rows.iter().rposition(|r| !r.is_empty())?;
    let i = (last.saturating_sub(8)..=last).find(|&i| codex_menu_head(&rows[i]))?;
    let sibling = rows[i + 1..].iter().find(|r| !r.is_empty())?;
    if !(sibling.starts_with(' ') && codex_numbered_option(sibling)) {
        return None;
    }
    rows[i + 1..]
        .iter()
        .all(|r| !r.starts_with('') || codex_menu_head(r))
        .then(|| ("awaiting approval".to_string(), "codex:approval-menu"))
}

/// The token/status bar, when painted: the bottom-most
/// `{model} · {…} in · {…} out` row among the last six painted rows.
/// Independent of the composer pin because the bar may be absent; without it,
/// the anchor has no model prefix.
fn codex_token_line(rows: &[String]) -> Option<usize> {
    let last = rows.iter().rposition(|r| !r.is_empty())?;
    (last.saturating_sub(5)..=last).rev().find(|&i| {
        let segs: Vec<&str> = rows[i].trim().split(" · ").collect();
        segs.len() >= 3
            && !segs[0].is_empty()
            && segs[segs.len() - 2].ends_with(" in")
            && segs[segs.len() - 1].ends_with(" out")
    })
}

/// The composer: the bottom-most column-0 `›` row that is not a modal
/// selector. Rows below it are tolerated, never required: blank rows,
/// indented affordance hints (`tab to queue message`), or the token bar.
/// The working layout can paint hints below the composer with no bar at
/// all. Prompt echoes in scrollback share the `›` head but sit above the
/// composer, so the bottom-most wins.
fn codex_composer(rows: &[String]) -> Option<usize> {
    rows.iter()
        .rposition(|r| (r.as_str() == "" || r.starts_with("")) && !codex_menu_head(r))
}

/// Walk up from the composer through the status region: blanks and indented
/// rows (tool-output attachments like `└ ok`, wrapped continuations) are
/// skipped, and the first column-0 row decides. Only two heads extract
/// (`• Working (` and `• Ran `); any other column-0 row (a reply bullet,
/// a `⚠` notice, a turn separator) stops the scan: scrollback holds `• Ran`
/// rows from every prior turn, and skipping an unknown row to reach one
/// would resurface stale work as live status.
fn codex_status(rows: &[String], composer: usize) -> Option<(String, &'static str)> {
    for row in rows[composer.saturating_sub(10)..composer].iter().rev() {
        if row.is_empty() || row.starts_with(' ') {
            continue;
        }
        if let Some(after_paren) = row.strip_prefix("• Working (") {
            return Some((codex_working(after_paren), "codex:working"));
        }
        if let Some(cmd) = row.strip_prefix("• Ran ")
            && !cmd.is_empty()
        {
            return Some((format!("Ran {cmd}"), "codex:ran"));
        }
        return None;
    }
    None
}

/// `7s • esc to interrupt) · 1 background terminal running · /ps to view ·
/// /stop to close` → `Working · 1 background terminal running`. The
/// parenthetical is the elapsed counter plus interrupt affordance, dropped
/// whole: an unclosed paren is CLI-side truncation mid-affordance and drops
/// to the end. Of the ` · ` suffixes, `/`-headed segments are key hints;
/// everything else is slow-moving state and is kept, with its own ellipsis
/// when the CLI truncated it.
fn codex_working(after_paren: &str) -> String {
    let tail = after_paren.find(')').map_or("", |i| &after_paren[i + 1..]);
    format!("Working{}", slow_segments(tail, |seg| seg.starts_with('/')))
}

// ------------------------------------------------------------------ grok --

/// grok (alt screen). The pin is its bordered input box; the status row
/// (braille spinner while working, `Worked for {n}s` after a turn) is the
/// first painted row above the box's top border.
pub struct GrokSummary;

impl SummaryAdapter for GrokSummary {
    fn live_preview(&self, screen: &dyn ScreenFacts) -> Option<(String, &'static str)> {
        let rows = screen.live_rows();
        let (top, _) = grok_input_box(&rows)?;
        // One probe row: the first painted row above the box. The splash
        // panel's hints and the session header land here in non-working
        // states and match neither shape.
        let probe = rows[..top].iter().rev().find(|r| !r.is_empty())?;
        let t = probe.trim_start();
        // Keep the label through its first ellipsis. Wrapped tail rows have no
        // spinner prefix, so they fail the frame check and fall through.
        if let Some(text) = spinner_text(t, |c| ('\u{2800}'..='\u{28FF}').contains(&c)) {
            return Some((text, "grok:spinner"));
        }
        grok_worked(t).then(|| (t.to_string(), "grok:worked"))
    }

    fn model_label(&self, screen: &dyn ScreenFacts) -> Option<String> {
        let rows = screen.live_rows();
        let (_, bottom) = grok_input_box(&rows)?;
        grok_border_label(&rows[bottom])
    }
}

/// grok's input box: the bottom-most `╰…╯` border (the splash panel's box
/// sits higher), a `╭…╮` top border within six rows above it, and at least
/// one `│`-headed row between. Returns `(top, bottom)` border indexes.
fn grok_input_box(rows: &[String]) -> Option<(usize, usize)> {
    let bottom = rows.iter().rposition(|r| {
        let t = r.trim();
        t.starts_with('') && t.ends_with('')
    })?;
    let top = (bottom.saturating_sub(6)..bottom).rev().find(|&i| {
        let t = rows[i].trim();
        t.starts_with('') && t.ends_with('')
    })?;
    rows[top + 1..bottom]
        .iter()
        .any(|r| r.trim_start().starts_with(''))
        .then_some((top, bottom))
}

/// The completion row grok leaves above its box, kept verbatim: `Worked for
/// 8.7s`, with digits, `.`, and the `m`/`h`/space of longer durations
/// tolerated after a leading digit.
fn grok_worked(t: &str) -> bool {
    t.strip_prefix("Worked for ")
        .and_then(|r| r.strip_suffix('s'))
        .is_some_and(|n| {
            n.starts_with(|c: char| c.is_ascii_digit())
                && n.chars()
                    .all(|c| c.is_ascii_digit() || matches!(c, '.' | ' ' | 'm' | 'h'))
        })
}

/// `╰──── Grok 4.5 (xhigh) · always-approve ─╯` → `Grok 4.5 (xhigh)`: the
/// text grok embeds in its bottom border, first ` · ` segment (the second is
/// the approval mode). A plain border has nothing after its last `─` and
/// yields no label.
fn grok_border_label(row: &str) -> Option<String> {
    let t = row.trim().strip_suffix('')?;
    let t = t.trim_end_matches(['', ' ']);
    let text = &t[t.rfind('')? + ''.len_utf8()..];
    let label = text.trim().split(" · ").next()?.trim();
    (!label.is_empty()).then(|| label.to_string())
}

#[cfg(test)]
#[path = "summary_tests.rs"]
mod tests;