@rse/ase 2.0.4 → 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.
@@ -5,227 +5,23 @@
5
5
  */
6
6
 
7
7
  /* Claude Code function hooks module ("mod", EARLY ACCESS):
8
- Grill table, redrawing the grilling tables of ASE with one answer
9
- alternative per line (wrapped lines indented) and rendering in red
10
- the answers picked in the prompt via "nX" short responses and the
11
- question selected in the prompt via the last standalone "n". In the
12
- latest table, questions and answers are clickable and insert their
13
- "n" or "nX" into the prompt. */
14
-
15
- import type { Register, RenderElement } from "claude-code"
16
-
17
- /* the marker line above a grilling table (single-round and multi-round,
18
- optionally labeled with the perspective and/or grounding) */
19
- const MARKER = /⧉ \*{0,2}ASE\*{0,2}: GRILLING(?: ROUND \d+\/\d+)?(?: \([^)]*\))?:/
20
-
21
- /* a row and the delimiter row of a Markdown table */
22
- const ROW = /^ *\|.*\| *$/
23
- const RULE = /^ *\|(?: *:?-+:? *\|)+ *$/
24
-
25
- /* the separator between two answers, the letter of
26
- an answer, and the number of a question */
27
- const ANSWER = /,\s+(?=\*{0,2}[A-Z]\*{0,2}\s+▶)/
28
- const LETTER = /^\*{0,2}([A-Z])\*{0,2}\s+▶/
29
- const NUMBER = /^\*{0,2}(\d+)\*{0,2}\s+▶/
30
-
31
- /* a short response "nX" inside the prompt */
32
- const PICK = /(?<![^\s,])(\d+)([a-zA-Z])(?![^\s,])/g
33
-
34
- /* a standalone question number "n" inside the prompt */
35
- const SELECT = /(?<![^\s,])(\d+)(?![^\s,])/g
36
-
37
- /* the indent of the wrapped lines of an answer, the colors of a picked answer
38
- and of a code span, and the size limit of a single "Markdown" element */
39
- const INDENT = 4
40
- const PICKED = "red"
41
- const CODE = "blue"
42
- const LIMIT = 10000
43
-
44
- /* a single styled character of a table cell */
45
- type Char = { ch: string, bold: boolean, color: string | undefined }
46
-
47
- /* a grilling table with the Markdown text before and after it */
48
- type Table = {
49
- before: string,
50
- after: string,
51
- header: string[],
52
- rows: { number: number, question: string, answers: { letter: string, text: string }[] }[]
53
- }
54
-
55
- /* split a Markdown table row into its cells */
56
- const cells = (line: string): string[] => {
57
- const result: string[] = []
58
- const body = line.trim().replace(/^\|/, "").replace(/\|$/, "")
59
- let cell = ""
60
- let code = false
61
- for (let i = 0; i < body.length; i++) {
62
- const ch = body[i]!
63
- if (ch === "`")
64
- code = !code
65
- if (!code && ch === "\\" && body[i + 1] === "|") {
66
- cell += "|"
67
- i++
68
- }
69
- else if (!code && ch === "|") {
70
- result.push(cell.trim())
71
- cell = ""
72
- }
73
- else
74
- cell += ch
75
- }
76
- result.push(cell.trim())
77
- return result
78
- }
79
-
80
- /* parse the grilling table out of the text of a reply */
81
- const parse = (text: string): Table | undefined => {
82
- const lines = text.split("\n")
83
- const m = lines.findIndex((line) => MARKER.test(line))
84
- const h = lines.findIndex((line, i) => m !== -1 && i > m && ROW.test(line))
85
- if (h === -1 || !RULE.test(lines[h + 1] ?? ""))
86
- return undefined
87
- const header = cells(lines[h]!).map((cell) => cell.replace(/\*/g, ""))
88
- if (header.length !== 2 || header[0] !== "QUESTION" || !/^ANSWERS?$/.test(header[1]!))
89
- return undefined
90
- let end = h + 2
91
- while (end < lines.length && ROW.test(lines[end]!))
92
- end++
93
- const rows = lines.slice(h + 2, end).map((line) => cells(line))
94
- if (rows.some((row) => row.length !== 2))
95
- return undefined
96
- return {
97
- before: lines.slice(0, h).join("\n").trim(),
98
- after: lines.slice(end).join("\n").replace(/^\n+/, "").trimEnd(),
99
- header,
100
- rows: rows.map((row, i) => ({
101
- number: Number(NUMBER.exec(row[0]!)?.[1] ?? i + 1),
102
- question: row[0]!,
103
- answers: row[1]!.split(ANSWER).map((answer) =>
104
- ({ letter: LETTER.exec(answer)?.[1] ?? "", text: answer }))
105
- }))
106
- }
107
- }
108
-
109
- /* determine the picked answer letter of each question out of a prompt (last pick wins) */
110
- const pick = (text: string): Map<number, string> =>
111
- new Map([ ...text.matchAll(PICK) ].map((m) => [ Number(m[1]!), m[2]!.toUpperCase() ]))
112
-
113
- /* determine the selected question number out of a prompt (last standalone number wins) */
114
- const select = (text: string): number | undefined => {
115
- const m = [ ...text.matchAll(SELECT) ].at(-1)
116
- return m !== undefined ? Number(m[1]!) : undefined
117
- }
118
-
119
- /* the standalone tokens of question "n" inside the prompt: its bare
120
- number "n" (letter "") or its short responses "nX" (letter "[a-zA-Z]") */
121
- const token = (number: number, letter: string): RegExp =>
122
- new RegExp(`(?<![^\\s,])${number}${letter}(?![^\\s,])`, "g")
123
-
124
- /* insert the number "n" of a clicked question or the short response "nX" of a
125
- clicked answer into a prompt. Answer: an existing "nY" is replaced in place
126
- (and a bare "n" removed), else a bare "n" is replaced in place, else "nX" is
127
- appended. Question: an existing bare "n" is kept, else the last bare number
128
- of another question (the selected one) is replaced in place, else "n" is
129
- appended. */
130
- const insert = (text: string, number: number, letter?: string): string => {
131
- const bare = token(number, "")
132
- const picks = token(number, "[a-zA-Z]")
133
- if (letter !== undefined && text.search(picks) !== -1)
134
- return text.replace(picks, `${number}${letter}`)
135
- .replace(new RegExp(`\\s*${bare.source}`, "g"), "").trimStart()
136
- if (text.search(bare) !== -1)
137
- return letter !== undefined ? text.replace(bare, `${number}${letter}`) : text
138
- const last = letter === undefined ? [ ...text.matchAll(SELECT) ].at(-1) : undefined
139
- if (last !== undefined)
140
- return text.slice(0, last.index) + String(number) + text.slice(last.index + last[0].length)
141
- const head = text.trimEnd()
142
- return `${head}${head !== "" ? " " : ""}${number}${letter ?? ""}`
143
- }
144
-
145
- /* convert inline Markdown (bold and code spans) into styled characters */
146
- const style = (text: string, color: string | undefined): Char[] => {
147
- const chars: Char[] = []
148
- let bold = false
149
- let code = false
150
- for (let i = 0; i < text.length; i++) {
151
- if (!code && text.startsWith("**", i)) {
152
- bold = !bold
153
- i++
154
- }
155
- else if (text[i] === "`")
156
- code = !code
157
- else {
158
- const ch = String.fromCodePoint(text.codePointAt(i)!)
159
- chars.push({ ch, bold, color: color ?? (code ? CODE : undefined) })
160
- i += ch.length - 1
161
- }
162
- }
163
- return chars
164
- }
165
-
166
- /* the width of styled characters in terminal cells */
167
- const width = (chars: Char[]): number => {
168
- let n = 0
169
- for (const { ch } of chars) {
170
- const c = ch.codePointAt(0)!
171
- if (c === 0x200B || c === 0x2060 || (c >= 0x0300 && c <= 0x036F) || (c >= 0xFE00 && c <= 0xFE0F))
172
- continue
173
- const wide =
174
- (c >= 0x1100 && c <= 0x115F) || (c >= 0x2E80 && c <= 0xA4CF) ||
175
- (c >= 0xAC00 && c <= 0xD7A3) || (c >= 0xF900 && c <= 0xFAFF) ||
176
- (c >= 0xFF00 && c <= 0xFF60) || (c >= 0x1F300 && c <= 0x1FAFF)
177
- n += wide ? 2 : 1
178
- }
179
- return n
180
- }
181
-
182
- /* hard-wrap styled characters at word boundaries to the given columns,
183
- indenting the first line by "lead" and all further lines by "indent"
184
- (and chopping overlong words) */
185
- const wrap = (chars: Char[], columns: number, indent: number, lead = 0): Char[][] => {
186
- const space: Char = { ch: " ", bold: false, color: undefined }
187
- const words: Char[][] = [ [] ]
188
- for (const char of chars) {
189
- if (char.ch === " ")
190
- words.push([])
191
- else
192
- words.at(-1)!.push(char)
193
- }
194
- const lines: Char[][] = []
195
- let line: Char[] = Array.from({ length: lead }, () => space)
196
- let start = lead
197
- let size = lead
198
- const flush = () => {
199
- lines.push(line)
200
- line = Array.from({ length: indent }, () => space)
201
- start = indent
202
- size = indent
203
- }
204
- for (let word of words.filter((word) => word.length > 0)) {
205
- while (word.length > 0) {
206
- const w = width(word)
207
- if (size > start && size + 1 + w <= columns) {
208
- line.push(space, ...word)
209
- size += 1 + w
210
- word = []
211
- }
212
- else if (size > start)
213
- flush()
214
- else {
215
- let n = 1
216
- while (n < word.length && width(word.slice(0, n + 1)) <= columns - start)
217
- n++
218
- line.push(...word.slice(0, n))
219
- size += width(word.slice(0, n))
220
- word = word.slice(n)
221
- if (word.length > 0)
222
- flush()
223
- }
224
- }
225
- }
226
- lines.push(line)
227
- return lines
228
- }
8
+ Hooking infrastructure, registering the function hooks and dispatching
9
+ them into the grill table ("ase-mods-grill.ts"), the intent help
10
+ ("ase-mods-help-find.ts"), the synopsis form ("ase-mods-help-compose.ts"),
11
+ the manual band ("ase-mods-help-show.ts"), the manpage browser
12
+ ("ase-mods-help-browse.ts"), and the hint band ("ase-mods-help-hint.ts").
13
+ As the engine loads only a module spelling "on", "$", and "next" literally,
14
+ this is the only module touching the engine, and it binds the engine
15
+ services needed by the other modules as plain functions. */
16
+
17
+ import type { Register, PromptBox, Timer } from "claude-code"
18
+ import { type Band, TRIGGER } from "./ase-mods-common.ts"
19
+ import { modGrill } from "./ase-mods-grill.ts"
20
+ import { modHelpFind } from "./ase-mods-help-find.ts"
21
+ import { modHelpCompose } from "./ase-mods-help-compose.ts"
22
+ import { modHelpShow } from "./ase-mods-help-show.ts"
23
+ import { modHelpBrowse } from "./ase-mods-help-browse.ts"
24
+ import { modHelpHint } from "./ase-mods-help-hint.ts"
229
25
 
