From 96ad9016328bd4520331741cc36d622b64472742 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 11:27:58 +0000 Subject: [PATCH 1/6] feat(notes): render STML markup as terminal UI in agent notes Agent comments can now carry a small HTML-like markup (STML, ported from the sideshow-term experiment) that renders as real terminal UI inside the inline note card: bordered boxes, rows of shapes, lists, badges, code blocks, and styled text. Plain summaries stay as the fallback so note lists and narrow layouts keep working. Because the review stream is row-windowed, the markup is laid out by a deterministic line-layout engine (src/ui/lib/stml) instead of flexbox, so planned note heights and mounted heights stay in exact lockstep. Colors stay symbolic until render time and resolve against the active theme. Markup arrives via the agent-context sidecar (annotation.markup), hunk session comment add --markup, or comment apply batch items. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGrhiytMJoffcKg2GiaUve --- .changeset/lucky-panthers-brake.md | 5 + examples/9-agent-markup-notes/README.md | 29 + examples/9-agent-markup-notes/after/retry.ts | 15 + .../9-agent-markup-notes/agent-context.json | 24 + examples/9-agent-markup-notes/before/retry.ts | 3 + examples/9-agent-markup-notes/change.patch | 22 + examples/README.md | 1 + skills/hunk-review/SKILL.md | 3 +- src/core/agent.ts | 1 + src/core/cli.test.ts | 30 + src/core/cli.ts | 5 + src/core/liveComments.test.ts | 17 + src/core/liveComments.ts | 3 + src/core/types.ts | 4 + src/hunk-session/cli.ts | 1 + src/session-broker/brokerServer.ts | 2 + src/session/protocol.ts | 1 + src/ui/components/panes/AgentInlineNote.tsx | 104 ++- src/ui/components/ui-components.test.tsx | 66 +- src/ui/lib/stml/colors.ts | 79 ++ src/ui/lib/stml/layout.test.ts | 173 ++++ src/ui/lib/stml/layout.ts | 844 ++++++++++++++++++ src/ui/lib/stml/parse.test.ts | 94 ++ src/ui/lib/stml/parse.ts | 402 +++++++++ 24 files changed, 1916 insertions(+), 12 deletions(-) create mode 100644 .changeset/lucky-panthers-brake.md create mode 100644 examples/9-agent-markup-notes/README.md create mode 100644 examples/9-agent-markup-notes/after/retry.ts create mode 100644 examples/9-agent-markup-notes/agent-context.json create mode 100644 examples/9-agent-markup-notes/before/retry.ts create mode 100644 examples/9-agent-markup-notes/change.patch create mode 100644 src/ui/lib/stml/colors.ts create mode 100644 src/ui/lib/stml/layout.test.ts create mode 100644 src/ui/lib/stml/layout.ts create mode 100644 src/ui/lib/stml/parse.test.ts create mode 100644 src/ui/lib/stml/parse.ts diff --git a/.changeset/lucky-panthers-brake.md b/.changeset/lucky-panthers-brake.md new file mode 100644 index 00000000..f86baf0d --- /dev/null +++ b/.changeset/lucky-panthers-brake.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": minor +--- + +Agent notes can now carry STML markup — a small HTML-like markup rendered as real terminal UI inside the inline note card (bordered boxes, rows of shapes, 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. diff --git a/examples/9-agent-markup-notes/README.md b/examples/9-agent-markup-notes/README.md new file mode 100644 index 00000000..7e36a94a --- /dev/null +++ b/examples/9-agent-markup-notes/README.md @@ -0,0 +1,29 @@ +# 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 +``` + +See `src/ui/lib/stml/` for the supported 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..5c10b49a 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 . @@ -111,6 +111,7 @@ hunk session comment clear --repo . --yes [--file README.md] - Pass `--focus` when you want to jump to the new note or the first note in a batch - `comment list` and `comment clear` accept optional `--file` - Quote `--summary` and `--rationale` defensively in the shell +- `--markup` (or a `markup` field on apply items) renders the note body as STML — a small HTML-like markup for terminal UI: `box`/`card`/`row` shapes with borders, `h1`-`h3`, `list`/`item`, `code`, `badge`, `b`/`i`/`u`/`dim`, and `color` with semantic tokens (`accent`, `success`, `warning`, `danger`, `info`, `muted`) or hex. Keep `--summary` meaningful: it is the fallback and what `comment list` shows. Example: `--markup 'parselayoutTODO add jitter'` ## New files in working-tree reviews 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..e14dea36 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -542,6 +542,36 @@ describe("parseCli", () => { }); }); + 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..22a77502 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -305,6 +305,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, }; }); @@ -919,6 +920,7 @@ async function parseSessionCommand(tokens: 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 +933,7 @@ async function parseSessionCommand(tokens: string[]): Promise { oldLine?: number; newLine?: number; rationale?: string; + markup?: string; author?: string; focus?: boolean; json?: boolean; @@ -949,6 +952,7 @@ async function parseSessionCommand(tokens: string[]): Promise { oldLine?: number; newLine?: number; rationale?: string; + markup?: string; author?: string; focus?: boolean; json?: boolean; @@ -983,6 +987,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, }; 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/types.ts b/src/core/types.ts index 4c6c5c95..d4d2b32e 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; } diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index 36e29806..b69808e2 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, }) 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/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index ed442cda..128cba9e 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,4 +1,4 @@ -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 type { AgentAnnotation, DiffFile, LayoutMode } from "../../../core/types"; @@ -6,7 +6,9 @@ import { annotationRangeLabel, reviewNoteSource } from "../../lib/agentAnnotatio 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,6 +27,26 @@ interface AgentInlineNoteLine { text: string; } +/** + * 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 clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } @@ -92,6 +114,18 @@ export function measureAgentInlineNoteHeight({ const innerWidth = Math.max(1, boxWidth - 2); const bodyWidth = innerWidth; const contentWidth = Math.max(1, bodyWidth - 2); + + if (annotation.source === "user-draft") { + // Keep geometry aligned with the rendered textarea rows, including soft wraps. + return draftVisualLineCount(annotation.summary, contentWidth) + 6; + } + + const markupLines = agentInlineNoteMarkupLines(annotation, contentWidth); + if (markupLines) { + // top border + top padding + markup lines + bottom border + return 3 + markupLines.length; + } + const lines: AgentInlineNoteLine[] = [ ...wrapNoteText(annotation.summary, contentWidth).map((text) => ({ kind: "summary" as const, @@ -105,11 +139,6 @@ export function measureAgentInlineNoteHeight({ : []), ]; - 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; } @@ -495,6 +524,59 @@ export function AgentInlineNote({ ); } + 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, + }), + }); + + const renderMarkupBodyRow = (key: string, line: StmlLine) => { + const usedWidth = line.spans.reduce((total, span) => total + measureTextWidth(span.text), 0); + return ( + + + {" ".repeat(boxLeft)} + + + + │ + + + + + + {line.spans.map((span, spanIndex) => ( + + {span.text} + + ))} + {usedWidth < contentWidth ? ( + {" ".repeat(contentWidth - usedWidth)} + ) : null} + + + + + + │ + + + + ); + }; + const renderSavedBodyRow = (key: string, text: string, kind: AgentInlineNoteLine["kind"]) => ( - 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/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/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..56be8808 --- /dev/null +++ b/src/ui/lib/stml/layout.ts @@ -0,0 +1,844 @@ +// 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; + +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..1d18424c --- /dev/null +++ b/src/ui/lib/stml/parse.ts @@ -0,0 +1,402 @@ +// 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 { + let bytes = 0; + for (const ch of text) { + bytes += utf8CharBytes(ch); + } + return bytes; +} + +function truncateUtf8(text: string, maxBytes: number): string { + let bytes = 0; + let out = ""; + for (const ch of text) { + const next = bytes + utf8CharBytes(ch); + if (next > maxBytes) { + break; + } + bytes = next; + out += ch; + } + return out; +} + +function utf8CharBytes(ch: string): number { + const code = ch.codePointAt(0) ?? 0; + if (code <= 0x7f) { + return 1; + } + if (code <= 0x7ff) { + return 2; + } + if (code <= 0xffff) { + return 3; + } + return 4; +} + +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 }; +} From 0de68349174820cbed28cad3c243782a37fcbf2c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 12:25:11 +0000 Subject: [PATCH 2/6] feat(markup): teach agents STML with a guide, preview command, and write-path feedback Authoring good markup needs an iteration loop, not just a tag list, so give agents three: `hunk markup guide` prints a pattern-driven authoring guide (gauges, pipelines, scorecards, checklists, key-value blocks) whose snippets are test-validated against the real layout engine; `hunk markup render ( | -)` previews markup headlessly at any width with render notes on stderr or --json output; and comment add/apply responses now carry markupNotes whenever a comment's markup degraded, so agents get corrective feedback from the write itself. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGrhiytMJoffcKg2GiaUve --- .changeset/lucky-panthers-brake.md | 4 +- examples/9-agent-markup-notes/README.md | 17 ++- skills/hunk-review/SKILL.md | 11 +- src/core/cli.test.ts | 48 +++++++ src/core/cli.ts | 90 +++++++++++- src/core/startup.ts | 28 +++- src/core/types.ts | 18 ++- src/hunk-session/cli.test.ts | 15 ++ src/hunk-session/cli.ts | 20 ++- src/hunk-session/types.ts | 2 + src/main.tsx | 17 +++ src/ui/hooks/useReviewController.test.tsx | 45 ++++++ src/ui/hooks/useReviewController.ts | 23 ++-- src/ui/lib/stml/cli.ts | 58 ++++++++ src/ui/lib/stml/guide.test.ts | 34 +++++ src/ui/lib/stml/guide.ts | 159 ++++++++++++++++++++++ src/ui/lib/stml/layout.ts | 12 ++ src/ui/lib/stml/render.test.ts | 35 +++++ src/ui/lib/stml/render.ts | 94 +++++++++++++ 19 files changed, 707 insertions(+), 23 deletions(-) create mode 100644 src/ui/lib/stml/cli.ts create mode 100644 src/ui/lib/stml/guide.test.ts create mode 100644 src/ui/lib/stml/guide.ts create mode 100644 src/ui/lib/stml/render.test.ts create mode 100644 src/ui/lib/stml/render.ts diff --git a/.changeset/lucky-panthers-brake.md b/.changeset/lucky-panthers-brake.md index f86baf0d..04226e79 100644 --- a/.changeset/lucky-panthers-brake.md +++ b/.changeset/lucky-panthers-brake.md @@ -2,4 +2,6 @@ "hunkdiff": minor --- -Agent notes can now carry STML markup — a small HTML-like markup rendered as real terminal UI inside the inline note card (bordered boxes, rows of shapes, 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. +Agent notes can now carry STML markup — 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. + +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. `comment add`/`apply` responses also return `markupNotes` when a comment's markup degraded, so agents get corrective feedback in the write path itself. diff --git a/examples/9-agent-markup-notes/README.md b/examples/9-agent-markup-notes/README.md index 7e36a94a..a594b8c0 100644 --- a/examples/9-agent-markup-notes/README.md +++ b/examples/9-agent-markup-notes/README.md @@ -22,8 +22,15 @@ hunk session comment add --repo . --file src/retry.ts --new-line 3 \ --focus ``` -See `src/ui/lib/stml/` for the supported 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. +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/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 5c10b49a..dd46affa 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -111,7 +111,16 @@ hunk session comment clear --repo . --yes [--file README.md] - Pass `--focus` when you want to jump to the new note or the first note in a batch - `comment list` and `comment clear` accept optional `--file` - Quote `--summary` and `--rationale` defensively in the shell -- `--markup` (or a `markup` field on apply items) renders the note body as STML — a small HTML-like markup for terminal UI: `box`/`card`/`row` shapes with borders, `h1`-`h3`, `list`/`item`, `code`, `badge`, `b`/`i`/`u`/`dim`, and `color` with semantic tokens (`accent`, `success`, `warning`, `danger`, `info`, `muted`) or hex. Keep `--summary` meaningful: it is the fallback and what `comment list` shows. Example: `--markup 'parselayoutTODO add jitter'` + +### 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: bordered boxes, rows of shapes, gauges, badges, lists, and code blocks. Keep `--summary` a real sentence: it is the fallback and what `comment list` shows. + +Workflow for good markup: + +1. `hunk markup guide` — read once per session; it has copy-paste patterns for gauges, pipelines, scorecards, checklists, and key-value blocks, plus the width rules. +2. `hunk markup render - --width 56` — preview from stdin before publishing; render notes (unknown tags, layout degradations) print to stderr, `--json` gives `{ lines, notes }`. +3. `comment add`/`comment apply` responses include `markupNotes` when the markup degraded — treat any note as a prompt to fix and update the comment. ## New files in working-tree reviews diff --git a/src/core/cli.test.ts b/src/core/cli.test.ts index e14dea36..728d20d9 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -542,6 +542,54 @@ 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", diff --git a/src/core/cli.ts b/src/core/cli.ts index 22a77502..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", "", @@ -586,7 +588,13 @@ async function parseDifftoolCommand(tokens: string[], argv: 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; @@ -1369,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/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 d4d2b32e..7e1ebd54 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -353,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..6a44ac8f 100644 --- a/src/hunk-session/cli.test.ts +++ b/src/hunk-session/cli.test.ts @@ -450,6 +450,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 b69808e2..09873760 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -406,8 +406,18 @@ 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 = "") { + return (result.markupNotes ?? []).map( + (note) => `${indent}Markup note: ${note} (preview with \`hunk markup render\`).`, + ); +} + 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( @@ -420,10 +430,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/types.ts b/src/hunk-session/types.ts index 3cb2860a..dcd825d4 100644 --- a/src/hunk-session/types.ts +++ b/src/hunk-session/types.ts @@ -135,6 +135,8 @@ export interface AppliedCommentResult { hunkIndex: number; side: DiffSide; line: number; + /** STML render notes for the comment's markup, present only when non-empty. */ + markupNotes?: string[]; } export interface AppliedCommentBatchResult { 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/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx index 8dd65666..ac0d4d67 100644 --- a/src/ui/hooks/useReviewController.test.tsx +++ b/src/ui/hooks/useReviewController.test.tsx @@ -345,6 +345,51 @@ describe("useReviewController", () => { } }); + 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..b3ff7950 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -41,6 +41,7 @@ import { selectGapForKeyboardToggle } from "../diff/expandCollapsedRows"; import { trailingCollapsedLines } from "../diff/pierre"; import { findNextHunkCursor } from "../lib/hunks"; import { reviewNoteSource } from "../lib/agentAnnotations"; +import { validateStmlMarkup } from "../lib/stml/layout"; import { buildReviewState, buildSelectedHunkSummary, @@ -587,6 +588,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon selectHunk(file.id, target.hunkIndex); } + const markupNotes = input.markup ? validateStmlMarkup(input.markup) : []; return { commentId, fileId: file.id, @@ -594,6 +596,7 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon hunkIndex: target.hunkIndex, side: target.side, line: target.line, + ...(markupNotes.length > 0 ? { markupNotes } : {}), }; }, [allFiles, selectHunk], @@ -647,14 +650,18 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon } return { - applied: prepared.map(({ file, target, liveComment }) => ({ - commentId: liveComment.id, - fileId: file.id, - filePath: file.path, - hunkIndex: target.hunkIndex, - side: target.side, - line: target.line, - })), + applied: prepared.map(({ file, target, liveComment }) => { + const markupNotes = liveComment.markup ? validateStmlMarkup(liveComment.markup) : []; + return { + commentId: liveComment.id, + fileId: file.id, + filePath: file.path, + hunkIndex: target.hunkIndex, + side: target.side, + line: target.line, + ...(markupNotes.length > 0 ? { markupNotes } : {}), + }; + }), }; }, [allFiles, selectHunk], 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/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..57e05f3f --- /dev/null +++ b/src/ui/lib/stml/guide.ts @@ -0,0 +1,159 @@ +// The STML authoring guide printed by `hunk markup guide`. +// +// This is the canonical teaching artifact for agents writing markup notes, so +// it optimizes for copy-paste: every pattern is a complete, working snippet +// inside a ```stml fence. guide.test.ts extracts each fenced snippet and lays +// it out at the reference width, so the guide can never drift from what the +// renderer actually accepts. + +import { STML_REFERENCE_WIDTH } from "./layout"; + +export const STML_GUIDE = `# STML — terminal markup for Hunk agent notes + +STML is a small HTML-like markup rendered as real terminal UI inside Hunk's +inline note cards: bordered boxes, rows of shapes, lists, badges, gauges, and +code blocks instead of plain text. + +Where markup goes (the plain --summary stays as the fallback text): + + hunk session comment add ... --markup '...' + comment apply batch items: { "markup": "...", ... } + agent-context sidecar: annotations[].markup + +Preview before you publish (reads a file or stdin): + + echo 'OK ready' | hunk markup render - + +## Ground rules + +- Design for ~${STML_REFERENCE_WIDTH} columns. Notes are ~terminal-width in stack + layout but docked to roughly half the pane in split layout; text wraps and + code clips to fit. Preview at --width ${STML_REFERENCE_WIDTH} to match the tightest common case. +- There is no chart tag. Gauges and bars are block characters (█ ░) inside + color spans — see the gauge pattern below. +- Unknown tags and bad colors never crash: they degrade and produce render + notes. \`comment add\`/\`apply\` return those notes, and \`markup render\` + prints them to stderr — treat any note as a prompt to fix your markup. +- Entities work in text: → renders →, ✓ renders ✓, & renders &. + +## 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 + +Attributes on box/card: border, border-style (single|rounded|double|heavy), +border-color, title, title-color, bg, padding / padding-x / padding-y, +width (cells or %). row: gap. list: marker. spacer: size. code: title. + +Colors (fg=/bg=/color=/border-color=): semantic tokens accent, success, +warning, danger, info, muted, subtle, heading — these follow the user's Hunk +theme, so prefer them. ANSI-ish names (red, green, orange, …) and #hex also +work. + +## Patterns + +### Status line — verdict up front + +\`\`\`stml +PASS 34 tests · TODO add jitter · reviewed by fable +\`\`\` + +### Titled card — one framed takeaway + +\`\`\`stml + + Retries are capped at 3 attempts; the last error is rethrown, so + callers see the same failure mode as before. + +\`\`\` + +### Scorecard — a row of titled boxes + +\`\`\`stml + + + 34 pass + + + RISK unbounded delay + + +\`\`\` + +### Gauges — block characters in color spans + +Pick a fixed bar budget (~20 cells), split it filled/empty, label the end. + +\`\`\`stml +coverage ████████████████░░░░ 80% +p95 ███████░░░░░░░░░░░░░ 340ms +risk ████░░░░░░░░░░░░░░░░ low +\`\`\` + +### Pipeline — boxes joined by arrow columns + +The
pushes each arrow down one row so it aligns with the box body. + +\`\`\`stml + + parse +
+ layout +
+ render +
+\`\`\` + +### Checklist — badges as row markers + +\`\`\`stml + + DONE bounded retry loop + TODO add jitter to the backoff + RISK delayMs grows unbounded + +\`\`\` + +### Key-value block — fixed label column + +\`\`\`stml + + attempts
base delay
growth
+ 3 max
100ms
×2 per attempt
+
+\`\`\` + +### Code suggestion — verbatim, clipped, framed + +\`\`\`stml +Consider extracting the policy: + +const backoff = (attempt: number) => + 100 * 2 ** (attempt - 1); + +\`\`\` + +### Keyboard hints + +\`\`\`stml +press a to toggle notes, [ ] to jump hunks +\`\`\` + +## Taste + +- Lead with the verdict (badge or heading), then evidence. Reviewers scan. +- One idea per note; two or three blocks max. A note is a callout, not a page. +- Use semantic color tokens for meaning, not decoration — danger means danger. +- Keep --summary a real sentence: it is what note lists and fallbacks show. +`; + +/** 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.ts b/src/ui/lib/stml/layout.ts index 56be8808..48070682 100644 --- a/src/ui/lib/stml/layout.ts +++ b/src/ui/lib/stml/layout.ts @@ -40,6 +40,18 @@ export interface StmlLayoutResult { /** Minimum content width the layout engine will attempt to fill. */ export const MIN_STML_LAYOUT_WIDTH = 8; +/** + * The width agents should design notes for, and the width write-path + * validation uses. Chosen to match the tightest common note body: a split + * layout dock on a typical terminal. Documented in the STML guide. + */ +export const STML_REFERENCE_WIDTH = 56; + +/** Lay out markup at the reference width and return its render notes. */ +export function validateStmlMarkup(markup: string): string[] { + return layoutStmlCached(markup, STML_REFERENCE_WIDTH).errors; +} + const MAX_LAYOUT_ERRORS = 20; const INLINE_TAGS = new Set([ 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..e20b7247 --- /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 StmlLine, 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[]; +} + +function lineToPlainText(line: StmlLine): string { + return line.spans + .map((span) => span.text) + .join("") + .replace(/\s+$/, ""); +} + +/** Render markup to plain text rows at a given width. */ +export function renderStmlToText(markup: string, width: number): StmlTextRenderResult { + const { lines, errors } = layoutStml(markup, width); + return { lines: lines.map(lineToPlainText), errors }; +} + +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 { + const { lines, errors } = layoutStml(markup, width); + const rendered = lines.map((line) => { + const parts = line.spans.map((span) => { + const sgr = spanSgr(span, theme); + return sgr ? `${sgr}${span.text}\x1b[0m` : span.text; + }); + return parts.join("").replace(/\s+$/, ""); + }); + return { lines: rendered, errors }; +} From 2f203ae13e9383cfc6fed09419279d8d825c3c41 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 12:47:38 +0000 Subject: [PATCH 3/6] feat(markup): validate and report STML markup at the live note width A fixed reference width was blind to real sessions: a unified/stack view on a large terminal renders notes near full pane width, while a narrow split dock can be under 50 columns. Extract the note card's placement math into agentNoteGeometry (shared by rendering, measurement, and reporting so they cannot drift), publish the live layout and pane width to the review controller, and validate comment markup at the width the note actually renders at. Responses echo that markupWidth, `hunk session context` reports noteMarkupWidth so agents can preview at the real width before writing, and the guide teaches the width-discovery workflow. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGrhiytMJoffcKg2GiaUve --- .changeset/lucky-panthers-brake.md | 2 +- skills/hunk-review/SKILL.md | 5 +- src/hunk-session/cli.test.ts | 1 + src/hunk-session/cli.ts | 9 ++- src/hunk-session/projections.ts | 1 + src/hunk-session/types.ts | 6 ++ src/hunk-session/wire.test.ts | 17 +++++ src/hunk-session/wire.ts | 1 + src/ui/App.tsx | 58 +++++++++------ src/ui/components/panes/AgentInlineNote.tsx | 45 +----------- src/ui/hooks/useHunkSessionBridge.ts | 5 ++ src/ui/hooks/useReviewController.test.tsx | 74 ++++++++++++++++++- src/ui/hooks/useReviewController.ts | 79 ++++++++++++++++----- src/ui/lib/agentNoteGeometry.test.ts | 51 +++++++++++++ src/ui/lib/agentNoteGeometry.ts | 70 ++++++++++++++++++ src/ui/lib/stml/guide.ts | 12 +++- src/ui/lib/stml/layout.ts | 13 ++-- 17 files changed, 351 insertions(+), 98 deletions(-) create mode 100644 src/ui/lib/agentNoteGeometry.test.ts create mode 100644 src/ui/lib/agentNoteGeometry.ts diff --git a/.changeset/lucky-panthers-brake.md b/.changeset/lucky-panthers-brake.md index 04226e79..3e72f44e 100644 --- a/.changeset/lucky-panthers-brake.md +++ b/.changeset/lucky-panthers-brake.md @@ -4,4 +4,4 @@ Agent notes can now carry STML markup — 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. -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. `comment add`/`apply` responses also return `markupNotes` when a comment's markup degraded, so agents get corrective feedback in the write path itself. +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/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index dd46affa..87caca3e 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -119,8 +119,9 @@ hunk session comment clear --repo . --yes [--file README.md] Workflow for good markup: 1. `hunk markup guide` — read once per session; it has copy-paste patterns for gauges, pipelines, scorecards, checklists, and key-value blocks, plus the width rules. -2. `hunk markup render - --width 56` — preview from stdin before publishing; render notes (unknown tags, layout degradations) print to stderr, `--json` gives `{ lines, notes }`. -3. `comment add`/`comment apply` responses include `markupNotes` when the markup degraded — treat any note as a prompt to fix and update the comment. +2. `hunk session context --json` reports `noteMarkupWidth` — the width markup renders at in the session's current layout and terminal size (stack/unified is near full pane, split is about half). +3. `hunk markup render - --width ` — preview from stdin before publishing; render notes (unknown tags, layout degradations) print to stderr, `--json` gives `{ lines, notes }`. Use `--width 56` when no session is running. +4. `comment add`/`comment apply` responses echo `markupWidth` and include `markupNotes` when the markup degraded at that width — treat any note as a prompt to fix and update the comment. Users can resize or switch layouts afterwards, so prefer content that also survives ~56 columns. ## New files in working-tree reviews diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts index 6a44ac8f..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"), diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index 09873760..b3f35983 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -353,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"); @@ -408,9 +409,11 @@ export function formatReloadOutput(selector: SessionSelectorInput, result: Reloa /** Format the STML render notes attached to one applied comment, if any. */ function formatMarkupNotes(result: AppliedCommentResult, indent = "") { - return (result.markupNotes ?? []).map( - (note) => `${indent}Markup note: ${note} (preview with \`hunk markup render\`).`, - ); + 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) { 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 dcd825d4..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,8 @@ 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[]; } @@ -203,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/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 128cba9e..afd27bdf 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -2,6 +2,7 @@ import { createTextAttributes, type TextareaRenderable } from "@opentui/core"; import { flushSync } from "@opentui/react"; import { useEffect, useLayoutEffect, useRef, useState } 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"; @@ -47,10 +48,6 @@ export function agentInlineNoteMarkupLines( return lines.length > 0 ? lines : null; } -function clamp(value: number, min: number, max: number) { - return Math.min(Math.max(value, min), max); -} - function draftLineCount(text: string) { return Math.max(1, text.split("\n").length); } @@ -82,15 +79,6 @@ 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 }; -} - export function measureAgentInlineNoteHeight({ annotation, anchorSide, @@ -102,18 +90,7 @@ 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 { contentWidth } = agentNoteBoxLayout({ anchorSide, layout, width }); if (annotation.source === "user-draft") { // Keep geometry aligned with the rendered textarea rows, including soft wraps. @@ -221,25 +198,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 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 ac0d4d67..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,67 @@ 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"), diff --git a/src/ui/hooks/useReviewController.ts b/src/ui/hooks/useReviewController.ts index b3ff7950..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,8 +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 { validateStmlMarkup } from "../lib/stml/layout"; +import { STML_REFERENCE_WIDTH, validateStmlMarkup } from "../lib/stml/layout"; import { buildReviewState, buildSelectedHunkSummary, @@ -185,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); @@ -555,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( ( @@ -588,7 +635,6 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon selectHunk(file.id, target.hunkIndex); } - const markupNotes = input.markup ? validateStmlMarkup(input.markup) : []; return { commentId, fileId: file.id, @@ -596,10 +642,10 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon hunkIndex: target.hunkIndex, side: target.side, line: target.line, - ...(markupNotes.length > 0 ? { markupNotes } : {}), + ...markupFeedback(input.markup, target.side), }; }, - [allFiles, selectHunk], + [allFiles, markupFeedback, selectHunk], ); /** Apply several live comments together after validating every target first. */ @@ -650,18 +696,15 @@ export function useReviewController({ files }: { files: DiffFile[] }): ReviewCon } return { - applied: prepared.map(({ file, target, liveComment }) => { - const markupNotes = liveComment.markup ? validateStmlMarkup(liveComment.markup) : []; - return { - commentId: liveComment.id, - fileId: file.id, - filePath: file.path, - hunkIndex: target.hunkIndex, - side: target.side, - line: target.line, - ...(markupNotes.length > 0 ? { markupNotes } : {}), - }; - }), + applied: prepared.map(({ file, target, liveComment }) => ({ + commentId: liveComment.id, + fileId: file.id, + filePath: file.path, + hunkIndex: target.hunkIndex, + side: target.side, + line: target.line, + ...markupFeedback(liveComment.markup, target.side), + })), }; }, [allFiles, selectHunk], 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..61100f63 --- /dev/null +++ b/src/ui/lib/agentNoteGeometry.ts @@ -0,0 +1,70 @@ +// 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"; + +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); +} + +/** Column split used by side-by-side diff rows (marker + two code columns). */ +export 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 }; +} + +/** Resolve the note card's box placement for one anchor side and pane width. */ +export function agentNoteBoxLayout({ + anchorSide, + layout, + width, +}: AgentNoteGeometryInput): AgentNoteBoxLayout { + 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 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/guide.ts b/src/ui/lib/stml/guide.ts index 57e05f3f..76f75f1f 100644 --- a/src/ui/lib/stml/guide.ts +++ b/src/ui/lib/stml/guide.ts @@ -26,9 +26,15 @@ Preview before you publish (reads a file or stdin): ## Ground rules -- Design for ~${STML_REFERENCE_WIDTH} columns. Notes are ~terminal-width in stack - layout but docked to roughly half the pane in split layout; text wraps and - code clips to fit. Preview at --width ${STML_REFERENCE_WIDTH} to match the tightest common case. +- Note width follows the live session: stack (unified) layout gives the note + nearly the full pane, split layout docks it to roughly half, and a big + terminal gives a big note. Read the real width instead of guessing: + \`hunk session context --json\` reports \`noteMarkupWidth\`, and every + \`comment add\`/\`apply\` response echoes the \`markupWidth\` it validated + at — preview with \`hunk markup render - --width \`. When the width + is unknowable, design for ~${STML_REFERENCE_WIDTH} columns: content that + fits ${STML_REFERENCE_WIDTH} still looks right wider, and the user can + resize or switch layouts at any time, so avoid designs that only work wide. - There is no chart tag. Gauges and bars are block characters (█ ░) inside color spans — see the gauge pattern below. - Unknown tags and bad colors never crash: they degrade and produce render diff --git a/src/ui/lib/stml/layout.ts b/src/ui/lib/stml/layout.ts index 48070682..7a16fbec 100644 --- a/src/ui/lib/stml/layout.ts +++ b/src/ui/lib/stml/layout.ts @@ -41,15 +41,16 @@ export interface StmlLayoutResult { export const MIN_STML_LAYOUT_WIDTH = 8; /** - * The width agents should design notes for, and the width write-path - * validation uses. Chosen to match the tightest common note body: a split - * layout dock on a typical terminal. Documented in the STML guide. + * 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 the reference width and return its render notes. */ -export function validateStmlMarkup(markup: string): string[] { - return layoutStmlCached(markup, STML_REFERENCE_WIDTH).errors; +/** 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; From 6e12460b4594bbfccc1f15b72934e0eb46269fdd Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 13:23:53 +0000 Subject: [PATCH 4/6] docs(markup): tighten the STML guide for token efficiency The guide is fetched on demand but agents pay its cost every read, so keep the copy-paste snippets (the part prose can't replace) and cut the narration: terser ground rules, one-line pattern headers, style advice softened to a single closing sentence. ~30% smaller. Also slim the always-loaded markup section in the review skill to a short paragraph. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGrhiytMJoffcKg2GiaUve --- skills/hunk-review/SKILL.md | 9 +-- src/ui/lib/stml/guide.ts | 108 ++++++++++++++---------------------- 2 files changed, 44 insertions(+), 73 deletions(-) diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 87caca3e..3ebe686f 100644 --- a/skills/hunk-review/SKILL.md +++ b/skills/hunk-review/SKILL.md @@ -114,14 +114,9 @@ hunk session comment clear --repo . --yes [--file README.md] ### 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: bordered boxes, rows of shapes, gauges, badges, lists, and code blocks. Keep `--summary` a real sentence: it is the fallback and what `comment list` shows. +`--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. -Workflow for good markup: - -1. `hunk markup guide` — read once per session; it has copy-paste patterns for gauges, pipelines, scorecards, checklists, and key-value blocks, plus the width rules. -2. `hunk session context --json` reports `noteMarkupWidth` — the width markup renders at in the session's current layout and terminal size (stack/unified is near full pane, split is about half). -3. `hunk markup render - --width ` — preview from stdin before publishing; render notes (unknown tags, layout degradations) print to stderr, `--json` gives `{ lines, notes }`. Use `--width 56` when no session is running. -4. `comment add`/`comment apply` responses echo `markupWidth` and include `markupNotes` when the markup degraded at that width — treat any note as a prompt to fix and update the comment. Users can resize or switch layouts afterwards, so prefer content that also survives ~56 columns. +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 diff --git a/src/ui/lib/stml/guide.ts b/src/ui/lib/stml/guide.ts index 76f75f1f..564402b8 100644 --- a/src/ui/lib/stml/guide.ts +++ b/src/ui/lib/stml/guide.ts @@ -1,80 +1,68 @@ // The STML authoring guide printed by `hunk markup guide`. // -// This is the canonical teaching artifact for agents writing markup notes, so -// it optimizes for copy-paste: every pattern is a complete, working snippet -// inside a ```stml fence. guide.test.ts extracts each fenced snippet and lays -// it out at the reference width, so the guide can never drift from what the -// renderer actually accepts. +// 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 -STML is a small HTML-like markup rendered as real terminal UI inside Hunk's -inline note cards: bordered boxes, rows of shapes, lists, badges, gauges, and -code blocks instead of plain text. - -Where markup goes (the plain --summary stays as the fallback text): +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 batch items: { "markup": "...", ... } - agent-context sidecar: annotations[].markup + comment apply items: { "markup": "...", ... } + agent-context sidecar: annotations[].markup -Preview before you publish (reads a file or stdin): +Preview from a file or stdin: echo 'OK ready' | hunk markup render - ## Ground rules -- Note width follows the live session: stack (unified) layout gives the note - nearly the full pane, split layout docks it to roughly half, and a big - terminal gives a big note. Read the real width instead of guessing: - \`hunk session context --json\` reports \`noteMarkupWidth\`, and every - \`comment add\`/\`apply\` response echoes the \`markupWidth\` it validated - at — preview with \`hunk markup render - --width \`. When the width - is unknowable, design for ~${STML_REFERENCE_WIDTH} columns: content that - fits ${STML_REFERENCE_WIDTH} still looks right wider, and the user can - resize or switch layouts at any time, so avoid designs that only work wide. -- There is no chart tag. Gauges and bars are block characters (█ ░) inside - color spans — see the gauge pattern below. -- Unknown tags and bad colors never crash: they degrade and produce render - notes. \`comment add\`/\`apply\` return those notes, and \`markup render\` - prints them to stderr — treat any note as a prompt to fix your markup. -- Entities work in text: → renders →, ✓ renders ✓, & renders &. +- 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 - -Attributes on box/card: border, border-style (single|rounded|double|heavy), -border-color, title, title-color, bg, padding / padding-x / padding-y, -width (cells or %). row: gap. list: marker. spacer: size. code: title. - -Colors (fg=/bg=/color=/border-color=): semantic tokens accent, success, -warning, danger, info, muted, subtle, heading — these follow the user's Hunk -theme, so prefer them. ANSI-ish names (red, green, orange, …) and #hex also -work. +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 — verdict up front +Status line: \`\`\`stml PASS 34 tests · TODO add jitter · reviewed by fable \`\`\` -### Titled card — one framed takeaway +Titled card: \`\`\`stml - Retries are capped at 3 attempts; the last error is rethrown, so - callers see the same failure mode as before. + Retries cap at 3 attempts; the last error is rethrown. \`\`\` -### Scorecard — a row of titled boxes +Scorecard row: \`\`\`stml @@ -87,71 +75,59 @@ work. \`\`\` -### Gauges — block characters in color spans - -Pick a fixed bar budget (~20 cells), split it filled/empty, label the end. +Gauges (fixed bar budget ~20 cells, filled/empty split, label at the end): \`\`\`stml coverage ████████████████░░░░ 80% p95 ███████░░░░░░░░░░░░░ 340ms -risk ████░░░░░░░░░░░░░░░░ low \`\`\` -### Pipeline — boxes joined by arrow columns - -The
pushes each arrow down one row so it aligns with the box body. +Pipeline (the
drops each arrow to the boxes' middle row): \`\`\`stml parse
- layout -
render
\`\`\` -### Checklist — badges as row markers +Checklist: \`\`\`stml DONE bounded retry loop - TODO add jitter to the backoff + TODO add jitter RISK delayMs grows unbounded \`\`\` -### Key-value block — fixed label column +Key-value block: \`\`\`stml - attempts
base delay
growth
- 3 max
100ms
×2 per attempt
+ attempts
base delay
+ 3 max
100ms
\`\`\` -### Code suggestion — verbatim, clipped, framed +Code suggestion (verbatim; clips, never wraps): \`\`\`stml -Consider extracting the policy: const backoff = (attempt: number) => 100 * 2 ** (attempt - 1); \`\`\` -### Keyboard hints +Keyboard hints: \`\`\`stml -press a to toggle notes, [ ] to jump hunks +press a to toggle notes \`\`\` -## Taste - -- Lead with the verdict (badge or heading), then evidence. Reviewers scan. -- One idea per note; two or three blocks max. A note is a callout, not a page. -- Use semantic color tokens for meaning, not decoration — danger means danger. -- Keep --summary a real sentence: it is what note lists and fallbacks show. +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. */ From 6b49ad9c60c539a5b85e164c4bdbddfb01b1481e Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 14:52:53 +0000 Subject: [PATCH 5/6] docs(markup): mark STML experimental and record its architecture stance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Stamp the guide and changeset so the tag/color vocabulary stays free to change while adoption is unproven, and add a CLAUDE.md bullet explaining why the layout engine is deterministic line layout rather than flexbox — so a fresh-context agent doesn't simplify it into broken scroll geometry. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGrhiytMJoffcKg2GiaUve --- .changeset/lucky-panthers-brake.md | 2 +- AGENTS.md | 1 + src/ui/lib/stml/guide.ts | 3 +++ 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/.changeset/lucky-panthers-brake.md b/.changeset/lucky-panthers-brake.md index 3e72f44e..a960392b 100644 --- a/.changeset/lucky-panthers-brake.md +++ b/.changeset/lucky-panthers-brake.md @@ -2,6 +2,6 @@ "hunkdiff": minor --- -Agent notes can now carry STML markup — 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. +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/src/ui/lib/stml/guide.ts b/src/ui/lib/stml/guide.ts index 564402b8..21418723 100644 --- a/src/ui/lib/stml/guide.ts +++ b/src/ui/lib/stml/guide.ts @@ -11,6 +11,9 @@ 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): From ec827a4995e13d774edc1014847cd3a577f25c56 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 4 Jul 2026 15:45:37 +0000 Subject: [PATCH 6/6] refactor(notes): deduplicate note-card and STML render paths Four consolidations, net -60 lines with identical behavior: the note card reuses the diff view's resolveSplitPaneWidths instead of a private copy of the same split math; the summary/rationale body is built by one helper shared by measurement and rendering; the card body row frame is one function that markup and plain rows both fill; and the UTF-8 byte limit helpers in the STML parser use TextEncoder/TextDecoder instead of hand-rolled codepoint math. The plain/ANSI headless renderers now share one line walker parameterized by a span formatter. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01UGrhiytMJoffcKg2GiaUve --- src/ui/components/panes/AgentInlineNote.tsx | 130 ++++++++------------ src/ui/lib/agentNoteGeometry.ts | 14 +-- src/ui/lib/stml/parse.ts | 34 +---- src/ui/lib/stml/render.ts | 32 ++--- 4 files changed, 77 insertions(+), 133 deletions(-) diff --git a/src/ui/components/panes/AgentInlineNote.tsx b/src/ui/components/panes/AgentInlineNote.tsx index afd27bdf..d2e3a13f 100644 --- a/src/ui/components/panes/AgentInlineNote.tsx +++ b/src/ui/components/panes/AgentInlineNote.tsx @@ -1,6 +1,6 @@ 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"; @@ -79,6 +79,25 @@ function wrapNoteText(text: string, width: number) { return text.split("\n").flatMap((line) => wrapText(sanitizeTerminalLine(line), width)); } +/** 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({ annotation, anchorSide, @@ -98,26 +117,12 @@ export function measureAgentInlineNoteHeight({ } const markupLines = agentInlineNoteMarkupLines(annotation, contentWidth); - if (markupLines) { - // top border + top padding + markup lines + bottom border - return 3 + markupLines.length; - } - - 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 bodyLineCount = markupLines + ? markupLines.length + : agentInlineNoteBodyLines(annotation, contentWidth).length; - // top border + title row + body lines + bottom border - return 3 + lines.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. */ @@ -234,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), @@ -500,45 +494,8 @@ export function AgentInlineNote({ }), }); - const renderMarkupBodyRow = (key: string, line: StmlLine) => { - const usedWidth = line.spans.reduce((total, span) => total + measureTextWidth(span.text), 0); - return ( - - - {" ".repeat(boxLeft)} - - - - │ - - - - - - {line.spans.map((span, spanIndex) => ( - - {span.text} - - ))} - {usedWidth < contentWidth ? ( - {" ".repeat(contentWidth - usedWidth)} - ) : null} - - - - - - │ - - - - ); - }; - - const renderSavedBodyRow = (key: string, text: string, kind: AgentInlineNoteLine["kind"]) => ( + /** One card body row: left offset, side borders, and a one-line content cell. */ + const renderBodyRow = (key: string, content: ReactNode) => ( - - - {padText(text, contentWidth)} - - + {content} @@ -566,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 ( diff --git a/src/ui/lib/agentNoteGeometry.ts b/src/ui/lib/agentNoteGeometry.ts index 61100f63..dbb1f3b8 100644 --- a/src/ui/lib/agentNoteGeometry.ts +++ b/src/ui/lib/agentNoteGeometry.ts @@ -7,6 +7,7 @@ // 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"; @@ -28,23 +29,14 @@ function clamp(value: number, min: number, max: number) { return Math.min(Math.max(value, min), max); } -/** Column split used by side-by-side diff rows (marker + two code columns). */ -export 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 }; -} - /** Resolve the note card's box placement for one anchor side and pane width. */ export function agentNoteBoxLayout({ anchorSide, layout, width, }: AgentNoteGeometryInput): AgentNoteBoxLayout { - const splitWidths = splitColumnWidths(width); + // 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 diff --git a/src/ui/lib/stml/parse.ts b/src/ui/lib/stml/parse.ts index 1d18424c..f9ac7538 100644 --- a/src/ui/lib/stml/parse.ts +++ b/src/ui/lib/stml/parse.ts @@ -271,39 +271,13 @@ function limitedErrorCollector(errors: string[], maxErrors: number): (message: s } function utf8ByteLength(text: string): number { - let bytes = 0; - for (const ch of text) { - bytes += utf8CharBytes(ch); - } - return bytes; + return new TextEncoder().encode(text).length; } function truncateUtf8(text: string, maxBytes: number): string { - let bytes = 0; - let out = ""; - for (const ch of text) { - const next = bytes + utf8CharBytes(ch); - if (next > maxBytes) { - break; - } - bytes = next; - out += ch; - } - return out; -} - -function utf8CharBytes(ch: string): number { - const code = ch.codePointAt(0) ?? 0; - if (code <= 0x7f) { - return 1; - } - if (code <= 0x7ff) { - return 2; - } - if (code <= 0xffff) { - return 3; - } - return 4; + 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 { diff --git a/src/ui/lib/stml/render.ts b/src/ui/lib/stml/render.ts index e20b7247..272272a3 100644 --- a/src/ui/lib/stml/render.ts +++ b/src/ui/lib/stml/render.ts @@ -4,7 +4,7 @@ import type { AppTheme } from "../../themes"; import { resolveStmlColor } from "./colors"; -import { layoutStml, type StmlLine, type StmlSpan } from "./layout"; +import { layoutStml, type StmlSpan } from "./layout"; export interface StmlTextRenderResult { /** One string per terminal row. */ @@ -13,17 +13,22 @@ export interface StmlTextRenderResult { errors: string[]; } -function lineToPlainText(line: StmlLine): string { - return line.spans - .map((span) => span.text) - .join("") - .replace(/\s+$/, ""); +/** 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 { - const { lines, errors } = layoutStml(markup, width); - return { lines: lines.map(lineToPlainText), errors }; + return renderLines(markup, width, (span) => span.text); } function hexToRgb(color: string): [number, number, number] | null { @@ -82,13 +87,8 @@ export function renderStmlToAnsi( width: number, theme: AppTheme, ): StmlTextRenderResult { - const { lines, errors } = layoutStml(markup, width); - const rendered = lines.map((line) => { - const parts = line.spans.map((span) => { - const sgr = spanSgr(span, theme); - return sgr ? `${sgr}${span.text}\x1b[0m` : span.text; - }); - return parts.join("").replace(/\s+$/, ""); + return renderLines(markup, width, (span) => { + const sgr = spanSgr(span, theme); + return sgr ? `${sgr}${span.text}\x1b[0m` : span.text; }); - return { lines: rendered, errors }; }