@crouton-kit/humanloop 0.3.8 → 0.3.10
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/dist/api.js +9 -3
- package/dist/cli.js +55 -6
- package/dist/editor/review.d.ts +26 -0
- package/dist/editor/review.js +191 -29
- package/dist/tui/input.js +89 -12
- package/dist/tui/render.js +32 -9
- package/dist/types.d.ts +5 -1
- package/package.json +1 -1
package/dist/api.js
CHANGED
|
@@ -25,11 +25,17 @@ function buildSummary(deck, responses) {
|
|
|
25
25
|
const ft = r.freetext !== undefined && r.freetext !== '' ? r.freetext : undefined;
|
|
26
26
|
let picked;
|
|
27
27
|
if (r.selectedOptionIds !== undefined) {
|
|
28
|
-
const
|
|
28
|
+
const oc = r.optionComments;
|
|
29
|
+
const parts = r.selectedOptionIds
|
|
29
30
|
.map((id) => it.options.find((o) => o.id === id))
|
|
30
31
|
.filter((o) => o !== undefined)
|
|
31
|
-
.map((o) =>
|
|
32
|
-
|
|
32
|
+
.map((o) => {
|
|
33
|
+
const note = oc !== undefined ? oc[o.id] : undefined;
|
|
34
|
+
return typeof note === 'string' && note.length > 0
|
|
35
|
+
? `${o.label} ("${note}")`
|
|
36
|
+
: o.label;
|
|
37
|
+
});
|
|
38
|
+
picked = parts.length > 0 ? parts.join(', ') : undefined;
|
|
33
39
|
}
|
|
34
40
|
else if (r.selectedOptionId !== undefined) {
|
|
35
41
|
picked = it.options.find((o) => o.id === r.selectedOptionId)?.label;
|
package/dist/cli.js
CHANGED
|
@@ -5,7 +5,7 @@ import { readdirSync } from 'node:fs';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { resolve, join, basename } from 'node:path';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
|
-
import { launchReview } from './editor/review.js';
|
|
8
|
+
import { launchReview, reviewVimscript } from './editor/review.js';
|
|
9
9
|
import { validateDeck } from './inbox/deck-schema.js';
|
|
10
10
|
import { ask, inbox } from './api.js';
|
|
11
11
|
import { display } from './surfaces/display.js';
|
|
@@ -244,6 +244,11 @@ const RESPONSE_SCHEMA = {
|
|
|
244
244
|
selectedOptionId: { type: 'string' },
|
|
245
245
|
selectedOptionIds: { type: 'array', items: { type: 'string' } },
|
|
246
246
|
freetext: { type: 'string' },
|
|
247
|
+
optionComments: {
|
|
248
|
+
type: 'object',
|
|
249
|
+
description: 'Multi-select per-option comments, keyed by option id.',
|
|
250
|
+
additionalProperties: { type: 'string' },
|
|
251
|
+
},
|
|
247
252
|
},
|
|
248
253
|
},
|
|
249
254
|
},
|
|
@@ -559,7 +564,13 @@ reviewCmd
|
|
|
559
564
|
' editor?: string|null, tmux?: bool=true }\n' +
|
|
560
565
|
'stdout { job_id: string, output: string (absolute), follow_up: string }\n' +
|
|
561
566
|
'\n' +
|
|
562
|
-
'Effects: spawns nvim/vim read-only
|
|
567
|
+
'Effects: spawns nvim/vim read-only DETACHED in a tmux pane when tmux=true\n' +
|
|
568
|
+
' and $TMUX set; returns before the human starts. Writes\n' +
|
|
569
|
+
' <dir>/review.vim, <dir>/feedback.json (on finish), <dir>/job.log.\n' +
|
|
570
|
+
' autosaves feedback JSON; the open pane live-reloads the source on\n' +
|
|
571
|
+
' disk edits. The review is open-ended (a human may take many\n' +
|
|
572
|
+
' minutes) — collect via `hl job result` with wait:true, run\n' +
|
|
573
|
+
' backgrounded so the call does not block on the human.\n')
|
|
563
574
|
.helpOption('-h, --help', 'Show help')
|
|
564
575
|
.action(async () => {
|
|
565
576
|
const input = parseStdinJson();
|
|
@@ -571,15 +582,52 @@ reviewCmd
|
|
|
571
582
|
emitError({ error: 'file_not_found', message: `File not found: ${absFile}`, field: 'file', next: 'Check the file path.' });
|
|
572
583
|
}
|
|
573
584
|
const output = resolve(input.output ? input.output : `${absFile}.feedback.json`);
|
|
574
|
-
const
|
|
575
|
-
|
|
585
|
+
const useTmux = input.tmux !== false;
|
|
586
|
+
// Shared job dir: the detached child reuses the parent's via input.dir;
|
|
587
|
+
// a top-level call mints one. The review.vim is written up front (and the
|
|
588
|
+
// job_started logged) so the job is recognizable as a live review — and so
|
|
589
|
+
// the child sources the exact vimscript — before any pane is spawned.
|
|
590
|
+
const jobDir = input.dir ? resolve(input.dir) : mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
591
|
+
mkdirSync(jobDir, { recursive: true });
|
|
576
592
|
const jobId = basename(jobDir);
|
|
577
|
-
|
|
593
|
+
if (!input.dir) {
|
|
594
|
+
writeFileSync(join(jobDir, 'review.vim'), reviewVimscript());
|
|
595
|
+
appendJobLog(jobDir, { level: 'info', event: 'job_started', message: 'review open job started', data: { jobId, file: absFile } });
|
|
596
|
+
}
|
|
597
|
+
// tmux path: detach a child that owns the editor pane and return the handle
|
|
598
|
+
// now, mirroring `deck ask`. The child re-enters this leaf with tmux:false
|
|
599
|
+
// and the shared dir, falling through to the in-process branch below.
|
|
600
|
+
if (process.env['TMUX'] && useTmux) {
|
|
601
|
+
const scriptPath = process.argv[1];
|
|
602
|
+
if (!scriptPath) {
|
|
603
|
+
emitError({ error: 'internal', message: 'Cannot determine hl script path', next: 'Report this as a bug.' });
|
|
604
|
+
}
|
|
605
|
+
const sq = (s) => /^[a-zA-Z0-9_\-./:@%+=]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
|
|
606
|
+
const childInput = JSON.stringify({ file: absFile, output, editor: input.editor ?? null, tmux: false, dir: jobDir });
|
|
607
|
+
const cmd = `echo ${sq(childInput)} | ${sq(process.execPath)} ${sq(scriptPath)} review open`;
|
|
608
|
+
try {
|
|
609
|
+
execFileSync('tmux', ['split-window', '-d', '-h', cmd], { stdio: 'ignore' });
|
|
610
|
+
}
|
|
611
|
+
catch (spawnErr) {
|
|
612
|
+
const msg = spawnErr instanceof Error ? spawnErr.message : String(spawnErr);
|
|
613
|
+
appendJobLog(jobDir, { level: 'error', event: 'job_failed', message: `tmux spawn failed: ${msg}` });
|
|
614
|
+
emitError({ error: 'internal', message: `tmux spawn failed: ${msg}`, next: 'Check that $TMUX is set. Or pass tmux:false.' });
|
|
615
|
+
}
|
|
616
|
+
process.stdout.write(JSON.stringify({
|
|
617
|
+
job_id: jobId,
|
|
618
|
+
output,
|
|
619
|
+
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to block until the human finishes the review. They may take many minutes — run this backgrounded; you will be notified on completion.`,
|
|
620
|
+
}) + '\n');
|
|
621
|
+
process.exit(0);
|
|
622
|
+
}
|
|
623
|
+
// In-process path: the detached child (this pane is its TTY), or a degraded
|
|
624
|
+
// top-level call with no tmux. launchReview blocks until the editor exits.
|
|
578
625
|
try {
|
|
579
626
|
const result = await launchReview(absFile, {
|
|
580
627
|
output,
|
|
581
628
|
editor: (input.editor && typeof input.editor === 'string') ? input.editor : undefined,
|
|
582
|
-
noTmux,
|
|
629
|
+
noTmux: true,
|
|
630
|
+
jobDir,
|
|
583
631
|
});
|
|
584
632
|
appendJobLog(jobDir, { level: 'info', event: 'job_finished', message: 'review finished', data: { comments: result.comments.length } });
|
|
585
633
|
writeFileSync(join(jobDir, 'feedback.json'), JSON.stringify(result, null, 2));
|
|
@@ -587,6 +635,7 @@ reviewCmd
|
|
|
587
635
|
job_id: jobId,
|
|
588
636
|
output,
|
|
589
637
|
follow_up: `Call hl job result with stdin {"job_id":"${jobId}","wait":true} to retrieve the feedback.`,
|
|
638
|
+
...(input.dir ? {} : { _note: 'No tmux: launchReview blocked synchronously. Result is already available.' }),
|
|
590
639
|
}) + '\n');
|
|
591
640
|
process.exit(0);
|
|
592
641
|
}
|
package/dist/editor/review.d.ts
CHANGED
|
@@ -6,7 +6,33 @@ export interface ReviewOptions {
|
|
|
6
6
|
editor?: string;
|
|
7
7
|
/** Force running in the current terminal even when $TMUX is set. */
|
|
8
8
|
noTmux?: boolean;
|
|
9
|
+
/**
|
|
10
|
+
* When set, an explicit `<Space>s` / `:HLSubmit` invocation writes a sentinel
|
|
11
|
+
* file at this path. Node-side uses the file's existence to gate `submitted: true`.
|
|
12
|
+
* When omitted, any editor exit finalizes `submitted: true` (legacy behavior).
|
|
13
|
+
*/
|
|
14
|
+
submitFlagPath?: string;
|
|
15
|
+
/**
|
|
16
|
+
* When true and $TMUX is set and !opts.noTmux, launch via
|
|
17
|
+
* `tmux display-popup -E` instead of `tmux split-window`.
|
|
18
|
+
* display-popup -E is synchronous so no poll loop is needed.
|
|
19
|
+
*/
|
|
20
|
+
tmuxPopup?: boolean;
|
|
21
|
+
/** Override the default 90%/90% popup size. Only used when tmuxPopup is true. */
|
|
22
|
+
popupSize?: {
|
|
23
|
+
w: string;
|
|
24
|
+
h: string;
|
|
25
|
+
};
|
|
26
|
+
/**
|
|
27
|
+
* Reuse this directory for `review.vim` instead of minting a fresh temp dir.
|
|
28
|
+
* The async `review open` kickoff passes its job dir so the detached child
|
|
29
|
+
* sources the same vimscript the parent already wrote there (which also lets
|
|
30
|
+
* the job be recognized as a review while it's still live). When the file
|
|
31
|
+
* already exists it is left untouched.
|
|
32
|
+
*/
|
|
33
|
+
jobDir?: string;
|
|
9
34
|
}
|
|
35
|
+
export declare function reviewVimscript(): string;
|
|
10
36
|
/**
|
|
11
37
|
* Compact stdout rendering for the agent: per comment just the line:col
|
|
12
38
|
* range, the original text in that span, and the note — plus the source
|
package/dist/editor/review.js
CHANGED
|
@@ -26,12 +26,13 @@ function resolveEditor(override) {
|
|
|
26
26
|
// The entire review UX as a clean, minimal Vimscript config sourced via `-u`.
|
|
27
27
|
// Works in both Neovim and Vim 8+. The source file is opened read-only; the
|
|
28
28
|
// human anchors comments to real source lines/selections and quits to submit.
|
|
29
|
-
function reviewVimscript() {
|
|
29
|
+
export function reviewVimscript() {
|
|
30
30
|
return [
|
|
31
31
|
`" hl propose — review layer. Runs on a CLEAN config (nvim -u NONE: no`,
|
|
32
32
|
`" init.lua, no LazyVim, no plugins/keymaps). Look/feel is ONLY the user's`,
|
|
33
|
-
`" 'gloam' colorscheme + built-in treesitter markdown highlighting
|
|
34
|
-
`"
|
|
33
|
+
`" 'gloam' colorscheme + built-in treesitter markdown highlighting + (when`,
|
|
34
|
+
`" installed) render-markdown.nvim for GFM tables/headings, applied below.`,
|
|
35
|
+
`" The rest is the read-only guard, comment commands, and autosave.`,
|
|
35
36
|
`let g:hl_out = $HL_OUTPUT`,
|
|
36
37
|
`let g:hl_src = $HL_SOURCE`,
|
|
37
38
|
`let s:comments = []`,
|
|
@@ -146,39 +147,103 @@ function reviewVimscript() {
|
|
|
146
147
|
` echo 'Removed last comment — ' . len(s:comments) . ' left'`,
|
|
147
148
|
`endfunction`,
|
|
148
149
|
``,
|
|
149
|
-
`
|
|
150
|
-
`
|
|
151
|
-
`
|
|
152
|
-
`
|
|
153
|
-
`
|
|
150
|
+
`" Build the list buffer lines plus a parallel map from buffer line -> comment`,
|
|
151
|
+
`" index (0-based; -1 for non-actionable rows like the quote continuation or`,
|
|
152
|
+
`" the empty-state hint). Stored on the buffer so the action maps can resolve`,
|
|
153
|
+
`" which comment the cursor is on.`,
|
|
154
|
+
`function! s:ListLines() abort`,
|
|
154
155
|
` let l:lines = []`,
|
|
156
|
+
` let l:map = []`,
|
|
155
157
|
` if empty(s:comments)`,
|
|
156
|
-
`
|
|
158
|
+
` call add(l:lines, '(no comments yet — select text or put the cursor on a line, then <Space>c or :HLComment)')`,
|
|
159
|
+
` call add(l:map, -1)`,
|
|
157
160
|
` else`,
|
|
158
|
-
` let l:
|
|
161
|
+
` let l:idx = 0`,
|
|
159
162
|
` for l:c in s:comments`,
|
|
160
163
|
` let l:ln = get(l:c,'line',0)`,
|
|
161
164
|
` let l:end = get(l:c,'endLine',l:ln)`,
|
|
162
165
|
` let l:loc = l:ln == l:end ? ('L' . l:ln) : ('L' . l:ln . '-' . l:end)`,
|
|
163
|
-
` call add(l:lines, l:
|
|
166
|
+
` call add(l:lines, (l:idx+1) . '. [' . l:loc . '] ' . get(l:c,'comment',''))`,
|
|
167
|
+
` call add(l:map, l:idx)`,
|
|
164
168
|
` if !empty(get(l:c,'quote',''))`,
|
|
165
169
|
` call add(l:lines, ' > ' . substitute(get(l:c,'quote',''), "\\n", ' / ', 'g'))`,
|
|
170
|
+
` call add(l:map, l:idx)`,
|
|
166
171
|
` endif`,
|
|
167
|
-
` let l:
|
|
172
|
+
` let l:idx += 1`,
|
|
168
173
|
` endfor`,
|
|
169
174
|
` endif`,
|
|
170
|
-
`
|
|
171
|
-
`
|
|
172
|
-
|
|
175
|
+
` return [l:lines, l:map]`,
|
|
176
|
+
`endfunction`,
|
|
177
|
+
``,
|
|
178
|
+
`" Refill the (already-open) comments buffer in place and refresh the line map.`,
|
|
179
|
+
`function! s:RenderList() abort`,
|
|
180
|
+
` let [l:lines, l:map] = s:ListLines()`,
|
|
181
|
+
` let b:hl_map = l:map`,
|
|
173
182
|
` setlocal modifiable`,
|
|
183
|
+
` silent! 1,$delete _`,
|
|
174
184
|
` call setline(1, l:lines)`,
|
|
175
185
|
` setlocal nomodifiable`,
|
|
186
|
+
`endfunction`,
|
|
187
|
+
``,
|
|
188
|
+
`" Comment index (0-based) under the cursor in the comments buffer, or -1.`,
|
|
189
|
+
`function! s:ListIdx() abort`,
|
|
190
|
+
` if !exists('b:hl_map') | return -1 | endif`,
|
|
191
|
+
` let l:lnum = line('.')`,
|
|
192
|
+
` if l:lnum < 1 || l:lnum > len(b:hl_map) | return -1 | endif`,
|
|
193
|
+
` return b:hl_map[l:lnum - 1]`,
|
|
194
|
+
`endfunction`,
|
|
195
|
+
``,
|
|
196
|
+
`function! s:ListDelete() abort`,
|
|
197
|
+
` let l:idx = s:ListIdx()`,
|
|
198
|
+
` if l:idx < 0`,
|
|
199
|
+
` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
|
|
200
|
+
` endif`,
|
|
201
|
+
` call remove(s:comments, l:idx)`,
|
|
202
|
+
` call s:Save()`,
|
|
203
|
+
` call s:Marks()`,
|
|
204
|
+
` call s:RenderList()`,
|
|
205
|
+
` echo 'Deleted comment — ' . len(s:comments) . ' left'`,
|
|
206
|
+
`endfunction`,
|
|
207
|
+
``,
|
|
208
|
+
`function! s:ListEdit() abort`,
|
|
209
|
+
` let l:idx = s:ListIdx()`,
|
|
210
|
+
` if l:idx < 0`,
|
|
211
|
+
` echohl WarningMsg | echo 'No comment on this line' | echohl NONE | return`,
|
|
212
|
+
` endif`,
|
|
213
|
+
` let l:cur = get(s:comments[l:idx], 'comment', '')`,
|
|
214
|
+
` let l:txt = input('Edit comment: ', l:cur)`,
|
|
215
|
+
` redraw`,
|
|
216
|
+
` if empty(trim(l:txt))`,
|
|
217
|
+
` echohl WarningMsg | echo 'Edit cancelled — comment unchanged' | echohl NONE | return`,
|
|
218
|
+
` endif`,
|
|
219
|
+
` let s:comments[l:idx]['comment'] = l:txt`,
|
|
220
|
+
` call s:Save()`,
|
|
221
|
+
` call s:RenderList()`,
|
|
222
|
+
` echo 'Updated comment'`,
|
|
223
|
+
`endfunction`,
|
|
224
|
+
``,
|
|
225
|
+
`function! s:List() abort`,
|
|
226
|
+
` let l:wid = bufwinid('__HL_Comments__')`,
|
|
227
|
+
` if l:wid != -1`,
|
|
228
|
+
` call win_gotoid(l:wid) | close | return`,
|
|
229
|
+
` endif`,
|
|
230
|
+
` botright 10split __HL_Comments__`,
|
|
231
|
+
` setlocal buftype=nofile bufhidden=wipe noswapfile nobuflisted`,
|
|
232
|
+
` setlocal nonumber cursorline winfixheight signcolumn=no`,
|
|
233
|
+
` call s:RenderList()`,
|
|
176
234
|
` nnoremap <buffer> <silent> q :close<CR>`,
|
|
177
235
|
` nnoremap <buffer> <silent> <Space>l :close<CR>`,
|
|
236
|
+
` nnoremap <buffer> <silent> dd :call <SID>ListDelete()<CR>`,
|
|
237
|
+
` nnoremap <buffer> <silent> e :call <SID>ListEdit()<CR>`,
|
|
238
|
+
` nnoremap <buffer> <silent> <CR> :call <SID>ListEdit()<CR>`,
|
|
239
|
+
` echohl Question | echo 'list — e/<CR> edit · dd delete · q/<Space>l close' | echohl NONE`,
|
|
178
240
|
`endfunction`,
|
|
179
241
|
``,
|
|
180
242
|
`function! s:Submit() abort`,
|
|
181
243
|
` call s:Save()`,
|
|
244
|
+
` if $HL_SUBMIT_FLAG !=# ''`,
|
|
245
|
+
` call writefile([''], $HL_SUBMIT_FLAG)`,
|
|
246
|
+
` endif`,
|
|
182
247
|
` qa!`,
|
|
183
248
|
`endfunction`,
|
|
184
249
|
``,
|
|
@@ -186,7 +251,7 @@ function reviewVimscript() {
|
|
|
186
251
|
`command! HLList call <SID>List()`,
|
|
187
252
|
`command! HLUndo call <SID>Undo()`,
|
|
188
253
|
`command! HLSubmit call <SID>Submit()`,
|
|
189
|
-
`command! HLHelp echo 'REVIEW <Space>c comment
|
|
254
|
+
`command! HLHelp echo 'REVIEW <Space>c comment <Space>l list <Space>u undo-last <Space>s submit & quit | in list: e/<CR> edit · dd delete · q close (any quit submits)'`,
|
|
190
255
|
``,
|
|
191
256
|
`" Highlights are (re)applied after any colorscheme so the user's theme`,
|
|
192
257
|
`" (e.g. gloam) loads first, then our anchor highlight sits on top.`,
|
|
@@ -212,9 +277,51 @@ function reviewVimscript() {
|
|
|
212
277
|
` if &filetype !=# 'markdown' | setlocal filetype=markdown | endif`,
|
|
213
278
|
` " gloam only defines treesitter @markup.* highlight groups for markdown,`,
|
|
214
279
|
` " so the styling needs treesitter active. Built-in treesitter plus the`,
|
|
215
|
-
` " site-dir markdown parser render it with zero plugins
|
|
280
|
+
` " site-dir markdown parser render it with zero plugins. When the user has`,
|
|
281
|
+
` " render-markdown.nvim installed (lazy dir / packpath), pull it in too so`,
|
|
282
|
+
` " GFM tables and headings render the same as their normal editor — it only`,
|
|
283
|
+
` " draws extmarks/conceal, so the read-only buffer text (and the line:col`,
|
|
284
|
+
` " anchors comments hang off of) is never touched.`,
|
|
216
285
|
` if has('nvim')`,
|
|
217
|
-
` silent! lua
|
|
286
|
+
` silent! lua << HLLUA`,
|
|
287
|
+
`pcall(vim.treesitter.start, 0, 'markdown')`,
|
|
288
|
+
`pcall(function()`,
|
|
289
|
+
` local path = vim.fn.stdpath('data') .. '/lazy/render-markdown.nvim'`,
|
|
290
|
+
` if vim.fn.isdirectory(path) == 0 then`,
|
|
291
|
+
` local hits = vim.fn.globpath(vim.o.packpath, '*/*/render-markdown.nvim', false, true)`,
|
|
292
|
+
` path = hits[1]`,
|
|
293
|
+
` end`,
|
|
294
|
+
` if not path or vim.fn.isdirectory(path) == 0 then return end`,
|
|
295
|
+
` vim.opt.runtimepath:prepend(path)`,
|
|
296
|
+
` vim.g.render_markdown_config = {`,
|
|
297
|
+
` file_types = { 'markdown' },`,
|
|
298
|
+
` completions = { lsp = { enabled = false } },`,
|
|
299
|
+
` heading = { border = false },`,
|
|
300
|
+
` }`,
|
|
301
|
+
` vim.cmd('silent! runtime plugin/render-markdown.lua')`,
|
|
302
|
+
` -- Defer the initial paint to the next tick: during VimEnter the window`,
|
|
303
|
+
` -- isn't laid out yet, so an immediate render no-ops and tables show raw`,
|
|
304
|
+
` -- until the first cursor move. api.render() forces a paint for the`,
|
|
305
|
+
` -- buffer's window (the live autocmds the plugin installed keep it updated`,
|
|
306
|
+
` -- on edits/scroll thereafter). render-markdown refuses to draw while a`,
|
|
307
|
+
` -- prompt mode is active ('r' hit-enter, 'rm' more, 'r?' confirm) — e.g. a`,
|
|
308
|
+
` -- wrapped :echo — so re-defer until we're back in a normal state.`,
|
|
309
|
+
` local function paint()`,
|
|
310
|
+
` local m = vim.api.nvim_get_mode().mode`,
|
|
311
|
+
` if m == 'r' or m == 'rm' or m == 'r?' then`,
|
|
312
|
+
` vim.defer_fn(paint, 60)`,
|
|
313
|
+
` return`,
|
|
314
|
+
` end`,
|
|
315
|
+
` pcall(function()`,
|
|
316
|
+
` require('render-markdown.api').render({`,
|
|
317
|
+
` buf = vim.api.nvim_get_current_buf(),`,
|
|
318
|
+
` win = vim.api.nvim_get_current_win(),`,
|
|
319
|
+
` })`,
|
|
320
|
+
` end)`,
|
|
321
|
+
` end`,
|
|
322
|
+
` vim.schedule(paint)`,
|
|
323
|
+
`end)`,
|
|
324
|
+
`HLLUA`,
|
|
218
325
|
` endif`,
|
|
219
326
|
` " Buffer-local <Space> maps. Clean config has no which-key/<leader>`,
|
|
220
327
|
` " bindings to collide with, and these are gone outside this buffer.`,
|
|
@@ -227,11 +334,29 @@ function reviewVimscript() {
|
|
|
227
334
|
` call s:Load()`,
|
|
228
335
|
` call s:Marks()`,
|
|
229
336
|
` redraw`,
|
|
230
|
-
`
|
|
337
|
+
` " Keep this hint short: a line that wraps past the cmdline triggers the`,
|
|
338
|
+
` " hit-enter prompt, whose mode blocks the deferred render-markdown paint.`,
|
|
339
|
+
` " The full key list lives in :HLHelp (and is printed to stderr on launch).`,
|
|
340
|
+
` echohl Question | echo 'hl review — <Space>c comment · l list · u undo · s submit (:HLHelp)' | echohl NONE`,
|
|
231
341
|
`endfunction`,
|
|
232
342
|
`autocmd VimEnter * call s:Setup()`,
|
|
233
343
|
`autocmd VimLeavePre * call s:Save()`,
|
|
234
344
|
``,
|
|
345
|
+
`" Watchmode: the source is opened read-only here but may be rewritten on`,
|
|
346
|
+
`" disk by the agent while the human reviews. autoread + a periodic checktime`,
|
|
347
|
+
`" (timers fire even when the tmux pane is unfocused) reload the buffer`,
|
|
348
|
+
`" silently — it is never locally modified, so there is nothing to clobber.`,
|
|
349
|
+
`" On reload, re-run Setup (read-only guard, treesitter, maps) and redraw the`,
|
|
350
|
+
`" comment anchors, which the reload cleared. Comments live in s:comments /`,
|
|
351
|
+
`" the autosave file, not the buffer, so they survive; their line anchors may`,
|
|
352
|
+
`" drift if the edit moved text, which is expected — the human sees the latest.`,
|
|
353
|
+
`set autoread`,
|
|
354
|
+
`autocmd FocusGained,BufEnter,CursorHold * silent! checktime`,
|
|
355
|
+
`autocmd FileChangedShellPost * call s:Setup() | call s:Marks() | redraw`,
|
|
356
|
+
`if exists('*timer_start')`,
|
|
357
|
+
` let s:hl_watch = timer_start(1000, {-> execute('silent! checktime')}, {'repeat': -1})`,
|
|
358
|
+
`endif`,
|
|
359
|
+
``,
|
|
235
360
|
].join('\n');
|
|
236
361
|
}
|
|
237
362
|
function atomicWrite(path, data) {
|
|
@@ -285,7 +410,24 @@ function rangeLabel(c) {
|
|
|
285
410
|
export function formatFeedbackSummary(result, feedbackJsonPath) {
|
|
286
411
|
const n = result.comments.length;
|
|
287
412
|
const out = [];
|
|
288
|
-
if (
|
|
413
|
+
if (!result.submitted) {
|
|
414
|
+
if (n > 0) {
|
|
415
|
+
out.push(`hl propose — draft saved: ${n} comment${n === 1 ? '' : 's'} on ${result.file} (not yet submitted)`);
|
|
416
|
+
out.push('');
|
|
417
|
+
result.comments.forEach((c, i) => {
|
|
418
|
+
const original = c.quote && c.quote.length > 0 ? c.quote : c.lineText;
|
|
419
|
+
const lines = original.split('\n');
|
|
420
|
+
out.push(` ${i + 1}. ${rangeLabel(c)}`);
|
|
421
|
+
lines.forEach((ln, k) => out.push(k === 0 ? ` text: ${ln}` : ` ${ln}`));
|
|
422
|
+
out.push(` comment: ${c.comment}`);
|
|
423
|
+
out.push('');
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
else {
|
|
427
|
+
out.push(`hl propose — draft saved: no comments yet on ${result.file}`);
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
else if (n === 0) {
|
|
289
431
|
out.push(`hl propose — approved: 0 comments on ${result.file} (human signalled looks-good; proceed)`);
|
|
290
432
|
}
|
|
291
433
|
else {
|
|
@@ -364,10 +506,18 @@ export async function launchReview(file, opts) {
|
|
|
364
506
|
}
|
|
365
507
|
const outPath = resolve(opts.output);
|
|
366
508
|
const bin = resolveEditor(opts.editor);
|
|
367
|
-
|
|
509
|
+
// When the async kickoff passes its job dir, source the review.vim the parent
|
|
510
|
+
// already wrote there; otherwise mint a private temp dir as before.
|
|
511
|
+
const dir = opts.jobDir ?? mkdtempSync(join(tmpdir(), 'hl-review-'));
|
|
368
512
|
const initPath = join(dir, 'review.vim');
|
|
369
|
-
|
|
370
|
-
|
|
513
|
+
if (!existsSync(initPath))
|
|
514
|
+
writeFileSync(initPath, reviewVimscript());
|
|
515
|
+
const env = {
|
|
516
|
+
...process.env,
|
|
517
|
+
HL_OUTPUT: outPath,
|
|
518
|
+
HL_SOURCE: absFile,
|
|
519
|
+
...(opts.submitFlagPath ? { HL_SUBMIT_FLAG: opts.submitFlagPath } : {}),
|
|
520
|
+
};
|
|
371
521
|
// `-u NONE`: do NOT load the user's init.lua / LazyVim / plugins / keymaps.
|
|
372
522
|
// Default runtimepath still includes the config dir (for the gloam
|
|
373
523
|
// colorscheme) and the site dir (for the treesitter markdown parser), so the
|
|
@@ -382,16 +532,27 @@ export async function launchReview(file, opts) {
|
|
|
382
532
|
if (inTmux) {
|
|
383
533
|
// `exec env …` so the editor replaces the shell and becomes the pane's
|
|
384
534
|
// process (so tmux `#{pane_current_command}` is `nvim`, not the shell).
|
|
535
|
+
const envPairs = [
|
|
536
|
+
`HL_OUTPUT=${shellQuote(outPath)}`,
|
|
537
|
+
`HL_SOURCE=${shellQuote(absFile)}`,
|
|
538
|
+
...(opts.submitFlagPath ? [`HL_SUBMIT_FLAG=${shellQuote(opts.submitFlagPath)}`] : []),
|
|
539
|
+
];
|
|
385
540
|
const paneCmd = [
|
|
386
541
|
'exec',
|
|
387
542
|
'env',
|
|
388
|
-
|
|
389
|
-
`HL_SOURCE=${shellQuote(absFile)}`,
|
|
543
|
+
...envPairs,
|
|
390
544
|
shellQuote(bin),
|
|
391
545
|
...editorArgs.map(shellQuote),
|
|
392
546
|
].join(' ');
|
|
393
547
|
try {
|
|
394
|
-
|
|
548
|
+
if (opts.tmuxPopup) {
|
|
549
|
+
const w = opts.popupSize ? opts.popupSize.w : '90%';
|
|
550
|
+
const h = opts.popupSize ? opts.popupSize.h : '90%';
|
|
551
|
+
execFileSync('tmux', ['display-popup', '-E', '-w', w, '-h', h, paneCmd], { stdio: 'inherit' });
|
|
552
|
+
}
|
|
553
|
+
else {
|
|
554
|
+
await runInTmuxPane(paneCmd);
|
|
555
|
+
}
|
|
395
556
|
}
|
|
396
557
|
catch (err) {
|
|
397
558
|
process.stderr.write(`tmux dispatch failed, running in current terminal: ${err instanceof Error ? err.message : String(err)}\n`);
|
|
@@ -412,12 +573,13 @@ export async function launchReview(file, opts) {
|
|
|
412
573
|
}
|
|
413
574
|
}
|
|
414
575
|
const now = new Date().toISOString();
|
|
576
|
+
const didSubmit = opts.submitFlagPath ? existsSync(opts.submitFlagPath) : true;
|
|
415
577
|
const result = {
|
|
416
578
|
file: absFile,
|
|
417
|
-
submitted:
|
|
418
|
-
approved: comments.length === 0,
|
|
579
|
+
submitted: didSubmit,
|
|
580
|
+
approved: didSubmit && comments.length === 0,
|
|
419
581
|
comments,
|
|
420
|
-
submittedAt: now,
|
|
582
|
+
...(didSubmit ? { submittedAt: now } : {}),
|
|
421
583
|
savedAt: now,
|
|
422
584
|
};
|
|
423
585
|
atomicWrite(outPath, JSON.stringify(result, null, 2) + '\n');
|
package/dist/tui/input.js
CHANGED
|
@@ -177,15 +177,37 @@ function handleInteractionAction(input, key, state, interaction, render) {
|
|
|
177
177
|
render();
|
|
178
178
|
return;
|
|
179
179
|
}
|
|
180
|
-
// Comment mode: allowFreetext + options exist.
|
|
181
|
-
//
|
|
180
|
+
// Comment mode: allowFreetext + options exist.
|
|
181
|
+
//
|
|
182
|
+
// Single-select: pre-attach the focused option (the comment qualifies that
|
|
183
|
+
// pick). Multi-select: also pre-attach the focused option — the comment is
|
|
184
|
+
// saved under `optionComments[id]` (per-option note) and submitting
|
|
185
|
+
// auto-checks the option. From the [c] freetext row in multi-select,
|
|
186
|
+
// start with no attachment so the comment becomes the overall freetext.
|
|
182
187
|
if (input === 'c' && interaction.allowFreetext && opts.length > 0) {
|
|
183
|
-
const
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
188
|
+
const onOption = state.selectedAction < opts.length;
|
|
189
|
+
if (onOption) {
|
|
190
|
+
const optId = opts[state.selectedAction].id;
|
|
191
|
+
let prefill = '';
|
|
192
|
+
if (interaction.multiSelect) {
|
|
193
|
+
const existing = state.responses.get(interaction.id);
|
|
194
|
+
const prior = existing?.optionComments?.[optId];
|
|
195
|
+
if (typeof prior === 'string')
|
|
196
|
+
prefill = prior;
|
|
197
|
+
}
|
|
198
|
+
state.inputMode = { kind: 'comment', buffer: prefill, selectedOptionId: optId };
|
|
199
|
+
}
|
|
200
|
+
else {
|
|
201
|
+
// On the [c] row in multi-select: pre-fill from existing overall freetext.
|
|
202
|
+
let prefill = '';
|
|
203
|
+
if (interaction.multiSelect) {
|
|
204
|
+
const existing = state.responses.get(interaction.id);
|
|
205
|
+
if (existing !== undefined && typeof existing.freetext === 'string') {
|
|
206
|
+
prefill = existing.freetext;
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
state.inputMode = { kind: 'comment', buffer: prefill };
|
|
210
|
+
}
|
|
189
211
|
render();
|
|
190
212
|
return;
|
|
191
213
|
}
|
|
@@ -250,6 +272,18 @@ function handleInputMode(input, key, state, render) {
|
|
|
250
272
|
}
|
|
251
273
|
if (key.return) {
|
|
252
274
|
const interaction = state.interactions[state.currentIndex];
|
|
275
|
+
const perOption = interaction.multiSelect === true
|
|
276
|
+
&& mode.kind === 'comment'
|
|
277
|
+
&& mode.selectedOptionId !== undefined;
|
|
278
|
+
if (perOption) {
|
|
279
|
+
// Per-option comment: save under optionComments[id], auto-check the
|
|
280
|
+
// option (idempotent), stay on this interaction so the human can comment
|
|
281
|
+
// on another option.
|
|
282
|
+
setOptionComment(state, interaction, mode.selectedOptionId, mode.buffer);
|
|
283
|
+
state.inputMode = null;
|
|
284
|
+
render();
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
253
287
|
if (interaction.multiSelect) {
|
|
254
288
|
commitMulti(state, interaction, mode.buffer);
|
|
255
289
|
}
|
|
@@ -361,14 +395,20 @@ function submitOption(state, interaction, selectedOptionId, freetext) {
|
|
|
361
395
|
// submitOption immediacy); Enter just confirms the accumulated set + advances.
|
|
362
396
|
function toggleMulti(state, interaction, optionId) {
|
|
363
397
|
const existing = state.responses.get(interaction.id);
|
|
364
|
-
const
|
|
398
|
+
const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
|
|
399
|
+
? existing.selectedOptionIds
|
|
400
|
+
: [];
|
|
401
|
+
const set = new Set(priorIds);
|
|
365
402
|
if (set.has(optionId))
|
|
366
403
|
set.delete(optionId);
|
|
367
404
|
else
|
|
368
405
|
set.add(optionId);
|
|
369
406
|
const response = { id: interaction.id, selectedOptionIds: [...set] };
|
|
370
|
-
if (existing
|
|
407
|
+
if (existing !== undefined && existing.freetext !== undefined)
|
|
371
408
|
response.freetext = existing.freetext;
|
|
409
|
+
if (existing !== undefined && existing.optionComments !== undefined) {
|
|
410
|
+
response.optionComments = { ...existing.optionComments };
|
|
411
|
+
}
|
|
372
412
|
state.responses.set(interaction.id, response);
|
|
373
413
|
// User edited the checked set — no longer a passive carry-over.
|
|
374
414
|
state.preAnsweredIds.delete(interaction.id);
|
|
@@ -378,15 +418,52 @@ function toggleMulti(state, interaction, optionId) {
|
|
|
378
418
|
* answered, optionally setting/replacing freetext. */
|
|
379
419
|
function commitMulti(state, interaction, freetext) {
|
|
380
420
|
const existing = state.responses.get(interaction.id);
|
|
421
|
+
const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
|
|
422
|
+
? existing.selectedOptionIds
|
|
423
|
+
: [];
|
|
381
424
|
const response = {
|
|
382
425
|
id: interaction.id,
|
|
383
|
-
selectedOptionIds: [...
|
|
426
|
+
selectedOptionIds: [...priorIds],
|
|
384
427
|
};
|
|
385
|
-
|
|
428
|
+
let ft;
|
|
429
|
+
if (freetext !== undefined)
|
|
430
|
+
ft = freetext;
|
|
431
|
+
else if (existing !== undefined)
|
|
432
|
+
ft = existing.freetext;
|
|
386
433
|
if (ft !== undefined)
|
|
387
434
|
response.freetext = ft;
|
|
435
|
+
if (existing !== undefined && existing.optionComments !== undefined) {
|
|
436
|
+
response.optionComments = { ...existing.optionComments };
|
|
437
|
+
}
|
|
388
438
|
state.responses.set(interaction.id, response);
|
|
389
439
|
// Explicit confirm overrides any preAnswered seed.
|
|
390
440
|
state.preAnsweredIds.delete(interaction.id);
|
|
391
441
|
state.persist?.();
|
|
392
442
|
}
|
|
443
|
+
/**
|
|
444
|
+
* Multi-select per-option comment: write `comment` to optionComments[optionId]
|
|
445
|
+
* and auto-check the option. Empty comment still records the entry (the user
|
|
446
|
+
* committed deliberately via Enter; Esc cancels without write).
|
|
447
|
+
*/
|
|
448
|
+
function setOptionComment(state, interaction, optionId, comment) {
|
|
449
|
+
const existing = state.responses.get(interaction.id);
|
|
450
|
+
const priorIds = existing !== undefined && existing.selectedOptionIds !== undefined
|
|
451
|
+
? existing.selectedOptionIds
|
|
452
|
+
: [];
|
|
453
|
+
const set = new Set(priorIds);
|
|
454
|
+
set.add(optionId);
|
|
455
|
+
const priorComments = existing !== undefined && existing.optionComments !== undefined
|
|
456
|
+
? existing.optionComments
|
|
457
|
+
: {};
|
|
458
|
+
const nextComments = { ...priorComments, [optionId]: comment };
|
|
459
|
+
const response = {
|
|
460
|
+
id: interaction.id,
|
|
461
|
+
selectedOptionIds: [...set],
|
|
462
|
+
optionComments: nextComments,
|
|
463
|
+
};
|
|
464
|
+
if (existing !== undefined && existing.freetext !== undefined)
|
|
465
|
+
response.freetext = existing.freetext;
|
|
466
|
+
state.responses.set(interaction.id, response);
|
|
467
|
+
state.preAnsweredIds.delete(interaction.id);
|
|
468
|
+
state.persist?.();
|
|
469
|
+
}
|
package/dist/tui/render.js
CHANGED
|
@@ -289,10 +289,11 @@ export function renderItemReview(state, cols, rows) {
|
|
|
289
289
|
const label = interaction.freetextLabel !== undefined
|
|
290
290
|
? interaction.freetextLabel
|
|
291
291
|
: state.inputMode.kind === 'comment' ? 'Comment' : 'Response';
|
|
292
|
-
// Show attached option
|
|
293
|
-
//
|
|
292
|
+
// Show attached option in comment mode. For single-select the comment
|
|
293
|
+
// qualifies the pick; for multi-select an attached option means the
|
|
294
|
+
// comment is saved as a per-option note (and auto-checks the option).
|
|
294
295
|
let attachedLine;
|
|
295
|
-
if (state.inputMode.kind === 'comment'
|
|
296
|
+
if (state.inputMode.kind === 'comment') {
|
|
296
297
|
const attachedId = state.inputMode.selectedOptionId;
|
|
297
298
|
const opts = interaction.options;
|
|
298
299
|
if (opts.length > 0) {
|
|
@@ -301,7 +302,7 @@ export function renderItemReview(state, cols, rows) {
|
|
|
301
302
|
: undefined;
|
|
302
303
|
const valueText = attached !== undefined
|
|
303
304
|
? `${CYAN}${singleLine(attached.label)}${RESET}`
|
|
304
|
-
: `${DIM}none${RESET}`;
|
|
305
|
+
: `${DIM}none (overall)${RESET}`;
|
|
305
306
|
attachedLine = ` ${DIM}attached:${RESET} ${valueText} ${DIM}[tab to cycle]${RESET}`;
|
|
306
307
|
}
|
|
307
308
|
}
|
|
@@ -382,10 +383,11 @@ function renderActions(interaction, selectedAction, maxW, existing) {
|
|
|
382
383
|
const prefixWidth = multi ? 12 : 8;
|
|
383
384
|
const indent = ' '.repeat(prefixWidth);
|
|
384
385
|
const contentMax = Math.max(20, maxW - prefixWidth);
|
|
386
|
+
const optionComments = existing !== undefined ? existing.optionComments : undefined;
|
|
385
387
|
for (let i = 0; i < opts.length; i++) {
|
|
386
388
|
const o = opts[i];
|
|
387
389
|
const cursor = i === selectedAction ? `${CYAN}▸${RESET}` : ' ';
|
|
388
|
-
const sc = o.shortcut
|
|
390
|
+
const sc = o.shortcut === undefined ? ' ' : o.shortcut;
|
|
389
391
|
const keyBadge = `${DIM}[${sc}]${RESET}`;
|
|
390
392
|
const box = multi
|
|
391
393
|
? (checked.has(o.id) ? `${GREEN}[x]${RESET}` : `${DIM}[ ]${RESET}`) + ' '
|
|
@@ -401,10 +403,25 @@ function renderActions(interaction, selectedAction, maxW, existing) {
|
|
|
401
403
|
lines.push(`${indent}${DIM}${dl}${RESET}`);
|
|
402
404
|
}
|
|
403
405
|
}
|
|
406
|
+
if (multi && optionComments !== undefined) {
|
|
407
|
+
const note = optionComments[o.id];
|
|
408
|
+
if (typeof note === 'string' && note.length > 0) {
|
|
409
|
+
const noteLines = wrap(`✎ ${sanitize(note)}`, contentMax);
|
|
410
|
+
for (const nl of noteLines) {
|
|
411
|
+
lines.push(`${indent}${YELLOW}${nl}${RESET}`);
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
}
|
|
404
415
|
}
|
|
405
416
|
if (interaction.allowFreetext && opts.length > 0) {
|
|
406
417
|
const cursor = opts.length === selectedAction ? `${CYAN}▸${RESET}` : ' ';
|
|
407
|
-
|
|
418
|
+
let label;
|
|
419
|
+
if (interaction.freetextLabel !== undefined)
|
|
420
|
+
label = interaction.freetextLabel;
|
|
421
|
+
else if (multi)
|
|
422
|
+
label = 'Add overall comment (c on an option for per-option)';
|
|
423
|
+
else
|
|
424
|
+
label = 'Add comment';
|
|
408
425
|
lines.push(` ${cursor} ${DIM}[c]${RESET} ${label}`);
|
|
409
426
|
}
|
|
410
427
|
else if (interaction.allowFreetext && opts.length === 0) {
|
|
@@ -463,11 +480,17 @@ export function renderFinal(state, cols, rows) {
|
|
|
463
480
|
}
|
|
464
481
|
export function responseSummary(r, interaction) {
|
|
465
482
|
if (r.selectedOptionIds !== undefined) {
|
|
466
|
-
const
|
|
483
|
+
const oc = r.optionComments;
|
|
484
|
+
const parts = r.selectedOptionIds
|
|
467
485
|
.map((id) => interaction.options.find((o) => o.id === id))
|
|
468
486
|
.filter((o) => o !== undefined)
|
|
469
|
-
.map((o) =>
|
|
470
|
-
|
|
487
|
+
.map((o) => {
|
|
488
|
+
const note = oc !== undefined ? oc[o.id] : undefined;
|
|
489
|
+
return typeof note === 'string' && note.length > 0
|
|
490
|
+
? `${sanitize(o.label)} ("${sanitize(note)}")`
|
|
491
|
+
: sanitize(o.label);
|
|
492
|
+
});
|
|
493
|
+
const picks = parts.length > 0 ? parts.join(', ') : '(none)';
|
|
471
494
|
if (r.freetext)
|
|
472
495
|
return `${picks}: "${sanitize(r.freetext)}"`;
|
|
473
496
|
return picks;
|
package/dist/types.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { Key } from './tui/terminal.js';
|
|
2
|
-
export type InteractionKind = 'notify' | 'validation' | 'decision' | 'context' | 'error';
|
|
2
|
+
export type InteractionKind = 'notify' | 'validation' | 'decision' | 'context' | 'error' | 'review';
|
|
3
3
|
export interface InteractionOption {
|
|
4
4
|
id: string;
|
|
5
5
|
label: string;
|
|
@@ -45,6 +45,10 @@ export interface InteractionResponse {
|
|
|
45
45
|
/** Multi-select picks (set only for `multiSelect` interactions). */
|
|
46
46
|
selectedOptionIds?: string[];
|
|
47
47
|
freetext?: string;
|
|
48
|
+
/** Multi-select per-option comments, keyed by option id. Each entry is a
|
|
49
|
+
* comment scoped to that specific option (independent of the overall
|
|
50
|
+
* `freetext`). Set only for `multiSelect` interactions. */
|
|
51
|
+
optionComments?: Record<string, string>;
|
|
48
52
|
}
|
|
49
53
|
export interface DeckSource {
|
|
50
54
|
sessionName?: string;
|