@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,405 @@
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
+ Manpage browser, showing for a prompt of just "//", or of a free text
9
+ suffixed with a blank and "//", the manual pages of all ASE skills in a
10
+ master/detail view in the band above the prompt, filtered by a filter
11
+ field (initially the free text) with a fuzzy search (like "ase man"),
12
+ where typed text edits the filter, LEFT/RIGHT (or the wheel over the
13
+ skills) selects a skill, UP/DOWN (or the wheel over the manual page)
14
+ scroll its manual page (with the matches of the filter marked), RETURN
15
+ takes over its command into the prompt, "!!" switches over to the
16
+ intent help for the filter, and "//" leaves the filter in the prompt
17
+ (which mirrors the filter while the browser is shown). */
18
+
19
+ import type { Elements, Frozen, PromptBox, PromptEditInput, RenderElement } from "claude-code"
20
+ import { type Engine, type Char, type BandRender, type BandScroll, type View, PICKED,
21
+ held, decode, uniform, wrap, layout, wheel, panel } from "./ase-mods-common.ts"
22
+ import { page } from "./ase-mods-help-show.ts"
23
+
24
+ /* the trigger of a manpage browser inside the prompt (just "//", or a free text
25
+ suffixed with a blank and "//"), the skill directories (relative to the plugin
26
+ directory), the title placed into the top edge of the frame, and the share of
27
+ the band columns taken by the master view */
28
+ const BROWSE = /^(?:([^]*\S)\s+)?\/\/$/
29
+ const SKILLS = "skills"
30
+ const TITLE = "[ ⧉ ASE: Browse Skill Manpages ]"
31
+ const MASTER = 0.3
32
+
33
+ /* the manual page of a skill: its name, its Markdown source, and the lower-cased
34
+ Markdown source and its words (for searching) */
35
+ type Doc = { skill: string, manual: string, text: string, words: Set<string> }
36
+
37
+ /* a manpage browser: the scroll state of its band, the filter, the selected
38
+ manual page (by its index into the matching ones), the last key, if a "/"
39
+ or "!", and the selection and filter shown (resetting the scrolling on
40
+ change) of the detail view */
41
+ type Browse = View & {
42
+ filter: string,
43
+ select: number,
44
+ pending: string,
45
+ anchor: string
46
+ }
47
+
48
+ /* the minimum length of a word for its fuzzy matching, the Levenshtein edit
49
+ distance, and the Sorensen-Dice bigram similarity coefficient of two words
50
+ (copies of "fuzzyMin", "levenshtein", and "diceCoefficient" of
51
+ "tool/src/ase-task-board-filter.ts", so keep them in sync) */
52
+ const fuzzyMin = 4
53
+ const levenshtein = (a: string, b: string): number => {
54
+ let prev = [] as number[]
55
+ let curr = [] as number[]
56
+ for (let j = 0; j <= b.length; j++)
57
+ prev[j] = j
58
+ for (let i = 1; i <= a.length; i++) {
59
+ curr[0] = i
60
+ for (let j = 1; j <= b.length; j++)
61
+ curr[j] = Math.min(prev[j]! + 1, curr[j - 1]! + 1,
62
+ prev[j - 1]! + (a[i - 1] === b[j - 1] ? 0 : 1))
63
+ const swap = prev
64
+ prev = curr
65
+ curr = swap
66
+ }
67
+ return prev[b.length]!
68
+ }
69
+ const diceCoefficient = (a: string, b: string): number => {
70
+ if (a === b)
71
+ return 1
72
+ if (a.length < 2 || b.length < 2)
73
+ return 0
74
+ const bigrams = new Map<string, number>()
75
+ for (let i = 0; i < a.length - 1; i++) {
76
+ const bigram = a.substring(i, i + 2)
77
+ bigrams.set(bigram, (bigrams.get(bigram) ?? 0) + 1)
78
+ }
79
+ let hits = 0
80
+ for (let i = 0; i < b.length - 1; i++) {
81
+ const bigram = b.substring(i, i + 2)
82
+ const count = bigrams.get(bigram) ?? 0
83
+ if (count > 0) {
84
+ bigrams.set(bigram, count - 1)
85
+ hits++
86
+ }
87
+ }
88
+ return (2 * hits) / (a.length + b.length - 2)
89
+ }
90
+
91
+ /* escape a string for its literal use within a regular expression */
92
+ const escape = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
93
+
94
+ /* the regular expression of the matches of a search in a page: the keywords as
95
+ plain substrings, plus the fuzzy variants of the page as whole words, with the
96
+ longest alternative first, so a variant is never cut short by a keyword nested
97
+ inside it (a copy of "matchRE" of "tool/src/ase-man-catalog.ts", so keep it in sync) */
98
+ const matchRE = (keywords: string[], variants: string[]): RegExp => {
99
+ const alts = keywords.map((k) => ({ len: k.length, src: escape(k) }))
100
+ .concat(variants.map((w) => ({ len: w.length, src: "(?<![a-z0-9])" + escape(w) + "(?![a-z0-9])" })))
101
+ alts.sort((a, b) => b.len - a.len)
102
+ return new RegExp(alts.map((a) => a.src).join("|"), "gi")
103
+ }
104
+
105
+ /* search all pages for a query (like "ase man"): all whitespace-separated keywords
106
+ have to occur in a page (AND logic), in up to two passes, the keywords as literal
107
+ substrings first and, only if this matches no page at all, as fuzzy variants of
108
+ their words; the result maps the skills of the matching pages onto the regular
109
+ expressions of their matches, or is null for a query without any keywords (a copy
110
+ of "searchPages" of "tool/src/ase-man-catalog.ts", so keep it in sync) */
111
+ const search = (docs: Doc[], vocab: Set<string>, query: string): Map<string, RegExp> | null => {
112
+ const keywords = query.toLowerCase().split(/\s+/).filter((word) => word !== "")
113
+ if (keywords.length === 0)
114
+ return null
115
+ const hits = new Map<string, RegExp>()
116
+ for (const doc of docs)
117
+ if (keywords.every((k) => doc.text.includes(k)))
118
+ hits.set(doc.skill, matchRE(keywords, []))
119
+ if (hits.size > 0)
120
+ return hits
121
+
122
+ /* the fuzzy-equivalent vocabulary words of a keyword, cached per keyword */
123
+ const fuzzy = new Map<string, string[]>()
124
+ const fuzzyWords = (word: string): string[] => {
125
+ let result = fuzzy.get(word)
126
+ if (result === undefined) {
127
+ result = word.length < fuzzyMin ? [] : [ ...vocab ].filter((given) =>
128
+ Math.abs(word.length - given.length) <= 1
129
+ && (diceCoefficient(word, given) >= 0.50 || levenshtein(word, given) <= 2))
130
+ fuzzy.set(word, result)
131
+ }
132
+ return result
133
+ }
134
+
135
+ /* the regular expression of the fuzzy variants of a compound keyword (like
136
+ "ase-task-edit"), with each of its words literally or fuzzily replaced
137
+ and its separators kept, cached per keyword (null for a single word) */
138
+ const compound = new Map<string, RegExp | null>()
139
+ const compoundRE = (keyword: string): RegExp | null => {
140
+ let result = compound.get(keyword)
141
+ if (result === undefined) {
142
+ const parts = keyword.split(/([^a-z0-9]+)/)
143
+ result = parts.length < 2 ? null : new RegExp("(?<![a-z0-9])" + parts.map((part, i) =>
144
+ i % 2 === 1 ? escape(part) : part === "" ? "" :
145
+ "(?:" + [ part, ...fuzzyWords(part) ].map(escape).join("|") + ")").join("") + "(?![a-z0-9])", "g")
146
+ compound.set(keyword, result)
147
+ }
148
+ return result
149
+ }
150
+ for (const doc of docs) {
151
+ const variants = [] as string[]
152
+ const hit = keywords.every((k) => {
153
+ if (doc.text.includes(k))
154
+ return true
155
+ const re = compoundRE(k)
156
+ const near = re !== null ? [ ...new Set([ ...doc.text.matchAll(re) ].map((m) => m[0])) ] :
157
+ fuzzyWords(k).filter((w) => doc.words.has(w))
158
+ variants.push(...near)
159
+ return near.length > 0
160
+ })
161
+ if (hit)
162
+ hits.set(doc.skill, matchRE(keywords, variants))
163
+ }
164
+ return hits
165
+ }
166
+
167
+ /* mark the matches of a (global) regular expression within a line of styled
168
+ characters (in bold and the color of a picked item) */
169
+ const mark = (line: Char[], re: RegExp): Char[] => {
170
+ const text = line.map((char) => char.ch).join("")
171
+ const hits = new Set<number>()
172
+ for (const m of text.matchAll(re))
173
+ for (let i = m.index; i < m.index + m[0].length; i++)
174
+ hits.add(i)
175
+ if (hits.size === 0)
176
+ return line
177
+ let pos = 0
178
+ return line.map((char) => {
179
+ const hit = hits.has(pos)
180
+ pos += char.ch.length
181
+ return hit ? { ...char, bold: true, color: PICKED } : char
182
+ })
183
+ }
184
+
185
+ /* the manpage browser: its state and its operations */
186
+ export const modHelpBrowse = () => {
187
+ /* the manual pages of all skills and their vocabulary (loaded on first use),
188
+ and the currently shown browser */
189
+ let docs: Doc[] | undefined
190
+ let vocab: Set<string> = new Set()
191
+ let browse: Browse | undefined
192
+
193
+ /* determine whether the manpage browser is shown */
194
+ const active = () => browse !== undefined
195
+
196
+ /* close the manpage browser */
197
+ const reset = () => {
198
+ browse = undefined
199
+ }
200
+
201
+ /* determine the prompt to leave on closing the manpage browser: its filter
202
+ (with a single trailing blank to type on), if any */
203
+ const leave = (current: Browse): string =>
204
+ current.filter.trim() !== "" ? `${current.filter.trimEnd()} ` : ""
205
+
206
+ /* close the manpage browser (as on a failure) and determine the prompt to
207
+ restore (the filter), if it was shown */
208
+ const cancel = (): string | undefined => {
209
+ const text = browse !== undefined ? leave(browse) : undefined
210
+ reset()
211
+ return text
212
+ }
213
+
214
+ /* determine the matching manual pages of the filter (all of them for an empty
215
+ filter), with the regular expressions of their matches, cached per filter
216
+ (and manual pages) */
217
+ let cache: { filter: string, docs: Doc[] | undefined, list: { doc: Doc, re: RegExp | undefined }[] } | undefined
218
+ const matching = (current: Browse): { doc: Doc, re: RegExp | undefined }[] => {
219
+ if (cache?.filter !== current.filter || cache.docs !== docs) {
220
+ const hits = search(docs ?? [], vocab, current.filter)
221
+ cache = { filter: current.filter, docs, list: (docs ?? []).filter((doc) => hits === null || hits.has(doc.skill))
222
+ .map((doc) => ({ doc, re: hits?.get(doc.skill) })) }
223
+ }
224
+ return cache.list
225
+ }
226
+
227
+ /* hold the prompt as the current filter while the browser is shown */
228
+ const hold = (current: Browse): PromptBox => held(current.filter.trimEnd())
229
+
230
+ /* take over the command of the selected manual page into the prompt (closing
231
+ the browser), if any page matches, and determine the prompt to show */
232
+ const take = (current: Browse): PromptBox => {
233
+ const doc = matching(current)[current.select]?.doc
234
+ if (doc === undefined)
235
+ return hold(current)
236
+ reset()
237
+ const text = `/${doc.skill} `
238
+ return { text, cursor: text.length }
239
+ }
240
+
241
+ /* while the manpage browser is shown, every edit is a key of the browser:
242
+ LEFT/RIGHT select a manual page, UP/DOWN scroll it by a line, RETURN takes
243
+ over the command of the selected page, "//" (two "/" in a row) closes the
244
+ browser and leaves the filter in the prompt, "!!" (two "!"
245
+ in a row, for a non-empty filter) closes the browser with the filter suffixed
246
+ with the trigger of the intent help (to switch over to it), a deletion (like
247
+ BACKSPACE) deletes the last character of the filter, and any other typed
248
+ text is appended to the filter */
249
+ const edit = (e: Frozen<PromptEditInput>): PromptBox => {
250
+ const current = browse!
251
+ const key = decode(e)
252
+ const pending = current.pending
253
+ current.pending = (key.text === "/" || (key.text === "!" && current.filter.trim() !== "")) &&
254
+ key.text !== pending ? key.text : ""
255
+ if (current.pending !== "") {
256
+ if (pending !== "") {
257
+ current.filter += pending
258
+ current.select = 0
259
+ }
260
+ return hold(current)
261
+ }
262
+ if (key.text === "//" || (key.text === "/" && pending === "/")) {
263
+ reset()
264
+ const text = leave(current)
265
+ return { text, cursor: text.length }
266
+ }
267
+ if (current.filter.trim() !== "" && (key.text === "!!" || (key.text === "!" && pending === "!"))) {
268
+ reset()
269
+ const text = `${current.filter.trim()} !!`
270
+ return { text, cursor: text.length }
271
+ }
272
+ current.filter += pending
273
+ if (key.name === "return")
274
+ return take(current)
275
+ if (key.name === "up" || key.name === "down")
276
+ current.scroll = Math.max(0, current.scroll + (key.name === "up" ? -1 : 1))
277
+ else if (key.step !== 0) {
278
+ current.select = Math.max(0, Math.min(matching(current).length - 1, current.select + key.step))
279
+ current.scroll = 0
280
+ }
281
+ else if (key.erase < 0)
282
+ current.filter = current.filter.slice(0, -1)
283
+ else if (key.text !== "")
284
+ current.filter += key.text.replace(/\s+/g, " ")
285
+ if (pending !== "" || key.erase < 0 || key.text !== "")
286
+ current.select = 0
287
+ return hold(current)
288
+ }
289
+
290
+ /* for a prompt of just the trigger, or of a free text suffixed with it, show
291
+ the manpage browser with the free text as its filter (and hold the prompt) */
292
+ const open = async (box: PromptBox, engine: Pick<Engine, "read" | "dirs">): Promise<PromptBox | undefined> => {
293
+ const m = BROWSE.exec(box.text)
294
+ if (m === null)
295
+ return undefined
296
+ if (docs === undefined) {
297
+ const skills = (await engine.dirs(SKILLS)).filter((name) => /^ase-[a-z0-9]+-[a-z0-9]+$/.test(name)).sort()
298
+ const loaded = await Promise.all(skills.map(async (skill) => {
299
+ const manual = await engine.read(`${SKILLS}/${skill}/help.md`).catch(() => undefined)
300
+ if (manual === undefined)
301
+ return []
302
+ const text = manual.toLowerCase()
303
+ return [ { skill, manual, text, words: new Set(text.split(/[^a-z0-9]+/).filter((w) => w !== "")) } ]
304
+ }))
305
+ docs = loaded.flat()
306
+ vocab = new Set(docs.flatMap((doc) => [ ...doc.words ]))
307
+ }
308
+ browse = { filter: m[1] ?? "", select: 0, pending: "", anchor: "",
309
+ top: 0, scroll: 0, page: 1, split: 0 }
310
+ return hold(browse)
311
+ }
312
+
313
+ /* operate the manpage browser by the wheel: over the master view select a
314
+ manual page, over the detail view scroll it, resp. scroll it by a page with
315
+ the scroll keys (PAGEUP/PAGEDOWN) while the band is focused (and tell
316
+ whether the browser took the scrolling) */
317
+ const scroll = (e: BandScroll): boolean => {
318
+ if (browse === undefined)
319
+ return false
320
+ const current = browse
321
+ wheel(e, current, (by) => {
322
+ const select = Math.max(0, Math.min(matching(current).length - 1, current.select + by))
323
+ if (select !== current.select) {
324
+ current.select = select
325
+ current.scroll = 0
326
+ }
327
+ })
328
+ return true
329
+ }
330
+
331
+ /* operate the manpage browser by RETURN at the prompt and determine the
332
+ prompt to refill (the held prompt while the browser stays shown, but
333
+ without its space after the cursor, else the taken over command) */
334
+ const submit = (): string => {
335
+ const box = take(browse!)
336
+ return browse !== undefined ? box.text.trimEnd() : box.text
337
+ }
338
+
339
+ /* draw the manpage browser into the band above the prompt (terminal only) */
340
+ const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
341
+ /* yield to a survey and draw nothing without a browser */
342
+ if (browse === undefined || e.props.hasSurvey)
343
+ return undefined
344
+ const current = browse
345
+ const { Text } = ui
346
+
347
+ /* determine the geometry of the band and the matching manual pages
348
+ (with the selection clamped onto them) */
349
+ const geometry = layout(e, MASTER)
350
+ const { body, detail } = geometry
351
+ const list = matching(current)
352
+ current.select = Math.max(0, Math.min(list.length - 1, current.select))
353
+ const selected = list[current.select]
354
+
355
+ /* determine the lines of the detail view: the manual page of the selected
356
+ skill with the matches of the filter marked */
357
+ const lines = selected === undefined ?
358
+ wrap(uniform("No manual page matches the filter.", false, undefined), detail, 0) :
359
+ page(selected.doc.manual, detail).map((line) => selected.re !== undefined ? mark(line, selected.re) : line)
360
+
361
+ /* reset the scroll position of the detail view once the selection or the
362
+ filter changes, and keep the selected manual page in the master view
363
+ (below the filter field and its horizontal line) */
364
+ const anchor = `${current.select}/${current.filter}`
365
+ if (current.anchor !== anchor) {
366
+ current.anchor = anchor
367
+ current.scroll = 0
368
+ }
369
+ const rows = Math.max(1, body - 2)
370
+ current.top = Math.max(0, Math.min(current.select, Math.max(current.top, current.select - rows + 1)))
371
+
372
+ /* determine the lines of the master view: the filter field (with its caret,
373
+ resp. its placeholder), a horizontal line (joining the borders), and the
374
+ matching manual pages (the selected one in red) */
375
+ const field = Text({ wrap: "truncate-start", children: [ "❯ ",
376
+ ...(current.filter !== "" ? [ Text({ bold: true, children: current.filter }) ] : []),
377
+ Text({ inverse: true, children: " " }),
378
+ ...(current.filter === "" ? [ Text({ dimColor: true, italic: true, children: " filter…" }) ] : []) ] })
379
+ const entry = (k: number) => {
380
+ const item = list[k]
381
+ if (item === undefined)
382
+ return undefined
383
+ const picked = k === current.select
384
+ return Text({ wrap: "truncate-end", bold: picked, color: picked ? PICKED : undefined,
385
+ children: `${picked ? "▶" : " "} ${item.doc.skill}` })
386
+ }
387
+
388
+ /* draw both views (with the count of the matching manual pages in the
389
+ bottom edge) and the key hints */
390
+ const wheeled = geometry.overflow ? "/wheel" : ""
391
+ return panel(ui, geometry, current, {
392
+ title: TITLE,
393
+ status: `[ ${list.length}/${docs?.length ?? 0} manpages ]`,
394
+ hints: `←/→${wheeled}: select · ` +
395
+ `${geometry.overflow ? "wheel or CTRL+x + TAB + ↑/↓/PgUp/PgDn" : "↑/↓"}: scroll · ` +
396
+ "//: cancel+close · ⏎: apply+close" +
397
+ (current.filter.trim() !== "" ? " · !!: Find Skill Command" : "") + " · (type for filtering)",
398
+ rows: (i) => i === 0 ? field : entry(current.top + i - 2),
399
+ rule: (i) => i === 1,
400
+ lines
401
+ })
402
+ }
403
+
404
+ return { active, reset, cancel, edit, open, scroll, submit, render }
405
+ }