pi-do-always 0.3.0 → 0.4.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 +45 -11
- package/do-always.json +4 -3
- package/index.ts +155 -11
- package/package.json +1 -1
- package/pi.image.png +0 -0
- package/tasks.ts +56 -3
package/README.md
CHANGED
|
@@ -5,17 +5,18 @@ repeated "do the usual" prompts.
|
|
|
5
5
|
|
|
6
6
|
Type `/do-always` → a numbered list of tasks appears, grouped by category → press a number,
|
|
7
7
|
type to filter, scroll or click, or navigate with arrows + Enter → the task's prompt is
|
|
8
|
-
**filled into the input editor**. Review it, tweak it, press Enter to run.
|
|
8
|
+
**filled into the input editor**. Review it, tweak it, press Enter to run. Tasks marked `⚡`
|
|
9
|
+
(the `Plan` category by default) auto-run on selection instead — see `autoRun` below.
|
|
9
10
|
|
|
10
11
|
```text
|
|
11
12
|
do-always — pick a task
|
|
12
13
|
|
|
13
14
|
PLAN
|
|
14
|
-
▸ 1. Review
|
|
15
|
-
2. Cleanup
|
|
16
|
-
3. Security
|
|
17
|
-
4. Performance
|
|
18
|
-
5. Propose features
|
|
15
|
+
▸ 1. ⚡ Review Review code and double-check changes (Plan)
|
|
16
|
+
2. ⚡ Cleanup Clean up dead code and duplicates (Plan)
|
|
17
|
+
3. ⚡ Security Security audit (Plan)
|
|
18
|
+
4. ⚡ Performance Performance review (Plan)
|
|
19
|
+
5. ⚡ Propose features Propose new features (Plan)
|
|
19
20
|
DO
|
|
20
21
|
6. Build Test build is ok and fix issues
|
|
21
22
|
7. Tests Run tests and fix failures
|
|
@@ -25,7 +26,12 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
|
|
|
25
26
|
9. Release Prepare a release (version, changelog, tag)
|
|
26
27
|
10. Commit Prepare a clean commit
|
|
27
28
|
|
|
28
|
-
|
|
29
|
+
Review — prompt:
|
|
30
|
+
Review the changes on branch fix/login-null (3 changed files: auth.ts, login.ts,
|
|
31
|
+
test/auth.test.ts). Last commit: Fix null check in login. Check `git status` and
|
|
32
|
+
`git diff` to see what changed, then double-check the changes for bugs, edge …
|
|
33
|
+
|
|
34
|
+
1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs
|
|
29
35
|
```
|
|
30
36
|
|
|
31
37
|

