read the shell prompt again and stop the reconnect loop
Build / build (push) Canceled after 0s
Playwright / e2e (push) Canceled after 0s

The terminal was the last surface in the chat tool panel still wearing
someone else's design system, and it had two defects underneath that.

The font stack named no Nerd Font, so the private-use glyphs a default
zsh prompt draws its separators and branch marks from rendered as notdef
boxes. The ~ and the path after it drew fine, which is what made it read
as a font bug rather than a missing-glyph one.

Both components also carried their own copy of the palette -- four themes
in one, fifteen in the other, drifting, defaulting to a near-black that
appears nowhere in the token set. Changing --code-bg did nothing to the
terminal because the terminal never read a token. They now share one
module built from the tokens, so an error in the terminal is the same
red as an error anywhere else.

TerminalView reconnected on a bare setTimeout with no attempt cap and no
error surface, retrying invisibly every three seconds forever against an
unreachable backend. It now shares the panel's cap of three and renders
the same retry affordance, counting down again once a session is created.

Also drops a dead ws.onopen assignment and a 21px padding that was on no
spacing scale, and records what the chat page still owes the prototype.
This commit is contained in:
2026-10-19 17:03:38 +08:00
parent a623398fe8
commit 8052bf5781
9 changed files with 465 additions and 307 deletions
+87 -231
View File
@@ -7,213 +7,16 @@ import "@xterm/xterm/css/xterm.css";
import { getApiKey, getBaseUrlValue } from "@/api/client";
import { useMessage } from "@/composables/useMessage";
import { useI18n } from "vue-i18n";
import type { ITheme } from "@xterm/xterm";
import {
DEFAULT_TERMINAL_THEME,
TERMINAL_FONT_FAMILY,
getTerminalTheme,
terminalThemeOptions,
} from "@/utils/hermes/terminal-theme";
const { t } = useI18n();
const message = useMessage();
// ─── Terminal themes ────────────────────────────────────────────
const TERMINAL_THEMES: Record<string, { label: string; theme: ITheme }> = {
default: {
label: "Default",
theme: {
background: "#1a1a2e",
foreground: "#e0e0e0",
cursor: "#4cc9f0",
cursorAccent: "#1a1a2e",
selectionBackground: "rgba(76, 201, 240, 0.3)",
black: "#000000", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379",
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
brightCyan: "#56b6c2", brightWhite: "#ffffff",
},
},
"solarized-dark": {
label: "Solarized Dark",
theme: {
background: "#002b36", foreground: "#839496",
cursor: "#93a1a1", cursorAccent: "#002b36",
selectionBackground: "rgba(147, 161, 161, 0.3)",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
"solarized-light": {
label: "Solarized Light",
theme: {
background: "#fdf6e3", foreground: "#657b83",
cursor: "#586e75", cursorAccent: "#fdf6e3",
selectionBackground: "rgba(88, 110, 117, 0.3)",
black: "#073642", red: "#dc322f", green: "#859900", yellow: "#b58900",
blue: "#268bd2", magenta: "#d33682", cyan: "#2aa198", white: "#eee8d5",
brightBlack: "#002b36", brightRed: "#cb4b16", brightGreen: "#586e75",
brightYellow: "#657b83", brightBlue: "#839496", brightMagenta: "#6c71c4",
brightCyan: "#93a1a1", brightWhite: "#fdf6e3",
},
},
monokai: {
label: "Monokai",
theme: {
background: "#272822", foreground: "#f8f8f2",
cursor: "#f8f8f0", cursorAccent: "#272822",
selectionBackground: "rgba(248, 248, 242, 0.2)",
black: "#272822", red: "#f92672", green: "#a6e22e", yellow: "#f4bf75",
blue: "#66d9ef", magenta: "#ae81ff", cyan: "#a1efe4", white: "#f8f8f2",
brightBlack: "#75715e", brightRed: "#fd971f", brightGreen: "#a6e22e",
brightYellow: "#e6db74", brightBlue: "#66d9ef", brightMagenta: "#ae81ff",
brightCyan: "#a1efe4", brightWhite: "#f9f8f5",
},
},
dracula: {
label: "Dracula",
theme: {
background: "#282a36", foreground: "#f8f8f2",
cursor: "#f8f8f2", cursorAccent: "#282a36",
selectionBackground: "rgba(248, 248, 242, 0.2)",
black: "#21222c", red: "#ff5555", green: "#50fa7b", yellow: "#f1fa8c",
blue: "#bd93f9", magenta: "#ff79c6", cyan: "#8be9fd", white: "#f8f8f2",
brightBlack: "#6272a4", brightRed: "#ff6e6e", brightGreen: "#69ff94",
brightYellow: "#ffffa5", brightBlue: "#d6acff", brightMagenta: "#ff92df",
brightCyan: "#a4ffff", brightWhite: "#ffffff",
},
},
nord: {
label: "Nord",
theme: {
background: "#2e3440", foreground: "#d8dee9",
cursor: "#d8dee9", cursorAccent: "#2e3440",
selectionBackground: "rgba(216, 222, 233, 0.2)",
black: "#3b4252", red: "#bf616a", green: "#a3be8c", yellow: "#ebcb8b",
blue: "#81a1c1", magenta: "#b48ead", cyan: "#88c0d0", white: "#e5e9f0",
brightBlack: "#4c566a", brightRed: "#bf616a", brightGreen: "#a3be8c",
brightYellow: "#ebcb8b", brightBlue: "#81a1c1", brightMagenta: "#b48ead",
brightCyan: "#8fbcbb", brightWhite: "#eceff4",
},
},
"one-dark": {
label: "One Dark",
theme: {
background: "#282c34", foreground: "#abb2bf",
cursor: "#528bff", cursorAccent: "#282c34",
selectionBackground: "rgba(82, 139, 255, 0.25)",
black: "#282c34", red: "#e06c75", green: "#98c379", yellow: "#e5c07b",
blue: "#61afef", magenta: "#c678dd", cyan: "#56b6c2", white: "#abb2bf",
brightBlack: "#5c6370", brightRed: "#e06c75", brightGreen: "#98c379",
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
brightCyan: "#56b6c2", brightWhite: "#ffffff",
},
},
"github-dark": {
label: "GitHub Dark",
theme: {
background: "#0d1117", foreground: "#c9d1d9",
cursor: "#58a6ff", cursorAccent: "#0d1117",
selectionBackground: "rgba(88, 166, 255, 0.25)",
black: "#484f58", red: "#ff7b72", green: "#7ee787", yellow: "#ffa657",
blue: "#79c0ff", magenta: "#d2a8ff", cyan: "#a5d6ff", white: "#c9d1d9",
brightBlack: "#6e7681", brightRed: "#ffa198", brightGreen: "#56d364",
brightYellow: "#e3b341", brightBlue: "#58a6ff", brightMagenta: "#bc8cff",
brightCyan: "#79c0ff", brightWhite: "#f0f6fc",
},
},
"tokyo-night": {
label: "Tokyo Night",
theme: {
background: "#1a1b26", foreground: "#a9b1d6",
cursor: "#c0caf5", cursorAccent: "#1a1b26",
selectionBackground: "rgba(192, 202, 245, 0.2)",
black: "#15161e", red: "#f7768e", green: "#9ece6a", yellow: "#e0af68",
blue: "#7aa2f7", magenta: "#bb9af7", cyan: "#7dcfff", white: "#a9b1d6",
brightBlack: "#414868", brightRed: "#f7768e", brightGreen: "#9ece6a",
brightYellow: "#e0af68", brightBlue: "#7aa2f7", brightMagenta: "#bb9af7",
brightCyan: "#7dcfff", brightWhite: "#c0caf5",
},
},
"github-light": {
label: "GitHub Light",
theme: {
background: "#ffffff", foreground: "#24292f",
cursor: "#0969da", cursorAccent: "#ffffff",
selectionBackground: "rgba(9, 105, 218, 0.2)",
black: "#24292f", red: "#cf222e", green: "#116329", yellow: "#4d2d00",
blue: "#0969da", magenta: "#8250df", cyan: "#1b7c83", white: "#57606a",
brightBlack: "#57606a", brightRed: "#a40e26", brightGreen: "#1a7f37",
brightYellow: "#633c01", brightBlue: "#218bff", brightMagenta: "#a475f4",
brightCyan: "#3192aa", brightWhite: "#8c959f",
},
},
"catppuccin-latte": {
label: "Catppuccin Latte",
theme: {
background: "#eff1f5", foreground: "#4c4f69",
cursor: "#dc8a78", cursorAccent: "#eff1f5",
selectionBackground: "rgba(220, 138, 120, 0.2)",
black: "#5c5f77", red: "#d20f39", green: "#40a02b", yellow: "#df8e1d",
blue: "#1e66f5", magenta: "#ea76cb", cyan: "#179299", white: "#4c4f69",
brightBlack: "#6c6f85", brightRed: "#d20f39", brightGreen: "#40a02b",
brightYellow: "#df8e1d", brightBlue: "#1e66f5", brightMagenta: "#ea76cb",
brightCyan: "#179299", brightWhite: "#bcc0cc",
},
},
"alabaster-light": {
label: "Alabaster",
theme: {
background: "#f7f7f7", foreground: "#434343",
cursor: "#528bff", cursorAccent: "#f7f7f7",
selectionBackground: "rgba(82, 139, 255, 0.2)",
black: "#000000", red: "#aa3731", green: "#448c27", yellow: "#cb9000",
blue: "#325cc0", magenta: "#7a3e9d", cyan: "#0083b2", white: "#434343",
brightBlack: "#777777", brightRed: "#f05050", brightGreen: "#60cb00",
brightYellow: "#ffbc5d", brightBlue: "#0070ea", brightMagenta: "#ca64e2",
brightCyan: "#00aacb", brightWhite: "#999999",
},
},
"xterm-light": {
label: "XTerm Light",
theme: {
background: "#fafafa", foreground: "#383a42",
cursor: "#526fff", cursorAccent: "#fafafa",
selectionBackground: "rgba(82, 111, 255, 0.2)",
black: "#383a42", red: "#e45649", green: "#50a14f", yellow: "#c18401",
blue: "#4078f2", magenta: "#a626a4", cyan: "#0184bc", white: "#a0a1a7",
brightBlack: "#4f525e", brightRed: "#e06c75", brightGreen: "#98c379",
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
brightCyan: "#56b6c2", brightWhite: "#ffffff",
},
},
"one-light": {
label: "One Light",
theme: {
background: "#fafafa", foreground: "#383a42",
cursor: "#526eff", cursorAccent: "#fafafa",
selectionBackground: "rgba(82, 110, 255, 0.2)",
black: "#383a42", red: "#e45649", green: "#50a14f", yellow: "#c18401",
blue: "#4078f2", magenta: "#a626a4", cyan: "#0184bc", white: "#a0a1a7",
brightBlack: "#4f525e", brightRed: "#e06c75", brightGreen: "#98c379",
brightYellow: "#e5c07b", brightBlue: "#61afef", brightMagenta: "#c678dd",
brightCyan: "#56b6c2", brightWhite: "#ffffff",
},
},
"gruvbox-light": {
label: "Gruvbox Light",
theme: {
background: "#fbf1c7", foreground: "#3c3836",
cursor: "#9d0006", cursorAccent: "#fbf1c7",
selectionBackground: "rgba(157, 0, 6, 0.15)",
black: "#fbf1c7", red: "#cc241d", green: "#98971a", yellow: "#d79921",
blue: "#458588", magenta: "#b16286", cyan: "#689d6a", white: "#504945",
brightBlack: "#928374", brightRed: "#9d0006", brightGreen: "#79740e",
brightYellow: "#b57614", brightBlue: "#076678", brightMagenta: "#8f3f71",
brightCyan: "#427b58", brightWhite: "#3c3836",
},
},
};
const STORAGE_KEY_THEME = "hermes_terminal_theme";
// ─── Types ──────────────────────────────────────────────────────
@@ -233,7 +36,11 @@ const terminalRef = ref<HTMLDivElement | null>(null);
const showSessions = ref(true);
const sessions = ref<SessionInfo[]>([]);
const activeSessionId = ref<string | null>(null);
const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default");
const selectedTheme = ref(
localStorage.getItem(STORAGE_KEY_THEME) || DEFAULT_TERMINAL_THEME,
);
const connectionError = ref<string | null>(null);
const isConnecting = ref(false);
let ws: WebSocket | null = null;
// Keep all terminal instances alive, only dispose on close
@@ -244,6 +51,8 @@ const termMap = new Map<
let activeTerm: Terminal | null = null;
let activeFitAddon: FitAddon | null = null;
let resizeObserver: ResizeObserver | null = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 3;
let mobileQuery: MediaQueryList | null = null;
let touchScrollLastY: number | null = null;
let touchScrollRemainder = 0;
@@ -255,17 +64,13 @@ const activeSession = computed(
() => sessions.value.find((s) => s.id === activeSessionId.value) || null,
);
const themeOptions = computed(() =>
Object.entries(TERMINAL_THEMES).map(([key, val]) => ({
label: val.label,
value: key,
})),
);
const terminalBg = computed(
() => TERMINAL_THEMES[selectedTheme.value]?.theme.background ?? "#1a1a2e",
);
const themeOptions = computed(() => terminalThemeOptions());
// 背景由 CSS 负责上色:项目样式把 .xterm-screen 强制成透明,所以容器必须自己有底色。
// 这里只把选中主题的色值交给 CSS 变量,绘制仍留在样式层,并且令牌值作为兜底。
const terminalBgStyle = computed(() => ({
"--terminal-bg": getTerminalTheme(selectedTheme.value).background,
}));
// ─── WebSocket ──────────────────────────────────────────────────
function formatHostForPort(hostname: string, port: number): string {
@@ -298,11 +103,22 @@ function buildWsUrl(): string {
}
function connect() {
if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
connectionError.value = t("terminal.connectionFailed");
isConnecting.value = false;
return;
}
const url = buildWsUrl();
connectionError.value = null;
isConnecting.value = true;
reconnectAttempts++;
ws = new WebSocket(url);
ws.onopen = () => {
// Server auto-creates the first session and sends 'created'
isConnecting.value = false;
connectionError.value = null;
};
ws.onmessage = (event) => {
@@ -316,18 +132,21 @@ function connect() {
}
};
// On reconnect, recreate all terminals for existing sessions
ws.onopen = () => {
// Server will auto-create the first session again
};
ws.onclose = (event) => {
isConnecting.value = false;
// 正常关闭与认证失败不重连:重连只会拿到同样的结果。
if (event.code === 1000 || event.code === 1003 || event.code === 1008) {
connectionError.value = t("terminal.connectionClosed");
return;
}
ws.onclose = () => {
// Reconnect after delay
setTimeout(connect, 3000);
};
ws.onerror = () => {
// let onclose handle reconnect
// onclose 会接手重连,这里不重复处理。
connectionError.value = t("terminal.connectionError");
};
}
@@ -341,6 +160,7 @@ function send(data: object | string) {
function handleControl(msg: any) {
switch (msg.type) {
case "created":
reconnectAttempts = 0;
sessions.value.push({
id: msg.id,
shell: msg.shell,
@@ -386,9 +206,9 @@ function getOrCreateTerm(id: string): { term: Terminal; fitAddon: FitAddon } {
if (!entry) {
const term = new Terminal({
cursorBlink: true,
fontSize: 14,
fontFamily: 'Menlo, Monaco, "Courier New", monospace',
theme: { ...TERMINAL_THEMES[selectedTheme.value].theme },
fontSize: 13,
fontFamily: TERMINAL_FONT_FAMILY,
theme: { ...getTerminalTheme(selectedTheme.value) },
});
const fitAddon = new FitAddon();
term.loadAddon(fitAddon);
@@ -532,8 +352,7 @@ function handleTerminalTouchEnd() {
function applyTheme(themeName: string) {
selectedTheme.value = themeName;
localStorage.setItem(STORAGE_KEY_THEME, themeName);
const themeObj = TERMINAL_THEMES[themeName]?.theme;
if (!themeObj) return;
const themeObj = getTerminalTheme(themeName);
for (const entry of termMap.values()) {
entry.term.options.theme = { ...themeObj };
}
@@ -614,8 +433,22 @@ onUnmounted(() => {
</div>
</div>
<div v-if="showSessions" class="session-items">
<div v-if="sessions.length === 0" class="session-empty">
{{ t("common.loading") }}
<div v-if="connectionError" class="session-error">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="8" x2="12" y2="12" />
<line x1="12" y1="16" x2="12.01" y2="16" />
</svg>
<span>{{ connectionError }}</span>
<a-button size="mini" @click="connect">{{ t("common.retry") }}</a-button>
</div>
<div v-else-if="sessions.length === 0" class="session-empty">
<template v-if="isConnecting">
{{ t("common.loading") }}
</template>
<template v-else>
{{ t("terminal.noSessions") }}
</template>
</div>
<button
v-for="s in sessions"
@@ -719,7 +552,7 @@ onUnmounted(() => {
<div
ref="terminalRef"
class="terminal-xterm"
:style="{ backgroundColor: terminalBg }"
:style="terminalBgStyle"
@touchstart="handleTerminalTouchStart"
@touchmove="handleTerminalTouchMove"
@touchend="handleTerminalTouchEnd"
@@ -811,6 +644,27 @@ onUnmounted(() => {
text-align: center;
}
.session-error {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 20px 12px;
font-size: 12px;
color: $error;
text-align: center;
svg {
width: 32px;
height: 32px;
opacity: 0.8;
}
span {
flex: 1;
}
}
.session-item {
display: flex;
align-items: center;
@@ -931,7 +785,7 @@ onUnmounted(() => {
display: flex;
align-items: center;
justify-content: space-between;
padding: 21px 20px;
padding: 12px 16px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
}
@@ -978,6 +832,8 @@ onUnmounted(() => {
.terminal-xterm {
flex: 1;
// --terminal-bg 由脚本按选中主题写入;令牌值兜底,保证 JS 未就绪时也不是黑洞。
background: var(--terminal-bg, #{$bg-input});
border-radius: $radius-md;
overflow: hidden;
border: 1px solid $border-color;