pi-do-always 0.9.0 → 0.10.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.
@@ -34,24 +34,35 @@
34
34
  */
35
35
 
36
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";
37
+ import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
38
+ import { join, relative } from "node:path";
39
+ import type { 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
+ chainUndo,
53
63
  evaluateGuards,
54
64
  evaluateWhen,
65
+ formatChainSequence,
55
66
  formatList,
56
67
  groupTasksByCategory,
57
68
  isValidKeyId,
@@ -59,17 +70,38 @@ import {
59
70
  parseConfig,
60
71
  parseStatusPorcelain,
61
72
  orderTasksByCategory,
73
+ reportAbandonedFooter,
74
+ reportFooter,
75
+ reportHeader,
76
+ reportStepSection,
77
+ reportWorthKeeping,
62
78
  renderPrompt,
79
+ resolveReportPath,
63
80
  resolveShortcut,
64
81
  resolveTask,
65
82
  shouldAutoRun,
66
- splitFileLines,
67
83
  toPromptContext,
84
+ validateChain,
68
85
  type DoAlwaysTask,
69
86
  type TaskContext,
70
- type TaskGroup,
87
+ type TableRow,
71
88
  } from "./tasks";
72
89
 
90
+ /**
91
+ * What the selector resolved to: a single task (the classic pick), a chain to
92
+ * run, or a cancel.
93
+ */
94
+ type SelectorResult =
95
+ | { kind: "single"; task: DoAlwaysTask }
96
+ | { kind: "chain"; names: string[] }
97
+ | { kind: "cancel" };
98
+
99
+ /**
100
+ * Selector cursor: a cell in the task table (TASK or ORDER column) or the
101
+ * pinned Run row.
102
+ */
103
+ type Cursor = { kind: "cell"; row: number; col: "task" | "order" } | { kind: "run" };
104
+
73
105
  /**
74
106
  * Load tasks and the selector shortcut from config files.
75
107
  * Project-local tasks override global tasks with the same name (or are
@@ -86,16 +118,18 @@ function loadConfig(
86
118
  ): {
87
119
  tasks: DoAlwaysTask[];
88
120
  shortcut: string | null;
121
+ /** Whether chain runs write a Markdown report file (default true). */
122
+ report: boolean;
89
123
  } {
90
124
  const globalPath = join(getAgentDir(), "do-always.json");
91
125
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
92
126
 
93
127
  const global = existsSync(globalPath)
94
128
  ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath, onError)
95
- : { tasks: [], shortcut: undefined, merge: undefined };
129
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
96
130
  const project = existsSync(projectPath)
97
131
  ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath, onError)
98
- : { tasks: [], shortcut: undefined, merge: undefined };
132
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
99
133
 
100
134
  // The project file's merge mode wins; otherwise the global value; otherwise
101
135
  // override (the historical behavior), so existing configs are unaffected.
@@ -106,6 +140,9 @@ function loadConfig(
106
140
  // `/do-always <n>`, and `list` all share one consistent order.
107
141
  tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
108
142
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
143
+ // The project file's value wins; otherwise the global value; otherwise
144
+ // reports are on.
145
+ report: project.report ?? global.report ?? true,
109
146
  };
110
147
  }
111
148
 
@@ -129,47 +166,96 @@ function git(cwd: string, args: string[]): string | undefined {
129
166
  }
130
167
  }
131
168
 
169
+ /**
170
+ * Cache that deduplicates `buildContext` calls for the same cwd within a
171
+ * single user action (chain run, /do-always invocation, session_start).
172
+ * The cache is created fresh for each action entry point.
173
+ */
174
+ function createContextCache(): {
175
+ get(cwd: string): TaskContext;
176
+ } {
177
+ let cached: { cwd: string; ctx: TaskContext } | null = null;
178
+ return {
179
+ get(cwd: string): TaskContext {
180
+ if (cached && cached.cwd === cwd) return cached.ctx;
181
+ cached = { cwd, ctx: buildContext(cwd) };
182
+ return cached.ctx;
183
+ },
184
+ };
185
+ }
186
+
132
187
  /**
133
188
  * Build the structured context for the current directory. Git facts fall back
134
189
  * 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`.
190
+ * default prompts read cleanly in any directory.
191
+ *
192
+ * Batches the 9 individual git calls into 5:
193
+ * 1. rev-parse --abbrev-ref HEAD --is-inside-work-tree → branch + isGitRepo
194
+ * 2. log -1 --format="%H %s" → commit hash + subject
195
+ * 3. config --get-regexp "^user.name$|^remote.origin.url$" → user + remote
196
+ * 4. status --porcelain → stagedFiles + unstagedFiles (parsed from porcelain)
197
+ * 5. diff --shortstat → diffStat
137
198
  */
138
199
  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"]);
200
+ // 1. Branch + isGitRepo in one call.
201
+ // rev-parse --abbrev-ref HEAD --is-inside-work-tree outputs two lines:
202
+ // <branch-name>
203
+ // true|false
204
+ // Fails (exit != 0) when not in a work tree.
205
+ const revParse = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD", "--is-inside-work-tree"]);
206
+ const lines = revParse ? revParse.split("\n") : [];
207
+ const branch = lines[0] ?? "unknown";
208
+ const isGitRepo = lines[1] === "true";
209
+
210
+ // 2. Commit subject (hash is unused but cheap to fetch alongside).
211
+ const commitLine = isGitRepo ? git(cwd, ["log", "-1", "--format=%H %s"]) : undefined;
212
+ const lastCommit = commitLine ? commitLine.split(" ", 2).pop() ?? "unknown" : "unknown";
213
+
214
+ // 3. User + remote in one config call.
215
+ const user = isGitRepo ? git(cwd, ["config", "--get-regexp", "^user\\.name$"]) : undefined;
216
+ const remoteUrl = isGitRepo ? git(cwd, ["config", "--get-regexp", "^remote\\.origin\\.url$"]) : undefined;
149
217
  const repo = remoteUrl
150
218
  ? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
151
219
  : cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
152
220
 
221
+ // 4. status --porcelain gives staged + unstaged in one call.
222
+ // Lines starting with a space are unstaged; others are staged/untracked.
223
+ const porcelain = isGitRepo ? git(cwd, ["status", "--porcelain"]) : "";
224
+ const stagedFiles: string[] = [];
225
+ const unstagedFiles: string[] = [];
226
+ const fileSet = new Set<string>();
227
+ if (porcelain) {
228
+ for (const line of porcelain.split("\n")) {
229
+ if (line.length < 2) continue;
230
+ const file = line.slice(2);
231
+ if (fileSet.has(file)) continue; // deduplicate
232
+ fileSet.add(file);
233
+ if (line[0] === " ") {
234
+ unstagedFiles.push(file);
235
+ } else {
236
+ stagedFiles.push(file);
237
+ }
238
+ }
239
+ }
240
+
241
+ // 5. diff --shortstat.
242
+ const diffStat = isGitRepo ? git(cwd, ["diff", "--shortstat"]) : undefined;
243
+
153
244
  return {
154
245
  cwd,
155
246
  date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
156
247
  branch,
157
248
  lastCommit,
158
- files: parseStatusPorcelain(git(cwd, ["status", "--porcelain"]) ?? ""),
159
- user,
160
- diffStat: git(cwd, ["diff", "--shortstat"]) ?? "none",
249
+ files: parseStatusPorcelain(porcelain ?? ""),
250
+ user: user ?? "unknown",
251
+ diffStat: diffStat ?? "none",
161
252
  repo,
162
- stagedFiles: splitFileLines(git(cwd, ["diff", "--cached", "--name-only"])),
163
- unstagedFiles: splitFileLines(git(cwd, ["diff", "--name-only"])),
253
+ stagedFiles,
254
+ unstagedFiles,
164
255
  isGitRepo,
165
256
  };
166
257
  }
