pi-do-always 0.9.0 → 0.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +92 -16
- package/extensions/pi-do-always/do-always.json +2 -2
- package/extensions/pi-do-always/index.ts +1364 -167
- package/extensions/pi-do-always/tasks.ts +506 -32
- package/package.json +1 -1
- package/pi.image.png +0 -0
|
@@ -33,43 +33,95 @@
|
|
|
33
33
|
* If no config file exists, built-in default tasks are used.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
-
import {
|
|
37
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
38
|
-
import { join } from "node:path";
|
|
39
|
-
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
36
|
+
import { execFile } from "node:child_process";
|
|
37
|
+
import { appendFileSync, existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
38
|
+
import { join, relative } from "node:path";
|
|
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,
|
|
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
|
+
chainSummary,
|
|
63
|
+
chainUndo,
|
|
53
64
|
evaluateGuards,
|
|
54
65
|
evaluateWhen,
|
|
66
|
+
formatChainSequence,
|
|
55
67
|
formatList,
|
|
56
68
|
groupTasksByCategory,
|
|
57
69
|
isValidKeyId,
|
|
58
70
|
mergeTasks,
|
|
59
71
|
parseConfig,
|
|
72
|
+
parseConfigRegexpValueForKey,
|
|
73
|
+
parseCommitSubject,
|
|
60
74
|
parseStatusPorcelain,
|
|
75
|
+
parseStatusStagedUnstaged,
|
|
61
76
|
orderTasksByCategory,
|
|
77
|
+
reportAbandonedFooter,
|
|
78
|
+
reportFooter,
|
|
79
|
+
reportHeader,
|
|
80
|
+
reportStepSection,
|
|
81
|
+
reportWorthKeeping,
|
|
62
82
|
renderPrompt,
|
|
83
|
+
resolveReportPath,
|
|
63
84
|
resolveShortcut,
|
|
64
85
|
resolveTask,
|
|
65
86
|
shouldAutoRun,
|
|
66
|
-
|
|
87
|
+
stepSummary,
|
|
67
88
|
toPromptContext,
|
|
89
|
+
validateChain,
|
|
90
|
+
type ChainStepOutcome,
|
|
68
91
|
type DoAlwaysTask,
|
|
69
92
|
type TaskContext,
|
|
70
|
-
type
|
|
93
|
+
type TableRow,
|
|
71
94
|
} from "./tasks";
|
|
72
95
|
|
|
96
|
+
/**
|
|
97
|
+
* What the selector resolved to: a single task (the classic pick), a chain to
|
|
98
|
+
* run, or a cancel.
|
|
99
|
+
*/
|
|
100
|
+
type SelectorResult =
|
|
101
|
+
| { kind: "single"; task: DoAlwaysTask }
|
|
102
|
+
| { kind: "chain"; names: string[] }
|
|
103
|
+
| { kind: "cancel" };
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Selector cursor: a cell in the task table (TASK or ORDER column) or the
|
|
107
|
+
* pinned Run row.
|
|
108
|
+
*/
|
|
109
|
+
type Cursor = { kind: "cell"; row: number; col: "task" | "order" } | { kind: "run" };
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Safely read a config file's contents, returning null if missing or unreadable.
|
|
113
|
+
* Read errors are reported through `onError`.
|
|
114
|
+
*/
|
|
115
|
+
function readConfigFile(filePath: string, onError: (message: string) => void): string | null {
|
|
116
|
+
if (!existsSync(filePath)) return null;
|
|
117
|
+
try {
|
|
118
|
+
return readFileSync(filePath, "utf-8");
|
|
119
|
+
} catch (err) {
|
|
120
|
+
onError(`do-always: could not read ${filePath}: ${err}`);
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
73
125
|
/**
|
|
74
126
|
* Load tasks and the selector shortcut from config files.
|
|
75
127
|
* Project-local tasks override global tasks with the same name (or are
|
|
@@ -86,16 +138,21 @@ function loadConfig(
|
|
|
86
138
|
): {
|
|
87
139
|
tasks: DoAlwaysTask[];
|
|
88
140
|
shortcut: string | null;
|
|
141
|
+
/** Whether chain runs write a Markdown report file (default true). */
|
|
142
|
+
report: boolean;
|
|
89
143
|
} {
|
|
90
144
|
const globalPath = join(getAgentDir(), "do-always.json");
|
|
91
145
|
const projectPath = join(cwd, CONFIG_DIR_NAME, "do-always.json");
|
|
92
146
|
|
|
93
|
-
const
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
const
|
|
97
|
-
? parseConfig(
|
|
98
|
-
: { tasks: [], shortcut: undefined, merge: undefined };
|
|
147
|
+
const globalRaw = readConfigFile(globalPath, onError);
|
|
148
|
+
const projectRaw = readConfigFile(projectPath, onError);
|
|
149
|
+
|
|
150
|
+
const global = globalRaw !== null
|
|
151
|
+
? parseConfig(globalRaw, globalPath, onError)
|
|
152
|
+
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
153
|
+
const project = projectRaw !== null
|
|
154
|
+
? parseConfig(projectRaw, projectPath, onError)
|
|
155
|
+
: { tasks: [], shortcut: undefined, merge: undefined, report: undefined };
|
|
99
156
|
|
|
100
157
|
// The project file's merge mode wins; otherwise the global value; otherwise
|
|
101
158
|
// override (the historical behavior), so existing configs are unaffected.
|
|
@@ -106,6 +163,9 @@ function loadConfig(
|
|
|
106
163
|
// `/do-always <n>`, and `list` all share one consistent order.
|
|
107
164
|
tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS, mode)),
|
|
108
165
|
shortcut: resolveShortcut(global.shortcut, project.shortcut),
|
|
166
|
+
// The project file's value wins; otherwise the global value; otherwise
|
|
167
|
+
// reports are on.
|
|
168
|
+
report: project.report ?? global.report ?? true,
|
|
109
169
|
};
|
|
110
170
|
}
|
|
111
171
|
|
|
@@ -115,61 +175,109 @@ function loadConfig(
|
|
|
115
175
|
* empty repo, …) so callers can fall back to a neutral value.
|
|
116
176
|
* No shell is involved (argument array), so file names cannot inject commands.
|
|
117
177
|
*/
|
|
118
|
-
function git(cwd: string, args: string[]): string | undefined {
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
178
|
+
function git(cwd: string, args: string[]): Promise<string | undefined> {
|
|
179
|
+
return new Promise((resolve) => {
|
|
180
|
+
// No stdio option: execFile's string-encoding overload doesn't accept
|
|
181
|
+
// it, and stdin/stderr need no special handling here (no input is
|
|
182
|
+
// written; stderr is simply ignored by the callback).
|
|
183
|
+
execFile(
|
|
184
|
+
"git",
|
|
185
|
+
args,
|
|
186
|
+
{ cwd, encoding: "utf-8" },
|
|
187
|
+
(error, stdout) => {
|
|
188
|
+
if (error) {
|
|
189
|
+
resolve(undefined);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
const trimmed = stdout.trim();
|
|
193
|
+
resolve(trimmed === "" ? undefined : trimmed);
|
|
194
|
+
},
|
|
195
|
+
);
|
|
196
|
+
});
|
|
130
197
|
}
|
|
131
198
|
|
|
132
199
|
/**
|
|
133
200
|
* Build the structured context for the current directory. Git facts fall back
|
|
134
201
|
* to neutral values when unavailable (non-git dir, no git, empty repo) so
|
|
135
|
-
* default prompts read cleanly in any directory.
|
|
136
|
-
*
|
|
202
|
+
* default prompts read cleanly in any directory.
|
|
203
|
+
*
|
|
204
|
+
* Collects the git facts the prompts interpolate:
|
|
205
|
+
* 1. rev-parse --is-inside-work-tree → isGitRepo
|
|
206
|
+
* 2. branch --show-current → branch (fallback: rev-parse --abbrev-ref HEAD)
|
|
207
|
+
* 3. log -1 --format="%H %s" → commit hash + subject
|
|
208
|
+
* 4. config --get-regexp "^(user.name|remote.origin.url)$" → user + repo
|
|
209
|
+
* 5. status --porcelain → files + stagedFiles + unstagedFiles
|
|
210
|
+
* 6. diff --shortstat → diffStat
|
|
211
|
+
*
|
|
212
|
+
* Outside a work tree only call 1 runs; inside one, calls 2–6 run in
|
|
213
|
+
* parallel, so wall time is about one spawn.
|
|
137
214
|
*/
|
|
138
|
-
function buildContext(cwd: string): TaskContext {
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
|
|
148
|
-
|
|
215
|
+
async function buildContext(cwd: string): Promise<TaskContext> {
|
|
216
|
+
// 1. Authoritative check: are we inside a work tree?
|
|
217
|
+
// rev-parse --is-inside-work-tree outputs "true" when inside a work tree,
|
|
218
|
+
// and fails (exit != 0) when outside.
|
|
219
|
+
const inside = await git(cwd, ["rev-parse", "--is-inside-work-tree"]);
|
|
220
|
+
const isGitRepo = inside === "true";
|
|
221
|
+
|
|
222
|
+
// Outside a work tree the remaining facts don't exist; skip the spawns
|
|
223
|
+
// and return the neutral fallbacks directly.
|
|
224
|
+
if (!isGitRepo) {
|
|
225
|
+
return {
|
|
226
|
+
cwd,
|
|
227
|
+
date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
|
|
228
|
+
branch: "unknown",
|
|
229
|
+
lastCommit: "unknown",
|
|
230
|
+
files: [],
|
|
231
|
+
user: "unknown",
|
|
232
|
+
diffStat: "none",
|
|
233
|
+
repo: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown",
|
|
234
|
+
stagedFiles: [],
|
|
235
|
+
unstagedFiles: [],
|
|
236
|
+
isGitRepo: false,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// 2–6. In a work tree: query branch and the remaining facts in parallel.
|
|
241
|
+
const [branchOut, lastCommitLine, configLine, porcelain, diffStat] = await Promise.all([
|
|
242
|
+
// branch --show-current works on both normal and unborn (empty repo) branches;
|
|
243
|
+
// returns empty on detached HEAD.
|
|
244
|
+
git(cwd, ["branch", "--show-current"]),
|
|
245
|
+
// "%H %s" prints "<hash> <subject>"; the subject may contain spaces.
|
|
246
|
+
git(cwd, ["log", "-1", "--format=%H %s"]),
|
|
247
|
+
// --get-regexp prints "key value" lines; one call covers both keys.
|
|
248
|
+
git(cwd, ["config", "--get-regexp", "^(user\\.name|remote\\.origin\\.url)$"]),
|
|
249
|
+
git(cwd, ["status", "--porcelain"]),
|
|
250
|
+
git(cwd, ["diff", "--shortstat"]),
|
|
251
|
+
]);
|
|
252
|
+
|
|
253
|
+
const branch = branchOut || (await git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"])) || "unknown";
|
|
254
|
+
const lastCommit = parseCommitSubject(lastCommitLine);
|
|
255
|
+
const user = parseConfigRegexpValueForKey(configLine, "user.name");
|
|
256
|
+
const remoteUrl = parseConfigRegexpValueForKey(configLine, "remote.origin.url");
|
|
149
257
|
const repo = remoteUrl
|
|
150
258
|
? (remoteUrl.replace(/\.git$/, "").split("/").pop() ?? "unknown")
|
|
151
259
|
: cwd.split(/[\\/]/).filter(Boolean).pop() ?? "unknown";
|
|
152
260
|
|
|
261
|
+
// status --porcelain gives staged + unstaged in one call.
|
|
262
|
+
// Porcelain v1 lines are "XY <path>" (path starts at index 3); a space
|
|
263
|
+
// in the X column means unstaged, anything else is staged/untracked.
|
|
264
|
+
const { staged: stagedFiles, unstaged: unstagedFiles } = parseStatusStagedUnstaged(porcelain ?? "");
|
|
265
|
+
|
|
153
266
|
return {
|
|
154
267
|
cwd,
|
|
155
268
|
date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
|
|
156
269
|
branch,
|
|
157
270
|
lastCommit,
|
|
158
|
-
files: parseStatusPorcelain(
|
|
159
|
-
user,
|
|
160
|
-
diffStat:
|
|
271
|
+
files: parseStatusPorcelain(porcelain ?? ""),
|
|
272
|
+
user: user ?? "unknown",
|
|
273
|
+
diffStat: diffStat ?? "none",
|
|
161
274
|
repo,
|
|
162
|
-
stagedFiles
|
|
163
|
-
unstagedFiles
|
|
275
|
+
stagedFiles,
|
|
276
|
+
unstagedFiles,
|
|
164
277
|
isGitRepo,
|
|
165
278
|
};
|
|
166
279
|
}
|
|
167
280
|
|
|
168
|
-
/** Find the group (among `groups`) that contains a task. */
|
|
169
|
-
function findGroupOf(task: DoAlwaysTask, groups: TaskGroup[]): TaskGroup | undefined {
|
|
170
|
-
return groups.find((g) => g.items.includes(task));
|
|
171
|
-
}
|
|
172
|
-
|
|
173
281
|
/** True for a single printable ASCII character (used for filter typing). */
|
|
174
282
|
function isPrintable(data: string): boolean {
|
|
175
283
|
return data.length === 1 && data >= " " && data <= "~";
|
|
@@ -180,6 +288,28 @@ const PREVIEW_DELAY_MS = 2000;
|
|
|
180
288
|
/** Max lines of the prompt shown in the selector preview. */
|
|
181
289
|
const PREVIEW_MAX_LINES = 3;
|
|
182
290
|
|
|
291
|
+
/** Outcome of one chain step's run (see sendAndWait). */
|
|
292
|
+
/**
|
|
293
|
+
* Status of one chain step for the below-prompt status widget: pending
|
|
294
|
+
* (not reached yet), running (its turn is in flight), waiting (fill-first:
|
|
295
|
+
* step 1 is in the editor, waiting for the user's Enter), completed, or one
|
|
296
|
+
* of the stop outcomes (failed-to-start/aborted/error/skipped-by-guards).
|
|
297
|
+
*/
|
|
298
|
+
type ChainStepStatus =
|
|
299
|
+
| "pending"
|
|
300
|
+
| "running"
|
|
301
|
+
| "waiting"
|
|
302
|
+
| "completed"
|
|
303
|
+
| "failed-to-start"
|
|
304
|
+
| "aborted"
|
|
305
|
+
| "error"
|
|
306
|
+
| "skipped";
|
|
307
|
+
|
|
308
|
+
interface ChainStepView {
|
|
309
|
+
name: string;
|
|
310
|
+
status: ChainStepStatus;
|
|
311
|
+
}
|
|
312
|
+
|
|
183
313
|
export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
184
314
|
let tasks: DoAlwaysTask[] = [];
|
|
185
315
|
let loadedCwd = ""; // cwd the cached `tasks` were loaded for
|
|
@@ -189,6 +319,474 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
189
319
|
// the full list rather than guessing.
|
|
190
320
|
let visibleCache: { cwd: string; visible: DoAlwaysTask[] } | null = null;
|
|
191
321
|
|
|
322
|
+
// Chain control: `pi.sendUserMessage` is fire-and-forget (returns void),
|
|
323
|
+
// so the chain runner sequences steps on session events:
|
|
324
|
+
// agent_start — the run actually began. A send that fails before the
|
|
325
|
+
// run starts (no API key, compaction collision) never
|
|
326
|
+
// emits agent events and its error is swallowed by the
|
|
327
|
+
// runtime; the grace timer in sendAndWait turns that
|
|
328
|
+
// into "failed-to-start".
|
|
329
|
+
// agent_end — carries the run's messages; the last assistant
|
|
330
|
+
// message's stopReason gives completed/aborted/error.
|
|
331
|
+
// agent_settled — the session is fully idle (the busy flag is cleared
|
|
332
|
+
// before this fires), so the next step can be sent
|
|
333
|
+
// safely; auto-retry, compaction, and queued
|
|
334
|
+
// continuations have all had their chance.
|
|
335
|
+
let chainWaiter: {
|
|
336
|
+
started: boolean;
|
|
337
|
+
outcome: "completed" | "aborted" | "error" | null;
|
|
338
|
+
timer: NodeJS.Timeout | null;
|
|
339
|
+
resolve: (outcome: ChainStepOutcome) => void;
|
|
340
|
+
/** Which chain step (0-based) this waiter belongs to — for the report. */
|
|
341
|
+
stepIndex: number;
|
|
342
|
+
startTime?: number;
|
|
343
|
+
} | null = null;
|
|
344
|
+
|
|
345
|
+
function settleChainWaiter(outcome: ChainStepOutcome) {
|
|
346
|
+
if (!chainWaiter) return;
|
|
347
|
+
const waiter = chainWaiter;
|
|
348
|
+
chainWaiter = null;
|
|
349
|
+
if (waiter.timer) clearTimeout(waiter.timer);
|
|
350
|
+
if (waiter.startTime !== undefined) {
|
|
351
|
+
chainDurations[waiter.stepIndex] = performance.now() - waiter.startTime;
|
|
352
|
+
}
|
|
353
|
+
waiter.resolve(outcome);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// Below-prompt status widget while a chain is running: the chain's tasks
|
|
357
|
+
// with per-step status and a (n/N) progress marker. Shown in TUI mode
|
|
358
|
+
// only; cleared when the chain completes, kept (as a trace) when it stops
|
|
359
|
+
// early, and reset on session start.
|
|
360
|
+
let chainStatus: { steps: ChainStepView[]; note?: string } | null = null;
|
|
361
|
+
const CHAIN_WIDGET_KEY = "do-always-chain";
|
|
362
|
+
// The most recent command context, so event handlers (which carry no
|
|
363
|
+
// context of their own) can still refresh the widget.
|
|
364
|
+
let lastCtx: ExtensionContext | null = null;
|
|
365
|
+
// True while a chain's runner is in flight (from start to its final
|
|
366
|
+
// outcome). A second chain started while one is running would interleave
|
|
367
|
+
// their event waiters (the old chain's sendAndWait would resolve on the
|
|
368
|
+
// new chain's step), so starting one is refused until the first ends.
|
|
369
|
+
let chainActive = false;
|
|
370
|
+
// Per-step durations (ms) for the chain-end summary. Indexed by step
|
|
371
|
+
// position; 0 means the step was not timed (e.g. blocked before running).
|
|
372
|
+
let chainDurations: number[] = [];
|
|
373
|
+
// For single auto-run tasks: the task name whose summary we post after
|
|
374
|
+
// agent_end. Only set when a Plan task (auto-run) is sent via
|
|
375
|
+
// pi.sendUserMessage (which does NOT arm a chainWaiter). The grace timer
|
|
376
|
+
// mirrors the chain's failed-to-start handling: a send that fails before
|
|
377
|
+
// the run starts emits no agent events at all, so without it the stale
|
|
378
|
+
// flag would post a spurious summary for the next unrelated turn.
|
|
379
|
+
let pendingSummaryTask: string | null = null;
|
|
380
|
+
let pendingSummaryTimer: NodeJS.Timeout | null = null;
|
|
381
|
+
|
|
382
|
+
/** Clear the auto-run summary flag and its grace timer (session start). */
|
|
383
|
+
function resetPendingSummary(): void {
|
|
384
|
+
pendingSummaryTask = null;
|
|
385
|
+
if (pendingSummaryTimer) {
|
|
386
|
+
clearTimeout(pendingSummaryTimer);
|
|
387
|
+
pendingSummaryTimer = null;
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
// Whether chain runs write a Markdown report file (config `report`,
|
|
391
|
+
// default true). Refreshed whenever the config is (re)loaded.
|
|
392
|
+
let reportEnabled = true;
|
|
393
|
+
// The in-flight chain's report file: its path (absolute + relative for
|
|
394
|
+
// display), the precomputed header (deferred — written together with the
|
|
395
|
+
// first step section, so a chain that dies before that leaves no
|
|
396
|
+
// header-only file behind), whether the file exists on disk yet, when the
|
|
397
|
+
// current step's run actually started (agent_start; null until then and
|
|
398
|
+
// for failed-to-start steps), whether the footer has been appended, the
|
|
399
|
+
// index of the last step section written (dedupes retried runs), and
|
|
400
|
+
// whether any step section carried result text (the file is worth
|
|
401
|
+
// keeping).
|
|
402
|
+
// Max in-memory sections kept for inline display; the file on disk retains
|
|
403
|
+
// all steps. Bounded to avoid holding 400KB–2MB of assistant text per chain.
|
|
404
|
+
const MAX_INLINE_SECTIONS = 3;
|
|
405
|
+
|
|
406
|
+
let chainReport: {
|
|
407
|
+
path: string;
|
|
408
|
+
display: string;
|
|
409
|
+
/** Precomputed header; written together with the first section. */
|
|
410
|
+
header: string;
|
|
411
|
+
/** True once the file exists on disk (header written). */
|
|
412
|
+
written: boolean;
|
|
413
|
+
stepStartedAt: Date | null;
|
|
414
|
+
/** True once the summary (or abandoned) footer has been appended. */
|
|
415
|
+
footerWritten: boolean;
|
|
416
|
+
/** Index of the last step whose section was appended (-1 = none). */
|
|
417
|
+
lastStepSection: number;
|
|
418
|
+
/** True once a step section with result text was appended. */
|
|
419
|
+
hasContent: boolean;
|
|
420
|
+
/**
|
|
421
|
+
* In-memory section data for inline display (populated in agent_end,
|
|
422
|
+
* bounded to the last MAX_INLINE_SECTIONS). `index` is the step's
|
|
423
|
+
* position in the chain (retries share it); `markdown` is the formatted section.
|
|
424
|
+
*/
|
|
425
|
+
sections: Array<{ index: number; markdown: string }>;
|
|
426
|
+
} | null = null;
|
|
427
|
+
|
|
428
|
+
/** Status marker glyph (all one column wide) with its color. */
|
|
429
|
+
function stepMarker(status: ChainStepStatus, theme: Theme): string {
|
|
430
|
+
switch (status) {
|
|
431
|
+
case "completed":
|
|
432
|
+
return theme.fg("success", "✓");
|
|
433
|
+
case "running":
|
|
434
|
+
return theme.fg("accent", theme.bold("▶"));
|
|
435
|
+
case "waiting":
|
|
436
|
+
return theme.fg("warning", "▶");
|
|
437
|
+
case "aborted":
|
|
438
|
+
return theme.fg("warning", "⊘");
|
|
439
|
+
case "error":
|
|
440
|
+
case "failed-to-start":
|
|
441
|
+
return theme.fg("error", "✗");
|
|
442
|
+
case "skipped":
|
|
443
|
+
return theme.fg("muted", "–");
|
|
444
|
+
default:
|
|
445
|
+
return theme.fg("dim", "○");
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Replace the chain status and refresh the widget. */
|
|
450
|
+
function showChainStatus(ctx: ExtensionContext, steps: ChainStepView[], note?: string): void {
|
|
451
|
+
chainStatus = { steps, note };
|
|
452
|
+
updateChainWidget(ctx);
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
/** Update one step's status (and optionally the note) and refresh. */
|
|
456
|
+
function setChainStep(ctx: ExtensionContext, index: number, status: ChainStepStatus, note?: string): void {
|
|
457
|
+
if (!chainStatus) return;
|
|
458
|
+
const s = chainStatus.steps[index];
|
|
459
|
+
if (s) s.status = status;
|
|
460
|
+
if (note !== undefined) chainStatus.note = note;
|
|
461
|
+
updateChainWidget(ctx);
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
/**
|
|
465
|
+
* Mark the chain as stopped at `index` with `status`, keeping the widget
|
|
466
|
+
* visible as a trace of where it stopped.
|
|
467
|
+
*/
|
|
468
|
+
function markChainStopped(ctx: ExtensionContext, index: number, status: ChainStepStatus, detail?: string): void {
|
|
469
|
+
const name = chainStatus?.steps[index]?.name;
|
|
470
|
+
setChainStep(
|
|
471
|
+
ctx,
|
|
472
|
+
index,
|
|
473
|
+
status,
|
|
474
|
+
`stopped at step ${index + 1}${name ? ` (${name})` : ""}${detail ? `: ${detail}` : ""}`,
|
|
475
|
+
);
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/**
|
|
479
|
+
* Finish the report file: append the summary footer. Called on every
|
|
480
|
+
* terminal path (complete, stopped, skipped); a no-op when no report was
|
|
481
|
+
* created (disabled or write failure). A run that produced nothing worth
|
|
482
|
+
* keeping (no completed step, no result text) leaves no file behind — see
|
|
483
|
+
* `reportWorthKeeping`. When the chain completed fully (all steps done),
|
|
484
|
+
* removes the status widget so nothing lingers below the prompt; otherwise
|
|
485
|
+
* keeps it as a trace with the report path in the note.
|
|
486
|
+
*/
|
|
487
|
+
/**
|
|
488
|
+
* Write the deferred report header so the file exists before the first
|
|
489
|
+
* section/footer append. Returns false when there is no report or the
|
|
490
|
+
* write failed (a warning was shown); the chain runs on without a file.
|
|
491
|
+
*/
|
|
492
|
+
function ensureReportFile(ctx: ExtensionContext | null): boolean {
|
|
493
|
+
const report = chainReport;
|
|
494
|
+
if (!report || report.written) return report !== null;
|
|
495
|
+
try {
|
|
496
|
+
writeFileSync(report.path, report.header);
|
|
497
|
+
report.written = true;
|
|
498
|
+
return true;
|
|
499
|
+
} catch (err) {
|
|
500
|
+
ctx?.ui.notify(`do-always: could not create the report file: ${err}`, "warning");
|
|
501
|
+
return false;
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
async function finishReport(ctx: ExtensionContext): Promise<void> {
|
|
506
|
+
if (!chainReport || !chainStatus) return;
|
|
507
|
+
const statuses = chainStatus.steps.map((s) => s.status);
|
|
508
|
+
// Nothing worth keeping — remove the (mostly) empty file so a quick
|
|
509
|
+
// same-minute retry doesn't get a -N sibling next to it.
|
|
510
|
+
if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
|
|
511
|
+
closeAbandonedReport(statuses);
|
|
512
|
+
chainReport = null;
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
if (ensureReportFile(ctx)) {
|
|
516
|
+
try {
|
|
517
|
+
appendFileSync(chainReport.path, reportFooter(statuses, new Date()));
|
|
518
|
+
chainReport.footerWritten = true;
|
|
519
|
+
} catch (err) {
|
|
520
|
+
ctx.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
const prev = chainStatus.note ? `${chainStatus.note} • ` : "";
|
|
524
|
+
chainStatus.note = `${prev}📄 ${chainReport.display}`;
|
|
525
|
+
// Chain fully done → show the inline report, then clear the
|
|
526
|
+
// trace widget. Chain stopped early → keep the trace widget.
|
|
527
|
+
const allDone = statuses.every((s) => s === "completed");
|
|
528
|
+
if (allDone) {
|
|
529
|
+
await showInlineReport(ctx);
|
|
530
|
+
clearChainWidget(ctx);
|
|
531
|
+
} else {
|
|
532
|
+
updateChainWidget(ctx);
|
|
533
|
+
}
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Show the full chain report in an ephemeral editor view when a chain
|
|
538
|
+
* completes (TUI only). Built from in-memory section data (no file read
|
|
539
|
+
* needed). Nothing is persisted to the session — the view closes with
|
|
540
|
+
* Esc and the report file on disk is the permanent artifact. In non-TUI
|
|
541
|
+
* modes the editor is a no-op; the final notification carries the file
|
|
542
|
+
* path.
|
|
543
|
+
*/
|
|
544
|
+
async function showInlineReport(ctx: ExtensionContext): Promise<void> {
|
|
545
|
+
if (ctx.mode !== "tui") return;
|
|
546
|
+
if (!chainReport || !chainStatus || chainReport.sections.length === 0) return;
|
|
547
|
+
// Reconstruct the report from in-memory sections. Only the last
|
|
548
|
+
// MAX_INLINE_SECTIONS are kept (the file on disk has all of them), so
|
|
549
|
+
// flag the omission when a step has no section in the window.
|
|
550
|
+
const shownSteps = new Set(chainReport.sections.map((s) => s.index));
|
|
551
|
+
const omitted = chainStatus.steps.length - shownSteps.size;
|
|
552
|
+
const lines: string[] = [];
|
|
553
|
+
lines.push(`# do-always chain report — ${new Date().toISOString().slice(0, 10)}`);
|
|
554
|
+
lines.push("");
|
|
555
|
+
lines.push(`- Project: ${chainReport.display}`);
|
|
556
|
+
lines.push(`- Steps: ${chainStatus.steps.map((s) => s.name).join(" → ")}`);
|
|
557
|
+
lines.push("");
|
|
558
|
+
if (omitted > 0) {
|
|
559
|
+
lines.push(`> … ${omitted} earlier step${omitted === 1 ? "" : "s"} omitted — see ${chainReport.display}`);
|
|
560
|
+
lines.push("");
|
|
561
|
+
}
|
|
562
|
+
for (const sec of chainReport.sections) {
|
|
563
|
+
lines.push(sec.markdown);
|
|
564
|
+
}
|
|
565
|
+
// Footer.
|
|
566
|
+
const statuses = chainStatus.steps.map((s) => s.status);
|
|
567
|
+
lines.push(reportFooter(statuses, new Date()));
|
|
568
|
+
|
|
569
|
+
// Ephemeral editor view: read the report, Esc to dismiss. Unlike
|
|
570
|
+
// pi.sendMessage this persists nothing to the session.
|
|
571
|
+
await ctx.ui.editor("do-always — chain report", lines.join("\n"));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* Close an in-flight report that never reached a terminal path (e.g.,
|
|
576
|
+
* the session ended mid-chain): delete the file when the run produced
|
|
577
|
+
* nothing worth keeping (a no-op when the deferred header was never
|
|
578
|
+
* written), otherwise append an "abandoned" footer so it does not stay
|
|
579
|
+
* header-only on disk.
|
|
580
|
+
*/
|
|
581
|
+
function closeAbandonedReport(statuses: string[]): void {
|
|
582
|
+
if (!chainReport || chainReport.footerWritten) return;
|
|
583
|
+
if (!reportWorthKeeping(statuses, chainReport.hasContent)) {
|
|
584
|
+
// Nothing worth keeping — remove the file if it was written.
|
|
585
|
+
if (chainReport.written) {
|
|
586
|
+
try {
|
|
587
|
+
unlinkSync(chainReport.path);
|
|
588
|
+
} catch {
|
|
589
|
+
// Best effort — the file stays on disk.
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
return;
|
|
593
|
+
}
|
|
594
|
+
try {
|
|
595
|
+
if (chainReport.written) {
|
|
596
|
+
appendFileSync(chainReport.path, reportAbandonedFooter(statuses, new Date()));
|
|
597
|
+
} else {
|
|
598
|
+
writeFileSync(chainReport.path, chainReport.header + reportAbandonedFooter(statuses, new Date()));
|
|
599
|
+
}
|
|
600
|
+
} catch {
|
|
601
|
+
// Best effort — the report file stays as-is on disk.
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/**
|
|
606
|
+
* Remove the widget and forget the status (and any in-flight report).
|
|
607
|
+
* When the report never reached a terminal path (e.g., the session
|
|
608
|
+
* ended mid-chain), it is closed by `closeAbandonedReport`.
|
|
609
|
+
*/
|
|
610
|
+
function clearChainWidget(ctx: ExtensionContext): void {
|
|
611
|
+
if (!chainStatus) return;
|
|
612
|
+
const statuses = chainStatus.steps.map((s) => s.status);
|
|
613
|
+
chainStatus = null;
|
|
614
|
+
closeAbandonedReport(statuses);
|
|
615
|
+
chainReport = null;
|
|
616
|
+
if (ctx.mode === "tui") ctx.ui.setWidget(CHAIN_WIDGET_KEY, undefined);
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/**
|
|
620
|
+
* Render the status widget from `chainStatus` (TUI only): the chain's
|
|
621
|
+
* steps with per-step markers, a (n/N) progress line, and the note,
|
|
622
|
+
* below the editor. Refreshed on every step change; removed by
|
|
623
|
+
* `clearChainWidget` when the chain completes or a new prompt starts.
|
|
624
|
+
*/
|
|
625
|
+
function updateChainWidget(ctx: ExtensionContext): void {
|
|
626
|
+
if (ctx.mode !== "tui" || !chainStatus) return;
|
|
627
|
+
const { steps, note } = chainStatus;
|
|
628
|
+
|
|
629
|
+
// Chain status widget: stays below the editor.
|
|
630
|
+
ctx.ui.setWidget(
|
|
631
|
+
CHAIN_WIDGET_KEY,
|
|
632
|
+
(tui, theme) => {
|
|
633
|
+
const lines: string[] = [];
|
|
634
|
+
// (n/N): the step the chain is currently at (N when it is done).
|
|
635
|
+
let at = 0;
|
|
636
|
+
steps.forEach((s, i) => {
|
|
637
|
+
if (s.status !== "pending") at = i + 1;
|
|
638
|
+
});
|
|
639
|
+
lines.push(theme.fg("accent", theme.bold(`⛓ do-always (${at}/${steps.length})`)));
|
|
640
|
+
for (const s of steps) {
|
|
641
|
+
lines.push(` ${stepMarker(s.status, theme)} ${s.name}`);
|
|
642
|
+
}
|
|
643
|
+
if (note) lines.push(theme.fg("muted", truncateToWidth(` ${note}`, tui.terminal.columns - 2, "…")));
|
|
644
|
+
const container = new Container();
|
|
645
|
+
for (const line of lines) container.addChild(new Text(line, 1, 0));
|
|
646
|
+
return container;
|
|
647
|
+
},
|
|
648
|
+
{ placement: "belowEditor" },
|
|
649
|
+
);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
pi.on("agent_start", () => {
|
|
653
|
+
if (chainWaiter) {
|
|
654
|
+
chainWaiter.started = true;
|
|
655
|
+
chainWaiter.startTime = performance.now();
|
|
656
|
+
}
|
|
657
|
+
// The run actually began — cancel the auto-run task's failed-to-start
|
|
658
|
+
// grace timer (its summary posts at agent_end).
|
|
659
|
+
if (pendingSummaryTimer) {
|
|
660
|
+
clearTimeout(pendingSummaryTimer);
|
|
661
|
+
pendingSummaryTimer = null;
|
|
662
|
+
}
|
|
663
|
+
// The run actually began — time the step for the report.
|
|
664
|
+
if (chainReport) chainReport.stepStartedAt = new Date();
|
|
665
|
+
// Fill-first: step 1 left the editor and is running — update the
|
|
666
|
+
// widget (and drop the "press Enter" note) as soon as the run starts.
|
|
667
|
+
if (lastCtx && chainStatus?.steps[0]?.status === "waiting") {
|
|
668
|
+
setChainStep(lastCtx, 0, "running", "");
|
|
669
|
+
}
|
|
670
|
+
// User entered a new prompt and the old chain is no longer active —
|
|
671
|
+
// clear the trace widget so nothing lingers below the prompt.
|
|
672
|
+
if (!chainActive && chainStatus && lastCtx) {
|
|
673
|
+
clearChainWidget(lastCtx);
|
|
674
|
+
}
|
|
675
|
+
});
|
|
676
|
+
/** The turn's last assistant message (backward scan — no copy/reverse). */
|
|
677
|
+
function lastAssistantMessage(messages: AgentEndEvent["messages"]) {
|
|
678
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
679
|
+
const m = messages[i];
|
|
680
|
+
if (m.role === "assistant") return m;
|
|
681
|
+
}
|
|
682
|
+
return null;
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Map an assistant message's stopReason to a chain step outcome. */
|
|
686
|
+
function outcomeFromStopReason(stopReason: string | undefined): "completed" | "aborted" | "error" {
|
|
687
|
+
if (stopReason === "aborted") return "aborted";
|
|
688
|
+
if (stopReason === "error") return "error";
|
|
689
|
+
return "completed";
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
pi.on("agent_end", (event) => {
|
|
693
|
+
// The agent may have changed the repo — drop the TTL context cache so
|
|
694
|
+
// the next action sees the new tree (a running chain keeps its own
|
|
695
|
+
// per-action snapshot and is unaffected).
|
|
696
|
+
contextCache = null;
|
|
697
|
+
// Single auto-run task summary (no chainWaiter: pi.sendUserMessage is
|
|
698
|
+
// fire-and-forget, so no armWaiter is called).
|
|
699
|
+
if (!chainWaiter && pendingSummaryTask) {
|
|
700
|
+
if (pendingSummaryTimer) {
|
|
701
|
+
clearTimeout(pendingSummaryTimer);
|
|
702
|
+
pendingSummaryTimer = null;
|
|
703
|
+
}
|
|
704
|
+
const lastAssistant = lastAssistantMessage(event.messages);
|
|
705
|
+
if (lastAssistant) {
|
|
706
|
+
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
707
|
+
const summary = stepSummary(outcome, pendingSummaryTask, 0, 0);
|
|
708
|
+
lastCtx?.ui.notify(`do-always: ${summary}`, "info");
|
|
709
|
+
}
|
|
710
|
+
pendingSummaryTask = null;
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (!chainWaiter) return;
|
|
714
|
+
const lastAssistant = lastAssistantMessage(event.messages);
|
|
715
|
+
if (lastAssistant) {
|
|
716
|
+
const outcome = outcomeFromStopReason(lastAssistant.stopReason);
|
|
717
|
+
chainWaiter.outcome = outcome;
|
|
718
|
+
// Append this step's result to the report while the transcript is
|
|
719
|
+
// fresh (the step's final assistant message is its result).
|
|
720
|
+
if (chainReport && chainStatus) {
|
|
721
|
+
const idx = chainWaiter.stepIndex;
|
|
722
|
+
// A retried/continued run emits a second agent_end for the
|
|
723
|
+
// same step before agent_settled — mark the repeat so the
|
|
724
|
+
// report shows both attempts without a duplicate heading.
|
|
725
|
+
const isRetry = chainReport.lastStepSection === idx;
|
|
726
|
+
const name = chainStatus.steps[idx]?.name ?? `step ${idx + 1}`;
|
|
727
|
+
const displayName = isRetry ? `${name} (retry)` : name;
|
|
728
|
+
const text = assistantText(lastAssistant.content);
|
|
729
|
+
const section = reportStepSection(
|
|
730
|
+
idx,
|
|
731
|
+
displayName,
|
|
732
|
+
outcome,
|
|
733
|
+
chainReport.stepStartedAt,
|
|
734
|
+
new Date(),
|
|
735
|
+
text,
|
|
736
|
+
);
|
|
737
|
+
if (ensureReportFile(lastCtx)) {
|
|
738
|
+
try {
|
|
739
|
+
appendFileSync(chainReport.path, section);
|
|
740
|
+
chainReport.lastStepSection = idx;
|
|
741
|
+
if (text.trim() !== "") chainReport.hasContent = true;
|
|
742
|
+
// Keep in-memory section data for inline display (bounded);
|
|
743
|
+
// index lets it number sections like the file.
|
|
744
|
+
chainReport.sections.push({ index: idx, markdown: section });
|
|
745
|
+
if (chainReport.sections.length > MAX_INLINE_SECTIONS) {
|
|
746
|
+
chainReport.sections.splice(0, chainReport.sections.length - MAX_INLINE_SECTIONS);
|
|
747
|
+
}
|
|
748
|
+
} catch (err) {
|
|
749
|
+
lastCtx?.ui.notify(`do-always: could not update the report file: ${err}`, "warning");
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
chainReport.stepStartedAt = null;
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
});
|
|
756
|
+
pi.on("agent_settled", () => {
|
|
757
|
+
if (!chainWaiter) return;
|
|
758
|
+
settleChainWaiter(chainWaiter.started ? (chainWaiter.outcome ?? "completed") : "failed-to-start");
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
/**
|
|
762
|
+
* Arm the chain waiter and resolve when the next run has fully settled
|
|
763
|
+
* (agent_settled), reporting that run's outcome. With `graceMs`, resolves
|
|
764
|
+
* "failed-to-start" if no agent_start arrives in time — a send that
|
|
765
|
+
* throws before the run begins emits no agent events and its error is
|
|
766
|
+
* swallowed by the runtime. `stepIndex` tags the waiter so the report
|
|
767
|
+
* knows which chain step the run belongs to.
|
|
768
|
+
*/
|
|
769
|
+
function armWaiter(graceMs?: number, stepIndex = 0): Promise<ChainStepOutcome> {
|
|
770
|
+
return new Promise((resolve) => {
|
|
771
|
+
const timer = graceMs
|
|
772
|
+
? setTimeout(() => {
|
|
773
|
+
if (chainWaiter && !chainWaiter.started) settleChainWaiter("failed-to-start");
|
|
774
|
+
}, graceMs)
|
|
775
|
+
: null;
|
|
776
|
+
chainWaiter = { started: false, outcome: null, timer, resolve, stepIndex };
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Send a prompt and resolve when the run it starts has fully settled,
|
|
782
|
+
* reporting the run's outcome (see armWaiter).
|
|
783
|
+
*/
|
|
784
|
+
function sendAndWait(prompt: string, graceMs = 10_000, stepIndex = 0): Promise<ChainStepOutcome> {
|
|
785
|
+
const done = armWaiter(graceMs, stepIndex);
|
|
786
|
+
pi.sendUserMessage(prompt);
|
|
787
|
+
return done;
|
|
788
|
+
}
|
|
789
|
+
|
|
192
790
|
/** Filter tasks by their `when` condition and refresh the completion cache. */
|
|
193
791
|
function refreshVisible(cwd: string, context: TaskContext): DoAlwaysTask[] {
|
|
194
792
|
const visible = tasks.filter((t) => evaluateWhen(t, context));
|
|
@@ -215,20 +813,66 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
215
813
|
});
|
|
216
814
|
}
|
|
217
815
|
|
|
218
|
-
pi.on("session_start", (_event, ctx) => {
|
|
816
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
817
|
+
lastCtx = ctx;
|
|
219
818
|
// Surface config validation problems (the README promises warnings);
|
|
220
819
|
// in non-TUI modes there is no UI, so fall back to the console.
|
|
221
820
|
const onError = (m: string) => {
|
|
222
821
|
if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
|
|
223
822
|
else console.warn(m);
|
|
224
823
|
};
|
|
824
|
+
// A failed chain's status widget is a trace of the previous session;
|
|
825
|
+
// start each session clean.
|
|
826
|
+
clearChainWidget(ctx);
|
|
827
|
+
// Likewise a stale auto-run summary flag (e.g., a send that never
|
|
828
|
+
// started) must not post a spurious summary for this session's turns.
|
|
829
|
+
resetPendingSummary();
|
|
225
830
|
loadedCwd = ctx.cwd;
|
|
226
831
|
const config = loadConfig(ctx.cwd, onError);
|
|
227
832
|
tasks = config.tasks;
|
|
228
|
-
|
|
833
|
+
reportEnabled = config.report;
|
|
834
|
+
refreshVisible(ctx.cwd, await getContext(ctx.cwd));
|
|
229
835
|
registerShortcut(config.shortcut, onError);
|
|
230
836
|
});
|
|
231
837
|
|
|
838
|
+
// Extension-level git context cache, keyed by cwd with a short TTL:
|
|
839
|
+
// buildContext spawns git, and the repo rarely changes between actions,
|
|
840
|
+
// so the session_start precompute feeds the first /do-always and
|
|
841
|
+
// repeated invocations within the window reuse the same context.
|
|
842
|
+
// Invalidated on agent_end (the agent may have changed the repo); a
|
|
843
|
+
// running chain keeps its own per-action snapshot (createContextCache),
|
|
844
|
+
// so its steps reuse one context across the run.
|
|
845
|
+
let contextCache: { cwd: string; at: number; ctx: TaskContext } | null = null;
|
|
846
|
+
const CONTEXT_TTL_MS = 5_000;
|
|
847
|
+
|
|
848
|
+
async function getContext(cwd: string): Promise<TaskContext> {
|
|
849
|
+
if (contextCache && contextCache.cwd === cwd && Date.now() - contextCache.at < CONTEXT_TTL_MS) {
|
|
850
|
+
return contextCache.ctx;
|
|
851
|
+
}
|
|
852
|
+
const ctx = await buildContext(cwd);
|
|
853
|
+
contextCache = { cwd, at: Date.now(), ctx };
|
|
854
|
+
return ctx;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
858
|
+
* Per-action context cache: the first build consults the extension-level
|
|
859
|
+
* TTL cache (getContext), and later builds within the same action reuse
|
|
860
|
+
* that snapshot — what the user saw in the preview is what gets injected.
|
|
861
|
+
* Created fresh at each action entry point (runDoAlways, runChain).
|
|
862
|
+
*/
|
|
863
|
+
function createContextCache(): {
|
|
864
|
+
get(cwd: string): Promise<TaskContext>;
|
|
865
|
+
} {
|
|
866
|
+
let cached: { cwd: string; ctx: TaskContext } | null = null;
|
|
867
|
+
return {
|
|
868
|
+
async get(cwd: string): Promise<TaskContext> {
|
|
869
|
+
if (cached && cached.cwd === cwd) return cached.ctx;
|
|
870
|
+
cached = { cwd, ctx: await getContext(cwd) };
|
|
871
|
+
return cached.ctx;
|
|
872
|
+
},
|
|
873
|
+
};
|
|
874
|
+
}
|
|
875
|
+
|
|
232
876
|
/** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
|
|
233
877
|
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext, context: TaskContext): Promise<void> {
|
|
234
878
|
const blocked = evaluateGuards(task, context);
|
|
@@ -240,7 +884,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
240
884
|
// user saw is exactly what gets injected.
|
|
241
885
|
const prompt = renderPrompt(task.prompt, toPromptContext(context));
|
|
242
886
|
if (shouldAutoRun(task)) {
|
|
243
|
-
|
|
887
|
+
// Fire-and-forget: sendUserMessage returns void; the run proceeds
|
|
888
|
+
// independently (see the chain control notes for why).
|
|
889
|
+
// Track the task so we can post a summary after agent_end.
|
|
890
|
+
pendingSummaryTask = task.name;
|
|
891
|
+
// If the run never starts (no agent events at all — same failure
|
|
892
|
+
// mode the chain's grace timer handles), drop the flag so a later
|
|
893
|
+
// unrelated turn can't post a spurious summary for this task.
|
|
894
|
+
if (pendingSummaryTimer) clearTimeout(pendingSummaryTimer);
|
|
895
|
+
pendingSummaryTimer = setTimeout(() => {
|
|
896
|
+
pendingSummaryTimer = null;
|
|
897
|
+
if (!pendingSummaryTask) return;
|
|
898
|
+
const name = pendingSummaryTask;
|
|
899
|
+
pendingSummaryTask = null;
|
|
900
|
+
lastCtx?.ui.notify(`do-always: "${name}" failed to start (check model/API key)`, "error");
|
|
901
|
+
}, 10_000);
|
|
902
|
+
pi.sendUserMessage(prompt);
|
|
244
903
|
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
245
904
|
return;
|
|
246
905
|
}
|
|
@@ -248,23 +907,214 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
248
907
|
ctx.ui.setEditorText(prompt);
|
|
249
908
|
ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
|
|
250
909
|
} else {
|
|
251
|
-
|
|
910
|
+
pi.sendUserMessage(prompt);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
/**
|
|
915
|
+
* Run a chain: each step is sent as its own turn, awaited in order, so the
|
|
916
|
+
* steps run strictly one after another. Aborting (or erroring) a step
|
|
917
|
+
* stops the chain.
|
|
918
|
+
*
|
|
919
|
+
* Step 1 follows the task's autoRun semantics: ⚡ tasks (and non-TUI modes)
|
|
920
|
+
* are sent immediately; fill tasks put step 1 in the editor and wait for
|
|
921
|
+
* its run to settle before starting the remaining steps.
|
|
922
|
+
*/
|
|
923
|
+
async function runChain(
|
|
924
|
+
names: string[],
|
|
925
|
+
ctx: ExtensionContext,
|
|
926
|
+
cache: ReturnType<typeof createContextCache>,
|
|
927
|
+
): Promise<void> {
|
|
928
|
+
if (chainActive) {
|
|
929
|
+
ctx.ui.notify("do-always: a chain is already running — wait for it to finish (or abort the current step with Esc)", "info");
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
const steps = names
|
|
933
|
+
.map((n) => tasks.find((t) => t.name === n))
|
|
934
|
+
.filter((t): t is DoAlwaysTask => t !== undefined);
|
|
935
|
+
if (steps.length === 0) {
|
|
936
|
+
ctx.ui.notify("do-always: nothing to run", "info");
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
// Fail fast: report the first blocked step before sending anything.
|
|
940
|
+
const blocked = validateChain(tasks, { items: names, history: [] }, await cache.get(ctx.cwd));
|
|
941
|
+
if (blocked) {
|
|
942
|
+
ctx.ui.notify(`do-always: chain blocked at step ${blocked.step} (${blocked.task.name}): ${blocked.message}`, "warning");
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
chainActive = true;
|
|
946
|
+
// Per-step durations for the chain-end summary. Re-initialized each
|
|
947
|
+
// chain run so stale durations from a previous run don't leak in.
|
|
948
|
+
// Pre-filled with 0 so the fill-first step (index 0, timed outside
|
|
949
|
+
// runChainSteps) and any unrun steps don't leave `undefined` holes
|
|
950
|
+
// that would poison the reduce with NaN.
|
|
951
|
+
chainDurations = Array.from({ length: steps.length }, () => 0);
|
|
952
|
+
// Report file: one per run, in the project root. The header is
|
|
953
|
+
// deferred until the first step section (a chain that dies before
|
|
954
|
+
// that leaves no file), and each step is appended as it finishes
|
|
955
|
+
// (see the report section in tasks.ts). A write failure is not
|
|
956
|
+
// fatal — the chain still runs, just without a report.
|
|
957
|
+
if (reportEnabled) {
|
|
958
|
+
const now = new Date();
|
|
959
|
+
const path = resolveReportPath(ctx.cwd, now);
|
|
960
|
+
chainReport = {
|
|
961
|
+
path,
|
|
962
|
+
display: relative(ctx.cwd, path),
|
|
963
|
+
header: reportHeader(ctx.cwd, steps.map((t) => t.name), now),
|
|
964
|
+
written: false,
|
|
965
|
+
stepStartedAt: null,
|
|
966
|
+
footerWritten: false,
|
|
967
|
+
lastStepSection: -1,
|
|
968
|
+
hasContent: false,
|
|
969
|
+
sections: [],
|
|
970
|
+
};
|
|
971
|
+
}
|
|
972
|
+
const first = steps[0];
|
|
973
|
+
if (shouldAutoRun(first) || ctx.mode !== "tui") {
|
|
974
|
+
try {
|
|
975
|
+
showChainStatus(
|
|
976
|
+
ctx,
|
|
977
|
+
steps.map((t, i) => ({ name: t.name, status: i === 0 ? "running" : "pending" })),
|
|
978
|
+
);
|
|
979
|
+
await runChainSteps(steps, ctx, 0);
|
|
980
|
+
} finally {
|
|
981
|
+
chainActive = false;
|
|
982
|
+
}
|
|
983
|
+
return;
|
|
984
|
+
}
|
|
985
|
+
// Fill-first: put step 1 in the editor; the remaining steps start once
|
|
986
|
+
// step 1's run has settled successfully. No grace timer — the user
|
|
987
|
+
// takes as long as they need to press Enter. (If the user runs an
|
|
988
|
+
// unrelated prompt instead, the chain continues after it, as the
|
|
989
|
+
// notification says.)
|
|
990
|
+
showChainStatus(
|
|
991
|
+
ctx,
|
|
992
|
+
steps.map((t, i) => ({ name: t.name, status: i === 0 ? "waiting" : "pending" })),
|
|
993
|
+
"step 1 is in the editor — press Enter to start",
|
|
994
|
+
);
|
|
995
|
+
const firstContext = await cache.get(ctx.cwd);
|
|
996
|
+
ctx.ui.setEditorText(renderPrompt(first.prompt, toPromptContext(firstContext)));
|
|
997
|
+
ctx.ui.notify(
|
|
998
|
+
`do-always: step 1 of ${steps.length} in the editor — press Enter to run; steps 2–${steps.length} follow automatically`,
|
|
999
|
+
"info",
|
|
1000
|
+
);
|
|
1001
|
+
void armWaiter(undefined, 0).then(async (outcome) => {
|
|
1002
|
+
try {
|
|
1003
|
+
if (outcome !== "completed") {
|
|
1004
|
+
markChainStopped(ctx, 0, outcome);
|
|
1005
|
+
await finishReport(ctx);
|
|
1006
|
+
ctx.ui.notify(`do-always: step 1 — ${outcome}; chain stopped`, "error");
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
setChainStep(ctx, 0, "completed");
|
|
1010
|
+
// Post-step summary for step 1 (fill-first)
|
|
1011
|
+
const postContext = await getContext(ctx.cwd);
|
|
1012
|
+
const fileCount = postContext.files.length;
|
|
1013
|
+
const summary = stepSummary(outcome, first.name, chainDurations[0], fileCount);
|
|
1014
|
+
ctx.ui.notify(`do-always: step 1/${steps.length} — ${summary}`, "info");
|
|
1015
|
+
await runChainSteps(steps, ctx, 1);
|
|
1016
|
+
} finally {
|
|
1017
|
+
chainActive = false;
|
|
1018
|
+
}
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Send chain steps `startAt..end` sequentially. Each step evaluates guards
|
|
1024
|
+
* and renders prompts against fresh repository context, and each step is
|
|
1025
|
+
* awaited until its run has fully settled; an aborted/errored step (or a
|
|
1026
|
+
* send that failed to start) stops the chain.
|
|
1027
|
+
*/
|
|
1028
|
+
async function runChainSteps(
|
|
1029
|
+
steps: DoAlwaysTask[],
|
|
1030
|
+
ctx: ExtensionContext,
|
|
1031
|
+
startAt: number,
|
|
1032
|
+
): Promise<void> {
|
|
1033
|
+
for (let i = startAt; i < steps.length; i++) {
|
|
1034
|
+
const step = steps[i];
|
|
1035
|
+
const context = await getContext(ctx.cwd);
|
|
1036
|
+
const blocked = evaluateGuards(step, context);
|
|
1037
|
+
if (blocked) {
|
|
1038
|
+
markChainStopped(ctx, i, "skipped", blocked);
|
|
1039
|
+
await finishReport(ctx);
|
|
1040
|
+
ctx.ui.notify(`do-always: chain stopped at step ${i + 1} (${step.name}): ${blocked}`, "warning");
|
|
1041
|
+
return;
|
|
1042
|
+
}
|
|
1043
|
+
const prompt = renderPrompt(step.prompt, toPromptContext(context));
|
|
1044
|
+
const label = `do-always: step ${i + 1}/${steps.length} — ${step.name}`;
|
|
1045
|
+
setChainStep(ctx, i, "running");
|
|
1046
|
+
ctx.ui.notify(`${label} — starting`, "info");
|
|
1047
|
+
const stepStart = performance.now();
|
|
1048
|
+
const outcome = await sendAndWait(prompt, 10_000, i);
|
|
1049
|
+
const stepDuration = chainDurations[i] || performance.now() - stepStart;
|
|
1050
|
+
chainDurations[i] = stepDuration;
|
|
1051
|
+
if (outcome === "completed") {
|
|
1052
|
+
setChainStep(ctx, i, "completed");
|
|
1053
|
+
// Post-step summary: files changed + duration.
|
|
1054
|
+
const postContext = await getContext(ctx.cwd);
|
|
1055
|
+
const fileCount = postContext.files.length;
|
|
1056
|
+
const summary = stepSummary(outcome, step.name, stepDuration, fileCount);
|
|
1057
|
+
ctx.ui.notify(`do-always: step ${i + 1}/${steps.length} — ${summary}`, "info");
|
|
1058
|
+
continue;
|
|
1059
|
+
}
|
|
1060
|
+
markChainStopped(ctx, i, outcome);
|
|
1061
|
+
await finishReport(ctx);
|
|
1062
|
+
const summary = stepSummary(outcome, step.name, stepDuration, 0);
|
|
1063
|
+
if (outcome === "failed-to-start") {
|
|
1064
|
+
ctx.ui.notify(`${label} — ${summary} (check model/API key); chain stopped`, "error");
|
|
1065
|
+
} else if (outcome === "aborted") {
|
|
1066
|
+
ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
|
|
1067
|
+
} else {
|
|
1068
|
+
ctx.ui.notify(`${label} — ${summary}; chain stopped`, "error");
|
|
1069
|
+
}
|
|
1070
|
+
return;
|
|
1071
|
+
}
|
|
1072
|
+
// Complete: the report file holds the full results of every step;
|
|
1073
|
+
// finishReport clears the status widget so nothing lingers below
|
|
1074
|
+
// the prompt. Capture the display path first — finishReport clears
|
|
1075
|
+
// `chainReport` when the chain is fully done.
|
|
1076
|
+
const reportDisplay = chainReport?.display;
|
|
1077
|
+
// Chain-end summary.
|
|
1078
|
+
const completed = chainDurations.filter((d) => d > 0).length;
|
|
1079
|
+
const totalMs = chainDurations.reduce((a, b) => a + b, 0);
|
|
1080
|
+
const chainSum = chainSummary(completed, steps.length, totalMs);
|
|
1081
|
+
if (chainReport) {
|
|
1082
|
+
await finishReport(ctx);
|
|
1083
|
+
ctx.ui.notify(`do-always: ${chainSum} — report: ${reportDisplay}`, "info");
|
|
1084
|
+
} else {
|
|
1085
|
+
clearChainWidget(ctx);
|
|
1086
|
+
ctx.ui.notify(`do-always: ${chainSum}`, "info");
|
|
252
1087
|
}
|
|
253
1088
|
}
|
|
254
1089
|
|
|
255
1090
|
/**
|
|
256
|
-
*
|
|
257
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
1091
|
+
* Task table with an ORDER column (the chain) and a pinned Run row:
|
|
1092
|
+
*
|
|
1093
|
+
* # TASK DESCRIPTION ORDER
|
|
1094
|
+
* 1 ⚡ Review changes Review the current ►[1]
|
|
1095
|
+
* 2 Build Build the project ·
|
|
1096
|
+
* ─────────────────────────────────────────────────────
|
|
1097
|
+
* Run the chain (1)
|
|
1098
|
+
*
|
|
1099
|
+
* The TASK column is primary: Enter runs just the task under the cursor
|
|
1100
|
+
* (the classic pick). The ORDER column is the optional chain: Enter
|
|
1101
|
+
* toggles the task's membership, and the pinned Run row runs the whole
|
|
1102
|
+
* chain. ←/→ switch columns, 1-9 still runs a task immediately (closing
|
|
1103
|
+
* the selector, discarding the chain). The context is built once per command
|
|
1104
|
+
* run (never inside the render loop — no process spawning per frame) and
|
|
1105
|
+
* shared with `fillPrompt`.
|
|
260
1106
|
*/
|
|
261
|
-
async function showSelector(
|
|
1107
|
+
async function showSelector(
|
|
1108
|
+
ctx: ExtensionContext,
|
|
1109
|
+
context: TaskContext,
|
|
1110
|
+
cache: ReturnType<typeof createContextCache>,
|
|
1111
|
+
): Promise<void> {
|
|
262
1112
|
// Filter by the `when` condition once per session, so hidden tasks never
|
|
263
1113
|
// appear, are never numbered, and can't be picked.
|
|
264
1114
|
const visibleTasks = tasks.filter((t) => evaluateWhen(t, context));
|
|
265
1115
|
// String view for prompt rendering (derived once, used by the preview).
|
|
266
1116
|
const strings = toPromptContext(context);
|
|
267
|
-
const
|
|
1117
|
+
const result = await ctx.ui.custom<SelectorResult>((tui, theme, _kb, done) => {
|
|
268
1118
|
let settled = false;
|
|
269
1119
|
let previewVisible = false;
|
|
270
1120
|
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -276,16 +1126,28 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
276
1126
|
}
|
|
277
1127
|
}
|
|
278
1128
|
|
|
279
|
-
//
|
|
280
|
-
//
|
|
281
|
-
//
|
|
282
|
-
//
|
|
283
|
-
|
|
1129
|
+
// Finish the selector with a result. Reference-based (task object /
|
|
1130
|
+
// chain names), so it stays correct while a text filter is active
|
|
1131
|
+
// (itemRows is then a subset of visibleTasks and positional indices
|
|
1132
|
+
// would point at the wrong task).
|
|
1133
|
+
function finishSingle(task: DoAlwaysTask) {
|
|
284
1134
|
if (settled) return;
|
|
285
1135
|
settled = true;
|
|
286
1136
|
clearPreviewTimer();
|
|
287
|
-
done(
|
|
288
|
-
}
|
|
1137
|
+
done({ kind: "single", task });
|
|
1138
|
+
}
|
|
1139
|
+
function finishChain(names: string[]) {
|
|
1140
|
+
if (settled) return;
|
|
1141
|
+
settled = true;
|
|
1142
|
+
clearPreviewTimer();
|
|
1143
|
+
done({ kind: "chain", names });
|
|
1144
|
+
}
|
|
1145
|
+
function finishCancel() {
|
|
1146
|
+
if (settled) return;
|
|
1147
|
+
settled = true;
|
|
1148
|
+
clearPreviewTimer();
|
|
1149
|
+
done({ kind: "cancel" });
|
|
1150
|
+
}
|
|
289
1151
|
|
|
290
1152
|
// The prompt preview appears only after the selection has been stable
|
|
291
1153
|
// for PREVIEW_DELAY_MS; any change hides it and restarts the delay.
|
|
@@ -307,7 +1169,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
307
1169
|
const kb = getKeybindings();
|
|
308
1170
|
const maxVisible = 12;
|
|
309
1171
|
let filter = "";
|
|
310
|
-
let
|
|
1172
|
+
let chain = chainClear();
|
|
1173
|
+
let cursor: Cursor = { kind: "cell", row: 0, col: "task" };
|
|
1174
|
+
let lastCellRow = 0;
|
|
311
1175
|
let mousePressedIndex: number | null = null;
|
|
312
1176
|
|
|
313
1177
|
// Arm the preview timer for the initial selection.
|
|
@@ -323,92 +1187,203 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
323
1187
|
);
|
|
324
1188
|
};
|
|
325
1189
|
|
|
326
|
-
// Recompute the visible (filtered
|
|
327
|
-
// filter typing
|
|
1190
|
+
// Recompute the visible (filtered) table rows on every render so
|
|
1191
|
+
// filter typing and chain edits update the table live.
|
|
328
1192
|
function getVisible() {
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
const
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
1193
|
+
const filteredGroups = groups.map((g) => ({ name: g.name, items: g.items.filter(matchesFilter) }));
|
|
1194
|
+
const tableRows = buildTableRows(filteredGroups, chain);
|
|
1195
|
+
const bodyRows: TableRow[] = [];
|
|
1196
|
+
const itemRows: { task: DoAlwaysTask; globalIndex: number; order?: number }[] = [];
|
|
1197
|
+
// O(n) index map so indexOf → O(1) lookup.
|
|
1198
|
+
const taskToGlobalIndex = new Map(visibleTasks.map((t, i) => [t, i]));
|
|
1199
|
+
for (const r of tableRows) {
|
|
1200
|
+
if (r.kind === "run") continue; // pinned row, rendered separately
|
|
1201
|
+
bodyRows.push(r);
|
|
1202
|
+
if (r.kind === "task" && r.task) {
|
|
1203
|
+
itemRows.push({ task: r.task, globalIndex: taskToGlobalIndex.get(r.task) ?? -1, order: r.order });
|
|
1204
|
+
}
|
|
339
1205
|
}
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
1206
|
+
// Scroll window over the filtered items.
|
|
1207
|
+
const anchor = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
1208
|
+
const winStart =
|
|
1209
|
+
itemRows.length > maxVisible
|
|
1210
|
+
? Math.max(0, Math.min(anchor + 1 - maxVisible, itemRows.length - maxVisible))
|
|
1211
|
+
: 0;
|
|
1212
|
+
// Headers whose group has at least one item in the window.
|
|
1213
|
+
const inWindow = new Set(itemRows.slice(winStart, winStart + maxVisible).map((r) => r.task));
|
|
1214
|
+
const visibleHeaderNames = new Set(
|
|
1215
|
+
filteredGroups.filter((g) => g.items.some((t) => inWindow.has(t))).map((g) => g.name),
|
|
1216
|
+
);
|
|
1217
|
+
return { bodyRows, itemRows, winStart, visibleHeaderNames };
|
|
348
1218
|
}
|
|
1219
|
+
// One pass's visible state, computed once per input event / render
|
|
1220
|
+
// and shared by clampCursor, buildTable, and the navigation logic.
|
|
1221
|
+
type Visible = ReturnType<typeof getVisible>;
|
|
349
1222
|
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
return isSelected ? theme.fg("accent", theme.bold(line)) : line;
|
|
359
|
-
}
|
|
360
|
-
// Width-aware two-column layout; fall back to label-only when the
|
|
361
|
-
// terminal is too narrow to fit a description column.
|
|
362
|
-
const effCol = Math.max(1, Math.min(labelCol, width - 8));
|
|
363
|
-
const nameOnly = truncateToWidth(label, effCol, "");
|
|
364
|
-
const pad = " ".repeat(Math.max(1, effCol - visibleWidth(nameOnly)));
|
|
365
|
-
const remaining = width - visibleWidth(nameOnly) - pad.length - 2;
|
|
366
|
-
if (remaining < 10) {
|
|
367
|
-
const line = truncateToWidth(label, Math.max(1, width - 2), "");
|
|
368
|
-
return isSelected ? theme.fg("accent", theme.bold(line)) : line;
|
|
1223
|
+
// Keep the cursor valid after the rows or the chain change. (The
|
|
1224
|
+
// ORDER cell of a non-chained row is a valid cursor position: it is
|
|
1225
|
+
// the "add" state.)
|
|
1226
|
+
function clampCursor(visible: Visible) {
|
|
1227
|
+
const { itemRows } = visible;
|
|
1228
|
+
if (itemRows.length === 0) {
|
|
1229
|
+
cursor = { kind: "cell", row: 0, col: "task" };
|
|
1230
|
+
return;
|
|
369
1231
|
}
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return theme.fg("accent", theme.bold(`${nameOnly}${pad}${desc}`));
|
|
1232
|
+
if (cursor.kind === "cell" && cursor.row >= itemRows.length) {
|
|
1233
|
+
cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
|
|
373
1234
|
}
|
|
374
|
-
return `${nameOnly}${pad}${theme.fg("muted", desc)}`;
|
|
375
1235
|
}
|
|
376
1236
|
|
|
377
|
-
//
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
|
|
1237
|
+
// --- Table geometry ---------------------------------------------------
|
|
1238
|
+
// Three tiers by width:
|
|
1239
|
+
// >= 76: # TASK DESCRIPTION ORDER
|
|
1240
|
+
// 58-75: # TASK ORDER
|
|
1241
|
+
// < 58: # TASK (chain shown on its own line below the list)
|
|
1242
|
+
const ORDER_COL_W = 5;
|
|
1243
|
+
const TASK_COL_W = 24;
|
|
1244
|
+
type Tier = "full" | "compact" | "narrow";
|
|
1245
|
+
function tierFor(width: number): Tier {
|
|
1246
|
+
if (width >= 76) return "full";
|
|
1247
|
+
if (width >= 58) return "compact";
|
|
1248
|
+
return "narrow";
|
|
1249
|
+
}
|
|
1250
|
+
// Column geometry: [2] # [3] [taskCol] [descCol] [ORDER_COL_W] [1]
|
|
1251
|
+
function tableGeometry(width: number) {
|
|
1252
|
+
const tier = tierFor(width);
|
|
1253
|
+
const taskCol = tier === "full" ? TASK_COL_W : Math.max(10, width - 2 - 3 - 2 - 2 - ORDER_COL_W - 1);
|
|
1254
|
+
const descCol = tier === "full" ? Math.max(8, width - 2 - 3 - 2 - TASK_COL_W - 2 - 2 - ORDER_COL_W - 1) : 0;
|
|
1255
|
+
// The ORDER cell is the last ORDER_COL_W characters of the line
|
|
1256
|
+
// (the line is width-2 chars wide), so it starts at width-2-W.
|
|
1257
|
+
const orderColX = tier === "narrow" ? null : width - 2 - ORDER_COL_W;
|
|
1258
|
+
return { tier, taskCol, descCol, orderColX };
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// Cached last buildTable result (avoids double-work on mouse hit-test).
|
|
1262
|
+
// The table depends on cursor and preview state, so render() must
|
|
1263
|
+
// rebuild it on every pass; handleMouse only reuses the last render's
|
|
1264
|
+
// table (or builds one if no render has happened yet, e.g. the very
|
|
1265
|
+
// first mouse event).
|
|
1266
|
+
type Table = { lines: string[]; itemLine: Map<number, DoAlwaysTask>; runLine: number; orderColX: number | null };
|
|
1267
|
+
let lastTable: Table | null = null;
|
|
1268
|
+
|
|
1269
|
+
// Build the full selector output for a width, plus the line map for
|
|
1270
|
+
// mouse handling (itemLine: line -> task, runLine: the Run row,
|
|
1271
|
+
// orderColX: where the ORDER cell starts, or null in the narrow tier).
|
|
1272
|
+
function buildTable(width: number, visible: Visible = getVisible()): Table {
|
|
1273
|
+
const { bodyRows, itemRows, winStart, visibleHeaderNames } = visible;
|
|
1274
|
+
const { tier, taskCol, descCol, orderColX } = tableGeometry(width);
|
|
381
1275
|
const lines: string[] = [];
|
|
382
1276
|
const itemLine = new Map<number, DoAlwaysTask>();
|
|
383
|
-
|
|
384
|
-
|
|
1277
|
+
let runLine = -1;
|
|
1278
|
+
// Cursor marker: a large triangle in the accent color. The row
|
|
1279
|
+
// background alone can be invisible (some themes map selectedBg
|
|
1280
|
+
// to the terminal's default background), so the marker carries
|
|
1281
|
+
// the cursor.
|
|
1282
|
+
const cursorMark = theme.fg("accent", "►");
|
|
1283
|
+
|
|
1284
|
+
// Header.
|
|
1285
|
+
lines.push(
|
|
1286
|
+
theme.fg(
|
|
1287
|
+
"muted",
|
|
1288
|
+
truncateToWidth(
|
|
1289
|
+
// The 6-char prefix (" # ") lines the header up with
|
|
1290
|
+
// the body rows (2-digit number + 2 spaces).
|
|
1291
|
+
tier === "full"
|
|
1292
|
+
? ` # ${"TASK".padEnd(taskCol)} ${"DESCRIPTION".padEnd(descCol)} ${"ORDER".padEnd(ORDER_COL_W)}`
|
|
1293
|
+
: tier === "compact"
|
|
1294
|
+
? ` # ${"TASK".padEnd(taskCol)} ${"ORDER".padEnd(ORDER_COL_W)}`
|
|
1295
|
+
: " # TASK",
|
|
1296
|
+
width - 2,
|
|
1297
|
+
"",
|
|
1298
|
+
),
|
|
1299
|
+
),
|
|
1300
|
+
);
|
|
1301
|
+
|
|
385
1302
|
if (itemRows.length === 0) {
|
|
386
1303
|
lines.push(theme.fg("warning", " No matching tasks"));
|
|
387
1304
|
} else {
|
|
388
|
-
|
|
1305
|
+
let itemShown = 0;
|
|
1306
|
+
// O(n) index map so findIndex → O(1) lookup.
|
|
1307
|
+
const idxMap = new Map(itemRows.map((x, i) => [x.task, i]));
|
|
1308
|
+
for (const row of bodyRows) {
|
|
389
1309
|
if (row.kind === "header") {
|
|
390
|
-
if (!visibleHeaderNames.has(row.name)) continue;
|
|
391
|
-
|
|
1310
|
+
if (!visibleHeaderNames.has(row.name ?? "")) continue;
|
|
1311
|
+
if (itemShown >= maxVisible) break;
|
|
1312
|
+
lines.push(
|
|
1313
|
+
theme.fg("accent", theme.bold(truncateToWidth(` ${(row.name ?? "").toUpperCase()}`, width - 2, ""))),
|
|
1314
|
+
);
|
|
392
1315
|
continue;
|
|
393
1316
|
}
|
|
394
|
-
if (!
|
|
395
|
-
const
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
1317
|
+
if (!row.task) continue;
|
|
1318
|
+
const idx = idxMap.get(row.task);
|
|
1319
|
+
if (idx === undefined || idx < winStart || idx >= winStart + maxVisible) continue;
|
|
1320
|
+
itemShown++;
|
|
1321
|
+
const task = row.task;
|
|
1322
|
+
const auto = shouldAutoRun(task);
|
|
1323
|
+
const focused = cursor.kind === "cell" && cursor.row === idx;
|
|
1324
|
+
// Cursor markers: ► in the left gutter = TASK column, ► in the
|
|
1325
|
+
// ORDER cell = ORDER column. The row background is applied too,
|
|
1326
|
+
// but some themes map selectedBg to a color that is nearly
|
|
1327
|
+
// indistinguishable from the terminal background, so the
|
|
1328
|
+
// character marker is the reliable indicator.
|
|
1329
|
+
const inTaskCol =
|
|
1330
|
+
cursor.kind === "cell" && cursor.row === idx && cursor.col === "task";
|
|
1331
|
+
const inOrderCol =
|
|
1332
|
+
cursor.kind === "cell" && cursor.row === idx && cursor.col === "order";
|
|
1333
|
+
const num = inTaskCol
|
|
1334
|
+
? `${cursorMark} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
|
|
1335
|
+
: inOrderCol && tier === "narrow"
|
|
1336
|
+
? `${theme.fg("warning", "►")} ${String(itemRows[idx].globalIndex + 1).padStart(2)}`
|
|
1337
|
+
: ` ${String(itemRows[idx].globalIndex + 1).padStart(2)}`;
|
|
1338
|
+
// ⚡ is 2 columns wide, so "⚡ " takes 3 — reserve it so
|
|
1339
|
+
// auto-run rows align with the others (ORDER cell is
|
|
1340
|
+
// hit-tested at a fixed x).
|
|
1341
|
+
const name = truncateToWidth(task.name, taskCol - (auto ? 3 : 0), "…", true);
|
|
1342
|
+
const taskCell = (auto ? "⚡ " : "") + name;
|
|
1343
|
+
// Every ORDER cell is exactly ORDER_COL_W wide so the column
|
|
1344
|
+
// stays aligned (and mouse hit-testing stays exact).
|
|
1345
|
+
const orderCell =
|
|
1346
|
+
row.order !== undefined
|
|
1347
|
+
? inOrderCol
|
|
1348
|
+
? truncateToWidth(`${cursorMark}[${row.order}]`, ORDER_COL_W, "", true)
|
|
1349
|
+
: ` [${row.order}] `
|
|
1350
|
+
: inOrderCol
|
|
1351
|
+
? `${cursorMark} · `
|
|
1352
|
+
: " · ";
|
|
1353
|
+
let line: string;
|
|
1354
|
+
if (tier === "full") {
|
|
1355
|
+
const desc = truncateToWidth(task.description ?? "", descCol, "…", true);
|
|
1356
|
+
line = `${num} ${taskCell} ${desc} ${orderCell}`;
|
|
1357
|
+
} else if (tier === "compact") {
|
|
1358
|
+
line = `${num} ${taskCell} ${orderCell}`;
|
|
1359
|
+
} else {
|
|
1360
|
+
line = truncateToWidth(`${num} ${taskCell}`, width - 2, "…");
|
|
1361
|
+
}
|
|
1362
|
+
// Full-row background + bold where the theme makes it visible;
|
|
1363
|
+
// the ► gutter/cell marker carries the cursor either way.
|
|
1364
|
+
if (focused) line = theme.bg("selectedBg", theme.bold(line));
|
|
1365
|
+
lines.push(line);
|
|
1366
|
+
itemLine.set(lines.length - 1, task);
|
|
399
1367
|
}
|
|
400
1368
|
if (itemRows.length > maxVisible) {
|
|
401
|
-
const
|
|
402
|
-
lines.push(theme.fg("dim", truncateToWidth(
|
|
1369
|
+
const anchor = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
1370
|
+
lines.push(theme.fg("dim", truncateToWidth(` (${anchor + 1}/${itemRows.length})`, width - 2, "")));
|
|
1371
|
+
}
|
|
1372
|
+
// Narrow tier: the chain gets its own line instead of a column.
|
|
1373
|
+
if (tier === "narrow" && chain.items.length > 0) {
|
|
1374
|
+
lines.push(
|
|
1375
|
+
theme.fg("dim", truncateToWidth(` chain: ${formatChainSequence(visibleTasks, chain)}`, width - 2, "…")),
|
|
1376
|
+
);
|
|
403
1377
|
}
|
|
404
1378
|
}
|
|
405
|
-
|
|
406
|
-
//
|
|
407
|
-
|
|
408
|
-
|
|
1379
|
+
|
|
1380
|
+
// Prompt preview: revealed after the cursor has been stable on a
|
|
1381
|
+
// task row for PREVIEW_DELAY_MS, showing exactly what will be
|
|
1382
|
+
// injected.
|
|
1383
|
+
if (previewVisible && cursor.kind === "cell" && cursor.col === "task") {
|
|
1384
|
+
const sel = itemRows[cursor.row];
|
|
409
1385
|
if (sel) {
|
|
410
1386
|
const wrapWidth = Math.max(10, width - 4);
|
|
411
|
-
// Show the rendered prompt — exactly what will be injected.
|
|
412
1387
|
const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, strings), wrapWidth);
|
|
413
1388
|
const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
|
|
414
1389
|
const truncated = wrapped.length > PREVIEW_MAX_LINES;
|
|
@@ -421,60 +1396,244 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
421
1396
|
});
|
|
422
1397
|
}
|
|
423
1398
|
}
|
|
1399
|
+
|
|
1400
|
+
// Pinned Run row (always visible, outside the scroll window).
|
|
424
1401
|
lines.push("");
|
|
425
|
-
|
|
426
|
-
const
|
|
427
|
-
|
|
428
|
-
|
|
1402
|
+
lines.push(theme.fg("dim", " " + "─".repeat(Math.max(1, width - 4))));
|
|
1403
|
+
const runLabel = chainRunLabel(chain.items.length);
|
|
1404
|
+
runLine = lines.length;
|
|
1405
|
+
// No play glyph in the label: the ► cursor marker is the only
|
|
1406
|
+
// ">" and it appears only while the row is selected (like task rows).
|
|
1407
|
+
if (cursor.kind === "run") {
|
|
1408
|
+
lines.push(theme.bg("selectedBg", theme.bold(`${cursorMark} ${runLabel}`)));
|
|
1409
|
+
} else if (chain.items.length === 0) {
|
|
1410
|
+
lines.push(theme.fg("dim", ` ${runLabel}`));
|
|
1411
|
+
} else {
|
|
1412
|
+
lines.push(theme.fg("accent", ` ${runLabel}`));
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// Context-sensitive footer.
|
|
1416
|
+
let footer: string;
|
|
1417
|
+
if (cursor.kind === "run") {
|
|
1418
|
+
footer =
|
|
1419
|
+
chain.items.length > 0
|
|
1420
|
+
? ` ⏎ run: ${formatChainSequence(visibleTasks, chain)}`
|
|
1421
|
+
: " ⏎ run the chain (chain is empty)";
|
|
1422
|
+
} else if (cursor.col === "order") {
|
|
1423
|
+
const inChain = chain.items.includes(itemRows[cursor.row]?.task.name ?? "");
|
|
1424
|
+
footer = inChain ? " ← tasks • ⏎ remove • esc" : " ← tasks • ⏎ add • esc";
|
|
1425
|
+
} else {
|
|
1426
|
+
footer = " 1-9 run now • ⏎ select • → order • ⌫ undo • esc";
|
|
1427
|
+
}
|
|
1428
|
+
if (chain.items.length > 0 && cursor.kind !== "run") footer += " • ctrl+u clear";
|
|
429
1429
|
lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
|
|
430
|
-
|
|
1430
|
+
|
|
1431
|
+
return { lines, itemLine, runLine, orderColX };
|
|
431
1432
|
}
|
|
432
1433
|
|
|
433
1434
|
return {
|
|
434
1435
|
render(width: number) {
|
|
435
|
-
|
|
1436
|
+
lastTable = buildTable(width, getVisible());
|
|
1437
|
+
return lastTable.lines;
|
|
1438
|
+
},
|
|
1439
|
+
invalidate() {
|
|
1440
|
+
clearPreviewTimer();
|
|
436
1441
|
},
|
|
437
|
-
invalidate() {},
|
|
438
1442
|
handleInput(data: string) {
|
|
439
|
-
// Direct pick by number (1-9) —
|
|
440
|
-
//
|
|
1443
|
+
// Direct pick by number (1-9) — runs the task immediately (the
|
|
1444
|
+
// classic fast path), closing the selector and discarding the
|
|
1445
|
+
// chain. Only when not filtering, and within the visible set,
|
|
1446
|
+
// so digits pick a visible task by its number.
|
|
441
1447
|
if (!filter && /^[1-9]$/.test(data) && Number(data) <= visibleTasks.length) {
|
|
442
|
-
|
|
1448
|
+
finishSingle(visibleTasks[Number(data) - 1]);
|
|
443
1449
|
return;
|
|
444
1450
|
}
|
|
445
|
-
// Filter typing
|
|
1451
|
+
// Filter typing (Backspace edits the filter; with an empty
|
|
1452
|
+
// filter it undoes the last chain add). The filter change
|
|
1453
|
+
// invalidates the visible set, so the clamp gets a fresh pass.
|
|
446
1454
|
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
|
447
|
-
filter
|
|
448
|
-
|
|
1455
|
+
if (filter.length > 0) {
|
|
1456
|
+
filter = filter.slice(0, -1);
|
|
1457
|
+
clampCursor(getVisible());
|
|
1458
|
+
} else {
|
|
1459
|
+
const { state, removed } = chainUndo(chain);
|
|
1460
|
+
if (removed) {
|
|
1461
|
+
chain = state;
|
|
1462
|
+
clampCursor(getVisible());
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
449
1465
|
resetPreview();
|
|
450
1466
|
tui.requestRender();
|
|
451
1467
|
return;
|
|
452
1468
|
}
|
|
453
1469
|
if (isPrintable(data)) {
|
|
454
1470
|
filter += data;
|
|
455
|
-
|
|
1471
|
+
clampCursor(getVisible());
|
|
456
1472
|
resetPreview();
|
|
457
1473
|
tui.requestRender();
|
|
458
1474
|
return;
|
|
459
1475
|
}
|
|
460
|
-
//
|
|
461
|
-
|
|
1476
|
+
// One pass for the rest of the event: navigation, confirm,
|
|
1477
|
+
// and clear below all share this result (chain edits don't
|
|
1478
|
+
// change itemRows, so it stays valid).
|
|
1479
|
+
const visible = getVisible();
|
|
1480
|
+
const { itemRows } = visible;
|
|
1481
|
+
if (itemRows.length === 0) {
|
|
1482
|
+
// Nothing to navigate; only Esc is useful here.
|
|
1483
|
+
if (kb.matches(data, "tui.select.cancel")) finishCancel();
|
|
1484
|
+
return;
|
|
1485
|
+
}
|
|
1486
|
+
// Column switching.
|
|
1487
|
+
if (matchesKey(data, "left")) {
|
|
1488
|
+
if (cursor.kind === "run") {
|
|
1489
|
+
cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
|
|
1490
|
+
} else if (cursor.col === "order") {
|
|
1491
|
+
cursor = { kind: "cell", row: cursor.row, col: "task" };
|
|
1492
|
+
}
|
|
1493
|
+
lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
1494
|
+
resetPreview();
|
|
1495
|
+
tui.requestRender();
|
|
1496
|
+
return;
|
|
1497
|
+
}
|
|
1498
|
+
if (matchesKey(data, "right")) {
|
|
1499
|
+
if (cursor.kind === "cell" && cursor.col === "task") {
|
|
1500
|
+
// Same row: the ORDER cell shows whether this task is
|
|
1501
|
+
// in the chain, and Enter toggles it.
|
|
1502
|
+
cursor = { kind: "cell", row: cursor.row, col: "order" };
|
|
1503
|
+
}
|
|
1504
|
+
// (→ in the ORDER column and on the Run row is a no-op:
|
|
1505
|
+
// the cursor is already at the right/bottom edge.)
|
|
1506
|
+
lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
1507
|
+
resetPreview();
|
|
1508
|
+
tui.requestRender();
|
|
1509
|
+
return;
|
|
1510
|
+
}
|
|
1511
|
+
// Row navigation: the cursor moves in both columns (wrap at
|
|
1512
|
+
// the edges); the Run row is reached from the last task row.
|
|
462
1513
|
if (kb.matches(data, "tui.select.up")) {
|
|
463
|
-
|
|
1514
|
+
if (cursor.kind === "run") {
|
|
1515
|
+
cursor = { kind: "cell", row: itemRows.length - 1, col: "task" };
|
|
1516
|
+
} else {
|
|
1517
|
+
cursor = {
|
|
1518
|
+
kind: "cell",
|
|
1519
|
+
row: cursor.row === 0 ? itemRows.length - 1 : cursor.row - 1,
|
|
1520
|
+
col: cursor.col,
|
|
1521
|
+
};
|
|
1522
|
+
}
|
|
1523
|
+
lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
464
1524
|
resetPreview();
|
|
465
1525
|
tui.requestRender();
|
|
1526
|
+
return;
|
|
466
1527
|
}
|
|
467
|
-
|
|
468
|
-
|
|
1528
|
+
if (kb.matches(data, "tui.select.down")) {
|
|
1529
|
+
if (cursor.kind === "run") {
|
|
1530
|
+
cursor = { kind: "cell", row: 0, col: "task" };
|
|
1531
|
+
} else if (cursor.row === itemRows.length - 1) {
|
|
1532
|
+
// The pinned Run row sits below the last task row.
|
|
1533
|
+
cursor = { kind: "run" };
|
|
1534
|
+
lastCellRow = itemRows.length - 1;
|
|
1535
|
+
} else {
|
|
1536
|
+
cursor = {
|
|
1537
|
+
kind: "cell",
|
|
1538
|
+
row: cursor.row + 1,
|
|
1539
|
+
col: cursor.col,
|
|
1540
|
+
};
|
|
1541
|
+
}
|
|
1542
|
+
lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
469
1543
|
resetPreview();
|
|
470
1544
|
tui.requestRender();
|
|
1545
|
+
return;
|
|
471
1546
|
}
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
1547
|
+
// Extended navigation: home, end, pageup, pagedown.
|
|
1548
|
+
if (matchesKey(data, "home")) {
|
|
1549
|
+
cursor = { kind: "cell", row: 0, col: cursor.kind === "cell" ? cursor.col : "task" };
|
|
1550
|
+
lastCellRow = 0;
|
|
1551
|
+
resetPreview();
|
|
1552
|
+
tui.requestRender();
|
|
1553
|
+
return;
|
|
475
1554
|
}
|
|
476
|
-
|
|
477
|
-
|
|
1555
|
+
if (matchesKey(data, "end")) {
|
|
1556
|
+
cursor = { kind: "run" };
|
|
1557
|
+
lastCellRow = itemRows.length - 1;
|
|
1558
|
+
resetPreview();
|
|
1559
|
+
tui.requestRender();
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
if (matchesKey(data, "pageUp")) {
|
|
1563
|
+
if (cursor.kind === "run") {
|
|
1564
|
+
cursor = { kind: "cell", row: Math.max(0, itemRows.length - maxVisible), col: "task" };
|
|
1565
|
+
} else {
|
|
1566
|
+
cursor = {
|
|
1567
|
+
kind: "cell",
|
|
1568
|
+
row: Math.max(0, cursor.row - maxVisible),
|
|
1569
|
+
col: cursor.col,
|
|
1570
|
+
};
|
|
1571
|
+
}
|
|
1572
|
+
lastCellRow = cursor.row;
|
|
1573
|
+
resetPreview();
|
|
1574
|
+
tui.requestRender();
|
|
1575
|
+
return;
|
|
1576
|
+
}
|
|
1577
|
+
if (matchesKey(data, "pageDown")) {
|
|
1578
|
+
if (cursor.kind === "cell") {
|
|
1579
|
+
const next = cursor.row + maxVisible;
|
|
1580
|
+
if (next >= itemRows.length) {
|
|
1581
|
+
cursor = { kind: "run" };
|
|
1582
|
+
lastCellRow = itemRows.length - 1;
|
|
1583
|
+
} else {
|
|
1584
|
+
cursor = { kind: "cell", row: next, col: cursor.col };
|
|
1585
|
+
lastCellRow = next;
|
|
1586
|
+
}
|
|
1587
|
+
}
|
|
1588
|
+
resetPreview();
|
|
1589
|
+
tui.requestRender();
|
|
1590
|
+
return;
|
|
1591
|
+
}
|
|
1592
|
+
// Confirm: context-dependent.
|
|
1593
|
+
if (kb.matches(data, "tui.select.confirm")) {
|
|
1594
|
+
if (cursor.kind === "run") {
|
|
1595
|
+
if (chain.items.length === 0) {
|
|
1596
|
+
ctx.ui.notify("do-always: chain is empty — add a task first", "info");
|
|
1597
|
+
} else {
|
|
1598
|
+
finishChain([...chain.items]);
|
|
1599
|
+
}
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const row = itemRows[cursor.row];
|
|
1603
|
+
if (!row) return;
|
|
1604
|
+
if (cursor.col === "task") {
|
|
1605
|
+
// The classic pick: run just this task (fill or
|
|
1606
|
+
// auto-run per its autoRun), discarding the chain.
|
|
1607
|
+
finishSingle(row.task);
|
|
1608
|
+
return;
|
|
1609
|
+
}
|
|
1610
|
+
// ORDER column: toggle this task's chain membership.
|
|
1611
|
+
if (chain.items.includes(row.task.name)) {
|
|
1612
|
+
chain = chainRemove(chain, row.task.name);
|
|
1613
|
+
} else {
|
|
1614
|
+
const { state, result } = chainAdd(chain, row.task.name);
|
|
1615
|
+
chain = state;
|
|
1616
|
+
if (result === "full") {
|
|
1617
|
+
ctx.ui.notify(`do-always: chain is full (${CHAIN_MAX}) — remove a task first`, "error");
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
lastCellRow = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
1621
|
+
resetPreview();
|
|
1622
|
+
tui.requestRender();
|
|
1623
|
+
return;
|
|
1624
|
+
}
|
|
1625
|
+
// Clear the chain.
|
|
1626
|
+
if (matchesKey(data, "ctrl+u")) {
|
|
1627
|
+
if (chain.items.length > 0) {
|
|
1628
|
+
chain = chainClear();
|
|
1629
|
+
clampCursor(visible);
|
|
1630
|
+
resetPreview();
|
|
1631
|
+
tui.requestRender();
|
|
1632
|
+
}
|
|
1633
|
+
return;
|
|
1634
|
+
}
|
|
1635
|
+
if (kb.matches(data, "tui.select.cancel")) {
|
|
1636
|
+
finishCancel();
|
|
478
1637
|
}
|
|
479
1638
|
},
|
|
480
1639
|
handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
|
|
@@ -482,21 +1641,51 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
482
1641
|
const { itemRows } = getVisible();
|
|
483
1642
|
if (itemRows.length === 0) return undefined;
|
|
484
1643
|
const delta = event.wheelDelta < 0 ? -1 : 1;
|
|
485
|
-
const prev =
|
|
486
|
-
|
|
487
|
-
if (
|
|
488
|
-
|
|
1644
|
+
const prev = cursor.kind === "cell" ? cursor.row : lastCellRow;
|
|
1645
|
+
const next = Math.max(0, Math.min(itemRows.length - 1, prev + delta));
|
|
1646
|
+
if (next === prev) return { handled: true };
|
|
1647
|
+
cursor = { kind: "cell", row: next, col: "task" };
|
|
1648
|
+
lastCellRow = next;
|
|
1649
|
+
resetPreview();
|
|
1650
|
+
return { handled: true, render: true };
|
|
489
1651
|
}
|
|
490
1652
|
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
|
491
|
-
|
|
1653
|
+
// Reuse the last render's table for hit-testing (avoids rebuilding
|
|
1654
|
+
// the table twice per mouse event). The table is always fresh
|
|
1655
|
+
// because handleInput calls requestRender before the next mouse
|
|
1656
|
+
// event can arrive; build one if no render has happened yet.
|
|
1657
|
+
// One getVisible() per event, shared by the (possibly fresh)
|
|
1658
|
+
// table and the itemRows lookup below.
|
|
1659
|
+
const visible = getVisible();
|
|
1660
|
+
const { itemLine, runLine, orderColX } = lastTable ?? buildTable(event.width, visible);
|
|
1661
|
+
// Pinned Run row: press runs the chain.
|
|
1662
|
+
if (runLine >= 0 && event.y === runLine) {
|
|
1663
|
+
if (event.type === "press" && chain.items.length > 0) {
|
|
1664
|
+
finishChain([...chain.items]);
|
|
1665
|
+
}
|
|
1666
|
+
return { handled: true };
|
|
1667
|
+
}
|
|
492
1668
|
const task = itemLine.get(event.y);
|
|
493
1669
|
if (!task) return undefined;
|
|
1670
|
+
const { itemRows } = visible;
|
|
494
1671
|
const idx = itemRows.findIndex((r) => r.task === task);
|
|
495
1672
|
if (idx < 0) return undefined;
|
|
1673
|
+
// ORDER cell: press toggles chain membership.
|
|
1674
|
+
if (orderColX !== null && event.x >= orderColX) {
|
|
1675
|
+
if (event.type === "press") {
|
|
1676
|
+
chain = chain.items.includes(task.name) ? chainRemove(chain, task.name) : chainAdd(chain, task.name).state;
|
|
1677
|
+
clampCursor(visible);
|
|
1678
|
+
resetPreview();
|
|
1679
|
+
return { handled: true, render: true };
|
|
1680
|
+
}
|
|
1681
|
+
return { handled: true }; // swallow the click after the press action
|
|
1682
|
+
}
|
|
1683
|
+
// Task area: press selects, click runs (the classic fast path).
|
|
496
1684
|
if (event.type === "press") {
|
|
497
1685
|
mousePressedIndex = idx;
|
|
498
|
-
if (
|
|
499
|
-
|
|
1686
|
+
if (cursor.kind !== "cell" || cursor.row !== idx) {
|
|
1687
|
+
cursor = { kind: "cell", row: idx, col: "task" };
|
|
1688
|
+
lastCellRow = idx;
|
|
500
1689
|
resetPreview();
|
|
501
1690
|
}
|
|
502
1691
|
return { handled: true, focus: true, render: true };
|
|
@@ -504,14 +1693,18 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
504
1693
|
const clicked = mousePressedIndex ?? idx;
|
|
505
1694
|
mousePressedIndex = null;
|
|
506
1695
|
const chosen = itemRows[clicked];
|
|
507
|
-
if (chosen)
|
|
1696
|
+
if (chosen) finishSingle(chosen.task);
|
|
508
1697
|
return { handled: true };
|
|
509
1698
|
},
|
|
510
1699
|
};
|
|
511
1700
|
});
|
|
512
1701
|
|
|
513
|
-
if (
|
|
514
|
-
|
|
1702
|
+
if (!result || result.kind === "cancel") return;
|
|
1703
|
+
if (result.kind === "single") {
|
|
1704
|
+
await fillPrompt(result.task, ctx, context);
|
|
1705
|
+
} else {
|
|
1706
|
+
await runChain(result.names, ctx, cache);
|
|
1707
|
+
}
|
|
515
1708
|
}
|
|
516
1709
|
|
|
517
1710
|
pi.registerCommand("do-always", {
|
|
@@ -536,6 +1729,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
536
1729
|
});
|
|
537
1730
|
|
|
538
1731
|
async function runDoAlways(args: string, ctx: ExtensionContext): Promise<void> {
|
|
1732
|
+
lastCtx = ctx;
|
|
539
1733
|
// Reload when the active directory changes, so switching projects
|
|
540
1734
|
// mid-session serves the right config instead of stale tasks.
|
|
541
1735
|
if (ctx.cwd !== loadedCwd) {
|
|
@@ -544,19 +1738,22 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
544
1738
|
if (ctx.mode === "tui") ctx.ui.notify(m, "warning");
|
|
545
1739
|
else console.warn(m);
|
|
546
1740
|
};
|
|
547
|
-
|
|
1741
|
+
const config = loadConfig(ctx.cwd, onError);
|
|
1742
|
+
tasks = config.tasks;
|
|
1743
|
+
reportEnabled = config.report;
|
|
548
1744
|
}
|
|
549
1745
|
|
|
550
1746
|
// One context per command run: shared by visibility filtering, rendering,
|
|
551
1747
|
// and the completion cache (never inside a render loop).
|
|
552
|
-
const
|
|
1748
|
+
const cache = createContextCache();
|
|
1749
|
+
const context = await cache.get(ctx.cwd);
|
|
553
1750
|
const visible = refreshVisible(ctx.cwd, context);
|
|
554
1751
|
|
|
555
1752
|
const arg = args.trim();
|
|
556
1753
|
|
|
557
1754
|
if (!arg) {
|
|
558
1755
|
if (ctx.mode === "tui") {
|
|
559
|
-
await showSelector(ctx, context);
|
|
1756
|
+
await showSelector(ctx, context, cache);
|
|
560
1757
|
} else {
|
|
561
1758
|
ctx.ui.notify(`do-always tasks (use /do-always <number|name>):\n${formatList(visible)}`, "info");
|
|
562
1759
|
}
|