pi-do-always 0.8.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,58 +34,102 @@
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,
63
+ evaluateGuards,
64
+ evaluateWhen,
65
+ formatChainSequence,
53
66
  formatList,
54
67
  groupTasksByCategory,
55
68
  isValidKeyId,
56
69
  mergeTasks,
57
70
  parseConfig,
71
+ parseStatusPorcelain,
58
72
  orderTasksByCategory,
73
+ reportAbandonedFooter,
74
+ reportFooter,
75
+ reportHeader,
76
+ reportStepSection,
77
+ reportWorthKeeping,
59
78
  renderPrompt,
60
- evaluateGuards,
61
- evaluateWhen,
79
+ resolveReportPath,
62
80
  resolveShortcut,
63
81
  resolveTask,
64
82
  shouldAutoRun,
83
+ toPromptContext,
84
+ validateChain,
65
85
  type DoAlwaysTask,
66
- type PromptContext,
67
- type TaskGroup,
86
+ type TaskContext,
87
+ type TableRow,
68
88
  } from "./tasks";
69
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
+
70
105
  /**
71
106
  * Load tasks and the selector shortcut from config files.
72
- * Project-local tasks override global tasks with the same name; new ones are appended.
73
- * Falls back to DEFAULT_TASKS when nothing is defined.
107
+ * Project-local tasks override global tasks with the same name (or are
108
+ * appended, per the `merge` field); new ones are appended. Falls back to
109
+ * DEFAULT_TASKS when nothing is defined.
110
+ *
111
+ * Validation problems (malformed JSON, invalid tasks/shortcut/merge/when/
112
+ * guards) are reported through `onError` — callers must wire it up, since
113
+ * the default is a silent no-op.
74
114
  */
75
- function loadConfig(cwd: string): {
115
+ function loadConfig(
116
+ cwd: string,
117
+ onError: (message: string) => void = () => {},
118
+ ): {
76
119
  tasks: DoAlwaysTask[];
77
120
  shortcut: string | null;
78
- merge: "append" | "override";
121
+ /** Whether chain runs write a Markdown report file (default true). */
122
+ report: boolean;
79
123
  } {
80
124
  const globalPath = join(getAgentDir(), "do-always.json");
81
125
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
82
126
 
83
127
  const global = existsSync(globalPath)
84
- ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath)
85
- : { tasks: [], shortcut: undefined, merge: undefined };
128
+ ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath, onError)
129
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
86
130
  const project = existsSync(projectPath)
87
- ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath)
88
- : { tasks: [], shortcut: undefined, merge: undefined };
131
+ ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath, onError)
132
+ : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
89
133
 
90
134
  // The project file's merge mode wins; otherwise the global value; otherwise
91
135
  // override (the historical behavior), so existing configs are unaffected.
@@ -96,7 +140,9 @@ function loadConfig(cwd: string): {
96
140
  // `/do-always <n>`, and `list` all share one consistent order.
97
141
  tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
98
142
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
99
- merge: mode,
143
+ // The project file's value wins; otherwise the global value; otherwise
144
+ // reports are on.
145
+ report: project.report ?? global.report ?? true,
100
146
  };
101
147
  }
102
148
 
@@ -120,67 +166,96 @@ function git(cwd: string, args: string[]): string | undefined {
120
166
  }
121
167
  }
122
168
 
123
- /** Max number of file paths listed in `files_changed` (the count stays exact). */
124
- const MAX_FILES_LISTED = 20;
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
+ }
125
186
 
126
187
  /**
127
- * Build the prompt context for `renderPrompt`. Always contains every
128
- * PROMPT_CONTEXT_KEYS entry: git facts fall back to neutral values when
129
- * unavailable (non-git dir, no git, empty repo) so default prompts read
130
- * cleanly in any directory.
188
+ * Build the structured context for the current directory. Git facts fall back
189
+ * to neutral values when unavailable (non-git dir, no git, empty repo) so
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
131
198
  */
