pi-do-always 0.10.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -33,10 +33,10 @@
33
33
  * If no config file exists, built-in default tasks are used.
34
34
  */
35
35
 
36
- import { execFileSync } from "node:child_process";
36
+ import { execFile } from "node:child_process";
37
37
  import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
38
38
  import { join, relative } from "node:path";
39
- import type { ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
39
+ import type { AgentEndEvent, ExtensionAPI, ExtensionContext, Theme } from "@earendil-works/pi-coding-agent";
40
40
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
41
41
  import {
42
42
  type KeyId,
@@ -59,6 +59,7 @@ import {
59
59
  chainClear,
60
60
  chainRemove,
61
61
  chainRunLabel,
62
+ chainSummary,
62
63
  chainUndo,
63
64
  evaluateGuards,
64
65
  evaluateWhen,
@@ -68,7 +69,10 @@ import {
68
69
  isValidKeyId,
69
70
  mergeTasks,
70
71
  parseConfig,
72
+ parseConfigRegexpValueForKey,
73
+ parseCommitSubject,
71
74
  parseStatusPorcelain,
75
+ parseStatusStagedUnstaged,
72
76
  orderTasksByCategory,
73
77
  reportAbandonedFooter,
74
78
  reportFooter,
@@ -80,8 +84,10 @@ import {
80
84
  resolveShortcut,
81
85
  resolveTask,
82
86
  shouldAutoRun,
87
+ stepSummary,
83
88
  toPromptContext,
84
89
  validateChain,
90
+ type ChainStepOutcome,
85
91
  type DoAlwaysTask,
86
92
  type TaskContext,
87
93
  type TableRow,
@@ -102,6 +108,20 @@ type SelectorResult =
102
108
  */
103
109
  type Cursor = { kind: "cell"; row: number; col: "task" | "order" } | { kind: "run" };
104
110
 
111
+ /**
112
+ * Safely read a config file's contents, returning null if missing or unreadable.
113
+ * Read errors are reported through `onError`.
114
+ */
115
+ function readConfigFile(filePath: string, onError: (message: string) => void): string | null {
116
+ if (!existsSync(filePath)) return null;
117
+ try {
118
+ return readFileSync(filePath, "utf-8");
119
+ } catch (err) {
120
+ onError(`do-always: could not read ${filePath}: ${err}`);
121
+ return null;
122
+ }
123
+ }
124
+
105
125
  /**
106
126
  * Load tasks and the selector shortcut from config files.
107
127
  * Project-local tasks override global tasks with the same name (or are
@@ -124,11 +144,14 @@ function loadConfig(
124
144
  const globalPath = join(getAgentDir(), "do-always.json");
125
145
  const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
126
146
 
127
- const global = existsSync(globalPath)
128
- ? parseConfig(readFileSync(globalPath, "utf-8"), globalPath, onError)
147
+ const globalRaw = readConfigFile(globalPath, onError);
148
+ const projectRaw = readConfigFile(projectPath, onError);
149
+
150
+ const global = globalRaw !== null
151
+ ? parseConfig(globalRaw, globalPath, onError)
129
152
  : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
130
- const project = existsSync(projectPath)
131
- ? parseConfig(readFileSync(projectPath, "utf-8"), projectPath, onError)
153
+ const project = projectRaw !== null
154
+ ? parseConfig(projectRaw, projectPath, onError)
132
155
  : { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
133
156
 
134
157
  // The project file's merge mode wins; otherwise the global value; otherwise
@@ -152,36 +175,25 @@ function loadConfig(
152
175
  * empty repo, …) so callers can fall back to a neutral value.
153
176
  * No shell is involved (argument array), so file names cannot inject commands.
154
177
  */
155
- function git(cwd: string, args: string[]): string | undefined {
156
- try {
157
- const out = execFileSync("git", args, {
158
- cwd,
159
- encoding: "utf-8",
160
- stdio: ["ignore", "pipe", "ignore"],
161
- });
162
- const trimmed = out.trim();
163
- return trimmed === "" ? undefined : trimmed;
164
- } catch {
165
- return undefined;
166
- }
167
- }
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
- };
178
+ function git(cwd: string, args: string[]): Promise<string | undefined> {
179
+ return new Promise((resolve) => {
180
+ // No stdio option: execFile's string-encoding overload doesn't accept
181
+ // it, and stdin/stderr need no special handling here (no input is
182
+ // written; stderr is simply ignored by the callback).
183
+ execFile(
184
+ "git",
185
+ args,
186
+ { cwd, encoding: "utf-8" },
187
+ (error, stdout) => {
188
+ if (error) {
189
+ resolve(undefined);
190
+ return;
191
+ }
192
+ const trimmed = stdout.trim();
193
+ resolve(trimmed === "" ? undefined : trimmed);
194
+ },
195
+ );
196
+ });
185
197
  }
186
198
 
187
199
  /**
@@ -189,57 +201,67 @@ function createContextCache(): {
189
201
  * to neutral values when unavailable (non-git dir, no git, empty repo) so
190
202
  * default prompts read cleanly in any directory.
191
203
  *
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
204
+ * Collects the git facts the prompts interpolate:
205
+ * 1. rev-parse --is-inside-work-tree → isGitRepo
206
+ * 2. branch --show-current → branch (fallback: rev-parse --abbrev-ref HEAD)
207
+ * 3. log -1 --format="%H %s" → commit hash + subject
208
+ * 4. config --get-regexp "^(user.name|remote.origin.url)$" → user + repo
209
+ * 5. status --porcelain → files + stagedFiles + unstagedFiles
210
+ * 6. diff --shortstat → diffStat
211
+ *
212
+ * Outside a work tree only call 1 runs; inside one, calls 2–6 run in
213
+ * parallel, so wall time is about one spawn.
198
214
  */
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;
215
+ async function buildContext(cwd: string): Promise<TaskContext> {
216
+ // 1. Authoritative check: are we inside a work tree?
217
+ // rev-parse --is-inside-work-tree outputs "true" when inside a work tree,
218
+ // and fails (exit != 0) when outside.
219
+ const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
220
+ const isGitRepo = inside === "true";
221
+
222
+ // Outside a work tree the remaining facts don't exist; skip the spawns
223
+ // and return the neutral fallbacks directly.
224
+ if (!isGitRepo) {
225
+ return {
226
+ cwd,
227
+ date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
228
+ branch: "unknown",
229
+ lastCommit: "unknown",
230
+ files: [],
231
+ user: "unknown",
232
+ diffStat: "none",
233
+ repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
234
+ stagedFiles: [],
235
+ unstagedFiles: [],
236
+ isGitRepo: false,
237
+ };
238
+ }
239
+
240
+ // 2–6. In a work tree: query branch and the remaining facts in parallel.
241
+ const [branchOut, lastCommitLine, configLine, porcelain, diffStat] = await Promise.all([
242
+ // branch --show-current works on both normal and unborn (empty repo) branches;
243
+ // returns empty on detached HEAD.
244
+ git(cwd, ["branch", "--show-current"]),
245
+ // "%H %s" prints "<hash> <subject>"; the subject may contain spaces.
246
+ git(cwd, ["log", "-1", "--format=%H %s"]),
247
+ // --get-regexp prints "key value" lines; one call covers both keys.
248
+ git(cwd, ["config", "--get-regexp", "^(user\\.name|remote\\.origin\\.url)$"]),
249
+ git(cwd, ["status", "--porcelain"]),
250
+ git(cwd, ["diff", "--shortstat"]),
251
+ ]);
252
+
253
+ const branch = branchOut || (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])) || "unknown";
254
+ const lastCommit = parseCommitSubject(lastCommitLine);
255
+ const user = parseConfigRegexpValueForKey(configLine, "user.name");
256
+ const remoteUrl = parseConfigRegexpValueForKey(configLine, "remote.origin.url");
217
257
  const repo = remoteUrl
218
258
  ? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
219
259
  : cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
220
260
 
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;
261
+ // status --porcelain gives staged + unstaged in one call.
262
+ // Porcelain v1 lines are "XY <path>" (path starts at index 3); a space
263
+ // in the X column means unstaged, anything else is staged/untracked.
264
+ const { staged: stagedFiles, unstaged: unstagedFiles } = parseStatusStagedUnstaged(porcelain ?? "");
243
265
 
244
266
  return {
245
267
  cwd,
@@ -267,8 +289,6 @@ const PREVIEW_DELAY_MS = 2000;
267
289
  const PREVIEW_MAX_LINES = 3;
268
290
 
269
291
  /** Outcome of one chain step's run (see sendAndWait). */
270
- type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
271
-
272
292
  /**
273
293
  * Status of one chain step for the below-prompt status widget: pending
274
294
  * (not reached yet), running (its turn is in flight), waiting (fill-first:
@@ -319,6 +339,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
319
339
  resolve: (outcome: ChainStepOutcome) => void;
320
340
  /** Which chain step (0-based) this waiter belongs to — for the report. */
321
341
  stepIndex: number;
342
+ startTime?: number;
322
343
  } | null = null;
323
344
 
324
345
  function settleChainWaiter(outcome: ChainStepOutcome) {
@@ -326,6 +347,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
326
347
  const waiter = chainWaiter;
327
348
  chainWaiter = null;
328
349
  if (waiter.timer) clearTimeout(waiter.timer);
350
+ if (waiter.startTime !== undefined) {
351
+ chainDurations[waiter.stepIndex] = performance.now() - waiter.startTime;
352
+ }
329
353
  waiter.resolve(outcome);
330
354
  }
331
355
 
@@ -343,18 +367,49 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
343
367
  // their event waiters (the old chain's sendAndWait would resolve on the
344
368
  // new chain's step), so starting one is refused until the first ends.
345
369
  let chainActive = false;
370
+ // Per-step durations (ms) for the chain-end summary. Indexed by step
371
+ // position; 0 means the step was not timed (e.g. blocked before running).
372
+ let chainDurations: number[] = [];
373
+ // For single auto-run tasks: the task name whose summary we post after
374
+ // agent_end. Only set when a Plan task (auto-run) is sent via
375
+ // pi.sendUserMessage (which does NOT arm a chainWaiter). The grace timer
376
+ // mirrors the chain's failed-to-start handling: a send that fails before
377
+ // the run starts emits no agent events at all, so without it the stale
378
+ // flag would post a spurious summary for the next unrelated turn.
379
+ let pendingSummaryTask: string | null = null;
380
+ let pendingSummaryTimer: NodeJS.Timeout | null = null;
381
+
382
+ /** Clear the auto-run summary flag and its grace timer (session start). */
383
+ function resetPendingSummary(): void {
384
+ pendingSummaryTask = null;
385
+ if (pendingSummaryTimer) {
386
+ clearTimeout(pendingSummaryTimer);
387
+ pendingSummaryTimer = null;
388
+ }
389
+ }
346
390
  // Whether chain runs write a Markdown report file (config `report`,
347
391
  // default true). Refreshed whenever the config is (re)loaded.
348
392
  let reportEnabled = true;
349
393
  // 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).
394
+ // display), the precomputed header (deferred — written together with the
395
+ // first step section, so a chain that dies before that leaves no
396
+ // header-only file behind), whether the file exists on disk yet, when the
397
+ // current step's run actually started (agent_start; null until then and
398
+ // for failed-to-start steps), whether the footer has been appended, the
399
+ // index of the last step section written (dedupes retried runs), and
400
+ // whether any step section carried result text (the file is worth
401
+ // keeping).
402
+ // Max in-memory sections kept for inline display; the file on disk retains
403
+ // all steps. Bounded to avoid holding 400KB–2MB of assistant text per chain.
404
+ const MAX_INLINE_SECTIONS = 3;
405
+
355
406
  let chainReport: {
356
407
  path: string;
357
408
  display: string;
409
+ /** Precomputed header; written together with the first section. */
410
+ header: string;
411
+ /** True once the file exists on disk (header written). */
412
+ written: boolean;
358
413
  stepStartedAt: Date | null;
359
414
  /** True once the summary (or abandoned) footer has been appended. */
360
415
  footerWritten: boolean;
@@ -362,8 +417,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
362
417
  lastStepSection: number;
363
418
  /** True once a step section with result text was appended. */
364
419
  hasContent: boolean;
365
- /** In-memory section data for inline display (populated in agent_end). */
366
- sections: Array<{ name: string; outcome: string; text: string }>;
420
+ /**
421
+ * In-memory section data for inline display (populated in agent_end,
422
+ * bounded to the last MAX_INLINE_SECTIONS). `index` is the step's
423
+ * position in the chain (retries share it); `markdown` is the formatted section.
424
+ */
425
+ sections: Array<{ index: number; markdown: string }>;
367
426
  } | null = null;
368
427
 
369
428
  /** Status marker glyph (all one column wide) with its color. */
@@ -425,7 +484,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
425
484
  * removes the status widget so nothing lingers below the prompt; otherwise
426
485
  * keeps it as a trace with the report path in the note.
427
486
  */
428
- function finishReport(ctx: ExtensionContext): void {
487
+ /**
488
+ * Write the deferred report header so the file exists before the first
489
+ * section/footer append. Returns false when there is no report or the
490
+ * write failed (a warning was shown); the chain runs on without a file.
491
+ */
492
+ function ensureReportFile(ctx: ExtensionContext | null): boolean {
493
+ const report = chainReport;
494
+ if (!report || report.written) return report !== null;
495
+ try {
496
+ writeFileSync(report.path, report.header);
497
+ report.written = true;
498
+ return true;
499
+ } catch (err) {
500
+ ctx?.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
501
+ return false;
502
+ }
503
+ }
504
+
505
+ async function finishReport(ctx: ExtensionContext): Promise<void> {
429
506
  if (!chainReport || !chainStatus) return;
430
507
  const statuses = chainStatus.steps.map((s) => s.status);
431
508
  // Nothing worth keeping — remove the (mostly) empty file so a quick
@@ -435,19 +512,21 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
435
512
  chainReport = null;
436
513
  return;
437
514
  }
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");
515
+ if (ensureReportFile(ctx)) {
516
+ try {
517
+ appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
518
+ chainReport.footerWritten = true;
519
+ } catch (err) {
520
+ ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
521
+ }
443
522
  }
444
523
  const prev = chainStatus.note ? `${chainStatus.note} • ` : "";
445
524
  chainStatus.note = `${prev}📄 ${chainReport.display}`;
446
- // Chain fully done → show inline report above editor, then clear the
525
+ // Chain fully done → show the inline report, then clear the
447
526
  // trace widget. Chain stopped early → keep the trace widget.
448
527
  const allDone = statuses.every((s) => s === "completed");
449
528
  if (allDone) {
450
- showInlineReport();
529
+ await showInlineReport(ctx);
451
530
  clearChainWidget(ctx);
452
531
  } else {
453
532
  updateChainWidget(ctx);
@@ -455,63 +534,69 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
455
534
  }
456
535
 
457
536
  /**
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.
537
+ * Show the full chain report in an ephemeral editor view when a chain
538
+ * completes (TUI only). Built from in-memory section data (no file read
539
+ * needed). Nothing is persisted to the session — the view closes with
540
+ * Esc and the report file on disk is the permanent artifact. In non-TUI
541
+ * modes the editor is a no-op; the final notification carries the file
542
+ * path.
461
543
  */
462
- function showInlineReport(): void {
544
+ async function showInlineReport(ctx: ExtensionContext): Promise<void> {
545
+ if (ctx.mode !== "tui") return;
463
546
  if (!chainReport || !chainStatus || chainReport.sections.length === 0) return;
464
- // Reconstruct the full markdown report from in-memory sections.
547
+ // Reconstruct the report from in-memory sections. Only the last
548
+ // MAX_INLINE_SECTIONS are kept (the file on disk has all of them), so
549
+ // flag the omission when a step has no section in the window.
550
+ const shownSteps = new Set(chainReport.sections.map((s) => s.index));
551
+ const omitted = chainStatus.steps.length - shownSteps.size;
465
552
  const lines: string[] = [];
466
553
  lines.push(`# do-always chain report — ${new Date().toISOString().slice(0, 10)}`);
467
554
  lines.push("");
468
555
  lines.push(`- Project: ${chainReport.display}`);
469
556
  lines.push(`- Steps: ${chainStatus.steps.map((s) => s.name).join(" → ")}`);
470
557
  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);
558
+ if (omitted > 0) {
559
+ lines.push(`> … ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted — see ${chainReport.display}`);
481
560
  lines.push("");
482
561
  }
562
+ for (const sec of chainReport.sections) {
563
+ lines.push(sec.markdown);
564
+ }
483
565
  // Footer.
484
566
  const statuses = chainStatus.steps.map((s) => s.status);
485
567
  lines.push(reportFooter(statuses, new Date()));
486
568
 
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
- });
569
+ // Ephemeral editor view: read the report, Esc to dismiss. Unlike
570
+ // pi.sendMessage this persists nothing to the session.
571
+ await ctx.ui.editor("do-always — chain report", lines.join("\n"));
494
572
  }
495
573
 
496
574
  /**
497
575
  * Close an in-flight report that never reached a terminal path (e.g.,
498
576
  * 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.
577
+ * nothing worth keeping (a no-op when the deferred header was never
578
+ * written), otherwise append an "abandoned" footer so it does not stay
579
+ * header-only on disk.
501
580
  */
502
581
  function closeAbandonedReport(statuses: string[]): void {
503
582
  if (!chainReport || chainReport.footerWritten) return;
504
583
  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.
584
+ // Nothing worth keeping — remove the file if it was written.
585
+ if (chainReport.written) {
586
+ try {
587
+ unlinkSync(chainReport.path);
588
+ } catch {
589
+ // Best effort — the file stays on disk.
590
+ }
510
591
  }
511
592
  return;
512
593
  }
513
594
  try {
514
- appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
595
+ if (chainReport.written) {
596
+ appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
597
+ } else {
598
+ writeFileSync(chainReport.path, chainReport.header + reportAbandonedFooter(statuses, new Date()));
599
+ }
515
600
  } catch {
516
601
  // Best effort — the report file stays as-is on disk.
517
602
  }
@@ -565,7 +650,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
565
650
  }
566
651
 
567
652
  pi.on("agent_start", () => {
568
- if (chainWaiter) chainWaiter.started = true;
653
+ if (chainWaiter) {
654
+ chainWaiter.started = true;
655
+ chainWaiter.startTime = performance.now();
656
+ }
657
+ // The run actually began — cancel the auto-run task's failed-to-start
658
+ // grace timer (its summary posts at agent_end).
659
+ if (pendingSummaryTimer) {
660
+ clearTimeout(pendingSummaryTimer);
661
+ pendingSummaryTimer = null;
662
+ }
569
663
  // The run actually began — time the step for the report.
570
664
  if (chainReport) chainReport.stepStartedAt = new Date();
571
665
  // Fill-first: step 1 left the editor and is running — update the
@@ -579,12 +673,47 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
579
673
  clearChainWidget(lastCtx);
580
674
  }
581
675
  });
