Skip to content
Open
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
27 changes: 26 additions & 1 deletion packages/opencode/src/cli/cmd/tui/app.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ import { TuiConfigProvider, useTuiConfig } from "./context/tui-config"
import { TuiConfig } from "@/cli/cmd/tui/config/tui"
import { createTuiApi, TuiPluginRuntime, type RouteMap } from "./plugin"
import { FormatError, FormatUnknownError } from "@/cli/error"
import { isPlainTerminal } from "./util/terminal"
import { isPlainTerminal, needsTextSizingDisabled } from "./util/terminal"

import type { EventSource } from "./context/sdk"
import { DialogVariant } from "./component/dialog-variant"
Expand Down Expand Up @@ -149,6 +149,31 @@ export function tui(input: {
}

const plainTerminal = isPlainTerminal()

// VTE-based terminals (MATE Terminal, GNOME Terminal, etc.) do not support
// the OSC 66 explicit-width protocol used by opentui. When unsupported, VTE
// prints the raw escape sequence as literal text ("]66;w=1;…"), and because
// this moves the cursor, opentui incorrectly concludes the terminal supports
// explicit_width and then emits OSC 66 for every rendered grapheme, causing
// continuous garbled output throughout the TUI.
//
// OPENTUI_FORCE_EXPLICIT_WIDTH=0 fixes this in two ways:
// 1. Sets skip_explicit_width_query=true → detection queries are never sent
// 2. Forces caps.explicit_width=false → rendering never emits OSC 66
//
// OPENTUI_FORCE_WCWIDTH=1 additionally uses the standard wcwidth algorithm
// for CJK character-width measurement instead of Unicode mode 2027, which
// VTE terminals also do not support.
//
// Both env vars must be set before createCliRenderer so the native Zig
// renderer picks them up when its constructor forwards env vars via
// setTerminalEnvVar (verified against @opentui/core 0.1.101). ??= leaves any
// explicit user-provided opentui override untouched.
if (needsTextSizingDisabled()) {
process.env.OPENTUI_FORCE_EXPLICIT_WIDTH ??= "0"
process.env.OPENTUI_FORCE_WCWIDTH ??= "1"
}

const renderer = await createCliRenderer(rendererConfig(input.config, plainTerminal))
// 默认使用 dark 模式(不跟随终端背景);用户手动切换后会被 theme_mode_lock 记住并优先。
const mode = "dark"
Expand Down
24 changes: 24 additions & 0 deletions packages/opencode/src/cli/cmd/tui/util/terminal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ export function isPlainTerminal(input?: { platform?: NodeJS.Platform; termProgra
return isMacNativeTerminal(input)
}

/**
* Returns true when opentui's OSC 66 explicit-width protocol should be disabled.
*
* VTE-based terminals (MATE Terminal, GNOME Terminal, Xfce Terminal, etc.) do
* not understand OSC 66. Instead of ignoring the sequence they print it as
* literal text, which moves the cursor. opentui reads the cursor movement as a
* positive capability signal, then emits OSC 66 for every rendered grapheme,
* which VTE again prints literally—causing continuous garbled output in the TUI.
*
* VTE exports VTE_VERSION automatically, so that is used as the detection signal.
* Users can override auto-detection with MIMOCODE_DISABLE_TEXT_SIZING:
* =1 force-disable (apply the workaround regardless of terminal type)
* =0 force-enable (skip the workaround even on VTE terminals)
*/
export function needsTextSizingDisabled(input?: { vteVersion?: string; disableTextSizing?: string }): boolean {
const disable = input?.disableTextSizing ?? process.env.MIMOCODE_DISABLE_TEXT_SIZING
if (disable === "false" || disable === "0") return false
if (disable === "true" || disable === "1") return true
// VTE_VERSION is set by all VTE-based terminals (MATE Terminal, GNOME Terminal,
// Xfce Terminal, Tilix, Guake, etc.)
const vteVersion = input?.vteVersion ?? process.env.VTE_VERSION
return vteVersion !== undefined && vteVersion !== ""
}

function parse(color: string): RGBA | null {
if (color.startsWith("rgb:")) {
const parts = color.substring(4).split("/")
Expand Down
58 changes: 58 additions & 0 deletions packages/opencode/test/cli/tui/terminal.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { expect, test, describe } from "bun:test"

const { needsTextSizingDisabled, isMacNativeTerminal, isPlainTerminal } = await import(
"../../../src/cli/cmd/tui/util/terminal"
)

describe("needsTextSizingDisabled", () => {
test("returns true for VTE terminals (VTE_VERSION set)", () => {
// MATE Terminal / GNOME Terminal export a numeric VTE_VERSION like "6800".
expect(needsTextSizingDisabled({ vteVersion: "6800" })).toBe(true)
})

test("returns false on a non-VTE terminal with no override", () => {
expect(needsTextSizingDisabled({})).toBe(false)
})

test("treats an empty VTE_VERSION as not-a-VTE", () => {
expect(needsTextSizingDisabled({ vteVersion: "" })).toBe(false)
})

test("MIMOCODE_DISABLE_TEXT_SIZING=1/true forces the workaround on any terminal", () => {
expect(needsTextSizingDisabled({ disableTextSizing: "1" })).toBe(true)
expect(needsTextSizingDisabled({ disableTextSizing: "true" })).toBe(true)
})

test("MIMOCODE_DISABLE_TEXT_SIZING=0/false overrides VTE auto-detection", () => {
// Explicit opt-out must win even when VTE_VERSION is present.
expect(needsTextSizingDisabled({ vteVersion: "6800", disableTextSizing: "0" })).toBe(false)
expect(needsTextSizingDisabled({ vteVersion: "6800", disableTextSizing: "false" })).toBe(false)
})

test("an unrecognized MIMOCODE_DISABLE_TEXT_SIZING value falls back to VTE auto-detection", () => {
expect(needsTextSizingDisabled({ vteVersion: "6800", disableTextSizing: "maybe" })).toBe(true)
expect(needsTextSizingDisabled({ disableTextSizing: "maybe" })).toBe(false)
})
})

describe("isMacNativeTerminal", () => {
test("true only for Apple_Terminal on darwin", () => {
expect(isMacNativeTerminal({ platform: "darwin", termProgram: "Apple_Terminal" })).toBe(true)
expect(isMacNativeTerminal({ platform: "darwin", termProgram: "iTerm.app" })).toBe(false)
expect(isMacNativeTerminal({ platform: "linux", termProgram: "Apple_Terminal" })).toBe(false)
})
})

describe("isPlainTerminal", () => {
test("MIMOCODE_TUI_PLAIN overrides terminal heuristics", () => {
expect(isPlainTerminal({ plain: "1" })).toBe(true)
expect(isPlainTerminal({ plain: "true" })).toBe(true)
expect(isPlainTerminal({ plain: "0", platform: "darwin", termProgram: "Apple_Terminal" })).toBe(false)
expect(isPlainTerminal({ plain: "false", platform: "darwin", termProgram: "Apple_Terminal" })).toBe(false)
})

test("defaults to mac-native detection when MIMOCODE_TUI_PLAIN is unset", () => {
expect(isPlainTerminal({ platform: "darwin", termProgram: "Apple_Terminal" })).toBe(true)
expect(isPlainTerminal({ platform: "linux", termProgram: "xterm" })).toBe(false)
})
})
Loading