@higherdev/cli 0.16.0 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, }) {
@@ -117,19 +117,21 @@ export function AgentsColumn({ board, width, rows, }) {
117
117
  ]
118
118
  : []),
119
119
  ...shown.map((row) => {
120
- const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" ? "warning" : "muted";
121
- return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsxs(Text, { color: UI.text, wrap: "truncate", children: [row.name, _jsx(Text, { color: UI.dim, children: row.run
120
+ const tone = row.run ? "blue" : row.state === "offline" || row.state === "limited" || row.state === "draining"
121
+ ? "warning" : "muted";
122
+ return (_jsxs(Box, { flexWrap: "nowrap", children: [_jsxs(Text, { color: inkColor(tone), children: [DOT, " "] }), _jsxs(Text, { color: UI.text, wrap: "truncate", children: [row.name, row.state === "draining" ? null : (_jsx(Text, { color: UI.dim, children: row.run
122
123
  ? ` · ${row.ticket?.key ?? row.run.kind} ${elapsed(row.run.started_at ?? row.run.created_at)}`
123
- : ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state}` })] })] }, row.key));
124
+ : ` · ${row.limitedUntil ? `limited until ${row.limitedUntil}` : row.state}` }))] })] }, row.key));
124
125
  }),
125
126
  _jsx(More, { count: displayRows.length - shown.length }, "more"),
126
127
  ] }));
127
128
  }
128
129
  const KIND_COLOR = {
129
130
  error: UI.danger,
131
+ agent: UI.accent,
130
132
  tool: UI.dim,
131
133
  status: UI.dim,
132
- text: UI.text,
134
+ raw: UI.dim,
133
135
  };
134
136
  /**
135
137
  * What the agents are doing, as it arrives. The panel is a fixed height on
@@ -146,7 +148,7 @@ export function StreamPanel({ lines, width, rows, live, }) {
146
148
  _jsx(Text, { color: UI.dim, children: live ? "Waiting for the first step." : "Nothing running." }, "empty"),
147
149
  ]
148
150
  : []),
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))),
151
+ ...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
152
  ] }));
151
153
  }
152
154
  /** How many rows the epics strip wants, heading included, or none. */
@@ -154,6 +156,21 @@ export function epicsRows(board, cap = 3) {
154
156
  const open = epicProgress(board).filter((row) => row.epic.status !== "done");
155
157
  return open.length === 0 ? 0 : Math.min(open.length, cap) + 1;
156
158
  }
159
+ export function nowEntries(board) {
160
+ return board.tickets.filter((ticket) => !["backlog", "merged", "cancelled"].includes(ticket.status))
161
+ .map((ticket) => ({ key: ticket.key, headline: ticket.latest_headline ?? `${ticket.key}: ${ticket.title}` }));
162
+ }
163
+ function NowStrip({ board, width, rows }) {
164
+ const entries = nowEntries(board);
165
+ if (!entries.length || rows < 2)
166
+ return null;
167
+ const shown = entries.slice(0, contentRows(rows, entries.length));
168
+ return _jsx(Panel, { width: width, rows: rows, children: [
169
+ _jsx(Heading, { text: "Now", note: `${entries.length}` }, "h"),
170
+ ...shown.map((entry) => _jsx(Text, { color: UI.text, wrap: "truncate", children: entry.headline }, entry.key)),
171
+ _jsx(More, { count: entries.length - shown.length }, "more"),
172
+ ] });
173
+ }
157
174
  /**
158
175
  * Epic progress, above the board it explains. Capped hard: this is the summary
159
176
  * line, and the tickets underneath are what you are here to read.
@@ -174,12 +191,13 @@ export function EpicsStrip({ board, width, rows }) {
174
191
  export function Cockpit({ board, width, rows, cursor, }) {
175
192
  // The epics strip is spent out of the same budget, so adding it shortens the
176
193
  // 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);
194
+ const now = Math.min(nowEntries(board).length + 1, 4, Math.max(0, rows - 4));
195
+ const epics = Math.min(epicsRows(board), Math.max(0, rows - now - (now > 0 ? 1 : 0) - 4));
196
+ const rest = rows - (now > 0 ? now + 1 : 0) - (epics > 0 ? epics + 1 : 0);
179
197
  const columns = renderColumns(board, width, rest, cursor);
180
- if (epics === 0)
198
+ if (epics === 0 && now === 0)
181
199
  return columns;
182
- return (_jsxs(Box, { flexDirection: "column", children: [_jsx(EpicsStrip, { board: board, width: width, rows: epics }), _jsx(Box, { height: 1 }), columns] }));
200
+ 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
201
  }
184
202
  function renderColumns(board, width, rows, cursor) {
185
203
  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
  }
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" },