Skip to content

Render terminals on the GPU, with a context budget and a DOM fallback - #63

Open
Sadykhzadeh wants to merge 4 commits into
mainfrom
perf/terminal-webgl-renderer
Open

Render terminals on the GPU, with a context budget and a DOM fallback#63
Sadykhzadeh wants to merge 4 commits into
mainfrom
perf/terminal-webgl-renderer

Conversation

@Sadykhzadeh

@Sadykhzadeh Sadykhzadeh commented Sep 10, 2026

Copy link
Copy Markdown
Member

Fixes #62.

Terminals ran on xterm's DOM renderer — the documented fallback, which rebuilds one <span> per styled run per row whenever the screen changes. This loads @xterm/addon-webgl and moves that work to the GPU.

The addon is easy to ship badly, so the two failure modes that produce a blank terminal rather than a slow one are part of the change rather than hardening bolted on afterwards.

What changed

desktop/src/lib/terminalRenderer.ts (new)

A process-wide, least-recently-used budget of eight GPU-backed panes.

The constraint is specific to this app. Chromium keeps 16 WebGL contexts alive per renderer process and kills the oldest without warning past that — measured directly: 64 contexts created, 16 alive, 48 lost, oldest first. TerminalWorkspace.vue:101-102 mounts every pane of every tab at once, which is correct and keeps xterm from remounting when panes move between tabs, but it means the number of live terminals is not bounded by what is on screen. Eight leaves half the cap as headroom, and a pane pushed out of the budget reverts to the DOM renderer — the renderer every pane uses today — rather than to a dead context.

Scope of that measurement, corrected. The first revision of this PR wrote the invariant as a platform-general rule ("the cap must never be reached"). It is not one: 16-and-oldest-first was measured on Chromium only, which is the Windows WebView2 path. The app also ships on WKWebView (macOS) and WebKitGTK (Linux), and I have no machine for either, so neither was measured. Rather than invent numbers, the comment now cites what upstream declares: WebKit's Source/WebCore/html/canvas/WebGLRenderingContextBase.cpp has static constexpr size_t maxActiveContexts = 16 (and maxActiveWorkerContexts = 4), and addActiveContext recycles the context with the lowest activeOrdinal() — least-recently-active, not oldest-created, a subtly different policy from Chromium's. Both WebKit ports build that same WebCore file. That is a constant read out of upstream source, not a number observed in this app, and it is labelled as such in the code.

Why the budget stays at eight rather than going more conservative. The argument for lowering it is that an unmeasured engine is an unknown lower bound, and WebKit's own worker-context limit of 4 shows the project does use smaller numbers somewhere. The argument against, which I find stronger: the two unmeasured engines declare the same ceiling, so this is not an unknown bound so much as an unconfirmed one; a smaller budget would start demoting panes that are simultaneously on screen in a multi-pane split, which is exactly the case the GPU renderer exists to serve; and the cost of being wrong is bounded. Overshooting the real cap costs the least-recently-used pane a stale frame for the addon's three-second restoration window and then drops it to the DOM renderer with its buffer intact — a quality regression on the pane furthest from the user's attention, never a correctness one. If either WebKit port turns out to enforce something lower in practice than the constant suggests, the budget is one constant to change and the tests already pin the eviction behaviour.

It is least-recently-used rather than visibility-gated on purpose. Disposing on hide and re-attaching on show is the obvious design and it is worse: attach costs 19 ms warm and 57 ms cold (shader compile plus texture atlas), so it would put a visible hitch on every tab switch. A pane claims a slot when it comes to the front and keeps it until eight other panes have been used more recently, so alternating between a few tabs never re-attaches at all.

Support is probed once per process, and the probe hands its own context straight back rather than holding one of the sixteen.

Repeat claims for the same pane join the pending one instead of racing it — a pane can be shown, hidden and shown again inside the dynamic import's window, and without that guard it ends up with two contexts.

Context loss

onContextLoss disposes the addon, which is what restores the DOM renderer. Verified against the installed version rather than assumed:

  • WebglAddon.activate() registers a disposable that calls renderService.setRenderer(terminal._core._createRenderer()) then handleResize(cols, rows).
  • _createRenderer() in @xterm/xterm 5.5.0 returns DomRenderer.
  • After dispose() the live renderer's constructor is identical to a never-accelerated terminal's, output written afterwards appears in the DOM, and nothing throws.

