# Terminal State Machine

Definitive reference for terminal activity states, notifications, and question detection.

## State Variables

Each terminal has these reactive fields in `terminalsStore`:

| Field | Type | Default | Source of truth |
|-------|------|---------|-----------------|
| `shellState` | `"busy" \| "idle" \| null` | `null` | **Rust** (emitted as parsed event) |
| `awaitingInput` | `"question" \| "error" \| null` | `null` | Frontend (from parsed events) |
| `awaitingInputConfident` | `boolean` | `false` | Frontend (from Question event) |
| `activeSubTasks` | `number` | `0` | **Rust** (parsed + stored per session) |
| `debouncedBusy` | `boolean` | `false` | Frontend (derived from shellState with 2s hold) |
| `unseen` | `boolean` | `false` | Frontend (set by `fireCompletion`, cleared on tab focus) |
| `agentType` | `AgentType \| null` | `null` | Frontend (from agent detection) |

Rust-side per-session state:

| Field | Location | Purpose |
|-------|----------|---------|
| `SilenceState.last_output_at` | `pty.rs` | Timestamp of last **real** output (not mode-line ticks) |
| `SilenceState.last_chunk_at` | `pty.rs` | Timestamp of last chunk of **any** kind (real or chrome-only). Used by backup idle timer to detect reader thread activity. |
| `SilenceState.last_status_line_at` | `pty.rs` | Timestamp of last spinner/status-line |
| `SilenceState.pending_question_line` | `pty.rs` | Candidate `?`-ending line for silence detection |
| `SilenceState.output_chunks_after_question` | `pty.rs` | Staleness counter: real-output chunks since last `?` candidate |
| `SilenceState.question_already_emitted` | `pty.rs` | Prevents re-emission of the same question |
| `SilenceState.suppress_echo_until` | `pty.rs` | Deadline to ignore PTY echo of user-typed `?` lines |
| `active_sub_tasks` | `AppState.session_states` | Sub-agent count per session |
| `shell_states` | `AppState.shell_states` | `DashMap<String, AtomicU8>`: 0=null, 1=busy, 2=idle. Transitions use `compare_exchange` to prevent duplicate events when reader thread and silence timer race. |
| `last_output_ms` | `AppState.last_output_ms` | Epoch ms of last **real** output (not chrome-only). Stamped only when `!chrome_only`. |

## 1. Tab Indicator — Visual Priority

The tab dot reflects the terminal's **highest-priority** active state:

```
Priority    State       Color       CSS var         Condition
────────    ─────       ─────       ───────         ─────────
   1        Error       red         --error         awaitingInput == "error"
   2        Question    orange      --attention     awaitingInput == "question"
   3        Busy        blue ●̣      --activity      debouncedBusy && !awaitingInput
   4        Unseen      purple      --unseen        unseen && !debouncedBusy && !awaitingInput
   5        Done        green       --success       shellState=="idle" && !unseen && !debouncedBusy && !awaitingInput
   6        Idle        gray        (default)       shellState==null or none of above
```

Error and Question have pulse animation. Busy has pulse animation. Unseen and Done are static.

### Complete state combination matrix

Every valid combination of the 4 key fields and the resulting indicator:

```
awaitingInput  debouncedBusy  unseen  shellState   → Indicator
─────────────  ─────────────  ──────  ──────────   ──────────
"error"        true           any     any          → Error (red)
"error"        false          any     any          → Error (red)
"question"     true           any     any          → Question (orange)
"question"     false          any     any          → Question (orange)
null           true           any     any          → Busy (blue pulse)
null           false          true    "idle"       → Unseen (purple)
null           false          true    null         → Unseen (purple)
null           false          false   "idle"       → Done (green)
null           false          false   "busy"       → (transient: cooldown pending)
null           false          false   null         → Idle (gray)
```

### Lifecycle of each indicator

```
                    ┌──────────────────────── Error (red) ◄─── API error / agent crash
                    │                              │
                    │   ┌──────────────────── Question (orange) ◄─── agent asks ?
                    │   │                          │
                    │   │    ┌─────────────── Busy (blue) ◄─── real output detected
                    │   │    │                     │
                    │   │    │    ┌────────── Unseen (purple) ◄─── completion fired,
                    │   │    │    │                │                user not watching
                    │   │    │    │    ┌───── Done (green) ◄─── user viewed unseen tab,
                    │   │    │    │    │            │              or short idle session
                    │   │    │    │    │    ┌─ Idle (gray) ◄─── no session / fresh
                    │   │    │    │    │    │
                    ▼   ▼    ▼    ▼    ▼    ▼
                 [ Higher priority wins when multiple states active ]
```

