detached c37833b

perf(web): update vev-vt to v0.6.0 and coalesce browser updates (#3)

## Summary

Update vev-vt to v0.6.0 and adapt the browser terminal to it.

- **vev-vt v0.6.0**: HTML update schema v2 sends rows as text runs instead of one entry per cell, plus an allocation pass across the VT pipeline.
- **webterm**: queued updates are merged with `applyAll`, so each row is rebuilt once per frame. Presents are capped at about 30 per second; the first update after a pause still goes out on the next frame, so typing latency is unchanged.
- **Benchmark**: `scripts/web-scroll-bench.cjs` measures a fast history scroll in Chromium (WebSocket bytes and main-thread time). Usage is in `docs/web-terminal.md`.

## Before / after

`main` (f449bae6, vev-vt `6ae9bc5f`) against this branch. Median of 3 runs each.

### Browser: fast history scroll

10,000 colored lines, 120 wheel notches (half up, half down), headless Chromium.

| | 189×57 before | 189×57 after | 243×71 at 125% before | 243×71 at 125% after |
|---|---:|---:|---:|---:|
| Data sent over WebSocket | 64.1 MiB | 11.5 MiB (**−82%**) | 94.9 MiB | 16.2 MiB (**−83%**) |
| Average update size | 472 KiB | 46 KiB (**10×**) | 759 KiB | 61 KiB (**12×**) |
| Main-thread busy time | 8.35 s | 2.28 s (**3.7×**) | 11.3 s | 3.6 s (**3.1×**) |
| — of which JavaScript | 3.86 s | 0.81 s | 5.13 s | 1.24 s |
| — style + layout + paint | 4.16 s | 1.32 s | 5.14 s | 2.20 s |
| Busy time per wheel notch | 69.6 ms | 19.0 ms | 94.3 ms | 30.0 ms |

Sizes count JSON characters received by the page. The new build sends about twice as many updates, each 10–12× smaller: the browser now keeps up and presents more intermediate frames instead of falling behind.

Headless Chromium has no GPU compositor, so paint is underestimated. On a real GPU at 243×96, the 30 fps cap alone cut main-thread time by a further 11–17%.

### Go: vev-vt renderers and VT (`benchstat`, n=8 and n=6)

| Benchmark | Before | After | Change |
|---|---:|---:|---:|
| HTML snapshot 240×80 | 5.68 ms, 10.2 MB, 19,511 allocs | 0.90 ms, 483 KB, 38 allocs | **−84% time** |
| HTML one changed row 240×80 | 2.07 ms, 6.9 MB | 0.48 ms, 38 KB | **−77% time, −99% bytes** |
| HTML renderer, geomean of 6 sizes | 939 µs | 196 µs | **−79%** |
| ANSI one-cell incremental draw | 11.3 µs, 83 KB | 1.35 µs, 80 B | **−88%** |
| Shell redraw burst | 878 µs, 83 KB | 525 µs, 0 B | **−40%** |
| History build 10K×120 (styled) | 138 ms, 142 MB | 49 ms, 22 MB | **−65%** |
| VT and history, geomean | 4.41 ms | 2.57 ms | **−42%** |

ANSI output bytes are identical before and after.

## Checks

- `make lint`, `go test ./... -race`
- vev-vt v0.6.0 CI: Go tests, lint, and the browser harness on Chromium, Firefox and WebKit

## Reproduce

```sh
go build -o /tmp/vev-new .
PLAYWRIGHT_MODULE=/path/to/node_modules/playwright VEV_BINARY=/tmp/vev-new \
  node scripts/web-scroll-bench.cjs --viewport 2048x1234 --scale 1.25
```

Build `main` the same way and compare. Go numbers come from `go test -bench` in vev-vt; see its `docs/performance.md`.

Reviewed-on: https://git.bnema.dev/bnema/vev/pulls/3
commit
c37833be6fb98d52dfa83a25cea552254f4dce9f
author
brice · · 2026-10-02 18:49 UTC
committer
bnema · · 2026-10-02 18:49 UTC
parent
f449bae
signature
unsigned
refs
tag: v0.5.0
7 files · +192 -15
M docs/web-terminal.md +25 -0
@@ -81,3 +81,28 @@ VEV_ENV=web-visual .dev/vev-web ls
8181 Keep the printed link out of screenshots.
8282
8383 `scripts/web-visual-smoke.cjs` and `scripts/web-access-smoke.cjs` run Playwright checks. Set `PLAYWRIGHT_MODULE`, `VEV_BINARY`, `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH`. Run them only against an isolated profile: they create sessions and revoke access.
84+
85+## Measuring scroll performance
86+
87+`scripts/web-scroll-bench.cjs` measures how much browser work a fast history scroll costs. It starts its own gateway in a temporary profile, prints 10,000 colored lines, scrolls up and back down with the mouse wheel, and sums the page's main-thread time from a Chromium trace. It stops the gateway and deletes the profile when it ends. Linux only.
88+
89+```sh
90+go build -o .dev/vev-web .
91+PLAYWRIGHT_MODULE=/path/to/node_modules/playwright VEV_BINARY=$PWD/.dev/vev-web \
92+ node scripts/web-scroll-bench.cjs --viewport 1638x987 --scale 1.25
93+```
94+
95+| Option | Default | Meaning |
96+|---|---|---|
97+| `--lines` | 10000 | History lines printed before scrolling |
98+| `--notches` | 120 | Wheel notches: half up, half down, one every 16 ms |
99+| `--port` | 8799 | Gateway port on 127.0.0.1 |
100+| `--viewport` | 1600x1000 | Page size in CSS pixels (headless only) |
101+| `--scale` | 1 | Device pixel ratio, for example `1.25` for 125% zoom (headless only) |
102+| `--cdp` | | Use a running Chromium instead of headless, for example `http://127.0.0.1:9222` |
103+
104+The output is JSON. `wireFrames`, `wireKB` and `avgFrameKB` count the WebSocket updates the page received during the scroll. `totalMs` is the main thread's busy time; `scriptMs`, `styleMs`, `layoutMs` and `paintMs` split it. `totalMsPerNotch` above 16 means the browser cannot keep up with a 60 Hz wheel. Compare two builds with the same options and run each a few times: the numbers vary by about 10%.
105+
106+Headless Chromium has no GPU compositor, so it underestimates paint. For numbers that match what users see, start Chromium with `--remote-debugging-port=9222`, keep the benchmark tab in the foreground, and pass `--cdp`. The window size and zoom then come from that browser. Close it afterwards: the debugging port lets any local process control it.
107+
108+To try a frontend change without rebuilding, set `WEB_APP_JS` or `WEB_RUNTIME_JS` to a local `app.js` or `terminal.js`.
@@ -81,3 +81,28 @@ VEV_ENV=web-visual .dev/vev-web ls
81 Keep the printed link out of screenshots.81 Keep the printed link out of screenshots.
82 82
83 `scripts/web-visual-smoke.cjs` and `scripts/web-access-smoke.cjs` run Playwright checks. Set `PLAYWRIGHT_MODULE`, `VEV_BINARY`, `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH`. Run them only against an isolated profile: they create sessions and revoke access.83 `scripts/web-visual-smoke.cjs` and `scripts/web-access-smoke.cjs` run Playwright checks. Set `PLAYWRIGHT_MODULE`, `VEV_BINARY`, `VEV_ENV=web-visual`, and optionally `CHROMIUM_PATH`. Run them only against an isolated profile: they create sessions and revoke access.
84+
85+## Measuring scroll performance
86+
87+`scripts/web-scroll-bench.cjs` measures how much browser work a fast history scroll costs. It starts its own gateway in a temporary profile, prints 10,000 colored lines, scrolls up and back down with the mouse wheel, and sums the page's main-thread time from a Chromium trace. It stops the gateway and deletes the profile when it ends. Linux only.
88+
89+```sh
90+go build -o .dev/vev-web .
91+PLAYWRIGHT_MODULE=/path/to/node_modules/playwright VEV_BINARY=$PWD/.dev/vev-web \
92+ node scripts/web-scroll-bench.cjs --viewport 1638x987 --scale 1.25
93+```
94+
95+| Option | Default | Meaning |
96+|---|---|---|
97+| `--lines` | 10000 | History lines printed before scrolling |
98+| `--notches` | 120 | Wheel notches: half up, half down, one every 16 ms |
99+| `--port` | 8799 | Gateway port on 127.0.0.1 |
100+| `--viewport` | 1600x1000 | Page size in CSS pixels (headless only) |
101+| `--scale` | 1 | Device pixel ratio, for example `1.25` for 125% zoom (headless only) |
102+| `--cdp` | | Use a running Chromium instead of headless, for example `http://127.0.0.1:9222` |
103+
104+The output is JSON. `wireFrames`, `wireKB` and `avgFrameKB` count the WebSocket updates the page received during the scroll. `totalMs` is the main thread's busy time; `scriptMs`, `styleMs`, `layoutMs` and `paintMs` split it. `totalMsPerNotch` above 16 means the browser cannot keep up with a 60 Hz wheel. Compare two builds with the same options and run each a few times: the numbers vary by about 10%.
105+
106+Headless Chromium has no GPU compositor, so it underestimates paint. For numbers that match what users see, start Chromium with `--remote-debugging-port=9222`, keep the benchmark tab in the foreground, and pass `--cdp`. The window size and zoom then come from that browser. Close it afterwards: the debugging port lets any local process control it.
107+
108+To try a frontend change without rebuilding, set `WEB_APP_JS` or `WEB_RUNTIME_JS` to a local `app.js` or `terminal.js`.
M go.mod +1 -1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
33 go 1.27
44
55 require (
6- github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde91
6+ github.com/bnema/vev-vt v0.6.0
77 github.com/coder/websocket v1.8.15
88 github.com/quic-go/quic-go v0.62.0
99 github.com/stretchr/testify v1.12.1
@@ -3,7 +3,7 @@ module github.com/bnema/vev
3 go 1.273 go 1.27
4 4
5 require (5 require (
6- github.com/bnema/vev-vt v0.5.2-0.20260926054010-6ae9bc5fde916+ github.com/bnema/vev-vt v0.6.0
7 github.com/coder/websocket v1.8.157 github.com/coder/websocket v1.8.15
8 github.com/quic-go/quic-go v0.62.08 github.com/quic-go/quic-go v0.62.0
9 github.com/stretchr/testify v1.12.19 github.com/stretchr/testify v1.12.1
M go.sum +2 -4 generated file: collapsed
M internal/adapters/webterm/app.js +18 -6
@@ -134,18 +134,30 @@
134134 status.textContent = 'Connecting…';
135135 reconnect.hidden = true;
136136 // Presentation coalescing: at most one scheduled present task per
137- // animation frame, preserving every incremental update in order. Each
138- // queued delta is still applied in order; never drops intermediate
139- // deltas. requestAnimationFrame is throttled to zero in hidden pages,
140- // so a timeout fallback keeps the terminal live there.
137+ // animation frame. applyAll validates every queued update, then merges
138+ // them so each changed row is rebuilt once per frame. requestAnimationFrame
139+ // is throttled to zero in hidden pages, so a timeout fallback keeps the
140+ // terminal live there.
141+ // Presents are capped at about 30 per second: a full-screen redraw (fast
142+ // history scroll) costs the browser more style, layout and paint than one
143+ // 60 Hz frame allows. The first update after a pause is presented on the
144+ // next frame, so typing latency is unchanged.
145+ const minPresentMs = 32;
141146 let queuedUpdates = [];
142147 let presentScheduled = false;
143- function flushUpdates() {
148+ let lastPresent = -Infinity;
149+ function flushUpdates(now = performance.now()) {
150+ if (now - lastPresent < minPresentMs) {
151+ if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates);
152+ else setTimeout(flushUpdates, minPresentMs - (now - lastPresent));
153+ return;
154+ }
155+ lastPresent = now;
144156 presentScheduled = false;
145157 const batch = queuedUpdates;
146158 queuedUpdates = [];
147159 try {
148- for (const update of batch) terminal.apply(update);
160+ terminal.applyAll(batch);
149161 } catch {
150162 status.textContent = 'Invalid terminal update — connection stopped.';
151163 socket.close();
@@ -134,18 +134,30 @@
134 status.textContent = 'Connecting…';134 status.textContent = 'Connecting…';
135 reconnect.hidden = true;135 reconnect.hidden = true;
136 // Presentation coalescing: at most one scheduled present task per136 // Presentation coalescing: at most one scheduled present task per
137- // animation frame, preserving every incremental update in order. Each137+ // animation frame. applyAll validates every queued update, then merges
138- // queued delta is still applied in order; never drops intermediate138+ // them so each changed row is rebuilt once per frame. requestAnimationFrame
139- // deltas. requestAnimationFrame is throttled to zero in hidden pages,139+ // is throttled to zero in hidden pages, so a timeout fallback keeps the
140- // so a timeout fallback keeps the terminal live there.140+ // terminal live there.
141+ // Presents are capped at about 30 per second: a full-screen redraw (fast
142+ // history scroll) costs the browser more style, layout and paint than one
143+ // 60 Hz frame allows. The first update after a pause is presented on the
144+ // next frame, so typing latency is unchanged.
145+ const minPresentMs = 32;
141 let queuedUpdates = [];146 let queuedUpdates = [];
142 let presentScheduled = false;147 let presentScheduled = false;
143- function flushUpdates() {148+ let lastPresent = -Infinity;
149+ function flushUpdates(now = performance.now()) {
150+ if (now - lastPresent < minPresentMs) {
151+ if (document.visibilityState === 'visible') requestAnimationFrame(flushUpdates);
152+ else setTimeout(flushUpdates, minPresentMs - (now - lastPresent));
153+ return;
154+ }
155+ lastPresent = now;
144 presentScheduled = false;156 presentScheduled = false;
145 const batch = queuedUpdates;157 const batch = queuedUpdates;
146 queuedUpdates = [];158 queuedUpdates = [];
147 try {159 try {
148- for (const update of batch) terminal.apply(update);160+ terminal.applyAll(batch);
149 } catch {161 } catch {
150 status.textContent = 'Invalid terminal update — connection stopped.';162 status.textContent = 'Invalid terminal update — connection stopped.';
151 socket.close();163 socket.close();
M internal/adapters/webterm/server_test.go +5 -2
@@ -3,6 +3,7 @@ package webterm
33 import (
44 "context"
55 "encoding/json"
6+ "fmt"
67 "io"
78 "net/http"
89 "net/http/httptest"
@@ -11,6 +12,7 @@ import (
1112 "testing"
1213 "time"
1314
15+ "github.com/bnema/vev-vt/html/browser"
1416 "github.com/coder/websocket"
1517 "github.com/stretchr/testify/require"
1618 )
@@ -115,11 +117,12 @@ func TestWebSocketRoundTripAndDetach(t *testing.T) {
115117 _, data, err := conn.Read(ctx)
116118 require.NoError(t, err)
117119 require.True(t, json.Valid(data))
118- require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(`{"schemaVersion":1,"type":"text","text":"Z"}`)))
120+ event := fmt.Sprintf(`{"schemaVersion":%d,"type":"text","text":"Z"}`, browser.EventSchemaVersion)
121+ require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(event)))
119122 for {
120123 _, data, err = conn.Read(ctx)
121124 require.NoError(t, err)
122- if strings.Contains(string(data), `"text":"Z"`) {
125+ if strings.Contains(string(data), `"text":"Z`) {
123126 break
124127 }
125128 }
@@ -3,6 +3,7 @@ package webterm
3 import (3 import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6+ "fmt"
6 "io"7 "io"
7 "net/http"8 "net/http"
8 "net/http/httptest"9 "net/http/httptest"
@@ -11,6 +12,7 @@ import (
11 "testing"12 "testing"
12 "time"13 "time"
13 14
15+ "github.com/bnema/vev-vt/html/browser"
14 "github.com/coder/websocket"16 "github.com/coder/websocket"
15 "github.com/stretchr/testify/require"17 "github.com/stretchr/testify/require"
16 )18 )
@@ -115,11 +117,12 @@ func TestWebSocketRoundTripAndDetach(t *testing.T) {
115 _, data, err := conn.Read(ctx)117 _, data, err := conn.Read(ctx)
116 require.NoError(t, err)118 require.NoError(t, err)
117 require.True(t, json.Valid(data))119 require.True(t, json.Valid(data))
118- require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(`{"schemaVersion":1,"type":"text","text":"Z"}`)))120+ event := fmt.Sprintf(`{"schemaVersion":%d,"type":"text","text":"Z"}`, browser.EventSchemaVersion)
121+ require.NoError(t, conn.Write(ctx, websocket.MessageText, []byte(event)))
119 for {122 for {
120 _, data, err = conn.Read(ctx)123 _, data, err = conn.Read(ctx)
121 require.NoError(t, err)124 require.NoError(t, err)
122- if strings.Contains(string(data), `"text":"Z"`) {125+ if strings.Contains(string(data), `"text":"Z`) {
123 break126 break
124 }127 }
125 }128 }
M internal/app/web_daemon_broker_test.go +3 -2
@@ -15,6 +15,7 @@ import (
1515 "testing"
1616 "time"
1717
18+ "github.com/bnema/vev-vt/html/browser"
1819 "github.com/coder/websocket"
1920 "github.com/stretchr/testify/require"
2021
@@ -190,7 +191,7 @@ func (tab *webBrowserTab) close() {
190191 // sendText types one browser text event at the tab.
191192 func (tab *webBrowserTab) sendText(t *testing.T, text string) {
192193 t.Helper()
193- payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "text", "text": text})
194+ payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "text", "text": text})
194195 require.NoError(t, err)
195196 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
196197 }
@@ -199,7 +200,7 @@ func (tab *webBrowserTab) sendText(t *testing.T, text string) {
199200 // A text event deliberately cannot carry a control byte.
200201 func (tab *webBrowserTab) sendEnter(t *testing.T) {
201202 t.Helper()
202- payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "key", "key": "Enter", "code": "Enter"})
203+ payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "key", "key": "Enter", "code": "Enter"})
203204 require.NoError(t, err)
204205 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
205206 }
@@ -15,6 +15,7 @@ import (
15 "testing"15 "testing"
16 "time"16 "time"
17 17
18+ "github.com/bnema/vev-vt/html/browser"
18 "github.com/coder/websocket"19 "github.com/coder/websocket"
19 "github.com/stretchr/testify/require"20 "github.com/stretchr/testify/require"
20 21
@@ -190,7 +191,7 @@ func (tab *webBrowserTab) close() {
190 // sendText types one browser text event at the tab.191 // sendText types one browser text event at the tab.
191 func (tab *webBrowserTab) sendText(t *testing.T, text string) {192 func (tab *webBrowserTab) sendText(t *testing.T, text string) {
192 t.Helper()193 t.Helper()
193- payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "text", "text": text})194+ payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "text", "text": text})
194 require.NoError(t, err)195 require.NoError(t, err)
195 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))196 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
196 }197 }
@@ -199,7 +200,7 @@ func (tab *webBrowserTab) sendText(t *testing.T, text string) {
199 // A text event deliberately cannot carry a control byte.200 // A text event deliberately cannot carry a control byte.
200 func (tab *webBrowserTab) sendEnter(t *testing.T) {201 func (tab *webBrowserTab) sendEnter(t *testing.T) {
201 t.Helper()202 t.Helper()
202- payload, err := json.Marshal(map[string]any{"schemaVersion": 1, "type": "key", "key": "Enter", "code": "Enter"})203+ payload, err := json.Marshal(map[string]any{"schemaVersion": browser.EventSchemaVersion, "type": "key", "key": "Enter", "code": "Enter"})
203 require.NoError(t, err)204 require.NoError(t, err)
204 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))205 require.NoError(t, tab.conn.Write(t.Context(), websocket.MessageText, payload))
205 }206 }
A scripts/web-scroll-bench.cjs +138 -0
@@ -0,0 +1,138 @@
1+// Browser scroll benchmark for the web terminal. Starts an isolated gateway,
2+// fills history, scrolls it fast with the wheel and reports where the
3+// browser's main thread spent its time. Linux only. See docs/web-terminal.md.
4+const { spawnSync } = require('node:child_process');
5+const fs = require('node:fs');
6+const os = require('node:os');
7+const path = require('node:path');
8+
9+const options = { lines: 10000, notches: 120, port: 8799, viewport: '1600x1000', scale: 1, cdp: '' };
10+for (let i = 2; i < process.argv.length; i += 2) {
11+ const key = process.argv[i].replace(/^--/, '');
12+ if (!(key in options) || process.argv[i + 1] === undefined) {
13+ console.error('usage: web-scroll-bench.cjs [--lines N] [--notches N] [--port N] [--viewport WxH] [--scale F] [--cdp URL]');
14+ process.exit(2);
15+ }
16+ options[key] = typeof options[key] === 'number' ? Number(process.argv[i + 1]) : process.argv[i + 1];
17+}
18+if (!process.env.VEV_BINARY) {
19+ console.error('VEV_BINARY is required');
20+ process.exit(2);
21+}
22+const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
23+
24+// Main-thread trace events, summed per phase.
25+const PHASES = {
26+ total: ['RunTask'],
27+ script: ['FunctionCall', 'EvaluateScript', 'v8.callFunction'],
28+ style: ['UpdateLayoutTree'],
29+ layout: ['Layout'],
30+ paint: ['Paint', 'PaintImage', 'RasterTask'],
31+};
32+
33+// Kill every process started under root (session daemon and gateway).
34+function stopProcesses(root) {
35+ for (const pid of fs.readdirSync('/proc').filter(name => /^\d+$/.test(name))) {
36+ try {
37+ const environ = fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0');
38+ if (environ.includes(`VEV_ENV_ROOT=${root}`)) process.kill(Number(pid), 'SIGTERM');
39+ } catch {}
40+ }
41+}
42+
43+(async () => {
44+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vev-scroll-bench-'));
45+ const env = { ...process.env, VEV_ENV: 'scroll-bench', VEV_ENV_ROOT: root, SHELL: '/bin/bash' };
46+ const origin = `http://127.0.0.1:${options.port}`;
47+ let browser;
48+ try {
49+ const started = spawnSync(process.env.VEV_BINARY, ['--web-daemon', '--web-listen', `127.0.0.1:${options.port}`, '--web-origin', origin], { cwd: root, env, encoding: 'utf8', timeout: 20000 });
50+ const token = started.stdout?.match(/#token=([A-Za-z0-9_-]+)/)?.[1];
51+ if (!token) throw new Error(`gateway did not start: ${started.stderr || started.error}`);
52+
53+ const [width, height] = options.viewport.split('x').map(Number);
54+ let page;
55+ if (options.cdp) {
56+ browser = await chromium.connectOverCDP(options.cdp);
57+ page = await browser.contexts()[0].newPage();
58+ await page.bringToFront();
59+ } else {
60+ browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH });
61+ page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: options.scale });
62+ }
63+ if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({ path: process.env.WEB_APP_JS, contentType: 'text/javascript' }));
64+ if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({ path: process.env.WEB_RUNTIME_JS, contentType: 'text/javascript' }));
65+
66+ // Count WebSocket frames and bytes received by the page.
67+ await page.addInitScript(() => {
68+ const NativeWebSocket = window.WebSocket;
69+ window.wire = { frames: 0, bytes: 0 };
70+ window.WebSocket = class extends NativeWebSocket {
71+ constructor(...args) {
72+ super(...args);
73+ this.addEventListener('message', event => {
74+ window.wire.frames += 1;
75+ window.wire.bytes += typeof event.data === 'string' ? event.data.length : event.data.byteLength ?? event.data.size;
76+ });
77+ }
78+ };
79+ });
80+ await page.goto(`${origin}/#token=${token}`);
81+ await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected');
82+ await page.waitForTimeout(1000);
83+ await page.locator('#terminal').click();
84+ // Colored, styled and wide content, close to a real TUI.
85+ await page.keyboard.type(`for i in $(seq 1 ${options.lines}); do printf '\\e[3%dm%05d\\e[0m │ \\e[1mlorem\\e[0m ipsum \\e[48;5;236mdolor sit\\e[0m amet ✓ consectetur … │ %s\\n' $((i%7+1)) $i "$(printf '%*s' $((i%40)) x)"; done; echo bench-$((6*7))-ready\n`, { delay: 2 });
86+ await page.waitForFunction(() => document.querySelector('.vev-terminal__accessible-output').textContent.includes('bench-42-ready'), null, { timeout: 120000 });
87+ const geometry = await page.evaluate(() => {
88+ const style = getComputedStyle(document.querySelector('.vev-terminal'));
89+ return { columns: Number(style.getPropertyValue('--vev-cols')), rows: Number(style.getPropertyValue('--vev-rows')), viewport: [innerWidth, innerHeight], scale: devicePixelRatio };
90+ });
91+
92+ const box = await page.locator('#terminal').boundingBox();
93+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
94+ const cdp = await page.context().newCDPSession(page);
95+ const events = [];
96+ cdp.on('Tracing.dataCollected', event => events.push(...event.value));
97+ const done = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve));
98+ await cdp.send('Tracing.start', { transferMode: 'ReportEvents', traceConfig: { includedCategories: ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'v8'] } });
99+ const wireBefore = await page.evaluate(() => ({ ...window.wire }));
100+ const start = Date.now();
101+ // Half the notches scroll up into history, half come back down.
102+ for (let i = 0; i < options.notches; i++) {
103+ await page.mouse.wheel(0, i < options.notches / 2 ? -100 : 100);
104+ await page.waitForTimeout(16);
105+ }
106+ await page.waitForTimeout(800);
107+ const wall = (Date.now() - start) / 1000;
108+ const wireAfter = await page.evaluate(() => ({ ...window.wire }));
109+ await cdp.send('Tracing.end');
110+ await done;
111+
112+ // The page's main thread is the one that ran Layout.
113+ const layout = events.find(event => event.name === 'Layout' && event.ph === 'X');
114+ const frames = wireAfter.frames - wireBefore.frames;
115+ const bytes = wireAfter.bytes - wireBefore.bytes;
116+ const result = {
117+ ...geometry, notches: options.notches, wallSeconds: wall,
118+ wireFrames: frames, wireKB: Math.round(bytes / 1024), avgFrameKB: frames ? Number((bytes / frames / 1024).toFixed(1)) : 0
119+ };
120+ for (const [phase, names] of Object.entries(PHASES)) {
121+ let ms = 0;
122+ for (const event of events) {
123+ if (event.ph === 'X' && event.dur && event.pid === layout?.pid && event.tid === layout?.tid && names.includes(event.name)) ms += event.dur / 1000;
124+ }
125+ result[`${phase}Ms`] = Math.round(ms);
126+ }
127+ result.totalMsPerNotch = Number((result.totalMs / options.notches).toFixed(1));
128+ console.log(JSON.stringify(result, null, 2));
129+ await page.close();
130+ } finally {
131+ if (browser) await browser.close().catch(() => {});
132+ stopProcesses(root);
133+ fs.rmSync(root, { recursive: true, force: true });
134+ }
135+})().catch(error => {
136+ console.error(error);
137+ process.exit(1);
138+});
@@ -0,0 +1,138 @@
1+// Browser scroll benchmark for the web terminal. Starts an isolated gateway,
2+// fills history, scrolls it fast with the wheel and reports where the
3+// browser's main thread spent its time. Linux only. See docs/web-terminal.md.
4+const { spawnSync } = require('node:child_process');
5+const fs = require('node:fs');
6+const os = require('node:os');
7+const path = require('node:path');
8+
9+const options = { lines: 10000, notches: 120, port: 8799, viewport: '1600x1000', scale: 1, cdp: '' };
10+for (let i = 2; i < process.argv.length; i += 2) {
11+ const key = process.argv[i].replace(/^--/, '');
12+ if (!(key in options) || process.argv[i + 1] === undefined) {
13+ console.error('usage: web-scroll-bench.cjs [--lines N] [--notches N] [--port N] [--viewport WxH] [--scale F] [--cdp URL]');
14+ process.exit(2);
15+ }
16+ options[key] = typeof options[key] === 'number' ? Number(process.argv[i + 1]) : process.argv[i + 1];
17+}
18+if (!process.env.VEV_BINARY) {
19+ console.error('VEV_BINARY is required');
20+ process.exit(2);
21+}
22+const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
23+
24+// Main-thread trace events, summed per phase.
25+const PHASES = {
26+ total: ['RunTask'],
27+ script: ['FunctionCall', 'EvaluateScript', 'v8.callFunction'],
28+ style: ['UpdateLayoutTree'],
29+ layout: ['Layout'],
30+ paint: ['Paint', 'PaintImage', 'RasterTask'],
31+};
32+
33+// Kill every process started under root (session daemon and gateway).
34+function stopProcesses(root) {
35+ for (const pid of fs.readdirSync('/proc').filter(name => /^\d+$/.test(name))) {
36+ try {
37+ const environ = fs.readFileSync(`/proc/${pid}/environ`, 'utf8').split('\0');
38+ if (environ.includes(`VEV_ENV_ROOT=${root}`)) process.kill(Number(pid), 'SIGTERM');
39+ } catch {}
40+ }
41+}
42+
43+(async () => {
44+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'vev-scroll-bench-'));
45+ const env = { ...process.env, VEV_ENV: 'scroll-bench', VEV_ENV_ROOT: root, SHELL: '/bin/bash' };
46+ const origin = `http://127.0.0.1:${options.port}`;
47+ let browser;
48+ try {
49+ const started = spawnSync(process.env.VEV_BINARY, ['--web-daemon', '--web-listen', `127.0.0.1:${options.port}`, '--web-origin', origin], { cwd: root, env, encoding: 'utf8', timeout: 20000 });
50+ const token = started.stdout?.match(/#token=([A-Za-z0-9_-]+)/)?.[1];
51+ if (!token) throw new Error(`gateway did not start: ${started.stderr || started.error}`);
52+
53+ const [width, height] = options.viewport.split('x').map(Number);
54+ let page;
55+ if (options.cdp) {
56+ browser = await chromium.connectOverCDP(options.cdp);
57+ page = await browser.contexts()[0].newPage();
58+ await page.bringToFront();
59+ } else {
60+ browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH });
61+ page = await browser.newPage({ viewport: { width, height }, deviceScaleFactor: options.scale });
62+ }
63+ if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({ path: process.env.WEB_APP_JS, contentType: 'text/javascript' }));
64+ if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({ path: process.env.WEB_RUNTIME_JS, contentType: 'text/javascript' }));
65+
66+ // Count WebSocket frames and bytes received by the page.
67+ await page.addInitScript(() => {
68+ const NativeWebSocket = window.WebSocket;
69+ window.wire = { frames: 0, bytes: 0 };
70+ window.WebSocket = class extends NativeWebSocket {
71+ constructor(...args) {
72+ super(...args);
73+ this.addEventListener('message', event => {
74+ window.wire.frames += 1;
75+ window.wire.bytes += typeof event.data === 'string' ? event.data.length : event.data.byteLength ?? event.data.size;
76+ });
77+ }
78+ };
79+ });
80+ await page.goto(`${origin}/#token=${token}`);
81+ await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected');
82+ await page.waitForTimeout(1000);
83+ await page.locator('#terminal').click();
84+ // Colored, styled and wide content, close to a real TUI.
85+ await page.keyboard.type(`for i in $(seq 1 ${options.lines}); do printf '\\e[3%dm%05d\\e[0m │ \\e[1mlorem\\e[0m ipsum \\e[48;5;236mdolor sit\\e[0m amet ✓ consectetur … │ %s\\n' $((i%7+1)) $i "$(printf '%*s' $((i%40)) x)"; done; echo bench-$((6*7))-ready\n`, { delay: 2 });
86+ await page.waitForFunction(() => document.querySelector('.vev-terminal__accessible-output').textContent.includes('bench-42-ready'), null, { timeout: 120000 });
87+ const geometry = await page.evaluate(() => {
88+ const style = getComputedStyle(document.querySelector('.vev-terminal'));
89+ return { columns: Number(style.getPropertyValue('--vev-cols')), rows: Number(style.getPropertyValue('--vev-rows')), viewport: [innerWidth, innerHeight], scale: devicePixelRatio };
90+ });
91+
92+ const box = await page.locator('#terminal').boundingBox();
93+ await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
94+ const cdp = await page.context().newCDPSession(page);
95+ const events = [];
96+ cdp.on('Tracing.dataCollected', event => events.push(...event.value));
97+ const done = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve));
98+ await cdp.send('Tracing.start', { transferMode: 'ReportEvents', traceConfig: { includedCategories: ['devtools.timeline', 'disabled-by-default-devtools.timeline', 'v8'] } });
99+ const wireBefore = await page.evaluate(() => ({ ...window.wire }));
100+ const start = Date.now();
101+ // Half the notches scroll up into history, half come back down.
102+ for (let i = 0; i < options.notches; i++) {
103+ await page.mouse.wheel(0, i < options.notches / 2 ? -100 : 100);
104+ await page.waitForTimeout(16);
105+ }
106+ await page.waitForTimeout(800);
107+ const wall = (Date.now() - start) / 1000;
108+ const wireAfter = await page.evaluate(() => ({ ...window.wire }));
109+ await cdp.send('Tracing.end');
110+ await done;
111+
112+ // The page's main thread is the one that ran Layout.
113+ const layout = events.find(event => event.name === 'Layout' && event.ph === 'X');
114+ const frames = wireAfter.frames - wireBefore.frames;
115+ const bytes = wireAfter.bytes - wireBefore.bytes;
116+ const result = {
117+ ...geometry, notches: options.notches, wallSeconds: wall,
118+ wireFrames: frames, wireKB: Math.round(bytes / 1024), avgFrameKB: frames ? Number((bytes / frames / 1024).toFixed(1)) : 0
119+ };
120+ for (const [phase, names] of Object.entries(PHASES)) {
121+ let ms = 0;
122+ for (const event of events) {
123+ if (event.ph === 'X' && event.dur && event.pid === layout?.pid && event.tid === layout?.tid && names.includes(event.name)) ms += event.dur / 1000;
124+ }
125+ result[`${phase}Ms`] = Math.round(ms);
126+ }
127+ result.totalMsPerNotch = Number((result.totalMs / options.notches).toFixed(1));
128+ console.log(JSON.stringify(result, null, 2));
129+ await page.close();
130+ } finally {
131+ if (browser) await browser.close().catch(() => {});
132+ stopProcesses(root);
133+ fs.rmSync(root, { recursive: true, force: true });
134+ }
135+})().catch(error => {
136+ console.error(error);
137+ process.exit(1);
138+});

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close