Worth knowing: the addon does not report the loss immediately. It calls preventDefault() and waits 3000 ms for restoration first — measured at 3075 ms end to end. The pane is frozen for those three seconds. Handling it is still mandatory; without the dispose the pane never comes back.

The Safari-below-16 path was an unhandled rejection

new module.WebglAddon() sat outside the try that guards activation, so the constructor's own failure escaped the fallback entirely. attach() is async, so on any platform where the constructor throws, acquireWebglRenderer() returned a rejected promise instead of resolving false — an unhandled rejection at every call site, rather than the clean drop to the DOM renderer the rest of the module is built around. Nothing covered it.

It is not a theoretical path. In @xterm/addon-webgl 0.18.0 the constructor runs its own probe before xterm ever sees the addon:

if (!document.createElement("canvas").getContext("webgl2", {antialias:false, depth:false, preserveDrawingBuffer:true}))
  throw new Error("Webgl2 is only supported on Safari 16 and above");

Safari below 16 is the cited case, but note the probe uses different context attributes from supportsWebgl2() above it, and supportsWebgl2() caches its answer for the life of the process. So a platform that refuses preserveDrawingBuffer, or simply a moment when the engine is already at its context cap, fails here and not there — the two probes are not interchangeable.

Fixed by constructing inside a try that returns false, and onContextLoss moved into the existing try alongside loadAddon for the same reason (same one-line failure class, same handler).

Test result, before and after. Two tests were added first and run against the unmodified branch:

FAIL  falls back to the DOM renderer when the addon constructor rejects the platform
  AssertionError: promise rejected "Error: Webgl2 is only supported on Safari…" instead of resolving
  ❯ attach src/lib/terminalRenderer.ts:94:17
FAIL  leaves no pending claim behind when the constructor rejects the platform
  AssertionError: promise rejected "Error: Webgl2 is only supported on Safari…" instead of resolving

Tests  2 failed | 13 passed (15)

After the fix, same file: Tests 15 passed (15). The second test also pins that a constructor failure leaves no stale entry in inFlight, so the pane can try again later rather than joining a dead claim.

Lazy loading

import("@xterm/addon-webgl") on first use, matching the approach #37 introduces for addon-search. It lands in its own chunk, so the entry chunk grows by 1.33 kB rather than 101 kB.

Pinned to 0.18.0 exactly — the addon published alongside @xterm/xterm 5.5.0 — because addon/core mismatches cause subtle rendering bugs.

@xterm/xterm is now pinned exactly too, at 5.5.0 instead of ^5.5.0. Pinning one side of that pair and not the other did not describe the actual coupling. As the section above spells out, the DOM fallback this whole change rests on is the addon calling terminal._core._renderService.setRenderer(terminal._core._createRenderer()). None of _core, _renderService or _createRenderer appears in @xterm/xterm's public typings, so semver says nothing about them and an xterm patch release is free to rename or drop any one of them.

Pin both rather than loosen both, because of how it would fail. Lease.detach swallows a throwing dispose() on purpose, so that a renderer which cannot unwind does not take the pane down with it. That same catch means a renamed private method produces no error anywhere: evicted panes would silently keep their GPU contexts, and the budget would quietly stop bounding anything — the exact failure this PR exists to prevent, reintroduced by a patch bump and invisible until a user hits the cap. A loud break would be tolerable on a range; a silent one is not. The coupling is now written down next to the catch, with instructions to re-run the eviction and context-loss tests against the real addon on any bump. Resolution is unchanged (@xterm/xterm was already resolving to 5.5.0), so the lockfile delta is one line.

(Unrelated, deliberately not touched here: @xterm/addon-search is on ^0.16.0, which resolves to the xterm 6.0.0-era addon against a 5.5.0 core, and @xterm/addon-fit reaches into _core as well. Both predate this branch and belong in their own change.)

@xterm/addon-canvas was considered and left out

