diff --git a/docs/chat-chain-changes/2026-10-19-terminal-cyber-theme.md b/docs/chat-chain-changes/2026-10-19-terminal-cyber-theme.md new file mode 100644 index 0000000..235f669 --- /dev/null +++ b/docs/chat-chain-changes/2026-10-19-terminal-cyber-theme.md @@ -0,0 +1,77 @@ +--- +date: 2026-10-19 +pr: pending +feature: Terminal CYBER theme, Nerd Font fallback, and bounded reconnect +impact: The chat tool panel's Terminal tab now paints from the CYBER token set instead of a hardcoded One Dark palette, resolves its font through a Nerd Font stack so shell prompts stop rendering as tofu boxes, and stops reconnecting after three failed attempts with an inline retry instead of looping forever. No WebSocket message names, payload shapes, or session lifecycle behavior changed. +--- + +# Terminal brought onto the CYBER token set + +The terminal was the last surface in the chat tool panel still wearing someone +else's design system. Three defects, in order of how visible they were. + +## Tofu boxes in the prompt + +The font stack was `Menlo, Monaco, "Courier New", monospace`. A default zsh +prompt (powerlevel10k, oh-my-zsh) draws its separators, arrows and branch marks +from the Unicode private use area — `U+E0A0` through `U+E0D4`. None of those +fonts carry those glyphs, so the browser fell back to drawing notdef boxes for +the entire prompt line. The `~` and the path after it rendered fine, which is +what made it read as a font bug rather than a missing-glyph bug. + +The stack now names Nerd Font variants explicitly, in +`variables.scss` as `--font-mono`. It is a CSS custom property rather than only +an SCSS variable because xterm reads it from JS at `Terminal` construction time. + +## Colors came from a different product + +Both terminal components carried their own copy of the palette — four themes in +`TerminalPanel.vue`, fifteen in `TerminalView.vue`, drifting from each other, +all of them third-party themes with a `#1a1a2e` default that appears nowhere in +the token set. Changing `--code-bg` did nothing to the terminal, because the +terminal never read a token. + +Both now import `utils/hermes/terminal-theme.ts`, which holds a CYBER theme +built from the tokens plus two user-selectable alternates. The mapping is +recorded in that file: `--bg-input` for the background, `--text-secondary` for +foreground, `--accent-primary` for the cursor, and `--error` / `--success` / +`--warning` / `--accent-info` mapped onto red / green / yellow / blue so an +error in the terminal is the same red as an error anywhere else. + +xterm's `ITheme` only accepts hex, so the OKLCH values were converted rather +than interpolated. The conversion is documented per-color; changing a token +means updating that file too, which is a real coupling and the reason the +comment says so. + +Note the tradeoff on ANSI `cyan`: it is deliberately *not* the accent cyan. +`--accent-primary` at full strength reads as UI chrome, and a terminal where +every cyan prompt fragment glows competes with the actual UI. The accent is +spent on the cursor, and `brightCyan` carries it for text. + +## Reconnect looped forever + +`TerminalView.vue` reconnected on a bare `setTimeout` with no attempt cap and no +error surface, so an unreachable backend produced an invisible retry every three +seconds indefinitely. `TerminalPanel.vue` already capped at three attempts and +rendered a retry affordance; `TerminalView.vue` now does the same, sharing the +same cap and the same `connectionFailed` / `connectionClosed` / `connectionError` +strings. The counter resets when a session is successfully created, so a +transient blip does not permanently exhaust the budget. + +Two smaller items came out of the same pass: `ws.onopen` was assigned twice in +`TerminalView.vue` with the first assignment dead, and the header used a `21px` +vertical padding that was on no spacing scale. Both are gone. + +## Not changed + +The session list, theme picker, and new-terminal button stay. The static +prototype has no equivalent of any of them, and deleting working features to +match a mock is the inverse of the problem worth solving. + +## Verification + +`npm run harness:check`, `npm run test` and `npm run build` pass. The existing +`tests/e2e/terminal.spec.ts` still passes unchanged — it asserts the WebSocket +URL carries the token, that `create` is sent, that keyboard input is forwarded, +and that a `resize` with non-zero cols/rows arrives. Those four are exactly the +behaviors this change had to preserve. diff --git a/docs/chat-development-todo.md b/docs/chat-development-todo.md new file mode 100644 index 0000000..03a0ede --- /dev/null +++ b/docs/chat-development-todo.md @@ -0,0 +1,118 @@ +# 对话页面待开发事项 + +对话台(ChatView)是全项目离 CYBER 原型最远的一页。设置页那套骨架已经落地, +但对话台没有套上 —— 全仓库只有 `SettingsView.vue` 在用 `cy-*` 骨架类。 + +本文记录已核实的差距和推进顺序。所有条目均已对照 +`prototype_reference/hermes-ui-cyber-chat.html` 与真实组件确认,不是推测。 + +## 为什么对话台排在最前面 + +对话台是结构最复杂的一页:一个页面里有会话列表、气泡流、审批卡、输入区、 +右侧两个面板,六种结构各自的视觉规则都不同。骨架层没套上来,意味着这六块 +各自沿用迁移前的样子,霓虹暗色的辨识度出不来。 + +令牌层和控件层已经达标 —— `styles/global.scss` 里的 `neon-sheen`、 +`neon-pulse`、各处 `text-shadow` 都在。**缺的是页面骨架层和组件级语义配色**, +也就是原型第 3、7、9 三节。 + +## 现状:骨架类使用情况 + +`cy-view` / `cy-panel` / `cy-tabs` / `cy-sec-t` 的全仓库使用点: + +| 文件 | 位置 | +| --- | --- | +| `views/hermes/SettingsView.vue` | `cy-tabs`、`cy-panel`、`cy-panel-b` | +| `styles/global.scss` | 定义处 | + +对话台相关组件 —— `ChatPanel.vue`、`MessageList.vue`、`MessageItem.vue`、 +`ChatInput.vue`、`SessionListItem.vue` —— **一个都没用**。 + +## 任务清单 + +### P0 · 修 rainbow-glow(违反三色语义规则) + +一次动画里四种语义色同时在闪,直接违反「同屏内每种颜色只承担一种含义」。 +硬编码值,未走令牌。 + +三处重复定义,需要收敛成一处: + +| 文件 | 行 | +| --- | --- | +| `components/hermes/chat/MessageItem.vue` | `1523`(定义)、`1451`(调用) | +| `components/hermes/chat/SessionListItem.vue` | `409`(定义)、`395`(调用) | +| `components/hermes/group-chat/GroupMessageItem.vue` | `1051`(定义)、`1114`(调用) | + +当前色值循环:`#ff6b6b` → `#feca57` → `#48dbfb` → `#ff9ff3` → 回环。 + +处理方向:按调用场景收敛到单色。`.speech-playing` 是语音播放态, +语义上属于「机器在动」,应改用 `--success`,去掉循环。 + +### P1 · 组件级语义配色 + +| 项 | 原型 | 真实实现 | 差距 | +| --- | --- | --- | --- | +| 发送键扫光 | 原型 133-140:28×26,青管壁 + `sheen` 每 3.4s 扫过 | `ChatInput.vue:1765-1783`:30×30,`border: 0 !important` + `box-shadow: none !important`,禁用态 `#9f9f9f` 纯灰 | **未实现**,且被 `!important` 显式压制 | +| 气泡 role 配色 | 原型 216-227:用户=青、助手=绿,助手气泡左缘一道 `lime-line` | `MessageItem.vue` 无 role 配色规则 | **未实现** | +| LED 指示灯 | 原型 151-156:7px 圆点 + 发光 + 呼吸动画 | 全局无 `.led` 类 | **未实现** | +| 会话列表当前项 | 原型 190-192:青软底 + 左侧 2px 发光条 | `SessionListItem.vue:288`:`box-shadow: 0 0 0 3px` 描边 | 形似,神韵不同 | +| 顶栏青色微光 | 原型 200-201:`radial-gradient` 压顶 | 无 | **未实现** | +| 审批卡橙区 | 原型 264-267:橙边 + 内发光 | `MessageList.vue:942` 有 `approval-pulse`,非原型那套橙语义区 | 部分 | +| 上下文余量条 | 原型 273-278:绿→橙→红三段 | `ChatInput.vue` 有 context meter | 已有,分段配色待确认 | + +### P2 · 响应式 + +原型 166-177 定义了两档断点: + +- `≤1180px`:Outline 从挤压主区改为浮层覆盖(`position: absolute`) +- `≤900px`:会话侧栏整体隐藏,顶栏 `.ws-tag` 隐藏 + +对话台真实实现是否已有等价断点,未核实。 + +## 已知原型侧问题(不是待开发项) + +以下原型自身与真实实现不符,接入时以真实实现为准,不要照抄原型: + +- **会话来源标签**:原型列了 8 种,真实只有 4 种 + (`api_server` / `cli` / `global_agent` / `workflow`,见 + `stores/hermes/chat.ts`)。原型里的 `cron` / `channel` / `group_chat` / + `terminal` / `file` / `kanban` 不存在。 +- **`moa` 作为气泡 role**:原型写进「role 全集」,但真实 `Message.role` 是 + 5 元联合类型(`stores/hermes/chat.ts:47`)。`moa` 是 `display_role`, + 渲染时被映射成 `system`(同文件 `:483`)。原型的 `.bubble-row.moa` 和 + `AV.moa = '◇'` 都应删。 +- **Outline 的「附件」区块**:原型 578-580 有,真实 `OutlinePanel.vue:82-111` + 只产出「用户提问 + h1/h2/h3 标题」两类条目,没有附件概念。 +- **会话列表 `⌕` 搜索按钮**:原型 498 有,全项目无会话搜索功能。 +- **顶栏副标题 `01JQ8F2K · default profile`**:原型 618 有,真实 header 只有 + 单个 `headerTitle` 字符串(`ChatPanel.vue:307-311`),无第二行。 +- **`m.reasoning` 作为消息字段**:原型当作字段,实际是 + `utils/thinking-parser.ts:51` 从 `content` 里正则摘 `` 标签得到。 + 折叠交互是真的,数据结构是假的。 +- **`⇤` 语义错位**:原型 615 画成「返回」,真实同位置是切换会话侧栏显隐。 +- **`~/work/hermes-ui` 完整路径**:真实 `workspace-badge` 只显示 basename + (`ChatPanel.vue:1599`)。 +- **顶栏常驻 `streaming` tag**:真实 streaming 标记在会话列表项上 + (`ChatPanel.vue:1209`),不在 header。 + +## 执行顺序建议 + +1. **P0 rainbow-glow** —— 范围明确、风险低、3 处收敛成 1 处。 +2. **P1 骨架 + 语义色** —— 工作量最大。`ChatPanel.vue` 2300+ 行、 + `MessageItem.vue` 2100+ 行,套骨架要动结构不只是样式,建议逐块推进, + 每块单独验证。 +3. **P2 响应式** —— 等 P1 的布局稳定后再做,否则要返工。 + +## 验证基线 + +改动前后都应跑: + +```bash +npm run harness:check +npm run test # 当前 277 文件 / 2049 通过 +npm run test:e2e # 当前 35 通过 +npm run build +``` + +`chat/` 属聊天链,改动需要 `docs/chat-chain-changes/-.md` 片段, +`harness:check` 会强制校验。 diff --git a/packages/client/src/components/hermes/chat/TerminalPanel.vue b/packages/client/src/components/hermes/chat/TerminalPanel.vue index afdd85f..1d0a262 100644 --- a/packages/client/src/components/hermes/chat/TerminalPanel.vue +++ b/packages/client/src/components/hermes/chat/TerminalPanel.vue @@ -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 = { - 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(null); const sessions = ref([]); const activeSessionId = ref(null); -const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default"); +const selectedTheme = ref( + localStorage.getItem(STORAGE_KEY_THEME) || DEFAULT_TERMINAL_THEME, +); const connectionError = ref(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(() => {
('dark') // 固定应用暗色:Arco 的 token 通过 arco-theme 属性切换,项目的令牌写在 :root。 +// 也要挂:Arco 的暗色令牌声明在 body[arco-theme='dark'] 下,属性选择器不继承。 document.documentElement.setAttribute('arco-theme', 'dark') +document.body.setAttribute('arco-theme', 'dark') export function useTheme() { const themeName = computed(() => 'dark') diff --git a/packages/client/src/main.ts b/packages/client/src/main.ts index fc10cae..701e361 100644 --- a/packages/client/src/main.ts +++ b/packages/client/src/main.ts @@ -9,7 +9,11 @@ import './styles/global.scss' import 'katex/dist/katex.min.css' // CYBER 单主题:固定暗色,无需读取 localStorage 或匹配系统偏好。 +// 属性必须同时挂在 和 :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) diff --git a/packages/client/src/styles/global.scss b/packages/client/src/styles/global.scss index bc4e940..5448206 100644 --- a/packages/client/src/styles/global.scss +++ b/packages/client/src/styles/global.scss @@ -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。 diff --git a/packages/client/src/styles/variables.scss b/packages/client/src/styles/variables.scss index 994b4bd..4f42675 100644 --- a/packages/client/src/styles/variables.scss +++ b/packages/client/src/styles/variables.scss @@ -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; diff --git a/packages/client/src/utils/hermes/terminal-theme.ts b/packages/client/src/utils/hermes/terminal-theme.ts new file mode 100644 index 0000000..3b3dba8 --- /dev/null +++ b/packages/client/src/utils/hermes/terminal-theme.ts @@ -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 = { + 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, + })); +} diff --git a/packages/client/src/views/hermes/TerminalView.vue b/packages/client/src/views/hermes/TerminalView.vue index 748e9d4..b8bda48 100644 --- a/packages/client/src/views/hermes/TerminalView.vue +++ b/packages/client/src/views/hermes/TerminalView.vue @@ -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 = { - 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(null); const showSessions = ref(true); const sessions = ref([]); const activeSessionId = ref(null); -const selectedTheme = ref(localStorage.getItem(STORAGE_KEY_THEME) || "default"); +const selectedTheme = ref( + localStorage.getItem(STORAGE_KEY_THEME) || DEFAULT_TERMINAL_THEME, +); +const connectionError = ref(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(() => {
-
- {{ t("common.loading") }} +
+ + + + + + {{ connectionError }} + {{ t("common.retry") }} +
+
+ +