Skip to content
Closed
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
Binary file added experiments/screenshots/image1-slider.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added experiments/screenshots/image2-plan.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added experiments/screenshots/image3-strike.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added experiments/screenshots/image4-system-bug.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
75 changes: 75 additions & 0 deletions experiments/terminal-font-ready-analysis.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
# Terminal rendering bugs investigation — issue #273

## Symptoms reported

Screenshots gathered from the issue and follow-up comments (saved under
`experiments/screenshots/`):

| File | Visible defect |
| --- | --- |
| `image1-slider.png` | Typed text (`asd`) appears below the prompt bar instead of inside it; the input slider falls outside the visible row. |
| `image2-plan.png` | Claude Code plan dialog (`Yes, and bypass permissions / manually approve / refine / Tell Claude what to change`) is visible but key presses do not select an option. |
| `image3-strike.png` | A line of typed text (`больше уязвимостей в коде нету?`) renders with a horizontal stroke crossing every character cell. |
| `image4-system-bug.png` | Chrome devtools issue panel reports "A form field element should have an id or name attribute" twice. |

All four are observed in the web terminal that hosts the Claude Code TUI
(see `packages/app/src/web/terminal-panel-runtime-core.ts`).

## Root cause

xterm.js measures the character cell once when `terminal.open(host)` is
called and again whenever `FitAddon.fit()` runs. The measurement is taken
from a canvas `measureText` call using the configured `fontFamily`.

`packages/app/src/web/terminal-panel-runtime-core.ts:79` configures
`fontFamily: "'IBM Plex Mono', 'SFMono-Regular', monospace"`. `IBM Plex
Mono` is loaded asynchronously from Google Fonts in
`packages/app/index.html:9-12`:

```html
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=Space+Grotesk:wght@500;700&display=swap"
rel="stylesheet"
/>
```

`display=swap` means the browser paints the fallback (`SFMono-Regular` or
generic `monospace`) until the webfont arrives. xterm.js initialises
before the swap, caches the fallback metrics, and never rechecks them. As
soon as `IBM Plex Mono` is applied the on-screen glyphs grow/shrink
relative to the cell grid, which is what produces:

* the cursor/prompt offset in `image1-slider.png`;
* the apparent strikethrough in `image3-strike.png` — every cell paints a
background underline/strikethrough decoration on its baseline, but the
glyphs from the new font are rendered at a different vertical offset,
so the decoration cuts through the middle of the characters;
* the unclickable plan options in `image2-plan.png` — xterm.js maps the
pointer to the wrong text cell because the cached cell width no longer
matches the painted width, so the buttons (`1/2/3/4` rows rendered by
the TUI) never receive focus when clicked.

`image4-system-bug.png` is unrelated to fonts: the "Show system" toggle
in `packages/app/src/web/panel-tasks.tsx:54` declares an `<input
type="checkbox">` without `id`/`name`, and Chrome's Issues panel flags
two such elements.

## Fix outline

1. Resolve `IBM Plex Mono` from `document.fonts.ready` (and explicit
`document.fonts.load(...)` requests) before mounting the xterm.js
instance. While the font is still loading, keep the host hidden so
that the canvas measurement is taken with the final font metrics.
2. After mount, re-run `fitAddon.fit()` whenever `document.fonts` reports
that the relevant face has loaded, to handle slow networks.
3. Provide an `id` and `name` on the checkbox so Chrome stops flagging
it.

## Verification

* `terminal-font-readiness.test.ts` exercises the new helper with mock
`FontFaceSet` implementations covering the happy path, the timeout
path, and the environment without `document.fonts` support.
* Manual: run `bun run --cwd packages/app dev:web`, throttle network to
"Slow 3G" in devtools, and confirm the terminal stays empty until the
webfont arrives, then renders without strikethrough artifacts.
2 changes: 2 additions & 0 deletions packages/app/src/web/panel-tasks.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const TaskSystemToggle = (
<label style={systemToggleStyle}>
<input
checked={includeDefault}
id="task-panel-show-system"
name="task-panel-show-system"
onChange={(event) => {
onIncludeDefaultChange(event.currentTarget.checked)
}}
Expand Down
103 changes: 103 additions & 0 deletions packages/app/src/web/terminal-font-readiness.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Effect } from "effect"

type FontLoadResult = ReadonlyArray<object>

