Skip to content

Commit 49c8bc8

Browse files
committed
perf: update terminal
1 parent 69a4197 commit 49c8bc8

7 files changed

Lines changed: 183 additions & 17 deletions

File tree

ui/koko/components/Terminal/index.vue

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -47,18 +47,18 @@ const onUploadChange = (event: Event) => {
4747
</template>
4848

4949
<style scoped>
50-
#terminal-container :deep(.terminal) {
51-
height: 100%;
52-
padding: 10px 0 5px 10px;
50+
/* 背景与 xterm 主题背景同源(--app-main-bg),padding 区域无色差 */
51+
#terminal-container {
52+
background: var(--app-main-bg);
5353
}
5454
55-
#terminal-container :deep(.xterm-viewport)::-webkit-scrollbar {
56-
height: 4px;
57-
width: 7px;
55+
#terminal-container :deep(.terminal) {
56+
height: 100%;
57+
padding: 12px 4px 8px 12px;
5858
}
5959
60-
#terminal-container :deep(.xterm-viewport)::-webkit-scrollbar-thumb {
61-
background: rgba(255, 255, 255, 0.35);
62-
border-radius: 3px;
60+
/* 主题切换瞬间由容器背景兜底,避免闪色块;滚动条不覆盖,走 main.css 全局窄样式 */
61+
#terminal-container :deep(.xterm-viewport) {
62+
background-color: transparent !important;
6363
}
6464
</style>

ui/koko/composables/useTerminalSocket.ts

