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