## 2. shellState — Derived in Rust

Rust is the single source of truth. The reader thread classifies every PTY chunk:

```
PTY chunk arrives in reader thread
         │
         ▼
    ┌─────────────────────────────┐
    │ Compute chrome_only:        │
    │  = no regex question found  │
    │  AND no ?-ending line       │
    │  AND changed_rows non-empty │
    │  AND ALL changed rows pass  │
    │    is_chrome_row() (contain │
    │    ⏵/›/✻/• markers)        │
    └─────────┬───────────────────┘
              │
         chrome_only?
        ╱            ╲
      YES             NO
       │               │
       ▼               ▼
  Mode-line tick    Real output
  (timer, spinner)  (agent working)
       │               │
       │               ├── last_output_at = now
       │               │
       │               └── if shell_state ≠ busy:
       │                      emit ShellState { "busy" }
       │                      shell_state = busy
       │
       └── if shell_state == busy
           AND last_output_at > threshold ago
               (500ms for shell, 5s for agent sessions)
           AND active_sub_tasks == 0
           AND not in resize grace:
              emit ShellState { "idle" }
              shell_state = idle
```

A **backup timer** (the existing silence timer, 1s interval) also checks:

```
Silence timer (every 1s)
         │
         ▼
    reader thread active?  ─── YES ──► skip
    (last_chunk_at < 2s)       (reader handles idle via !has_status_line guard)
         │ NO
         ▼
    shell_state == busy?  ─── NO ──► skip
         │ YES
         ▼
    last_output_at > threshold ago?  ─── NO ──► skip
    (500ms shell / 2.5s agent)
         │ YES
         ▼
    active_sub_tasks == 0?  ─── NO ──► skip
         │ YES
         ▼
    emit ShellState { "idle" }
    shell_state = idle
```

This catches the case where NO chunks arrive at all (agent truly silent — reader
thread blocked on `read()`). When chrome-only chunks are arriving (mode-line timer
ticks), the reader thread is active and handles idle transitions correctly via its
own `!has_status_line` guard.

### Session end

When the reader thread loop breaks (`Ok(0)`), Rust emits `ShellState { "idle" }` before
stopping, ensuring the frontend sees the final transition. The frontend then receives the
exit callback and sets `sessionId = null`.

### Frontend consumption

```
pty-parsed event: ShellState { state }
         │
         ▼
terminalsStore.update(id, { shellState: state })
         │
         ▼
handleShellStateChange(prev, next)  ← existing debounced busy logic
```

The frontend does NOT derive shellState from raw PTY data. `handlePtyData` renders
grid frames and updates `lastDataAt` — but never touches `shellState`.

### Transition table

| From | To | Trigger | Condition |
|------|----|---------|-----------|
| `null` | `busy` | First real output chunk | — |
| `busy` | `idle` | Chrome-only chunk or silence timer | `last_output_at > threshold` (500ms shell / 2.5s agent) AND `active_sub_tasks == 0` AND not resize grace |
| `idle` | `busy` | Real output chunk | — |
| `busy` | `idle` | Session ends (reader thread exit) | Always (cleanup) |
| any | `null` | Terminal removed from store | cleanup |

### What does NOT cause transitions

| Event | Why it's ignored |
|-------|-----------------|
| Mode-line timer tick (`✻ Cogitated 3m 47s`) | Classified as `chrome_only` |
| Status-line update (`▶▶ ... 1 local agent`) | Classified as `chrome_only` |
| ActiveSubtasks event | Updates counter, doesn't produce real output |
| Resize redraw | Suppressed by resize grace (1s) |

## 3. debouncedBusy — Derived from shellState

Smoothed version with a 2-second hold to prevent flicker:

```
shellState events from Rust:

  busy ─────────── idle ──── busy ─────── idle ──────────────────
                     │         │            │
debouncedBusy:       │         │            │
  true ──────────────┼─────────┼── true ────┼── true ──┐ false ──
                     │         │            │          │
                     └── 2s ───┘            └── 2s ───┘
                     cooldown               cooldown
                     cancelled              expires
```