It would be a third renderer to test and a second chunk, and it only helps where WebGL2 is unavailable — where the DOM renderer already works and is already the fallback this change relies on. Not worth a dependency on the evidence available.

Measurements

Measured on this machine (Windows 11, RTX 5060 Ti), Playwright Chromium through ANGLE → D3D11, the same graphics path WebView2 uses on Windows, with vsync and the frame-rate limit disabled. 4 MiB of realistic output: a third heavily styled, a third plain bulk text, a third build-log lines.

Render-bound streaming — one 8 KiB chunk per animation frame, so the renderer draws every frame:

terminal DOM frame WebGL frame speedup DOM WebGL
120x40 4.54 ms 0.73 ms 6.2x 1.72 MiB/s 10.72 MiB/s
200x50 5.01 ms 0.90 ms 5.6x 1.56 MiB/s 8.68 MiB/s
80x24 1.85 ms 0.52 ms 3.6x 4.22 MiB/s 14.97 MiB/s

Burst drain — the whole corpus written as fast as term.write accepts it:

terminal DOM WebGL speedup
120x40 37.7 MiB/s 52.9 MiB/s 1.40x
200x50 38.5 MiB/s 41.9 MiB/s 1.09x
80x24 43.5 MiB/s 41.1 MiB/s 0.94x

The second table is the honest limit of this change: when output outruns the refresh rate xterm coalesces and draws only the final state, so the work is in the VT parser and the renderer barely matters. cat bigfile is close to that case. The 5-6x applies to sustained interactive-rate output.

Entry chunk 653.47 kB → 654.80 kB raw (181.03 → 181.46 kB gzip); new addon-webgl chunk 101.22 kB / 26.06 kB gzip, fetched when a pane first comes on screen.

The transcript leaves the DOM

The GPU renderer draws to a canvas, so .xterm-rows does not exist and a pane's text is not in the DOM. This is the part that deserves a decision rather than a default:

  • The browser regression suites depended on it. Five assertions across four files read the transcript from .xterm-rows, and they time out rather than failing loudly. tauri-fixture.js now reports no WebGL2 so those scenarios run on the DOM renderer — which is a genuinely supported configuration, the same path a machine without WebGL2 takes — and they keep proving exactly what they proved before, since routing, focus, lifecycle and fault isolation are all renderer-independent. A new terminal-gpu-renderer.mjs opts back in and covers the renderer the app actually ships with, so the shipped path is not left unverified.
  • Accessibility. The app sets screenReaderMode: false, so xterm builds no accessibility layer under either renderer. Today a screen reader traverses the DOM rows incidentally; under WebGL there is nothing to traverse. That is not xterm's intended accessibility path — screenReaderMode is, and it has its own cost — but it is a real reduction in what assistive technology can reach, and it is not something this PR can measure away. Happy to follow up with screenReaderMode wired to a setting if you want that closed before this lands.

Rendering is otherwise equivalent. Same fixture session, same output, dark theme:

DOM renderer:

Terminal on xterm's DOM renderer

GPU renderer:

Terminal on the WebGL renderer

Conflict with #49

#49 rewrites the data path in TerminalPane.vue (its hunks are around lines 147-260 — listener setup and connectSession). This change touches lines 13, 350 and 358 — the import, terminal construction and teardown — so TerminalPane.vue should merge cleanly.

The overlap is desktop/e2e/tauri-fixture.js: #49 edits around lines 8-21 and this inserts eleven lines at line 3, so the hunks share context and git will likely flag it. The resolution is mechanical — keep both, the WebGL2 guard at the top of the IIFE and #49's channel changes below it. Happy to rebase whichever way round they land.

Rust and core/src/session.rs are untouched; this is frontend-only.

Test plan

  • cd desktop && npx vue-tsc --noEmit — clean, exit 0
  • cd desktop && npm test329 passed across 38 files (308 before, +21: 15 for the budget module, 6 for the component wiring)
  • cd desktop/e2e && UI_RECORD=0 npm test72 scenarios pass, exit 0 (68 before, +4 GPU renderer scenarios)
  • node scripts/verify/check-comments.mjs{"ok":true,"count":0,"files":128}
  • node scripts/verify/control-clavyn.mjs doctor --pretty — all checks ok
  • cd desktop && npm run build — entry chunk +1.33 kB, addon in its own chunk