676
+ /** The turn's last assistant message (backward scan — no copy/reverse). */
677
+ function lastAssistantMessage(messages: AgentEndEvent["messages"]) {
678
+ for (let i = messages.length - 1; i >= 0; i--) {
679
+ const m = messages[i];
680
+ if (m.role === "assistant") return m;
681
+ }
682
+ return null;
683
+ }
684
+
685
+ /** Map an assistant message's stopReason to a chain step outcome. */
686
+ function outcomeFromStopReason(stopReason: string | undefined): "completed" | "aborted" | "error" {
687
+ if (stopReason === "aborted") return "aborted";
688
+ if (stopReason === "error") return "error";
689
+ return "completed";
690
+ }
691
+
582
692
  pi.on("agent_end", (event) => {
693
+ // The agent may have changed the repo — drop the TTL context cache so
694
+ // the next action sees the new tree (a running chain keeps its own
695
+ // per-action snapshot and is unaffected).
696
+ contextCache = null;
697
+ // Single auto-run task summary (no chainWaiter: pi.sendUserMessage is
698
+ // fire-and-forget, so no armWaiter is called).
699
+ if (!chainWaiter && pendingSummaryTask) {
700
+ if (pendingSummaryTimer) {
701
+ clearTimeout(pendingSummaryTimer);
702
+ pendingSummaryTimer = null;
703
+ }
704
+ const lastAssistant = lastAssistantMessage(event.messages);
705
+ if (lastAssistant) {
706
+ const outcome = outcomeFromStopReason(lastAssistant.stopReason);
707
+ const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
708
+ lastCtx?.ui.notify(`do-always: ${summary}`, "info");
709
+ }
710
+ pendingSummaryTask = null;
711
+ return;
712
+ }
583
713
  if (!chainWaiter) return;
584
- const lastAssistant = [...event.messages].reverse().find((m) => m.role === "assistant");
714
+ const lastAssistant = lastAssistantMessage(event.messages);
585
715
  if (lastAssistant) {
586
- const stopReason = lastAssistant.stopReason;
587
- const outcome = stopReason === "aborted" ? "aborted" : stopReason === "error" ? "error" : "completed";
716
+ const outcome = outcomeFromStopReason(lastAssistant.stopReason);
588
717
  chainWaiter.outcome = outcome;
589
718
  // Append this step's result to the report while the transcript is
590
719
  // fresh (the step's final assistant message is its result).
@@ -595,23 +724,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
595
724
  // report shows both attempts without a duplicate heading.
596
725
  const isRetry = chainReport.lastStepSection === idx;
597
726
  const name = chainStatus.steps[idx]?.name ?? `step ${idx + 1}`;
727
+ const displayName = isRetry ? `${name} (retry)` : name;
598
728
  const text = assistantText(lastAssistant.content);
599
729
  const section = reportStepSection(
600
730
  idx,
601
- isRetry ? `${name} (retry)` : name,
731
+ displayName,
602
732
  outcome,
603
733
  chainReport.stepStartedAt,
604
734
  new Date(),
605
735
  text,
606
736
  );
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");
737
+ if (ensureReportFile(lastCtx)) {
738
+ try {
739
+ appendFileSync(chainReport.path, section);
740
+ chainReport.lastStepSection = idx;
741
+ if (text.trim() !== "") chainReport.hasContent = true;
742
+ // Keep in-memory section data for inline display (bounded);
743
+ // index lets it number sections like the file.
744
+ chainReport.sections.push({ index: idx, markdown: section });
745
+ if (chainReport.sections.length > MAX_INLINE_SECTIONS) {
746
+ chainReport.sections.splice(0, chainReport.sections.length - MAX_INLINE_SECTIONS);
747
+ }
748
+ } catch (err) {
749
+ lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
750
+ }
615
751
  }
616
752
  chainReport.stepStartedAt = null;
617
753
  }
@@ -677,7 +813,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
677
813
  });
