Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/adrs/045.client.smooth-mouse-wheel.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ In detail:
- **The glide ignores its own line moves.** Rounding the offset to device pixels can put the viewport on the next line while the position is just below it; ghostty-web's `onScroll` then reported a line other than `floor(position)`, and the glide took it for an outside move and stopped, losing the rest of its distance (a 100 px notch stopped at 75 px in the end-to-end test).
- The glide moves the same fractional position as the trackpad, so it renders through the overscan canvas (ADR 043) and only copies rows when it crosses lines.
- A trackpad delta finishes the glide at its target and applies there. Output snapping to the bottom, keyboard scrolling, a scrollbar drag or a resize stops the glide where ghostty-web put the viewport.
- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, or follows the same spring while the stream is fast (ticks per frame of a hard push went from 4–13 to 9–10), a few per frame, starting from its latest frame like the glide. The ticks go out evenly whatever the pace's easing, as an app moves in whole lines, and the count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `WheelStream.forApp` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks.
- **Pace the ticks sent to apps.** Mouse-tracking apps and the alternate screen still get whole-line ticks (SGR scroll buttons, ADR 040, or arrow keys), which they scroll themselves, and the tick counts are unchanged. But they no longer go out in one burst, as the scrollback glides: `TickPacer` sends a wheel notch's ticks evenly over 125 ms and a sparse delta's over its gap plus a frame, or follows the same spring while the stream is fast (ticks per frame of a hard push went from 4–13 to 9–10), a few per frame, starting from its latest frame like the glide. The ticks go out evenly whatever the pace's easing, as an app moves in whole lines, and the count is rounded up, so the first tick goes out on the next frame and a one-tick notch is not held back. `WheelStream.forApp` makes the call with the same classifier; trackpad deltas, which already come every frame, still send their ticks at once, and a zero-spread event flushes any queued ticks first. A direction change drops the queued ticks. (Update: [ADR 069](069.client.scroll-ticks-wait-for-the-app.md) holds the next ticks until the app answers and sends those due meanwhile together, at most 12 a go to an app slower than 40 ms.)
- **Count arrow keys by distance.** On the alternate screen without mouse tracking (`less`, `man`), the vendored terminal sent `round(deltaY / 33)` arrow keys per event, at most 5, with no remainder kept: slow trackpad deltas and Firefox's 3-line notches sent nothing, and a hard TrackPoint push was capped at 5 lines. `wheelArrows` now sends one per line of travel (pixel deltas by cell height, line deltas by line, page deltas by rows), keeping the remainder like `wheelTicks`. With application cursor keys (DECCKM), which `less` sets, they are `ESC O A`/`ESC O B`; `less` ignored `ESC [ A`/`ESC [ B`, so the wheel did nothing in it.
- **Respect reduced motion.** With `prefers-reduced-motion: reduce`, wheel and sparse deltas scroll the scrollback at once instead of gliding, and a wheel notch's ticks go to apps at once. A sparse stream's ticks stay paced: pacing times input, it does not animate, and sending them at once brought back bursts of 20 ticks every other frame.

Expand Down
48 changes: 48 additions & 0 deletions docs/adrs/069.client.scroll-ticks-wait-for-the-app.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
# ADR 069: client — Scroll ticks wait for the app's answer

**SPEC:** [client](../specs/client.md)
**Status:** Accepted
**Date:** 2026-10-15

---

## Context

Scrolling Claude Code's fullscreen mode inside herdr lagged and then jumped a long way, while Claude Code on its own, pi on its own and pi inside herdr scrolled smoothly. With a TrackPoint, Claude Code inside herdr moved continuously but in uneven jumps. 1.15 lagged the same way. Measured on a real herdr session (several Claude Code panes, a 204 × 109 pane at Retina scale):

