aboutsummaryrefslogtreecommitdiff
path: root/bundle/src
diff options
context:
space:
mode:
author_ov4 <ov4@ov4.org>2026-10-22 01:45:15 +0000
committer_ov4 <ov4@ov4.org>2026-10-22 01:45:15 +0000
commit660d618f8b0df318d636cd780e547890d0248c0c (patch)
treeea3f7746105d271558287ccd7268834245df68fd /bundle/src
parent427acc420c6fce10fa77e603f7696f3e89d342d6 (diff)
feat(bundle): color compiler diagnostics in Output
The runner now passes -fdiagnostics-color=always, since auto only sees a pipe. src/ansi.js turns stderr's SGR sequences into bold and colored runs, keeping style and cut-off sequences across chunks and dropping other sequences such as gcc's erase-line. Program stdout stays verbatim. Colors come from --vscode-terminal-ansi* variables. 2026 Dark leaves them unset, so vendor/vscode/terminal-colors.json pins VS Code's dark terminal defaults from the same upstream commit as the themes; a theme value still wins. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'bundle/src')
-rw-r--r--bundle/src/ansi.js36
-rw-r--r--bundle/src/editor.js5
-rw-r--r--bundle/src/main.js32
3 files changed, 63 insertions, 10 deletions
diff --git a/bundle/src/ansi.js b/bundle/src/ansi.js
new file mode 100644
index 0000000..66173ea
--- /dev/null
+++ b/bundle/src/ansi.js
@@ -0,0 +1,36 @@
+// Splits terminal text into runs styled by SGR escape sequences, keeping the
+// style and any sequence cut off at a chunk boundary for the next chunk.
+// Handles reset, bold and the 16 foreground colors; drops other sequences.
+const names = ['Black', 'Red', 'Green', 'Yellow', 'Blue', 'Magenta', 'Cyan', 'White'];
+const sequence = /\x1b\[([0-?]*)[ -/]*([@-~])/g;
+const unfinished = /\x1b(\[[0-?]*[ -/]*)?$/;
+
+export function createAnsiParser() {
+ let bold = false;
+ let color = null;
+ let rest = '';
+ return text => {
+ text = rest + text;
+ const cut = text.match(unfinished);
+ rest = cut ? cut[0] : '';
+ if (cut) text = text.slice(0, cut.index);
+ const runs = [];
+ let start = 0;
+ for (const match of text.matchAll(sequence)) {
+ if (match.index > start) runs.push({ text: text.slice(start, match.index), bold, color });
+ start = match.index + match[0].length;
+ if (match[2] !== 'm') continue;
+ for (const parameter of match[1].split(';')) {
+ const code = Number(parameter || 0);
+ if (code === 0) { bold = false; color = null; }
+ else if (code === 1) bold = true;
+ else if (code === 22) bold = false;
+ else if (code === 39) color = null;
+ else if (code >= 30 && code <= 37) color = `terminal.ansi${names[code - 30]}`;
+ else if (code >= 90 && code <= 97) color = `terminal.ansiBright${names[code - 90]}`;
+ }
+ }
+ if (start < text.length) runs.push({ text: text.slice(start), bold, color });
+ return runs;
+ };
+}
diff --git a/bundle/src/editor.js b/bundle/src/editor.js
index 853fa82..58b0e19 100644
--- a/bundle/src/editor.js
+++ b/bundle/src/editor.js
@@ -33,7 +33,10 @@ export const theme = {
tokenColors: [{ settings: { foreground: dark.colors['editor.foreground'], background: dark.colors['editor.background'] } }, ...dark.tokenColors]
};
-for (const [key, value] of Object.entries(dark.colors)) document.documentElement.style.setProperty(`--vscode-${key.replaceAll('.', '-')}`, value);
+export const themeVariable = key => `--vscode-${key.replaceAll('.', '-')}`;
+// VS Code falls back to its terminal defaults when a theme leaves terminal.ansi* unset.
+const colors = { ...parse(sources['../vendor/vscode/terminal-colors.json']).colors, ...dark.colors };
+for (const [key, value] of Object.entries(colors)) document.documentElement.style.setProperty(themeVariable(key), value);
class TokenState {
constructor(stack) { this.stack = stack; }
diff --git a/bundle/src/main.js b/bundle/src/main.js
index b71ed27..991ef06 100644
--- a/bundle/src/main.js
+++ b/bundle/src/main.js
@@ -3,7 +3,8 @@ import { getCurrentWindow } from '@tauri-apps/api/window';
import { open, save, message } from '@tauri-apps/plugin-dialog';
import { KeyCode, KeyMod } from 'monaco-editor/editor/editor.api.js';
import { StandardKeyboardEvent } from 'monaco-editor/base/browser/keyboardEvent.js';
-import { createEditor } from './editor.js';
+import { createEditor, themeVariable } from './editor.js';
+import { createAnsiParser } from './ansi.js';
import './style.css';
const status = document.querySelector('#status');
@@ -34,26 +35,39 @@ async function runFile() {
output.textContent = '';
status.textContent = 'Running';
const decoders = { stdout: new TextDecoder(), stderr: new TextDecoder() };
- let pending = '';
+ // Only compiler diagnostics on stderr are colored; program output stays verbatim.
+ const diagnostics = createAnsiParser();
+ const pending = document.createDocumentFragment();
let frame = null;
function flush() {
frame = null;
- if (!pending) return;
- output.append(document.createTextNode(pending));
- pending = '';
+ if (!pending.hasChildNodes()) return;
+ output.append(pending);
output.scrollTop = output.scrollHeight;
}
- function append(text) {
- pending += text;
+ function append(stream, text) {
+ for (const run of stream === 'stderr' ? diagnostics(text) : [{ text }]) {
+ if (!run.text) continue;
+ if (!run.bold && !run.color) {
+ if (pending.lastChild?.nodeType === Node.TEXT_NODE) pending.lastChild.appendData(run.text);
+ else pending.append(run.text);
+ continue;
+ }
+ const span = document.createElement('span');
+ if (run.bold) span.style.fontWeight = 'bold';
+ if (run.color) span.style.color = `var(${themeVariable(run.color)})`;
+ span.textContent = run.text;
+ pending.append(span);
+ }
if (frame === null) frame = requestAnimationFrame(flush);
}
let finish;
const finished = new Promise(resolve => { finish = resolve; });
const channel = new Channel(event => {
if (event.event === 'data') {
- append(decoders[event.stream].decode(Uint8Array.from(event.bytes), { stream: true }));
+ append(event.stream, decoders[event.stream].decode(Uint8Array.from(event.bytes), { stream: true }));
} else if (event.event === 'finished') {
- for (const decoder of Object.values(decoders)) append(decoder.decode());
+ for (const [stream, decoder] of Object.entries(decoders)) append(stream, decoder.decode());
if (frame !== null) cancelAnimationFrame(frame);
flush();
status.textContent = event.stopped ? 'Stopped' : `Exit code: ${event.code ?? 'terminated'}`;