678
814
  }
679
815
 
680
- pi.on("session_start", (_event, ctx) => {
816
+ pi.on("session_start", async (_event, ctx) => {
681
817
  lastCtx = ctx;
682
818
  // Surface config validation problems (the README promises warnings);
683
819
  // in non-TUI modes there is no UI, so fall back to the console.
@@ -688,14 +824,55 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
688
824
  // A failed chain's status widget is a trace of the previous session;
689
825
  // start each session clean.
690
826
  clearChainWidget(ctx);
827
+ // Likewise a stale auto-run summary flag (e.g., a send that never
828
+ // started) must not post a spurious summary for this session's turns.
829
+ resetPendingSummary();
691
830
  loadedCwd = ctx.cwd;
692
831
  const config = loadConfig(ctx.cwd, onError);
693
832
  tasks = config.tasks;
694
833
  reportEnabled = config.report;
695
- refreshVisible(ctx.cwd, buildContext(ctx.cwd));
834
+ refreshVisible(ctx.cwd, await getContext(ctx.cwd));
696
835
  registerShortcut(config.shortcut, onError);
697
836
  });
698
837
 
838
+ // Extension-level git context cache, keyed by cwd with a short TTL:
839
+ // buildContext spawns git, and the repo rarely changes between actions,
840
+ // so the session_start precompute feeds the first /do-always and
841
+ // repeated invocations within the window reuse the same context.
842
+ // Invalidated on agent_end (the agent may have changed the repo); a
843
+ // running chain keeps its own per-action snapshot (createContextCache),
844
+ // so its steps reuse one context across the run.
845
+ let contextCache: { cwd: string; at: number; ctx: TaskContext } | null = null;
846
+ const CONTEXT_TTL_MS = 5_000;
847
+
848
+ async function getContext(cwd: string): Promise<TaskContext> {
849
+ if (contextCache && contextCache.cwd === cwd && Date.now() - contextCache.at < CONTEXT_TTL_MS) {
850
+ return contextCache.ctx;
851
+ }
852
+ const ctx = await buildContext(cwd);
853
+ contextCache = { cwd, at: Date.now(), ctx };
854
+ return ctx;
855
+ }
856
+
857
+ /**
858
+ * Per-action context cache: the first build consults the extension-level
859
+ * TTL cache (getContext), and later builds within the same action reuse
860
+ * that snapshot — what the user saw in the preview is what gets injected.
861
+ * Created fresh at each action entry point (runDoAlways, runChain).
862
+ */
863
+ function createContextCache(): {
864
+ get(cwd: string): Promise<TaskContext>;
865
+ } {
866
+ let cached: { cwd: string; ctx: TaskContext } | null = null;
867
+ return {
868
+ async get(cwd: string): Promise<TaskContext> {
869
+ if (cached && cached.cwd === cwd) return cached.ctx;
870
+ cached = { cwd, ctx: await getContext(cwd) };
871
+ return cached.ctx;
872
+ },
873
+ };
874
+ }
875
+
699
876
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
700
877
  async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
701
878
  const blocked = evaluateGuards(task, context);
@@ -709,6 +886,19 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
709
886
  if (shouldAutoRun(task)) {
710
887
  // Fire-and-forget: sendUserMessage returns void; the run proceeds
711
888
  // independently (see the chain control notes for why).
889
+ // Track the task so we can post a summary after agent_end.
890
+ pendingSummaryTask = task.name;
891
+ // If the run never starts (no agent events at all — same failure
892
+ // mode the chain's grace timer handles), drop the flag so a later
893
+ // unrelated turn can't post a spurious summary for this task.
894
+ if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
895
+ pendingSummaryTimer = setTimeout(() => {
896
+ pendingSummaryTimer = null;
897
+ if (!pendingSummaryTask) return;
898
+ const name = pendingSummaryTask;
899
+ pendingSummaryTask = null;
900
+ lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
901
+ }, 10_000);
712
902
  pi.sendUserMessage(prompt);
713
903
  ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
714
904
  return;
@@ -747,33 +937,37 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
747
937
  return;
748
938
  }
749
939
  // Fail fast: report the first blocked step before sending anything.
750
- const blocked = validateChain(tasks, { items: names, history: [] }, cache.get(ctx.cwd));
940
+ const blocked = validateChain(tasks, { items: names, history: [] }, await cache.get(ctx.cwd));
751
941
  if (blocked) {
752
942
  ctx.ui.notify(`do-always: chain blocked at step ${blocked.step} (${blocked.task.name}): ${blocked.message}`, "warning");
753
943
  return;
754
944
  }
755
945
  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
946
+ // Per-step durations for the chain-end summary. Re-initialized each
947
+ // chain run so stale durations from a previous run don't leak in.
948
+ // Pre-filled with 0 so the fill-first step (index 0, timed outside
949
+ // runChainSteps) and any unrun steps don't leave `undefined` holes
950
+ // that would poison the reduce with NaN.
951
+ chainDurations = Array.from({ length: steps.length }, () => 0);
952
+ // Report file: one per run, in the project root. The header is
953
+ // deferred until the first step section (a chain that dies before
954
+ // that leaves no file), and each step is appended as it finishes
955
+ // (see the report section in tasks.ts). A write failure is not
758
956
  // fatal — the chain still runs, just without a report.
759
957
  if (reportEnabled) {
760
958
  const now = new Date();
761
959
  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
- }
960
+ chainReport = {
961
+ path,
962
+ display: relative(ctx.cwd, path),
963
+ header: reportHeader(ctx.cwd, steps.map((t) => t.name), now),
964
+ written: false,
965
+ stepStartedAt: null,
966
+ footerWritten: false,
967
+ lastStepSection: -1,
968
+ hasContent: false,
969
+ sections: [],
970
+ };
777
971
  }
778
972
  const first = steps[0];
779
973
  if (shouldAutoRun(first) || ctx.mode !== "tui") {
@@ -782,7 +976,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
782
976
  ctx,
783
977
  steps.map((t, i) => ({ name: t.name, status: i === 0 ? "running" : "pending" })),
784
978
  );
785
- await runChainSteps(steps, ctx, 0, cache);
979
+ await runChainSteps(steps, ctx, 0);
786
980
  } finally {
787
981
  chainActive = false;
788
982
  }