132
- function buildContext(cwd: string): PromptContext {
133
- const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "unknown";
134
- const lastCommit = git(cwd, ["log", "-1", "--format=%s"]) ?? "unknown";
135
- const user = git(cwd, ["config", "user.name"]) ?? "unknown";
136
-
137
- // repo = bare name of the git remote (owner/repo.git -> repo), falling back
138
- // to the basename of cwd so monorepo work stays disambiguated everywhere.
139
- const remoteUrl = git(cwd, ["config", "--get", "remote.origin.url"]);
199
+ function buildContext(cwd: string): TaskContext {
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;
140
217
  const repo = remoteUrl
141
218
  ? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
142
219
  : cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
143
220
 
144
- const files: string[] = [];
145
- const status = git(cwd, ["status", "--porcelain"]);
146
- if (status) {
147
- for (const line of status.split("\n")) {
148
- // Porcelain v1 lines are "XY <path>" (X = index, Y = worktree).
149
- if (line.length < 4) continue;
150
- const path = line.slice(3);
151
- if (path && !files.includes(path)) files.push(path);
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
+ }
152
238
  }
153
- files.sort();
154
- }
155
- let listed: string;
156
- if (files.length === 0) {
157
- listed = "none";
158
- } else if (files.length > MAX_FILES_LISTED) {
159
- listed = [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
160
- } else {
161
- listed = files.join(", ");
162
239
  }
163
240
 
241
+ // 5. diff --shortstat.
242
+ const diffStat = isGitRepo ? git(cwd, ["diff", "--shortstat"]) : undefined;
243
+
164
244
  return {
165
245
  cwd,
166
246
  date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
167
247
  branch,
168
- last_commit: lastCommit,
169
- files_changed: listed,
170
- files_changed_count: String(files.length),
171
- user,
172
- diff_stat: git(cwd, ["diff", "--shortstat"]) ?? "none",
248
+ lastCommit,
249
+ files: parseStatusPorcelain(porcelain ?? ""),
250
+ user: user ?? "unknown",
251
+ diffStat: diffStat ?? "none",
173
252
  repo,
174
- staged_files: git(cwd, ["diff", "--cached", "--name-only"]) ?? "none",
175
- unstaged_files: git(cwd, ["diff", "--name-only"]) ?? "none",
253
+ stagedFiles,
254
+ unstagedFiles,
255
+ isGitRepo,
176
256
  };
177
257
  }
178
258
 
179
- /** Find the group (among `groups`) that contains a task. */
180
- function findGroupOf(task: DoAlwaysTask, groups: TaskGroup[]): TaskGroup | undefined {
181
- return groups.find((g) => g.items.includes(task));
182
- }
183
-
184
259
  /** True for a single printable ASCII character (used for filter typing). */
185
260
  function isPrintable(data: string): boolean {
186
261
  return data.length === 1 && data >= " " && data <= "~";
@@ -191,28 +266,450 @@ const PREVIEW_DELAY_MS = 2000;
191
266
  /** Max lines of the prompt shown in the selector preview. */
192
267
  const PREVIEW_MAX_LINES = 3;
193
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
+
194
293
  export default function doAlwaysExtension(pi: ExtensionAPI) {
195
294
  let tasks: DoAlwaysTask[] = [];
196
295
  let loadedCwd = ""; // cwd the cached `tasks` were loaded for
296
+ // The visible (when-filtered) list for the last context we built, so
297
+ // argument completions number tasks the same way the selector and
298
+ // `/do-always <n>` do. When stale (or absent), completions fall back to
299
+ // the full list rather than guessing.
300
+ let visibleCache: { cwd: string; visible: DoAlwaysTask[] } | null = null;
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
+
654
+ /** Filter tasks by their `when` condition and refresh the completion cache. */
655
+ function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
656
+ const visible = tasks.filter((t) => evaluateWhen(t, context));
657
+ visibleCache = { cwd, visible };
658
+ return visible;
659
+ }
660
+
661
+ /**
662
+ * Register the selector shortcut from a resolved config value (null
663
+ * disables it). Called from session_start so it reads the session's cwd,
664
+ * not the process cwd; re-registering the same key is idempotent.
665
+ */
666
+ function registerShortcut(shortcut: string | null, onError: (message: string) => void): void {
667
+ if (shortcut === null) return;
668
+ const shortcutKey = isValidKeyId(shortcut) ? shortcut : DEFAULT_SHORTCUT;
669
+ if (shortcutKey !== shortcut) {
670
+ onError(`do-always: invalid shortcut "${shortcut}" in do-always.json — using ${DEFAULT_SHORTCUT}`);
671
+ }
672
+ pi.registerShortcut(shortcutKey as KeyId, {
673
+ description: "do-always: pick a common task",
674
+ handler: async (ctx) => {
675
+ await runDoAlways("", ctx);
676
+ },
677
+ });
678
+ }
197
679
 
198
- pi.on("session_start", async (_event, ctx) => {
680
+ pi.on("session_start", (_event, ctx) => {
681
+ lastCtx = ctx;
682
+ // Surface config validation problems (the README promises warnings);
683
+ // in non-TUI modes there is no UI, so fall back to the console.
684
+ const onError = (m: string) => {
685
+ if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
686
+ else console.warn(m);
687
+ };
688
+ // A failed chain's status widget is a trace of the previous session;
689
+ // start each session clean.
690
+ clearChainWidget(ctx);
199
691
  loadedCwd = ctx.cwd;
200
- tasks = loadConfig(ctx.cwd).tasks;
692
+ const config = loadConfig(ctx.cwd, onError);
693
+ tasks = config.tasks;
694
+ reportEnabled = config.report;
695
+ refreshVisible(ctx.cwd, buildContext(ctx.cwd));
696
+ registerShortcut(config.shortcut, onError);
201
697
  });
202
698
 
203
699
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
204
- async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext): Promise<void> {
205
- // Render the prompt with the current context (branch, changed files, …)
206
- // so the injected text matches this directory at this moment.
207
- const context = buildContext(ctx.cwd);
700
+ async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
208
701
  const blocked = evaluateGuards(task, context);
209
702
  if (blocked) {
210
703
  ctx.ui.notify(`do-always: ${blocked}`, "info");
211
704
  return;
212
705
  }
213
- const prompt = renderPrompt(task.prompt, context);
706
+ // Render with the same context the selector/preview used, so what the
707
+ // user saw is exactly what gets injected.
708
+ const prompt = renderPrompt(task.prompt, toPromptContext(context));
214
709
  if (shouldAutoRun(task)) {
215
- 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);
216
713
  ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
217
714
  return;
218
715
  }
@@ -220,23 +717,192 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
220
717
  ctx.ui.setEditorText(prompt);
221
718
  ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
222
719
  } else {
223
- await pi.sendUserMessage(prompt);
720
+ pi.sendUserMessage(prompt);
721
+ }
722
+ }
723
+
724
+ /**
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.
732
+ */
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");
224
875
  }
