pi-do-always 0.2.2 → 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 CHANGED
@@ -3,20 +3,35 @@
3
3
  A [Pi](https://github.com/earendil-works/pi) extension that gives you a `/do-always` command for your
4
4
  repeated "do the usual" prompts.
5
5
 
6
- Type `/do-always` → a numbered list of tasks appears → press a number (or arrows + Enter) →
7
- the task's prompt is **filled into the input editor**. Review it, tweak it, press Enter to run.
6
+ Type `/do-always` → a numbered list of tasks appears, grouped by category → press a number,
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. Tasks marked `⚡`
9
+ (the `Plan` category by default) auto-run on selection instead — see `autoRun` below.
8
10
 
9
11
  ```text
10
- ┌ do-always — pick a task ┐
11
- │ 1. review — Review code and double-check changes │
12
- │ 2. readme — Update the README │
13
- │ 3. tests — Run tests and fix failures │
14
- │ 4. commit — Prepare a clean commit │
15
- │ 5. cleanup — Propose a plan to clean up dead code and duplicates │
16
- │ 6. release — Prepare a release (version, changelog, tag) │
17
- │ 7. security — Security audit — plan proposal │
18
- │ 8. perf — Performance review — plan proposal │
19
- └───────────────────────────────────────────────────────────────────┘
12
+ do-always — pick a task
13
+
14
+ PLAN
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)
20
+ DO
21
+ 6. Build Test build is ok and fix issues
22
+ 7. Tests Run tests and fix failures
23
+ DOCS
24
+ 8. Readme Update the README.md
25
+ OPS
26
+ 9. Release Prepare a release (version, changelog, tag)
27
+ 10. Commit Prepare a clean commit
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
20
35
  ```
21
36
 
22
37
  ![do-always — the numbered task selector](pi.image.png)
@@ -29,7 +44,16 @@ the task's prompt is **filled into the input editor**. Review it, tweak it, pres
29
44
  |`/do-always 2`|Fill the prompt for task #2 directly|
30
45
  |`/do-always review`|Fill the prompt for the task named `review` (task names autocomplete after `/do-always`)|
31
46
  |`/do-always list`|Print the task list|
32
- |`/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|
48
+
49
+ The selector supports direct number-pick (1-9), live type-to-filter, arrow/Enter navigation,
50
+ mouse-wheel scrolling, and click-to-select. While a filter is active, typed digits refine the
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.
33
57
 
34
58
  In non-interactive modes (no TUI) there is no editor to fill, so the selected prompt is sent
35
59
  as a user message instead.
@@ -63,7 +87,7 @@ Tasks are read from JSON files (an array of tasks, or the object form `{"tasks":
63
87
  |`~/.pi/agent/do-always.json`|Global (all projects)|
64
88
  |`<project>/.pi/do-always.json`|Project-local; overrides global tasks with the same `name`|
65
89
 
66
- If neither file exists, the built-in defaults (review, readme, tests, commit, cleanup, release, security, perf) are used.
90
+ If neither file exists, the built-in defaults (Review, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
67
91
  This repo ships a sample in [`do-always.json`](./do-always.json) — copy it to one of the
68
92
  locations above to make it your own:
69
93
 
@@ -71,8 +95,9 @@ locations above to make it your own:
71
95
  [
72
96
  {
73
97
  "name": "review",
98
+ "category": "Plan",
74
99
  "description": "Review code and double-check changes",
75
- "prompt": "Review the recent code changes in this project. Check `git status` and `git diff` ..."
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` ..."
76
101
  }
77
102
  ]
78
103
  ```
@@ -80,8 +105,10 @@ locations above to make it your own:
80
105
  Fields:
81
106
 
82
107
  - `name` (required) — short unique id, used for `/do-always <name>`
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`.
83
109
  - `description` (optional) — one-line label shown in the selector
84
- - `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.
85
112
 
86
113
  In the object form you can also configure the selector shortcut:
87
114
 
@@ -89,6 +116,28 @@ In the object form you can also configure the selector shortcut:
89
116
 
90
117
  Reload Pi (or start a new session) after editing a config file.
91
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
+
92
141
  ## Development
93
142
 
94
143
  ```bash
@@ -97,6 +146,6 @@ npm run typecheck # tsc --noEmit
97
146
  npm test # run the unit tests (node:test + tsx, in test/)
98
147
  ```
99
148
 
100
- The pure task logic (`parseConfig`, `mergeTasks`, `resolveTask`, `formatList`) lives in
149
+ The pure task logic (`parseConfig`, `mergeTasks`, `renderPrompt`, `resolveTask`, `formatList`) lives in
101
150
  [`tasks.ts`](./tasks.ts) with no Pi dependencies, so it is unit-tested independently of the
102
151
  Pi runtime. The extension (`index.ts`) imports that logic and adds only the Pi UI.
package/do-always.json CHANGED
@@ -2,44 +2,65 @@
2
2
  "shortcut": "f4",
3
3
  "tasks": [
4
4
  {
5
- "name": "review",
6
- "description": "Review code and double-check changes",
7
- "prompt": "Review the recent code changes in this project. 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."
5
+ "name": "Review",
6
+ "category": "Plan",
7
+ "description": "Review code and double-check changes (Plan)",
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"
8
9
  },
9
10
  {
10
- "name": "readme",
11
- "description": "Update the README",
12
- "prompt": "Update the README to match the current state of the project. Check the code, scripts, and configuration, then update the README sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate."
11
+ "name": "Cleanup",
12
+ "category": "Plan",
13
+ "autoRun": false,
14
+ "description": "Clean up dead code and duplicates (Plan)",
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."
13
16
  },
14
17
  {
15
- "name": "tests",
16
- "description": "Run tests and fix failures",
17
- "prompt": "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results."
18
+ "name": "Security",
19
+ "category": "Plan",
20
+ "description": "Security audit (Plan)",
21
+ "prompt": "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet."
18
22
  },
19
23
  {
20
- "name": "commit",
21
- "description": "Prepare a clean commit",
22
- "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."
24
+ "name": "Performance",
25
+ "category": "Plan",
26
+ "description": "Performance review (Plan)",
27
+ "prompt": "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet."
23
28
  },
24
29
  {
25
- "name": "cleanup",
26
- "description": "Propose a plan to clean up dead code and duplicates",
27
- "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."
30
+ "name": "Propose features",
31
+ "category": "Plan",
32
+ "description": "Propose new features (Plan)",
33
+ "prompt": "Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet."
28
34
  },
29
35
  {
30
- "name": "release",
31
- "description": "Prepare a release (version, changelog, tag)",
32
- "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. Do not push."
36
+ "name": "Build",
37
+ "category": "Do",
38
+ "description": "Test build is ok and fix issues",
39
+ "prompt": "Build the project (run the build script, e.g. `npm run build`, plus type check if available). If the build fails, diagnose the errors and fix them, then re-build until it succeeds. Summarize what was broken and what you changed."
33
40
  },
34
41
  {
35
- "name": "security",
36
- "description": "Security audit — plan proposal",
37
- "prompt": "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet."
42
+ "name": "Tests",
43
+ "category": "Do",
44
+ "description": "Run tests and fix failures",
45
+ "prompt": "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results."
38
46
  },
39
47
  {
40
- "name": "perf",
41
- "description": "Performance review — plan proposal",
42
- "prompt": "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet."
48
+ "name": "Readme",
49
+ "category": "Docs",
50
+ "description": "Update the README.md",
51
+ "prompt": "Update the README.md to match the current state of the project. Check the code, scripts, and configuration, then update the README.md sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate."
52
+ },
53
+ {
54
+ "name": "Release",
55
+ "category": "Ops",
56
+ "description": "Prepare a release (version, changelog, tag)",
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."
58
+ },
59
+ {
60
+ "name": "Commit",
61
+ "category": "Ops",
62
+ "description": "Prepare a clean commit",
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."
43
64
  }
44
65
  ]
45
66
  }
package/index.ts CHANGED
@@ -33,21 +33,36 @@
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";
39
40
  import { CONFIG_DIR_NAME, getAgentDir } from "@earendil-works/pi-coding-agent";
40
- import { Container, type KeyId, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
41
+ import {
42
+ type KeyId,
43
+ type TuiMouseEvent,
44
+ type TuiMouseEventResult,
45
+ getKeybindings,
46
+ truncateToWidth,
47
+ visibleWidth,
48
+ wrapTextWithAnsi,
49
+ } from "@earendil-works/pi-tui";
41
50
  import {
42
51
  DEFAULT_SHORTCUT,
43
52
  DEFAULT_TASKS,
44
53
  formatList,
54
+ groupTasksByCategory,
45
55
  isValidKeyId,
46
56
  mergeTasks,
47
57
  parseConfig,
58
+ orderTasksByCategory,
59
+ renderPrompt,
48
60
  resolveShortcut,
49
61
  resolveTask,
62
+ shouldAutoRun,
50
63
  type DoAlwaysTask,
64
+ type PromptContext,
65
+ type TaskGroup,
51
66
  } from "./tasks";
52
67
 
53
68
  /**
@@ -67,11 +82,93 @@ function loadConfig(cwd: string): { tasks: DoAlwaysTask[]; shortcut: string | nu
67
82
  : { tasks: [], shortcut: undefined };
68
83
 
69
84
  return {
70
- tasks: mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS),
85
+ // Order the merged list by category so the selector numbers, digit-pick,
86
+ // `/do-always <n>`, and `list` all share one consistent order.
87
+ tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS)),
71
88
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
72
89
  };
73
90
  }
74
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
+
157
+ /** Find the group (among `groups`) that contains a task. */
158
+ function findGroupOf(task: DoAlwaysTask, groups: TaskGroup[]): TaskGroup | undefined {
159
+ return groups.find((g) => g.items.includes(task));
160
+ }
161
+
162
+ /** True for a single printable ASCII character (used for filter typing). */
163
+ function isPrintable(data: string): boolean {
164
+ return data.length === 1 && data >= " " && data <= "~";
165
+ }
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
+
75
172
  export default function doAlwaysExtension(pi: ExtensionAPI) {
76
173
  let tasks: DoAlwaysTask[] = [];
77
174
  let loadedCwd = ""; // cwd the cached `tasks` were loaded for
@@ -83,60 +180,269 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
83
180
 
84
181
  /** Put the task prompt into the editor (TUI) or send it as a user message (other modes). */
85
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
+ }
86
191
  if (ctx.mode === "tui") {
87
- ctx.ui.setEditorText(task.prompt);
192
+ ctx.ui.setEditorText(prompt);
88
193
  ctx.ui.notify(`do-always: prompt for "${task.name}" filled — press Enter to run`, "info");
89
194
  } else {
90
- await pi.sendUserMessage(task.prompt);
195
+ await pi.sendUserMessage(prompt);
91
196
  }
92
197
  }
93
198
 
94
- /** Numbered selector: press 1-9 to pick, or arrows + Enter, Esc to cancel. */
199
+ /**
200
+ * Numbered selector with categorized sections. Press 1-9 to pick by global
201
+ * number, type to filter, or navigate with arrows + Enter, Esc to cancel.
202
+ */
95
203
  async function showSelector(ctx: ExtensionContext): Promise<void> {
96
- const items: SelectItem[] = tasks.map((t, i) => ({
97
- value: String(i),
98
- label: `${i + 1}. ${t.name}`,
99
- description: t.description,
100
- }));
101
-
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);
102
208
  const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
103
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
+
104
220
  const finish = (value: number | null) => {
105
221
  if (settled) return;
106
222
  settled = true;
223
+ clearPreviewTimer();
107
224
  done(value);
108
225
  };
109
226
 
110
- const container = new Container();
111
- container.addChild(new Text(theme.fg("accent", theme.bold("do-always — pick a task"))));
112
-
113
- const selectList = new SelectList(items, Math.min(items.length, 10), {
114
- selectedPrefix: (text) => theme.fg("accent", text),
115
- selectedText: (text) => theme.fg("accent", text),
116
- description: (text) => theme.fg("muted", text),
117
- scrollInfo: (text) => theme.fg("dim", text),
118
- noMatch: (text) => theme.fg("warning", text),
119
- });
120
- selectList.onSelect = (item) => finish(Number(item.value));
121
- selectList.onCancel = () => finish(null);
122
- container.addChild(selectList);
123
- container.addChild(new Text(theme.fg("dim", "1-9 pick by number • ↑↓ navigate • enter select • esc cancel")));
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
+
241
+ // Group tasks under category headers, in a stable order.
242
+ const groups = groupTasksByCategory(tasks);
243
+
244
+ const kb = getKeybindings();
245
+ const maxVisible = 12;
246
+ let filter = "";
247
+ let selectedIndex = 0;
248
+ let mousePressedIndex: number | null = null;
249
+
250
+ // Arm the preview timer for the initial selection.
251
+ resetPreview();
252
+
253
+ const matchesFilter = (t: DoAlwaysTask) => {
254
+ if (!filter) return true;
255
+ const f = filter.toLowerCase();
256
+ return (
257
+ t.name.toLowerCase().includes(f) ||
258
+ (t.description ?? "").toLowerCase().includes(f) ||
259
+ (t.category ?? "").toLowerCase().includes(f)
260
+ );
261
+ };
262
+
263
+ // Recompute the visible (filtered, grouped) rows on every render so
264
+ // filter typing updates the list live.
265
+ function getVisible() {
266
+ const visibleGroups = groups
267
+ .map((g) => ({ name: g.name, items: g.items.filter(matchesFilter) }))
268
+ .filter((g) => g.items.length > 0);
269
+ const rows: Array<
270
+ | { kind: "header"; name: string }
271
+ | { kind: "item"; task: DoAlwaysTask; group: string }
272
+ > = [];
273
+ for (const g of visibleGroups) {
274
+ rows.push({ kind: "header", name: g.name });
275
+ for (const t of g.items) rows.push({ kind: "item", task: t, group: g.name });
276
+ }
277
+ const itemRows = rows.filter((r): r is (typeof rows)[number] & { kind: "item" } => r.kind === "item");
278
+ // Clamp selection to the visible item count.
279
+ selectedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, itemRows.length - 1)));
280
+ // Visible item window with scrolling.
281
+ const winStart = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), Math.max(0, itemRows.length - maxVisible)));
282
+ const visibleItemKeys = new Set(itemRows.slice(winStart, winStart + maxVisible).map((r) => r.task));
283
+ const visibleHeaderNames = new Set([...visibleItemKeys].map((t) => findGroupOf(t, visibleGroups)?.name ?? ""));
284
+ return { rows, itemRows, visibleItemKeys, visibleHeaderNames };
285
+ }
286
+
287
+ const labelCol = 26;
288
+
289
+ function renderLabel(task: DoAlwaysTask, globalIndex: number, isSelected: boolean, width: number): string {
290
+ const prefix = isSelected ? "▸ " : " ";
291
+ const marker = shouldAutoRun(task) ? "⚡ " : "";
292
+ const label = `${prefix}${globalIndex + 1}. ${marker}${task.name}`;
293
+ if (!task.description) {
294
+ const line = truncateToWidth(label, Math.max(1, width - 2), "");
295
+ return isSelected ? theme.fg("accent", theme.bold(line)) : line;
296
+ }
297
+ // Width-aware two-column layout; fall back to label-only when the
298
+ // terminal is too narrow to fit a description column.
299
+ const effCol = Math.max(1, Math.min(labelCol, width - 8));
300
+ const nameOnly = truncateToWidth(label, effCol, "");
301
+ const pad = " ".repeat(Math.max(1, effCol - visibleWidth(nameOnly)));
302
+ const remaining = width - visibleWidth(nameOnly) - pad.length - 2;
303
+ if (remaining < 10) {
304
+ const line = truncateToWidth(label, Math.max(1, width - 2), "");
305
+ return isSelected ? theme.fg("accent", theme.bold(line)) : line;
306
+ }
307
+ const desc = truncateToWidth(task.description, remaining, "");
308
+ if (isSelected) {
309
+ return theme.fg("accent", theme.bold(`${nameOnly}${pad}${desc}`));
310
+ }
311
+ return `${nameOnly}${pad}${theme.fg("muted", desc)}`;
312
+ }
313
+
314
+ // Build the full selector output for a width, plus a map from line
315
+ // index to task for the item rows (used by mouse handling).
316
+ function buildRender(width: number) {
317
+ const { rows, itemRows, visibleItemKeys, visibleHeaderNames } = getVisible();
318
+ const lines: string[] = [];
319
+ const itemLine = new Map<number, DoAlwaysTask>();
320
+ lines.push(theme.fg("accent", theme.bold(" do-always — pick a task")));
321
+ lines.push("");
322
+ if (itemRows.length === 0) {
323
+ lines.push(theme.fg("warning", " No matching tasks"));
324
+ } else {
325
+ for (const row of rows) {
326
+ if (row.kind === "header") {
327
+ if (!visibleHeaderNames.has(row.name)) continue;
328
+ lines.push(theme.fg("accent", theme.bold(` ${row.name.toUpperCase()}`)));
329
+ continue;
330
+ }
331
+ if (!visibleItemKeys.has(row.task)) continue;
332
+ const globalIndex = tasks.indexOf(row.task);
333
+ const isSelected = row.task === itemRows[selectedIndex].task;
334
+ lines.push(renderLabel(row.task, globalIndex, isSelected, width));
335
+ itemLine.set(lines.length - 1, row.task);
336
+ }
337
+ if (itemRows.length > maxVisible) {
338
+ const hint = ` (${selectedIndex + 1}/${itemRows.length})`;
339
+ lines.push(theme.fg("dim", truncateToWidth(hint, width - 2, "")));
340
+ }
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
+ }
361
+ lines.push("");
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, "")));
367
+ return { lines, itemLine, itemRows };
368
+ }
124
369
 
