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