| Event | debouncedBusy effect |
|-------|---------------------|
| shellState → busy | Immediately `true`. Cancel any running cooldown. Record `busySince` (first time only). |
| shellState → idle | Start 2s cooldown. If cooldown expires: set `false`, fire `onBusyToIdle(id, duration)`. |
| shellState → busy during cooldown | Cancel cooldown. Stay `true`. Keep original `busySince`. |

`onBusyToIdle` fires exactly once per busy→idle cycle, after the 2s cooldown fully expires.

## 4. awaitingInput — Question and Error Detection

### State diagram

```
                          Question event
                          (passes all guards)
    ┌──────┐             ┌──────────┐
    │ null │────────────►│ question │
    └──┬───┘             └─────┬────┘
       │                       │
       │  ◄── clear triggers ──┘
       │      (see table below)
       │
       │  Error event
       │  (API error, agent crash)
       │                 ┌────────┐
       └────────────────►│ error  │
                         └───┬────┘
                             │
                    ◄── clear triggers ──┘
                        (see table below)
```

### Clear triggers

| Trigger | Clears "question"? | Clears "error"? | Why |
|---------|--------------------|-----------------|-----|
| StatusLine parsed event | **Yes** | **Yes** | Agent is working again (showing a task) |
| Progress parsed event | **Yes** | **Yes** | Agent is making progress |
| User keystroke (`terminal.onData`) | **Yes** | **Yes** | User typed something — prompt answered |
| shellState idle → busy | **Yes** | No | Agent resumed real output (reliable post-refactor since mode-line ticks no longer cause idle→busy) |
| Process exit | **Yes** | **Yes** | Session over |

### What does NOT clear awaitingInput

| Event | Why it doesn't clear |
|-------|---------------------|
| shellState idle → busy | Clears `"question"` but **not** `"error"`. API errors are persistent and need explicit agent activity (status-line) or process exit to clear. |
| Mode-line tick | Chrome-only output, not agent activity |
| activeSubTasks change | Sub-agent count changing doesn't mean the main question was answered |

### Notification sounds

Sounds play on **transitions into** a state, never on repeated sets or clearing:

```
getAwaitingInputSound(prev, current):

  prev      current     sound
  ────      ───────     ─────
  null   →  question →  play "question"
  null   →  error    →  play "error"
  *      →  same     →  null (no sound)
  *      →  null     →  null (clearing, no sound)
  question→ error    →  play "error" (state changed)
  error  →  question →  play "question" (state changed)
```

## 5. unseen — Completion Visibility Tracking

`unseen` tracks whether the user has seen a completed task.

### Lifecycle

```
                                          ┌─────────┐
  fireCompletion() ──────────────────────►│ unseen  │
  (background tab, agent done)            │ = true  │
                                          └────┬────┘
                                               │
  User clicks/switches to this tab ───────────►│
  (setActive clears unseen)                    │
                                               ▼
                                          ┌─────────┐
                                          │ unseen  │
                                          │ = false │
                                          └─────────┘
```

### What sets unseen

Only ONE place: `App.tsx` `fireCompletion()` sets `unseen = true` (along with `activity = true`).

### What clears unseen

Only ONE place: `terminalsStore.setActive(id)` sets `unseen = false`.

### Tab color transitions for unseen

```
Agent working    Agent done     User switches    User switches
(background)     (background)   to other tab     to THIS tab
    │                │               │                │
    ▼                ▼               ▼                ▼
  Blue ●̣  ───►  Purple ● ────►  Purple ● ────►  Green ●
  (busy)       (unseen)        (stays unseen)   (done/idle)
```

## 6. activeSubTasks — Sub-agent Tracking

Parsed from the agent mode line by Rust `OutputParser`:

```
Mode line text                               Parsed count
──────────────────────────────────────────   ────────────
"▶▶ bypass permissions on · 1 local agent"  → 1
"▶▶ Reading files · 3 local agents"         → 3
"▶▶ bypass permissions on"                  → 0
(no mode line)                               → unchanged
```

Stored in **both** Rust (`AppState.active_sub_tasks`) and frontend (`terminalsStore`).

### Effects on other states

