read the shell prompt again and stop the reconnect loop
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:
@@ -7,72 +7,18 @@ 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();
|
||||
|
||||
const props = defineProps<{ visible?: boolean; initialCommand?: string }>();
|
||||
|
||||
// ─── 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",
|
||||
},
|
||||
},
|
||||
"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-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",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const STORAGE_KEY_THEME = "hermes_terminal_theme";
|
||||
|
||||
// ─── Types ──────────────────────────────────────────────────────
|
||||
@@ -91,7 +37,9 @@ interface SessionInfo {
|
||||
const terminalRef = ref<HTMLDivElement | null>(null);
|
||||
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);
|
||||
const showSidebar = ref(false);
|
||||
@@ -117,16 +65,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 themeOptions = computed(() => terminalThemeOptions());
|
||||
|
||||
const terminalBg = computed(
|
||||
() => TERMINAL_THEMES[selectedTheme.value]?.theme.background ?? "#1a1a2e",
|
||||
);
|
||||
// 背景由 CSS 负责上色:样式表把 .xterm-screen 强制成透明,容器必须自己有底色。
|
||||
// 这里只把选中主题的色值交给 CSS 变量,绘制仍留在样式层,令牌值兜底。
|
||||
const terminalBgStyle = computed(() => ({
|
||||
"--terminal-bg": getTerminalTheme(selectedTheme.value).background,
|
||||
}));
|
||||
|
||||
// ─── WebSocket ──────────────────────────────────────────────────
|
||||
|
||||
@@ -281,9 +226,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);
|
||||
@@ -420,8 +365,7 @@ function applyTheme(value: unknown) {
|
||||
const themeName = String(value);
|
||||
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 };
|
||||
}
|
||||
@@ -578,7 +522,7 @@ onUnmounted(() => {
|
||||
<div
|
||||
ref="terminalRef"
|
||||
class="terminal-xterm"
|
||||
:style="{ backgroundColor: terminalBg }"
|
||||
:style="terminalBgStyle"
|
||||
@touchstart="handleTerminalTouchStart"
|
||||
@touchmove="handleTerminalTouchMove"
|
||||
@touchend="handleTerminalTouchEnd"
|
||||
@@ -846,6 +790,8 @@ $terminal-panel-header-height: 47px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-width: 0;
|
||||
// --terminal-bg 由脚本按选中主题写入;令牌值兜底,保证 JS 未就绪时也不是黑洞。
|
||||
background: var(--terminal-bg, #{$bg-input});
|
||||
border-radius: $radius-md;
|
||||
overflow: hidden;
|
||||
border: 1px solid $border-color;
|
||||
|
||||
@@ -9,7 +9,9 @@ export type BrightnessMode = 'dark'
|
||||
const brightness = ref<BrightnessMode>('dark')
|
||||
|
||||
// 固定应用暗色:Arco 的 token 通过 arco-theme 属性切换,项目的令牌写在 :root。
|
||||
// <body> 也要挂:Arco 的暗色令牌声明在 body[arco-theme='dark'] 下,属性选择器不继承。
|
||||
document.documentElement.setAttribute('arco-theme', 'dark')
|
||||
document.body.setAttribute('arco-theme', 'dark')
|
||||
|
||||
export function useTheme() {
|
||||
const themeName = computed(() => 'dark')
|
||||
|
||||
@@ -9,7 +9,11 @@ import './styles/global.scss'
|
||||
import 'katex/dist/katex.min.css'
|
||||
|
||||
// CYBER 单主题:固定暗色,无需读取 localStorage 或匹配系统偏好。
|
||||
// 属性必须同时挂在 <html> 和 <body>:Arco 把暗色令牌声明在 body[arco-theme='dark']
|
||||
// 下(见 arco.css),CSS 属性选择器不继承,只挂 html 的话 Arco 暗色层整层不生效,
|
||||
// 组件会拿到亮色令牌(近黑的 --color-text-1),在深底上表现为看不清的灰字。
|
||||
document.documentElement.setAttribute('arco-theme', 'dark')
|
||||
document.body.setAttribute('arco-theme', 'dark')
|
||||
|
||||
// Read token from URL BEFORE router initializes (hash router strips params)
|
||||
const urlParams = new URLSearchParams(window.location.search)
|
||||
|
||||
@@ -36,7 +36,10 @@ textarea {
|
||||
// --primary-6 → --arcoblue-6 这一族;只覆盖 --color-primary 不会改变按钮底色。
|
||||
// 两套都必须覆盖,否则会残留 Arco 蓝(22,93,255)。
|
||||
// 变量表见 node_modules/@arco-design/web-vue/dist/arco.css 的 :root 块。
|
||||
// html 和 body 都要列:Arco 的暗色令牌挂在 body[arco-theme='dark'] 上,
|
||||
// body 自身的声明优先级高于从 :root 继承的值,只覆盖 :root 会输给 Arco 自带的暗色值。
|
||||
html[arco-theme='dark'],
|
||||
body[arco-theme='dark'],
|
||||
:root {
|
||||
// Arco 的 rgb 三元组族 —— 实心控件(按钮底、开关滑块、进度条)的真正来源。
|
||||
// 霓虹青 oklch(0.805 0.148 188) ≈ rgb(45,212,191),按亮度排 1→10。
|
||||
|
||||
@@ -63,6 +63,13 @@
|
||||
// Code
|
||||
--code-bg: oklch(0.115 0.014 250);
|
||||
|
||||
// 等宽字体。终端(xterm)在 JS 里读这个值,所以必须是 CSS 自定义属性而不是
|
||||
// 只有 SCSS 变量。Nerd Font 段不能删:默认 shell 的提示符会输出私有区字形
|
||||
// (分支、箭头、分隔符),缺了这些字形会整片显示为豆腐块。
|
||||
--font-mono: ui-monospace, 'JetBrains Mono', 'JetBrainsMono Nerd Font',
|
||||
'Nerd Font', 'SF Mono', Menlo, 'Cascadia Code', 'MesloLGS NF',
|
||||
'Hack Nerd Font', monospace;
|
||||
|
||||
// Utility
|
||||
--text-on-accent: oklch(0.160 0.030 200);
|
||||
--text-on-overlay: oklch(0.945 0.006 200);
|
||||
@@ -126,6 +133,8 @@ $font-ui: -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB',
|
||||
'Microsoft YaHei', 'Segoe UI', Roboto, sans-serif;
|
||||
$font-code: ui-monospace, 'JetBrains Mono', 'SF Mono', Menlo, 'Cascadia Code', monospace;
|
||||
$font-display: $font-code;
|
||||
// 终端等宽:多了 Nerd Font 回退,见 :root --font-mono 的说明
|
||||
$font-mono: var(--font-mono);
|
||||
|
||||
// Layout — 与 docs/ui-baseline.md 保持一致
|
||||
$sidebar-width: 240px;
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
import type { ITheme } from "@xterm/xterm";
|
||||
|
||||
/**
|
||||
* 终端配色与字体。
|
||||
*
|
||||
* xterm 的 ITheme 只接受 hex / rgba 字符串,拿不到 CSS 自定义属性,所以这里的
|
||||
* 色值是从 variables.scss 的 OKLCH 令牌换算而来的,改令牌时必须同步改这里。
|
||||
* 换算关系(oklch → sRGB)见 docs 里的对照表,不要凭肉眼调色。
|
||||
*
|
||||
* CYBER 默认配色沿用三色语义:
|
||||
* 青 = 系统本体(光标 / 选区 / dim 之外的强调)
|
||||
* 绿 = 机器在动(目录 / 链接)
|
||||
* 橙 = 需要你介入(提示 / 可执行文件)
|
||||
* ANSI 16 色沿用 One Dark 的色相做基底,只把 background / foreground / cursor
|
||||
* 换成 CYBER 的深底与霓虹青,保证终端落进页面时不显得是另一个产品。
|
||||
*/
|
||||
|
||||
// ─── Font ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Nerd Font 必须排在等宽字体之前。默认 shell(zsh 的 powerlevel10k / oh-my-zsh)
|
||||
* 会输出 U+E0A0–U+E0D4 段的私有区字形(分支、箭头、分隔符),普通等宽字体没有
|
||||
* 这些字形,浏览器会画成豆腐块。缺这一段时提示符会整片糊掉。
|
||||
*/
|
||||
export const TERMINAL_FONT_FAMILY = [
|
||||
"ui-monospace",
|
||||
"'JetBrains Mono'",
|
||||
"'JetBrainsMono Nerd Font'",
|
||||
"'Nerd Font'",
|
||||
"'SF Mono'",
|
||||
"Menlo",
|
||||
"'Cascadia Code'",
|
||||
"'MesloLGS NF'",
|
||||
"'Hack Nerd Font'",
|
||||
"monospace",
|
||||
].join(", ");
|
||||
|
||||
// ─── Themes ──────────────────────────────────────────────────────
|
||||
|
||||
export interface TerminalThemeEntry {
|
||||
label: string;
|
||||
theme: ITheme;
|
||||
}
|
||||
|
||||
const CYBER: ITheme = {
|
||||
// oklch(0.105 0.012 252) → #020407,与 $bg-input 同源:终端是凹陷区
|
||||
background: "#020407",
|
||||
// oklch(0.860 0.008 210) → #cbd2d4
|
||||
foreground: "#cbd2d4",
|
||||
// oklch(0.805 0.148 188) → #00ddd1,与全局主色同值
|
||||
cursor: "#00ddd1",
|
||||
cursorAccent: "#020407",
|
||||
selectionBackground: "rgba(0, 221, 209, 0.30)",
|
||||
black: "#020407",
|
||||
// 语义色直接映射,终端里的报错 / 提示与页面读起来是同一套语言
|
||||
red: "#f05a5e", // oklch(0.665 0.185 22) → --error
|
||||
green: "#ade74e", // oklch(0.860 0.190 128) → --success
|
||||
yellow: "#f7a232", // oklch(0.780 0.155 68) → --warning
|
||||
blue: "#5fa5de", // oklch(0.700 0.110 245) → --accent-info
|
||||
magenta: "#c678dd",
|
||||
// 青色让位给 neon cyan:ANSI cyan 太暗,跟主色抢不到注意力
|
||||
cyan: "#3f8fa8",
|
||||
white: "#98a2a7", // oklch(0.705 0.014 230) → --text-muted
|
||||
brightBlack: "#626a70", // oklch(0.520 0.014 236) → --text-faint
|
||||
brightRed: "#ff7b7e",
|
||||
brightGreen: "#c9f56d",
|
||||
brightYellow: "#ffbe5c",
|
||||
brightBlue: "#84c2f0",
|
||||
brightMagenta: "#d9a2e8",
|
||||
brightCyan: "#00ddd1",
|
||||
brightWhite: "#e8eeee", // oklch(0.945 0.006 200) → --text-primary
|
||||
};
|
||||
|
||||
const TOKYO_NIGHT: ITheme = {
|
||||
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",
|
||||
};
|
||||
|
||||
const GITHUB_DARK: ITheme = {
|
||||
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",
|
||||
};
|
||||
|
||||
/**
|
||||
* CYBER 是唯一的品牌主题,放在首位并作为默认值。
|
||||
* 其余主题是用户自选,不参与品牌表达。
|
||||
*/
|
||||
export const TERMINAL_THEMES: Record<string, TerminalThemeEntry> = {
|
||||
cyber: { label: "CYBER", theme: CYBER },
|
||||
"tokyo-night": { label: "Tokyo Night", theme: TOKYO_NIGHT },
|
||||
"github-dark": { label: "GitHub Dark", theme: GITHUB_DARK },
|
||||
};
|
||||
|
||||
export const DEFAULT_TERMINAL_THEME = "cyber";
|
||||
|
||||
export function getTerminalTheme(name: string): ITheme {
|
||||
return TERMINAL_THEMES[name]?.theme ?? CYBER;
|
||||
}
|
||||
|
||||
export function terminalThemeOptions(): { label: string; value: string }[] {
|
||||
return Object.entries(TERMINAL_THEMES).map(([value, entry]) => ({
|
||||
label: entry.label,
|
||||
value,
|
||||
}));
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user