@asrunjs/asrun 0.12.0 → 0.13.0
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/dist/ui/server/ai-stream.d.ts +2 -2
- package/dist/ui/server/ai-stream.js +190 -30
- package/dist/ui/shared/log-text.d.ts +13 -0
- package/dist/ui/shared/log-text.js +45 -0
- package/dist/ui/shared/types.d.ts +6 -1
- package/docs/web-ui.md +18 -4
- package/package.json +3 -1
- package/ui/dist/assets/{dist-Bfv586Or.js → dist-BB2cF3XN.js} +1 -1
- package/ui/dist/assets/{dist-CJnqQMvX.js → dist-C46EmZuI.js} +1 -1
- package/ui/dist/assets/{dist-DkAx5a-Y.js → dist-D9GmSkjm.js} +1 -1
- package/ui/dist/assets/{dist-Clj0wGFy.js → dist-DGzg2gyA.js} +1 -1
- package/ui/dist/assets/{dist-CaAcCNRS.js → dist-DMiOs9Qj.js} +1 -1
- package/ui/dist/assets/{dist-cuboTVh-.js → dist-Db_XhDui.js} +1 -1
- package/ui/dist/assets/{dist-D8TUm3Ai.js → dist-DrFzCwMc.js} +1 -1
- package/ui/dist/assets/{dist-BX88bMYN.js → dist-Dy45nXf8.js} +1 -1
- package/ui/dist/assets/{dist-DUDqh_3e.js → dist-P9roUi1O.js} +1 -1
- package/ui/dist/assets/{dist-BjlbPf3t.js → dist-_0g6iC9K.js} +1 -1
- package/ui/dist/assets/index-BN4ucmmn.js +376 -0
- package/ui/dist/assets/index-Bo4XKJvP.css +2 -0
- package/ui/dist/index.html +2 -2
- package/ui/dist/assets/index-DaUQORCy.js +0 -353
- package/ui/dist/assets/index-j3W4BBCj.css +0 -2
|
@@ -53,8 +53,8 @@ export declare class AiStreamFormatter {
|
|
|
53
53
|
/** A call the agent has started and not finished yet (Claude reports both ends). */
|
|
54
54
|
private start;
|
|
55
55
|
/**
|
|
56
|
-
* The end of a call, plus
|
|
57
|
-
*
|
|
56
|
+
* The end of a call, plus what it returned. `fallback` is for a CLI that reports a call only when it is over
|
|
57
|
+
* (OpenCode), which still knows when it started.
|
|
58
58
|
*/
|
|
59
59
|
private finishCall;
|
|
60
60
|
/** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
|
|
@@ -1,10 +1,21 @@
|
|
|
1
|
+
import { expandEscapedText } from '../shared/log-text.js';
|
|
1
2
|
const MAX_DETAIL = 180;
|
|
2
3
|
const MAX_ERROR = 300;
|
|
3
4
|
const MAX_REASONING = 2000;
|
|
4
|
-
/**
|
|
5
|
-
const
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
/** The argument of a call is kept whole, within reason: the log opens it instead of a truncated summary. */
|
|
6
|
+
const MAX_INPUT = 4000;
|
|
7
|
+
/**
|
|
8
|
+
* An edit carries the snippet it replaces and the one it writes, or the whole content of a small file. Keeping more of
|
|
9
|
+
* it is what lets the log draw a diff instead of a JSON blob cut in the middle.
|
|
10
|
+
*/
|
|
11
|
+
const MAX_INPUT_EDIT = 20_000;
|
|
12
|
+
/** Tools whose argument is a change to a file (the same family the log draws as a diff). */
|
|
13
|
+
const EDIT_TOOL = /^(edit|multiedit|write|notebookedit|patch|applypatch)$/;
|
|
14
|
+
/** What a call returned is kept up to this, so the log can show it all; a runaway dump still has an end. */
|
|
15
|
+
const MAX_RESULT_CHARS = 20_000;
|
|
16
|
+
const MAX_RESULT_LINES = 400;
|
|
17
|
+
/** One line longer than this is a minified document or a data URL: its tail says nothing more. */
|
|
18
|
+
const MAX_RESULT_LINE = 2000;
|
|
8
19
|
const ANSI = /\u001b\[[0-9;?]*[ -/]*[@-~]/g;
|
|
9
20
|
const SANDBOX_ERROR = /listen EPERM|outside of the sandbox|sandbox/i;
|
|
10
21
|
/** A saved login is a password (AGENTS.md F5): never copy what a call read from `.auth` into the log. */
|
|
@@ -14,8 +25,15 @@ const oneLine = (value, max) => {
|
|
|
14
25
|
return text.length > max ? `${text.slice(0, max - 1)}…` : text;
|
|
15
26
|
};
|
|
16
27
|
const cap = (text, max) => (text.length > max ? `${text.slice(0, max - 1)}…` : text);
|
|
28
|
+
/** Keeps a body whole up to `max`, and says how much it left out instead of cutting it silently. */
|
|
29
|
+
function capBody(text, max) {
|
|
30
|
+
if (text.length <= max)
|
|
31
|
+
return text;
|
|
32
|
+
const rest = text.length - max;
|
|
33
|
+
return `${text.slice(0, max)}\n… (${rest} more character${rest === 1 ? '' : 's'} not shown)`;
|
|
34
|
+
}
|
|
17
35
|
const seconds = (ms) => (ms < 90_000 ? `${Math.round(ms / 1000)} s` : `${(ms / 60_000).toFixed(1)} min`);
|
|
18
|
-
/** The first thing a piece of code does, for
|
|
36
|
+
/** The first thing a piece of code does, for code that makes no tool call of its own (`await page.goto(...)`). */
|
|
19
37
|
function firstStatement(code) {
|
|
20
38
|
const line = code
|
|
21
39
|
.split('\n')
|
|
@@ -23,12 +41,98 @@ function firstStatement(code) {
|
|
|
23
41
|
.find((text) => text !== '' && !text.startsWith('//') && !text.startsWith('#'));
|
|
24
42
|
return line ?? '';
|
|
25
43
|
}
|
|
44
|
+
/** `tools["playwright-test"]`, `tools.playwright.mcp`, `tools['x']['y']`: one step into the tools object. */
|
|
45
|
+
const ACCESSOR = /^\s*(?:\.\s*([A-Za-z0-9_$]+)|\[\s*(['"`])((?:\\.|(?!\2)[^\\])*)\2\s*\])/;
|
|
46
|
+
function stepInto(text) {
|
|
47
|
+
const match = ACCESSOR.exec(text);
|
|
48
|
+
if (!match)
|
|
49
|
+
return null;
|
|
50
|
+
return { value: match[1] ?? match[3], length: match[0].length };
|
|
51
|
+
}
|
|
52
|
+
/** How much of a call's arguments is read to name the thing it touched. */
|
|
53
|
+
const SUMMARY_ARGS = 600;
|
|
54
|
+
/** The argument that says most about a call, in the order a person would look for it: the place it touched, then the
|
|
55
|
+
* intent it was given for. */
|
|
56
|
+
const ARG_KEYS = [
|
|
57
|
+
'url',
|
|
58
|
+
'path',
|
|
59
|
+
'file_path',
|
|
60
|
+
'filePath',
|
|
61
|
+
'filename',
|
|
62
|
+
'element',
|
|
63
|
+
'selector',
|
|
64
|
+
'pattern',
|
|
65
|
+
'query',
|
|
66
|
+
'intent',
|
|
67
|
+
'description',
|
|
68
|
+
'name',
|
|
69
|
+
'text',
|
|
70
|
+
];
|
|
71
|
+
/**
|
|
72
|
+
* The calls a code-mode `execute` makes on the tools it is given
|
|
73
|
+
* (`tools["playwright-test"].browser_navigate({url: "..."})`). The code is the only thing that says what such a call
|
|
74
|
+
* did: the tool `execute` itself names nothing.
|
|
75
|
+
*/
|
|
76
|
+
function nestedCalls(code) {
|
|
77
|
+
const calls = [];
|
|
78
|
+
let from = 0;
|
|
79
|
+
for (;;) {
|
|
80
|
+
const at = code.indexOf('tools', from);
|
|
81
|
+
if (at < 0)
|
|
82
|
+
break;
|
|
83
|
+
from = at + 'tools'.length;
|
|
84
|
+
// `prototype.tools[...]` is somebody else's object, not the code mode's own.
|
|
85
|
+
if (at > 0 && /[A-Za-z0-9_$.]/.test(code[at - 1]))
|
|
86
|
+
continue;
|
|
87
|
+
const namespace = stepInto(code.slice(from));
|
|
88
|
+
if (!namespace)
|
|
89
|
+
continue;
|
|
90
|
+
const tool = stepInto(code.slice(from + namespace.length));
|
|
91
|
+
if (!tool)
|
|
92
|
+
continue;
|
|
93
|
+
const callAt = from + namespace.length + tool.length;
|
|
94
|
+
const open = /^\s*\(/.test(code.slice(callAt)) ? code.indexOf('(', callAt) : -1;
|
|
95
|
+
if (open < 0)
|
|
96
|
+
continue;
|
|
97
|
+
calls.push({ name: tool.value, args: code.slice(open + 1, open + 1 + SUMMARY_ARGS) });
|
|
98
|
+
from = open + 1;
|
|
99
|
+
}
|
|
100
|
+
return calls;
|
|
101
|
+
}
|
|
102
|
+
/** The first argument of a call that says what it touched: the URL, the element, the file, the intent. */
|
|
103
|
+
function firstArgument(args) {
|
|
104
|
+
const found = new Map();
|
|
105
|
+
for (const match of args.matchAll(/(['"]?)([A-Za-z_$][\w$]*)\1\s*:\s*(['"`])((?:\\.|(?!\3)[^\\])*?)\3/g)) {
|
|
106
|
+
if (!found.has(match[2]))
|
|
107
|
+
found.set(match[2], match[4].replace(/\\(['"`\\])/g, '$1'));
|
|
108
|
+
}
|
|
109
|
+
for (const key of ARG_KEYS) {
|
|
110
|
+
const value = found.get(key);
|
|
111
|
+
if (value && value.trim())
|
|
112
|
+
return oneLine(value, MAX_DETAIL);
|
|
113
|
+
}
|
|
114
|
+
return '';
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* What a code-mode call does, in one line: `browser_navigate(http://…)`, or the code's first statement when it makes no
|
|
118
|
+
* tool call. The whole code opens from the call's card when the line is not enough.
|
|
119
|
+
*/
|
|
120
|
+
function codeSummary(code) {
|
|
121
|
+
const calls = nestedCalls(code);
|
|
122
|
+
const [call] = calls;
|
|
123
|
+
const summary = call ? `${call.name}(${firstArgument(call.args)})` : oneLine(firstStatement(code), MAX_DETAIL);
|
|
124
|
+
const extra = calls.length - 1;
|
|
125
|
+
if (extra > 0)
|
|
126
|
+
return cap(`${summary} +${extra} more call${extra === 1 ? '' : 's'}`, MAX_DETAIL);
|
|
127
|
+
return cap(summary, MAX_DETAIL);
|
|
128
|
+
}
|
|
26
129
|
/** `mcp__playwright-test__browser_click` -> `browser_click`. */
|
|
27
130
|
const toolName = (name) => name.replace(/^mcp__[^_]+(?:-[^_]+)*__/, '');
|
|
28
131
|
/**
|
|
29
132
|
* What the call is about, in a few words: the intent the agent gave, or the argument that names the thing it touched.
|
|
30
133
|
* The names differ per CLI and per tool (`file_path` for Claude, `path` for OpenCode; `pattern` for glob and grep;
|
|
31
|
-
* `code` for a code-mode `execute`), so every one of them is looked at.
|
|
134
|
+
* `code` for a code-mode `execute`), so every one of them is looked at. This is the log's one line; the whole argument
|
|
135
|
+
* travels next to it (`fullInput`) and is opened on demand.
|
|
32
136
|
*/
|
|
33
137
|
function describe(input) {
|
|
34
138
|
if (!input || typeof input !== 'object')
|
|
@@ -51,45 +155,100 @@ function describe(input) {
|
|
|
51
155
|
].find((name) => typeof args[name] === 'string' && args[name].trim() !== '');
|
|
52
156
|
if (key)
|
|
53
157
|
return oneLine(args[key], MAX_DETAIL);
|
|
158
|
+
// A patch names its file inside the text, and the text itself would fill the whole line with diff noise.
|
|
159
|
+
const patch = args.patchText ?? args.patch_text ?? args.patch ?? args.diff ?? args.input;
|
|
160
|
+
if (typeof patch === 'string') {
|
|
161
|
+
const file = /^\+\+\+ b\/(.+)$/m.exec(patch)?.[1] ?? /^\*\*\* (?:Update|Add|Delete) File: (.+)$/m.exec(patch)?.[1];
|
|
162
|
+
if (file)
|
|
163
|
+
return oneLine(file.trim(), MAX_DETAIL);
|
|
164
|
+
}
|
|
54
165
|
if (typeof args.code === 'string')
|
|
55
|
-
return
|
|
166
|
+
return codeSummary(args.code);
|
|
56
167
|
// Nothing known: the longest string still tells more than the bare tool name.
|
|
57
168
|
const longest = Object.values(args)
|
|
58
169
|
.filter((value) => typeof value === 'string')
|
|
59
170
|
.sort((a, b) => b.length - a.length)[0];
|
|
60
171
|
return longest ? oneLine(longest, MAX_DETAIL) : '';
|
|
61
172
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
173
|
+
/**
|
|
174
|
+
* The whole call, as the log opens it: the code or the command as written, else the arguments as JSON. One short
|
|
175
|
+
* argument that the summary already shows is not worth a second copy of the same words.
|
|
176
|
+
*/
|
|
177
|
+
function fullInput(input, detail, tool) {
|
|
178
|
+
if (!input || typeof input !== 'object')
|
|
179
|
+
return undefined;
|
|
180
|
+
const args = input;
|
|
181
|
+
const edit = EDIT_TOOL.test(tool.toLowerCase().replace(/[_-]/g, ''));
|
|
182
|
+
const max = edit ? MAX_INPUT_EDIT : MAX_INPUT;
|
|
183
|
+
if (typeof args.code === 'string' && args.code.trim())
|
|
184
|
+
return capBody(args.code.trimEnd(), max);
|
|
185
|
+
if (typeof args.command === 'string' && args.command.trim())
|
|
186
|
+
return capBody(args.command.trimEnd(), max);
|
|
187
|
+
// One short argument that the summary already shows is not worth a second copy of the same words; anything longer
|
|
188
|
+
// than the summary (a patch, a file's content) or anything more (a window, a flag) is worth opening. An edit always
|
|
189
|
+
// keeps its argument: that is what the log draws as a diff.
|
|
190
|
+
const named = Object.entries(args).filter(([, value]) => value !== undefined && value !== null && String(value).trim() !== '');
|
|
191
|
+
const only = named.length === 1 ? named[0][1] : undefined;
|
|
192
|
+
if (!edit && typeof only === 'string' && only.trim().length <= MAX_DETAIL && oneLine(only, MAX_DETAIL) === detail) {
|
|
193
|
+
return undefined;
|
|
194
|
+
}
|
|
195
|
+
try {
|
|
196
|
+
const json = JSON.stringify(args, null, 2);
|
|
197
|
+
return json && json !== '{}' ? capBody(json, max) : undefined;
|
|
198
|
+
}
|
|
199
|
+
catch {
|
|
200
|
+
return undefined;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
const callOf = (name, input) => {
|
|
204
|
+
const detail = describe(input);
|
|
205
|
+
const full = fullInput(input, detail, name);
|
|
206
|
+
return {
|
|
207
|
+
name: toolName(name),
|
|
208
|
+
detail,
|
|
209
|
+
...(full && full !== detail && { input: full }),
|
|
210
|
+
ok: true,
|
|
211
|
+
pending: true,
|
|
212
|
+
};
|
|
213
|
+
};
|
|
68
214
|
const labelText = (call) => (call.detail ? `${call.name}: ${call.detail}` : call.name);
|
|
69
215
|
/**
|
|
70
|
-
*
|
|
71
|
-
*
|
|
216
|
+
* A JSON result is kept with the indentation a person reads: a minified document is one endless line, which no reader
|
|
217
|
+
* and no line cap can handle. Anything that is not JSON comes back as it was.
|
|
218
|
+
*/
|
|
219
|
+
function prettyJsonText(text) {
|
|
220
|
+
if (!text.startsWith('{') && !text.startsWith('['))
|
|
221
|
+
return text;
|
|
222
|
+
try {
|
|
223
|
+
return JSON.stringify(JSON.parse(text), null, 2);
|
|
224
|
+
}
|
|
225
|
+
catch {
|
|
226
|
+
return text;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* What a call returned, whole: the log opens one call and reads all of it. With the colors the shell put in it. A
|
|
231
|
+
* runaway dump is still cut at a point a person can come back from, and the line left out says how much it was.
|
|
72
232
|
*/
|
|
73
|
-
function
|
|
74
|
-
const
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
.trim();
|
|
233
|
+
function resultText(value) {
|
|
234
|
+
const raw = (typeof value === 'string' ? value : value == null ? '' : String(value)).replace(/\r\n?/g, '\n');
|
|
235
|
+
// A result that is a JSON string of its own text is decoded first: nobody reads `\n` as a line break in their head.
|
|
236
|
+
const text = prettyJsonText(expandEscapedText(raw).replace(/\t/g, ' ').trim());
|
|
78
237
|
if (!text)
|
|
79
238
|
return null;
|
|
80
239
|
const lines = text.split('\n');
|
|
81
240
|
const kept = [];
|
|
82
241
|
let chars = 0;
|
|
83
242
|
for (const line of lines) {
|
|
84
|
-
if (kept.length >=
|
|
243
|
+
if (kept.length >= MAX_RESULT_LINES || chars >= MAX_RESULT_CHARS)
|
|
85
244
|
break;
|
|
86
245
|
// A cut line loses its colors, so a half escape sequence cannot bleed into the rest of the block.
|
|
87
|
-
const shown = line.length >
|
|
246
|
+
const shown = line.length > MAX_RESULT_LINE ? oneLine(line.replace(ANSI, ''), MAX_RESULT_LINE) : line;
|
|
88
247
|
kept.push(shown);
|
|
89
248
|
chars += shown.length;
|
|
90
249
|
}
|
|
91
250
|
const rest = lines.length - kept.length;
|
|
92
|
-
return kept.join('\n') + (rest > 0 ? `\n… (${rest} more line${rest === 1 ? '' : 's'})` : '');
|
|
251
|
+
return kept.join('\n') + (rest > 0 ? `\n… (${rest} more line${rest === 1 ? '' : 's'} not shown)` : '');
|
|
93
252
|
}
|
|
94
253
|
const HANDOFF_CFG = '__handoffCfg';
|
|
95
254
|
const HANDOFF_CARD = /handoff-card\.js/;
|
|
@@ -251,12 +410,12 @@ export class AiStreamFormatter {
|
|
|
251
410
|
/** A call the agent has started and not finished yet (Claude reports both ends). */
|
|
252
411
|
start(id, call) {
|
|
253
412
|
const now = Date.now();
|
|
254
|
-
this.pending.set(id, { name: call.name, detail: call.detail, startedAt: now });
|
|
413
|
+
this.pending.set(id, { name: call.name, detail: call.detail, input: call.input, startedAt: now });
|
|
255
414
|
return [{ kind: 'tool', at: now, data: `>> ${labelText(call)}`, tool: call }];
|
|
256
415
|
}
|
|
257
416
|
/**
|
|
258
|
-
* The end of a call, plus
|
|
259
|
-
*
|
|
417
|
+
* The end of a call, plus what it returned. `fallback` is for a CLI that reports a call only when it is over
|
|
418
|
+
* (OpenCode), which still knows when it started.
|
|
260
419
|
*/
|
|
261
420
|
finishCall(id, ok, output, fallback) {
|
|
262
421
|
const now = Date.now();
|
|
@@ -266,6 +425,7 @@ export class AiStreamFormatter {
|
|
|
266
425
|
this.waitingForUser = null;
|
|
267
426
|
const name = call?.name ?? fallback?.name ?? 'tool';
|
|
268
427
|
const detail = call?.detail ?? fallback?.detail ?? '';
|
|
428
|
+
const input = call?.input ?? fallback?.input;
|
|
269
429
|
const startedAt = call?.startedAt ?? fallback?.startedAt;
|
|
270
430
|
const took = startedAt ? seconds(now - startedAt) : undefined;
|
|
271
431
|
const label = labelText({ name, detail });
|
|
@@ -275,7 +435,7 @@ export class AiStreamFormatter {
|
|
|
275
435
|
kind: 'tool',
|
|
276
436
|
at: now,
|
|
277
437
|
data: `${ok ? 'ok' : 'FAILED'}: ${label}${took ? ` after ${took}` : ''}`,
|
|
278
|
-
tool: { name, detail, ok, ...(took && { took }) },
|
|
438
|
+
tool: { name, detail, ...(input && { input }), ok, ...(took && { took }) },
|
|
279
439
|
},
|
|
280
440
|
];
|
|
281
441
|
if (!ok) {
|
|
@@ -285,9 +445,9 @@ export class AiStreamFormatter {
|
|
|
285
445
|
return parts;
|
|
286
446
|
}
|
|
287
447
|
// A saved login is a password: what a call read from `.auth` never goes into the log.
|
|
288
|
-
const
|
|
289
|
-
if (
|
|
290
|
-
parts.push({ kind: 'result', at: now, data:
|
|
448
|
+
const result = SECRET_PATH.test(detail) ? null : resultText(output);
|
|
449
|
+
if (result)
|
|
450
|
+
parts.push({ kind: 'result', at: now, data: result });
|
|
291
451
|
return parts;
|
|
292
452
|
}
|
|
293
453
|
/** Said once per run, after the first tool call that Claude Code's sandbox refused (nobody is there to approve it). */
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text of a tool call, as the log can show it. A tool that answers with a JSON string of its own text (`"…\n…"`, which
|
|
3
|
+
* is what an MCP result arrives as) is unreadable as escapes: the line is decoded here, once, for both the server that
|
|
4
|
+
* stores it and the page that renders what an older run already stored.
|
|
5
|
+
*/
|
|
6
|
+
/**
|
|
7
|
+
* One line of a result. If the whole line is a JSON string literal with escapes in it, the text it holds comes back —
|
|
8
|
+
* `"a\nb"` becomes two lines; a plain `"OK"` or a sentence in quotes keeps its quotes. Two rounds cover a value that
|
|
9
|
+
* was serialized twice, which is as deep as tools go.
|
|
10
|
+
*/
|
|
11
|
+
export declare function expandEscapedLine(line: string): string;
|
|
12
|
+
/** The whole result, line by line (`resultText` writes it this way on the server). */
|
|
13
|
+
export declare function expandEscapedText(text: string): string;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Text of a tool call, as the log can show it. A tool that answers with a JSON string of its own text (`"…\n…"`, which
|
|
3
|
+
* is what an MCP result arrives as) is unreadable as escapes: the line is decoded here, once, for both the server that
|
|
4
|
+
* stores it and the page that renders what an older run already stored.
|
|
5
|
+
*/
|
|
6
|
+
/** A string literal that carries an escape: `\n`, `\t`, `\"`, `\\`, `\u001b`. Plain quotes are left alone. */
|
|
7
|
+
const ESCAPED = /\\(?:[nrtbfv"'\\/]|u[0-9a-fA-F]{4})/;
|
|
8
|
+
/**
|
|
9
|
+
* One line of a result. If the whole line is a JSON string literal with escapes in it, the text it holds comes back —
|
|
10
|
+
* `"a\nb"` becomes two lines; a plain `"OK"` or a sentence in quotes keeps its quotes. Two rounds cover a value that
|
|
11
|
+
* was serialized twice, which is as deep as tools go.
|
|
12
|
+
*/
|
|
13
|
+
export function expandEscapedLine(line) {
|
|
14
|
+
let text = line;
|
|
15
|
+
for (let depth = 0; depth < 3; depth += 1) {
|
|
16
|
+
const trimmed = text.trim();
|
|
17
|
+
if (trimmed.length < 2 || !trimmed.startsWith('"') || !trimmed.endsWith('"'))
|
|
18
|
+
break;
|
|
19
|
+
if (!ESCAPED.test(trimmed))
|
|
20
|
+
break;
|
|
21
|
+
let parsed;
|
|
22
|
+
try {
|
|
23
|
+
parsed = JSON.parse(trimmed);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
break;
|
|
27
|
+
}
|
|
28
|
+
if (typeof parsed !== 'string')
|
|
29
|
+
break;
|
|
30
|
+
// The indentation a log line arrived with stays in front of what it turned into.
|
|
31
|
+
const indent = /^\s*/.exec(text)?.[0] ?? '';
|
|
32
|
+
text = parsed
|
|
33
|
+
.split('\n')
|
|
34
|
+
.map((part) => `${indent}${part}`)
|
|
35
|
+
.join('\n');
|
|
36
|
+
}
|
|
37
|
+
return text;
|
|
38
|
+
}
|
|
39
|
+
/** The whole result, line by line (`resultText` writes it this way on the server). */
|
|
40
|
+
export function expandEscapedText(text) {
|
|
41
|
+
return text
|
|
42
|
+
.split('\n')
|
|
43
|
+
.map((line) => expandEscapedLine(line))
|
|
44
|
+
.join('\n');
|
|
45
|
+
}
|
|
@@ -67,7 +67,7 @@ export type ResultState = 'ready' | 'needs-input';
|
|
|
67
67
|
/**
|
|
68
68
|
* How one block of an AI job's log is presented. Absent for the plain output of every other job, which stays raw.
|
|
69
69
|
* `markdown`: what the agent said; `reasoning`: what it was thinking (not a result); `tool`: one call, started or
|
|
70
|
-
* finished; `result`:
|
|
70
|
+
* finished; `result`: what a call returned (whole, up to a cap); `error` / `hint`: what failed and what to do about it;
|
|
71
71
|
* `meta`: anything else a person should read (the waiting line, noise the CLI printed).
|
|
72
72
|
*/
|
|
73
73
|
export type JobChunkKind = 'tool' | 'result' | 'reasoning' | 'markdown' | 'error' | 'hint' | 'meta';
|
|
@@ -76,6 +76,11 @@ export interface JobChunkTool {
|
|
|
76
76
|
name: string;
|
|
77
77
|
/** What the call is about (the file, the command, the intent); empty when the CLI gave nothing telling. */
|
|
78
78
|
detail: string;
|
|
79
|
+
/**
|
|
80
|
+
* The whole argument the call was made with (its code, its command, the rest as JSON), so the log can open one call
|
|
81
|
+
* and show it in full. Absent when the call gave nothing beyond `detail`, which a saved login's path never is.
|
|
82
|
+
*/
|
|
83
|
+
input?: string;
|
|
79
84
|
ok: boolean;
|
|
80
85
|
/** Still running: Claude reports the start of a call, OpenCode only its end. */
|
|
81
86
|
pending?: boolean;
|
package/docs/web-ui.md
CHANGED
|
@@ -170,10 +170,24 @@ cancelled there. Opening it shrinks the page instead of covering it.
|
|
|
170
170
|
job it clashes with. A run that says nothing for 10 minutes — 15 while a handoff card waits for a person — is stopped
|
|
171
171
|
and its log says why; see [troubleshooting](./troubleshooting.md#ai-steps).
|
|
172
172
|
|
|
173
|
-
- An AI job's log is structured: a timestamp, one line per tool call (name, what it touched, how long it took),
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
173
|
+
- An AI job's log is structured: a timestamp, one line per tool call (name, what it touched, how long it took), what
|
|
174
|
+
that call returned with the colors the command printed, the agent's thinking dimmed while **Thinking** is ticked, and
|
|
175
|
+
its messages rendered as Markdown. Every call is a card: closed it shows the first lines of what came back, and
|
|
176
|
+
clicking it opens the whole argument (the code it ran, the command) and the whole output, each with a **Copy**. The
|
|
177
|
+
line itself names the call in one look — `browser_navigate(http://…)` for code that drives a browser tool. **Expand
|
|
178
|
+
all** opens every card of the run at once; **Wrap lines** applies to the opened text too. A message of the agent has
|
|
179
|
+
**Copy markdown**, which takes the source as it wrote it. Colors are mapped onto the theme (`lib/ansi.ts`), because a
|
|
180
|
+
shell's VGA blue is unreadable on the dark pane. The same text, whole, is kept in `.tmp/ui-jobs/`.
|
|
181
|
+
- A call that answered with JSON is drawn as a tree, in the log's colors, with every node folding on its own
|
|
182
|
+
(**Expand nodes** / **Collapse nodes** open and close them all, and the section's **Copy** takes the indented
|
|
183
|
+
document). A document bigger than a tree should draw, or a result that had to be cut short, stays text instead. A tool
|
|
184
|
+
that answers with a JSON string of its own text (`"\nRunning 1 test…"`) is decoded to lines first, so no escape is
|
|
185
|
+
read as `\n`.
|
|
186
|
+
- An edit (`edit`, `multi-edit`, `write`, `patch`, …) is drawn as a diff: the file, `+N −M` in the card's line and in
|
|
187
|
+
the diff's own header, then the lines themselves — removed in red, added in green, long stretches of unchanged lines
|
|
188
|
+
folded away. **Copy** takes the diff as a unified patch. **Raw** switches back to the arguments the agent passed. An
|
|
189
|
+
edit's arguments are kept longer than other calls' (20 000 characters) so a whole small file can be diffed; past
|
|
190
|
+
that the card falls back to the plain argument.
|
|
177
191
|
- When an AI job ends, a thin **What to do next** bar appears under the log (or **Details** opens it in a sheet):
|
|
178
192
|
open questions with an answer box and **Use the suggested defaults**, the confirmations the exploration marked, and
|
|
179
193
|
the next action (**Generate spec**, **Open Run tests**, **View spec**, **Re-explore**). A run that ends with
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@asrunjs/asrun",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.13.0",
|
|
4
4
|
"description": "Multi-profile end-to-end test suite: plain-language cases, AI-written Playwright specs, evidence for every run.",
|
|
5
5
|
"license": "UNLICENSED",
|
|
6
6
|
"author": "Zoro Chen",
|
|
@@ -110,7 +110,9 @@
|
|
|
110
110
|
"@tanstack/react-query": "^5.105.0",
|
|
111
111
|
"@uiw/react-codemirror": "^4.25.12",
|
|
112
112
|
"anser": "^2.3.5",
|
|
113
|
+
"diff": "^9.0.0",
|
|
113
114
|
"hono": "^4.13.14",
|
|
115
|
+
"json-edit-react": "2.0.5",
|
|
114
116
|
"pdfjs-dist": "^6.4.299",
|
|
115
117
|
"react": "^19.3.1",
|
|
116
118
|
"react-dom": "^19.3.1",
|