You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 30e56f1
Browse filesBrowse the repository at this point in the historyBrowse files
Copy file name to clipboardExpand all lines: DESIGN.md
+12-39Lines changed: 12 additions & 39 deletions
Display the source diff
Display the rich diff
Original file line number
Diff line number
Diff line change
@@ -889,44 +889,17 @@ were refuted or accepted as trade-offs.
889
889
890
890
## 20. Round 11: terminal CPU in long agent sessions
891
891
892
-
Report: CPU stays high while a terminal runs a long Claude Code session. Measured first, headless
893
-
on JavaScriptCore (the engine of the Mac app's WebView), with real Claude Code 2.1.316 output
894
-
captured under codeviewer's PTY environment and synthetic TUI streams.
895
-
896
-
- Claude Code itself sends little: its fullscreen renderer (the default here) writes cell diffs
897
-
(about 40 bytes a keystroke, nothing when idle); the classic renderer prints the transcript once
898
-
on resume (185 KB) and then small diffs, with no full reprints on typing or resize. The server
899
-
side (replay ring, mode tracking, flush) costs 0.2 ms even for 1.2 MB.
900
-
- The client did more work than the output needed. A redraw of unchanged rows (TUIs erase and
901
-
reprint their region every tick) repainted every touched row: 12 rows for a 12-line status
902
-
region, all 60 for a full-screen repaint, where one row had changed. Background tabs and a
903
-
closed panel painted every frame too. The scrollbar read `clientHeight` right after writing
904
-
styles, forcing a layout per frame. Parsing spent 30% in per-character `print()` and 21% in
905
-
`Line.toString()` for the screen-reader log, which stringified every finished line although it
906
-
keeps 200.
892
+
Agent TUIs redraw constantly, but Claude Code sends little (its fullscreen renderer writes cell
893
+
diffs; the classic one prints the transcript once, then diffs). The cost was in the client doing
894
+
more work than the output needed. The terminal now follows these rules:
907
895
908
-
| Finding | Fix |
909
-
| --- | --- |
910
-
| Dirty rows meant "touched", not "changed": constant TUI redraws repainted identical rows. | The renderer keeps what each viewport row last showed (cells plus a key for cursor, selection, link hover and reverse video) and skips rows that match. Spinner tick: 12 → 1 rows; full-screen repaint: 60 → 1.6. `invalidate()` (theme, font, resize) still repaints everything. |
911
-
| Hidden terminals (other tabs, closed panel) painted every frame. |`Renderer.setVisible`: no frames while hidden, one full repaint when shown. `TerminalPanel.activate`/`setShown` drive it; output is still parsed, so the screen is current when shown. Hidden terminals also skip the screen-reader log (a hidden live region is not announced). |
912
-
| Per-character `print()` dominated parsing. |`printAscii` copies runs of printable ASCII straight into the line; wrap, insert mode, DEC graphics and ZWJ joins still go through `print()`. A seeded fuzz test compares it with per-character printing (screen, history, cursor, dirty rows). |
913
-
| Every finished line was stringified for the screen-reader log, then all but 200 dropped. | Completed lines are queued and stringified at the end of the write, the newest `announceLimit` (200) only; a line redrawn within one write is reported once, as it finally reads. A queued line that scrolls into history follows its compact copy; one reused inside a margin region is captured first. `toString` joins only up to the high-water mark. |
914
-
| (Review) With unchanged rows never repainted, a terminal face that loads after the 2 s startup wait would leave idle rows in the fallback font (the next redraw used to heal them). | When the wait times out, the terminals re-measure and repaint once the faces arrive. Only then: listening to every font load would also resize running programs when a UI face loads. |
915
-
| (Review) Panel and tab visibility had no test. |`client.termview.test.ts` covers create while closed, open, switching tabs and closing the panel. |
916
-
|`updateScrollbar` forced a synchronous layout every frame. | The track height is read after a resize or show/hide only, and styles are written only when they change. |
917
-
918
-
PR review (eight findings):
919
-
920
-
| Finding | Outcome |
896
+
| Rule | Where |
921
897
| --- | --- |
922
-
| A canvas whose backing store is discarded (GPU reset) would stay blank: identical cells are skipped. | Fixed: `contextrestored` resets the context state and invalidates. Test added. |
923
-
| History lines from `compactCopy` lacked `spoken`/`ann`, so scrollback mixed object shapes. | Fixed: same fields in constructor order. |
924
-
| Snapshots cost time when every row changes (streaming). | Measured: 0.239 vs 0.219 ms per frame with a no-op canvas, against ~180 real `fillText` calls a frame. Kept. |
925
-
| A late-font re-measure resizes the PTY even at the same grid size, sending SIGWINCH. | Refuted: the kernel signals only on a real size change (checked with a `trap WINCH` shell). |
926
-
| A glyph overflowing its row is no longer healed by full redraws. | Kept: partial repaints already clipped such overflow before this change; at line height 1.2 the baseline leaves room for JetBrains Mono's descenders and accents. |
927
-
| A line erased within the same write is no longer reported. | Intended: the screen reader gets what the screen shows; a line overwritten before any frame was never visible either. |
928
-
|`printAscii` duplicates `print()` bookkeeping; `asciiRuns` is a test switch on a hot path. | Kept: the seeded fuzz test pins equivalence (mutation-checked); the switch is read once per run, not per character. |
929
-
930
-
Result on the same benchmarks: parse of an 8,000-line reprint 41 → 21 ms, a real classic-renderer
931
-
resume 4.5 → 2.6 ms, plain text about 24 MB/s; idle and spinner frames repaint only what changed.
932
-
Not changed: the scrollback copy per scrolled line (about 4%), and the server, which was not a cost.
898
+
| Paint only rows whose pixels change: each viewport row keeps a snapshot of its cells plus a key (cursor, selection, link hover, reverse video). New pixel state belongs in the key, or calls `invalidate()`; theme, font, resize and a restored canvas context invalidate. |`Renderer.rowKey`, `unchanged`, `remember`|
899
+
| Hidden terminals (other tabs, closed panel) parse but neither paint nor feed the screen-reader log; showing one repaints it in full. |`Renderer.setVisible`, `TerminalPanel.activate`/`setShown`|
900
+
| Printable ASCII is copied into the line in runs; anything with per-character rules goes through `print()`. A seeded fuzz test keeps both paths identical. |`Terminal.printAscii`|
901
+
| Completed lines are stringified at the end of a write, the newest 200 only; a line redrawn within one write is reported once, as it finally reads. |`Terminal.announce`, `flushAnnounced`|
902
+
| No layout reads per frame: the scrollbar measures its track after a resize only and writes styles only on change. |`TerminalView.updateScrollbar`|
903
+
904
+
Measured on JavaScriptCore: parsing an 8,000-line reprint went from 41 to 21 ms, a spinner tick
905
+
repaints one row instead of the whole status region, and plain text parses at about 24 MB/s.
0 commit comments