pi-do-always 0.12.0 → 0.15.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 +20 -1
- package/extensions/pi-do-always/index.ts +739 -29
- package/extensions/pi-do-always/tasks.ts +231 -3
- package/package.json +1 -1
- package/pi.image.png +0 -0
|
@@ -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. */
|
|
@@ -781,15 +1353,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
781
1353
|
* Send a prompt and resolve when the run it starts has fully settled,
|
|
782
1354
|
* reporting the run's outcome (see armWaiter).
|
|
783
1355
|
*/
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
1356
|
+
function sendAndWait(prompt: string, graceMs = 10_000, stepIndex = 0): Promise<ChainStepOutcome> {
|
|
1357
|
+
const done = armWaiter(graceMs, stepIndex);
|
|
1358
|
+
pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
1359
|
+
return done;
|
|
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, { deliverAs: 'followUp' });
|
|
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");
|
|
@@ -899,7 +1571,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
899
1571
|
pendingSummaryTask = null;
|
|
900
1572
|
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
901
1573
|
}, 10_000);
|
|
902
|
-
pi.sendUserMessage(prompt);
|
|
1574
|
+
pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
903
1575
|
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
904
1576
|
return;
|
|
905
1577
|
}
|
|
@@ -907,7 +1579,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
907
1579
|
ctx.ui.setEditorText(prompt);
|
|
908
1580
|
ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
|
|
909
1581
|
} else {
|
|
910
|
-
pi.sendUserMessage(prompt);
|
|
1582
|
+
pi.sendUserMessage(prompt, { deliverAs: 'followUp' });
|
|
911
1583
|
}
|
|
912
1584
|
}
|
|
913
1585
|
|
|
@@ -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;
|