@higherdev/cli 0.15.4 → 0.17.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/README.md +3 -2
- package/dist/agent-commands.js +80 -0
- package/dist/api.js +14 -3
- package/dist/index.js +90 -28
- package/dist/out.js +2 -1
- package/dist/tui/App.js +213 -56
- package/dist/tui/Dashboard.js +25 -8
- package/dist/tui/Decision.js +18 -31
- package/dist/tui/Help.js +5 -5
- package/dist/tui/Panels.js +80 -42
- package/dist/tui/TextInput.js +5 -1
- package/dist/tui/agent-rows.js +1 -1
- package/dist/tui/data.js +42 -16
- package/dist/tui/decide-nav.js +62 -0
- package/dist/tui/inbox.js +29 -0
- package/dist/tui/narrate.js +97 -0
- package/dist/tui/parse.js +13 -10
- package/dist/tui/stream.js +51 -34
- package/dist/tui/ticket-view.js +226 -0
- package/package.json +1 -1
package/dist/tui/stream.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { narrateEvents } from "./narrate.js";
|
|
1
2
|
/** How much of the stream is kept. Older lines have already scrolled past. */
|
|
2
3
|
export const STREAM_LIMIT = 200;
|
|
3
4
|
/**
|
|
@@ -5,31 +6,43 @@ export const STREAM_LIMIT = 200;
|
|
|
5
6
|
* caller did not name are dropped, which is how another workspace's traffic
|
|
6
7
|
* stays out of this one's stream.
|
|
7
8
|
*/
|
|
8
|
-
export function toStreamLines(events, runs) {
|
|
9
|
+
export function toStreamLines(events, runs, raw = false) {
|
|
9
10
|
const lines = [];
|
|
10
|
-
for (const
|
|
11
|
-
const
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
for (const run of runs.values()) {
|
|
12
|
+
const target = run.ticket ? ` on ${run.ticket}` : "";
|
|
13
|
+
lines.push({ id: `${run.runId}:start`, sourceIds: [`${run.runId}:start`], runId: run.runId,
|
|
14
|
+
agent: run.agent, at: run.startedAt ?? "", seq: -1, kind: "status",
|
|
15
|
+
title: `${run.agent} started ${run.kind}${target} (attempt ${run.attempt})` });
|
|
16
|
+
}
|
|
17
|
+
for (const run of runs.values()) {
|
|
14
18
|
// The id has to be a property of the event, not of the batch it arrived in.
|
|
15
19
|
// Live delivery converts one row at a time and a backfill converts eighty,
|
|
16
20
|
// so an id carrying a batch position would give the same event two ids and
|
|
17
21
|
// defeat the dedupe below. One event can still produce several lines, which
|
|
18
22
|
// is what the second half counts.
|
|
19
|
-
|
|
20
|
-
produced.forEach((line, index) => {
|
|
23
|
+
narrateEvents(events.filter((event) => event.run_id === run.runId), raw).forEach((line) => {
|
|
21
24
|
lines.push({
|
|
22
|
-
id:
|
|
23
|
-
|
|
25
|
+
id: line.id,
|
|
26
|
+
sourceIds: [line.sourceId],
|
|
27
|
+
runId: run.runId,
|
|
24
28
|
agent: run.agent,
|
|
25
|
-
at:
|
|
26
|
-
seq:
|
|
29
|
+
at: line.at,
|
|
30
|
+
seq: line.seq,
|
|
27
31
|
kind: line.kind,
|
|
28
32
|
title: line.title,
|
|
29
33
|
});
|
|
30
34
|
});
|
|
31
35
|
}
|
|
32
|
-
|
|
36
|
+
for (const run of runs.values()) {
|
|
37
|
+
if (["queued", "running"].includes(run.status) || !run.summary)
|
|
38
|
+
continue;
|
|
39
|
+
const target = run.ticket ? ` on ${run.ticket}` : "";
|
|
40
|
+
lines.push({ id: `${run.runId}:end`, sourceIds: [`${run.runId}:end`], runId: run.runId,
|
|
41
|
+
agent: run.agent, at: run.endedAt ?? "", seq: Number.MAX_SAFE_INTEGER,
|
|
42
|
+
kind: run.status === "failed" || run.status === "killed" ? "error" : "status",
|
|
43
|
+
title: `${run.agent} finished ${run.kind}${target}: ${run.summary}` });
|
|
44
|
+
}
|
|
45
|
+
return collapse(lines.sort((a, b) => a.at.localeCompare(b.at) || a.seq - b.seq), raw);
|
|
33
46
|
}
|
|
34
47
|
/**
|
|
35
48
|
* Adds lines to the stream without letting it grow forever and without showing
|
|
@@ -40,11 +53,14 @@ export function toStreamLines(events, runs) {
|
|
|
40
53
|
export function appendLines(prior, incoming, limit = STREAM_LIMIT) {
|
|
41
54
|
if (incoming.length === 0)
|
|
42
55
|
return prior;
|
|
43
|
-
const seen = new Set(prior.
|
|
44
|
-
const fresh = incoming.
|
|
56
|
+
const seen = new Set(prior.flatMap((line) => line.sourceIds ?? [line.id]));
|
|
57
|
+
const fresh = incoming.flatMap((line) => {
|
|
58
|
+
const ids = (line.sourceIds ?? [line.id]).filter((id) => !seen.has(id));
|
|
59
|
+
return ids.length ? [{ ...line, sourceIds: ids, count: line.count ? ids.length : undefined }] : [];
|
|
60
|
+
});
|
|
45
61
|
if (fresh.length === 0)
|
|
46
62
|
return prior;
|
|
47
|
-
const next = [...prior, ...fresh].sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
|
|
63
|
+
const next = collapse([...prior, ...fresh].sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq));
|
|
48
64
|
return next.length > limit ? next.slice(next.length - limit) : next;
|
|
49
65
|
}
|
|
50
66
|
/**
|
|
@@ -82,33 +98,32 @@ export async function backfill(opts) {
|
|
|
82
98
|
return;
|
|
83
99
|
opts.apply((prior) => appendLines(prior, lines));
|
|
84
100
|
}
|
|
85
|
-
function
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
: "text";
|
|
101
|
-
return [{ kind, title }];
|
|
101
|
+
function collapse(lines, raw = false) {
|
|
102
|
+
if (raw)
|
|
103
|
+
return lines;
|
|
104
|
+
const out = [];
|
|
105
|
+
for (const line of lines) {
|
|
106
|
+
const previous = out.at(-1);
|
|
107
|
+
const gap = previous ? Date.parse(line.at) - Date.parse(previous.at) : Infinity;
|
|
108
|
+
if (previous && previous.runId === line.runId && previous.kind === line.kind && previous.title === line.title && gap <= 2_000) {
|
|
109
|
+
previous.count = (previous.count ?? 1) + (line.count ?? 1);
|
|
110
|
+
previous.sourceIds = [...(previous.sourceIds ?? [previous.id]), ...(line.sourceIds ?? [line.id])];
|
|
111
|
+
}
|
|
112
|
+
else
|
|
113
|
+
out.push({ ...line });
|
|
114
|
+
}
|
|
115
|
+
return out;
|
|
102
116
|
}
|
|
103
117
|
/**
|
|
104
118
|
* The live runs, keyed by id. Realtime hands over a run event before the board
|
|
105
119
|
* has caught up with the run that produced it, so anything this does not know
|
|
106
120
|
* about is dropped and recovered by the backfill when the board does catch up.
|
|
107
121
|
*/
|
|
108
|
-
export function runLabels(board) {
|
|
122
|
+
export function runLabels(board, now = Date.now()) {
|
|
109
123
|
const map = new Map();
|
|
110
124
|
for (const run of board.runs) {
|
|
111
|
-
|
|
125
|
+
const ended = run.ended_at ? Date.parse(run.ended_at) : NaN;
|
|
126
|
+
if (run.status !== "running" && (!Number.isFinite(ended) || now - ended > 15 * 60_000))
|
|
112
127
|
continue;
|
|
113
128
|
const agent = board.agents.find((row) => row.id === run.agent_id);
|
|
114
129
|
const ticket = board.tickets.find((row) => row.id === run.ticket_id);
|
|
@@ -116,6 +131,8 @@ export function runLabels(board) {
|
|
|
116
131
|
runId: run.id,
|
|
117
132
|
agent: agent?.display_name ?? run.kind,
|
|
118
133
|
ticket: ticket?.key ?? null,
|
|
134
|
+
kind: run.kind, status: run.status, attempt: ticket?.attempts ?? 1,
|
|
135
|
+
startedAt: run.started_at, endedAt: run.ended_at ?? null, summary: run.summary ?? null,
|
|
119
136
|
});
|
|
120
137
|
}
|
|
121
138
|
return map;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
export const TICKET_SECTIONS = ["body", "acceptance", "timeline", "messages", "pr", "runs", "decisions"];
|
|
2
|
+
export function ticketPr(ticket) {
|
|
3
|
+
if (!ticket.pr_url && ticket.pr_number == null)
|
|
4
|
+
return null;
|
|
5
|
+
const reason = ticket.stuck_reason ?? "";
|
|
6
|
+
let checks = "unknown";
|
|
7
|
+
let mergeability = "open";
|
|
8
|
+
if (/^Checks failing/i.test(reason)) {
|
|
9
|
+
checks = reason.replace(/^Checks failing(?: \(details unavailable:[^)]+\))?:?\s*/i, "") || reason;
|
|
10
|
+
}
|
|
11
|
+
else if (ticket.status === "merged" || ticket.status === "approved") {
|
|
12
|
+
checks = "passing";
|
|
13
|
+
}
|
|
14
|
+
if (/conflicts with main/i.test(reason))
|
|
15
|
+
mergeability = "conflicting";
|
|
16
|
+
else if (ticket.status === "merged")
|
|
17
|
+
mergeability = "merged";
|
|
18
|
+
else if (ticket.status === "approved" && !/^Checks failing/i.test(reason))
|
|
19
|
+
mergeability = "ready";
|
|
20
|
+
return { url: ticket.pr_url, number: ticket.pr_number ?? null, checks, mergeability };
|
|
21
|
+
}
|
|
22
|
+
export function wrapLines(text, width) {
|
|
23
|
+
if (!text)
|
|
24
|
+
return [""];
|
|
25
|
+
const lines = [];
|
|
26
|
+
const max = Math.max(8, width);
|
|
27
|
+
for (const paragraph of text.split("\n")) {
|
|
28
|
+
if (!paragraph) {
|
|
29
|
+
lines.push("");
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
let line = "";
|
|
33
|
+
for (const word of paragraph.split(/\s+/).filter(Boolean)) {
|
|
34
|
+
const candidate = line ? `${line} ${word}` : word;
|
|
35
|
+
if (candidate.length <= max) {
|
|
36
|
+
line = candidate;
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (line)
|
|
40
|
+
lines.push(line);
|
|
41
|
+
let rest = word;
|
|
42
|
+
while (rest.length > max) {
|
|
43
|
+
lines.push(rest.slice(0, max));
|
|
44
|
+
rest = rest.slice(max);
|
|
45
|
+
}
|
|
46
|
+
line = rest;
|
|
47
|
+
}
|
|
48
|
+
if (line)
|
|
49
|
+
lines.push(line);
|
|
50
|
+
}
|
|
51
|
+
return lines;
|
|
52
|
+
}
|
|
53
|
+
function elapsedLabel(ms) {
|
|
54
|
+
if (ms == null)
|
|
55
|
+
return "elapsed unknown";
|
|
56
|
+
const seconds = Math.round(ms / 1000);
|
|
57
|
+
return seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m ${seconds % 60}s`;
|
|
58
|
+
}
|
|
59
|
+
function pushWrapped(lines, section, prefix, text, width) {
|
|
60
|
+
wrapLines(text, Math.max(8, width - prefix.length)).forEach((row, index) => {
|
|
61
|
+
lines.push({
|
|
62
|
+
key: `${section}:${prefix}:${index}:${row.slice(0, 12)}`,
|
|
63
|
+
kind: "line",
|
|
64
|
+
section,
|
|
65
|
+
text: `${prefix}${row}`,
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
export function ticketViewLines(ticket, width, collapsed = []) {
|
|
70
|
+
const lines = [];
|
|
71
|
+
const hidden = new Set(collapsed);
|
|
72
|
+
const pr = ticket.pr ?? ticketPr(ticket);
|
|
73
|
+
lines.push({
|
|
74
|
+
key: "header:title", kind: "line", section: "header",
|
|
75
|
+
text: `${ticket.key} ${ticket.title}`.trim(),
|
|
76
|
+
});
|
|
77
|
+
lines.push({
|
|
78
|
+
key: "header:status", kind: "line", section: "header",
|
|
79
|
+
text: [
|
|
80
|
+
ticket.status.replaceAll("_", " "),
|
|
81
|
+
ticket.area,
|
|
82
|
+
ticket.agent_name ?? (ticket.provider ?? "unassigned"),
|
|
83
|
+
ticket.attempts ? `attempt ${ticket.attempts}` : "",
|
|
84
|
+
].filter(Boolean).join(" "),
|
|
85
|
+
});
|
|
86
|
+
const why = ticket.stuck ?? ticket.stuck_reason;
|
|
87
|
+
if (why) {
|
|
88
|
+
pushWrapped(lines, "header", "why: ", why, width);
|
|
89
|
+
}
|
|
90
|
+
if (ticket.blocker_keys?.length) {
|
|
91
|
+
lines.push({
|
|
92
|
+
key: "header:blocked", kind: "line", section: "header",
|
|
93
|
+
text: `blocked by ${ticket.blocker_keys.join(", ")}`,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
if (ticket.branch) {
|
|
97
|
+
lines.push({ key: "header:branch", kind: "line", section: "header", text: ticket.branch });
|
|
98
|
+
}
|
|
99
|
+
const addSection = (id, title, fill, count) => {
|
|
100
|
+
const folded = hidden.has(id);
|
|
101
|
+
const note = folded ? " (folded)" : count != null ? ` (${count})` : "";
|
|
102
|
+
lines.push({
|
|
103
|
+
key: `section:${id}`, kind: "section", section: id,
|
|
104
|
+
text: `${folded ? "▸" : "▾"} ${title}${note}`,
|
|
105
|
+
});
|
|
106
|
+
if (folded)
|
|
107
|
+
return;
|
|
108
|
+
fill();
|
|
109
|
+
};
|
|
110
|
+
addSection("body", "Body", () => {
|
|
111
|
+
if (!ticket.body_md.trim()) {
|
|
112
|
+
lines.push({ key: "body:empty", kind: "line", section: "body", text: " No body." });
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
pushWrapped(lines, "body", " ", ticket.body_md.trim(), width);
|
|
116
|
+
});
|
|
117
|
+
addSection("acceptance", "Acceptance", () => {
|
|
118
|
+
if (!ticket.acceptance_md.trim()) {
|
|
119
|
+
lines.push({ key: "acceptance:empty", kind: "line", section: "acceptance", text: " No acceptance." });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
pushWrapped(lines, "acceptance", " ", ticket.acceptance_md.trim(), width);
|
|
123
|
+
});
|
|
124
|
+
addSection("timeline", "Timeline", () => {
|
|
125
|
+
if (!ticket.timeline?.length) {
|
|
126
|
+
lines.push({ key: "timeline:empty", kind: "line", section: "timeline", text: " No completed runs yet." });
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
for (const event of ticket.timeline) {
|
|
130
|
+
pushWrapped(lines, "timeline", " ", event.headline, width);
|
|
131
|
+
lines.push({
|
|
132
|
+
key: `${event.id}:meta`, kind: "line", section: "timeline",
|
|
133
|
+
text: ` ${event.outcome} · ${event.kind}/${event.provider} · ${elapsedLabel(event.elapsed_ms)}`,
|
|
134
|
+
});
|
|
135
|
+
if (event.send_back_reason) {
|
|
136
|
+
pushWrapped(lines, "timeline", " sent back because: ", event.send_back_reason, width);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}, ticket.timeline?.length);
|
|
140
|
+
addSection("messages", "Messages", () => {
|
|
141
|
+
if (!ticket.messages?.length) {
|
|
142
|
+
lines.push({ key: "messages:empty", kind: "line", section: "messages", text: " No messages yet." });
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
for (const message of ticket.messages) {
|
|
146
|
+
const sender = message.from_name?.trim() || message.from_role;
|
|
147
|
+
lines.push({
|
|
148
|
+
key: `${message.id}:meta`, kind: "line", section: "messages",
|
|
149
|
+
text: ` ${message.created_at.slice(0, 16).replace("T", " ")} ${sender}`,
|
|
150
|
+
});
|
|
151
|
+
pushWrapped(lines, "messages", " ", message.body_md.trim() || "(empty)", width);
|
|
152
|
+
}
|
|
153
|
+
}, ticket.messages?.length);
|
|
154
|
+
addSection("pr", "PR", () => {
|
|
155
|
+
if (!pr) {
|
|
156
|
+
lines.push({ key: "pr:empty", kind: "line", section: "pr", text: " No pull request yet." });
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
lines.push({
|
|
160
|
+
key: "pr:id", kind: "line", section: "pr",
|
|
161
|
+
text: ` ${pr.number != null ? `#${pr.number} ` : ""}${pr.url ?? ""}`.trimEnd(),
|
|
162
|
+
});
|
|
163
|
+
lines.push({ key: "pr:checks", kind: "line", section: "pr", text: ` checks: ${pr.checks}` });
|
|
164
|
+
lines.push({
|
|
165
|
+
key: "pr:merge", kind: "line", section: "pr",
|
|
166
|
+
text: ` mergeability: ${pr.mergeability}`,
|
|
167
|
+
});
|
|
168
|
+
});
|
|
169
|
+
addSection("runs", "Runs", () => {
|
|
170
|
+
if (!ticket.runs?.length) {
|
|
171
|
+
lines.push({ key: "runs:empty", kind: "line", section: "runs", text: " No runs yet." });
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
for (const run of ticket.runs) {
|
|
175
|
+
lines.push({
|
|
176
|
+
key: `${run.id}:meta`, kind: "line", section: "runs",
|
|
177
|
+
text: ` ${run.kind} ${run.provider} ${run.status} ${elapsedLabel(run.elapsed_ms)}`,
|
|
178
|
+
});
|
|
179
|
+
if (run.summary?.trim())
|
|
180
|
+
pushWrapped(lines, "runs", " ", run.summary.trim(), width);
|
|
181
|
+
}
|
|
182
|
+
}, ticket.runs?.length);
|
|
183
|
+
addSection("decisions", "Decisions", () => {
|
|
184
|
+
if (!ticket.decisions?.length) {
|
|
185
|
+
lines.push({ key: "decisions:empty", kind: "line", section: "decisions", text: " No decisions yet." });
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
for (const decision of ticket.decisions) {
|
|
189
|
+
const state = decision.answered_at ? "answered" : "asked";
|
|
190
|
+
pushWrapped(lines, "decisions", ` ${state} (${decision.asked_by_role}): `, decision.question_md.trim(), width);
|
|
191
|
+
if (decision.answer_md?.trim()) {
|
|
192
|
+
pushWrapped(lines, "decisions", " answer: ", decision.answer_md.trim(), width);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}, ticket.decisions?.length);
|
|
196
|
+
return lines;
|
|
197
|
+
}
|
|
198
|
+
export function ticketScrollOffset(count, rows, offset) {
|
|
199
|
+
if (count <= rows)
|
|
200
|
+
return 0;
|
|
201
|
+
return Math.max(0, Math.min(offset, count - rows));
|
|
202
|
+
}
|
|
203
|
+
export function sectionAt(lines, index) {
|
|
204
|
+
if (!lines.length)
|
|
205
|
+
return null;
|
|
206
|
+
const start = Math.max(0, Math.min(index, lines.length - 1));
|
|
207
|
+
for (let i = start; i >= 0; i -= 1) {
|
|
208
|
+
if (lines[i].kind === "section")
|
|
209
|
+
return lines[i].section;
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
export function toggleSection(collapsed, section) {
|
|
214
|
+
if (!section || section === "header")
|
|
215
|
+
return collapsed;
|
|
216
|
+
return collapsed.includes(section)
|
|
217
|
+
? collapsed.filter((id) => id !== section)
|
|
218
|
+
: [...collapsed, section];
|
|
219
|
+
}
|
|
220
|
+
export function formatElapsedMs(ms, started, ended) {
|
|
221
|
+
if (typeof ms === "number")
|
|
222
|
+
return ms;
|
|
223
|
+
const start = started ? Date.parse(started) : Number.NaN;
|
|
224
|
+
const end = ended ? Date.parse(ended) : Number.NaN;
|
|
225
|
+
return Number.isFinite(start) && Number.isFinite(end) ? Math.max(0, end - start) : null;
|
|
226
|
+
}
|