- **webtty was idle.** A Chrome performance trace of the lag showed the page's main thread busy 5% of the time, one frame over 16 ms in 12 s, and stretches of 2.5 s in which 43 scroll ticks went out and no output came back.
- **The server passed everything on at once.** A trace of the server's input and output (1,186 ticks in about 25 s) found output flow control ([ADR 048](048.webtty.output-flow-control.md)) never blocked, at most 78 KB unacknowledged against its 2 MB limit, and every PTY chunk sent as it came. From a tick reaching the PTY to herdr's next output: 104 ms at the median, 1.0 s at the 90th percentile, 3.7 s at worst.
- **herdr redraws its whole screen for every tick.** Each tick came back as one synchronized update (DEC mode 2026) of about 35 KB, starting 30–45 ms after the tick and arriving in about 35 chunks over 8 ms, however little of the screen changed. Claude Code renders its pane, then herdr its screen.
- **Paced ticks doubled the work.** [ADR 045](045.client.smooth-mouse-wheel.md) spreads an event's ticks over frames, about one a frame, so each reached herdr and Claude Code alone and each was redrawn. Replaying a fast scroll (about 45 ticks a second) on that session at its size, paced ticks brought back 967–1,182 KB of redraws against 490–568 KB for the same ticks sent at once. Where the app needs 100 ms or more a redraw, ticks a frame apart queue up in it, and when it catches up it applies them together: the jump.
- **A TrackPoint is far faster than its old capture.** A capture on the machine in question: light pushes of 4 px every 130–350 ms, hard ones of 300–370 px every 15–60 ms, the hardest 1,150 px every 30 ms, about 2,700 lines a second. Inside herdr, Claude Code shows about a dozen updates a second, so a hard push has to move 24 lines an update.

No terminal setting changes herdr's frame time; herdr has none.

## Decision

- **Ticks wait for the app's answer.** `TickPacer` (`wheel.ts`) remembers when ticks last went out. Until the app answers, it sends no more: the ticks that come due meanwhile, from the pace's glide or spring or at once, are sent together when it does. An app that answers within a frame (pi, vim, Claude Code on its own) gets its ticks as before.
- **The answer is output that leaves no synchronized update open.** The write pipeline's `wroteOutput` tells the wheel controller (`WheelController.answered`) after each write, unless the engine still has mode 2026 on (`SyncHold.inUpdate`). herdr's frame is a single update in 35 chunks, and the next ticks wait for its end rather than its first chunk. Output for other reasons (another pane, a spinner) also counts, which at worst lets ticks go a little early, as before this change.
- **An app that does not answer gets the next ticks after `ANSWER_WAIT_MS` (100 ms)**, so an app that redraws nothing for a tick (at its top already) is not held.
- **A slow app gets at most `SLOW_GO_TICKS` (12) ticks a go.** The pacer keeps a running average of the time the app takes to answer (at most `ANSWER_WAIT_MS` a sample). While it is over `SLOW_ANSWER_MS` (40 ms), a go sends 12 ticks at most, keeps one more go queued and drops the rest, so updates move evenly and the scroll stops when the push does. A fast app is never capped.
- The arrow keys sent to an alternate-screen app without mouse tracking ([ADR 045](045.client.smooth-mouse-wheel.md)) go through the same pacer and wait the same way.

## Considered Options

- **Send ticks at once, as 1.15 did.** Rejected: it lagged the same way, and pi and vim lose ADR 045's even pace.
- **Answer on the first output after ticks.** The first version. Equal in every measure here, but it let ticks reach herdr in the middle of its frame; the end of the update is when the app has finished.
- **Throttle on the server (hold PTY input until output).** Rejected: the server cannot tell scroll ticks from typing, and the client already knows its pace.
- **Even out the go sizes without a cap.** Rejected: a hard push inside herdr still moved about 24 lines an update, only more steadily.
- **Cap every app.** Rejected: replaying the TrackPoint capture with an app answering in 10–25 ms, a 12-tick cap cut a hard push to 59% of its distance where nothing was wrong.
- **Slow the TrackPoint down everywhere.** Rejected: it scrolls well in pi and Claude Code on their own.

## Consequences

