@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.
Files changed (33) hide show
  1. package/dst/ase-agent-relay-client.js +3 -4
  2. package/dst/ase-config-schema.js +4 -1
  3. package/dst/ase-lib-fetch.js +12 -0
  4. package/dst/ase-lib-server-cli.js +8 -5
  5. package/dst/ase-task-board-diff.js +120 -0
  6. package/dst/ase-task-board-tui-control.js +8 -5
  7. package/dst/ase-task-board-tui-model.js +54 -14
  8. package/dst/ase-task-board-tui-popup.js +41 -8
  9. package/dst/ase-task-board-tui.js +42 -27
  10. package/dst/ase-task-board-web-client.css +1 -1
  11. package/dst/ase-task-board-web-client.html +16 -0
  12. package/dst/ase-task-board-web-client.js +33 -33
  13. package/dst/ase-task-board-web-server.js +44 -9
  14. package/dst/ase-task-board.js +12 -4
  15. package/dst/ase-task-store-client.js +4 -5
  16. package/dst/man/ase-config-init.md +2 -1
  17. package/dst/man/ase-config.md +5 -0
  18. package/dst/man/ase-task-board.md +44 -2
  19. package/package.json +7 -6
  20. package/plugin/.claude-plugin/plugin.json +1 -1
  21. package/plugin/.codex-plugin/plugin.json +1 -1
  22. package/plugin/.github/plugin/plugin.json +1 -1
  23. package/plugin/meta/ase-common-task.md +24 -0
  24. package/plugin/mods/ase-mods-common.ts +73 -42
  25. package/plugin/mods/ase-mods-task-edit.ts +427 -0
  26. package/plugin/mods/ase-mods.ts +45 -14
  27. package/plugin/skills/ase-help-intent/data.md +78 -8
  28. package/plugin/skills/ase-task-edit/SKILL.md +114 -9
  29. package/plugin/skills/ase-task-edit/help.md +60 -0
  30. package/plugin/skills/ase-task-grill/SKILL.md +35 -20
  31. package/plugin/skills/ase-task-grill/help.md +18 -8
  32. /package/plugin/mods/{ase-mods-evaluate.ts → ase-mods-meta-evaluate.ts} +0 -0
  33. /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, and the hint
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, and the reading and filling of the prompt */
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
- while (j < chars.length && chars[j]!.bold === bold && chars[j]!.color === color)
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 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
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, 2), false ], [ start, true ] ] as [ string, boolean ][] : []),
318
- [ line.slice(start !== "" ? 2 + start.length : 0, k), false ],
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 || color !== undefined ? Text({ bold, color, children: text }) : text)
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, the (dimmed) track, or nothing */
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 of two columns and
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, 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
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 + (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) ]
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
+ }