pi-do-always 0.10.0 → 0.13.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.
- package/README.md +44 -36
- package/extensions/pi-do-always/do-always.json +21 -2
- package/extensions/pi-do-always/index.ts +1199 -214
- package/extensions/pi-do-always/tasks.ts +417 -39
- package/package.json +1 -1
- package/pi.image.png +0 -0
|
@@ -33,10 +33,10 @@
|
|
|
33
33
|
* If no config file exists, built-in default tasks are used.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import {
|
|
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,
|
|
@@ -51,6 +51,8 @@ import {
|
|
|
51
51
|
} from "@earendil-works/pi-tui";
|
|
52
52
|
import {
|
|
53
53
|
CHAIN_MAX,
|
|
54
|
+
COMMIT_BROWSER_MAX,
|
|
55
|
+
COMMIT_SELECT_MAX,
|
|
54
56
|
DEFAULT_SHORTCUT,
|
|
55
57
|
DEFAULT_TASKS,
|
|
56
58
|
assistantText,
|
|
@@ -59,16 +61,24 @@ import {
|
|
|
59
61
|
chainClear,
|
|
60
62
|
chainRemove,
|
|
61
63
|
chainRunLabel,
|
|
64
|
+
chainSummary,
|
|
62
65
|
chainUndo,
|
|
63
66
|
evaluateGuards,
|
|
64
67
|
evaluateWhen,
|
|
65
68
|
formatChainSequence,
|
|
66
69
|
formatList,
|
|
70
|
+
formatSelectedCommits,
|
|
71
|
+
groupCommitsByDate,
|
|
67
72
|
groupTasksByCategory,
|
|
73
|
+
isTaskVisible,
|
|
68
74
|
isValidKeyId,
|
|
69
75
|
mergeTasks,
|
|
70
76
|
parseConfig,
|
|
77
|
+
parseConfigRegexpValueForKey,
|
|
78
|
+
parseCommitSubject,
|
|
79
|
+
parseGitLogOutput,
|
|
71
80
|
parseStatusPorcelain,
|
|
81
|
+
parseStatusStagedUnstaged,
|
|
72
82
|
orderTasksByCategory,
|
|
73
83
|
reportAbandonedFooter,
|
|
74
84
|
reportFooter,
|
|
@@ -80,9 +90,14 @@ import {
|
|
|
80
90
|
resolveShortcut,
|
|
81
91
|
resolveTask,
|
|
82
92
|
shouldAutoRun,
|
|
93
|
+
stepSummary,
|
|
83
94
|
toPromptContext,
|
|
84
95
|
validateChain,
|
|
96
|
+
type ChainStepOutcome,
|
|
97
|
+
type CommitInfo,
|
|
98
|
+
type DateGroup,
|
|
85
99
|
type DoAlwaysTask,
|
|
100
|
+
type SelectedCommit,
|
|
86
101
|
type TaskContext,
|
|
87
102
|
type TableRow,
|
|
88
103
|
} from "./tasks";
|
|
@@ -102,6 +117,20 @@ type SelectorResult =
|
|
|
102
117
|
*/
|
|
103
118
|
type Cursor = { kind: "cell"; row: number; col: "task" | "order" } | { kind: "run" };
|
|
104
119
|
|
|
120
|
+
/**
|
|
121
|
+
* Safely read a config file's contents, returning null if missing or unreadable.
|
|
122
|
+
* Read errors are reported through `onError`.
|
|
123
|
+
*/
|
|
124
|
+
function readConfigFile(filePath: string, onError: (message: string) => void): string | null {
|
|
125
|
+
if (!existsSync(filePath)) return null;
|
|
126
|
+
try {
|
|
127
|
+
return readFileSync(filePath, "utf-8");
|
|
128
|
+
} catch (err) {
|
|
129
|
+
onError(`do-always: could not read ${filePath}: ${err}`);
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
105
134
|
/**
|
|
106
135
|
* Load tasks and the selector shortcut from config files.
|
|
107
136
|
* Project-local tasks override global tasks with the same name (or are
|
|
@@ -124,11 +153,14 @@ function loadConfig(
|
|
|
124
153
|
const globalPath = join(getAgentDir(), "do-always.json");
|
|
125
154
|
const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
|
|
126
155
|
|
|
127
|
-
const
|
|
128
|
-
|
|
156
|
+
const globalRaw = readConfigFile(globalPath, onError);
|
|
157
|
+
const projectRaw = readConfigFile(projectPath, onError);
|
|
158
|
+
|
|
159
|
+
const global = globalRaw !== null
|
|
160
|
+
? parseConfig(globalRaw, globalPath, onError)
|
|
129
161
|
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
130
|
-
const project =
|
|
131
|
-
? parseConfig(
|
|
162
|
+
const project = projectRaw !== null
|
|
163
|
+
? parseConfig(projectRaw, projectPath, onError)
|
|
132
164
|
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
133
165
|
|
|
134
166
|
// The project file's merge mode wins; otherwise the global value; otherwise
|
|
@@ -152,36 +184,36 @@ function loadConfig(
|
|
|
152
184
|
* empty repo, …) so callers can fall back to a neutral value.
|
|
153
185
|
* No shell is involved (argument array), so file names cannot inject commands.
|
|
154
186
|
*/
|
|
155
|
-
function git(cwd: string, args: string[]): string | undefined {
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
187
|
+
function git(cwd: string, args: string[]): Promise<string | undefined> {
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
// No stdio option: execFile's string-encoding overload doesn't accept
|
|
190
|
+
// it, and stdin/stderr need no special handling here (no input is
|
|
191
|
+
// written; stderr is simply ignored by the callback).
|
|
192
|
+
execFile(
|
|
193
|
+
"git",
|
|
194
|
+
args,
|
|
195
|
+
{ cwd, encoding: "utf-8" },
|
|
196
|
+
(error, stdout) => {
|
|
197
|
+
if (error) {
|
|
198
|
+
resolve(undefined);
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
const trimmed = stdout.trim();
|
|
202
|
+
resolve(trimmed === "" ? undefined : trimmed);
|
|
203
|
+
},
|
|
204
|
+
);
|
|
205
|
+
});
|
|
167
206
|
}
|
|
168
207
|
|
|
169
208
|
/**
|
|
170
|
-
*
|
|
171
|
-
*
|
|
172
|
-
*
|
|
209
|
+
* Count changed files (staged, unstaged, untracked) with a single git spawn.
|
|
210
|
+
* Lighter than a full `buildContext` when only the count is needed (chain
|
|
211
|
+
* step summaries). Returns 0 when git is unavailable — the same neutral
|
|
212
|
+
* result `buildContext` yields via its empty file list.
|
|
173
213
|
*/
|
|
174
|
-
function
|
|
175
|
-
|
|
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
|
-
};
|
|
214
|
+
async function changedFileCount(cwd: string): Promise<number> {
|
|
215
|
+
const porcelain = await git(cwd, ["status", "--porcelain"]);
|
|
216
|
+
return porcelain ? parseStatusPorcelain(porcelain).length : 0;
|
|
185
217
|
}
|
|
186
218
|
|
|
187
219
|
/**
|
|
@@ -189,57 +221,68 @@ function createContextCache(): {
|
|
|
189
221
|
* to neutral values when unavailable (non-git dir, no git, empty repo) so
|
|
190
222
|
* default prompts read cleanly in any directory.
|
|
191
223
|
*
|
|
192
|
-
*
|
|
193
|
-
* 1. rev-parse --
|
|
194
|
-
* 2.
|
|
195
|
-
* 3.
|
|
196
|
-
* 4.
|
|
197
|
-
* 5.
|
|
224
|
+
* Collects the git facts the prompts interpolate:
|
|
225
|
+
* 1. rev-parse --is-inside-work-tree → isGitRepo
|
|
226
|
+
* 2. branch --show-current → branch (fallback: rev-parse --abbrev-ref HEAD)
|
|
227
|
+
* 3. log -1 --format="%H %s" → commit hash + subject
|
|
228
|
+
* 4. config --get-regexp "^(user.name|remote.origin.url)$" → user + repo
|
|
229
|
+
* 5. status --porcelain → files + stagedFiles + unstagedFiles
|
|
230
|
+
* 6. diff --shortstat → diffStat
|
|
231
|
+
*
|
|
232
|
+
* Outside a work tree only call 1 runs; inside one, calls 2–6 run in
|
|
233
|
+
* parallel, so wall time is about one spawn.
|
|
198
234
|
*/
|
|
199
|
-
function buildContext(cwd: string): TaskContext {
|
|
200
|
-
// 1.
|
|
201
|
-
// rev-parse --
|
|
202
|
-
//
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
235
|
+
async function buildContext(cwd: string): Promise<TaskContext> {
|
|
236
|
+
// 1. Authoritative check: are we inside a work tree?
|
|
237
|
+
// rev-parse --is-inside-work-tree outputs "true" when inside a work tree,
|
|
238
|
+
// and fails (exit != 0) when outside.
|
|
239
|
+
const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
240
|
+
const isGitRepo = inside === "true";
|
|
241
|
+
|
|
242
|
+
// Outside a work tree the remaining facts don't exist; skip the spawns
|
|
243
|
+
// and return the neutral fallbacks directly.
|
|
244
|
+
if (!isGitRepo) {
|
|
245
|
+
return {
|
|
246
|
+
cwd,
|
|
247
|
+
date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
|
|
248
|
+
branch: "unknown",
|
|
249
|
+
lastCommit: "unknown",
|
|
250
|
+
files: [],
|
|
251
|
+
user: "unknown",
|
|
252
|
+
diffStat: "none",
|
|
253
|
+
repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
|
|
254
|
+
stagedFiles: [],
|
|
255
|
+
unstagedFiles: [],
|
|
256
|
+
selectedCommits: "none",
|
|
257
|
+
isGitRepo: false,
|
|
258
|
+
};
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// 2–6. In a work tree: query branch and the remaining facts in parallel.
|
|
262
|
+
const [branchOut, lastCommitLine, configLine, porcelain, diffStat] = await Promise.all([
|
|
263
|
+
// branch --show-current works on both normal and unborn (empty repo) branches;
|
|
264
|
+
// returns empty on detached HEAD.
|
|
265
|
+
git(cwd, ["branch", "--show-current"]),
|
|
266
|
+
// "%H %s" prints "<hash> <subject>"; the subject may contain spaces.
|
|
267
|
+
git(cwd, ["log", "-1", "--format=%H %s"]),
|
|
268
|
+
// --get-regexp prints "key value" lines; one call covers both keys.
|
|
269
|
+
git(cwd, ["config", "--get-regexp", "^(user\\.name|remote\\.origin\\.url)$"]),
|
|
270
|
+
git(cwd, ["status", "--porcelain"]),
|
|
271
|
+
git(cwd, ["diff", "--shortstat"]),
|
|
272
|
+
]);
|
|
273
|
+
|
|
274
|
+
const branch = branchOut || (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])) || "unknown";
|
|
275
|
+
const lastCommit = parseCommitSubject(lastCommitLine);
|
|
276
|
+
const user = parseConfigRegexpValueForKey(configLine, "user.name");
|
|
277
|
+
const remoteUrl = parseConfigRegexpValueForKey(configLine, "remote.origin.url");
|
|
217
278
|
const repo = remoteUrl
|
|
218
279
|
? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
|
|
219
280
|
: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
|
|
220
281
|
|
|
221
|
-
//
|
|
222
|
-
//
|
|
223
|
-
|
|
224
|
-
const stagedFiles:
|
|
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;
|
|
282
|
+
// status --porcelain gives staged + unstaged in one call.
|
|
283
|
+
// Porcelain v1 lines are "XY <path>" (path starts at index 3); a space
|
|
284
|
+
// in the X column means unstaged, anything else is staged/untracked.
|
|
285
|
+
const { staged: stagedFiles, unstaged: unstagedFiles } = parseStatusStagedUnstaged(porcelain ?? "");
|
|
243
286
|
|
|
244
287
|
return {
|
|
245
288
|
cwd,
|
|
@@ -252,23 +295,572 @@ function buildContext(cwd: string): TaskContext {
|
|
|
252
295
|
repo,
|
|
253
296
|
stagedFiles,
|
|
254
297
|
unstagedFiles,
|
|
298
|
+
selectedCommits: "none",
|
|
255
299
|
isGitRepo,
|
|
256
300
|
};
|
|
257
301
|
}
|
|
258
302
|
|
|
303
|
+
/**
|
|
304
|
+
* Fetch a page of commits from git with lightweight stats via a single log call.
|
|
305
|
+
* Fetches pageSize + 1 commits to determine if more commits are available.
|
|
306
|
+
*/
|
|
307
|
+
async function fetchCommitsPage(
|
|
308
|
+
cwd: string,
|
|
309
|
+
page = 0,
|
|
310
|
+
pageSize = COMMIT_BROWSER_MAX,
|
|
311
|
+
): Promise<{ commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }> {
|
|
312
|
+
const countToFetch = pageSize + 1;
|
|
313
|
+
const skip = page * pageSize;
|
|
314
|
+
const format = "COMMIT%x09%H%x09%h%x09%s%x09%ad%x09%an";
|
|
315
|
+
const logOutput = await git(cwd, [
|
|
316
|
+
"log",
|
|
317
|
+
"-n",
|
|
318
|
+
`${countToFetch}`,
|
|
319
|
+
`--skip=${skip}`,
|
|
320
|
+
`--format=${format}`,
|
|
321
|
+
"--shortstat",
|
|
322
|
+
"--date=short",
|
|
323
|
+
]);
|
|
324
|
+
if (!logOutput) {
|
|
325
|
+
return { commits: [], groups: [], hasMore: false };
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
const allParsed = parseGitLogOutput(logOutput);
|
|
329
|
+
const hasMore = allParsed.length > pageSize;
|
|
330
|
+
const commits = hasMore ? allParsed.slice(0, pageSize) : allParsed;
|
|
331
|
+
const groups = groupCommitsByDate(commits);
|
|
332
|
+
return { commits, groups, hasMore };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Date-grouped commit browser: browse recent commits in pages of 20, select some,
|
|
337
|
+
* then review them. Reuses the task-selector interaction model (↑↓, Space toggle,
|
|
338
|
+
* type-to-filter, Ctrl+U clear, Esc cancel, ←/→ or PgUp/PgDn for paging).
|
|
339
|
+
*
|
|
340
|
+
* Returns the selected commits (ordered by user selection), or null on cancel.
|
|
341
|
+
*/
|
|
342
|
+
function browseCommits(
|
|
343
|
+
ctx: ExtensionContext,
|
|
344
|
+
initialData?: { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean },
|
|
345
|
+
): Promise<SelectedCommit[] | null> {
|
|
346
|
+
return new Promise<SelectedCommit[] | null>((resolve) => {
|
|
347
|
+
const pageCache = new Map<number, { commits: CommitInfo[]; groups: DateGroup[]; hasMore: boolean }>();
|
|
348
|
+
let page = 0;
|
|
349
|
+
if (initialData) {
|
|
350
|
+
pageCache.set(0, initialData);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
type CommitCursor = { kind: "commit"; index: number } | { kind: "run" };
|
|
354
|
+
const maxVisible = 14; // commits visible in the scroll window
|
|
355
|
+
const selectedMap = new Map<string, SelectedCommit>(); // hash -> SelectedCommit
|
|
356
|
+
let cursor: CommitCursor = { kind: "commit", index: 0 };
|
|
357
|
+
let lastCommitIndex = 0;
|
|
358
|
+
let filter = "";
|
|
359
|
+
let settled = false;
|
|
360
|
+
let isLoading = false;
|
|
361
|
+
|
|
362
|
+
ctx.ui.custom<SelectedCommit[] | null>((tui, theme, _kb, done) => {
|
|
363
|
+
const kb = getKeybindings();
|
|
364
|
+
|
|
365
|
+
function finish(selectedCommits: SelectedCommit[]) {
|
|
366
|
+
if (settled) return;
|
|
367
|
+
settled = true;
|
|
368
|
+
done(selectedCommits);
|
|
369
|
+
resolve(selectedCommits);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function finishCancel() {
|
|
373
|
+
if (settled) return;
|
|
374
|
+
settled = true;
|
|
375
|
+
done(null);
|
|
376
|
+
resolve(null);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
async function loadPage(newPage: number) {
|
|
380
|
+
if (isLoading || newPage < 0) return;
|
|
381
|
+
page = newPage;
|
|
382
|
+
if (!pageCache.has(page)) {
|
|
383
|
+
isLoading = true;
|
|
384
|
+
tui.requestRender();
|
|
385
|
+
try {
|
|
386
|
+
const fetched = await fetchCommitsPage(ctx.cwd, page);
|
|
387
|
+
pageCache.set(page, fetched);
|
|
388
|
+
} finally {
|
|
389
|
+
isLoading = false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
cursor = { kind: "commit", index: 0 };
|
|
393
|
+
lastCommitIndex = 0;
|
|
394
|
+
tui.requestRender();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
function getCurrentPageData() {
|
|
398
|
+
return pageCache.get(page) ?? { commits: [], groups: [], hasMore: false };
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function getVisibleCommits(): CommitInfo[] {
|
|
402
|
+
const { commits } = getCurrentPageData();
|
|
403
|
+
if (!filter) return commits;
|
|
404
|
+
const f = filter.toLowerCase();
|
|
405
|
+
return commits.filter(
|
|
406
|
+
(c) => c.subject.toLowerCase().includes(f) || c.shortHash.toLowerCase().includes(f),
|
|
407
|
+
);
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function render(width: number): string[] {
|
|
411
|
+
const currentData = getCurrentPageData();
|
|
412
|
+
const visibleCommits = getVisibleCommits();
|
|
413
|
+
const lines: string[] = [];
|
|
414
|
+
|
|
415
|
+
// Clamp cursor index if visible list changed
|
|
416
|
+
if (cursor.kind === "commit") {
|
|
417
|
+
if (visibleCommits.length === 0) {
|
|
418
|
+
cursor = { kind: "run" };
|
|
419
|
+
} else if (cursor.index >= visibleCommits.length) {
|
|
420
|
+
cursor = { kind: "commit", index: visibleCommits.length - 1 };
|
|
421
|
+
lastCommitIndex = cursor.index;
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Header
|
|
426
|
+
const startNum = page * COMMIT_BROWSER_MAX + 1;
|
|
427
|
+
const endNum = page * COMMIT_BROWSER_MAX + (visibleCommits.length || 0);
|
|
428
|
+
const pageLabel = currentData.hasMore
|
|
429
|
+
? `Page ${page + 1} (${startNum}–${endNum}+)`
|
|
430
|
+
: `Page ${page + 1} (${startNum}–${endNum})`;
|
|
431
|
+
|
|
432
|
+
lines.push(
|
|
433
|
+
theme.fg(
|
|
434
|
+
"muted",
|
|
435
|
+
truncateToWidth(
|
|
436
|
+
` # DATE HASH FILES ORDER SUBJECT [${pageLabel}]`,
|
|
437
|
+
width - 2,
|
|
438
|
+
"",
|
|
439
|
+
),
|
|
440
|
+
),
|
|
441
|
+
);
|
|
442
|
+
|
|
443
|
+
if (isLoading) {
|
|
444
|
+
lines.push(theme.fg("accent", " Loading commits from git…"));
|
|
445
|
+
} else if (visibleCommits.length === 0) {
|
|
446
|
+
lines.push(
|
|
447
|
+
filter
|
|
448
|
+
? theme.fg("warning", ` No commits matching "${filter}" on this page`)
|
|
449
|
+
: theme.fg("muted", " No commits found"),
|
|
450
|
+
);
|
|
451
|
+
} else {
|
|
452
|
+
const anchor = cursor.kind === "commit" ? cursor.index : lastCommitIndex;
|
|
453
|
+
const winStart = visibleCommits.length > maxVisible
|
|
454
|
+
? Math.max(0, Math.min(anchor + 1 - maxVisible, visibleCommits.length - maxVisible))
|
|
455
|
+
: 0;
|
|
456
|
+
const winEnd = Math.min(winStart + maxVisible, visibleCommits.length);
|
|
457
|
+
|
|
458
|
+
for (let i = winStart; i < winEnd; i++) {
|
|
459
|
+
const c = visibleCommits[i];
|
|
460
|
+
// Show date divider if it's the first commit in window or date changed
|
|
461
|
+
if (i === winStart || c.date !== visibleCommits[i - 1].date) {
|
|
462
|
+
lines.push(theme.fg("dim", ` ── ${c.date} ──`));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const isCursor = cursor.kind === "commit" && cursor.index === i;
|
|
466
|
+
const isSelected = selectedMap.has(c.hash);
|
|
467
|
+
const order = isSelected ? [...selectedMap.keys()].indexOf(c.hash) + 1 : 0;
|
|
468
|
+
const cursorMark = isCursor ? theme.fg("accent", "►") : " ";
|
|
469
|
+
const globalIdx = page * COMMIT_BROWSER_MAX + i + 1;
|
|
470
|
+
const num = `${cursorMark} ${String(globalIdx).padStart(2)}`;
|
|
471
|
+
const marker = isSelected ? `${theme.fg("accent", "◉")} [${order}]` : " · ";
|
|
472
|
+
const hash = theme.fg("dim", c.shortHash.slice(0, 7));
|
|
473
|
+
const statStr = c.filesChanged > 0 ? `${c.filesChanged}f` : "";
|
|
474
|
+
const statCell = statStr.padStart(3);
|
|
475
|
+
const dateShort = c.date.slice(5); // MM-DD
|
|
476
|
+
const subject = truncateToWidth(c.subject, Math.max(10, width - 36), "…");
|
|
477
|
+
|
|
478
|
+
const rowText = `${num} ${dateShort} ${hash} ${statCell} ${marker} ${subject}`;
|
|
479
|
+
if (isCursor) {
|
|
480
|
+
lines.push(theme.bg("selectedBg", theme.bold(rowText)));
|
|
481
|
+
} else {
|
|
482
|
+
lines.push(rowText);
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
if (visibleCommits.length > maxVisible) {
|
|
487
|
+
lines.push(
|
|
488
|
+
theme.fg(
|
|
489
|
+
"dim",
|
|
490
|
+
truncateToWidth(
|
|
491
|
+
` (${cursor.kind === "commit" ? cursor.index + 1 : lastCommitIndex + 1}/${visibleCommits.length} on page ${page + 1})`,
|
|
492
|
+
width - 2,
|
|
493
|
+
"",
|
|
494
|
+
),
|
|
495
|
+
),
|
|
496
|
+
);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// Run row
|
|
501
|
+
lines.push("");
|
|
502
|
+
lines.push(theme.fg("dim", " " + "─".repeat(Math.max(1, width - 4))));
|
|
503
|
+
const runLabel = selectedMap.size === 0
|
|
504
|
+
? "do on the commits (no commits selected)"
|
|
505
|
+
: `do on the commits (${selectedMap.size} commit${selectedMap.size !== 1 ? "s" : ""})`;
|
|
506
|
+
if (cursor.kind === "run") {
|
|
507
|
+
lines.push(theme.bg("selectedBg", theme.bold(`${theme.fg("accent", "►")} ${runLabel}`)));
|
|
508
|
+
} else if (selectedMap.size === 0) {
|
|
509
|
+
lines.push(theme.fg("dim", ` ${runLabel}`));
|
|
510
|
+
} else {
|
|
511
|
+
lines.push(theme.fg("accent", ` ${runLabel}`));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Footer
|
|
515
|
+
const pageNavHints: string[] = [];
|
|
516
|
+
if (page > 0) pageNavHints.push("← prev page");
|
|
517
|
+
if (currentData.hasMore) pageNavHints.push("→ next page");
|
|
518
|
+
const pageHintStr = pageNavHints.length > 0 ? ` • ${pageNavHints.join(" • ")}` : "";
|
|
519
|
+
|
|
520
|
+
let footer = ` space/⏎ select • ↑/↓ move${pageHintStr} • esc`;
|
|
521
|
+
if (selectedMap.size > 0) {
|
|
522
|
+
footer += " • ctrl+u clear";
|
|
523
|
+
}
|
|
524
|
+
if (filter) {
|
|
525
|
+
footer += ` • filter: "${filter}"`;
|
|
526
|
+
}
|
|
527
|
+
lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
|
|
528
|
+
|
|
529
|
+
return lines;
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
async function handleInput(data: string) {
|
|
533
|
+
if (settled) return;
|
|
534
|
+
const currentData = getCurrentPageData();
|
|
535
|
+
const visibleCommits = getVisibleCommits();
|
|
536
|
+
|
|
537
|
+
// Space: toggle selection on current commit
|
|
538
|
+
if (matchesKey(data, "space")) {
|
|
539
|
+
if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
|
|
540
|
+
const c = visibleCommits[cursor.index];
|
|
541
|
+
if (selectedMap.has(c.hash)) {
|
|
542
|
+
selectedMap.delete(c.hash);
|
|
543
|
+
let i = 1;
|
|
544
|
+
for (const sc of selectedMap.values()) {
|
|
545
|
+
sc.selectionOrder = i++;
|
|
546
|
+
}
|
|
547
|
+
} else if (selectedMap.size < COMMIT_SELECT_MAX) {
|
|
548
|
+
selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
|
|
549
|
+
} else {
|
|
550
|
+
ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
|
|
551
|
+
}
|
|
552
|
+
tui.requestRender();
|
|
553
|
+
}
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
// Confirm (Enter): run the chain if on run row, or toggle selection if on a commit
|
|
558
|
+
if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
|
|
559
|
+
if (cursor.kind === "run") {
|
|
560
|
+
if (selectedMap.size === 0) {
|
|
561
|
+
ctx.ui.notify("do-always: no commits selected", "info");
|
|
562
|
+
} else {
|
|
563
|
+
finish([...selectedMap.values()]);
|
|
564
|
+
}
|
|
565
|
+
return;
|
|
566
|
+
}
|
|
567
|
+
if (cursor.kind === "commit" && visibleCommits[cursor.index]) {
|
|
568
|
+
const c = visibleCommits[cursor.index];
|
|
569
|
+
if (selectedMap.has(c.hash)) {
|
|
570
|
+
selectedMap.delete(c.hash);
|
|
571
|
+
let i = 1;
|
|
572
|
+
for (const sc of selectedMap.values()) {
|
|
573
|
+
sc.selectionOrder = i++;
|
|
574
|
+
}
|
|
575
|
+
} else if (selectedMap.size < COMMIT_SELECT_MAX) {
|
|
576
|
+
selectedMap.set(c.hash, { ...c, selectionOrder: selectedMap.size + 1 });
|
|
577
|
+
} else {
|
|
578
|
+
ctx.ui.notify(`do-always: chain is full (${COMMIT_SELECT_MAX}) — remove a commit first`, "error");
|
|
579
|
+
}
|
|
580
|
+
tui.requestRender();
|
|
581
|
+
}
|
|
582
|
+
return;
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Cancel (Esc / Ctrl+C)
|
|
586
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
587
|
+
finishCancel();
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Ctrl+U: clear selections
|
|
592
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
593
|
+
if (selectedMap.size > 0) {
|
|
594
|
+
selectedMap.clear();
|
|
595
|
+
tui.requestRender();
|
|
596
|
+
}
|
|
597
|
+
return;
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
// Backspace: delete character from filter, or undo last selection
|
|
601
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
|
|
602
|
+
if (filter.length > 0) {
|
|
603
|
+
filter = filter.slice(0, -1);
|
|
604
|
+
cursor = { kind: "commit", index: 0 };
|
|
605
|
+
lastCommitIndex = 0;
|
|
606
|
+
} else if (selectedMap.size > 0) {
|
|
607
|
+
const lastHash = [...selectedMap.keys()].pop()!;
|
|
608
|
+
selectedMap.delete(lastHash);
|
|
609
|
+
}
|
|
610
|
+
tui.requestRender();
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Page navigation: Next page (Right arrow, or PageDown when at the end)
|
|
615
|
+
if (matchesKey(data, "right")) {
|
|
616
|
+
if (currentData.hasMore) {
|
|
617
|
+
await loadPage(page + 1);
|
|
618
|
+
}
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
if (kb.matches(data, "tui.select.pageDown") || matchesKey(data, "pageDown")) {
|
|
622
|
+
if (cursor.kind === "commit") {
|
|
623
|
+
const next = cursor.index + maxVisible;
|
|
624
|
+
if (next < visibleCommits.length) {
|
|
625
|
+
cursor = { kind: "commit", index: next };
|
|
626
|
+
lastCommitIndex = next;
|
|
627
|
+
tui.requestRender();
|
|
628
|
+
} else if (currentData.hasMore) {
|
|
629
|
+
await loadPage(page + 1);
|
|
630
|
+
} else {
|
|
631
|
+
cursor = { kind: "run" };
|
|
632
|
+
tui.requestRender();
|
|
633
|
+
}
|
|
634
|
+
} else if (currentData.hasMore) {
|
|
635
|
+
await loadPage(page + 1);
|
|
636
|
+
}
|
|
637
|
+
return;
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
// Page navigation: Previous page (Left arrow, or PageUp when at the top)
|
|
641
|
+
if (matchesKey(data, "left")) {
|
|
642
|
+
if (page > 0) {
|
|
643
|
+
await loadPage(page - 1);
|
|
644
|
+
}
|
|
645
|
+
return;
|
|
646
|
+
}
|
|
647
|
+
if (kb.matches(data, "tui.select.pageUp") || matchesKey(data, "pageUp")) {
|
|
648
|
+
if (cursor.kind === "commit") {
|
|
649
|
+
const prev = cursor.index - maxVisible;
|
|
650
|
+
if (prev >= 0) {
|
|
651
|
+
cursor = { kind: "commit", index: prev };
|
|
652
|
+
lastCommitIndex = prev;
|
|
653
|
+
tui.requestRender();
|
|
654
|
+
} else if (page > 0) {
|
|
655
|
+
await loadPage(page - 1);
|
|
656
|
+
} else {
|
|
657
|
+
cursor = { kind: "commit", index: 0 };
|
|
658
|
+
lastCommitIndex = 0;
|
|
659
|
+
tui.requestRender();
|
|
660
|
+
}
|
|
661
|
+
} else if (visibleCommits.length > 0) {
|
|
662
|
+
cursor = { kind: "commit", index: Math.max(0, visibleCommits.length - maxVisible) };
|
|
663
|
+
lastCommitIndex = cursor.index;
|
|
664
|
+
tui.requestRender();
|
|
665
|
+
}
|
|
666
|
+
return;
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// Up arrow
|
|
670
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
|
|
671
|
+
if (cursor.kind === "run") {
|
|
672
|
+
if (visibleCommits.length > 0) {
|
|
673
|
+
cursor = { kind: "commit", index: visibleCommits.length - 1 };
|
|
674
|
+
lastCommitIndex = visibleCommits.length - 1;
|
|
675
|
+
}
|
|
676
|
+
} else if (cursor.index > 0) {
|
|
677
|
+
cursor = { kind: "commit", index: cursor.index - 1 };
|
|
678
|
+
lastCommitIndex = cursor.index;
|
|
679
|
+
} else {
|
|
680
|
+
cursor = { kind: "run" };
|
|
681
|
+
}
|
|
682
|
+
tui.requestRender();
|
|
683
|
+
return;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// Down arrow
|
|
687
|
+
if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
|
|
688
|
+
if (cursor.kind === "run") {
|
|
689
|
+
if (visibleCommits.length > 0) {
|
|
690
|
+
cursor = { kind: "commit", index: 0 };
|
|
691
|
+
lastCommitIndex = 0;
|
|
692
|
+
}
|
|
693
|
+
} else if (cursor.index < visibleCommits.length - 1) {
|
|
694
|
+
cursor = { kind: "commit", index: cursor.index + 1 };
|
|
695
|
+
lastCommitIndex = cursor.index;
|
|
696
|
+
} else {
|
|
697
|
+
cursor = { kind: "run" };
|
|
698
|
+
}
|
|
699
|
+
tui.requestRender();
|
|
700
|
+
return;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
// Home / End
|
|
704
|
+
if (matchesKey(data, "home")) {
|
|
705
|
+
if (visibleCommits.length > 0) {
|
|
706
|
+
cursor = { kind: "commit", index: 0 };
|
|
707
|
+
lastCommitIndex = 0;
|
|
708
|
+
tui.requestRender();
|
|
709
|
+
}
|
|
710
|
+
return;
|
|
711
|
+
}
|
|
712
|
+
if (matchesKey(data, "end")) {
|
|
713
|
+
cursor = { kind: "run" };
|
|
714
|
+
tui.requestRender();
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
// Filter typing (printable chars, excluding Space which toggles)
|
|
719
|
+
if (isPrintable(data) && data !== " ") {
|
|
720
|
+
filter += data;
|
|
721
|
+
cursor = { kind: "commit", index: 0 };
|
|
722
|
+
lastCommitIndex = 0;
|
|
723
|
+
tui.requestRender();
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
return {
|
|
729
|
+
render,
|
|
730
|
+
handleInput,
|
|
731
|
+
invalidate: () => {},
|
|
732
|
+
};
|
|
733
|
+
});
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
|
|
259
737
|
/** True for a single printable ASCII character (used for filter typing). */
|
|
260
738
|
function isPrintable(data: string): boolean {
|
|
261
739
|
return data.length === 1 && data >= " " && data <= "~";
|
|
262
740
|
}
|
|
263
741
|
|
|
742
|
+
/**
|
|
743
|
+
* Task picker shown after a commit selection: proposes the Plan tasks (hidden
|
|
744
|
+
* ones included — e.g. "Review commits" exists to be picked here) to run on
|
|
745
|
+
* the selected commits. Type to filter, ↑/↓ move, Enter runs, Esc cancels.
|
|
746
|
+
* Returns the chosen task, or null on cancel.
|
|
747
|
+
*/
|
|
748
|
+
function pickPlanTaskForCommits(
|
|
749
|
+
ctx: ExtensionContext,
|
|
750
|
+
candidates: DoAlwaysTask[],
|
|
751
|
+
commitCount: number,
|
|
752
|
+
): Promise<DoAlwaysTask | null> {
|
|
753
|
+
return new Promise<DoAlwaysTask | null>((resolve) => {
|
|
754
|
+
ctx.ui.custom<DoAlwaysTask | null>((tui, theme, _kb, done) => {
|
|
755
|
+
const kb = getKeybindings();
|
|
756
|
+
let settled = false;
|
|
757
|
+
let filter = "";
|
|
758
|
+
let cursor = 0;
|
|
759
|
+
|
|
760
|
+
const visible = () => {
|
|
761
|
+
if (!filter) return candidates;
|
|
762
|
+
const f = filter.toLowerCase();
|
|
763
|
+
return candidates.filter(
|
|
764
|
+
(t) =>
|
|
765
|
+
t.name.toLowerCase().includes(f) ||
|
|
766
|
+
(t.description ?? "").toLowerCase().includes(f),
|
|
767
|
+
);
|
|
768
|
+
};
|
|
769
|
+
|
|
770
|
+
function finish(task: DoAlwaysTask | null) {
|
|
771
|
+
if (settled) return;
|
|
772
|
+
settled = true;
|
|
773
|
+
done(task);
|
|
774
|
+
resolve(task);
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function render(width: number): string[] {
|
|
778
|
+
const items = visible();
|
|
779
|
+
if (items.length === 0) cursor = 0;
|
|
780
|
+
else if (cursor >= items.length) cursor = items.length - 1;
|
|
781
|
+
const lines: string[] = [];
|
|
782
|
+
lines.push(
|
|
783
|
+
theme.fg(
|
|
784
|
+
"accent",
|
|
785
|
+
theme.bold(
|
|
786
|
+
truncateToWidth(
|
|
787
|
+
` do-always — run on ${commitCount} selected commit${commitCount !== 1 ? "s" : ""}`,
|
|
788
|
+
width - 2,
|
|
789
|
+
"",
|
|
790
|
+
),
|
|
791
|
+
),
|
|
792
|
+
),
|
|
793
|
+
);
|
|
794
|
+
lines.push(theme.fg("muted", " # TASK"));
|
|
795
|
+
if (items.length === 0) {
|
|
796
|
+
lines.push(theme.fg("warning", ` No Plan tasks matching "${filter}"`));
|
|
797
|
+
} else {
|
|
798
|
+
for (let i = 0; i < items.length; i++) {
|
|
799
|
+
const t = items[i];
|
|
800
|
+
const auto = shouldAutoRun(t);
|
|
801
|
+
const name = truncateToWidth(t.name, Math.max(10, width - 8 - (auto ? 3 : 0)), "…", true);
|
|
802
|
+
const rowText = ` ${String(i + 1).padStart(2)} ${auto ? "⚡ " : ""}${name}`;
|
|
803
|
+
lines.push(i === cursor ? theme.bg("selectedBg", theme.bold(rowText)) : rowText);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
lines.push(
|
|
807
|
+
theme.fg(
|
|
808
|
+
"dim",
|
|
809
|
+
truncateToWidth(
|
|
810
|
+
` ↑/↓ move • ⏎ run • esc${filter ? ` • filter: "${filter}"` : ""}`,
|
|
811
|
+
width - 2,
|
|
812
|
+
"",
|
|
813
|
+
),
|
|
814
|
+
),
|
|
815
|
+
);
|
|
816
|
+
return lines;
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function handleInput(data: string) {
|
|
820
|
+
if (settled) return;
|
|
821
|
+
const items = visible();
|
|
822
|
+
if (kb.matches(data, "tui.select.cancel") || matchesKey(data, "escape")) {
|
|
823
|
+
finish(null);
|
|
824
|
+
return;
|
|
825
|
+
}
|
|
826
|
+
if (kb.matches(data, "tui.select.confirm") || matchesKey(data, "enter")) {
|
|
827
|
+
finish(items[cursor] ?? null);
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
if (kb.matches(data, "tui.select.up") || matchesKey(data, "up")) {
|
|
831
|
+
if (items.length > 0) cursor = cursor === 0 ? items.length - 1 : cursor - 1;
|
|
832
|
+
tui.requestRender();
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
if (kb.matches(data, "tui.select.down") || matchesKey(data, "down")) {
|
|
836
|
+
if (items.length > 0) cursor = cursor === items.length - 1 ? 0 : cursor + 1;
|
|
837
|
+
tui.requestRender();
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
if (kb.matches(data, "tui.editor.deleteCharBackward") || matchesKey(data, "backspace")) {
|
|
841
|
+
filter = filter.slice(0, -1);
|
|
842
|
+
cursor = 0;
|
|
843
|
+
tui.requestRender();
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
if (isPrintable(data)) {
|
|
847
|
+
filter += data;
|
|
848
|
+
cursor = 0;
|
|
849
|
+
tui.requestRender();
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
return { render, handleInput, invalidate: () => {} };
|
|
854
|
+
});
|
|
855
|
+
});
|
|
856
|
+
}
|
|
857
|
+
|
|
264
858
|
/** Delay before the selector reveals the selected task's prompt preview. */
|
|
265
859
|
const PREVIEW_DELAY_MS = 2000;
|
|
266
860
|
/** Max lines of the prompt shown in the selector preview. */
|
|
267
861
|
const PREVIEW_MAX_LINES = 3;
|
|
268
862
|
|
|
269
863
|
/** Outcome of one chain step's run (see sendAndWait). */
|
|
270
|
-
type ChainStepOutcome = "completed" | "aborted" | "error" | "failed-to-start";
|
|
271
|
-
|
|
272
864
|
/**
|
|
273
865
|
* Status of one chain step for the below-prompt status widget: pending
|
|
274
866
|
* (not reached yet), running (its turn is in flight), waiting (fill-first:
|
|
@@ -319,6 +911,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
319
911
|
resolve: (outcome: ChainStepOutcome) => void;
|
|
320
912
|
/** Which chain step (0-based) this waiter belongs to — for the report. */
|
|
321
913
|
stepIndex: number;
|
|
914
|
+
startTime?: number;
|
|
322
915
|
} | null = null;
|
|
323
916
|
|
|
324
917
|
function settleChainWaiter(outcome: ChainStepOutcome) {
|
|
@@ -326,6 +919,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
326
919
|
const waiter = chainWaiter;
|
|
327
920
|
chainWaiter = null;
|
|
328
921
|
if (waiter.timer) clearTimeout(waiter.timer);
|
|
922
|
+
if (waiter.startTime !== undefined) {
|
|
923
|
+
chainDurations[waiter.stepIndex] = performance.now() - waiter.startTime;
|
|
924
|
+
}
|
|
329
925
|
waiter.resolve(outcome);
|
|
330
926
|
}
|
|
331
927
|
|
|
@@ -343,18 +939,49 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
343
939
|
// their event waiters (the old chain's sendAndWait would resolve on the
|
|
344
940
|
// new chain's step), so starting one is refused until the first ends.
|
|
345
941
|
let chainActive = false;
|
|
942
|
+
// Per-step durations (ms) for the chain-end summary. Indexed by step
|
|
943
|
+
// position; 0 means the step was not timed (e.g. blocked before running).
|
|
944
|
+
let chainDurations: number[] = [];
|
|
945
|
+
// For single auto-run tasks: the task name whose summary we post after
|
|
946
|
+
// agent_end. Only set when a Plan task (auto-run) is sent via
|
|
947
|
+
// pi.sendUserMessage (which does NOT arm a chainWaiter). The grace timer
|
|
948
|
+
// mirrors the chain's failed-to-start handling: a send that fails before
|
|
949
|
+
// the run starts emits no agent events at all, so without it the stale
|
|
950
|
+
// flag would post a spurious summary for the next unrelated turn.
|
|
951
|
+
let pendingSummaryTask: string | null = null;
|
|
952
|
+
let pendingSummaryTimer: NodeJS.Timeout | null = null;
|
|
953
|
+
|
|
954
|
+
/** Clear the auto-run summary flag and its grace timer (session start). */
|
|
955
|
+
function resetPendingSummary(): void {
|
|
956
|
+
pendingSummaryTask = null;
|
|
957
|
+
if (pendingSummaryTimer) {
|
|
958
|
+
clearTimeout(pendingSummaryTimer);
|
|
959
|
+
pendingSummaryTimer = null;
|
|
960
|
+
}
|
|
961
|
+
}
|
|
346
962
|
// Whether chain runs write a Markdown report file (config `report`,
|
|
347
963
|
// default true). Refreshed whenever the config is (re)loaded.
|
|
348
964
|
let reportEnabled = true;
|
|
349
965
|
// The in-flight chain's report file: its path (absolute + relative for
|
|
350
|
-
// display),
|
|
351
|
-
//
|
|
352
|
-
//
|
|
353
|
-
//
|
|
354
|
-
//
|
|
966
|
+
// display), the precomputed header (deferred — written together with the
|
|
967
|
+
// first step section, so a chain that dies before that leaves no
|
|
968
|
+
// header-only file behind), whether the file exists on disk yet, when the
|
|
969
|
+
// current step's run actually started (agent_start; null until then and
|
|
970
|
+
// for failed-to-start steps), whether the footer has been appended, the
|
|
971
|
+
// index of the last step section written (dedupes retried runs), and
|
|
972
|
+
// whether any step section carried result text (the file is worth
|
|
973
|
+
// keeping).
|
|
974
|
+
// Max in-memory sections kept for inline display; the file on disk retains
|
|
975
|
+
// all steps. Bounded to avoid holding 400KB–2MB of assistant text per chain.
|
|
976
|
+
const MAX_INLINE_SECTIONS = 3;
|
|
977
|
+
|
|
355
978
|
let chainReport: {
|
|
356
979
|
path: string;
|
|
357
980
|
display: string;
|
|
981
|
+
/** Precomputed header; written together with the first section. */
|
|
982
|
+
header: string;
|
|
983
|
+
/** True once the file exists on disk (header written). */
|
|
984
|
+
written: boolean;
|
|
358
985
|
stepStartedAt: Date | null;
|
|
359
986
|
/** True once the summary (or abandoned) footer has been appended. */
|
|
360
987
|
footerWritten: boolean;
|
|
@@ -362,8 +989,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
362
989
|
lastStepSection: number;
|
|
363
990
|
/** True once a step section with result text was appended. */
|
|
364
991
|
hasContent: boolean;
|
|
365
|
-
/**
|
|
366
|
-
|
|
992
|
+
/**
|
|
993
|
+
* In-memory section data for inline display (populated in agent_end,
|
|
994
|
+
* bounded to the last MAX_INLINE_SECTIONS). `index` is the step's
|
|
995
|
+
* position in the chain (retries share it); `markdown` is the formatted section.
|
|
996
|
+
*/
|
|
997
|
+
sections: Array<{ index: number; markdown: string }>;
|
|
367
998
|
} | null = null;
|
|
368
999
|
|
|
369
1000
|
/** Status marker glyph (all one column wide) with its color. */
|
|
@@ -425,7 +1056,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
425
1056
|
* removes the status widget so nothing lingers below the prompt; otherwise
|
|
426
1057
|
* keeps it as a trace with the report path in the note.
|
|
427
1058
|
*/
|
|
428
|
-
|
|
1059
|
+
/**
|
|
1060
|
+
* Write the deferred report header so the file exists before the first
|
|
1061
|
+
* section/footer append. Returns false when there is no report or the
|
|
1062
|
+
* write failed (a warning was shown); the chain runs on without a file.
|
|
1063
|
+
*/
|
|
1064
|
+
function ensureReportFile(ctx: ExtensionContext | null): boolean {
|
|
1065
|
+
const report = chainReport;
|
|
1066
|
+
if (!report || report.written) return report !== null;
|
|
1067
|
+
try {
|
|
1068
|
+
writeFileSync(report.path, report.header);
|
|
1069
|
+
report.written = true;
|
|
1070
|
+
return true;
|
|
1071
|
+
} catch (err) {
|
|
1072
|
+
ctx?.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
|
|
1073
|
+
return false;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
async function finishReport(ctx: ExtensionContext): Promise<void> {
|
|
429
1078
|
if (!chainReport || !chainStatus) return;
|
|
430
1079
|
const statuses = chainStatus.steps.map((s) => s.status);
|
|
431
1080
|
// Nothing worth keeping — remove the (mostly) empty file so a quick
|
|
@@ -435,19 +1084,21 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
435
1084
|
chainReport = null;
|
|
436
1085
|
return;
|
|
437
1086
|
}
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
1087
|
+
if (ensureReportFile(ctx)) {
|
|
1088
|
+
try {
|
|
1089
|
+
appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
|
|
1090
|
+
chainReport.footerWritten = true;
|
|
1091
|
+
} catch (err) {
|
|
1092
|
+
ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
|
|
1093
|
+
}
|
|
443
1094
|
}
|
|
444
1095
|
const prev = chainStatus.note ? `${chainStatus.note} • ` : "";
|
|
445
1096
|
chainStatus.note = `${prev}📄 ${chainReport.display}`;
|
|
446
|
-
// Chain fully done → show inline report
|
|
1097
|
+
// Chain fully done → show the inline report, then clear the
|
|
447
1098
|
// trace widget. Chain stopped early → keep the trace widget.
|
|
448
1099
|
const allDone = statuses.every((s) => s === "completed");
|
|
449
1100
|
if (allDone) {
|
|
450
|
-
showInlineReport();
|
|
1101
|
+
await showInlineReport(ctx);
|
|
451
1102
|
clearChainWidget(ctx);
|
|
452
1103
|
} else {
|
|
453
1104
|
updateChainWidget(ctx);
|
|
@@ -455,63 +1106,69 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
455
1106
|
}
|
|
456
1107
|
|
|
457
1108
|
/**
|
|
458
|
-
*
|
|
459
|
-
* completes. Built from in-memory section data (no file read
|
|
460
|
-
*
|
|
1109
|
+
* Show the full chain report in an ephemeral editor view when a chain
|
|
1110
|
+
* completes (TUI only). Built from in-memory section data (no file read
|
|
1111
|
+
* needed). Nothing is persisted to the session — the view closes with
|
|
1112
|
+
* Esc and the report file on disk is the permanent artifact. In non-TUI
|
|
1113
|
+
* modes the editor is a no-op; the final notification carries the file
|
|
1114
|
+
* path.
|
|
461
1115
|
*/
|
|
462
|
-
function showInlineReport(): void {
|
|
1116
|
+
async function showInlineReport(ctx: ExtensionContext): Promise<void> {
|
|
1117
|
+
if (ctx.mode !== "tui") return;
|
|
463
1118
|
if (!chainReport || !chainStatus || chainReport.sections.length === 0) return;
|
|
464
|
-
// Reconstruct the
|
|
1119
|
+
// Reconstruct the report from in-memory sections. Only the last
|
|
1120
|
+
// MAX_INLINE_SECTIONS are kept (the file on disk has all of them), so
|
|
1121
|
+
// flag the omission when a step has no section in the window.
|
|
1122
|
+
const shownSteps = new Set(chainReport.sections.map((s) => s.index));
|
|
1123
|
+
const omitted = chainStatus.steps.length - shownSteps.size;
|
|
465
1124
|
const lines: string[] = [];
|
|
466
1125
|
lines.push(`# do-always chain report — ${new Date().toISOString().slice(0, 10)}`);
|
|
467
1126
|
lines.push("");
|
|
468
1127
|
lines.push(`- Project: ${chainReport.display}`);
|
|
469
1128
|
lines.push(`- Steps: ${chainStatus.steps.map((s) => s.name).join(" → ")}`);
|
|
470
1129
|
lines.push("");
|
|
471
|
-
|
|
472
|
-
|
|
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);
|
|
1130
|
+
if (omitted > 0) {
|
|
1131
|
+
lines.push(`> … ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted — see ${chainReport.display}`);
|
|
481
1132
|
lines.push("");
|
|
482
1133
|
}
|
|
1134
|
+
for (const sec of chainReport.sections) {
|
|
1135
|
+
lines.push(sec.markdown);
|
|
1136
|
+
}
|
|
483
1137
|
// Footer.
|
|
484
1138
|
const statuses = chainStatus.steps.map((s) => s.status);
|
|
485
1139
|
lines.push(reportFooter(statuses, new Date()));
|
|
486
1140
|
|
|
487
|
-
//
|
|
488
|
-
pi.sendMessage
|
|
489
|
-
|
|
490
|
-
content: lines.join("\n"),
|
|
491
|
-
display: true,
|
|
492
|
-
details: chainReport.display,
|
|
493
|
-
});
|
|
1141
|
+
// Ephemeral editor view: read the report, Esc to dismiss. Unlike
|
|
1142
|
+
// pi.sendMessage this persists nothing to the session.
|
|
1143
|
+
await ctx.ui.editor("do-always — chain report", lines.join("\n"));
|
|
494
1144
|
}
|
|
495
1145
|
|
|
496
1146
|
/**
|
|
497
1147
|
* Close an in-flight report that never reached a terminal path (e.g.,
|
|
498
1148
|
* the session ended mid-chain): delete the file when the run produced
|
|
499
|
-
* nothing worth keeping
|
|
500
|
-
* does not stay
|
|
1149
|
+
* nothing worth keeping (a no-op when the deferred header was never
|
|
1150
|
+
* written), otherwise append an "abandoned" footer so it does not stay
|
|
1151
|
+
* header-only on disk.
|
|
501
1152
|
*/
|
|
502
1153
|
function closeAbandonedReport(statuses: string[]): void {
|
|
503
1154
|
if (!chainReport || chainReport.footerWritten) return;
|
|
504
1155
|
if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
|
|
505
|
-
// Nothing worth keeping — remove the
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
1156
|
+
// Nothing worth keeping — remove the file if it was written.
|
|
1157
|
+
if (chainReport.written) {
|
|
1158
|
+
try {
|
|
1159
|
+
unlinkSync(chainReport.path);
|
|
1160
|
+
} catch {
|
|
1161
|
+
// Best effort — the file stays on disk.
|
|
1162
|
+
}
|
|
510
1163
|
}
|
|
511
1164
|
return;
|
|
512
1165
|
}
|
|
513
1166
|
try {
|
|
514
|
-
|
|
1167
|
+
if (chainReport.written) {
|
|
1168
|
+
appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
|
|
1169
|
+
} else {
|
|
1170
|
+
writeFileSync(chainReport.path, chainReport.header + reportAbandonedFooter(statuses, new Date()));
|
|
1171
|
+
}
|
|
515
1172
|
} catch {
|
|
516
1173
|
// Best effort — the report file stays as-is on disk.
|
|
517
1174
|
}
|
|
@@ -565,7 +1222,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
565
1222
|
}
|
|
566
1223
|
|
|
567
1224
|
pi.on("agent_start", () => {
|
|
568
|
-
if (chainWaiter)
|
|
1225
|
+
if (chainWaiter) {
|
|
1226
|
+
chainWaiter.started = true;
|
|
1227
|
+
chainWaiter.startTime = performance.now();
|
|
1228
|
+
}
|
|
1229
|
+
// The run actually began — cancel the auto-run task's failed-to-start
|
|
1230
|
+
// grace timer (its summary posts at agent_end).
|
|
1231
|
+
if (pendingSummaryTimer) {
|
|
1232
|
+
clearTimeout(pendingSummaryTimer);
|
|
1233
|
+
pendingSummaryTimer = null;
|
|
1234
|
+
}
|
|
569
1235
|
// The run actually began — time the step for the report.
|
|
570
1236
|
if (chainReport) chainReport.stepStartedAt = new Date();
|
|
571
1237
|
// Fill-first: step 1 left the editor and is running — update the
|
|
@@ -579,12 +1245,47 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
579
1245
|
clearChainWidget(lastCtx);
|
|
580
1246
|
}
|
|
581
1247
|
});
|
|
1248
|
+
/** The turn's last assistant message (backward scan — no copy/reverse). */
|
|
1249
|
+
function lastAssistantMessage(messages: AgentEndEvent["messages"]) {
|
|
1250
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
1251
|
+
const m = messages[i];
|
|
1252
|
+
if (m.role === "assistant") return m;
|
|
1253
|
+
}
|
|
1254
|
+
return null;
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
/** Map an assistant message's stopReason to a chain step outcome. */
|
|
1258
|
+
function outcomeFromStopReason(stopReason: string | undefined): "completed" | "aborted" | "error" {
|
|
1259
|
+
if (stopReason === "aborted") return "aborted";
|
|
1260
|
+
if (stopReason === "error") return "error";
|
|
1261
|
+
return "completed";
|
|
1262
|
+
}
|
|
1263
|
+
|
|
582
1264
|
pi.on("agent_end", (event) => {
|
|
1265
|
+
// The agent may have changed the repo — drop the TTL context cache so
|
|
1266
|
+
// the next action sees the new tree (a running chain keeps its own
|
|
1267
|
+
// per-action snapshot and is unaffected).
|
|
1268
|
+
contextCache = null;
|
|
1269
|
+
// Single auto-run task summary (no chainWaiter: pi.sendUserMessage is
|
|
1270
|
+
// fire-and-forget, so no armWaiter is called).
|
|
1271
|
+
if (!chainWaiter && pendingSummaryTask) {
|
|
1272
|
+
if (pendingSummaryTimer) {
|
|
1273
|
+
clearTimeout(pendingSummaryTimer);
|
|
1274
|
+
pendingSummaryTimer = null;
|
|
1275
|
+
}
|
|
1276
|
+
const lastAssistant = lastAssistantMessage(event.messages);
|
|
1277
|
+
if (lastAssistant) {
|
|
1278
|
+
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
1279
|
+
const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
|
|
1280
|
+
lastCtx?.ui.notify(`do-always: ${summary}`, "info");
|
|
1281
|
+
}
|
|
1282
|
+
pendingSummaryTask = null;
|
|
1283
|
+
return;
|
|
1284
|
+
}
|
|
583
1285
|
if (!chainWaiter) return;
|
|
584
|
-
const lastAssistant =
|
|
1286
|
+
const lastAssistant = lastAssistantMessage(event.messages);
|
|
585
1287
|
if (lastAssistant) {
|
|
586
|
-
const
|
|
587
|
-
const outcome = stopReason === "aborted" ? "aborted" : stopReason === "error" ? "error" : "completed";
|
|
1288
|
+
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
588
1289
|
chainWaiter.outcome = outcome;
|
|
589
1290
|
// Append this step's result to the report while the transcript is
|
|
590
1291
|
// fresh (the step's final assistant message is its result).
|
|
@@ -595,23 +1296,30 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
595
1296
|
// report shows both attempts without a duplicate heading.
|
|
596
1297
|
const isRetry = chainReport.lastStepSection === idx;
|
|
597
1298
|
const name = chainStatus.steps[idx]?.name ?? `step ${idx + 1}`;
|
|
1299
|
+
const displayName = isRetry ? `${name} (retry)` : name;
|
|
598
1300
|
const text = assistantText(lastAssistant.content);
|
|
599
1301
|
const section = reportStepSection(
|
|
600
1302
|
idx,
|
|
601
|
-
|
|
1303
|
+
displayName,
|
|
602
1304
|
outcome,
|
|
603
1305
|
chainReport.stepStartedAt,
|
|
604
1306
|
new Date(),
|
|
605
1307
|
text,
|
|
606
1308
|
);
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
1309
|
+
if (ensureReportFile(lastCtx)) {
|
|
1310
|
+
try {
|
|
1311
|
+
appendFileSync(chainReport.path, section);
|
|
1312
|
+
chainReport.lastStepSection = idx;
|
|
1313
|
+
if (text.trim() !== "") chainReport.hasContent = true;
|
|
1314
|
+
// Keep in-memory section data for inline display (bounded);
|
|
1315
|
+
// index lets it number sections like the file.
|
|
1316
|
+
chainReport.sections.push({ index: idx, markdown: section });
|
|
1317
|
+
if (chainReport.sections.length > MAX_INLINE_SECTIONS) {
|
|
1318
|
+
chainReport.sections.splice(0, chainReport.sections.length - MAX_INLINE_SECTIONS);
|
|
1319
|
+
}
|
|
1320
|
+
} catch (err) {
|
|
1321
|
+
lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
|
|
1322
|
+
}
|
|
615
1323
|
}
|
|
616
1324
|
chainReport.stepStartedAt = null;
|
|
617
1325
|
}
|
|
@@ -651,9 +1359,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
651
1359
|
return done;
|
|
652
1360
|
}
|
|
653
1361
|
|
|
654
|
-
/** Filter tasks by
|
|
1362
|
+
/** Filter tasks by visibility (`hidden` flag + `when` condition) and refresh the completion cache. */
|
|
655
1363
|
function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
|
|
656
|
-
const visible = tasks.filter((t) =>
|
|
1364
|
+
const visible = tasks.filter((t) => isTaskVisible(t, context));
|
|
657
1365
|
visibleCache = { cwd, visible };
|
|
658
1366
|
return visible;
|
|
659
1367
|
}
|
|
@@ -677,7 +1385,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
677
1385
|
});
|
|
678
1386
|
}
|
|
679
1387
|
|
|
680
|
-
pi.on("session_start", (_event, ctx) => {
|
|
1388
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
681
1389
|
lastCtx = ctx;
|
|
682
1390
|
// Surface config validation problems (the README promises warnings);
|
|
683
1391
|
// in non-TUI modes there is no UI, so fall back to the console.
|
|
@@ -688,16 +1396,157 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
688
1396
|
// A failed chain's status widget is a trace of the previous session;
|
|
689
1397
|
// start each session clean.
|
|
690
1398
|
clearChainWidget(ctx);
|
|
1399
|
+
// Likewise a stale auto-run summary flag (e.g., a send that never
|
|
1400
|
+
// started) must not post a spurious summary for this session's turns.
|
|
1401
|
+
resetPendingSummary();
|
|
691
1402
|
loadedCwd = ctx.cwd;
|
|
692
1403
|
const config = loadConfig(ctx.cwd, onError);
|
|
693
1404
|
tasks = config.tasks;
|
|
694
1405
|
reportEnabled = config.report;
|
|
695
|
-
refreshVisible(ctx.cwd,
|
|
1406
|
+
refreshVisible(ctx.cwd, await getContext(ctx.cwd));
|
|
696
1407
|
registerShortcut(config.shortcut, onError);
|
|
697
1408
|
});
|
|
698
1409
|
|
|
1410
|
+
// Extension-level git context cache, keyed by cwd with a short TTL:
|
|
1411
|
+
// buildContext spawns git, and the repo rarely changes between actions,
|
|
1412
|
+
// so the session_start precompute feeds the first /do-always and
|
|
1413
|
+
// repeated invocations within the window reuse the same context.
|
|
1414
|
+
// Invalidated on agent_end (the agent may have changed the repo); a
|
|
1415
|
+
// running chain keeps its own per-action snapshot (createContextCache),
|
|
1416
|
+
// so its steps reuse one context across the run.
|
|
1417
|
+
let contextCache: { cwd: string; at: number; ctx: TaskContext } | null = null;
|
|
1418
|
+
const CONTEXT_TTL_MS = 5_000;
|
|
1419
|
+
|
|
1420
|
+
async function getContext(cwd: string): Promise<TaskContext> {
|
|
1421
|
+
if (contextCache && contextCache.cwd === cwd && Date.now() - contextCache.at < CONTEXT_TTL_MS) {
|
|
1422
|
+
return contextCache.ctx;
|
|
1423
|
+
}
|
|
1424
|
+
const ctx = await buildContext(cwd);
|
|
1425
|
+
contextCache = { cwd, at: Date.now(), ctx };
|
|
1426
|
+
return ctx;
|
|
1427
|
+
}
|
|
1428
|
+
|
|
1429
|
+
/**
|
|
1430
|
+
* Per-action context cache: the first build consults the extension-level
|
|
1431
|
+
* TTL cache (getContext), and later builds within the same action reuse
|
|
1432
|
+
* that snapshot — what the user saw in the preview is what gets injected.
|
|
1433
|
+
* Created fresh at each action entry point (runDoAlways, runChain).
|
|
1434
|
+
*/
|
|
1435
|
+
function createContextCache(): {
|
|
1436
|
+
get(cwd: string): Promise<TaskContext>;
|
|
1437
|
+
} {
|
|
1438
|
+
let cached: { cwd: string; ctx: TaskContext } | null = null;
|
|
1439
|
+
return {
|
|
1440
|
+
async get(cwd: string): Promise<TaskContext> {
|
|
1441
|
+
if (cached && cached.cwd === cwd) return cached.ctx;
|
|
1442
|
+
cached = { cwd, ctx: await getContext(cwd) };
|
|
1443
|
+
return cached.ctx;
|
|
1444
|
+
},
|
|
1445
|
+
};
|
|
1446
|
+
}
|
|
1447
|
+
|
|
699
1448
|
/** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
|
|
700
1449
|
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
|
|
1450
|
+
// Commit browser: any task with `browser: "commits"` opens the
|
|
1451
|
+
// date-grouped browser first. The name check keeps configs that
|
|
1452
|
+
// predate the field working. After the selection: a task whose prompt
|
|
1453
|
+
// references {{selected_commits}} runs itself on the selection; a
|
|
1454
|
+
// generic entry (e.g. "Browse commits") proposes the Plan tasks to
|
|
1455
|
+
// run on it — hidden tasks included, since they exist to be picked
|
|
1456
|
+
// here (non-TUI: the first one whose guards pass).
|
|
1457
|
+
if (task.browser === "commits" || task.name === "Review commits") {
|
|
1458
|
+
let selected: SelectedCommit[] = [];
|
|
1459
|
+
if (ctx.mode !== "tui") {
|
|
1460
|
+
const initialData = await fetchCommitsPage(ctx.cwd, 0, 1);
|
|
1461
|
+
if (initialData.commits.length === 0) {
|
|
1462
|
+
ctx.ui.notify("do-always: no commits found", "info");
|
|
1463
|
+
return;
|
|
1464
|
+
}
|
|
1465
|
+
selected = [{ ...initialData.commits[0], selectionOrder: 1 }];
|
|
1466
|
+
} else {
|
|
1467
|
+
const initialData = await fetchCommitsPage(ctx.cwd, 0);
|
|
1468
|
+
if (initialData.commits.length === 0) {
|
|
1469
|
+
ctx.ui.notify("do-always: no commits found", "info");
|
|
1470
|
+
return;
|
|
1471
|
+
}
|
|
1472
|
+
const result = await browseCommits(ctx, initialData);
|
|
1473
|
+
if (!result) {
|
|
1474
|
+
ctx.ui.notify("do-always: commit browser cancelled", "info");
|
|
1475
|
+
return;
|
|
1476
|
+
}
|
|
1477
|
+
if (result.length === 0) {
|
|
1478
|
+
ctx.ui.notify("do-always: no commits selected", "info");
|
|
1479
|
+
return;
|
|
1480
|
+
}
|
|
1481
|
+
selected = result;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
// Which task runs on the selection: the originating task when its
|
|
1485
|
+
// prompt consumes {{selected_commits}}; otherwise a Plan task
|
|
1486
|
+
// picked by the user (TUI) or the first whose guards pass (non-TUI).
|
|
1487
|
+
const planTasks = tasks.filter(
|
|
1488
|
+
(t) =>
|
|
1489
|
+
!t.notForCommits &&
|
|
1490
|
+
evaluateWhen(t, context) &&
|
|
1491
|
+
(t.category ?? "").trim().toLowerCase() === "plan",
|
|
1492
|
+
);
|
|
1493
|
+
if (planTasks.length === 0) {
|
|
1494
|
+
ctx.ui.notify("do-always: no Plan task available to run on the selected commits", "info");
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
let chosen: DoAlwaysTask | undefined;
|
|
1498
|
+
if (/\{\{\s*selected_commits\s*\}\}/.test(task.prompt)) {
|
|
1499
|
+
chosen = task;
|
|
1500
|
+
} else if (ctx.mode === "tui") {
|
|
1501
|
+
const picked = await pickPlanTaskForCommits(ctx, planTasks, selected.length);
|
|
1502
|
+
if (!picked) {
|
|
1503
|
+
ctx.ui.notify("do-always: no task chosen — commits not used", "info");
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
chosen = picked;
|
|
1507
|
+
} else {
|
|
1508
|
+
// Non-TUI: no picker — the first Plan task whose guards pass.
|
|
1509
|
+
chosen = planTasks.find((t) => evaluateGuards(t, context) === null);
|
|
1510
|
+
}
|
|
1511
|
+
if (!chosen) {
|
|
1512
|
+
ctx.ui.notify("do-always: no Plan task available to run on the selected commits (guards unmet)", "info");
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
|
|
1516
|
+
// Guards apply to the commit run too: a picked (or originating)
|
|
1517
|
+
// task blocked for the current tree is not sent — same as the
|
|
1518
|
+
// non-browser path below.
|
|
1519
|
+
const blocked = evaluateGuards(chosen, context);
|
|
1520
|
+
if (blocked) {
|
|
1521
|
+
ctx.ui.notify(`do-always: ${blocked}`, "info");
|
|
1522
|
+
return;
|
|
1523
|
+
}
|
|
1524
|
+
|
|
1525
|
+
// Inject the selection: substitute {{selected_commits}} when the
|
|
1526
|
+
// prompt references it, otherwise append the detail block. The run
|
|
1527
|
+
// is a single turn (not a chain): sendUserMessage + summary.
|
|
1528
|
+
const details = formatSelectedCommits(selected);
|
|
1529
|
+
const strings = toPromptContext(context);
|
|
1530
|
+
const prompt = /\{\{\s*selected_commits\s*\}\}/.test(chosen.prompt)
|
|
1531
|
+
? renderPrompt(chosen.prompt, { ...strings, selected_commits: details })
|
|
1532
|
+
: `${renderPrompt(chosen.prompt, strings)}\n\nSelected commits:\n${details}`;
|
|
1533
|
+
|
|
1534
|
+
pendingSummaryTask = chosen.name;
|
|
1535
|
+
if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
|
|
1536
|
+
pendingSummaryTimer = setTimeout(() => {
|
|
1537
|
+
pendingSummaryTimer = null;
|
|
1538
|
+
if (!pendingSummaryTask) return;
|
|
1539
|
+
const name = pendingSummaryTask;
|
|
1540
|
+
pendingSummaryTask = null;
|
|
1541
|
+
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
1542
|
+
}, 10_000);
|
|
1543
|
+
pi.sendUserMessage(prompt);
|
|
1544
|
+
ctx.ui.notify(
|
|
1545
|
+
`do-always: ${selected.length} commit${selected.length !== 1 ? "s" : ""} → ${chosen.name}`,
|
|
1546
|
+
"info",
|
|
1547
|
+
);
|
|
1548
|
+
return;
|
|
1549
|
+
}
|
|
701
1550
|
const blocked = evaluateGuards(task, context);
|
|
702
1551
|
if (blocked) {
|
|
703
1552
|
ctx.ui.notify(`do-always: ${blocked}`, "info");
|
|
@@ -709,6 +1558,19 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
709
1558
|
if (shouldAutoRun(task)) {
|
|
710
1559
|
// Fire-and-forget: sendUserMessage returns void; the run proceeds
|
|
711
1560
|
// independently (see the chain control notes for why).
|
|
1561
|
+
// Track the task so we can post a summary after agent_end.
|
|
1562
|
+
pendingSummaryTask = task.name;
|
|
1563
|
+
// If the run never starts (no agent events at all — same failure
|
|
1564
|
+
// mode the chain's grace timer handles), drop the flag so a later
|
|
1565
|
+
// unrelated turn can't post a spurious summary for this task.
|
|
1566
|
+
if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
|
|
1567
|
+
pendingSummaryTimer = setTimeout(() => {
|
|
1568
|
+
pendingSummaryTimer = null;
|
|
1569
|
+
if (!pendingSummaryTask) return;
|
|
1570
|
+
const name = pendingSummaryTask;
|
|
1571
|
+
pendingSummaryTask = null;
|
|
1572
|
+
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
1573
|
+
}, 10_000);
|
|
712
1574
|
pi.sendUserMessage(prompt);
|
|
713
1575
|
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
714
1576
|
return;
|
|
@@ -747,33 +1609,37 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
747
1609
|
return;
|
|
748
1610
|
}
|
|
749
1611
|
// Fail fast: report the first blocked step before sending anything.
|
|
750
|
-
const blocked = validateChain(tasks, { items: names, history: [] }, cache.get(ctx.cwd));
|
|
1612
|
+
const blocked = validateChain(tasks, { items: names, history: [] }, await cache.get(ctx.cwd));
|
|
751
1613
|
if (blocked) {
|
|
752
1614
|
ctx.ui.notify(`do-always: chain blocked at step ${blocked.step} (${blocked.task.name}): ${blocked.message}`, "warning");
|
|
753
1615
|
return;
|
|
754
1616
|
}
|
|
755
1617
|
chainActive = true;
|
|
756
|
-
//
|
|
757
|
-
//
|
|
1618
|
+
// Per-step durations for the chain-end summary. Re-initialized each
|
|
1619
|
+
// chain run so stale durations from a previous run don't leak in.
|
|
1620
|
+
// Pre-filled with 0 so the fill-first step (index 0, timed outside
|
|
1621
|
+
// runChainSteps) and any unrun steps don't leave `undefined` holes
|
|
1622
|
+
// that would poison the reduce with NaN.
|
|
1623
|
+
chainDurations = Array.from({ length: steps.length }, () => 0);
|
|
1624
|
+
// Report file: one per run, in the project root. The header is
|
|
1625
|
+
// deferred until the first step section (a chain that dies before
|
|
1626
|
+
// that leaves no file), and each step is appended as it finishes
|
|
1627
|
+
// (see the report section in tasks.ts). A write failure is not
|
|
758
1628
|
// fatal — the chain still runs, just without a report.
|
|
759
1629
|
if (reportEnabled) {
|
|
760
1630
|
const now = new Date();
|
|
761
1631
|
const path = resolveReportPath(ctx.cwd, now);
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
} catch (err) {
|
|
774
|
-
ctx.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
|
|
775
|
-
chainReport = null;
|
|
776
|
-
}
|
|
1632
|
+
chainReport = {
|
|
1633
|
+
path,
|
|
1634
|
+
display: relative(ctx.cwd, path),
|
|
1635
|
+
header: reportHeader(ctx.cwd, steps.map((t) => t.name), now),
|
|
1636
|
+
written: false,
|
|
1637
|
+
stepStartedAt: null,
|
|
1638
|
+
footerWritten: false,
|
|
1639
|
+
lastStepSection: -1,
|
|
1640
|
+
hasContent: false,
|
|
1641
|
+
sections: [],
|
|
1642
|
+
};
|
|
777
1643
|
}
|
|
778
1644
|
const first = steps[0];
|
|
779
1645
|
if (shouldAutoRun(first) || ctx.mode !== "tui") {
|
|
@@ -782,7 +1648,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
782
1648
|
ctx,
|
|
783
1649
|
steps.map((t, i) => ({ name: t.name, status: i === 0 ? "running" : "pending" })),
|
|
784
1650
|
);
|
|
785
|
-
await runChainSteps(steps, ctx, 0
|
|
1651
|
+
await runChainSteps(steps, ctx, 0);
|
|
786
1652
|
} finally {
|
|
787
1653
|
chainActive = false;
|
|
788
1654
|
}
|
|
@@ -798,7 +1664,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
798
1664
|
steps.map((t, i) => ({ name: t.name, status: i === 0 ? "waiting" : "pending" })),
|
|
799
1665
|
"step 1 is in the editor — press Enter to start",
|
|
800
1666
|
);
|
|
801
|
-
|
|
1667
|
+
const firstContext = await cache.get(ctx.cwd);
|
|
1668
|
+
ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(firstContext)));
|
|
802
1669
|
ctx.ui.notify(
|
|
803
1670
|
`do-always: step 1 of ${steps.length} in the editor — press Enter to run; steps 2–${steps.length} follow automatically`,
|
|
804
1671
|
"info",
|
|
@@ -807,12 +1674,26 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
807
1674
|
try {
|
|
808
1675
|
if (outcome !== "completed") {
|
|
809
1676
|
markChainStopped(ctx, 0, outcome);
|
|
810
|
-
finishReport(ctx);
|
|
1677
|
+
await finishReport(ctx);
|
|
811
1678
|
ctx.ui.notify(`do-always: step 1 — ${outcome}; chain stopped`, "error");
|
|
812
1679
|
return;
|
|
813
1680
|
}
|
|
814
1681
|
setChainStep(ctx, 0, "completed");
|
|
815
|
-
|
|
1682
|
+
// Post-step summary for step 1 (fill-first). When more steps
|
|
1683
|
+
// follow, the fresh full build doubles as step 2's pre-context
|
|
1684
|
+
// (the tree can only change via agent runs, which have settled);
|
|
1685
|
+
// a single-task chain only needs the file count.
|
|
1686
|
+
let nextContext: TaskContext | undefined;
|
|
1687
|
+
let fileCount: number;
|
|
1688
|
+
if (steps.length === 1) {
|
|
1689
|
+
fileCount = await changedFileCount(ctx.cwd);
|
|
1690
|
+
} else {
|
|
1691
|
+
nextContext = await getContext(ctx.cwd);
|
|
1692
|
+
fileCount = nextContext.files.length;
|
|
1693
|
+
}
|
|
1694
|
+
const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
|
|
1695
|
+
ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
|
|
1696
|
+
await runChainSteps(steps, ctx, 1, nextContext);
|
|
816
1697
|
} finally {
|
|
817
1698
|
chainActive = false;
|
|
818
1699
|
}
|
|
@@ -820,24 +1701,31 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
820
1701
|
}
|
|
821
1702
|
|
|
822
1703
|
/**
|
|
823
|
-
* Send chain steps `startAt..end` sequentially. Each step
|
|
824
|
-
*
|
|
825
|
-
* its run has fully settled; an aborted/errored step (or a
|
|
826
|
-
* failed to start) stops the chain.
|
|
1704
|
+
* Send chain steps `startAt..end` sequentially. Each step evaluates guards
|
|
1705
|
+
* and renders prompts against fresh repository context, and each step is
|
|
1706
|
+
* awaited until its run has fully settled; an aborted/errored step (or a
|
|
1707
|
+
* send that failed to start) stops the chain.
|
|
1708
|
+
*
|
|
1709
|
+
* Context builds are shared across steps: after each completed step the
|
|
1710
|
+
* fresh full build (needed for the file-count summary) is reused as the
|
|
1711
|
+
* next step's pre-context — the tree can only change via agent runs, and
|
|
1712
|
+
* the step has fully settled before the build runs. `initialContext` lets
|
|
1713
|
+
* the fill-first path hand over its post-step-1 build. The last step's
|
|
1714
|
+
* summary uses a single-spawn file count instead of a full build.
|
|
827
1715
|
*/
|
|
828
1716
|
async function runChainSteps(
|
|
829
1717
|
steps: DoAlwaysTask[],
|
|
830
1718
|
ctx: ExtensionContext,
|
|
831
1719
|
startAt: number,
|
|
832
|
-
|
|
1720
|
+
initialContext?: TaskContext,
|
|
833
1721
|
): Promise<void> {
|
|
1722
|
+
let context = initialContext ?? (await getContext(ctx.cwd));
|
|
834
1723
|
for (let i = startAt; i < steps.length; i++) {
|
|
835
1724
|
const step = steps[i];
|
|
836
|
-
const context = cache.get(ctx.cwd);
|
|
837
1725
|
const blocked = evaluateGuards(step, context);
|
|
838
1726
|
if (blocked) {
|
|
839
1727
|
markChainStopped(ctx, i, "skipped", blocked);
|
|
840
|
-
finishReport(ctx);
|
|
1728
|
+
await finishReport(ctx);
|
|
841
1729
|
ctx.ui.notify(`do-always: chain stopped at step ${i + 1} (${step.name}): ${blocked}`, "warning");
|
|
842
1730
|
return;
|
|
843
1731
|
}
|
|
@@ -845,19 +1733,35 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
845
1733
|
const label = `do-always: step ${i + 1}/${steps.length} — ${step.name}`;
|
|
846
1734
|
setChainStep(ctx, i, "running");
|
|
847
1735
|
ctx.ui.notify(`${label} — starting`, "info");
|
|
1736
|
+
const stepStart = performance.now();
|
|
848
1737
|
const outcome = await sendAndWait(prompt, 10_000, i);
|
|
1738
|
+
const stepDuration = chainDurations[i] || performance.now() - stepStart;
|
|
1739
|
+
chainDurations[i] = stepDuration;
|
|
849
1740
|
if (outcome === "completed") {
|
|
850
1741
|
setChainStep(ctx, i, "completed");
|
|
1742
|
+
// Post-step summary: files changed + duration. All but the last
|
|
1743
|
+
// step get a fresh full build that doubles as the next step's
|
|
1744
|
+
// pre-context; the last step only needs the count (one spawn).
|
|
1745
|
+
let fileCount: number;
|
|
1746
|
+
if (i === steps.length - 1) {
|
|
1747
|
+
fileCount = await changedFileCount(ctx.cwd);
|
|
1748
|
+
} else {
|
|
1749
|
+
context = await getContext(ctx.cwd);
|
|
1750
|
+
fileCount = context.files.length;
|
|
1751
|
+
}
|
|
1752
|
+
const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
|
|
1753
|
+
ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
|
|
851
1754
|
continue;
|
|
852
1755
|
}
|
|
853
1756
|
markChainStopped(ctx, i, outcome);
|
|
854
|
-
finishReport(ctx);
|
|
1757
|
+
await finishReport(ctx);
|
|
1758
|
+
const summary = stepSummary(outcome, step.name, stepDuration, 0);
|
|
855
1759
|
if (outcome === "failed-to-start") {
|
|
856
|
-
ctx.ui.notify(`${label} —
|
|
1760
|
+
ctx.ui.notify(`${label} — ${summary} (check model/API key); chain stopped`, "error");
|
|
857
1761
|
} else if (outcome === "aborted") {
|
|
858
|
-
ctx.ui.notify(`${label} —
|
|
1762
|
+
ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
|
|
859
1763
|
} else {
|
|
860
|
-
ctx.ui.notify(`${label} —
|
|
1764
|
+
ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
|
|
861
1765
|
}
|
|
862
1766
|
return;
|
|
863
1767
|
}
|
|
@@ -866,12 +1770,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
866
1770
|
// the prompt. Capture the display path first — finishReport clears
|
|
867
1771
|
// `chainReport` when the chain is fully done.
|
|
868
1772
|
const reportDisplay = chainReport?.display;
|
|
1773
|
+
// Chain-end summary.
|
|
1774
|
+
const completed = chainDurations.filter((d) => d > 0).length;
|
|
1775
|
+
const totalMs = chainDurations.reduce((a, b) => a + b, 0);
|
|
1776
|
+
const chainSum = chainSummary(completed, steps.length, totalMs);
|
|
869
1777
|
if (chainReport) {
|
|
870
|
-
finishReport(ctx);
|
|
871
|
-
ctx.ui.notify(`do-always:
|
|
1778
|
+
await finishReport(ctx);
|
|
1779
|
+
ctx.ui.notify(`do-always: ${chainSum} — report: ${reportDisplay}`, "info");
|
|
872
1780
|
} else {
|
|
873
1781
|
clearChainWidget(ctx);
|
|
874
|
-
ctx.ui.notify(`do-always:
|
|
1782
|
+
ctx.ui.notify(`do-always: ${chainSum}`, "info");
|
|
875
1783
|
}
|
|
876
1784
|
}
|
|
877
1785
|
|
|
@@ -897,9 +1805,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
897
1805
|
context: TaskContext,
|
|
898
1806
|
cache: ReturnType<typeof createContextCache>,
|
|
899
1807
|
): Promise<void> {
|
|
900
|
-
// Filter by the `when` condition once per session,
|
|
901
|
-
// appear, are never numbered, and can't be picked
|
|
902
|
-
|
|
1808
|
+
// Filter by the `hidden` flag and the `when` condition once per session,
|
|
1809
|
+
// so hidden tasks never appear, are never numbered, and can't be picked
|
|
1810
|
+
// (same predicate as refreshVisible, which numbers `/do-always <n>`).
|
|
1811
|
+
const visibleTasks = tasks.filter((t) => isTaskVisible(t, context));
|
|
903
1812
|
// String view for prompt rendering (derived once, used by the preview).
|
|
904
1813
|
const strings = toPromptContext(context);
|
|
905
1814
|
const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
|
|
@@ -1004,12 +1913,15 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1004
1913
|
);
|
|
1005
1914
|
return { bodyRows, itemRows, winStart, visibleHeaderNames };
|
|
1006
1915
|
}
|
|
1916
|
+
// One pass's visible state, computed once per input event / render
|
|
1917
|
+
// and shared by clampCursor, buildTable, and the navigation logic.
|
|
1918
|
+
type Visible = ReturnType<typeof getVisible>;
|
|
1007
1919
|
|
|
1008
1920
|
// Keep the cursor valid after the rows or the chain change. (The
|
|
1009
1921
|
// ORDER cell of a non-chained row is a valid cursor position: it is
|
|
1010
1922
|
// the "add" state.)
|
|
1011
|
-
function clampCursor() {
|
|
1012
|
-
const { itemRows } =
|
|
1923
|
+
function clampCursor(visible: Visible) {
|
|
1924
|
+
const { itemRows } = visible;
|
|
1013
1925
|
if (itemRows.length === 0) {
|
|
1014
1926
|
cursor = { kind: "cell", row: 0, col: "task" };
|
|
1015
1927
|
return;
|
|
@@ -1044,15 +1956,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1044
1956
|
}
|
|
1045
1957
|
|
|
1046
1958
|
// Cached last buildTable result (avoids double-work on mouse hit-test).
|
|
1047
|
-
//
|
|
1048
|
-
//
|
|
1049
|
-
|
|
1959
|
+
// The table depends on cursor and preview state, so render() must
|
|
1960
|
+
// rebuild it on every pass; handleMouse only reuses the last render's
|
|
1961
|
+
// table (or builds one if no render has happened yet, e.g. the very
|
|
1962
|
+
// first mouse event).
|
|
1963
|
+
type Table = { lines: string[]; itemLine: Map<number, DoAlwaysTask>; runLine: number; orderColX: number | null };
|
|
1964
|
+
let lastTable: Table | null = null;
|
|
1050
1965
|
|
|
1051
1966
|
// Build the full selector output for a width, plus the line map for
|
|
1052
1967
|
// mouse handling (itemLine: line -> task, runLine: the Run row,
|
|
1053
1968
|
// orderColX: where the ORDER cell starts, or null in the narrow tier).
|
|
1054
|
-
function buildTable(width: number) {
|
|
1055
|
-
const { bodyRows, itemRows, winStart, visibleHeaderNames } =
|
|
1969
|
+
function buildTable(width: number, visible: Visible = getVisible()): Table {
|
|
1970
|
+
const { bodyRows, itemRows, winStart, visibleHeaderNames } = visible;
|
|
1056
1971
|
const { tier, taskCol, descCol, orderColX } = tableGeometry(width);
|
|
1057
1972
|
const lines: string[] = [];
|
|
1058
1973
|
const itemLine = new Map<number, DoAlwaysTask>();
|
|
@@ -1114,7 +2029,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1114
2029
|
cursor.kind === "cell" && cursor.row === idx && cursor.col === "order";
|
|
1115
2030
|
const num = inTaskCol
|
|
1116
2031
|
? `${cursorMark} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
|
|
1117
|
-
:
|
|
2032
|
+
: inOrderCol && tier === "narrow"
|
|
2033
|
+
? `${theme.fg("warning", "►")} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
|
|
2034
|
+
: ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
|
|
1118
2035
|
// ⚡ is 2 columns wide, so "⚡ " takes 3 — reserve it so
|
|
1119
2036
|
// auto-run rows align with the others (ORDER cell is
|
|
1120
2037
|
// hit-tested at a fixed x).
|
|
@@ -1213,10 +2130,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1213
2130
|
|
|
1214
2131
|
return {
|
|
1215
2132
|
render(width: number) {
|
|
1216
|
-
lastTable = buildTable(width);
|
|
1217
|
-
return lastTable
|
|
2133
|
+
lastTable = buildTable(width, getVisible());
|
|
2134
|
+
return lastTable.lines;
|
|
2135
|
+
},
|
|
2136
|
+
invalidate() {
|
|
2137
|
+
clearPreviewTimer();
|
|
1218
2138
|
},
|
|
1219
|
-
invalidate() {},
|
|
1220
2139
|
handleInput(data: string) {
|
|
1221
2140
|
// Direct pick by number (1-9) — runs the task immediately (the
|
|
1222
2141
|
// classic fast path), closing the selector and discarding the
|
|
@@ -1227,16 +2146,17 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1227
2146
|
return;
|
|
1228
2147
|
}
|
|
1229
2148
|
// Filter typing (Backspace edits the filter; with an empty
|
|
1230
|
-
// filter it undoes the last chain add).
|
|
2149
|
+
// filter it undoes the last chain add). The filter change
|
|
2150
|
+
// invalidates the visible set, so the clamp gets a fresh pass.
|
|
1231
2151
|
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
|
1232
2152
|
if (filter.length > 0) {
|
|
1233
2153
|
filter = filter.slice(0, -1);
|
|
1234
|
-
clampCursor();
|
|
2154
|
+
clampCursor(getVisible());
|
|
1235
2155
|
} else {
|
|
1236
2156
|
const { state, removed } = chainUndo(chain);
|
|
1237
2157
|
if (removed) {
|
|
1238
2158
|
chain = state;
|
|
1239
|
-
clampCursor();
|
|
2159
|
+
clampCursor(getVisible());
|
|
1240
2160
|
}
|
|
1241
2161
|
}
|
|
1242
2162
|
resetPreview();
|
|
@@ -1245,12 +2165,16 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1245
2165
|
}
|
|
1246
2166
|
if (isPrintable(data)) {
|
|
1247
2167
|
filter += data;
|
|
1248
|
-
clampCursor();
|
|
2168
|
+
clampCursor(getVisible());
|
|
1249
2169
|
resetPreview();
|
|
1250
2170
|
tui.requestRender();
|
|
1251
2171
|
return;
|
|
1252
2172
|
}
|
|
1253
|
-
|
|
2173
|
+
// One pass for the rest of the event: navigation, confirm,
|
|
2174
|
+
// and clear below all share this result (chain edits don't
|
|
2175
|
+
// change itemRows, so it stays valid).
|
|
2176
|
+
const visible = getVisible();
|
|
2177
|
+
const { itemRows } = visible;
|
|
1254
2178
|
if (itemRows.length === 0) {
|
|
1255
2179
|
// Nothing to navigate; only Esc is useful here.
|
|
1256
2180
|
if (kb.matches(data, "tui.select.cancel")) finishCancel();
|
|
@@ -1317,6 +2241,51 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1317
2241
|
tui.requestRender();
|
|
1318
2242
|
return;
|
|
1319
2243
|
}
|
|
2244
|
+
// Extended navigation: home, end, pageup, pagedown.
|
|
2245
|
+
if (matchesKey(data, "home")) {
|
|
2246
|
+
cursor = { kind: "cell", row: 0, col: cursor.kind === "cell" ? cursor.col : "task" };
|
|
2247
|
+
lastCellRow = 0;
|
|
2248
|
+
resetPreview();
|
|
2249
|
+
tui.requestRender();
|
|
2250
|
+
return;
|
|
2251
|
+
}
|
|
2252
|
+
if (matchesKey(data, "end")) {
|
|
2253
|
+
cursor = { kind: "run" };
|
|
2254
|
+
lastCellRow = itemRows.length - 1;
|
|
2255
|
+
resetPreview();
|
|
2256
|
+
tui.requestRender();
|
|
2257
|
+
return;
|
|
2258
|
+
}
|
|
2259
|
+
if (matchesKey(data, "pageUp")) {
|
|
2260
|
+
if (cursor.kind === "run") {
|
|
2261
|
+
cursor = { kind: "cell", row: Math.max(0, itemRows.length - maxVisible), col: "task" };
|
|
2262
|
+
} else {
|
|
2263
|
+
cursor = {
|
|
2264
|
+
kind: "cell",
|
|
2265
|
+
row: Math.max(0, cursor.row - maxVisible),
|
|
2266
|
+
col: cursor.col,
|
|
2267
|
+
};
|
|
2268
|
+
}
|
|
2269
|
+
lastCellRow = cursor.row;
|
|
2270
|
+
resetPreview();
|
|
2271
|
+
tui.requestRender();
|
|
2272
|
+
return;
|
|
2273
|
+
}
|
|
2274
|
+
if (matchesKey(data, "pageDown")) {
|
|
2275
|
+
if (cursor.kind === "cell") {
|
|
2276
|
+
const next = cursor.row + maxVisible;
|
|
2277
|
+
if (next >= itemRows.length) {
|
|
2278
|
+
cursor = { kind: "run" };
|
|
2279
|
+
lastCellRow = itemRows.length - 1;
|
|
2280
|
+
} else {
|
|
2281
|
+
cursor = { kind: "cell", row: next, col: cursor.col };
|
|
2282
|
+
lastCellRow = next;
|
|
2283
|
+
}
|
|
2284
|
+
}
|
|
2285
|
+
resetPreview();
|
|
2286
|
+
tui.requestRender();
|
|
2287
|
+
return;
|
|
2288
|
+
}
|
|
1320
2289
|
// Confirm: context-dependent.
|
|
1321
2290
|
if (kb.matches(data, "tui.select.confirm")) {
|
|
1322
2291
|
if (cursor.kind === "run") {
|
|
@@ -1336,6 +2305,13 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1336
2305
|
return;
|
|
1337
2306
|
}
|
|
1338
2307
|
// ORDER column: toggle this task's chain membership.
|
|
2308
|
+
// Browser tasks can't be chained: the chain runner sends
|
|
2309
|
+
// prompts directly, so the browser (and
|
|
2310
|
+
// {{selected_commits}}) never run — run them on their own.
|
|
2311
|
+
if (row.task.browser) {
|
|
2312
|
+
ctx.ui.notify(`do-always: "${row.task.name}" opens a browser — it can't be chained, run it on its own`, "info");
|
|
2313
|
+
return;
|
|
2314
|
+
}
|
|
1339
2315
|
if (chain.items.includes(row.task.name)) {
|
|
1340
2316
|
chain = chainRemove(chain, row.task.name);
|
|
1341
2317
|
} else {
|
|
@@ -1354,7 +2330,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1354
2330
|
if (matchesKey(data, "ctrl+u")) {
|
|
1355
2331
|
if (chain.items.length > 0) {
|
|
1356
2332
|
chain = chainClear();
|
|
1357
|
-
clampCursor();
|
|
2333
|
+
clampCursor(visible);
|
|
1358
2334
|
resetPreview();
|
|
1359
2335
|
tui.requestRender();
|
|
1360
2336
|
}
|
|
@@ -1378,11 +2354,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1378
2354
|
return { handled: true, render: true };
|
|
1379
2355
|
}
|
|
1380
2356
|
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
|
1381
|
-
|
|
1382
|
-
//
|
|
1383
|
-
//
|
|
1384
|
-
//
|
|
1385
|
-
|
|
2357
|
+
// Reuse the last render's table for hit-testing (avoids rebuilding
|
|
2358
|
+
// the table twice per mouse event). The table is always fresh
|
|
2359
|
+
// because handleInput calls requestRender before the next mouse
|
|
2360
|
+
// event can arrive; build one if no render has happened yet.
|
|
2361
|
+
// One getVisible() per event, shared by the (possibly fresh)
|
|
2362
|
+
// table and the itemRows lookup below.
|
|
2363
|
+
const visible = getVisible();
|
|
2364
|
+
const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width, visible);
|
|
1386
2365
|
// Pinned Run row: press runs the chain.
|
|
1387
2366
|
if (runLine >= 0 && event.y === runLine) {
|
|
1388
2367
|
if (event.type === "press" && chain.items.length > 0) {
|
|
@@ -1392,19 +2371,25 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1392
2371
|
}
|
|
1393
2372
|
const task = itemLine.get(event.y);
|
|
1394
2373
|
if (!task) return undefined;
|
|
1395
|
-
const { itemRows } =
|
|
2374
|
+
const { itemRows } = visible;
|
|
1396
2375
|
const idx = itemRows.findIndex((r) => r.task === task);
|
|
1397
2376
|
if (idx < 0) return undefined;
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
2377
|
+
// ORDER cell: press toggles chain membership.
|
|
2378
|
+
if (orderColX !== null && event.x >= orderColX) {
|
|
2379
|
+
if (event.type === "press") {
|
|
2380
|
+
if (task.browser) {
|
|
2381
|
+
// Same rule as the keyboard toggle: browser tasks
|
|
2382
|
+
// can't be chained (see ORDER column).
|
|
2383
|
+
ctx.ui.notify(`do-always: "${task.name}" opens a browser — it can't be chained, run it on its own`, "info");
|
|
2384
|
+
return { handled: true };
|
|
2385
|
+
}
|
|
2386
|
+
chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
|
|
2387
|
+
clampCursor(visible);
|
|
2388
|
+
resetPreview();
|
|
2389
|
+
return { handled: true, render: true };
|
|
2390
|
+
}
|
|
2391
|
+
return { handled: true }; // swallow the click after the press action
|
|
1405
2392
|
}
|
|
1406
|
-
return { handled: true }; // swallow the click after the press action
|
|
1407
|
-
}
|
|
1408
2393
|
// Task area: press selects, click runs (the classic fast path).
|
|
1409
2394
|
if (event.type === "press") {
|
|
1410
2395
|
mousePressedIndex = idx;
|
|
@@ -1471,7 +2456,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
1471
2456
|
// One context per command run: shared by visibility filtering, rendering,
|
|
1472
2457
|
// and the completion cache (never inside a render loop).
|
|
1473
2458
|
const cache = createContextCache();
|
|
1474
|
-
const context = cache.get(ctx.cwd);
|
|
2459
|
+
const context = await cache.get(ctx.cwd);
|
|
1475
2460
|
const visible = refreshVisible(ctx.cwd, context);
|
|
1476
2461
|
|
|
1477
2462
|
const arg = args.trim();
|