diff --git a/.changeset/lucky-panthers-brake.md b/.changeset/lucky-panthers-brake.md new file mode 100644 index 00000000..a960392b --- /dev/null +++ b/.changeset/lucky-panthers-brake.md @@ -0,0 +1,7 @@ +--- +"hunkdiff": minor +--- + +Agent notes can now carry STML markup (**experimental**) — a small HTML-like markup rendered as real terminal UI inside the inline note card (bordered boxes, rows of shapes, gauges, lists, badges, code blocks, styled text). Provide it via the `markup` field on agent-context sidecar annotations, `hunk session comment add --markup`, or a `markup` field on `comment apply` batch items; the plain `summary` stays as the fallback and list view text. While the feature is experimental, the tag and color vocabulary may change between releases without a major bump. + +Two new commands make markup easy to author well: `hunk markup guide` prints a pattern-driven authoring guide (gauges, pipelines, scorecards, checklists), and `hunk markup render ( | -)` previews markup as terminal text at any width without launching the TUI, with render notes on stderr or in `--json` output. Markup feedback follows the live session geometry: `hunk session context` reports `noteMarkupWidth` (the width notes render at in the current layout and terminal size), and `comment add`/`apply` responses echo the `markupWidth` they validated at plus `markupNotes` when the markup degraded — so agents design for the width the user is actually looking at, whether that is a narrow split dock or a full-width unified pane on a large screen. diff --git a/AGENTS.md b/AGENTS.md index b56f33d6..f1f4f8be 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -91,6 +91,7 @@ CLI input - Agent context belongs beside the code, not hidden in a separate mode or workflow. - Agent notes are hunk-specific: show notes for the selected hunk, render them in the diff flow near the annotated row, and keep a clear spatial relationship to the code they explain. - Keep note behavior explicit. If the UI intentionally prioritizes one note, one selection, or one active target, encode that as a named policy rather than scattering array-index assumptions through the codebase. +- STML markup notes (experimental) live in `src/ui/lib/stml/`. The layout engine is deliberately a deterministic line layout, not OpenTUI flexbox: the row-windowed review stream needs exact note heights before mount, so `(markup, width)` must always produce the same lines. Colors stay symbolic until render time so measurement never needs a theme. Do not "simplify" this into flexbox renderables, and keep note-card geometry in `agentNoteGeometry` as the single source for rendering, measurement, and agent-facing width reporting. - If you choose to use a local sidecar for temporary review context, keep it concise and review-oriented: one changeset summary, file summaries in narrative order, and a few hunk-level annotations with real rationale. - If a local sidecar is present, its file order is intentional, but the visible note UI should stay hunk-note driven rather than showing generic file or changeset explainer cards. - `hunk diff` working-tree reviews include untracked files by default. Use `--exclude-untracked` if you explicitly want tracked changes only. diff --git a/examples/9-agent-markup-notes/README.md b/examples/9-agent-markup-notes/README.md new file mode 100644 index 00000000..a594b8c0 --- /dev/null +++ b/examples/9-agent-markup-notes/README.md @@ -0,0 +1,36 @@ +# 9 — agent markup notes (STML) + +Shows agent notes that carry **STML markup** — a small HTML-like markup that +Hunk renders as real terminal UI inside the inline note card: bordered boxes, +rows of shapes, lists, badges, and code blocks instead of plain text. + +Run from the repository root: + +```sh +hunk patch examples/9-agent-markup-notes/change.patch \ + --agent-context examples/9-agent-markup-notes/agent-context.json +``` + +Press `a` to reveal the agent notes for the selected hunk. + +The same markup works for live comments from an agent driving a session: + +```sh +hunk session comment add --repo . --file src/retry.ts --new-line 3 \ + --summary "Retry flow" \ + --markup 'shapes in a note' \ + --focus +``` + +Learn and iterate from the CLI: + +```sh +hunk markup guide # authoring guide with copy-paste patterns +echo 'OK ready' | \ + hunk markup render - --width 56 # preview before publishing +``` + +Tags: block (`box`, `card`, `row`, `text`, `h1`–`h3`, `list`/`item`, `hr`, +`spacer`, `code`) and inline (`b`, `i`, `u`, `s`, `dim`, `color`, `kbd`, +`badge`, `a`, `br`). Colors accept semantic tokens (`accent`, `success`, +`warning`, `danger`, `info`, `muted`), ANSI-style names, or hex. diff --git a/examples/9-agent-markup-notes/after/retry.ts b/examples/9-agent-markup-notes/after/retry.ts new file mode 100644 index 00000000..8307a971 --- /dev/null +++ b/examples/9-agent-markup-notes/after/retry.ts @@ -0,0 +1,15 @@ +export async function fetchWithRetry(url: string, attempts = 3): Promise { + let delayMs = 100; + for (let attempt = 1; attempt <= attempts; attempt += 1) { + try { + return await fetch(url); + } catch (error) { + if (attempt === attempts) { + throw error; + } + await new Promise((resolve) => setTimeout(resolve, delayMs)); + delayMs *= 2; + } + } + throw new Error("unreachable"); +} diff --git a/examples/9-agent-markup-notes/agent-context.json b/examples/9-agent-markup-notes/agent-context.json new file mode 100644 index 00000000..d9e5eee5 --- /dev/null +++ b/examples/9-agent-markup-notes/agent-context.json @@ -0,0 +1,24 @@ +{ + "version": 1, + "summary": "Replaces the single-shot fetch with bounded exponential-backoff retries.", + "files": [ + { + "path": "src/retry.ts", + "summary": "fetchOnce becomes fetchWithRetry with doubling delays between attempts.", + "annotations": [ + { + "newRange": [1, 13], + "summary": "Adds a bounded retry loop with exponential backoff.", + "author": "sonnet", + "markup": "

Retry flow

fetchfail?backoff ×2OK caps at 3 attempts before rethrowingTODO add jitter before shipping this" + }, + { + "newRange": [10, 11], + "summary": "Backoff policy could be extracted for reuse and testing.", + "author": "sonnet", + "markup": "The doubling is inline; consider extracting the policy:const backoff = (attempt: number) =>\n 100 * 2 ** (attempt - 1);Keeps the loop readable and lets tests pin delays." + } + ] + } + ] +} diff --git a/examples/9-agent-markup-notes/before/retry.ts b/examples/9-agent-markup-notes/before/retry.ts new file mode 100644 index 00000000..609120b9 --- /dev/null +++ b/examples/9-agent-markup-notes/before/retry.ts @@ -0,0 +1,3 @@ +export async function fetchOnce(url: string): Promise { + return fetch(url); +} diff --git a/examples/9-agent-markup-notes/change.patch b/examples/9-agent-markup-notes/change.patch new file mode 100644 index 00000000..1bd91b2a --- /dev/null +++ b/examples/9-agent-markup-notes/change.patch @@ -0,0 +1,22 @@ +diff --git a/src/retry.ts b/src/retry.ts +index 609120b..8307a97 100644 +--- a/src/retry.ts ++++ b/src/retry.ts +@@ -1,3 +1,15 @@ +-export async function fetchOnce(url: string): Promise { +- return fetch(url); ++export async function fetchWithRetry(url: string, attempts = 3): Promise { ++ let delayMs = 100; ++ for (let attempt = 1; attempt <= attempts; attempt += 1) { ++ try { ++ return await fetch(url); ++ } catch (error) { ++ if (attempt === attempts) { ++ throw error; ++ } ++ await new Promise((resolve) => setTimeout(resolve, delayMs)); ++ delayMs *= 2; ++ } ++ } ++ throw new Error("unreachable"); + } diff --git a/examples/README.md b/examples/README.md index 5d1f6334..a7f2f226 100644 --- a/examples/README.md +++ b/examples/README.md @@ -16,6 +16,7 @@ Each folder tells a small review story and includes the exact command to run fro | `6-readme-screenshot` | README screenshot with agent notes | `hunk patch examples/6-readme-screenshot/change.patch --agent-context examples/6-readme-screenshot/agent-context.json --mode split --theme midnight` | | `7-opentui-component` | embedding `HunkDiffView` in OpenTUI | `bun run examples/7-opentui-component/from-files.tsx` | | `8-opentui-primitives` | composing Hunk's OpenTUI primitives | `bun run examples/8-opentui-primitives/primitives-demo.tsx` | +| `9-agent-markup-notes` | STML markup rendered inside notes | `hunk patch examples/9-agent-markup-notes/change.patch --agent-context examples/9-agent-markup-notes/agent-context.json` | ## Notes diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 67f0d30e..3ebe686f 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -96,7 +96,7 @@ hunk session reload --session-path /path/to/live-window --source /path/to/other- ### Comments ```bash -hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--author "agent"] [--focus] +hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--markup ""] [--author "agent"] [--focus] printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin [--focus] hunk session comment list --repo . [--file README.md] [--type live|all|ai|agent|user] hunk session comment rm --repo . @@ -112,6 +112,12 @@ hunk session comment clear --repo . --yes [--file README.md] - `comment list` and `comment clear` accept optional `--file` - Quote `--summary` and `--rationale` defensively in the shell +### Rich markup notes (STML) + +`--markup` (or a `markup` field on apply items) renders the note body as STML — a small HTML-like markup for terminal UI (boxes, rows, gauges, badges, lists, code). Keep `--summary` a real sentence: it is the fallback and the `comment list` text. + +Before writing markup, run `hunk markup guide` once — it has copy-paste patterns and the width rules. `hunk session context --json` reports `noteMarkupWidth` (the live render width); preview with `hunk markup render - --width `. Comment responses echo `markupWidth` and return `markupNotes` when markup degraded — fix what they flag. + ## New files in working-tree reviews `hunk diff` includes untracked files by default. If the user wants tracked changes only, reload with `--exclude-untracked`: diff --git a/src/core/agent.ts b/src/core/agent.ts index 87fabd93..517ded91 100644 --- a/src/core/agent.ts +++ b/src/core/agent.ts @@ -67,6 +67,7 @@ function normalizeAnnotationFile(file: unknown): AgentFileContext { newRange: normalizeRange(item.newRange), summary: item.summary, rationale: typeof item.rationale === "string" ? item.rationale : undefined, + markup: typeof item.markup === "string" && item.markup.length > 0 ? item.markup : undefined, tags: Array.isArray(item.tags) ? item.tags.filter((tag): tag is string => typeof tag === "string") : undefined, diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index 45bdbb41..728d20d9 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -542,6 +542,84 @@ describe("parseCli", () => { }); }); + test("parses markup render with defaults and options", async () => { + expect(await parseCli(["bun", "hunk", "markup", "render"])).toEqual({ + kind: "markup-render", + file: "-", + width: 56, + color: "auto", + theme: undefined, + json: false, + }); + + expect( + await parseCli([ + "bun", + "hunk", + "markup", + "render", + "note.stml", + "--width", + "72", + "--color", + "never", + "--theme", + "midnight", + "--json", + ]), + ).toEqual({ + kind: "markup-render", + file: "note.stml", + width: 72, + color: "never", + theme: "midnight", + json: true, + }); + }); + + test("rejects invalid markup render color modes and unknown markup subcommands", async () => { + await expect( + parseCli(["bun", "hunk", "markup", "render", "-", "--color", "sometimes"]), + ).rejects.toThrow("--color must be auto, always, or never."); + await expect(parseCli(["bun", "hunk", "markup", "bogus"])).rejects.toThrow( + "Supported markup subcommands are render and guide.", + ); + }); + + test("parses markup guide", async () => { + expect(await parseCli(["bun", "hunk", "markup", "guide"])).toEqual({ kind: "markup-guide" }); + }); + + test("parses session comment add with --markup", async () => { + const parsed = await parseCli([ + "bun", + "hunk", + "session", + "comment", + "add", + "session-1", + "--file", + "README.md", + "--new-line", + "7", + "--summary", + "Rendered note", + "--markup", + "hot path", + ]); + + expect(parsed).toMatchObject({ + kind: "session", + action: "comment-add", + selector: { sessionId: "session-1" }, + filePath: "README.md", + side: "new", + line: 7, + summary: "Rendered note", + markup: "hot path", + }); + }); + test("parses session comment add with --focus", async () => { const parsed = await parseCli([ "bun", diff --git a/src/core/cli.ts b/src/core/cli.ts index 043ed265..673fbf60 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -141,6 +141,8 @@ function renderCliHelp() { " hunk pager general Git pager wrapper with diff detection", " hunk difftool [path] review Git difftool file pairs", " hunk session inspect or control a live Hunk session", + " hunk markup render ( | -) preview STML note markup as terminal text", + " hunk markup guide print the STML authoring guide for agents", " hunk skill path print the bundled Hunk review skill path", " hunk daemon serve run the local Hunk session daemon", "", @@ -305,6 +307,7 @@ function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemIn line: oldLine ?? newLine, summary, rationale: typeof item.rationale === "string" ? item.rationale : undefined, + markup: typeof item.markup === "string" && item.markup.length > 0 ? item.markup : undefined, author: typeof item.author === "string" ? item.author : undefined, }; }); @@ -585,7 +588,13 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise

