From 778fbdf07bd98bd2112bfa1305b3ccb21f3fac62 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:36 -0700 Subject: [PATCH 01/25] Split the TUI's renderer-free theme and stream code out (AI-assisted) palette.ts holds the palette, Helix theme mapping and theme config with no Bun.TOML or node:fs, and events.ts parses NDJSON text with stream.ts left to decode bytes. The model layer can then be bundled outside Bun. The bundled dark and light palettes move to theme.ts with the rest of theme loading. Agent-Session: 01a118c6-60a6-7373-bad2-7f763da2c3c2 Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/tui/packages/hunk/src/diffr/events.ts | 28 +++ .../packages/hunk/src/diffr/guides.test.ts | 3 +- diffr/tui/packages/hunk/src/diffr/palette.ts | 177 +++++++++++++++++ .../packages/hunk/src/diffr/regions.test.ts | 5 +- .../tui/packages/hunk/src/diffr/rows.test.ts | 3 +- diffr/tui/packages/hunk/src/diffr/rows.ts | 6 +- diffr/tui/packages/hunk/src/diffr/stream.ts | 32 +--- .../tui/packages/hunk/src/diffr/theme.test.ts | 3 +- diffr/tui/packages/hunk/src/diffr/theme.ts | 180 +----------------- diffr/tui/packages/hunk/src/main.tsx | 3 +- diffr/tui/packages/hunk/src/ui/App.tsx | 3 +- diffr/tui/packages/hunk/src/ui/Settings.tsx | 2 +- .../packages/hunk/src/ui/diff/CodeRowView.tsx | 2 +- 13 files changed, 232 insertions(+), 215 deletions(-) create mode 100644 diffr/tui/packages/hunk/src/diffr/events.ts create mode 100644 diffr/tui/packages/hunk/src/diffr/palette.ts diff --git a/diffr/tui/packages/hunk/src/diffr/events.ts b/diffr/tui/packages/hunk/src/diffr/events.ts new file mode 100644 index 000000000..d7bb98283 --- /dev/null +++ b/diffr/tui/packages/hunk/src/diffr/events.ts @@ -0,0 +1,28 @@ +/** Parse diffr's NDJSON text incrementally. diffr's records are trusted as sent: only malformed + * JSON, an unknown protocol version, and a stream that ends before `complete` are errors. */ +import { eventSchema, type DiffEvent } from "./wire"; +export async function* parseDiffEvents(texts: AsyncIterable): AsyncGenerator { + let buffer = "", + complete = false; + function parse(line: string): DiffEvent { + if (line.length > 128 * 1024 * 1024) + throw new Error("diffr event exceeds 128 MiB"); + const event = eventSchema.parse(JSON.parse(line)); + // A stream cut short, say by a signal, ends without `complete`. + if (event.type === "complete") complete = true; + return event; + } + for await (const text of texts) { + buffer += text; + let end: number; + while ((end = buffer.indexOf("\n")) >= 0) { + const line = buffer.slice(0, end); + buffer = buffer.slice(end + 1); + if (line.trim()) yield parse(line); + } + if (buffer.length > 128 * 1024 * 1024) + throw new Error("diffr event exceeds 128 MiB"); + } + if (buffer.trim()) yield parse(buffer); + if (!complete) throw new Error("Incomplete diffr stream"); +} diff --git a/diffr/tui/packages/hunk/src/diffr/guides.test.ts b/diffr/tui/packages/hunk/src/diffr/guides.test.ts index 963db881d..3e3840eb2 100644 --- a/diffr/tui/packages/hunk/src/diffr/guides.test.ts +++ b/diffr/tui/packages/hunk/src/diffr/guides.test.ts @@ -1,6 +1,7 @@ import {expect, test} from "bun:test"; import {createGuideDiffFile, createTestDiffFile, leaf, line, root} from "./fixture"; -import {dark, rowsForFile} from "./rows"; +import {rowsForFile} from "./rows"; +import {dark} from "./theme"; import {byteColumn, defaultCollapsed} from "./regions"; const sourceText = (cell: {spans: {text: string}[]} | undefined) => cell?.spans.map(s => s.text).join(""); test("Paper: enclosing guides cross blank lines and indented gap bands; syntax folds join opener and closer", () => { diff --git a/diffr/tui/packages/hunk/src/diffr/palette.ts b/diffr/tui/packages/hunk/src/diffr/palette.ts new file mode 100644 index 000000000..6b42fea4c --- /dev/null +++ b/diffr/tui/packages/hunk/src/diffr/palette.ts @@ -0,0 +1,177 @@ +/** The painter's palette from a parsed Helix theme, and diffr's theme config. No Node or Bun APIs. */ +import type { FoldTint } from "./regions"; +export interface Style { + fg?: string; + bg?: string; + modifiers: string[]; +} +export interface HelixTheme { + name: string; + styles: Map; + palette: Map; +} +/** Colours the row painter reads; syntax colours come from the theme by capture name. */ +export interface Palette { + name: string; + isLight: boolean; + bg: string; + fg: string; + muted: string; + /** Menu bar, status bar, and overlay chrome. */ + chrome: string; + /** A file header's surface: the one band in the diff that marks where a file starts. */ + fileHeader: string; + /** The directory half of a header's path, so the file name stands out. */ + fileHeaderDir: string; + /** Sidebar highlight for the active file. */ + highlight: string; + addition: string; + deletion: string; + modification: string; + addWord: string; + deleteWord: string; + addedText: string; + removedText: string; + /** An accent for interactive text such as links and the layout badge. */ + accent: string; + /** VS Code's editor.foldPlaceholderForeground. */ + foldPlaceholder: string; + guide: string; + /** The body of a scope about to fold, while the pointer is on its rail or chevron. */ + focusWash: string; + /** VS Code's bracket-match box, on the brackets of a scope about to fold. */ + focusBrace: string; + /** Foreground for a tree-sitter capture such as `keyword.return`; undefined when the theme has no scope for it. */ + syntax: (capture: string) => string | undefined; +} +/** Helix's named terminal colours, used when a theme writes `fg = "red"` outside its palette. */ +const ansi: Record = { + black: "#000000", red: "#cd3131", green: "#0dbc79", yellow: "#e5e510", blue: "#2472c8", + magenta: "#bc3fbc", cyan: "#11a8cd", white: "#e5e5e5", gray: "#666666", grey: "#666666", + "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543", "light-blue": "#3b8eea", + "light-magenta": "#d670d6", "light-cyan": "#29b8db", "light-white": "#ffffff", "light-gray": "#a0a0a0", + "light-grey": "#a0a0a0", "light-black": "#333333", +}; +interface RawStyle { + fg?: string; + bg?: string; + modifiers?: string[]; +} +export function helixTheme(raw: Record, name: string): HelixTheme { + const palette = new Map(); + const rawPalette = raw.palette; + if (rawPalette && typeof rawPalette === "object") + for (const [key, value] of Object.entries(rawPalette as Record)) { + if (typeof value !== "string") throw new Error(`Theme ${name}: palette entry ${key} is not a colour`); + palette.set(key, value); + } + const styles = new Map(); + for (const [scope, value] of Object.entries(raw)) { + if (scope === "palette" || scope === "inherits") continue; + if (typeof value === "string") styles.set(scope, { fg: value, modifiers: [] }); + else if (value && typeof value === "object") { + const style = value as RawStyle; + styles.set(scope, { fg: style.fg, bg: style.bg, modifiers: style.modifiers ?? [] }); + } else throw new Error(`Theme ${name}: scope ${scope} has an unsupported value`); + } + if (typeof raw.inherits === "string") + throw new Error(`Theme ${name} inherits from ${raw.inherits}; inherited themes are not supported`); + return { name, styles, palette }; +} +/** A palette name, a hex colour, or one of Helix's terminal colour names. */ +export function colorOf(theme: HelixTheme, value: string): string { + const fromPalette = theme.palette.get(value); + if (fromPalette !== undefined) return colorOf(theme, fromPalette); + if (/^#[0-9a-fA-F]{6}$/.test(value)) return value; + const named = ansi[value]; + if (named) return named; + throw new Error(`Theme ${theme.name}: unknown colour ${value}`); +} +/** The style for a scope, falling back to its parent scopes: `keyword.return` → `keyword`. */ +export function scopeStyle(theme: HelixTheme, scope: string): Style | undefined { + const parts = scope.split("."); + while (parts.length) { + const style = theme.styles.get(parts.join(".")); + if (style) return style; + parts.pop(); + } + return undefined; +} +const scopeFg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.fg === undefined ? undefined : colorOf(theme, style.fg); +}; +const scopeBg = (theme: HelixTheme, scope: string) => { + const style = scopeStyle(theme, scope); + return style?.bg === undefined ? undefined : colorOf(theme, style.bg); +}; +function luminance(hex: string) { + const channel = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255; + return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); +} +function mix(hex: string, other: string, amount: number) { + const channel = (i: number) => + Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount) + parseInt(other.slice(i, i + 2), 16) * amount); + return `#${[1, 3, 5].map((i) => channel(i).toString(16).padStart(2, "0")).join("")}`; +} +/** Build the painter's palette: chrome from `ui.*`, syntax by capture, change tints mixed into the background. */ +export function paletteFromHelix(theme: HelixTheme): Palette { + const bg = scopeBg(theme, "ui.background"); + const fg = scopeFg(theme, "ui.text"); + if (!bg || !fg) throw new Error(`Theme ${theme.name} lacks ui.background or ui.text`); + const isLight = luminance(bg) > 0.5; + const muted = scopeFg(theme, "ui.linenr") ?? scopeFg(theme, "comment") ?? mix(fg, bg, 0.4); + const plus = scopeFg(theme, "diff.plus") ?? (isLight ? "#1a7f37" : "#7ee787"); + const minus = scopeFg(theme, "diff.minus") ?? (isLight ? "#cf222e" : "#ffa198"); + const delta = scopeFg(theme, "diff.delta") ?? (isLight ? "#9a6700" : "#e3b341"); + const selection = scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15); + // Derived rather than read from ui.statusline, which themes paint for other jobs: onedark's + // matches its cursorline, solarized_light's is the colour of its text. + const fileHeader = mix(bg, fg, isLight ? 0.12 : 0.16); + const accent = scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg; + return { + name: theme.name, + isLight, + bg, + fg, + muted, + chrome: scopeBg(theme, "ui.statusline") ?? mix(bg, fg, 0.06), + fileHeader, + fileHeaderDir: mix(fg, fileHeader, 0.3), + highlight: selection, + addition: mix(bg, plus, 0.12), + deletion: mix(bg, minus, 0.12), + modification: mix(bg, delta, 0.12), + addWord: mix(bg, plus, 0.28), + deleteWord: mix(bg, minus, 0.28), + addedText: plus, + removedText: minus, + accent, + foldPlaceholder: mix(muted, fg, 0.45), + guide: scopeFg(theme, "ui.virtual.indent-guide") ?? mix(bg, fg, 0.22), + focusWash: mix(bg, accent, 0.08), + focusBrace: mix(bg, accent, 0.3), + syntax: (capture) => scopeFg(theme, capture), + }; +} +/** Bundled Helix themes under tui/themes; the two defaults are aliases. */ +export const bundledThemes: Record = { + "default-dark": "onedark", + "default-light": "onelight", + onedark: "onedark", + onelight: "onelight", + gruvbox: "gruvbox", + solarized_light: "solarized_light", +}; +/** The theme section of `diffr config show --json`. */ +export function themeConfig(show: unknown): { name: string; path: string | null } { + const theme = (show as { theme?: { name?: unknown; path?: unknown } }).theme; + if (!theme || typeof theme.name !== "string" || (theme.path !== null && theme.path !== undefined && typeof theme.path !== "string")) + throw new Error("diffr config show did not include a theme section"); + return { name: theme.name, path: theme.path ?? null }; +} +/** Paired folds sit on the code's own background; only one-sided folds carry a change tint. */ +export function foldBackground(theme: Palette, tint: FoldTint) { + return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion + : tint === "modified" ? theme.modification : theme.bg; +} diff --git a/diffr/tui/packages/hunk/src/diffr/regions.test.ts b/diffr/tui/packages/hunk/src/diffr/regions.test.ts index ce1b726a8..b9af2ee86 100644 --- a/diffr/tui/packages/hunk/src/diffr/regions.test.ts +++ b/diffr/tui/packages/hunk/src/diffr/regions.test.ts @@ -1,9 +1,10 @@ import { expect, test } from "bun:test"; import { createTestDiffFile, fold, leaf, line, root } from "./fixture"; import { collapsedFolds, defaultCollapsed, flatten, foldHeaders, foldIds, foldTint, gapIds, hiddenLines, pairedIds } from "./regions"; -import { dark, rowsForFile } from "./rows"; +import { rowsForFile } from "./rows"; +import { dark } from "./theme"; import type { DiffFile, Region } from "./wire"; -import { foldBackground } from "./theme"; +import { foldBackground } from "./palette"; /** Rust-style body folds: each covers its body alone, so the lines that open and close a * construct are leaves around it, like VS Code's rows. */ export function createFoldedDiffFile(): DiffFile { diff --git a/diffr/tui/packages/hunk/src/diffr/rows.test.ts b/diffr/tui/packages/hunk/src/diffr/rows.test.ts index 38917ddc6..563ce76fa 100644 --- a/diffr/tui/packages/hunk/src/diffr/rows.test.ts +++ b/diffr/tui/packages/hunk/src/diffr/rows.test.ts @@ -1,7 +1,8 @@ import { expect, test } from "bun:test"; import { createTestDiffFile, fold, leaf, line, withIdenticalLines, root } from "./fixture"; import type { Region, Span } from "./wire"; -import { captureColor, dark, light, lineSpans, rowsForFile } from "./rows"; +import { captureColor, lineSpans, rowsForFile } from "./rows"; +import { dark, light } from "./theme"; import { pairedIds } from "./regions"; import { measureRows, visibleRows } from "./geometry"; import { copySelection } from "./selection"; diff --git a/diffr/tui/packages/hunk/src/diffr/rows.ts b/diffr/tui/packages/hunk/src/diffr/rows.ts index c7f64b44a..d0af96b15 100644 --- a/diffr/tui/packages/hunk/src/diffr/rows.ts +++ b/diffr/tui/packages/hunk/src/diffr/rows.ts @@ -5,7 +5,7 @@ import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diff import { sliceSpansWindow } from "../ui/diff/styledSpanLayout"; import { measureTextWidth } from "../ui/lib/text"; import { byteColumn, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, type Fold, type FoldTint, type Leaf, type Side } from "./regions"; -import { foldBackground, loadBundledTheme, type Palette } from "./theme"; +import { foldBackground, type Palette } from "./palette"; export { sourceLines }; export type Layout = "split" | "unified"; export interface ViewerRow { @@ -21,10 +21,6 @@ export interface ViewerRow { right?: SplitLineCell; cell?: UnifiedLineCell; } -export type { Palette } from "./theme"; -/** The bundled defaults, for tests and the settings screen. */ -export const dark: Palette = loadBundledTheme("default-dark"); -export const light: Palette = loadBundledTheme("default-light"); /** Foreground for a tree-sitter capture: the theme's scope, its parents, else plain text. */ export function captureColor(capture: string, theme: Palette): string { return theme.syntax(capture) ?? theme.fg; diff --git a/diffr/tui/packages/hunk/src/diffr/stream.ts b/diffr/tui/packages/hunk/src/diffr/stream.ts index bd0eaa1b9..d27fd5a71 100644 --- a/diffr/tui/packages/hunk/src/diffr/stream.ts +++ b/diffr/tui/packages/hunk/src/diffr/stream.ts @@ -1,32 +1,12 @@ -/** Decode stdout incrementally. diffr's records are trusted as sent: only malformed JSON, an - * unknown protocol version, and a stream that ends before `complete` are errors. */ -import { eventSchema, type DiffEvent } from "./wire"; +import { parseDiffEvents } from "./events"; +import type { DiffEvent } from "./wire"; export async function* readDiffStream( chunks: AsyncIterable, ): AsyncGenerator { const decoder = new TextDecoder("utf-8", { fatal: true }); - let buffer = "", - complete = false; - function parse(line: string): DiffEvent { - if (line.length > 128 * 1024 * 1024) - throw new Error("diffr event exceeds 128 MiB"); - const event = eventSchema.parse(JSON.parse(line)); - // A stream cut short, say by a signal, ends without `complete`. - if (event.type === "complete") complete = true; - return event; + async function* texts() { + for await (const chunk of chunks) yield decoder.decode(chunk, { stream: true }); + yield decoder.decode(); } - for await (const chunk of chunks) { - buffer += decoder.decode(chunk, { stream: true }); - let end: number; - while ((end = buffer.indexOf("\n")) >= 0) { - const line = buffer.slice(0, end); - buffer = buffer.slice(end + 1); - if (line.trim()) yield parse(line); - } - if (buffer.length > 128 * 1024 * 1024) - throw new Error("diffr event exceeds 128 MiB"); - } - buffer += decoder.decode(); - if (buffer.trim()) yield parse(buffer); - if (!complete) throw new Error("Incomplete diffr stream"); + yield* parseDiffEvents(texts()); } diff --git a/diffr/tui/packages/hunk/src/diffr/theme.test.ts b/diffr/tui/packages/hunk/src/diffr/theme.test.ts index 956419ab0..ff299a453 100644 --- a/diffr/tui/packages/hunk/src/diffr/theme.test.ts +++ b/diffr/tui/packages/hunk/src/diffr/theme.test.ts @@ -1,5 +1,6 @@ import { expect, test } from "bun:test"; -import { bundledThemes, colorOf, foldBackground, loadBundledTheme, paletteFromHelix, parseHelixTheme, scopeStyle, themeConfig, themesFromConfig } from "./theme"; +import { bundledThemes, colorOf, foldBackground, paletteFromHelix, scopeStyle, themeConfig } from "./palette"; +import { loadBundledTheme, parseHelixTheme, themesFromConfig } from "./theme"; const sample = ` "keyword" = { fg = "red", modifiers = ["bold"] } "keyword.control" = { fg = "purple" } diff --git a/diffr/tui/packages/hunk/src/diffr/theme.ts b/diffr/tui/packages/hunk/src/diffr/theme.ts index da7daff1c..39517b831 100644 --- a/diffr/tui/packages/hunk/src/diffr/theme.ts +++ b/diffr/tui/packages/hunk/src/diffr/theme.ts @@ -1,174 +1,13 @@ /** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ -import type { FoldTint } from "./regions"; import { readFileSync } from "node:fs"; +import { bundledThemes, helixTheme, paletteFromHelix, type HelixTheme, type Palette } from "./palette"; import onedark from "../../../../themes/onedark.toml" with { type: "text" }; import onelight from "../../../../themes/onelight.toml" with { type: "text" }; import gruvbox from "../../../../themes/gruvbox.toml" with { type: "text" }; import solarized_light from "../../../../themes/solarized_light.toml" with { type: "text" }; -export interface Style { - fg?: string; - bg?: string; - modifiers: string[]; -} -export interface HelixTheme { - name: string; - styles: Map; - palette: Map; -} -/** Colours the row painter reads; syntax colours come from the theme by capture name. */ -export interface Palette { - name: string; - isLight: boolean; - bg: string; - fg: string; - muted: string; - /** Menu bar, status bar, and overlay chrome. */ - chrome: string; - /** A file header's surface: the one band in the diff that marks where a file starts. */ - fileHeader: string; - /** The directory half of a header's path, so the file name stands out. */ - fileHeaderDir: string; - /** Sidebar highlight for the active file. */ - highlight: string; - addition: string; - deletion: string; - modification: string; - addWord: string; - deleteWord: string; - addedText: string; - removedText: string; - /** An accent for interactive text such as links and the layout badge. */ - accent: string; - /** VS Code's editor.foldPlaceholderForeground. */ - foldPlaceholder: string; - guide: string; - /** The body of a scope about to fold, while the pointer is on its rail or chevron. */ - focusWash: string; - /** VS Code's bracket-match box, on the brackets of a scope about to fold. */ - focusBrace: string; - /** Foreground for a tree-sitter capture such as `keyword.return`; undefined when the theme has no scope for it. */ - syntax: (capture: string) => string | undefined; -} -/** Helix's named terminal colours, used when a theme writes `fg = "red"` outside its palette. */ -const ansi: Record = { - black: "#000000", red: "#cd3131", green: "#0dbc79", yellow: "#e5e510", blue: "#2472c8", - magenta: "#bc3fbc", cyan: "#11a8cd", white: "#e5e5e5", gray: "#666666", grey: "#666666", - "light-red": "#f14c4c", "light-green": "#23d18b", "light-yellow": "#f5f543", "light-blue": "#3b8eea", - "light-magenta": "#d670d6", "light-cyan": "#29b8db", "light-white": "#ffffff", "light-gray": "#a0a0a0", - "light-grey": "#a0a0a0", "light-black": "#333333", -}; -interface RawStyle { - fg?: string; - bg?: string; - modifiers?: string[]; -} export function parseHelixTheme(text: string, name: string): HelixTheme { - const raw = Bun.TOML.parse(text) as Record; - const palette = new Map(); - const rawPalette = raw.palette; - if (rawPalette && typeof rawPalette === "object") - for (const [key, value] of Object.entries(rawPalette as Record)) { - if (typeof value !== "string") throw new Error(`Theme ${name}: palette entry ${key} is not a colour`); - palette.set(key, value); - } - const styles = new Map(); - for (const [scope, value] of Object.entries(raw)) { - if (scope === "palette" || scope === "inherits") continue; - if (typeof value === "string") styles.set(scope, { fg: value, modifiers: [] }); - else if (value && typeof value === "object") { - const style = value as RawStyle; - styles.set(scope, { fg: style.fg, bg: style.bg, modifiers: style.modifiers ?? [] }); - } else throw new Error(`Theme ${name}: scope ${scope} has an unsupported value`); - } - if (typeof raw.inherits === "string") - throw new Error(`Theme ${name} inherits from ${raw.inherits}; inherited themes are not supported`); - return { name, styles, palette }; -} -/** A palette name, a hex colour, or one of Helix's terminal colour names. */ -export function colorOf(theme: HelixTheme, value: string): string { - const fromPalette = theme.palette.get(value); - if (fromPalette !== undefined) return colorOf(theme, fromPalette); - if (/^#[0-9a-fA-F]{6}$/.test(value)) return value; - const named = ansi[value]; - if (named) return named; - throw new Error(`Theme ${theme.name}: unknown colour ${value}`); -} -/** The style for a scope, falling back to its parent scopes: `keyword.return` → `keyword`. */ -export function scopeStyle(theme: HelixTheme, scope: string): Style | undefined { - const parts = scope.split("."); - while (parts.length) { - const style = theme.styles.get(parts.join(".")); - if (style) return style; - parts.pop(); - } - return undefined; + return helixTheme(Bun.TOML.parse(text) as Record, name); } -const scopeFg = (theme: HelixTheme, scope: string) => { - const style = scopeStyle(theme, scope); - return style?.fg === undefined ? undefined : colorOf(theme, style.fg); -}; -const scopeBg = (theme: HelixTheme, scope: string) => { - const style = scopeStyle(theme, scope); - return style?.bg === undefined ? undefined : colorOf(theme, style.bg); -}; -function luminance(hex: string) { - const channel = (i: number) => parseInt(hex.slice(i, i + 2), 16) / 255; - return 0.2126 * channel(1) + 0.7152 * channel(3) + 0.0722 * channel(5); -} -function mix(hex: string, other: string, amount: number) { - const channel = (i: number) => - Math.round(parseInt(hex.slice(i, i + 2), 16) * (1 - amount) + parseInt(other.slice(i, i + 2), 16) * amount); - return `#${[1, 3, 5].map((i) => channel(i).toString(16).padStart(2, "0")).join("")}`; -} -/** Build the painter's palette: chrome from `ui.*`, syntax by capture, change tints mixed into the background. */ -export function paletteFromHelix(theme: HelixTheme): Palette { - const bg = scopeBg(theme, "ui.background"); - const fg = scopeFg(theme, "ui.text"); - if (!bg || !fg) throw new Error(`Theme ${theme.name} lacks ui.background or ui.text`); - const isLight = luminance(bg) > 0.5; - const muted = scopeFg(theme, "ui.linenr") ?? scopeFg(theme, "comment") ?? mix(fg, bg, 0.4); - const plus = scopeFg(theme, "diff.plus") ?? (isLight ? "#1a7f37" : "#7ee787"); - const minus = scopeFg(theme, "diff.minus") ?? (isLight ? "#cf222e" : "#ffa198"); - const delta = scopeFg(theme, "diff.delta") ?? (isLight ? "#9a6700" : "#e3b341"); - const selection = scopeBg(theme, "ui.selection") ?? mix(bg, fg, 0.15); - // Derived rather than read from ui.statusline, which themes paint for other jobs: onedark's - // matches its cursorline, solarized_light's is the colour of its text. - const fileHeader = mix(bg, fg, isLight ? 0.12 : 0.16); - const accent = scopeFg(theme, "function") ?? scopeFg(theme, "ui.text.focus") ?? fg; - return { - name: theme.name, - isLight, - bg, - fg, - muted, - chrome: scopeBg(theme, "ui.statusline") ?? mix(bg, fg, 0.06), - fileHeader, - fileHeaderDir: mix(fg, fileHeader, 0.3), - highlight: selection, - addition: mix(bg, plus, 0.12), - deletion: mix(bg, minus, 0.12), - modification: mix(bg, delta, 0.12), - addWord: mix(bg, plus, 0.28), - deleteWord: mix(bg, minus, 0.28), - addedText: plus, - removedText: minus, - accent, - foldPlaceholder: mix(muted, fg, 0.45), - guide: scopeFg(theme, "ui.virtual.indent-guide") ?? mix(bg, fg, 0.22), - focusWash: mix(bg, accent, 0.08), - focusBrace: mix(bg, accent, 0.3), - syntax: (capture) => scopeFg(theme, capture), - }; -} -/** Bundled Helix themes under tui/themes; the two defaults are aliases. */ -export const bundledThemes: Record = { - "default-dark": "onedark", - "default-light": "onelight", - onedark: "onedark", - onelight: "onelight", - gruvbox: "gruvbox", - solarized_light: "solarized_light", -}; const themeSources: Record = { onedark, onelight, gruvbox, solarized_light }; export function loadBundledTheme(name: string): Palette { const file = bundledThemes[name]; @@ -189,15 +28,6 @@ export function themesFromConfig(config: { name: string; path: string | null }): const initial = config.path ? loadThemeFile(config.path) : loadBundledTheme(config.name); return { initial, dark, light }; } -export function themeConfig(show: unknown): { name: string; path: string | null } { - const theme = (show as { theme?: { name?: unknown; path?: unknown } }).theme; - if (!theme || typeof theme.name !== "string" || (theme.path !== null && theme.path !== undefined && typeof theme.path !== "string")) - throw new Error("diffr config show did not include a theme section"); - return { name: theme.name, path: theme.path ?? null }; -} - -/** Paired folds sit on the code's own background; only one-sided folds carry a change tint. */ -export function foldBackground(theme: Palette, tint: FoldTint) { - return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion - : tint === "modified" ? theme.modification : theme.bg; -} +/** The bundled defaults, for tests and the settings screen. */ +export const dark: Palette = loadBundledTheme("default-dark"); +export const light: Palette = loadBundledTheme("default-light"); diff --git a/diffr/tui/packages/hunk/src/main.tsx b/diffr/tui/packages/hunk/src/main.tsx index db6f933b8..4a1320216 100644 --- a/diffr/tui/packages/hunk/src/main.tsx +++ b/diffr/tui/packages/hunk/src/main.tsx @@ -8,7 +8,8 @@ import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; import { DiffStore } from "./diffr/store"; import { cliClient, flattenSchema, type Setting } from "./diffr/config"; -import { loadBundledTheme, loadThemeFile, themeConfig, themesFromConfig, type ThemeSet } from "./diffr/theme"; +import { loadBundledTheme, loadThemeFile, themesFromConfig, type ThemeSet } from "./diffr/theme"; +import { themeConfig } from "./diffr/palette"; import { App } from "./ui/App"; import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), diff --git a/diffr/tui/packages/hunk/src/ui/App.tsx b/diffr/tui/packages/hunk/src/ui/App.tsx index 5e34ca44a..b04101e29 100644 --- a/diffr/tui/packages/hunk/src/ui/App.tsx +++ b/diffr/tui/packages/hunk/src/ui/App.tsx @@ -22,7 +22,8 @@ import { type Layout, type ViewerRow, } from "../diffr/rows"; -import type { Palette, ThemeSet } from "../diffr/theme"; +import type { Palette } from "../diffr/palette"; +import type { ThemeSet } from "../diffr/theme"; import { measureRows, visibleRows, positionAt, positionTop, rowFold, type ViewPosition } from "../diffr/geometry"; import { copySelection, diff --git a/diffr/tui/packages/hunk/src/ui/Settings.tsx b/diffr/tui/packages/hunk/src/ui/Settings.tsx index 6b59b0b23..519964775 100644 --- a/diffr/tui/packages/hunk/src/ui/Settings.tsx +++ b/diffr/tui/packages/hunk/src/ui/Settings.tsx @@ -11,7 +11,7 @@ import { type ConfigClient, type Setting, } from "../diffr/config"; -import { dark } from "../diffr/rows"; +import { dark } from "../diffr/theme"; import { sliceTextByWidth } from "./lib/text"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, Math.max(0, width)).text; diff --git a/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 29dc80659..87cd9fa90 100644 --- a/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -8,7 +8,7 @@ import type { UnifiedLineCell, } from "./diffRowModel"; import type { Geometry, MeasuredRow } from "../../diffr/geometry"; -import { foldBackground, type Palette } from "../../diffr/theme"; +import { foldBackground, type Palette } from "../../diffr/palette"; import type { RowFold } from "../../diffr/regions"; import { measureTextWidth } from "../lib/text"; const colors = new Map>(); From acf3882a722654274d7d74b7c57a2992e5102336 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:37 -0700 Subject: [PATCH 02/25] TUI: light closed folds under the pointer and keep pseudocode beside its rail (AI-assisted) A collapsed fold's chevron and label arm it, so its chevron takes the accent as an open scope's does. A syntax body folded behind a multi-line summary keeps its chevron on the opener, stands the summary beside the scope's own rail, and boxes its brackets while armed, as the Whiteboard client does. Label lines name their fold with labelOf, so clicking the summary opens it. Agent-Session: 01a118c6-60a6-7373-bad2-7f763da2c3c2 Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- .../packages/hunk/src/diffr/regions.test.ts | 14 ------ diffr/tui/packages/hunk/src/diffr/rows.ts | 47 +++++++++++++------ .../packages/hunk/src/ui/Interaction.test.tsx | 22 +++++++++ .../packages/hunk/src/ui/diff/CodeRowView.tsx | 17 ++++--- .../packages/hunk/src/ui/diff/diffRowModel.ts | 3 ++ 5 files changed, 68 insertions(+), 35 deletions(-) diff --git a/diffr/tui/packages/hunk/src/diffr/regions.test.ts b/diffr/tui/packages/hunk/src/diffr/regions.test.ts index b9af2ee86..fcc238799 100644 --- a/diffr/tui/packages/hunk/src/diffr/regions.test.ts +++ b/diffr/tui/packages/hunk/src/diffr/regions.test.ts @@ -122,20 +122,6 @@ test("a multi-line label uses the declared enclosing indent inside the fold tint const unified = rowsForFile(file, 0, "unified", dark, new Set([11])); expect(unified.filter((r) => r.cell?.foldLabel)).toHaveLength(3); }); -test("a syntax body with a multi-line label is quoted between its opener and closer", () => { - const file = createFoldedDiffFile(); - if (file.diff.type !== "text") throw new Error(); - for (const source of [file.diff.lhs!, file.diff.rhs!]) { - const closure = source.root.children[1].children[1]; - if (closure.kind !== "fold") throw new Error(); - closure.visibility = { collapsed: true, label: "call a\ncall b" }; - closure.syntax = { start: { line: 1, column: 13 }, end: { line: 4, column: 4 } }; - } - const rows = rowsForFile(file, 0, "split", dark, new Set([11])).filter((r) => r.right); - const text = (r: (typeof rows)[number]) => r.right!.spans.map((s) => s.text).join(""); - expect(rows.slice(1, 5).map(text)).toEqual([" inner(|| {", " > call a · 1 line changed", " > call b", " });"]); - expect(rows[2].right!.fold?.id).toBe(11); -}); test("a collapsed leaf is one fold row with the chevron, its label, and no line number", () => { const file = createTestDiffFile(); if (file.diff.type !== "text") throw new Error(); diff --git a/diffr/tui/packages/hunk/src/diffr/rows.ts b/diffr/tui/packages/hunk/src/diffr/rows.ts index d0af96b15..e30132d74 100644 --- a/diffr/tui/packages/hunk/src/diffr/rows.ts +++ b/diffr/tui/packages/hunk/src/diffr/rows.ts @@ -103,6 +103,15 @@ export function rowsForFile( // with a one-line label instead joins its opener, label and closing suffix on the opener's // line, so its closer is masked too. const bands = folds.map(side => collapsedFolds(side, collapsed)); + // A syntax fold with a multi-line label shows it between its opener and closer lines. + const quoted = bands.map((sideBands) => { + const result = new Map(); + for (const fold of sideBands.values()) + if (fold.syntax && fold.label.includes("\n") && fold.syntax.start.line < fold.startLine) + result.set(fold.syntax.start.line, fold as SyntaxFold); + return result; + }); + const isQuoted = (region: Leaf | Fold): region is SyntaxFold => quoted[region.side].get((region as Fold).syntax?.start.line ?? -1) === region; const inline = bands.map((sideBands, side) => { const result = new Map(); for (const fold of sideBands.values()) { @@ -150,9 +159,16 @@ export function rowsForFile( body: around.filter(fold => fold.syntax.start.line < line && line < fold.syntax.end.line).map(fold => fold.foldStateId) }; }; // The opener is the byte before a scope's syntax range and the closer the byte at its end. + // Quoted folds keep their brackets on the visible opener and closer. + const quotedEnds = quoted.map((side) => { + const lines = new Map(); + for (const fold of side.values()) + for (const line of [fold.syntax.start.line, fold.syntax.end.line]) lines.set(line, [...(lines.get(line) ?? []), fold]); + return lines; + }); const withBraces = (spans: RenderSpan[], side: Side, line: number) => { let result = spans; - for (const fold of scopes[side].get(line) ?? []) { + for (const fold of [...(scopes[side].get(line) ?? []), ...(quotedEnds[side].get(line) ?? [])]) { const { start, end } = fold.syntax, text = texts[side][line]!; if (start.line === line && start.column > 0) result = markBrace(result, byteColumn(text, start.column - 1), fold.foldStateId); if (end.line === line) result = markBrace(result, byteColumn(text, end.column), fold.foldStateId); @@ -198,6 +214,8 @@ export function rowsForFile( if (line === null || leaf === null) return empty; let spans = withBraces(spansOf(side, line, leaf), side, line); let fold = headers[side].get(line); + const opens = quoted[side].get(line); + if (opens) fold = { id: opens.foldStateId, label: opens.label, collapsed: true, tint: collapsedTint(opens).tint }; const folded = inline[side].get(line); if (folded) { const { start, end } = folded.syntax; @@ -229,27 +247,28 @@ export function rowsForFile( // A collapsed region is one row: chevron and label, no line number, whether the region is a // fold or a leaf the context plugin cut out. It starts at its parent's indent; a multi-line // label (pseudocode) hangs under it at that indent, one row per line. A syntax body with a - // multi-line label is instead quoted between its opener and closer, at the body's indent. + // multi-line label is instead quoted between its opener and closer, at the body's indent beside + // the scope's rail. const band = (region: Leaf | Fold) => { const { tint, note } = collapsedTint(region); const multiline = region.label.includes("\n"); - const quoted = "syntax" in region && region.syntax !== undefined && multiline; const body = texts[region.side][region.startLine]!; - const column = quoted ? byteColumn(body, body.length - body.trimStart().length) : region.parentColumn; - const lead = (text: string) => withGuides([{ text: " ".repeat(column) }, placeholder(text, tint)], - guides[region.side].get(region.startLine) ?? [], theme); - if (quoted) { - const [first, ...rest] = region.label.split("\n"); - const header = { kind: "context" as const, sign: " ", band: tint, spans: lead(`> ${first}${note}`), - fold: { id: region.foldStateId, label: region.label, collapsed: true, tint }, ...scopeOf(region.side, region.startLine) }; - return { header, labels: rest.map(text => ({ ...header, foldLabel: true, spans: lead(`> ${text}`), fold: undefined })) }; + const enclosing = guides[region.side].get(region.startLine) ?? []; + const label = { kind: "context" as const, sign: " ", band: tint, foldLabel: true, labelOf: region.foldStateId, + ...scopeOf(region.side, region.startLine) }; + if (isQuoted(region)) { + const opener = texts[region.side][region.syntax.start.line]!; + const rail = { column: byteColumn(opener, opener.length - opener.trimStart().length), id: region.foldStateId }; + const lead = (text: string) => withGuides([{ text: " ".repeat(byteColumn(body, body.length - body.trimStart().length)) }, + placeholder(text, tint)], [...enclosing, rail], theme); + const [header, ...labels] = region.label.split("\n").map((text, i) => ({ ...label, spans: lead(i ? text : `${text}${note}`) })); + return { header: header!, labels }; } + const lead = (text: string) => withGuides([{ text: " ".repeat(region.parentColumn) }, placeholder(text, tint)], enclosing, theme); const header = { kind: "context" as const, sign: " ", band: tint, spans: lead(`⋯${multiline ? "" : " " + labelOf(region)}${note}`), fold: { id: region.foldStateId, label: region.label, collapsed: true, tint }, ...scopeOf(region.side, region.startLine) }; - const labels = multiline - ? region.label.split("\n").map(text => ({ ...header, foldLabel: true, spans: lead(text), fold: undefined })) - : []; + const labels = multiline ? region.label.split("\n").map(text => ({ ...label, spans: lead(text) })) : []; return { header, labels }; }; const collapsedRow = (kind: string, leftRegion: Leaf | Fold | null, rightRegion: Leaf | Fold | null) => { diff --git a/diffr/tui/packages/hunk/src/ui/Interaction.test.tsx b/diffr/tui/packages/hunk/src/ui/Interaction.test.tsx index 391f71af1..d2bf48d93 100644 --- a/diffr/tui/packages/hunk/src/ui/Interaction.test.tsx +++ b/diffr/tui/packages/hunk/src/ui/Interaction.test.tsx @@ -96,6 +96,28 @@ test("Paper folds stay under the mouse, pair both sides, accent guides, and surv expect(frame()).toContain("open(true)"); } finally { await act(async () => { t.renderer.destroy(); }); } }); +test("a closed scope's pseudocode stands beside its rail: pointing at it arms the scope, and a click opens it", async () => { + const store = new DiffStore(), file = createGuideDiffFile(); + if (file.diff.type !== "text") throw new Error(); + for (const source of [file.diff.lhs!, file.diff.rhs!]) + source.root.children[1]!.children[3]!.visibility = {collapsed: true, label: "call one\ncall two"}; + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:170, height:25}); + const lines = () => t.captureCharFrame().split("\n"); + try { + await act(async () => { await t.renderOnce(); }); + const header = lines().findIndex(l => l.includes("fn other() {")); + expect(lines()[header].slice(0, 40)).toContain("▸"); + const y = lines().findIndex(l => l.includes("call one")); + expect(lines()[y]).not.toContain("┃"); + await act(async () => { await t.mockMouse.moveTo(lines()[y].indexOf("call one") + 2, y); await t.renderOnce(); }); + await t.waitFor(() => lines()[y].includes("┃") && lines()[y + 1].includes("┃")); + expect(lines()[y]).toContain("┃"); + await act(async () => { await t.mockMouse.click(lines()[y].indexOf("call one") + 2, y); await t.renderOnce(); }); + await t.waitForFrame(f => f.includes("one();")); + expect(t.captureCharFrame()).not.toContain("call one"); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); test("pointing at a rail arms that scope, even an outer one, and clicking the rail folds it", async () => { const store = new DiffStore(), file = createGuideDiffFile(); store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); diff --git a/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx index 87cd9fa90..de85220ea 100644 --- a/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx +++ b/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx @@ -106,22 +106,25 @@ export const CodeRowView = memo(function CodeRowView({ const painted = value.band && rest > 0 ? [...spans, {text: (value.fold ? " " + "┄".repeat(rest) : " ".repeat(rest)).slice(0, rest), fg: theme.guide, bg: foldBackground(theme, value.band)}] : spans; - // What the pointer is on. A rail or the chevron arms its scope, so a click folds it; anywhere - // else on the row reads the innermost scope around the line. + // What the pointer is on. A rail or the chevron arms its scope, so a click folds it, and a + // collapsed fold's chevron or label arms that fold, so a click opens it; anywhere else on the + // row reads the innermost scope around the line. const chevronColumn = numbers.length, codeColumn = numbers.length + 2; + const opens = fold?.collapsed ? fold.id : value.labelOf; const rail = (event: MouseEvent) => { const column = event.x - (event.currentTarget?.x ?? 0); return column >= codeColumn ? railAt(painted, column - codeColumn) : undefined; }; const target = (event: MouseEvent): ScopeFocus | undefined => { const column = event.x - (event.currentTarget?.x ?? 0); - const id = column === chevronColumn && fold && !fold.collapsed && !visualLine ? fold.id : rail(event); + const id = column === chevronColumn && fold && !visualLine ? fold.id + : rail(event) ?? (column >= codeColumn ? opens : undefined); if (id !== undefined) return { id, armed: true }; return value.scope === undefined ? undefined : { id: value.scope, armed: false }; }; // Open chevrons rest faint so they don't compete with the code; a collapsed one stays - // legible, since it is the way back in, and the focused scope's lights up. - const chevronFg = fold && !fold.collapsed && fold.id === focus?.id ? theme.accent + // legible, since it is the way back in, and the focused scope's lights up, open or not. + const chevronFg = fold && fold.id === focus?.id ? theme.accent : fold?.collapsed ? theme.muted : theme.guide; return ( { if (fold?.collapsed) event.stopPropagation(); }} + onMouseDown={event => { if (opens !== undefined) event.stopPropagation(); }} onMouseUp={event => { - if (fold?.collapsed && event.button === 0) { event.stopPropagation(); onFold(fold.id, event.modifiers.alt); } + if (opens !== undefined && event.button === 0) { event.stopPropagation(); onFold(opens, event.modifiers.alt); } }} content={styled( selectedSide === side ? painted.map((s) => ({ ...s, bg })) : painted, diff --git a/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts index 93d8a84b5..e077e4bb0 100644 --- a/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts +++ b/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts @@ -28,6 +28,8 @@ export interface SplitLineCell { fold?: RowFold; /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ foldLabel?: boolean; + /** On a label line, its fold's fold-state id: a click opens that fold. */ + labelOf?: number; /** A collapsed row or label line: its tint fills the rest of the line. */ band?: FoldTint; /** The innermost open scope holding this line, its opener and closer included. */ @@ -44,6 +46,7 @@ export interface UnifiedLineCell { newLineNumber?: number; fold?: RowFold; foldLabel?: boolean; + labelOf?: number; band?: FoldTint; scope?: number; body?: number[]; From f87b78396011d0918a7426db58617c0f906a12fd Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:38 -0700 Subject: [PATCH 03/25] Move the TUI under diffr/packages (AI-assisted) The TUI moves from tui/ to packages/tui, with Hunk's packages/hunk/src flattened to src. xtask, the release archives and their smoke test, NOTICE and the diffr-ts fixture path follow. Release archives keep mirroring the repository's license paths, as NOTICE cites them. No code changes. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/NOTICE | 8 ++++---- diffr/TUI_UPSTREAM.md | 2 +- diffr/diffr-ts/README.md | 2 +- diffr/diffr-ts/src/contract.test.ts | 2 +- diffr/{ => packages}/tui/.gitignore | 0 diffr/{ => packages}/tui/CLAUDE.md | 0 diffr/{ => packages}/tui/LICENSE | 0 diffr/{ => packages}/tui/bun.lock | 0 diffr/{ => packages}/tui/package.json | 4 ++-- diffr/{tui/packages/hunk => packages/tui}/src/assets.d.ts | 0 .../hunk => packages/tui}/src/diffr/config.test.ts | 0 .../packages/hunk => packages/tui}/src/diffr/config.ts | 0 .../hunk => packages/tui}/src/diffr/counts.test.ts | 0 .../packages/hunk => packages/tui}/src/diffr/counts.ts | 0 .../packages/hunk => packages/tui}/src/diffr/events.ts | 0 .../hunk => packages/tui}/src/diffr/fileTree.test.ts | 0 .../packages/hunk => packages/tui}/src/diffr/fileTree.ts | 0 .../packages/hunk => packages/tui}/src/diffr/fixture.ts | 0 .../packages/hunk => packages/tui}/src/diffr/geometry.ts | 0 .../hunk => packages/tui}/src/diffr/guides.test.ts | 0 .../packages/hunk => packages/tui}/src/diffr/palette.ts | 2 +- .../hunk => packages/tui}/src/diffr/regions.test.ts | 0 .../packages/hunk => packages/tui}/src/diffr/regions.ts | 0 .../packages/hunk => packages/tui}/src/diffr/rows.test.ts | 0 .../{tui/packages/hunk => packages/tui}/src/diffr/rows.ts | 0 .../packages/hunk => packages/tui}/src/diffr/selection.ts | 0 .../hunk => packages/tui}/src/diffr/store.test.ts | 0 .../packages/hunk => packages/tui}/src/diffr/store.ts | 0 .../hunk => packages/tui}/src/diffr/stream.test.ts | 2 +- .../packages/hunk => packages/tui}/src/diffr/stream.ts | 0 .../hunk => packages/tui}/src/diffr/theme.test.ts | 0 .../packages/hunk => packages/tui}/src/diffr/theme.ts | 8 ++++---- .../{tui/packages/hunk => packages/tui}/src/diffr/wire.ts | 0 .../hunk => packages/tui}/src/lib/terminalText.test.ts | 0 .../hunk => packages/tui}/src/lib/terminalText.ts | 0 diffr/{tui/packages/hunk => packages/tui}/src/main.tsx | 0 .../packages/hunk => packages/tui}/src/shutdown.test.ts | 0 diffr/{tui/packages/hunk => packages/tui}/src/shutdown.ts | 0 .../packages/hunk => packages/tui}/src/ui/App.test.tsx | 0 diffr/{tui/packages/hunk => packages/tui}/src/ui/App.tsx | 0 .../hunk => packages/tui}/src/ui/Interaction.test.tsx | 0 .../hunk => packages/tui}/src/ui/Settings.test.tsx | 0 .../packages/hunk => packages/tui}/src/ui/Settings.tsx | 0 .../hunk => packages/tui}/src/ui/diff/CodeRowView.tsx | 0 .../hunk => packages/tui}/src/ui/diff/diffRowModel.ts | 0 .../tui}/src/ui/diff/rowWindowing.test.ts | 0 .../hunk => packages/tui}/src/ui/diff/rowWindowing.ts | 0 .../hunk => packages/tui}/src/ui/diff/styledSpanLayout.ts | 0 .../packages/hunk => packages/tui}/src/ui/lib/keys.ts | 0 .../packages/hunk => packages/tui}/src/ui/lib/sidebar.ts | 0 .../packages/hunk => packages/tui}/src/ui/lib/text.ts | 0 diffr/{ => packages}/tui/test/fixtures/comparison.ndjson | 0 diffr/{ => packages}/tui/test/review-smoke.tsx | 8 ++++---- diffr/{ => packages}/tui/themes/LICENSE | 0 diffr/{ => packages}/tui/themes/README.md | 0 diffr/{ => packages}/tui/themes/gruvbox.toml | 0 diffr/{ => packages}/tui/themes/onedark.toml | 0 diffr/{ => packages}/tui/themes/onelight.toml | 0 diffr/{ => packages}/tui/themes/solarized_light.toml | 0 diffr/{ => packages}/tui/tsconfig.json | 2 +- diffr/scripts/release.py | 6 +++--- diffr/xtask/src/main.rs | 6 +++--- 62 files changed, 26 insertions(+), 26 deletions(-) rename diffr/{ => packages}/tui/.gitignore (100%) rename diffr/{ => packages}/tui/CLAUDE.md (100%) rename diffr/{ => packages}/tui/LICENSE (100%) rename diffr/{ => packages}/tui/bun.lock (100%) rename diffr/{ => packages}/tui/package.json (84%) rename diffr/{tui/packages/hunk => packages/tui}/src/assets.d.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/config.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/config.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/counts.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/counts.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/events.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/fileTree.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/fileTree.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/fixture.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/geometry.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/guides.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/palette.ts (98%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/regions.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/regions.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/rows.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/rows.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/selection.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/store.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/store.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/stream.test.ts (98%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/stream.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/theme.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/theme.ts (82%) rename diffr/{tui/packages/hunk => packages/tui}/src/diffr/wire.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/lib/terminalText.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/lib/terminalText.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/main.tsx (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/shutdown.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/shutdown.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/App.test.tsx (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/App.tsx (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/Interaction.test.tsx (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/Settings.test.tsx (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/Settings.tsx (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/diff/CodeRowView.tsx (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/diff/diffRowModel.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/diff/rowWindowing.test.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/diff/rowWindowing.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/diff/styledSpanLayout.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/lib/keys.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/lib/sidebar.ts (100%) rename diffr/{tui/packages/hunk => packages/tui}/src/ui/lib/text.ts (100%) rename diffr/{ => packages}/tui/test/fixtures/comparison.ndjson (100%) rename diffr/{ => packages}/tui/test/review-smoke.tsx (93%) rename diffr/{ => packages}/tui/themes/LICENSE (100%) rename diffr/{ => packages}/tui/themes/README.md (100%) rename diffr/{ => packages}/tui/themes/gruvbox.toml (100%) rename diffr/{ => packages}/tui/themes/onedark.toml (100%) rename diffr/{ => packages}/tui/themes/onelight.toml (100%) rename diffr/{ => packages}/tui/themes/solarized_light.toml (100%) rename diffr/{ => packages}/tui/tsconfig.json (77%) diff --git a/diffr/NOTICE b/diffr/NOTICE index 94d41b2e5..fd4ae2a35 100644 --- a/diffr/NOTICE +++ b/diffr/NOTICE @@ -5,14 +5,14 @@ difftastic Copyright (c) 2021-2025 Wilfred Hughes. MIT License (see LICENSE). diffr forked difftastic at commit 274d0a8f5 (2026-09-08). -hunk (terminal frontend under tui/) +hunk (terminal frontend under packages/tui/) https://github.com/modem-dev/hunk - Copyright (c) Modem Labs Inc. MIT License (see tui/LICENSE). + Copyright (c) Modem Labs Inc. MIT License (see packages/tui/LICENSE). Import details in TUI_UPSTREAM.md. -Helix editor themes (tui/themes/) +Helix editor themes (packages/tui/themes/) https://github.com/helix-editor/helix/tree/master/runtime/themes - Mozilla Public License 2.0 (see tui/themes/LICENSE). + Mozilla Public License 2.0 (see packages/tui/themes/LICENSE). GitHub Linguist language data (plugins/classify/rust/src/linguist/) https://github.com/github-linguist/linguist diff --git a/diffr/TUI_UPSTREAM.md b/diffr/TUI_UPSTREAM.md index 210f78e52..9caad90ff 100644 --- a/diffr/TUI_UPSTREAM.md +++ b/diffr/TUI_UPSTREAM.md @@ -1,7 +1,7 @@ # Terminal frontend provenance Imported unchanged from https://github.com/modem-dev/hunk at f86a04ed0325e641d7d96067992f14849016ec8b. -Upstream MIT license is retained in tui/LICENSE. +Upstream MIT license is retained in packages/tui/LICENSE. Import and subsequent refactoring performed with AI assistance. Additional restored primitives: `ui/lib/sidebar.ts` is copied unchanged from Hunk; diff --git a/diffr/diffr-ts/README.md b/diffr/diffr-ts/README.md index 4fe41c8e5..c1117b454 100644 --- a/diffr/diffr-ts/README.md +++ b/diffr/diffr-ts/README.md @@ -21,7 +21,7 @@ Keep these files in sync: |---|---| | `crates/diffr-core/src/protocol/mod.rs`, `crates/diffr-core/src/pairing.rs`, `diffr-ts/src/contract.ts` | Contract tests against the binary; Rust version check | | `docs/streaming.md` | Review against the wire format | -| `tui/packages/hunk/src/diffr/wire.ts` (v3 only) | TUI fixture tests | +| `packages/tui/src/diffr/wire.ts` (v3 only) | TUI fixture tests | | `../packages/review-protocol/package.json` and `../packages/review/package.json` | Exact package version pins | ## Release diff --git a/diffr/diffr-ts/src/contract.test.ts b/diffr/diffr-ts/src/contract.test.ts index 88e68061a..0eadf9f8d 100644 --- a/diffr/diffr-ts/src/contract.test.ts +++ b/diffr/diffr-ts/src/contract.test.ts @@ -88,7 +88,7 @@ describe("every record the binary writes validates", () => { }); test("the terminal UI's committed v3 fixture still validates", () => { - const text = readFileSync(join(repositoryRoot, "tui/test/fixtures/comparison.ndjson"), "utf8"); + const text = readFileSync(join(repositoryRoot, "packages/tui/test/fixtures/comparison.ndjson"), "utf8"); const events = text.split("\n").filter((line) => line.length > 0).map(decodeStructuralDiffEvent); expect(events[0]).toMatchObject({ type: "start", version: STRUCTURAL_DIFF_WIRE_VERSION }); expect(events.at(-1)).toMatchObject({ type: "complete", failed: 0 }); diff --git a/diffr/tui/.gitignore b/diffr/packages/tui/.gitignore similarity index 100% rename from diffr/tui/.gitignore rename to diffr/packages/tui/.gitignore diff --git a/diffr/tui/CLAUDE.md b/diffr/packages/tui/CLAUDE.md similarity index 100% rename from diffr/tui/CLAUDE.md rename to diffr/packages/tui/CLAUDE.md diff --git a/diffr/tui/LICENSE b/diffr/packages/tui/LICENSE similarity index 100% rename from diffr/tui/LICENSE rename to diffr/packages/tui/LICENSE diff --git a/diffr/tui/bun.lock b/diffr/packages/tui/bun.lock similarity index 100% rename from diffr/tui/bun.lock rename to diffr/packages/tui/bun.lock diff --git a/diffr/tui/package.json b/diffr/packages/tui/package.json similarity index 84% rename from diffr/tui/package.json rename to diffr/packages/tui/package.json index 68908f639..2ccd4d553 100644 --- a/diffr/tui/package.json +++ b/diffr/packages/tui/package.json @@ -4,9 +4,9 @@ "license": "MIT", "type": "module", "scripts": { - "start": "bun run packages/hunk/src/main.tsx", + "start": "bun run src/main.tsx", "typecheck": "tsc --noEmit", - "test": "bun test packages" + "test": "bun test src" }, "dependencies": { "@opentui/core": "0.5.6", diff --git a/diffr/tui/packages/hunk/src/assets.d.ts b/diffr/packages/tui/src/assets.d.ts similarity index 100% rename from diffr/tui/packages/hunk/src/assets.d.ts rename to diffr/packages/tui/src/assets.d.ts diff --git a/diffr/tui/packages/hunk/src/diffr/config.test.ts b/diffr/packages/tui/src/diffr/config.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/config.test.ts rename to diffr/packages/tui/src/diffr/config.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/config.ts b/diffr/packages/tui/src/diffr/config.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/config.ts rename to diffr/packages/tui/src/diffr/config.ts diff --git a/diffr/tui/packages/hunk/src/diffr/counts.test.ts b/diffr/packages/tui/src/diffr/counts.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/counts.test.ts rename to diffr/packages/tui/src/diffr/counts.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/counts.ts b/diffr/packages/tui/src/diffr/counts.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/counts.ts rename to diffr/packages/tui/src/diffr/counts.ts diff --git a/diffr/tui/packages/hunk/src/diffr/events.ts b/diffr/packages/tui/src/diffr/events.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/events.ts rename to diffr/packages/tui/src/diffr/events.ts diff --git a/diffr/tui/packages/hunk/src/diffr/fileTree.test.ts b/diffr/packages/tui/src/diffr/fileTree.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/fileTree.test.ts rename to diffr/packages/tui/src/diffr/fileTree.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/fileTree.ts b/diffr/packages/tui/src/diffr/fileTree.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/fileTree.ts rename to diffr/packages/tui/src/diffr/fileTree.ts diff --git a/diffr/tui/packages/hunk/src/diffr/fixture.ts b/diffr/packages/tui/src/diffr/fixture.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/fixture.ts rename to diffr/packages/tui/src/diffr/fixture.ts diff --git a/diffr/tui/packages/hunk/src/diffr/geometry.ts b/diffr/packages/tui/src/diffr/geometry.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/geometry.ts rename to diffr/packages/tui/src/diffr/geometry.ts diff --git a/diffr/tui/packages/hunk/src/diffr/guides.test.ts b/diffr/packages/tui/src/diffr/guides.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/guides.test.ts rename to diffr/packages/tui/src/diffr/guides.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/palette.ts b/diffr/packages/tui/src/diffr/palette.ts similarity index 98% rename from diffr/tui/packages/hunk/src/diffr/palette.ts rename to diffr/packages/tui/src/diffr/palette.ts index 6b42fea4c..4c36ae3c9 100644 --- a/diffr/tui/packages/hunk/src/diffr/palette.ts +++ b/diffr/packages/tui/src/diffr/palette.ts @@ -154,7 +154,7 @@ export function paletteFromHelix(theme: HelixTheme): Palette { syntax: (capture) => scopeFg(theme, capture), }; } -/** Bundled Helix themes under tui/themes; the two defaults are aliases. */ +/** Bundled Helix themes under packages/tui/themes; the two defaults are aliases. */ export const bundledThemes: Record = { "default-dark": "onedark", "default-light": "onelight", diff --git a/diffr/tui/packages/hunk/src/diffr/regions.test.ts b/diffr/packages/tui/src/diffr/regions.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/regions.test.ts rename to diffr/packages/tui/src/diffr/regions.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/regions.ts b/diffr/packages/tui/src/diffr/regions.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/regions.ts rename to diffr/packages/tui/src/diffr/regions.ts diff --git a/diffr/tui/packages/hunk/src/diffr/rows.test.ts b/diffr/packages/tui/src/diffr/rows.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/rows.test.ts rename to diffr/packages/tui/src/diffr/rows.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/rows.ts b/diffr/packages/tui/src/diffr/rows.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/rows.ts rename to diffr/packages/tui/src/diffr/rows.ts diff --git a/diffr/tui/packages/hunk/src/diffr/selection.ts b/diffr/packages/tui/src/diffr/selection.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/selection.ts rename to diffr/packages/tui/src/diffr/selection.ts diff --git a/diffr/tui/packages/hunk/src/diffr/store.test.ts b/diffr/packages/tui/src/diffr/store.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/store.test.ts rename to diffr/packages/tui/src/diffr/store.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/store.ts b/diffr/packages/tui/src/diffr/store.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/store.ts rename to diffr/packages/tui/src/diffr/store.ts diff --git a/diffr/tui/packages/hunk/src/diffr/stream.test.ts b/diffr/packages/tui/src/diffr/stream.test.ts similarity index 98% rename from diffr/tui/packages/hunk/src/diffr/stream.test.ts rename to diffr/packages/tui/src/diffr/stream.test.ts index 997c82bde..dc54fefe0 100644 --- a/diffr/tui/packages/hunk/src/diffr/stream.test.ts +++ b/diffr/packages/tui/src/diffr/stream.test.ts @@ -86,7 +86,7 @@ test("results arrive in any order", async () => { test("a stream recorded from diffr parses, with manifest tags and each record's visibility", async () => { // Recorded with `diffr --format ndjson --syntax --jobs 1` from a repository with // one source file and one test file; the test file is hidden by the hide-files plugin. - const bytes = await Bun.file(new URL("../../../../test/fixtures/comparison.ndjson", import.meta.url)).bytes(); + const bytes = await Bun.file(new URL("../../test/fixtures/comparison.ndjson", import.meta.url)).bytes(); async function* chunks() { yield bytes; } diff --git a/diffr/tui/packages/hunk/src/diffr/stream.ts b/diffr/packages/tui/src/diffr/stream.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/stream.ts rename to diffr/packages/tui/src/diffr/stream.ts diff --git a/diffr/tui/packages/hunk/src/diffr/theme.test.ts b/diffr/packages/tui/src/diffr/theme.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/theme.test.ts rename to diffr/packages/tui/src/diffr/theme.test.ts diff --git a/diffr/tui/packages/hunk/src/diffr/theme.ts b/diffr/packages/tui/src/diffr/theme.ts similarity index 82% rename from diffr/tui/packages/hunk/src/diffr/theme.ts rename to diffr/packages/tui/src/diffr/theme.ts index 39517b831..39b9b1fb1 100644 --- a/diffr/tui/packages/hunk/src/diffr/theme.ts +++ b/diffr/packages/tui/src/diffr/theme.ts @@ -1,10 +1,10 @@ /** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ import { readFileSync } from "node:fs"; import { bundledThemes, helixTheme, paletteFromHelix, type HelixTheme, type Palette } from "./palette"; -import onedark from "../../../../themes/onedark.toml" with { type: "text" }; -import onelight from "../../../../themes/onelight.toml" with { type: "text" }; -import gruvbox from "../../../../themes/gruvbox.toml" with { type: "text" }; -import solarized_light from "../../../../themes/solarized_light.toml" with { type: "text" }; +import onedark from "../../themes/onedark.toml" with { type: "text" }; +import onelight from "../../themes/onelight.toml" with { type: "text" }; +import gruvbox from "../../themes/gruvbox.toml" with { type: "text" }; +import solarized_light from "../../themes/solarized_light.toml" with { type: "text" }; export function parseHelixTheme(text: string, name: string): HelixTheme { return helixTheme(Bun.TOML.parse(text) as Record, name); } diff --git a/diffr/tui/packages/hunk/src/diffr/wire.ts b/diffr/packages/tui/src/diffr/wire.ts similarity index 100% rename from diffr/tui/packages/hunk/src/diffr/wire.ts rename to diffr/packages/tui/src/diffr/wire.ts diff --git a/diffr/tui/packages/hunk/src/lib/terminalText.test.ts b/diffr/packages/tui/src/lib/terminalText.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/lib/terminalText.test.ts rename to diffr/packages/tui/src/lib/terminalText.test.ts diff --git a/diffr/tui/packages/hunk/src/lib/terminalText.ts b/diffr/packages/tui/src/lib/terminalText.ts similarity index 100% rename from diffr/tui/packages/hunk/src/lib/terminalText.ts rename to diffr/packages/tui/src/lib/terminalText.ts diff --git a/diffr/tui/packages/hunk/src/main.tsx b/diffr/packages/tui/src/main.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/main.tsx rename to diffr/packages/tui/src/main.tsx diff --git a/diffr/tui/packages/hunk/src/shutdown.test.ts b/diffr/packages/tui/src/shutdown.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/shutdown.test.ts rename to diffr/packages/tui/src/shutdown.test.ts diff --git a/diffr/tui/packages/hunk/src/shutdown.ts b/diffr/packages/tui/src/shutdown.ts similarity index 100% rename from diffr/tui/packages/hunk/src/shutdown.ts rename to diffr/packages/tui/src/shutdown.ts diff --git a/diffr/tui/packages/hunk/src/ui/App.test.tsx b/diffr/packages/tui/src/ui/App.test.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/ui/App.test.tsx rename to diffr/packages/tui/src/ui/App.test.tsx diff --git a/diffr/tui/packages/hunk/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/ui/App.tsx rename to diffr/packages/tui/src/ui/App.tsx diff --git a/diffr/tui/packages/hunk/src/ui/Interaction.test.tsx b/diffr/packages/tui/src/ui/Interaction.test.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/ui/Interaction.test.tsx rename to diffr/packages/tui/src/ui/Interaction.test.tsx diff --git a/diffr/tui/packages/hunk/src/ui/Settings.test.tsx b/diffr/packages/tui/src/ui/Settings.test.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/ui/Settings.test.tsx rename to diffr/packages/tui/src/ui/Settings.test.tsx diff --git a/diffr/tui/packages/hunk/src/ui/Settings.tsx b/diffr/packages/tui/src/ui/Settings.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/ui/Settings.tsx rename to diffr/packages/tui/src/ui/Settings.tsx diff --git a/diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx similarity index 100% rename from diffr/tui/packages/hunk/src/ui/diff/CodeRowView.tsx rename to diffr/packages/tui/src/ui/diff/CodeRowView.tsx diff --git a/diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts b/diffr/packages/tui/src/ui/diff/diffRowModel.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/diff/diffRowModel.ts rename to diffr/packages/tui/src/ui/diff/diffRowModel.ts diff --git a/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.test.ts b/diffr/packages/tui/src/ui/diff/rowWindowing.test.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/diff/rowWindowing.test.ts rename to diffr/packages/tui/src/ui/diff/rowWindowing.test.ts diff --git a/diffr/tui/packages/hunk/src/ui/diff/rowWindowing.ts b/diffr/packages/tui/src/ui/diff/rowWindowing.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/diff/rowWindowing.ts rename to diffr/packages/tui/src/ui/diff/rowWindowing.ts diff --git a/diffr/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts b/diffr/packages/tui/src/ui/diff/styledSpanLayout.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/diff/styledSpanLayout.ts rename to diffr/packages/tui/src/ui/diff/styledSpanLayout.ts diff --git a/diffr/tui/packages/hunk/src/ui/lib/keys.ts b/diffr/packages/tui/src/ui/lib/keys.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/lib/keys.ts rename to diffr/packages/tui/src/ui/lib/keys.ts diff --git a/diffr/tui/packages/hunk/src/ui/lib/sidebar.ts b/diffr/packages/tui/src/ui/lib/sidebar.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/lib/sidebar.ts rename to diffr/packages/tui/src/ui/lib/sidebar.ts diff --git a/diffr/tui/packages/hunk/src/ui/lib/text.ts b/diffr/packages/tui/src/ui/lib/text.ts similarity index 100% rename from diffr/tui/packages/hunk/src/ui/lib/text.ts rename to diffr/packages/tui/src/ui/lib/text.ts diff --git a/diffr/tui/test/fixtures/comparison.ndjson b/diffr/packages/tui/test/fixtures/comparison.ndjson similarity index 100% rename from diffr/tui/test/fixtures/comparison.ndjson rename to diffr/packages/tui/test/fixtures/comparison.ndjson diff --git a/diffr/tui/test/review-smoke.tsx b/diffr/packages/tui/test/review-smoke.tsx similarity index 93% rename from diffr/tui/test/review-smoke.tsx rename to diffr/packages/tui/test/review-smoke.tsx index edf06a1cc..0c9ff44cc 100644 --- a/diffr/tui/test/review-smoke.tsx +++ b/diffr/packages/tui/test/review-smoke.tsx @@ -5,10 +5,10 @@ import { mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { rgbToHex } from "@opentui/core"; import { act } from "react"; import { testRender } from "@opentui/react/test-utils"; -import { App } from "../packages/hunk/src/ui/App"; -import { DiffStore } from "../packages/hunk/src/diffr/store"; -import { eventSchema } from "../packages/hunk/src/diffr/wire"; -import { loadBundledTheme } from "../packages/hunk/src/diffr/theme"; +import { App } from "../src/ui/App"; +import { DiffStore } from "../src/diffr/store"; +import { eventSchema } from "../src/diffr/wire"; +import { loadBundledTheme } from "../src/diffr/theme"; const [input, output = "/tmp/diffr-review"] = process.argv.slice(2); if (!input) throw new Error("Pass a saved NDJSON comparison"); mkdirSync(output, {recursive: true}); diff --git a/diffr/tui/themes/LICENSE b/diffr/packages/tui/themes/LICENSE similarity index 100% rename from diffr/tui/themes/LICENSE rename to diffr/packages/tui/themes/LICENSE diff --git a/diffr/tui/themes/README.md b/diffr/packages/tui/themes/README.md similarity index 100% rename from diffr/tui/themes/README.md rename to diffr/packages/tui/themes/README.md diff --git a/diffr/tui/themes/gruvbox.toml b/diffr/packages/tui/themes/gruvbox.toml similarity index 100% rename from diffr/tui/themes/gruvbox.toml rename to diffr/packages/tui/themes/gruvbox.toml diff --git a/diffr/tui/themes/onedark.toml b/diffr/packages/tui/themes/onedark.toml similarity index 100% rename from diffr/tui/themes/onedark.toml rename to diffr/packages/tui/themes/onedark.toml diff --git a/diffr/tui/themes/onelight.toml b/diffr/packages/tui/themes/onelight.toml similarity index 100% rename from diffr/tui/themes/onelight.toml rename to diffr/packages/tui/themes/onelight.toml diff --git a/diffr/tui/themes/solarized_light.toml b/diffr/packages/tui/themes/solarized_light.toml similarity index 100% rename from diffr/tui/themes/solarized_light.toml rename to diffr/packages/tui/themes/solarized_light.toml diff --git a/diffr/tui/tsconfig.json b/diffr/packages/tui/tsconfig.json similarity index 77% rename from diffr/tui/tsconfig.json rename to diffr/packages/tui/tsconfig.json index bcc1a77be..04f377732 100644 --- a/diffr/tui/tsconfig.json +++ b/diffr/packages/tui/tsconfig.json @@ -10,6 +10,6 @@ "noEmit": true, "types": ["bun", "react"] }, - "include": ["packages/**/*.ts", "packages/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], + "include": ["src/**/*.ts", "src/**/*.tsx", "test/**/*.ts", "test/**/*.tsx"], "exclude": ["test/fixtures"] } diff --git a/diffr/scripts/release.py b/diffr/scripts/release.py index 2ae63189f..72c80c065 100644 --- a/diffr/scripts/release.py +++ b/diffr/scripts/release.py @@ -35,7 +35,7 @@ def pack(version, target, install, output): binaries = ("diffr", "diffr-tui") if artifact == "diffr" else ("diffr",) for name in binaries: archive.add(install / "bin" / f"{name}{exe}", arcname=f"{name}{exe}") - for name in ("LICENSE", "NOTICE", "tui/LICENSE", "tui/themes/LICENSE"): + for name in ("LICENSE", "NOTICE", "packages/tui/LICENSE", "packages/tui/themes/LICENSE"): archive.add(ROOT / name, arcname=name) @@ -111,8 +111,8 @@ def install bin.install "diffr", "diffr-tui" doc.install "NOTICE" (pkgshare/"licenses").install "LICENSE" - (pkgshare/"licenses/tui").install "tui/LICENSE" - (pkgshare/"licenses/themes").install "tui/themes/LICENSE" + (pkgshare/"licenses/tui").install "packages/tui/LICENSE" + (pkgshare/"licenses/themes").install "packages/tui/themes/LICENSE" end test do diff --git a/diffr/xtask/src/main.rs b/diffr/xtask/src/main.rs index 109a8c697..478e40d80 100644 --- a/diffr/xtask/src/main.rs +++ b/diffr/xtask/src/main.rs @@ -41,7 +41,7 @@ fn install(root: &Path, with_cli: bool) -> Result<()> { ); run( Command::new(&bun) - .current_dir(root.join("tui")) + .current_dir(root.join("packages/tui")) .args(["install", "--frozen-lockfile"]), "Installing TUI dependencies", )?; @@ -51,7 +51,7 @@ fn install(root: &Path, with_cli: bool) -> Result<()> { std::fs::create_dir_all(artifact.parent().unwrap())?; run( Command::new(&bun) - .current_dir(root.join("tui")) + .current_dir(root.join("packages/tui")) .args([ "build", "--compile", @@ -61,7 +61,7 @@ fn install(root: &Path, with_cli: bool) -> Result<()> { } else { "process.env.OPENTUI_LIBC=\"glibc\"" }, - "packages/hunk/src/main.tsx", + "src/main.tsx", "--outfile", ]) .arg(&artifact), From d0f35967b4ccde9af2e3c8b9628bee74126c9042 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:38 -0700 Subject: [PATCH 04/25] TUI: parse theme files with smol-toml instead of Bun.TOML (AI-assisted) Theme parsing then runs anywhere, not only under Bun, so the code can be shared with frontends that do not run on Bun. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/bun.lock | 3 +++ diffr/packages/tui/package.json | 3 ++- diffr/packages/tui/src/diffr/theme.ts | 3 ++- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/diffr/packages/tui/bun.lock b/diffr/packages/tui/bun.lock index f306a15ff..e1c13bd04 100644 --- a/diffr/packages/tui/bun.lock +++ b/diffr/packages/tui/bun.lock @@ -9,6 +9,7 @@ "@opentui/react": "0.5.6", "get-east-asian-width": "1.4.0", "react": "19.2.0", + "smol-toml": "1.4.2", "string-width": "8.1.0", "zod": "4.1.12", }, @@ -72,6 +73,8 @@ "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], + "smol-toml": ["smol-toml@1.4.2", "", {}, "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g=="], + "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], diff --git a/diffr/packages/tui/package.json b/diffr/packages/tui/package.json index 2ccd4d553..48a45628d 100644 --- a/diffr/packages/tui/package.json +++ b/diffr/packages/tui/package.json @@ -11,8 +11,9 @@ "dependencies": { "@opentui/core": "0.5.6", "@opentui/react": "0.5.6", - "react": "19.2.0", "get-east-asian-width": "1.4.0", + "react": "19.2.0", + "smol-toml": "1.4.2", "string-width": "8.1.0", "zod": "4.1.12" }, diff --git a/diffr/packages/tui/src/diffr/theme.ts b/diffr/packages/tui/src/diffr/theme.ts index 39b9b1fb1..5892d8cf9 100644 --- a/diffr/packages/tui/src/diffr/theme.ts +++ b/diffr/packages/tui/src/diffr/theme.ts @@ -1,12 +1,13 @@ /** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ import { readFileSync } from "node:fs"; +import { parse } from "smol-toml"; import { bundledThemes, helixTheme, paletteFromHelix, type HelixTheme, type Palette } from "./palette"; import onedark from "../../themes/onedark.toml" with { type: "text" }; import onelight from "../../themes/onelight.toml" with { type: "text" }; import gruvbox from "../../themes/gruvbox.toml" with { type: "text" }; import solarized_light from "../../themes/solarized_light.toml" with { type: "text" }; export function parseHelixTheme(text: string, name: string): HelixTheme { - return helixTheme(Bun.TOML.parse(text) as Record, name); + return helixTheme(parse(text), name); } const themeSources: Record = { onedark, onelight, gruvbox, solarized_light }; export function loadBundledTheme(name: string): Palette { From f829f0f2887cd90261d77cf34e7b0ca9dc6db1a5 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:43 -0700 Subject: [PATCH 05/25] Move the TUI's renderer-free code into @diffr/viewer (AI-assisted) packages/viewer holds what does not depend on OpenTUI, one folder per domain: protocol (diffr's wire format, events and store), document (regions, rows, the file tree and selection), viewport (row geometry), terminal (text measurement and sanitizing) and theme (the palette, Helix theme loading and the themes). It type-checks against ES2023 plus the few web APIs every host has, so nothing Bun-, Node- or DOM-only creeps in. The viewer is plain TypeScript and tests with vitest. Bun and Node both run it from source, so one package manager must own its dependencies: the viewer and the TUI join the pnpm workspace, and Bun only runs and compiles the TUI. xtask installs the TUI with pnpm before compiling it. Code moves unchanged apart from imports; the row and span types join rows.ts, and the regions tests' file builders join fixture.ts. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- .github/workflows/diffr-release.yml | 4 + diffr/NOTICE | 6 +- diffr/README.md | 2 +- diffr/diffr-ts/README.md | 2 +- diffr/packages/tui/bun.lock | 100 ------ diffr/packages/tui/package.json | 8 +- diffr/packages/tui/src/diffr/stream.test.ts | 4 +- diffr/packages/tui/src/diffr/stream.ts | 4 +- diffr/packages/tui/src/diffr/theme.test.ts | 77 +---- diffr/packages/tui/src/diffr/theme.ts | 21 +- diffr/packages/tui/src/main.tsx | 7 +- diffr/packages/tui/src/ui/App.test.tsx | 9 +- diffr/packages/tui/src/ui/App.tsx | 26 +- .../packages/tui/src/ui/Interaction.test.tsx | 8 +- diffr/packages/tui/src/ui/Settings.tsx | 4 +- .../packages/tui/src/ui/diff/CodeRowView.tsx | 15 +- .../packages/tui/src/ui/diff/diffRowModel.ts | 60 ---- diffr/packages/tui/test/review-smoke.tsx | 6 +- diffr/packages/viewer/package.json | 24 ++ .../packages/{tui => viewer}/src/assets.d.ts | 0 .../src/document}/counts.test.ts | 2 +- .../diffr => viewer/src/document}/counts.ts | 2 +- .../src/document}/fileTree.test.ts | 4 +- .../diffr => viewer/src/document}/fileTree.ts | 2 +- .../src/document}/guides.test.ts | 6 +- .../src/document}/regions.test.ts | 66 +--- .../diffr => viewer/src/document}/regions.ts | 4 +- .../src/document}/rows.test.ts | 10 +- .../src/diffr => viewer/src/document}/rows.ts | 62 +++- .../src/document}/selection.ts | 2 +- diffr/packages/viewer/src/env.d.ts | 9 + .../diffr => viewer/src/protocol}/events.ts | 0 .../diffr => viewer/src/protocol}/fixture.ts | 56 ++++ .../src/protocol}/store.test.ts | 4 +- .../diffr => viewer/src/protocol}/store.ts | 0 .../src/diffr => viewer/src/protocol}/wire.ts | 0 .../src/terminal/sanitize.test.ts} | 4 +- .../src/terminal/sanitize.ts} | 0 .../src/terminal/spans.ts} | 6 +- .../ui/lib => viewer/src/terminal}/text.ts | 2 +- .../src/terminal/window.test.ts} | 4 +- .../src/terminal/window.ts} | 0 .../src/diffr => viewer/src/theme}/palette.ts | 4 +- .../packages/viewer/src/theme/themes.test.ts | 76 +++++ diffr/packages/viewer/src/theme/themes.ts | 20 ++ .../diffr => viewer/src/viewport}/geometry.ts | 7 +- diffr/packages/{tui => viewer}/themes/LICENSE | 0 .../packages/{tui => viewer}/themes/README.md | 0 .../{tui => viewer}/themes/gruvbox.toml | 0 .../{tui => viewer}/themes/onedark.toml | 0 .../{tui => viewer}/themes/onelight.toml | 0 .../themes/solarized_light.toml | 0 diffr/packages/viewer/tsconfig.json | 14 + diffr/packages/viewer/tsconfig.test.json | 6 + diffr/packages/viewer/vitest.config.ts | 7 + diffr/scripts/release.py | 4 +- diffr/xtask/src/main.rs | 8 +- pnpm-lock.yaml | 315 ++++++++++++++++++ pnpm-workspace.yaml | 2 + 59 files changed, 681 insertions(+), 414 deletions(-) delete mode 100644 diffr/packages/tui/bun.lock delete mode 100644 diffr/packages/tui/src/ui/diff/diffRowModel.ts create mode 100644 diffr/packages/viewer/package.json rename diffr/packages/{tui => viewer}/src/assets.d.ts (100%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/counts.test.ts (97%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/counts.ts (97%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/fileTree.test.ts (92%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/fileTree.ts (96%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/guides.test.ts (97%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/regions.test.ts (85%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/regions.ts (99%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/rows.test.ts (98%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/rows.ts (91%) rename diffr/packages/{tui/src/diffr => viewer/src/document}/selection.ts (97%) create mode 100644 diffr/packages/viewer/src/env.d.ts rename diffr/packages/{tui/src/diffr => viewer/src/protocol}/events.ts (100%) rename diffr/packages/{tui/src/diffr => viewer/src/protocol}/fixture.ts (68%) rename diffr/packages/{tui/src/diffr => viewer/src/protocol}/store.test.ts (94%) rename diffr/packages/{tui/src/diffr => viewer/src/protocol}/store.ts (100%) rename diffr/packages/{tui/src/diffr => viewer/src/protocol}/wire.ts (100%) rename diffr/packages/{tui/src/lib/terminalText.test.ts => viewer/src/terminal/sanitize.test.ts} (98%) rename diffr/packages/{tui/src/lib/terminalText.ts => viewer/src/terminal/sanitize.ts} (100%) rename diffr/packages/{tui/src/ui/diff/styledSpanLayout.ts => viewer/src/terminal/spans.ts} (98%) rename diffr/packages/{tui/src/ui/lib => viewer/src/terminal}/text.ts (99%) rename diffr/packages/{tui/src/ui/diff/rowWindowing.test.ts => viewer/src/terminal/window.test.ts} (91%) rename diffr/packages/{tui/src/ui/diff/rowWindowing.ts => viewer/src/terminal/window.ts} (100%) rename diffr/packages/{tui/src/diffr => viewer/src/theme}/palette.ts (97%) create mode 100644 diffr/packages/viewer/src/theme/themes.test.ts create mode 100644 diffr/packages/viewer/src/theme/themes.ts rename diffr/packages/{tui/src/diffr => viewer/src/viewport}/geometry.ts (94%) rename diffr/packages/{tui => viewer}/themes/LICENSE (100%) rename diffr/packages/{tui => viewer}/themes/README.md (100%) rename diffr/packages/{tui => viewer}/themes/gruvbox.toml (100%) rename diffr/packages/{tui => viewer}/themes/onedark.toml (100%) rename diffr/packages/{tui => viewer}/themes/onelight.toml (100%) rename diffr/packages/{tui => viewer}/themes/solarized_light.toml (100%) create mode 100644 diffr/packages/viewer/tsconfig.json create mode 100644 diffr/packages/viewer/tsconfig.test.json create mode 100644 diffr/packages/viewer/vitest.config.ts diff --git a/.github/workflows/diffr-release.yml b/.github/workflows/diffr-release.yml index 53f9f5820..f609c291f 100644 --- a/.github/workflows/diffr-release.yml +++ b/.github/workflows/diffr-release.yml @@ -45,6 +45,10 @@ jobs: - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 with: bun-version: 1.3.14 + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 - uses: dtolnay/rust-toolchain@stable - name: Build and package env: diff --git a/diffr/NOTICE b/diffr/NOTICE index fd4ae2a35..914d378ee 100644 --- a/diffr/NOTICE +++ b/diffr/NOTICE @@ -5,14 +5,14 @@ difftastic Copyright (c) 2021-2025 Wilfred Hughes. MIT License (see LICENSE). diffr forked difftastic at commit 274d0a8f5 (2026-09-08). -hunk (terminal frontend under packages/tui/) +hunk (terminal frontend under packages/tui/ and packages/viewer/) https://github.com/modem-dev/hunk Copyright (c) Modem Labs Inc. MIT License (see packages/tui/LICENSE). Import details in TUI_UPSTREAM.md. -Helix editor themes (packages/tui/themes/) +Helix editor themes (packages/viewer/themes/) https://github.com/helix-editor/helix/tree/master/runtime/themes - Mozilla Public License 2.0 (see packages/tui/themes/LICENSE). + Mozilla Public License 2.0 (see packages/viewer/themes/LICENSE). GitHub Linguist language data (plugins/classify/rust/src/linguist/) https://github.com/github-linguist/linguist diff --git a/diffr/README.md b/diffr/README.md index 54452f058..27c62f9bf 100644 --- a/diffr/README.md +++ b/diffr/README.md @@ -45,7 +45,7 @@ cargo install diffr-cli --locked Windows x64: download `diffr--.tar.gz` from [Whiteboard Releases](https://github.com/devdotfast/whiteboard/releases) and extract it with `tar -xzf`, or use `cargo binstall`. -From a Whiteboard checkout: `cd diffr && cargo xtask install` (requires Rust and [Bun](https://bun.sh)). +TODO(smenon):UPDATEME! ## History diff --git a/diffr/diffr-ts/README.md b/diffr/diffr-ts/README.md index c1117b454..4eded9529 100644 --- a/diffr/diffr-ts/README.md +++ b/diffr/diffr-ts/README.md @@ -21,7 +21,7 @@ Keep these files in sync: |---|---| | `crates/diffr-core/src/protocol/mod.rs`, `crates/diffr-core/src/pairing.rs`, `diffr-ts/src/contract.ts` | Contract tests against the binary; Rust version check | | `docs/streaming.md` | Review against the wire format | -| `packages/tui/src/diffr/wire.ts` (v3 only) | TUI fixture tests | +| `packages/viewer/src/protocol/wire.ts` (v3 only) | TUI fixture tests | | `../packages/review-protocol/package.json` and `../packages/review/package.json` | Exact package version pins | ## Release diff --git a/diffr/packages/tui/bun.lock b/diffr/packages/tui/bun.lock deleted file mode 100644 index e1c13bd04..000000000 --- a/diffr/packages/tui/bun.lock +++ /dev/null @@ -1,100 +0,0 @@ -{ - "lockfileVersion": 1, - "configVersion": 1, - "workspaces": { - "": { - "name": "@diffr/tui", - "dependencies": { - "@opentui/core": "0.5.6", - "@opentui/react": "0.5.6", - "get-east-asian-width": "1.4.0", - "react": "19.2.0", - "smol-toml": "1.4.2", - "string-width": "8.1.0", - "zod": "4.1.12", - }, - "devDependencies": { - "@types/bun": "1.3.14", - "@types/react": "19.2.0", - "typescript": "5.9.3", - }, - }, - }, - "packages": { - "@opentui/core": ["@opentui/core@0.5.6", "", { "dependencies": { "bun-ffi-structs": "0.3.1", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.5.6", "@opentui/core-darwin-x64": "0.5.6", "@opentui/core-linux-arm64": "0.5.6", "@opentui/core-linux-arm64-musl": "0.5.6", "@opentui/core-linux-x64": "0.5.6", "@opentui/core-linux-x64-musl": "0.5.6", "@opentui/core-win32-arm64": "0.5.6", "@opentui/core-win32-x64": "0.5.6" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ=="], - - "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.5.6", "", { "os": "darwin", "cpu": "arm64" }, "sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg=="], - - "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.5.6", "", { "os": "darwin", "cpu": "x64" }, "sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw=="], - - "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw=="], - - "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.5.6", "", { "os": "linux", "cpu": "arm64" }, "sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g=="], - - "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ=="], - - "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.5.6", "", { "os": "linux", "cpu": "x64" }, "sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ=="], - - "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.5.6", "", { "os": "win32", "cpu": "arm64" }, "sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA=="], - - "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA=="], - - "@opentui/react": ["@opentui/react@0.5.6", "", { "dependencies": { "@opentui/core": "0.5.6", "react-reconciler": "^0.33.0" }, "peerDependencies": { "react": ">=19.2.0", "react-devtools-core": "^7.0.1", "ws": "^8.18.0" } }, "sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ=="], - - "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], - - "@types/node": ["@types/node@22.20.2", "", { "dependencies": { "undici-types": "~6.21.0" } }, "sha512-xlvWf4Vs9n1PEVYwP1n4vvG07M6y8WgvJ2t0vbrWTmijsIHp1cS+uJ2kMIRdY3nHZK0nCYKrPeD171+SzF4/zw=="], - - "@types/react": ["@types/react@19.2.0", "", { "dependencies": { "csstype": "^3.0.2" } }, "sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA=="], - - "ansi-regex": ["ansi-regex@6.3.0", "", {}, "sha512-WpDfL7NO6j7tH88IDBNVdUJxDh9nmCteAVW9dsep846XdwF4naCBK+/tGLX3KJgcpgMRXCFlTM2hKGoK9FsdrQ=="], - - "bun-ffi-structs": ["bun-ffi-structs@0.3.1", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ=="], - - "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], - - "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], - - "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="], - - "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="], - - "get-east-asian-width": ["get-east-asian-width@1.4.0", "", {}, "sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q=="], - - "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="], - - "react": ["react@19.2.0", "", {}, "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ=="], - - "react-devtools-core": ["react-devtools-core@7.0.1", "", { "dependencies": { "shell-quote": "^1.6.1", "ws": "^7" } }, "sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw=="], - - "react-reconciler": ["react-reconciler@0.33.0", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.0" } }, "sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA=="], - - "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], - - "shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="], - - "smol-toml": ["smol-toml@1.4.2", "", {}, "sha512-rInDH6lCNiEyn3+hH8KVGFdbjc099j47+OSgbMrfDYX1CmXLfdKd7qi6IfcWj2wFxvSVkuI46M+wPGYfEOEj6g=="], - - "string-width": ["string-width@8.1.0", "", { "dependencies": { "get-east-asian-width": "^1.3.0", "strip-ansi": "^7.1.0" } }, "sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg=="], - - "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="], - - "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], - - "undici-types": ["undici-types@6.21.0", "", {}, "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ=="], - - "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="], - - "ws": ["ws@8.21.3", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw=="], - - "zod": ["zod@4.1.12", "", {}, "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ=="], - - "@opentui/core/string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="], - - "react-devtools-core/ws": ["ws@7.5.13", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": "^5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA=="], - - "string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - - "@opentui/core/string-width/strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="], - } -} diff --git a/diffr/packages/tui/package.json b/diffr/packages/tui/package.json index 48a45628d..3c8254e57 100644 --- a/diffr/packages/tui/package.json +++ b/diffr/packages/tui/package.json @@ -9,13 +9,13 @@ "test": "bun test src" }, "dependencies": { + "@diffr/viewer": "workspace:*", "@opentui/core": "0.5.6", "@opentui/react": "0.5.6", - "get-east-asian-width": "1.4.0", "react": "19.2.0", - "smol-toml": "1.4.2", - "string-width": "8.1.0", - "zod": "4.1.12" + "react-devtools-core": "7.0.1", + "web-tree-sitter": "0.25.10", + "ws": "8.21.3" }, "devDependencies": { "@types/bun": "1.3.14", diff --git a/diffr/packages/tui/src/diffr/stream.test.ts b/diffr/packages/tui/src/diffr/stream.test.ts index dc54fefe0..149072445 100644 --- a/diffr/packages/tui/src/diffr/stream.test.ts +++ b/diffr/packages/tui/src/diffr/stream.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "bun:test"; import { readDiffStream } from "./stream"; -import { createTestDiffFile, fold, leaf, root } from "./fixture"; -import { fileVisibility, type FileChange } from "./wire"; +import { createTestDiffFile, fold, leaf, root } from "@diffr/viewer/protocol/fixture"; +import { fileVisibility, type FileChange } from "@diffr/viewer/protocol/wire"; const manifest = (file: ReturnType): FileChange => ({ file: file.file, status: "modified", tags: [] }); const start = { diff --git a/diffr/packages/tui/src/diffr/stream.ts b/diffr/packages/tui/src/diffr/stream.ts index d27fd5a71..bb20cdfcf 100644 --- a/diffr/packages/tui/src/diffr/stream.ts +++ b/diffr/packages/tui/src/diffr/stream.ts @@ -1,5 +1,5 @@ -import { parseDiffEvents } from "./events"; -import type { DiffEvent } from "./wire"; +import { parseDiffEvents } from "@diffr/viewer/protocol/events"; +import type { DiffEvent } from "@diffr/viewer/protocol/wire"; export async function* readDiffStream( chunks: AsyncIterable, ): AsyncGenerator { diff --git a/diffr/packages/tui/src/diffr/theme.test.ts b/diffr/packages/tui/src/diffr/theme.test.ts index ff299a453..eace78b87 100644 --- a/diffr/packages/tui/src/diffr/theme.test.ts +++ b/diffr/packages/tui/src/diffr/theme.test.ts @@ -1,61 +1,6 @@ import { expect, test } from "bun:test"; -import { bundledThemes, colorOf, foldBackground, paletteFromHelix, scopeStyle, themeConfig } from "./palette"; -import { loadBundledTheme, parseHelixTheme, themesFromConfig } from "./theme"; -const sample = ` -"keyword" = { fg = "red", modifiers = ["bold"] } -"keyword.control" = { fg = "purple" } -"string" = "green" -"comment" = { fg = "#808080", modifiers = ["italic"] } -"ui.background" = { bg = "black" } -"ui.text" = { fg = "white" } -"ui.selection" = { bg = "#264f78" } -"diff.plus" = "green" -"diff.minus" = "red" - -[palette] -red = "#e06c75" -purple = "#c678dd" -green = "#98c379" -black = "#282c34" -white = "#abb2bf" -`; -test("scopes fall back to their parents and colours resolve through the palette", () => { - const theme = parseHelixTheme(sample, "sample"); - expect(scopeStyle(theme, "keyword.return")!.fg).toBe("red"); - expect(scopeStyle(theme, "keyword.control.import")!.fg).toBe("purple"); - expect(scopeStyle(theme, "string.special")!.fg).toBe("green"); - expect(scopeStyle(theme, "variable.parameter")).toBeUndefined(); - expect(colorOf(theme, "red")).toBe("#e06c75"); - expect(colorOf(theme, "#123456")).toBe("#123456"); - expect(colorOf(theme, "light-blue")).toBe("#3b8eea"); - expect(() => colorOf(theme, "chartreuse")).toThrow("unknown colour"); - const palette = paletteFromHelix(theme); - expect(palette.bg).toBe("#282c34"); - expect(palette.isLight).toBe(false); - expect(palette.syntax("keyword.return")).toBe("#e06c75"); - expect(palette.syntax("comment")).toBe("#808080"); - expect(palette.syntax("variable")).toBeUndefined(); - expect(palette.highlight).toBe("#264f78"); - expect(palette.addWord).not.toBe(palette.addition); -}); -test("a theme without ui colours or with inheritance is rejected", () => { - expect(() => paletteFromHelix(parseHelixTheme('"keyword" = "red"', "bare"))).toThrow("ui.background"); - expect(() => parseHelixTheme('inherits = "onedark"\n"ui.background" = { bg = "black" }', "child")).toThrow("inherits"); -}); -test("bundled themes load, the defaults are aliases, and captures are visibly distinct", () => { - for (const name of Object.keys(bundledThemes)) { - const palette = loadBundledTheme(name); - const colours = ["keyword", "string", "comment", "type", "function"].map((c) => palette.syntax(c)); - expect(colours.every((c) => c !== undefined)).toBe(true); - // gruvbox shares one green between strings and functions; everything else stays apart. - expect(new Set(colours).size).toBeGreaterThanOrEqual(4); - expect(palette.syntax("keyword.storage.modifier.ref")).toBeDefined(); - } - expect(loadBundledTheme("default-dark").isLight).toBe(false); - expect(loadBundledTheme("default-light").isLight).toBe(true); - expect(loadBundledTheme("default-dark").bg).toBe(loadBundledTheme("onedark").bg); - expect(() => loadBundledTheme("dracula")).toThrow("Unknown theme dracula"); -}); +import { themeConfig } from "@diffr/viewer/theme/palette"; +import { themesFromConfig } from "./theme"; test("the theme set follows diffr's config and errors on a missing section", () => { expect(themeConfig({ theme: { name: "gruvbox", path: null } })).toEqual({ name: "gruvbox", path: null }); expect(() => themeConfig({ folds: {} })).toThrow("theme section"); @@ -66,21 +11,3 @@ test("the theme set follows diffr's config and errors on a missing section", () expect(() => themesFromConfig({ name: "nope", path: null })).toThrow("Unknown theme"); expect(() => themesFromConfig({ name: "default-dark", path: "/nonexistent/theme.toml" })).toThrow(); }); -test("in every bundled theme a file header stands apart from code and folds, and its path reads", () => { - const channel = (hex: string, i: number) => { - const c = parseInt(hex.slice(i, i + 2), 16) / 255; - return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; - }; - const luminance = (hex: string) => 0.2126 * channel(hex, 1) + 0.7152 * channel(hex, 3) + 0.0722 * channel(hex, 5); - const contrast = (a: string, b: string) => { - const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); - return (high + 0.05) / (low + 0.05); - }; - for (const name of Object.keys(bundledThemes)) { - const palette = loadBundledTheme(name); - expect(palette.fileHeader).not.toBe(palette.bg); - expect(palette.fileHeader).not.toBe(foldBackground(palette, "neutral")); - expect(contrast(palette.fg, palette.fileHeader)).toBeGreaterThanOrEqual(4); - expect(contrast(palette.fileHeaderDir, palette.fileHeader)).toBeGreaterThanOrEqual(2.5); - } -}); diff --git a/diffr/packages/tui/src/diffr/theme.ts b/diffr/packages/tui/src/diffr/theme.ts index 5892d8cf9..6fedc5932 100644 --- a/diffr/packages/tui/src/diffr/theme.ts +++ b/diffr/packages/tui/src/diffr/theme.ts @@ -1,20 +1,6 @@ -/** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ import { readFileSync } from "node:fs"; -import { parse } from "smol-toml"; -import { bundledThemes, helixTheme, paletteFromHelix, type HelixTheme, type Palette } from "./palette"; -import onedark from "../../themes/onedark.toml" with { type: "text" }; -import onelight from "../../themes/onelight.toml" with { type: "text" }; -import gruvbox from "../../themes/gruvbox.toml" with { type: "text" }; -import solarized_light from "../../themes/solarized_light.toml" with { type: "text" }; -export function parseHelixTheme(text: string, name: string): HelixTheme { - return helixTheme(parse(text), name); -} -const themeSources: Record = { onedark, onelight, gruvbox, solarized_light }; -export function loadBundledTheme(name: string): Palette { - const file = bundledThemes[name]; - if (!file) throw new Error(`Unknown theme ${name}; bundled themes: ${Object.keys(bundledThemes).join(", ")}`); - return paletteFromHelix(parseHelixTheme(themeSources[file], name)); -} +import { paletteFromHelix, type Palette } from "@diffr/viewer/theme/palette"; +import { loadBundledTheme, parseHelixTheme } from "@diffr/viewer/theme/themes"; export function loadThemeFile(path: string): Palette { return paletteFromHelix(parseHelixTheme(readFileSync(path, "utf8"), path)); } @@ -29,6 +15,3 @@ export function themesFromConfig(config: { name: string; path: string | null }): const initial = config.path ? loadThemeFile(config.path) : loadBundledTheme(config.name); return { initial, dark, light }; } -/** The bundled defaults, for tests and the settings screen. */ -export const dark: Palette = loadBundledTheme("default-dark"); -export const light: Palette = loadBundledTheme("default-light"); diff --git a/diffr/packages/tui/src/main.tsx b/diffr/packages/tui/src/main.tsx index 4a1320216..420fe1153 100644 --- a/diffr/packages/tui/src/main.tsx +++ b/diffr/packages/tui/src/main.tsx @@ -6,10 +6,11 @@ import { spawn } from "node:child_process"; import { createCliRenderer } from "@opentui/core"; import { createRoot } from "@opentui/react"; import { readDiffStream } from "./diffr/stream"; -import { DiffStore } from "./diffr/store"; +import { DiffStore } from "@diffr/viewer/protocol/store"; import { cliClient, flattenSchema, type Setting } from "./diffr/config"; -import { loadBundledTheme, loadThemeFile, themesFromConfig, type ThemeSet } from "./diffr/theme"; -import { themeConfig } from "./diffr/palette"; +import { loadThemeFile, themesFromConfig, type ThemeSet } from "./diffr/theme"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; +import { themeConfig } from "@diffr/viewer/theme/palette"; import { App } from "./ui/App"; import { Settings } from "./ui/Settings"; const args = process.argv.slice(2), diff --git a/diffr/packages/tui/src/ui/App.test.tsx b/diffr/packages/tui/src/ui/App.test.tsx index 792a4ea49..7f85379bd 100644 --- a/diffr/packages/tui/src/ui/App.test.tsx +++ b/diffr/packages/tui/src/ui/App.test.tsx @@ -4,11 +4,10 @@ import { act, Profiler } from "react"; import { TextRenderable, type BaseRenderable } from "@opentui/core"; import { App } from "./App"; -import { DiffStore } from "../diffr/store"; -import { createTestDiffFile, leaf, line, manifestEntry, startFor, withIdenticalLines, root } from "../diffr/fixture"; -import type { DiffEvent, DiffFile } from "../diffr/wire"; -import { createBundledDiffFile, createFoldedDiffFile } from "../diffr/regions.test"; -import { loadBundledTheme } from "../diffr/theme"; +import { DiffStore } from "@diffr/viewer/protocol/store"; +import { createBundledDiffFile, createFoldedDiffFile, createTestDiffFile, leaf, line, manifestEntry, startFor, withIdenticalLines, root } from "@diffr/viewer/protocol/fixture"; +import type { DiffEvent, DiffFile } from "@diffr/viewer/protocol/wire"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; const themes = { initial: loadBundledTheme("default-dark"), dark: loadBundledTheme("default-dark"), light: loadBundledTheme("default-light") }; // Wait for the stream batch to be published inside React's act boundary. async function accept(store: DiffStore, ...events: DiffEvent[]) { diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index b04101e29..07d5cf69b 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -12,31 +12,31 @@ import { useTerminalDimensions, } from "@opentui/react"; import { TextAttributes } from "@opentui/core"; -import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "../diffr/fileTree"; +import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "@diffr/viewer/document/fileTree"; import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; -import type { ScopeFocus } from "./diff/diffRowModel"; +import type { ScopeFocus } from "@diffr/viewer/document/rows"; import { rowsForFile, type Layout, type ViewerRow, -} from "../diffr/rows"; -import type { Palette } from "../diffr/palette"; +} from "@diffr/viewer/document/rows"; +import type { Palette } from "@diffr/viewer/theme/palette"; import type { ThemeSet } from "../diffr/theme"; -import { measureRows, visibleRows, positionAt, positionTop, rowFold, type ViewPosition } from "../diffr/geometry"; +import { measureRows, visibleRows, positionAt, positionTop, rowFold, type ViewPosition } from "@diffr/viewer/viewport/geometry"; import { copySelection, selectionBounds, type SourceSelection, -} from "../diffr/selection"; -import { filePath, fileVisibility, type DiffFile, type TextDiff } from "../diffr/wire"; -import { defaultCollapsed, foldIds, gapIds, nestedIds, sourceLines } from "../diffr/regions"; -import { placeholderRows } from "../diffr/rows"; -import { add, blockBar, comparisonLabel, zero, type LineCounts } from "../diffr/counts"; -import type { DiffStore } from "../diffr/store"; -import { sanitizeTerminalLine } from "../lib/terminalText"; -import { measureTextWidth, sliceTextByWidth } from "./lib/text"; +} from "@diffr/viewer/document/selection"; +import { filePath, fileVisibility, type DiffFile, type TextDiff } from "@diffr/viewer/protocol/wire"; +import { defaultCollapsed, foldIds, gapIds, nestedIds, sourceLines } from "@diffr/viewer/document/regions"; +import { placeholderRows } from "@diffr/viewer/document/rows"; +import { add, blockBar, comparisonLabel, zero, type LineCounts } from "@diffr/viewer/document/counts"; +import type { DiffStore } from "@diffr/viewer/protocol/store"; +import { sanitizeTerminalLine } from "@diffr/viewer/terminal/sanitize"; +import { measureTextWidth, sliceTextByWidth } from "@diffr/viewer/terminal/text"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; export function App({ diff --git a/diffr/packages/tui/src/ui/Interaction.test.tsx b/diffr/packages/tui/src/ui/Interaction.test.tsx index d2bf48d93..523ce4bd8 100644 --- a/diffr/packages/tui/src/ui/Interaction.test.tsx +++ b/diffr/packages/tui/src/ui/Interaction.test.tsx @@ -3,10 +3,10 @@ import {act} from "react"; import {rgbToHex} from "@opentui/core"; import {testRender} from "@opentui/react/test-utils"; import {App} from "./App"; -import {DiffStore} from "../diffr/store"; -import {createGuideDiffFile, createTestDiffFile, startFor, withIdenticalLines} from "../diffr/fixture"; -import {loadBundledTheme} from "../diffr/theme"; -import type {DiffEvent, DiffFile} from "../diffr/wire"; +import {DiffStore} from "@diffr/viewer/protocol/store"; +import {createGuideDiffFile, createTestDiffFile, startFor, withIdenticalLines} from "@diffr/viewer/protocol/fixture"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; +import type {DiffEvent, DiffFile} from "@diffr/viewer/protocol/wire"; const dark = loadBundledTheme("default-dark"), light = loadBundledTheme("default-light"); const themes = {initial: dark, dark, light}; async function publish(store: DiffStore, ...events: DiffEvent[]) { diff --git a/diffr/packages/tui/src/ui/Settings.tsx b/diffr/packages/tui/src/ui/Settings.tsx index 519964775..7371e26e2 100644 --- a/diffr/packages/tui/src/ui/Settings.tsx +++ b/diffr/packages/tui/src/ui/Settings.tsx @@ -11,8 +11,8 @@ import { type ConfigClient, type Setting, } from "../diffr/config"; -import { dark } from "../diffr/theme"; -import { sliceTextByWidth } from "./lib/text"; +import { dark } from "@diffr/viewer/theme/themes"; +import { sliceTextByWidth } from "@diffr/viewer/terminal/text"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, Math.max(0, width)).text; const pad = (text: string, width: number) => fit(text, width).padEnd(Math.max(0, width)); diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx index de85220ea..38a81cb32 100644 --- a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -1,16 +1,11 @@ /** Paint measured Hunk-style code cells; source identity and viewport geometry stay outside React. */ import { memo } from "react"; import { StyledText, parseColor, type MouseEvent } from "@opentui/core"; -import type { - RenderSpan, - ScopeFocus, - SplitLineCell, - UnifiedLineCell, -} from "./diffRowModel"; -import type { Geometry, MeasuredRow } from "../../diffr/geometry"; -import { foldBackground, type Palette } from "../../diffr/palette"; -import type { RowFold } from "../../diffr/regions"; -import { measureTextWidth } from "../lib/text"; +import type { RenderSpan, ScopeFocus, SplitLineCell, UnifiedLineCell } from "@diffr/viewer/document/rows"; +import type { Geometry, MeasuredRow } from "@diffr/viewer/viewport/geometry"; +import { foldBackground, type Palette } from "@diffr/viewer/theme/palette"; +import type { RowFold } from "@diffr/viewer/document/regions"; +import { measureTextWidth } from "@diffr/viewer/terminal/text"; const colors = new Map>(); function color(value: string) { let c = colors.get(value); diff --git a/diffr/packages/tui/src/ui/diff/diffRowModel.ts b/diffr/packages/tui/src/ui/diff/diffRowModel.ts deleted file mode 100644 index e077e4bb0..000000000 --- a/diffr/packages/tui/src/ui/diff/diffRowModel.ts +++ /dev/null @@ -1,60 +0,0 @@ -/** - * Declares the terminal diff row model: the row and span shapes every review surface - * measures, windows, and paints. - * - * Kept as a leaf module so both the row builders (`diffRows.ts`) and their consumers — - * column math, the highlight worker, geometry — can share these types without importing - * the builders themselves. - */ -import type { FoldTint, RowFold } from "../../diffr/regions"; - -export interface RenderSpan { - text: string; - /** Fold-state id of an indent guide; paint-only hover accent. */ - guide?: number; - /** Fold-state id of the scope whose opening or closing bracket this is. */ - brace?: number; - fg?: string; - bg?: string; - /** Resolve paint-only foreground effects after cursor and copy-selection backgrounds apply. */ - transformFg?: (sourceFg: string | undefined, renderedBg: string) => string; -} - -export interface SplitLineCell { - kind: "context" | "addition" | "deletion" | "empty"; - sign: string; - lineNumber?: number; - /** This cell starts a fold region; the chevron and placeholder come from here. */ - fold?: RowFold; - /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ - foldLabel?: boolean; - /** On a label line, its fold's fold-state id: a click opens that fold. */ - labelOf?: number; - /** A collapsed row or label line: its tint fills the rest of the line. */ - band?: FoldTint; - /** The innermost open scope holding this line, its opener and closer included. */ - scope?: number; - /** Open scopes whose body (between opener and closer) holds this line. */ - body?: number[]; - spans: RenderSpan[]; -} - -export interface UnifiedLineCell { - kind: "context" | "addition" | "deletion"; - sign: string; - oldLineNumber?: number; - newLineNumber?: number; - fold?: RowFold; - foldLabel?: boolean; - labelOf?: number; - band?: FoldTint; - scope?: number; - body?: number[]; - spans: RenderSpan[]; -} - -/** The scope the pointer is on. Armed when it points at the scope's rail or chevron, which fold it. */ -export interface ScopeFocus { - id: number; - armed: boolean; -} diff --git a/diffr/packages/tui/test/review-smoke.tsx b/diffr/packages/tui/test/review-smoke.tsx index 0c9ff44cc..7558cc8c1 100644 --- a/diffr/packages/tui/test/review-smoke.tsx +++ b/diffr/packages/tui/test/review-smoke.tsx @@ -6,9 +6,9 @@ import { rgbToHex } from "@opentui/core"; import { act } from "react"; import { testRender } from "@opentui/react/test-utils"; import { App } from "../src/ui/App"; -import { DiffStore } from "../src/diffr/store"; -import { eventSchema } from "../src/diffr/wire"; -import { loadBundledTheme } from "../src/diffr/theme"; +import { DiffStore } from "@diffr/viewer/protocol/store"; +import { eventSchema } from "@diffr/viewer/protocol/wire"; +import { loadBundledTheme } from "@diffr/viewer/theme/themes"; const [input, output = "/tmp/diffr-review"] = process.argv.slice(2); if (!input) throw new Error("Pass a saved NDJSON comparison"); mkdirSync(output, {recursive: true}); diff --git a/diffr/packages/viewer/package.json b/diffr/packages/viewer/package.json new file mode 100644 index 000000000..944dd3913 --- /dev/null +++ b/diffr/packages/viewer/package.json @@ -0,0 +1,24 @@ +{ + "name": "@diffr/viewer", + "private": true, + "license": "MIT", + "type": "module", + "exports": { + "./*": "./src/*.ts" + }, + "scripts": { + "typecheck": "tsc --noEmit -p . && tsc --noEmit -p tsconfig.test.json", + "test": "vitest run" + }, + "dependencies": { + "get-east-asian-width": "1.4.0", + "smol-toml": "1.9.0", + "string-width": "8.1.0", + "zod": "4.6.5" + }, + "devDependencies": { + "typescript": "5.9.3", + "@types/node": "24.12.2", + "vitest": "4.1.11" + } +} diff --git a/diffr/packages/tui/src/assets.d.ts b/diffr/packages/viewer/src/assets.d.ts similarity index 100% rename from diffr/packages/tui/src/assets.d.ts rename to diffr/packages/viewer/src/assets.d.ts diff --git a/diffr/packages/tui/src/diffr/counts.test.ts b/diffr/packages/viewer/src/document/counts.test.ts similarity index 97% rename from diffr/packages/tui/src/diffr/counts.test.ts rename to diffr/packages/viewer/src/document/counts.test.ts index 0bad94f1c..ad13844d2 100644 --- a/diffr/packages/tui/src/diffr/counts.test.ts +++ b/diffr/packages/viewer/src/document/counts.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, test } from "vitest"; import { blockBar, comparisonLabel } from "./counts"; test("the block bar splits five blocks by share and keeps a block for any non-zero side", () => { expect(blockBar({ added: 0, removed: 0 })).toEqual(["neutral", "neutral", "neutral", "neutral", "neutral"]); diff --git a/diffr/packages/tui/src/diffr/counts.ts b/diffr/packages/viewer/src/document/counts.ts similarity index 97% rename from diffr/packages/tui/src/diffr/counts.ts rename to diffr/packages/viewer/src/document/counts.ts index 8244648fe..b9f3ea033 100644 --- a/diffr/packages/tui/src/diffr/counts.ts +++ b/diffr/packages/viewer/src/document/counts.ts @@ -1,6 +1,6 @@ /** Change counts for the headers. The numbers are diffr's `stats`, shown verbatim: folding * changes what is on screen, never the count. */ -import type { LineCounts } from "./wire"; +import type { LineCounts } from "../protocol/wire"; export type { LineCounts }; export const zero: LineCounts = { added: 0, removed: 0 }; export const add = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added + b.added, removed: a.removed + b.removed }); diff --git a/diffr/packages/tui/src/diffr/fileTree.test.ts b/diffr/packages/viewer/src/document/fileTree.test.ts similarity index 92% rename from diffr/packages/tui/src/diffr/fileTree.test.ts rename to diffr/packages/viewer/src/document/fileTree.test.ts index fc92c6458..de17389a0 100644 --- a/diffr/packages/tui/src/diffr/fileTree.test.ts +++ b/diffr/packages/viewer/src/document/fileTree.test.ts @@ -1,6 +1,6 @@ -import { expect, test } from "bun:test"; +import { expect, test } from "vitest"; import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "./fileTree"; -import { createTestDiffFile } from "./fixture"; +import { createTestDiffFile } from "../protocol/fixture"; test("tree shares directories, retains file identities, and folds subtrees", () => { const files = ["src/ui/App.tsx", "README.md", "src/core.rs", "test/ui/App.tsx"].map(path => { const file = createTestDiffFile(); diff --git a/diffr/packages/tui/src/diffr/fileTree.ts b/diffr/packages/viewer/src/document/fileTree.ts similarity index 96% rename from diffr/packages/tui/src/diffr/fileTree.ts rename to diffr/packages/viewer/src/document/fileTree.ts index d16d1fc92..6dcbea3a7 100644 --- a/diffr/packages/tui/src/diffr/fileTree.ts +++ b/diffr/packages/viewer/src/document/fileTree.ts @@ -1,4 +1,4 @@ -import { filePath, type DiffFile, type FileChange } from "./wire"; +import { filePath, type DiffFile, type FileChange } from "../protocol/wire"; export interface TreeNode { key: string; diff --git a/diffr/packages/tui/src/diffr/guides.test.ts b/diffr/packages/viewer/src/document/guides.test.ts similarity index 97% rename from diffr/packages/tui/src/diffr/guides.test.ts rename to diffr/packages/viewer/src/document/guides.test.ts index 3e3840eb2..6c527077c 100644 --- a/diffr/packages/tui/src/diffr/guides.test.ts +++ b/diffr/packages/viewer/src/document/guides.test.ts @@ -1,7 +1,7 @@ -import {expect, test} from "bun:test"; -import {createGuideDiffFile, createTestDiffFile, leaf, line, root} from "./fixture"; +import { expect, test } from "vitest"; +import {createGuideDiffFile, createTestDiffFile, leaf, line, root} from "../protocol/fixture"; import {rowsForFile} from "./rows"; -import {dark} from "./theme"; +import { dark } from "../theme/themes"; import {byteColumn, defaultCollapsed} from "./regions"; const sourceText = (cell: {spans: {text: string}[]} | undefined) => cell?.spans.map(s => s.text).join(""); test("Paper: enclosing guides cross blank lines and indented gap bands; syntax folds join opener and closer", () => { diff --git a/diffr/packages/tui/src/diffr/regions.test.ts b/diffr/packages/viewer/src/document/regions.test.ts similarity index 85% rename from diffr/packages/tui/src/diffr/regions.test.ts rename to diffr/packages/viewer/src/document/regions.test.ts index fcc238799..cbad5260e 100644 --- a/diffr/packages/tui/src/diffr/regions.test.ts +++ b/diffr/packages/viewer/src/document/regions.test.ts @@ -1,43 +1,10 @@ -import { expect, test } from "bun:test"; -import { createTestDiffFile, fold, leaf, line, root } from "./fixture"; +import { expect, test } from "vitest"; +import { createBundledDiffFile, createFoldedDiffFile, createTestDiffFile, fold, leaf, line, root } from "../protocol/fixture"; import { collapsedFolds, defaultCollapsed, flatten, foldHeaders, foldIds, foldTint, gapIds, hiddenLines, pairedIds } from "./regions"; import { rowsForFile } from "./rows"; -import { dark } from "./theme"; -import type { DiffFile, Region } from "./wire"; -import { foldBackground } from "./palette"; -/** Rust-style body folds: each covers its body alone, so the lines that open and close a - * construct are leaves around it, like VS Code's rows. */ -export function createFoldedDiffFile(): DiffFile { - const file = createTestDiffFile(); - const lines = [ - "fn outer() {", // 0 - " inner(|| {", // 1 - " a();", // 2 - " b();", // 3 - " });", // 4 - " // trailing", // 5 - " // comment", // 6 - "}", // 7 - ]; - const text = lines.join("\n") + "\n"; - // ids: 10 outer body (lines 1-6), 11 closure body (2-3), 12 comment (5-6); leaves tile the file. - const regions = (changed: boolean): Region[] => [ - leaf(1, 0, 1), - fold(10, [1, 0], [7, 0], [ - leaf(7, 1, 2), - fold(11, [2, 0], [4, 0], [leaf(2, 2, 3), leaf(3, 3, 4, changed ? [line(3, 8, 12)] : [])]), - leaf(4, 4, 5), - // A whole-node fold ending at end of line covers its last line too. - fold(12, [5, 4], [6, 14], [leaf(5, 5, 7)], "Comment", ["summarize:docstring"]), - ]), - leaf(6, 7, 8), - ]; - if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); - file.diff.lhs = { text, syntax: [], root: root(regions(false))}; - file.diff.rhs = { text, syntax: [], root: root(regions(true))}; - file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } }; - return file; -} +import { dark } from "../theme/themes"; +import type { Region } from "../protocol/wire"; +import { foldBackground } from "../theme/palette"; test("a fold covers its body, so collapsing it hides every line it holds", () => { const file = createFoldedDiffFile(); if (file.diff.type !== "text") throw new Error(); @@ -183,29 +150,6 @@ test("regions sharing a fold_state_id collapse and expand as one bundle", () => expect(rows.map((r) => r.right!.lineNumber)).toEqual([undefined, 3, undefined, 5]); }); -/** - * The shape diffr emits for a new, summarized function (src/tags/mod.rs, crates/diffr-core/src/protocol/project.rs - * `syntax_spans`): a one-line docstring region tagged `summarize:docstring`, collapsed - * with an empty label, sharing fold state 4 with the collapsed function fold after it, right side only. - */ -export function createBundledDiffFile(): DiffFile { - const file = createTestDiffFile(); - if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); - const text = [ - "];", // 0 - "/// The built-in rule for a path.", // 1 - "pub(crate) fn from_path(path: &str) -> Option<&str> {", // 2 - " None", // 3 - "}", // 4 - ].join("\n") + "\n"; - const docstring: Region = { - ...leaf(38, 1, 2), fold_state_id: 4, tags: ["summarize:docstring"], visibility: { collapsed: true, label: "" }, - }; - const body = fold(4, [3, 0], [4, 0], [leaf(5, 3, 4)], "look up path\nreturn None", ["summarize:function"], true); - file.diff.rhs = { text, syntax: [], root: root([leaf(3, 0, 1), docstring, leaf(7, 2, 3), body, leaf(6, 4, 5)])}; - file.diff.lhs = undefined; - return file; -} test("a collapsed docstring leaf bundled with its function renders as a bare ⋯ row", () => { const file = createBundledDiffFile(); if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); diff --git a/diffr/packages/tui/src/diffr/regions.ts b/diffr/packages/viewer/src/document/regions.ts similarity index 99% rename from diffr/packages/tui/src/diffr/regions.ts rename to diffr/packages/viewer/src/document/regions.ts index d1e2586ec..aa76dedc3 100644 --- a/diffr/packages/tui/src/diffr/regions.ts +++ b/diffr/packages/viewer/src/document/regions.ts @@ -1,6 +1,6 @@ /** Flatten diffr's per-side region trees; collapsed state belongs to the viewer. */ -import type { FoldRegion, Region, Source, Span, TextDiff } from "./wire"; -import { measureTextWidth } from "../ui/lib/text"; +import type { FoldRegion, Region, Source, Span, TextDiff } from "../protocol/wire"; +import { measureTextWidth } from "../terminal/text"; export type Side = 0 | 1; /** A leaf tiles its side; the same alignmentId on the other side is the leaf its rows line up with. */ export interface Leaf { diff --git a/diffr/packages/tui/src/diffr/rows.test.ts b/diffr/packages/viewer/src/document/rows.test.ts similarity index 98% rename from diffr/packages/tui/src/diffr/rows.test.ts rename to diffr/packages/viewer/src/document/rows.test.ts index 563ce76fa..5b35e2be7 100644 --- a/diffr/packages/tui/src/diffr/rows.test.ts +++ b/diffr/packages/viewer/src/document/rows.test.ts @@ -1,10 +1,10 @@ -import { expect, test } from "bun:test"; -import { createTestDiffFile, fold, leaf, line, withIdenticalLines, root } from "./fixture"; -import type { Region, Span } from "./wire"; +import { expect, test } from "vitest"; +import { createTestDiffFile, fold, leaf, line, withIdenticalLines, root } from "../protocol/fixture"; +import type { Region, Span } from "../protocol/wire"; import { captureColor, lineSpans, rowsForFile } from "./rows"; -import { dark, light } from "./theme"; +import { dark, light } from "../theme/themes"; import { pairedIds } from "./regions"; -import { measureRows, visibleRows } from "./geometry"; +import { measureRows, visibleRows } from "../viewport/geometry"; import { copySelection } from "./selection"; test("split zips leaves on their alignment ids, unified groups old before new", () => { const file = createTestDiffFile(); diff --git a/diffr/packages/tui/src/diffr/rows.ts b/diffr/packages/viewer/src/document/rows.ts similarity index 91% rename from diffr/packages/tui/src/diffr/rows.ts rename to diffr/packages/viewer/src/document/rows.ts index e30132d74..c62d29e13 100644 --- a/diffr/packages/tui/src/diffr/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -1,13 +1,61 @@ /** Project diffr's per-side region trees into terminal cells by zipping leaves on their ids. */ -import type { DiffFile, Span, SyntaxSpan } from "./wire"; -import { filePath } from "./wire"; -import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../ui/diff/diffRowModel"; -import { sliceSpansWindow } from "../ui/diff/styledSpanLayout"; -import { measureTextWidth } from "../ui/lib/text"; -import { byteColumn, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, type Fold, type FoldTint, type Leaf, type Side } from "./regions"; -import { foldBackground, type Palette } from "./palette"; +import type { DiffFile, Span, SyntaxSpan } from "../protocol/wire"; +import { filePath } from "../protocol/wire"; +import { sliceSpansWindow } from "../terminal/spans"; +import { measureTextWidth } from "../terminal/text"; +import { byteColumn, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, type Fold, type FoldTint, type Leaf, type RowFold, type Side } from "./regions"; +import { foldBackground, type Palette } from "../theme/palette"; export { sourceLines }; export type Layout = "split" | "unified"; +export interface RenderSpan { + text: string; + /** Fold-state id of an indent guide; paint-only hover accent. */ + guide?: number; + /** Fold-state id of the scope whose opening or closing bracket this is. */ + brace?: number; + fg?: string; + bg?: string; + /** Resolve paint-only foreground effects after cursor and copy-selection backgrounds apply. */ + transformFg?: (sourceFg: string | undefined, renderedBg: string) => string; +} + +export interface SplitLineCell { + kind: "context" | "addition" | "deletion" | "empty"; + sign: string; + lineNumber?: number; + /** This cell starts a fold region; the chevron and placeholder come from here. */ + fold?: RowFold; + /** A line of a collapsed fold's label, painted in the fold tint without a line number. */ + foldLabel?: boolean; + labelOf?: number; + /** A collapsed row or label line: its tint fills the rest of the line. */ + band?: FoldTint; + /** The innermost open scope holding this line, its opener and closer included. */ + scope?: number; + /** Open scopes whose body (between opener and closer) holds this line. */ + body?: number[]; + spans: RenderSpan[]; +} + +export interface UnifiedLineCell { + kind: "context" | "addition" | "deletion"; + sign: string; + oldLineNumber?: number; + newLineNumber?: number; + fold?: RowFold; + foldLabel?: boolean; + labelOf?: number; + band?: FoldTint; + scope?: number; + body?: number[]; + spans: RenderSpan[]; +} + +/** The scope the pointer is on. Armed when it points at the scope's rail or chevron, which fold it. */ +export interface ScopeFocus { + id: number; + armed: boolean; +} export interface ViewerRow { key: string; fileIndex: number; diff --git a/diffr/packages/tui/src/diffr/selection.ts b/diffr/packages/viewer/src/document/selection.ts similarity index 97% rename from diffr/packages/tui/src/diffr/selection.ts rename to diffr/packages/viewer/src/document/selection.ts index a48986007..1cc7f88d0 100644 --- a/diffr/packages/tui/src/diffr/selection.ts +++ b/diffr/packages/viewer/src/document/selection.ts @@ -1,5 +1,5 @@ /** Copy original source lines from one side, excluding gutters, padding, and wrapped duplicates. */ -import type { DiffFile } from "./wire"; +import type { DiffFile } from "../protocol/wire"; import type { ViewerRow } from "./rows"; export interface SourceSelection { anchor: string; diff --git a/diffr/packages/viewer/src/env.d.ts b/diffr/packages/viewer/src/env.d.ts new file mode 100644 index 000000000..b4b70acc7 --- /dev/null +++ b/diffr/packages/viewer/src/env.d.ts @@ -0,0 +1,9 @@ +/** Globals common to Bun, browsers and Claude Code; no Node APIs. */ +declare class TextEncoder { + encode(input?: string): Uint8Array; +} +declare class TextDecoder { + constructor(label?: string, options?: { fatal?: boolean }); + decode(input?: Uint8Array, options?: { stream?: boolean }): string; +} +declare function setTimeout(handler: () => void, timeout?: number): unknown; diff --git a/diffr/packages/tui/src/diffr/events.ts b/diffr/packages/viewer/src/protocol/events.ts similarity index 100% rename from diffr/packages/tui/src/diffr/events.ts rename to diffr/packages/viewer/src/protocol/events.ts diff --git a/diffr/packages/tui/src/diffr/fixture.ts b/diffr/packages/viewer/src/protocol/fixture.ts similarity index 68% rename from diffr/packages/tui/src/diffr/fixture.ts rename to diffr/packages/viewer/src/protocol/fixture.ts index 60c13de48..e6d94b5a8 100644 --- a/diffr/packages/tui/src/diffr/fixture.ts +++ b/diffr/packages/viewer/src/protocol/fixture.ts @@ -104,3 +104,59 @@ export function createGuideDiffFile(): DiffFile { } return file; } +/** Rust-style body folds: each covers its body alone, so the lines that open and close a + * construct are leaves around it, like VS Code's rows. */ +export function createFoldedDiffFile(): DiffFile { + const file = createTestDiffFile(); + const lines = [ + "fn outer() {", // 0 + " inner(|| {", // 1 + " a();", // 2 + " b();", // 3 + " });", // 4 + " // trailing", // 5 + " // comment", // 6 + "}", // 7 + ]; + const text = lines.join("\n") + "\n"; + // ids: 10 outer body (lines 1-6), 11 closure body (2-3), 12 comment (5-6); leaves tile the file. + const regions = (changed: boolean): Region[] => [ + leaf(1, 0, 1), + fold(10, [1, 0], [7, 0], [ + leaf(7, 1, 2), + fold(11, [2, 0], [4, 0], [leaf(2, 2, 3), leaf(3, 3, 4, changed ? [line(3, 8, 12)] : [])]), + leaf(4, 4, 5), + // A whole-node fold ending at end of line covers its last line too. + fold(12, [5, 4], [6, 14], [leaf(5, 5, 7)], "Comment", ["summarize:docstring"]), + ]), + leaf(6, 7, 8), + ]; + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = { text, syntax: [], root: root(regions(false))}; + file.diff.rhs = { text, syntax: [], root: root(regions(true))}; + file.diff.stats = { textual: { added: 1, removed: 0 }, visible: { added: 1, removed: 0 } }; + return file; +} +/** + * The shape diffr emits for a new, summarized function (src/tags/mod.rs, crates/diffr-core/src/protocol/project.rs + * `syntax_spans`): a one-line docstring region tagged `summarize:docstring`, collapsed + * with an empty label, sharing fold state 4 with the collapsed function fold after it, right side only. + */ +export function createBundledDiffFile(): DiffFile { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + const text = [ + "];", // 0 + "/// The built-in rule for a path.", // 1 + "pub(crate) fn from_path(path: &str) -> Option<&str> {", // 2 + " None", // 3 + "}", // 4 + ].join("\n") + "\n"; + const docstring: Region = { + ...leaf(38, 1, 2), fold_state_id: 4, tags: ["summarize:docstring"], visibility: { collapsed: true, label: "" }, + }; + const body = fold(4, [3, 0], [4, 0], [leaf(5, 3, 4)], "look up path\nreturn None", ["summarize:function"], true); + file.diff.rhs = { text, syntax: [], root: root([leaf(3, 0, 1), docstring, leaf(7, 2, 3), body, leaf(6, 4, 5)])}; + file.diff.lhs = undefined; + return file; +} diff --git a/diffr/packages/tui/src/diffr/store.test.ts b/diffr/packages/viewer/src/protocol/store.test.ts similarity index 94% rename from diffr/packages/tui/src/diffr/store.test.ts rename to diffr/packages/viewer/src/protocol/store.test.ts index 692de89db..3ffc437d5 100644 --- a/diffr/packages/tui/src/diffr/store.test.ts +++ b/diffr/packages/viewer/src/protocol/store.test.ts @@ -1,4 +1,4 @@ -import { expect, test } from "bun:test"; +import { expect, test } from "vitest"; import { DiffStore } from "./store"; import { createTestDiffFile, startFor } from "./fixture"; @@ -40,6 +40,6 @@ test("an unsubscribed viewer is not notified by a pending batch", async () => { const unsubscribe = store.subscribe(() => calls++); store.fail("stopped"); unsubscribe(); - await Bun.sleep(30); + await new Promise(resolve => setTimeout(resolve, 30)); expect(calls).toBe(0); }); diff --git a/diffr/packages/tui/src/diffr/store.ts b/diffr/packages/viewer/src/protocol/store.ts similarity index 100% rename from diffr/packages/tui/src/diffr/store.ts rename to diffr/packages/viewer/src/protocol/store.ts diff --git a/diffr/packages/tui/src/diffr/wire.ts b/diffr/packages/viewer/src/protocol/wire.ts similarity index 100% rename from diffr/packages/tui/src/diffr/wire.ts rename to diffr/packages/viewer/src/protocol/wire.ts diff --git a/diffr/packages/tui/src/lib/terminalText.test.ts b/diffr/packages/viewer/src/terminal/sanitize.test.ts similarity index 98% rename from diffr/packages/tui/src/lib/terminalText.test.ts rename to diffr/packages/viewer/src/terminal/sanitize.test.ts index aa99429ae..6cfdadec7 100644 --- a/diffr/packages/tui/src/lib/terminalText.test.ts +++ b/diffr/packages/viewer/src/terminal/sanitize.test.ts @@ -1,9 +1,9 @@ -import { describe, expect, test } from "bun:test"; +import { describe, expect, test } from "vitest"; import { formatTerminalPath, sanitizeTerminalSpans, sanitizeTerminalText, -} from "./terminalText"; +} from "./sanitize"; const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; const OSC_ST = "\x1b]8;;https://example.test\x1b\\"; diff --git a/diffr/packages/tui/src/lib/terminalText.ts b/diffr/packages/viewer/src/terminal/sanitize.ts similarity index 100% rename from diffr/packages/tui/src/lib/terminalText.ts rename to diffr/packages/viewer/src/terminal/sanitize.ts diff --git a/diffr/packages/tui/src/ui/diff/styledSpanLayout.ts b/diffr/packages/viewer/src/terminal/spans.ts similarity index 98% rename from diffr/packages/tui/src/ui/diff/styledSpanLayout.ts rename to diffr/packages/viewer/src/terminal/spans.ts index 4261736f3..e8b9a7dfc 100644 --- a/diffr/packages/tui/src/ui/diff/styledSpanLayout.ts +++ b/diffr/packages/viewer/src/terminal/spans.ts @@ -1,5 +1,5 @@ // Plans terminal-cell slicing and wrapping for styled diff spans. -import { sanitizeTerminalSpans } from "../../lib/terminalText"; +import { sanitizeTerminalSpans } from "./sanitize"; import { isPrintableAsciiText, measureSanitizedTextWidth, @@ -7,8 +7,8 @@ import { sliceSanitizedTextByWidth, textClusters, wrapSanitizedTextByWidth, -} from "../lib/text"; -import type { RenderSpan } from "./diffRowModel"; +} from "./text"; +import type { RenderSpan } from "../document/rows"; /** Append a styled span while preserving color-run coalescing. */ function appendRenderSpan(target: RenderSpan[], span: RenderSpan) { diff --git a/diffr/packages/tui/src/ui/lib/text.ts b/diffr/packages/viewer/src/terminal/text.ts similarity index 99% rename from diffr/packages/tui/src/ui/lib/text.ts rename to diffr/packages/viewer/src/terminal/text.ts index 95cd1f9b8..ee4bec2ef 100644 --- a/diffr/packages/tui/src/ui/lib/text.ts +++ b/diffr/packages/viewer/src/terminal/text.ts @@ -1,6 +1,6 @@ import { eastAsianWidth } from "get-east-asian-width"; import stringWidth from "string-width"; -import { sanitizeTerminalLine } from "../../lib/terminalText"; +import { sanitizeTerminalLine } from "./sanitize"; const printableAsciiRegex = /^[\u0020-\u007E]*$/; diff --git a/diffr/packages/tui/src/ui/diff/rowWindowing.test.ts b/diffr/packages/viewer/src/terminal/window.test.ts similarity index 91% rename from diffr/packages/tui/src/ui/diff/rowWindowing.test.ts rename to diffr/packages/viewer/src/terminal/window.test.ts index 48a93bc12..683d24469 100644 --- a/diffr/packages/tui/src/ui/diff/rowWindowing.test.ts +++ b/diffr/packages/viewer/src/terminal/window.test.ts @@ -1,5 +1,5 @@ -import { expect, test } from "bun:test"; -import { resolveVisibleRowIndexWindow as window } from "./rowWindowing"; +import { expect, test } from "vitest"; +import { resolveVisibleRowIndexWindow as window } from "./window"; test("window keeps wrapped rows intersecting either edge and replaces skipped height", () => { expect( window({ diff --git a/diffr/packages/tui/src/ui/diff/rowWindowing.ts b/diffr/packages/viewer/src/terminal/window.ts similarity index 100% rename from diffr/packages/tui/src/ui/diff/rowWindowing.ts rename to diffr/packages/viewer/src/terminal/window.ts diff --git a/diffr/packages/tui/src/diffr/palette.ts b/diffr/packages/viewer/src/theme/palette.ts similarity index 97% rename from diffr/packages/tui/src/diffr/palette.ts rename to diffr/packages/viewer/src/theme/palette.ts index 4c36ae3c9..ea987dcc9 100644 --- a/diffr/packages/tui/src/diffr/palette.ts +++ b/diffr/packages/viewer/src/theme/palette.ts @@ -1,5 +1,5 @@ /** The painter's palette from a parsed Helix theme, and diffr's theme config. No Node or Bun APIs. */ -import type { FoldTint } from "./regions"; +import type { FoldTint } from "../document/regions"; export interface Style { fg?: string; bg?: string; @@ -154,7 +154,6 @@ export function paletteFromHelix(theme: HelixTheme): Palette { syntax: (capture) => scopeFg(theme, capture), }; } -/** Bundled Helix themes under packages/tui/themes; the two defaults are aliases. */ export const bundledThemes: Record = { "default-dark": "onedark", "default-light": "onelight", @@ -163,7 +162,6 @@ export const bundledThemes: Record = { gruvbox: "gruvbox", solarized_light: "solarized_light", }; -/** The theme section of `diffr config show --json`. */ export function themeConfig(show: unknown): { name: string; path: string | null } { const theme = (show as { theme?: { name?: unknown; path?: unknown } }).theme; if (!theme || typeof theme.name !== "string" || (theme.path !== null && theme.path !== undefined && typeof theme.path !== "string")) diff --git a/diffr/packages/viewer/src/theme/themes.test.ts b/diffr/packages/viewer/src/theme/themes.test.ts new file mode 100644 index 000000000..7d8337bdd --- /dev/null +++ b/diffr/packages/viewer/src/theme/themes.test.ts @@ -0,0 +1,76 @@ +import { expect, test } from "vitest"; +import { bundledThemes, colorOf, foldBackground, paletteFromHelix, scopeStyle } from "./palette"; +import { loadBundledTheme, parseHelixTheme } from "./themes"; +const sample = ` +"keyword" = { fg = "red", modifiers = ["bold"] } +"keyword.control" = { fg = "purple" } +"string" = "green" +"comment" = { fg = "#808080", modifiers = ["italic"] } +"ui.background" = { bg = "black" } +"ui.text" = { fg = "white" } +"ui.selection" = { bg = "#264f78" } +"diff.plus" = "green" +"diff.minus" = "red" + +[palette] +red = "#e06c75" +purple = "#c678dd" +green = "#98c379" +black = "#282c34" +white = "#abb2bf" +`; +test("scopes fall back to their parents and colours resolve through the palette", () => { + const theme = parseHelixTheme(sample, "sample"); + expect(scopeStyle(theme, "keyword.return")!.fg).toBe("red"); + expect(scopeStyle(theme, "keyword.control.import")!.fg).toBe("purple"); + expect(scopeStyle(theme, "string.special")!.fg).toBe("green"); + expect(scopeStyle(theme, "variable.parameter")).toBeUndefined(); + expect(colorOf(theme, "red")).toBe("#e06c75"); + expect(colorOf(theme, "#123456")).toBe("#123456"); + expect(colorOf(theme, "light-blue")).toBe("#3b8eea"); + expect(() => colorOf(theme, "chartreuse")).toThrow("unknown colour"); + const palette = paletteFromHelix(theme); + expect(palette.bg).toBe("#282c34"); + expect(palette.isLight).toBe(false); + expect(palette.syntax("keyword.return")).toBe("#e06c75"); + expect(palette.syntax("comment")).toBe("#808080"); + expect(palette.syntax("variable")).toBeUndefined(); + expect(palette.highlight).toBe("#264f78"); + expect(palette.addWord).not.toBe(palette.addition); +}); +test("a theme without ui colours or with inheritance is rejected", () => { + expect(() => paletteFromHelix(parseHelixTheme('"keyword" = "red"', "bare"))).toThrow("ui.background"); + expect(() => parseHelixTheme('inherits = "onedark"\n"ui.background" = { bg = "black" }', "child")).toThrow("inherits"); +}); +test("bundled themes load, the defaults are aliases, and captures are visibly distinct", () => { + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + const colours = ["keyword", "string", "comment", "type", "function"].map((c) => palette.syntax(c)); + expect(colours.every((c) => c !== undefined)).toBe(true); + // gruvbox shares one green between strings and functions; everything else stays apart. + expect(new Set(colours).size).toBeGreaterThanOrEqual(4); + expect(palette.syntax("keyword.storage.modifier.ref")).toBeDefined(); + } + expect(loadBundledTheme("default-dark").isLight).toBe(false); + expect(loadBundledTheme("default-light").isLight).toBe(true); + expect(loadBundledTheme("default-dark").bg).toBe(loadBundledTheme("onedark").bg); + expect(() => loadBundledTheme("dracula")).toThrow("Unknown theme dracula"); +}); +test("in every bundled theme a file header stands apart from code and folds, and its path reads", () => { + const channel = (hex: string, i: number) => { + const c = parseInt(hex.slice(i, i + 2), 16) / 255; + return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; + }; + const luminance = (hex: string) => 0.2126 * channel(hex, 1) + 0.7152 * channel(hex, 3) + 0.0722 * channel(hex, 5); + const contrast = (a: string, b: string) => { + const [high, low] = [luminance(a), luminance(b)].sort((x, y) => y - x); + return (high + 0.05) / (low + 0.05); + }; + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + expect(palette.fileHeader).not.toBe(palette.bg); + expect(palette.fileHeader).not.toBe(foldBackground(palette, "neutral")); + expect(contrast(palette.fg, palette.fileHeader)).toBeGreaterThanOrEqual(4); + expect(contrast(palette.fileHeaderDir, palette.fileHeader)).toBeGreaterThanOrEqual(2.5); + } +}); diff --git a/diffr/packages/viewer/src/theme/themes.ts b/diffr/packages/viewer/src/theme/themes.ts new file mode 100644 index 000000000..2e7a433c2 --- /dev/null +++ b/diffr/packages/viewer/src/theme/themes.ts @@ -0,0 +1,20 @@ +/** Load Helix themes (TOML keyed by tree-sitter capture names) into the painter's palette. */ +/// +import { parse } from "smol-toml"; +import { bundledThemes, helixTheme, paletteFromHelix, type HelixTheme, type Palette } from "./palette"; +import onedark from "../../themes/onedark.toml" with { type: "text" }; +import onelight from "../../themes/onelight.toml" with { type: "text" }; +import gruvbox from "../../themes/gruvbox.toml" with { type: "text" }; +import solarized_light from "../../themes/solarized_light.toml" with { type: "text" }; +export function parseHelixTheme(text: string, name: string): HelixTheme { + return helixTheme(parse(text), name); +} +const themeSources: Record = { onedark, onelight, gruvbox, solarized_light }; +export function loadBundledTheme(name: string): Palette { + const file = bundledThemes[name]; + if (!file) throw new Error(`Unknown theme ${name}; bundled themes: ${Object.keys(bundledThemes).join(", ")}`); + return paletteFromHelix(parseHelixTheme(themeSources[file]!, name)); +} +/** The bundled defaults, for tests and the settings screen. */ +export const dark: Palette = loadBundledTheme("default-dark"); +export const light: Palette = loadBundledTheme("default-light"); diff --git a/diffr/packages/tui/src/diffr/geometry.ts b/diffr/packages/viewer/src/viewport/geometry.ts similarity index 94% rename from diffr/packages/tui/src/diffr/geometry.ts rename to diffr/packages/viewer/src/viewport/geometry.ts index aa890df07..be33016b4 100644 --- a/diffr/packages/tui/src/diffr/geometry.ts +++ b/diffr/packages/viewer/src/viewport/geometry.ts @@ -1,8 +1,7 @@ /** Measure once per layout change, then window measured rows using Hunk's binary search. */ -import { sliceSpansWindow, wrapSpans } from "../ui/diff/styledSpanLayout"; -import { resolveVisibleRowIndexWindow } from "../ui/diff/rowWindowing"; -import type { RenderSpan } from "../ui/diff/diffRowModel"; -import type { ViewerRow } from "./rows"; +import { sliceSpansWindow, wrapSpans } from "../terminal/spans"; +import { resolveVisibleRowIndexWindow } from "../terminal/window"; +import type { RenderSpan, ViewerRow } from "../document/rows"; export interface MeasuredRow { row: ViewerRow; top: number; diff --git a/diffr/packages/tui/themes/LICENSE b/diffr/packages/viewer/themes/LICENSE similarity index 100% rename from diffr/packages/tui/themes/LICENSE rename to diffr/packages/viewer/themes/LICENSE diff --git a/diffr/packages/tui/themes/README.md b/diffr/packages/viewer/themes/README.md similarity index 100% rename from diffr/packages/tui/themes/README.md rename to diffr/packages/viewer/themes/README.md diff --git a/diffr/packages/tui/themes/gruvbox.toml b/diffr/packages/viewer/themes/gruvbox.toml similarity index 100% rename from diffr/packages/tui/themes/gruvbox.toml rename to diffr/packages/viewer/themes/gruvbox.toml diff --git a/diffr/packages/tui/themes/onedark.toml b/diffr/packages/viewer/themes/onedark.toml similarity index 100% rename from diffr/packages/tui/themes/onedark.toml rename to diffr/packages/viewer/themes/onedark.toml diff --git a/diffr/packages/tui/themes/onelight.toml b/diffr/packages/viewer/themes/onelight.toml similarity index 100% rename from diffr/packages/tui/themes/onelight.toml rename to diffr/packages/viewer/themes/onelight.toml diff --git a/diffr/packages/tui/themes/solarized_light.toml b/diffr/packages/viewer/themes/solarized_light.toml similarity index 100% rename from diffr/packages/tui/themes/solarized_light.toml rename to diffr/packages/viewer/themes/solarized_light.toml diff --git a/diffr/packages/viewer/tsconfig.json b/diffr/packages/viewer/tsconfig.json new file mode 100644 index 000000000..6d4d6a92b --- /dev/null +++ b/diffr/packages/viewer/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "es2023", + "lib": ["es2023"], + "types": [], + "module": "esnext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["src/**/*.test.ts"] +} diff --git a/diffr/packages/viewer/tsconfig.test.json b/diffr/packages/viewer/tsconfig.test.json new file mode 100644 index 000000000..4a50a7d41 --- /dev/null +++ b/diffr/packages/viewer/tsconfig.test.json @@ -0,0 +1,6 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { "types": ["node"] }, + "include": ["src"], + "exclude": [] +} diff --git a/diffr/packages/viewer/vitest.config.ts b/diffr/packages/viewer/vitest.config.ts new file mode 100644 index 000000000..1beae8a66 --- /dev/null +++ b/diffr/packages/viewer/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +// Bun and esbuild read `with { type: "text" }` imports; Vite needs this to load the themes. +export default defineConfig({ + plugins: [{ name: "toml-as-text", transform: (code, id) => (id.endsWith(".toml") ? `export default ${JSON.stringify(code)};` : undefined) }], + test: { environment: "node" }, +}); diff --git a/diffr/scripts/release.py b/diffr/scripts/release.py index 72c80c065..e28754a9f 100644 --- a/diffr/scripts/release.py +++ b/diffr/scripts/release.py @@ -35,7 +35,7 @@ def pack(version, target, install, output): binaries = ("diffr", "diffr-tui") if artifact == "diffr" else ("diffr",) for name in binaries: archive.add(install / "bin" / f"{name}{exe}", arcname=f"{name}{exe}") - for name in ("LICENSE", "NOTICE", "packages/tui/LICENSE", "packages/tui/themes/LICENSE"): + for name in ("LICENSE", "NOTICE", "packages/tui/LICENSE", "packages/viewer/themes/LICENSE"): archive.add(ROOT / name, arcname=name) @@ -112,7 +112,7 @@ def install doc.install "NOTICE" (pkgshare/"licenses").install "LICENSE" (pkgshare/"licenses/tui").install "packages/tui/LICENSE" - (pkgshare/"licenses/themes").install "packages/tui/themes/LICENSE" + (pkgshare/"licenses/themes").install "packages/viewer/themes/LICENSE" end test do diff --git a/diffr/xtask/src/main.rs b/diffr/xtask/src/main.rs index 478e40d80..305b93e3a 100644 --- a/diffr/xtask/src/main.rs +++ b/diffr/xtask/src/main.rs @@ -40,10 +40,10 @@ fn install(root: &Path, with_cli: bool) -> Result<()> { "Bun --version failed; check your Bun installation" ); run( - Command::new(&bun) - .current_dir(root.join("packages/tui")) - .args(["install", "--frozen-lockfile"]), - "Installing TUI dependencies", + Command::new(if cfg!(windows) { "pnpm.cmd" } else { "pnpm" }) + .current_dir(root.parent().unwrap()) + .args(["--filter", "@diffr/tui...", "install", "--frozen-lockfile"]), + "Installing TUI dependencies with pnpm", )?; let artifact = root .join("target/tui") diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 6e71fa231..f37a985b3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -108,6 +108,65 @@ importers: specifier: 0.1.17 version: 0.1.17 + diffr/packages/tui: + dependencies: + '@diffr/viewer': + specifier: workspace:* + version: link:../viewer + '@opentui/core': + specifier: 0.5.6 + version: 0.5.6(typescript@5.9.3)(web-tree-sitter@0.25.10) + '@opentui/react': + specifier: 0.5.6 + version: 0.5.6(react-devtools-core@7.0.1)(react@19.2.0)(typescript@5.9.3)(web-tree-sitter@0.25.10)(ws@8.21.3) + react: + specifier: 19.2.0 + version: 19.2.0 + react-devtools-core: + specifier: 7.0.1 + version: 7.0.1 + web-tree-sitter: + specifier: 0.25.10 + version: 0.25.10 + ws: + specifier: 8.21.3 + version: 8.21.3 + devDependencies: + '@types/bun': + specifier: 1.3.14 + version: 1.3.14 + '@types/react': + specifier: 19.2.0 + version: 19.2.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + + diffr/packages/viewer: + dependencies: + get-east-asian-width: + specifier: 1.4.0 + version: 1.4.0 + smol-toml: + specifier: 1.9.0 + version: 1.9.0 + string-width: + specifier: 8.1.0 + version: 8.1.0 + zod: + specifier: 4.6.5 + version: 4.6.5 + devDependencies: + '@types/node': + specifier: 24.12.2 + version: 24.12.2 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: 4.1.11 + version: 4.1.11(@types/node@24.12.2)(@vitest/browser-playwright@4.1.11)(esbuild@0.28.1)(jsdom@29.1.1)(tsx@4.21.0) + packages/agent-plugins/pi: {} packages/json: @@ -865,6 +924,60 @@ packages: engines: {node: '>=16'} hasBin: true + '@opentui/core-darwin-arm64@0.5.6': + resolution: {integrity: sha512-g/OOSSXuFlpLOhLHm8w8MsMh1oGVYlQ7QuW8l6oqG9KIkOg4hkYN5eFnpe6Px1p6cNCw5NOC+198WwdcKhQncg==} + cpu: [arm64] + os: [darwin] + + '@opentui/core-darwin-x64@0.5.6': + resolution: {integrity: sha512-4+z7/CaV27u9JpdooMa6oRIpLwsaA5j1a2xz7Xzm8oWhakeKGZnW0wUJAfStn/sks3NK5tUpF+eKHa9oawGiEw==} + cpu: [x64] + os: [darwin] + + '@opentui/core-linux-arm64-musl@0.5.6': + resolution: {integrity: sha512-cMdxhADkuJW0Nkn7pTB43q5Djg4Ch5i3hlC4iLOVkbOgvckKqz8xJHdl7fuqVs8g5LaadGBmZqdlFKjXIMKl2g==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-arm64@0.5.6': + resolution: {integrity: sha512-RAiRmosbg7DH4+ZuXRMjEmQmDAWhcRpuQKQ6bGL76nUd6wl/CufBiD7il/FQMQyUGzmQQH0Hu+pVQLz+kAkNJw==} + cpu: [arm64] + os: [linux] + + '@opentui/core-linux-x64-musl@0.5.6': + resolution: {integrity: sha512-vb5mn1i1s8/3lp03O6lX3190eN+iVCZxXOl9qCO5BTCXWtFztyIkp5YDad3/20SptcOHcLPKDiRGWNmSkYBrkQ==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@opentui/core-linux-x64@0.5.6': + resolution: {integrity: sha512-gzlNauPg5UT5UnWiSIlONRwKnkNA6fjN8SyRGlT3SGvcAox+vT2WeP6TwoJSM6pq5H5MF/tlY+REIqc/ZMGJtQ==} + cpu: [x64] + os: [linux] + + '@opentui/core-win32-arm64@0.5.6': + resolution: {integrity: sha512-FTSpFrNyuh4k2Z3+KeLVLlryFpe89ZyQcqBE0XcmtgiU3RlXuoMPSj2TtRekukR/FaWw9toDBfWokiQjOJGjrA==} + cpu: [arm64] + os: [win32] + + '@opentui/core-win32-x64@0.5.6': + resolution: {integrity: sha512-rWplkCuHX0OxVi7bO8dAVPttzPpCgmz6Bsu1XCm4ErPAhZOofRd5FbeW2+pC71AbLOKicSwI7lQ4mhGCNaweaA==} + cpu: [x64] + os: [win32] + + '@opentui/core@0.5.6': + resolution: {integrity: sha512-e38H1VceXoSSOEYmhUeHDE3F3LHXn/sFEZ6NhbXkbYdrkdV+1MMTgEkGZJ9I5fHhBlJdGJ1LDTPw8V0ZSUXSyQ==} + peerDependencies: + web-tree-sitter: 0.25.10 + + '@opentui/react@0.5.6': + resolution: {integrity: sha512-dMQTlcD1zlDTQC8r9rZKlHD7CORkY2M5D7tqhgoccoOK9p6q05FqrdRCTYHu1+VZYafqIV0k2IpMY2e34sngiQ==} + peerDependencies: + react: '>=19.2.0' + react-devtools-core: ^7.0.1 + ws: ^8.18.0 + '@oxc-project/types@0.133.0': resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} @@ -1350,6 +1463,9 @@ packages: '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} + '@types/bun@1.3.14': + resolution: {integrity: sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw==} + '@types/chai@5.2.3': resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==} @@ -1407,6 +1523,9 @@ packages: peerDependencies: '@types/react': ^19.2.0 + '@types/react@19.2.0': + resolution: {integrity: sha512-1LOH8xovvsKsCBq1wnT4ntDUdCJKmnEakhsuoUSy6ExlHCkGP2hqnatagYTgFk6oeL0VU31u7SNjunPN+GchtA==} + '@types/react@19.2.14': resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==} @@ -1775,6 +1894,10 @@ packages: ajv@8.20.0: resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + ansi-regex@6.4.0: + resolution: {integrity: sha512-KzTVk2tCWAHtYrvvvaP8bJKJq2pVinhLcGEQdtLIYPbmNGNyYe8QwNaTUYQp2J7/vIsUKt5QCqAfUkYyG9DkOw==} + engines: {node: '>=12'} + ansis@4.3.1: resolution: {integrity: sha512-BJ8/l4R5LRE7hW9WdSuGYrLSHi2ynxeFpDFbH0K/CgNeY/tyhk+vO6TYxXC5r5CpUhNVX310xzPsN/H9lCdfOA==} engines: {node: '>=14'} @@ -1804,6 +1927,14 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true + bun-ffi-structs@0.3.1: + resolution: {integrity: sha512-3gM7PpVWLyrwxWjcilSiGuhWanhZivvo6l0u573NziPH6f/gwk6McbaYgn7oJWov6pKGRTDbrg94W5DcJsKTtQ==} + peerDependencies: + typescript: ^5 + + bun-types@1.3.14: + resolution: {integrity: sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ==} + bundle-name@4.1.0: resolution: {integrity: sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==} engines: {node: '>=18'} @@ -2012,6 +2143,9 @@ packages: elkjs@0.11.1: resolution: {integrity: sha512-zxxR9k+rx5ktMwT/FwyLdPCrq7xN6e4VGGHH8hA01vVYKjTFik7nHOxBnAYtrgYUB1RpAiLvA1/U2YraWxyKKg==} + emoji-regex@10.6.0: + resolution: {integrity: sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==} + empathic@2.0.1: resolution: {integrity: sha512-YGRs8knHhKHVShLkFET/rWAU8kmHbOV5LwN938RHI0pljAJ1Gf6SzXsSmRaEzcXTtOOmVqJ5+WtQPL5uigY50Q==} engines: {node: '>=14'} @@ -2140,6 +2274,10 @@ packages: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} + get-east-asian-width@1.4.0: + resolution: {integrity: sha512-QZjmEOC+IT1uk6Rx0sX22V6uHWVwbdbxf1faPqJ1QhLdGgsRGCZoyaQBm/piRdJy/D2um6hM1UP7ZEeQ4EkP+Q==} + engines: {node: '>=18'} + get-intrinsic@1.3.0: resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} engines: {node: '>= 0.4'} @@ -2386,6 +2524,11 @@ packages: markdown-table@3.0.4: resolution: {integrity: sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==} + marked@17.0.1: + resolution: {integrity: sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==} + engines: {node: '>= 20'} + hasBin: true + math-intrinsics@1.1.0: resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} engines: {node: '>= 0.4'} @@ -2718,11 +2861,24 @@ packages: resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} engines: {node: '>= 0.10'} + react-devtools-core@7.0.1: + resolution: {integrity: sha512-C3yNvRHaizlpiASzy7b9vbnBGLrhvdhl1CbdU6EnZgxPNbai60szdLtl+VL76UNOt5bOoVTOz5rNWZxgGt+Gsw==} + react-dom@19.2.5: resolution: {integrity: sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==} peerDependencies: react: ^19.2.5 + react-reconciler@0.33.0: + resolution: {integrity: sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA==} + engines: {node: '>=0.10.0'} + peerDependencies: + react: ^19.2.0 + + react@19.2.0: + resolution: {integrity: sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==} + engines: {node: '>=0.10.0'} + react@19.2.5: resolution: {integrity: sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==} engines: {node: '>=0.10.0'} @@ -2836,6 +2992,10 @@ packages: resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} engines: {node: '>=8'} + shell-quote@1.11.0: + resolution: {integrity: sha512-JdxDPD0DBTyu08pq0kPC0xSNet/qsU07qT6IsX1AS8oO2ICNRY4ldNa8OAI6PuwAH8tG3lxEhbqmyp4Dw4036g==} + engines: {node: '>= 0.4'} + side-channel-list@1.0.1: resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} engines: {node: '>= 0.4'} @@ -2887,6 +3047,22 @@ packages: std-env@4.2.0: resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + + string-width@8.1.0: + resolution: {integrity: sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg==} + engines: {node: '>=20'} + + strip-ansi@7.1.2: + resolution: {integrity: sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA==} + engines: {node: '>=12'} + + strip-ansi@7.2.0: + resolution: {integrity: sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==} + engines: {node: '>=12'} + strip-json-comments@5.0.3: resolution: {integrity: sha512-1tB5mhVo7U+ETBKNf92xT4hrQa3pm0MZ0PQvuDnWgAAGHDsfp4lPSpiS6psrSiet87wyGPh9ft6wmhOMQ0hDiw==} engines: {node: '>=14.16'} @@ -3151,6 +3327,14 @@ packages: resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==} engines: {node: '>=18'} + web-tree-sitter@0.25.10: + resolution: {integrity: sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA==} + peerDependencies: + '@types/emscripten': ^1.40.0 + peerDependenciesMeta: + '@types/emscripten': + optional: true + webidl-conversions@8.0.1: resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==} engines: {node: '>=20'} @@ -3179,6 +3363,18 @@ packages: wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + ws@7.5.13: + resolution: {integrity: sha512-rsKI6xDBFVf4r/x8XyChGK04QR/XHroxs/jUcoWvtEZM8TPU/X/uIY9B1CsSzYws9ZJb/6bbBu7dPhFW00CAoA==} + engines: {node: '>=8.3.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: ^5.0.2 + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + ws@8.21.3: resolution: {integrity: sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==} engines: {node: '>=10.0.0'} @@ -3663,6 +3859,61 @@ snapshots: '@openai/codex@0.158.0': {} + '@opentui/core-darwin-arm64@0.5.6': + optional: true + + '@opentui/core-darwin-x64@0.5.6': + optional: true + + '@opentui/core-linux-arm64-musl@0.5.6': + optional: true + + '@opentui/core-linux-arm64@0.5.6': + optional: true + + '@opentui/core-linux-x64-musl@0.5.6': + optional: true + + '@opentui/core-linux-x64@0.5.6': + optional: true + + '@opentui/core-win32-arm64@0.5.6': + optional: true + + '@opentui/core-win32-x64@0.5.6': + optional: true + + '@opentui/core@0.5.6(typescript@5.9.3)(web-tree-sitter@0.25.10)': + dependencies: + bun-ffi-structs: 0.3.1(typescript@5.9.3) + diff: 9.0.0 + marked: 17.0.1 + string-width: 7.2.0 + strip-ansi: 7.1.2 + web-tree-sitter: 0.25.10 + optionalDependencies: + '@opentui/core-darwin-arm64': 0.5.6 + '@opentui/core-darwin-x64': 0.5.6 + '@opentui/core-linux-arm64': 0.5.6 + '@opentui/core-linux-arm64-musl': 0.5.6 + '@opentui/core-linux-x64': 0.5.6 + '@opentui/core-linux-x64-musl': 0.5.6 + '@opentui/core-win32-arm64': 0.5.6 + '@opentui/core-win32-x64': 0.5.6 + transitivePeerDependencies: + - typescript + + '@opentui/react@0.5.6(react-devtools-core@7.0.1)(react@19.2.0)(typescript@5.9.3)(web-tree-sitter@0.25.10)(ws@8.21.3)': + dependencies: + '@opentui/core': 0.5.6(typescript@5.9.3)(web-tree-sitter@0.25.10) + react: 19.2.0 + react-devtools-core: 7.0.1 + react-reconciler: 0.33.0(react@19.2.0) + ws: 8.21.3 + transitivePeerDependencies: + - typescript + - web-tree-sitter + '@oxc-project/types@0.133.0': {} '@oxc-project/types@0.143.0': {} @@ -3936,6 +4187,10 @@ snapshots: tslib: 2.8.1 optional: true + '@types/bun@1.3.14': + dependencies: + bun-types: 1.3.14 + '@types/chai@5.2.3': dependencies: '@types/deep-eql': 4.0.2 @@ -3998,6 +4253,10 @@ snapshots: dependencies: '@types/react': 19.2.14 + '@types/react@19.2.0': + dependencies: + csstype: 3.2.3 + '@types/react@19.2.14': dependencies: csstype: 3.2.3 @@ -4259,6 +4518,8 @@ snapshots: json-schema-traverse: 1.0.0 require-from-string: 2.0.2 + ansi-regex@6.4.0: {} + ansis@4.3.1: {} assertion-error@2.0.1: {} @@ -4293,6 +4554,14 @@ snapshots: node-releases: 2.0.56 update-browserslist-db: 1.3.3(browserslist@4.29.0) + bun-ffi-structs@0.3.1(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + bun-types@1.3.14: + dependencies: + '@types/node': 24.12.2 + bundle-name@4.1.0: dependencies: run-applescript: 7.1.0 @@ -4453,6 +4722,8 @@ snapshots: elkjs@0.11.1: {} + emoji-regex@10.6.0: {} + empathic@2.0.1: {} encodeurl@2.0.0: {} @@ -4602,6 +4873,8 @@ snapshots: gensync@1.0.0-beta.2: {} + get-east-asian-width@1.4.0: {} + get-intrinsic@1.3.0: dependencies: call-bind-apply-helpers: 1.0.2 @@ -4822,6 +5095,8 @@ snapshots: markdown-table@3.0.4: {} + marked@17.0.1: {} + math-intrinsics@1.1.0: {} mdast-util-find-and-replace@3.0.2: @@ -5367,11 +5642,26 @@ snapshots: iconv-lite: 0.7.3 unpipe: 1.0.0 + react-devtools-core@7.0.1: + dependencies: + shell-quote: 1.11.0 + ws: 7.5.13 + transitivePeerDependencies: + - bufferutil + - utf-8-validate + react-dom@19.2.5(react@19.2.5): dependencies: react: 19.2.5 scheduler: 0.27.0 + react-reconciler@0.33.0(react@19.2.0): + dependencies: + react: 19.2.0 + scheduler: 0.27.0 + + react@19.2.0: {} + react@19.2.5: {} real-require@0.2.0: {} @@ -5510,6 +5800,8 @@ snapshots: shebang-regex@3.0.0: {} + shell-quote@1.11.0: {} + side-channel-list@1.0.1: dependencies: es-errors: 1.3.0 @@ -5564,6 +5856,25 @@ snapshots: std-env@4.2.0: {} + string-width@7.2.0: + dependencies: + emoji-regex: 10.6.0 + get-east-asian-width: 1.4.0 + strip-ansi: 7.1.2 + + string-width@8.1.0: + dependencies: + get-east-asian-width: 1.4.0 + strip-ansi: 7.2.0 + + strip-ansi@7.1.2: + dependencies: + ansi-regex: 6.4.0 + + strip-ansi@7.2.0: + dependencies: + ansi-regex: 6.4.0 + strip-json-comments@5.0.3: {} styleq@0.2.1: {} @@ -5790,6 +6101,8 @@ snapshots: dependencies: xml-name-validator: 5.0.0 + web-tree-sitter@0.25.10: {} + webidl-conversions@8.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -5815,6 +6128,8 @@ snapshots: wrappy@1.0.2: {} + ws@7.5.13: {} + ws@8.21.3: {} wsl-utils@1.0.0: diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index a8164362c..60ba5d5d0 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -3,6 +3,8 @@ packages: - "packages/agent-plugins/pi" - "packages/review/app" - "diffr/diffr-ts" + - "diffr/packages/viewer" + - "diffr/packages/tui" - "apps/*" catalog: From 5a3fa62beef34cdeab624f32101061cf135c2148 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:46 -0700 Subject: [PATCH 06/25] Plan the TUI's code cells in @diffr/viewer (AI-assisted) planCell lays one side of a code row out as coloured runs, with the cells a click folds or opens and the scope the pointer focuses. CodeRowView draws the runs and routes OpenTUI's mouse through those targets. No behaviour change. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 2 +- .../packages/tui/src/ui/diff/CodeRowView.tsx | 136 ++---------------- diffr/packages/viewer/src/document/rows.ts | 6 - diffr/packages/viewer/src/viewport/cell.ts | 113 +++++++++++++++ 4 files changed, 128 insertions(+), 129 deletions(-) create mode 100644 diffr/packages/viewer/src/viewport/cell.ts diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index 07d5cf69b..c03569a01 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -16,7 +16,7 @@ import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "@ import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; -import type { ScopeFocus } from "@diffr/viewer/document/rows"; +import type { ScopeFocus } from "@diffr/viewer/viewport/cell"; import { rowsForFile, type Layout, diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx index 38a81cb32..ba9786c03 100644 --- a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -1,11 +1,10 @@ /** Paint measured Hunk-style code cells; source identity and viewport geometry stay outside React. */ import { memo } from "react"; import { StyledText, parseColor, type MouseEvent } from "@opentui/core"; -import type { RenderSpan, ScopeFocus, SplitLineCell, UnifiedLineCell } from "@diffr/viewer/document/rows"; +import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "@diffr/viewer/document/rows"; import type { Geometry, MeasuredRow } from "@diffr/viewer/viewport/geometry"; -import { foldBackground, type Palette } from "@diffr/viewer/theme/palette"; -import type { RowFold } from "@diffr/viewer/document/regions"; -import { measureTextWidth } from "@diffr/viewer/terminal/text"; +import { planCell, targetAt, type PaintRun, type ScopeFocus } from "@diffr/viewer/viewport/cell"; +import type { Palette } from "@diffr/viewer/theme/palette"; const colors = new Map>(); function color(value: string) { let c = colors.get(value); @@ -15,36 +14,8 @@ function color(value: string) { } return c; } -/** The focused scope's rail and brackets take the accent; armed, the rail thickens and the - * brackets get the bracket-match box. */ -function styled(spans: RenderSpan[], theme: Palette, bg: string, focus?: ScopeFocus) { - return new StyledText( - spans.map((span) => { - const rail = span.guide !== undefined && span.guide === focus?.id; - const brace = span.brace !== undefined && span.brace === focus?.id; - return { - __isChunk: true as const, - text: rail && focus!.armed ? span.text.replace(/│/g, "┃") : span.text, - fg: color(rail || brace ? theme.accent : span.fg ?? theme.fg), - bg: color(brace && focus!.armed ? theme.focusBrace : span.bg ?? bg), - }; - }), - ); -} -function chevron(fold: RowFold | undefined) { - if (!fold) return " "; - return fold.collapsed ? "▸" : "▾"; -} -/** The rail under a terminal column of a row's spans, if any. */ -function railAt(spans: RenderSpan[], column: number): number | undefined { - let at = 0; - for (const span of spans) { - const width = measureTextWidth(span.text); - if (column < at + width) return span.guide; - at += width; - } - return undefined; -} +const styled = (runs: PaintRun[]) => + new StyledText(runs.map((run) => ({ __isChunk: true as const, text: run.text, fg: color(run.fg), bg: color(run.bg) }))); export const CodeRowView = memo(function CodeRowView({ measured, visualLine, @@ -76,110 +47,31 @@ export const CodeRowView = memo(function CodeRowView({ side: "left" | "right", unified = false, ) { - const fold = value.fold; - const washed = focus?.armed && value.body?.includes(focus.id); - const bg = - selectedSide === side - ? theme.highlight - : value.kind === "addition" - ? theme.addition - : value.kind === "deletion" - ? theme.deletion - : washed ? theme.focusWash : theme.bg; - // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. - const digits = geometry.gutter - 4; - const number = (n: number | undefined) => `${visualLine ? "" : (n ?? "")}`.padStart(digits); - const numbers = unified - ? ` ${number((value as UnifiedLineCell).oldLineNumber)} ${number((value as UnifiedLineCell).newLineNumber)} ` - : ` ${number("lineNumber" in value ? value.lineNumber : undefined)} `; - const gutterWidth = unified ? geometry.unifiedGutter : geometry.gutter; - const available = Math.max(1, width - gutterWidth); - const used = Math.min(available, spans.reduce((n, s) => n + measureTextWidth(s.text), 0)); - // A collapsed row's tint fills the rest of the line; its header row also draws a ┄ rule to - // the edge, so a fold reads as a seam in the code rather than a band like a file header. - const rest = available - used; - const painted = value.band && rest > 0 - ? [...spans, {text: (value.fold ? " " + "┄".repeat(rest) : " ".repeat(rest)).slice(0, rest), - fg: theme.guide, bg: foldBackground(theme, value.band)}] : spans; - // What the pointer is on. A rail or the chevron arms its scope, so a click folds it, and a - // collapsed fold's chevron or label arms that fold, so a click opens it; anywhere else on the - // row reads the innermost scope around the line. - const chevronColumn = numbers.length, codeColumn = numbers.length + 2; - const opens = fold?.collapsed ? fold.id : value.labelOf; - const rail = (event: MouseEvent) => { - const column = event.x - (event.currentTarget?.x ?? 0); - return column >= codeColumn ? railAt(painted, column - codeColumn) : undefined; - }; - const target = (event: MouseEvent): ScopeFocus | undefined => { - const column = event.x - (event.currentTarget?.x ?? 0); - const id = column === chevronColumn && fold && !visualLine ? fold.id - : rail(event) ?? (column >= codeColumn ? opens : undefined); - if (id !== undefined) return { id, armed: true }; - return value.scope === undefined ? undefined : { id: value.scope, armed: false }; - }; - // Open chevrons rest faint so they don't compete with the code; a collapsed one stays - // legible, since it is the way back in, and the focused scope's lights up, open or not. - const chevronFg = fold && fold.id === focus?.id ? theme.accent - : fold?.collapsed ? theme.muted : theme.guide; + const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side }); + const column = (event: MouseEvent) => event.x - (event.currentTarget?.x ?? 0); + // Pressing a fold target doesn't start a selection. + const fold = (event: MouseEvent) => targetAt(plan.hits, column(event)); return ( { if (event.button !== 0) return; - if (rail(event) !== undefined) event.stopPropagation(); + if (fold(event) !== undefined) event.stopPropagation(); else onSelect(side); }} onMouseUp={(event) => { - const id = event.button === 0 ? rail(event) : undefined; + const id = event.button === 0 ? fold(event) : undefined; if (id === undefined) return; event.stopPropagation(); onFold(id, event.modifiers.alt); }} - onMouseMove={(event) => { onExtend(); onHover(target(event)); }} + onMouseMove={(event) => { onExtend(); onHover(targetAt(plan.hovers, column(event))); }} onMouseOut={() => onHover(undefined)} > - - {numbers} - - { - if (fold && !visualLine) event.stopPropagation(); - }} - onMouseUp={(event) => { - if (fold && !visualLine && event.button === 0) { - event.stopPropagation(); - onFold(fold.id, event.modifiers.alt); - } - }} - > - {visualLine ? " " : chevron(fold)} - - - {" "} - - { if (opens !== undefined) event.stopPropagation(); }} - onMouseUp={event => { - if (opens !== undefined && event.button === 0) { event.stopPropagation(); onFold(opens, event.modifiers.alt); } - }} - content={styled( - selectedSide === side ? painted.map((s) => ({ ...s, bg })) : painted, - theme, - bg, - focus, - )} - selectable={false} - /> - + ); } diff --git a/diffr/packages/viewer/src/document/rows.ts b/diffr/packages/viewer/src/document/rows.ts index c62d29e13..71710bccb 100644 --- a/diffr/packages/viewer/src/document/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -50,12 +50,6 @@ export interface UnifiedLineCell { body?: number[]; spans: RenderSpan[]; } - -/** The scope the pointer is on. Armed when it points at the scope's rail or chevron, which fold it. */ -export interface ScopeFocus { - id: number; - armed: boolean; -} export interface ViewerRow { key: string; fileIndex: number; diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts new file mode 100644 index 000000000..c1cfc0255 --- /dev/null +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -0,0 +1,113 @@ +import type { Geometry } from "./geometry"; +import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../document/rows"; +import { measureTextWidth } from "../terminal/text"; +import type { RowFold } from "../document/regions"; +import { foldBackground, type Palette } from "../theme/palette"; + +/** The scope the pointer is on. Armed when it points at the scope's rail or chevron, which fold it. */ +export interface ScopeFocus { + id: number; + armed: boolean; +} + +export interface PaintRun { + text: string; + fg: string; + bg: string; +} + +export type Target = [from: number, to: number, value: T]; + +/** Targets are listed most specific first. */ +export const targetAt = (targets: readonly Target[], x: number): T | undefined => + targets.find(([from, to]) => x >= from && x < to)?.[2]; + +interface CellPlan { + bg: string; + runs: PaintRun[]; + hits: Target[]; + hovers: Target[]; +} + +export interface CellOptions { + theme: Palette; + geometry: Geometry; + visualLine: number; + focus?: ScopeFocus; + selected?: boolean; +} + +function chevron(fold: RowFold | undefined) { + if (!fold) return " "; + return fold.collapsed ? "▸" : "▾"; +} + +export function planCell( + value: SplitLineCell | UnifiedLineCell, + spans: RenderSpan[], + width: number, + unified: boolean, + { theme, geometry, visualLine, focus, selected = false }: CellOptions, +): CellPlan { + const fold = value.fold; + const washed = focus?.armed && value.body?.includes(focus.id); + const bg = selected ? theme.highlight + : value.kind === "addition" ? theme.addition + : value.kind === "deletion" ? theme.deletion + : washed ? theme.focusWash : theme.bg; + // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. + const digits = geometry.gutter - 4; + const number = (n: number | undefined) => `${visualLine ? "" : (n ?? "")}`.padStart(digits); + const numbers = unified + ? ` ${number((value as UnifiedLineCell).oldLineNumber)} ${number((value as UnifiedLineCell).newLineNumber)} ` + : ` ${number("lineNumber" in value ? value.lineNumber : undefined)} `; + const gutterWidth = unified ? geometry.unifiedGutter : geometry.gutter; + const available = Math.max(1, width - gutterWidth); + const used = Math.min(available, spans.reduce((n, s) => n + measureTextWidth(s.text), 0)); + // A collapsed row's tint fills the rest of the line; its header row also draws a ┄ rule to + // the edge, so a fold reads as a seam in the code rather than a band like a file header. + const rest = available - used; + const painted = value.band && rest > 0 + ? [...spans, { text: (value.fold ? " " + "┄".repeat(rest) : " ".repeat(rest)).slice(0, rest), + fg: theme.guide, bg: foldBackground(theme, value.band) }] : spans; + // Open chevrons rest faint so they don't compete with the code; a collapsed one stays + // legible, since it is the way back in, and the focused scope's lights up, open or not. + const chevronFg = fold && fold.id === focus?.id ? theme.accent + : fold?.collapsed ? theme.muted : theme.guide; + const chevronColumn = numbers.length, codeColumn = chevronColumn + 2; + const runs: PaintRun[] = [ + { text: numbers, fg: theme.muted, bg }, + { text: visualLine ? " " : chevron(fold), fg: chevronFg, bg }, + { text: " ", fg: theme.fg, bg }, + ]; + const hits: Target[] = [], hovers: Target[] = []; + // A rail or chevron arms its scope to fold; a collapsed fold's chevron or label arms it to open. + if (fold && !visualLine) { + hits.push([chevronColumn, chevronColumn + 1, fold.id]); + hovers.push([chevronColumn, chevronColumn + 1, { id: fold.id, armed: true }]); + } + // A collapsed fold's row, or a line of its label, opens it. + const opens = fold?.collapsed ? fold.id : value.labelOf; + if (opens !== undefined) hits.push([codeColumn, width, opens]); + let column = codeColumn; + for (const span of painted) { + const rail = span.guide !== undefined && span.guide === focus?.id; + const brace = span.brace !== undefined && span.brace === focus?.id; + const text = rail && focus!.armed ? span.text.replace(/│/g, "┃") : span.text; + const cells = measureTextWidth(text); + runs.push({ + text, + fg: rail || brace ? theme.accent : span.fg ?? theme.fg, + bg: brace && focus!.armed ? theme.focusBrace : selected ? bg : span.bg ?? bg, + }); + if (span.guide !== undefined) { + hits.push([column, column + cells, span.guide]); + hovers.push([column, column + cells, { id: span.guide, armed: true }]); + } + column += cells; + } + if (column < width) runs.push({ text: " ".repeat(width - column), fg: theme.fg, bg }); + if (opens !== undefined) hovers.push([codeColumn, width, { id: opens, armed: true }]); + if (value.scope !== undefined) hovers.push([0, width, { id: value.scope, armed: false }]); + return { bg, runs, hits, hovers }; +} From d741501ec5acb647a7e5d01a2d156f1017deea84 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:50 -0700 Subject: [PATCH 07/25] Click the rail the pointer arms, even beside a fold's label (AI-assisted) On a collapsed fold's label line, the pointer over an enclosing scope's rail arms that scope, but a click opened the label's fold instead. A click now picks the same target as the pointer. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/viewer/src/viewport/cell.ts | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts index c1cfc0255..e27ab9c49 100644 --- a/diffr/packages/viewer/src/viewport/cell.ts +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -86,9 +86,6 @@ export function planCell( hits.push([chevronColumn, chevronColumn + 1, fold.id]); hovers.push([chevronColumn, chevronColumn + 1, { id: fold.id, armed: true }]); } - // A collapsed fold's row, or a line of its label, opens it. - const opens = fold?.collapsed ? fold.id : value.labelOf; - if (opens !== undefined) hits.push([codeColumn, width, opens]); let column = codeColumn; for (const span of painted) { const rail = span.guide !== undefined && span.guide === focus?.id; @@ -107,7 +104,12 @@ export function planCell( column += cells; } if (column < width) runs.push({ text: " ".repeat(width - column), fg: theme.fg, bg }); - if (opens !== undefined) hovers.push([codeColumn, width, { id: opens, armed: true }]); + // A collapsed fold's row, or a line of its label, opens it. + const opens = fold?.collapsed ? fold.id : value.labelOf; + if (opens !== undefined) { + hits.push([codeColumn, width, opens]); + hovers.push([codeColumn, width, { id: opens, armed: true }]); + } if (value.scope !== undefined) hovers.push([0, width, { id: value.scope, armed: false }]); return { bg, runs, hits, hovers }; } From 1fea674ea248e8b7b9047a7756436000943ba7da Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:54 -0700 Subject: [PATCH 08/25] Move the TUI's viewer state and keys into @diffr/viewer (AI-assisted) Viewer holds open files and folds, the reader's position, layout mode, wrap, theme, pan, the scope under the pointer, and the fold and navigation keys. It lays out the diff column at a given size and notifies on change, so App reads it through useSyncExternalStore. App keeps its file tree, menus, selection and breakdown. No behaviour change. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 293 +++++------------------ diffr/packages/viewer/src/viewer.ts | 358 ++++++++++++++++++++++++++++ 2 files changed, 412 insertions(+), 239 deletions(-) create mode 100644 diffr/packages/viewer/src/viewer.ts diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index c03569a01..8b3537c1a 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -11,34 +11,31 @@ import { useRenderer, useTerminalDimensions, } from "@opentui/react"; -import { TextAttributes } from "@opentui/core"; -import { buildFileTree, flattenFileTree, parentDirectories, lineCounts } from "@diffr/viewer/document/fileTree"; +import { TextAttributes, type KeyEvent } from "@opentui/core"; +import { buildFileTree, flattenFileTree, lineCounts, parentDirectories } from "@diffr/viewer/document/fileTree"; import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; -import type { ScopeFocus } from "@diffr/viewer/viewport/cell"; -import { - rowsForFile, - type Layout, - type ViewerRow, -} from "@diffr/viewer/document/rows"; -import type { Palette } from "@diffr/viewer/theme/palette"; import type { ThemeSet } from "../diffr/theme"; -import { measureRows, visibleRows, positionAt, positionTop, rowFold, type ViewPosition } from "@diffr/viewer/viewport/geometry"; import { copySelection, selectionBounds, type SourceSelection, } from "@diffr/viewer/document/selection"; -import { filePath, fileVisibility, type DiffFile, type TextDiff } from "@diffr/viewer/protocol/wire"; -import { defaultCollapsed, foldIds, gapIds, nestedIds, sourceLines } from "@diffr/viewer/document/regions"; -import { placeholderRows } from "@diffr/viewer/document/rows"; +import { filePath } from "@diffr/viewer/protocol/wire"; import { add, blockBar, comparisonLabel, zero, type LineCounts } from "@diffr/viewer/document/counts"; import type { DiffStore } from "@diffr/viewer/protocol/store"; +import { visibleRows } from "@diffr/viewer/viewport/geometry"; import { sanitizeTerminalLine } from "@diffr/viewer/terminal/sanitize"; import { measureTextWidth, sliceTextByWidth } from "@diffr/viewer/terminal/text"; +import { Viewer, type KeyPress } from "@diffr/viewer/viewer"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; +/** Shifted letters become capitals; cmd counts as meta. */ +const keyPress = (key: KeyEvent): KeyPress => ({ + key: key.shift && /^[a-z]$/.test(key.name) ? key.name.toUpperCase() : key.name, + ctrl: key.ctrl, shift: key.shift, meta: key.meta || key.super === true, +}); export function App({ store, onQuit, @@ -48,31 +45,13 @@ export function App({ onQuit: () => void; themes: ThemeSet; }) { - const snapshot = useSyncExternalStore(store.subscribe, store.getSnapshot); + const viewer = useMemo(() => new Viewer(store, themes.initial, 100), [store, themes]); + useSyncExternalStore(viewer.subscribe, viewer.getVersion); const renderer = useRenderer(), { width, height } = useTerminalDimensions(); - const [mode, setMode] = useState("auto"), - [showSidebar, setShowSidebar] = useState(true), - [wrap, setWrap] = useState(false), - [theme, setTheme] = useState(themes.initial); - const [position, setPosition] = useState(null); - const [horizontal, setHorizontal] = useState(0); - const [hoveredFold, setHoveredFold] = useState<{file: number; focus: ScopeFocus} | null>(null); - const [spinner, setSpinner] = useState(0); - useEffect(() => { - if (snapshot.complete) return; - const timer = setInterval(() => setSpinner(n => (n + 1) % 10), 80); - return () => clearInterval(timer); - }, [snapshot.complete]); - const loadingGlyph = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[spinner]; - // Files the user closed or opened; unset files follow the visibility on their file record. - const [closed, setClosed] = useState>(new Map()); + const [showSidebar, setShowSidebar] = useState(true); const [selection, setSelection] = useState(null), [message, setMessage] = useState(""); - // Fold ids collapsed per manifest file; unset files start where diffr's visibility says. - const [collapsed, setCollapsed] = useState>>(new Map()); - // Vim's z prefix: the next key names the fold command. - const pendingZ = useRef(false); const [closedDirectories, setClosedDirectories] = useState>(new Set()); const [treeScroll, setTreeScroll] = useState(0); const [sidebarWidth, setSidebarWidth] = useState(28); @@ -81,141 +60,25 @@ export function App({ const [showBreakdown, setShowBreakdown] = useState(false); const dragging = useRef(false), thumbDragging = useRef(false); - // `t` swaps between the two bundled defaults; a configured theme is left by the first press. - const toggleTheme = () => setTheme((current) => (current.isLight ? themes.dark : themes.light)); const sidebar = showSidebar && width >= 60 ? Math.max(16, Math.min(sidebarWidth, width - 40)) : 0; const contentWidth = Math.max(10, width - sidebar - 1), viewportHeight = Math.max(1, height - 3); - const layout = - mode === "auto" ? (contentWidth >= 100 ? "split" : "unified") : mode; - // Viewer state is keyed by manifest index; `files[i]` is undefined until file i arrives. + const { snapshot, theme, wrap, hover: hovered, layout, rows, geometry, top, maxScroll, viewport, currentFile, sticky, thumb } = + viewer.lay({ columns: contentWidth, rows: viewportHeight }); + const [spinner, setSpinner] = useState(0); + useEffect(() => { + if (snapshot.complete) return; + const timer = setInterval(() => setSpinner(n => (n + 1) % 10), 80); + return () => clearInterval(timer); + }, [snapshot.complete]); + const loadingGlyph = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"[spinner]; const { inventory, files, failures } = snapshot; - const textDiffs = useMemo(() => files.flatMap((file, index) => - file?.diff.type === "text" ? [{ index, diff: file.diff }] : []), [files]); - const foldsOf = (index: number, diff: TextDiff): ReadonlySet => collapsed.get(index) ?? defaultCollapsed(diff); - const isClosed = (index: number, file: DiffFile) => closed.get(index) ?? fileVisibility(file).collapsed; // The file's mark in the tree and on its header while it has no diff. const statusGlyph = (index: number) => failures[index] || snapshot.complete ? "!" : loadingGlyph; const tree = useMemo(() => buildFileTree(inventory), [inventory]); const fileOrder = useMemo(() => flattenFileTree(tree, new Set()).flatMap(({node}) => node.fileIndex === undefined ? [] : [node.fileIndex]), [tree]); - const rowCache = useRef( - new WeakMap(), - ); - const rows = useMemo(() => { - const perFile = fileOrder.map((index): ViewerRow[] => { - const file = files[index]; - if (!file) { - const failure = failures[index]; - const status = failure ?? (snapshot.complete ? "File did not load" : "Computing diff…"); - return [{ key: `${index}:header`, fileIndex: index, label: filePath(inventory[index].file) }, - ...["", status, "", ""].map((label, line) => ({ key: `${index}:pending:${line}`, fileIndex: index, - label, pending: line === 1 && !failure && !snapshot.complete }))]; - } - const folds = file.diff.type === "text" ? foldsOf(index, file.diff) : new Set(); - const key = `${index}:${layout}:${theme.name}:${[...folds].sort((a, b) => a - b).join(",")}`; - let cached = rowCache.current.get(file); - if (cached?.key !== key) { - cached = { key, rows: rowsForFile(file, index, layout, theme, folds) }; - rowCache.current.set(file, cached); - } - if (!isClosed(index, file)) return cached.rows; - return fileVisibility(file).collapsed - ? [cached.rows[0], ...placeholderRows(index, fileVisibility(file).label)] - : cached.rows.slice(0, 1); - }); - // A blank row closes an open file before the next header. It belongs to the file it closes, - // so the sticky header doesn't repeat the header just below it. - const all: ViewerRow[] = []; - for (const fileRows of perFile) { - const last = all.at(-1); - if (last && !last.key.endsWith(":header") && last.label !== "") - all.push({ key: `${last.fileIndex}:end`, fileIndex: last.fileIndex, label: "" }); - all.push(...fileRows); - } - for (const [i, error] of snapshot.errors.entries()) - all.push({ key: `error:${i}`, fileIndex: -1, label: error }); - return all; - }, [files, failures, snapshot.errors, snapshot.complete, inventory, layout, theme, closed, fileOrder, collapsed]); - const maxLine = useMemo(() => Math.max(1, ...textDiffs.flatMap(({ diff }) => - [diff.lhs, diff.rhs].map(source => source ? sourceLines(source.text).length : 0))), [textDiffs]); - const geometry = useMemo( - () => measureRows(rows, contentWidth, wrap, horizontal, maxLine), - [rows, contentWidth, wrap, horizontal, maxLine], - ); - const lastFileTop = useMemo(() => - geometry.rows.findLast(r => r.row.key.endsWith(":header"))?.top ?? 0, [geometry]); - // One past the end, since the sticky file header takes the viewport's first row. - const maxScroll = Math.max(lastFileTop, geometry.height - viewportHeight + 1); - const clamp = (value: number) => Math.max(0, Math.min(maxScroll, value)); - const top = Math.min(positionTop(geometry, position), maxScroll); - const scrollTo = (value: number) => setPosition(positionAt(geometry, clamp(value))); - // Relative to the latest position, so key repeats within one frame add up. - const move = (amount: number) => - setPosition(current => positionAt(geometry, clamp(Math.min(positionTop(geometry, current), maxScroll) + amount))); - const toggleFile = (index: number) => { - const file = files[index]; - if (!file) return; - // Toggling the file being read moves to its header; the rows above it stay put. - const header = geometry.rows.find(r => r.row.key === `${index}:header`)!; - if (header.top < top) setPosition({ key: header.row.key, fileIndex: index, offset: 0, side: "right" }); - setClosed(old => new Map(old).set(index, !(old.get(index) ?? fileVisibility(file).collapsed))); - }; - // "toggle" reads the current state inside the update, so quick repeated clicks alternate. - const setFolds = (fileIndex: number, diff: TextDiff, ids: number[], collapse: boolean | "toggle") => - setCollapsed((old) => { - const next = new Set(old.get(fileIndex) ?? defaultCollapsed(diff)); - const close = collapse === "toggle" ? !next.has(ids[0]) : collapse; - for (const id of ids) if (close) next.add(id); else next.delete(id); - return new Map(old).set(fileIndex, next); - }); - // Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. - const setFold = (fileIndex: number, id: number, collapse: boolean | "toggle", recursive: boolean) => { - const diff = files[fileIndex]?.diff; - if (diff?.type !== "text") throw new Error(`File ${fileIndex} has no folds`); - setFolds(fileIndex, diff, recursive ? [id, ...nestedIds(diff, id)] : [id], collapse); - }; - // `c`: reveal every context gap, or hide them again. - const toggleContext = () => { - const opened = textDiffs.some(({ index, diff }) => gapIds(diff).some((id) => !foldsOf(index, diff).has(id))); - textDiffs.forEach(({ index, diff }) => setFolds(index, diff, gapIds(diff), opened)); - }; - const toggleFold = (fileIndex: number, id: number, recursive: boolean) => - setFold(fileIndex, id, "toggle", recursive); - const navigateFold = (direction: number) => { - const headers = geometry.rows.filter((r) => rowFold(r.row)); - const target = - direction > 0 - ? headers.find((r) => r.top > top) - : headers.findLast((r) => r.top < top); - if (target) scrollTo(target.top); - }; - // Vim fold commands act on the fold whose header is the top visible row. - const foldCommand = (command: string) => { - if (command === "R") return foldAll(false); - if (command === "M") return foldAll(true); - if (command === "j") return navigateFold(1); - if (command === "k") return navigateFold(-1); - const current = visibleRows(geometry, top, 1)[0]?.row; - const fold = current && rowFold(current); - if (!current || !fold) return; - const recursive = command === command.toUpperCase(); - const letter = command.toLowerCase(); - if (letter === "a") toggleFold(current.fileIndex, fold.id, recursive); - else if (letter === "o") setFold(current.fileIndex, fold.id, false, recursive); - else if (letter === "c") setFold(current.fileIndex, fold.id, true, recursive); - }; - const foldAll = (collapse: boolean) => - textDiffs.forEach(({ index, diff }) => setFolds(index, diff, foldIds(diff), collapse)); - const jump = (index: number) => scrollTo(geometry.rows.find((r) => r.row.fileIndex === index)!.top); - const navigateHunk = (direction: number) => { - const headers = geometry.rows.filter((r) => r.row.hunkStart); - const target = - direction > 0 - ? headers.find((r) => r.top > top) - : headers.findLast((r) => r.top < top); - if (target) scrollTo(target.top); - }; + const toggleLayout = () => { viewer.toggleLayout(); setSelection(null); }; const copy = () => { if (!selection) return; const text = copySelection(files, rows, selection); @@ -225,46 +88,22 @@ export function App({ } }; useKeyboard((key) => { - // Hunk's chord matcher handles raw control bytes and Kitty events alike. - const is = (...chords: string[]) => !key.super && chords.some(chord => matchesKey(chord, key)); - if (pendingZ.current) { - pendingZ.current = false; - const command = key.shift ? key.name?.toUpperCase() : key.name; - if (!key.ctrl && !key.meta && command?.length === 1 && "aocAOCRMjk".includes(command)) - foldCommand(command); + // Viewer first, so a pending z chord takes any key. + const press = keyPress(key), chord = viewer.chording; + if (viewer.press(press)) { + // s and c reshape the rows, so drop the selection, unless they finish a z chord. + if (!chord && !press.ctrl && !press.meta && (press.key === "s" || press.key === "c")) setSelection(null); return; } + // Hunk's chord matcher handles raw control bytes and Kitty events alike. + const is = (...chords: string[]) => !key.super && chords.some(chord => matchesKey(chord, key)); if (is("q", "ctrl+c")) onQuit(); else if ((key.name === "b" && (key.super || key.meta)) || is("\\")) { key.preventDefault(); setShowSidebar(v => !v); } - else if (is("d", "ctrl+d", "u", "ctrl+u")) - move((is("d", "ctrl+d") ? 1 : -1) * Math.max(1, Math.floor(viewportHeight / 2))); - else if (is("pagedown", "space", "f", "ctrl+f")) move(viewportHeight); - else if (is("pageup", "b", "shift+space", "ctrl+b")) move(-viewportHeight); - else if (is("down", "j")) move(1); - else if (is("up", "k")) move(-1); - else if (is("g")) scrollTo(0); - else if (is("home")) scrollTo(0); - else if (is("G", "end")) scrollTo(maxScroll); - else if (is("right", "shift+right", "l")) setHorizontal(n => n + (key.shift ? 16 : 4)); - else if (is("left", "shift+left", "h")) setHorizontal(n => Math.max(0, n - (key.shift ? 16 : 4))); - else if (key.name === "]") navigateHunk(1); - else if (key.name === "[") navigateHunk(-1); - else if (key.name === "s") { - setMode(layout === "split" ? "unified" : "split"); - setSelection(null); - } else if (key.name === "w") setWrap((v) => !v); - else if (key.name === "c") { toggleContext(); setSelection(null); } - else if (key.name === "t") toggleTheme(); else if (key.name === "y") copy(); else if (key.name === "escape") { setSelection(null); setMenu(null); setShowBreakdown(false); } else if (key.name === "i") setShowBreakdown((v) => !v); - else if (key.name === "return") { - const current = visibleRows(geometry, top, 1)[0]; - if (current && current.row.fileIndex >= 0) - toggleFile(current.row.fileIndex); - } else if (is("z")) pendingZ.current = true; }); const [selectionStart, selectionEnd] = useMemo( () => selectionBounds(rows, selection), @@ -274,9 +113,6 @@ export function App({ () => new Map(rows.map((row, i) => [row.key, i])), [rows], ); - const viewport = visibleRows(geometry, top, viewportHeight); - const currentFile = viewport[0]?.row.fileIndex ?? 0; - const sticky = !!viewport.length && !viewport[0].row.key.endsWith(":header"); // Shortcuts past a file that is still loading. const place = fileOrder.indexOf(currentFile); const previousLoaded = fileOrder.slice(0, place).findLast(index => files[index]); @@ -316,13 +152,13 @@ export function App({ const loaded = !!file && !!count; const statsWidth = loaded ? String(count.added).length + String(count.removed).length + 5 : 0; const pathWidth = Math.max(1, contentWidth - statsWidth - 1); - const glyph = loaded ? (isClosed(fileIndex, file) ? "▸" : "▾") : statusGlyph(fileIndex); + const glyph = loaded ? (viewer.isClosed(fileIndex, file) ? "▸" : "▾") : statusGlyph(fileIndex); const directory = fit(`${glyph} ${path.slice(0, path.lastIndexOf("/") + 1)}`, pathWidth); const directoryWidth = measureTextWidth(directory); const name = fit(path.slice(path.lastIndexOf("/") + 1), Math.max(0, pathWidth - directoryWidth)); return { if (loaded) toggleFile(fileIndex); }}> + onMouseUp={() => { if (loaded) viewer.toggleFile(fileIndex); }}> ▌ {directory} { - if (row.loadDiff) toggleFile(row.fileIndex); + if (row.loadDiff) viewer.toggleFile(row.fileIndex); }} > {fit(sanitizeTerminalLine(row.pending ? ` ${loadingGlyph} ${row.label}` : row.loadDiff ? ` ${row.label}` : row.label), contentWidth)} @@ -381,10 +217,9 @@ export function App({ if (dragging.current) setSelection((s) => (s ? { ...s, end: row.key } : s)); }} - focus={hoveredFold?.file === row.fileIndex ? hoveredFold.focus : undefined} - onHover={focus => setHoveredFold(old => old?.file === row.fileIndex && old.focus.id === focus?.id - && old.focus.armed === focus.armed ? old : focus === undefined ? null : {file: row.fileIndex, focus})} - onFold={(id, recursive) => toggleFold(row.fileIndex, id, recursive)} + focus={hovered?.file === row.fileIndex ? hovered : undefined} + onHover={focus => viewer.setHover(focus ? { file: row.fileIndex, ...focus } : null)} + onFold={(id, recursive) => viewer.setFold(row.fileIndex, id, "toggle", recursive)} />, ); } @@ -393,33 +228,15 @@ export function App({ if (currentFile >= 0 && sticky) { rendered.unshift(fileHeader(currentFile, "sticky-header")); } - const thumbHeight = Math.max( - 1, - Math.floor( - (viewportHeight * viewportHeight) / - Math.max(viewportHeight, geometry.height), - ), - ); - const thumbTop = maxScroll - ? Math.round((top / maxScroll) * (viewportHeight - thumbHeight)) - : 0; - function scrub(y: number) { - scrollTo( - Math.round( - Math.max(0, Math.min(1, (y - 2) / Math.max(1, viewportHeight - 1))) * - maxScroll, - ), - ); - } const menuItems: Record void][]> = { File: [["Toggle file tree ⌘B / \\", () => setShowSidebar(v => !v)], ["Copy selection y", copy], ["Quit q", onQuit]], - View: [[`Layout: ${layout} s`, () => { setMode(layout === "split" ? "unified" : "split"); setSelection(null); }], - [`Wrap: ${wrap ? "on" : "off"} w`, () => setWrap(v => !v)], - ["Toggle context gaps c", () => { toggleContext(); setSelection(null); }], - ["Fold all zM", () => foldAll(true)], ["Unfold all zR", () => foldAll(false)]], - Navigate: [["Previous change [", () => navigateHunk(-1)], ["Next change ]", () => navigateHunk(1)], - ["First file Home", () => scrollTo(0)], ["Last file End", () => scrollTo(maxScroll)]], - Theme: [[`Dark (${themes.dark.name}) t`, () => setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => setTheme(themes.light)]], + View: [[`Layout: ${layout} s`, toggleLayout], + [`Wrap: ${wrap ? "on" : "off"} w`, () => viewer.toggleWrap()], + ["Toggle context gaps c", () => { viewer.toggleContext(); setSelection(null); }], + ["Fold all zM", () => viewer.foldAll(true)], ["Unfold all zR", () => viewer.foldAll(false)]], + Navigate: [["Previous change [", () => viewer.navigateHunk(-1)], ["Next change ]", () => viewer.navigateHunk(1)], + ["First file Home", () => viewer.scrollTo(0)], ["Last file End", () => viewer.scrollTo(maxScroll)]], + Theme: [[`Dark (${themes.dark.name}) t`, () => viewer.setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => viewer.setTheme(themes.light)]], Help: [["Scroll: j/k · h/l · gg/G", () => setMessage("j/k scroll · h/l pan · gg first · G last")], ["Half page: Ctrl-D / Ctrl-U", () => setMessage("d / Ctrl-D: half down · u / Ctrl-U: half up")], ["Full page: Ctrl-F / Ctrl-B", () => setMessage("Ctrl-F: page down · Ctrl-B: page up")], @@ -437,7 +254,7 @@ export function App({ if (sidebarDrag.current) { setSidebarWidth(resizeSidebarWidth(sidebarDrag.current.width, sidebarDrag.current.x, event.x, 16, width - 40)); - } else if (thumbDragging.current) scrub(event.y); + } else if (thumbDragging.current) viewer.scrub(event.y - 2); else if (dragging.current && !(event.y === 2 && !viewport[0]?.row.key.endsWith(":header"))) { const target = visibleRows( geometry, @@ -462,7 +279,7 @@ export function App({ ))} { - setMode(layout === "split" ? "unified" : "split"); setSelection(null); + toggleLayout(); }}>{` ${layout} [s] `} @@ -485,11 +302,9 @@ export function App({ const d = event.scroll; if (d) { if (d.direction === "left" || d.direction === "right") - setHorizontal((n) => - Math.max(0, n + (d.direction === "left" ? -4 : 4)), - ); + viewer.pan(d.direction === "left" ? -4 : 4); else - move((d.direction === "up" ? -1 : 1) * Math.max(1, d.delta) * 3); + viewer.move((d.direction === "up" ? -1 : 1) * Math.max(1, d.delta) * 3); } }} > @@ -509,7 +324,7 @@ export function App({ selectable={false} onMouseUp={() => { if (node.fileIndex !== undefined) { - setMessage(""); jump(node.fileIndex); + setMessage(""); viewer.jump(node.fileIndex); } else setClosedDirectories(old => { const next = new Set(old); @@ -544,8 +359,8 @@ export function App({ > {currentFile >= 0 && !files[currentFile] && !snapshot.complete && - {previousLoaded !== undefined && { event.stopPropagation(); jump(previousLoaded); }}> ↑ previous loaded } - {nextLoaded !== undefined && { event.stopPropagation(); jump(nextLoaded); }}> ↓ next loaded } + {previousLoaded !== undefined && { event.stopPropagation(); viewer.jump(previousLoaded); }}> ↑ previous loaded } + {nextLoaded !== undefined && { event.stopPropagation(); viewer.jump(nextLoaded); }}> ↓ next loaded } } {rendered.length ? ( rendered @@ -560,20 +375,20 @@ export function App({ height={viewportHeight} onMouseDown={(event) => { thumbDragging.current = true; - scrub(event.y); + viewer.scrub(event.y - 2); }} onMouseMove={(event) => { if (sidebarDrag.current) { setSidebarWidth(resizeSidebarWidth(sidebarDrag.current.width, sidebarDrag.current.x, event.x, 16, width - 40)); - } else if (thumbDragging.current) scrub(event.y); + } else if (thumbDragging.current) viewer.scrub(event.y - 2); }} > diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts new file mode 100644 index 000000000..42a493b6a --- /dev/null +++ b/diffr/packages/viewer/src/viewer.ts @@ -0,0 +1,358 @@ +import { buildFileTree, flattenFileTree } from "./document/fileTree"; +import { defaultCollapsed, foldIds, gapIds, nestedIds, sourceLines } from "./document/regions"; +import { placeholderRows, rowsForFile, type Layout, type ViewerRow } from "./document/rows"; +import type { DiffStore, Snapshot } from "./protocol/store"; +import { filePath, fileVisibility, type DiffFile, type TextDiff } from "./protocol/wire"; +import { measureRows, positionAt, positionTop, rowFold, visibleRows, type Geometry, type MeasuredRow, type ViewPosition } from "./viewport/geometry"; +import type { Palette } from "./theme/palette"; +import { dark, light } from "./theme/themes"; + +/** Excludes the frontend's scrollbar and sidebar. */ +export interface Size { + columns: number; + rows: number; +} + +/** `armed`: a click would fold or open the scope. */ +export interface Hover { + file: number; + id: number; + armed: boolean; +} + +/** A key as frontends report it; a shifted letter arrives as its capital. */ +export interface KeyPress { + key: string; + ctrl?: boolean; + shift?: boolean; + meta?: boolean; +} + +interface Laid { + size: Size; + snapshot: Snapshot; + theme: Palette; + layout: Layout; + wrap: boolean; + horizontal: number; + hover: Hover | null; + rows: ViewerRow[]; + geometry: Geometry; + /** First visible visual row. */ + top: number; + maxScroll: number; + viewport: MeasuredRow[]; + currentFile: number; + sticky: boolean; + thumb: { top: number; height: number }; +} + +export class Viewer { + private mode: Layout | "auto" = "auto"; + private wrap = false; + private position: ViewPosition | null = null; + private horizontal = 0; + private hover: Hover | null = null; + private readonly closed = new Map(); + private readonly collapsed = new Map>(); + private pendingZ = false; + private readonly rowCache = new WeakMap(); + /** Bumped when rows change; keys the geometry cache. */ + private revision = 0; + private measured: { key: string; snapshot: Snapshot; rows: ViewerRow[]; geometry: Geometry } | undefined; + private size: Size | undefined; + private readonly listeners = new Set<() => void>(); + /** Bumped by every change, for `useSyncExternalStore`. */ + private version = 0; + + /** `splitColumns`: the narrowest diff column `auto` lays out split. */ + constructor(private readonly store: DiffStore, private theme: Palette, private readonly splitColumns: number) { + store.subscribe(() => this.emit()); + } + + subscribe = (listener: () => void) => { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + }; + + getVersion = () => this.version; + + private emit() { + this.version++; + this.listeners.forEach((listener) => listener()); + } + + private reshape() { + this.revision++; + this.emit(); + } + + private get snapshot(): Snapshot { + return this.store.getSnapshot(); + } + + /** A `z` was pressed: the next key names a fold command. */ + get chording(): boolean { + return this.pendingZ; + } + + isClosed(index: number, file: DiffFile) { + return this.closed.get(index) ?? fileVisibility(file).collapsed; + } + + private foldsOf(index: number, diff: TextDiff): ReadonlySet { + return this.collapsed.get(index) ?? defaultCollapsed(diff); + } + + private fileOrder(snapshot: Snapshot) { + return flattenFileTree(buildFileTree(snapshot.inventory), new Set()).flatMap(({ node }) => + node.fileIndex === undefined ? [] : [node.fileIndex]); + } + + private documentRows(snapshot: Snapshot, layout: Layout): ViewerRow[] { + const { inventory, files, failures } = snapshot; + const perFile = this.fileOrder(snapshot).map((index): ViewerRow[] => { + const file = files[index]; + if (!file) { + const failure = failures[index]; + const status = failure ?? (snapshot.complete ? "File did not load" : "Computing diff…"); + return [{ key: `${index}:header`, fileIndex: index, label: filePath(inventory[index]!.file) }, + ...["", status, "", ""].map((label, line) => ({ key: `${index}:pending:${line}`, fileIndex: index, + label, pending: line === 1 && !failure && !snapshot.complete }))]; + } + const folds = file.diff.type === "text" ? this.foldsOf(index, file.diff) : new Set(); + const key = `${index}:${layout}:${this.theme.name}:${[...folds].sort((a, b) => a - b).join(",")}`; + let cached = this.rowCache.get(file); + if (cached?.key !== key) { + cached = { key, rows: rowsForFile(file, index, layout, this.theme, folds) }; + this.rowCache.set(file, cached); + } + if (!this.isClosed(index, file)) return cached.rows; + return fileVisibility(file).collapsed + ? [cached.rows[0]!, ...placeholderRows(index, fileVisibility(file).label)] + : cached.rows.slice(0, 1); + }); + // A blank row closes an open file before the next header. It belongs to the file it closes, + // so the sticky header doesn't repeat the header just below it. + const all: ViewerRow[] = []; + for (const fileRows of perFile) { + const last = all.at(-1); + if (last && !last.key.endsWith(":header") && last.label !== "") + all.push({ key: `${last.fileIndex}:end`, fileIndex: last.fileIndex, label: "" }); + all.push(...fileRows); + } + for (const [i, error] of snapshot.errors.entries()) + all.push({ key: `error:${i}`, fileIndex: -1, label: error }); + return all; + } + + lay(size: Size): Laid { + this.size = size; + const snapshot = this.snapshot; + const contentWidth = size.columns, viewportHeight = size.rows; + const layout = this.mode === "auto" ? (contentWidth >= this.splitColumns ? "split" : "unified") : this.mode; + const key = `${this.revision}:${layout}:${contentWidth}:${this.wrap}:${this.horizontal}`; + if (this.measured?.key !== key || this.measured.snapshot !== snapshot) { + const rows = this.documentRows(snapshot, layout); + const maxLine = Math.max(1, ...snapshot.files.flatMap((file) => file?.diff.type === "text" + ? [file.diff.lhs, file.diff.rhs].map((source) => source ? sourceLines(source.text).length : 0) : [])); + this.measured = { key, snapshot, rows, geometry: measureRows(rows, contentWidth, this.wrap, this.horizontal, maxLine) }; + } + const { rows, geometry } = this.measured; + const lastFileTop = geometry.rows.findLast((r) => r.row.key.endsWith(":header"))?.top ?? 0; + // One past the end, since the sticky file header takes the viewport's first row. + const maxScroll = Math.max(lastFileTop, geometry.height - viewportHeight + 1); + const top = Math.min(positionTop(geometry, this.position), maxScroll); + const viewport = visibleRows(geometry, top, viewportHeight); + const currentFile = viewport[0]?.row.fileIndex ?? 0; + const sticky = !!viewport.length && !viewport[0]!.row.key.endsWith(":header"); + const thumbHeight = Math.max(1, Math.floor((viewportHeight * viewportHeight) / Math.max(viewportHeight, geometry.height))); + const thumbTop = maxScroll ? Math.round((top / maxScroll) * (viewportHeight - thumbHeight)) : 0; + return { size, snapshot, theme: this.theme, layout, wrap: this.wrap, horizontal: this.horizontal, hover: this.hover, + rows, geometry, top, maxScroll, viewport, currentFile, sticky, thumb: { top: thumbTop, height: thumbHeight } }; + } + + private current(): Laid { + if (!this.size) throw new Error("The viewer has not been laid out yet"); + return this.lay(this.size); + } + + scrollTo(value: number) { + const at = this.current(); + this.position = positionAt(at.geometry, Math.max(0, Math.min(at.maxScroll, value))); + this.emit(); + } + + /** Relative to the latest position, so key repeats within one frame add up. */ + move(amount: number) { + this.scrollTo(this.current().top + amount); + } + + scrub(row: number) { + const at = this.current(); + this.scrollTo(Math.round(Math.max(0, Math.min(1, row / Math.max(1, at.size.rows - 1))) * at.maxScroll)); + } + + jump(index: number) { + const row = this.current().geometry.rows.find((r) => r.row.fileIndex === index); + if (!row) throw new Error(`File ${index} has no rows`); + this.scrollTo(row.top); + } + + toggleFile(index: number) { + const file = this.snapshot.files[index]; + if (!file) return; + const at = this.current(); + // Toggling the file being read moves to its header; the rows above it stay put. + const header = at.geometry.rows.find((r) => r.row.key === `${index}:header`)!; + if (header.top < at.top) this.position = { key: header.row.key, fileIndex: index, offset: 0, side: "right" }; + this.closed.set(index, !this.isClosed(index, file)); + this.reshape(); + } + + /** "toggle" reads the first id's state, so quick repeated clicks alternate. */ + private setFolds(fileIndex: number, diff: TextDiff, ids: number[], collapse: boolean | "toggle") { + const next = new Set(this.foldsOf(fileIndex, diff)); + const close = collapse === "toggle" ? !next.has(ids[0]!) : collapse; + for (const id of ids) if (close) next.add(id); else next.delete(id); + this.collapsed.set(fileIndex, next); + } + + /** Recursive commands (Alt-click, zC, zO, zA) include every fold nested inside. */ + setFold(fileIndex: number, id: number, collapse: boolean | "toggle", recursive: boolean) { + const diff = this.snapshot.files[fileIndex]?.diff; + if (diff?.type !== "text") throw new Error(`File ${fileIndex} has no folds`); + this.setFolds(fileIndex, diff, recursive ? [id, ...nestedIds(diff, id)] : [id], collapse); + this.reshape(); + } + + private textDiffs() { + return this.snapshot.files.flatMap((file, index) => file?.diff.type === "text" ? [{ index, diff: file.diff }] : []); + } + + toggleContext() { + const textDiffs = this.textDiffs(); + const opened = textDiffs.some(({ index, diff }) => gapIds(diff).some((id) => !this.foldsOf(index, diff).has(id))); + textDiffs.forEach(({ index, diff }) => this.setFolds(index, diff, gapIds(diff), opened)); + this.reshape(); + } + + foldAll(collapse: boolean) { + this.textDiffs().forEach(({ index, diff }) => this.setFolds(index, diff, foldIds(diff), collapse)); + this.reshape(); + } + + private navigate(direction: number, matches: (row: ViewerRow) => boolean) { + const at = this.current(); + const headers = at.geometry.rows.filter((r) => matches(r.row)); + const target = direction > 0 ? headers.find((r) => r.top > at.top) : headers.findLast((r) => r.top < at.top); + if (target) this.scrollTo(target.top); + } + + navigateHunk(direction: number) { + this.navigate(direction, (row) => !!row.hunkStart); + } + + private navigateFold(direction: number) { + this.navigate(direction, (row) => !!rowFold(row)); + } + + private foldCommand(command: string) { + if (command === "R") return this.foldAll(false); + if (command === "M") return this.foldAll(true); + if (command === "j") return this.navigateFold(1); + if (command === "k") return this.navigateFold(-1); + const at = this.current(); + const current = visibleRows(at.geometry, at.top, 1)[0]?.row; + const fold = current && rowFold(current); + if (!current || !fold) return; + const recursive = command === command.toUpperCase(); + const letter = command.toLowerCase(); + if (letter === "a") this.setFold(current.fileIndex, fold.id, "toggle", recursive); + else if (letter === "o") this.setFold(current.fileIndex, fold.id, false, recursive); + else if (letter === "c") this.setFold(current.fileIndex, fold.id, true, recursive); + } + + toggleLayout() { + this.mode = this.current().layout === "split" ? "unified" : "split"; + this.reshape(); + } + + toggleWrap() { + this.wrap = !this.wrap; + this.emit(); + } + + setTheme(theme: Palette) { + this.theme = theme; + this.reshape(); + } + + /** `t` swaps between the two bundled defaults; a configured theme is left by the first press. */ + private toggleTheme() { + this.setTheme(this.theme.isLight ? dark : light); + } + + pan(columns: number) { + this.horizontal = Math.max(0, this.horizontal + columns); + this.emit(); + } + + setHover(hover: Hover | null) { + if (hover?.file === this.hover?.file && hover?.id === this.hover?.id && hover?.armed === this.hover?.armed) return; + this.hover = hover; + this.emit(); + } + + private toggleTopFile() { + const at = this.current(); + const current = visibleRows(at.geometry, at.top, 1)[0]; + if (current && current.row.fileIndex >= 0) this.toggleFile(current.row.fileIndex); + } + + /** Returns false for keys the frontend handles. */ + press(key: KeyPress): boolean { + const at = this.current(); + const page = at.size.rows, half = Math.max(1, Math.floor(at.size.rows / 2)); + const name = key.key; + if (this.pendingZ) { + this.pendingZ = false; + if (!key.ctrl && !key.meta && name.length === 1 && "aocAOCRMjk".includes(name)) this.foldCommand(name); + return true; + } + if (key.meta) return false; + if (key.ctrl) { + if (name === "d") this.move(half); + else if (name === "u") this.move(-half); + else if (name === "f") this.move(page); + else if (name === "b") this.move(-page); + else return false; + return true; + } + switch (name) { + case "d": this.move(half); break; + case "u": this.move(-half); break; + case " ": case "space": this.move(key.shift ? -page : page); break; + case "pagedown": case "f": this.move(page); break; + case "pageup": case "b": this.move(-page); break; + case "down": case "j": this.move(1); break; + case "up": case "k": this.move(-1); break; + case "g": case "home": this.scrollTo(0); break; + case "G": case "end": this.scrollTo(at.maxScroll); break; + case "right": case "l": this.pan(key.shift ? 16 : 4); break; + case "left": case "h": this.pan(key.shift ? -16 : -4); break; + case "]": this.navigateHunk(1); break; + case "[": this.navigateHunk(-1); break; + case "s": this.toggleLayout(); break; + case "w": this.toggleWrap(); break; + case "c": this.toggleContext(); break; + case "t": this.toggleTheme(); break; + case "z": this.pendingZ = true; break; + case "return": case "enter": this.toggleTopFile(); break; + default: return false; + } + return true; + } +} From 9b1cddedb20e4d0bded7f15b015aeaa4b5acca16 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:39:57 -0700 Subject: [PATCH 09/25] Move foldBackground from the theme to the document rows (AI-assisted) It maps a fold's tint to a palette colour, as captureColor maps a syntax capture, so the theme no longer depends on the document. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: ab10ae26-42f2-4ecd-86d0-79f5a9edecf0 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/viewer/src/document/regions.test.ts | 2 +- diffr/packages/viewer/src/document/rows.ts | 7 ++++++- diffr/packages/viewer/src/theme/palette.ts | 7 ------- diffr/packages/viewer/src/theme/themes.test.ts | 3 +-- diffr/packages/viewer/src/viewer.ts | 2 ++ diffr/packages/viewer/src/viewport/cell.ts | 4 ++-- 6 files changed, 12 insertions(+), 13 deletions(-) diff --git a/diffr/packages/viewer/src/document/regions.test.ts b/diffr/packages/viewer/src/document/regions.test.ts index cbad5260e..c53f5c762 100644 --- a/diffr/packages/viewer/src/document/regions.test.ts +++ b/diffr/packages/viewer/src/document/regions.test.ts @@ -4,7 +4,7 @@ import { collapsedFolds, defaultCollapsed, flatten, foldHeaders, foldIds, foldTi import { rowsForFile } from "./rows"; import { dark } from "../theme/themes"; import type { Region } from "../protocol/wire"; -import { foldBackground } from "../theme/palette"; +import { foldBackground } from "./rows"; test("a fold covers its body, so collapsing it hides every line it holds", () => { const file = createFoldedDiffFile(); if (file.diff.type !== "text") throw new Error(); diff --git a/diffr/packages/viewer/src/document/rows.ts b/diffr/packages/viewer/src/document/rows.ts index 71710bccb..26a1feaed 100644 --- a/diffr/packages/viewer/src/document/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -4,7 +4,7 @@ import { filePath } from "../protocol/wire"; import { sliceSpansWindow } from "../terminal/spans"; import { measureTextWidth } from "../terminal/text"; import { byteColumn, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, type Fold, type FoldTint, type Leaf, type RowFold, type Side } from "./regions"; -import { foldBackground, type Palette } from "../theme/palette"; +import type { Palette } from "../theme/palette"; export { sourceLines }; export type Layout = "split" | "unified"; export interface RenderSpan { @@ -63,6 +63,11 @@ export interface ViewerRow { right?: SplitLineCell; cell?: UnifiedLineCell; } +/** Paired folds sit on the code's own background; only one-sided folds carry a change tint. */ +export function foldBackground(theme: Palette, tint: FoldTint) { + return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion + : tint === "modified" ? theme.modification : theme.bg; +} /** Foreground for a tree-sitter capture: the theme's scope, its parents, else plain text. */ export function captureColor(capture: string, theme: Palette): string { return theme.syntax(capture) ?? theme.fg; diff --git a/diffr/packages/viewer/src/theme/palette.ts b/diffr/packages/viewer/src/theme/palette.ts index ea987dcc9..9a1747628 100644 --- a/diffr/packages/viewer/src/theme/palette.ts +++ b/diffr/packages/viewer/src/theme/palette.ts @@ -1,5 +1,3 @@ -/** The painter's palette from a parsed Helix theme, and diffr's theme config. No Node or Bun APIs. */ -import type { FoldTint } from "../document/regions"; export interface Style { fg?: string; bg?: string; @@ -168,8 +166,3 @@ export function themeConfig(show: unknown): { name: string; path: string | null throw new Error("diffr config show did not include a theme section"); return { name: theme.name, path: theme.path ?? null }; } -/** Paired folds sit on the code's own background; only one-sided folds carry a change tint. */ -export function foldBackground(theme: Palette, tint: FoldTint) { - return tint === "inserted" ? theme.addition : tint === "removed" ? theme.deletion - : tint === "modified" ? theme.modification : theme.bg; -} diff --git a/diffr/packages/viewer/src/theme/themes.test.ts b/diffr/packages/viewer/src/theme/themes.test.ts index 7d8337bdd..f3dd5e4e5 100644 --- a/diffr/packages/viewer/src/theme/themes.test.ts +++ b/diffr/packages/viewer/src/theme/themes.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "vitest"; -import { bundledThemes, colorOf, foldBackground, paletteFromHelix, scopeStyle } from "./palette"; +import { bundledThemes, colorOf, paletteFromHelix, scopeStyle } from "./palette"; import { loadBundledTheme, parseHelixTheme } from "./themes"; const sample = ` "keyword" = { fg = "red", modifiers = ["bold"] } @@ -69,7 +69,6 @@ test("in every bundled theme a file header stands apart from code and folds, and for (const name of Object.keys(bundledThemes)) { const palette = loadBundledTheme(name); expect(palette.fileHeader).not.toBe(palette.bg); - expect(palette.fileHeader).not.toBe(foldBackground(palette, "neutral")); expect(contrast(palette.fg, palette.fileHeader)).toBeGreaterThanOrEqual(4); expect(contrast(palette.fileHeaderDir, palette.fileHeader)).toBeGreaterThanOrEqual(2.5); } diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts index 42a493b6a..b90ca1f0a 100644 --- a/diffr/packages/viewer/src/viewer.ts +++ b/diffr/packages/viewer/src/viewer.ts @@ -342,7 +342,9 @@ export class Viewer { case "g": case "home": this.scrollTo(0); break; case "G": case "end": this.scrollTo(at.maxScroll); break; case "right": case "l": this.pan(key.shift ? 16 : 4); break; + case "L": this.pan(16); break; case "left": case "h": this.pan(key.shift ? -16 : -4); break; + case "H": this.pan(-16); break; case "]": this.navigateHunk(1); break; case "[": this.navigateHunk(-1); break; case "s": this.toggleLayout(); break; diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts index e27ab9c49..313f5800d 100644 --- a/diffr/packages/viewer/src/viewport/cell.ts +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -1,8 +1,8 @@ import type { Geometry } from "./geometry"; -import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "../document/rows"; +import { foldBackground, type RenderSpan, type SplitLineCell, type UnifiedLineCell } from "../document/rows"; import { measureTextWidth } from "../terminal/text"; import type { RowFold } from "../document/regions"; -import { foldBackground, type Palette } from "../theme/palette"; +import type { Palette } from "../theme/palette"; /** The scope the pointer is on. Armed when it points at the scope's rail or chevron, which fold it. */ export interface ScopeFocus { From d6d79ec1bf243c82e921f262bd12278e27a47357 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:01 -0700 Subject: [PATCH 10/25] Make the terminal span helpers generic over any styled span (AI-assisted) They slice and wrap spans by their text and merge runs whose other fields all match, so they no longer import the document's span type. That removes the last import from a lower folder of @diffr/viewer to a higher one. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/viewer/src/terminal/spans.ts | 64 +++++++++++---------- 1 file changed, 33 insertions(+), 31 deletions(-) diff --git a/diffr/packages/viewer/src/terminal/spans.ts b/diffr/packages/viewer/src/terminal/spans.ts index e8b9a7dfc..7fa538e1c 100644 --- a/diffr/packages/viewer/src/terminal/spans.ts +++ b/diffr/packages/viewer/src/terminal/spans.ts @@ -1,4 +1,3 @@ -// Plans terminal-cell slicing and wrapping for styled diff spans. import { sanitizeTerminalSpans } from "./sanitize"; import { isPrintableAsciiText, @@ -8,19 +7,22 @@ import { textClusters, wrapSanitizedTextByWidth, } from "./text"; -import type { RenderSpan } from "../document/rows"; +interface StyledSpan { + text: string; +} + +/** Spans whose every field but `text` matches join into one; a missing field equals undefined. */ +function sameStyle(a: StyledSpan, b: StyledSpan) { + const left = a as unknown as Record, right = b as unknown as Record; + for (const key in left) if (key !== "text" && left[key] !== right[key]) return false; + for (const key in right) if (key !== "text" && left[key] !== right[key]) return false; + return true; +} /** Append a styled span while preserving color-run coalescing. */ -function appendRenderSpan(target: RenderSpan[], span: RenderSpan) { +function appendSpan(target: S[], span: S) { const previous = target.at(-1); - if ( - previous && - previous.guide === span.guide && - previous.brace === span.brace && - previous.fg === span.fg && - previous.bg === span.bg && - previous.transformFg === span.transformFg - ) { + if (previous && sameStyle(previous, span)) { previous.text += span.text; } else { target.push(span); @@ -42,7 +44,7 @@ function boundaryScalar(text: string, first: boolean) { } /** Return whether a styled-span boundary may divide one grapheme cluster. */ -function spansMaySplitGrapheme(spans: RenderSpan[]) { +function spansMaySplitGrapheme(spans: StyledSpan[]) { for (let index = 1; index < spans.length; index += 1) { const left = boundaryScalar(spans[index - 1]?.text ?? "", false); const right = boundaryScalar(spans[index]?.text ?? "", true); @@ -57,8 +59,8 @@ function spansMaySplitGrapheme(spans: RenderSpan[]) { } /** Merge indivisible graphemes while preserving the style where each cluster starts. */ -function mergeCrossSpanGraphemes(spans: RenderSpan[]) { - const normalized: RenderSpan[] = []; +function mergeCrossSpanGraphemes(spans: S[]) { + const normalized: S[] = []; const text = spans.map((span) => span.text).join(""); let sourceIndex = 0; let sourceEnd = spans[0]?.text.length ?? 0; @@ -71,7 +73,7 @@ function mergeCrossSpanGraphemes(spans: RenderSpan[]) { } const source = spans[sourceIndex]; if (source) { - appendRenderSpan(normalized, { ...source, text: cluster }); + appendSpan(normalized, { ...source, text: cluster }); } cursor += cluster.length; } @@ -79,24 +81,24 @@ function mergeCrossSpanGraphemes(spans: RenderSpan[]) { } /** Merge only indivisible graphemes that may cross styled-span boundaries. */ -function preserveCrossSpanGraphemes(spans: RenderSpan[]) { +function preserveCrossSpanGraphemes(spans: S[]) { return spansMaySplitGrapheme(spans) ? mergeCrossSpanGraphemes(spans) : spans; } /** Slice styled spans to one visible window while preserving color runs. */ -export function sliceSpansWindow( - spans: RenderSpan[], +export function sliceSpansWindow( + spans: S[], offset: number, width: number, ) { if (width <= 0) { return { - spans: [] as RenderSpan[], + spans: [] as S[], usedWidth: 0, }; } - const sliced: RenderSpan[] = []; + const sliced: S[] = []; let remainingOffset = Math.max(0, offset); let remaining = width; let usedWidth = 0; @@ -108,7 +110,7 @@ export function sliceSpansWindow( const spanWidth = measureSanitizedTextWidth(span.text); if (spanWidth === 0) { - appendRenderSpan(sliced, { ...span }); + appendSpan(sliced, { ...span }); continue; } @@ -119,8 +121,8 @@ export function sliceSpansWindow( if (remainingOffset === 0 && spanWidth <= remaining) { // Preserve the full safe span without re-slicing its graphemes. Clone before coalescing so - // appendRenderSpan can never mutate the caller-owned highlighted span object. - appendRenderSpan(sliced, { ...span }); + // appendSpan can never mutate the caller-owned highlighted span object. + appendSpan(sliced, { ...span }); remaining -= spanWidth; usedWidth += spanWidth; continue; @@ -142,7 +144,7 @@ export function sliceSpansWindow( text: visible.text, }; - appendRenderSpan(sliced, nextSpan); + appendSpan(sliced, nextSpan); remaining -= visible.width; usedWidth += visible.width; @@ -160,12 +162,12 @@ export function sliceSpansWindow( const SINGLE_PASS_WRAP_LINE_THRESHOLD = 8; /** Wrap styled spans into visual lines while preserving color runs across splits. */ -export function wrapSpans(spans: RenderSpan[], width: number) { +export function wrapSpans(spans: S[], width: number) { if (width <= 0) { - return [[]] as RenderSpan[][]; + return [[]] as S[][]; } - const lines: RenderSpan[][] = [[]]; + const lines: S[][] = [[]]; let current = lines[0]!; let remaining = width; const safeSpans = sanitizeTerminalSpans(spans); @@ -189,7 +191,7 @@ export function wrapSpans(spans: RenderSpan[], width: number) { const simpleSpanWidth = simpleSpanWidths[spanIndex] ?? null; const spanWidth = simpleSpanWidth ?? measureSanitizedTextWidth(span.text); if (spanWidth === 0) { - appendRenderSpan(current, { ...span }); + appendSpan(current, { ...span }); continue; } @@ -210,7 +212,7 @@ export function wrapSpans(spans: RenderSpan[], width: number) { remaining = width; } if (chunk.text.length > 0) { - appendRenderSpan(current, { ...span, text: chunk.text }); + appendSpan(current, { ...span, text: chunk.text }); } remaining -= chunk.width; } @@ -253,7 +255,7 @@ export function wrapSpans(spans: RenderSpan[], width: number) { ...span, text: visible.text, }; - appendRenderSpan(current, nextSpan); + appendSpan(current, nextSpan); offset += visible.width; remaining -= visible.width; @@ -265,7 +267,7 @@ export function wrapSpans(spans: RenderSpan[], width: number) { /** Count wrapped visual lines without allocating the styled line arrays used by rendering. */ export function measureWrappedSpansLineCount( - spans: RenderSpan[], + spans: StyledSpan[], width: number, ) { if (width <= 0) { From 51d5af344535aa1d42e1f241753bc10a616919fd Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:04 -0700 Subject: [PATCH 11/25] Find visible rows with a direct search instead of Hunk's row windowing (AI-assisted) visibleRows used only the start and end of Hunk's window, whose zero-height rows and spacer sizes never occur in diffr: every measured row is at least one line tall. A binary search for the first row in view and a walk to the bottom replace it. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- .../viewer/src/terminal/window.test.ts | 54 ------ diffr/packages/viewer/src/terminal/window.ts | 177 ------------------ .../packages/viewer/src/viewport/geometry.ts | 18 +- 3 files changed, 10 insertions(+), 239 deletions(-) delete mode 100644 diffr/packages/viewer/src/terminal/window.test.ts delete mode 100644 diffr/packages/viewer/src/terminal/window.ts diff --git a/diffr/packages/viewer/src/terminal/window.test.ts b/diffr/packages/viewer/src/terminal/window.test.ts deleted file mode 100644 index 683d24469..000000000 --- a/diffr/packages/viewer/src/terminal/window.test.ts +++ /dev/null @@ -1,54 +0,0 @@ -import { expect, test } from "vitest"; -import { resolveVisibleRowIndexWindow as window } from "./window"; -test("window keeps wrapped rows intersecting either edge and replaces skipped height", () => { - expect( - window({ - bodyHeight: 12, - rowBounds: [ - { top: 0, height: 2 }, - { top: 2, height: 5 }, - { top: 7, height: 1 }, - { top: 8, height: 4 }, - ], - visibleBodyBounds: { top: 3, height: 5 }, - }), - ).toEqual({ - startIndex: 1, - endIndex: 3, - topSpacerHeight: 2, - bottomSpacerHeight: 4, - }); -}); -test("offscreen file becomes only spacers", () => { - expect( - window({ - bodyHeight: 12, - rowBounds: [{ top: 0, height: 12 }], - visibleBodyBounds: { top: 20, height: 5 }, - }), - ).toEqual({ - startIndex: 0, - endIndex: 0, - topSpacerHeight: 12, - bottomSpacerHeight: 0, - }); -}); -test("hidden structural rows at the viewport boundary retain their anchors", () => { - expect( - window({ - bodyHeight: 4, - rowBounds: [ - { top: 0, height: 2 }, - { top: 2, height: 0 }, - { top: 2, height: 2 }, - { top: 4, height: 0 }, - ], - visibleBodyBounds: { top: 2, height: 2 }, - }), - ).toEqual({ - startIndex: 1, - endIndex: 4, - topSpacerHeight: 2, - bottomSpacerHeight: 0, - }); -}); diff --git a/diffr/packages/viewer/src/terminal/window.ts b/diffr/packages/viewer/src/terminal/window.ts deleted file mode 100644 index a48f97da3..000000000 --- a/diffr/packages/viewer/src/terminal/window.ts +++ /dev/null @@ -1,177 +0,0 @@ -export interface RowBounds { - top: number; - height: number; -} - -/** One visible slice within a file body, measured in file-local row units. */ -export interface VisibleBodyBounds { - top: number; - height: number; -} - -/** Index-only visible slice over measured terminal rows. */ -export interface VisibleRowIndexWindow { - bottomSpacerHeight: number; - endIndex: number; - startIndex: number; - topSpacerHeight: number; -} - -/** - * Find the first row whose bottom edge is after the visible top boundary. - * Requires row bounds to be sorted by non-decreasing row bottom. - */ -function findFirstRowWithBottomAfter( - rowBounds: readonly RowBounds[], - top: number, -) { - let low = 0; - let high = rowBounds.length - 1; - let result = rowBounds.length; - - while (low <= high) { - const mid = (low + high) >>> 1; - const rowBoundsEntry = rowBounds[mid]!; - - if (rowBoundsEntry.top + rowBoundsEntry.height > top) { - result = mid; - high = mid - 1; - } else { - low = mid + 1; - } - } - - return result; -} - -/** - * Find the last row whose top edge is before the visible bottom boundary. - * Requires row bounds to be sorted by non-decreasing row top. - */ -function findLastRowWithTopBefore( - rowBounds: readonly RowBounds[], - bottom: number, -) { - let low = 0; - let high = rowBounds.length - 1; - let result = -1; - - while (low <= high) { - const mid = (low + high) >>> 1; - const rowBoundsEntry = rowBounds[mid]!; - - if (rowBoundsEntry.top < bottom) { - result = mid; - low = mid + 1; - } else { - high = mid - 1; - } - } - - return result; -} - -/** Return whether one measured row overlaps the requested closed-open visible interval. */ -function rowOverlapsVisibleRange( - rowBounds: RowBounds, - minVisibleTop: number, - maxVisibleBottom: number, -) { - if (rowBounds.height <= 0) { - return false; - } - - const rowBottom = rowBounds.top + rowBounds.height; - return rowBottom > minVisibleTop && rowBounds.top < maxVisibleBottom; -} - -/** Resolve a measured visible slice in O(log n + edge structural rows). */ -export function resolveVisibleRowIndexWindow({ - bodyHeight, - rowBounds, - visibleBodyBounds, -}: { - bodyHeight: number; - rowBounds: readonly RowBounds[]; - visibleBodyBounds: VisibleBodyBounds; -}): VisibleRowIndexWindow { - const minVisibleTop = Math.max(0, visibleBodyBounds.top); - const maxVisibleBottom = Math.min( - bodyHeight, - visibleBodyBounds.top + Math.max(0, visibleBodyBounds.height), - ); - - let firstVisibleIndex = findFirstRowWithBottomAfter(rowBounds, minVisibleTop); - while ( - firstVisibleIndex < rowBounds.length && - !rowOverlapsVisibleRange( - rowBounds[firstVisibleIndex]!, - minVisibleTop, - maxVisibleBottom, - ) - ) { - firstVisibleIndex += 1; - } - - let lastVisibleIndex = findLastRowWithTopBefore(rowBounds, maxVisibleBottom); - while ( - lastVisibleIndex >= 0 && - !rowOverlapsVisibleRange( - rowBounds[lastVisibleIndex]!, - minVisibleTop, - maxVisibleBottom, - ) - ) { - lastVisibleIndex -= 1; - } - - if (firstVisibleIndex >= rowBounds.length) { - firstVisibleIndex = -1; - } - - // firstVisibleIndex > lastVisibleIndex should not happen with sorted row bounds, but keep the - // empty-window fallback defensive in case an upstream geometry invariant is ever broken. - if ( - firstVisibleIndex < 0 || - lastVisibleIndex < 0 || - firstVisibleIndex > lastVisibleIndex - ) { - const topSpacerHeight = Math.min(bodyHeight, minVisibleTop); - - return { - bottomSpacerHeight: Math.max(0, bodyHeight - topSpacerHeight), - endIndex: 0, - startIndex: 0, - topSpacerHeight, - }; - } - - let startIndex = firstVisibleIndex; - // Zero-height rows still matter structurally: for example, hidden hunk headers keep anchor ids - // and stable row ordering. If one sits immediately before the visible slice, keep it attached. - while (startIndex > 0 && rowBounds[startIndex - 1]?.height === 0) { - startIndex -= 1; - } - - let endIndex = lastVisibleIndex + 1; - // Do the same on the trailing edge so hidden structural rows continue to travel with the last - // visible rendered row instead of being stranded in the spacer region. - while (endIndex < rowBounds.length && rowBounds[endIndex]?.height === 0) { - endIndex += 1; - } - - const startRowBounds = rowBounds[startIndex]!; - const endRowBounds = rowBounds[endIndex - 1]!; - - return { - // The top spacer is exactly the skipped body height before the first mounted row. - topSpacerHeight: startRowBounds.top, - startIndex, - endIndex, - // The bottom spacer is the remaining body height after the last mounted row's bottom edge. - bottomSpacerHeight: Math.max( - 0, - bodyHeight - (endRowBounds.top + endRowBounds.height), - ), - }; -} diff --git a/diffr/packages/viewer/src/viewport/geometry.ts b/diffr/packages/viewer/src/viewport/geometry.ts index be33016b4..2dc040389 100644 --- a/diffr/packages/viewer/src/viewport/geometry.ts +++ b/diffr/packages/viewer/src/viewport/geometry.ts @@ -1,6 +1,4 @@ -/** Measure once per layout change, then window measured rows using Hunk's binary search. */ import { sliceSpansWindow, wrapSpans } from "../terminal/spans"; -import { resolveVisibleRowIndexWindow } from "../terminal/window"; import type { RenderSpan, ViewerRow } from "../document/rows"; export interface MeasuredRow { row: ViewerRow; @@ -54,12 +52,16 @@ export function measureRows( return { rows: measured, height: top, leftWidth, rightWidth, gutter, unifiedGutter }; } export function visibleRows(geometry: Geometry, top: number, height: number) { - const window = resolveVisibleRowIndexWindow({ - bodyHeight: geometry.height, - rowBounds: geometry.rows, - visibleBodyBounds: { top, height }, - }); - return geometry.rows.slice(window.startIndex, window.endIndex); + const rows = geometry.rows, bottom = top + Math.max(0, height); + let start = 0, end = rows.length; + while (start < end) { + const mid = (start + end) >>> 1; + if (rows[mid]!.top + rows[mid]!.height > top) end = mid; + else start = mid + 1; + } + end = start; + while (end < rows.length && rows[end]!.top < bottom) end++; + return rows.slice(start, end); } /** The source line a row shows on one side. */ From de133d93993540ce835332501b328905bbbdd777 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:08 -0700 Subject: [PATCH 12/25] Delete terminal helpers vendored from Hunk that nothing calls (AI-assisted) wrapText, wrapTextByWidth, cellRangeToCharRange, fitText, padText, measureWrappedSpansLineCount, formatTerminalPath, RenderSpan.transformFg, and the sanitizer's options for keeping newlines and ANSI colours: only sanitizeTerminalLine's behaviour is used. Their tests go with them. rows.ts also stops re-exporting sourceLines, which callers import from regions.ts. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/viewer/src/document/rows.ts | 3 - .../viewer/src/terminal/sanitize.test.ts | 75 +------- .../packages/viewer/src/terminal/sanitize.ts | 97 +--------- diffr/packages/viewer/src/terminal/spans.ts | 67 ------- diffr/packages/viewer/src/terminal/text.ts | 171 ------------------ 5 files changed, 10 insertions(+), 403 deletions(-) diff --git a/diffr/packages/viewer/src/document/rows.ts b/diffr/packages/viewer/src/document/rows.ts index 26a1feaed..0d801b6f8 100644 --- a/diffr/packages/viewer/src/document/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -5,7 +5,6 @@ import { sliceSpansWindow } from "../terminal/spans"; import { measureTextWidth } from "../terminal/text"; import { byteColumn, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, type Fold, type FoldTint, type Leaf, type RowFold, type Side } from "./regions"; import type { Palette } from "../theme/palette"; -export { sourceLines }; export type Layout = "split" | "unified"; export interface RenderSpan { text: string; @@ -15,8 +14,6 @@ export interface RenderSpan { brace?: number; fg?: string; bg?: string; - /** Resolve paint-only foreground effects after cursor and copy-selection backgrounds apply. */ - transformFg?: (sourceFg: string | undefined, renderedBg: string) => string; } export interface SplitLineCell { diff --git a/diffr/packages/viewer/src/terminal/sanitize.test.ts b/diffr/packages/viewer/src/terminal/sanitize.test.ts index 6cfdadec7..0574f91be 100644 --- a/diffr/packages/viewer/src/terminal/sanitize.test.ts +++ b/diffr/packages/viewer/src/terminal/sanitize.test.ts @@ -1,9 +1,5 @@ import { describe, expect, test } from "vitest"; -import { - formatTerminalPath, - sanitizeTerminalSpans, - sanitizeTerminalText, -} from "./sanitize"; +import { sanitizeTerminalLine, sanitizeTerminalSpans } from "./sanitize"; const OSC52_CLIPBOARD = "\x1b]52;c;SGVsbG8=\x07"; const OSC_ST = "\x1b]8;;https://example.test\x1b\\"; @@ -26,7 +22,7 @@ function expectNoUnsafeTerminalControls(text: string) { expect(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/.test(text)).toBe(false); } -describe("sanitizeTerminalText", () => { +describe("sanitize", () => { test("removes terminal control strings without dropping surrounding text", () => { const input = [ "before", @@ -43,7 +39,7 @@ describe("sanitizeTerminalText", () => { "after", ].join(""); - const output = sanitizeTerminalText(input); + const output = sanitizeTerminalLine(input); expect(output).toContain("before"); expect(output).toContain("after"); @@ -51,7 +47,7 @@ describe("sanitizeTerminalText", () => { }); test("neutralizes visual spoofing controls", () => { - const output = sanitizeTerminalText("safe\rOVERWRITE\bhidden\x1b"); + const output = sanitizeTerminalLine("safe\rOVERWRITE\bhidden\x1b"); expect(output).toContain("safe"); expect(output).toContain("OVERWRITE"); @@ -59,69 +55,6 @@ describe("sanitizeTerminalText", () => { expectNoUnsafeTerminalControls(output); }); - test("preserves ordinary multiline text and tabs when allowed", () => { - expect(sanitizeTerminalText("alpha\n\tbeta")).toBe("alpha\n\tbeta"); - }); - - test("can preserve ANSI SGR styling while removing unsafe controls", () => { - const output = sanitizeTerminalText( - `plain\x1b[1;34mblue\x1b[m${OSC52_CLIPBOARD}${CSI_CLEAR_SCREEN}\x1b[2Kdone`, - { preserveAnsiStyle: true }, - ); - - expect(output).toBe("plain\x1b[1;34mblue\x1b[mdone"); - }); - - test("does not preserve non-SGR CSI sequences as ANSI styling", () => { - const output = sanitizeTerminalText("safe\x1b[2J\x1b[H\x1b[?25ltext", { - preserveAnsiStyle: true, - }); - - expect(output).toBe("safetext"); - }); - - test("removes crafted style placeholder delimiters before restoring ANSI styling", () => { - const output = sanitizeTerminalText( - "safe\u{f0000}0\u{f0001}\x1b[31mred\x1b[m", - { - preserveAnsiStyle: true, - }, - ); - - expect(output).toBe("safe0\x1b[31mred\x1b[m"); - }); - - test("restores dense ANSI styling in linear time", () => { - // `hunk pager` receives whole `git log --graph --color=always` streams from hosts like - // LazyGit: a few megabytes carrying ~170k SGR sequences. Restoring styles one at a time - // rescanned the entire document per sequence, so this input pegged a core for minutes and - // grew to gigabytes. Assert both the styled output and a wall-clock budget that only - // quadratic restoration can exceed. - const commitCount = 5_000; - const input = Array.from( - { length: commitCount }, - (_, index) => - `\x1b[33m* commit ${index}\x1b[m \x1b[1;36m(\x1b[1;32mHEAD\x1b[1;36m)\x1b[m\n` + - `\x1b[32m| Author: someone\x1b[m\n`, - ).join(""); - const sequenceCount = input.match(/\x1b\[[0-9;:]*m/g)?.length ?? 0; - expect(sequenceCount).toBeGreaterThan(25_000); - - const startedAt = performance.now(); - const output = sanitizeTerminalText(input, { preserveAnsiStyle: true }); - const elapsedMs = performance.now() - startedAt; - - expect(output).toBe(input); - expect(elapsedMs).toBeLessThan(2_000); - }); - - test("renders path controls as visible escapes without confusing literal backslashes", () => { - const output = formatTerminalPath("dir/literal\\t-tab\tline\nescape\x1b"); - - expect(output).toBe("dir/literal\\\\t-tab\\tline\\nescape\\x1b"); - expectNoUnsafeTerminalControls(output); - }); - test("returns an already-safe mutable span array unchanged", () => { const spans = [{ text: "safe", fg: "#fff" }]; diff --git a/diffr/packages/viewer/src/terminal/sanitize.ts b/diffr/packages/viewer/src/terminal/sanitize.ts index 3910c349a..420e9681f 100644 --- a/diffr/packages/viewer/src/terminal/sanitize.ts +++ b/diffr/packages/viewer/src/terminal/sanitize.ts @@ -1,12 +1,3 @@ -export interface SanitizeTerminalTextOptions { - /** Preserve line feeds for multiline text fields. Defaults to true. */ - preserveNewlines?: boolean; - /** Preserve horizontal tabs for text fields that intentionally support them. Defaults to true. */ - preserveTabs?: boolean; - /** Preserve ANSI SGR style sequences such as Git color output. Defaults to false. */ - preserveAnsiStyle?: boolean; -} - const controlCodeRegex = /[\x00-\x1f\x7f-\x9f]/; // Keep these global regexes private and use them only with String#replace below. // Calling test/exec on shared /g regexes would make lastIndex stateful between calls. @@ -14,95 +5,19 @@ const sevenBitControlStrings = /\x1b(?:\][\s\S]*?(?:\x07|\x1b\\|\x9c)|[PX^_][\s\S]*?(?:\x1b\\|\x9c)|\[[0-?]*[ -/]*[@-~])/g; const c1ControlStrings = /[\x90\x98\x9d\x9e\x9f][\s\S]*?(?:\x07|\x1b\\|\x9c)/g; const c1Csi = /\x9b[0-?]*[ -/]*[@-~]/g; -const preservedStyleTokenDelimiters = /[\u{f0000}\u{f0001}]/gu; -const preservedStyleTokens = /\u{f0000}(\d+)\u{f0001}/gu; +/** Every control character but tab. */ +const controlCharacters = /[\x00-\x08\x0a-\x1f\x7f-\x9f]/g; -/** Normalize untrusted terminal-bound text before rendering it in Hunk UI surfaces. */ -export function sanitizeTerminalText( - text: string, - { - preserveNewlines = true, - preserveTabs = true, - preserveAnsiStyle = false, - }: SanitizeTerminalTextOptions = {}, -) { +/** Sanitize a single terminal row or cell where newlines must never be preserved. */ +export function sanitizeTerminalLine(text: string) { if (!controlCodeRegex.test(text)) { return text; } - - const controlCharacters = preserveNewlines - ? preserveTabs - ? /[\x00-\x08\x0b-\x1f\x7f-\x9f]/g - : /[\x00-\x09\x0b-\x1f\x7f-\x9f]/g - : preserveTabs - ? /[\x00-\x08\x0a-\x1f\x7f-\x9f]/g - : /[\x00-\x1f\x7f-\x9f]/g; - const preservedStyles: string[] = []; - const preserveStyle = (sequence: string) => { - if (!preserveAnsiStyle || !/^\x1b\[[0-9;:]*m$/.test(sequence)) { - return ""; - } - - const token = `\u{f0000}${preservedStyles.length}\u{f0001}`; - preservedStyles.push(sequence); - return token; - }; - - // Strip placeholder delimiters from untrusted input so authored text cannot spoof - // an internal token that later restores an ANSI sequence at the wrong location. - const tokenSafeText = preserveAnsiStyle - ? text.replace(preservedStyleTokenDelimiters, "") - : text; - - const sanitized = tokenSafeText - .replace(sevenBitControlStrings, preserveStyle) + return text + .replace(sevenBitControlStrings, "") .replace(c1ControlStrings, "") .replace(c1Csi, "") .replace(controlCharacters, ""); - - if (preservedStyles.length === 0) { - return sanitized; - } - - // Restore every placeholder in a single pass. Replacing one style at a time rescans and - // reallocates the whole document per preserved sequence, which is quadratic in ANSI-dense - // input: a few megabytes of `git log --graph --color=always` piped through `hunk pager` - // carries ~170k sequences and would peg a core for minutes while churning gigabytes. - // Input delimiters were stripped above, so every surviving token indexes a captured style. - return sanitized.replace( - preservedStyleTokens, - (_token, index: string) => preservedStyles[Number(index)] ?? "", - ); -} - -/** Sanitize a single terminal row or cell where newlines must never be preserved. */ -export function sanitizeTerminalLine(text: string) { - return sanitizeTerminalText(text, { - preserveNewlines: false, - preserveTabs: true, - }); -} - -/** Render an exact filesystem path safely without letting controls alter terminal geometry. */ -export function formatTerminalPath(path: string) { - let formatted = ""; - for (const character of path) { - const codePoint = character.codePointAt(0)!; - if (character === "\\") { - formatted += "\\\\"; - } else if (character === "\t") { - formatted += "\\t"; - } else if (character === "\n") { - formatted += "\\n"; - } else if (character === "\r") { - formatted += "\\r"; - } else if (codePoint <= 0x1f || (codePoint >= 0x7f && codePoint <= 0x9f)) { - formatted += `\\x${codePoint.toString(16).padStart(2, "0")}`; - } else { - formatted += character; - } - } - return formatted; } /** Sanitize render spans while preserving their non-text styling metadata. */ diff --git a/diffr/packages/viewer/src/terminal/spans.ts b/diffr/packages/viewer/src/terminal/spans.ts index 7fa538e1c..a36c41c44 100644 --- a/diffr/packages/viewer/src/terminal/spans.ts +++ b/diffr/packages/viewer/src/terminal/spans.ts @@ -29,35 +29,6 @@ function appendSpan(target: S[], span: S) { } } -/** Return the first or last scalar in one non-empty string. */ -function boundaryScalar(text: string, first: boolean) { - if (first) { - const codePoint = text.codePointAt(0); - return codePoint === undefined ? "" : String.fromCodePoint(codePoint); - } - - let scalar = ""; - for (const candidate of text) { - scalar = candidate; - } - return scalar; -} - -/** Return whether a styled-span boundary may divide one grapheme cluster. */ -function spansMaySplitGrapheme(spans: StyledSpan[]) { - for (let index = 1; index < spans.length; index += 1) { - const left = boundaryScalar(spans[index - 1]?.text ?? "", false); - const right = boundaryScalar(spans[index]?.text ?? "", true); - if ( - (left && measureSimpleSanitizedTextWidth(left) === null) || - (right && measureSimpleSanitizedTextWidth(right) === null) - ) { - return true; - } - } - return false; -} - /** Merge indivisible graphemes while preserving the style where each cluster starts. */ function mergeCrossSpanGraphemes(spans: S[]) { const normalized: S[] = []; @@ -80,11 +51,6 @@ function mergeCrossSpanGraphemes(spans: S[]) { return normalized; } -/** Merge only indivisible graphemes that may cross styled-span boundaries. */ -function preserveCrossSpanGraphemes(spans: S[]) { - return spansMaySplitGrapheme(spans) ? mergeCrossSpanGraphemes(spans) : spans; -} - /** Slice styled spans to one visible window while preserving color runs. */ export function sliceSpansWindow( spans: S[], @@ -265,36 +231,3 @@ export function wrapSpans(spans: S[], width: number) { return lines; } -/** Count wrapped visual lines without allocating the styled line arrays used by rendering. */ -export function measureWrappedSpansLineCount( - spans: StyledSpan[], - width: number, -) { - if (width <= 0) { - return 1; - } - - let lineCount = 1; - let remaining = width; - let currentLineHasContent = false; - const safeSpans = preserveCrossSpanGraphemes(sanitizeTerminalSpans(spans)); - for (const span of safeSpans) { - // Preserve zero-width span presence across styled runs so a later over-wide grapheme makes the - // same continuation decision as wrapSpans' concrete line arrays. - for (const chunk of wrapSanitizedTextByWidth( - span.text, - width, - remaining, - currentLineHasContent, - )) { - if (chunk.startsNewLine) { - lineCount += 1; - remaining = width; - currentLineHasContent = false; - } - remaining -= chunk.width; - currentLineHasContent ||= chunk.text.length > 0; - } - } - return lineCount; -} diff --git a/diffr/packages/viewer/src/terminal/text.ts b/diffr/packages/viewer/src/terminal/text.ts index ee4bec2ef..d47e81ee5 100644 --- a/diffr/packages/viewer/src/terminal/text.ts +++ b/diffr/packages/viewer/src/terminal/text.ts @@ -254,98 +254,12 @@ export function measureTextWidth(text: string) { return measureSanitizedTextWidth(sanitizeTerminalLine(text)); } -/** Wrap plain prose to terminal-cell width, preferring word boundaries. */ -export function wrapText(text: string, width: number) { - if (width <= 0) { - return [""]; - } - - const normalized = sanitizeTerminalLine(text).trim().replace(/\s+/g, " "); - if (normalized.length === 0) { - return [""]; - } - - const words = normalized.split(" "); - const lines: string[] = []; - let current = ""; - let currentWidth = 0; - - /** Commit the current prose row before starting another one. */ - const pushCurrent = () => { - if (current.length > 0) { - lines.push(current); - current = ""; - currentWidth = 0; - } - }; - - for (const word of words) { - const wordWidth = measureTextWidth(word); - - if (wordWidth > width) { - pushCurrent(); - let offset = 0; - while (offset < wordWidth) { - const chunk = sliceTextByWidth(word, offset, width); - if (chunk.width <= 0) { - // Width is narrower than one cluster; keep the remainder on one - // line (fitText clamps at render time) instead of dropping it. - const rest = sliceTextByWidth(word, offset, Number.MAX_SAFE_INTEGER); - if (rest.text.length > 0) { - lines.push(rest.text); - } - break; - } - lines.push(chunk.text); - offset += chunk.width; - } - continue; - } - - const nextWidth = - current.length === 0 ? wordWidth : currentWidth + 1 + wordWidth; - if (nextWidth <= width) { - current = current.length === 0 ? word : `${current} ${word}`; - currentWidth = nextWidth; - continue; - } - - pushCurrent(); - current = word; - currentWidth = wordWidth; - } - - pushCurrent(); - return lines.length > 0 ? lines : [""]; -} - export interface WrappedTextChunk { text: string; width: number; startsNewLine: boolean; } -/** - * Split text into line-sized terminal-cell chunks in one grapheme traversal. - * - * The first chunk may use the remaining cells on an existing line. Later chunks use the full - * line width. `startsNewLine` lets styled-span callers preserve style while sharing line state - * across adjacent spans. - */ -export function wrapTextByWidth( - text: string, - lineWidth: number, - firstLineWidth = lineWidth, - firstLineHasContent = false, -): WrappedTextChunk[] { - return wrapSanitizedTextByWidth( - sanitizeTerminalLine(text), - lineWidth, - firstLineWidth, - firstLineHasContent, - ); -} - /** Split already-sanitized text by terminal width without rescanning for control sequences. */ export function wrapSanitizedTextByWidth( safeText: string, @@ -551,88 +465,3 @@ export function sliceSanitizedTextByWidth( return { text: visibleText, width: usedWidth }; } -/** - * Convert an inclusive terminal-cell range into string slice bounds (`endIndex` exclusive). - * - * Mouse selection coordinates are terminal cells while `String#slice` counts UTF-16 code units, - * and the two drift apart on non-ASCII lines: a full-width CJK character is one code unit but - * two cells, and an emoji cluster can be many code units but two cells. Clusters only partially - * covered by the cell range are included in full so a selection can never split a wide - * character, and zero-width clusters at the range start are kept so invisible characters - * round-trip through the clipboard. Indices refer to `text` as given; callers pass rendered - * lines that are already sanitized (sanitizing here could shift the returned indices off the - * caller's string). Callers must pass a normalized range (`startCell <= endCell`); inverted - * ranges are unspecified. - */ -export function cellRangeToCharRange( - text: string, - startCell: number, - endCell: number, -) { - const safeStartCell = Math.max(0, startCell); - - if (printableAsciiRegex.test(text)) { - const startIndex = Math.min(text.length, safeStartCell); - return { - startIndex, - endIndex: Math.min(text.length, Math.max(startIndex, endCell + 1)), - }; - } - - let cellCursor = 0; - let unitCursor = 0; - let startIndex = -1; - let endIndex = text.length; - - for (const cluster of textClusters(text)) { - // The current cluster starts past the end cell, so everything before it is the slice end. - if (cellCursor > endCell) { - endIndex = unitCursor; - break; - } - - const clusterWidth = measureClusterWidth(cluster); - // Zero-width clusters (e.g. U+200B) occupy no cell, so they never satisfy the covering - // check; attach them to a range that starts at their position instead of dropping them. - const coversStart = - clusterWidth > 0 - ? cellCursor + clusterWidth > safeStartCell - : cellCursor >= safeStartCell; - if (startIndex < 0 && coversStart) { - startIndex = unitCursor; - } - - cellCursor += clusterWidth; - unitCursor += cluster.length; - } - - // A range starting past the end of the line resolves to an empty slice at the text end. - if (startIndex < 0) { - startIndex = text.length; - } - - return { startIndex, endIndex: Math.max(startIndex, endIndex) }; -} - -/** Clamp text to a fixed width using a cell-aware overflow marker. */ -export function fitText(text: string, width: number, overflowMarker = ".") { - const safeText = sanitizeTerminalLine(text); - if (width <= 0) { - return ""; - } - - if (measureTextWidth(safeText) <= width) { - return safeText; - } - - const safeMarker = sanitizeTerminalLine(overflowMarker); - const marker = sliceTextByWidth(safeMarker, 0, width); - const textWidth = Math.max(0, width - marker.width); - return `${sliceTextByWidth(safeText, 0, textWidth).text}${marker.text}`; -} - -/** Clamp and then right-pad text to an exact width. */ -export function padText(text: string, width: number) { - const trimmed = fitText(text, width); - return `${trimmed}${" ".repeat(Math.max(0, width - measureTextWidth(trimmed)))}`; -} From 7fe0bab2708665f12dfd035f649e9e1845c2289d Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:12 -0700 Subject: [PATCH 13/25] Name a comparison's ends once, in the protocol (AI-assisted) counts.ts redeclared the wire's snapshot union as its own Snapshot, which also shadowed the store's unrelated Snapshot. The protocol now exports it as ComparisonEnd, and the store and the comparison label use it. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/viewer/src/document/counts.ts | 12 +++--------- diffr/packages/viewer/src/protocol/store.ts | 5 ++--- diffr/packages/viewer/src/protocol/wire.ts | 1 + 3 files changed, 6 insertions(+), 12 deletions(-) diff --git a/diffr/packages/viewer/src/document/counts.ts b/diffr/packages/viewer/src/document/counts.ts index b9f3ea033..de0bc61c1 100644 --- a/diffr/packages/viewer/src/document/counts.ts +++ b/diffr/packages/viewer/src/document/counts.ts @@ -1,6 +1,6 @@ /** Change counts for the headers. The numbers are diffr's `stats`, shown verbatim: folding * changes what is on screen, never the count. */ -import type { LineCounts } from "../protocol/wire"; +import type { ComparisonEnd, LineCounts } from "../protocol/wire"; export type { LineCounts }; export const zero: LineCounts = { added: 0, removed: 0 }; export const add = (a: LineCounts, b: LineCounts): LineCounts => ({ added: a.added + b.added, removed: a.removed + b.removed }); @@ -22,13 +22,7 @@ export function blockBar(counts: LineCounts, blocks = 5): ("added" | "removed" | ...Array.from({ length: blocks - green - red }, () => "neutral" as const), ]; } -export type Snapshot = - | { type: "revision"; rev: string } - | { type: "index" } - | { type: "working_tree" } - | { type: "empty_tree" } - | { type: "path"; path: string }; -export function snapshotLabel(snapshot: Snapshot): string { +export function snapshotLabel(snapshot: ComparisonEnd): string { switch (snapshot.type) { case "revision": return /^[0-9a-f]{40}$/.test(snapshot.rev) ? snapshot.rev.slice(0, 7) : snapshot.rev; @@ -42,4 +36,4 @@ export function snapshotLabel(snapshot: Snapshot): string { return snapshot.path; } } -export const comparisonLabel = (lhs: Snapshot, rhs: Snapshot) => `${snapshotLabel(lhs)}…${snapshotLabel(rhs)}`; +export const comparisonLabel = (lhs: ComparisonEnd, rhs: ComparisonEnd) => `${snapshotLabel(lhs)}…${snapshotLabel(rhs)}`; diff --git a/diffr/packages/viewer/src/protocol/store.ts b/diffr/packages/viewer/src/protocol/store.ts index 32d5f2f89..1a21884ca 100644 --- a/diffr/packages/viewer/src/protocol/store.ts +++ b/diffr/packages/viewer/src/protocol/store.ts @@ -1,9 +1,8 @@ /** Hold streamed files separately from presentation state and notify React in batches. */ -import { fileIdentity, filePath, type FileChange, type DiffEvent, type DiffFile } from "./wire"; -type StartEvent = Extract; +import { fileIdentity, filePath, type ComparisonEnd, type FileChange, type DiffEvent, type DiffFile } from "./wire"; export interface Snapshot { /** The two ends of the comparison, from the start event. */ - comparison: { lhs: StartEvent["lhs"]; rhs: StartEvent["rhs"] } | null; + comparison: { lhs: ComparisonEnd; rhs: ComparisonEnd } | null; /** The manifest, in comparison order. `files` and `failures` are indexed like it. */ inventory: FileChange[]; /** Each manifest file's diff, once it arrives. */ diff --git a/diffr/packages/viewer/src/protocol/wire.ts b/diffr/packages/viewer/src/protocol/wire.ts index 85b68b510..cc899b93c 100644 --- a/diffr/packages/viewer/src/protocol/wire.ts +++ b/diffr/packages/viewer/src/protocol/wire.ts @@ -124,6 +124,7 @@ export type Span = z.infer; export type SyntaxSpan = z.infer; export type Stats = z.infer; export type LineCounts = z.infer; +export type ComparisonEnd = z.infer; export type DiffEvent = z.infer; export type FileEvent = Extract; /** A file record that carries a diff; failures are kept separately by the store. */ From f6215c28c855a0186ceaa8172eb761347128e4cc Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:15 -0700 Subject: [PATCH 14/25] Copy a selection for an agent with Y (AI-assisted) The copy names the file and lines the selection lives on. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Agent-Session: ea85fcc1-e10f-453d-afea-1d46506e7e1e Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 14 ++++- .../viewer/src/document/selection.test.ts | 50 +++++++++++++++ .../packages/viewer/src/document/selection.ts | 62 +++++++++++++++++-- 3 files changed, 120 insertions(+), 6 deletions(-) create mode 100644 diffr/packages/viewer/src/document/selection.test.ts diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index 8b3537c1a..be8fd3b72 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -18,6 +18,7 @@ import { resizeSidebarWidth } from "./lib/sidebar"; import { CodeRowView } from "./diff/CodeRowView"; import type { ThemeSet } from "../diffr/theme"; import { + agentReference, copySelection, selectionBounds, type SourceSelection, @@ -87,6 +88,15 @@ export function App({ setMessage("Copied source lines"); } }; + const copyForAgent = () => { + if (!selection) return; + if (!snapshot.comparison) throw new Error("A selection exists before diffr named the comparison"); + const text = agentReference(files, snapshot.comparison, rows, selection); + if (text) { + renderer.copyToClipboardOSC52(text); + setMessage("Copied for agent"); + } + }; useKeyboard((key) => { // Viewer first, so a pending z chord takes any key. const press = keyPress(key), chord = viewer.chording; @@ -101,7 +111,7 @@ export function App({ else if ((key.name === "b" && (key.super || key.meta)) || is("\\")) { key.preventDefault(); setShowSidebar(v => !v); } - else if (key.name === "y") copy(); + else if (key.name === "y") { if (key.shift) copyForAgent(); else copy(); } else if (key.name === "escape") { setSelection(null); setMenu(null); setShowBreakdown(false); } else if (key.name === "i") setShowBreakdown((v) => !v); }); @@ -427,7 +437,7 @@ export function App({ } {fit( - `${snapshot.loaded}/${inventory.length} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · i breakdown · drag selects lines · y copy · q quit ${message}`, + `${snapshot.loaded}/${inventory.length} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · i breakdown · drag selects lines · y copy · Y for agent · q quit ${message}`, width, )} diff --git a/diffr/packages/viewer/src/document/selection.test.ts b/diffr/packages/viewer/src/document/selection.test.ts new file mode 100644 index 000000000..9477e579c --- /dev/null +++ b/diffr/packages/viewer/src/document/selection.test.ts @@ -0,0 +1,50 @@ +import { expect, test } from "vitest"; +import { createTestDiffFile } from "../protocol/fixture"; +import { rowsForFile } from "./rows"; +import { agentReference, type SourceSelection } from "./selection"; +import { dark } from "../theme/themes"; +import type { DiffFile } from "../protocol/wire"; + +const againstMain = { lhs: { type: "revision" as const, rev: "main" }, rhs: { type: "working_tree" as const } }; + +/** Select from the row showing line `from` to the row showing line `to` on one side of a split. */ +function select(file: DiffFile, side: SourceSelection["side"], from: number, to: number) { + const rows = rowsForFile(file, 0, "split", dark); + const at = (line: number) => rows.find((row) => row[side]?.lineNumber === line)!.key; + return { rows, selection: { anchor: at(to), end: at(from), side } }; +} + +test("lines from the file on disk are named by path and range alone, then fenced", () => { + const file = createTestDiffFile(); + const { rows, selection } = select(file, "right", 2, 3); + expect(agentReference([file], againstMain, rows, selection)).toBe( + 'demo.ts:2-3\n```ts\nsend("new");\nextra();\n```', + ); +}); + +test("lines from a revision name the revision, so an agent can find that version", () => { + const file = createTestDiffFile(); + const { rows, selection } = select(file, "left", 2, 2); + expect(agentReference([file], againstMain, rows, selection)).toBe( + 'demo.ts:2 at main\n```ts\nsend("old");\n```', + ); +}); + +test("lines from the index say they are staged", () => { + const file = createTestDiffFile(); + const { rows, selection } = select(file, "left", 1, 1); + const staged = { lhs: { type: "index" as const }, rhs: { type: "working_tree" as const } }; + expect(agentReference([file], staged, rows, selection)).toBe( + "demo.ts:1 in the git index (staged)\n```ts\nstart();\n```", + ); +}); + +test("the fence outgrows any run of backticks in the code", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text" || !file.diff.rhs) throw new Error("The fixture is a text diff"); + file.diff.rhs = { ...file.diff.rhs, text: 'start();\nsend("new");\n```\nfinish();\n', syntax: [] }; + const { rows, selection } = select(file, "right", 3, 3); + expect(agentReference([file], againstMain, rows, selection)).toBe( + "demo.ts:3\n````ts\n```\n````", + ); +}); diff --git a/diffr/packages/viewer/src/document/selection.ts b/diffr/packages/viewer/src/document/selection.ts index 1cc7f88d0..6aae26cd5 100644 --- a/diffr/packages/viewer/src/document/selection.ts +++ b/diffr/packages/viewer/src/document/selection.ts @@ -1,6 +1,9 @@ /** Copy original source lines from one side, excluding gutters, padding, and wrapped duplicates. */ import type { DiffFile } from "../protocol/wire"; import type { ViewerRow } from "./rows"; +import type { Snapshot } from "../protocol/store"; +import { snapshotLabel } from "./counts"; +import { sourceLines } from "./regions"; export interface SourceSelection { anchor: string; end: string; @@ -15,6 +18,60 @@ export function selectionBounds( b = rows.findIndex((r) => r.key === selection.end); return a < 0 || b < 0 ? [-1, -1] : [Math.min(a, b), Math.max(a, b)]; } +const lineNumber = (row: ViewerRow, side: SourceSelection["side"]) => + side === "left" + ? (row.left?.lineNumber ?? row.cell?.oldLineNumber) + : (row.right?.lineNumber ?? row.cell?.newLineNumber); +type Version = NonNullable["lhs"]; +/** Where an agent finds this version of a file: the file on disk needs no words, the rest are named. */ +function whereToFind(version: Version): string { + switch (version.type) { + case "working_tree": + return ""; + case "index": + return " in the git index (staged)"; + case "revision": + case "path": + return ` at ${snapshotLabel(version)}`; + case "empty_tree": + throw new Error("The empty tree has no source to copy"); + } +} +/** + * The selection as a reference an agent can read: for each file, its path and line range and, + * unless it is the file on disk, the version it comes from, then those source lines fenced. The + * range runs from the first selected line to the last, folded lines included, so the code always + * matches the range it claims. + */ +export function agentReference( + files: (DiffFile | undefined)[], + comparison: NonNullable, + rows: ViewerRow[], + selection: SourceSelection, +): string { + const [a, b] = selectionBounds(rows, selection); + const ranges = new Map(); + for (const row of rows.slice(a < 0 ? rows.length : a, b + 1)) { + const n = lineNumber(row, selection.side); + if (n === undefined) continue; + const range = ranges.get(row.fileIndex); + ranges.set(row.fileIndex, range ? [Math.min(range[0], n), Math.max(range[1], n)] : [n, n]); + } + const left = selection.side === "left"; + return [...ranges].map(([fileIndex, [start, end]]) => { + const file = files[fileIndex]; + const source = file?.diff.type === "text" ? (left ? file.diff.lhs : file.diff.rhs) : undefined; + const path = left ? file?.file.lhs?.path : file?.file.rhs?.path; + if (!source || path === undefined) throw new Error(`File ${fileIndex} has no ${selection.side} source to copy`); + const code = sourceLines(source.text).slice(start - 1, end).join("\n"); + // Longer than any run of backticks in the code, so the code can't close it. + const fence = "`".repeat(Math.max(3, ...[...code.matchAll(/`+/g)].map((run) => run[0].length + 1))); + const language = /\.([^./]+)$/.exec(path)?.[1] ?? ""; + const lines = start === end ? `${start}` : `${start}-${end}`; + const where = whereToFind(left ? comparison.lhs : comparison.rhs); + return `${path}:${lines}${where}\n${fence}${language}\n${code}\n${fence}`; + }).join("\n\n"); +} export function copySelection( files: (DiffFile | undefined)[], rows: ViewerRow[], @@ -25,10 +82,7 @@ export function copySelection( result: string[] = []; const sources = new Map(); for (const row of rows.slice(a < 0 ? rows.length : a, b + 1)) { - const n = - selection.side === "left" - ? (row.left?.lineNumber ?? row.cell?.oldLineNumber) - : (row.right?.lineNumber ?? row.cell?.newLineNumber); + const n = lineNumber(row, selection.side); if (n === undefined) continue; const key = `${row.fileIndex}:${n}`; if (seen.has(key)) continue; From 5d57cabd193dc12e64699794852e484940177243 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:19 -0700 Subject: [PATCH 15/25] Mark files viewed in the TUI (AI-assisted) Marks last as long as the comparison is open; a hint sits beside each box. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 43 +++++++++++----- .../packages/tui/src/ui/Interaction.test.tsx | 27 ++++++++++ .../packages/tui/src/ui/diff/CodeRowView.tsx | 4 +- diffr/packages/viewer/src/theme/palette.ts | 5 ++ diffr/packages/viewer/src/viewer.ts | 50 +++++++++++++++++-- diffr/packages/viewer/src/viewport/cell.ts | 15 ++++-- 6 files changed, 123 insertions(+), 21 deletions(-) diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index be8fd3b72..5fa63483e 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -30,6 +30,7 @@ import { visibleRows } from "@diffr/viewer/viewport/geometry"; import { sanitizeTerminalLine } from "@diffr/viewer/terminal/sanitize"; import { measureTextWidth, sliceTextByWidth } from "@diffr/viewer/terminal/text"; import { Viewer, type KeyPress } from "@diffr/viewer/viewer"; +import { viewedBox, viewedHint } from "@diffr/viewer/viewport/cell"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; /** Shifted letters become capitals; cmd counts as meta. */ @@ -76,6 +77,7 @@ export function App({ const { inventory, files, failures } = snapshot; // The file's mark in the tree and on its header while it has no diff. const statusGlyph = (index: number) => failures[index] || snapshot.complete ? "!" : loadingGlyph; + const progress = viewer.viewedFiles(); const tree = useMemo(() => buildFileTree(inventory), [inventory]); const fileOrder = useMemo(() => flattenFileTree(tree, new Set()).flatMap(({node}) => node.fileIndex === undefined ? [] : [node.fileIndex]), [tree]); @@ -160,7 +162,13 @@ export function App({ const file = files[fileIndex], count = counts[fileIndex]?.visible; const path = sanitizeTerminalLine(filePath(inventory[fileIndex].file)); const loaded = !!file && !!count; - const statsWidth = loaded ? String(count.added).length + String(count.removed).length + 5 : 0; + const state = loaded ? viewer.isViewed(fileIndex) : undefined, viewed = state === true; + // The counts, then the viewed box. A viewed file is read, so its counts go. + const tally = loaded && !viewed ? [` +${count.added}`, ` −${count.removed}`] : []; + const box = state === undefined ? "" : ` ${viewedBox(state)}`; + // While the pointer is on the box, say what a click does beside it. + const hint = state !== undefined && hovered && "header" in hovered && hovered.file === fileIndex ? ` ${viewedHint(state)}` : ""; + const statsWidth = loaded ? measureTextWidth(tally.join("") + hint + box) + 1 : 0; const pathWidth = Math.max(1, contentWidth - statsWidth - 1); const glyph = loaded ? (viewer.isClosed(fileIndex, file) ? "▸" : "▾") : statusGlyph(fileIndex); const directory = fit(`${glyph} ${path.slice(0, path.lastIndexOf("/") + 1)}`, pathWidth); @@ -169,14 +177,23 @@ export function App({ return { if (loaded) viewer.toggleFile(fileIndex); }}> - ▌ - {directory} - {name} - {loaded && <> - {` +${count.added}`} - {` −${count.removed} `} + ▌ + {directory} + {name} + {tally.length > 0 && <> + {tally[0]} + {tally[1]} } + {hint && <> + + {hint.slice(1)} + } + {state !== undefined && viewer.setHover({ file: fileIndex, header: true })} + onMouseOut={() => viewer.setHover(null)} + onMouseUp={(event) => { event.stopPropagation(); viewer.toggleViewedFile(fileIndex); }}>{box}} + {loaded && } ; }; const rendered = []; @@ -227,9 +244,10 @@ export function App({ if (dragging.current) setSelection((s) => (s ? { ...s, end: row.key } : s)); }} - focus={hovered?.file === row.fileIndex ? hovered : undefined} + focus={hovered?.file === row.fileIndex && "id" in hovered ? hovered : undefined} onHover={focus => viewer.setHover(focus ? { file: row.fileIndex, ...focus } : null)} onFold={(id, recursive) => viewer.setFold(row.fileIndex, id, "toggle", recursive)} + read={viewer.isViewed(row.fileIndex) === true} />, ); } @@ -329,7 +347,8 @@ export function App({ }}> {treeRows.slice(sidebarStart, sidebarStart + viewportHeight).map(({node, depth}) => ( { @@ -344,7 +363,7 @@ export function App({ }}> {fit(sanitizeTerminalLine(" ".repeat(depth) + (node.fileIndex === undefined ? (closedDirectories.has(node.key) ? "▸ " : "▾ ") - : `▤ ${files[node.fileIndex] ? " " : statusGlyph(node.fileIndex)} `) + node.name), sidebar - 1)} + : `▤ ${files[node.fileIndex] ? (viewer.isViewed(node.fileIndex) ? "✓" : " ") : statusGlyph(node.fileIndex)} `) + node.name), sidebar - 1)} ))} @@ -437,7 +456,7 @@ export function App({ } {fit( - `${snapshot.loaded}/${inventory.length} files ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · i breakdown · drag selects lines · y copy · Y for agent · q quit ${message}`, + `${snapshot.loaded}/${inventory.length} files · ${progress.viewed}/${progress.total} viewed ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · V viewed · i breakdown · drag selects lines · y copy · Y for agent · q quit ${message}`, width, )} diff --git a/diffr/packages/tui/src/ui/Interaction.test.tsx b/diffr/packages/tui/src/ui/Interaction.test.tsx index 523ce4bd8..8490b6036 100644 --- a/diffr/packages/tui/src/ui/Interaction.test.tsx +++ b/diffr/packages/tui/src/ui/Interaction.test.tsx @@ -157,3 +157,30 @@ test("closing a scrolled file and reopening it starts at its header and first so expect(lines[3]).toContain("line 0"); } finally { await act(async () => { t.renderer.destroy(); }); } }); +test("V or the header's box marks a file viewed: it closes, and its header, the tree and the status line say so", async () => { + const store = new DiffStore(), file = createTestDiffFile(); + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:170, height:25}); + const frame = () => t.captureCharFrame(); + const lines = () => frame().split("\n"); + const header = () => lines().find(l => l.includes("│▌")) ?? ""; + try { + await act(async () => { await t.renderOnce(); }); + expect(header()).toContain("+2 −1 [ ]"); + expect(frame()).toContain("0/1 viewed"); + const y = lines().findIndex(l => l.includes("│▌")), box = lines()[y].lastIndexOf("[ ]"); + await act(async () => { await t.mockMouse.moveTo(box + 1, y); await t.renderOnce(); }); + await t.waitFor(() => header().includes("Mark as viewed · V")); + expect(header().lastIndexOf("[ ]")).toBe(box); + await act(async () => { await t.mockMouse.click(box + 1, y); await t.renderOnce(); }); + await t.waitFor(() => frame().includes("1/1 viewed")); + expect(header()).toContain("[✓]"); + expect(header()).not.toContain("+2"); + expect(frame()).not.toContain('send("new")'); + expect(header()).toContain("▤ ✓ demo.ts"); + await act(async () => { await t.mockMouse.moveTo(1, 0); t.mockInput.pressKey("V"); await t.renderOnce(); }); + await t.waitFor(() => frame().includes("0/1 viewed")); + expect(header()).toContain("+2 −1 [ ]"); + expect(frame()).toContain('send("new")'); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx index ba9786c03..be1f1b77a 100644 --- a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -27,6 +27,7 @@ export const CodeRowView = memo(function CodeRowView({ onFold, focus, onHover, + read, }: { measured: MeasuredRow; visualLine: number; @@ -38,6 +39,7 @@ export const CodeRowView = memo(function CodeRowView({ focus?: ScopeFocus; onHover: (focus: ScopeFocus | undefined) => void; onFold: (id: number, recursive: boolean) => void; + read: boolean; }) { const row = measured.row; function cell( @@ -47,7 +49,7 @@ export const CodeRowView = memo(function CodeRowView({ side: "left" | "right", unified = false, ) { - const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side }); + const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side, read }); const column = (event: MouseEvent) => event.x - (event.currentTarget?.x ?? 0); // Pressing a fold target doesn't start a selection. const fold = (event: MouseEvent) => targetAt(plan.hits, column(event)); diff --git a/diffr/packages/viewer/src/theme/palette.ts b/diffr/packages/viewer/src/theme/palette.ts index 9a1747628..b3f068f5e 100644 --- a/diffr/packages/viewer/src/theme/palette.ts +++ b/diffr/packages/viewer/src/theme/palette.ts @@ -26,6 +26,9 @@ export interface Palette { addition: string; deletion: string; modification: string; + /** A viewed line's tints: halfway back to the background, so read code recedes. */ + readAddition: string; + readDeletion: string; addWord: string; deleteWord: string; addedText: string; @@ -140,6 +143,8 @@ export function paletteFromHelix(theme: HelixTheme): Palette { addition: mix(bg, plus, 0.12), deletion: mix(bg, minus, 0.12), modification: mix(bg, delta, 0.12), + readAddition: mix(bg, plus, 0.06), + readDeletion: mix(bg, minus, 0.06), addWord: mix(bg, plus, 0.28), deleteWord: mix(bg, minus, 0.28), addedText: plus, diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts index b90ca1f0a..ada78244c 100644 --- a/diffr/packages/viewer/src/viewer.ts +++ b/diffr/packages/viewer/src/viewer.ts @@ -13,14 +13,20 @@ export interface Size { rows: number; } -/** `armed`: a click would fold or open the scope. */ -export interface Hover { +interface ScopeHover { file: number; id: number; armed: boolean; } -/** A key as frontends report it; a shifted letter arrives as its capital. */ +interface HeaderHover { + file: number; + header: true; +} + +export type Hover = ScopeHover | HeaderHover; + +/** A shifted letter arrives as its capital. */ export interface KeyPress { key: string; ctrl?: boolean; @@ -56,6 +62,8 @@ export class Viewer { private readonly closed = new Map(); private readonly collapsed = new Map>(); private pendingZ = false; + /** Files marked viewed, for the viewer's lifetime only. */ + private readonly viewed = new Set(); private readonly rowCache = new WeakMap(); /** Bumped when rows change; keys the geometry cache. */ private revision = 0; @@ -204,11 +212,42 @@ export class Viewer { toggleFile(index: number) { const file = this.snapshot.files[index]; if (!file) return; + this.setClosed(index, !this.isClosed(index, file)); + this.reshape(); + } + + private setClosed(index: number, closed: boolean) { const at = this.current(); // Toggling the file being read moves to its header; the rows above it stay put. const header = at.geometry.rows.find((r) => r.row.key === `${index}:header`)!; if (header.top < at.top) this.position = { key: header.row.key, fileIndex: index, offset: 0, side: "right" }; - this.closed.set(index, !this.isClosed(index, file)); + this.closed.set(index, closed); + } + + /** Undefined when the file shows no changed lines. */ + isViewed(index: number): boolean | undefined { + const diff = this.snapshot.files[index]?.diff; + const markable = diff?.type === "text" && diff.stats.visible.added + diff.stats.visible.removed > 0; + return markable ? this.viewed.has(index) : undefined; + } + + viewedFiles(): { viewed: number; total: number } { + let viewed = 0, total = 0; + this.snapshot.files.forEach((_, index) => { + const state = this.isViewed(index); + if (state === undefined) return; + total++; + if (state) viewed++; + }); + return { viewed, total }; + } + + toggleViewedFile(index: number) { + const state = this.isViewed(index); + if (state === undefined) return; + if (state) this.viewed.delete(index); + else this.viewed.add(index); + this.setClosed(index, !state); this.reshape(); } @@ -301,7 +340,7 @@ export class Viewer { } setHover(hover: Hover | null) { - if (hover?.file === this.hover?.file && hover?.id === this.hover?.id && hover?.armed === this.hover?.armed) return; + if (JSON.stringify(hover) === JSON.stringify(this.hover)) return; this.hover = hover; this.emit(); } @@ -353,6 +392,7 @@ export class Viewer { case "t": this.toggleTheme(); break; case "z": this.pendingZ = true; break; case "return": case "enter": this.toggleTopFile(); break; + case "V": if (at.currentFile >= 0) this.toggleViewedFile(at.currentFile); break; default: return false; } return true; diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts index 313f5800d..3d6d237c7 100644 --- a/diffr/packages/viewer/src/viewport/cell.ts +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -35,8 +35,14 @@ export interface CellOptions { visualLine: number; focus?: ScopeFocus; selected?: boolean; + read?: boolean; } +/** A file header's viewed box, one cell wide in every terminal. */ +export const viewedBox = (viewed: boolean) => (viewed ? "[✓]" : "[ ]"); + +export const viewedHint = (viewed: boolean) => ` ${viewed ? "Unmark viewed" : "Mark as viewed"} · V `; + function chevron(fold: RowFold | undefined) { if (!fold) return " "; return fold.collapsed ? "▸" : "▾"; @@ -47,14 +53,17 @@ export function planCell( spans: RenderSpan[], width: number, unified: boolean, - { theme, geometry, visualLine, focus, selected = false }: CellOptions, + { theme, geometry, visualLine, focus, selected = false, read = false }: CellOptions, ): CellPlan { const fold = value.fold; const washed = focus?.armed && value.body?.includes(focus.id); + const changed = value.kind === "addition" || value.kind === "deletion"; const bg = selected ? theme.highlight - : value.kind === "addition" ? theme.addition - : value.kind === "deletion" ? theme.deletion + : value.kind === "addition" ? (read ? theme.readAddition : theme.addition) + : value.kind === "deletion" ? (read ? theme.readDeletion : theme.deletion) : washed ? theme.focusWash : theme.bg; + // A viewed line keeps its shape but drops its colours: muted ink, no word emphasis. + if (read && changed) spans = spans.map((span) => ({ ...span, fg: theme.muted, bg: undefined })); // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. const digits = geometry.gutter - 4; const number = (n: number | undefined) => `${visualLine ? "" : (n ?? "")}`.padStart(digits); From a149203bb84d86507a3e2c68633455679961bfb3 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:23 -0700 Subject: [PATCH 16/25] Search with / and walk matches with n and N (AI-assisted) Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11a13-5682-7023-a529-1836d6939a48 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 31 ++-- .../packages/tui/src/ui/diff/CodeRowView.tsx | 11 +- diffr/packages/viewer/package.json | 1 + diffr/packages/viewer/src/document/regions.ts | 13 ++ diffr/packages/viewer/src/document/search.ts | 21 +++ diffr/packages/viewer/src/search.test.ts | 72 +++++++++ diffr/packages/viewer/src/theme/palette.ts | 5 + diffr/packages/viewer/src/viewer.ts | 142 +++++++++++++++++- diffr/packages/viewer/src/viewport/cell.ts | 39 ++++- pnpm-lock.yaml | 3 + 10 files changed, 320 insertions(+), 18 deletions(-) create mode 100644 diffr/packages/viewer/src/document/search.ts create mode 100644 diffr/packages/viewer/src/search.test.ts diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index 5fa63483e..96e413f40 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -15,7 +15,7 @@ import { TextAttributes, type KeyEvent } from "@opentui/core"; import { buildFileTree, flattenFileTree, lineCounts, parentDirectories } from "@diffr/viewer/document/fileTree"; import { matchesKey } from "./lib/keys"; import { resizeSidebarWidth } from "./lib/sidebar"; -import { CodeRowView } from "./diff/CodeRowView"; +import { CodeRowView, styled } from "./diff/CodeRowView"; import type { ThemeSet } from "../diffr/theme"; import { agentReference, @@ -30,7 +30,7 @@ import { visibleRows } from "@diffr/viewer/viewport/geometry"; import { sanitizeTerminalLine } from "@diffr/viewer/terminal/sanitize"; import { measureTextWidth, sliceTextByWidth } from "@diffr/viewer/terminal/text"; import { Viewer, type KeyPress } from "@diffr/viewer/viewer"; -import { viewedBox, viewedHint } from "@diffr/viewer/viewport/cell"; +import { litRuns, viewedBox, viewedHint } from "@diffr/viewer/viewport/cell"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; /** Shifted letters become capitals; cmd counts as meta. */ @@ -78,6 +78,10 @@ export function App({ // The file's mark in the tree and on its header while it has no diff. const statusGlyph = (index: number) => failures[index] || snapshot.complete ? "!" : loadingGlyph; const progress = viewer.viewedFiles(); + // While typing, the status line is the prompt; after a search, it leads with where the search stands. + const found = viewer.searchState(); + const searched = !found || "prompt" in found ? "" + : `/${found.pattern} · ${found.total ? `match ${found.at} of ${found.total} in ${found.files} files · n/N` : "no matches"} · `; const tree = useMemo(() => buildFileTree(inventory), [inventory]); const fileOrder = useMemo(() => flattenFileTree(tree, new Set()).flatMap(({node}) => node.fileIndex === undefined ? [] : [node.fileIndex]), [tree]); @@ -101,10 +105,10 @@ export function App({ }; useKeyboard((key) => { // Viewer first, so a pending z chord takes any key. - const press = keyPress(key), chord = viewer.chording; + const press = keyPress(key), chord = viewer.chording, typing = viewer.prompting; if (viewer.press(press)) { - // s and c reshape the rows, so drop the selection, unless they finish a z chord. - if (!chord && !press.ctrl && !press.meta && (press.key === "s" || press.key === "c")) setSelection(null); + // s and c reshape the rows, so drop the selection, unless they finish a z chord or go into the prompt. + if (!chord && !typing && !press.ctrl && !press.meta && (press.key === "s" || press.key === "c")) setSelection(null); return; } // Hunk's chord matcher handles raw control bytes and Kitty events alike. @@ -174,13 +178,16 @@ export function App({ const directory = fit(`${glyph} ${path.slice(0, path.lastIndexOf("/") + 1)}`, pathWidth); const directoryWidth = measureTextWidth(directory); const name = fit(path.slice(path.lastIndexOf("/") + 1), Math.max(0, pathWidth - directoryWidth)); + const lit = viewer.highlight(fileIndex, `${fileIndex}:header`, "right"); return { if (loaded) viewer.toggleFile(fileIndex); }}> ▌ - {directory} - {name} + + {tally.length > 0 && <> {tally[0]} {tally[1]} @@ -248,6 +255,7 @@ export function App({ onHover={focus => viewer.setHover(focus ? { file: row.fileIndex, ...focus } : null)} onFold={(id, recursive) => viewer.setFold(row.fileIndex, id, "toggle", recursive)} read={viewer.isViewed(row.fileIndex) === true} + litOf={(side) => viewer.highlight(row.fileIndex, row.key, side)} />, ); } @@ -454,9 +462,10 @@ export function App({ ))} } - - {fit( - `${snapshot.loaded}/${inventory.length} files · ${progress.viewed}/${progress.total} viewed ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · za fold · V viewed · i breakdown · drag selects lines · y copy · Y for agent · q quit ${message}`, + + {fit(found && "prompt" in found + ? `/${found.prompt}▏ · ${found.count} matches · ⏎ go · esc cancel` + : `${searched}${snapshot.loaded}/${inventory.length} files · ${progress.viewed}/${progress.total} viewed ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · / search · za fold · V viewed · i breakdown · drag selects lines · y copy · Y for agent · q quit ${message}`, width, )} diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx index be1f1b77a..dbdfc1ad7 100644 --- a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -3,7 +3,7 @@ import { memo } from "react"; import { StyledText, parseColor, type MouseEvent } from "@opentui/core"; import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "@diffr/viewer/document/rows"; import type { Geometry, MeasuredRow } from "@diffr/viewer/viewport/geometry"; -import { planCell, targetAt, type PaintRun, type ScopeFocus } from "@diffr/viewer/viewport/cell"; +import { planCell, targetAt, type Lit, type PaintRun, type ScopeFocus } from "@diffr/viewer/viewport/cell"; import type { Palette } from "@diffr/viewer/theme/palette"; const colors = new Map>(); function color(value: string) { @@ -14,8 +14,8 @@ function color(value: string) { } return c; } -const styled = (runs: PaintRun[]) => - new StyledText(runs.map((run) => ({ __isChunk: true as const, text: run.text, fg: color(run.fg), bg: color(run.bg) }))); +export const styled = (runs: PaintRun[], attributes?: number) => + new StyledText(runs.map((run) => ({ __isChunk: true as const, text: run.text, fg: color(run.fg), bg: color(run.bg), attributes }))); export const CodeRowView = memo(function CodeRowView({ measured, visualLine, @@ -28,6 +28,7 @@ export const CodeRowView = memo(function CodeRowView({ focus, onHover, read, + litOf, }: { measured: MeasuredRow; visualLine: number; @@ -40,6 +41,8 @@ export const CodeRowView = memo(function CodeRowView({ onHover: (focus: ScopeFocus | undefined) => void; onFold: (id: number, recursive: boolean) => void; read: boolean; + /** The search pattern to light on one side of the row. */ + litOf: (side: "left" | "right") => Lit | undefined; }) { const row = measured.row; function cell( @@ -49,7 +52,7 @@ export const CodeRowView = memo(function CodeRowView({ side: "left" | "right", unified = false, ) { - const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side, read }); + const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side, read, search: litOf(side) }); const column = (event: MouseEvent) => event.x - (event.currentTarget?.x ?? 0); // Pressing a fold target doesn't start a selection. const fold = (event: MouseEvent) => targetAt(plan.hits, column(event)); diff --git a/diffr/packages/viewer/package.json b/diffr/packages/viewer/package.json index 944dd3913..2260f12e4 100644 --- a/diffr/packages/viewer/package.json +++ b/diffr/packages/viewer/package.json @@ -11,6 +11,7 @@ "test": "vitest run" }, "dependencies": { + "fuzzysort": "4.0.2", "get-east-asian-width": "1.4.0", "smol-toml": "1.9.0", "string-width": "8.1.0", diff --git a/diffr/packages/viewer/src/document/regions.ts b/diffr/packages/viewer/src/document/regions.ts index aa76dedc3..44df422ec 100644 --- a/diffr/packages/viewer/src/document/regions.ts +++ b/diffr/packages/viewer/src/document/regions.ts @@ -178,6 +178,19 @@ export function gapIds(diff: TextDiff): number[] { }); return [...gaps]; } +export function hidingIds(diff: TextDiff, side: Side, line: number, collapsed: ReadonlySet): number[] { + const { leaves, folds } = flatten(diff); + const ids = new Set(); + for (const fold of folds[side]) { + // A syntax fold with a one-line label collapses onto its opener, so it hides its closer's line too. + const inline = fold.syntax && !fold.label.includes("\n"); + const last = inline ? Math.max(fold.lastHidden, fold.syntax!.end.line) : fold.lastHidden; + if (collapsed.has(fold.foldStateId) && fold.startLine <= line && line <= last) ids.add(fold.foldStateId); + } + for (const leaf of leaves[side]) + if (collapsed.has(leaf.foldStateId) && leaf.startLine <= line && line < leaf.endLine) ids.add(leaf.foldStateId); + return [...ids]; +} /** Source lines hidden on one side: a collapsed fold hides every line it covers. */ export function hiddenLines(folds: Fold[], collapsed: ReadonlySet): Set { const hidden = new Set(); diff --git a/diffr/packages/viewer/src/document/search.ts b/diffr/packages/viewer/src/document/search.ts new file mode 100644 index 000000000..db763fe8c --- /dev/null +++ b/diffr/packages/viewer/src/document/search.ts @@ -0,0 +1,21 @@ +/** Vim's `/`: plain text, case-blind until the pattern holds a capital, over file paths and code. */ + +export interface Match { + fileIndex: number; + key: string; + side: "left" | "right"; + line?: number; +} + +/** Vim's smartcase: a capital in the pattern makes it match case. */ +const caseSensitive = (pattern: string) => pattern !== pattern.toLowerCase(); + +export function occurrences(text: string, pattern: string): [number, number][] { + if (!pattern) return []; + const exact = caseSensitive(pattern); + const haystack = exact ? text : text.toLowerCase(), needle = exact ? pattern : pattern.toLowerCase(); + const ranges: [number, number][] = []; + for (let at = haystack.indexOf(needle); at >= 0; at = haystack.indexOf(needle, at + needle.length)) + ranges.push([at, at + needle.length]); + return ranges; +} diff --git a/diffr/packages/viewer/src/search.test.ts b/diffr/packages/viewer/src/search.test.ts new file mode 100644 index 000000000..a9dccaf46 --- /dev/null +++ b/diffr/packages/viewer/src/search.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "vitest"; +import { createGuideDiffFile, startFor } from "./protocol/fixture"; +import { DiffStore } from "./protocol/store"; +import { visibleRows } from "./viewport/geometry"; +import { dark } from "./theme/themes"; +import { Viewer, type KeyPress } from "./viewer"; + +/** The guide file: `log();` and `keep();` sit in a folded gap, `one();` and `two();` in a folded body. */ +function open() { + const store = new DiffStore(), file = createGuideDiffFile(); + store.accept(startFor([file])); + store.accept(file); + const viewer = new Viewer(store, dark, 100); + viewer.lay({ columns: 160, rows: 4 }); + const type = (...keys: string[]) => keys.forEach((key) => viewer.press({ key } satisfies KeyPress)); + const top = () => { + const at = viewer.lay({ columns: 160, rows: 4 }); + return visibleRows(at.geometry, at.top, 1)[0]!.row; + }; + const shown = () => viewer.lay({ columns: 160, rows: 4 }).rows; + return { viewer, type, top, shown }; +} + +test("/ opens a prompt that takes every key, counts as it goes, and Enter lands on the first match", () => { + const { viewer, type, top } = open(); + type("/", "k", "e", "e", "p"); + expect(viewer.prompting).toBe(true); + expect(viewer.searchState()).toEqual({ prompt: "keep", count: 2 }); + type("return"); + expect(viewer.prompting).toBe(false); + expect(viewer.searchState()).toEqual({ pattern: "keep", at: 1, total: 2, files: 1 }); + expect(top().left?.lineNumber).toBe(7); + expect(viewer.highlight(0, top().key, "left")).toEqual({ pattern: "keep", current: true }); + expect(viewer.highlight(0, top().key, "right")).toEqual({ pattern: "keep", current: false }); +}); + +test("n opens the fold hiding the next match, and N walks back, wrapping at either end", () => { + const { viewer, type, top, shown } = open(); + expect(shown().some((row) => row.right?.spans.some((span) => span.text.includes("two();")))).toBe(false); + type("/", "t", "w", "o", "(", "return"); + expect(viewer.searchState()).toMatchObject({ at: 1, total: 2 }); + expect(top().right?.lineNumber).toBe(12); + expect(shown().some((row) => row.right?.spans.some((span) => span.text.includes("two();")))).toBe(true); + type("n"); + expect(viewer.searchState()).toMatchObject({ at: 2 }); + type("n"); + expect(viewer.searchState()).toMatchObject({ at: 1 }); + type("N"); + expect(viewer.searchState()).toMatchObject({ at: 2 }); +}); + +test("file paths are searched too, and a capital makes the pattern match case", () => { + const { viewer, type, top } = open(); + type("/", "d", "e", "m", "o", "return"); + expect(viewer.searchState()).toEqual({ pattern: "demo", at: 1, total: 1, files: 1 }); + expect(top().key).toBe("0:header"); + type("/", "K", "e", "e", "p", "return"); + expect(viewer.searchState()).toEqual({ pattern: "Keep", at: 0, total: 0, files: 0 }); +}); + +test("Escape leaves the prompt without searching, and Backspace on an empty prompt leaves it too", () => { + const { viewer, type } = open(); + type("/", "o", "escape"); + expect(viewer.prompting).toBe(false); + expect(viewer.searchState()).toBeUndefined(); + type("/", "o", "backspace"); + expect(viewer.searchState()).toEqual({ prompt: "", count: 0 }); + type("backspace"); + expect(viewer.prompting).toBe(false); + type("/", "q"); + expect(viewer.searchState()).toEqual({ prompt: "q", count: 0 }); +}); diff --git a/diffr/packages/viewer/src/theme/palette.ts b/diffr/packages/viewer/src/theme/palette.ts index b3f068f5e..2829bba0d 100644 --- a/diffr/packages/viewer/src/theme/palette.ts +++ b/diffr/packages/viewer/src/theme/palette.ts @@ -29,6 +29,9 @@ export interface Palette { /** A viewed line's tints: halfway back to the background, so read code recedes. */ readAddition: string; readDeletion: string; + /** Every match of the last search, a soft wash; the one the view is on, solid. */ + searchMatch: string; + searchCurrent: string; addWord: string; deleteWord: string; addedText: string; @@ -145,6 +148,8 @@ export function paletteFromHelix(theme: HelixTheme): Palette { modification: mix(bg, delta, 0.12), readAddition: mix(bg, plus, 0.06), readDeletion: mix(bg, minus, 0.06), + searchMatch: mix(bg, delta, 0.3), + searchCurrent: delta, addWord: mix(bg, plus, 0.28), deleteWord: mix(bg, minus, 0.28), addedText: plus, diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts index ada78244c..efae6fb69 100644 --- a/diffr/packages/viewer/src/viewer.ts +++ b/diffr/packages/viewer/src/viewer.ts @@ -1,6 +1,7 @@ import { buildFileTree, flattenFileTree } from "./document/fileTree"; -import { defaultCollapsed, foldIds, gapIds, nestedIds, sourceLines } from "./document/regions"; -import { placeholderRows, rowsForFile, type Layout, type ViewerRow } from "./document/rows"; +import { defaultCollapsed, foldIds, gapIds, hidingIds, nestedIds, sourceLines } from "./document/regions"; +import { occurrences, type Match } from "./document/search"; +import { placeholderRows, rowsForFile, type Layout, type SplitLineCell, type UnifiedLineCell, type ViewerRow } from "./document/rows"; import type { DiffStore, Snapshot } from "./protocol/store"; import { filePath, fileVisibility, type DiffFile, type TextDiff } from "./protocol/wire"; import { measureRows, positionAt, positionTop, rowFold, visibleRows, type Geometry, type MeasuredRow, type ViewPosition } from "./viewport/geometry"; @@ -64,6 +65,11 @@ export class Viewer { private pendingZ = false; /** Files marked viewed, for the viewer's lifetime only. */ private readonly viewed = new Set(); + private prompt: string | null = null; + private search: { pattern: string; matches: Match[]; at: number; snapshot: Snapshot } | null = null; + private promptCount: { pattern: string; snapshot: Snapshot; count: number } | undefined; + /** Each file's rows with every fold open, in the layout they were built for: what search reads. */ + private readonly openRows = new WeakMap(); private readonly rowCache = new WeakMap(); /** Bumped when rows change; keys the geometry cache. */ private revision = 0; @@ -251,6 +257,131 @@ export class Viewer { this.reshape(); } + /** The `/` prompt is open: every key goes to it. */ + get prompting(): boolean { + return this.prompt !== null; + } + + searchState(): { prompt: string; count: number } | { pattern: string; at: number; total: number; files: number } | undefined { + if (this.prompt !== null) { + const snapshot = this.snapshot; + if (this.promptCount?.pattern !== this.prompt || this.promptCount.snapshot !== snapshot) + this.promptCount = { pattern: this.prompt, snapshot, count: this.findMatches(this.prompt).length }; + return { prompt: this.prompt, count: this.promptCount.count }; + } + if (!this.search) return undefined; + const { pattern, matches, at } = this.search; + return { pattern, at: at + 1, total: matches.length, files: new Set(matches.map((match) => match.fileIndex)).size }; + } + + /** The pattern to light on one side of a row: the one being typed, else the last search's; `current` on the match the view is on. */ + highlight(fileIndex: number, key: string, side: "left" | "right"): { pattern: string; current: boolean } | undefined { + const pattern = this.prompt ?? this.search?.pattern; + if (!pattern) return undefined; + const on = this.prompt === null && this.search ? this.search.matches[this.search.at] : undefined; + return { pattern, current: !!on && on.fileIndex === fileIndex && on.key === key && on.side === side }; + } + + private allOpenRows(index: number, file: DiffFile, layout: Layout): ViewerRow[] { + let cached = this.openRows.get(file); + if (cached?.layout !== layout) { + cached = { layout, rows: rowsForFile(file, index, layout, this.theme, new Set()) }; + this.openRows.set(file, cached); + } + return cached.rows; + } + + private findMatches(pattern: string): Match[] { + const { files, inventory } = this.snapshot; + const layout = this.current().layout; + const matches: Match[] = []; + for (const index of this.fileOrder(this.snapshot)) { + for (const _ of occurrences(filePath(inventory[index]!.file), pattern)) + matches.push({ fileIndex: index, key: `${index}:header`, side: "right" }); + const file = files[index]; + if (!file) continue; + for (const row of this.allOpenRows(index, file, layout)) { + const cells: ["left" | "right", SplitLineCell | UnifiedLineCell | undefined, number | undefined][] = row.cell + ? [[row.cell.newLineNumber === undefined ? "left" : "right", row.cell, row.cell.newLineNumber ?? row.cell.oldLineNumber]] + : [["left", row.left, row.left?.lineNumber], ["right", row.right, row.right?.lineNumber]]; + for (const [side, cell, line] of cells) { + if (!cell || line === undefined) continue; + for (const _ of occurrences(cell.spans.map((span) => span.text).join(""), pattern)) + matches.push({ fileIndex: index, key: row.key, side, line }); + } + } + } + return matches; + } + + private placeOf(order: number[], layout: Layout, fileIndex: number, key: string, side: "left" | "right", line?: number): number { + const file = this.snapshot.files[fileIndex]; + const rows = file ? this.allOpenRows(fileIndex, file, layout) : []; + const lineOf = (row: ViewerRow) => side === "left" ? row.left?.lineNumber ?? row.cell?.oldLineNumber + : row.right?.lineNumber ?? row.cell?.newLineNumber; + let row = rows.findIndex((r) => r.key === key); + // A row that only exists folded, such as a collapsed fold's own row, sits where its line does. + if (row < 0 && line !== undefined) row = rows.findIndex((r) => (lineOf(r) ?? -1) >= line); + // No file has ten million rows, so a file's place times that leaves room for all of its rows. + return order.indexOf(fileIndex) * 1e7 + Math.max(0, row); + } + + private commitSearch(pattern: string) { + this.prompt = null; + if (!pattern) return this.emit(); + const snapshot = this.snapshot, matches = this.findMatches(pattern); + this.search = { pattern, matches, at: -1, snapshot }; + if (!matches.length) return this.emit(); + const at = this.current(), top = positionAt(at.geometry, at.top); + const order = this.fileOrder(snapshot); + const from = top ? this.placeOf(order, at.layout, top.fileIndex, top.key, top.side, top.line) : 0; + const next = matches.findIndex((match) => this.placeOf(order, at.layout, match.fileIndex, match.key, match.side, match.line) >= from); + this.search.at = next < 0 ? 0 : next; + this.goTo(matches[this.search.at]!); + } + + /** Also searches files that streamed in since the search. */ + private stepMatch(direction: 1 | -1) { + const search = this.search; + if (!search) return; + if (search.snapshot !== this.snapshot) { + const on = search.matches[search.at]; + search.matches = this.findMatches(search.pattern); + search.snapshot = this.snapshot; + search.at = on ? search.matches.findIndex((m) => m.fileIndex === on.fileIndex && m.key === on.key && m.side === on.side) : -1; + } + const total = search.matches.length; + if (!total) return this.emit(); + search.at = (search.at + direction + total) % total; + this.goTo(search.matches[search.at]!); + } + + private goTo(match: Match) { + const file = this.snapshot.files[match.fileIndex]; + if (file && match.line !== undefined) { + if (this.isClosed(match.fileIndex, file)) this.closed.set(match.fileIndex, false); + if (file.diff.type === "text") { + const hiding = hidingIds(file.diff, match.side === "left" ? 0 : 1, match.line - 1, this.foldsOf(match.fileIndex, file.diff)); + if (hiding.length) this.setFolds(match.fileIndex, file.diff, hiding, false); + } + } + this.position = { key: match.key, fileIndex: match.fileIndex, offset: 0, side: match.side, line: match.line }; + this.reshape(); + } + + private type(key: KeyPress) { + const name = key.key, prompt = this.prompt!; + if (name === "return" || name === "enter") return this.commitSearch(prompt); + if (name === "escape" || (key.ctrl && (name === "c" || name === "g"))) this.prompt = null; + else if (name === "backspace") this.prompt = prompt ? prompt.slice(0, -1) : null; + else if (!key.ctrl && !key.meta) { + const text = name === "space" ? " " : name; + if ([...text].length !== 1) return; + this.prompt = prompt + text; + } else return; + this.emit(); + } + /** "toggle" reads the first id's state, so quick repeated clicks alternate. */ private setFolds(fileIndex: number, diff: TextDiff, ids: number[], collapse: boolean | "toggle") { const next = new Set(this.foldsOf(fileIndex, diff)); @@ -356,6 +487,10 @@ export class Viewer { const at = this.current(); const page = at.size.rows, half = Math.max(1, Math.floor(at.size.rows / 2)); const name = key.key; + if (this.prompt !== null) { + this.type(key); + return true; + } if (this.pendingZ) { this.pendingZ = false; if (!key.ctrl && !key.meta && name.length === 1 && "aocAOCRMjk".includes(name)) this.foldCommand(name); @@ -393,6 +528,9 @@ export class Viewer { case "z": this.pendingZ = true; break; case "return": case "enter": this.toggleTopFile(); break; case "V": if (at.currentFile >= 0) this.toggleViewedFile(at.currentFile); break; + case "/": this.prompt = ""; this.emit(); break; + case "n": this.stepMatch(1); break; + case "N": this.stepMatch(-1); break; default: return false; } return true; diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts index 3d6d237c7..b2f718c0a 100644 --- a/diffr/packages/viewer/src/viewport/cell.ts +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -2,6 +2,7 @@ import type { Geometry } from "./geometry"; import { foldBackground, type RenderSpan, type SplitLineCell, type UnifiedLineCell } from "../document/rows"; import { measureTextWidth } from "../terminal/text"; import type { RowFold } from "../document/regions"; +import { occurrences } from "../document/search"; import type { Palette } from "../theme/palette"; /** The scope the pointer is on. Armed when it points at the scope's rail or chevron, which fold it. */ @@ -36,6 +37,41 @@ export interface CellOptions { focus?: ScopeFocus; selected?: boolean; read?: boolean; + /** The pattern to light, and whether this cell holds the match the view is on. */ + search?: Lit; +} + +export interface Lit { + pattern: string; + current: boolean; +} + +/** Splits spans so the pattern's occurrences take the search colours: a wash for every match, solid where the view is. */ +function lightMatches(spans: RenderSpan[], lit: Lit, theme: Palette): RenderSpan[] { + const ranges = occurrences(spans.map((span) => span.text).join(""), lit.pattern); + if (!ranges.length) return spans; + const result: RenderSpan[] = []; + let offset = 0; + for (const span of spans) { + const end = offset + span.text.length; + let cut = offset; + for (const [from, to] of ranges) { + if (to <= cut || from >= end) continue; + const start = Math.max(from, cut), stop = Math.min(to, end); + if (start > cut) result.push({ ...span, text: span.text.slice(cut - offset, start - offset) }); + result.push({ ...span, text: span.text.slice(start - offset, stop - offset), + bg: lit.current ? theme.searchCurrent : theme.searchMatch, fg: lit.current ? theme.bg : span.fg }); + cut = stop; + } + if (cut < end) result.push({ ...span, text: span.text.slice(cut - offset) }); + offset = end; + } + return result; +} + +export function litRuns(text: string, fg: string, bg: string, lit: Lit | undefined, theme: Palette): PaintRun[] { + const spans = lit ? lightMatches([{ text, fg, bg }], lit, theme) : [{ text, fg, bg }]; + return spans.map((span) => ({ text: span.text, fg: span.fg ?? fg, bg: span.bg ?? bg })); } /** A file header's viewed box, one cell wide in every terminal. */ @@ -53,7 +89,7 @@ export function planCell( spans: RenderSpan[], width: number, unified: boolean, - { theme, geometry, visualLine, focus, selected = false, read = false }: CellOptions, + { theme, geometry, visualLine, focus, selected = false, read = false, search }: CellOptions, ): CellPlan { const fold = value.fold; const washed = focus?.armed && value.body?.includes(focus.id); @@ -64,6 +100,7 @@ export function planCell( : washed ? theme.focusWash : theme.bg; // A viewed line keeps its shape but drops its colours: muted ink, no word emphasis. if (read && changed) spans = spans.map((span) => ({ ...span, fg: theme.muted, bg: undefined })); + if (search) spans = lightMatches(spans, search, theme); // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. const digits = geometry.gutter - 4; const number = (n: number | undefined) => `${visualLine ? "" : (n ?? "")}`.padStart(digits); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f37a985b3..903468ec1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -144,6 +144,9 @@ importers: diffr/packages/viewer: dependencies: + fuzzysort: + specifier: 4.0.2 + version: 4.0.2 get-east-asian-width: specifier: 1.4.0 version: 1.4.0 From 5a67d98f7c7a2c1e4e5ec8a6ccc9e60fa3ef3f01 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:26 -0700 Subject: [PATCH 17/25] Go to a changed file with Ctrl-P, ranked by fuzzysort (AI-assisted) Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 11 +++ .../packages/tui/src/ui/diff/CodeRowView.tsx | 8 +- diffr/packages/viewer/src/document/pick.ts | 30 +++++++ diffr/packages/viewer/src/pick.test.ts | 62 ++++++++++++++ diffr/packages/viewer/src/viewer.ts | 52 +++++++++++- diffr/packages/viewer/src/viewport/picker.ts | 81 +++++++++++++++++++ 6 files changed, 239 insertions(+), 5 deletions(-) create mode 100644 diffr/packages/viewer/src/document/pick.ts create mode 100644 diffr/packages/viewer/src/pick.test.ts create mode 100644 diffr/packages/viewer/src/viewport/picker.ts diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index 96e413f40..acc501d5b 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -31,6 +31,7 @@ import { sanitizeTerminalLine } from "@diffr/viewer/terminal/sanitize"; import { measureTextWidth, sliceTextByWidth } from "@diffr/viewer/terminal/text"; import { Viewer, type KeyPress } from "@diffr/viewer/viewer"; import { litRuns, viewedBox, viewedHint } from "@diffr/viewer/viewport/cell"; +import { pickerLines } from "@diffr/viewer/viewport/picker"; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; /** Shifted letters become capitals; cmd counts as meta. */ @@ -452,6 +453,16 @@ export function App({ {fit(" esc close", boxWidth)} ; })()} + {(() => { + const picker = viewer.pickerState(); + if (!picker) return null; + const drawn = pickerLines(picker, width, Math.min(10, viewportHeight), theme, + (index) => counts[index]?.visible, (index) => viewer.isViewed(index) === true, "esc close"); + return + {drawn.map(({ runs, pick }, i) => { if (pick !== undefined) viewer.pickFile(pick); }} />)} + ; + })()} {menu && diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx index dbdfc1ad7..c9c4c7747 100644 --- a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -1,6 +1,6 @@ /** Paint measured Hunk-style code cells; source identity and viewport geometry stay outside React. */ import { memo } from "react"; -import { StyledText, parseColor, type MouseEvent } from "@opentui/core"; +import { StyledText, TextAttributes, parseColor, type MouseEvent } from "@opentui/core"; import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "@diffr/viewer/document/rows"; import type { Geometry, MeasuredRow } from "@diffr/viewer/viewport/geometry"; import { planCell, targetAt, type Lit, type PaintRun, type ScopeFocus } from "@diffr/viewer/viewport/cell"; @@ -14,8 +14,10 @@ function color(value: string) { } return c; } -export const styled = (runs: PaintRun[], attributes?: number) => - new StyledText(runs.map((run) => ({ __isChunk: true as const, text: run.text, fg: color(run.fg), bg: color(run.bg), attributes }))); +/** A run may ask for bold, as the picker's matched letters do; `attributes` applies to the rest. */ +export const styled = (runs: (PaintRun & { bold?: boolean })[], attributes?: number) => + new StyledText(runs.map((run) => ({ __isChunk: true as const, text: run.text, fg: color(run.fg), bg: color(run.bg), + attributes: run.bold ? TextAttributes.BOLD : attributes }))); export const CodeRowView = memo(function CodeRowView({ measured, visualLine, diff --git a/diffr/packages/viewer/src/document/pick.ts b/diffr/packages/viewer/src/document/pick.ts new file mode 100644 index 000000000..af1d3f2ba --- /dev/null +++ b/diffr/packages/viewer/src/document/pick.ts @@ -0,0 +1,30 @@ +import fuzzysort, { type Prepared } from "fuzzysort"; +import { filePath, type FileChange } from "../protocol/wire"; + +export interface Pick { + fileIndex: number; + path: string; + indexes: readonly number[]; +} + +const prepared = new WeakMap(); + +function preparedPaths(inventory: readonly FileChange[]) { + let paths = prepared.get(inventory); + if (!paths) { + paths = inventory.map((change) => fuzzysort.prepare(filePath(change.file))); + prepared.set(inventory, paths); + } + return paths; +} + +/** Viewed files sink, keeping order. */ +export function rankFiles(inventory: readonly FileChange[], order: readonly number[], query: string, + viewed: (index: number) => boolean): Pick[] { + const paths = preparedPaths(inventory); + const picks: Pick[] = query + ? fuzzysort.go(query, order, { key: (index) => paths[index], limit: 0, threshold: 0 }) + .map((result) => ({ fileIndex: result.obj, path: result.target, indexes: result.indexes })) + : order.map((index) => ({ fileIndex: index, path: paths[index]!.target, indexes: [] })); + return [...picks.filter((pick) => !viewed(pick.fileIndex)), ...picks.filter((pick) => viewed(pick.fileIndex))]; +} diff --git a/diffr/packages/viewer/src/pick.test.ts b/diffr/packages/viewer/src/pick.test.ts new file mode 100644 index 000000000..71af06a4a --- /dev/null +++ b/diffr/packages/viewer/src/pick.test.ts @@ -0,0 +1,62 @@ +import { expect, test } from "vitest"; +import { createTestDiffFile, startFor, withIdenticalLines } from "./protocol/fixture"; +import { DiffStore } from "./protocol/store"; +import { visibleRows } from "./viewport/geometry"; +import type { DiffFile } from "./protocol/wire"; +import { dark } from "./theme/themes"; +import { Viewer, type KeyPress } from "./viewer"; + +const named = (path: string) => { + const file = withIdenticalLines(createTestDiffFile(), 30); + file.file.lhs!.path = file.file.rhs!.path = path; + return file; +}; + +function open(files: DiffFile[]) { + const store = new DiffStore(); + store.accept(startFor(files)); + files.forEach((file) => store.accept(file)); + const viewer = new Viewer(store, dark, 100); + viewer.lay({ columns: 160, rows: 6 }); + const press = (...keys: (string | KeyPress)[]) => + keys.forEach((key) => viewer.press(typeof key === "string" ? { key } : key)); + const topFile = () => { + const at = viewer.lay({ columns: 160, rows: 6 }); + return visibleRows(at.geometry, at.top, 1)[0]!.row; + }; + return { viewer, press, topFile }; +} + +const files = () => [named("src/model/store.ts"), named("src/model/selection.ts"), named("src/protocol/wire.ts"), named("docs/notes.md")]; + +test("Ctrl-P lists every changed file, and letters in order narrow it, best match first", () => { + const { viewer, press } = open(files()); + press({ key: "p", ctrl: true }); + expect(viewer.picking).toBe(true); + expect(viewer.pickerState()!.picks.map((pick) => pick.path)).toEqual( + ["docs/notes.md", "src/model/selection.ts", "src/model/store.ts", "src/protocol/wire.ts"]); + press("s", "t", "o"); + const picks = viewer.pickerState()!.picks; + expect(picks[0]!.path).toBe("src/model/store.ts"); + expect(picks.map((pick) => pick.path)).not.toContain("docs/notes.md"); + expect(picks[0]!.indexes.map((index) => picks[0]!.path[index]).join("")).toBe("sto"); +}); + +test("Enter goes to the file under the cursor and closes the picker; Escape closes it where it was", () => { + const { viewer, press, topFile } = open(files()); + press({ key: "p", ctrl: true }, "w", "i", "r", "e", "return"); + expect(viewer.picking).toBe(false); + expect(topFile().key).toBe(`${2}:header`); + press({ key: "p", ctrl: true }, "down", { key: "n", ctrl: true }, { key: "p", ctrl: true }); + expect(viewer.pickerState()!.cursor).toBe(1); + press("escape"); + expect(viewer.picking).toBe(false); + expect(topFile().key).toBe(`${2}:header`); +}); + +test("viewed files sink below the rest of the matches", () => { + const { viewer, press } = open(files()); + viewer.toggleViewedFile(0); + press({ key: "p", ctrl: true }, "s"); + expect(viewer.pickerState()!.picks.at(-1)!.path).toBe("src/model/store.ts"); +}); diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts index efae6fb69..98c2e2ccf 100644 --- a/diffr/packages/viewer/src/viewer.ts +++ b/diffr/packages/viewer/src/viewer.ts @@ -1,6 +1,7 @@ import { buildFileTree, flattenFileTree } from "./document/fileTree"; import { defaultCollapsed, foldIds, gapIds, hidingIds, nestedIds, sourceLines } from "./document/regions"; import { occurrences, type Match } from "./document/search"; +import { rankFiles, type Pick } from "./document/pick"; import { placeholderRows, rowsForFile, type Layout, type SplitLineCell, type UnifiedLineCell, type ViewerRow } from "./document/rows"; import type { DiffStore, Snapshot } from "./protocol/store"; import { filePath, fileVisibility, type DiffFile, type TextDiff } from "./protocol/wire"; @@ -68,7 +69,7 @@ export class Viewer { private prompt: string | null = null; private search: { pattern: string; matches: Match[]; at: number; snapshot: Snapshot } | null = null; private promptCount: { pattern: string; snapshot: Snapshot; count: number } | undefined; - /** Each file's rows with every fold open, in the layout they were built for: what search reads. */ + private picker: { query: string; cursor: number } | null = null; private readonly openRows = new WeakMap(); private readonly rowCache = new WeakMap(); /** Bumped when rows change; keys the geometry cache. */ @@ -257,7 +258,46 @@ export class Viewer { this.reshape(); } - /** The `/` prompt is open: every key goes to it. */ + /** The Ctrl-P picker is open: every key goes to it. */ + get picking(): boolean { + return this.picker !== null; + } + + pickerState(): { query: string; cursor: number; picks: Pick[]; total: number } | undefined { + if (!this.picker) return undefined; + const { inventory } = this.snapshot; + const picks = rankFiles(inventory, this.fileOrder(this.snapshot), this.picker.query, (index) => this.isViewed(index) === true); + return { query: this.picker.query, cursor: Math.min(this.picker.cursor, Math.max(0, picks.length - 1)), picks, total: inventory.length }; + } + + pickFile(index: number) { + this.picker = null; + const file = this.snapshot.files[index]; + if (file && this.isClosed(index, file)) { + this.closed.set(index, false); + this.revision++; + } + this.jump(index); + } + + private typePicker(key: KeyPress) { + const picker = this.picker!, name = key.key; + if (name === "escape" || (key.ctrl && (name === "c" || name === "g"))) this.picker = null; + else if (name === "return" || name === "enter") { + const state = this.pickerState()!, pick = state.picks[state.cursor]; + if (pick) return this.pickFile(pick.fileIndex); + this.picker = null; + } else if (name === "up" || (key.ctrl && name === "p")) picker.cursor = Math.max(0, picker.cursor - 1); + else if (name === "down" || (key.ctrl && name === "n")) picker.cursor += 1; + else if (name === "backspace") this.picker = { query: picker.query.slice(0, -1), cursor: 0 }; + else if (!key.ctrl && !key.meta) { + const text = name === "space" ? " " : name; + if ([...text].length !== 1) return; + this.picker = { query: picker.query + text, cursor: 0 }; + } else return; + this.emit(); + } + get prompting(): boolean { return this.prompt !== null; } @@ -487,6 +527,10 @@ export class Viewer { const at = this.current(); const page = at.size.rows, half = Math.max(1, Math.floor(at.size.rows / 2)); const name = key.key; + if (this.picker) { + this.typePicker(key); + return true; + } if (this.prompt !== null) { this.type(key); return true; @@ -502,6 +546,10 @@ export class Viewer { else if (name === "u") this.move(-half); else if (name === "f") this.move(page); else if (name === "b") this.move(-page); + else if (name === "p") { + this.picker = { query: "", cursor: 0 }; + this.emit(); + } else return false; return true; } diff --git a/diffr/packages/viewer/src/viewport/picker.ts b/diffr/packages/viewer/src/viewport/picker.ts new file mode 100644 index 000000000..7283183f0 --- /dev/null +++ b/diffr/packages/viewer/src/viewport/picker.ts @@ -0,0 +1,81 @@ +import type { LineCounts } from "../document/counts"; +import type { Pick } from "../document/pick"; +import { measureTextWidth, sliceTextByWidth } from "../terminal/text"; +import { sanitizeTerminalLine } from "../terminal/sanitize"; +import type { Palette } from "../theme/palette"; +import type { PaintRun } from "./cell"; + +interface PickerRun extends PaintRun { + bold?: boolean; +} + +interface PickerLine { + runs: PickerRun[]; + pick?: number; +} + +interface PickerState { + query: string; + cursor: number; + picks: Pick[]; + total: number; +} + +function exactly(runs: PickerRun[], width: number, bg: string): PickerRun[] { + const result: PickerRun[] = []; + let room = width; + for (const run of runs) { + if (room <= 0) break; + const cells = measureTextWidth(run.text); + result.push(cells <= room ? run : { ...run, text: sliceTextByWidth(run.text, 0, room).text }); + room -= Math.min(cells, room); + } + if (room > 0) result.push({ text: " ".repeat(room), fg: bg, bg }); + return result; +} + +/** Long paths lose their start so the file name shows. */ +export function pickerLines(state: PickerState, width: number, height: number, theme: Palette, + counts: (index: number) => LineCounts | undefined, viewed: (index: number) => boolean, closeHint: string): PickerLine[] { + const bg = theme.chrome; + const room = Math.max(1, Math.min(state.picks.length, height - 2)); + const first = Math.max(0, Math.min(state.cursor - room + 1, state.picks.length - room)); + const count = `${state.picks.length} of ${state.total} changed files `; + const lines: PickerLine[] = [{ runs: exactly([{ text: " ".repeat(Math.max(0, width - measureTextWidth(count))), fg: bg, bg }, + { text: count, fg: theme.muted, bg }], width, bg) }]; + for (const [offset, pick] of state.picks.slice(first, first + room).entries()) { + const cursor = first + offset === state.cursor, rowBg = cursor ? theme.highlight : bg; + const read = viewed(pick.fileIndex), tally = counts(pick.fileIndex); + const right: PickerRun[] = read ? [{ text: "✓ ", fg: theme.accent, bg: rowBg }] + : tally ? [{ text: `+${tally.added} `, fg: theme.addedText, bg: rowBg }, { text: `−${tally.removed} `, fg: theme.removedText, bg: rowBg }] + : [{ text: " ", fg: rowBg, bg: rowBg }]; + const rightWidth = right.reduce((n, run) => n + measureTextWidth(run.text), 0); + const room = Math.max(1, width - 2 - rightWidth - 1); + let path = sanitizeTerminalLine(pick.path), shift = 0; + if (measureTextWidth(path) > room) { + shift = path.length - (room - 1); + path = `…${path.slice(shift)}`; + shift -= 1; + } + const matched = new Set(pick.indexes.map((index) => index - shift)); + const nameStart = path.lastIndexOf("/") + 1; + const runs: PickerRun[] = [{ text: cursor ? "▸ " : " ", fg: theme.accent, bg: rowBg }]; + [...path].forEach((char, index) => { + const fg = matched.has(index) ? theme.accent : read ? theme.muted : index < nameStart ? theme.fileHeaderDir : theme.fg; + const bold = matched.has(index) && !read; + const last = runs.at(-1)!; + if (last.fg === fg && !!last.bold === bold && runs.length > 1) last.text += char; + else runs.push({ text: char, fg, bg: rowBg, bold }); + }); + const used = runs.reduce((n, run) => n + measureTextWidth(run.text), 0); + runs.push({ text: " ".repeat(Math.max(0, width - used - rightWidth)), fg: rowBg, bg: rowBg }, ...right); + lines.push({ runs: exactly(runs, width, rowBg), pick: pick.fileIndex }); + } + const hint = `⏎ open · ↑↓ or ctrl-n/p move · ${closeHint} `; + const typed: PickerRun[] = [{ text: "› ", fg: theme.accent, bg }, { text: state.query, fg: theme.fg, bg }, + { text: " ", fg: theme.bg, bg: theme.fg }]; + const typedWidth = typed.reduce((n, run) => n + measureTextWidth(run.text), 0); + lines.push({ runs: exactly([...typed, { text: " ".repeat(Math.max(1, width - typedWidth - measureTextWidth(hint))), fg: bg, bg }, + { text: hint, fg: theme.muted, bg }], width, bg) }); + return lines; +} From 3fc9603bcb7b6bfb818b5f272e3dd9fea7f79273 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:30 -0700 Subject: [PATCH 18/25] Search and picker polish, ]c and [c, and a status line that keeps ? keys (AI-assisted) Search shows one hue for matches and another for the current one. Cmd-P opens the picker where the terminal passes Cmd through. ]c and [c go to the next and previous change, as in vim. Typed characters from kitty terminals act as keys. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 47 ++++++++---- .../packages/tui/src/ui/Interaction.test.tsx | 23 ++++++ .../packages/tui/src/ui/diff/CodeRowView.tsx | 7 +- diffr/packages/viewer/src/document/rows.ts | 1 + diffr/packages/viewer/src/document/search.ts | 1 + diffr/packages/viewer/src/pick.test.ts | 27 +++++++ diffr/packages/viewer/src/search.test.ts | 22 +++++- diffr/packages/viewer/src/theme/palette.ts | 8 +- diffr/packages/viewer/src/viewer.ts | 75 +++++++++++++------ diffr/packages/viewer/src/viewport/cell.ts | 23 +++--- 10 files changed, 172 insertions(+), 62 deletions(-) diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index acc501d5b..7123c95eb 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -32,13 +32,19 @@ import { measureTextWidth, sliceTextByWidth } from "@diffr/viewer/terminal/text" import { Viewer, type KeyPress } from "@diffr/viewer/viewer"; import { litRuns, viewedBox, viewedHint } from "@diffr/viewer/viewport/cell"; import { pickerLines } from "@diffr/viewer/viewport/picker"; +/** Pinned to the status line's right end, so cut hints never cut the way to the key list. */ +const KEYS_BUTTON = " ? keys "; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; -/** Shifted letters become capitals; cmd counts as meta. */ -const keyPress = (key: KeyEvent): KeyPress => ({ - key: key.shift && /^[a-z]$/.test(key.name) ? key.name.toUpperCase() : key.name, - ctrl: key.ctrl, shift: key.shift, meta: key.meta || key.super === true, -}); +/** Kitty reports the base key; use the typed sequence for printables. */ +const keyPress = (key: KeyEvent): KeyPress => { + const modified = key.ctrl || key.meta || key.super === true; + const typed = !modified && key.sequence !== " " && /^[^\x00-\x1f\x7f]$/u.test(key.sequence) ? key.sequence : undefined; + return { + key: typed ?? (key.shift && /^[a-z]$/.test(key.name) ? key.name.toUpperCase() : key.name), + ctrl: key.ctrl, shift: key.shift, meta: key.meta || key.super === true, + }; +}; export function App({ store, onQuit, @@ -121,6 +127,7 @@ export function App({ else if (key.name === "y") { if (key.shift) copyForAgent(); else copy(); } else if (key.name === "escape") { setSelection(null); setMenu(null); setShowBreakdown(false); } else if (key.name === "i") setShowBreakdown((v) => !v); + else if (press.key === "?") setMenu((m) => (m === "Help" ? null : "Help")); }); const [selectionStart, selectionEnd] = useMemo( () => selectionBounds(rows, selection), @@ -179,7 +186,7 @@ export function App({ const directory = fit(`${glyph} ${path.slice(0, path.lastIndexOf("/") + 1)}`, pathWidth); const directoryWidth = measureTextWidth(directory); const name = fit(path.slice(path.lastIndexOf("/") + 1), Math.max(0, pathWidth - directoryWidth)); - const lit = viewer.highlight(fileIndex, `${fileIndex}:header`, "right"); + const lit = viewer.headerLit(fileIndex); return { if (loaded) viewer.toggleFile(fileIndex); }}> @@ -256,7 +263,6 @@ export function App({ onHover={focus => viewer.setHover(focus ? { file: row.fileIndex, ...focus } : null)} onFold={(id, recursive) => viewer.setFold(row.fileIndex, id, "toggle", recursive)} read={viewer.isViewed(row.fileIndex) === true} - litOf={(side) => viewer.highlight(row.fileIndex, row.key, side)} />, ); } @@ -271,7 +277,7 @@ export function App({ [`Wrap: ${wrap ? "on" : "off"} w`, () => viewer.toggleWrap()], ["Toggle context gaps c", () => { viewer.toggleContext(); setSelection(null); }], ["Fold all zM", () => viewer.foldAll(true)], ["Unfold all zR", () => viewer.foldAll(false)]], - Navigate: [["Previous change [", () => viewer.navigateHunk(-1)], ["Next change ]", () => viewer.navigateHunk(1)], + Navigate: [["Previous change [c", () => viewer.navigateHunk(-1)], ["Next change ]c", () => viewer.navigateHunk(1)], ["First file Home", () => viewer.scrollTo(0)], ["Last file End", () => viewer.scrollTo(maxScroll)]], Theme: [[`Dark (${themes.dark.name}) t`, () => viewer.setTheme(themes.dark)], [`Light (${themes.light.name}) t`, () => viewer.setTheme(themes.light)]], Help: [["Scroll: j/k · h/l · gg/G", () => setMessage("j/k scroll · h/l pan · gg first · G last")], @@ -279,7 +285,11 @@ export function App({ ["Full page: Ctrl-F / Ctrl-B", () => setMessage("Ctrl-F: page down · Ctrl-B: page up")], ["Drag to select · y to copy", () => setMessage("Drag source lines; y copies original source")], ["Change breakdown i", () => setShowBreakdown(true)], - ["Folds: click ▾ · za zo zc · zM zR", () => setMessage("Click the chevron or ⋯ · za toggle, zo open, zc close the top fold (zA zO zC recursive) · zM/zR fold/unfold all · zj/zk next/previous fold")]], + ["Folds: click ▾ · za zo zc · zM zR", () => setMessage("Click the chevron or ⋯ · za toggle, zo open, zc close the top fold (zA zO zC recursive) · zM/zR fold/unfold all · zj/zk next/previous fold")], + ["Search: / · n N", () => setMessage("/ searches paths and code · n/N next/previous match")], + ["Go to a file Ctrl-P / ⌘P", () => viewer.press({ key: "p", ctrl: true })], + ["Mark the file viewed V", () => viewer.toggleViewedFile(currentFile)], + ["Copy for an agent Y", copyForAgent]], }; return ( ))} } - - {fit(found && "prompt" in found - ? `/${found.prompt}▏ · ${found.count} matches · ⏎ go · esc cancel` - : `${searched}${snapshot.loaded}/${inventory.length} files · ${progress.viewed}/${progress.total} viewed ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} [/] hunks · / search · za fold · V viewed · i breakdown · drag selects lines · y copy · Y for agent · q quit ${message}`, - width, - )} - + {found && "prompt" in found + ? {fit(`/${found.prompt}▏ · ${found.count} matches · ⏎ go · esc cancel`, width)} + : (() => { + // The hints give way first, cut with an ellipsis; ? keys stays at the right end and opens the Help menu. + const room = Math.max(1, width - measureTextWidth(KEYS_BUTTON)); + const text = `${message ? `${message} · ` : ""}${searched}${snapshot.loaded}/${inventory.length} files · ${progress.viewed}/${progress.total} viewed ${snapshot.complete ? "" : "loading…"} ${snapshot.errors.length ? `${snapshot.errors.length} errors` : ""} h/l pan · / search · ctrl-p files · ⌘B tree · V viewed · drag selects lines · y/Y copy · q quit`; + return + {measureTextWidth(text) > room ? `${fit(text, room - 1)}…` : text} + setMenu((m) => (m === "Help" ? null : "Help"))}>{KEYS_BUTTON} + ; + })()} ); } diff --git a/diffr/packages/tui/src/ui/Interaction.test.tsx b/diffr/packages/tui/src/ui/Interaction.test.tsx index 8490b6036..b06709d63 100644 --- a/diffr/packages/tui/src/ui/Interaction.test.tsx +++ b/diffr/packages/tui/src/ui/Interaction.test.tsx @@ -184,3 +184,26 @@ test("V or the header's box marks a file viewed: it closes, and its header, the expect(frame()).toContain('send("new")'); } finally { await act(async () => { t.renderer.destroy(); }); } }); +test("a kitty-protocol terminal names the base key of a shifted symbol, but the TUI acts on what was typed", async () => { + const store = new DiffStore(), file = createTestDiffFile(); + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:100, height:20, kittyKeyboard: true}); + const frame = () => t.captureCharFrame(); + const status = () => frame().split("\n").at(-2) ?? ""; + // CSI key:shifted;modifiers u, as kitty sends shift+/ for ? and shift+9 for (. + const send = async (sequence: string) => { await act(async () => { t.renderer.stdin.emit("data", Buffer.from(sequence)); await t.renderOnce(); }); }; + try { + await act(async () => { await t.renderOnce(); }); + await send("\x1b[47:63;2u"); + await t.waitFor(() => frame().includes("Go to a file Ctrl-P")); + expect(status()).not.toContain("/▏"); + await send("\x1b[27u"); + await send("\x1b[47u"); + await send("\x1b[57:40;2u"); + await t.waitFor(() => status().includes("/(▏")); + // ⌘P, which kitty sends as p with the super modifier (8, so 9), opens the file picker. + await send("\x1b[27u"); + await send("\x1b[112;9u"); + await t.waitFor(() => frame().includes("changed files")); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx index c9c4c7747..352bbf04b 100644 --- a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -3,7 +3,7 @@ import { memo } from "react"; import { StyledText, TextAttributes, parseColor, type MouseEvent } from "@opentui/core"; import type { RenderSpan, SplitLineCell, UnifiedLineCell } from "@diffr/viewer/document/rows"; import type { Geometry, MeasuredRow } from "@diffr/viewer/viewport/geometry"; -import { planCell, targetAt, type Lit, type PaintRun, type ScopeFocus } from "@diffr/viewer/viewport/cell"; +import { planCell, targetAt, type PaintRun, type ScopeFocus } from "@diffr/viewer/viewport/cell"; import type { Palette } from "@diffr/viewer/theme/palette"; const colors = new Map>(); function color(value: string) { @@ -30,7 +30,6 @@ export const CodeRowView = memo(function CodeRowView({ focus, onHover, read, - litOf, }: { measured: MeasuredRow; visualLine: number; @@ -43,8 +42,6 @@ export const CodeRowView = memo(function CodeRowView({ onHover: (focus: ScopeFocus | undefined) => void; onFold: (id: number, recursive: boolean) => void; read: boolean; - /** The search pattern to light on one side of the row. */ - litOf: (side: "left" | "right") => Lit | undefined; }) { const row = measured.row; function cell( @@ -54,7 +51,7 @@ export const CodeRowView = memo(function CodeRowView({ side: "left" | "right", unified = false, ) { - const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side, read, search: litOf(side) }); + const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side, read }); const column = (event: MouseEvent) => event.x - (event.currentTarget?.x ?? 0); // Pressing a fold target doesn't start a selection. const fold = (event: MouseEvent) => targetAt(plan.hits, column(event)); diff --git a/diffr/packages/viewer/src/document/rows.ts b/diffr/packages/viewer/src/document/rows.ts index 0d801b6f8..7118e34f4 100644 --- a/diffr/packages/viewer/src/document/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -14,6 +14,7 @@ export interface RenderSpan { brace?: number; fg?: string; bg?: string; + lit?: boolean; } export interface SplitLineCell { diff --git a/diffr/packages/viewer/src/document/search.ts b/diffr/packages/viewer/src/document/search.ts index db763fe8c..42cce5dd0 100644 --- a/diffr/packages/viewer/src/document/search.ts +++ b/diffr/packages/viewer/src/document/search.ts @@ -5,6 +5,7 @@ export interface Match { key: string; side: "left" | "right"; line?: number; + nth: number; } /** Vim's smartcase: a capital in the pattern makes it match case. */ diff --git a/diffr/packages/viewer/src/pick.test.ts b/diffr/packages/viewer/src/pick.test.ts index 71af06a4a..95ba5bcbd 100644 --- a/diffr/packages/viewer/src/pick.test.ts +++ b/diffr/packages/viewer/src/pick.test.ts @@ -60,3 +60,30 @@ test("viewed files sink below the rest of the matches", () => { press({ key: "p", ctrl: true }, "s"); expect(viewer.pickerState()!.picks.at(-1)!.path).toBe("src/model/store.ts"); }); + +test("⌘P opens the picker too, where the terminal passes Cmd through", () => { + const { viewer, press } = open(files()); + press({ key: "p", meta: true }); + expect(viewer.picking).toBe(true); +}); + +test("]c and [c go to the next and previous change, as in vim; ] or [ with anything else does nothing", () => { + const changed = ["a.ts", "b.ts", "c.ts", "d.ts"].map((path) => { + const file = createTestDiffFile(); + file.file.lhs!.path = file.file.rhs!.path = path; + return file; + }); + const { viewer, press, topFile } = open(changed); + press("]", "c"); + const first = topFile(); + expect(first.hunkStart).toBe(true); + press("]", "c"); + const second = topFile(); + expect(second.hunkStart).toBe(true); + expect(second.key).not.toBe(first.key); + press("[", "c"); + expect(topFile().key).toBe(first.key); + press("]", "j"); + expect(topFile().key).toBe(first.key); + expect(viewer.chording).toBe(false); +}); diff --git a/diffr/packages/viewer/src/search.test.ts b/diffr/packages/viewer/src/search.test.ts index a9dccaf46..2a1e31d97 100644 --- a/diffr/packages/viewer/src/search.test.ts +++ b/diffr/packages/viewer/src/search.test.ts @@ -30,17 +30,31 @@ test("/ opens a prompt that takes every key, counts as it goes, and Enter lands expect(viewer.prompting).toBe(false); expect(viewer.searchState()).toEqual({ pattern: "keep", at: 1, total: 2, files: 1 }); expect(top().left?.lineNumber).toBe(7); - expect(viewer.highlight(0, top().key, "left")).toEqual({ pattern: "keep", current: true }); - expect(viewer.highlight(0, top().key, "right")).toEqual({ pattern: "keep", current: false }); + const litBg = (spans: { text: string; bg?: string }[]) => spans.filter((span) => span.text === "keep").map((span) => span.bg); + expect(litBg(top().left!.spans)).toEqual([dark.searchCurrent]); + expect(litBg(top().right!.spans)).toEqual([dark.searchMatch]); +}); + +test("of several matches on one line, only the one the view is on is solid, in a different hue from the rest", () => { + const { viewer, type, top } = open(); + expect(dark.searchCurrent).not.toBe(dark.searchMatch); + type("/", "e", "v", "e", "n", "t", "return"); + type("/", "e", "return"); + const line = () => top().left!.spans; + while (!line().map((span) => span.text).join("").includes("if event.open")) type("n"); + const lit = () => line().filter((span) => span.lit).map((span) => span.bg); + expect(lit()).toEqual([dark.searchCurrent, dark.searchMatch, dark.searchMatch]); + type("n"); + expect(lit()).toEqual([dark.searchMatch, dark.searchCurrent, dark.searchMatch]); }); test("n opens the fold hiding the next match, and N walks back, wrapping at either end", () => { const { viewer, type, top, shown } = open(); - expect(shown().some((row) => row.right?.spans.some((span) => span.text.includes("two();")))).toBe(false); + expect(shown().some((row) => row.right?.spans.map((span) => span.text).join("").includes("two();"))).toBe(false); type("/", "t", "w", "o", "(", "return"); expect(viewer.searchState()).toMatchObject({ at: 1, total: 2 }); expect(top().right?.lineNumber).toBe(12); - expect(shown().some((row) => row.right?.spans.some((span) => span.text.includes("two();")))).toBe(true); + expect(shown().some((row) => row.right?.spans.map((span) => span.text).join("").includes("two();"))).toBe(true); type("n"); expect(viewer.searchState()).toMatchObject({ at: 2 }); type("n"); diff --git a/diffr/packages/viewer/src/theme/palette.ts b/diffr/packages/viewer/src/theme/palette.ts index 2829bba0d..f9043e1ef 100644 --- a/diffr/packages/viewer/src/theme/palette.ts +++ b/diffr/packages/viewer/src/theme/palette.ts @@ -29,9 +29,10 @@ export interface Palette { /** A viewed line's tints: halfway back to the background, so read code recedes. */ readAddition: string; readDeletion: string; - /** Every match of the last search, a soft wash; the one the view is on, solid. */ + /** Every match of the last search, a wash of the accent; the one the view is on, solid in another hue. */ searchMatch: string; searchCurrent: string; + searchCurrentText: string; addWord: string; deleteWord: string; addedText: string; @@ -148,8 +149,9 @@ export function paletteFromHelix(theme: HelixTheme): Palette { modification: mix(bg, delta, 0.12), readAddition: mix(bg, plus, 0.06), readDeletion: mix(bg, minus, 0.06), - searchMatch: mix(bg, delta, 0.3), - searchCurrent: delta, + searchMatch: mix(bg, accent, 0.4), + searchCurrent: scopeFg(theme, "warning") ?? delta, + searchCurrentText: isLight ? fg : bg, addWord: mix(bg, plus, 0.28), deleteWord: mix(bg, minus, 0.28), addedText: plus, diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts index 98c2e2ccf..aad198128 100644 --- a/diffr/packages/viewer/src/viewer.ts +++ b/diffr/packages/viewer/src/viewer.ts @@ -2,7 +2,8 @@ import { buildFileTree, flattenFileTree } from "./document/fileTree"; import { defaultCollapsed, foldIds, gapIds, hidingIds, nestedIds, sourceLines } from "./document/regions"; import { occurrences, type Match } from "./document/search"; import { rankFiles, type Pick } from "./document/pick"; -import { placeholderRows, rowsForFile, type Layout, type SplitLineCell, type UnifiedLineCell, type ViewerRow } from "./document/rows"; +import { placeholderRows, rowsForFile, type Layout, type RenderSpan, type SplitLineCell, type UnifiedLineCell, type ViewerRow } from "./document/rows"; +import { lightMatches, type Lit } from "./viewport/cell"; import type { DiffStore, Snapshot } from "./protocol/store"; import { filePath, fileVisibility, type DiffFile, type TextDiff } from "./protocol/wire"; import { measureRows, positionAt, positionTop, rowFold, visibleRows, type Geometry, type MeasuredRow, type ViewPosition } from "./viewport/geometry"; @@ -64,7 +65,7 @@ export class Viewer { private readonly closed = new Map(); private readonly collapsed = new Map>(); private pendingZ = false; - /** Files marked viewed, for the viewer's lifetime only. */ + private pendingBracket: "]" | "[" | null = null; private readonly viewed = new Set(); private prompt: string | null = null; private search: { pattern: string; matches: Match[]; at: number; snapshot: Snapshot } | null = null; @@ -108,9 +109,8 @@ export class Viewer { return this.store.getSnapshot(); } - /** A `z` was pressed: the next key names a fold command. */ get chording(): boolean { - return this.pendingZ; + return this.pendingZ || this.pendingBracket !== null; } isClosed(index: number, file: DiffFile) { @@ -168,9 +168,11 @@ export class Viewer { const snapshot = this.snapshot; const contentWidth = size.columns, viewportHeight = size.rows; const layout = this.mode === "auto" ? (contentWidth >= this.splitColumns ? "split" : "unified") : this.mode; - const key = `${this.revision}:${layout}:${contentWidth}:${this.wrap}:${this.horizontal}`; + // Search lights whole lines before wrap and pan cut them, so its colours travel with the text. + const lit = this.lit(); + const key = `${this.revision}:${layout}:${contentWidth}:${this.wrap}:${this.horizontal}:${lit ? JSON.stringify(lit) : ""}`; if (this.measured?.key !== key || this.measured.snapshot !== snapshot) { - const rows = this.documentRows(snapshot, layout); + const rows = lit ? this.lightRows(this.documentRows(snapshot, layout), lit) : this.documentRows(snapshot, layout); const maxLine = Math.max(1, ...snapshot.files.flatMap((file) => file?.diff.type === "text" ? [file.diff.lhs, file.diff.rhs].map((source) => source ? sourceLines(source.text).length : 0) : [])); this.measured = { key, snapshot, rows, geometry: measureRows(rows, contentWidth, this.wrap, this.horizontal, maxLine) }; @@ -314,12 +316,36 @@ export class Viewer { return { pattern, at: at + 1, total: matches.length, files: new Set(matches.map((match) => match.fileIndex)).size }; } - /** The pattern to light on one side of a row: the one being typed, else the last search's; `current` on the match the view is on. */ - highlight(fileIndex: number, key: string, side: "left" | "right"): { pattern: string; current: boolean } | undefined { + private lit(): { pattern: string; on?: Match } | undefined { const pattern = this.prompt ?? this.search?.pattern; if (!pattern) return undefined; - const on = this.prompt === null && this.search ? this.search.matches[this.search.at] : undefined; - return { pattern, current: !!on && on.fileIndex === fileIndex && on.key === key && on.side === side }; + return { pattern, on: this.prompt === null && this.search ? this.search.matches[this.search.at] : undefined }; + } + + private litFor(lit: { pattern: string; on?: Match }, fileIndex: number, key: string, side: "left" | "right"): Lit { + const { on } = lit; + return { pattern: lit.pattern, current: on && on.fileIndex === fileIndex && on.key === key && on.side === side ? on.nth : undefined }; + } + + headerLit(fileIndex: number): Lit | undefined { + const lit = this.lit(); + return lit && this.litFor(lit, fileIndex, `${fileIndex}:header`, "right"); + } + + private lightRows(rows: ViewerRow[], lit: { pattern: string; on?: Match }): ViewerRow[] { + const light = (cell: Cell | undefined, fileIndex: number, key: string, side: "left" | "right") => { + if (!cell) return cell; + const spans = lightMatches(cell.spans, this.litFor(lit, fileIndex, key, side), this.theme); + return spans === cell.spans ? cell : { ...cell, spans }; + }; + return rows.map((row) => { + if (row.cell) { + const cell = light(row.cell, row.fileIndex, row.key, row.cell.newLineNumber === undefined ? "left" : "right"); + return cell === row.cell ? row : { ...row, cell }; + } + const left = light(row.left, row.fileIndex, row.key, "left"), right = light(row.right, row.fileIndex, row.key, "right"); + return left === row.left && right === row.right ? row : { ...row, left, right }; + }); } private allOpenRows(index: number, file: DiffFile, layout: Layout): ViewerRow[] { @@ -336,8 +362,8 @@ export class Viewer { const layout = this.current().layout; const matches: Match[] = []; for (const index of this.fileOrder(this.snapshot)) { - for (const _ of occurrences(filePath(inventory[index]!.file), pattern)) - matches.push({ fileIndex: index, key: `${index}:header`, side: "right" }); + occurrences(filePath(inventory[index]!.file), pattern).forEach((_, nth) => + matches.push({ fileIndex: index, key: `${index}:header`, side: "right", nth })); const file = files[index]; if (!file) continue; for (const row of this.allOpenRows(index, file, layout)) { @@ -346,8 +372,8 @@ export class Viewer { : [["left", row.left, row.left?.lineNumber], ["right", row.right, row.right?.lineNumber]]; for (const [side, cell, line] of cells) { if (!cell || line === undefined) continue; - for (const _ of occurrences(cell.spans.map((span) => span.text).join(""), pattern)) - matches.push({ fileIndex: index, key: row.key, side, line }); + occurrences(cell.spans.map((span) => span.text).join(""), pattern).forEach((_, nth) => + matches.push({ fileIndex: index, key: row.key, side, line, nth })); } } } @@ -388,7 +414,7 @@ export class Viewer { const on = search.matches[search.at]; search.matches = this.findMatches(search.pattern); search.snapshot = this.snapshot; - search.at = on ? search.matches.findIndex((m) => m.fileIndex === on.fileIndex && m.key === on.key && m.side === on.side) : -1; + search.at = on ? search.matches.findIndex((m) => m.fileIndex === on.fileIndex && m.key === on.key && m.side === on.side && m.nth === on.nth) : -1; } const total = search.matches.length; if (!total) return this.emit(); @@ -540,16 +566,24 @@ export class Viewer { if (!key.ctrl && !key.meta && name.length === 1 && "aocAOCRMjk".includes(name)) this.foldCommand(name); return true; } + if (this.pendingBracket) { + const bracket = this.pendingBracket; + this.pendingBracket = null; + if (!key.ctrl && !key.meta && name === "c") this.navigateHunk(bracket === "]" ? 1 : -1); + return true; + } + // ⌘P, as in VS Code, where the terminal passes Cmd through; Ctrl-P everywhere else. + if ((key.meta || key.ctrl) && name === "p") { + this.picker = { query: "", cursor: 0 }; + this.emit(); + return true; + } if (key.meta) return false; if (key.ctrl) { if (name === "d") this.move(half); else if (name === "u") this.move(-half); else if (name === "f") this.move(page); else if (name === "b") this.move(-page); - else if (name === "p") { - this.picker = { query: "", cursor: 0 }; - this.emit(); - } else return false; return true; } @@ -567,8 +601,7 @@ export class Viewer { case "L": this.pan(16); break; case "left": case "h": this.pan(key.shift ? -16 : -4); break; case "H": this.pan(-16); break; - case "]": this.navigateHunk(1); break; - case "[": this.navigateHunk(-1); break; + case "]": case "[": this.pendingBracket = name; break; case "s": this.toggleLayout(); break; case "w": this.toggleWrap(); break; case "c": this.toggleContext(); break; diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts index b2f718c0a..833bf8c78 100644 --- a/diffr/packages/viewer/src/viewport/cell.ts +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -37,17 +37,15 @@ export interface CellOptions { focus?: ScopeFocus; selected?: boolean; read?: boolean; - /** The pattern to light, and whether this cell holds the match the view is on. */ - search?: Lit; } export interface Lit { pattern: string; - current: boolean; + current?: number; } -/** Splits spans so the pattern's occurrences take the search colours: a wash for every match, solid where the view is. */ -function lightMatches(spans: RenderSpan[], lit: Lit, theme: Palette): RenderSpan[] { +/** Lit spans skip the viewed-line fade. */ +export function lightMatches(spans: RenderSpan[], lit: Lit, theme: Palette): RenderSpan[] { const ranges = occurrences(spans.map((span) => span.text).join(""), lit.pattern); if (!ranges.length) return spans; const result: RenderSpan[] = []; @@ -55,12 +53,12 @@ function lightMatches(spans: RenderSpan[], lit: Lit, theme: Palette): RenderSpan for (const span of spans) { const end = offset + span.text.length; let cut = offset; - for (const [from, to] of ranges) { + for (const [nth, [from, to]] of ranges.entries()) { if (to <= cut || from >= end) continue; - const start = Math.max(from, cut), stop = Math.min(to, end); + const start = Math.max(from, cut), stop = Math.min(to, end), current = nth === lit.current; if (start > cut) result.push({ ...span, text: span.text.slice(cut - offset, start - offset) }); - result.push({ ...span, text: span.text.slice(start - offset, stop - offset), - bg: lit.current ? theme.searchCurrent : theme.searchMatch, fg: lit.current ? theme.bg : span.fg }); + result.push({ ...span, text: span.text.slice(start - offset, stop - offset), lit: true, + bg: current ? theme.searchCurrent : theme.searchMatch, fg: current ? theme.searchCurrentText : span.fg }); cut = stop; } if (cut < end) result.push({ ...span, text: span.text.slice(cut - offset) }); @@ -89,7 +87,7 @@ export function planCell( spans: RenderSpan[], width: number, unified: boolean, - { theme, geometry, visualLine, focus, selected = false, read = false, search }: CellOptions, + { theme, geometry, visualLine, focus, selected = false, read = false }: CellOptions, ): CellPlan { const fold = value.fold; const washed = focus?.armed && value.body?.includes(focus.id); @@ -98,9 +96,8 @@ export function planCell( : value.kind === "addition" ? (read ? theme.readAddition : theme.addition) : value.kind === "deletion" ? (read ? theme.readDeletion : theme.deletion) : washed ? theme.focusWash : theme.bg; - // A viewed line keeps its shape but drops its colours: muted ink, no word emphasis. - if (read && changed) spans = spans.map((span) => ({ ...span, fg: theme.muted, bg: undefined })); - if (search) spans = lightMatches(spans, search, theme); + // A viewed line keeps its shape but drops its colours: muted ink, no word emphasis. Search matches keep theirs. + if (read && changed) spans = spans.map((span) => (span.lit ? span : { ...span, fg: theme.muted, bg: undefined })); // Row colours carry addition and deletion, so the gutter holds numbers and the chevron only. const digits = geometry.gutter - 4; const number = (n: number | undefined) => `${visualLine ? "" : (n ?? "")}`.padStart(digits); From b671a8b204f1ea86279a20c1d4d3f056962d95e5 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:34 -0700 Subject: [PATCH 19/25] Close search, the picker and other open views when the viewer loses focus (AI-assisted) Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/viewer/src/viewer.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts index aad198128..0ca6138ea 100644 --- a/diffr/packages/viewer/src/viewer.ts +++ b/diffr/packages/viewer/src/viewer.ts @@ -260,7 +260,15 @@ export class Viewer { this.reshape(); } - /** The Ctrl-P picker is open: every key goes to it. */ + cancel() { + if (this.prompt === null && !this.picker && !this.pendingZ && !this.pendingBracket) return; + this.prompt = null; + this.picker = null; + this.pendingZ = false; + this.pendingBracket = null; + this.emit(); + } + get picking(): boolean { return this.picker !== null; } From 106a740a0202a8e37fcc2109ae26fc5973749995 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:37 -0700 Subject: [PATCH 20/25] Lay out files of a million lines without overflowing the stack (AI-assisted) Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/viewer/src/document/rows.test.ts | 13 +++++++++++++ diffr/packages/viewer/src/document/rows.ts | 3 ++- diffr/packages/viewer/src/viewer.ts | 2 +- 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/diffr/packages/viewer/src/document/rows.test.ts b/diffr/packages/viewer/src/document/rows.test.ts index 5b35e2be7..fab83bf75 100644 --- a/diffr/packages/viewer/src/document/rows.test.ts +++ b/diffr/packages/viewer/src/document/rows.test.ts @@ -221,3 +221,16 @@ test("binary and one-sided files render without a second side", () => { const rows = rowsForFile(added, 0, "split", dark).filter((r) => r.left); expect(rows.map((r) => [r.left!.kind, r.right!.lineNumber])).toEqual([["empty", 1]]); }); + +test("a file of a million added lines lays out, as a vendored parser does", () => { + // Past the stack's limit on arguments, were the rows ever spread into one call. + const count = 1_000_000; + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("fixture is not a text diff"); + file.diff.lhs = { text: "", syntax: [], root: root([]) }; + file.diff.rhs = { text: Array.from({ length: count }, (_, i) => `line ${i}`).join("\n"), syntax: [], + root: root([leaf(1, 0, count, Array.from({ length: count }, (_, i) => line(i, 0, 4)))], 1001) }; + for (const layout of ["unified", "split"] as const) + expect(rowsForFile(file, 0, layout, dark).filter((r) => r.cell?.kind === "addition" || r.right?.kind === "addition")).toHaveLength(count); + // A million rows in each of two layouts takes seconds. +}, 30_000); diff --git a/diffr/packages/viewer/src/document/rows.ts b/diffr/packages/viewer/src/document/rows.ts index 7118e34f4..6f3a16e9e 100644 --- a/diffr/packages/viewer/src/document/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -285,7 +285,8 @@ export function rowsForFile( }; let pendingOld: ViewerRow[] = [], pendingNew: ViewerRow[] = []; const flush = () => { - rows.push(...pendingOld, ...pendingNew); + for (const row of pendingOld) rows.push(row); + for (const row of pendingNew) rows.push(row); pendingOld = []; pendingNew = []; }; diff --git a/diffr/packages/viewer/src/viewer.ts b/diffr/packages/viewer/src/viewer.ts index 0ca6138ea..9203ecd22 100644 --- a/diffr/packages/viewer/src/viewer.ts +++ b/diffr/packages/viewer/src/viewer.ts @@ -156,7 +156,7 @@ export class Viewer { const last = all.at(-1); if (last && !last.key.endsWith(":header") && last.label !== "") all.push({ key: `${last.fileIndex}:end`, fileIndex: last.fileIndex, label: "" }); - all.push(...fileRows); + for (const row of fileRows) all.push(row); } for (const [i, error] of snapshot.errors.entries()) all.push({ key: `error:${i}`, fileIndex: -1, label: error }); From 8f9f4ed80986836a26b2a253129a548136eb457b Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:41 -0700 Subject: [PATCH 21/25] Show a selection bar with one action (AI-assisted) Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 21 ++++- .../packages/tui/src/ui/Interaction.test.tsx | 26 ++++++- .../viewer/src/document/selection.test.ts | 9 ++- .../packages/viewer/src/document/selection.ts | 78 ++++++++++++++----- diffr/packages/viewer/src/theme/palette.ts | 3 + .../packages/viewer/src/theme/themes.test.ts | 8 ++ diffr/packages/viewer/src/viewport/cell.ts | 2 +- diffr/packages/viewer/src/viewport/picker.ts | 2 +- 8 files changed, 125 insertions(+), 24 deletions(-) diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index 7123c95eb..7c776671b 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -20,6 +20,8 @@ import type { ThemeSet } from "../diffr/theme"; import { agentReference, copySelection, + selectedRanges, + selectionLead, selectionBounds, type SourceSelection, } from "@diffr/viewer/document/selection"; @@ -34,6 +36,8 @@ import { litRuns, viewedBox, viewedHint } from "@diffr/viewer/viewport/cell"; import { pickerLines } from "@diffr/viewer/viewport/picker"; /** Pinned to the status line's right end, so cut hints never cut the way to the key list. */ const KEYS_BUTTON = " ? keys "; +/** On the selection bar, which takes the status line while lines are selected. */ +const AGENT_BUTTON = " Y Copy for agent "; const fit = (text: string, width: number) => sliceTextByWidth(text, 0, width).text; /** Kitty reports the base key; use the typed sequence for printables. */ @@ -101,6 +105,7 @@ export function App({ setMessage("Copied source lines"); } }; + const selected = useMemo(() => (selection ? selectedRanges(files, rows, selection) : []), [files, rows, selection]); const copyForAgent = () => { if (!selection) return; if (!snapshot.comparison) throw new Error("A selection exists before diffr named the comparison"); @@ -484,7 +489,21 @@ export function App({ ))} } {found && "prompt" in found - ? {fit(`/${found.prompt}▏ · ${found.count} matches · ⏎ go · esc cancel`, width)} + ? {fit(`/${found.prompt}▏ · ${found.count} matches · enter go · esc cancel`, width)} + : selected.length ? (() => { + // The selection bar, bright so it catches the eye: what is selected, then the button that copies it for an agent. + const lead = fit(selectionLead(message, selected, width - measureTextWidth(AGENT_BUTTON)), + Math.max(0, width - measureTextWidth(AGENT_BUTTON))); + const room = width - measureTextWidth(lead) - measureTextWidth(AGENT_BUTTON); + const hints = " esc clear"; + return + {lead} + {AGENT_BUTTON} + {room > 0 && + {measureTextWidth(hints) > room ? `${fit(hints, room - 1)}…` : hints}} + ; + })() : (() => { // The hints give way first, cut with an ellipsis; ? keys stays at the right end and opens the Help menu. const room = Math.max(1, width - measureTextWidth(KEYS_BUTTON)); diff --git a/diffr/packages/tui/src/ui/Interaction.test.tsx b/diffr/packages/tui/src/ui/Interaction.test.tsx index b06709d63..bd087d7d4 100644 --- a/diffr/packages/tui/src/ui/Interaction.test.tsx +++ b/diffr/packages/tui/src/ui/Interaction.test.tsx @@ -1,4 +1,4 @@ -import {expect, test} from "bun:test"; +import {expect, spyOn, test} from "bun:test"; import {act} from "react"; import {rgbToHex} from "@opentui/core"; import {testRender} from "@opentui/react/test-utils"; @@ -207,3 +207,27 @@ test("a kitty-protocol terminal names the base key of a shifted symbol, but the await t.waitFor(() => frame().includes("changed files")); } finally { await act(async () => { t.renderer.destroy(); }); } }); +test("while lines are selected the status line is a bright bar naming them; its button copies them for an agent, Escape clears it", async () => { + const store = new DiffStore(), file = createTestDiffFile(); + store.accept(startFor([file])); store.accept(file); store.accept({type:"complete", succeeded:1, failed:0}); + const t = await testRender( {}} />, {width:150, height:20}); + const status = () => t.captureCharFrame().split("\n").at(-2) ?? ""; + try { + await act(async () => { await t.renderOnce(); }); + await t.waitForFrame((frame) => frame.includes('send("old")')); + expect(status()).not.toContain("Copy for agent"); + await act(async () => { await t.mockMouse.drag(40, 3, 40, 6); }); + await t.waitFor(() => status().includes("Copy for agent")); + expect(status()).toStartWith(" demo.ts:1-3 · 3 lines Y Copy for agent esc clear"); + const y = t.captureCharFrame().split("\n").length - 2; + const clipboard = spyOn(t.renderer, "copyToClipboardOSC52").mockReturnValue(true); + await act(async () => { await t.mockMouse.click(status().indexOf("Copy for agent"), y); await t.renderOnce(); }); + expect(clipboard).toHaveBeenCalledTimes(1); + expect(clipboard.mock.calls[0]![0]).toStartWith("demo.ts:1-3 in the git index (staged)\n```ts\nstart();"); + clipboard.mockRestore(); + await t.waitFor(() => status().trim().startsWith("Copied for agent · demo.ts:1-3")); + // A lone escape is only recognised once the parser's escape-sequence timeout passes. + await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); + await t.waitFor(() => !status().includes("Copy for agent")); + } finally { await act(async () => { t.renderer.destroy(); }); } +}); diff --git a/diffr/packages/viewer/src/document/selection.test.ts b/diffr/packages/viewer/src/document/selection.test.ts index 9477e579c..ab003216f 100644 --- a/diffr/packages/viewer/src/document/selection.test.ts +++ b/diffr/packages/viewer/src/document/selection.test.ts @@ -1,7 +1,7 @@ import { expect, test } from "vitest"; import { createTestDiffFile } from "../protocol/fixture"; import { rowsForFile } from "./rows"; -import { agentReference, type SourceSelection } from "./selection"; +import { agentReference, type SourceSelection, selectionLead } from "./selection"; import { dark } from "../theme/themes"; import type { DiffFile } from "../protocol/wire"; @@ -48,3 +48,10 @@ test("the fence outgrows any run of backticks in the code", () => { "demo.ts:3\n````ts\n```\n````", ); }); + +test("the selection bar names the range with its path, or just its file's name when the path won't fit", () => { + const range = { fileIndex: 0, path: "diffr/packages/claude-code/plugin/hooks/diffr.test.ts", side: "right" as const, start: 187, end: 193 }; + expect(selectionLead("", [range], 80)).toBe(" diffr/packages/claude-code/plugin/hooks/diffr.test.ts:187-193 · 7 lines "); + expect(selectionLead("", [range], 40)).toBe(" diffr.test.ts:187-193 · 7 lines "); + expect(selectionLead("Copied for agent", [range, { ...range, fileIndex: 1, start: 1, end: 1 }], 80)).toBe(" Copied for agent · 2 files · 8 lines "); +}); diff --git a/diffr/packages/viewer/src/document/selection.ts b/diffr/packages/viewer/src/document/selection.ts index 6aae26cd5..532fe4d33 100644 --- a/diffr/packages/viewer/src/document/selection.ts +++ b/diffr/packages/viewer/src/document/selection.ts @@ -4,6 +4,7 @@ import type { ViewerRow } from "./rows"; import type { Snapshot } from "../protocol/store"; import { snapshotLabel } from "./counts"; import { sourceLines } from "./regions"; +import { measureTextWidth } from "../terminal/text"; export interface SourceSelection { anchor: string; end: string; @@ -37,18 +38,23 @@ function whereToFind(version: Version): string { throw new Error("The empty tree has no source to copy"); } } +/** One file's part of a selection: its lines on the selected side, 1-based, from `start` to `end`. */ +export interface SelectedRange { + fileIndex: number; + path: string; + side: SourceSelection["side"]; + start: number; + end: number; +} /** - * The selection as a reference an agent can read: for each file, its path and line range and, - * unless it is the file on disk, the version it comes from, then those source lines fenced. The - * range runs from the first selected line to the last, folded lines included, so the code always - * matches the range it claims. + * Each file's part of the selection, from its first selected line to its last, folded lines + * included, so the code always matches the range it claims. */ -export function agentReference( +export function selectedRanges( files: (DiffFile | undefined)[], - comparison: NonNullable, rows: ViewerRow[], selection: SourceSelection, -): string { +): SelectedRange[] { const [a, b] = selectionBounds(rows, selection); const ranges = new Map(); for (const row of rows.slice(a < 0 ? rows.length : a, b + 1)) { @@ -57,20 +63,54 @@ export function agentReference( const range = ranges.get(row.fileIndex); ranges.set(row.fileIndex, range ? [Math.min(range[0], n), Math.max(range[1], n)] : [n, n]); } - const left = selection.side === "left"; return [...ranges].map(([fileIndex, [start, end]]) => { const file = files[fileIndex]; - const source = file?.diff.type === "text" ? (left ? file.diff.lhs : file.diff.rhs) : undefined; - const path = left ? file?.file.lhs?.path : file?.file.rhs?.path; - if (!source || path === undefined) throw new Error(`File ${fileIndex} has no ${selection.side} source to copy`); - const code = sourceLines(source.text).slice(start - 1, end).join("\n"); - // Longer than any run of backticks in the code, so the code can't close it. - const fence = "`".repeat(Math.max(3, ...[...code.matchAll(/`+/g)].map((run) => run[0].length + 1))); - const language = /\.([^./]+)$/.exec(path)?.[1] ?? ""; - const lines = start === end ? `${start}` : `${start}-${end}`; - const where = whereToFind(left ? comparison.lhs : comparison.rhs); - return `${path}:${lines}${where}\n${fence}${language}\n${code}\n${fence}`; - }).join("\n\n"); + const path = selection.side === "left" ? file?.file.lhs?.path : file?.file.rhs?.path; + if (path === undefined) throw new Error(`File ${fileIndex} has no ${selection.side} side to select`); + return { fileIndex, path, side: selection.side, start, end }; + }); +} +/** `src/cli.rs:14-19`, as a range is named in prose. */ +export function rangeName(range: SelectedRange): string { + return `${range.path}:${range.start === range.end ? range.start : `${range.start}-${range.end}`}`; +} +/** A path too long for `width` gives way to its file name. */ +export function selectionLead(message: string, ranges: SelectedRange[], width: number): string { + const count = ranges.reduce((sum, range) => sum + range.end - range.start + 1, 0); + const lead = (what: string) => ` ${message ? `${message} · ` : ""}${what} · ${count} ${count === 1 ? "line" : "lines"} `; + if (ranges.length !== 1) return lead(`${ranges.length} files`); + const [range] = ranges as [SelectedRange]; + const whole = lead(rangeName(range)); + return measureTextWidth(whole) <= width ? whole : lead(rangeName({ ...range, path: range.path.split("/").at(-1)! })); +} +/** + * A range as a reference an agent can read: its name and, unless it is the file on disk, the + * version it comes from, then those source lines fenced. + */ +export function rangeReference( + files: (DiffFile | undefined)[], + comparison: NonNullable, + range: SelectedRange, +): string { + const file = files[range.fileIndex]; + const left = range.side === "left"; + const source = file?.diff.type === "text" ? (left ? file.diff.lhs : file.diff.rhs) : undefined; + if (!source) throw new Error(`File ${range.fileIndex} has no ${range.side} source to copy`); + const code = sourceLines(source.text).slice(range.start - 1, range.end).join("\n"); + // Longer than any run of backticks in the code, so the code can't close it. + let fence = "```"; + for (const run of code.matchAll(/`+/g)) if (run[0].length >= fence.length) fence = "`".repeat(run[0].length + 1); + const language = /\.([^./]+)$/.exec(range.path)?.[1] ?? ""; + const where = whereToFind(left ? comparison.lhs : comparison.rhs); + return `${rangeName(range)}${where}\n${fence}${language}\n${code}\n${fence}`; +} +export function agentReference( + files: (DiffFile | undefined)[], + comparison: NonNullable, + rows: ViewerRow[], + selection: SourceSelection, +): string { + return selectedRanges(files, rows, selection).map((range) => rangeReference(files, comparison, range)).join("\n\n"); } export function copySelection( files: (DiffFile | undefined)[], diff --git a/diffr/packages/viewer/src/theme/palette.ts b/diffr/packages/viewer/src/theme/palette.ts index f9043e1ef..24fca259a 100644 --- a/diffr/packages/viewer/src/theme/palette.ts +++ b/diffr/packages/viewer/src/theme/palette.ts @@ -23,6 +23,8 @@ export interface Palette { fileHeaderDir: string; /** Sidebar highlight for the active file. */ highlight: string; + /** Selected lines: blue, so a selection reads apart from additions and deletions. */ + selection: string; addition: string; deletion: string; modification: string; @@ -144,6 +146,7 @@ export function paletteFromHelix(theme: HelixTheme): Palette { fileHeader, fileHeaderDir: mix(fg, fileHeader, 0.3), highlight: selection, + selection: mix(bg, ansi["light-blue"], 0.35), addition: mix(bg, plus, 0.12), deletion: mix(bg, minus, 0.12), modification: mix(bg, delta, 0.12), diff --git a/diffr/packages/viewer/src/theme/themes.test.ts b/diffr/packages/viewer/src/theme/themes.test.ts index f3dd5e4e5..cee628ec9 100644 --- a/diffr/packages/viewer/src/theme/themes.test.ts +++ b/diffr/packages/viewer/src/theme/themes.test.ts @@ -73,3 +73,11 @@ test("in every bundled theme a file header stands apart from code and folds, and expect(contrast(palette.fileHeaderDir, palette.fileHeader)).toBeGreaterThanOrEqual(2.5); } }); +test("in every bundled theme selected lines read blue, unlike any change tint", () => { + const rgb = (hex: string) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16)); + for (const name of Object.keys(bundledThemes)) { + const palette = loadBundledTheme(name); + const [r, g, b] = rgb(palette.selection); + expect(b - Math.max(r, g)).toBeGreaterThanOrEqual(0x10); + } +}); diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts index 833bf8c78..74dc251d9 100644 --- a/diffr/packages/viewer/src/viewport/cell.ts +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -92,7 +92,7 @@ export function planCell( const fold = value.fold; const washed = focus?.armed && value.body?.includes(focus.id); const changed = value.kind === "addition" || value.kind === "deletion"; - const bg = selected ? theme.highlight + const bg = selected ? theme.selection : value.kind === "addition" ? (read ? theme.readAddition : theme.addition) : value.kind === "deletion" ? (read ? theme.readDeletion : theme.deletion) : washed ? theme.focusWash : theme.bg; diff --git a/diffr/packages/viewer/src/viewport/picker.ts b/diffr/packages/viewer/src/viewport/picker.ts index 7283183f0..1db369066 100644 --- a/diffr/packages/viewer/src/viewport/picker.ts +++ b/diffr/packages/viewer/src/viewport/picker.ts @@ -71,7 +71,7 @@ export function pickerLines(state: PickerState, width: number, height: number, t runs.push({ text: " ".repeat(Math.max(0, width - used - rightWidth)), fg: rowBg, bg: rowBg }, ...right); lines.push({ runs: exactly(runs, width, rowBg), pick: pick.fileIndex }); } - const hint = `⏎ open · ↑↓ or ctrl-n/p move · ${closeHint} `; + const hint = `enter open · ↑↓ or ctrl-n/p move · ${closeHint} `; const typed: PickerRun[] = [{ text: "› ", fg: theme.accent, bg }, { text: state.query, fg: theme.fg, bg }, { text: " ", fg: theme.bg, bg: theme.fg }]; const typedWidth = typed.reduce((n, run) => n + measureTextWidth(run.text), 0); From facee413197686e40e8dc2c2dc9dae2d7a62a3ff Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:45 -0700 Subject: [PATCH 22/25] Select both sides of a diff and hand agents a Git patch (AI-assisted) A selection can span the old and new sides, as GitLab positions a comment. Agents receive it as a standard Git patch numbered from the diff's own order, so folds never change it, with the comparison's base and head. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a11ab9-c68e-7c52-916b-e1b0123e302f Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- diffr/packages/tui/src/ui/App.tsx | 20 +- .../packages/tui/src/ui/Interaction.test.tsx | 5 +- .../packages/tui/src/ui/diff/CodeRowView.tsx | 13 +- diffr/packages/viewer/src/document/regions.ts | 71 +++++ .../packages/viewer/src/document/rows.test.ts | 12 +- diffr/packages/viewer/src/document/rows.ts | 25 +- .../viewer/src/document/selection.test.ts | 209 +++++++++++-- .../packages/viewer/src/document/selection.ts | 291 ++++++++++++------ 8 files changed, 477 insertions(+), 169 deletions(-) diff --git a/diffr/packages/tui/src/ui/App.tsx b/diffr/packages/tui/src/ui/App.tsx index 7c776671b..ca578e8c3 100644 --- a/diffr/packages/tui/src/ui/App.tsx +++ b/diffr/packages/tui/src/ui/App.tsx @@ -22,7 +22,7 @@ import { copySelection, selectedRanges, selectionLead, - selectionBounds, + selectionCover, type SourceSelection, } from "@diffr/viewer/document/selection"; import { filePath } from "@diffr/viewer/protocol/wire"; @@ -134,10 +134,7 @@ export function App({ else if (key.name === "i") setShowBreakdown((v) => !v); else if (press.key === "?") setMenu((m) => (m === "Help" ? null : "Help")); }); - const [selectionStart, selectionEnd] = useMemo( - () => selectionBounds(rows, selection), - [rows, selection], - ); + const cover = useMemo(() => selectionCover(rows, selection), [rows, selection]); const indices = useMemo( () => new Map(rows.map((row, i) => [row.key, i])), [rows], @@ -251,18 +248,15 @@ export function App({ visualLine={line} geometry={geometry} theme={theme} - selectedSide={ - selection && index >= selectionStart && index <= selectionEnd - ? selection.side - : undefined - } + selectedLeft={cover(index)?.left === true} + selectedRight={cover(index)?.right === true} onSelect={(side) => { dragging.current = true; - setSelection({ anchor: row.key, end: row.key, side }); + setSelection({ anchor: row.key, anchorSide: side, end: row.key, endSide: side }); }} - onExtend={() => { + onExtend={(side) => { if (dragging.current) - setSelection((s) => (s ? { ...s, end: row.key } : s)); + setSelection((s) => (s ? { ...s, end: row.key, endSide: side } : s)); }} focus={hovered?.file === row.fileIndex && "id" in hovered ? hovered : undefined} onHover={focus => viewer.setHover(focus ? { file: row.fileIndex, ...focus } : null)} diff --git a/diffr/packages/tui/src/ui/Interaction.test.tsx b/diffr/packages/tui/src/ui/Interaction.test.tsx index bd087d7d4..4c9ae6548 100644 --- a/diffr/packages/tui/src/ui/Interaction.test.tsx +++ b/diffr/packages/tui/src/ui/Interaction.test.tsx @@ -218,14 +218,13 @@ test("while lines are selected the status line is a bright bar naming them; its expect(status()).not.toContain("Copy for agent"); await act(async () => { await t.mockMouse.drag(40, 3, 40, 6); }); await t.waitFor(() => status().includes("Copy for agent")); - expect(status()).toStartWith(" demo.ts:1-3 · 3 lines Y Copy for agent esc clear"); const y = t.captureCharFrame().split("\n").length - 2; const clipboard = spyOn(t.renderer, "copyToClipboardOSC52").mockReturnValue(true); await act(async () => { await t.mockMouse.click(status().indexOf("Copy for agent"), y); await t.renderOnce(); }); expect(clipboard).toHaveBeenCalledTimes(1); - expect(clipboard.mock.calls[0]![0]).toStartWith("demo.ts:1-3 in the git index (staged)\n```ts\nstart();"); + expect(clipboard.mock.calls[0]![0]).toContain("@@ -1,2 +1 @@\n start();\n-send(\"old\");\n"); clipboard.mockRestore(); - await t.waitFor(() => status().trim().startsWith("Copied for agent · demo.ts:1-3")); + await t.waitFor(() => status().trim().startsWith("Copied for agent · demo.ts:L1-3")); // A lone escape is only recognised once the parser's escape-sequence timeout passes. await act(async () => { t.mockInput.pressKey("ESCAPE"); await new Promise((resolve) => setTimeout(resolve, 100)); }); await t.waitFor(() => !status().includes("Copy for agent")); diff --git a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx index 352bbf04b..4fe77f91b 100644 --- a/diffr/packages/tui/src/ui/diff/CodeRowView.tsx +++ b/diffr/packages/tui/src/ui/diff/CodeRowView.tsx @@ -23,7 +23,8 @@ export const CodeRowView = memo(function CodeRowView({ visualLine, geometry, theme, - selectedSide, + selectedLeft, + selectedRight, onSelect, onExtend, onFold, @@ -35,9 +36,11 @@ export const CodeRowView = memo(function CodeRowView({ visualLine: number; geometry: Geometry; theme: Palette; - selectedSide?: "left" | "right"; + /** Which halves of the row the selection covers; a unified row is either. Booleans, so the memo holds. */ + selectedLeft: boolean; + selectedRight: boolean; onSelect: (side: "left" | "right") => void; - onExtend: () => void; + onExtend: (side: "left" | "right") => void; focus?: ScopeFocus; onHover: (focus: ScopeFocus | undefined) => void; onFold: (id: number, recursive: boolean) => void; @@ -51,7 +54,7 @@ export const CodeRowView = memo(function CodeRowView({ side: "left" | "right", unified = false, ) { - const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: selectedSide === side, read }); + const plan = planCell(value, spans, width, unified, { theme, geometry, visualLine, focus, selected: unified ? selectedLeft || selectedRight : side === "left" ? selectedLeft : selectedRight, read }); const column = (event: MouseEvent) => event.x - (event.currentTarget?.x ?? 0); // Pressing a fold target doesn't start a selection. const fold = (event: MouseEvent) => targetAt(plan.hits, column(event)); @@ -72,7 +75,7 @@ export const CodeRowView = memo(function CodeRowView({ event.stopPropagation(); onFold(id, event.modifiers.alt); }} - onMouseMove={(event) => { onExtend(); onHover(targetAt(plan.hovers, column(event))); }} + onMouseMove={(event) => { onExtend(side); onHover(targetAt(plan.hovers, column(event))); }} onMouseOut={() => onHover(undefined)} > diff --git a/diffr/packages/viewer/src/document/regions.ts b/diffr/packages/viewer/src/document/regions.ts index 44df422ec..3071fafc3 100644 --- a/diffr/packages/viewer/src/document/regions.ts +++ b/diffr/packages/viewer/src/document/regions.ts @@ -122,6 +122,77 @@ export function flatten(diff: TextDiff) { const rhs = diff.rhs ? flattenSide(diff.rhs, 1) : { leaves: [], folds: [] }; return { leaves: [lhs.leaves, rhs.leaves] as const, folds: [lhs.folds, rhs.folds] as const }; } +/** diffr sends paired leaves in the same order on both sides. */ +export function zipLeaves(leaves: readonly [Leaf[], Leaf[]], visit: (left: Leaf | null, right: Leaf | null) => void) { + const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); + let cursor = 0; + const flushRight = (until: number) => { + for (; cursor < until; cursor++) visit(null, leaves[1][cursor]!); + }; + for (const left of leaves[0]) { + const partner = rightIndex.get(left.alignmentId); + if (partner === undefined) { + visit(left, null); + continue; + } + flushRight(partner); + visit(left, leaves[1][partner]!); + cursor = partner + 1; + } + flushRight(leaves[1].length); +} +export function changedLine(leaves: readonly [Leaf[], Leaf[]]): (leaf: Leaf, line: number) => boolean { + const alignments = leaves.map(side => new Set(side.map(leaf => leaf.alignmentId))); + return (leaf, line) => leaf.changed.has(line) || !alignments[leaf.side ? 0 : 1]!.has(leaf.alignmentId); +} +export interface DiffLine { + old?: number; + new?: number; +} +export interface DiffOrder { + lines: DiffLine[]; + oldAt: Map; + newAt: Map; + oldBefore: number[]; + newBefore: number[]; +} +/** Independent of folds, so a patch numbered from it is exact whatever is folded. */ +export function diffOrder(diff: TextDiff): DiffOrder { + const { leaves } = flatten(diff); + const isChanged = changedLine(leaves); + const order: DiffOrder = { lines: [], oldAt: new Map(), newAt: new Map(), oldBefore: [], newBefore: [] }; + let pendingOld: DiffLine[] = [], pendingNew: DiffLine[] = []; + let olds = 0, news = 0; + const push = (line: DiffLine) => { + const index = order.lines.length; + order.lines.push(line); + order.oldBefore.push(olds); + order.newBefore.push(news); + if (line.old !== undefined) { order.oldAt.set(line.old, index); olds++; } + if (line.new !== undefined) { order.newAt.set(line.new, index); news++; } + }; + const flush = () => { + for (const line of pendingOld) push(line); + for (const line of pendingNew) push(line); + pendingOld = []; + pendingNew = []; + }; + zipLeaves(leaves, (left, right) => { + const leftLines = left ? left.endLine - left.startLine : 0, rightLines = right ? right.endLine - right.startLine : 0; + for (let i = 0; i < Math.max(leftLines, rightLines); i++) { + const l = i < leftLines ? left!.startLine + i : null, r = i < rightLines ? right!.startLine + i : null; + if (l !== null && r !== null && !isChanged(left!, l) && !isChanged(right!, r)) { + flush(); + push({ old: l + 1, new: r + 1 }); + continue; + } + if (l !== null) pendingOld.push({ old: l + 1 }); + if (r !== null) pendingNew.push({ new: r + 1 }); + } + }); + flush(); + return order; +} /** Fold-state ids diffr asks to start collapsed: context gaps and folded bodies, on either side. */ export function defaultCollapsed(diff: TextDiff): Set { const ids = new Set(); diff --git a/diffr/packages/viewer/src/document/rows.test.ts b/diffr/packages/viewer/src/document/rows.test.ts index fab83bf75..bdc13216a 100644 --- a/diffr/packages/viewer/src/document/rows.test.ts +++ b/diffr/packages/viewer/src/document/rows.test.ts @@ -131,14 +131,15 @@ test("wrapping adds equal split heights and windowing mounts only intersecting r ); expect(visibleRows(geometry, 0, 2).length).toBe(2); }); -test("selection copies one source side and excludes padding and added lines", () => { +test("a split selection kept to the left column copies the old source, without padding or added lines", () => { const file = createTestDiffFile(), rows = rowsForFile(file, 0, "split", dark); expect( copySelection([file], rows, { anchor: rows[1].key, + anchorSide: "left", end: rows.at(-1)!.key, - side: "left", + endSide: "left", }), ).toBe('start();\nsend("old");\nfinish();'); }); @@ -182,10 +183,9 @@ test("unified trusts diffr's changed spans despite different source indentation" expect(rows).toHaveLength(2); expect(rows[1].cell).toMatchObject({kind: "context", sign: " ", oldLineNumber: 1, newLineNumber: 1}); expect(rows[1].cell!.spans.map(s => s.text).join("")).toBe(" call();"); - for (const side of ["left", "right"] as const) { - expect(copySelection([file], rows, {anchor: rows[1].key, end: rows[1].key, side})) - .toBe(side === "left" ? " call();" : " call();"); - } + // An unchanged unified row is one line on both sides; a copy takes the new version's. + expect(copySelection([file], rows, {anchor: rows[1].key, anchorSide: "left", end: rows[1].key, endSide: "left"})) + .toBe(" call();"); // Only the side with a changed span gets word emphasis, but the old line, printed alone with // its old number, still reads as removed. file.diff.rhs.root.children = [leaf(1, 0, 1, [line(0, 0, 11)])]; diff --git a/diffr/packages/viewer/src/document/rows.ts b/diffr/packages/viewer/src/document/rows.ts index 6f3a16e9e..fa2a711ba 100644 --- a/diffr/packages/viewer/src/document/rows.ts +++ b/diffr/packages/viewer/src/document/rows.ts @@ -3,7 +3,7 @@ import type { DiffFile, Span, SyntaxSpan } from "../protocol/wire"; import { filePath } from "../protocol/wire"; import { sliceSpansWindow } from "../terminal/spans"; import { measureTextWidth } from "../terminal/text"; -import { byteColumn, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, type Fold, type FoldTint, type Leaf, type RowFold, type Side } from "./regions"; +import { byteColumn, changedLine, collapsedFolds, flatten, foldHeaders, foldTint, hiddenLines, pairedIds, sourceLines, zipLeaves, type Fold, type FoldTint, type Leaf, type RowFold, type Side } from "./regions"; import type { Palette } from "../theme/palette"; export type Layout = "split" | "unified"; export interface RenderSpan { @@ -225,9 +225,7 @@ export function rowsForFile( // fold no plugin labelled still reads `{ ⋯ 3 lines }` rather than a bare `{ ⋯ }`. const labelOf = (region: Leaf | Fold) => region.label || lineCount("lastHidden" in region ? region.lastHidden - region.startLine + 1 : region.endLine - region.startLine); - const alignments = leaves.map(side => new Set(side.map(leaf => leaf.alignmentId))); - const isChanged = (leaf: Leaf, line: number) => - leaf.changed.has(line) || !alignments[leaf.side ? 0 : 1].has(leaf.alignmentId); + const isChanged = changedLine(leaves); // A paired leaf is changed when its counterpart has change spans. const spanned = new Set(leaves.flat().filter(leaf => leaf.changed.size).map(leaf => leaf.alignmentId)); // A collapsed paired region that hides a change is modified, and counts its side's changed lines. @@ -382,24 +380,7 @@ export function rowsForFile( for (let i = 0; i < Math.max(leftLines, rightLines); i++) emit(i < leftLines ? left!.startLine + i : null, i < rightLines ? right!.startLine + i : null, left, right); }; - // Zip: walk the left leaves; a partner ahead on the right flushes what precedes it as right-only. - // diffr sends paired leaves in the same order on both sides. - const rightIndex = new Map(leaves[1].map((leaf, index) => [leaf.alignmentId, index])); - let cursor = 0; - const flushRight = (until: number) => { - for (; cursor < until; cursor++) leafRows(null, leaves[1][cursor]); - }; - for (const left of leaves[0]) { - const partner = rightIndex.get(left.alignmentId); - if (partner === undefined) { - leafRows(left, null); - continue; - } - flushRight(partner); - leafRows(left, leaves[1][partner]); - cursor = partner + 1; - } - flushRight(leaves[1].length); + zipLeaves(leaves, leafRows); flush(); return markHunks(rows); } diff --git a/diffr/packages/viewer/src/document/selection.test.ts b/diffr/packages/viewer/src/document/selection.test.ts index ab003216f..f2e7c122c 100644 --- a/diffr/packages/viewer/src/document/selection.test.ts +++ b/diffr/packages/viewer/src/document/selection.test.ts @@ -1,57 +1,202 @@ import { expect, test } from "vitest"; -import { createTestDiffFile } from "../protocol/fixture"; -import { rowsForFile } from "./rows"; -import { agentReference, type SourceSelection, selectionLead } from "./selection"; +import { spawnSync } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { createTestDiffFile, leaf, root } from "../protocol/fixture"; +import { rowsForFile, type ViewerRow } from "./rows"; +import { agentReference, copySelection, selectedRanges, selectionLead, type Side, type SourceSelection } from "./selection"; import { dark } from "../theme/themes"; import type { DiffFile } from "../protocol/wire"; const againstMain = { lhs: { type: "revision" as const, rev: "main" }, rhs: { type: "working_tree" as const } }; -/** Select from the row showing line `from` to the row showing line `to` on one side of a split. */ -function select(file: DiffFile, side: SourceSelection["side"], from: number, to: number) { - const rows = rowsForFile(file, 0, "split", dark); - const at = (line: number) => rows.find((row) => row[side]?.lineNumber === line)!.key; - return { rows, selection: { anchor: at(to), end: at(from), side } }; +/* + * The fixture, demo.ts: + * old: 1 start(); 2 send("old"); 3 finish(); + * new: 1 start(); 2 send("new"); 3 extra(); 4 finish(); + */ +type Point = [(row: ViewerRow) => boolean, Side]; +function drag(rows: ViewerRow[], from: Point, to: Point): SourceSelection { + const key = ([at]: Point) => rows.find(at)!.key; + return { anchor: key(from), anchorSide: from[1], end: key(to), endSide: to[1] }; } +const unifiedOld = (line: number) => (row: ViewerRow) => row.cell?.oldLineNumber === line && row.cell.newLineNumber === undefined; +const unifiedNew = (line: number) => (row: ViewerRow) => row.cell?.newLineNumber === line; +const left = (line: number) => (row: ViewerRow) => row.left?.lineNumber === line; +const right = (line: number) => (row: ViewerRow) => row.right?.lineNumber === line; + +test("a unified drag from a removed line to an added one takes both sides, as a Git patch with base and head sides", () => { + const file = createTestDiffFile(); + const rows = rowsForFile(file, 0, "unified", dark); + const selection = drag(rows, [unifiedOld(2), "left"], [unifiedNew(3), "right"]); + expect(agentReference([file], againstMain, rows, selection)).toBe([ + "Base: main", + "Head: working tree (uncommitted)", + "", + "diff --git a/demo.ts b/demo.ts", + "index 1111111..2222222 100644", + "--- a/demo.ts", + "+++ b/demo.ts", + "@@ -2 +2,2 @@", + '-send("old");', + '+send("new");', + "+extra();", + "", + ].join("\n")); + expect(copySelection([file], rows, selection)).toBe('send("new");\nextra();'); +}); -test("lines from the file on disk are named by path and range alone, then fenced", () => { +test("unchanged lines are both old and new; added lines alone are all R, at the old line they follow", () => { const file = createTestDiffFile(); - const { rows, selection } = select(file, "right", 2, 3); - expect(agentReference([file], againstMain, rows, selection)).toBe( - 'demo.ts:2-3\n```ts\nsend("new");\nextra();\n```', - ); + const rows = rowsForFile(file, 0, "unified", dark); + const withContext = agentReference([file], againstMain, rows, drag(rows, [unifiedNew(1), "right"], [unifiedNew(3), "right"])); + expect(withContext).toContain("@@ -1,2 +1,3 @@\n start();\n-send(\"old\");\n+send(\"new\");\n+extra();\n"); + const added = agentReference([file], againstMain, rows, drag(rows, [unifiedNew(2), "right"], [unifiedNew(3), "right"])); + expect(added).toContain("@@ -2,0 +2,2 @@\n+send(\"new\");\n+extra();\n"); }); -test("lines from a revision name the revision, so an agent can find that version", () => { +test("a split drag from the left column to the right takes everything between, removed lines first", () => { const file = createTestDiffFile(); - const { rows, selection } = select(file, "left", 2, 2); - expect(agentReference([file], againstMain, rows, selection)).toBe( - 'demo.ts:2 at main\n```ts\nsend("old");\n```', - ); + const unified = rowsForFile(file, 0, "unified", dark); + const rows = rowsForFile(file, 0, "split", dark); + expect(agentReference([file], againstMain, rows, drag(rows, [left(2), "left"], [right(3), "right"]))) + .toBe(agentReference([file], againstMain, unified, drag(unified, [unifiedOld(2), "left"], [unifiedNew(3), "right"]))); + const [range] = selectedRanges([file], rows, drag(rows, [right(1), "right"], [left(2), "left"])); + expect(range!.lines).toEqual([{ old: 1, new: 1 }, { old: 2 }]); }); -test("lines from the index say they are staged", () => { +test("a split drag kept to one column emits only that side's selected changes", () => { const file = createTestDiffFile(); - const { rows, selection } = select(file, "left", 1, 1); + const rows = rowsForFile(file, 0, "split", dark); + const added = agentReference([file], againstMain, rows, drag(rows, [right(3), "right"], [right(2), "right"])); + expect(added).toContain('@@ -2,0 +2,2 @@\n+send("new");\n+extra();\n'); + expect(added).not.toContain('-send("old");'); + const removed = agentReference([file], againstMain, rows, drag(rows, [left(2), "left"], [left(2), "left"])); + expect(removed).toContain('@@ -2 +1,0 @@\n-send("old");\n'); + expect(removed).not.toContain('+send("new");'); const staged = { lhs: { type: "index" as const }, rhs: { type: "working_tree" as const } }; - expect(agentReference([file], staged, rows, selection)).toBe( - "demo.ts:1 in the git index (staged)\n```ts\nstart();\n```", - ); + expect(agentReference([file], staged, rows, drag(rows, [left(1), "left"], [left(1), "left"]))) + .toContain("@@ -1 +1 @@\n start();\n"); +}); + +test("mutable snapshots are identified without inventing commit SHAs", () => { + const file = createTestDiffFile(); + file.file.rhs!.oid = "0".repeat(40); + const rows = rowsForFile(file, 0, "unified", dark); + const staged = { lhs: { type: "index" as const }, rhs: { type: "working_tree" as const } }; + const context = agentReference([file], staged, rows, drag(rows, [unifiedOld(2), "left"], [unifiedNew(3), "right"])); + expect(context).toContain("Base: index (staged)\nHead: working tree (uncommitted)\n\n"); + expect(context).toContain(`index ${file.file.lhs!.oid}..${file.file.rhs!.oid} 100644\n`); +}); + +test("a folded stretch inside the selection ends one hunk and starts the next", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("The fixture is a text diff"); + const text = Array.from({ length: 10 }, (_, i) => `line ${i + 1}`).join("\n"); + const regions = () => { + const gap = leaf(2, 2, 7); + gap.visibility = { collapsed: true, label: "5 unchanged lines" }; + return [leaf(1, 0, 2), gap, leaf(3, 7, 10)]; + }; + file.diff.lhs = { text, syntax: [], root: root(regions()) }; + file.diff.rhs = { text, syntax: [], root: root(regions(), 1001) }; + const rows = rowsForFile(file, 0, "unified", dark, new Set([2])); + const selection = drag(rows, [unifiedNew(1), "right"], [unifiedNew(10), "right"]); + expect(agentReference([file], againstMain, rows, selection)).toBe([ + "Base: main", + "Head: working tree (uncommitted)", + "", + "diff --git a/demo.ts b/demo.ts", + "index 1111111..2222222 100644", + "--- a/demo.ts", + "+++ b/demo.ts", + "@@ -1,2 +1,2 @@", + " line 1", + " line 2", + "@@ -8,3 +8,3 @@", + " line 8", + " line 9", + " line 10", + "\\ No newline at end of file", + "", + ].join("\n")); + expect(selectionLead("", selectedRanges([file], rows, selection), 80)).toBe(" demo.ts:R1-10 · 5 lines "); }); -test("the fence outgrows any run of backticks in the code", () => { +test("backticks in selected code remain literal patch content", () => { const file = createTestDiffFile(); if (file.diff.type !== "text" || !file.diff.rhs) throw new Error("The fixture is a text diff"); file.diff.rhs = { ...file.diff.rhs, text: 'start();\nsend("new");\n```\nfinish();\n', syntax: [] }; - const { rows, selection } = select(file, "right", 3, 3); - expect(agentReference([file], againstMain, rows, selection)).toBe( - "demo.ts:3\n````ts\n```\n````", - ); + const rows = rowsForFile(file, 0, "split", dark); + expect(agentReference([file], againstMain, rows, drag(rows, [right(3), "right"], [right(3), "right"]))) + .toContain("@@ -2,0 +3 @@\n+```\n"); }); test("the selection bar names the range with its path, or just its file's name when the path won't fit", () => { - const range = { fileIndex: 0, path: "diffr/packages/claude-code/plugin/hooks/diffr.test.ts", side: "right" as const, start: 187, end: 193 }; - expect(selectionLead("", [range], 80)).toBe(" diffr/packages/claude-code/plugin/hooks/diffr.test.ts:187-193 · 7 lines "); - expect(selectionLead("", [range], 40)).toBe(" diffr.test.ts:187-193 · 7 lines "); - expect(selectionLead("Copied for agent", [range, { ...range, fileIndex: 1, start: 1, end: 1 }], 80)).toBe(" Copied for agent · 2 files · 8 lines "); + const file = (path: string) => { + const each = createTestDiffFile(); + each.file.lhs!.path = each.file.rhs!.path = path; + return each; + }; + const files: DiffFile[] = [file("diffr/packages/claude-code/plugin/hooks/diffr.test.ts"), file("b.ts")]; + const rows = [...rowsForFile(files[0]!, 0, "split", dark), ...rowsForFile(files[1]!, 1, "split", dark)]; + const one = selectedRanges(files, rows, drag(rows, [right(1), "right"], [right(3), "right"])); + expect(selectionLead("", one, 80)).toBe(" diffr/packages/claude-code/plugin/hooks/diffr.test.ts:R1-3 · 3 lines "); + expect(selectionLead("", one, 40)).toBe(" diffr.test.ts:R1-3 · 3 lines "); + const across = drag(rows, [right(1), "right"], [(row) => row.fileIndex === 1 && row.right?.lineNumber === 2, "right"]); + expect(selectionLead("Copied for agent", selectedRanges(files, rows, across), 80)).toBe(" Copied for agent · 2 files · 6 lines "); +}); + +test("a line diffr pairs as only reformatted is removed and added in the patch, which needs unchanged lines to match", () => { + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("The fixture is a text diff"); + file.diff.lhs = { text: " call();\n", syntax: [], root: root([leaf(1, 0, 1)]) }; + file.diff.rhs = { text: " call();\n", syntax: [], root: root([leaf(1, 0, 1)], 1001) }; + const rows = rowsForFile(file, 0, "unified", dark); + const selection = drag(rows, [unifiedNew(1), "right"], [unifiedNew(1), "right"]); + expect(agentReference([file], againstMain, rows, selection)).toContain("@@ -1 +1 @@\n- call();\n+ call();\n"); +}); + +test("a hunk's place on a side it has no lines on is counted from the whole diff, lines folded away included", () => { + // old: a, x, y new: a, c x and y are removed and folded away; c is added after them. + const file = createTestDiffFile(); + if (file.diff.type !== "text") throw new Error("The fixture is a text diff"); + const removed = leaf(2, 1, 3); + removed.visibility = { collapsed: true, label: "2 removed lines" }; + file.diff.lhs = { text: "a\nx\ny", syntax: [], root: root([leaf(1, 0, 1), removed]) }; + file.diff.rhs = { text: "a\nc", syntax: [], root: root([leaf(1, 0, 1), leaf(3, 1, 2)], 1001) }; + const rows = rowsForFile(file, 0, "unified", dark, new Set([2])); + expect(rows.some((row) => row.cell?.oldLineNumber === 2)).toBe(false); + const reference = agentReference([file], againstMain, rows, drag(rows, [unifiedNew(1), "right"], [unifiedNew(2), "right"])); + expect(reference).toContain("@@ -1 +1 @@\n a\n@@ -3,0 +2 @@\n+c\n\\ No newline at end of file\n"); + const open = rowsForFile(file, 0, "unified", dark); + expect(agentReference([file], againstMain, open, drag(open, [unifiedNew(1), "right"], [unifiedNew(2), "right"]))) + .toContain("@@ -1,3 +1,2 @@\n a\n-x\n-y\n\\ No newline at end of file\n+c\n\\ No newline at end of file\n"); +}); + +// Exercise Git's parser and application, including final-newline and quoted-path handling. +test("Git can apply the selected patch to its base and recover the head", () => { + const directory = mkdtempSync(join(tmpdir(), "diffr-patch-")); + try { + for (const ending of ["\n", ""]) { + const file = createTestDiffFile(); + if (file.diff.type !== "text" || !file.diff.lhs || !file.diff.rhs) throw new Error("Text fixture required"); + const path = 'a file"name.ts'; + file.file.lhs!.path = file.file.rhs!.path = path; + file.diff.rhs.text = file.diff.rhs.text.trimEnd() + ending; + const rows = rowsForFile(file, 0, "unified", dark); + const comparison = { lhs: { type: "revision" as const, rev: "a".repeat(40) }, rhs: { type: "revision" as const, rev: "b".repeat(40) } }; + const patch = agentReference([file], comparison, rows, drag(rows, [unifiedNew(1), "right"], [unifiedNew(4), "right"])); + expect(patch).toContain(`Base: ${comparison.lhs.rev}\nHead: ${comparison.rhs.rev}\n\n`); + expect(patch).toContain(`index ${file.file.lhs!.oid}..${file.file.rhs!.oid} 100644\n`); + writeFileSync(join(directory, path), file.diff.lhs.text); + const applied = spawnSync("git", ["apply", "--unidiff-zero", "-"], { cwd: directory, input: patch }); + expect(applied.stderr.toString()).toBe(""); + expect(applied.status).toBe(0); + expect(readFileSync(join(directory, path), "utf8")).toBe(file.diff.rhs.text); + } + } finally { + rmSync(directory, { recursive: true, force: true }); + } }); diff --git a/diffr/packages/viewer/src/document/selection.ts b/diffr/packages/viewer/src/document/selection.ts index 532fe4d33..a6f8a7890 100644 --- a/diffr/packages/viewer/src/document/selection.ts +++ b/diffr/packages/viewer/src/document/selection.ts @@ -1,14 +1,16 @@ -/** Copy original source lines from one side, excluding gutters, padding, and wrapped duplicates. */ -import type { DiffFile } from "../protocol/wire"; +/** Positioned by old/new line numbers, so a selection can mix removed, added and unchanged lines. */ +import type { DiffFile, TextDiff } from "../protocol/wire"; import type { ViewerRow } from "./rows"; import type { Snapshot } from "../protocol/store"; -import { snapshotLabel } from "./counts"; -import { sourceLines } from "./regions"; +import { diffOrder, sourceLines, type DiffLine, type DiffOrder } from "./regions"; import { measureTextWidth } from "../terminal/text"; + +export type Side = "left" | "right"; export interface SourceSelection { anchor: string; + anchorSide: Side; end: string; - side: "left" | "right"; + endSide: Side; } export function selectionBounds( rows: ViewerRow[], @@ -19,90 +21,219 @@ export function selectionBounds( b = rows.findIndex((r) => r.key === selection.end); return a < 0 || b < 0 ? [-1, -1] : [Math.min(a, b), Math.max(a, b)]; } -const lineNumber = (row: ViewerRow, side: SourceSelection["side"]) => - side === "left" - ? (row.left?.lineNumber ?? row.cell?.oldLineNumber) - : (row.right?.lineNumber ?? row.cell?.newLineNumber); -type Version = NonNullable["lhs"]; -/** Where an agent finds this version of a file: the file on disk needs no words, the rest are named. */ -function whereToFind(version: Version): string { - switch (version.type) { - case "working_tree": - return ""; - case "index": - return " in the git index (staged)"; - case "revision": - case "path": - return ` at ${snapshotLabel(version)}`; - case "empty_tree": - throw new Error("The empty tree has no source to copy"); - } +interface Cover { + left: boolean; + right: boolean; +} +/** In split, a one-column drag takes that column; a crossing drag takes everything between its ends. */ +export function selectionCover(rows: ViewerRow[], selection: SourceSelection | null): (index: number) => Cover | undefined { + const [a, b] = selectionBounds(rows, selection); + if (!selection || a < 0) return () => undefined; + const anchor = rows.findIndex((r) => r.key === selection.anchor), end = rows.findIndex((r) => r.key === selection.end); + const position = (index: number, side: Side) => index * 2 + (side === "right" ? 1 : 0); + const ends = [position(anchor, selection.anchorSide), position(end, selection.endSide)]; + const [first, last] = [Math.min(...ends), Math.max(...ends)]; + const oneColumn = selection.anchorSide === selection.endSide ? selection.anchorSide : undefined; + return (index) => { + if (index < a || index > b) return undefined; + if (rows[index]!.cell) return { left: true, right: true }; + if (oneColumn) return { left: oneColumn === "left", right: oneColumn === "right" }; + return { left: position(index, "left") >= first && position(index, "left") <= last, + right: position(index, "right") >= first && position(index, "right") <= last }; + }; } -/** One file's part of a selection: its lines on the selected side, 1-based, from `start` to `end`. */ -export interface SelectedRange { + +export type { DiffLine }; +interface SelectedRange { fileIndex: number; - path: string; - side: SourceSelection["side"]; - start: number; - end: number; + oldPath?: string; + newPath?: string; + start: DiffLine; + end: DiffLine; + /** Set when a split drag stays in one column: that version's lines, folded ones included. */ + side?: "old" | "new"; + lines: DiffLine[]; } -/** - * Each file's part of the selection, from its first selected line to its last, folded lines - * included, so the code always matches the range it claims. - */ +/** Each diff's order, worked out once: the selection bar asks for it every frame. */ +const orders = new WeakMap(); +function orderOf(diff: TextDiff): DiffOrder { + let order = orders.get(diff); + if (!order) { + order = diffOrder(diff); + orders.set(diff, order); + } + return order; +} +const textDiff = (files: (DiffFile | undefined)[], fileIndex: number): TextDiff => { + const diff = files[fileIndex]?.diff; + if (diff?.type !== "text") throw new Error(`File ${fileIndex} has no text to select`); + return diff; +}; +/** An unchanged line counts once, whichever half the drag covered. */ export function selectedRanges( files: (DiffFile | undefined)[], rows: ViewerRow[], selection: SourceSelection, ): SelectedRange[] { const [a, b] = selectionBounds(rows, selection); - const ranges = new Map(); - for (const row of rows.slice(a < 0 ? rows.length : a, b + 1)) { - const n = lineNumber(row, selection.side); - if (n === undefined) continue; - const range = ranges.get(row.fileIndex); - ranges.set(row.fileIndex, range ? [Math.min(range[0], n), Math.max(range[1], n)] : [n, n]); + if (a < 0) return []; + const cover = selectionCover(rows, selection); + const oneColumn = selection.anchorSide === selection.endSide && !rows[a]!.cell + ? (selection.anchorSide === "left" ? "old" : "new") : undefined; + const touched = new Map; new: Set }>(); + for (let index = a; index <= b; index++) { + const row = rows[index]!, covered = cover(index); + if (!covered) continue; + const old = row.cell ? row.cell.oldLineNumber : covered.left ? row.left?.lineNumber : undefined; + const neu = row.cell ? row.cell.newLineNumber : covered.right ? row.right?.lineNumber : undefined; + if (old === undefined && neu === undefined) continue; + let numbers = touched.get(row.fileIndex); + if (!numbers) touched.set(row.fileIndex, numbers = { old: new Set(), new: new Set() }); + if (old !== undefined) numbers.old.add(old); + if (neu !== undefined) numbers.new.add(neu); } - return [...ranges].map(([fileIndex, [start, end]]) => { + const ranges: SelectedRange[] = []; + for (const [fileIndex, numbers] of touched) { + let lines: DiffLine[]; + if (oneColumn) lines = [...numbers[oneColumn]].sort((x, y) => x - y).map((n) => (oneColumn === "old" ? { old: n } : { new: n })); + else { + const order = orderOf(textDiff(files, fileIndex)); + const at = (map: Map, n: number) => { + const index = map.get(n); + if (index === undefined) throw new Error(`Line ${n} of file ${fileIndex} is missing from its diff's order`); + return index; + }; + const indices = new Set([...[...numbers.old].map((n) => at(order.oldAt, n)), ...[...numbers.new].map((n) => at(order.newAt, n))]); + lines = [...indices].sort((x, y) => x - y).map((index) => order.lines[index]!); + } + if (!lines.length) continue; const file = files[fileIndex]; - const path = selection.side === "left" ? file?.file.lhs?.path : file?.file.rhs?.path; - if (path === undefined) throw new Error(`File ${fileIndex} has no ${selection.side} side to select`); - return { fileIndex, path, side: selection.side, start, end }; - }); + ranges.push({ fileIndex, oldPath: file?.file.lhs?.path, newPath: file?.file.rhs?.path, + start: lines[0]!, end: lines.at(-1)!, side: oneColumn, lines }); + } + return ranges; +} + +function span(range: SelectedRange, side: "old" | "new"): [number, number] | undefined { + const numbers = range.lines.flatMap((line) => line[side] ?? []); + return numbers.length ? [numbers[0]!, numbers.at(-1)!] : undefined; } -/** `src/cli.rs:14-19`, as a range is named in prose. */ +const removed = (line: DiffLine) => line.new === undefined; +const added = (line: DiffLine) => line.old === undefined; + +/** + * `src/a.rs:L13-R15`, as GitHub names a diff range: L for old line numbers, R for new, an + * unchanged line counting as R. A range with no removed lines is all R, one with no added lines all L. + */ export function rangeName(range: SelectedRange): string { - return `${range.path}:${range.start === range.end ? range.start : `${range.start}-${range.end}`}`; + const both = !range.side && range.lines.some(removed) && range.lines.some(added); + const side = range.side ?? (both ? undefined : range.lines.some(removed) ? "old" : "new"); + const path = (side === "old" ? range.oldPath : range.newPath ?? range.oldPath)!; + if (side) { + const [first, last] = span(range, side)!; + const letter = side === "old" ? "L" : "R"; + return `${path}:${letter}${first}${first === last ? "" : `-${last}`}`; + } + return `${path}:L${span(range, "old")![0]}-R${span(range, "new")![1]}`; +} +function lineCount(range: SelectedRange): number { + if (!range.side) return range.lines.length; + const [first, last] = span(range, range.side)!; + return last - first + 1; } /** A path too long for `width` gives way to its file name. */ export function selectionLead(message: string, ranges: SelectedRange[], width: number): string { - const count = ranges.reduce((sum, range) => sum + range.end - range.start + 1, 0); + const count = ranges.reduce((sum, range) => sum + lineCount(range), 0); const lead = (what: string) => ` ${message ? `${message} · ` : ""}${what} · ${count} ${count === 1 ? "line" : "lines"} `; if (ranges.length !== 1) return lead(`${ranges.length} files`); - const [range] = ranges as [SelectedRange]; - const whole = lead(rangeName(range)); - return measureTextWidth(whole) <= width ? whole : lead(rangeName({ ...range, path: range.path.split("/").at(-1)! })); + const name = rangeName(ranges[0]!); + const whole = lead(name); + if (measureTextWidth(whole) <= width) return whole; + const colon = name.lastIndexOf(":"); + return lead(`${name.slice(0, colon).split("/").at(-1)}${name.slice(colon)}`); +} + +function sourcesOf(files: (DiffFile | undefined)[], range: SelectedRange) { + const diff = files[range.fileIndex]?.diff; + if (diff?.type !== "text") throw new Error(`File ${range.fileIndex} has no text to copy`); + return { old: diff.lhs ? sourceLines(diff.lhs.text) : [], new: diff.rhs ? sourceLines(diff.rhs.text) : [], + oldTerminated: diff.lhs?.text.endsWith("\n") ?? true, newTerminated: diff.rhs?.text.endsWith("\n") ?? true }; +} +/** Lines left out between selected ones, as by a fold, start a new hunk. */ +function patch(range: SelectedRange, order: DiffOrder, sources: ReturnType): string { + const oldPath = `a/${range.oldPath ?? range.newPath}`; + const newPath = `b/${range.newPath ?? range.oldPath}`; + const quote = (path: string) => /["\\\t\r\n]/.test(path) ? JSON.stringify(path) : path; + const out = [`diff --git ${quote(oldPath)} ${quote(newPath)}`, `--- ${range.oldPath === undefined ? "/dev/null" : quote(oldPath)}`, + `+++ ${range.newPath === undefined ? "/dev/null" : quote(newPath)}`]; + const indexOf = (line: DiffLine) => (line.old !== undefined ? order.oldAt.get(line.old) : order.newAt.get(line.new!))!; + const hunks: number[][] = []; + // A one-column selection includes folded lines within that version's selected span. + const indices = range.side + ? (() => { + const [first, last] = span(range, range.side)!; + const at = range.side === "old" ? order.oldAt : order.newAt; + return Array.from({ length: last - first + 1 }, (_, i) => at.get(first + i)!); + })() + : range.lines.map(indexOf); + for (const index of indices) { + const hunk = hunks.at(-1); + if (hunk && hunk.at(-1) === index - 1) hunk.push(index); + else hunks.push([index]); + } + for (const hunk of hunks) { + const lines = hunk.map((index) => order.lines[index]!); + // As git writes it: a count of 1 left out, and a side with no lines at the line before them. + const side = (key: "old" | "new") => { + const numbers = lines.flatMap((line) => line[key] ?? []); + const start = numbers[0] ?? (key === "old" ? order.oldBefore : order.newBefore)[hunk[0]!]!; + return numbers.length === 1 ? `${start}` : `${start},${numbers.length}`; + }; + out.push(`@@ -${side("old")} +${side("new")} @@`); + for (const line of lines) { + const [before, after] = [line.old === undefined ? undefined : sources.old[line.old - 1]!, + line.new === undefined ? undefined : sources.new[line.new - 1]!]; + const emit = (prefix: string, text: string, side: "old" | "new") => { + out.push(`${prefix}${text}`); + if (line[side] === sources[side].length && !sources[`${side}Terminated`]) out.push("\\ No newline at end of file"); + }; + // A paired line can still differ in formatting or its final newline. + const sameEnding = (line.old === sources.old.length && !sources.oldTerminated) + === (line.new === sources.new.length && !sources.newTerminated); + if (before !== undefined && before === after && sameEnding) emit(" ", after, "new"); + else { + if (before !== undefined) emit("-", before, "old"); + if (after !== undefined) emit("+", after, "new"); + } + } + } + return out.join("\n") + "\n"; +} +function snapshotIdentity(snapshot: NonNullable["lhs"]): string { + switch (snapshot.type) { + case "revision": return snapshot.rev; + case "index": return "index (staged)"; + case "working_tree": return "working tree (uncommitted)"; + case "path": return `path ${JSON.stringify(snapshot.path)}`; + case "empty_tree": return "empty tree"; + } } -/** - * A range as a reference an agent can read: its name and, unless it is the file on disk, the - * version it comes from, then those source lines fenced. - */ export function rangeReference( files: (DiffFile | undefined)[], comparison: NonNullable, range: SelectedRange, ): string { - const file = files[range.fileIndex]; - const left = range.side === "left"; - const source = file?.diff.type === "text" ? (left ? file.diff.lhs : file.diff.rhs) : undefined; - if (!source) throw new Error(`File ${range.fileIndex} has no ${range.side} source to copy`); - const code = sourceLines(source.text).slice(range.start - 1, range.end).join("\n"); - // Longer than any run of backticks in the code, so the code can't close it. - let fence = "```"; - for (const run of code.matchAll(/`+/g)) if (run[0].length >= fence.length) fence = "`".repeat(run[0].length + 1); - const language = /\.([^./]+)$/.exec(range.path)?.[1] ?? ""; - const where = whereToFind(left ? comparison.lhs : comparison.rhs); - return `${rangeName(range)}${where}\n${fence}${language}\n${code}\n${fence}`; + const body = patch(range, orderOf(textDiff(files, range.fileIndex)), sourcesOf(files, range)); + const { lhs, rhs } = files[range.fileIndex]!.file; + // Blob IDs; null means unstored working-tree content. + const oid = /^[0-9a-f]+$/i; + const old = lhs?.oid ?? "0".repeat(rhs?.oid.length ?? 40); + const neu = rhs?.oid ?? "0".repeat(lhs?.oid.length ?? 40); + const mode = lhs?.mode === rhs?.mode ? ` ${lhs!.mode}` : ""; + const headers = oid.test(old) && oid.test(neu) ? `index ${old}..${neu}${mode}\n` : ""; + const firstLine = body.indexOf("\n") + 1; + return `Base: ${snapshotIdentity(comparison.lhs)}\nHead: ${snapshotIdentity(comparison.rhs)}\n\n` + + body.slice(0, firstLine) + headers + body.slice(firstLine); } export function agentReference( files: (DiffFile | undefined)[], @@ -110,33 +241,17 @@ export function agentReference( rows: ViewerRow[], selection: SourceSelection, ): string { - return selectedRanges(files, rows, selection).map((range) => rangeReference(files, comparison, range)).join("\n\n"); + return selectedRanges(files, rows, selection).map((range) => rangeReference(files, comparison, range)).join("\n"); } +/** The new version's lines, or the old's when none are new. */ export function copySelection( files: (DiffFile | undefined)[], rows: ViewerRow[], selection: SourceSelection, ): string { - const [a, b] = selectionBounds(rows, selection), - seen = new Set(), - result: string[] = []; - const sources = new Map(); - for (const row of rows.slice(a < 0 ? rows.length : a, b + 1)) { - const n = lineNumber(row, selection.side); - if (n === undefined) continue; - const key = `${row.fileIndex}:${n}`; - if (seen.has(key)) continue; - seen.add(key); - const diff = files[row.fileIndex]?.diff; - if (!diff || diff.type !== "text") continue; - const source = selection.side === "left" ? diff.lhs : diff.rhs; - if (!source) continue; - let lines = sources.get(row.fileIndex); - if (!lines) { - lines = source.text.split("\n"); - sources.set(row.fileIndex, lines); - } - result.push(lines[n - 1]); - } - return result.join("\n"); + return selectedRanges(files, rows, selection).flatMap((range) => { + const sources = sourcesOf(files, range); + const side = range.lines.some((line) => line.new !== undefined) ? "new" : "old"; + return range.lines.flatMap((line) => line[side] === undefined ? [] : [sources[side][line[side]! - 1]!]); + }).join("\n"); } From e95df6e1daeb9f7f0b0c0e1e7b6050c48cda93f1 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:40:49 -0700 Subject: [PATCH 23/25] Widen fold rail and caret hit areas with matching hover feedback (AI-assisted) Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: cbc75bbf-5438-46f4-b8ed-41beb0e20f0c Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- .../packages/viewer/src/viewport/cell.test.ts | 39 +++++++++++++++++++ diffr/packages/viewer/src/viewport/cell.ts | 13 ++++++- 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 diffr/packages/viewer/src/viewport/cell.test.ts diff --git a/diffr/packages/viewer/src/viewport/cell.test.ts b/diffr/packages/viewer/src/viewport/cell.test.ts new file mode 100644 index 000000000..dea7e095c --- /dev/null +++ b/diffr/packages/viewer/src/viewport/cell.test.ts @@ -0,0 +1,39 @@ +import { expect, test } from "vitest"; +import { createGuideDiffFile } from "../protocol/fixture"; +import { rowsForFile } from "../document/rows"; +import { dark } from "../theme/themes"; +import { measureRows } from "./geometry"; +import { planCell, targetAt, type ScopeFocus } from "./cell"; + +test.each(["split", "unified"] as const)("%s fold targets include neighboring blanks and share their hover bounds", layout => { + const rows = rowsForFile(createGuideDiffFile(), 0, layout, dark, new Set()); + const geometry = measureRows(rows, 180, false, 0, 20); + const at = (line: number, focus?: ScopeFocus) => { + const value = rows.find(row => layout === "unified" ? row.cell?.newLineNumber === line : row.right?.lineNumber === line)!; + const cell = (layout === "unified" ? value.cell : value.right)!; + return planCell(cell, cell.spans, 80, layout === "unified", { theme: dark, geometry, visualLine: 0, focus }); + }; + const opener = at(3); + const caret = opener.runs.map(run => run.text).join("").indexOf("▾"); + const fold = targetAt(opener.hits, caret)!; + for (const x of [caret - 1, caret, caret + 1]) { + expect(targetAt(opener.hits, x)).toBe(fold); + expect(targetAt(opener.hovers, x)).toEqual({ id: fold, armed: true }); + } + const body = at(4); + const text = body.runs.map(run => run.text).join(""); + const rail = text.lastIndexOf("│"); + for (const x of [rail - 1, rail, rail + 1]) { + expect(targetAt(body.hits, x)).toBe(fold); + const focus = targetAt(body.hovers, x)!; + expect(focus).toEqual({ id: fold, armed: true }); + expect(at(4, focus).runs.map(run => run.text).join("")[rail]).toBe("┃"); + } + // Padding never steals the code or the actual rail of an outer scope. + const firstCode = text.search(/[A-Za-z]/); + expect(firstCode).toBeGreaterThan(rail); + expect(targetAt(body.hits, firstCode)).toBeUndefined(); + const outer = text.indexOf("│"); + expect(targetAt(body.hits, outer)).not.toBe(fold); + expect(targetAt(body.hovers, outer)?.id).toBe(targetAt(body.hits, outer)); +}); diff --git a/diffr/packages/viewer/src/viewport/cell.ts b/diffr/packages/viewer/src/viewport/cell.ts index 74dc251d9..cc1a66468 100644 --- a/diffr/packages/viewer/src/viewport/cell.ts +++ b/diffr/packages/viewer/src/viewport/cell.ts @@ -1,6 +1,6 @@ import type { Geometry } from "./geometry"; import { foldBackground, type RenderSpan, type SplitLineCell, type UnifiedLineCell } from "../document/rows"; -import { measureTextWidth } from "../terminal/text"; +import { measureTextWidth, sliceTextByWidth } from "../terminal/text"; import type { RowFold } from "../document/regions"; import { occurrences } from "../document/search"; import type { Palette } from "../theme/palette"; @@ -147,6 +147,17 @@ export function planCell( column += cells; } if (column < width) runs.push({ text: " ".repeat(width - column), fg: theme.fg, bg }); + // A terminal glyph is a narrow target. Include the blank cell beside either + // edge of a rail or chevron, without taking source text or line numbers away + // from selection. Exact targets win when neighboring scopes are close. + const text = runs.map(run => run.text).join(""); + const padding = hits.map(([from, to, id]): Target => [ + from > 0 && sliceTextByWidth(text, from - 1, 1).text === " " ? from - 1 : from, + to < width && sliceTextByWidth(text, to, 1).text === " " ? to + 1 : to, + id, + ]); + hits.push(...padding); + hovers.push(...padding.map(([from, to, id]): Target => [from, to, { id, armed: true }])); // A collapsed fold's row, or a line of its label, opens it. const opens = fold?.collapsed ? fold.id : value.labelOf; if (opens !== undefined) { From 8a74216aa188341ef89827487f338301f4c6e9e7 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Thu, 8 Oct 2026 18:41:07 -0700 Subject: [PATCH 24/25] Test the TUI and @diffr/viewer in CI (AI-assisted) One job installs the pnpm workspace once, then typechecks and tests @diffr/viewer and the TUI. Agent-Session: fb96350b-c19e-48f0-b364-31e9ad2b72f4 Agent-Session: a7af3b88-3bb7-48a2-ae20-7626a83628a1 Agent-Session: 01a1196d-bad4-7f61-bafc-f63d12217676 Agent-Session: 7d7ac281-cc4b-4437-9e1e-3872651e8bcf Agent-Session: 01a11aca-5007-7b80-ad35-c0a0f746a05d Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: 01a11cf1-8ad4-7910-b120-b0d622692a95 Agent-Session: 01a11ded-d896-7251-bf8f-c71bf07fd283 Agent-Session: 01a11e03-81df-7d63-bfa5-953f535444d2 Agent-Session: 01a11e0a-d042-71d2-9bc9-ac7b652487d1 Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Claude-Session: https://claude.ai/code/session_01HNbERuYJjvvAjBN19zEDbo Agent-Session: 01a11ecf-aa86-7a81-a5e6-c5dfe72d48eb Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 --- .github/workflows/diffr-test.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/.github/workflows/diffr-test.yml b/.github/workflows/diffr-test.yml index 2c30e807d..9630e69e8 100644 --- a/.github/workflows/diffr-test.yml +++ b/.github/workflows/diffr-test.yml @@ -271,6 +271,33 @@ jobs: pnpm build pnpm test + typescript_packages: + name: TypeScript frontends + permissions: + contents: read + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + with: + persist-credentials: false + - uses: pnpm/action-setup@0977fd99725f1db4007ccb2928dbb4e90d06cc86 # v6 + - uses: actions/setup-node@249970729cb0ef3589644e2896645e5dc5ba9c38 # v6 + with: + node-version: 24 + cache: pnpm + - uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0 + with: + bun-version: 1.3.14 + - run: pnpm install --frozen-lockfile + - working-directory: diffr/packages/viewer + run: | + pnpm typecheck + pnpm test + - working-directory: diffr/packages/tui + run: | + pnpm typecheck + pnpm test + whiteboard_integration: name: Whiteboard integration with diffr runs-on: ubuntu-22.04 From b9b62087b37cf57f0573db480e39381b36191e47 Mon Sep 17 00:00:00 2001 From: Sidharth Menon Date: Fri, 9 Oct 2026 00:52:12 -0700 Subject: [PATCH 25/25] Leave diffr's packages to Diffr Test in Whiteboard CI (AI-assisted) Whiteboard CI typechecked and tested every workspace package, so the TUI's `bun test` failed on a runner without Bun, and the Claude Code mod's tests failed to typecheck without the Claude Code types it generates locally. Diffr Test already checks diffr's packages. Claude-Session: https://claude.ai/code/session_01HTHD79i4QdthzVCkDZ26gd Agent-Session: ec5d2c35-3045-44f3-9c41-ba4378797c8e Agent-Session: b5985b3c-e5d2-4bb4-b6ac-7fec168d8328 Agent-Session: 01a11f51-383b-7f63-a323-39195d82ab2e Agent-Session: cac10624-9b5a-46ba-b261-1e0359c62796 Agent-Session: 8881e3f3-a916-4405-9706-bb6e3a2f193d --- .github/workflows/review-desktop-ci.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/review-desktop-ci.yml b/.github/workflows/review-desktop-ci.yml index fa36304ae..d98c04268 100644 --- a/.github/workflows/review-desktop-ci.yml +++ b/.github/workflows/review-desktop-ci.yml @@ -151,15 +151,16 @@ jobs: run: pnpm --filter @dev.fast/whiteboard check:tutorial # The build already built workspace dependencies and typechecked Code OSS. + # Diffr Test checks diffr's packages, with the Bun and Claude Code types they need. - name: Typecheck run: | - pnpm -r --filter '!@dev.fast/review-desktop' --workspace-concurrency=1 --if-present typecheck + pnpm -r --filter '!@dev.fast/review-desktop' --filter '!./diffr/**' --workspace-concurrency=1 --if-present typecheck # Typecheck prepared the shared tutorial assets before concurrent pretests. - name: Test run: | node --test "scripts/**/*.test.mjs" - pnpm -r --filter '!@dev.fast/diffr' --no-sort --workspace-concurrency=3 --if-present test + pnpm -r --filter '!./diffr/**' --no-sort --workspace-concurrency=3 --if-present test - name: Upload browser test diagnostics if: failure()