@hncode/hncode 0.5.0 → 0.5.2

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 (48) hide show
  1. package/bin/install.cjs +24 -44
  2. package/bin/preinstall.cjs +0 -14
  3. package/package.json +1 -1
  4. package/src/agent-task.js +12 -1
  5. package/src/agent.js +115 -35
  6. package/src/cell-buffer.js +5 -0
  7. package/src/clipboard.js +42 -6
  8. package/src/colors.js +478 -387
  9. package/src/config.js +38 -11
  10. package/src/context-bar.js +46 -5
  11. package/src/doctor.js +214 -11
  12. package/src/git.js +35 -4
  13. package/src/llm.js +6 -0
  14. package/src/memory-profile.js +247 -0
  15. package/src/permissions.js +29 -7
  16. package/src/registry-browser.js +21 -6
  17. package/src/row-cache.js +147 -142
  18. package/src/session-core.js +203 -0
  19. package/src/session-host.js +213 -0
  20. package/src/session-hub.js +1 -0
  21. package/src/session-view.js +110 -0
  22. package/src/settings-schema.js +304 -0
  23. package/src/shell-safety.js +95 -6
  24. package/src/swarm-progress.js +348 -293
  25. package/src/tasks-browser.js +4 -1
  26. package/src/team/constants.js +20 -0
  27. package/src/team/context.js +89 -0
  28. package/src/team/mailbox.js +139 -0
  29. package/src/team/runner.js +347 -0
  30. package/src/team/spawn.js +247 -0
  31. package/src/team/store.js +145 -0
  32. package/src/team/tasks.js +164 -0
  33. package/src/team/team.js +311 -0
  34. package/src/team-panel.js +226 -0
  35. package/src/term.js +27 -0
  36. package/src/tool-result.js +279 -236
  37. package/src/tools/agent-swarm.js +114 -11
  38. package/src/tools/agent.js +53 -3
  39. package/src/tools/edit.js +591 -436
  40. package/src/tools/git.js +15 -3
  41. package/src/tools/index.js +2 -0
  42. package/src/tools/team.js +398 -0
  43. package/src/tools/web-search.js +88 -75
  44. package/src/tui.js +6187 -4780
  45. package/src/update.js +285 -115
  46. package/src/web-daemon.js +27 -4
  47. package/src/web-live.js +250 -0
  48. package/src/web.js +3 -1