New coverage:

  • the addon attaches when a pane is on screen, and not while its tab is in the background or the workspace is hidden
  • WebGL2 unavailable, activation throwing, and the addon constructor throwing, all three leave a working DOM-rendered pane
  • the budget holds at eight however many panes mount, evicting least-recently-used and reverting each one
  • context loss disposes and falls back without throwing, including when the teardown itself throws
  • a released pane does not get attached if the module was still loading, and repeat claims share one context
  • end to end: a GPU-backed pane loses its context, falls back to the DOM renderer, keeps its session, and renders output written afterwards

One caveat on how this was verified, because it nearly produced a false pass: the browser suites target a dev server on 127.0.0.1:1420, and another checkout on this machine already held that port, so the first run drove the wrong build and reported 68/68 against code that did not contain this change. Every result above is from a dev server on a port confirmed to be serving this branch — for the follow-up run, UI_URL=http://127.0.0.1:5199 with the served /src/lib/terminalRenderer.ts fetched and checked to contain new module.WebglAddon() inside a try before the suite was started.

xterm was left on its DOM renderer, which rebuilds one span per styled run
per row on every frame. Loading @xterm/addon-webgl moves that to the GPU:
measured on this machine, a render-bound stream costs 4.5 ms per frame on
the DOM renderer and 0.73 ms on the GPU renderer at 120x40 (5.9x), and
1.7 MiB/s becomes 10.7 MiB/s. Output that arrives faster than the screen
refreshes is parser-bound, not render-bound, and gains only ~1.4x.

Two failure modes make this addon easy to ship badly, so both are handled
rather than assumed away.

A browser keeps 16 WebGL contexts alive per renderer process and silently
kills the oldest past that; the 17th terminal blanks the first for the
three seconds the addon waits for restoration. TerminalWorkspace mounts
every pane of every tab at once by design, so the pane count is not bounded
by what is on screen. A least-recently-used budget of eight panes bounds
the GPU-backed subset to half the cap: a pane claims a slot when it comes
to the front, and panes pushed out revert to the DOM renderer instead of
losing a context. Holding the claim across tab switches avoids paying the
19 ms reattach on every switch.

On context loss the addon is disposed, which restores xterm's DOM renderer
with the buffer intact, so a GPU reset costs rendering quality and never
the pane's contents.

The addon is imported on demand and lands in its own 101 kB chunk, leaving
the entry chunk 1.3 kB larger rather than 101 kB larger.

The GPU renderer draws to a canvas, so a pane's transcript is no longer in
the DOM. The browser regression scenarios read it from `.xterm-rows`, so
the fixture now reports no WebGL2 — the same path a machine without WebGL2
takes — and terminal-gpu-renderer.mjs opts back in to cover the renderer
the app ships with.
@greptile-apps

greptile-apps Bot commented Sep 10, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a budgeted WebGL terminal renderer with DOM fallback, context-loss recovery, lazy loading, and persisted screen-reader support. Existing terminals now apply screen-reader mode when the accessibility preference changes.

Confidence Score: 5/5

Safe to merge; the only remaining prior concern is non-blocking.

The previous terminal-output accessibility finding is fixed: terminal creation enables xterm screen-reader mode from the persisted setting, and open terminals update when that setting changes. One previous non-blocking accessibility finding remains outstanding: the Screen reader support switch has no accessible name, so screen-reader users cannot identify what the control changes.

Reviews (5): Last reviewed commit: "Say the default rather than who asks for..." | Re-trigger Greptile

observer = new ResizeObserver(() => fit());
observer.observe(containerRef.value);
fit();
if (props.visible) void acquireWebglRenderer(props.pane.id, term);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Keep terminal output accessible

Visible panes enable the WebGL renderer here, which replaces the terminal text rows with canvas output without enabling a screen-reader mode or maintaining an accessible transcript. Assistive technologies can access the input field but cannot read terminal output, preventing affected users from operating GPU-backed terminals. This must be fixed before merging.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Evidence from the check

  • The exact Playwright/Chromium script executed the DOM and WebGL terminal paths, injected a unique output marker, and inspected terminal DOM and accessibility-tree state; it provides reproducible validation.