@@ -798,7 +992,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
798
992
  steps.map((t, i) => ({ name: t.name, status: i === 0 ? "waiting" : "pending" })),
799
993
  "step 1 is in the editor — press Enter to start",
800
994
  );
801
- ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(cache.get(ctx.cwd))));
995
+ const firstContext = await cache.get(ctx.cwd);
996
+ ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(firstContext)));
802
997
  ctx.ui.notify(
803
998
  `do-always: step 1 of ${steps.length} in the editor — press Enter to run; steps 2–${steps.length} follow automatically`,
804
999
  "info",
@@ -807,12 +1002,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
807
1002
  try {
808
1003
  if (outcome !== "completed") {
809
1004
  markChainStopped(ctx, 0, outcome);
810
- finishReport(ctx);
1005
+ await finishReport(ctx);
811
1006
  ctx.ui.notify(`do-always: step 1 — ${outcome}; chain stopped`, "error");
812
1007
  return;
813
1008
  }
814
1009
  setChainStep(ctx, 0, "completed");
815
- await runChainSteps(steps, ctx, 1, cache);
1010
+ // Post-step summary for step 1 (fill-first)
1011
+ const postContext = await getContext(ctx.cwd);
1012
+ const fileCount = postContext.files.length;
1013
+ const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
1014
+ ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
1015
+ await runChainSteps(steps, ctx, 1);
816
1016
  } finally {
817
1017
  chainActive = false;
818
1018
  }
@@ -820,24 +1020,23 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
820
1020
  }
