pi-do-always 0.9.0 → 0.12.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.
@@ -33,43 +33,95 @@
33
33
  * If no config file exists, built-in default tasks are used.
34
34
  */
35
35
 
36
- import { execFileSync } from "node:child_process";
37
- import { existsSync, readFileSync } from "node:fs";
38
- import { join } from "node:path";
39
- import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
36
+ import { execFile } from "node:child_process";
37
+ import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
38
+ import { join, relative } from "node:path";
39
+ import type { AgentEndEvent, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
40
40
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
41
41
  import {
42
42
  type KeyId,
43
43
  type TuiMouseEvent,
44
44
  type TuiMouseEventResult,
45
+ Container,
46
+ Text,
45
47
  getKeybindings,
48
+ matchesKey,
46
49
  truncateToWidth,
47
- visibleWidth,
48
50
  wrapTextWithAnsi,
49
51
  } from "@earendil-works/pi-tui";
50
52
  import {
53
+ CHAIN_MAX,
51
54
  DEFAULT_SHORTCUT,
52
55
  DEFAULT_TASKS,
56
+ assistantText,
57
+ buildTableRows,
58
+ chainAdd,
59
+ chainClear,
60
+ chainRemove,
61
+ chainRunLabel,
62
+ chainSummary,
63
+ chainUndo,
53
64
  evaluateGuards,
54
65
  evaluateWhen,
66
+ formatChainSequence,
55
67
  formatList,
56
68
  groupTasksByCategory,
57
69
  isValidKeyId,
58
70
  mergeTasks,
59
71
  parseConfig,
72
+ parseConfigRegexpValueForKey,
73
+ parseCommitSubject,
60
74
  parseStatusPorcelain,
75
+ parseStatusStagedUnstaged,
61
76
  orderTasksByCategory,
77
+ reportAbandonedFooter,
78
+ reportFooter,
79
+ reportHeader,
80
+ reportStepSection,
81
+ reportWorthKeeping,
62
82
  renderPrompt,
83
+ resolveReportPath,
63
84
  resolveShortcut,
64
85
  resolveTask,
65
86
  shouldAutoRun,
66
- splitFileLines,
87
+ stepSummary,
67
88
  toPromptContext,
89
+ validateChain,
90
+ type ChainStepOutcome,
68
91
  type DoAlwaysTask,
69
92
  type TaskContext,
70
- type TaskGroup,
93
+ type TableRow,
71
94
  } from "./tasks";
72
95
 
96
+ /**
97
+ * What the selector resolved to: a single task (the classic pick), a chain to
98
+ * run, or a cancel.
99
+ */
100
+ type SelectorResult =
101
+ | { kind: "single"; task: DoAlwaysTask }
102
+ | { kind: "chain"; names: string[] }
103
+ | { kind: "cancel" };
104
+
105
+ /**
106
+ * Selector cursor: a cell in the task table (TASK or ORDER column) or the
107
+ * pinned Run row.
108
+ */
109
+ type Cursor = { kind: "cell"; row: number; col: "task" | "order" } | { kind: "run" };
110
+
111
+ /**
112
+ * Safely read a config file's contents, returning null if missing or unreadable.
113
+ * Read errors are reported through `onError`.
114
+ */
115
+ function readConfigFile(filePath: string, onError: (message: string) => void): string | null {
116
+ if (!existsSync(filePath)) return null;
117
+ try {
118
+ return readFileSync(filePath, "utf-8");
119
+ } catch (err) {
120
+ onError(`do-always: could not read ${filePath}: ${err}`);
121
+ return null;
122
+ }
123
+ }
124
+
73
125
  /**
74
126
  * Load tasks and the selector shortcut from config files.
75
127
  * Project-local tasks override global tasks with the same name (or are
@@ -86,16 +138,21 @@ function loadConfig(
86
138
  ): {
87
139
  tasks: DoAlwaysTask[];
88
140
  shortcut: string | null;
141
+ /** Whether chain runs write a Markdown report file (default true). */
142
+ report: boolean;
89
143
  } {
90
144
  const globalPath = join(getAgentDir(), "do-always.json");
91
145
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
92
146
 
93
- const global = existsSync(globalPath)
94
- ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath, onError)
95
- : { tasks: [], shortcut: undefined, merge: undefined };
96
- const project = existsSync(projectPath)
97
- ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath, onError)
98
- : { tasks: [], shortcut: undefined, merge: undefined };
147
+ const globalRaw = readConfigFile(globalPath, onError);
148
+ const projectRaw = readConfigFile(projectPath, onError);
149
+
150
+ const global = globalRaw !== null
151
+ ? parseConfig(globalRaw, globalPath, onError)
152
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
153
+ const project = projectRaw !== null
154
+ ? parseConfig(projectRaw, projectPath, onError)
155
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
99
156
 
100
157
  // The project file's merge mode wins; otherwise the global value; otherwise
101
158
  // override (the historical behavior), so existing configs are unaffected.
@@ -106,6 +163,9 @@ function loadConfig(
106
163
  // `/do-always <n>`, and `list` all share one consistent order.
107
164
  tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
108
165
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
166
+ // The project file's value wins; otherwise the global value; otherwise
167
+ // reports are on.
168
+ report: project.report ?? global.report ?? true,
109
169
  };
110
170
  }
111
171
 
@@ -115,61 +175,109 @@ function loadConfig(
115
175
  * empty repo, …) so callers can fall back to a neutral value.
116
176
  * No shell is involved (argument array), so file names cannot inject commands.
117
177
  */
118
- function git(cwd: string, args: string[]): string | undefined {
119
- try {
120
- const out = execFileSync("git", args, {
121
- cwd,
122
- encoding: "utf-8",
123
- stdio: ["ignore", "pipe", "ignore"],
124
- });
125
- const trimmed = out.trim();
126
- return trimmed === "" ? undefined : trimmed;
127
- } catch {
128
- return undefined;
129
- }
178
+ function git(cwd: string, args: string[]): Promise<string | undefined> {
179
+ return new Promise((resolve) => {
180
+ // No stdio option: execFile's string-encoding overload doesn't accept
181
+ // it, and stdin/stderr need no special handling here (no input is
182
+ // written; stderr is simply ignored by the callback).
183
+ execFile(
184
+ "git",
185
+ args,
186
+ { cwd, encoding: "utf-8" },
187
+ (error, stdout) => {
188
+ if (error) {
189
+ resolve(undefined);
190
+ return;
191
+ }
192
+ const trimmed = stdout.trim();
193
+ resolve(trimmed === "" ? undefined : trimmed);
194
+ },
195
+ );
196
+ });
130
197
  }
131
198
 
132
199
  /**
133
200
  * Build the structured context for the current directory. Git facts fall back
134
201
  * to neutral values when unavailable (non-git dir, no git, empty repo) so
135
- * default prompts read cleanly in any directory. The string view for
136
- * `renderPrompt` is derived with `toPromptContext`.
202
+ * default prompts read cleanly in any directory.
203
+ *
204
+ * Collects the git facts the prompts interpolate:
205
+ * 1. rev-parse --is-inside-work-tree → isGitRepo
206
+ * 2. branch --show-current → branch (fallback: rev-parse --abbrev-ref HEAD)
207
+ * 3. log -1 --format="%H %s" → commit hash + subject
208
+ * 4. config --get-regexp "^(user.name|remote.origin.url)$" → user + repo
209
+ * 5. status --porcelain → files + stagedFiles + unstagedFiles
210
+ * 6. diff --shortstat → diffStat
211
+ *
212
+ * Outside a work tree only call 1 runs; inside one, calls 2–6 run in
213
+ * parallel, so wall time is about one spawn.
137
214
  */
138
- function buildContext(cwd: string): TaskContext {
139
- const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "unknown";
140
- const lastCommit = git(cwd, ["log", "-1", "--format=%s"]) ?? "unknown";
141
- const user = git(cwd, ["config", "user.name"]) ?? "unknown";
142
- // Authoritative working-tree check — the branch sentinel is not (a branch
143
- // could literally be named "unknown", and detached HEAD reports "HEAD").
144
- const isGitRepo = git(cwd, ["rev-parse", "--is-inside-work-tree"]) === "true";
145
-
146
- // repo = bare name of the git remote (owner/repo.git -> repo), falling back
147
- // to the basename of cwd so monorepo work stays disambiguated everywhere.
148
- const remoteUrl = git(cwd, ["config", "--get", "remote.origin.url"]);
215
+ async function buildContext(cwd: string): Promise<TaskContext> {
216
+ // 1. Authoritative check: are we inside a work tree?
217
+ // rev-parse --is-inside-work-tree outputs "true" when inside a work tree,
218
+ // and fails (exit != 0) when outside.
219
+ const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
220
+ const isGitRepo = inside === "true";
221
+
222
+ // Outside a work tree the remaining facts don't exist; skip the spawns
223
+ // and return the neutral fallbacks directly.
224
+ if (!isGitRepo) {
225
+ return {
226
+ cwd,
227
+ date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
228
+ branch: "unknown",
229
+ lastCommit: "unknown",
230
+ files: [],
231
+ user: "unknown",
232
+ diffStat: "none",
233
+ repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
234
+ stagedFiles: [],
235
+ unstagedFiles: [],
236
+ isGitRepo: false,
237
+ };
238
+ }
239
+
240
+ // 2–6. In a work tree: query branch and the remaining facts in parallel.
241
+ const [branchOut, lastCommitLine, configLine, porcelain, diffStat] = await Promise.all([
242
+ // branch --show-current works on both normal and unborn (empty repo) branches;
243
+ // returns empty on detached HEAD.
244
+ git(cwd, ["branch", "--show-current"]),
245
+ // "%H %s" prints "<hash> <subject>"; the subject may contain spaces.
246
+ git(cwd, ["log", "-1", "--format=%H %s"]),
247
+ // --get-regexp prints "key value" lines; one call covers both keys.
248
+ git(cwd, ["config", "--get-regexp", "^(user\\.name|remote\\.origin\\.url)$"]),
249
+ git(cwd, ["status", "--porcelain"]),
250
+ git(cwd, ["diff", "--shortstat"]),
251
+ ]);
252
+
253
+ const branch = branchOut || (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])) || "unknown";
254
+ const lastCommit = parseCommitSubject(lastCommitLine);
255
+ const user = parseConfigRegexpValueForKey(configLine, "user.name");
256
+ const remoteUrl = parseConfigRegexpValueForKey(configLine, "remote.origin.url");
149
257
  const repo = remoteUrl
150
258
  ? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
151
259
  : cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
152
260
 
261
+ // status --porcelain gives staged + unstaged in one call.
262
+ // Porcelain v1 lines are "XY <path>" (path starts at index 3); a space
263
+ // in the X column means unstaged, anything else is staged/untracked.
264
+ const { staged: stagedFiles, unstaged: unstagedFiles } = parseStatusStagedUnstaged(porcelain ?? "");
265
+
153
266
  return {
154
267
  cwd,
155
268
  date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
156
269
  branch,
157
270
  lastCommit,
158
- files: parseStatusPorcelain(git(cwd, ["status", "--porcelain"]) ?? ""),
159
- user,
160
- diffStat: git(cwd, ["diff", "--shortstat"]) ?? "none",
271
+ files: parseStatusPorcelain(porcelain ?? ""),
272
+ user: user ?? "unknown",
273
+ diffStat: diffStat ?? "none",
161
274
  repo,
162
- stagedFiles: splitFileLines(git(cwd, ["diff", "--cached", "--name-only"])),
163
- unstagedFiles: splitFileLines(git(cwd, ["diff", "--name-only"])),
275
+ stagedFiles,
276
+ unstagedFiles,
164
277
  isGitRepo,
165
278
  };
166
279
  }
