@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
package/dist/tui/Panels.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
-
import {
|
|
3
|
-
import { BOARD_COLUMNS, statusLabel, statusTone, } from "./data.js";
|
|
2
|
+
import { Text } from "ink";
|
|
3
|
+
import { BOARD_COLUMNS, decisionOptions, statusLabel, statusTone, } from "./data.js";
|
|
4
4
|
import { elapsed, pad, relativeTime, truncate } from "../out/format.js";
|
|
5
5
|
import { inkColor } from "../out/theme.js";
|
|
6
6
|
import { UI } from "./theme.js";
|
|
@@ -8,6 +8,8 @@ import { BoundedPanel as Panel, Heading, More, contentRows } from "./bounded.js"
|
|
|
8
8
|
import { scrollWindow } from "./Dashboard.js";
|
|
9
9
|
import { agentDisplayRows } from "./agent-rows.js";
|
|
10
10
|
import { inboxHeader } from "./inbox.js";
|
|
11
|
+
import { DECISION_CURSOR, decisionIdAt } from "./decide-nav.js";
|
|
12
|
+
import { ticketScrollOffset, ticketViewLines } from "./ticket-view.js";
|
|
11
13
|
const DOT = "●";
|
|
12
14
|
/**
|
|
13
15
|
* The single-purpose views behind /board, /agents, /feed and /inbox. Each is
|
|
@@ -26,8 +28,10 @@ export function AgentsPanel({ board, width = 80, rows = 12, }) {
|
|
|
26
28
|
]
|
|
27
29
|
: []),
|
|
28
30
|
...shown.map((row) => {
|
|
29
|
-
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited"
|
|
30
|
-
|
|
31
|
+
const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
|
|
32
|
+
? "warning" : "muted";
|
|
33
|
+
const state = row.state === "draining" ? ""
|
|
34
|
+
: row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state;
|
|
31
35
|
return (_jsxs(Text, { wrap: "truncate", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsx(Text, { color: UI.text, children: pad(truncate(row.name, 13), 14) }), _jsx(Text, { color: UI.dim, children: pad(row.agent?.role ?? row.run?.kind ?? "", 13) }), _jsx(Text, { color: UI.dim, children: pad(truncate(row.agent?.model ?? "", 23), 24) }), _jsx(Text, { color: UI.text, children: pad(state, row.limitedUntil ? 22 : 9) }), _jsxs(Text, { color: UI.dim, children: [row.ticket ? `${row.ticket.key} ` : "", row.run ? elapsed(row.run.started_at ?? row.run.created_at) : ""] })] }, row.key));
|
|
32
36
|
}),
|
|
33
37
|
_jsx(More, { count: displayRows.length - shown.length }, "more"),
|
|
@@ -85,10 +89,11 @@ export function FeedPanel({ entries, width = 80, rows = 12, }) {
|
|
|
85
89
|
* The whole queue, one row each, numbered the way `/decide` reaches them: the
|
|
86
90
|
* one on screen is 1.
|
|
87
91
|
*/
|
|
88
|
-
export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
|
|
89
|
-
const entries = inboxEntries(board, width);
|
|
92
|
+
export function InboxPanel({ board, width = 80, rows = 12, focus = 0, selectedId = null, answeringId = null, }) {
|
|
93
|
+
const entries = inboxEntries(board, width, answeringId);
|
|
90
94
|
const unread = board.messages ?? [];
|
|
91
95
|
const earlier = board.earlier ?? [];
|
|
96
|
+
const selected = selectedId ?? decisionIdAt(entries, focus);
|
|
92
97
|
const inner = Math.max(0, rows - 1);
|
|
93
98
|
const window = scrollWindow(entries.length, inner, focus);
|
|
94
99
|
const hiddenAbove = window.start;
|
|
@@ -103,11 +108,16 @@ export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
|
|
|
103
108
|
_jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
|
|
104
109
|
]
|
|
105
110
|
: []),
|
|
106
|
-
...entries.slice(window.start, window.end).map((entry, index) =>
|
|
111
|
+
...entries.slice(window.start, window.end).map((entry, index) => {
|
|
112
|
+
const cursor = Boolean(entry.kind === "header" && entry.decisionId && entry.decisionId === selected);
|
|
113
|
+
const color = entry.kind === "option" ? UI.accent : entry.kind === "body" ? UI.text : UI.warn;
|
|
114
|
+
const prefix = entry.kind === "body" || entry.kind === "option" ? " " : cursor ? `${DECISION_CURSOR} ` : entry.decisionId ? " " : "";
|
|
115
|
+
return (_jsxs(Text, { color: color, wrap: "truncate", inverse: cursor, children: [prefix, entry.text] }, entry.key));
|
|
116
|
+
}),
|
|
107
117
|
] }));
|
|
108
118
|
}
|
|
109
119
|
/** Every physical line in /inbox, with decision and message bodies wrapped but never clipped. */
|
|
110
|
-
export function inboxEntries(board, width) {
|
|
120
|
+
export function inboxEntries(board, width, answeringId) {
|
|
111
121
|
const entries = [];
|
|
112
122
|
const bodyWidth = Math.max(12, width - 2);
|
|
113
123
|
const unread = board.messages ?? [];
|
|
@@ -117,11 +127,28 @@ export function inboxEntries(board, width) {
|
|
|
117
127
|
entries.push({
|
|
118
128
|
key: `${decision.id}:header`,
|
|
119
129
|
kind: "header",
|
|
130
|
+
decisionId: decision.id,
|
|
120
131
|
text: `${index + 1}) ${ticket?.key ?? decision.id.slice(0, 8)}`,
|
|
121
132
|
});
|
|
122
133
|
wrapLines(decision.question_md.trim(), bodyWidth).forEach((text, line) => {
|
|
123
|
-
entries.push({ key: `${decision.id}:body:${line}`, kind: "body", text });
|
|
134
|
+
entries.push({ key: `${decision.id}:body:${line}`, kind: "body", decisionId: decision.id, text });
|
|
124
135
|
});
|
|
136
|
+
if (answeringId === decision.id) {
|
|
137
|
+
decisionOptions(decision).forEach((option, optionIndex) => {
|
|
138
|
+
entries.push({
|
|
139
|
+
key: `${decision.id}:option:${optionIndex}`,
|
|
140
|
+
kind: "option",
|
|
141
|
+
decisionId: decision.id,
|
|
142
|
+
text: `${optionIndex + 1}) ${option}`,
|
|
143
|
+
});
|
|
144
|
+
});
|
|
145
|
+
entries.push({
|
|
146
|
+
key: `${decision.id}:prompt`,
|
|
147
|
+
kind: "body",
|
|
148
|
+
decisionId: decision.id,
|
|
149
|
+
text: "Type a number or your answer. Esc cancels.",
|
|
150
|
+
});
|
|
151
|
+
}
|
|
125
152
|
});
|
|
126
153
|
entries.push({ key: "unread:section", kind: "section", text: "Unread" });
|
|
127
154
|
if (unread.length === 0) {
|
|
@@ -191,28 +218,19 @@ function wrapLines(text, width) {
|
|
|
191
218
|
* rows nothing had budgeted, and a frame past the window is one Ink stops
|
|
192
219
|
* repainting in place.
|
|
193
220
|
*/
|
|
194
|
-
export function TicketPanel({ ticket, width = 80, rows = 24, }) {
|
|
195
|
-
const
|
|
196
|
-
_jsxs(Text, { color: UI.text, bold: true, wrap: "truncate", children: [ticket.key, " ", ticket.title] }, "title"),
|
|
197
|
-
_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Text, { color: inkColor(statusTone(ticket.status)), wrap: "truncate", children: statusLabel(ticket.status) }), _jsxs(Text, { color: UI.dim, wrap: "truncate", children: [ticket.area ? ` ${ticket.area}` : "", ticket.agent_name ? ` ${ticket.agent_name}` : " unassigned", ticket.attempts ? ` attempt ${ticket.attempts}` : ""] })] }, "status"),
|
|
198
|
-
];
|
|
199
|
-
if (ticket.stuck) {
|
|
200
|
-
lines.push(_jsxs(Text, { color: UI.warn, wrap: "truncate", children: ["why: ", ticket.stuck] }, "stuck"));
|
|
201
|
-
}
|
|
202
|
-
if (ticket.blocker_keys.length) {
|
|
203
|
-
lines.push(_jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["blocked by ", ticket.blocker_keys.join(", ")] }, "blocked"));
|
|
204
|
-
}
|
|
205
|
-
if (ticket.pr_url) {
|
|
206
|
-
lines.push(_jsx(Text, { color: UI.accent, wrap: "truncate", children: ticket.pr_url }, "pr"));
|
|
207
|
-
}
|
|
208
|
-
// The blank row before the body comes out of the body's own allowance.
|
|
221
|
+
export function TicketPanel({ ticket, width = 80, rows = 24, offset = 0, collapsed = [], }) {
|
|
222
|
+
const entries = ticketViewLines(ticket, Math.max(20, width), collapsed);
|
|
209
223
|
const drawable = Math.max(0, rows);
|
|
210
|
-
const
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
224
|
+
const overflow = entries.length > drawable;
|
|
225
|
+
const inner = overflow ? Math.max(0, drawable - 1) : drawable;
|
|
226
|
+
const start = ticketScrollOffset(entries.length, inner, offset);
|
|
227
|
+
const shown = entries.slice(start, start + inner);
|
|
228
|
+
const hiddenAbove = start;
|
|
229
|
+
const hiddenBelow = Math.max(0, entries.length - start - shown.length);
|
|
230
|
+
return (_jsx(Panel, { width: width, rows: drawable, children: [
|
|
231
|
+
...shown.map((entry) => (_jsx(Text, { color: entry.kind === "section" ? UI.warn : UI.text, wrap: "truncate", children: entry.text }, entry.key))),
|
|
232
|
+
overflow ? (_jsxs(Text, { color: UI.dim, wrap: "truncate", children: [hiddenAbove ? `${hiddenAbove}↑ ` : "", hiddenBelow ? `${hiddenBelow}↓` : ""] }, "more")) : null,
|
|
233
|
+
] }));
|
|
216
234
|
}
|
|
217
235
|
/**
|
|
218
236
|
* Text cut to the rows it may occupy once wrapped.
|
package/dist/tui/TextInput.js
CHANGED
|
@@ -19,7 +19,7 @@ function printableOf(text) {
|
|
|
19
19
|
// eslint-disable-next-line no-control-regex
|
|
20
20
|
return text.replace(/[\x00-\x1f\x7f]/g, "");
|
|
21
21
|
}
|
|
22
|
-
export default function TextInput({ value, onChange, onSubmit, onCancel, onUp, onDown, isActive = true, placeholder = "", prompt, color, }) {
|
|
22
|
+
export default function TextInput({ value, onChange, onSubmit, onCancel, onUp, onDown, onPageUp, onPageDown, isActive = true, placeholder = "", prompt, color, }) {
|
|
23
23
|
const [cursor, setCursor] = useState(value.length);
|
|
24
24
|
// The last value this component produced. Anything else arriving in `value`
|
|
25
25
|
// was swapped in by the caller.
|
|
@@ -62,6 +62,10 @@ export default function TextInput({ value, onChange, onSubmit, onCancel, onUp, o
|
|
|
62
62
|
return onUp?.();
|
|
63
63
|
if (key.downArrow)
|
|
64
64
|
return onDown?.();
|
|
65
|
+
if (key.pageUp)
|
|
66
|
+
return onPageUp?.();
|
|
67
|
+
if (key.pageDown)
|
|
68
|
+
return onPageDown?.();
|
|
65
69
|
// A whole line and its Enter can arrive as a single chunk. Unbracketed
|
|
66
70
|
// paste does it, and so does any link that buffers, which over ssh is
|
|
67
71
|
// most of them. Ink reports that as ordinary input with key.return
|
package/dist/tui/agent-rows.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { formatDrainStatus } from "../host.js";
|
|
1
2
|
const LIMITED_UNTIL = /^(?:Waiting on )?(\w+) (?:limited )?until (\d{1,2}:\d{2})\.?$/;
|
|
2
3
|
export function limitedUntilByProvider(tickets) {
|
|
3
4
|
const limited = new Map();
|
|
@@ -22,7 +23,18 @@ function agentForRun(board, run) {
|
|
|
22
23
|
return board.agents.find((agent) => agent.enabled && agent.role === role && agent.provider === run.provider) ?? null;
|
|
23
24
|
}
|
|
24
25
|
/** Expand live invocations into rows, then append enabled agents with no live run. */
|
|
25
|
-
export function agentDisplayRows(board) {
|
|
26
|
+
export function agentDisplayRows(board, now = Date.now()) {
|
|
27
|
+
const drain = board.hostDrain
|
|
28
|
+
? [{
|
|
29
|
+
key: "drain",
|
|
30
|
+
name: formatDrainStatus(board.hostDrain.live, board.hostDrain.until, now),
|
|
31
|
+
agent: null,
|
|
32
|
+
run: null,
|
|
33
|
+
ticket: null,
|
|
34
|
+
state: "draining",
|
|
35
|
+
limitedUntil: null,
|
|
36
|
+
}]
|
|
37
|
+
: [];
|
|
26
38
|
const activeAgents = new Set();
|
|
27
39
|
const live = board.runs
|
|
28
40
|
.filter((run) => run.status === "running" || run.status === "queued")
|
|
@@ -71,5 +83,5 @@ export function agentDisplayRows(board) {
|
|
|
71
83
|
limitedUntil: until ?? null,
|
|
72
84
|
};
|
|
73
85
|
});
|
|
74
|
-
return [...live, ...idle];
|
|
86
|
+
return [...drain, ...live, ...idle];
|
|
75
87
|
}
|
package/dist/tui/data.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv,
|
|
1
|
+
import { approveEpic as approveEpicNow, answerDecision, cancelTicket as cancelTicketNow, createAgent as postAgent, createEpic as postEpic, deleteEpic as removeEpic, getStatus, getWorkspace, listAgents, listEpics, listFeed, listMessages, markMessagesDelivered, listWorkspaceEnv as getWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage as sendMessage, queueTicket as queueTicketNow, setPaused as setPausedNow, showTicket, updateAgent as patchAgent, updateCaps, updateWorkspace as patchWorkspace, deleteAgent as removeAgent, } from "../api.js";
|
|
2
2
|
import { loadConfig, switchWorkspace as selectWorkspace, } from "../config.js";
|
|
3
3
|
import { readEpicSpec } from "../epics.js";
|
|
4
4
|
import { EARLIER_PAGE } from "./inbox.js";
|
|
@@ -79,7 +79,8 @@ export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
|
79
79
|
earlier: earlier.slice(0, earlierLimit),
|
|
80
80
|
earlierHasMore: earlier.length > earlierLimit,
|
|
81
81
|
availability,
|
|
82
|
-
runs: status.live_runs,
|
|
82
|
+
runs: status.recent_runs ?? status.live_runs,
|
|
83
|
+
hostDrain: status.host?.draining ?? null,
|
|
83
84
|
},
|
|
84
85
|
feed: feedData.entries,
|
|
85
86
|
};
|
|
@@ -125,7 +126,18 @@ export async function postAgentMessage(role, body, config) {
|
|
|
125
126
|
await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
|
|
126
127
|
}
|
|
127
128
|
export async function loadTicketDetail(config, key) {
|
|
128
|
-
|
|
129
|
+
const { ticket, pr, events, runs, messages, decisions } = await showTicket(key, config);
|
|
130
|
+
return {
|
|
131
|
+
ticket: {
|
|
132
|
+
...ticket,
|
|
133
|
+
stuck: ticket.stuck_reason,
|
|
134
|
+
pr,
|
|
135
|
+
timeline: events,
|
|
136
|
+
messages,
|
|
137
|
+
runs,
|
|
138
|
+
decisions,
|
|
139
|
+
},
|
|
140
|
+
};
|
|
129
141
|
}
|
|
130
142
|
export async function createEpicFromFile(config, path) {
|
|
131
143
|
return postEpic(await readEpicSpec(path), config);
|
|
@@ -187,14 +199,16 @@ export async function updateWorkspace(config, fields) {
|
|
|
187
199
|
}
|
|
188
200
|
export async function loadLiveEvents(config, board, ticketKey) {
|
|
189
201
|
if (ticketKey)
|
|
190
|
-
return (await
|
|
202
|
+
return (await listTicketRunEvents(ticketKey, undefined, undefined, config)).events
|
|
191
203
|
.sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
|
|
192
|
-
const
|
|
204
|
+
const now = Date.now();
|
|
205
|
+
const liveIds = new Set(board.runs.filter((run) => run.status === "running" || Boolean(run.ended_at)
|
|
206
|
+
&& now - Date.parse(run.ended_at) <= 15 * 60_000).map((run) => run.id));
|
|
193
207
|
const keys = new Set(board.runs
|
|
194
208
|
.filter((run) => liveIds.has(run.id) && run.ticket_id)
|
|
195
209
|
.map((run) => board.tickets.find((ticket) => ticket.id === run.ticket_id)?.key)
|
|
196
210
|
.filter((key) => Boolean(key)));
|
|
197
|
-
const batches = await Promise.all([...keys].map((key) =>
|
|
211
|
+
const batches = await Promise.all([...keys].map((key) => listTicketRunEvents(key, undefined, undefined, config)));
|
|
198
212
|
return batches
|
|
199
213
|
.flatMap((batch) => batch.events)
|
|
200
214
|
.filter((event) => liveIds.has(event.run_id))
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { decisionOptions } from "./data.js";
|
|
2
|
+
export const DECISION_CURSOR = "▶";
|
|
3
|
+
export function decisionIdAt(entries, focus) {
|
|
4
|
+
if (!entries.length)
|
|
5
|
+
return null;
|
|
6
|
+
const start = Math.max(0, Math.min(focus, entries.length - 1));
|
|
7
|
+
for (let i = start; i >= 0; i -= 1) {
|
|
8
|
+
const entry = entries[i];
|
|
9
|
+
if (entry.kind === "section")
|
|
10
|
+
return null;
|
|
11
|
+
if (entry.decisionId)
|
|
12
|
+
return entry.decisionId;
|
|
13
|
+
}
|
|
14
|
+
return null;
|
|
15
|
+
}
|
|
16
|
+
export function decisionHeaderIndex(entries, decisionId) {
|
|
17
|
+
return entries.findIndex((entry) => entry.kind === "header" && entry.decisionId === decisionId);
|
|
18
|
+
}
|
|
19
|
+
export function moveDecisionFocus(entries, focus, delta) {
|
|
20
|
+
const headers = entries
|
|
21
|
+
.map((entry, index) => ({ entry, index }))
|
|
22
|
+
.filter((row) => row.entry.kind === "header" && row.entry.decisionId);
|
|
23
|
+
const currentId = decisionIdAt(entries, focus);
|
|
24
|
+
if (currentId && headers.length) {
|
|
25
|
+
const at = headers.findIndex((row) => row.entry.decisionId === currentId);
|
|
26
|
+
const next = at + delta;
|
|
27
|
+
if (next >= 0 && next < headers.length)
|
|
28
|
+
return headers[next].index;
|
|
29
|
+
if (next >= headers.length) {
|
|
30
|
+
const after = entries.findIndex((entry, index) => index > headers[headers.length - 1].index && entry.kind === "section");
|
|
31
|
+
return after >= 0 ? after : headers[headers.length - 1].index;
|
|
32
|
+
}
|
|
33
|
+
return headers[0].index;
|
|
34
|
+
}
|
|
35
|
+
const next = Math.max(0, Math.min(entries.length - 1, focus + delta));
|
|
36
|
+
if (delta < 0 && headers.length && decisionIdAt(entries, next)) {
|
|
37
|
+
return headers[headers.length - 1].index;
|
|
38
|
+
}
|
|
39
|
+
return next;
|
|
40
|
+
}
|
|
41
|
+
export function nextUnanswered(decisions, answeredId) {
|
|
42
|
+
const index = decisions.findIndex((decision) => decision.id === answeredId);
|
|
43
|
+
if (index < 0)
|
|
44
|
+
return decisions[0] ?? null;
|
|
45
|
+
return decisions[index + 1] ?? null;
|
|
46
|
+
}
|
|
47
|
+
export function resolveDecisionAnswer(decision, text) {
|
|
48
|
+
const trimmed = text.trim();
|
|
49
|
+
const options = decisionOptions(decision);
|
|
50
|
+
if (/^\d+$/.test(trimmed)) {
|
|
51
|
+
const option = options[Number(trimmed) - 1];
|
|
52
|
+
if (option)
|
|
53
|
+
return option;
|
|
54
|
+
}
|
|
55
|
+
return trimmed;
|
|
56
|
+
}
|
|
57
|
+
export function answeredLine(decisions, tickets, decision, answer) {
|
|
58
|
+
const number = decisions.findIndex((row) => row.id === decision.id) + 1;
|
|
59
|
+
const ticket = tickets.find((row) => row.id === decision.ticket_id);
|
|
60
|
+
const label = ticket?.key ?? decision.id.slice(0, 8);
|
|
61
|
+
return `Answered ${number}) ${label}: ${answer}`;
|
|
62
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
const record = (value) => value && typeof value === "object" && !Array.isArray(value)
|
|
2
|
+
? value : {};
|
|
3
|
+
const string = (value) => typeof value === "string" ? value : "";
|
|
4
|
+
const clip = (value) => value.length > 60 ? `${value.slice(0, 57)}...` : value;
|
|
5
|
+
const path = (value) => string(value).replace(/^.*\/[A-Z][A-Z0-9]*-\d+\//, "").replace(/^\.\//, "");
|
|
6
|
+
const stat = (value) => value.additions != null || value.deletions != null
|
|
7
|
+
? ` (+${Number(value.additions ?? 0)} -${Number(value.deletions ?? 0)})` : "";
|
|
8
|
+
const shell = (value) => string(value).trim().replace(/^\/bin\/(?:ba)?sh -lc (["'])([\s\S]*)\1$/, "$2");
|
|
9
|
+
const sha = (payload) => string(payload.aggregated_output || payload.output)
|
|
10
|
+
.match(/\b([0-9a-f]{7,40})\b/)?.[1] ?? "";
|
|
11
|
+
function command(value, payload, status, lastSha = "") {
|
|
12
|
+
const cmd = shell(value);
|
|
13
|
+
const code = payload.exit_code ?? payload.exitCode;
|
|
14
|
+
const failed = status === "failed" || code != null && Number(code) !== 0;
|
|
15
|
+
const read = cmd.match(/(?:^|[;&|]\s*)(?:cat|head|tail)\s+(?:-[^\s]+\s+)*["']?([^\s;|&"']+)/)
|
|
16
|
+
?? cmd.match(/(?:^|[;&|]\s*)sed\s+-n\s+["']?[^\s]+["']?\s+["']?([^\s;|&"']+)/);
|
|
17
|
+
if (read)
|
|
18
|
+
return status === "in_progress" ? null
|
|
19
|
+
: { kind: failed ? "error" : "tool", title: `Read ${path(read[1])}${failed ? " (failed)" : ""}` };
|
|
20
|
+
if (/(?:^|\s)git push(?:\s|$)/.test(cmd)) {
|
|
21
|
+
const branch = cmd.match(/git push(?:\s+--set-upstream|\s+-u)?\s+origin\s+([^\s;&]+)/)?.[1] ?? "remote";
|
|
22
|
+
const pushed = lastSha || sha(payload);
|
|
23
|
+
return status === "in_progress" ? null : failed ? { kind: "error", title: `Push to ${branch} failed` }
|
|
24
|
+
: { kind: "tool", title: `Pushed${pushed ? ` ${pushed.slice(0, 7)}` : ""} to ${branch}` };
|
|
25
|
+
}
|
|
26
|
+
const state = status === "in_progress" || code == null && status !== "completed" ? "running" : failed ? "failed" : "passed";
|
|
27
|
+
return { kind: state === "failed" ? "error" : "tool", title: `Ran \`${clip(cmd || "command")}\` (${state})` };
|
|
28
|
+
}
|
|
29
|
+
function assistant(payload) {
|
|
30
|
+
const item = record(payload.item);
|
|
31
|
+
const message = record(payload.message);
|
|
32
|
+
const direct = string(payload.text) || string(item.text) || string(payload.result);
|
|
33
|
+
return direct || (Array.isArray(message.content)
|
|
34
|
+
? message.content.map((part) => string(record(part).text)).filter(Boolean).join("\n") : string(message.content));
|
|
35
|
+
}
|
|
36
|
+
function isJson(value) {
|
|
37
|
+
try {
|
|
38
|
+
JSON.parse(value);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
export function narrateEvents(events, raw = false) {
|
|
46
|
+
const lines = [];
|
|
47
|
+
let lastSha = "";
|
|
48
|
+
for (const event of events.slice().sort((a, b) => a.seq - b.seq)) {
|
|
49
|
+
const payload = record(event.payload);
|
|
50
|
+
const item = record(payload.item);
|
|
51
|
+
const itemType = string(item.type);
|
|
52
|
+
let made = [];
|
|
53
|
+
if (raw)
|
|
54
|
+
made = [{ kind: event.type === "error" ? "error" : "raw", title: JSON.stringify(event.payload) }];
|
|
55
|
+
else if (event.type === "error") {
|
|
56
|
+
const error = record(payload.error);
|
|
57
|
+
made = [{ kind: "error", title: string(error.message) || string(payload.error || payload.message) || "Run error" }];
|
|
58
|
+
}
|
|
59
|
+
else if (itemType === "command_execution") {
|
|
60
|
+
const line = command(item.command, item, item.status, lastSha);
|
|
61
|
+
if (line)
|
|
62
|
+
made = [line];
|
|
63
|
+
}
|
|
64
|
+
else if (itemType === "file_change" && item.status !== "in_progress") {
|
|
65
|
+
made = (Array.isArray(item.changes) ? item.changes.map(record) : [item])
|
|
66
|
+
.map((change) => ({ kind: "tool", title: `Edited ${path(change.path)}${stat(change)}` }));
|
|
67
|
+
}
|
|
68
|
+
else if (event.type === "tool_use") {
|
|
69
|
+
const input = record(payload.input ?? payload.args ?? payload);
|
|
70
|
+
const name = string(payload.name || payload.tool).toLowerCase();
|
|
71
|
+
if (name === "read")
|
|
72
|
+
made = [{ kind: "tool", title: `Read ${path(input.file_path || input.path)}` }];
|
|
73
|
+
else if (["edit", "write", "apply_patch"].includes(name))
|
|
74
|
+
made = [{ kind: "tool", title: `Edited ${path(input.file_path || input.path)}${stat(input)}` }];
|
|
75
|
+
else if (["bash", "shell", "exec_command"].includes(name) || input.command || input.cmd) {
|
|
76
|
+
const line = command(input.command || input.cmd, input, "in_progress", lastSha);
|
|
77
|
+
if (line)
|
|
78
|
+
made = [line];
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
else if (event.type === "command") {
|
|
82
|
+
const line = command(payload.command || payload.cmd, payload, payload.status, lastSha);
|
|
83
|
+
if (line)
|
|
84
|
+
made = [line];
|
|
85
|
+
}
|
|
86
|
+
else if (event.type === "file_changed")
|
|
87
|
+
made = [{ kind: "tool", title: `Edited ${path(payload.path)}${stat(payload)}` }];
|
|
88
|
+
else if (event.type === "text") {
|
|
89
|
+
const text = assistant(payload);
|
|
90
|
+
if (text.trim() && !isJson(text))
|
|
91
|
+
made = [{ kind: "agent", title: text }];
|
|
92
|
+
}
|
|
93
|
+
made.forEach((line, index) => lines.push({ ...line, id: `${event.id}:${index}`, sourceId: event.id, at: event.at, seq: event.seq }));
|
|
94
|
+
lastSha = sha(item) || sha(payload) || lastSha;
|
|
95
|
+
}
|
|
96
|
+
return lines;
|
|
97
|
+
}
|
package/dist/tui/parse.js
CHANGED
|
@@ -94,8 +94,11 @@ export function parseLine(raw) {
|
|
|
94
94
|
return rest.length >= 2 ? { kind: "message", key: rest[0].toUpperCase(), text: rest.slice(1).join(" ") }
|
|
95
95
|
: { kind: "unknown", command: "msg needs KEY TEXT" };
|
|
96
96
|
case "logs":
|
|
97
|
-
|
|
98
|
-
|
|
97
|
+
if (rest[0]?.toLowerCase() === "raw" && rest.length <= 2) {
|
|
98
|
+
return { kind: "logs", key: rest[1]?.toUpperCase() ?? null, raw: true };
|
|
99
|
+
}
|
|
100
|
+
return rest.length <= 1 ? { kind: "logs", key: rest[0]?.toUpperCase() ?? null, raw: false }
|
|
101
|
+
: { kind: "unknown", command: "logs takes raw and an optional ticket key" };
|
|
99
102
|
case "refresh":
|
|
100
103
|
return { kind: "refresh" };
|
|
101
104
|
case "exit":
|
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;
|