From 61033a99bbb581bacb0e7b6f39a359377e0865f7 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Tue, 7 Jul 2026 16:16:50 -0700 Subject: [PATCH 01/53] feat(command-visualizer): vendor cursorless hat-allocation algorithm Self-contained copy of cursorless's allocateHats subgraph (chooseTokenHat, getHatRankingContext, HatMetrics, getTokenComparator, maxByFirstDiffering) at SHA 42452eb, plus a grapheme splitter and a tokens-in ranking wrapper. Vendored because the algorithm is not exported from any cursorless library entry point; see VENDOR.md. Byte-identical hat assignments, no IDE dep. --- .../src/vendor/allocate-hats/VENDOR.md | 39 +++ .../allocate-hats/common/CompositeKeyMap.ts | 48 +++ .../vendor/allocate-hats/common/DefaultMap.ts | 33 ++ .../src/vendor/allocate-hats/common/index.ts | 3 + .../src/vendor/allocate-hats/common/types.ts | 105 ++++++ .../src/vendor/allocate-hats/index.ts | 313 ++++++++++++++++++ .../src/vendor/allocate-hats/rank.ts | 46 +++ .../src/vendor/allocate-hats/splitter.ts | 96 ++++++ .../vendor/allocate-hats/vendor/HatMetrics.ts | 116 +++++++ .../allocate-hats/vendor/chooseTokenHat.ts | 86 +++++ .../vendor/getHatRankingContext.ts | 81 +++++ .../vendor/getTokenComparator.ts | 46 +++ .../vendor/maxByFirstDiffering.ts | 75 +++++ 13 files changed, 1087 insertions(+) create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/VENDOR.md create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/common/CompositeKeyMap.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/common/DefaultMap.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/common/index.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/common/types.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/index.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/rank.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/splitter.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/vendor/HatMetrics.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/vendor/chooseTokenHat.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/vendor/getHatRankingContext.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/vendor/getTokenComparator.ts create mode 100644 packages/command-visualizer/src/vendor/allocate-hats/vendor/maxByFirstDiffering.ts diff --git a/packages/command-visualizer/src/vendor/allocate-hats/VENDOR.md b/packages/command-visualizer/src/vendor/allocate-hats/VENDOR.md new file mode 100644 index 0000000000..549369d591 --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/VENDOR.md @@ -0,0 +1,39 @@ +# Vendored: allocate-hats + +Internalized copy of the `allocate-hats` package's `src/` tree. + +- **Source repo:** github.com/trillium/allocate-hats +- **Vendored commit / SHA:** `42452eb` (recorded in the source headers; this is + also the cursorless SHA `42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe` whose + `allocateHats` subgraph these files vendor, proven byte-identical to the + shipped prose-overlay bundle). +- **Why internalized:** `allocate-hats@0.1.0` is unpublished (npm has no such + package; it lived only at `~/code/allocate-hats`). Vendoring lets + `@cursorless/command-visualizer` compile in-monorepo with zero external deps. + +## What was copied vs. dropped + +Copied verbatim (the allocation core our package actually uses): + +- `index.ts` — public entry (`allocateHats`, `StandaloneGraphemeSplitter`, + `HatStyleMap`, style-map builders). +- `rank.ts`, `splitter.ts` +- `common/` — `types.ts`, `index.ts`, `CompositeKeyMap.ts`, `DefaultMap.ts` +- `vendor/` — `chooseTokenHat.ts`, `getHatRankingContext.ts`, + `getTokenComparator.ts`, `HatMetrics.ts`, `maxByFirstDiffering.ts` + +Dropped during vendoring (unused by this package — grep-confirmed no importers +in `src/`, and nothing inside the vendored tree imports them): + +- `bundle.ts` — QuickJS IIFE entry (exposes API on `globalThis`). +- `proseCompat.ts` — prose-overlay legacy JSON-in/JSON-out compatibility API. + +## Consumer + +`src/hat-allocator.ts` imports `allocateHats`, `StandaloneGraphemeSplitter`, +and the `HatStyleMap` type from `./vendor/allocate-hats/index`. + +## Updating + +Re-copy `~/code/allocate-hats/src/`, then re-drop `bundle.ts` and +`proseCompat.ts`, and bump the SHA above. diff --git a/packages/command-visualizer/src/vendor/allocate-hats/common/CompositeKeyMap.ts b/packages/command-visualizer/src/vendor/allocate-hats/common/CompositeKeyMap.ts new file mode 100644 index 0000000000..fb7e82472a --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/common/CompositeKeyMap.ts @@ -0,0 +1,48 @@ +/** + * Vendored verbatim from cursorless packages/common/src/util/CompositeKeyMap.ts + * at SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe. No edits below this header. + */ + +/** + * A map that can use a composite key, i.e. a key that is an array or object. + * The key is hashed by running {@link hashFunction}, which is expected to + * output a list whose entries can be converted to string. + * + * Based on https://stackoverflow.com/a/54523103 + */ +export class CompositeKeyMap { + private map: Record = {}; + + /** + * + * @param hashFunction A function that maps from a key to a list whose entries can be converted to string + */ + constructor(private hashFunction: (key: K) => unknown[]) {} + + private hash(key: K): string { + return this.hashFunction(key).join("\u0000"); + } + + set(key: K, item: V): this { + this.map[this.hash(key)] = item; + return this; + } + + has(key: K): boolean { + return this.hash(key) in this.map; + } + + get(key: K): V | undefined { + return this.map[this.hash(key)]; + } + + delete(key: K): this { + delete this.map[this.hash(key)]; + return this; + } + + clear(): this { + this.map = {}; + return this; + } +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/common/DefaultMap.ts b/packages/command-visualizer/src/vendor/allocate-hats/common/DefaultMap.ts new file mode 100644 index 0000000000..9e1b215653 --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/common/DefaultMap.ts @@ -0,0 +1,33 @@ +/** + * Vendored verbatim from cursorless packages/common/src/util/DefaultMap.ts + * at SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe. + * One edit: `export default class` -> `export class` (this package uses + * named exports throughout). + */ + +/** + * A map that returns a default value when a key is not found. + * + * Based on https://aaronmoat.com/implementing-pythons-defaultdict-in-javascript/ + */ +export class DefaultMap extends Map { + /** + * @param getDefaultValue A function that returns the default value for a given key + */ + constructor(private getDefaultValue: (key: K) => V) { + super(); + } + + get(key: K): V { + const currentValue = super.get(key); + + if (currentValue != null) { + return currentValue; + } + + const value = this.getDefaultValue(key); + this.set(key, value); + + return value; + } +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/common/index.ts b/packages/command-visualizer/src/vendor/allocate-hats/common/index.ts new file mode 100644 index 0000000000..30bf1a433e --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/common/index.ts @@ -0,0 +1,3 @@ +export * from "./types"; +export { CompositeKeyMap } from "./CompositeKeyMap"; +export { DefaultMap } from "./DefaultMap"; diff --git a/packages/command-visualizer/src/vendor/allocate-hats/common/types.ts b/packages/command-visualizer/src/vendor/allocate-hats/common/types.ts new file mode 100644 index 0000000000..75492782cd --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/common/types.ts @@ -0,0 +1,105 @@ +/** + * Minimal type surface extracted from @cursorless/common at cursorless SHA + * 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe (the pinned SHA all three delivery + * substrates already build against). + * + * Position and Range are deliberately SIMPLIFIED: the allocation path only + * ever constructs them and reads `.line` / `.character` / `.start` / `.end`. + * Upstream's method surface (isEqual, union, toSelection, ...) is unused by + * the allocator and omitted. Token / TokenHat / HatStyleMap match upstream + * shapes field-for-field. + */ + +export class Position { + constructor( + public readonly line: number, + public readonly character: number, + ) {} +} + +export class Range { + readonly start: Position; + readonly end: Position; + + constructor(start: Position, end: Position) { + this.start = start; + this.end = end; + } +} + +export interface RangeOffsets { + start: number; + end: number; +} + +/** + * The allocator never dereferences the editor beyond `id` (used as part of + * the CompositeKeyMap token identity key). + */ +export interface MinimalEditor { + id: string; +} + +/** Mirrors @cursorless/common types/Token.ts */ +export interface Token { + editor: MinimalEditor; + range: Range; + offsets: RangeOffsets; + text: string; +} + +export type HatStyleName = string; + +/** Mirrors @cursorless/common types/HatTokenMap.ts TokenHat */ +export interface TokenHat { + hatStyle: HatStyleName; + grapheme: string; + token: Token; + hatRange: Range; +} + +export type HatStyleMap = Record; + +/** + * Mirrors @cursorless/common ide/types/HatStability.ts — string enum values + * are load-bearing (callers pass the raw strings across JSON boundaries). + */ +export enum HatStability { + greedy = "greedy", + balanced = "balanced", + stable = "stable", +} + +/** Mirrors the grapheme shape produced by cursorless's TokenGraphemeSplitter */ +export interface Grapheme { + text: string; + tokenStartOffset: number; + tokenEndOffset: number; +} + +/** + * Structural stand-in for cursorless's TokenGraphemeSplitter — the ranking + * context only calls `getTokenGraphemes`. + */ +export interface GraphemeSplitter { + getTokenGraphemes(tokenText: string): Grapheme[]; +} + +/** + * Mirrors HatCandidate from cursorless-engine util/allocateHats/allocateHats.ts + */ +export interface HatCandidate { + grapheme: Grapheme; + style: HatStyleName; + penalty: number; +} + +/** Mirrors RankedToken from util/allocateHats/getRankedTokens.ts */ +export interface RankedToken { + token: Token; + /** + * Higher rank = more likely to be used = gets a better hat. Upstream uses + * rank = -sortedIndex (0 is best, increasingly negative is worse). + */ + rank: number; +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/index.ts b/packages/command-visualizer/src/vendor/allocate-hats/index.ts new file mode 100644 index 0000000000..84a9e4e7a2 --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/index.ts @@ -0,0 +1,313 @@ +/** + * allocate-hats — standalone Cursorless hat allocation with a tokens-in + * interface. + * + * You bring tokens (already tokenized however your substrate likes); this + * package runs the REAL cursorless allocation algorithm (chooseTokenHat + + * getHatRankingContext + HatMetrics + maxByFirstDiffering, vendored at SHA + * 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe) and returns hat assignments. + * + * Language support is upstream of allocation by design: languageId appears + * nowhere in the allocator (verified across cursorless, prose-overlay, + * Touchless, cursorless-css-state — brain-1uh8e §4). Tokenize with whatever + * language machinery you have; allocation is language-blind. + */ + +import { + CompositeKeyMap, + DefaultMap, + HatStability, + type HatCandidate, + type HatStyleMap, + type HatStyleName, + Position, + Range, + type RankedToken, + type Token, + type TokenHat, +} from "./common"; +import { chooseTokenHat } from "./vendor/chooseTokenHat"; +import { getHatRankingContext } from "./vendor/getHatRankingContext"; +import { rankTokensByProximity } from "./rank"; +import { StandaloneGraphemeSplitter } from "./splitter"; + +export { HatStability } from "./common"; +export type { HatStyleMap, HatStyleName } from "./common"; +export { StandaloneGraphemeSplitter, deburr } from "./splitter"; + +// --------------------------------------------------------------------------- +// Public input/output types +// --------------------------------------------------------------------------- + +export interface InputToken { + /** The token's text. Graphemes are derived internally by the splitter. */ + text: string; + /** + * Optional explicit rank: HIGHER = more important = better hat. When every + * token carries a rank, ranks are used as-is. When any token omits it, all + * ranks are derived from cursor proximity instead (see + * {@link AllocateHatsOptions.cursorIndex}). + */ + rank?: number; + /** + * Optional document position for multi-line proximity ranking (touchless / + * css-state style inputs). When omitted, the token sits at line 0, + * character = its array index (the single-line prose model). + */ + position?: { line: number; character: number }; +} + +export interface HatAssignment { + /** Index into the input tokens array. */ + tokenIdx: number; + /** Character offset of the hatted grapheme within the token text. */ + charIdx: number; + /** The normalized grapheme the hat sits on. */ + grapheme: string; + /** Full style name, e.g. "blue" or "blue-frame". */ + styleName: string; +} + +export interface AllocateHatsOptions { + tokens: InputToken[]; + /** + * Hat assignments from the previous allocation — pass these back each + * recompute to get hat stability (hats stay put as the buffer changes). + */ + oldAssignments?: HatAssignment[]; + /** Keep/steal trade-off. Default: balanced (cursorless's default). */ + stability?: HatStability | "greedy" | "balanced" | "stable"; + /** + * Enabled hat styles with penalties (lower penalty = preferred). Defaults + * to {@link buildColorStyles} — 9 colors, no shapes. + */ + enabledHatStyles?: HatStyleMap; + /** + * Used only when explicit per-token ranks are absent: tokens are ranked by + * proximity to this gap index (0 = before first token, N = after last). + * Defaults to tokens.length (end-biased), matching prose-overlay's + * no-cursor behavior. Ignored when {@link cursorPosition} is set. + */ + cursorIndex?: number; + /** + * Multi-line variant of {@link cursorIndex}: the reference position tokens + * are ranked against (closest = best hat), for use with + * {@link InputToken.position}. Ranking is |displayLine delta| then + * |character delta| — cursorless's getTokenComparator, vendored. + */ + cursorPosition?: { line: number; character: number }; +} + +// --------------------------------------------------------------------------- +// Default style maps (prose-overlay palette; matches cursorless's color set) +// --------------------------------------------------------------------------- + +export const HAT_COLORS = [ + "gray", + "blue", + "green", + "red", + "pink", + "yellow", + "purple", + "black", + "white", +] as const; + +export const HAT_COLOR_PENALTIES: Record = { + gray: 0, + blue: 1, + green: 1, + red: 1, + pink: 2, + yellow: 2, + purple: 2, + black: 3, + white: 3, +}; + +/** + * Shape suffix vocabulary — mirrors cursorless HAT_NON_DEFAULT_SHAPES + * (packages/common/src/types/command/legacy/targetDescriptorV2.types.ts). + */ +export const HAT_SHAPES = [ + "ex", + "fox", + "wing", + "hole", + "frame", + "curve", + "eye", + "play", + "bolt", + "crosshairs", +] as const; + +/** 9-entry color-only style map. */ +export function buildColorStyles(): HatStyleMap { + const out: HatStyleMap = {}; + for (const color of HAT_COLORS) { + out[color] = { penalty: HAT_COLOR_PENALTIES[color] }; + } + return out; +} + +/** + * Full 99-entry color x (no-shape + 10 shapes) map. Shape adds +1 to the + * color's penalty, matching the upstream convention that each style + * component contributes to total penalty. + */ +export function buildColorShapeStyles(): HatStyleMap { + const out: HatStyleMap = {}; + for (const color of HAT_COLORS) { + const colorPenalty = HAT_COLOR_PENALTIES[color]; + out[color] = { penalty: colorPenalty }; + for (const shape of HAT_SHAPES) { + out[`${color}-${shape}`] = { penalty: colorPenalty + 1 }; + } + } + return out; +} + +// --------------------------------------------------------------------------- +// Internal: token construction + ranking +// --------------------------------------------------------------------------- + +const FAKE_EDITOR = { id: "allocate-hats" }; + +function makeToken(input: InputToken, index: number): Token { + const line = input.position?.line ?? 0; + const character = input.position?.character ?? index; + const start = new Position(line, character); + const end = new Position(line, character + 1); + return { + editor: FAKE_EDITOR, + text: input.text, + // offsets.start doubles as the stable token identity (= input index); + // range carries the (possibly caller-supplied) document position used + // for proximity ranking. + range: new Range(start, end), + offsets: { start: index, end: index + 1 }, + }; +} + +/** + * getTokenRemainingHatCandidates — copied from cursorless allocateHats.ts + * (unexported upstream; same copy prose-overlay's proseStandalone.ts carries). + */ +function getTokenRemainingHatCandidates( + splitter: StandaloneGraphemeSplitter, + token: Token, + graphemeRemainingHatCandidates: DefaultMap, + enabledHatStyles: HatStyleMap, +): HatCandidate[] { + const candidates: HatCandidate[] = []; + const graphemes = splitter.getTokenGraphemes(token.text); + for (const grapheme of graphemes) { + for (const style of graphemeRemainingHatCandidates.get(grapheme.text)) { + candidates.push({ + grapheme, + style, + penalty: enabledHatStyles[style]?.penalty ?? 99, + }); + } + } + return candidates; +} + +// --------------------------------------------------------------------------- +// Main entry point +// --------------------------------------------------------------------------- + +export function allocateHats(options: AllocateHatsOptions): HatAssignment[] { + const { + tokens, + oldAssignments = [], + stability = HatStability.balanced, + enabledHatStyles = buildColorStyles(), + cursorIndex, + cursorPosition, + } = options; + + const hatStability = stability as HatStability; + const splitter = new StandaloneGraphemeSplitter(); + const enabledHatStyleNames = Object.keys(enabledHatStyles); + + const tokenObjects = tokens.map((t, i) => makeToken(t, i)); + + // Ranking: explicit ranks when every token has one, else proximity to the + // reference position (cursorPosition, or gap cursorIndex on line 0 — + // defaulting to end-biased, matching prose-overlay's no-cursor behavior). + const allRanked = tokens.every((t) => typeof t.rank === "number"); + const referencePosition = cursorPosition + ? new Position(cursorPosition.line, cursorPosition.character) + : new Position(0, cursorIndex ?? tokens.length); + const rankedTokens: RankedToken[] = allRanked + ? tokens.map((t, i) => ({ token: tokenObjects[i], rank: t.rank! })) + : rankTokensByProximity(tokenObjects, referencePosition); + + // Old hat map for stability. + const tokenOldHatMap = new CompositeKeyMap( + ({ editor, offsets }) => [editor.id, offsets.start, offsets.end], + ); + for (const { tokenIdx, grapheme, styleName } of oldAssignments) { + if (tokenIdx >= 0 && tokenIdx < tokens.length) { + const token = tokenObjects[tokenIdx]; + tokenOldHatMap.set(token, { + hatStyle: styleName, + grapheme, + token, + hatRange: token.range, + }); + } + } + + const context = getHatRankingContext(rankedTokens, tokenOldHatMap, splitter); + + const graphemeRemainingHatCandidates = new DefaultMap( + () => [...enabledHatStyleNames], + ); + + // Process tokens in descending rank order (best tokens first). + const sortedRanked = [...rankedTokens].sort((a, b) => b.rank - a.rank); + const result: HatAssignment[] = []; + + for (const { token, rank } of sortedRanked) { + const candidates = getTokenRemainingHatCandidates( + splitter, + token, + graphemeRemainingHatCandidates, + enabledHatStyles, + ); + + const chosen = chooseTokenHat( + context, + hatStability, + rank, + tokenOldHatMap.get(token), + candidates, + ); + + if (chosen == null) { + continue; + } + + // Remove the chosen hat from candidates for lower-ranked tokens. + graphemeRemainingHatCandidates.set( + chosen.grapheme.text, + graphemeRemainingHatCandidates + .get(chosen.grapheme.text) + .filter((s) => s !== chosen.style), + ); + + result.push({ + tokenIdx: token.offsets.start, + charIdx: chosen.grapheme.tokenStartOffset, + grapheme: chosen.grapheme.text, + styleName: String(chosen.style), + }); + } + + result.sort((a, b) => a.tokenIdx - b.tokenIdx); + return result; +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/rank.ts b/packages/command-visualizer/src/vendor/allocate-hats/rank.ts new file mode 100644 index 0000000000..c6e937b9e8 --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/rank.ts @@ -0,0 +1,46 @@ +/** + * Port of `rankPreTokenizedInput` from cursorless + * packages/cursorless-engine/src/util/allocateHats/getRankedTokens.ts at SHA + * 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe, with the fake-editor plumbing + * removed: the reference cursor position is a plain argument instead of + * `activeTextEditor.selections[0].active`. The display-line map construction, + * the comparator (vendored getTokenComparator), and the rank = -sortedIndex + * convention are unchanged. + */ + +import { Position, type RankedToken, type Token } from "./common"; +import { getTokenComparator } from "./vendor/getTokenComparator"; + +export function rankTokensByProximity( + tokens: readonly Token[], + referencePosition: Position, +): RankedToken[] { + if (tokens.length === 0) { + return []; + } + + // Build a stable display-line map directly from the supplied tokens so the + // comparator sees a consistent ordering even without an editor walk. + const lines = new Set([referencePosition.line]); + for (const token of tokens) { + lines.add(token.range.start.line); + } + const sortedLines = [...lines].sort((a, b) => a - b); + const displayLineMap = new Map( + sortedLines.map((line, index) => [line, index]), + ); + + const withDisplayLine = tokens.map((token) => ({ + ...token, + displayLine: displayLineMap.get(token.range.start.line)!, + })); + + withDisplayLine.sort( + getTokenComparator( + displayLineMap.get(referencePosition.line)!, + referencePosition.character, + ), + ); + + return withDisplayLine.map((token, index) => ({ token, rank: -index })); +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/splitter.ts b/packages/command-visualizer/src/vendor/allocate-hats/splitter.ts new file mode 100644 index 0000000000..fc62f972ea --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/splitter.ts @@ -0,0 +1,96 @@ +/** + * Standalone grapheme splitter — lifted from proseStandalone.ts in the + * cursorless fork (SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe), which in + * turn mirrors the canonical splitter at + * packages/cursorless-engine/src/tokenGraphemeSplitter/tokenGraphemeSplitter.ts:74 + * with a STATIC config (no ide().configuration): no lettersToPreserve / + * symbolsToPreserve user overrides. + */ + +import type { Grapheme, GraphemeSplitter } from "./common/types"; + +/** + * Inline deburr — strips combining diacritics after NFC normalization. + * Replaces lodash.deburr to keep the bundle free of lodash (whose CommonJS + * module blows QuickJS's call stack). + */ +export function deburr(str: string): string { + return ( + str + .normalize("NFC") + // eslint-disable-next-line no-misleading-character-class + .replace(/[\u0300-\u036f\u1dc0-\u1dff\u20d0-\u20ff\ufe20-\ufe2f]/g, "") + ); +} + +const KNOWN_SYMBOLS = [ + "!", + "#", + "$", + "%", + "&", + "'", + "(", + ")", + "*", + "+", + ",", + "-", + ".", + "/", + ":", + ";", + "<", + "=", + ">", + "?", + "@", + "[", + "\\", + "]", + "^", + "_", + "`", + "{", + "|", + "}", + "~", + "£", + '"', +]; +const UNKNOWN_GRAPHEME = "[unk]"; +const KNOWN_GRAPHEME_MATCHER = new RegExp( + `^([a-zA-Z0-9]|${KNOWN_SYMBOLS.map((s) => + s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), + ).join("|")})$`, + "u", +); + +// Letters + combining marks OR any single Unicode Number / Punctuation / Symbol. +const GRAPHEME_SPLIT_SOURCE = String.raw`\p{L}\p{M}*|[\p{N}\p{P}\p{S}]`; +const GRAPHEME_SPLIT_FLAGS = "gu"; + +export class StandaloneGraphemeSplitter implements GraphemeSplitter { + getTokenGraphemes(tokenText: string): Grapheme[] { + const re = new RegExp(GRAPHEME_SPLIT_SOURCE, GRAPHEME_SPLIT_FLAGS); + const results: Grapheme[] = []; + let match: RegExpExecArray | null; + while ((match = re.exec(tokenText)) != null) { + results.push({ + text: this.normalizeGrapheme(match[0]), + tokenStartOffset: match.index, + tokenEndOffset: match.index + match[0].length, + }); + } + return results; + } + + normalizeGrapheme(raw: string): string { + let val = raw.normalize("NFC").toLowerCase(); + val = deburr(val); + if (!KNOWN_GRAPHEME_MATCHER.test(val)) { + val = UNKNOWN_GRAPHEME; + } + return val; + } +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/vendor/HatMetrics.ts b/packages/command-visualizer/src/vendor/allocate-hats/vendor/HatMetrics.ts new file mode 100644 index 0000000000..0ec2dffd88 --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/vendor/HatMetrics.ts @@ -0,0 +1,116 @@ +/** + * Vendored from cursorless packages/cursorless-engine/src/util/allocateHats/HatMetrics.ts + * at SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe. + * Edits are IMPORT REWRITES ONLY (applied by scripts/vendor.sh): + * - "@cursorless/common" -> "../common" barrel + * - HatCandidate: "./allocateHats" -> "../common/types" + */ + +import { CompositeKeyMap, HatStability, TokenHat } from "../common"; + +// Inline replacements for lodash memoize and min — avoids bundling the full +// lodash CommonJS module (5,500 lines) which blows QuickJS's call stack. +function memoize( + fn: (arg: T) => R, +): (arg: T) => R { + const cache = new Map(); + return (arg: T) => { + if (!cache.has(arg)) { + cache.set(arg, fn(arg)); + } + return cache.get(arg)!; + }; +} +function min(arr: number[]): number | undefined { + return arr.length ? Math.min(...arr) : undefined; +} +import { HatCandidate } from "../common/types"; + +/** + * A function that takes a hat candidate and returns a number representing its + * quality; greater is always better + */ +export type HatMetric = (hat: HatCandidate) => number; + +/** + * @returns A metric that just returns the negative penalty of the given hat + * candidate + */ +export const negativePenalty: HatMetric = ({ penalty }) => -penalty; + +/** + * @param hatOldTokenRanks A map from a hat candidate (grapheme+style combination) to the score of the + * token that used the given hat in the previous hat allocation. + * @returns A metric that returns Infinity if the hat candidate is not in use in + * the old allocation, otherwise the rank of the token from the old allocation + * that we'd steal the hat from + */ +export function hatOldTokenRank( + hatOldTokenRanks: CompositeKeyMap< + { grapheme: string; hatStyle: string }, + number + >, +): HatMetric { + return ({ grapheme: { text: grapheme }, style }) => { + const hatOldTokenRank = hatOldTokenRanks.get({ + grapheme, + hatStyle: style, + }); + + return hatOldTokenRank == null ? Infinity : -hatOldTokenRank; + }; +} + +/** + * @param tokenRank The rank of the current token, so that we don't consider + * higher ranked tokens (which already have been assigned hats) + * @param graphemeTokenRanks A map from graphemes to an ordered list of the + * ranks of tokens containing the grapheme + * @returns A metric which returns the minimum token rank among lower ranked + * tokens that contain the hat's grapheme (or Infinity if the grapheme doesn't + * appear in any lower ranked tokens) + */ +export function minimumTokenRankContainingGrapheme( + tokenRank: number, + graphemeTokenRanks: { [key: string]: number[] }, +): HatMetric { + const coreMetric = memoize((graphemeText: string): number => { + return ( + min(graphemeTokenRanks[graphemeText].filter((r) => r > tokenRank)) ?? + Infinity + ); + }); + return ({ grapheme: { text } }) => coreMetric(text); +} + +/** + * @param oldTokenHat The old hat for the token to which we're assigning a hat + * @returns A metric which returns 1 if the hat candidate is the one the token + * currently has, 0 otherwise + */ +export function isOldTokenHat(oldTokenHat: TokenHat | undefined): HatMetric { + return (hat) => + hat.grapheme.text === oldTokenHat?.grapheme && + hat.style === oldTokenHat?.hatStyle + ? 1 + : 0; +} + +/** + * Given a {@link HatStability}, returns its equivalence class function. + * + * @param HatStability The user setting for which we need to return + * equivalence class + * @returns A hat metric that will collapse hats that are not different enough + * to justify keeping / stealing + */ +export function penaltyEquivalenceClass(hatStability: HatStability): HatMetric { + switch (hatStability) { + case HatStability.greedy: + return ({ penalty }) => -penalty; + case HatStability.balanced: + return ({ penalty }) => -(penalty < 2 ? 0 : 1); + case HatStability.stable: + return (_) => 0; + } +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/vendor/chooseTokenHat.ts b/packages/command-visualizer/src/vendor/allocate-hats/vendor/chooseTokenHat.ts new file mode 100644 index 0000000000..b14af67b81 --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/vendor/chooseTokenHat.ts @@ -0,0 +1,86 @@ +/** + * Vendored from cursorless packages/cursorless-engine/src/util/allocateHats/chooseTokenHat.ts + * at SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe. + * Edits are IMPORT REWRITES ONLY (applied by scripts/vendor.sh): + * - "@cursorless/common" -> "../common" barrel + * - HatCandidate: "./allocateHats" -> "../common/types" + */ + +import { HatStability, TokenHat } from "../common"; +import { HatCandidate } from "../common/types"; +import { RankingContext } from "./getHatRankingContext"; +import { + hatOldTokenRank, + isOldTokenHat, + minimumTokenRankContainingGrapheme, + negativePenalty, + penaltyEquivalenceClass, +} from "./HatMetrics"; +import { maxByFirstDiffering } from "./maxByFirstDiffering"; + +/** + * Selects a hat for a given token from amongst {@link candidates}, trading off + * hat quality and hat stability + * + * **IMPORTANT**: This function assumes that all tokens with a lower rank than + * the given token have already been assigned hats. + * + * We proceed as follows: + * + * 1. Decide whether to keep our own hat, if a higher ranked token hasn't + * already taken it + * 2. Decide whether to steal a hat from a lower ranked token + * + * See [hat assignment](/docs/user/hatAssignment) for more info. + * + * FIXME: Could be improved by ignoring subsequent tokens that also contain + * another character that can be used with lower color. To compute that, look at + * all the other characters in the given subsequent token, look at their current + * color, and add the number of times it appears in between the current token + * and the given subsequent token. + * + * Here is an example where the existing algorithm falls down: "ab ax b". It + * will put a hat on `b` for token `ab` because `b` appears two tokens away + * whereas `a` appears in the next token. However, if it had chosen `a`, then + * it could use `x` for `ax`, leaving `b` free for the final `b` token. + * + * @param context Lookup tables with information about which graphemes / hats + * other tokens have + * @param hatStability The user settings that determine when to keep / steal + * hats + * @param tokenRank The rank of the token for whom we're picking a hat + * @param oldTokenHat The hat that was on the token before (or `undefined` if it + * didn't have one) + * @param candidates The set of candidate hats under consideration (includes + * both hat style and grapheme) + * @returns The chosen hat, or `undefined` if {@link candidates} was empty + */ +export function chooseTokenHat( + { hatOldTokenRanks, graphemeTokenRanks }: RankingContext, + hatStability: HatStability, + tokenRank: number, + oldTokenHat: TokenHat | undefined, + candidates: HatCandidate[], +): HatCandidate | undefined { + // We narrow down the candidates by a series of criteria until there is only + // one left + return maxByFirstDiffering(candidates, [ + // 1. Discard any hats that are sufficiently worse than the best hat that we + // wouldn't use them even if they were our old hat + penaltyEquivalenceClass(hatStability), + + // 2. Use our old hat if it's still in the running + isOldTokenHat(oldTokenHat), + + // 3. Use a free hat if possible; if not, steal the hat of the token with + // lowest rank + hatOldTokenRank(hatOldTokenRanks), + + // 4. Narrow to the hats with the lowest penalty + negativePenalty, + + // 5. Prefer hats that sit on a grapheme that doesn't appear in any highly + // ranked token + minimumTokenRankContainingGrapheme(tokenRank, graphemeTokenRanks), + ])!; +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/vendor/getHatRankingContext.ts b/packages/command-visualizer/src/vendor/allocate-hats/vendor/getHatRankingContext.ts new file mode 100644 index 0000000000..424310fe1d --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/vendor/getHatRankingContext.ts @@ -0,0 +1,81 @@ +/** + * Vendored from cursorless packages/cursorless-engine/src/util/allocateHats/getHatRankingContext.ts + * at SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe. + * Edits are IMPORT REWRITES ONLY (applied by scripts/vendor.sh): + * - "@cursorless/common" -> "../common" barrel + * - TokenGraphemeSplitter (concrete class) -> GraphemeSplitter (structural + * interface in ../common/types with the same getTokenGraphemes surface) + * - RankedToken: "./getRankedTokens" -> "../common/types" + */ + +import { + CompositeKeyMap, + HatStyleName, + Token, + TokenHat, +} from "../common"; +import { GraphemeSplitter } from "../common/types"; +import { RankedToken } from "../common/types"; + +export interface RankingContext { + /** + * Maps from a hat candidate (grapheme+style combination) to the score of the + * token that used the given hat in the previous hat allocation. + */ + hatOldTokenRanks: CompositeKeyMap< + { + grapheme: string; + hatStyle: HatStyleName; + }, + number + >; + + /** + * Maps from a grapheme to the list of ranks of the tokens in which the + * given grapheme appears. + */ + graphemeTokenRanks: { + [key: string]: number[]; + }; +} + +export function getHatRankingContext( + tokens: RankedToken[], + oldTokenHatMap: CompositeKeyMap, + tokenGraphemeSplitter: GraphemeSplitter, +): RankingContext { + const graphemeTokenRanks: { + [key: string]: number[]; + } = {}; + + const hatOldTokenRanks = new CompositeKeyMap< + { grapheme: string; hatStyle: HatStyleName }, + number + >(({ grapheme, hatStyle }) => [grapheme, hatStyle]); + + tokens.forEach(({ token, rank }) => { + const existingTokenHat = oldTokenHatMap.get(token); + if (existingTokenHat != null) { + hatOldTokenRanks.set(existingTokenHat, rank); + } + tokenGraphemeSplitter + .getTokenGraphemes(token.text) + .forEach(({ text: graphemeText }) => { + let tokenRanksForGrapheme: number[]; + + if (graphemeText in graphemeTokenRanks) { + tokenRanksForGrapheme = graphemeTokenRanks[graphemeText]; + } else { + tokenRanksForGrapheme = []; + graphemeTokenRanks[graphemeText] = tokenRanksForGrapheme; + } + + tokenRanksForGrapheme.push(rank); + }); + }); + + return { + hatOldTokenRanks, + graphemeTokenRanks, + }; +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/vendor/getTokenComparator.ts b/packages/command-visualizer/src/vendor/allocate-hats/vendor/getTokenComparator.ts new file mode 100644 index 0000000000..1aabf0364e --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/vendor/getTokenComparator.ts @@ -0,0 +1,46 @@ +/** + * Vendored from cursorless packages/cursorless-engine/src/util/allocateHats/getTokenComparator.ts + * at SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe. + * Edits are IMPORT REWRITES ONLY (applied by scripts/vendor.sh): + * - "@cursorless/common" -> "../common" barrel + */ + +import { Token } from "../common"; + +interface TokenWithDisplayLine extends Token { + displayLine: number; +} + +/** + * Gets a comparison function that can be used to sort tokens based on their + * distance from the current cursor in terms of display lines. + * @param selectionDisplayLine The display line of the cursor location + * @param selectionCharacterIndex The character index of current cursor within line + */ +export function getTokenComparator( + selectionDisplayLine: number, + selectionCharacterIndex: number, +): (a: TokenWithDisplayLine, b: TokenWithDisplayLine) => number { + return (token1, token2) => { + const token1LineDiff = Math.abs(token1.displayLine - selectionDisplayLine); + const token2LineDiff = Math.abs(token2.displayLine - selectionDisplayLine); + + if (token1LineDiff < token2LineDiff) { + return -1; + } + + if (token1LineDiff > token2LineDiff) { + return 1; + } + + const token1CharacterDiff = Math.abs( + token1.range.start.character - selectionCharacterIndex, + ); + + const token2CharacterDiff = Math.abs( + token2.range.start.character - selectionCharacterIndex, + ); + + return token1CharacterDiff - token2CharacterDiff; + }; +} diff --git a/packages/command-visualizer/src/vendor/allocate-hats/vendor/maxByFirstDiffering.ts b/packages/command-visualizer/src/vendor/allocate-hats/vendor/maxByFirstDiffering.ts new file mode 100644 index 0000000000..a42a4bacbb --- /dev/null +++ b/packages/command-visualizer/src/vendor/allocate-hats/vendor/maxByFirstDiffering.ts @@ -0,0 +1,75 @@ +/** + * Vendored from cursorless packages/cursorless-engine/src/util/allocateHats/maxByFirstDiffering.ts + * at SHA 42452eba521bb9cccbb3e04a2cd9e9afcf6cbffe. + * Edits are IMPORT REWRITES ONLY (applied by scripts/vendor.sh): + * (none — file has no imports) + */ + +/** + * Given an array of items and a list of functions that return a number for each + * item, return the item that has the maximum value according to the + * mathematical lexicographic order. Ie, first narrows down to items that share + * the maximum value for the first function, then of those remaining, narrows + * down to those that have the maximum value for the second, etc. Whenever + * there is only 1 item remaining, that item is returned. If the list is empty, + * undefined is returned. + * + * @example maxByFirstDiffering([{a: 1, b: 1}, {a: 1, b: 2}], [({a}) => a, ({b}) => + * b]) === {a: 1, b: 2} + * + * @param arr The array to find the max value of + * @param fns A list of functions that return a number for each item in the + * array + * @returns The item in the array that has the maximum value, or undefined if + * array is empty or all items are removed + */ +export function maxByFirstDiffering( + arr: T[], + fns: ((item: T) => number)[], +): T | undefined { + if (arr.length === 0) { + return undefined; + } + let remainingValues = arr; + for (const fn of fns) { + if (remainingValues.length === 1) { + return remainingValues[0]; + } + remainingValues = maxByAllowingTies(remainingValues, fn); + } + return remainingValues[0]; +} + +/** + * Given an array of items and a function that returns a number for each item, + * return all items that share the maximum value according to that function. + * @param arr The array to find the max values of + * @param fn A function that returns a number for each item in the array + * @returns All items in the array that share the maximum value + **/ +export function maxByAllowingTies(arr: T[], fn: (item: T) => number): T[] { + // This is equivalent to, but faster than: + // + // const max = Math.max(...arr.map(fn)); + // return arr.filter((item) => fn(item) === max); + // + // It does only a single pass through the array, and allocates no + // intermediate arrays (in the common case). + + // Accumulate all items with the single highest value, + // resetting whenever we find a new highest value. + let best: number = -Infinity; + const keep: T[] = []; + for (const item of arr) { + const value = fn(item); + if (value < best) { + continue; + } + if (value > best) { + best = value; + keep.length = 0; + } + keep.push(item); + } + return keep; +} From 196d0da09128f909f07237ab3d986e15aedc19f9 Mon Sep 17 00:00:00 2001 From: Trillium Smith Date: Tue, 7 Jul 2026 16:16:55 -0700 Subject: [PATCH 02/53] feat(command-visualizer): animated SVG command-cascade renderer New @cursorless/command-visualizer package: pure function from a recorded test fixture (YAML) to a self-contained, -embeddable animated SVG of a cursorless command. Zero runtime JS in the output; one CSS --dur timeline. Fixtures parsed with js-yaml; hat allocation via the vendored algorithm; FlashStyle/color/shape data mirrored from cursorless with provenance notes. --- packages/command-visualizer/package.json | 30 ++ packages/command-visualizer/src/chain.ts | 135 +++++ packages/command-visualizer/src/columns.ts | 180 +++++++ .../command-visualizer/src/css-cascade.ts | 272 ++++++++++ packages/command-visualizer/src/css.ts | 196 +++++++ .../command-visualizer/src/data/colors.ts | 67 +++ .../src/data/decorations.ts | 79 +++ .../command-visualizer/src/data/shapes.ts | 108 ++++ .../command-visualizer/src/fixture-extract.ts | 211 ++++++++ .../command-visualizer/src/fixture-root.ts | 82 +++ .../command-visualizer/src/fixture-yaml.ts | 42 ++ .../command-visualizer/src/frame-state.ts | 68 +++ .../command-visualizer/src/hat-allocator.ts | 175 ++++++ packages/command-visualizer/src/index.ts | 20 + packages/command-visualizer/src/jumbotron.ts | 503 ++++++++++++++++++ packages/command-visualizer/src/overlays.ts | 152 ++++++ packages/command-visualizer/src/pipeline.ts | 319 +++++++++++ .../src/serialize-cascade.ts | 219 ++++++++ packages/command-visualizer/src/serialize.ts | 160 ++++++ packages/command-visualizer/src/svg-wrap.ts | 153 ++++++ packages/command-visualizer/src/symbols.ts | 25 + packages/command-visualizer/src/timeline.ts | 66 +++ packages/command-visualizer/src/tokenize.ts | 96 ++++ .../command-visualizer/src/word-segments.ts | 73 +++ packages/command-visualizer/tsconfig.json | 7 + 25 files changed, 3438 insertions(+) create mode 100644 packages/command-visualizer/package.json create mode 100644 packages/command-visualizer/src/chain.ts create mode 100644 packages/command-visualizer/src/columns.ts create mode 100644 packages/command-visualizer/src/css-cascade.ts create mode 100644 packages/command-visualizer/src/css.ts create mode 100644 packages/command-visualizer/src/data/colors.ts create mode 100644 packages/command-visualizer/src/data/decorations.ts create mode 100644 packages/command-visualizer/src/data/shapes.ts create mode 100644 packages/command-visualizer/src/fixture-extract.ts create mode 100644 packages/command-visualizer/src/fixture-root.ts create mode 100644 packages/command-visualizer/src/fixture-yaml.ts create mode 100644 packages/command-visualizer/src/frame-state.ts create mode 100644 packages/command-visualizer/src/hat-allocator.ts create mode 100644 packages/command-visualizer/src/index.ts create mode 100644 packages/command-visualizer/src/jumbotron.ts create mode 100644 packages/command-visualizer/src/overlays.ts create mode 100644 packages/command-visualizer/src/pipeline.ts create mode 100644 packages/command-visualizer/src/serialize-cascade.ts create mode 100644 packages/command-visualizer/src/serialize.ts create mode 100644 packages/command-visualizer/src/svg-wrap.ts create mode 100644 packages/command-visualizer/src/symbols.ts create mode 100644 packages/command-visualizer/src/timeline.ts create mode 100644 packages/command-visualizer/src/tokenize.ts create mode 100644 packages/command-visualizer/src/word-segments.ts create mode 100644 packages/command-visualizer/tsconfig.json diff --git a/packages/command-visualizer/package.json b/packages/command-visualizer/package.json new file mode 100644 index 0000000000..b31822f858 --- /dev/null +++ b/packages/command-visualizer/package.json @@ -0,0 +1,30 @@ +{ + "name": "@cursorless/command-visualizer", + "version": "0.0.1", + "type": "module", + "description": "Visualizes cursorless commands as animated SVGs: the spoken command entering, flashes firing, the edit landing, hats reallocating — rendered from recorded test fixtures, embeddable via plain .", + "main": "./out/index.js", + "types": "./out/index.d.ts", + "scripts": { + "compile:tsc": "tsc", + "compile:esbuild": "esbuild ./src/index.ts --sourcemap --format=esm --bundle --packages=external --outfile=./out/index.js", + "compile": "pnpm compile:tsc && pnpm compile:esbuild", + "clean": "rm -rf ./out tsconfig.tsbuildinfo ./dist ./build" + }, + "keywords": [ + "cursorless", + "visualization", + "svg", + "fixtures" + ], + "author": "", + "license": "MIT", + "dependencies": { + "@cursorless/lib-common": "workspace:*", + "@cursorless/lib-node-common": "workspace:*", + "js-yaml": "^5.2.1" + }, + "devDependencies": { + "@types/js-yaml": "^4.0.9" + } +} diff --git a/packages/command-visualizer/src/chain.ts b/packages/command-visualizer/src/chain.ts new file mode 100644 index 0000000000..2ce8de0c23 --- /dev/null +++ b/packages/command-visualizer/src/chain.ts @@ -0,0 +1,135 @@ +// Multi-step chain semantics (Trillium's model, 2026-07-07): +// +// every entry: initialState / referenceFlashes[] / finalState +// chained: finalState.i and initialState.{i+1} MUST agree, and the +// hats from initialState.{i+1} flow BACKWARD onto that +// boundary — in cursorless the transition is ONE animated +// change, not two. finalState.i is never rendered with its +// own hats; the merged frame IS initialState.{i+1}. +// +// Frame list for an n-step chain: [before_0, before_1, ..., before_{n-1}, after_{n-1}] +// where each merged frame before_{i+1}: +// - renders step {i+1}'s initialState (hats = its marks + real allocation), +// - inherits step i's AFTER-riding decorations (justAdded flashes, thatMark +// references) — they light at the merged frame's slot START, +// - keeps its own BEFORE-riding decorations (pendingDelete) — they light at +// the merged frame's slot END, +// - carries step i's produced clipboard when step {i+1} has none. + +import type { CascadeState, Frame } from "./frame-state"; + +/** + * Pre-gif / post-gif bumpers: a 500ms PRE frame (initial state, before step 0 + * begins) and a 500ms RESET frame (re-shows the initial state so the infinite + * loop wraps onto identical pixels). Applied to every animated cascade. + */ +export function withBumpers(state: CascadeState): CascadeState { + if (state.frames.length < 2) { + return state; + } + const first = state.frames[0]; + const clone = (flags: Partial): Frame => ({ + role: "after", + lines: first.lines, + cursors: first.cursors, + selections: first.selections, + decorations: [], + clipboard: first.clipboard, + ...flags, + }); + return { + ...state, + frames: [ + clone({ role: "before", pre: true }), + ...state.frames, + clone({ reset: true }), + ], + }; +} + +export class ChainContinuityError extends Error { + constructor( + public stepIndex: number, + message: string, + ) { + super(message); + } +} + +/** Reconstruct a frame's document text from its render tokens (GATE 0 exact). */ +export function frameDocText(frame: Frame): string { + return frame.lines + .map((line) => line.tokens.map((t) => t.text).join("")) + .join("\n"); +} + +/** + * Merge per-step cascades ([before, after] each) into one chain cascade. + * Throws ChainContinuityError when finalState.i !== initialState.{i+1}. + */ +export function chainCascades( + states: CascadeState[], + fixtureLabel: string, +): CascadeState { + if (states.length === 1) { + return states[0]; + } + + const frames: Frame[] = []; + for (let i = 0; i < states.length; i++) { + const step = states[i]; + const before = step.frames.find((f) => f.role === "before"); + const after = step.frames.find((f) => f.role === "after"); + if (!before) { + throw new ChainContinuityError(i, `step ${i} has no before frame`); + } + + if (i > 0) { + const prevAfter = states[i - 1].frames.find((f) => f.role === "after"); + if (!prevAfter) { + throw new ChainContinuityError( + i - 1, + `step ${i - 1} has no finalState to chain from`, + ); + } + const prevDoc = frameDocText(prevAfter); + const thisDoc = frameDocText(before); + if (prevDoc !== thisDoc) { + throw new ChainContinuityError( + i, + `chain discontinuity between steps ${i - 1} and ${i}: ` + + `finalState.${i - 1} and initialState.${i} must agree. ` + + `finalState.${i - 1}=${JSON.stringify(prevDoc).slice(0, 80)} ` + + `initialState.${i}=${JSON.stringify(thisDoc).slice(0, 80)}`, + ); + } + // Backward hat flow: the merged frame IS this step's before (its + // hats). Step i-1's AFTER-riding decorations + clipboard transfer + // onto it; prevAfter itself is never rendered. + before.decorations = [...prevAfter.decorations, ...before.decorations]; + if (before.clipboard == null && prevAfter.clipboard != null) { + before.clipboard = prevAfter.clipboard; + } + } + frames.push(before); + + // The step's DURING phase (pre-edit flash window) rides between its + // initial and the next merged frame. + const during = step.frames.find((f) => f.role === "during"); + if (during) { + frames.push(during); + } + + if (i === states.length - 1) { + if (after) { + frames.push(after); + } + } + } + + return { + ...states[0], + meta: { fixture: fixtureLabel }, + frames, + }; +} diff --git a/packages/command-visualizer/src/columns.ts b/packages/command-visualizer/src/columns.ts new file mode 100644 index 0000000000..356a3dc96a --- /dev/null +++ b/packages/command-visualizer/src/columns.ts @@ -0,0 +1,180 @@ +// Column model — SPEC §2 (resolves D7). +// Converts each line's UTF-16 token stream into an ordered list of VISUAL columns. +// Build-time only; no render-time logic. +// +// - Graphemes per cursorless GRAPHEME_SPLIT_REGEX (SPEC §2.1). +// - Tab → next tabSize stop (SPEC §2.2). +// - East-Asian Wide/Fullwidth glyph = 2 columns (SPEC §2.3). + +import type { HatColor } from "./data/colors"; +import type { HatShape } from "./data/shapes"; + +// Cursorless's grapheme splitter regex +// (tokenGraphemeSplitter.ts:73). A base letter + its combining marks is ONE +// grapheme; numbers/punct/symbols are each their own grapheme. +export const GRAPHEME_SPLIT_REGEX = /\p{L}\p{M}*|[\p{N}\p{P}\p{S}]/gu; + +export interface InputHat { + color: HatColor; + shape: HatShape; + /** grapheme index within the token; default 0 */ + anchorGrapheme?: number; +} + +export interface Token { + text: string; + range: { start: number; end: number }; // UTF-16 offsets within the line + hat?: InputHat | null; +} + +export interface Line { + tokens: Token[]; +} + +/** One emitted visual column (or multi-column cell for a wide glyph / tab). */ +export interface Column { + /** display text for the cell (a grapheme, or "" for blank tab filler) */ + text: string; + /** visual column index (first column of this cell) */ + col: number; + /** number of columns this cell spans (1, 2, or a tab advance) */ + width: number; + /** UTF-16 char index of this cell's first code unit within the line */ + charIndex: number; + isAnchor: boolean; + hatColor?: HatColor; + hatShape?: HatShape; +} + +// East_Asian_Width Wide (W) + Fullwidth (F) ranges. Covers CJK, Hangul, +// fullwidth forms, kana, common emoji presentation. Sufficient for the +// column-model torture test; extend the table if a fixture needs more. +const WIDE_RANGES: ReadonlyArray = [ + [0x1100, 0x115f], // Hangul Jamo + [0x2329, 0x232a], // angle brackets + [0x2e80, 0x303e], // CJK radicals .. symbols + [0x3041, 0x33ff], // Hiragana, Katakana, CJK symbols/punct + [0x3400, 0x4dbf], // CJK Ext A + [0x4e00, 0x9fff], // CJK Unified + [0xa000, 0xa4cf], // Yi + [0xac00, 0xd7a3], // Hangul Syllables + [0xf900, 0xfaff], // CJK Compatibility Ideographs + [0xfe10, 0xfe19], // vertical forms + [0xfe30, 0xfe6f], // CJK compat / small forms + [0xff00, 0xff60], // Fullwidth Forms + [0xffe0, 0xffe6], // Fullwidth signs + [0x1f300, 0x1f64f], // emoji + emoticons + [0x1f900, 0x1f9ff], // supplemental symbols/pictographs + [0x20000, 0x3fffd], // CJK Ext B+ (SIP/TIP) +]; + +function isWideCodePoint(cp: number): boolean { + for (const [lo, hi] of WIDE_RANGES) { + if (cp >= lo && cp <= hi) { + return true; + } + if (cp < lo) { + break; + } + } + return false; +} + +/** Display width of a grapheme cluster: 2 if its base code point is EAW W/F, else 1. */ +export function graphemeWidth(grapheme: string): number { + const cp = grapheme.codePointAt(0); + if (cp === undefined) { + return 1; + } + return isWideCodePoint(cp) ? 2 : 1; +} + +interface GraphemeUnit { + text: string; + /** char index within the token text */ + offset: number; +} + +/** Split a token's text into grapheme clusters, tracking each one's char offset. */ +export function splitGraphemes(text: string): GraphemeUnit[] { + const out: GraphemeUnit[] = []; + const re = new RegExp(GRAPHEME_SPLIT_REGEX); + let m: RegExpExecArray | null; + let lastIndex = 0; + while ((m = re.exec(text)) !== null) { + // Emit any chars the regex skipped (e.g. whitespace) as single cells so + // every column of every line is owned by exactly one cell. + if (m.index > lastIndex) { + for (let i = lastIndex; i < m.index; i++) { + out.push({ text: text[i], offset: i }); + } + } + out.push({ text: m[0], offset: m.index }); + lastIndex = m.index + m[0].length; + if (m[0].length === 0) { + re.lastIndex++; + } // guard zero-width + } + for (let i = lastIndex; i < text.length; i++) { + out.push({ text: text[i], offset: i }); + } + return out; +} + +/** + * Expand a line's tokens into ordered visual columns (SPEC §2). + * Resolves each hat's anchor to a computed visual column (never a raw char index). + */ +export function expandColumns(line: Line, tabSize: number): Column[] { + const cols: Column[] = []; + let col = 0; + + for (const token of line.tokens) { + const graphemes = splitGraphemes(token.text); + const hat = token.hat ?? undefined; + const anchorIdx = hat?.anchorGrapheme ?? 0; + + graphemes.forEach((g, gi) => { + const charIndex = token.range.start + g.offset; + const isAnchor = !!hat && gi === anchorIdx; + + if (g.text === "\t") { + const advance = tabSize - (col % tabSize) || tabSize; + cols.push({ + text: "", + col, + width: advance, + charIndex, + isAnchor, + hatColor: isAnchor ? hat!.color : undefined, + hatShape: isAnchor ? hat!.shape : undefined, + }); + col += advance; + return; + } + + const w = graphemeWidth(g.text); + cols.push({ + text: g.text, + col, + width: w, + charIndex, + isAnchor, + hatColor: isAnchor ? hat!.color : undefined, + hatShape: isAnchor ? hat!.shape : undefined, + }); + col += w; + }); + } + + return cols; +} + +/** Total visual columns in a line. */ +export function lineWidth(cols: Column[]): number { + if (cols.length === 0) { + return 0; + } + const last = cols[cols.length - 1]; + return last.col + last.width; +} diff --git a/packages/command-visualizer/src/css-cascade.ts b/packages/command-visualizer/src/css-cascade.ts new file mode 100644 index 0000000000..812ee48f99 --- /dev/null +++ b/packages/command-visualizer/src/css-cascade.ts @@ -0,0 +1,272 @@ +// Cascade + overlay CSS — SPEC-v2 §3 (highlight bands) + §4 (stacked-frame +// opacity timeline). Generated from the single-sourced decoration hexes so the +// band colors never drift. Theme-INVARIANT (background-only translucent hexes). + +import { DECORATION_HEX, ALL_DECORATION_STYLES } from "./data/decorations"; +import { FLASH_STYLES, HIGHLIGHT_STYLES } from "./data/decorations"; +import { FLASH_PULSE_MS } from "./data/decorations"; +import type { Frame } from "./frame-state"; +import { timelineOf, type Timeline } from "./timeline"; + +// §3.2 — char-range bands on the per-char grid (one bg attr per .ch). +// The STATIC path (single-frame PNG renders, animate=false): the band is just a +// solid background-color, present for the whole frame. Used by Phase-3 PNG tests. +function charBandRules(): string { + const flash = FLASH_STYLES.map( + (s) => `.ch[data-flash="${s}"] { background-color: ${DECORATION_HEX[s]}; }`, + ); + const hl = HIGHLIGHT_STYLES.map( + (s) => `.ch[data-hl="${s}"] { background-color: ${DECORATION_HEX[s]}; }`, + ); + return [...flash, ...hl].join("\n"); +} + +// §4.2 DURING beats — flash TIMING. A flash is a TRANSIENT beat *within* one +// frame's timeline slot, not a static band. Two opposite directions: +// +// DELETE (R2-i, pendingDelete) rides the BEFORE frame (frame 0, slot [0, 1/N]): +// plain (band transparent) → hold plain → red band fades IN +// then the frame's opacity snap crosses to the after-frame (text gone). The band +// fades in LATE in the slot so the before-doc reads plain first, then highlights +// the doomed span just before it vanishes. +// +// ADD (R2-ii, justAdded) rides the AFTER frame (frame N-1, slot [(N-1)/N, 1]): +// green band PRESENT → hold green → green band fades OUT → plain (text stays) +// the insert just happened, so the green is up-front when the after-frame appears, +// then fades out over the latter part of the slot leaving plain text behind. This +// is the mirror image of the delete beat (green out-front, fade to transparent vs. +// red back-end, fade in from transparent). +// +// Both animate ONLY background-color (text stays fully opaque the whole slot); +// CSS-only, zero view-time JS. Each flash style gets its own @keyframes scoped to +// its native frame, so other frames / styles keep the static band. +const pct = (x: number): string => x.toFixed(3); + +const DELETE_FLASH_STYLES = ["pendingDelete"] as const; +const ADD_FLASH_STYLES = ["justAdded"] as const; + +// B2/B6 FIX — the flash is a FIXED 100ms pulse, pinned to cursorless's +// `pendingEditDecorationTime` (FLASH_PULSE_MS), DECOUPLED from the readability +// state-hold cadence. +// +// The cascade timeline is `--dur = N · MS_PER_STATE` ms (serialize-cascade.ts), +// so each of the N frame slots lasts MS_PER_STATE ms. Working in keyframe-% of +// the WHOLE timeline (each @keyframes runs over var(--dur)): +// 1% of timeline = (--dur)/100 ms = (N·MS_PER_STATE)/100 ms +// ⇒ a P-ms window in % = P / (N·MS_PER_STATE) · 100 +// The flash's FULL-target held window is exactly FLASH_PULSE_MS ms → `pulsePct` +// below. A short fade ramp (FADE_FRAC of the pulse) softens each edge but the +// held-full-color span is exactly the pulse, which is what verify:flash-timing +// measures. Result: the pulse is ~100ms for ANY N (no slot scaling). +const FADE_FRAC = 0.4; // soft-edge ramp length as a fraction of the pulse window + +// Reference-class pre-edit flashes (Bring sources/destinations etc.) — they +// sequence BEFORE deletion flashes inside a DURING window (real cursorless +// fires pre-edit flashes in parallel; sequenced here by spec). +const REFERENCE_FLASH_STYLES = [ + "referenced", + "pendingModification0", + "pendingModification1", +] as const; + +function flashFadeKeyframes( + frames: readonly Frame[], + tl: Timeline, + pulseMs: number = FLASH_PULSE_MS, +): string { + const out: string[] = []; + const pct100 = (x: number) => pct(Math.max(0, Math.min(100, x * 100))); + + frames.forEach((frame, k) => { + const lo = tl.startFrac[k]; + const hi = tl.endFrac[k]; + + if (frame.role === "during") { + // DURING window: reference flashes first, deletion flashes second. + // Halves when both classes are present; the full window otherwise. + const styles = new Set(frame.decorations.map((d) => d.style)); + // The during phase keeps its full duration regardless of content, but + // the FIRST flash class present INITIATES AT THE PHASE START — the + // same instant the command pill goes active. When reference flashes + // exist they take the first half and deletion follows at the midpoint; + // with deletions only, red starts with the pill and holds to the edit. + const hasRef = REFERENCE_FLASH_STYLES.some((st) => + styles.has(st as never), + ); + const mid = (lo + hi) / 2; + const refWin: [number, number] = [lo, mid]; + const delWin: [number, number] = [hasRef ? mid : lo, hi]; + const emit = (style: string, w: [number, number], holdOn: boolean) => { + out.push( + `@keyframes flashfade-${style}-s${k} {\n` + + ` 0% { background-color: transparent; }\n` + + ` ${pct100(w[0])}% { background-color: transparent; }\n` + + ` ${pct100(w[0] + 0.0001)}% { background-color: ${DECORATION_HEX[style as keyof typeof DECORATION_HEX]}; }\n` + + ` ${pct100(w[1])}% { background-color: ${DECORATION_HEX[style as keyof typeof DECORATION_HEX]}; }\n` + + (holdOn + ? ` 100% { background-color: ${DECORATION_HEX[style as keyof typeof DECORATION_HEX]}; }\n` + : ` ${pct100(w[1] + 0.0001)}% { background-color: transparent; }\n 100% { background-color: transparent; }\n`) + + `}`, + ); + }; + for (const st of REFERENCE_FLASH_STYLES) { + if (styles.has(st)) { + emit(st, refWin, false); + } + } + if (styles.has("pendingDelete")) { + emit("pendingDelete", delWin, true); + } // held to the edit + } else { + // ADD pulse at the frame's slot start (post-edit justAdded). + const durFrac = pulseMs / tl.totalMs; + const aFullEnd = Math.min(hi, lo + durFrac); + const aFadeEnd = Math.min(hi, aFullEnd + durFrac * FADE_FRAC); + for (const st of ADD_FLASH_STYLES) { + out.push( + `@keyframes flashfade-${st}-s${k} {\n` + + ` 0% { background-color: ${DECORATION_HEX[st]}; }\n` + + ` ${pct100(lo)}% { background-color: ${DECORATION_HEX[st]}; }\n` + + ` ${pct100(aFullEnd)}% { background-color: ${DECORATION_HEX[st]}; }\n` + + ` ${pct100(aFadeEnd)}% { background-color: transparent; }\n` + + ` 100% { background-color: transparent; }\n` + + `}`, + ); + } + } + }); + + return out.join("\n"); +} + +function flashFadeRules(frames: readonly Frame[]): string { + const rules: string[] = []; + frames.forEach((frame, k) => { + if (frame.role === "during") { + const styles = new Set(frame.decorations.map((d) => d.style)); + for (const st of [...REFERENCE_FLASH_STYLES, "pendingDelete"]) { + if (!styles.has(st as never)) { + continue; + } + rules.push( + `.cl-cascade .frame[data-frame="${k}"] .ch[data-flash="${st}"] {\n` + + ` background-color: transparent;\n` + + ` animation: flashfade-${st}-s${k} var(--dur, 2s) linear 1 forwards paused;\n` + + `}`, + ); + } + } else { + for (const st of ADD_FLASH_STYLES) { + rules.push( + `.cl-cascade .frame[data-frame="${k}"] .ch[data-flash="${st}"] {\n` + + ` background-color: ${DECORATION_HEX[st]};\n` + + ` animation: flashfade-${st}-s${k} var(--dur, 2s) linear 1 forwards paused;\n` + + `}`, + ); + } + } + }); + return rules.join("\n"); +} + +// §3.4 — full-width line-range bands on .cl-line (content box, DECISIONS §7.7). +function lineBandRules(): string { + return ALL_DECORATION_STYLES.map( + (s) => + `.cl-line[data-line-flash="${s}"] { background-color: ${DECORATION_HEX[s]}; display: block; width: 100%; }`, + ).join("\n"); +} + +// §4.2 — per-frame opacity timeline. Frame k of N is opaque on [k/N,(k+1)/N). +// steps(1,end) gives a hard BEFORE→AFTER snap (no ghosty in-between). +function frameKeyframes(tl: Timeline): string { + const n = tl.startFrac.length; + if (n <= 1) { + return `@keyframes f0 { 0%{opacity:1} 100%{opacity:1} }`; + } + const out: string[] = []; + for (let k = 0; k < n; k++) { + const lo = tl.startFrac[k] * 100; + const hi = tl.endFrac[k] * 100; + if (k === 0) { + out.push( + `@keyframes f0 { 0%{opacity:1} ${pct(hi)}%{opacity:1} ${pct(hi + 0.001)}%{opacity:0} 100%{opacity:0} }`, + ); + } else if (k === n - 1) { + out.push( + `@keyframes f${k} { 0%{opacity:0} ${pct(lo)}%{opacity:0} ${pct(lo + 0.001)}%{opacity:1} 100%{opacity:1} }`, + ); + } else { + out.push( + `@keyframes f${k} { 0%{opacity:0} ${pct(lo)}%{opacity:0} ${pct(lo + 0.001)}%{opacity:1} ${pct(hi)}%{opacity:1} ${pct(hi + 0.001)}%{opacity:0} 100%{opacity:0} }`, + ); + } + } + return out.join("\n"); +} + +export function cascadeStyleSheet( + frames: readonly Frame[], + flashPulseMs: number = FLASH_PULSE_MS, +): string { + const n = Math.max(1, frames.length); + const tl = timelineOf(frames); + // Only multi-frame cascades get the timed DURING beat; n<=1 single-frame + // renders keep the pure-static band (Phase-3 PNG tests must stay green). + const animated = n >= 2; + const flashFade = animated + ? `\n/* ---- DURING beats: delete (R2-i) + insert (R2-ii) flash-timing (SPEC-v2 §4.2) ---- */\n${flashFadeKeyframes(frames, tl, flashPulseMs)}\n${flashFadeRules(frames)}\n` + : ""; + return `/* ---- decoration overlay layer (SPEC-v2 §3) ---- */ +${charBandRules()} +${lineBandRules()} +${flashFade} + +/* ---- cascade container + stacked frames (SPEC-v2 §4.1) ---- */ +.cl-cascade { + position: relative; + display: inline-block; + min-width: 100%; + box-sizing: border-box; + background: var(--editor-bg, #1e1e1e); + border-radius: 10px; + overflow: hidden; + padding: 1.2em 1em 1.6em; +} +.cl-cascade .frame { + position: absolute; + inset: 1.2em 1em 1.6em; + opacity: 0; + animation: f0 var(--dur, 2s) steps(1, end) 1 forwards paused; +} +/* the FIRST frame establishes the container height (in normal flow) */ +.cl-cascade .frame[data-frame="0"] { position: relative; inset: auto; } + +/* inherit the editor surface chrome onto the cascade box via data-theme */ +.cl-cascade[data-theme] { color: var(--editor-fg, #d4d4d4); } +.cl-cascade .cl-code { + font-family: "JetBrains Mono", "SF Mono", "Menlo", ui-monospace, monospace; + font-size: 18px; + font-variant-ligatures: none; + font-feature-settings: "liga" 0, "calt" 0; + letter-spacing: 0; + line-height: var(--code-line-height, 1.35); + white-space: pre; +} + +/* ---- per-frame opacity timeline (SPEC-v2 §4.2) ---- */ +${frameKeyframes(tl)} + +/* the static (non-animated) default: show the LAST frame so a no-capture view + still reads as the end state; capture harness seeks each slot. */ +.cl-cascade .frame { animation-play-state: paused; } +.cl-cascade .frame[data-frame="${n - 1}"] { opacity: 1; } +`; +} + +// Map cascade theme vars onto the cascade box (so .cl-cascade gets --editor-bg). +// The base styleSheet() already defines .cl-editor[data-theme]; mirror it here. +export function cascadeThemeBridge(): string { + return `.cl-cascade[data-theme="dark"] { --editor-bg:#1e1e1e; --editor-fg:#d4d4d4; --editor-sel:#264f78; --editor-caret:#aeafad; } +.cl-cascade[data-theme="light"] { --editor-bg:#ffffff; --editor-fg:#1f1f1f; --editor-sel:#add6ff; --editor-caret:#000000; }`; +} diff --git a/packages/command-visualizer/src/css.ts b/packages/command-visualizer/src/css.ts new file mode 100644 index 0000000000..1bea6c6664 --- /dev/null +++ b/packages/command-visualizer/src/css.ts @@ -0,0 +1,196 @@ +// CSS contract — SPEC §4. Generated from the single-sourced data so color + +// adjustment values never drift. No render-time JS; CSS does 100% of visual work. + +import { COLOR_MATRIX, EDITOR_CHROME, HAT_COLORS } from "./data/colors"; +import { HAT_SHAPES, SHAPE_ADJUSTMENTS } from "./data/shapes"; + +function shapeAdjustmentRules(): string { + const lines: string[] = [ + `.hat { --shape-size-adj: 0; --shape-voffset: 0em; }`, + ]; + for (const shape of HAT_SHAPES) { + const adj = SHAPE_ADJUSTMENTS[shape]; + const parts: string[] = []; + if (adj.sizeAdjustment !== undefined) { + // percent → fraction + parts.push(`--shape-size-adj: ${adj.sizeAdjustment / 100};`); + } + if (adj.verticalOffset !== undefined) { + // percent → em/100 + parts.push(`--shape-voffset: ${adj.verticalOffset / 100}em;`); + } + if (parts.length) { + lines.push( + `.ch--anchor[data-hat-shape="${shape}"] .hat { ${parts.join(" ")} }`, + ); + } + } + return lines.join("\n"); +} + +function colorVarRules(): string { + return HAT_COLORS.map( + (c) => + `.ch--anchor[data-hat-color="${c}"] .hat { --hat-color: var(--c-${c}); }`, + ).join("\n"); +} + +function themeVars(theme: "dark" | "light"): string { + const cm = COLOR_MATRIX[theme]; + const ch = EDITOR_CHROME[theme]; + const colorVars = HAT_COLORS.map((c) => ` --c-${c}: ${cm[c]};`).join("\n"); + return ( + `.cl-editor[data-theme="${theme}"] {\n${colorVars}\n` + + ` --editor-bg: ${ch.bg}; --editor-fg: ${ch.fg};` + + ` --editor-sel: ${ch.sel}; --editor-caret: ${ch.caret};\n}` + ); +} + +export function styleSheet(): string { + return `/* Cursorless state → static CSS. SPEC §4. Generated; do not hand-edit. */ + +:root { + --hat-height: 0.36em; /* DEFAULT_HAT_HEIGHT_EM */ + --hat-base-voffset: 0.05em; /* DEFAULT_VERTICAL_OFFSET_EM */ + --user-size-adj: 0; /* cursorless.hatSizeAdjustment as fraction */ + --user-voffset: 0em; + /* Real VS Code default line-height resolves to ~1.35× for the editor font + (measured from screenshots/oracle/REAL-main-demo-frame1.png: 24px pitch + over ~17.8px glyphs). Single-sourced here so the hat anchor can stay + glyph-relative regardless of the chosen value. */ + --code-line-height: 1.35; +} + +/* ---- per-shape adjustments (shapeAdjustments.ts, SPEC §4.2) ---- */ +${shapeAdjustmentRules()} + +/* ---- color tint vars (SPEC §4.4) ---- */ +${colorVarRules()} + +${themeVars("dark")} +${themeVars("light")} + +/* ---- editor surface ---- */ +@keyframes caretblink { + 0%, 55% { opacity: 1; } + 56%, 100% { opacity: 0; } +} +.caret { animation: caretblink 1.06s steps(1, end) infinite paused; } + +.cl-editor { + background: var(--editor-bg); + color: var(--editor-fg); + border-radius: 10px; + overflow: hidden; + padding: 1.2em 1em 1.6em; + display: inline-block; + min-width: 100%; + box-sizing: border-box; +} +.cl-code { + font-family: "JetBrains Mono", "SF Mono", "Menlo", ui-monospace, monospace; + font-size: 18px; + font-variant-ligatures: none; /* D8: 1 glyph = its column(s) */ + font-feature-settings: "liga" 0, "calt" 0; + letter-spacing: 0; + line-height: var(--code-line-height); + white-space: pre; +} +.cl-line { display: block; min-height: calc(var(--code-line-height) * 1em); } + +/* The shared symbol-sheet (symbols.ts) is a 0×0 def holder, but an inline + still generates a one-line-tall INLINE LINE BOX. As the first child of + .cl-cascade — ahead of the in-flow position:relative frame 0 — that phantom + line box shoved frame 0's content down by 1em (~18px) while the position:absolute + later frames pinned to the padding box and ignored it, so the whole text block + jittered 1em vertically on every frame snap. display:block kills the inline line + box (0-height block contributes nothing), aligning frame 0 with the rest. */ +svg.cl-defs { display: block; } + +/* ---- per-char column grid (D8) ---- */ +.ch { + display: inline-block; + position: relative; /* hat's offset parent — no long-line drift */ + width: 1ch; + text-align: center; +} +.ch[data-col-span="2"] { width: 2ch; } +.ch[data-col-span="3"] { width: 3ch; } +.ch[data-col-span="4"] { width: 4ch; } +.ch[data-col-span="5"] { width: 5ch; } +.ch[data-col-span="6"] { width: 6ch; } +.ch[data-col-span="7"] { width: 7ch; } +.ch[data-col-span="8"] { width: 8ch; } + +/* ---- hat (SPEC §4.3): tint via color + currentColor (D4) ---- */ +.hat { + position: absolute; + left: 50%; + height: calc(var(--hat-height) * (1 + var(--user-size-adj) + var(--shape-size-adj))); + width: calc(var(--hat-height) * (1 + var(--user-size-adj) + var(--shape-size-adj)) * 12 / 9); + transform: translateX(-50%); + /* Anchor the hat to the GLYPH TOP, not the line-box bottom, so it hugs the + character cap regardless of line-height (cursorless positions the hat from + the glyph: VscodeHatRenderer.ts:67, hatVOffsetPx = vOffsetEm*fontSize, + glyph-relative). .ch is inline-block so its box height == line-height; + the glyph content box (1em) is centered, leaving (line-height - 1em)/2 of + half-leading above it. Add that half-leading back to the 1em baseline-stack + so the hat sits just above the cap and never drifts into the inter-line gap + when line-height changes. + + The final term mirrors cursorless's + hatVerticalOffsetPx = (0.05 + voffsetEm)*fontSize - hatHeightPx/2 + (VscodeHatRenderer.ts:222-241). Cursorless lifts the hat bottom by half the + RENDERED hat height, so taller shapes sink lower relative to the glyph top. + Our eye-tuned baseline above already lands the DEFAULT shape correctly + (which carries shapeSizeAdj -0.30), so we only need the per-shape DELTA from + that default height -- subtracting (thisHatHeight - defaultShapeHatHeight)/2 + reproduces cursorless's -hatHeightPx/2 spread without disturbing the tuned + default. thisHatHeight reuses the exact height calc; defaultShapeHatHeight + is the same expression with shapeSizeAdj pinned to the default-shape -0.30. */ + bottom: calc(1em + (var(--code-line-height) - 1) * 0.5em + + var(--hat-base-voffset) + var(--user-voffset) + var(--shape-voffset) + - (var(--hat-height) * (1 + var(--user-size-adj) + var(--shape-size-adj)) + - var(--hat-height) * (1 + var(--user-size-adj) - 0.30)) / 2); + color: var(--hat-color); + pointer-events: none; + overflow: visible; + display: block; +} + +/* ---- cursor + selection (SPEC §4.5) ---- */ +.caret[data-cursor] { + display: inline-block; + width: 0; height: 1.2em; + margin: 0 -1px; + border-left: 2px solid var(--editor-caret); + vertical-align: text-bottom; +} +.ch[data-sel] { background: var(--editor-sel); } + +/* ---- optional line-number gutter (R3): OFF by default ---- + A leading inline-block .cl-lineno per .cl-line. Because the number shares the + line box with its code, it aligns to that exact row automatically — works + identically for single-frame renders and absolutely-positioned stacked cascade + frames (no offset math). The gutter only exists when an ancestor carries + data-line-numbers; absent that attribute, no .cl-lineno is emitted and output + is byte-identical to the no-gutter render. */ +:root { + --gutter-digits: 2; /* widest line number's digit count */ + --gutter-pad-right: 1.1ch; /* gap between numbers and code */ + --gutter-pad-left: 0.6ch; +} +.cl-lineno { + display: inline-block; + width: calc(var(--gutter-digits) * 1ch); + padding-left: var(--gutter-pad-left); + padding-right: var(--gutter-pad-right); + text-align: right; /* VS Code: right-aligned numbers */ + color: var(--editor-fg); + opacity: 0.42; /* dim, muted gray */ + font-variant-numeric: tabular-nums; + user-select: none; + pointer-events: none; +} +`; +} diff --git a/packages/command-visualizer/src/data/colors.ts b/packages/command-visualizer/src/data/colors.ts new file mode 100644 index 0000000000..78862e8994 --- /dev/null +++ b/packages/command-visualizer/src/data/colors.ts @@ -0,0 +1,67 @@ +// Color matrix — hexes VERBATIM from +// cursorless/packages/app-vscode/package.json cursorless.colors.{dark,light} +// (verified 2026-06-08; also mirrored in screenshots/oracle/color-matrix.json). +// SPEC §4.4. + +export type HatColor = + | "default" + | "blue" + | "green" + | "red" + | "pink" + | "yellow" + | "userColor1" + | "userColor2" + | "userColor3" + | "userColor4"; + +export const HAT_COLORS: HatColor[] = [ + "default", + "blue", + "green", + "red", + "pink", + "yellow", + "userColor1", + "userColor2", + "userColor3", + "userColor4", +]; + +export type Theme = "dark" | "light"; + +export const COLOR_MATRIX: Record> = { + dark: { + default: "#B9B6CD", + blue: "#089ad3", + green: "#36B33F", + red: "#E02D28", + pink: "#E06CAA", + yellow: "#E5C02C", + userColor1: "#6a00ff", + userColor2: "#ffd8b1", + userColor3: "#6b8e23", + userColor4: "#e0e0e0", + }, + light: { + default: "#757180", + blue: "#089ad3", + green: "#36B33F", + red: "#E02D28", + pink: "#e0679f", + yellow: "#edb62b", + userColor1: "#6a00ff", + userColor2: "#ffd8b1", + userColor3: "#6b8e23", + userColor4: "#e0e0e0", + }, +}; + +// Editor chrome colors (VS Code dark+ / light+ defaults). +export const EDITOR_CHROME: Record< + Theme, + { bg: string; fg: string; sel: string; caret: string } +> = { + dark: { bg: "#1e1e1e", fg: "#d4d4d4", sel: "#264f78", caret: "#aeafad" }, + light: { bg: "#ffffff", fg: "#1f1f1f", sel: "#add6ff", caret: "#000000" }, +}; diff --git a/packages/command-visualizer/src/data/decorations.ts b/packages/command-visualizer/src/data/decorations.ts new file mode 100644 index 0000000000..4c91dc4b38 --- /dev/null +++ b/packages/command-visualizer/src/data/decorations.ts @@ -0,0 +1,79 @@ +// Decoration style hexes — VERBATIM from cursorless flash/highlight palette +// (research/highlight-rendering.md §3; verified 2026-06-08). SPEC-v2 §3.1. +// All background-only; alpha baked into the 8-digit hex; theme-INVARIANT. + +// Provenance: these five names are the string values of @cursorless/lib-common's +// `FlashStyle` enum (ide/types/FlashDescriptor.ts). We deliberately keep a local +// string-union rather than importing the enum: the names compose here with +// HighlightStyle into DecorationStyle, which keys DECORATION_HEX and drives +// overlayPrecedence — switching to enum members would churn every literal and +// buy no runtime behavior. The HEX values, FLASH_PULSE_MS, MS_PER_STATE, and +// precedence are ours and are not exported by cursorless. +export type FlashStyle = + | "pendingDelete" + | "justAdded" + | "referenced" + | "pendingModification0" + | "pendingModification1"; + +export type HighlightStyle = "highlight0" | "highlight1"; + +export type DecorationStyle = FlashStyle | HighlightStyle; + +// All 11 decoration styles' background hexes (the 2 scope-pair styles use the +// same band hex family; per-edge borders are BONUS, SPEC-v2 §3.5, not here). +export const DECORATION_HEX: Record = { + pendingDelete: "#ff00008a", // REQUIRED (COMPLETION crit 3) + justAdded: "#09ff005b", // REQUIRED (COMPLETION crit 3) + referenced: "#00a2ff4d", + pendingModification0: "#8c00ff86", + pendingModification1: "#ff009d7e", + highlight0: "#d449ff42", + highlight1: "#60daff7a", +}; + +export const FLASH_STYLES: FlashStyle[] = [ + "pendingDelete", + "justAdded", + "referenced", + "pendingModification0", + "pendingModification1", +]; + +export const HIGHLIGHT_STYLES: HighlightStyle[] = ["highlight0", "highlight1"]; + +// Flash PULSE duration — pinned VERBATIM to cursorless's +// `cursorless.pendingEditDecorationTime` default (100ms): +// packages/app-vscode/package.json:375 ("default": 100) +// packages/app-vscode/src/ide/vscode/VscodeFlashHandler.ts:26 +// flashRanges(...) → await sleep(getPendingEditDecorationTime()) → clear +// A flash is a FIXED 100ms pulse, decoupled from the readability state-hold +// cadence (SPEC-v2 §4.2, B2/B6). Both the delete (pendingDelete) and insert +// (justAdded) beats are pinned to this. verify:flash-timing is the oracle. +export const FLASH_PULSE_MS = 100; + +// Real-time duration of one readability state-hold slot, in ms. The cascade +// timeline is `--dur = N · MS_PER_STATE` (serialize-cascade.ts), so each of the +// N frame slots lasts exactly MS_PER_STATE ms regardless of N. The flash pulse +// (FLASH_PULSE_MS) is pinned in absolute ms and is INDEPENDENT of this cadence — +// changing MS_PER_STATE rescales the state-hold but never the 100ms flash. +export const MS_PER_STATE = 1000; + +export const ALL_DECORATION_STYLES: DecorationStyle[] = [ + ...FLASH_STYLES, + ...HIGHLIGHT_STYLES, +]; + +// Single-winner precedence (SPEC-v2 §3.2, DECISIONS §7.1): selection < highlight +// < flash, last wins; exactly ONE background per cell. Higher number wins. +export function overlayPrecedence( + style: DecorationStyle | "selection", +): number { + if (style === "selection") { + return 0; + } + if (HIGHLIGHT_STYLES.includes(style as HighlightStyle)) { + return 1; + } + return 2; // flash +} diff --git a/packages/command-visualizer/src/data/shapes.ts b/packages/command-visualizer/src/data/shapes.ts new file mode 100644 index 0000000000..4dd6fbe095 --- /dev/null +++ b/packages/command-visualizer/src/data/shapes.ts @@ -0,0 +1,108 @@ +// Hat shape SVG path data — copied VERBATIM from +// cursorless/resources/images/hats/{shape}.svg (verified 2026-06-08). +// Only `crosshairs` carries fill-rule="evenodd" clip-rule="evenodd"; every +// other shape uses the SVG default (nonzero). `ex` is a single subpath (no hole). +// SPEC §4.1 / DECISIONS.md CORRECTION. + +export type HatShape = + | "default" + | "bolt" + | "curve" + | "fox" + | "frame" + | "play" + | "wing" + | "hole" + | "ex" + | "crosshairs" + | "eye"; + +export const HAT_SHAPES: HatShape[] = [ + "default", + "bolt", + "curve", + "fox", + "frame", + "play", + "wing", + "hole", + "ex", + "crosshairs", + "eye", +]; + +export interface ShapePath { + d: string; + /** Only set for crosshairs. */ + fillRule?: "evenodd"; +} + +// d= strings are byte-for-byte from the source SVGs. +export const SHAPE_PATHS: Record = { + default: { + d: "M6 9C9.31371 9 12 6.98528 12 4.5C12 2.01472 9.31371 0 6 0C2.68629 0 0 2.01472 0 4.5C0 6.98528 2.68629 9 6 9Z", + }, + bolt: { + d: "M12 4V0C12 0 9 5 8 5C7 5 3 0 3 0L0 5V9C0 9 3 5 4 5C5 5 9 9 9 9L12 4Z", + }, + curve: { + d: "M6.00016 3.5C10 3.5 12 7.07378 12 9C12 4 10.5 0 6.00016 0C1.50032 0 0 4 0 9C0 7.07378 2.00032 3.5 6.00016 3.5Z", + }, + fox: { + d: "M6.00001 9L0 0C0 0 3.71818 2.5 6 2.5C8.28182 2.5 12 0 12 0L6.00001 9Z", + }, + frame: { + d: "M0 0.000115976V8.99988H12V0L0 0.000115976ZM9.5 6.5H6H2.5V4.5V2.5H6H9.5V4.5V6.5Z", + }, + play: { + d: "M12 4.49999L0 9C0 9 3 6.2746 3 4.49999C3 2.72537 0 0 0 0L12 4.49999Z", + }, + wing: { + d: "M6 0C6 0 7 3 8.5 4.5C10 6 12 7 12 7V9C12 9 8.5 7 6 7C3.5 7 0 9 0 9V7C0 7 2 6 3.5 4.5C5 3 6 0 6 0Z", + }, + hole: { + d: "M1.5 4.5L0 7H2.5L3.5 9L6 7.5L8.5 9L9.5 7H12L10.5 4.5L12 2H9.5L8.5 0L6 1.5L3.5 0L2.5 2H0Z M6 5.5L4 6.5L3 4.5L4 2.5L6 3.5L8 2.5L9 4.5L8 6.5L6 5.5Z", + }, + ex: { + d: "M9.99997 9C9.99997 9 7.5 6.5 6 6.5C4.5 6.5 2 9 2 9C2 9 0.999999 9 0 9C0 9 2.5 6 2.5 4.5C2.5 3 6.5473e-05 0 6.5473e-05 0C6.5473e-05 0 1 0 2 0C2 0 4.5 2.5 6 2.5C7.5 2.5 9.99997 0 9.99997 0C11 0 12 0 12 0C12 0 9.5 3 9.5 4.5C9.5 6 12 9 12 9C12 9 11 9 9.99997 9Z", + }, + crosshairs: { + d: "M5.25 0C5.25 0 4.5 1.5 3.5 2.5C2.49483 3.50517 0 3.75 0 3.75V5.25C0 5.25 2.49483 5.49483 3.5 6.5C4.5 7.5 5.25 9 5.25 9H6.75C6.75 9 7.5 7.5 8.5 6.5C9.50517 5.49483 12 5.25 12 5.25V3.75C12 3.75 9.50517 3.50517 8.5 2.5C7.5 1.5 6.75 0 6.75 0H5.25ZM5.75 6.5H6.25C6.25 6.5 6.58435 5.25599 7 5C7.41565 4.74401 8.75 4.75 8.75 4.75V4.25C8.75 4.25 7.41565 4.25599 7 4C6.58435 3.74401 6.25 2.5 6.25 2.5H5.75C5.75 2.5 5.41565 3.74401 5 4C4.58435 4.25599 3.25 4.25 3.25 4.25V4.75C3.25 4.75 4.58435 4.74401 5 5C5.41565 5.25599 5.75 6.5 5.75 6.5Z", + fillRule: "evenodd", + }, + eye: { + d: "M12 4L6.5 0H5.5L0 4V5L5.5 9H6.5L12 5V4ZM6 7.5C6 7.5 4.5 6.5 4.5 4.5C4.5 2.5 6.01103 1.5 6 1.5C6 1.5 7.5 2.5 7.5 4.5C7.5 6.5 6 7.5 6 7.5Z", + }, +}; + +/** + * Per-shape adjustments, ported from + * cursorless/.../hats/shapeAdjustments.ts (verified 2026-06-08). + * sizeAdjustment is a PERCENT (e.g. -30 = -0.30 fraction). + * verticalOffset is a PERCENT applied /100 to em (e.g. -5 = -0.05em). + */ +export interface ShapeAdjustment { + /** percent */ + sizeAdjustment?: number; + /** percent → em/100 */ + verticalOffset?: number; + /** deferred (two-tone stroke, SPEC §7) */ + strokeFactor?: number; +} + +export const SHAPE_ADJUSTMENTS: Record = { + default: { sizeAdjustment: -30 }, + ex: { sizeAdjustment: -12.5 }, + fox: { sizeAdjustment: -5 }, + wing: { sizeAdjustment: -2.5 }, + hole: { strokeFactor: 0.7 }, + frame: { sizeAdjustment: -20 }, + curve: { verticalOffset: -5 }, + eye: {}, + play: {}, + bolt: {}, + crosshairs: {}, +}; + +export const DEFAULT_HAT_HEIGHT_EM = 0.36; +export const DEFAULT_VERTICAL_OFFSET_EM = 0.05; diff --git a/packages/command-visualizer/src/fixture-extract.ts b/packages/command-visualizer/src/fixture-extract.ts new file mode 100644 index 0000000000..bdc150d8b3 --- /dev/null +++ b/packages/command-visualizer/src/fixture-extract.ts @@ -0,0 +1,211 @@ +// Field-extraction helpers for the fixture → state pipeline (SPEC-v2 §2 steps +// 3/4/5). Pulls the YAML-shape coercion + marks→hats + selections + range +// mapping out of pipeline.ts so each module stays under the line ceiling. + +import type { YamlValue } from "./fixture-yaml"; +import { tokenizeDoc } from "./tokenize"; +import type { Line, Token, InputHat } from "./columns"; +import type { HatColor } from "./data/colors"; +import type { HatShape } from "./data/shapes"; +import { HAT_COLORS } from "./data/colors"; +import type { GeneralizedRange } from "./frame-state"; +import type { Pos, Range } from "./serialize"; +import { allocateHats } from "./hat-allocator"; + +export type Obj = Record; + +export function asObj(v: YamlValue | undefined): Obj | null { + return v && typeof v === "object" && !Array.isArray(v) ? (v as Obj) : null; +} +export function asArr(v: YamlValue | undefined): YamlValue[] { + return Array.isArray(v) ? v : []; +} +export function num(v: YamlValue | undefined): number { + return typeof v === "number" ? v : Number(v); +} +export function pos(v: YamlValue | undefined): Pos { + const o = asObj(v) ?? {}; + return { line: num(o.line), character: num(o.character) }; +} + +// Dedup note (step 4c): @cursorless/lib-common exports +// serializedMarksToTokenHats, but it requires a live TextEditor (document +// offsetAt/getText) and returns TokenHat[] — a shape built for the engine, not +// for SVG rendering. Our parseMarks reads plain fixture-YAML objects with no +// editor and yields the MarkInfo render model buildLines() needs. buildLines() +// itself (tokenize → attach fixture hats → real allocator fill → author +// overrides) is genuinely ours. Adopting the engine helper would mean faking a +// TextEditor and rewriting buildLines — scope balloon for no gain. Kept. + +// Step 4: a `{color}.{grapheme}` mark with its line range. +export interface MarkInfo { + key: string; + grapheme: string; + color: HatColor; + start: Pos; + end: Pos; +} + +export function parseMarks(marksObj: Obj | null): MarkInfo[] { + if (!marksObj) { + return []; + } + const out: MarkInfo[] = []; + for (const [key, val] of Object.entries(marksObj)) { + const range = asObj(val); + if (!range) { + continue; + } + const dot = key.indexOf("."); + const colorRaw = key.slice(0, dot); + const grapheme = key.slice(dot + 1); + const color = (HAT_COLORS as string[]).includes(colorRaw) + ? (colorRaw as HatColor) + : ("default" as HatColor); + out.push({ + key, + grapheme, + color, + start: pos(range.start), + end: pos(range.end), + }); + } + return out; +} + +/** Options for buildLines beyond the mark list. */ +export interface BuildLinesOptions { + /** Per-mark-key shape overrides, e.g. { "default.f": "fox" }. */ + shapeOverride?: Record; + /** + * "dense" (default): real-allocator fill over every unhatted hattable + * token — the whole image wears hats, like a live session. + * "marks-only": render exactly the fixture's recorded marks, no fill. + */ + fill?: "dense" | "marks-only"; + /** + * Position-keyed exact-hat overrides applied LAST, to any token (marked or + * fill), keyed "{line}:{startChar}". Author intent wins over both the + * fixture and the allocator; may deliberately duplicate a (grapheme, style) + * the allocator assigned elsewhere — that's on the author. + */ + hatOverride?: Record; +} + +// Steps 3 + 4 + 5: tokenize a doc and attach hats. +export function buildLines( + doc: string, + marks: MarkInfo[], + opts: BuildLinesOptions = {}, +): Line[] { + const { shapeOverride, fill = "dense", hatOverride } = opts; + const lines = tokenizeDoc(doc); + + // Pass 1: command-relevant fixture marks — exact fixture color; shape is + // "default" unless a per-fixture override says otherwise. Fixture mark keys + // ({color}.{grapheme}) carry no shape component: the recorded session's + // hats were default-shape, so "default" is the faithful rendering (the old + // synthetic hash injected fake shapes here — removed in task-8tq). + for (const mk of marks) { + if (mk.start.line !== mk.end.line) { + continue; + } // marks are single-line in corpus + const line = lines[mk.start.line]; + if (!line) { + continue; + } + const hat: InputHat = { + color: mk.color, + shape: shapeOverride?.[mk.key] ?? "default", + }; + let target: Token | undefined = line.tokens.find( + (t) => + t.range.start === mk.start.character && + t.range.end === mk.end.character, + ); + if (!target) { + target = line.tokens.find( + (t) => + t.range.start <= mk.start.character && + mk.start.character < t.range.end, + ); + } + if (target) { + target.hat = hat; + } + } + + // Pass 2: REAL hat allocation (allocate-hats package — cursorless's own + // chooseTokenHat at SHA 42452eb). Fixture-marked tokens from pass 1 enter + // as old assignments (kept, and their colors consumed from the pool); every + // other hattable token gets the algorithm's color AND shape — shapes now + // appear only under real collision pressure instead of hash randomness. + // Skipped in "marks-only" mode: render exactly what the fixture recorded. + if (fill === "dense") { + allocateHats(lines); + } + + // Pass 3: position-keyed exact-hat overrides — author intent wins last. + if (hatOverride) { + for (let lineIdx = 0; lineIdx < lines.length; lineIdx++) { + for (const token of lines[lineIdx].tokens) { + const o = hatOverride[`${lineIdx}:${token.range.start}`]; + if (!o) { + continue; + } + token.hat = { + color: o.color ?? token.hat?.color ?? "default", + shape: o.shape ?? token.hat?.shape ?? "default", + }; + } + } + } + + return lines; +} + +// Selections → {cursors, selections}. anchor==active ⇒ caret; reversed normalize. +export function deriveSelections(selArr: YamlValue[]): { + cursors: Pos[]; + selections: Range[]; +} { + const cursors: Pos[] = []; + const selections: Range[] = []; + for (const s of selArr) { + const o = asObj(s); + if (!o) { + continue; + } + const anchor = pos(o.anchor); + const active = pos(o.active); + if (anchor.line === active.line && anchor.character === active.character) { + cursors.push(anchor); + } else { + const before = + anchor.line < active.line || + (anchor.line === active.line && anchor.character <= active.character); + selections.push( + before + ? { start: anchor, end: active } + : { start: active, end: anchor }, + ); + } + } + return { cursors, selections }; +} + +// Steps 6/7: a flash/highlight range YAML → a Decoration GeneralizedRange. +export function toGeneralizedRange(rangeObj: Obj): GeneralizedRange | null { + if (rangeObj.type === "line") { + return { + type: "line", + startLine: num(rangeObj.start), + endLine: num(rangeObj.end), // INCLUSIVE per GeneralizedRange.ts + }; + } + return { + type: "character", + start: pos(rangeObj.start), + end: pos(rangeObj.end), + }; +} diff --git a/packages/command-visualizer/src/fixture-root.ts b/packages/command-visualizer/src/fixture-root.ts new file mode 100644 index 0000000000..dd3035fc61 --- /dev/null +++ b/packages/command-visualizer/src/fixture-root.ts @@ -0,0 +1,82 @@ +// Portable resolution of the cursorless repo root and its fixture subpaths. +// +// Resolution order: +// 1. $CURSORLESS_REPO env var (explicit override — CI, alt checkouts) +// 2. $HOME/code/cursorless (sensible default, NOT a hardcoded user) +// +// Layout probe: the local repo may use either of two directory structures: +// A (mini2 fork): resources/images/hats/ + resources/fixtures/recorded/ +// B (main repo): images/hats/ + data/fixtures/recorded/ +// +// Throws a CLEAR error if the resolved root does not exist, so a misconfigured +// checkout fails loudly at startup instead of with an opaque ENOENT mid-run. +// +// Dedup note (step 4b): @cursorless/lib-node-common exports getFixturesPath / +// getRecordedTestsDirPath, but they hardcode the single `resources/fixtures` +// layout and offer no $CURSORLESS_REPO override. This module deliberately keeps +// its dual-layout probe (resources/… fork vs data/… main repo) plus the env +// override and loud errors, so it works across both checkout shapes the +// renderer targets. Adopting lib-node-common's helpers would be a regression. + +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; + +/** Absolute path to the cursorless repo root (env override, else $HOME/code/cursorless). */ +export function cursorlessRepoRoot(): string { + const fromEnv = process.env.CURSORLESS_REPO?.trim(); + const root = + fromEnv && fromEnv.length > 0 + ? fromEnv + : join(homedir(), "code", "cursorless"); + + if (!existsSync(root)) { + const how = fromEnv + ? '$CURSORLESS_REPO is set to "' + root + '"' + : 'defaulted to "' + root + '" ($HOME/code/cursorless)'; + throw new Error( + `cursorless repo not found: ${how}, but that path does not exist.\n` + + `Set CURSORLESS_REPO to your cursorless checkout, e.g.\n` + + ` CURSORLESS_REPO=/path/to/cursorless bun run verify`, + ); + } + return root; +} + +/** Detect which directory layout the repo uses and return the hats dir. */ +export function hatsRoot(): string { + const root = cursorlessRepoRoot(); + // Layout A (mini2 fork): resources/images/hats/ + const layoutA = join(root, "resources", "images", "hats"); + if (existsSync(layoutA)) { + return layoutA; + } + // Layout B (main repo): images/hats/ + const layoutB = join(root, "images", "hats"); + if (existsSync(layoutB)) { + return layoutB; + } + throw new Error( + `cursorless hat SVGs not found in "${root}".\n` + + `Expected one of:\n ${layoutA}\n ${layoutB}`, + ); +} + +/** Detect which directory layout the repo uses and return the recorded fixtures dir. */ +export function fixtureRoot(): string { + const root = cursorlessRepoRoot(); + // Layout A (mini2 fork): resources/fixtures/recorded/ + const layoutA = join(root, "resources", "fixtures", "recorded"); + if (existsSync(layoutA)) { + return layoutA; + } + // Layout B (main repo): data/fixtures/recorded/ + const layoutB = join(root, "data", "fixtures", "recorded"); + if (existsSync(layoutB)) { + return layoutB; + } + throw new Error( + `cursorless recorded fixtures not found in "${root}".\n` + + `Expected one of:\n ${layoutA}\n ${layoutB}`, + ); +} diff --git a/packages/command-visualizer/src/fixture-yaml.ts b/packages/command-visualizer/src/fixture-yaml.ts new file mode 100644 index 0000000000..b073380793 --- /dev/null +++ b/packages/command-visualizer/src/fixture-yaml.ts @@ -0,0 +1,42 @@ +// Fixture YAML reader — SPEC-v2 §2.1. +// +// Was a hand-rolled parser for the recorded-fixture YAML subset (block maps, +// block scalars, block sequences, flow maps). Replaced with cursorless's own +// YAML library, `js-yaml` — the same dependency and the same `load()` entry +// point `@cursorless/lib-node-common`'s loadFixture.ts uses to read these +// fixtures. This drops ~250 lines of bespoke parser and the yaml-scalars.ts +// primitives module in favor of the battle-tested lib, while preserving the +// exported surface (`parseFixtureYaml`, `YamlValue`) that fixture-extract.ts +// and pipeline.ts depend on. +// +// Byte-exact `documentContents` (GATE 0) is preserved: js-yaml's literal block +// scalar (`|` / `|N`) handling is the reference implementation the hand-rolled +// reader was mimicking. + +import { load } from "js-yaml"; + +/** + * Recursive JSON-ish value produced by parsing a fixture YAML document. Matches + * the shape js-yaml's default schema yields for the recorded-fixture subset + * (scalars, block/flow maps, block sequences). Kept stable for downstream + * consumers (fixture-extract.ts coerces via asObj/asArr/num/pos). + */ +export type YamlValue = + | string + | number + | boolean + | null + | YamlValue[] + | { [k: string]: YamlValue }; + +/** + * Parse a full fixture YAML document into a plain object. Returns `{}` for an + * empty/null document so callers can index into it unconditionally. + */ +export function parseFixtureYaml(src: string): { [k: string]: YamlValue } { + const value = load(src) as YamlValue | undefined; + if (value != null && typeof value === "object" && !Array.isArray(value)) { + return value as { [k: string]: YamlValue }; + } + return {}; +} diff --git a/packages/command-visualizer/src/frame-state.ts b/packages/command-visualizer/src/frame-state.ts new file mode 100644 index 0000000000..fecc961242 --- /dev/null +++ b/packages/command-visualizer/src/frame-state.ts @@ -0,0 +1,68 @@ +// Multi-frame state schema — SPEC-v2 §1. A FRAME is exactly one SPEC.md +// `.cl-editor` surface (EditorState minus the doc-level theme/tab). v2 wraps an +// ordered list of frames in a cascade and adds the decoration OVERLAY layer. + +import type { Theme } from "./data/colors"; +import type { Line } from "./columns"; +import type { Pos, Range } from "./serialize"; +import type { DecorationStyle } from "./data/decorations"; + +export type FrameRole = "before" | "during" | "after"; + +export type OverlayRole = + | "flash" + | "highlight" + | "that" + | "source" + | `scope:${string}`; + +// GeneralizedRange (SPEC-v2 §1.1) — character (half-open columns) or line +// (full-width, endLine INCLUSIVE). +export type CharacterRange = { + type: "character"; + start: Pos; + end: Pos; +}; +export type LineRange = { + type: "line"; + startLine: number; + endLine: number; // INCLUSIVE +}; +export type GeneralizedRange = CharacterRange | LineRange; + +export interface Decoration { + style: DecorationStyle; + range: GeneralizedRange; + role: OverlayRole; +} + +export interface Frame { + role: FrameRole; + lines: Line[]; + cursors: Pos[]; + selections: Range[]; + decorations: Decoration[]; + /** Spoken form of the command this frame is the BEFORE of (command strip). */ + command?: string; + /** Clipboard contents relevant to this frame (VisualizerMetadata port). */ + clipboard?: string; + /** Seamless-loop reset frame: re-shows the sequence initial state. */ + reset?: boolean; + /** Pre-gif bumper frame: shows the initial state before step 0 begins. */ + pre?: boolean; + /** Explicit duration override (ms). Defaults resolve by role in timeline.ts. */ + durMs?: number; +} + +export interface CascadeMeta { + spokenForm?: string; + action?: string; + fixture?: string; +} + +export interface CascadeState { + theme: Theme; + tabSize: number; + meta?: CascadeMeta; + frames: Frame[]; +} diff --git a/packages/command-visualizer/src/hat-allocator.ts b/packages/command-visualizer/src/hat-allocator.ts new file mode 100644 index 0000000000..4a0093812d --- /dev/null +++ b/packages/command-visualizer/src/hat-allocator.ts @@ -0,0 +1,175 @@ +// REAL hat allocation — cursorless's actual algorithm via the allocate-hats +// sources, INTERNALIZED under ./vendor/allocate-hats (vendored from the +// allocate-hats repo at commit 42452eb, which itself vendors cursorless's +// allocateHats subgraph at SHA 42452eb — proven byte-identical to the shipped +// prose-overlay bundle). Internalized here so the package compiles in-monorepo +// without the unpublished `allocate-hats` npm dependency; the unused QuickJS +// bundle.ts / proseCompat.ts entry points were dropped during vendoring. +// +// Replaces (task-8tq, 2026-07-07): +// - the greedy per-grapheme color-pool allocator that lived here, and +// - synthetic-shape.ts (sum-of-code-units % 11) — the repo's one known-fake +// render element. DECISIONS §7.5 blessing superseded; see §7.5b. +// +// Model: +// - Every hattable token (single grapheme, per tokenize.ts R5) goes to the +// real allocator with its document position; ranking is cursorless's own +// comparator (line delta, then character delta from the cursor). +// - Fixture marks are passed as OLD ASSIGNMENTS with stability "stable": +// the algorithm keeps them (its own keep-metric) and their (grapheme, +// color) pairs are consumed from the pool, so fill tokens can't collide +// with them — the old step-3b pre-drain, now done by the real thing. +// - Marks render with shape "default" unless a per-fixture override says +// otherwise. This is MORE faithful than the synthetic hash: fixture mark +// keys carry no shape component, meaning the recorded session's hats were +// default-shape. +// - Fill tokens take the allocator's style wholesale (color + shape). With +// the full color x shape style map, shapes appear only under genuine +// collision pressure (>10 tokens sharing an anchor grapheme) — exactly +// like a real cursorless session, instead of hash-random shapes. +// +// Determinism: allocate-hats is pure (no Date/random); same lines + marks + +// cursor -> identical assignments. verify-allocation.ts gates this. + +import { + allocateHats as allocateHatsReal, + StandaloneGraphemeSplitter, + type HatStyleMap, +} from "./vendor/allocate-hats/index"; +import type { Line } from "./columns"; +import type { HatColor } from "./data/colors"; +import type { HatShape } from "./data/shapes"; +import { HAT_COLORS } from "./data/colors"; +import { HAT_SHAPES } from "./data/shapes"; +import type { Pos } from "./serialize"; +import { segmentLines } from "./word-segments"; + +/** True iff the token text is a single hattable grapheme (letter/number/punct/symbol). */ +const SPLITTER = new StandaloneGraphemeSplitter(); + +// --------------------------------------------------------------------------- +// Style map: our full palette x (default + 10 shapes), penalty-ordered the way +// cursorless orders its own map — default color 0, named colors 1, user colors +// 2, +1 for a shape. Pure colors are inserted BEFORE shaped variants so free +// pure colors win penalty ties in the allocator's candidate ordering (matches +// real cursorless, where shapes appear only after the color pool drains). +// --------------------------------------------------------------------------- + +function colorPenalty(color: HatColor): number { + if (color === "default") { + return 0; + } + return color.startsWith("userColor") ? 2 : 1; +} + +export function cssStateHatStyles(): HatStyleMap { + const out: HatStyleMap = {}; + for (const color of HAT_COLORS) { + out[color] = { penalty: colorPenalty(color) }; + } + for (const color of HAT_COLORS) { + for (const shape of HAT_SHAPES) { + if (shape === "default") { + continue; + } // bare color IS the default shape + out[`${color}-${shape}`] = { penalty: colorPenalty(color) + 1 }; + } + } + return out; +} + +/** Split an allocator style name back into our (color, shape) pair. */ +function styleToHat(styleName: string): { color: HatColor; shape: HatShape } { + const dash = styleName.indexOf("-"); + if (dash === -1) { + return { color: styleName as HatColor, shape: "default" }; + } + return { + color: styleName.slice(0, dash) as HatColor, + shape: styleName.slice(dash + 1) as HatShape, + }; +} + +// --------------------------------------------------------------------------- +// Main entry — called by fixture-extract.buildLines after pass 1 has attached +// fixture-mark hats to specific grapheme tokens. +// +// Allocation is per WORD-LEVEL token (word-segments.ts): each segment gets AT +// MOST ONE hat, anchored at the grapheme the real algorithm chooses (its +// returned charIdx). Fixture-marked segments enter as old assignments +// (stability "stable") so the algorithm keeps them and their (grapheme, +// color) pairs drain from the pool; the mark keeps its exact pass-1 grapheme +// position and hat. +// --------------------------------------------------------------------------- + +export function allocateHats( + lines: Line[], + cursor: Pos = { line: 0, character: 0 }, +): void { + const segments = segmentLines(lines); + + const inputTokens = segments.map((seg) => ({ + text: seg.text, + position: { line: seg.lineIdx, character: seg.start }, + })); + + const oldAssignments: { + tokenIdx: number; + charIdx: number; + grapheme: string; + styleName: string; + }[] = []; + const pinned = new Set(); + + segments.forEach((seg, idx) => { + for (const g of seg.graphemes) { + if (!g.hat) { + continue; + } + pinned.add(idx); + oldAssignments.push({ + tokenIdx: idx, + charIdx: g.range.start - seg.start, + grapheme: SPLITTER.normalizeGrapheme(g.text), + styleName: g.hat.color, + }); + break; // one mark pins the whole segment + } + }); + + const assignments = allocateHatsReal({ + tokens: inputTokens, + oldAssignments, + stability: "stable", + enabledHatStyles: cssStateHatStyles(), + cursorPosition: cursor, + }); + + for (const a of assignments) { + const seg = segments[a.tokenIdx]; + if (pinned.has(a.tokenIdx)) { + // Mark keeps its pass-1 hat; tripwire if the algorithm dropped it. + const marked = seg.graphemes.find((g) => g.hat); + if (marked && SPLITTER.normalizeGrapheme(marked.text) !== a.grapheme) { + // Same segment, different anchor grapheme chosen — allowed only if + // the style survived; color change would break fixture fidelity. + const kept = styleToHat(a.styleName); + if (kept.color !== marked.hat!.color) { + throw new Error( + `[hat-allocator] real allocator reassigned pinned mark in segment ` + + `"${seg.text}" (line ${seg.lineIdx}): fixture color ${marked.hat!.color}, ` + + `allocator gave ${a.styleName}`, + ); + } + } + continue; + } + // One hat per word-level token, on the algorithm-chosen grapheme. + const target = seg.graphemes.find( + (g) => g.range.start - seg.start === a.charIdx, + ); + if (target) { + target.hat = styleToHat(a.styleName); + } + } +} diff --git a/packages/command-visualizer/src/index.ts b/packages/command-visualizer/src/index.ts new file mode 100644 index 0000000000..2ed616bb39 --- /dev/null +++ b/packages/command-visualizer/src/index.ts @@ -0,0 +1,20 @@ +// @cursorless/cascade-renderer — public surface. +// +// fixture YAML in -> animated SVG out. See STATUS.md for integration state. + +export { fixtureToCascade, type PipelineOptions } from "./pipeline"; +export { chainCascades, withBumpers, ChainContinuityError } from "./chain"; +export { + serializeCascade, + serializeCascadeDocument, +} from "./serialize-cascade"; +export { wrapCascadeSvg } from "./svg-wrap"; +export { serializeJumbotron, jumbotronCss } from "./jumbotron"; +export { + timelineOf, + frameDurMs, + INITIAL_MS, + DURING_MS, + BUMPER_MS, +} from "./timeline"; +export type { CascadeState, Frame } from "./frame-state"; diff --git a/packages/command-visualizer/src/jumbotron.ts b/packages/command-visualizer/src/jumbotron.ts new file mode 100644 index 0000000000..edc503e212 --- /dev/null +++ b/packages/command-visualizer/src/jumbotron.ts @@ -0,0 +1,503 @@ +// Jumbotron shape — ported from Trillium's VisualizerWrapper implementation +// (cursorless fork branch gen_2026_01_20, packages/test-case-component/src/ +// components/VisualizerWrapper/: JumbotronView.tsx, VisualizerMetadata.tsx, +// StateNavigationDots.tsx, VisualizerWrapper.css). See brain-a08pj. +// +// EVERY render uses the jumbo shape, one step or many: +// +//
+//
<- the cl-cascade frame carousel +//
<- command bar (carousel for chains) +//