From 2781fb24e4f79efb94fdc98ec176f3f037d2ea54 Mon Sep 17 00:00:00 2001 From: okcodes <80954089+okcodes@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:23:18 -0600 Subject: [PATCH 1/4] Highlight matching brackets in the REPL input Adds DevTools-style bracket-pair highlighting to the REPL's live input: the bracket adjacent to the caret and its match get a subtle background plus underline, updated as the caret moves (not just as text changes). findMatchingBrackets() (bracket-matcher.ts) resolves which bracket is "active" for the caret the same way Chrome/CodeMirror do - checking the character immediately behind the caret before the one in front - so back-to-back brackets with no whitespace (`}]`) resolve unambiguously, while whitespace-separated brackets match from either side since only one side is ever touching a bracket character. Matching itself walks the existing tokenizer's punctuation stream with a stack, so brackets inside strings/comments are never candidates and mismatched closers are left unpaired rather than mispairing. highlightCode() takes an optional matched-pair offset tuple and wraps just those two punctuation tokens; the REPL wires it up by reading live caret/selection state (not values captured at event time) inside the existing debounced highlight update, and adds click/keyup/select listeners so pure caret movement (not just typing) also refreshes it. Co-Authored-By: Claude Sonnet 5 --- src/runtime/bracket-matcher.test.ts | 71 +++++++++++++++++++++++++++++ src/runtime/bracket-matcher.ts | 62 +++++++++++++++++++++++++ src/runtime/styles/base.css | 11 +++++ src/runtime/syntax-highlighter.ts | 23 +++++++++- src/runtime/ui.ts | 24 ++++++++-- 5 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 src/runtime/bracket-matcher.test.ts create mode 100644 src/runtime/bracket-matcher.ts diff --git a/src/runtime/bracket-matcher.test.ts b/src/runtime/bracket-matcher.test.ts new file mode 100644 index 0000000..8889dc6 --- /dev/null +++ b/src/runtime/bracket-matcher.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from 'vitest'; +import { findMatchingBrackets } from './bracket-matcher'; + +describe('findMatchingBrackets', () => { + it('matches a simple pair with the caret right after the opener', () => { + // "(a)" - caret after "(" (index 1) + expect(findMatchingBrackets('(a)', 1)).toEqual([0, 2]); + }); + + it('matches a simple pair with the caret right before the opener', () => { + // "(a)" - caret before "(" (index 0), nothing precedes it + expect(findMatchingBrackets('(a)', 0)).toEqual([0, 2]); + }); + + it('matches with the caret right before the closer', () => { + // "(a)" - caret before ")" (index 2) + expect(findMatchingBrackets('(a)', 2)).toEqual([0, 2]); + }); + + it('matches with the caret right after the closer', () => { + // "(a)" - caret after ")" (index 3) + expect(findMatchingBrackets('(a)', 3)).toEqual([0, 2]); + }); + + it('prefers the bracket behind the caret when two sit back-to-back with no space', () => { + const code = '[{ONE:{a:1},TWO:[1,2]}]'; + // 0123456789... + + // Caret between the inner object's "}" (21) and the outer array's + // "]" (22): the character immediately behind the caret ("}") wins, + // so this should match the outer object's braces, not the array. + expect(findMatchingBrackets(code, 22)).toEqual([1, 21]); + + // Caret after the final "]" (23): behind the caret is "]" (22), + // which matches the array's opening "[" at index 0. + expect(findMatchingBrackets(code, 23)).toEqual([0, 22]); + }); + + it('matches the same pair from either side when brackets are separated by whitespace', () => { + const code = '[ { } ]'; + // 0123456 + + // Caret just after "{" (index 3) and just before "{" (index 2) are + // both "touching" the same bracket once whitespace separates it + // from its neighbor, so the side doesn't change the result. + expect(findMatchingBrackets(code, 2)).toEqual([2, 4]); + expect(findMatchingBrackets(code, 3)).toEqual([2, 4]); + }); + + it('returns null when the caret is not adjacent to any bracket', () => { + expect(findMatchingBrackets('( a )', 2)).toBeNull(); + }); + + it('returns null for an unmatched (unbalanced) bracket', () => { + expect(findMatchingBrackets('(a', 1)).toBeNull(); + }); + + it('returns null for mismatched bracket types instead of pairing them', () => { + // "(]" - the "(" and "]" never match, so neither is highlighted. + expect(findMatchingBrackets('(]', 1)).toBeNull(); + expect(findMatchingBrackets('(]', 2)).toBeNull(); + }); + + it('ignores brackets inside a string literal', () => { + expect(findMatchingBrackets('"(a)"', 2)).toBeNull(); + }); + + it('returns null for empty input', () => { + expect(findMatchingBrackets('', 0)).toBeNull(); + }); +}); diff --git a/src/runtime/bracket-matcher.ts b/src/runtime/bracket-matcher.ts new file mode 100644 index 0000000..1a89e5e --- /dev/null +++ b/src/runtime/bracket-matcher.ts @@ -0,0 +1,62 @@ +import { tokenize } from './tokenizer'; + +const OPEN_TO_CLOSE: Record = { '(': ')', '[': ']', '{': '}' }; +const OPENERS = new Set(Object.keys(OPEN_TO_CLOSE)); +const CLOSERS = new Set(Object.values(OPEN_TO_CLOSE)); + +interface BracketToken { + char: string; + pos: number; +} + +/** + * Finds the character offsets of the bracket pair that should be highlighted + * for a given cursor position, mirroring Chrome DevTools / editor behavior: + * only punctuation-token brackets count (so `"{"` inside a string is never a + * candidate - tokenize() already separates those out), and matching runs + * left-to-right with a stack so mismatched closers are simply left unpaired + * rather than corrupting the rest of the match. + * + * Which bracket is "active" for the cursor resolves the ambiguity of + * back-to-back brackets with no whitespace between them (`}]`): the + * character immediately *before* the cursor wins over the one immediately + * after. When whitespace separates brackets, only one side is ever actually + * touching a bracket character, so this priority never changes the outcome - + * it only matters for the tightly-packed case. + */ +export function findMatchingBrackets(code: string, cursorPos: number): [number, number] | null { + const brackets: BracketToken[] = []; + let offset = 0; + for (const token of tokenize(code)) { + if (token.type === 'punctuation' && (OPENERS.has(token.value) || CLOSERS.has(token.value))) { + brackets.push({ char: token.value, pos: offset }); + } + offset += token.value.length; + } + + if (brackets.length === 0) return null; + + const matches = new Map(); + const stack: BracketToken[] = []; + for (const bracket of brackets) { + if (OPENERS.has(bracket.char)) { + stack.push(bracket); + continue; + } + const top = stack[stack.length - 1]; + if (top && OPEN_TO_CLOSE[top.char] === bracket.char) { + stack.pop(); + matches.set(top.pos, bracket.pos); + matches.set(bracket.pos, top.pos); + } + } + + const findAt = (pos: number) => brackets.find((b) => b.pos === pos); + const active = findAt(cursorPos - 1) ?? findAt(cursorPos); + if (!active) return null; + + const matchPos = matches.get(active.pos); + if (matchPos === undefined) return null; + + return active.pos < matchPos ? [active.pos, matchPos] : [matchPos, active.pos]; +} diff --git a/src/runtime/styles/base.css b/src/runtime/styles/base.css index 87ccaed..06f3f2d 100644 --- a/src/runtime/styles/base.css +++ b/src/runtime/styles/base.css @@ -506,6 +506,17 @@ color: var(--vc-comment); } +/* Matching bracket pair in the REPL input - subtle background plus a small + underline, like DevTools/editor bracket-match highlighting. Uses the same + theme-agnostic gray overlay as the rest of this file (see the various + rgba(128, 128, 128, ...) rules above) instead of a per-theme variable, + since every theme's text stays legible over a neutral gray at low alpha. */ +.vc-bracket-match { + background: rgba(128, 128, 128, 0.25); + border-bottom: 1px solid rgba(128, 128, 128, 0.7); + border-radius: 2px; +} + .vc-property-key { margin-right: 6px; color: var(--vc-property-key); diff --git a/src/runtime/syntax-highlighter.ts b/src/runtime/syntax-highlighter.ts index 0703caf..23f7535 100644 --- a/src/runtime/syntax-highlighter.ts +++ b/src/runtime/syntax-highlighter.ts @@ -8,7 +8,8 @@ const TOKEN_CLASS = { BOOLEAN: 'vc-boolean', COMMENT: 'vc-comment', OPERATOR: 'vc-operator', - FUNCTION: 'vc-function' + FUNCTION: 'vc-function', + BRACKET_MATCH: 'vc-bracket-match' }; function escapeHtml(unsafe: string): string { @@ -24,14 +25,32 @@ function escapeHtml(unsafe: string): string { * Renders a token stream as HTML with a per highlighted token, for * use as a display layer behind a transparent (so it can't be run * through a full parser/AST). + * + * `matchBrackets`, when given, is a pair of character offsets (from + * findMatchingBrackets) identifying the two bracket punctuation tokens to + * render with the matched-pair style instead of their normal plain + * passthrough. */ -export function highlightCode(code: string): string { +export function highlightCode(code: string, matchBrackets?: [number, number] | null): string { if (!code) return ''; const tokens = tokenize(code); let html = ''; + let offset = 0; tokens.forEach((token: Token, index: number) => { + const tokenStart = offset; + offset += token.value.length; + + if ( + token.type === 'punctuation' && + matchBrackets && + (tokenStart === matchBrackets[0] || tokenStart === matchBrackets[1]) + ) { + html += `${escapeHtml(token.value)}`; + return; + } + switch (token.type) { case 'string': html += `${escapeHtml(token.value)}`; diff --git a/src/runtime/ui.ts b/src/runtime/ui.ts index 5a60ff7..57135f1 100644 --- a/src/runtime/ui.ts +++ b/src/runtime/ui.ts @@ -5,6 +5,7 @@ import type { IconName } from './icons'; import { createObjectViewer } from './object-viewer'; import { repl } from './repl'; import { highlightCode } from './syntax-highlighter'; +import { findMatchingBrackets } from './bracket-matcher'; import { getStorageItem, setStorageItem, STORAGE_KEYS } from './storage'; import { cycleTheme, getThemeConfig, initThemeIndex } from './theme'; import type { LogType } from './types'; @@ -668,9 +669,16 @@ function setupREPL( } }); - // Debounced so retyping fast on mobile doesn't re-tokenize on every keystroke - const updateHighlight = debounce((code: string) => { - highlightBackdrop.innerHTML = highlightCode(code); + // Debounced so retyping fast on mobile doesn't re-tokenize on every + // keystroke. Reads selection state live (not via captured args) so a + // trailing call always reflects where the caret ended up, even after a + // programmatic value change followed by an async caret move (see the + // ArrowUp/ArrowDown history handling below). + const updateHighlight = debounce(() => { + const cursorPos = input.selectionStart ?? 0; + const hasSelection = cursorPos !== (input.selectionEnd ?? cursorPos); + const matchBrackets = hasSelection ? null : findMatchingBrackets(input.value, cursorPos); + highlightBackdrop.innerHTML = highlightCode(input.value, matchBrackets); }, 50); // Grows the textarea to fit its content (up to the CSS max-height, past @@ -760,7 +768,7 @@ function setupREPL( const val = input.value; autosize(); - updateHighlight(val); + updateHighlight(); // Pre-evaluation preview const result = repl.preEvaluate(val); @@ -811,4 +819,12 @@ function setupREPL( suggestionsBox.style.display = 'none'; }, 200); }); + + // Bracket matching depends on caret position, not just text content, so + // it also needs to refresh on pure caret movement - a click to + // reposition, arrow-key navigation, or a mouse/keyboard text selection - + // none of which fire 'input'. + input.addEventListener('click', updateHighlight); + input.addEventListener('keyup', updateHighlight); + input.addEventListener('select', updateHighlight); } From 7ada45f149752bd09fe546f997871e72df0e8065 Mon Sep 17 00:00:00 2001 From: okcodes <80954089+okcodes@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:24:29 -0600 Subject: [PATCH 2/4] bump main to v0.7.9-alpha.1 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 39e52e5..9ef5c22 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@codehacks/virtual-console", - "version": "0.7.9-alpha.0", + "version": "0.7.9-alpha.1", "packageManager": "pnpm@11.25.0", "description": "DevTools-style console for the places that don't have DevTools — iOS WebViews, Android WebViews, any page where console.log goes nowhere.", "type": "module", From 011e45e83e5f7aa39999faa5fe0a218e201821a8 Mon Sep 17 00:00:00 2001 From: okcodes <80954089+okcodes@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:27:11 -0600 Subject: [PATCH 3/4] Bump website to use 0.7.9-alpha.1 --- website/package.json | 2 +- website/pnpm-lock.yaml | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/website/package.json b/website/package.json index 74cb042..4f81b5e 100644 --- a/website/package.json +++ b/website/package.json @@ -12,7 +12,7 @@ "clean": "rm -rf dist dist-ssr .vite" }, "dependencies": { - "@codehacks/virtual-console": "0.7.9-alpha.0", + "@codehacks/virtual-console": "0.7.9-alpha.1", "lucide-react": "^1.39.0", "react": "^19.2.8", "react-dom": "^19.2.8" diff --git a/website/pnpm-lock.yaml b/website/pnpm-lock.yaml index 2e6c156..b0e3ded 100644 --- a/website/pnpm-lock.yaml +++ b/website/pnpm-lock.yaml @@ -9,8 +9,8 @@ importers: .: dependencies: '@codehacks/virtual-console': - specifier: 0.7.9-alpha.0 - version: 0.7.9-alpha.0(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) + specifier: 0.7.9-alpha.1 + version: 0.7.9-alpha.1(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0)) lucide-react: specifier: ^1.39.0 version: 1.39.0(react@19.2.8) @@ -48,8 +48,8 @@ importers: packages: - '@codehacks/virtual-console@0.7.9-alpha.0': - resolution: {integrity: sha512-17KbQoaWbYc+ZtJ2UyWI6QU1kmtoddhEfXeuiqzR7nQRKKP2sLQJIKrI8kKdqjaQ6LWpWjI+qVGs6Zr34kFJQg==} + '@codehacks/virtual-console@0.7.9-alpha.1': + resolution: {integrity: sha512-hGMaQt0k+57bued+S9ylN8TNF/UW59q0ed2atWFzkEC57K4s7cw2tQ4h7PUj82Kjnbk58C0D7dRpyvzKtTAS5A==} engines: {node: '>=26'} peerDependencies: vite: '>=5.0.0 <9.0.0' @@ -705,7 +705,7 @@ packages: snapshots: - '@codehacks/virtual-console@0.7.9-alpha.0(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))': + '@codehacks/virtual-console@0.7.9-alpha.1(vite@8.2.2(@types/node@26.4.1)(jiti@2.7.0))': optionalDependencies: vite: 8.2.2(@types/node@26.4.1)(jiti@2.7.0) From 916edfcf7a52c0b2cd6474e89b285c488e0c46ef Mon Sep 17 00:00:00 2001 From: okcodes <80954089+okcodes@users.noreply.github.com> Date: Sun, 6 Sep 2026 08:27:33 -0600 Subject: [PATCH 4/4] bump website to v0.1.5-alpha.0 --- website/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/website/package.json b/website/package.json index 4f81b5e..8e6e585 100644 --- a/website/package.json +++ b/website/package.json @@ -1,7 +1,7 @@ { "name": "virtual-console-website", "private": true, - "version": "0.1.4", + "version": "0.1.5-alpha.0", "type": "module", "scripts": { "dev": "vite",