pi-do-always 0.2.1 → 0.3.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,29 @@
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.
8
9
 
9
10
  ```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
- └───────────────────────────────────────────────────────────────────┘
11
+ do-always — pick a task
12
+
13
+ PLAN
14
+ ▸ 1. Review Review code and double-check changes (Plan)
15
+ 2. Cleanup Clean up dead code and duplicates (Plan)
16
+ 3. Security Security audit (Plan)
17
+ 4. Performance Performance review (Plan)
18
+ 5. Propose features Propose new features (Plan)
19
+ DO
20
+ 6. Build Test build is ok and fix issues
21
+ 7. Tests Run tests and fix failures
22
+ DOCS
23
+ 8. Readme Update the README.md
24
+ OPS
25
+ 9. Release Prepare a release (version, changelog, tag)
26
+ 10. Commit Prepare a clean commit
27
+
28
+ 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel
20
29
  ```
21
30
 
22
31
  ![do-always — the numbered task selector](pi.image.png)
@@ -31,24 +40,26 @@ the task's prompt is **filled into the input editor**. Review it, tweak it, pres
31
40
  |`/do-always list`|Print the task list|
32
41
  |`/do-always list-details`|Show the full prompt text each task will inject|
33
42
 
43
+ The selector supports direct number-pick (1-9), live type-to-filter, arrow/Enter navigation,
44
+ mouse-wheel scrolling, and click-to-select. While a filter is active, typed digits refine the
45
+ filter instead of picking by number; clear the filter (backspace) to use number-pick again.
46
+
34
47
  In non-interactive modes (no TUI) there is no editor to fill, so the selected prompt is sent
35
48
  as a user message instead.
36
49
 
37
50
  ## Install
38
51
 
39
- Install it as a Pi package, which loads the bundled `index.ts` (and its `tasks.ts`) without
52
+ Install it from npm as a Pi package, which loads the bundled `index.ts` (and its `tasks.ts`) without
40
53
  managing symlinks:
41
54
 
42
55
  ```bash
43
- pi install ./pi-always # from a local copy of this repo
44
- pi install git:github.com/aginies/pi-always # from the git repo
56
+ pi install npm:pi-do-always
45
57
  ```
46
58
 
47
- (It is not on npm yet; once published it will be `pi install npm:pi-do-always`.)
48
-
49
59
  Manage it with `pi list` (to see installed sources) and `pi remove <source>` using the same
50
- source you installed with (e.g. `pi remove git:github.com/aginies/pi-always`). Alternatively, it is auto-discovered from
51
- the extensions directory, so you can symlink it instead:
60
+ source you installed with (e.g. `pi remove npm:pi-do-always`).
61
+
62
+ Alternatively, you can install from the git repo or symlink a local checkout for development:
52
63
 