- On the herdr session above at its size, replaying the fast scroll: 31–42 ms from tick to output at the 90th percentile and 45–48 ms at worst, against 50–125 ms and 67–212 ms with paced ticks, and 48–79 ms and 49–116 ms for 1.15.
- Replaying the TrackPoint capture through the wheel code against an app answering in 35–100 ms (Claude Code inside herdr): every update during the hard push moves 12 lines, against a median of 24 and up to 190 before, and the push scrolls 28% as far. Against an app answering in 10–25 ms, nothing changes: the same 125 updates and 1,388 lines. A touchpad capture (1–5 px every 16 ms) never reaches the cap and is unchanged.
- Claude Code inside herdr still follows a scroll at herdr's pace, about one update every 30–45 ms here, slower than on its own: herdr redraws its whole screen for each tick, which only herdr can change.
- An app that answers slowly for other reasons, such as one over a slow link, is capped too while its average stays over 40 ms.
- Scroll benchmark ([SPEC](../specs/scroll-benchmark.md)), 2026-10-15, headless Chrome, the build before this change (before) against this one (after), medians of two runs with a trackpad, a wheel and a TrackPoint, plus a touchpad capture (1–5 px every 16 ms) on the fullscreen and herdr scenarios:
- **Unchanged:** pi fullscreen and classic, Claude Code classic, vim, nvim, yazi, the shell (`seq`), the stream test and pi inside herdr, with every input; the touchpad capture everywhere; no blinks.
- **Claude Code inside herdr:** with a wheel, 28.5 → 35.9 paints a second and 50 → 33 ms between paints at the 90th percentile; with a TrackPoint, 6.2 → 9.7 and 375 → 83 ms. Headless herdr answered within 40 ms, so the cap did not apply.
- **Claude Code on its own and `less`, with a wheel:** 46 → 36 and 40 → 35 paints a second. Ticks the app has not answered yet go in pairs, so it redraws less often for the same distance (Claude Code: 25 → 33 ms at the 90th percentile, 73 → 65 px a paint).
- **Claude Code on its own with a TrackPoint:** 158 → 67 ms between paints at the 90th percentile; one blink in one of two runs (0 before), as in [ADR 068](068.scroll-benchmark.app-modes-and-scroll-behaviour.md)'s runs.
1 change: 1 addition & 0 deletions docs/specs/client.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ When a session ends (shell exits → WS close code `4001`) or the server stops (
| Scroll copies rows | A whole-line scroll copies the rows still in view with one canvas self-copy and draws only the rows that came into view; a frame with nothing changed, including one parked in scrollback, draws nothing; every partial frame equals a full redraw | [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ |
| Smooth scroll via overscan | Pixel-smooth scrollback draws 16 extra rows above and below the viewport (from the scrollback provider), when they come into view, and translates the canvas across them before copying rows, replacing ADR 040's strip canvas | [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 043](../adrs/043.client.scroll-copy-and-overscan.md) | ✅ |
| Smooth mouse wheel | A mouse wheel notch glides to its target over 125 ms (ease-out cubic), and notches during a glide add up; a sparse stream of pixel deltas (a TrackPoint) glides each delta linearly over the time it took to arrive, or follows a spring while the stream is fast, and in apps the scroll ticks of wheel notches and sparse deltas are paced over the same times instead of sent in a burst; a trackpad still scrolls immediately. VS Code's classifier tells the two apart by their recent deltas | [ADR 045](../adrs/045.client.smooth-mouse-wheel.md) | ✅ |
| Scroll ticks wait for the app | Scroll ticks and arrow keys sent to an app go out once the app has answered the last ones with output that leaves no synchronized update open (or after 100 ms without an answer); the ticks due meanwhile go together, so an app that redraws slowly (Claude Code's fullscreen mode inside herdr) redraws once for them instead of falling behind and jumping. While the app's answers take over 40 ms on average, a go sends at most 12 ticks and the rest past one more go are dropped | [ADR 069](../adrs/069.client.scroll-ticks-wait-for-the-app.md) | ✅ |
| ~~Glyph cache~~ | ~~Full repaints copy glyphs from an atlas canvas instead of calling `fillText` per cell~~: in Chrome it took about twice the script and GPU time of `fillText`, and `measureText` ink boxes were not exact enough | [ADR 044](../adrs/044.client.glyph-cache.md) | ❌ rejected |
| Mouse scroll | When the PTY app enables mouse tracking (e.g. vim `set mouse=a`), the terminal forwards wheel events as mouse buttons 4/5 at the pointer's cell, encoded by Ghostty in the app's format (`\x1b[<64/65;col;rowM` under SGR), instead of arrow keys, so apps scroll their buffer rather than move the cursor | [ADR 017](../adrs/017.client.mouse-scroll.md), [ADR 041](../adrs/041.client.libghostty-vt.md), [ADR 049](../adrs/049.client.official-mouse-cursor-paste.md) | ✅ |
| Keyboard bindings | Capture-phase `keydown` handler intercepts configured `key`+`mods` combos and sends `chars` to PTY; defaults to `[]` (no built-in bindings) | [ADR 018](../adrs/018.key-bindings.config-support.md) | ✅ |
Expand Down
1 change: 1 addition & 0 deletions src/client/vendor/ghostty-web/PROVENANCE.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ webtty edits to these files are tracked in git from the commit that added them;
- `core/render-loop.ts`, `core/write-pipeline.ts`, `core/terminal-controllers.ts`, `core/terminal-emitters.ts`, `terminal.ts`, `engine/engine-types.ts`: a frame inside the app's synchronized update (DEC mode 2026) skips its render, and so does the echo render after input; the write pipeline hands each write's output to `SyncHold`, whose policy is Ghostty's: every `CSI ? 2026 h` restarts a 1 s timer, and when it runs out the mode is turned off. `Terminal.renderHeld(now)` tells the embedder's own painting (the kitty overlay) to hold too, and `Terminal.reset()` hands the controllers' engine state to `TerminalControllers.engineReplaced`; after a held frame the write pipeline feeds output up to where an update ends, paints and feeds the rest, and `Terminal.onUpdatePainted` (a new `TerminalEmitters.updatePainted`) tells the embedder; `syncHoldHost` is exported with the other host builders, and `GhosttyTerminal.setMode` sets a mode as the app would (ADR 067).
- `core/terminal-controllers.ts`: the builders of the controllers' hosts (`focusHost`, `findHost`, `writeHost`, `renderLoopHost`, `scrollbarHost`, `mouseHost`, `wheelHost`) are exported, so a test pins where each member forwards (ADR 064).
- `render/cell-painter.ts`: a line's text pass sets the font and the fill colour only when they change from the cell before, from four cached font strings, and a blank cell (a space or no codepoint, with no underline, strikethrough or link line) draws no text. Upstream built and set the font and set the colour for every cell and drew each space with `fillText`. Pixel-identical; under herdr's full-screen redraws, rows took 58% less time to paint in headless Chrome. The test canvas logs a painted row at its own clear, since a blank row draws no text.
- `wheel.ts`, `core/wheel-controller.ts`, `core/terminal-controllers.ts`, `core/sync-hold.ts`: scroll ticks wait for the app (ADR 069). `TickPacer` sends no more ticks until `answered()` (or `ANSWER_WAIT_MS`), sending those due meanwhile together, and to an app whose answers average over `SLOW_ANSWER_MS` at most `SLOW_GO_TICKS` a go, dropping the rest past one more go. The write host's `wroteOutput` calls `WheelController.answered` unless `SyncHold.inUpdate()`.

## webtty-original files

Expand Down
5 changes: 5 additions & 0 deletions src/client/vendor/ghostty-web/core/sync-hold.ts
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,11 @@ export class SyncHold {
return this.host.active() && (this.since === null || now - this.since < SYNC_HOLD_MAX_MS);
}

/** The app's synchronized update is still open: the output written so far began one that has not ended. */
inUpdate(): boolean {
return this.host.active();
}

/** The render loop held a frame's paint. */
frameHeld(): void {
this.waiting = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -235,7 +235,12 @@ export function writeHost(term: ControllersHost, inner: Inner, core: TerminalCon
paint(inner, core);
inner.emit.updatePainted.fire();
},
wroteOutput: (data) => core.sync.wrote(data),
wroteOutput: (data) => {
core.sync.wrote(data);
// The app's answer to the scroll ticks it was sent: its output, once a synchronized
// update in it has ended, so the next ticks do not reach it mid-frame (ADR 069)
if (!core.sync.inUpdate()) core.wheel.answered();
},
// Not inside a synchronized update: the frame after it ends paints (ADR 067)
renderEcho: () => {
if (!core.sync.held()) paint(inner, core);
Expand Down
7 changes: 6 additions & 1 deletion src/client/vendor/ghostty-web/core/wheel-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ export class WheelController implements Disposable {
/** `pacer` sends the ticks and arrows over frames; by default to `host.send`. */
constructor(
private readonly host: WheelHost,
private readonly pacer: Pick<TickPacer, 'push' | 'cancel'> = new TickPacer((data) =>
private readonly pacer: Pick<TickPacer, 'push' | 'cancel' | 'answered'> = new TickPacer((data) =>
host.send(data),
),
) {}
Expand All @@ -58,6 +58,11 @@ export class WheelController implements Disposable {
);
}

/** The app's output arrived: ticks held for its answer may go (ADR 069). */
answered(): void {
this.pacer.answered();
}

/** Removes the listener and drops the queued ticks. Calling it again does nothing. */
dispose(): void {
this.pacer.cancel();
Expand Down
Loading
Loading