167
258
 
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
259
  /** True for a single printable ASCII character (used for filter typing). */
174
260
  function isPrintable(data: string): boolean {
175
261
  return data.length === 1 && data >= " " && data <= "~";
@@ -180,6 +266,30 @@ const PREVIEW_DELAY_MS = 2000;
180
266
  /** Max lines of the prompt shown in the selector preview. */
181
267
  const PREVIEW_MAX_LINES = 3;
182
268
 
269
+ /** Outcome of one chain step's run (see sendAndWait). */
270
+ type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
271
+
272
+ /**
273
+ * Status of one chain step for the below-prompt status widget: pending
274
+ * (not reached yet), running (its turn is in flight), waiting (fill-first:
275
+ * step 1 is in the editor, waiting for the user's Enter), completed, or one
276
+ * of the stop outcomes (failed-to-start/aborted/error/skipped-by-guards).
277
+ */
278
+ type ChainStepStatus =
279
+ | "pending"
280
+ | "running"
281
+ | "waiting"
282
+ | "completed"
283
+ | "failed-to-start"
284
+ | "aborted"
285
+ | "error"
286
+ | "skipped";
287
+
288
+ interface ChainStepView {
289
+ name: string;
290
+ status: ChainStepStatus;
291
+ }
292
+
183
293
  export default function doAlwaysExtension(pi: ExtensionAPI) {
184
294
  let tasks: DoAlwaysTask[] = [];
185
295
  let loadedCwd = ""; // cwd the cached `tasks` were loaded for
@@ -189,6 +299,358 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
189
299
  // the full list rather than guessing.
190
300
  let visibleCache: { cwd: string; visible: DoAlwaysTask[] } | null = null;
191
301
 
302
+ // Chain control: `pi.sendUserMessage` is fire-and-forget (returns void),
303
+ // so the chain runner sequences steps on session events:
304
+ // agent_start — the run actually began. A send that fails before the
305
+ // run starts (no API key, compaction collision) never
306
+ // emits agent events and its error is swallowed by the
307
+ // runtime; the grace timer in sendAndWait turns that
308
+ // into "failed-to-start".
309
+ // agent_end — carries the run's messages; the last assistant
310
+ // message's stopReason gives completed/aborted/error.
311
+ // agent_settled — the session is fully idle (the busy flag is cleared
312
+ // before this fires), so the next step can be sent
313
+ // safely; auto-retry, compaction, and queued
314
+ // continuations have all had their chance.
315
+ let chainWaiter: {
316
+ started: boolean;
317
+ outcome: "completed" | "aborted" | "error" | null;
318
+ timer: NodeJS.Timeout | null;
319
+ resolve: (outcome: ChainStepOutcome) => void;
320
+ /** Which chain step (0-based) this waiter belongs to — for the report. */
321
+ stepIndex: number;
322
+ } | null = null;
323
+
324
+ function settleChainWaiter(outcome: ChainStepOutcome) {
325
+ if (!chainWaiter) return;
326
+ const waiter = chainWaiter;
327
+ chainWaiter = null;
328
+ if (waiter.timer) clearTimeout(waiter.timer);
329
+ waiter.resolve(outcome);
330
+ }
331
+
332
+ // Below-prompt status widget while a chain is running: the chain's tasks
333
+ // with per-step status and a (n/N) progress marker. Shown in TUI mode
334
+ // only; cleared when the chain completes, kept (as a trace) when it stops
335
+ // early, and reset on session start.
336
+ let chainStatus: { steps: ChainStepView[]; note?: string } | null = null;
337
+ const CHAIN_WIDGET_KEY = "do-always-chain";
338
+ // The most recent command context, so event handlers (which carry no
339
+ // context of their own) can still refresh the widget.
340
+ let lastCtx: ExtensionContext | null = null;
341
+ // True while a chain's runner is in flight (from start to its final
342
+ // outcome). A second chain started while one is running would interleave
343
+ // their event waiters (the old chain's sendAndWait would resolve on the
344
+ // new chain's step), so starting one is refused until the first ends.
345
+ let chainActive = false;
346
+ // Whether chain runs write a Markdown report file (config `report`,
347
+ // default true). Refreshed whenever the config is (re)loaded.
348
+ let reportEnabled = true;
349
+ // The in-flight chain's report file: its path (absolute + relative for
350
+ // display), when the current step's run actually started (agent_start;
351
+ // null until then and for failed-to-start steps), whether the footer has
352
+ // been appended, the index of the last step section written (dedupes
353
+ // retried runs), and whether any step section carried result text (the
354
+ // file is worth keeping).
355
+ let chainReport: {
356
+ path: string;
357
+ display: string;
358
+ stepStartedAt: Date | null;
359
+ /** True once the summary (or abandoned) footer has been appended. */
360
+ footerWritten: boolean;
361
+ /** Index of the last step whose section was appended (-1 = none). */
362
+ lastStepSection: number;
363
+ /** True once a step section with result text was appended. */
364
+ hasContent: boolean;
365
+ /** In-memory section data for inline display (populated in agent_end). */
366
+ sections: Array<{ name: string; outcome: string; text: string }>;
367
+ } | null = null;
368
+
369
+ /** Status marker glyph (all one column wide) with its color. */
370
+ function stepMarker(status: ChainStepStatus, theme: Theme): string {
371
+ switch (status) {
372
+ case "completed":
373
+ return theme.fg("success", "✓");
374
+ case "running":
375
+ return theme.fg("accent", theme.bold("▶"));
376
+ case "waiting":
377
+ return theme.fg("warning", "▶");
378
+ case "aborted":
379
+ return theme.fg("warning", "⊘");
380
+ case "error":
381
+ case "failed-to-start":
382
+ return theme.fg("error", "✗");
383
+ case "skipped":
384
+ return theme.fg("muted", "–");
385
+ default:
386
+ return theme.fg("dim", "○");
387
+ }
388
+ }
389
+
390
+ /** Replace the chain status and refresh the widget. */
391
+ function showChainStatus(ctx: ExtensionContext, steps: ChainStepView[], note?: string): void {
392
+ chainStatus = { steps, note };
393
+ updateChainWidget(ctx);
394
+ }
395
+
396
+ /** Update one step's status (and optionally the note) and refresh. */
397
+ function setChainStep(ctx: ExtensionContext, index: number, status: ChainStepStatus, note?: string): void {
398
+ if (!chainStatus) return;
399
+ const s = chainStatus.steps[index];
400
+ if (s) s.status = status;
401
+ if (note !== undefined) chainStatus.note = note;
402
+ updateChainWidget(ctx);
403
+ }
404
+
405
+ /**
406
+ * Mark the chain as stopped at `index` with `status`, keeping the widget
407
+ * visible as a trace of where it stopped.
408
+ */
409
+ function markChainStopped(ctx: ExtensionContext, index: number, status: ChainStepStatus, detail?: string): void {
410
+ const name = chainStatus?.steps[index]?.name;
411
+ setChainStep(
412
+ ctx,
413
+ index,
414
+ status,
415
+ `stopped at step ${index + 1}${name ? ` (${name})` : ""}${detail ? `: ${detail}` : ""}`,
416
+ );
417
+ }
418
+
419
+ /**
420
+ * Finish the report file: append the summary footer. Called on every
421
+ * terminal path (complete, stopped, skipped); a no-op when no report was
422
+ * created (disabled or write failure). A run that produced nothing worth
423
+ * keeping (no completed step, no result text) leaves no file behind — see
424
+ * `reportWorthKeeping`. When the chain completed fully (all steps done),
425
+ * removes the status widget so nothing lingers below the prompt; otherwise
426
+ * keeps it as a trace with the report path in the note.
427
+ */
428
+ function finishReport(ctx: ExtensionContext): void {
429
+ if (!chainReport || !chainStatus) return;
430
+ const statuses = chainStatus.steps.map((s) => s.status);
431
+ // Nothing worth keeping — remove the (mostly) empty file so a quick
432
+ // same-minute retry doesn't get a -N sibling next to it.
433
+ if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
434
+ closeAbandonedReport(statuses);
435
+ chainReport = null;
436
+ return;
437
+ }
438
+ try {
439
+ appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
440
+ chainReport.footerWritten = true;
441
+ } catch (err) {
442
+ ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
443
+ }
444
+ const prev = chainStatus.note ? `${chainStatus.note} • ` : "";
445
+ chainStatus.note = `${prev}📄 ${chainReport.display}`;
446
+ // Chain fully done → show inline report above editor, then clear the
447
+ // trace widget. Chain stopped early → keep the trace widget.
448
+ const allDone = statuses.every((s) => s === "completed");
449
+ if (allDone) {
450
+ showInlineReport();
451
+ clearChainWidget(ctx);
452
+ } else {
453
+ updateChainWidget(ctx);
454
+ }
455
+ }
456
+
457
+ /**
458
+ * Send the full chain report as a markdown message in the chat when a chain
459
+ * completes. Built from in-memory section data (no file read needed).
460
+ * Auto-cleared on the next agent_start / session_start.
461
+ */
462
+ function showInlineReport(): void {
463
+ if (!chainReport || !chainStatus || chainReport.sections.length === 0) return;
464
+ // Reconstruct the full markdown report from in-memory sections.
465
+ const lines: string[] = [];
466
+ lines.push(`# do-always chain report — ${new Date().toISOString().slice(0, 10)}`);
467
+ lines.push("");
468
+ lines.push(`- Project: ${chainReport.display}`);
469
+ lines.push(`- Steps: ${chainStatus.steps.map((s) => s.name).join(" → ")}`);
470
+ lines.push("");
471
+ for (const sec of chainReport.sections) {
472
+ const section = reportStepSection(
473
+ 0, // index not meaningful for display
474
+ sec.name,
475
+ sec.outcome,
476
+ new Date(), // approximate start
477
+ new Date(), // approximate end
478
+ sec.text,
479
+ );
480
+ lines.push(section);
481
+ lines.push("");
482
+ }
483
+ // Footer.
484
+ const statuses = chainStatus.steps.map((s) => s.status);
485
+ lines.push(reportFooter(statuses, new Date()));
486
+
487
+ // Send as a markdown message in the chat (renders as markdown, not plain text).
488
+ pi.sendMessage({
489
+ customType: "do-always-report",
490
+ content: lines.join("\n"),
491
+ display: true,
492
+ details: chainReport.display,
493
+ });
494
+ }
495
+
496
+ /**
497
+ * Close an in-flight report that never reached a terminal path (e.g.,
498
+ * the session ended mid-chain): delete the file when the run produced
499
+ * nothing worth keeping, otherwise append an "abandoned" footer so it
500
+ * does not stay header-only on disk.
501
+ */
502
+ function closeAbandonedReport(statuses: string[]): void {
503
+ if (!chainReport || chainReport.footerWritten) return;
504
+ if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
505
+ // Nothing worth keeping — remove the (mostly) empty file.
506
+ try {
507
+ unlinkSync(chainReport.path);
508
+ } catch {
509
+ // Best effort — the file stays on disk.
510
+ }
511
+ return;
512
+ }
513
+ try {
514
+ appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
515
+ } catch {
516
+ // Best effort — the report file stays as-is on disk.
517
+ }
518
+ }
519
+
520
+ /**
521
+ * Remove the widget and forget the status (and any in-flight report).
522
+ * When the report never reached a terminal path (e.g., the session
523
+ * ended mid-chain), it is closed by `closeAbandonedReport`.
524
+ */
525
+ function clearChainWidget(ctx: ExtensionContext): void {
526
+ if (!chainStatus) return;
527
+ const statuses = chainStatus.steps.map((s) => s.status);
528
+ chainStatus = null;
529
+ closeAbandonedReport(statuses);
530
+ chainReport = null;
531
+ if (ctx.mode === "tui") ctx.ui.setWidget(CHAIN_WIDGET_KEY, undefined);
532
+ }
533
+
534
+ /**
535
+ * Render the status widget from `chainStatus` (TUI only): the chain's
536
+ * steps with per-step markers, a (n/N) progress line, and the note,
537
+ * below the editor. Refreshed on every step change; removed by
538
+ * `clearChainWidget` when the chain completes or a new prompt starts.
539
+ */
540
+ function updateChainWidget(ctx: ExtensionContext): void {
541
+ if (ctx.mode !== "tui" || !chainStatus) return;
542
+ const { steps, note } = chainStatus;
543
+
544
+ // Chain status widget: stays below the editor.
545
+ ctx.ui.setWidget(
546
+ CHAIN_WIDGET_KEY,
547
+ (tui, theme) => {
548
+ const lines: string[] = [];
549
+ // (n/N): the step the chain is currently at (N when it is done).
550
+ let at = 0;
551
+ steps.forEach((s, i) => {
552
+ if (s.status !== "pending") at = i + 1;
553
+ });
554
+ lines.push(theme.fg("accent", theme.bold(`⛓ do-always (${at}/${steps.length})`)));
555
+ for (const s of steps) {
556
+ lines.push(` ${stepMarker(s.status, theme)} ${s.name}`);
557
+ }
558
+ if (note) lines.push(theme.fg("muted", truncateToWidth(` ${note}`, tui.terminal.columns - 2, "…")));
559
+ const container = new Container();
560
+ for (const line of lines) container.addChild(new Text(line, 1, 0));
561
+ return container;
562
+ },
563
+ { placement: "belowEditor" },
564
+ );
565
+ }
566
+
567
+ pi.on("agent_start", () => {
568
+ if (chainWaiter) chainWaiter.started = true;
569
+ // The run actually began — time the step for the report.
570
+ if (chainReport) chainReport.stepStartedAt = new Date();
571
+ // Fill-first: step 1 left the editor and is running — update the
572
+ // widget (and drop the "press Enter" note) as soon as the run starts.
573
+ if (lastCtx && chainStatus?.steps[0]?.status === "waiting") {
574
+ setChainStep(lastCtx, 0, "running", "");
575
+ }
576
+ // User entered a new prompt and the old chain is no longer active —
577
+ // clear the trace widget so nothing lingers below the prompt.
578
+ if (!chainActive && chainStatus && lastCtx) {
579
+ clearChainWidget(lastCtx);
580
+ }
581
+ });
582
+ pi.on("agent_end", (event) => {
583
+ if (!chainWaiter) return;
584
+ const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant");
585
+ if (lastAssistant) {
586
+ const stopReason = lastAssistant.stopReason;
587
+ const outcome = stopReason === "aborted" ? "aborted" : stopReason === "error" ? "error" : "completed";
588
+ chainWaiter.outcome = outcome;
589
+ // Append this step's result to the report while the transcript is
590
+ // fresh (the step's final assistant message is its result).
591
+ if (chainReport && chainStatus) {
592
+ const idx = chainWaiter.stepIndex;
593
+ // A retried/continued run emits a second agent_end for the
594
+ // same step before agent_settled — mark the repeat so the
595
+ // report shows both attempts without a duplicate heading.
596
+ const isRetry = chainReport.lastStepSection === idx;
597
+ const name = chainStatus.steps[idx]?.name ?? `step ${idx + 1}`;
598
+ const text = assistantText(lastAssistant.content);
599
+ const section = reportStepSection(
600
+ idx,
601
+ isRetry ? `${name} (retry)` : name,
602
+ outcome,
603
+ chainReport.stepStartedAt,
604
+ new Date(),
605
+ text,
606
+ );
607
+ try {
608
+ appendFileSync(chainReport.path, section);
609
+ chainReport.lastStepSection = idx;
610
+ if (text.trim() !== "") chainReport.hasContent = true;
611
+ // Keep in-memory section data for inline display.
612
+ chainReport.sections.push({ name, outcome, text });
613
+ } catch (err) {
614
+ lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
615
+ }
616
+ chainReport.stepStartedAt = null;
617
+ }
618
+ }
619
+ });
620
+ pi.on("agent_settled", () => {
621
+ if (!chainWaiter) return;
622
+ settleChainWaiter(chainWaiter.started ? (chainWaiter.outcome ?? "completed") : "failed-to-start");
623
+ });
624
+
625
+ /**
626
+ * Arm the chain waiter and resolve when the next run has fully settled
627
+ * (agent_settled), reporting that run's outcome. With `graceMs`, resolves
628
+ * "failed-to-start" if no agent_start arrives in time — a send that
629
+ * throws before the run begins emits no agent events and its error is
630
+ * swallowed by the runtime. `stepIndex` tags the waiter so the report
631
+ * knows which chain step the run belongs to.
632
+ */
633
+ function armWaiter(graceMs?: number, stepIndex = 0): Promise<ChainStepOutcome> {
634
+ return new Promise((resolve) => {
635
+ const timer = graceMs
636
+ ? setTimeout(() => {
637
+ if (chainWaiter && !chainWaiter.started) settleChainWaiter("failed-to-start");
638
+ }, graceMs)
639
+ : null;
640
+ chainWaiter = { started: false, outcome: null, timer, resolve, stepIndex };
641
+ });
642
+ }
643
+
644
+ /**
645
+ * Send a prompt and resolve when the run it starts has fully settled,
646
+ * reporting the run's outcome (see armWaiter).
647
+ */
648
+ function sendAndWait(prompt: string, graceMs = 10_000, stepIndex = 0): Promise<ChainStepOutcome> {
649
+ const done = armWaiter(graceMs, stepIndex);
650
+ pi.sendUserMessage(prompt);
651
+ return done;
652
+ }
653
+
192
654
  /** Filter tasks by their `when` condition and refresh the completion cache. */
