@rse/ase 2.0.3 → 2.0.5

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.
Files changed (198) hide show
  1. package/dst/ase-hook.js +13 -4
  2. package/dst/ase-lib-version.js +14 -5
  3. package/dst/ase-man-catalog.js +229 -0
  4. package/dst/ase-man-render.js +278 -0
  5. package/dst/ase-man-tui.js +636 -0
  6. package/dst/ase-man.js +33 -27
  7. package/dst/ase-setup.js +2 -2
  8. package/dst/ase-spec.js +103 -20
  9. package/dst/ase-task-board-filter.js +5 -4
  10. package/dst/ase-task-board-web-client.js +26 -26
  11. package/dst/man/ase-agent-adhoc.md +1165 -0
  12. package/dst/man/ase-agent-condenser.md +383 -0
  13. package/dst/man/ase-agent-editor.md +441 -0
  14. package/dst/man/ase-agent-estimator.md +477 -0
  15. package/dst/man/ase-agent-fleet.md +358 -0
  16. package/dst/man/ase-agent-implementer.md +521 -0
  17. package/dst/man/ase-agent-integrator.md +449 -0
  18. package/dst/man/ase-agent-qualifier.md +426 -0
  19. package/dst/man/ase-agent-rebooter.md +384 -0
  20. package/dst/man/ase-agent-relay.md +262 -0
  21. package/dst/man/ase-agent-researcher.md +478 -0
  22. package/dst/man/ase-agent.md +628 -0
  23. package/dst/man/ase-artifact-list.md +57 -0
  24. package/dst/man/ase-artifact-name.md +56 -0
  25. package/dst/man/ase-artifact.md +92 -0
  26. package/dst/man/ase-config-delete.md +74 -0
  27. package/dst/man/ase-config-edit.md +83 -0
  28. package/dst/man/ase-config-get.md +70 -0
  29. package/dst/man/ase-config-init.md +110 -0
  30. package/dst/man/ase-config-list.md +68 -0
  31. package/dst/man/ase-config-set.md +92 -0
  32. package/dst/man/ase-config.md +426 -0
  33. package/dst/man/ase-hook-permission-request.md +61 -0
  34. package/dst/man/ase-hook-pre-tool-use.md +96 -0
  35. package/dst/man/ase-hook-session-end.md +71 -0
  36. package/dst/man/ase-hook-session-persona.md +65 -0
  37. package/dst/man/ase-hook-session-start.md +184 -0
  38. package/dst/man/ase-hook-stop.md +52 -0
  39. package/dst/man/ase-hook-user-prompt-submit.md +54 -0
  40. package/dst/man/ase-hook.md +93 -0
  41. package/dst/man/ase-man.md +195 -0
  42. package/dst/man/ase-mcp.md +102 -0
  43. package/dst/man/ase-service-log.md +59 -0
  44. package/dst/man/ase-service-send.md +60 -0
  45. package/dst/man/ase-service-start.md +93 -0
  46. package/dst/man/ase-service-status.md +58 -0
  47. package/dst/man/ase-service-stop.md +52 -0
  48. package/dst/man/ase-service.md +124 -0
  49. package/dst/man/ase-setup-disable.md +67 -0
  50. package/dst/man/ase-setup-enable.md +66 -0
  51. package/dst/man/ase-setup-install.md +116 -0
  52. package/dst/man/ase-setup-mcp.md +213 -0
  53. package/dst/man/ase-setup-status.md +101 -0
  54. package/dst/man/ase-setup-statusline.md +162 -0
  55. package/dst/man/ase-setup-telemetry.md +166 -0
  56. package/dst/man/ase-setup-uninstall.md +85 -0
  57. package/dst/man/ase-setup-update.md +117 -0
  58. package/dst/man/ase-setup.md +156 -0
  59. package/dst/man/ase-spec-export.md +99 -0
  60. package/dst/man/ase-spec-init.md +57 -0
  61. package/dst/man/ase-spec-lint.md +74 -0
  62. package/dst/man/ase-spec-preview.md +57 -0
  63. package/dst/man/ase-spec.md +85 -0
  64. package/dst/man/ase-statusline.md +330 -0
  65. package/dst/man/ase-task-assign.md +138 -0
  66. package/dst/man/ase-task-board.md +651 -0
  67. package/dst/man/ase-task-delete.md +46 -0
  68. package/dst/man/ase-task-edit.md +73 -0
  69. package/dst/man/ase-task-idscheme.md +92 -0
  70. package/dst/man/ase-task-lifecycle.md +92 -0
  71. package/dst/man/ase-task-list.md +86 -0
  72. package/dst/man/ase-task-load.md +48 -0
  73. package/dst/man/ase-task-newid.md +100 -0
  74. package/dst/man/ase-task-purge.md +49 -0
  75. package/dst/man/ase-task-rename.md +69 -0
  76. package/dst/man/ase-task-request.md +108 -0
  77. package/dst/man/ase-task-save.md +78 -0
  78. package/dst/man/ase-task-status.md +79 -0
  79. package/dst/man/ase-task-store.md +377 -0
  80. package/dst/man/ase-task-view.md +50 -0
  81. package/dst/man/ase-task.md +335 -0
  82. package/dst/man/ase-util-compat.md +38 -0
  83. package/dst/man/ase-util-diagram.md +140 -0
  84. package/dst/man/ase-util-meta.md +68 -0
  85. package/dst/man/ase-util-metric.md +74 -0
  86. package/dst/man/ase-util-mint.md +85 -0
  87. package/dst/man/ase-util-worktree.md +109 -0
  88. package/dst/man/ase-util.md +81 -0
  89. package/dst/man/ase.md +228 -0
  90. package/package.json +16 -21
  91. package/plugin/.claude-plugin/plugin.json +1 -1
  92. package/plugin/.codex-plugin/plugin.json +1 -1
  93. package/plugin/.github/plugin/plugin.json +1 -1
  94. package/plugin/agents/ase-code-lint.md +3 -3
  95. package/plugin/agents/ase-docs-proofread.md +3 -3
  96. package/plugin/agents/ase-docs-refine.md +3 -3
  97. package/plugin/agents/ase-spec-lint.md +338 -0
  98. package/plugin/mods/ase-mods-common.ts +444 -0
  99. package/plugin/mods/ase-mods-grill.ts +312 -0
  100. package/plugin/mods/ase-mods-help-browse.ts +405 -0
  101. package/plugin/mods/ase-mods-help-compose.ts +561 -0
  102. package/plugin/mods/ase-mods-help-find.ts +426 -0
  103. package/plugin/mods/ase-mods-help-hint.ts +134 -0
  104. package/plugin/mods/ase-mods-help-show.ts +142 -0
  105. package/plugin/mods/ase-mods.ts +214 -381
  106. package/plugin/mods/claude-code.d.ts +26 -3
  107. package/plugin/skills/ase-code-lint/SKILL.md +15 -15
  108. package/plugin/skills/ase-code-lint/help.md +21 -8
  109. package/plugin/skills/ase-help-intent/SKILL.md +2 -1
  110. package/plugin/skills/ase-help-intent/data.md +214 -9
  111. package/plugin/skills/ase-help-skill/catalog.md +1 -0
  112. package/plugin/skills/ase-meta-workflow/workflow.txt +2 -0
  113. package/plugin/skills/ase-repo-commit/SKILL.md +1 -0
  114. package/plugin/skills/ase-repo-merge/SKILL.md +1 -0
  115. package/plugin/skills/ase-spec-edit/SKILL.md +3 -3
  116. package/plugin/skills/ase-spec-edit/help.md +1 -1
  117. package/plugin/skills/ase-spec-lint/SKILL.md +695 -0
  118. package/plugin/skills/ase-spec-lint/help.md +184 -0
  119. package/plugin/skills/ase-task-implement/SKILL.md +24 -6
  120. package/plugin/skills/ase-task-implement/help.md +11 -1
  121. package/dst/man/ase-agent-adhoc.1 +0 -515
  122. package/dst/man/ase-agent-condenser.1 +0 -193
  123. package/dst/man/ase-agent-editor.1 +0 -212
  124. package/dst/man/ase-agent-estimator.1 +0 -214
  125. package/dst/man/ase-agent-fleet.1 +0 -185
  126. package/dst/man/ase-agent-implementer.1 +0 -233
  127. package/dst/man/ase-agent-integrator.1 +0 -213
  128. package/dst/man/ase-agent-qualifier.1 +0 -214
  129. package/dst/man/ase-agent-rebooter.1 +0 -193
  130. package/dst/man/ase-agent-relay.1 +0 -128
  131. package/dst/man/ase-agent-researcher.1 +0 -217
  132. package/dst/man/ase-agent.1 +0 -264
  133. package/dst/man/ase-artifact-list.1 +0 -38
  134. package/dst/man/ase-artifact-name.1 +0 -39
  135. package/dst/man/ase-artifact.1 +0 -56
  136. package/dst/man/ase-config-delete.1 +0 -45
  137. package/dst/man/ase-config-edit.1 +0 -48
  138. package/dst/man/ase-config-get.1 +0 -46
  139. package/dst/man/ase-config-init.1 +0 -60
  140. package/dst/man/ase-config-list.1 +0 -42
  141. package/dst/man/ase-config-set.1 +0 -52
  142. package/dst/man/ase-config.1 +0 -231
  143. package/dst/man/ase-hook-permission-request.1 +0 -36
  144. package/dst/man/ase-hook-pre-tool-use.1 +0 -59
  145. package/dst/man/ase-hook-session-end.1 +0 -47
  146. package/dst/man/ase-hook-session-persona.1 +0 -37
  147. package/dst/man/ase-hook-session-start.1 +0 -81
  148. package/dst/man/ase-hook-stop.1 +0 -34
  149. package/dst/man/ase-hook-user-prompt-submit.1 +0 -34
  150. package/dst/man/ase-hook.1 +0 -51
  151. package/dst/man/ase-man.1 +0 -67
  152. package/dst/man/ase-mcp.1 +0 -61
  153. package/dst/man/ase-service-log.1 +0 -43
  154. package/dst/man/ase-service-send.1 +0 -47
  155. package/dst/man/ase-service-start.1 +0 -54
  156. package/dst/man/ase-service-status.1 +0 -46
  157. package/dst/man/ase-service-stop.1 +0 -37
  158. package/dst/man/ase-service.1 +0 -85
  159. package/dst/man/ase-setup-disable.1 +0 -44
  160. package/dst/man/ase-setup-enable.1 +0 -45
  161. package/dst/man/ase-setup-install.1 +0 -73
  162. package/dst/man/ase-setup-mcp.1 +0 -124
  163. package/dst/man/ase-setup-status.1 +0 -60
  164. package/dst/man/ase-setup-statusline.1 +0 -92
  165. package/dst/man/ase-setup-telemetry.1 +0 -92
  166. package/dst/man/ase-setup-uninstall.1 +0 -59
  167. package/dst/man/ase-setup-update.1 +0 -64
  168. package/dst/man/ase-setup.1 +0 -93
  169. package/dst/man/ase-spec-export.1 +0 -62
  170. package/dst/man/ase-spec-lint.1 +0 -40
  171. package/dst/man/ase-spec-preview.1 +0 -40
  172. package/dst/man/ase-spec.1 +0 -46
  173. package/dst/man/ase-statusline.1 +0 -189
  174. package/dst/man/ase-task-assign.1 +0 -71
  175. package/dst/man/ase-task-board.1 +0 -384
  176. package/dst/man/ase-task-delete.1 +0 -35
  177. package/dst/man/ase-task-edit.1 +0 -46
  178. package/dst/man/ase-task-idscheme.1 +0 -65
  179. package/dst/man/ase-task-lifecycle.1 +0 -57
  180. package/dst/man/ase-task-list.1 +0 -47
  181. package/dst/man/ase-task-load.1 +0 -36
  182. package/dst/man/ase-task-newid.1 +0 -61
  183. package/dst/man/ase-task-purge.1 +0 -37
  184. package/dst/man/ase-task-rename.1 +0 -44
  185. package/dst/man/ase-task-request.1 +0 -61
  186. package/dst/man/ase-task-save.1 +0 -48
  187. package/dst/man/ase-task-status.1 +0 -55
  188. package/dst/man/ase-task-store.1 +0 -194
  189. package/dst/man/ase-task-view.1 +0 -40
  190. package/dst/man/ase-task.1 +0 -160
  191. package/dst/man/ase-util-compat.1 +0 -25
  192. package/dst/man/ase-util-diagram.1 +0 -76
  193. package/dst/man/ase-util-meta.1 +0 -45
  194. package/dst/man/ase-util-metric.1 +0 -54
  195. package/dst/man/ase-util-mint.1 +0 -59
  196. package/dst/man/ase-util-worktree.1 +0 -72
  197. package/dst/man/ase-util.1 +0 -50
  198. package/dst/man/ase.1 +0 -124
@@ -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
+