Fix terminal input reordering and Linux terminal rendering
Secret Scan / scan (push) Successful in 4s
Build App (Preview) / compute-version (pull_request) Successful in 4s
Secret Scan / scan (pull_request) Successful in 4s
Build App (Preview) / create-release (pull_request) Successful in 2s
Build App (Preview) / build-macos (pull_request) Successful in 2m41s
Build App (Preview) / build-linux (pull_request) Successful in 5m25s
Build App (Preview) / build-windows (pull_request) Successful in 5m32s
Build App (Preview) / prune-previews (pull_request) Successful in 8s

Two separate defects behind the same report: typing in a container terminal
is sluggish on Linux, and a backspace can land *after* the characters typed
behind it.

The web terminal was the control that separated them. It shares the Docker
exec, the PTY, `exec_manager`, the input channel and its serial writer task,
and xterm.js itself — and it does not exhibit either symptom. Only three
things differ, and each accounts for part of the report.

**Input ordering.** Every keystroke was its own `invoke("terminal_input")`.
That command is `async`, so Tauri spawns each one as an independent task, and
those tasks then race for the session mutex in `ExecSessionManager::send_input`
— nothing preserved the order the bytes were typed in. The serial writer
downstream cannot help, because the order is already lost before anything
reaches the channel. The web terminal gets ordering for free by awaiting
`send_input` inline in a single WebSocket reader loop.

`useTerminal` now holds a per-session queue: one write in flight at a time,
the next only after the previous resolves. Anything typed meanwhile coalesces
into the next chunk, which also collapses a burst of typing into a couple of
IPC round trips rather than one per key. The queue is module scope, not hook
scope, because `useTerminal()` is called from several components — a per-hook
queue would leave speech-to-text, image paste and typing racing each other.
Each caller's promise still settles only when its own bytes have gone, so
`await sendInput(...)` keeps its meaning.

**The DMA-BUF escape hatch did not exist.** `apply_webkit_wayland_workaround`
left any pre-set value alone, including `0`, on a stated assumption that
WebKitGTK reads the variable as a boolean. It reads presence, so
`WEBKIT_DISABLE_DMABUF_RENDERER=0` disabled DMA-BUF exactly like `=1`, and no
value a user could set got the accelerated path back. `0`/`false`/`no`/empty
now remove the variable, which is the only thing WebKitGTK reads as enabled.
The default is unchanged: unset still means disabled on Linux.

**WebGL does not degrade to canvas here.** The comment on that workaround
assumed `@xterm/addon-webgl` would fall back to the canvas renderer once
DMA-BUF was off. Its constructor throws only when WebGL is *absent*, and with
DMA-BUF disabled WebGL is still present — served by software rasterisation.
So the addon loads and every frame is rendered on the CPU, slower than the
canvas renderer it was assumed to fall back to. `AppSettings::terminal_gpu_
rendering` decides whether it loads at all: `None` is auto (on for macOS and
Windows, off on Linux), `Some(_)` forces it either way from Settings →
Terminal. `Option<bool>` rather than `bool` so the zero value means "we
choose" instead of pinning every existing settings file to one answer.

