@higherdev/cli 0.16.0 → 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.
@@ -1,48 +1,35 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { relativeTime, truncate } from "../out/format.js";
4
- import { clipToRows } from "./Panels.js";
5
- import { decisionOptions } from "./data.js";
4
+ import { DECISION_CURSOR } from "./decide-nav.js";
6
5
  import { UI } from "./theme.js";
7
6
  /**
8
- * Everything the panel spends that is not question or options: two rows of
9
- * frame, the heading, the line saying how to answer, and the blank row after.
10
- */
11
- const DECISION_CHROME = 5;
12
- /**
13
- * Rows the flag wants, so the caller can take them out of the panel budget
14
- * before anything is drawn. Zero when nothing is waiting.
7
+ * Heading, one row per pending decision, and the hint. Cap keeps the flag
8
+ * from eating the cockpit on a busy inbox.
15
9
  */
16
10
  export function decisionRows(decisions, cap = 9) {
17
11
  if (decisions.length === 0)
18
12
  return 0;
19
- // Two rows of question is the least worth reading, and the options after it.
20
- return Math.min(cap, DECISION_CHROME + 2 + decisionOptions(decisions[0]).length);
13
+ return Math.min(cap, 4 + decisions.length);
21
14
  }
22
15
  /**
23
- * A decision waiting on you, in the way of the prompt rather than counted in
24
- * the status line. This is the one thing in the frame that is not a report:
25
- * nothing moves on the ticket it blocks until it is answered, so it stays in
26
- * front of you until it is.
16
+ * Pending decisions on the home view, numbered the same way `/inbox` and
17
+ * `/decide N` use, so the owner can answer any of them.
27
18
  */
28
- export function DecisionPanel({ decisions, board, width, rows, }) {
19
+ export function DecisionPanel({ decisions, board, width, rows, selectedId = null, }) {
29
20
  if (decisions.length === 0)
30
21
  return null;
31
- const decision = decisions[0];
32
- const ticket = board?.tickets.find((row) => row.id === decision.ticket_id);
33
- const options = decisionOptions(decision);
34
22
  const inner = Math.max(8, width - 4);
35
- const forBody = rows - DECISION_CHROME;
36
- // Too little room for a frame at all. The flag still has to appear, so it
37
- // shrinks to the one line that says something is waiting and how to answer.
38
- if (forBody < 1) {
39
- return (_jsxs(Box, { width: width, flexWrap: "nowrap", children: [_jsx(Text, { color: UI.warn, bold: true, wrap: "truncate", children: decisions.length > 1 ? `${decisions.length} decisions waiting` : "Decision waiting" }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: ` ${truncate(decision.question_md, Math.max(8, width - 26))} /decide` })] }));
23
+ const selected = selectedId ?? decisions[0]?.id ?? null;
24
+ if (rows < 4) {
25
+ return (_jsxs(Box, { width: width, flexWrap: "nowrap", children: [_jsxs(Text, { color: UI.warn, bold: true, wrap: "truncate", children: [decisions.length, " decision", decisions.length === 1 ? "" : "s"] }), _jsx(Text, { color: UI.dim, wrap: "truncate", children: ` /decide N answer or /inbox then Enter` })] }));
40
26
  }
41
- // The question gets a row before any option does, and the options take what
42
- // is left after it.
43
- const optionRows = Math.min(options.length, Math.max(0, forBody - 1));
44
- const question = clipToRows(decision.question_md.trim(), inner, forBody - optionRows);
45
- return (_jsxs(Box, { borderStyle: "single", borderColor: UI.warn, flexDirection: "column", paddingX: 1, width: width, marginBottom: 1, children: [_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Text, { color: UI.warn, bold: true, wrap: "truncate", children: decisions.length > 1 ? `Decision 1 of ${decisions.length}` : "Decision" }), _jsxs(Text, { color: UI.dim, wrap: "truncate", children: [ticket ? ` ${ticket.key}` : "", ` asked by ${decision.asked_by_role}`, ` ${relativeTime(decision.created_at)}`] })] }), _jsx(Text, { color: UI.text, wrap: "wrap", children: question }), options.slice(0, optionRows).map((option, index) => (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: 3, flexShrink: 0, children: _jsxs(Text, { color: UI.accent, children: [index + 1, ")"] }) }), _jsx(Text, { color: UI.text, wrap: "truncate", children: truncate(option, Math.max(8, inner - 4)) })] }, index))), _jsx(Text, { color: UI.dim, wrap: "truncate", children: options.length
46
- ? `/decide 1 to ${options.length}, or /decide <your answer>`
47
- : "/decide <your answer>" })] }));
27
+ const shown = decisions.slice(0, Math.max(1, rows - 4));
28
+ return (_jsxs(Box, { borderStyle: "single", borderColor: UI.warn, flexDirection: "column", paddingX: 1, width: width, marginBottom: 1, children: [_jsxs(Text, { color: UI.warn, bold: true, wrap: "truncate", children: ["Decisions (", decisions.length, ")"] }), shown.map((decision, index) => {
29
+ const ticket = board?.tickets.find((row) => row.id === decision.ticket_id);
30
+ const cursor = decision.id === selected;
31
+ const label = ticket?.key ?? decision.id.slice(0, 8);
32
+ const question = decision.question_md.trim().split("\n")[0] ?? "";
33
+ return (_jsxs(Text, { color: UI.text, wrap: "truncate", inverse: cursor, children: [cursor ? `${DECISION_CURSOR} ` : " ", index + 1, ") ", label, question ? ` ${truncate(question, Math.max(8, inner - label.length - 8))}` : "", ` ${relativeTime(decision.created_at)}`] }, decision.id));
34
+ }), decisions.length > shown.length ? (_jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["+", decisions.length - shown.length, " more in /inbox"] })) : null, _jsx(Text, { color: UI.dim, wrap: "truncate", children: "/decide 3 answer \u00B7 /inbox then Enter" })] }));
48
35
  }
