Compare commits

...
2 Commits
Author SHA1 Message Date
Bill b7c7c5cd76 fix(highlight): settings UI fixes from review — grouped view, import guard, basic flag
CI / typecheck + test + build (windows) (push) Canceled after 0s
- grouped view actually clusters: drop the priority column's defaultSortOrder
  that made antd re-sort the dataSource and undo the category clustering
- replace import is Popconfirm-guarded and the import mode resets to append
  each time the dialog opens (it stayed on the destructive choice)
- rule editor exposes the basic flag (basic-mode membership) with a switch;
  clearing it on edit now actually removes the flag
- rule/profile writes read the store at call time instead of the render-scoped
  array, so two writes in one React batch no longer drop the first
- profile editor lists the rules a non-empty profile leaves out (uses the
  previously dead excludedByProfile helper)
- bands editor keeps rows sorted by min; band preview keys by index (duplicate
  min no longer collides); clear-background also closes the bg picker
- TerminalView pushes compiled rules into the HighlightStream from an effect
  instead of during render
2026-09-28 13:06:40 +08:00
Bill 462da60977 perf(highlight): precompute SGR at compile, sorted span claiming, stream ESC-free fast path
- compileRules precomputes the full SGR open sequence per rule and per band;
  wrap() is now pure concatenation, bandOpen() a table lookup (output bytes
  unchanged, bg keeps its unvalidated white-fallback)
- claim() replaces the linear overlaps() scan: claimed spans stay sorted by
  start, O(1) append on the common left-to-right sweep plus one binary search
  otherwise (match-dense 200KB payload: -19% one-shot, -56% streamed)
- HighlightStream: bufferHasEsc flag skips the O(buffer) escape rescans when
  neither the held text nor the chunk contains ESC, fixing quadratic rescan on
  escape-free floods (plain 200KB streamed: -54%)
- applyHighlights tracks the consumed match budget incrementally instead of
  two budgets.reduce() passes per text token
- ESCAPE_RE now covers DCS/APC/PM/SOS (BEL or ST terminated) and single-char
  Fe escapes (DECSC/DECRC/NEL/RI/RIS, orphan ST); isIncompleteEscape holds cut
  tails of the new families; split-smoke exercises every cut point through them
2026-09-28 13:06:29 +08:00
12 changed files with 347 additions and 85 deletions

No files matched your search

