@rse/ase 2.0.4 → 2.0.6
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/dst/ase-config-schema.js +1 -1
- package/dst/ase-hook.js +12 -3
- package/dst/ase-setup-settings.js +111 -1
- package/dst/ase-setup.js +19 -7
- package/dst/ase-spec.js +184 -49
- package/dst/ase-task-board-tui-model.js +8 -1
- package/dst/ase-task-board-tui.js +26 -2
- package/dst/ase-task-board-web-client.js +10 -10
- package/dst/man/ase-artifact.md +1 -1
- package/dst/man/ase-config.md +5 -3
- package/dst/man/ase-mcp.md +1 -1
- package/dst/man/ase-setup-install.md +10 -1
- package/dst/man/ase-setup-status.md +6 -0
- package/dst/man/ase-setup-uninstall.md +3 -1
- package/dst/man/ase-setup-update.md +3 -0
- package/dst/man/ase-setup.md +4 -4
- package/dst/man/ase-spec-export.md +14 -3
- package/dst/man/ase-spec-init.md +10 -1
- package/dst/man/ase-spec-lint.md +2 -1
- package/dst/man/ase-spec-preview.md +10 -1
- package/dst/man/ase-spec-query.md +96 -0
- package/dst/man/ase-spec.md +32 -11
- package/dst/man/ase.md +3 -3
- package/package.json +21 -18
- package/plugin/.claude-plugin/icon.png +0 -0
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/.github/plugin/plugin.json +1 -1
- package/plugin/meta/ase-format-spec.md +29 -5
- package/plugin/meta/ase-format-specbook.md +427 -47
- package/plugin/meta/ase-format-specbook.yaml +180 -78
- package/plugin/mods/ase-mods-common.ts +474 -0
- package/plugin/mods/ase-mods-evaluate.ts +227 -0
- package/plugin/mods/ase-mods-grill.ts +283 -0
- package/plugin/mods/ase-mods-help-browse.ts +410 -0
- package/plugin/mods/ase-mods-help-compose.ts +561 -0
- package/plugin/mods/ase-mods-help-find.ts +434 -0
- package/plugin/mods/ase-mods-help-hint.ts +134 -0
- package/plugin/mods/ase-mods-help-show.ts +142 -0
- package/plugin/mods/ase-mods.ts +220 -381
- package/plugin/skills/ase-code-lint/SKILL.md +15 -15
- package/plugin/skills/ase-code-lint/help.md +21 -8
- package/plugin/skills/ase-help-intent/SKILL.md +2 -1
- package/plugin/skills/ase-help-intent/data.md +42 -16
- package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
- package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
- package/plugin/skills/ase-spec-lint/help.md +21 -8
- package/plugin/mods/claude-code.d.ts +0 -13440
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* Claude Code function hooks module ("mod", EARLY ACCESS):
|
|
8
|
+
Intent help, matching the still unsubmitted prompt, once suffixed with
|
|
9
|
+
a blank and "!!", as an intent against the help of all ASE skills (like
|
|
10
|
+
the skill "ase-help-intent", but over the session context), and showing
|
|
11
|
+
the proposed skills in a master/detail view in the band above the
|
|
12
|
+
prompt, where LEFT/RIGHT (or the wheel over the skills) selects a skill,
|
|
13
|
+
UP/DOWN and "b"/"f" (or the wheel over the details) scroll its details,
|
|
14
|
+
RETURN picks its command into the prompt, SPACE picks it into the synopsis
|
|
15
|
+
form, and "!!", BACKSPACE, or any other key restores the prompt as typed
|
|
16
|
+
without the trigger. */
|
|
17
|
+
|
|
18
|
+
import type { Elements, Frozen, PromptBox, PromptEditInput, RenderElement } from "claude-code"
|
|
19
|
+
import { type Engine, type Char, type BandRender, type BandScroll, type View, PICKED, CODE,
|
|
20
|
+
held, decode, section, wrap, uniform, flow, frame, layout, wheel, panel } from "./ase-mods-common.ts"
|
|
21
|
+
|
|
22
|
+
/* the trigger of an intent inside the prompt (suffixed with a blank and "!!",
|
|
23
|
+
so the doubled exclamation mark of a sentence is no trigger), the skill of
|
|
24
|
+
a proposed command (with or without its "ase:" plugin prefix), the maximum
|
|
25
|
+
number of command proposals, the help corpus of all skills (relative to
|
|
26
|
+
the plugin directory), and the fallback model without any session to fork */
|
|
27
|
+
const INTENT = /\s+!!$/
|
|
28
|
+
const SKILL = /^\/?(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+)(?![\w-])/
|
|
29
|
+
const PROPOSALS = 8
|
|
30
|
+
const CORPUS = "skills/ase-help-intent/data.md"
|
|
31
|
+
const FALLBACK = "sonnet"
|
|
32
|
+
|
|
33
|
+
/* the color of the box around (and of the rationale inside) the command and
|
|
34
|
+
rationale of a proposal, the title placed into the top edge of the frame,
|
|
35
|
+
and the share of the band columns taken by the master view */
|
|
36
|
+
const BOXED = "blue"
|
|
37
|
+
const TITLE = "[ ⧉ ASE: Find Skill Command ]"
|
|
38
|
+
const MASTER = 0.3
|
|
39
|
+
|
|
40
|
+
/* the frames of the spinner animating the status message while matching,
|
|
41
|
+
and the milliseconds between two frames */
|
|
42
|
+
const SPINNER = "⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏"
|
|
43
|
+
const SPIN = 100
|
|
44
|
+
|
|
45
|
+
/* the help of a single skill: its catalog entry and its entire manual page */
|
|
46
|
+
type Help = { catalog: string, manual: string }
|
|
47
|
+
|
|
48
|
+
/* a command proposal for an intent */
|
|
49
|
+
type Proposal = { skill: string, command: string, rationale: string }
|
|
50
|
+
|
|
51
|
+
/* an intent under matching: the scroll state of its band, the original prompt
|
|
52
|
+
(without the trigger and its blank, resp. without the trigger only), the
|
|
53
|
+
matching state, the status message,
|
|
54
|
+
the spinner frame, the command proposals, the selected proposal, and
|
|
55
|
+
the last key, if a "!" or "/" */
|
|
56
|
+
type Intent = View & {
|
|
57
|
+
draft: string,
|
|
58
|
+
typed: string,
|
|
59
|
+
state: "busy" | "done" | "fail",
|
|
60
|
+
message: string,
|
|
61
|
+
spin: number,
|
|
62
|
+
proposals: Proposal[],
|
|
63
|
+
select: number,
|
|
64
|
+
pending: string
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/* parse the help corpus into the help of each skill, where
|
|
68
|
+
the catalog entry is the NAME, SYNOPSIS, DESCRIPTION, and SCENARIOS only */
|
|
69
|
+
const helps = (text: string): Map<string, Help> => {
|
|
70
|
+
const result = new Map<string, Help>()
|
|
71
|
+
for (const m of text.matchAll(/<purpose name="([^"]+)">\n([\s\S]*?)<\/purpose>/g)) {
|
|
72
|
+
const manual = m[2]!.trim()
|
|
73
|
+
const catalog = manual.split(/^(?=## {2})/m)
|
|
74
|
+
.filter((part) => /^## {2}(?:NAME|SYNOPSIS|DESCRIPTION|SCENARIOS)\b/.test(part))
|
|
75
|
+
.join("").trim()
|
|
76
|
+
result.set(m[1]!, { catalog, manual })
|
|
77
|
+
}
|
|
78
|
+
return result
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/* extract the text out of a model reply (as the runtime result of a fork
|
|
82
|
+
or completion may deviate from its declared type, like a string, a result
|
|
83
|
+
object, an unanswered reply, or a refusal with a reason instead of null) */
|
|
84
|
+
const reply = (result: unknown): string | undefined => {
|
|
85
|
+
if (typeof result === "string")
|
|
86
|
+
return result
|
|
87
|
+
if (typeof result === "object" && result !== null) {
|
|
88
|
+
const r = result as { isAnswered?: boolean, text?: unknown }
|
|
89
|
+
if (r.isAnswered !== false && typeof r.text === "string")
|
|
90
|
+
return r.text
|
|
91
|
+
}
|
|
92
|
+
return undefined
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/* extract the JSON array out of a model reply (trying each opening bracket,
|
|
96
|
+
as any prose before the array may contain brackets, too) */
|
|
97
|
+
const array = (text: string | undefined): unknown[] | undefined => {
|
|
98
|
+
if (text === undefined)
|
|
99
|
+
return undefined
|
|
100
|
+
const j = text.lastIndexOf("]")
|
|
101
|
+
for (let i = text.indexOf("["); i !== -1 && i < j; i = text.indexOf("[", i + 1)) {
|
|
102
|
+
try {
|
|
103
|
+
const value: unknown = JSON.parse(text.slice(i, j + 1))
|
|
104
|
+
if (Array.isArray(value))
|
|
105
|
+
return value
|
|
106
|
+
}
|
|
107
|
+
catch {
|
|
108
|
+
/* continue with the next opening bracket */
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return undefined
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/* the prompt for selecting the fitting skills out of the catalog */
|
|
115
|
+
const selection = (draft: string, catalog: string) => `
|
|
116
|
+
Do *not* call any tools and do *not* perform the following intent!
|
|
117
|
+
Instead, only *match* the following free-text intent, interpreted in the
|
|
118
|
+
context of this session, against the following catalog of ASE skills:
|
|
119
|
+
|
|
120
|
+
<intent>
|
|
121
|
+
${draft}
|
|
122
|
+
</intent>
|
|
123
|
+
|
|
124
|
+
<catalog>
|
|
125
|
+
${catalog}
|
|
126
|
+
</catalog>
|
|
127
|
+
|
|
128
|
+
Select *every* skill which adequately fits the intent -- judging the
|
|
129
|
+
fit primarily by its SCENARIOS and DESCRIPTION, and secondarily by its
|
|
130
|
+
NAME and SYNOPSIS -- ordered from best-fitting to worst-fitting, and
|
|
131
|
+
keep at most the ${PROPOSALS} best ones. Reply with
|
|
132
|
+
a JSON array of the skill names only (like \`[ "ase-code-lint" ]\`),
|
|
133
|
+
resp. with an empty JSON array if no skill fits, and nothing else.
|
|
134
|
+
`
|
|
135
|
+
|
|
136
|
+
/* the prompt for generating the commands out of the manual pages */
|
|
137
|
+
const generation = (draft: string, manuals: string) => `
|
|
138
|
+
Do *not* call any tools and do *not* perform the following intent!
|
|
139
|
+
Instead, only *generate* commands which realize the following free-text
|
|
140
|
+
intent, interpreted in the context of this session, out of the
|
|
141
|
+
following manual pages of ASE skills:
|
|
142
|
+
|
|
143
|
+
<intent>
|
|
144
|
+
${draft}
|
|
145
|
+
</intent>
|
|
146
|
+
|
|
147
|
+
<manuals>
|
|
148
|
+
${manuals}
|
|
149
|
+
</manuals>
|
|
150
|
+
|
|
151
|
+
For each skill, in the given order, generate a concrete command which
|
|
152
|
+
realizes the intent, with the option flags and positional arguments
|
|
153
|
+
derived from its SYNOPSIS, OPTIONS, and ARGUMENTS, and their concrete
|
|
154
|
+
values taken from the intent and the context of this session. Reply with
|
|
155
|
+
a JSON array of objects \`{ "command": "/ase:<skill> <arguments>",
|
|
156
|
+
"rationale": "<justification>" }\`, where the justification is a *very
|
|
157
|
+
brief*, single-sentence explanation of why the skill and its arguments
|
|
158
|
+
match the intent, and nothing else.
|
|
159
|
+
`
|
|
160
|
+
|
|
161
|
+
/* determine the lines of the detail view of a proposal out of the manual page
|
|
162
|
+
of its skill: the command and rationale of the proposal (inside a box), and
|
|
163
|
+
the name, title, and description of its skill */
|
|
164
|
+
const details = (proposal: Proposal, manual: string, columns: number): Char[][] => {
|
|
165
|
+
const title = section(manual, "NAME").trim().replace(/^`[^`]*`\s+-\s+/, "")
|
|
166
|
+
const boxed = wrap(uniform(`❯ ${proposal.command}`, true, CODE), columns - 4, 2)
|
|
167
|
+
if (proposal.rationale !== "")
|
|
168
|
+
boxed.push(...wrap(uniform(`▷ Rationale: ${proposal.rationale}`, false, BOXED), columns - 4, 2))
|
|
169
|
+
const lines = frame(boxed, columns, BOXED)
|
|
170
|
+
lines.push([], ...wrap(uniform(title !== "" ? `${proposal.skill}: ${title}` : proposal.skill,
|
|
171
|
+
true, undefined), columns, 0))
|
|
172
|
+
const description = section(manual, "DESCRIPTION")
|
|
173
|
+
if (description.trim() !== "")
|
|
174
|
+
lines.push([], ...flow(description, columns))
|
|
175
|
+
return lines
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/* the intent help: its state and its operations */
|
|
179
|
+
export const modHelpFind = () => {
|
|
180
|
+
/* the help of all skills (loaded on first use) and the currently shown intent */
|
|
181
|
+
let corpus: Map<string, Help> | undefined
|
|
182
|
+
let intent: Intent | undefined
|
|
183
|
+
|
|
184
|
+
/* determine whether the intent band is shown */
|
|
185
|
+
const active = () => intent !== undefined
|
|
186
|
+
|
|
187
|
+
/* close the intent band */
|
|
188
|
+
const reset = () => {
|
|
189
|
+
intent = undefined
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/* close the intent band (as on a failure) and determine the prompt to
|
|
193
|
+
restore (the prompt as typed), if the band was shown */
|
|
194
|
+
const cancel = (): string | undefined => {
|
|
195
|
+
const typed = intent?.typed
|
|
196
|
+
reset()
|
|
197
|
+
return typed
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/* while the intent band is shown, every edit is a key of the band (with the
|
|
201
|
+
prompt held): LEFT/RIGHT select a proposal, UP/DOWN scroll its details by
|
|
202
|
+
a line and "b"/"f" by a page (as the engine consumes PAGEUP/PAGEDOWN),
|
|
203
|
+
RETURN (arriving as a typed newline in the held prompt) operates the band
|
|
204
|
+
as at the prompt, SPACE closes the band with the command of the selected
|
|
205
|
+
proposal suffixed with the trigger of the synopsis form, "//" (two "/"
|
|
206
|
+
in a row) closes the band with the prompt as typed suffixed with the
|
|
207
|
+
trigger of the manpage browser (both to switch over to the other band),
|
|
208
|
+
and "!!" (two "!" in a row), a deletion (like BACKSPACE), or any
|
|
209
|
+
other key closes the band and restores the prompt as typed without the
|
|
210
|
+
trigger (onto whose end the edit of a typed text is still to be applied) */
|
|
211
|
+
const edit = (e: Frozen<PromptEditInput>): PromptBox | string => {
|
|
212
|
+
const current = intent!
|
|
213
|
+
const { draft, typed, state, proposals } = current
|
|
214
|
+
const key = decode(e)
|
|
215
|
+
const pending = current.pending
|
|
216
|
+
current.pending = /^[!/]$/.test(key.text) && key.text !== pending ? key.text : ""
|
|
217
|
+
if (current.pending !== "")
|
|
218
|
+
return held(draft)
|
|
219
|
+
if (key.text === "//" || (key.text === "/" && pending === "/")) {
|
|
220
|
+
reset()
|
|
221
|
+
const text = `${typed}//`
|
|
222
|
+
return { text, cursor: text.length }
|
|
223
|
+
}
|
|
224
|
+
if (key.name === "return") {
|
|
225
|
+
const text = submit()
|
|
226
|
+
return intent !== undefined ? held(draft) : { text, cursor: text.length }
|
|
227
|
+
}
|
|
228
|
+
if (state === "done" && key.text === " ") {
|
|
229
|
+
reset()
|
|
230
|
+
const text = `${proposals[current.select]!.command} !!`
|
|
231
|
+
return { text, cursor: text.length }
|
|
232
|
+
}
|
|
233
|
+
const roll = key.name === "up" ? -1 : key.name === "down" ? 1 :
|
|
234
|
+
key.text === "b" ? -current.page : key.text === "f" ? current.page : 0
|
|
235
|
+
const step = roll !== 0 ? 0 : key.step
|
|
236
|
+
if (state === "done" && (roll !== 0 || step !== 0)) {
|
|
237
|
+
if (step !== 0) {
|
|
238
|
+
current.select = Math.max(0, Math.min(proposals.length - 1, current.select + step))
|
|
239
|
+
current.scroll = 0
|
|
240
|
+
}
|
|
241
|
+
else
|
|
242
|
+
current.scroll = Math.max(0, current.scroll + roll)
|
|
243
|
+
return held(draft)
|
|
244
|
+
}
|
|
245
|
+
reset()
|
|
246
|
+
if (key.text === "" || key.text === "!!" || (key.text === "!" && pending === "!"))
|
|
247
|
+
return { text: typed, cursor: typed.length }
|
|
248
|
+
return typed
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/* stash the prompt as the intent, hold the prompt (so the keys of the
|
|
252
|
+
band arrive as edits), and match the intent in the background */
|
|
253
|
+
const open = (box: PromptBox, engine: Pick<Engine, "invalidate" | "read" | "fork" | "complete" | "sleep">): PromptBox | undefined => {
|
|
254
|
+
const draft = box.text.replace(INTENT, "")
|
|
255
|
+
if (!INTENT.test(box.text) || draft.trim() === "")
|
|
256
|
+
return undefined
|
|
257
|
+
const current: Intent = { draft, typed: box.text.slice(0, -2), state: "busy",
|
|
258
|
+
message: "matching intent...", spin: 0, proposals: [],
|
|
259
|
+
select: 0, top: 0, scroll: 0, page: 1, split: 0, pending: "" }
|
|
260
|
+
intent = current
|
|
261
|
+
|
|
262
|
+
/* update the intent, unless it was closed in the meantime */
|
|
263
|
+
const settle = (state: Intent["state"], message: string, proposals: Proposal[] = []) => {
|
|
264
|
+
if (intent !== current)
|
|
265
|
+
return
|
|
266
|
+
Object.assign(current, { state, message, proposals })
|
|
267
|
+
engine.invalidate()
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/* ask the model for a stage over the transcript of the session (sharing its
|
|
271
|
+
prompt cache), or, as the fork yields no text on a cold snapshot or an API
|
|
272
|
+
error, without any session context (telling so in the status message) */
|
|
273
|
+
const ask = async (prompt: string, stage: string): Promise<string | undefined> => {
|
|
274
|
+
settle("busy", `${stage}...`)
|
|
275
|
+
const forked = reply(await engine.fork(prompt))
|
|
276
|
+
if (forked !== undefined)
|
|
277
|
+
return forked
|
|
278
|
+
if (intent !== current)
|
|
279
|
+
return undefined
|
|
280
|
+
settle("busy", `${stage} (without session context)...`)
|
|
281
|
+
return reply(await engine.complete({ model: FALLBACK, prompt, maxTokens: 4096 }))
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/* match the intent in two stages: select the fitting skills out of
|
|
285
|
+
the catalog, then generate the commands out of their manual pages */
|
|
286
|
+
const match = async () => {
|
|
287
|
+
const skills = corpus ??= helps(await engine.read(CORPUS))
|
|
288
|
+
const catalog = [ ...skills ].map(([ name, help ]) =>
|
|
289
|
+
`<skill name="${name}">\n${help.catalog}\n</skill>`).join("\n")
|
|
290
|
+
const names = (array(await ask(selection(draft, catalog), "matching intent")) ?? [])
|
|
291
|
+
.filter((name, i, all): name is string =>
|
|
292
|
+
typeof name === "string" && skills.has(name) && all.indexOf(name) === i)
|
|
293
|
+
.slice(0, PROPOSALS)
|
|
294
|
+
if (intent !== current)
|
|
295
|
+
return
|
|
296
|
+
if (names.length === 0) {
|
|
297
|
+
settle("fail", "no confident match for the intent -- please refine or clarify it")
|
|
298
|
+
return
|
|
299
|
+
}
|
|
300
|
+
const manuals = names.map((name) =>
|
|
301
|
+
`<skill name="${name}">\n${skills.get(name)!.manual}\n</skill>`).join("\n")
|
|
302
|
+
const proposals = (array(await ask(generation(draft, manuals), `generating commands for ${names.join(", ")}`)) ?? [])
|
|
303
|
+
.flatMap((item): Proposal[] => {
|
|
304
|
+
const { command, rationale } = (item ?? {}) as { command?: unknown, rationale?: unknown }
|
|
305
|
+
const text = typeof command === "string" ? command.trim() : ""
|
|
306
|
+
const skill = SKILL.exec(text)?.[1]
|
|
307
|
+
if (skill === undefined || !skills.has(skill))
|
|
308
|
+
return []
|
|
309
|
+
return [ {
|
|
310
|
+
skill,
|
|
311
|
+
command: text.replace(SKILL, "/ase:$1").replace(/\s+/g, " "),
|
|
312
|
+
rationale: typeof rationale === "string" ? rationale.trim().replace(/\s+/g, " ") : ""
|
|
313
|
+
} ]
|
|
314
|
+
})
|
|
315
|
+
.slice(0, PROPOSALS)
|
|
316
|
+
|
|
317
|
+
/* without any generated command, still propose the bare commands of the selected skills */
|
|
318
|
+
if (proposals.length === 0)
|
|
319
|
+
settle("done", "pick a command (without generated arguments)",
|
|
320
|
+
names.map((skill) => ({ skill, command: `/ase:${skill}`, rationale: "" })))
|
|
321
|
+
else
|
|
322
|
+
settle("done", "pick a command", proposals)
|
|
323
|
+
}
|
|
324
|
+
match().catch((err: unknown) => {
|
|
325
|
+
settle("fail", `matching failed: ${err instanceof Error ? err.message : String(err)}`)
|
|
326
|
+
})
|
|
327
|
+
|
|
328
|
+
/* animate the spinner while matching, unless the intent was closed in the meantime */
|
|
329
|
+
const animate = async () => {
|
|
330
|
+
while (intent === current && current.state === "busy") {
|
|
331
|
+
await engine.sleep(SPIN)
|
|
332
|
+
current.spin = (current.spin + 1) % SPINNER.length
|
|
333
|
+
engine.invalidate()
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
animate().catch(() => {
|
|
337
|
+
/* ignore a stopped animation, as the status message stays readable */
|
|
338
|
+
})
|
|
339
|
+
return held(draft)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
/* operate the intent band by the wheel: over the master view step the
|
|
343
|
+
selection, over the detail view scroll it, resp. scroll it by a page
|
|
344
|
+
with the scroll keys (PAGEUP/PAGEDOWN) while the band is focused
|
|
345
|
+
(and tell whether the band took the scrolling) */
|
|
346
|
+
const scroll = (e: BandScroll): boolean => {
|
|
347
|
+
if (intent === undefined || intent.state !== "done")
|
|
348
|
+
return false
|
|
349
|
+
const current = intent
|
|
350
|
+
wheel(e, current, (by) => {
|
|
351
|
+
const select = Math.max(0, Math.min(current.proposals.length - 1, current.select + by))
|
|
352
|
+
if (select !== current.select) {
|
|
353
|
+
current.select = select
|
|
354
|
+
current.scroll = 0
|
|
355
|
+
}
|
|
356
|
+
})
|
|
357
|
+
return true
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/* operate the intent band by RETURN at the prompt: pick the selected
|
|
361
|
+
proposal (closing the band), if any, resp. close a failed band, and
|
|
362
|
+
determine the prompt to refill (the held prompt while the band stays
|
|
363
|
+
shown, but without its space after the cursor, as the refill places
|
|
364
|
+
the cursor at its end) */
|
|
365
|
+
const submit = (): string => {
|
|
366
|
+
const current = intent!
|
|
367
|
+
const proposal = current.state === "done" ? current.proposals[current.select] : undefined
|
|
368
|
+
if (proposal !== undefined || current.state === "fail")
|
|
369
|
+
reset()
|
|
370
|
+
return proposal !== undefined ? `${proposal.command} ` :
|
|
371
|
+
intent !== undefined ? held(current.draft).text.trimEnd() : `${current.draft} `
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/* draw the intent into the band above the prompt (terminal only): a master view
|
|
375
|
+
of the proposed skills and a detail view of the selected one side by side */
|
|
376
|
+
const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
|
|
377
|
+
/* yield to a survey and draw nothing without an intent */
|
|
378
|
+
if (intent === undefined || e.props.hasSurvey)
|
|
379
|
+
return undefined
|
|
380
|
+
const current = intent
|
|
381
|
+
const { proposals } = current
|
|
382
|
+
|
|
383
|
+
/* determine the geometry of the band (with the master view in a fixed ratio)
|
|
384
|
+
and the lines of the detail view: the command and rationale of the
|
|
385
|
+
selected proposal (inside a box), and the name, title, and description
|
|
386
|
+
of its skill */
|
|
387
|
+
const { Text } = ui
|
|
388
|
+
const geometry = layout(e, MASTER)
|
|
389
|
+
const { span, body, master, detail } = geometry
|
|
390
|
+
const clip = (text: string, n: number) =>
|
|
391
|
+
text.length > n ? `${text.slice(0, n - 1)}…` : text
|
|
392
|
+
const proposal = current.state === "done" ? proposals[current.select] : undefined
|
|
393
|
+
const lines = proposal !== undefined ?
|
|
394
|
+
details(proposal, corpus?.get(proposal.skill)?.manual ?? "", detail) : undefined
|
|
395
|
+
|
|
396
|
+
/* keep the selected proposal in the master view */
|
|
397
|
+
current.top = Math.max(0, current.select - body + 1, Math.min(current.top, current.select))
|
|
398
|
+
|
|
399
|
+
/* determine the rows of the status message (as long as no proposals exist):
|
|
400
|
+
a box of half the columns, centered vertically and horizontally */
|
|
401
|
+
const hint: string[] = []
|
|
402
|
+
if (proposal === undefined) {
|
|
403
|
+
const size = Math.max(12, Math.floor(span / 2))
|
|
404
|
+
const text = current.state === "busy" ? `${SPINNER[current.spin]} ${current.message}` : current.message
|
|
405
|
+
const boxed = frame(wrap(uniform(text, false, undefined), size - 4, 0)
|
|
406
|
+
.slice(0, Math.max(1, body - 2)), size, undefined)
|
|
407
|
+
const pad = " ".repeat(Math.max(0, Math.floor((span - size) / 2)))
|
|
408
|
+
hint.push(...Array.from({ length: Math.max(0, Math.floor((body - boxed.length) / 2)) }, () => " "),
|
|
409
|
+
...boxed.map((line) => pad + line.map((char) => char.ch).join("")))
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/* draw the status message resp. both views (the master view with the
|
|
413
|
+
selected proposal in red, and the detail view), and the key hints
|
|
414
|
+
(the intent itself stays visible in the prompt) */
|
|
415
|
+
return panel(ui, geometry, current, {
|
|
416
|
+
title: TITLE,
|
|
417
|
+
hints: current.state === "done" ? "⌫/!!: cancel+close · ⏎: apply+close · SPACE: compose · ←/→: select · b/f: scroll" +
|
|
418
|
+
(geometry.overflow ? " · wheel: select/scroll" : "") + " · //: Browse Skill Manpages" :
|
|
419
|
+
"⌫/!!/any key: cancel+close · //: Browse Skill Manpages",
|
|
420
|
+
rows: (i) => {
|
|
421
|
+
if (proposal === undefined)
|
|
422
|
+
return Text({ dimColor: true, wrap: "truncate-end", children: hint[i] ?? " " })
|
|
423
|
+
const k = current.top + i
|
|
424
|
+
const p = proposals[k]
|
|
425
|
+
return p !== undefined ? Text({ color: k === current.select ? PICKED : undefined,
|
|
426
|
+
children: clip(`${k === current.select ? "▶" : " "} ${p.skill}`, master) }) : undefined
|
|
427
|
+
},
|
|
428
|
+
lines
|
|
429
|
+
})
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
return { active, reset, cancel, edit, open, scroll, submit, render }
|
|
433
|
+
}
|
|
434
|
+
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/*
|
|
2
|
+
** Agentic Software Engineering (ASE)
|
|
3
|
+
** Copyright (c) 2025-2026 Dr. Ralf S. Engelschall <rse@engelschall.com>
|
|
4
|
+
** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/* Claude Code function hooks module ("mod", EARLY ACCESS):
|
|
8
|
+
Hint band, showing for a skill command (bare or with its options and
|
|
9
|
+
arguments already typed) the entire argument hint of the skill, wrapped
|
|
10
|
+
over multiple lines, in the band above the prompt (instead of the one-line
|
|
11
|
+
and hence usually truncated argument hint of the engine), together with
|
|
12
|
+
the hint on the manual band and the synopsis form, while the prompt starts
|
|
13
|
+
with the skill command followed by a blank (so the typeahead of the engine
|
|
14
|
+
is closed), until the prompt is submitted. */
|
|
15
|
+
|
|
16
|
+
import type { Elements, RenderElement } from "claude-code"
|
|
17
|
+
import { type Engine, type Char, type BandRender, type View, COMMAND,
|
|
18
|
+
wrap, uniform, layout, panel } from "./ase-mods-common.ts"
|
|
19
|
+
|
|
20
|
+
/* the skill command (followed by a blank) at the start of the prompt, the
|
|
21
|
+
argument hint in the frontmatter of a skill, its groups (the bracketed
|
|
22
|
+
resp. parenthesized options and arguments, with one level of nesting, and
|
|
23
|
+
the other words), the non-breaking space keeping a group on a single line,
|
|
24
|
+
the colors of its syntax characters and of its references, the indentation
|
|
25
|
+
of the continuation lines, and the title placed into the top edge of the band */
|
|
26
|
+
const SKILL = /^\/(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+)(?=\s)/
|
|
27
|
+
const HINT = /^argument-hint:\s*"(.*)"\s*$/m
|
|
28
|
+
const GROUP = /\[(?:[^[\]]|\[[^\]]*\])*\]|\((?:[^()]|\([^)]*\))*\)|\S+/g
|
|
29
|
+
const NBSP = String.fromCodePoint(0xA0)
|
|
30
|
+
const SYNTAX = "gray"
|
|
31
|
+
const REFER = "red"
|
|
32
|
+
const INDENT = 4
|
|
33
|
+
const TITLE = "[ ⧉ ASE: Show Skill Synopsis ]"
|
|
34
|
+
|
|
35
|
+
/* a hint band: the scroll state of its frame, the skill command as typed,
|
|
36
|
+
the argument hint of its skill, and the lines with the columns they were
|
|
37
|
+
wrapped to */
|
|
38
|
+
type Hint = View & {
|
|
39
|
+
command: string,
|
|
40
|
+
hint: string,
|
|
41
|
+
lines: Char[][],
|
|
42
|
+
columns: number
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/* convert an argument hint into styled characters: the syntax characters
|
|
46
|
+
of its optional groups, plain groups, and alternatives in grey and its "<...>" references in red */
|
|
47
|
+
const tint = (text: string): Char[] => {
|
|
48
|
+
const chars: Char[] = []
|
|
49
|
+
let reference = false
|
|
50
|
+
for (const ch of text) {
|
|
51
|
+
if (ch === "<")
|
|
52
|
+
reference = true
|
|
53
|
+
chars.push({ ch, bold: false, color: reference ? REFER : "[()|]".includes(ch) ? SYNTAX : undefined })
|
|
54
|
+
if (ch === ">")
|
|
55
|
+
reference = false
|
|
56
|
+
}
|
|
57
|
+
return chars
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/* the hint band: its state and its operations */
|
|
61
|
+
export const modHelpHint = () => {
|
|
62
|
+
/* the argument hints of the skills (loaded on first use, undefined
|
|
63
|
+
for an unknown skill), the currently shown band, and the generation
|
|
64
|
+
of the latest tracking resp. closing (discarding any older tracking) */
|
|
65
|
+
const hints = new Map<string, string | undefined>()
|
|
66
|
+
let shown: Hint | undefined
|
|
67
|
+
let generation = 0
|
|
68
|
+
|
|
69
|
+
/* close the hint band */
|
|
70
|
+
const reset = () => {
|
|
71
|
+
generation++
|
|
72
|
+
shown = undefined
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/* determine the argument hint of a skill (loaded on first use) */
|
|
76
|
+
const synopsis = async (skill: string, engine: Pick<Engine, "read">): Promise<string | undefined> => {
|
|
77
|
+
if (!hints.has(skill))
|
|
78
|
+
hints.set(skill, await engine.read(`skills/${skill}/SKILL.md`)
|
|
79
|
+
.then((text) => HINT.exec(text)?.[1]).catch(() => undefined))
|
|
80
|
+
return hints.get(skill)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* determine the lines of the hint band, wrapped to the given columns: the
|
|
84
|
+
skill command after the prompt marker and in the prompt color, followed by the tinted groups of the
|
|
85
|
+
argument hint, each kept on a single line (if it fits) */
|
|
86
|
+
const wrapped = (current: Hint, columns: number): Char[][] => {
|
|
87
|
+
if (current.columns !== columns) {
|
|
88
|
+
const groups = (current.hint.match(GROUP) ?? []).map((group) =>
|
|
89
|
+
group.length <= columns - INDENT ? group.replaceAll(" ", NBSP) : group)
|
|
90
|
+
current.lines = wrap([ ...uniform("❯ ", false, undefined), ...uniform(current.command, false, COMMAND),
|
|
91
|
+
...tint(` ${groups.join(" ")}`) ], columns, INDENT)
|
|
92
|
+
current.columns = columns
|
|
93
|
+
}
|
|
94
|
+
return current.lines
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/* track the prompt and show the hint band while the prompt starts with the
|
|
98
|
+
command of a skill with an argument hint and a blank, else close it
|
|
99
|
+
(and tell whether the band changed) */
|
|
100
|
+
const track = async (text: string, engine: Pick<Engine, "read">): Promise<boolean> => {
|
|
101
|
+
const prior = shown
|
|
102
|
+
const current = ++generation
|
|
103
|
+
const command = SKILL.exec(text)
|
|
104
|
+
if (command === null)
|
|
105
|
+
shown = undefined
|
|
106
|
+
else if (shown?.command !== command[0]) {
|
|
107
|
+
const hint = await synopsis(command[1]!, engine)
|
|
108
|
+
if (current !== generation)
|
|
109
|
+
return false
|
|
110
|
+
shown = hint !== undefined ? { command: command[0], hint, lines: [], columns: 0,
|
|
111
|
+
top: 0, scroll: 0, page: 1, split: 0 } : undefined
|
|
112
|
+
}
|
|
113
|
+
return shown !== prior
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/* draw the argument hint into the band above the prompt (terminal only),
|
|
117
|
+
with the band exactly as tall as the hint (as it always fits, without
|
|
118
|
+
a scroll position), yielding to a survey */
|
|
119
|
+
const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
|
|
120
|
+
if (shown === undefined || e.props.hasSurvey)
|
|
121
|
+
return undefined
|
|
122
|
+
const geometry = layout(e, 0)
|
|
123
|
+
const lines = wrapped(shown, geometry.span - 3)
|
|
124
|
+
return panel(ui, { ...geometry, body: lines.length, overflow: false }, shown, {
|
|
125
|
+
title: TITLE,
|
|
126
|
+
hints: "//: Show Skill Manpage · !!: Compose Skill Command · (keep typing the command)",
|
|
127
|
+
lines,
|
|
128
|
+
fit: true
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
return { reset, synopsis, track, render }
|
|
133
|
+
}
|
|
134
|
+
|