Lines changed: 23 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ import { useKokoConnectionStore } from "~/koko/stores/connection";
1515
import { useKokoTerminalSettingsStore } from "~/koko/stores/terminalSettings";
1616
import { MaxTimeout } from "~/koko/utils/config";
1717
import { getDefaultTerminalConfig } from "~/koko/utils/guard";
18-
import { terminalTheme } from "~/koko/utils/terminalTheme";
18+
import { appTerminalTheme, terminalTheme } from "~/koko/utils/terminalTheme";
1919
import { formatMessage, getXTerminalLineContent, preprocessInput, updateIcon, writeBufferToTerminal } from "~/koko/utils/terminalUtils";
2020
import {
2121
FORMATTER_MESSAGE_TYPE,
@@ -58,6 +58,9 @@ export const useKokoTerminalSocket = () => {
5858
const terminalSettingsStore = useKokoTerminalSettingsStore();
5959
const sessionCtxRef = inject(connectorSessionKey, null);
6060
const queryTerminalThemeName = computed(() => unref(sessionCtxRef)?.terminalThemeName || "");
61+
// 未显式指定主题名(workspace 内嵌场景)时跟随应用主题;独立 /koko/connect 路由带主题名则维持原逻辑
62+
const followAppTheme = computed(() => !!unref(sessionCtxRef) && !queryTerminalThemeName.value);
63+
let themeObserver: MutationObserver | null = null;
6164

6265
const fitAddon = new FitAddon();
6366
const webglAddon = new WebglAddon();
@@ -171,8 +174,9 @@ export const useKokoTerminalSocket = () => {
171174
terminalSettingsStore.setDefaultTerminalConfig("ctrlCAsCtrlZ", sessionInfo.ctrlCAsCtrlZ ? "1" : "0");
172175
}
173176

177+
// 跟随应用主题时不让服务端 themeName 覆盖
174178
const effectiveThemeName = queryTerminalThemeName.value || sessionInfo.themeName;
175-
if (effectiveThemeName) {
179+
if (effectiveThemeName && !followAppTheme.value) {
176180
nextTick(() => {
177181
terminalRef.value!.options.theme = terminalTheme(effectiveThemeName);
178182
});
@@ -367,7 +371,8 @@ export const useKokoTerminalSocket = () => {
367371
rightClickSelectsWord: true,
368372
scrollback: 5000,
369373
scrollOnUserInput: true,
370-
theme: terminalTheme(defaultTerminalCfg.themeName),
374+
theme: followAppTheme.value ? appTerminalTheme() : terminalTheme(defaultTerminalCfg.themeName),
375+
minimumContrastRatio: 4.5,
371376
allowProposedApi: true,
372377
customGlyphs: true
373378
});
@@ -390,10 +395,24 @@ export const useKokoTerminalSocket = () => {
390395
socketRef.value = ws.value;
391396
};
392397

398+
// dark/light 切 class、preset 切 data-theme-preset、Luna 预设写内联 style,三种路径都在 <html> 属性上
399+
const observeAppTheme = () => {
400+
if (!followAppTheme.value || !import.meta.client) return;
401+
themeObserver = new MutationObserver(() => {
402+
if (!terminalRef.value) return;
403+
terminalRef.value.options.theme = appTerminalTheme();
404+
});
405+
themeObserver.observe(document.documentElement, {
406+
attributes: true,
407+
attributeFilter: ["class", "data-theme-preset", "style"]
408+
});
409+
};
410+
393411
onMounted(() => {
394412
if (!containerRef.value) return;
395413
createTerminal();
396414
createWebSocket();
415+
observeAppTheme();
397416
nextTick(() => {
398417
listenSocketEvent();
399418
listenTerminalRefEvent();
@@ -405,6 +424,7 @@ export const useKokoTerminalSocket = () => {
405424

406425
onUnmounted(() => {
407426
autoTerminalFit();
427+
themeObserver?.disconnect();
408428
if (pingInterval.value) clearInterval(pingInterval.value);
409429
if (warningInterval.value) clearInterval(warningInterval.value);
410430
socketRef.value?.close();

ui/koko/utils/color.check.mts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
// 自检:node ui/koko/utils/color.check.mts
2+
import assert from "node:assert/strict";
3+
import { isDarkColor, parseColorToRgb } from "./color.ts";
4+
5+
assert.deepEqual(parseColorToRgb("#1e1e2e"), [30, 30, 46]);
6+
assert.deepEqual(parseColorToRgb("#fff"), [255, 255, 255]);
7+
assert.deepEqual(parseColorToRgb("rgb(239, 241, 245)"), [239, 241, 245]);
8+
assert.deepEqual(parseColorToRgb("rgba(30, 30, 46, 0.5)"), [30, 30, 46]);
9+
// color-mix() 计算值的序列化形式
10+
assert.deepEqual(parseColorToRgb("color(srgb 0.936 0.944 0.959)"), [239, 241, 245]);
11+
assert.deepEqual(parseColorToRgb("color(srgb 0.1 0.1 0.15 / 0.5)"), [26, 26, 38]);
12+
assert.deepEqual(parseColorToRgb("color(srgb 100% 0% 50%)"), [255, 0, 128]);
13+
assert.equal(parseColorToRgb("color-mix(in srgb, red, blue)"), null);
14+
assert.equal(parseColorToRgb(""), null);
15+
16+
assert.equal(isDarkColor("color(srgb 0.936 0.944 0.959)"), false, "latte main-bg resolves light");
17+
18+
assert.equal(isDarkColor("#1e1e2e"), true, "mocha bg is dark");
19+
assert.equal(isDarkColor("#eff1f5"), false, "latte bg is light");
20+
assert.equal(isDarkColor("rgb(255, 255, 255)"), false);
21+
assert.equal(isDarkColor("garbage"), true, "unparsable falls back to dark");
22+
23+
console.log("color.check: ok");

ui/koko/utils/color.ts

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
// 纯函数、零依赖:可被 node 直接运行自检(见 color.check.mts)
2+
3+
export function parseColorToRgb(color: string): [number, number, number] | null {
4+
const value = color.trim();
5+
6+
const hex = value.match(/^#([0-9a-f]{3}|[0-9a-f]{6})$/i)?.[1];
7+
if (hex) {
8+
const full = hex.length === 3 ? hex.split("").map((c) => c + c).join("") : hex;
9+
return [
10+
Number.parseInt(full.slice(0, 2), 16),
11+
Number.parseInt(full.slice(2, 4), 16),
12+
Number.parseInt(full.slice(4, 6), 16)
13+
];
14+
}
15+
16+
const rgb = value.match(/^rgba?\(\s*(\d+(?:\.\d+)?)[\s,]+(\d+(?:\.\d+)?)[\s,]+(\d+(?:\.\d+)?)/i);
17+
if (rgb) {
18+
const channels = [Math.round(Number(rgb[1])), Math.round(Number(rgb[2])), Math.round(Number(rgb[3]))];
19+
if (channels.some((c) => c > 255)) return null;
20+
return channels as [number, number, number];
21+
}
22+
23+
// color-mix() 的计算值序列化为 color(srgb r g b [/ a]),通道是 0-1 浮点或百分比
24+
const srgb = value.match(/^color\(srgb\s+([\d.]+%?)\s+([\d.]+%?)\s+([\d.]+%?)/i);
25+
if (srgb) {
26+
const toByte = (channel: string) => {
27+
const ratio = channel.endsWith("%") ? Number(channel.slice(0, -1)) / 100 : Number(channel);
28+
return Math.round(Math.min(Math.max(ratio, 0), 1) * 255);
29+
};
30+
return [toByte(srgb[1]!), toByte(srgb[2]!), toByte(srgb[3]!)];
31+
}
32+
33+
return null;
34+
}
35+
36+
/** 相对亮度 < 0.5 视为暗色背景(ponytail: 简单线性亮度,够用于二分明暗) */
37+
export function isDarkColor(color: string): boolean {
38+
const rgb = parseColorToRgb(color);
39+
if (!rgb) return true;
40+
const [r, g, b] = rgb;
41+
return (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255 < 0.5;
42+
}

ui/koko/utils/guard.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,8 @@ export function getDefaultTerminalConfig(): ITerminalSettings {
5353
return {
5454
fontSize,
5555
lineHeight: 1.2,
56-
fontFamily: "monaco, Consolas, \"Lucida Console\", monospace",
56+
// 与 main.css --font-mono 一致(xterm 不解析 CSS 变量,写具体字体串)
57+
fontFamily: "\"SFMono-Regular\", \"JetBrains Mono\", \"Cascadia Code\", \"Fira Code\", \"Menlo\", \"Consolas\", monospace",
5758
themeName,
5859
quickPaste: commandLine.is_right_click_quickly_paste ? "1" : "0",
5960
ctrlCAsCtrlZ: "0",

ui/koko/utils/terminalTheme.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,88 @@
1+
import type { ITheme } from "@xterm/xterm";
2+
13
import xtermTheme from "xterm-theme";
4+
import { isDarkColor, parseColorToRgb } from "~/koko/utils/color";
25
import { defaultTheme } from "~/koko/utils/config";
36

7+
// 暗色背景下的 ANSI 16 色(柔和高亮度,保证对比度)
8+
const DARK_ANSI = {
9+
black: "#3f4451",
10+
red: "#e06c75",
11+
green: "#98c379",
12+
yellow: "#e5c07b",
13+
blue: "#61afef",
14+
magenta: "#c678dd",
15+
cyan: "#56b6c2",
16+
white: "#d7dae0",
17+
brightBlack: "#5c6370",
18+
brightRed: "#ef7b85",
19+
brightGreen: "#a9d47f",
20+
brightYellow: "#f0cf8a",
21+
brightBlue: "#74bdf7",
22+
brightMagenta: "#d58ae5",
23+
brightCyan: "#6cc9d5",
24+
brightWhite: "#f0f2f5"
25+
};
26+
27+
// 亮色背景下的 ANSI 16 色(加深,避免浅底浅字)
28+
const LIGHT_ANSI = {
29+
black: "#3a3d45",
30+
red: "#ca1243",
31+
green: "#3d8a26",
32+
yellow: "#a8660d",
33+
blue: "#1a5fb4",
34+
magenta: "#8e3ab5",
35+
cyan: "#0e7490",
36+
white: "#a0a1a7",
37+
brightBlack: "#6b6f7a",
38+
brightRed: "#e45649",
39+
brightGreen: "#50a14f",
40+
brightYellow: "#c18401",
41+
brightBlue: "#2d78d6",
42+
brightMagenta: "#a626a4",
43+
brightCyan: "#0997b3",
44+
brightWhite: "#111111"
45+
};
46+
47+
/**
48+
* 主题变量大量用 color-mix(),xterm 只认 hex/rgb:
49+
* 用探针元素让浏览器解析(计算值可能是 color(srgb ...) 形式),再规范化成 rgb() 字符串
50+
*/
51+
function resolveCssColor(varName: string, fallback: string): string {
52+
if (!import.meta.client) return fallback;
53+
54+
const probe = document.createElement("span");
55+
probe.style.setProperty("color", `var(${varName})`);
56+
probe.style.setProperty("position", "absolute");
57+
probe.style.setProperty("visibility", "hidden");
58+
document.body.appendChild(probe);
59+
const resolved = getComputedStyle(probe).color;
60+
probe.remove();
61+
62+
const rgb = parseColorToRgb(resolved);
63+
return rgb ? `rgb(${rgb[0]}, ${rgb[1]}, ${rgb[2]})` : fallback;
64+
}
65+
66+
/** 跟随项目主题(data-theme-preset / dark-light / Luna 动态预设)的终端配色 */
67+
export function appTerminalTheme(): ITheme {
68+
const background = resolveCssColor("--app-main-bg", "#121414");
69+
const foreground = resolveCssColor("--app-fg", "#d7dae0");
70+
const accent = resolveCssColor("--theme-accent", "#1ab394");
71+
const dark = isDarkColor(background);
72+
73+
const [fr, fg, fb] = parseColorToRgb(foreground) ?? [215, 218, 224];
74+
75+
return {
76+
background,
77+
foreground,
78+
cursor: accent,
79+
cursorAccent: background,
80+
selectionBackground: `rgba(${fr}, ${fg}, ${fb}, 0.22)`,
81+
...(dark ? DARK_ANSI : LIGHT_ANSI)
82+
};
83+
}
84+
85+
/** xterm-theme 具名主题,用于显式指定 terminal_theme_name 的场景(独立 /koko/connect 路由) */
486
export const terminalTheme = (themeName: string) => {
587
if (!(xtermTheme as Record<string, typeof defaultTheme>)[themeName]) {
688
return defaultTheme;

ui/koko/workspace/SessionSurface.vue

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,6 @@ provide(connectorSessionKey, sessionContext);
2121
const token = computed(() => props.tab.payload?.token || props.tab.payload || {});
2222
const tokenId = computed(() => props.tab.payload?.id || token.value?.id || "");
2323
24-
const terminalThemeName = computed(() => (colorMode.value === "dark" ? "OneHalfDark" : "OneHalfLight"));
2524
const themeType = computed(() => (colorMode.value === "dark" ? "darkGary" : "default"));
2625
2726
async function fetchEndpointUrl() {
@@ -103,7 +102,7 @@ async function prepareSession() {
103102
ticket,
104103
endpointUrl,
105104
tabId: props.tab.id,
106-
terminalThemeName: terminalThemeName.value,
105+
// 不指定 terminalThemeName:终端跟随应用主题(appTerminalTheme)
107106
colorMode: colorMode.value,
108107
themeType: themeType.value,
109108
disableAutoHash: "false"
@@ -124,9 +123,8 @@ async function prepareSession() {
124123
}
125124
126125
watch(() => props.tab.payload, () => void prepareSession(), { deep: true });
127-
watch([terminalThemeName, themeType], () => {
126+
watch(themeType, () => {
128127
if (sessionContext.value) {
129-
sessionContext.value.terminalThemeName = terminalThemeName.value;
130128
sessionContext.value.themeType = themeType.value;
131129
sessionContext.value.colorMode = colorMode.value;
132130
}

0 commit comments

Comments
 (0)