package/dist/tui/Help.js CHANGED
@@ -6,17 +6,17 @@ 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", args: "[more]", help: "unread and earlier messages; more pages earlier" },
10
- { name: "/ticket", args: "HD-12 | new [PATH.md]", help: "open or create a ticket" },
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 or clear the activity stream" },
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 a targeted or focused decision" },
19
+ { name: "/decide", args: "[N|ID] [answer]", help: "answer the selected or numbered decision" },
20
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" },
@@ -1,6 +1,6 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { Box, Text } from "ink";
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
@@ -85,10 +87,11 @@ export function FeedPanel({ entries, width = 80, rows = 12, }) {
85
87
  * The whole queue, one row each, numbered the way `/decide` reaches them: the
86
88
  * one on screen is 1.
87
89
  */
88
- export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
89
- const entries = inboxEntries(board, width);
90
+ export function InboxPanel({ board, width = 80, rows = 12, focus = 0, selectedId = null, answeringId = null, }) {
91
+ const entries = inboxEntries(board, width, answeringId);
90
92
  const unread = board.messages ?? [];
91
93
  const earlier = board.earlier ?? [];
94
+ const selected = selectedId ?? decisionIdAt(entries, focus);
92
95
  const inner = Math.max(0, rows - 1);
93
96
  const window = scrollWindow(entries.length, inner, focus);
94
97
  const hiddenAbove = window.start;
@@ -103,11 +106,16 @@ export function InboxPanel({ board, width = 80, rows = 12, focus = 0, }) {
103
106
  _jsx(Text, { color: UI.dim, children: "Nothing waiting on you." }, "empty"),
104
107
  ]
105
108
  : []),
106
- ...entries.slice(window.start, window.end).map((entry, index) => (_jsx(Text, { color: entry.kind === "body" ? UI.text : UI.warn, wrap: "truncate", inverse: window.start + index === focus, children: entry.kind === "body" ? ` ${entry.text}` : entry.text }, entry.key))),
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
+ }),
107
115
  ] }));
108
116
  }
109
117
  /** Every physical line in /inbox, with decision and message bodies wrapped but never clipped. */