193
655
  function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
194
656
  const visible = tasks.filter((t) => evaluateWhen(t, context));
@@ -216,15 +678,20 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
216
678
  }
217
679
 
218
680
  pi.on("session_start", (_event, ctx) => {
681
+ lastCtx = ctx;
219
682
  // Surface config validation problems (the README promises warnings);
220
683
  // in non-TUI modes there is no UI, so fall back to the console.
221
684
  const onError = (m: string) => {
222
685
  if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
223
686
  else console.warn(m);
224
687
  };
688
+ // A failed chain's status widget is a trace of the previous session;
689
+ // start each session clean.
690
+ clearChainWidget(ctx);
225
691
  loadedCwd = ctx.cwd;
226
692
  const config = loadConfig(ctx.cwd, onError);
227
693
  tasks = config.tasks;
694
+ reportEnabled = config.report;
228
695
  refreshVisible(ctx.cwd, buildContext(ctx.cwd));
229
696
  registerShortcut(config.shortcut, onError);
230
697
  });
@@ -240,7 +707,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
240
707
  // user saw is exactly what gets injected.
241
708
  const prompt = renderPrompt(task.prompt, toPromptContext(context));
242
709
  if (shouldAutoRun(task)) {
243
- await pi.sendUserMessage(prompt);
710
+ // Fire-and-forget: sendUserMessage returns void; the run proceeds
711
+ // independently (see the chain control notes for why).
712
+ pi.sendUserMessage(prompt);
244
713
  ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
245
714
  return;
246
715
  }