export type FontReadinessTarget = {
readonly load: (descriptor: string) => PromiseLike<FontLoadResult>
readonly ready: PromiseLike<object>
}

export type DelayScheduler = {
readonly schedule: (callback: () => void, delayMs: number) => () => void
}

export type TerminalFontReadinessArgs = {
readonly descriptors: ReadonlyArray<string>
readonly fonts: FontReadinessTarget | undefined
readonly scheduler?: DelayScheduler
readonly timeoutMs?: number
}

const defaultTimeoutMs = 2000

const awaitSettled = <A>(thenable: PromiseLike<A>): Effect.Effect<void> =>
Effect.async((resume: (effect: Effect.Effect<void>) => void) => {
thenable.then(
() => {
resume(Effect.void)
},
() => {
resume(Effect.void)
}
)
})

const loadDescriptor = (
fonts: FontReadinessTarget,
descriptor: string
): Effect.Effect<void> => awaitSettled(fonts.load(descriptor))

const awaitFontsReady = (fonts: FontReadinessTarget): Effect.Effect<void> => awaitSettled(fonts.ready)

const ensureFontsLoaded = (
fonts: FontReadinessTarget,
descriptors: ReadonlyArray<string>
): Effect.Effect<void> =>
Effect.all(
[awaitFontsReady(fonts), ...descriptors.map((descriptor) => loadDescriptor(fonts, descriptor))],
{ concurrency: "unbounded" }
).pipe(Effect.asVoid)

const delayedFallback = (
timeoutMs: number,
scheduler: DelayScheduler
): Effect.Effect<void> =>
Effect.async((resume: (effect: Effect.Effect<void>) => void) => {
const cancel = scheduler.schedule(() => {
resume(Effect.void)
}, timeoutMs)
return Effect.sync(() => {
cancel()
})
})

const defaultScheduler: DelayScheduler = {
schedule: (callback, delayMs) => {
const handle = globalThis.setTimeout(callback, delayMs)
return () => {
globalThis.clearTimeout(handle)
}
}
}

const resolveScheduler = (args: TerminalFontReadinessArgs): DelayScheduler => args.scheduler ?? defaultScheduler

const fontReadinessEffect = (
fonts: FontReadinessTarget,
args: TerminalFontReadinessArgs
): Effect.Effect<void> => {
const timeoutMs = args.timeoutMs ?? defaultTimeoutMs
const work = ensureFontsLoaded(fonts, args.descriptors)
if (timeoutMs <= 0) {
return work
}
return Effect.race(work, delayedFallback(timeoutMs, resolveScheduler(args)))
}

export const awaitTerminalFontReadiness = (
args: TerminalFontReadinessArgs
): Effect.Effect<void> => {
if (args.fonts === undefined) {
return Effect.void
}
return fontReadinessEffect(args.fonts, args)
}

type GlobalThisWithDocument = {
readonly document?: { readonly fonts: FontReadinessTarget }
}

export const resolveDocumentFontFaceSet = (): FontReadinessTarget | undefined => {
const globals: GlobalThisWithDocument = globalThis
return globals.document?.fonts
}
3 changes: 1 addition & 2 deletions packages/app/src/web/terminal-image-paths.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@ const imagePathPattern = new RegExp(
"giu"
)

