@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/dist/tui/App.js CHANGED
@@ -5,6 +5,7 @@ import { loadConfig } from "../config.js";
5
5
  import { epicProgressRows } from "../epics.js";
6
6
  import { promptOnStdin } from "../prompt.js";
7
7
  import { ticketNew } from "../ticket-commands.js";
8
+ import { agentAdd } from "../agent-commands.js";
8
9
  import { workspaceGrantRunnerAccess, workspaceNew, workspaceRotateKey, workspaceSet } from "../workspace-commands.js";
9
10
  import { Banner } from "./Banner.js";
10
11
  import { Bubble } from "./Bubble.js";
@@ -12,6 +13,7 @@ import { Cockpit, StreamPanel, boardTicketIds, nextCursor } from "./Dashboard.js
12
13
  import { DecisionPanel, decisionRows } from "./Decision.js";
13
14
  import { COMMANDS, Help } from "./Help.js";
14
15
  import { AgentsPanel, BoardPanel, FeedPanel, InboxPanel, TicketPanel, inboxEntries } from "./Panels.js";
16
+ import { sectionAt, ticketScrollOffset, ticketViewLines, toggleSection } from "./ticket-view.js";
15
17
  import { SettingsPanel } from "./Settings.js";
16
18
  import { Splash } from "./Splash.js";
17
19
  import TextInput from "./TextInput.js";
@@ -19,8 +21,10 @@ import { alertOnce } from "./alert.js";
19
21
  import { bubbleRows } from "./height.js";
20
22
  import { planLayout, splitPanels } from "./layout.js";
21
23
  import { parseLine } from "./parse.js";
22
- import { configuredSlugs, acknowledgeInbox, approveEpic, cancelTicket, createAgent, createEpicFromFile, decisionOptions, deleteAgent, deleteEpic, loadLiveEvents, listWorkspaceEnv, loadTicketDetail, pollSnapshot, postAgentMessage, postTicketMessage, queueTicket, resolveDecision, selectDecision, setWorkspacePaused, switchWorkspace, updateAgent, updateProviderCap, updateWorkspace, waitForReply, } from "./data.js";
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";
23
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";
27
+ import { EARLIER_PAGE } from "./inbox.js";
24
28
  import { editFor, editableKeys, nextValue, seedFor, settingsRows } from "./settings-model.js";
25
29
  import { appendLines, runLabels, toStreamLines } from "./stream.js";
26
30
  import { UI } from "./theme.js";
@@ -46,14 +50,23 @@ export function App({ initial }) {
46
50
  const [busy, setBusy] = useState(false);
47
51
  const [notice, setNotice] = useState(null);
48
52
  const [ticketKey, setTicketKey] = useState(null);
53
+ const [ticketOffset, setTicketOffset] = useState(0);
54
+ const [ticketCollapsed, setTicketCollapsed] = useState([]);
49
55
  const [ready, setReady] = useState(false);
50
56
  const [stream, setStream] = useState([]);
51
57
  const [logsFilter, setLogsFilter] = useState(null);
58
+ const [rawLogs, setRawLogs] = useState(false);
52
59
  const [cursor, setCursor] = useState(null);
53
60
  const [started, setStarted] = useState(false);
54
61
  const [field, setField] = useState(null);
55
62
  const [editing, setEditing] = useState(null);
56
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);
67
+ const [earlierLimit, setEarlierLimit] = useState(EARLIER_PAGE);
68
+ const earlierLimitRef = useRef(EARLIER_PAGE);
69
+ earlierLimitRef.current = earlierLimit;
57
70
  const selectedRef = useRef(null);
58
71
  const fieldRef = useRef(null);
59
72
  const editingRef = useRef(null);
@@ -89,12 +102,14 @@ export function App({ initial }) {
89
102
  if (!loads.current.isCurrent(token))
90
103
  return;
91
104
  setWorkspace(snapshot.workspace);
92
- 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
+ })) }));
93
108
  setFeed(snapshot.feed);
94
109
  }, []);
95
110
  useEffect(() => {
96
111
  setLive("connecting");
97
- const polling = pollSnapshot(config, applySnapshot, setLive, (error) => setNotice(error instanceof Error ? error.message : String(error)));
112
+ const polling = pollSnapshot(config, applySnapshot, setLive, (error) => setNotice(error instanceof Error ? error.message : String(error)), () => earlierLimitRef.current);
98
113
  refreshRef.current = polling.refresh;
99
114
  return polling.close;
100
115
  }, [config, applySnapshot]);
@@ -114,13 +129,17 @@ export function App({ initial }) {
114
129
  if (logsFilter)
115
130
  for (const event of events)
116
131
  if (!eventLabels.has(event.run_id)) {
117
- 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 });
118
137
  }
119
- setStream((prior) => appendLines(prior, toStreamLines(events, eventLabels)));
138
+ setStream((prior) => appendLines(prior, toStreamLines(events, eventLabels, rawLogs)));
120
139
  })
