pi-do-always 0.12.0 → 0.13.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/README.md +31 -25
- package/extensions/pi-do-always/do-always.json +19 -0
- package/extensions/pi-do-always/index.ts +732 -22
- package/extensions/pi-do-always/tasks.ts +230 -2
- package/package.json +1 -1
- package/pi.image.png +0 -0
package/README.md
CHANGED
|
@@ -8,29 +8,6 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
|
|
|
8
8
|
**filled into the input editor**. Review it, tweak it, press Enter to run. Tasks marked `⚡`
|
|
9
9
|
(the `Plan` category by default) auto-run on selection instead — see `autoRun` below.
|
|
10
10
|
|
|
11
|
-
```text
|
|
12
|
-
do-always — pick a task
|
|
13
|
-
|
|
14
|
-
# TASK DESCRIPTION ORDER
|
|
15
|
-
PLAN
|
|
16
|
-
1 ⚡ Review changes Review the current code changes (Plan) ·
|
|
17
|
-
2 ⚡ Review code Review the whole project's code quality (Plan) [1]
|
|
18
|
-
3 ⚡ Cleanup Clean up dead code and duplicates (Plan) [2]
|
|
19
|
-
4 ⚡ Security Security audit (Plan) ·
|
|
20
|
-
5 ⚡ Performance Performance review (Plan) ►[3]
|
|
21
|
-
6 ⚡ Propose features Propose new features (Plan) ·
|
|
22
|
-
DO
|
|
23
|
-
7 Build Test build is ok and fix issues ·
|
|
24
|
-
8 Tests Run tests and fix failures ·
|
|
25
|
-
DOCS
|
|
26
|
-
9 Readme Update the README.md ·
|
|
27
|
-
|
|
28
|
-
──────────────────────────────────────────────────────────────────────────────────────────────────
|
|
29
|
-
Run the chain (3)
|
|
30
|
-
← tasks • ⏎ remove • esc • ctrl+u clear
|
|
31
|
-
|
|
32
|
-
1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs
|
|
33
|
-
```
|
|
34
11
|
|
|
35
12
|

