Every key paints within a frame: typing echoes in 2 ms, session switches are instant, and git-backed views never freeze the UI - #84
Merged
Conversation
…ssion switches are instant, and git-backed views never freeze the UI — measured, with memory flat - The INPUT LATENCY PROBE (`NEBULA_PERF_LOG`) and `make perf`: the real TUI driven through 123 scripted steps in a private tmux, printing per step how long the key's handler held the loop, how long the key waited for its frame, how long the screen took to settle, the echo time in a locked pane, and the peak RSS of the TUI and the DAEMON. `PERF_REPO=` measures against a large checkout; `report.py BEFORE AFTER` compares two runs. - FRAME PACING is a token bucket, not a 16 ms tick: a key's frame and its answer's follow 2 ms apart, sustained PTY output still paints at 60 fps. A key that only goes to the PTY paints no frame of its own, and the DAEMON flushes PTY output that breaks a silence at once instead of holding it 5 ms. Echo in a locked pane: 19.8 → 2.3 ms release, 24.9 → 3.5 ms dev. - BACKGROUND READS (`view_jobs.rs`): the DIFF VIEWER, the FILE FINDER, its grep view, the TREE BROWSER and the FILE TABS open on the keypress and fill in when git and the disk answer; what is typed meanwhile is kept. `git grep` is debounced, streamed and killed at the result cap. `open`, `pbcopy` and the footer's `ps` no longer run on the loop. On a ten-thousand-file checkout `g` went from 262 ms of frozen UI to a list in 1.4 ms, and grep from 370 ms frozen per keystroke to none. - The DIFF VIEWER opens on the changed-files badge's last `git status`, reads the row after the cursor ahead and keeps up to 2 MB of diffs while it is open; only the visible lines are styled. - OPTIMISTIC UPDATES (`event_loop/optimistic.rs`): rename, archive, unarchive, delete and close-terminal change the row on the keypress by way of the event the DAEMON is about to broadcast; an Error puts it back. - The pane's screen cache keeps six screens inside half the memory two used to be allowed: a screen over budget lets its scrollback go, and scrolling up replays the ring once. Session, worktree and workspace switches settle in 1.3–1.7 ms where they took 19–22. - `nebula-fuzzy`: the list filters' matcher in a crate of its own so dev builds optimise it, without per-candidate allocations — 27 → 5 ms a keystroke over ten thousand paths in the build `make dev` runs. - `SendCloudMessage` runs off the DAEMON's request loop. Two e2e flows follow the NEW SESSION PICKER into its task box (they failed on main). The shot harness can slow any git subcommand, with scenes for the two new loading states. Peak RSS over the scenario: TUI 55.6 → 54.0 MB, DAEMON 21.7 → 19.7 MB. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
`NEBULA_PERF_LOG` times keys; it does not record them. A plain or shifted character is `key:char` in the log — it is as likely typed at an agent, a shell or a password prompt as at a panel, the rule the KEY COMBO DISPLAY already keeps — while chords and named keys (`key:C-q`, `key:Enter`) still say which command they were. The report reads step labels from the driver, so nothing it prints changes. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
v0.30.0 folded the DIFF VIEWER's file list into a directory tree and gave rows a one-shot sweep; this branch moved every git read off the event loop. They met in the same five files. How the two were stitched: - The tree is one more shape of the same list, so the BACKGROUND READS ask it, not the flat list, what the cursor is on: `load_selected_diff` shows a directory row's summary instead of waiting on a diff, the read-ahead walks to the next file row (`DiffView::file_after_cursor`), and `fill_view` folds the fresh listing into the tree the reader had up — keeping their folds — before homing the cursor on the first unreviewed file, in either list (`DiffView::at_home`). - `g` opens in the shape the reader left the modal in: the optimistic open on the changed-files badge builds the tree before the `git status` it opened ahead of lands. - `restore_reviewed_marks` is gone: checking the stored marks is a diff per marked file, and that now happens inside `read_listing`, off the loop. - The tree browser's row walk is main's shared `visible_rows` (the diff tree folds through it as well), with this branch's one reusable `fuzzy::Matcher` for the whole walk rather than one per path. - The screen cache keeps six sessions bounded by a shared cell budget (this branch), alongside main's ONE_SHOT_SWEEP. make ci is green. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every action in the TUI was timed against the real app, and everything that kept a key from painting inside a frame was fixed: a character typed at an agent echoes in 2.3 ms where it took 19.8, no scripted step waits more than 6 ms for its frame (12 of 123 used to wait over 16), and on a ten-thousand-file checkout
gno longer freezes the UI for a quarter of a second. Peak memory is flat to slightly lower — the pane's screen cache got faster by holding less.Contents: 📊 Scorecard · ✨ What changed · 📸 Screenshots · 🧭 Where the time goes ·⚠️ Risk · 🔧 Technical overview · 📝 Notes
📊 Scorecard
Release build, this repository as the checkout, a stand-in agent with a full 1 MB ring. Paint is key → the frame that shows it; settle is key → the screen in its final state; frozen is how long the key's handler held the whole event loop.
make perf BIN=target/release/nebulaj/kor click (settle)]attention jump · workspace switch (settle)gDIFF VIEWER: frozen → paintgDIFF VIEWER: first diff on screen↓to the next file's difff·bopen (paint; the list lands at ~13 ms as before)Fgrep, per character typedShift+Grepo pageps -o rss, sampled 2×/s by the harnessThe same run against a clone of a ten-thousand-file checkout, in the dev build
make devruns — where git has real work to do and this crate is unoptimised:gDIFF VIEWERPERF_REPO=… make perf↓to the next file's difff·bopenFgrep, per character typedf·b, per charactercargo test -p nebula-tui -p nebula-fuzzycargo test -p nebula-daemone2e_tuimake ci— the two failed on untouchedmaintoo, see Notesgit diff --stat origin/mainOne number that reads worse and is not: grep's results now land ~66 ms after the last character (a 40 ms debounce, then git), where they used to be on screen with each character — because each character froze the UI until they were. A nine-letter word was nine freezes.
✨ What changed
g,f,bandFopen on the keypress and never freeze the UI.git status/git ls-filesanswer; what you type meanwhile is kept and applied.gopens on the list the changed-files badge already has, so the first diff is being read while the list is checked.↓in the DIFF VIEWER finds the next diff already read; up to 2 MB of diffs are kept while the modal is open and freed when it closes.git grepwaits 40 ms for the next character, streams, and is killed at 200 hits or when the query moves on.Shift+G), a Ghostty tab (Shift+C), and copying a selection start their process and move on.pson the UI loop every five seconds.nebula openread and highlight their files off the loop.make perf.NEBULA_PERF_LOG=<file>turns on the INPUT LATENCY PROBE; off, it costs oneOptioncheck per event. It never records a typed character.PERF_REPO=<path>measures against a large checkout;python3 scripts/perf/report.py BEFORE AFTERcompares two runs.📸 Screenshots
The new states are transient by design, so both shots hold git still for a few seconds (
NEBULA_SHOT_SLOW_GIT_MATCH, a new knob on the SCREENSHOT HARNESS) — this is what a coldgiton a large checkout shows for a moment.f: the FILE FINDER is up and has takenreadbeforegit ls-fileshas answeredg: the DIFF VIEWER ahead of a coldgit statusAnd what
make perfprints — the top of the release run the scorecard is taken from:🧭 Where the time goes
flowchart LR subgraph echo["A character typed in a LOCKED PANE · 19.8 ms, now 2.3 ms"] K([key]) -->|"its own frame · was 1 ms plus a 16 ms cap behind it, now no frame"| I["Input to the DAEMON"] I -->|"PTY write and echo · unchanged, under 1 ms"| P["pty pump"] P -->|"coalescing hold · was 5 ms, now 0 after a quiet spell"| O["Output to the TUI"] O -->|"wait for the frame cap · was up to 16 ms, now a 2 ms gap"| F([echo painted]) end subgraph diff["g on a ten-thousand-file checkout · 262 ms frozen, now 1.4 ms"] G([key]) -->|"was git status, rev-parse and git diff inline"| H["handler · 0.1 ms"] H -->|"opens on the badge's file list"| M([modal painted · 1.4 ms]) H -.->|"BACKGROUND READ"| B["blocking pool: git diff, then git status"] B -.->|"ticketed answer"| L["land_view_answer"] L --> C([first diff · 59 ms]) end classDef faster fill:#bbf7d0,stroke:#166534,color:#111 classDef gone fill:#fecaca,stroke:#991b1b,color:#111 class P,O,H faster class B goneRed is work that left the event loop; green is a stage that got shorter.
🎯 Attack surface
NEBULA_PERF_LOG— the TUI creates and truncates the file it names. Reached by whoever controls the TUI's environment, who can already run anything as the user. Held by: it is read once at start, writes with the user's own permissions, and logs timings, overlay and FOCUS names, session ids, and key classes — a plain or shifted character is alwayskey:char, under test. Not held: a chord is named (key:C-q), and a run left on by accident grows a file until the TUI quits.ClientRequest, route, exec or file.SendCloudMessageruns the sameclaudeinvocation it did, on its own task instead of the connection's.git_diff::git_command(GIT_OPTIONAL_LOCKS=0); what changed is the thread it runs on. The streaming grep passes the query as an-eargument exactly as the inline one did.Verdict: 🟡 Merge with care — no new surface and the gate is green, but it moves the frame loop, the PTY byte path and the vendored terminal emulator's scrollback at once, and a regression in any of those is felt on every keystroke.
issues_tx,branch_switch.tx), and optimistic rows reusehandle_server_eventrather than a second code path; the departures are a fifth workspace crate that exists for a dev-profile override, and two accessors added to the vendoredvt100Rollback:
git revertof the merge undoes all of it — no PROTOCOL VERSION bump, no store migration, no config key. What it does not undo: a running DAEMON keeps the new flush behaviour until it is restarted, and the two images stay onpr-assets.🔧 Technical overview
view_jobs::Jobshandle runs its git or disk read on the blocking pool and the main loop lands the answer by ticket (land_view_answer), dropping any nobody is waiting for; a view built without a handle — every unit test — reads inline through the same parsers. While a read is out the pane keeps what it showed for 60 ms, then saysloading…. Optimistic verbs handhandle_server_eventthe very upsert or removal the DAEMON is about to send, with the old row riding aPendingIntent::Undo.crates/nebula-tui/src/event_loop/pacing.rs— the token bucket (BURST3,MIN_GAP2 ms, one token per 16 ms, never sooner than the draw itself took).crates/nebula-tui/src/view_jobs.rs—Jobs,Answer, tickets,Cancel, the stale grace.crates/nebula-tui/src/git_diff.rs—read_listing,fill_view(reconciles the badge's list with the fresh one, reader kept in place), the cache and read-ahead inload_selected_diff/land_diff.crates/nebula-tui/src/event_loop/optimistic.rs— the five verbs,App::deletingtombstones,undo/settled.crates/nebula-tui/src/app.rs—stash_term(six screens, one 400 k-cell budget, histories go before screens),AttachedTerm::drop_history,apply_outputskipping bytes a replay covered.crates/nebula-tui/src/event_loop.rs—typing_into_pane,rehydrate_history,keep_changed_files,spawn_and_reap, the landing functions.crates/nebula-daemon/src/pty/mod.rs—flush_deadline: the hold counts from the last flush, not the first pending byte.crates/nebula-fuzzy/— the matcher, moved whole, plus a reusableMatcherwith an allocation-free reject pass;[profile.dev.package.nebula-fuzzy] opt-level = 3.vendor/vt100/src/{grid,screen}.rs—scrollback_rows()andclear_scrollback(), marked NEBULA PATCH.crates/nebula-tui/src/perf.rs,scripts/perf/— the probe and the harness.scripts/perf/run.shdriving the binary in a private tmux at 190×50 against an isolated DAEMON, one run per build per side; the baseline ismainplus only the probe, built in its own target dir. Inputs and frames are stamped with the wall clock (macOSInstantdrifts ~1.5 ms/s against it). Single runs — the git-bound rows moved ±30 % between runs on the large checkout; the frozen-versus-not rows did not.make cigreen on the first commit: fmt, clippy, every suite — 947 TUI and matcher tests, 280 DAEMON,e2e_pty31,e2e_tui11. The second commit (the probe's redaction, +2 tests) rancargo fmt --check, clippy onnebula-tuiand the TUI unit tests, not the e2e suites again.📝 Notes
origin/main(12a31cc, v0.29.0); no conflicts.make cycle): the flush change lives there. Everything else is the TUI's and applies on its next launch. No PROTOCOL VERSION bump — a new TUI works against an old DAEMON, only without that one gain.e2e_tuiflows (nebula_open_from_inside_a_session_raises_the_file_tabs,tui_projects_worktrees_agents_navigation) were failing on untouchedmain: they waited forNew sessionto disappear, and the NEW SESSION PICKER now ends in a task box with the same title. They follow the box by its hint line now, which also puts the async FILE TABS preview under an end-to-end test.target/perf/<stamp>.scripts/shot/shot.shstill leaves$TMPDIR/nebula-shot/<pid>behind — not touched here.🤖 Generated with Claude Code