|
|
@@ -38,11 +44,16 @@ type to filter, scroll or click, or navigate with arrows + Enter → the task's
|
|
|
38
44
|
|`/do-always 2`|Fill the prompt for task #2 directly|
|
|
39
45
|
|`/do-always review`|Fill the prompt for the task named `review` (task names autocomplete after `/do-always`)|
|
|
40
46
|
|`/do-always list`|Print the task list|
|
|
41
|
-
|`/do-always list-details`|Show the full prompt text each task will inject|
|
|
47
|
+
|`/do-always list-details`|Show the full rendered prompt text each task will inject|
|
|
42
48
|
|
|
43
49
|
The selector supports direct number-pick (1-9), live type-to-filter, arrow/Enter navigation,
|
|
44
50
|
mouse-wheel scrolling, and click-to-select. While a filter is active, typed digits refine the
|
|
45
51
|
filter instead of picking by number; clear the filter (backspace) to use number-pick again.
|
|
52
|
+
Pause on a task for two seconds and its full prompt is previewed below the list, so you can
|
|
53
|
+
see exactly what will be injected before running it; moving the selection or typing hides it
|
|
54
|
+
and restarts the delay. A task marked `⚡` runs immediately on selection (its prompt is sent,
|
|
55
|
+
not filled): the `Plan` category does this by default, and any task can opt in or out via the
|
|
56
|
+
`autoRun` field.
|
|
46
57
|
|
|
47
58
|
In non-interactive modes (no TUI) there is no editor to fill, so the selected prompt is sent
|
|
48
59
|
as a user message instead.
|
|
@@ -86,7 +97,7 @@ locations above to make it your own:
|
|
|
86
97
|
"name": "review",
|
|
87
98
|
"category": "Plan",
|
|
88
99
|
"description": "Review code and double-check changes",
|
|
89
|
-
"prompt": "Review the
|
|
100
|
+
"prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Last commit: {{last_commit}}. Check `git status` and `git diff` ..."
|
|
90
101
|
}
|
|
91
102
|
]
|
|
92
103
|
```
|
|
@@ -96,7 +107,8 @@ Fields:
|
|
|
96
107
|
- `name` (required) — short unique id, used for `/do-always <name>`
|
|
97
108
|
- `category` (optional) — group header the task is shown under in the selector (e.g. `"Plan"`, `"Do"`). Matching is case-insensitive and the header is title-cased, so `"plan"` and `"Plan"` land in the same `Plan` group. Tasks without a category fall under `Other`. The built-in defaults are grouped into `Plan`, `Do`, `Docs`, and `Ops`.
|
|
98
109
|
- `description` (optional) — one-line label shown in the selector
|
|
99
|
-
- `prompt` (required) — the text filled into the editor
|
|
110
|
+
- `prompt` (required) — the text filled into the editor (supports `{{placeholders}}` — see [Prompt placeholders](#prompt-placeholders))
|
|
111
|
+
- `autoRun` (optional) — when `true`, selecting the task sends its prompt immediately instead of filling the editor; when `false`, it always fills the editor. When omitted, the default is derived from the category: `Plan` tasks auto-run, everything else fills the editor. Auto-run tasks are marked `⚡` in the selector.
|
|
100
112
|
|
|
101
113
|
In the object form you can also configure the selector shortcut:
|
|
102
114
|
|
|
@@ -104,6 +116,28 @@ In the object form you can also configure the selector shortcut:
|
|
|
104
116
|
|
|
105
117
|
Reload Pi (or start a new session) after editing a config file.
|
|
106
118
|
|
|
119
|
+
## Prompt placeholders
|
|
120
|
+
|
|
121
|
+
Task prompts support `{{placeholders}}` that are filled in from the current
|
|
122
|
+
directory when a task is selected — so `/do-always review` on a hotfix branch
|
|
123
|
+
injects “Review the changes on branch `fix/login-null` (3 changed files:
|
|
124
|
+
`auth.ts`, `login.ts`, `test/auth.test.ts`) …” instead of a generic nudge.
|
|
125
|
+
|
|
126
|
+
|Placeholder|Value|
|
|
127
|
+
|---|---|
|
|
128
|
+
|`{{cwd}}`|Absolute path of the working directory|
|
|
129
|
+
|`{{date}}`|Local date (`YYYY-MM-DD`)|
|
|
130
|
+
|`{{branch}}`|Current git branch (`unknown` outside a git repo)|
|
|
131
|
+
|`{{last_commit}}`|Subject of the latest commit (`unknown` if unavailable, e.g. empty repo)|
|
|
132
|
+
|`{{files_changed}}`|Changed files from `git status` — comma-separated, capped at 20 entries (`none` when clean or not a git repo)|
|
|
133
|
+
|`{{files_changed_count}}`|Number of changed files (`0` when clean or not a git repo)|
|
|
134
|
+
|`{{user}}`|`git config user.name` (`unknown` when unset)|
|
|
135
|
+
|
|
136
|
+
Unknown placeholders are left as-is, and a prompt without placeholders is
|
|
137
|
+
injected unchanged, so existing configs keep working. The selector preview and
|
|
138
|
+
`/do-always list-details` show the rendered prompt — what you see is what gets
|
|
139
|
+
injected.
|
|
140
|
+
|
|
107
141
|
## Development
|
|
108
142
|
|
|
109
143
|
```bash
|
|
@@ -112,6 +146,6 @@ npm run typecheck # tsc --noEmit
|
|
|
112
146
|
npm test # run the unit tests (node:test + tsx, in test/)
|
|
113
147
|
```
|
|
114
148
|
|
|
115
|
-
The pure task logic (`parseConfig`, `mergeTasks`, `resolveTask`, `formatList`) lives in
|
|
149
|
+
The pure task logic (`parseConfig`, `mergeTasks`, `renderPrompt`, `resolveTask`, `formatList`) lives in
|
|
116
150
|
[`tasks.ts`](./tasks.ts) with no Pi dependencies, so it is unit-tested independently of the
|
|
117
151
|
Pi runtime. The extension (`index.ts`) imports that logic and adds only the Pi UI.
|
package/do-always.json
CHANGED
|
@@ -5,11 +5,12 @@
|
|
|
5
5
|
"name": "Review",
|
|
6
6
|
"category": "Plan",
|
|
7
7
|
"description": "Review code and double-check changes (Plan)",
|
|
8
|
-
"prompt": "Review the
|
|
8
|
+
"prompt": "Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings"
|
|
9
9
|
},
|
|
10
10
|
{
|
|
11
11
|
"name": "Cleanup",
|
|
12
12
|
"category": "Plan",
|
|
13
|
+
"autoRun": false,
|
|
13
14
|
"description": "Clean up dead code and duplicates (Plan)",
|
|
14
15
|
"prompt": "Scan the project for dead code, unused imports, commented-out blocks, and duplicated logic. Do a plan proposal for the removals and consolidations, keeping behavior unchanged. Do not make any changes yet."
|
|
15
16
|
},
|
|
@@ -53,13 +54,13 @@
|
|
|
53
54
|
"name": "Release",
|
|
54
55
|
"category": "Ops",
|
|
55
56
|
"description": "Prepare a release (version, changelog, tag)",
|
|
56
|
-
"prompt": "Prepare a release for this project: check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag if git present. Do not push."
|
|
57
|
+
"prompt": "Prepare a release for this project (branch {{branch}}): check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag if git present. Do not push."
|
|
57
58
|
},
|
|
58
59
|
{
|
|
59
60
|
"name": "Commit",
|
|
60
61
|
"category": "Ops",
|
|
61
62
|
"description": "Prepare a clean commit",
|
|
62
|
-
"prompt": "Prepare the working tree for a clean commit: review `git status` and `git diff`, stage the relevant changes, and write a clear commit message describing what changed and why. Do not push."
|
|
63
|
+
"prompt": "Prepare the working tree on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}) for a clean commit: review `git status` and `git diff`, stage the relevant changes, and write a clear commit message describing what changed and why. Do not push."
|
|
63
64
|
}
|
|
64
65
|
]
|
|
65
66
|
}
|
package/index.ts
CHANGED
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
* If no config file exists, built-in default tasks are used.
|
|
34
34
|
*/
|
|
35
35
|
|
|
36
|
+
import { execFileSync } from "node:child_process";
|
|
36
37
|
import { existsSync, readFileSync } from "node:fs";
|
|
37
38
|
import { join } from "node:path";
|
|
38
39
|
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -44,6 +45,7 @@ import {
|
|
|
44
45
|
getKeybindings,
|
|
45
46
|
truncateToWidth,
|
|
46
47
|
visibleWidth,
|
|
48
|
+
wrapTextWithAnsi,
|
|
47
49
|
} from "@earendil-works/pi-tui";
|
|
48
50
|
import {
|
|
49
51
|
DEFAULT_SHORTCUT,
|
|
@@ -54,9 +56,12 @@ import {
|
|
|
54
56
|
mergeTasks,
|
|
55
57
|
parseConfig,
|
|
56
58
|
orderTasksByCategory,
|
|
59
|
+
renderPrompt,
|
|
57
60
|
resolveShortcut,
|
|
58
61
|
resolveTask,
|
|
62
|
+
shouldAutoRun,
|
|
59
63
|
type DoAlwaysTask,
|
|
64
|
+
type PromptContext,
|
|
60
65
|
type TaskGroup,
|
|
61
66
|
} from "./tasks";
|
|
62
67
|
|
|
@@ -84,6 +89,71 @@ function loadConfig(cwd: string): { tasks: DoAlwaysTask[]; shortcut: string | nu
|
|
|
84
89
|
};
|
|
85
90
|
}
|
|
86
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Run a git command in `cwd` and return its trimmed stdout.
|
|
94
|
+
* Returns undefined on any failure (not a git repo, git not installed,
|
|
95
|
+
* empty repo, …) so callers can fall back to a neutral value.
|
|
96
|
+
* No shell is involved (argument array), so file names cannot inject commands.
|
|
97
|
+
*/
|
|
98
|
+
function git(cwd: string, args: string[]): string | undefined {
|
|
99
|
+
try {
|
|
100
|
+
const out = execFileSync("git", args, {
|
|
101
|
+
cwd,
|
|
102
|
+
encoding: "utf-8",
|
|
103
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
104
|
+
});
|
|
105
|
+
const trimmed = out.trim();
|
|
106
|
+
return trimmed === "" ? undefined : trimmed;
|
|
107
|
+
} catch {
|
|
108
|
+
return undefined;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Max number of file paths listed in `files_changed` (the count stays exact). */
|
|
113
|
+
const MAX_FILES_LISTED = 20;
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Build the prompt context for `renderPrompt`. Always contains every
|
|
117
|
+
* PROMPT_CONTEXT_KEYS entry: git facts fall back to neutral values when
|
|
118
|
+
* unavailable (non-git dir, no git, empty repo) so default prompts read
|
|
119
|
+
* cleanly in any directory.
|
|
120
|
+
*/
|
|
121
|
+
function buildContext(cwd: string): PromptContext {
|
|
122
|
+
const branch = git(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]) ?? "unknown";
|
|
123
|
+
const lastCommit = git(cwd, ["log", "-1", "--format=%s"]) ?? "unknown";
|
|
124
|
+
const user = git(cwd, ["config", "user.name"]) ?? "unknown";
|
|
125
|
+
|
|
126
|
+
const files: string[] = [];
|
|
127
|
+
const status = git(cwd, ["status", "--porcelain"]);
|
|
128
|
+
if (status) {
|
|
129
|
+
for (const line of status.split("\n")) {
|
|
130
|
+
// Porcelain v1 lines are "XY <path>" (X = index, Y = worktree).
|
|
131
|
+
if (line.length < 4) continue;
|
|
132
|
+
const path = line.slice(3);
|
|
133
|
+
if (path && !files.includes(path)) files.push(path);
|
|
134
|
+
}
|
|
135
|
+
files.sort();
|
|
136
|
+
}
|
|
137
|
+
let listed: string;
|
|
138
|
+
if (files.length === 0) {
|
|
139
|
+
listed = "none";
|
|
140
|
+
} else if (files.length > MAX_FILES_LISTED) {
|
|
141
|
+
listed = [...files.slice(0, MAX_FILES_LISTED), `… (+${files.length - MAX_FILES_LISTED} more)`].join(", ");
|
|
142
|
+
} else {
|
|
143
|
+
listed = files.join(", ");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
cwd,
|
|
148
|
+
date: new Date().toLocaleDateString("en-CA"), // local YYYY-MM-DD
|
|
149
|
+
branch,
|
|
150
|
+
last_commit: lastCommit,
|
|
151
|
+
files_changed: listed,
|
|
152
|
+
files_changed_count: String(files.length),
|
|
153
|
+
user,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
87
157
|
/** Find the group (among `groups`) that contains a task. */
|
|
88
158
|
function findGroupOf(task: DoAlwaysTask, groups: TaskGroup[]): TaskGroup | undefined {
|
|
89
159
|
return groups.find((g) => g.items.includes(task));
|
|
@@ -94,6 +164,11 @@ function isPrintable(data: string): boolean {
|
|
|
94
164
|
return data.length === 1 && data >= " " && data <= "~";
|
|
95
165
|
}
|
|
96
166
|
|
|
167
|
+
/** Delay before the selector reveals the selected task's prompt preview. */
|
|
168
|
+
const PREVIEW_DELAY_MS = 2000;
|
|
169
|
+
/** Max lines of the prompt shown in the selector preview. */
|
|
170
|
+
const PREVIEW_MAX_LINES = 3;
|
|
171
|
+
|
|
97
172
|
export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
98
173
|
let tasks: DoAlwaysTask[] = [];
|
|
99
174
|
let loadedCwd = ""; // cwd the cached `tasks` were loaded for
|
|
@@ -105,11 +180,19 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
105
180
|
|
|
106
181
|
/** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
|
|
107
182
|
async function fillPrompt(task: DoAlwaysTask, ctx: ExtensionContext): Promise<void> {
|
|
183
|
+
// Render the prompt with the current context (branch, changed files, …)
|
|
184
|
+
// so the injected text matches this directory at this moment.
|
|
185
|
+
const prompt = renderPrompt(task.prompt, buildContext(ctx.cwd));
|
|
186
|
+
if (shouldAutoRun(task)) {
|
|
187
|
+
await pi.sendUserMessage(prompt);
|
|
188
|
+
ctx.ui.notify(`do-always: auto-ran "${task.name}"`, "info");
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
108
191
|
if (ctx.mode === "tui") {
|
|
109
|
-
ctx.ui.setEditorText(
|
|
192
|
+
ctx.ui.setEditorText(prompt);
|
|
110
193
|
ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
|
|
111
194
|
} else {
|
|
112
|
-
await pi.sendUserMessage(
|
|
195
|
+
await pi.sendUserMessage(prompt);
|
|
113
196
|
}
|
|
114
197
|
}
|
|
115
198
|
|
|
@@ -118,14 +201,43 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
118
201
|
* number, type to filter, or navigate with arrows + Enter, Esc to cancel.
|
|
119
202
|
*/
|
|
120
203
|
async function showSelector(ctx: ExtensionContext): Promise<void> {
|
|
204
|
+
// Build the context once per selector session (never inside the render
|
|
205
|
+
// loop — no process spawning per frame). fillPrompt re-renders at
|
|
206
|
+
// selection time, so a few seconds of drift is acceptable.
|
|
207
|
+
const context = buildContext(ctx.cwd);
|
|
121
208
|
const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
|
|
122
209
|
let settled = false;
|
|
210
|
+
let previewVisible = false;
|
|
211
|
+
let previewTimer: ReturnType<typeof setTimeout> | null = null;
|
|
212
|
+
|
|
213
|
+
function clearPreviewTimer() {
|
|
214
|
+
if (previewTimer) {
|
|
215
|
+
clearTimeout(previewTimer);
|
|
216
|
+
previewTimer = null;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
123
220
|
const finish = (value: number | null) => {
|
|
124
221
|
if (settled) return;
|
|
125
222
|
settled = true;
|
|
223
|
+
clearPreviewTimer();
|
|
126
224
|
done(value);
|
|
127
225
|
};
|
|
128
226
|
|
|
227
|
+
// The prompt preview appears only after the selection has been stable
|
|
228
|
+
// for PREVIEW_DELAY_MS; any change hides it and restarts the delay.
|
|
229
|
+
function resetPreview() {
|
|
230
|
+
previewVisible = false;
|
|
231
|
+
clearPreviewTimer();
|
|
232
|
+
previewTimer = setTimeout(() => {
|
|
233
|
+
previewTimer = null;
|
|
234
|
+
if (!settled) {
|
|
235
|
+
previewVisible = true;
|
|
236
|
+
tui.requestRender();
|
|
237
|
+
}
|
|
238
|
+
}, PREVIEW_DELAY_MS);
|
|
239
|
+
}
|
|
240
|
+
|
|
129
241
|
// Group tasks under category headers, in a stable order.
|
|
130
242
|
const groups = groupTasksByCategory(tasks);
|
|
131
243
|
|
|
@@ -135,6 +247,9 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
135
247
|
let selectedIndex = 0;
|
|
136
248
|
let mousePressedIndex: number | null = null;
|
|
137
249
|
|
|
250
|
+
// Arm the preview timer for the initial selection.
|
|
251
|
+
resetPreview();
|
|
252
|
+
|
|
138
253
|
const matchesFilter = (t: DoAlwaysTask) => {
|
|
139
254
|
if (!filter) return true;
|
|
140
255
|
const f = filter.toLowerCase();
|
|
@@ -173,7 +288,8 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
173
288
|
|
|
174
289
|
function renderLabel(task: DoAlwaysTask, globalIndex: number, isSelected: boolean, width: number): string {
|
|
175
290
|
const prefix = isSelected ? "▸ " : " ";
|
|
176
|
-
const
|
|
291
|
+
const marker = shouldAutoRun(task) ? "⚡ " : "";
|
|
292
|
+
const label = `${prefix}${globalIndex + 1}. ${marker}${task.name}`;
|
|
177
293
|
if (!task.description) {
|
|
178
294
|
const line = truncateToWidth(label, Math.max(1, width - 2), "");
|
|
179
295
|
return isSelected ? theme.fg("accent", theme.bold(line)) : line;
|
|
@@ -223,13 +339,31 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
223
339
|
lines.push(theme.fg("dim", truncateToWidth(hint, width - 2, "")));
|
|
224
340
|
}
|
|
225
341
|
}
|
|
342
|
+
// Prompt preview: revealed after the selection has been stable for
|
|
343
|
+
// PREVIEW_DELAY_MS, showing exactly what will be injected.
|
|
344
|
+
if (previewVisible) {
|
|
345
|
+
const sel = itemRows[selectedIndex];
|
|
346
|
+
if (sel) {
|
|
347
|
+
const wrapWidth = Math.max(10, width - 4);
|
|
348
|
+
// Show the rendered prompt — exactly what will be injected.
|
|
349
|
+
const wrapped = wrapTextWithAnsi(renderPrompt(sel.task.prompt, context), wrapWidth);
|
|
350
|
+
const shown = wrapped.slice(0, PREVIEW_MAX_LINES);
|
|
351
|
+
const truncated = wrapped.length > PREVIEW_MAX_LINES;
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(theme.fg("dim", theme.bold(` ${sel.task.name} — prompt:`)));
|
|
354
|
+
shown.forEach((ln, i) => {
|
|
355
|
+
const isLast = i === shown.length - 1;
|
|
356
|
+
const text = isLast && truncated ? truncateToWidth(`${ln} …`, wrapWidth, "") : ln;
|
|
357
|
+
lines.push(theme.fg("muted", ` ${text}`));
|
|
358
|
+
});
|
|
359
|
+
}
|
|
360
|
+
}
|
|
226
361
|
lines.push("");
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
);
|
|
362
|
+
const anyAutoRun = itemRows.some((r) => shouldAutoRun(r.task));
|
|
363
|
+
const footer = anyAutoRun
|
|
364
|
+
? " 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel • ⚡ auto-runs"
|
|
365
|
+
: " 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel";
|
|
366
|
+
lines.push(theme.fg("dim", truncateToWidth(footer, width - 2, "")));
|
|
233
367
|
return { lines, itemLine, itemRows };
|
|
234
368
|
}
|
|
235
369
|
|
|
@@ -249,12 +383,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
249
383
|
if (kb.matches(data, "tui.editor.deleteCharBackward")) {
|
|
250
384
|
filter = filter.slice(0, -1);
|
|
251
385
|
selectedIndex = 0;
|
|
386
|
+
resetPreview();
|
|
252
387
|
tui.requestRender();
|
|
253
388
|
return;
|
|
254
389
|
}
|
|
255
390
|
if (isPrintable(data)) {
|
|
256
391
|
filter += data;
|
|
257
392
|
selectedIndex = 0;
|
|
393
|
+
resetPreview();
|
|
258
394
|
tui.requestRender();
|
|
259
395
|
return;
|
|
260
396
|
}
|
|
@@ -262,10 +398,12 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
262
398
|
const { itemRows } = getVisible();
|
|
263
399
|
if (kb.matches(data, "tui.select.up")) {
|
|
264
400
|
selectedIndex = selectedIndex === 0 ? itemRows.length - 1 : selectedIndex - 1;
|
|
401
|
+
resetPreview();
|
|
265
402
|
tui.requestRender();
|
|
266
403
|
}
|
|
267
404
|
else if (kb.matches(data, "tui.select.down")) {
|
|
268
405
|
selectedIndex = selectedIndex === itemRows.length - 1 ? 0 : selectedIndex + 1;
|
|
406
|
+
resetPreview();
|
|
269
407
|
tui.requestRender();
|
|
270
408
|
}
|
|
271
409
|
else if (kb.matches(data, "tui.select.confirm")) {
|
|
@@ -283,6 +421,7 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
283
421
|
const delta = event.wheelDelta < 0 ? -1 : 1;
|
|
284
422
|
const prev = selectedIndex;
|
|
285
423
|
selectedIndex = Math.max(0, Math.min(itemRows.length - 1, selectedIndex + delta));
|
|
424
|
+
if (selectedIndex !== prev) resetPreview();
|
|
286
425
|
return { handled: true, render: selectedIndex !== prev };
|
|
287
426
|
}
|
|
288
427
|
if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
|
|
@@ -293,7 +432,10 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
293
432
|
if (idx < 0) return undefined;
|
|
294
433
|
if (event.type === "press") {
|
|
295
434
|
mousePressedIndex = idx;
|
|
296
|
-
selectedIndex
|
|
435
|
+
if (selectedIndex !== idx) {
|
|
436
|
+
selectedIndex = idx;
|
|
437
|
+
resetPreview();
|
|
438
|
+
}
|
|
297
439
|
return { handled: true, focus: true, render: true };
|
|
298
440
|
}
|
|
299
441
|
const clicked = mousePressedIndex ?? idx;
|
|
@@ -369,12 +511,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
|
|
|
369
511
|
|
|
370
512
|
if (arg.toLowerCase() === "list-details") {
|
|
371
513
|
// Display only — the description is metadata; selecting a task injects just its prompt.
|
|
514
|
+
// Render with the current context so what is shown is what gets injected.
|
|
515
|
+
const context = buildContext(ctx.cwd);
|
|
372
516
|
const details = tasks
|
|
373
517
|
.map((t, i) => {
|
|
374
518
|
const lines = [`${i + 1}. ${t.name}`];
|
|
375
519
|
if (t.description) lines.push(` description: ${t.description}`);
|
|
376
520
|
lines.push(" prompt (this is what gets injected on select):");
|
|
377
|
-
for (const line of t.prompt.split("\n")) lines.push(` ${line}`);
|
|
521
|
+
for (const line of renderPrompt(t.prompt, context).split("\n")) lines.push(` ${line}`);
|
|
378
522
|
return lines.join("\n");
|
|
379
523
|
})
|
|
380
524
|
.join("\n\n");
|
package/package.json
CHANGED
package/pi.image.png
CHANGED
|
Binary file
|
package/tasks.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface DoAlwaysTask {
|
|
|
13
13
|
description?: string;
|
|
14
14
|
/** Prompt filled into the editor when the task is selected */
|
|
15
15
|
prompt: string;
|
|
16
|
+
/**
|
|
17
|
+
* Whether selecting the task sends its prompt immediately (auto-run) instead
|
|
18
|
+
* of filling the editor. When omitted, the default is derived from the
|
|
19
|
+
* category: "Plan" tasks auto-run, everything else fills the editor.
|
|
20
|
+
*/
|
|
21
|
+
autoRun?: boolean;
|
|
16
22
|
}
|
|
17
23
|
|
|
18
24
|
/**
|
|
@@ -35,6 +41,25 @@ export interface ParsedDoAlwaysConfig {
|
|
|
35
41
|
shortcut: string | null | undefined;
|
|
36
42
|
}
|
|
37
43
|
|
|
44
|
+
/**
|
|
45
|
+
* The set of context keys the extension can inject into prompts (see
|
|
46
|
+
* `renderPrompt`). `index.ts` is responsible for supplying all of them (with
|
|
47
|
+
* neutral fallbacks when a fact is unavailable); tests use this to check that
|
|
48
|
+
* default prompts only reference known keys.
|
|
49
|
+
*/
|
|
50
|
+
export const PROMPT_CONTEXT_KEYS = [
|
|
51
|
+
"cwd",
|
|
52
|
+
"date",
|
|
53
|
+
"branch",
|
|
54
|
+
"last_commit",
|
|
55
|
+
"files_changed",
|
|
56
|
+
"files_changed_count",
|
|
57
|
+
"user",
|
|
58
|
+
] as const;
|
|
59
|
+
|
|
60
|
+
/** A fully populated prompt context: one entry per PROMPT_CONTEXT_KEYS. */
|
|
61
|
+
export type PromptContext = Record<(typeof PROMPT_CONTEXT_KEYS)[number], string>;
|
|
62
|
+
|
|
38
63
|
/** Used when neither config file defines any task. */
|
|
39
64
|
export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
40
65
|
{
|
|
@@ -42,7 +67,7 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
42
67
|
category: "Plan",
|
|
43
68
|
description: "Review code and double-check changes (Plan)",
|
|
44
69
|
prompt:
|
|
45
|
-
"Review the
|
|
70
|
+
"Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings",
|
|
46
71
|
},
|
|
47
72
|
{
|
|
48
73
|
name: "Cleanup",
|
|
@@ -91,14 +116,14 @@ export const DEFAULT_TASKS: DoAlwaysTask[] = [
|
|
|
91
116
|
category: "Ops",
|
|
92
117
|
description: "Prepare a release (version, changelog, tag)",
|
|
93
118
|
prompt:
|
|
94
|
-
"Prepare a release for this project: check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag if git present. Do not push.",
|
|
119
|
+
"Prepare a release for this project (branch {{branch}}): check `git log` since the last tag, update the version in package.json (or the equivalent location), add a changelog entry summarizing the changes, and create a git tag if git present. Do not push.",
|
|
95
120
|
},
|
|
96
121
|
{
|
|
97
122
|
name: "Commit",
|
|
98
123
|
category: "Ops",
|
|
99
124
|
description: "Prepare a clean commit",
|
|
100
125
|
prompt:
|
|
101
|
-
"Prepare the working tree
|
|
126
|
+
"Prepare the working tree on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}) for a clean commit: stage the relevant changes, and write a clear commit message describing what changed and why. Do not push.",
|
|
102
127
|
},
|
|
103
128
|
{
|
|
104
129
|
name: "Propose features",
|
|
@@ -145,6 +170,7 @@ export function parseConfig(
|
|
|
145
170
|
};
|
|
146
171
|
if (typeof t.description === "string") task.description = t.description;
|
|
147
172
|
if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
|
|
173
|
+
if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
|
|
148
174
|
tasks.push(task);
|
|
149
175
|
} else {
|
|
150
176
|
onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
|
|
@@ -264,6 +290,17 @@ export function orderTasksByCategory(
|
|
|
264
290
|
return groupTasksByCategory(tasks, order).flatMap((g) => g.items);
|
|
265
291
|
}
|
|
266
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Whether selecting a task should auto-run it (send its prompt immediately)
|
|
295
|
+
* instead of filling the editor. An explicit `autoRun` flag wins; otherwise the
|
|
296
|
+
* default is derived from the category — "Plan" tasks auto-run, everything else
|
|
297
|
+
* fills the editor.
|
|
298
|
+
*/
|
|
299
|
+
export function shouldAutoRun(task: DoAlwaysTask): boolean {
|
|
300
|
+
if (typeof task.autoRun === "boolean") return task.autoRun;
|
|
301
|
+
return (task.category ?? "").trim().toLowerCase() === "plan";
|
|
302
|
+
}
|
|
303
|
+
|
|
267
304
|
/**
|
|
268
305
|
* Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
|
|
269
306
|
*/
|
|
@@ -277,6 +314,22 @@ export function resolveTask(tasks: DoAlwaysTask[], arg: string): DoAlwaysTask |
|
|
|
277
314
|
return tasks.find((t) => t.name.toLowerCase() === a.toLowerCase());
|
|
278
315
|
}
|
|
279
316
|
|
|
317
|
+
/** Matches a `{{key}}` placeholder: key is [A-Za-z0-9_]+, optional inner whitespace. */
|
|
318
|
+
const PLACEHOLDER_RE = /\{\{\s*([A-Za-z0-9_]+)\s*\}\}/g;
|
|
319
|
+
|
|
320
|
+
/**
|
|
321
|
+
* Substitute `{{key}}` placeholders in `template` with values from `ctx`.
|
|
322
|
+
* A key that is not present in `ctx` is left as-is, so missing context stays
|
|
323
|
+
* visible instead of silently blanking the sentence; a key present with an
|
|
324
|
+
* empty-string value renders as empty. Templates without placeholders are
|
|
325
|
+
* returned unchanged, so existing plain prompts keep working.
|
|
326
|
+
*/
|
|
327
|
+
export function renderPrompt(template: string, ctx: Record<string, string>): string {
|
|
328
|
+
return template.replace(PLACEHOLDER_RE, (match, key: string) =>
|
|
329
|
+
Object.prototype.hasOwnProperty.call(ctx, key) ? ctx[key] : match,
|
|
330
|
+
);
|
|
331
|
+
}
|
|
332
|
+
|
|
280
333
|
/**
|
|
281
334
|
* Render a numbered, one-line-per-task list. When tasks span more than one
|
|
282
335
|
* category, a header line is emitted before each group (matching the selector).
|