const treePointerImagePathSource =
String.raw`[^\s"'(<>\[\]{}|\\/][^\s"'(<>\[\]{}|\\]*\.(?:${extensionAlternation})`
const treePointerImagePathSource = String.raw`[^\s"'(<>\[\]{}|\\/][^\s"'(<>\[\]{}|\\]*\.(?:${extensionAlternation})`
const treePointerImagePathPattern = new RegExp(
String.raw`(?:^|\s)[└├]\s+(${treePointerImagePathSource})(?=$|[\s"')<>\[\]{}|.,;:?!])`,
"giu"
Expand Down
38 changes: 37 additions & 1 deletion packages/app/src/web/terminal-panel-runtime-core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { Effect, Either } from "effect"
import { Terminal } from "xterm"
import { FitAddon } from "xterm-addon-fit"

import { awaitTerminalFontReadiness, resolveDocumentFontFaceSet } from "./terminal-font-readiness.js"
import { resolveTerminalImageFetchUrl } from "./terminal-image-url.js"
import { splitTerminalInlineImageOutput, type TerminalInlineImageOutputSegment } from "./terminal-inline-images-core.js"
import {
Expand All @@ -29,6 +30,11 @@ import { installTerminalQuerySuppression } from "./terminal-query-suppression.js
import { resolveTerminalReconnectDelay, terminalReconnectGraceMs } from "./terminal-reconnect.js"
import { parseTerminalServerMessage, resolveTerminalWebSocketUrl } from "./terminal.js"

const terminalFontDescriptors: ReadonlyArray<string> = [
"14px 'IBM Plex Mono'",
"bold 14px 'IBM Plex Mono'"
]

type TerminalClientMessage =
| { readonly data: string; readonly type: "input" }
| { readonly cols: number; readonly rows: number; readonly type: "resize" }
Expand Down Expand Up @@ -71,7 +77,36 @@ const clearReconnectTimer = (lifecycle: TerminalLifecycleState): void => {
}
}

export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime => {
const refitWhenFontsLoaded = (
fitAddon: FitAddon,
terminal: Terminal,
onResize: (() => void) | undefined
): void => {
const fonts = resolveDocumentFontFaceSet()
if (fonts === undefined) {
return
}
Effect.runFork(
awaitTerminalFontReadiness({ descriptors: terminalFontDescriptors, fonts }).pipe(
Effect.tap(() =>
Effect.sync(() => {
runOptionalTerminalOperation(() => {
fitAddon.fit()
})
runOptionalTerminalOperation(() => {
terminal.refresh(0, terminal.rows - 1)
})
onResize?.()
})
)
)
)
}

export const createTerminalRuntime = (
host: HTMLDivElement,
onFontMetricsSettled?: () => void
): TerminalRuntime => {
const terminal = new Terminal({
allowProposedApi: true,
convertEol: false,
Expand All @@ -86,6 +121,7 @@ export const createTerminalRuntime = (host: HTMLDivElement): TerminalRuntime =>
terminal.open(host)
fitAddon.fit()
terminal.focus()
refitWhenFontsLoaded(fitAddon, terminal, onFontMetricsSettled)
return { fitAddon, terminal }
}

Expand Down
31 changes: 23 additions & 8 deletions packages/app/src/web/terminal-panel-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,27 @@ const resolveMountHost = (
return hostRef.current
}

type TerminalMountContext = {
readonly lifecycle: ReturnType<typeof createLifecycleState>
readonly sendResize: () => void
readonly socketRef: TerminalSocketRef
readonly terminal: ReturnType<typeof createTerminalRuntime>["terminal"]
}

const initialiseMountContext = (host: HTMLDivElement): TerminalMountContext => {
const lifecycle = createLifecycleState()
const socketRef: TerminalSocketRef = { current: null }
const sendResizeRef: { current: () => void } = { current: () => {} }
const { fitAddon, terminal } = createTerminalRuntime(host, () => {
sendResizeRef.current()
})
const sendResize = (): void => {
sendTerminalResize(fitAddon, socketRef, terminal)
}
sendResizeRef.current = sendResize
return { lifecycle, sendResize, socketRef, terminal }
}

const mountTerminalSession = (
{ connectionRef, hostRef, notifyMessage, onAttachFailure, runtimeRef, session, setStatus }: TerminalLifecycleArgs
): (() => void) | undefined => {
Expand All @@ -87,14 +108,8 @@ const mountTerminalSession = (
}

connectionRef.current = { closing: false, opened: false }
const lifecycle = createLifecycleState()
const socketRef: TerminalSocketRef = { current: null }
const { fitAddon, terminal } = createTerminalRuntime(host)
const terminalInputController = createTerminalInputController(terminal, socketRef)
const { lifecycle, sendResize, socketRef, terminal } = initialiseMountContext(host)
const pasteGuard = createTerminalPasteGuard()
const sendResize = () => {
sendTerminalResize(fitAddon, socketRef, terminal)
}
const resizeObserver = observeTerminalResize(host, sendResize)
const inputDisposable = attachTerminalInput(terminal, socketRef, pasteGuard)
const imagePasteDisposable = attachTerminalImagePaste({ host, notifyMessage, pasteGuard, socketRef, terminal })
Expand All @@ -119,7 +134,7 @@ const mountTerminalSession = (
terminal
})

runtimeRef.current = terminalInputController
runtimeRef.current = createTerminalInputController(terminal, socketRef)
attachGlobalResizeListeners(sendResize)
connectSocket()

Expand Down
Loading