53
64
  ```bash
54
65
  ln -s "$PWD" ~/.pi/agent/extensions/do-always # uninstall with: rm that symlink
@@ -65,7 +76,7 @@ Tasks are read from JSON files (an array of tasks, or the object form `{"tasks":
65
76
  |`~/.pi/agent/do-always.json`|Global (all projects)|
66
77
  |`<project>/.pi/do-always.json`|Project-local; overrides global tasks with the same `name`|
67
78
 
68
- If neither file exists, the built-in defaults (review, readme, tests, commit, cleanup, release, security, perf) are used.
79
+ If neither file exists, the built-in defaults (Review, Cleanup, Security, Performance, Propose features, Build, Tests, Readme, Release, Commit) are used.
69
80
  This repo ships a sample in [`do-always.json`](./do-always.json) — copy it to one of the
70
81
  locations above to make it your own:
71
82
 
@@ -73,6 +84,7 @@ locations above to make it your own:
73
84
  [
74
85
  {
75
86
  "name": "review",
87
+ "category": "Plan",
76
88
  "description": "Review code and double-check changes",
77
89
  "prompt": "Review the recent code changes in this project. Check `git status` and `git diff` ..."
78
90
  }
@@ -82,6 +94,7 @@ locations above to make it your own:
82
94
  Fields:
83
95
 
84
96
  - `name` (required) — short unique id, used for `/do-always <name>`
97
+ - `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`.
85
98
  - `description` (optional) — one-line label shown in the selector
86
99
  - `prompt` (required) — the text filled into the editor
87
100
 
@@ -102,22 +115,3 @@ npm test # run the unit tests (node:test + tsx, in test/)
102
115
  The pure task logic (`parseConfig`, `mergeTasks`, `resolveTask`, `formatList`) lives in
103
116
  [`tasks.ts`](./tasks.ts) with no Pi dependencies, so it is unit-tested independently of the
104
117
  Pi runtime. The extension (`index.ts`) imports that logic and adds only the Pi UI.
105
-
106
- No build step is needed — Pi loads the TypeScript directly via jiti.
107
-
108
- ## Publishing
109
-
110
- To publish on npm:
111
-
112
- ```bash
113
- npm login
114
- npm publish
115
- ```
116
-
117
- `@earendil-works/pi-coding-agent` and `@earendil-works/pi-tui` are declared as `peerDependencies`
118
- with a `"*"` range and are **not** bundled — Pi supplies them to extensions. The `pi-package`
119
- keyword makes this package eligible for the [Pi package gallery](https://pi.dev/packages).
120
-
121
- The gallery page is built from `package.json` (name, description, author, version, license,
122
- repository, `pi` manifest) plus the README. Optional `pi.image` / `pi.video` fields in
123
- `package.json` add a gallery preview if you want one.
package/do-always.json CHANGED
@@ -2,44 +2,64 @@
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 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 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
+ "description": "Clean up dead code and duplicates (Plan)",
14
+ "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
15
  },
14
16
  {
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."
17
+ "name": "Security",
18
+ "category": "Plan",
19
+ "description": "Security audit (Plan)",
20
+ "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
21
  },
19
22
  {
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."
23
+ "name": "Performance",
24
+ "category": "Plan",
25
+ "description": "Performance review (Plan)",
26
+ "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
27
  },
24
28
  {
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."
29
+ "name": "Propose features",
30
+ "category": "Plan",
31
+ "description": "Propose new features (Plan)",
32
+ "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
33
  },
29
34
  {
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."
35
+ "name": "Build",
36
+ "category": "Do",
37
+ "description": "Test build is ok and fix issues",
38
+ "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
39
  },
34
40
  {
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."
41
+ "name": "Tests",
42
+ "category": "Do",
43
+ "description": "Run tests and fix failures",
44
+ "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
45
  },
39
46
  {
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."
47
+ "name": "Readme",
48
+ "category": "Docs",
49
+ "description": "Update the README.md",
50
+ "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."
51
+ },
52
+ {
53
+ "name": "Release",
54
+ "category": "Ops",
55
+ "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
+ },
58
+ {
59
+ "name": "Commit",
60
+ "category": "Ops",
61
+ "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."
43
63
  }
44
64
  ]
45
65
  }
package/index.ts CHANGED
@@ -37,17 +37,27 @@ import { existsSync, readFileSync } from "node:fs";
37
37
  import { join } from "node:path";
38
38
  import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
39
39
  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";
40
+ import {
41
+ type KeyId,
42
+ type TuiMouseEvent,
43
+ type TuiMouseEventResult,
44
+ getKeybindings,
45
+ truncateToWidth,
46
+ visibleWidth,
47
+ } from "@earendil-works/pi-tui";
41
48
  import {
42
49
  DEFAULT_SHORTCUT,
43
50
  DEFAULT_TASKS,
44
51
  formatList,
52
+ groupTasksByCategory,
45
53
  isValidKeyId,
46
54
  mergeTasks,
47
55
  parseConfig,
56
+ orderTasksByCategory,
48
57
  resolveShortcut,
49
58
  resolveTask,
50
59
  type DoAlwaysTask,
60
+ type TaskGroup,
51
61
  } from "./tasks";