167
280
 
168
- /** Find the group (among `groups`) that contains a task. */
169
- function findGroupOf(task: DoAlwaysTask, groups: TaskGroup[]): TaskGroup | undefined {
170
- return groups.find((g) => g.items.includes(task));
171
- }
172
-
173
281
  /** True for a single printable ASCII character (used for filter typing). */
174
282
  function isPrintable(data: string): boolean {
175
283
  return data.length === 1 && data >= " " && data <= "~";
@@ -180,6 +288,28 @@ const PREVIEW_DELAY_MS = 2000;
180
288
  /** Max lines of the prompt shown in the selector preview. */
181
289
  const PREVIEW_MAX_LINES = 3;
182
290
 
291
+ /** Outcome of one chain step's run (see sendAndWait). */
292
+ /**
293
+ * Status of one chain step for the below-prompt status widget: pending
294
+ * (not reached yet), running (its turn is in flight), waiting (fill-first:
295
+ * step 1 is in the editor, waiting for the user's Enter), completed, or one
296
+ * of the stop outcomes (failed-to-start/aborted/error/skipped-by-guards).
297
+ */
298
+ type ChainStepStatus =
299
+ | "pending"
300
+ | "running"
301
+ | "waiting"
302
+ | "completed"
303
+ | "failed-to-start"
304
+ | "aborted"
305
+ | "error"
306
+ | "skipped";
307
+
308
+ interface ChainStepView {
309
+ name: string;
310
+ status: ChainStepStatus;
311
+ }
312
+
183
313
  export default function doAlwaysExtension(pi: ExtensionAPI) {
184
314
  let tasks: DoAlwaysTask[] = [];
185
315
  let loadedCwd = ""; // cwd the cached `tasks` were loaded for
@@ -189,6 +319,474 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
189
319
  // the full list rather than guessing.
190
320
  let visibleCache: { cwd: string; visible: DoAlwaysTask[] } | null = null;
191
321
 
322
+ // Chain control: `pi.sendUserMessage` is fire-and-forget (returns void),
323
+ // so the chain runner sequences steps on session events:
324
+ // agent_start — the run actually began. A send that fails before the
325
+ // run starts (no API key, compaction collision) never
326
+ // emits agent events and its error is swallowed by the
327
+ // runtime; the grace timer in sendAndWait turns that
328
+ // into "failed-to-start".
329
+ // agent_end — carries the run's messages; the last assistant
330
+ // message's stopReason gives completed/aborted/error.
331
+ // agent_settled — the session is fully idle (the busy flag is cleared
332
+ // before this fires), so the next step can be sent
333
+ // safely; auto-retry, compaction, and queued
334
+ // continuations have all had their chance.
335
+ let chainWaiter: {
336
+ started: boolean;
337
+ outcome: "completed" | "aborted" | "error" | null;
338
+ timer: NodeJS.Timeout | null;
339
+ resolve: (outcome: ChainStepOutcome) => void;
340
+ /** Which chain step (0-based) this waiter belongs to — for the report. */
341
+ stepIndex: number;
342
+ startTime?: number;
343
+ } | null = null;
344
+
345
+ function settleChainWaiter(outcome: ChainStepOutcome) {
346
+ if (!chainWaiter) return;
347
+ const waiter = chainWaiter;
348
+ chainWaiter = null;
349
+ if (waiter.timer) clearTimeout(waiter.timer);
350
+ if (waiter.startTime !== undefined) {
351
+ chainDurations[waiter.stepIndex] = performance.now() - waiter.startTime;
352
+ }
353
+ waiter.resolve(outcome);
354
+ }
355
+
356
+ // Below-prompt status widget while a chain is running: the chain's tasks
357
+ // with per-step status and a (n/N) progress marker. Shown in TUI mode
358
+ // only; cleared when the chain completes, kept (as a trace) when it stops
359
+ // early, and reset on session start.
360
+ let chainStatus: { steps: ChainStepView[]; note?: string } | null = null;
361
+ const CHAIN_WIDGET_KEY = "do-always-chain";
362
+ // The most recent command context, so event handlers (which carry no
363
+ // context of their own) can still refresh the widget.
364
+ let lastCtx: ExtensionContext | null = null;
365
+ // True while a chain's runner is in flight (from start to its final
366
+ // outcome). A second chain started while one is running would interleave
367
+ // their event waiters (the old chain's sendAndWait would resolve on the
368
+ // new chain's step), so starting one is refused until the first ends.
369
+ let chainActive = false;
370
+ // Per-step durations (ms) for the chain-end summary. Indexed by step
371
+ // position; 0 means the step was not timed (e.g. blocked before running).
372
+ let chainDurations: number[] = [];
373
+ // For single auto-run tasks: the task name whose summary we post after
374
+ // agent_end. Only set when a Plan task (auto-run) is sent via
375
+ // pi.sendUserMessage (which does NOT arm a chainWaiter). The grace timer
376
+ // mirrors the chain's failed-to-start handling: a send that fails before
377
+ // the run starts emits no agent events at all, so without it the stale
378
+ // flag would post a spurious summary for the next unrelated turn.
379
+ let pendingSummaryTask: string | null = null;
380
+ let pendingSummaryTimer: NodeJS.Timeout | null = null;
381
+
382
+ /** Clear the auto-run summary flag and its grace timer (session start). */
383
+ function resetPendingSummary(): void {
384
+ pendingSummaryTask = null;
385
+ if (pendingSummaryTimer) {
386
+ clearTimeout(pendingSummaryTimer);
387
+ pendingSummaryTimer = null;
388
+ }
389
+ }
390
+ // Whether chain runs write a Markdown report file (config `report`,
391
+ // default true). Refreshed whenever the config is (re)loaded.
392
+ let reportEnabled = true;
393
+ // The in-flight chain's report file: its path (absolute + relative for
394
+ // display), the precomputed header (deferred — written together with the
395
+ // first step section, so a chain that dies before that leaves no
396
+ // header-only file behind), whether the file exists on disk yet, when the
397
+ // current step's run actually started (agent_start; null until then and
398
+ // for failed-to-start steps), whether the footer has been appended, the
399
+ // index of the last step section written (dedupes retried runs), and
400
+ // whether any step section carried result text (the file is worth
401
+ // keeping).
402
+ // Max in-memory sections kept for inline display; the file on disk retains
403
+ // all steps. Bounded to avoid holding 400KB–2MB of assistant text per chain.
404
+ const MAX_INLINE_SECTIONS = 3;
405
+
406
+ let chainReport: {
407
+ path: string;
408
+ display: string;
409
+ /** Precomputed header; written together with the first section. */
410
+ header: string;
411
+ /** True once the file exists on disk (header written). */
412
+ written: boolean;
413
+ stepStartedAt: Date | null;
414
+ /** True once the summary (or abandoned) footer has been appended. */
415
+ footerWritten: boolean;
416
+ /** Index of the last step whose section was appended (-1 = none). */
417
+ lastStepSection: number;
418
+ /** True once a step section with result text was appended. */
419
+ hasContent: boolean;
420
+ /**
421
+ * In-memory section data for inline display (populated in agent_end,
422
+ * bounded to the last MAX_INLINE_SECTIONS). `index` is the step's
423
+ * position in the chain (retries share it); `markdown` is the formatted section.
424
+ */
425
+ sections: Array<{ index: number; markdown: string }>;
426
+ } | null = null;
427
+
428
+ /** Status marker glyph (all one column wide) with its color. */
429
+ function stepMarker(status: ChainStepStatus, theme: Theme): string {
430
+ switch (status) {
431
+ case "completed":
432
+ return theme.fg("success", "✓");
433
+ case "running":
434
+ return theme.fg("accent", theme.bold("▶"));
435
+ case "waiting":
436
+ return theme.fg("warning", "▶");
437
+ case "aborted":
438
+ return theme.fg("warning", "⊘");
439
+ case "error":
440
+ case "failed-to-start":
441
+ return theme.fg("error", "✗");
442
+ case "skipped":
443
+ return theme.fg("muted", "–");
444
+ default:
445
+ return theme.fg("dim", "○");
446
+ }
447
+ }
448
+
449
+ /** Replace the chain status and refresh the widget. */
450
+ function showChainStatus(ctx: ExtensionContext, steps: ChainStepView[], note?: string): void {
451
+ chainStatus = { steps, note };
452
+ updateChainWidget(ctx);
453
+ }
454
+
455
+ /** Update one step's status (and optionally the note) and refresh. */
456
+ function setChainStep(ctx: ExtensionContext, index: number, status: ChainStepStatus, note?: string): void {
457
+ if (!chainStatus) return;
458
+ const s = chainStatus.steps[index];
459
+ if (s) s.status = status;
460
+ if (note !== undefined) chainStatus.note = note;
461
+ updateChainWidget(ctx);
462
+ }
463
+
464
+ /**
465
+ * Mark the chain as stopped at `index` with `status`, keeping the widget
466
+ * visible as a trace of where it stopped.
467
+ */
468
+ function markChainStopped(ctx: ExtensionContext, index: number, status: ChainStepStatus, detail?: string): void {
469
+ const name = chainStatus?.steps[index]?.name;
470
+ setChainStep(
471
+ ctx,
472
+ index,
473
+ status,
474
+ `stopped at step ${index + 1}${name ? ` (${name})` : ""}${detail ? `: ${detail}` : ""}`,
475
+ );
476
+ }
477
+
478
+ /**
479
+ * Finish the report file: append the summary footer. Called on every
480
+ * terminal path (complete, stopped, skipped); a no-op when no report was
481
+ * created (disabled or write failure). A run that produced nothing worth
482
+ * keeping (no completed step, no result text) leaves no file behind — see
483
+ * `reportWorthKeeping`. When the chain completed fully (all steps done),
484
+ * removes the status widget so nothing lingers below the prompt; otherwise
485
+ * keeps it as a trace with the report path in the note.
486
+ */
487
+ /**
488
+ * Write the deferred report header so the file exists before the first
489
+ * section/footer append. Returns false when there is no report or the
490
+ * write failed (a warning was shown); the chain runs on without a file.
491
+ */
492
+ function ensureReportFile(ctx: ExtensionContext | null): boolean {
493
+ const report = chainReport;
494
+ if (!report || report.written) return report !== null;
495
+ try {
496
+ writeFileSync(report.path, report.header);
497
+ report.written = true;
498
+ return true;
499
+ } catch (err) {
500
+ ctx?.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
501
+ return false;
502
+ }
503
+ }
504
+
505
+ async function finishReport(ctx: ExtensionContext): Promise<void> {
506
+ if (!chainReport || !chainStatus) return;
507
+ const statuses = chainStatus.steps.map((s) => s.status);
508
+ // Nothing worth keeping — remove the (mostly) empty file so a quick
509
+ // same-minute retry doesn't get a -N sibling next to it.
510
+ if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
511
+ closeAbandonedReport(statuses);
512
+ chainReport = null;
513
+ return;
514
+ }
515
+ if (ensureReportFile(ctx)) {
516
+ try {
517
+ appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
518
+ chainReport.footerWritten = true;
519
+ } catch (err) {
520
+ ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
521
+ }
522
+ }
523
+ const prev = chainStatus.note ? `${chainStatus.note} • ` : "";
524
+ chainStatus.note = `${prev}📄 ${chainReport.display}`;
525
+ // Chain fully done → show the inline report, then clear the
526
+ // trace widget. Chain stopped early → keep the trace widget.
527
+ const allDone = statuses.every((s) => s === "completed");
528
+ if (allDone) {
529
+ await showInlineReport(ctx);
530
+ clearChainWidget(ctx);
531
+ } else {
532
+ updateChainWidget(ctx);
533
+ }
534
+ }
535
+
536
+ /**
537
+ * Show the full chain report in an ephemeral editor view when a chain
538
+ * completes (TUI only). Built from in-memory section data (no file read
539
+ * needed). Nothing is persisted to the session — the view closes with
540
+ * Esc and the report file on disk is the permanent artifact. In non-TUI
541
+ * modes the editor is a no-op; the final notification carries the file
542
+ * path.
543
+ */
544
+ async function showInlineReport(ctx: ExtensionContext): Promise<void> {
545
+ if (ctx.mode !== "tui") return;
546
+ if (!chainReport || !chainStatus || chainReport.sections.length === 0) return;
547
+ // Reconstruct the report from in-memory sections. Only the last
548
+ // MAX_INLINE_SECTIONS are kept (the file on disk has all of them), so
549
+ // flag the omission when a step has no section in the window.
550
+ const shownSteps = new Set(chainReport.sections.map((s) => s.index));
551
+ const omitted = chainStatus.steps.length - shownSteps.size;
552
+ const lines: string[] = [];
553
+ lines.push(`# do-always chain report — ${new Date().toISOString().slice(0, 10)}`);
554
+ lines.push("");
555
+ lines.push(`- Project: ${chainReport.display}`);
556
+ lines.push(`- Steps: ${chainStatus.steps.map((s) => s.name).join(" → ")}`);
557
+ lines.push("");
558
+ if (omitted > 0) {
559
+ lines.push(`> … ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted — see ${chainReport.display}`);
560
+ lines.push("");
561
+ }
562
+ for (const sec of chainReport.sections) {
563
+ lines.push(sec.markdown);
564
+ }
565
+ // Footer.
566
+ const statuses = chainStatus.steps.map((s) => s.status);
567
+ lines.push(reportFooter(statuses, new Date()));
568
+
569
+ // Ephemeral editor view: read the report, Esc to dismiss. Unlike
570
+ // pi.sendMessage this persists nothing to the session.
571
+ await ctx.ui.editor("do-always — chain report", lines.join("\n"));
572
+ }
573
+
574
+ /**
575
+ * Close an in-flight report that never reached a terminal path (e.g.,
576
+ * the session ended mid-chain): delete the file when the run produced
577
+ * nothing worth keeping (a no-op when the deferred header was never
578
+ * written), otherwise append an "abandoned" footer so it does not stay
579
+ * header-only on disk.
580
+ */
581
+ function closeAbandonedReport(statuses: string[]): void {
582
+ if (!chainReport || chainReport.footerWritten) return;
583
+ if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
584
+ // Nothing worth keeping — remove the file if it was written.
585
+ if (chainReport.written) {
586
+ try {
587
+ unlinkSync(chainReport.path);
588
+ } catch {
589
+ // Best effort — the file stays on disk.
590
+ }
591
+ }
592
+ return;
593
+ }
594
+ try {
595
+ if (chainReport.written) {
596
+ appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
597
+ } else {
598
+ writeFileSync(chainReport.path, chainReport.header + reportAbandonedFooter(statuses, new Date()));
599
+ }
600
+ } catch {
601
+ // Best effort — the report file stays as-is on disk.
602
+ }
603
+ }
604
+
605
+ /**
606
+ * Remove the widget and forget the status (and any in-flight report).
607
+ * When the report never reached a terminal path (e.g., the session
608
+ * ended mid-chain), it is closed by `closeAbandonedReport`.
609
+ */
610
+ function clearChainWidget(ctx: ExtensionContext): void {
611
+ if (!chainStatus) return;
612
+ const statuses = chainStatus.steps.map((s) => s.status);
613
+ chainStatus = null;
614
+ closeAbandonedReport(statuses);
615
+ chainReport = null;
616
+ if (ctx.mode === "tui") ctx.ui.setWidget(CHAIN_WIDGET_KEY, undefined);
617
+ }
618
+
619
+ /**
620
+ * Render the status widget from `chainStatus` (TUI only): the chain's
621
+ * steps with per-step markers, a (n/N) progress line, and the note,
622
+ * below the editor. Refreshed on every step change; removed by
623
+ * `clearChainWidget` when the chain completes or a new prompt starts.
624
+ */
625
+ function updateChainWidget(ctx: ExtensionContext): void {
626
+ if (ctx.mode !== "tui" || !chainStatus) return;
627
+ const { steps, note } = chainStatus;
628
+
629
+ // Chain status widget: stays below the editor.
630
+ ctx.ui.setWidget(
631
+ CHAIN_WIDGET_KEY,
632
+ (tui, theme) => {
633
+ const lines: string[] = [];
634
+ // (n/N): the step the chain is currently at (N when it is done).
635
+ let at = 0;
636
+ steps.forEach((s, i) => {
637
+ if (s.status !== "pending") at = i + 1;
638
+ });
639
+ lines.push(theme.fg("accent", theme.bold(`⛓ do-always (${at}/${steps.length})`)));
640
+ for (const s of steps) {
641
+ lines.push(` ${stepMarker(s.status, theme)} ${s.name}`);
642
+ }
643
+ if (note) lines.push(theme.fg("muted", truncateToWidth(` ${note}`, tui.terminal.columns - 2, "…")));
644
+ const container = new Container();
645
+ for (const line of lines) container.addChild(new Text(line, 1, 0));
646
+ return container;
647
+ },
648
+ { placement: "belowEditor" },
649
+ );
650
+ }
651
+
652
+ pi.on("agent_start", () => {
653
+ if (chainWaiter) {
654
+ chainWaiter.started = true;
655
+ chainWaiter.startTime = performance.now();
656
+ }
657
+ // The run actually began — cancel the auto-run task's failed-to-start
658
+ // grace timer (its summary posts at agent_end).
659
+ if (pendingSummaryTimer) {
660
+ clearTimeout(pendingSummaryTimer);
661
+ pendingSummaryTimer = null;
662
+ }
663
+ // The run actually began — time the step for the report.
664
+ if (chainReport) chainReport.stepStartedAt = new Date();
665
+ // Fill-first: step 1 left the editor and is running — update the
666
+ // widget (and drop the "press Enter" note) as soon as the run starts.
667
+ if (lastCtx && chainStatus?.steps[0]?.status === "waiting") {
668
+ setChainStep(lastCtx, 0, "running", "");
669
+ }
670
+ // User entered a new prompt and the old chain is no longer active —
671
+ // clear the trace widget so nothing lingers below the prompt.
672
+ if (!chainActive && chainStatus && lastCtx) {
673
+ clearChainWidget(lastCtx);
674
+ }
675
+ });
676
+ /** The turn's last assistant message (backward scan — no copy/reverse). */
677
+ function lastAssistantMessage(messages: AgentEndEvent["messages"]) {
678
+ for (let i = messages.length - 1; i >= 0; i--) {
679
+ const m = messages[i];
680
+ if (m.role === "assistant") return m;
681
+ }
682
+ return null;
683
+ }
684
+
685
+ /** Map an assistant message's stopReason to a chain step outcome. */
686
+ function outcomeFromStopReason(stopReason: string | undefined): "completed" | "aborted" | "error" {
687
+ if (stopReason === "aborted") return "aborted";
688
+ if (stopReason === "error") return "error";
689
+ return "completed";
690
+ }
691
+
692
+ pi.on("agent_end", (event) => {
693
+ // The agent may have changed the repo — drop the TTL context cache so
694
+ // the next action sees the new tree (a running chain keeps its own
695
+ // per-action snapshot and is unaffected).
696
+ contextCache = null;
697
+ // Single auto-run task summary (no chainWaiter: pi.sendUserMessage is
698
+ // fire-and-forget, so no armWaiter is called).
699
+ if (!chainWaiter && pendingSummaryTask) {
700
+ if (pendingSummaryTimer) {
701
+ clearTimeout(pendingSummaryTimer);
702
+ pendingSummaryTimer = null;
703
+ }
704
+ const lastAssistant = lastAssistantMessage(event.messages);
705
+ if (lastAssistant) {
706
+ const outcome = outcomeFromStopReason(lastAssistant.stopReason);
707
+ const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
708
+ lastCtx?.ui.notify(`do-always: ${summary}`, "info");
709
+ }
710
+ pendingSummaryTask = null;
711
+ return;
712
+ }
713
+ if (!chainWaiter) return;
714
+ const lastAssistant = lastAssistantMessage(event.messages);
715
+ if (lastAssistant) {
716
+ const outcome = outcomeFromStopReason(lastAssistant.stopReason);
717
+ chainWaiter.outcome = outcome;
718
+ // Append this step's result to the report while the transcript is
719
+ // fresh (the step's final assistant message is its result).
720
+ if (chainReport && chainStatus) {
721
+ const idx = chainWaiter.stepIndex;
722
+ // A retried/continued run emits a second agent_end for the
723
+ // same step before agent_settled — mark the repeat so the
724
+ // report shows both attempts without a duplicate heading.
725
+ const isRetry = chainReport.lastStepSection === idx;
726
+ const name = chainStatus.steps[idx]?.name ?? `step ${idx + 1}`;
727
+ const displayName = isRetry ? `${name} (retry)` : name;
728
+ const text = assistantText(lastAssistant.content);
729
+ const section = reportStepSection(
730
+ idx,
731
+ displayName,
732
+ outcome,
733
+ chainReport.stepStartedAt,
734
+ new Date(),
735
+ text,
736
+ );
737
+ if (ensureReportFile(lastCtx)) {
738
+ try {
739
+ appendFileSync(chainReport.path, section);
740
+ chainReport.lastStepSection = idx;
741
+ if (text.trim() !== "") chainReport.hasContent = true;
742
+ // Keep in-memory section data for inline display (bounded);
743
+ // index lets it number sections like the file.
744
+ chainReport.sections.push({ index: idx, markdown: section });
745
+ if (chainReport.sections.length > MAX_INLINE_SECTIONS) {
746
+ chainReport.sections.splice(0, chainReport.sections.length - MAX_INLINE_SECTIONS);
747
+ }
748
+ } catch (err) {
749
+ lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
750
+ }
751
+ }
752
+ chainReport.stepStartedAt = null;
753
+ }
754
+ }
755
+ });
756
+ pi.on("agent_settled", () => {
757
+ if (!chainWaiter) return;
758
+ settleChainWaiter(chainWaiter.started ? (chainWaiter.outcome ?? "completed") : "failed-to-start");
759
+ });
760
+
761
+ /**
762
+ * Arm the chain waiter and resolve when the next run has fully settled
763
+ * (agent_settled), reporting that run's outcome. With `graceMs`, resolves
764
+ * "failed-to-start" if no agent_start arrives in time — a send that
765
+ * throws before the run begins emits no agent events and its error is
766
+ * swallowed by the runtime. `stepIndex` tags the waiter so the report
767
+ * knows which chain step the run belongs to.
768
+ */
769
+ function armWaiter(graceMs?: number, stepIndex = 0): Promise<ChainStepOutcome> {
770
+ return new Promise((resolve) => {
771
+ const timer = graceMs
772
+ ? setTimeout(() => {
773
+ if (chainWaiter && !chainWaiter.started) settleChainWaiter("failed-to-start");
774
+ }, graceMs)
775
+ : null;
776
+ chainWaiter = { started: false, outcome: null, timer, resolve, stepIndex };
777
+ });
778
+ }
779
+
780
+ /**
781
+ * Send a prompt and resolve when the run it starts has fully settled,
782
+ * reporting the run's outcome (see armWaiter).
783
+ */
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
+ }
789
+
192
790
  /** Filter tasks by their `when` condition and refresh the completion cache. */