```
                    activeSubTasks
                    ┌─────────────────────────────────────────────┐
                    │                                             │
                    ▼                                             ▼
              > 0 (agents running)                    == 0 (no agents)
              ┌────────────────────┐                  ┌──────────────────┐
              │ shellState:        │                  │ shellState:      │
              │   stays busy       │                  │   normal rules   │
              │   (idle blocked)   │                  │   (500ms timer)  │
              │                    │                  │                  │
              │ Question guard:    │                  │ Question guard:  │
              │   low-confidence   │                  │   passes through │
              │   IGNORED          │                  │                  │
              │                    │                  │                  │
              │ Completion:        │                  │ Completion:      │
              │   SUPPRESSED       │                  │   normal rules   │
              └────────────────────┘                  └──────────────────┘
```

### Reset

| Event | Effect |
|-------|--------|
| `ActiveSubtasks { count: N }` parsed event | Set to N |
| `UserInput` parsed event | Reset to 0 (new agent cycle) |
| Process exit | Reset to 0 |

## 7. Completion Notification

Fires when an agent was busy for ≥5s then truly goes idle.

**Two independent paths** can trigger completion:

### Path 1: Session exit (Terminal.tsx)

```
Process exits → reader thread ends → exit callback fires
         │
         ├── terminal is active tab? → SKIP
         │
         └── play("completion")
             (does NOT set unseen — user may switch soon)
```

### Path 2: Busy-to-idle (App.tsx) — sets unseen

```
onBusyToIdle(id, durationMs)
         │
         ├── durationMs < 5s? ────────────────────── SKIP
         ├── terminal is active tab? ─────────────── SKIP
         │
         ├── agentType set? ── YES ─► defer 10s ──► fireCompletion()
         │                 NO ──────► fireCompletion()
         │
         ▼
    fireCompletion()
         │
         ├── terminal is active tab? ──── SKIP (user switched to it)
         ├── debouncedBusy still true? ── SKIP (went busy again)
         ├── terminal removed? ────────── SKIP
         ├── activeSubTasks > 0? ──────── SKIP (agents still running)
         ├── awaitingInput set? ────────── SKIP (question/error active)
         │
         ▼
    play("completion")
    set unseen = true
    → tab turns purple (Unseen)
    → when user views: tab turns green (Done)
```

### Sound deduplication

Both paths can fire for the same session. Path 1 fires immediately on exit. Path 2 fires
after cooldown + deferral. The notification manager handles dedup (cooldown between
identical sounds).

### Timing under the new architecture

```
t=0       Agent starts working (real output) → shellState: busy
t=0..T    Agent works. Mode-line ticks arrive but don't affect shellState.
t=T       Agent stops real output. Mode-line may continue.
t=T+0.5   Shell session: Rust idle threshold (500ms) reached → shellState: idle
          Agent session: still within 2.5s threshold → stays busy
           (If sub_tasks > 0: stays busy regardless of threshold)
t=T+2.5   Shell: Cooldown expires → debouncedBusy: false → onBusyToIdle fires
          Agent: Rust idle threshold (2.5s) reached → shellState: idle
t=T+4.5   Agent: Cooldown expires → debouncedBusy: false → onBusyToIdle fires
t=*+0     duration = T seconds. If T ≥ 5s and agentType:
             → defer 10s → fireCompletion
t=*+10    fireCompletion checks all guards → play("completion"), unseen=true
```

## 8. Question Detection Pipeline

Two layers: **Rust detection** → **Frontend notification**.

### Rust: Two parallel detection strategies

