From 9753ec19bf4be3ab491b3d87603f5392b0319b8e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 27 Aug 2026 18:10:46 +0800 Subject: [PATCH 1/6] =?UTF-8?q?feat(windows):=20=E4=B8=BA=20Run=20?= =?UTF-8?q?=E8=BE=93=E5=87=BA=E5=A2=9E=E5=8A=A0=E6=97=B6=E9=97=B4=E6=88=B3?= =?UTF-8?q?=E5=92=8C=20ANSI/=E7=BA=A7=E5=88=AB=E7=9D=80=E8=89=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 为新的 Run 输出行加上本地时间戳,渲染 ANSI 样式,并在进程没有着色时按 Maven/Spring Boot 的级别着色。输出保持可选择,可用鼠标或 Ctrl+A 复制日志。 Fixes #275 --- .../run/components/run-output-text.tsx | 54 +++ .../src/features/run/components/run-pane.tsx | 10 +- .../src/features/run/stores/run.store.ts | 9 +- .../run/utils/output-timestamper.test.ts | 59 +++ .../features/run/utils/output-timestamper.ts | 51 +++ .../run/utils/run-output-style.test.ts | 81 ++++ .../features/run/utils/run-output-style.ts | 346 ++++++++++++++++++ 7 files changed, 604 insertions(+), 6 deletions(-) create mode 100644 windows/tauri/src/features/run/components/run-output-text.tsx create mode 100644 windows/tauri/src/features/run/utils/output-timestamper.test.ts create mode 100644 windows/tauri/src/features/run/utils/output-timestamper.ts create mode 100644 windows/tauri/src/features/run/utils/run-output-style.test.ts create mode 100644 windows/tauri/src/features/run/utils/run-output-style.ts diff --git a/windows/tauri/src/features/run/components/run-output-text.tsx b/windows/tauri/src/features/run/components/run-output-text.tsx new file mode 100644 index 000000000..a7c49cda4 --- /dev/null +++ b/windows/tauri/src/features/run/components/run-output-text.tsx @@ -0,0 +1,54 @@ +import { useMemo, useRef, type KeyboardEvent } from "react"; +import { renderRunOutput } from "../utils/run-output-style"; + +export function RunOutputText({ + source, + emptyLabel, + title, +}: { + source: string; + emptyLabel: string; + title: string; +}) { + const preRef = useRef(null); + const spans = useMemo(() => renderRunOutput(source), [source]); + + const selectAll = () => { + const node = preRef.current; + const selection = window.getSelection(); + if (!node || !selection) return; + const range = document.createRange(); + range.selectNodeContents(node); + selection.removeAllRanges(); + selection.addRange(range); + }; + + const handleKeyDown = (event: KeyboardEvent) => { + if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return; + event.preventDefault(); + event.stopPropagation(); + selectAll(); + }; + + return ( +
+
{title}
+ {source ? ( +
+          {spans.map((span, index) => (
+            
+              {span.text}
+            
+          ))}
+        
+ ) : ( +
+          {emptyLabel}
+        
+ )} +
+ ); +} diff --git a/windows/tauri/src/features/run/components/run-pane.tsx b/windows/tauri/src/features/run/components/run-pane.tsx index a8f8b39d4..9246d3562 100644 --- a/windows/tauri/src/features/run/components/run-pane.tsx +++ b/windows/tauri/src/features/run/components/run-pane.tsx @@ -28,6 +28,7 @@ import { } from "../utils/run-configuration"; import { RunConfigurationEditor } from "./run-configuration-editor"; import { JavaCupIcon, RunIcon } from "./run-icon"; +import { RunOutputText } from "./run-output-text"; export default function RunPane() { const { t } = useTranslation(); @@ -229,10 +230,11 @@ export default function RunPane() { )}
-
{t("run.processOutput")}
-
-                {output || t("run.emptyOutput")}
-              
+
{isSelectedRunning ? ( (); @@ -127,6 +128,10 @@ function trimOutput(output: string): string { return output.slice(output.length - MAXIMUM_OUTPUT_CHARACTERS); } +function appendStampedOutput(existing: string, chunk: string): string { + return trimOutput(existing + stampRunChunk(existing, chunk)); +} + function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { return { javaHomePath: configuration.javaHomePath, @@ -495,13 +500,13 @@ export const createRunStore = () => appendOutput: (sessionId, chunk) => { if (sessionId === PRIMARY_SESSION_ID) { - set({ primaryOutput: trimOutput(get().primaryOutput + chunk) }); + set({ primaryOutput: appendStampedOutput(get().primaryOutput, chunk) }); return; } set((current) => ({ sessions: current.sessions.map((session) => session.id === sessionId - ? { ...session, output: trimOutput(session.output + chunk) } + ? { ...session, output: appendStampedOutput(session.output, chunk) } : session, ), })); diff --git a/windows/tauri/src/features/run/utils/output-timestamper.test.ts b/windows/tauri/src/features/run/utils/output-timestamper.test.ts new file mode 100644 index 000000000..6995fcb6c --- /dev/null +++ b/windows/tauri/src/features/run/utils/output-timestamper.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, test } from "bun:test"; +import { + hasLeadingTime, + leadingTimeLength, + stampOutput, + stampRunChunk, +} from "./output-timestamper"; + +const noon = new Date(2026, 7, 8, 10, 12, 33, 123); + +describe("output timestamping", () => { + test("stamps Maven lines that have no clock of their own", () => { + const stamped = stampOutput( + "[INFO] Building backend-api\n[ERROR] Port in use\n", + false, + noon, + ); + const lines = stamped.split("\n"); + expect(lines[0]?.endsWith("[INFO] Building backend-api")).toBe(true); + expect(lines[1]?.endsWith("[ERROR] Port in use")).toBe(true); + expect(hasLeadingTime(lines[0] ?? "")).toBe(true); + expect(hasLeadingTime(lines[1] ?? "")).toBe(true); + }); + + test("leaves Spring Boot timestamps alone", () => { + const line = "2026-08-08T10:12:33.123 INFO 1 --- [main] Started App"; + expect(stampOutput(`${line}\n`, false, noon)).toBe(`${line}\n`); + }); + + test("does not stamp a continuation of a partial line", () => { + expect(stampOutput("rest of message\n", true, noon)).toBe("rest of message\n"); + }); + + test("preserves whether the chunk ended with a newline", () => { + const stamped = stampOutput("partial", false, noon); + expect(stamped.endsWith("\n")).toBe(false); + expect(stamped.endsWith("partial")).toBe(true); + }); + + test("does not stamp blank lines", () => { + expect(stampOutput("\n\n", false, noon)).toBe("\n\n"); + }); + + test("measures the whole clock including fractional seconds", () => { + expect(leadingTimeLength("10:12:33.123 [INFO] hello")).toBe("10:12:33.123 ".length); + }); + + test("reports no clock for a plain line", () => { + expect(leadingTimeLength("[INFO] hello")).toBeUndefined(); + }); + + test("stamps only the first fragment when a line is split across chunks", () => { + const first = stampRunChunk("", "[INFO] Building", noon); + const second = stampRunChunk(first, " backend-api\n", noon); + expect(first.startsWith("10:12:33.123 ")).toBe(true); + expect(second).toBe(" backend-api\n"); + expect(`${first}${second}`).toBe("10:12:33.123 [INFO] Building backend-api\n"); + }); +}); diff --git a/windows/tauri/src/features/run/utils/output-timestamper.ts b/windows/tauri/src/features/run/utils/output-timestamper.ts new file mode 100644 index 000000000..6cf05681b --- /dev/null +++ b/windows/tauri/src/features/run/utils/output-timestamper.ts @@ -0,0 +1,51 @@ +const LEADING_TIME_PATTERN = /^\s*(?:\d{4}-\d{2}-\d{2}[T ])?\d{2}:\d{2}:\d{2}/; + +function formatTimestamp(now: Date): string { + const hours = String(now.getHours()).padStart(2, "0"); + const minutes = String(now.getMinutes()).padStart(2, "0"); + const seconds = String(now.getSeconds()).padStart(2, "0"); + const milliseconds = String(now.getMilliseconds()).padStart(3, "0"); + return `${hours}:${minutes}:${seconds}.${milliseconds} `; +} + +export function leadingTimeLength(line: string): number | undefined { + const match = LEADING_TIME_PATTERN.exec(line); + if (!match) return undefined; + let end = match[0].length; + while (end < line.length) { + const character = line[end]; + if (character !== "." && character !== " " && (character < "0" || character > "9")) { + break; + } + end += 1; + if (character === " ") break; + } + return end; +} + +export function hasLeadingTime(line: string): boolean { + return leadingTimeLength(line) !== undefined; +} + +export function stampOutput(value: string, continuingLine: boolean, now = new Date()): string { + if (value.length === 0) return value; + const stamp = formatTimestamp(now); + const lines = value.split("\n"); + let isLineStart = !continuingLine; + let result = ""; + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (isLineStart && line.length > 0 && !hasLeadingTime(line)) { + result += stamp; + } + result += line; + if (index < lines.length - 1) result += "\n"; + isLineStart = true; + } + return result; +} + +export function stampRunChunk(existing: string, chunk: string, now = new Date()): string { + const continuingLine = existing.length > 0 && !existing.endsWith("\n"); + return stampOutput(chunk.replace(/\r/g, ""), continuingLine, now); +} diff --git a/windows/tauri/src/features/run/utils/run-output-style.test.ts b/windows/tauri/src/features/run/utils/run-output-style.test.ts new file mode 100644 index 000000000..686615e2b --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-output-style.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, test } from "bun:test"; +import { stampOutput } from "./output-timestamper"; +import { + parseAnsi, + renderRunOutput, + severityOfLine, +} from "./run-output-style"; + +const noon = new Date(2026, 7, 8, 10, 12, 33, 123); + +describe("output severity coloring", () => { + test("recognizes bracketed Maven levels", () => { + expect(severityOfLine("[ERROR] Failed to execute goal")).toBe("error"); + expect(severityOfLine("[WARNING] deprecated API")).toBe("warning"); + expect(severityOfLine("[INFO] Building")).toBe("info"); + }); + + test("recognizes Spring Boot spaced levels", () => { + expect( + severityOfLine("2026-08-08T10:12:33.123 WARN 1 --- [main] Port 8081 was already in use"), + ).toBe("warning"); + }); + + test("ignores level words embedded in paths", () => { + expect(severityOfLine(" at src/main/java/ErrorHandler.java:42")).toBeUndefined(); + expect(severityOfLine("Compiling Information.kt")).toBeUndefined(); + }); + + test("reports no severity for ordinary output", () => { + expect(severityOfLine("Tomcat started on port 8081")).toBeUndefined(); + }); +}); + +describe("ANSI output colors", () => { + test("strips escape sequences and keeps the visible text", () => { + const parsed = parseAnsi("\u001b[31mred\u001b[0m and plain\n"); + expect(parsed.text).toBe("red and plain\n"); + expect(parsed.hasStyling).toBe(true); + }); + + test("applies the palette color for a foreground SGR code", () => { + const spans = renderRunOutput("\u001b[31mred\u001b[0m"); + expect(spans.map((span) => span.text).join("")).toBe("red"); + expect(spans[0]?.style?.color).toBe("#f16d75"); + }); + + test("restores the default style after a reset sequence", () => { + const spans = renderRunOutput("\u001b[31mred\u001b[0mplain"); + expect(spans).toHaveLength(2); + expect(spans[0]?.style?.color).toBe("#f16d75"); + expect(spans[1]?.text).toBe("plain"); + expect(spans[1]?.style?.color).toBeUndefined(); + expect(spans[1]?.className).toBeUndefined(); + }); + + test("skips severity coloring when the process already styled the line", () => { + const spans = renderRunOutput("\u001b[1m[ERROR] Failed\u001b[0m\n"); + expect(spans.map((span) => span.text).join("")).toBe("[ERROR] Failed\n"); + expect(spans.some((span) => span.className === "text-destructive")).toBe(false); + expect(spans[0]?.style?.fontWeight).toBe(700); + }); +}); + +describe("run output rendering", () => { + test("colors Maven severity lines when no ANSI is present", () => { + const spans = renderRunOutput("[ERROR] Failed to execute goal\n[INFO] Building\n"); + expect(spans[0]?.className).toBe("text-destructive"); + expect(spans[0]?.text.startsWith("[ERROR]")).toBe(true); + expect(spans[1]?.className).toBe("text-info"); + expect(spans[1]?.text.startsWith("[INFO]")).toBe(true); + }); + + test("dims timestamps without recoloring the message", () => { + const stamped = stampOutput("[ERROR] Port in use\n", false, noon); + const spans = renderRunOutput(stamped); + expect(spans[0]?.className).toBe("text-subtle-foreground/70"); + expect(spans[0]?.text).toBe("10:12:33.123 "); + expect(spans[1]?.className).toBe("text-destructive"); + expect(spans[1]?.text).toBe("[ERROR] Port in use\n"); + }); +}); diff --git a/windows/tauri/src/features/run/utils/run-output-style.ts b/windows/tauri/src/features/run/utils/run-output-style.ts new file mode 100644 index 000000000..c1e4fbc96 --- /dev/null +++ b/windows/tauri/src/features/run/utils/run-output-style.ts @@ -0,0 +1,346 @@ +import { leadingTimeLength } from "./output-timestamper"; + +export type OutputSeverity = "error" | "warning" | "info" | "debug"; + +export interface RunOutputSpan { + text: string; + className?: string; + style?: { + color?: string; + backgroundColor?: string; + fontWeight?: number; + }; +} + +interface AnsiStyle { + color?: string; + background?: string; + bold: boolean; +} + +interface AnsiRange { + start: number; + end: number; + style: AnsiStyle; +} + +interface ParsedAnsi { + text: string; + ranges: AnsiRange[]; + hasStyling: boolean; +} + +const SEVERITY_PATTERN = /(?:^|\s|\[)(ERROR|SEVERE|FATAL|WARN(?:ING)?|INFO|DEBUG|TRACE)(?:\]|\s|:)/; + +const SEVERITY_CLASS: Record = { + error: "text-destructive", + warning: "text-warning", + info: "text-info", + debug: "text-subtle-foreground", +}; + +const TIMESTAMP_CLASS = "text-subtle-foreground/70"; + +const ANSI_PALETTE = [ + "#0f1012", + "#f16d75", + "#4cc38a", + "#d9a441", + "#58a6e7", + "#c8a2f4", + "#61c0bf", + "#c4c9d1", + "#757d89", + "#ff858d", + "#68d5a0", + "#edbb5c", + "#75b9f0", + "#dab9ff", + "#7bd3d2", + "#ffffff", +]; + +export function severityOfLine(line: string): OutputSeverity | undefined { + const match = SEVERITY_PATTERN.exec(line); + if (!match) return undefined; + switch (match[1]) { + case "ERROR": + case "SEVERE": + case "FATAL": + return "error"; + case "WARN": + case "WARNING": + return "warning"; + case "INFO": + return "info"; + case "DEBUG": + case "TRACE": + return "debug"; + default: + return undefined; + } +} + +export function parseAnsi(source: string): ParsedAnsi { + const ranges: AnsiRange[] = []; + let text = ""; + let buffer = ""; + let style: AnsiStyle = { bold: false }; + let hasStyling = false; + let index = 0; + + const flush = () => { + if (buffer.length === 0) return; + ranges.push({ + start: text.length, + end: text.length + buffer.length, + style: { ...style }, + }); + text += buffer; + buffer = ""; + }; + + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 27 && index + 1 < source.length) { + const next = source[index + 1]; + if (next === "[") { + flush(); + const sequence = readCsi(source, index); + if (sequence.final === "m") { + if (sequence.params !== "0" && sequence.params.length > 0) { + hasStyling = true; + } + style = applySgr(sequence.params, style); + } + index = sequence.next; + continue; + } + if (next === "]") { + flush(); + index = skipOsc(source, index); + continue; + } + } + + if (code === 8 || code === 127) { + if (buffer.length > 0) buffer = buffer.slice(0, -1); + index += 1; + continue; + } + if (code === 13 || (code < 32 && code !== 9 && code !== 10)) { + index += 1; + continue; + } + + buffer += source[index]; + index += 1; + } + flush(); + return { text, ranges, hasStyling }; +} + +export function renderRunOutput(source: string): RunOutputSpan[] { + if (source.length === 0) return []; + const parsed = parseAnsi(source); + const spans: RunOutputSpan[] = []; + let lineStart = 0; + let rangeIndex = 0; + + const emit = (start: number, end: number, overlay: LineOverlay) => { + if (start >= end) return; + while (rangeIndex < parsed.ranges.length && parsed.ranges[rangeIndex].end <= start) { + rangeIndex += 1; + } + let position = start; + let index = rangeIndex; + while (position < end && index < parsed.ranges.length) { + const range = parsed.ranges[index]; + if (range.end <= position) { + index += 1; + continue; + } + if (range.start >= end) break; + const sliceStart = Math.max(position, range.start); + const sliceEnd = Math.min(end, range.end); + if (sliceStart < sliceEnd) { + pushSpan(spans, parsed.text.slice(sliceStart, sliceEnd), overlay, range.style, parsed.hasStyling); + position = sliceEnd; + } + if (range.end <= end) index += 1; + else break; + } + }; + + while (lineStart < parsed.text.length) { + const newline = parsed.text.indexOf("\n", lineStart); + const lineEnd = newline === -1 ? parsed.text.length : newline; + const line = parsed.text.slice(lineStart, lineEnd); + const timeLength = leadingTimeLength(line) ?? 0; + const severity = parsed.hasStyling ? undefined : severityOfLine(line); + emit(lineStart, lineStart + timeLength, { kind: "timestamp" }); + emit(lineStart + timeLength, newline === -1 ? parsed.text.length : newline + 1, { + kind: "body", + severity, + }); + if (newline === -1) break; + lineStart = newline + 1; + } + + return spans; +} + +type LineOverlay = + | { kind: "timestamp" } + | { kind: "body"; severity?: OutputSeverity }; + +function pushSpan( + spans: RunOutputSpan[], + text: string, + overlay: LineOverlay, + style: AnsiStyle, + hasStyling: boolean, +): void { + if (text.length === 0) return; + const span: RunOutputSpan = { text }; + if (overlay.kind === "timestamp") { + span.className = TIMESTAMP_CLASS; + } else if (hasStyling) { + span.style = ansiStyle(style); + } else if (overlay.severity) { + span.className = SEVERITY_CLASS[overlay.severity]; + } + if (span.style && Object.keys(span.style).length === 0) { + delete span.style; + } + const previous = spans[spans.length - 1]; + if (previous && sameSpanStyle(previous, span)) { + previous.text += text; + return; + } + spans.push(span); +} + +function sameSpanStyle(left: RunOutputSpan, right: RunOutputSpan): boolean { + return ( + left.className === right.className && + left.style?.color === right.style?.color && + left.style?.backgroundColor === right.style?.backgroundColor && + left.style?.fontWeight === right.style?.fontWeight + ); +} + +function ansiStyle(style: AnsiStyle): RunOutputSpan["style"] { + const result: NonNullable = {}; + if (style.color) result.color = style.color; + if (style.background) result.backgroundColor = style.background; + if (style.bold) result.fontWeight = 700; + return result; +} + +function readCsi(source: string, start: number): { next: number; final: string; params: string } { + let index = start + 2; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) { + return { + next: index + 1, + final: source[index], + params: source.slice(start + 2, index), + }; + } + index += 1; + } + return { next: source.length, final: "", params: source.slice(start + 2) }; +} + +function skipOsc(source: string, start: number): number { + let index = start + 2; + while (index < source.length) { + const code = source.charCodeAt(index); + if (code === 7) return index + 1; + if (code === 27 && source[index + 1] === "\\") return index + 2; + index += 1; + } + return source.length; +} + +function applySgr(parameters: string, current: AnsiStyle): AnsiStyle { + const codes = parameters.length === 0 ? [0] : parameters.split(";").flatMap((part) => { + if (part.length === 0) return []; + const value = Number.parseInt(part, 10); + return Number.isNaN(value) ? [] : [value]; + }); + let next: AnsiStyle = { ...current }; + let index = 0; + while (index < codes.length) { + const code = codes[index]; + if (code === 0) { + next = { bold: false }; + } else if (code === 1) { + next.bold = true; + } else if (code === 22) { + next.bold = false; + } else if ((code >= 30 && code <= 37) || (code >= 90 && code <= 97)) { + next.color = paletteColor(code); + } else if (code === 39) { + next.color = undefined; + } else if ((code >= 40 && code <= 47) || (code >= 100 && code <= 107)) { + next.background = paletteColor(code - 10); + } else if (code === 49) { + next.background = undefined; + } else if (code === 38 || code === 48) { + const color = readExtendedColor(codes, index); + if (color) { + if (code === 38) next.color = color.value; + else next.background = color.value; + index += color.consumed; + } + } + index += 1; + } + return next; +} + +function readExtendedColor( + codes: number[], + index: number, +): { value: string; consumed: number } | undefined { + if (index + 2 < codes.length && codes[index + 1] === 5) { + return { value: color256(codes[index + 2]), consumed: 2 }; + } + if (index + 4 < codes.length && codes[index + 1] === 2) { + return { + value: rgb(codes[index + 2], codes[index + 3], codes[index + 4]), + consumed: 4, + }; + } + return undefined; +} + +function paletteColor(code: number): string { + if (code >= 90) return ANSI_PALETTE[(code - 90) % 8 + 8]; + return ANSI_PALETTE[(code - 30) % 8]; +} + +function color256(value: number): string { + if (value < 16) return ANSI_PALETTE[value] ?? ANSI_PALETTE[7]; + if (value >= 232) { + const component = 8 + (value - 232) * 10; + return rgb(component, component, component); + } + const offset = value - 16; + const red = Math.floor(offset / 36); + const green = Math.floor(offset / 6) % 6; + const blue = offset % 6; + const channel = (level: number) => (level === 0 ? 0 : 40 * level + 55); + return rgb(channel(red), channel(green), channel(blue)); +} + +function rgb(red: number, green: number, blue: number): string { + return `rgb(${clampByte(red)}, ${clampByte(green)}, ${clampByte(blue)})`; +} + +function clampByte(value: number): number { + return Math.max(0, Math.min(255, value)); +} From 0f5987646f46bbd6c50e9e06080cbbd6b8ee8dad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 27 Aug 2026 18:31:57 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(windows):=20=E9=81=BF=E5=85=8D=20ANSI?= =?UTF-8?q?=20=E5=89=8D=E7=BC=80=E5=AF=BC=E8=87=B4=20Run=20=E8=BE=93?= =?UTF-8?q?=E5=87=BA=E9=87=8D=E5=A4=8D=E6=89=93=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 判断已有时间戳前跳过行首 ANSI/OSC 序列,并将 Run 输出测试接入 Windows CI。 Co-authored-by: Cursor --- .github/workflows/ci-windows.yml | 1 + .../run/utils/output-timestamper.test.ts | 19 ++++++ .../features/run/utils/output-timestamper.ts | 62 ++++++++++++++++++- 3 files changed, 80 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml index 755305ff7..4a1a1ae59 100644 --- a/.github/workflows/ci-windows.yml +++ b/.github/workflows/ci-windows.yml @@ -230,6 +230,7 @@ jobs: bun test src/features/editor/stores/editor-app.store.test.ts src/features/editor/services/document-external-change-workflow.test.ts src/features/editor/services/document-save-lifecycle.test.ts bun test src/features/editor/lsp/language-server-navigation.test.ts src/features/editor/lsp/java-navigation-marker-loader.test.ts src/features/editor/engines/monaco/definition-link-scheduler.test.ts src/features/editor/engines/monaco/java-implementation-markers.test.ts bun test src/features/editor/lsp/java-workspace-language-server.test.ts src/features/editor/lsp/java-workspace-change-scheduler.test.ts + bun test src/features/run - name: Test shared Rust Core if: needs.changes.outputs.rust_core == 'true' diff --git a/windows/tauri/src/features/run/utils/output-timestamper.test.ts b/windows/tauri/src/features/run/utils/output-timestamper.test.ts index 6995fcb6c..bacab67b6 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.test.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.test.ts @@ -56,4 +56,23 @@ describe("output timestamping", () => { expect(second).toBe(" backend-api\n"); expect(`${first}${second}`).toBe("10:12:33.123 [INFO] Building backend-api\n"); }); + + test("leaves Spring Boot timestamps that start with ANSI styling alone", () => { + const line = "\u001b[32m2026-08-08T10:12:33.123 INFO 1 --- [main] Started App\u001b[0m"; + expect(hasLeadingTime(line)).toBe(true); + expect(stampOutput(`${line}\n`, false, noon)).toBe(`${line}\n`); + }); + + test("stamps colored Maven lines that have no clock of their own", () => { + const stamped = stampOutput("\u001b[1m[INFO]\u001b[0m Building\n", false, noon); + expect(stamped.startsWith("10:12:33.123 ")).toBe(true); + expect(stamped.endsWith("\u001b[1m[INFO]\u001b[0m Building\n")).toBe(true); + }); + + test("does not stamp an ANSI-only prefix when the timestamp arrives in the next chunk", () => { + const first = stampRunChunk("", "\u001b[32m", noon); + const second = stampRunChunk(first, "2026-08-08T10:12:33.123 INFO started\n", noon); + expect(first).toBe("\u001b[32m"); + expect(second).toBe("2026-08-08T10:12:33.123 INFO started\n"); + }); }); diff --git a/windows/tauri/src/features/run/utils/output-timestamper.ts b/windows/tauri/src/features/run/utils/output-timestamper.ts index 6cf05681b..6d3d33f4e 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.ts @@ -24,7 +24,65 @@ export function leadingTimeLength(line: string): number | undefined { } export function hasLeadingTime(line: string): boolean { - return leadingTimeLength(line) !== undefined; + return leadingTimeLength(skipLeadingOutputControls(line)) !== undefined; +} + +// Colored tools often emit SGR/OSC before a clock. Skip those so we do not +// add a second timestamp after the escapes are stripped for display. +function skipLeadingOutputControls(line: string): string { + let index = 0; + while (index < line.length) { + const code = line.charCodeAt(index); + if (code === 32 || code === 9) { + index += 1; + continue; + } + if (code !== 27) break; + if (index + 1 >= line.length) { + index = line.length; + break; + } + const next = line.charCodeAt(index + 1); + if (next === 91) { + let cursor = index + 2; + while (cursor < line.length) { + const finalCode = line.charCodeAt(cursor); + if (finalCode >= 0x40 && finalCode <= 0x7e) { + cursor += 1; + break; + } + cursor += 1; + } + index = cursor; + continue; + } + if (next === 93) { + let cursor = index + 2; + while (cursor < line.length) { + const terminator = line.charCodeAt(cursor); + if (terminator === 7) { + cursor += 1; + break; + } + if (terminator === 27 && line.charCodeAt(cursor + 1) === 92) { + cursor += 2; + break; + } + cursor += 1; + } + index = cursor; + continue; + } + index += 1; + } + return line.slice(index); +} + +function shouldStampLine(line: string): boolean { + if (line.length === 0) return false; + const visible = skipLeadingOutputControls(line); + if (visible.length === 0) return false; + return leadingTimeLength(visible) === undefined; } export function stampOutput(value: string, continuingLine: boolean, now = new Date()): string { @@ -35,7 +93,7 @@ export function stampOutput(value: string, continuingLine: boolean, now = new Da let result = ""; for (let index = 0; index < lines.length; index += 1) { const line = lines[index]; - if (isLineStart && line.length > 0 && !hasLeadingTime(line)) { + if (isLineStart && shouldStampLine(line)) { result += stamp; } result += line; From 682f645601e79aea77a4a01bc736e9eb865ee6b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 27 Aug 2026 18:40:46 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(windows):=20=E7=BC=93=E5=AD=98=E8=B7=A8?= =?UTF-8?q?=20chunk=20=E7=9A=84=20Run=20=E8=BE=93=E5=87=BA=E5=89=8D?= =?UTF-8?q?=E7=BC=80=E5=86=8D=E6=89=93=E6=88=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 每个 session 保留待判定的行首 ANSI/时间戳前缀,避免 4096 字节分块导致漏打或重复打时间戳。 Co-authored-by: Cursor --- .../src/features/run/stores/run.store.ts | 47 ++++++++++++-- .../run/utils/output-timestamper.test.ts | 32 +++++++-- .../features/run/utils/output-timestamper.ts | 65 +++++++++++++++++++ 3 files changed, 133 insertions(+), 11 deletions(-) diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 510a11a1e..8f3dfd541 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -46,10 +46,11 @@ import { selectedToolchainCandidates, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; -import { stampRunChunk } from "../utils/output-timestamper"; +import { createOutputStamper, type OutputStamper } from "../utils/output-timestamper"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; const sessionWorkspaces = new Map(); +const outputStampers = new Map(); interface RunState { root: string | null; @@ -128,8 +129,25 @@ function trimOutput(output: string): string { return output.slice(output.length - MAXIMUM_OUTPUT_CHARACTERS); } -function appendStampedOutput(existing: string, chunk: string): string { - return trimOutput(existing + stampRunChunk(existing, chunk)); +function stamperFor(sessionId: string): OutputStamper { + let stamper = outputStampers.get(sessionId); + if (!stamper) { + stamper = createOutputStamper(); + outputStampers.set(sessionId, stamper); + } + return stamper; +} + +function resetOutputStamper(sessionId: string): void { + stamperFor(sessionId).reset(); +} + +function appendStampedOutput(sessionId: string, existing: string, chunk: string): string { + return trimOutput(existing + stamperFor(sessionId).push(chunk)); +} + +function flushStampedOutput(sessionId: string, existing: string): string { + return trimOutput(existing + stamperFor(sessionId).flush()); } function optionsFromConfiguration(configuration: RunConfiguration): RunOptions { @@ -349,6 +367,7 @@ export const createRunStore = () => const sessionId = configuration.execution === "service" ? configuration.id : PRIMARY_SESSION_ID; bindRunSessionWorkspace(sessionId); + resetOutputStamper(sessionId); await stopRunProcess(sessionId).catch(() => undefined); try { const plan = await createLaunchPlan(root, configuration.id, currentFile); @@ -436,9 +455,11 @@ export const createRunStore = () => clearOutput: (sessionId) => { const target = sessionId ?? get().selectedSessionId; if (!target || target === PRIMARY_SESSION_ID) { + resetOutputStamper(PRIMARY_SESSION_ID); set({ primaryOutput: "", primaryExitCode: null }); return; } + resetOutputStamper(target); set((current) => ({ sessions: current.sessions.map((session) => session.id === target ? { ...session, output: "", exitCode: null } : session, @@ -500,13 +521,13 @@ export const createRunStore = () => appendOutput: (sessionId, chunk) => { if (sessionId === PRIMARY_SESSION_ID) { - set({ primaryOutput: appendStampedOutput(get().primaryOutput, chunk) }); + set({ primaryOutput: appendStampedOutput(sessionId, get().primaryOutput, chunk) }); return; } set((current) => ({ sessions: current.sessions.map((session) => session.id === sessionId - ? { ...session, output: appendStampedOutput(session.output, chunk) } + ? { ...session, output: appendStampedOutput(sessionId, session.output, chunk) } : session, ), })); @@ -514,12 +535,23 @@ export const createRunStore = () => finishProcess: (sessionId, exitCode) => { if (sessionId === PRIMARY_SESSION_ID) { - set({ primaryRunning: false, primaryExitCode: exitCode }); + set({ + primaryRunning: false, + primaryExitCode: exitCode, + primaryOutput: flushStampedOutput(sessionId, get().primaryOutput), + }); return; } set((current) => ({ sessions: current.sessions.map((session) => - session.id === sessionId ? { ...session, isRunning: false, exitCode } : session, + session.id === sessionId + ? { + ...session, + isRunning: false, + exitCode, + output: flushStampedOutput(sessionId, session.output), + } + : session, ), })); }, @@ -539,6 +571,7 @@ export function runStoreForSession(sessionId: string) { export function releaseRunSessionWorkspace(sessionId: string): void { sessionWorkspaces.delete(sessionId); + outputStampers.delete(sessionId); } export function runOptionsFor(configuration: RunConfiguration): RunOptions { diff --git a/windows/tauri/src/features/run/utils/output-timestamper.test.ts b/windows/tauri/src/features/run/utils/output-timestamper.test.ts index bacab67b6..5fb10ff6b 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.test.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; import { + createOutputStamper, hasLeadingTime, leadingTimeLength, stampOutput, @@ -70,9 +71,32 @@ describe("output timestamping", () => { }); test("does not stamp an ANSI-only prefix when the timestamp arrives in the next chunk", () => { - const first = stampRunChunk("", "\u001b[32m", noon); - const second = stampRunChunk(first, "2026-08-08T10:12:33.123 INFO started\n", noon); - expect(first).toBe("\u001b[32m"); - expect(second).toBe("2026-08-08T10:12:33.123 INFO started\n"); + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32m", noon)).toBe(""); + expect(stamper.push("2026-08-08T10:12:33.123 INFO started\n", noon)).toBe( + "\u001b[32m2026-08-08T10:12:33.123 INFO started\n", + ); + }); + + test("stamps a line whose ANSI prefix arrives before the untimed body", () => { + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32m", noon)).toBe(""); + expect(stamper.push("[INFO] Building\n", noon)).toBe("10:12:33.123 \u001b[32m[INFO] Building\n"); + }); + + test("does not stamp a clock that is split across chunks", () => { + const stamper = createOutputStamper(); + expect(stamper.push("2026-08-08T10:", noon)).toBe(""); + expect(stamper.push("12:33.123 INFO started\n", noon)).toBe( + "2026-08-08T10:12:33.123 INFO started\n", + ); + }); + + test("holds an incomplete SGR sequence until the line can be classified", () => { + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32", noon)).toBe(""); + expect(stamper.push("m[INFO] Building\n", noon)).toBe( + "10:12:33.123 \u001b[32m[INFO] Building\n", + ); }); }); diff --git a/windows/tauri/src/features/run/utils/output-timestamper.ts b/windows/tauri/src/features/run/utils/output-timestamper.ts index 6d3d33f4e..36c1597ad 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.ts @@ -85,6 +85,21 @@ function shouldStampLine(line: string): boolean { return leadingTimeLength(visible) === undefined; } +const INCOMPLETE_TIMESTAMP_PREFIX = + /^\s*(?:\d{4}(?:-\d{0,2}(?:-\d{0,2}(?:[T ]\d{0,2}(?::\d{0,2}(?::\d{0,2})?)?)?)?)?|\d{1,2}(?::\d{0,2}(?::\d{0,2})?)?)$/; + +function isIncompleteTimestampPrefix(visible: string): boolean { + return INCOMPLETE_TIMESTAMP_PREFIX.test(visible); +} + +function isUndecidedPrefix(line: string): boolean { + if (line.length === 0) return false; + const visible = skipLeadingOutputControls(line); + if (visible.length === 0) return true; + if (leadingTimeLength(visible) !== undefined) return false; + return isIncompleteTimestampPrefix(visible); +} + export function stampOutput(value: string, continuingLine: boolean, now = new Date()): string { if (value.length === 0) return value; const stamp = formatTimestamp(now); @@ -107,3 +122,53 @@ export function stampRunChunk(existing: string, chunk: string, now = new Date()) const continuingLine = existing.length > 0 && !existing.endsWith("\n"); return stampOutput(chunk.replace(/\r/g, ""), continuingLine, now); } + +export interface OutputStamper { + push(chunk: string, now?: Date): string; + flush(now?: Date): string; + reset(): void; +} + +export function createOutputStamper(): OutputStamper { + // Piped reads can split a line inside an ANSI sequence or an existing clock. + // Hold that prefix until the next chunk makes the line classifiable. + let pending = ""; + let atLineStart = true; + + const emitLine = (line: string, now: Date, withNewline: boolean): string => { + const stamp = atLineStart && shouldStampLine(line) ? formatTimestamp(now) : ""; + atLineStart = withNewline; + return `${stamp}${line}${withNewline ? "\n" : ""}`; + }; + + return { + push(chunk, now = new Date()) { + const value = `${pending}${chunk}`.replace(/\r/g, ""); + pending = ""; + if (value.length === 0) return ""; + const lines = value.split("\n"); + let result = ""; + const lastIndex = lines.length - 1; + for (let index = 0; index < lastIndex; index += 1) { + result += emitLine(lines[index], now, true); + } + if (value.endsWith("\n")) return result; + const last = lines[lastIndex]; + if (atLineStart && isUndecidedPrefix(last)) { + pending = last; + return result; + } + return result + emitLine(last, now, false); + }, + flush(now = new Date()) { + if (pending.length === 0) return ""; + const line = pending; + pending = ""; + return emitLine(line, now, false); + }, + reset() { + pending = ""; + atLineStart = true; + }, + }; +} From 252634d546056578ddcae86ca6f9a29e534c99ec Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Thu, 27 Aug 2026 20:19:05 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(windows):=20=E9=81=BF=E5=85=8D=E6=95=B0?= =?UTF-8?q?=E5=AD=97=E5=89=8D=E7=BC=80=E6=97=A0=E9=99=90=E6=9A=82=E5=AD=98?= =?UTF-8?q?=E5=B9=B6=E5=9C=A8=20ANSI=20=E8=BE=B9=E7=95=8C=E8=A3=81?= =?UTF-8?q?=E5=89=AA=E8=BE=93=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 不再把普通数字提示当成未完成时间戳;Stop 时 flush 暂存内容;超限裁剪落在完整行或完整 CSI/OSC 边界。 Co-authored-by: Cursor --- .../src/features/run/stores/run.store.test.ts | 19 ++++++++ .../src/features/run/stores/run.store.ts | 28 +++++++----- .../run/utils/output-timestamper.test.ts | 29 ++++++++++++ .../features/run/utils/output-timestamper.ts | 44 ++++++++++++++++++- 4 files changed, 109 insertions(+), 11 deletions(-) create mode 100644 windows/tauri/src/features/run/stores/run.store.test.ts diff --git a/windows/tauri/src/features/run/stores/run.store.test.ts b/windows/tauri/src/features/run/stores/run.store.test.ts new file mode 100644 index 000000000..f363151a1 --- /dev/null +++ b/windows/tauri/src/features/run/stores/run.store.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, mock, test } from "bun:test"; + +mock.module("@/platform/tauri-core", () => ({ + invoke: mock(async () => undefined), +})); + +const { createRunStore } = await import("./run.store"); +const { PRIMARY_SESSION_ID } = await import("../types/run.types"); + +describe("run output session lifecycle", () => { + test("stop flushes a held prefix that never received a newline", async () => { + const store = createRunStore(); + store.getState().actions.appendOutput(PRIMARY_SESSION_ID, "\u001b[32m"); + expect(store.getState().primaryOutput).toBe(""); + await store.getState().actions.stop(PRIMARY_SESSION_ID); + expect(store.getState().primaryOutput).toBe("\u001b[32m"); + expect(store.getState().primaryRunning).toBe(false); + }); +}); diff --git a/windows/tauri/src/features/run/stores/run.store.ts b/windows/tauri/src/features/run/stores/run.store.ts index 7975c13e9..9d6677f1f 100644 --- a/windows/tauri/src/features/run/stores/run.store.ts +++ b/windows/tauri/src/features/run/stores/run.store.ts @@ -48,7 +48,7 @@ import { selectedToolchainCandidates, } from "../utils/run-configuration"; import { editorSaveFailureMessage, runEditorSaveWorkflow } from "../services/run-editor-save"; -import { createOutputStamper, type OutputStamper } from "../utils/output-timestamper"; +import { createOutputStamper, trimRunOutput, type OutputStamper } from "../utils/output-timestamper"; const MAXIMUM_OUTPUT_CHARACTERS = 500_000; const sessionWorkspaces = new Map(); @@ -133,8 +133,7 @@ type ReadyRunState = Pick< >; function trimOutput(output: string): string { - if (output.length <= MAXIMUM_OUTPUT_CHARACTERS) return output; - return output.slice(output.length - MAXIMUM_OUTPUT_CHARACTERS); + return trimRunOutput(output, MAXIMUM_OUTPUT_CHARACTERS); } function stamperFor(sessionId: string): OutputStamper { @@ -478,14 +477,23 @@ export const createRunStore = () => const target = sessionId ?? get().selectedSessionId ?? PRIMARY_SESSION_ID; await stopRunProcess(target).catch(() => undefined); if (target === PRIMARY_SESSION_ID) { - set({ primaryRunning: false }); - } else { - set((current) => ({ - sessions: current.sessions.map((session) => - session.id === target ? { ...session, isRunning: false } : session, - ), - })); + set({ + primaryRunning: false, + primaryOutput: flushStampedOutput(target, get().primaryOutput), + }); + return; } + set((current) => ({ + sessions: current.sessions.map((session) => + session.id === target + ? { + ...session, + isRunning: false, + output: flushStampedOutput(target, session.output), + } + : session, + ), + })); }, clearOutput: (sessionId) => { diff --git a/windows/tauri/src/features/run/utils/output-timestamper.test.ts b/windows/tauri/src/features/run/utils/output-timestamper.test.ts index 5fb10ff6b..a0df29f31 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.test.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.test.ts @@ -5,6 +5,7 @@ import { leadingTimeLength, stampOutput, stampRunChunk, + trimRunOutput, } from "./output-timestamper"; const noon = new Date(2026, 7, 8, 10, 12, 33, 123); @@ -99,4 +100,32 @@ describe("output timestamping", () => { "10:12:33.123 \u001b[32m[INFO] Building\n", ); }); + + test("emits a numeric prompt instead of holding it as a timestamp prefix", () => { + const stamper = createOutputStamper(); + expect(stamper.push("12", noon)).toBe("10:12:33.123 12"); + }); + + test("flushes a held ANSI prefix when the session is stopped", () => { + const stamper = createOutputStamper(); + expect(stamper.push("\u001b[32m", noon)).toBe(""); + expect(stamper.flush(noon)).toBe("\u001b[32m"); + }); +}); + +describe("run output trimming", () => { + test("drops a broken CSI prefix when the cut lands inside the sequence", () => { + const output = `${"x".repeat(8)}\u001b[31mred`; + expect(trimRunOutput(output, 6)).toBe("red"); + }); + + test("drops a broken OSC prefix when the cut lands inside the sequence", () => { + const output = `${"x".repeat(8)}\u001b]0;title\u0007ok`; + expect(trimRunOutput(output, 8)).toBe("ok"); + }); + + test("trims at the next complete line when the window starts mid-line", () => { + const output = `${"a".repeat(10)}\nkept\n`; + expect(trimRunOutput(output, 10)).toBe("kept\n"); + }); }); diff --git a/windows/tauri/src/features/run/utils/output-timestamper.ts b/windows/tauri/src/features/run/utils/output-timestamper.ts index 36c1597ad..f8fdc5566 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.ts @@ -86,7 +86,7 @@ function shouldStampLine(line: string): boolean { } const INCOMPLETE_TIMESTAMP_PREFIX = - /^\s*(?:\d{4}(?:-\d{0,2}(?:-\d{0,2}(?:[T ]\d{0,2}(?::\d{0,2}(?::\d{0,2})?)?)?)?)?|\d{1,2}(?::\d{0,2}(?::\d{0,2})?)?)$/; + /^\s*\d{4}-\d{0,2}(?:-\d{0,2}(?:[T ]\d{0,2}(?::\d{0,2}(?::\d{0,2})?)?)?)?$/; function isIncompleteTimestampPrefix(visible: string): boolean { return INCOMPLETE_TIMESTAMP_PREFIX.test(visible); @@ -172,3 +172,45 @@ export function createOutputStamper(): OutputStamper { }, }; } + +function controlSequenceEnd(text: string, escapeIndex: number): number | undefined { + if (escapeIndex + 1 >= text.length) return undefined; + const next = text.charCodeAt(escapeIndex + 1); + if (next === 91) { + for (let index = escapeIndex + 2; index < text.length; index += 1) { + const code = text.charCodeAt(index); + if (code >= 0x40 && code <= 0x7e) return index + 1; + } + return undefined; + } + if (next === 93) { + for (let index = escapeIndex + 2; index < text.length; index += 1) { + if (text.charCodeAt(index) === 7) return index + 1; + if (text.charCodeAt(index) === 27 && text.charCodeAt(index + 1) === 92) return index + 2; + } + return undefined; + } + return escapeIndex + 2; +} + +function advancePastIncompleteControl(text: string, start: number): number { + if (start <= 0) return 0; + const lookback = Math.max(0, start - 64); + for (let index = start - 1; index >= lookback; index -= 1) { + if (text.charCodeAt(index) !== 27) continue; + const end = controlSequenceEnd(text, index); + if (end === undefined || end > start) return end ?? text.length; + break; + } + return start; +} + +export function trimRunOutput(output: string, maximum: number): string { + if (output.length <= maximum) return output; + let start = output.length - maximum; + const newline = output.indexOf("\n", start); + if (newline !== -1) start = newline + 1; + // A mid-sequence cut would leave `[31m` visible after the ESC is dropped. + start = advancePastIncompleteControl(output, start); + return output.slice(start); +} From 5dad3d0fba5a19a388af0463cbbe49ba7003b9ba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Fri, 28 Aug 2026 06:09:54 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(windows):=20=E8=AE=A9=20Run=20ANSI=20?= =?UTF-8?q?=E8=B7=9F=E9=9A=8F=E4=B8=BB=E9=A2=98=E5=B9=B6=E4=BF=AE=E5=A5=BD?= =?UTF-8?q?=E5=9B=9E=E8=BD=A6=E4=B8=8E=E7=9F=AD=E6=97=B6=E9=92=9F=E5=88=86?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 16 色改用 --terminal-* 变量;缓存 HH:mm: 前缀避免重复打戳;独立 CR 按覆盖语义处理,不再粘成一行。 Co-authored-by: Cursor --- .../run/utils/output-timestamper.test.ts | 33 ++++++++++++ .../features/run/utils/output-timestamper.ts | 40 ++++++++++++--- .../run/utils/run-output-style.test.ts | 38 +++++++++++++- .../features/run/utils/run-output-style.ts | 50 +++++++++++-------- 4 files changed, 131 insertions(+), 30 deletions(-) diff --git a/windows/tauri/src/features/run/utils/output-timestamper.test.ts b/windows/tauri/src/features/run/utils/output-timestamper.test.ts index a0df29f31..a8d8ce1a8 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.test.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.test.ts @@ -106,11 +106,44 @@ describe("output timestamping", () => { expect(stamper.push("12", noon)).toBe("10:12:33.123 12"); }); + test("does not stamp a time-only clock that is split across chunks", () => { + const stamper = createOutputStamper(); + expect(stamper.push("10:12:", noon)).toBe(""); + expect(stamper.push("33.123 INFO started\n", noon)).toBe("10:12:33.123 INFO started\n"); + }); + test("flushes a held ANSI prefix when the session is stopped", () => { const stamper = createOutputStamper(); expect(stamper.push("\u001b[32m", noon)).toBe(""); expect(stamper.flush(noon)).toBe("\u001b[32m"); }); + + test("keeps Windows newlines as a single line break", () => { + const stamper = createOutputStamper(); + expect(stamper.push("Hello\r\nWorld\n", noon)).toBe( + "10:12:33.123 Hello\n10:12:33.123 World\n", + ); + }); + + test("overwrites a carriage-return progress line instead of gluing frames", () => { + const stamper = createOutputStamper(); + expect( + stamper.push("Downloading 10%\rDownloading 20%\rDownloading 30%\n", noon), + ).toBe("10:12:33.123 Downloading 30%\n"); + }); + + test("overwrites a progress line that is split across chunks", () => { + const stamper = createOutputStamper(); + expect(stamper.push("Downloading 10%\r", noon)).toBe(""); + expect(stamper.push("Downloading 20%\r", noon)).toBe(""); + expect(stamper.push("Downloading 30%\n", noon)).toBe("10:12:33.123 Downloading 30%\n"); + }); + + test("flushes the latest carriage-return frame when the session is stopped", () => { + const stamper = createOutputStamper(); + expect(stamper.push("Downloading 10%\rDownloading 20%", noon)).toBe(""); + expect(stamper.flush(noon)).toBe("10:12:33.123 Downloading 20%"); + }); }); describe("run output trimming", () => { diff --git a/windows/tauri/src/features/run/utils/output-timestamper.ts b/windows/tauri/src/features/run/utils/output-timestamper.ts index f8fdc5566..ec8f211fd 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.ts @@ -85,11 +85,14 @@ function shouldStampLine(line: string): boolean { return leadingTimeLength(visible) === undefined; } -const INCOMPLETE_TIMESTAMP_PREFIX = +const INCOMPLETE_ISO_TIMESTAMP_PREFIX = /^\s*\d{4}-\d{0,2}(?:-\d{0,2}(?:[T ]\d{0,2}(?::\d{0,2}(?::\d{0,2})?)?)?)?$/; +// `HH:mm:` is already clock-like. Bare digits such as "12" must stay visible. +const INCOMPLETE_CLOCK_PREFIX = /^\s*\d{2}:\d{2}(?::\d{0,2})?$/; + function isIncompleteTimestampPrefix(visible: string): boolean { - return INCOMPLETE_TIMESTAMP_PREFIX.test(visible); + return INCOMPLETE_ISO_TIMESTAMP_PREFIX.test(visible) || INCOMPLETE_CLOCK_PREFIX.test(visible); } function isUndecidedPrefix(line: string): boolean { @@ -118,9 +121,31 @@ export function stampOutput(value: string, continuingLine: boolean, now = new Da return result; } +function normalizeCrlf(value: string): string { + return value.replace(/\r\n/g, "\n"); +} + +function overwriteCarriageReturns(line: string): string { + if (!line.includes("\r")) return line; + let current = ""; + for (const part of line.split("\r")) { + current = part.length >= current.length ? part : `${part}${current.slice(part.length)}`; + } + return current; +} + +function applyCarriageReturns(value: string): string { + const normalized = normalizeCrlf(value); + if (!normalized.includes("\r")) return normalized; + return normalized + .split("\n") + .map((line) => overwriteCarriageReturns(line)) + .join("\n"); +} + export function stampRunChunk(existing: string, chunk: string, now = new Date()): string { const continuingLine = existing.length > 0 && !existing.endsWith("\n"); - return stampOutput(chunk.replace(/\r/g, ""), continuingLine, now); + return stampOutput(applyCarriageReturns(chunk), continuingLine, now); } export interface OutputStamper { @@ -143,18 +168,19 @@ export function createOutputStamper(): OutputStamper { return { push(chunk, now = new Date()) { - const value = `${pending}${chunk}`.replace(/\r/g, ""); + const value = normalizeCrlf(`${pending}${chunk}`); pending = ""; if (value.length === 0) return ""; const lines = value.split("\n"); let result = ""; const lastIndex = lines.length - 1; for (let index = 0; index < lastIndex; index += 1) { - result += emitLine(lines[index], now, true); + result += emitLine(overwriteCarriageReturns(lines[index]), now, true); } if (value.endsWith("\n")) return result; const last = lines[lastIndex]; - if (atLineStart && isUndecidedPrefix(last)) { + // Hold a CR-updating line so the next chunk can overwrite it. + if (last.includes("\r") || (atLineStart && isUndecidedPrefix(last))) { pending = last; return result; } @@ -162,7 +188,7 @@ export function createOutputStamper(): OutputStamper { }, flush(now = new Date()) { if (pending.length === 0) return ""; - const line = pending; + const line = overwriteCarriageReturns(pending); pending = ""; return emitLine(line, now, false); }, diff --git a/windows/tauri/src/features/run/utils/run-output-style.test.ts b/windows/tauri/src/features/run/utils/run-output-style.test.ts index 686615e2b..238cad9d3 100644 --- a/windows/tauri/src/features/run/utils/run-output-style.test.ts +++ b/windows/tauri/src/features/run/utils/run-output-style.test.ts @@ -1,8 +1,10 @@ import { describe, expect, test } from "bun:test"; +import { getLitheDefaultTheme } from "@/extensions/themes/default-theme"; import { stampOutput } from "./output-timestamper"; import { parseAnsi, renderRunOutput, + resolveAnsi16Palette, severityOfLine, } from "./run-output-style"; @@ -41,13 +43,13 @@ describe("ANSI output colors", () => { test("applies the palette color for a foreground SGR code", () => { const spans = renderRunOutput("\u001b[31mred\u001b[0m"); expect(spans.map((span) => span.text).join("")).toBe("red"); - expect(spans[0]?.style?.color).toBe("#f16d75"); + expect(spans[0]?.style?.color).toBe("var(--terminal-red)"); }); test("restores the default style after a reset sequence", () => { const spans = renderRunOutput("\u001b[31mred\u001b[0mplain"); expect(spans).toHaveLength(2); - expect(spans[0]?.style?.color).toBe("#f16d75"); + expect(spans[0]?.style?.color).toBe("var(--terminal-red)"); expect(spans[1]?.text).toBe("plain"); expect(spans[1]?.style?.color).toBeUndefined(); expect(spans[1]?.className).toBeUndefined(); @@ -79,3 +81,35 @@ describe("run output rendering", () => { expect(spans[1]?.text).toBe("[ERROR] Port in use\n"); }); }); + +describe("theme ANSI palette", () => { + test("uses current-theme variables for the 16-color palette", () => { + expect(renderRunOutput("\u001b[30mblack")[0]?.style?.color).toBe("var(--terminal-black)"); + expect(renderRunOutput("\u001b[37mwhite")[0]?.style?.color).toBe("var(--terminal-white)"); + expect(renderRunOutput("\u001b[97mbright")[0]?.style?.color).toBe( + "var(--terminal-bright-white)", + ); + }); + + test("light theme keeps SGR 37/97 visible against the background", () => { + const theme = getLitheDefaultTheme("light"); + const palette = resolveAnsi16Palette(theme.colors); + expect(renderRunOutput("\u001b[37mwhite")[0]?.style?.color).toBe("var(--terminal-white)"); + expect(renderRunOutput("\u001b[97mbright")[0]?.style?.color).toBe( + "var(--terminal-bright-white)", + ); + expect(palette[7]).toBe(theme.colors["terminal-white"]); + expect(palette[15]).toBe(theme.colors["terminal-bright-white"]); + expect(palette[7]?.toLowerCase()).not.toBe(theme.colors.background.toLowerCase()); + expect(palette[15]?.toLowerCase()).not.toBe(theme.colors.background.toLowerCase()); + expect(palette[15]?.toLowerCase()).not.toBe("#ffffff"); + }); + + test("dark theme keeps SGR 30 visible against the background", () => { + const theme = getLitheDefaultTheme("dark"); + const palette = resolveAnsi16Palette(theme.colors); + expect(renderRunOutput("\u001b[30mblack")[0]?.style?.color).toBe("var(--terminal-black)"); + expect(palette[0]).toBe(theme.colors["terminal-black"]); + expect(palette[0]?.toLowerCase()).not.toBe(theme.colors.background.toLowerCase()); + }); +}); diff --git a/windows/tauri/src/features/run/utils/run-output-style.ts b/windows/tauri/src/features/run/utils/run-output-style.ts index c1e4fbc96..5ba55a14e 100644 --- a/windows/tauri/src/features/run/utils/run-output-style.ts +++ b/windows/tauri/src/features/run/utils/run-output-style.ts @@ -41,24 +41,32 @@ const SEVERITY_CLASS: Record = { const TIMESTAMP_CLASS = "text-subtle-foreground/70"; -const ANSI_PALETTE = [ - "#0f1012", - "#f16d75", - "#4cc38a", - "#d9a441", - "#58a6e7", - "#c8a2f4", - "#61c0bf", - "#c4c9d1", - "#757d89", - "#ff858d", - "#68d5a0", - "#edbb5c", - "#75b9f0", - "#dab9ff", - "#7bd3d2", - "#ffffff", -]; +const ANSI_PALETTE_KEYS = [ + "terminal-black", + "terminal-red", + "terminal-green", + "terminal-yellow", + "terminal-blue", + "terminal-magenta", + "terminal-cyan", + "terminal-white", + "terminal-bright-black", + "terminal-bright-red", + "terminal-bright-green", + "terminal-bright-yellow", + "terminal-bright-blue", + "terminal-bright-magenta", + "terminal-bright-cyan", + "terminal-bright-white", +] as const; + +export function ansiPaletteCssVariable(index: number): string { + return `var(--${ANSI_PALETTE_KEYS[index] ?? "terminal-white"})`; +} + +export function resolveAnsi16Palette(colors: Record): string[] { + return ANSI_PALETTE_KEYS.map((key) => colors[key] ?? ""); +} export function severityOfLine(line: string): OutputSeverity | undefined { const match = SEVERITY_PATTERN.exec(line); @@ -319,12 +327,12 @@ function readExtendedColor( } function paletteColor(code: number): string { - if (code >= 90) return ANSI_PALETTE[(code - 90) % 8 + 8]; - return ANSI_PALETTE[(code - 30) % 8]; + if (code >= 90) return ansiPaletteCssVariable((code - 90) % 8 + 8); + return ansiPaletteCssVariable((code - 30) % 8); } function color256(value: number): string { - if (value < 16) return ANSI_PALETTE[value] ?? ANSI_PALETTE[7]; + if (value < 16) return ansiPaletteCssVariable(value); if (value >= 232) { const component = 8 + (value - 232) * 10; return rgb(component, component, component); From 8900ea8cfcefb74fb8644d463b28286400487028 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A4=A9=E5=A4=A9=E5=9B=B0?= <2570024918@qq.com> Date: Fri, 28 Aug 2026 09:40:25 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(windows):=20=E6=8C=89=20range=20?= =?UTF-8?q?=E7=9D=80=E8=89=B2=E5=B9=B6=E6=89=AB=E6=8F=8F=E5=AE=8C=E6=95=B4?= =?UTF-8?q?=20CSI/OSC=20=E5=86=8D=E8=A3=81=E5=89=AA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 级别色不再被整份 ANSI 关掉;超长 OSC 从候选行扫描到完整 BEL/ST 边界后再截取。 Co-authored-by: Cursor --- .../run/utils/output-timestamper.test.ts | 12 ++++++++++++ .../features/run/utils/output-timestamper.ts | 16 +++++++++++----- .../features/run/utils/run-output-style.test.ts | 17 +++++++++++++++-- .../src/features/run/utils/run-output-style.ts | 11 +++++++---- 4 files changed, 45 insertions(+), 11 deletions(-) diff --git a/windows/tauri/src/features/run/utils/output-timestamper.test.ts b/windows/tauri/src/features/run/utils/output-timestamper.test.ts index a8d8ce1a8..bd66696b9 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.test.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.test.ts @@ -157,6 +157,18 @@ describe("run output trimming", () => { expect(trimRunOutput(output, 8)).toBe("ok"); }); + test("drops a long BEL-terminated OSC when the cut lands inside the payload", () => { + const osc = `\u001b]0;${"t".repeat(80)}\u0007`; + const output = `${"x".repeat(8)}${osc}ok`; + expect(trimRunOutput(output, 10)).toBe("ok"); + }); + + test("drops a long ST-terminated OSC when the cut lands inside the payload", () => { + const osc = `\u001b]0;${"t".repeat(80)}\u001b\\`; + const output = `${"x".repeat(8)}${osc}ok`; + expect(trimRunOutput(output, 10)).toBe("ok"); + }); + test("trims at the next complete line when the window starts mid-line", () => { const output = `${"a".repeat(10)}\nkept\n`; expect(trimRunOutput(output, 10)).toBe("kept\n"); diff --git a/windows/tauri/src/features/run/utils/output-timestamper.ts b/windows/tauri/src/features/run/utils/output-timestamper.ts index ec8f211fd..c5394448d 100644 --- a/windows/tauri/src/features/run/utils/output-timestamper.ts +++ b/windows/tauri/src/features/run/utils/output-timestamper.ts @@ -221,12 +221,18 @@ function controlSequenceEnd(text: string, escapeIndex: number): number | undefin function advancePastIncompleteControl(text: string, start: number): number { if (start <= 0) return 0; - const lookback = Math.max(0, start - 64); - for (let index = start - 1; index >= lookback; index -= 1) { - if (text.charCodeAt(index) !== 27) continue; + // Walk the candidate line from its start so a long OSC whose ESC sits far + // before `start` is still recognized, instead of a fixed lookback window. + let index = text.lastIndexOf("\n", start - 1) + 1; + while (index < start) { + if (text.charCodeAt(index) !== 27) { + index += 1; + continue; + } const end = controlSequenceEnd(text, index); - if (end === undefined || end > start) return end ?? text.length; - break; + if (end === undefined) return text.length; + if (end > start) return end; + index = end; } return start; } diff --git a/windows/tauri/src/features/run/utils/run-output-style.test.ts b/windows/tauri/src/features/run/utils/run-output-style.test.ts index 238cad9d3..ce4b96193 100644 --- a/windows/tauri/src/features/run/utils/run-output-style.test.ts +++ b/windows/tauri/src/features/run/utils/run-output-style.test.ts @@ -57,9 +57,22 @@ describe("ANSI output colors", () => { test("skips severity coloring when the process already styled the line", () => { const spans = renderRunOutput("\u001b[1m[ERROR] Failed\u001b[0m\n"); + const error = spans.find((span) => span.text.includes("[ERROR]")); expect(spans.map((span) => span.text).join("")).toBe("[ERROR] Failed\n"); - expect(spans.some((span) => span.className === "text-destructive")).toBe(false); - expect(spans[0]?.style?.fontWeight).toBe(700); + expect(error?.className).toBeUndefined(); + expect(error?.style?.fontWeight).toBe(700); + }); + + test("keeps severity coloring on unstyled ERROR lines after an ANSI-styled line", () => { + const spans = renderRunOutput( + "\u001b[32m[INFO] Building\u001b[0m\n[ERROR] Failed to execute goal\n", + ); + const info = spans.find((span) => span.text.includes("[INFO]")); + const error = spans.find((span) => span.text.includes("[ERROR]")); + expect(info?.style?.color).toBe("var(--terminal-green)"); + expect(info?.className).toBeUndefined(); + expect(error?.className).toBe("text-destructive"); + expect(error?.style?.color).toBeUndefined(); }); }); diff --git a/windows/tauri/src/features/run/utils/run-output-style.ts b/windows/tauri/src/features/run/utils/run-output-style.ts index 5ba55a14e..7ec57faa4 100644 --- a/windows/tauri/src/features/run/utils/run-output-style.ts +++ b/windows/tauri/src/features/run/utils/run-output-style.ts @@ -172,7 +172,7 @@ export function renderRunOutput(source: string): RunOutputSpan[] { const sliceStart = Math.max(position, range.start); const sliceEnd = Math.min(end, range.end); if (sliceStart < sliceEnd) { - pushSpan(spans, parsed.text.slice(sliceStart, sliceEnd), overlay, range.style, parsed.hasStyling); + pushSpan(spans, parsed.text.slice(sliceStart, sliceEnd), overlay, range.style); position = sliceEnd; } if (range.end <= end) index += 1; @@ -185,7 +185,7 @@ export function renderRunOutput(source: string): RunOutputSpan[] { const lineEnd = newline === -1 ? parsed.text.length : newline; const line = parsed.text.slice(lineStart, lineEnd); const timeLength = leadingTimeLength(line) ?? 0; - const severity = parsed.hasStyling ? undefined : severityOfLine(line); + const severity = severityOfLine(line); emit(lineStart, lineStart + timeLength, { kind: "timestamp" }); emit(lineStart + timeLength, newline === -1 ? parsed.text.length : newline + 1, { kind: "body", @@ -202,18 +202,21 @@ type LineOverlay = | { kind: "timestamp" } | { kind: "body"; severity?: OutputSeverity }; +function hasNonDefaultSgr(style: AnsiStyle): boolean { + return style.bold || style.color !== undefined || style.background !== undefined; +} + function pushSpan( spans: RunOutputSpan[], text: string, overlay: LineOverlay, style: AnsiStyle, - hasStyling: boolean, ): void { if (text.length === 0) return; const span: RunOutputSpan = { text }; if (overlay.kind === "timestamp") { span.className = TIMESTAMP_CLASS; - } else if (hasStyling) { + } else if (hasNonDefaultSgr(style)) { span.style = ansiStyle(style); } else if (overlay.severity) { span.className = SEVERITY_CLASS[overlay.severity];