@rse/ase 2.0.7 → 2.0.8
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-agent-relay-client.js +3 -4
- package/dst/ase-config-schema.js +4 -1
- package/dst/ase-lib-fetch.js +12 -0
- package/dst/ase-lib-server-cli.js +8 -5
- package/dst/ase-task-board-diff.js +120 -0
- package/dst/ase-task-board-tui-control.js +8 -5
- package/dst/ase-task-board-tui-model.js +54 -14
- package/dst/ase-task-board-tui-popup.js +41 -8
- package/dst/ase-task-board-tui.js +42 -27
- package/dst/ase-task-board-web-client.css +1 -1
- package/dst/ase-task-board-web-client.html +16 -0
- package/dst/ase-task-board-web-client.js +33 -33
- package/dst/ase-task-board-web-server.js +44 -9
- package/dst/ase-task-board.js +12 -4
- package/dst/ase-task-store-client.js +4 -5
- package/dst/man/ase-config-init.md +2 -1
- package/dst/man/ase-config.md +5 -0
- package/dst/man/ase-task-board.md +44 -2
- package/package.json +7 -6
- 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-common-task.md +24 -0
- package/plugin/mods/ase-mods-common.ts +73 -42
- package/plugin/mods/ase-mods-task-edit.ts +427 -0
- package/plugin/mods/ase-mods.ts +45 -14
- package/plugin/skills/ase-help-intent/data.md +78 -8
- package/plugin/skills/ase-task-edit/SKILL.md +114 -9
- package/plugin/skills/ase-task-edit/help.md +60 -0
- package/plugin/skills/ase-task-grill/SKILL.md +35 -20
- package/plugin/skills/ase-task-grill/help.md +18 -8
- /package/plugin/mods/{ase-mods-evaluate.ts → ase-mods-meta-evaluate.ts} +0 -0
- /package/plugin/mods/{ase-mods-grill.ts → ase-mods-task-grill.ts} +0 -0
|
@@ -6,20 +6,21 @@
|
|
|
6
6
|
|
|
7
7
|
/* Claude Code function hooks module ("mod", EARLY ACCESS):
|
|
8
8
|
Common parts, shared by the grill table, the decision matrix, the intent
|
|
9
|
-
help, the synopsis form, the manual band, the manpage browser,
|
|
10
|
-
band: the rows of the Markdown tables, the styled characters of the terminal
|
|
9
|
+
help, the synopsis form, the manual band, the manpage browser, the hint band,
|
|
10
|
+
and the task band: the rows of the Markdown tables, the styled characters of the terminal
|
|
11
11
|
drawing, their Markdown conversion, wrapping, and framing, and the contract,
|
|
12
12
|
keys, geometry, and drawing of the master/detail view in the band above the
|
|
13
13
|
prompt. */
|
|
14
14
|
|
|
15
15
|
import type { Elements, Frozen, MatchedEvent, ModelCompleteRequest, ModelForkResult,
|
|
16
|
-
PromptBox, PromptEditInput, PromptFilled, RenderElement } from "claude-code"
|
|
16
|
+
PromptBox, PromptEditInput, PromptFilled, RenderElement, Timer } from "claude-code"
|
|
17
17
|
|
|
18
18
|
/* the services of the engine, bound by the hooking infrastructure for the
|
|
19
19
|
modules (as the engine refuses to load a module passing "$" itself): the
|
|
20
20
|
redrawing, the reading of a plugin file and of the names of the directories
|
|
21
21
|
in a plugin directory, the forked and the plain model completion, the
|
|
22
|
-
waiting,
|
|
22
|
+
waiting, the reading and filling of the prompt, the loading of a task
|
|
23
|
+
(with its entity tag), and the running of a repeating timer */
|
|
23
24
|
export type Engine = {
|
|
24
25
|
invalidate: () => void,
|
|
25
26
|
read: (file: string) => Promise<string>,
|
|
@@ -28,7 +29,9 @@ export type Engine = {
|
|
|
28
29
|
complete: (request: ModelCompleteRequest) => Promise<string>,
|
|
29
30
|
sleep: (ms: number) => Promise<void>,
|
|
30
31
|
prompt: () => Promise<PromptBox>,
|
|
31
|
-
fill: (text: string) => Promise<PromptFilled
|
|
32
|
+
fill: (text: string) => Promise<PromptFilled>,
|
|
33
|
+
load: (id: string) => Promise<string>,
|
|
34
|
+
every: (ms: number, fn: () => void) => Timer
|
|
32
35
|
}
|
|
33
36
|
|
|
34
37
|
/* the colors of a picked (resp. selected or focused) item, of a code span,
|
|
@@ -131,7 +134,7 @@ export const cells = (line: string): string[] => {
|
|
|
131
134
|
}
|
|
132
135
|
|
|
133
136
|
/* a single styled character of the terminal drawing */
|
|
134
|
-
export type Char = { ch: string, bold: boolean, color: string | undefined }
|
|
137
|
+
export type Char = { ch: string, bold: boolean, color: string | undefined, italic?: boolean, inverse?: boolean, background?: string }
|
|
135
138
|
|
|
136
139
|
/* extract a section of a manual page (without its heading) */
|
|
137
140
|
export const section = (manual: string, name: string): string =>
|
|
@@ -225,16 +228,21 @@ export const wrap = (chars: Char[], columns: number, indent: number, lead = 0):
|
|
|
225
228
|
}
|
|
226
229
|
|
|
227
230
|
/* a run of equally styled characters */
|
|
228
|
-
type Run = { text: string, bold: boolean, color: string | undefined }
|
|
231
|
+
type Run = { text: string, bold: boolean, color: string | undefined, italic: boolean, inverse: boolean, background: string | undefined }
|
|
229
232
|
|
|
230
233
|
/* split a line of styled characters into runs of equally styled characters */
|
|
231
234
|
export const split = (chars: Char[]): Run[] => {
|
|
232
235
|
const runs: Run[] = []
|
|
233
236
|
for (let i = 0, j = 0; i < chars.length; i = j) {
|
|
234
237
|
const { bold, color } = chars[i]!
|
|
235
|
-
|
|
238
|
+
const italic = chars[i]!.italic ?? false
|
|
239
|
+
const inverse = chars[i]!.inverse ?? false
|
|
240
|
+
const { background } = chars[i]!
|
|
241
|
+
while (j < chars.length && chars[j]!.bold === bold && chars[j]!.color === color &&
|
|
242
|
+
(chars[j]!.italic ?? false) === italic && (chars[j]!.inverse ?? false) === inverse &&
|
|
243
|
+
chars[j]!.background === background)
|
|
236
244
|
j++
|
|
237
|
-
runs.push({ text: chars.slice(i, j).map((char) => char.ch).join(""), bold, color })
|
|
245
|
+
runs.push({ text: chars.slice(i, j).map((char) => char.ch).join(""), bold, color, italic, inverse, background })
|
|
238
246
|
}
|
|
239
247
|
return runs
|
|
240
248
|
}
|
|
@@ -307,15 +315,15 @@ export const frame = (lines: Char[][], columns: number, color: string | undefine
|
|
|
307
315
|
}
|
|
308
316
|
|
|
309
317
|
/* split a horizontal edge of a frame into runs with a title placed after its
|
|
310
|
-
first and another one before its last
|
|
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 +
|
|
314
|
-
const start = head !== "" && line.length >= head.length + (end !== "" ? end.length +
|
|
315
|
-
const k = end !== "" ? line.length -
|
|
318
|
+
first "lead" and another one before its last "trail" characters (each if it
|
|
319
|
+
fits, the one at the end with precedence), flagged whether they are a title */
|
|
320
|
+
const titled = (line: string, head: string, tail: string, lead: number, trail: number): [ string, boolean ][] => {
|
|
321
|
+
const end = tail !== "" && line.length >= tail.length + trail + 2 ? tail : ""
|
|
322
|
+
const start = head !== "" && line.length >= head.length + lead + trail + (end !== "" ? end.length + 2 : 0) ? head : ""
|
|
323
|
+
const k = end !== "" ? line.length - trail - end.length : line.length
|
|
316
324
|
return [
|
|
317
|
-
...(start !== "" ? [ [ line.slice(0,
|
|
318
|
-
[ line.slice(start !== "" ?
|
|
325
|
+
...(start !== "" ? [ [ line.slice(0, lead), false ], [ start, true ] ] as [ string, boolean ][] : []),
|
|
326
|
+
[ line.slice(start !== "" ? lead + start.length : 0, k), false ],
|
|
319
327
|
...(end !== "" ? [ [ end, true ], [ line.slice(k + end.length), false ] ] as [ string, boolean ][] : [])
|
|
320
328
|
]
|
|
321
329
|
}
|
|
@@ -348,14 +356,15 @@ export type Text = Elements["terminal"]["Text"]
|
|
|
348
356
|
|
|
349
357
|
/* draw a line of styled characters: its styled runs */
|
|
350
358
|
export const paint = (Text: Text, chars: Char[]): (string | RenderElement)[] =>
|
|
351
|
-
split(chars).map(({ text, bold, color }) =>
|
|
352
|
-
bold ||
|
|
359
|
+
split(chars).map(({ text, bold, color, italic, inverse, background }) =>
|
|
360
|
+
bold || italic || inverse || color !== undefined || background !== undefined ?
|
|
361
|
+
Text({ bold, italic, inverse, color, backgroundColor: background, children: text }) : text)
|
|
353
362
|
|
|
354
363
|
/* draw a horizontal edge of the self-drawn frame (dimmed), with titles at its
|
|
355
364
|
start and its end (with their brackets dimmed, an "ASE" prefix before a colon
|
|
356
365
|
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 ]) => {
|
|
366
|
+
const edge = (Text: Text, line: string, head = "", tail = "", lead = 2, trail = 2): RenderElement =>
|
|
367
|
+
Text({ children: titled(line, head, tail, lead, trail).flatMap(([ text, label ]) => {
|
|
359
368
|
const m = /^(\[ )?([^:]*)(:.*?)( \])?$/.exec(text)
|
|
360
369
|
if (!label || m === null)
|
|
361
370
|
return [ Text({ ...BORDER, children: text }) ]
|
|
@@ -367,9 +376,10 @@ const edge = (Text: Text, line: string, head = "", tail = ""): RenderElement =>
|
|
|
367
376
|
]
|
|
368
377
|
}) })
|
|
369
378
|
|
|
370
|
-
/* draw a row of a scrollbar: the thumb
|
|
379
|
+
/* draw a row of a scrollbar: the dark grey thumb (an inverse space, as it fills
|
|
380
|
+
the entire cell, unlike a block glyph), the (dimmed) track, or nothing */
|
|
371
381
|
const bar = (Text: Text, mark: Mark | undefined): RenderElement =>
|
|
372
|
-
mark === "thumb" ? Text({ children: "
|
|
382
|
+
mark === "thumb" ? Text({ inverse: true, color: "ansi256(242)", children: " " }) :
|
|
373
383
|
mark === "track" ? Text({ dimColor: true, children: "░" }) : Text({ children: " " })
|
|
374
384
|
|
|
375
385
|
/* the geometry of the band above the prompt: the inner columns of the self-drawn
|
|
@@ -384,7 +394,7 @@ export type Layout = { span: number, body: number, master: number, detail: numbe
|
|
|
384
394
|
and, once focused, by the keys only for a tree taller than the band), all rows of
|
|
385
395
|
the band but the one of the engine's "n more" row (as the band then takes them
|
|
386
396
|
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
|
|
397
|
+
detail view (with a separator column between them, and a gap, a separator, and
|
|
388
398
|
a scrollbar column right of the detail view) */
|
|
389
399
|
export const layout = (e: BandRender, share: number, band = BAND): Layout => {
|
|
390
400
|
const span = Math.max(20, e.props.bodyColumns - FRAME.cols)
|
|
@@ -412,10 +422,14 @@ export const wheel = (e: BandScroll, view: View, step?: (by: number) => void): v
|
|
|
412
422
|
|
|
413
423
|
/* draw the band above the prompt: inside the self-drawn frame (with the title
|
|
414
424
|
in its top edge) either a single view of the given rows, a single view of the
|
|
415
|
-
given lines with its scrollbar
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
425
|
+
given lines with its scrollbar (optionally with the first given characters of
|
|
426
|
+
each line in a gutter at the left border, separated by a vertical line joining
|
|
427
|
+
the edges, with the background of a line filling the columns up to its scrollbar),
|
|
428
|
+
or a master view of the given rows (where the given rule rows are horizontal lines
|
|
429
|
+
joining the borders) and, joined by a separator, a detail view of the given lines
|
|
430
|
+
(optionally dimmed) with its scrollbar, every scrollbar (of lines not always
|
|
431
|
+
fitting) separated by a vertical line joining the edges,
|
|
432
|
+
into the bottom edge the given status (if any) at its start and
|
|
419
433
|
the scroll position of the lines (unless they always fit) at its end, and below
|
|
420
434
|
the frame the key hints (followed by blank rows for an overflowing band),
|
|
421
435
|
where the scroll position is clamped, and the page size (for the scroll keys)
|
|
@@ -428,7 +442,8 @@ export const panel = (ui: Elements["terminal"], { span, body, master, detail, ov
|
|
|
428
442
|
rule?: (i: number) => boolean,
|
|
429
443
|
lines?: Char[][],
|
|
430
444
|
dim?: boolean,
|
|
431
|
-
fit?: boolean
|
|
445
|
+
fit?: boolean,
|
|
446
|
+
gutter?: number
|
|
432
447
|
}): RenderElement => {
|
|
433
448
|
const { Box, Text } = ui
|
|
434
449
|
const lines = spec.lines ?? []
|
|
@@ -438,22 +453,38 @@ export const panel = (ui: Elements["terminal"], { span, body, master, detail, ov
|
|
|
438
453
|
view.scroll = Math.max(0, Math.min(view.scroll, lines.length - body))
|
|
439
454
|
const marks = scrollbar(body, lines.length, view.scroll)
|
|
440
455
|
const blank = Text({ children: " " })
|
|
456
|
+
const gutter = solo ? spec.gutter ?? 0 : 0
|
|
457
|
+
const rail = spec.lines !== undefined && spec.fit !== true
|
|
458
|
+
const lead = gutter > 0 ? gutter + 2 : 2
|
|
459
|
+
const trail = rail ? 3 : 2
|
|
441
460
|
const border = (left: string, joint: string, right: string) =>
|
|
442
|
-
left + (
|
|
443
|
-
"─".repeat(
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
461
|
+
left + (!rail ? "─".repeat(span + 1) :
|
|
462
|
+
(gutter > 0 ? `${"─".repeat(gutter)}${joint}${"─".repeat(span - 2 - gutter)}` :
|
|
463
|
+
solo ? "─".repeat(span - 1) :
|
|
464
|
+
`${"─".repeat(master + 2)}${joint}${"─".repeat(detail + 2)}`) + `${joint}─`) + right
|
|
465
|
+
const scrolled = (i: number, width: number) => {
|
|
466
|
+
const line = lines[view.scroll + i] ?? []
|
|
467
|
+
const fill = line[0]?.background
|
|
468
|
+
return [
|
|
469
|
+
...(gutter > 0 ? [
|
|
470
|
+
Box({ width: gutter, flexShrink: 0, children: Text({ children: paint(Text, line.slice(0, gutter)) }) }),
|
|
471
|
+
Text({ ...BORDER, children: "│" }),
|
|
472
|
+
Text({ backgroundColor: fill, children: " " })
|
|
473
|
+
] : []),
|
|
474
|
+
Box({ width, flexShrink: 0, children:
|
|
475
|
+
Text({ wrap: "truncate-end", dimColor: spec.dim ?? false,
|
|
476
|
+
children: paint(Text, line.slice(gutter)) }) }),
|
|
477
|
+
...(rail ? [ Text({ backgroundColor: fill, children: " " }), Text({ ...BORDER, children: "│" }) ] :
|
|
478
|
+
[ Text({ children: " " }) ]),
|
|
479
|
+
bar(Text, marks[i])
|
|
480
|
+
]
|
|
481
|
+
}
|
|
482
|
+
const rows: RenderElement[] = [ edge(Text, border("╭", "┬", "╮"), spec.title, "", lead, trail) ]
|
|
452
483
|
for (let i = 0; i < body; i++) {
|
|
453
484
|
const rule = !solo && spec.lines !== undefined && spec.rule?.(i) === true
|
|
454
485
|
rows.push(Box({ flexDirection: "row", children: [
|
|
455
|
-
Text({ ...BORDER, children: rule ? "├─" : "│ " }),
|
|
456
|
-
...(solo ? scrolled(i, span - 3) : spec.lines === undefined ? [
|
|
486
|
+
Text({ ...BORDER, children: rule ? "├─" : gutter > 0 ? "│" : "│ " }),
|
|
487
|
+
...(solo ? scrolled(i, gutter > 0 ? span - 4 - gutter : span - 3) : spec.lines === undefined ? [
|
|
457
488
|
Box({ width: span, flexShrink: 0, children: spec.rows!(i) ?? blank })
|
|
458
489
|
] : [
|
|
459
490
|
rule ? Text({ ...BORDER, children: `${"─".repeat(master + 1)}┤ ` }) :
|
|
@@ -465,7 +496,7 @@ export const panel = (ui: Elements["terminal"], { span, body, master, detail, ov
|
|
|
465
496
|
] }))
|
|
466
497
|
}
|
|
467
498
|
rows.push(edge(Text, border("╰", "┴", "╯"), spec.status,
|
|
468
|
-
spec.fit === true ? "" : position(body, lines.length, view.scroll)))
|
|
499
|
+
spec.fit === true ? "" : position(body, lines.length, view.scroll), lead, trail))
|
|
469
500
|
rows.push(Box({ paddingLeft: 1, children: Text({ dimColor: true, wrap: "truncate-end", children: spec.hints }) }))
|
|
470
501
|
if (overflow)
|
|
471
502
|
rows.push(blank, blank)
|
|
@@ -0,0 +1,427 @@
|
|
|
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
|
+
Task band, showing for "ase-task-edit --interactive" (signaled through a tool
|
|
9
|
+
registered by this mod, whose absence tells the skill that no band exists) the
|
|
10
|
+
task in the band above the prompt, rendered like the read dialog of the task
|
|
11
|
+
board TUI, following all changes of the task (keeping the scroll position and
|
|
12
|
+
showing the last change as a unified diff with line numbers) and scrolled by the
|
|
13
|
+
wheel, while the prompt stays free for typing. A "##" typed at the end of the
|
|
14
|
+
prompt drops the diff, showing just the latest state of the task. The band closes
|
|
15
|
+
once a next step of the skill (like "DONE") is submitted at the prompt. */
|
|
16
|
+
|
|
17
|
+
import type { Elements, PromptBox, RenderElement, Timer } from "claude-code"
|
|
18
|
+
import { type Engine, type Char, type BandRender, type BandScroll, type View, PICKED,
|
|
19
|
+
uniform, width, wrap, layout, wheel, panel } from "./ase-mods-common.ts"
|
|
20
|
+
|
|
21
|
+
/* the tool signaling the band, as listed for the model */
|
|
22
|
+
export const TOOL = {
|
|
23
|
+
name: "ase_task_band",
|
|
24
|
+
description: "Open the band above the prompt, which shows the task `id` and follows its changes, " +
|
|
25
|
+
"for \"ase-task-edit --interactive\". Returns `OK: ...` or `ERROR: ...` as text.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
id: { type: "string", description: "task identifier" },
|
|
30
|
+
sections: { type: "string", description: "comma-separated names of the plan sections under focus, " +
|
|
31
|
+
"or empty to show the entire task" }
|
|
32
|
+
},
|
|
33
|
+
required: [ "id" ]
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/* the next steps of the skill closing the band once submitted, the entity tag
|
|
38
|
+
at the end of a loaded task (present for an existing task only), the share of
|
|
39
|
+
the terminal height taken by the band outside fullscreen, the interval of
|
|
40
|
+
following the task changes, the least digits of the line numbers, the columns of
|
|
41
|
+
comparing the shown task, and the colors of the rendering (the "dim", "accent", and
|
|
42
|
+
"signal" roles of the task board TUI, the first also for the line numbers, and the
|
|
43
|
+
lines and words of the diff, with the codes the diffs of the session end up with, so
|
|
44
|
+
they look alike under any recoloring, where the accent and the removed lines use
|
|
45
|
+
the terminal palette, as the engine maps a color name to a fixed color and
|
|
46
|
+
refuses "ansi:*"), and the changed share of the words up to which they are shown */
|
|
47
|
+
const CLOSE = /^(?:DONE|GRILL|DRAFT|IMPLEMENT|CANCEL)$/i
|
|
48
|
+
const TAG = /\n<task-plan-tag>[^<]*<\/task-plan-tag>\s*$/
|
|
49
|
+
const HEIGHT = 0.75
|
|
50
|
+
const POLL = 2000
|
|
51
|
+
const DIGITS = 2
|
|
52
|
+
const WIDE = 1000
|
|
53
|
+
const DIM = "ansi256(244)"
|
|
54
|
+
const ACCENT = "ansi256(4)"
|
|
55
|
+
const SIGNAL = PICKED
|
|
56
|
+
const REMOVE = "ansi256(1)"
|
|
57
|
+
const ADDBG = "ansi256(194)"
|
|
58
|
+
const DELBG = "ansi256(224)"
|
|
59
|
+
const ADDWD = "ansi256(157)"
|
|
60
|
+
const DELWD = "ansi256(217)"
|
|
61
|
+
const DELFG = "ansi256(16)"
|
|
62
|
+
const ALIKE = 0.4
|
|
63
|
+
|
|
64
|
+
/* a task band: the scroll state of its frame, the task, the plan sections under
|
|
65
|
+
focus (empty for the entire task), the loaded task and its predecessor (if it
|
|
66
|
+
already changed), and the lines of the task with the digits of their line
|
|
67
|
+
numbers and the band columns they were wrapped for */
|
|
68
|
+
type Band = View & {
|
|
69
|
+
id: string,
|
|
70
|
+
sections: string[],
|
|
71
|
+
loaded: string,
|
|
72
|
+
before: string | undefined,
|
|
73
|
+
lines: Char[][],
|
|
74
|
+
digits: number,
|
|
75
|
+
columns: number
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/* make a text safe for the terminal: expand tabs and strip control characters
|
|
79
|
+
(a copy of "sanitize" of "tool/src/ase-task-board-tui-popup.ts", so keep it in sync) */
|
|
80
|
+
const sanitize = (text: string): string =>
|
|
81
|
+
text.replace(/\t/g, " ").replace(/[\p{Cc}\u{061C}\u{200E}\u{200F}\u{202A}-\u{202E}\u{2066}-\u{2069}]/gu, "")
|
|
82
|
+
|
|
83
|
+
/* a shown row of a task: its styled characters and the line of the task text it
|
|
84
|
+
shows, as seen in the editor (absent for the continuation of a wrapped line) */
|
|
85
|
+
type Row = { chars: Char[], number: number | undefined }
|
|
86
|
+
|
|
87
|
+
/* split a task into the keys of its frontmatter (without "Type"), each with its
|
|
88
|
+
line, and its body with its first line (without the backmatter, which starts
|
|
89
|
+
at the first "---" line outside a fenced code block) */
|
|
90
|
+
const parts = (source: string): { keys: [ string, string, number ][], body: string, first: number } => {
|
|
91
|
+
const head = /^---\n([^]*?)\n---\n/.exec(source)
|
|
92
|
+
const lines = source.slice(head?.[0].length ?? 0).split("\n")
|
|
93
|
+
let fence = false
|
|
94
|
+
const end = lines.findIndex((line) => {
|
|
95
|
+
if (line.startsWith("```"))
|
|
96
|
+
fence = !fence
|
|
97
|
+
return !fence && line === "---"
|
|
98
|
+
})
|
|
99
|
+
return {
|
|
100
|
+
keys: (head?.[1] ?? "").split("\n").flatMap((line, k): [ string, string, number ][] => {
|
|
101
|
+
const m = /^([^:\s]+):\s*(.*)$/.exec(line)
|
|
102
|
+
return m !== null && m[1] !== "Type" ? [ [ m[1]!, m[2]!, k + 2 ] ] : []
|
|
103
|
+
}),
|
|
104
|
+
body: (end === -1 ? lines : lines.slice(0, end)).join("\n"),
|
|
105
|
+
first: head?.[0].split("\n").length ?? 1
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/* convert inline Markdown (code spans, bold, and italic) into styled characters */
|
|
110
|
+
const inline = (text: string, bold: boolean, color: string | undefined): Char[] => {
|
|
111
|
+
const chars: Char[] = []
|
|
112
|
+
let last = 0
|
|
113
|
+
for (const m of text.matchAll(/`([^`]+)`|\*\*(.+?)\*\*|__(.+?)__|\*([^*\s](?:[^*]*?[^*\s])?)\*/g)) {
|
|
114
|
+
chars.push(...uniform(text.slice(last, m.index), bold, color))
|
|
115
|
+
const body = uniform(m[1] ?? m[2] ?? m[3] ?? m[4]!, bold || m[2] !== undefined || m[3] !== undefined,
|
|
116
|
+
m[1] !== undefined ? ACCENT : color)
|
|
117
|
+
chars.push(...(m[4] !== undefined ? body.map((char) => ({ ...char, italic: true })) : body))
|
|
118
|
+
last = m.index + m[0].length
|
|
119
|
+
}
|
|
120
|
+
chars.push(...uniform(text.slice(last), bold, color))
|
|
121
|
+
return chars
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* convert a list marker into styled characters, where a checkbox of an item which
|
|
125
|
+
is done, in progress, or open is accented, and any other checkbox is signaled */
|
|
126
|
+
const marker = (prefix: string): Char[] => {
|
|
127
|
+
const chars = uniform(prefix, false, undefined)
|
|
128
|
+
const m = /^(\s*(?:[-*]|\d+[.)])\s+)\[(.)\]/.exec(prefix)
|
|
129
|
+
if (m !== null)
|
|
130
|
+
for (let k = m[1]!.length; k < m[1]!.length + 3; k++)
|
|
131
|
+
chars[k] = { ...chars[k]!, color: /[x/ ]/.test(m[2]!) ? ACCENT : SIGNAL }
|
|
132
|
+
return chars
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/* convert a Markdown body, starting at the given line of the task text, into rows,
|
|
136
|
+
hard-wrapped to the given columns (except for the lines of fenced code blocks) */
|
|
137
|
+
const markdown = (body: string, columns: number, first: number): Row[] => {
|
|
138
|
+
const rows: Row[] = []
|
|
139
|
+
const lines = body.split("\n")
|
|
140
|
+
const wrapped = (chars: Char[][], number: number): Row[] =>
|
|
141
|
+
chars.map((chars, k) => ({ chars, number: k === 0 ? number : undefined }))
|
|
142
|
+
let fence = false
|
|
143
|
+
if (lines.at(-1) === "")
|
|
144
|
+
lines.pop()
|
|
145
|
+
for (const [ k, line ] of lines.entries()) {
|
|
146
|
+
const number = first + k
|
|
147
|
+
const heading = /^(#{1,6})[ \t]+/.exec(line)
|
|
148
|
+
const prefix = /^\s*(?:(?:[-*]|\d+[.)])\s+(?:\[.\]\s+)?)?/.exec(line)![0]
|
|
149
|
+
if (line.trimStart().startsWith("```"))
|
|
150
|
+
fence = !fence
|
|
151
|
+
else if (fence)
|
|
152
|
+
rows.push({ chars: uniform(line, false, ACCENT), number })
|
|
153
|
+
else if (line.trim() === "")
|
|
154
|
+
rows.push({ chars: [], number })
|
|
155
|
+
else if (heading !== null)
|
|
156
|
+
rows.push(...wrapped(wrap(inline(line.slice(heading[0].length), true,
|
|
157
|
+
heading[1]!.length <= 4 ? ACCENT : undefined), columns, 0), number))
|
|
158
|
+
else {
|
|
159
|
+
const text = wrap(inline(line.slice(prefix.length), false, undefined), columns, prefix.length, prefix.length)
|
|
160
|
+
text[0]!.splice(0, prefix.length, ...marker(prefix))
|
|
161
|
+
rows.push(...wrapped(text, number))
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
return rows
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/* convert a task into rows, hard-wrapped to the given columns: under a focus only
|
|
168
|
+
the focused sections (in plan order), else the keys of the frontmatter (each cut
|
|
169
|
+
to the columns), a rule for its end, and the body, all without trailing blank rows */
|
|
170
|
+
const page = (task: string, sections: string[], columns: number): Row[] => {
|
|
171
|
+
const { keys, body, first } = parts(task.replace(TAG, "").split("\n").map(sanitize).join("\n"))
|
|
172
|
+
const rows: Row[] = []
|
|
173
|
+
if (sections.length > 0) {
|
|
174
|
+
let line = first
|
|
175
|
+
for (const part of body.split(/^(?=##\s)/m)) {
|
|
176
|
+
if (sections.includes(/^##\s+(\S+)/.exec(part)?.[1] ?? ""))
|
|
177
|
+
rows.push(...markdown(part, columns, line))
|
|
178
|
+
line += part.split("\n").length - 1
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
else {
|
|
182
|
+
for (const [ key, val, number ] of keys) {
|
|
183
|
+
const text = `${`${key}:`.padEnd(11)}${val}`
|
|
184
|
+
rows.push({ chars: uniform(text.length > columns ? `${text.slice(0, Math.max(0, columns - 1))}…` : text, false, DIM), number })
|
|
185
|
+
}
|
|
186
|
+
if (keys.length > 0)
|
|
187
|
+
rows.push({ chars: uniform("─".repeat(columns), false, DIM), number: first - 1 })
|
|
188
|
+
rows.push(...markdown(body, columns, first))
|
|
189
|
+
}
|
|
190
|
+
while (rows.at(-1)?.chars.length === 0)
|
|
191
|
+
rows.pop()
|
|
192
|
+
return rows
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/* prefix a line with its line number (if any): grey for an unchanged line, resp.
|
|
196
|
+
colored for a changed line, whose number and text get a background over all
|
|
197
|
+
columns (except for its already emphasized words) */
|
|
198
|
+
const numbered = (line: Char[], number: number | undefined, digits: number, columns: number, sign?: "-" | "+"): Char[] => {
|
|
199
|
+
const cell = (number !== undefined ? String(number) : "").padStart(digits)
|
|
200
|
+
if (sign === undefined)
|
|
201
|
+
return [ ...uniform(cell, false, DIM), ...line ]
|
|
202
|
+
const background = sign === "-" ? DELBG : ADDBG
|
|
203
|
+
return [
|
|
204
|
+
...uniform(cell, false, sign === "-" ? REMOVE : ACCENT),
|
|
205
|
+
...line,
|
|
206
|
+
...uniform(" ".repeat(Math.max(0, columns - width(line))), false, undefined)
|
|
207
|
+
].map((char) => ({ ...char, background: char.background ?? background }))
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* align two lists of strings along the longest common subsequence between their
|
|
211
|
+
common ends, into steps of a kept, removed, or added string (with the removed
|
|
212
|
+
before the added strings of a change) */
|
|
213
|
+
type Step = { sign: " " | "-" | "+", i: number, j: number }
|
|
214
|
+
const align = (a: string[], b: string[]): Step[] => {
|
|
215
|
+
let head = 0
|
|
216
|
+
while (head < a.length && head < b.length && a[head] === b[head])
|
|
217
|
+
head++
|
|
218
|
+
let tail = 0
|
|
219
|
+
while (tail < a.length - head && tail < b.length - head && a[a.length - 1 - tail] === b[b.length - 1 - tail])
|
|
220
|
+
tail++
|
|
221
|
+
const n = a.length - head - tail
|
|
222
|
+
const m = b.length - head - tail
|
|
223
|
+
const span = m + 1
|
|
224
|
+
const lcs = new Uint32Array((n + 1) * span)
|
|
225
|
+
for (let i = n - 1; i >= 0; i--)
|
|
226
|
+
for (let j = m - 1; j >= 0; j--)
|
|
227
|
+
lcs[i * span + j] = a[head + i] === b[head + j] ? lcs[(i + 1) * span + j + 1]! + 1 :
|
|
228
|
+
Math.max(lcs[(i + 1) * span + j]!, lcs[i * span + j + 1]!)
|
|
229
|
+
const steps: Step[] = Array.from({ length: head }, (_, k) => ({ sign: " ", i: k, j: k }))
|
|
230
|
+
let i = 0
|
|
231
|
+
let j = 0
|
|
232
|
+
while (i < n || j < m) {
|
|
233
|
+
if (i < n && j < m && a[head + i] === b[head + j])
|
|
234
|
+
steps.push({ sign: " ", i: head + i++, j: head + j++ })
|
|
235
|
+
else if (i < n && (j === m || lcs[(i + 1) * span + j]! >= lcs[i * span + j + 1]!))
|
|
236
|
+
steps.push({ sign: "-", i: head + i++, j: head + j })
|
|
237
|
+
else
|
|
238
|
+
steps.push({ sign: "+", i: head + i, j: head + j++ })
|
|
239
|
+
}
|
|
240
|
+
for (let k = tail; k > 0; k--)
|
|
241
|
+
steps.push({ sign: " ", i: a.length - k, j: b.length - k })
|
|
242
|
+
return steps
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* split lines into their words and other non-blank characters, each with its position */
|
|
246
|
+
type Token = { text: string, row: number, from: number, to: number }
|
|
247
|
+
const tokens = (lines: Char[][]): Token[] => lines.flatMap((line, row) => {
|
|
248
|
+
const list: Token[] = []
|
|
249
|
+
for (let from = 0, to = 0; from < line.length; from = to) {
|
|
250
|
+
to = from + 1
|
|
251
|
+
if (/[\p{L}\p{N}_]/u.test(line[from]!.ch))
|
|
252
|
+
while (to < line.length && /[\p{L}\p{N}_]/u.test(line[to]!.ch))
|
|
253
|
+
to++
|
|
254
|
+
if (line[from]!.ch !== " ")
|
|
255
|
+
list.push({ text: line.slice(from, to).map((char) => char.ch).join(""), row, from, to })
|
|
256
|
+
}
|
|
257
|
+
return list
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
/* emphasize the changed words of the removed and added lines of a change (and the
|
|
261
|
+
blanks between adjacent ones), unless too many of the words changed, where the
|
|
262
|
+
words alone decide, so a change of the wrapping only emphasizes nothing */
|
|
263
|
+
const emphasize = (removed: Char[][], added: Char[][]): [ Char[][], Char[][] ] => {
|
|
264
|
+
const a = tokens(removed)
|
|
265
|
+
const b = tokens(added)
|
|
266
|
+
const steps = align(a.map((token) => token.text), b.map((token) => token.text))
|
|
267
|
+
if (steps.filter(({ sign }) => sign !== " ").length > (a.length + b.length) * ALIKE)
|
|
268
|
+
return [ removed, added ]
|
|
269
|
+
const del = removed.map((line) => [ ...line ])
|
|
270
|
+
const add = added.map((line) => [ ...line ])
|
|
271
|
+
let last: Token | undefined
|
|
272
|
+
let kind = " "
|
|
273
|
+
for (const { sign, i, j } of steps) {
|
|
274
|
+
const token = sign === "-" ? a[i]! : sign === "+" ? b[j]! : undefined
|
|
275
|
+
if (token !== undefined) {
|
|
276
|
+
const line = (sign === "-" ? del : add)[token.row]!
|
|
277
|
+
for (let k = kind === sign && last?.row === token.row ? last.to : token.from; k < token.to; k++)
|
|
278
|
+
line[k] = sign === "-" ? { ...line[k]!, color: DELFG, background: DELWD } : { ...line[k]!, background: ADDWD }
|
|
279
|
+
}
|
|
280
|
+
last = token
|
|
281
|
+
kind = sign
|
|
282
|
+
}
|
|
283
|
+
return [ del, add ]
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/* merge the old and the new rows of a task into the lines of their unified diff,
|
|
287
|
+
each with its line number (the old one for a removed row, else the new one),
|
|
288
|
+
where only the shown characters of the rows decide about a change */
|
|
289
|
+
const diff = (before: Row[], after: Row[], digits: number, columns: number): Char[][] => {
|
|
290
|
+
const lines: Char[][] = []
|
|
291
|
+
let removed: Row[] = []
|
|
292
|
+
let added: Row[] = []
|
|
293
|
+
const flush = () => {
|
|
294
|
+
const [ del, add ] = emphasize(removed.map((row) => row.chars), added.map((row) => row.chars))
|
|
295
|
+
lines.push(
|
|
296
|
+
...del.map((line, k) => numbered(line, removed[k]!.number, digits, columns, "-")),
|
|
297
|
+
...add.map((line, k) => numbered(line, added[k]!.number, digits, columns, "+")))
|
|
298
|
+
removed = []
|
|
299
|
+
added = []
|
|
300
|
+
}
|
|
301
|
+
for (const { sign, i, j } of align(before.map((row) => JSON.stringify(row.chars)), after.map((row) => JSON.stringify(row.chars)))) {
|
|
302
|
+
if (sign === "-")
|
|
303
|
+
removed.push(before[i]!)
|
|
304
|
+
else if (sign === "+")
|
|
305
|
+
added.push(after[j]!)
|
|
306
|
+
else {
|
|
307
|
+
flush()
|
|
308
|
+
lines.push(numbered(after[j]!.chars, after[j]!.number, digits, columns))
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
flush()
|
|
312
|
+
return lines
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/* the task band: its state and its operations */
|
|
316
|
+
export const modTaskEdit = () => {
|
|
317
|
+
/* the currently shown band and the timer following the changes of its task */
|
|
318
|
+
let shown: Band | undefined
|
|
319
|
+
let timer: Timer | undefined
|
|
320
|
+
|
|
321
|
+
/* determine whether the task band is shown */
|
|
322
|
+
const active = () => shown !== undefined
|
|
323
|
+
|
|
324
|
+
/* close the task band */
|
|
325
|
+
const reset = () => {
|
|
326
|
+
timer?.cancel()
|
|
327
|
+
timer = undefined
|
|
328
|
+
shown = undefined
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/* show the task band of a task and follow the changes of the task, where an
|
|
332
|
+
update keeps the scroll position, and determine the answer of the tool */
|
|
333
|
+
const open = async (id: string, sections: string,
|
|
334
|
+
engine: Pick<Engine, "load" | "every" | "invalidate">): Promise<string> => {
|
|
335
|
+
const loaded = await engine.load(id)
|
|
336
|
+
if (!TAG.test(loaded))
|
|
337
|
+
return `ERROR: task "${id}" cannot be loaded`
|
|
338
|
+
reset()
|
|
339
|
+
const band: Band = { id, sections: sections.split(",").map((name) => name.trim()).filter((name) => name !== ""),
|
|
340
|
+
loaded, before: undefined, lines: [], digits: DIGITS, columns: 0, top: 0, scroll: 0, page: 1, split: 0 }
|
|
341
|
+
shown = band
|
|
342
|
+
let busy = false
|
|
343
|
+
timer = engine.every(POLL, () => {
|
|
344
|
+
if (busy)
|
|
345
|
+
return
|
|
346
|
+
busy = true
|
|
347
|
+
engine.load(id).then((text) => {
|
|
348
|
+
if (shown === band && text !== band.loaded && TAG.test(text)) {
|
|
349
|
+
/* a change without any shown effect (like a usage booking in
|
|
350
|
+
an attachment) keeps the last change and its diff */
|
|
351
|
+
if (JSON.stringify(page(text, band.sections, WIDE)) !==
|
|
352
|
+
JSON.stringify(page(band.loaded, band.sections, WIDE))) {
|
|
353
|
+
band.before = band.loaded
|
|
354
|
+
band.columns = 0
|
|
355
|
+
engine.invalidate()
|
|
356
|
+
}
|
|
357
|
+
band.loaded = text
|
|
358
|
+
}
|
|
359
|
+
}).catch(() => {
|
|
360
|
+
/* ignore a task store which is temporarily not accessible */
|
|
361
|
+
}).finally(() => {
|
|
362
|
+
busy = false
|
|
363
|
+
})
|
|
364
|
+
})
|
|
365
|
+
engine.invalidate()
|
|
366
|
+
return `OK: showing task "${id}" in the band above the prompt`
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/* close the task band once a next step of the skill is submitted at the prompt */
|
|
370
|
+
const submit = (text: string): void => {
|
|
371
|
+
if (CLOSE.test(text.trim()))
|
|
372
|
+
reset()
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/* drop the diff of the last change from the task band, so just the latest state
|
|
376
|
+
remains, once "##" is typed (not pasted with other text) at the end of the
|
|
377
|
+
prompt, and remove it from there again */
|
|
378
|
+
const edit = (box: PromptBox, input: string): PromptBox => {
|
|
379
|
+
if (shown === undefined || !/^#{1,2}$/.test(input) || box.cursor !== box.text.length || !box.text.endsWith("##"))
|
|
380
|
+
return box
|
|
381
|
+
shown.before = undefined
|
|
382
|
+
shown.columns = 0
|
|
383
|
+
const text = box.text.slice(0, -2)
|
|
384
|
+
return { ...box, text, cursor: text.length }
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/* scroll the task in the band by the wheel
|
|
388
|
+
(and tell whether the band took the scrolling) */
|
|
389
|
+
const scroll = (e: BandScroll): boolean => {
|
|
390
|
+
if (shown === undefined)
|
|
391
|
+
return false
|
|
392
|
+
wheel(e, shown)
|
|
393
|
+
return true
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/* draw the task into the band above the prompt (terminal only), yielding to a survey,
|
|
397
|
+
with a blank row (taken from the rows of the view) separating it from the transcript */
|
|
398
|
+
const render = (e: BandRender, ui: Elements["terminal"]): RenderElement | undefined => {
|
|
399
|
+
if (shown === undefined || e.props.hasSurvey)
|
|
400
|
+
return undefined
|
|
401
|
+
const geometry = layout(e, 0, HEIGHT)
|
|
402
|
+
geometry.body = Math.max(1, geometry.body - 1)
|
|
403
|
+
if (shown.columns !== geometry.span) {
|
|
404
|
+
const { before, loaded, sections } = shown
|
|
405
|
+
const largest = (task: string) => Math.max(0, ...page(task, sections, WIDE).map((row) => row.number ?? 0))
|
|
406
|
+
const digits = Math.max(DIGITS, String(Math.max(largest(loaded), largest(before ?? loaded))).length)
|
|
407
|
+
const columns = geometry.span - 4 - digits
|
|
408
|
+
const rows = page(loaded, sections, columns)
|
|
409
|
+
shown.lines = diff(before !== undefined ? page(before, sections, columns) : rows, rows, digits, columns)
|
|
410
|
+
shown.digits = digits
|
|
411
|
+
shown.columns = geometry.span
|
|
412
|
+
}
|
|
413
|
+
return ui.Box({ flexDirection: "column", width: "100%", children: [
|
|
414
|
+
ui.Text({ children: " " }),
|
|
415
|
+
panel(ui, geometry, shown, {
|
|
416
|
+
title: `[ ⧉ ASE: Edit Task: ${shown.id}${shown.sections.length > 0 ? ` (${shown.sections.join(", ")})` : ""} ]`,
|
|
417
|
+
hints: (geometry.overflow ? "wheel or CTRL+x + TAB + ↑/↓/PgUp/PgDn: scroll · " : "") +
|
|
418
|
+
(shown.before !== undefined ? "##: clear diff · " : "") +
|
|
419
|
+
"DONE · GRILL · DRAFT · IMPLEMENT · CANCEL · (type for refining)",
|
|
420
|
+
lines: shown.lines,
|
|
421
|
+
gutter: shown.digits
|
|
422
|
+
})
|
|
423
|
+
] })
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
return { active, reset, open, submit, edit, scroll, render }
|
|
427
|
+
}
|