@xynogen/pix-hunk 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +73 -0
- package/package.json +54 -0
- package/src/index.test.ts +413 -0
- package/src/index.ts +539 -0
- package/src/lib.test.ts +144 -0
- package/src/lib.ts +142 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xynogen
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# @xynogen/pix-hunk
|
|
2
|
+
|
|
3
|
+
Pi tool for driving a live [Hunk](https://github.com/modem-dev/hunk) diff review.
|
|
4
|
+
One `hunk` call accepts ordered `ops[]`, so an agent can inspect, navigate,
|
|
5
|
+
comment, highlight, and reload a review without repeated shell commands or
|
|
6
|
+
quoting.
|
|
7
|
+
|
|
8
|
+
## Requirements
|
|
9
|
+
|
|
10
|
+
Install Hunk and launch its interactive TUI in another terminal. This extension
|
|
11
|
+
only calls `hunk session *`; it never starts `hunk diff`, `show`, `patch`, or
|
|
12
|
+
another interactive command.
|
|
13
|
+
|
|
14
|
+
## Tool
|
|
15
|
+
|
|
16
|
+
```ts
|
|
17
|
+
hunk({
|
|
18
|
+
ops: [
|
|
19
|
+
{ action: "review" },
|
|
20
|
+
{ action: "navigate", file: "src/App.tsx", hunk: 2 },
|
|
21
|
+
{
|
|
22
|
+
action: "comment",
|
|
23
|
+
file: "src/App.tsx",
|
|
24
|
+
newLine: 42,
|
|
25
|
+
summary: "Handle the empty state",
|
|
26
|
+
},
|
|
27
|
+
{ action: "comment_list", type: "user" },
|
|
28
|
+
],
|
|
29
|
+
});
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
Supported actions: `list`, `get`, `context`, `review`, `navigate`, `comment`,
|
|
33
|
+
`comment_list`, `comment_rm`, `highlight`, `highlight_clear`, and `reload`.
|
|
34
|
+
Operations execute in order and every result is returned, including failures.
|
|
35
|
+
Hunk JSON is converted to compact model-facing records:
|
|
36
|
+
|
|
37
|
+
```text
|
|
38
|
+
review s1 Working tree
|
|
39
|
+
selected src/App.tsx:h2
|
|
40
|
+
src/App.tsx +12 -4
|
|
41
|
+
h1 old=10-14 new=10-18
|
|
42
|
+
comment c17 src/App.tsx:new:42 h1
|
|
43
|
+
comment_list
|
|
44
|
+
note-81bc src/App.tsx:new:47 h2 @xynogen: Can this branch be removed?
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
IDs come directly from Hunk. Successful records omit redundant numbering and
|
|
48
|
+
`ok`; failures use `action error: message`. TUI output auto-collapses to one
|
|
49
|
+
colored summary row; expanded UI hides internal IDs and comment bodies while
|
|
50
|
+
full model/audit data remains unchanged.
|
|
51
|
+
|
|
52
|
+
Model-facing output defaults to 10,000 characters. Set `maxCharacters` between
|
|
53
|
+
1,000 and 50,000 when more or less context is useful. Each operation receives a
|
|
54
|
+
quota, so a large patch cannot hide later results. Full structured results
|
|
55
|
+
remain available in tool details for inspection.
|
|
56
|
+
|
|
57
|
+
## Install
|
|
58
|
+
|
|
59
|
+
```bash
|
|
60
|
+
pi install npm:@xynogen/pix-hunk
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
Standalone/opt-in: Hunk and a live local review session are required, so this
|
|
64
|
+
package is not bundled by `@xynogen/pix-core`.
|
|
65
|
+
|
|
66
|
+
## Scope ceiling
|
|
67
|
+
|
|
68
|
+
`comment apply`, STML markup, and comment clearing are intentionally omitted.
|
|
69
|
+
Use Hunk's CLI directly until repeated demand justifies extending the schema.
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
package/package.json
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@xynogen/pix-hunk",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Pi tool — drive live Hunk diff reviews with ordered bulk operations",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "src/index.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./src/index.ts"
|
|
9
|
+
},
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "bun test"
|
|
12
|
+
},
|
|
13
|
+
"files": [
|
|
14
|
+
"src",
|
|
15
|
+
"README.md",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"pi": {
|
|
19
|
+
"extensions": [
|
|
20
|
+
"src/index.ts"
|
|
21
|
+
]
|
|
22
|
+
},
|
|
23
|
+
"keywords": [
|
|
24
|
+
"pi",
|
|
25
|
+
"pi-package",
|
|
26
|
+
"pi-extension",
|
|
27
|
+
"hunk",
|
|
28
|
+
"code-review",
|
|
29
|
+
"diff"
|
|
30
|
+
],
|
|
31
|
+
"author": "xynogen",
|
|
32
|
+
"license": "MIT",
|
|
33
|
+
"repository": {
|
|
34
|
+
"type": "git",
|
|
35
|
+
"url": "git+https://github.com/xynogen/pix-mono.git",
|
|
36
|
+
"directory": "packages/pix-hunk"
|
|
37
|
+
},
|
|
38
|
+
"homepage": "https://github.com/xynogen/pix-mono/tree/main/packages/pix-hunk#readme",
|
|
39
|
+
"bugs": {
|
|
40
|
+
"url": "https://github.com/xynogen/pix-mono/issues"
|
|
41
|
+
},
|
|
42
|
+
"publishConfig": {
|
|
43
|
+
"access": "public"
|
|
44
|
+
},
|
|
45
|
+
"dependencies": {
|
|
46
|
+
"@xynogen/pix-pretty": "^1.19.0",
|
|
47
|
+
"@xynogen/pix-runtime": "^0.8.1",
|
|
48
|
+
"typebox": "^1.1.38"
|
|
49
|
+
},
|
|
50
|
+
"peerDependencies": {
|
|
51
|
+
"@earendil-works/pi-coding-agent": "*",
|
|
52
|
+
"@earendil-works/pi-tui": "*"
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -0,0 +1,413 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import registerHunk, { type HunkRunner } from "./index.ts";
|
|
3
|
+
|
|
4
|
+
type RenderTheme = {
|
|
5
|
+
fg: (color: string, text: string) => string;
|
|
6
|
+
bold: (text: string) => string;
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
type RenderContext = {
|
|
10
|
+
expanded: boolean;
|
|
11
|
+
isError: boolean;
|
|
12
|
+
state: Record<string, unknown>;
|
|
13
|
+
invalidate: () => void;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ToolDef = {
|
|
17
|
+
renderShell?: "self";
|
|
18
|
+
renderCall: (
|
|
19
|
+
args: { ops: Array<Record<string, unknown>> },
|
|
20
|
+
theme: RenderTheme,
|
|
21
|
+
context: RenderContext,
|
|
22
|
+
) => { render: (width: number) => string[] };
|
|
23
|
+
renderResult: (
|
|
24
|
+
result: { content: Array<{ type: "text"; text: string }>; details: unknown },
|
|
25
|
+
options: { expanded: boolean; isPartial: boolean },
|
|
26
|
+
theme: RenderTheme,
|
|
27
|
+
context: RenderContext,
|
|
28
|
+
) => { render: (width: number) => string[] };
|
|
29
|
+
execute: (
|
|
30
|
+
id: string,
|
|
31
|
+
params: { ops: Array<Record<string, unknown>>; maxCharacters?: number },
|
|
32
|
+
signal: AbortSignal | undefined,
|
|
33
|
+
onUpdate: undefined,
|
|
34
|
+
ctx: { cwd: string },
|
|
35
|
+
) => Promise<{ content: Array<{ type: "text"; text: string }>; details: unknown }>;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const taggedTheme: RenderTheme = {
|
|
39
|
+
fg: (color, text) => `[${color}]${text}[/${color}]`,
|
|
40
|
+
bold: (text) => `[bold]${text}[/bold]`,
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const renderContext = (expanded = false, collapsed = false): RenderContext => ({
|
|
44
|
+
expanded,
|
|
45
|
+
isError: false,
|
|
46
|
+
state: collapsed ? { collapsed: true } : {},
|
|
47
|
+
invalidate: () => {},
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
function rendered(component: { render: (width: number) => string[] }): string {
|
|
51
|
+
return component.render(240).join("\n");
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function capture(runner: HunkRunner): ToolDef {
|
|
55
|
+
let tool: ToolDef | undefined;
|
|
56
|
+
const pi = {
|
|
57
|
+
registerTool(def: ToolDef) {
|
|
58
|
+
tool = def;
|
|
59
|
+
},
|
|
60
|
+
} as never;
|
|
61
|
+
registerHunk(pi, runner);
|
|
62
|
+
if (!tool) throw new Error("hunk tool not registered");
|
|
63
|
+
return tool;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
test("renders a compact colored summary and hides internal ids from expanded UI", async () => {
|
|
67
|
+
let call = 0;
|
|
68
|
+
const runner: HunkRunner = async () => {
|
|
69
|
+
call++;
|
|
70
|
+
if (call === 1) {
|
|
71
|
+
return {
|
|
72
|
+
stdout: JSON.stringify({
|
|
73
|
+
result: { filePath: "src/a.ts", hunkIndex: 0, selectedHunk: { newRange: [10, 20] } },
|
|
74
|
+
}),
|
|
75
|
+
stderr: "",
|
|
76
|
+
code: 0,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
stdout: JSON.stringify({
|
|
81
|
+
result: {
|
|
82
|
+
commentId: "mcp:3681add-very-long-internal-id",
|
|
83
|
+
filePath: "src/a.ts",
|
|
84
|
+
hunkIndex: 0,
|
|
85
|
+
side: "new",
|
|
86
|
+
line: 13,
|
|
87
|
+
},
|
|
88
|
+
}),
|
|
89
|
+
stderr: "",
|
|
90
|
+
code: 0,
|
|
91
|
+
};
|
|
92
|
+
};
|
|
93
|
+
const tool = capture(runner);
|
|
94
|
+
const args = {
|
|
95
|
+
ops: [
|
|
96
|
+
{ action: "navigate", file: "src/a.ts", hunk: 1 },
|
|
97
|
+
{ action: "comment", file: "src/a.ts", newLine: 13, summary: "Long body hidden" },
|
|
98
|
+
],
|
|
99
|
+
};
|
|
100
|
+
const result = await tool.execute("render", args, undefined, undefined, { cwd: "/repo" });
|
|
101
|
+
|
|
102
|
+
expect(tool.renderShell).toBe("self");
|
|
103
|
+
expect(rendered(tool.renderCall(args, taggedTheme, renderContext()))).toContain(
|
|
104
|
+
"[toolTitle][bold]hunk[/bold][/toolTitle] [dim]navigate +1[/dim]",
|
|
105
|
+
);
|
|
106
|
+
const compactContext = renderContext(false, true);
|
|
107
|
+
expect(rendered(tool.renderCall(args, taggedTheme, compactContext))).toBe("");
|
|
108
|
+
const compact = rendered(
|
|
109
|
+
tool.renderResult(result, { expanded: false, isPartial: false }, taggedTheme, compactContext),
|
|
110
|
+
);
|
|
111
|
+
expect(compact).toContain("[toolTitle][bold]hunk[/bold][/toolTitle]");
|
|
112
|
+
expect(compact).toContain("[dim]navigate +1[/dim]");
|
|
113
|
+
expect(compact).toContain("[muted]2 ops · 1 comment[/muted]");
|
|
114
|
+
expect(compact).not.toContain("mcp:3681add");
|
|
115
|
+
expect(compact).not.toContain("─");
|
|
116
|
+
|
|
117
|
+
const expanded = rendered(
|
|
118
|
+
tool.renderResult(
|
|
119
|
+
result,
|
|
120
|
+
{ expanded: true, isPartial: false },
|
|
121
|
+
taggedTheme,
|
|
122
|
+
renderContext(true, true),
|
|
123
|
+
),
|
|
124
|
+
);
|
|
125
|
+
expect(expanded).toContain("[success]────────────────────────────────");
|
|
126
|
+
expect(expanded).toContain("[dim]src/a.ts:h1[/dim] [muted]· 1 comment[/muted]");
|
|
127
|
+
expect(expanded).not.toContain("[text]navigate");
|
|
128
|
+
expect(expanded).not.toContain("[text]comment");
|
|
129
|
+
expect(expanded).not.toContain("[toolTitle]navigate");
|
|
130
|
+
expect(expanded).not.toContain("[toolTitle]comment");
|
|
131
|
+
expect(expanded).not.toContain("src/a.ts:new:13");
|
|
132
|
+
expect(expanded).not.toContain("mcp:3681add");
|
|
133
|
+
expect(expanded).not.toContain("Long body hidden");
|
|
134
|
+
|
|
135
|
+
const partial = rendered(
|
|
136
|
+
tool.renderResult(
|
|
137
|
+
result,
|
|
138
|
+
{ expanded: true, isPartial: true },
|
|
139
|
+
taggedTheme,
|
|
140
|
+
renderContext(true, true),
|
|
141
|
+
),
|
|
142
|
+
);
|
|
143
|
+
expect(partial).not.toContain("─");
|
|
144
|
+
|
|
145
|
+
const failed = await capture(async () => ({
|
|
146
|
+
stdout: "",
|
|
147
|
+
stderr: "session gone",
|
|
148
|
+
code: 1,
|
|
149
|
+
})).execute("render-error", { ops: [{ action: "list" }] }, undefined, undefined, {
|
|
150
|
+
cwd: "/repo",
|
|
151
|
+
});
|
|
152
|
+
const failedExpanded = rendered(
|
|
153
|
+
tool.renderResult(
|
|
154
|
+
failed,
|
|
155
|
+
{ expanded: true, isPartial: false },
|
|
156
|
+
taggedTheme,
|
|
157
|
+
renderContext(true, true),
|
|
158
|
+
),
|
|
159
|
+
);
|
|
160
|
+
expect(failedExpanded).toContain("[error]────────────────────────────────");
|
|
161
|
+
expect(failedExpanded).toContain("[error]list: session gone[/error]");
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
test("executes bulk operations in order and returns every parsed result", async () => {
|
|
165
|
+
const calls: string[][] = [];
|
|
166
|
+
const runner: HunkRunner = async (args) => {
|
|
167
|
+
calls.push(args);
|
|
168
|
+
return calls.length === 1
|
|
169
|
+
? { stdout: '{"sessions":[]}', stderr: "", code: 0 }
|
|
170
|
+
: { stdout: "", stderr: "No active Hunk sessions", code: 1 };
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
const result = await capture(runner).execute(
|
|
174
|
+
"t1",
|
|
175
|
+
{ ops: [{ action: "list" }, { action: "review", repo: "/work" }] },
|
|
176
|
+
undefined,
|
|
177
|
+
undefined,
|
|
178
|
+
{ cwd: "/cwd" },
|
|
179
|
+
);
|
|
180
|
+
|
|
181
|
+
expect(calls).toEqual([
|
|
182
|
+
["session", "list", "--json"],
|
|
183
|
+
["session", "review", "--repo", "/work", "--json"],
|
|
184
|
+
]);
|
|
185
|
+
expect(result.content[0]?.text).toBe("list sessions=0\nreview error: No active Hunk sessions");
|
|
186
|
+
expect(result.details).toEqual({
|
|
187
|
+
_type: "hunkResult",
|
|
188
|
+
outcome: "error",
|
|
189
|
+
results: [
|
|
190
|
+
{ action: "list", ok: true, data: { sessions: [] } },
|
|
191
|
+
{ action: "review", ok: false, error: "No active Hunk sessions" },
|
|
192
|
+
],
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test("summarizes review JSON as files and hunks instead of dumping it", async () => {
|
|
197
|
+
const runner: HunkRunner = async () => ({
|
|
198
|
+
stdout: JSON.stringify({
|
|
199
|
+
review: {
|
|
200
|
+
sessionId: "s1",
|
|
201
|
+
title: "Working tree",
|
|
202
|
+
selectedFile: { path: "src/a.ts" },
|
|
203
|
+
files: [
|
|
204
|
+
{
|
|
205
|
+
path: "src/a.ts",
|
|
206
|
+
additions: 5,
|
|
207
|
+
deletions: 2,
|
|
208
|
+
patch: "@@ -10,2 +10,3 @@\n-old\n+new",
|
|
209
|
+
hunks: [
|
|
210
|
+
{
|
|
211
|
+
index: 0,
|
|
212
|
+
header: "@@ -10,2 +10,3 @@",
|
|
213
|
+
oldRange: [10, 11],
|
|
214
|
+
newRange: [10, 12],
|
|
215
|
+
},
|
|
216
|
+
],
|
|
217
|
+
},
|
|
218
|
+
],
|
|
219
|
+
},
|
|
220
|
+
}),
|
|
221
|
+
stderr: "",
|
|
222
|
+
code: 0,
|
|
223
|
+
});
|
|
224
|
+
const result = await capture(runner).execute(
|
|
225
|
+
"t-review",
|
|
226
|
+
{ ops: [{ action: "review" }] },
|
|
227
|
+
undefined,
|
|
228
|
+
undefined,
|
|
229
|
+
{ cwd: "/repo" },
|
|
230
|
+
);
|
|
231
|
+
|
|
232
|
+
expect(result.content[0]?.text).toBe(
|
|
233
|
+
"review s1 Working tree\n" +
|
|
234
|
+
" selected src/a.ts\n" +
|
|
235
|
+
" src/a.ts +5 -2\n" +
|
|
236
|
+
" h1 old=10-11 new=10-12\n" +
|
|
237
|
+
" patch\n" +
|
|
238
|
+
" @@ -10,2 +10,3 @@\n" +
|
|
239
|
+
" -old\n" +
|
|
240
|
+
" +new",
|
|
241
|
+
);
|
|
242
|
+
expect(result.content[0]?.text).not.toContain("sessionId");
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
test("returns exact comment ids and locations for follow-up actions", async () => {
|
|
246
|
+
let call = 0;
|
|
247
|
+
const runner: HunkRunner = async () => {
|
|
248
|
+
call++;
|
|
249
|
+
if (call === 1) {
|
|
250
|
+
return {
|
|
251
|
+
stdout: JSON.stringify({
|
|
252
|
+
result: {
|
|
253
|
+
commentId: "c17",
|
|
254
|
+
filePath: "src/a.ts",
|
|
255
|
+
hunkIndex: 0,
|
|
256
|
+
side: "new",
|
|
257
|
+
line: 12,
|
|
258
|
+
},
|
|
259
|
+
}),
|
|
260
|
+
stderr: "",
|
|
261
|
+
code: 0,
|
|
262
|
+
};
|
|
263
|
+
}
|
|
264
|
+
if (call === 2) {
|
|
265
|
+
return {
|
|
266
|
+
stdout: JSON.stringify({
|
|
267
|
+
comments: [
|
|
268
|
+
{
|
|
269
|
+
noteId: "note-81bc",
|
|
270
|
+
filePath: "src/a.ts",
|
|
271
|
+
hunkIndex: 0,
|
|
272
|
+
newRange: [13, 13],
|
|
273
|
+
author: "xynogen",
|
|
274
|
+
body: "Can this be simpler?",
|
|
275
|
+
},
|
|
276
|
+
],
|
|
277
|
+
}),
|
|
278
|
+
stderr: "",
|
|
279
|
+
code: 0,
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
return {
|
|
283
|
+
stdout: JSON.stringify({
|
|
284
|
+
result: { commentId: "c17", removed: true, remainingCommentCount: 1 },
|
|
285
|
+
}),
|
|
286
|
+
stderr: "",
|
|
287
|
+
code: 0,
|
|
288
|
+
};
|
|
289
|
+
};
|
|
290
|
+
const result = await capture(runner).execute(
|
|
291
|
+
"t-comments",
|
|
292
|
+
{
|
|
293
|
+
ops: [
|
|
294
|
+
{ action: "comment", file: "src/a.ts", newLine: 12, summary: "Fix this" },
|
|
295
|
+
{ action: "comment_list", type: "user" },
|
|
296
|
+
{ action: "comment_rm", commentId: "c17" },
|
|
297
|
+
],
|
|
298
|
+
},
|
|
299
|
+
undefined,
|
|
300
|
+
undefined,
|
|
301
|
+
{ cwd: "/repo" },
|
|
302
|
+
);
|
|
303
|
+
|
|
304
|
+
expect(result.content[0]?.text).toBe(
|
|
305
|
+
"comment c17 src/a.ts:new:12 h1\n" +
|
|
306
|
+
"comment_list\n" +
|
|
307
|
+
"note-81bc src/a.ts:new:13 h1 @xynogen: Can this be simpler?\n" +
|
|
308
|
+
"comment_rm c17 remaining=1",
|
|
309
|
+
);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
test("budgets each operation so a large patch cannot hide later results", async () => {
|
|
313
|
+
let call = 0;
|
|
314
|
+
const runner: HunkRunner = async () => {
|
|
315
|
+
call++;
|
|
316
|
+
return call === 1
|
|
317
|
+
? {
|
|
318
|
+
stdout: JSON.stringify({
|
|
319
|
+
review: {
|
|
320
|
+
title: "Large review",
|
|
321
|
+
files: [
|
|
322
|
+
{
|
|
323
|
+
path: "src/large.ts",
|
|
324
|
+
additions: 1,
|
|
325
|
+
deletions: 0,
|
|
326
|
+
hunks: [],
|
|
327
|
+
patch: `+${"x".repeat(60_000)}`,
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
},
|
|
331
|
+
}),
|
|
332
|
+
stderr: "",
|
|
333
|
+
code: 0,
|
|
334
|
+
}
|
|
335
|
+
: {
|
|
336
|
+
stdout: JSON.stringify({
|
|
337
|
+
result: {
|
|
338
|
+
commentId: "c-final",
|
|
339
|
+
filePath: "src/final.ts",
|
|
340
|
+
hunkIndex: 2,
|
|
341
|
+
side: "new",
|
|
342
|
+
line: 44,
|
|
343
|
+
},
|
|
344
|
+
}),
|
|
345
|
+
stderr: "",
|
|
346
|
+
code: 0,
|
|
347
|
+
};
|
|
348
|
+
};
|
|
349
|
+
const result = await capture(runner).execute(
|
|
350
|
+
"t2",
|
|
351
|
+
{
|
|
352
|
+
ops: [
|
|
353
|
+
{ action: "review", includePatch: true },
|
|
354
|
+
{ action: "comment", file: "src/final.ts", newLine: 44, summary: "Fix" },
|
|
355
|
+
],
|
|
356
|
+
},
|
|
357
|
+
undefined,
|
|
358
|
+
undefined,
|
|
359
|
+
{ cwd: "/repo" },
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
expect(result.content[0]?.text.length).toBeLessThanOrEqual(10_000);
|
|
363
|
+
expect(result.content[0]?.text).toContain("review Large review");
|
|
364
|
+
expect(result.content[0]?.text).toContain("[truncated; full result in tool details]");
|
|
365
|
+
expect(result.content[0]?.text).toContain("comment c-final src/final.ts:new:44 h3");
|
|
366
|
+
expect(JSON.stringify(result.details).length).toBeGreaterThan(60_000);
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
test("allows a bounded model-output override", async () => {
|
|
370
|
+
const huge = JSON.stringify({
|
|
371
|
+
review: {
|
|
372
|
+
title: "Large review",
|
|
373
|
+
files: [
|
|
374
|
+
{
|
|
375
|
+
path: "src/large.ts",
|
|
376
|
+
additions: 1,
|
|
377
|
+
deletions: 0,
|
|
378
|
+
hunks: [],
|
|
379
|
+
patch: `+${"x".repeat(30_000)}`,
|
|
380
|
+
},
|
|
381
|
+
],
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
const runner: HunkRunner = async () => ({ stdout: huge, stderr: "", code: 0 });
|
|
385
|
+
const result = await capture(runner).execute(
|
|
386
|
+
"t-limit",
|
|
387
|
+
{ ops: [{ action: "review", includePatch: true }], maxCharacters: 20_000 },
|
|
388
|
+
undefined,
|
|
389
|
+
undefined,
|
|
390
|
+
{ cwd: "/repo" },
|
|
391
|
+
);
|
|
392
|
+
|
|
393
|
+
expect(result.content[0]?.text.length).toBeGreaterThan(10_500);
|
|
394
|
+
expect(result.content[0]?.text.length).toBeLessThanOrEqual(20_000);
|
|
395
|
+
expect(result.content[0]?.text).toContain("[truncated; full result in tool details]");
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
test("reports a missing Hunk executable clearly", async () => {
|
|
399
|
+
const runner: HunkRunner = async () => ({
|
|
400
|
+
stdout: "",
|
|
401
|
+
stderr: "spawn hunk ENOENT",
|
|
402
|
+
code: 1,
|
|
403
|
+
});
|
|
404
|
+
const result = await capture(runner).execute(
|
|
405
|
+
"t3",
|
|
406
|
+
{ ops: [{ action: "list" }] },
|
|
407
|
+
undefined,
|
|
408
|
+
undefined,
|
|
409
|
+
{ cwd: "/repo" },
|
|
410
|
+
);
|
|
411
|
+
|
|
412
|
+
expect(result.content[0]?.text).toContain("Hunk CLI not found");
|
|
413
|
+
});
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,539 @@
|
|
|
1
|
+
import { execFile } from "node:child_process";
|
|
2
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
3
|
+
import { Text } from "@earendil-works/pi-tui";
|
|
4
|
+
import {
|
|
5
|
+
dotJoin,
|
|
6
|
+
formatCollapsedToolRow,
|
|
7
|
+
frameToolResult,
|
|
8
|
+
hideCollapsedToolCall,
|
|
9
|
+
} from "@xynogen/pix-pretty/utils";
|
|
10
|
+
import { type CollapseState, tickCollapse } from "@xynogen/pix-runtime/collapse";
|
|
11
|
+
import { Type } from "typebox";
|
|
12
|
+
import {
|
|
13
|
+
buildHunkArgs,
|
|
14
|
+
type CommentType,
|
|
15
|
+
type HighlightTone,
|
|
16
|
+
type HunkAction,
|
|
17
|
+
type HunkOp,
|
|
18
|
+
} from "./lib.ts";
|
|
19
|
+
|
|
20
|
+
const MAX_OUTPUT_BYTES = 8 * 1024 * 1024;
|
|
21
|
+
const DEFAULT_MODEL_CHARS = 10_000;
|
|
22
|
+
|
|
23
|
+
export interface HunkRunResult {
|
|
24
|
+
stdout: string;
|
|
25
|
+
stderr: string;
|
|
26
|
+
code: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export type HunkRunner = (args: string[], signal?: AbortSignal) => Promise<HunkRunResult>;
|
|
30
|
+
|
|
31
|
+
export const runHunk: HunkRunner = (args, signal) =>
|
|
32
|
+
new Promise((resolve, reject) => {
|
|
33
|
+
execFile("hunk", args, { maxBuffer: MAX_OUTPUT_BYTES, signal }, (error, stdout, stderr) => {
|
|
34
|
+
if (error && signal?.aborted) {
|
|
35
|
+
reject(error);
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
resolve({
|
|
39
|
+
stdout,
|
|
40
|
+
stderr: stderr || error?.message || "",
|
|
41
|
+
code: typeof error?.code === "number" ? error.code : error ? 1 : 0,
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
47
|
+
|
|
48
|
+
interface HunkOperationResult {
|
|
49
|
+
action: HunkAction;
|
|
50
|
+
ok: boolean;
|
|
51
|
+
data?: JsonValue;
|
|
52
|
+
error?: string;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
interface HunkResultDetails {
|
|
56
|
+
_type: "hunkResult";
|
|
57
|
+
outcome: "success" | "error";
|
|
58
|
+
results: HunkOperationResult[];
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
type HunkTheme = {
|
|
62
|
+
fg: (color: string, text: string) => string;
|
|
63
|
+
bold: (text: string) => string;
|
|
64
|
+
};
|
|
65
|
+
|
|
66
|
+
function parseOutput(text: string): JsonValue {
|
|
67
|
+
const trimmed = text.trim();
|
|
68
|
+
if (!trimmed) return {};
|
|
69
|
+
try {
|
|
70
|
+
return JSON.parse(trimmed) as JsonValue;
|
|
71
|
+
} catch {
|
|
72
|
+
return trimmed;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const ActionSchema = Type.Enum(
|
|
77
|
+
[
|
|
78
|
+
"list",
|
|
79
|
+
"get",
|
|
80
|
+
"context",
|
|
81
|
+
"review",
|
|
82
|
+
"navigate",
|
|
83
|
+
"comment",
|
|
84
|
+
"comment_list",
|
|
85
|
+
"comment_rm",
|
|
86
|
+
"highlight",
|
|
87
|
+
"highlight_clear",
|
|
88
|
+
"reload",
|
|
89
|
+
] as const,
|
|
90
|
+
{ type: "string" },
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
const OpSchema = Type.Object({
|
|
94
|
+
action: ActionSchema,
|
|
95
|
+
repo: Type.Optional(Type.String({ description: "Session repo root (default: cwd)." })),
|
|
96
|
+
sessionId: Type.Optional(Type.String({ description: "Exact session id." })),
|
|
97
|
+
file: Type.Optional(Type.String({ description: "File path within the review." })),
|
|
98
|
+
hunk: Type.Optional(Type.Integer({ minimum: 1, description: "1-based hunk index." })),
|
|
99
|
+
newLine: Type.Optional(Type.Integer({ minimum: 1, description: "1-based new-side line." })),
|
|
100
|
+
oldLine: Type.Optional(Type.Integer({ minimum: 1, description: "1-based old-side line." })),
|
|
101
|
+
summary: Type.Optional(Type.String({ description: "Inline comment summary." })),
|
|
102
|
+
rationale: Type.Optional(Type.String({ description: "Inline comment rationale." })),
|
|
103
|
+
commentId: Type.Optional(Type.String({ description: "Comment id to remove." })),
|
|
104
|
+
type: Type.Optional(
|
|
105
|
+
Type.Enum(["live", "all", "ai", "agent", "user"] as const, {
|
|
106
|
+
type: "string",
|
|
107
|
+
description: "Comment-list filter.",
|
|
108
|
+
}),
|
|
109
|
+
),
|
|
110
|
+
start: Type.Optional(Type.Integer({ minimum: 0, description: "Highlight start offset." })),
|
|
111
|
+
end: Type.Optional(Type.Integer({ minimum: 1, description: "Highlight end offset." })),
|
|
112
|
+
tone: Type.Optional(
|
|
113
|
+
Type.Enum(["match", "info", "warning", "error", "current"] as const, {
|
|
114
|
+
type: "string",
|
|
115
|
+
}),
|
|
116
|
+
),
|
|
117
|
+
nextComment: Type.Optional(Type.Boolean()),
|
|
118
|
+
prevComment: Type.Optional(Type.Boolean()),
|
|
119
|
+
includePatch: Type.Optional(Type.Boolean()),
|
|
120
|
+
focus: Type.Optional(Type.Boolean()),
|
|
121
|
+
reloadArgs: Type.Optional(
|
|
122
|
+
Type.Array(Type.String(), { description: 'Reload command, e.g. ["diff","main...HEAD"].' }),
|
|
123
|
+
),
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
type ToolOp = HunkOp & { type?: CommentType; tone?: HighlightTone };
|
|
127
|
+
|
|
128
|
+
type JsonObject = { [key: string]: JsonValue };
|
|
129
|
+
|
|
130
|
+
function object(value: JsonValue | undefined): JsonObject | undefined {
|
|
131
|
+
return value && typeof value === "object" && !Array.isArray(value) ? value : undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function string(value: JsonValue | undefined): string | undefined {
|
|
135
|
+
return typeof value === "string" ? value : undefined;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function number(value: JsonValue | undefined): number | undefined {
|
|
139
|
+
return typeof value === "number" ? value : undefined;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function array(value: JsonValue | undefined): JsonValue[] {
|
|
143
|
+
return Array.isArray(value) ? value : [];
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function range(value: JsonValue | undefined): string | undefined {
|
|
147
|
+
const values = array(value);
|
|
148
|
+
const start = number(values[0]);
|
|
149
|
+
const end = number(values[1]);
|
|
150
|
+
return start !== undefined && end !== undefined ? `${start}-${end}` : undefined;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function boolean(value: JsonValue | undefined): boolean | undefined {
|
|
154
|
+
return typeof value === "boolean" ? value : undefined;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function inline(value: string | undefined, fallback = "-"): string {
|
|
158
|
+
return value?.replace(/\s+/g, " ").trim() || fallback;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function selection(file: string | undefined, hunk: number | undefined): string {
|
|
162
|
+
return file ? `${inline(file)}${hunk === undefined ? "" : `:h${hunk + 1}`}` : "none";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function ranges(value: JsonObject | undefined): string {
|
|
166
|
+
const oldRange = range(value?.oldRange);
|
|
167
|
+
const newRange = range(value?.newRange);
|
|
168
|
+
return [oldRange && `old=${oldRange}`, newRange && `new=${newRange}`].filter(Boolean).join(" ");
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function formatList(data: JsonObject): string[] {
|
|
172
|
+
const sessions = array(data.sessions);
|
|
173
|
+
if (sessions.length === 0) return ["list sessions=0"];
|
|
174
|
+
const lines = ["list"];
|
|
175
|
+
for (const rawSession of sessions) {
|
|
176
|
+
const session = object(rawSession);
|
|
177
|
+
if (!session) continue;
|
|
178
|
+
const state = object(object(object(session.snapshot)?.state));
|
|
179
|
+
const selected = selection(string(state?.selectedFilePath), number(state?.selectedHunkIndex));
|
|
180
|
+
lines.push(
|
|
181
|
+
` ${inline(string(session.sessionId))} ${inline(string(session.title), "")}`.trimEnd(),
|
|
182
|
+
` repo=${inline(string(session.repoRoot) ?? string(session.cwd))} selected=${selected} files=${number(session.fileCount) ?? array(session.files).length} comments=${number(state?.liveCommentCount) ?? 0}`,
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
return lines;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function formatGet(data: JsonObject): string[] {
|
|
189
|
+
const session = object(data.session) ?? data;
|
|
190
|
+
const state = object(object(object(session.snapshot)?.state));
|
|
191
|
+
return [
|
|
192
|
+
`get ${inline(string(session.sessionId))} ${inline(string(session.title), "")}`.trimEnd(),
|
|
193
|
+
` cwd=${inline(string(session.cwd))} repo=${inline(string(session.repoRoot))} source=${inline(string(session.sourceLabel))}`,
|
|
194
|
+
` selected ${selection(string(state?.selectedFilePath), number(state?.selectedHunkIndex))}`,
|
|
195
|
+
];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function formatContext(data: JsonObject): string[] {
|
|
199
|
+
const context = object(data.context) ?? data;
|
|
200
|
+
const selectedFile = object(context.selectedFile);
|
|
201
|
+
const selectedHunk = object(context.selectedHunk);
|
|
202
|
+
const detail = ranges(selectedHunk);
|
|
203
|
+
return [
|
|
204
|
+
`context ${inline(string(context.sessionId))} ${inline(string(context.title), "")}`.trimEnd(),
|
|
205
|
+
` selected ${selection(string(selectedFile?.path), number(selectedHunk?.index))}${detail ? ` ${detail}` : ""} comments=${number(context.liveCommentCount) ?? 0}`,
|
|
206
|
+
];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
function formatReview(data: JsonObject): string[] {
|
|
210
|
+
const review = object(data.review) ?? data;
|
|
211
|
+
const selectedFile = object(review.selectedFile);
|
|
212
|
+
const selectedHunk = object(review.selectedHunk);
|
|
213
|
+
const sessionId = string(review.sessionId);
|
|
214
|
+
const lines = [
|
|
215
|
+
`review${sessionId ? ` ${inline(sessionId)}` : ""} ${inline(string(review.title), "live review")}`,
|
|
216
|
+
` selected ${selection(string(selectedFile?.path), number(selectedHunk?.index))}`,
|
|
217
|
+
];
|
|
218
|
+
for (const rawFile of array(review.files)) {
|
|
219
|
+
const file = object(rawFile);
|
|
220
|
+
if (!file) continue;
|
|
221
|
+
lines.push(
|
|
222
|
+
` ${inline(string(file.path), "(unknown file)")} +${number(file.additions) ?? 0} -${number(file.deletions) ?? 0}`,
|
|
223
|
+
);
|
|
224
|
+
for (const rawHunk of array(file.hunks)) {
|
|
225
|
+
const hunk = object(rawHunk);
|
|
226
|
+
if (!hunk) continue;
|
|
227
|
+
const detail = ranges(hunk);
|
|
228
|
+
lines.push(` h${(number(hunk.index) ?? 0) + 1}${detail ? ` ${detail}` : ""}`);
|
|
229
|
+
}
|
|
230
|
+
const patch = string(file.patch);
|
|
231
|
+
if (patch) lines.push(" patch", ...patch.split("\n").map((line) => ` ${line}`));
|
|
232
|
+
}
|
|
233
|
+
return lines;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function target(result: JsonObject): string {
|
|
237
|
+
const file = inline(string(result.filePath), "(unknown file)");
|
|
238
|
+
const side = string(result.side);
|
|
239
|
+
const line = number(result.line);
|
|
240
|
+
const location = side && line !== undefined ? `${file}:${side}:${line}` : file;
|
|
241
|
+
const hunk = number(result.hunkIndex);
|
|
242
|
+
return `${location}${hunk === undefined ? "" : ` h${hunk + 1}`}`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatComment(data: JsonObject): string[] {
|
|
246
|
+
const result = object(data.result) ?? data;
|
|
247
|
+
return [`comment ${inline(string(result.commentId))} ${target(result)}`];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function noteTarget(comment: JsonObject): string {
|
|
251
|
+
const file = inline(string(comment.filePath), "(unknown file)");
|
|
252
|
+
const hunk = number(comment.hunkIndex);
|
|
253
|
+
const suffix = hunk === undefined ? "" : ` h${hunk + 1}`;
|
|
254
|
+
const oldRange = array(comment.oldRange);
|
|
255
|
+
const newRange = array(comment.newRange);
|
|
256
|
+
const oldStart = number(oldRange[0]);
|
|
257
|
+
const oldEnd = number(oldRange[1]);
|
|
258
|
+
const newStart = number(newRange[0]);
|
|
259
|
+
const newEnd = number(newRange[1]);
|
|
260
|
+
if (newStart !== undefined && newStart === newEnd && oldStart === undefined) {
|
|
261
|
+
return `${file}:new:${newStart}${suffix}`;
|
|
262
|
+
}
|
|
263
|
+
if (oldStart !== undefined && oldStart === oldEnd && newStart === undefined) {
|
|
264
|
+
return `${file}:old:${oldStart}${suffix}`;
|
|
265
|
+
}
|
|
266
|
+
const detail = ranges(comment);
|
|
267
|
+
return `${file}${suffix}${detail ? ` ${detail}` : ""}`;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function formatCommentList(data: JsonObject): string[] {
|
|
271
|
+
const comments = array(data.comments);
|
|
272
|
+
if (comments.length === 0) return ["comment_list none"];
|
|
273
|
+
const lines = ["comment_list"];
|
|
274
|
+
for (const rawComment of comments) {
|
|
275
|
+
const comment = object(rawComment);
|
|
276
|
+
if (!comment) continue;
|
|
277
|
+
const id = inline(string(comment.commentId) ?? string(comment.noteId));
|
|
278
|
+
const detail = target(comment);
|
|
279
|
+
const location = string(comment.side) ? detail : noteTarget(comment);
|
|
280
|
+
const author = string(comment.author);
|
|
281
|
+
const body = string(comment.summary) ?? string(comment.body) ?? "";
|
|
282
|
+
const prefix = `${id} ${location}${author ? ` @${inline(author)}` : ""}:`;
|
|
283
|
+
const bodyLines = body.split(/\r?\n/);
|
|
284
|
+
lines.push(`${prefix} ${bodyLines[0] ?? ""}`.trimEnd());
|
|
285
|
+
lines.push(...bodyLines.slice(1).map((line) => ` ${line}`));
|
|
286
|
+
}
|
|
287
|
+
return lines;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function formatResultAction(action: HunkAction, data: JsonObject, op: ToolOp): string[] {
|
|
291
|
+
const result = object(data.result) ?? data;
|
|
292
|
+
switch (action) {
|
|
293
|
+
case "list":
|
|
294
|
+
return formatList(data);
|
|
295
|
+
case "get":
|
|
296
|
+
return formatGet(data);
|
|
297
|
+
case "context":
|
|
298
|
+
return formatContext(data);
|
|
299
|
+
case "review":
|
|
300
|
+
return formatReview(data);
|
|
301
|
+
case "navigate": {
|
|
302
|
+
const detail = ranges(object(result.selectedHunk));
|
|
303
|
+
return [
|
|
304
|
+
`navigate ${selection(string(result.filePath), number(result.hunkIndex))}${detail ? ` ${detail}` : ""}`,
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
case "comment":
|
|
308
|
+
return formatComment(data);
|
|
309
|
+
case "comment_list":
|
|
310
|
+
return formatCommentList(data);
|
|
311
|
+
case "comment_rm":
|
|
312
|
+
return [
|
|
313
|
+
`comment_rm ${inline(string(result.commentId) ?? op.commentId)}${boolean(result.removed) === false ? " removed=false" : ""} remaining=${number(result.remainingCommentCount) ?? "-"}`,
|
|
314
|
+
];
|
|
315
|
+
case "highlight": {
|
|
316
|
+
const side = op.newLine === undefined ? "old" : "new";
|
|
317
|
+
const line = op.newLine ?? op.oldLine;
|
|
318
|
+
return [
|
|
319
|
+
`highlight ${inline(op.file)}:${side}:${line ?? "-"} chars=${op.start}-${op.end}${op.tone ? ` tone=${op.tone}` : ""}${op.focus ? " focus" : ""}`,
|
|
320
|
+
];
|
|
321
|
+
}
|
|
322
|
+
case "highlight_clear":
|
|
323
|
+
return [`highlight_clear ${op.file ? inline(op.file) : "session"}`];
|
|
324
|
+
case "reload":
|
|
325
|
+
return [
|
|
326
|
+
`reload ${inline(string(result.title))} files=${number(result.fileCount) ?? 0} selected=${selection(string(result.selectedFilePath), number(result.selectedHunkIndex))}`,
|
|
327
|
+
];
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function formatOperation(result: HunkOperationResult, op: ToolOp): string {
|
|
332
|
+
if (!result.ok) return `${result.action} error: ${result.error ?? "failed"}`;
|
|
333
|
+
const data = object(result.data);
|
|
334
|
+
return data
|
|
335
|
+
? formatResultAction(result.action, data, op).join("\n")
|
|
336
|
+
: `${result.action} complete`;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
function truncateRecord(text: string, limit: number): string {
|
|
340
|
+
if (text.length <= limit) return text;
|
|
341
|
+
const marker = "\n [truncated; full result in tool details]";
|
|
342
|
+
const header = text.split("\n", 1)[0] ?? text;
|
|
343
|
+
if (header.length + marker.length > limit) return header.slice(0, limit);
|
|
344
|
+
return `${text.slice(0, limit - marker.length).trimEnd()}${marker}`;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function modelText(results: HunkOperationResult[], ops: ToolOp[], maxCharacters: number): string {
|
|
348
|
+
const separators = Math.max(0, results.length - 1);
|
|
349
|
+
const available = maxCharacters - separators;
|
|
350
|
+
const quota = Math.floor(available / results.length);
|
|
351
|
+
let remainder = available % results.length;
|
|
352
|
+
return results
|
|
353
|
+
.map((result, index) => {
|
|
354
|
+
const limit = quota + (remainder-- > 0 ? 1 : 0);
|
|
355
|
+
return truncateRecord(
|
|
356
|
+
formatOperation(result, ops[index] ?? { action: result.action }),
|
|
357
|
+
limit,
|
|
358
|
+
);
|
|
359
|
+
})
|
|
360
|
+
.join("\n");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function executionError(run: HunkRunResult): string {
|
|
364
|
+
const raw = (run.stderr || run.stdout || `hunk exited ${run.code}`).trim();
|
|
365
|
+
return /(?:^|\s)(?:spawn\s+)?hunk\s+ENOENT(?:\s|$)/i.test(raw)
|
|
366
|
+
? "Hunk CLI not found. Install Hunk, launch a review session, then retry."
|
|
367
|
+
: raw;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
function actionSummary(actions: HunkAction[]): string {
|
|
371
|
+
if (actions.length === 0) return "empty";
|
|
372
|
+
return `${actions[0]}${actions.length > 1 ? ` +${actions.length - 1}` : ""}`;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
function listedCommentCount(result: HunkOperationResult): number | undefined {
|
|
376
|
+
if (result.action !== "comment_list" || !result.ok) return undefined;
|
|
377
|
+
return array(object(result.data)?.comments).length;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
function commentCount(results: HunkOperationResult[]): number {
|
|
381
|
+
const listed = results.map(listedCommentCount).find((count) => count !== undefined);
|
|
382
|
+
return listed ?? results.filter((result) => result.action === "comment" && result.ok).length;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function renderSummary(details: HunkResultDetails, theme: HunkTheme): string {
|
|
386
|
+
const actions = details.results.map((result) => result.action);
|
|
387
|
+
const comments = commentCount(details.results);
|
|
388
|
+
const meta = dotJoin([
|
|
389
|
+
`${details.results.length} ops`,
|
|
390
|
+
comments > 0 && `${comments} comment${comments === 1 ? "" : "s"}`,
|
|
391
|
+
]);
|
|
392
|
+
return formatCollapsedToolRow(
|
|
393
|
+
theme,
|
|
394
|
+
"hunk",
|
|
395
|
+
actionSummary(actions),
|
|
396
|
+
meta,
|
|
397
|
+
details.outcome === "error" ? "error" : "success",
|
|
398
|
+
);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function resultTarget(result: HunkOperationResult): string {
|
|
402
|
+
const data = object(result.data);
|
|
403
|
+
const value = object(data?.result) ?? data;
|
|
404
|
+
if (!value) return result.ok ? "complete" : (result.error ?? "failed");
|
|
405
|
+
if (result.action === "comment_list") {
|
|
406
|
+
const count = array(data?.comments).length;
|
|
407
|
+
return count === 0 ? "none" : `${count} comment${count === 1 ? "" : "s"}`;
|
|
408
|
+
}
|
|
409
|
+
if (result.action === "list") return `sessions=${array(data?.sessions).length}`;
|
|
410
|
+
if (result.action === "comment_rm") {
|
|
411
|
+
return `remaining=${number(value.remainingCommentCount) ?? "-"}`;
|
|
412
|
+
}
|
|
413
|
+
const file = string(value.filePath) ?? string(object(value.selectedFile)?.path);
|
|
414
|
+
const hunk = number(value.hunkIndex) ?? number(object(value.selectedHunk)?.index);
|
|
415
|
+
const side = string(value.side);
|
|
416
|
+
const line = number(value.line);
|
|
417
|
+
if (file && side && line !== undefined) {
|
|
418
|
+
return `${file}:${side}:${line}${hunk === undefined ? "" : ` h${hunk + 1}`}`;
|
|
419
|
+
}
|
|
420
|
+
if (file) return selection(file, hunk);
|
|
421
|
+
return "complete";
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function renderExpanded(details: HunkResultDetails, theme: HunkTheme): string {
|
|
425
|
+
const targetResult =
|
|
426
|
+
details.results.find((result) => result.ok && result.action === "navigate") ??
|
|
427
|
+
details.results.find(
|
|
428
|
+
(result) => result.ok && !["comment_list", "comment_rm"].includes(result.action),
|
|
429
|
+
);
|
|
430
|
+
const target = targetResult ? resultTarget(targetResult) : "complete";
|
|
431
|
+
const comments = commentCount(details.results);
|
|
432
|
+
const meta = comments > 0 ? `${comments} comment${comments === 1 ? "" : "s"}` : "";
|
|
433
|
+
const summary = `${theme.fg("dim", target)}${meta ? ` ${theme.fg("muted", `· ${meta}`)}` : ""}`;
|
|
434
|
+
const errors = details.results
|
|
435
|
+
.filter((result) => !result.ok)
|
|
436
|
+
.map((result) => theme.fg("error", `${result.action}: ${result.error ?? "failed"}`));
|
|
437
|
+
return [summary, ...errors].join("\n");
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
export default function registerHunk(pi: ExtensionAPI, runner: HunkRunner = runHunk): void {
|
|
441
|
+
pi.registerTool({
|
|
442
|
+
name: "hunk",
|
|
443
|
+
label: "Hunk",
|
|
444
|
+
renderShell: "self",
|
|
445
|
+
description:
|
|
446
|
+
"Drive a live Hunk diff review through `hunk session *`. Runs ordered `ops` in one call and returns every result. Supports inspection, navigation, comments, highlights, and reload. Never launches the interactive TUI.",
|
|
447
|
+
promptSnippet: "Drive a live Hunk diff review with an ordered batch of session operations",
|
|
448
|
+
promptGuidelines: [
|
|
449
|
+
"hunk: start with `review` (raw patch only via `includePatch:true`); use `navigate`, `comment`, and `highlight` to guide the live review. If no session exists, ask the user to launch Hunk.",
|
|
450
|
+
],
|
|
451
|
+
parameters: Type.Object({
|
|
452
|
+
ops: Type.Array(OpSchema, {
|
|
453
|
+
minItems: 1,
|
|
454
|
+
maxItems: 20,
|
|
455
|
+
description: "Operations executed in order; all results are returned.",
|
|
456
|
+
}),
|
|
457
|
+
maxCharacters: Type.Optional(
|
|
458
|
+
Type.Integer({
|
|
459
|
+
minimum: 1_000,
|
|
460
|
+
maximum: 50_000,
|
|
461
|
+
description: "Maximum model-facing result characters (default 10000).",
|
|
462
|
+
}),
|
|
463
|
+
),
|
|
464
|
+
}),
|
|
465
|
+
renderCall(args, theme, context) {
|
|
466
|
+
const text = new Text("", 0, 0);
|
|
467
|
+
if (
|
|
468
|
+
hideCollapsedToolCall(context.state as CollapseState, context.expanded, (value) =>
|
|
469
|
+
text.setText(value),
|
|
470
|
+
)
|
|
471
|
+
)
|
|
472
|
+
return text;
|
|
473
|
+
const actions = ((args as { ops?: ToolOp[] }).ops ?? []).map((op) => op.action);
|
|
474
|
+
text.setText(
|
|
475
|
+
`${theme.fg("toolTitle", theme.bold("hunk"))} ${theme.fg("dim", actionSummary(actions))}`,
|
|
476
|
+
);
|
|
477
|
+
return text;
|
|
478
|
+
},
|
|
479
|
+
renderResult(result, options, theme, context) {
|
|
480
|
+
const text = new Text("", 0, 0);
|
|
481
|
+
const details = result.details as HunkResultDetails | undefined;
|
|
482
|
+
if (!details) {
|
|
483
|
+
text.setText(
|
|
484
|
+
result.content.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"),
|
|
485
|
+
);
|
|
486
|
+
return text;
|
|
487
|
+
}
|
|
488
|
+
const collapsed = tickCollapse(
|
|
489
|
+
"hunk",
|
|
490
|
+
context.state as CollapseState,
|
|
491
|
+
context.invalidate,
|
|
492
|
+
options.expanded,
|
|
493
|
+
);
|
|
494
|
+
if (collapsed) {
|
|
495
|
+
text.setText(renderSummary(details, theme as HunkTheme));
|
|
496
|
+
return text;
|
|
497
|
+
}
|
|
498
|
+
text.setText(renderExpanded(details, theme as HunkTheme));
|
|
499
|
+
return options.isPartial
|
|
500
|
+
? text
|
|
501
|
+
: frameToolResult(text, theme, context.isError || details.outcome === "error");
|
|
502
|
+
},
|
|
503
|
+
async execute(_toolCallId, params, signal, _onUpdate, ctx) {
|
|
504
|
+
const ops = params.ops as ToolOp[];
|
|
505
|
+
const results: HunkOperationResult[] = [];
|
|
506
|
+
for (const op of ops) {
|
|
507
|
+
try {
|
|
508
|
+
const run = await runner(buildHunkArgs(op, ctx.cwd), signal);
|
|
509
|
+
results.push(
|
|
510
|
+
run.code === 0
|
|
511
|
+
? { action: op.action, ok: true, data: parseOutput(run.stdout || run.stderr) }
|
|
512
|
+
: { action: op.action, ok: false, error: executionError(run) },
|
|
513
|
+
);
|
|
514
|
+
} catch (error) {
|
|
515
|
+
if (signal?.aborted) throw error;
|
|
516
|
+
results.push({
|
|
517
|
+
action: op.action,
|
|
518
|
+
ok: false,
|
|
519
|
+
error: error instanceof Error ? error.message : String(error),
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const details: HunkResultDetails = {
|
|
524
|
+
_type: "hunkResult",
|
|
525
|
+
outcome: results.every((result) => result.ok) ? "success" : "error",
|
|
526
|
+
results,
|
|
527
|
+
};
|
|
528
|
+
return {
|
|
529
|
+
content: [
|
|
530
|
+
{
|
|
531
|
+
type: "text" as const,
|
|
532
|
+
text: modelText(results, ops, params.maxCharacters ?? DEFAULT_MODEL_CHARS),
|
|
533
|
+
},
|
|
534
|
+
],
|
|
535
|
+
details,
|
|
536
|
+
};
|
|
537
|
+
},
|
|
538
|
+
});
|
|
539
|
+
}
|
package/src/lib.test.ts
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { buildHunkArgs, type HunkOp } from "./lib.ts";
|
|
3
|
+
|
|
4
|
+
describe("buildHunkArgs", () => {
|
|
5
|
+
test("lists sessions as JSON", () => {
|
|
6
|
+
expect(buildHunkArgs({ action: "list" }, "/repo")).toEqual(["session", "list", "--json"]);
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
test("builds documented argv for every review operation", () => {
|
|
10
|
+
const cases: Array<[HunkOp, string[]]> = [
|
|
11
|
+
[{ action: "get" as const }, ["session", "get", "--repo", "/repo", "--json"]],
|
|
12
|
+
[{ action: "context" as const, sessionId: "s1" }, ["session", "context", "s1", "--json"]],
|
|
13
|
+
[
|
|
14
|
+
{ action: "review" as const, includePatch: true },
|
|
15
|
+
["session", "review", "--repo", "/repo", "--include-patch", "--json"],
|
|
16
|
+
],
|
|
17
|
+
[
|
|
18
|
+
{ action: "navigate" as const, file: "src/a.ts", hunk: 2 },
|
|
19
|
+
["session", "navigate", "--repo", "/repo", "--file", "src/a.ts", "--hunk", "2", "--json"],
|
|
20
|
+
],
|
|
21
|
+
[
|
|
22
|
+
{ action: "navigate" as const, nextComment: true },
|
|
23
|
+
["session", "navigate", "--repo", "/repo", "--next-comment", "--json"],
|
|
24
|
+
],
|
|
25
|
+
[
|
|
26
|
+
{
|
|
27
|
+
action: "comment" as const,
|
|
28
|
+
file: "src/a.ts",
|
|
29
|
+
newLine: 42,
|
|
30
|
+
summary: "Fix this",
|
|
31
|
+
rationale: "It breaks",
|
|
32
|
+
focus: true,
|
|
33
|
+
},
|
|
34
|
+
[
|
|
35
|
+
"session",
|
|
36
|
+
"comment",
|
|
37
|
+
"add",
|
|
38
|
+
"--repo",
|
|
39
|
+
"/repo",
|
|
40
|
+
"--file",
|
|
41
|
+
"src/a.ts",
|
|
42
|
+
"--new-line",
|
|
43
|
+
"42",
|
|
44
|
+
"--summary",
|
|
45
|
+
"Fix this",
|
|
46
|
+
"--rationale",
|
|
47
|
+
"It breaks",
|
|
48
|
+
"--focus",
|
|
49
|
+
"--json",
|
|
50
|
+
],
|
|
51
|
+
],
|
|
52
|
+
[
|
|
53
|
+
{ action: "comment_list" as const, file: "src/a.ts", type: "user" as const },
|
|
54
|
+
[
|
|
55
|
+
"session",
|
|
56
|
+
"comment",
|
|
57
|
+
"list",
|
|
58
|
+
"--repo",
|
|
59
|
+
"/repo",
|
|
60
|
+
"--file",
|
|
61
|
+
"src/a.ts",
|
|
62
|
+
"--type",
|
|
63
|
+
"user",
|
|
64
|
+
"--json",
|
|
65
|
+
],
|
|
66
|
+
],
|
|
67
|
+
[
|
|
68
|
+
{ action: "comment_rm" as const, commentId: "c1" },
|
|
69
|
+
["session", "comment", "rm", "--repo", "/repo", "c1", "--json"],
|
|
70
|
+
],
|
|
71
|
+
[
|
|
72
|
+
{
|
|
73
|
+
action: "highlight" as const,
|
|
74
|
+
file: "src/a.ts",
|
|
75
|
+
oldLine: 9,
|
|
76
|
+
start: 1,
|
|
77
|
+
end: 4,
|
|
78
|
+
tone: "warning" as const,
|
|
79
|
+
},
|
|
80
|
+
[
|
|
81
|
+
"session",
|
|
82
|
+
"highlight",
|
|
83
|
+
"add",
|
|
84
|
+
"--repo",
|
|
85
|
+
"/repo",
|
|
86
|
+
"--file",
|
|
87
|
+
"src/a.ts",
|
|
88
|
+
"--old-line",
|
|
89
|
+
"9",
|
|
90
|
+
"--start",
|
|
91
|
+
"1",
|
|
92
|
+
"--end",
|
|
93
|
+
"4",
|
|
94
|
+
"--tone",
|
|
95
|
+
"warning",
|
|
96
|
+
"--json",
|
|
97
|
+
],
|
|
98
|
+
],
|
|
99
|
+
[
|
|
100
|
+
{ action: "highlight_clear" as const, file: "src/a.ts" },
|
|
101
|
+
["session", "highlight", "clear", "--repo", "/repo", "--file", "src/a.ts", "--json"],
|
|
102
|
+
],
|
|
103
|
+
[
|
|
104
|
+
{ action: "reload" as const, reloadArgs: ["diff", "main...HEAD", "--", "src"] },
|
|
105
|
+
[
|
|
106
|
+
"session",
|
|
107
|
+
"reload",
|
|
108
|
+
"--repo",
|
|
109
|
+
"/repo",
|
|
110
|
+
"--json",
|
|
111
|
+
"--",
|
|
112
|
+
"diff",
|
|
113
|
+
"main...HEAD",
|
|
114
|
+
"--",
|
|
115
|
+
"src",
|
|
116
|
+
],
|
|
117
|
+
],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
for (const [op, expected] of cases) {
|
|
121
|
+
expect(buildHunkArgs(op, "/repo")).toEqual(expected);
|
|
122
|
+
}
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("rejects invalid action fields before execution", () => {
|
|
126
|
+
const invalid = [
|
|
127
|
+
[{ action: "navigate" as const, file: "a.ts" }, "navigation target"],
|
|
128
|
+
[
|
|
129
|
+
{ action: "navigate" as const, nextComment: true, prevComment: true },
|
|
130
|
+
"one comment direction",
|
|
131
|
+
],
|
|
132
|
+
[{ action: "comment" as const, file: "a.ts", newLine: 1 }, "summary"],
|
|
133
|
+
[
|
|
134
|
+
{ action: "highlight" as const, file: "a.ts", newLine: 1, start: 4, end: 4 },
|
|
135
|
+
"greater than start",
|
|
136
|
+
],
|
|
137
|
+
[{ action: "comment_rm" as const }, "commentId"],
|
|
138
|
+
] as const;
|
|
139
|
+
|
|
140
|
+
for (const [op, message] of invalid) {
|
|
141
|
+
expect(() => buildHunkArgs(op, "/repo")).toThrow(message);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
});
|
package/src/lib.ts
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
export type HunkAction =
|
|
2
|
+
| "list"
|
|
3
|
+
| "get"
|
|
4
|
+
| "context"
|
|
5
|
+
| "review"
|
|
6
|
+
| "navigate"
|
|
7
|
+
| "comment"
|
|
8
|
+
| "comment_list"
|
|
9
|
+
| "comment_rm"
|
|
10
|
+
| "highlight"
|
|
11
|
+
| "highlight_clear"
|
|
12
|
+
| "reload";
|
|
13
|
+
|
|
14
|
+
export type CommentType = "live" | "all" | "ai" | "agent" | "user";
|
|
15
|
+
export type HighlightTone = "match" | "info" | "warning" | "error" | "current";
|
|
16
|
+
|
|
17
|
+
export interface HunkOp {
|
|
18
|
+
action: HunkAction;
|
|
19
|
+
repo?: string;
|
|
20
|
+
sessionId?: string;
|
|
21
|
+
file?: string;
|
|
22
|
+
hunk?: number;
|
|
23
|
+
newLine?: number;
|
|
24
|
+
oldLine?: number;
|
|
25
|
+
summary?: string;
|
|
26
|
+
rationale?: string;
|
|
27
|
+
commentId?: string;
|
|
28
|
+
type?: CommentType;
|
|
29
|
+
start?: number;
|
|
30
|
+
end?: number;
|
|
31
|
+
tone?: HighlightTone;
|
|
32
|
+
nextComment?: boolean;
|
|
33
|
+
prevComment?: boolean;
|
|
34
|
+
includePatch?: boolean;
|
|
35
|
+
focus?: boolean;
|
|
36
|
+
reloadArgs?: string[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function selector(op: HunkOp, cwd: string): string[] {
|
|
40
|
+
return op.sessionId ? [op.sessionId] : ["--repo", op.repo ?? cwd];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function lineTarget(op: HunkOp): string[] {
|
|
44
|
+
if (op.newLine !== undefined) return ["--new-line", String(op.newLine)];
|
|
45
|
+
if (op.oldLine !== undefined) return ["--old-line", String(op.oldLine)];
|
|
46
|
+
return [];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function hasOneLineTarget(op: HunkOp): boolean {
|
|
50
|
+
return (op.newLine !== undefined ? 1 : 0) + (op.oldLine !== undefined ? 1 : 0) === 1;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function validateOp(op: HunkOp): void {
|
|
54
|
+
if (op.action === "navigate") {
|
|
55
|
+
const directions = (op.nextComment ? 1 : 0) + (op.prevComment ? 1 : 0);
|
|
56
|
+
if (directions > 1) throw new Error("navigate accepts one comment direction");
|
|
57
|
+
const absoluteTargets =
|
|
58
|
+
(op.hunk !== undefined ? 1 : 0) +
|
|
59
|
+
(op.newLine !== undefined ? 1 : 0) +
|
|
60
|
+
(op.oldLine !== undefined ? 1 : 0);
|
|
61
|
+
if (directions === 0 && (!op.file || absoluteTargets !== 1)) {
|
|
62
|
+
throw new Error("navigate requires file and exactly one navigation target");
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
if (op.action === "comment" && (!op.file || !op.summary || !hasOneLineTarget(op))) {
|
|
66
|
+
throw new Error("comment requires file, summary, and exactly one line target");
|
|
67
|
+
}
|
|
68
|
+
if (op.action === "comment_rm" && !op.commentId) {
|
|
69
|
+
throw new Error("comment_rm requires commentId");
|
|
70
|
+
}
|
|
71
|
+
if (op.action === "highlight") {
|
|
72
|
+
if (!op.file || !hasOneLineTarget(op) || op.start === undefined || op.end === undefined) {
|
|
73
|
+
throw new Error("highlight requires file, one line target, start, and end");
|
|
74
|
+
}
|
|
75
|
+
if (op.end <= op.start) throw new Error("highlight end must be greater than start");
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function buildHunkArgs(op: HunkOp, cwd: string): string[] {
|
|
80
|
+
validateOp(op);
|
|
81
|
+
const args = ["session"];
|
|
82
|
+
|
|
83
|
+
switch (op.action) {
|
|
84
|
+
case "list":
|
|
85
|
+
args.push("list");
|
|
86
|
+
break;
|
|
87
|
+
case "get":
|
|
88
|
+
case "context":
|
|
89
|
+
args.push(op.action, ...selector(op, cwd));
|
|
90
|
+
break;
|
|
91
|
+
case "review":
|
|
92
|
+
args.push("review", ...selector(op, cwd));
|
|
93
|
+
if (op.includePatch) args.push("--include-patch");
|
|
94
|
+
break;
|
|
95
|
+
case "navigate":
|
|
96
|
+
args.push("navigate", ...selector(op, cwd));
|
|
97
|
+
if (op.nextComment) args.push("--next-comment");
|
|
98
|
+
else if (op.prevComment) args.push("--prev-comment");
|
|
99
|
+
else {
|
|
100
|
+
if (op.file) args.push("--file", op.file);
|
|
101
|
+
if (op.hunk !== undefined) args.push("--hunk", String(op.hunk));
|
|
102
|
+
else args.push(...lineTarget(op));
|
|
103
|
+
}
|
|
104
|
+
break;
|
|
105
|
+
case "comment":
|
|
106
|
+
args.push("comment", "add", ...selector(op, cwd));
|
|
107
|
+
if (op.file) args.push("--file", op.file);
|
|
108
|
+
args.push(...lineTarget(op));
|
|
109
|
+
if (op.summary) args.push("--summary", op.summary);
|
|
110
|
+
if (op.rationale) args.push("--rationale", op.rationale);
|
|
111
|
+
if (op.focus) args.push("--focus");
|
|
112
|
+
break;
|
|
113
|
+
case "comment_list":
|
|
114
|
+
args.push("comment", "list", ...selector(op, cwd));
|
|
115
|
+
if (op.file) args.push("--file", op.file);
|
|
116
|
+
if (op.type) args.push("--type", op.type);
|
|
117
|
+
break;
|
|
118
|
+
case "comment_rm":
|
|
119
|
+
args.push("comment", "rm", ...selector(op, cwd));
|
|
120
|
+
if (op.commentId) args.push(op.commentId);
|
|
121
|
+
break;
|
|
122
|
+
case "highlight":
|
|
123
|
+
args.push("highlight", "add", ...selector(op, cwd));
|
|
124
|
+
if (op.file) args.push("--file", op.file);
|
|
125
|
+
args.push(...lineTarget(op));
|
|
126
|
+
if (op.start !== undefined) args.push("--start", String(op.start));
|
|
127
|
+
if (op.end !== undefined) args.push("--end", String(op.end));
|
|
128
|
+
if (op.tone) args.push("--tone", op.tone);
|
|
129
|
+
if (op.focus) args.push("--focus");
|
|
130
|
+
break;
|
|
131
|
+
case "highlight_clear":
|
|
132
|
+
args.push("highlight", "clear", ...selector(op, cwd));
|
|
133
|
+
if (op.file) args.push("--file", op.file);
|
|
134
|
+
break;
|
|
135
|
+
case "reload":
|
|
136
|
+
args.push("reload", ...selector(op, cwd), "--json", "--", ...(op.reloadArgs ?? ["diff"]));
|
|
137
|
+
return args;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
args.push("--json");
|
|
141
|
+
return args;
|
|
142
|
+
}
|