52
62
 
53
63
  /**
@@ -67,11 +77,23 @@ function loadConfig(cwd: string): { tasks: DoAlwaysTask[]; shortcut: string | nu
67
77
  : { tasks: [], shortcut: undefined };
68
78
 
69
79
  return {
70
- tasks: mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS),
80
+ // Order the merged list by category so the selector numbers, digit-pick,
81
+ // `/do-always <n>`, and `list` all share one consistent order.
82
+ tasks: orderTasksByCategory(mergeTasks(global.tasks, project.tasks, DEFAULT_TASKS)),
71
83
  shortcut: resolveShortcut(global.shortcut, project.shortcut),
72
84
  };
73
85
  }
74
86
 
87
+ /** Find the group (among `groups`) that contains a task. */
88
+ function findGroupOf(task: DoAlwaysTask, groups: TaskGroup[]): TaskGroup | undefined {
89
+ return groups.find((g) => g.items.includes(task));
90
+ }
91
+
92
+ /** True for a single printable ASCII character (used for filter typing). */
93
+ function isPrintable(data: string): boolean {
94
+ return data.length === 1 && data >= " " && data <= "~";
95
+ }
96
+
75
97
  export default function doAlwaysExtension(pi: ExtensionAPI) {
76
98
  let tasks: DoAlwaysTask[] = [];
77
99
  let loadedCwd = ""; // cwd the cached `tasks` were loaded for
@@ -91,14 +113,11 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
91
113
  }
92
114
  }
93
115
 
94
- /** Numbered selector: press 1-9 to pick, or arrows + Enter, Esc to cancel. */
116
+ /**
117
+ * Numbered selector with categorized sections. Press 1-9 to pick by global
118
+ * number, type to filter, or navigate with arrows + Enter, Esc to cancel.
119
+ */
95
120
  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
