diff --git a/.changeset/legal-glasses-guess.md b/.changeset/legal-glasses-guess.md new file mode 100644 index 00000000..0b850b7b --- /dev/null +++ b/.changeset/legal-glasses-guess.md @@ -0,0 +1,5 @@ +--- +"hunkdiff": patch +--- + +Require reviews to opt into experimental STML agent-note rendering with `--experimental`. Normal reviews now use plain-text note fallbacks and reject live STML comments, while opted-in live sessions advertise the `stml` capability to agents. diff --git a/README.md b/README.md index 5014239e..81e09997 100644 --- a/README.md +++ b/README.md @@ -94,7 +94,7 @@ A good generic prompt is: Load the Hunk skill and use it for this review. Run `hunk skill path` to get the skill path. ``` -For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md). +For the full live-session and `--agent-context` workflow guide, see [docs/agent-workflows.md](docs/agent-workflows.md). Experimental rich STML note bodies require starting the review with `--experimental`; plain agent notes remain the default. ## Feature comparison diff --git a/docs/agent-workflows.md b/docs/agent-workflows.md index e7cf2cc8..4dbcfa84 100644 --- a/docs/agent-workflows.md +++ b/docs/agent-workflows.md @@ -139,6 +139,17 @@ hunk patch change.patch --agent-context notes.json For a compact real example, see [`examples/3-agent-review-demo/agent-context.json`](../examples/3-agent-review-demo/agent-context.json). +## Opt into experimental rich notes + +STML note bodies are experimental and disabled by default. Start a new review with `--experimental` to render sidecar `markup` fields and accept live comments that carry markup: + +```bash +hunk --experimental diff --agent-context notes.json +# Equivalent: hunk diff --experimental --agent-context notes.json +``` + +Normal reviews keep using each annotation's required plain-text `summary` fallback. Opted-in live sessions list `stml` in `hunk session context --json` under `experimentalFeatures`; reload commands cannot change the launch opt-in. + ## Practical defaults - start with `hunk session review --repo . --json` diff --git a/skills/hunk-review/SKILL.md b/skills/hunk-review/SKILL.md index 3ebe686f..3b7529ab 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 "..."] [--markup ""] [--author "agent"] [--focus] +hunk session comment add --repo . --file README.md --new-line 103 --summary "Tighten this wording" [--rationale "..."] [--author "agent"] [--focus] printf '%s\n' '{"comments":[{"filePath":"README.md","newLine":103,"summary":"Tighten this wording"}]}' | hunk session comment apply --repo . --stdin [--focus] hunk session comment list --repo . [--file README.md] [--type live|all|ai|agent|user] hunk session comment rm --repo . @@ -112,11 +112,13 @@ 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) +### Experimental 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. +Only use STML when `hunk session context --json` lists `stml` in `experimentalFeatures`. The user opts into that experience by launching the review with `--experimental`; do not ask a normal session to render markup. -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. +For an opted-in session, `--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. The session context also 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/core/cli.test.ts b/src/core/cli.test.ts index b691bea2..346c2708 100644 --- a/src/core/cli.test.ts +++ b/src/core/cli.test.ts @@ -56,6 +56,8 @@ describe("parseCli", () => { expect(parsed.text).toContain("Global options:"); expect(parsed.text).toContain("Common review options:"); expect(parsed.text).toContain("auto-reload when the current diff input changes"); + expect(parsed.text).toContain("--experimental"); + expect(parsed.text).toContain("experimental STML"); expect(parsed.text).toContain("Git diff options:"); expect(parsed.text).toContain("Notes:"); expect(parsed.text).toContain( @@ -104,6 +106,7 @@ describe("parseCli", () => { "--agent-notes", "--transparent-bg", "--watch", + "--experimental", ]); expect(parsed).toMatchObject({ @@ -115,6 +118,7 @@ describe("parseCli", () => { theme: "github-light-default", agentContext: "notes.json", watch: true, + experimental: true, lineNumbers: false, tabWidth: 4, wrapLines: true, @@ -125,6 +129,15 @@ describe("parseCli", () => { }); }); + test("accepts --experimental before the review command", async () => { + const parsed = await parseCli(["bun", "hunk", "--experimental", "diff"]); + + expect(parsed).toMatchObject({ + kind: "vcs", + options: { experimental: true }, + }); + }); + test("parses transparent background toggles", async () => { const transparent = await parseCli(["bun", "hunk", "diff", "--transparent-bg"]); const opaque = await parseCli(["bun", "hunk", "diff", "--no-transparent-bg"]); @@ -1198,6 +1211,12 @@ describe("parseCli argument validation", () => { ).rejects.toThrow("Specify either or --repo , not both."); }); + test("rejects --experimental for non-review commands", async () => { + await expect(parseCli(["bun", "hunk", "--experimental", "session", "list"])).rejects.toThrow( + "must be used with a Hunk review command", + ); + }); + test("rejects unknown top-level, skill, daemon, stash, and comment subcommands", async () => { await expect(parseCli(["bun", "hunk", "skill", "bogus"])).rejects.toThrow( "Only `hunk skill path` is supported.", diff --git a/src/core/cli.ts b/src/core/cli.ts index 35594801..8e8892a3 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -65,6 +65,7 @@ function buildCommonOptions( agentContext?: string; pager?: boolean; watch?: boolean; + experimental?: boolean; transparentBackground?: boolean; tabWidth?: number; }, @@ -76,6 +77,7 @@ function buildCommonOptions( agentContext: options.agentContext, pager: options.pager ? true : undefined, watch: options.watch ? true : undefined, + experimental: options.experimental || argv[2] === "--experimental" ? true : undefined, excludeUntracked: resolveBooleanFlag(argv, "--exclude-untracked", "--no-exclude-untracked"), lineNumbers: resolveBooleanFlag(argv, "--line-numbers", "--no-line-numbers"), tabWidth: options.tabWidth, @@ -93,6 +95,7 @@ function applyCommonOptions(command: Command) { .option("--theme ", "named theme override") .option("--agent-context ", "JSON sidecar with agent rationale") .option("--pager", "use pager-style chrome and controls") + .option("--experimental", "enable experimental features (currently STML agent-note markup)") .option("--line-numbers", "show line numbers") .option("--no-line-numbers", "hide line numbers") .option("-x, --tab-width ", "tab stop width: 1-16", parseTabWidth) @@ -149,14 +152,15 @@ 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 markup render ( | -) preview experimental STML note markup", + " hunk markup guide print the experimental STML authoring guide", " hunk skill path print the bundled Hunk review skill path", " hunk daemon serve run the local Hunk session daemon", "", "Global options:", " -h, --help show help", " -v, --version show version", + " --experimental enable experimental review features (currently STML)", "", "Common review options:", " --mode layout mode: auto, split, stack", @@ -937,7 +941,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("--markup ", "experimental STML body (target session must opt in)") .option("--author ", "optional author label") .option("--focus", "add the note and focus the viewport on it") .option("--json", "emit structured JSON"); @@ -1243,6 +1247,8 @@ const MARKUP_HELP = [ " hunk markup render ( | -) [--width ] [--color ] [--theme ] [--json]", " hunk markup guide", "", + "Experimental STML authoring tools.", + "", "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)", @@ -1272,7 +1278,7 @@ async function parseMarkupCommand(tokens: string[]): Promise { if (subcommand === "render") { const command = new Command("markup render") - .description("preview STML markup as terminal text") + .description("preview experimental 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") @@ -1438,7 +1444,9 @@ async function parseStashCommand(tokens: string[], argv: string[]): Promise { - const args = argv.slice(2); + const rawArgs = argv.slice(2); + const prefixedExperimental = rawArgs[0] === "--experimental"; + const args = prefixedExperimental ? rawArgs.slice(1) : rawArgs; const [commandName, ...rest] = args; if (!commandName || commandName === "help" || commandName === "--help" || commandName === "-h") { @@ -1449,6 +1457,13 @@ export async function parseCli(argv: string[]): Promise { return { kind: "help", text: renderCliVersion() }; } + if ( + prefixedExperimental && + !["diff", "show", "patch", "pager", "difftool", "stash"].includes(commandName) + ) { + throw new Error("`--experimental` must be used with a Hunk review command."); + } + switch (commandName) { case "diff": return parseDiffCommand(rest, argv); diff --git a/src/core/config.test.ts b/src/core/config.test.ts index e9562a13..23c85ee7 100644 --- a/src/core/config.test.ts +++ b/src/core/config.test.ts @@ -416,6 +416,22 @@ describe("config resolution", () => { ).toThrow('Expected a [custom_theme] table when config selects theme = "custom".'); }); + test("requires experimental features to be enabled by the launch CLI", () => { + const home = createTempDir("hunk-config-experimental-"); + mkdirSync(join(home, ".config", "hunk"), { recursive: true }); + writeFileSync(join(home, ".config", "hunk", "config.toml"), "experimental = true\n"); + + const normal = resolveConfiguredCliInput(createPatchPagerInput(), { + env: { HOME: home }, + }); + const optedIn = resolveConfiguredCliInput(createPatchPagerInput({ experimental: true }), { + env: { HOME: home }, + }); + + expect(normal.input.options.experimental).toBe(false); + expect(optedIn.input.options.experimental).toBe(true); + }); + test("accepts transparent background config and CLI overrides", () => { const home = createTempDir("hunk-config-home-"); mkdirSync(join(home, ".config", "hunk"), { recursive: true }); diff --git a/src/core/config.ts b/src/core/config.ts index 7f36bbd5..219aef6c 100644 --- a/src/core/config.ts +++ b/src/core/config.ts @@ -358,6 +358,7 @@ function mergeOptions(base: CommonOptions, overrides: CommonOptions): CommonOpti agentContext: overrides.agentContext ?? base.agentContext, pager: overrides.pager ?? base.pager, watch: overrides.watch ?? base.watch, + experimental: overrides.experimental ?? base.experimental, excludeUntracked: overrides.excludeUntracked ?? base.excludeUntracked, lineNumbers: overrides.lineNumbers ?? base.lineNumbers, tabWidth: overrides.tabWidth ?? base.tabWidth, @@ -525,6 +526,7 @@ export function resolveConfiguredCliInput( agentContext: input.options.agentContext, pager: input.options.pager ?? false, watch: input.options.watch ?? false, + experimental: false, excludeUntracked: false, lineNumbers: DEFAULT_VIEW_PREFERENCES.showLineNumbers, tabWidth: DEFAULT_TAB_WIDTH, @@ -559,6 +561,7 @@ export function resolveConfiguredCliInput( agentContext: input.options.agentContext, pager: input.options.pager ?? false, watch: input.options.watch ?? resolvedOptions.watch ?? false, + experimental: input.options.experimental ?? false, excludeUntracked: resolvedOptions.excludeUntracked ?? false, theme: resolvedOptions.theme, vcs: resolvedOptions.vcs ?? getDefaultVcsAdapter().id, diff --git a/src/core/experimental.test.ts b/src/core/experimental.test.ts new file mode 100644 index 00000000..a0d034b2 --- /dev/null +++ b/src/core/experimental.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, test } from "bun:test"; +import { createTestAgentFileContext, createTestDiffFile } from "../../test/helpers/diff-helpers"; +import { resolveExperimentalDiffFiles, resolveExperimentalFeatures } from "./experimental"; + +describe("experimental review features", () => { + test("normal reviews derive plain-text note fallbacks without mutating sidecar data", () => { + const agent = createTestAgentFileContext("example.ts", { + annotations: [ + { + newRange: [1, 1], + summary: "Updated the answer.", + markup: "42", + }, + ], + }); + const files = [createTestDiffFile({ agent })]; + + const resolved = resolveExperimentalDiffFiles(files, {}); + + expect(resolved[0]?.agent?.annotations[0]?.summary).toBe("Updated the answer."); + expect(resolved[0]?.agent?.annotations[0]?.markup).toBeUndefined(); + expect(files[0]?.agent?.annotations[0]?.markup).toBe("42"); + }); + + test("experimental reviews preserve STML and advertise the feature", () => { + const files = [ + createTestDiffFile({ + agent: createTestAgentFileContext("example.ts", { + annotations: [{ summary: "Updated", markup: "42" }], + }), + }), + ]; + + expect(resolveExperimentalDiffFiles(files, { experimental: true })).toBe(files); + expect(resolveExperimentalFeatures({ experimental: true })).toEqual(["stml"]); + expect(resolveExperimentalFeatures({})).toEqual([]); + }); +}); diff --git a/src/core/experimental.ts b/src/core/experimental.ts new file mode 100644 index 00000000..1db639be --- /dev/null +++ b/src/core/experimental.ts @@ -0,0 +1,60 @@ +import type { AgentContext, AgentFileContext, CommonOptions, DiffFile } from "./types"; + +export const EXPERIMENTAL_FEATURES = ["stml"] as const; +export type ExperimentalFeature = (typeof EXPERIMENTAL_FEATURES)[number]; + +/** Resolve the experimental features enabled for one review launch. */ +export function resolveExperimentalFeatures( + options: Pick, +): ExperimentalFeature[] { + return options.experimental ? [...EXPERIMENTAL_FEATURES] : []; +} + +/** Return whether one experimental feature is enabled for this review launch. */ +export function experimentalFeatureEnabled( + options: Pick, + feature: ExperimentalFeature, +) { + return options.experimental === true && EXPERIMENTAL_FEATURES.includes(feature); +} + +/** Remove disabled STML bodies from one file while preserving plain-text fallbacks. */ +function resolveExperimentalAgentFileContext(file: AgentFileContext): AgentFileContext { + return { + ...file, + annotations: file.annotations.map((annotation) => { + const resolved = { ...annotation }; + delete resolved.markup; + return resolved; + }), + }; +} + +/** Remove disabled STML bodies while preserving their required plain-text fallbacks. */ +export function resolveExperimentalAgentContext( + context: AgentContext | null, + options: Pick, +): AgentContext | null { + if (!context || experimentalFeatureEnabled(options, "stml")) { + return context; + } + + return { + ...context, + files: context.files.map(resolveExperimentalAgentFileContext), + }; +} + +/** Derive review files whose annotation bodies match the launch's enabled features. */ +export function resolveExperimentalDiffFiles( + files: DiffFile[], + options: Pick, +): DiffFile[] { + if (experimentalFeatureEnabled(options, "stml")) { + return files; + } + + return files.map((file) => + file.agent ? { ...file, agent: resolveExperimentalAgentFileContext(file.agent) } : file, + ); +} diff --git a/src/core/types.ts b/src/core/types.ts index 1ad64f35..d02a1619 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -89,6 +89,8 @@ export interface CommonOptions { agentContext?: string; pager?: boolean; watch?: boolean; + /** Enable launch-scoped experimental review features. */ + experimental?: boolean; excludeUntracked?: boolean; lineNumbers?: boolean; tabWidth?: number; diff --git a/src/hunk-session/cli.test.ts b/src/hunk-session/cli.test.ts index 53a13269..2c833eef 100644 --- a/src/hunk-session/cli.test.ts +++ b/src/hunk-session/cli.test.ts @@ -355,13 +355,28 @@ describe("Hunk session CLI formatters", () => { "Old range: -", "New range: -", "Agent notes visible: yes", - "Note markup width: -", "Live comments: 2", "", ].join("\n"), ); }); + test("context output exposes STML geometry only for opted-in sessions", () => { + const normal = createTestSelectedSessionContext({ + experimentalFeatures: [], + noteMarkupWidth: undefined, + }); + const experimental = createTestSelectedSessionContext({ + experimentalFeatures: ["stml"], + noteMarkupWidth: 72, + }); + + expect(formatContextOutput(normal)).not.toContain("markup"); + expect(formatContextOutput(normal)).not.toContain("Experimental features"); + expect(formatContextOutput(experimental)).toContain("Experimental features: stml\n"); + expect(formatContextOutput(experimental)).toContain("Note markup width: 72\n"); + }); + test("review output keeps file order, hunk headers, and no-selection fallbacks", () => { const firstFile = createTestSessionReviewFile({ id: "file-1", diff --git a/src/hunk-session/cli.ts b/src/hunk-session/cli.ts index b3f35983..6dba90e5 100644 --- a/src/hunk-session/cli.ts +++ b/src/hunk-session/cli.ts @@ -316,6 +316,9 @@ export function formatSessionOutput(session: ListedSession) { `Path: ${session.cwd}`, `Repo: ${session.repoRoot ?? "-"}`, `Input: ${session.inputKind}`, + ...((session.experimentalFeatures?.length ?? 0) > 0 + ? [`Experimental features: ${session.experimentalFeatures!.join(", ")}`] + : []), `Launched: ${session.launchedAt}`, ...formatTerminalLines(terminal, { headerLabel: "Terminal", @@ -353,7 +356,12 @@ export function formatContextOutput(context: SelectedSessionContext) { `Old range: ${oldRange}`, `New range: ${newRange}`, `Agent notes visible: ${context.showAgentNotes ? "yes" : "no"}`, - `Note markup width: ${context.noteMarkupWidth ?? "-"}`, + ...(context.experimentalFeatures?.includes("stml") + ? [ + `Experimental features: ${context.experimentalFeatures.join(", ")}`, + `Note markup width: ${context.noteMarkupWidth ?? "-"}`, + ] + : []), `Live comments: ${context.liveCommentCount}`, "", ].join("\n"); @@ -371,6 +379,9 @@ export function formatReviewOutput(review: SessionReview) { `Path: ${review.cwd ?? "-"}`, `Repo: ${review.repoRoot ?? "-"}`, `Input: ${review.inputKind}`, + ...((review.experimentalFeatures?.length ?? 0) > 0 + ? [`Experimental features: ${review.experimentalFeatures!.join(", ")}`] + : []), `Selected file: ${selectedFile}`, `Selected hunk: ${hunkNumber}`, `Agent notes visible: ${review.showAgentNotes ? "yes" : "no"}`, diff --git a/src/hunk-session/projections.test.ts b/src/hunk-session/projections.test.ts index 6447613d..eb263fcf 100644 --- a/src/hunk-session/projections.test.ts +++ b/src/hunk-session/projections.test.ts @@ -23,6 +23,7 @@ describe("hunk session projections", () => { test("buildListedHunkSession keeps terminal metadata and file summaries", () => { const entry = { registration: createTestSessionRegistration({ + experimentalFeatures: ["stml"], terminal: { program: "iTerm.app", locations: [{ source: "tty", tty: "/dev/ttys003" }], @@ -34,6 +35,7 @@ describe("hunk session projections", () => { expect(buildListedHunkSession(entry)).toEqual( expect.objectContaining({ terminal: entry.registration.terminal, + experimentalFeatures: ["stml"], fileCount: 1, files: [expect.objectContaining({ path: "src/example.ts", hunkCount: 1 })], }), @@ -42,7 +44,7 @@ describe("hunk session projections", () => { test("buildSelectedHunkSessionContext projects the current file and selected ranges", () => { const session = buildListedHunkSession({ - registration: createTestSessionRegistration(), + registration: createTestSessionRegistration({ experimentalFeatures: ["stml"] }), snapshot: createTestSessionSnapshot({ selectedHunkIndex: 1, selectedHunkOldRange: [8, 8], @@ -52,6 +54,7 @@ describe("hunk session projections", () => { expect(buildSelectedHunkSessionContext(session)).toEqual( expect.objectContaining({ + experimentalFeatures: ["stml"], selectedFile: expect.objectContaining({ path: "src/example.ts" }), selectedHunk: { index: 1, diff --git a/src/hunk-session/projections.ts b/src/hunk-session/projections.ts index 7da963a8..625cc9a6 100644 --- a/src/hunk-session/projections.ts +++ b/src/hunk-session/projections.ts @@ -75,6 +75,7 @@ export function buildListedHunkSession(entry: HunkSessionEntryLike): ListedSessi inputKind: entry.registration.info.inputKind, title: entry.registration.info.title, sourceLabel: entry.registration.info.sourceLabel, + experimentalFeatures: entry.registration.info.experimentalFeatures ?? [], fileCount: entry.registration.info.files.length, files: entry.registration.info.files.map(summarizeReviewFile), snapshot: entry.snapshot, @@ -92,6 +93,7 @@ export function buildSelectedHunkSessionContext(session: ListedSession): Selecte cwd: session.cwd, repoRoot: session.repoRoot, inputKind: session.inputKind, + experimentalFeatures: session.experimentalFeatures, selectedFile, selectedHunk: selectedFile ? { @@ -121,6 +123,7 @@ export function buildHunkSessionReview( cwd: entry.registration.cwd, repoRoot: entry.registration.repoRoot, inputKind: entry.registration.info.inputKind, + experimentalFeatures: entry.registration.info.experimentalFeatures ?? [], selectedFile: selectedFile ? serializeReviewFile(selectedFile, includePatch) : null, selectedHunk: selectedFile ? (selectedFile.hunks[entry.snapshot.state.selectedHunkIndex] ?? null) diff --git a/src/hunk-session/sessionRegistration.test.ts b/src/hunk-session/sessionRegistration.test.ts index f3f0ef78..f8c8ad60 100644 --- a/src/hunk-session/sessionRegistration.test.ts +++ b/src/hunk-session/sessionRegistration.test.ts @@ -51,6 +51,7 @@ describe("session registration", () => { inputKind: "vcs", title: "working tree", sourceLabel: "/repo", + experimentalFeatures: [], files: [ { id: "file-1", @@ -95,10 +96,21 @@ describe("session registration", () => { inputKind: "patch", title: "patch file", sourceLabel: "change.patch", + experimentalFeatures: [], files: [], }); }); + test("registration advertises STML only for opted-in launches", () => { + const registration = createSessionRegistration( + createBootstrap({ + input: { kind: "vcs", staged: false, options: { experimental: true } }, + }), + ); + + expect(registration.info.experimentalFeatures).toEqual(["stml"]); + }); + // Intent: initial snapshots expose first-hunk focus and configured note visibility. test("createInitialSessionSnapshot starts with the first hunk and note visibility", () => { const snapshot = createInitialSessionSnapshot(createBootstrap()); diff --git a/src/hunk-session/sessionRegistration.ts b/src/hunk-session/sessionRegistration.ts index 958d93cc..38ba8b28 100644 --- a/src/hunk-session/sessionRegistration.ts +++ b/src/hunk-session/sessionRegistration.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto"; import { spawnSync } from "node:child_process"; +import { resolveExperimentalFeatures } from "../core/experimental"; import { formatHunkHeader } from "../core/hunkHeader"; import { hunkLineRange } from "../core/liveComments"; import type { AppBootstrap } from "../core/types"; @@ -67,6 +68,7 @@ export function createSessionRegistration(bootstrap: AppBootstrap): HunkSessionR inputKind: bootstrap.input.kind, title: bootstrap.changeset.title, sourceLabel: bootstrap.changeset.sourceLabel, + experimentalFeatures: resolveExperimentalFeatures(bootstrap.input.options), files: buildSessionFiles(bootstrap), }, }; @@ -85,6 +87,7 @@ export function updateSessionRegistration( inputKind: bootstrap.input.kind, title: bootstrap.changeset.title, sourceLabel: bootstrap.changeset.sourceLabel, + experimentalFeatures: resolveExperimentalFeatures(bootstrap.input.options), files: buildSessionFiles(bootstrap), }, }; diff --git a/src/hunk-session/types.ts b/src/hunk-session/types.ts index cc37076d..567d9a48 100644 --- a/src/hunk-session/types.ts +++ b/src/hunk-session/types.ts @@ -1,3 +1,4 @@ +import type { ExperimentalFeature } from "../core/experimental"; import type { CommentTargetInput, DiffSide } from "../core/liveComments"; import type { CliInput, ReviewNoteSource } from "../core/types"; import type { SessionBrokerClient } from "../session-broker/brokerClient"; @@ -44,6 +45,7 @@ export interface HunkSessionInfo { inputKind: CliInput["kind"]; title: string; sourceLabel: string; + experimentalFeatures?: ExperimentalFeature[]; files: SessionReviewFile[]; } @@ -192,6 +194,7 @@ export interface ListedSession { inputKind: CliInput["kind"]; title: string; sourceLabel: string; + experimentalFeatures?: ExperimentalFeature[]; fileCount: number; files: SessionFileSummary[]; snapshot: HunkSessionSnapshot; @@ -204,6 +207,7 @@ export interface SelectedSessionContext { cwd?: string; repoRoot?: string; inputKind: CliInput["kind"]; + experimentalFeatures?: ExperimentalFeature[]; selectedFile: SessionFileSummary | null; selectedHunk: SelectedHunkSummary | null; showAgentNotes: boolean; @@ -219,6 +223,7 @@ export interface SessionReview { cwd?: string; repoRoot?: string; inputKind: CliInput["kind"]; + experimentalFeatures?: ExperimentalFeature[]; selectedFile: SessionReviewFile | null; selectedHunk: SessionReviewHunk | null; showAgentNotes: boolean; diff --git a/src/hunk-session/wire.test.ts b/src/hunk-session/wire.test.ts index 8bcf0fbc..f9733230 100644 --- a/src/hunk-session/wire.test.ts +++ b/src/hunk-session/wire.test.ts @@ -105,10 +105,30 @@ describe("hunk session wire parsing", () => { inputKind: "vcs", title: "repo working tree", sourceLabel: "/repo", + experimentalFeatures: [], files: [], }); }); + test("registration preserves only recognized experimental feature ids", () => { + const registration = parseSessionRegistration({ + registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, + sessionId: "session-1", + pid: 123, + cwd: "/repo", + launchedAt: "2026-03-22T00:00:00.000Z", + info: { + inputKind: "vcs", + title: "repo working tree", + sourceLabel: "/repo", + experimentalFeatures: ["stml", "future-feature", "stml", 42], + files: [], + }, + }); + + expect(registration?.info.experimentalFeatures).toEqual(["stml"]); + }); + test("rejects registrations with more files than the cap", () => { const files = Array.from({ length: MAX_REGISTRATION_FILES + 1 }, (_, index) => createFile({ id: `file-${index}`, path: `src/file-${index}.ts` }), diff --git a/src/hunk-session/wire.ts b/src/hunk-session/wire.ts index 6cc2d506..03b24da4 100644 --- a/src/hunk-session/wire.ts +++ b/src/hunk-session/wire.ts @@ -1,3 +1,4 @@ +import { EXPERIMENTAL_FEATURES, type ExperimentalFeature } from "../core/experimental"; import type { CliInput } from "../core/types"; import { MAX_REGISTRATION_FILES, @@ -28,6 +29,19 @@ const REVIEW_INPUT_KINDS = new Set([ "patch", "difftool", ]); +const EXPERIMENTAL_FEATURE_SET = new Set(EXPERIMENTAL_FEATURES); + +/** Preserve only recognized experimental feature ids from a session registration. */ +function parseExperimentalFeatures(value: unknown): ExperimentalFeature[] { + if (!Array.isArray(value)) { + return []; + } + + return [...new Set(value)].filter( + (feature): feature is ExperimentalFeature => + typeof feature === "string" && EXPERIMENTAL_FEATURE_SET.has(feature), + ); +} /** Parse one optional diff-side line range tuple when the payload shape matches. */ function parseOptionalRange(value: unknown): [number, number] | undefined { @@ -216,6 +230,7 @@ function parseHunkSessionInfo(value: unknown): HunkSessionInfo | null { inputKind, title, sourceLabel, + experimentalFeatures: parseExperimentalFeatures(record.experimentalFeatures), files: files as SessionReviewFile[], }; } diff --git a/src/session-broker/brokerServer.test.ts b/src/session-broker/brokerServer.test.ts index 99377f95..9d4de469 100644 --- a/src/session-broker/brokerServer.test.ts +++ b/src/session-broker/brokerServer.test.ts @@ -7,6 +7,7 @@ import { createTestSessionSnapshot, } from "../../test/helpers/session-daemon-fixtures"; import { SessionBrokerState } from "@hunk/session-broker-core"; +import { HUNK_SESSION_API_VERSION, HUNK_SESSION_DAEMON_VERSION } from "../session/protocol"; import { serveSessionBrokerDaemon } from "./brokerServer"; const originalHost = process.env.HUNK_MCP_HOST; @@ -290,8 +291,8 @@ describe("Hunk session daemon server", () => { const capabilities = await fetch(`http://127.0.0.1:${port}/session-api/capabilities`); expect(capabilities.status).toBe(200); await expect(capabilities.json()).resolves.toMatchObject({ - version: 1, - daemonVersion: 5, + version: HUNK_SESSION_API_VERSION, + daemonVersion: HUNK_SESSION_DAEMON_VERSION, actions: [ "list", "get", diff --git a/src/session/capabilities.test.ts b/src/session/capabilities.test.ts index 27679478..90c0de30 100644 --- a/src/session/capabilities.test.ts +++ b/src/session/capabilities.test.ts @@ -83,13 +83,13 @@ describe("readHunkSessionDaemonCapabilities", () => { await expect(readHunkSessionDaemonCapabilities(config)).resolves.toBeNull(); }); - test("rejects version 4 daemons that drop STML fields from comment payloads", async () => { + test("rejects version 5 daemons that drop session experimental-feature fields", async () => { const { config } = await listen((_request: IncomingMessage, response: ServerResponse) => { response.writeHead(200, { "content-type": "application/json" }); response.end( JSON.stringify({ version: HUNK_SESSION_API_VERSION, - daemonVersion: 4, + daemonVersion: 5, actions: ["comment-add", "comment-apply"], }), ); diff --git a/src/session/protocol.ts b/src/session/protocol.ts index 339b5a48..7f6ed7d8 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -32,7 +32,7 @@ export const HUNK_SESSION_API_VERSION = 1; * builds can refresh an older daemon even when it still exposes the same API endpoints. Bump this * when daemon-forwarded payloads change, even if the supported action names stay stable. */ -export const HUNK_SESSION_DAEMON_VERSION = 5; +export const HUNK_SESSION_DAEMON_VERSION = 6; export type SessionDaemonAction = | "list" diff --git a/src/ui/App.tsx b/src/ui/App.tsx index 962a4bde..aad22aa5 100644 --- a/src/ui/App.tsx +++ b/src/ui/App.tsx @@ -10,6 +10,7 @@ import { saveGlobalViewPreferences, saveViewPreferencesPromptPreference, } from "../core/config"; +import { experimentalFeatureEnabled, resolveExperimentalDiffFiles } from "../core/experimental"; import { DEFAULT_TAB_WIDTH } from "../core/tabWidth"; import type { AppBootstrap, @@ -128,6 +129,11 @@ export function App({ const pagerMode = Boolean(bootstrap.input.options.pager); const tabWidth = bootstrap.initialTabWidth ?? DEFAULT_TAB_WIDTH; + const stmlEnabled = experimentalFeatureEnabled(bootstrap.input.options, "stml"); + const reviewFiles = useMemo( + () => resolveExperimentalDiffFiles(bootstrap.changeset.files, bootstrap.input.options), + [bootstrap.changeset.files, bootstrap.input.options.experimental], + ); const renderer = useRenderer(); const terminal = useTerminalDimensions(); const sidebarScrollRef = useRef(null); @@ -250,8 +256,9 @@ export function App({ // the current values through a ref instead of a render-time parameter. const noteGeometryRef = useRef(null); const review = useReviewController({ - files: bootstrap.changeset.files, + files: reviewFiles, noteGeometry: noteGeometryRef, + stmlEnabled, }); const filteredFiles = review.visibleFiles; const selectedFile = review.selectedFile; @@ -331,7 +338,7 @@ export function App({ liveCommentCount: review.liveCommentCount, liveCommentSummaries: review.liveCommentSummaries, navigateToLocation: review.navigateToLocation, - noteMarkupWidth, + noteMarkupWidth: stmlEnabled ? noteMarkupWidth : undefined, openAgentNotes, reloadSession: onReloadSession, removeLiveComment: review.removeLiveComment, diff --git a/src/ui/AppHost.interactions.test.tsx b/src/ui/AppHost.interactions.test.tsx index ddef9003..6b510aee 100644 --- a/src/ui/AppHost.interactions.test.tsx +++ b/src/ui/AppHost.interactions.test.tsx @@ -75,7 +75,7 @@ function createMockHostClient({ let bridge: Bridge = null; let latestSnapshot: HunkSessionSnapshot["state"] | null = null; - const registration: HunkSessionRegistration = { + let registration: HunkSessionRegistration = { registrationVersion: SESSION_BROKER_REGISTRATION_VERSION, sessionId: "session-1", pid: process.pid, @@ -92,7 +92,9 @@ function createMockHostClient({ return { hostClient: { getRegistration: () => registration, - replaceSession: () => {}, + replaceSession: (nextRegistration: HunkSessionRegistration) => { + registration = nextRegistration; + }, setBridge: (nextBridge: Bridge) => { bridge = nextBridge; }, @@ -108,6 +110,7 @@ function createMockHostClient({ return bridge.dispatchCommand(message); }, getBridge: () => bridge, + getLatestRegistration: () => registration, getLatestSnapshot: () => latestSnapshot, navigateToHunk: async ( input: Extract["input"], @@ -1641,6 +1644,70 @@ describe("App interactions", () => { } }); + test("session reload cannot opt a normal launch into experimental STML", async () => { + const dir = mkdtempSync(join(process.cwd(), ".hunk-session-experimental-reload-")); + const left = join(dir, "before.ts"); + const right = join(dir, "after.ts"); + writeFileSync(left, "export const answer = 41;\n"); + writeFileSync(right, "export const answer = 42;\n"); + + const bootstrap = await loadAppBootstrap({ + kind: "diff", + left, + right, + options: { mode: "split" }, + }); + const { dispatchCommand, getLatestRegistration, hostClient } = createMockHostClient(); + const setup = await testRender(, { + width: 220, + height: 20, + }); + + try { + await flush(setup); + + await act(async () => { + await dispatchCommand({ + type: "command", + requestId: "reload-experimental", + command: "reload_session", + input: { + sessionId: "session-1", + nextInput: { + kind: "diff", + left, + right, + options: { mode: "split", experimental: true }, + }, + }, + }); + }); + await flush(setup); + + expect(getLatestRegistration().info.experimentalFeatures).toEqual([]); + await expect( + dispatchCommand({ + type: "command", + requestId: "comment-experimental", + command: "comment", + input: { + sessionId: "session-1", + filePath: "after.ts", + side: "new", + line: 1, + summary: "Plain fallback", + markup: "disabled", + }, + }), + ).rejects.toThrow("Relaunch Hunk with --experimental"); + } finally { + await act(async () => { + setup.renderer.destroy(); + }); + rmSync(dir, { force: true, recursive: true }); + } + }); + test("session reload rejects file reads outside the initial repo root", async () => { const repo = mkdtempSync(join(tmpdir(), "hunk-session-reload-root-")); const outside = mkdtempSync(join(tmpdir(), "hunk-session-reload-outside-")); diff --git a/src/ui/AppHost.tsx b/src/ui/AppHost.tsx index c8bd829a..eb6a76b5 100644 --- a/src/ui/AppHost.tsx +++ b/src/ui/AppHost.tsx @@ -33,6 +33,9 @@ export function AppHost({ }) { const [activeBootstrap, setActiveBootstrap] = useState(bootstrap); const [appVersion, setAppVersion] = useState(0); + // Experimental capabilities are launch authority: remote/watch reloads may replace content, + // but opting in or out requires starting a new Hunk process. + const launchExperimental = bootstrap.input.options.experimental === true; const [sessionFileBounds] = useState(() => createSessionReloadBounds(bootstrap, { cwd: bootstrap.reloadContext.cwd }), ); @@ -48,7 +51,13 @@ export function AppHost({ // runtime defaults and config layering instead of assuming `nextInput` is already final. // `sourcePath` matters for daemon-driven reloads that ask Hunk to reopen content from a // different working directory than the process originally started in. - const runtimeInput = resolveRuntimeCliInput(nextInput); + const runtimeInput = resolveRuntimeCliInput({ + ...nextInput, + options: { + ...nextInput.options, + experimental: launchExperimental, + }, + }); const { cwd } = validateSessionReloadWithinBounds(sessionFileBounds, runtimeInput, { sourcePath: options?.sourcePath, }); @@ -91,7 +100,7 @@ export function AppHost({ selectedHunkIndex: nextSnapshot.state.selectedHunkIndex, }; }, - [hostClient, sessionFileBounds], + [hostClient, launchExperimental, sessionFileBounds], ); return ( diff --git a/src/ui/hooks/useReviewController.test.tsx b/src/ui/hooks/useReviewController.test.tsx index 3814e624..18d9d007 100644 --- a/src/ui/hooks/useReviewController.test.tsx +++ b/src/ui/hooks/useReviewController.test.tsx @@ -87,16 +87,18 @@ function expectValue(value: T): NonNullable { function ReviewControllerHarness({ initialFiles, noteGeometry, + stmlEnabled, onController, onSetFiles, }: { initialFiles: DiffFile[]; noteGeometry?: Parameters[0]["noteGeometry"]; + stmlEnabled?: boolean; onController: (controller: ReviewController) => void; onSetFiles?: (setFiles: (nextFiles: DiffFile[]) => void) => void; }) { const [files, setFiles] = useState(initialFiles); - const controller = useReviewController({ files, noteGeometry }); + const controller = useReviewController({ files, noteGeometry, stmlEnabled }); useEffect(() => { onController(controller); @@ -115,9 +117,11 @@ async function renderReviewController( { strictMode = false, noteGeometry, + stmlEnabled, }: { strictMode?: boolean; noteGeometry?: Parameters[0]["noteGeometry"]; + stmlEnabled?: boolean; } = {}, ) { const controllerRef: { current: ReviewController | null } = { current: null }; @@ -126,6 +130,7 @@ async function renderReviewController( { controllerRef.current = nextController; }} @@ -367,7 +372,7 @@ describe("useReviewController", () => { "export const alpha = 2;\n", ), ], - { noteGeometry }, + { noteGeometry, stmlEnabled: true }, ); try { @@ -416,9 +421,17 @@ 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"), - ]); + const { controllerRef, setup } = await renderReviewController( + [ + createDiffFile( + "alpha", + "alpha.ts", + "export const alpha = 1;\n", + "export const alpha = 2;\n", + ), + ], + { stmlEnabled: true }, + ); try { await flush(setup); @@ -460,6 +473,46 @@ describe("useReviewController", () => { } }); + test("normal sessions reject STML live comments without mutating review state", async () => { + const { controllerRef, setup } = await renderReviewController([createAlphaFile()]); + + try { + await flush(setup); + + expect(() => + expectValue(controllerRef.current).addLiveComment( + { + filePath: "alpha.ts", + side: "new", + line: 1, + summary: "Plain fallback", + markup: "hidden", + }, + "comment-disabled", + ), + ).toThrow("Relaunch Hunk with --experimental"); + expect(() => + expectValue(controllerRef.current).addLiveCommentBatch( + [ + { + filePath: "alpha.ts", + hunkIndex: 0, + summary: "Plain fallback", + markup: "hidden", + }, + ], + "batch-disabled", + ), + ).toThrow("Relaunch Hunk with --experimental"); + + expect(expectValue(controllerRef.current).liveCommentCount).toBe(0); + } 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 a0185291..d1cd89e6 100644 --- a/src/ui/hooks/useReviewController.ts +++ b/src/ui/hooks/useReviewController.ts @@ -196,8 +196,11 @@ export interface AgentNoteGeometrySnapshot { export function useReviewController({ files, noteGeometry, + stmlEnabled = false, }: { files: DiffFile[]; + /** Allow STML bodies for live comments in this explicitly opted-in session. */ + stmlEnabled?: boolean; /** * 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; @@ -594,6 +597,12 @@ export function useReviewController({ return {}; } + if (!stmlEnabled) { + throw new Error( + "STML markup is disabled for this session. Relaunch Hunk with --experimental, or omit markup.", + ); + } + const geometry = noteGeometry?.current; const markupWidth = geometry ? agentNoteMarkupWidth({ anchorSide, layout: geometry.layout, width: geometry.width }) @@ -604,7 +613,7 @@ export function useReviewController({ ...(markupNotes.length > 0 ? { markupNotes } : {}), }; }, - [noteGeometry], + [noteGeometry, stmlEnabled], ); /** Add one live comment, optionally revealing its hunk in the active review. */ @@ -620,6 +629,7 @@ export function useReviewController({ } const target = resolveCommentTarget(file, input); + const feedback = markupFeedback(input.markup, target.side); const liveComment = buildLiveComment( { @@ -647,7 +657,7 @@ export function useReviewController({ hunkIndex: target.hunkIndex, side: target.side, line: target.line, - ...markupFeedback(input.markup, target.side), + ...feedback, }; }, [allFiles, markupFeedback, selectHunk], @@ -668,9 +678,11 @@ export function useReviewController({ } const target = resolveCommentTarget(file, input); + const feedback = markupFeedback(input.markup, target.side); return { file, target, + feedback, liveComment: buildLiveComment( { ...input, @@ -701,18 +713,18 @@ export function useReviewController({ } return { - applied: prepared.map(({ file, target, liveComment }) => ({ + applied: prepared.map(({ feedback, 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), + ...feedback, })), }; }, - [allFiles, selectHunk], + [allFiles, markupFeedback, selectHunk], ); /** Remove one daemon-addressable comment, including human notes by stable `user:*` id. */ diff --git a/src/ui/lib/stml/guide.test.ts b/src/ui/lib/stml/guide.test.ts index 5fe1384d..489d01cd 100644 --- a/src/ui/lib/stml/guide.test.ts +++ b/src/ui/lib/stml/guide.test.ts @@ -11,6 +11,8 @@ describe("STML guide", () => { expect(STML_GUIDE).toContain("█"); expect(STML_GUIDE).toContain("→"); expect(STML_GUIDE).toContain("--width"); + expect(STML_GUIDE).toContain("--experimental"); + expect(STML_GUIDE).toContain("experimentalFeatures"); expect(STML_GUIDE).toContain(`${STML_REFERENCE_WIDTH}`); }); diff --git a/src/ui/lib/stml/guide.ts b/src/ui/lib/stml/guide.ts index 739613a5..4ec3b5ac 100644 --- a/src/ui/lib/stml/guide.ts +++ b/src/ui/lib/stml/guide.ts @@ -12,7 +12,8 @@ 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. +The review must be launched with \`--experimental\`; otherwise Hunk uses the +required plain-text summary fallback and rejects live markup comments. Small HTML-like markup rendered as real terminal UI inside agent notes: boxes, rows, badges, gauges, lists, code blocks. Sources (--summary stays @@ -31,8 +32,9 @@ Preview from a file or stdin: - Hunk supplies the note's outer frame, author, and source location. STML is the note body; use borders for useful inner hierarchy rather than duplicating that frame around the whole body. Sibling and nested boxes are supported. -- Width follows the live session: stack ≈ full pane, split ≈ half, big - terminal = big note. \`hunk session context --json\` reports +- Confirm \`hunk session context --json\` lists \`stml\` in + \`experimentalFeatures\` before authoring markup. Width follows the live + session: stack ≈ full pane, split ≈ half. The context 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. diff --git a/test/helpers/session-daemon-fixtures.ts b/test/helpers/session-daemon-fixtures.ts index 85eaac72..462e965c 100644 --- a/test/helpers/session-daemon-fixtures.ts +++ b/test/helpers/session-daemon-fixtures.ts @@ -74,7 +74,10 @@ export function createTestSessionSnapshot( export function createTestSessionRegistration( overrides: Partial & Partial< - Pick + Pick< + HunkSessionRegistration["info"], + "inputKind" | "title" | "sourceLabel" | "experimentalFeatures" | "files" + > > & { info?: Partial; } = {}, @@ -83,6 +86,7 @@ export function createTestSessionRegistration( inputKind, title, sourceLabel, + experimentalFeatures, files, info: infoOverrides, ...registrationOverrides @@ -101,6 +105,7 @@ export function createTestSessionRegistration( inputKind: inputKind ?? infoOverrides?.inputKind ?? "vcs", title: title ?? infoOverrides?.title ?? "repo working tree", sourceLabel: sourceLabel ?? infoOverrides?.sourceLabel ?? "/repo", + experimentalFeatures: experimentalFeatures ?? infoOverrides?.experimentalFeatures ?? [], files: resolvedFiles, }, }; @@ -119,6 +124,7 @@ export function createTestListedSession(overrides: Partial = {}): inputKind: "vcs", title: "repo working tree", sourceLabel: "/repo", + experimentalFeatures: [], ...overrides, fileCount: overrides.fileCount ?? files.length, files, @@ -150,6 +156,7 @@ export function createTestSelectedSessionContext( sourceLabel: "/repo", repoRoot: "/repo", inputKind: "diff", + experimentalFeatures: [], selectedFile: createTestSessionFileSummary({ additions: 1, deletions: 0, @@ -181,6 +188,7 @@ export function createTestSessionReview(overrides: Partial = {}): sourceLabel: "/repo", repoRoot: "/repo", inputKind: "vcs", + experimentalFeatures: [], showAgentNotes: false, liveCommentCount: 0, ...overrides, diff --git a/test/pty/harness.ts b/test/pty/harness.ts index 32b9fe62..a4dafacb 100644 --- a/test/pty/harness.ts +++ b/test/pty/harness.ts @@ -282,6 +282,7 @@ export function createPtyHarness() { newRange: [2, 2], summary: "Adds bonus export.", rationale: "Highlights the follow-up addition for review.", + markup: 'STML ACTIVE', }, ], }, diff --git a/test/pty/notes.test.ts b/test/pty/notes.test.ts index 5838e529..dc4e0ecb 100644 --- a/test/pty/notes.test.ts +++ b/test/pty/notes.test.ts @@ -46,6 +46,7 @@ describe("PTY notes", () => { const withNotes = await session.waitForText(/Adds bonus export\./, { timeout: 5_000 }); expect(withNotes).toContain("Highlights the follow-up addition for review."); + expect(withNotes).not.toContain("STML ACTIVE"); await session.press("a"); const withoutNotes = await harness.waitForSnapshot( @@ -60,6 +61,34 @@ describe("PTY notes", () => { } }); + test("experimental launches render STML note bodies", async () => { + const fixture = harness.createAgentFilePair(); + const session = await harness.launchHunk({ + args: [ + "diff", + fixture.before, + fixture.after, + "--mode", + "split", + "--agent-context", + fixture.agentContext, + "--experimental", + ], + cols: 140, + rows: 20, + }); + + try { + await session.waitForText(/View\s+Navigate\s+Agent\s+Help/, { timeout: 15_000 }); + await session.press("a"); + const withMarkup = await session.waitForText(/STML ACTIVE/, { timeout: 5_000 }); + + expect(withMarkup).not.toContain("Highlights the follow-up addition for review."); + } finally { + session.close(); + } + }); + test("user notes can be drafted and saved inline in a real PTY", async () => { const fixture = harness.createLongWrapFilePair(); const session = await harness.launchHunk({