821
1021
 
822
1022
  /**
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.
1023
+ * Send chain steps `startAt..end` sequentially. Each step evaluates guards
1024
+ * and renders prompts against fresh repository context, and each step is
1025
+ * awaited until its run has fully settled; an aborted/errored step (or a
1026
+ * send that failed to start) stops the chain.
827
1027
  */
828
1028
  async function runChainSteps(
829
1029
  steps: DoAlwaysTask[],
830
1030
  ctx: ExtensionContext,
831
1031
  startAt: number,
832
- cache: ReturnType<typeof createContextCache>,
833
1032
  ): Promise<void> {
834
1033
  for (let i = startAt; i < steps.length; i++) {
835
1034
  const step = steps[i];
836
- const context = cache.get(ctx.cwd);
1035
+ const context = await getContext(ctx.cwd);
837
1036
  const blocked = evaluateGuards(step, context);
838
1037
  if (blocked) {
839
1038
  markChainStopped(ctx, i, "skipped", blocked);
840
- finishReport(ctx);
1039
+ await finishReport(ctx);
841
1040
  ctx.ui.notify(`do-always: chain stopped at step ${i + 1} (${step.name}): ${blocked}`, "warning");
842
1041
  return;
843
1042
  }
@@ -845,19 +1044,28 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
845
1044
  const label = `do-always: step ${i + 1}/${steps.length} — ${step.name}`;
846
1045
  setChainStep(ctx, i, "running");
847
1046
  ctx.ui.notify(`${label} — starting`, "info");
1047
+ const stepStart = performance.now();
848
1048
  const outcome = await sendAndWait(prompt, 10_000, i);
1049
+ const stepDuration = chainDurations[i] || performance.now() - stepStart;
1050
+ chainDurations[i] = stepDuration;
849
1051
  if (outcome === "completed") {
850
1052
  setChainStep(ctx, i, "completed");
1053
+ // Post-step summary: files changed + duration.
1054
+ const postContext = await getContext(ctx.cwd);
1055
+ const fileCount = postContext.files.length;
1056
+ const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
1057
+ ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
851
1058
  continue;
852
1059
  }
853
1060
  markChainStopped(ctx, i, outcome);
854
- finishReport(ctx);
1061
+ await finishReport(ctx);
1062
+ const summary = stepSummary(outcome, step.name, stepDuration, 0);
855
1063
  if (outcome === "failed-to-start") {
856
- ctx.ui.notify(`${label} — failed to start (check model/API key); chain stopped`, "error");
1064
+ ctx.ui.notify(`${label} — ${summary} (check model/API key); chain stopped`, "error");
857
1065
  } else if (outcome === "aborted") {
858
- ctx.ui.notify(`${label} — aborted; chain stopped`, "error");
1066
+ ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
859
1067
  } else {
860
- ctx.ui.notify(`${label} — run errored; chain stopped`, "error");
1068
+ ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
861
1069
  }
862
1070
  return;
863
1071
  }
@@ -866,12 +1074,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
866
1074
  // the prompt. Capture the display path first — finishReport clears
867
1075
  // `chainReport` when the chain is fully done.
868
1076
  const reportDisplay = chainReport?.display;
1077
+ // Chain-end summary.
1078
+ const completed = chainDurations.filter((d) => d > 0).length;
1079
+ const totalMs = chainDurations.reduce((a, b) => a + b, 0);
1080
+ const chainSum = chainSummary(completed, steps.length, totalMs);
869
1081
  if (chainReport) {
870
- finishReport(ctx);
871
- ctx.ui.notify(`do-always: chain complete (${steps.length} steps) — report: ${reportDisplay}`, "info");
1082
+ await finishReport(ctx);
1083
+ ctx.ui.notify(`do-always: ${chainSum} — report: ${reportDisplay}`, "info");
872
1084
  } else {
873
1085
  clearChainWidget(ctx);
874
- ctx.ui.notify(`do-always: chain complete (${steps.length} steps)`, "info");
1086
+ ctx.ui.notify(`do-always: ${chainSum}`, "info");
875
1087
  }
876
1088
  }