```
┌─────────────────────────────────────────────────────────────────┐
│                    READER THREAD (per chunk)                     │
│                                                                 │
│  PTY data → parse_clean_lines(changed_rows) → events[]          │
│                                                                 │
│  ┌─ Strategy A: Regex (instant) ──────────────────────────────┐ │
│  │ parse_question() matches "Enter to select"                 │ │
│  │ → Question { confident: true }                             │ │
│  │ → emitted immediately in the events list                   │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
│  ┌─ Strategy B: Silence (delayed) ────────────────────────────┐ │
│  │ extract_question_line(changed_rows)                        │ │
│  │ → finds last line ending with '?' that passes              │ │
│  │   is_plausible_question() filter                           │ │
│  │ → stored as pending_question_line in SilenceState          │ │
│  │ → NOT emitted yet — waits for silence timer                │ │
│  └────────────────────────────────────────────────────────────┘ │
│                                                                 │
│  on_chunk() updates SilenceState:                               │
│    - regex fired? → clear pending, mark emitted                 │
│    - echo suppress window? → ignore '?' line                    │
│    - same line already emitted? → ignore (repaint)              │
│    - new '?' line? → set as pending candidate                   │
│    - real output after '?'? → increment staleness               │
│    - mode-line tick? → do nothing                               │
│    - staleness > 10? → clear pending (agent kept working)       │
└─────────────────────────────────────────────────────────────────┘

┌─────────────────────────────────────────────────────────────────┐
│                    SILENCE TIMER (every 1s)                      │
│                                                                 │
│  is_silent()?                                                   │
│    ├── question_already_emitted? → skip                         │
│    ├── is_spinner_active()? → skip (status-line < 10s ago)      │
│    └── last_output_at < 10s? → skip                             │
│                                                                 │
│  If silent (all three pass):                                    │
│                                                                 │
│  ┌─ Strategy 1: Screen-based ────────────────────────────────┐  │
│  │ Read VT screen → extract_last_chat_line()                 │  │
│  │ → find line above prompt (❯, ›, >)                        │  │
│  │ → ends with '?' AND is_plausible_question()?              │  │
│  │ → emit Question { confident: false }                      │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  ┌─ Strategy 2: Chunk-based fallback ────────────────────────┐  │
│  │ check_silence() → pending_question_line exists?           │  │
│  │ AND not stale (≤ 10 real-output chunks after)?            │  │
│  │ → verify_question_on_screen() (bottom 5 rows)            │  │
│  │ → emit Question { confident: false }                      │  │
│  └───────────────────────────────────────────────────────────┘  │
│                                                                 │
│  If neither strategy finds a question: continue sleeping.       │
└─────────────────────────────────────────────────────────────────┘
```

### SilenceState update rules

| Chunk type | last_chunk_at | last_output_at | last_status_line_at | staleness counter | pending_question_line |
|-----------|--------------|----------------|--------------------|--------------------|----------------------|
| Real output, no '?' | Reset to now | Reset to now | — | +1 (if pending exists) | Cleared if >10 |
| Real output with '?' | Reset to now | Reset to now | — | Reset to 0 | Set to new line |
| Real output + status-line | Reset to now | Reset to now | Reset to now | (per above rules) | (per above rules) |
| Mode-line tick only | **Reset to now** | **Not reset** | **Not reset** | **Not incremented** | **Not affected** |
| Regex question fired | Reset to now | Reset to now | — | Reset to 0 | Cleared (handled) |

### Frontend: event handler + notification

```
pty-parsed: Question { prompt_text, confident }
         │
         ▼
    ┌──────────────────────────────────────────────────┐
    │ Guard: low-confidence question while agent busy  │
    │                                                  │
    │ NOT confident                                    │
    │ AND (shellState == "busy"                        │
    │      OR activeSubTasks > 0)?                     │
    │                                                  │
    │ YES → IGNORE (likely false positive)             │
    │ NO  → continue                                   │
    └──────────────────┬───────────────────────────────┘
                       │
                       ▼
    setAwaitingInput(id, "question", confident)
                       │
                       ▼
    createEffect detects transition (null → "question")
                       │
                       ▼
    play("question")
```

## 9. Timing Constants

| Constant | Value | Location | Purpose |
|----------|-------|----------|---------|
| Shell idle threshold | 500ms | `pty.rs` (Rust) | Real output silence before idle (plain shell) |
| Agent idle threshold | 2.5s | `pty.rs` (Rust) | Real output silence before idle (agent sessions) |
| Debounce hold | 2s | `terminals.ts` | debouncedBusy hold after idle |
| Silence question threshold | 10s | `pty.rs` | Silence before '?' line → question |
| Silence check interval | 1s | `pty.rs` | Timer thread wake frequency |
| Backup idle chunk threshold | 2s | `pty.rs` | Skip backup idle if any chunk arrived within this window |
| Stale question chunks | 10 | `pty.rs` | Real-output chunks before discarding '?' candidate |
| Resize grace | 1s | `pty.rs` | Suppress all events after resize |
| Echo suppress window | 500ms | `pty.rs` | Ignore PTY echo of user-typed '?' lines |
| Screen verify rows | 5 | `pty.rs` | Bottom N rows checked for screen verification |
| Completion threshold | 5s | `App.tsx` | Minimum busy duration for completion notification |
| Completion deferral | 10s | `App.tsx` | Extra wait for agent processes (sub-agents may still run) |