Verified: 643 frontend tests and 530 Rust tests pass, clippy clean, secret
scan clean. The Linux rendering half needs confirming on a real desktop —
neither symptom reproduces in a headless container.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ApLYH6ybHwQFkMCtKuHrrV
This commit is contained in:
2026-08-28 12:51:18 -07:00
co-authored by Claude Opus 5
parent 88d6bed6db
commit 3a49a67c1f
9 changed files with 424 additions and 18 deletions
@@ -15,6 +15,8 @@ import type { EnvVar } from "../../lib/types";
import Tooltip from "../ui/Tooltip";
import AccordionSection from "../ui/AccordionSection";
import Toggle from "../ui/Toggle";
import SegmentedControl from "../ui/SegmentedControl";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import WebTerminalSettings from "./WebTerminalSettings";
import SttSettings from "./SttSettings";
import SharedAuthSettings from "./SharedAuthSettings";
@@ -67,6 +69,14 @@ export default function SettingsPanel() {
}
};
const handleGpuRenderingChange = async (value: "auto" | "on" | "off") => {
if (!appSettings) return;
await saveSettings({
...appSettings,
terminal_gpu_rendering: value === "auto" ? null : value === "on",
});
};
const handleAutoCheckToggle = async () => {
if (!appSettings) return;
await saveSettings({ ...appSettings, auto_check_updates: !appSettings.auto_check_updates });
@@ -242,6 +252,45 @@ export default function SettingsPanel() {
<SttSettings />
</AccordionSection>
<AccordionSection id="terminal" title="Terminal" defaultOpen={false}>
<div className="space-y-2">
<label className="text-xs text-[var(--text-secondary)]">GPU rendering</label>
<SegmentedControl
label="Terminal GPU rendering"
value={
appSettings?.terminal_gpu_rendering == null
? "auto"
: appSettings.terminal_gpu_rendering
? "on"
: "off"
}
onChange={handleGpuRenderingChange}
segments={[
{
value: "auto",
label: "Auto",
hint: resolveTerminalGpuRendering(null, navigator.userAgent)
? "On for this platform."
: "Off on Linux — the DMA-BUF workaround leaves WebGL on software rendering, which is slower than the canvas renderer.",
},
{
value: "on",
label: "On",
hint: "Always load the WebGL renderer.",
},
{
value: "off",
label: "Off",
hint: "Always use xterm's canvas renderer. Try this if typing feels laggy.",
},
]}
/>
<p className="text-xs text-[var(--text-secondary)]">
Takes effect when a terminal tab is next switched to.
</p>
</div>
</AccordionSection>
<AccordionSection id="updates" title="Updates" defaultOpen={false}>
<div className="space-y-2">
{appVersion && (
+26 -9
View File
@@ -28,6 +28,7 @@ import UrlToast, {
URL_TOAST_SHORTCUT,
} from "./UrlToast";
import { trimSelection } from "./trimSelection";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import TerminalContextMenu from "./TerminalContextMenu";
interface Props {
@@ -95,6 +96,7 @@ export default function TerminalView({ sessionId, active }: Props) {
const webglRef = useRef<WebglAddon | null>(null);
const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const gpuRenderingSetting = useAppState(s => s.appSettings?.terminal_gpu_rendering ?? null);
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
@@ -491,7 +493,11 @@ export default function TerminalView({ sessionId, active }: Props) {
// Handle user input -> backend
const inputDisposable = term.onData((data) => {
sendInput(sessionId, data);
// Ordered and coalesced by the queue in `useTerminal`; a rejection here
// means the session is gone, which the exit listener already reports.
sendInput(sessionId, data).catch((e) =>
console.error("Failed to send terminal input:", e)
);
});
// Detect user-initiated scroll-up (mouse wheel) to pause auto-follow.
@@ -684,7 +690,16 @@ export default function TerminalView({ sessionId, active }: Props) {
const term = termRef.current;
if (!term) return;
if (active) {
// Auto on macOS/Windows, off on Linux, overridable either way — see
// `resolveTerminalGpuRendering`. Loading the addon under a software-GL
// WebKitGTK is slower than xterm's canvas renderer, not faster.
const useGpu = resolveTerminalGpuRendering(gpuRenderingSetting, navigator.userAgent);
// The renderer and the activation work are independent: a terminal with
// GPU rendering switched off still has to fit and take focus when its tab
// becomes active. Keeping these in one branch made "GPU off" silently mean
// "never re-fit, never focus".
if (active && useGpu) {
// Attach WebGL renderer
if (!webglRef.current) {
try {
@@ -699,19 +714,21 @@ export default function TerminalView({ sessionId, active }: Props) {
// WebGL not available, canvas renderer is fine
}
}
} else if (webglRef.current) {
// Release the context — for inactive terminals, and when the setting
// turns GPU rendering off while this terminal is on screen.
try { webglRef.current.dispose(); } catch { /* ignore */ }
webglRef.current = null;
}
if (active) {
fitRef.current?.fit();
if (autoFollowRef.current) {
term.scrollToBottom();
}
term.focus();
} else {
// Release WebGL context for inactive terminals
if (webglRef.current) {
try { webglRef.current.dispose(); } catch { /* ignore */ }
webglRef.current = null;
}
}
}, [active]);
}, [active, gpuRenderingSetting]);
// Auto-dismiss toast after 30 seconds — unless the user is standing in it.
// A keyboard user who has just jumped into the toast is mid-decision, and