193
791
  function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
194
792
  const visible = tasks.filter((t) => evaluateWhen(t, context));
@@ -215,20 +813,66 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
215
813
  });
216
814
  }
217
815
 
218
- pi.on("session_start", (_event, ctx) => {
816
+ pi.on("session_start", async (_event, ctx) => {
817
+ lastCtx = ctx;
219
818
  // Surface config validation problems (the README promises warnings);
220
819
  // in non-TUI modes there is no UI, so fall back to the console.
221
820
  const onError = (m: string) => {
222
821
  if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
223
822
  else console.warn(m);
224
823
  };
824
+ // A failed chain's status widget is a trace of the previous session;
825
+ // start each session clean.
826
+ clearChainWidget(ctx);
827
+ // Likewise a stale auto-run summary flag (e.g., a send that never
828
+ // started) must not post a spurious summary for this session's turns.
829
+ resetPendingSummary();
225
830
  loadedCwd = ctx.cwd;
226
831
  const config = loadConfig(ctx.cwd, onError);
227
832
  tasks = config.tasks;
228
- refreshVisible(ctx.cwd, buildContext(ctx.cwd));
833
+ reportEnabled = config.report;
834
+ refreshVisible(ctx.cwd, await getContext(ctx.cwd));
229
835
  registerShortcut(config.shortcut, onError);
230
836
  });
231
837
 
838
+ // Extension-level git context cache, keyed by cwd with a short TTL:
839
+ // buildContext spawns git, and the repo rarely changes between actions,
840
+ // so the session_start precompute feeds the first /do-always and
841
+ // repeated invocations within the window reuse the same context.
842
+ // Invalidated on agent_end (the agent may have changed the repo); a
843
+ // running chain keeps its own per-action snapshot (createContextCache),
844
+ // so its steps reuse one context across the run.
845
+ let contextCache: { cwd: string; at: number; ctx: TaskContext } | null = null;
846
+ const CONTEXT_TTL_MS = 5_000;
847
+
848
+ async function getContext(cwd: string): Promise<TaskContext> {
849
+ if (contextCache && contextCache.cwd === cwd && Date.now() - contextCache.at < CONTEXT_TTL_MS) {
850
+ return contextCache.ctx;
851
+ }
852
+ const ctx = await buildContext(cwd);
853
+ contextCache = { cwd, at: Date.now(), ctx };
854
+ return ctx;
855
+ }
856
+
857
+ /**
858
+ * Per-action context cache: the first build consults the extension-level
859
+ * TTL cache (getContext), and later builds within the same action reuse
860
+ * that snapshot — what the user saw in the preview is what gets injected.
861
+ * Created fresh at each action entry point (runDoAlways, runChain).
862
+ */
863
+ function createContextCache(): {
864
+ get(cwd: string): Promise<TaskContext>;
865
+ } {
866
+ let cached: { cwd: string; ctx: TaskContext } | null = null;
867
+ return {
868
+ async get(cwd: string): Promise<TaskContext> {
869
+ if (cached && cached.cwd === cwd) return cached.ctx;
870
+ cached = { cwd, ctx: await getContext(cwd) };
871
+ return cached.ctx;
872
+ },
873
+ };
874
+ }
875
+
232
876
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
233
877
  async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
234
878
  const blocked = evaluateGuards(task, context);
@@ -240,7 +884,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
240
884
  // user saw is exactly what gets injected.
241
885
  const prompt = renderPrompt(task.prompt, toPromptContext(context));
242
886
  if (shouldAutoRun(task)) {
243
- await pi.sendUserMessage(prompt);
887
+ // Fire-and-forget: sendUserMessage returns void; the run proceeds
888
+ // independently (see the chain control notes for why).
889
+ // Track the task so we can post a summary after agent_end.
890
+ pendingSummaryTask = task.name;
891
+ // If the run never starts (no agent events at all — same failure
892
+ // mode the chain's grace timer handles), drop the flag so a later
893
+ // unrelated turn can't post a spurious summary for this task.
894
+ if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
895
+ pendingSummaryTimer = setTimeout(() => {
896
+ pendingSummaryTimer = null;
897
+ if (!pendingSummaryTask) return;
898
+ const name = pendingSummaryTask;
899
+ pendingSummaryTask = null;
900
+ lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
901
+ }, 10_000);
902
+ pi.sendUserMessage(prompt);
244
903
  ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
245
904
  return;
246
905
  }
@@ -248,23 +907,214 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
248
907
  ctx.ui.setEditorText(prompt);
249
908
  ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
250
909
  } else {
251
- await pi.sendUserMessage(prompt);
910
+ pi.sendUserMessage(prompt);
911
+ }
912
+ }
913
+
914
+ /**
915
+ * Run a chain: each step is sent as its own turn, awaited in order, so the
916
+ * steps run strictly one after another. Aborting (or erroring) a step
917
+ * stops the chain.
918
+ *
919
+ * Step 1 follows the task's autoRun semantics: ⚡ tasks (and non-TUI modes)
920
+ * are sent immediately; fill tasks put step 1 in the editor and wait for
921
+ * its run to settle before starting the remaining steps.
922
+ */
923
+ async function runChain(
924
+ names: string[],
925
+ ctx: ExtensionContext,
926
+ cache: ReturnType<typeof createContextCache>,
927
+ ): Promise<void> {
928
+ if (chainActive) {
929
+ ctx.ui.notify("do-always: a chain is already running — wait for it to finish (or abort the current step with Esc)", "info");
930
+ return;
931
+ }
932
+ const steps = names
933
+ .map((n) => tasks.find((t) => t.name === n))
934
+ .filter((t): t is DoAlwaysTask => t !== undefined);
935
+ if (steps.length === 0) {
936
+ ctx.ui.notify("do-always: nothing to run", "info");
937
+ return;
938
+ }
939
+ // Fail fast: report the first blocked step before sending anything.
940
+ const blocked = validateChain(tasks, { items: names, history: [] }, await cache.get(ctx.cwd));
941
+ if (blocked) {
942
+ ctx.ui.notify(`do-always: chain blocked at step ${blocked.step} (${blocked.task.name}): ${blocked.message}`, "warning");
943
+ return;
944
+ }
945
+ chainActive = true;
946
+ // Per-step durations for the chain-end summary. Re-initialized each
947
+ // chain run so stale durations from a previous run don't leak in.
948
+ // Pre-filled with 0 so the fill-first step (index 0, timed outside
949
+ // runChainSteps) and any unrun steps don't leave `undefined` holes
950
+ // that would poison the reduce with NaN.
951
+ chainDurations = Array.from({ length: steps.length }, () => 0);
952
+ // Report file: one per run, in the project root. The header is
953
+ // deferred until the first step section (a chain that dies before
954
+ // that leaves no file), and each step is appended as it finishes
955
+ // (see the report section in tasks.ts). A write failure is not
956
+ // fatal — the chain still runs, just without a report.
957
+ if (reportEnabled) {
958
+ const now = new Date();
959
+ const path = resolveReportPath(ctx.cwd, now);
960
+ chainReport = {
961
+ path,
962
+ display: relative(ctx.cwd, path),
963
+ header: reportHeader(ctx.cwd, steps.map((t) => t.name), now),
964
+ written: false,
965
+ stepStartedAt: null,
966
+ footerWritten: false,
967
+ lastStepSection: -1,
968
+ hasContent: false,
969
+ sections: [],
970
+ };
971
+ }
972
+ const first = steps[0];
973
+ if (shouldAutoRun(first) || ctx.mode !== "tui") {
974
+ try {
975
+ showChainStatus(
976
+ ctx,
977
+ steps.map((t, i) => ({ name: t.name, status: i === 0 ? "running" : "pending" })),
978
+ );
979
+ await runChainSteps(steps, ctx, 0);
980
+ } finally {
981
+ chainActive = false;
982
+ }
983
+ return;
984
+ }
985
+ // Fill-first: put step 1 in the editor; the remaining steps start once
986
+ // step 1's run has settled successfully. No grace timer — the user
987
+ // takes as long as they need to press Enter. (If the user runs an
988
+ // unrelated prompt instead, the chain continues after it, as the
989
+ // notification says.)
990
+ showChainStatus(
991
+ ctx,
992
+ steps.map((t, i) => ({ name: t.name, status: i === 0 ? "waiting" : "pending" })),
993
+ "step 1 is in the editor — press Enter to start",
994
+ );
995
+ const firstContext = await cache.get(ctx.cwd);
996
+ ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(firstContext)));
997
+ ctx.ui.notify(
998
+ `do-always: step 1 of ${steps.length} in the editor — press Enter to run; steps 2–${steps.length} follow automatically`,
999
+ "info",
1000
+ );
1001
+ void armWaiter(undefined, 0).then(async (outcome) => {
1002
+ try {
1003
+ if (outcome !== "completed") {
1004
+ markChainStopped(ctx, 0, outcome);
1005
+ await finishReport(ctx);
1006
+ ctx.ui.notify(`do-always: step 1 — ${outcome}; chain stopped`, "error");
1007
+ return;
1008
+ }
1009
+ 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;
1013
+ const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
1014
+ ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
1015
+ await runChainSteps(steps, ctx, 1);
1016
+ } finally {
1017
+ chainActive = false;
1018
+ }
1019
+ });
1020
+ }
1021
+
1022
+ /**
1023
+ * Send chain steps `startAt..end` sequentially. Each step evaluates guards
1024
+ * and renders prompts against fresh repository context, and each step is
1025
+ * awaited until its run has fully settled; an aborted/errored step (or a
1026
+ * send that failed to start) stops the chain.
1027
+ */
1028
+ async function runChainSteps(
1029
+ steps: DoAlwaysTask[],
1030
+ ctx: ExtensionContext,
1031
+ startAt: number,
1032
+ ): Promise<void> {
1033
+ for (let i = startAt; i < steps.length; i++) {
1034
+ const step = steps[i];
1035
+ const context = await getContext(ctx.cwd);
1036
+ const blocked = evaluateGuards(step, context);
1037
+ if (blocked) {
1038
+ markChainStopped(ctx, i, "skipped", blocked);
1039
+ await finishReport(ctx);
1040
+ ctx.ui.notify(`do-always: chain stopped at step ${i + 1} (${step.name}): ${blocked}`, "warning");
1041
+ return;
1042
+ }
1043
+ const prompt = renderPrompt(step.prompt, toPromptContext(context));
1044
+ const label = `do-always: step ${i + 1}/${steps.length} — ${step.name}`;
1045
+ setChainStep(ctx, i, "running");
1046
+ ctx.ui.notify(`${label} — starting`, "info");
1047
+ const stepStart = performance.now();
1048
+ const outcome = await sendAndWait(prompt, 10_000, i);
1049
+ const stepDuration = chainDurations[i] || performance.now() - stepStart;
1050
+ chainDurations[i] = stepDuration;
1051
+ if (outcome === "completed") {
1052
+ setChainStep(ctx, i, "completed");
1053
+ // Post-step summary: files changed + duration.
1054
+ const postContext = await getContext(ctx.cwd);
1055
+ const fileCount = postContext.files.length;
1056
+ const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
1057
+ ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
1058
+ continue;
1059
+ }
1060
+ markChainStopped(ctx, i, outcome);
1061
+ await finishReport(ctx);
1062
+ const summary = stepSummary(outcome, step.name, stepDuration, 0);
1063
+ if (outcome === "failed-to-start") {
1064
+ ctx.ui.notify(`${label} — ${summary} (check model/API key); chain stopped`, "error");
1065
+ } else if (outcome === "aborted") {
1066
+ ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
1067
+ } else {
1068
+ ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
1069
+ }
1070
+ return;
1071
+ }
1072
+ // Complete: the report file holds the full results of every step;
1073
+ // finishReport clears the status widget so nothing lingers below
1074
+ // the prompt. Capture the display path first — finishReport clears
1075
+ // `chainReport` when the chain is fully done.
1076
+ const reportDisplay = chainReport?.display;
1077
+ // Chain-end summary.
1078
+ const completed = chainDurations.filter((d) => d > 0).length;
1079
+ const totalMs = chainDurations.reduce((a, b) => a + b, 0);
1080
+ const chainSum = chainSummary(completed, steps.length, totalMs);
1081
+ if (chainReport) {
1082
+ await finishReport(ctx);
1083
+ ctx.ui.notify(`do-always: ${chainSum} — report: ${reportDisplay}`, "info");
1084
+ } else {
1085
+ clearChainWidget(ctx);
1086
+ ctx.ui.notify(`do-always: ${chainSum}`, "info");
252
1087
  }
253
1088
  }