## 10. Scenarios

### A: Agent asks "Procedo?" — no sub-agents

```
t=0       Agent outputs "Procedo?" (real output)
          → shellState: busy
          → pending_question_line = "Procedo?"
          → last_output_at = now

t=2.5     No more real output. Rust idle check:
          last_output_at > 2.5s (agent threshold), active_sub_tasks=0
          → shellState: idle  │  Tab: blue→(cooldown)
          → debouncedBusy cooldown starts (2s)

t=1-9     Mode-line ticks arrive (chrome_only=true)
          → shellState stays busy (agent threshold not reached)
          → pending_question_line preserved

t=4.5     Cooldown expires → debouncedBusy: false
          → onBusyToIdle fires (duration ~2.5s < 5s → no completion)
          │  Tab: blue→green (Done)

t=10      Silence timer: is_silent()? YES
          → Strategy 1 or 2 finds "Procedo?"
          → emit Question { confident: false }
          → Frontend: guard passes (idle, subTasks=0)
          → awaitingInput = "question"
          → play("question") ✓
          │  Tab: green→orange (Question)

t=???     User types response → UserInput event
          → clearAwaitingInput
          │  Tab: orange→green (Done)
          → agent resumes → status-line → clearAwaitingInput (redundant, safe)
          │  Tab: green→blue (Busy)
```

### B: Agent asks "Procedo?" — sub-agents running

```
t=0       Agent outputs "Procedo?" while 2 sub-agents run
          → shellState: busy  │  Tab: blue
          → pending_question_line = "Procedo?"
          → active_sub_tasks = 2

t=0.5+    No more real output but active_sub_tasks > 0
          → shellState stays busy (Rust: idle blocked)

t=10      Silence timer: is_silent()? YES
          → emit Question { confident: false }
          → Frontend: activeSubTasks=2 > 0, NOT confident → IGNORED

t=60      Last sub-agent finishes → ActiveSubtasks { count: 0 }

t=62.5    Rust: last_output_at > 2.5s (agent threshold), sub_tasks=0
          → shellState: idle  │  Tab: blue→(cooldown)

t=64.5    Cooldown expires → onBusyToIdle(duration=60s)
          → ≥ 5s, agentType set → defer 10s

t=74.5    fireCompletion()
          → activeSubTasks=0, awaitingInput=null
          → play("completion") ✓, unseen=true
          │  Tab: purple (Unseen)

          User switches to tab → unseen cleared
          │  Tab: purple→green (Done)
```

### C: Ink menu — "Enter to select"

```
t=0       Agent renders Ink menu with "Enter to select" footer
          → parse_question() regex match (INK_FOOTER_RE)
          → emit Question { confident: true } immediately
          → SilenceState: pending cleared, question_already_emitted = true

t=0       Frontend: confident=true → guard skipped (always passes)
          → awaitingInput = "question"
          → play("question") ✓
          │  Tab: orange (Question)

          No 10s wait needed — instant detection.
```

### D: False positive — agent discusses code with '?'

```
t=0       Agent outputs "// Should we use HashMap?"
          → is_plausible_question → false (starts with //)
          → NO candidate set

t=0       Agent outputs "Does this look right?"
          → is_plausible_question → true
          → pending_question_line = "Does this look right?"

t=0.1+    Agent continues with more real output (non-'?')
          → staleness +1, +2, ... +11 (> STALE_QUESTION_CHUNKS=10)
          → pending_question_line cleared

t=10+     Silence timer: pending is None → nothing emitted ✓
          │  No false notification
```

### E: Agent completes long task — no question

```
t=0       Agent starts working (real output)
          → shellState: busy  │  Tab: blue

t=120     Agent finishes, goes to prompt. No more real output.

t=122.5   Rust: last_output_at > 2.5s (agent threshold), sub_tasks=0
          → shellState: idle  │  Tab: blue→(cooldown)

t=124.5   Cooldown expires → onBusyToIdle(duration=120s)
          → ≥ 5s, agentType set → defer 10s

t=134.5   fireCompletion()
          → all guards pass
          → play("completion") ✓, unseen=true
          │  Tab: purple (Unseen)

          User switches to tab → unseen cleared
          │  Tab: purple→green (Done)
```