Command output from the check

  • The command log records the executed command, `/home/user/repo` working directory, and exit code 0; the rendered accessibility check completed successfully.

Evidence from the check

  • The successful observed JSON records DOM rows and marker availability before WebGL and canvas-only output with no marker in DOM or Chromium accessibility tree after WebGL; it confirms the accessibility regression.

Terminal output with the DOM renderer baseline

  • The rendered DOM-renderer baseline shows the connected terminal after fixture output was injected; its output is backed by DOM rows.

▶ Recording of the check

  • The DOM-renderer recording shows the rendered terminal after output injection, providing the accessible-row baseline.

Terminal output after the WebGL renderer is enabled

  • The rendered WebGL terminal shows the visible output area after the same output injection; it is drawn by canvas rather than DOM rows.

▶ Recording of the check

  • The WebGL recording shows the changed canvas-rendered terminal state after output injection; terminal output has no validated accessible text alternative.

View artifacts

T-Rex Ran code and verified through T-Rex

@Sadykhzadeh

Copy link
Copy Markdown
Member Author

APPROVE WITH NITS

I re-ran everything against a dev server I confirmed was serving aa1de95 (curled /src/lib/terminalRenderer.ts off it before trusting a single result — thanks for calling that trap out in the description, it saved me the same false pass):

  • desktop/e2e: 72/72, exit 0 — including all four GPU scenarios.
  • desktop: 327 passed / 38 files; vue-tsc --noEmit clean.
  • node scripts/verify/check-comments.mjs{"ok":true,"count":0,"files":128}.

And I checked the two claims that would be quietly fatal if wrong:

Nits, worst first.

1. The budget's safety margin is measured on an engine the app ships on only one of three platforms. desktop/src/lib/terminalRenderer.ts:3-11 states the 16-context cap as a property of "a browser" and sets the invariant that "the cap must never be reached rather than merely survived". That measurement is Playwright Chromium; it carries to WebView2 on Windows, but the app also ships on WKWebView (macOS) and WebKitGTK (Linux), where the per-process WebGL context limit is not measured here and is not necessarily 16. If either is 8 or lower, a heavy workspace reaches the cap and the user gets exactly the three-second blank this change exists to prevent. The failure is graceful in the end, so it is not a blocker — but please either scope the comment to Chromium/WebView2 explicitly, or lower WEBGL_PANE_BUDGET on non-Chromium engines.

2. new module.WebglAddon() sits outside the try. terminalRenderer.ts:94 is one line above the try that wraps terminal.loadAddon(addon). The addon's constructor throws on Safari < 16 without WebGL2 (WebglAddon.ts:34-45), and both call sites are void acquireWebglRenderer(...) (TerminalPane.vue:353 and :365), so that path is an unhandled rejection, not a fallback. The pane is still fine — no lease is taken — but it is an untested branch: terminalRenderer.test.ts:71 only covers loadAddon throwing. One line into the try closes it.

3. The pin is on the wrong package. @xterm/addon-webgl is 0.18.0 exact, which is right, but @xterm/xterm stays ^5.5.0 (desktop/package.json:22-23). The entire fallback runs through terminal._core._createRenderer() — a private API. A 5.6.x that renames it breaks the fallback while the addon pin holds firm, and AGENTS.md asks for pinned versions. Pin the core to 5.5.0 too, at least while the addon depends on its internals.

4. The WebGL2 opt-out is wider than the five assertions that need it. tauri-fixture.js:9-14 suppresses WebGL2 for the whole fixture, and vite.config.ts:25 injects that same fixture into every browser dev session. So npm run dev in a browser and scripts/verify/control-clavyn.mjs — the canonical UI-verification path in AGENTS.md — now never exercise the renderer the app ships with. Only terminal-workspace.mjs:59, terminal-input-routing.mjs:163, terminal-keyboard-close.mjs:137,140 and terminal-fault-isolation.mjs:74 actually read .xterm-rows; the other ~63 scenarios are moved off the shipped renderer for free. I do not think this hides coverage — the four new scenarios genuinely cover the GPU path and they pass — but a per-suite opt-out plus a __clavynAllowWebgl flag on the control CLI would be tighter, and worth a line in scripts/verify/features/terminal.md.