{ .option("--old-line ", "1-based line number on the old side", parsePositiveInt) .option("--new-line ", "1-based line number on the new side", parsePositiveInt) .option("--rationale ", "optional longer explanation") + .option("--markup ", "optional STML markup rendered as the note body") .option("--author ", "optional author label") .option("--focus", "add the note and focus the viewport on it") .option("--json", "emit structured JSON"); @@ -931,6 +941,7 @@ async function parseSessionCommand(tokens: string[]): Promise { oldLine?: number; newLine?: number; rationale?: string; + markup?: string; author?: string; focus?: boolean; json?: boolean; @@ -949,6 +960,7 @@ async function parseSessionCommand(tokens: string[]): Promise { oldLine?: number; newLine?: number; rationale?: string; + markup?: string; author?: string; focus?: boolean; json?: boolean; @@ -983,6 +995,7 @@ async function parseSessionCommand(tokens: string[]): Promise { line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, summary: parsedOptions.summary, rationale: parsedOptions.rationale, + markup: parsedOptions.markup, author: parsedOptions.author, reveal: parsedOptions.focus ?? false, }; @@ -1216,6 +1229,84 @@ async function parseSessionCommand(tokens: string[]): Promise { throw new Error(`Unknown session command: ${subcommand}`); } +const MARKUP_HELP = [ + "Usage:", + " hunk markup render ( | -) [--width ] [--color ] [--theme ] [--json]", + " hunk markup guide", + "", + "render preview STML markup as terminal text without launching the TUI;", + " render notes (unknown tags, layout degradations) go to stderr", + " --width layout width in columns (default 56, the note reference width)", + " --color auto (default: color when stdout is a TTY), always, or never", + " --theme hunk theme used to resolve colors (default github-dark-default)", + " --json emit { width, lines, notes } instead of text", + "guide print the STML authoring guide with copy-paste patterns", + "", +].join("\n"); + +/** Parse `hunk markup ...` for STML preview and guide commands. */ +async function parseMarkupCommand(tokens: string[]): Promise { + const [subcommand, ...rest] = tokens; + if (!subcommand || subcommand === "--help" || subcommand === "-h") { + return { kind: "help", text: MARKUP_HELP }; + } + + if (subcommand === "guide") { + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: MARKUP_HELP }; + } + if (rest.length > 0) { + throw new Error("`hunk markup guide` does not accept additional arguments."); + } + return { kind: "markup-guide" }; + } + + if (subcommand === "render") { + const command = new Command("markup render") + .description("preview STML markup as terminal text") + .argument("[file]", "markup file path, or - for stdin", "-") + .option("--width ", "layout width in columns", parsePositiveInt) + .option("--color ", "auto, always, or never", "auto") + .option("--theme ", "hunk theme used to resolve colors") + .option("--json", "emit structured JSON"); + + let parsedFile = "-"; + let parsedOptions: { width?: number; color: string; theme?: string; json?: boolean } = { + color: "auto", + }; + command.action( + ( + file: string, + options: { width?: number; color: string; theme?: string; json?: boolean }, + ) => { + parsedFile = file; + parsedOptions = options; + }, + ); + + if (rest.includes("--help") || rest.includes("-h")) { + return { kind: "help", text: `${command.helpInformation().trimEnd()}\n` }; + } + + await parseStandaloneCommand(command, rest); + + if (!["auto", "always", "never"].includes(parsedOptions.color)) { + throw new Error("--color must be auto, always, or never."); + } + + return { + kind: "markup-render", + file: parsedFile, + width: parsedOptions.width ?? 56, + color: parsedOptions.color as "auto" | "always" | "never", + theme: parsedOptions.theme, + json: parsedOptions.json ?? false, + }; + } + + throw new Error("Supported markup subcommands are render and guide."); +} + /** Parse `hunk skill ...` for bundled skill discovery commands. */ async function parseSkillCommand(tokens: string[]): Promise { const [subcommand, ...rest] = tokens; @@ -1364,6 +1455,8 @@ export async function parseCli(argv: string[]): Promise { return parseStashCommand(rest, argv); case "session": return parseSessionCommand(rest); + case "markup": + return parseMarkupCommand(rest); case "skill": return parseSkillCommand(rest); case "daemon": diff --git a/src/core/liveComments.test.ts b/src/core/liveComments.test.ts index 294af3d8..d661350a 100644 --- a/src/core/liveComments.test.ts +++ b/src/core/liveComments.test.ts @@ -76,6 +76,23 @@ describe("live comment helpers", () => { }); }); + test("carries STML markup through to the live annotation", () => { + const comment = buildLiveComment( + { + filePath: "src/example.ts", + side: "new", + line: 4, + summary: "Rendered note", + markup: "shape", + }, + "comment-2", + "2026-03-22T00:00:00.000Z", + 0, + ); + + expect(comment.markup).toBe("shape"); + }); + test("builds a live MCP comment annotation", () => { const comment = buildLiveComment( { diff --git a/src/core/liveComments.ts b/src/core/liveComments.ts index 7dcf6799..dd1ff1a3 100644 --- a/src/core/liveComments.ts +++ b/src/core/liveComments.ts @@ -10,6 +10,8 @@ export interface CommentTargetInput { line?: number; summary: string; rationale?: string; + /** Optional STML markup rendered as the note body (see src/ui/lib/stml). */ + markup?: string; author?: string; } @@ -164,6 +166,7 @@ export function buildLiveComment( line: input.line, summary: input.summary, rationale: input.rationale, + markup: input.markup, oldRange: input.side === "old" ? [input.line, input.line] : undefined, newRange: input.side === "new" ? [input.line, input.line] : undefined, tags: ["mcp"], diff --git a/src/core/startup.ts b/src/core/startup.ts index da046503..7fc83f6c 100644 --- a/src/core/startup.ts +++ b/src/core/startup.ts @@ -9,7 +9,13 @@ import { usesPipedPatchInput, type ControllingTerminal, } from "./terminal"; -import type { AppBootstrap, CliInput, ParsedCliInput, SessionCommandInput } from "./types"; +import type { + AppBootstrap, + CliInput, + MarkupRenderCommandInput, + ParsedCliInput, + SessionCommandInput, +} from "./types"; import { canReloadInput } from "./watch"; import { parseCli } from "./cli"; @@ -39,6 +45,13 @@ export type StartupPlan = options: CliInput["options"]; customTheme?: AppBootstrap["customTheme"]; } + | { + kind: "markup-render"; + input: MarkupRenderCommandInput; + } + | { + kind: "markup-guide"; + } | { kind: "app"; bootstrap: AppBootstrap; @@ -115,6 +128,19 @@ export async function prepareStartupPlan( }; } + if (parsedCliInput.kind === "markup-render") { + return { + kind: "markup-render", + input: parsedCliInput, + }; + } + + if (parsedCliInput.kind === "markup-guide") { + return { + kind: "markup-guide", + }; + } + if (parsedCliInput.kind === "pager") { const stdinText = await readStdinText(); const pagerOptions = parsedCliInput.options; diff --git a/src/core/types.ts b/src/core/types.ts index 4c6c5c95..7e1ebd54 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -19,6 +19,8 @@ export interface AgentAnnotation { newRange?: [number, number]; summary: string; rationale?: string; + /** Optional STML markup rendered as the note body in place of summary/rationale text. */ + markup?: string; tags?: string[]; confidence?: "low" | "medium" | "high"; source?: string; @@ -236,6 +238,7 @@ export interface SessionCommentAddCommandInput { line: number; summary: string; rationale?: string; + markup?: string; author?: string; reveal: boolean; } @@ -247,6 +250,7 @@ export interface SessionCommentApplyItemInput { line?: number; summary: string; rationale?: string; + markup?: string; author?: string; } @@ -349,12 +353,28 @@ export type CliInput = | PatchCommandInput | DiffToolCommandInput; +export interface MarkupRenderCommandInput { + kind: "markup-render"; + /** Markup source path, or "-" for stdin. */ + file: string; + width: number; + color: "auto" | "always" | "never"; + theme?: string; + json: boolean; +} + +export interface MarkupGuideCommandInput { + kind: "markup-guide"; +} + export type ParsedCliInput = | CliInput | HelpCommandInput | PagerCommandInput | DaemonServeCommandInput - | SessionCommandInput; + | SessionCommandInput + | MarkupRenderCommandInput + | MarkupGuideCommandInput; export interface AppBootstrap { input: CliInput; diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts index 440fc5c3..53a13269 100644 --- a/src/hunk-session/cli.test.ts +++ b/src/hunk-session/cli.test.ts @@ -355,6 +355,7 @@ describe("Hunk session CLI formatters", () => { "Old range: -", "New range: -", "Agent notes visible: yes", + "Note markup width: -", "Live comments: 2", "", ].join("\n"), @@ -450,6 +451,21 @@ describe("Hunk session CLI formatters", () => { "Added live comment comment-1 on src/app.ts:12 (new) in hunk 1 for session session-1.\n", ); + expect( + formatCommentOutput(selector, { + commentId: "comment-1", + fileId: "file-1", + filePath: "src/app.ts", + hunkIndex: 0, + side: "new", + line: 12, + markupNotes: ["unknown tag "], + }), + ).toBe( + "Added live comment comment-1 on src/app.ts:12 (new) in hunk 1 for session session-1.\n" + + "Markup note: unknown tag (preview with `hunk markup render`).\n", + ); + expect(formatCommentApplyOutput(selector, { applied: [] })).toBe( "Applied 0 live comments to session session-1.\n", ); diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index 36e29806..b3f35983 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -158,6 +158,7 @@ class HttpHunkSessionCliClient implements HunkSessionCliClient { line: input.line, summary: input.summary, rationale: input.rationale, + markup: input.markup, author: input.author, reveal: input.reveal, }) @@ -352,6 +353,7 @@ export function formatContextOutput(context: SelectedSessionContext) { `Old range: ${oldRange}`, `New range: ${newRange}`, `Agent notes visible: ${context.showAgentNotes ? "yes" : "no"}`, + `Note markup width: ${context.noteMarkupWidth ?? "-"}`, `Live comments: ${context.liveCommentCount}`, "", ].join("\n"); @@ -405,8 +407,20 @@ export function formatReloadOutput(selector: SessionSelectorInput, result: Reloa return `Reloaded ${describeSessionSelector(selector)} with ${result.title} (${result.fileCount} files). Selected: ${selected}.\n`; } +/** Format the STML render notes attached to one applied comment, if any. */ +function formatMarkupNotes(result: AppliedCommentResult, indent = "") { + const widthHint = + result.markupWidth !== undefined + ? ` (preview with \`hunk markup render - --width ${result.markupWidth}\`)` + : " (preview with `hunk markup render`)"; + return (result.markupNotes ?? []).map((note) => `${indent}Markup note: ${note}${widthHint}.`); +} + export function formatCommentOutput(selector: SessionSelectorInput, result: AppliedCommentResult) { - return `Added live comment ${result.commentId} on ${result.filePath}:${result.line} (${result.side}) in hunk ${result.hunkIndex + 1} for ${describeSessionSelector(selector)}.\n`; + return `${[ + `Added live comment ${result.commentId} on ${result.filePath}:${result.line} (${result.side}) in hunk ${result.hunkIndex + 1} for ${describeSessionSelector(selector)}.`, + ...formatMarkupNotes(result), + ].join("\n")}\n`; } export function formatCommentApplyOutput( @@ -419,10 +433,10 @@ export function formatCommentApplyOutput( return `${[ `Applied ${result.applied.length} live comments to ${describeSessionSelector(selector)}:`, - ...result.applied.map( - (comment) => - ` - ${comment.commentId} on ${comment.filePath}:${comment.line} (${comment.side}) hunk ${comment.hunkIndex + 1}`, - ), + ...result.applied.flatMap((comment) => [ + ` - ${comment.commentId} on ${comment.filePath}:${comment.line} (${comment.side}) hunk ${comment.hunkIndex + 1}`, + ...formatMarkupNotes(comment, " "), + ]), "", ].join("\n")}`; } diff --git a/src/hunk-session/projections.ts b/src/hunk-session/projections.ts index e9b436b1..7da963a8 100644 --- a/src/hunk-session/projections.ts +++ b/src/hunk-session/projections.ts @@ -101,6 +101,7 @@ export function buildSelectedHunkSessionContext(session: ListedSession): Selecte } : null, showAgentNotes: session.snapshot.state.showAgentNotes, + noteMarkupWidth: session.snapshot.state.noteMarkupWidth, liveCommentCount: session.snapshot.state.liveCommentCount, }; } diff --git a/src/hunk-session/types.ts b/src/hunk-session/types.ts index 3cb2860a..cc37076d 100644 --- a/src/hunk-session/types.ts +++ b/src/hunk-session/types.ts @@ -55,6 +55,8 @@ export interface HunkSessionState { selectedHunkOldRange?: [number, number]; selectedHunkNewRange?: [number, number]; showAgentNotes: boolean; + /** Width STML note markup renders at in the session's current layout ("new"-side anchor). */ + noteMarkupWidth?: number; liveCommentCount: number; liveComments: SessionLiveCommentSummary[]; reviewNoteCount?: number; @@ -135,6 +137,10 @@ export interface AppliedCommentResult { hunkIndex: number; side: DiffSide; line: number; + /** Width the comment's STML markup was validated at, present when markup was given. */ + markupWidth?: number; + /** STML render notes for the comment's markup, present only when non-empty. */ + markupNotes?: string[]; } export interface AppliedCommentBatchResult { @@ -201,6 +207,8 @@ export interface SelectedSessionContext { selectedFile: SessionFileSummary | null; selectedHunk: SelectedHunkSummary | null; showAgentNotes: boolean; + /** Width STML note markup renders at in the session's current layout. */ + noteMarkupWidth?: number; liveCommentCount: number; } diff --git a/src/hunk-session/wire.test.ts b/src/hunk-session/wire.test.ts index 489843ae..8bcf0fbc 100644 --- a/src/hunk-session/wire.test.ts +++ b/src/hunk-session/wire.test.ts @@ -69,6 +69,23 @@ describe("hunk session wire parsing", () => { expect(snapshot?.state.liveCommentCount).toBe(1); }); + test("snapshot carries the live note markup width and drops invalid values", () => { + const parse = (noteMarkupWidth: unknown) => + parseSessionSnapshot({ + updatedAt: "2026-03-22T00:00:00.000Z", + state: { + selectedHunkIndex: 0, + showAgentNotes: true, + noteMarkupWidth, + liveComments: [], + }, + }); + + expect(parse(112)?.state.noteMarkupWidth).toBe(112); + expect(parse("wide")?.state.noteMarkupWidth).toBeUndefined(); + expect(parse(undefined)?.state.noteMarkupWidth).toBeUndefined(); + }); + test("registration parses app info from the nested broker envelope", () => { const registration = parseSessionRegistration({ registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, diff --git a/src/hunk-session/wire.ts b/src/hunk-session/wire.ts index c03c92fd..6cc2d506 100644 --- a/src/hunk-session/wire.ts +++ b/src/hunk-session/wire.ts @@ -252,6 +252,7 @@ function parseHunkSessionState(value: unknown): HunkSessionState | null { selectedHunkOldRange: parseOptionalRange(record.selectedHunkOldRange), selectedHunkNewRange: parseOptionalRange(record.selectedHunkNewRange), showAgentNotes, + noteMarkupWidth: brokerWireParsers.parseNonNegativeInt(record.noteMarkupWidth) ?? undefined, liveCommentCount: liveComments.length, liveComments, reviewNoteCount: reviewNotes.length, diff --git a/src/main.tsx b/src/main.tsx index 1dfd3ae1..3b399252 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -50,6 +50,23 @@ async function main() { process.exit(0); } + if (startupPlan.kind === "markup-guide") { + const { runMarkupGuideCommand } = await import("./ui/lib/stml/cli"); + process.exit(runMarkupGuideCommand({ stdout: (text) => process.stdout.write(text) })); + } + + if (startupPlan.kind === "markup-render") { + const { runMarkupRenderCommand } = await import("./ui/lib/stml/cli"); + process.exit( + await runMarkupRenderCommand(startupPlan.input, { + stdout: (text) => process.stdout.write(text), + stderr: (text) => process.stderr.write(text), + stdoutIsTTY: Boolean(process.stdout.isTTY), + readStdinText: () => new Response(Bun.stdin.stream()).text(), + }), + ); + } + if (startupPlan.kind === "plain-text-pager") { await pagePlainText(startupPlan.text); process.exit(0); diff --git a/src/session-broker/brokerServer.ts b/src/session-broker/brokerServer.ts index 869a7e8d..caf31f4f 100644 --- a/src/session-broker/brokerServer.ts +++ b/src/session-broker/brokerServer.ts @@ -300,6 +300,7 @@ export async function handleSessionApiRequest(state: HunkSessionBrokerState, req line: input.line, summary: input.summary, rationale: input.rationale, + markup: input.markup, author: input.author, reveal: input.reveal, }, @@ -321,6 +322,7 @@ export async function handleSessionApiRequest(state: HunkSessionBrokerState, req line: comment.line, summary: comment.summary, rationale: comment.rationale, + markup: comment.markup, author: comment.author, })), revealMode: input.revealMode, diff --git a/src/session/protocol.ts b/src/session/protocol.ts index 6f1a5aea..2198093e 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -93,6 +93,7 @@ export type SessionDaemonRequest = line: number; summary: string; rationale?: string; + markup?: string; author?: string; reveal: boolean; } diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 63f8225a..4acd36f7 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -22,7 +22,8 @@ import type { ActiveAddNoteAffordance } from "./diff/PierreDiffView"; import { useAppKeyboardShortcuts } from "./hooks/useAppKeyboardShortcuts"; import { useHunkSessionBridge } from "./hooks/useHunkSessionBridge"; import { useMenuController } from "./hooks/useMenuController"; -import { useReviewController } from "./hooks/useReviewController"; +import { useReviewController, type AgentNoteGeometrySnapshot } from "./hooks/useReviewController"; +import { agentNoteMarkupWidth } from "./lib/agentNoteGeometry"; import { buildAppMenus } from "./lib/appMenus"; import { fileRowId } from "./lib/ids"; import { openSelectedFileInEditor } from "./lib/openInEditor"; @@ -181,7 +182,13 @@ export function App({ })), [activeTheme.id, themeOptions], ); - const review = useReviewController({ files: bootstrap.changeset.files }); + // App computes layout geometry below this hook call, so the controller reads + // the current values through a ref instead of a render-time parameter. + const noteGeometryRef = useRef(null); + const review = useReviewController({ + files: bootstrap.changeset.files, + noteGeometry: noteGeometryRef, + }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; const selectedHunkIndex = review.selectedHunkIndex; @@ -222,25 +229,6 @@ export function App({ }; }, []); - useHunkSessionBridge({ - addLiveComment: review.addLiveComment, - addLiveCommentBatch: review.addLiveCommentBatch, - clearLiveComments: review.clearLiveComments, - hostClient, - liveCommentCount: review.liveCommentCount, - liveCommentSummaries: review.liveCommentSummaries, - navigateToLocation: review.navigateToLocation, - openAgentNotes, - reloadSession: onReloadSession, - removeLiveComment: review.removeLiveComment, - reviewNoteCount: review.reviewNoteCount, - reviewNoteSummaries: review.reviewNoteSummaries, - selectedFile, - selectedHunk: review.selectedHunk, - selectedHunkIndex, - showAgentNotes, - }); - const bodyPadding = pagerMode ? 0 : BODY_PADDING; const bodyWidth = Math.max(0, terminal.width - bodyPadding); const responsiveLayout = resolveResponsiveLayout(layoutMode, terminal.width); @@ -262,6 +250,34 @@ export function App({ ? Math.max(DIFF_MIN_WIDTH, availableCenterWidth - clampedSidebarWidth) : Math.max(0, availableCenterWidth); const diffContentWidth = Math.max(12, diffPaneWidth - 2); + // Publish the live note geometry for daemon-driven markup validation; the + // note markup width mirrors what AgentInlineNote lays STML out at. + noteGeometryRef.current = { layout: resolvedLayout, width: diffContentWidth }; + const noteMarkupWidth = agentNoteMarkupWidth({ + anchorSide: "new", + layout: resolvedLayout, + width: diffContentWidth, + }); + + useHunkSessionBridge({ + addLiveComment: review.addLiveComment, + addLiveCommentBatch: review.addLiveCommentBatch, + clearLiveComments: review.clearLiveComments, + hostClient, + liveCommentCount: review.liveCommentCount, + liveCommentSummaries: review.liveCommentSummaries, + navigateToLocation: review.navigateToLocation, + noteMarkupWidth, + openAgentNotes, + reloadSession: onReloadSession, + removeLiveComment: review.removeLiveComment, + reviewNoteCount: review.reviewNoteCount, + reviewNoteSummaries: review.reviewNoteSummaries, + selectedFile, + selectedHunk: review.selectedHunk, + selectedHunkIndex, + showAgentNotes, + }); const maxVisibleLineNumber = useMemo( () => filteredFiles.reduce( diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index ed442cda..d2e3a13f 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,12 +1,15 @@ -import type { TextareaRenderable } from "@opentui/core"; +import { createTextAttributes, type TextareaRenderable } from "@opentui/core"; import { flushSync } from "@opentui/react"; -import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from "react"; import type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; +import { agentNoteBoxLayout } from "../../lib/agentNoteGeometry"; import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotations"; import { wrapText } from "../../lib/agentPopover"; import { isEscapeKey, isSaveDraftNoteKey } from "../../lib/keyboard"; import { sanitizeTerminalLine } from "../../../lib/terminalText"; -import { fitText, padText } from "../../lib/text"; +import { fitText, measureTextWidth, padText } from "../../lib/text"; +import { resolveStmlColor } from "../../lib/stml/colors"; +import { layoutStmlCached, type StmlLine, type StmlSpan } from "../../lib/stml/layout"; import type { AppTheme } from "../../themes"; export function inlineNoteTitle(annotation: AgentAnnotation, noteIndex: number, noteCount: number) { @@ -25,8 +28,24 @@ interface AgentInlineNoteLine { text: string; } -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); +/** + * Lay out an annotation's optional STML markup body for one content width. + * + * Returns null when the annotation has no markup or the markup degrades to + * nothing, so callers fall back to the plain summary/rationale body. Both + * measurement and rendering call this with the same width, which keeps the + * planned row height and the mounted card height in exact lockstep. + */ +export function agentInlineNoteMarkupLines( + annotation: AgentAnnotation, + contentWidth: number, +): StmlLine[] | null { + if (!annotation.markup || annotation.source === "user-draft") { + return null; + } + + const { lines } = layoutStmlCached(annotation.markup, contentWidth); + return lines.length > 0 ? lines : null; } function draftLineCount(text: string) { @@ -60,13 +79,23 @@ function wrapNoteText(text: string, width: number) { return text.split("\n").flatMap((line) => wrapText(sanitizeTerminalLine(line), width)); } -function splitColumnWidths(width: number) { - const markerWidth = 1; - const separatorWidth = 1; - const usableWidth = Math.max(0, width - markerWidth - separatorWidth); - const leftWidth = Math.max(0, markerWidth + Math.floor(usableWidth / 2)); - const rightWidth = Math.max(0, separatorWidth + usableWidth - Math.floor(usableWidth / 2)); - return { leftWidth, rightWidth }; +/** Build the plain summary/rationale body used when a note has no markup. */ +function agentInlineNoteBodyLines( + annotation: AgentAnnotation, + contentWidth: number, +): AgentInlineNoteLine[] { + return [ + ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ + kind: "summary" as const, + text, + })), + ...(annotation.rationale + ? wrapNoteText(annotation.rationale, contentWidth).map((text) => ({ + kind: "rationale" as const, + text, + })) + : []), + ]; } export function measureAgentInlineNoteHeight({ @@ -80,38 +109,20 @@ export function measureAgentInlineNoteHeight({ layout: Exclude; width: number; }) { - const splitWidths = splitColumnWidths(width); - const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; - const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; - const preferredDockWidth = canDockRight - ? splitWidths.rightWidth - : canDockLeft - ? splitWidths.leftWidth - : Math.max(34, width - 4); - const boxWidth = clamp(preferredDockWidth, 28, Math.max(28, width - 4)); - const innerWidth = Math.max(1, boxWidth - 2); - const bodyWidth = innerWidth; - const contentWidth = Math.max(1, bodyWidth - 2); - const lines: AgentInlineNoteLine[] = [ - ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ - kind: "summary" as const, - text, - })), - ...(annotation.rationale - ? wrapNoteText(annotation.rationale, contentWidth).map((text) => ({ - kind: "rationale" as const, - text, - })) - : []), - ]; + const { contentWidth } = agentNoteBoxLayout({ anchorSide, layout, width }); if (annotation.source === "user-draft") { // Keep geometry aligned with the rendered textarea rows, including soft wraps. return draftVisualLineCount(annotation.summary, contentWidth) + 6; } - // top border + title row + body lines + bottom border - return 3 + lines.length; + const markupLines = agentInlineNoteMarkupLines(annotation, contentWidth); + const bodyLineCount = markupLines + ? markupLines.length + : agentInlineNoteBodyLines(annotation, contentWidth).length; + + // top border + top padding row + body lines + bottom border + return 3 + bodyLineCount; } /** Render the note card itself before the start of an annotated range. */ @@ -192,25 +203,9 @@ export function AgentInlineNote({ const closeText = onClose ? "[x]" : ""; const titleText = `${inlineNoteTitle(annotation, noteIndex, noteCount)} - ${annotationRangeLabel(annotation, file)}`; - const splitWidths = splitColumnWidths(width); - const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; - const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; - const preferredDockWidth = canDockRight - ? splitWidths.rightWidth - : canDockLeft - ? splitWidths.leftWidth - : Math.max(34, width - 4); - const boxWidth = clamp(preferredDockWidth, 28, Math.max(28, width - 4)); - const boxLeft = canDockRight - ? Math.max(0, width - boxWidth) - : canDockLeft - ? 0 - : Math.min(4, Math.max(0, width - boxWidth)); - const innerWidth = Math.max(1, boxWidth - 2); + const { boxWidth, boxLeft, contentWidth } = agentNoteBoxLayout({ anchorSide, layout, width }); const closeGapWidth = closeText ? 1 : 0; const closeWidth = closeText.length; - const bodyWidth = innerWidth; - const contentWidth = Math.max(1, bodyWidth - 2); const draftInnerWidth = Math.max(1, boxWidth - 2); const draftContentWidth = Math.max(1, draftInnerWidth - 2); const draftVisibleRows = draft @@ -244,18 +239,7 @@ export function AgentInlineNote({ }); }; - const lines: AgentInlineNoteLine[] = [ - ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ - kind: "summary" as const, - text, - })), - ...(annotation.rationale - ? wrapNoteText(annotation.rationale, contentWidth).map((text) => ({ - kind: "rationale" as const, - text, - })) - : []), - ]; + const lines = agentInlineNoteBodyLines(annotation, contentWidth); const savedTitleText = fitText( ` ${titleText} `, Math.max(0, boxWidth - 4 - closeGapWidth - closeWidth), @@ -495,7 +479,23 @@ export function AgentInlineNote({ ); } - const renderSavedBodyRow = (key: string, text: string, kind: AgentInlineNoteLine["kind"]) => ( + const markupLines = agentInlineNoteMarkupLines(annotation, contentWidth); + + /** Resolve one STML span into concrete OpenTUI text props for this theme. */ + const markupSpanProps = (span: StmlSpan) => ({ + fg: resolveStmlColor(span.fg, theme) ?? theme.text, + bg: resolveStmlColor(span.bg, theme) ?? theme.panel, + attributes: createTextAttributes({ + bold: span.bold, + italic: span.italic, + underline: span.underline, + dim: span.dim, + strikethrough: span.strike, + }), + }); + + /** One card body row: left offset, side borders, and a one-line content cell. */ + const renderBodyRow = (key: string, content: ReactNode) => ( - - - {padText(text, contentWidth)} - - + {content} @@ -523,6 +519,31 @@ export function AgentInlineNote({ ); + const renderMarkupBodyRow = (key: string, line: StmlLine) => { + const usedWidth = line.spans.reduce((total, span) => total + measureTextWidth(span.text), 0); + return renderBodyRow( + key, + + {line.spans.map((span, spanIndex) => ( + + {span.text} + + ))} + {usedWidth < contentWidth ? ( + {" ".repeat(contentWidth - usedWidth)} + ) : null} + , + ); + }; + + const renderSavedBodyRow = (key: string, text: string, kind: AgentInlineNoteLine["kind"]) => + renderBodyRow( + key, + + {padText(text, contentWidth)} + , + ); + return ( @@ -566,9 +587,11 @@ export function AgentInlineNote({ {renderSavedBodyRow("saved-note-top-padding", "", "summary")} - {lines.map((line, index) => - renderSavedBodyRow(`${line.kind}:${index}`, line.text, line.kind), - )} + {markupLines + ? markupLines.map((line, index) => renderMarkupBodyRow(`markup:${index}`, line)) + : lines.map((line, index) => + renderSavedBodyRow(`${line.kind}:${index}`, line.text, line.kind), + )} diff --git a/src/ui/components/ui-components.test.tsx b/src/ui/components/ui-components.test.tsx index b163124b..7aa78ee3 100644 --- a/src/ui/components/ui-components.test.tsx +++ b/src/ui/components/ui-components.test.tsx @@ -21,7 +21,7 @@ const { buildSidebarEntries } = await import("../lib/files"); const { HelpDialog } = await import("./chrome/HelpDialog"); const { SidebarPane } = await import("./panes/SidebarPane"); const { AgentCard } = await import("./panes/AgentCard"); -const { AgentInlineNote } = await import("./panes/AgentInlineNote"); +const { AgentInlineNote, measureAgentInlineNoteHeight } = await import("./panes/AgentInlineNote"); const { DiffPane } = await import("./panes/DiffPane"); const { MenuDropdown } = await import("./chrome/MenuDropdown"); const { StatusBar } = await import("./chrome/StatusBar"); @@ -1815,6 +1815,70 @@ describe("UI components", () => { expect(lines[4]?.trimStart().startsWith("╰")).toBe(true); }); + test("AgentInlineNote renders STML markup as the note body at its measured height", async () => { + const theme = resolveTheme("github-dark-default", null); + const annotation = { + newRange: [2, 4] as [number, number], + summary: "Plain fallback summary", + markup: + '

Refactor

keep hot path allocation-freeshape', + }; + const measured = measureAgentInlineNoteHeight({ + annotation, + anchorSide: "new", + layout: "stack", + width: 60, + }); + const frame = await captureFrame( + {}} + />, + 64, + measured + 2, + ); + + const lines = frame.split("\n").map((line) => line.trimEnd()); + expect(lines[0]).toContain("Agent note - R2–R4"); + expect(lines[2]).toContain("Refactor"); + expect(lines[3]).toContain("• keep hot path allocation-free"); + expect(lines[4]).toContain("╔"); + expect(lines[5]).toContain("║shape"); + expect(lines[6]).toContain("╚"); + // The markup body replaces the plain summary text entirely. + expect(frame).not.toContain("Plain fallback summary"); + // The mounted card ends exactly where the planned measurement said it would. + expect(lines[measured - 1]?.trimStart().startsWith("╰")).toBe(true); + expect(lines.slice(measured).every((line) => line.trim() === "")).toBe(true); + }); + + test("AgentInlineNote falls back to the summary when markup renders to nothing", async () => { + const theme = resolveTheme("github-dark-default", null); + const annotation = { + newRange: [2, 2] as [number, number], + summary: "Fallback summary", + markup: "", + }; + const frame = await captureFrame( + {}} + />, + 64, + 6, + ); + + expect(frame).toContain("Fallback summary"); + }); + test("AgentInlineNote renders draft notes as an editable composer", async () => { const theme = resolveTheme("github-dark-default", null); const file = createTestDiffFile( diff --git a/src/ui/hooks/useHunkSessionBridge.ts b/src/ui/hooks/useHunkSessionBridge.ts index d7c9d690..3b297779 100644 --- a/src/ui/hooks/useHunkSessionBridge.ts +++ b/src/ui/hooks/useHunkSessionBridge.ts @@ -19,6 +19,7 @@ export function useHunkSessionBridge({ liveCommentCount, liveCommentSummaries, navigateToLocation, + noteMarkupWidth, openAgentNotes, reloadSession, removeLiveComment, @@ -36,6 +37,8 @@ export function useHunkSessionBridge({ liveCommentCount: number; liveCommentSummaries: SessionLiveCommentSummary[]; navigateToLocation: ReviewController["navigateToLocation"]; + /** Width STML note markup currently renders at (see agentNoteMarkupWidth). */ + noteMarkupWidth?: number; openAgentNotes: () => void; reloadSession: ( nextInput: CliInput, @@ -95,6 +98,7 @@ export function useHunkSessionBridge({ selectedHunkOldRange: selectedRange?.oldRange, selectedHunkNewRange: selectedRange?.newRange, showAgentNotes, + noteMarkupWidth, liveCommentCount, liveComments: liveCommentSummaries, reviewNoteCount, @@ -105,6 +109,7 @@ export function useHunkSessionBridge({ hostClient, liveCommentCount, liveCommentSummaries, + noteMarkupWidth, reviewNoteCount, reviewNoteSummaries, selectedFile?.id, diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx index 8dd65666..91c56efc 100644 --- a/src/ui/hooks/useReviewController.test.tsx +++ b/src/ui/hooks/useReviewController.test.tsx @@ -86,15 +86,17 @@ function expectValue(value: T): NonNullable { function ReviewControllerHarness({ initialFiles, + noteGeometry, onController, onSetFiles, }: { initialFiles: DiffFile[]; + noteGeometry?: Parameters[0]["noteGeometry"]; onController: (controller: ReviewController) => void; onSetFiles?: (setFiles: (nextFiles: DiffFile[]) => void) => void; }) { const [files, setFiles] = useState(initialFiles); - const controller = useReviewController({ files }); + const controller = useReviewController({ files, noteGeometry }); useEffect(() => { onController(controller); @@ -110,13 +112,20 @@ function ReviewControllerHarness({ /** Render the controller hook and expose its latest state to tests. */ async function renderReviewController( initialFiles: DiffFile[], - { strictMode = false }: { strictMode?: boolean } = {}, + { + strictMode = false, + noteGeometry, + }: { + strictMode?: boolean; + noteGeometry?: Parameters[0]["noteGeometry"]; + } = {}, ) { const controllerRef: { current: ReviewController | null } = { current: null }; const setFilesRef: { current: ((nextFiles: DiffFile[]) => void) | null } = { current: null }; const harness = ( { controllerRef.current = nextController; }} @@ -345,6 +354,112 @@ describe("useReviewController", () => { } }); + test("live comments validate markup at the published live width", async () => { + const noteGeometry: { current: { layout: "split" | "stack"; width: number } | null } = { + current: { layout: "stack", width: 120 }, + }; + const { controllerRef, setup } = await renderReviewController( + [ + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\n", + ), + ], + { noteGeometry }, + ); + + try { + await flush(setup); + + const results: Array<{ markupWidth?: number }> = []; + await act(async () => { + results.push( + expectValue(controllerRef.current).addLiveComment( + { + filePath: "alpha.ts", + side: "new", + line: 1, + summary: "Wide note", + markup: "ok", + }, + "comment-wide", + { reveal: false }, + ), + ); + // Simulate the user narrowing the terminal / switching layout. + noteGeometry.current = { layout: "split", width: 120 }; + results.push( + expectValue(controllerRef.current).addLiveComment( + { + filePath: "alpha.ts", + side: "new", + line: 1, + summary: "Docked note", + markup: "ok", + }, + "comment-docked", + { reveal: false }, + ), + ); + }); + + // stack at width 120 → content width 112; split dock is roughly half. + expect(results[0]!.markupWidth).toBe(112); + expect(results[1]!.markupWidth).toBeLessThan(70); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + + test("live comments with degraded markup return render notes for the agent", async () => { + const { controllerRef, setup } = await renderReviewController([ + createDiffFile("alpha", "alpha.ts", "export const alpha = 1;\n", "export const alpha = 2;\n"), + ]); + + try { + await flush(setup); + + const results: Array<{ markupNotes?: string[] }> = []; + await act(async () => { + results.push( + expectValue(controllerRef.current).addLiveComment( + { + filePath: "alpha.ts", + side: "new", + line: 1, + summary: "Degraded markup", + markup: "1 2 3", + }, + "comment-degraded", + { reveal: false }, + ), + expectValue(controllerRef.current).addLiveComment( + { + filePath: "alpha.ts", + side: "new", + line: 1, + summary: "Clean markup", + markup: "ok", + }, + "comment-clean", + { reveal: false }, + ), + ); + }); + + expect(results[0]!.markupNotes?.some((note) => note.includes("unknown tag"))).toBe(true); + expect(results[1]!.markupNotes).toBeUndefined(); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + } + }); + test("batch live comments validate together and reveal the first applied hunk", async () => { const { controllerRef, setup } = await renderReviewController([createTwoHunkFile()]); diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index 6e14f203..b25938e7 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -22,7 +22,7 @@ import { resolveCommentTarget, } from "../../core/liveComments"; import { SourceTextTooLargeError } from "../../core/fileSource"; -import type { AgentAnnotation, DiffFile, UserNoteLineTarget } from "../../core/types"; +import type { AgentAnnotation, DiffFile, LayoutMode, UserNoteLineTarget } from "../../core/types"; import type { AppliedCommentBatchResult, AppliedCommentResult, @@ -40,7 +40,9 @@ import type { FileSourceStatus } from "../diff/expandCollapsedRows"; import { selectGapForKeyboardToggle } from "../diff/expandCollapsedRows"; import { trailingCollapsedLines } from "../diff/pierre"; import { findNextHunkCursor } from "../lib/hunks"; +import { agentNoteMarkupWidth } from "../lib/agentNoteGeometry"; import { reviewNoteSource } from "../lib/agentAnnotations"; +import { STML_REFERENCE_WIDTH, validateStmlMarkup } from "../lib/stml/layout"; import { buildReviewState, buildSelectedHunkSummary, @@ -184,7 +186,25 @@ export interface ReviewController { } /** Own the shared review stream state used by both the UI and session bridge. */ -export function useReviewController({ files }: { files: DiffFile[] }): ReviewController { +/** Live note-card geometry the app publishes for markup validation. */ +export interface AgentNoteGeometrySnapshot { + layout: Exclude; + /** Diff pane content width — the width the diff view renders at. */ + width: number; +} + +export function useReviewController({ + files, + noteGeometry, +}: { + files: DiffFile[]; + /** + * Mutable ref the app keeps pointed at the current layout and pane width. + * A ref (not a value) because App computes geometry after this hook runs; + * daemon commands arrive asynchronously, so reads always see fresh state. + */ + noteGeometry?: { current: AgentNoteGeometrySnapshot | null }; +}): ReviewController { const [filter, setFilter] = useState(""); const [selectedFileId, setSelectedFileId] = useState(files[0]?.id ?? ""); const [selectedHunkIndex, setSelectedHunkIndex] = useState(0); @@ -554,6 +574,34 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon [allFiles, selectHunk, selectedFile?.id, selectedHunkIndex, visibleFiles], ); + /** + * Validate one comment's STML markup at the width the note will actually + * render at right now — live layout mode and pane width, falling back to + * the documented reference width when geometry is not published (tests, + * headless callers). Reports the width back so agents can preview at it. + */ + const markupFeedback = useCallback( + ( + markup: string | undefined, + anchorSide: "old" | "new", + ): Pick => { + if (!markup) { + return {}; + } + + const geometry = noteGeometry?.current; + const markupWidth = geometry + ? agentNoteMarkupWidth({ anchorSide, layout: geometry.layout, width: geometry.width }) + : STML_REFERENCE_WIDTH; + const markupNotes = validateStmlMarkup(markup, markupWidth); + return { + markupWidth, + ...(markupNotes.length > 0 ? { markupNotes } : {}), + }; + }, + [noteGeometry], + ); + /** Add one live comment, optionally revealing its hunk in the active review. */ const addLiveComment = useCallback( ( @@ -594,9 +642,10 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon hunkIndex: target.hunkIndex, side: target.side, line: target.line, + ...markupFeedback(input.markup, target.side), }; }, - [allFiles, selectHunk], + [allFiles, markupFeedback, selectHunk], ); /** Apply several live comments together after validating every target first. */ @@ -654,6 +703,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon hunkIndex: target.hunkIndex, side: target.side, line: target.line, + ...markupFeedback(liveComment.markup, target.side), })), }; }, diff --git a/src/ui/lib/agentNoteGeometry.test.ts b/src/ui/lib/agentNoteGeometry.test.ts new file mode 100644 index 00000000..50b3f18c --- /dev/null +++ b/src/ui/lib/agentNoteGeometry.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from "bun:test"; +import { agentNoteBoxLayout, agentNoteMarkupWidth } from "./agentNoteGeometry"; + +describe("agentNoteBoxLayout", () => { + test("stack layout gives the note nearly the full pane", () => { + const { boxWidth, contentWidth } = agentNoteBoxLayout({ + anchorSide: "new", + layout: "stack", + width: 120, + }); + expect(boxWidth).toBe(116); + expect(contentWidth).toBe(112); + }); + + test("split layout docks new-side notes to roughly half the pane", () => { + const { boxWidth, boxLeft } = agentNoteBoxLayout({ + anchorSide: "new", + layout: "split", + width: 120, + }); + expect(boxWidth).toBeLessThan(70); + expect(boxLeft).toBe(120 - boxWidth); + }); + + test("split layout docks old-side notes on the left", () => { + const { boxLeft } = agentNoteBoxLayout({ anchorSide: "old", layout: "split", width: 120 }); + expect(boxLeft).toBe(0); + }); + + test("narrow split panes fall back to full-width placement", () => { + const wide = agentNoteBoxLayout({ anchorSide: "new", layout: "split", width: 83 }); + expect(wide.boxWidth).toBe(79); + }); + + test("never collapses below the minimum card width", () => { + const { boxWidth, contentWidth } = agentNoteBoxLayout({ + anchorSide: "new", + layout: "stack", + width: 20, + }); + expect(boxWidth).toBeGreaterThanOrEqual(16); + expect(contentWidth).toBeGreaterThanOrEqual(1); + }); + + test("huge terminals grow the markup width with the pane", () => { + expect(agentNoteMarkupWidth({ anchorSide: "new", layout: "stack", width: 220 })).toBe(212); + expect( + agentNoteMarkupWidth({ anchorSide: "new", layout: "split", width: 220 }), + ).toBeGreaterThan(100); + }); +}); diff --git a/src/ui/lib/agentNoteGeometry.ts b/src/ui/lib/agentNoteGeometry.ts new file mode 100644 index 00000000..dbb1f3b8 --- /dev/null +++ b/src/ui/lib/agentNoteGeometry.ts @@ -0,0 +1,62 @@ +// Placement and sizing for the inline agent note card. +// +// This is the single source of truth for how wide a note card is and where it +// docks. It is shared by the card renderer, the planned-row height +// measurement, and the live markup-width reporting that tells agents what +// width their STML will actually be laid out at — all three must agree or +// note heights and agent feedback drift from what the terminal shows. + +import type { LayoutMode } from "../../core/types"; +import { resolveSplitPaneWidths } from "../diff/codeColumns"; + +export interface AgentNoteGeometryInput { + anchorSide?: "old" | "new"; + layout: Exclude; + /** Diff pane content width (the `width` prop the diff view renders at). */ + width: number; +} + +export interface AgentNoteBoxLayout { + /** Total card width including its borders. */ + boxWidth: number; + /** Columns of left padding before the card starts. */ + boxLeft: number; + /** Width the note body (summary text or STML markup) is laid out at. */ + contentWidth: number; +} + +function clamp(value: number, min: number, max: number) { + return Math.min(Math.max(value, min), max); +} + +/** Resolve the note card's box placement for one anchor side and pane width. */ +export function agentNoteBoxLayout({ + anchorSide, + layout, + width, +}: AgentNoteGeometryInput): AgentNoteBoxLayout { + // Docked notes align to the same column split the side-by-side diff uses. + const splitWidths = resolveSplitPaneWidths(width); + const canDockRight = layout === "split" && anchorSide === "new" && width >= 84; + const canDockLeft = layout === "split" && anchorSide === "old" && width >= 84; + const preferredDockWidth = canDockRight + ? splitWidths.rightWidth + : canDockLeft + ? splitWidths.leftWidth + : Math.max(34, width - 4); + const boxWidth = clamp(preferredDockWidth, 28, Math.max(28, width - 4)); + const boxLeft = canDockRight + ? Math.max(0, width - boxWidth) + : canDockLeft + ? 0 + : Math.min(4, Math.max(0, width - boxWidth)); + const innerWidth = Math.max(1, boxWidth - 2); + const contentWidth = Math.max(1, innerWidth - 2); + + return { boxWidth, boxLeft, contentWidth }; +} + +/** The width STML markup in a note body is laid out at for this geometry. */ +export function agentNoteMarkupWidth(input: AgentNoteGeometryInput): number { + return agentNoteBoxLayout(input).contentWidth; +} diff --git a/src/ui/lib/stml/cli.ts b/src/ui/lib/stml/cli.ts new file mode 100644 index 00000000..54541e0e --- /dev/null +++ b/src/ui/lib/stml/cli.ts @@ -0,0 +1,58 @@ +// Runners for `hunk markup render` and `hunk markup guide`. Kept out of +// main.tsx so the command behavior is directly testable. + +import { resolve as resolvePath } from "node:path"; +import type { MarkupRenderCommandInput } from "../../../core/types"; +import { resolveTheme } from "../../themes"; +import { STML_GUIDE } from "./guide"; +import { renderStmlToAnsi, renderStmlToText } from "./render"; + +export interface MarkupCommandIo { + stdout: (text: string) => void; + stderr: (text: string) => void; + stdoutIsTTY: boolean; + readStdinText: () => Promise; +} + +const DEFAULT_PREVIEW_THEME = "github-dark-default"; + +/** Execute `hunk markup render`; returns the process exit code. */ +export async function runMarkupRenderCommand( + input: MarkupRenderCommandInput, + io: MarkupCommandIo, +): Promise { + const markup = + input.file === "-" + ? await io.readStdinText() + : await Bun.file(resolvePath(process.cwd(), input.file)).text(); + + const useColor = + input.color === "always" || (input.color === "auto" && io.stdoutIsTTY && !input.json); + + const result = useColor + ? renderStmlToAnsi( + markup, + input.width, + resolveTheme(input.theme ?? DEFAULT_PREVIEW_THEME, null), + ) + : renderStmlToText(markup, input.width); + + if (input.json) { + io.stdout( + `${JSON.stringify({ width: input.width, lines: result.lines, notes: result.errors }, null, 2)}\n`, + ); + return 0; + } + + io.stdout(`${result.lines.join("\n")}\n`); + for (const note of result.errors) { + io.stderr(`note: ${note}\n`); + } + return 0; +} + +/** Execute `hunk markup guide`. */ +export function runMarkupGuideCommand(io: Pick): number { + io.stdout(STML_GUIDE); + return 0; +} diff --git a/src/ui/lib/stml/colors.ts b/src/ui/lib/stml/colors.ts new file mode 100644 index 00000000..7b7052b1 --- /dev/null +++ b/src/ui/lib/stml/colors.ts @@ -0,0 +1,79 @@ +// Resolve STML's symbolic color vocabulary against the active AppTheme. +// Layout keeps colors as strings (tokens, names, or hex) so measurement is +// theme-free; this is the single render-time mapping from that vocabulary to +// concrete colors. + +import type { AppTheme } from "../../themes"; + +/** Fixed fallback palette for ANSI-style color names in agent markup. */ +const NAMED_COLORS: Record = { + black: "#1c1c1c", + red: "#e05252", + green: "#4fb469", + yellow: "#d9a331", + blue: "#4f8fd9", + magenta: "#b969d9", + cyan: "#3fb5b5", + white: "#e8e8e8", + gray: "#8a8a8a", + grey: "#8a8a8a", + orange: "#e0873d", + purple: "#9a6fd0", + pink: "#d9699a", +}; + +const HEX_COLOR = /^#(?:[0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/; + +/** + * Map one STML color token to a concrete theme color. + * + * Semantic tokens follow the sideshow-term vocabulary (`accent`, `success`, + * `warning`, `danger`, `info`, `muted`, `subtle`, `heading`) plus a few + * internal tokens layout emits (`note-border`, `badge-text`). Unknown values + * resolve to null so callers can degrade to the default text color. + */ +export function resolveStmlColor(token: string | undefined, theme: AppTheme): string | null { + if (!token) { + return null; + } + + const value = token.trim().toLowerCase(); + + switch (value) { + case "accent": + return theme.accent; + case "info": + return theme.accentMuted; + case "success": + return theme.addedSignColor; + case "danger": + case "error": + return theme.removedSignColor; + case "warning": + return theme.fileModified; + case "muted": + return theme.muted; + case "subtle": + return theme.panelAlt; + case "heading": + case "text": + return theme.text; + case "panel": + case "bg": + return theme.panel; + case "note-border": + return theme.noteBorder; + case "badge-text": + // Badge glyphs sit on a bright badge background, so the app background + // is the highest-contrast text color on both light and dark themes. + return theme.background; + default: + break; + } + + if (HEX_COLOR.test(value)) { + return value; + } + + return NAMED_COLORS[value] ?? null; +} diff --git a/src/ui/lib/stml/guide.test.ts b/src/ui/lib/stml/guide.test.ts new file mode 100644 index 00000000..2c22768c --- /dev/null +++ b/src/ui/lib/stml/guide.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, test } from "bun:test"; +import { STML_GUIDE, stmlGuideSnippets } from "./guide"; +import { layoutStml, STML_REFERENCE_WIDTH } from "./layout"; + +describe("STML guide", () => { + test("contains a copy-paste snippet for every core pattern", () => { + const snippets = stmlGuideSnippets(); + expect(snippets.length).toBeGreaterThanOrEqual(8); + + // The idioms agents cannot derive from the tag list alone. + expect(STML_GUIDE).toContain("█"); + expect(STML_GUIDE).toContain("→"); + expect(STML_GUIDE).toContain("--width"); + expect(STML_GUIDE).toContain(`${STML_REFERENCE_WIDTH}`); + }); + + test("every snippet lays out cleanly at the reference width", () => { + for (const snippet of stmlGuideSnippets()) { + const { lines, errors } = layoutStml(snippet, STML_REFERENCE_WIDTH); + expect(errors).toEqual([]); + expect(lines.length).toBeGreaterThan(0); + } + }); + + test("snippets stay within the reference width", () => { + for (const snippet of stmlGuideSnippets()) { + const { lines } = layoutStml(snippet, STML_REFERENCE_WIDTH); + for (const line of lines) { + const text = line.spans.map((span) => span.text).join(""); + expect(text.length).toBeLessThanOrEqual(STML_REFERENCE_WIDTH); + } + } + }); +}); diff --git a/src/ui/lib/stml/guide.ts b/src/ui/lib/stml/guide.ts new file mode 100644 index 00000000..21418723 --- /dev/null +++ b/src/ui/lib/stml/guide.ts @@ -0,0 +1,144 @@ +// The STML authoring guide printed by `hunk markup guide`. +// +// This is the canonical teaching artifact for agents writing markup notes. +// It is loaded on demand (never embedded in a prompt), but agents pay its +// token cost each time they read it, so it stays terse: the copy-paste +// snippets carry the teaching and prose is kept to what a snippet can't say. +// guide.test.ts lays out every ```stml fence at the reference width, so the +// guide can never drift from what the renderer accepts. + +import { STML_REFERENCE_WIDTH } from "./layout"; + +export const STML_GUIDE = `# STML — terminal markup for Hunk agent notes + +Experimental: the tag and color vocabulary may change between releases. +Markup degrades to plain text, so worst case a note loses polish, not content. + +Small HTML-like markup rendered as real terminal UI inside note cards: +boxes, rows, badges, gauges, lists, code blocks. Sources (--summary stays +as the plain-text fallback): + + hunk session comment add ... --markup '...' + comment apply items: { "markup": "...", ... } + agent-context sidecar: annotations[].markup + +Preview from a file or stdin: + + echo 'OK ready' | hunk markup render - + +## Ground rules + +- Width follows the live session: stack ≈ full pane, split ≈ half, big + terminal = big note. \`hunk session context --json\` reports + \`noteMarkupWidth\`; comment responses echo \`markupWidth\`. Preview with + \`hunk markup render - --width \`. Unknown? Design for ~${STML_REFERENCE_WIDTH} cols — + it holds up wider, and users resize/switch layouts anytime. +- No chart tag: gauges are block chars (█ ░) in color spans (pattern below). +- Bad markup degrades instead of crashing and produces render notes + (in comment responses and on \`markup render\` stderr) — fix what they flag. +- Entities work: → → ✓ ✓ & &. + +## Tags + +Block: box card section col row · text p · h1 h2 h3 · list ul ol item · +hr · spacer · code pre +Inline: b i u s dim · c/color · kbd · badge · a · br +box/card attrs: border, border-style (single|rounded|double|heavy), +border-color, title, title-color, bg, padding[-x|-y], width (cells or %). +row: gap. list: marker. spacer: size. code: title. +Colors: theme tokens accent success warning danger info muted subtle heading +(preferred — they follow the user's theme), ANSI names, or #hex. + +## Patterns + +Status line: + +\`\`\`stml +PASS 34 tests · TODO add jitter · reviewed by fable +\`\`\` + +Titled card: + +\`\`\`stml + + Retries cap at 3 attempts; the last error is rethrown. + +\`\`\` + +Scorecard row: + +\`\`\`stml + + + 34 pass + + + RISK unbounded delay + + +\`\`\` + +Gauges (fixed bar budget ~20 cells, filled/empty split, label at the end): + +\`\`\`stml +coverage ████████████████░░░░ 80% +p95 ███████░░░░░░░░░░░░░ 340ms +\`\`\` + +Pipeline (the
drops each arrow to the boxes' middle row): + +\`\`\`stml + + parse +
+ render +
+\`\`\` + +Checklist: + +\`\`\`stml + + DONE bounded retry loop + TODO add jitter + RISK delayMs grows unbounded + +\`\`\` + +Key-value block: + +\`\`\`stml + + attempts
base delay
+ 3 max
100ms
+
+\`\`\` + +Code suggestion (verbatim; clips, never wraps): + +\`\`\`stml + +const backoff = (attempt: number) => + 100 * 2 ** (attempt - 1); + +\`\`\` + +Keyboard hints: + +\`\`\`stml +press a to toggle notes +\`\`\` + +A note reads best as a callout, not a page: verdict first, a couple of +blocks of evidence, semantic colors used for meaning. +`; + +/** Extract the fenced \`\`\`stml snippets from the guide, in order. */ +export function stmlGuideSnippets(guide: string = STML_GUIDE): string[] { + const snippets: string[] = []; + const fence = /```stml\n([\s\S]*?)```/g; + for (let match = fence.exec(guide); match; match = fence.exec(guide)) { + snippets.push(match[1]!.trimEnd()); + } + return snippets; +} diff --git a/src/ui/lib/stml/layout.test.ts b/src/ui/lib/stml/layout.test.ts new file mode 100644 index 00000000..3de9c3d6 --- /dev/null +++ b/src/ui/lib/stml/layout.test.ts @@ -0,0 +1,173 @@ +import { describe, expect, test } from "bun:test"; +import { layoutStml, layoutStmlCached, type StmlLine } from "./layout"; + +function lineText(line: StmlLine): string { + return line.spans.map((span) => span.text).join(""); +} + +function frameText(lines: StmlLine[]): string[] { + return lines.map(lineText); +} + +describe("layoutStml", () => { + test("wraps plain text to the given width", () => { + const { lines, errors } = layoutStml("one two three four five", 10); + expect(errors).toHaveLength(0); + expect(frameText(lines)).toEqual(["one two", "three four", "five"]); + }); + + test("is deterministic: same input, same lines", () => { + const markup = 'alpha beta'; + const a = layoutStml(markup, 30); + const b = layoutStml(markup, 30); + expect(a).toEqual(b); + }); + + test("carries inline styles through wrapping", () => { + const { lines } = layoutStml("bold and long words here", 12); + expect(lines.length).toBeGreaterThan(1); + for (const line of lines) { + for (const span of line.spans) { + expect(span.bold).toBe(true); + } + } + }); + + test("decodes entities in flowing text", () => { + const { lines } = layoutStml("a → b & c", 30); + expect(lineText(lines[0]!)).toBe("a → b & c"); + }); + + test("honors explicit
line breaks", () => { + const { lines } = layoutStml("first
second", 40); + expect(frameText(lines)).toEqual(["first", "second"]); + }); + + test("renders a bordered card with a title, filling the exact width", () => { + const { lines } = layoutStml('hi', 20); + const rows = frameText(lines); + expect(rows[0]).toContain("╭─ Plan "); + expect(rows[rows.length - 1]).toBe(`╰${"─".repeat(18)}╯`); + for (const row of rows) { + expect(row.length).toBe(20); + } + // card = top border + padding row + content + padding row + bottom border + expect(rows).toHaveLength(5); + }); + + test("box without border attribute stays frameless", () => { + const { lines } = layoutStml("hi", 20); + expect(frameText(lines)).toEqual(["hi" + " ".repeat(18)]); + }); + + test("double border style uses double glyphs", () => { + const { lines } = layoutStml('x', 10); + expect(lineText(lines[0]!)).toBe(`╔${"═".repeat(8)}╗`); + }); + + test("lays out row columns side by side with a gap", () => { + const { lines } = layoutStml("aabb", 21); + const rows = frameText(lines); + expect(rows[0]).toBe(`┌${"─".repeat(8)}┐ ┌${"─".repeat(8)}┐`); + expect(rows[1]).toContain("│aa"); + expect(rows[1]).toContain("│bb"); + }); + + test("row honors fixed column widths", () => { + const { lines } = layoutStml( + 'ab', + 20, + ); + const top = lineText(lines[0]!); + expect(top.startsWith(`┌${"─".repeat(4)}┐ ┌`)).toBe(true); + expect(top.length).toBe(20); + }); + + test("degrades a too-narrow row to stacked blocks with a note", () => { + const { lines, errors } = layoutStml("" + "x".repeat(6) + "", 9); + expect(errors.some((error) => error.includes("too narrow"))).toBe(true); + expect(lines.length).toBeGreaterThan(6); + }); + + test("renders ordered and unordered lists with hanging indents", () => { + const { lines } = layoutStml( + "
    first item that wraps aroundsecond
", + 16, + ); + const rows = frameText(lines); + expect(rows[0]!.startsWith("1. first")).toBe(true); + expect(rows[1]!.startsWith(" ")).toBe(true); + expect(rows[rows.length - 1]!.startsWith("2. second")).toBe(true); + }); + + test("keeps code blocks verbatim, clipped instead of wrapped", () => { + const markup = ` + const value = 1; + const aVeryLongLineThatShouldClipInsteadOfWrappingAnywhereAtAll = true; + `; + const { lines } = layoutStml(markup, 24); + const rows = frameText(lines); + expect(rows[1]).toContain("const value = 1;"); + // 2 border rows + 2 code lines, no soft wrap + expect(rows).toHaveLength(4); + for (const row of rows) { + expect(row.length).toBeLessThanOrEqual(24); + } + }); + + test("hr fills the width", () => { + const { lines } = layoutStml("
", 12); + expect(lineText(lines[0]!)).toBe("─".repeat(12)); + }); + + test("headings are bold and h1 is underlined", () => { + const { lines } = layoutStml("

Title

Sub

", 20); + expect(lines[0]!.spans[0]).toMatchObject({ bold: true, underline: true, fg: "heading" }); + expect(lines[1]!.spans[0]).toMatchObject({ bold: true, fg: "heading" }); + }); + + test("badges pad their label and default to accent background", () => { + const { lines } = layoutStml("OK", 20); + const spans = lines[0]!.spans; + expect(lineText(lines[0]!)).toBe(" OK "); + expect(spans.every((span) => span.bg === "accent")).toBe(true); + }); + + test("unknown tags degrade to their children plus an error note", () => { + const { lines, errors } = layoutStml("content", 20); + expect(frameText(lines)).toEqual(["content"]); + expect(errors.some((error) => error.includes("unknown tag"))).toBe(true); + }); + + test("returns no lines below the minimum width", () => { + const { lines, errors } = layoutStml("hello", 3); + expect(lines).toHaveLength(0); + expect(errors.length).toBeGreaterThan(0); + }); + + test("spacer emits blank rows", () => { + const { lines } = layoutStml('ab', 10); + expect(frameText(lines)).toEqual(["a", "", "", "b"]); + }); + + test("hard-slices a single word wider than the line", () => { + const { lines } = layoutStml("abcdefghijklmnop", 8); + expect(frameText(lines)).toEqual(["abcdefgh", "ijklmnop"]); + }); + + test("bg fills padded box rows", () => { + const { lines } = layoutStml('x', 12); + for (const line of lines) { + expect(lineText(line).length).toBe(12); + for (const span of line.spans) { + expect(span.bg).toBe("subtle"); + } + } + }); + + test("cached layout returns stable results", () => { + const markup = "cache me"; + expect(layoutStmlCached(markup, 20)).toBe(layoutStmlCached(markup, 20)); + expect(layoutStmlCached(markup, 20)).not.toBe(layoutStmlCached(markup, 24)); + }); +}); diff --git a/src/ui/lib/stml/layout.ts b/src/ui/lib/stml/layout.ts new file mode 100644 index 00000000..7a16fbec --- /dev/null +++ b/src/ui/lib/stml/layout.ts @@ -0,0 +1,857 @@ +// STML -> styled terminal lines. Turns a parsed STML tree into a flat list of +// fixed-width line/span rows. +// +// Hunk's review stream is row-windowed: every planned row must know its exact +// terminal height before it mounts (see plannedReviewRows.ts). A flexbox +// renderer cannot promise that, so this is a small deterministic layout +// engine instead — the same (markup, width) input always produces the same +// lines, and a note's height is simply `lines.length`. +// +// Colors stay symbolic here (`accent`, `success`, `#ff00aa`); resolving them +// against the active AppTheme happens at render time in resolveStmlColor, so +// measurement never needs a theme. + +import { measureTextWidth, sliceTextByWidth } from "../text"; +import { decodeStmlEntities, parseStml, type StmlElement, type StmlNode } from "./parse"; + +export interface StmlStyle { + fg?: string; + bg?: string; + bold?: boolean; + italic?: boolean; + underline?: boolean; + dim?: boolean; + strike?: boolean; +} + +export interface StmlSpan extends StmlStyle { + text: string; +} + +export interface StmlLine { + spans: StmlSpan[]; +} + +export interface StmlLayoutResult { + lines: StmlLine[]; + errors: string[]; +} + +/** Minimum content width the layout engine will attempt to fill. */ +export const MIN_STML_LAYOUT_WIDTH = 8; + +/** + * The width agents should design notes for when the live width is unknown, + * and the default preview width. Chosen to match the tightest common note + * body: a split layout dock on a typical terminal. Documented in the STML + * guide. Write-path validation prefers the session's live note width. + */ +export const STML_REFERENCE_WIDTH = 56; + +/** Lay out markup at one note width and return its render notes. */ +export function validateStmlMarkup(markup: string, width: number = STML_REFERENCE_WIDTH): string[] { + return layoutStmlCached(markup, width).errors; +} + +const MAX_LAYOUT_ERRORS = 20; + +const INLINE_TAGS = new Set([ + "b", + "strong", + "i", + "em", + "u", + "dim", + "muted", + "s", + "strike", + "del", + "c", + "color", + "span", + "a", + "link", + "kbd", + "badge", + "br", +]); + +interface BorderChars { + topLeft: string; + topRight: string; + bottomLeft: string; + bottomRight: string; + horizontal: string; + vertical: string; +} + +const BORDER_STYLES: Record = { + single: { + topLeft: "┌", + topRight: "┐", + bottomLeft: "└", + bottomRight: "┘", + horizontal: "─", + vertical: "│", + }, + rounded: { + topLeft: "╭", + topRight: "╮", + bottomLeft: "╰", + bottomRight: "╯", + horizontal: "─", + vertical: "│", + }, + double: { + topLeft: "╔", + topRight: "╗", + bottomLeft: "╚", + bottomRight: "╝", + horizontal: "═", + vertical: "║", + }, + heavy: { + topLeft: "┏", + topRight: "┓", + bottomLeft: "┗", + bottomRight: "┛", + horizontal: "━", + vertical: "┃", + }, +}; + +const truthyAttr = (value: string | undefined) => + value === undefined || value === "" || value === "true" || value === "yes" || value === "on"; + +const collapseWs = (text: string) => text.replace(/\s+/g, " "); + +const mergeStyle = (base: StmlStyle, over: StmlStyle): StmlStyle => ({ ...base, ...over }); + +function numAttr(value: string | undefined): number | undefined { + if (value === undefined) { + return undefined; + } + const n = Number(value); + return Number.isFinite(n) ? n : undefined; +} + +/** Resolve a width attribute (cells or percentage of the available width). */ +function widthAttr(value: string | undefined, available: number): number | undefined { + if (value === undefined) { + return undefined; + } + const percent = /^(\d+(?:\.\d+)?)%$/.exec(value); + if (percent) { + return Math.max(1, Math.floor((available * Number(percent[1])) / 100)); + } + const n = numAttr(value); + return n !== undefined ? Math.max(1, Math.floor(n)) : undefined; +} + +function attrStyle(attrs: Record): StmlStyle { + const style: StmlStyle = {}; + if (attrs.fg ?? attrs.color) { + style.fg = attrs.fg ?? attrs.color; + } + if (attrs.bg) { + style.bg = attrs.bg; + } + if ("bold" in attrs) { + style.bold = truthyAttr(attrs.bold); + } + if ("italic" in attrs) { + style.italic = truthyAttr(attrs.italic); + } + if ("underline" in attrs) { + style.underline = truthyAttr(attrs.underline); + } + if ("dim" in attrs) { + style.dim = truthyAttr(attrs.dim); + } + if ("strike" in attrs) { + style.strike = truthyAttr(attrs.strike); + } + return style; +} + +function inlineStyle(tag: string, attrs: Record): StmlStyle { + switch (tag) { + case "b": + case "strong": + return { bold: true }; + case "i": + case "em": + return { italic: true }; + case "u": + return { underline: true }; + case "s": + case "strike": + case "del": + return { strike: true }; + case "dim": + case "muted": + return { dim: true }; + case "kbd": + return { bg: "subtle", fg: "heading" }; + case "badge": + return { + bg: attrs.color ?? attrs.bg ?? "accent", + fg: attrs.fg ?? "badge-text", + bold: true, + }; + case "a": + case "link": + return { fg: "accent", underline: true }; + default: + return attrStyle(attrs); + } +} + +class LayoutErrors { + readonly messages: string[] = []; + + add(message: string) { + if (this.messages.length < MAX_LAYOUT_ERRORS) { + this.messages.push(message); + } else if (this.messages.length === MAX_LAYOUT_ERRORS) { + this.messages.push("further layout notes omitted"); + } + } +} + +// --- inline flow --- + +/** Flatten one inline subtree into styled spans; `\n` spans mark hard breaks. */ +function inlineSpans(node: StmlNode, style: StmlStyle): StmlSpan[] { + if (node.type === "text") { + const text = collapseWs(decodeStmlEntities(node.value)); + return text === "" ? [] : [{ ...style, text }]; + } + if (node.tag === "br") { + return [{ ...style, text: "\n" }]; + } + const next = mergeStyle(style, inlineStyle(node.tag, node.attrs)); + const padded = node.tag === "badge" || node.tag === "kbd"; + const out: StmlSpan[] = []; + if (padded) { + out.push({ ...next, text: " " }); + } + for (const child of node.children) { + out.push(...inlineSpans(child, next)); + } + if (padded) { + out.push({ ...next, text: " " }); + } + return out; +} + +interface InlineToken { + span: StmlSpan; + kind: "word" | "space" | "break"; + width: number; +} + +/** Split styled spans into wrap-safe word/space/break tokens. */ +function tokenizeSpans(spans: StmlSpan[]): InlineToken[] { + const tokens: InlineToken[] = []; + for (const span of spans) { + const parts = span.text.split(/(\n| +)/); + for (const part of parts) { + if (part === "") { + continue; + } + if (part === "\n") { + tokens.push({ span: { ...span, text: "\n" }, kind: "break", width: 0 }); + } else if (/^ +$/.test(part) && !span.bg) { + // Background-colored spaces (badge/kbd padding) are visible content, + // so only plain spaces participate in wrap collapsing. + tokens.push({ span: { ...span, text: part }, kind: "space", width: part.length }); + } else { + tokens.push({ span: { ...span, text: part }, kind: "word", width: measureTextWidth(part) }); + } + } + } + return tokens; +} + +function sameStyle(a: StmlStyle, b: StmlStyle): boolean { + return ( + a.fg === b.fg && + a.bg === b.bg && + a.bold === b.bold && + a.italic === b.italic && + a.underline === b.underline && + a.dim === b.dim && + a.strike === b.strike + ); +} + +/** Append a span to a line, merging with the previous span when styles match. */ +function pushSpan(line: StmlLine, span: StmlSpan) { + const last = line.spans[line.spans.length - 1]; + if (last && sameStyle(last, span)) { + last.text += span.text; + } else { + line.spans.push({ ...span }); + } +} + +/** Greedy word-wrap styled spans into lines no wider than `width`. */ +function wrapSpans(spans: StmlSpan[], width: number): StmlLine[] { + const usable = Math.max(1, width); + const tokens = tokenizeSpans(spans); + const lines: StmlLine[] = []; + let current: StmlLine = { spans: [] }; + let currentWidth = 0; + let started = false; + + const flush = () => { + // Right-trim plain trailing spaces; bg-colored spaces stay (see tokenize). + while (current.spans.length > 0) { + const last = current.spans[current.spans.length - 1]!; + if (last.bg || !/^ *$/.test(last.text)) { + last.text = last.bg ? last.text : last.text.replace(/ +$/, ""); + break; + } + current.spans.pop(); + } + lines.push(current); + current = { spans: [] }; + currentWidth = 0; + started = false; + }; + + for (const token of tokens) { + if (token.kind === "break") { + flush(); + continue; + } + if (token.kind === "space") { + // Leading spaces on a fresh line vanish, matching normal text flow. + if (!started) { + continue; + } + if (currentWidth + token.width > usable) { + flush(); + continue; + } + pushSpan(current, token.span); + currentWidth += token.width; + continue; + } + + if (currentWidth + token.width <= usable) { + pushSpan(current, token.span); + currentWidth += token.width; + started = true; + continue; + } + + if (started) { + flush(); + } + + // A word longer than the whole line gets hard-sliced across lines. + let rest = token.span.text; + while (measureTextWidth(rest) > usable) { + const slice = sliceTextByWidth(rest, 0, usable); + if (slice.text.length === 0) { + break; + } + pushSpan(current, { ...token.span, text: slice.text }); + flush(); + rest = rest.slice(slice.text.length); + } + if (rest.length > 0) { + pushSpan(current, { ...token.span, text: rest }); + currentWidth = measureTextWidth(rest); + started = true; + } + } + + // Trim trailing whitespace-only tail and drop a dangling empty line unless + // it is the only line (an explicit
chain keeps its blank rows). + if (current.spans.length > 0 || lines.length === 0) { + lines.push(current); + } + return lines; +} + +function lineWidth(line: StmlLine): number { + return line.spans.reduce((total, span) => total + measureTextWidth(span.text), 0); +} + +/** Pad every line to an exact width, filling with the block background. */ +function padLines(lines: StmlLine[], width: number, bg?: string): StmlLine[] { + return lines.map((line) => { + const spans = bg + ? line.spans.map((span) => ({ ...span, bg: span.bg ?? bg })) + : line.spans.map((span) => ({ ...span })); + const used = lineWidth({ spans }); + if (used < width) { + spans.push({ text: " ".repeat(width - used), ...(bg ? { bg } : {}) }); + } + return { spans }; + }); +} + +// --- raw text helpers (for /
) ---
+
+const rawText = (el: StmlElement) =>
+  el.children.map((child) => (child.type === "text" ? child.value : "")).join("");
+
+// Strip the leading newline and shared indentation so agents can indent the
+// body of a  block to match surrounding markup.
+function dedent(text: string): string {
+  const lines = text.replace(/^\n/, "").replace(/\s+$/, "").split("\n");
+  let min = Infinity;
+  for (const line of lines) {
+    if (line.trim() === "") {
+      continue;
+    }
+    min = Math.min(min, line.length - line.trimStart().length);
+  }
+  if (!Number.isFinite(min) || min === 0) {
+    return lines.join("\n");
+  }
+  return lines.map((line) => line.slice(min)).join("\n");
+}
+
+// --- block layout ---
+
+function borderChars(styleAttr: string | undefined, fallback: keyof typeof BORDER_STYLES) {
+  if (styleAttr && BORDER_STYLES[styleAttr]) {
+    return { chars: BORDER_STYLES[styleAttr]!, unknown: false };
+  }
+  return { chars: BORDER_STYLES[fallback]!, unknown: styleAttr !== undefined };
+}
+
+/** Wrap block content in a box frame with optional title and padding. */
+function frameLines(
+  content: StmlLine[],
+  {
+    width,
+    border,
+    chars,
+    borderColor,
+    title,
+    titleColor,
+    bg,
+    paddingX,
+    paddingY,
+  }: {
+    width: number;
+    border: boolean;
+    chars: BorderChars;
+    borderColor: string;
+    title?: string;
+    titleColor: string;
+    bg?: string;
+    paddingX: number;
+    paddingY: number;
+  },
+): StmlLine[] {
+  const innerWidth = Math.max(1, width - (border ? 2 : 0) - paddingX * 2);
+  const padded = padLines(content, innerWidth, bg);
+  const sidePad: StmlSpan | null =
+    paddingX > 0 ? { text: " ".repeat(paddingX), ...(bg ? { bg } : {}) } : null;
+
+  const bodyLines: StmlLine[] = [];
+  const blankRow = (): StmlLine => ({
+    spans: [{ text: " ".repeat(innerWidth + paddingX * 2), ...(bg ? { bg } : {}) }],
+  });
+
+  for (let i = 0; i < paddingY; i++) {
+    bodyLines.push(blankRow());
+  }
+  for (const line of padded) {
+    const spans: StmlSpan[] = [];
+    if (sidePad) {
+      spans.push({ ...sidePad });
+    }
+    spans.push(...line.spans);
+    if (sidePad) {
+      spans.push({ ...sidePad });
+    }
+    bodyLines.push({ spans });
+  }
+  for (let i = 0; i < paddingY; i++) {
+    bodyLines.push(blankRow());
+  }
+
+  if (!border) {
+    return bodyLines;
+  }
+
+  const horizontalWidth = Math.max(0, width - 2);
+  const top: StmlLine = { spans: [] };
+  if (title && title.trim() !== "") {
+    const label = ` ${title.trim()} `;
+    const fitted = sliceTextByWidth(label, 0, Math.max(0, horizontalWidth - 2)).text;
+    const remainder = Math.max(0, horizontalWidth - 1 - measureTextWidth(fitted));
+    top.spans.push({ text: `${chars.topLeft}${chars.horizontal}`, fg: borderColor });
+    top.spans.push({ text: fitted, fg: titleColor, bold: true });
+    top.spans.push({
+      text: `${chars.horizontal.repeat(remainder)}${chars.topRight}`,
+      fg: borderColor,
+    });
+  } else {
+    top.spans.push({
+      text: `${chars.topLeft}${chars.horizontal.repeat(horizontalWidth)}${chars.topRight}`,
+      fg: borderColor,
+    });
+  }
+
+  const bottom: StmlLine = {
+    spans: [
+      {
+        text: `${chars.bottomLeft}${chars.horizontal.repeat(horizontalWidth)}${chars.bottomRight}`,
+        fg: borderColor,
+      },
+    ],
+  };
+
+  const framed: StmlLine[] = [top];
+  for (const line of bodyLines) {
+    framed.push({
+      spans: [
+        { text: chars.vertical, fg: borderColor, ...(bg ? { bg } : {}) },
+        ...line.spans,
+        { text: chars.vertical, fg: borderColor, ...(bg ? { bg } : {}) },
+      ],
+    });
+  }
+  framed.push(bottom);
+  return framed;
+}
+
+/** Lay out one bullet/numbered item with a hanging indent. */
+function bulletLines(
+  prefix: string,
+  children: StmlNode[],
+  width: number,
+  style: StmlStyle,
+  errors: LayoutErrors,
+): StmlLine[] {
+  const prefixWidth = measureTextWidth(prefix);
+  const bodyWidth = Math.max(1, width - prefixWidth);
+  const body = layoutBlockNodes(children, bodyWidth, style, errors);
+  return body.map((line, index) => ({
+    spans: [
+      index === 0 ? { text: prefix, fg: "muted" } : { text: " ".repeat(prefixWidth) },
+      ...line.spans,
+    ],
+  }));
+}
+
+/** Merge column line lists side by side, padding shorter columns. */
+function mergeColumns(columns: StmlLine[][], widths: number[], gap: number): StmlLine[] {
+  const height = Math.max(0, ...columns.map((column) => column.length));
+  const merged: StmlLine[] = [];
+  for (let rowIndex = 0; rowIndex < height; rowIndex++) {
+    const spans: StmlSpan[] = [];
+    columns.forEach((column, columnIndex) => {
+      if (columnIndex > 0 && gap > 0) {
+        spans.push({ text: " ".repeat(gap) });
+      }
+      const width = widths[columnIndex]!;
+      const line = column[rowIndex];
+      if (line) {
+        spans.push(...line.spans);
+        const used = lineWidth(line);
+        if (used < width) {
+          spans.push({ text: " ".repeat(width - used) });
+        }
+      } else {
+        spans.push({ text: " ".repeat(width) });
+      }
+    });
+    merged.push({ spans });
+  }
+  return merged;
+}
+
+function layoutRow(
+  el: StmlElement,
+  width: number,
+  style: StmlStyle,
+  errors: LayoutErrors,
+): StmlLine[] {
+  const children = el.children.filter(
+    (child): child is StmlElement => child.type === "element" && !INLINE_TAGS.has(child.tag),
+  );
+  const looseInline = el.children.filter(
+    (child) => child.type === "text" || (child.type === "element" && INLINE_TAGS.has(child.tag)),
+  );
+
+  if (children.length === 0) {
+    return layoutBlockNodes(el.children, width, style, errors);
+  }
+  if (looseInline.some((node) => node.type !== "text" || node.value.trim() !== "")) {
+    errors.add(" mixes bare text with block children; text laid out above the row");
+  }
+
+  const gap = Math.max(0, numAttr(el.attrs.gap) ?? 1);
+  const totalGap = gap * (children.length - 1);
+  const available = width - totalGap;
+
+  // Fixed-width columns claim their space first; the rest share what remains.
+  const fixed = children.map((child) => widthAttr(child.attrs.width, available));
+  const fixedTotal = fixed.reduce((total, w) => total + (w ?? 0), 0);
+  const flexCount = fixed.filter((w) => w === undefined).length;
+  const flexSpace = Math.max(flexCount, available - fixedTotal);
+  const flexWidth = flexCount > 0 ? Math.floor(flexSpace / flexCount) : 0;
+  let flexRemainder = flexCount > 0 ? flexSpace - flexWidth * flexCount : 0;
+
+  if (available < children.length) {
+    // Too narrow to sit side by side — degrade to stacked blocks.
+    errors.add(" too narrow for its columns; stacking vertically");
+    return children.flatMap((child) => layoutBlock(child, width, style, errors));
+  }
+
+  const widths = fixed.map((w) => {
+    if (w !== undefined) {
+      return Math.max(1, Math.min(w, available));
+    }
+    const extra = flexRemainder > 0 ? 1 : 0;
+    flexRemainder -= extra;
+    return Math.max(1, flexWidth + extra);
+  });
+
+  const inlinePrefix =
+    looseInline.length > 0 ? layoutBlockNodes(looseInline, width, style, errors) : [];
+  const columns = children.map((child, index) => layoutBlock(child, widths[index]!, style, errors));
+  return [...inlinePrefix, ...mergeColumns(columns, widths, gap)];
+}
+
+function layoutBlock(
+  el: StmlElement,
+  width: number,
+  style: StmlStyle,
+  errors: LayoutErrors,
+): StmlLine[] {
+  const tag = el.tag;
+  switch (tag) {
+    case "box":
+    case "card":
+    case "col":
+    case "column":
+    case "stack":
+    case "section": {
+      const isCard = tag === "card";
+      const border =
+        "border" in el.attrs ? truthyAttr(el.attrs.border) : isCard || "border-style" in el.attrs;
+      const { chars, unknown } = borderChars(
+        el.attrs["border-style"],
+        isCard ? "rounded" : "single",
+      );
+      if (unknown) {
+        errors.add(`unknown border-style "${el.attrs["border-style"]}"`);
+      }
+      const padding = Math.max(0, numAttr(el.attrs.padding) ?? (isCard ? 1 : 0));
+      const paddingX = Math.max(0, numAttr(el.attrs["padding-x"]) ?? padding);
+      const paddingY = Math.max(0, numAttr(el.attrs["padding-y"]) ?? padding);
+      const requestedWidth = widthAttr(el.attrs.width, width);
+      const boxWidth = Math.max(4, Math.min(requestedWidth ?? width, width));
+      const innerWidth = Math.max(1, boxWidth - (border ? 2 : 0) - paddingX * 2);
+      const childStyle = mergeStyle(style, attrStyle(el.attrs));
+      const content = layoutBlockNodes(el.children, innerWidth, childStyle, errors);
+      return frameLines(content, {
+        width: boxWidth,
+        border,
+        chars,
+        borderColor: el.attrs["border-color"] ?? "note-border",
+        title: el.attrs.title,
+        titleColor: el.attrs["title-color"] ?? "heading",
+        bg: el.attrs.bg,
+        paddingX,
+        paddingY,
+      });
+    }
+
+    case "row":
+      return layoutRow(el, width, style, errors);
+
+    case "text":
+    case "p":
+      return wrapSpans(
+        el.children.flatMap((child) => inlineSpans(child, mergeStyle(style, attrStyle(el.attrs)))),
+        width,
+      );
+
+    case "h":
+    case "h1":
+    case "h2":
+    case "h3":
+    case "heading":
+    case "title": {
+      const base = mergeStyle(style, {
+        bold: true,
+        fg: el.attrs.fg ?? el.attrs.color ?? "heading",
+      });
+      if (tag === "h1" || tag === "title") {
+        base.underline = true;
+      }
+      return wrapSpans(
+        el.children.flatMap((child) => inlineSpans(child, base)),
+        width,
+      );
+    }
+
+    case "hr":
+    case "rule":
+    case "divider":
+      return [
+        {
+          spans: [{ text: "─".repeat(Math.max(1, width)), fg: el.attrs.color ?? "muted" }],
+        },
+      ];
+
+    case "spacer":
+    case "space": {
+      const size = Math.max(1, Math.min(20, numAttr(el.attrs.size) ?? 1));
+      return Array.from({ length: size }, () => ({ spans: [{ text: "" }] }));
+    }
+
+    case "list":
+    case "ul":
+    case "ol": {
+      const ordered = tag === "ol";
+      const marker = el.attrs.marker ?? "•";
+      const lines: StmlLine[] = [];
+      let index = 1;
+      for (const child of el.children) {
+        if (child.type !== "element" || (child.tag !== "item" && child.tag !== "li")) {
+          continue;
+        }
+        const prefix = ordered ? `${index++}. ` : `${marker} `;
+        lines.push(...bulletLines(prefix, child.children, width, style, errors));
+      }
+      return lines;
+    }
+
+    case "item":
+    case "li":
+      return bulletLines("• ", el.children, width, style, errors);
+
+    case "code":
+    case "pre": {
+      const { chars } = borderChars(el.attrs["border-style"], "single");
+      const codeStyle: StmlStyle = { ...style, fg: el.attrs.fg ?? style.fg };
+      const codeWidth = Math.max(1, width - 4);
+      const content = dedent(rawText(el))
+        .split("\n")
+        .map((line): StmlLine => {
+          // Code never soft-wraps; long lines clip so the block height stays
+          // proportional to its source line count.
+          const fitted = sliceTextByWidth(line.replaceAll("\t", "  "), 0, codeWidth);
+          return { spans: [{ ...codeStyle, text: fitted.text }] };
+        });
+      return frameLines(content, {
+        width,
+        border: true,
+        chars,
+        borderColor: el.attrs["border-color"] ?? "subtle",
+        title: el.attrs.title,
+        titleColor: "heading",
+        bg: el.attrs.bg,
+        paddingX: 1,
+        paddingY: 0,
+      });
+    }
+
+    default: {
+      errors.add(`unknown tag <${tag}>`);
+      return layoutBlockNodes(el.children, width, style, errors);
+    }
+  }
+}
+
+/** Walk a child list: group consecutive inline nodes, lay out blocks one by one. */
+function layoutBlockNodes(
+  nodes: StmlNode[],
+  width: number,
+  style: StmlStyle,
+  errors: LayoutErrors,
+): StmlLine[] {
+  const out: StmlLine[] = [];
+  let run: StmlNode[] = [];
+
+  const flush = () => {
+    if (run.length === 0) {
+      return;
+    }
+    const spans = run.flatMap((node) => inlineSpans(node, style));
+    const meaningful = spans.some((span) => span.text.trim() !== "" || span.text === "\n");
+    if (meaningful) {
+      out.push(...wrapSpans(spans, width));
+    }
+    run = [];
+  };
+
+  for (const node of nodes) {
+    if (node.type === "text" || INLINE_TAGS.has(node.tag)) {
+      run.push(node);
+      continue;
+    }
+    flush();
+    out.push(...layoutBlock(node, width, style, errors));
+  }
+  flush();
+  return out;
+}
+
+/** Parse STML markup and lay it out into styled lines for a given width. */
+export function layoutStml(markup: string, width: number): StmlLayoutResult {
+  if (width < MIN_STML_LAYOUT_WIDTH) {
+    return { lines: [], errors: [`width ${width} below minimum ${MIN_STML_LAYOUT_WIDTH}`] };
+  }
+
+  const errors = new LayoutErrors();
+  const parsed = parseStml(markup);
+  for (const message of parsed.errors) {
+    errors.add(message);
+  }
+
+  const lines = layoutBlockNodes(parsed.nodes, width, {}, errors);
+
+  // Drop leading/trailing fully blank rows so the note card hugs its content.
+  while (
+    lines.length > 0 &&
+    lineWidth(lines[0]!) === 0 &&
+    lines[0]!.spans.every((s) => s.text.trim() === "")
+  ) {
+    lines.shift();
+  }
+  while (
+    lines.length > 0 &&
+    lineWidth(lines[lines.length - 1]!) === 0 &&
+    lines[lines.length - 1]!.spans.every((s) => s.text.trim() === "")
+  ) {
+    lines.pop();
+  }
+
+  return { lines, errors: errors.messages };
+}
+
+// Layout is recomputed by both measurement (plannedReviewRows) and rendering
+// (AgentInlineNote) on every plan pass, so memoize per (markup, width).
+const layoutCache = new Map();
+const LAYOUT_CACHE_LIMIT = 256;
+
+/** Memoized layoutStml for the hot measure/render path. */
+export function layoutStmlCached(markup: string, width: number): StmlLayoutResult {
+  const key = `${width}${markup}`;
+  const cached = layoutCache.get(key);
+  if (cached) {
+    return cached;
+  }
+  const result = layoutStml(markup, width);
+  if (layoutCache.size >= LAYOUT_CACHE_LIMIT) {
+    // Simple full reset: the cache only exists to dedupe the measure/render
+    // pair within a frame, not to persist history.
+    layoutCache.clear();
+  }
+  layoutCache.set(key, result);
+  return result;
+}
diff --git a/src/ui/lib/stml/parse.test.ts b/src/ui/lib/stml/parse.test.ts
new file mode 100644
index 00000000..758edcf9
--- /dev/null
+++ b/src/ui/lib/stml/parse.test.ts
@@ -0,0 +1,94 @@
+import { describe, expect, test } from "bun:test";
+import { decodeStmlEntities, parseStml, type StmlElement, type StmlText } from "./parse";
+
+function firstElement(markup: string): StmlElement {
+  const { nodes } = parseStml(markup);
+  const element = nodes.find((node): node is StmlElement => node.type === "element");
+  if (!element) {
+    throw new Error("expected an element");
+  }
+  return element;
+}
+
+describe("parseStml", () => {
+  test("parses nested elements with attributes", () => {
+    const el = firstElement('hi');
+    expect(el.tag).toBe("box");
+    expect(el.attrs["border-style"]).toBe("rounded");
+    expect(el.attrs.title).toBe("Auth");
+    expect(el.children).toHaveLength(1);
+    expect((el.children[0] as StmlElement).tag).toBe("text");
+  });
+
+  test("keeps bare text and lone angle brackets as text", () => {
+    const { nodes, errors } = parseStml("a < b and 3<4");
+    expect(errors).toHaveLength(0);
+    expect(nodes).toHaveLength(1);
+    expect((nodes[0] as StmlText).value).toBe("a < b and 3<4");
+  });
+
+  test("tolerates stray closing tags with an error note", () => {
+    const { nodes, errors } = parseStml("hello");
+    expect((nodes[0] as StmlText).value).toBe("hello");
+    expect(errors[0]).toContain("stray closing tag");
+  });
+
+  test("implicitly closes unbalanced tags", () => {
+    const { nodes, errors } = parseStml("hi");
+    expect(errors.some((error) => error.includes("implicitly closed"))).toBe(true);
+    expect((nodes[0] as StmlElement).tag).toBe("box");
+  });
+
+  test("reports unclosed tags", () => {
+    const { errors } = parseStml("hi");
+    expect(errors.some((error) => error.includes("unclosed tag(s)"))).toBe(true);
+  });
+
+  test("treats void tags as childless", () => {
+    const el = firstElement("line one
line two
"); + const brIndex = el.children.findIndex( + (child) => child.type === "element" && child.tag === "br", + ); + expect(brIndex).toBeGreaterThan(-1); + }); + + test("takes code content verbatim without nested parsing", () => { + const el = firstElement("const a = 1;"); + expect(el.children).toHaveLength(1); + expect((el.children[0] as StmlText).value).toBe("const a = 1;"); + }); + + test("strips terminal control sequences from text and attributes", () => { + const { nodes } = parseStml('danger\u001b[2Jzone'); + const el = nodes[0] as StmlElement; + expect(el.attrs.fg).toBe("red"); + expect((el.children[0] as StmlText).value).toBe("dangerzone"); + }); + + test("ignores comments", () => { + const { nodes } = parseStml("visible"); + expect((nodes[0] as StmlText).value).toBe("visible"); + }); + + test("enforces the node limit without throwing", () => { + const markup = "x".repeat(50); + const { errors } = parseStml(markup, { maxNodes: 10 }); + expect(errors.some((error) => error.includes("node limit"))).toBe(true); + }); + + test("enforces the depth limit without throwing", () => { + const markup = `${"".repeat(40)}hi${"".repeat(40)}`; + const { errors } = parseStml(markup, { maxDepth: 5 }); + expect(errors.some((error) => error.includes("depth limit"))).toBe(true); + }); +}); + +describe("decodeStmlEntities", () => { + test("decodes named and numeric entities", () => { + expect(decodeStmlEntities("<a> & AB")).toBe(" & AB"); + }); + + test("keeps unknown and out-of-range entities literal", () => { + expect(decodeStmlEntities("&unknown; �")).toBe("&unknown; �"); + }); +}); diff --git a/src/ui/lib/stml/parse.ts b/src/ui/lib/stml/parse.ts new file mode 100644 index 00000000..f9ac7538 --- /dev/null +++ b/src/ui/lib/stml/parse.ts @@ -0,0 +1,376 @@ +// STML — a small, tolerant, HTML-like markup for terminal-rendered agent +// notes, ported from the sideshow-term experiment. The parser is pure +// data-in/data-out so it stays unit-testable and renderer-agnostic; parsing +// never throws — malformed input degrades to a best-effort tree plus a list of +// human-readable `errors`, so a sloppy note still renders something useful. + +import { sanitizeTerminalText } from "../../../lib/terminalText"; + +export interface StmlText { + type: "text"; + value: string; +} + +export interface StmlElement { + type: "element"; + tag: string; + attrs: Record; + children: StmlNode[]; +} + +export type StmlNode = StmlText | StmlElement; + +export interface StmlParseResult { + nodes: StmlNode[]; + errors: string[]; +} + +export interface StmlParseOptions { + maxInputBytes?: number; + maxNodes?: number; + maxDepth?: number; + maxErrors?: number; +} + +export const DEFAULT_STML_PARSE_LIMITS = { + maxInputBytes: 64 * 1024, + maxNodes: 2000, + maxDepth: 32, + maxErrors: 20, +} as const satisfies Required; + +// Tags that never have children — they may be written unclosed (`
`) or +// self-closed (`
`); either way any "" is tolerated and ignored. +const VOID_TAGS = new Set(["br", "hr", "rule", "divider", "spacer", "space"]); + +// Tags whose inner text is taken verbatim — no nested tags, whitespace and +// case preserved. This is what makes ergonomic. +const RAW_TAGS = new Set(["code", "pre"]); + +const NAMED_ENTITIES: Record = { + amp: "&", + lt: "<", + gt: ">", + quot: '"', + apos: "'", + nbsp: " ", + mdash: "—", + ndash: "–", + hellip: "…", + bull: "•", + middot: "·", + rarr: "→", + larr: "←", + uarr: "↑", + darr: "↓", + check: "✓", + cross: "✗", + times: "×", +}; + +function isValidCodePoint(code: number): boolean { + return Number.isInteger(code) && code >= 0 && code <= 0x10ffff; +} + +/** Decode a small, predictable entity set; unknown entities stay literal. */ +export function decodeStmlEntities(text: string): string { + return text.replace(/&(#x?[0-9a-fA-F]+|[a-zA-Z][a-zA-Z0-9]+);/g, (whole, body: string) => { + if (body[0] !== "#") { + return NAMED_ENTITIES[body.toLowerCase()] ?? whole; + } + + const code = + body[1] === "x" || body[1] === "X" + ? parseInt(body.slice(2), 16) + : parseInt(body.slice(1), 10); + return isValidCodePoint(code) ? String.fromCodePoint(code) : whole; + }); +} + +/** Neutralize control sequences in agent markup before it reaches the TUI. */ +function sanitizeStmlText(text: string): string { + return sanitizeTerminalText(text, { preserveNewlines: true, preserveTabs: false }); +} + +const isSpace = (ch: string) => + ch === " " || ch === "\t" || ch === "\n" || ch === "\r" || ch === "\f"; +const isNameChar = (ch: string) => /[a-zA-Z0-9\-_]/.test(ch); +// A tag name must start with a letter — so "3<4" and "a < b" stay as text. +const isTagStart = (ch: string | undefined) => ch !== undefined && /[a-zA-Z]/.test(ch); + +/** Parse STML markup into a tolerant node tree; never throws. */ +export function parseStml(input: string, options: StmlParseOptions = {}): StmlParseResult { + const limits: Required = { ...DEFAULT_STML_PARSE_LIMITS, ...options }; + const errors: string[] = []; + const addError = limitedErrorCollector(errors, limits.maxErrors); + const root: StmlNode[] = []; + const stack: StmlElement[] = []; + const top = () => (stack.length > 0 ? stack[stack.length - 1]!.children : root); + + let source = input; + const bytes = utf8ByteLength(source); + if (bytes > limits.maxInputBytes) { + source = truncateUtf8(source, limits.maxInputBytes); + addError(`input truncated at ${limits.maxInputBytes} byte(s)`); + } + + let i = 0; + const n = source.length; + let nodeCount = 0; + let nodeLimitReached = false; + + const canAddNode = () => { + if (nodeCount < limits.maxNodes) { + nodeCount += 1; + return true; + } + if (!nodeLimitReached) { + nodeLimitReached = true; + addError(`node limit reached at ${limits.maxNodes} node(s); remaining markup ignored`); + } + return false; + }; + + const pushText = (value: string) => { + if (value.length === 0 || nodeLimitReached) { + return; + } + const safe = sanitizeStmlText(value); + if (safe.length === 0) { + return; + } + const siblings = top(); + const last = siblings[siblings.length - 1]; + // Merge adjacent text so a bare "<" doesn't fragment a run into pieces. + if (last && last.type === "text") { + last.value += safe; + } else if (canAddNode()) { + siblings.push({ type: "text", value: safe }); + } + }; + + while (i < n && !nodeLimitReached) { + const lt = source.indexOf("<", i); + if (lt === -1) { + pushText(source.slice(i)); + break; + } + if (lt > i) { + pushText(source.slice(i, lt)); + } + if (nodeLimitReached) { + break; + } + i = lt; + + // Comment + if (source.startsWith("", i + 4); + i = end === -1 ? n : end + 3; + continue; + } + + // Closing tag + if (source[i + 1] === "/") { + let j = i + 2; + let name = ""; + while (j < n && isNameChar(source[j]!)) { + name += source[j++]; + } + while (j < n && source[j] !== ">") { + j++; + } + i = j + 1; + name = name.toLowerCase(); + // Pop to the nearest matching open element; tolerate stray/mismatched + // closers rather than discarding the whole tree. + const idx = findOpen(stack, name); + if (idx === -1) { + addError(`stray closing tag `); + } else { + if (idx !== stack.length - 1) { + addError(`closing implicitly closed ${stack.length - 1 - idx} tag(s)`); + } + stack.length = idx; + } + continue; + } + + // Not a real tag (a bare "<", or "<" before a digit) — emit as text. + if (!isTagStart(source[i + 1])) { + pushText("<"); + i += 1; + continue; + } + + // Opening tag + const open = readOpenTag(source, i); + if (!open) { + pushText("<"); + i += 1; + continue; + } + i = open.next; + + if (stack.length >= limits.maxDepth) { + addError(`depth limit reached at <${open.tag}> (${limits.maxDepth} level(s))`); + continue; + } + if (!canAddNode()) { + break; + } + + const el: StmlElement = { type: "element", tag: open.tag, attrs: open.attrs, children: [] }; + top().push(el); + + if (open.selfClosing || VOID_TAGS.has(open.tag)) { + continue; + } + + if (RAW_TAGS.has(open.tag)) { + const close = ` 0 && canAddNode()) { + el.children.push({ type: "text", value: sanitizeStmlText(raw) }); + } + if (end === -1) { + addError(`unclosed <${open.tag}>`); + i = n; + } else { + const gt = source.indexOf(">", end); + i = gt === -1 ? n : gt + 1; + } + continue; + } + + stack.push(el); + } + + if (stack.length > 0) { + addError(`unclosed tag(s): ${stack.map((e) => `<${e.tag}>`).join(", ")}`); + } + return { nodes: root, errors }; +} + +function limitedErrorCollector(errors: string[], maxErrors: number): (message: string) => void { + let omitted = false; + return (message: string) => { + if (errors.length < maxErrors) { + errors.push(message); + return; + } + if (!omitted) { + omitted = true; + if (errors.length === 0) { + return; + } + errors[errors.length - 1] = `${errors[errors.length - 1]} (further parse errors omitted)`; + } + }; +} + +function utf8ByteLength(text: string): number { + return new TextEncoder().encode(text).length; +} + +function truncateUtf8(text: string, maxBytes: number): string { + const bytes = new TextEncoder().encode(text).slice(0, maxBytes); + // Lossy decode, then strip the single replacement char a mid-codepoint cut leaves. + return new TextDecoder("utf-8", { fatal: false }).decode(bytes).replace(/�$/, ""); +} + +function findOpen(stack: StmlElement[], name: string): number { + for (let k = stack.length - 1; k >= 0; k--) { + if (stack[k]!.tag === name) { + return k; + } + } + return -1; +} + +// Case-insensitive search for a closing tag whose name matches, e.g. "; + selfClosing: boolean; + next: number; +} + +function readOpenTag(input: string, start: number): OpenTag | null { + const n = input.length; + let i = start + 1; + let tag = ""; + while (i < n && isNameChar(input[i]!)) { + tag += input[i++]; + } + if (!tag) { + return null; + } + tag = tag.toLowerCase(); + const attrs: Record = {}; + + while (i < n) { + while (i < n && isSpace(input[i]!)) { + i++; + } + if (i >= n) { + break; + } + if (input[i] === ">") { + return { tag, attrs, selfClosing: false, next: i + 1 }; + } + if (input[i] === "/" && input[i + 1] === ">") { + return { tag, attrs, selfClosing: true, next: i + 2 }; + } + // attribute name + let name = ""; + while (i < n && isNameChar(input[i]!)) { + name += input[i++]; + } + if (!name) { + // unexpected char inside tag — skip it to stay tolerant + i++; + continue; + } + name = name.toLowerCase(); + while (i < n && isSpace(input[i]!)) { + i++; + } + if (input[i] === "=") { + i++; + while (i < n && isSpace(input[i]!)) { + i++; + } + const quote = input[i]; + if (quote === '"' || quote === "'") { + i++; + let value = ""; + while (i < n && input[i] !== quote) { + value += input[i++]; + } + i++; // closing quote + attrs[name] = sanitizeStmlText(decodeStmlEntities(value)); + } else { + let value = ""; + while ( + i < n && + !isSpace(input[i]!) && + input[i] !== ">" && + !(input[i] === "/" && input[i + 1] === ">") + ) { + value += input[i++]; + } + attrs[name] = sanitizeStmlText(decodeStmlEntities(value)); + } + } else { + // bare boolean attribute + attrs[name] = ""; + } + } + return { tag, attrs, selfClosing: false, next: n }; +} diff --git a/src/ui/lib/stml/render.test.ts b/src/ui/lib/stml/render.test.ts new file mode 100644 index 00000000..9142e19b --- /dev/null +++ b/src/ui/lib/stml/render.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, test } from "bun:test"; +import { resolveTheme } from "../../themes"; +import { renderStmlToAnsi, renderStmlToText } from "./render"; + +describe("renderStmlToText", () => { + test("renders markup to plain rows without trailing whitespace", () => { + const { lines, errors } = renderStmlToText("hi", 12); + expect(errors).toEqual([]); + expect(lines).toEqual([`┌${"─".repeat(10)}┐`, "│hi │", `└${"─".repeat(10)}┘`]); + }); + + test("surfaces render notes for degraded markup", () => { + const { errors } = renderStmlToText("x", 40); + expect(errors.some((error) => error.includes("unknown tag"))).toBe(true); + }); +}); + +describe("renderStmlToAnsi", () => { + const theme = resolveTheme("github-dark-default", null); + + test("emits truecolor SGR sequences for styled spans", () => { + const { lines } = renderStmlToAnsi('ok', 20, theme); + expect(lines[0]).toMatch(/\x1b\[38;2;\d+;\d+;\d+mok\x1b\[0m/); + }); + + test("emits attribute codes for bold text", () => { + const { lines } = renderStmlToAnsi("bold", 20, theme); + expect(lines[0]).toContain("\x1b[1m"); + }); + + test("leaves plain spans free of escape codes", () => { + const { lines } = renderStmlToAnsi("plain", 20, theme); + expect(lines[0]).toBe("plain"); + }); +}); diff --git a/src/ui/lib/stml/render.ts b/src/ui/lib/stml/render.ts new file mode 100644 index 00000000..272272a3 --- /dev/null +++ b/src/ui/lib/stml/render.ts @@ -0,0 +1,94 @@ +// Headless STML rendering for `hunk markup render`: markup in, terminal text +// out. Gives agents a preview loop — see what a note will look like at a +// given width before publishing it — without launching the TUI. + +import type { AppTheme } from "../../themes"; +import { resolveStmlColor } from "./colors"; +import { layoutStml, type StmlSpan } from "./layout"; + +export interface StmlTextRenderResult { + /** One string per terminal row. */ + lines: string[]; + /** Parse/layout degradation notes, empty when the markup is clean. */ + errors: string[]; +} + +/** Render lines to strings via one span formatter, right-trimming each row. */ +function renderLines( + markup: string, + width: number, + formatSpan: (span: StmlSpan) => string, +): StmlTextRenderResult { + const { lines, errors } = layoutStml(markup, width); + return { + lines: lines.map((line) => line.spans.map(formatSpan).join("").replace(/\s+$/, "")), + errors, + }; +} + +/** Render markup to plain text rows at a given width. */ +export function renderStmlToText(markup: string, width: number): StmlTextRenderResult { + return renderLines(markup, width, (span) => span.text); +} + +function hexToRgb(color: string): [number, number, number] | null { + const hex = color.startsWith("#") ? color.slice(1) : color; + const full = + hex.length === 3 + ? hex + .split("") + .map((c) => c + c) + .join("") + : hex; + if (!/^[0-9a-fA-F]{6}$/.test(full)) { + return null; + } + return [ + parseInt(full.slice(0, 2), 16), + parseInt(full.slice(2, 4), 16), + parseInt(full.slice(4, 6), 16), + ]; +} + +/** Build the SGR prefix for one styled span; empty when the span is plain. */ +function spanSgr(span: StmlSpan, theme: AppTheme): string { + const codes: string[] = []; + if (span.bold) { + codes.push("1"); + } + if (span.dim) { + codes.push("2"); + } + if (span.italic) { + codes.push("3"); + } + if (span.underline) { + codes.push("4"); + } + if (span.strike) { + codes.push("9"); + } + const fg = resolveStmlColor(span.fg, theme); + const fgRgb = fg ? hexToRgb(fg) : null; + if (fgRgb) { + codes.push(`38;2;${fgRgb[0]};${fgRgb[1]};${fgRgb[2]}`); + } + const bg = resolveStmlColor(span.bg, theme); + const bgRgb = bg ? hexToRgb(bg) : null; + if (bgRgb) { + codes.push(`48;2;${bgRgb[0]};${bgRgb[1]};${bgRgb[2]}`); + } + return codes.length > 0 ? `\x1b[${codes.join(";")}m` : ""; +} + +/** Render markup to ANSI-colored rows, resolving colors against a theme. */ +export function renderStmlToAnsi( + markup: string, + width: number, + theme: AppTheme, +): StmlTextRenderResult { + return renderLines(markup, width, (span) => { + const sgr = spanSgr(span, theme); + return sgr ? `${sgr}${span.text}\x1b[0m` : span.text; + }); +}