@@ -1,293 +1,348 @@
1
- // AgentSwarm live progress block, mirroring kimi-code's AgentSwarmProgress.
2
- //
3
- // kimi does NOT render a swarm as a tool line plus a text dump. It renders a live
4
- // BLOCK in the transcript:
5
- //
6
- // ─ Agent Swarm ─ 两个子代理各回你好 ─ coder
7
- //
8
- // 001 [#---------------] Working… Inspecting src/tui.js
9
- // 002 [###-------------] Queued…
10
- // 003 [################] Completed.
11
- //
12
- // Completed. ━━━━━━━━━━━━━━━━━━━━━
13
- //
14
- // Structure (all of it from kimi's component):
15
- // * header - "Agent Swarm" + " ─ " + description + " ─ " + model, padded with ─
16
- // * grid - one CELL per subagent: an index, a progress bar, a phase label
17
- // and the latest line of that subagent's output
18
- // * status - the overall label plus a segmented "pip bar": one ━ per subagent,
19
- // coloured by that subagent's phase, in STATUS_BAR_ORDER
20
- //
21
- // This module is PURE: it takes a snapshot and returns lines. No timers, no
22
- // state — the TUI owns the member list and calls renderSwarmProgress().
23
-
24
- import { C } from './colors.js';
25
- import { visualWidth } from './term.js';
26
-
27
- const ESC = '\x1b';
28
- function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
29
- const ELLIPSIS = '\u2026';
30
- function col(text, c) { return c + String(text) + C.reset; }
31
-
32
- // The order kimi lays the pip-bar segments in, so the bar always reads
33
- // completed-first regardless of which subagent finished when.
34
- const STATUS_BAR_ORDER = ['completed', 'working', 'suspended', 'queued', 'cancelled', 'failed'];
35
-
36
- // phase -> (label, colour). kimi's labels, verbatim.
37
- const PHASE = {
38
- queued: { label: 'Queued\u2026', color: C.gray },
39
- prompting: { label: 'Prompting\u2026', color: C.cyan },
40
- working: { label: 'Working\u2026', color: C.cyan },
41
- suspended: { label: 'Rate limited\u2026', color: C.yellow },
42
- completed: { label: 'Completed.', color: C.green },
43
- failed: { label: 'Failed.', color: C.red },
44
- cancelled: { label: 'Cancelled.', color: C.gray },
45
- };
46
- export function phaseInfo(phase) { return PHASE[phase] || PHASE.working; }
47
-
48
- const BAR_EMPTY = '-';
49
- const BAR_FULL = '#';
50
- const STATUS_BAR_CHAR = '\u2501'; // ━
51
- const SUCCESS_MARK = '\u2714'; // ✔
52
- const FAILURE_MARK = '\u2716'; // ✖
53
- const CANCELLED_MARK = '\u2716';
54
-
55
- // ---- helpers ---------------------------------------------------------------
56
-
57
- function truncateToWidth(s, width) {
58
- const str = String(s);
59
- if (width <= 0) return '';
60
- if (visWidth(str) <= width) return str;
61
- let out = '';
62
- let n = 0;
63
- let i = 0;
64
- while (i < str.length) {
65
- if (str[i] === ESC) {
66
- const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i));
67
- if (m) { out += m[0]; i += m[0].length; continue; }
68
- }
69
- const cp = str.codePointAt(i);
70
- const ch = String.fromCodePoint(cp);
71
- const cw = visualWidth(ch);
72
- if (n + cw > width - 1) break; // leave a cell for the ellipsis
73
- n += cw;
74
- out += ch;
75
- i += cp > 0xffff ? 2 : 1;
76
- }
77
- return out + ELLIPSIS;
78
- }
79
-
80
- function padTo(s, width) {
81
- const w = visWidth(s);
82
- return w >= width ? s : s + ' '.repeat(width - w);
83
- }
84
-
85
- function collapseWhitespace(s) {
86
- return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
87
- }
88
-
89
- // ---- the model -> phases ---------------------------------------------------
90
-
91
- // A background "agent" task carries the swarm in ctx: we track members from the
92
- // progress lines the AgentSwarm tool appends (`[n/m] finished`) plus, when the
93
- // caller has richer data, an explicit member list. Members are normalised here so
94
- // the renderer never has to guess.
95
- export function normalizeMember(m, i) {
96
- const src = m || {}; // a half-built member must not crash the grid
97
- const phase = PHASE[src.phase] ? src.phase : 'queued';
98
- return {
99
- index: i + 1,
100
- phase,
101
- latestText: collapseWhitespace(src.latestText || src.text || ''),
102
- label: src.label || '',
103
- };
104
- }
105
-
106
-
107
- // Overall status of the swarm: failed wins over aborted over working/completed.
108
- // `members` is normally an array, but this is also reachable from a rebuilt session
109
- // where it can be null — guard rather than crash the whole render.
110
- export function totalStatus(members, flags = {}) {
111
- if (flags && flags.failed) return 'failed';
112
- if (flags && flags.aborted) return 'aborted';
113
- if (!Array.isArray(members) || !members.length) return 'working';
114
- const phaseOf = (m) => (m && m.phase) || 'queued'; // a null member is "queued", not a crash
115
- if (members.some((m) => phaseOf(m) === 'failed')) return 'failed';
116
- if (members.every((m) => phaseOf(m) === 'completed')) return 'completed';
117
- if (members.every((m) => ['completed', 'cancelled', 'failed'].includes(phaseOf(m)))) return 'completed';
118
- return 'working';
119
- }
120
-
121
- const TOTAL_LABEL = {
122
- working: 'Working\u2026',
123
- completed: 'Completed.',
124
- failed: 'Failed.',
125
- aborted: 'Aborted.',
126
- cancelled: 'Cancelled.',
127
- };
128
-
129
- // ---- grid layout (kimi's calculateAgentSwarmGridLayout, simplified) --------
130
-
131
- export function computeGridLayout(input) {
132
- const { width, height, count } = input || {};
133
- // Every input is coerced: a caller that passes nothing (or a NaN width) would
134
- // otherwise produce NaN rows/cells and a frame full of garbage.
135
- const safeWidth = Number.isFinite(width) && width > 0 ? width : 1;
136
- const safeCount = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1;
137
- const safeHeight = Number.isFinite(height) ? Math.max(0, height) : Number.POSITIVE_INFINITY;
138
- // Try the widest sensible column count whose cells still hold a readable bar.
139
- // Then clamp the ROWS back to the height budget: kimi falls back to COMPACT
140
- // bars rather than spilling. We keep the text cells but cap the row count, so a
141
- // 128-subagent swarm cannot push the transcript off screen.
142
- const MIN_CELL = 22;
143
- const GAP = 2;
144
- let best = { columns: 1, rows: safeCount, cellWidth: safeWidth };
145
- for (let columns = 1; columns <= safeCount; columns++) {
146
- const cellWidth = Math.floor((safeWidth - GAP * (columns - 1)) / columns);
147
- if (cellWidth < MIN_CELL) break;
148
- best = { columns, rows: Math.ceil(safeCount / columns), cellWidth };
149
- }
150
- // Visible rows never exceed the budget. The caller adds one "+N more" line.
151
- // Visible rows never exceed the budget. The caller adds one "+N more" line.
152
- const maxRows = Number.isFinite(safeHeight) ? Math.max(1, safeHeight) : best.rows;
153
- const shownRows = Math.min(best.rows, maxRows);
154
- return {
155
- columns: best.columns,
156
- rows: shownRows,
157
- cellWidth: best.cellWidth,
158
- totalRows: best.rows,
159
- truncated: best.rows > shownRows,
160
- // How many cells actually get drawn, so the caller can report the rest.
161
- visibleCount: Math.min(safeCount, shownRows * best.columns),
162
- count: safeCount,
163
- };
164
- }
165
-
166
- // ---- cells -----------------------------------------------------------------
167
-
168
- function renderBar(ratio, cells, color) {
169
- const n = Math.max(0, Math.round(ratio * cells));
170
- return col(BAR_FULL.repeat(n), color) + col(BAR_EMPTY.repeat(Math.max(0, cells - n)), C.gray);
171
- }
172
-
173
- // One cell: `001 [####----] Working… <latest text>`
174
- // The cell is truncated to EXACTLY `cellWidth` visible columns, so joining cells
175
- // with a gap cannot overflow the row (kimi truncates every cell the same way).
176
- function renderCell(member, cellWidth) {
177
- const idx = String(member.index).padStart(3, '0');
178
- const info = phaseInfo(member.phase);
179
- const label = info.label;
180
- const ratio = member.phase === 'completed' ? 1
181
- : member.phase === 'queued' ? 0
182
- : (member.ratio != null ? Math.max(0, Math.min(1, member.ratio)) : 0.5);
183
- // Budget: "001 [" + bar + "] " + label + optional " " + text.
184
- const fixed = 3 + 2 + 2 + visWidth(label);
185
- const barCells = Math.max(0, Math.min(20, cellWidth - fixed));
186
- let cell = col(idx, C.gray) + ' [' + renderBar(ratio, barCells, info.color) + '] '
187
- + col(label, info.color);
188
- const room = cellWidth - visWidth(cell);
189
- if (room > 3 && member.latestText) {
190
- cell += ' ' + col(truncateToWidth(member.latestText, room - 2), C.gray);
191
- }
192
- return padTo(truncateToWidth(cell, cellWidth), cellWidth);
193
- }
194
-
195
- // ---- the status pip bar ----------------------------------------------------
196
-
197
- function renderPipBar(members, width) {
198
- const counts = new Map();
199
- for (const m of members) {
200
- const p = STATUS_BAR_ORDER.includes(m.phase) ? m.phase : 'working';
201
- counts.set(p, (counts.get(p) || 0) + 1);
202
- }
203
- const segs = STATUS_BAR_ORDER.filter((p) => counts.get(p)).map((p) => ({ p, n: counts.get(p) }));
204
- if (!segs.length) return col(STATUS_BAR_CHAR.repeat(Math.max(1, width)), C.gray);
205
- const total = segs.reduce((a, s) => a + s.n, 0);
206
- let used = 0;
207
- return segs.map((s, i) => {
208
- const w = i === segs.length - 1 ? width - used : Math.round((s.n / total) * width);
209
- used += w;
210
- if (w <= 0) return '';
211
- return col(STATUS_BAR_CHAR.repeat(w), phaseInfo(s.p).color);
212
- }).join('');
213
- }
214
-
215
- // ---- public renderer -------------------------------------------------------
216
-
217
- /**
218
- * Render the swarm block. `snapshot`:
219
- * { description, model, members: [{phase, ratio, latestText}], failed, aborted }
220
- * Returns an array of already-coloured lines, ALREADY indented by `indent`.
221
- */
222
- export function renderSwarmProgress(snapshot, width, opts = {}) {
223
- const snap = snapshot || {};
224
- const desc = collapseWhitespace(snap.description || '');
225
- const model = collapseWhitespace(snap.model || '');
226
- const members = (Array.isArray(snap.members) ? snap.members : []).map(normalizeMember);
227
- const indent = opts.indent == null ? ' ' : opts.indent;
228
- const innerW = Math.max(10, width - visWidth(indent));
229
- const lines = [];
230
-
231
- // ---- header: `─ Agent Swarm ─ desc ─ model ─────` ----
232
- const title = col('Agent Swarm', C.spring + C.bold);
233
- const suffix = [];
234
- if (desc) suffix.push(col(' \u2500 ', C.spring) + col(desc, C.white));
235
- if (model) suffix.push(col(' \u2500 ', C.spring) + col(model, C.gray));
236
- const used = 3 + visWidth(title) + visWidth(suffix.join(''));
237
- const tail = Math.max(0, innerW - used);
238
- const header = col(' \u2500 ', C.spring) + title + suffix.join('') + (tail > 1 ? col(' ' + '\u2500'.repeat(tail - 1), C.spring) : '');
239
- lines.push(indent + truncateToWidth(header, innerW));
240
-
241
- // ---- optional blank + grid, exactly like kimi's [ '', header, '', grid, '', status, '' ] ----
242
- lines.push(indent + '');
243
- const gridH = Number.isFinite(opts.availableGridHeight) ? opts.availableGridHeight : undefined;
244
- const layout = computeGridLayout({ width: innerW, height: gridH, count: members.length });
245
- if (!members.length) {
246
- lines.push(indent + col('Orchestrating\u2026', C.cyan));
247
- } else {
248
- for (let r = 0; r < layout.rows; r++) {
249
- const cells = [];
250
- for (let c = 0; c < layout.columns; c++) {
251
- const m = members[r * layout.columns + c];
252
- if (!m) continue;
253
- cells.push(renderCell(m, layout.cellWidth));
254
- }
255
- if (cells.length) lines.push(indent + cells.join(' '));
256
- }
257
- // A swarm taller than the budget reports the remainder instead of spilling.
258
- if (layout.truncated) {
259
- lines.push(indent + col(`\u2026 +${layout.count - layout.visibleCount} more`, C.gray));
260
- }
261
- }
262
- lines.push(indent + '');
263
-
264
- // ---- status: mark + label + pip bar ----
265
- const status = totalStatus(members, { failed: snap.failed, aborted: snap.aborted });
266
- const mark = status === 'completed' ? SUCCESS_MARK
267
- : status === 'failed' ? FAILURE_MARK
268
- : status === 'aborted' ? CANCELLED_MARK
269
- : '';
270
- const label = TOTAL_LABEL[status] || TOTAL_LABEL.working;
271
- const lc = status === 'completed' ? C.green
272
- : status === 'failed' ? C.red
273
- : status === 'aborted' ? C.gray
274
- : C.cyan;
275
- const head = (mark ? col(mark + ' ', lc) : ' ') + col(label, lc);
276
- const barW = Math.max(0, innerW - visWidth(head) - 2);
277
- lines.push(indent + head + (barW > 0 ? ' ' + renderPipBar(members, barW) : ''));
278
- lines.push(indent + '');
279
- return lines;
280
- }
281
-
282
- // Derive a member list from the progress lines the AgentSwarm tool appends to a
283
- // task's output (`[n/m] finished`). Used as the fallback when the caller has no
284
- // richer per-subagent data — e.g. rebuilding the block after a session reload.
285
- export function membersFromOutput(output, total) {
286
- const text = String(output || '');
287
- const done = (text.match(/^\[\d+\/\d+\] finished$/gm) || []).length;
288
- const n = Math.max(total || 0, done);
289
- return Array.from({ length: n }, (_, i) => ({
290
- phase: i < done ? 'completed' : 'queued',
291
- latestText: '',
292
- }));
293
- }
1
+ // AgentSwarm live progress block, mirroring kimi-code's AgentSwarmProgress.
2
+ //
3
+ // kimi does NOT render a swarm as a tool line plus a text dump. It renders a live
4
+ // BLOCK in the transcript:
5
+ //
6
+ // ─ Agent Swarm ─ 两个子代理各回你好 ─ coder
7
+ //
8
+ // 001 [#---------------] Working… Inspecting src/tui.js
9
+ // 002 [###-------------] Queued…
10
+ // 003 [################] Completed.
11
+ //
12
+ // Completed. ━━━━━━━━━━━━━━━━━━━━━
13
+ //
14
+ // Structure (all of it from kimi's component):
15
+ // * header - "Agent Swarm" + " ─ " + description + " ─ " + model, padded with ─
16
+ // * grid - one CELL per subagent: an index, a progress bar, a phase label
17
+ // and the latest line of that subagent's output
18
+ // * status - the overall label plus a segmented "pip bar": one ━ per subagent,
19
+ // coloured by that subagent's phase, in STATUS_BAR_ORDER
20
+ //
21
+ // This module is PURE: it takes a snapshot and returns lines. No timers, no
22
+ // state — the TUI owns the member list and calls renderSwarmProgress().
23
+
24
+ import { C } from './colors.js';
25
+ import { visualWidth } from './term.js';
26
+
27
+ const ESC = '\x1b';
28
+ function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
29
+ const ELLIPSIS = '\u2026';
30
+ function col(text, c) { return c + String(text) + C.reset; }
31
+
32
+ // The order kimi lays the pip-bar segments in, so the bar always reads
33
+ // completed-first regardless of which subagent finished when.
34
+ const STATUS_BAR_ORDER = ['completed', 'working', 'suspended', 'queued', 'cancelled', 'failed'];
35
+
36
+ // phase -> (label, colour). kimi's labels, verbatim.
37
+ const PHASE = {
38
+ queued: { label: 'Queued\u2026', color: C.gray },
39
+ prompting: { label: 'Prompting\u2026', color: C.cyan },
40
+ working: { label: 'Working\u2026', color: C.cyan },
41
+ suspended: { label: 'Rate limited\u2026', color: C.yellow },
42
+ completed: { label: 'Completed.', color: C.green },
43
+ failed: { label: 'Failed.', color: C.red },
44
+ cancelled: { label: 'Cancelled.', color: C.gray },
45
+ };
46
+ export function phaseInfo(phase) { return PHASE[phase] || PHASE.working; }
47
+
48
+ const BAR_EMPTY = '-';
49
+ const BAR_FULL = '#';
50
+ const STATUS_BAR_CHAR = '\u2501'; // ━
51
+ const SUCCESS_MARK = '\u2714'; // ✔
52
+ const FAILURE_MARK = '\u2716'; // ✖
53
+ const CANCELLED_MARK = '\u2716';
54
+
55
+ // ---- helpers ---------------------------------------------------------------
56
+
57
+ function truncateToWidth(s, width) {
58
+ const str = String(s);
59
+ if (width <= 0) return '';
60
+ if (visWidth(str) <= width) return str;
61
+ let out = '';
62
+ let n = 0;
63
+ let i = 0;
64
+ while (i < str.length) {
65
+ if (str[i] === ESC) {
66
+ const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i));
67
+ if (m) { out += m[0]; i += m[0].length; continue; }
68
+ }
69
+ const cp = str.codePointAt(i);
70
+ const ch = String.fromCodePoint(cp);
71
+ const cw = visualWidth(ch);
72
+ if (n + cw > width - 1) break; // leave a cell for the ellipsis
73
+ n += cw;
74
+ out += ch;
75
+ i += cp > 0xffff ? 2 : 1;
76
+ }
77
+ return out + ELLIPSIS;
78
+ }
79
+
80
+ function padTo(s, width) {
81
+ const w = visWidth(s);
82
+ return w >= width ? s : s + ' '.repeat(width - w);
83
+ }
84
+
85
+ function collapseWhitespace(s) {
86
+ return String(s == null ? '' : s).replace(/\s+/g, ' ').trim();
87
+ }
88
+
89
+ // ---- the model -> phases ---------------------------------------------------
90
+
91
+ // A background "agent" task carries the swarm in ctx: we track members from the
92
+ // progress lines the AgentSwarm tool appends (`[n/m] finished`) plus, when the
93
+ // caller has richer data, an explicit member list. Members are normalised here so
94
+ // the renderer never has to guess.
95
+ export function normalizeMember(m, i) {
96
+ const src = m || {}; // a half-built member must not crash the grid
97
+ const phase = PHASE[src.phase] ? src.phase : 'queued';
98
+ return {
99
+ index: i + 1,
100
+ phase,
101
+ // Carried through, not dropped: `renderCell` reads it for the bar, and omitting it
102
+ // here meant every cell fell back to the placeholder ratio no matter what the agent
103
+ // reported — the wire carried a real figure and the renderer never saw one.
104
+ ratio: typeof src.ratio === 'number' && Number.isFinite(src.ratio) ? src.ratio : null,
105
+ latestText: collapseWhitespace(src.latestText || src.text || ''),
106
+ label: src.label || '',
107
+ };
108
+ }
109
+
110
+
111
+ // Overall status of the swarm: failed wins over aborted over working/completed.
112
+ // `members` is normally an array, but this is also reachable from a rebuilt session
113
+ // where it can be null — guard rather than crash the whole render.
114
+ export function totalStatus(members, flags = {}) {
115
+ if (flags && flags.failed) return 'failed';
116
+ if (flags && flags.aborted) return 'aborted';
117
+ if (!Array.isArray(members) || !members.length) return 'working';
118
+ const phaseOf = (m) => (m && m.phase) || 'queued'; // a null member is "queued", not a crash
119
+ if (members.some((m) => phaseOf(m) === 'failed')) return 'failed';
120
+ if (members.every((m) => phaseOf(m) === 'completed')) return 'completed';
121
+ if (members.every((m) => ['completed', 'cancelled', 'failed'].includes(phaseOf(m)))) return 'completed';
122
+ return 'working';
123
+ }
124
+
125
+ const TOTAL_LABEL = {
126
+ working: 'Working\u2026',
127
+ completed: 'Completed.',
128
+ failed: 'Failed.',
129
+ aborted: 'Aborted.',
130
+ cancelled: 'Cancelled.',
131
+ };
132
+
133
+ // ---- grid layout (kimi's calculateAgentSwarmGridLayout, simplified) --------
134
+
135
+ export function computeGridLayout(input) {
136
+ const { width, height, count } = input || {};
137
+ // Every input is coerced: a caller that passes nothing (or a NaN width) would
138
+ // otherwise produce NaN rows/cells and a frame full of garbage.
139
+ const safeWidth = Number.isFinite(width) && width > 0 ? width : 1;
140
+ const safeCount = Number.isFinite(count) ? Math.max(1, Math.floor(count)) : 1;
141
+ const safeHeight = Number.isFinite(height) ? Math.max(0, height) : Number.POSITIVE_INFINITY;
142
+ // Try the widest sensible column count whose cells still hold a readable bar.
143
+ // Then clamp the ROWS back to the height budget: kimi falls back to COMPACT
144
+ // bars rather than spilling. We keep the text cells but cap the row count, so a
145
+ // 128-subagent swarm cannot push the transcript off screen.
146
+ const MIN_CELL = 22;
147
+ const GAP = 2;
148
+ let best = { columns: 1, rows: safeCount, cellWidth: safeWidth };
149
+ for (let columns = 1; columns <= safeCount; columns++) {
150
+ const cellWidth = Math.floor((safeWidth - GAP * (columns - 1)) / columns);
151
+ if (cellWidth < MIN_CELL) break;
152
+ best = { columns, rows: Math.ceil(safeCount / columns), cellWidth };
153
+ }
154
+ // Visible rows never exceed the budget. The caller adds one "+N more" line.
155
+ // Visible rows never exceed the budget. The caller adds one "+N more" line.
156
+ const maxRows = Number.isFinite(safeHeight) ? Math.max(1, safeHeight) : best.rows;
157
+ const shownRows = Math.min(best.rows, maxRows);
158
+ return {
159
+ columns: best.columns,
160
+ rows: shownRows,
161
+ cellWidth: best.cellWidth,
162
+ totalRows: best.rows,
163
+ truncated: best.rows > shownRows,
164
+ // How many cells actually get drawn, so the caller can report the rest.
165
+ visibleCount: Math.min(safeCount, shownRows * best.columns),
166
+ count: safeCount,
167
+ };
168
+ }
169
+
170
+ // ---- cells -----------------------------------------------------------------
171
+
172
+ function renderBar(ratio, cells, color) {
173
+ const n = Math.max(0, Math.round(ratio * cells));
174
+ return col(BAR_FULL.repeat(n), color) + col(BAR_EMPTY.repeat(Math.max(0, cells - n)), C.gray);
175
+ }
176
+
177
+ // One cell: `001 [####----] Working… <latest text>`
178
+ // The cell is truncated to EXACTLY `cellWidth` visible columns, so joining cells
179
+ // with a gap cannot overflow the row (kimi truncates every cell the same way).
180
+ function renderCell(member, cellWidth) {
181
+ const idx = String(member.index).padStart(3, '0');
182
+ const info = phaseInfo(member.phase);
183
+ const label = info.label;
184
+ // A working cell with no ratio from its agent shows a near-empty bar, not a confident
185
+ // half. `0.5` was a fixed placeholder: every running agent drew the same half-filled bar
186
+ // for its whole life, which read as "halfway done" and was simply untrue — a swarm of
187
+ // seven showed seven identical 50% bars and then jumped straight to 100%.
188
+ const ratio = member.phase === 'completed' ? 1
189
+ : member.phase === 'queued' ? 0
190
+ : (member.ratio != null ? Math.max(0, Math.min(1, member.ratio)) : 0.05);
191
+ // Budget: "001 [" + bar + "] " + label + optional " " + text.
192
+ const fixed = 3 + 2 + 2 + visWidth(label);
193
+ const barCells = Math.max(0, Math.min(20, cellWidth - fixed));
194
+ let cell = col(idx, C.gray) + ' [' + renderBar(ratio, barCells, info.color) + '] '
195
+ + col(label, info.color);
196
+ const room = cellWidth - visWidth(cell);
197
+ if (room > 3 && member.latestText) {
198
+ cell += ' ' + col(truncateToWidth(member.latestText, room - 2), C.gray);
199
+ }
200
+ return padTo(truncateToWidth(cell, cellWidth), cellWidth);
201
+ }
202
+
203
+ // ---- the status pip bar ----------------------------------------------------
204
+
205
+ function renderPipBar(members, width) {
206
+ const counts = new Map();
207
+ for (const m of members) {
208
+ const p = STATUS_BAR_ORDER.includes(m.phase) ? m.phase : 'working';
209
+ counts.set(p, (counts.get(p) || 0) + 1);
210
+ }
211
+ const segs = STATUS_BAR_ORDER.filter((p) => counts.get(p)).map((p) => ({ p, n: counts.get(p) }));
212
+ if (!segs.length) return col(STATUS_BAR_CHAR.repeat(Math.max(1, width)), C.gray);
213
+ const total = segs.reduce((a, s) => a + s.n, 0);
214
+ let used = 0;
215
+ return segs.map((s, i) => {
216
+ const w = i === segs.length - 1 ? width - used : Math.round((s.n / total) * width);
217
+ used += w;
218
+ if (w <= 0) return '';
219
+ return col(STATUS_BAR_CHAR.repeat(w), phaseInfo(s.p).color);
220
+ }).join('');
221
+ }
222
+
223
+ // ---- public renderer -------------------------------------------------------
224
+
225
+ /**
226
+ * Render the swarm block. `snapshot`:
227
+ * { description, model, members: [{phase, ratio, latestText}], failed, aborted }
228
+ * Returns an array of already-coloured lines, ALREADY indented by `indent`.
229
+ */
230
+ export function renderSwarmProgress(snapshot, width, opts = {}) {
231
+ const snap = snapshot || {};
232
+ const desc = collapseWhitespace(snap.description || '');
233
+ const model = collapseWhitespace(snap.model || '');
234
+ const members = (Array.isArray(snap.members) ? snap.members : []).map(normalizeMember);
235
+ const indent = opts.indent == null ? ' ' : opts.indent;
236
+ const innerW = Math.max(10, width - visWidth(indent));
237
+ const lines = [];
238
+
239
+ // ---- header: `─ Agent Swarm ─ desc ─ model ─────` ----
240
+ const title = col('Agent Swarm', C.spring + C.bold);
241
+ const suffix = [];
242
+ if (desc) suffix.push(col(' \u2500 ', C.spring) + col(desc, C.white));
243
+ if (model) suffix.push(col(' \u2500 ', C.spring) + col(model, C.gray));
244
+ const used = 3 + visWidth(title) + visWidth(suffix.join(''));
245
+ const tail = Math.max(0, innerW - used);
246
+ const header = col(' \u2500 ', C.spring) + title + suffix.join('') + (tail > 1 ? col(' ' + '\u2500'.repeat(tail - 1), C.spring) : '');
247
+ lines.push(indent + truncateToWidth(header, innerW));
248
+
249
+ // ---- optional blank + grid, exactly like kimi's [ '', header, '', grid, '', status, '' ] ----
250
+ lines.push(indent + '');
251
+ const gridH = Number.isFinite(opts.availableGridHeight) ? opts.availableGridHeight : undefined;
252
+ const layout = computeGridLayout({ width: innerW, height: gridH, count: members.length });
253
+ if (!members.length) {
254
+ lines.push(indent + col('Orchestrating\u2026', C.cyan));
255
+ } else {
256
+ for (let r = 0; r < layout.rows; r++) {
257
+ const cells = [];
258
+ for (let c = 0; c < layout.columns; c++) {
259
+ const m = members[r * layout.columns + c];
260
+ if (!m) continue;
261
+ cells.push(renderCell(m, layout.cellWidth));
262
+ }
263
+ if (cells.length) lines.push(indent + cells.join(' '));
264
+ }
265
+ // A swarm taller than the budget reports the remainder instead of spilling.
266
+ if (layout.truncated) {
267
+ lines.push(indent + col(`\u2026 +${layout.count - layout.visibleCount} more`, C.gray));
268
+ }
269
+ }
270
+ lines.push(indent + '');
271
+
272
+ // ---- status: mark + label + pip bar ----
273
+ const status = totalStatus(members, { failed: snap.failed, aborted: snap.aborted });
274
+ const mark = status === 'completed' ? SUCCESS_MARK
275
+ : status === 'failed' ? FAILURE_MARK
276
+ : status === 'aborted' ? CANCELLED_MARK
277
+ : '';
278
+ const label = TOTAL_LABEL[status] || TOTAL_LABEL.working;
279
+ const lc = status === 'completed' ? C.green
280
+ : status === 'failed' ? C.red
281
+ : status === 'aborted' ? C.gray
282
+ : C.cyan;
283
+ const head = (mark ? col(mark + ' ', lc) : ' ') + col(label, lc);
284
+ const barW = Math.max(0, innerW - visWidth(head) - 2);
285
+ lines.push(indent + head + (barW > 0 ? ' ' + renderPipBar(members, barW) : ''));
286
+ lines.push(indent + '');
287
+ return lines;
288
+ }
289
+
290
+ // Derive a member list from the progress lines the AgentSwarm tool appends to a
291
+ // task's output (`[n/m] finished`). Used as the fallback when the caller has no
292
+ // richer per-subagent data — e.g. rebuilding the block after a session reload.
293
+ export function membersFromOutput(output, total) {
294
+ const text = String(output || '');
295
+ // Each line names its agent, and the agent it names is the one it is about:
296
+ // `[#i] started` this agent began
297
+ // `[#i] progress <pct>` this agent is pct% through its own work
298
+ // `[#i] finished [n/m]` this agent, with a running total for readers that only count
299
+ //
300
+ // Reading a bare `[n/m]` as "the first n are done" was the earlier mistake: workers pull
301
+ // from a shared queue, so agent 3 can finish before agent 1, and the count says nothing
302
+ // about which. A `[n/m]`-only line is still accepted, for output written before this
303
+ // named its agent, and is counted but not attributed.
304
+ const started = new Set();
305
+ const doneSet = new Set();
306
+ const ratioOf = new Map();
307
+ let bareDone = 0;
308
+ for (const line of text.split('\n').map((l) => l.replace(/\r$/, ''))) {
309
+ let m = /^\[#(\d+)\] started$/.exec(line);
310
+ if (m) { started.add(Number(m[1])); continue; }
311
+ m = /^\[#(\d+)\] progress (\d+)$/.exec(line);
312
+ if (m) { ratioOf.set(Number(m[1]), Math.max(0, Math.min(100, Number(m[2]))) / 100); continue; }
313
+ m = /^\[#(\d+)\] finished(?: \[\d+\/\d+\])?$/.exec(line);
314
+ if (m) { doneSet.add(Number(m[1])); continue; }
315
+ if (/^\[\d+\/\d+\] finished$/.test(line)) bareDone++;
316
+ }
317
+ // A named finish also counts as "an agent started", so a log that lost its `started` lines
318
+ // still attributes the finish instead of leaving the cell stuck on "working" forever.
319
+ for (const idx of doneSet) started.add(idx);
320
+ // Bounded. `highest` comes off the wire, and one malformed line (`[#999999] progress 1`)
321
+ // would otherwise allocate an array that size and hang the render on every frame.
322
+ const MAX_MEMBERS = 128;
323
+ const highest = Math.min(MAX_MEMBERS, Math.max(0, ...started, ...doneSet, ...ratioOf.keys()));
324
+ const n = Math.min(MAX_MEMBERS, Math.max(total || 0, bareDone, highest));
325
+ // The lines no name claims are the OLD format's only evidence of who finished, so they are
326
+ // attributed to the agents that have not spoken for themselves — counting from the front
327
+ // and skipping any agent that announced itself, rather than refusing to attribute them at
328
+ // all whenever a single named line is present.
329
+ let unattributed = Math.max(0, bareDone - doneSet.size);
330
+ const legacyDone = new Set();
331
+ for (let idx = 1; idx <= n && unattributed > 0; idx++) {
332
+ if (started.has(idx) || doneSet.has(idx) || ratioOf.has(idx)) continue;
333
+ legacyDone.add(idx);
334
+ unattributed--;
335
+ }
336
+ return Array.from({ length: n }, (_, i) => {
337
+ const idx = i + 1;
338
+ const completed = doneSet.has(idx) || legacyDone.has(idx);
339
+ const phase = completed ? 'completed'
340
+ : started.has(idx) ? 'working'
341
+ : 'queued';
342
+ const out = { phase, latestText: '' };
343
+ // `ratioOf` only ever holds a finite value, so the extra `isFinite` test was dead — and
344
+ // it turned a genuine 0 into `null`, which the renderer reads as "no figure at all".
345
+ if (ratioOf.has(idx) && !completed) out.ratio = ratioOf.get(idx);
346
+ return out;
347
+ });
348
+ }