+1 -1
View File
@@ -83,7 +83,7 @@ Electron + electron-vite + React 终端工具(本地终端 / SSH / SFTP)。
- 跟随主题(`settings.terminal.highlightThemeColors`,默认关):`src/renderer/src/theme/highlightColors.ts` 按**色相分桶**把规则颜色映射到当前主题的 ANSI 调色板(不用"最近色",否则语义会漂移),亮度决定用普通色还是 bright 色(这样 percent 的两档绿仍能区分);饱和度低于 0.15 的中性色保持原样;**背景色不映射**(它是文字底块,不是语义信号)。映射在编译期一次性完成,热路径零成本;编辑器预览走同一函数,否则预览会与终端不一致
- 统计(`settings.terminal.highlightStats`,默认关):引擎的 `applyHighlights`/`HighlightStream` 接受可选 `StatsSink`,**只在传了 sink 时才计数与计时**(默认路径不插桩);`TerminalView` 只在开关打开时挂 sink,并**每秒发布一次快照**(不是每块),否则忙碌的终端会把设置页重渲染到卡死;设置页通过 `subscribeHighlightStats` 订阅,多出「命中/耗时」两列(耗时按 µs/ms 格式化)
- 性能护栏(别拆):`MAX_CHUNK` 512KB 整块跳过、`MAX_LINE_LEN` 4KB 超长行不跑规则(挡 `(a+)+b` 这类回溯)、`MAX_PER_RULE` 300 每条规则每块上限。实测 180–200KB 混合输出:**全预设 4–9ms/块**(0.02–0.05ms/KB,取决于转义序列密度;典型 4KB chunk ≈ 0.05–0.26ms)、**basic 档 ~1.3ms/块(≈49µs/chunk)**、**off 档 0**;单条规则最贵的是 `http`(0.82ms)、`percent`、`danger`
- 总开关是**三档模式** `settings.terminal.highlightMode`(`all` / `basic` / `off`,读取一律过 `highlightModeOf` 兜底):`basic` 只跑带 `basic: true` 的规则(danger/secret/okstate/warnstate/badstate 这 5 条),`off` 时 `TerminalView` 把规则集清空而不是绕过 `HighlightStream`——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本
- 总开关是**三档模式** `settings.terminal.highlightMode`(`all` / `basic` / `off`,读取一律过 `highlightModeOf` 兜底):`basic` 只跑带 `basic: true` 的规则(预设里是 danger/secret/okstate/warnstate/badstate 这 5 条;用户可在规则编辑器里用「基础规则」开关给自建规则打标),`off` 时 `TerminalView` 把规则集清空而不是绕过 `HighlightStream`——stream 会 hold 住尾部文本,绕过会丢字节;空规则集在 tokenize 之前就返回,几乎零成本
- 按主机绑定规则集(`settings.terminal.highlightPerHost`,默认关):`HighlightProfile { id, name, ruleIds }` 存在 `AppSettings.highlightProfiles`(顶层数组,仿 `customThemes`),清洗在 `src/shared/highlightProfiles.ts`;`ruleIds` **为空 = 全部规则**,`rulesForProfile` 对未绑定 / 绑到不存在的 id / 空 profile 一律回落到全集,`excludedByProfile` 给设置页算「这个 profile 排除了哪些规则」。绑定存在 **`connections.json`**(`SshConnection.highlightProfileId`)而不是 settings,`TerminalView` 只在 mount 时查一次连接——改了绑定要重开会话才生效。新增连接字段时必须同时改三处:`PUBLIC_KEYS`(update 路径靠它回写)、`toPublic()`、`saveConnection()` 的新记录字面量,漏一处该字段会在某条路径上静默丢失
- 编辑器预览的输入要**截断**(前 2000 字符):200KB 样本会产出 7000+ 个 span,React 每敲一个键重渲染会卡
- `caseInsensitive: true` 的规则编译成 `gi`(规则级开关,默认关闭);预设里 `okstate` 带 `(?<!not\s)` 反向断言,把 `not ok` 让给 `badstate`
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useState } from 'react'
import { Alert, Button, Input, Modal, Radio, Space } from 'antd'
import { Alert, Button, Input, Modal, Popconfirm, Radio, Space } from 'antd'
import { t } from '@shared/i18n'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '@shared/highlightIO'
import type { ImportError } from '@shared/highlightIO'
@@ -40,6 +40,7 @@ export function HighlightImportExport({
setDraft('')
setResult(null)
setCopied(false)
setMode('append')
}, [open])
const handleCopy = async (): Promise<void> => {
@@ -83,7 +84,9 @@ export function HighlightImportExport({
setResult({ warnings: parsed.warnings, count: 0 })
return
}
await setHighlightRules(mergeRules(highlightRules, parsed.rules, mode))
// read at call time: the render-scoped array goes stale inside a React batch
const current = useSettingsStore.getState().settings.highlightRules
await setHighlightRules(mergeRules(current, parsed.rules, mode))
setResult({ warnings: parsed.warnings, count: parsed.rules.length })
}
@@ -145,14 +148,30 @@ export function HighlightImportExport({
<Radio.Button value="append">{t('settings.highlight.importAppend')}</Radio.Button>
<Radio.Button value="replace">{t('settings.highlight.importReplace')}</Radio.Button>
</Radio.Group>
<Button
size="small"
type="primary"
disabled={draft.trim() === ''}
onClick={() => void handleImport()}
>
{t('settings.highlight.importOk')}
</Button>
{mode === 'replace' ? (
// replace wipes the whole set, so it gets the same guard as deletes
<Popconfirm
title={t('settings.highlight.importReplaceTitle')}
description={t('settings.highlight.importReplaceDesc')}
okText={t('settings.highlight.importOk')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void handleImport()}
>
<Button size="small" type="primary" disabled={draft.trim() === ''}>
{t('settings.highlight.importOk')}
</Button>
</Popconfirm>
) : (
<Button
size="small"
type="primary"
disabled={draft.trim() === ''}
onClick={() => void handleImport()}
>
{t('settings.highlight.importOk')}
</Button>
)}
</Space>
{result && (
<Alert
@@ -2,6 +2,7 @@ import { useEffect, useState } from 'react'
import { Button, Checkbox, Input, Modal, Popconfirm, Switch, Table, Tooltip } from 'antd'
import type { ColumnsType } from 'antd/es/table'
import { t } from '@shared/i18n'
import { excludedByProfile } from '@shared/highlightProfiles'
import type { HighlightProfile } from '@shared/settings'
import { ruleLabel } from './ruleLabel'
import { useSettingsStore } from './store'
@@ -39,11 +40,15 @@ export function HighlightProfiles(): React.JSX.Element {
if (name.trim() === '') return
setSaving(true)
try {
const next = editing
? profiles.map((profile) =>
profile.id === editing.id ? { ...profile, name: name.trim(), ruleIds } : profile
// read at call time: the render-scoped array goes stale inside a React
// batch, and two writes in one batch would silently drop the first
const current = useSettingsStore.getState().settings.highlightProfiles
const target = editingId !== null && editingId !== '' ? current.find((profile) => profile.id === editingId) : undefined
const next = target
? current.map((profile) =>
profile.id === target.id ? { ...profile, name: name.trim(), ruleIds } : profile
)
: [...profiles, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
: [...current, { id: crypto.randomUUID(), name: name.trim(), ruleIds }]
await setHighlightProfiles(next)
setEditingId(null)
} finally {
@@ -51,6 +56,16 @@ export function HighlightProfiles(): React.JSX.Element {
}
}
/**
* What the current selection leaves out, shown under the checkboxes so the
* user sees which rules a host bound to this profile will NOT run. An empty
* selection means "all rules", so nothing is excluded by definition.
*/
const excluded =
ruleIds.length > 0 && ruleIds.length < rules.length
? excludedByProfile(rules, { id: editingId ?? '', name, ruleIds })
: []
const columns: ColumnsType<HighlightProfile> = [
{
title: t('settings.highlight.profileName'),
@@ -84,7 +99,11 @@ export function HighlightProfiles(): React.JSX.Element {
okText={t('common.delete')}
cancelText={t('common.cancel')}
okButtonProps={{ danger: true }}
onConfirm={() => void setHighlightProfiles(profiles.filter((p) => p.id !== record.id))}
onConfirm={() =>
void setHighlightProfiles(
useSettingsStore.getState().settings.highlightProfiles.filter((p) => p.id !== record.id)
)
}
>
<Button size="small" danger>
{t('common.delete')}
@@ -154,6 +173,14 @@ export function HighlightProfiles(): React.JSX.Element {
onChange={(values) => setRuleIds(values as string[])}
options={rules.map((rule) => ({ value: rule.id, label: ruleLabel(rule) }))}
/>
{excluded.length > 0 && (
<span className="hl-editor-hint">
{t('settings.highlight.profileExcluded', {
n: excluded.length,
list: excluded.map((rule) => ruleLabel(rule)).join('; ')
})}
</span>
)}
</div>
</div>
</div>
+38 -11
View File
@@ -35,8 +35,6 @@ import {
import { applyThemeColors } from '../theme/highlightColors'
import './highlight.css'
const ORDERED_SORT: 'ascend' = 'ascend'
/** preset color swatches for the highlighting editor. */
const PRESET_COLORS = ['#3fb950', '#f85149', '#e3b341', '#58a6ff', '#d2a8ff', '#79c0ff', '#bc8cff', '#f2cc60']
@@ -92,6 +90,7 @@ interface RuleDraft {
caseInsensitive: boolean
note?: string
enabled: boolean
basic: boolean
}
interface EditorState {
@@ -115,6 +114,8 @@ export function HighlightTab(): React.JSX.Element {
const rows = [...highlightRules].sort((a, b) => a.priority - b.priority)
// Grouped view: cluster by category, keeping priority order inside a group
// (Array.prototype.sort is stable, so the priority sort above survives).
// The table must not carry a defaultSortOrder — antd would re-sort the
// dataSource globally and undo this clustering.
return groupByCategory ? rows.sort((a, b) => categoryRank(a) - categoryRank(b)) : rows
}, [highlightRules, groupByCategory])
@@ -123,7 +124,10 @@ export function HighlightTab(): React.JSX.Element {
const closeEditor = (): void => setEditor((prev) => (prev.open ? { ...prev, open: false } : prev))
const replaceRule = async (updated: HighlightRule): Promise<void> => {
const next = highlightRules.map((r) => (r.id === updated.id ? updated : r))
// read at call time: the render-scoped array goes stale inside a React
// batch, and two writes in one batch would silently drop the first
const current = useSettingsStore.getState().settings.highlightRules
const next = current.map((r) => (r.id === updated.id ? updated : r))
await setHighlightRules(next)
}
@@ -132,7 +136,8 @@ export function HighlightTab(): React.JSX.Element {
}
const handleDelete = async (id: string): Promise<void> => {
await setHighlightRules(highlightRules.filter((r) => r.id !== id))
const current = useSettingsStore.getState().settings.highlightRules
await setHighlightRules(current.filter((r) => r.id !== id))
}
const handleReset = async (): Promise<void> => {
@@ -206,7 +211,6 @@ export function HighlightTab(): React.JSX.Element {
width: 76,
align: 'center',
sorter: (a, b) => a.priority - b.priority,
defaultSortOrder: ORDERED_SORT,
render: (priority: number) => <span className="hl-cell-priority">{priority}</span>
},
{
@@ -217,8 +221,8 @@ export function HighlightTab(): React.JSX.Element {
render: (color: HighlightRule['color'], record) =>
record.bands && record.bands.length > 0 ? (
<div className="hl-band-preview">
{record.bands.map((band) => (
<Tooltip key={band.min} title={`≥ ${band.min}`}>
{record.bands.map((band, index) => (
<Tooltip key={index} title={`≥ ${band.min}`}>
<span className="hl-preview-text" style={{ color: band.fg }}>
≥{band.min}
</span>
@@ -423,7 +427,8 @@ function HighlightEditor({
bg: undefined,
caseInsensitive: false,
note: undefined,
enabled: true
enabled: true,
basic: false
})
const [saving, setSaving] = useState(false)
const [showBg, setShowBg] = useState(false)
@@ -485,9 +490,10 @@ function HighlightEditor({
const note = editing ? editing.note : undefined
const enabled = editing ? editing.enabled : true
const caseInsensitive = editing ? editing.caseInsensitive === true : false
const basic = editing ? editing.basic === true : false
const bands = editing?.bands
const category = editing?.category
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled })
setDraft({ pattern, priority, fg, bg, bands, category, caseInsensitive, note, enabled, basic })
setShowBg(bg != null)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [open])
@@ -507,7 +513,12 @@ function HighlightEditor({
const updateBand = (index: number, partial: Partial<{ min: number; fg: string }>): void => {
const bands = draft.bands
if (!bands) return
patch({ bands: bands.map((band, i) => (i === index ? { ...band, ...partial } : band)) })
const next = bands.map((band, i) => (i === index ? { ...band, ...partial } : band))
// the engine applies bands in ascending min order (compileBands sorts), so
// keep the editor rows in that same order — a stable sort keeps equal mins
// in their entry order
next.sort((a, b) => a.min - b.min)
patch({ bands: next })
}
const removeBand = (index: number): void => {
@@ -535,6 +546,7 @@ function HighlightEditor({
bands,
category: draft.category,
caseInsensitive,
...(draft.basic ? { basic: true as const } : {}),
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules([...highlightRules, rule])
@@ -548,6 +560,9 @@ function HighlightEditor({
bands,
category: draft.category,
caseInsensitive,
// explicit override (not a conditional spread): unchecking must clear
// a basic flag the edited rule may already carry
basic: draft.basic ? true : undefined,
note: draft.note?.trim() ? draft.note.trim() : undefined
}
await setHighlightRules(highlightRules.map((r) => (r.id === rule.id ? rule : r)))
@@ -646,7 +661,13 @@ function HighlightEditor({
value={draft.bg ?? PRESET_COLORS[0]}
onChange={(v) => patch({ bg: v })}
/>
<Button size="small" onClick={() => patch({ bg: undefined })}>
<Button
size="small"
onClick={() => {
patch({ bg: undefined })
setShowBg(false)
}}
>
{t('settings.highlight.clearBg')}
</Button>
</>
@@ -742,6 +763,12 @@ function HighlightEditor({
/>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.basic')}</span>
<Switch checked={draft.basic} onChange={(c) => patch({ basic: c })} />
<span className="hl-editor-hint">{t('settings.highlight.basicHint')}</span>
</div>
<div className="hl-editor-row">
<span className="hl-editor-label">{t('settings.highlight.enabled')}</span>
<Switch checked={draft.enabled} onChange={(c) => patch({ enabled: c })} />
+5 -1
View File
@@ -386,7 +386,11 @@ export const TerminalView: ForwardRefExoticComponent<TerminalViewProps & { ref?:
const timer = window.setInterval(() => publishHighlightStats(new Map(sink)), 1000)
return () => window.clearInterval(timer)
}, [settings.terminal.highlightStats])
streamRef.current?.setRules(compiledRules)
// Push the latest compiled rules into the stream from an effect — assigning
// during render would be a side effect in the render body.
useEffect(() => {
streamRef.current?.setRules(compiledRules)
}, [compiledRules])
// M5: load completion candidates once per session (history + library cached).
const ensureCompletionCache = useCallback((): void => {
+158 -54
View File
@@ -14,14 +14,18 @@ export interface CompiledRule {
id: string
/** compiled with the 'g' flag (plus 'i' for case-insensitive rules) */
regex: RegExp
/** hex fg, e.g. "#3fb950" — converted to rgb only at wrap time */
/** hex fg, e.g. "#3fb950" — kept for introspection; `open` is precomputed from it */
fg: string
/** optional hex bg */
/** optional hex bg (not re-validated; a malformed value folds to white, as before) */
bg?: string
/** optional value bands, ascending by `min` — see HighlightRule.bands */
bands?: { min: number; fg: string }[]
bands?: { min: number; fg: string; open: string }[]
/** copied from the source rule for stable priority ordering */
priority: number
/** precomputed truecolor fg fragment, e.g. "38;2;63;185;80" */
fgSgr: string
/** full SGR open sequence `\x1b[38;2;…(;48;2;…)?m` incl. optional bg, built once here */
open: string
}
/** Per-rule counters, only filled when a stats sink is passed in (see `StatsSink`). */
@@ -39,11 +43,26 @@ export interface RuleStat {
export type StatsSink = Map<string, RuleStat>
type Token = { kind: 'seq' | 'text'; value: string }
type Span = { start: number; end: number; fg: string; bg?: string }
/** A claimed span carries its precomputed SGR open sequence, never a hex colour. */
type Span = { start: number; end: number; open: string }
/** ANSI escape tokens: CSI, OSC, two-char (ESC ( / ) ..), and ESC = / > . */
/**
* ANSI escape tokens:
* - CSI `ESC [ … final`
* - OSC `ESC ] … (BEL | ST)`
* - DCS/APC/PM/SOS `ESC P|X|^|_ … (BEL | ST)` — tmux, sixel, kitty graphics.
* The `[^\x1b]*` body means an unterminated one stops at the next ESC
* instead of swallowing the rest of the buffer (it then stays a text run,
* which applyHighlights passes through raw).
* - two-char charset `ESC ( x` / `ESC ) x`, keypad modes `ESC =` / `ESC >`
* - single-char Fe escapes: `ESC` + one 0x30–0x7E byte (DECSC `\x1b7`,
* DECRC `\x1b8`, NEL `\x1bE`, RI `\x1bM`, RIS `\x1bc`, orphan ST `\x1b\`, …).
* Last in the alternation, and the initiators of the longer forms
* (`[ ] ( ) P X ^ _ = >`) are excluded from the class so it can never steal
* the leading bytes of a longer sequence.
*/
const ESCAPE_RE =
/(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>])/g
/(\x1b\[[0-9;:?]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[PX^_][^\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>]|\x1b[0-9:;<?@A-OQ-WYZ\\`a-z{|}~])/g
/** Matches a single rule may inject per chunk (bomb guard). */
const MAX_PER_RULE = 300
@@ -69,6 +88,12 @@ export function hexToRgb(hex: string): { r: number; g: number; b: number } {
return { r: (n >> 16) & 0xff, g: (n >> 8) & 0xff, b: n & 0xff }
}
/** "r;g;b" decimal SGR fragment for one hex colour (white fallback via hexToRgb). */
function rgbFragment(hex: string): string {
const { r, g, b } = hexToRgb(hex)
return `${r};${g};${b}`
}
/** Split a chunk into escape-sequence tokens and plain-text runs. */
export function tokenize(input: string): Token[] {
const out: Token[] = []
@@ -110,63 +135,94 @@ export function compileRules(rules: HighlightRule[]): CompiledRule[] {
const fg = typeof rule.color?.fg === 'string' ? rule.color.fg : ''
if (!/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(fg)) continue
const bg = typeof rule.color?.bg === 'string' ? rule.color.bg : undefined
out.push({ id: rule.id, regex, fg, bg, bands: compileBands(rule.bands), priority: rule.priority })
// bg stays unvalidated on purpose (as before): a malformed hex folds to
// white inside hexToRgb, byte-identical to the old wrap-time conversion.
const bgPart = bg ? `;48;2;${rgbFragment(bg)}` : ''
const fgSgr = rgbFragment(fg)
out.push({
id: rule.id,
regex,
fg,
bg,
bands: compileBands(rule.bands, bgPart),
priority: rule.priority,
fgSgr,
open: `\x1b[38;2;${fgSgr}${bgPart}m`
})
}
out.sort((a, b) => a.priority - b.priority)
return out
}
/** Keep only well-formed value bands, ascending — the engine walks them in order. */
function compileBands(bands: HighlightRule['bands']): CompiledRule['bands'] {
function compileBands(bands: HighlightRule['bands'], bgPart: string): CompiledRule['bands'] {
if (!Array.isArray(bands)) return undefined
const kept: { min: number; fg: string }[] = []
const kept: NonNullable<CompiledRule['bands']> = []
for (const band of bands) {
if (!band || !Number.isFinite(band.min)) continue
if (typeof band.fg !== 'string' || !/^#[0-9a-fA-F]{3}$|^#[0-9a-fA-F]{6}$/.test(band.fg)) continue
kept.push({ min: band.min, fg: band.fg })
kept.push({ min: band.min, fg: band.fg, open: `\x1b[38;2;${rgbFragment(band.fg)}${bgPart}m` })
}
if (kept.length === 0) return undefined
kept.sort((a, b) => a.min - b.min)
return kept
}
/** Wrap one plain-text span with truecolor SGR. */
function wrap(text: string, fgHex: string, bgHex?: string): string {
const f = hexToRgb(fgHex)
let s = `\x1b[38;2;${f.r};${f.g};${f.b}`
if (bgHex) {
const b = hexToRgb(bgHex)
s += `;48;2;${b.r};${b.g};${b.b}`
}
return `${s}m${text}\x1b[0m`
}
/** True when [s,e) overlaps any claimed span. */
function overlaps(claimed: Span[], s: number, e: number): boolean {
for (const c of claimed) {
if (s < c.end && e > c.start) return true
}
return false
/** Wrap one plain-text span with its precomputed SGR open sequence. */
function wrap(text: string, open: string): string {
return `${open}${text}\x1b[0m`
}
/**
* Colour for one match: with value bands the match's first number picks the
* band (last `min` that is <= the value wins); everything else keeps the rule's
* own foreground.
* Try to claim [s,e) for a span. Claimed spans are pairwise disjoint and kept
* sorted by start (ends ascend with starts), which turns the old linear scan
* over every claimed span (O(spans²) on match-dense runs) into:
* - an O(1) append when the candidate lands past every claim — the common
* case, since rule scans sweep a run left to right; otherwise
* - one binary search: the only possible clash is with the nearest span left
* of the insertion point.
* The span colour is only computed on accept. Returns true when claimed.
*/
function bandColor(rule: CompiledRule, matched: string): string {
function claim(claimed: Span[], s: number, e: number, rule: CompiledRule, matched: string): boolean {
const n = claimed.length
if (n === 0 || claimed[n - 1].end <= s) {
claimed.push({ start: s, end: e, open: bandOpen(rule, matched) })
return true
}
let lo = 0
let hi = n
while (lo < hi) {
const mid = (lo + hi) >> 1
if (claimed[mid].start < e) lo = mid + 1
else hi = mid
}
if (lo > 0 && claimed[lo - 1].end > s) return false
claimed.splice(lo, 0, { start: s, end: e, open: bandOpen(rule, matched) })
return true
}
/** First-number finder for band selection; no /g, so the shared literal is stateless. */
const BAND_NUMBER_RE = /-?\d+(?:\.\d+)?/
/**
* SGR open sequence for one match: with value bands the match's first number
* picks the band (last `min` that is <= the value wins); everything else keeps
* the rule's own precomputed open sequence. Pure table lookup — no colour math
* on the hot path.
*/
function bandOpen(rule: CompiledRule, matched: string): string {
const bands = rule.bands
if (!bands || bands.length === 0) return rule.fg
const number = /-?\d+(?:\.\d+)?/.exec(matched)
if (number === null) return rule.fg
if (!bands || bands.length === 0) return rule.open
const number = BAND_NUMBER_RE.exec(matched)
if (number === null) return rule.open
const value = Number(number[0])
if (!Number.isFinite(value)) return rule.fg
let fg = rule.fg
if (!Number.isFinite(value)) return rule.open
let open = rule.open
for (const band of bands) {
if (value < band.min) break
fg = band.fg
open = band.open
}
return fg
return open
}
/** True when `text` holds a newline-free segment longer than MAX_LINE_LEN. */
@@ -189,10 +245,17 @@ function hasLongLine(text: string): boolean {
/** Millisecond clock for the optional stats sink; `performance` when present. */
const now = (): number => (typeof performance !== 'undefined' ? performance.now() : Date.now())
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink): string {
if (text.length === 0) return ''
if (hasLongLine(text)) return text
function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?: StatsSink, spent?: { n: number }): string {
if (text.length === 0) {
if (spent !== undefined) spent.n = 0
return ''
}
if (hasLongLine(text)) {
if (spent !== undefined) spent.n = 0
return text
}
const claimed: Span[] = []
let totalMade = 0
for (let ri = 0; ri < rules.length; ri++) {
const rule = rules[ri]
if (budgets[ri] <= 0) continue
@@ -208,9 +271,9 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
rule.regex.lastIndex++
continue
}
if (!overlaps(claimed, start, end)) {
claimed.push({ start, end, fg: bandColor(rule, m[0]), bg: rule.bg })
if (claim(claimed, start, end, rule, m[0])) {
made++
totalMade++
budgets[ri]--
if (budgets[ri] <= 0) break
}
@@ -223,13 +286,14 @@ function applyRun(text: string, rules: CompiledRule[], budgets: number[], stats?
stats.set(rule.id, stat)
}
}
if (spent !== undefined) spent.n = totalMade
if (claimed.length === 0) return text
claimed.sort((a, b) => a.start - b.start)
// `claim` keeps the spans sorted by start — no final sort needed here.
let out = ''
let pos = 0
for (const c of claimed) {
if (c.start > pos) out += text.slice(pos, c.start)
out += wrap(text.slice(c.start, c.end), c.fg, c.bg)
out += wrap(text.slice(c.start, c.end), c.open)
pos = c.end
}
if (pos < text.length) out += text.slice(pos)
@@ -249,6 +313,9 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
let budgets = rules.map(() => MAX_PER_RULE)
let usedBudget = false
let out = ''
// applyRun reports how many matches it claimed, replacing the old two
// budgets.reduce() passes per text token.
const spent = { n: 0 }
for (const token of tokenize(chunk)) {
if (token.kind === 'seq') {
out += token.value
@@ -260,10 +327,9 @@ export function applyHighlights(chunk: string, rules: CompiledRule[], stats?: St
out += token.value
continue
}
const before = budgets.reduce((a, b) => a + b, 0)
out += applyRun(token.value, rules, budgets, stats)
const after = budgets.reduce((a, b) => a + b, 0)
if (after < before) usedBudget = true
spent.n = 0
out += applyRun(token.value, rules, budgets, stats, spent)
if (spent.n > 0) usedBudget = true
}
return usedBudget ? out : chunk
}
@@ -308,15 +374,30 @@ export function previewSpans(text: string, rules: CompiledRule[]): PreviewSpan[]
return spans
}
/** True when `seg` is a dangling, unfinished escape fragment (must not be emitted). */function isIncompleteEscape(seg: string): boolean {
/**
* True when `seg` is a dangling, unfinished escape fragment (must not be
* emitted before its terminator arrives).
*/
function isIncompleteEscape(seg: string): boolean {
// `seg` is expected to start at (or be) a lone ESC.
// A lone ESC — the leading half of any CSI/OSC/two-char/ST sequence.
// A lone ESC — the leading half of any CSI/OSC/DCS/two-char/ST/Fe sequence.
if (seg === '\x1b') return true
// Unfinished CSI: ESC [ plus parameter bytes but no final byte in 0x40..0x7E.
if (/^\x1b\[[0-9;:?]*$/.test(seg)) return true
// Unfinished OSC: ESC ] ... with no BEL (\x07) or ST (\x1b\ ) terminator.
// (A trailing half-ST is covered by the lone-ESC case above.)
if (seg.startsWith('\x1b]') && !seg.includes('\x07') && !seg.includes('\x1b\\')) return true
// Unfinished OSC / DCS / APC / PM / SOS: opened but no BEL (\x07) or ST
// (\x1b\) terminator yet, so the fragment could still grow into any of them.
// (A trailing half-ST — the ESC of `\x1b\` with the `\` still to come — is
// covered by the lone-ESC case above, since findIncompleteIndex slices from
// the *last* ESC.)
// NB: `[]` would be an *empty* class in JS regex, so `]` is kept in a
// startsWith instead of a merged character class.
if (
(seg.startsWith('\x1b]') || /^\x1b[PX^_]/.test(seg)) &&
!seg.includes('\x07') &&
!seg.includes('\x1b\\')
) {
return true
}
return false
}
@@ -356,6 +437,8 @@ export class HighlightStream {
private rules: CompiledRule[]
private stats?: StatsSink
private buffer = ''
/** Whether `buffer` holds any ESC — lets push skip the O(buffer) escape scans on escape-free floods. */
private bufferHasEsc = false
constructor(rules: CompiledRule[]) {
this.rules = rules
@@ -378,6 +461,20 @@ export class HighlightStream {
/** Join held bytes with `chunk`, return content safe to hand to xterm. */
push(chunk: string): string {
// Fast path: neither the held text nor the new chunk contains a single ESC,
// so findIncompleteIndex/lastEscapeEnd would both come back -1. Skip their
// O(buffer) rescans — they made escape-free floods (cat of a large text
// file) quadratic in the held buffer — and go straight to the trailing-text
// hold. Decision-identical to the full path below.
if (!this.bufferHasEsc && !chunk.includes('\x1b')) {
this.buffer += chunk
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer
this.buffer = ''
return applyHighlights(raw, this.rules, this.stats)
}
return ''
}
this.buffer += chunk
// (a) trailing incomplete escape -> hold it, emit the closed prefix.
@@ -389,9 +486,11 @@ export class HighlightStream {
// Give up waiting on a pathologically long unterminated sequence: emit
// the fragment raw (applyHighlights skips raw \x1b in text runs).
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(head, this.rules, this.stats) + tail
}
this.buffer = tail
this.bufferHasEsc = true // the held tail starts at the dangling ESC
return applyHighlights(head, this.rules, this.stats)
}
@@ -404,12 +503,16 @@ export class HighlightStream {
if (this.buffer.length > MAX_CHUNK) {
const raw = this.buffer
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(raw, this.rules, this.stats)
}
// The buffer (or the chunk that just joined it) brought the ESC in.
this.bufferHasEsc = true
return ''
}
const head = this.buffer.slice(0, lastEnd)
this.buffer = this.buffer.slice(lastEnd)
this.bufferHasEsc = this.buffer.includes('\x1b')
return applyHighlights(head, this.rules, this.stats)
}
@@ -417,6 +520,7 @@ export class HighlightStream {
flush(): string {
const b = this.buffer
this.buffer = ''
this.bufferHasEsc = false
return applyHighlights(b, this.rules, this.stats)
}
}
+6
View File
@@ -200,6 +200,9 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': 'Mode',
'settings.highlight.importReplace': 'Replace current rules',
'settings.highlight.importAppend': 'Append to current rules',
'settings.highlight.importReplaceTitle': 'Replace current rules?',
'settings.highlight.importReplaceDesc':
'Importing overwrites every current rule with this file. This cannot be undone.',
'settings.highlight.importOk': 'Import',
'settings.highlight.importEmpty': 'No usable rules found',
'settings.highlight.importBadJson': 'Not valid JSON',
@@ -214,6 +217,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeOff': 'Off',
'settings.highlight.modeDesc':
'Basic only: keeps just the key rules (errors, success, warnings, dangerous commands, secrets) — cheaper and quieter. Off: no colour injection at all (rules and colours are kept).',
'settings.highlight.basic': 'Basic rule',
'settings.highlight.basicHint': 'keep this rule active when the highlight mode is "Basic only"',
'settings.highlight.category': 'Category',
'settings.highlight.groupByCategory': 'Group by category',
'settings.highlight.category.safety': 'Safety',
@@ -245,6 +250,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': 'e.g. production (errors only)',
'settings.highlight.profileRules': 'Included rules',
'settings.highlight.profileRulesHint': 'Unchecked rules stay off on that host',
'settings.highlight.profileExcluded': 'Excluded {n}: {list}',
'settings.highlight.profileEmpty': 'No rule sets yet',
'settings.highlight.profileDeleteTitle': 'Delete rule set',
'settings.highlight.actions': 'Actions',
+6
View File
@@ -196,6 +196,9 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '取り込み方法',
'settings.highlight.importReplace': '現在のルールを置き換える',
'settings.highlight.importAppend': '現在のルールに追加する',
'settings.highlight.importReplaceTitle': '現在のルールを置き換えますか?',
'settings.highlight.importReplaceDesc':
'インポートすると現在のすべてのルールがこのファイルの内容で上書きされます。元に戻せません。',
'settings.highlight.importOk': 'インポート',
'settings.highlight.importEmpty': '有効なルールが見つかりません',
'settings.highlight.importBadJson': '有効な JSON ではありません',
@@ -210,6 +213,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeOff': 'オフ',
'settings.highlight.modeDesc':
'基本のみ:エラー/成功/警告/危険なコマンド/シークレットなど重要なルールだけを適用(軽量・低ノイズ)。オフ:色をまったく注入しません(ルールと色は保持)。',
'settings.highlight.basic': '基本ルール',
'settings.highlight.basicHint': 'オンにすると、ハイライトが「基本のみ」モードのときもこのルールが適用されます',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '分類でグループ化',
'settings.highlight.category.safety': '安全',
@@ -240,6 +245,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例:本番機(エラーのみ)',
'settings.highlight.profileRules': '含めるルール',
'settings.highlight.profileRulesHint': 'チェックを外したルールはそのホストでは効きません',
'settings.highlight.profileExcluded': '{n} 件を除外:{list}',
'settings.highlight.profileEmpty': 'ルールセットがまだありません',
'settings.highlight.profileDeleteTitle': 'ルールセットを削除',
'settings.highlight.actions': '操作',
+5
View File
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '导入方式',
'settings.highlight.importReplace': '替换现有规则',
'settings.highlight.importAppend': '追加到现有规则',
'settings.highlight.importReplaceTitle': '替换现有规则?',
'settings.highlight.importReplaceDesc': '导入后当前全部规则将被这份文件覆盖,无法恢复。',
'settings.highlight.importOk': '导入',
'settings.highlight.importEmpty': '没有解析到可用规则',
'settings.highlight.importBadJson': '不是有效的 JSON',
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeBasic': '仅基础',
'settings.highlight.modeOff': '关闭',
'settings.highlight.modeDesc': '仅基础:只保留错误/成功/警告/危险命令/密钥等关键规则,开销更小;关闭:完全不注入颜色(规则与颜色都保留)',
'settings.highlight.basic': '基础规则',
'settings.highlight.basicHint': '开启后,终端高亮为「仅基础」模式时该规则仍会生效',
'settings.highlight.category': '分类',
'settings.highlight.groupByCategory': '按分类分组',
'settings.highlight.category.safety': '安全',
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例如:生产机(只看错误)',
'settings.highlight.profileRules': '包含的规则',
'settings.highlight.profileRulesHint': '未勾选的规则在这台主机上不生效',
'settings.highlight.profileExcluded': '未包含 {n} 条:{list}',
'settings.highlight.profileEmpty': '尚未创建规则集',
'settings.highlight.profileDeleteTitle': '删除规则集',
'settings.highlight.actions': '操作',
+5
View File
@@ -190,6 +190,8 @@ const settings: Record<string, string> = {
'settings.highlight.importMode': '匯入方式',
'settings.highlight.importReplace': '取代現有規則',
'settings.highlight.importAppend': '附加到現有規則',
'settings.highlight.importReplaceTitle': '取代現有規則?',
'settings.highlight.importReplaceDesc': '匯入後目前全部規則將被這份檔案覆蓋,無法復原。',
'settings.highlight.importOk': '匯入',
'settings.highlight.importEmpty': '沒有解析到可用規則',
'settings.highlight.importBadJson': '不是有效的 JSON',
@@ -203,6 +205,8 @@ const settings: Record<string, string> = {
'settings.highlight.modeBasic': '僅基礎',
'settings.highlight.modeOff': '關閉',
'settings.highlight.modeDesc': '僅基礎:只保留錯誤/成功/警告/危險命令/密鑰等關鍵規則,開銷更小;關閉:完全不注入顏色(規則與顏色都保留)',
'settings.highlight.basic': '基礎規則',
'settings.highlight.basicHint': '開啟後,終端機高亮為「僅基礎」模式時該規則仍會生效',
'settings.highlight.category': '分類',
'settings.highlight.groupByCategory': '依分類分組',
'settings.highlight.category.safety': '安全',
@@ -230,6 +234,7 @@ const settings: Record<string, string> = {
'settings.highlight.profileNamePlaceholder': '例如:正式機(只看錯誤)',
'settings.highlight.profileRules': '包含的規則',
'settings.highlight.profileRulesHint': '未勾選的規則在這台主機上不生效',
'settings.highlight.profileExcluded': '未包含 {n} 條:{list}',
'settings.highlight.profileEmpty': '尚未建立規則集',
'settings.highlight.profileDeleteTitle': '刪除規則集',
'settings.highlight.actions': '操作',
+48 -1
View File
@@ -12,7 +12,7 @@
* --outfile=tests/.hl-rules.cjs --alias:@shared=./src/shared
* node tests/hl-rules.mjs
*/
import { compileRules, applyHighlights } from '../src/renderer/src/terminal/highlightEngine.ts'
import { compileRules, applyHighlights, __testHooks as hooks } from '../src/renderer/src/terminal/highlightEngine.ts'
import { previewSpans } from '../src/renderer/src/terminal/highlightEngine.ts'
import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
import { exportHighlightRules, mergeRules, parseHighlightRules } from '../src/shared/highlightIO.ts'
@@ -739,5 +739,52 @@ console.log('[profiles]')
)
}
// ---- 19. escape sequences pass through untouched ---------------------------
console.log('[escape sequences]')
{
// DCS / APC / PM / SOS bodies and single-char Fe escapes must keep their
// bytes raw — never wrapped in SGR — while text around them stays highlighted.
const families = [
['DCS (ST)', '\x1bP0;1;0q#0;2;0;0;0~..\x1b\\'],
['DCS (BEL)', '\x1bP1;2|9/9\x07'],
['APC (kitty graphics)', '\x1b_Gf=32,s=10,v=10,m=1\x1b\\'],
['PM', '\x1b^weather\x1b\\'],
['SOS', '\x1bXsos-frame\x1b\\']
]
// one coloured span, exactly as the engine emits it (ESC-free span text)
const SPAN_RE = /\x1b\[38;2;\d+;\d+;\d+(?:;48;2;\d+;\d+;\d+)?m[^\x1b]*\x1b\[0m/g
for (const [name, seq] of families) {
const out = applyHighlights(`DONE ${seq} ERROR`, rules)
ok(out.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), `${name}: text before it keeps its highlight`)
ok(out.includes(`${sgr(BAD_RED)}ERROR\x1b[0m`), `${name}: text after it keeps its highlight`)
ok(
out.replace(SPAN_RE, '').includes(seq),
`${name}: sequence bytes pass through raw, never wrapped in SGR`
)
}
const fe = applyHighlights('\x1b7DONE\x1b8 1', rules)
ok(fe.includes(`${sgr(OK_GREEN)}DONE\x1b[0m`), 'text between DECSC/DECRC keeps its highlight')
ok(fe.includes('\x1b7') && fe.includes('\x1b8'), 'DECSC/DECRC bytes pass through byte-for-byte')
// backstop: a text run holding a residual (unterminated) ESC passes through
// raw byte-for-byte — never partially highlighted, never corrupted
const raw = 'DONE \x1bP0;1;0qcut ERROR'
ok(applyHighlights(raw, rules) === raw, 'an unterminated DCS keeps its run byte-for-byte raw')
// the stream treats a cut DCS/APC/PM/SOS tail as incomplete, like OSC
ok(hooks.isIncompleteEscape('\x1b') === true, 'a lone trailing ESC stays incomplete')
ok(hooks.isIncompleteEscape('\x1bP0;1;0qcut') === true, 'a cut DCS tail counts as incomplete')
ok(hooks.isIncompleteEscape('\x1b_Gf=32') === true, 'a cut APC tail counts as incomplete')
ok(
hooks.isIncompleteEscape('\x1b^w') === true && hooks.isIncompleteEscape('\x1bXs') === true,
'cut PM/SOS tails count as incomplete'
)
ok(hooks.isIncompleteEscape('\x1bP0;1;0qx\x1b\\') === false, 'a terminated DCS is complete')
ok(hooks.isIncompleteEscape('\x1b7') === false, 'a complete two-byte Fe escape is complete')
ok(hooks.findIncompleteIndex('DONE \x1bP0;1;0qcut') === 5, 'the stream holds from the DCS initiator')
ok(hooks.findIncompleteIndex('DONE \x1b7') === -1, 'a trailing DECSC is not held as incomplete')
}
console.log(failed === 0 ? '\n[hl-rules] ALL CHECKS PASSED' : `\n[hl-rules] ${failed} CHECK(S) FAILED`)
process.exit(failed === 0 ? 0 : 1)
+14 -2
View File
@@ -7,12 +7,24 @@ import { DEFAULT_HIGHLIGHT_RULES } from '../src/shared/settings.ts'
const rules = compileRules(DEFAULT_HIGHLIGHT_RULES)
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints
// kimi-like: truecolor SGR, OSC title, box drawing, per-line repaints.
// The tail line exercises the string-terminated families (DCS/sixel, APC/kitty
// graphics, PM, SOS), a BEL-terminated DCS, and DECSC/DECRC (`\x1b7`/`\x1b8`)
// so every cut point through them is checked against the reference.
const banner =
'\x1b]0;kimi — session\x07' +
'\x1b[38;2;79;168;255m╭───────╮\x1b[0m\r\n' +
'\x1b[38;2;136;136;136mWelcome to Kimi Code!\x1b[0m \x1b[38;2;79;168;255m│\x1b[0m send /help\r\n' +
'\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n'
'\x1b[38;2;224;224;224mModel: MiniMax-M3 0.41.038 ERROR 99% https://example.com/x\x1b[0m\r\n' +
'\x1b7' +
'\x1b[38;2;224;224;224mERROR 85% exit code 1\x1b[0m' +
'\x1bP0;1;0q#0;2;0;0;0~-..\x1b\\' +
'\x1bP1;2|9/9\x07' +
'\x1b_Gf=32,s=10,v=10,m=1\x1b\\' +
'\x1b^pm-note\x1b\\' +
'\x1bXsos-frame\x1b\\' +
'\x1b[38;2;136;136;136mdone \u2713\x1b[0m' +
'\x1b8\r\n'
const reference = applyHighlights(banner, rules)