110
- export function inboxEntries(board, width) {
118
+ export function inboxEntries(board, width, answeringId) {
111
119
  const entries = [];
112
120
  const bodyWidth = Math.max(12, width - 2);
113
121
  const unread = board.messages ?? [];
@@ -117,11 +125,28 @@ export function inboxEntries(board, width) {
117
125
  entries.push({
118
126
  key: `${decision.id}:header`,
119
127
  kind: "header",
128
+ decisionId: decision.id,
120
129
  text: `${index + 1}) ${ticket?.key ?? decision.id.slice(0, 8)}`,
121
130
  });
122
131
  wrapLines(decision.question_md.trim(), bodyWidth).forEach((text, line) => {
123
- entries.push({ key: `${decision.id}:body:${line}`, kind: "body", text });
132
+ entries.push({ key: `${decision.id}:body:${line}`, kind: "body", decisionId: decision.id, text });
124
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
+ }
125
150
  });
126
151
  entries.push({ key: "unread:section", kind: "section", text: "Unread" });
127
152
  if (unread.length === 0) {
@@ -191,28 +216,19 @@ function wrapLines(text, width) {
191
216
  * rows nothing had budgeted, and a frame past the window is one Ink stops
192
217
  * repainting in place.
193
218
  */
194
- export function TicketPanel({ ticket, width = 80, rows = 24, }) {
195
- const lines = [
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.
219
+ export function TicketPanel({ ticket, width = 80, rows = 24, offset = 0, collapsed = [], }) {
220
+ const entries = ticketViewLines(ticket, Math.max(20, width), collapsed);
209
221
  const drawable = Math.max(0, rows);
210
- const body = clipToRows(ticket.body_md.trim(), Math.max(20, width), Math.max(0, drawable - lines.length - 1));
211
- if (body) {
212
- lines.push(_jsx(Box, { height: 1 }, "gap"));
213
- lines.push(_jsx(Text, { color: UI.text, wrap: "wrap", children: body }, "body"));
214
- }
215
- return (_jsx(Panel, { width: width, rows: drawable, children: lines }));
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
+ ] }));
216
232
  }
217
233
  /**
218
234
  * Text cut to the rows it may occupy once wrapped.
@@ -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/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, listTicketEvents, 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";
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,7 @@ 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
83
  },
84
84
  feed: feedData.entries,
85
85
  };
@@ -125,7 +125,18 @@ export async function postAgentMessage(role, body, config) {
125
125
  await sendMessage({ body_md: body, to_role: role, delivery: "queue" }, config);
126
126
  }
127
127
  export async function loadTicketDetail(config, key) {
128
- return showTicket(key, config);
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
+ };
129
140
  }
130
141
  export async function createEpicFromFile(config, path) {
131
142
  return postEpic(await readEpicSpec(path), config);
@@ -187,14 +198,16 @@ export async function updateWorkspace(config, fields) {
187
198
  }
188
199
  export async function loadLiveEvents(config, board, ticketKey) {
189
200
  if (ticketKey)
190
- return (await listTicketEvents(ticketKey, undefined, undefined, config)).events
201
+ return (await listTicketRunEvents(ticketKey, undefined, undefined, config)).events
191
202
  .sort((left, right) => left.at.localeCompare(right.at) || left.seq - right.seq);
192
- const liveIds = new Set(board.runs.filter((run) => run.status === "running").map((run) => run.id));
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));
193
206
  const keys = new Set(board.runs
194
207
  .filter((run) => liveIds.has(run.id) && run.ticket_id)
195
208
  .map((run) => board.tickets.find((ticket) => ticket.id === run.ticket_id)?.key)
196
209
  .filter((key) => Boolean(key)));
197
- const batches = await Promise.all([...keys].map((key) => listTicketEvents(key, undefined, undefined, config)));
210
+ const batches = await Promise.all([...keys].map((key) => listTicketRunEvents(key, undefined, undefined, config)));
198
211
  return batches
199
212
  .flatMap((batch) => batch.events)
200
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,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
- return rest.length <= 1 ? { kind: "logs", key: rest[0]?.toUpperCase() ?? null }
98
- : { kind: "unknown", command: "logs takes one ticket key" };
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":
@@ -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 event of events) {
11
- const run = runs.get(event.run_id);
12
- if (!run)
13
- continue;
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
- const produced = transcriptLines(event);
20
- produced.forEach((line, index) => {
23
+ narrateEvents(events.filter((event) => event.run_id === run.runId), raw).forEach((line) => {
21
24
  lines.push({
22
- id: `${event.id}:${index}`,
23
- runId: event.run_id,
25
+ id: line.id,
26
+ sourceIds: [line.sourceId],
27
+ runId: run.runId,
24
28
  agent: run.agent,
25
- at: String(event.at),
26
- seq: event.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
- return lines;
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.map((line) => line.id));
44
- const fresh = incoming.filter((line) => !seen.has(line.id));
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 transcriptLines(event) {
86
- const payload = event.payload && typeof event.payload === "object" && !Array.isArray(event.payload)
87
- ? event.payload
88
- : {};
89
- const text = [payload.text, payload.message, payload.summary, payload.command, payload.path]
90
- .find((value) => typeof value === "string");
91
- const title = text?.trim() || JSON.stringify(event.payload) || event.type;
92
- if (!title)
93
- return [];
94
- const kind = event.type === "error"
95
- ? "error"
96
- : ["tool_use", "tool_result", "command", "file_changed"].includes(event.type)
97
- ? "tool"
98
- : event.type === "status"
99
- ? "status"
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
- if (run.status !== "running")
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;