254
1089
 
255
1090
  /**
256
- * Numbered selector with categorized sections. Press 1-9 to pick by global
257
- * number, type to filter, or navigate with arrows + Enter, Esc to cancel.
258
- * The context is built once per command run (never inside the render loop
259
- * — no process spawning per frame) and shared with `fillPrompt`.
1091
+ * Task table with an ORDER column (the chain) and a pinned Run row:
1092
+ *
1093
+ * # TASK DESCRIPTION ORDER
1094
+ * 1 ⚡ Review changes Review the current ►[1]
1095
+ * 2 Build Build the project ·
1096
+ * ─────────────────────────────────────────────────────
1097
+ * Run the chain (1)
1098
+ *
1099
+ * The TASK column is primary: Enter runs just the task under the cursor
1100
+ * (the classic pick). The ORDER column is the optional chain: Enter
1101
+ * toggles the task's membership, and the pinned Run row runs the whole
1102
+ * chain. ←/→ switch columns, 1-9 still runs a task immediately (closing
1103
+ * the selector, discarding the chain). The context is built once per command
1104
+ * run (never inside the render loop — no process spawning per frame) and
1105
+ * shared with `fillPrompt`.
260
1106
  */
261
- async function showSelector(ctx: ExtensionContext, context: TaskContext): Promise<void> {
1107
+ async function showSelector(
1108
+ ctx: ExtensionContext,
1109
+ context: TaskContext,
1110
+ cache: ReturnType<typeof createContextCache>,
1111
+ ): Promise<void> {
262
1112
  // Filter by the `when` condition once per session, so hidden tasks never
263
1113
  // appear, are never numbered, and can't be picked.
264
1114
  const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
265
1115
  // String view for prompt rendering (derived once, used by the preview).
266
1116
  const strings = toPromptContext(context);
267
- const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
1117
+ const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
268
1118
  let settled = false;
269
1119
  let previewVisible = false;
270
1120
  let previewTimer: ReturnType<typeof setTimeout> | null = null;
@@ -276,16 +1126,28 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
276
1126
  }
277
1127
  }
