@opencode-cockpit/subagents 0.7.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/LICENSE +21 -0
- package/README.md +107 -0
- package/dist/agent/plugin.js +134 -0
- package/dist/cli/preview.js +99 -0
- package/dist/core/adapt/v1.js +341 -0
- package/dist/core/adapt/v2.js +379 -0
- package/dist/core/model/changes.js +17 -0
- package/dist/core/model/model.js +301 -0
- package/dist/core/sample.js +261 -0
- package/dist/core/view/markdown.js +211 -0
- package/dist/core/view/report.js +58 -0
- package/dist/core/view/rows.js +146 -0
- package/dist/core/view/screen.js +767 -0
- package/dist/core/view/sidebar.js +151 -0
- package/dist/server.js +2 -0
- package/dist/tui/index.js +991 -0
- package/dist/tui/render.js +103 -0
- package/dist/tui/source.js +228 -0
- package/dist/tui/view/overlay.js +87 -0
- package/dist/tui/view/sidebar.js +78 -0
- package/package.json +64 -0
- package/server.js +6 -0
- package/tui.js +6 -0
- package/types/agent/plugin.d.ts +32 -0
- package/types/cli/preview.d.ts +10 -0
- package/types/core/adapt/v1.d.ts +33 -0
- package/types/core/adapt/v2.d.ts +22 -0
- package/types/core/model/changes.d.ts +98 -0
- package/types/core/model/model.d.ts +99 -0
- package/types/core/sample.d.ts +8 -0
- package/types/core/view/markdown.d.ts +22 -0
- package/types/core/view/report.d.ts +15 -0
- package/types/core/view/rows.d.ts +43 -0
- package/types/core/view/screen.d.ts +101 -0
- package/types/core/view/sidebar.d.ts +30 -0
- package/types/server.d.ts +2 -0
- package/types/tui/index.d.ts +26 -0
- package/types/tui/render.d.ts +27 -0
- package/types/tui/source.d.ts +45 -0
- package/types/tui/view/overlay.d.ts +30 -0
- package/types/tui/view/sidebar.d.ts +22 -0
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A run caught mid-flight, for the preview: build has launched three subagents — one searching, one
|
|
3
|
+
* held on a permission, one finished. Fixed times, so the preview draws the same thing every time.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export const SAMPLE_ROOT = "ses_build";
|
|
7
|
+
export const SAMPLE_NOW = 1_790_000_060_000;
|
|
8
|
+
export function sample() {
|
|
9
|
+
const t = s => SAMPLE_NOW - 60_000 + s * 1000;
|
|
10
|
+
return [{
|
|
11
|
+
type: "session",
|
|
12
|
+
id: SAMPLE_ROOT,
|
|
13
|
+
agent: "build",
|
|
14
|
+
title: "Refactor auth and fix login tests",
|
|
15
|
+
at: t(0)
|
|
16
|
+
}, {
|
|
17
|
+
type: "session",
|
|
18
|
+
id: "ses_explore",
|
|
19
|
+
parentID: SAMPLE_ROOT,
|
|
20
|
+
agent: "explore",
|
|
21
|
+
title: "Map the authentication flow",
|
|
22
|
+
at: t(9)
|
|
23
|
+
}, {
|
|
24
|
+
type: "prompt",
|
|
25
|
+
id: "ses_explore",
|
|
26
|
+
key: "u1",
|
|
27
|
+
text: "Map how a session is created, read and destroyed in src/auth. List every caller of requireSession.",
|
|
28
|
+
at: t(9)
|
|
29
|
+
}, {
|
|
30
|
+
type: "status",
|
|
31
|
+
id: "ses_explore",
|
|
32
|
+
status: "busy",
|
|
33
|
+
at: t(9)
|
|
34
|
+
}, {
|
|
35
|
+
type: "thinking",
|
|
36
|
+
id: "ses_explore",
|
|
37
|
+
key: "r1",
|
|
38
|
+
text: "Start with the file tree, then session.ts — the name suggests it owns the lifecycle. The middleware probably reads it per request. I'll grep for requireSession afterwards to find the callers.",
|
|
39
|
+
done: true,
|
|
40
|
+
at: t(10)
|
|
41
|
+
}, {
|
|
42
|
+
type: "tool",
|
|
43
|
+
id: "ses_explore",
|
|
44
|
+
call: "c1",
|
|
45
|
+
name: "glob",
|
|
46
|
+
state: "completed",
|
|
47
|
+
input: {
|
|
48
|
+
pattern: "src/auth/**/*.ts"
|
|
49
|
+
},
|
|
50
|
+
output: "23 files",
|
|
51
|
+
at: t(12)
|
|
52
|
+
}, {
|
|
53
|
+
type: "tool",
|
|
54
|
+
id: "ses_explore",
|
|
55
|
+
call: "c1",
|
|
56
|
+
at: t(12)
|
|
57
|
+
}, {
|
|
58
|
+
type: "tool",
|
|
59
|
+
id: "ses_explore",
|
|
60
|
+
call: "c2",
|
|
61
|
+
name: "read",
|
|
62
|
+
state: "completed",
|
|
63
|
+
input: {
|
|
64
|
+
filePath: "/acme/src/auth/session.ts"
|
|
65
|
+
},
|
|
66
|
+
at: t(13)
|
|
67
|
+
}, {
|
|
68
|
+
type: "tool",
|
|
69
|
+
id: "ses_explore",
|
|
70
|
+
call: "c3",
|
|
71
|
+
name: "read",
|
|
72
|
+
state: "completed",
|
|
73
|
+
input: {
|
|
74
|
+
filePath: "/acme/src/auth/middleware.ts"
|
|
75
|
+
},
|
|
76
|
+
at: t(14)
|
|
77
|
+
}, {
|
|
78
|
+
type: "tool",
|
|
79
|
+
id: "ses_explore",
|
|
80
|
+
call: "c3b",
|
|
81
|
+
name: "bash",
|
|
82
|
+
state: "completed",
|
|
83
|
+
input: {
|
|
84
|
+
command: "git status --short",
|
|
85
|
+
description: "Show changed files"
|
|
86
|
+
},
|
|
87
|
+
output: ["M CHANGELOG.md", " M src/auth/session.ts", " M src/auth/middleware.ts", " M src/auth/token.ts", " M src/routes/login.ts", " M src/routes/logout.ts", " M test/auth/session.test.ts", " M test/auth/login.test.ts", " M test/routes/login.test.ts", " M package.json", "?? src/auth/refresh.ts", "?? test/auth/refresh.test.ts"].join("\n"),
|
|
88
|
+
started: t(14),
|
|
89
|
+
ended: t(15),
|
|
90
|
+
at: t(14)
|
|
91
|
+
}, {
|
|
92
|
+
type: "tool",
|
|
93
|
+
id: "ses_explore",
|
|
94
|
+
call: "c3c",
|
|
95
|
+
name: "bash",
|
|
96
|
+
state: "failed",
|
|
97
|
+
input: {
|
|
98
|
+
command: "git rev-parse --verify refs/heads/auth-refresh"
|
|
99
|
+
},
|
|
100
|
+
error: "fatal: Needed a single revision",
|
|
101
|
+
started: t(15),
|
|
102
|
+
ended: t(15),
|
|
103
|
+
at: t(15)
|
|
104
|
+
}, {
|
|
105
|
+
type: "thinking",
|
|
106
|
+
id: "ses_explore",
|
|
107
|
+
key: "r2",
|
|
108
|
+
text: "createSession and readSession are the two entry points. Need to confirm where tokens are refreshed.",
|
|
109
|
+
done: true,
|
|
110
|
+
at: t(16)
|
|
111
|
+
}, {
|
|
112
|
+
type: "tool",
|
|
113
|
+
id: "ses_explore",
|
|
114
|
+
call: "c4",
|
|
115
|
+
name: "grep",
|
|
116
|
+
state: "running",
|
|
117
|
+
input: {
|
|
118
|
+
pattern: "session",
|
|
119
|
+
include: "src/auth/**"
|
|
120
|
+
},
|
|
121
|
+
output: "src/auth/session.ts:14: export function createSession(user: User) {\nsrc/auth/session.ts:41: export function readSession(req: Request) {\nsrc/auth/middleware.ts:7: const session = readSession(req)",
|
|
122
|
+
at: t(56)
|
|
123
|
+
}, {
|
|
124
|
+
type: "reply",
|
|
125
|
+
id: "ses_explore",
|
|
126
|
+
key: "t1",
|
|
127
|
+
delta: "So far: sessions are created in session.ts:14 and read per request by the middleware, and",
|
|
128
|
+
at: t(57)
|
|
129
|
+
}, {
|
|
130
|
+
type: "usage",
|
|
131
|
+
id: "ses_explore",
|
|
132
|
+
tokens: 6100,
|
|
133
|
+
cost: 0.004,
|
|
134
|
+
at: t(57)
|
|
135
|
+
}, {
|
|
136
|
+
type: "session",
|
|
137
|
+
id: "ses_login",
|
|
138
|
+
parentID: SAMPLE_ROOT,
|
|
139
|
+
agent: "general",
|
|
140
|
+
title: "Fix the failing login test",
|
|
141
|
+
at: t(10)
|
|
142
|
+
}, {
|
|
143
|
+
type: "prompt",
|
|
144
|
+
id: "ses_login",
|
|
145
|
+
key: "u1",
|
|
146
|
+
text: "The login test fails after the session refactor. Find out why and fix it.",
|
|
147
|
+
at: t(10)
|
|
148
|
+
}, {
|
|
149
|
+
type: "status",
|
|
150
|
+
id: "ses_login",
|
|
151
|
+
status: "busy",
|
|
152
|
+
at: t(10)
|
|
153
|
+
}, {
|
|
154
|
+
type: "tool",
|
|
155
|
+
id: "ses_login",
|
|
156
|
+
call: "l1",
|
|
157
|
+
name: "read",
|
|
158
|
+
state: "completed",
|
|
159
|
+
input: {
|
|
160
|
+
filePath: "/acme/test/login.test.ts"
|
|
161
|
+
},
|
|
162
|
+
at: t(12)
|
|
163
|
+
}, {
|
|
164
|
+
type: "tool",
|
|
165
|
+
id: "ses_login",
|
|
166
|
+
call: "l2",
|
|
167
|
+
name: "edit",
|
|
168
|
+
state: "completed",
|
|
169
|
+
input: {
|
|
170
|
+
filePath: "/acme/test/login.test.ts"
|
|
171
|
+
},
|
|
172
|
+
at: t(30)
|
|
173
|
+
}, {
|
|
174
|
+
type: "tool",
|
|
175
|
+
id: "ses_login",
|
|
176
|
+
call: "l3",
|
|
177
|
+
name: "bash",
|
|
178
|
+
state: "pending",
|
|
179
|
+
input: {
|
|
180
|
+
command: "npm test -- login"
|
|
181
|
+
},
|
|
182
|
+
at: t(58)
|
|
183
|
+
}, {
|
|
184
|
+
type: "status",
|
|
185
|
+
id: "ses_login",
|
|
186
|
+
status: "waiting",
|
|
187
|
+
at: t(58)
|
|
188
|
+
}, {
|
|
189
|
+
type: "usage",
|
|
190
|
+
id: "ses_login",
|
|
191
|
+
tokens: 3900,
|
|
192
|
+
cost: 0.003,
|
|
193
|
+
at: t(58)
|
|
194
|
+
}, {
|
|
195
|
+
type: "session",
|
|
196
|
+
id: "ses_readme",
|
|
197
|
+
parentID: SAMPLE_ROOT,
|
|
198
|
+
agent: "general",
|
|
199
|
+
title: "Update README for the new auth API",
|
|
200
|
+
at: t(11)
|
|
201
|
+
}, {
|
|
202
|
+
type: "prompt",
|
|
203
|
+
id: "ses_readme",
|
|
204
|
+
key: "u1",
|
|
205
|
+
text: "Update the README's auth section for the new session API.",
|
|
206
|
+
at: t(11)
|
|
207
|
+
}, {
|
|
208
|
+
type: "status",
|
|
209
|
+
id: "ses_readme",
|
|
210
|
+
status: "busy",
|
|
211
|
+
at: t(11)
|
|
212
|
+
}, {
|
|
213
|
+
type: "tool",
|
|
214
|
+
id: "ses_readme",
|
|
215
|
+
call: "d1",
|
|
216
|
+
name: "read",
|
|
217
|
+
state: "completed",
|
|
218
|
+
input: {
|
|
219
|
+
filePath: "/acme/README.md"
|
|
220
|
+
},
|
|
221
|
+
at: t(13)
|
|
222
|
+
}, {
|
|
223
|
+
type: "tool",
|
|
224
|
+
id: "ses_readme",
|
|
225
|
+
call: "d2",
|
|
226
|
+
name: "edit",
|
|
227
|
+
state: "completed",
|
|
228
|
+
input: {
|
|
229
|
+
filePath: "/acme/README.md"
|
|
230
|
+
},
|
|
231
|
+
at: t(25)
|
|
232
|
+
}, {
|
|
233
|
+
type: "tool",
|
|
234
|
+
id: "ses_readme",
|
|
235
|
+
call: "d3",
|
|
236
|
+
name: "edit",
|
|
237
|
+
state: "completed",
|
|
238
|
+
input: {
|
|
239
|
+
filePath: "/acme/README.md"
|
|
240
|
+
},
|
|
241
|
+
at: t(33)
|
|
242
|
+
}, {
|
|
243
|
+
type: "reply",
|
|
244
|
+
id: "ses_readme",
|
|
245
|
+
key: "t1",
|
|
246
|
+
text: "Updated the README's auth section: createSession, readSession and requireSession, with an example.",
|
|
247
|
+
done: true,
|
|
248
|
+
at: t(38)
|
|
249
|
+
}, {
|
|
250
|
+
type: "status",
|
|
251
|
+
id: "ses_readme",
|
|
252
|
+
status: "idle",
|
|
253
|
+
at: t(39)
|
|
254
|
+
}, {
|
|
255
|
+
type: "usage",
|
|
256
|
+
id: "ses_readme",
|
|
257
|
+
tokens: 2200,
|
|
258
|
+
cost: 0.001,
|
|
259
|
+
at: t(39)
|
|
260
|
+
}];
|
|
261
|
+
}
|
|
@@ -0,0 +1,211 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A subagent's answer as rows: the markdown it writes, drawn rather than shown as source.
|
|
3
|
+
*
|
|
4
|
+
* Only what agents actually write: headings, **bold**, `code`, lists, quotes and fenced code. Inline
|
|
5
|
+
* styles survive wrapping — a line is cut into styled words first, then the words are laid out —
|
|
6
|
+
* so a path in `code` that crosses the edge keeps its colour on both lines.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { fit, widthOf } from "./rows.js";
|
|
10
|
+
/** `**bold**` and `` `code` `` in one line, as styled pieces. */
|
|
11
|
+
export function inline(text, base) {
|
|
12
|
+
const out = [];
|
|
13
|
+
const pattern = /(\*\*[^*]+\*\*|`[^`]+`)/g;
|
|
14
|
+
let last = 0;
|
|
15
|
+
for (const match of text.matchAll(pattern)) {
|
|
16
|
+
const at = match.index ?? 0;
|
|
17
|
+
if (at > last) out.push({
|
|
18
|
+
...base,
|
|
19
|
+
text: text.slice(last, at)
|
|
20
|
+
});
|
|
21
|
+
const token = match[0];
|
|
22
|
+
if (token.startsWith("**")) out.push({
|
|
23
|
+
...base,
|
|
24
|
+
text: token.slice(2, -2),
|
|
25
|
+
bold: true
|
|
26
|
+
});else out.push({
|
|
27
|
+
...base,
|
|
28
|
+
text: token.slice(1, -1),
|
|
29
|
+
tone: "tool"
|
|
30
|
+
});
|
|
31
|
+
last = at + token.length;
|
|
32
|
+
}
|
|
33
|
+
if (last < text.length) out.push({
|
|
34
|
+
...base,
|
|
35
|
+
text: text.slice(last)
|
|
36
|
+
});
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Styled pieces onto lines of `width`, word by word. `first` leads the first line and `rest` every
|
|
42
|
+
* line after it — a bullet, then the indent that lines up under the words.
|
|
43
|
+
*/
|
|
44
|
+
export function flow(pieces, width, first, rest) {
|
|
45
|
+
const words = [];
|
|
46
|
+
for (const piece of pieces) {
|
|
47
|
+
for (const part of piece.text.split(/(\s+)/)) if (part) words.push({
|
|
48
|
+
...piece,
|
|
49
|
+
text: part
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const lines = [];
|
|
53
|
+
let line = [...first];
|
|
54
|
+
let used = widthOf(first.map(run => run.text).join(""));
|
|
55
|
+
const lead = () => widthOf(rest.map(run => run.text).join(""));
|
|
56
|
+
const empty = () => line.every(run => rest.includes(run) || first.includes(run));
|
|
57
|
+
for (const word of words) {
|
|
58
|
+
const w = widthOf(word.text);
|
|
59
|
+
const space = /^\s+$/.test(word.text);
|
|
60
|
+
if (used + w > width && !empty()) {
|
|
61
|
+
lines.push(line);
|
|
62
|
+
line = [...rest];
|
|
63
|
+
used = lead();
|
|
64
|
+
if (space) continue;
|
|
65
|
+
}
|
|
66
|
+
if (space && empty()) continue;
|
|
67
|
+
if (w > width - used) {
|
|
68
|
+
// A word longer than the line: cut it where the line ends.
|
|
69
|
+
let text = word.text;
|
|
70
|
+
while (widthOf(text) > width - used) {
|
|
71
|
+
let head = "";
|
|
72
|
+
for (const char of text) {
|
|
73
|
+
if (widthOf(head) + widthOf(char) > width - used) break;
|
|
74
|
+
head += char;
|
|
75
|
+
}
|
|
76
|
+
if (!head) break;
|
|
77
|
+
line.push({
|
|
78
|
+
...word,
|
|
79
|
+
text: head
|
|
80
|
+
});
|
|
81
|
+
lines.push(line);
|
|
82
|
+
line = [...rest];
|
|
83
|
+
used = lead();
|
|
84
|
+
text = text.slice(head.length);
|
|
85
|
+
}
|
|
86
|
+
if (text) {
|
|
87
|
+
line.push({
|
|
88
|
+
...word,
|
|
89
|
+
text
|
|
90
|
+
});
|
|
91
|
+
used += widthOf(text);
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
line.push(word);
|
|
96
|
+
used += w;
|
|
97
|
+
}
|
|
98
|
+
if (line.length > 0) lines.push(line);
|
|
99
|
+
return lines;
|
|
100
|
+
}
|
|
101
|
+
export function markdownRows(text, width, {
|
|
102
|
+
indent
|
|
103
|
+
}) {
|
|
104
|
+
const pad = {
|
|
105
|
+
text: " ".repeat(indent)
|
|
106
|
+
};
|
|
107
|
+
const room = Math.max(8, width);
|
|
108
|
+
const rows = [];
|
|
109
|
+
let fence = false;
|
|
110
|
+
let blank = false;
|
|
111
|
+
const push = row => {
|
|
112
|
+
rows.push(fit(row, width));
|
|
113
|
+
blank = false;
|
|
114
|
+
};
|
|
115
|
+
const gap = () => {
|
|
116
|
+
if (!blank && rows.length > 0) rows.push(fit([], width));
|
|
117
|
+
blank = true;
|
|
118
|
+
};
|
|
119
|
+
for (const raw of text.replace(/\r/g, "").split("\n")) {
|
|
120
|
+
const line = raw.replace(/\s+$/, "");
|
|
121
|
+
if (/^\s*```/.test(line)) {
|
|
122
|
+
fence = !fence;
|
|
123
|
+
if (fence) gap();
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (fence) {
|
|
127
|
+
push([pad, {
|
|
128
|
+
text: "│ ",
|
|
129
|
+
tone: "muted",
|
|
130
|
+
fill: "block"
|
|
131
|
+
}, {
|
|
132
|
+
text: line,
|
|
133
|
+
tone: "text",
|
|
134
|
+
fill: "block"
|
|
135
|
+
}]);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
if (line.trim() === "") {
|
|
139
|
+
gap();
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
|
|
143
|
+
if (heading) {
|
|
144
|
+
gap();
|
|
145
|
+
const level = heading[1].length;
|
|
146
|
+
const base = level <= 2 ? {
|
|
147
|
+
tone: "accent",
|
|
148
|
+
bold: true
|
|
149
|
+
} : {
|
|
150
|
+
tone: "text",
|
|
151
|
+
bold: true
|
|
152
|
+
};
|
|
153
|
+
for (const row of flow(inline(heading[2], base), room, [pad], [pad])) push(row);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
const bullet = /^(\s*)[-*+]\s+(.*)$/.exec(line);
|
|
157
|
+
if (bullet) {
|
|
158
|
+
const depth = Math.min(3, Math.floor(bullet[1].length / 2));
|
|
159
|
+
const lead = " ".repeat(depth * 2);
|
|
160
|
+
const first = [pad, {
|
|
161
|
+
text: `${lead}• `,
|
|
162
|
+
tone: "muted"
|
|
163
|
+
}];
|
|
164
|
+
const rest = [pad, {
|
|
165
|
+
text: `${lead} `
|
|
166
|
+
}];
|
|
167
|
+
for (const row of flow(inline(bullet[2], {
|
|
168
|
+
tone: "text"
|
|
169
|
+
}), room, first, rest)) push(row);
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
const numbered = /^(\s*)(\d+)[.)]\s+(.*)$/.exec(line);
|
|
173
|
+
if (numbered) {
|
|
174
|
+
const mark = `${numbered[2]}. `;
|
|
175
|
+
const first = [pad, {
|
|
176
|
+
text: mark,
|
|
177
|
+
tone: "muted"
|
|
178
|
+
}];
|
|
179
|
+
const rest = [pad, {
|
|
180
|
+
text: " ".repeat(mark.length)
|
|
181
|
+
}];
|
|
182
|
+
for (const row of flow(inline(numbered[3], {
|
|
183
|
+
tone: "text"
|
|
184
|
+
}), room, first, rest)) push(row);
|
|
185
|
+
continue;
|
|
186
|
+
}
|
|
187
|
+
const quote = /^>\s?(.*)$/.exec(line);
|
|
188
|
+
if (quote) {
|
|
189
|
+
const first = [pad, {
|
|
190
|
+
text: "▎ ",
|
|
191
|
+
tone: "border"
|
|
192
|
+
}];
|
|
193
|
+
for (const row of flow(inline(quote[1], {
|
|
194
|
+
tone: "muted"
|
|
195
|
+
}), room, first, first)) push(row);
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
if (/^\s*([-*_])\1{2,}\s*$/.test(line)) {
|
|
199
|
+
push([pad, {
|
|
200
|
+
text: "─".repeat(Math.max(0, Math.min(room - indent, 40))),
|
|
201
|
+
tone: "border"
|
|
202
|
+
}]);
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
for (const row of flow(inline(line, {
|
|
206
|
+
tone: "text"
|
|
207
|
+
}), room, [pad], [pad])) push(row);
|
|
208
|
+
}
|
|
209
|
+
while (rows.length > 0 && rows.at(-1)?.every(run => run.text.trim() === "")) rows.pop();
|
|
210
|
+
return rows;
|
|
211
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The main agent's view of its subagents: what `subagents_list` answers.
|
|
3
|
+
*
|
|
4
|
+
* Written for a model, not a person — plain lines, every id in full, and the one thing it needs to do
|
|
5
|
+
* with them said once: continue a subagent by its id rather than start a new one, so the follow-up
|
|
6
|
+
* keeps everything that subagent already read and tried. Pure, like everything in `core/`.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { elapsed } from "./rows.js";
|
|
10
|
+
|
|
11
|
+
/** How much of a subagent's last answer the list repeats. */
|
|
12
|
+
const ANSWER = 400;
|
|
13
|
+
export function subagentReport({
|
|
14
|
+
nodes,
|
|
15
|
+
now,
|
|
16
|
+
version
|
|
17
|
+
}) {
|
|
18
|
+
if (nodes.length === 0) return "This conversation has no subagents yet.";
|
|
19
|
+
const how = version === 1 ? "call the task tool with its id as task_id" : "call the subagent tool with its id as sessionID";
|
|
20
|
+
const lines = [`Subagents of this conversation, oldest first. To follow up on work one of them did, continue that same subagent — ${how} — instead of launching a new one: it keeps everything it already read and tried.`, ""];
|
|
21
|
+
for (const {
|
|
22
|
+
session,
|
|
23
|
+
depth
|
|
24
|
+
} of nodes) {
|
|
25
|
+
const calls = session.entries.filter(entry => entry.kind === "tool").length;
|
|
26
|
+
const rounds = session.entries.filter(entry => entry.kind === "prompt").length;
|
|
27
|
+
/** What it said after it was last asked something: an answer only if it ended on words. */
|
|
28
|
+
const since = session.entries.slice(lastPrompt(session.entries) + 1);
|
|
29
|
+
const tail = since.at(-1);
|
|
30
|
+
const answered = tail?.kind === "reply" && tail.text.trim() !== "";
|
|
31
|
+
const cancelled = session.status === "failed" && /abort|interrupt|cancel/i.test(session.error ?? "");
|
|
32
|
+
/** Idle before it did anything is finished, not working: nothing is coming from it. */
|
|
33
|
+
const ended = session.status === "done" || session.status === "starting" && session.ended !== undefined;
|
|
34
|
+
const ago = elapsed(now - (session.ended ?? now));
|
|
35
|
+
const state = cancelled ? `cancelled ${ago} ago — stopped before it finished${answered ? "" : ", no final answer"}` : ended ? answered ? `finished ${ago} ago` : `ended ${ago} ago without a final answer${since.length === 0 ? " (it never started on its task)" : ""}` : session.status === "failed" ? `failed${session.error ? ` (${session.error})` : ""}` : session.status === "waiting" ? "waiting for a permission" : "working now";
|
|
36
|
+
const indent = " ".repeat(depth);
|
|
37
|
+
lines.push(`${indent}- ${session.id} · ${session.agent} · "${session.title || "subagent"}" · ${state} · ${calls} call${calls === 1 ? "" : "s"}${rounds > 1 ? ` · ${rounds} rounds` : ""}${session.background ? " · background" : ""}`);
|
|
38
|
+
if (session.task) lines.push(`${indent} Task: ${clip(session.task, ANSWER)}`);
|
|
39
|
+
const answer = lastAnswer(session.entries);
|
|
40
|
+
if (answer) lines.push(answered ? `${indent} Last answer: ${clip(answer, ANSWER)}` : `${indent} Last words (a progress note, not its answer): ${clip(answer, ANSWER)}`);
|
|
41
|
+
}
|
|
42
|
+
return lines.join("\n");
|
|
43
|
+
}
|
|
44
|
+
function lastPrompt(entries) {
|
|
45
|
+
for (let i = entries.length - 1; i >= 0; i--) if (entries[i]?.kind === "prompt") return i;
|
|
46
|
+
return -1;
|
|
47
|
+
}
|
|
48
|
+
function lastAnswer(entries) {
|
|
49
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
50
|
+
const entry = entries[i];
|
|
51
|
+
if (entry?.kind === "reply" && entry.text.trim()) return entry.text;
|
|
52
|
+
}
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const clip = (text, most) => {
|
|
56
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
57
|
+
return flat.length > most ? `${flat.slice(0, most - 1)}…` : flat;
|
|
58
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Rows of styled runs — the one drawing of every Subagents surface, pure so a test can measure it.
|
|
3
|
+
*
|
|
4
|
+
* Tones name a meaning, never a colour; `tui/render.ts` is the only place that knows what a tone
|
|
5
|
+
* looks like, so the preview CLI and OpenCode draw the same rows (docs/building/terminal-ui.md).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* What sits behind a run: the header band, a block (code, the input), a card (the task, your
|
|
10
|
+
* messages), or the item under the cursor.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
export const rowText = row => row.map(run => run.text).join("");
|
|
14
|
+
|
|
15
|
+
/** Columns a string takes. Wide characters (CJK, most emoji) take two. */
|
|
16
|
+
export function widthOf(text, limit = Number.POSITIVE_INFINITY) {
|
|
17
|
+
let width = 0;
|
|
18
|
+
for (const char of text) {
|
|
19
|
+
/** Past the limit the answer is "too wide" — a 100 kB line need not be measured to the end. */
|
|
20
|
+
if (width > limit) return width;
|
|
21
|
+
const code = char.codePointAt(0) ?? 0;
|
|
22
|
+
width += code >= 0x1100 && (code <= 0x115f || code >= 0x2e80 && code <= 0xa4cf || code >= 0xac00 && code <= 0xd7a3 || code >= 0xf900 && code <= 0xfaff || code >= 0xfe30 && code <= 0xfe4f || code >= 0xff00 && code <= 0xff60 || code >= 0xffe0 && code <= 0xffe6 || code >= 0x1f300 && code <= 0x1faff || code >= 0x20000 && code <= 0x3fffd) ? 2 : 1;
|
|
23
|
+
}
|
|
24
|
+
return width;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** `text` in at most `width` columns, ending in `…` when cut. */
|
|
28
|
+
export function cut(text, width) {
|
|
29
|
+
if (width <= 0) return "";
|
|
30
|
+
if (widthOf(text, width) <= width) return text;
|
|
31
|
+
let out = "";
|
|
32
|
+
let used = 0;
|
|
33
|
+
for (const char of text) {
|
|
34
|
+
const w = widthOf(char);
|
|
35
|
+
if (used + w > width - 1) break;
|
|
36
|
+
out += char;
|
|
37
|
+
used += w;
|
|
38
|
+
}
|
|
39
|
+
return `${out}…`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* A row exactly `width` columns wide: runs cut where they overflow, and padded — in the last run's
|
|
44
|
+
* fill — where they fall short, so a band reaches both edges. Every row every surface draws goes
|
|
45
|
+
* through here; the grid test holds it to it.
|
|
46
|
+
*/
|
|
47
|
+
export function fit(row, width) {
|
|
48
|
+
const out = [];
|
|
49
|
+
let used = 0;
|
|
50
|
+
for (const run of row) {
|
|
51
|
+
if (used >= width) break;
|
|
52
|
+
const room = width - used;
|
|
53
|
+
const w = widthOf(run.text, room);
|
|
54
|
+
if (w <= room) {
|
|
55
|
+
out.push(run);
|
|
56
|
+
used += w;
|
|
57
|
+
} else {
|
|
58
|
+
const text = cut(run.text, room);
|
|
59
|
+
out.push({
|
|
60
|
+
...run,
|
|
61
|
+
text
|
|
62
|
+
});
|
|
63
|
+
used += widthOf(text);
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (used < width) {
|
|
68
|
+
const last = out.at(-1);
|
|
69
|
+
out.push({
|
|
70
|
+
text: " ".repeat(width - used),
|
|
71
|
+
...(last?.fill ? {
|
|
72
|
+
fill: last.fill
|
|
73
|
+
} : {})
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Left and right parts on one row, the right part flush against the edge; the left gives way. */
|
|
80
|
+
export function spread(left, right, width) {
|
|
81
|
+
const rightWidth = widthOf(rowText(right));
|
|
82
|
+
if (rightWidth >= width) return fit(right, width);
|
|
83
|
+
const leftRoom = width - rightWidth - 1;
|
|
84
|
+
const shown = fit(left, leftRoom);
|
|
85
|
+
const fill = right[0]?.fill ?? left.at(-1)?.fill;
|
|
86
|
+
return [...shown, {
|
|
87
|
+
text: " ",
|
|
88
|
+
...(fill ? {
|
|
89
|
+
fill
|
|
90
|
+
} : {})
|
|
91
|
+
}, ...right];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Words onto lines of at most `width` columns; newlines in the text are kept; a long word breaks. */
|
|
95
|
+
export function wrap(text, width) {
|
|
96
|
+
const room = Math.max(1, width);
|
|
97
|
+
const lines = [];
|
|
98
|
+
for (const paragraph of text.replace(/\r/g, "").split("\n")) {
|
|
99
|
+
if (paragraph.trim() === "") {
|
|
100
|
+
lines.push("");
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
let line = "";
|
|
104
|
+
for (const word of paragraph.split(/(\s+)/)) {
|
|
105
|
+
if (word === "") continue;
|
|
106
|
+
if (widthOf(line) + widthOf(word) <= room) {
|
|
107
|
+
line += word;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (line.trim() !== "") lines.push(line.trimEnd());
|
|
111
|
+
line = /^\s+$/.test(word) ? "" : word;
|
|
112
|
+
while (widthOf(line) > room) {
|
|
113
|
+
let head = "";
|
|
114
|
+
for (const char of line) {
|
|
115
|
+
if (widthOf(head) + widthOf(char) > room) break;
|
|
116
|
+
head += char;
|
|
117
|
+
}
|
|
118
|
+
lines.push(head);
|
|
119
|
+
line = line.slice(head.length);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
lines.push(line.trimEnd());
|
|
123
|
+
}
|
|
124
|
+
return lines;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** A duration in the fewest characters that still reads: `4s`, `51s`, `2m04s`, `1h12m`. */
|
|
128
|
+
export function elapsed(ms) {
|
|
129
|
+
const s = Math.max(0, Math.floor(ms / 1000));
|
|
130
|
+
if (s < 60) return `${s}s`;
|
|
131
|
+
const m = Math.floor(s / 60);
|
|
132
|
+
if (m < 60) return `${m}m${String(s % 60).padStart(2, "0")}s`;
|
|
133
|
+
return `${Math.floor(m / 60)}h${String(m % 60).padStart(2, "0")}m`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** `1.2k`, `24k`, `1.1M` — tokens in a narrow column. */
|
|
137
|
+
export function compact(n) {
|
|
138
|
+
if (n < 1000) return `${n}`;
|
|
139
|
+
if (n < 10_000) return `${(n / 1000).toFixed(1).replace(/\.0$/, "")}k`;
|
|
140
|
+
if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
|
|
141
|
+
return `${(n / 1_000_000).toFixed(1).replace(/\.0$/, "")}M`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** A spinner frame, from a clock that ticks on every paint. */
|
|
145
|
+
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
146
|
+
export const spin = frame => SPINNER[Math.abs(frame) % SPINNER.length];
|