diff --git a/.gitignore b/.gitignore
index b9cfb3a..c2056bd 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,6 +4,10 @@ app.log
aisdk_debug.log
sounds/complete.wav
+__pycache__/
+*.py[cod]
+*$py.class
+
_dev_reference1
_dev_reference2
.env
diff --git a/PERF.md b/PERF.md
new file mode 100644
index 0000000..a17f285
--- /dev/null
+++ b/PERF.md
@@ -0,0 +1,361 @@
+# Performance notes
+
+Startup + idle-CPU vs peer agent CLIs. Update this file after meaningful runs.
+
+```bash
+just bench-perf
+# Prefer the release binary for fair idle numbers:
+cargo build --release && PATH="./target/release:$PATH" just bench-perf
+```
+
+Script: [`scripts/bench-perf.py`](scripts/bench-perf.py) · recipe: `just bench-perf`
+
+| Section | What | Tool |
+| --- | --- | --- |
+| **A** | `--version` startup | hyperfine |
+| **B** | TUI first frame | PTY + terminal-query replies |
+| **C** | Idle CPU after settle | process-tree `ps` (macOS) / `/proc` (Linux) |
+
+Related: [#28](https://github.com/Blankeos/crabcode/issues/28) (idle CPU peg).
+
+---
+
+## Latest
+
+**2026-08-26** · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+settle=`3s` · sample=`8s` · interval=`0.25s` · version runs=`50`
+
+### A) `--version` (lower is better)
+
+| Agent | mean ± σ | min … max |
+| --- | ---: | ---: |
+| **crabcode** | **7.71 ms ± 0.88** | 6.82 … 10.81 |
+| grok | 10.45 ms ± 0.57 | 9.55 … 12.21 |
+| codex | 12.61 ms ± 0.93 | 10.24 … 14.84 |
+| opencode | 355.67 ms ± 7.51 | 348.53 … 381.50 |
+
+crabcode is **1.35×** faster than grok, **1.64×** than codex, **~46×** than opencode.
+
+### B) TUI first frame (lower is better)
+
+| Agent | mean | best … worst |
+| --- | ---: | ---: |
+| **codex** | **50.9 ms** | 50.5 … 51.6 |
+| crabcode | 54.9 ms | 54.8 … 55.0 |
+| opencode | 1023.7 ms | 969.5 … 1125.0 |
+| grok | 1743.3 ms | 1554.0 … 1936.3 |
+
+### C) Idle CPU after settle (lower is better)
+
+| Agent | mean | p50 | p95 | max | RSS |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| **crabcode** | **0.1%** | 0.0% | 0.3% | 1.3% | 52.4 MB |
+| grok | 1.0% | 1.0% | 1.4% | 1.5% | 95.9 MB |
+| codex | 1.0% | 0.7% | 2.3% | 4.6% | 204.3 MB |
+| opencode | 8.8% | 4.8% | 28.5% | 49.3% | 1027.5 MB |
+
+**Verdict:** crabcode best (or tied) on idle CPU.
+
+> Tip: use a **release** binary and `--settle 5 --sample 10+`. Debug builds / short settle can still show Home blink (~60fps) and inflate idle %.
+
+
+Raw dump
+
+```
+A) --version startup (hyperfine)
+ crabcode 7.71 ms ± 0.88 (min 6.82, max 10.81, n=50)
+ codex 12.61 ms ± 0.93 (min 10.24, max 14.84, n=50)
+ grok 10.45 ms ± 0.57 (min 9.55, max 12.21, n=50)
+ opencode 355.67 ms ± 7.51 (min 348.53, max 381.50, n=50)
+
+B) TUI first frame
+ crabcode first_frame 54.9 ms (best 54.8, worst 55.0)
+ codex first_frame 50.9 ms (best 50.5, worst 51.6)
+ grok first_frame 1743.3 ms (best 1554.0, worst 1936.3)
+ opencode first_frame 1023.7 ms (best 969.5, worst 1125.0)
+
+C) Idle CPU (settle=3s, sample=8s)
+ crabcode cpu mean= 0.1% p50= 0.0% p95= 0.3% max= 1.3% rss= 52.4MB procs=1 n=25
+ codex cpu mean= 1.0% p50= 0.7% p95= 2.3% max= 4.6% rss= 204.3MB procs=1 n=25
+ grok cpu mean= 1.0% p50= 1.0% p95= 1.4% max= 1.5% rss= 95.9MB procs=1 n=25
+ opencode cpu mean= 8.8% p50= 4.8% p95= 28.5% max= 49.3% rss=1027.5MB procs=1 n=26
+```
+
+
+
+---
+
+## History
+
+
+2026-08-26 · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+
+**2026-08-26** · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+settle=`3s` · sample=`8s` · interval=`0.25s` · version runs=`50`
+
+### A) `--version` (lower is better)
+
+| Agent | mean ± σ | min … max |
+| --- | ---: | ---: |
+| **crabcode** | **8.98 ms ± 1.33** | 6.53 … 14.01 |
+| grok | 12.74 ms ± 1.49 | 10.35 … 17.83 |
+| codex | 15.24 ms ± 1.18 | 12.58 … 18.59 |
+| opencode | 360.30 ms ± 7.27 | 349.46 … 385.32 |
+
+crabcode is **1.42×** faster than grok, **1.70×** than codex, **~40×** than opencode.
+
+### B) TUI first frame (lower is better)
+
+| Agent | mean | best … worst |
+| --- | ---: | ---: |
+| **codex** | **50.7 ms** | 50.3 … 50.9 |
+| crabcode | 54.6 ms | 53.7 … 55.1 |
+| opencode | 914.3 ms | 908.1 … 924.6 |
+| grok | 1518.7 ms | 1303.7 … 1843.2 |
+
+### C) Idle CPU after settle (lower is better)
+
+| Agent | mean | p50 | p95 | max | RSS |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| **crabcode** | **0.1%** | 0.0% | 0.3% | 1.4% | 52.3 MB |
+| grok | 0.8% | 0.9% | 1.2% | 2.1% | 96.8 MB |
+| codex | 0.9% | 0.7% | 2.3% | 2.6% | 209.5 MB |
+| opencode | 5.2% | 2.5% | 21.7% | 27.0% | 999.1 MB |
+
+**Verdict:** crabcode best (or tied) on idle CPU.
+
+> Tip: use a **release** binary and `--settle 5 --sample 10+`. Debug builds / short settle can still show Home blink (~60fps) and inflate idle %.
+
+
+Raw dump
+
+```
+A) --version startup (hyperfine)
+ crabcode 8.98 ms ± 1.33 (min 6.53, max 14.01, n=50)
+ codex 15.24 ms ± 1.18 (min 12.58, max 18.59, n=50)
+ grok 12.74 ms ± 1.49 (min 10.35, max 17.83, n=50)
+ opencode 360.30 ms ± 7.27 (min 349.46, max 385.32, n=50)
+
+B) TUI first frame
+ crabcode first_frame 54.6 ms (best 53.7, worst 55.1)
+ codex first_frame 50.7 ms (best 50.3, worst 50.9)
+ grok first_frame 1518.7 ms (best 1303.7, worst 1843.2)
+ opencode first_frame 914.3 ms (best 908.1, worst 924.6)
+
+C) Idle CPU (settle=3s, sample=8s)
+ crabcode cpu mean= 0.1% p50= 0.0% p95= 0.3% max= 1.4% rss= 52.3MB procs=1 n=26
+ codex cpu mean= 0.9% p50= 0.7% p95= 2.3% max= 2.6% rss= 209.5MB procs=1 n=26
+ grok cpu mean= 0.8% p50= 0.9% p95= 1.2% max= 2.1% rss= 96.8MB procs=1 n=26
+ opencode cpu mean= 5.2% p50= 2.5% p95= 21.7% max= 27.0% rss= 999.1MB procs=1 n=26
+```
+
+
+
+
+
+
+2026-08-26 · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+
+**2026-08-26** · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+settle=`3s` · sample=`8s` · interval=`0.25s` · version runs=`50`
+
+### A) `--version` (lower is better)
+
+| Agent | mean ± σ | min … max |
+| --- | ---: | ---: |
+| **crabcode** | **8.47 ms ± 1.91** | 6.46 … 18.29 |
+| codex | 12.71 ms ± 1.31 | 11.11 … 18.57 |
+| grok | 12.86 ms ± 1.49 | 9.66 … 15.58 |
+| opencode | 367.74 ms ± 13.62 | 352.03 … 430.16 |
+
+crabcode is **1.50×** faster than codex, **1.52×** than grok, **~43×** than opencode.
+
+### B) TUI first frame (lower is better)
+
+| Agent | mean | best … worst |
+| --- | ---: | ---: |
+| **codex** | **70.7 ms** | 50.4 … 110.0 |
+| crabcode | 123.1 ms | 106.2 … 156.8 |
+| opencode | 1097.4 ms | 973.4 … 1342.9 |
+| grok | 1537.8 ms | 1347.4 … 1650.7 |
+
+### C) Idle CPU after settle (lower is better)
+
+| Agent | mean | p50 | p95 | max | RSS |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| **crabcode** | **0.1%** | 0.0% | 0.2% | 1.0% | 51.9 MB |
+| grok | 0.9% | 0.9% | 1.6% | 1.7% | 97.9 MB |
+| codex | 1.0% | 0.6% | 2.6% | 5.1% | 192.4 MB |
+| opencode | 5.6% | 1.5% | 27.3% | 33.9% | 990.1 MB |
+
+**Verdict:** crabcode best (or tied) on idle CPU.
+
+> Tip: use a **release** binary and `--settle 5 --sample 10+`. Debug builds / short settle can still show Home blink (~60fps) and inflate idle %.
+
+
+Raw dump
+
+```
+A) --version startup (hyperfine)
+ crabcode 8.47 ms ± 1.91 (min 6.46, max 18.29, n=50)
+ codex 12.71 ms ± 1.31 (min 11.11, max 18.57, n=50)
+ grok 12.86 ms ± 1.49 (min 9.66, max 15.58, n=50)
+ opencode 367.74 ms ± 13.62 (min 352.03, max 430.16, n=50)
+
+B) TUI first frame
+ crabcode first_frame 123.1 ms (best 106.2, worst 156.8)
+ codex first_frame 70.7 ms (best 50.4, worst 110.0)
+ grok first_frame 1537.8 ms (best 1347.4, worst 1650.7)
+ opencode first_frame 1097.4 ms (best 973.4, worst 1342.9)
+
+C) Idle CPU (settle=3s, sample=8s)
+ crabcode cpu mean= 0.1% p50= 0.0% p95= 0.2% max= 1.0% rss= 51.9MB procs=1 n=26
+ codex cpu mean= 1.0% p50= 0.6% p95= 2.6% max= 5.1% rss= 192.4MB procs=1 n=26
+ grok cpu mean= 0.9% p50= 0.9% p95= 1.6% max= 1.7% rss= 97.9MB procs=1 n=26
+ opencode cpu mean= 5.6% p50= 1.5% p95= 27.3% max= 33.9% rss= 990.1MB procs=1 n=26
+```
+
+
+
+
+
+
+2026-08-26 · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+
+**2026-08-26** · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+settle=`3s` · sample=`8s` · interval=`0.25s` · version runs=`50`
+
+### A) `--version` (lower is better)
+
+| Agent | mean ± σ | min … max |
+| --- | ---: | ---: |
+| **crabcode** | **7.09 ms ± 0.83** | 5.84 … 9.76 |
+| codex | 12.53 ms ± 0.96 | 11.56 … 15.46 |
+| grok | 12.60 ms ± 2.40 | 10.23 … 18.87 |
+| opencode | 361.40 ms ± 6.30 | 352.33 … 378.60 |
+
+crabcode is **1.77×** faster than codex, **1.78×** than grok, **~51×** than opencode.
+
+### B) TUI first frame (lower is better)
+
+| Agent | mean | best … worst |
+| --- | ---: | ---: |
+| **codex** | **50.8 ms** | 50.1 … 51.4 |
+| crabcode | 103.4 ms | 101.8 … 105.2 |
+| opencode | 1000.0 ms | 907.2 … 1126.8 |
+| grok | 1684.7 ms | 1394.4 … 2103.6 |
+
+### C) Idle CPU after settle (lower is better)
+
+| Agent | mean | p50 | p95 | max | RSS |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| **crabcode** | **0.1%** | 0.0% | 0.3% | 1.7% | 51.8 MB |
+| codex | 0.6% | 0.4% | 2.3% | 4.3% | 195.5 MB |
+| grok | 1.0% | 1.0% | 1.5% | 1.8% | 103.6 MB |
+| opencode | 7.1% | 2.6% | 18.3% | 84.4% | 1020.1 MB |
+
+**Verdict:** crabcode best (or tied) on idle CPU.
+
+> Tip: use a **release** binary and `--settle 5 --sample 10+`. Debug builds / short settle can still show Home blink (~60fps) and inflate idle %.
+
+
+Raw dump
+
+```
+A) --version startup (hyperfine)
+ crabcode 7.09 ms ± 0.83 (min 5.84, max 9.76, n=50)
+ codex 12.53 ms ± 0.96 (min 11.56, max 15.46, n=50)
+ grok 12.60 ms ± 2.40 (min 10.23, max 18.87, n=50)
+ opencode 361.40 ms ± 6.30 (min 352.33, max 378.60, n=50)
+
+B) TUI first frame
+ crabcode first_frame 103.4 ms (best 101.8, worst 105.2)
+ codex first_frame 50.8 ms (best 50.1, worst 51.4)
+ grok first_frame 1684.7 ms (best 1394.4, worst 2103.6)
+ opencode first_frame 1000.0 ms (best 907.2, worst 1126.8)
+
+C) Idle CPU (settle=3s, sample=8s)
+ crabcode cpu mean= 0.1% p50= 0.0% p95= 0.3% max= 1.7% rss= 51.8MB procs=1 n=25
+ codex cpu mean= 0.6% p50= 0.4% p95= 2.3% max= 4.3% rss= 195.5MB procs=1 n=25
+ grok cpu mean= 1.0% p50= 1.0% p95= 1.5% max= 1.8% rss= 103.6MB procs=1 n=25
+ opencode cpu mean= 7.1% p50= 2.6% p95= 18.3% max= 84.4% rss=1020.1MB procs=1 n=25
+```
+
+
+
+
+
+
+2026-08-26 · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+
+**2026-08-26** · darwin · `Carlos-MacBook-Pro.local` · cwd = repo
+settle=`3s` · sample=`8s` · interval=`0.25s` · version runs=`50`
+
+### A) `--version` (lower is better)
+
+| Agent | mean ± σ | min … max |
+| --- | ---: | ---: |
+| **crabcode** | **7.66 ms ± 0.67** | 6.42 … 9.14 |
+| grok | 12.97 ms ± 2.00 | 10.31 … 20.68 |
+| codex | 14.28 ms ± 1.49 | 11.78 … 19.44 |
+| opencode | 366.29 ms ± 7.38 | 356.21 … 388.76 |
+
+crabcode is **1.69×** faster than grok, **1.86×** than codex, **~48×** than opencode.
+
+### B) TUI first frame (lower is better)
+
+| Agent | mean | best … worst |
+| --- | ---: | ---: |
+| **codex** | **53.0 ms** | 51.3 … 55.1 |
+| crabcode | 121.5 ms | 101.9 … 159.9 |
+| opencode | 1020.7 ms | 967.7 … 1126.1 |
+| grok | 1771.7 ms | 1505.9 … 2145.1 |
+
+### C) Idle CPU after settle (lower is better) · [#28](https://github.com/Blankeos/crabcode/issues/28)
+
+| Agent | mean | p50 | p95 | max | RSS |
+| --- | ---: | ---: | ---: | ---: | ---: |
+| **codex** | **0.0%** | 0.0% | 0.0% | 0.1% | 48.8 MB |
+| grok | 0.9% | 0.9% | 1.4% | 1.5% | 97.4 MB |
+| crabcode | 3.3% | 3.3% | 3.9% | 4.0% | 51.9 MB |
+| opencode | 5.2% | 2.4% | 13.3% | 42.9% | 1014.8 MB |
+
+**Verdict:** loses idle-CPU to codex + grok on this run. Aim: mean ≤ best peer (and ≪ 100% on Linux for #28).
+
+> Tip: use a **release** binary and `--settle 5 --sample 10+`. Debug builds / short settle can still show Home blink (~60fps) and inflate idle %.
+
+
+Raw dump
+
+```
+A) --version startup (hyperfine)
+ crabcode 7.66 ms ± 0.67 (min 6.42, max 9.14, n=50)
+ codex 14.28 ms ± 1.49 (min 11.78, max 19.44, n=50)
+ grok 12.97 ms ± 2.00 (min 10.31, max 20.68, n=50)
+ opencode 366.29 ms ± 7.38 (min 356.21, max 388.76, n=50)
+
+B) TUI first frame
+ crabcode first_frame 121.5 ms (best 101.9, worst 159.9, n=3)
+ codex first_frame 53.0 ms (best 51.3, worst 55.1, n=3)
+ grok first_frame 1771.7 ms (best 1505.9, worst 2145.1, n=3)
+ opencode first_frame 1020.7 ms (best 967.7, worst 1126.1, n=3)
+
+C) Idle CPU (settle=3.0s, sample=8.0s)
+ crabcode cpu mean= 3.3% p50= 3.3% p95= 3.9% max= 4.0% rss= 51.9MB procs=1 n=25
+ codex cpu mean= 0.0% p50= 0.0% p95= 0.0% max= 0.1% rss= 48.8MB procs=1 n=25
+ grok cpu mean= 0.9% p50= 0.9% p95= 1.4% max= 1.5% rss= 97.4MB procs=1 n=25
+ opencode cpu mean= 5.2% p50= 2.4% p95= 13.3% max= 42.9% rss=1014.8MB procs=1 n=25
+```
+
+
+
+
+
+---
+
+## How to refresh
+
+1. `cargo build --release`
+2. `PATH="./target/release:$PATH" just bench-perf --settle 5 --sample 10 --runs 50`
+3. Answer **`y`** to `Add this to PERF.md?` (or pass `--write-perf` / `--no-write-perf`).
+4. Optional JSON: `--json-out /tmp/crabcode-bench-perf.json`
diff --git a/README.md b/README.md
index 9d7683b..216bc4c 100644
--- a/README.md
+++ b/README.md
@@ -143,6 +143,8 @@ Like any benchmark, please take this with a grain of salt. I have a cherry-picke
| 🔲 opencode | 100% | 19/19 | 34.9s | 4612 | $0.0279 |
| ⚛️ codex | 100% | 19/19 | 33.7s | 36888 | $0.3506 |
+CLI startup / first-frame / idle-CPU vs peers (hyperfine + PTY): see **[PERF.md](PERF.md)** (`just bench-perf`).
+
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.
diff --git a/_plans/FIRST_PAINT.md b/_plans/FIRST_PAINT.md
new file mode 100644
index 0000000..9231850
--- /dev/null
+++ b/_plans/FIRST_PAINT.md
@@ -0,0 +1,32 @@
+# First Paint Speed (vs Codex)
+
+Target: Codex ~53ms first frame. Crabcode today: ~103–123ms (`PERF.md`).
+
+## Ranked wins
+
+| Rank | Win | Est. | Status |
+|------|-----|------|--------|
+| 1 | Skip blocking `supports_keyboard_enhancement()` (CSI probe + timeout). Always push enhancement flags like Codex. Opt out: `CRABCODE_DISABLE_KEYBOARD_ENHANCEMENT`. | 10–50ms | done |
+| 2 | Defer `SessionManager` SQLite history until after first draw. Sync load only for `--session`. | 5–30ms | done |
+| 3 | Split `App::new`: minimal shell for frame 0; hydrate config/prefs/themes/skills after. Codex `StartupDraft` pattern. | 20–40ms | done |
+| 4 | Theme resolve before first paint (peek config + prefs + discover); keep skills/autocomplete deferred. | flash fix | done |
+| 5 | Move prefs SQLite + model preference reads off the critical path when CLI model override is set. | 2–10ms | later |
+| 6 | Optional: draw before full `App::new` (terminal init → empty frame → hydrate). | variable | later |
+
+## Codex reference
+
+- Probe skip: `.devrefs/references/openai/codex/codex-rs/tui/src/tui.rs` (`enable_keyboard_mode`)
+- Policy: `tui/src/tui/keyboard_modes.rs` (`always_enable` unless disabled)
+- Startup draft / deferred hydrate: TUI app entry around `StartupDraft`
+
+## Our critical path today (`main` → first `terminal.draw`)
+
+1. `App::new_with_model_override` (config, prefs DB, themes, skills, autocomplete, …) — history deferred
+2. `enable_raw_mode`
+3. alt screen + mouse + paste (+ keyboard flags, no CSI probe)
+4. `Terminal::new`
+5. `run_event_loop` → first `terminal.draw` → then `ensure_session_history`
+
+## Measurement
+
+Re-run `scripts/bench-perf.py` / `PERF.md` workflow after each win. Prefer `CRABCODE_STARTUP_DIAG=1` spans if we add timed checkpoints.
diff --git a/justfile b/justfile
index 2315b5d..9a0af0c 100644
--- a/justfile
+++ b/justfile
@@ -48,6 +48,22 @@ gen-themes *args:
bench-agents *args:
bun run scripts/bench-agents.ts {{ args }}
+[doc("""
+ Startup + idle-CPU perf vs peer CLIs.
+
+ A) hyperfine --version B) PTY first-frame C) idle CPU after settle
+ Ends with: Add this to PERF.md? [y/N]
+
+ just bench-perf
+ just bench-perf --agents crabcode,codex,grok
+ just bench-perf --section idle --settle 5 --sample 15
+ just bench-perf --write-perf # skip prompt, update PERF.md
+ just bench-perf --no-write-perf # skip prompt, don't update
+ cargo build --release && PATH="./target/release:$PATH" just bench-perf
+""")]
+bench-perf *args:
+ python3 scripts/bench-perf.py {{ args }}
+
devdocs:
gittydocs dev _docs
diff --git a/scripts/bench-perf.py b/scripts/bench-perf.py
new file mode 100644
index 0000000..02ef98a
--- /dev/null
+++ b/scripts/bench-perf.py
@@ -0,0 +1,1072 @@
+#!/usr/bin/env python3
+"""Compare crabcode vs peer agent CLIs on startup + idle CPU.
+
+Sections
+ A) --version startup (hyperfine, lazygitrs-style)
+ B) TUI open / first-frame (PTY + terminal-query replies)
+ C) Idle CPU after settle (process tree via ps / optional /proc)
+
+Examples
+ just bench-perf
+ python3 scripts/bench-perf.py --agents crabcode,codex,grok,opencode
+ python3 scripts/bench-perf.py --section version
+ python3 scripts/bench-perf.py --section idle --settle 5 --sample 10
+ python3 scripts/bench-perf.py --cwd /tmp --json-out /tmp/bench.json
+
+Requires: python3. Optional: hyperfine (section A).
+"""
+
+from __future__ import annotations
+
+import argparse
+import datetime as dt
+import fcntl
+import json
+import os
+import pty
+import re
+import select
+import shutil
+import signal
+import statistics
+import struct
+import subprocess
+import sys
+import termios
+import time
+from dataclasses import asdict, dataclass, field
+from pathlib import Path
+
+DEFAULT_AGENTS = ("crabcode", "codex", "grok", "opencode")
+
+# argv to launch interactive TUI (no prompt / no print mode)
+AGENT_ARGV: dict[str, list[str]] = {
+ "crabcode": ["crabcode"],
+ "codex": ["codex"],
+ "grok": ["grok"],
+ "opencode": ["opencode"],
+}
+
+AGENT_VERSION_ARGV: dict[str, list[str]] = {
+ "crabcode": ["crabcode", "--version"],
+ "codex": ["codex", "--version"],
+ "grok": ["grok", "--version"],
+ "opencode": ["opencode", "--version"],
+}
+
+
+@dataclass
+class VersionResult:
+ agent: str
+ available: bool
+ mean_ms: float | None = None
+ stddev_ms: float | None = None
+ min_ms: float | None = None
+ max_ms: float | None = None
+ runs: int | None = None
+ error: str | None = None
+ raw: str | None = None
+
+
+@dataclass
+class OpenResult:
+ agent: str
+ available: bool
+ first_byte_ms: float | None = None
+ first_frame_ms: float | None = None
+ best_ms: float | None = None
+ worst_ms: float | None = None
+ bytes_seen: int = 0
+ error: str | None = None
+
+
+@dataclass
+class IdleSample:
+ cpu_pct: float
+ rss_kb: int
+ nprocs: int
+
+
+@dataclass
+class IdleResult:
+ agent: str
+ available: bool
+ first_frame_ms: float | None = None
+ cpu_mean: float | None = None
+ cpu_p50: float | None = None
+ cpu_p95: float | None = None
+ cpu_max: float | None = None
+ cpu_min: float | None = None
+ rss_mean_mb: float | None = None
+ nprocs: int | None = None
+ samples: int = 0
+ error: str | None = None
+
+
+@dataclass
+class Report:
+ host: str
+ platform: str
+ cwd: str
+ settle_s: float
+ sample_s: float
+ sample_interval_s: float
+ version: list[VersionResult] = field(default_factory=list)
+ open: list[OpenResult] = field(default_factory=list)
+ idle: list[IdleResult] = field(default_factory=list)
+
+
+def which_agent(name: str) -> str | None:
+ argv = AGENT_ARGV.get(name, [name])
+ return shutil.which(argv[0])
+
+
+def set_winsize(fd: int, rows: int = 40, cols: int = 120) -> None:
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
+
+
+def reply_queries(data: bytes) -> bytes:
+ """Answer common DA / cursor / OSC probes so TUIs leave the probe phase."""
+ out = b""
+ if b"\x1b[c" in data or b"\x1b[0c" in data:
+ out += b"\x1b[?62;22c"
+ if b"\x1b[>c" in data or b"\x1b[>0c" in data:
+ out += b"\x1b[>1;10;0c"
+ if b"\x1b[6n" in data:
+ out += b"\x1b[24;80R"
+ if b"\x1b[>0q" in data:
+ out += b"\x1bP>|xterm-256color\x1b\\"
+ if b"\x1b]10;?" in data:
+ out += b"\x1b]10;rgb:aaaa/aaaa/aaaa\x1b\\"
+ if b"\x1b]11;?" in data:
+ out += b"\x1b]11;rgb:1111/1111/1111\x1b\\"
+ return out
+
+
+def looks_like_frame(buf: bytes) -> bool:
+ if len(buf) < 400:
+ return False
+ markers = (b"\x1b[2J", b"\x1b[H", b"\x1b[?1049h", b"\x1b[?2026h", b"\x1b[?1049h")
+ return any(m in buf for m in markers)
+
+
+def descendant_pids(root: int) -> set[int]:
+ pids = {root}
+ queue = [root]
+ while queue:
+ parent = queue.pop()
+ try:
+ out = subprocess.check_output(
+ ["pgrep", "-P", str(parent)], text=True, stderr=subprocess.DEVNULL
+ )
+ except (subprocess.CalledProcessError, FileNotFoundError):
+ continue
+ for line in out.split():
+ try:
+ child = int(line)
+ except ValueError:
+ continue
+ if child not in pids:
+ pids.add(child)
+ queue.append(child)
+ return pids
+
+
+def read_cpu_rss(pids: set[int]) -> tuple[float, int, int]:
+ """Return (cpu_pct_sum, rss_kb_sum, alive_count).
+
+ On Linux, prefer /proc//stat delta when available for more stable
+ instantaneous CPU; fall back to `ps -o %cpu` everywhere else.
+ """
+ if sys.platform.startswith("linux"):
+ try:
+ return _linux_cpu_rss(pids)
+ except Exception:
+ pass
+ return _ps_cpu_rss(pids)
+
+
+def _ps_cpu_rss(pids: set[int]) -> tuple[float, int, int]:
+ total_cpu = 0.0
+ total_rss = 0
+ alive = 0
+ for pid in list(pids):
+ try:
+ out = subprocess.check_output(
+ ["ps", "-p", str(pid), "-o", "%cpu=,rss="],
+ text=True,
+ stderr=subprocess.DEVNULL,
+ ).strip()
+ except subprocess.CalledProcessError:
+ continue
+ if not out:
+ continue
+ parts = out.split()
+ if len(parts) < 2:
+ continue
+ try:
+ total_cpu += float(parts[0])
+ total_rss += int(parts[1])
+ alive += 1
+ except ValueError:
+ continue
+ return total_cpu, total_rss, alive
+
+
+_linux_prev: dict[int, tuple[int, int, float]] = {}
+
+
+def _linux_cpu_rss(pids: set[int]) -> tuple[float, int, int]:
+ """Approximate %CPU over the last sample interval using /proc jiffies."""
+ global _linux_prev
+ clk = os.sysconf(os.sysconf_names.get("SC_CLK_TCK", "SC_CLK_TCK"))
+ now = time.time()
+ total_cpu = 0.0
+ total_rss = 0
+ alive = 0
+ seen: set[int] = set()
+ for pid in list(pids):
+ stat_path = Path(f"/proc/{pid}/stat")
+ status_path = Path(f"/proc/{pid}/status")
+ if not stat_path.exists():
+ continue
+ try:
+ fields = stat_path.read_text().rsplit(")", 1)[-1].split()
+ # After comm: utime=14th field of full stat → index 11 in post-comm
+ utime = int(fields[11])
+ stime = int(fields[12])
+ jiffies = utime + stime
+ except Exception:
+ continue
+ rss_kb = 0
+ try:
+ for line in status_path.read_text().splitlines():
+ if line.startswith("VmRSS:"):
+ rss_kb = int(line.split()[1])
+ break
+ except Exception:
+ pass
+ prev = _linux_prev.get(pid)
+ if prev is not None:
+ prev_j, _prev_rss, prev_t = prev
+ dt = max(now - prev_t, 1e-6)
+ dj = max(jiffies - prev_j, 0)
+ total_cpu += (dj / clk) / dt * 100.0
+ _linux_prev[pid] = (jiffies, rss_kb, now)
+ seen.add(pid)
+ total_rss += rss_kb
+ alive += 1
+ # Drop stale
+ for pid in list(_linux_prev):
+ if pid not in seen:
+ _linux_prev.pop(pid, None)
+ return total_cpu, total_rss, alive
+
+
+def kill_tree(proc: subprocess.Popen[bytes]) -> None:
+ try:
+ os.killpg(proc.pid, signal.SIGTERM)
+ except ProcessLookupError:
+ return
+ try:
+ proc.wait(timeout=2)
+ return
+ except subprocess.TimeoutExpired:
+ pass
+ try:
+ os.killpg(proc.pid, signal.SIGKILL)
+ proc.wait(timeout=1)
+ except Exception:
+ pass
+
+
+def spawn_pty(argv: list[str], cwd: str) -> tuple[subprocess.Popen[bytes], int]:
+ master, slave = pty.openpty()
+ set_winsize(slave)
+ set_winsize(master)
+ env = os.environ.copy()
+ env.setdefault("TERM", "xterm-256color")
+ env.setdefault("COLORTERM", "truecolor")
+ # Keep auth out of the way for idle benches when possible
+ env.setdefault("CI", "1")
+ proc = subprocess.Popen(
+ argv,
+ stdin=slave,
+ stdout=slave,
+ stderr=slave,
+ env=env,
+ cwd=cwd,
+ preexec_fn=os.setsid,
+ )
+ os.close(slave)
+ return proc, master
+
+
+def drain_and_reply(master: int, buf: bytearray, timeout: float = 0.0) -> int:
+ """Read available PTY output, reply to queries. Returns bytes read."""
+ read_n = 0
+ end = time.perf_counter() + timeout
+ while True:
+ wait = max(0.0, end - time.perf_counter()) if timeout else 0.0
+ r, _, _ = select.select([master], [], [], wait)
+ if master not in r:
+ break
+ try:
+ chunk = os.read(master, 65536)
+ except OSError:
+ break
+ if not chunk:
+ break
+ buf.extend(chunk)
+ read_n += len(chunk)
+ reply = reply_queries(chunk)
+ if reply:
+ try:
+ os.write(master, reply)
+ except OSError:
+ pass
+ if timeout == 0.0:
+ # non-blocking drain: keep going while data ready
+ continue
+ if time.perf_counter() >= end:
+ break
+ return read_n
+
+
+def wait_first_frame(
+ proc: subprocess.Popen[bytes], master: int, timeout: float
+) -> tuple[float | None, float | None, bytearray]:
+ buf = bytearray()
+ start = time.perf_counter()
+ first_byte: float | None = None
+ first_frame: float | None = None
+ deadline = start + timeout
+ while time.perf_counter() < deadline:
+ n = drain_and_reply(master, buf, timeout=0.05)
+ now = time.perf_counter()
+ if n and first_byte is None:
+ first_byte = now - start
+ if first_frame is None and looks_like_frame(bytes(buf)):
+ first_frame = now - start
+ break
+ if proc.poll() is not None:
+ break
+ return first_byte, first_frame, buf
+
+
+def percentile(xs: list[float], p: float) -> float:
+ if not xs:
+ return 0.0
+ ys = sorted(xs)
+ if len(ys) == 1:
+ return ys[0]
+ k = (len(ys) - 1) * p
+ f = int(k)
+ c = min(f + 1, len(ys) - 1)
+ if f == c:
+ return ys[f]
+ return ys[f] + (ys[c] - ys[f]) * (k - f)
+
+
+# ---------------------------------------------------------------------------
+# Section A: --version via hyperfine
+# ---------------------------------------------------------------------------
+
+
+def bench_version(agents: list[str], warmup: int, runs: int) -> list[VersionResult]:
+ results: list[VersionResult] = []
+ hyperfine = shutil.which("hyperfine")
+ if not hyperfine:
+ print(" ! hyperfine not found — skipping section A (brew install hyperfine)")
+ for name in agents:
+ results.append(
+ VersionResult(agent=name, available=bool(which_agent(name)), error="hyperfine missing")
+ )
+ return results
+
+ for name in agents:
+ path = which_agent(name)
+ if not path:
+ results.append(VersionResult(agent=name, available=False, error="not on PATH"))
+ print(f" {name:10} SKIP (not on PATH)")
+ continue
+ argv = AGENT_VERSION_ARGV[name]
+ cmd = " ".join(shlex_join(argv))
+ try:
+ proc = subprocess.run(
+ [
+ hyperfine,
+ "--style",
+ "none",
+ "--warmup",
+ str(warmup),
+ "--runs",
+ str(runs),
+ "--export-json",
+ "/dev/stdout",
+ cmd,
+ ],
+ check=True,
+ capture_output=True,
+ text=True,
+ )
+ # hyperfine may print progress on stderr; JSON on stdout
+ data = json.loads(proc.stdout)
+ entry = data["results"][0]
+ vr = VersionResult(
+ agent=name,
+ available=True,
+ mean_ms=entry["mean"] * 1000,
+ stddev_ms=entry["stddev"] * 1000,
+ min_ms=entry["min"] * 1000,
+ max_ms=entry["max"] * 1000,
+ runs=entry.get("times") and len(entry["times"]) or runs,
+ raw=proc.stdout,
+ )
+ results.append(vr)
+ print(
+ f" {name:10} {vr.mean_ms:7.2f} ms ± {vr.stddev_ms:5.2f} "
+ f"(min {vr.min_ms:.2f}, max {vr.max_ms:.2f}, n={vr.runs})"
+ )
+ except Exception as e:
+ results.append(VersionResult(agent=name, available=True, error=str(e)))
+ print(f" {name:10} ERROR {e}")
+ return results
+
+
+def shlex_join(argv: list[str]) -> list[str]:
+ # tiny local join that quotes only when needed
+ import shlex
+
+ return [shlex.quote(a) for a in argv]
+
+
+# ---------------------------------------------------------------------------
+# Section B: TUI open / first frame
+# ---------------------------------------------------------------------------
+
+
+def bench_open(agents: list[str], cwd: str, timeout: float, repeats: int) -> list[OpenResult]:
+ results: list[OpenResult] = []
+ for name in agents:
+ if not which_agent(name):
+ results.append(OpenResult(agent=name, available=False, error="not on PATH"))
+ print(f" {name:10} SKIP (not on PATH)")
+ continue
+ argv = AGENT_ARGV[name]
+ frames: list[float] = []
+ bytes_last = 0
+ err: str | None = None
+ for _ in range(repeats):
+ proc = None
+ master = None
+ try:
+ proc, master = spawn_pty(argv, cwd)
+ first_byte, first_frame, buf = wait_first_frame(proc, master, timeout)
+ bytes_last = len(buf)
+ if first_frame is None:
+ err = f"no frame within {timeout}s (bytes={len(buf)})"
+ else:
+ frames.append(first_frame * 1000)
+ except Exception as e:
+ err = str(e)
+ finally:
+ if proc is not None:
+ kill_tree(proc)
+ if master is not None:
+ try:
+ os.close(master)
+ except OSError:
+ pass
+ time.sleep(0.15)
+ if frames:
+ mean = statistics.mean(frames)
+ results.append(
+ OpenResult(
+ agent=name,
+ available=True,
+ first_byte_ms=None,
+ first_frame_ms=mean,
+ best_ms=min(frames),
+ worst_ms=max(frames),
+ bytes_seen=bytes_last,
+ )
+ )
+ print(
+ f" {name:10} first_frame {mean:7.1f} ms "
+ f"(best {min(frames):.1f}, worst {max(frames):.1f}, n={len(frames)})"
+ )
+ else:
+ results.append(OpenResult(agent=name, available=True, error=err, bytes_seen=bytes_last))
+ print(f" {name:10} ERROR {err}")
+ return results
+
+
+# ---------------------------------------------------------------------------
+# Section C: Idle CPU
+# ---------------------------------------------------------------------------
+
+
+def bench_idle(
+ agents: list[str],
+ cwd: str,
+ open_timeout: float,
+ settle_s: float,
+ sample_s: float,
+ interval_s: float,
+) -> list[IdleResult]:
+ results: list[IdleResult] = []
+ for name in agents:
+ if not which_agent(name):
+ results.append(IdleResult(agent=name, available=False, error="not on PATH"))
+ print(f" {name:10} SKIP (not on PATH)")
+ continue
+ argv = AGENT_ARGV[name]
+ proc = None
+ master = None
+ try:
+ # Reset linux jiffy baseline between agents
+ _linux_prev.clear()
+ proc, master = spawn_pty(argv, cwd)
+ first_byte, first_frame, buf = wait_first_frame(proc, master, open_timeout)
+ if first_frame is None:
+ results.append(
+ IdleResult(
+ agent=name,
+ available=True,
+ error=f"no frame within {open_timeout}s (bytes={len(buf)})",
+ )
+ )
+ print(f" {name:10} ERROR no first frame (bytes={len(buf)})")
+ continue
+
+ # Settle: keep answering queries / draining
+ settle_end = time.perf_counter() + settle_s
+ while time.perf_counter() < settle_end:
+ drain_and_reply(master, buf, timeout=0.1)
+ if proc.poll() is not None:
+ break
+
+ if proc.poll() is not None:
+ results.append(
+ IdleResult(
+ agent=name,
+ available=True,
+ first_frame_ms=first_frame * 1000,
+ error=f"exited during settle (code={proc.returncode})",
+ )
+ )
+ print(f" {name:10} ERROR exited during settle")
+ continue
+
+ samples: list[IdleSample] = []
+ sample_end = time.perf_counter() + sample_s
+ # Prime linux counters once
+ pids = descendant_pids(proc.pid)
+ read_cpu_rss(pids)
+ time.sleep(interval_s)
+
+ while time.perf_counter() < sample_end:
+ drain_and_reply(master, buf, timeout=0.0)
+ if proc.poll() is not None:
+ break
+ pids = descendant_pids(proc.pid)
+ cpu, rss, n = read_cpu_rss(pids)
+ samples.append(IdleSample(cpu_pct=cpu, rss_kb=rss, nprocs=n))
+ time.sleep(interval_s)
+
+ if not samples:
+ results.append(
+ IdleResult(
+ agent=name,
+ available=True,
+ first_frame_ms=first_frame * 1000,
+ error="no samples",
+ )
+ )
+ print(f" {name:10} ERROR no samples")
+ continue
+
+ cpus = [s.cpu_pct for s in samples]
+ rsss = [s.rss_kb for s in samples]
+ ir = IdleResult(
+ agent=name,
+ available=True,
+ first_frame_ms=first_frame * 1000,
+ cpu_mean=statistics.mean(cpus),
+ cpu_p50=percentile(cpus, 0.50),
+ cpu_p95=percentile(cpus, 0.95),
+ cpu_max=max(cpus),
+ cpu_min=min(cpus),
+ rss_mean_mb=statistics.mean(rsss) / 1024.0,
+ nprocs=samples[-1].nprocs,
+ samples=len(samples),
+ )
+ results.append(ir)
+ print(
+ f" {name:10} cpu mean={ir.cpu_mean:5.1f}% "
+ f"p50={ir.cpu_p50:5.1f}% p95={ir.cpu_p95:5.1f}% "
+ f"max={ir.cpu_max:5.1f}% rss={ir.rss_mean_mb:6.1f}MB "
+ f"procs={ir.nprocs} n={ir.samples}"
+ )
+ except Exception as e:
+ results.append(IdleResult(agent=name, available=True, error=str(e)))
+ print(f" {name:10} ERROR {e}")
+ finally:
+ if proc is not None:
+ kill_tree(proc)
+ if master is not None:
+ try:
+ os.close(master)
+ except OSError:
+ pass
+ time.sleep(0.2)
+ return results
+
+
+# ---------------------------------------------------------------------------
+# Summary
+# ---------------------------------------------------------------------------
+
+
+def print_summary(report: Report) -> None:
+ print()
+ print("=" * 72)
+ print("SUMMARY")
+ print("=" * 72)
+
+ if report.version:
+ print()
+ print("A) --version (lower is better)")
+ rows = [v for v in report.version if v.mean_ms is not None]
+ rows.sort(key=lambda v: v.mean_ms or 1e9)
+ if rows:
+ best = rows[0].mean_ms or 1.0
+ for v in rows:
+ ratio = (v.mean_ms or 0) / best
+ flag = " ←" if v.agent == "crabcode" else ""
+ print(f" {v.agent:10} {v.mean_ms:7.2f} ms ({ratio:.2f}× best){flag}")
+
+ if report.open:
+ print()
+ print("B) TUI first frame (lower is better)")
+ rows = [o for o in report.open if o.first_frame_ms is not None]
+ rows.sort(key=lambda o: o.first_frame_ms or 1e9)
+ if rows:
+ best = rows[0].first_frame_ms or 1.0
+ for o in rows:
+ ratio = (o.first_frame_ms or 0) / best
+ flag = " ←" if o.agent == "crabcode" else ""
+ print(f" {o.agent:10} {o.first_frame_ms:7.1f} ms ({ratio:.2f}× best){flag}")
+
+ if report.idle:
+ print()
+ print("C) Idle CPU after settle (lower is better)")
+ rows = [i for i in report.idle if i.cpu_mean is not None]
+ # Prefer crabcode on exact ties so the ← marker sits on the winner row.
+ rows.sort(key=lambda i: (round(i.cpu_mean or 1e9, 2), 0 if i.agent == "crabcode" else 1))
+ if rows:
+ best = rows[0].cpu_mean or 0.0
+ for i in rows:
+ if best < 0.05:
+ ratio_s = "tied" if (i.cpu_mean or 0) < 0.05 else f"+{i.cpu_mean:.1f}pp"
+ else:
+ ratio_s = f"{(i.cpu_mean or 0) / best:.2f}× best"
+ flag = " ←" if i.agent == "crabcode" else ""
+ print(
+ f" {i.agent:10} {i.cpu_mean:5.1f}% mean "
+ f"p95={i.cpu_p95:5.1f}% rss={i.rss_mean_mb:6.1f}MB "
+ f"({ratio_s}){flag}"
+ )
+
+ crab = next((i for i in report.idle if i.agent == "crabcode" and i.cpu_mean is not None), None)
+ peers = [i for i in report.idle if i.agent != "crabcode" and i.cpu_mean is not None]
+ if crab and peers:
+ # Treat <0.05% as floor noise on macOS ps.
+ crab_cpu = crab.cpu_mean or 0.0
+ winners = [p for p in peers if (p.cpu_mean or 0) + 0.05 < crab_cpu]
+ if winners:
+ names = ", ".join(p.agent for p in winners)
+ print()
+ print(f" verdict: crabcode loses idle-CPU to: {names}")
+ print(" aim: mean ≤ best peer (and ≪ 100% on Linux)")
+ else:
+ print()
+ print(" verdict: crabcode best (or tied) on idle CPU ✓")
+
+ print()
+ print(f"host={report.host} platform={report.platform} cwd={report.cwd}")
+ print(f"settle={report.settle_s}s sample={report.sample_s}s interval={report.sample_interval_s}s")
+ print()
+ print("Notes")
+ print(" • macOS `ps %cpu` is smoothed — use Linux /proc for a clearer idle peg.")
+ print(" • Run from a real project dir to include indexer cost.")
+ print(" • Compare release binary: `cargo build --release && PATH=./target/release:$PATH just bench-perf`")
+
+
+# ---------------------------------------------------------------------------
+# PERF.md update
+# ---------------------------------------------------------------------------
+
+
+def repo_root() -> Path:
+ return Path(__file__).resolve().parent.parent
+
+
+def default_perf_md() -> Path:
+ return repo_root() / "PERF.md"
+
+
+def _cwd_label(cwd: str) -> str:
+ root = str(repo_root())
+ if os.path.abspath(cwd) == root:
+ return "repo"
+ return cwd
+
+
+def _idle_verdict(report: Report) -> str:
+ crab = next((i for i in report.idle if i.agent == "crabcode" and i.cpu_mean is not None), None)
+ peers = [i for i in report.idle if i.agent != "crabcode" and i.cpu_mean is not None]
+ if not crab or not peers:
+ return "n/a (incomplete idle section)"
+ crab_cpu = crab.cpu_mean or 0.0
+ winners = [p for p in peers if (p.cpu_mean or 0) + 0.05 < crab_cpu]
+ if winners:
+ names = " + ".join(p.agent for p in winners)
+ return f"loses idle-CPU to {names} on this run. Aim: mean ≤ best peer (and ≪ 100% on Linux)."
+ return "crabcode best (or tied) on idle CPU."
+
+
+def format_latest_markdown(report: Report, version_runs: int | None) -> str:
+ today = dt.date.today().isoformat()
+ runs = version_runs
+ if runs is None:
+ for v in report.version:
+ if v.runs:
+ runs = v.runs
+ break
+ runs_s = str(runs) if runs is not None else "?"
+
+ lines: list[str] = []
+ lines.append(f"**{today}** · {report.platform} · `{report.host}` · cwd = {_cwd_label(report.cwd)} ")
+ lines.append(
+ f"settle=`{report.settle_s:g}s` · sample=`{report.sample_s:g}s` · "
+ f"interval=`{report.sample_interval_s:g}s` · version runs=`{runs_s}`"
+ )
+ lines.append("")
+
+ if any(v.mean_ms is not None for v in report.version):
+ rows = [v for v in report.version if v.mean_ms is not None]
+ rows.sort(key=lambda v: v.mean_ms or 1e9)
+ best = rows[0]
+ lines.append("### A) `--version` (lower is better)")
+ lines.append("")
+ lines.append("| Agent | mean ± σ | min … max |")
+ lines.append("| --- | ---: | ---: |")
+ for v in rows:
+ name = f"**{v.agent}**" if v.agent == best.agent else v.agent
+ mean = f"**{v.mean_ms:.2f} ms ± {v.stddev_ms:.2f}**" if v.agent == best.agent else f"{v.mean_ms:.2f} ms ± {v.stddev_ms:.2f}"
+ lines.append(f"| {name} | {mean} | {v.min_ms:.2f} … {v.max_ms:.2f} |")
+ lines.append("")
+ crab = next((v for v in rows if v.agent == "crabcode"), None)
+ if crab and best.agent == "crabcode" and best.mean_ms:
+ parts: list[str] = []
+ for v in rows:
+ if v.agent == "crabcode" or not v.mean_ms:
+ continue
+ ratio = v.mean_ms / best.mean_ms
+ if v.agent == "opencode":
+ parts.append(f"**~{ratio:.0f}×** than opencode")
+ elif not parts:
+ parts.append(f"**{ratio:.2f}×** faster than {v.agent}")
+ else:
+ parts.append(f"**{ratio:.2f}×** than {v.agent}")
+ if parts:
+ lines.append("crabcode is " + ", ".join(parts) + ".")
+ lines.append("")
+ elif crab and best.mean_ms and crab.mean_ms:
+ lines.append(
+ f"crabcode is **{crab.mean_ms / best.mean_ms:.2f}×** best "
+ f"(best: {best.agent})."
+ )
+ lines.append("")
+
+ if any(o.first_frame_ms is not None for o in report.open):
+ rows = [o for o in report.open if o.first_frame_ms is not None]
+ rows.sort(key=lambda o: o.first_frame_ms or 1e9)
+ best = rows[0]
+ lines.append("### B) TUI first frame (lower is better)")
+ lines.append("")
+ lines.append("| Agent | mean | best … worst |")
+ lines.append("| --- | ---: | ---: |")
+ for o in rows:
+ name = f"**{o.agent}**" if o.agent == best.agent else o.agent
+ mean = (
+ f"**{o.first_frame_ms:.1f} ms**"
+ if o.agent == best.agent
+ else f"{o.first_frame_ms:.1f} ms"
+ )
+ if o.best_ms is not None and o.worst_ms is not None:
+ rng = f"{o.best_ms:.1f} … {o.worst_ms:.1f}"
+ else:
+ rng = "—"
+ lines.append(f"| {name} | {mean} | {rng} |")
+ lines.append("")
+
+ if any(i.cpu_mean is not None for i in report.idle):
+ rows = [i for i in report.idle if i.cpu_mean is not None]
+ rows.sort(key=lambda i: (round(i.cpu_mean or 1e9, 2), 0 if i.agent == "crabcode" else 1))
+ best = rows[0]
+ lines.append("### C) Idle CPU after settle (lower is better)")
+ lines.append("")
+ lines.append("| Agent | mean | p50 | p95 | max | RSS |")
+ lines.append("| --- | ---: | ---: | ---: | ---: | ---: |")
+ for i in rows:
+ name = f"**{i.agent}**" if i.agent == best.agent else i.agent
+ mean = f"**{i.cpu_mean:.1f}%**" if i.agent == best.agent else f"{i.cpu_mean:.1f}%"
+ lines.append(
+ f"| {name} | {mean} | {i.cpu_p50:.1f}% | {i.cpu_p95:.1f}% | "
+ f"{i.cpu_max:.1f}% | {i.rss_mean_mb:.1f} MB |"
+ )
+ lines.append("")
+ lines.append(f"**Verdict:** {_idle_verdict(report)}")
+ lines.append("")
+
+ lines.append(
+ "> Tip: use a **release** binary and `--settle 5 --sample 10+`. "
+ "Debug builds / short settle can still show Home blink (~60fps) and inflate idle %."
+ )
+ lines.append("")
+ lines.append("")
+ lines.append("Raw dump
")
+ lines.append("")
+ lines.append("```")
+ lines.extend(_raw_dump_lines(report))
+ lines.append("```")
+ lines.append("")
+ lines.append(" ")
+ return "\n".join(lines)
+
+
+def _raw_dump_lines(report: Report) -> list[str]:
+ out: list[str] = []
+ if report.version:
+ out.append("A) --version startup (hyperfine)")
+ for v in report.version:
+ if v.mean_ms is None:
+ out.append(f" {v.agent:10} ERROR {v.error or 'n/a'}")
+ else:
+ out.append(
+ f" {v.agent:10} {v.mean_ms:6.2f} ms ± {v.stddev_ms:5.2f} "
+ f"(min {v.min_ms:.2f}, max {v.max_ms:.2f}, n={v.runs})"
+ )
+ out.append("")
+ if report.open:
+ out.append("B) TUI first frame")
+ for o in report.open:
+ if o.first_frame_ms is None:
+ out.append(f" {o.agent:10} ERROR {o.error or 'n/a'}")
+ elif o.best_ms is not None and o.worst_ms is not None:
+ out.append(
+ f" {o.agent:10} first_frame {o.first_frame_ms:7.1f} ms "
+ f"(best {o.best_ms:.1f}, worst {o.worst_ms:.1f})"
+ )
+ else:
+ out.append(f" {o.agent:10} first_frame {o.first_frame_ms:7.1f} ms")
+ out.append("")
+ if report.idle:
+ out.append(
+ f"C) Idle CPU (settle={report.settle_s:g}s, sample={report.sample_s:g}s)"
+ )
+ for i in report.idle:
+ if i.cpu_mean is None:
+ out.append(f" {i.agent:10} ERROR {i.error or 'n/a'}")
+ else:
+ out.append(
+ f" {i.agent:10} cpu mean={i.cpu_mean:5.1f}% "
+ f"p50={i.cpu_p50:5.1f}% p95={i.cpu_p95:5.1f}% "
+ f"max={i.cpu_max:5.1f}% rss={i.rss_mean_mb:6.1f}MB "
+ f"procs={i.nprocs} n={i.samples}"
+ )
+ return out
+
+
+def update_perf_md(path: Path, report: Report, version_runs: int | None) -> None:
+ if not path.exists():
+ raise FileNotFoundError(f"{path} not found")
+
+ text = path.read_text()
+ latest_new = format_latest_markdown(report, version_runs=version_runs)
+
+ # Split on ## Latest ... ## History
+ m = re.search(
+ r"(?s)(## Latest\n)(.*?)(\n---\n\n## History\n)(.*?)(\n---\n\n## How to refresh\n)",
+ text,
+ )
+ if not m:
+ raise ValueError(
+ f"{path} missing expected ## Latest / ## History / ## How to refresh markers"
+ )
+
+ old_latest = m.group(2).strip("\n")
+ old_history = m.group(4).strip("\n")
+
+ # Archive previous Latest into History (newest first), skip placeholder
+ placeholder = old_latest.strip().startswith("_(") or "none yet" in old_latest.lower()
+ archived = []
+ if old_latest.strip() and not placeholder:
+ archived.append("")
+ archived.append(f"{_history_summary(old_latest)}
")
+ archived.append("")
+ archived.append(old_latest.strip())
+ archived.append("")
+ archived.append(" ")
+ if old_history.strip() and "none yet" not in old_history.lower():
+ archived.append("")
+ archived.append(old_history.strip())
+
+ history_body = "\n".join(archived).strip() if archived else "_(none yet)_"
+
+ new_text = (
+ text[: m.start()]
+ + m.group(1)
+ + "\n"
+ + latest_new
+ + "\n"
+ + m.group(3)
+ + "\n"
+ + history_body
+ + "\n"
+ + m.group(5)
+ + text[m.end() :]
+ )
+ path.write_text(new_text)
+
+
+def _history_summary(latest_block: str) -> str:
+ first = latest_block.strip().splitlines()[0] if latest_block.strip() else "previous run"
+ # Strip markdown bold
+ return first.replace("**", "").strip()
+
+
+def prompt_write_perf(report: Report, version_runs: int | None, perf_path: Path) -> None:
+ if not sys.stdin.isatty():
+ print("\n(non-interactive — skip PERF.md prompt; pass --write-perf to update)")
+ return
+ try:
+ ans = input("\nAdd this to PERF.md? [y/N] ").strip().lower()
+ except EOFError:
+ return
+ if ans not in ("y", "yes"):
+ print("skipped")
+ return
+ update_perf_md(perf_path, report, version_runs=version_runs)
+ print(f"updated {perf_path}")
+
+
+def parse_args() -> argparse.Namespace:
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ p.add_argument(
+ "--agents",
+ default=",".join(DEFAULT_AGENTS),
+ help=f"comma-separated agents (default: {','.join(DEFAULT_AGENTS)})",
+ )
+ p.add_argument(
+ "--section",
+ choices=("all", "version", "open", "idle"),
+ default="all",
+ help="which section to run",
+ )
+ p.add_argument("--cwd", default=os.getcwd(), help="working directory for TUI launch")
+ p.add_argument("--settle", type=float, default=3.0, help="seconds to settle before idle sample")
+ p.add_argument("--sample", type=float, default=8.0, help="seconds of idle CPU sampling")
+ p.add_argument("--interval", type=float, default=0.25, help="sample interval seconds")
+ p.add_argument("--open-timeout", type=float, default=8.0, help="max wait for first frame")
+ p.add_argument("--open-repeats", type=int, default=3, help="TUI open repeats")
+ p.add_argument("--warmup", type=int, default=5, help="hyperfine warmup runs")
+ p.add_argument("--runs", type=int, default=50, help="hyperfine measured runs")
+ p.add_argument("--json-out", default=None, help="write full report JSON to path")
+ p.add_argument(
+ "--perf-md",
+ default=None,
+ help="PERF.md path (default: repo PERF.md)",
+ )
+ g = p.add_mutually_exclusive_group()
+ g.add_argument(
+ "--write-perf",
+ action="store_true",
+ help="write results into PERF.md without prompting",
+ )
+ g.add_argument(
+ "--no-write-perf",
+ action="store_true",
+ help="skip the Add-to-PERF.md prompt",
+ )
+ return p.parse_args()
+
+
+def main() -> int:
+ args = parse_args()
+ agents = [a.strip() for a in args.agents.split(",") if a.strip()]
+ unknown = [a for a in agents if a not in AGENT_ARGV]
+ if unknown:
+ print(f"unknown agents: {unknown}. known: {list(AGENT_ARGV)}", file=sys.stderr)
+ return 2
+
+ import socket
+
+ report = Report(
+ host=socket.gethostname(),
+ platform=sys.platform,
+ cwd=os.path.abspath(args.cwd),
+ settle_s=args.settle,
+ sample_s=args.sample,
+ sample_interval_s=args.interval,
+ )
+
+ print(f"bench-perf agents={agents} cwd={report.cwd} platform={report.platform}")
+ print()
+
+ do_all = args.section == "all"
+
+ if do_all or args.section == "version":
+ print("A) --version startup (hyperfine)")
+ report.version = bench_version(agents, warmup=args.warmup, runs=args.runs)
+ print()
+
+ if do_all or args.section == "open":
+ print("B) TUI first frame")
+ report.open = bench_open(
+ agents, cwd=report.cwd, timeout=args.open_timeout, repeats=args.open_repeats
+ )
+ print()
+
+ if do_all or args.section == "idle":
+ print(f"C) Idle CPU (settle={args.settle}s, sample={args.sample}s)")
+ report.idle = bench_idle(
+ agents,
+ cwd=report.cwd,
+ open_timeout=args.open_timeout,
+ settle_s=args.settle,
+ sample_s=args.sample,
+ interval_s=args.interval,
+ )
+
+ print_summary(report)
+
+ if args.json_out:
+ Path(args.json_out).write_text(json.dumps(asdict(report), indent=2) + "\n")
+ print(f"wrote {args.json_out}")
+
+ perf_path = Path(args.perf_md) if args.perf_md else default_perf_md()
+ if args.write_perf:
+ update_perf_md(perf_path, report, version_runs=args.runs)
+ print(f"updated {perf_path}")
+ elif not args.no_write_perf:
+ prompt_write_perf(report, version_runs=args.runs, perf_path=perf_path)
+
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/app.rs b/src/app.rs
index c72212b..d9b4428 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -905,6 +905,8 @@ pub struct App {
last_sessions_dialog_metadata_probe: std::time::Instant,
last_frame_size: ratatui::layout::Rect,
last_animation_update: std::time::Instant,
+ /// Last keyboard/mouse/paste (or Home entry). Home blink runs only briefly after this.
+ last_user_activity: std::time::Instant,
last_session_spinner_update: std::time::Instant,
cached_git_branch: Option,
cached_git_branch_path: String,
@@ -918,6 +920,10 @@ pub struct App {
terminal_title_last: Option,
terminal_title_animation_origin: std::time::Instant,
remote_launch_request: Option,
+ /// False until config/prefs/themes/skills hydrate after first paint.
+ startup_hydrated: bool,
+ pending_model_override: Option,
+ pending_cli_agent: Option,
}
/// Cached sum of context tokens for all completed messages of the currently
@@ -961,28 +967,36 @@ impl App {
Self::new_with_model_override(None, None)
}
+ /// Load SQLite session index if needed. Deferred past first TUI paint.
+ pub fn ensure_session_history(&mut self) {
+ let _ = self.session_manager.ensure_history();
+ }
+
pub fn new_with_model_override(
model_override: Option<&str>,
cli_agent: Option<&str>,
) -> Result {
+ Self::new_shell(model_override, cli_agent)
+ }
+
+ /// Minimal App for first paint. Heavy config/prefs/themes/skills load in
+ /// [`Self::ensure_startup_hydrated`].
+ fn new_shell(model_override: Option<&str>, cli_agent: Option<&str>) -> Result {
let mut registry = Registry::new();
register_all_commands(&mut registry);
+ let mut input = Input::new();
let placeholder = Self::get_random_placeholder();
let placeholder_static: &'static str = Box::leak(placeholder.into_boxed_str());
- let mut input = Input::new();
input.set_placeholder(placeholder_static);
+ input.set_image_open_config(crate::config::ImagesConfig::default());
- let cwd_path = crate::utils::cwd::current_dir()?;
- let cwd = cwd_path
- .to_str()
- .map(|s| s.to_string())
- .unwrap_or_else(|| "?".to_string());
+ let mut chat = Chat::new();
+ chat.set_agent_mention_names(Vec::new());
+ let popup = Popup::new();
let home_state = init_home();
- let mut agent = "Build".to_string();
- let mut chat = Chat::new();
- let suggestions_popup_state = init_suggestions_popup(Popup::new());
+ let suggestions_popup_state = init_suggestions_popup(popup);
let agents_dialog_state = init_agents_dialog("Select agent", vec![]);
let models_dialog_state = init_models_dialog("Models", vec![]);
let themes_dialog_state = init_themes_dialog("Themes", vec![], false);
@@ -1003,23 +1017,181 @@ impl App {
let storage_dialog_state = init_storage_dialog();
let title_dialog_state = init_title_dialog();
let api_key_input = crate::ui::components::api_key_input::ApiKeyInput::new();
+ let session_manager = SessionManager::new();
- let session_manager = SessionManager::new()
- .with_history()
- .unwrap_or_else(|_| SessionManager::new());
+ let cwd_path = crate::utils::cwd::current_dir_or_dot();
+ let cwd = cwd_path.display().to_string();
- let prefs_dao = match crate::persistence::PrefsDAO::new() {
- Ok(dao) => Some(dao),
- Err(e) => {
- crate::startup_diag!("Warning: Failed to initialize preferences DAO: {}", e);
- None
- }
+ let (active_model, active_provider_name) = if let Some(model) = model_override {
+ let (provider, model_id) = parse_model_ref(model);
+ (model_id, provider)
+ } else {
+ ("big-pickle".to_string(), "opencode".to_string())
+ };
+ let agent = cli_agent
+ .map(str::trim)
+ .filter(|name| !name.is_empty())
+ .map(titlecase_agent_name)
+ .unwrap_or_else(|| "Build".to_string());
+
+ // Resolve real theme before first paint (avoids builtin flash).
+ let prefs_dao = crate::persistence::PrefsDAO::new().ok();
+ let prefs_theme_id = prefs_dao
+ .as_ref()
+ .and_then(|dao| dao.get_active_theme().ok().flatten());
+ let theme_transparent = prefs_dao
+ .as_ref()
+ .and_then(|dao| dao.get_theme_transparent().ok())
+ .unwrap_or(false);
+ let (themes, current_theme_index, dark_mode, theme_transparent) =
+ crate::config::resolve_startup_theme(
+ &cwd_path,
+ prefs_theme_id.as_deref(),
+ theme_transparent,
+ );
+ let theme_for_colors = themes
+ .get(current_theme_index)
+ .or_else(|| themes.first())
+ .cloned()
+ .unwrap_or_else(theme::Theme::load_builtin_default);
+ let colors = theme_for_colors.get_colors_with(dark_mode, theme_transparent);
+ let chat_state = init_chat(chat, &agent, &colors, true);
+ let session_rename_dialog_state = init_session_rename_dialog(colors);
+ let now = std::time::Instant::now();
+
+ Ok(Self {
+ running: true,
+ version: env!("CARGO_PKG_VERSION").to_string(),
+ input,
+ command_registry: registry,
+ session_manager,
+ home_state,
+ chat_state,
+ suggestions_popup_state,
+ agents_dialog_state,
+ models_dialog_state,
+ themes_dialog_state,
+ themes_dialog_original_theme_index: 0,
+ themes_dialog_original_dark_mode: true,
+ themes_dialog_committed: false,
+ connect_dialog_state,
+ connect_dialog_mode: ConnectDialogMode::ProviderSelection,
+ provider_oauth_flow_state,
+ sessions_dialog_state,
+ move_session_dialog_state,
+ session_rename_dialog_state,
+ permission_dialog_state,
+ question_dialog_state,
+ terminal_session_dialog_state,
+ remote_dialog_state,
+ skills_dialog_state,
+ mcp_dialog_state,
+ command_palette_state,
+ find_bar,
+ storage_dialog_state,
+ title_dialog_state,
+ which_key_state,
+ timeline_dialog_state,
+ esc_primed_at: None,
+ copy_actions_dialog: None,
+ message_actions_index: None,
+ message_actions_dialog: None,
+ message_actions_return_focus: OverlayFocus::TimelineDialog,
+ selection_action_bar: None,
+ pending_chat_message_click: None,
+ api_key_input,
+ provider_oauth_receiver: None,
+ provider_oauth_in_progress: None,
+ compaction_receiver: None,
+ compaction_pending: None,
+ storage_receiver: None,
+ models_receiver: None,
+ models_dialog_provider_ids: None,
+ title_generation_receiver: None,
+ prefs_dao,
+ agent,
+ agent_registry: crate::agent::definition::AgentRegistry::default(),
+ agent_steps: std::collections::HashMap::new(),
+ provider_timeouts: std::collections::HashMap::new(),
+ model: active_model,
+ provider_name: active_provider_name,
+ small_model: None,
+ reasoning_efforts: ReasoningEffortOverrides::new(),
+ model_reasoning_options: ModelReasoningOptions::new(),
+ cwd,
+ base_focus: BaseFocus::Home,
+ overlay_focus: OverlayFocus::None,
+ just_closed_overlay: false,
+ ctrl_c_press_count: 0,
+ last_ctrl_c_time: now,
+ themes,
+ current_theme_index,
+ dark_mode,
+ theme_transparent,
+ sounds: crate::sound::ResolvedSoundsConfig::default(),
+ notifications: crate::config::NotificationsConfig::default(),
+ images: crate::config::ImagesConfig::default(),
+ websearch: crate::config::configuration::WebsearchConfig::default(),
+ mcp: crate::config::configuration::McpConfig::default(),
+ config_raw_merged: serde_json::json!({}),
+ custom_instructions: String::new(),
+ terminal_focused: true,
+ tool_permissions: crate::tools::ToolPermissions::new(cwd_path),
+ skills_dirs: Vec::new(),
+ is_streaming: false,
+ pending_session_title: None,
+ session_view_states: std::collections::HashMap::new(),
+ session_spinner_frame: 0,
+ stream_drain_rotation: 0,
+ sessions_dialog_live_dirty: true,
+ last_sessions_dialog_metadata_probe: now,
+ last_frame_size: ratatui::layout::Rect::default(),
+ last_animation_update: now,
+ last_user_activity: now,
+ last_session_spinner_update: now,
+ cached_git_branch: None,
+ cached_git_branch_path: String::new(),
+ last_git_branch_check: now,
+ discovery: None,
+ cached_usage_text: String::new(),
+ cached_usage_check: (0, 0, 0),
+ cached_usage_streaming_base: None,
+ terminal_title_enabled: crate::notify::terminal_title_supported(),
+ terminal_title_items: crate::terminal_title::default_items(),
+ terminal_title_last: None,
+ terminal_title_animation_origin: now,
+ remote_launch_request: None,
+ startup_hydrated: false,
+ pending_model_override: model_override.map(str::to_string),
+ pending_cli_agent: cli_agent.map(str::to_string),
+ })
+ }
+
+ /// Load config/prefs/themes/skills after first paint (or immediately for remote/CLI).
+ pub fn ensure_startup_hydrated(&mut self) -> Result<()> {
+ if self.startup_hydrated {
+ return Ok(());
+ }
+
+ let model_override = self.pending_model_override.as_deref();
+ let cli_agent = self.pending_cli_agent.as_deref();
+ let cwd_path = crate::utils::cwd::current_dir_or_dot();
+
+ // Prefer prefs already opened in new_shell (avoids double SQLite open).
+ let prefs_dao = match self.prefs_dao.take() {
+ Some(dao) => Some(dao),
+ None => match crate::persistence::PrefsDAO::new() {
+ Ok(dao) => Some(dao),
+ Err(e) => {
+ crate::startup_diag!("Warning: Failed to initialize preferences DAO: {}", e);
+ None
+ }
+ },
};
let loaded_config = crate::config::ConfigLoader::load()?;
let mut mcp_config = loaded_config.merged_config.mcp.clone();
crate::remote_mcp::apply_mcp_overrides(&mut mcp_config, prefs_dao.as_ref());
- // Warm MCP connections in the background so the first chat never waits.
if !mcp_config.is_empty() {
let warm_cfg = mcp_config.clone();
let warm_cwd =
@@ -1028,7 +1200,8 @@ impl App {
let _ = crate::mcp::McpManager::ensure(warm_cfg, warm_cwd);
});
}
- input.set_image_open_config(loaded_config.merged_config.images.clone());
+ self.input
+ .set_image_open_config(loaded_config.merged_config.images.clone());
if !loaded_config.diagnostics.info.is_empty() {
for msg in &loaded_config.diagnostics.info {
crate::startup_diag!("Config: {}", msg);
@@ -1048,11 +1221,13 @@ impl App {
crate::skill::init_skill_store(&loaded_config.xdg_config_home, &loaded_config.project_root);
for command in loaded_config.merged_config.commands.clone() {
- registry.register_custom(command);
+ self.command_registry.register_custom(command);
}
- crate::command::handlers::register_skill_commands(&mut registry);
+ crate::command::handlers::register_skill_commands(&mut self.command_registry);
let agent_registry = loaded_config.merged_config.agent_registry.clone();
- chat.set_agent_mention_names(agent_registry.visible_agent_names_for_mentions());
+ self.chat_state
+ .chat
+ .set_agent_mention_names(agent_registry.visible_agent_names_for_mentions());
let agent_suggestions = agent_registry
.visible_subagents()
.into_iter()
@@ -1063,9 +1238,9 @@ impl App {
)
})
.collect();
- input.autocomplete = Some(
+ self.input.autocomplete = Some(
AutoComplete::new_at_with_file_config(
- crate::autocomplete::CommandAuto::new(®istry),
+ crate::autocomplete::CommandAuto::new(&self.command_registry),
&cwd_path,
loaded_config.merged_config.watcher.is_enabled(),
loaded_config.merged_config.watcher.ignored_paths().to_vec(),
@@ -1073,8 +1248,9 @@ impl App {
.with_agents(agent_suggestions),
);
+ let mut agent = self.agent.clone();
if let Some(default_agent) = loaded_config.merged_config.default_agent.clone() {
- if !default_agent.trim().is_empty() {
+ if !default_agent.trim().is_empty() && self.pending_cli_agent.is_none() {
agent = default_agent;
}
}
@@ -1152,39 +1328,10 @@ impl App {
.map(|prefs| reasoning_effort_overrides_from_prefs(&prefs))
.unwrap_or_default();
- let configured_theme_id = loaded_config.merged_config.theme.as_deref();
- let persisted_theme_id = if configured_theme_id.is_none() {
- prefs_dao
- .as_ref()
- .and_then(|dao| dao.get_active_theme().ok().flatten())
- } else {
- None
- };
- let selected_theme_id = configured_theme_id.or(persisted_theme_id.as_deref());
- let (themes, current_theme_index) = crate::config::discover_themes(
- &loaded_config.xdg_config_home,
- &loaded_config.project_root,
- &loaded_config.cwd,
- selected_theme_id,
- );
+ // Theme already resolved in new_shell for first paint; keep it.
let agent_steps = agent_registry.max_steps_map();
let provider_timeouts = loaded_config.merged_config.provider_timeouts.clone();
- let theme_for_colors = themes
- .get(current_theme_index)
- .or_else(|| themes.first())
- .cloned()
- .unwrap_or_else(theme::Theme::load_builtin_default);
- let theme_transparent = prefs_dao
- .as_ref()
- .and_then(|dao| dao.get_theme_transparent().ok())
- .unwrap_or(false);
- // Align dark_mode with the selected theme's appearance so light themes
- // don't render with dark-mode color slots by default.
- let dark_mode = match theme_for_colors.appearance {
- theme::ThemeAppearance::Light => false,
- theme::ThemeAppearance::Dark => true,
- };
- let colors = theme_for_colors.get_colors_with(dark_mode, theme_transparent);
+ let colors = self.get_current_theme_colors();
let configured_compact_mode = loaded_config.merged_config.tui_compact_mode;
let persisted_compact_mode = if configured_compact_mode.is_none() {
@@ -1197,121 +1344,41 @@ impl App {
let compact_mode = configured_compact_mode
.or(persisted_compact_mode)
.unwrap_or(true);
- let chat_state = init_chat(chat, &agent, &colors, compact_mode);
- let session_rename_dialog_state = init_session_rename_dialog(colors);
+ self.chat_state.compact_mode = compact_mode;
+ let agent_color = crate::theme::agent_color(&agent, &colors);
+ self.chat_state.wave_spinner.set_color(agent_color);
+ self.session_rename_dialog_state.set_colors(colors);
+
let runtime = crate::config::ConfigRuntime::from_merged(
&loaded_config.merged_config,
cwd_path.clone(),
crate::config::ConfigRuntimeOptions::default(),
);
- let tool_permissions = runtime.tool_permissions;
- let discovery = runtime.discovery;
- let custom_instructions = runtime.custom_instructions;
- let now = std::time::Instant::now();
- Ok(Self {
- running: true,
- version: env!("CARGO_PKG_VERSION").to_string(),
- input,
- command_registry: registry,
- session_manager,
- home_state,
- chat_state,
- suggestions_popup_state,
- agents_dialog_state,
- models_dialog_state,
- themes_dialog_state,
- themes_dialog_original_theme_index: 0,
- themes_dialog_original_dark_mode: true,
- themes_dialog_committed: false,
- connect_dialog_state,
- connect_dialog_mode: ConnectDialogMode::ProviderSelection,
- provider_oauth_flow_state,
- sessions_dialog_state,
- move_session_dialog_state,
- session_rename_dialog_state,
- permission_dialog_state,
- question_dialog_state,
- terminal_session_dialog_state,
- remote_dialog_state,
- skills_dialog_state,
- mcp_dialog_state,
- command_palette_state,
- find_bar,
- storage_dialog_state,
- title_dialog_state,
- which_key_state,
- timeline_dialog_state,
- esc_primed_at: None,
- copy_actions_dialog: None,
- message_actions_index: None,
- message_actions_dialog: None,
- message_actions_return_focus: OverlayFocus::TimelineDialog,
- selection_action_bar: None,
- pending_chat_message_click: None,
- api_key_input,
- provider_oauth_receiver: None,
- provider_oauth_in_progress: None,
- compaction_receiver: None,
- compaction_pending: None,
- storage_receiver: None,
- models_receiver: None,
- models_dialog_provider_ids: None,
- title_generation_receiver: None,
- prefs_dao,
- agent,
- agent_registry,
- agent_steps,
- provider_timeouts,
- model: active_model,
- provider_name: active_provider_name,
- small_model,
- reasoning_efforts,
- model_reasoning_options: ModelReasoningOptions::new(),
- cwd: cwd.clone(),
- base_focus: BaseFocus::Home,
- overlay_focus: OverlayFocus::None,
- just_closed_overlay: false,
- ctrl_c_press_count: 0,
- last_ctrl_c_time: std::time::Instant::now(),
- themes,
- current_theme_index,
- dark_mode,
- theme_transparent,
- sounds: resolved_sounds,
- notifications: loaded_config.merged_config.notifications,
- images: loaded_config.merged_config.images,
- websearch: loaded_config.merged_config.websearch,
- mcp: mcp_config.clone(),
- config_raw_merged: loaded_config.raw_merged,
- custom_instructions,
- terminal_focused: true,
- tool_permissions,
- skills_dirs: loaded_config.inventory.opencode_skills_dirs,
- // Note: skills_dirs is legacy; skill loading is now handled by src/skill/mod.rs
- is_streaming: false,
- pending_session_title: None,
- session_view_states: std::collections::HashMap::new(),
- session_spinner_frame: 0,
- stream_drain_rotation: 0,
- sessions_dialog_live_dirty: true,
- last_sessions_dialog_metadata_probe: now,
- last_frame_size: ratatui::layout::Rect::default(),
- last_animation_update: now,
- last_session_spinner_update: now,
- cached_git_branch: None,
- cached_git_branch_path: String::new(),
- last_git_branch_check: now,
- discovery,
- cached_usage_text: String::new(),
- cached_usage_check: (0, 0, 0),
- cached_usage_streaming_base: None,
- terminal_title_enabled: crate::notify::terminal_title_supported(),
- terminal_title_items,
- terminal_title_last: None,
- terminal_title_animation_origin: now,
- remote_launch_request: None,
- })
+ self.prefs_dao = prefs_dao;
+ self.agent = agent;
+ self.agent_registry = agent_registry;
+ self.agent_steps = agent_steps;
+ self.provider_timeouts = provider_timeouts;
+ self.model = active_model;
+ self.provider_name = active_provider_name;
+ self.small_model = small_model;
+ self.reasoning_efforts = reasoning_efforts;
+ self.sounds = resolved_sounds;
+ self.notifications = loaded_config.merged_config.notifications.clone();
+ self.images = loaded_config.merged_config.images.clone();
+ self.websearch = loaded_config.merged_config.websearch.clone();
+ self.mcp = mcp_config;
+ self.config_raw_merged = loaded_config.raw_merged;
+ self.custom_instructions = runtime.custom_instructions;
+ self.tool_permissions = runtime.tool_permissions;
+ self.skills_dirs = loaded_config.inventory.opencode_skills_dirs;
+ self.discovery = runtime.discovery;
+ self.terminal_title_items = terminal_title_items;
+ self.startup_hydrated = true;
+ self.pending_model_override = None;
+ self.pending_cli_agent = None;
+ Ok(())
}
fn play_sound_event(&self, event: crate::sound::SoundEvent) {
@@ -2009,6 +2076,7 @@ impl App {
self.chat_state.chat.clear();
self.input.clear();
self.base_focus = BaseFocus::Home;
+ self.note_user_activity();
self.sync_active_streaming_flag();
self.cached_usage_check = (usize::MAX, u64::MAX, usize::MAX);
self.refresh_sessions_dialog();
@@ -2025,6 +2093,7 @@ impl App {
self.chat_state.chat.clear();
self.input.clear();
self.base_focus = BaseFocus::Home;
+ self.note_user_activity();
self.sync_active_streaming_flag();
self.cached_usage_check = (usize::MAX, u64::MAX, usize::MAX);
self.refresh_sessions_dialog();
@@ -3213,6 +3282,7 @@ impl App {
if key.kind == KeyEventKind::Release {
return;
}
+ self.note_user_activity();
if self.overlay_focus == OverlayFocus::FindBar && !self.can_open_find_bar() {
self.close_find_bar_focus();
@@ -4580,6 +4650,9 @@ impl App {
}
pub fn handle_mouse_event(&mut self, mouse: MouseEvent) {
+ if !matches!(mouse.kind, MouseEventKind::Moved) {
+ self.note_user_activity();
+ }
if std::env::var_os("CRABCODE_MOUSE_TRACE").is_some() {
crate::emit_log!(
"Handle mouse: kind={:?} modifiers={:?} col={} row={} base={:?} overlay={:?}",
@@ -5225,6 +5298,7 @@ impl App {
}
pub fn handle_paste(&mut self, text: String) {
+ self.note_user_activity();
const MAX_PASTE_SIZE: usize = 20 * 1024 * 1024;
if text.len() > MAX_PASTE_SIZE {
@@ -6325,6 +6399,7 @@ impl App {
if parsed.name == "new" || parsed.name == "home" {
self.chat_state.chat.clear();
self.base_focus = BaseFocus::Home;
+ self.note_user_activity();
self.pending_session_title = None;
self.session_manager.clear_current_session();
} else if self.base_focus == BaseFocus::Home
@@ -6560,6 +6635,7 @@ impl App {
if parsed.name == "new" || parsed.name == "home" {
self.chat_state.chat.clear();
self.base_focus = BaseFocus::Home;
+ self.note_user_activity();
self.pending_session_title = None;
self.session_manager.clear_current_session();
} else if self.base_focus == BaseFocus::Home && parsed.name != "refreshmodels" {
@@ -8808,8 +8884,17 @@ impl App {
}
}
+ /// How long the Home cursor blink keeps the ~60fps loop alive after activity.
+ const HOME_ANIM_IDLE: std::time::Duration = std::time::Duration::from_secs(3);
+
+ pub fn note_user_activity(&mut self) {
+ self.last_user_activity = std::time::Instant::now();
+ }
+
pub fn is_animation_running(&self) -> bool {
- self.base_focus == BaseFocus::Home
+ let home_animating = self.base_focus == BaseFocus::Home
+ && self.last_user_activity.elapsed() < Self::HOME_ANIM_IDLE;
+ home_animating
|| self.has_active_selection_edge_scroll()
|| self.is_streaming
|| self.chat_state.chat.has_active_tool_messages()
@@ -10289,6 +10374,7 @@ impl App {
if self.session_manager.get_current_session_id().is_none() {
self.base_focus = BaseFocus::Home;
+ self.note_user_activity();
self.overlay_focus = OverlayFocus::None;
self.pending_session_title = None;
self.input.clear();
@@ -11474,6 +11560,7 @@ mod tests {
last_sessions_dialog_metadata_probe: std::time::Instant::now(),
last_frame_size: ratatui::layout::Rect::default(),
last_animation_update: std::time::Instant::now(),
+ last_user_activity: std::time::Instant::now(),
last_session_spinner_update: std::time::Instant::now(),
cached_git_branch: None,
cached_git_branch_path: ".".to_string(),
@@ -11487,6 +11574,9 @@ mod tests {
terminal_title_last: None,
terminal_title_animation_origin: std::time::Instant::now(),
remote_launch_request: None,
+ startup_hydrated: true,
+ pending_model_override: None,
+ pending_cli_agent: None,
}
}
@@ -12721,6 +12811,20 @@ mod tests {
assert!(!app.is_streaming_animation_only());
}
+ #[test]
+ fn home_animation_freezes_after_idle() {
+ let mut app = test_app();
+ app.base_focus = BaseFocus::Home;
+ app.note_user_activity();
+ assert!(app.is_animation_running());
+
+ app.last_user_activity = std::time::Instant::now() - std::time::Duration::from_secs(4);
+ assert!(
+ !app.is_animation_running(),
+ "Home alone must not pin the 60fps loop after idle"
+ );
+ }
+
#[test]
fn messages_wait_until_streaming_finishes() {
let input_type = parse_input("send another prompt");
diff --git a/src/autocomplete/file.rs b/src/autocomplete/file.rs
index 7a45a12..b633228 100644
--- a/src/autocomplete/file.rs
+++ b/src/autocomplete/file.rs
@@ -13,7 +13,8 @@ use std::time::{Duration, Instant};
const MAX_SUGGESTIONS: usize = 80;
const EVENT_DEBOUNCE: Duration = Duration::from_millis(100);
-const INDEXER_POLL_INTERVAL: Duration = Duration::from_secs(1);
+// Wake rarely when idle; notify events still force an immediate refresh via refresh_tx.
+const INDEXER_POLL_INTERVAL: Duration = Duration::from_secs(30);
const WATCHED_SAFETY_REFRESH_INTERVAL: Duration = Duration::from_secs(5 * 60);
const UNWATCHED_REFRESH_INTERVAL: Duration = Duration::from_secs(2);
diff --git a/src/config/configuration.rs b/src/config/configuration.rs
index 6d10d34..9be27b4 100644
--- a/src/config/configuration.rs
+++ b/src/config/configuration.rs
@@ -11,6 +11,49 @@ use std::path::{Path, PathBuf};
// Re-export json5 for use in load_config_value
use json5;
+/// Cheap theme resolve for first paint: peek config `theme` + prefs, then discover.
+/// Skips full ConfigLoader / skills / agents.
+pub fn resolve_startup_theme(
+ cwd: &Path,
+ prefs_theme_id: Option<&str>,
+ theme_transparent: bool,
+) -> (Vec, usize, bool, bool) {
+ let xdg_config_home = xdg_config_home();
+ let project_root = discover_project_root(cwd);
+ let config_theme_id = peek_config_theme_id(&xdg_config_home, &project_root);
+ let selected = config_theme_id
+ .as_deref()
+ .or(prefs_theme_id)
+ .map(str::trim)
+ .filter(|id| !id.is_empty());
+ let (themes, idx) = discover_themes(&xdg_config_home, &project_root, cwd, selected);
+ let theme = themes
+ .get(idx)
+ .or_else(|| themes.first())
+ .cloned()
+ .unwrap_or_else(crate::theme::Theme::load_builtin_default);
+ let dark_mode = matches!(theme.appearance, crate::theme::ThemeAppearance::Dark);
+ (themes, idx, dark_mode, theme_transparent)
+}
+
+fn peek_config_theme_id(xdg_config_home: &Path, project_root: &Path) -> Option {
+ let sources = resolve_sources(xdg_config_home, project_root).ok()?;
+ let mut theme_id = None;
+ for source in sources {
+ let Ok(value) = load_config_value(&source.path) else {
+ continue;
+ };
+ let filtered = filter_top_level(value, source.kind);
+ if let Some(id) = filtered.get("theme").and_then(|v| v.as_str()) {
+ let id = id.trim();
+ if !id.is_empty() {
+ theme_id = Some(id.to_string());
+ }
+ }
+ }
+ theme_id
+}
+
pub fn discover_themes(
xdg_config_home: &Path,
project_root: &Path,
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 0b01b66..fadb69b 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -14,4 +14,4 @@ pub use configuration::McpLocalConfig;
#[cfg(target_os = "macos")]
pub use configuration::MacosNotificationBackend;
-pub use configuration::discover_themes;
+pub use configuration::resolve_startup_theme;
diff --git a/src/main.rs b/src/main.rs
index a8151c2..588f98d 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -75,10 +75,7 @@ use ratatui::crossterm::{
MouseEventKind, PopKeyboardEnhancementFlags, PushKeyboardEnhancementFlags,
},
execute,
- terminal::{
- disable_raw_mode, enable_raw_mode, supports_keyboard_enhancement, EnterAlternateScreen,
- LeaveAlternateScreen,
- },
+ terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
};
use ratatui::{backend::CrosstermBackend, buffer::Buffer, style::Color, Terminal};
use std::io::{self, IsTerminal, Read, Write};
@@ -894,7 +891,13 @@ async fn main() -> Result<()> {
// Keep herdr authority until this guard drops (normal exit or panic).
let _herdr = crate::herdr::Session::start();
+ let mut session_history_loaded = false;
+
if let Some(ref session_id) = args.session {
+ // --session needs full hydrate + SQLite index before first paint.
+ app.ensure_startup_hydrated()?;
+ app.ensure_session_history();
+ session_history_loaded = true;
if app.session_manager.ensure_session_loaded(session_id) {
app.session_manager.switch_session(session_id);
if let Some(session) = app.session_manager.get_session(session_id) {
@@ -911,7 +914,9 @@ async fn main() -> Result<()> {
enable_raw_mode()?;
let mut stdout = io::stdout();
- let keyboard_enhancement = supports_keyboard_enhancement()?;
+ // Skip blocking supports_keyboard_enhancement() CSI probe (Codex pattern).
+ // Always push flags; terminals that ignore them are fine. Opt out via env.
+ let keyboard_enhancement = std::env::var_os("CRABCODE_DISABLE_KEYBOARD_ENHANCEMENT").is_none();
if keyboard_enhancement {
execute!(
stdout,
@@ -937,7 +942,14 @@ async fn main() -> Result<()> {
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
- let result = run_event_loop(&mut terminal, &mut app).await;
+ let startup_hydrated = args.session.is_some();
+ let result = run_event_loop(
+ &mut terminal,
+ &mut app,
+ session_history_loaded,
+ startup_hydrated,
+ )
+ .await;
let remote_launch_request = app.take_remote_launch_request();
let close_info = {
@@ -1269,12 +1281,14 @@ mod tests {
async fn run_event_loop(
terminal: &mut Terminal>,
app: &mut App,
+ mut session_history_loaded: bool,
+ mut startup_hydrated: bool,
) -> Result<()> {
- // Adaptive poll duration: fast when animations run (home page / streaming),
- // slow otherwise to avoid wasting CPU on unnecessary re-renders.
+ // Adaptive poll: fast for home blink / streaming, park nearly forever when idle.
+ // A short "idle" poll still burns needless redraws/sec; block until input instead.
const FAST_POLL: Duration = Duration::from_millis(16); // ~60fps for interactive animations
const STREAMING_POLL: Duration = Duration::from_millis(40); // 25fps, matches wave spinner
- const SLOW_POLL: Duration = Duration::from_millis(250); // ~4fps idle
+ const IDLE_POLL: Duration = Duration::from_secs(30); // wake only on input / timeout
let mut needs_redraw = true;
let mut last_complete_frame: Option = None;
@@ -1290,7 +1304,7 @@ async fn run_event_loop(
} else if animation_needed {
FAST_POLL
} else {
- SLOW_POLL
+ IDLE_POLL
};
let elapsed_before_poll = loop_start.elapsed();
@@ -1463,6 +1477,18 @@ async fn run_event_loop(
last_full_render_at = std::time::Instant::now();
}
needs_redraw = false;
+
+ // Hydrate config/prefs/themes/skills, then session index, after first paint.
+ if !startup_hydrated {
+ let _ = app.ensure_startup_hydrated();
+ startup_hydrated = true;
+ needs_redraw = true;
+ }
+ if !session_history_loaded {
+ app.ensure_session_history();
+ session_history_loaded = true;
+ needs_redraw = true;
+ }
}
}
Ok(())
diff --git a/src/remote/mod.rs b/src/remote/mod.rs
index 2dd828c..eac8a34 100644
--- a/src/remote/mod.rs
+++ b/src/remote/mod.rs
@@ -689,10 +689,10 @@ pub async fn serve(options: ServeOptions) -> Result<()> {
suggested_alias.clone(),
options.pair_code.clone(),
)?);
- let app = Arc::new(TokioMutex::new(App::new_with_model_override(
- options.model_override.as_deref(),
- None,
- )?));
+ let mut app_inner = App::new_with_model_override(options.model_override.as_deref(), None)?;
+ app_inner.ensure_startup_hydrated()?;
+ app_inner.ensure_session_history();
+ let app = Arc::new(TokioMutex::new(app_inner));
{
let app = app.lock().await;
diff --git a/src/session/manager.rs b/src/session/manager.rs
index 85174ae..47eb694 100644
--- a/src/session/manager.rs
+++ b/src/session/manager.rs
@@ -81,14 +81,32 @@ impl SessionManager {
}
}
- pub fn with_history(self) -> Result {
- self.with_history_for_workspace(crate::utils::cwd::current_dir_or_dot())
+ pub fn with_history(mut self) -> Result {
+ self.ensure_history()?;
+ Ok(self)
}
pub fn with_history_for_workspace(
mut self,
workspace: impl AsRef,
) -> Result {
+ self.ensure_history_for_workspace(workspace)?;
+ Ok(self)
+ }
+
+ /// Load session history if not already loaded. Safe to call repeatedly.
+ /// Deferred past first paint on the interactive TUI path.
+ pub fn ensure_history(&mut self) -> Result<(), SessionError> {
+ self.ensure_history_for_workspace(crate::utils::cwd::current_dir_or_dot())
+ }
+
+ pub fn ensure_history_for_workspace(
+ &mut self,
+ workspace: impl AsRef,
+ ) -> Result<(), SessionError> {
+ if self.history_dao.is_some() {
+ return Ok(());
+ }
let history_dao = HistoryDAO::new_for_workspace(workspace)
.map_err(|e| SessionError::PersistenceError(e.to_string()))?;
self.current_workspace_id = history_dao.current_workspace_id();
@@ -97,7 +115,7 @@ impl SessionManager {
self.refresh_workspace_sort_orders(&history_dao)?;
self.load_sessions_from_db(&history_dao)?;
self.history_dao = Some(history_dao);
- Ok(self)
+ Ok(())
}
fn refresh_workspace_sort_orders(&mut self, dao: &HistoryDAO) -> Result<(), SessionError> {
@@ -327,6 +345,7 @@ impl SessionManager {
parent_id: Option,
make_current: bool,
) -> String {
+ let _ = self.ensure_history();
self.session_counter += 1;
let title = name
.clone()