278
1128
 
279
- // `finish` receives the chosen task (or null) and translates it to the
280
- // full index `tasks[selected]` expects. Reference-based, so it stays
281
- // correct while a text filter is active (itemRows is then a subset of
282
- // visibleTasks and positional indices would point at the wrong task).
283
- const finish = (task: DoAlwaysTask | null) => {
1129
+ // Finish the selector with a result. Reference-based (task object /
1130
+ // chain names), so it stays correct while a text filter is active
1131
+ // (itemRows is then a subset of visibleTasks and positional indices
1132
+ // would point at the wrong task).
1133
+ function finishSingle(task: DoAlwaysTask) {
284
1134
  if (settled) return;
285
1135
  settled = true;
286
1136
  clearPreviewTimer();
287
- done(task ? tasks.indexOf(task) : null);
288
- };
1137
+ done({ kind: "single", task });
1138
+ }
1139
+ function finishChain(names: string[]) {
1140
+ if (settled) return;
1141
+ settled = true;
1142
+ clearPreviewTimer();
1143
+ done({ kind: "chain", names });
1144
+ }
1145
+ function finishCancel() {
1146
+ if (settled) return;
1147
+ settled = true;
1148
+ clearPreviewTimer();
1149
+ done({ kind: "cancel" });
1150
+ }
289
1151
 
290
1152
  // The prompt preview appears only after the selection has been stable
291
1153
  // for PREVIEW_DELAY_MS; any change hides it and restarts the delay.
@@ -307,7 +1169,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
307
1169
  const kb = getKeybindings();
308
1170
  const maxVisible = 12;
309
1171
  let filter = "";
310
- let selectedIndex = 0;
1172
+ let chain = chainClear();
1173
+ let cursor: Cursor = { kind: "cell", row: 0, col: "task" };
1174
+ let lastCellRow = 0;
311
1175
  let mousePressedIndex: number | null = null;
312
1176
 
313
1177
  // Arm the preview timer for the initial selection.
@@ -323,92 +1187,203 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
323
1187
  );
324
1188
  };
325
1189
 
