@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,474 @@
|
|
|
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
|
+
Common parts, shared by the grill table, the decision matrix, the intent
|
|
9
|
+
help, the synopsis form, the manual band, the manpage browser, and the hint
|
|
10
|
+
band: the rows of the Markdown tables, the styled characters of the terminal
|
|
11
|
+
drawing, their Markdown conversion, wrapping, and framing, and the contract,
|
|
12
|
+
keys, geometry, and drawing of the master/detail view in the band above the
|
|
13
|
+
prompt. */
|
|
14
|
+
|
|
15
|
+
import type { Elements, Frozen, MatchedEvent, ModelCompleteRequest, ModelForkResult,
|
|
16
|
+
PromptBox, PromptEditInput, PromptFilled, RenderElement } from "claude-code"
|
|
17
|
+
|
|
18
|
+
/* the services of the engine, bound by the hooking infrastructure for the
|
|
19
|
+
modules (as the engine refuses to load a module passing "$" itself): the
|
|
20
|
+
redrawing, the reading of a plugin file and of the names of the directories
|
|
21
|
+
in a plugin directory, the forked and the plain model completion, the
|
|
22
|
+
waiting, and the reading and filling of the prompt */
|
|
23
|
+
export type Engine = {
|
|
24
|
+
invalidate: () => void,
|
|
25
|
+
read: (file: string) => Promise<string>,
|
|
26
|
+
dirs: (dir: string) => Promise<string[]>,
|
|
27
|
+
fork: (prompt: string) => Promise<ModelForkResult | null>,
|
|
28
|
+
complete: (request: ModelCompleteRequest) => Promise<string>,
|
|
29
|
+
sleep: (ms: number) => Promise<void>,
|
|
30
|
+
prompt: () => Promise<PromptBox>,
|
|
31
|
+
fill: (text: string) => Promise<PromptFilled>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/* the colors of a picked (resp. selected or focused) item, of a code span,
|
|
35
|
+
and of a skill command (the theme color of a command inside the prompt) */
|
|
36
|
+
export const PICKED = "red"
|
|
37
|
+
export const CODE = "blue"
|
|
38
|
+
export const COMMAND = "suggestion"
|
|
39
|
+
|
|
40
|
+
/* the trigger suffix of a synopsis form or an intent ("!!"), resp. of a manual band
|
|
41
|
+
or a manpage browser ("//") inside the prompt (each further constrained by the
|
|
42
|
+
pattern of the synopsis form, the intent, the manual band, resp. the browser) */
|
|
43
|
+
export const TRIGGER = /(?:!!|\/\/)$/
|
|
44
|
+
|
|
45
|
+
/* the share of the terminal height taken by the band above the prompt */
|
|
46
|
+
const BAND = 0.25
|
|
47
|
+
|
|
48
|
+
/* the self-drawn rounded frame around the content of the band above the
|
|
49
|
+
prompt: the columns taken by its borders and its left padding, the
|
|
50
|
+
columns before its content, and the rows taken by its edges, and the
|
|
51
|
+
style of its lines (the dimmed default color, which renders as a darker
|
|
52
|
+
grey than the "gray" color) */
|
|
53
|
+
const FRAME = { cols: 3, lead: 2, rows: 2 } as const
|
|
54
|
+
const BORDER = { dimColor: true } as const
|
|
55
|
+
|
|
56
|
+
/* the events of drawing resp. scrolling the band above the prompt */
|
|
57
|
+
export type BandRender = Frozen<MatchedEvent<"ui.render", { component: "AbovePrompt", surface: "terminal" }>>
|
|
58
|
+
export type BandScroll = Frozen<MatchedEvent<"ui.scroll", { component: "AbovePrompt" }>>
|
|
59
|
+
|
|
60
|
+
/* a band above the prompt (the synopsis form, the intent band, or the manpage
|
|
61
|
+
browser, of which at most one is shown at a time), as dispatched by the
|
|
62
|
+
hooking infrastructure: whether it is shown, closing it, closing it on a
|
|
63
|
+
failure (determining the prompt to restore), operating it by a key
|
|
64
|
+
(determining the prompt to hold, resp. the text onto whose end the edit is
|
|
65
|
+
still to be applied), by the wheel, and by RETURN at the prompt
|
|
66
|
+
(determining the prompt to refill), and drawing it */
|
|
67
|
+
export type Band = {
|
|
68
|
+
active: () => boolean,
|
|
69
|
+
reset: () => void,
|
|
70
|
+
cancel: () => string | undefined,
|
|
71
|
+
edit: (e: Frozen<PromptEditInput>) => PromptBox | string,
|
|
72
|
+
scroll: (e: BandScroll) => boolean,
|
|
73
|
+
submit: () => string,
|
|
74
|
+
render: (e: BandRender, ui: Elements["terminal"]) => RenderElement | undefined
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/* hold the prompt while the band above the prompt is shown: the text with a
|
|
78
|
+
trailing space and backslash before the cursor and another space after it
|
|
79
|
+
(so, as a move or deletion at either end of the prompt raises no edit, moves
|
|
80
|
+
and deletions in both directions stay possible, and, as RETURN after a
|
|
81
|
+
backslash inserts a newline instead of submitting the prompt, RETURN
|
|
82
|
+
arrives as an edit, too, which keeps the cursor inside the prompt) */
|
|
83
|
+
export const held = (text: string): PromptBox =>
|
|
84
|
+
({ text: `${text} \\ `, cursor: text.length + 2 })
|
|
85
|
+
|
|
86
|
+
/* a key pressed in the band above the prompt: its name, its typed text, its
|
|
87
|
+
cursor step (-1 left, +1 right), and its deletion (-1 backward, +1 forward) */
|
|
88
|
+
export type Key = { name: string, text: string, step: number, erase: number }
|
|
89
|
+
|
|
90
|
+
/* decode an edit of the held prompt into a key, where RETURN arrives as a
|
|
91
|
+
typed newline, and cursor moves and deletions are told by their splice */
|
|
92
|
+
export const decode = (e: Frozen<PromptEditInput>): Key => {
|
|
93
|
+
const name = e.inputText === "\n" ? "return" : e.key?.key ?? ""
|
|
94
|
+
const erased = e.inputText === "" && e.end > e.start
|
|
95
|
+
return {
|
|
96
|
+
name,
|
|
97
|
+
text: e.inputText === "\n" ? "" : e.inputText,
|
|
98
|
+
step: name === "left" ? -1 : name === "right" ? 1 :
|
|
99
|
+
e.inputText === "" && !erased ? Math.sign(e.start - e.cursor) : 0,
|
|
100
|
+
erase: erased ? (e.start < e.cursor ? -1 : 1) : 0
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/* a row and the delimiter row of a Markdown table */
|
|
105
|
+
export const ROW = /^ *\|.*\| *$/
|
|
106
|
+
export const RULE = /^ *\|(?: *:?-+:? *\|)+ *$/
|
|
107
|
+
|
|
108
|
+
/* split a Markdown table row into its cells */
|
|
109
|
+
export const cells = (line: string): string[] => {
|
|
110
|
+
const result: string[] = []
|
|
111
|
+
const body = line.trim().replace(/^\|/, "").replace(/\|$/, "")
|
|
112
|
+
let cell = ""
|
|
113
|
+
let code = false
|
|
114
|
+
for (let i = 0; i < body.length; i++) {
|
|
115
|
+
const ch = body[i]!
|
|
116
|
+
if (ch === "`")
|
|
117
|
+
code = !code
|
|
118
|
+
if (!code && ch === "\\" && body[i + 1] === "|") {
|
|
119
|
+
cell += "|"
|
|
120
|
+
i++
|
|
121
|
+
}
|
|
122
|
+
else if (!code && ch === "|") {
|
|
123
|
+
result.push(cell.trim())
|
|
124
|
+
cell = ""
|
|
125
|
+
}
|
|
126
|
+
else
|
|
127
|
+
cell += ch
|
|
128
|
+
}
|
|
129
|
+
result.push(cell.trim())
|
|
130
|
+
return result
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* a single styled character of the terminal drawing */
|
|
134
|
+
export type Char = { ch: string, bold: boolean, color: string | undefined }
|
|
135
|
+
|
|
136
|
+
/* extract a section of a manual page (without its heading) */
|
|
137
|
+
export const section = (manual: string, name: string): string =>
|
|
138
|
+
manual.split(/^(?=## {2})/m).find((text) => text.startsWith(`## ${name}`))?.replace(/^.*\n/, "") ?? ""
|
|
139
|
+
|
|
140
|
+
/* convert inline Markdown (bold and code spans) into styled characters */
|
|
141
|
+
export const style = (text: string, color: string | undefined): Char[] => {
|
|
142
|
+
const chars: Char[] = []
|
|
143
|
+
let bold = false
|
|
144
|
+
let code = false
|
|
145
|
+
for (let i = 0; i < text.length; i++) {
|
|
146
|
+
if (!code && text.startsWith("**", i)) {
|
|
147
|
+
bold = !bold
|
|
148
|
+
i++
|
|
149
|
+
}
|
|
150
|
+
else if (text[i] === "`")
|
|
151
|
+
code = !code
|
|
152
|
+
else {
|
|
153
|
+
const ch = String.fromCodePoint(text.codePointAt(i)!)
|
|
154
|
+
chars.push({ ch, bold, color: color ?? (code ? CODE : undefined) })
|
|
155
|
+
i += ch.length - 1
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return chars
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/* the width of styled characters in terminal cells */
|
|
162
|
+
export const width = (chars: Char[]): number => {
|
|
163
|
+
let n = 0
|
|
164
|
+
for (const { ch } of chars) {
|
|
165
|
+
const c = ch.codePointAt(0)!
|
|
166
|
+
if ((c >= 0x200B && c <= 0x200D) || c === 0x2060 || c === 0xFEFF ||
|
|
167
|
+
(c >= 0x0300 && c <= 0x036F) || (c >= 0xFE00 && c <= 0xFE0F))
|
|
168
|
+
continue
|
|
169
|
+
const wide =
|
|
170
|
+
(c >= 0x1100 && c <= 0x115F) || (c >= 0x2E80 && c <= 0xA4CF) ||
|
|
171
|
+
(c >= 0xAC00 && c <= 0xD7A3) || (c >= 0xF900 && c <= 0xFAFF) ||
|
|
172
|
+
(c >= 0xFF00 && c <= 0xFF60) || (c >= 0x1F300 && c <= 0x1FAFF) ||
|
|
173
|
+
(c >= 0x20000 && c <= 0x3FFFD)
|
|
174
|
+
n += wide ? 2 : 1
|
|
175
|
+
}
|
|
176
|
+
return n
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/* hard-wrap styled characters at word boundaries to the given columns,
|
|
180
|
+
indenting the first line by "lead" and all further lines by "indent"
|
|
181
|
+
(and chopping overlong words) */
|
|
182
|
+
export const wrap = (chars: Char[], columns: number, indent: number, lead = 0): Char[][] => {
|
|
183
|
+
const space: Char = { ch: " ", bold: false, color: undefined }
|
|
184
|
+
const words: Char[][] = [ [] ]
|
|
185
|
+
for (const char of chars) {
|
|
186
|
+
if (char.ch === " ")
|
|
187
|
+
words.push([])
|
|
188
|
+
else
|
|
189
|
+
words.at(-1)!.push(char)
|
|
190
|
+
}
|
|
191
|
+
const lines: Char[][] = []
|
|
192
|
+
let line: Char[] = Array.from({ length: lead }, () => space)
|
|
193
|
+
let start = lead
|
|
194
|
+
let size = lead
|
|
195
|
+
const flush = () => {
|
|
196
|
+
lines.push(line)
|
|
197
|
+
line = Array.from({ length: indent }, () => space)
|
|
198
|
+
start = indent
|
|
199
|
+
size = indent
|
|
200
|
+
}
|
|
201
|
+
for (let word of words.filter((word) => word.length > 0)) {
|
|
202
|
+
while (word.length > 0) {
|
|
203
|
+
const w = width(word)
|
|
204
|
+
if (size > start && size + 1 + w <= columns) {
|
|
205
|
+
line.push(space, ...word)
|
|
206
|
+
size += 1 + w
|
|
207
|
+
word = []
|
|
208
|
+
}
|
|
209
|
+
else if (size > start)
|
|
210
|
+
flush()
|
|
211
|
+
else {
|
|
212
|
+
let n = 1
|
|
213
|
+
while (n < word.length && width(word.slice(0, n + 1)) <= columns - start)
|
|
214
|
+
n++
|
|
215
|
+
line.push(...word.slice(0, n))
|
|
216
|
+
size += width(word.slice(0, n))
|
|
217
|
+
word = word.slice(n)
|
|
218
|
+
if (word.length > 0)
|
|
219
|
+
flush()
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
lines.push(line)
|
|
224
|
+
return lines
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/* a run of equally styled characters */
|
|
228
|
+
type Run = { text: string, bold: boolean, color: string | undefined }
|
|
229
|
+
|
|
230
|
+
/* split a line of styled characters into runs of equally styled characters */
|
|
231
|
+
export const split = (chars: Char[]): Run[] => {
|
|
232
|
+
const runs: Run[] = []
|
|
233
|
+
for (let i = 0, j = 0; i < chars.length; i = j) {
|
|
234
|
+
const { bold, color } = chars[i]!
|
|
235
|
+
while (j < chars.length && chars[j]!.bold === bold && chars[j]!.color === color)
|
|
236
|
+
j++
|
|
237
|
+
runs.push({ text: chars.slice(i, j).map((char) => char.ch).join(""), bold, color })
|
|
238
|
+
}
|
|
239
|
+
return runs
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/* convert plain text (without any Markdown) into equally styled characters */
|
|
243
|
+
export const uniform = (text: string, bold: boolean, color: string | undefined): Char[] =>
|
|
244
|
+
Array.from(text, (ch) => ({ ch, bold, color }))
|
|
245
|
+
|
|
246
|
+
/* strip the single-asterisk emphasis outside the code spans of inline Markdown */
|
|
247
|
+
export const strip = (text: string): string =>
|
|
248
|
+
text.split("`").map((part, i) => i % 2 === 0 ? part.replace(/(?<!\*)\*(?!\*)/g, "") : part).join("`")
|
|
249
|
+
|
|
250
|
+
/* convert the Markdown blocks of a manual page section (paragraphs, list items,
|
|
251
|
+
and code fences) into lines of styled characters, hard-wrapped to the given
|
|
252
|
+
columns (except for the code lines), with a blank line between the blocks,
|
|
253
|
+
where "nested" keeps the indentation of the blocks and of the list item texts */
|
|
254
|
+
export const flow = (text: string, columns: number, nested = false): Char[][] => {
|
|
255
|
+
const result: Char[][] = []
|
|
256
|
+
let block: string[] = []
|
|
257
|
+
let fence = false
|
|
258
|
+
const flush = () => {
|
|
259
|
+
if (block.length === 0)
|
|
260
|
+
return
|
|
261
|
+
if (result.length > 0)
|
|
262
|
+
result.push([])
|
|
263
|
+
const pad = nested ? /^ */.exec(block[0]!)![0].length : 0
|
|
264
|
+
const body = strip(block.map((line) => line.trim()).join(" "))
|
|
265
|
+
const item = /^(-|\d+\.)\s+/.exec(body)
|
|
266
|
+
if (item !== null) {
|
|
267
|
+
const mark = (item[1] === "-" ? "•" : item[1]!).padEnd(nested ? item[0].length : item[1]!.length + 1)
|
|
268
|
+
const lines = wrap(style(body.slice(item[0].length), undefined), columns, pad + mark.length, pad + mark.length)
|
|
269
|
+
lines[0]!.splice(pad, mark.length, ...uniform(mark, false, undefined))
|
|
270
|
+
result.push(...lines)
|
|
271
|
+
}
|
|
272
|
+
else
|
|
273
|
+
result.push(...wrap(style(body, undefined), columns, pad, pad))
|
|
274
|
+
block = []
|
|
275
|
+
}
|
|
276
|
+
for (const line of text.replace(/^\s*\n|\s+$/g, "").split("\n")) {
|
|
277
|
+
if (line.startsWith("```")) {
|
|
278
|
+
flush()
|
|
279
|
+
if (!fence && result.length > 0)
|
|
280
|
+
result.push([])
|
|
281
|
+
fence = !fence
|
|
282
|
+
}
|
|
283
|
+
else if (fence)
|
|
284
|
+
result.push(uniform(line, false, CODE))
|
|
285
|
+
else if (line.trim() === "")
|
|
286
|
+
flush()
|
|
287
|
+
else {
|
|
288
|
+
if (/^(?:-|\d+\.)\s/.test(line))
|
|
289
|
+
flush()
|
|
290
|
+
block.push(line)
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
flush()
|
|
294
|
+
return result
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/* surround lines of styled characters with a rounded box of the given total
|
|
298
|
+
columns and border color (padding the lines to the inner columns) */
|
|
299
|
+
export const frame = (lines: Char[][], columns: number, color: string | undefined): Char[][] => {
|
|
300
|
+
const rim = (text: string) => uniform(text, false, color)
|
|
301
|
+
return [
|
|
302
|
+
rim(`╭${"─".repeat(columns - 2)}╮`),
|
|
303
|
+
...lines.map((line) => [ ...rim("│ "), ...line,
|
|
304
|
+
...uniform(" ".repeat(Math.max(0, columns - 4 - width(line))), false, undefined), ...rim(" │") ]),
|
|
305
|
+
rim(`╰${"─".repeat(columns - 2)}╯`)
|
|
306
|
+
]
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* split a horizontal edge of a frame into runs with a title placed after its
|
|
310
|
+
first and another one before its last two characters (each if it fits, the
|
|
311
|
+
one at the end with precedence), flagged whether they are a title */
|
|
312
|
+
const titled = (line: string, head: string, tail: string): [ string, boolean ][] => {
|
|
313
|
+
const end = tail !== "" && line.length >= tail.length + 4 ? tail : ""
|
|
314
|
+
const start = head !== "" && line.length >= head.length + (end !== "" ? end.length + 6 : 4) ? head : ""
|
|
315
|
+
const k = end !== "" ? line.length - 2 - end.length : line.length
|
|
316
|
+
return [
|
|
317
|
+
...(start !== "" ? [ [ line.slice(0, 2), false ], [ start, true ] ] as [ string, boolean ][] : []),
|
|
318
|
+
[ line.slice(start !== "" ? 2 + start.length : 0, k), false ],
|
|
319
|
+
...(end !== "" ? [ [ end, true ], [ line.slice(k + end.length), false ] ] as [ string, boolean ][] : [])
|
|
320
|
+
]
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/* the scroll position of a view with the given rows over the given lines
|
|
324
|
+
at the given scroll position, as placed into the bottom edge of a frame */
|
|
325
|
+
const position = (rows: number, lines: number, scroll: number): string =>
|
|
326
|
+
lines === 0 ? "" : `[ lines ${scroll + 1}-${Math.min(scroll + rows, lines)}/${lines} ]`
|
|
327
|
+
|
|
328
|
+
/* the mark of a scrollbar row: the thumb, the track, or nothing */
|
|
329
|
+
type Mark = "thumb" | "track" | "none"
|
|
330
|
+
|
|
331
|
+
/* determine the scrollbar of a view with the given rows over the given lines at
|
|
332
|
+
the given scroll position: for each row the thumb, the track, or nothing */
|
|
333
|
+
const scrollbar = (rows: number, lines: number, scroll: number): Mark[] => {
|
|
334
|
+
if (lines <= rows)
|
|
335
|
+
return Array.from({ length: rows }, () => "none")
|
|
336
|
+
const thumb = Math.max(1, Math.round(rows * rows / lines))
|
|
337
|
+
const offset = Math.round(scroll / (lines - rows) * (rows - thumb))
|
|
338
|
+
return Array.from({ length: rows }, (_, i) => i >= offset && i < offset + thumb ? "thumb" : "track")
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/* determine the rows a scroll event moves a view by: a page of the given size
|
|
342
|
+
for the scroll keys (PAGEUP/PAGEDOWN), resp. its rows for the wheel */
|
|
343
|
+
const paging = (e: { pointer?: unknown, by: number }, size: number): number =>
|
|
344
|
+
e.pointer === undefined && Math.abs(e.by) > 1 ? Math.sign(e.by) * size : e.by
|
|
345
|
+
|
|
346
|
+
/* the text element of the terminal surface */
|
|
347
|
+
export type Text = Elements["terminal"]["Text"]
|
|
348
|
+
|
|
349
|
+
/* draw a line of styled characters: its styled runs */
|
|
350
|
+
export const paint = (Text: Text, chars: Char[]): (string | RenderElement)[] =>
|
|
351
|
+
split(chars).map(({ text, bold, color }) =>
|
|
352
|
+
bold || color !== undefined ? Text({ bold, color, children: text }) : text)
|
|
353
|
+
|
|
354
|
+
/* draw a horizontal edge of the self-drawn frame (dimmed), with titles at its
|
|
355
|
+
start and its end (with their brackets dimmed, an "ASE" prefix before a colon
|
|
356
|
+
in bold, and the rest dimmed) */
|
|
357
|
+
const edge = (Text: Text, line: string, head = "", tail = ""): RenderElement =>
|
|
358
|
+
Text({ children: titled(line, head, tail).flatMap(([ text, label ]) => {
|
|
359
|
+
const m = /^(\[ )?([^:]*)(:.*?)( \])?$/.exec(text)
|
|
360
|
+
if (!label || m === null)
|
|
361
|
+
return [ Text({ ...BORDER, children: text }) ]
|
|
362
|
+
return [
|
|
363
|
+
...(m[1] !== undefined ? [ Text({ ...BORDER, children: m[1] }) ] : []),
|
|
364
|
+
Text({ bold: true, children: m[2]! }),
|
|
365
|
+
Text({ dimColor: true, children: m[3]! }),
|
|
366
|
+
...(m[4] !== undefined ? [ Text({ ...BORDER, children: m[4] }) ] : [])
|
|
367
|
+
]
|
|
368
|
+
}) })
|
|
369
|
+
|
|
370
|
+
/* draw a row of a scrollbar: the thumb, the (dimmed) track, or nothing */
|
|
371
|
+
const bar = (Text: Text, mark: Mark | undefined): RenderElement =>
|
|
372
|
+
mark === "thumb" ? Text({ children: "█" }) :
|
|
373
|
+
mark === "track" ? Text({ dimColor: true, children: "░" }) : Text({ children: " " })
|
|
374
|
+
|
|
375
|
+
/* the geometry of the band above the prompt: the inner columns of the self-drawn
|
|
376
|
+
frame, the rows of its views, the columns of its master and detail view, and
|
|
377
|
+
whether the band overflows (to receive the scrolling) */
|
|
378
|
+
export type Layout = { span: number, body: number, master: number, detail: number, overflow: boolean }
|
|
379
|
+
|
|
380
|
+
/* determine the geometry of the band above the prompt: the inner columns (both
|
|
381
|
+
borders and the left padding subtracted), the rows (with the frame and the footer
|
|
382
|
+
subtracted) out of a share of the terminal height (but never more than the rows of
|
|
383
|
+
the band), resp., in fullscreen (where the engine raises the scrolling by the wheel
|
|
384
|
+
and, once focused, by the keys only for a tree taller than the band), all rows of
|
|
385
|
+
the band but the one of the engine's "n more" row (as the band then takes them
|
|
386
|
+
anyway), and the columns of the master view (by the given share) and of the
|
|
387
|
+
detail view (with a separator column between them, and a gap of two columns and
|
|
388
|
+
a scrollbar column right of the detail view) */
|
|
389
|
+
export const layout = (e: BandRender, share: number, band = BAND): Layout => {
|
|
390
|
+
const span = Math.max(20, e.props.bodyColumns - FRAME.cols)
|
|
391
|
+
const overflow = e.viewport?.isFullscreen === true
|
|
392
|
+
const height = overflow ? Math.max(FRAME.rows + 2, e.props.maxRows - 1) : Math.min(e.props.maxRows,
|
|
393
|
+
Math.max(FRAME.rows + 2, Math.floor((e.viewport?.rows ?? e.props.maxRows) * band)))
|
|
394
|
+
const master = Math.floor((span - 3 - 3) * share)
|
|
395
|
+
return { span, body: Math.max(1, height - FRAME.rows - 1), master, detail: span - master - 3 - 3, overflow }
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
/* the scroll state of the band above the prompt: the first shown row of the
|
|
399
|
+
master view, the first shown line and the lines of a page of the detail
|
|
400
|
+
view, and the first band column right of the master view */
|
|
401
|
+
export type View = { top: number, scroll: number, page: number, split: number }
|
|
402
|
+
|
|
403
|
+
/* operate a view by the wheel: over the master view step it (if steppable),
|
|
404
|
+
else scroll the detail view, resp. scroll it by a page with the scroll keys
|
|
405
|
+
(PAGEUP/PAGEDOWN) while the band is focused */
|
|
406
|
+
export const wheel = (e: BandScroll, view: View, step?: (by: number) => void): void => {
|
|
407
|
+
if (step !== undefined && e.pointer !== undefined && e.pointer.column < view.split)
|
|
408
|
+
step(Math.sign(e.by))
|
|
409
|
+
else
|
|
410
|
+
view.scroll = Math.max(0, view.scroll + paging(e, view.page))
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/* draw the band above the prompt: inside the self-drawn frame (with the title
|
|
414
|
+
in its top edge) either a single view of the given rows, a single view of the
|
|
415
|
+
given lines with its scrollbar, or a master view of the given rows (where the
|
|
416
|
+
given rule rows are horizontal lines joining the borders) and, joined by a
|
|
417
|
+
separator, a detail view of the given lines (optionally dimmed) with its
|
|
418
|
+
scrollbar, into the bottom edge the given status (if any) at its start and
|
|
419
|
+
the scroll position of the lines (unless they always fit) at its end, and below
|
|
420
|
+
the frame the key hints (followed by blank rows for an overflowing band),
|
|
421
|
+
where the scroll position is clamped, and the page size (for the scroll keys)
|
|
422
|
+
and the end of the master view (for the wheel) are remembered in the view */
|
|
423
|
+
export const panel = (ui: Elements["terminal"], { span, body, master, detail, overflow }: Layout, view: View, spec: {
|
|
424
|
+
title: string,
|
|
425
|
+
status?: string,
|
|
426
|
+
hints: string,
|
|
427
|
+
rows?: (i: number) => RenderElement | undefined,
|
|
428
|
+
rule?: (i: number) => boolean,
|
|
429
|
+
lines?: Char[][],
|
|
430
|
+
dim?: boolean,
|
|
431
|
+
fit?: boolean
|
|
432
|
+
}): RenderElement => {
|
|
433
|
+
const { Box, Text } = ui
|
|
434
|
+
const lines = spec.lines ?? []
|
|
435
|
+
const solo = spec.rows === undefined
|
|
436
|
+
view.page = Math.max(1, body - 1)
|
|
437
|
+
view.split = solo ? 0 : FRAME.lead + master
|
|
438
|
+
view.scroll = Math.max(0, Math.min(view.scroll, lines.length - body))
|
|
439
|
+
const marks = scrollbar(body, lines.length, view.scroll)
|
|
440
|
+
const blank = Text({ children: " " })
|
|
441
|
+
const border = (left: string, joint: string, right: string) =>
|
|
442
|
+
left + (spec.lines === undefined || solo ? "─".repeat(span + 1) :
|
|
443
|
+
"─".repeat(master + 2) + joint + "─".repeat(detail + 4)) + right
|
|
444
|
+
const scrolled = (i: number, width: number) => [
|
|
445
|
+
Box({ width, flexShrink: 0, children:
|
|
446
|
+
Text({ wrap: "truncate-end", dimColor: spec.dim ?? false,
|
|
447
|
+
children: paint(Text, lines[view.scroll + i] ?? []) }) }),
|
|
448
|
+
Text({ children: " " }),
|
|
449
|
+
bar(Text, marks[i])
|
|
450
|
+
]
|
|
451
|
+
const rows: RenderElement[] = [ edge(Text, border("╭", "┬", "╮"), spec.title) ]
|
|
452
|
+
for (let i = 0; i < body; i++) {
|
|
453
|
+
const rule = !solo && spec.lines !== undefined && spec.rule?.(i) === true
|
|
454
|
+
rows.push(Box({ flexDirection: "row", children: [
|
|
455
|
+
Text({ ...BORDER, children: rule ? "├─" : "│ " }),
|
|
456
|
+
...(solo ? scrolled(i, span - 3) : spec.lines === undefined ? [
|
|
457
|
+
Box({ width: span, flexShrink: 0, children: spec.rows!(i) ?? blank })
|
|
458
|
+
] : [
|
|
459
|
+
rule ? Text({ ...BORDER, children: `${"─".repeat(master + 1)}┤ ` }) :
|
|
460
|
+
Box({ width: master, flexShrink: 0, children: spec.rows!(i) ?? blank }),
|
|
461
|
+
...(rule ? [] : [ Text({ ...BORDER, children: " │ " }) ]),
|
|
462
|
+
...scrolled(i, detail)
|
|
463
|
+
]),
|
|
464
|
+
Text({ ...BORDER, children: "│" })
|
|
465
|
+
] }))
|
|
466
|
+
}
|
|
467
|
+
rows.push(edge(Text, border("╰", "┴", "╯"), spec.status,
|
|
468
|
+
spec.fit === true ? "" : position(body, lines.length, view.scroll)))
|
|
469
|
+
rows.push(Box({ paddingLeft: 1, children: Text({ dimColor: true, wrap: "truncate-end", children: spec.hints }) }))
|
|
470
|
+
if (overflow)
|
|
471
|
+
rows.push(blank, blank)
|
|
472
|
+
return Box({ flexDirection: "column", width: "100%", children: rows })
|
|
473
|
+
}
|
|
474
|
+
|