fix: correct web terminal glyph rendering and tmux display (#25059)

The web terminal was rendering Claude Code and Codex incorrectly because
xterm's custom glyph renderer draws block and quadrant characters with
its own geometry. The reconnecting PTY screen backend also exposed
`screen.xterm-256color` to the user's shell, which made tmux rendering
issues harder to reason about.

This PR:

* Disables xterm custom glyph rendering so the selected terminal font
draws block and quadrant glyphs.
* Adds a tiny Powerline-only terminal symbol fallback font so common
prompt separators still render when custom glyphs are disabled.
* Configures the screen backend to keep the inner shell `TERM` aligned
with the browser terminal emulator, including background color erase
behavior.
* Tightens reconnecting PTY tests around prompt synchronization and
`TERM` assertions.

<!-- linear:table-colwidths:200,200 -->
| Before | After |
| -- | -- |
| <img
src="https://uploads.linear.app/e62091d9-44f5-421c-8e5c-df481fc99003/3c45efce-9d7e-43b4-b24f-88d4d23d294a/ba68155e-949e-4961-b0b2-124757cb07bb?signature=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXRoIjoiL2U2MjA5MWQ5LTQ0ZjUtNDIxYy04ZTVjLWRmNDgxZmM5OTAwMy8zYzQ1ZWZjZS05ZDdlLTQzYjQtYjI0Zi04OGQ0ZDIzZDI5NGEvYmE2ODE1NWUtOTQ5ZS00OTYxLWIwYjItMTI0NzU3Y2IwN2JiIiwiaWF0IjoxNzc4MTgxNjUwLCJleHAiOjE4MDk3NTIyMTB9.45f1ZzBpWOF5OCJV0xHfICdpyRQ1UoGMbJjLYPqeAkg
" alt="Before: Claude Code logo rendering is distorted in the web
terminal outside and inside tmux" width="640" /> | <img
src="https://uploads.linear.app/e62091d9-44f5-421c-8e5c-df481fc99003/26b0a109-5e21-4000-b1b5-ddac87c409d4/46a301c2-a815-419a-92d2-c51cecdefe40?signature=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXRoIjoiL2U2MjA5MWQ5LTQ0ZjUtNDIxYy04ZTVjLWRmNDgxZmM5OTAwMy8yNmIwYTEwOS01ZTIxLTQwMDAtYjFiNS1kZGFjODdjNDA5ZDQvNDZhMzAxYzItYTgxNS00MTlhLTkyZDItYzUxY2VjZGVmZTQwIiwiaWF0IjoxNzc4MTgxNjUwLCJleHAiOjE4MDk3NTIyMTB9.SQVwUbtaf2OrpjRJPkRH3uc0nPqad0bNBVvcRyuR6NQ
" alt="After: Claude Code logo renders correctly in the web terminal
outside and inside tmux" width="640" /> |

## Validation

* `go test ./agent -run '^TestAgent_ReconnectingPTY$' -count=1`
* `pnpm --dir site test -- src/theme/constants.test.ts`
* `pnpm --dir site lint:types`
* `pnpm --dir site check`
* `pnpm --dir site build`
* `git commit` pre-commit hook passed
* `git push` pre-push hook ran and printed the repo CI monitoring hint

> Mux worked on this PR on Mike's behalf.

---------

Co-authored-by: Michael Suchacz <203725896+ibetitsmike@users.noreply.github.com>
This commit is contained in:
Atif Ali
2026-05-20 13:57:50 +02:00
committed by GitHub
co-authored by Michael Suchacz
parent 962b6850cf
commit 7ffeac711c
11 changed files with 104 additions and 29 deletions
+15 -2
View File
@@ -2229,12 +2229,25 @@ func TestAgent_ReconnectingPTY(t *testing.T) {
return strings.Contains(line, "exit") || strings.Contains(line, "logout")
}
// Wait for the prompt before writing commands. If the command arrives before the prompt is written, screen
// will sometimes put the command output on the same line as the command and the test will flake
// Wait for the prompt before writing commands. If the command
// arrives before the prompt is written, screen will sometimes put
// the command output on the same line as the command and the test
// will flake.
require.NoError(t, tr1.ReadUntil(ctx, matchPrompt), "find prompt")
require.NoError(t, tr2.ReadUntil(ctx, matchPrompt), "find prompt")
data, err := json.Marshal(workspacesdk.ReconnectingPTYRequest{
Data: "printf '%s\\n' \"$TERM\"\r",
})
require.NoError(t, err)
_, err = netConn1.Write(data)
require.NoError(t, err)
require.NoError(t, tr1.ReadUntilString(ctx, "xterm-256color"), "find TERM output")
require.NoError(t, tr2.ReadUntilString(ctx, "xterm-256color"), "find TERM output")
require.NoError(t, tr1.ReadUntil(ctx, matchPrompt), "find prompt")
require.NoError(t, tr2.ReadUntil(ctx, matchPrompt), "find prompt")
data, err = json.Marshal(workspacesdk.ReconnectingPTYRequest{
Data: "echo test\r",
})
require.NoError(t, err)
+1 -1
View File
@@ -60,7 +60,7 @@ func newBuffered(ctx context.Context, logger slog.Logger, execer agentexec.Exece
// first argument so remove it.
cmdWithEnv := execer.PTYCommandContext(ctx, cmd.Path, cmd.Args[1:]...)
//nolint:gocritic
cmdWithEnv.Env = append(rpty.command.Env, "TERM=xterm-256color")
cmdWithEnv.Env = append(rpty.command.Env, "TERM="+xterm256Color)
cmdWithEnv.Dir = rpty.command.Dir
ptty, process, err := pty.Start(cmdWithEnv)
if err != nil {
+11 -5
View File
@@ -19,11 +19,17 @@ import (
"github.com/coder/coder/v2/pty"
)
// attachTimeout is the initial timeout for attaching and will probably be far
// shorter than the reconnect timeout in most cases; in tests it might be
// longer. It should be at least long enough for the first screen attach to be
// able to start up the daemon and for the buffered pty to start.
const attachTimeout = 30 * time.Second
const (
// attachTimeout is the initial timeout for attaching and will probably be far
// shorter than the reconnect timeout in most cases; in tests it might be
// longer. It should be at least long enough for the first screen attach to be
// able to start up the daemon and for the buffered pty to start.
attachTimeout = 30 * time.Second
// xterm256Color is the terminal type exposed to commands running in the web
// terminal.
xterm256Color = "xterm-256color"
)
// Options allows configuring the reconnecting pty.
type Options struct {
+9 -2
View File
@@ -103,6 +103,13 @@ func newScreen(ctx context.Context, logger slog.Logger, execer agentexec.Execer,
// output when scrolling back with the mouse wheel (copy mode still works
// since that is screen itself scrolling).
"altscreen on",
// Match the background color erase capability advertised by xterm-256color.
"defbce on",
// Keep the shell environment aligned with the web terminal emulator. Some
// terminal applications, including tmux, render differently when they see
// screen.xterm-256color even though screen is only an implementation
// detail for reconnecting.
"term " + xterm256Color,
// Remap the control key to C-s since C-a may be used in applications. C-s
// is chosen because it cannot actually be used because by default it will
// pause and C-q to resume will just kill the browser window. We may not
@@ -230,7 +237,7 @@ func (rpty *screenReconnectingPTY) doAttach(ctx context.Context, conn net.Conn,
// pty.Cmd duplicates Path as the first argument so remove it.
}, rpty.command.Args[1:]...)...)
//nolint:gocritic
cmd.Env = append(rpty.command.Env, "TERM=xterm-256color")
cmd.Env = append(rpty.command.Env, "TERM="+xterm256Color)
cmd.Dir = rpty.command.Dir
ptty, process, err := pty.Start(cmd, pty.WithPTYOption(
pty.WithSSHRequest(ssh.Pty{
@@ -346,7 +353,7 @@ func (rpty *screenReconnectingPTY) sendCommand(ctx context.Context, command stri
"-X", command,
)
//nolint:gocritic
cmd.Env = append(rpty.command.Env, "TERM=xterm-256color")
cmd.Env = append(rpty.command.Env, "TERM="+xterm256Color)
cmd.Dir = rpty.command.Dir
cmd.Stdout = &stdout
err := cmd.Run()
@@ -164,6 +164,12 @@ export const WorkspaceTerminal = ({
const nextTerminal = new Terminal({
allowProposedApi: true,
allowTransparency: true,
// Use the selected terminal font for block element, box drawing,
// quadrant, and powerline glyphs. xterm's custom glyph renderer helps
// when fonts lack powerline glyphs, but it changes block and quadrant
// shapes and breaks TUIs that use them for pixel art, like Claude
// Code's logo.
customGlyphs: false,
disableStdin: false,
fontFamily: terminalFontFamily,
fontSize: 16,
+9
View File
@@ -0,0 +1,9 @@
import { terminalFonts } from "./constants";
describe("terminalFonts", () => {
it("uses the terminal symbol fallback before generic monospace", () => {
for (const fontFamily of Object.values(terminalFonts)) {
expect(fontFamily).toMatch(/'Coder Terminal Symbols', monospace$/);
}
});
});
+19 -19
View File
@@ -3,30 +3,30 @@ import type { TerminalFontName } from "#/api/typesGenerated";
export const borderRadius = 8;
const MONOSPACE_DEFAULT_FONT = "Geist Mono Variable";
const TERMINAL_SYMBOL_FONT = "'Coder Terminal Symbols'";
export const MONOSPACE_FONT_FAMILY =
"'Geist Mono Variable', 'IBM Plex Mono', 'Lucida Console', 'Lucida Sans Typewriter', 'Liberation Mono', 'Monaco', 'Courier New', Courier, monospace";
export const BODY_FONT_FAMILY = `"Geist Variable", system-ui, sans-serif`;
export const terminalFonts: Record<TerminalFontName, string> = {
"fira-code": MONOSPACE_FONT_FAMILY.replace(
MONOSPACE_DEFAULT_FONT,
"Fira Code",
),
"jetbrains-mono": MONOSPACE_FONT_FAMILY.replace(
MONOSPACE_DEFAULT_FONT,
"JetBrains Mono",
),
"source-code-pro": MONOSPACE_FONT_FAMILY.replace(
MONOSPACE_DEFAULT_FONT,
"Source Code Pro",
),
"ibm-plex-mono": MONOSPACE_FONT_FAMILY.replace(
MONOSPACE_DEFAULT_FONT,
"IBM Plex Mono",
),
"geist-mono": MONOSPACE_FONT_FAMILY,
const withTerminalSymbolFallback = (fontFamily: string) =>
fontFamily.replace(", monospace", `, ${TERMINAL_SYMBOL_FONT}, monospace`);
"": MONOSPACE_FONT_FAMILY,
export const terminalFonts: Record<TerminalFontName, string> = {
"fira-code": withTerminalSymbolFallback(
MONOSPACE_FONT_FAMILY.replace(MONOSPACE_DEFAULT_FONT, "Fira Code"),
),
"jetbrains-mono": withTerminalSymbolFallback(
MONOSPACE_FONT_FAMILY.replace(MONOSPACE_DEFAULT_FONT, "JetBrains Mono"),
),
"source-code-pro": withTerminalSymbolFallback(
MONOSPACE_FONT_FAMILY.replace(MONOSPACE_DEFAULT_FONT, "Source Code Pro"),
),
"ibm-plex-mono": withTerminalSymbolFallback(
MONOSPACE_FONT_FAMILY.replace(MONOSPACE_DEFAULT_FONT, "IBM Plex Mono"),
),
"geist-mono": withTerminalSymbolFallback(MONOSPACE_FONT_FAMILY),
"": withTerminalSymbolFallback(MONOSPACE_FONT_FAMILY),
};
export const terminalFontLabels: Record<TerminalFontName, string> = {
"geist-mono": "Geist Mono",
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2014 Ryan L McIntyre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -0,0 +1,12 @@
/*
* Powerline fallback glyphs subset from Symbols Nerd Font Mono v3.4.0.
* Source: https://github.com/ryanoasis/nerd-fonts/releases/tag/v3.4.0
*/
@font-face {
font-family: "Coder Terminal Symbols";
font-style: normal;
font-display: swap;
font-weight: 400 700;
src: url("./coder-terminal-symbols-powerline.woff2?no-inline") format("woff2");
unicode-range: U+E0A0, U+E0B0-E0BF;
}
+1
View File
@@ -5,6 +5,7 @@ import "@fontsource/ibm-plex-mono/600.css";
import "@fontsource-variable/geist";
import "@fontsource-variable/geist-mono";
// Alternative fonts for Terminal
import "./fonts/coder-terminal-symbols.css";
import "@fontsource/fira-code/400.css";
import "@fontsource/fira-code/600.css";
import "@fontsource/source-code-pro/400.css";