@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.
Files changed (48) hide show
  1. package/dst/ase-config-schema.js +1 -1
  2. package/dst/ase-hook.js +12 -3
  3. package/dst/ase-setup-settings.js +111 -1
  4. package/dst/ase-setup.js +19 -7
  5. package/dst/ase-spec.js +184 -49
  6. package/dst/ase-task-board-tui-model.js +8 -1
  7. package/dst/ase-task-board-tui.js +26 -2
  8. package/dst/ase-task-board-web-client.js +10 -10
  9. package/dst/man/ase-artifact.md +1 -1
  10. package/dst/man/ase-config.md +5 -3
  11. package/dst/man/ase-mcp.md +1 -1
  12. package/dst/man/ase-setup-install.md +10 -1
  13. package/dst/man/ase-setup-status.md +6 -0
  14. package/dst/man/ase-setup-uninstall.md +3 -1
  15. package/dst/man/ase-setup-update.md +3 -0
  16. package/dst/man/ase-setup.md +4 -4
  17. package/dst/man/ase-spec-export.md +14 -3
  18. package/dst/man/ase-spec-init.md +10 -1
  19. package/dst/man/ase-spec-lint.md +2 -1
  20. package/dst/man/ase-spec-preview.md +10 -1
  21. package/dst/man/ase-spec-query.md +96 -0
  22. package/dst/man/ase-spec.md +32 -11
  23. package/dst/man/ase.md +3 -3
  24. package/package.json +21 -18
  25. package/plugin/.claude-plugin/icon.png +0 -0
  26. package/plugin/.claude-plugin/plugin.json +1 -1
  27. package/plugin/.codex-plugin/plugin.json +1 -1
  28. package/plugin/.github/plugin/plugin.json +1 -1
  29. package/plugin/meta/ase-format-spec.md +29 -5
  30. package/plugin/meta/ase-format-specbook.md +427 -47
  31. package/plugin/meta/ase-format-specbook.yaml +180 -78
  32. package/plugin/mods/ase-mods-common.ts +474 -0
  33. package/plugin/mods/ase-mods-evaluate.ts +227 -0
  34. package/plugin/mods/ase-mods-grill.ts +283 -0
  35. package/plugin/mods/ase-mods-help-browse.ts +410 -0
  36. package/plugin/mods/ase-mods-help-compose.ts +561 -0
  37. package/plugin/mods/ase-mods-help-find.ts +434 -0
  38. package/plugin/mods/ase-mods-help-hint.ts +134 -0
  39. package/plugin/mods/ase-mods-help-show.ts +142 -0
  40. package/plugin/mods/ase-mods.ts +220 -381
  41. package/plugin/skills/ase-code-lint/SKILL.md +15 -15
  42. package/plugin/skills/ase-code-lint/help.md +21 -8
  43. package/plugin/skills/ase-help-intent/SKILL.md +2 -1
  44. package/plugin/skills/ase-help-intent/data.md +42 -16
  45. package/plugin/skills/ase-spec-activate/SKILL.md +4 -1
  46. package/plugin/skills/ase-spec-lint/SKILL.md +15 -15
  47. package/plugin/skills/ase-spec-lint/help.md +21 -8
  48. 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
+