326
- // Recompute the visible (filtered, grouped) rows on every render so
327
- // filter typing updates the list live.
1190
+ // Recompute the visible (filtered) table rows on every render so
1191
+ // filter typing and chain edits update the table live.
328
1192
  function getVisible() {
329
- const visibleGroups = groups
330
- .map((g) => ({ name: g.name, items: g.items.filter(matchesFilter) }))
331
- .filter((g) => g.items.length > 0);
332
- const rows: Array<
333
- | { kind: "header"; name: string }
334
- | { kind: "item"; task: DoAlwaysTask; group: string }
335
- > = [];
336
- for (const g of visibleGroups) {
337
- rows.push({ kind: "header", name: g.name });
338
- for (const t of g.items) rows.push({ kind: "item", task: t, group: g.name });
1193
+ const filteredGroups = groups.map((g) => ({ name: g.name, items: g.items.filter(matchesFilter) }));
1194
+ const tableRows = buildTableRows(filteredGroups, chain);
1195
+ const bodyRows: TableRow[] = [];
1196
+ const itemRows: { task: DoAlwaysTask; globalIndex: number; order?: number }[] = [];
1197
+ // O(n) index map so indexOf → O(1) lookup.
1198
+ const taskToGlobalIndex = new Map(visibleTasks.map((t, i) => [t, i]));
1199
+ for (const r of tableRows) {
1200
+ if (r.kind === "run") continue; // pinned row, rendered separately
1201
+ bodyRows.push(r);
1202
+ if (r.kind === "task" && r.task) {
1203
+ itemRows.push({ task: r.task, globalIndex: taskToGlobalIndex.get(r.task) ?? -1, order: r.order });
1204
+ }
339
1205
  }
340
- const itemRows = rows.filter((r): r is (typeof rows)[number] & { kind: "item" } => r.kind === "item");
341
- // Clamp selection to the visible item count.
342
- selectedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, itemRows.length - 1)));
343
- // Visible item window with scrolling.
344
- const winStart = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), Math.max(0, itemRows.length - maxVisible)));
345
- const visibleItemKeys = new Set(itemRows.slice(winStart, winStart + maxVisible).map((r) => r.task));
346
- const visibleHeaderNames = new Set([...visibleItemKeys].map((t) => findGroupOf(t, visibleGroups)?.name ?? ""));
347
- return { rows, itemRows, visibleItemKeys, visibleHeaderNames };
1206
+ // Scroll window over the filtered items.
1207
+ const anchor = cursor.kind === "cell" ? cursor.row : lastCellRow;
1208
+ const winStart =
1209
+ itemRows.length > maxVisible
1210
+ ? Math.max(0, Math.min(anchor + 1 - maxVisible, itemRows.length - maxVisible))
1211
+ : 0;
1212
+ // Headers whose group has at least one item in the window.
1213
+ const inWindow = new Set(itemRows.slice(winStart, winStart + maxVisible).map((r) => r.task));
1214
+ const visibleHeaderNames = new Set(
1215
+ filteredGroups.filter((g) => g.items.some((t) => inWindow.has(t))).map((g) => g.name),
1216
+ );
1217
+ return { bodyRows, itemRows, winStart, visibleHeaderNames };
348
1218
  }
1219
+ // One pass's visible state, computed once per input event / render
1220
+ // and shared by clampCursor, buildTable, and the navigation logic.
1221
+ type Visible = ReturnType<typeof getVisible>;
349
1222
 