225
876
  }
226
877
 
227
878
  /**
228
- * Numbered selector with categorized sections. Press 1-9 to pick by global
229
- * number, type to filter, or navigate with arrows + Enter, Esc to cancel.
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`.
230
894
  */
231
- async function showSelector(ctx: ExtensionContext): Promise<void> {
232
- // Build the context once per selector session (never inside the render
233
- // loop — no process spawning per frame). fillPrompt re-renders at
234
- // selection time, so a few seconds of drift is acceptable.
235
- const context = buildContext(ctx.cwd);
895
+ async function showSelector(
896
+ ctx: ExtensionContext,
897
+ context: TaskContext,
898
+ cache: ReturnType<typeof createContextCache>,
899
+ ): Promise<void> {
236
900
  // Filter by the `when` condition once per session, so hidden tasks never
237
901
  // appear, are never numbered, and can't be picked.
238
902
  const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
239
- const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
903
+ // String view for prompt rendering (derived once, used by the preview).
904
+ const strings = toPromptContext(context);
905
+ const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
240
906
  let settled = false;
241
907
  let previewVisible = false;
242
908
  let previewTimer: ReturnType<typeof setTimeout> | null = null;
@@ -248,16 +914,28 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
248
914
  }
249
915
  }
250
916
 
251
- // `finish` receives the chosen task (or null) and translates it to the
252
- // full index `tasks[selected]` expects. Reference-based, so it stays
253
- // correct while a text filter is active (itemRows is then a subset of
254
- // visibleTasks and positional indices would point at the wrong task).
255
- 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) {
256
922
  if (settled) return;
257
923
  settled = true;
258
924
  clearPreviewTimer();
259
- done(task ? tasks.indexOf(task) : null);
260
- };
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
+ }
261
939
 
