Skip to content
Open
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/lucky-panthers-brake.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
"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.

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 (<file> | -)` 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.
36 changes: 36 additions & 0 deletions examples/9-agent-markup-notes/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# 9 — agent markup notes (STML)

Shows agent notes that carry **STML markup** — a small HTML-like markup that
Hunk renders as real terminal UI inside the inline note card: bordered boxes,
rows of shapes, lists, badges, and code blocks instead of plain text.

Run from the repository root:

```sh
hunk patch examples/9-agent-markup-notes/change.patch \
--agent-context examples/9-agent-markup-notes/agent-context.json
```

Press `a` to reveal the agent notes for the selected hunk.

The same markup works for live comments from an agent driving a session:

```sh
hunk session comment add --repo . --file src/retry.ts --new-line 3 \
--summary "Retry flow" \
--markup '<box border border-color="accent">shapes in a note</box>' \
--focus
```

Learn and iterate from the CLI:

```sh
hunk markup guide # authoring guide with copy-paste patterns
echo '<badge color="success">OK</badge> 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.
15 changes: 15 additions & 0 deletions examples/9-agent-markup-notes/after/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
export async function fetchWithRetry(url: string, attempts = 3): Promise<Response> {
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");
}
24 changes: 24 additions & 0 deletions examples/9-agent-markup-notes/agent-context.json
Original file line number Diff line number Diff line change
@@ -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": "<h2>Retry flow</h2><row gap=\"1\"><box border border-color=\"accent\" padding-x=\"1\">fetch</box><box border border-color=\"warning\" padding-x=\"1\">fail?</box><box border border-color=\"success\" padding-x=\"1\">backoff ×2</box></row><spacer/><list><item><badge color=\"success\">OK</badge> caps at <b>3 attempts</b> before rethrowing</item><item><badge color=\"warning\">TODO</badge> add <i>jitter</i> before shipping this</item></list>"
},
{
"newRange": [10, 11],
"summary": "Backoff policy could be extracted for reuse and testing.",
"author": "sonnet",
"markup": "<text>The doubling is inline; consider extracting the policy:</text><code>const backoff = (attempt: number) =>\n 100 * 2 ** (attempt - 1);</code><text><dim>Keeps the loop readable and lets tests pin delays.</dim></text>"
}
]
}
]
}
3 changes: 3 additions & 0 deletions examples/9-agent-markup-notes/before/retry.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
export async function fetchOnce(url: string): Promise<Response> {
return fetch(url);
}
22 changes: 22 additions & 0 deletions examples/9-agent-markup-notes/change.patch
Original file line number Diff line number Diff line change
@@ -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<Response> {
- return fetch(url);
+export async function fetchWithRetry(url: string, attempts = 3): Promise<Response> {
+ 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");
}
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 7 additions & 1 deletion skills/hunk-review/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 "<stml>"] [--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 . <comment-id>
Expand All @@ -112,6 +112,12 @@ hunk session comment clear --repo . --yes [--file README.md]
- `comment list` and `comment clear` accept optional `--file`
- Quote `--summary` and `--rationale` defensively in the shell

### Rich markup notes (STML)

`--markup` (or a `markup` field on apply items) renders the note body as STML — a small HTML-like markup for terminal UI (boxes, rows, gauges, badges, lists, code). Keep `--summary` a real sentence: it is the fallback and the `comment list` text.

Before writing markup, run `hunk markup guide` once — it has copy-paste patterns and the width rules. `hunk session context --json` reports `noteMarkupWidth` (the live render width); preview with `hunk markup render - --width <that>`. Comment responses echo `markupWidth` and return `markupNotes` when markup degraded — fix what they flag.

## New files in working-tree reviews

`hunk diff` includes untracked files by default. If the user wants tracked changes only, reload with `--exclude-untracked`:
Expand Down
1 change: 1 addition & 0 deletions src/core/agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
78 changes: 78 additions & 0 deletions src/core/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -542,6 +542,84 @@ describe("parseCli", () => {
});
});

test("parses markup render with defaults and options", async () => {
expect(await parseCli(["bun", "hunk", "markup", "render"])).toEqual({
kind: "markup-render",
file: "-",
width: 56,
color: "auto",
theme: undefined,
json: false,
});

expect(
await parseCli([
"bun",
"hunk",
"markup",
"render",
"note.stml",
"--width",
"72",
"--color",
"never",
"--theme",
"midnight",
"--json",
]),
).toEqual({
kind: "markup-render",
file: "note.stml",
width: 72,
color: "never",
theme: "midnight",
json: true,
});
});

