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 +33 -39
- package/do-always.json +44 -24
- package/index.ts +195 -31
- package/package.json +9 -5
- package/pi.image.png +0 -0
- package/tasks.ts +119 -28
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
|
|
7
|
-
|
|
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
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|

|
|
@@ -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
|
|
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
|
|
51
|
-
|
|
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 (
|
|
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": "
|
|
6
|
-
"
|
|
7
|
-
"
|
|
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": "
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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": "
|
|
16
|
-
"
|
|
17
|
-
"
|
|
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": "
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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": "
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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": "
|
|
31
|
-
"
|
|
32
|
-
"
|
|
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": "
|
|
36
|
-
"
|
|
37
|
-
"
|
|
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": "
|
|
41
|
-
"
|
|
42
|
-
"
|
|
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 {
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
const
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
tui.
|
|
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.
|
|
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
|
|
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: "
|
|
40
|
-
|
|
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: "
|
|
46
|
-
|
|
48
|
+
name: "Cleanup",
|
|
49
|
+
category: "Plan",
|
|
50
|
+
description: "Clean up dead code and duplicates (Plan)",
|
|
47
51
|
prompt:
|
|
48
|
-
"
|
|
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: "
|
|
52
|
-
|
|
55
|
+
name: "Readme",
|
|
56
|
+
category: "Docs",
|
|
57
|
+
description: "Update the README.md",
|
|
53
58
|
prompt:
|
|
54
|
-
"
|
|
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: "
|
|
58
|
-
|
|
62
|
+
name: "Build",
|
|
63
|
+
category: "Do",
|
|
64
|
+
description: "Test build is ok and fix issues",
|
|
59
65
|
prompt:
|
|
60
|
-
"
|
|
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: "
|
|
64
|
-
|
|
69
|
+
name: "Security",
|
|
70
|
+
category: "Plan",
|
|
71
|
+
description: "Security audit (Plan)",
|
|
65
72
|
prompt:
|
|
66
|
-
"
|
|
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: "
|
|
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: "
|
|
76
|
-
|
|
97
|
+
name: "Commit",
|
|
98
|
+
category: "Ops",
|
|
99
|
+
description: "Prepare a clean commit",
|
|
77
100
|
prompt:
|
|
78
|
-
"
|
|
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: "
|
|
82
|
-
|
|
104
|
+
name: "Propose features",
|
|
105
|
+
category: "Plan",
|
|
106
|
+
description: "Propose new features (Plan)",
|
|
83
107
|
prompt:
|
|
84
|
-
"Review this project
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|