262
940
  // The prompt preview appears only after the selection has been stable
263
941
  // for PREVIEW_DELAY_MS; any change hides it and restarts the delay.
@@ -279,7 +957,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
279
957
  const kb = getKeybindings();
280
958
  const maxVisible = 12;
281
959
  let filter = "";
282
- let selectedIndex = 0;
960
+ let chain = chainClear();
961
+ let cursor: Cursor = { kind: "cell", row: 0, col: "task" };
962
+ let lastCellRow = 0;
283
963
  let mousePressedIndex: number | null = null;
284
964
 
285
965
  // Arm the preview timer for the initial selection.
@@ -295,93 +975,196 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
295
975
  );
296
976
  };
297
977
 
298
- // Recompute the visible (filtered, grouped) rows on every render so
299
- // 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.
300
980
  function getVisible() {
301
- const visibleGroups = groups
302
- .map((g) => ({ name: g.name, items: g.items.filter(matchesFilter) }))
303
- .filter((g) => g.items.length > 0);
304
- const rows: Array<
305
- | { kind: "header"; name: string }
306
- | { kind: "item"; task: DoAlwaysTask; group: string }
307
- > = [];
308
- for (const g of visibleGroups) {
309
- rows.push({ kind: "header", name: g.name });
310
- 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
+ }
311
993
  }
312
- const itemRows = rows.filter((r): r is (typeof rows)[number] & { kind: "item" } => r.kind === "item");
313
- // Clamp selection to the visible item count.
314
- selectedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, itemRows.length - 1)));
315
- // Visible item window with scrolling.
316
- const winStart = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), Math.max(0, itemRows.length - maxVisible)));
317
- const visibleItemKeys = new Set(itemRows.slice(winStart, winStart + maxVisible).map((r) => r.task));
318
- const visibleHeaderNames = new Set([...visibleItemKeys].map((t) => findGroupOf(t, visibleGroups)?.name ?? ""));
319
- 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 };
320
1006
  }
321
1007
 
322
- const labelCol = 26;
323
-
324
- function renderLabel(task: DoAlwaysTask, globalIndex: number, isSelected: boolean, width: number): string {
325
- const prefix = isSelected ? "▸ " : " ";
326
- const marker = shouldAutoRun(task) ? "⚡ " : "";
327
- const label = `${prefix}${globalIndex + 1}. ${marker}${task.name}`;
328
- if (!task.description) {
329
- const line = truncateToWidth(label, Math.max(1, width - 2), "");
330
- return isSelected ? theme.fg("accent", theme.bold(line)) : line;
331
- }
332
- // Width-aware two-column layout; fall back to label-only when the
333
- // terminal is too narrow to fit a description column.
334
- const effCol = Math.max(1, Math.min(labelCol, width - 8));
335
- const nameOnly = truncateToWidth(label, effCol, "");
336
- const pad = " ".repeat(Math.max(1, effCol - visibleWidth(nameOnly)));
337
- const remaining = width - visibleWidth(nameOnly) - pad.length - 2;
338
- if (remaining < 10) {
339
- const line = truncateToWidth(label, Math.max(1, width - 2), "");
340
- 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;
341
1016
  }
342
- const desc = truncateToWidth(task.description, remaining, "");
343
- if (isSelected) {
344
- 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" };
345
1019
  }
346
- return `${nameOnly}${pad}${theme.fg("muted", desc)}`;
347
1020
  }
348
1021
 
349
- // Build the full selector output for a width, plus a map from line
350
- // index to task for the item rows (used by mouse handling).
351
- function buildRender(width: number) {
352
- 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);
353
1057
  const lines: string[] = [];