### F: User watches terminal — active tab

```
t=0       Agent working in active tab (user watching)
          → shellState: busy  │  Tab: blue

t=60      Agent finishes → idle

t=62      onBusyToIdle fires
          → terminal IS active tab → SKIP
          → no sound, no unseen
          │  Tab: blue→green (Done) — user was watching
```

### G: Short command — under 5s

```
t=0       User runs `ls` → shellState: busy  │  Tab: blue

t=0.1     Output finishes → idle

t=2.1     Cooldown expires → onBusyToIdle(duration=0.1s)
          → duration < 5s → SKIP
          → no sound, no unseen
          │  Tab: blue→green (Done)
```

### H: Process exits in background tab

```
t=0       Agent working in background tab
          → shellState: busy  │  Tab: blue

t=60      Process exits → reader thread ends
          → Rust emits ShellState { "idle" }
          → Frontend exit callback:
            sessionId = null, clearAwaitingInput
            play("completion") [Path 1] ✓

t=62      Cooldown expires → onBusyToIdle(duration=60s)
          → fireCompletion [Path 2] → play("completion"), unseen=true
          │  Tab: purple (Unseen)

          User switches to tab → unseen cleared
          │  Tab: purple→green (Done)
```

### I: Rate-limit detected

```
t=0       Agent output matches rate-limit pattern
          → RateLimit parsed event emitted

t=0       Frontend handler:
          shellState == "busy"?
            YES → IGNORE (false positive from streaming code)
            NO  → agentType set, not recently detected?
              YES → play("warning") ✓, rateLimitStore updated
              NO  → SKIP (dedup)
```

### J: Resize during question display

```
t=0       "Procedo?" visible on screen, awaitingInput = "question"
          │  Tab: orange (Question)

t=X       User resizes terminal pane
          → resize_pty called → SilenceState.on_resize()
          → Shell redraws visible output (real PTY output)
          → Rust: shellState → busy (real output)

t=X       Resize grace active (1s):
          → All notification events SUPPRESSED (Question, RateLimit, ApiError)
          → "Procedo?" in redraw doesn't re-trigger question

t=X+1     Grace expires. awaitingInput still "question" (never cleared).
          │  Tab stays orange ✓
```

### K: Agent error (API error, stuck)

```
t=0       Agent output matches API error pattern
          → ApiError parsed event emitted

t=0       Frontend handler:
          → awaitingInput = "error"
          → play("error") ✓
          │  Tab: red (Error)

          User answers / agent retries → StatusLine event
          → clearAwaitingInput
          │  Tab: red→blue (Busy)
```

### L: Question then error (priority override)

```
t=0       Agent asks question → awaitingInput = "question"
          │  Tab: orange (Question)

t=5       API error while question is pending
          → awaitingInput = "error" (overrides question)
          → play("error") ✓
          │  Tab: orange→red (Error)

          Agent recovers → StatusLine event
          → clearAwaitingInput
          │  Tab: red→blue (Busy)
```

## 11. File Reference

| File | Responsibility |
|------|---------------|
| `src-tauri/src/pty.rs` | `SilenceState`, `spawn_silence_timer`, shellState derivation, `extract_question_line`, `verify_question_on_screen`, `extract_last_chat_line`, `spawn_reader_thread` |
| `src-tauri/src/output_parser.rs` | `parse_question` (INK_FOOTER_RE), `parse_active_subtasks`, `ParsedEvent` enum |
| `src-tauri/src/state.rs` | `AppState` (includes `shell_state`, `active_sub_tasks` maps) |
| `src/stores/terminals.ts` | `shellState`, `awaitingInput`, `debouncedBusy`, `handleShellStateChange`, `onBusyToIdle` |
| `src/components/Terminal/Terminal.tsx` | `handlePtyData` (grid frame render), `pty-parsed` event handler, notification effect |
| `src/components/Terminal/awaitingInputSound.ts` | `getAwaitingInputSound` edge detection |
| `src/App.tsx` | `onBusyToIdle` → completion notification with deferral + guards |
| `src/stores/notifications.ts` | `play()`, `playQuestion()`, `playCompletion()` etc. |
| `src/components/TabBar/TabBar.tsx` | Tab indicator class priority logic |
| `src/components/TabBar/TabBar.module.css` | Indicator colors and animations |
