@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/Help.js
CHANGED
|
@@ -6,18 +6,18 @@ export const HELP_FOOTER = "Anything not starting with / goes to whoever you are
|
|
|
6
6
|
/** Everything you can type. The app is driven from here, not from flags. */
|
|
7
7
|
export const COMMANDS = [
|
|
8
8
|
{ name: "/board", help: "the kanban board" },
|
|
9
|
-
{ name: "/inbox", help: "
|
|
10
|
-
{ name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open or create
|
|
9
|
+
{ name: "/inbox", args: "[more]", help: "unread, earlier, and numbered decisions; Enter answers" },
|
|
10
|
+
{ name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open a full ticket view or create one" },
|
|
11
11
|
{ name: "/queue", args: "HD-12", help: "queue a complete ticket now" },
|
|
12
12
|
{ name: "/cancel", args: "HD-12", help: "cancel a ticket" },
|
|
13
13
|
{ name: "/msg", args: "HD-12 TEXT", help: "message a ticket's builder" },
|
|
14
|
-
{ name: "/logs", args: "[HD-12]", help: "filter
|
|
14
|
+
{ name: "/logs", args: "[raw] [HD-12]", help: "filter activity; raw reveals event JSON" },
|
|
15
15
|
{ name: "/epic", args: "new PATH | approve ID | rm ID", help: "create, approve, or remove a draft epic" },
|
|
16
16
|
{ name: "/epics", help: "list epics and ticket progress" },
|
|
17
17
|
{ name: "/architect", help: "talk to the agent that shapes draft epics" },
|
|
18
18
|
{ name: "/plan", help: "alias for /architect" },
|
|
19
|
-
{ name: "/decide", args: "[N|ID] answer", help: "answer
|
|
20
|
-
{ name: "/agents", args: "[add
|
|
19
|
+
{ name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
|
|
20
|
+
{ name: "/agents", args: "[add [ROLE] [flags] | rm ROLE|ID]", help: "view or manage named agents" },
|
|
21
21
|
{ name: "/env", help: "list workspace environment variable names" },
|
|
22
22
|
{ name: "/settings", help: "change provider caps and agent settings" },
|
|
23
23
|
{ name: "/workspace", args: "[slug | new | set | rotate-key | grant-runner-access]", help: "list, switch, create, or configure" },
|
package/dist/tui/Panels.js
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
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";
|
|
7
7
|
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
|
+
import { inboxHeader } from "./inbox.js";
|
|
11
|
+
import { DECISION_CURSOR, decisionIdAt } from "./decide-nav.js";
|
|
12
|
+
import { ticketScrollOffset, ticketViewLines } from "./ticket-view.js";
|
|
10
13
|
const DOT = "●";
|
|
11
14
|
/**
|
|
12
15
|
* The single-purpose views behind /board, /agents, /feed and /inbox. Each is
|
|
@@ -84,52 +87,96 @@ export function FeedPanel({ entries, width = 80, rows = 12, }) {
|
|
|
84
87
|
* The whole queue, one row each, numbered the way `/decide` reaches them: the
|
|
85
88
|
* one on screen is 1.
|
|
86
89
|
*/
|
|
87
|
-
export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
|
|
88
|
-
const entries = inboxEntries(board, width);
|
|
89
|
-
const
|
|
90
|
+
export function InboxPanel({ board, width = 80, rows = 12, focus = 0, selectedId = null, answeringId = null, }) {
|
|
91
|
+
const entries = inboxEntries(board, width, answeringId);
|
|
92
|
+
const unread = board.messages ?? [];
|
|
93
|
+
const earlier = board.earlier ?? [];
|
|
94
|
+
const selected = selectedId ?? decisionIdAt(entries, focus);
|
|
90
95
|
const inner = Math.max(0, rows - 1);
|
|
91
96
|
const window = scrollWindow(entries.length, inner, focus);
|
|
92
97
|
const hiddenAbove = window.start;
|
|
93
98
|
const hiddenBelow = entries.length - window.end;
|
|
94
|
-
const note = `${board.decisions.length}d · ${
|
|
99
|
+
const note = `${board.decisions.length}d · ${unread.length} unread`
|
|
95
100
|
+ `${hiddenAbove ? ` ${hiddenAbove}↑` : ""}${hiddenBelow ? ` ${hiddenBelow}↓` : ""}`;
|
|
101
|
+
const empty = board.decisions.length === 0 && unread.length === 0 && earlier.length === 0;
|
|
96
102
|
return (_jsx(Panel, { width: width, rows: rows, children: [
|
|
97
103
|
_jsx(Heading, { text: "Inbox", note: note }, "h"),
|
|
98
|
-
...(
|
|
104
|
+
...(empty
|
|
99
105
|
? [
|
|
100
106
|
_jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
|
|
101
107
|
]
|
|
102
108
|
: []),
|
|
103
|
-
...entries.slice(window.start, window.end).map((entry, index) =>
|
|
109
|
+
...entries.slice(window.start, window.end).map((entry, index) => {
|
|
110
|
+
const cursor = Boolean(entry.kind === "header" && entry.decisionId && entry.decisionId === selected);
|
|
111
|
+
const color = entry.kind === "option" ? UI.accent : entry.kind === "body" ? UI.text : UI.warn;
|
|
112
|
+
const prefix = entry.kind === "body" || entry.kind === "option" ? " " : cursor ? `${DECISION_CURSOR} ` : entry.decisionId ? " " : "";
|
|
113
|
+
return (_jsxs(Text, { color: color, wrap: "truncate", inverse: cursor, children: [prefix, entry.text] }, entry.key));
|
|
114
|
+
}),
|
|
104
115
|
] }));
|
|
105
116
|
}
|
|
106
|
-
/** Every physical line in /inbox, with decision bodies wrapped but never clipped. */
|
|
107
|
-
export function inboxEntries(board, width) {
|
|
117
|
+
/** Every physical line in /inbox, with decision and message bodies wrapped but never clipped. */
|
|
118
|
+
export function inboxEntries(board, width, answeringId) {
|
|
108
119
|
const entries = [];
|
|
109
120
|
const bodyWidth = Math.max(12, width - 2);
|
|
121
|
+
const unread = board.messages ?? [];
|
|
122
|
+
const earlier = board.earlier ?? [];
|
|
110
123
|
board.decisions.forEach((decision, index) => {
|
|
111
124
|
const ticket = board.tickets.find((row) => row.id === decision.ticket_id);
|
|
112
125
|
entries.push({
|
|
113
126
|
key: `${decision.id}:header`,
|
|
114
127
|
kind: "header",
|
|
128
|
+
decisionId: decision.id,
|
|
115
129
|
text: `${index + 1}) ${ticket?.key ?? decision.id.slice(0, 8)}`,
|
|
116
130
|
});
|
|
117
131
|
wrapLines(decision.question_md.trim(), bodyWidth).forEach((text, line) => {
|
|
118
|
-
entries.push({ key: `${decision.id}:body:${line}`, kind: "body", text });
|
|
119
|
-
});
|
|
120
|
-
});
|
|
121
|
-
(board.messages ?? []).forEach((message) => {
|
|
122
|
-
entries.push({
|
|
123
|
-
key: `${message.id}:header`,
|
|
124
|
-
kind: "header",
|
|
125
|
-
text: `Message from ${message.from_name ?? message.from_role}`,
|
|
126
|
-
});
|
|
127
|
-
wrapLines(message.body_md.trim(), bodyWidth).forEach((text, line) => {
|
|
128
|
-
entries.push({ key: `${message.id}:body:${line}`, kind: "body", text });
|
|
132
|
+
entries.push({ key: `${decision.id}:body:${line}`, kind: "body", decisionId: decision.id, text });
|
|
129
133
|
});
|
|
134
|
+
if (answeringId === decision.id) {
|
|
135
|
+
decisionOptions(decision).forEach((option, optionIndex) => {
|
|
136
|
+
entries.push({
|
|
137
|
+
key: `${decision.id}:option:${optionIndex}`,
|
|
138
|
+
kind: "option",
|
|
139
|
+
decisionId: decision.id,
|
|
140
|
+
text: `${optionIndex + 1}) ${option}`,
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
entries.push({
|
|
144
|
+
key: `${decision.id}:prompt`,
|
|
145
|
+
kind: "body",
|
|
146
|
+
decisionId: decision.id,
|
|
147
|
+
text: "Type a number or your answer. Esc cancels.",
|
|
148
|
+
});
|
|
149
|
+
}
|
|
130
150
|
});
|
|
151
|
+
entries.push({ key: "unread:section", kind: "section", text: "Unread" });
|
|
152
|
+
if (unread.length === 0) {
|
|
153
|
+
entries.push({ key: "unread:empty", kind: "body", text: "No unread messages." });
|
|
154
|
+
}
|
|
155
|
+
else {
|
|
156
|
+
unread.forEach((message) => pushMessageEntries(entries, message, bodyWidth));
|
|
157
|
+
}
|
|
158
|
+
entries.push({ key: "earlier:section", kind: "section", text: "Earlier" });
|
|
159
|
+
if (earlier.length === 0) {
|
|
160
|
+
entries.push({ key: "earlier:empty", kind: "body", text: "No earlier messages." });
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
earlier.forEach((message) => pushMessageEntries(entries, message, bodyWidth));
|
|
164
|
+
if (board.earlierHasMore) {
|
|
165
|
+
entries.push({ key: "earlier:more", kind: "body", text: "Type /inbox more for older messages." });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
131
168
|
return entries;
|
|
132
169
|
}
|
|
170
|
+
function pushMessageEntries(entries, message, bodyWidth) {
|
|
171
|
+
entries.push({
|
|
172
|
+
key: `${message.id}:header`,
|
|
173
|
+
kind: "header",
|
|
174
|
+
text: inboxHeader(message),
|
|
175
|
+
});
|
|
176
|
+
wrapLines(message.body_md.trim(), bodyWidth).forEach((text, line) => {
|
|
177
|
+
entries.push({ key: `${message.id}:body:${line}`, kind: "body", text });
|
|
178
|
+
});
|
|
179
|
+
}
|
|
133
180
|
function wrapLines(text, width) {
|
|
134
181
|
if (!text)
|
|
135
182
|
return [""];
|
|
@@ -169,28 +216,19 @@ function wrapLines(text, width) {
|
|
|
169
216
|
* rows nothing had budgeted, and a frame past the window is one Ink stops
|
|
170
217
|
* repainting in place.
|
|
171
218
|
*/
|
|
172
|
-
export function TicketPanel({ ticket, width = 80, rows = 24, }) {
|
|
173
|
-
const
|
|
174
|
-
_jsxs(Text, { color: UI.text, bold: true, wrap: "truncate", children: [ticket.key, " ", ticket.title] }, "title"),
|
|
175
|
-
_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"),
|
|
176
|
-
];
|
|
177
|
-
if (ticket.stuck) {
|
|
178
|
-
lines.push(_jsxs(Text, { color: UI.warn, wrap: "truncate", children: ["why: ", ticket.stuck] }, "stuck"));
|
|
179
|
-
}
|
|
180
|
-
if (ticket.blocker_keys.length) {
|
|
181
|
-
lines.push(_jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["blocked by ", ticket.blocker_keys.join(", ")] }, "blocked"));
|
|
182
|
-
}
|
|
183
|
-
if (ticket.pr_url) {
|
|
184
|
-
lines.push(_jsx(Text, { color: UI.accent, wrap: "truncate", children: ticket.pr_url }, "pr"));
|
|
185
|
-
}
|
|
186
|
-
// The blank row before the body comes out of the body's own allowance.
|
|
219
|
+
export function TicketPanel({ ticket, width = 80, rows = 24, offset = 0, collapsed = [], }) {
|
|
220
|
+
const entries = ticketViewLines(ticket, Math.max(20, width), collapsed);
|
|
187
221
|
const drawable = Math.max(0, rows);
|
|
188
|
-
const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
222
|
+
const overflow = entries.length > drawable;
|
|
223
|
+
const inner = overflow ? Math.max(0, drawable - 1) : drawable;
|
|
224
|
+
const start = ticketScrollOffset(entries.length, inner, offset);
|
|
225
|
+
const shown = entries.slice(start, start + inner);
|
|
226
|
+
const hiddenAbove = start;
|
|
227
|
+
const hiddenBelow = Math.max(0, entries.length - start - shown.length);
|
|
228
|
+
return (_jsx(Panel, { width: width, rows: drawable, children: [
|
|
229
|
+
...shown.map((entry) => (_jsx(Text, { color: entry.kind === "section" ? UI.warn : UI.text, wrap: "truncate", children: entry.text }, entry.key))),
|
|
230
|
+
overflow ? (_jsxs(Text, { color: UI.dim, wrap: "truncate", children: [hiddenAbove ? `${hiddenAbove}↑ ` : "", hiddenBelow ? `${hiddenBelow}↓` : ""] }, "more")) : null,
|
|
231
|
+
] }));
|
|
194
232
|
}
|
|
195
233
|
/**
|
|
196
234
|
* 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
package/dist/tui/data.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
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
|
+
import { EARLIER_PAGE } from "./inbox.js";
|
|
4
5
|
export const POLL_MS = 5_000;
|
|
5
6
|
export const providers = ["claude", "codex", "gemini", "grok"];
|
|
6
7
|
export const efforts = ["low", "medium", "high"];
|
|
@@ -21,8 +22,16 @@ export const BOARD_COLUMNS = [
|
|
|
21
22
|
export async function configuredSlugs(config = loadConfig()) {
|
|
22
23
|
return (await listWorkspaces(config)).map((workspace) => workspace.slug).sort();
|
|
23
24
|
}
|
|
24
|
-
|
|
25
|
-
const
|
|
25
|
+
function withTicketKeys(messages, tickets) {
|
|
26
|
+
const keys = new Map(tickets.map((ticket) => [ticket.id, ticket.key]));
|
|
27
|
+
return messages.map((message) => ({
|
|
28
|
+
...message,
|
|
29
|
+
ticket_key: message.ticket_key ?? (message.ticket_id ? keys.get(message.ticket_id) ?? null : null),
|
|
30
|
+
}));
|
|
31
|
+
}
|
|
32
|
+
export async function loadSnapshot(config = loadConfig(), options = {}) {
|
|
33
|
+
const earlierLimit = options.earlierLimit ?? EARLIER_PAGE;
|
|
34
|
+
const [status, workspaceData, ticketData, agentData, epicData, feedData, unreadData, earlierData] = await Promise.all([
|
|
26
35
|
getStatus(config),
|
|
27
36
|
getWorkspace(config),
|
|
28
37
|
listTickets(config),
|
|
@@ -30,6 +39,9 @@ export async function loadSnapshot(config = loadConfig()) {
|
|
|
30
39
|
listEpics(config),
|
|
31
40
|
listFeed(config),
|
|
32
41
|
listMessages({ toRoles: ["human", "all"], undelivered: true, limit: 500 }, config),
|
|
42
|
+
listMessages({
|
|
43
|
+
toRoles: ["human", "all"], delivered: true, order: "desc", limit: earlierLimit + 1,
|
|
44
|
+
}, config),
|
|
33
45
|
]);
|
|
34
46
|
const byId = new Map(ticketData.tickets.map((ticket) => [ticket.id, ticket]));
|
|
35
47
|
const agents = new Map(agentData.agents.map((agent) => [agent.id, agent]));
|
|
@@ -39,11 +51,10 @@ export async function loadSnapshot(config = loadConfig()) {
|
|
|
39
51
|
blocker_keys: ticket.blocked_by.map((id) => byId.get(id)?.key).filter((key) => Boolean(key)),
|
|
40
52
|
stuck: ticket.stuck_reason,
|
|
41
53
|
}));
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
});
|
|
54
|
+
const availability = status.workspace.provider_capabilities?.length
|
|
55
|
+
? status.workspace.provider_capabilities
|
|
56
|
+
: providers.map((provider) => ({ provider, available: false, reason: "runner capability unknown" }));
|
|
57
|
+
const earlier = withTicketKeys(earlierData.messages, tickets);
|
|
47
58
|
return {
|
|
48
59
|
config,
|
|
49
60
|
workspace: {
|
|
@@ -64,14 +75,16 @@ export async function loadSnapshot(config = loadConfig()) {
|
|
|
64
75
|
agents: agentData.agents,
|
|
65
76
|
epics: epicData.epics,
|
|
66
77
|
decisions: status.decisions,
|
|
67
|
-
messages:
|
|
78
|
+
messages: withTicketKeys(unreadData.messages, tickets),
|
|
79
|
+
earlier: earlier.slice(0, earlierLimit),
|
|
80
|
+
earlierHasMore: earlier.length > earlierLimit,
|
|
68
81
|
availability,
|
|
69
|
-
runs: status.live_runs,
|
|
82
|
+
runs: status.recent_runs ?? status.live_runs,
|
|
70
83
|
},
|
|
71
84
|
feed: feedData.entries,
|
|
72
85
|
};
|
|
73
86
|
}
|
|
74
|
-
export function pollSnapshot(config, onSnapshot, onState, onError) {
|
|
87
|
+
export function pollSnapshot(config, onSnapshot, onState, onError, earlierLimit) {
|
|
75
88
|
let closed = false;
|
|
76
89
|
let active = false;
|
|
77
90
|
const refresh = async () => {
|
|
@@ -79,7 +92,7 @@ export function pollSnapshot(config, onSnapshot, onState, onError) {
|
|
|
79
92
|
return;
|
|
80
93
|
active = true;
|
|
81
94
|
try {
|
|
82
|
-
const snapshot = await loadSnapshot(config);
|
|
95
|
+
const snapshot = await loadSnapshot(config, { earlierLimit: earlierLimit?.() ?? EARLIER_PAGE });
|
|
83
96
|
if (closed)
|
|
84
97
|
return;
|
|
85
98
|
onSnapshot(snapshot);
|
|
@@ -112,7 +125,18 @@ export async function postAgentMessage(role, body, config) {
|
|
|
112
125
|
await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
|
|
113
126
|
}
|
|
114
127
|
export async function loadTicketDetail(config, key) {
|
|
115
|
-
|
|
128
|
+
const { ticket, pr, events, runs, messages, decisions } = await showTicket(key, config);
|
|
129
|
+
return {
|
|
130
|
+
ticket: {
|
|
131
|
+
...ticket,
|
|
132
|
+
stuck: ticket.stuck_reason,
|
|
133
|
+
pr,
|
|
134
|
+
timeline: events,
|
|
135
|
+
messages,
|
|
136
|
+
runs,
|
|
137
|
+
decisions,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
116
140
|
}
|
|
117
141
|
export async function createEpicFromFile(config, path) {
|
|
118
142
|
return postEpic(await readEpicSpec(path), config);
|
|
@@ -174,14 +198,16 @@ export async function updateWorkspace(config, fields) {
|
|
|
174
198
|
}
|
|
175
199
|
export async function loadLiveEvents(config, board, ticketKey) {
|
|
176
200
|
if (ticketKey)
|
|
177
|
-
return (await
|
|
201
|
+
return (await listTicketRunEvents(ticketKey, undefined, undefined, config)).events
|
|
178
202
|
.sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
|
|
179
|
-
const
|
|
203
|
+
const now = Date.now();
|
|
204
|
+
const liveIds = new Set(board.runs.filter((run) => run.status === "running" || Boolean(run.ended_at)
|
|
205
|
+
&& now - Date.parse(run.ended_at) <= 15 * 60_000).map((run) => run.id));
|
|
180
206
|
const keys = new Set(board.runs
|
|
181
207
|
.filter((run) => liveIds.has(run.id) && run.ticket_id)
|
|
182
208
|
.map((run) => board.tickets.find((ticket) => ticket.id === run.ticket_id)?.key)
|
|
183
209
|
.filter((key) => Boolean(key)));
|
|
184
|
-
const batches = await Promise.all([...keys].map((key) =>
|
|
210
|
+
const batches = await Promise.all([...keys].map((key) => listTicketRunEvents(key, undefined, undefined, config)));
|
|
185
211
|
return batches
|
|
186
212
|
.flatMap((batch) => batch.events)
|
|
187
213
|
.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,29 @@
|
|
|
1
|
+
export const EARLIER_PAGE = 50;
|
|
2
|
+
/** Split a mixed inbox fixture into Unread and Earlier, newest-first for delivered. */
|
|
3
|
+
export function inboxHistory(messages, earlierLimit = EARLIER_PAGE) {
|
|
4
|
+
const unread = messages
|
|
5
|
+
.filter((message) => !message.delivered_at)
|
|
6
|
+
.sort((a, b) => Date.parse(a.created_at) - Date.parse(b.created_at));
|
|
7
|
+
const earlier = messages
|
|
8
|
+
.filter((message) => Boolean(message.delivered_at))
|
|
9
|
+
.sort((a, b) => Date.parse(b.created_at) - Date.parse(a.created_at));
|
|
10
|
+
return {
|
|
11
|
+
unread,
|
|
12
|
+
earlier: earlier.slice(0, earlierLimit),
|
|
13
|
+
earlierHasMore: earlier.length > earlierLimit,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export function inboxTime(iso) {
|
|
17
|
+
const at = Date.parse(iso);
|
|
18
|
+
if (Number.isNaN(at))
|
|
19
|
+
return iso;
|
|
20
|
+
return new Date(at).toISOString().slice(0, 16).replace("T", " ");
|
|
21
|
+
}
|
|
22
|
+
export function inboxSender(message) {
|
|
23
|
+
return message.from_name?.trim() || message.from_role;
|
|
24
|
+
}
|
|
25
|
+
export function inboxHeader(message) {
|
|
26
|
+
const key = message.ticket_key?.trim();
|
|
27
|
+
const prefix = `${inboxTime(message.created_at)} ${inboxSender(message)}`;
|
|
28
|
+
return key ? `${prefix} ${key}` : prefix;
|
|
29
|
+
}
|
|
@@ -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
|
@@ -19,22 +19,22 @@ export function parseLine(raw) {
|
|
|
19
19
|
: { kind: "mode", mode: "architect" };
|
|
20
20
|
case "board":
|
|
21
21
|
case "feed":
|
|
22
|
-
case "inbox":
|
|
23
22
|
case "settings":
|
|
24
23
|
return { kind: "view", view: word.toLowerCase() };
|
|
24
|
+
case "inbox":
|
|
25
|
+
if (!argument)
|
|
26
|
+
return { kind: "view", view: "inbox" };
|
|
27
|
+
return rest.length === 1 && rest[0].toLowerCase() === "more"
|
|
28
|
+
? { kind: "inbox-more" }
|
|
29
|
+
: { kind: "unknown", command: "inbox takes no arguments, or more" };
|
|
25
30
|
case "agents": {
|
|
26
31
|
if (!argument)
|
|
27
32
|
return { kind: "view", view: "agents" };
|
|
28
33
|
const [verb, target, ...tail] = rest;
|
|
29
34
|
if (verb === "rm" && target && !tail.length)
|
|
30
35
|
return { kind: "agent-rm", target };
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
if (tail[i]?.startsWith("--") && tail[i + 1])
|
|
34
|
-
opts[tail[i].slice(2)] = tail[i + 1];
|
|
35
|
-
return verb === "add" && target && opts.provider && opts.model
|
|
36
|
-
? { kind: "agent-add", role: target, provider: opts.provider, model: opts.model,
|
|
37
|
-
...(opts.effort ? { effort: opts.effort } : {}), ...(opts.name ? { name: opts.name } : {}) }
|
|
36
|
+
return verb === "add"
|
|
37
|
+
? { kind: "agent-add", args: rest.slice(1) }
|
|
38
38
|
: { kind: "unknown", command: "agents needs add ROLE --provider P --model M or rm ROLE|ID" };
|
|
39
39
|
}
|
|
40
40
|
case "env":
|
|
@@ -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":
|