354
1058
  const itemLine = new Map<number, DoAlwaysTask>();
355
- lines.push(theme.fg("accent", theme.bold(" do-always — pick a task")));
356
- 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
+
357
1084
  if (itemRows.length === 0) {
358
1085
  lines.push(theme.fg("warning", " No matching tasks"));
359
1086
  } else {
360
- 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) {
361
1091
  if (row.kind === "header") {
362
- if (!visibleHeaderNames.has(row.name)) continue;
363
- 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
+ );
364
1097
  continue;
365
1098
  }
366
- if (!visibleItemKeys.has(row.task)) continue;
367
- const globalIndex = visibleTasks.indexOf(row.task);
368
- const isSelected = row.task === itemRows[selectedIndex].task;
369
- lines.push(renderLabel(row.task, globalIndex, isSelected, width));
370
- 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);
371
1147
  }
372
1148
  if (itemRows.length > maxVisible) {
373
- const hint = ` (${selectedIndex + 1}/${itemRows.length})`;
374
- 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
+ );
375
1157
  }
376
1158
  }
377
- // Prompt preview: revealed after the selection has been stable for
378
- // PREVIEW_DELAY_MS, showing exactly what will be injected.
379
- if (previewVisible) {
380
- 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];
381
1165
  if (sel) {
382
1166
  const wrapWidth = Math.max(10, width - 4);
383
- // Show the rendered prompt — exactly what will be injected.
384
- const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, context), wrapWidth);
1167
+ const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, strings), wrapWidth);
385
1168
  const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
386
1169
  const truncated = wrapped.length > PREVIEW_MAX_LINES;
387
1170
  lines.push("");
@@ -393,60 +1176,192 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
393
1176
  });
394
1177
  }
395
1178
  }
1179
+
1180
+ // Pinned Run row (always visible, outside the scroll window).
396
1181
  lines.push("");
397
- const anyAutoRun = itemRows.some((r) => shouldAutoRun(r.task));
398
- const footer = anyAutoRun
399
- ? " 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs"
400
- : " 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";
401
1209
  lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
402
- return { lines, itemLine, itemRows };
1210
+
1211
+ return { lines, itemLine, runLine, orderColX };
403
1212
  }
404
1213
 
405
1214
  return {
406
1215
  render(width: number) {
407
- return buildRender(width).lines;
1216
+ lastTable = buildTable(width);
1217
+ return lastTable!.lines;
408
1218
  },
409
1219
  invalidate() {},
410
1220
  handleInput(data: string) {
411
- // Direct pick by number (1-9) — only when not filtering, and within
412
- // 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.
413
1225
  if (!filter && /^[1-9]$/.test(data) && Number(data) <= visibleTasks.length) {
414
- finish(visibleTasks[Number(data) - 1]);
1226
+ finishSingle(visibleTasks[Number(data) - 1]);
415
1227
  return;
416
1228
  }
417
- // Filter typing.
1229
+ // Filter typing (Backspace edits the filter; with an empty
1230
+ // filter it undoes the last chain add).
418
1231
  if (kb.matches(data, "tui.editor.deleteCharBackward")) {
419
- filter = filter.slice(0, -1);
420
- 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
+ }
421
1242
  resetPreview();
422
1243
  tui.requestRender();
423
1244
  return;
424
1245
  }
425
1246
  if (isPrintable(data)) {
426
1247
  filter += data;
427
- selectedIndex = 0;
1248
+ clampCursor();
428
1249
  resetPreview();
429
1250
  tui.requestRender();
430
1251
  return;
431
1252
  }
432
- // Navigation / confirmation.
433
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.
434
1286
  if (kb.matches(data, "tui.select.up")) {
435
- 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;
436
1297
  resetPreview();
437
1298
  tui.requestRender();
1299
+ return;
438
1300
  }
439
- else if (kb.matches(data, "tui.select.down")) {
440
- 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;
441
1316
  resetPreview();
442
1317
  tui.requestRender();
1318
+ return;
443
1319
  }
444
- else if (kb.matches(data, "tui.select.confirm")) {
445
- const chosen = itemRows[selectedIndex];
446
- 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;
1352
+ }
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;
447
1362
  }
