From 18d17af78c5fb3daf6abce64d0103d9eb82fbdcf Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 13 Aug 2026 16:06:08 -0400 Subject: [PATCH 01/10] feat(session): add agent highlight actions to the session protocol Agents could only annotate and navigate live sessions at hunk granularity; the extension API's character-range marks and line-exact reveals had no daemon-facing counterpart. Declare highlight-add / highlight-clear actions, their strict request schemas, and the applied/cleared result shapes, and let navigate results report whether a line target landed line-exactly. Bump the daemon compatibility version because the forwarded payload set grew. --- src/core/types.ts | 28 ++++++++++++++++++- src/session/protocol.ts | 30 ++++++++++++++++++-- src/session/protocolSchemas.ts | 16 +++++++++++ src/session/types.ts | 51 ++++++++++++++++++++++++++++++++-- 4 files changed, 119 insertions(+), 6 deletions(-) diff --git a/src/core/types.ts b/src/core/types.ts index f19510db4..af8c16054 100644 --- a/src/core/types.ts +++ b/src/core/types.ts @@ -278,6 +278,30 @@ export interface SessionCommentClearCommandInput { confirmed: boolean; } +export interface SessionHighlightAddCommandInput { + kind: "session"; + action: "highlight-add"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + filePath: string; + side: "old" | "new"; + line: number; + /** 0-based inclusive UTF-16 code-unit offset into the line's raw text. */ + start: number; + /** Exclusive end offset; must exceed `start`. */ + end: number; + tone?: "match" | "current" | "info" | "warning" | "error"; + reveal: boolean; +} + +export interface SessionHighlightClearCommandInput { + kind: "session"; + action: "highlight-clear"; + output: SessionCommandOutput; + selector: SessionSelectorInput; + filePath?: string; +} + export type SessionCommandInput = | SessionListCommandInput | SessionGetCommandInput @@ -288,7 +312,9 @@ export type SessionCommandInput = | SessionCommentApplyCommandInput | SessionCommentListCommandInput | SessionCommentRemoveCommandInput - | SessionCommentClearCommandInput; + | SessionCommentClearCommandInput + | SessionHighlightAddCommandInput + | SessionHighlightClearCommandInput; /** * Review requests extend the published input views rather than restating them, diff --git a/src/session/protocol.ts b/src/session/protocol.ts index c08b69386..f20ade4ce 100644 --- a/src/session/protocol.ts +++ b/src/session/protocol.ts @@ -4,6 +4,8 @@ import type { SessionCommentClearCommandInput, SessionCommentListCommandInput, SessionCommentRemoveCommandInput, + SessionHighlightAddCommandInput, + SessionHighlightClearCommandInput, SessionNavigateCommandInput, SessionReloadCommandInput, SessionReviewCommandInput, @@ -12,7 +14,9 @@ import type { import type { AppliedCommentBatchResult, AppliedCommentResult, + AppliedHighlightResult, ClearedCommentsResult, + ClearedHighlightsResult, ListedSession, NavigatedSelectionResult, ReloadedSessionResult, @@ -32,7 +36,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 = 8; +export const HUNK_SESSION_DAEMON_VERSION = 9; export type SessionDaemonAction = | "list" @@ -45,7 +49,9 @@ export type SessionDaemonAction = | "comment-apply" | "comment-list" | "comment-rm" - | "comment-clear"; + | "comment-clear" + | "highlight-add" + | "highlight-clear"; export interface SessionDaemonCapabilities { version: number; @@ -120,6 +126,22 @@ export type SessionDaemonRequest = selector: SessionCommentClearCommandInput["selector"]; filePath?: string; includeUser?: boolean; + } + | { + action: "highlight-add"; + selector: SessionHighlightAddCommandInput["selector"]; + filePath: string; + side: "old" | "new"; + line: number; + start: number; + end: number; + tone?: "match" | "current" | "info" | "warning" | "error"; + reveal: boolean; + } + | { + action: "highlight-clear"; + selector: SessionHighlightClearCommandInput["selector"]; + filePath?: string; }; export type SessionDaemonResponse = @@ -133,4 +155,6 @@ export type SessionDaemonResponse = | { result: AppliedCommentBatchResult } | { comments: Array } | { result: RemovedCommentResult } - | { result: ClearedCommentsResult }; + | { result: ClearedCommentsResult } + | { result: AppliedHighlightResult } + | { result: ClearedHighlightsResult }; diff --git a/src/session/protocolSchemas.ts b/src/session/protocolSchemas.ts index 8f8d111fc..f4eea82a9 100644 --- a/src/session/protocolSchemas.ts +++ b/src/session/protocolSchemas.ts @@ -106,6 +106,22 @@ export const sessionDaemonRequestSchema = z.discriminatedUnion("action", [ filePath: z.string().optional(), includeUser: z.boolean().optional(), }), + z.strictObject({ + action: z.literal("highlight-add"), + selector: selectorSchema, + filePath: z.string(), + side: sideSchema, + line: z.int().positive(), + start: z.int().nonnegative(), + end: z.int().positive(), + tone: z.enum(["match", "current", "info", "warning", "error"]).optional(), + reveal: z.boolean(), + }), + z.strictObject({ + action: z.literal("highlight-clear"), + selector: selectorSchema, + filePath: z.string().optional(), + }), ]); /** Compose one readable rejection reason from the first schema issue. */ diff --git a/src/session/types.ts b/src/session/types.ts index 3d9e0d2f3..848b037a3 100644 --- a/src/session/types.ts +++ b/src/session/types.ts @@ -1,4 +1,5 @@ import type { ExperimentalFeature } from "../core/experimental"; +import type { ExtensionLineHighlightTone } from "../extension-api/types"; import type { CommentTargetInput, DiffSide } from "../core/liveComments"; import type { ReviewPublicationAddress } from "../core/review/generationOrder"; import type { CliInput, ReviewNoteSource } from "../core/types"; @@ -146,6 +147,23 @@ export interface ReadReviewResourceToolInput export interface ApplyReviewActionToolInput extends SessionTargetInput, HunkReviewActionEnvelopeV1 {} +/** One agent-set attention mark: a character range inside one diff line. */ +export interface HighlightToolInput extends SessionTargetInput { + filePath: string; + side: DiffSide; + line: number; + /** `[start, end)` UTF-16 code-unit offsets into the line's raw source text. */ + start: number; + end: number; + tone?: ExtensionLineHighlightTone; + /** Also land the viewport on the marked line. */ + reveal?: boolean; +} + +export interface ClearHighlightsToolInput extends SessionTargetInput { + filePath?: string; +} + export interface SessionLiveCommentSummary { commentId: string; filePath: string; @@ -195,6 +213,31 @@ export interface NavigatedSelectionResult { filePath: string; hunkIndex: number; selectedHunk?: SelectedHunkSummary; + /** For line targets: whether the viewport landed on the exact line or fell back to its hunk. */ + revealed?: "line" | "hunk"; + side?: DiffSide; + line?: number; +} + +export interface AppliedHighlightResult { + fileId: string; + filePath: string; + hunkIndex: number; + side: DiffSide; + line: number; + start: number; + end: number; + tone: ExtensionLineHighlightTone; + /** Agent marks now active on this file, including this one. */ + fileMarkCount: number; + /** Where the optional `reveal` landed. */ + revealed?: "line" | "hunk"; +} + +export interface ClearedHighlightsResult { + removedCount: number; + remainingCount: number; + filePath?: string; } export interface RemovedCommentResult { @@ -295,7 +338,9 @@ export type HunkSessionCommandResult = | RemovedCommentResult | ClearedCommentsResult | ReloadedSessionResult - | HunkReviewResultV1; + | HunkReviewResultV1 + | AppliedHighlightResult + | ClearedHighlightsResult; export type HunkSessionClientMessage = SessionClientMessage< HunkSessionInfo, @@ -318,4 +363,6 @@ export type HunkSessionServerMessage = | SessionServerMessage<"remove_comment", RemoveCommentToolInput> | SessionServerMessage<"clear_comments", ClearCommentsToolInput> | SessionServerMessage<"read_review_resource", ReadReviewResourceToolInput> - | SessionServerMessage<"apply_review_action", ApplyReviewActionToolInput>; + | SessionServerMessage<"apply_review_action", ApplyReviewActionToolInput> + | SessionServerMessage<"highlight", HighlightToolInput> + | SessionServerMessage<"clear_highlights", ClearHighlightsToolInput>; From d5e5f074e23489213926993a286e8fb6863bbd5e Mon Sep 17 00:00:00 2001 From: Justin Giancola Date: Thu, 13 Aug 2026 16:06:16 -0400 Subject: [PATCH 02/10] feat(cli): describe and parse the session highlight command family hunk session highlight add / clear are siblings of the comment family: the same selector notation, the same --old-line/--new-line targeting constraint, and offsets that reuse the extension API's [start, end) vocabulary so one range means the same thing to agents and extensions. --start introduces a non-negative integer parse because offsets are 0-based. --- src/core/cli.ts | 108 +++++++++++++++++++++++++++++++ src/session/agent/errors.test.ts | 4 ++ src/session/agent/errors.ts | 12 ++++ src/session/agent/surface.ts | 79 +++++++++++++++++++++- 4 files changed, 200 insertions(+), 3 deletions(-) diff --git a/src/core/cli.ts b/src/core/cli.ts index e4ccca782..c166eceff 100644 --- a/src/core/cli.ts +++ b/src/core/cli.ts @@ -26,15 +26,20 @@ import { type SessionCommandOptions, COMMENT_DIRECTION_CONSTRAINT, COMMENT_TARGET_CONSTRAINT, + HIGHLIGHT_TARGET_CONSTRAINT, + HIGHLIGHT_TONES, + isHighlightTone, NAVIGATE_TARGET_CONSTRAINT, optionKeyFromFlag, SESSION_AGENT_COMMANDS, SESSION_AGENT_COMMAND_LIST, SESSION_COMMENT_COMMAND_LIST, + SESSION_HIGHLIGHT_COMMAND_LIST, } from "../session/agent/surface"; import { COMMENT_APPLY_STDIN_MESSAGE, constraintViolationMessage, + HIGHLIGHT_RANGE_MESSAGE, RELOAD_SEPARATOR_MESSAGE, } from "../session/agent/errors"; import { DEFAULT_TAB_WIDTH, parseTabWidth } from "./tabWidth"; @@ -263,6 +268,20 @@ function parsePositiveInt(value: string) { return parsed; } +/** Parse one required non-negative integer CLI value, accepting 0. */ +function parseNonNegativeInt(value: string) { + if (!/^(0|[1-9]\d*)$/.test(value)) { + throw new Error(`Invalid non-negative integer: ${value}`); + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + throw new Error(`Invalid non-negative integer: ${value}`); + } + + return parsed; +} + /** Read one paired positive/negative boolean flag directly from raw argv. */ function resolveBooleanFlag(argv: string[], enabledFlag: string, disabledFlag: string) { let resolved: boolean | undefined; @@ -880,6 +899,8 @@ function buildSessionCommand(spec: AgentCommandSpec) { : command.option.bind(command); if (option.parse === "positiveInt") { register(option.flag, option.description, parsePositiveInt); + } else if (option.parse === "nonNegativeInt") { + register(option.flag, option.description, parseNonNegativeInt); } else { register(option.flag, option.description); } @@ -1294,6 +1315,93 @@ async function parseSessionCommand(tokens: string[]): Promise { throw new Error("Supported comment subcommands are add, apply, list, rm, and clear."); } + if (subcommand === "highlight") { + const [highlightSubcommand, ...highlightRest] = rest; + if (!highlightSubcommand || highlightSubcommand === "--help" || highlightSubcommand === "-h") { + return { + kind: "help", + text: ["Usage:", ...sessionUsageLines(SESSION_HIGHLIGHT_COMMAND_LIST)].join("\n") + "\n", + }; + } + + if (highlightSubcommand === "add") { + const command = buildSessionCommand(SESSION_AGENT_COMMANDS["highlight-add"]); + + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"highlight-add"> = { + file: "", + start: 0, + end: 0, + }; + + command.action( + (sessionId: string | undefined, options: SessionCommandOptions<"highlight-add">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); + + if (highlightRest.includes("--help") || highlightRest.includes("-h")) { + return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["highlight-add"]); + } + + await parseStandaloneCommand(command, highlightRest); + + enforceConstraint(HIGHLIGHT_TARGET_CONSTRAINT, parsedOptions); + if (parsedOptions.end <= parsedOptions.start) { + throw new Error(HIGHLIGHT_RANGE_MESSAGE); + } + const tone = parsedOptions.tone; + if (tone !== undefined && !isHighlightTone(tone)) { + throw new Error(`Highlight tone must be one of ${HIGHLIGHT_TONES.join(", ")}.`); + } + + return { + kind: "session", + action: "highlight-add", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + side: parsedOptions.oldLine !== undefined ? "old" : "new", + line: parsedOptions.oldLine ?? parsedOptions.newLine ?? 0, + start: parsedOptions.start, + end: parsedOptions.end, + ...(tone !== undefined && isHighlightTone(tone) ? { tone } : {}), + reveal: parsedOptions.focus ?? false, + }; + } + + if (highlightSubcommand === "clear") { + const command = buildSessionCommand(SESSION_AGENT_COMMANDS["highlight-clear"]); + + let parsedSessionId: string | undefined; + let parsedOptions: SessionCommandOptions<"highlight-clear"> = {}; + + command.action( + (sessionId: string | undefined, options: SessionCommandOptions<"highlight-clear">) => { + parsedSessionId = sessionId; + parsedOptions = options; + }, + ); + + if (highlightRest.includes("--help") || highlightRest.includes("-h")) { + return sessionCommandHelpText(command, SESSION_AGENT_COMMANDS["highlight-clear"]); + } + + await parseStandaloneCommand(command, highlightRest); + + return { + kind: "session", + action: "highlight-clear", + output: resolveJsonOutput(parsedOptions), + selector: resolveExplicitSessionSelector(parsedSessionId, parsedOptions.repo), + filePath: parsedOptions.file, + }; + } + + throw new Error("Supported highlight subcommands are add and clear."); + } + throw new Error(`Unknown session command: ${subcommand}`); } diff --git a/src/session/agent/errors.test.ts b/src/session/agent/errors.test.ts index 53989f5ae..4f1d8e32a 100644 --- a/src/session/agent/errors.test.ts +++ b/src/session/agent/errors.test.ts @@ -5,6 +5,7 @@ import { agentErrorQuotePrefix, COMMENT_APPLY_STDIN_MESSAGE, constraintViolationMessage, + HIGHLIGHT_RANGE_MESSAGE, NO_ACTIVE_SESSIONS_MESSAGE, noDiffFileMatchesMessage, RELOAD_SEPARATOR_MESSAGE, @@ -13,6 +14,7 @@ import { import { COMMENT_DIRECTION_CONSTRAINT, COMMENT_TARGET_CONSTRAINT, + HIGHLIGHT_TARGET_CONSTRAINT, NAVIGATE_TARGET_CONSTRAINT, } from "./surface"; @@ -66,6 +68,8 @@ describe("agent error messages", () => { COMMENT_APPLY_STDIN_MESSAGE, constraintViolationMessage(NAVIGATE_TARGET_CONSTRAINT), constraintViolationMessage(COMMENT_TARGET_CONSTRAINT), + constraintViolationMessage(HIGHLIGHT_TARGET_CONSTRAINT), + HIGHLIGHT_RANGE_MESSAGE, constraintViolationMessage(COMMENT_DIRECTION_CONSTRAINT), reviewResourceUnavailableMessage("src/App.tsx"), ]; diff --git a/src/session/agent/errors.ts b/src/session/agent/errors.ts index b41b37901..dcba9dad4 100644 --- a/src/session/agent/errors.ts +++ b/src/session/agent/errors.ts @@ -33,6 +33,10 @@ export const RELOAD_SEPARATOR_MESSAGE = /** `comment apply` invoked without opting into the stdin JSON batch. */ export const COMMENT_APPLY_STDIN_MESSAGE = "Pass --stdin to read batch comments from stdin JSON."; +/** `highlight add` invoked with an empty or inverted character range. */ +export const HIGHLIGHT_RANGE_MESSAGE = + "Highlight --end must be greater than --start; the range is [start, end) with an exclusive end."; + /** The daemon is reachable but no live Hunk session has registered with it. */ export const NO_ACTIVE_SESSIONS_MESSAGE = "No active Hunk sessions are registered with the daemon. Open Hunk and wait for it to connect."; @@ -104,6 +108,14 @@ export const AGENT_ERROR_DOCS: AgentErrorDoc[] = [ quote: "Specify exactly one comment target", remedy: "pass `comment add` one of `--old-line` or `--new-line`.", }, + { + quote: "Specify exactly one highlight target", + remedy: "pass `highlight add` one of `--old-line` or `--new-line`.", + }, + { + quote: "Highlight --end must be greater than --start", + remedy: "offsets are `[start, end)` UTF-16 code units into the line text; end is exclusive.", + }, { quote: "Specify either --next-comment or --prev-comment, not both.", remedy: "choose one comment-navigation direction.", diff --git a/src/session/agent/surface.ts b/src/session/agent/surface.ts index 261fdbf09..2ea475543 100644 --- a/src/session/agent/surface.ts +++ b/src/session/agent/surface.ts @@ -15,8 +15,8 @@ export interface AgentCommandOption { readonly flag: string; /** Description shared by `--help` output and generated docs. */ readonly description: string; - /** Parse the option value as a 1-based positive integer. */ - readonly parse?: "positiveInt"; + /** Parse the option value as a 1-based positive or 0-based non-negative integer. */ + readonly parse?: "positiveInt" | "nonNegativeInt"; /** Register with Commander as a required option. */ readonly required?: boolean; } @@ -60,7 +60,7 @@ type FlagBody = Flag extends `--${infer Name} ${string}` /** The parsed value Commander produces for one option: boolean flag, string value, or number. */ type OptionValue