5. One .xterm-rows reader is missing from the list. Both the issue and this description say five assertions across four files; scripts/verify/verify-fullscreen.mjs:41 is a sixth (hasContent, firstRow). It still works today because that script goes through the fixture, but it reports an empty pane silently the moment the harness opts in. Relatedly, scripts/verify/features/terminal.md:42 already asserts "text is not in the DOM", which was wrong before this change and is now true only outside the fixture — worth correcting in the same pass.

6. driverNoise is too broad. terminal-gpu-renderer.mjs:22 filters /WebGL|.../i out of the console-error assertion, which also swallows any application error whose text mentions WebGL — the exact category this suite exists to catch. Anchor it to the driver prefixes instead.

On accessibility. I think you have laid the decision out fairly and I would not hold the PR for it. One framing I would add for whoever decides: with screenReaderMode: false xterm builds no accessibility tree under either renderer, so what is being lost is an incidental affordance rather than a supported path — the trade is an accidental partial affordance for a measured 5-6x, and the real fix (screenReaderMode behind a setting) is orthogonal work that deserves its own issue rather than a gate on this one. Worth filing that issue as you merge so it does not evaporate.

The honesty about the burst-drain table is what makes the rest of this reviewable — leaving 0.94x at 80x24 in the description rather than quietly reporting the 6.2x is the right call.

new WebglAddon() sat outside the try. The addon runs its own webgl2 probe
in the constructor, with attributes our cached probe never used, and throws
before xterm ever sees it -- Safari below 16 being the case that always
throws. Inside an async function that became a rejected promise rather than
a fall back to the DOM renderer, and nothing covered it. Two tests now pin
the behaviour; both fail against the previous code.

Rewrite the budget comment to say what was actually measured. The cap of 16
and oldest-first eviction were observed on Chromium only; WKWebView and
WebKitGTK were never measured here, so cite WebKit's own maxActiveContexts
constant for those and mark them unverified rather than implying they were
tested. The budget stays at 8, with the reasoning written down.

Pin @xterm/xterm exactly to match @xterm/addon-webgl. The DOM fallback runs
through xterm's private _core._createRenderer(), which no public typing
covers and semver does not protect, and a broken dispose fails silently
because the teardown catch swallows it. Note the coupling next to the catch.
@greptile-apps

greptile-apps Bot commented Sep 11, 2026

Copy link
Copy Markdown

Want your agent to iterate on Greptile's feedback? Try greploops.

@computerbox124

Copy link
Copy Markdown
Member

@greptile-apps, can you review this PR and report the found issues?

A terminal draws its rows for the eye. xterm marks the DOM renderer's
row container aria-hidden and the GPU renderer draws to a canvas, so
neither is readable by assistive technology and terminal output has
never been exposed to it here.

What is readable is xterm's accessibility layer: it builds its own row
elements and a live region from the buffer, and takes nothing from the
renderer but its dimensions, so it works the same whichever one is
drawing. A setting turns it on, applies to terminals that are already
open, and is remembered. It costs a DOM tree per terminal, so it is off
until asked for -- and it does not cost the GPU renderer, which is the
point of keeping it independent of the drawing path.
@Sadykhzadeh

Copy link
Copy Markdown
Member Author

Addressed in 829d94b, though the finding's premise needs one correction first.

The DOM renderer is not accessible either. DomRenderer sets aria-hidden="true" on its row container and on its selection container (@xterm/xterm/src/browser/renderer/dom/DomRenderer.ts:69,73). So terminal output has never been readable by assistive technology in this app — not before this PR and not after it. GPU rendering does not regress accessibility here; it makes a gap that was already total no worse.

What is readable is xterm's accessibility layer, and it is independent of the renderer. AccessibilityManager builds its own .xterm-accessibility subtree — one element per row plus an aria-live region — from the buffer, and the only thing it takes from IRenderService is dimensions and render events (AccessibilityManager.ts:61-105,117,393-405). It is created by Terminal itself when screenReaderMode is on (Terminal.ts:259,554-559), not by any renderer. So the accessible path and the GPU path are not a trade — you can have both.