448
- else if (kb.matches(data, "tui.select.cancel")) {
449
- finish(null);
1363
+ if (kb.matches(data, "tui.select.cancel")) {
1364
+ finishCancel();
450
1365
  }
451
1366
  },
452
1367
  handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
@@ -454,21 +1369,48 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
454
1369
  const { itemRows } = getVisible();
455
1370
  if (itemRows.length === 0) return undefined;
456
1371
  const delta = event.wheelDelta < 0 ? -1 : 1;
457
- const prev = selectedIndex;
458
- selectedIndex = Math.max(0, Math.min(itemRows.length - 1, selectedIndex + delta));
459
- if (selectedIndex !== prev) resetPreview();
460
- 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 };
461
1379
  }
462
1380
  if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
463
- 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
+ }
464
1393
  const task = itemLine.get(event.y);
465
1394
  if (!task) return undefined;
1395
+ const { itemRows } = getVisible();
466
1396
  const idx = itemRows.findIndex((r) => r.task === task);
467
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).
468
1409
  if (event.type === "press") {
469
1410
  mousePressedIndex = idx;
470
- if (selectedIndex !== idx) {
471
- selectedIndex = idx;
1411
+ if (cursor.kind !== "cell" || cursor.row !== idx) {
1412
+ cursor = { kind: "cell", row: idx, col: "task" };
1413
+ lastCellRow = idx;
472
1414
  resetPreview();
473
1415
  }
474
1416
  return { handled: true, focus: true, render: true };
@@ -476,24 +1418,33 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
476
1418
  const clicked = mousePressedIndex ?? idx;
477
1419
  mousePressedIndex = null;
478
1420
  const chosen = itemRows[clicked];
479
- if (chosen) finish(chosen.task);
1421
+ if (chosen) finishSingle(chosen.task);
480
1422
  return { handled: true };
481
1423
  },
482
1424
  };
483
1425
  });
484
1426
 
485
- if (selected === null || selected === undefined) return;
486
- await fillPrompt(tasks[selected], ctx);
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
+ }
487
1433
  }
488
1434
 
489
1435
  pi.registerCommand("do-always", {
490
1436
  description: "Pick a common task (review, readme, ...) by number — fills the prompt",
491
1437
  getArgumentCompletions: (prefix) => {
492
1438
  const p = prefix.trim().toLowerCase();
1439
+ // Number tasks by position in the VISIBLE (when-filtered) list — the
1440
+ // same list the selector and `/do-always <n>` use. The cache is
1441
+ // refreshed on session start and every command run; when it is
1442
+ // stale, fall back to the full list rather than guessing.
1443
+ const visible = visibleCache?.cwd === loadedCwd ? visibleCache.visible : tasks;
493
1444
  const matches = [
494
1445
  { value: "list", label: "list" },
495
1446
  { value: "list-details", label: "list-details" },
496
- ...tasks.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
1447
+ ...visible.map((t, i) => ({ value: t.name, label: `${i + 1}. ${t.name}` })),
497
1448
  ].filter((c) => c.value.toLowerCase().includes(p));
498
1449
  return matches.length > 0 ? matches : null;
499
1450
  },
@@ -502,48 +1453,39 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
502
1453
  },
503
1454
  });
504
1455
 
505
- // Keyboard shortcut: open the task selector without typing the command.
506
- // The key is configurable via the "shortcut" field in do-always.json
507
- // (null disables it). It is read once at extension load, so changing it
508
- // requires a reload or a new session.
509
- const configuredShortcut = loadConfig(process.cwd()).shortcut;
510
- if (configuredShortcut !== null) {
511
- const shortcutKey = isValidKeyId(configuredShortcut) ? configuredShortcut : DEFAULT_SHORTCUT;
512
- if (shortcutKey !== configuredShortcut) {
513
- console.warn(`do-always: invalid shortcut "${configuredShortcut}" in do-always.json — using ${DEFAULT_SHORTCUT}`);
514
- }
515
- pi.registerShortcut(shortcutKey as KeyId, {
516
- description: "do-always: pick a common task",
517
- handler: async (ctx) => {
518
- await runDoAlways("", ctx);
519
- },
520
- });
521
- }
522
-
523
1456
  async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
