@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
@@ -4,8 +4,6 @@
4
4
  ** Licensed under Apache 2.0 <https://spdx.org/licenses/Apache-2.0>
5
5
  */
6
6
  import os from "node:os";
7
- import { ofetch } from "ofetch";
8
- import { Agent } from "undici";
9
7
  import * as v from "valibot";
10
8
  import { WebSocket } from "ws";
11
9
  import { isScalar } from "yaml";
@@ -14,6 +12,7 @@ import { configSchema } from "./ase-config-schema.js";
14
12
  import { parseScope } from "./ase-config-scope.js";
15
13
  import * as TaskFormat from "./ase-task-format.js";
16
14
  import { urlHost, isLoopback } from "./ase-lib-server-bind.js";
15
+ import { dispatchFetch, insecureAgent } from "./ase-lib-fetch.js";
17
16
  import { storeSchema } from "./ase-task-store-server-cli.js";
18
17
  import { relaySchema } from "./ase-agent-relay-server-cli.js";
19
18
  import * as Relay from "./ase-agent-relay-core.js";
@@ -181,7 +180,7 @@ export class AgentRelayClient {
181
180
  constructor(log, spec) {
182
181
  this.log = log;
183
182
  this.spec = spec;
184
- this.dispatcher = spec.insecure ? new Agent({ connect: { rejectUnauthorized: false } }) : undefined;
183
+ this.dispatcher = spec.insecure ? insecureAgent() : undefined;
185
184
  }
186
185
  /* the base URL of the agent relay */
187
186
  get base() {
@@ -189,7 +188,7 @@ export class AgentRelayClient {
189
188
  }
190
189
  /* perform a request, raising an error response or a failed connection */
191
190
  async request(method, url, body) {
192
- const r = await ofetch.raw(`${this.spec.base}/projects/${this.spec.prjId}/agents${url}`, {
191
+ const r = await dispatchFetch.raw(`${this.spec.base}/projects/${this.spec.prjId}/agents${url}`, {
193
192
  method,
194
193
  headers: {
195
194
  Authorization: `Bearer ${this.spec.token}`,
@@ -78,6 +78,7 @@ export const projectClassificationPresets = {
78
78
  "board.web.color.accent": webColorDefaults.accent,
79
79
  "board.web.color.signal": webColorDefaults.signal,
80
80
  "board.web.editor.keymap": "default",
81
+ "board.web.theme": "auto",
81
82
  "telemetry.enabled": "false",
82
83
  "telemetry.implicit": "true",
83
84
  "telemetry.prices.ai": "*=4.00/20.00/0.20/5.00",
@@ -131,6 +132,7 @@ export const configWritableScopes = {
131
132
  "board.web.color.accent": ["user", "project"],
132
133
  "board.web.color.signal": ["user", "project"],
133
134
  "board.web.editor.keymap": ["user", "project"],
135
+ "board.web.theme": ["user", "project"],
134
136
  "telemetry.enabled": ["user", "project"],
135
137
  "telemetry.implicit": ["user", "project"],
136
138
  "telemetry.service.port": ["user"],
@@ -337,7 +339,8 @@ export const configSchema = v.nullish(v.strictObject({
337
339
  })),
338
340
  editor: v.optional(v.strictObject({
339
341
  keymap: v.optional(v.picklist(["default", "vim", "emacs"]))
340
- }))
342
+ })),
343
+ theme: v.optional(v.picklist(["auto", "light", "dark"]))
341
344
  }))
342
345
  }))
343
346
  }));
@@ -0,0 +1,12 @@
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
+ import { ofetch } from "ofetch";
7
+ import { Agent, fetch as undiciFetch } from "undici";
8
+ /* the fetch for requests with a dispatcher of the npm "undici": its own fetch, as the
9
+ built-in fetch of Node (its bundled, possibly older undici) rejects a foreign dispatcher */
10
+ export const dispatchFetch = ofetch.create({}, { fetch: undiciFetch });
11
+ /* create a dispatcher skipping the TLS certificate verification */
12
+ export const insecureAgent = () => new Agent({ connect: { rejectUnauthorized: false } });
@@ -7,20 +7,19 @@ import path from "node:path";
7
7
  import fs from "node:fs";
8
8
  import crypto from "node:crypto";
9
9
  import os from "node:os";
10
- import { ofetch } from "ofetch";
11
- import { Agent } from "undici";
12
10
  import { isSeq, isScalar } from "yaml";
13
11
  import { Config } from "./ase-config-core.js";
14
12
  import { parseScope } from "./ase-config-scope.js";
15
13
  import { isLogLevel } from "./ase-lib-log.js";
16
14
  import { writeStdout } from "./ase-lib-stdio.js";
15
+ import { dispatchFetch, insecureAgent } from "./ase-lib-fetch.js";
17
16
  import { Daemon, DaemonCommand } from "./ase-lib-daemon-cli.js";