The change. A screenReaderMode setting, off by default because the layer costs a DOM tree per terminal, surfaced as a Screen reader support toggle in Settings, passed to new Terminal({...}), and watched so turning it on reaches terminals that are already open rather than only ones created afterwards. The WebGL budget is untouched: a pane with the accessibility layer on still claims a GPU slot.

Deliberately not auto-detected. There is no reliable way to detect a screen reader from a webview — the approaches that exist are heuristics that get it wrong in both directions, and a wrong guess either silently costs every terminal a DOM tree or silently withholds the layer from someone who needs it.

TestsTerminalAccessibility.regression.test.ts, five cases: off by default, on when enabled, still GPU-backed while on, reaches an already-open terminal, and survives a restart. Three fail if the wiring is removed from TerminalPane.vue, and the GPU one is the load-bearing assertion: it fails if the accessible path is ever made to give up the renderer.

vitest run: 334 passed across 39 files. vue-tsc --noEmit: clean.

On the earlier nits from my own review — the engine-scope comment on WEBGL_PANE_BUDGET, the new WebglAddon() construction sitting outside the try, and the @xterm/xterm pin — those are all addressed in a73ac94.

@computerbox124 всё готово, можно смотреть.

Comment on lines +219 to +226
<button
type="button"
role="switch"
:aria-checked="settings.screenReaderMode"
class="flex h-6 w-11 items-center rounded-full transition-colors duration-100 shrink-0"
:class="settings.screenReaderMode ? 'bg-primary' : 'bg-muted'"
@click="settings.setScreenReaderMode(!settings.screenReaderMode)"
>

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Name the screen-reader switch

The Screen reader support control is exposed as a switch without an accessible name. Screen readers announce only an unnamed switch and its state, so users cannot tell that it enables terminal output accessibility. Give the button an aria-label="Screen reader support" or reference the adjacent visible label with aria-labelledby. This is non-blocking, but makes the new accessibility preference difficult to identify for the people it is intended to support.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Artifacts

Evidence from the check

  • The authored Chromium Playwright script opens Settings, queries the target switch accessibility tree, records two captures, and writes the observed result; it is the executed source.

Command output from the check

  • The actual executed command output records the target’s role, accessible-name lookup count, DOM associations, and exit code 0; it shows the switch has no accessible name.

Evidence from the check

  • The test’s machine-generated result records the accessibility snapshot `- switch`, zero name-matched switches, and missing naming attributes; it confirms the accessible name is empty.

▶ Recording of the check

  • The initial rendered Settings view shows the visible Screen reader support text and its adjacent switch before focus; it establishes the tested UI scope.

Settings view before focusing the Screen reader support switch

  • The poster frame shows the visible Screen reader support text beside the unlabeled target switch; it establishes the tested UI scope.

▶ Recording of the check

  • The second rendered capture focuses the exact switch inspected by Playwright; it emphasizes the control whose accessibility tree exposes only `switch` without a name.

Settings view with the Screen reader support switch focused

  • The poster frame shows the focused target switch adjacent to Screen reader support; it identifies the exact rendered control with an empty accessible name.

View artifacts

T-Rex Ran code and verified through T-Rex

The comment hygiene check reads "unless asked for" as attribution. The
sentence states the default instead.
@Sadykhzadeh

Copy link
Copy Markdown
Member Author

Two follow-ups.

6e632e5 rephrases the screenReaderMode doc comment — "off unless asked for" reads as attribution to the hygiene check, so it states the default instead. That check only started working on Windows checkouts with #70; re-running the fixed scanner across all nineteen branch heads finds exactly two violations, this and one in #38, both now fixed.

Second, this branch conflicts with nothing else in the set. I merged all nineteen PRs into one tree to check the whole batch composes: cargo test --workspace 189 passed, vitest run 369 passed across 43 files, vue-tsc --noEmit clean, check-comments.mjs {"ok":true,"count":0,"files":137}. Order and the six overlaps that do exist are in #71.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Terminals render on xterm's DOM fallback renderer; sustained output costs 5-6x more per frame than it needs to

2 participants