230
26
  /* function hook registration entry point */
231
27
  export const register: Register = (on, options) => {
@@ -233,181 +29,218 @@ export const register: Register = (on, options) => {
233
29
  if (options.enabled === false)
234
30
  return
235
31
 
236
- /* the last known width of the terminal, the already seen tables, the
237
- latest (still unanswered) table, the picks and the selected question of the
238
- still unsubmitted prompt, and the frozen picks of the already answered tables */
239
- let columns = 80
240
- const seen = new Set<string>()
241
- let latest: string | undefined
242
- let picks = new Map<number, string>()
243
- let selected: number | undefined
244
- const frozen = new Map<string, Map<number, string>>()
245
-
246
- /* track the picks inside the still unsubmitted prompt of the user */
32
+ /* the grill table, the intent help, the synopsis form, the manual band,
33
+ the manpage browser, and the hint band */
34
+ const grill = modGrill()
35
+ const intent = modHelpFind()
36
+ const skill = modHelpCompose()
37
+ const manual = modHelpShow()
38
+ const browse = modHelpBrowse()
39
+ const hint = modHelpHint()
40
+
41
+ /* the bands above the prompt (the synopsis form, the intent band, and the
42
+ manpage browser, opened in this order), the shown one (as at most one is
43
+ shown at a time), and closing all of them resp. the shown one on a failure
44
+ (determining the prompt to restore) */
45
+ const bands: Band[] = [ skill, intent, browse ]
46
+ const shown = () => bands.find((band) => band.active())
47
+ const reset = () => bands.forEach((band) => band.reset())
48
+ const cancel = () => shown()?.cancel()
49
+
50
+ /* the generation of the latest submission (ending any older refilling of the prompt) */
51
+ let submissions = 0
52
+
53
+ /* the partial slash command inside the prompt, and the poller of the prompt
54
+ for the hint band while one is typed (as a completion of the typeahead
55
+ raises no edit) */
56
+ const partial = /^\/\S*$/
57
+ let poller: Timer | undefined
58
+
59
+ /* handle the still unsubmitted prompt of the user: while the intent band,
60
+ the synopsis form, or the manpage browser is shown, every edit is a key of
61
+ it, else act on the typed trigger suffix at the end of the prompt only
62
+ (toggling the manual band for a skill command with "//", resp. showing the
63
+ synopsis form for a skill command with a manual page with "!!", else arming
64
+ the matching of the prompt as an intent with "!!", resp. showing the manpage
65
+ browser for any other prompt with "//"), close the manual band once the
66
+ prompt left its skill command or another band opened, track the skill
67
+ command of the hint band, and track the picks of the grill table inside
68
+ the edited prompt while no band is shown */
247
69
  on("prompt.edit", async ($, e, next) => {
248
- const box = await next(e)
249
- const now = pick(box.text)
250
- const sel = select(box.text)
251
- if (JSON.stringify([ ...now ]) !== JSON.stringify([ ...picks ]) || sel !== selected) {
252
- picks = now
253
- selected = sel
254
- $.ui.invalidate("ui.render")
70
+ const band = shown()
71
+ const paged = manual.active()
72
+ const read = (file: string) => $.fs.read(`${$.plugin.root}/${file}`)
73
+ const dirs = async (dir: string) => (await $.fs.list(`${$.plugin.root}/${dir}`))
74
+ .filter((entry) => entry.kind === "dir" || entry.isLink).map((entry) => entry.name)
75
+ const open = async (box: PromptBox) =>
76
+ await manual.toggle(box, { read }) ?? await skill.open(box, { read }) ?? intent.open(box, {
77
+ read,
78
+ invalidate: () => $.ui.invalidate("ui.render"),
79
+ fork: (prompt) => $.model.fork({ prompt }),
80
+ complete: (request) => $.model.complete(request),
81
+ sleep: (ms) => $.clock.sleep(ms)
82
+ }) ?? await browse.open(box, { read, dirs }) ?? box
83
+ let box: PromptBox
84
+ if (band !== undefined) {
85
+ const result = band.edit(e)
86
+ box = typeof result !== "string" ? result :
87
+ await next({ ...e, text: result, cursor: result.length, start: result.length, end: result.length })
88
+
89
+ /* switch over from a closed band to another one by the trigger it left in the prompt */
90
+ if (shown() === undefined && TRIGGER.test(box.text))
91
+ box = await open(box)
92
+ }
93
+ else {
94
+ box = await next(e)
95
+ if (/[!/]$/.test(e.inputText) && box.cursor === box.text.length && TRIGGER.test(box.text))
96
+ box = await open(box)
255
97
  }
98
+ manual.track(box.text)
99
+ if (shown() !== undefined)
100
+ manual.reset()
101
+ const hinted = await hint.track(box.text, { read })
102
+ if (poller === undefined && partial.test(box.text)) {
103
+ const stop = () => {
104
+ timer.cancel()
105
+ poller = undefined
106
+ }
107
+ const timer = $.clock.every(100, () => {
108
+ $.prompt.read().then(async ({ text }) => {
109
+ if (!partial.test(text))
110
+ stop()
111
+ if (shown() === undefined && await hint.track(text, { read }))
112
+ $.ui.invalidate("ui.render")
113
+ }).catch(stop)
114
+ })
115
+ poller = timer
116
+ }
117
+ if (shown() !== undefined || grill.track(box.text) || band !== undefined || paged || manual.active() || hinted)
118
+ $.ui.invalidate("ui.render")
256
119
  return box
257
- })
258
-
259
- /* freeze the picks in the latest table once the prompt is submitted */
260
- on("prompt.submit", ($, e, next) => {
261
- if (latest !== undefined)
262
- frozen.set(latest, pick(e.text))
263
- latest = undefined
264
- picks = new Map()
265
- selected = undefined
120
+ }).catch(($, e, next) => {
121
+ /* close a failed intent band resp. synopsis form and restore the prompt
122
+ resp. the command as typed (instead of the held prompt), else let the
123
+ edit pass */
124
+ const text = cancel()
266
125
  $.ui.invalidate("ui.render")
267
- return next(e)
126
+ return text !== undefined ? { text, cursor: text.length } : next(e)
268
127
  })
269
128
 
270
- /* redraw the replies which contain a grilling table (terminal only) */
271
- on("ui.render", { component: "AssistantMessage", surface: "terminal" }, async ($, e, next) => {
272
- /* act on grilling tables only, which fit into the terminal
273
- (gutter, grid lines, cell paddings, and a spare column subtracted) */
274
- columns = e.viewport?.columns ?? columns
275
- const table = parse(e.props.text)
276
- const avail = columns - 2 - 7 - 1
277
- if (table === undefined || avail < 40 || table.before.length > LIMIT || table.after.length > LIMIT)
278
- return next(e)
279
-
280
- /* let the chain beneath run, although its drawing is replaced by us
281
- (but this at least silences a warning in the debug output of Claude Code) */
282
- await next(e)
283
-
284
- /* determine the picks: frozen ones or, for the latest table, the live ones */
285
- if (!seen.has(e.requestId)) {
286
- seen.add(e.requestId)
287
- latest = e.requestId
288
- }
289
- const chosen = frozen.get(e.requestId) ?? (e.requestId === latest ? picks : undefined)
290
- const marked = e.requestId === latest ? selected : undefined
291
-
292
- /* style the cells and determine the column widths, right-aligning the
293
- question numbers to the widest one (so the wrapped lines of all
294
- questions share the same indent) */
295
- const head = table.header.map((cell) => style(`**${cell}**`, undefined))
296
- const digits = Math.max(...table.rows.map((row) => String(row.number).length))
297
- const rows = table.rows.map((row) => {
298
- const numbered = NUMBER.test(row.question)
299
- return {
300
- lead: numbered ? digits - String(row.number).length : 0,
301
- indent: numbered ? digits + 3 : 0,
302
- question: style(row.question, marked === row.number ? PICKED : undefined),
303
- answers: row.answers.map((answer) =>
304
- style(answer.text, chosen?.get(row.number) === answer.letter ? PICKED : undefined))
129
+ /* operate the synopsis form resp. the intent band, else scroll the manual
130
+ page in the band, by the wheel, and never let the engine move the window
131
+ over our (overflowing) band, even while it takes no scrolling */
132
+ on("ui.scroll", { component: "AbovePrompt" }, ($, e, next) => {
133
+ if (!bands.some((band) => band.scroll(e)) && !manual.scroll(e))
134
+ return shown() === undefined && !manual.active() ? next(e) : {}
135
+ $.ui.invalidate("ui.render")
136
+ return {}
137
+ }).catch(($, e, next) =>
138
+ /* let a failed scrolling of the synopsis form, the intent band, resp. the manual page pass */
139
+ next(e))
140
+
141
+ /* operate the intent band and the synopsis form by RETURN, resp. freeze the
142
+ picks in the latest table and close the intent band and the synopsis form
143
+ once the prompt is submitted */
144
+ on("prompt.submit", ($, e, next) => {
145
+ /* while the intent band or the synopsis form is shown, RETURN at the prompt
146
+ (which usually arrives as an edit of the held prompt instead, so this is
147
+ the fallback only) is a key of it, entering nothing and refilling the
148
+ prompt (immediately, and again whenever the submission cleared it or
149
+ restored the submitted prompt, as a duplicate of or next to the refilled
150
+ one, within a grace period) */
151
+ const band = shown()
152
+ if (band !== undefined && e.origin.kind === "composer") {
153
+ const text = band.submit()
154
+ const generation = ++submissions
155
+ $.ui.invalidate("ui.render")
156
+ const fill = async () => {
157
+ const box = await $.prompt.fill({ text })
158
+ if (shown() === undefined) {
159
+ grill.refresh(box.text)
160
+ $.ui.invalidate("ui.render")
161
+ }
305
162
  }
306
- })
307
- const w1nat = Math.max(width(head[0]!), ...rows.map((row) => row.lead + width(row.question)))
308
- const w2nat = Math.max(width(head[1]!), ...rows.flatMap((row) => row.answers.map((answer) => width(answer))))
309
- let w1 = Math.min(w1nat, Math.floor(avail * 0.4))
310
- const w2 = Math.min(w2nat, avail - w1)
311
- w1 = Math.min(w1nat, avail - w2)
312
-
313
- /* resolve the UI elements */
314
- const { Box, Text, Button, Markdown } = $.ui.resolve(e)
315
-
316
- /* split a line of a cell into runs of equally styled characters */
317
- const split = (chars: Char[]): { text: string, bold: boolean, color: string | undefined }[] => {
318
- const runs: { text: string, bold: boolean, color: string | undefined }[] = []
319
- for (let i = 0, j = 0; i < chars.length; i = j) {
320
- const { bold, color } = chars[i]!
321
- while (j < chars.length && chars[j]!.bold === bold && chars[j]!.color === color)
322
- j++
323
- runs.push({ text: chars.slice(i, j).map((char) => char.ch).join(""), bold, color })
163
+ const refill = async () => {
164
+ await fill()
165
+ const prompt = text.trim()
166
+ const submitted = e.text.trim()
167
+ for (const ms of [ 25, 50, 100, 200, 400 ]) {
168
+ await $.clock.sleep(ms)
169
+ const box = (await $.prompt.read()).text
170
+ if (generation !== submissions)
171
+ return
172
+ if (box === "" || box.indexOf(prompt) !== box.lastIndexOf(prompt)
173
+ || (submitted !== prompt && box.includes(submitted)))
174
+ await fill()
175
+ }
324
176
  }
325
- return runs
177
+ refill().catch(() => {
178
+ /* ignore a prompt which is no longer accessible */
179
+ })
180
+ return { text: e.text }
326
181
  }
327
182
 
328
- /* draw a line of a cell: its styled runs, padded to the column width */
329
- const paint = (chars: Char[], span: number): (string | RenderElement)[] => [
330
- ...split(chars).map(({ text, bold, color }) =>
331
- bold || color !== undefined ? Text({ bold, color, children: text }) : text),
332
- " ".repeat(Math.max(0, span - width(chars)))
333
- ]
183
+ /* let any other submission (like a task notification) pass while the
184
+ intent band or the synopsis form is shown, as it leaves the held
185
+ prompt in place */
186
+ if (band !== undefined)
187
+ return next(e)
334
188
 
335
- /* insert the number of a clicked question or the short response of a clicked
336
- answer into the prompt and refresh the picks and the selection from it */
337
- const press = (number: number, letter?: string) => () => {
338
- $.prompt.read()
339
- .then(({ text }) => $.prompt.fill({ text: insert(text, number, letter) }))
340
- .then((box) => {
341
- picks = pick(box.text)
342
- selected = select(box.text)
343
- $.ui.invalidate("ui.render")
344
- })
345
- .catch(() => {
346
- /* ignore a prompt which is no longer accessible */
347
- })
348
- }
189
+ submissions++
190
+ grill.submit(e.text)
191
+ reset()
192
+ manual.reset()
193
+ hint.reset()
194
+ $.ui.invalidate("ui.render")
195
+ return next(e)
196
+ }).catch(($, e, next) =>
197
+ /* never let a failed RETURN of the intent band or synopsis form enter the prompt */
198
+ next.called || shown() === undefined ? next(e) :
199
+ { drop: "intent band or synopsis form failed" })
200
+
201
+ /* redraw the replies which contain a grilling table (terminal only), where
202
+ the chain beneath runs, although its drawing is replaced by us (but this
203
+ at least silences a warning in the debug output of Claude Code) */
204
+ on("ui.render", { component: "AssistantMessage", surface: "terminal" }, async ($, e, next) => {
205
+ const drawn = await next(e)
206
+ return grill.render(e, $.ui.resolve(e), {
207
+ invalidate: () => $.ui.invalidate("ui.render"),
208
+ prompt: () => $.prompt.read(),
209
+ fill: (text) => $.prompt.fill({ text })
210
+ }) ?? drawn
211
+ })
349
212
 
350
- /* draw a line of a cell: in the latest table as the styled text inside a
351
- hover-scoping box, which while any line of its group (all lines of the
352
- same question or answer) is hovered reveals a row of clickable plain
353
- buttons (one per styled run, inversed in the default colors and carrying
354
- the run's bold style, but not its color, as its hover style, because the
355
- engine leaves the space cells uncolored) on top of it; otherwise as
356
- styled text only */
357
- const draw = (chars: Char[], span: number, group: string, line: number,
358
- onPress: () => void): RenderElement[] => {
359
- const w = width(chars)
360
- if (e.requestId !== latest || w === 0)
361
- return [ Text({ children: paint(chars, span) }) ]
362
- const buttons = split(chars).map(({ text, bold }, r) =>
363
- Button({ key: `${group}/${line}/${r}`, label: text, plain: true, onPress,
364
- hover: { scope: group, inverse: true, bold } }))
365
- return [
366
- Box({ key: `${group}/${line}`, hover: { scope: group }, children: [
367
- Text({ children: paint(chars, w) }),
368
- Box({ position: "absolute", top: 0, left: 0, display: "none",
369
- hover: { scope: group, display: "flex" }, flexDirection: "row", children: buttons })
370
- ] }),
371
- Text({ children: " ".repeat(Math.max(0, span - w)) })
372
- ]
213
+ /* draw the intent band resp. the synopsis form, else the manual page,
214
+ else the argument hint, into the band above the prompt (terminal only) */
215
+ on("ui.render", { component: "AbovePrompt", surface: "terminal" }, async ($, e, next) => {
216
+ const drawn = await next(e)
217
+ const ui = $.ui.resolve(e)
218
+ return shown()?.render(e, ui) ?? manual.render(e, ui) ?? hint.render(e, ui) ?? drawn
219
+ }).catch(($, e, next) => {
220
+ /* close a failed intent band resp. synopsis form, restore the prompt
221
+ resp. the command as typed (instead of the held prompt), resp. close
222
+ a failed manual band and hint band, and keep the drawing beneath */
223
+ const text = cancel()
224
+ if (text === undefined) {
225
+ manual.reset()
226
+ hint.reset()
373
227
  }
374
-
375
- /* draw the table as a grid: a rule between all rows and one answer per line */
376
- const rule = (l: string, m: string, r: string) =>
377
- Text({ wrap: "truncate-end", children: l + "─".repeat(w1 + 2) + m + "─".repeat(w2 + 2) + r })
378
- const grid: RenderElement[] = [ rule("┌", "┬", "┐") ]
379
- grid.push(Text({ wrap: "truncate-end", children:
380
- [ "│ ", ...paint(head[0]!, w1), " │ ", ...paint(head[1]!, w2), " │" ] }))
381
- grid.push(rule("├", "┼", "┤"))
382
- rows.forEach((row, k) => {
383
- const number = table.rows[k]!.number
384
- const q = wrap(row.question, w1, row.indent, row.lead).map((line, i) =>
385
- draw(line, w1, `q${number}`, i, press(number)))
386
- const a = row.answers.flatMap((answer, j) => {
387
- const letter = table.rows[k]!.answers[j]!.letter
388
- return wrap(answer, w2, INDENT).map((line, i) =>
389
- draw(line, w2, `a${number}${letter}`, i, press(number, letter)))
228
+ if (text !== undefined)
229
+ $.prompt.fill({ text }).catch(() => {
230
+ /* ignore a prompt which is no longer accessible */
390
231
  })
391
- for (let i = 0; i < Math.max(q.length, a.length); i++)
392
- grid.push(Box({ flexDirection: "row", children: [
393
- Text({ children: "│ " }),
394
- ...(q[i] ?? [ Text({ children: " ".repeat(w1) }) ]),
395
- Text({ children: " │ " }),
396
- ...(a[i] ?? [ Text({ children: " ".repeat(w2) }) ]),
397
- Text({ children: " │" })
398
- ] }))
399
- grid.push(k < rows.length - 1 ? rule("├", "┼", "┤") : rule("└", "┴", "┘"))
400
- })
232
+ return next(e)
233
+ })
401
234
 
402
- /* mimic the reply row of the engine: blank row above and bullet gutter */
403
- return Box({ flexDirection: "row", marginTop: 1, width: "100%", children: [
404
- Box({ flexShrink: 0, minWidth: 2, children: Text({ children: e.props.isFirstOfReply ? "⏺" : " " }) }),
405
- Box({ flexDirection: "column", flexGrow: 1, flexShrink: 1, children: [
406
- ...(table.before !== "" ? [ Markdown({ text: table.before }) ] : []),
407
- Box({ flexDirection: "column", marginTop: table.before !== "" ? 1 : 0, children: grid }),
408
- ...(table.after !== "" ? [ Box({ marginTop: 1, children: Markdown({ text: table.after }) }) ] : [])
409
- ] })
410
- ] })
235
+ /* drop the one-line (and hence usually truncated) argument hint of the
236
+ skills from the typeahead and "/help", as the hint band shows it entirely */
237
+ on("command.describe", async ($, e, next) => {
238
+ const result = await next(e)
239
+ const command = /^(?:ase:)?(ase-[a-z0-9]+-[a-z0-9]+)$/.exec(e.command)
240
+ const read = (file: string) => $.fs.read(`${$.plugin.root}/${file}`)
241
+ if (command === null || await hint.synopsis(command[1]!, { read }) === undefined)
242
+ return result
243
+ return { description: result.description, isHidden: result.isHidden }
411
244
  })
412
245
  }
413
246