@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
|
@@ -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 ?
|
|
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
|
|
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}`,
|
package/dst/ase-config-schema.js
CHANGED
|
@@ -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 =
|
|
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) =>
|
|
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
|
|
411
|
-
|
|
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(
|
|
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
|
-
|
|
603
|
-
|
|
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)))));
|