18
17
  import { urlHost, isLoopback, serverHeader } from "./ase-lib-server-bind.js";
19
18
  /* the defaults */
20
19
  export const DEFAULT_ADDRESS = "127.0.0.1";
21
20
  /* the dispatcher of the host-local probe, skipping the certificate verification,
22
21
  as a certificate rarely covers a loopback or local interface address */
23
- const probeAgent = new Agent({ connect: { rejectUnauthorized: false } });
22
+ const probeAgent = insecureAgent();
24
23
  /* whether an address belongs to this host (loopback or a local network interface) */
25
24
  export const isLocal = (address) => isLoopback(address) || Object.values(os.networkInterfaces())
26
25
  .some((ifaces) => (ifaces ?? []).some((iface) => iface.address === address));
@@ -146,7 +145,7 @@ export class ServerCommand extends DaemonCommand {
146
145
  /* skip the certificate verification for host-local addresses only,
147
146
  as elsewhere a man-in-the-middle could capture the token */
148
147
  const lax = isLocal(host.replace(/^\[|\]$/g, ""));
149
- const request = (auth) => ofetch.raw(url, {
148
+ const request = (auth) => dispatchFetch.raw(url, {
150
149
  method: "GET",
151
150
  headers: auth !== null ? { Authorization: `Bearer ${auth}` } : {},
152
151
  dispatcher: lax ? probeAgent : undefined,
@@ -170,8 +169,12 @@ export class ServerCommand extends DaemonCommand {
170
169
  return "running";
171
170
  return "foreign";
172
171
  }
173
- catch {
172
+ catch (err) {
174
173
  /* treat an unreachable or unresponsive server as not running */
174
+ let cause = err;
175
+ while (cause instanceof Error && cause.cause instanceof Error)
176
+ cause = cause.cause;
177
+ this.log.write("debug", `${this.label}: probe of ${url} failed: ${cause instanceof Error ? cause.message : String(cause)}`);
175
178
  return null;
176
179
  }
177
180
  }
@@ -0,0 +1,120 @@
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
+ import { diffArrays } from "diff";
7
+ /* the changed share of the words up to which they are emphasized, the Markdown
8
+ syntax characters never being emphasized (as this would break their constructs),
9
+ the closing placeholder of a change mark, and the leading syntax of a Markdown
10
+ line (block quote, heading, or list marker) and its checkbox, staying outside of
11
+ its mark resp. of its emphasized words */
12
+ const ALIKE = 0.4;
13
+ const SYNTAX = /^[*`~|\\[\]()<>#!]$/;
14
+ const CLOSE = "⟦/chg⟧";
15
+ const HEAD = /^\s*(?:>\s*)*(?:#{1,6}[ \t]+|(?:[-*+]|\d+[.)])[ \t]+)?/;
16
+ const BOX = /^\[.\][ \t]+/;
17
+ /* split two lists of items, compared by their keys, into the parts of their diff */
18
+ export const diffHunks = (before, after, key) => {
19
+ const hunks = [];
20
+ let i = 0;
21
+ let j = 0;
22
+ for (const { added, removed, count } of diffArrays(before.map(key), after.map(key))) {
23
+ if (!added && !removed) {
24
+ hunks.push({ kept: after.slice(j, j + count), removed: [], added: [] });
25
+ i += count;
26
+ j += count;
27
+ continue;
28
+ }
29
+ let hunk = hunks.at(-1);
30
+ if (hunk === undefined || hunk.kept.length > 0) {
31
+ hunk = { kept: [], removed: [], added: [] };
32
+ hunks.push(hunk);
33
+ }
34
+ if (removed) {
35
+ hunk.removed.push(...before.slice(i, i + count));
36
+ i += count;
37
+ }
38
+ else {
39
+ hunk.added.push(...after.slice(j, j + count));
40
+ j += count;
41
+ }
42
+ }
43
+ return hunks;
44
+ };
45
+ const tokens = (lines) => lines.flatMap((line, row) => [...line.matchAll(/[\p{L}\p{N}_]+|\S/gu)].map((m) => ({ text: m[0], row, from: m.index, to: m.index + m[0].length })));
46
+ /* determine the changed words of the removed and added lines of a change (joined with
47
+ the blanks between adjacent ones), unless too many of the words changed, where the
48
+ words alone decide, so a change of the wrapping only emphasizes nothing */
49
+ export const changedWords = (removed, added) => {
50
+ const a = tokens(removed);
51
+ const b = tokens(added);
52
+ const del = removed.map(() => []);
53
+ const add = added.map(() => []);
54
+ const changes = diffArrays(a.map((token) => token.text), b.map((token) => token.text));
55
+ const changed = changes.reduce((n, change) => n + (change.added || change.removed ? change.count : 0), 0);
56
+ if (changed > (a.length + b.length) * ALIKE)
57
+ return [del, add];
58
+ const mark = (list, spans) => {
59
+ let last;
60
+ for (const token of list) {
61
+ const row = spans[token.row];
62
+ if (SYNTAX.test(token.text))
63
+ last = undefined;
64
+ else {
65
+ if (last?.row === token.row)
66
+ row[row.length - 1].to = token.to;
67
+ else
68
+ row.push({ from: token.from, to: token.to });
69
+ last = token;
70
+ }
71
+ }
72
+ };
73
+ let i = 0;
74
+ let j = 0;
75
+ for (const { added: plus, removed: minus, count } of changes) {
76
+ if (minus)
77
+ mark(a.slice(i, i + count), del);
78
+ else if (plus)
79
+ mark(b.slice(j, j + count), add);
80
+ i += plus ? 0 : count;
81
+ j += minus ? 0 : count;
82
+ }
83
+ return [del, add];
84
+ };
85
+ /* mark a changed Markdown line and its changed words through placeholders surviving
86
+ the Markdown rendering: fences and lines of pure syntax stay unmarked (as a
87
+ mark would break them), and the cells of a table row are marked individually */
88
+ const markLine = (line, kind, spans) => {
89
+ if (!/[\p{L}\p{N}]/u.test(line) || /^\s*(?:```|~~~)/.test(line))
90
+ return line;
91
+ if (/^\s*\|/.test(line))
92
+ return line.split(/(?<!\\)\|/).map((cell) => cell.replace(/\S(?:.*\S)?/, (text) => `⟦chg:${kind}⟧${text}${CLOSE}`)).join("|");
93
+ const head = HEAD.exec(line)[0];
94
+ const rest = line.slice(head.length);
95
+ const box = BOX.exec(rest)?.[0] ?? "";
96
+ let text = rest.slice(box.length);
97
+ for (const { from, to } of [...spans].reverse())
98
+ text = `${text.slice(0, from)}⟦chg:${kind}-word⟧${text.slice(from, to)}${CLOSE}${text.slice(to)}`;
99
+ return `${head}⟦chg:${kind}⟧${box}${text}${CLOSE}`;
100
+ };
101
+ /* the text of a Markdown line, without its leading syntax and its checkbox */
102
+ const lineText = (line) => {
103
+ const rest = line.slice(HEAD.exec(line)[0].length);
104
+ return rest.slice(BOX.exec(rest)?.[0].length ?? 0);
105
+ };
106
+ /* merge two Markdown texts into one, with the removed before the added lines of a
107
+ change and with both marked through placeholders (resolved by "diffMarksToHTML") */
108
+ export const diffMarkdown = (before, after) => diffHunks(before.split(/\r?\n/), after.split(/\r?\n/), (line) => line).flatMap(({ kept, removed, added }) => {
109
+ const [del, add] = changedWords(removed.map(lineText), added.map(lineText));
110
+ return [
111
+ ...kept,
112
+ ...removed.map((line, k) => markLine(line, "del", del[k])),
113
+ ...added.map((line, k) => markLine(line, "add", add[k]))
114
+ ];
115
+ }).join("\n");
116
+ /* resolve the placeholders of the change marks in rendered HTML, where
117
+ the ones which ended up inside a tag (like in a link target) are dropped */
118
+ export const diffMarksToHTML = (html) => html.replace(/<[^>]*⟦[^>]*>/g, (tag) => tag.replace(/⟦\/?chg[^⟧]*⟧/g, ""))
119
+ .replace(/⟦chg:([a-z-]+)⟧/g, (_m, kinds) => `<span class="chg ${kinds.replace(/-/g, " ")}">`)
120
+ .replace(/⟦\/chg⟧/g, "</span>");
@@ -12,7 +12,7 @@ import { refSegs, tabFirst, tabLayout, confirmBox, transferBox, assignBox, promp
12
12
  /* handle a mouse press onto the deletion confirmation, the transfer popup, the assign
13
13
  popup, the prompt popup, or the read dialog, returning whether one of them was open */
14
14
  const handlePopupMouse = (ctx, btn, mx, my) => {
15
- const { columns, rows, all, dialog, setDialog, setNotice, confirm, setConfirm, transfer, setTransfer, assign, setAssign, assignTo, prompt, setPrompt, cancelPrompt, submitPrompt, dialogW, tabLabels, dialogSel, dialogScroll, transferList, remove, transferTo } = ctx;
15
+ const { columns, rows, all, dialog, setDialog, setNotice, confirm, setConfirm, transfer, setTransfer, assign, setAssign, assignTo, prompt, setPrompt, cancelPrompt, submitPrompt, dialogW, tabLabels, dialogSel, dialogScroll, transferList, remove, transferTo, watched } = ctx;
16
16
  if (prompt !== null) {
17
17
  /* a click onto a suggested request name of the drop-down picks it (and switches to the
18
18
  prompt, resp. to the request button if the prompt is ignored), a click onto the request
@@ -85,10 +85,11 @@ const handlePopupMouse = (ctx, btn, mx, my) => {
85
85
  return true;
86
86
  }
87
87
  if (dialog !== null) {
88
- /* a click onto the " X " of the header (the second dialog row) closes */
88
+ /* a click onto the " X " of the header (the second dialog row) closes
89
+ (the task viewer has no " X " at all, as only "q" quits it) */
89
90
  const barX = Math.floor((columns - dialogW) / 2) + 1;
90
91
  const closeX = barX - 1 + dialogW - DIALOG_CLOSE;
91
- if (btn === 0 && my === 1 && mx >= closeX && mx < closeX + 3)
92
+ if (btn === 0 && my === 1 && mx >= closeX && mx < closeX + 3 && watched === undefined)
92
93
  setDialog(null);
93
94
  else if (btn === 0 && my === 3 && mx >= barX && mx < barX + dialogW - 2) {
94
95
  /* a click onto a predecessor or successor id (in the fourth dialog row) jumps to its task view */
@@ -457,12 +458,14 @@ const handlePopupKey = (ctx, input, key) => {
457
458
  };
458
459
  /* handle a key press within the read dialog */
459
460
  const handleDialogKey = (ctx, dialog, input, key) => {
460
- const { rows, setDialog, dialogW, tabLabels, dialogSel, dialogScroll, startEdit } = ctx;
461
+ const { rows, setDialog, dialogW, tabLabels, dialogSel, dialogScroll, dialogDiff, clearDiff, startEdit, watched } = ctx;
461
462
  const page = Math.max(1, rows - DIALOG_CHROME - 2);
462
- if (key.escape || key.return)
463
+ if (watched !== undefined ? input === "q" : key.escape || key.return)
463
464
  setDialog(null);
464
465
  else if (input === "e")
465
466
  startEdit(dialog.id);
467
+ else if (input === "#" && dialogDiff)
468
+ clearDiff();
466
469
  else if (key.leftArrow || key.rightArrow || key.tab) {
467
470
  const tab = Math.max(0, Math.min(tabLabels.length - 1, dialogSel + (key.leftArrow || (key.tab && key.shift) ? -1 : 1)));
468
471
  setDialog({ ...dialog, tab, first: tabFirst(tabLabels, dialog.first, tab, dialogW - 4) });
@@ -19,7 +19,7 @@ import { statsOf, statsRanges, effortScale } from "./ase-task-board-stats.js";
19
19
  import { layoutGraph } from "./ase-task-board-graph.js";
20
20
  import { flowOf, layoutFlow, flowState } from "./ase-task-board-flow.js";
21
21
  import { fitGroups, agentLogWidth, agentOpLines, agentOpHeight, AGENT_INFO_H } from "./ase-task-board-tui-view.js";
22
- import { dialogTabs, planLines, attachmentLines, agentLogLines, agentLogKey, DIALOG_CHROME, DIALOG_WIDTH } from "./ase-task-board-tui-popup.js";
22
+ import { dialogTabs, planLines, planChanged, planDiffLines, attachmentLines, agentLogLines, agentLogKey, DIALOG_CHROME, DIALOG_WIDTH } from "./ase-task-board-tui-popup.js";
23
23
  import { pulseFrames } from "./ase-task-board-tui-style.js";
24
24
  /* the suggested request names of a request by prompt, matching its request name as last
25
25
  typed (all of them for an empty or an exactly suggested request name, to offer the
@@ -407,13 +407,14 @@ const usePromptActions = (ctx) => {
407
407
  };
408
408
  return { startPrompt, startRequest, cancelPrompt, submitPrompt };
409
409
  };
410
- /* the state of the terminal board: all state, the derived values, and the actions */
411
- export const useBoardState = (log, initial, relay) => {
410
+ /* the state of the terminal board (resp. with a task id, of the
411
+ task viewer): all state, the derived values, and the actions */
412
+ export const useBoardState = (log, initial, relay, watched) => {
412
413
  const { exit, suspendTerminal } = useApp();
413
414
  const { columns, rows } = useWindowSize();
414
415
  const [all, setBoard] = React.useState(initial);
415
416
  const [surface, setSurface] = React.useState(() => BoardState.load().tui);
416
- const [view, setView] = React.useState(() => surface.view);
417
+ const [view, setView] = React.useState(() => watched !== undefined ? "lanes" : surface.view);
417
418
  const [filter, setFilter] = React.useState("");
418
419
  const [typing, setTyping] = React.useState(false);
419
420
  /* the shown board: all tasks reduced onto the ones matching the filter
@@ -424,7 +425,7 @@ export const useBoardState = (log, initial, relay) => {
424
425
  return view === "graph" && !surface.standalone ? dropStandalone(found) : found;
425
426
  }, [all, filter, view, surface.standalone]);
426
427
  const [sel, setSel] = React.useState(() => relocate(board, { g: 0, l: 0, id: "" }, surface));
427
- const [dialog, setDialog] = React.useState(null);
428
+ const [dialog, setDialog] = React.useState(watched !== undefined ? { id: watched, tab: 0, first: 0, scrolls: {} } : null);
428
429
  const [plan, setPlan] = React.useState(null);
429
430
  const [files, setFiles] = React.useState(new Map());
430
431
  const [first, setFirst] = React.useState(0);
@@ -480,6 +481,8 @@ export const useBoardState = (log, initial, relay) => {
480
481
  }, [log, all]);
481
482
  /* persist the shown view for the next start of the terminal board */
482
483
  React.useEffect(() => {
484
+ if (watched !== undefined)
485
+ return;
483
486
  BoardState.setView("tui", view).catch((err) => {
484
487
  log.write("warning", `board: persisting view failed: ${err instanceof Error ? err.message : String(err)}`);
485
488
  });
@@ -523,6 +526,21 @@ export const useBoardState = (log, initial, relay) => {
523
526
  setNotice(`editing task "${prompt.id}" by prompt cancelled: task vanished in the meantime`);
524
527
  }
525
528
  }, [all, prompt]);
529
+ /* quit the task viewer once its dialog is closed, resp. with a warning once
530
+ its task vanished (except while an edit may rename it, which the dialog follows) */
531
+ const seen = React.useRef("");
532
+ React.useEffect(() => {
533
+ if (watched === undefined)
534
+ return;
535
+ if (dialog === null)
536
+ exit();
537
+ else if (all.cards.has(dialog.id))
538
+ seen.current = dialog.id;
539
+ else if (seen.current === dialog.id && !editing.current) {
540
+ log.write("warning", `board: task "${dialog.id}" no longer exists`);
541
+ exit();
542
+ }
543
+ }, [all, dialog?.id]);
526
544
  /* remember the box of every graph node for the spatial navigation and the scrolling */
527
545
  const places = React.useMemo(() => placesOf(layout?.graph), [layout]);
528
546
  /* follow changes of the task storage and of the lifecycle mode,
@@ -624,18 +642,26 @@ export const useBoardState = (log, initial, relay) => {
624
642
  };
625
643
  /* fetch the plan (with its attachments) of the read dialog whenever it is opened or the board changes
626
644
  (the latter in the background, i.e. without the busy popup), together with loading the
627
- syntax highlighter of YAML or JSON attachments (on the first opening only) */
645
+ syntax highlighter of YAML or JSON attachments (on the first opening only), where a
646
+ change with a shown effect remembers its predecessor for the diff of the read dialog,
647
+ and where closing the dialog forgets the plan, as its changes are followed only while open */
628
648
  const dialogId = dialog?.id;
629
649
  const dialogTab = dialog?.tab ?? 0;
630
650
  React.useEffect(() => {
631
- if (dialogId === undefined)
651
+ if (dialogId === undefined) {
652
+ setPlan(null);
632
653
  return;
654
+ }
633
655
  let live = true;
634
656
  const load = Promise.all([Task.parts(log, dialogId), Task.attachments(log, dialogId), dataLoad()]);
635
657
  const run = plan?.id === dialogId ? load : track({ text: "loading task", id: dialogId }, load);
636
658
  run.then(([parts, atts]) => {
637
659
  if (live)
638
- setPlan({ id: dialogId, parts: parts === null ? null : { ...parts, atts } });
660
+ setPlan((prev) => {
661
+ const next = parts === null ? null : { ...parts, atts };
662
+ const same = prev?.id === dialogId && next !== null;
663
+ return { id: dialogId, parts: next, before: !same ? undefined : planChanged(prev.parts, next) ? prev.parts : prev.before };
664
+ });
639
665
  }).catch((err) => {
640
666
  log.write("warning", `board: loading plan failed: ${err instanceof Error ? err.message : String(err)}`);
641
667
  if (live)
@@ -708,6 +734,12 @@ export const useBoardState = (log, initial, relay) => {
708
734
  const ref = process.platform === "win32" ? "\"%ASE_TASK_FILE%\"" : "\"$ASE_TASK_FILE\"";
709
735
  await suspendTerminal(async () => {
710
736
  mouseReporting(false);
737
+ /* stop the read of stdin still pending from Ink, which only removes its
738
+ listener, as it else takes the next input away from the editor and
739
+ passes it to the board afterwards (a pause in non-flowing mode does
740
+ not emit the event on which Node stops reading, and the stop is deferred) */
741
+ process.stdin.emit("pause");
742
+ await new Promise((resolve) => { process.nextTick(resolve); });
711
743
  try {
712
744
  await execa(`${editor} ${ref}`, { shell: true, stdio: "inherit", env: { ASE_TASK_FILE: file } });
713
745
  }
@@ -791,9 +823,10 @@ export const useBoardState = (log, initial, relay) => {
791
823
  setNotice(`creating task failed: ${msg} (press "N" to re-edit, or empty it to discard)`);
792
824
  }
793
825
  };
794
- /* delete a task (after its confirmation), closing its read dialog */
826
+ /* delete a task (after its confirmation), closing its read dialog
827
+ (in the task viewer only once it vanished, as closing it quits the process) */
795
828
  const remove = (id) => {
796
- if (dialog?.id === id)
829
+ if (dialog?.id === id && watched === undefined)
797
830
  setDialog(null);
798
831
  track({ text: "deleting task", id }, Task.delete(log, id)).then((existed) => {
799
832
  setNotice(existed ? `task "${id}" deleted` : `task "${id}" no longer exists`);
@@ -910,16 +943,23 @@ export const useBoardState = (log, initial, relay) => {
910
943
  const { agentList, agentIdx, agent, agentOp, logLines, logH, logFirst, logMax, selectAgent, scrollLog, focusAgent } = useAgentsView(agents, columns, boardH);
911
944
  /* the tabs of the read dialog, the lines of its selected tab (clamped to
912
945
  the existing tabs), and the maximum scroll offset of these lines */
913
- const dialogW = Math.min(columns - 2, DIALOG_WIDTH);
946
+ const dialogW = watched !== undefined ? columns : Math.min(columns - 2, DIALOG_WIDTH);
914
947
  const dialogParts = dialog !== null && plan?.id === dialog.id ? plan.parts : undefined;
915
948
  const tabLabels = dialogTabs(dialogParts);
916
949
  const dialogSel = Math.min(dialogTab, tabLabels.length - 1);
917
950
  const dialogAtt = dialogSel > 0 && dialogParts !== undefined && dialogParts !== null && !(dialogParts instanceof Error) ?
918
951
  dialogParts.atts[dialogSel - 1] : undefined;
952
+ const planDiff = React.useMemo(() => plan?.before !== undefined ?
953
+ planDiffLines(plan.before, plan.parts, plan.id, Math.max(1, dialogW - 5)) : null, [plan, dialogW]);
954
+ const dialogDiff = planDiff !== null && dialogSel === 0 && plan?.id === dialog?.id;
919
955
  const dialogLines = dialog === null ? [] : dialogAtt !== undefined ?
920
956
  attachmentLines(dialogAtt, files.get(`${dialog.id}:${dialogSel}`), Math.max(1, dialogW - 5)) :
921
- planLines(dialogParts, dialog.id, Math.max(1, dialogW - 5));
957
+ dialogDiff ? planDiff : planLines(dialogParts, dialog.id, Math.max(1, dialogW - 5));
922
958
  const dialogMax = Math.max(0, dialogLines.length - (rows - DIALOG_CHROME));
959
+ /* drop the diff of the last change from the read dialog, so just the latest state remains */
960
+ const clearDiff = () => {
961
+ setPlan((prev) => prev !== null ? { ...prev, before: undefined } : prev);
962
+ };
923
963
  /* the entries of the transfer popup of its (still existing) task */
924
964
  const transferCard = transfer !== null ? all.cards.get(transfer.id) : undefined;
925
965
  const transferList = transferCard !== undefined && cycle !== null ? transferEntries(all, cycle, transferCard) : [];
@@ -1015,11 +1055,11 @@ export const useBoardState = (log, initial, relay) => {
1015
1055
  /* the wall-clock and the heartbeats of the agents (for the agents view and the agent graph) */
1016
1056
  const { now, beatOf } = useBeats(agents, view);
1017
1057
  return {
1018
- log, exit, columns, rows, all, board, surface, view, setView, filter, setFilter, typing, setTyping,
1058
+ log, exit, watched, columns, rows, all, board, surface, view, setView, filter, setFilter, typing, setTyping,
1019
1059
  sel, setSel, dialog, setDialog, scroll, layout, notice, setNotice, carry, setCarry, cycle,
1020
1060
  confirm, setConfirm, transfer, setTransfer, assign, setAssign, prompt, setPrompt, mouse, setMouse, lastClick, grown, setGrow,
1021
1061
  cardBoxes, laneBoxes, groupBoxes, headBoxes, agentBoxes, rangeBoxes, cardRef, laneRef, groupRef, headRef, agentRef, rangeRef, boxAt, graphView,
1022
- places, graphTitles, boardH, dialogW, tabLabels, dialogSel, dialogLines, dialogScroll,
1062
+ places, graphTitles, boardH, dialogW, tabLabels, dialogSel, dialogLines, dialogScroll, dialogDiff, clearDiff,
1023
1063
  transferCard, transferList, fit, viewH, viewW, x, y, nodes, dim, pulse, now, beatOf, store, busy, busyTick,
1024
1064
  agents, working, cycleView, agentList, agentIdx, agent, agentOp, logLines, logH, logFirst, logMax, selectAgent, scrollLog, stats, usage, range, setRange, stepRange,
1025
1065
  flow, flowLayout, flowSel, setFlowSel, flowPlaces, focusAgent, scrollFlow,
@@ -5,6 +5,7 @@
5
5
  */
6
6
  import { Box, Text } from "ink";
7
7
  import { cardLabel, groupLabel, attachmentTabs, isDiff, diffTones, dataLang, dataSpans, clampLines } from "./ase-task-board-core.js";
8
+ import { diffHunks, changedWords } from "./ase-task-board-diff.js";
8
9
  import { h } from "./ase-task-board-tui-view.js";
9
10
  import { palette, cx, spinnerFrames, eighths } from "./ase-task-board-tui-style.js";
10
11
  const BOLD = 1;
@@ -13,6 +14,7 @@ const CODE = 4;
13
14
  const ACCENT = 8;
14
15
  const SIGNAL = 16;
15
16
  const DIM = 32;
17
+ const CHANGED = 64;
16
18
  /* strip the Markdown inline markers of a line ("`" for code, "**" and
17
19
  "__" for bold, "*" for italic), recording the style of the marked text */
18
20
  const emphasis = (line) => {
@@ -109,6 +111,35 @@ export const planLines = (parts, id, width) => {
109
111
  ...markdownLines(parts.body, width)
110
112
  ];
111
113
  };
114
+ /* determine whether a task plan differs from its predecessor in what the read
115
+ dialog shows (and not, for instance, only in its attachments) */
116
+ export const planChanged = (before, after) => {
117
+ const shown = (parts) => parts === undefined || parts === null || parts instanceof Error ? null :
118
+ JSON.stringify([[...parts.keys].filter(([key]) => key !== "Type"), parts.body]);
119
+ return shown(before) !== null && shown(after) !== null && shown(before) !== shown(after);
120
+ };
121
+ /* the lines of the diff of two task plans for the read dialog: the rendered lines of
122
+ both, where removed lines are red and added lines blue, with their sign in the
123
+ otherwise empty first column and their changed words marked, and where only the
124
+ shown content of the lines decides about a change */
125
+ export const planDiffLines = (before, after, id, width) => {
126
+ const changed = (line, sign, tint, spans) => {
127
+ const mask = [...(line.mask ?? new Array(line.text.length).fill(0))];
128
+ for (const { from, to } of spans)
129
+ for (let k = from; k < to; k++)
130
+ mask[k] |= CHANGED;
131
+ return { ...line, mask, sign, tint };
132
+ };
133
+ return diffHunks(planLines(before, id, width), planLines(after, id, width), (line) => JSON.stringify(line))
134
+ .flatMap(({ kept, removed, added }) => {
135
+ const [del, add] = changedWords(removed.map((line) => line.text), added.map((line) => line.text));
136
+ return [
137
+ ...kept,
138
+ ...removed.map((line, k) => changed(line, "-", "red", del[k])),
139
+ ...added.map((line, k) => changed(line, "+", "blue", add[k]))
140
+ ];
141
+ });
142
+ };
112
143
  /* the lines of a task plan attachment for the read dialog: its embedded
113
144
  data or its (lazily loaded) file content, rendered as Markdown for a
114
145
  Markdown type, syntax-highlighted for a YAML or JSON type, colored per
@@ -549,8 +580,7 @@ export const renderAssign = (assign, choices, unknown, columns, rows) => {
549
580
  };
550
581
  /* render the read dialog: full height, horizontally centered, with a
551
582
  vertical scroll bar in its right border */
552
- export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, first, scroll, lines, columns, rows, notice }) => {
553
- const width = Math.min(columns - 2, DIALOG_WIDTH);
583
+ export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, first, scroll, lines, width, columns, rows, notice, viewer, diffed }) => {
554
584
  const left = Math.floor((columns - width) / 2);
555
585
  const bodyW = width - 5;
556
586
  const viewH = rows - DIALOG_CHROME;
@@ -599,14 +629,16 @@ export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, fir
599
629
  /* the key hints, or instead the status notice of an edit, truncated to the free width */
600
630
  const free = Math.max(0, innerW - pos.length - 1);
601
631
  const keys = (notice !== null ? " " + sanitize(notice) :
602
- " ←/→/⇤/⇥: switch tab · ↑/↓/⇈/⇊: scroll · e: edit · E: edit by prompt · R: request by prompt · T: transition · A: assign · D: delete · M: toggle mouse · ⏎/ESC: close").slice(0, free);
603
- return h(Box, { key: "dialog", top: 0, left, width, height: rows, ...cx("popup", "border-dim") }, h(Text, {}, " ", ...(prefix !== "" ? [h(Text, { ...cx("bold"), color: tint(id) }, prefix)] : []), h(Text, { ...cx("badge"), color: tint(id) }, ` ${epic ? "♛ " : ""}≡ ${head.slice(0, id.length)} `), h(Text, { color: tint(id) }, head.slice(id.length)), h(Text, cx("dim"), right.slice(0, right.length - lane.length)), h(Text, cx("dim", "bold"), lane), " ", h(Text, cx("dim", "inverse"), " X "), " "), separator("sep-title"), h(Text, {}, ...segs.map((seg, k) => h(Text, { key: k, color: seg.color, inverse: seg.inverse }, seg.text))), separator("sep-head"), h(Text, { wrap: "truncate" }, ...tabBar.map((seg, k) => h(Text, { key: k, color: seg.color, bold: seg.bold, inverse: seg.inverse }, seg.text))), separator("sep-tabs"), ...Array.from({ length: viewH }, (_, i) => {
632
+ (diffed ? " #: clear diff ·" : "") +
633
+ " ←/→/⇤/⇥: switch tab · ↑/↓/⇈/⇊: scroll · e: edit · E: edit by prompt · R: request by prompt · T: transition · A: assign · D: delete · M: toggle mouse · " +
634
+ (viewer ? "q: quit" : "⏎/ESC: close")).slice(0, free);
635
+ return h(Box, { key: "dialog", top: 0, left, width, height: rows, ...cx("popup", "border-dim") }, h(Text, {}, " ", ...(prefix !== "" ? [h(Text, { ...cx("bold"), color: tint(id) }, prefix)] : []), h(Text, { ...cx("badge"), color: tint(id) }, ` ${epic ? "♛ " : ""}≡ ${head.slice(0, id.length)} `), h(Text, { color: tint(id) }, head.slice(id.length)), h(Text, cx("dim"), right.slice(0, right.length - lane.length)), h(Text, cx("dim", "bold"), lane), " ", h(Text, cx("dim", !viewer && "inverse"), viewer ? " " : " X "), " "), separator("sep-title"), h(Text, {}, ...segs.map((seg, k) => h(Text, { key: k, color: seg.color, inverse: seg.inverse }, seg.text))), separator("sep-head"), h(Text, { wrap: "truncate" }, ...tabBar.map((seg, k) => h(Text, { key: k, color: seg.color, bold: seg.bold, inverse: seg.inverse }, seg.text))), separator("sep-tabs"), ...Array.from({ length: viewH }, (_, i) => {
604
636
  const line = lines[s + i];
605
637
  const bar = lines.length <= viewH ? " " : (i >= thumbY && i < thumbY + thumbH ? "█" : "░");
606
638
  /* split the padded line into runs of equal inline style (cut to the body width,
607
639
  as a wider line would wrap and push the rows of the dialog out of place) */
608
- const text = " " + (line?.text ?? "").slice(0, bodyW).padEnd(bodyW);
609
- const mask = [0, ...(line?.mask ?? [])];
640
+ const text = (line?.sign ?? " ") + (line?.text ?? "").slice(0, bodyW).padEnd(bodyW);
641
+ const mask = [line?.sign !== undefined ? CHANGED : 0, ...(line?.mask ?? [])];
610
642
  const runs = [];
611
643
  for (let k = 0; k < text.length; k++) {
612
644
  const style = mask[k] ?? 0;
@@ -617,8 +649,9 @@ export const renderDialog = ({ card, group, id, pred, succ, tint, tabs, tab, fir
617
649
  }
618
650
  return h(Box, { key: i }, ...runs.map((run, k) => h(Text, {
619
651
  key: k, wrap: "truncate",
620
- color: (run.style & (CODE | ACCENT)) !== 0 ? palette.accent : (run.style & SIGNAL) !== 0 ? palette.signal :
621
- (run.style & DIM) !== 0 ? palette.dim : line?.color,
652
+ color: line?.tint ?? ((run.style & (CODE | ACCENT)) !== 0 ? palette.accent : (run.style & SIGNAL) !== 0 ? palette.signal :
653
+ (run.style & DIM) !== 0 ? palette.dim : line?.color),
654
+ inverse: (run.style & CHANGED) !== 0,
622
655
  bold: (line?.bold ?? false) || (run.style & BOLD) !== 0, italic: (run.style & ITALIC) !== 0
623
656
  }, run.text)), h(Text, cx("signal"), ` ${bar}`));
624
657
  }), separator("sep-foot"), h(Box, {}, h(Text, cx("hint", notice !== null && "signal"), keys), h(Text, cx("hint"), pos.padStart(Math.max(0, innerW - keys.length)))));