877
1089
 
@@ -1004,12 +1216,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1004
1216
  );
1005
1217
  return { bodyRows, itemRows, winStart, visibleHeaderNames };
1006
1218
  }
1219
+ // One pass's visible state, computed once per input event / render
1220
+ // and shared by clampCursor, buildTable, and the navigation logic.
1221
+ type Visible = ReturnType<typeof getVisible>;
1007
1222
 
1008
1223
  // Keep the cursor valid after the rows or the chain change. (The
1009
1224
  // ORDER cell of a non-chained row is a valid cursor position: it is
1010
1225
  // the "add" state.)
1011
- function clampCursor() {
1012
- const { itemRows } = getVisible();
1226
+ function clampCursor(visible: Visible) {
1227
+ const { itemRows } = visible;
1013
1228
  if (itemRows.length === 0) {
1014
1229
  cursor = { kind: "cell", row: 0, col: "task" };
1015
1230
  return;
@@ -1044,15 +1259,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1044
1259
  }
1045
1260
 
1046
1261
  // 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;
1262
+ // The table depends on cursor and preview state, so render() must
1263
+ // rebuild it on every pass; handleMouse only reuses the last render's
1264
+ // table (or builds one if no render has happened yet, e.g. the very
1265
+ // first mouse event).
1266
+ type Table = { lines: string[]; itemLine: Map<number, DoAlwaysTask>; runLine: number; orderColX: number | null };
1267
+ let lastTable: Table | null = null;
1050
1268
 
1051
1269
  // Build the full selector output for a width, plus the line map for
1052
1270
  // mouse handling (itemLine: line -> task, runLine: the Run row,
1053
1271
  // orderColX: where the ORDER cell starts, or null in the narrow tier).
1054
- function buildTable(width: number) {
1055
- const { bodyRows, itemRows, winStart, visibleHeaderNames } = getVisible();
1272
+ function buildTable(width: number, visible: Visible = getVisible()): Table {
1273
+ const { bodyRows, itemRows, winStart, visibleHeaderNames } = visible;
1056
1274
  const { tier, taskCol, descCol, orderColX } = tableGeometry(width);
1057
1275
  const lines: string[] = [];
1058
1276
  const itemLine = new Map<number, DoAlwaysTask>();
@@ -1114,7 +1332,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1114
1332
  cursor.kind === "cell" && cursor.row === idx && cursor.col === "order";
1115
1333
  const num = inTaskCol
1116
1334
  ? `${cursorMark} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
1117
- : ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
1335
+ : inOrderCol && tier === "narrow"
1336
+ ? `${theme.fg("warning", "►")} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
1337
+ : ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
1118
1338
  // ⚡ is 2 columns wide, so "⚡ " takes 3 — reserve it so
1119
1339
  // auto-run rows align with the others (ORDER cell is
1120
1340
  // hit-tested at a fixed x).
@@ -1213,10 +1433,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1213
1433
 
1214
1434
  return {
1215
1435
  render(width: number) {
1216
- lastTable = buildTable(width);
1217
- return lastTable!.lines;
1436
+ lastTable = buildTable(width, getVisible());
1437
+ return lastTable.lines;
1438
+ },
1439
+ invalidate() {
1440
+ clearPreviewTimer();
1218
1441
  },
1219
- invalidate() {},
1220
1442
  handleInput(data: string) {
1221
1443
  // Direct pick by number (1-9) — runs the task immediately (the
1222
1444
  // classic fast path), closing the selector and discarding the
@@ -1227,16 +1449,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1227
1449
  return;
1228
1450
  }
1229
1451
  // Filter typing (Backspace edits the filter; with an empty
1230
- // filter it undoes the last chain add).
1452
+ // filter it undoes the last chain add). The filter change
1453
+ // invalidates the visible set, so the clamp gets a fresh pass.
1231
1454
  if (kb.matches(data, "tui.editor.deleteCharBackward")) {
1232
1455
  if (filter.length > 0) {
1233
1456
  filter = filter.slice(0, -1);
1234
- clampCursor();
1457
+ clampCursor(getVisible());
1235
1458
  } else {
1236
1459
  const { state, removed } = chainUndo(chain);
1237
1460
  if (removed) {
1238
1461
  chain = state;
1239
- clampCursor();
1462
+ clampCursor(getVisible());
1240
1463
  }
1241
1464
  }
1242
1465
  resetPreview();
@@ -1245,12 +1468,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1245
1468
  }
1246
1469
  if (isPrintable(data)) {
1247
1470
  filter += data;
1248
- clampCursor();
1471
+ clampCursor(getVisible());
1249
1472
  resetPreview();
1250
1473
  tui.requestRender();
1251
1474
  return;
1252
1475
  }
1253
- const { itemRows } = getVisible();
1476
+ // One pass for the rest of the event: navigation, confirm,
1477
+ // and clear below all share this result (chain edits don't
1478
+ // change itemRows, so it stays valid).
1479
+ const visible = getVisible();
1480
+ const { itemRows } = visible;
1254
1481
  if (itemRows.length === 0) {
1255
1482
  // Nothing to navigate; only Esc is useful here.
1256
1483
  if (kb.matches(data, "tui.select.cancel")) finishCancel();
@@ -1317,6 +1544,51 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1317
1544
  tui.requestRender();
1318
1545
  return;
1319
1546
  }
1547
+ // Extended navigation: home, end, pageup, pagedown.
1548
+ if (matchesKey(data, "home")) {
1549
+ cursor = { kind: "cell", row: 0, col: cursor.kind === "cell" ? cursor.col : "task" };
1550
+ lastCellRow = 0;
1551
+ resetPreview();
1552
+ tui.requestRender();
1553
+ return;
1554
+ }
1555
+ if (matchesKey(data, "end")) {
1556
+ cursor = { kind: "run" };
1557
+ lastCellRow = itemRows.length - 1;
1558
+ resetPreview();
1559
+ tui.requestRender();
1560
+ return;
1561
+ }
1562
+ if (matchesKey(data, "pageUp")) {
1563
+ if (cursor.kind === "run") {
1564
+ cursor = { kind: "cell", row: Math.max(0, itemRows.length - maxVisible), col: "task" };
1565
+ } else {
1566
+ cursor = {
1567
+ kind: "cell",
1568
+ row: Math.max(0, cursor.row - maxVisible),
1569
+ col: cursor.col,
1570
+ };
1571
+ }
1572
+ lastCellRow = cursor.row;
1573
+ resetPreview();
1574
+ tui.requestRender();
1575
+ return;
1576
+ }
1577
+ if (matchesKey(data, "pageDown")) {
1578
+ if (cursor.kind === "cell") {
1579
+ const next = cursor.row + maxVisible;
1580
+ if (next >= itemRows.length) {
1581
+ cursor = { kind: "run" };
1582
+ lastCellRow = itemRows.length - 1;
1583
+ } else {
1584
+ cursor = { kind: "cell", row: next, col: cursor.col };
1585
+ lastCellRow = next;
1586
+ }
1587
+ }
1588
+ resetPreview();
1589
+ tui.requestRender();
1590
+ return;
1591
+ }
1320
1592
  // Confirm: context-dependent.
1321
1593
  if (kb.matches(data, "tui.select.confirm")) {
1322
1594
  if (cursor.kind === "run") {
@@ -1354,7 +1626,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1354
1626
  if (matchesKey(data, "ctrl+u")) {
1355
1627
  if (chain.items.length > 0) {
1356
1628
  chain = chainClear();
1357
- clampCursor();
1629
+ clampCursor(visible);
1358
1630
  resetPreview();
1359
1631
  tui.requestRender();
1360
1632
  }
@@ -1378,11 +1650,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1378
1650
  return { handled: true, render: true };
1379
1651
  }
1380
1652
  if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
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);
1653
+ // Reuse the last render's table for hit-testing (avoids rebuilding
1654
+ // the table twice per mouse event). The table is always fresh
1655
+ // because handleInput calls requestRender before the next mouse
1656
+ // event can arrive; build one if no render has happened yet.
1657
+ // One getVisible() per event, shared by the (possibly fresh)
1658
+ // table and the itemRows lookup below.
1659
+ const visible = getVisible();
1660
+ const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width, visible);
1386
1661
  // Pinned Run row: press runs the chain.
1387
1662
  if (runLine >= 0 && event.y === runLine) {
1388
1663
  if (event.type === "press" && chain.items.length > 0) {
@@ -1392,14 +1667,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1392
1667
  }
1393
1668
  const task = itemLine.get(event.y);
1394
1669
  if (!task) return undefined;
1395
- const { itemRows } = getVisible();
1670
+ const { itemRows } = visible;
1396
1671
  const idx = itemRows.findIndex((r) => r.task === task);
1397
1672
  if (idx < 0) return undefined;
1398
1673
  // ORDER cell: press toggles chain membership.
1399
1674
  if (orderColX !== null && event.x >= orderColX) {
1400
1675
  if (event.type === "press") {
1401
1676
  chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
1402
- clampCursor();
1677
+ clampCursor(visible);
1403
1678
  resetPreview();
1404
1679
  return { handled: true, render: true };
1405
1680
  }
@@ -1471,7 +1746,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
1471
1746
  // One context per command run: shared by visibility filtering, rendering,
1472
1747
  // and the completion cache (never inside a render loop).
1473
1748
  const cache = createContextCache();
1474
- const context = cache.get(ctx.cwd);
1749
+ const context = await cache.get(ctx.cwd);
1475
1750
  const visible = refreshVisible(ctx.cwd, context);
1476
1751
 
1477
1752
  const arg = args.trim();