@hncode/hncode 0.2.0 → 0.3.1
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 +674 -219
- package/README.md +1 -0
- package/README_CN.md +1 -0
- package/package.json +1 -1
- package/src/agent-task.js +178 -0
- package/src/agent.js +101 -8
- package/src/colors.js +14 -0
- package/src/config.js +191 -10
- package/src/index.js +31 -9
- package/src/llm.js +60 -11
- package/src/plugin.js +25 -2
- package/src/prompt-presets.js +429 -0
- package/src/session.js +257 -24
- package/src/subagent-types.js +147 -0
- package/src/swarm-progress.js +293 -0
- package/src/task-output-viewer.js +135 -0
- package/src/tasks-browser.js +279 -0
- package/src/tools/agent-swarm.js +208 -0
- package/src/tools/agent.js +342 -0
- package/src/tools/ask-user-question.js +206 -0
- package/src/tools/bash.js +53 -29
- package/src/tools/edit.js +122 -11
- package/src/tools/file-lines.js +41 -8
- package/src/tools/glob.js +17 -5
- package/src/tools/grep.js +16 -3
- package/src/tools/index.js +19 -2
- package/src/tools/matchers.js +75 -71
- package/src/tools/read-media-file.js +17 -3
- package/src/tools/read.js +2 -0
- package/src/tools/tasks.js +141 -126
- package/src/tools/todo.js +29 -2
- package/src/tools/utils.js +11 -1
- package/src/tui.js +2264 -337
- package/src/update.js +95 -0
- package/src/version.js +20 -0
|
@@ -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
|
+
}
|