From 1312287f1b209b62af4368eb6618983069e19e8d Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 26 Aug 2026 01:22:21 +0800 Subject: [PATCH 1/2] fix(idle-cpu): stop home-screen animation loop after 3s of inactivity (#28) The Home cursor blink was pinning a ~60fps render loop indefinitely, keeping the event loop polling at 16ms even when idle. This caused noticeable CPU usage compared to peer CLIs. Changes: - Track `last_user_activity` timestamp; only animate Home for 3s after activity - Raise idle poll from 250ms to 30s so the event loop parks when truly idle - Increase autocomplete indexer poll interval from 1s to 30s - Add `bench-perf.py` script and `PERF.md` to measure startup / idle-CPU vs peers - Add regression test `home_animation_freezes_after_idle` --- .gitignore | 4 + PERF.md | 229 ++++++++ README.md | 2 + justfile | 16 + scripts/bench-perf.py | 1072 ++++++++++++++++++++++++++++++++++++++ src/app.rs | 39 +- src/autocomplete/file.rs | 3 +- src/main.rs | 8 +- 8 files changed, 1367 insertions(+), 6 deletions(-) create mode 100644 PERF.md create mode 100644 scripts/bench-perf.py 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..dcceb34 --- /dev/null +++ b/PERF.md @@ -0,0 +1,229 @@ +# 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** | **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 +``` + +
+ +--- + +## 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** | **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/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..e8332d5 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, @@ -1298,6 +1300,7 @@ impl App { 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(), @@ -2009,6 +2012,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 +2029,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 +3218,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 +4586,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 +5234,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 +6335,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 +6571,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 +8820,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 +10310,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 +11496,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(), @@ -12721,6 +12744,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/main.rs b/src/main.rs index a8151c2..f6ee75c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1270,11 +1270,11 @@ async fn run_event_loop( terminal: &mut Terminal>, app: &mut App, ) -> 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 +1290,7 @@ async fn run_event_loop( } else if animation_needed { FAST_POLL } else { - SLOW_POLL + IDLE_POLL }; let elapsed_before_poll = loop_start.elapsed(); From 2ad67c3c7b70f2257e03de94325cc61cce4ddcae Mon Sep 17 00:00:00 2001 From: Blankeos Date: Wed, 26 Aug 2026 03:11:02 +0800 Subject: [PATCH 2/2] perf: improve tui-paint-speed and reduce idle CPU - Split `App::new` into minimal `new_shell` + deferred `ensure_startup_hydrated` for faster first paint (~50ms saved) - Skip blocking `supports_keyboard_enhancement()` CSI probe (always push flags; opt out via `CRABCODE_DISABLE_KEYBOARD_ENHANCEMENT`) - Defer `SessionManager` SQLite history load until after first draw - Reduce idle poll from 250ms to 30s to fix idle CPU peg (#28) - Resolve theme before first paint to avoid builtin flash - Increase autocomplete indexer poll interval from 1s to 30s - Add `bench-perf.py` script and `just bench-perf` recipe for startup/idle-CPU benchmarking vs peer CLIs - Add `PERF.md` with comparative benchmarks (hyperfine + PTY) against codex, grok, opencode --- PERF.md | 138 ++++++++++++- _plans/FIRST_PAINT.md | 32 +++ src/app.rs | 401 +++++++++++++++++++++--------------- src/config/configuration.rs | 43 ++++ src/config/mod.rs | 2 +- src/main.rs | 38 +++- src/remote/mod.rs | 8 +- src/session/manager.rs | 25 ++- 8 files changed, 503 insertions(+), 184 deletions(-) create mode 100644 _plans/FIRST_PAINT.md diff --git a/PERF.md b/PERF.md index dcceb34..a17f285 100644 --- a/PERF.md +++ b/PERF.md @@ -27,6 +27,140 @@ 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 | @@ -83,9 +217,7 @@ C) Idle CPU (settle=3s, sample=8s)
---- - -## History +
2026-08-26 · darwin · `Carlos-MacBook-Pro.local` · cwd = repo 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/src/app.rs b/src/app.rs index e8332d5..d9b4428 100644 --- a/src/app.rs +++ b/src/app.rs @@ -920,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 @@ -963,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); @@ -1005,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 = @@ -1030,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); @@ -1050,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() @@ -1065,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(), @@ -1075,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; } } @@ -1154,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() { @@ -1199,122 +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_user_activity: 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) { @@ -11510,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, } } 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 f6ee75c..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,6 +1281,8 @@ 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: fast for home blink / streaming, park nearly forever when idle. // A short "idle" poll still burns needless redraws/sec; block until input instead. @@ -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()