121
140
  .catch(() => { });
122
- }, [liveRunIds, board, config, labels, workspace.id, logsFilter]);
123
- useEffect(() => setStream([]), [logsFilter]);
141
+ }, [liveRunIds, board, config, labels, workspace.id, logsFilter, rawLogs]);
142
+ useEffect(() => setStream([]), [logsFilter, rawLogs]);
124
143
  const say = useCallback((speaker, body, steps) => {
125
144
  setMessages((prior) => [...prior, { id: nextId(), speaker, body, steps, done: true }]);
126
145
  }, []);
@@ -129,7 +148,7 @@ export function App({ initial }) {
129
148
  const settingsOrder = useMemo(() => editableKeys(settings), [settings]);
130
149
  const browsing = mode === "browse" && draft.length === 0 && cursor !== null;
131
150
  const configuring = view === "settings" && !editing;
132
- const inbox = useMemo(() => inboxEntries(board, width), [board, width]);
151
+ const inbox = useMemo(() => inboxEntries(board, width, answering), [board, width, answering]);
133
152
  const moveCursor = useCallback((delta) => {
134
153
  if (!order.length)
135
154
  return false;
@@ -149,11 +168,32 @@ export function App({ initial }) {
149
168
  return true;
150
169
  }, [settingsOrder]);
151
170
  const moveInbox = useCallback((delta) => {
152
- setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current + delta)));
153
- }, [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]);
154
179
  useEffect(() => {
155
180
  setInboxFocus((current) => Math.max(0, Math.min(inbox.length - 1, current)));
156
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]);
157
197
  useEffect(() => {
158
198
  if (view !== "inbox")
159
199
  return;
@@ -170,6 +210,38 @@ export function App({ initial }) {
170
210
  const refresh = useCallback(async () => {
171
211
  await refreshRef.current?.();
172
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]);
173
245
  const applyEdit = useCallback(async (key, raw) => {
174
246
  const row = settings.find((entry) => entry.key === key);
175
247
  if (!row)
@@ -212,6 +284,10 @@ export function App({ initial }) {
212
284
  setStream([]);
213
285
  setLogsFilter(null);
214
286
  acknowledgedMessages.current.clear();
287
+ setAnswering(null);
288
+ setSelectedDecisionId(snapshot.board.decisions[0]?.id ?? null);
289
+ setEarlierLimit(EARLIER_PAGE);
290
+ earlierLimitRef.current = EARLIER_PAGE;
215
291
  setCursor(null);
216
292
  selectedRef.current = null;
217
293
  setView("home");
@@ -247,6 +323,33 @@ export function App({ initial }) {
247
323
  }
248
324
  })();
249
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]);
250
353
  const run = useCallback(async (raw) => {
251
354
  const text = raw.trim();
252
355
  if (view === "settings") {
@@ -273,13 +376,39 @@ export function App({ initial }) {
273
376
  }
274
377
  }
275
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
+ }
276
398
  const selected = board.tickets.find((ticket) => ticket.id === selectedRef.current);
277
399
  if (browsing && selected) {
278
- setTicketKey(selected.key);
279
- setView("ticket");
400
+ await openTicket(selected.key);
280
401
  }
281
402
  return;
282
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
+ }
283
412
  history.current.push(text);
284
413
  historyAt.current = -1;
285
414
  setDraft("");
@@ -304,8 +433,11 @@ export function App({ initial }) {
304
433
  return;
305
434
  case "view":
306
435
  setView(action.view);
307
- if (action.view === "inbox")
308
- 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
+ }
309
441
  if (action.view === "board" && order.length) {
310
442
  const next = selectedRef.current && order.includes(selectedRef.current) ? selectedRef.current : order[0];
311
443
  selectedRef.current = next;
@@ -391,22 +523,7 @@ export function App({ initial }) {
391
523
  }
392
524
  return;
393
525
  case "ticket":
394
- setTicketKey(action.key);
395
- setView("ticket");
396
- setBusy(true);
397
- try {
398
- const { ticket: detail } = await loadTicketDetail(config, action.key);
399
- setBoard((prior) => ({
400
- ...prior,
401
- tickets: prior.tickets.map((ticket) => ticket.id === detail.id ? { ...ticket, ...detail } : ticket),
402
- }));
403
- }
404
- catch (error) {
405
- setNotice(error instanceof Error ? error.message : String(error));
406
- }
407
- finally {
408
- setBusy(false);
409
- }
526
+ await openTicket(action.key);
410
527
  return;
411
528
  case "epic-new":
412
529
  setBusy(true);
@@ -502,8 +619,13 @@ export function App({ initial }) {
502
619
  case "agent-add":
503
620
  setBusy(true);
504
621
  try {
505
- const { agent } = await createAgent(config, { role: action.role, provider: action.provider,
506
- model: action.model, effort: action.effort, display_name: action.name ?? action.provider });
622
+ let created;
623
+ await suspendTerminal(async () => {
624
+ created = await agentAdd(action.args, { config, isTTY: true, prompt: tuiPrompt });
625
+ });
626
+ if (!created)
627
+ throw new Error("Agent wizard did not finish.");
628
+ const { agent } = created;
507
629
  say("system", `Added ${agent.display_name} (${agent.id}).`);
508
630
  await refresh();
509
631
  }
@@ -562,10 +684,11 @@ export function App({ initial }) {
562
684
  return;
563
685
  case "logs":
564
686
  setLogsFilter(action.key);
565
- 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"}.`);
566
689
  return;
567
690
  case "decide": {
568
- const focused = inbox[inboxFocus]?.key.split(":")[0] ?? null;
691
+ const focused = selectedDecisionId ?? decisionIdAt(inbox, inboxFocus);
569
692
  const decision = selectDecision(board.decisions, action.target, focused);
570
693
  if (!decision) {
571
694
  setNotice(action.target ? `No decision matches ${action.target}.` : "Nothing is waiting on a decision.");
@@ -575,30 +698,32 @@ export function App({ initial }) {
575
698
  setNotice("Skipping decisions is not available through the HDX API. Answer it instead.");
576
699
  return;
577
700
  }
578
- const options = decisionOptions(decision);
579
- const option = /^\d+$/.test(action.answer) ? options[Number(action.answer) - 1] : undefined;
580
- const answer = option ?? action.answer;
581
- if (!answer) {
582
- 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);
583
708
  return;
584
709
  }
585
- setBusy(true);
586
- try {
587
- await resolveDecision(config, decision.id, answer);
588
- say("system", `Answered: ${answer}`);
589
- await refresh();
590
- }
591
- catch (error) {
592
- setNotice(error instanceof Error ? error.message : String(error));
593
- }
594
- finally {
595
- setBusy(false);
596
- }
710
+ await submitDecision(decision.id, action.answer);
597
711
  return;
598
712
  }
599
713
  case "help":
600
714
  setMessages((prior) => [...prior, { id: nextId(), speaker: "system", body: "", panel: "help", done: true }]);
601
715
  return;
716
+ case "inbox-more":
717
+ if (view !== "inbox")
718
+ setView("inbox");
719
+ if (!board.earlierHasMore) {
720
+ say("system", "No more earlier messages.");
721
+ return;
722
+ }
723
+ setEarlierLimit((current) => current + EARLIER_PAGE);
724
+ earlierLimitRef.current += EARLIER_PAGE;
725
+ await refresh();
726
+ return;
602
727
  case "refresh":
603
728
  await refresh();
604
729
  return;
@@ -612,12 +737,16 @@ export function App({ initial }) {
612
737
  return;
613
738
  }
614
739
  }, [view, settings, applyEdit, board, browsing, mode, say, askAgent, order, settingsOrder, changeWorkspace,
615
- config, refresh, suspendTerminal, exit, inbox, inboxFocus]);
740
+ config, refresh, suspendTerminal, exit, inbox, inboxFocus, openTicket, answering, selectedDecisionId,
741
+ submitDecision, ticketKey, ticketCollapsed, ticketOffset, width]);
616
742
  useInput((input, key) => {
617
743
  if (key.ctrl && input === "c")
618
744
  exit();
619
745
  });
620
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;
621
750
  const waiting = decisions.length;
622
751
  const announced = useRef(0);
623
752
  useEffect(() => {
@@ -648,13 +777,21 @@ export function App({ initial }) {
648
777
  if (item.message.panel === "help")
649
778
  return _jsx(Help, { width: width }, item.key);
650
779
  return _jsx(Bubble, { message: item.message, width: width }, item.key);
651
- } }), 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
652
- ? _jsx(TicketPanel, { ticket: ticket, width: width, rows: plan.panels })
653
- : _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) => {
654
783
  setDraft(next);
655
784
  if (editingRef.current)
656
785
  setEditing({ key: editingRef.current.key, draft: next });
657
- }, 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
+ }
658
795
  if (editing) {
659
796
  setEditing(null);
660
797
  setDraft("");
@@ -669,6 +806,11 @@ export function App({ initial }) {
669
806
  setCursor(null);
670
807
  selectedRef.current = null;
671
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
+ }
672
814
  if (view === "inbox" && !draft) {
673
815
  moveInbox(-1);
674
816
  return;
@@ -682,6 +824,11 @@ export function App({ initial }) {
682
824
  historyAt.current = historyAt.current < 0 ? history.current.length - 1 : Math.max(0, historyAt.current - 1);
683
825
  setDraft(history.current[historyAt.current] ?? "");
684
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
+ }
685
832
  if (view === "inbox" && !draft) {
686
833
  moveInbox(1);
687
834
  return;
@@ -699,6 +846,16 @@ export function App({ initial }) {
699
846
  return;
700
847
  }
701
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
+ }
702
859
  } }) })] })] }));
703
860
  }
704
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);
@@ -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
  }