@@ -248,23 +717,192 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
248
717
  ctx.ui.setEditorText(prompt);
249
718
  ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
250
719
  } else {
251
- await pi.sendUserMessage(prompt);
720
+ pi.sendUserMessage(prompt);
252
721
  }
253
722
  }
254
723
 
255
724
  /**
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`.
725
+ * Run a chain: each step is sent as its own turn, awaited in order, so the
726
+ * steps run strictly one after another. Aborting (or erroring) a step
727
+ * stops the chain.
728
+ *
729
+ * Step 1 follows the task's autoRun semantics: ⚡ tasks (and non-TUI modes)
730
+ * are sent immediately; fill tasks put step 1 in the editor and wait for
731
+ * its run to settle before starting the remaining steps.
260
732
  */
261
- async function showSelector(ctx: ExtensionContext, context: TaskContext): Promise<void> {
733
+ async function runChain(
734
+ names: string[],
735
+ ctx: ExtensionContext,
736
+ cache: ReturnType<typeof createContextCache>,
737
+ ): Promise<void> {
738
+ if (chainActive) {
739
+ ctx.ui.notify("do-always: a chain is already running — wait for it to finish (or abort the current step with Esc)", "info");
740
+ return;
741
+ }
742
+ const steps = names
743
+ .map((n) => tasks.find((t) => t.name === n))
744
+ .filter((t): t is DoAlwaysTask => t !== undefined);
745
+ if (steps.length === 0) {
746
+ ctx.ui.notify("do-always: nothing to run", "info");
747
+ return;
748
+ }
749
+ // Fail fast: report the first blocked step before sending anything.
750
+ const blocked = validateChain(tasks, { items: names, history: [] }, cache.get(ctx.cwd));
751
+ if (blocked) {
752
+ ctx.ui.notify(`do-always: chain blocked at step ${blocked.step} (${blocked.task.name}): ${blocked.message}`, "warning");
753
+ return;
754
+ }
755
+ chainActive = true;
756
+ // Report file: one per run, in the project root, appended as each step
757
+ // finishes (see the report section in tasks.ts). A write failure is not
758
+ // fatal — the chain still runs, just without a report.
759
+ if (reportEnabled) {
760
+ const now = new Date();
761
+ const path = resolveReportPath(ctx.cwd, now);
762
+ try {
763
+ writeFileSync(path, reportHeader(ctx.cwd, steps.map((t) => t.name), now));
764
+ chainReport = {
765
+ path,
766
+ display: relative(ctx.cwd, path),
767
+ stepStartedAt: null,
768
+ footerWritten: false,
769
+ lastStepSection: -1,
770
+ hasContent: false,
771
+ sections: [],
772
+ };
773
+ } catch (err) {
774
+ ctx.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
775
+ chainReport = null;
776
+ }
777
+ }
778
+ const first = steps[0];
779
+ if (shouldAutoRun(first) || ctx.mode !== "tui") {
780
+ try {
781
+ showChainStatus(
782
+ ctx,
783
+ steps.map((t, i) => ({ name: t.name, status: i === 0 ? "running" : "pending" })),
784
+ );
785
+ await runChainSteps(steps, ctx, 0, cache);
786
+ } finally {
787
+ chainActive = false;
788
+ }
789
+ return;
790
+ }
791
+ // Fill-first: put step 1 in the editor; the remaining steps start once
792
+ // step 1's run has settled successfully. No grace timer — the user
793
+ // takes as long as they need to press Enter. (If the user runs an
794
+ // unrelated prompt instead, the chain continues after it, as the
795
+ // notification says.)
796
+ showChainStatus(
797
+ ctx,
798
+ steps.map((t, i) => ({ name: t.name, status: i === 0 ? "waiting" : "pending" })),
799
+ "step 1 is in the editor — press Enter to start",
800
+ );
801
+ ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(cache.get(ctx.cwd))));
802
+ ctx.ui.notify(
803
+ `do-always: step 1 of ${steps.length} in the editor — press Enter to run; steps 2–${steps.length} follow automatically`,
804
+ "info",
805
+ );
806
+ void armWaiter(undefined, 0).then(async (outcome) => {
807
+ try {
808
+ if (outcome !== "completed") {
809
+ markChainStopped(ctx, 0, outcome);
810
+ finishReport(ctx);
811
+ ctx.ui.notify(`do-always: step 1 — ${outcome}; chain stopped`, "error");
812
+ return;
813
+ }
814
+ setChainStep(ctx, 0, "completed");
815
+ await runChainSteps(steps, ctx, 1, cache);
816
+ } finally {
817
+ chainActive = false;
818
+ }
819
+ });
820
+ }
821
+
822
+ /**
823
+ * Send chain steps `startAt..end` sequentially. Each step gets a fresh
824
+ * context (so its guards see the tree as it is now) and is awaited until
825
+ * its run has fully settled; an aborted/errored step (or a send that
826
+ * failed to start) stops the chain.
827
+ */
828
+ async function runChainSteps(
829
+ steps: DoAlwaysTask[],
830
+ ctx: ExtensionContext,
831
+ startAt: number,
832
+ cache: ReturnType<typeof createContextCache>,
833
+ ): Promise<void> {
834
+ for (let i = startAt; i < steps.length; i++) {
835
+ const step = steps[i];
836
+ const context = cache.get(ctx.cwd);
837
+ const blocked = evaluateGuards(step, context);
838
+ if (blocked) {
839
+ markChainStopped(ctx, i, "skipped", blocked);
840
+ finishReport(ctx);
841
+ ctx.ui.notify(`do-always: chain stopped at step ${i + 1} (${step.name}): ${blocked}`, "warning");
842
+ return;
843
+ }
844
+ const prompt = renderPrompt(step.prompt, toPromptContext(context));
845
+ const label = `do-always: step ${i + 1}/${steps.length} — ${step.name}`;
846
+ setChainStep(ctx, i, "running");
847
+ ctx.ui.notify(`${label} — starting`, "info");
848
+ const outcome = await sendAndWait(prompt, 10_000, i);
849
+ if (outcome === "completed") {
850
+ setChainStep(ctx, i, "completed");
851
+ continue;
852
+ }
853
+ markChainStopped(ctx, i, outcome);
854
+ finishReport(ctx);
855
+ if (outcome === "failed-to-start") {
856
+ ctx.ui.notify(`${label} — failed to start (check model/API key); chain stopped`, "error");
857
+ } else if (outcome === "aborted") {
858
+ ctx.ui.notify(`${label} — aborted; chain stopped`, "error");
859
+ } else {
860
+ ctx.ui.notify(`${label} — run errored; chain stopped`, "error");
861
+ }
862
+ return;
863
+ }
864
+ // Complete: the report file holds the full results of every step;
865
+ // finishReport clears the status widget so nothing lingers below
866
+ // the prompt. Capture the display path first — finishReport clears
867
+ // `chainReport` when the chain is fully done.
868
+ const reportDisplay = chainReport?.display;
869
+ if (chainReport) {
870
+ finishReport(ctx);
871
+ ctx.ui.notify(`do-always: chain complete (${steps.length} steps) — report: ${reportDisplay}`, "info");
872
+ } else {
873
+ clearChainWidget(ctx);
874
+ ctx.ui.notify(`do-always: chain complete (${steps.length} steps)`, "info");
875
+ }
876
+ }
877
+
878
+ /**
879
+ * Task table with an ORDER column (the chain) and a pinned Run row:
880
+ *
881
+ * # TASK DESCRIPTION ORDER
882
+ * 1 ⚡ Review changes Review the current ►[1]
883
+ * 2 Build Build the project ·
884
+ * ─────────────────────────────────────────────────────
885
+ * Run the chain (1)
886
+ *
887
+ * The TASK column is primary: Enter runs just the task under the cursor
888
+ * (the classic pick). The ORDER column is the optional chain: Enter
889
+ * toggles the task's membership, and the pinned Run row runs the whole
890
+ * chain. ←/→ switch columns, 1-9 still runs a task immediately (closing
891
+ * the selector, discarding the chain). The context is built once per command
892
+ * run (never inside the render loop — no process spawning per frame) and
893
+ * shared with `fillPrompt`.
894
+ */
895
+ async function showSelector(
896
+ ctx: ExtensionContext,
897
+ context: TaskContext,
898
+ cache: ReturnType<typeof createContextCache>,
899
+ ): Promise<void> {
262
900
  // Filter by the `when` condition once per session, so hidden tasks never
263
901
  // appear, are never numbered, and can't be picked.
264
902
  const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
265
903
  // String view for prompt rendering (derived once, used by the preview).
266
904
  const strings = toPromptContext(context);
267
- const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
905
+ const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
268
906
  let settled = false;
269
907
  let previewVisible = false;
270
908
  let previewTimer: ReturnType<typeof setTimeout> | null = null;
@@ -276,16 +914,28 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
276
914
  }