-
102
121
  const selected = await ctx.ui.custom<number | null>((tui, theme, _kb, done) => {
103
122
  let settled = false;
104
123
  const finish = (value: number | null) => {
@@ -107,36 +126,181 @@ export default function doAlwaysExtension(pi: ExtensionAPI) {
107
126
  done(value);
108
127
  };
109
128
 
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")));
129
+ // Group tasks under category headers, in a stable order.
130
+ const groups = groupTasksByCategory(tasks);
131
+
132
+ const kb = getKeybindings();
133
+ const maxVisible = 12;
134
+ let filter = "";
135
+ let selectedIndex = 0;
136
+ let mousePressedIndex: number | null = null;
137
+
138
+ const matchesFilter = (t: DoAlwaysTask) => {
139
+ if (!filter) return true;
140
+ const f = filter.toLowerCase();
141
+ return (
142
+ t.name.toLowerCase().includes(f) ||
143
+ (t.description ?? "").toLowerCase().includes(f) ||
144
+ (t.category ?? "").toLowerCase().includes(f)
145
+ );
146
+ };
147
+
148
+ // Recompute the visible (filtered, grouped) rows on every render so
149
+ // filter typing updates the list live.
150
+ function getVisible() {
151
+ const visibleGroups = groups
152
+ .map((g) => ({ name: g.name, items: g.items.filter(matchesFilter) }))
153
+ .filter((g) => g.items.length > 0);
154
+ const rows: Array<
155
+ | { kind: "header"; name: string }
156
+ | { kind: "item"; task: DoAlwaysTask; group: string }
157
+ > = [];
158
+ for (const g of visibleGroups) {
159
+ rows.push({ kind: "header", name: g.name });
160
+ for (const t of g.items) rows.push({ kind: "item", task: t, group: g.name });
161
+ }
162
+ const itemRows = rows.filter((r): r is (typeof rows)[number] & { kind: "item" } => r.kind === "item");
163
+ // Clamp selection to the visible item count.
164
+ selectedIndex = Math.max(0, Math.min(selectedIndex, Math.max(0, itemRows.length - 1)));
165
+ // Visible item window with scrolling.
166
+ const winStart = Math.max(0, Math.min(selectedIndex - Math.floor(maxVisible / 2), Math.max(0, itemRows.length - maxVisible)));
167
+ const visibleItemKeys = new Set(itemRows.slice(winStart, winStart + maxVisible).map((r) => r.task));
168
+ const visibleHeaderNames = new Set([...visibleItemKeys].map((t) => findGroupOf(t, visibleGroups)?.name ?? ""));
169
+ return { rows, itemRows, visibleItemKeys, visibleHeaderNames };
170
+ }
171
+
172
+ const labelCol = 26;
173
+
174
+ function renderLabel(task: DoAlwaysTask, globalIndex: number, isSelected: boolean, width: number): string {
175
+ const prefix = isSelected ? "▸ " : " ";
176
+ const label = `${prefix}${globalIndex + 1}. ${task.name}`;
177
+ if (!task.description) {
178
+ const line = truncateToWidth(label, Math.max(1, width - 2), "");
179
+ return isSelected ? theme.fg("accent", theme.bold(line)) : line;
180
+ }
181
+ // Width-aware two-column layout; fall back to label-only when the
182
+ // terminal is too narrow to fit a description column.
183
+ const effCol = Math.max(1, Math.min(labelCol, width - 8));
184
+ const nameOnly = truncateToWidth(label, effCol, "");
185
+ const pad = " ".repeat(Math.max(1, effCol - visibleWidth(nameOnly)));
186
+ const remaining = width - visibleWidth(nameOnly) - pad.length - 2;
187
+ if (remaining < 10) {
188
+ const line = truncateToWidth(label, Math.max(1, width - 2), "");
189
+ return isSelected ? theme.fg("accent", theme.bold(line)) : line;
190
+ }
191
+ const desc = truncateToWidth(task.description, remaining, "");
192
+ if (isSelected) {
193
+ return theme.fg("accent", theme.bold(`${nameOnly}${pad}${desc}`));
194
+ }
195
+ return `${nameOnly}${pad}${theme.fg("muted", desc)}`;
196
+ }
197
+
198
+ // Build the full selector output for a width, plus a map from line
199
+ // index to task for the item rows (used by mouse handling).
200
+ function buildRender(width: number) {
201
+ const { rows, itemRows, visibleItemKeys, visibleHeaderNames } = getVisible();
202
+ const lines: string[] = [];
203
+ const itemLine = new Map<number, DoAlwaysTask>();
204
+ lines.push(theme.fg("accent", theme.bold(" do-always — pick a task")));
205
+ lines.push("");
206
+ if (itemRows.length === 0) {
207
+ lines.push(theme.fg("warning", " No matching tasks"));
208
+ } else {
209
+ for (const row of rows) {
210
+ if (row.kind === "header") {
211
+ if (!visibleHeaderNames.has(row.name)) continue;
212
+ lines.push(theme.fg("accent", theme.bold(` ${row.name.toUpperCase()}`)));
213
+ continue;
214
+ }
215
+ if (!visibleItemKeys.has(row.task)) continue;
216
+ const globalIndex = tasks.indexOf(row.task);
217
+ const isSelected = row.task === itemRows[selectedIndex].task;
218
+ lines.push(renderLabel(row.task, globalIndex, isSelected, width));
219
+ itemLine.set(lines.length - 1, row.task);
220
+ }
221
+ if (itemRows.length > maxVisible) {
222
+ const hint = ` (${selectedIndex + 1}/${itemRows.length})`;
223
+ lines.push(theme.fg("dim", truncateToWidth(hint, width - 2, "")));
224
+ }
225
+ }
226
+ lines.push("");
227
+ lines.push(
228
+ theme.fg(
229
+ "dim",
230
+ truncateToWidth(" 1-9 pick by number • type to filter • ↑↓ navigate • enter select • esc cancel", width - 2, ""),
231
+ ),
232
+ );
233
+ return { lines, itemLine, itemRows };
234
+ }
124
235
 
125
236
  return {
126
237
  render(width: number) {
127
- return container.render(width);
128
- },
129
- invalidate() {
130
- container.invalidate();
238
+ return buildRender(width).lines;
131
239
  },
240
+ invalidate() {},
132
241
  handleInput(data: string) {
133
- // Direct pick by number (1-9)
134
- if (/^[1-9]$/.test(data) && Number(data) <= tasks.length) {
242
+ // Direct pick by number (1-9) — only when not filtering, so
243
+ // digits can be typed into the filter otherwise.
244
+ if (!filter && /^[1-9]$/.test(data) && Number(data) <= tasks.length) {
135
245
  finish(Number(data) - 1);
136
246
  return;
137
247
  }
138
- selectList.handleInput(data);
139
- tui.requestRender();
248
+ // Filter typing.
249
+ if (kb.matches(data, "tui.editor.deleteCharBackward")) {
250
+ filter = filter.slice(0, -1);
251
+ selectedIndex = 0;
252
+ tui.requestRender();
253
+ return;
254
+ }
255
+ if (isPrintable(data)) {
256
+ filter += data;
257
+ selectedIndex = 0;
258
+ tui.requestRender();
259
+ return;
260
+ }
261
+ // Navigation / confirmation.
262
+ const { itemRows } = getVisible();
263
+ if (kb.matches(data, "tui.select.up")) {
264
+ selectedIndex = selectedIndex === 0 ? itemRows.length - 1 : selectedIndex - 1;
265
+ tui.requestRender();
266
+ }
267
+ else if (kb.matches(data, "tui.select.down")) {
268
+ selectedIndex = selectedIndex === itemRows.length - 1 ? 0 : selectedIndex + 1;
269
+ tui.requestRender();
270
+ }
271
+ else if (kb.matches(data, "tui.select.confirm")) {
272
+ const chosen = itemRows[selectedIndex];
273
+ if (chosen) finish(tasks.indexOf(chosen.task));
274
+ }
275
+ else if (kb.matches(data, "tui.select.cancel")) {
276
+ finish(null);
277
+ }
278
+ },
279
+ handleMouse(event: TuiMouseEvent): TuiMouseEventResult | undefined {
280
+ if (event.type === "wheel" && event.wheelDelta) {
281
+ const { itemRows } = getVisible();
282
+ if (itemRows.length === 0) return undefined;
283
+ const delta = event.wheelDelta < 0 ? -1 : 1;
284
+ const prev = selectedIndex;
285
+ selectedIndex = Math.max(0, Math.min(itemRows.length - 1, selectedIndex + delta));
286
+ return { handled: true, render: selectedIndex !== prev };
287
+ }
288
+ if (event.button !== "left" || (event.type !== "press" && event.type !== "click")) return undefined;
289
+ const { itemLine, itemRows } = buildRender(event.width);
290
+ const task = itemLine.get(event.y);
291
+ if (!task) return undefined;
292
+ const idx = itemRows.findIndex((r) => r.task === task);
293
+ if (idx < 0) return undefined;
294
+ if (event.type === "press") {
295
+ mousePressedIndex = idx;
296
+ selectedIndex = idx;
297
+ return { handled: true, focus: true, render: true };
298
+ }
299
+ const clicked = mousePressedIndex ?? idx;
300
+ mousePressedIndex = null;
301
+ const chosen = itemRows[clicked];
302
+ if (chosen) finish(tasks.indexOf(chosen.task));
303
+ return { handled: true };
140
304
  },
141
305
  };
142
306
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-do-always",
3
- "version": "0.2.1",
3
+ "version": "0.3.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",
@@ -39,7 +39,11 @@
39
39
  "scripts": {
40
40
  "dev": "pi --extension ./index.ts",
41
41
  "typecheck": "tsc --noEmit",
42
- "test": "node --import tsx --test test/**/*.test.ts"
42
+ "test": "node --import tsx --test test/*.test.ts",
43
+ "prepublishOnly": "npm run typecheck && npm test"
44
+ },
45
+ "publishConfig": {
46
+ "access": "public"
43
47
  },
44
48
  "peerDependencies": {
45
49
  "@earendil-works/pi-coding-agent": "*",
package/pi.image.png CHANGED
Binary file
package/tasks.ts CHANGED
@@ -7,6 +7,8 @@
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 */
@@ -36,52 +38,74 @@ export interface ParsedDoAlwaysConfig {
36
38
  /** Used when neither config file defines any task. */
37
39
  export const DEFAULT_TASKS: DoAlwaysTask[] = [
38
40
  {
39
- name: "review",
40
- description: "Review code and double-check changes",
41
+ name: "Review",
42
+ category: "Plan",
43
+ description: "Review code and double-check changes (Plan)",
41
44
  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.",
45
+ "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 if needed. Do a summary of your findings",
43
46
  },
44
47
  {
45
- name: "readme",
46
- description: "Update the README",
48
+ name: "Cleanup",
49
+ category: "Plan",
50
+ description: "Clean up dead code and duplicates (Plan)",
47
51
  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.",
52
+ "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
53
  },
50
54
  {
51
- name: "tests",
52
- description: "Run tests and fix failures",
55
+ name: "Readme",
56
+ category: "Docs",
57
+ description: "Update the README.md",
53
58
  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.",
59
+ "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
60
  },
56
61
  {
57
- name: "commit",
58
- description: "Prepare a clean commit",
62
+ name: "Build",
63
+ category: "Do",
64
+ description: "Test build is ok and fix issues",
59
65
  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.",
66
+ "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
67
  },
62
68
  {
63
- name: "cleanup",
64
- description: "Propose a plan to clean up dead code and duplicates",
69
+ name: "Security",
70
+ category: "Plan",
71
+ description: "Security audit (Plan)",
65
72
  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.",
73
+ "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.",
74
+ },
75
+ {
76
+ name: "Performance",
77
+ category: "Plan",
78
+ description: "Performance review (Plan)",
79
+ prompt:
80
+ "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.",
81
+ },
82
+ {
83
+ name: "Tests",
84
+ category: "Do",
85
+ description: "Run tests and fix failures",
86
+ prompt:
87
+ "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.",
67
88
  },
68
89
  {
69
- name: "release",
90
+ name: "Release",
91
+ category: "Ops",
70
92
  description: "Prepare a release (version, changelog, tag)",
71
93
  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.",
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.",
73
95
  },
74
96
  {
75
- name: "security",
76
- description: "Security audit — plan proposal",
97
+ name: "Commit",
98
+ category: "Ops",
99
+ description: "Prepare a clean commit",
77
100
  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.",
101
+ "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.",
79
102
  },
80
103
  {
81
- name: "perf",
82
- description: "Performance review — plan proposal",
104
+ name: "Propose features",
105
+ category: "Plan",
106
+ description: "Propose new features (Plan)",
83
107
  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.",
108
+ "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
109
  },
86
110
  ];
87
111
 
@@ -115,11 +139,13 @@ export function parseConfig(
115
139
  for (const entry of list) {
116
140
  const t = entry as Partial<DoAlwaysTask> | null;
117
141
  if (t && typeof t.name === "string" && t.name.length > 0 && typeof t.prompt === "string" && t.prompt.length > 0) {
118
- tasks.push({
142
+ const task: DoAlwaysTask = {
119
143
  name: t.name,
120
- description: typeof t.description === "string" ? t.description : undefined,
121
144
  prompt: t.prompt,
122
- });
145
+ };
146
+ if (typeof t.description === "string") task.description = t.description;
147
+ if (typeof t.category === "string" && t.category.trim() !== "") task.category = t.category.trim();
148
+ tasks.push(task);
123
149
  } else {
124
150
  onError(`do-always: skipping invalid task in ${path} (each task needs "name" and "prompt")`);
125
151
  }
@@ -187,6 +213,57 @@ export function mergeTasks(globalTasks: DoAlwaysTask[], projectTasks: DoAlwaysTa
187
213
  return merged.length > 0 ? merged : fallback;
188
214
  }
189
215
 
216
+ /** Default order for category headers in the selector. */
217
+ export const DEFAULT_CATEGORY_ORDER = ["Plan", "Do", "Docs", "Ops", "Other"];
218
+
219
+ /** A category group: a display name and the tasks that belong to it. */
220
+ export interface TaskGroup {
221
+ name: string;
222
+ items: DoAlwaysTask[];
223
+ }
224
+
225
+ /** Capitalize the first letter of each word ("my-category" -> "My-Category"). */
226
+ function titleCase(s: string): string {
227
+ return s.replace(/\b[a-z]/g, (c) => c.toUpperCase());
228
+ }
229
+
230
+ /**
231
+ * Group tasks by category, case-insensitively. Categories are ordered by
232
+ * `order` (case-insensitive), then alphabetically; the original order within a
233
+ * group is preserved. The group `name` is the title-cased category. Tasks
234
+ * without a (non-empty) category fall under "Other".
235
+ */
236
+ export function groupTasksByCategory(
237
+ tasks: DoAlwaysTask[],
238
+ order: string[] = DEFAULT_CATEGORY_ORDER,
239
+ ): TaskGroup[] {
240
+ const orderLower = order.map((o) => o.toLowerCase());
241
+ const groups = new Map<string, DoAlwaysTask[]>();
242
+ for (const t of tasks) {
243
+ const key = (t.category ?? "").trim().toLowerCase() || "other";
244
+ if (!groups.has(key)) groups.set(key, []);
245
+ groups.get(key)!.push(t);
246
+ }
247
+ const keys = [...groups.keys()].sort((a, b) => {
248
+ const na = orderLower.indexOf(a) === -1 ? order.length : orderLower.indexOf(a);
249
+ const nb = orderLower.indexOf(b) === -1 ? order.length : orderLower.indexOf(b);
250
+ return na - nb || a.localeCompare(b);
251
+ });
252
+ return keys.map((key) => ({ name: titleCase(key), items: groups.get(key)! }));
253
+ }
254
+
255
+ /**
256
+ * Return tasks in display order: grouped by category (see
257
+ * `groupTasksByCategory`), flattened. This is the canonical order the selector
258
+ * numbers, digit-pick, `/do-always <n>`, and `list` all share.
259
+ */
260
+ export function orderTasksByCategory(
261
+ tasks: DoAlwaysTask[],
262
+ order: string[] = DEFAULT_CATEGORY_ORDER,
263
+ ): DoAlwaysTask[] {
264
+ return groupTasksByCategory(tasks, order).flatMap((g) => g.items);
265
+ }
266
+
190
267
  /**
191
268
  * Resolve a task from a command argument: by number (1-based) or by name (case-insensitive).
192
269
  */
@@ -200,7 +277,21 @@ export function resolveTask(tasks: DoAlwaysTask[], arg: string): DoAlwaysTask |
200
277
  return tasks.find((t) => t.name.toLowerCase() === a.toLowerCase());
201
278
  }
202
279
 
203
- /** Render a numbered, one-line-per-task list. */
280
+ /**
281
+ * Render a numbered, one-line-per-task list. When tasks span more than one
282
+ * category, a header line is emitted before each group (matching the selector).
283
+ */
204
284
  export function formatList(tasks: DoAlwaysTask[]): string {
205
- return tasks.map((t, i) => `${i + 1}. ${t.name} — ${t.description ?? ""}`).join("\n");
285
+ const groups = groupTasksByCategory(tasks);
286
+ const lines: string[] = [];
287
+ const showHeaders = groups.length > 1;
288
+ let n = 0;
289
+ for (const g of groups) {
290
+ if (showHeaders) lines.push(g.name.toUpperCase());
291
+ for (const t of g.items) {
292
+ n++;
293
+ lines.push(`${n}. ${t.name} — ${t.description ?? ""}`);
294
+ }
295
+ }
296
+ return lines.join("\n");
206
297
  }