@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,561 @@
|
|
|
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
|
+
Synopsis form, showing for a skill command (bare or with its options and
|
|
9
|
+
arguments already typed, which prefill the form), once suffixed with
|
|
10
|
+
"!!", the SYNOPSIS of its manual page as a form in a master/detail view
|
|
11
|
+
in the band above the prompt, where LEFT/RIGHT steps through its options
|
|
12
|
+
and arguments (and shows their descriptions, scrolled by "b"/"f"),
|
|
13
|
+
SPACE toggles them, RETURN edits their values (with a drop-down box for
|
|
14
|
+
fixed values), RETURN at the end (or "!!" outside a value) takes over
|
|
15
|
+
the composed command into the prompt, and BACKSPACE cancels. */
|
|
16
|
+
|
|
17
|
+
import type { Elements, Frozen, PromptBox, PromptEditInput, RenderElement } from "claude-code"
|
|
18
|
+
import { type Engine, type Text, type Key, type BandRender, type BandScroll, type View, PICKED, COMMAND,
|
|
19
|
+
held, decode, section, wrap, uniform, flow, frame, paint, layout, wheel, panel } from "./ase-mods-common.ts"
|
|
20
|
+
|
|
21
|
+
/* the trigger of a synopsis form inside the prompt (a skill command suffixed
|
|
22
|
+
with "!!", and with its typed options and arguments, if any, separated by
|
|
23
|
+
a blank from it), the color of the drop-down box of a field with fixed
|
|
24
|
+
values, the title placed into the top edge of the frame, and the name of
|
|
25
|
+
the extra field with the typed words not recognized as of the synopsis */
|
|
26
|
+
const SYNOPSIS = /^(\/(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+))(?:\s+(\S(?:[^]*\S)?)\s+|\s*)!!$/
|
|
27
|
+
const CHOICES = "gray"
|
|
28
|
+
const TITLE = "[ ⧉ ASE: Compose Skill Command ]"
|
|
29
|
+
const UNKNOWN = "unrecognized"
|
|
30
|
+
|
|
31
|
+
/* a field of a synopsis form: an option (a flag or with a value) or an argument,
|
|
32
|
+
with its short option name, placeholder, fixed values, value suffix,
|
|
33
|
+
description, and state */
|
|
34
|
+
type Field = {
|
|
35
|
+
kind: "option" | "argument",
|
|
36
|
+
name: string,
|
|
37
|
+
short: string,
|
|
38
|
+
label: string,
|
|
39
|
+
choices: string[],
|
|
40
|
+
list: boolean,
|
|
41
|
+
valued: boolean,
|
|
42
|
+
suffix: string,
|
|
43
|
+
required: boolean,
|
|
44
|
+
help: string,
|
|
45
|
+
enabled: boolean,
|
|
46
|
+
value: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/* a synopsis form: the scroll state of its band, the skill command, the prompt
|
|
50
|
+
to restore on cancel (the command as typed), its fields, the focused field
|
|
51
|
+
(the apply position after the last field), the state of an edited field,
|
|
52
|
+
the focus and editing state shown (resetting the scrolling on change) of
|
|
53
|
+
the detail view, and the last key outside a field, if a "!" or "/" */
|
|
54
|
+
type Form = View & {
|
|
55
|
+
command: string,
|
|
56
|
+
typed: string,
|
|
57
|
+
fields: Field[],
|
|
58
|
+
pos: number,
|
|
59
|
+
edit: boolean,
|
|
60
|
+
buffer: string,
|
|
61
|
+
caret: number,
|
|
62
|
+
choice: number,
|
|
63
|
+
picked: Set<string>,
|
|
64
|
+
anchor: string,
|
|
65
|
+
pending: string
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* extract the descriptions of the (usually bulleted) items of a manual page
|
|
69
|
+
section, keyed by every match of a global pattern in the head line of each item */
|
|
70
|
+
const descriptions = (text: string, key: RegExp): Map<string, string> => {
|
|
71
|
+
const result = new Map<string, string>()
|
|
72
|
+
for (const item of text.split(/^(?=(?:- {3})?[`*[])/m).filter((item) => /^(?:- {3})?[`*[]/.test(item))) {
|
|
73
|
+
const [ head, ...body ] = item.split("\n")
|
|
74
|
+
const help = `${head!.replace(/^- {3}/, "").replace(/:$/, "")}\n\n` +
|
|
75
|
+
body.map((line) => line.replace(/^ {4}/, "")).join("\n").trim()
|
|
76
|
+
for (const m of head!.matchAll(key))
|
|
77
|
+
if (!result.has(m[1]!))
|
|
78
|
+
result.set(m[1]!, help)
|
|
79
|
+
}
|
|
80
|
+
return result
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/* split an argument line of a synopsis into its top-level units */
|
|
84
|
+
const units = (line: string): string[] => {
|
|
85
|
+
const result: string[] = []
|
|
86
|
+
let unit = ""
|
|
87
|
+
let depth = 0
|
|
88
|
+
for (const ch of line) {
|
|
89
|
+
if (ch === " " && depth === 0) {
|
|
90
|
+
if (unit !== "")
|
|
91
|
+
result.push(unit)
|
|
92
|
+
unit = ""
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
depth += ch === "[" ? 1 : ch === "]" ? -1 : 0
|
|
96
|
+
unit += ch
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
if (unit !== "")
|
|
100
|
+
result.push(unit)
|
|
101
|
+
return result
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* parse the SYNOPSIS of a manual page into the fields of a synopsis form, where
|
|
105
|
+
the fields of all synopsis variants are merged (those of further variants and
|
|
106
|
+
of alternative groups as optional ones), the "--help" option is skipped, and
|
|
107
|
+
an option with fixed values initially has its first value */
|
|
108
|
+
const synopsis = (manual: string): Field[] => {
|
|
109
|
+
const opthelp = descriptions(section(manual, "OPTIONS"), /`(--[\w-]+)`/g)
|
|
110
|
+
const arghelp = descriptions(section(manual, "ARGUMENTS"), /\*([^*]+)\*/g)
|
|
111
|
+
const result: Field[] = []
|
|
112
|
+
let variants = 0
|
|
113
|
+
for (const line of section(manual, "SYNOPSIS").split("\n").map((line) => line.trim())) {
|
|
114
|
+
const heading = /^`ase-[a-z0-9]+-[a-z0-9]+`$/.test(line)
|
|
115
|
+
variants += heading ? 1 : 0
|
|
116
|
+
if (line === "" || heading)
|
|
117
|
+
continue
|
|
118
|
+
const group = /^\(.*\)$/.test(line)
|
|
119
|
+
const parts = group ? line.slice(1, -1).split(" | ") : line.includes("`--") ? [ line ] : units(line)
|
|
120
|
+
for (const part of parts) {
|
|
121
|
+
const required = !group && variants <= 1 && !part.startsWith("[")
|
|
122
|
+
const inner = part.replace(/^\[(.*)\]$/, "$1")
|
|
123
|
+
const long = /`(--[\w-]+)/.exec(inner)?.[1]
|
|
124
|
+
if (long !== undefined) {
|
|
125
|
+
if (long === "--help" || result.some((field) => field.name === long))
|
|
126
|
+
continue
|
|
127
|
+
const short = /`(-\w)[`=]/.exec(inner)?.[1] ?? ""
|
|
128
|
+
const rest = inner.replace(/`-{1,2}[\w-]+(?:=[^`]*)?`/g, "").replace(/^[\s|]+/, "")
|
|
129
|
+
const preset = /`-{1,2}[\w-]+=([^`]+)`/.exec(inner)?.[1]
|
|
130
|
+
const choices = [ ...rest.matchAll(/`([\w.-]+)`/g) ].map((m) => m[1]!)
|
|
131
|
+
const label = choices.length > 0 ? choices.join("|") :
|
|
132
|
+
(rest.replace(/[`*]/g, "").replace(/^[\s=]+/, "").trim() || (preset ?? ""))
|
|
133
|
+
result.push({ kind: "option", name: long, short, label, choices, list: rest.includes("..."),
|
|
134
|
+
valued: label !== "", suffix: "", required, help: opthelp.get(long) ?? "",
|
|
135
|
+
enabled: required, value: choices[0] ?? "" })
|
|
136
|
+
}
|
|
137
|
+
else {
|
|
138
|
+
const label = inner.replace(/[`*]/g, "")
|
|
139
|
+
if (result.some((field) => field.name === label))
|
|
140
|
+
continue
|
|
141
|
+
const names = [ ...new Set([ ...inner.matchAll(/\*([^*]+)\*/g) ].map((m) => m[1]!)) ]
|
|
142
|
+
result.push({ kind: "argument", name: label, short: "", label, choices: [], list: inner.includes("..."),
|
|
143
|
+
valued: true, suffix: /^\*[^*]+\*`:`$/.test(inner) ? ":" : "", required,
|
|
144
|
+
help: names.flatMap((name) => arghelp.get(name) ?? []).join("\n\n"),
|
|
145
|
+
enabled: required, value: "" })
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
return result
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/* a word of a command line: its verbatim range and its unquoted text */
|
|
153
|
+
type Word = { start: number, end: number, text: string }
|
|
154
|
+
|
|
155
|
+
/* split a command line into its words (honoring double quotes with
|
|
156
|
+
backslash escapes, single quotes, and backslash escapes) */
|
|
157
|
+
const tokenize = (line: string): Word[] =>
|
|
158
|
+
[ ...line.matchAll(/(?:"(?:\\[^]|[^"\\])*"?|'[^']*'?|\\[^]|[^\s"'\\])+/g) ].map((m) => ({
|
|
159
|
+
start: m.index,
|
|
160
|
+
end: m.index + m[0].length,
|
|
161
|
+
text: m[0].replace(/"((?:\\[^]|[^"\\])*)"?|'([^']*)'?|\\([^])/g,
|
|
162
|
+
(_: string, double: string | undefined, single: string | undefined, escaped: string | undefined) =>
|
|
163
|
+
double !== undefined ? double.replace(/\\([^])/g, "$1") : single ?? escaped ?? "")
|
|
164
|
+
}))
|
|
165
|
+
|
|
166
|
+
/* prefill the fields of a synopsis form out of the typed options and arguments
|
|
167
|
+
of its command, like the option parsing of the skills: the options up to the
|
|
168
|
+
first non-option word (or "--", kept verbatim), by long name, unambiguous long
|
|
169
|
+
name prefix, or short name, with their value after "=" or as the next word
|
|
170
|
+
(fixed values case-insensitively), and the remaining words, starting with the
|
|
171
|
+
first unrecognized option, if any, distributed over the argument fields (each
|
|
172
|
+
taking a word, a field with a suffix only a word with it, and the last field
|
|
173
|
+
the rest verbatim), resp., without any, kept in an extra field */
|
|
174
|
+
const prefill = (fields: Field[], line: string): void => {
|
|
175
|
+
const words = tokenize(line)
|
|
176
|
+
const options = fields.filter((field) => field.kind === "option")
|
|
177
|
+
let i = 0
|
|
178
|
+
for (; i < words.length; i++) {
|
|
179
|
+
const word = words[i]!.text
|
|
180
|
+
if (word === "--" || !word.startsWith("-") || word === "-")
|
|
181
|
+
break
|
|
182
|
+
const m = /^(--[\w-]+|-\w)(?:=([^]*))?$/.exec(word)
|
|
183
|
+
if (m === null)
|
|
184
|
+
break
|
|
185
|
+
const name = m[1]!
|
|
186
|
+
const matches = name.startsWith("--") ? options.filter((field) => field.name.startsWith(name)) : []
|
|
187
|
+
const field = options.find((field) => field.name === name || field.short === name) ??
|
|
188
|
+
(matches.length === 1 ? matches[0] : undefined)
|
|
189
|
+
if (field === undefined)
|
|
190
|
+
break
|
|
191
|
+
const next = field.valued && m[2] === undefined
|
|
192
|
+
let value = m[2] ?? (next ? words[i + 1]?.text : undefined)
|
|
193
|
+
if (field.valued !== (value !== undefined))
|
|
194
|
+
break
|
|
195
|
+
if (value !== undefined && field.choices.length > 0) {
|
|
196
|
+
const values = (field.list ? value.split(",") : [ value ]).map((value) =>
|
|
197
|
+
field.choices.find((choice) => choice.toLowerCase() === value.toLowerCase()))
|
|
198
|
+
if (values.some((value) => value === undefined))
|
|
199
|
+
break
|
|
200
|
+
value = values.join(",")
|
|
201
|
+
}
|
|
202
|
+
i += next ? 1 : 0
|
|
203
|
+
field.enabled = true
|
|
204
|
+
field.value = value ?? ""
|
|
205
|
+
}
|
|
206
|
+
const rest = i < words.length ? line.slice(words[i]!.start) : ""
|
|
207
|
+
const params = fields.filter((field) => field.kind === "argument")
|
|
208
|
+
if (rest !== "" && params.length === 0) {
|
|
209
|
+
fields.push({ kind: "argument", name: UNKNOWN, short: "", label: UNKNOWN, choices: [], list: false,
|
|
210
|
+
valued: true, suffix: "", required: false, enabled: true, value: rest,
|
|
211
|
+
help: "Words not recognized as options or arguments of the skill, kept verbatim." })
|
|
212
|
+
return
|
|
213
|
+
}
|
|
214
|
+
const items = tokenize(rest)
|
|
215
|
+
let k = 0
|
|
216
|
+
for (const [ j, field ] of params.entries()) {
|
|
217
|
+
const item = items[k]
|
|
218
|
+
if (item === undefined)
|
|
219
|
+
break
|
|
220
|
+
if (j === params.length - 1)
|
|
221
|
+
field.value = rest.slice(item.start)
|
|
222
|
+
else if (item.text.endsWith(field.suffix)) {
|
|
223
|
+
field.value = rest.slice(item.start, item.end)
|
|
224
|
+
k++
|
|
225
|
+
}
|
|
226
|
+
else
|
|
227
|
+
continue
|
|
228
|
+
field.enabled = true
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/* compose the command of a synopsis form out of its enabled fields
|
|
233
|
+
(double-quoting option values with whitespace or quotes, and omitting
|
|
234
|
+
valued options without a value yet) */
|
|
235
|
+
const compose = (form: Form): string => {
|
|
236
|
+
const words = [ form.command ]
|
|
237
|
+
for (const field of form.fields.filter((field) => field.enabled)) {
|
|
238
|
+
const value = field.value !== "" && !field.value.endsWith(field.suffix) ? field.value + field.suffix : field.value
|
|
239
|
+
if (field.kind === "option" && (!field.valued || value !== ""))
|
|
240
|
+
words.push(value === "" ? field.name : `${field.name} ` +
|
|
241
|
+
(/[\s"'\\]/.test(value) ? `"${value.replace(/(["\\])/g, "\\$1")}"` : value))
|
|
242
|
+
else if (value !== "")
|
|
243
|
+
words.push(value)
|
|
244
|
+
}
|
|
245
|
+
return words.join(" ")
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/* determine whether a field can be toggled: always, except for enabling
|
|
249
|
+
a field with a free-text value which has not received a value yet */
|
|
250
|
+
const togglable = (field: Field): boolean =>
|
|
251
|
+
field.enabled || !field.valued || field.choices.length > 0 || field.value !== ""
|
|
252
|
+
|
|
253
|
+
/* operate a synopsis form by a key: outside a field, step through the fields
|
|
254
|
+
(LEFT/RIGHT), toggle the focused one (SPACE, if togglable), enter its editing
|
|
255
|
+
(RETURN, except for a flag), apply the form (RETURN at the end), or cancel the
|
|
256
|
+
form (BACKSPACE); inside a field, choose a fixed value (LEFT/RIGHT/UP/DOWN or
|
|
257
|
+
its initial letter) and pick it for a list (SPACE), resp. edit the text,
|
|
258
|
+
and leave the field with (RETURN) or, for fixed values, without (BACKSPACE)
|
|
259
|
+
taking over the value */
|
|
260
|
+
const operate = (form: Form, key: Key): "apply" | "cancel" | undefined => {
|
|
261
|
+
const field = form.fields[form.pos]
|
|
262
|
+
if (key.name === "return") {
|
|
263
|
+
if (field === undefined)
|
|
264
|
+
return "apply"
|
|
265
|
+
if (form.edit) {
|
|
266
|
+
field.value = field.choices.length === 0 ? form.buffer.trim() :
|
|
267
|
+
field.list && form.picked.size > 0 ? field.choices.filter((choice) => form.picked.has(choice)).join(",") :
|
|
268
|
+
field.choices[form.choice]!
|
|
269
|
+
field.enabled = field.required || field.value !== ""
|
|
270
|
+
form.edit = false
|
|
271
|
+
}
|
|
272
|
+
else if (field.valued) {
|
|
273
|
+
form.edit = true
|
|
274
|
+
form.buffer = field.value
|
|
275
|
+
form.caret = field.value.length
|
|
276
|
+
form.choice = Math.max(0, field.choices.indexOf(field.value))
|
|
277
|
+
form.picked = new Set(field.value.split(",").filter((value) => field.choices.includes(value)))
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
else if (!form.edit || field === undefined) {
|
|
281
|
+
if (key.step !== 0)
|
|
282
|
+
form.pos = Math.max(0, Math.min(form.fields.length, form.pos + key.step))
|
|
283
|
+
else if (key.text === " " && field !== undefined) {
|
|
284
|
+
if (togglable(field))
|
|
285
|
+
field.enabled = !field.enabled
|
|
286
|
+
}
|
|
287
|
+
else if (key.erase < 0)
|
|
288
|
+
return "cancel"
|
|
289
|
+
}
|
|
290
|
+
else if (field.choices.length > 0) {
|
|
291
|
+
const n = field.choices.length
|
|
292
|
+
if (key.step !== 0 || key.name === "up" || key.name === "down")
|
|
293
|
+
form.choice = (form.choice + n + (key.step < 0 || key.name === "up" ? -1 : 1)) % n
|
|
294
|
+
else if (key.text === " " && field.list) {
|
|
295
|
+
const choice = field.choices[form.choice]!
|
|
296
|
+
if (!form.picked.delete(choice))
|
|
297
|
+
form.picked.add(choice)
|
|
298
|
+
}
|
|
299
|
+
else if (key.erase < 0)
|
|
300
|
+
form.edit = false
|
|
301
|
+
else if (key.text.length === 1) {
|
|
302
|
+
const k = field.choices.findIndex((choice) => choice.toLowerCase().startsWith(key.text.toLowerCase()))
|
|
303
|
+
if (k !== -1)
|
|
304
|
+
form.choice = k
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
else {
|
|
308
|
+
const { buffer, caret } = form
|
|
309
|
+
if (key.step !== 0)
|
|
310
|
+
form.caret = Math.max(0, Math.min(buffer.length, caret + key.step))
|
|
311
|
+
else if (key.erase < 0 && caret > 0) {
|
|
312
|
+
form.buffer = buffer.slice(0, caret - 1) + buffer.slice(caret)
|
|
313
|
+
form.caret = caret - 1
|
|
314
|
+
}
|
|
315
|
+
else if (key.erase > 0)
|
|
316
|
+
form.buffer = buffer.slice(0, caret) + buffer.slice(caret + 1)
|
|
317
|
+
else if (key.text !== "") {
|
|
318
|
+
const text = key.text.replace(/\s+/g, " ")
|
|
319
|
+
form.buffer = buffer.slice(0, caret) + text + buffer.slice(caret)
|
|
320
|
+
form.caret = caret + text.length
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
return undefined
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/* draw the underlined input box of a field: its text with the (inverted)
|
|
327
|
+
caret while being edited, its chosen fixed value(s) while being chosen,
|
|
328
|
+
its value, or its italic placeholder (in the color of the field, i.e.,
|
|
329
|
+
grey while disabled, bold while enabled, and red while focused) */
|
|
330
|
+
const input = (Text: Text, form: Form, field: Field,
|
|
331
|
+
look: { color?: string, dimColor: boolean, bold: boolean }, focus: boolean): RenderElement => {
|
|
332
|
+
const { buffer, caret, choice, picked } = form
|
|
333
|
+
const editing = focus && form.edit
|
|
334
|
+
if (editing && field.choices.length === 0)
|
|
335
|
+
return Text({ children: [
|
|
336
|
+
[ buffer.slice(0, caret), false ] as const,
|
|
337
|
+
[ buffer[caret] ?? " ", true ] as const,
|
|
338
|
+
[ buffer.slice(caret + 1), false ] as const
|
|
339
|
+
].filter(([ text ]) => text !== "").map(([ text, inverse ]) =>
|
|
340
|
+
Text({ bold: look.bold, inverse, underline: true, children: text })) })
|
|
341
|
+
const value = !editing ? field.value :
|
|
342
|
+
field.list && picked.size > 0 ? field.choices.filter((choice) => picked.has(choice)).join(",") :
|
|
343
|
+
field.choices[choice]!
|
|
344
|
+
return value !== "" ?
|
|
345
|
+
Text({ ...look, underline: true, children: value }) :
|
|
346
|
+
Text({ ...look, underline: true, italic: true, children: field.label })
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/* draw a field of a synopsis form, as in the prompt: grey while disabled,
|
|
350
|
+
bold while enabled, and red (and marked by "▶") while focused, with options by their name
|
|
351
|
+
and, if valued, their input box, marked in front as a drop-down box (for
|
|
352
|
+
fixed values) resp. as a text field (for free-text values) */
|
|
353
|
+
const draw = (Text: Text, form: Form, field: Field, i: number): RenderElement => {
|
|
354
|
+
const focus = form.pos === i
|
|
355
|
+
const look = { color: focus ? PICKED : undefined, dimColor: !field.enabled && !focus, bold: field.enabled }
|
|
356
|
+
const children: RenderElement[] = [ Text({ color: look.color, children: focus ? "▶ " : " " }) ]
|
|
357
|
+
if (field.kind === "option")
|
|
358
|
+
children.push(Text({ ...look, children: field.name }))
|
|
359
|
+
if (field.kind === "option" && field.valued)
|
|
360
|
+
children.push(Text({ children: " " }))
|
|
361
|
+
if (field.valued)
|
|
362
|
+
children.push(Text({ color: look.color, dimColor: look.dimColor, children: field.choices.length > 0 ? "▾ " : "✎ " }),
|
|
363
|
+
input(Text, form, field, look, focus))
|
|
364
|
+
return Text({ wrap: "truncate-end", children })
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/* determine the key hints below the frame of a synopsis form (with the scroll
|
|
368
|
+
keys only while its detail view is scrollable, and the wheel only where the
|
|
369
|
+
band receives it) */
|
|
370
|
+
const hints = (form: Form, scrollable: boolean, wheel: boolean): string => {
|
|
371
|
+
const focused = form.fields[form.pos]
|
|
372
|
+
if (focused === undefined || !form.edit)
|
|
373
|
+
return `←/→${wheel ? "/wheel" : ""}: select` +
|
|
374
|
+
(focused?.valued === true ? " · ⏎: edit value" : "") +
|
|
375
|
+
` · ⌫: cancel+close · ${focused === undefined ? "⏎/!!" : "!!"}: apply+close` +
|
|
376
|
+
" · //: Show Skill Manpage" +
|
|
377
|
+
(focused !== undefined && togglable(focused) ?
|
|
378
|
+
` · SPACE: ${focused.enabled ? "deactivate" : "activate"}` : "") +
|
|
379
|
+
(scrollable ? ` · b/f${wheel ? "/wheel" : ""}: scroll bwd/fwd` : "")
|
|
380
|
+
return (focused.choices.length > 0 ? `⌫: abort · ⏎: accept · ←/→: choose${focused.list ? " · SPACE: pick" : ""}` :
|
|
381
|
+
"⏎: accept · ←/→: move · type the value") + (wheel && scrollable ? " · wheel: scroll" : "")
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/* the synopsis form: its state and its operations */
|
|
385
|
+
export const modHelpCompose = () => {
|
|
386
|
+
/* the synopsis fields of the skills (loaded on first use, undefined
|
|
387
|
+
for a skill without a manual page) and the currently shown form */
|
|
388
|
+
const synopses = new Map<string, Field[] | undefined>()
|
|
389
|
+
let form: Form | undefined
|
|
390
|
+
|
|
391
|
+
/* determine whether the synopsis form is shown */
|
|
392
|
+
const active = () => form !== undefined
|
|
393
|
+
|
|
394
|
+
/* close the synopsis form */
|
|
395
|
+
const reset = () => {
|
|
396
|
+
form = undefined
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/* close the synopsis form (as on a failure) and determine the prompt to
|
|
400
|
+
restore (the command as typed), if the form was shown */
|
|
401
|
+
const cancel = (): string | undefined => {
|
|
402
|
+
const typed = form?.typed
|
|
403
|
+
reset()
|
|
404
|
+
return typed
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/* hold the prompt with the composed command while the synopsis form is shown */
|
|
408
|
+
const hold = (current: Form): PromptBox => held(compose(current))
|
|
409
|
+
|
|
410
|
+
/* while the synopsis form is shown, every edit is a key of the form, where
|
|
411
|
+
outside a field "b"/"f" scroll the detail view of the form by a page, "!!"
|
|
412
|
+
(two "!" in a row) applies the form (as RETURN at its end does), and "//"
|
|
413
|
+
(two "/" in a row) closes the form with the composed command suffixed
|
|
414
|
+
with the trigger of the manual band (to switch over to it) */
|
|
415
|
+
const edit = (e: Frozen<PromptEditInput>): PromptBox => {
|
|
416
|
+
const current = form!
|
|
417
|
+
const key = decode(e)
|
|
418
|
+
const pending = current.pending
|
|
419
|
+
const repeat = current.edit ? "" :
|
|
420
|
+
/^([!/])\1$/.exec(key.text)?.[1] ?? (pending !== "" && key.text === pending ? pending : "")
|
|
421
|
+
current.pending = !current.edit && repeat === "" && /^[!/]$/.test(key.text) ? key.text : ""
|
|
422
|
+
if (current.pending !== "")
|
|
423
|
+
return hold(current)
|
|
424
|
+
if (!current.edit && (key.text === "b" || key.text === "f")) {
|
|
425
|
+
current.scroll = Math.max(0, current.scroll + (key.text === "b" ? -1 : 1) * current.page)
|
|
426
|
+
return hold(current)
|
|
427
|
+
}
|
|
428
|
+
if (repeat === "/") {
|
|
429
|
+
form = undefined
|
|
430
|
+
const text = `${compose(current)} //`
|
|
431
|
+
return { text, cursor: text.length }
|
|
432
|
+
}
|
|
433
|
+
const result = repeat === "!" ? "apply" : operate(current, key)
|
|
434
|
+
if (result !== undefined) {
|
|
435
|
+
form = undefined
|
|
436
|
+
const text = result === "apply" ? `${compose(current)} ` : current.typed
|
|
437
|
+
return { text, cursor: text.length }
|
|
438
|
+
}
|
|
439
|
+
return hold(current)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/* for a skill command with a manual page, once suffixed with the trigger,
|
|
443
|
+
show its synopsis form, prefilled with the typed options and arguments
|
|
444
|
+
(and hold the prompt) */
|
|
445
|
+
const open = async (box: PromptBox, engine: Pick<Engine, "read">): Promise<PromptBox | undefined> => {
|
|
446
|
+
const command = SYNOPSIS.exec(box.text)
|
|
447
|
+
if (command === null)
|
|
448
|
+
return undefined
|
|
449
|
+
const skill = command[2]!
|
|
450
|
+
if (!synopses.has(skill)) {
|
|
451
|
+
const manual = await engine.read(`skills/${skill}/help.md`).catch(() => undefined)
|
|
452
|
+
synopses.set(skill, manual !== undefined ? synopsis(manual) : undefined)
|
|
453
|
+
}
|
|
454
|
+
const fields = synopses.get(skill)
|
|
455
|
+
if (fields === undefined)
|
|
456
|
+
return undefined
|
|
457
|
+
const args = command[3] ?? ""
|
|
458
|
+
const filled = fields.map((field) => ({ ...field }))
|
|
459
|
+
prefill(filled, args)
|
|
460
|
+
form = { command: command[1]!, typed: `${command[1]!} ${args !== "" ? `${args} ` : ""}`, fields: filled,
|
|
461
|
+
pos: 0, edit: false, buffer: "", caret: 0, choice: 0, picked: new Set(),
|
|
462
|
+
top: 0, scroll: 0, page: 1, anchor: "", split: 0, pending: "" }
|
|
463
|
+
return hold(form)
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/* operate the synopsis form by the wheel: over the master view step its
|
|
467
|
+
focused field, over the detail view scroll it, resp. scroll it by a page
|
|
468
|
+
with the scroll keys (PAGEUP/PAGEDOWN) while the band is focused
|
|
469
|
+
(and tell whether the form took the scrolling) */
|
|
470
|
+
const scroll = (e: BandScroll): boolean => {
|
|
471
|
+
if (form === undefined)
|
|
472
|
+
return false
|
|
473
|
+
const current = form
|
|
474
|
+
wheel(e, current, current.edit ? undefined : (by) =>
|
|
475
|
+
operate(current, { name: "", text: "", step: by, erase: 0 }))
|
|
476
|
+
return true
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/* operate the synopsis form by RETURN at the prompt (closing the form
|
|
480
|
+
once applied or cancelled) and determine the prompt to refill (the held
|
|
481
|
+
prompt while the form stays shown, but without its space after the
|
|
482
|
+
cursor, else with a single trailing space only, as the refill places
|
|
483
|
+
the cursor at its end) */
|
|
484
|
+
const submit = (): string => {
|
|
485
|
+
const current = form!
|
|
486
|
+
const result = operate(current, { name: "return", text: "", step: 0, erase: 0 })
|
|
487
|
+
if (result !== undefined)
|
|
488
|
+
form = undefined
|
|
489
|
+
return form !== undefined ? hold(current).text.trimEnd() : `${compose(current)} `
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/* draw the synopsis form into the band above the prompt (terminal only) */
|
|
493
|
+
const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
|
|
494
|
+
/* yield to a survey and draw nothing without a synopsis form */
|
|
495
|
+
if (form === undefined || e.props.hasSurvey)
|
|
496
|
+
return undefined
|
|
497
|
+
const current = form
|
|
498
|
+
const focused = current.fields[current.pos]
|
|
499
|
+
const editing = focused !== undefined && current.edit
|
|
500
|
+
|
|
501
|
+
/* determine the geometry of the band (with master and detail view of equal widths) */
|
|
502
|
+
const { Text } = ui
|
|
503
|
+
const geometry = layout(e, 0.5)
|
|
504
|
+
const { body, detail } = geometry
|
|
505
|
+
|
|
506
|
+
/* determine the lines of the master view (the command after the prompt
|
|
507
|
+
marker, colored as in the prompt, and indented below it each field on
|
|
508
|
+
its own line and the apply position), with the drop-down box of a field
|
|
509
|
+
with fixed values while being chosen directly below the field (aligned
|
|
510
|
+
with its input box), and the line of the focused field */
|
|
511
|
+
const entries: RenderElement[] = [
|
|
512
|
+
Text({ wrap: "truncate-end", children: [ "❯ ",
|
|
513
|
+
Text({ color: COMMAND, children: current.command }) ] }),
|
|
514
|
+
...current.fields.map((field, i) => draw(Text, current, field, i)),
|
|
515
|
+
Text({ bold: true, color: focused === undefined ? PICKED : undefined,
|
|
516
|
+
children: focused === undefined ? "▶ ⏎" : " ⏎" })
|
|
517
|
+
]
|
|
518
|
+
const row = current.pos + 1
|
|
519
|
+
let popup = 0
|
|
520
|
+
if (editing && focused.choices.length > 0) {
|
|
521
|
+
const items = focused.choices.map((choice) =>
|
|
522
|
+
focused.list ? `${current.picked.has(choice) ? "☑" : "☐"} ${choice}` : choice)
|
|
523
|
+
const indent = 2 + (focused.kind === "option" ? focused.name.length + 1 : 0) + 2
|
|
524
|
+
const box = frame(items.map((item, k) => uniform(item, false, k === current.choice ? PICKED : undefined)),
|
|
525
|
+
Math.max(...items.map((item) => item.length)) + 4, CHOICES)
|
|
526
|
+
popup = box.length
|
|
527
|
+
entries.splice(row + 1, 0, ...box.map((line) =>
|
|
528
|
+
Text({ wrap: "truncate-end", children: [ " ".repeat(indent), ...paint(Text, line) ] })))
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/* determine the lines of the detail view: the description of the focused
|
|
532
|
+
field (resp. the hint of the apply position, dimmed as the key hints) */
|
|
533
|
+
const lines = focused === undefined ?
|
|
534
|
+
wrap(uniform("Take over the composed command into the prompt.", false, undefined), detail, 0) :
|
|
535
|
+
flow(focused.help !== "" ? focused.help : `*${focused.label}*`, detail)
|
|
536
|
+
|
|
537
|
+
/* reset the scroll position of the detail view once the focused field or its
|
|
538
|
+
editing changes, and keep the focused field and, if possible, its drop-down
|
|
539
|
+
box in the master view */
|
|
540
|
+
const anchor = `${current.pos}/${current.edit}`
|
|
541
|
+
if (current.anchor !== anchor) {
|
|
542
|
+
current.anchor = anchor
|
|
543
|
+
current.scroll = 0
|
|
544
|
+
}
|
|
545
|
+
current.top = Math.max(0, Math.min(row,
|
|
546
|
+
Math.max(Math.min(current.top, row), row + popup - body + 1)))
|
|
547
|
+
|
|
548
|
+
/* draw both views (the master view with the synopsis, and the detail
|
|
549
|
+
view), and the key hints */
|
|
550
|
+
return panel(ui, geometry, current, {
|
|
551
|
+
title: TITLE,
|
|
552
|
+
hints: hints(current, lines.length > body, geometry.overflow),
|
|
553
|
+
rows: (i) => entries[current.top + i],
|
|
554
|
+
lines,
|
|
555
|
+
dim: focused === undefined
|
|
556
|
+
})
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
return { active, reset, cancel, edit, open, scroll, submit, render }
|
|
560
|
+
}
|
|
561
|
+
|