mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
co-authored by
Michael Suchacz
parent
962b6850cf
commit
7ffeac711c
+15
-2
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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.
|
||||
Binary file not shown.
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user