|
|
36
13
|
|
|
@@ -134,6 +111,31 @@ that view and in the report file on disk. A run that stops early (aborted, error
|
|
|
134
111
|
skipped steps) does not show the inline report — only the status trace widget below
|
|
135
112
|
the prompt.
|
|
136
113
|
|
|
114
|
+
## Commit browser
|
|
115
|
+
|
|
116
|
+
Two built-in tasks open the date-grouped commit browser (pages of 20, type to
|
|
117
|
+
filter, Space/Enter to select, ←/→ to page):
|
|
118
|
+
|
|
119
|
+
- **Browse commits** (category `Browse`, only shown inside a git repo) — the
|
|
120
|
+
generic entry: select commits, then confirm on the `do on the commits
|
|
121
|
+
(n commits)` row to pick which task runs on them — a picker lists your
|
|
122
|
+
visible `Plan` tasks, except those marked `notForCommits: true` (the
|
|
123
|
+
built-in Review changes, Review code, and Propose features — they operate
|
|
124
|
+
on the working tree or the whole project, not on a set of commits). The
|
|
125
|
+
chosen task's prompt is sent as a single turn (not a chain) with the
|
|
126
|
+
selection injected.
|
|
127
|
+
- **Review commits** (category `Plan`, hidden from the selector) — offered in
|
|
128
|
+
the picker after a selection; the review runs directly on the selected
|
|
129
|
+
commits (its prompt consumes the selection). Still resolvable by name:
|
|
130
|
+
`/do-always review commits` opens the browser as before.
|
|
131
|
+
|
|
132
|
+
The selection is injected into the chosen task's prompt: a prompt that
|
|
133
|
+
references `{{selected_commits}}` gets the numbered commit detail block
|
|
134
|
+
substituted in; any other prompt gets the block appended under
|
|
135
|
+
`Selected commits:`. In non-interactive modes (no TUI) the browser is skipped:
|
|
136
|
+
the latest commit is selected and the first eligible `Plan` task (not
|
|
137
|
+
`notForCommits`) whose guards pass runs on it.
|
|
138
|
+
|
|
137
139
|
## Install
|
|
138
140
|
|
|
139
141
|
Install it from npm as a Pi package, which loads the bundled `index.ts` (and its `tasks.ts`) without
|
|
@@ -163,7 +165,7 @@ Tasks are read from JSON files (an array of tasks, or the object form `{"tasks":
|
|
|
163
165
|
|`~/.pi/agent/do-always.json`|Global (all projects)|
|
|
164
166
|
|`<project>/.pi/do-always.json`|Project-local; overrides global tasks with the same `name`|
|
|
165
167
|
|
|
166
|
-
If neither file exists, the built-in defaults (Review changes, Review code, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
|
|
168
|
+
If neither file exists, the built-in defaults (Review changes, Review code, Cleanup, Security, Performance, Propose features, Review commits, Browse commits, Build, Tests, Readme, Release, Commit) are used.
|
|
167
169
|
This repo ships a sample in [`do-always.json`](./extensions/pi-do-always/do-always.json) — copy it to one of the
|
|
168
170
|
locations above to make it your own:
|
|
169
171
|
|
|
@@ -181,10 +183,13 @@ locations above to make it your own:
|
|
|
181
183
|
Fields:
|
|
182
184
|
|
|
183
185
|
- `name` (required) — short unique id, used for `/do-always <name>`
|
|
184
|
-
- `category` (optional) — group header the task is shown under in the selector (e.g. `"Plan"`, `"Do"`). Matching is case-insensitive and the header is title-cased, so `"plan"` and `"Plan"` land in the same `Plan` group. Tasks without a category fall under `Other`. The built-in defaults are grouped into `Plan`, `Do`, `Docs`, and `Ops`.
|
|
186
|
+
- `category` (optional) — group header the task is shown under in the selector (e.g. `"Plan"`, `"Do"`). Matching is case-insensitive and the header is title-cased, so `"plan"` and `"Plan"` land in the same `Plan` group. Tasks without a category fall under `Other`. The built-in defaults are grouped into `Plan`, `Browse`, `Do`, `Docs`, and `Ops`.
|
|
185
187
|
- `description` (optional) — one-line label shown in the selector
|
|
186
188
|
- `prompt` (required) — the text filled into the editor (supports `{{placeholders}}` — see [Prompt placeholders](#prompt-placeholders))
|
|
187
189
|
- `autoRun` (optional) — when `true`, selecting the task sends its prompt immediately instead of filling the editor; when `false`, it always fills the editor. When omitted, the default is derived from the category: `Plan` tasks auto-run, everything else fills the editor. Auto-run tasks are marked `⚡` in the selector.
|
|
190
|
+
- `browser` (optional) — a browser to open on selection instead of injecting the prompt. Only `"commits"` is supported: it opens the date-grouped commit browser, and after the selection the task runs on the selected commits — directly when its prompt references `{{selected_commits}}`, otherwise via a picker of `Plan` tasks (see [Commit browser](#commit-browser)). An invalid value is ignored with a warning.
|
|
191
|
+
- `hidden` (optional) — when `true`, the task is not shown in the selector or in `/do-always list` / `list-details`. Unlike a `when` condition, a hidden task can still be run by name (`/do-always <name>`), and it is offered as a candidate by the commit picker. The built-in `Review commits` uses this: it is a pick-after-browse option, not a standalone entry.
|
|
192
|
+
- `notForCommits` (optional) — when `true`, the task is excluded from the commit picker (the “run on the selected commits” list) because it does not operate on a set of commits. The task is otherwise unaffected (selector, lists, CLI). The built-in `Review changes`, `Review code`, and `Propose features` use this.
|
|
188
193
|
- `when` (optional) — a condition that hides the task from the selector and lists when it is not met (see [Conditionals](#conditionals)).
|
|
189
194
|
- `guards` (optional) — an array of selection-time guards that block the task (with a message, not a hide) when a condition is unmet (see [Guards](#guards)). The legacy `requireDirty` (boolean) still works and is combined with any `guards`.
|
|
190
195
|
|
|
@@ -227,6 +232,7 @@ injects “Review the changes on branch `fix/login-null` (3 changed files:
|
|
|
227
232
|
|`{{repo}}`|Basename of the git remote (or of the working directory when there is no remote) — disambiguates monorepo work|
|
|
228
233
|
|`{{staged_files}}`|Files staged for commit, one per line (`none` when empty)|
|
|
229
234
|
|`{{unstaged_files}}`|Modified-but-unstaged files, one per line (`none` when empty)|
|
|
235
|
+
|`{{selected_commits}}`|Commits selected in the commit browser, as a numbered detail block (`none` outside a browser run)|
|
|
230
236
|
|
|
231
237
|
Unknown placeholders are left as-is, and a prompt without placeholders is
|
|
232
238
|
injected unchanged, so existing configs keep working. The selector preview and
|
|
@@ -6,12 +6,14 @@
|
|
|
6
6
|
"category": "Plan",
|
|
7
7
|
"description": "Review the current code changes (Plan)",
|
|
8
8
|
"requireDirty": true,
|
|
9
|
+
"notForCommits": true,
|
|
9
10
|
"prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Change summary: {{diff_stat}}. Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings."
|
|
10
11
|
},
|
|
11
12
|
{
|
|
12
13
|
"name": "Review code",
|
|
13
14
|
"category": "Plan",
|
|
14
15
|
"description": "Review the whole project's code quality (Plan)",
|
|
16
|
+
"notForCommits": true,
|
|
15
17
|
"prompt": "Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet."
|
|
16
18
|
},
|
|
17
19
|
{
|
|
@@ -36,8 +38,25 @@
|
|
|
36
38
|
"name": "Propose features",
|
|
37
39
|
"category": "Plan",
|
|
38
40
|
"description": "Propose new features (Plan)",
|
|
41
|
+
"notForCommits": true,
|
|
39
42
|
"prompt": "Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in terms of changes, whether this will break APIs, or introduce compatibility issues."
|
|
40
43
|
},
|
|
44
|
+
{
|
|
45
|
+
"name": "Review commits",
|
|
46
|
+
"category": "Plan",
|
|
47
|
+
"description": "Review the selected commits (Plan)",
|
|
48
|
+
"browser": "commits",
|
|
49
|
+
"hidden": true,
|
|
50
|
+
"prompt": "Review the selected commits: {{selected_commits}}. Inspect each with `git show <hash>`, double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Summarize your findings per commit and propose a plan for any fixes if needed. Do not make any changes yet."
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
"name": "Browse commits",
|
|
54
|
+
"category": "Browse",
|
|
55
|
+
"description": "Browse and select commits, then pick a task to run on them",
|
|
56
|
+
"browser": "commits",
|
|
57
|
+
"when": "git",
|
|
58
|
+
"prompt": "Browse recent commits, select some, then pick a task to run on them."
|
|
59
|
+
},
|
|
41
60
|
{
|
|
42
61
|
"name": "Build",
|
|
43
62
|
"category": "Do",
|
|
@@ -51,6 +51,8 @@ import {
|
|
|
51
51
|
} from "@earendil-works/pi-tui";
|
|
52
52
|
import {
|
|
53
53
|
CHAIN_MAX,
|
|
54
|
+
COMMIT_BROWSER_MAX,
|
|
55
|
+
COMMIT_SELECT_MAX,
|
|
54
56
|
DEFAULT_SHORTCUT,
|
|
55
57
|
DEFAULT_TASKS,
|
|
56
58
|
assistantText,
|
|
@@ -65,12 +67,16 @@ import {
|
|
|
65
67
|
evaluateWhen,
|
|
66
68
|
formatChainSequence,
|
|
67
69
|
formatList,
|
|
70
|
+
formatSelectedCommits,
|
|
71
|
+
groupCommitsByDate,
|
|
68
72
|
groupTasksByCategory,
|
|
73
|
+
isTaskVisible,
|
|
69
74
|
isValidKeyId,
|
|
70
75
|
mergeTasks,
|
|
71
76
|
parseConfig,
|
|
72
77
|
parseConfigRegexpValueForKey,
|
|
73
78
|
parseCommitSubject,
|
|
79
|
+
parseGitLogOutput,
|
|
74
80
|
parseStatusPorcelain,
|
|
75
81
|
parseStatusStagedUnstaged,
|
|
76
82
|
orderTasksByCategory,
|
|
@@ -88,7 +94,10 @@ import {
|
|
|
88
94
|
toPromptContext,
|
|
89
95
|
validateChain,
|
|
90
96
|
type ChainStepOutcome,
|
|
97
|
+
type CommitInfo,
|
|
98
|
+
type DateGroup,
|
|
91
99
|
type DoAlwaysTask,
|
|
100
|
+
type SelectedCommit,
|
|
92
101
|
type TaskContext,
|
|
93
102
|
type TableRow,
|
|
94
103
|
} from "./tasks";
|
|
@@ -196,6 +205,17 @@ function git(cwd: string, args: string[]): Promise<string | undefined> {
|
|
|
196
205
|
});
|
|
197
206
|
}
|
|
198
207
|
|
|
208
|
+
/**
|
|
209
|
+
* Count changed files (staged, unstaged, untracked) with a single git spawn.
|
|
210
|
+
* Lighter than a full `buildContext` when only the count is needed (chain
|
|
211
|
+
* step summaries). Returns 0 when git is unavailable — the same neutral
|
|
212
|
+
* result `buildContext` yields via its empty file list.
|
|
213
|
+
*/
|
|
214
|
+
async function changedFileCount(cwd: string): Promise<number> {
|
|
215
|
+
const porcelain = await git(cwd, ["status", "--porcelain"]);
|
|
216
|
+
return porcelain ? parseStatusPorcelain(porcelain).length : 0;
|
|
217
|
+
}
|
|
218
|
+
|
|
199
219
|
/**
|
|
200
220
|
* Build the structured context for the current directory. Git facts fall back
|
|
201
221
|
* to neutral values when unavailable (non-git dir, no git, empty repo) so
|
|
@@ -233,6 +253,7 @@ async function buildContext(cwd: string): Promise<TaskContext> {
|
|
|
233
253
|
repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
|
|
234
254
|
stagedFiles: [],
|
|
235
255
|
unstagedFiles: [],
|
|
256
|
+
selectedCommits: "none",
|
|
236
257
|
isGitRepo: false,
|
|
237
258
|
};
|
|
238
259
|
}
|
|
@@ -274,15 +295,566 @@ async function buildContext(cwd: string): Promise<TaskContext> {
|
|
|
274
295
|
repo,
|
|
275
296
|
stagedFiles,
|
|
276
297
|
unstagedFiles,
|
|
298
|
+
selectedCommits: "none",
|
|
277
299
|
isGitRepo,
|
|
278
300
|
};
|
|
279
301
|
}
|
|
280
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Fetch a page of commits from git with lightweight stats via a single log call.
|
|
305
|
+
* Fetches pageSize + 1 commits to determine if more commits are available.
|
|
306
|
+
*/
|
|
307
|
+
async function fetchCommitsPage(
|
|
308
|
+
cwd: string,
|
|
309
|
+
page = 0,
|
|
310
|
+
pageSize = COMMIT_BROWSER_MAX,
|
|
311
|
+
): Promise<{ commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }> {
|
|
312
|
+
const countToFetch = pageSize + 1;
|
|
313
|
+
const skip = page * pageSize;
|
|
314
|
+
const format = "COMMIT%x09%H%x09%h%x09%s%x09%ad%x09%an";
|
|
315
|
+
const logOutput = await git(cwd, [
|
|
316
|
+
"log",
|
|
317
|
+
"-n",
|
|
318
|
+
`${countToFetch}`,
|
|
319
|
+
`--skip=${skip}`,
|
|
320
|
+
`--format=${format}`,
|
|
321
|
+
"--shortstat",
|
|
322
|
+
"--date=short",
|
|
323
|
+
]);
|
|
324
|
+
if (!logOutput) {
|
|
325
|
+
return { commits: [], groups: [], hasMore: false };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const allParsed = parseGitLogOutput(logOutput);
|
|
329
|
+
const hasMore = allParsed.length > pageSize;
|
|
330
|
+
const commits = hasMore ? allParsed.slice(0, pageSize) : allParsed;
|
|
331
|
+
const groups = groupCommitsByDate(commits);
|
|
332
|
+
return { commits, groups, hasMore };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Date-grouped commit browser: browse recent commits in pages of 20, select some,
|
|
337
|
+
* then review them. Reuses the task-selector interaction model (↑↓, Space toggle,
|
|
338
|
+
* type-to-filter, Ctrl+U clear, Esc cancel, ←/→ or PgUp/PgDn for paging).
|
|
339
|
+
*
|
|
340
|
+
* Returns the selected commits (ordered by user selection), or null on cancel.
|
|
341
|
+
*/
|
|
342
|
+
function browseCommits(
|
|
343
|
+
ctx: ExtensionContext,
|
|
344
|
+
initialData?: { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean },
|
|
345
|
+
): Promise<SelectedCommit[] | null> {
|
|
346
|
+
return new Promise<SelectedCommit[] | null>((resolve) => {
|
|
347
|
+
const pageCache = new Map<number, { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }>();
|
|
348
|
+
let page = 0;
|
|
349
|
+
if (initialData) {
|
|
350
|
+
pageCache.set(0, initialData);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
type CommitCursor = { kind: "commit"; index: number } | { kind: "run" };
|
|
354
|
+
const maxVisible = 14; // commits visible in the scroll window
|
|
355
|
+
const selectedMap = new Map<string, SelectedCommit>(); // hash -> SelectedCommit
|
|
356
|
+
let cursor: CommitCursor = { kind: "commit", index: 0 };
|
|
357
|
+
let lastCommitIndex = 0;
|
|
358
|
+
let filter = "";
|
|
359
|
+
let settled = false;
|
|
360
|
+
let isLoading = false;
|
|
361
|
+
|
|
362
|
+
ctx.ui.custom<SelectedCommit[] | null>((tui, theme, _kb, done) => {
|
|
363
|
+
const kb = getKeybindings();
|
|
364
|
+
|
|
365
|
+
function finish(selectedCommits: SelectedCommit[]) {
|
|
366
|
+
if (settled) return;
|
|
367
|
+
settled = true;
|
|
368
|
+
done(selectedCommits);
|
|
369
|
+
resolve(selectedCommits);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function finishCancel() {
|
|
373
|
+
if (settled) return;
|
|
374
|
+
settled = true;
|
|
375
|
+
done(null);
|
|
376
|
+
resolve(null);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function loadPage(newPage: number) {
|
|
380
|
+
if (isLoading || newPage < 0) return;
|
|
381
|
+
page = newPage;
|
|
382
|
+
if (!pageCache.has(page)) {
|
|
383
|
+
isLoading = true;
|
|
384
|
+
tui.requestRender();
|
|
385
|
+
try {
|
|
386
|
+
const fetched = await fetchCommitsPage(ctx.cwd, page);
|
|
387
|
+
pageCache.set(page, fetched);
|
|
388
|
+
} finally {
|
|
389
|
+
isLoading = false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
cursor = { kind: "commit", index: 0 };
|
|
393
|
+
lastCommitIndex = 0;
|
|
394
|
+
tui.requestRender();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getCurrentPageData() {
|
|
398
|
+
return pageCache.get(page) ?? { commits: [], groups: [], hasMore: false };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function getVisibleCommits(): CommitInfo[] {
|
|
402
|
+
const { commits } = getCurrentPageData();
|
|
403
|
+
if (!filter) return commits;
|
|
404
|
+
const f = filter.toLowerCase();
|
|
405
|
+
return commits.filter(
|
|
406
|
+
(c) => c.subject.toLowerCase().includes(f) || c.shortHash.toLowerCase().includes(f),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function render(width: number): string[] {
|
|
411
|
+
const currentData = getCurrentPageData();
|
|
412
|
+
const visibleCommits = getVisibleCommits();
|
|
413
|
+
const lines: string[] = [];
|
|
414
|
+
|
|
415
|
+
// Clamp cursor index if visible list changed
|
|
416
|
+
if (cursor.kind === "commit") {
|
|
417
|
+
if (visibleCommits.length === 0) {
|
|
418
|
+
cursor = { kind: "run" };
|
|
419
|
+
} else if (cursor.index >= visibleCommits.length) {
|
|
420
|
+
cursor = { kind: "commit", index: visibleCommits.length - 1 };
|
|
421
|
+
lastCommitIndex = cursor.index;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Header
|
|
426
|
+
const startNum = page * COMMIT_BROWSER_MAX + 1;
|
|
427
|
+
const endNum = page * COMMIT_BROWSER_MAX + (visibleCommits.length || 0);
|
|
428
|
+
const pageLabel = currentData.hasMore
|
|
429
|
+
? `Page ${page + 1} (${startNum}–${endNum}+)`
|
|
430
|
+
: `Page ${page + 1} (${startNum}–${endNum})`;
|
|
431
|
+
|
|
432
|
+
lines.push(
|
|
433
|
+
theme.fg(
|
|
434
|
+
"muted",
|
|
435
|
+
truncateToWidth(
|
|
436
|
+
` # DATE HASH FILES ORDER SUBJECT [${pageLabel}]`,
|
|
437
|
+
width - 2,
|
|
438
|
+
"",
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
if (isLoading) {
|
|
444
|
+
lines.push(theme.fg("accent", " Loading commits from git…"));
|
|
445
|
+
} else if (visibleCommits.length === 0) {
|
|
446
|
+
lines.push(
|
|
447
|
+
filter
|
|
448
|
+
? theme.fg("warning", ` No commits matching "${filter}" on this page`)
|
|
449
|
+
: theme.fg("muted", " No commits found"),
|
|
450
|
+
);
|
|
451
|
+
} else {
|
|
452
|
+
const anchor = cursor.kind === "commit" ? cursor.index : lastCommitIndex;
|
|
453
|
+
const winStart = visibleCommits.length > maxVisible
|
|
454
|
+
? Math.max(0, Math.min(anchor + 1 - maxVisible, visibleCommits.length - maxVisible))
|
|
455
|
+
: 0;
|
|
456
|
+
const winEnd = Math.min(winStart + maxVisible, visibleCommits.length);
|
|
457
|
+
|
|
458
|
+
for (let i = winStart; i < winEnd; i++) {
|
|
459
|
+
const c = visibleCommits[i];
|
|
460
|
+
// Show date divider if it's the first commit in window or date changed
|
|
461
|
+
if (i === winStart || c.date !== visibleCommits[i - 1].date) {
|
|
462
|
+
lines.push(theme.fg("dim", ` ── ${c.date} ──`));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const isCursor = cursor.kind === "commit" && cursor.index === i;
|
|
466
|
+
const isSelected = selectedMap.has(c.hash);
|
|
467
|
+
const order = isSelected ? [...selectedMap.keys()].indexOf(c.hash) + 1 : 0;
|
|
468
|
+
const cursorMark = isCursor ? theme.fg("accent", "►") : " ";
|
|
469
|
+
const globalIdx = page * COMMIT_BROWSER_MAX + i + 1;
|
|
470
|
+
const num = `${cursorMark} ${String(globalIdx).padStart(2)}`;
|
|
471
|
+
const marker = isSelected ? `${theme.fg("accent", "◉")} [${order}]` : " · ";
|
|
472
|
+
const hash = theme.fg("dim", c.shortHash.slice(0, 7));
|
|
473
|
+
const statStr = c.filesChanged > 0 ? `${c.filesChanged}f` : "";
|
|
474
|
+
const statCell = statStr.padStart(3);
|
|
475
|
+
const dateShort = c.date.slice(5); // MM-DD
|
|
476
|
+
const subject = truncateToWidth(c.subject, Math.max(10, width - 36), "…");
|
|
477
|
+
|
|
478
|
+
const rowText = `${num} ${dateShort} ${hash} ${statCell} ${marker} ${subject}`;
|
|
479
|
+
if (isCursor) {
|
|
480
|
+
lines.push(theme.bg("selectedBg", theme.bold(rowText)));
|
|
481
|
+
} else {
|
|
482
|
+
lines.push(rowText);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (visibleCommits.length > maxVisible) {
|
|
487
|
+
lines.push(
|
|
488
|
+
theme.fg(
|
|
489
|
+
"dim",
|
|
490
|
+
truncateToWidth(
|
|
491
|
+
` (${cursor.kind === "commit" ? cursor.index + 1 : lastCommitIndex + 1}/${visibleCommits.length} on page ${page + 1})`,
|
|
492
|
+
width - 2,
|
|
493
|
+
"",
|
|
494
|
+
),
|
|
495
|
+
),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Run row
|
|
501
|
+
lines.push("");
|
|
502
|
+
lines.push(theme.fg("dim", " " + "─".repeat(Math.max(1, width - 4))));
|
|
503
|
+
const runLabel = selectedMap.size === 0
|
|
504
|
+
? "do on the commits (no commits selected)"
|
|
505
|
+
: `do on the commits (${selectedMap.size} commit${selectedMap.size !== 1 ? "s" : ""})`;
|
|
506
|
+
if (cursor.kind === "run") {
|
|
507
|
+
lines.push(theme.bg("selectedBg", theme.bold(`${theme.fg("accent", "►")} ${runLabel}`)));
|
|
508
|
+
} else if (selectedMap.size === 0) {
|
|
509
|
+
lines.push(theme.fg("dim", ` ${runLabel}`));
|
|
510
|
+
} else {
|
|
511
|
+
lines.push(theme.fg("accent", ` ${runLabel}`));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Footer
|
|
515
|
+
const pageNavHints: string[] = [];
|
|
516
|
+
if (page > 0) pageNavHints.push("← prev page");
|
|
517
|
+
if (currentData.hasMore) pageNavHints.push("→ next page");
|
|
518
|
+
const pageHintStr = pageNavHints.length > 0 ? ` • ${pageNavHints.join(" • ")}` : "";
|
|
519
|
+
|
|
520
|
+
let footer = ` space/⏎ select • ↑/↓ move${pageHintStr} • esc`;
|
|
521
|
+
if (selectedMap.size > 0) {
|
|
522
|
+
footer += " • ctrl+u clear";
|
|
523
|
+
}
|
|
524
|
+
if (filter) {
|
|
525
|
+
footer += ` • filter: "${filter}"`;
|
|
526
|
+
}
|
|
527
|
+
lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
|
|
528
|
+
|
|
529
|
+
return lines;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function handleInput(data: string) {
|
|
533
|
+
if (settled) return;
|
|
534
|
+
const currentData = getCurrentPageData();
|
|
535
|
+
const visibleCommits = getVisibleCommits();
|
|
536
|
+
|
|
537
|
+
// Space: toggle selection on current commit
|
|
538
|
+
if (matchesKey(data, "space")) {
|
|
539
|
+
if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
|
|
540
|
+
const c = visibleCommits[cursor.index];
|
|
541
|
+
if (selectedMap.has(c.hash)) {
|
|
542
|
+
selectedMap.delete(c.hash);
|
|
543
|
+
let i = 1;
|
|
544
|
+
for (const sc of selectedMap.values()) {
|
|
545
|
+
sc.selectionOrder = i++;
|
|
546
|
+
}
|
|
547
|
+
} else if (selectedMap.size < COMMIT_SELECT_MAX) {
|
|
548
|
+
selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
|
|
549
|
+
} else {
|
|
550
|
+
ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
|
|
551
|
+
}
|
|
552
|
+
tui.requestRender();
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Confirm (Enter): run the chain if on run row, or toggle selection if on a commit
|
|
558
|
+
if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
|
|
559
|
+
if (cursor.kind === "run") {
|
|
560
|
+
if (selectedMap.size === 0) {
|
|
561
|
+
ctx.ui.notify("do-always: no commits selected", "info");
|
|
562
|
+
} else {
|
|
563
|
+
finish([...selectedMap.values()]);
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
|
|
568
|
+
const c = visibleCommits[cursor.index];
|
|
569
|
+
if (selectedMap.has(c.hash)) {
|
|
570
|
+
selectedMap.delete(c.hash);
|
|
571
|
+
let i = 1;
|
|
572
|
+
for (const sc of selectedMap.values()) {
|
|
573
|
+
sc.selectionOrder = i++;
|
|
574
|
+
}
|
|
575
|
+
} else if (selectedMap.size < COMMIT_SELECT_MAX) {
|
|
576
|
+
selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
|
|
577
|
+
} else {
|
|
578
|
+
ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
|
|
579
|
+
}
|
|
580
|
+
tui.requestRender();
|
|
581
|
+
}
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Cancel (Esc / Ctrl+C)
|
|
586
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
587
|
+
finishCancel();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Ctrl+U: clear selections
|
|
592
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
593
|
+
if (selectedMap.size > 0) {
|
|
594
|
+
selectedMap.clear();
|
|
595
|
+
tui.requestRender();
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Backspace: delete character from filter, or undo last selection
|
|
601
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
|
|
602
|
+
if (filter.length > 0) {
|
|
603
|
+
filter = filter.slice(0, -1);
|
|
604
|
+
cursor = { kind: "commit", index: 0 };
|
|
605
|
+
lastCommitIndex = 0;
|
|
606
|
+
} else if (selectedMap.size > 0) {
|
|
607
|
+
const lastHash = [...selectedMap.keys()].pop()!;
|
|
608
|
+
selectedMap.delete(lastHash);
|
|
609
|
+
}
|
|
610
|
+
tui.requestRender();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Page navigation: Next page (Right arrow, or PageDown when at the end)
|
|
615
|
+
if (matchesKey(data, "right")) {
|
|
616
|
+
if (currentData.hasMore) {
|
|
617
|
+
await loadPage(page + 1);
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (kb.matches(data, "tui.select.pageDown") || matchesKey(data, "pageDown")) {
|
|
622
|
+
if (cursor.kind === "commit") {
|
|
623
|
+
const next = cursor.index + maxVisible;
|
|
624
|
+
if (next < visibleCommits.length) {
|
|
625
|
+
cursor = { kind: "commit", index: next };
|
|
626
|
+
lastCommitIndex = next;
|
|
627
|
+
tui.requestRender();
|
|
628
|
+
} else if (currentData.hasMore) {
|
|
629
|
+
await loadPage(page + 1);
|
|
630
|
+
} else {
|
|
631
|
+
cursor = { kind: "run" };
|
|
632
|
+
tui.requestRender();
|
|
633
|
+
}
|
|
634
|
+
} else if (currentData.hasMore) {
|
|
635
|
+
await loadPage(page + 1);
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Page navigation: Previous page (Left arrow, or PageUp when at the top)
|
|
641
|
+
if (matchesKey(data, "left")) {
|
|
642
|
+
if (page > 0) {
|
|
643
|
+
await loadPage(page - 1);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (kb.matches(data, "tui.select.pageUp") || matchesKey(data, "pageUp")) {
|
|
648
|
+
if (cursor.kind === "commit") {
|
|
649
|
+
const prev = cursor.index - maxVisible;
|
|
650
|
+
if (prev >= 0) {
|
|
651
|
+
cursor = { kind: "commit", index: prev };
|
|
652
|
+
lastCommitIndex = prev;
|
|
653
|
+
tui.requestRender();
|
|
654
|
+
} else if (page > 0) {
|
|
655
|
+
await loadPage(page - 1);
|
|
656
|
+
} else {
|
|
657
|
+
cursor = { kind: "commit", index: 0 };
|
|
658
|
+
lastCommitIndex = 0;
|
|
659
|
+
tui.requestRender();
|
|
660
|
+
}
|
|
661
|
+
} else if (visibleCommits.length > 0) {
|
|
662
|
+
cursor = { kind: "commit", index: Math.max(0, visibleCommits.length - maxVisible) };
|
|
663
|
+
lastCommitIndex = cursor.index;
|
|
664
|
+
tui.requestRender();
|
|
665
|
+
}
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Up arrow
|
|
670
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
|
|
671
|
+
if (cursor.kind === "run") {
|
|
672
|
+
if (visibleCommits.length > 0) {
|
|
673
|
+
cursor = { kind: "commit", index: visibleCommits.length - 1 };
|
|
674
|
+
lastCommitIndex = visibleCommits.length - 1;
|
|
675
|
+
}
|
|
676
|
+
} else if (cursor.index > 0) {
|
|
677
|
+
cursor = { kind: "commit", index: cursor.index - 1 };
|
|
678
|
+
lastCommitIndex = cursor.index;
|
|
679
|
+
} else {
|
|
680
|
+
cursor = { kind: "run" };
|
|
681
|
+
}
|
|
682
|
+
tui.requestRender();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Down arrow
|
|
687
|
+
if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
|
|
688
|
+
if (cursor.kind === "run") {
|
|
689
|
+
if (visibleCommits.length > 0) {
|
|
690
|
+
cursor = { kind: "commit", index: 0 };
|
|
691
|
+
lastCommitIndex = 0;
|
|
692
|
+
}
|
|
693
|
+
} else if (cursor.index < visibleCommits.length - 1) {
|
|
694
|
+
cursor = { kind: "commit", index: cursor.index + 1 };
|
|
695
|
+
lastCommitIndex = cursor.index;
|
|
696
|
+
} else {
|
|
697
|
+
cursor = { kind: "run" };
|
|
698
|
+
}
|
|
699
|
+
tui.requestRender();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Home / End
|
|
704
|
+
if (matchesKey(data, "home")) {
|
|
705
|
+
if (visibleCommits.length > 0) {
|
|
706
|
+
cursor = { kind: "commit", index: 0 };
|
|
707
|
+
lastCommitIndex = 0;
|
|
708
|
+
tui.requestRender();
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (matchesKey(data, "end")) {
|
|
713
|
+
cursor = { kind: "run" };
|
|
714
|
+
tui.requestRender();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Filter typing (printable chars, excluding Space which toggles)
|
|
719
|
+
if (isPrintable(data) && data !== " ") {
|
|
720
|
+
filter += data;
|
|
721
|
+
cursor = { kind: "commit", index: 0 };
|
|
722
|
+
lastCommitIndex = 0;
|
|
723
|
+
tui.requestRender();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
render,
|
|
730
|
+
handleInput,
|
|
731
|
+
invalidate: () => {},
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
281
737
|
/** True for a single printable ASCII character (used for filter typing). */
|
|
282
738
|
function isPrintable(data: string): boolean {
|
|
283
739
|
return data.length === 1 && data >= " " && data <= "~";
|
|
284
740
|
}
|
|
285
741
|
|
|
742
|
+
/**
|
|
743
|
+
* Task picker shown after a commit selection: proposes the Plan tasks (hidden
|
|
744
|
+
* ones included — e.g. "Review commits" exists to be picked here) to run on
|
|
745
|
+
* the selected commits. Type to filter, ↑/↓ move, Enter runs, Esc cancels.
|
|
746
|
+
* Returns the chosen task, or null on cancel.
|
|
747
|
+
*/
|
|
748
|
+
function pickPlanTaskForCommits(
|
|
749
|
+
ctx: ExtensionContext,
|
|
750
|
+
candidates: DoAlwaysTask[],
|
|
751
|
+
commitCount: number,
|
|
752
|
+
): Promise<DoAlwaysTask | null> {
|
|
753
|
+
return new Promise<DoAlwaysTask | null>((resolve) => {
|
|
754
|
+
ctx.ui.custom<DoAlwaysTask | null>((tui, theme, _kb, done) => {
|
|
755
|
+
const kb = getKeybindings();
|
|
756
|
+
let settled = false;
|
|
757
|
+
let filter = "";
|
|
758
|
+
let cursor = 0;
|
|
759
|
+
|
|
760
|
+
const visible = () => {
|
|
761
|
+
if (!filter) return candidates;
|
|
762
|
+
const f = filter.toLowerCase();
|
|
763
|
+
return candidates.filter(
|
|
764
|
+
(t) =>
|
|
765
|
+
t.name.toLowerCase().includes(f) ||
|
|
766
|
+
(t.description ?? "").toLowerCase().includes(f),
|
|
767
|
+
);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
function finish(task: DoAlwaysTask | null) {
|
|
771
|
+
if (settled) return;
|
|
772
|
+
settled = true;
|
|
773
|
+
done(task);
|
|
774
|
+
resolve(task);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function render(width: number): string[] {
|
|
778
|
+
const items = visible();
|
|
779
|
+
if (items.length === 0) cursor = 0;
|
|
780
|
+
else if (cursor >= items.length) cursor = items.length - 1;
|
|
781
|
+
const lines: string[] = [];
|
|
782
|
+
lines.push(
|
|
783
|
+
theme.fg(
|
|
784
|
+
"accent",
|
|
785
|
+
theme.bold(
|
|
786
|
+
truncateToWidth(
|
|
787
|
+
` do-always — run on ${commitCount} selected commit${commitCount !== 1 ? "s" : ""}`,
|
|
788
|
+
width - 2,
|
|
789
|
+
"",
|
|
790
|
+
),
|
|
791
|
+
),
|
|
792
|
+
),
|
|
793
|
+
);
|
|
794
|
+
lines.push(theme.fg("muted", " # TASK"));
|
|
795
|
+
if (items.length === 0) {
|
|
796
|
+
lines.push(theme.fg("warning", ` No Plan tasks matching "${filter}"`));
|
|
797
|
+
} else {
|
|
798
|
+
for (let i = 0; i < items.length; i++) {
|
|
799
|
+
const t = items[i];
|
|
800
|
+
const auto = shouldAutoRun(t);
|
|
801
|
+
const name = truncateToWidth(t.name, Math.max(10, width - 8 - (auto ? 3 : 0)), "…", true);
|
|
802
|
+
const rowText = ` ${String(i + 1).padStart(2)} ${auto ? "⚡ " : ""}${name}`;
|
|
803
|
+
lines.push(i === cursor ? theme.bg("selectedBg", theme.bold(rowText)) : rowText);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
lines.push(
|
|
807
|
+
theme.fg(
|
|
808
|
+
"dim",
|
|
809
|
+
truncateToWidth(
|
|
810
|
+
` ↑/↓ move • ⏎ run • esc${filter ? ` • filter: "${filter}"` : ""}`,
|
|
811
|
+
width - 2,
|
|
812
|
+
"",
|
|
813
|
+
),
|
|
814
|
+
),
|
|
815
|
+
);
|
|
816
|
+
return lines;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function handleInput(data: string) {
|
|
820
|
+
if (settled) return;
|
|
821
|
+
const items = visible();
|
|
822
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
823
|
+
finish(null);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
|
|
827
|
+
finish(items[cursor] ?? null);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
|
|
831
|
+
if (items.length > 0) cursor = cursor === 0 ? items.length - 1 : cursor - 1;
|
|
832
|
+
tui.requestRender();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
|
|
836
|
+
if (items.length > 0) cursor = cursor === items.length - 1 ? 0 : cursor + 1;
|
|
837
|
+
tui.requestRender();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
|
|
841
|
+
filter = filter.slice(0, -1);
|
|
842
|
+
cursor = 0;
|
|
843
|
+
tui.requestRender();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (isPrintable(data)) {
|
|
847
|
+
filter += data;
|
|
848
|
+
cursor = 0;
|
|
849
|
+
tui.requestRender();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return { render, handleInput, invalidate: () => {} };
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
286
858
|
/** Delay before the selector reveals the selected task's prompt preview. */
|
|
287
859
|
const PREVIEW_DELAY_MS = 2000;
|
|
288
860
|
/** Max lines of the prompt shown in the selector preview. */
|
|
@@ -787,9 +1359,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
787
1359
|
return done;
|
|
788
1360
|
}
|
|
789
1361
|
|
|
790
|
-
/** Filter tasks by
|
|
1362
|
+
/** Filter tasks by visibility (`hidden` flag + `when` condition) and refresh the completion cache. */
|
|
791
1363
|
function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
|
|
792
|
-
const visible = tasks.filter((t) =>
|
|
1364
|
+
const visible = tasks.filter((t) => isTaskVisible(t, context));
|
|
793
1365
|
visibleCache = { cwd, visible };
|
|
794
1366
|
return visible;
|
|
795
1367
|
}
|
|
@@ -875,6 +1447,106 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
875
1447
|
|
|
876
1448
|
/** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
|
|
877
1449
|
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
|
|
1450
|
+
// Commit browser: any task with `browser: "commits"` opens the
|
|
1451
|
+
// date-grouped browser first. The name check keeps configs that
|
|
1452
|
+
// predate the field working. After the selection: a task whose prompt
|
|
1453
|
+
// references {{selected_commits}} runs itself on the selection; a
|
|
1454
|
+
// generic entry (e.g. "Browse commits") proposes the Plan tasks to
|
|
1455
|
+
// run on it — hidden tasks included, since they exist to be picked
|
|
1456
|
+
// here (non-TUI: the first one whose guards pass).
|
|
1457
|
+
if (task.browser === "commits" || task.name === "Review commits") {
|
|
1458
|
+
let selected: SelectedCommit[] = [];
|
|
1459
|
+
if (ctx.mode !== "tui") {
|
|
1460
|
+
const initialData = await fetchCommitsPage(ctx.cwd, 0, 1);
|
|
1461
|
+
if (initialData.commits.length === 0) {
|
|
1462
|
+
ctx.ui.notify("do-always: no commits found", "info");
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
selected = [{ ...initialData.commits[0], selectionOrder: 1 }];
|
|
1466
|
+
} else {
|
|
1467
|
+
const initialData = await fetchCommitsPage(ctx.cwd, 0);
|
|
1468
|
+
if (initialData.commits.length === 0) {
|
|
1469
|
+
ctx.ui.notify("do-always: no commits found", "info");
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const result = await browseCommits(ctx, initialData);
|
|
1473
|
+
if (!result) {
|
|
1474
|
+
ctx.ui.notify("do-always: commit browser cancelled", "info");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (result.length === 0) {
|
|
1478
|
+
ctx.ui.notify("do-always: no commits selected", "info");
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
selected = result;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Which task runs on the selection: the originating task when its
|
|
1485
|
+
// prompt consumes {{selected_commits}}; otherwise a Plan task
|
|
1486
|
+
// picked by the user (TUI) or the first whose guards pass (non-TUI).
|
|
1487
|
+
const planTasks = tasks.filter(
|
|
1488
|
+
(t) =>
|
|
1489
|
+
!t.notForCommits &&
|
|
1490
|
+
evaluateWhen(t, context) &&
|
|
1491
|
+
(t.category ?? "").trim().toLowerCase() === "plan",
|
|
1492
|
+
);
|
|
1493
|
+
if (planTasks.length === 0) {
|
|
1494
|
+
ctx.ui.notify("do-always: no Plan task available to run on the selected commits", "info");
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
let chosen: DoAlwaysTask | undefined;
|
|
1498
|
+
if (/\{\{\s*selected_commits\s*\}\}/.test(task.prompt)) {
|
|
1499
|
+
chosen = task;
|
|
1500
|
+
} else if (ctx.mode === "tui") {
|
|
1501
|
+
const picked = await pickPlanTaskForCommits(ctx, planTasks, selected.length);
|
|
1502
|
+
if (!picked) {
|
|
1503
|
+
ctx.ui.notify("do-always: no task chosen — commits not used", "info");
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
chosen = picked;
|
|
1507
|
+
} else {
|
|
1508
|
+
// Non-TUI: no picker — the first Plan task whose guards pass.
|
|
1509
|
+
chosen = planTasks.find((t) => evaluateGuards(t, context) === null);
|
|
1510
|
+
}
|
|
1511
|
+
if (!chosen) {
|
|
1512
|
+
ctx.ui.notify("do-always: no Plan task available to run on the selected commits (guards unmet)", "info");
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Guards apply to the commit run too: a picked (or originating)
|
|
1517
|
+
// task blocked for the current tree is not sent — same as the
|
|
1518
|
+
// non-browser path below.
|
|
1519
|
+
const blocked = evaluateGuards(chosen, context);
|
|
1520
|
+
if (blocked) {
|
|
1521
|
+
ctx.ui.notify(`do-always: ${blocked}`, "info");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Inject the selection: substitute {{selected_commits}} when the
|
|
1526
|
+
// prompt references it, otherwise append the detail block. The run
|
|
1527
|
+
// is a single turn (not a chain): sendUserMessage + summary.
|
|
1528
|
+
const details = formatSelectedCommits(selected);
|
|
1529
|
+
const strings = toPromptContext(context);
|
|
1530
|
+
const prompt = /\{\{\s*selected_commits\s*\}\}/.test(chosen.prompt)
|
|
1531
|
+
? renderPrompt(chosen.prompt, { ...strings, selected_commits: details })
|
|
1532
|
+
: `${renderPrompt(chosen.prompt, strings)}\n\nSelected commits:\n${details}`;
|
|
1533
|
+
|
|
1534
|
+
pendingSummaryTask = chosen.name;
|
|
1535
|
+
if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
|
|
1536
|
+
pendingSummaryTimer = setTimeout(() => {
|
|
1537
|
+
pendingSummaryTimer = null;
|
|
1538
|
+
if (!pendingSummaryTask) return;
|
|
1539
|
+
const name = pendingSummaryTask;
|
|
1540
|
+
pendingSummaryTask = null;
|
|
1541
|
+
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
1542
|
+
}, 10_000);
|
|
1543
|
+
pi.sendUserMessage(prompt);
|
|
1544
|
+
ctx.ui.notify(
|
|
1545
|
+
`do-always: ${selected.length} commit${selected.length !== 1 ? "s" : ""} → ${chosen.name}`,
|
|
1546
|
+
"info",
|
|
1547
|
+
);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
878
1550
|
const blocked = evaluateGuards(task, context);
|
|
879
1551
|
if (blocked) {
|
|
880
1552
|
ctx.ui.notify(`do-always: ${blocked}`, "info");
|
|
@@ -1007,12 +1679,21 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1007
1679
|
return;
|
|
1008
1680
|
}
|
|
1009
1681
|
setChainStep(ctx, 0, "completed");
|
|
1010
|
-
// Post-step summary for step 1 (fill-first)
|
|
1011
|
-
|
|
1012
|
-
|
|
1682
|
+
// Post-step summary for step 1 (fill-first). When more steps
|
|
1683
|
+
// follow, the fresh full build doubles as step 2's pre-context
|
|
1684
|
+
// (the tree can only change via agent runs, which have settled);
|
|
1685
|
+
// a single-task chain only needs the file count.
|
|
1686
|
+
let nextContext: TaskContext | undefined;
|
|
1687
|
+
let fileCount: number;
|
|
1688
|
+
if (steps.length === 1) {
|
|
1689
|
+
fileCount = await changedFileCount(ctx.cwd);
|
|
1690
|
+
} else {
|
|
1691
|
+
nextContext = await getContext(ctx.cwd);
|
|
1692
|
+
fileCount = nextContext.files.length;
|
|
1693
|
+
}
|
|
1013
1694
|
const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
|
|
1014
1695
|
ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
|
|
1015
|
-
await runChainSteps(steps, ctx, 1);
|
|
1696
|
+
await runChainSteps(steps, ctx, 1, nextContext);
|
|
1016
1697
|
} finally {
|
|
1017
1698
|
chainActive = false;
|
|
1018
1699
|
}
|
|
@@ -1024,15 +1705,23 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1024
1705
|
* and renders prompts against fresh repository context, and each step is
|
|
1025
1706
|
* awaited until its run has fully settled; an aborted/errored step (or a
|
|
1026
1707
|
* send that failed to start) stops the chain.
|
|
1708
|
+
*
|
|
1709
|
+
* Context builds are shared across steps: after each completed step the
|
|
1710
|
+
* fresh full build (needed for the file-count summary) is reused as the
|
|
1711
|
+
* next step's pre-context — the tree can only change via agent runs, and
|
|
1712
|
+
* the step has fully settled before the build runs. `initialContext` lets
|
|
1713
|
+
* the fill-first path hand over its post-step-1 build. The last step's
|
|
1714
|
+
* summary uses a single-spawn file count instead of a full build.
|
|
1027
1715
|
*/
|
|
1028
1716
|
async function runChainSteps(
|
|
1029
1717
|
steps: DoAlwaysTask[],
|
|
1030
1718
|
ctx: ExtensionContext,
|
|
1031
1719
|
startAt: number,
|
|
1720
|
+
initialContext?: TaskContext,
|
|
1032
1721
|
): Promise<void> {
|
|
1722
|
+
let context = initialContext ?? (await getContext(ctx.cwd));
|
|
1033
1723
|
for (let i = startAt; i < steps.length; i++) {
|
|
1034
1724
|
const step = steps[i];
|
|
1035
|
-
const context = await getContext(ctx.cwd);
|
|
1036
1725
|
const blocked = evaluateGuards(step, context);
|
|
1037
1726
|
if (blocked) {
|
|
1038
1727
|
markChainStopped(ctx, i, "skipped", blocked);
|
|
@@ -1050,9 +1739,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1050
1739
|
chainDurations[i] = stepDuration;
|
|
1051
1740
|
if (outcome === "completed") {
|
|
1052
1741
|
setChainStep(ctx, i, "completed");
|
|
1053
|
-
// Post-step summary: files changed + duration.
|
|
1054
|
-
|
|
1055
|
-
|
|
1742
|
+
// Post-step summary: files changed + duration. All but the last
|
|
1743
|
+
// step get a fresh full build that doubles as the next step's
|
|
1744
|
+
// pre-context; the last step only needs the count (one spawn).
|
|
1745
|
+
let fileCount: number;
|
|
1746
|
+
if (i === steps.length - 1) {
|
|
1747
|
+
fileCount = await changedFileCount(ctx.cwd);
|
|
1748
|
+
} else {
|
|
1749
|
+
context = await getContext(ctx.cwd);
|
|
1750
|
+
fileCount = context.files.length;
|
|
1751
|
+
}
|
|
1056
1752
|
const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
|
|
1057
1753
|
ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
|
|
1058
1754
|
continue;
|
|
@@ -1109,9 +1805,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1109
1805
|
context: TaskContext,
|
|
1110
1806
|
cache: ReturnType<typeof createContextCache>,
|
|
1111
1807
|
): Promise<void> {
|
|
1112
|
-
// Filter by the `when` condition once per session,
|
|
1113
|
-
// appear, are never numbered, and can't be picked
|
|
1114
|
-
|
|
1808
|
+
// Filter by the `hidden` flag and the `when` condition once per session,
|
|
1809
|
+
// so hidden tasks never appear, are never numbered, and can't be picked
|
|
1810
|
+
// (same predicate as refreshVisible, which numbers `/do-always <n>`).
|
|
1811
|
+
const visibleTasks = tasks.filter((t) => isTaskVisible(t, context));
|
|
1115
1812
|
// String view for prompt rendering (derived once, used by the preview).
|
|
1116
1813
|
const strings = toPromptContext(context);
|
|
1117
1814
|
const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
|
|
@@ -1608,6 +2305,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1608
2305
|
return;
|
|
1609
2306
|
}
|
|
1610
2307
|
// ORDER column: toggle this task's chain membership.
|
|
2308
|
+
// Browser tasks can't be chained: the chain runner sends
|
|
2309
|
+
// prompts directly, so the browser (and
|
|
2310
|
+
// {{selected_commits}}) never run — run them on their own.
|
|
2311
|
+
if (row.task.browser) {
|
|
2312
|
+
ctx.ui.notify(`do-always: "${row.task.name}" opens a browser — it can't be chained, run it on its own`, "info");
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
1611
2315
|
if (chain.items.includes(row.task.name)) {
|
|
1612
2316
|
chain = chainRemove(chain, row.task.name);
|
|
1613
2317
|
} else {
|
|
@@ -1670,16 +2374,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1670
2374
|
const { itemRows } = visible;
|
|
1671
2375
|
const idx = itemRows.findIndex((r) => r.task === task);
|
|
1672
2376
|
if (idx < 0) return undefined;
|
|
1673
|
-
|
|
1674
|
-
|
|
1675
|
-
|
|
1676
|
-
|
|
1677
|
-
|
|
1678
|
-
|
|
1679
|
-
|
|
2377
|
+
// ORDER cell: press toggles chain membership.
|
|
2378
|
+
if (orderColX !== null && event.x >= orderColX) {
|
|
2379
|
+
if (event.type === "press") {
|
|
2380
|
+
if (task.browser) {
|
|
2381
|
+
// Same rule as the keyboard toggle: browser tasks
|
|
2382
|
+
// can't be chained (see ORDER column).
|
|
2383
|
+
ctx.ui.notify(`do-always: "${task.name}" opens a browser — it can't be chained, run it on its own`, "info");
|
|
2384
|
+
return { handled: true };
|
|
2385
|
+
}
|
|
2386
|
+
chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
|
|
2387
|
+
clampCursor(visible);
|
|
2388
|
+
resetPreview();
|
|
2389
|
+
return { handled: true, render: true };
|
|
2390
|
+
}
|
|
2391
|
+
return { handled: true }; // swallow the click after the press action
|
|
1680
2392
|
}
|
|
1681
|
-
return { handled: true }; // swallow the click after the press action
|
|
1682
|
-
}
|
|
1683
2393
|
// Task area: press selects, click runs (the classic fast path).
|
|
1684
2394
|
if (event.type === "press") {
|
|
1685
2395
|
mousePressedIndex = idx;
|
|
@@ -37,6 +37,21 @@ export interface DoAlwaysTask {
|
|
|
37
37
|
* Omitted/undefined always shows the task. Evaluated by `evaluateWhen`.
|
|
38
38
|
*/
|
|
39
39
|
when?: string | Record<string, unknown>;
|
|
40
|
+
/**
|
|
41
|
+
* Hide the task from the selector and the `list` commands. Hidden tasks
|
|
42
|
+
* are not standalone user-visible entries; they can still be resolved by
|
|
43
|
+
* name on the command line and are offered as candidates by the commit
|
|
44
|
+
* picker. The built-in "Review commits" uses this: it is a pick-after-
|
|
45
|
+
* browse option, not an entry point ("Browse commits" is the entry).
|
|
46
|
+
*/
|
|
47
|
+
hidden?: boolean;
|
|
48
|
+
/**
|
|
49
|
+
* Exclude the task from the commit picker (the "run on the selected
|
|
50
|
+
* commits" list shown after a commit selection). For Plan tasks that
|
|
51
|
+
* operate on the working tree or the whole project rather than on a set
|
|
52
|
+
* of commits. The task is otherwise unaffected (selector, lists, CLI).
|
|
53
|
+
*/
|
|
54
|
+
notForCommits?: boolean;
|
|
40
55
|
/**
|
|
41
56
|
* Extra selection-time guards, evaluated alongside the legacy `requireDirty`
|
|
42
57
|
* (see `evaluateGuards`). Each guard blocks the task (with a message, not a
|
|
@@ -44,6 +59,14 @@ export interface DoAlwaysTask {
|
|
|
44
59
|
* compatibility; new guards use this array so the set is extensible.
|
|
45
60
|
*/
|
|
46
61
|
guards?: Guard[];
|
|
62
|
+
/**
|
|
63
|
+
* A browser to open on selection instead of injecting the prompt directly.
|
|
64
|
+
* Currently only "commits": opens the date-grouped commit browser, and the
|
|
65
|
+
* selected commits drive the review prompt (see `fillPrompt` in index.ts).
|
|
66
|
+
* The built-in "Review commits" task also triggers the browser by name, so
|
|
67
|
+
* configs that predate this field keep working.
|
|
68
|
+
*/
|
|
69
|
+
browser?: BrowserType;
|
|
47
70
|
}
|
|
48
71
|
|
|
49
72
|
/** The set of known guard types (used for validation at parse time). */
|
|
@@ -66,6 +89,13 @@ export interface Guard {
|
|
|
66
89
|
value?: string;
|
|
67
90
|
}
|
|
68
91
|
|
|
92
|
+
/** The set of known browser types (used for validation at parse time). */
|
|
93
|
+
export const BROWSER_TYPES = [
|
|
94
|
+
"commits",
|
|
95
|
+
] as const;
|
|
96
|
+
|
|
97
|
+
export type BrowserType = (typeof BROWSER_TYPES)[number];
|
|
98
|
+
|
|
69
99
|
/**
|
|
70
100
|
* A config file can be a bare array of tasks, or {"tasks": [...], "shortcut": ...}.
|
|
71
101
|
* `shortcut` is a key id string (e.g. "f4", "ctrl+shift+p"), or null to disable
|
|
@@ -144,6 +174,12 @@ export interface TaskContext {
|
|
|
144
174
|
stagedFiles: string[];
|
|
145
175
|
/** Modified-but-unstaged files. */
|
|
146
176
|
unstagedFiles: string[];
|
|
177
|
+
/**
|
|
178
|
+
* Commits selected in the commit browser, formatted for prompt injection
|
|
179
|
+
* (see `formatSelectedCommits`), or "none" when no selection is active.
|
|
180
|
+
* Context builds always set "none"; the browser flow overrides it at render.
|
|
181
|
+
*/
|
|
182
|
+
selectedCommits: string;
|
|
147
183
|
/** True when cwd is inside a git working tree (authoritative, not inferred from the branch name). */
|
|
148
184
|
isGitRepo: boolean;
|
|
149
185
|
}
|
|
@@ -166,6 +202,7 @@ export const PROMPT_CONTEXT_KEYS = [
|
|
|
166
202
|
"repo",
|
|
167
203
|
"staged_files",
|
|
168
204
|
"unstaged_files",
|
|
205
|
+
"selected_commits",
|
|
169
206
|
] as const;
|
|
170
207
|
|
|
171
208
|
/** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
|
|
@@ -193,6 +230,7 @@ export function toPromptContext(ctx: TaskContext): PromptContext {
|
|
|
193
230
|
repo: ctx.repo,
|
|
194
231
|
staged_files: formatFileLines(ctx.stagedFiles),
|
|
195
232
|
unstaged_files: formatFileLines(ctx.unstagedFiles),
|
|
233
|
+
selected_commits: ctx.selectedCommits,
|
|
196
234
|
};
|
|
197
235
|
}
|
|
198
236
|
|
|
@@ -315,6 +353,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
315
353
|
category: "Plan",
|
|
316
354
|
description: "Review the current code changes (Plan)",
|
|
317
355
|
requireDirty: true,
|
|
356
|
+
notForCommits: true,
|
|
318
357
|
prompt:
|
|
319
358
|
"Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). " +
|
|
320
359
|
"Change summary: {{diff_stat}}. Last commit: {{last_commit}}. " +
|
|
@@ -326,6 +365,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
326
365
|
name: "Review code",
|
|
327
366
|
category: "Plan",
|
|
328
367
|
description: "Review the whole project's code quality (Plan)",
|
|
368
|
+
notForCommits: true,
|
|
329
369
|
prompt:
|
|
330
370
|
"Review this project's code holistically: identify code smells, dead code, duplication, awkward architecture or patterns, maintainability issues, inconsistencies, and missing or unclear documentation. " +
|
|
331
371
|
"Prioritize by impact, propose a plan for the fixes, and summarize your findings. Do not make any changes yet.",
|
|
@@ -393,9 +433,29 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
393
433
|
name: "Propose features",
|
|
394
434
|
category: "Plan",
|
|
395
435
|
description: "Propose new features (Plan)",
|
|
436
|
+
notForCommits: true,
|
|
396
437
|
prompt:
|
|
397
438
|
"Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet. Try to evaluate how many lines this will be in terms of changes, whether this will break APIs, or introduce compatibility issues.",
|
|
398
439
|
},
|
|
440
|
+
{
|
|
441
|
+
name: "Review commits",
|
|
442
|
+
category: "Plan",
|
|
443
|
+
description: "Review the selected commits (Plan)",
|
|
444
|
+
browser: "commits",
|
|
445
|
+
hidden: true,
|
|
446
|
+
prompt:
|
|
447
|
+
"Review the selected commits: {{selected_commits}}. Inspect each with `git show <hash>`, " +
|
|
448
|
+
"double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. " +
|
|
449
|
+
"Summarize your findings per commit and propose a plan for any fixes if needed. Do not make any changes yet.",
|
|
450
|
+
},
|
|
451
|
+
{
|
|
452
|
+
name: "Browse commits",
|
|
453
|
+
category: "Browse",
|
|
454
|
+
description: "Browse and select commits, then pick a task to run on them",
|
|
455
|
+
browser: "commits",
|
|
456
|
+
when: "git",
|
|
457
|
+
prompt: "Browse recent commits, select some, then pick a task to run on them.",
|
|
458
|
+
},
|
|
399
459
|
];
|
|
400
460
|
|
|
401
461
|
/**
|
|
@@ -427,7 +487,7 @@ export function parseConfig(
|
|
|
427
487
|
const tasks: DoAlwaysTask[] = [];
|
|
428
488
|
for (const entry of list) {
|
|
429
489
|
const t = entry as Partial<DoAlwaysTask> | null;
|
|
430
|
-
if (t && typeof t.name === "string" && t.name.length > 0 && typeof t.prompt === "string" && t.prompt.length > 0) {
|
|
490
|
+
if (t && typeof t.name === "string" && t.name.length > 0 && typeof t.prompt === "string" && (t.prompt.length > 0 || t.autoRun === true)) {
|
|
431
491
|
const task: DoAlwaysTask = {
|
|
432
492
|
name: t.name,
|
|
433
493
|
prompt: t.prompt,
|
|
@@ -436,6 +496,15 @@ export function parseConfig(
|
|
|
436
496
|
if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
|
|
437
497
|
if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
|
|
438
498
|
if (typeof t.requireDirty === "boolean") task.requireDirty = t.requireDirty;
|
|
499
|
+
if (typeof t.hidden === "boolean") task.hidden = t.hidden;
|
|
500
|
+
if (typeof t.notForCommits === "boolean") task.notForCommits = t.notForCommits;
|
|
501
|
+
if (t.browser !== undefined) {
|
|
502
|
+
if (typeof t.browser === "string" && BROWSER_TYPES.includes(t.browser as BrowserType)) {
|
|
503
|
+
task.browser = t.browser as BrowserType;
|
|
504
|
+
} else {
|
|
505
|
+
onError(`do-always: ignoring invalid "browser" in ${path} (expected one of: ${BROWSER_TYPES.join(", ")})`);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
439
508
|
if (t.guards !== undefined) {
|
|
440
509
|
if (Array.isArray(t.guards)) {
|
|
441
510
|
const guards: Guard[] = [];
|
|
@@ -665,8 +734,17 @@ export function evaluateWhen(task: DoAlwaysTask, ctx: TaskContext): boolean {
|
|
|
665
734
|
return true;
|
|
666
735
|
}
|
|
667
736
|
|
|
737
|
+
/**
|
|
738
|
+
* Whether a task is visible in the selector and the lists: not `hidden` and
|
|
739
|
+
* its `when` condition passes. Single source of truth so the TUI selector and
|
|
740
|
+
* the `/do-always <n>` numbering can never diverge.
|
|
741
|
+
*/
|
|
742
|
+
export function isTaskVisible(task: DoAlwaysTask, ctx: TaskContext): boolean {
|
|
743
|
+
return !task.hidden && evaluateWhen(task, ctx);
|
|
744
|
+
}
|
|
745
|
+
|
|
668
746
|
/** Default order for category headers in the selector. Exported for tests. */
|
|
669
|
-
export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
|
|
747
|
+
export const DEFAULT_CATEGORY_ORDER = ["Plan", "Browse", "Do", "Docs", "Ops", "Other"];
|
|
670
748
|
|
|
671
749
|
/** A category group: a display name and the tasks that belong to it. */
|
|
672
750
|
interface TaskGroup {
|
|
@@ -1263,3 +1341,153 @@ function outcomeToMarker(outcome: ChainStepOutcome): string {
|
|
|
1263
1341
|
throw new Error(`unexpected outcome: ${outcome as string}`);
|
|
1264
1342
|
}
|
|
1265
1343
|
}
|
|
1344
|
+
|
|
1345
|
+
// ── Commit browser types ─────────────────────────────────────────────────
|
|
1346
|
+
//
|
|
1347
|
+
// Types and constants for the date-grouped commit browser (used by
|
|
1348
|
+
// the "Review commits" task in index.ts). Pure data — no Pi dependencies.
|
|
1349
|
+
|
|
1350
|
+
/** Maximum commits shown per page in the date-grouped browser. */
|
|
1351
|
+
export const COMMIT_BROWSER_MAX = 20;
|
|
1352
|
+
|
|
1353
|
+
/** Maximum commits that can be selected for a review chain. */
|
|
1354
|
+
export const COMMIT_SELECT_MAX = 20;
|
|
1355
|
+
|
|
1356
|
+
/** A single commit as returned by `git log --format`. */
|
|
1357
|
+
export interface CommitInfo {
|
|
1358
|
+
/** Full 40-char SHA. */
|
|
1359
|
+
hash: string;
|
|
1360
|
+
/** Short 7-char SHA. */
|
|
1361
|
+
shortHash: string;
|
|
1362
|
+
/** Commit subject line. */
|
|
1363
|
+
subject: string;
|
|
1364
|
+
/** Commit date (YYYY-MM-DD). */
|
|
1365
|
+
date: string;
|
|
1366
|
+
/** Commit author name. */
|
|
1367
|
+
author: string;
|
|
1368
|
+
/** Number of files changed in this commit. */
|
|
1369
|
+
filesChanged: number;
|
|
1370
|
+
/** Insertions in this commit. */
|
|
1371
|
+
insertions: number;
|
|
1372
|
+
/** Deletions in this commit. */
|
|
1373
|
+
deletions: number;
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
/** A commit selected by the user for review. */
|
|
1377
|
+
export interface SelectedCommit extends CommitInfo {
|
|
1378
|
+
/** 1-based position in the user's selection order. */
|
|
1379
|
+
selectionOrder: number;
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
/** Group of commits sharing the same date. */
|
|
1383
|
+
export interface DateGroup {
|
|
1384
|
+
/** Date string (YYYY-MM-DD). */
|
|
1385
|
+
date: string;
|
|
1386
|
+
/** Number of commits in this group. */
|
|
1387
|
+
count: number;
|
|
1388
|
+
/** Commits in this group (newest first). */
|
|
1389
|
+
commits: CommitInfo[];
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
/**
|
|
1393
|
+
* Parse git log output formatted with:
|
|
1394
|
+
* `COMMIT%x09%H%x09%h%x09%s%x09%ad%x09%an` and optional `--shortstat`.
|
|
1395
|
+
*/
|
|
1396
|
+
export function parseGitLogOutput(output: string): CommitInfo[] {
|
|
1397
|
+
if (!output) return [];
|
|
1398
|
+
const commits: CommitInfo[] = [];
|
|
1399
|
+
let current: CommitInfo | null = null;
|
|
1400
|
+
for (const rawLine of output.split("\n")) {
|
|
1401
|
+
const line = rawLine.trim();
|
|
1402
|
+
if (line.startsWith("COMMIT\t")) {
|
|
1403
|
+
if (current) commits.push(current);
|
|
1404
|
+
const parts = line.split("\t");
|
|
1405
|
+
if (parts.length >= 6) {
|
|
1406
|
+
const hash = parts[1];
|
|
1407
|
+
const shortHash = parts[2];
|
|
1408
|
+
const date = parts[parts.length - 2];
|
|
1409
|
+
const author = parts[parts.length - 1];
|
|
1410
|
+
const subject = parts.slice(3, parts.length - 2).join("\t") || "(no subject)";
|
|
1411
|
+
current = {
|
|
1412
|
+
hash,
|
|
1413
|
+
shortHash,
|
|
1414
|
+
subject,
|
|
1415
|
+
date,
|
|
1416
|
+
author,
|
|
1417
|
+
filesChanged: 0,
|
|
1418
|
+
insertions: 0,
|
|
1419
|
+
deletions: 0,
|
|
1420
|
+
};
|
|
1421
|
+
} else {
|
|
1422
|
+
current = null;
|
|
1423
|
+
}
|
|
1424
|
+
continue;
|
|
1425
|
+
}
|
|
1426
|
+
if (current && line.includes("changed")) {
|
|
1427
|
+
const mFiles = line.match(/(\d+) file/);
|
|
1428
|
+
const mIns = line.match(/(\d+) insertion/);
|
|
1429
|
+
const mDel = line.match(/(\d+) deletion/);
|
|
1430
|
+
if (mFiles) current.filesChanged = Number(mFiles[1]);
|
|
1431
|
+
if (mIns) current.insertions = Number(mIns[1]);
|
|
1432
|
+
if (mDel) current.deletions = Number(mDel[1]);
|
|
1433
|
+
}
|
|
1434
|
+
}
|
|
1435
|
+
if (current) commits.push(current);
|
|
1436
|
+
return commits;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
/** Group commits by date (preserving existing order within and between groups). */
|
|
1440
|
+
export function groupCommitsByDate(commits: CommitInfo[]): DateGroup[] {
|
|
1441
|
+
const groups = new Map<string, CommitInfo[]>();
|
|
1442
|
+
for (const c of commits) {
|
|
1443
|
+
if (!groups.has(c.date)) groups.set(c.date, []);
|
|
1444
|
+
groups.get(c.date)!.push(c);
|
|
1445
|
+
}
|
|
1446
|
+
return [...groups.entries()].map(([date, groupCommits]) => ({
|
|
1447
|
+
date,
|
|
1448
|
+
count: groupCommits.length,
|
|
1449
|
+
commits: groupCommits,
|
|
1450
|
+
}));
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
/**
|
|
1454
|
+
* Format the selected commits as a numbered detail block (hash, subject,
|
|
1455
|
+
* author, date, stats per commit). Injected into task prompts via the
|
|
1456
|
+
* `{{selected_commits}}` placeholder; composed into the full review prompt by
|
|
1457
|
+
* `formatCommitReviewPrompt`.
|
|
1458
|
+
*/
|
|
1459
|
+
export function formatSelectedCommits(commits: SelectedCommit[]): string {
|
|
1460
|
+
const count = commits.length;
|
|
1461
|
+
return commits
|
|
1462
|
+
.map((c, i) => {
|
|
1463
|
+
const num = count > 1 ? `${i + 1}. ` : "";
|
|
1464
|
+
return (
|
|
1465
|
+
`${num}Commit ${c.shortHash} (${c.hash})\n` +
|
|
1466
|
+
` Subject: ${c.subject}\n` +
|
|
1467
|
+
` Author: ${c.author} on ${c.date}\n` +
|
|
1468
|
+
` Stats: ${c.filesChanged} file${c.filesChanged !== 1 ? "s" : ""} changed, +${c.insertions}/-${c.deletions} lines`
|
|
1469
|
+
);
|
|
1470
|
+
})
|
|
1471
|
+
.join("\n\n");
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
/**
|
|
1475
|
+
* Build the review prompt for the selected commits to send to the agent.
|
|
1476
|
+
*/
|
|
1477
|
+
export function formatCommitReviewPrompt(commits: SelectedCommit[]): string {
|
|
1478
|
+
const count = commits.length;
|
|
1479
|
+
const commitDetails = formatSelectedCommits(commits);
|
|
1480
|
+
|
|
1481
|
+
const showCmd = count === 1 ? `git show ${commits[0].hash}` : "git show <hash>";
|
|
1482
|
+
|
|
1483
|
+
return (
|
|
1484
|
+
`Review the following git commit${count !== 1 ? "s" : ""}:\n\n` +
|
|
1485
|
+
`${commitDetails}\n\n` +
|
|
1486
|
+
`Please inspect the changes using \`${showCmd}\` (and any related files in the repository). ` +
|
|
1487
|
+
`Double-check the changes for:\n` +
|
|
1488
|
+
`1. Bugs, edge cases, potential regressions, and logic errors.\n` +
|
|
1489
|
+
`2. Code quality, security, and unintended side effects.\n` +
|
|
1490
|
+
`3. Consistency with surrounding project patterns and tests.\n\n` +
|
|
1491
|
+
`Summarize your findings for each commit and propose a plan for any fixes if needed. Do not make changes yet.`
|
|
1492
|
+
);
|
|
1493
|
+
}
|
package/package.json
CHANGED
package/pi.image.png
CHANGED
|
Binary file
|