feat: terminal ANSI colors and enhanced shell syntax highlighting

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Your Name
2026-06-01 15:49:27 +08:00
parent 799140f6c4
commit 1cfac23c01
5 changed files with 416 additions and 126 deletions
@@ -34,3 +34,17 @@
## 回滚
还原上述前端文件并重新构建部署。
---
## 补充(同日)— SSH 输出 ANSI 颜色
- 术语:**ANSI 颜色**(终端输出);底部输入栏为 **Shell 语法高亮**
- 前端:xterm 浅色主题补全 16 色 ANSI 调色板,`drawBoldTextInBrightColors`
- 后端:连接后 `export TERM=xterm-256color COLORTERM=truecolor FORCE_COLOR=1`
## 补充(同日)— Shell 语法高亮增强
- 新增 `frontend/src/utils/shellHighlight.ts`:命令/管道/重定向/字符串/变量/注释/路径等分词着色
- 新增 `ShellHighlightField.vue`:底部命令栏 + 快捷命令编辑框统一高亮
- 深浅色主题各一套配色(`:deep(.sh-*)`
@@ -0,0 +1,126 @@
<template>
<div
class="sh-field position-relative"
:class="{ 'sh-field--disabled': disabled, 'sh-field--empty': !modelValue }"
>
<div
v-if="modelValue"
class="sh-field__highlight font-mono"
:class="multiline ? 'sh-field__highlight--multi' : 'sh-field__highlight--single'"
aria-hidden="true"
v-html="highlighted"
/>
<component
:is="multiline ? 'textarea' : 'input'"
:value="modelValue"
:placeholder="placeholder"
:disabled="disabled"
:rows="multiline ? rows : undefined"
class="sh-field__input font-mono"
:class="multiline ? 'sh-field__input--multi' : 'sh-field__input--single'"
spellcheck="false"
autocomplete="off"
@input="onInput"
@keydown="onKeydown"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { highlightShellToHtml } from '@/utils/shellHighlight'
const props = withDefaults(
defineProps<{
modelValue: string
placeholder?: string
disabled?: boolean
multiline?: boolean
rows?: number
}>(),
{
placeholder: '',
disabled: false,
multiline: false,
rows: 3,
},
)
const emit = defineEmits<{
'update:modelValue': [value: string]
keydown: [event: KeyboardEvent]
}>()
const highlighted = computed(() => highlightShellToHtml(props.modelValue))
function onInput(e: Event) {
const el = e.target as HTMLInputElement | HTMLTextAreaElement
emit('update:modelValue', el.value)
}
function onKeydown(e: KeyboardEvent) {
emit('keydown', e)
}
</script>
<style scoped>
.sh-field__highlight,
.sh-field__input {
font-size: 0.875rem;
line-height: 1.5;
padding: 8px 12px;
border-radius: 4px;
width: 100%;
box-sizing: border-box;
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', Menlo, monospace;
}
.sh-field__highlight--single,
.sh-field__input--single {
min-height: 36px;
}
.sh-field__highlight--multi,
.sh-field__input--multi {
min-height: 72px;
resize: vertical;
}
.sh-field__highlight {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 1;
white-space: pre-wrap;
word-break: break-word;
overflow: hidden;
border: 1px solid transparent;
}
.sh-field__input {
position: relative;
z-index: 2;
background: transparent;
color: transparent;
caret-color: rgb(var(--v-theme-primary));
border: 1px solid rgba(var(--v-border-color), var(--v-border-opacity));
outline: none;
}
.sh-field__input:focus {
border-color: rgb(var(--v-theme-primary));
}
.sh-field--disabled .sh-field__input {
opacity: 0.5;
pointer-events: none;
}
.sh-field--empty .sh-field__input {
color: rgba(var(--v-theme-on-surface), 0.42);
}
.sh-field:not(.sh-field--empty) .sh-field__input {
color: transparent;
}
</style>
+75 -125
View File
@@ -1,5 +1,9 @@
<template>
<v-container fluid class="pa-0 d-flex flex-column terminal-root">
<v-container
fluid
class="pa-0 d-flex flex-column terminal-root"
:class="theme.global.current.value.dark ? 'terminal-root--dark' : 'terminal-root--light'"
>
<!-- Server Sidebar -->
<v-navigation-drawer v-model="showServerSidebar" width="250" location="start" temporary>
<v-list-subheader>服务器列表</v-list-subheader>
@@ -189,29 +193,18 @@
</v-btn>
</div>
<!-- Command Bar with Shell Highlight -->
<div class="d-flex align-center ga-2 px-3 py-1 shrink-0" style="background: rgb(var(--v-theme-surface)); border-top: 1px solid rgb(var(--v-theme-surface-variant))" :class="{ 'opacity-50 pointer-events-none': !activeSession?.ws }">
<!-- Command BarShell 语法高亮 -->
<div
class="d-flex align-center ga-2 px-3 py-1 shrink-0 cmd-bar"
:class="{ 'opacity-50 pointer-events-none': !activeSession?.ws }"
>
<span class="text-body-2 font-mono text-primary shrink-0"></span>
<!-- Highlighted command display -->
<div class="flex-grow-1 position-relative">
<div
v-if="cmdInput"
class="cmd-highlight font-mono text-body-2"
aria-hidden="true"
v-html="highlightedCmd"
/>
<v-text-field
<div class="flex-grow-1">
<ShellHighlightField
v-model="cmdInput"
placeholder="输入命令,Enter 发送…"
variant="outlined"
density="compact"
hide-details
class="font-mono cmd-input"
:class="{ 'cmd-input--has-content': !!cmdInput }"
autocomplete="off"
@keydown.enter="sendCmd"
@keydown.up.prevent="cmdHistoryUp"
@keydown.down.prevent="cmdHistoryDown"
placeholder="输入命令,Enter 发送…(语法高亮)"
:disabled="!activeSession?.ws"
@keydown="onCmdKeydown"
/>
</div>
<v-btn size="small" variant="tonal" color="primary" @click="sendCmd" :disabled="!activeSession?.ws">发送</v-btn>
@@ -252,7 +245,8 @@
<v-card-title>{{ qcEditId ? '编辑命令' : '添加命令' }}</v-card-title>
<v-card-text>
<v-text-field v-model="qcName" label="命令名称" variant="outlined" density="compact" class="mb-3" />
<v-textarea v-model="qcCmd" label="命令内容(含 \\r 回车)" variant="outlined" density="compact" rows="3" class="font-mono" />
<div class="text-caption text-medium-emphasis mb-1">命令内容Shell 语法高亮发送时自动补 \\r</div>
<ShellHighlightField v-model="qcCmd" multiline :rows="4" placeholder="systemctl status nginx" />
</v-card-text>
<v-card-actions>
<v-spacer />
@@ -276,6 +270,7 @@ import { WebLinksAddon } from '@xterm/addon-web-links'
import '@xterm/xterm/css/xterm.css'
import { useSnackbar } from '@/composables/useSnackbar'
import { buildWebSocketUrl } from '@/utils/wsUrl'
import ShellHighlightField from '@/components/ShellHighlightField.vue'
const route = useRoute()
const router = useRouter()
@@ -420,67 +415,21 @@ async function applyRouteQuery() {
await newSession(id, { path })
}
// ── Shell syntax highlighter ──
const SHELL_KEYWORDS = new Set([
'sudo', 'cd', 'ls', 'grep', 'find', 'cat', 'chmod', 'chown', 'systemctl',
'docker', 'apt', 'yum', 'dnf', 'pip', 'npm', 'npx', 'ssh', 'scp', 'rsync',
'curl', 'wget', 'echo', 'export', 'source', 'rm', 'cp', 'mv', 'mkdir',
'tar', 'gunzip', 'kill', 'ps', 'top', 'htop', 'nano', 'vim', 'vi',
'journalctl', 'crontab', 'ufw', 'iptables', 'nginx', 'supervisorctl',
'systemctl', 'service', 'df', 'du', 'free', 'top', 'uname', 'hostname',
'whoami', 'id', 'pwd', 'env', 'which', 'whereis', 'man', 'less', 'more',
'head', 'tail', 'wc', 'sort', 'uniq', 'awk', 'sed', 'xargs', 'tee',
'touch', 'ln', 'chmod', 'chown', 'chgrp', 'useradd', 'usermod', 'passwd',
'netstat', 'ss', 'ping', 'traceroute', 'dig', 'nslookup', 'ip',
])
const highlightedCmd = computed(() => {
const input = cmdInput.value
if (!input) return ''
return tokenizeShell(input).map(t => {
const escaped = t.value.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
switch (t.type) {
case 'keyword': return `<span class="sh-keyword">${escaped}</span>`
case 'sudo': return `<span class="sh-sudo">${escaped}</span>`
case 'pipe': return `<span class="sh-pipe">${escaped}</span>`
case 'redirect': return `<span class="sh-redirect">${escaped}</span>`
case 'string': return `<span class="sh-string">${escaped}</span>`
case 'flag': return `<span class="sh-flag">${escaped}</span>`
default: return escaped
}
}).join('')
})
interface ShellToken {
type: 'keyword' | 'sudo' | 'pipe' | 'redirect' | 'string' | 'flag' | 'text'
value: string
}
function tokenizeShell(input: string): ShellToken[] {
const tokens: ShellToken[] = []
// Match tokens: quoted strings, pipes, redirects, flags, words
const re = /(?:'[^']*'|"[^"]*"|\\.)|(\|{1,2}|&&|;|>>|>|<<|<)|(--?[a-zA-Z][\w-]*)|(\S+)/g
let match: RegExpExecArray | null
while ((match = re.exec(input)) !== null) {
const full = match[0]
if (match[1]) {
// Pipe / redirect / operator
const isPipe = full === '|' || full === '||'
tokens.push({ type: isPipe ? 'pipe' : 'redirect', value: full })
} else if (match[2]) {
// Flag (--help, -a, -la)
tokens.push({ type: 'flag', value: full })
} else if (full.startsWith("'") || full.startsWith('"')) {
tokens.push({ type: 'string', value: full })
} else if (full === 'sudo') {
tokens.push({ type: 'sudo', value: full })
} else if (SHELL_KEYWORDS.has(full)) {
tokens.push({ type: 'keyword', value: full })
} else {
tokens.push({ type: 'text', value: full })
}
function onCmdKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault()
sendCmd()
return
}
if (e.key === 'ArrowUp') {
e.preventDefault()
cmdHistoryUp()
return
}
if (e.key === 'ArrowDown') {
e.preventDefault()
cmdHistoryDown()
}
return tokens
}
// ── Terminal ref management ──
@@ -508,12 +457,18 @@ function createTerminal(): Terminal {
brightYellow: '#fde047', brightBlue: '#93c5fd', brightMagenta: '#d8b4fe',
brightCyan: '#67e8f9', brightWhite: '#f8fafc',
} : {
background: '#ffffff',
foreground: '#1e293b',
cursor: '#7c8bf4',
background: '#f8fafc',
foreground: '#0f172a',
cursor: '#4f46e5',
selectionBackground: '#bfdbfe',
black: '#1e293b', red: '#dc2626', green: '#16a34a', yellow: '#ca8a04',
blue: '#2563eb', magenta: '#9333ea', cyan: '#0891b2', white: '#f1f5f9',
brightBlack: '#64748b', brightRed: '#ef4444', brightGreen: '#22c55e',
brightYellow: '#eab308', brightBlue: '#3b82f6', brightMagenta: '#a855f7',
brightCyan: '#06b6d4', brightWhite: '#ffffff',
},
allowProposedApi: true,
drawBoldTextInBrightColors: true,
})
}
@@ -1166,44 +1121,39 @@ onBeforeUnmount(() => {
:deep(.xterm) { padding: 4px; height: 100%; }
:deep(.xterm-viewport) { overflow-y: auto !important; }
/* ── Shell syntax highlight colors ── */
.sh-keyword { color: #60a5fa; font-weight: 500; }
.sh-sudo { color: #f87171; font-weight: 600; }
.sh-pipe { color: #fbbf24; }
.sh-redirect { color: #fbbf24; }
.sh-string { color: #4ade80; }
.sh-flag { color: #c084fc; }
.cmd-bar {
background: rgb(var(--v-theme-surface));
border-top: 1px solid rgb(var(--v-theme-surface-variant));
}
/* ── Command input with highlight overlay ── */
.position-relative { position: relative; }
.cmd-highlight {
position: absolute;
inset: 0;
display: flex;
align-items: center;
padding: 0 12px;
pointer-events: none;
z-index: 1;
white-space: pre;
color: transparent;
line-height: 1.5;
font-size: inherit;
}
.cmd-input :deep(.v-field) {
background: transparent;
}
.cmd-input :deep(.v-field__input) {
color: transparent;
caret-color: #7c8bf4;
padding-top: 0;
padding-bottom: 0;
min-height: 36px;
}
.cmd-input--has-content :deep(.v-field__input) {
color: transparent;
}
/* When no content, show normal text */
.cmd-input:not(.cmd-input--has-content) :deep(.v-field__input) {
color: inherit;
}
/* Shell 语法高亮(命令栏 + 快捷命令编辑) */
.terminal-root--dark :deep(.sh-keyword) { color: #60a5fa; font-weight: 500; }
.terminal-root--dark :deep(.sh-builtin) { color: #38bdf8; }
.terminal-root--dark :deep(.sh-sudo) { color: #f87171; font-weight: 600; }
.terminal-root--dark :deep(.sh-pipe),
.terminal-root--dark :deep(.sh-operator) { color: #fbbf24; font-weight: 600; }
.terminal-root--dark :deep(.sh-redirect) { color: #fb923c; }
.terminal-root--dark :deep(.sh-string) { color: #4ade80; }
.terminal-root--dark :deep(.sh-flag) { color: #c084fc; }
.terminal-root--dark :deep(.sh-variable) { color: #f472b6; }
.terminal-root--dark :deep(.sh-backtick) { color: #a78bfa; }
.terminal-root--dark :deep(.sh-comment) { color: #64748b; font-style: italic; }
.terminal-root--dark :deep(.sh-number) { color: #fbbf24; }
.terminal-root--dark :deep(.sh-path) { color: #2dd4bf; }
.terminal-root--dark :deep(.sh-text) { color: #e2e8f0; }
.terminal-root--light :deep(.sh-keyword) { color: #2563eb; font-weight: 500; }
.terminal-root--light :deep(.sh-builtin) { color: #0284c7; }
.terminal-root--light :deep(.sh-sudo) { color: #dc2626; font-weight: 600; }
.terminal-root--light :deep(.sh-pipe),
.terminal-root--light :deep(.sh-operator) { color: #ca8a04; font-weight: 600; }
.terminal-root--light :deep(.sh-redirect) { color: #ea580c; }
.terminal-root--light :deep(.sh-string) { color: #16a34a; }
.terminal-root--light :deep(.sh-flag) { color: #9333ea; }
.terminal-root--light :deep(.sh-variable) { color: #db2777; }
.terminal-root--light :deep(.sh-backtick) { color: #7c3aed; }
.terminal-root--light :deep(.sh-comment) { color: #94a3b8; font-style: italic; }
.terminal-root--light :deep(.sh-number) { color: #d97706; }
.terminal-root--light :deep(.sh-path) { color: #0d9488; }
.terminal-root--light :deep(.sh-text) { color: #0f172a; }
</style>
+196
View File
@@ -0,0 +1,196 @@
/**
* Shell/bash syntax highlighting for command input (HTML overlay, XSS-safe).
*/
export type ShellTokenType =
| 'keyword'
| 'builtin'
| 'sudo'
| 'pipe'
| 'operator'
| 'redirect'
| 'string'
| 'flag'
| 'variable'
| 'backtick'
| 'comment'
| 'number'
| 'path'
| 'text'
export interface ShellToken {
type: ShellTokenType
value: string
}
const KEYWORDS = new Set([
'if', 'then', 'else', 'elif', 'fi', 'for', 'while', 'do', 'done', 'case', 'esac',
'in', 'function', 'return', 'break', 'continue', 'select', 'until',
'cd', 'ls', 'grep', 'egrep', 'fgrep', 'find', 'cat', 'chmod', 'chown',
'docker', 'podman', 'kubectl', 'apt', 'apt-get', 'yum', 'dnf', 'apk',
'pip', 'pip3', 'npm', 'npx', 'ssh', 'scp', 'rsync', 'curl', 'wget',
'tar', 'gzip', 'gunzip', 'kill', 'ps', 'top', 'htop', 'nano', 'vim', 'vi',
'journalctl', 'systemctl', 'service', 'supervisorctl', 'crontab', 'nginx',
'df', 'du', 'free', 'mount', 'uname', 'hostname', 'whoami', 'id', 'pwd',
'head', 'tail', 'wc', 'sort', 'uniq', 'awk', 'sed', 'cut', 'tr', 'xargs', 'tee',
'netstat', 'ss', 'ip', 'ping', 'dig', 'git', 'make', 'nohup', 'timeout', 'watch',
])
const BUILTINS = new Set([
'echo', 'printf', 'test', 'read', 'pushd', 'popd', 'exit', 'set', 'unset',
'export', 'source', 'alias', 'type', 'command', 'hash', 'help', 'history',
])
function classifyWord(word: string): ShellTokenType {
if (word === 'sudo') return 'sudo'
if (KEYWORDS.has(word)) return 'keyword'
if (BUILTINS.has(word)) return 'builtin'
if (/^[/~]/.test(word) || word.startsWith('./') || word.startsWith('../')) return 'path'
if (/^\d+(?:\.\d+)?$/.test(word)) return 'number'
return 'text'
}
/** Tokenize shell command line (bash-oriented, best-effort). */
export function tokenizeShell(input: string): ShellToken[] {
const tokens: ShellToken[] = []
let i = 0
while (i < input.length) {
const ch = input[i]
if (ch === '#') {
let j = i + 1
while (j < input.length && input[j] !== '\n') j++
tokens.push({ type: 'comment', value: input.slice(i, j) })
i = j
continue
}
if (ch === "'" || ch === '"') {
const q = ch
let j = i + 1
while (j < input.length) {
if (input[j] === '\\' && q === '"') {
j += 2
continue
}
if (input[j] === q) {
j++
break
}
j++
}
tokens.push({ type: 'string', value: input.slice(i, j) })
i = j
continue
}
if (ch === '`') {
let j = i + 1
while (j < input.length && input[j] !== '`') j++
if (j < input.length) j++
tokens.push({ type: 'backtick', value: input.slice(i, j) })
i = j
continue
}
if (ch === '$') {
let j = i + 1
if (input[j] === '{') {
while (j < input.length && input[j] !== '}') j++
if (j < input.length) j++
} else if (input[j] === '(') {
j++
} else {
while (j < input.length && /[\w]/.test(input[j])) j++
}
tokens.push({ type: 'variable', value: input.slice(i, j) })
i = j
continue
}
if (ch === '|' || ch === '&' || ch === ';') {
let j = i
if ((input[j] === '|' && input[j + 1] === '|') || (input[j] === '&' && input[j + 1] === '&')) {
j += 2
} else {
j += 1
}
const v = input.slice(i, j)
tokens.push({ type: v === '|' || v === '||' ? 'pipe' : 'operator', value: v })
i = j
continue
}
if (ch === '>' || ch === '<') {
let j = i
if (input[j] === '>' && input[j + 1] === '>') j += 2
else if (input[j] === '<' && input[j + 1] === '<') j += 2
else j += 1
if (input[j] === '&') j++
tokens.push({ type: 'redirect', value: input.slice(i, j) })
i = j
continue
}
if (/\s/.test(ch)) {
let j = i
while (j < input.length && /\s/.test(input[j])) j++
tokens.push({ type: 'text', value: input.slice(i, j) })
i = j
continue
}
if (ch === '-' && input[i + 1] === '-') {
let j = i + 2
while (j < input.length && /[\w-]/.test(input[j])) j++
tokens.push({ type: 'flag', value: input.slice(i, j) })
i = j
continue
}
if (ch === '-' && /[a-zA-Z]/.test(input[i + 1] || '')) {
let j = i + 1
while (j < input.length && /[a-zA-Z]/.test(input[j])) j++
tokens.push({ type: 'flag', value: input.slice(i, j) })
i = j
continue
}
let j = i
while (j < input.length && !/[\s#"'`$|;&<>]/.test(input[j])) j++
const word = input.slice(i, j)
tokens.push({ type: classifyWord(word), value: word })
i = j
}
return tokens
}
function escapeHtml(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
}
const CLASS_MAP: Record<ShellTokenType, string> = {
keyword: 'sh-keyword',
builtin: 'sh-builtin',
sudo: 'sh-sudo',
pipe: 'sh-pipe',
operator: 'sh-operator',
redirect: 'sh-redirect',
string: 'sh-string',
flag: 'sh-flag',
variable: 'sh-variable',
backtick: 'sh-backtick',
comment: 'sh-comment',
number: 'sh-number',
path: 'sh-path',
text: 'sh-text',
}
/** Render highlighted HTML for overlay (use only with escaped content). */
export function highlightShellToHtml(input: string): string {
if (!input) return ''
return tokenizeShell(input)
.map((t) => `<span class="${CLASS_MAP[t.type]}">${escapeHtml(t.value)}</span>`)
.join('')
}
+5 -1
View File
@@ -217,7 +217,11 @@ async def terminal_ws(
"server_id": server.id,
})
# Inject CWD tracking via OSC title escape
# Color + CWD: ANSI/256/truecolor for ls, grep, vim, etc.
shell.stdin.write(
"export TERM=xterm-256color COLORTERM=truecolor "
"FORCE_COLOR=1 CLICOLOR_FORCE=1\n"
)
shell.stdin.write('export PROMPT_COMMAND=\'echo -ne "\\033]0;${PWD}\\a"\'\n')
# ── Bidirectional relay ──