1457
+ lastCtx = ctx;
524
1458
  // Reload when the active directory changes, so switching projects
525
1459
  // mid-session serves the right config instead of stale tasks.
526
1460
  if (ctx.cwd !== loadedCwd) {
527
1461
  loadedCwd = ctx.cwd;
528
- tasks = loadConfig(ctx.cwd).tasks;
1462
+ const onError = (m: string) => {
1463
+ if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
1464
+ else console.warn(m);
1465
+ };
1466
+ const config = loadConfig(ctx.cwd, onError);
1467
+ tasks = config.tasks;
1468
+ reportEnabled = config.report;
529
1469
  }
530
1470
 
1471
+ // One context per command run: shared by visibility filtering, rendering,
1472
+ // and the completion cache (never inside a render loop).
1473
+ const cache = createContextCache();
1474
+ const context = cache.get(ctx.cwd);
1475
+ const visible = refreshVisible(ctx.cwd, context);
1476
+
531
1477
  const arg = args.trim();
532
1478
 
533
1479
  if (!arg) {
534
1480
  if (ctx.mode === "tui") {
535
- await showSelector(ctx);
1481
+ await showSelector(ctx, context, cache);
536
1482
  } else {
537
- const context = buildContext(ctx.cwd);
538
- const visible = tasks.filter((t) => evaluateWhen(t, context));
539
1483
  ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(visible)}`, "info");
540
1484
  }
541
1485
  return;
542
1486
  }
543
1487
 
544
1488
  if (arg.toLowerCase() === "list") {
545
- const context = buildContext(ctx.cwd);
546
- const visible = tasks.filter((t) => evaluateWhen(t, context));
547
1489
  ctx.ui.notify(formatList(visible), "info");
548
1490
  return;
549
1491
  }
@@ -551,14 +1493,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
551
1493
  if (arg.toLowerCase() === "list-details") {
552
1494
  // Display only — the description is metadata; selecting a task injects just its prompt.
553
1495
  // Render with the current context so what is shown is what gets injected.
554
- const context = buildContext(ctx.cwd);
555
- const visible = tasks.filter((t) => evaluateWhen(t, context));
1496
+ const strings = toPromptContext(context);
556
1497
  const details = visible
557
1498
  .map((t, i) => {
558
1499
  const lines = [`${i + 1}. ${t.name}`];
559
1500
  if (t.description) lines.push(` description: ${t.description}`);
560
1501
  lines.push(" prompt (this is what gets injected on select):");
561
- for (const line of renderPrompt(t.prompt, context).split("\n")) lines.push(` ${line}`);
1502
+ for (const line of renderPrompt(t.prompt, strings).split("\n")) lines.push(` ${line}`);
562
1503
  return lines.join("\n");
563
1504
  })
564
1505
  .join("\n\n");
@@ -573,8 +1514,6 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
573
1514
  // Numbers index the VISIBLE list (what the user sees in the selector and
574
1515
  // `list`); names resolve against the full set so picking a hidden task by
575
1516
  // name gets an explanatory message below instead of "unknown task".
576
- const context = buildContext(ctx.cwd);
577
- const visible = tasks.filter((t) => evaluateWhen(t, context));
578
1517
  const task = resolveTask(/^\d+$/.test(arg) ? visible : tasks, arg);
579
1518
  if (!task) {
580
1519
  const available = visible.map((t, i) => `${i + 1}=${t.name}`).join(", ");
@@ -587,6 +1526,6 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
587
1526
  ctx.ui.notify(`do-always: "${task.name}" is hidden by its "when" condition`, "info");
588
1527
  return;
589
1528
  }
590
- await fillPrompt(task, ctx);
1529
+ await fillPrompt(task, ctx, context);
591
1530
  }
592
1531
  }