350
- const labelCol = 26;
351
-
352
- function renderLabel(task: DoAlwaysTask, globalIndex: number, isSelected: boolean, width: number): string {
353
- const prefix = isSelected ? "▸ " : " ";
354
- const marker = shouldAutoRun(task) ? "⚡ " : "";
355
- const label = `${prefix}${globalIndex + 1}. ${marker}${task.name}`;
356
- if (!task.description) {
357
- const line = truncateToWidth(label, Math.max(1, width - 2), "");
358
- return isSelected ? theme.fg("accent", theme.bold(line)) : line;
359
- }
360
- // Width-aware two-column layout; fall back to label-only when the
361
- // terminal is too narrow to fit a description column.
362
- const effCol = Math.max(1, Math.min(labelCol, width - 8));
363
- const nameOnly = truncateToWidth(label, effCol, "");
364
- const pad = " ".repeat(Math.max(1, effCol - visibleWidth(nameOnly)));
365
- const remaining = width - visibleWidth(nameOnly) - pad.length - 2;
366
- if (remaining < 10) {
367
- const line = truncateToWidth(label, Math.max(1, width - 2), "");
368
- return isSelected ? theme.fg("accent", theme.bold(line)) : line;
1223
+ // Keep the cursor valid after the rows or the chain change. (The
1224
+ // ORDER cell of a non-chained row is a valid cursor position: it is
1225
+ // the "add" state.)
1226
+ function clampCursor(visible: Visible) {
1227
+ const { itemRows } = visible;
1228
+ if (itemRows.length === 0) {
1229
+ cursor = { kind: "cell", row: 0, col: "task" };
1230
+ return;
369
1231
  }
370
- const desc = truncateToWidth(task.description, remaining, "");
371
- if (isSelected) {
372
- return theme.fg("accent", theme.bold(`${nameOnly}${pad}${desc}`));
1232
+ if (cursor.kind === "cell" && cursor.row >= itemRows.length) {
1233
+ cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
373
1234
  }
374
- return `${nameOnly}${pad}${theme.fg("muted", desc)}`;
375
1235
  }
376
1236
 
377
- // Build the full selector output for a width, plus a map from line
378
- // index to task for the item rows (used by mouse handling).
379
- function buildRender(width: number) {
380
- const { rows, itemRows, visibleItemKeys, visibleHeaderNames } = getVisible();
1237
+ // --- Table geometry ---------------------------------------------------
1238
+ // Three tiers by width:
1239
+ // >= 76: # TASK DESCRIPTION ORDER
1240
+ // 58-75: # TASK ORDER
1241
+ // < 58: # TASK (chain shown on its own line below the list)
1242
+ const ORDER_COL_W = 5;
1243
+ const TASK_COL_W = 24;
1244
+ type Tier = "full" | "compact" | "narrow";
1245
+ function tierFor(width: number): Tier {
1246
+ if (width >= 76) return "full";
1247
+ if (width >= 58) return "compact";
1248
+ return "narrow";
1249
+ }
1250
+ // Column geometry: [2] # [3] [taskCol] [descCol] [ORDER_COL_W] [1]
1251
+ function tableGeometry(width: number) {
1252
+ const tier = tierFor(width);
1253
+ const taskCol = tier === "full" ? TASK_COL_W : Math.max(10, width - 2 - 3 - 2 - 2 - ORDER_COL_W - 1);
1254
+ const descCol = tier === "full" ? Math.max(8, width - 2 - 3 - 2 - TASK_COL_W - 2 - 2 - ORDER_COL_W - 1) : 0;
1255
+ // The ORDER cell is the last ORDER_COL_W characters of the line
1256
+ // (the line is width-2 chars wide), so it starts at width-2-W.
1257
+ const orderColX = tier === "narrow" ? null : width - 2 - ORDER_COL_W;
1258
+ return { tier, taskCol, descCol, orderColX };
1259
+ }
1260
+
1261
+ // Cached last buildTable result (avoids double-work on mouse hit-test).
1262
+ // The table depends on cursor and preview state, so render() must
1263
+ // rebuild it on every pass; handleMouse only reuses the last render's
1264
+ // table (or builds one if no render has happened yet, e.g. the very
1265
+ // first mouse event).
1266
+ type Table = { lines: string[]; itemLine: Map<number, DoAlwaysTask>; runLine: number; orderColX: number | null };
1267
+ let lastTable: Table | null = null;
1268
+
1269
+ // Build the full selector output for a width, plus the line map for
1270
+ // mouse handling (itemLine: line -> task, runLine: the Run row,
1271
+ // orderColX: where the ORDER cell starts, or null in the narrow tier).
1272
+ function buildTable(width: number, visible: Visible = getVisible()): Table {
1273
+ const { bodyRows, itemRows, winStart, visibleHeaderNames } = visible;
1274
+ const { tier, taskCol, descCol, orderColX } = tableGeometry(width);
381
1275
  const lines: string[] = [];
382
1276
  const itemLine = new Map<number, DoAlwaysTask>();
383
- lines.push(theme.fg("accent", theme.bold(" do-always — pick a task")));
384
- lines.push("");
1277
+ let runLine = -1;
1278
+ // Cursor marker: a large triangle in the accent color. The row
1279
+ // background alone can be invisible (some themes map selectedBg
1280
+ // to the terminal's default background), so the marker carries
1281
+ // the cursor.
1282
+ const cursorMark = theme.fg("accent", "►");
1283
+
1284
+ // Header.
1285
+ lines.push(
1286
+ theme.fg(
1287
+ "muted",
1288
+ truncateToWidth(
1289
+ // The 6-char prefix (" # ") lines the header up with
1290
+ // the body rows (2-digit number + 2 spaces).
1291
+ tier === "full"
1292
+ ? ` # ${"TASK".padEnd(taskCol)} ${"DESCRIPTION".padEnd(descCol)} ${"ORDER".padEnd(ORDER_COL_W)}`
1293
+ : tier === "compact"
1294
+ ? ` # ${"TASK".padEnd(taskCol)} ${"ORDER".padEnd(ORDER_COL_W)}`
1295
+ : " # TASK",
1296
+ width - 2,
1297
+ "",
1298
+ ),
1299
+ ),
1300
+ );
1301
+
385
1302
  if (itemRows.length === 0) {
386
1303
  lines.push(theme.fg("warning", " No matching tasks"));
387
1304
  } else {
388
- for (const row of rows) {
1305
+ let itemShown = 0;
1306
+ // O(n) index map so findIndex → O(1) lookup.
1307
+ const idxMap = new Map(itemRows.map((x, i) => [x.task, i]));
1308
+ for (const row of bodyRows) {
389
1309
  if (row.kind === "header") {
390
- if (!visibleHeaderNames.has(row.name)) continue;
391
- lines.push(theme.fg("accent", theme.bold(` ${row.name.toUpperCase()}`)));
1310
+ if (!visibleHeaderNames.has(row.name ?? "")) continue;
1311
+ if (itemShown >= maxVisible) break;
1312
+ lines.push(
1313
+ theme.fg("accent", theme.bold(truncateToWidth(` ${(row.name ?? "").toUpperCase()}`, width - 2, ""))),
1314
+ );
392
1315
  continue;
393
1316
  }
394
- if (!visibleItemKeys.has(row.task)) continue;
395
- const globalIndex = visibleTasks.indexOf(row.task);
396
- const isSelected = row.task === itemRows[selectedIndex].task;
397
- lines.push(renderLabel(row.task, globalIndex, isSelected, width));
398
- itemLine.set(lines.length - 1, row.task);
1317
+ if (!row.task) continue;
1318
+ const idx = idxMap.get(row.task);
1319
+ if (idx === undefined || idx < winStart || idx >= winStart + maxVisible) continue;
1320
+ itemShown++;
1321
+ const task = row.task;
1322
+ const auto = shouldAutoRun(task);
1323
+ const focused = cursor.kind === "cell" && cursor.row === idx;
1324
+ // Cursor markers: ► in the left gutter = TASK column, ► in the
1325
+ // ORDER cell = ORDER column. The row background is applied too,
1326
+ // but some themes map selectedBg to a color that is nearly
1327
+ // indistinguishable from the terminal background, so the
1328
+ // character marker is the reliable indicator.
1329
+ const inTaskCol =
1330
+ cursor.kind === "cell" && cursor.row === idx && cursor.col === "task";
1331
+ const inOrderCol =
1332
+ cursor.kind === "cell" && cursor.row === idx && cursor.col === "order";
1333
+ const num = inTaskCol
1334
+ ? `${cursorMark} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
1335
+ : inOrderCol && tier === "narrow"
1336
+ ? `${theme.fg("warning", "►")} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
1337
+ : ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
1338
+ // ⚡ is 2 columns wide, so "⚡ " takes 3 — reserve it so
1339
+ // auto-run rows align with the others (ORDER cell is
1340
+ // hit-tested at a fixed x).
1341
+ const name = truncateToWidth(task.name, taskCol - (auto ? 3 : 0), "…", true);
1342
+ const taskCell = (auto ? "⚡ " : "") + name;
1343
+ // Every ORDER cell is exactly ORDER_COL_W wide so the column
1344
+ // stays aligned (and mouse hit-testing stays exact).
1345
+ const orderCell =
1346
+ row.order !== undefined
1347
+ ? inOrderCol
1348
+ ? truncateToWidth(`${cursorMark}[${row.order}]`, ORDER_COL_W, "", true)
1349
+ : ` [${row.order}] `
1350
+ : inOrderCol
1351
+ ? `${cursorMark} · `
1352
+ : " · ";
1353
+ let line: string;
1354
+ if (tier === "full") {
1355
+ const desc = truncateToWidth(task.description ?? "", descCol, "…", true);
1356
+ line = `${num} ${taskCell} ${desc} ${orderCell}`;
1357
+ } else if (tier === "compact") {
1358
+ line = `${num} ${taskCell} ${orderCell}`;
1359
+ } else {
1360
+ line = truncateToWidth(`${num} ${taskCell}`, width - 2, "…");
1361
+ }
1362
+ // Full-row background + bold where the theme makes it visible;
1363
+ // the ► gutter/cell marker carries the cursor either way.
1364
+ if (focused) line = theme.bg("selectedBg", theme.bold(line));
1365
+ lines.push(line);
1366
+ itemLine.set(lines.length - 1, task);
399
1367
  }
400
1368
  if (itemRows.length > maxVisible) {
401
- const hint = ` (${selectedIndex + 1}/${itemRows.length})`;
402
- lines.push(theme.fg("dim", truncateToWidth(hint, width - 2, "")));
1369
+ const anchor = cursor.kind === "cell" ? cursor.row : lastCellRow;
1370
+ lines.push(theme.fg("dim", truncateToWidth(` (${anchor + 1}/${itemRows.length})`, width - 2, "")));
1371
+ }
1372
+ // Narrow tier: the chain gets its own line instead of a column.
1373
+ if (tier === "narrow" && chain.items.length > 0) {
1374
+ lines.push(
1375
+ theme.fg("dim", truncateToWidth(` chain: ${formatChainSequence(visibleTasks, chain)}`, width - 2, "…")),
1376
+ );
403
1377
  }
404
1378
  }
405
- // Prompt preview: revealed after the selection has been stable for
406
- // PREVIEW_DELAY_MS, showing exactly what will be injected.
407
- if (previewVisible) {
408
- const sel = itemRows[selectedIndex];
1379
+
1380
+ // Prompt preview: revealed after the cursor has been stable on a
1381
+ // task row for PREVIEW_DELAY_MS, showing exactly what will be
1382
+ // injected.
1383
+ if (previewVisible && cursor.kind === "cell" && cursor.col === "task") {
1384
+ const sel = itemRows[cursor.row];
409
1385
  if (sel) {
410
1386
  const wrapWidth = Math.max(10, width - 4);
411
- // Show the rendered prompt — exactly what will be injected.
412
1387
  const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, strings), wrapWidth);
413
1388
  const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
414
1389
  const truncated = wrapped.length > PREVIEW_MAX_LINES;
@@ -421,60 +1396,244 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
421
1396
  });
422
1397
  }
423
1398
  }
1399
+
1400
+ // Pinned Run row (always visible, outside the scroll window).
424
1401
  lines.push("");
425
- const anyAutoRun = itemRows.some((r) => shouldAutoRun(r.task));
426
- const footer = anyAutoRun
427
- ? " 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs"
428
- : " 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel";
1402
+ lines.push(theme.fg("dim", " " + "─".repeat(Math.max(1, width - 4))));
1403
+ const runLabel = chainRunLabel(chain.items.length);
1404
+ runLine = lines.length;
1405
+ // No play glyph in the label: the ► cursor marker is the only
1406
+ // ">" and it appears only while the row is selected (like task rows).
1407
+ if (cursor.kind === "run") {
1408
+ lines.push(theme.bg("selectedBg", theme.bold(`${cursorMark} ${runLabel}`)));
1409
+ } else if (chain.items.length === 0) {
1410
+ lines.push(theme.fg("dim", ` ${runLabel}`));
1411
+ } else {
1412
+ lines.push(theme.fg("accent", ` ${runLabel}`));
1413
+ }
1414
+
1415
+ // Context-sensitive footer.
1416
+ let footer: string;
1417
+ if (cursor.kind === "run") {
1418
+ footer =
1419
+ chain.items.length > 0
1420
+ ? ` ⏎ run: ${formatChainSequence(visibleTasks, chain)}`
1421
+ : " ⏎ run the chain (chain is empty)";
1422
+ } else if (cursor.col === "order") {
1423
+ const inChain = chain.items.includes(itemRows[cursor.row]?.task.name ?? "");
1424
+ footer = inChain ? " ← tasks • ⏎ remove • esc" : " ← tasks • ⏎ add • esc";
1425
+ } else {
1426
+ footer = " 1-9 run now • ⏎ select • → order • ⌫ undo • esc";
1427
+ }
1428
+ if (chain.items.length > 0 && cursor.kind !== "run") footer += " • ctrl+u clear";
429
1429
  lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
430
- return { lines, itemLine, itemRows };
1430
+
1431
+ return { lines, itemLine, runLine, orderColX };
431
1432
  }
432
1433
 
433
1434
  return {
434
1435
  render(width: number) {
435
- return buildRender(width).lines;
1436
+ lastTable = buildTable(width, getVisible());
1437
+ return lastTable.lines;
1438
+ },
1439
+ invalidate() {
1440
+ clearPreviewTimer();
436
1441
  },
437
- invalidate() {},
438
1442
  handleInput(data: string) {
439
- // Direct pick by number (1-9) — only when not filtering, and within
440
- // the visible set, so digits pick a visible task by its number.
1443
+ // Direct pick by number (1-9) — runs the task immediately (the
1444
+ // classic fast path), closing the selector and discarding the
1445
+ // chain. Only when not filtering, and within the visible set,
1446
+ // so digits pick a visible task by its number.
441
1447
  if (!filter && /^[1-9]$/.test(data) && Number(data) <= visibleTasks.length) {
442
- finish(visibleTasks[Number(data) - 1]);
1448
+ finishSingle(visibleTasks[Number(data) - 1]);
443
1449
  return;
444
1450
  }
445
- // Filter typing.
1451
+ // Filter typing (Backspace edits the filter; with an empty
1452
+ // filter it undoes the last chain add). The filter change
1453
+ // invalidates the visible set, so the clamp gets a fresh pass.
446
1454
  if (kb.matches(data, "tui.editor.deleteCharBackward")) {
447
- filter = filter.slice(0, -1);
448
- selectedIndex = 0;
1455
+ if (filter.length > 0) {
1456
+ filter = filter.slice(0, -1);
1457
+ clampCursor(getVisible());
1458
+ } else {
1459
+ const { state, removed } = chainUndo(chain);
1460
+ if (removed) {
1461
+ chain = state;
1462
+ clampCursor(getVisible());
1463
+ }
1464
+ }
449
1465
  resetPreview();
450
1466
  tui.requestRender();
451
1467
  return;
452
1468
  }
453
1469
  if (isPrintable(data)) {
454
1470
  filter += data;
455
- selectedIndex = 0;
1471
+ clampCursor(getVisible());
456
1472
  resetPreview();
457
1473
  tui.requestRender();
458
1474
  return;
459
1475
  }
460
- // Navigation / confirmation.
461
- const { itemRows } = getVisible();
1476
+ // One pass for the rest of the event: navigation, confirm,
1477
+ // and clear below all share this result (chain edits don't
1478
+ // change itemRows, so it stays valid).
1479
+ const visible = getVisible();
1480
+ const { itemRows } = visible;
1481
+ if (itemRows.length === 0) {
1482
+ // Nothing to navigate; only Esc is useful here.
1483
+ if (kb.matches(data, "tui.select.cancel")) finishCancel();
1484
+ return;
1485
+ }
1486
+ // Column switching.
1487
+ if (matchesKey(data, "left")) {
1488
+ if (cursor.kind === "run") {
1489
+ cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
1490
+ } else if (cursor.col === "order") {
1491
+ cursor = { kind: "cell", row: cursor.row, col: "task" };
1492
+ }
1493
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
1494
+ resetPreview();
1495
+ tui.requestRender();
1496
+ return;
1497
+ }
1498
+ if (matchesKey(data, "right")) {
1499
+ if (cursor.kind === "cell" && cursor.col === "task") {
1500
+ // Same row: the ORDER cell shows whether this task is
1501
+ // in the chain, and Enter toggles it.
1502
+ cursor = { kind: "cell", row: cursor.row, col: "order" };
1503
+ }
1504
+ // (→ in the ORDER column and on the Run row is a no-op:
1505
+ // the cursor is already at the right/bottom edge.)
1506
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
1507
+ resetPreview();
1508
+ tui.requestRender();
1509
+ return;
1510
+ }
1511
+ // Row navigation: the cursor moves in both columns (wrap at
1512
+ // the edges); the Run row is reached from the last task row.
462
1513
  if (kb.matches(data, "tui.select.up")) {
463
- selectedIndex = selectedIndex === 0 ? itemRows.length - 1 : selectedIndex - 1;
1514
+ if (cursor.kind === "run") {
1515
+ cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
1516
+ } else {
1517
+ cursor = {
1518
+ kind: "cell",
1519
+ row: cursor.row === 0 ? itemRows.length - 1 : cursor.row - 1,
1520
+ col: cursor.col,
1521
+ };
1522
+ }
1523
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
464
1524
  resetPreview();
465
1525
  tui.requestRender();
1526
+ return;
466
1527
  }
467
- else if (kb.matches(data, "tui.select.down")) {
468
- selectedIndex = selectedIndex === itemRows.length - 1 ? 0 : selectedIndex + 1;
1528
+ if (kb.matches(data, "tui.select.down")) {
1529
+ if (cursor.kind === "run") {
1530
+ cursor = { kind: "cell", row: 0, col: "task" };
1531
+ } else if (cursor.row === itemRows.length - 1) {
1532
+ // The pinned Run row sits below the last task row.
1533
+ cursor = { kind: "run" };
1534
+ lastCellRow = itemRows.length - 1;
1535
+ } else {
1536
+ cursor = {
1537
+ kind: "cell",
1538
+ row: cursor.row + 1,
1539
+ col: cursor.col,
1540
+ };
1541
+ }
1542
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
469
1543
  resetPreview();
470
1544
  tui.requestRender();
1545
+ return;
471
1546
  }
472
- else if (kb.matches(data, "tui.select.confirm")) {
473
- const chosen = itemRows[selectedIndex];
474
- if (chosen) finish(chosen.task);
1547
+ // Extended navigation: home, end, pageup, pagedown.
1548
+ if (matchesKey(data, "home")) {
1549
+ cursor = { kind: "cell", row: 0, col: cursor.kind === "cell" ? cursor.col : "task" };
1550
+ lastCellRow = 0;
1551
+ resetPreview();
1552
+ tui.requestRender();
1553
+ return;
475
1554
  }
476
- else if (kb.matches(data, "tui.select.cancel")) {
477
- finish(null);
1555
+ if (matchesKey(data, "end")) {
1556
+ cursor = { kind: "run" };
1557
+ lastCellRow = itemRows.length - 1;
1558
+ resetPreview();
1559
+ tui.requestRender();
1560
+ return;
1561
+ }
1562
+ if (matchesKey(data, "pageUp")) {
1563
+ if (cursor.kind === "run") {
1564
+ cursor = { kind: "cell", row: Math.max(0, itemRows.length - maxVisible), col: "task" };
1565
+ } else {
1566
+ cursor = {
1567
+ kind: "cell",
1568
+ row: Math.max(0, cursor.row - maxVisible),
1569
+ col: cursor.col,
1570
+ };
1571
+ }
1572
+ lastCellRow = cursor.row;
1573
+ resetPreview();
1574
+ tui.requestRender();
1575
+ return;
1576
+ }
1577
+ if (matchesKey(data, "pageDown")) {
1578
+ if (cursor.kind === "cell") {
1579
+ const next = cursor.row + maxVisible;
1580
+ if (next >= itemRows.length) {
1581
+ cursor = { kind: "run" };
1582
+ lastCellRow = itemRows.length - 1;
1583
+ } else {
1584
+ cursor = { kind: "cell", row: next, col: cursor.col };
1585
+ lastCellRow = next;
1586
+ }
1587
+ }
1588
+ resetPreview();
1589
+ tui.requestRender();
1590
+ return;
1591
+ }
1592
+ // Confirm: context-dependent.
1593
+ if (kb.matches(data, "tui.select.confirm")) {
1594
+ if (cursor.kind === "run") {
1595
+ if (chain.items.length === 0) {
1596
+ ctx.ui.notify("do-always: chain is empty — add a task first", "info");
1597
+ } else {
1598
+ finishChain([...chain.items]);
1599
+ }
1600
+ return;
1601
+ }
1602
+ const row = itemRows[cursor.row];
1603
+ if (!row) return;
1604
+ if (cursor.col === "task") {
1605
+ // The classic pick: run just this task (fill or
1606
+ // auto-run per its autoRun), discarding the chain.
1607
+ finishSingle(row.task);
1608
+ return;
1609
+ }
1610
+ // ORDER column: toggle this task's chain membership.
1611
+ if (chain.items.includes(row.task.name)) {
1612
+ chain = chainRemove(chain, row.task.name);
1613
+ } else {
1614
+ const { state, result } = chainAdd(chain, row.task.name);
1615
+ chain = state;
1616
+ if (result === "full") {
1617
+ ctx.ui.notify(`do-always: chain is full (${CHAIN_MAX}) — remove a task first`, "error");
1618
+ }
1619
+ }
1620
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
1621
+ resetPreview();
1622
+ tui.requestRender();
1623
+ return;
1624
+ }
1625
+ // Clear the chain.
1626
+ if (matchesKey(data, "ctrl+u")) {
1627
+ if (chain.items.length > 0) {
1628
+ chain = chainClear();
1629
+ clampCursor(visible);
1630
+ resetPreview();
1631
+ tui.requestRender();
1632
+ }
1633
+ return;
1634
+ }
1635
+ if (kb.matches(data, "tui.select.cancel")) {
1636
+ finishCancel();
478
1637
  }
479
1638
  },
480
1639
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
@@ -482,21 +1641,51 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
482
1641
  const { itemRows } = getVisible();
483
1642
  if (itemRows.length === 0) return undefined;
484
1643
  const delta = event.wheelDelta < 0 ? -1 : 1;
485
- const prev = selectedIndex;
486
- selectedIndex = Math.max(0, Math.min(itemRows.length - 1, selectedIndex + delta));
487
- if (selectedIndex !== prev) resetPreview();
488
- return { handled: true, render: selectedIndex !== prev };
1644
+ const prev = cursor.kind === "cell" ? cursor.row : lastCellRow;
1645
+ const next = Math.max(0, Math.min(itemRows.length - 1, prev + delta));
1646
+ if (next === prev) return { handled: true };
1647
+ cursor = { kind: "cell", row: next, col: "task" };
1648
+ lastCellRow = next;
1649
+ resetPreview();
1650
+ return { handled: true, render: true };
489
1651
  }
490
1652
  if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
491
- const { itemLine, itemRows } = buildRender(event.width);
1653
+ // Reuse the last render's table for hit-testing (avoids rebuilding
1654
+ // the table twice per mouse event). The table is always fresh
1655
+ // because handleInput calls requestRender before the next mouse
1656
+ // event can arrive; build one if no render has happened yet.
1657
+ // One getVisible() per event, shared by the (possibly fresh)
1658
+ // table and the itemRows lookup below.
1659
+ const visible = getVisible();
1660
+ const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width, visible);
1661
+ // Pinned Run row: press runs the chain.
1662
+ if (runLine >= 0 && event.y === runLine) {
1663
+ if (event.type === "press" && chain.items.length > 0) {
1664
+ finishChain([...chain.items]);
1665
+ }
1666
+ return { handled: true };
1667
+ }
492
1668
  const task = itemLine.get(event.y);
493
1669
  if (!task) return undefined;
1670
+ const { itemRows } = visible;
494
1671
  const idx = itemRows.findIndex((r) => r.task === task);
495
1672
  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 };
1680
+ }
1681
+ return { handled: true }; // swallow the click after the press action
1682
+ }
1683
+ // Task area: press selects, click runs (the classic fast path).
496
1684
  if (event.type === "press") {
497
1685
  mousePressedIndex = idx;
498
- if (selectedIndex !== idx) {
499
- selectedIndex = idx;
1686
+ if (cursor.kind !== "cell" || cursor.row !== idx) {
1687
+ cursor = { kind: "cell", row: idx, col: "task" };
1688
+ lastCellRow = idx;
500
1689
  resetPreview();
501
1690
  }
502
1691
  return { handled: true, focus: true, render: true };
@@ -504,14 +1693,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
504
1693
  const clicked = mousePressedIndex ?? idx;
505
1694
  mousePressedIndex = null;
506
1695
  const chosen = itemRows[clicked];
507
- if (chosen) finish(chosen.task);
1696
+ if (chosen) finishSingle(chosen.task);
508
1697
  return { handled: true };
509
1698
  },
510
1699
  };
511
1700
  });