125
370
  return {
126
371
  render(width: number) {
127
- return container.render(width);
128
- },
129
- invalidate() {
130
- container.invalidate();
372
+ return buildRender(width).lines;
131
373
  },
374
+ invalidate() {},
132
375
  handleInput(data: string) {
133
- // Direct pick by number (1-9)
134
- if (/^[1-9]$/.test(data) && Number(data) <= tasks.length) {
376
+ // Direct pick by number (1-9) — only when not filtering, so
377
+ // digits can be typed into the filter otherwise.
378
+ if (!filter && /^[1-9]$/.test(data) && Number(data) <= tasks.length) {
135
379
  finish(Number(data) - 1);
136
380
  return;
137
381
  }
138
- selectList.handleInput(data);
139
- tui.requestRender();
382
+ // Filter typing.
383
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
384
+ filter = filter.slice(0, -1);
385
+ selectedIndex = 0;
386
+ resetPreview();
387
+ tui.requestRender();
388
+ return;
389
+ }
390
+ if (isPrintable(data)) {
391
+ filter += data;
392
+ selectedIndex = 0;
393
+ resetPreview();
394
+ tui.requestRender();
395
+ return;
396
+ }
397
+ // Navigation / confirmation.
398
+ const { itemRows } = getVisible();
399
+ if (kb.matches(data, "tui.select.up")) {
400
+ selectedIndex = selectedIndex === 0 ? itemRows.length - 1 : selectedIndex - 1;
401
+ resetPreview();
402
+ tui.requestRender();
403
+ }
404
+ else if (kb.matches(data, "tui.select.down")) {
405
+ selectedIndex = selectedIndex === itemRows.length - 1 ? 0 : selectedIndex + 1;
406
+ resetPreview();
407
+ tui.requestRender();
408
+ }
409
+ else if (kb.matches(data, "tui.select.confirm")) {
410
+ const chosen = itemRows[selectedIndex];
411
+ if (chosen) finish(tasks.indexOf(chosen.task));
412
+ }
413
+ else if (kb.matches(data, "tui.select.cancel")) {
414
+ finish(null);
415
+ }
416
+ },
417
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
418
+ if (event.type === "wheel" && event.wheelDelta) {
419
+ const { itemRows } = getVisible();
420
+ if (itemRows.length === 0) return undefined;
421
+ const delta = event.wheelDelta < 0 ? -1 : 1;
422
+ const prev = selectedIndex;
423
+ selectedIndex = Math.max(0, Math.min(itemRows.length - 1, selectedIndex + delta));
424
+ if (selectedIndex !== prev) resetPreview();
425
+ return { handled: true, render: selectedIndex !== prev };
426
+ }
427
+ if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
428
+ const { itemLine, itemRows } = buildRender(event.width);
429
+ const task = itemLine.get(event.y);
430
+ if (!task) return undefined;
431
+ const idx = itemRows.findIndex((r) => r.task === task);
432
+ if (idx < 0) return undefined;
433
+ if (event.type === "press") {
434
+ mousePressedIndex = idx;
435
+ if (selectedIndex !== idx) {
436
+ selectedIndex = idx;
437
+ resetPreview();
438
+ }
439
+ return { handled: true, focus: true, render: true };
440
+ }
441
+ const clicked = mousePressedIndex ?? idx;
442
+ mousePressedIndex = null;
443
+ const chosen = itemRows[clicked];
444
+ if (chosen) finish(tasks.indexOf(chosen.task));
445
+ return { handled: true };
140
446
  },
