@hncode/hncode 0.2.0 → 0.3.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.
@@ -0,0 +1,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
+ 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
+ }
@@ -0,0 +1,135 @@
1
+ // TaskOutputViewer - full-screen scrollable output for ONE background task,
2
+ // mirroring kimi-code's TaskOutputViewer.
3
+ //
4
+ // Keys: Up/Down or j/k line, PgUp/PgDn or Ctrl+U/D page, g/G top/bottom,
5
+ // Q/Esc close. The view follows the tail while the user is parked at the
6
+ // bottom; scrolling up keeps the position so history is readable.
7
+ import { C } from './colors.js';
8
+ import { visualWidth } from './term.js';
9
+ import { STATUS_LABEL } from './agent-task.js';
10
+ import { statusColorToken } from './tasks-browser.js';
11
+
12
+ const ELLIPSIS = '\u2026';
13
+ const ESC = '\x1b';
14
+ // visWidth() counts ANSI bytes, so strip escapes before measuring: every
15
+ // string here carries colour codes and would otherwise measure far too wide.
16
+ function visWidth(s) { return visualWidth(String(s).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '')); }
17
+
18
+ function truncateToWidth(s, width) {
19
+ const str = String(s);
20
+ let out = '';
21
+ let n = 0;
22
+ let i = 0;
23
+ while (i < str.length) {
24
+ if (str[i] === ESC) {
25
+ const m = /^\x1b\[[0-9;?]*[a-zA-Z]/.exec(str.slice(i));
26
+ if (m) { out += m[0]; i += m[0].length; continue; }
27
+ }
28
+ const cp = str.codePointAt(i);
29
+ const ch = String.fromCodePoint(cp);
30
+ const cw = visWidth(ch);
31
+ if (n + cw > width - 1) {
32
+ // Pad after the ellipsis so the row is EXACTLY `width` columns: a short
33
+ // row breaks the frame's right border alignment.
34
+ const used = n + 1;
35
+ return out + ELLIPSIS + ' '.repeat(Math.max(0, width - used));
36
+ }
37
+ n += cw;
38
+ out += ch;
39
+ i += cp > 0xffff ? 2 : 1;
40
+ }
41
+ return out;
42
+ }
43
+
44
+ function padToWidth(s, width) {
45
+ const w = visWidth(s);
46
+ if (w === width) return s;
47
+ if (w > width) return truncateToWidth(s, width);
48
+ return s + ' '.repeat(width - w);
49
+ }
50
+
51
+ function fitExactly(line, width) {
52
+ let s = String(line);
53
+ if (visWidth(s) > width) s = truncateToWidth(s, width);
54
+ return padToWidth(s, width) + C.reset;
55
+ }
56
+
57
+ export function makeViewerState(task) {
58
+ return { task, scrollTop: 0, follow: true };
59
+ }
60
+
61
+ function lines(state) {
62
+ const out = String((state.task && state.task.output) || '');
63
+ if (!out.length) return ['[no output captured]'];
64
+ return out.replace(/\r\n/g, '\n').split('\n');
65
+ }
66
+
67
+ function viewRows(rows) { return Math.max(1, rows - 4); }
68
+
69
+ function maxScroll(state, rows) {
70
+ return Math.max(0, lines(state).length - viewRows(rows));
71
+ }
72
+
73
+ export function handleViewerKey(state, t, rows) {
74
+ const ch = t.ch;
75
+ const max = maxScroll(state, rows);
76
+ const page = Math.max(1, viewRows(rows) - 1);
77
+ if (t.key === 'escape' || ch === 'q' || ch === 'Q') return 'close';
78
+ if (t.key === 'up' || ch === 'k') { state.scrollTop = Math.max(0, state.scrollTop - 1); state.follow = false; return 'scroll'; }
79
+ if (t.key === 'down' || ch === 'j') {
80
+ state.scrollTop = Math.min(max, state.scrollTop + 1);
81
+ state.follow = state.scrollTop >= max;
82
+ return 'scroll';
83
+ }
84
+ if (t.key === 'pageup' || ch === ' ') { state.scrollTop = Math.max(0, state.scrollTop - page); state.follow = false; return 'scroll'; }
85
+ if (t.key === 'pagedown') { state.scrollTop = Math.min(max, state.scrollTop + page); state.follow = state.scrollTop >= max; return 'scroll'; }
86
+ if (t.key === 'home' || ch === 'g') { state.scrollTop = 0; state.follow = false; return 'scroll'; }
87
+ if (t.key === 'end' || ch === 'G') { state.scrollTop = max; state.follow = true; return 'scroll'; }
88
+ return null;
89
+ }
90
+
91
+ export function renderTaskOutputViewer(state, cols, rows) {
92
+ const task = state.task;
93
+ // Follow the tail when parked at the bottom (like less +F).
94
+ const max = maxScroll(state, rows);
95
+ if (state.follow) state.scrollTop = max;
96
+ state.scrollTop = Math.max(0, Math.min(max, state.scrollTop));
97
+
98
+ // header
99
+ const segs = [C.cyan + C.bold + ' Task output ' + C.reset, C.white + C.bold + (task ? task.taskId : '(none)') + C.reset];
100
+ if (task) {
101
+ segs.push(statusColorToken(task.status) + (STATUS_LABEL[task.status] || task.status) + C.reset);
102
+ if (task.exitCode != null) segs.push(C.gray + 'exit ' + task.exitCode + C.reset);
103
+ if (task.description) segs.push(C.gray + task.description + C.reset);
104
+ }
105
+ const header = fitExactly(segs.join(' '), cols);
106
+
107
+ // body
108
+ const bodyH = rows - 2;
109
+ const all = lines(state);
110
+ const vr = viewRows(rows);
111
+ const innerW = Math.max(1, cols - 4);
112
+ const bodyLines = [C.border + '\u250c' + '\u2500'.repeat(Math.max(0, cols - 2)) + '\u2510' + C.reset];
113
+ for (let i = 0; i < vr; i++) {
114
+ const raw = all[state.scrollTop + i] ?? '';
115
+ bodyLines.push(C.border + '\u2502 ' + C.reset + fitExactly(C.white + raw + C.reset, innerW) + C.border + ' \u2502' + C.reset);
116
+ }
117
+ bodyLines.push(C.border + '\u2514' + '\u2500'.repeat(Math.max(0, cols - 2)) + '\u2518' + C.reset);
118
+
119
+ // footer with position + keys
120
+ const key = (s) => C.cyan + C.bold + s + C.reset;
121
+ const dim = (s) => C.gray + s + C.reset;
122
+ const total = all.length;
123
+ const maxs = Math.max(0, total - vr);
124
+ const percent = maxs === 0 ? 100 : Math.round((state.scrollTop / maxs) * 100);
125
+ const from = state.scrollTop + 1;
126
+ const to = Math.min(total, state.scrollTop + vr);
127
+ const pos = C.gray + ' ' + from + '-' + to + ' / ' + total + ' (' + percent + '%) ' + C.reset;
128
+ const keys = ' ' + key('\u2191\u2193') + ' ' + dim('line') + ' ' + key('PgUp/PgDn') + ' ' + dim('page') + ' '
129
+ + key('g/G') + ' ' + dim('top/bot') + ' ' + key('Q/Esc') + ' ' + dim('close');
130
+ const lw = visWidth(keys);
131
+ const rw = visWidth(pos);
132
+ const footer = lw + 2 + rw <= cols ? keys + ' '.repeat(cols - lw - rw) + pos : fitExactly(keys, cols);
133
+
134
+ return [header, ...bodyLines, footer];
135
+ }