@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.
package/README.md CHANGED
@@ -25,7 +25,7 @@ workspace-map config shapes are migrated automatically when they are read.
25
25
  | `hd --help` | Show the HigherDEV banner and usage |
26
26
  | `hd status` | Show workspace, ticket, run, and decision status |
27
27
  | `hd ticket list` | List tickets |
28
- | `hd ticket show KEY` | Show one ticket |
28
+ | `hd ticket show KEY [--json]` | Show one ticket (body, acceptance, timeline, messages, PR, runs, decisions) |
29
29
  | `hd ticket new [PATH.md]` | Create a ticket from the guided form or a Markdown spec |
30
30
  | `hd ticket new --title TITLE [--acceptance TEXT] [options]` | Create a ticket non-interactively |
31
31
  | `hd ticket queue KEY` | Queue a complete ticket now |
package/dist/api.js CHANGED
@@ -58,14 +58,19 @@ export async function queueTicket(key, config = loadConfig()) {
58
58
  export async function cancelTicket(key, config = loadConfig()) {
59
59
  return request(config, "POST", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/cancel`);
60
60
  }
61
- export async function listTicketEvents(key, afterAt, afterId, config = loadConfig()) {
61
+ export async function listTicketRunEvents(key, afterAt, afterId, config = loadConfig()) {
62
62
  const query = new URLSearchParams();
63
63
  if (afterAt)
64
64
  query.set("after_at", afterAt);
65
65
  if (afterId)
66
66
  query.set("after_id", afterId);
67
- const suffix = query.size ? `?${query.toString()}` : "";
68
- return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events${suffix}`);
67
+ const suffix = query.size ? `&${query.toString()}` : "";
68
+ const result = await request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events?stream=1${suffix}`);
69
+ const runs = new Map((result.runs ?? []).map((run) => [run.id, run]));
70
+ return { ...result, events: result.events.map((event) => ({ ...event, run: runs.get(event.run_id) })) };
71
+ }
72
+ export async function listTicketStory(key, config = loadConfig()) {
73
+ return request(config, "GET", `/api/w/${config.slug}/tickets/${encodeURIComponent(key)}/events`);
69
74
  }
70
75
  export async function postMessage(fields, config = loadConfig()) {
71
76
  return request(config, "POST", `/api/w/${config.slug}/messages`, fields);
package/dist/index.js CHANGED
@@ -1,13 +1,14 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
3
  import { pathToFileURL } from "node:url";
4
- import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
4
+ import { approveEpic, answerDecision, cancelTicket, createEpic, deleteAgent, deleteEpic, getStatus, getWorkspace, listAgents, listEpics, listHostEnv, listMessages, listWorkspaceEnv, listTicketRunEvents, listTickets, listWorkspaces, postMessage, queueTicket, removeWorkspaceEnv, removeHostEnv, setPaused, setWorkspaceEnv, setHostEnv, showTicket, updateAgent, updateCaps, } from "./api.js";
5
5
  import { initHost, parseHostFlags } from "./host.js";
6
6
  import { login, parseLoginFlags } from "./login.js";
7
7
  import { loadConfig } from "./config.js";
8
8
  import { epicProgressRows, readEpicSpec } from "./epics.js";
9
9
  import { banner, c, statusChip, table, truncate, usage } from "./out.js";
10
10
  import { ticketNew } from "./ticket-commands.js";
11
+ import { ticketViewLines } from "./tui/ticket-view.js";
11
12
  import { agentAdd, AGENT_ADD_USAGE } from "./agent-commands.js";
12
13
  import { WORKSPACE_USAGE, workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet, workspaceUse } from "./workspace-commands.js";
13
14
  import { defaultGh, hasWriteRepoPermission, HDX_RUNNER_GH_USER, requireAdminRepo, runnerRepoPermission } from "./workspace-preflight.js";
@@ -54,7 +55,8 @@ function printTickets(tickets) {
54
55
  }
55
56
  console.log(table(["KEY", "STATUS", "PROVIDER", "TITLE", "WHY"], tickets.map((ticket) => [
56
57
  c.bold(ticket.key), statusChip(ticket.status), ticket.provider ?? c.dim("-"),
57
- truncate(ticket.title, 48), ticket.stuck_reason ? c.yellow(truncate(ticket.stuck_reason, 40)) : "",
58
+ truncate(ticket.title, 48), ticket.latest_headline ? c.yellow(truncate(ticket.latest_headline, 40))
59
+ : ticket.stuck_reason ? c.yellow(truncate(ticket.stuck_reason, 40)) : "",
58
60
  ])));
59
61
  }
60
62
  async function cmdStatus() {
@@ -85,23 +87,31 @@ async function cmdTicket(argv, deps = {}) {
85
87
  return;
86
88
  }
87
89
  if (action === "show") {
88
- const key = rest[0];
89
- if (!key)
90
- fail("usage: hd ticket show KEY");
91
- const { ticket } = await showTicket(key);
92
- console.log(`${c.bold(ticket.key)} ${statusChip(ticket.status)} ${ticket.title}`);
93
- if (ticket.stuck_reason)
94
- console.log(`stuck: ${ticket.stuck_reason}`);
95
- if (ticket.provider)
96
- console.log(`provider: ${ticket.provider}`);
97
- if (ticket.area)
98
- console.log(`area: ${ticket.area}`);
99
- if (ticket.pr_url)
100
- console.log(`pr: ${ticket.pr_url}`);
101
- if (ticket.branch)
102
- console.log(`branch: ${ticket.branch}`);
103
- if (ticket.body_md)
104
- console.log(`\n${ticket.body_md}`);
90
+ const parsed = flags(rest);
91
+ const key = parsed.rest[0];
92
+ if (!key || parsed.rest.length !== 1)
93
+ fail("usage: hd ticket show KEY [--json]");
94
+ const data = await showTicket(key.toUpperCase());
95
+ if (parsed.bools.has("json")) {
96
+ console.log(JSON.stringify(data));
97
+ return;
98
+ }
99
+ const { ticket, pr, events, runs, messages, decisions } = data;
100
+ const width = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 80;
101
+ for (const line of ticketViewLines({
102
+ ...ticket,
103
+ stuck: ticket.stuck_reason,
104
+ pr,
105
+ timeline: events,
106
+ messages,
107
+ runs: runs.map((run) => ({
108
+ id: run.id, kind: run.kind, provider: run.provider, status: run.status,
109
+ summary: run.summary, elapsed_ms: run.elapsed_ms,
110
+ })),
111
+ decisions,
112
+ }, width)) {
113
+ console.log(line.text);
114
+ }
105
115
  return;
106
116
  }
107
117
  if (action === "new") {
@@ -182,7 +192,7 @@ async function cmdLogs(argv) {
182
192
  let afterId;
183
193
  const follow = bools.has("follow");
184
194
  async function tick() {
185
- const { events } = await listTicketEvents(key, afterAt, afterId);
195
+ const { events } = await listTicketRunEvents(key, afterAt, afterId);
186
196
  let printed = false;
187
197
  for (const event of events) {
188
198
  if (seen.has(event.id))
package/dist/out.js CHANGED
@@ -64,7 +64,7 @@ export function usage() {
64
64
  return [
65
65
  c.bold("Usage"),
66
66
  ` ${c.blue("hd status")} workspace overview`,
67
- ` ${c.blue("hd ticket list | show | new [PATH] | queue | cancel")} ticket operations`,
67
+ ` ${c.blue("hd ticket list | show KEY [--json] | new [PATH] | queue | cancel")} ticket operations`,
68
68
  ` ${c.blue("hd epic new PATH | list | approve | rm")} epic operations`,
69
69
  ` ${c.blue("hd plan")} use /architect in the TUI`,
70
70
  ` ${c.blue("hd workspace ls | use | new | set | rotate-key | grant-runner-access")} workspace operations`,
package/dist/tui/App.js CHANGED
@@ -13,6 +13,7 @@ import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js
13
13
  import { DecisionPanel, decisionRows } from "./Decision.js";
14
14
  import { COMMANDS, Help } from "./Help.js";
15
15
  import { AgentsPanel, BoardPanel, FeedPanel, InboxPanel, TicketPanel, inboxEntries } from "./Panels.js";
16
+ import { sectionAt, ticketScrollOffset, ticketViewLines, toggleSection } from "./ticket-view.js";
16
17
  import { SettingsPanel } from "./Settings.js";
17
18
  import { Splash } from "./Splash.js";
18
19
  import TextInput from "./TextInput.js";
@@ -22,6 +23,7 @@ import { planLayout, splitPanels } from "./layout.js";
22
23
  import { parseLine } from "./parse.js";
23
24
  import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, waitForReply, } from "./data.js";
24
25
  import { inputActive, promptPlaceholder, QUEUED_STEP, REPLY_WAIT_MS, settleChatReply } from "./chat-wait.js";
26
+ import { answeredLine, decisionHeaderIndex, decisionIdAt, moveDecisionFocus, nextUnanswered, resolveDecisionAnswer, } from "./decide-nav.js";
25
27
  import { EARLIER_PAGE } from "./inbox.js";
26
28
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
27
29
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
@@ -48,14 +50,20 @@ export function App({ initial }) {
48
50
  const [busy, setBusy] = useState(false);
49
51
  const [notice, setNotice] = useState(null);
50
52
  const [ticketKey, setTicketKey] = useState(null);
53
+ const [ticketOffset, setTicketOffset] = useState(0);
54
+ const [ticketCollapsed, setTicketCollapsed] = useState([]);
51
55
  const [ready, setReady] = useState(false);
52
56
  const [stream, setStream] = useState([]);
53
57
  const [logsFilter, setLogsFilter] = useState(null);
58
+ const [rawLogs, setRawLogs] = useState(false);
54
59
  const [cursor, setCursor] = useState(null);
55
60
  const [started, setStarted] = useState(false);
56
61
  const [field, setField] = useState(null);
57
62
  const [editing, setEditing] = useState(null);
58
63
  const [inboxFocus, setInboxFocus] = useState(0);
64
+ const [selectedDecisionId, setSelectedDecisionId] = useState(initial.board.decisions[0]?.id ?? null);
65
+ const [answering, setAnswering] = useState(null);
66
+ const focusAfterAnswer = useRef(null);
59
67
  const [earlierLimit, setEarlierLimit] = useState(EARLIER_PAGE);
60
68
  const earlierLimitRef = useRef(EARLIER_PAGE);
61
69
  earlierLimitRef.current = earlierLimit;
@@ -94,7 +102,9 @@ export function App({ initial }) {
94
102
  if (!loads.current.isCurrent(token))
95
103
  return;
96
104
  setWorkspace(snapshot.workspace);
97
- setBoard(snapshot.board);
105
+ setBoard((current) => ({ ...snapshot.board, tickets: snapshot.board.tickets.map((ticket) => ({
106
+ ...ticket, timeline: current.tickets.find((row) => row.id === ticket.id)?.timeline,
107
+ })) }));
98
108
  setFeed(snapshot.feed);
99
109
  }, []);
100
110
  useEffect(() => {
@@ -119,13 +129,17 @@ export function App({ initial }) {
119
129
  if (logsFilter)
120
130
  for (const event of events)
121
131
  if (!eventLabels.has(event.run_id)) {
122
- eventLabels.set(event.run_id, { runId: event.run_id, agent: logsFilter, ticket: logsFilter });
132
+ const run = event.run;
133
+ eventLabels.set(event.run_id, { runId: event.run_id, agent: run?.agent_name ?? logsFilter,
134
+ ticket: logsFilter, kind: run?.kind ?? "run", status: run?.status ?? "running",
135
+ attempt: run?.attempt ?? 1, startedAt: run?.started_at ?? event.at,
136
+ endedAt: run?.ended_at ?? null, summary: run?.summary ?? null });
123
137
  }
124
- setStream((prior) => appendLines(prior, toStreamLines(events, eventLabels)));
138
+ setStream((prior) => appendLines(prior, toStreamLines(events, eventLabels, rawLogs)));
125
139
  })
126
140
  .catch(() => { });
127
- }, [liveRunIds, board, config, labels, workspace.id, logsFilter]);
128
- useEffect(() => setStream([]), [logsFilter]);
141
+ }, [liveRunIds, board, config, labels, workspace.id, logsFilter, rawLogs]);
142
+ useEffect(() => setStream([]), [logsFilter, rawLogs]);
129
143
  const say = useCallback((speaker, body, steps) => {
130
144
  setMessages((prior) => [...prior, { id: nextId(), speaker, body, steps, done: true }]);
131
145
  }, []);
@@ -134,7 +148,7 @@ export function App({ initial }) {
134
148
  const settingsOrder = useMemo(() => editableKeys(settings), [settings]);
135
149
  const browsing = mode === "browse" && draft.length === 0 && cursor !== null;
136
150
  const configuring = view === "settings" && !editing;
137
- const inbox = useMemo(() => inboxEntries(board, width), [board, width]);
151
+ const inbox = useMemo(() => inboxEntries(board, width, answering), [board, width, answering]);
138
152
  const moveCursor = useCallback((delta) => {
139
153
  if (!order.length)
140
154
  return false;
@@ -154,11 +168,32 @@ export function App({ initial }) {
154
168
  return true;
155
169
  }, [settingsOrder]);
156
170
  const moveInbox = useCallback((delta) => {
157
- setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current + delta)));
158
- }, [inbox.length]);
171
+ setInboxFocus((current) => {
172
+ const next = moveDecisionFocus(inbox, current, delta);
173
+ const id = decisionIdAt(inbox, next);
174
+ if (id)
175
+ setSelectedDecisionId(id);
176
+ return next;
177
+ });
178
+ }, [inbox]);
159
179
  useEffect(() => {
160
180
  setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current)));
161
181
  }, [inbox.length]);
182
+ useEffect(() => {
183
+ if (selectedDecisionId && board.decisions.some((decision) => decision.id === selectedDecisionId))
184
+ return;
185
+ setSelectedDecisionId(board.decisions[0]?.id ?? null);
186
+ setAnswering((current) => (current && board.decisions.some((decision) => decision.id === current) ? current : null));
187
+ }, [board.decisions, selectedDecisionId]);
188
+ useEffect(() => {
189
+ const id = focusAfterAnswer.current;
190
+ if (!id)
191
+ return;
192
+ const header = decisionHeaderIndex(inbox, id);
193
+ if (header >= 0)
194
+ setInboxFocus(header);
195
+ focusAfterAnswer.current = null;
196
+ }, [inbox]);
162
197
  useEffect(() => {
163
198
  if (view !== "inbox")
164
199
  return;
@@ -175,6 +210,38 @@ export function App({ initial }) {
175
210
  const refresh = useCallback(async () => {
176
211
  await refreshRef.current?.();
177
212
  }, []);
213
+ const submitDecision = useCallback(async (id, text) => {
214
+ const decision = board.decisions.find((row) => row.id === id);
215
+ if (!decision) {
216
+ setNotice("Nothing is waiting on a decision.");
217
+ setAnswering(null);
218
+ return;
219
+ }
220
+ const answer = resolveDecisionAnswer(decision, text);
221
+ if (!answer) {
222
+ setNotice("Type a number or your answer.");
223
+ return;
224
+ }
225
+ const next = nextUnanswered(board.decisions, decision.id);
226
+ const line = answeredLine(board.decisions, board.tickets, decision, answer);
227
+ setBusy(true);
228
+ try {
229
+ await resolveDecision(config, decision.id, answer);
230
+ say("system", line);
231
+ setAnswering(null);
232
+ setSelectedDecisionId(next?.id ?? null);
233
+ focusAfterAnswer.current = next?.id ?? null;
234
+ if (next)
235
+ setView("inbox");
236
+ await refresh();
237
+ }
238
+ catch (error) {
239
+ setNotice(error instanceof Error ? error.message : String(error));
240
+ }
241
+ finally {
242
+ setBusy(false);
243
+ }
244
+ }, [board, config, refresh, say]);
178
245
  const applyEdit = useCallback(async (key, raw) => {
179
246
  const row = settings.find((entry) => entry.key === key);
180
247
  if (!row)
@@ -217,6 +284,8 @@ export function App({ initial }) {
217
284
  setStream([]);
218
285
  setLogsFilter(null);
219
286
  acknowledgedMessages.current.clear();
287
+ setAnswering(null);
288
+ setSelectedDecisionId(snapshot.board.decisions[0]?.id ?? null);
220
289
  setEarlierLimit(EARLIER_PAGE);
221
290
  earlierLimitRef.current = EARLIER_PAGE;
222
291
  setCursor(null);
@@ -254,6 +323,33 @@ export function App({ initial }) {
254
323
  }
255
324
  })();
256
325
  }, [config]);
326
+ const openTicket = useCallback(async (key) => {
327
+ setTicketKey(key);
328
+ setView("ticket");
329
+ setTicketOffset(0);
330
+ setTicketCollapsed([]);
331
+ setBusy(true);
332
+ try {
333
+ const { ticket: detail } = await loadTicketDetail(config, key);
334
+ setBoard((prior) => ({ ...prior,
335
+ tickets: prior.tickets.map((ticket) => ticket.id === detail.id ? { ...ticket, ...detail } : ticket) }));
336
+ }
337
+ catch (error) {
338
+ setNotice(error instanceof Error ? error.message : String(error));
339
+ }
340
+ finally {
341
+ setBusy(false);
342
+ }
343
+ }, [config]);
344
+ const visibleStory = ticketKey ? board.tickets.find((ticket) => ticket.key === ticketKey) : null;
345
+ useEffect(() => {
346
+ if (view !== "ticket" || !ticketKey || !visibleStory?.latest_headline
347
+ || visibleStory.timeline?.[0]?.headline === visibleStory.latest_headline)
348
+ return;
349
+ void loadTicketDetail(config, ticketKey).then(({ ticket: detail }) => setBoard((prior) => ({ ...prior,
350
+ tickets: prior.tickets.map((ticket) => ticket.id === detail.id ? { ...ticket, ...detail } : ticket) })))
351
+ .catch((error) => setNotice(error instanceof Error ? error.message : String(error)));
352
+ }, [view, ticketKey, visibleStory?.latest_headline, visibleStory?.timeline, config]);
257
353
  const run = useCallback(async (raw) => {
258
354
  const text = raw.trim();
259
355
  if (view === "settings") {
@@ -280,13 +376,39 @@ export function App({ initial }) {
280
376
  }
281
377
  }
282
378
  if (!text) {
379
+ if (answering) {
380
+ setNotice("Type a number or your answer.");
381
+ return;
382
+ }
383
+ if (view === "ticket" && ticketKey) {
384
+ const current = board.tickets.find((row) => row.key === ticketKey);
385
+ if (current) {
386
+ const lines = ticketViewLines(current, Math.max(20, width), ticketCollapsed);
387
+ setTicketCollapsed(toggleSection(ticketCollapsed, sectionAt(lines, ticketOffset)));
388
+ }
389
+ return;
390
+ }
391
+ const focusedId = selectedDecisionId ?? decisionIdAt(inbox, inboxFocus);
392
+ if (view === "inbox" && focusedId && board.decisions.some((decision) => decision.id === focusedId)) {
393
+ setSelectedDecisionId(focusedId);
394
+ setAnswering(focusedId);
395
+ setView("inbox");
396
+ return;
397
+ }
283
398
  const selected = board.tickets.find((ticket) => ticket.id === selectedRef.current);
284
399
  if (browsing && selected) {
285
- setTicketKey(selected.key);
286
- setView("ticket");
400
+ await openTicket(selected.key);
287
401
  }
288
402
  return;
289
403
  }
404
+ if (answering) {
405
+ history.current.push(text);
406
+ historyAt.current = -1;
407
+ setDraft("");
408
+ setNotice(null);
409
+ await submitDecision(answering, text);
410
+ return;
411
+ }
290
412
  history.current.push(text);
291
413
  historyAt.current = -1;
292
414
  setDraft("");
@@ -311,8 +433,11 @@ export function App({ initial }) {
311
433
  return;
312
434
  case "view":
313
435
  setView(action.view);
314
- if (action.view === "inbox")
315
- setInboxFocus(0);
436
+ if (action.view === "inbox") {
437
+ const header = selectedDecisionId ? decisionHeaderIndex(inbox, selectedDecisionId) : 0;
438
+ setInboxFocus(header >= 0 ? header : 0);
439
+ setAnswering(null);
440
+ }
316
441
  if (action.view === "board" && order.length) {
317
442
  const next = selectedRef.current && order.includes(selectedRef.current) ? selectedRef.current : order[0];
318
443
  selectedRef.current = next;
@@ -398,22 +523,7 @@ export function App({ initial }) {
398
523
  }
399
524
  return;
400
525
  case "ticket":
401
- setTicketKey(action.key);
402
- setView("ticket");
403
- setBusy(true);
404
- try {
405
- const { ticket: detail } = await loadTicketDetail(config, action.key);
406
- setBoard((prior) => ({
407
- ...prior,
408
- tickets: prior.tickets.map((ticket) => ticket.id === detail.id ? { ...ticket, ...detail } : ticket),
409
- }));
410
- }
411
- catch (error) {
412
- setNotice(error instanceof Error ? error.message : String(error));
413
- }
414
- finally {
415
- setBusy(false);
416
- }
526
+ await openTicket(action.key);
417
527
  return;
418
528
  case "epic-new":
419
529
  setBusy(true);
@@ -574,10 +684,11 @@ export function App({ initial }) {
574
684
  return;
575
685
  case "logs":
576
686
  setLogsFilter(action.key);
577
- say("system", action.key ? `Activity filtered to ${action.key}.` : "Activity filter cleared.");
687
+ setRawLogs(action.raw);
688
+ say("system", `${action.raw ? "Raw" : "Narrated"} activity${action.key ? ` filtered to ${action.key}` : " filter cleared"}.`);
578
689
  return;
579
690
  case "decide": {
580
- const focused = inbox[inboxFocus]?.key.split(":")[0] ?? null;
691
+ const focused = selectedDecisionId ?? decisionIdAt(inbox, inboxFocus);
581
692
  const decision = selectDecision(board.decisions, action.target, focused);
582
693
  if (!decision) {
583
694
  setNotice(action.target ? `No decision matches ${action.target}.` : "Nothing is waiting on a decision.");
@@ -587,25 +698,16 @@ export function App({ initial }) {
587
698
  setNotice("Skipping decisions is not available through the HDX API. Answer it instead.");
588
699
  return;
589
700
  }
590
- const options = decisionOptions(decision);
591
- const option = /^\d+$/.test(action.answer) ? options[Number(action.answer) - 1] : undefined;
592
- const answer = option ?? action.answer;
593
- if (!answer) {
594
- setNotice("Answer it with /decide 1 or /decide <your answer>.");
701
+ if (!action.answer) {
702
+ setSelectedDecisionId(decision.id);
703
+ setAnswering(decision.id);
704
+ setView("inbox");
705
+ const header = decisionHeaderIndex(inbox, decision.id);
706
+ if (header >= 0)
707
+ setInboxFocus(header);
595
708
  return;
596
709
  }
597
- setBusy(true);
598
- try {
599
- await resolveDecision(config, decision.id, answer);
600
- say("system", `Answered: ${answer}`);
601
- await refresh();
602
- }
603
- catch (error) {
604
- setNotice(error instanceof Error ? error.message : String(error));
605
- }
606
- finally {
607
- setBusy(false);
608
- }
710
+ await submitDecision(decision.id, action.answer);
609
711
  return;
610
712
  }
611
713
  case "help":
@@ -635,12 +737,16 @@ export function App({ initial }) {
635
737
  return;
636
738
  }
637
739
  }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, order, settingsOrder, changeWorkspace,
638
- config, refresh, suspendTerminal, exit, inbox, inboxFocus]);
740
+ config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering, selectedDecisionId,
741
+ submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
639
742
  useInput((input, key) => {
640
743
  if (key.ctrl && input === "c")
641
744
  exit();
642
745
  });
643
746
  const decisions = board.decisions;
747
+ const answeringDecision = answering ? decisions.find((decision) => decision.id === answering) : null;
748
+ const answeringOptions = answeringDecision ? decisionOptions(answeringDecision) : [];
749
+ const answeringNumber = answeringDecision ? decisions.findIndex((decision) => decision.id === answering) + 1 : 0;
644
750
  const waiting = decisions.length;
645
751
  const announced = useRef(0);
646
752
  useEffect(() => {
@@ -671,13 +777,21 @@ export function App({ initial }) {
671
777
  if (item.message.panel === "help")
672
778
  return _jsx(Help, { width: width }, item.key);
673
779
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
674
- } }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus }) : null, view === "ticket" && plan.panels > 0 ? ticket
675
- ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels })
676
- : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, inFlight.map((message) => _jsx(Bubble, { message: message, width: width }, message.id)), _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter ? _jsxs(Text, { color: UI.warn, children: ["logs ", logsFilter, " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", mode, view === "inbox" ? " ↑↓ scroll" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
780
+ } }), splash ? (_jsx(Splash, { columns: columns, rows: rows, width: width, ready: ready, helpFull: plan.helpFull, animate: plan.fits, onDone: () => setReady(true) })) : null, _jsxs(Box, { flexDirection: "column", width: width, children: [view === "board" && plan.panels > 0 ? _jsx(BoardPanel, { board: board, width: width, rows: plan.panels, cursor: cursor }) : null, view === "agents" && plan.panels > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(AgentsPanel, { board: board, width: width, rows: agentsView.top }), agentsView.bottom > 0 ? _jsxs(_Fragment, { children: [_jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: agentsView.bottom, live: running > 0 })] }) : null] })) : null, view === "feed" && plan.panels > 0 ? _jsx(FeedPanel, { entries: feed, width: width, rows: plan.panels }) : null, view === "settings" && plan.panels > 0 ? _jsx(SettingsPanel, { entries: settings, width: width, rows: plan.panels, title: "Settings", cursor: field, editing: editing }) : null, view === "inbox" && plan.panels > 0 ? _jsx(InboxPanel, { board: board, width: width, rows: plan.panels, focus: inboxFocus, selectedId: selectedDecisionId, answeringId: answering }) : null, view === "ticket" && plan.panels > 0 ? ticket
781
+ ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels, offset: ticketOffset, collapsed: ticketCollapsed })
782
+ : _jsxs(Text, { color: UI.warn, children: ["No ticket ", ticketKey, " here."] }) : null, view === "home" && plan.cockpit > 0 ? (_jsxs(Box, { flexDirection: "column", children: [_jsx(Cockpit, { board: board, width: width, rows: plan.cockpit, cursor: cursor }), _jsx(Box, { height: 1 }), _jsx(StreamPanel, { lines: stream, width: width, rows: plan.stream, live: running > 0 })] })) : null, inFlight.map((message) => _jsx(Bubble, { message: message, width: width }, message.id)), _jsx(DecisionPanel, { decisions: decisions, board: board, width: width, rows: plan.decision, selectedId: selectedDecisionId }), notice ? _jsx(Box, { marginBottom: 1, children: _jsx(Text, { color: UI.warn, children: notice }) }) : null, _jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: UI.text, bold: true, children: workspace.slug }), _jsxs(Text, { color: UI.dim, children: [" ", workspace.repo, " "] }), _jsx(Text, { color: live === "live" ? UI.accent : UI.dim, children: "\u25CF " }), _jsxs(Text, { color: UI.dim, children: [live === "live" ? "5s poll" : live, " "] }), _jsx(Text, { color: UI.dim, children: running ? `${running} running ` : "" }), board.decisions.length ? _jsxs(Text, { color: UI.warn, children: [board.decisions.length, " decisions "] }) : null, workspace.paused ? _jsx(Text, { color: UI.warn, children: "paused " }) : null, logsFilter || rawLogs ? _jsxs(Text, { color: UI.warn, children: ["logs ", rawLogs ? "raw " : "", logsFilter ?? "all", " "] }) : null, _jsxs(Text, { color: UI.dim, wrap: "truncate", children: ["\u00B7 ", mode, answering ? " esc cancels" : view === "ticket" ? " ↑↓ scroll · pgup/pgdn · enter folds" : view === "inbox" ? " ↑↓ decisions · enter answers" : cursor && selected ? ` ${selected.key} ↑↓ move · enter opens · esc leaves` : ""] })] }), _jsx(Box, { children: _jsx(TextInput, { value: draft, onChange: (next) => {
677
783
  setDraft(next);
678
784
  if (editingRef.current)
679
785
  setEditing({ key: editingRef.current.key, draft: next });
680
- }, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: mode === "browse" ? UI.dim : UI.cream, children: mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
786
+ }, onSubmit: (value) => void run(value), isActive: inputActive({ busy, pendingChats: inFlight.length }), placeholder: answering
787
+ ? (answeringOptions.length ? `1-${answeringOptions.length} or your answer` : "your answer")
788
+ : promptPlaceholder({ busy, pendingChats: inFlight.length }), prompt: _jsx(Text, { color: answering || mode !== "browse" ? UI.cream : UI.dim, children: answering ? `answer ${answeringNumber}> ` : mode === "browse" ? "> " : `${mode}> ` }), color: UI.text, onCancel: () => {
789
+ if (answering) {
790
+ setAnswering(null);
791
+ setDraft("");
792
+ setNotice(null);
793
+ return;
794
+ }
681
795
  if (editing) {
682
796
  setEditing(null);
683
797
  setDraft("");
@@ -692,6 +806,11 @@ export function App({ initial }) {
692
806
  setCursor(null);
693
807
  selectedRef.current = null;
694
808
  }, onUp: () => {
809
+ if (view === "ticket" && !draft) {
810
+ const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
811
+ setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - 1));
812
+ return;
813
+ }
695
814
  if (view === "inbox" && !draft) {
696
815
  moveInbox(-1);
697
816
  return;
@@ -705,6 +824,11 @@ export function App({ initial }) {
705
824
  historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
706
825
  setDraft(history.current[historyAt.current] ?? "");
707
826
  }, onDown: () => {
827
+ if (view === "ticket" && !draft) {
828
+ const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
829
+ setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + 1));
830
+ return;
831
+ }
708
832
  if (view === "inbox" && !draft) {
709
833
  moveInbox(1);
710
834
  return;
@@ -722,6 +846,16 @@ export function App({ initial }) {
722
846
  return;
723
847
  }
724
848
  setDraft(history.current[historyAt.current] ?? "");
849
+ }, onPageUp: () => {
850
+ if (view === "ticket" && !draft) {
851
+ const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
852
+ setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current - Math.max(1, plan.panels)));
853
+ }
854
+ }, onPageDown: () => {
855
+ if (view === "ticket" && !draft) {
856
+ const count = ticket ? ticketViewLines(ticket, Math.max(20, width), ticketCollapsed).length : 0;
857
+ setTicketOffset((current) => ticketScrollOffset(count, Math.max(1, plan.panels), current + Math.max(1, plan.panels)));
858
+ }
725
859
  } }) })] })] }));
726
860
  }
727
861
  export { COMMANDS };
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  import { Box, Text } from "ink";
3
3
  import { BOARD_COLUMNS, epicProgressCaption, epicProgress, statusLabel, statusTone, } from "./data.js";
4
4
  import { elapsed, truncate } from "../out/format.js";
@@ -103,7 +103,7 @@ export function BoardColumn({ board, width, rows, cursor, }) {
103
103
  ? [
104
104
  _jsx(Text, { color: UI.dim, children: "No tickets yet." }, "empty"),
105
105
  ]
106
- : entries.slice(start, end).map((entry) => entry.kind === "heading" ? (_jsxs(Text, { color: inkColor(statusTone(entry.status)), wrap: "truncate", children: [statusLabel(entry.status), " ", _jsxs(Text, { color: UI.dim, children: ["(", entry.count, ")"] })] }, entry.key)) : (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: UI.accent, children: entry.key === cursor ? "›" : " " }) }), _jsx(Box, { width: 8, flexShrink: 0, children: _jsx(Text, { color: UI.text, bold: true, wrap: "truncate", children: entry.ticket.key }) }), _jsx(Text, { color: entry.ticket.stuck ? UI.warn : UI.text, inverse: entry.key === cursor, wrap: "truncate", children: truncate(entry.ticket.title, title - 2) })] }, entry.key)))),
106
+ : entries.slice(start, end).map((entry) => entry.kind === "heading" ? (_jsxs(Text, { color: inkColor(statusTone(entry.status)), wrap: "truncate", children: [statusLabel(entry.status), " ", _jsxs(Text, { color: UI.dim, children: ["(", entry.count, ")"] })] }, entry.key)) : (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: 2, flexShrink: 0, children: _jsx(Text, { color: UI.accent, children: entry.key === cursor ? "›" : " " }) }), _jsx(Box, { width: 8, flexShrink: 0, children: _jsx(Text, { color: UI.text, bold: true, wrap: "truncate", children: entry.ticket.key }) }), _jsx(Text, { color: entry.ticket.stuck ? UI.warn : UI.text, inverse: entry.key === cursor, wrap: "truncate", children: truncate(entry.ticket.latest_headline ?? entry.ticket.title, title - 2) })] }, entry.key)))),
107
107
  ] }));
108
108
  }
109
109
  export function AgentsColumn({ board, width, rows, }) {
@@ -127,9 +127,10 @@ export function AgentsColumn({ board, width, rows, }) {
127
127
  }
128
128
  const KIND_COLOR = {
129
129
  error: UI.danger,
130
+ agent: UI.accent,
130
131
  tool: UI.dim,
131
132
  status: UI.dim,
132
- text: UI.text,
133
+ raw: UI.dim,
133
134
  };
134
135
  /**
135
136
  * What the agents are doing, as it arrives. The panel is a fixed height on
@@ -146,7 +147,7 @@ export function StreamPanel({ lines, width, rows, live, }) {
146
147
  _jsx(Text, { color: UI.dim, children: live ? "Waiting for the first step." : "Nothing running." }, "empty"),
147
148
  ]
148
149
  : []),
149
- ...lines.slice(-budget).map((line) => (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: name, flexShrink: 0, children: _jsx(Text, { color: UI.dim, wrap: "truncate", children: truncate(line.agent, name - 1) }) }), _jsxs(Text, { color: KIND_COLOR[line.kind], wrap: "truncate", children: [line.kind === "tool" ? "· " : "", truncate(line.title, Math.max(12, width - name - 3))] })] }, line.id))),
150
+ ...lines.slice(-budget).map((line) => (_jsxs(Box, { flexWrap: "nowrap", children: [_jsx(Box, { width: name, flexShrink: 0, children: _jsx(Text, { color: UI.dim, wrap: "truncate", children: truncate(line.agent, name - 1) }) }), _jsxs(Text, { color: KIND_COLOR[line.kind], wrap: "truncate", children: [line.kind === "tool" ? "· " : "", truncate(line.title, Math.max(12, width - name - 3)), line.count && line.count > 1 ? ` (x${line.count})` : ""] })] }, line.id))),
150
151
  ] }));
151
152
  }
152
153
  /** How many rows the epics strip wants, heading included, or none. */
@@ -154,6 +155,21 @@ export function epicsRows(board, cap = 3) {
154
155
  const open = epicProgress(board).filter((row) => row.epic.status !== "done");
155
156
  return open.length === 0 ? 0 : Math.min(open.length, cap) + 1;
156
157
  }
158
+ export function nowEntries(board) {
159
+ return board.tickets.filter((ticket) => !["backlog", "merged", "cancelled"].includes(ticket.status))
160
+ .map((ticket) => ({ key: ticket.key, headline: ticket.latest_headline ?? `${ticket.key}: ${ticket.title}` }));
161
+ }
162
+ function NowStrip({ board, width, rows }) {
163
+ const entries = nowEntries(board);
164
+ if (!entries.length || rows < 2)
165
+ return null;
166
+ const shown = entries.slice(0, contentRows(rows, entries.length));
167
+ return _jsx(Panel, { width: width, rows: rows, children: [
168
+ _jsx(Heading, { text: "Now", note: `${entries.length}` }, "h"),
169
+ ...shown.map((entry) => _jsx(Text, { color: UI.text, wrap: "truncate", children: entry.headline }, entry.key)),
170
+ _jsx(More, { count: entries.length - shown.length }, "more"),
171
+ ] });
172
+ }
157
173
  /**
158
174
  * Epic progress, above the board it explains. Capped hard: this is the summary
159
175
  * line, and the tickets underneath are what you are here to read.
@@ -174,12 +190,13 @@ export function EpicsStrip({ board, width, rows }) {
174
190
  export function Cockpit({ board, width, rows, cursor, }) {
175
191
  // The epics strip is spent out of the same budget, so adding it shortens the
176
192
  // board rather than making the frame taller.
177
- const epics = Math.min(epicsRows(board), Math.max(0, rows - 4));
178
- const rest = rows - (epics > 0 ? epics + 1 : 0);
193
+ const now = Math.min(nowEntries(board).length + 1, 4, Math.max(0, rows - 4));
194
+ const epics = Math.min(epicsRows(board), Math.max(0, rows - now - (now > 0 ? 1 : 0) - 4));
195
+ const rest = rows - (now > 0 ? now + 1 : 0) - (epics > 0 ? epics + 1 : 0);
179
196
  const columns = renderColumns(board, width, rest, cursor);
180
- if (epics === 0)
197
+ if (epics === 0 && now === 0)
181
198
  return columns;
182
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(EpicsStrip, { board: board, width: width, rows: epics }), _jsx(Box, { height: 1 }), columns] }));
199
+ return (_jsxs(Box, { flexDirection: "column", children: [now > 0 ? _jsxs(_Fragment, { children: [_jsx(NowStrip, { board: board, width: width, rows: now }), _jsx(Box, { height: 1 })] }) : null, epics > 0 ? _jsxs(_Fragment, { children: [_jsx(EpicsStrip, { board: board, width: width, rows: epics }), _jsx(Box, { height: 1 })] }) : null, columns] }));
183
200
  }
184
201
  function renderColumns(board, width, rows, cursor) {
185
202
  const split = splitWidths(width);