141
447
  };
142
448
  });
@@ -205,12 +511,14 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
205
511
 
206
512
  if (arg.toLowerCase() === "list-details") {
207
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);
208
516
  const details = tasks
209
517
  .map((t, i) => {
210
518
  const lines = [`${i + 1}. ${t.name}`];
211
519
  if (t.description) lines.push(` description: ${t.description}`);
212
520
  lines.push(" prompt (this is what gets injected on select):");
213
- 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}`);
214
522
  return lines.join("\n");
215
523
  })
216
524
  .join("\n\n");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.2.2",
3
+ "version": "0.4.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: /do-always — pick a common task by number, it fills your prompt",
6
6
  "author": {
@@ -17,11 +17,11 @@
17
17
  "license": "MIT",
18
18
  "repository": {
19
19
  "type": "git",
20
- "url": "https://github.com/aginies/pi-always"
20
+ "url": "https://github.com/aginies/pi-do-always"
21
21
  },
22
- "homepage": "https://github.com/aginies/pi-always",
22
+ "homepage": "https://github.com/aginies/pi-do-always",
23
23
  "bugs": {
24
- "url": "https://github.com/aginies/pi-always/issues"
24
+ "url": "https://github.com/aginies/pi-do-always/issues"
25
25
  },
26
26
  "files": [
27
27
  "index.ts",
package/pi.image.png CHANGED
Binary file
package/tasks.ts CHANGED
@@ -7,10 +7,18 @@
7
7
  export interface DoAlwaysTask {
8
8
  /** Short unique name, usable as /do-always <name> */
9
9
  name: string;
10
+ /** Group header the task is shown under in the selector (e.g. "Plan", "Do"). */
11
+ category?: string;
10
12
  /** Short one-line description shown next to the task in the selector */
11
13
  description?: string;
12
14
  /** Prompt filled into the editor when the task is selected */
13
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;
14
22
  }
15
23
 
16
24
  /**
@@ -33,55 +41,96 @@ export interface ParsedDoAlwaysConfig {
33
41
  shortcut: string | null | undefined;
34
42
  }
35
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
+
36
63
  /** Used when neither config file defines any task. */
37
64
  export const DEFAULT_TASKS: DoAlwaysTask[] = [
38
65
  {
39
- name: "review",
40
- description: "Review code and double-check changes",
66
+ name: "Review",
67
+ category: "Plan",
68
+ description: "Review code and double-check changes (Plan)",
41
69
  prompt:
42
- "Review the recent code changes in this project. 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.",
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",
43
71
  },
44
72
  {
45
- name: "readme",
46
- description: "Update the README",
73
+ name: "Cleanup",
74
+ category: "Plan",
75
+ description: "Clean up dead code and duplicates (Plan)",
47
76
  prompt:
48
- "Update the README to match the current state of the project. Check the code, scripts, and configuration, then update the README sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate.",
77
+ "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.",
49
78
  },
50
79
  {
51
- name: "tests",
52
- description: "Run tests and fix failures",
80
+ name: "Readme",
81
+ category: "Docs",
82
+ description: "Update the README.md",
53
83
  prompt:
54
- "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results.",
84
+ "Update the README.md to match the current state of the project. Check the code, scripts, and configuration, then update the README.md sections that are now out of date (description, installation, usage, configuration). Keep it concise and accurate.",
55
85
  },
56
86
  {
57
- name: "commit",
58
- description: "Prepare a clean commit",
87
+ name: "Build",
88
+ category: "Do",
89
+ description: "Test build is ok and fix issues",
59
90
  prompt:
60
- "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.",
91
+ "Build the project (run the build script, e.g. `npm run build`, plus type check if available). If the build fails, diagnose the errors and fix them, then re-build until it succeeds. Summarize what was broken and what you changed.",
61
92
  },
62
93
  {
63
- name: "cleanup",
64
- description: "Propose a plan to clean up dead code and duplicates",
94
+ name: "Security",
95
+ category: "Plan",
96
+ description: "Security audit (Plan)",
65
97
  prompt:
66
- "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.",
98
+ "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet.",
99
+ },
100
+ {
101
+ name: "Performance",
102
+ category: "Plan",
103
+ description: "Performance review (Plan)",
104
+ prompt:
105
+ "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet.",
67
106
  },
68
107
  {
69
- name: "release",
108
+ name: "Tests",
109
+ category: "Do",
110
+ description: "Run tests and fix failures",
111
+ prompt:
112
+ "Run the project's test suite (and type check / lint if available). If anything fails, diagnose and fix the failures, then re-run until green. Summarize the results.",
113
+ },
114
+ {
115
+ name: "Release",
116
+ category: "Ops",
70
117
  description: "Prepare a release (version, changelog, tag)",
71
118
  prompt:
72
- "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. 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.",
73
120
  },
74
121
  {
75
- name: "security",
76
- description: "Security audit — plan proposal",
122
+ name: "Commit",
123
+ category: "Ops",
124
+ description: "Prepare a clean commit",
77
125
  prompt:
78
- "Audit this project for security issues: hardcoded secrets or credentials, unsafe patterns (injection, path traversal, unsafe deserialization), and vulnerable or outdated dependencies. Do a plan proposal for the fixes. Do not make any changes yet.",
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.",
79
127
  },
80
128
  {
81
- name: "perf",
82
- description: "Performance review — plan proposal",
129
+ name: "Propose features",
130
+ category: "Plan",
131
+ description: "Propose new features (Plan)",
83
132
  prompt:
84
- "Review this project for likely performance bottlenecks: inefficient algorithms, redundant I/O or computation, missing caching, and memory leaks. Do a plan proposal for the optimizations, prioritized by impact. Do not make any changes yet.",
133
+ "Review this project and propose new features that would add value. For each idea, describe the problem it solves, the user benefit, and a rough implementation approach. Prioritize by impact and effort. Do not make any changes yet.",
85
134
  },
86
135
  ];
87
136
 
@@ -115,11 +164,14 @@ export function parseConfig(
115
164
  for (const entry of list) {
116
165
  const t = entry as Partial<DoAlwaysTask> | null;
117
166
  if (t && typeof t.name === "string" && t.name.length > 0 && typeof t.prompt === "string" && t.prompt.length > 0) {
118
- tasks.push({
167
+ const task: DoAlwaysTask = {
119
168
  name: t.name,
120
- description: typeof t.description === "string" ? t.description : undefined,
121
169
  prompt: t.prompt,
122
- });
170
+ };
171
+ if (typeof t.description === "string") task.description = t.description;
172
+ if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
173
+ if (typeof t.autoRun === "boolean") task.autoRun = t.autoRun;
174
+ tasks.push(task);
123
175
  } else {
124
176
  onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
125
177
  }
@@ -187,6 +239,68 @@ export function mergeTasks(globalTasks: DoAlwaysTask[], projectTasks: DoAlwaysTa
187
239
  return merged.length > 0 ? merged : fallback;
188
240
  }
189
241
 
242
+ /** Default order for category headers in the selector. */
243
+ export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
244
+
245
+ /** A category group: a display name and the tasks that belong to it. */
246
+ export interface TaskGroup {
247
+ name: string;
248
+ items: DoAlwaysTask[];
249
+ }
250
+
251
+ /** Capitalize the first letter of each word ("my-category" -> "My-Category"). */
252
+ function titleCase(s: string): string {
253
+ return s.replace(/\b[a-z]/g, (c) => c.toUpperCase());
254
+ }
255
+
256
+ /**
257
+ * Group tasks by category, case-insensitively. Categories are ordered by
258
+ * `order` (case-insensitive), then alphabetically; the original order within a
259
+ * group is preserved. The group `name` is the title-cased category. Tasks
260
+ * without a (non-empty) category fall under "Other".
261
+ */
262
+ export function groupTasksByCategory(
263
+ tasks: DoAlwaysTask[],
264
+ order: string[] = DEFAULT_CATEGORY_ORDER,
265
+ ): TaskGroup[] {
266
+ const orderLower = order.map((o) => o.toLowerCase());
267
+ const groups = new Map<string, DoAlwaysTask[]>();
268
+ for (const t of tasks) {
269
+ const key = (t.category ?? "").trim().toLowerCase() || "other";
270
+ if (!groups.has(key)) groups.set(key, []);
271
+ groups.get(key)!.push(t);
272
+ }
273
+ const keys = [...groups.keys()].sort((a, b) => {
274
+ const na = orderLower.indexOf(a) === -1 ? order.length : orderLower.indexOf(a);
275
+ const nb = orderLower.indexOf(b) === -1 ? order.length : orderLower.indexOf(b);
276
+ return na - nb || a.localeCompare(b);
277
+ });
278
+ return keys.map((key) => ({ name: titleCase(key), items: groups.get(key)! }));
279
+ }
280
+
281
+ /**
282
+ * Return tasks in display order: grouped by category (see
283
+ * `groupTasksByCategory`), flattened. This is the canonical order the selector
284
+ * numbers, digit-pick, `/do-always <n>`, and `list` all share.
285
+ */
286
+ export function orderTasksByCategory(
287
+ tasks: DoAlwaysTask[],
288
+ order: string[] = DEFAULT_CATEGORY_ORDER,
289
+ ): DoAlwaysTask[] {
290
+ return groupTasksByCategory(tasks, order).flatMap((g) => g.items);
291
+ }
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
+
190
304
  /**
191
305
  * Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
192
306
  */
@@ -200,7 +314,37 @@ export function resolveTask(tasks: DoAlwaysTask[], arg: string): DoAlwaysTask |
200
314
  return tasks.find((t) => t.name.toLowerCase() === a.toLowerCase());
201
315
  }
202
316
 
203
- /** Render a numbered, one-line-per-task list. */
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
+
333
+ /**
334
+ * Render a numbered, one-line-per-task list. When tasks span more than one
335
+ * category, a header line is emitted before each group (matching the selector).
336
+ */
204
337
  export function formatList(tasks: DoAlwaysTask[]): string {
205
- return tasks.map((t, i) => `${i + 1}. ${t.name} — ${t.description ?? ""}`).join("\n");
338
+ const groups = groupTasksByCategory(tasks);
339
+ const lines: string[] = [];
340
+ const showHeaders = groups.length > 1;
341
+ let n = 0;
342
+ for (const g of groups) {
343
+ if (showHeaders) lines.push(g.name.toUpperCase());
344
+ for (const t of g.items) {
345
+ n++;
346
+ lines.push(`${n}. ${t.name} — ${t.description ?? ""}`);
347
+ }
348
+ }
349
+ return lines.join("\n");
206
350
  }