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.
@@ -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
- function sendAndWait(prompt: string, graceMs = 10_000, stepIndex = 0): Promise<ChainStepOutcome> {
785
- const done = armWaiter(graceMs, stepIndex);
786
- pi.sendUserMessage(prompt);
787
- return done;
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 their `when` condition and refresh the completion cache. */
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) => evaluateWhen(t, context));
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
- const postContext = await getContext(ctx.cwd);
1012
- const fileCount = postContext.files.length;
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
- const postContext = await getContext(ctx.cwd);
1055
- const fileCount = postContext.files.length;
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, so hidden tasks never
1113
- // appear, are never numbered, and can't be picked.
1114
- const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
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
- // ORDER cell: press toggles chain membership.
1674
- if (orderColX !== null && event.x >= orderColX) {
1675
- if (event.type === "press") {
1676
- chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
1677
- clampCursor(visible);
1678
- resetPreview();
1679
- return { handled: true, render: true };
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;