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 +66 -17
- package/do-always.json +45 -24
- package/index.ts +342 -34
- package/package.json +4 -4
- package/pi.image.png +0 -0
- package/tasks.ts +172 -28
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
|
|
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. Tasks marked `⚡`
|
|
9
|
+
(the `Plan` category by default) auto-run on selection instead — see `autoRun` below.
|
|
8
10
|
|
|
9
11
|
```text
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|

|
|
@@ -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 (
|
|
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
|
|
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": "
|
|
6
|
-
"
|
|
7
|
-
"
|
|
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": "
|
|
11
|
-
"
|
|
12
|
-
"
|
|
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": "
|
|
16
|
-
"
|
|
17
|
-
"
|
|
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": "
|
|
21
|
-
"
|
|
22
|
-
"
|
|
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": "
|
|
26
|
-
"
|
|
27
|
-
"
|
|
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": "
|
|
31
|
-
"
|
|
32
|
-
"
|
|
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": "
|
|
36
|
-
"
|
|
37
|
-
"
|
|
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": "
|
|
41
|
-
"
|
|
42
|
-
"
|
|
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 {
|
|
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
|
-
|
|
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(
|
|
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(
|
|
195
|
+
await pi.sendUserMessage(prompt);
|
|
91
196
|
}
|
|
92
197
|
}
|
|
93
198
|
|
|
94
|
-
/**
|
|
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
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
139
|
-
tui.
|
|
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.
|
|
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: "
|
|
40
|
-
|
|
66
|
+
name: "Review",
|
|
67
|
+
category: "Plan",
|
|
68
|
+
description: "Review code and double-check changes (Plan)",
|
|
41
69
|
prompt:
|
|
42
|
-
"Review the
|
|
70
|
+
"Review the changes on branch {{branch}} ({{files_changed_count}} changed files: {{files_changed}}). Last commit: {{last_commit}}. Check `git status` and `git diff` to see what changed, then double-check the changes for bugs, edge cases, security issues, and consistency with the rest of the codebase. Do a plan proposal for the fixes if needed. Do a summary of your findings",
|
|
43
71
|
},
|
|
44
72
|
{
|
|
45
|
-
name: "
|
|
46
|
-
|
|
73
|
+
name: "Cleanup",
|
|
74
|
+
category: "Plan",
|
|
75
|
+
description: "Clean up dead code and duplicates (Plan)",
|
|
47
76
|
prompt:
|
|
48
|
-
"
|
|
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: "
|
|
52
|
-
|
|
80
|
+
name: "Readme",
|
|
81
|
+
category: "Docs",
|
|
82
|
+
description: "Update the README.md",
|
|
53
83
|
prompt:
|
|
54
|
-
"
|
|
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: "
|
|
58
|
-
|
|
87
|
+
name: "Build",
|
|
88
|
+
category: "Do",
|
|
89
|
+
description: "Test build is ok and fix issues",
|
|
59
90
|
prompt:
|
|
60
|
-
"
|
|
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: "
|
|
64
|
-
|
|
94
|
+
name: "Security",
|
|
95
|
+
category: "Plan",
|
|
96
|
+
description: "Security audit (Plan)",
|
|
65
97
|
prompt:
|
|
66
|
-
"
|
|
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: "
|
|
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: "
|
|
76
|
-
|
|
122
|
+
name: "Commit",
|
|
123
|
+
category: "Ops",
|
|
124
|
+
description: "Prepare a clean commit",
|
|
77
125
|
prompt:
|
|
78
|
-
"
|
|
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: "
|
|
82
|
-
|
|
129
|
+
name: "Propose features",
|
|
130
|
+
category: "Plan",
|
|
131
|
+
description: "Propose new features (Plan)",
|
|
83
132
|
prompt:
|
|
84
|
-
"Review this project
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
}
|