277
915
  }
278
916
 
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) => {
917
+ // Finish the selector with a result. Reference-based (task object /
918
+ // chain names), so it stays correct while a text filter is active
919
+ // (itemRows is then a subset of visibleTasks and positional indices
920
+ // would point at the wrong task).
921
+ function finishSingle(task: DoAlwaysTask) {
284
922
  if (settled) return;
285
923
  settled = true;
286
924
  clearPreviewTimer();
287
- done(task ? tasks.indexOf(task) : null);
288
- };
925
+ done({ kind: "single", task });
926
+ }
927
+ function finishChain(names: string[]) {
928
+ if (settled) return;
929
+ settled = true;
930
+ clearPreviewTimer();
931
+ done({ kind: "chain", names });
932
+ }
933
+ function finishCancel() {
934
+ if (settled) return;
935
+ settled = true;
936
+ clearPreviewTimer();
937
+ done({ kind: "cancel" });
938
+ }
289
939
 
290
940
  // The prompt preview appears only after the selection has been stable
291
941
  // for PREVIEW_DELAY_MS; any change hides it and restarts the delay.
@@ -307,7 +957,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
307
957
  const kb = getKeybindings();
308
958
  const maxVisible = 12;
309
959
  let filter = "";
310
- let selectedIndex = 0;
960
+ let chain = chainClear();
961
+ let cursor: Cursor = { kind: "cell", row: 0, col: "task" };
962
+ let lastCellRow = 0;
311
963
  let mousePressedIndex: number | null = null;
312
964
 
313
965
  // Arm the preview timer for the initial selection.
@@ -323,92 +975,195 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
323
975
  );
324
976
  };
325
977
 
326
- // Recompute the visible (filtered, grouped) rows on every render so
327
- // filter typing updates the list live.
978
+ // Recompute the visible (filtered) table rows on every render so
979
+ // filter typing and chain edits update the table live.
328
980
  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 });
981
+ const filteredGroups = groups.map((g) => ({ name: g.name, items: g.items.filter(matchesFilter) }));
982
+ const tableRows = buildTableRows(filteredGroups, chain);
983
+ const bodyRows: TableRow[] = [];
984
+ const itemRows: { task: DoAlwaysTask; globalIndex: number; order?: number }[] = [];
985
+ // O(n) index map so indexOf → O(1) lookup.
986
+ const taskToGlobalIndex = new Map(visibleTasks.map((t, i) => [t, i]));
987
+ for (const r of tableRows) {
988
+ if (r.kind === "run") continue; // pinned row, rendered separately
989
+ bodyRows.push(r);
990
+ if (r.kind === "task" && r.task) {
991
+ itemRows.push({ task: r.task, globalIndex: taskToGlobalIndex.get(r.task) ?? -1, order: r.order });
992
+ }
339
993
  }
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 };
994
+ // Scroll window over the filtered items.
995
+ const anchor = cursor.kind === "cell" ? cursor.row : lastCellRow;
996
+ const winStart =
997
+ itemRows.length > maxVisible
998
+ ? Math.max(0, Math.min(anchor + 1 - maxVisible, itemRows.length - maxVisible))
999
+ : 0;
1000
+ // Headers whose group has at least one item in the window.
1001
+ const inWindow = new Set(itemRows.slice(winStart, winStart + maxVisible).map((r) => r.task));
1002
+ const visibleHeaderNames = new Set(
1003
+ filteredGroups.filter((g) => g.items.some((t) => inWindow.has(t))).map((g) => g.name),
1004
+ );
1005
+ return { bodyRows, itemRows, winStart, visibleHeaderNames };
348
1006
  }