test("rejects invalid markup render color modes and unknown markup subcommands", async () => {
await expect(
parseCli(["bun", "hunk", "markup", "render", "-", "--color", "sometimes"]),
).rejects.toThrow("--color must be auto, always, or never.");
await expect(parseCli(["bun", "hunk", "markup", "bogus"])).rejects.toThrow(
"Supported markup subcommands are render and guide.",
);
});

test("parses markup guide", async () => {
expect(await parseCli(["bun", "hunk", "markup", "guide"])).toEqual({ kind: "markup-guide" });
});

test("parses session comment add with --markup", async () => {
const parsed = await parseCli([
"bun",
"hunk",
"session",
"comment",
"add",
"session-1",
"--file",
"README.md",
"--new-line",
"7",
"--summary",
"Rendered note",
"--markup",
"<box border><b>hot path</b></box>",
]);

expect(parsed).toMatchObject({
kind: "session",
action: "comment-add",
selector: { sessionId: "session-1" },
filePath: "README.md",
side: "new",
line: 7,
summary: "Rendered note",
markup: "<box border><b>hot path</b></box>",
});
});

test("parses session comment add with --focus", async () => {
const parsed = await parseCli([
"bun",
Expand Down
95 changes: 94 additions & 1 deletion src/core/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,8 @@ function renderCliHelp() {
" hunk pager general Git pager wrapper with diff detection",
" hunk difftool <left> <right> [path] review Git difftool file pairs",
" hunk session <subcommand> inspect or control a live Hunk session",
" hunk markup render (<file> | -) 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",
"",
Expand Down Expand Up @@ -305,6 +307,7 @@ function parseSessionCommentApplyPayload(raw: string): SessionCommentApplyItemIn
line: oldLine ?? newLine,
summary,
rationale: typeof item.rationale === "string" ? item.rationale : undefined,
markup: typeof item.markup === "string" && item.markup.length > 0 ? item.markup : undefined,
author: typeof item.author === "string" ? item.author : undefined,
};
});
Expand Down Expand Up @@ -585,7 +588,13 @@ async function parseDifftoolCommand(tokens: string[], argv: string[]): Promise<P
}

function requireReloadableCliInput(input: ParsedCliInput): CliInput {
if (input.kind === "help" || input.kind === "pager" || input.kind === "daemon-serve") {
if (
input.kind === "help" ||
input.kind === "pager" ||
input.kind === "daemon-serve" ||
input.kind === "markup-render" ||
input.kind === "markup-guide"
) {
throw new Error(
"Session reload requires a Hunk review command after --, such as `diff` or `show`.",
);
Expand Down Expand Up @@ -919,6 +928,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
.option("--old-line <n>", "1-based line number on the old side", parsePositiveInt)
.option("--new-line <n>", "1-based line number on the new side", parsePositiveInt)
.option("--rationale <text>", "optional longer explanation")
.option("--markup <stml>", "optional STML markup rendered as the note body")
.option("--author <name>", "optional author label")
.option("--focus", "add the note and focus the viewport on it")
.option("--json", "emit structured JSON");
Expand All @@ -931,6 +941,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
oldLine?: number;
newLine?: number;
rationale?: string;
markup?: string;
author?: string;
focus?: boolean;
json?: boolean;
Expand All @@ -949,6 +960,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
oldLine?: number;
newLine?: number;
rationale?: string;
markup?: string;
author?: string;
focus?: boolean;
json?: boolean;
Expand Down Expand Up @@ -983,6 +995,7 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0,
summary: parsedOptions.summary,
rationale: parsedOptions.rationale,
markup: parsedOptions.markup,
author: parsedOptions.author,
reveal: parsedOptions.focus ?? false,
};
Expand Down Expand Up @@ -1216,6 +1229,84 @@ async function parseSessionCommand(tokens: string[]): Promise<ParsedCliInput> {
throw new Error(`Unknown session command: ${subcommand}`);
}

const MARKUP_HELP = [
"Usage:",
" hunk markup render (<file> | -) [--width <n>] [--color <auto|always|never>] [--theme <id>] [--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 <n> layout width in columns (default 56, the note reference width)",
" --color auto (default: color when stdout is a TTY), always, or never",
" --theme <id> 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<ParsedCliInput> {
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 <n>", "layout width in columns", parsePositiveInt)
.option("--color <mode>", "auto, always, or never", "auto")
.option("--theme <id>", "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<HelpCommandInput> {
const [subcommand, ...rest] = tokens;
Expand Down Expand Up @@ -1364,6 +1455,8 @@ export async function parseCli(argv: string[]): Promise<ParsedCliInput> {
return parseStashCommand(rest, argv);
case "session":
return parseSessionCommand(rest);
case "markup":
return parseMarkupCommand(rest);
case "skill":
return parseSkillCommand(rest);
case "daemon":
Expand Down
Loading