512
1701
 
513
- if (selected === null || selected === undefined) return;
514
- await fillPrompt(tasks[selected], ctx, context);
1702
+ if (!result || result.kind === "cancel") return;
1703
+ if (result.kind === "single") {
1704
+ await fillPrompt(result.task, ctx, context);
1705
+ } else {
1706
+ await runChain(result.names, ctx, cache);
1707
+ }
515
1708
  }
516
1709
 
517
1710
  pi.registerCommand("do-always", {
@@ -536,6 +1729,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
536
1729
  });
537
1730
 
538
1731
  async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
1732
+ lastCtx = ctx;
539
1733
  // Reload when the active directory changes, so switching projects
540
1734
  // mid-session serves the right config instead of stale tasks.
541
1735
  if (ctx.cwd !== loadedCwd) {
@@ -544,19 +1738,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
544
1738
  if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
545
1739
  else console.warn(m);
546
1740
  };
547
- tasks = loadConfig(ctx.cwd, onError).tasks;
1741
+ const config = loadConfig(ctx.cwd, onError);
1742
+ tasks = config.tasks;
1743
+ reportEnabled = config.report;
548
1744
  }
549
1745
 
550
1746
  // One context per command run: shared by visibility filtering, rendering,
551
1747
  // and the completion cache (never inside a render loop).
552
- const context = buildContext(ctx.cwd);
1748
+ const cache = createContextCache();
1749
+ const context = await cache.get(ctx.cwd);
553
1750
  const visible = refreshVisible(ctx.cwd, context);
554
1751
 
555
1752
  const arg = args.trim();
556
1753
 
557
1754
  if (!arg) {
558
1755
  if (ctx.mode === "tui") {
559
- await showSelector(ctx, context);
1756
+ await showSelector(ctx, context, cache);
560
1757
  } else {
561
1758
  ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(visible)}`, "info");
562
1759
  }