349
1007
 
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;
1008
+ // Keep the cursor valid after the rows or the chain change. (The
1009
+ // ORDER cell of a non-chained row is a valid cursor position: it is
1010
+ // the "add" state.)
1011
+ function clampCursor() {
1012
+ const { itemRows } = getVisible();
1013
+ if (itemRows.length === 0) {
1014
+ cursor = { kind: "cell", row: 0, col: "task" };
1015
+ return;
369
1016
  }
370
- const desc = truncateToWidth(task.description, remaining, "");
371
- if (isSelected) {
372
- return theme.fg("accent", theme.bold(`${nameOnly}${pad}${desc}`));
1017
+ if (cursor.kind === "cell" && cursor.row >= itemRows.length) {
1018
+ cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
373
1019
  }
374
- return `${nameOnly}${pad}${theme.fg("muted", desc)}`;
375
1020
  }
376
1021
 
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();
1022
+ // --- Table geometry ---------------------------------------------------
1023
+ // Three tiers by width:
1024
+ // >= 76: # TASK DESCRIPTION ORDER
1025
+ // 58-75: # TASK ORDER
1026
+ // < 58: # TASK (chain shown on its own line below the list)
1027
+ const ORDER_COL_W = 5;
1028
+ const TASK_COL_W = 24;
1029
+ type Tier = "full" | "compact" | "narrow";
1030
+ function tierFor(width: number): Tier {
1031
+ if (width >= 76) return "full";
1032
+ if (width >= 58) return "compact";
1033
+ return "narrow";
1034
+ }
1035
+ // Column geometry: [2] # [3] [taskCol] [descCol] [ORDER_COL_W] [1]
1036
+ function tableGeometry(width: number) {
1037
+ const tier = tierFor(width);
1038
+ const taskCol = tier === "full" ? TASK_COL_W : Math.max(10, width - 2 - 3 - 2 - 2 - ORDER_COL_W - 1);
1039
+ const descCol = tier === "full" ? Math.max(8, width - 2 - 3 - 2 - TASK_COL_W - 2 - 2 - ORDER_COL_W - 1) : 0;
1040
+ // The ORDER cell is the last ORDER_COL_W characters of the line
1041
+ // (the line is width-2 chars wide), so it starts at width-2-W.
1042
+ const orderColX = tier === "narrow" ? null : width - 2 - ORDER_COL_W;
1043
+ return { tier, taskCol, descCol, orderColX };
1044
+ }
1045
+
1046
+ // Cached last buildTable result (avoids double-work on mouse hit-test).
1047
+ // Invalidated whenever chain/filter changes (handledInput calls
1048
+ // requestRender, which re-renders before any subsequent mouse event).
1049
+ let lastTable: ReturnType<typeof buildTable> | null = null;
1050
+
1051
+ // Build the full selector output for a width, plus the line map for
1052
+ // mouse handling (itemLine: line -> task, runLine: the Run row,
1053
+ // orderColX: where the ORDER cell starts, or null in the narrow tier).
1054
+ function buildTable(width: number) {
1055
+ const { bodyRows, itemRows, winStart, visibleHeaderNames } = getVisible();
1056
+ const { tier, taskCol, descCol, orderColX } = tableGeometry(width);
381
1057
  const lines: string[] = [];
382
1058
  const itemLine = new Map<number, DoAlwaysTask>();
383
- lines.push(theme.fg("accent", theme.bold(" do-always — pick a task")));
384
- lines.push("");
1059
+ let runLine = -1;
1060
+ // Cursor marker: a large triangle in the accent color. The row
1061
+ // background alone can be invisible (some themes map selectedBg
1062
+ // to the terminal's default background), so the marker carries
1063
+ // the cursor.
1064
+ const cursorMark = theme.fg("accent", "►");
1065
+
1066
+ // Header.
1067
+ lines.push(
1068
+ theme.fg(
1069
+ "muted",
1070
+ truncateToWidth(
1071
+ // The 6-char prefix (" # ") lines the header up with
1072
+ // the body rows (2-digit number + 2 spaces).
1073
+ tier === "full"
1074
+ ? ` # ${"TASK".padEnd(taskCol)} ${"DESCRIPTION".padEnd(descCol)} ${"ORDER".padEnd(ORDER_COL_W)}`
1075
+ : tier === "compact"
1076
+ ? ` # ${"TASK".padEnd(taskCol)} ${"ORDER".padEnd(ORDER_COL_W)}`
1077
+ : " # TASK",
1078
+ width - 2,
1079
+ "",
1080
+ ),
1081
+ ),
1082
+ );
1083
+
385
1084
  if (itemRows.length === 0) {
386
1085
  lines.push(theme.fg("warning", " No matching tasks"));
387
1086
  } else {
388
- for (const row of rows) {
1087
+ let itemShown = 0;
1088
+ // O(n) index map so findIndex → O(1) lookup.
1089
+ const idxMap = new Map(itemRows.map((x, i) => [x.task, i]));
1090
+ for (const row of bodyRows) {
389
1091
  if (row.kind === "header") {
390
- if (!visibleHeaderNames.has(row.name)) continue;
391
- lines.push(theme.fg("accent", theme.bold(` ${row.name.toUpperCase()}`)));
1092
+ if (!visibleHeaderNames.has(row.name ?? "")) continue;
1093
+ if (itemShown >= maxVisible) break;
1094
+ lines.push(
1095
+ theme.fg("accent", theme.bold(truncateToWidth(` ${(row.name ?? "").toUpperCase()}`, width - 2, ""))),
1096
+ );
392
1097
  continue;
393
1098
  }
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);
1099
+ if (!row.task) continue;
1100
+ const idx = idxMap.get(row.task);
1101
+ if (idx === undefined || idx < winStart || idx >= winStart + maxVisible) continue;
1102
+ itemShown++;
1103
+ const task = row.task;
1104
+ const auto = shouldAutoRun(task);
1105
+ const focused = cursor.kind === "cell" && cursor.row === idx;
1106
+ // Cursor markers: ► in the left gutter = TASK column, ► in the
1107
+ // ORDER cell = ORDER column. The row background is applied too,
1108
+ // but some themes map selectedBg to a color that is nearly
1109
+ // indistinguishable from the terminal background, so the
1110
+ // character marker is the reliable indicator.
1111
+ const inTaskCol =
1112
+ cursor.kind === "cell" && cursor.row === idx && cursor.col === "task";
1113
+ const inOrderCol =
1114
+ cursor.kind === "cell" && cursor.row === idx && cursor.col === "order";
1115
+ const num = inTaskCol
1116
+ ? `${cursorMark} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
1117
+ : ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
1118
+ // ⚡ is 2 columns wide, so "⚡ " takes 3 — reserve it so
1119
+ // auto-run rows align with the others (ORDER cell is
1120
+ // hit-tested at a fixed x).
1121
+ const name = truncateToWidth(task.name, taskCol - (auto ? 3 : 0), "…", true);
1122
+ const taskCell = (auto ? "⚡ " : "") + name;
1123
+ // Every ORDER cell is exactly ORDER_COL_W wide so the column
1124
+ // stays aligned (and mouse hit-testing stays exact).
1125
+ const orderCell =
1126
+ row.order !== undefined
1127
+ ? inOrderCol
1128
+ ? truncateToWidth(`${cursorMark}[${row.order}]`, ORDER_COL_W, "", true)
1129
+ : ` [${row.order}] `
1130
+ : inOrderCol
1131
+ ? `${cursorMark} · `
1132
+ : " · ";
1133
+ let line: string;
1134
+ if (tier === "full") {
1135
+ const desc = truncateToWidth(task.description ?? "", descCol, "…", true);
1136
+ line = `${num} ${taskCell} ${desc} ${orderCell}`;
1137
+ } else if (tier === "compact") {
1138
+ line = `${num} ${taskCell} ${orderCell}`;
1139
+ } else {
1140
+ line = truncateToWidth(`${num} ${taskCell}`, width - 2, "…");
1141
+ }
1142
+ // Full-row background + bold where the theme makes it visible;
1143
+ // the ► gutter/cell marker carries the cursor either way.
1144
+ if (focused) line = theme.bg("selectedBg", theme.bold(line));
1145
+ lines.push(line);
1146
+ itemLine.set(lines.length - 1, task);
399
1147
  }
400
1148
  if (itemRows.length > maxVisible) {
401
- const hint = ` (${selectedIndex + 1}/${itemRows.length})`;
402
- lines.push(theme.fg("dim", truncateToWidth(hint, width - 2, "")));
1149
+ const anchor = cursor.kind === "cell" ? cursor.row : lastCellRow;
1150
+ lines.push(theme.fg("dim", truncateToWidth(` (${anchor + 1}/${itemRows.length})`, width - 2, "")));
1151
+ }
1152
+ // Narrow tier: the chain gets its own line instead of a column.
1153
+ if (tier === "narrow" && chain.items.length > 0) {
1154
+ lines.push(
1155
+ theme.fg("dim", truncateToWidth(` chain: ${formatChainSequence(visibleTasks, chain)}`, width - 2, "…")),
1156
+ );
403
1157
  }
404
1158
  }
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];
1159
+
1160
+ // Prompt preview: revealed after the cursor has been stable on a
1161
+ // task row for PREVIEW_DELAY_MS, showing exactly what will be
1162
+ // injected.
1163
+ if (previewVisible && cursor.kind === "cell" && cursor.col === "task") {
1164
+ const sel = itemRows[cursor.row];
409
1165
  if (sel) {
410
1166
  const wrapWidth = Math.max(10, width - 4);
411
- // Show the rendered prompt — exactly what will be injected.
412
1167
  const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, strings), wrapWidth);
413
1168
  const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
414
1169
  const truncated = wrapped.length > PREVIEW_MAX_LINES;
@@ -421,60 +1176,192 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
421
1176
  });
422
1177
  }
423
1178
  }
1179
+
1180
+ // Pinned Run row (always visible, outside the scroll window).
424
1181
  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";
1182
+ lines.push(theme.fg("dim", " " + "─".repeat(Math.max(1, width - 4))));
1183
+ const runLabel = chainRunLabel(chain.items.length);
1184
+ runLine = lines.length;
1185
+ // No play glyph in the label: the ► cursor marker is the only
1186
+ // ">" and it appears only while the row is selected (like task rows).
1187
+ if (cursor.kind === "run") {
1188
+ lines.push(theme.bg("selectedBg", theme.bold(`${cursorMark} ${runLabel}`)));
1189
+ } else if (chain.items.length === 0) {
1190
+ lines.push(theme.fg("dim", ` ${runLabel}`));
1191
+ } else {
1192
+ lines.push(theme.fg("accent", ` ${runLabel}`));
1193
+ }
1194
+
1195
+ // Context-sensitive footer.
1196
+ let footer: string;
1197
+ if (cursor.kind === "run") {
1198
+ footer =
1199
+ chain.items.length > 0
1200
+ ? ` ⏎ run: ${formatChainSequence(visibleTasks, chain)}`
1201
+ : " ⏎ run the chain (chain is empty)";
1202
+ } else if (cursor.col === "order") {
1203
+ const inChain = chain.items.includes(itemRows[cursor.row]?.task.name ?? "");
1204
+ footer = inChain ? " ← tasks • ⏎ remove • esc" : " ← tasks • ⏎ add • esc";
1205
+ } else {
1206
+ footer = " 1-9 run now • ⏎ select • → order • ⌫ undo • esc";
1207
+ }
1208
+ if (chain.items.length > 0 && cursor.kind !== "run") footer += " • ctrl+u clear";
429
1209
  lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
430
- return { lines, itemLine, itemRows };
1210
+
1211
+ return { lines, itemLine, runLine, orderColX };
431
1212
  }
432
1213
 
433
1214
  return {
434
1215
  render(width: number) {
435
- return buildRender(width).lines;
1216
+ lastTable = buildTable(width);
1217
+ return lastTable!.lines;
436
1218
  },
437
1219
  invalidate() {},
438
1220
  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.
1221
+ // Direct pick by number (1-9) — runs the task immediately (the
1222
+ // classic fast path), closing the selector and discarding the
1223
+ // chain. Only when not filtering, and within the visible set,
1224
+ // so digits pick a visible task by its number.
441
1225
  if (!filter && /^[1-9]$/.test(data) && Number(data) <= visibleTasks.length) {
442
- finish(visibleTasks[Number(data) - 1]);
1226
+ finishSingle(visibleTasks[Number(data) - 1]);
443
1227
  return;
444
1228
  }
445
- // Filter typing.
1229
+ // Filter typing (Backspace edits the filter; with an empty
1230
+ // filter it undoes the last chain add).
446
1231
  if (kb.matches(data, "tui.editor.deleteCharBackward")) {
447
- filter = filter.slice(0, -1);
448
- selectedIndex = 0;
1232
+ if (filter.length > 0) {
1233
+ filter = filter.slice(0, -1);
1234
+ clampCursor();
1235
+ } else {
1236
+ const { state, removed } = chainUndo(chain);
1237
+ if (removed) {
1238
+ chain = state;
1239
+ clampCursor();
1240
+ }
1241
+ }
449
1242
  resetPreview();
450
1243
  tui.requestRender();
451
1244
  return;
452
1245
  }
453
1246
  if (isPrintable(data)) {
454
1247
  filter += data;
455
- selectedIndex = 0;
1248
+ clampCursor();
456
1249
  resetPreview();
457
1250
  tui.requestRender();
458
1251
  return;
459
1252
  }
460
- // Navigation / confirmation.
461
1253
  const { itemRows } = getVisible();
1254
+ if (itemRows.length === 0) {
1255
+ // Nothing to navigate; only Esc is useful here.
1256
+ if (kb.matches(data, "tui.select.cancel")) finishCancel();
1257
+ return;
1258
+ }
1259
+ // Column switching.
1260
+ if (matchesKey(data, "left")) {
1261
+ if (cursor.kind === "run") {
1262
+ cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
1263
+ } else if (cursor.col === "order") {
1264
+ cursor = { kind: "cell", row: cursor.row, col: "task" };
1265
+ }
1266
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
1267
+ resetPreview();
1268
+ tui.requestRender();
1269
+ return;
1270
+ }
1271
+ if (matchesKey(data, "right")) {
1272
+ if (cursor.kind === "cell" && cursor.col === "task") {
1273
+ // Same row: the ORDER cell shows whether this task is
1274
+ // in the chain, and Enter toggles it.
1275
+ cursor = { kind: "cell", row: cursor.row, col: "order" };
1276
+ }
1277
+ // (→ in the ORDER column and on the Run row is a no-op:
1278
+ // the cursor is already at the right/bottom edge.)
1279
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
1280
+ resetPreview();
1281
+ tui.requestRender();
1282
+ return;
1283
+ }
1284
+ // Row navigation: the cursor moves in both columns (wrap at
1285
+ // the edges); the Run row is reached from the last task row.
462
1286
  if (kb.matches(data, "tui.select.up")) {
463
- selectedIndex = selectedIndex === 0 ? itemRows.length - 1 : selectedIndex - 1;
1287
+ if (cursor.kind === "run") {
1288
+ cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
1289
+ } else {
1290
+ cursor = {
1291
+ kind: "cell",
1292
+ row: cursor.row === 0 ? itemRows.length - 1 : cursor.row - 1,
1293
+ col: cursor.col,
1294
+ };
1295
+ }
1296
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
464
1297
  resetPreview();
465
1298
  tui.requestRender();
1299
+ return;
466
1300
  }
467
- else if (kb.matches(data, "tui.select.down")) {
468
- selectedIndex = selectedIndex === itemRows.length - 1 ? 0 : selectedIndex + 1;
1301
+ if (kb.matches(data, "tui.select.down")) {
1302
+ if (cursor.kind === "run") {
1303
+ cursor = { kind: "cell", row: 0, col: "task" };
1304
+ } else if (cursor.row === itemRows.length - 1) {
1305
+ // The pinned Run row sits below the last task row.
1306
+ cursor = { kind: "run" };
1307
+ lastCellRow = itemRows.length - 1;
1308
+ } else {
1309
+ cursor = {
1310
+ kind: "cell",
1311
+ row: cursor.row + 1,
1312
+ col: cursor.col,
1313
+ };
1314
+ }
1315
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
469
1316
  resetPreview();
470
1317
  tui.requestRender();
1318
+ return;
471
1319
  }
472
- else if (kb.matches(data, "tui.select.confirm")) {
473
- const chosen = itemRows[selectedIndex];
474
- if (chosen) finish(chosen.task);
1320
+ // Confirm: context-dependent.
1321
+ if (kb.matches(data, "tui.select.confirm")) {
1322
+ if (cursor.kind === "run") {
1323
+ if (chain.items.length === 0) {
1324
+ ctx.ui.notify("do-always: chain is empty — add a task first", "info");
1325
+ } else {
1326
+ finishChain([...chain.items]);
1327
+ }
1328
+ return;
1329
+ }
1330
+ const row = itemRows[cursor.row];
1331
+ if (!row) return;
1332
+ if (cursor.col === "task") {
1333
+ // The classic pick: run just this task (fill or
1334
+ // auto-run per its autoRun), discarding the chain.
1335
+ finishSingle(row.task);
1336
+ return;
1337
+ }
1338
+ // ORDER column: toggle this task's chain membership.
1339
+ if (chain.items.includes(row.task.name)) {
1340
+ chain = chainRemove(chain, row.task.name);
1341
+ } else {
1342
+ const { state, result } = chainAdd(chain, row.task.name);
1343
+ chain = state;
1344
+ if (result === "full") {
1345
+ ctx.ui.notify(`do-always: chain is full (${CHAIN_MAX}) — remove a task first`, "error");
1346
+ }
1347
+ }
1348
+ lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
1349
+ resetPreview();
1350
+ tui.requestRender();
1351
+ return;
475
1352
  }
476
- else if (kb.matches(data, "tui.select.cancel")) {
477
- finish(null);
1353
+ // Clear the chain.
1354
+ if (matchesKey(data, "ctrl+u")) {
1355
+ if (chain.items.length > 0) {
1356
+ chain = chainClear();
1357
+ clampCursor();
1358
+ resetPreview();
1359
+ tui.requestRender();
1360
+ }
1361
+ return;
1362
+ }
1363
+ if (kb.matches(data, "tui.select.cancel")) {
1364
+ finishCancel();
478
1365
  }
479
1366
  },
480
1367
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
@@ -482,21 +1369,48 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
482
1369
  const { itemRows } = getVisible();
483
1370
  if (itemRows.length === 0) return undefined;
484
1371
  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 };
1372
+ const prev = cursor.kind === "cell" ? cursor.row : lastCellRow;
1373
+ const next = Math.max(0, Math.min(itemRows.length - 1, prev + delta));
1374
+ if (next === prev) return { handled: true };
1375
+ cursor = { kind: "cell", row: next, col: "task" };
1376
+ lastCellRow = next;
1377
+ resetPreview();
1378
+ return { handled: true, render: true };
489
1379
  }
490
1380
  if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
491
- const { itemLine, itemRows } = buildRender(event.width);
1381
+ // Reuse the last render's table for hit-testing (avoids
1382
+ // rebuilding the table twice per mouse event). The table is
1383
+ // always fresh because handleInput calls requestRender before
1384
+ // the next mouse event can arrive.
1385
+ const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width);
1386
+ // Pinned Run row: press runs the chain.
1387
+ if (runLine >= 0 && event.y === runLine) {
1388
+ if (event.type === "press" && chain.items.length > 0) {
1389
+ finishChain([...chain.items]);
1390
+ }
1391
+ return { handled: true };
1392
+ }
492
1393
  const task = itemLine.get(event.y);
493
1394
  if (!task) return undefined;
1395
+ const { itemRows } = getVisible();
494
1396
  const idx = itemRows.findIndex((r) => r.task === task);
495
1397
  if (idx < 0) return undefined;
1398
+ // ORDER cell: press toggles chain membership.
1399
+ if (orderColX !== null && event.x >= orderColX) {
1400
+ if (event.type === "press") {
1401
+ chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
1402
+ clampCursor();
1403
+ resetPreview();
1404
+ return { handled: true, render: true };
1405
+ }
1406
+ return { handled: true }; // swallow the click after the press action
1407
+ }
1408
+ // Task area: press selects, click runs (the classic fast path).
496
1409
  if (event.type === "press") {
497
1410
  mousePressedIndex = idx;
498
- if (selectedIndex !== idx) {
499
- selectedIndex = idx;
1411
+ if (cursor.kind !== "cell" || cursor.row !== idx) {
1412
+ cursor = { kind: "cell", row: idx, col: "task" };
1413
+ lastCellRow = idx;
500
1414
  resetPreview();
501
1415
  }
502
1416
  return { handled: true, focus: true, render: true };
@@ -504,14 +1418,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
504
1418
  const clicked = mousePressedIndex ?? idx;
505
1419
  mousePressedIndex = null;
506
1420
  const chosen = itemRows[clicked];
507
- if (chosen) finish(chosen.task);
1421
+ if (chosen) finishSingle(chosen.task);
508
1422
  return { handled: true };
509
1423
  },
510
1424
  };
511
1425
  });
512
1426
 
513
- if (selected === null || selected === undefined) return;
514
- await fillPrompt(tasks[selected], ctx, context);
1427
+ if (!result || result.kind === "cancel") return;
1428
+ if (result.kind === "single") {
1429
+ await fillPrompt(result.task, ctx, context);
1430
+ } else {
1431
+ await runChain(result.names, ctx, cache);
1432
+ }
515
1433
  }
516
1434
 
517
1435
  pi.registerCommand("do-always", {
@@ -536,6 +1454,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
536
1454
  });
537
1455
 
538
1456
  async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
1457
+ lastCtx = ctx;
539
1458
  // Reload when the active directory changes, so switching projects
540
1459
  // mid-session serves the right config instead of stale tasks.
541
1460
  if (ctx.cwd !== loadedCwd) {
@@ -544,19 +1463,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
544
1463
  if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
545
1464
  else console.warn(m);
546
1465
  };
547
- tasks = loadConfig(ctx.cwd, onError).tasks;
1466
+ const config = loadConfig(ctx.cwd, onError);
1467
+ tasks = config.tasks;
1468
+ reportEnabled = config.report;
548
1469
  }
549
1470
 
550
1471
  // One context per command run: shared by visibility filtering, rendering,
551
1472
  // and the completion cache (never inside a render loop).
552
- const context = buildContext(ctx.cwd);
1473
+ const cache = createContextCache();
1474
+ const context = cache.get(ctx.cwd);
553
1475
  const visible = refreshVisible(ctx.cwd, context);
554
1476
 
555
1477
  const arg = args.trim();
556
1478
 
557
1479
  if (!arg) {
558
1480
  if (ctx.mode === "tui") {
559
- await showSelector(ctx, context);
1481
+ await showSelector(ctx, context, cache);
560
1482
  } else {
561
1483
  ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(visible)}`, "info");
562
1484
  }