@higherdev/cli 0.16.0 → 0.18.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 +9 -2
- package/dist/api.js +8 -3
- package/dist/host.js +119 -0
- package/dist/index.js +57 -29
- package/dist/out.js +3 -3
- package/dist/tui/App.js +186 -52
- package/dist/tui/Dashboard.js +29 -11
- package/dist/tui/Decision.js +18 -31
- package/dist/tui/Help.js +4 -4
- package/dist/tui/Panels.js +48 -30
- package/dist/tui/TextInput.js +5 -1
- package/dist/tui/agent-rows.js +14 -2
- package/dist/tui/data.js +20 -6
- package/dist/tui/decide-nav.js +62 -0
- package/dist/tui/narrate.js +97 -0
- package/dist/tui/parse.js +5 -2
- package/dist/tui/stream.js +51 -34
- package/dist/tui/ticket-view.js +226 -0
- package/package.json +1 -1
|
@@ -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
|
+
}
|